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: feed-card style recipes on explore page

authored by

Patrick Dewey and committed by tangled.org c5718f20 84a6a229

+697 -76
+561
docs/recipes.md
··· 1 + # Recipe Evolution: From Structured to Freeform 2 + 3 + ## Problem 4 + 5 + Recipes today are pourover templates. They assume a fixed set of parameters 6 + (coffee amount, water amount, grind size, brewer, pours) that don't generalize 7 + to espresso, AeroPress, cold brew, milk drinks, or novel methods. Users can't 8 + express "steam milk to 65C" or "pull a 1:2 shot in 28s" within the current 9 + structure. 10 + 11 + The goal: let power users build custom recipes from composable parts, while 12 + keeping simple recipes simple and preserving the ability to filter/compare 13 + across recipes. 14 + 15 + ## Design Axes 16 + 17 + Two tensions shape the design space: 18 + 19 + 1. **Structure vs. Freedom** — Fixed fields enable queries and comparison. 20 + Freeform fields enable creativity and method diversity. 21 + 2. **Simple vs. Power** — A basic user wants to log "18g in, 300g out, medium 22 + grind." A power user wants timed pour sequences, temperature profiles, and 23 + milk steaming steps. 24 + 25 + The sweet spot is a design that serves both without forcing either into the 26 + other's workflow. 27 + 28 + ## The Spectrum 29 + 30 + ### Level 0: What We Have Now 31 + 32 + ``` 33 + name, brewerRef, coffeeAmount, waterAmount, grindSize, pours[], notes 34 + ``` 35 + 36 + Fixed schema. Works for pourover. Breaks for everything else. Pours are the 37 + only "composable" element, and they're locked to water amount + time. 38 + 39 + ### Level 1: Core + Extensions 40 + 41 + Keep the fields that are universal to nearly all coffee preparation, and add an 42 + open union array for everything else. 43 + 44 + **Core fields (always present, queryable):** 45 + - `name` — recipe name 46 + - `coffeeAmount` — dose in tenths of grams (universal to all methods) 47 + - `notes` — freeform text 48 + - `sourceRef` — fork provenance 49 + 50 + **Optional structured fields (queryable when present):** 51 + - `waterAmount` — total water (most methods, but not all — e.g., espresso 52 + yield is measured differently) 53 + - `grindSize` — text or numeric 54 + - `brewerRef` / `brewerType` — gear reference 55 + 56 + **Extensions (open union array):** 57 + ```json 58 + "extensions": { 59 + "type": "array", 60 + "items": { 61 + "type": "union", 62 + "refs": [ 63 + "#pourStep", 64 + "#waitStep", 65 + "#tempStep", 66 + "#pressStep", 67 + "#steamStep", 68 + "#textParam", 69 + "#gearRef" 70 + ] 71 + } 72 + } 73 + ``` 74 + 75 + This is the most conservative evolution. Simple recipes look identical to 76 + today. Power users bolt on steps and parameters. Filtering works on core 77 + fields. The split between "core" and "extension" can feel arbitrary though — 78 + why is grind size a core field but brew temperature isn't? 79 + 80 + **Good for:** incremental migration, backwards compat, keeping simple things 81 + simple. 82 + 83 + ### Level 2: Minimal Core + Rich Parameters 84 + 85 + Shrink the core to just what's truly universal, and push everything else into 86 + typed parameter blocks. 87 + 88 + **Core fields:** 89 + - `name` 90 + - `coffeeAmount` — the one thing every coffee recipe has 91 + - `notes` 92 + - `sourceRef` 93 + 94 + **Parameters (open union array):** 95 + ```json 96 + "parameters": { 97 + "type": "array", 98 + "items": { 99 + "type": "union", 100 + "refs": [ 101 + "#weightParam", 102 + "#ratioParam", 103 + "#tempParam", 104 + "#timeParam", 105 + "#textParam", 106 + "#gearRef", 107 + "#ingredientRef" 108 + ] 109 + } 110 + } 111 + ``` 112 + 113 + Each parameter carries a `label` (user-defined display name) and typed value: 114 + 115 + | Type | Fields | Example | 116 + |------------------|-------------------------------|--------------------------------| 117 + | `#weightParam` | `label`, `grams` (int/10ths) | "Water": 3000 (= 300.0g) | 118 + | `#ratioParam` | `label`, `ratio` (float-ish) | "Brew Ratio": 16.7 | 119 + | `#tempParam` | `label`, `celsius` (int/10th) | "Brew Temp": 930 (= 93.0C) | 120 + | `#timeParam` | `label`, `seconds` (int) | "Bloom Time": 45 | 121 + | `#textParam` | `label`, `value` (string) | "Grind": "18 clicks on C40" | 122 + | `#gearRef` | `label`, `ref` (at-uri) | "Brewer": at://did/collection/rkey | 123 + | `#ingredientRef` | `label`, `ref` (at-uri) | "Bean": at://did/collection/rkey | 124 + 125 + **Steps (separate open union array for process):** 126 + ```json 127 + "steps": { 128 + "type": "array", 129 + "items": { 130 + "type": "union", 131 + "refs": [ 132 + "#pourStep", 133 + "#waitStep", 134 + "#stirStep", 135 + "#pressStep", 136 + "#steamStep", 137 + "#customStep" 138 + ] 139 + } 140 + } 141 + ``` 142 + 143 + Steps are ordered and describe the process: 144 + 145 + | Type | Fields | Example | 146 + |---------------|-------------------------------------|----------------------------| 147 + | `#pourStep` | `label?`, `grams`, `seconds` | Bloom: 50g at 0:00 | 148 + | `#waitStep` | `label?`, `seconds` | Wait 45s | 149 + | `#stirStep` | `label?`, `technique?` | Rao spin | 150 + | `#pressStep` | `label?`, `seconds?` | Plunge over 30s | 151 + | `#steamStep` | `label?`, `milkType?`, `tempC?` | Steam oat milk to 65C | 152 + | `#customStep` | `label`, `description` | "Swirl the V60 3 times" | 153 + 154 + This gives you two dimensions: **what goes in** (parameters) and **what you do** 155 + (steps). A pourover recipe might have 3 parameters and 5 steps. An espresso 156 + recipe might have 6 parameters and 1 step. A milk drink might chain espresso 157 + extraction into steaming into latte art. 158 + 159 + **Good for:** method diversity, power users, recipe builder UI. 160 + 161 + ### Level 3: Everything is a Facet 162 + 163 + The most freeform option. No core fields beyond `name`. The recipe is a bag of 164 + typed facets, each one self-describing. 165 + 166 + ```json 167 + { 168 + "name": "Morning Espresso", 169 + "facets": [ 170 + { "$type": "#weightParam", "label": "Dose", "grams": 180 }, 171 + { "$type": "#weightParam", "label": "Yield", "grams": 360 }, 172 + { "$type": "#timeParam", "label": "Shot Time", "seconds": 28 }, 173 + { "$type": "#tempParam", "label": "Brew Temp", "celsius": 930 }, 174 + { "$type": "#gearRef", "label": "Machine", "ref": "at://..." }, 175 + { "$type": "#textParam", "label": "Grind", "value": "Setting 2.5" }, 176 + { "$type": "#steamStep", "label": "Milk", "milkType": "Oat", "tempC": 650 }, 177 + { "$type": "#customStep", "label": "Latte Art", "description": "Tulip" } 178 + ], 179 + "notes": "Pull shot first, steam while extracting", 180 + "sourceRef": "at://..." 181 + } 182 + ``` 183 + 184 + Maximum flexibility. But you lose the ability to query "all recipes with >15g 185 + dose" unless you define conventions about label names, which is fragile. Also 186 + mixes parameters and process into one flat list — rendering order matters but 187 + semantic grouping is lost. 188 + 189 + **Good for:** maximum creative freedom. **Bad for:** filtering, comparison, 190 + consistent UI. 191 + 192 + ## Recommended Approach: Level 2 Hybrid 193 + 194 + Level 2 hits the sweet spot. Here's why: 195 + 196 + ### coffeeAmount stays in core 197 + 198 + Every coffee recipe starts with a dose. Keeping it as a fixed field means: 199 + - Explore page can filter by dose range 200 + - Ratio computation works reliably (coffeeAmount + a waterAmount param or a 201 + ratioParam) 202 + - Simple recipes need zero extensions — just fill in the core 203 + 204 + ### waterAmount moves to a parameter (with a convention) 205 + 206 + Water/yield is method-dependent. For pourover it's total water. For espresso 207 + it's liquid yield. For cold brew it's steep water. Making it a `weightParam` 208 + with a conventional label handles all of these, but you lose trivial ratio 209 + computation unless the UI knows to look for the first `weightParam` or a 210 + `ratioParam`. 211 + 212 + **Alternative:** keep `waterAmount` in core too. It's *almost* universal, and 213 + having both dose and water in core makes ratio filtering work everywhere. The 214 + only methods where it's awkward are Turkish coffee (no measured water) and 215 + cupping — edge cases you can ignore for now. 216 + 217 + ### Parameters are the "what" 218 + 219 + Typed building blocks for inputs: grind settings, temperatures, gear 220 + references, ingredient refs. The type system means the UI can render 221 + appropriate inputs (number spinner for weight, temperature picker for temp, 222 + gear selector for refs). 223 + 224 + ### Steps are the "how" 225 + 226 + Ordered process instructions. Pour schedules, wait times, stir techniques, 227 + press actions. Having steps separate from parameters means you can show them 228 + differently in the UI — parameters in a summary card, steps in a timeline. 229 + 230 + ### The customStep / textParam escape hatches 231 + 232 + Power users can always add a `customStep` or `textParam` when the typed 233 + options don't cover their needs. This prevents the system from being limiting 234 + while still encouraging structured data when it fits. 235 + 236 + ## What This Looks Like in Practice 237 + 238 + ### Simple Pourover (basic user) 239 + 240 + Core fields only, no extensions needed: 241 + 242 + ``` 243 + name: "Daily V60" 244 + coffeeAmount: 180 (18.0g) 245 + waterAmount: 3000 (300.0g) [if kept in core] 246 + grindSize: "Medium-Fine" 247 + brewerRef: at://did/...brewer/abc 248 + notes: "Standard recipe, nothing fancy" 249 + ``` 250 + 251 + Identical to today. Zero learning curve. 252 + 253 + ### Detailed Pourover (power user) 254 + 255 + Core fields plus steps: 256 + 257 + ``` 258 + name: "Hoffmann V60" 259 + coffeeAmount: 150 (15.0g) 260 + waterAmount: 2500 (250.0g) 261 + grindSize: "Medium" 262 + brewerRef: at://did/...brewer/abc 263 + 264 + parameters: 265 + tempParam("Water Temp", 950) (95.0C) 266 + gearRef("Grinder", at://did/...grinder/xyz) 267 + textParam("Filter", "Cafec Abaca") 268 + 269 + steps: 270 + pourStep("Bloom", 50g, 0s) 271 + waitStep("Bloom Wait", 45s) 272 + pourStep("Main Pour", 200g, 45s) 273 + stirStep("Swirl") 274 + waitStep("Drawdown", 60s) 275 + ``` 276 + 277 + ### Espresso 278 + 279 + ``` 280 + name: "Morning Shot" 281 + coffeeAmount: 180 (18.0g) 282 + 283 + parameters: 284 + weightParam("Yield", 360) (36.0g) 285 + timeParam("Shot Time", 28) 286 + tempParam("Brew Temp", 930) 287 + gearRef("Machine", at://did/...brewer/abc) 288 + textParam("Grind", "2.5 on Niche") 289 + 290 + steps: 291 + customStep("Prep", "WDT, tamp level, pull shot") 292 + ``` 293 + 294 + ### Oat Latte 295 + 296 + ``` 297 + name: "Oat Flat White" 298 + coffeeAmount: 180 (18.0g) 299 + 300 + parameters: 301 + weightParam("Yield", 360) 302 + timeParam("Shot Time", 28) 303 + gearRef("Machine", at://did/...brewer/abc) 304 + ingredientRef("Bean", at://did/...bean/xyz) 305 + 306 + steps: 307 + extractStep("Pull Shot") 308 + steamStep("Steam Milk", milkType: "Oat", tempC: 650) 309 + customStep("Pour", "Flat white dot pattern") 310 + ``` 311 + 312 + ### Cold Brew 313 + 314 + ``` 315 + name: "Weekend Cold Brew" 316 + coffeeAmount: 700 (70.0g) 317 + 318 + parameters: 319 + weightParam("Water", 7000) 320 + textParam("Grind", "Coarse") 321 + tempParam("Steep Temp", 40) (4.0C / fridge) 322 + timeParam("Steep Time", 57600) (16 hours) 323 + 324 + steps: 325 + customStep("Combine", "Add grounds to jar, pour water, stir") 326 + waitStep("Steep", 57600) 327 + customStep("Filter", "Strain through Chemex filter, dilute 1:1") 328 + ``` 329 + 330 + ## Recipe Builder UI 331 + 332 + The UI becomes a two-panel recipe builder: 333 + 334 + **Left panel: Parameters** 335 + - Coffee amount always visible (core) 336 + - Water amount visible by default (core or auto-added param) 337 + - "Add parameter" dropdown: Weight, Temp, Time, Text, Gear, Ingredient 338 + - Each parameter rendered with appropriate input for its type 339 + - Drag to reorder 340 + 341 + **Right panel: Steps (optional)** 342 + - "Add step" dropdown: Pour, Wait, Stir, Press, Steam, Custom 343 + - Each step rendered as a timeline card 344 + - Drag to reorder 345 + - Collapse/expand for complex recipes 346 + 347 + **Simple mode:** Just the core fields — name, dose, water, grind, brewer, 348 + notes. No parameters panel, no steps panel. Looks like today's form. 349 + 350 + **Power mode:** Toggle or auto-expand when user adds first parameter or step. 351 + Or just always show the "Add parameter" / "Add step" buttons below the core 352 + fields. 353 + 354 + ## Migration 355 + 356 + The current lexicon can evolve to Level 2 without breaking existing records: 357 + 358 + 1. Existing fields (`coffeeAmount`, `waterAmount`, `grindSize`, `brewerRef`, 359 + `brewerType`, `pours`) remain and continue to work 360 + 2. Add `parameters` and `steps` as new optional array fields 361 + 3. Existing `pours` could be deprecated in favor of `steps` with `#pourStep`, 362 + but old records with `pours` still parse fine 363 + 4. New UI writes to both `pours` (backwards compat) and `steps` (new format) 364 + during transition, then drops `pours` in a future version 365 + 366 + No data migration needed. Old records just lack the new fields. 367 + 368 + ## Lexicon Sketch 369 + 370 + ```json 371 + { 372 + "lexicon": 1, 373 + "id": "social.arabica.alpha.recipe", 374 + "defs": { 375 + "main": { 376 + "type": "record", 377 + "key": "tid", 378 + "record": { 379 + "type": "object", 380 + "required": ["name", "createdAt"], 381 + "properties": { 382 + "name": { "type": "string", "maxLength": 200 }, 383 + "coffeeAmount": { "type": "integer", "minimum": 0 }, 384 + "waterAmount": { "type": "integer", "minimum": 0 }, 385 + "grindSize": { "type": "string", "maxLength": 100 }, 386 + "brewerRef": { "type": "string", "format": "at-uri" }, 387 + "brewerType": { "type": "string", "maxLength": 100 }, 388 + "parameters": { 389 + "type": "array", 390 + "maxLength": 20, 391 + "items": { "type": "union", "refs": [ 392 + "#weightParam", "#ratioParam", "#tempParam", 393 + "#timeParam", "#textParam", "#gearRef", "#ingredientRef" 394 + ]} 395 + }, 396 + "steps": { 397 + "type": "array", 398 + "maxLength": 30, 399 + "items": { "type": "union", "refs": [ 400 + "#pourStep", "#waitStep", "#stirStep", 401 + "#pressStep", "#steamStep", "#customStep" 402 + ]} 403 + }, 404 + "pours": { 405 + "type": "array", 406 + "description": "[DEPRECATED] Use steps with #pourStep instead", 407 + "items": { "type": "ref", "ref": "#pour" } 408 + }, 409 + "notes": { "type": "string", "maxLength": 2000 }, 410 + "sourceRef": { "type": "string", "format": "at-uri" }, 411 + "createdAt": { "type": "string", "format": "datetime" } 412 + } 413 + } 414 + }, 415 + 416 + "pour": { 417 + "type": "object", 418 + "description": "[DEPRECATED] Legacy pour format", 419 + "required": ["waterAmount", "timeSeconds"], 420 + "properties": { 421 + "waterAmount": { "type": "integer", "minimum": 0 }, 422 + "timeSeconds": { "type": "integer", "minimum": 0 } 423 + } 424 + }, 425 + 426 + "weightParam": { 427 + "type": "object", 428 + "required": ["label", "grams"], 429 + "properties": { 430 + "label": { "type": "string", "maxLength": 50 }, 431 + "grams": { "type": "integer", "minimum": 0, 432 + "description": "Weight in tenths of grams" } 433 + } 434 + }, 435 + "ratioParam": { 436 + "type": "object", 437 + "required": ["label", "ratio"], 438 + "properties": { 439 + "label": { "type": "string", "maxLength": 50 }, 440 + "ratio": { "type": "integer", "minimum": 0, 441 + "description": "Ratio in tenths (167 = 1:16.7)" } 442 + } 443 + }, 444 + "tempParam": { 445 + "type": "object", 446 + "required": ["label", "celsius"], 447 + "properties": { 448 + "label": { "type": "string", "maxLength": 50 }, 449 + "celsius": { "type": "integer", "minimum": 0, 450 + "description": "Temp in tenths of degrees C" } 451 + } 452 + }, 453 + "timeParam": { 454 + "type": "object", 455 + "required": ["label", "seconds"], 456 + "properties": { 457 + "label": { "type": "string", "maxLength": 50 }, 458 + "seconds": { "type": "integer", "minimum": 0 } 459 + } 460 + }, 461 + "textParam": { 462 + "type": "object", 463 + "required": ["label", "value"], 464 + "properties": { 465 + "label": { "type": "string", "maxLength": 50 }, 466 + "value": { "type": "string", "maxLength": 500 } 467 + } 468 + }, 469 + "gearRef": { 470 + "type": "object", 471 + "required": ["label", "ref"], 472 + "properties": { 473 + "label": { "type": "string", "maxLength": 50 }, 474 + "ref": { "type": "string", "format": "at-uri" } 475 + } 476 + }, 477 + "ingredientRef": { 478 + "type": "object", 479 + "required": ["label", "ref"], 480 + "properties": { 481 + "label": { "type": "string", "maxLength": 50 }, 482 + "ref": { "type": "string", "format": "at-uri" } 483 + } 484 + }, 485 + 486 + "pourStep": { 487 + "type": "object", 488 + "required": ["grams", "seconds"], 489 + "properties": { 490 + "label": { "type": "string", "maxLength": 50 }, 491 + "grams": { "type": "integer", "minimum": 0 }, 492 + "seconds": { "type": "integer", "minimum": 0 } 493 + } 494 + }, 495 + "waitStep": { 496 + "type": "object", 497 + "required": ["seconds"], 498 + "properties": { 499 + "label": { "type": "string", "maxLength": 50 }, 500 + "seconds": { "type": "integer", "minimum": 0 } 501 + } 502 + }, 503 + "stirStep": { 504 + "type": "object", 505 + "properties": { 506 + "label": { "type": "string", "maxLength": 50 }, 507 + "technique": { "type": "string", "maxLength": 200 } 508 + } 509 + }, 510 + "pressStep": { 511 + "type": "object", 512 + "properties": { 513 + "label": { "type": "string", "maxLength": 50 }, 514 + "seconds": { "type": "integer", "minimum": 0 } 515 + } 516 + }, 517 + "steamStep": { 518 + "type": "object", 519 + "properties": { 520 + "label": { "type": "string", "maxLength": 50 }, 521 + "milkType": { "type": "string", "maxLength": 100 }, 522 + "tempCelsius": { "type": "integer", "minimum": 0, 523 + "description": "Tenths of degrees C" } 524 + } 525 + }, 526 + "customStep": { 527 + "type": "object", 528 + "required": ["label", "description"], 529 + "properties": { 530 + "label": { "type": "string", "maxLength": 50 }, 531 + "description": { "type": "string", "maxLength": 500 } 532 + } 533 + } 534 + } 535 + } 536 + ``` 537 + 538 + ## Open Questions 539 + 540 + 1. **Should `waterAmount` stay in core?** It makes ratio filtering trivial but 541 + is awkward for espresso (where "yield" is the output measurement, not input 542 + water). Could keep it in core with the understanding that for espresso 543 + recipes, a `weightParam("Yield", ...)` is the meaningful number and 544 + `waterAmount` is omitted. 545 + 546 + 2. **Parameter ordering** — should the array order be meaningful (display 547 + order) or should the UI sort by type? Leaning toward array order = display 548 + order, since users will arrange their recipe builder intentionally. 549 + 550 + 3. **Step timing model** — current pours use absolute time (seconds from brew 551 + start). Steps could use relative time (duration of this step) or absolute. 552 + Relative is simpler for the user; absolute is easier for a timer UI. 553 + 554 + 4. **Preset templates** — should there be a `method` or `template` field that 555 + pre-populates parameters and steps? e.g., selecting "Espresso" auto-adds 556 + dose/yield/time/temp params. This is purely a UI concern, not a lexicon one. 557 + 558 + 5. **Brew integration** — brews currently reference a recipe. With freeform 559 + recipes, should a brew record capture the *snapshot* of parameters used, or 560 + just reference the recipe? Snapshot is more accurate (recipe might change), 561 + reference is lighter.
+11
docs/recipes.norg
··· 75 75 For links between a brew and recipe, which should have the optional ref? 76 76 Probably brew, but should a recipe contain a ref to the original brew that it 77 77 /may/ have been saved from. 78 + 79 + ** Alternate Recipe Design 80 + 81 + Recipes only contain minimal structure and are more freeform, (build a custom 82 + recipe), with just a content field in the lexicon that contains arbitrary 83 + content that can be customized as desired. This leads to more utility that 84 + what just a pourover recipe provides (e.g. maybe milk drink stuff for 85 + espresso?). 86 + 87 + This would require some sort of recipe customizer page that allows adding 88 + arbitrary key-value pairs (or pours/refs to gear/beans).
+119 -72
internal/web/pages/recipe_explore.templ
··· 124 124 <!-- Results --> 125 125 <div> 126 126 <template x-if="loading"> 127 - @components.LoadingSkeletonTable(components.LoadingSkeletonTableProps{Columns: 7, Rows: 3}) 127 + <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> 128 + <div class="feed-card animate-pulse"> 129 + <div class="h-4 bg-brown-200 rounded w-1/3 mb-3"></div> 130 + <div class="h-5 bg-brown-200 rounded w-2/3 mb-2"></div> 131 + <div class="h-4 bg-brown-200 rounded w-1/2 mb-3"></div> 132 + <div class="grid grid-cols-3 gap-2"> 133 + <div class="h-12 bg-brown-100 rounded"></div> 134 + <div class="h-12 bg-brown-100 rounded"></div> 135 + <div class="h-12 bg-brown-100 rounded"></div> 136 + </div> 137 + </div> 138 + <div class="feed-card animate-pulse hidden sm:block"> 139 + <div class="h-4 bg-brown-200 rounded w-1/3 mb-3"></div> 140 + <div class="h-5 bg-brown-200 rounded w-2/3 mb-2"></div> 141 + <div class="h-4 bg-brown-200 rounded w-1/2 mb-3"></div> 142 + <div class="grid grid-cols-3 gap-2"> 143 + <div class="h-12 bg-brown-100 rounded"></div> 144 + <div class="h-12 bg-brown-100 rounded"></div> 145 + <div class="h-12 bg-brown-100 rounded"></div> 146 + </div> 147 + </div> 148 + <div class="feed-card animate-pulse hidden lg:block"> 149 + <div class="h-4 bg-brown-200 rounded w-1/3 mb-3"></div> 150 + <div class="h-5 bg-brown-200 rounded w-2/3 mb-2"></div> 151 + <div class="h-4 bg-brown-200 rounded w-1/2 mb-3"></div> 152 + <div class="grid grid-cols-3 gap-2"> 153 + <div class="h-12 bg-brown-100 rounded"></div> 154 + <div class="h-12 bg-brown-100 rounded"></div> 155 + <div class="h-12 bg-brown-100 rounded"></div> 156 + </div> 157 + </div> 158 + </div> 128 159 </template> 129 160 <template x-if="!loading && recipes.length === 0"> 130 161 <div class="card card-inner text-center py-8"> ··· 133 164 </div> 134 165 </template> 135 166 <template x-if="!loading && recipes.length > 0"> 136 - <div class="card card-inner"> 167 + <div> 137 168 <p class="text-sm text-brown-600 mb-3"> 138 169 <span x-text="recipes.length"></span> recipe(s) found 139 170 </p> 140 - <div class="table-container overflow-x-auto"> 141 - <table class="table"> 142 - <thead class="table-header"> 143 - <tr> 144 - <th class="table-th whitespace-nowrap">Author</th> 145 - <th class="table-th whitespace-nowrap">Name</th> 146 - <th class="table-th whitespace-nowrap">Coffee</th> 147 - <th class="table-th whitespace-nowrap">Water</th> 148 - <th class="table-th whitespace-nowrap">Ratio</th> 149 - <th class="table-th whitespace-nowrap">Grind</th> 150 - <th class="table-th whitespace-nowrap">Brewer</th> 151 - <th class="table-th whitespace-nowrap"></th> 152 - </tr> 153 - </thead> 154 - <tbody class="table-body"> 155 - <template x-for="recipe in recipes" :key="recipe.rkey"> 156 - <tr class="table-row cursor-pointer hover:bg-brown-50" @click="selectRecipe(recipe)"> 157 - <td class="px-6 py-4 text-sm text-brown-900"> 158 - <div class="flex items-center gap-2"> 159 - <template x-if="recipe.author_avatar"> 160 - <img :src="recipe.author_avatar" class="w-6 h-6 rounded-full object-cover" :alt="recipe.author_display || recipe.author_handle || ''"/> 171 + <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> 172 + <template x-for="recipe in recipes" :key="recipe.rkey"> 173 + <div 174 + class="feed-card cursor-pointer" 175 + @click="selectRecipe(recipe)" 176 + > 177 + <!-- Author row --> 178 + <a 179 + :href="'/profile/' + recipe.author_did" 180 + class="flex items-center gap-2 mb-3 group/author" 181 + @click.stop 182 + > 183 + <template x-if="recipe.author_avatar"> 184 + <img :src="recipe.author_avatar" class="w-7 h-7 rounded-full object-cover" :alt="recipe.author_display || recipe.author_handle || ''"/> 185 + </template> 186 + <template x-if="!recipe.author_avatar"> 187 + <div class="w-7 h-7 rounded-full bg-brown-200 flex items-center justify-center text-brown-600 text-xs font-bold" x-text="(recipe.author_display || recipe.author_handle || '?')[0].toUpperCase()"></div> 188 + </template> 189 + <div class="min-w-0 flex-1"> 190 + <template x-if="recipe.author_display"> 191 + <span class="block truncate text-sm font-medium text-brown-700 group-hover/author:text-brown-900 group-hover/author:underline transition-colors" x-text="recipe.author_display"></span> 192 + </template> 193 + <span class="block truncate text-xs text-brown-600 group-hover/author:text-brown-800 transition-colors" x-text="recipe.author_handle || ''"></span> 194 + </div> 195 + </a> 196 + <!-- Recipe name --> 197 + <h3 class="font-semibold text-brown-900 mb-2 truncate" x-text="recipe.name"></h3> 198 + <!-- Brewer --> 199 + <template x-if="getBrewerDisplay(recipe) !== '-'"> 200 + <p class="text-sm text-brown-600 mb-3" x-text="getBrewerDisplay(recipe)"></p> 201 + </template> 202 + <!-- Stats grid --> 203 + <div class="grid grid-cols-3 gap-2 mb-3"> 204 + <div class="text-center bg-brown-50/60 rounded-md py-1.5"> 205 + <span class="block text-[10px] text-brown-500 uppercase tracking-wide">Coffee</span> 206 + <span class="block text-sm font-medium text-brown-900" x-text="recipe.coffee_amount > 0 ? recipe.coffee_amount.toFixed(1) + 'g' : '-'"></span> 207 + </div> 208 + <div class="text-center bg-brown-50/60 rounded-md py-1.5"> 209 + <span class="block text-[10px] text-brown-500 uppercase tracking-wide">Water</span> 210 + <span class="block text-sm font-medium text-brown-900" x-text="recipe.water_amount > 0 ? recipe.water_amount.toFixed(1) + 'g' : '-'"></span> 211 + </div> 212 + <div class="text-center bg-brown-50/60 rounded-md py-1.5"> 213 + <span class="block text-[10px] text-brown-500 uppercase tracking-wide">Ratio</span> 214 + <span class="block text-sm font-medium text-brown-900" x-text="formatRatio(recipe)"></span> 215 + </div> 216 + </div> 217 + <!-- Grind (if present) --> 218 + <template x-if="recipe.grind_size"> 219 + <p class="text-xs text-brown-600 mb-3"> 220 + <span class="uppercase text-brown-500">Grind:</span> 221 + <span class="ml-1" x-text="recipe.grind_size"></span> 222 + </p> 223 + </template> 224 + <!-- Counts row --> 225 + <template x-if="recipe.brew_count > 0 || recipe.fork_count > 0"> 226 + <div class="flex items-center gap-3 pt-2 border-t border-brown-200/60 text-xs text-brown-500"> 227 + <template x-if="recipe.brew_count > 0"> 228 + <span class="flex items-center gap-1" :title="recipe.brew_count + ' brews'"> 229 + <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.362 5.214A8.252 8.252 0 0 1 12 21 8.25 8.25 0 0 1 6.038 7.047 8.287 8.287 0 0 0 9 9.601a8.983 8.983 0 0 1 3.361-6.867 8.21 8.21 0 0 0 3 2.48Z"></path></svg> 230 + <span x-text="recipe.brew_count + ' brew' + (recipe.brew_count !== 1 ? 's' : '')"></span> 231 + </span> 232 + </template> 233 + <template x-if="recipe.fork_count > 0"> 234 + <span class="flex items-center gap-1" :title="recipe.fork_count + ' forks'"> 235 + <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5"></path></svg> 236 + <span x-text="recipe.fork_count + ' fork' + (recipe.fork_count !== 1 ? 's' : '')"></span> 237 + </span> 238 + </template> 239 + <template x-if="recipe.forker_avatars && recipe.forker_avatars.length > 0"> 240 + <div class="flex -space-x-1.5 ml-auto"> 241 + <template x-for="(avatar, i) in recipe.forker_avatars.slice(0, 3)" :key="i"> 242 + <img :src="avatar" class="w-5 h-5 rounded-full object-cover border border-white"/> 161 243 </template> 162 - <template x-if="!recipe.author_avatar"> 163 - <div class="w-6 h-6 rounded-full bg-brown-200 flex items-center justify-center text-brown-600 text-xs font-bold" x-text="(recipe.author_display || recipe.author_handle || '?')[0].toUpperCase()"></div> 164 - </template> 165 - <div class="min-w-0"> 166 - <template x-if="recipe.author_display"> 167 - <span class="block truncate text-xs font-medium" x-text="recipe.author_display"></span> 168 - </template> 169 - <span class="block truncate text-xs text-brown-500" x-text="recipe.author_handle || ''"></span> 170 - </div> 171 244 </div> 172 - </td> 173 - <td class="px-6 py-4 text-sm font-medium text-brown-900" x-text="recipe.name"></td> 174 - <td class="px-6 py-4 text-sm text-brown-900" x-text="recipe.coffee_amount > 0 ? recipe.coffee_amount.toFixed(1) + 'g' : '-'"></td> 175 - <td class="px-6 py-4 text-sm text-brown-900" x-text="recipe.water_amount > 0 ? recipe.water_amount.toFixed(1) + 'g' : '-'"></td> 176 - <td class="px-6 py-4 text-sm text-brown-900" x-text="formatRatio(recipe)"></td> 177 - <td class="px-6 py-4 text-sm text-brown-900" x-text="recipe.grind_size || '-'"></td> 178 - <td class="px-6 py-4 text-sm text-brown-900" x-text="getBrewerDisplay(recipe)"></td> 179 - <td class="px-6 py-4 text-sm text-brown-500"> 180 - <div class="flex items-center gap-3"> 181 - <template x-if="recipe.brew_count > 0"> 182 - <span class="flex items-center gap-1" :title="recipe.brew_count + ' brews'"> 183 - <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.362 5.214A8.252 8.252 0 0 1 12 21 8.25 8.25 0 0 1 6.038 7.047 8.287 8.287 0 0 0 9 9.601a8.983 8.983 0 0 1 3.361-6.867 8.21 8.21 0 0 0 3 2.48Z"></path></svg> 184 - <span x-text="recipe.brew_count"></span> 185 - </span> 186 - </template> 187 - <template x-if="recipe.fork_count > 0"> 188 - <span class="flex items-center gap-1" :title="recipe.fork_count + ' forks'"> 189 - <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5"></path></svg> 190 - <span x-text="recipe.fork_count"></span> 191 - </span> 192 - </template> 193 - <template x-if="recipe.forker_avatars && recipe.forker_avatars.length > 0"> 194 - <div class="flex -space-x-1.5"> 195 - <template x-for="(avatar, i) in recipe.forker_avatars.slice(0, 3)" :key="i"> 196 - <img :src="avatar" class="w-5 h-5 rounded-full object-cover border border-white"/> 197 - </template> 198 - </div> 199 - </template> 200 - </div> 201 - </td> 202 - </tr> 245 + </template> 246 + </div> 203 247 </template> 204 - </tbody> 205 - </table> 248 + </div> 249 + </template> 206 250 </div> 207 251 </div> 208 252 </template> ··· 213 257 <div class="flex justify-between items-start mb-4"> 214 258 <div> 215 259 <h3 class="text-xl font-bold text-brown-900" x-text="selectedRecipe.name"></h3> 216 - <div class="flex items-center gap-2 mt-1"> 260 + <a 261 + :href="'/profile/' + selectedRecipe.author_did" 262 + class="flex items-center gap-2 mt-1 group/author" 263 + > 217 264 <template x-if="selectedRecipe.author_avatar"> 218 265 <img :src="selectedRecipe.author_avatar" class="w-6 h-6 rounded-full object-cover" :alt="selectedRecipe.author_display || ''"/> 219 266 </template> ··· 222 269 </template> 223 270 <div> 224 271 <template x-if="selectedRecipe.author_display"> 225 - <span class="block text-sm font-medium text-brown-700" x-text="selectedRecipe.author_display"></span> 272 + <span class="block text-sm font-medium text-brown-700 group-hover/author:text-brown-900 group-hover/author:underline transition-colors" x-text="selectedRecipe.author_display"></span> 226 273 </template> 227 - <span class="block text-xs text-brown-500" x-text="selectedRecipe.author_handle || ''"></span> 274 + <span class="block text-xs text-brown-600 group-hover/author:text-brown-800 transition-colors" x-text="selectedRecipe.author_handle || ''"></span> 228 275 </div> 229 - </div> 276 + </a> 230 277 </div> 231 278 <div class="flex items-center gap-2"> 232 279 <!-- Actions dropdown --> ··· 355 402 </template> 356 403 </p> 357 404 </template> 358 - <div class="flex items-center gap-3"> 405 + <div class="flex flex-col sm:flex-row gap-2 sm:gap-3"> 359 406 <a 360 407 :href="'/brews/new?recipe=' + selectedRecipe.rkey" 361 - class="btn-primary text-sm" 408 + class="btn-primary text-sm text-center" 362 409 > 363 410 Use in Brew 364 411 </a> ··· 373 420 </template> 374 421 <a 375 422 :href="'/recipes/' + selectedRecipe.rkey + '?owner=' + encodeURIComponent(selectedRecipe.author_handle || selectedRecipe.author_did)" 376 - class="btn-secondary text-sm" 423 + class="btn-secondary text-sm text-center" 377 424 > 378 425 View Recipe 379 426 </a>
+6 -4
internal/web/pages/recipe_view.templ
··· 91 91 </div> 92 92 } 93 93 <!-- Action bar --> 94 - <div class="flex justify-between items-center"> 95 - @components.BackButton() 96 - <div class="flex items-center gap-3"> 94 + <div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3"> 95 + <div class="flex flex-col sm:flex-row gap-2 sm:gap-3 sm:items-center"> 96 + @components.BackButton() 97 97 if props.IsAuthenticated { 98 - <a href={ templ.SafeURL("/brews/new?recipe=" + props.Recipe.RKey) } class="btn-primary text-sm">Use in Brew</a> 98 + <a href={ templ.SafeURL("/brews/new?recipe=" + props.Recipe.RKey) } class="btn-primary text-sm text-center">Use in Brew</a> 99 99 } 100 100 if props.IsAuthenticated && !props.IsOwnProfile { 101 101 <button ··· 116 116 <span x-show="forking">Copying...</span> 117 117 </button> 118 118 } 119 + </div> 120 + <div class="flex items-center gap-3"> 119 121 <div class="bg-brown-50 rounded-lg px-3 py-2 border border-brown-200 brew-view-actions"> 120 122 @components.ActionBar(components.ActionBarProps{ 121 123 SubjectURI: props.SubjectURI,