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

Configure Feed

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

feat: improved styling of view pages #5

open opened by pdewey.com targeting main from push-wyqtrtsvtyyy
Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:hm5f3dnm6jdhrc55qp2npdja/sh.tangled.repo.pull/3mkm2vnrh6w22
+679 -525
Diff #0
+64
docs/plans/2026-04-15-bean-more-details-section.md
··· 1 + # Plan: Bean "More Details" Expandable Section 2 + 3 + ## Context 4 + The bean creation modal is already fairly large. Power users want fields like producer (farm/estate) and elevation, but adding them visibly would overwhelm casual users. Solution: move variety + new fields into a collapsible "More Details" section that auto-expands on edit when populated. 5 + 6 + ## Phase 1: Data Layer 7 + 8 + **`lexicons/social.arabica.alpha.bean.json`** — Add `producer` (string, maxLength 200) and `elevation` (string, maxLength 100) as optional properties. 9 + 10 + **`internal/models/models.go`** — Follow Variety/Process pattern exactly: 11 + - Add `MaxProducerLength = 200`, `MaxElevationLength = 100` constants 12 + - Add `Producer`, `Elevation` string fields to `Bean`, `CreateBeanRequest`, `UpdateBeanRequest` 13 + - Add length validation in both `Validate()` methods 14 + 15 + **`internal/atproto/records.go`** — Add producer/elevation to `BeanToRecord()` and `RecordToBean()` using same `if != ""` / type-assertion pattern as variety/process. 16 + 17 + ## Phase 2: Handlers 18 + 19 + **`internal/handlers/entities.go`** — Add `Producer: r.FormValue("producer")` and `Elevation: r.FormValue("elevation")` to both `HandleBeanCreate` and `HandleBeanUpdate` form decoders. 20 + 21 + ## Phase 3: Modal UI (main change) 22 + 23 + **`internal/web/components/dialog_modals.templ`**: 24 + 1. **Remove** the variety input from the "Origin Details" fieldset (keep roaster, roast level, process there) 25 + 2. **Add** a new expandable section between "Origin Details" and "Notes & Rating": 26 + - Go helper `beanMoreDetailsInit(bean)` returns `{ showMore: true }` or `{ showMore: false }` based on whether variety/producer/elevation have values 27 + - When collapsed: shows `+ More details (variety, producer, elevation)` as a quiet text link 28 + - When expanded: shows a fieldset with variety, producer, elevation inputs 29 + - Alpine.js `x-show` keeps inputs in DOM even when hidden (fields submit as empty strings = correct behavior) 30 + 3. **Add** `"producer"` and `"elevation"` cases to `getStringValue()` helper 31 + 32 + ## Phase 4: Display Views 33 + 34 + **`internal/web/pages/bean_view.templ`**: 35 + - Add `DetailField` entries for Producer and Elevation in the detail grid 36 + - Add `producer` and `elevation` to `beanBaseJSON()` for edit round-tripping 37 + - Pick appropriate icons (check existing icon set, add IconMountain if needed) 38 + 39 + **`internal/ogcard/entities.go`**: 40 + - Optionally append producer to the details line in `DrawBeanCard` (elevation too niche for OG cards) 41 + 42 + **Skip adding to BeanSummary and BeanCard** — these are detail-level fields; the card/summary already shows origin/variety/roast/process and would get cluttered. 43 + 44 + ## Phase 5: Verify 45 + 46 + ```bash 47 + templ generate 48 + go vet ./... 49 + go build ./... 50 + ``` 51 + 52 + Manual checks: 53 + - Create bean without expanding More Details — variety/producer/elevation should save as empty 54 + - Create bean with all three fields — should persist and display on view page 55 + - Edit a bean with populated fields — More Details section should auto-expand 56 + 57 + ## Files Modified 58 + - `lexicons/social.arabica.alpha.bean.json` 59 + - `internal/models/models.go` 60 + - `internal/atproto/records.go` 61 + - `internal/handlers/entities.go` 62 + - `internal/web/components/dialog_modals.templ` (main UI change) 63 + - `internal/web/pages/bean_view.templ` 64 + - `internal/ogcard/entities.go`
+1 -1
internal/web/components/layout.templ
··· 115 115 <link rel="icon" href="/static/favicon.svg" type="image/svg+xml"/> 116 116 <link rel="icon" href="/static/favicon-32.svg" type="image/svg+xml" sizes="32x32"/> 117 117 <link rel="apple-touch-icon" href="/static/icon-192.svg"/> 118 - <link rel="stylesheet" href="/static/css/output.css?v=0.9.3"/> 118 + <link rel="stylesheet" href="/static/css/output.css?v=0.10.0"/> 119 119 <style> 120 120 [x-cloak] { display: none !important; } 121 121 </style>
+72
internal/web/components/shared.templ
··· 218 218 </div> 219 219 } 220 220 221 + // DetailStackedProps defines properties for the typography-driven detail components. 222 + type DetailStackedProps struct { 223 + Label string 224 + Value string 225 + LinkHref string // Optional: wraps value in a link 226 + Icon templ.Component // Optional: icon rendered before the label 227 + Large bool // Uses detail-value-lg for primary details 228 + } 229 + 230 + // DetailStacked renders a label above a value with no container box. 231 + templ DetailStacked(props DetailStackedProps) { 232 + if props.Value != "" || props.LinkHref == "" { 233 + <div class="detail-stacked"> 234 + <span class="detail-label"> 235 + if props.Icon != nil { 236 + <span class="inline-flex items-center gap-1"> 237 + @props.Icon 238 + { props.Label } 239 + </span> 240 + } else { 241 + { props.Label } 242 + } 243 + </span> 244 + if props.Value != "" && props.LinkHref != "" { 245 + <a 246 + href={ templ.SafeURL(props.LinkHref) } 247 + class={ templ.Classes( 248 + templ.KV("detail-value-lg", props.Large), 249 + templ.KV("detail-value", !props.Large), 250 + ) } 251 + > 252 + { props.Value } 253 + </a> 254 + } else if props.Value != "" { 255 + <span 256 + class={ templ.Classes( 257 + templ.KV("detail-value-lg", props.Large), 258 + templ.KV("detail-value", !props.Large), 259 + ) } 260 + > 261 + { props.Value } 262 + </span> 263 + } else { 264 + <span class="text-sm" style="color: var(--text-muted)">—</span> 265 + } 266 + </div> 267 + } 268 + } 269 + 270 + // DetailInline renders a label and value side by side on one line. 271 + templ DetailInline(props DetailStackedProps) { 272 + if props.Value != "" { 273 + <div class="detail-inline"> 274 + <span class="detail-label"> 275 + if props.Icon != nil { 276 + <span class="inline-flex items-center gap-1"> 277 + @props.Icon 278 + { props.Label } 279 + </span> 280 + } else { 281 + { props.Label } 282 + } 283 + </span> 284 + if props.LinkHref != "" { 285 + <a href={ templ.SafeURL(props.LinkHref) } class="detail-value">{ props.Value }</a> 286 + } else { 287 + <span class="detail-value">{ props.Value }</span> 288 + } 289 + </div> 290 + } 291 + } 292 + 221 293 type PageHeaderProps struct { 222 294 Title string 223 295 BackURL string
+107 -116
internal/web/pages/bean_view.templ
··· 53 53 AuthorDisplay: props.AuthorDisplayName, 54 54 AuthorAvatar: props.AuthorAvatar, 55 55 }) 56 - <div class="space-y-6"> 57 - if props.Bean.Roaster != nil && props.Bean.Roaster.Name != "" { 58 - <div class="section-box"> 59 - <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2"> 60 - <span class="inline-flex items-center gap-1"> 61 - @components.IconStore() 62 - Roaster 63 - </span> 64 - </h3> 65 - <div class="font-semibold text-brown-900"> 66 - <a 67 - href={ templ.SafeURL(fmt.Sprintf("/roasters/%s?owner=%s", props.Bean.Roaster.RKey, getOwnerFromShareURL(props.ShareURL))) } 68 - class="hover:underline" 69 - > 70 - { props.Bean.Roaster.Name } 71 - </a> 72 - </div> 73 - if props.Bean.Roaster.Location != "" { 74 - <div class="text-sm text-brown-600 mt-1"> 75 - <span class="inline-flex items-center gap-1"> 76 - @components.IconMapPin() 77 - { props.Bean.Roaster.Location } 78 - </span> 79 - </div> 80 - } 81 - </div> 82 - } 83 - <div class="grid grid-cols-2 gap-4"> 84 - @components.DetailField(components.DetailFieldProps{Icon: components.IconMapPin(), Label: "Origin", Value: props.Bean.Origin}) 85 - @components.DetailField(components.DetailFieldProps{Icon: components.IconLeaf(), Label: "Variety", Value: props.Bean.Variety}) 86 - @components.DetailField(components.DetailFieldProps{Icon: components.IconFlame(), Label: "Roast Level", Value: props.Bean.RoastLevel}) 87 - @components.DetailField(components.DetailFieldProps{Icon: components.IconSprout(), Label: "Process", Value: props.Bean.Process}) 88 - if bff.FormatBeanRating(props.Bean.Rating) != "" { 89 - @components.DetailField(components.DetailFieldProps{Icon: components.IconStar(), Label: "Rating", Value: bff.FormatBeanRating(props.Bean.Rating)}) 90 - } 91 - if props.Bean.Closed { 92 - <div class="section-box"> 93 - <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">Status</h3> 94 - <span class="text-sm bg-brown-200 text-brown-700 px-2 py-1 rounded-md font-medium">Closed</span> 95 - </div> 56 + if props.Bean.Roaster != nil && props.Bean.Roaster.Name != "" { 57 + <div class="detail-stacked mb-6"> 58 + <span class="detail-label"> 59 + <span class="inline-flex items-center gap-1"> 60 + @components.IconStore() 61 + Roaster 62 + </span> 63 + </span> 64 + <a 65 + href={ templ.SafeURL(fmt.Sprintf("/roasters/%s?owner=%s", props.Bean.Roaster.RKey, getOwnerFromShareURL(props.ShareURL))) } 66 + class="detail-value-lg hover:underline" 67 + > 68 + { props.Bean.Roaster.Name } 69 + </a> 70 + if props.Bean.Roaster.Location != "" { 71 + <span class="text-sm inline-flex items-center gap-1" style="color: var(--text-muted)"> 72 + @components.IconMapPin() 73 + { props.Bean.Roaster.Location } 74 + </span> 96 75 } 97 76 </div> 98 - if props.Bean.Description != "" { 99 - <div class="section-box"> 100 - <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2"> 101 - <span class="inline-flex items-center gap-1"> 102 - @components.IconFileText() 103 - Description 104 - </span> 105 - </h3> 106 - <div class="text-brown-900 whitespace-pre-wrap">{ props.Bean.Description }</div> 77 + } 78 + if bff.FormatBeanRating(props.Bean.Rating) != "" { 79 + <div class="brew-rating-hero mb-6"> 80 + <span class="brew-rating-value">{ bff.FormatBeanRating(props.Bean.Rating) }</span> 81 + <span class="brew-rating-max">/10</span> 82 + </div> 83 + } 84 + <div class="detail-grid"> 85 + @components.DetailStacked(components.DetailStackedProps{Icon: components.IconMapPin(), Label: "Origin", Value: props.Bean.Origin}) 86 + @components.DetailStacked(components.DetailStackedProps{Icon: components.IconLeaf(), Label: "Variety", Value: props.Bean.Variety}) 87 + @components.DetailStacked(components.DetailStackedProps{Icon: components.IconFlame(), Label: "Roast Level", Value: props.Bean.RoastLevel}) 88 + @components.DetailStacked(components.DetailStackedProps{Icon: components.IconSprout(), Label: "Process", Value: props.Bean.Process}) 89 + if props.Bean.Closed { 90 + <div class="detail-stacked"> 91 + <span class="detail-label">Status</span> 92 + <span class="text-sm bg-brown-200 text-brown-700 px-2 py-1 rounded-md font-medium w-fit">Closed</span> 107 93 </div> 108 94 } 109 - if props.BrewCount > 0 { 110 - <div class="flex items-center gap-3 pt-3 border-t border-brown-200/60 text-xs text-brown-500"> 111 - <span class="flex items-center gap-1"> 112 - @components.IconCoffee() 113 - { fmt.Sprintf("%d brew%s", props.BrewCount, pluralS(props.BrewCount)) } 95 + </div> 96 + if props.Bean.Description != "" { 97 + <div class="mt-6"> 98 + <span class="detail-label mb-2 block"> 99 + <span class="inline-flex items-center gap-1"> 100 + @components.IconFileText() 101 + Description 114 102 </span> 115 - </div> 116 - } 117 - <div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3"> 118 - <div class="flex flex-col sm:flex-row gap-2 sm:gap-3 sm:items-center"> 119 - <div class="order-2 sm:order-first"> 120 - @components.BackButton() 121 - </div> 122 - if props.IsOwnProfile { 123 - if !props.Bean.Closed { 124 - <div x-data="{ showConfirm: false, _saving: false }"> 125 - <button 126 - type="button" 127 - @click="showConfirm = true" 128 - class="btn-secondary text-sm text-center" 129 - > 130 - Close Bag 131 - </button> 132 - @BeanCloseBagConfirm(props) 133 - </div> 134 - } 135 - <div x-data={ beanRateInitData(props.Bean) }> 103 + </span> 104 + <div class="prose-note">{ props.Bean.Description }</div> 105 + </div> 106 + } 107 + if props.BrewCount > 0 { 108 + <div class="record-stat-line"> 109 + <span class="flex items-center gap-1"> 110 + @components.IconCoffee() 111 + { fmt.Sprintf("%d brew%s", props.BrewCount, pluralS(props.BrewCount)) } 112 + </span> 113 + </div> 114 + } 115 + <div class="record-view-footer"> 116 + <div class="flex items-center gap-3"> 117 + @components.BackButton() 118 + if props.IsOwnProfile { 119 + if !props.Bean.Closed { 120 + <div x-data="{ showConfirm: false, _saving: false }"> 136 121 <button 137 122 type="button" 138 - @click="$refs.rateDialog.showModal()" 123 + @click="showConfirm = true" 139 124 class="btn-secondary text-sm text-center" 140 125 > 141 - if props.Bean.Rating != nil { 142 - Edit Rating 143 - } else { 144 - Rate Bag 145 - } 126 + Close Bag 146 127 </button> 147 - @BeanRateModal(props) 128 + @BeanCloseBagConfirm(props) 148 129 </div> 149 130 } 150 - </div> 151 - <div class="flex items-center gap-3"> 152 - <div class="bg-brown-50 rounded-lg px-3 py-2 border border-brown-200 brew-view-actions"> 153 - @components.ActionBar(components.ActionBarProps{ 154 - SubjectURI: props.SubjectURI, 155 - SubjectCID: props.SubjectCID, 156 - IsLiked: props.IsLiked, 157 - LikeCount: props.LikeCount, 158 - CommentCount: props.CommentCount, 159 - ShowComments: true, 160 - ShareURL: props.ShareURL, 161 - ShareTitle: getBeanShareTitle(props.Bean), 162 - ShareText: "Check out this bean on Arabica", 163 - IsOwner: props.IsOwnProfile, 164 - EditModalURL: "/api/modals/bean/" + props.Bean.RKey, 165 - DeleteURL: "/api/beans/" + props.Bean.RKey, 166 - DeleteRedirect: "/my-coffee", 167 - IsAuthenticated: props.IsAuthenticated, 168 - IsModerator: props.IsModerator, 169 - CanHideRecord: props.CanHideRecord, 170 - CanBlockUser: props.CanBlockUser, 171 - IsRecordHidden: props.IsRecordHidden, 172 - AuthorDID: props.AuthorDID, 173 - }) 131 + <div x-data={ beanRateInitData(props.Bean) }> 132 + <button 133 + type="button" 134 + @click="$refs.rateDialog.showModal()" 135 + class="btn-secondary text-sm text-center" 136 + > 137 + if props.Bean.Rating != nil { 138 + Edit Rating 139 + } else { 140 + Rate Bag 141 + } 142 + </button> 143 + @BeanRateModal(props) 174 144 </div> 175 - </div> 145 + } 176 146 </div> 177 - @components.CommentSection(components.CommentSectionProps{ 147 + @components.ActionBar(components.ActionBarProps{ 178 148 SubjectURI: props.SubjectURI, 179 149 SubjectCID: props.SubjectCID, 180 - Comments: props.Comments, 150 + IsLiked: props.IsLiked, 151 + LikeCount: props.LikeCount, 152 + CommentCount: props.CommentCount, 153 + ShowComments: true, 154 + ShareURL: props.ShareURL, 155 + ShareTitle: getBeanShareTitle(props.Bean), 156 + ShareText: "Check out this bean on Arabica", 157 + IsOwner: props.IsOwnProfile, 158 + EditModalURL: "/api/modals/bean/" + props.Bean.RKey, 159 + DeleteURL: "/api/beans/" + props.Bean.RKey, 160 + DeleteRedirect: "/my-coffee", 181 161 IsAuthenticated: props.IsAuthenticated, 182 - CurrentUserDID: props.CurrentUserDID, 183 - ModCtx: components.CommentModerationContext{ 184 - IsModerator: props.IsModerator, 185 - CanHideRecord: props.CanHideRecord, 186 - CanBlockUser: props.CanBlockUser, 187 - }, 188 - ViewURL: props.ShareURL, 162 + IsModerator: props.IsModerator, 163 + CanHideRecord: props.CanHideRecord, 164 + CanBlockUser: props.CanBlockUser, 165 + IsRecordHidden: props.IsRecordHidden, 166 + AuthorDID: props.AuthorDID, 189 167 }) 190 168 </div> 169 + @components.CommentSection(components.CommentSectionProps{ 170 + SubjectURI: props.SubjectURI, 171 + SubjectCID: props.SubjectCID, 172 + Comments: props.Comments, 173 + IsAuthenticated: props.IsAuthenticated, 174 + CurrentUserDID: props.CurrentUserDID, 175 + ModCtx: components.CommentModerationContext{ 176 + IsModerator: props.IsModerator, 177 + CanHideRecord: props.CanHideRecord, 178 + CanBlockUser: props.CanBlockUser, 179 + }, 180 + ViewURL: props.ShareURL, 181 + }) 191 182 } 192 183 193 184 func beanViewTitle(bean *models.Bean) string {
+105 -136
internal/web/pages/brew_view.templ
··· 56 56 AuthorDisplay: props.AuthorDisplayName, 57 57 AuthorAvatar: props.AuthorAvatar, 58 58 }) 59 - <!-- Rating: hero element, generous spacing --> 60 59 if props.Brew.Rating > 0 { 61 - <div class="mb-8"> 62 - @BrewRating(props.Brew.Rating) 60 + <div class="brew-rating-hero mb-6"> 61 + <span class="brew-rating-value">{ fmt.Sprintf("%d", props.Brew.Rating) }</span> 62 + <span class="brew-rating-max">/10</span> 63 63 </div> 64 64 } 65 - <!-- Bean + parameters: tightly grouped as core brew info --> 66 - <div class="space-y-4 mb-8"> 67 - @BrewBeanSection(props.Brew, getOwnerFromShareURL(props.ShareURL)) 68 - @BrewParametersGrid(props.Brew, getOwnerFromShareURL(props.ShareURL)) 65 + @BrewBeanSection(props.Brew, getOwnerFromShareURL(props.ShareURL)) 66 + <div class="detail-grid my-6"> 67 + @components.DetailStacked(components.DetailStackedProps{Icon: components.IconScale(), Label: "Coffee", Value: getCoffeeAmountDisplay(props.Brew)}) 68 + @components.DetailStacked(components.DetailStackedProps{Icon: components.IconCoffee(), Label: "Brew Method", Value: getBrewerName(props.Brew), LinkHref: getBrewerViewURL(props.Brew, getOwnerFromShareURL(props.ShareURL))}) 69 + @components.DetailStacked(components.DetailStackedProps{Icon: components.IconGear(), Label: "Grinder", Value: getGrinderName(props.Brew), LinkHref: getGrinderViewURL(props.Brew, getOwnerFromShareURL(props.ShareURL))}) 70 + @components.DetailStacked(components.DetailStackedProps{Icon: components.IconDisc(), Label: "Grind Size", Value: getGrindSizeDisplay(props.Brew)}) 71 + @components.DetailStacked(components.DetailStackedProps{Icon: components.IconDroplet(), Label: "Water", Value: getWaterAmountDisplay(props.Brew)}) 72 + @components.DetailStacked(components.DetailStackedProps{Icon: components.IconThermometer(), Label: "Temperature", Value: getTemperatureDisplay(props.Brew)}) 73 + @components.DetailStacked(components.DetailStackedProps{Icon: components.IconClock(), Label: "Brew Time", Value: getBrewTimeDisplay(props.Brew)}) 74 + if props.Brew.EspressoParams != nil { 75 + if props.Brew.EspressoParams.YieldWeight > 0 { 76 + @components.DetailStacked(components.DetailStackedProps{Icon: components.IconScale(), Label: "Yield", Value: fmt.Sprintf("%.1fg", props.Brew.EspressoParams.YieldWeight)}) 77 + } 78 + if props.Brew.EspressoParams.Pressure > 0 { 79 + @components.DetailStacked(components.DetailStackedProps{Icon: components.IconBarChart(), Label: "Pressure", Value: fmt.Sprintf("%.1f bar", props.Brew.EspressoParams.Pressure)}) 80 + } 81 + if props.Brew.EspressoParams.PreInfusionSeconds > 0 { 82 + @components.DetailStacked(components.DetailStackedProps{Icon: components.IconClock(), Label: "Pre-infusion", Value: fmt.Sprintf("%ds", props.Brew.EspressoParams.PreInfusionSeconds)}) 83 + } 84 + } 85 + if props.Brew.PouroverParams != nil { 86 + if props.Brew.PouroverParams.BloomWater > 0 || props.Brew.PouroverParams.BloomSeconds > 0 { 87 + @components.DetailStacked(components.DetailStackedProps{Icon: components.IconDroplet(), Label: "Bloom", Value: formatBloom(props.Brew.PouroverParams)}) 88 + } 89 + if props.Brew.PouroverParams.DrawdownSeconds > 0 { 90 + @components.DetailStacked(components.DetailStackedProps{Icon: components.IconClock(), Label: "Drawdown", Value: fmt.Sprintf("%ds", props.Brew.PouroverParams.DrawdownSeconds)}) 91 + } 92 + if props.Brew.PouroverParams.BypassWater > 0 { 93 + @components.DetailStacked(components.DetailStackedProps{Icon: components.IconDroplet(), Label: "Bypass Water", Value: fmt.Sprintf("%dg", props.Brew.PouroverParams.BypassWater)}) 94 + } 95 + if props.Brew.PouroverParams.Filter != "" { 96 + @components.DetailStacked(components.DetailStackedProps{Icon: components.IconSliders(), Label: "Filter", Value: props.Brew.PouroverParams.Filter}) 97 + } 98 + } 69 99 </div> 70 - <!-- Method details: recipe, pours, tasting notes — secondary info with more breathing room --> 71 100 <div class="space-y-6"> 72 101 if props.Brew.RecipeObj != nil { 73 102 @BrewRecipeSection(props.Brew.RecipeObj, getOwnerFromShareURL(props.ShareURL)) ··· 81 110 if props.IsOwnProfile && props.Brew.RecipeObj == nil { 82 111 @SaveAsRecipeButton(props.Brew.RKey) 83 112 } 84 - <div class="flex justify-between items-center pt-2"> 85 - @components.BackButton() 86 - <div class="bg-brown-50 rounded-lg px-3 py-2 border border-brown-200 brew-view-actions"> 87 - @components.ActionBar(components.ActionBarProps{ 88 - SubjectURI: props.SubjectURI, 89 - SubjectCID: props.SubjectCID, 90 - IsLiked: props.IsLiked, 91 - LikeCount: props.LikeCount, 92 - CommentCount: props.CommentCount, 93 - ShowComments: true, 94 - ShareURL: props.ShareURL, 95 - ShareTitle: getBrewShareTitle(props.Brew), 96 - ShareText: "Check out this brew on Arabica", 97 - IsOwner: props.IsOwnProfile, 98 - EditURL: "/brews/" + props.Brew.RKey + "/edit", 99 - DeleteURL: "/brews/" + props.Brew.RKey, 100 - DeleteRedirect: "/my-coffee", 101 - IsAuthenticated: props.IsAuthenticated, 102 - IsModerator: props.IsModerator, 103 - CanHideRecord: props.CanHideRecord, 104 - CanBlockUser: props.CanBlockUser, 105 - IsRecordHidden: props.IsRecordHidden, 106 - AuthorDID: props.AuthorDID, 107 - }) 108 - </div> 109 - </div> 110 - @components.CommentSection(components.CommentSectionProps{ 113 + </div> 114 + <div class="record-view-footer"> 115 + @components.BackButton() 116 + @components.ActionBar(components.ActionBarProps{ 111 117 SubjectURI: props.SubjectURI, 112 118 SubjectCID: props.SubjectCID, 113 - Comments: props.Comments, 119 + IsLiked: props.IsLiked, 120 + LikeCount: props.LikeCount, 121 + CommentCount: props.CommentCount, 122 + ShowComments: true, 123 + ShareURL: props.ShareURL, 124 + ShareTitle: getBrewShareTitle(props.Brew), 125 + ShareText: "Check out this brew on Arabica", 126 + IsOwner: props.IsOwnProfile, 127 + EditURL: "/brews/" + props.Brew.RKey + "/edit", 128 + DeleteURL: "/brews/" + props.Brew.RKey, 129 + DeleteRedirect: "/my-coffee", 114 130 IsAuthenticated: props.IsAuthenticated, 115 - CurrentUserDID: props.CurrentUserDID, 116 - ModCtx: components.CommentModerationContext{ 117 - IsModerator: props.IsModerator, 118 - CanHideRecord: props.CanHideRecord, 119 - CanBlockUser: props.CanBlockUser, 120 - }, 121 - ViewURL: props.ShareURL, 131 + IsModerator: props.IsModerator, 132 + CanHideRecord: props.CanHideRecord, 133 + CanBlockUser: props.CanBlockUser, 134 + IsRecordHidden: props.IsRecordHidden, 135 + AuthorDID: props.AuthorDID, 122 136 }) 123 137 </div> 138 + @components.CommentSection(components.CommentSectionProps{ 139 + SubjectURI: props.SubjectURI, 140 + SubjectCID: props.SubjectCID, 141 + Comments: props.Comments, 142 + IsAuthenticated: props.IsAuthenticated, 143 + CurrentUserDID: props.CurrentUserDID, 144 + ModCtx: components.CommentModerationContext{ 145 + IsModerator: props.IsModerator, 146 + CanHideRecord: props.CanHideRecord, 147 + CanBlockUser: props.CanBlockUser, 148 + }, 149 + ViewURL: props.ShareURL, 150 + }) 124 151 } 125 152 126 - // BrewRating renders the prominent rating display 127 - templ BrewRating(rating int) { 128 - <div class="text-center py-6"> 129 - <span class="badge-rating text-2xl !font-bold px-6 py-2.5"> 130 - <span class="inline-flex items-center gap-1.5"> 131 - @components.IconStar() 132 - { fmt.Sprintf("%d/10", rating) } 133 - </span> 134 - </span> 135 - </div> 136 - } 137 - 138 - // BrewBeanSection renders the coffee bean information 153 + // BrewBeanSection renders the coffee bean as a prominent reference card 139 154 templ BrewBeanSection(brew *models.Brew, owner string) { 140 - <div class="section-box"> 141 - <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2"> 142 - <span class="inline-flex items-center gap-1"> 143 - @components.IconCoffee() 144 - Coffee Bean 155 + if brew.Bean != nil { 156 + <div class="brew-bean-ref mb-2"> 157 + <span class="detail-label mb-2 block"> 158 + <span class="inline-flex items-center gap-1"> 159 + @components.IconBean() 160 + Coffee Bean 161 + </span> 145 162 </span> 146 - </h3> 147 - if brew.Bean != nil { 148 - <div class="font-bold text-lg text-brown-900"> 149 - <a href={ templ.SafeURL(fmt.Sprintf("/beans/%s?owner=%s", brew.Bean.RKey, owner)) } class="hover:underline"> 150 - if brew.Bean.Name != "" { 151 - { brew.Bean.Name } 152 - } else { 153 - { brew.Bean.Origin } 154 - } 155 - </a> 156 - </div> 163 + <a href={ templ.SafeURL(fmt.Sprintf("/beans/%s?owner=%s", brew.Bean.RKey, owner)) } class="detail-value-lg hover:underline"> 164 + if brew.Bean.Name != "" { 165 + { brew.Bean.Name } 166 + } else { 167 + { brew.Bean.Origin } 168 + } 169 + </a> 157 170 if brew.Bean.Roaster != nil && brew.Bean.Roaster.Name != "" { 158 - <div class="text-sm text-brown-700 mt-1"> 171 + <div class="text-sm mt-1" style="color: var(--text-secondary)"> 159 172 <span class="inline-flex items-center gap-1"> 160 173 @components.IconStore() 161 174 <a href={ templ.SafeURL(fmt.Sprintf("/roasters/%s?owner=%s", brew.Bean.Roaster.RKey, owner)) } class="hover:underline"> ··· 164 177 </span> 165 178 </div> 166 179 } 167 - <div class="flex flex-wrap gap-3 mt-2 text-sm text-brown-600"> 180 + <div class="flex flex-wrap gap-3 mt-2 text-sm" style="color: var(--text-muted)"> 168 181 if brew.Bean.Origin != "" { 169 182 <span class="inline-flex items-center gap-1"> 170 183 @components.IconMapPin() ··· 178 191 </span> 179 192 } 180 193 </div> 181 - } else { 182 - <span class="text-brown-400">Not specified</span> 183 - } 184 - </div> 185 - } 186 - 187 - // BrewParametersGrid renders the brew parameters in a grid 188 - templ BrewParametersGrid(brew *models.Brew, owner string) { 189 - <div class="grid grid-cols-2 gap-4"> 190 - @components.DetailField(components.DetailFieldProps{Icon: components.IconScale(), Label: "Coffee", Value: getCoffeeAmountDisplay(brew)}) 191 - @components.DetailField(components.DetailFieldProps{Icon: components.IconCoffee(), Label: "Brew Method", Value: getBrewerName(brew), LinkHref: getBrewerViewURL(brew, owner)}) 192 - @components.DetailField(components.DetailFieldProps{Icon: components.IconGear(), Label: "Grinder", Value: getGrinderName(brew), LinkHref: getGrinderViewURL(brew, owner)}) 193 - @components.DetailField(components.DetailFieldProps{Icon: components.IconDisc(), Label: "Grind Size", Value: getGrindSizeDisplay(brew)}) 194 - @components.DetailField(components.DetailFieldProps{Icon: components.IconDroplet(), Label: "Water", Value: getWaterAmountDisplay(brew)}) 195 - @components.DetailField(components.DetailFieldProps{Icon: components.IconThermometer(), Label: "Temperature", Value: getTemperatureDisplay(brew)}) 196 - <div class="col-span-2"> 197 - @components.DetailField(components.DetailFieldProps{Icon: components.IconClock(), Label: "Brew Time", Value: getBrewTimeDisplay(brew)}) 198 194 </div> 199 - if brew.EspressoParams != nil { 200 - if brew.EspressoParams.YieldWeight > 0 { 201 - @components.DetailField(components.DetailFieldProps{Icon: components.IconScale(), Label: "Yield", Value: fmt.Sprintf("%.1fg", brew.EspressoParams.YieldWeight)}) 202 - } 203 - if brew.EspressoParams.Pressure > 0 { 204 - @components.DetailField(components.DetailFieldProps{Icon: components.IconBarChart(), Label: "Pressure", Value: fmt.Sprintf("%.1f bar", brew.EspressoParams.Pressure)}) 205 - } 206 - if brew.EspressoParams.PreInfusionSeconds > 0 { 207 - @components.DetailField(components.DetailFieldProps{Icon: components.IconClock(), Label: "Pre-infusion", Value: fmt.Sprintf("%ds", brew.EspressoParams.PreInfusionSeconds)}) 208 - } 209 - } 210 - if brew.PouroverParams != nil { 211 - if brew.PouroverParams.BloomWater > 0 || brew.PouroverParams.BloomSeconds > 0 { 212 - @components.DetailField(components.DetailFieldProps{Icon: components.IconDroplet(), Label: "Bloom", Value: formatBloom(brew.PouroverParams)}) 213 - } 214 - if brew.PouroverParams.DrawdownSeconds > 0 { 215 - @components.DetailField(components.DetailFieldProps{Icon: components.IconClock(), Label: "Drawdown", Value: fmt.Sprintf("%ds", brew.PouroverParams.DrawdownSeconds)}) 216 - } 217 - if brew.PouroverParams.BypassWater > 0 { 218 - @components.DetailField(components.DetailFieldProps{Icon: components.IconDroplet(), Label: "Bypass Water", Value: fmt.Sprintf("%dg", brew.PouroverParams.BypassWater)}) 219 - } 220 - if brew.PouroverParams.Filter != "" { 221 - @components.DetailField(components.DetailFieldProps{Icon: components.IconSliders(), Label: "Filter", Value: brew.PouroverParams.Filter}) 222 - } 223 - } 224 - </div> 195 + } 225 196 } 226 197 227 198 func formatBloom(pp *models.PouroverParams) string { ··· 329 300 330 301 // SaveAsRecipeButton renders a button to save brew parameters as a recipe 331 302 templ SaveAsRecipeButton(brewRKey string) { 332 - <div class="section-box" x-data={ saveAsRecipeData(brewRKey) }> 303 + <div x-data={ saveAsRecipeData(brewRKey) }> 333 304 <template x-if="!showForm && !success"> 334 305 <button 335 306 @click="showForm = true" ··· 382 353 383 354 // BrewRecipeSection renders the linked recipe info 384 355 templ BrewRecipeSection(recipe *models.Recipe, owner string) { 385 - <div class="section-box"> 386 - <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">Recipe</h3> 387 - <a href={ templ.SafeURL(fmt.Sprintf("/recipes/%s?owner=%s", recipe.RKey, owner)) } class="font-bold text-lg text-brown-900 hover:text-brown-700 underline decoration-brown-300 hover:decoration-brown-500 transition-colors">{ recipe.Name }</a> 388 - <div class="flex flex-wrap gap-3 mt-2 text-sm text-brown-600"> 356 + <div> 357 + <span class="detail-label mb-2 block">Recipe</span> 358 + <a href={ templ.SafeURL(fmt.Sprintf("/recipes/%s?owner=%s", recipe.RKey, owner)) } class="detail-value-lg hover:underline">{ recipe.Name }</a> 359 + <div class="flex flex-wrap gap-3 mt-2 text-sm" style="color: var(--text-muted)"> 389 360 if recipe.CoffeeAmount > 0 { 390 361 <span class="inline-flex items-center gap-1"> 391 362 @components.IconCoffee() ··· 411 382 } 412 383 </div> 413 384 if recipe.Notes != "" { 414 - <div class="mt-2 text-sm text-brown-700 italic">"{ recipe.Notes }"</div> 385 + <div class="mt-2 text-sm italic" style="color: var(--text-secondary)">"{ recipe.Notes }"</div> 415 386 } 416 387 </div> 417 388 } 418 389 419 390 // BrewPoursSection renders the pours section 420 391 templ BrewPoursSection(pours []*models.Pour) { 421 - <div class="section-box"> 422 - <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-3"> 392 + <div> 393 + <span class="detail-label mb-3 block"> 423 394 <span class="inline-flex items-center gap-1"> 424 395 @components.IconDroplet() 425 396 Pours 426 397 </span> 427 - </h3> 398 + </span> 428 399 <div class="space-y-2"> 429 400 for _, pour := range pours { 430 - <div class="flex justify-between items-center bg-white p-3 rounded-lg border border-brown-200"> 431 - <div class="flex gap-4 text-sm"> 432 - <span class="font-semibold text-brown-800">{ fmt.Sprintf("%dg", pour.WaterAmount) }</span> 433 - // TODO: add a setting to allow users to configure "at" vs "for" in pours display here 434 - <span class="text-brown-600">{ "for " + bff.FormatTime(pour.TimeSeconds) }</span> 435 - </div> 401 + <div class="flex gap-4 text-sm py-2" style="border-bottom: 1px solid var(--surface-border)"> 402 + <span class="detail-value">{ fmt.Sprintf("%dg", pour.WaterAmount) }</span> 403 + // TODO: add a setting to allow users to configure "at" vs "for" in pours display here 404 + <span style="color: var(--text-muted)">{ "for " + bff.FormatTime(pour.TimeSeconds) }</span> 436 405 </div> 437 406 } 438 407 </div> ··· 441 410 442 411 // BrewTastingNotes renders the tasting notes section 443 412 templ BrewTastingNotes(notes string) { 444 - <div class="section-box"> 445 - <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2"> 413 + <div> 414 + <span class="detail-label mb-2 block"> 446 415 <span class="inline-flex items-center gap-1"> 447 416 @components.IconFileText() 448 417 Tasting Notes 449 418 </span> 450 - </h3> 451 - <div class="text-brown-900 whitespace-pre-wrap">{ notes }</div> 419 + </span> 420 + <div class="prose-note">{ notes }</div> 452 421 </div> 453 422 }
+50 -54
internal/web/pages/brewer_view.templ
··· 53 53 AuthorDisplay: props.AuthorDisplayName, 54 54 AuthorAvatar: props.AuthorAvatar, 55 55 }) 56 - <div class="space-y-6"> 57 - @components.DetailField(components.DetailFieldProps{Icon: components.IconCoffee(), Label: "Type", Value: props.Brewer.BrewerType}) 58 - if props.Brewer.Description != "" { 59 - <div class="section-box"> 60 - <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2"> 61 - <span class="inline-flex items-center gap-1"> 62 - @components.IconFileText() 63 - Description 64 - </span> 65 - </h3> 66 - <div class="text-brown-900 whitespace-pre-wrap">{ props.Brewer.Description }</div> 67 - </div> 68 - } 69 - if props.BrewCount > 0 { 70 - <div class="flex items-center gap-3 pt-3 border-t border-brown-200/60 text-xs text-brown-500"> 71 - <span class="flex items-center gap-1"> 72 - @components.IconCoffee() 73 - { fmt.Sprintf("%d brew%s", props.BrewCount, pluralS(props.BrewCount)) } 56 + @components.DetailStacked(components.DetailStackedProps{Icon: components.IconCoffee(), Label: "Type", Value: props.Brewer.BrewerType}) 57 + if props.Brewer.Description != "" { 58 + <div class="mt-6"> 59 + <span class="detail-label mb-2 block"> 60 + <span class="inline-flex items-center gap-1"> 61 + @components.IconFileText() 62 + Description 74 63 </span> 75 - </div> 76 - } 77 - <div class="flex justify-between items-center"> 78 - @components.BackButton() 79 - <div class="bg-brown-50 rounded-lg px-3 py-2 border border-brown-200 brew-view-actions"> 80 - @components.ActionBar(components.ActionBarProps{ 81 - SubjectURI: props.SubjectURI, 82 - SubjectCID: props.SubjectCID, 83 - IsLiked: props.IsLiked, 84 - LikeCount: props.LikeCount, 85 - CommentCount: props.CommentCount, 86 - ShowComments: true, 87 - ShareURL: props.ShareURL, 88 - ShareTitle: props.Brewer.Name, 89 - ShareText: "Check out this brewer on Arabica", 90 - IsOwner: props.IsOwnProfile, 91 - EditModalURL: "/api/modals/brewer/" + props.Brewer.RKey, 92 - DeleteURL: "/api/brewers/" + props.Brewer.RKey, 93 - DeleteRedirect: "/my-coffee", 94 - IsAuthenticated: props.IsAuthenticated, 95 - IsModerator: props.IsModerator, 96 - CanHideRecord: props.CanHideRecord, 97 - CanBlockUser: props.CanBlockUser, 98 - IsRecordHidden: props.IsRecordHidden, 99 - AuthorDID: props.AuthorDID, 100 - }) 101 - </div> 64 + </span> 65 + <div class="prose-note">{ props.Brewer.Description }</div> 102 66 </div> 103 - @components.CommentSection(components.CommentSectionProps{ 67 + } 68 + if props.BrewCount > 0 { 69 + <div class="record-stat-line"> 70 + <span class="flex items-center gap-1"> 71 + @components.IconCoffee() 72 + { fmt.Sprintf("%d brew%s", props.BrewCount, pluralS(props.BrewCount)) } 73 + </span> 74 + </div> 75 + } 76 + <div class="record-view-footer"> 77 + @components.BackButton() 78 + @components.ActionBar(components.ActionBarProps{ 104 79 SubjectURI: props.SubjectURI, 105 80 SubjectCID: props.SubjectCID, 106 - Comments: props.Comments, 81 + IsLiked: props.IsLiked, 82 + LikeCount: props.LikeCount, 83 + CommentCount: props.CommentCount, 84 + ShowComments: true, 85 + ShareURL: props.ShareURL, 86 + ShareTitle: props.Brewer.Name, 87 + ShareText: "Check out this brewer on Arabica", 88 + IsOwner: props.IsOwnProfile, 89 + EditModalURL: "/api/modals/brewer/" + props.Brewer.RKey, 90 + DeleteURL: "/api/brewers/" + props.Brewer.RKey, 91 + DeleteRedirect: "/my-coffee", 107 92 IsAuthenticated: props.IsAuthenticated, 108 - CurrentUserDID: props.CurrentUserDID, 109 - ModCtx: components.CommentModerationContext{ 110 - IsModerator: props.IsModerator, 111 - CanHideRecord: props.CanHideRecord, 112 - CanBlockUser: props.CanBlockUser, 113 - }, 114 - ViewURL: props.ShareURL, 93 + IsModerator: props.IsModerator, 94 + CanHideRecord: props.CanHideRecord, 95 + CanBlockUser: props.CanBlockUser, 96 + IsRecordHidden: props.IsRecordHidden, 97 + AuthorDID: props.AuthorDID, 115 98 }) 116 99 </div> 100 + @components.CommentSection(components.CommentSectionProps{ 101 + SubjectURI: props.SubjectURI, 102 + SubjectCID: props.SubjectCID, 103 + Comments: props.Comments, 104 + IsAuthenticated: props.IsAuthenticated, 105 + CurrentUserDID: props.CurrentUserDID, 106 + ModCtx: components.CommentModerationContext{ 107 + IsModerator: props.IsModerator, 108 + CanHideRecord: props.CanHideRecord, 109 + CanBlockUser: props.CanBlockUser, 110 + }, 111 + ViewURL: props.ShareURL, 112 + }) 117 113 } 118 114
+53 -57
internal/web/pages/grinder_view.templ
··· 53 53 AuthorDisplay: props.AuthorDisplayName, 54 54 AuthorAvatar: props.AuthorAvatar, 55 55 }) 56 - <div class="space-y-6"> 57 - <div class="grid grid-cols-2 gap-4"> 58 - @components.DetailField(components.DetailFieldProps{Icon: components.IconGear(), Label: "Type", Value: props.Grinder.GrinderType}) 59 - @components.DetailField(components.DetailFieldProps{Icon: components.IconDisc(), Label: "Burr Type", Value: props.Grinder.BurrType}) 60 - </div> 61 - if props.Grinder.Notes != "" { 62 - <div class="section-box"> 63 - <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2"> 64 - <span class="inline-flex items-center gap-1"> 65 - @components.IconFileText() 66 - Notes 67 - </span> 68 - </h3> 69 - <div class="text-brown-900 whitespace-pre-wrap">{ props.Grinder.Notes }</div> 70 - </div> 71 - } 72 - if props.BrewCount > 0 { 73 - <div class="flex items-center gap-3 pt-3 border-t border-brown-200/60 text-xs text-brown-500"> 74 - <span class="flex items-center gap-1"> 75 - @components.IconCoffee() 76 - { fmt.Sprintf("%d brew%s", props.BrewCount, pluralS(props.BrewCount)) } 56 + <div class="space-y-4"> 57 + @components.DetailStacked(components.DetailStackedProps{Icon: components.IconGear(), Label: "Type", Value: props.Grinder.GrinderType}) 58 + @components.DetailStacked(components.DetailStackedProps{Icon: components.IconDisc(), Label: "Burr Type", Value: props.Grinder.BurrType}) 59 + </div> 60 + if props.Grinder.Notes != "" { 61 + <div class="mt-6"> 62 + <span class="detail-label mb-2 block"> 63 + <span class="inline-flex items-center gap-1"> 64 + @components.IconFileText() 65 + Notes 77 66 </span> 78 - </div> 79 - } 80 - <div class="flex justify-between items-center"> 81 - @components.BackButton() 82 - <div class="bg-brown-50 rounded-lg px-3 py-2 border border-brown-200 brew-view-actions"> 83 - @components.ActionBar(components.ActionBarProps{ 84 - SubjectURI: props.SubjectURI, 85 - SubjectCID: props.SubjectCID, 86 - IsLiked: props.IsLiked, 87 - LikeCount: props.LikeCount, 88 - CommentCount: props.CommentCount, 89 - ShowComments: true, 90 - ShareURL: props.ShareURL, 91 - ShareTitle: props.Grinder.Name, 92 - ShareText: "Check out this grinder on Arabica", 93 - IsOwner: props.IsOwnProfile, 94 - EditModalURL: "/api/modals/grinder/" + props.Grinder.RKey, 95 - DeleteURL: "/api/grinders/" + props.Grinder.RKey, 96 - DeleteRedirect: "/my-coffee", 97 - IsAuthenticated: props.IsAuthenticated, 98 - IsModerator: props.IsModerator, 99 - CanHideRecord: props.CanHideRecord, 100 - CanBlockUser: props.CanBlockUser, 101 - IsRecordHidden: props.IsRecordHidden, 102 - AuthorDID: props.AuthorDID, 103 - }) 104 - </div> 67 + </span> 68 + <div class="prose-note">{ props.Grinder.Notes }</div> 105 69 </div> 106 - @components.CommentSection(components.CommentSectionProps{ 70 + } 71 + if props.BrewCount > 0 { 72 + <div class="record-stat-line"> 73 + <span class="flex items-center gap-1"> 74 + @components.IconCoffee() 75 + { fmt.Sprintf("%d brew%s", props.BrewCount, pluralS(props.BrewCount)) } 76 + </span> 77 + </div> 78 + } 79 + <div class="record-view-footer"> 80 + @components.BackButton() 81 + @components.ActionBar(components.ActionBarProps{ 107 82 SubjectURI: props.SubjectURI, 108 83 SubjectCID: props.SubjectCID, 109 - Comments: props.Comments, 84 + IsLiked: props.IsLiked, 85 + LikeCount: props.LikeCount, 86 + CommentCount: props.CommentCount, 87 + ShowComments: true, 88 + ShareURL: props.ShareURL, 89 + ShareTitle: props.Grinder.Name, 90 + ShareText: "Check out this grinder on Arabica", 91 + IsOwner: props.IsOwnProfile, 92 + EditModalURL: "/api/modals/grinder/" + props.Grinder.RKey, 93 + DeleteURL: "/api/grinders/" + props.Grinder.RKey, 94 + DeleteRedirect: "/my-coffee", 110 95 IsAuthenticated: props.IsAuthenticated, 111 - CurrentUserDID: props.CurrentUserDID, 112 - ModCtx: components.CommentModerationContext{ 113 - IsModerator: props.IsModerator, 114 - CanHideRecord: props.CanHideRecord, 115 - CanBlockUser: props.CanBlockUser, 116 - }, 117 - ViewURL: props.ShareURL, 96 + IsModerator: props.IsModerator, 97 + CanHideRecord: props.CanHideRecord, 98 + CanBlockUser: props.CanBlockUser, 99 + IsRecordHidden: props.IsRecordHidden, 100 + AuthorDID: props.AuthorDID, 118 101 }) 119 102 </div> 103 + @components.CommentSection(components.CommentSectionProps{ 104 + SubjectURI: props.SubjectURI, 105 + SubjectCID: props.SubjectCID, 106 + Comments: props.Comments, 107 + IsAuthenticated: props.IsAuthenticated, 108 + CurrentUserDID: props.CurrentUserDID, 109 + ModCtx: components.CommentModerationContext{ 110 + IsModerator: props.IsModerator, 111 + CanHideRecord: props.CanHideRecord, 112 + CanBlockUser: props.CanBlockUser, 113 + }, 114 + ViewURL: props.ShareURL, 115 + }) 120 116 } 121 117
+88 -101
internal/web/pages/recipe_view.templ
··· 66 66 </a> 67 67 </p> 68 68 } 69 - <div class="space-y-6"> 70 - <!-- Main details grid --> 71 - <div class="grid grid-cols-2 gap-4"> 72 - if props.Recipe.CoffeeAmount > 0 { 73 - @components.DetailField(components.DetailFieldProps{Icon: components.IconCoffee(), Label: "Coffee", Value: fmt.Sprintf("%.1fg", props.Recipe.CoffeeAmount)}) 74 - } 75 - if props.Recipe.WaterAmount > 0 { 76 - @components.DetailField(components.DetailFieldProps{Icon: components.IconDroplet(), Label: "Water", Value: fmt.Sprintf("%.1fg", props.Recipe.WaterAmount)}) 77 - } 78 - if props.Recipe.Ratio > 0 { 79 - @components.DetailField(components.DetailFieldProps{Icon: components.IconScale(), Label: "Ratio", Value: fmt.Sprintf("1:%.1f", props.Recipe.Ratio)}) 80 - } 81 - if props.Recipe.BrewerObj != nil { 82 - @components.DetailField(components.DetailFieldProps{ 83 - Icon: components.IconBrewer(), 84 - Label: "Brewer", 85 - Value: props.Recipe.BrewerObj.Name, 86 - LinkHref: recipeBrewerLink(props), 87 - }) 88 - } else if props.Recipe.BrewerType != "" { 89 - @components.DetailField(components.DetailFieldProps{Icon: components.IconBrewer(), Label: "Brewer Type", Value: props.Recipe.BrewerType}) 90 - } 91 - </div> 92 - <!-- Pours --> 69 + <div class="detail-grid"> 70 + if props.Recipe.CoffeeAmount > 0 { 71 + @components.DetailStacked(components.DetailStackedProps{Icon: components.IconCoffee(), Label: "Coffee", Value: fmt.Sprintf("%.1fg", props.Recipe.CoffeeAmount)}) 72 + } 73 + if props.Recipe.WaterAmount > 0 { 74 + @components.DetailStacked(components.DetailStackedProps{Icon: components.IconDroplet(), Label: "Water", Value: fmt.Sprintf("%.1fg", props.Recipe.WaterAmount)}) 75 + } 76 + if props.Recipe.Ratio > 0 { 77 + @components.DetailStacked(components.DetailStackedProps{Icon: components.IconScale(), Label: "Ratio", Value: fmt.Sprintf("1:%.1f", props.Recipe.Ratio)}) 78 + } 79 + if props.Recipe.BrewerObj != nil { 80 + @components.DetailStacked(components.DetailStackedProps{ 81 + Icon: components.IconBrewer(), 82 + Label: "Brewer", 83 + Value: props.Recipe.BrewerObj.Name, 84 + LinkHref: recipeBrewerLink(props), 85 + }) 86 + } else if props.Recipe.BrewerType != "" { 87 + @components.DetailStacked(components.DetailStackedProps{Icon: components.IconBrewer(), Label: "Brewer Type", Value: props.Recipe.BrewerType}) 88 + } 89 + </div> 90 + <div class="space-y-6 mt-6"> 93 91 if len(props.Recipe.Pours) > 0 { 94 - <div class="section-box"> 95 - <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2"> 92 + <div> 93 + <span class="detail-label mb-3 block"> 96 94 <span class="inline-flex items-center gap-1"> 97 95 @components.IconDroplet() 98 96 Pours 99 97 </span> 100 - </h3> 98 + </span> 101 99 <div class="space-y-2"> 102 100 for _, pour := range props.Recipe.Pours { 103 - <div class="flex justify-between items-center bg-white p-3 rounded-lg border border-brown-200"> 104 - <div class="flex gap-4 text-sm"> 105 - <span class="font-semibold text-brown-800">{ fmt.Sprintf("%dg", pour.WaterAmount) }</span> 106 - <span class="text-brown-600">{ "for " + bff.FormatTime(pour.TimeSeconds) }</span> 107 - </div> 101 + <div class="flex gap-4 text-sm py-2" style="border-bottom: 1px solid var(--surface-border)"> 102 + <span class="detail-value">{ fmt.Sprintf("%dg", pour.WaterAmount) }</span> 103 + <span style="color: var(--text-muted)">{ "for " + bff.FormatTime(pour.TimeSeconds) }</span> 108 104 </div> 109 105 } 110 106 </div> 111 107 </div> 112 108 } 113 - <!-- Notes --> 114 109 if props.Recipe.Notes != "" { 115 - <div class="section-box"> 116 - <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2"> 110 + <div> 111 + <span class="detail-label mb-2 block"> 117 112 <span class="inline-flex items-center gap-1"> 118 113 @components.IconFileText() 119 114 Notes 120 115 </span> 121 - </h3> 122 - <div class="text-brown-900 whitespace-pre-wrap">{ props.Recipe.Notes }</div> 116 + </span> 117 + <div class="prose-note">{ props.Recipe.Notes }</div> 123 118 </div> 124 119 } 125 - <!-- Action bar --> 126 - <div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3"> 127 - <div class="flex flex-col sm:flex-row gap-2 sm:gap-3 sm:items-center"> 128 - <div class="order-2 sm:order-first"> 129 - @components.BackButton() 130 - </div> 131 - if props.IsAuthenticated { 132 - <a href={ templ.SafeURL("/brews/new?recipe=" + props.Recipe.RKey + "&recipe_owner=" + props.Recipe.AuthorDID) } class="btn-primary text-sm text-center">Use in Brew</a> 133 - } 134 - if props.IsAuthenticated && !props.IsOwnProfile { 135 - <button 136 - type="button" 137 - x-data="{ forking: false }" 138 - @click={ fmt.Sprintf(` 139 - if (forking) return; 140 - forking = true; 141 - fetch('/api/recipes/fork/%s?owner=%s', { method: 'POST', credentials: 'same-origin' }) 142 - .then(r => { if (r.status === 401) { window.__showSessionExpiredModal(); forking = false; return; } if (!r.ok) throw new Error('Failed'); return r.json(); }) 143 - .then(d => { if (d) { $dispatch('notify', { message: 'Recipe copied to your library!' }); } forking = false; }) 144 - .catch(() => { $dispatch('notify', { message: 'Failed to copy recipe' }); forking = false; }); 145 - `, props.Recipe.RKey, props.AuthorDID) } 146 - class="btn-secondary text-sm" 147 - :disabled="forking" 148 - > 149 - <span x-show="!forking">Copy Recipe</span> 150 - <span x-show="forking">Copying...</span> 151 - </button> 152 - } 153 - </div> 154 - <div class="flex items-center gap-3"> 155 - <div class="bg-brown-50 rounded-lg px-3 py-2 border border-brown-200 brew-view-actions"> 156 - @components.ActionBar(components.ActionBarProps{ 157 - SubjectURI: props.SubjectURI, 158 - SubjectCID: props.SubjectCID, 159 - IsLiked: props.IsLiked, 160 - LikeCount: props.LikeCount, 161 - CommentCount: props.CommentCount, 162 - ShowComments: true, 163 - ShareURL: props.ShareURL, 164 - ShareTitle: props.Recipe.Name, 165 - ShareText: "Check out this recipe on Arabica", 166 - IsOwner: props.IsOwnProfile, 167 - EditModalURL: "/api/modals/recipe/" + props.Recipe.RKey, 168 - DeleteURL: "/api/recipes/" + props.Recipe.RKey, 169 - DeleteRedirect: "/my-coffee", 170 - IsAuthenticated: props.IsAuthenticated, 171 - IsModerator: props.IsModerator, 172 - CanHideRecord: props.CanHideRecord, 173 - CanBlockUser: props.CanBlockUser, 174 - IsRecordHidden: props.IsRecordHidden, 175 - AuthorDID: props.AuthorDID, 176 - }) 177 - </div> 178 - </div> 120 + </div> 121 + <div class="record-view-footer"> 122 + <div class="flex items-center gap-3"> 123 + @components.BackButton() 124 + if props.IsAuthenticated { 125 + <a href={ templ.SafeURL("/brews/new?recipe=" + props.Recipe.RKey + "&recipe_owner=" + props.Recipe.AuthorDID) } class="btn-primary text-sm text-center">Use in Brew</a> 126 + } 127 + if props.IsAuthenticated && !props.IsOwnProfile { 128 + <button 129 + type="button" 130 + x-data="{ forking: false }" 131 + @click={ fmt.Sprintf(` 132 + if (forking) return; 133 + forking = true; 134 + fetch('/api/recipes/fork/%s?owner=%s', { method: 'POST', credentials: 'same-origin' }) 135 + .then(r => { if (r.status === 401) { window.__showSessionExpiredModal(); forking = false; return; } if (!r.ok) throw new Error('Failed'); return r.json(); }) 136 + .then(d => { if (d) { $dispatch('notify', { message: 'Recipe copied to your library!' }); } forking = false; }) 137 + .catch(() => { $dispatch('notify', { message: 'Failed to copy recipe' }); forking = false; }); 138 + `, props.Recipe.RKey, props.AuthorDID) } 139 + class="btn-secondary text-sm" 140 + :disabled="forking" 141 + > 142 + <span x-show="!forking">Copy Recipe</span> 143 + <span x-show="forking">Copying...</span> 144 + </button> 145 + } 179 146 </div> 180 - <!-- Comments --> 181 - @components.CommentSection(components.CommentSectionProps{ 147 + @components.ActionBar(components.ActionBarProps{ 182 148 SubjectURI: props.SubjectURI, 183 149 SubjectCID: props.SubjectCID, 184 - Comments: props.Comments, 150 + IsLiked: props.IsLiked, 151 + LikeCount: props.LikeCount, 152 + CommentCount: props.CommentCount, 153 + ShowComments: true, 154 + ShareURL: props.ShareURL, 155 + ShareTitle: props.Recipe.Name, 156 + ShareText: "Check out this recipe on Arabica", 157 + IsOwner: props.IsOwnProfile, 158 + EditModalURL: "/api/modals/recipe/" + props.Recipe.RKey, 159 + DeleteURL: "/api/recipes/" + props.Recipe.RKey, 160 + DeleteRedirect: "/my-coffee", 185 161 IsAuthenticated: props.IsAuthenticated, 186 - CurrentUserDID: props.CurrentUserDID, 187 - ModCtx: components.CommentModerationContext{ 188 - IsModerator: props.IsModerator, 189 - CanHideRecord: props.CanHideRecord, 190 - CanBlockUser: props.CanBlockUser, 191 - }, 192 - ViewURL: props.ShareURL, 162 + IsModerator: props.IsModerator, 163 + CanHideRecord: props.CanHideRecord, 164 + CanBlockUser: props.CanBlockUser, 165 + IsRecordHidden: props.IsRecordHidden, 166 + AuthorDID: props.AuthorDID, 193 167 }) 194 168 </div> 169 + @components.CommentSection(components.CommentSectionProps{ 170 + SubjectURI: props.SubjectURI, 171 + SubjectCID: props.SubjectCID, 172 + Comments: props.Comments, 173 + IsAuthenticated: props.IsAuthenticated, 174 + CurrentUserDID: props.CurrentUserDID, 175 + ModCtx: components.CommentModerationContext{ 176 + IsModerator: props.IsModerator, 177 + CanHideRecord: props.CanHideRecord, 178 + CanBlockUser: props.CanBlockUser, 179 + }, 180 + ViewURL: props.ShareURL, 181 + }) 195 182 } 196 183 197 184
+54 -60
internal/web/pages/roaster_view.templ
··· 53 53 AuthorDisplay: props.AuthorDisplayName, 54 54 AuthorAvatar: props.AuthorAvatar, 55 55 }) 56 - <div class="space-y-6"> 57 - <div class="grid grid-cols-2 gap-4"> 58 - @components.DetailField(components.DetailFieldProps{Icon: components.IconMapPin(), Label: "Location", Value: props.Roaster.Location}) 59 - if props.Roaster.Website != "" { 60 - <div class="section-box"> 61 - <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2"> 62 - <span class="inline-flex items-center gap-1"> 63 - @components.IconLink() 64 - Website 65 - </span> 66 - </h3> 67 - if safeWebsite := bff.SafeWebsiteURL(props.Roaster.Website); safeWebsite != "" { 68 - <a href={ templ.SafeURL(safeWebsite) } target="_blank" rel="noopener noreferrer" class="font-semibold text-brown-900 hover:underline"> 69 - { safeWebsite } 70 - </a> 71 - } else { 72 - <span class="text-brown-400">Invalid URL</span> 73 - } 74 - </div> 75 - } 76 - </div> 77 - if props.BeanCount > 0 { 78 - <div class="flex items-center gap-3 pt-3 border-t border-brown-200/60 text-xs text-brown-500"> 79 - <span class="flex items-center gap-1"> 80 - @components.IconLeaf() 81 - { fmt.Sprintf("%d bean%s", props.BeanCount, pluralS(props.BeanCount)) } 56 + <div class="space-y-4"> 57 + @components.DetailStacked(components.DetailStackedProps{Icon: components.IconMapPin(), Label: "Location", Value: props.Roaster.Location, Large: true}) 58 + if props.Roaster.Website != "" { 59 + <div class="detail-stacked"> 60 + <span class="detail-label"> 61 + <span class="inline-flex items-center gap-1"> 62 + @components.IconLink() 63 + Website 64 + </span> 82 65 </span> 66 + if safeWebsite := bff.SafeWebsiteURL(props.Roaster.Website); safeWebsite != "" { 67 + <a href={ templ.SafeURL(safeWebsite) } target="_blank" rel="noopener noreferrer" class="detail-value hover:underline"> 68 + { safeWebsite } 69 + </a> 70 + } 83 71 </div> 84 72 } 85 - <div class="flex justify-between items-center"> 86 - @components.BackButton() 87 - <div class="bg-brown-50 rounded-lg px-3 py-2 border border-brown-200 brew-view-actions"> 88 - @components.ActionBar(components.ActionBarProps{ 89 - SubjectURI: props.SubjectURI, 90 - SubjectCID: props.SubjectCID, 91 - IsLiked: props.IsLiked, 92 - LikeCount: props.LikeCount, 93 - CommentCount: props.CommentCount, 94 - ShowComments: true, 95 - ShareURL: props.ShareURL, 96 - ShareTitle: props.Roaster.Name, 97 - ShareText: "Check out this roaster on Arabica", 98 - IsOwner: props.IsOwnProfile, 99 - EditModalURL: "/api/modals/roaster/" + props.Roaster.RKey, 100 - DeleteURL: "/api/roasters/" + props.Roaster.RKey, 101 - DeleteRedirect: "/my-coffee", 102 - IsAuthenticated: props.IsAuthenticated, 103 - IsModerator: props.IsModerator, 104 - CanHideRecord: props.CanHideRecord, 105 - CanBlockUser: props.CanBlockUser, 106 - IsRecordHidden: props.IsRecordHidden, 107 - AuthorDID: props.AuthorDID, 108 - }) 109 - </div> 73 + </div> 74 + if props.BeanCount > 0 { 75 + <div class="record-stat-line"> 76 + <span class="flex items-center gap-1"> 77 + @components.IconLeaf() 78 + { fmt.Sprintf("%d bean%s", props.BeanCount, pluralS(props.BeanCount)) } 79 + </span> 110 80 </div> 111 - @components.CommentSection(components.CommentSectionProps{ 81 + } 82 + <div class="record-view-footer"> 83 + @components.BackButton() 84 + @components.ActionBar(components.ActionBarProps{ 112 85 SubjectURI: props.SubjectURI, 113 86 SubjectCID: props.SubjectCID, 114 - Comments: props.Comments, 87 + IsLiked: props.IsLiked, 88 + LikeCount: props.LikeCount, 89 + CommentCount: props.CommentCount, 90 + ShowComments: true, 91 + ShareURL: props.ShareURL, 92 + ShareTitle: props.Roaster.Name, 93 + ShareText: "Check out this roaster on Arabica", 94 + IsOwner: props.IsOwnProfile, 95 + EditModalURL: "/api/modals/roaster/" + props.Roaster.RKey, 96 + DeleteURL: "/api/roasters/" + props.Roaster.RKey, 97 + DeleteRedirect: "/my-coffee", 115 98 IsAuthenticated: props.IsAuthenticated, 116 - CurrentUserDID: props.CurrentUserDID, 117 - ModCtx: components.CommentModerationContext{ 118 - IsModerator: props.IsModerator, 119 - CanHideRecord: props.CanHideRecord, 120 - CanBlockUser: props.CanBlockUser, 121 - }, 122 - ViewURL: props.ShareURL, 99 + IsModerator: props.IsModerator, 100 + CanHideRecord: props.CanHideRecord, 101 + CanBlockUser: props.CanBlockUser, 102 + IsRecordHidden: props.IsRecordHidden, 103 + AuthorDID: props.AuthorDID, 123 104 }) 124 105 </div> 106 + @components.CommentSection(components.CommentSectionProps{ 107 + SubjectURI: props.SubjectURI, 108 + SubjectCID: props.SubjectCID, 109 + Comments: props.Comments, 110 + IsAuthenticated: props.IsAuthenticated, 111 + CurrentUserDID: props.CurrentUserDID, 112 + ModCtx: components.CommentModerationContext{ 113 + IsModerator: props.IsModerator, 114 + CanHideRecord: props.CanHideRecord, 115 + CanBlockUser: props.CanBlockUser, 116 + }, 117 + ViewURL: props.ShareURL, 118 + }) 125 119 } 126 120
+85
static/css/app.css
··· 561 561 color: var(--text-muted); 562 562 } 563 563 564 + /* Record view — typography-driven detail fields (no container boxes) */ 565 + .detail-label { 566 + @apply text-xs font-medium uppercase tracking-wider; 567 + color: var(--text-muted); 568 + } 569 + 570 + .detail-value { 571 + @apply font-semibold; 572 + color: var(--text-primary); 573 + } 574 + 575 + .detail-value-lg { 576 + @apply font-bold text-lg; 577 + color: var(--text-primary); 578 + } 579 + 580 + .detail-value a { 581 + color: var(--text-primary); 582 + } 583 + 584 + .detail-value a:hover { 585 + text-decoration: underline; 586 + } 587 + 588 + /* Stacked: label on top, value below */ 589 + .detail-stacked { 590 + @apply flex flex-col gap-1; 591 + } 592 + 593 + /* Inline: label and value side by side */ 594 + .detail-inline { 595 + @apply flex items-baseline gap-2; 596 + } 597 + 598 + /* Compact parameter grid — tighter than standard, single col on mobile */ 599 + .detail-grid { 600 + @apply grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-4; 601 + } 602 + 603 + /* Journal-entry prose for notes/descriptions */ 604 + .prose-note { 605 + @apply pl-4 whitespace-pre-wrap leading-relaxed; 606 + border-left: 2px solid var(--surface-border); 607 + color: var(--text-primary); 608 + } 609 + 610 + /* Bean reference card on brew view */ 611 + .brew-bean-ref { 612 + @apply rounded-lg p-4; 613 + background: var(--surface-bg); 614 + transition: background 0.15s ease; 615 + } 616 + 617 + .brew-bean-ref:hover { 618 + background: var(--type-bean-tint); 619 + } 620 + 621 + /* Hero rating for brew view */ 622 + .brew-rating-hero { 623 + @apply inline-flex items-center gap-2 py-1; 624 + } 625 + 626 + .brew-rating-value { 627 + @apply text-4xl font-bold tracking-tight; 628 + color: var(--rating-text); 629 + } 630 + 631 + .brew-rating-max { 632 + @apply text-lg font-medium; 633 + color: var(--text-muted); 634 + } 635 + 636 + /* Clean record footer: back button left, actions right */ 637 + .record-view-footer { 638 + @apply flex justify-between items-center pt-4 mt-6; 639 + border-top: 1px solid var(--surface-border); 640 + } 641 + 642 + /* Stat line at bottom of record (brew count, bean count) */ 643 + .record-stat-line { 644 + @apply flex items-center gap-3 text-xs py-3; 645 + color: var(--text-muted); 646 + border-top: 1px solid var(--surface-border); 647 + } 648 + 564 649 /* Form field groups — semantic clusters within modals */ 565 650 .form-fieldset { 566 651 @apply space-y-3;

History

1 round 0 comments
sign up or login to add to the discussion
pdewey.com submitted #0
1 commit
expand
feat: improved styling of view pages
merge conflicts detected
expand
  • internal/web/components/layout.templ:115
  • internal/web/components/shared.templ:218
  • internal/web/pages/bean_view.templ:53
  • internal/web/pages/brew_view.templ:56
  • internal/web/pages/brewer_view.templ:53
  • internal/web/pages/grinder_view.templ:53
  • internal/web/pages/recipe_view.templ:66
  • internal/web/pages/roaster_view.templ:53
  • static/css/app.css:561
expand 0 comments