Coffee journaling on ATProto (alpha)
alpha.arabica.social
coffee
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}