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: my coffee and dashboard phase 3, 4

authored by

Patrick Dewey and committed by tangled.org 652849a7 eefecb93

+196 -48
+13 -4
internal/handlers/brew.go
··· 727 727 728 728 h.invalidateFeedCache() 729 729 730 - // Redirect to brew list 731 - w.Header().Set("HX-Redirect", "/brews") 730 + // Check if the bean is incomplete and include nudge info in response header. 731 + // The brew form JS reads this before HTMX processes the redirect. 732 + ctx := r.Context() 733 + if beanRKey != "" { 734 + if bean, beanErr := store.GetBeanByRKey(ctx, beanRKey); beanErr == nil && bean != nil && bean.IsIncomplete() { 735 + nudge := fmt.Sprintf(`{"entity_type":"bean","rkey":"%s","name":"%s","missing":"%s"}`, 736 + bean.RKey, bean.Name, strings.Join(bean.MissingFields(), ", ")) 737 + w.Header().Set("X-Incomplete-Nudge", nudge) 738 + } 739 + } 740 + 741 + w.Header().Set("HX-Redirect", "/my-coffee") 732 742 w.WriteHeader(http.StatusOK) 733 743 } 734 744 ··· 827 837 828 838 h.invalidateFeedCache() 829 839 830 - // Redirect to brew list 831 - w.Header().Set("HX-Redirect", "/brews") 840 + w.Header().Set("HX-Redirect", "/my-coffee") 832 841 w.WriteHeader(http.StatusOK) 833 842 } 834 843
+2 -2
internal/web/components/layout.templ
··· 251 251 <script src="/static/js/dropdown-manager.js?v=0.1.0"></script> 252 252 <!-- Load Alpine components BEFORE Alpine.js initializes --> 253 253 <script src="/static/js/theme.js?v=0.1.0"></script> 254 - <script src="/static/js/brew-form.js?v=0.5.0"></script> 254 + <script src="/static/js/brew-form.js?v=0.6.0"></script> 255 255 <script src="/static/js/entity-suggest.js?v=0.1.0"></script> 256 - <script src="/static/js/combo-select.js?v=0.3.0"></script> 256 + <script src="/static/js/combo-select.js?v=0.4.0"></script> 257 257 <!-- Load Alpine.js core with defer (will initialize after DOM loads) --> 258 258 <script src="/static/js/alpine.min.js?v=0.2.0" defer></script> 259 259 <!-- Load HTMX and other utilities -->
+61 -4
internal/web/pages/brew_form.templ
··· 265 265 passthroughStr = "true" 266 266 } 267 267 268 - // Entity-specific formatLabel and formatCreateData 269 - var formatLabel, formatCreateData string 268 + // Entity-specific formatLabel, formatCreateData, and extraFields 269 + var formatLabel, formatCreateData, extraFields string 270 270 switch entityType { 271 271 case "bean": 272 272 formatLabel = `(e) => { const n = e.name || e.Name || ''; const o = e.origin || e.Origin || ''; const r = e.roast_level || e.RoastLevel || ''; if (o && r) return n + ' (' + o + ' - ' + r + ')'; if (o) return n + ' (' + o + ')'; return n; }` 273 273 formatCreateData = `(name, s) => { const d = { name }; if (s && s.fields) { if (s.fields.origin) d.origin = s.fields.origin; if (s.fields.roastLevel) d.roast_level = s.fields.roastLevel; if (s.fields.process) d.process = s.fields.process; } return d; }` 274 + extraFields = `[{name:'origin',label:'Origin',type:'text',placeholder:'e.g. Ethiopia, Colombia'},{name:'roast_level',label:'Roast Level',type:'select',options:['Ultra-Light','Light','Medium-Light','Medium','Medium-Dark','Dark']},{name:'process',label:'Process',type:'text',placeholder:'e.g. Washed, Natural, Honey'}]` 274 275 case "brewer": 275 276 formatLabel = `(e) => e.name || e.Name || ''` 276 277 formatCreateData = `(name, s) => { const d = { name }; if (s && s.fields && s.fields.brewerType) d.brewer_type = s.fields.brewerType; return d; }` 278 + extraFields = `[{name:'brewer_type',label:'Type',type:'select',options:['pourover','espresso','immersion','mokapot','coldbrew','cupping','other']}]` 277 279 case "grinder": 278 280 formatLabel = `(e) => e.name || e.Name || ''` 279 281 formatCreateData = `(name, s) => { const d = { name }; if (s && s.fields) { if (s.fields.grinderType) d.grinder_type = s.fields.grinderType; if (s.fields.burrType) d.burr_type = s.fields.burrType; } return d; }` 282 + extraFields = `[{name:'grinder_type',label:'Type',type:'select',options:['Hand','Electric','Portable Electric']},{name:'burr_type',label:'Burr Type',type:'select',options:['Conical','Flat']}]` 280 283 case "recipe": 281 284 formatLabel = `(e) => { const n = e.name || e.Name || ''; const bt = e.brewer_type || e.BrewerType || (e.fields && e.fields.brewerType) || ''; return bt ? n + ' (' + bt + ')' : n; }` 282 285 formatCreateData = `(name) => ({ name })` 286 + extraFields = `[]` 283 287 default: 284 288 formatLabel = `(e) => e.name || e.Name || ''` 285 289 formatCreateData = `(name) => ({ name })` 290 + extraFields = `[]` 286 291 } 287 292 288 - return fmt.Sprintf(`comboSelect({ entityType: '%s', apiEndpoint: '%s', suggestEndpoint: '%s', inputName: '%s', placeholder: '%s', required: %s, passthrough: %s, formatLabel: %s, formatCreateData: %s })`, 289 - entityType, apiEndpoint, suggestEndpoint, inputName, placeholder, requiredStr, passthroughStr, formatLabel, formatCreateData) 293 + return fmt.Sprintf(`comboSelect({ entityType: '%s', apiEndpoint: '%s', suggestEndpoint: '%s', inputName: '%s', placeholder: '%s', required: %s, passthrough: %s, formatLabel: %s, formatCreateData: %s, extraFields: %s })`, 294 + entityType, apiEndpoint, suggestEndpoint, inputName, placeholder, requiredStr, passthroughStr, formatLabel, formatCreateData, extraFields) 290 295 } 291 296 292 297 // comboSelectInput renders the shared input + dropdown markup for combo-select fields ··· 384 389 <template x-if="allItems.length === 0 && query.trim()"> 385 390 <div class="combo-creating">No matches found</div> 386 391 </template> 392 + </div> 393 + </template> 394 + </div> 395 + <!-- Inline create form with extra details --> 396 + <div x-show="showCreateForm" x-transition x-cloak class="mt-2 p-3 rounded-lg" style="background: var(--surface-bg); border: 1px solid var(--surface-border);"> 397 + <p class="text-sm font-medium text-brown-900 mb-2"> 398 + Creating: <span x-text="createFormData.name" class="font-semibold"></span> 399 + </p> 400 + <template x-if="extraFields.length > 0"> 401 + <div class="space-y-2"> 402 + <template x-for="field in extraFields" :key="field.name"> 403 + <div> 404 + <template x-if="field.type === 'select'"> 405 + <select 406 + x-model="createFormData[field.name]" 407 + class="w-full form-input text-sm" 408 + > 409 + <option value="" x-text="field.label + ' (optional)'"></option> 410 + <template x-for="opt in field.options" :key="opt"> 411 + <option :value="opt" x-text="opt"></option> 412 + </template> 413 + </select> 414 + </template> 415 + <template x-if="field.type === 'text'"> 416 + <input 417 + type="text" 418 + :placeholder="field.placeholder || field.label" 419 + x-model="createFormData[field.name]" 420 + class="w-full form-input text-sm" 421 + /> 422 + </template> 423 + </div> 424 + </template> 425 + <div class="flex gap-2 mt-2"> 426 + <button 427 + type="button" 428 + @click="submitCreateForm()" 429 + class="flex-1 btn-primary text-sm py-1.5" 430 + :disabled="isCreating" 431 + > 432 + <span x-show="!isCreating">Save</span> 433 + <span x-show="isCreating">Saving...</span> 434 + </button> 435 + <button 436 + type="button" 437 + @click="skipCreateDetails()" 438 + class="flex-1 btn-secondary text-sm py-1.5" 439 + :disabled="isCreating" 440 + > 441 + Skip details 442 + </button> 443 + </div> 387 444 </div> 388 445 </template> 389 446 </div>
+16
static/js/brew-form.js
··· 4 4 * Uses shared entity-manager and dropdown-manager modules 5 5 */ 6 6 7 + // Capture incomplete entity nudge from brew save response before HTMX redirect. 8 + // htmx:afterRequest fires after any HTMX request completes, including redirects. 9 + document.addEventListener("htmx:afterRequest", (e) => { 10 + const xhr = e.detail.xhr; 11 + if (xhr) { 12 + const nudge = xhr.getResponseHeader("X-Incomplete-Nudge"); 13 + if (nudge) { 14 + try { 15 + sessionStorage.setItem("incompleteNudge", nudge); 16 + } catch (_) { 17 + // sessionStorage may be unavailable 18 + } 19 + } 20 + } 21 + }); 22 + 7 23 // Wait for Alpine to be available and register the component 8 24 document.addEventListener("alpine:init", () => { 9 25 Alpine.data("brewForm", () => ({
+66 -38
static/js/combo-select.js
··· 16 16 formatCreateData: config.formatCreateData || ((name) => ({ name })), 17 17 required: config.required || false, 18 18 passthrough: config.passthrough || false, 19 + extraFields: config.extraFields || [], 19 20 20 21 // State 21 22 query: "", ··· 24 25 isOpen: false, 25 26 highlightIndex: -1, 26 27 isCreating: false, 28 + 29 + // Inline create form state 30 + showCreateForm: false, 31 + createFormData: {}, 27 32 28 33 // Results 29 34 userResults: [], ··· 209 214 return; 210 215 } 211 216 212 - this.isCreating = true; 213 - try { 214 - const data = this.formatCreateData(suggestion.name, suggestion); 215 - if (suggestion.source_uri) { 216 - data.source_ref = suggestion.source_uri; 217 + // Build data from suggestion fields 218 + const data = this.formatCreateData(suggestion.name, suggestion); 219 + if (suggestion.source_uri) { 220 + data.source_ref = suggestion.source_uri; 221 + } 222 + 223 + // If extraFields configured, show form pre-filled with suggestion data 224 + if (this.extraFields.length > 0) { 225 + this.createFormData = { ...data }; 226 + // Ensure all extra fields have a value (even if empty) 227 + for (const field of this.extraFields) { 228 + if (!(field.name in this.createFormData)) { 229 + this.createFormData[field.name] = ""; 230 + } 217 231 } 218 - const resp = await fetch(this.apiEndpoint, { 219 - method: "POST", 220 - headers: { "Content-Type": "application/json" }, 221 - credentials: "same-origin", 222 - body: JSON.stringify(data), 223 - }); 224 - if (!resp.ok) throw new Error(`Create failed: ${resp.status}`); 225 - const created = await resp.json(); 226 - const rkey = created.rkey || created.RKey; 232 + this.showCreateForm = true; 233 + this.isOpen = false; 234 + return; 235 + } 236 + 237 + await this._doCreate(data); 238 + }, 239 + 240 + // Create a brand new entity — show detail form if extraFields configured 241 + createNew() { 242 + const name = this.query.trim(); 243 + if (!name) return; 227 244 228 - this.selectedRKey = rkey; 229 - this.selectedLabel = suggestion.name; 230 - this.query = suggestion.name; 245 + if (this.extraFields.length > 0) { 246 + this.createFormData = { name }; 247 + for (const field of this.extraFields) { 248 + this.createFormData[field.name] = ""; 249 + } 250 + this.showCreateForm = true; 231 251 this.isOpen = false; 252 + return; 253 + } 232 254 233 - // Invalidate cache so entity appears in future searches 234 - if (window.ArabicaCache) { 235 - window.ArabicaCache.invalidateCache(); 236 - } 255 + this._doCreate({ name }); 256 + }, 257 + 258 + // Submit the inline create form with all details 259 + async submitCreateForm() { 260 + await this._doCreate({ ...this.createFormData }); 261 + this.showCreateForm = false; 262 + this.createFormData = {}; 263 + }, 237 264 238 - this.$nextTick(() => { 239 - this.$dispatch("combo-change", { 240 - entityType: this.entityType, 241 - rkey, 242 - }); 243 - }); 244 - } catch (e) { 245 - console.error("Failed to create from suggestion:", e); 246 - } finally { 247 - this.isCreating = false; 265 + // Skip details — create with just the name (and any suggestion data) 266 + async skipCreateDetails() { 267 + const data = { name: this.createFormData.name }; 268 + if (this.createFormData.source_ref) { 269 + data.source_ref = this.createFormData.source_ref; 248 270 } 271 + this.showCreateForm = false; 272 + this.createFormData = {}; 273 + await this._doCreate(data); 249 274 }, 250 275 251 - // Create a brand new entity with just the name 252 - async createNew() { 253 - const name = this.query.trim(); 254 - if (!name) return; 276 + cancelCreateForm() { 277 + this.showCreateForm = false; 278 + this.createFormData = {}; 279 + }, 255 280 281 + // Internal: perform the actual POST to create the entity 282 + async _doCreate(data) { 256 283 this.isCreating = true; 257 284 try { 258 - const data = this.formatCreateData(name, null); 259 285 const resp = await fetch(this.apiEndpoint, { 260 286 method: "POST", 261 287 headers: { "Content-Type": "application/json" }, ··· 267 293 const rkey = created.rkey || created.RKey; 268 294 269 295 this.selectedRKey = rkey; 270 - this.selectedLabel = name; 271 - this.query = name; 296 + this.selectedLabel = data.name; 297 + this.query = data.name; 272 298 this.isOpen = false; 273 299 274 300 if (window.ArabicaCache) { ··· 315 341 this.selectedRKey = ""; 316 342 this.selectedLabel = ""; 317 343 this.query = ""; 344 + this.showCreateForm = false; 345 + this.createFormData = {}; 318 346 this.$dispatch("combo-change", { 319 347 entityType: this.entityType, 320 348 rkey: "",
+38
static/js/manage-page.js
··· 27 27 28 28 // Initialize entity managers 29 29 this.initEntityManagers(); 30 + 31 + // Check for incomplete entity nudge from brew save 32 + this.showIncompleteNudge(); 33 + }, 34 + 35 + showIncompleteNudge() { 36 + try { 37 + const raw = sessionStorage.getItem("incompleteNudge"); 38 + if (!raw) return; 39 + sessionStorage.removeItem("incompleteNudge"); 40 + const nudge = JSON.parse(raw); 41 + if (!nudge.name || !nudge.missing) return; 42 + 43 + // Create toast element 44 + const toast = document.createElement("div"); 45 + toast.className = 46 + "fixed bottom-6 left-1/2 -translate-x-1/2 z-50 max-w-md w-full mx-4 p-4 rounded-lg shadow-lg flex items-center gap-3"; 47 + toast.style.cssText = 48 + "background: var(--card-bg, #fff); border: 1px solid var(--surface-border, #d4c4a8); color: var(--text-primary, #3e2723);"; 49 + toast.innerHTML = ` 50 + <div class="flex-1 text-sm"> 51 + <strong>${nudge.name}</strong> is missing ${nudge.missing} 52 + </div> 53 + <button class="text-sm font-medium hover:opacity-80 whitespace-nowrap" style="color: var(--accent-primary, #5d4037);" 54 + onclick="this.closest('div').remove(); document.querySelector('#modal-container') && htmx.ajax('GET', '/api/modals/${nudge.entity_type}/${nudge.rkey}', {target: '#modal-container', swap: 'innerHTML'});"> 55 + Complete 56 + </button> 57 + <button class="text-brown-400 hover:text-brown-600" onclick="this.closest('div').remove();"> 58 + <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg> 59 + </button> 60 + `; 61 + document.body.appendChild(toast); 62 + 63 + // Auto-dismiss after 10 seconds 64 + setTimeout(() => toast.remove(), 10000); 65 + } catch (_) { 66 + // Ignore errors 67 + } 30 68 }, 31 69 32 70 initEntityManagers() {