Coffee journaling on ATProto (alpha) alpha.arabica.social
coffee
17
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: semantic groupings in create modals

authored by

Patrick Dewey and committed by tangled.org 58a4b58d 8809bb60

+500 -415
+466 -407
internal/web/components/dialog_modals.templ
··· 32 32 hx-trigger="submit" 33 33 hx-swap="none" 34 34 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(); }" 35 - class="space-y-4" 35 + class="space-y-5" 36 36 > 37 - if bean == nil { 38 - <div x-data="entitySuggest('/api/suggestions/beans')" class="relative"> 37 + <!-- Essentials --> 38 + <div class="form-fieldset"> 39 + <div class="form-fieldset-label">Essentials</div> 40 + if bean == nil { 41 + <div x-data="entitySuggest('/api/suggestions/beans')" class="relative"> 42 + <input 43 + type="text" 44 + name="name" 45 + placeholder="Name *" 46 + required 47 + class="w-full form-input" 48 + x-model="query" 49 + @input.debounce.300ms="search()" 50 + @blur.debounce.200ms="showSuggestions = false" 51 + @focus="if (suggestions.length > 0) showSuggestions = true" 52 + autocomplete="off" 53 + /> 54 + <input type="hidden" name="source_ref" x-model="sourceRef"/> 55 + <template x-if="showSuggestions && suggestions.length > 0"> 56 + <div class="suggestions-dropdown"> 57 + <template x-for="s in suggestions" :key="s.source_uri"> 58 + <button 59 + type="button" 60 + class="suggestions-item" 61 + @mousedown.prevent="selectBeanSuggestion(s)" 62 + > 63 + <span class="font-medium" x-text="s.name"></span> 64 + <template x-if="s.fields.origin"> 65 + <span class="text-xs text-brown-500" x-text="s.fields.origin"></span> 66 + </template> 67 + <template x-if="s.count > 1"> 68 + <span class="text-xs text-brown-400" x-text="s.count + ' users'"></span> 69 + </template> 70 + </button> 71 + </template> 72 + </div> 73 + </template> 74 + </div> 75 + } else { 39 76 <input 40 77 type="text" 41 78 name="name" 79 + value={ getStringValue(bean, "name") } 42 80 placeholder="Name *" 43 81 required 44 82 class="w-full form-input" 45 - x-model="query" 46 - @input.debounce.300ms="search()" 47 - @blur.debounce.200ms="showSuggestions = false" 48 - @focus="if (suggestions.length > 0) showSuggestions = true" 49 - autocomplete="off" 50 83 /> 51 - <input type="hidden" name="source_ref" x-model="sourceRef"/> 52 - <template x-if="showSuggestions && suggestions.length > 0"> 53 - <div class="suggestions-dropdown"> 54 - <template x-for="s in suggestions" :key="s.source_uri"> 55 - <button 56 - type="button" 57 - class="suggestions-item" 58 - @mousedown.prevent="selectBeanSuggestion(s)" 59 - > 60 - <span class="font-medium" x-text="s.name"></span> 61 - <template x-if="s.fields.origin"> 62 - <span class="text-xs text-brown-500" x-text="s.fields.origin"></span> 63 - </template> 64 - <template x-if="s.count > 1"> 65 - <span class="text-xs text-brown-400" x-text="s.count + ' users'"></span> 66 - </template> 67 - </button> 68 - </template> 69 - </div> 70 - </template> 71 - </div> 72 - } else { 84 + } 73 85 <input 74 86 type="text" 75 - name="name" 76 - value={ getStringValue(bean, "name") } 77 - placeholder="Name *" 87 + name="origin" 88 + value={ getStringValue(bean, "origin") } 89 + placeholder="Origin *" 78 90 required 79 91 class="w-full form-input" 80 92 /> 81 - } 82 - <input 83 - type="text" 84 - name="origin" 85 - value={ getStringValue(bean, "origin") } 86 - placeholder="Origin *" 87 - required 88 - class="w-full form-input" 89 - /> 90 - <input 91 - type="text" 92 - name="variety" 93 - value={ getStringValue(bean, "variety") } 94 - placeholder="Variety (e.g. SL28, Typica, Gesha)" 95 - class="w-full form-input" 96 - /> 97 - <div x-data={ roasterPickerInit(bean, roasters) } class="relative"> 93 + </div> 94 + <div class="form-divider"></div> 95 + <!-- Origin details --> 96 + <div class="form-fieldset"> 97 + <div class="form-fieldset-label">Origin Details <span class="form-optional-hint">(optional)</span></div> 98 98 <input 99 99 type="text" 100 - x-model="query" 101 - x-show="!showDetails" 102 - @input="filter()" 103 - @focus="showDropdown = true" 104 - @blur="setTimeout(() => showDropdown = false, 150)" 105 - @keydown.escape.prevent="showDropdown = false" 106 - placeholder="Search or create roaster (optional)" 100 + name="variety" 101 + value={ getStringValue(bean, "variety") } 102 + placeholder="Variety (e.g. SL28, Typica, Gesha)" 107 103 class="w-full form-input" 108 - autocomplete="off" 109 104 /> 110 - <input type="hidden" name="roaster_rkey" :value="selectedRKey"/> 111 - <input type="hidden" name="new_roaster_name" :value="newRoasterName"/> 112 - <input type="hidden" name="new_roaster_location" :value="newRoasterLocation"/> 113 - <input type="hidden" name="new_roaster_website" :value="newRoasterWebsite"/> 114 - <button 115 - type="button" 116 - x-show="(selectedRKey || newRoasterName) && !showDetails" 117 - @click="clear()" 118 - class="absolute right-2 top-1/2 -translate-y-1/2 text-brown-400 hover:text-brown-600 text-sm" 119 - x-cloak 120 - > 121 - &times; 122 - </button> 123 - <div x-show="showDropdown && !showDetails" x-cloak class="absolute z-10 w-full mt-1 max-h-48 overflow-y-auto rounded-lg shadow-lg" style="background: var(--card-bg, #fff); border: 1px solid var(--surface-border, #d4c4a8);"> 124 - <template x-for="r in filtered" :key="r.rkey"> 125 - <div 126 - class="px-3 py-2 cursor-pointer text-sm hover:bg-brown-100" 127 - @mousedown.prevent="selectRoaster(r)" 128 - x-text="r.name" 129 - ></div> 130 - </template> 131 - <template x-if="query.trim() && !exactMatch"> 132 - <div 133 - class="px-3 py-2 cursor-pointer text-sm font-medium border-t hover:bg-brown-100" 134 - style="border-color: var(--surface-border, #d4c4a8);" 135 - @mousedown.prevent="startCreate()" 136 - > 137 - Create "<span x-text="query.trim()"></span>" 138 - </div> 139 - </template> 140 - </div> 141 - <!-- Inline details for new roaster --> 142 - <div x-show="showDetails" x-transition x-cloak class="p-3 rounded-lg space-y-2" style="background: var(--surface-bg); border: 1px solid var(--surface-border);"> 143 - <p class="text-sm font-medium text-brown-900"> 144 - New roaster: <span x-text="newRoasterName" class="font-semibold"></span> 145 - </p> 105 + <div x-data={ roasterPickerInit(bean, roasters) } class="relative"> 146 106 <input 147 107 type="text" 148 - x-model="newRoasterLocation" 149 - placeholder="Location (optional)" 150 - class="w-full form-input text-sm" 108 + x-model="query" 109 + x-show="!showDetails" 110 + @input="filter()" 111 + @focus="showDropdown = true" 112 + @blur="setTimeout(() => showDropdown = false, 150)" 113 + @keydown.escape.prevent="showDropdown = false" 114 + placeholder="Search or create roaster" 115 + class="w-full form-input" 116 + autocomplete="off" 151 117 /> 152 - <input 153 - type="url" 154 - x-model="newRoasterWebsite" 155 - placeholder="Website (optional)" 156 - class="w-full form-input text-sm" 157 - /> 118 + <input type="hidden" name="roaster_rkey" :value="selectedRKey"/> 119 + <input type="hidden" name="new_roaster_name" :value="newRoasterName"/> 120 + <input type="hidden" name="new_roaster_location" :value="newRoasterLocation"/> 121 + <input type="hidden" name="new_roaster_website" :value="newRoasterWebsite"/> 158 122 <button 159 123 type="button" 160 - @click="cancelCreate()" 161 - class="text-xs text-brown-500 hover:text-brown-700" 124 + x-show="(selectedRKey || newRoasterName) && !showDetails" 125 + @click="clear()" 126 + class="absolute right-2 top-1/2 -translate-y-1/2 text-brown-400 hover:text-brown-600 text-sm" 127 + x-cloak 162 128 > 163 - Cancel 129 + &times; 164 130 </button> 131 + <div x-show="showDropdown && !showDetails" x-cloak class="absolute z-10 w-full mt-1 max-h-48 overflow-y-auto rounded-lg shadow-lg" style="background: var(--card-bg, #fff); border: 1px solid var(--surface-border, #d4c4a8);"> 132 + <template x-for="r in filtered" :key="r.rkey"> 133 + <div 134 + class="px-3 py-2 cursor-pointer text-sm hover:bg-brown-100" 135 + @mousedown.prevent="selectRoaster(r)" 136 + x-text="r.name" 137 + ></div> 138 + </template> 139 + <template x-if="query.trim() && !exactMatch"> 140 + <div 141 + class="px-3 py-2 cursor-pointer text-sm font-medium border-t hover:bg-brown-100" 142 + style="border-color: var(--surface-border, #d4c4a8);" 143 + @mousedown.prevent="startCreate()" 144 + > 145 + Create "<span x-text="query.trim()"></span>" 146 + </div> 147 + </template> 148 + </div> 149 + <!-- Inline details for new roaster --> 150 + <div x-show="showDetails" x-transition x-cloak class="p-3 rounded-lg space-y-2" style="background: var(--surface-bg); border: 1px solid var(--surface-border);"> 151 + <p class="text-sm font-medium text-brown-900"> 152 + New roaster: <span x-text="newRoasterName" class="font-semibold"></span> 153 + </p> 154 + <input 155 + type="text" 156 + x-model="newRoasterLocation" 157 + placeholder="Location (optional)" 158 + class="w-full form-input text-sm" 159 + /> 160 + <input 161 + type="url" 162 + x-model="newRoasterWebsite" 163 + placeholder="Website (optional)" 164 + class="w-full form-input text-sm" 165 + /> 166 + <button 167 + type="button" 168 + @click="cancelCreate()" 169 + class="text-xs text-brown-500 hover:text-brown-700" 170 + > 171 + Cancel 172 + </button> 173 + </div> 165 174 </div> 175 + <select 176 + name="roast_level" 177 + class="w-full form-input" 178 + > 179 + <option value="">Select Roast Level</option> 180 + for _, level := range models.RoastLevels { 181 + <option 182 + value={ level } 183 + if bean != nil && bean.RoastLevel == level { 184 + selected 185 + } 186 + > 187 + { level } 188 + </option> 189 + } 190 + </select> 191 + <input 192 + type="text" 193 + name="process" 194 + value={ getStringValue(bean, "process") } 195 + placeholder="Process (e.g. Washed, Natural, Honey)" 196 + class="w-full form-input" 197 + /> 166 198 </div> 167 - <select 168 - name="roast_level" 169 - class="w-full form-input" 170 - > 171 - <option value="">Select Roast Level (Optional)</option> 172 - for _, level := range models.RoastLevels { 173 - <option 174 - value={ level } 175 - if bean != nil && bean.RoastLevel == level { 176 - selected 177 - } 178 - > 179 - { level } 180 - </option> 181 - } 182 - </select> 183 - <input 184 - type="text" 185 - name="process" 186 - value={ getStringValue(bean, "process") } 187 - placeholder="Process (e.g. Washed, Natural, Honey)" 188 - class="w-full form-input" 189 - /> 190 - <textarea 191 - name="description" 192 - placeholder="Description" 193 - rows="3" 194 - class="w-full form-textarea" 195 - >{ getStringValue(bean, "description") }</textarea> 196 - <div x-data={ beanRatingInitData(bean) }> 197 - <div x-show="!showRating"> 198 - <button 199 - type="button" 200 - @click="showRating = true; if (rating === 0) rating = 5" 201 - class="text-sm font-medium text-brown-700 hover:text-brown-900 flex items-center gap-1" 202 - > 203 - @IconStar() 204 - Add Rating (Optional) 205 - </button> 206 - </div> 207 - <div x-show="showRating" x-transition class="space-y-2"> 208 - <div class="flex items-center justify-between"> 209 - <label class="form-label mb-0">Rating</label> 199 + <div class="form-divider"></div> 200 + <!-- Notes & rating --> 201 + <div class="form-fieldset"> 202 + <div class="form-fieldset-label">Notes & Rating <span class="form-optional-hint">(optional)</span></div> 203 + <textarea 204 + name="description" 205 + placeholder="Description" 206 + rows="3" 207 + class="w-full form-textarea" 208 + >{ getStringValue(bean, "description") }</textarea> 209 + <div x-data={ beanRatingInitData(bean) }> 210 + <div x-show="!showRating"> 210 211 <button 211 212 type="button" 212 - @click="showRating = false; rating = 0" 213 - class="text-xs text-brown-500 hover:text-brown-700" 213 + @click="showRating = true; if (rating === 0) rating = 5" 214 + class="text-sm font-medium text-brown-700 hover:text-brown-900 flex items-center gap-1" 214 215 > 215 - Remove rating 216 + @IconStar() 217 + Add Rating 216 218 </button> 217 219 </div> 218 - <input 219 - type="range" 220 - min="1" 221 - max="10" 222 - x-model.number="rating" 223 - class="w-full accent-brown-700" 224 - /> 225 - <div class="text-center text-2xl font-bold text-brown-800"> 226 - <span x-text="rating"></span>/10 220 + <div x-show="showRating" x-transition class="space-y-2"> 221 + <div class="flex items-center justify-between"> 222 + <label class="form-label mb-0">Rating</label> 223 + <button 224 + type="button" 225 + @click="showRating = false; rating = 0" 226 + class="text-xs text-brown-500 hover:text-brown-700" 227 + > 228 + Remove rating 229 + </button> 230 + </div> 231 + <input 232 + type="range" 233 + min="1" 234 + max="10" 235 + x-model.number="rating" 236 + class="w-full accent-brown-700" 237 + /> 238 + <div class="text-center text-2xl font-bold text-brown-800"> 239 + <span x-text="rating"></span>/10 240 + </div> 241 + <input type="hidden" name="rating" :value="showRating ? rating : ''"/> 227 242 </div> 228 - <input type="hidden" name="rating" :value="showRating ? rating : ''"/> 229 243 </div> 230 244 </div> 231 245 // Only show "close bag check" when editing 232 246 if bean != nil { 247 + <div class="form-divider"></div> 233 248 <div class="flex items-center gap-2"> 234 249 <input 235 250 type="checkbox" ··· 246 261 </label> 247 262 </div> 248 263 } 249 - <div class="flex gap-2"> 264 + <div class="flex gap-2 pt-2"> 250 265 <button type="submit" class="flex-1 btn-primary"> 251 266 Save 252 267 </button> ··· 283 298 hx-trigger="submit" 284 299 hx-swap="none" 285 300 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(); }" 286 - class="space-y-4" 301 + class="space-y-5" 287 302 > 288 - if grinder == nil { 289 - <div x-data="entitySuggest('/api/suggestions/grinders')" class="relative"> 303 + <!-- Essentials --> 304 + <div class="form-fieldset"> 305 + <div class="form-fieldset-label">Essentials</div> 306 + if grinder == nil { 307 + <div x-data="entitySuggest('/api/suggestions/grinders')" class="relative"> 308 + <input 309 + type="text" 310 + name="name" 311 + placeholder="Name *" 312 + required 313 + class="w-full form-input" 314 + x-model="query" 315 + @input.debounce.300ms="search()" 316 + @blur.debounce.200ms="showSuggestions = false" 317 + @focus="if (suggestions.length > 0) showSuggestions = true" 318 + autocomplete="off" 319 + /> 320 + <input type="hidden" name="source_ref" x-model="sourceRef"/> 321 + <template x-if="showSuggestions && suggestions.length > 0"> 322 + <div class="suggestions-dropdown"> 323 + <template x-for="s in suggestions" :key="s.source_uri"> 324 + <button 325 + type="button" 326 + class="suggestions-item" 327 + @mousedown.prevent="selectGrinderSuggestion(s)" 328 + > 329 + <span class="font-medium" x-text="s.name"></span> 330 + <template x-if="s.fields.grinderType"> 331 + <span class="text-xs text-brown-500" x-text="s.fields.grinderType"></span> 332 + </template> 333 + <template x-if="s.count > 1"> 334 + <span class="text-xs text-brown-400" x-text="s.count + ' users'"></span> 335 + </template> 336 + </button> 337 + </template> 338 + </div> 339 + </template> 340 + </div> 341 + } else { 290 342 <input 291 343 type="text" 292 344 name="name" 345 + value={ getStringValue(grinder, "name") } 293 346 placeholder="Name *" 294 347 required 295 348 class="w-full form-input" 296 - x-model="query" 297 - @input.debounce.300ms="search()" 298 - @blur.debounce.200ms="showSuggestions = false" 299 - @focus="if (suggestions.length > 0) showSuggestions = true" 300 - autocomplete="off" 301 349 /> 302 - <input type="hidden" name="source_ref" x-model="sourceRef"/> 303 - <template x-if="showSuggestions && suggestions.length > 0"> 304 - <div class="suggestions-dropdown"> 305 - <template x-for="s in suggestions" :key="s.source_uri"> 306 - <button 307 - type="button" 308 - class="suggestions-item" 309 - @mousedown.prevent="selectGrinderSuggestion(s)" 310 - > 311 - <span class="font-medium" x-text="s.name"></span> 312 - <template x-if="s.fields.grinderType"> 313 - <span class="text-xs text-brown-500" x-text="s.fields.grinderType"></span> 314 - </template> 315 - <template x-if="s.count > 1"> 316 - <span class="text-xs text-brown-400" x-text="s.count + ' users'"></span> 317 - </template> 318 - </button> 319 - </template> 320 - </div> 321 - </template> 322 - </div> 323 - } else { 324 - <input 325 - type="text" 326 - name="name" 327 - value={ getStringValue(grinder, "name") } 328 - placeholder="Name *" 350 + } 351 + <select 352 + name="grinder_type" 353 + class="w-full form-input" 329 354 required 355 + > 356 + <option value="">Select Grinder Type *</option> 357 + for _, gType := range models.GrinderTypes { 358 + <option 359 + value={ gType } 360 + if grinder != nil && grinder.GrinderType == gType { 361 + selected 362 + } 363 + > 364 + { gType } 365 + </option> 366 + } 367 + </select> 368 + </div> 369 + <div class="form-divider"></div> 370 + <!-- Details --> 371 + <div class="form-fieldset"> 372 + <div class="form-fieldset-label">Details <span class="form-optional-hint">(optional)</span></div> 373 + <select 374 + name="burr_type" 330 375 class="w-full form-input" 331 - /> 332 - } 333 - <select 334 - name="grinder_type" 335 - class="w-full form-input" 336 - required 337 - > 338 - <option value="">Select Grinder Type *</option> 339 - for _, gType := range models.GrinderTypes { 340 - <option 341 - value={ gType } 342 - if grinder != nil && grinder.GrinderType == gType { 343 - selected 344 - } 345 - > 346 - { gType } 347 - </option> 348 - } 349 - </select> 350 - <select 351 - name="burr_type" 352 - class="w-full form-input" 353 - > 354 - <option value="">Select Burr Type (Optional)</option> 355 - for _, bType := range models.BurrTypes { 356 - <option 357 - value={ bType } 358 - if grinder != nil && grinder.BurrType == bType { 359 - selected 360 - } 361 - > 362 - { bType } 363 - </option> 364 - } 365 - </select> 366 - <textarea 367 - name="notes" 368 - placeholder="Notes" 369 - rows="3" 370 - class="w-full form-textarea" 371 - >{ getStringValue(grinder, "notes") }</textarea> 372 - <div class="flex gap-2"> 376 + > 377 + <option value="">Select Burr Type</option> 378 + for _, bType := range models.BurrTypes { 379 + <option 380 + value={ bType } 381 + if grinder != nil && grinder.BurrType == bType { 382 + selected 383 + } 384 + > 385 + { bType } 386 + </option> 387 + } 388 + </select> 389 + <textarea 390 + name="notes" 391 + placeholder="Notes" 392 + rows="3" 393 + class="w-full form-textarea" 394 + >{ getStringValue(grinder, "notes") }</textarea> 395 + </div> 396 + <div class="flex gap-2 pt-2"> 373 397 <button type="submit" class="flex-1 btn-primary"> 374 398 Save 375 399 </button> ··· 406 430 hx-trigger="submit" 407 431 hx-swap="none" 408 432 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(); }" 409 - class="space-y-4" 433 + class="space-y-5" 410 434 > 411 - if brewer == nil { 412 - <div x-data="entitySuggest('/api/suggestions/brewers')" class="relative"> 435 + <!-- Essentials --> 436 + <div class="form-fieldset"> 437 + <div class="form-fieldset-label">Essentials</div> 438 + if brewer == nil { 439 + <div x-data="entitySuggest('/api/suggestions/brewers')" class="relative"> 440 + <input 441 + type="text" 442 + name="name" 443 + placeholder="Name *" 444 + required 445 + class="w-full form-input" 446 + x-model="query" 447 + @input.debounce.300ms="search()" 448 + @blur.debounce.200ms="showSuggestions = false" 449 + @focus="if (suggestions.length > 0) showSuggestions = true" 450 + autocomplete="off" 451 + /> 452 + <input type="hidden" name="source_ref" x-model="sourceRef"/> 453 + <template x-if="showSuggestions && suggestions.length > 0"> 454 + <div class="suggestions-dropdown"> 455 + <template x-for="s in suggestions" :key="s.source_uri"> 456 + <button 457 + type="button" 458 + class="suggestions-item" 459 + @mousedown.prevent="selectBrewerSuggestion(s)" 460 + > 461 + <span class="font-medium" x-text="s.name"></span> 462 + <template x-if="s.fields.brewerType"> 463 + <span class="text-xs text-brown-500" x-text="s.fields.brewerType"></span> 464 + </template> 465 + <template x-if="s.count > 1"> 466 + <span class="text-xs text-brown-400" x-text="s.count + ' users'"></span> 467 + </template> 468 + </button> 469 + </template> 470 + </div> 471 + </template> 472 + </div> 473 + } else { 413 474 <input 414 475 type="text" 415 476 name="name" 477 + value={ getStringValue(brewer, "name") } 416 478 placeholder="Name *" 417 479 required 418 480 class="w-full form-input" 419 - x-model="query" 420 - @input.debounce.300ms="search()" 421 - @blur.debounce.200ms="showSuggestions = false" 422 - @focus="if (suggestions.length > 0) showSuggestions = true" 423 - autocomplete="off" 424 481 /> 425 - <input type="hidden" name="source_ref" x-model="sourceRef"/> 426 - <template x-if="showSuggestions && suggestions.length > 0"> 427 - <div class="suggestions-dropdown"> 428 - <template x-for="s in suggestions" :key="s.source_uri"> 429 - <button 430 - type="button" 431 - class="suggestions-item" 432 - @mousedown.prevent="selectBrewerSuggestion(s)" 433 - > 434 - <span class="font-medium" x-text="s.name"></span> 435 - <template x-if="s.fields.brewerType"> 436 - <span class="text-xs text-brown-500" x-text="s.fields.brewerType"></span> 437 - </template> 438 - <template x-if="s.count > 1"> 439 - <span class="text-xs text-brown-400" x-text="s.count + ' users'"></span> 440 - </template> 441 - </button> 442 - </template> 443 - </div> 444 - </template> 445 - </div> 446 - } else { 447 - <input 448 - type="text" 449 - name="name" 450 - value={ getStringValue(brewer, "name") } 451 - placeholder="Name *" 452 - required 453 - class="w-full form-input" 454 - /> 455 - } 456 - <select 457 - name="brewer_type" 458 - class="w-full form-select" 459 - > 460 - <option value="">Select type...</option> 461 - <option value="pourover" if getStringValue(brewer, "brewer_type") == "pourover" || models.NormalizeBrewerType(getStringValue(brewer, "brewer_type")) == "pourover" { 482 + } 483 + <select 484 + name="brewer_type" 485 + class="w-full form-select" 486 + > 487 + <option value="">Select type...</option> 488 + <option value="pourover" if getStringValue(brewer, "brewer_type") == "pourover" || models.NormalizeBrewerType(getStringValue(brewer, "brewer_type")) == "pourover" { 462 489 selected 463 490 }>Pour-over</option> 464 - <option value="espresso" if getStringValue(brewer, "brewer_type") == "espresso" || models.NormalizeBrewerType(getStringValue(brewer, "brewer_type")) == "espresso" { 491 + <option value="espresso" if getStringValue(brewer, "brewer_type") == "espresso" || models.NormalizeBrewerType(getStringValue(brewer, "brewer_type")) == "espresso" { 465 492 selected 466 493 }>Espresso</option> 467 - <option value="immersion" if getStringValue(brewer, "brewer_type") == "immersion" || models.NormalizeBrewerType(getStringValue(brewer, "brewer_type")) == "immersion" { 494 + <option value="immersion" if getStringValue(brewer, "brewer_type") == "immersion" || models.NormalizeBrewerType(getStringValue(brewer, "brewer_type")) == "immersion" { 468 495 selected 469 496 }>Immersion</option> 470 - <option value="mokapot" if getStringValue(brewer, "brewer_type") == "mokapot" || models.NormalizeBrewerType(getStringValue(brewer, "brewer_type")) == "mokapot" { 497 + <option value="mokapot" if getStringValue(brewer, "brewer_type") == "mokapot" || models.NormalizeBrewerType(getStringValue(brewer, "brewer_type")) == "mokapot" { 471 498 selected 472 499 }>Moka Pot</option> 473 - <option value="coldbrew" if getStringValue(brewer, "brewer_type") == "coldbrew" || models.NormalizeBrewerType(getStringValue(brewer, "brewer_type")) == "coldbrew" { 500 + <option value="coldbrew" if getStringValue(brewer, "brewer_type") == "coldbrew" || models.NormalizeBrewerType(getStringValue(brewer, "brewer_type")) == "coldbrew" { 474 501 selected 475 502 }>Cold Brew</option> 476 - <option value="cupping" if getStringValue(brewer, "brewer_type") == "cupping" || models.NormalizeBrewerType(getStringValue(brewer, "brewer_type")) == "cupping" { 503 + <option value="cupping" if getStringValue(brewer, "brewer_type") == "cupping" || models.NormalizeBrewerType(getStringValue(brewer, "brewer_type")) == "cupping" { 477 504 selected 478 505 }>Cupping</option> 479 - <option value="other" if getStringValue(brewer, "brewer_type") == "other" || models.NormalizeBrewerType(getStringValue(brewer, "brewer_type")) == "other" { 506 + <option value="other" if getStringValue(brewer, "brewer_type") == "other" || models.NormalizeBrewerType(getStringValue(brewer, "brewer_type")) == "other" { 480 507 selected 481 508 }>Other</option> 482 - </select> 483 - <textarea 484 - name="description" 485 - placeholder="Description" 486 - rows="3" 487 - class="w-full form-textarea" 488 - >{ getStringValue(brewer, "description") }</textarea> 489 - <div class="flex gap-2"> 509 + </select> 510 + </div> 511 + <div class="form-divider"></div> 512 + <!-- Description --> 513 + <div class="form-fieldset"> 514 + <div class="form-fieldset-label">Description <span class="form-optional-hint">(optional)</span></div> 515 + <textarea 516 + name="description" 517 + placeholder="Description" 518 + rows="3" 519 + class="w-full form-textarea" 520 + >{ getStringValue(brewer, "description") }</textarea> 521 + </div> 522 + <div class="flex gap-2 pt-2"> 490 523 <button type="submit" class="flex-1 btn-primary"> 491 524 Save 492 525 </button> ··· 523 556 hx-trigger="submit" 524 557 hx-swap="none" 525 558 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(); }" 526 - class="space-y-4" 559 + class="space-y-5" 527 560 > 528 - if roaster == nil { 529 - <div x-data="entitySuggest('/api/suggestions/roasters')" class="relative"> 561 + <!-- Essentials --> 562 + <div class="form-fieldset"> 563 + <div class="form-fieldset-label">Essentials</div> 564 + if roaster == nil { 565 + <div x-data="entitySuggest('/api/suggestions/roasters')" class="relative"> 566 + <input 567 + type="text" 568 + name="name" 569 + placeholder="Name *" 570 + required 571 + class="w-full form-input" 572 + x-model="query" 573 + @input.debounce.300ms="search()" 574 + @blur.debounce.200ms="showSuggestions = false" 575 + @focus="if (suggestions.length > 0) showSuggestions = true" 576 + autocomplete="off" 577 + /> 578 + <input type="hidden" name="source_ref" x-model="sourceRef"/> 579 + <template x-if="showSuggestions && suggestions.length > 0"> 580 + <div class="suggestions-dropdown"> 581 + <template x-for="s in suggestions" :key="s.source_uri"> 582 + <button 583 + type="button" 584 + class="suggestions-item" 585 + @mousedown.prevent="selectRoasterSuggestion(s)" 586 + > 587 + <span class="font-medium" x-text="s.name"></span> 588 + <template x-if="s.fields.location"> 589 + <span class="text-xs text-brown-500" x-text="s.fields.location"></span> 590 + </template> 591 + <template x-if="s.count > 1"> 592 + <span class="text-xs text-brown-400" x-text="s.count + ' users'"></span> 593 + </template> 594 + </button> 595 + </template> 596 + </div> 597 + </template> 598 + </div> 599 + } else { 530 600 <input 531 601 type="text" 532 602 name="name" 603 + value={ getStringValue(roaster, "name") } 533 604 placeholder="Name *" 534 605 required 535 606 class="w-full form-input" 536 - x-model="query" 537 - @input.debounce.300ms="search()" 538 - @blur.debounce.200ms="showSuggestions = false" 539 - @focus="if (suggestions.length > 0) showSuggestions = true" 540 - autocomplete="off" 541 607 /> 542 - <input type="hidden" name="source_ref" x-model="sourceRef"/> 543 - <template x-if="showSuggestions && suggestions.length > 0"> 544 - <div class="suggestions-dropdown"> 545 - <template x-for="s in suggestions" :key="s.source_uri"> 546 - <button 547 - type="button" 548 - class="suggestions-item" 549 - @mousedown.prevent="selectRoasterSuggestion(s)" 550 - > 551 - <span class="font-medium" x-text="s.name"></span> 552 - <template x-if="s.fields.location"> 553 - <span class="text-xs text-brown-500" x-text="s.fields.location"></span> 554 - </template> 555 - <template x-if="s.count > 1"> 556 - <span class="text-xs text-brown-400" x-text="s.count + ' users'"></span> 557 - </template> 558 - </button> 559 - </template> 560 - </div> 561 - </template> 562 - </div> 563 - } else { 608 + } 609 + </div> 610 + <div class="form-divider"></div> 611 + <!-- Details --> 612 + <div class="form-fieldset"> 613 + <div class="form-fieldset-label">Details <span class="form-optional-hint">(optional)</span></div> 564 614 <input 565 615 type="text" 566 - name="name" 567 - value={ getStringValue(roaster, "name") } 568 - placeholder="Name *" 569 - required 616 + name="location" 617 + value={ getStringValue(roaster, "location") } 618 + placeholder="Location" 570 619 class="w-full form-input" 571 620 /> 572 - } 573 - <input 574 - type="text" 575 - name="location" 576 - value={ getStringValue(roaster, "location") } 577 - placeholder="Location" 578 - class="w-full form-input" 579 - /> 580 - <input 581 - type="url" 582 - name="website" 583 - value={ getStringValue(roaster, "website") } 584 - placeholder="Website" 585 - class="w-full form-input" 586 - /> 587 - <div class="flex gap-2"> 621 + <input 622 + type="url" 623 + name="website" 624 + value={ getStringValue(roaster, "website") } 625 + placeholder="Website" 626 + class="w-full form-input" 627 + /> 628 + </div> 629 + <div class="flex gap-2 pt-2"> 588 630 <button type="submit" class="flex-1 btn-primary"> 589 631 Save 590 632 </button> ··· 633 675 hx-trigger="submit" 634 676 hx-swap="none" 635 677 hx-on::after-request="if(event.detail.successful) { this.closest('dialog').close(); htmx.trigger('body', 'refreshManage'); }" 636 - class="space-y-4" 678 + class="space-y-5" 637 679 x-data={ fmt.Sprintf("{ pours: %s, addPour() { this.pours.push({water: '', time: ''}); }, removePour(i) { this.pours.splice(i, 1); } }", recipePourJSON(recipe)) } 638 680 > 639 - <input 640 - type="text" 641 - name="name" 642 - value={ getStringValue(recipe, "name") } 643 - placeholder="Name *" 644 - required 645 - class="w-full form-input" 646 - /> 647 - <select 648 - name="brewer_rkey" 649 - class="w-full form-input" 650 - > 651 - <option value="">Select Brewer (Optional)</option> 652 - for _, brewer := range brewers { 653 - <option 654 - value={ brewer.RKey } 655 - if recipe != nil && recipe.BrewerRKey == brewer.RKey { 656 - selected 657 - } 658 - > 659 - { brewer.Name } 660 - </option> 661 - } 662 - </select> 663 - <input 664 - type="text" 665 - name="brewer_type" 666 - value={ getStringValue(recipe, "brewer_type") } 667 - placeholder="Brewer Type (e.g., Pour-Over, Immersion)" 668 - class="w-full form-input" 669 - /> 670 - <input 671 - type="number" 672 - name="coffee_amount" 673 - value={ getStringValue(recipe, "coffee_amount") } 674 - placeholder="Coffee Amount (grams)" 675 - step="0.1" 676 - class="w-full form-input" 677 - /> 678 - <input 679 - type="number" 680 - name="water_amount" 681 - value={ getStringValue(recipe, "water_amount") } 682 - placeholder="Water Amount (grams)" 683 - step="0.1" 684 - class="w-full form-input" 685 - /> 681 + <!-- Essentials --> 682 + <div class="form-fieldset"> 683 + <div class="form-fieldset-label">Essentials</div> 684 + <input 685 + type="text" 686 + name="name" 687 + value={ getStringValue(recipe, "name") } 688 + placeholder="Name *" 689 + required 690 + class="w-full form-input" 691 + /> 692 + <select 693 + name="brewer_rkey" 694 + class="w-full form-input" 695 + > 696 + <option value="">Select Brewer</option> 697 + for _, brewer := range brewers { 698 + <option 699 + value={ brewer.RKey } 700 + if recipe != nil && recipe.BrewerRKey == brewer.RKey { 701 + selected 702 + } 703 + > 704 + { brewer.Name } 705 + </option> 706 + } 707 + </select> 708 + <input 709 + type="text" 710 + name="brewer_type" 711 + value={ getStringValue(recipe, "brewer_type") } 712 + placeholder="Brewer Type (e.g., Pour-Over, Immersion)" 713 + class="w-full form-input" 714 + /> 715 + </div> 716 + <div class="form-divider"></div> 717 + <!-- Amounts --> 718 + <div class="form-fieldset"> 719 + <div class="form-fieldset-label">Amounts <span class="form-optional-hint">(optional)</span></div> 720 + <div class="grid grid-cols-2 gap-3"> 721 + <input 722 + type="number" 723 + name="coffee_amount" 724 + value={ getStringValue(recipe, "coffee_amount") } 725 + placeholder="Coffee (g)" 726 + step="0.1" 727 + class="w-full form-input" 728 + /> 729 + <input 730 + type="number" 731 + name="water_amount" 732 + value={ getStringValue(recipe, "water_amount") } 733 + placeholder="Water (g)" 734 + step="0.1" 735 + class="w-full form-input" 736 + /> 737 + </div> 738 + </div> 739 + <div class="form-divider"></div> 686 740 <!-- Pours --> 687 - <div> 688 - <div class="flex items-center justify-between mb-2"> 689 - <label class="block text-sm font-medium text-brown-900">Pours (Optional)</label> 741 + <div class="form-fieldset"> 742 + <div class="flex items-center justify-between"> 743 + <div class="form-fieldset-label">Pours <span class="form-optional-hint">(optional)</span></div> 690 744 <button 691 745 type="button" 692 746 @click="addPour()" ··· 729 783 </template> 730 784 </div> 731 785 </div> 732 - <textarea 733 - name="notes" 734 - placeholder="Notes" 735 - rows="3" 736 - class="w-full form-textarea" 737 - >{ getStringValue(recipe, "notes") }</textarea> 738 - <div class="flex gap-2"> 786 + <div class="form-divider"></div> 787 + <!-- Notes --> 788 + <div class="form-fieldset"> 789 + <div class="form-fieldset-label">Notes <span class="form-optional-hint">(optional)</span></div> 790 + <textarea 791 + name="notes" 792 + placeholder="Notes" 793 + rows="3" 794 + class="w-full form-textarea" 795 + >{ getStringValue(recipe, "notes") }</textarea> 796 + </div> 797 + <div class="flex gap-2 pt-2"> 739 798 <button type="submit" class="flex-1 btn-primary"> 740 799 Save 741 800 </button>
+14 -8
internal/web/pages/brew_view.templ
··· 44 44 // BrewViewCard renders the brew details card 45 45 templ BrewViewCard(props BrewViewProps) { 46 46 @BrewViewHeader(props) 47 - <div class="space-y-6"> 48 - if props.Brew.Rating > 0 { 47 + <!-- Rating: hero element, generous spacing --> 48 + if props.Brew.Rating > 0 { 49 + <div class="mb-8"> 49 50 @BrewRating(props.Brew.Rating) 50 - } 51 + </div> 52 + } 53 + <!-- Bean + parameters: tightly grouped as core brew info --> 54 + <div class="space-y-4 mb-8"> 51 55 @BrewBeanSection(props.Brew, getOwnerFromShareURL(props.ShareURL)) 52 56 @BrewParametersGrid(props.Brew, getOwnerFromShareURL(props.ShareURL)) 57 + </div> 58 + <!-- Method details: recipe, pours, tasting notes — secondary info with more breathing room --> 59 + <div class="space-y-6"> 53 60 if props.Brew.RecipeObj != nil { 54 61 @BrewRecipeSection(props.Brew.RecipeObj, getOwnerFromShareURL(props.ShareURL)) 55 62 } ··· 62 69 if props.IsOwnProfile && props.Brew.RecipeObj == nil { 63 70 @SaveAsRecipeButton(props.Brew.RKey) 64 71 } 65 - <div class="flex justify-between items-center"> 72 + <div class="flex justify-between items-center pt-2"> 66 73 @components.BackButton() 67 74 <div class="bg-brown-50 rounded-lg px-3 py-2 border border-brown-200 brew-view-actions"> 68 75 @components.ActionBar(components.ActionBarProps{ ··· 114 121 115 122 // BrewRating renders the prominent rating display 116 123 templ BrewRating(rating int) { 117 - <div class="section-box text-center py-4"> 118 - <span class="badge-rating text-2xl !font-bold px-5 py-2"> 119 - <span class="inline-flex items-center gap-1"> 124 + <div class="text-center py-6"> 125 + <span class="badge-rating text-2xl !font-bold px-6 py-2.5"> 126 + <span class="inline-flex items-center gap-1.5"> 120 127 @components.IconStar() 121 128 { fmt.Sprintf("%d/10", rating) } 122 129 </span> 123 130 </span> 124 - <div class="text-sm text-brown-600 mt-2">Rating</div> 125 131 </div> 126 132 } 127 133
+20
static/css/app.css
··· 470 470 @apply rounded-lg p-4; 471 471 } 472 472 473 + /* Form field groups — semantic clusters within modals */ 474 + .form-fieldset { 475 + @apply space-y-3; 476 + } 477 + 478 + .form-fieldset-label { 479 + @apply text-xs font-medium uppercase tracking-wider mb-1; 480 + color: var(--text-muted); 481 + } 482 + 483 + .form-divider { 484 + border-top: 1px solid var(--surface-border); 485 + @apply my-2; 486 + } 487 + 488 + .form-optional-hint { 489 + @apply text-xs; 490 + color: var(--text-faint); 491 + } 492 + 473 493 /* Buttons */ 474 494 .btn { 475 495 @apply inline-flex items-center justify-center px-4 py-2 rounded-lg font-medium transition-colors cursor-pointer;