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: separate brew forms for recipe and freeform

- needs styling fixes to freeform form

fix: contrain free-form entry drop-downs

authored by

Patrick Dewey and committed by tangled.org 51449209 614e5582

+470 -238
+15 -7
README.md
··· 11 11 12 12 - Track coffee brews with detailed parameters 13 13 - Store data in your AT Protocol Personal Data Server 14 - - Community feed of recent brews from registered users (polling or real-time firehose) 14 + - Community feed of recent brews from registered users (polling or real-time 15 + firehose) 15 16 - Manage beans, roasters, grinders, and brewers 16 17 - Export brew data as JSON 17 18 - Mobile-friendly PWA design ··· 39 40 40 41 ### Command-Line Flags 41 42 42 - - `--known-dids <file>` - Path to file with DIDs to backfill on startup (one per line) 43 + - `--known-dids <file>` - Path to file with DIDs to backfill on startup (one per 44 + line) 43 45 44 46 ### Environment Variables 45 47 46 48 - `PORT` - Server port (default: 18910) 47 - - `SERVER_PUBLIC_URL` - Public URL for reverse proxy deployments (e.g., https://arabica.example.com) 49 + - `SERVER_PUBLIC_URL` - Public URL for reverse proxy deployments (e.g., 50 + https://arabica.example.com) 48 51 - `ARABICA_DB_PATH` - BoltDB path (default: ~/.local/share/arabica/arabica.db) 49 - - `ARABICA_FEED_INDEX_PATH` - Firehose index BoltDB path (default: ~/.local/share/arabica/feed-index.db) 52 + - `ARABICA_FEED_INDEX_PATH` - Firehose index BoltDB path (default: 53 + ~/.local/share/arabica/feed-index.db) 50 54 - `ARABICA_PROFILE_CACHE_TTL` - Profile cache duration (default: 1h) 51 55 - `OAUTH_CLIENT_ID` - OAuth client ID (optional, uses localhost mode if not set) 52 56 - `OAUTH_REDIRECT_URI` - OAuth redirect URI (optional) ··· 72 76 go build -o arabica cmd/server/main.go 73 77 ``` 74 78 75 - Without Nix, you'll need to have Go, Templ, and tailwindcss installed (just is optional but recommended). 79 + Without Nix, you'll need to have Go, Templ, and tailwindcss installed (just is 80 + optional but recommended). 76 81 77 82 ```sh 78 83 # Generate CSS files ··· 92 97 93 98 ### Reverse Proxy Setup 94 99 95 - When deploying behind a reverse proxy (nginx, Caddy, Cloudflare Tunnel, etc.), set the `SERVER_PUBLIC_URL` environment variable to your public-facing URL: 100 + When deploying behind a reverse proxy (nginx, Caddy, Cloudflare Tunnel, etc.), 101 + set the `SERVER_PUBLIC_URL` environment variable to your public-facing URL: 96 102 97 103 ```bash 98 104 # Example with nginx reverse proxy ··· 104 110 # But OAuth callbacks use https://arabica.example.com/oauth/callback 105 111 ``` 106 112 107 - The `SERVER_PUBLIC_URL` is used for OAuth client metadata and callback URLs, ensuring the AT Protocol OAuth flow works correctly when the server is accessed via a different URL than it's running on. 113 + The `SERVER_PUBLIC_URL` is used for OAuth client metadata and callback URLs, 114 + ensuring the AT Protocol OAuth flow works correctly when the server is accessed 115 + via a different URL than it's running on. 108 116 109 117 ### NixOS Deployment 110 118
+2 -1
internal/handlers/brew.go
··· 135 135 layoutData, _, _ := h.layoutDataFromRequest(r, "New Brew") 136 136 137 137 brewFormProps := pages.BrewFormProps{ 138 - Brew: nil, 138 + Brew: nil, 139 + RecipeRKey: r.URL.Query().Get("recipe"), 139 140 } 140 141 141 142 if err := pages.BrewFormPage(layoutData, brewFormProps).Render(r.Context(), w); err != nil {
+177 -65
internal/web/pages/brew_form.templ
··· 19 19 20 20 // Derived JSON for pours (if editing) 21 21 PoursJSON string 22 + 23 + // Recipe rkey from URL param (for auto-applying recipe on new brew) 24 + RecipeRKey string 22 25 } 23 26 24 27 // BrewFormPage renders the full brew form page with layout ··· 57 60 </div> 58 61 } 59 62 63 + func getFormRecipeRKey(props BrewFormProps) string { 64 + if props.RecipeRKey != "" { 65 + return props.RecipeRKey 66 + } 67 + if props.Brew != nil { 68 + return props.Brew.RecipeRKey 69 + } 70 + return "" 71 + } 72 + 60 73 // BrewFormElement renders the form element with all fields 61 74 templ BrewFormElement(props BrewFormProps) { 62 75 <form ··· 70 83 if props.PoursJSON != "" { 71 84 data-pours={ props.PoursJSON } 72 85 } 86 + if props.Brew != nil { 87 + data-editing="true" 88 + } 89 + if getFormRecipeRKey(props) != "" { 90 + data-recipe-rkey={ getFormRecipeRKey(props) } 91 + } 73 92 > 93 + @ModeChooser(props) 94 + @RecipeModeSection(props) 95 + @FreeformModeSection(props) 96 + <div x-show="formMode !== 'choose'" x-cloak> 97 + @SubmitButton(props) 98 + </div> 99 + </form> 100 + } 101 + 102 + // ModeChooser renders the recipe/freeform mode selection for new brews 103 + templ ModeChooser(props BrewFormProps) { 104 + <div x-show="formMode === 'choose'" x-cloak> 105 + <div class="section-box text-center py-8"> 106 + <p class="text-lg text-brown-700 mb-6">How would you like to start?</p> 107 + <div class="flex gap-4 justify-center"> 108 + <button 109 + type="button" 110 + @click="chooseRecipeMode()" 111 + class="btn-primary py-3 px-6 rounded-xl font-semibold text-lg shadow-lg hover:shadow-xl" 112 + > 113 + Use a Recipe 114 + </button> 115 + <button 116 + type="button" 117 + @click="chooseFreeformMode()" 118 + class="btn-secondary py-3 px-6 rounded-xl font-semibold text-lg" 119 + > 120 + Brew Freeform 121 + </button> 122 + </div> 123 + </div> 124 + </div> 125 + } 126 + 127 + // RecipeModeSection renders recipe select, summary bar, override fields, and non-recipe fields 128 + templ RecipeModeSection(props BrewFormProps) { 129 + <fieldset x-show="formMode === 'recipe'" :disabled="formMode !== 'recipe'" x-cloak class="space-y-6 border-0 p-0 m-0 min-w-0"> 74 130 @RecipeSelectField(props) 75 - @BeanSelectField(props) 76 - @CoffeeAmountField(props) 77 - @GrinderSelectField(props) 78 - @GrindSizeField(props) 79 - @BrewerSelectField(props) 80 - @WaterAmountField(props) 81 - @PoursSection(props) 82 - @TemperatureField(props) 83 - @BrewTimeField(props) 84 - @TastingNotesField(props) 85 - @RatingField(props) 86 - @SubmitButton(props) 87 - </form> 131 + <!-- Recipe summary bar --> 132 + <div class="section-box" x-show="activeRecipe" x-cloak> 133 + <div class="flex items-center justify-between gap-2"> 134 + <p class="text-sm text-brown-700 flex-1" x-text="recipeSummaryText"></p> 135 + <button type="button" @click="recipeSummaryExpanded = !recipeSummaryExpanded" class="text-sm btn-secondary"> 136 + <span x-text="recipeSummaryExpanded ? 'Collapse' : 'Edit'"></span> 137 + </button> 138 + </div> 139 + </div> 140 + <fieldset class="space-y-6 border border-brown-200 rounded-lg p-4 min-w-0"> 141 + <legend class="text-sm font-semibold text-brown-800 px-2">Coffee</legend> 142 + @BeanSelectField(props) 143 + <div x-show="recipeSummaryExpanded || !activeRecipe" x-cloak> 144 + @CoffeeAmountField(props) 145 + </div> 146 + @GrinderSelectField(props) 147 + @GrindSizeField(props) 148 + </fieldset> 149 + <fieldset class="space-y-6 border border-brown-200 rounded-lg p-4 min-w-0"> 150 + <legend class="text-sm font-semibold text-brown-800 px-2">Brewing</legend> 151 + <div x-show="recipeSummaryExpanded || !activeRecipe" x-cloak class="space-y-6"> 152 + @BrewerSelectField(props) 153 + @WaterAmountField(props) 154 + @PoursSection(props) 155 + </div> 156 + @TemperatureField(props) 157 + @BrewTimeField(props) 158 + </fieldset> 159 + <fieldset class="space-y-6 border border-brown-200 rounded-lg p-4 min-w-0"> 160 + <legend class="text-sm font-semibold text-brown-800 px-2">Results</legend> 161 + @TastingNotesField(props) 162 + @RatingField(props) 163 + </fieldset> 164 + </fieldset> 165 + } 166 + 167 + // FreeformModeSection renders all fields organized in fieldset groups 168 + templ FreeformModeSection(props BrewFormProps) { 169 + <fieldset x-show="formMode === 'freeform'" :disabled="formMode !== 'freeform'" x-cloak class="space-y-6 border-0 p-0 m-0 min-w-0"> 170 + <input type="hidden" name="recipe_rkey" value=""/> 171 + <fieldset class="space-y-6 border border-brown-200 rounded-lg p-4 min-w-0"> 172 + <legend class="text-sm font-semibold text-brown-800 px-2">Coffee</legend> 173 + @BeanSelectField(props) 174 + @CoffeeAmountField(props) 175 + @GrinderSelectField(props) 176 + @GrindSizeField(props) 177 + </fieldset> 178 + <fieldset class="space-y-6 border border-brown-200 rounded-lg p-4 min-w-0"> 179 + <legend class="text-sm font-semibold text-brown-800 px-2">Brewing</legend> 180 + @BrewerSelectField(props) 181 + @WaterAmountField(props) 182 + @PoursSection(props) 183 + @TemperatureField(props) 184 + @BrewTimeField(props) 185 + </fieldset> 186 + <fieldset class="space-y-6 border border-brown-200 rounded-lg p-4 min-w-0"> 187 + <legend class="text-sm font-semibold text-brown-800 px-2">Results</legend> 188 + @TastingNotesField(props) 189 + @RatingField(props) 190 + </fieldset> 191 + </fieldset> 88 192 } 89 193 90 194 // RecipeSelectField renders the recipe selection with autofill and filtering ··· 311 415 <div> 312 416 <label class="form-label">Brew Method</label> 313 417 <div class="flex gap-2"> 314 - <select name="brewer_rkey" class="flex-1 form-select"> 418 + <select name="brewer_rkey" class="flex-1 form-select" @change="onBrewerChange($event.target.value)"> 315 419 <option value="">Select brew method...</option> 316 420 for _, brewer := range props.Brewers { 317 421 <option ··· 341 445 </div> 342 446 } 343 447 344 - // WaterAmountField renders the water amount input 448 + // WaterAmountField renders the water amount input with dynamic helper text 345 449 templ WaterAmountField(props BrewFormProps) { 346 - @components.FormField( 347 - components.FormFieldProps{ 348 - Label: "Water Amount (grams)", 349 - HelperText: "Total water used (or leave empty if using pours below)", 350 - }, 351 - components.NumberInput(components.NumberInputProps{ 450 + <div> 451 + <label class="form-label">Water Amount (grams)</label> 452 + @components.NumberInput(components.NumberInputProps{ 352 453 Name: "water_amount", 353 454 Value: getWaterAmount(props), 354 455 Placeholder: "e.g. 250", 355 456 Step: "1", 356 457 Class: "w-full form-input-lg", 357 - }), 358 - ) 458 + }) 459 + <p class="text-helper" x-show="!showPours">Total water used</p> 460 + <p class="text-helper" x-show="showPours" x-cloak>Total water (pours tracked separately below)</p> 461 + </div> 359 462 } 360 463 361 464 func getWaterAmount(props BrewFormProps) string { ··· 365 468 return "" 366 469 } 367 470 368 - // PoursSection renders the pours editor section 471 + // PoursSection renders the pours editor with toggle visibility 369 472 templ PoursSection(props BrewFormProps) { 370 473 <div> 371 - <div class="flex items-center justify-between mb-2"> 372 - <label class="block text-sm font-medium text-brown-900">Pours (Optional)</label> 373 - <button 374 - type="button" 375 - @click="addPour()" 376 - class="text-sm btn-secondary" 377 - > 378 - + Add Pour 474 + <!-- Toggle link when pours are hidden --> 475 + <div x-show="!showPours" x-cloak> 476 + <button type="button" @click="togglePours()" class="text-sm text-brown-600 hover:text-brown-800 font-medium"> 477 + + Add pours 379 478 </button> 380 479 </div> 381 - <p class="text-sm text-brown-700 mb-3">Track individual pours for bloom and subsequent additions</p> 382 - <div class="space-y-3"> 383 - <template x-for="(pour, index) in pours" :key="index"> 384 - <div class="flex gap-2 items-center bg-brown-50 p-3 rounded-lg border border-brown-200"> 385 - <div class="flex-1"> 386 - <label class="text-xs text-brown-700 font-medium" x-text="'Pour ' + (index + 1)"></label> 387 - <input 388 - type="number" 389 - :name="'pour_water_' + index" 390 - x-model="pour.water" 391 - placeholder="Water (g)" 392 - class="w-full rounded-md border-brown-300 text-sm py-2 px-3 mt-1 bg-white" 393 - /> 394 - </div> 395 - <div class="flex-1"> 396 - <label class="text-xs text-brown-700 font-medium">Time (sec)</label> 397 - <input 398 - type="number" 399 - :name="'pour_time_' + index" 400 - x-model="pour.time" 401 - placeholder="e.g. 45" 402 - class="w-full rounded-md border-brown-300 text-sm py-2 px-3 mt-1 bg-white" 403 - /> 480 + <!-- Pours editor when visible --> 481 + <div x-show="showPours" x-cloak> 482 + <div class="flex items-center justify-between mb-2"> 483 + <label class="block text-sm font-medium text-brown-900">Pours</label> 484 + <button 485 + type="button" 486 + @click="addPour()" 487 + class="text-sm btn-secondary" 488 + > 489 + + Add Pour 490 + </button> 491 + </div> 492 + <p class="text-sm text-brown-700 mb-3">Track individual pours for bloom and subsequent additions</p> 493 + <div class="space-y-3"> 494 + <template x-for="(pour, index) in pours" :key="index"> 495 + <div class="flex gap-2 items-center bg-brown-50 p-3 rounded-lg border border-brown-200"> 496 + <div class="flex-1"> 497 + <label class="text-xs text-brown-700 font-medium" x-text="'Pour ' + (index + 1)"></label> 498 + <input 499 + type="number" 500 + :name="'pour_water_' + index" 501 + x-model="pour.water" 502 + placeholder="Water (g)" 503 + class="w-full rounded-md border-brown-300 text-sm py-2 px-3 mt-1 bg-white" 504 + /> 505 + </div> 506 + <div class="flex-1"> 507 + <label class="text-xs text-brown-700 font-medium">Time (sec)</label> 508 + <input 509 + type="number" 510 + :name="'pour_time_' + index" 511 + x-model="pour.time" 512 + placeholder="e.g. 45" 513 + class="w-full rounded-md border-brown-300 text-sm py-2 px-3 mt-1 bg-white" 514 + /> 515 + </div> 516 + <button 517 + type="button" 518 + @click="removePour(index)" 519 + class="text-brown-700 hover:text-brown-900 mt-5 font-bold" 520 + x-show="pours.length > 0" 521 + > 522 + 523 + </button> 404 524 </div> 405 - <button 406 - type="button" 407 - @click="removePour(index)" 408 - class="text-brown-700 hover:text-brown-900 mt-5 font-bold" 409 - x-show="pours.length > 0" 410 - > 411 - 412 - </button> 413 - </div> 414 - </template> 525 + </template> 526 + </div> 415 527 </div> 416 528 </div> 417 529 }
+1 -1
static/css/app.css
··· 128 128 } 129 129 130 130 .form-select { 131 - @apply form-input truncate max-w-full; 131 + @apply form-input truncate max-w-full min-w-0; 132 132 } 133 133 134 134 .form-textarea {
+156 -40
static/js/brew-form.js
··· 1 1 /** 2 2 * Alpine.js component for the brew form 3 - * Manages pours, new entity modals, and form state 3 + * Manages pours, new entity modals, form mode, and form state 4 4 * Uses shared entity-manager and dropdown-manager modules 5 5 */ 6 6 ··· 10 10 // Brew form specific 11 11 rating: 5, 12 12 pours: [], 13 + 14 + // Mode state 15 + formMode: "choose", // 'choose' | 'recipe' | 'freeform' 16 + recipeSummaryExpanded: false, 17 + activeRecipe: null, 18 + showPours: false, 19 + isEditing: false, 13 20 14 21 // Recipe filter state 15 22 searchQuery: "", ··· 33 40 // Initialize entity managers 34 41 this.initEntityManagers(); 35 42 36 - // Load existing pours if editing 43 + // Detect state from DOM 37 44 const root = this.$root || this.$el; 38 45 const formEl = root.querySelector("form"); 46 + 47 + this.isEditing = formEl?.hasAttribute("data-editing") || false; 48 + const recipeRKey = formEl?.getAttribute("data-recipe-rkey") || ""; 49 + 50 + // Load existing pours if editing 39 51 const poursData = formEl?.getAttribute("data-pours"); 40 52 if (poursData) { 41 53 try { ··· 44 56 console.error("Failed to parse pours data:", e); 45 57 this.pours = []; 46 58 } 59 + } 60 + 61 + // Determine initial mode 62 + if (this.isEditing) { 63 + this.formMode = recipeRKey ? "recipe" : "freeform"; 64 + } else if (recipeRKey) { 65 + this.formMode = "recipe"; 66 + } else { 67 + this.formMode = "choose"; 47 68 } 48 69 49 70 // Populate dropdowns from cache using stale-while-revalidate pattern ··· 63 84 }); 64 85 } 65 86 66 - // Auto-apply recipe from URL param (e.g. /brews/new?recipe=abc123) 67 - const urlParams = new URLSearchParams(window.location.search); 68 - const recipeRKey = urlParams.get("recipe"); 87 + // Auto-apply recipe if rkey present 69 88 if (recipeRKey) { 70 - const recipeSelect = root.querySelector('form select[name="recipe_rkey"]'); 89 + const recipeSelect = root.querySelector( 90 + 'form select[name="recipe_rkey"]', 91 + ); 71 92 if (recipeSelect) { 72 93 recipeSelect.value = recipeRKey; 73 94 } 74 95 await this.applyRecipe(recipeRKey); 75 96 } 97 + 98 + // Update pours visibility after setup 99 + this.updatePoursVisibility(); 100 + 101 + // Also check brewer type after DOM is settled 102 + this.$nextTick(() => { 103 + const selects = 104 + formEl?.querySelectorAll('select[name="brewer_rkey"]') || []; 105 + for (const sel of selects) { 106 + if (sel.value && !sel.disabled) { 107 + this.onBrewerChange(sel.value); 108 + break; 109 + } 110 + } 111 + }); 112 + }, 113 + 114 + // Mode switching 115 + chooseRecipeMode() { 116 + this.formMode = "recipe"; 117 + }, 118 + 119 + chooseFreeformMode() { 120 + this.formMode = "freeform"; 121 + this.updatePoursVisibility(); 122 + }, 123 + 124 + // Pours visibility 125 + updatePoursVisibility() { 126 + if (this.pours.length > 0) { 127 + this.showPours = true; 128 + return; 129 + } 130 + if (this.activeRecipe?.pours?.length > 0) { 131 + this.showPours = true; 132 + return; 133 + } 134 + }, 135 + 136 + togglePours() { 137 + this.showPours = !this.showPours; 138 + if (this.showPours && this.pours.length === 0) { 139 + this.addPour(); 140 + } 141 + }, 142 + 143 + onBrewerChange(rkey) { 144 + const brewerType = this.dropdownManager?.getBrewerType(rkey) || ""; 145 + if (brewerType.toLowerCase().includes("pour")) { 146 + this.showPours = true; 147 + } 148 + }, 149 + 150 + // Recipe summary text 151 + get recipeSummaryText() { 152 + if (!this.activeRecipe) return ""; 153 + const parts = []; 154 + if (this.activeRecipe.coffee_amount > 0) { 155 + parts.push(Math.round(this.activeRecipe.coffee_amount) + "g coffee"); 156 + } 157 + if (this.activeRecipe.water_amount > 0) { 158 + parts.push(Math.round(this.activeRecipe.water_amount) + "g water"); 159 + } 160 + if (this.activeRecipe.brewer_rkey) { 161 + const brewer = (this.dropdownManager?.brewers || []).find( 162 + (b) => (b.rkey || b.RKey) === this.activeRecipe.brewer_rkey, 163 + ); 164 + if (brewer) { 165 + parts.push(brewer.Name || brewer.name); 166 + } 167 + } 168 + if (this.activeRecipe.pours && this.activeRecipe.pours.length > 0) { 169 + parts.push(this.activeRecipe.pours.length + " pours"); 170 + } 171 + return parts.join(" \u00b7 "); 76 172 }, 77 173 78 174 initEntityManagers() { ··· 99 195 // Refresh dropdown data and repopulate 100 196 await this.dropdownManager.invalidateAndRefresh(); 101 197 102 - // Select the new bean 103 - const beanSelect = document.querySelector( 104 - 'form select[name="bean_rkey"]', 105 - ); 106 - if (beanSelect && newBean.rkey) { 107 - beanSelect.value = newBean.rkey; 108 - } 198 + // Select the new bean in all matching selects 199 + document 200 + .querySelectorAll('form select[name="bean_rkey"]') 201 + .forEach((sel) => { 202 + if (newBean.rkey) sel.value = newBean.rkey; 203 + }); 109 204 }, 110 205 }); 111 206 ··· 130 225 // Refresh dropdown data and repopulate 131 226 await this.dropdownManager.invalidateAndRefresh(); 132 227 133 - // Select the new grinder 134 - const grinderSelect = document.querySelector( 135 - 'form select[name="grinder_rkey"]', 136 - ); 137 - if (grinderSelect && newGrinder.rkey) { 138 - grinderSelect.value = newGrinder.rkey; 139 - } 228 + // Select the new grinder in all matching selects 229 + document 230 + .querySelectorAll('form select[name="grinder_rkey"]') 231 + .forEach((sel) => { 232 + if (newGrinder.rkey) sel.value = newGrinder.rkey; 233 + }); 140 234 }, 141 235 }); 142 236 ··· 160 254 // Refresh dropdown data and repopulate 161 255 await this.dropdownManager.invalidateAndRefresh(); 162 256 163 - // Select the new brewer 164 - const brewerSelect = document.querySelector( 165 - 'form select[name="brewer_rkey"]', 166 - ); 167 - if (brewerSelect && newBrewer.rkey) { 168 - brewerSelect.value = newBrewer.rkey; 169 - } 257 + // Select the new brewer in all matching selects 258 + document 259 + .querySelectorAll('form select[name="brewer_rkey"]') 260 + .forEach((sel) => { 261 + if (newBrewer.rkey) sel.value = newBrewer.rkey; 262 + }); 170 263 }, 171 264 }); 172 265 }, 173 266 174 267 // Recipe autofill 175 268 async applyRecipe(rkey) { 176 - // Use $root (the x-data element) to find the form, since $el in event 177 - // handlers points to the element with the directive (e.g. the select), 178 - // not the component root. 179 269 const root = this.$root || this.$el; 180 270 const form = root.querySelector("form") || root.closest("form"); 181 271 if (!form) return; ··· 183 273 // If no recipe selected, clear all recipe-populated fields 184 274 if (!rkey) { 185 275 this.clearRecipeFields(form); 276 + this.activeRecipe = null; 277 + this.recipeSummaryExpanded = false; 278 + this.updatePoursVisibility(); 186 279 return; 187 280 } 188 281 189 282 try { 190 - const resp = await fetch(`/api/recipes/${rkey}`, { credentials: "same-origin" }); 283 + const resp = await fetch(`/api/recipes/${rkey}`, { 284 + credentials: "same-origin", 285 + }); 191 286 if (!resp.ok) return; 192 287 const recipe = await resp.json(); 193 288 289 + // Store recipe data for summary display 290 + this.activeRecipe = recipe; 291 + this.recipeSummaryExpanded = false; 292 + 194 293 // Set or clear each field based on recipe data 195 - this.setFormField(form, "coffee_amount", recipe.coffee_amount > 0 ? Math.round(recipe.coffee_amount) : ""); 196 - this.setFormField(form, "water_amount", recipe.water_amount > 0 ? Math.round(recipe.water_amount) : ""); 294 + this.setFormField( 295 + form, 296 + "coffee_amount", 297 + recipe.coffee_amount > 0 ? Math.round(recipe.coffee_amount) : "", 298 + ); 299 + this.setFormField( 300 + form, 301 + "water_amount", 302 + recipe.water_amount > 0 ? Math.round(recipe.water_amount) : "", 303 + ); 197 304 this.setFormField(form, "brewer_rkey", recipe.brewer_rkey || ""); 198 305 199 306 // Always reset pours, then apply recipe pours if present 200 - this.pours = (recipe.pours && recipe.pours.length > 0) 201 - ? recipe.pours.map(p => ({ water: p.water_amount || "", time: p.time_seconds || "" })) 202 - : []; 307 + this.pours = 308 + recipe.pours && recipe.pours.length > 0 309 + ? recipe.pours.map((p) => ({ 310 + water: p.water_amount || "", 311 + time: p.time_seconds || "", 312 + })) 313 + : []; 314 + 315 + this.updatePoursVisibility(); 203 316 } catch (e) { 204 317 console.error("Failed to apply recipe:", e); 205 318 } 206 319 }, 207 320 208 321 setFormField(form, name, value) { 209 - const el = form.querySelector(`[name="${name}"]`); 210 - if (el) { 322 + // Set all matching fields (both mode sections have their own inputs) 323 + form.querySelectorAll(`[name="${name}"]`).forEach((el) => { 211 324 el.value = value; 212 325 el.dispatchEvent(new Event("input", { bubbles: true })); 213 - } 326 + }); 214 327 }, 215 328 216 329 clearRecipeFields(form) { ··· 360 473 // Interpolate water from pours if not set 361 474 let water = recipe.water_amount || 0; 362 475 if (water === 0 && recipe.pours && recipe.pours.length > 0) { 363 - water = recipe.pours.reduce((sum, p) => sum + (p.water_amount || 0), 0); 476 + water = recipe.pours.reduce( 477 + (sum, p) => sum + (p.water_amount || 0), 478 + 0, 479 + ); 364 480 } 365 481 366 482 // Text filter
+119 -124
static/js/dropdown-manager.js
··· 88 88 }, 89 89 90 90 /** 91 - * Populates bean dropdown 92 - * @param {string} selectSelector - CSS selector for the select element (optional) 91 + * Populates bean dropdown(s) 92 + * @param {string} selectSelector - CSS selector for the select elements (optional) 93 93 */ 94 94 populateBeans(selectSelector = 'form select[name="bean_rkey"]') { 95 - const beanSelect = document.querySelector(selectSelector); 96 - if (!beanSelect || this.beans.length === 0) return; 95 + const selects = document.querySelectorAll(selectSelector); 96 + if (selects.length === 0 || this.beans.length === 0) return; 97 97 98 - const selectedBean = beanSelect.value || ""; 98 + selects.forEach((beanSelect) => { 99 + const selectedBean = beanSelect.value || ""; 100 + beanSelect.innerHTML = ""; 99 101 100 - // Clear existing options 101 - beanSelect.innerHTML = ""; 102 + const placeholderOption = document.createElement("option"); 103 + placeholderOption.value = ""; 104 + placeholderOption.textContent = "Select a bean..."; 105 + beanSelect.appendChild(placeholderOption); 102 106 103 - // Add placeholder 104 - const placeholderOption = document.createElement("option"); 105 - placeholderOption.value = ""; 106 - placeholderOption.textContent = "Select a bean..."; 107 - beanSelect.appendChild(placeholderOption); 108 - 109 - // Add bean options (filter out closed bags) 110 - this.beans.forEach((bean) => { 111 - // Skip closed bags - they shouldn't appear in brew form dropdown 112 - if (bean.Closed || bean.closed) return; 113 - 114 - const option = document.createElement("option"); 115 - option.value = bean.rkey || bean.RKey; 116 - const roasterName = bean.Roaster?.Name || bean.roaster?.name || ""; 117 - const roasterSuffix = roasterName ? ` - ${roasterName}` : ""; 118 - // Using textContent ensures all user input is safely escaped 119 - option.textContent = `${bean.Name || bean.name} (${bean.Origin || bean.origin} - ${bean.RoastLevel || bean.roast_level})${roasterSuffix}`; 120 - option.className = "truncate"; 121 - if ((bean.rkey || bean.RKey) === selectedBean) { 122 - option.selected = true; 123 - } 124 - beanSelect.appendChild(option); 107 + this.beans.forEach((bean) => { 108 + if (bean.Closed || bean.closed) return; 109 + const option = document.createElement("option"); 110 + option.value = bean.rkey || bean.RKey; 111 + const roasterName = bean.Roaster?.Name || bean.roaster?.name || ""; 112 + const roasterSuffix = roasterName ? ` - ${roasterName}` : ""; 113 + option.textContent = `${bean.Name || bean.name} (${bean.Origin || bean.origin} - ${bean.RoastLevel || bean.roast_level})${roasterSuffix}`; 114 + option.className = "truncate"; 115 + if ((bean.rkey || bean.RKey) === selectedBean) { 116 + option.selected = true; 117 + } 118 + beanSelect.appendChild(option); 119 + }); 125 120 }); 126 121 }, 127 122 128 123 /** 129 - * Populates grinder dropdown 130 - * @param {string} selectSelector - CSS selector for the select element (optional) 124 + * Populates grinder dropdown(s) 125 + * @param {string} selectSelector - CSS selector for the select elements (optional) 131 126 */ 132 127 populateGrinders(selectSelector = 'form select[name="grinder_rkey"]') { 133 - const grinderSelect = document.querySelector(selectSelector); 134 - if (!grinderSelect || this.grinders.length === 0) return; 135 - 136 - const selectedGrinder = grinderSelect.value || ""; 128 + const selects = document.querySelectorAll(selectSelector); 129 + if (selects.length === 0 || this.grinders.length === 0) return; 137 130 138 - // Clear existing options 139 - grinderSelect.innerHTML = ""; 131 + selects.forEach((grinderSelect) => { 132 + const selectedGrinder = grinderSelect.value || ""; 133 + grinderSelect.innerHTML = ""; 140 134 141 - // Add placeholder 142 - const placeholderOption = document.createElement("option"); 143 - placeholderOption.value = ""; 144 - placeholderOption.textContent = "Select a grinder..."; 145 - grinderSelect.appendChild(placeholderOption); 135 + const placeholderOption = document.createElement("option"); 136 + placeholderOption.value = ""; 137 + placeholderOption.textContent = "Select a grinder..."; 138 + grinderSelect.appendChild(placeholderOption); 146 139 147 - // Add grinder options 148 - this.grinders.forEach((grinder) => { 149 - const option = document.createElement("option"); 150 - option.value = grinder.rkey || grinder.RKey; 151 - // Using textContent ensures all user input is safely escaped 152 - option.textContent = grinder.Name || grinder.name; 153 - option.className = "truncate"; 154 - if ((grinder.rkey || grinder.RKey) === selectedGrinder) { 155 - option.selected = true; 156 - } 157 - grinderSelect.appendChild(option); 140 + this.grinders.forEach((grinder) => { 141 + const option = document.createElement("option"); 142 + option.value = grinder.rkey || grinder.RKey; 143 + option.textContent = grinder.Name || grinder.name; 144 + option.className = "truncate"; 145 + if ((grinder.rkey || grinder.RKey) === selectedGrinder) { 146 + option.selected = true; 147 + } 148 + grinderSelect.appendChild(option); 149 + }); 158 150 }); 159 151 }, 160 152 161 153 /** 162 - * Populates brewer dropdown 163 - * @param {string} selectSelector - CSS selector for the select element (optional) 154 + * Populates brewer dropdown(s) 155 + * @param {string} selectSelector - CSS selector for the select elements (optional) 164 156 */ 165 157 populateBrewers(selectSelector = 'form select[name="brewer_rkey"]') { 166 - const brewerSelect = document.querySelector(selectSelector); 167 - if (!brewerSelect || this.brewers.length === 0) return; 158 + const selects = document.querySelectorAll(selectSelector); 159 + if (selects.length === 0 || this.brewers.length === 0) return; 168 160 169 - const selectedBrewer = brewerSelect.value || ""; 161 + selects.forEach((brewerSelect) => { 162 + const selectedBrewer = brewerSelect.value || ""; 163 + brewerSelect.innerHTML = ""; 170 164 171 - // Clear existing options 172 - brewerSelect.innerHTML = ""; 165 + const placeholderOption = document.createElement("option"); 166 + placeholderOption.value = ""; 167 + placeholderOption.textContent = "Select brew method..."; 168 + brewerSelect.appendChild(placeholderOption); 173 169 174 - // Add placeholder 175 - const placeholderOption = document.createElement("option"); 176 - placeholderOption.value = ""; 177 - placeholderOption.textContent = "Select brew method..."; 178 - brewerSelect.appendChild(placeholderOption); 179 - 180 - // Add brewer options 181 - this.brewers.forEach((brewer) => { 182 - const option = document.createElement("option"); 183 - option.value = brewer.rkey || brewer.RKey; 184 - // Using textContent ensures all user input is safely escaped 185 - option.textContent = brewer.Name || brewer.name; 186 - option.className = "truncate"; 187 - if ((brewer.rkey || brewer.RKey) === selectedBrewer) { 188 - option.selected = true; 189 - } 190 - brewerSelect.appendChild(option); 170 + this.brewers.forEach((brewer) => { 171 + const option = document.createElement("option"); 172 + option.value = brewer.rkey || brewer.RKey; 173 + option.textContent = brewer.Name || brewer.name; 174 + option.className = "truncate"; 175 + if ((brewer.rkey || brewer.RKey) === selectedBrewer) { 176 + option.selected = true; 177 + } 178 + brewerSelect.appendChild(option); 179 + }); 191 180 }); 192 181 }, 193 182 194 183 /** 195 - * Populates roaster dropdown (used in new bean modal) 196 - * @param {string} selectSelector - CSS selector for the select element (optional) 184 + * Populates roaster dropdown(s) (used in new bean modal) 185 + * @param {string} selectSelector - CSS selector for the select elements (optional) 197 186 */ 198 187 populateRoasters(selectSelector = 'select[name="roaster_rkey_modal"]') { 199 - const roasterSelect = document.querySelector(selectSelector); 200 - if (!roasterSelect || this.roasters.length === 0) return; 188 + const selects = document.querySelectorAll(selectSelector); 189 + if (selects.length === 0 || this.roasters.length === 0) return; 201 190 202 - const selectedRoaster = roasterSelect.value || ""; 191 + selects.forEach((roasterSelect) => { 192 + const selectedRoaster = roasterSelect.value || ""; 193 + roasterSelect.innerHTML = ""; 203 194 204 - // Clear existing options 205 - roasterSelect.innerHTML = ""; 195 + const placeholderOption = document.createElement("option"); 196 + placeholderOption.value = ""; 197 + placeholderOption.textContent = "No roaster"; 198 + roasterSelect.appendChild(placeholderOption); 206 199 207 - // Add placeholder 208 - const placeholderOption = document.createElement("option"); 209 - placeholderOption.value = ""; 210 - placeholderOption.textContent = "No roaster"; 211 - roasterSelect.appendChild(placeholderOption); 212 - 213 - // Add roaster options 214 - this.roasters.forEach((roaster) => { 215 - const option = document.createElement("option"); 216 - option.value = roaster.rkey || roaster.RKey; 217 - // Using textContent ensures all user input is safely escaped 218 - option.textContent = roaster.Name || roaster.name; 219 - if ((roaster.rkey || roaster.RKey) === selectedRoaster) { 220 - option.selected = true; 221 - } 222 - roasterSelect.appendChild(option); 200 + this.roasters.forEach((roaster) => { 201 + const option = document.createElement("option"); 202 + option.value = roaster.rkey || roaster.RKey; 203 + option.textContent = roaster.Name || roaster.name; 204 + if ((roaster.rkey || roaster.RKey) === selectedRoaster) { 205 + option.selected = true; 206 + } 207 + roasterSelect.appendChild(option); 208 + }); 223 209 }); 224 210 }, 225 211 226 212 /** 227 - * Populates recipe dropdown 228 - * @param {string} selectSelector - CSS selector for the select element (optional) 213 + * Populates recipe dropdown(s) 214 + * @param {string} selectSelector - CSS selector for the select elements (optional) 229 215 */ 230 216 populateRecipes(selectSelector = 'form select[name="recipe_rkey"]') { 231 - const recipeSelect = document.querySelector(selectSelector); 232 - if (!recipeSelect || this.recipes.length === 0) return; 217 + const selects = document.querySelectorAll(selectSelector); 218 + if (selects.length === 0 || this.recipes.length === 0) return; 233 219 234 - const selectedRecipe = recipeSelect.value || ""; 235 - 236 - // Clear existing options 237 - recipeSelect.innerHTML = ""; 220 + selects.forEach((recipeSelect) => { 221 + const selectedRecipe = recipeSelect.value || ""; 222 + recipeSelect.innerHTML = ""; 238 223 239 - // Add placeholder 240 - const placeholderOption = document.createElement("option"); 241 - placeholderOption.value = ""; 242 - placeholderOption.textContent = "No recipe"; 243 - recipeSelect.appendChild(placeholderOption); 224 + const placeholderOption = document.createElement("option"); 225 + placeholderOption.value = ""; 226 + placeholderOption.textContent = "No recipe"; 227 + recipeSelect.appendChild(placeholderOption); 244 228 245 - // Add recipe options 246 - this.recipes.forEach((recipe) => { 247 - const option = document.createElement("option"); 248 - option.value = recipe.rkey || recipe.RKey; 249 - // Using textContent ensures all user input is safely escaped 250 - option.textContent = recipe.Name || recipe.name; 251 - option.className = "truncate"; 252 - if ((recipe.rkey || recipe.RKey) === selectedRecipe) { 253 - option.selected = true; 254 - } 255 - recipeSelect.appendChild(option); 229 + this.recipes.forEach((recipe) => { 230 + const option = document.createElement("option"); 231 + option.value = recipe.rkey || recipe.RKey; 232 + option.textContent = recipe.Name || recipe.name; 233 + option.className = "truncate"; 234 + if ((recipe.rkey || recipe.RKey) === selectedRecipe) { 235 + option.selected = true; 236 + } 237 + recipeSelect.appendChild(option); 238 + }); 256 239 }); 240 + }, 241 + 242 + /** 243 + * Looks up a brewer's type from the cached brewers array 244 + * @param {string} rkey - The brewer's record key 245 + * @returns {string} The brewer_type value, or empty string if not found 246 + */ 247 + getBrewerType(rkey) { 248 + if (!rkey) return ""; 249 + const brewer = this.brewers.find((b) => (b.rkey || b.RKey) === rkey); 250 + if (!brewer) return ""; 251 + return brewer.brewer_type || brewer.BrewerType || ""; 257 252 }, 258 253 259 254 /**