+679
-525
Diff
round #0
+64
docs/plans/2026-04-15-bean-more-details-section.md
+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
+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>
+107
-116
internal/web/pages/bean_view.templ
+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
+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
+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
-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
+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
+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
+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
pdewey.com
submitted
#0
1 commit
expand
collapse
feat: improved styling of view pages
merge conflicts detected
expand
collapse
expand
collapse
- 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