Coffee journaling on ATProto (alpha) alpha.arabica.social
coffee
at main 653 lines 18 kB view raw
1package components 2 3import "arabica/internal/models" 4 5// DialogModalProps defines properties for a native HTML5 dialog modal 6type DialogModalProps struct { 7 ID string // Dialog ID (e.g., "bean-modal") 8 Title string 9} 10 11// BeanDialogModal renders the bean creation/edit modal using native <dialog> 12templ BeanDialogModal(bean *models.Bean, roasters []models.Roaster) { 13 <dialog id="entity-modal" class="modal-dialog"> 14 <div class="modal-content"> 15 <h3 class="modal-title"> 16 if bean != nil { 17 Edit Bean 18 } else { 19 Add Bean 20 } 21 </h3> 22 <form 23 if bean != nil { 24 hx-put={ "/api/beans/" + bean.RKey } 25 } else { 26 hx-post="/api/beans" 27 } 28 hx-trigger="submit" 29 hx-swap="none" 30 hx-on::after-request="if(event.detail.successful) { this.closest('dialog').close(); htmx.trigger('body', 'refreshManage'); } else if(event.detail.xhr && event.detail.xhr.status === 401) { this.closest('dialog').close(); window.__showSessionExpiredModal(); }" 31 class="space-y-4" 32 > 33 if bean == nil { 34 <div x-data="entitySuggest('/api/suggestions/beans')" class="relative"> 35 <input 36 type="text" 37 name="name" 38 placeholder="Name *" 39 required 40 class="w-full form-input" 41 x-model="query" 42 @input.debounce.300ms="search()" 43 @blur.debounce.200ms="showSuggestions = false" 44 @focus="if (suggestions.length > 0) showSuggestions = true" 45 autocomplete="off" 46 /> 47 <input type="hidden" name="source_ref" x-model="sourceRef"/> 48 <template x-if="showSuggestions && suggestions.length > 0"> 49 <div class="suggestions-dropdown"> 50 <template x-for="s in suggestions" :key="s.source_uri"> 51 <button 52 type="button" 53 class="suggestions-item" 54 @mousedown.prevent="selectBeanSuggestion(s)" 55 > 56 <span class="font-medium" x-text="s.name"></span> 57 <template x-if="s.fields.origin"> 58 <span class="text-xs text-brown-500" x-text="s.fields.origin"></span> 59 </template> 60 <template x-if="s.count > 1"> 61 <span class="text-xs text-brown-400" x-text="s.count + ' users'"></span> 62 </template> 63 </button> 64 </template> 65 </div> 66 </template> 67 </div> 68 } else { 69 <input 70 type="text" 71 name="name" 72 value={ getStringValue(bean, "name") } 73 placeholder="Name *" 74 required 75 class="w-full form-input" 76 /> 77 } 78 <input 79 type="text" 80 name="origin" 81 value={ getStringValue(bean, "origin") } 82 placeholder="Origin *" 83 required 84 class="w-full form-input" 85 /> 86 <input 87 type="text" 88 name="variety" 89 value={ getStringValue(bean, "variety") } 90 placeholder="Variety (e.g. SL28, Typica, Gesha)" 91 class="w-full form-input" 92 /> 93 <select 94 name="roaster_rkey" 95 class="w-full form-input" 96 > 97 <option value="">Select Roaster (Optional)</option> 98 for _, roaster := range roasters { 99 <option 100 value={ roaster.RKey } 101 if bean != nil && bean.RoasterRKey == roaster.RKey { 102 selected 103 } 104 > 105 { roaster.Name } 106 </option> 107 } 108 </select> 109 <select 110 name="roast_level" 111 class="w-full form-input" 112 > 113 <option value="">Select Roast Level (Optional)</option> 114 for _, level := range models.RoastLevels { 115 <option 116 value={ level } 117 if bean != nil && bean.RoastLevel == level { 118 selected 119 } 120 > 121 { level } 122 </option> 123 } 124 </select> 125 <input 126 type="text" 127 name="process" 128 value={ getStringValue(bean, "process") } 129 placeholder="Process (e.g. Washed, Natural, Honey)" 130 class="w-full form-input" 131 /> 132 <textarea 133 name="description" 134 placeholder="Description" 135 rows="3" 136 class="w-full form-textarea" 137 >{ getStringValue(bean, "description") }</textarea> 138 // Only show "close bag check" when editing 139 if bean != nil { 140 <div class="flex items-center gap-2"> 141 <input 142 type="checkbox" 143 name="closed" 144 value="true" 145 id="bean-closed-checkbox-modal" 146 if bean != nil && bean.Closed { 147 checked 148 } 149 class="rounded border-brown-300 text-brown-700 focus:ring-brown-500" 150 /> 151 <label for="bean-closed-checkbox-modal" class="text-sm text-brown-900"> 152 Bag is closed/finished 153 </label> 154 </div> 155 } 156 <div class="flex gap-2"> 157 <button type="submit" class="flex-1 btn-primary"> 158 Save 159 </button> 160 <button 161 type="button" 162 @click="$el.closest('dialog').close()" 163 class="flex-1 btn-secondary" 164 > 165 Cancel 166 </button> 167 </div> 168 </form> 169 </div> 170 </dialog> 171} 172 173// GrinderDialogModal renders the grinder creation/edit modal using native <dialog> 174templ GrinderDialogModal(grinder *models.Grinder) { 175 <dialog id="entity-modal" class="modal-dialog"> 176 <div class="modal-content"> 177 <h3 class="modal-title"> 178 if grinder != nil { 179 Edit Grinder 180 } else { 181 Add Grinder 182 } 183 </h3> 184 <form 185 if grinder != nil { 186 hx-put={ "/api/grinders/" + grinder.RKey } 187 } else { 188 hx-post="/api/grinders" 189 } 190 hx-trigger="submit" 191 hx-swap="none" 192 hx-on::after-request="if(event.detail.successful) { this.closest('dialog').close(); htmx.trigger('body', 'refreshManage'); } else if(event.detail.xhr && event.detail.xhr.status === 401) { this.closest('dialog').close(); window.__showSessionExpiredModal(); }" 193 class="space-y-4" 194 > 195 if grinder == nil { 196 <div x-data="entitySuggest('/api/suggestions/grinders')" class="relative"> 197 <input 198 type="text" 199 name="name" 200 placeholder="Name *" 201 required 202 class="w-full form-input" 203 x-model="query" 204 @input.debounce.300ms="search()" 205 @blur.debounce.200ms="showSuggestions = false" 206 @focus="if (suggestions.length > 0) showSuggestions = true" 207 autocomplete="off" 208 /> 209 <input type="hidden" name="source_ref" x-model="sourceRef"/> 210 <template x-if="showSuggestions && suggestions.length > 0"> 211 <div class="suggestions-dropdown"> 212 <template x-for="s in suggestions" :key="s.source_uri"> 213 <button 214 type="button" 215 class="suggestions-item" 216 @mousedown.prevent="selectGrinderSuggestion(s)" 217 > 218 <span class="font-medium" x-text="s.name"></span> 219 <template x-if="s.fields.grinderType"> 220 <span class="text-xs text-brown-500" x-text="s.fields.grinderType"></span> 221 </template> 222 <template x-if="s.count > 1"> 223 <span class="text-xs text-brown-400" x-text="s.count + ' users'"></span> 224 </template> 225 </button> 226 </template> 227 </div> 228 </template> 229 </div> 230 } else { 231 <input 232 type="text" 233 name="name" 234 value={ getStringValue(grinder, "name") } 235 placeholder="Name *" 236 required 237 class="w-full form-input" 238 /> 239 } 240 <select 241 name="grinder_type" 242 class="w-full form-input" 243 required 244 > 245 <option value="">Select Grinder Type *</option> 246 for _, gType := range models.GrinderTypes { 247 <option 248 value={ gType } 249 if grinder != nil && grinder.GrinderType == gType { 250 selected 251 } 252 > 253 { gType } 254 </option> 255 } 256 </select> 257 <select 258 name="burr_type" 259 class="w-full form-input" 260 > 261 <option value="">Select Burr Type (Optional)</option> 262 for _, bType := range models.BurrTypes { 263 <option 264 value={ bType } 265 if grinder != nil && grinder.BurrType == bType { 266 selected 267 } 268 > 269 { bType } 270 </option> 271 } 272 </select> 273 <textarea 274 name="notes" 275 placeholder="Notes" 276 rows="3" 277 class="w-full form-textarea" 278 >{ getStringValue(grinder, "notes") }</textarea> 279 <div class="flex gap-2"> 280 <button type="submit" class="flex-1 btn-primary"> 281 Save 282 </button> 283 <button 284 type="button" 285 @click="$el.closest('dialog').close()" 286 class="flex-1 btn-secondary" 287 > 288 Cancel 289 </button> 290 </div> 291 </form> 292 </div> 293 </dialog> 294} 295 296// BrewerDialogModal renders the brewer creation/edit modal using native <dialog> 297templ BrewerDialogModal(brewer *models.Brewer) { 298 <dialog id="entity-modal" class="modal-dialog"> 299 <div class="modal-content"> 300 <h3 class="modal-title"> 301 if brewer != nil { 302 Edit Brewer 303 } else { 304 Add Brewer 305 } 306 </h3> 307 <form 308 if brewer != nil { 309 hx-put={ "/api/brewers/" + brewer.RKey } 310 } else { 311 hx-post="/api/brewers" 312 } 313 hx-trigger="submit" 314 hx-swap="none" 315 hx-on::after-request="if(event.detail.successful) { this.closest('dialog').close(); htmx.trigger('body', 'refreshManage'); } else if(event.detail.xhr && event.detail.xhr.status === 401) { this.closest('dialog').close(); window.__showSessionExpiredModal(); }" 316 class="space-y-4" 317 > 318 if brewer == nil { 319 <div x-data="entitySuggest('/api/suggestions/brewers')" class="relative"> 320 <input 321 type="text" 322 name="name" 323 placeholder="Name *" 324 required 325 class="w-full form-input" 326 x-model="query" 327 @input.debounce.300ms="search()" 328 @blur.debounce.200ms="showSuggestions = false" 329 @focus="if (suggestions.length > 0) showSuggestions = true" 330 autocomplete="off" 331 /> 332 <input type="hidden" name="source_ref" x-model="sourceRef"/> 333 <template x-if="showSuggestions && suggestions.length > 0"> 334 <div class="suggestions-dropdown"> 335 <template x-for="s in suggestions" :key="s.source_uri"> 336 <button 337 type="button" 338 class="suggestions-item" 339 @mousedown.prevent="selectBrewerSuggestion(s)" 340 > 341 <span class="font-medium" x-text="s.name"></span> 342 <template x-if="s.fields.brewerType"> 343 <span class="text-xs text-brown-500" x-text="s.fields.brewerType"></span> 344 </template> 345 <template x-if="s.count > 1"> 346 <span class="text-xs text-brown-400" x-text="s.count + ' users'"></span> 347 </template> 348 </button> 349 </template> 350 </div> 351 </template> 352 </div> 353 } else { 354 <input 355 type="text" 356 name="name" 357 value={ getStringValue(brewer, "name") } 358 placeholder="Name *" 359 required 360 class="w-full form-input" 361 /> 362 } 363 <input 364 type="text" 365 name="brewer_type" 366 value={ getStringValue(brewer, "brewer_type") } 367 placeholder="Type (e.g., Pour-Over, Immersion, Espresso)" 368 class="w-full form-input" 369 /> 370 <textarea 371 name="description" 372 placeholder="Description" 373 rows="3" 374 class="w-full form-textarea" 375 >{ getStringValue(brewer, "description") }</textarea> 376 <div class="flex gap-2"> 377 <button type="submit" class="flex-1 btn-primary"> 378 Save 379 </button> 380 <button 381 type="button" 382 @click="$el.closest('dialog').close()" 383 class="flex-1 btn-secondary" 384 > 385 Cancel 386 </button> 387 </div> 388 </form> 389 </div> 390 </dialog> 391} 392 393// RoasterDialogModal renders the roaster creation/edit modal using native <dialog> 394templ RoasterDialogModal(roaster *models.Roaster) { 395 <dialog id="entity-modal" class="modal-dialog"> 396 <div class="modal-content"> 397 <h3 class="modal-title"> 398 if roaster != nil { 399 Edit Roaster 400 } else { 401 Add Roaster 402 } 403 </h3> 404 <form 405 if roaster != nil { 406 hx-put={ "/api/roasters/" + roaster.RKey } 407 } else { 408 hx-post="/api/roasters" 409 } 410 hx-trigger="submit" 411 hx-swap="none" 412 hx-on::after-request="if(event.detail.successful) { this.closest('dialog').close(); htmx.trigger('body', 'refreshManage'); } else if(event.detail.xhr && event.detail.xhr.status === 401) { this.closest('dialog').close(); window.__showSessionExpiredModal(); }" 413 class="space-y-4" 414 > 415 if roaster == nil { 416 <div x-data="entitySuggest('/api/suggestions/roasters')" class="relative"> 417 <input 418 type="text" 419 name="name" 420 placeholder="Name *" 421 required 422 class="w-full form-input" 423 x-model="query" 424 @input.debounce.300ms="search()" 425 @blur.debounce.200ms="showSuggestions = false" 426 @focus="if (suggestions.length > 0) showSuggestions = true" 427 autocomplete="off" 428 /> 429 <input type="hidden" name="source_ref" x-model="sourceRef"/> 430 <template x-if="showSuggestions && suggestions.length > 0"> 431 <div class="suggestions-dropdown"> 432 <template x-for="s in suggestions" :key="s.source_uri"> 433 <button 434 type="button" 435 class="suggestions-item" 436 @mousedown.prevent="selectRoasterSuggestion(s)" 437 > 438 <span class="font-medium" x-text="s.name"></span> 439 <template x-if="s.fields.location"> 440 <span class="text-xs text-brown-500" x-text="s.fields.location"></span> 441 </template> 442 <template x-if="s.count > 1"> 443 <span class="text-xs text-brown-400" x-text="s.count + ' users'"></span> 444 </template> 445 </button> 446 </template> 447 </div> 448 </template> 449 </div> 450 } else { 451 <input 452 type="text" 453 name="name" 454 value={ getStringValue(roaster, "name") } 455 placeholder="Name *" 456 required 457 class="w-full form-input" 458 /> 459 } 460 <input 461 type="text" 462 name="location" 463 value={ getStringValue(roaster, "location") } 464 placeholder="Location" 465 class="w-full form-input" 466 /> 467 <input 468 type="url" 469 name="website" 470 value={ getStringValue(roaster, "website") } 471 placeholder="Website" 472 class="w-full form-input" 473 /> 474 <div class="flex gap-2"> 475 <button type="submit" class="flex-1 btn-primary"> 476 Save 477 </button> 478 <button 479 type="button" 480 @click="$el.closest('dialog').close()" 481 class="flex-1 btn-secondary" 482 > 483 Cancel 484 </button> 485 </div> 486 </form> 487 </div> 488 </dialog> 489} 490 491// ReportDialogModalProps defines properties for the report dialog 492type ReportDialogModalProps struct { 493 SubjectURI string 494 SubjectCID string 495} 496 497// ReportDialogModal renders the report modal for submitting content reports 498templ ReportDialogModal(props ReportDialogModalProps) { 499 <dialog id="report-modal" class="modal-dialog" x-data="{ reason: '', charCount: 0, submitting: false, error: '', success: false }"> 500 <div class="modal-content"> 501 <h3 class="modal-title">Report Content</h3> 502 <template x-if="!success"> 503 <form 504 @submit.prevent=" 505 submitting = true; 506 error = ''; 507 const dialog = document.getElementById('report-modal'); 508 const body = new URLSearchParams({ 509 subject_uri: $el.querySelector('[name=subject_uri]').value, 510 subject_cid: $el.querySelector('[name=subject_cid]').value, 511 reason: reason 512 }); 513 fetch('/api/report', { 514 method: 'POST', 515 headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 516 body: body 517 }) 518 .then(r => r.json().then(data => ({ok: r.ok, data}))) 519 .then(({ok, data}) => { 520 submitting = false; 521 if (ok) { 522 success = true; 523 setTimeout(() => dialog.close(), 2000); 524 } else { 525 error = data.message || 'Failed to submit report'; 526 } 527 }) 528 .catch(() => { 529 submitting = false; 530 error = 'Network error. Please try again.'; 531 }); 532 " 533 class="space-y-4" 534 > 535 <input type="hidden" name="subject_uri" value={ props.SubjectURI }/> 536 <input type="hidden" name="subject_cid" value={ props.SubjectCID }/> 537 <p class="text-sm text-brown-700"> 538 Please describe why you're reporting this content. Reports are reviewed by moderators. 539 </p> 540 <div> 541 <textarea 542 x-model="reason" 543 @input="charCount = reason.length" 544 name="reason" 545 placeholder="Describe the issue (optional)" 546 rows="4" 547 maxlength="500" 548 class="w-full form-textarea" 549 ></textarea> 550 <div class="flex justify-between text-xs text-brown-500 mt-1"> 551 <span>Optional, but helpful for moderators</span> 552 <span x-text="charCount + '/500'"></span> 553 </div> 554 </div> 555 <template x-if="error"> 556 <div class="bg-red-100 border border-red-300 text-red-800 px-3 py-2 rounded-lg text-sm" x-text="error"></div> 557 </template> 558 <div class="flex gap-2"> 559 <button 560 type="submit" 561 class="flex-1 btn-primary" 562 :disabled="submitting" 563 > 564 <span x-show="!submitting">Submit Report</span> 565 <span x-show="submitting">Submitting...</span> 566 </button> 567 <button 568 type="button" 569 @click="$el.closest('dialog').close()" 570 class="flex-1 btn-secondary" 571 :disabled="submitting" 572 > 573 Cancel 574 </button> 575 </div> 576 </form> 577 </template> 578 <template x-if="success"> 579 <div class="text-center py-4"> 580 <div class="text-green-600 mb-2"> 581 <svg class="w-12 h-12 mx-auto" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 582 <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"></path> 583 </svg> 584 </div> 585 <p class="font-medium text-brown-900">Report Submitted</p> 586 <p class="text-sm text-brown-600 mt-1">Thank you for helping keep our community safe.</p> 587 </div> 588 </template> 589 </div> 590 </dialog> 591} 592 593// Helper function to get string value from bean (handles nil case) 594func getStringValue(entity interface{}, field string) string { 595 if entity == nil { 596 return "" 597 } 598 599 switch e := entity.(type) { 600 case *models.Bean: 601 if e == nil { 602 return "" 603 } 604 switch field { 605 case "name": 606 return e.Name 607 case "origin": 608 return e.Origin 609 case "variety": 610 return e.Variety 611 case "process": 612 return e.Process 613 case "description": 614 return e.Description 615 } 616 case *models.Grinder: 617 if e == nil { 618 return "" 619 } 620 switch field { 621 case "name": 622 return e.Name 623 case "notes": 624 return e.Notes 625 } 626 case *models.Brewer: 627 if e == nil { 628 return "" 629 } 630 switch field { 631 case "name": 632 return e.Name 633 case "brewer_type": 634 return e.BrewerType 635 case "description": 636 return e.Description 637 } 638 case *models.Roaster: 639 if e == nil { 640 return "" 641 } 642 switch field { 643 case "name": 644 return e.Name 645 case "location": 646 return e.Location 647 case "website": 648 return e.Website 649 } 650 } 651 652 return "" 653}