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.

fix: add roaster to bean creation details in brew form

+232 -1
+88
internal/web/components/combo_select.templ
··· 186 186 </template> 187 187 </div> 188 188 </template> 189 + <!-- Roaster picker (bean only) --> 190 + <template x-if="entityType === 'bean'"> 191 + <div class="mt-1"> 192 + <div class="relative"> 193 + <input 194 + type="text" 195 + x-model="roasterQuery" 196 + @input="searchRoasters(); roasterDropdownOpen = true" 197 + @focus="searchRoasters(); roasterDropdownOpen = true" 198 + @blur="setTimeout(() => roasterDropdownOpen = false, 150)" 199 + @keydown.escape.prevent="roasterDropdownOpen = false" 200 + placeholder="Roaster (optional)" 201 + class="w-full form-input text-sm" 202 + autocomplete="off" 203 + /> 204 + <button 205 + type="button" 206 + x-show="selectedRoasterRKey || creatingNewRoaster" 207 + @click="clearRoaster()" 208 + class="absolute right-2 top-1/2 -translate-y-1/2 text-brown-400 hover:text-brown-600" 209 + x-cloak 210 + > 211 + @IconX() 212 + </button> 213 + <!-- Roaster dropdown --> 214 + <div x-show="roasterDropdownOpen && (roasterResults.length > 0 || roasterSuggestions.length > 0 || roasterQuery.trim())" x-cloak class="combo-dropdown" @mousedown.prevent> 215 + <template x-if="roasterResults.length > 0"> 216 + <div> 217 + <div class="combo-section-label">Your roasters</div> 218 + <template x-for="(r, ri) in roasterResults" :key="r.rkey || r.RKey"> 219 + <div 220 + class="combo-item" 221 + @click="selectRoaster(r)" 222 + > 223 + <span x-text="r.name || r.Name"></span> 224 + </div> 225 + </template> 226 + </div> 227 + </template> 228 + <template x-if="roasterSuggestions.length > 0"> 229 + <div> 230 + <div class="combo-section-label">Community</div> 231 + <template x-for="(s, si) in roasterSuggestions" :key="s.source_uri || si"> 232 + <div 233 + class="combo-item" 234 + @click="selectRoasterSuggestion(s)" 235 + > 236 + <div x-text="s.name"></div> 237 + <div class="combo-item-sub"> 238 + <span x-show="s.fields?.location" x-text="s.fields?.location"></span> 239 + <span x-show="s.count > 1" x-text="' · ' + s.count + ' users'"></span> 240 + </div> 241 + </div> 242 + </template> 243 + </div> 244 + </template> 245 + <template x-if="roasterQuery.trim() && !roasterResults.some(r => (r.name || r.Name || '').toLowerCase() === roasterQuery.trim().toLowerCase()) && !roasterSuggestions.some(s => (s.name || '').toLowerCase() === roasterQuery.trim().toLowerCase())"> 246 + <div 247 + class="combo-item-create" 248 + @click="startCreateRoaster()" 249 + > 250 + Create "<span x-text="roasterQuery.trim()"></span>" 251 + </div> 252 + </template> 253 + </div> 254 + </div> 255 + <!-- New roaster detail fields --> 256 + <template x-if="creatingNewRoaster"> 257 + <div class="mt-2 ml-3 space-y-2 border-l-2 pl-3" style="border-color: var(--surface-border);"> 258 + <p class="text-xs text-brown-500"> 259 + New roaster: <span x-text="newRoasterName" class="font-medium"></span> 260 + </p> 261 + <input 262 + type="text" 263 + x-model="newRoasterLocation" 264 + placeholder="Location (optional)" 265 + class="w-full form-input text-sm" 266 + /> 267 + <input 268 + type="text" 269 + x-model="newRoasterWebsite" 270 + placeholder="Website (optional)" 271 + class="w-full form-input text-sm" 272 + /> 273 + </div> 274 + </template> 275 + </div> 276 + </template> 189 277 <div class="flex gap-2 mt-2"> 190 278 <button 191 279 type="button"
+2
static/css/app.css
··· 651 651 .modal-content { 652 652 background: var(--modal-bg); 653 653 border: 1px solid var(--modal-border); 654 + color: var(--text-primary); 654 655 @apply rounded-xl p-6 max-w-md w-full max-h-[90vh] overflow-y-auto; 655 656 box-shadow: var(--shadow-lg); 656 657 } ··· 673 674 .modal-dialog .modal-content { 674 675 background: var(--modal-bg); 675 676 border: 1px solid var(--modal-border); 677 + color: var(--text-primary); 676 678 @apply rounded-xl p-6 w-full max-h-[90vh] overflow-y-auto; 677 679 box-shadow: var(--shadow-lg); 678 680 }
+142 -1
static/js/combo-select.js
··· 30 30 showCreateForm: false, 31 31 createFormData: {}, 32 32 33 + // Roaster picker state (for bean inline creation) 34 + roasterQuery: "", 35 + roasterResults: [], 36 + roasterSuggestions: [], 37 + roasterDropdownOpen: false, 38 + selectedRoasterRKey: "", 39 + selectedRoasterLabel: "", 40 + creatingNewRoaster: false, 41 + newRoasterName: "", 42 + newRoasterLocation: "", 43 + newRoasterWebsite: "", 44 + _roasterSuggestTimer: null, 45 + 33 46 // Results 34 47 userResults: [], 35 48 closedResults: [], // Closed beans (only for bean entity type) ··· 259 272 260 273 // Submit the inline create form with all details 261 274 async submitCreateForm() { 262 - await this._doCreate({ ...this.createFormData }); 275 + const data = { ...this.createFormData }; 276 + 277 + // For beans: handle roaster creation/selection 278 + if (this.entityType === "bean") { 279 + if (this.selectedRoasterRKey) { 280 + data.roaster_rkey = this.selectedRoasterRKey; 281 + } else if (this.creatingNewRoaster && this.newRoasterName) { 282 + try { 283 + const resp = await fetch("/api/roasters", { 284 + method: "POST", 285 + headers: { "Content-Type": "application/json" }, 286 + credentials: "same-origin", 287 + body: JSON.stringify({ 288 + name: this.newRoasterName, 289 + location: this.newRoasterLocation, 290 + website: this.newRoasterWebsite, 291 + }), 292 + }); 293 + if (!resp.ok) throw new Error("Failed to create roaster"); 294 + const roaster = await resp.json(); 295 + data.roaster_rkey = roaster.rkey || roaster.RKey; 296 + } catch (e) { 297 + console.error("Roaster creation failed:", e); 298 + return; 299 + } 300 + } 301 + } 302 + 303 + await this._doCreate(data); 263 304 this.showCreateForm = false; 264 305 this.createFormData = {}; 306 + this.resetRoasterPicker(); 265 307 }, 266 308 267 309 // Skip details — create with just the name (and any suggestion data) ··· 272 314 } 273 315 this.showCreateForm = false; 274 316 this.createFormData = {}; 317 + this.resetRoasterPicker(); 275 318 await this._doCreate(data); 276 319 }, 277 320 278 321 cancelCreateForm() { 279 322 this.showCreateForm = false; 280 323 this.createFormData = {}; 324 + this.resetRoasterPicker(); 325 + }, 326 + 327 + // Roaster picker methods (for bean inline creation) 328 + searchRoasters() { 329 + const q = this.roasterQuery.trim().toLowerCase(); 330 + const roasters = 331 + (window.ArabicaCache?.getCachedData?.() || {}).roasters || []; 332 + if (!q) { 333 + this.roasterResults = roasters.slice(0, 8); 334 + } else { 335 + this.roasterResults = roasters.filter((r) => 336 + (r.name || r.Name || "").toLowerCase().includes(q), 337 + ); 338 + } 339 + this.selectedRoasterRKey = ""; 340 + this.selectedRoasterLabel = ""; 341 + this.creatingNewRoaster = false; 342 + this.newRoasterName = ""; 343 + 344 + // Debounced community suggestions 345 + clearTimeout(this._roasterSuggestTimer); 346 + if (q.length >= 2) { 347 + this._roasterSuggestTimer = setTimeout(() => { 348 + this.fetchRoasterSuggestions(q); 349 + }, 400); 350 + } else { 351 + this.roasterSuggestions = []; 352 + } 353 + }, 354 + 355 + async fetchRoasterSuggestions(q) { 356 + try { 357 + const resp = await fetch( 358 + `/api/suggestions/roasters?q=${encodeURIComponent(q)}&limit=5`, 359 + { credentials: "same-origin" }, 360 + ); 361 + if (resp.ok) { 362 + const data = await resp.json(); 363 + const roasters = 364 + (window.ArabicaCache?.getCachedData?.() || {}).roasters || []; 365 + const ownNames = new Set( 366 + roasters.map((r) => (r.name || r.Name || "").toLowerCase()), 367 + ); 368 + this.roasterSuggestions = (data || []).filter( 369 + (s) => !ownNames.has((s.name || "").toLowerCase()), 370 + ); 371 + } 372 + } catch (e) { 373 + console.error("Roaster suggestion fetch failed:", e); 374 + } 375 + }, 376 + 377 + selectRoaster(roaster) { 378 + this.selectedRoasterRKey = roaster.rkey || roaster.RKey; 379 + this.selectedRoasterLabel = roaster.name || roaster.Name || ""; 380 + this.roasterQuery = this.selectedRoasterLabel; 381 + this.roasterDropdownOpen = false; 382 + this.roasterSuggestions = []; 383 + this.creatingNewRoaster = false; 384 + }, 385 + 386 + selectRoasterSuggestion(suggestion) { 387 + // Pre-fill new roaster from community suggestion 388 + this.newRoasterName = suggestion.name || ""; 389 + this.newRoasterLocation = 390 + (suggestion.fields && suggestion.fields.location) || ""; 391 + this.newRoasterWebsite = 392 + (suggestion.fields && suggestion.fields.website) || ""; 393 + this.selectedRoasterRKey = ""; 394 + this.roasterQuery = suggestion.name || ""; 395 + this.creatingNewRoaster = true; 396 + this.roasterDropdownOpen = false; 397 + this.roasterSuggestions = []; 398 + }, 399 + 400 + startCreateRoaster() { 401 + this.newRoasterName = this.roasterQuery.trim(); 402 + this.selectedRoasterRKey = ""; 403 + this.creatingNewRoaster = true; 404 + this.roasterDropdownOpen = false; 405 + }, 406 + 407 + clearRoaster() { 408 + this.roasterQuery = ""; 409 + this.roasterResults = []; 410 + this.roasterSuggestions = []; 411 + this.selectedRoasterRKey = ""; 412 + this.selectedRoasterLabel = ""; 413 + this.creatingNewRoaster = false; 414 + this.newRoasterName = ""; 415 + this.newRoasterLocation = ""; 416 + this.newRoasterWebsite = ""; 417 + this.roasterDropdownOpen = false; 418 + }, 419 + 420 + resetRoasterPicker() { 421 + this.clearRoaster(); 281 422 }, 282 423 283 424 // Internal: perform the actual POST to create the entity