+511
-279
Diff
round #0
+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.10.0"/>
118
+
<link rel="stylesheet" href="/static/css/output.css?v=0.11.0"/>
119
119
<style>
120
120
[x-cloak] { display: none !important; }
121
121
</style>
+72
-58
internal/web/pages/bean_view.templ
+72
-58
internal/web/pages/bean_view.templ
···
10
10
)
11
11
12
12
type BeanViewProps struct {
13
-
Bean *models.Bean
14
-
IsOwnProfile bool
15
-
IsAuthenticated bool
16
-
SubjectURI string
17
-
SubjectCID string
18
-
IsLiked bool
19
-
LikeCount int
20
-
CommentCount int
21
-
Comments []firehose.IndexedComment
22
-
CurrentUserDID string
23
-
ShareURL string
24
-
IsModerator bool
25
-
CanHideRecord bool
26
-
CanBlockUser bool
27
-
IsRecordHidden bool
13
+
Bean *models.Bean
14
+
IsOwnProfile bool
15
+
IsAuthenticated bool
16
+
SubjectURI string
17
+
SubjectCID string
18
+
IsLiked bool
19
+
LikeCount int
20
+
CommentCount int
21
+
Comments []firehose.IndexedComment
22
+
CurrentUserDID string
23
+
ShareURL string
24
+
IsModerator bool
25
+
CanHideRecord bool
26
+
CanBlockUser bool
27
+
IsRecordHidden bool
28
28
AuthorDID string
29
29
AuthorHandle string
30
30
AuthorDisplayName string
···
53
53
AuthorDisplay: props.AuthorDisplayName,
54
54
AuthorAvatar: props.AuthorAvatar,
55
55
})
56
-
if props.Bean.Roaster != nil && props.Bean.Roaster.Name != "" {
57
-
<div class="detail-stacked mb-6">
58
-
<span class="detail-label">
56
+
<div class="record-label p-4">
57
+
if props.Bean.Roaster != nil && props.Bean.Roaster.Name != "" {
58
+
<div class="label-byline">
59
59
<span class="inline-flex items-center gap-1">
60
60
@components.IconStore()
61
-
Roaster
61
+
<a href={ templ.SafeURL(fmt.Sprintf("/roasters/%s?owner=%s", props.Bean.Roaster.RKey, getOwnerFromShareURL(props.ShareURL))) }>
62
+
{ props.Bean.Roaster.Name }
63
+
</a>
64
+
if props.Bean.Roaster.Location != "" {
65
+
<span style="color: var(--text-faint)">·</span>
66
+
<span class="inline-flex items-center gap-1">
67
+
@components.IconMapPin()
68
+
{ props.Bean.Roaster.Location }
69
+
</span>
70
+
}
62
71
</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 }
72
+
</div>
73
+
}
74
+
if props.Bean.Rating != nil {
75
+
<div class="brew-rating-hero mt-4">
76
+
<span class="brew-rating-value">{ fmt.Sprintf("%d", *props.Bean.Rating) }</span>
77
+
<span class="brew-rating-max">/10</span>
78
+
</div>
79
+
}
80
+
<div class="label-tags">
81
+
if props.Bean.Origin != "" {
82
+
<span class="label-tag">
83
+
<span class="inline-flex items-center gap-1">
84
+
@components.IconMapPin()
85
+
{ props.Bean.Origin }
86
+
</span>
74
87
</span>
75
88
}
89
+
if props.Bean.Variety != "" {
90
+
<span class="label-tag">
91
+
<span class="inline-flex items-center gap-1">
92
+
@components.IconLeaf()
93
+
{ props.Bean.Variety }
94
+
</span>
95
+
</span>
96
+
}
97
+
if props.Bean.RoastLevel != "" {
98
+
<span class="label-tag">
99
+
<span class="inline-flex items-center gap-1">
100
+
@components.IconFlame()
101
+
{ props.Bean.RoastLevel }
102
+
</span>
103
+
</span>
104
+
}
105
+
if props.Bean.Process != "" {
106
+
<span class="label-tag">
107
+
<span class="inline-flex items-center gap-1">
108
+
@components.IconSprout()
109
+
{ props.Bean.Process }
110
+
</span>
111
+
</span>
112
+
}
113
+
if props.Bean.Closed {
114
+
<span class="label-tag" style="border-color: var(--text-muted)">Closed</span>
115
+
}
76
116
</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>
93
-
</div>
117
+
if props.Bean.Description != "" {
118
+
<div class="label-description">{ props.Bean.Description }</div>
94
119
}
95
120
</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
102
-
</span>
103
-
</span>
104
-
<div class="prose-note">{ props.Bean.Description }</div>
105
-
</div>
106
-
}
107
121
if props.BrewCount > 0 {
108
122
<div class="record-stat-line">
109
123
<span class="flex items-center gap-1">
+111
-58
internal/web/pages/brew_view.templ
+111
-58
internal/web/pages/brew_view.templ
···
22
22
CurrentUserDID string // DID of the current user (for delete buttons)
23
23
ShareURL string // URL for sharing the brew
24
24
// Moderation state
25
-
IsModerator bool // User has moderator role
26
-
CanHideRecord bool // User has hide_record permission
27
-
CanBlockUser bool // User has blacklist_user permission
28
-
IsRecordHidden bool // This record is currently hidden
29
-
AuthorDID string // DID of the brew author
30
-
AuthorHandle string
31
-
AuthorDisplayName string
32
-
AuthorAvatar string
25
+
IsModerator bool // User has moderator role
26
+
CanHideRecord bool // User has hide_record permission
27
+
CanBlockUser bool // User has blacklist_user permission
28
+
IsRecordHidden bool // This record is currently hidden
29
+
AuthorDID string // DID of the brew author
30
+
AuthorHandle string
31
+
AuthorDisplayName string
32
+
AuthorAvatar string
33
33
}
34
34
35
35
// BrewView renders the full brew view page
···
56
56
AuthorDisplay: props.AuthorDisplayName,
57
57
AuthorAvatar: props.AuthorAvatar,
58
58
})
59
-
if props.Brew.Rating > 0 {
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
-
</div>
64
-
}
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)})
59
+
<div class="record-journal p-4">
60
+
@BrewSummary(props.Brew)
61
+
@BrewBeanSection(props.Brew, getOwnerFromShareURL(props.ShareURL))
62
+
<div class="my-6">
63
+
@components.JournalField(components.DetailStackedProps{Icon: components.IconScale(), Label: "Coffee", Value: getCoffeeAmountDisplay(props.Brew)})
64
+
@components.JournalField(components.DetailStackedProps{Icon: components.IconCoffee(), Label: "Brew Method", Value: getBrewerName(props.Brew), LinkHref: getBrewerViewURL(props.Brew, getOwnerFromShareURL(props.ShareURL))})
65
+
@components.JournalField(components.DetailStackedProps{Icon: components.IconGear(), Label: "Grinder", Value: getGrinderName(props.Brew), LinkHref: getGrinderViewURL(props.Brew, getOwnerFromShareURL(props.ShareURL))})
66
+
@components.JournalField(components.DetailStackedProps{Icon: components.IconDisc(), Label: "Grind Size", Value: getGrindSizeDisplay(props.Brew)})
67
+
@components.JournalField(components.DetailStackedProps{Icon: components.IconDroplet(), Label: "Water", Value: getWaterAmountDisplay(props.Brew)})
68
+
@components.JournalField(components.DetailStackedProps{Icon: components.IconThermometer(), Label: "Temperature", Value: getTemperatureDisplay(props.Brew)})
69
+
@components.JournalField(components.DetailStackedProps{Icon: components.IconClock(), Label: "Brew Time", Value: getBrewTimeDisplay(props.Brew)})
70
+
if props.Brew.EspressoParams != nil {
71
+
if props.Brew.EspressoParams.YieldWeight > 0 {
72
+
@components.JournalField(components.DetailStackedProps{Icon: components.IconScale(), Label: "Yield", Value: fmt.Sprintf("%.1fg", props.Brew.EspressoParams.YieldWeight)})
73
+
}
74
+
if props.Brew.EspressoParams.Pressure > 0 {
75
+
@components.JournalField(components.DetailStackedProps{Icon: components.IconBarChart(), Label: "Pressure", Value: fmt.Sprintf("%.1f bar", props.Brew.EspressoParams.Pressure)})
76
+
}
77
+
if props.Brew.EspressoParams.PreInfusionSeconds > 0 {
78
+
@components.JournalField(components.DetailStackedProps{Icon: components.IconClock(), Label: "Pre-infusion", Value: fmt.Sprintf("%ds", props.Brew.EspressoParams.PreInfusionSeconds)})
79
+
}
80
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)})
81
+
if props.Brew.PouroverParams != nil {
82
+
if props.Brew.PouroverParams.BloomWater > 0 || props.Brew.PouroverParams.BloomSeconds > 0 {
83
+
@components.JournalField(components.DetailStackedProps{Icon: components.IconDroplet(), Label: "Bloom", Value: formatBloom(props.Brew.PouroverParams)})
84
+
}
85
+
if props.Brew.PouroverParams.DrawdownSeconds > 0 {
86
+
@components.JournalField(components.DetailStackedProps{Icon: components.IconClock(), Label: "Drawdown", Value: fmt.Sprintf("%ds", props.Brew.PouroverParams.DrawdownSeconds)})
87
+
}
88
+
if props.Brew.PouroverParams.BypassWater > 0 {
89
+
@components.JournalField(components.DetailStackedProps{Icon: components.IconDroplet(), Label: "Bypass Water", Value: fmt.Sprintf("%dg", props.Brew.PouroverParams.BypassWater)})
90
+
}
91
+
if props.Brew.PouroverParams.Filter != "" {
92
+
@components.JournalField(components.DetailStackedProps{Icon: components.IconSliders(), Label: "Filter", Value: props.Brew.PouroverParams.Filter})
93
+
}
83
94
}
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)})
95
+
</div>
96
+
<div class="space-y-6">
97
+
if props.Brew.RecipeObj != nil {
98
+
@BrewRecipeSection(props.Brew.RecipeObj, getOwnerFromShareURL(props.ShareURL))
88
99
}
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)})
100
+
if props.Brew.Pours != nil && len(props.Brew.Pours) > 0 {
101
+
@BrewPoursSection(props.Brew.Pours)
91
102
}
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)})
103
+
if props.Brew.TastingNotes != "" {
104
+
@BrewTastingNotes(props.Brew.TastingNotes)
94
105
}
95
-
if props.Brew.PouroverParams.Filter != "" {
96
-
@components.DetailStacked(components.DetailStackedProps{Icon: components.IconSliders(), Label: "Filter", Value: props.Brew.PouroverParams.Filter})
106
+
if props.IsOwnProfile && props.Brew.RecipeObj == nil {
107
+
@SaveAsRecipeButton(props.Brew.RKey)
97
108
}
98
-
}
99
-
</div>
100
-
<div class="space-y-6">
101
-
if props.Brew.RecipeObj != nil {
102
-
@BrewRecipeSection(props.Brew.RecipeObj, getOwnerFromShareURL(props.ShareURL))
103
-
}
104
-
if props.Brew.Pours != nil && len(props.Brew.Pours) > 0 {
105
-
@BrewPoursSection(props.Brew.Pours)
106
-
}
107
-
if props.Brew.TastingNotes != "" {
108
-
@BrewTastingNotes(props.Brew.TastingNotes)
109
-
}
110
-
if props.IsOwnProfile && props.Brew.RecipeObj == nil {
111
-
@SaveAsRecipeButton(props.Brew.RKey)
112
-
}
109
+
</div>
113
110
</div>
114
111
<div class="record-view-footer">
115
112
@components.BackButton()
···
150
147
})
151
148
}
152
149
150
+
// BrewSummary renders the at-a-glance headline at the top of the journal:
151
+
// the rating on the left paired with a compact recipe summary on the right.
152
+
templ BrewSummary(brew *models.Brew) {
153
+
if brew.Rating > 0 || hasBrewSummaryStats(brew) {
154
+
<div class="brew-summary">
155
+
if brew.Rating > 0 {
156
+
<div class="brew-rating-hero">
157
+
<span class="brew-rating-value">{ fmt.Sprintf("%d", brew.Rating) }</span>
158
+
<span class="brew-rating-max">/10</span>
159
+
</div>
160
+
}
161
+
if hasBrewSummaryStats(brew) {
162
+
<dl class="brew-summary-stats">
163
+
if ratio := getBrewRatioDisplay(brew); ratio != "" {
164
+
<div class="brew-summary-stat">
165
+
<dt>Ratio</dt>
166
+
<dd>{ ratio }</dd>
167
+
</div>
168
+
}
169
+
if t := getBrewTimeDisplay(brew); t != "" {
170
+
<div class="brew-summary-stat">
171
+
<dt>Time</dt>
172
+
<dd>{ t }</dd>
173
+
</div>
174
+
}
175
+
if m := getBrewerName(brew); m != "" {
176
+
<div class="brew-summary-stat">
177
+
<dt>Method</dt>
178
+
<dd>{ m }</dd>
179
+
</div>
180
+
}
181
+
</dl>
182
+
}
183
+
</div>
184
+
}
185
+
}
186
+
187
+
func hasBrewSummaryStats(brew *models.Brew) bool {
188
+
return getBrewRatioDisplay(brew) != "" || getBrewTimeDisplay(brew) != "" || getBrewerName(brew) != ""
189
+
}
190
+
191
+
func getBrewRatioDisplay(brew *models.Brew) string {
192
+
water := 0
193
+
if brew.WaterAmount > 0 {
194
+
water = brew.WaterAmount
195
+
} else {
196
+
for _, pour := range brew.Pours {
197
+
water += pour.WaterAmount
198
+
}
199
+
}
200
+
if brew.CoffeeAmount > 0 && water > 0 {
201
+
return fmt.Sprintf("1:%.1f", float64(water)/float64(brew.CoffeeAmount))
202
+
}
203
+
return ""
204
+
}
205
+
153
206
// BrewBeanSection renders the coffee bean as a prominent reference card
154
207
templ BrewBeanSection(brew *models.Brew, owner string) {
155
208
if brew.Bean != nil {
156
-
<div class="brew-bean-ref mb-2">
209
+
<div class="journal-bean-ref mb-2">
157
210
<span class="detail-label mb-2 block">
158
211
<span class="inline-flex items-center gap-1">
159
212
@components.IconBean()
···
417
470
Tasting Notes
418
471
</span>
419
472
</span>
420
-
<div class="prose-note">{ notes }</div>
473
+
<div class="journal-prose">{ notes }</div>
421
474
</div>
422
475
}
+28
-27
internal/web/pages/brewer_view.templ
+28
-27
internal/web/pages/brewer_view.templ
···
10
10
)
11
11
12
12
type BrewerViewProps struct {
13
-
Brewer *models.Brewer
14
-
IsOwnProfile bool
15
-
IsAuthenticated bool
16
-
SubjectURI string
17
-
SubjectCID string
18
-
IsLiked bool
19
-
LikeCount int
20
-
CommentCount int
21
-
Comments []firehose.IndexedComment
22
-
CurrentUserDID string
23
-
ShareURL string
24
-
IsModerator bool
25
-
CanHideRecord bool
26
-
CanBlockUser bool
27
-
IsRecordHidden bool
13
+
Brewer *models.Brewer
14
+
IsOwnProfile bool
15
+
IsAuthenticated bool
16
+
SubjectURI string
17
+
SubjectCID string
18
+
IsLiked bool
19
+
LikeCount int
20
+
CommentCount int
21
+
Comments []firehose.IndexedComment
22
+
CurrentUserDID string
23
+
ShareURL string
24
+
IsModerator bool
25
+
CanHideRecord bool
26
+
CanBlockUser bool
27
+
IsRecordHidden bool
28
28
AuthorDID string
29
29
AuthorHandle string
30
30
AuthorDisplayName string
···
53
53
AuthorDisplay: props.AuthorDisplayName,
54
54
AuthorAvatar: props.AuthorAvatar,
55
55
})
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
56
+
<div class="record-journal p-4">
57
+
@components.JournalField(components.DetailStackedProps{Icon: components.IconCoffee(), Label: "Type", Value: props.Brewer.BrewerType})
58
+
if props.Brewer.Description != "" {
59
+
<div class="mt-6">
60
+
<span class="detail-label mb-2 block">
61
+
<span class="inline-flex items-center gap-1">
62
+
@components.IconFileText()
63
+
Description
64
+
</span>
63
65
</span>
64
-
</span>
65
-
<div class="prose-note">{ props.Brewer.Description }</div>
66
-
</div>
67
-
}
66
+
<div class="journal-prose">{ props.Brewer.Description }</div>
67
+
</div>
68
+
}
69
+
</div>
68
70
if props.BrewCount > 0 {
69
71
<div class="record-stat-line">
70
72
<span class="flex items-center gap-1">
···
111
113
ViewURL: props.ShareURL,
112
114
})
113
115
}
114
-
+31
-30
internal/web/pages/grinder_view.templ
+31
-30
internal/web/pages/grinder_view.templ
···
10
10
)
11
11
12
12
type GrinderViewProps struct {
13
-
Grinder *models.Grinder
14
-
IsOwnProfile bool
15
-
IsAuthenticated bool
16
-
SubjectURI string
17
-
SubjectCID string
18
-
IsLiked bool
19
-
LikeCount int
20
-
CommentCount int
21
-
Comments []firehose.IndexedComment
22
-
CurrentUserDID string
23
-
ShareURL string
24
-
IsModerator bool
25
-
CanHideRecord bool
26
-
CanBlockUser bool
27
-
IsRecordHidden bool
13
+
Grinder *models.Grinder
14
+
IsOwnProfile bool
15
+
IsAuthenticated bool
16
+
SubjectURI string
17
+
SubjectCID string
18
+
IsLiked bool
19
+
LikeCount int
20
+
CommentCount int
21
+
Comments []firehose.IndexedComment
22
+
CurrentUserDID string
23
+
ShareURL string
24
+
IsModerator bool
25
+
CanHideRecord bool
26
+
CanBlockUser bool
27
+
IsRecordHidden bool
28
28
AuthorDID string
29
29
AuthorHandle string
30
30
AuthorDisplayName string
···
53
53
AuthorDisplay: props.AuthorDisplayName,
54
54
AuthorAvatar: props.AuthorAvatar,
55
55
})
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
66
-
</span>
67
-
</span>
68
-
<div class="prose-note">{ props.Grinder.Notes }</div>
56
+
<div class="record-journal p-4">
57
+
<div>
58
+
@components.JournalField(components.DetailStackedProps{Icon: components.IconGear(), Label: "Type", Value: props.Grinder.GrinderType})
59
+
@components.JournalField(components.DetailStackedProps{Icon: components.IconDisc(), Label: "Burr Type", Value: props.Grinder.BurrType})
69
60
</div>
70
-
}
61
+
if props.Grinder.Notes != "" {
62
+
<div class="mt-6">
63
+
<span class="detail-label mb-2 block">
64
+
<span class="inline-flex items-center gap-1">
65
+
@components.IconFileText()
66
+
Notes
67
+
</span>
68
+
</span>
69
+
<div class="journal-prose">{ props.Grinder.Notes }</div>
70
+
</div>
71
+
}
72
+
</div>
71
73
if props.BrewCount > 0 {
72
74
<div class="record-stat-line">
73
75
<span class="flex items-center gap-1">
···
114
116
ViewURL: props.ShareURL,
115
117
})
116
118
}
117
-
+50
-49
internal/web/pages/recipe_view.templ
+50
-49
internal/web/pages/recipe_view.templ
···
66
66
</a>
67
67
</p>
68
68
}
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">
91
-
if len(props.Recipe.Pours) > 0 {
92
-
<div>
93
-
<span class="detail-label mb-3 block">
94
-
<span class="inline-flex items-center gap-1">
95
-
@components.IconDroplet()
96
-
Pours
69
+
<div class="record-journal p-4">
70
+
<div>
71
+
if props.Recipe.CoffeeAmount > 0 {
72
+
@components.JournalField(components.DetailStackedProps{Icon: components.IconCoffee(), Label: "Coffee", Value: fmt.Sprintf("%.1fg", props.Recipe.CoffeeAmount)})
73
+
}
74
+
if props.Recipe.WaterAmount > 0 {
75
+
@components.JournalField(components.DetailStackedProps{Icon: components.IconDroplet(), Label: "Water", Value: fmt.Sprintf("%.1fg", props.Recipe.WaterAmount)})
76
+
}
77
+
if props.Recipe.Ratio > 0 {
78
+
@components.JournalField(components.DetailStackedProps{Icon: components.IconScale(), Label: "Ratio", Value: fmt.Sprintf("1:%.1f", props.Recipe.Ratio)})
79
+
}
80
+
if props.Recipe.BrewerObj != nil {
81
+
@components.JournalField(components.DetailStackedProps{
82
+
Icon: components.IconBrewer(),
83
+
Label: "Brewer",
84
+
Value: props.Recipe.BrewerObj.Name,
85
+
LinkHref: recipeBrewerLink(props),
86
+
})
87
+
} else if props.Recipe.BrewerType != "" {
88
+
@components.JournalField(components.DetailStackedProps{Icon: components.IconBrewer(), Label: "Brewer Type", Value: props.Recipe.BrewerType})
89
+
}
90
+
</div>
91
+
<div class="space-y-6 mt-6">
92
+
if len(props.Recipe.Pours) > 0 {
93
+
<div>
94
+
<span class="detail-label mb-3 block">
95
+
<span class="inline-flex items-center gap-1">
96
+
@components.IconDroplet()
97
+
Pours
98
+
</span>
97
99
</span>
98
-
</span>
99
-
<div class="space-y-2">
100
-
for _, pour := range props.Recipe.Pours {
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>
104
-
</div>
105
-
}
100
+
<div>
101
+
for _, pour := range props.Recipe.Pours {
102
+
<div class="flex gap-4 text-sm py-2" style="border-bottom: 1px solid var(--surface-border)">
103
+
<span class="detail-value">{ fmt.Sprintf("%dg", pour.WaterAmount) }</span>
104
+
<span style="color: var(--text-muted)">{ "for " + bff.FormatTime(pour.TimeSeconds) }</span>
105
+
</div>
106
+
}
107
+
</div>
106
108
</div>
107
-
</div>
108
-
}
109
-
if props.Recipe.Notes != "" {
110
-
<div>
111
-
<span class="detail-label mb-2 block">
112
-
<span class="inline-flex items-center gap-1">
113
-
@components.IconFileText()
114
-
Notes
109
+
}
110
+
if props.Recipe.Notes != "" {
111
+
<div>
112
+
<span class="detail-label mb-2 block">
113
+
<span class="inline-flex items-center gap-1">
114
+
@components.IconFileText()
115
+
Notes
116
+
</span>
115
117
</span>
116
-
</span>
117
-
<div class="prose-note">{ props.Recipe.Notes }</div>
118
-
</div>
119
-
}
118
+
<div class="journal-prose">{ props.Recipe.Notes }</div>
119
+
</div>
120
+
}
121
+
</div>
120
122
</div>
121
123
<div class="record-view-footer">
122
124
<div class="flex items-center gap-3">
···
181
183
})
182
184
}
183
185
184
-
185
186
func recipeBrewerLink(props RecipeViewProps) string {
186
187
if props.Recipe.BrewerObj == nil || props.Recipe.BrewerRKey == "" {
187
188
return ""
+30
-19
internal/web/pages/roaster_view.templ
+30
-19
internal/web/pages/roaster_view.templ
···
10
10
)
11
11
12
12
type RoasterViewProps struct {
13
-
Roaster *models.Roaster
14
-
IsOwnProfile bool
15
-
IsAuthenticated bool
16
-
SubjectURI string
17
-
SubjectCID string
18
-
IsLiked bool
19
-
LikeCount int
20
-
CommentCount int
21
-
Comments []firehose.IndexedComment
22
-
CurrentUserDID string
23
-
ShareURL string
24
-
IsModerator bool
25
-
CanHideRecord bool
26
-
CanBlockUser bool
27
-
IsRecordHidden bool
13
+
Roaster *models.Roaster
14
+
IsOwnProfile bool
15
+
IsAuthenticated bool
16
+
SubjectURI string
17
+
SubjectCID string
18
+
IsLiked bool
19
+
LikeCount int
20
+
CommentCount int
21
+
Comments []firehose.IndexedComment
22
+
CurrentUserDID string
23
+
ShareURL string
24
+
IsModerator bool
25
+
CanHideRecord bool
26
+
CanBlockUser bool
27
+
IsRecordHidden bool
28
28
AuthorDID string
29
29
AuthorHandle string
30
30
AuthorDisplayName string
···
53
53
AuthorDisplay: props.AuthorDisplayName,
54
54
AuthorAvatar: props.AuthorAvatar,
55
55
})
56
-
<div class="space-y-4">
57
-
@components.DetailStacked(components.DetailStackedProps{Icon: components.IconMapPin(), Label: "Location", Value: props.Roaster.Location, Large: true})
56
+
<div class="record-label p-4">
57
+
<div class="label-detail">
58
+
<span class="detail-label">
59
+
<span class="inline-flex items-center gap-1">
60
+
@components.IconMapPin()
61
+
Location
62
+
</span>
63
+
</span>
64
+
if props.Roaster.Location != "" {
65
+
<span class="label-origin-hero" style="font-size: 1.5rem">{ props.Roaster.Location }</span>
66
+
} else {
67
+
<span class="text-sm" style="color: var(--text-faint)">—</span>
68
+
}
69
+
</div>
58
70
if props.Roaster.Website != "" {
59
-
<div class="detail-stacked">
71
+
<div class="label-detail">
60
72
<span class="detail-label">
61
73
<span class="inline-flex items-center gap-1">
62
74
@components.IconLink()
···
117
129
ViewURL: props.ShareURL,
118
130
})
119
131
}
120
-
+165
-37
static/css/app.css
+165
-37
static/css/app.css
···
34
34
--feed-board-border: #B09470;
35
35
/* Sticky note base — warm off-white */
36
36
--feed-card-bg: #FFFDF5;
37
-
--texture-paper: none;
37
+
/* Dot-grid — faint dots on warm paper, for journal pages */
38
+
--texture-dotgrid: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20'%3E%3Ccircle cx='10' cy='10' r='0.7' fill='%23d2bab0' fill-opacity='0.35'/%3E%3C/svg%3E");
39
+
/* Kraft grain — subtle fiber texture for label pages */
40
+
--texture-kraft: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='150' height='150'%3E%3Cfilter id='k'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='matrix' values='0.15 0 0 0 0.1 0.1 0 0 0 0.06 0.05 0 0 0 0.02 0 0 0 0.07 0'/%3E%3C/filter%3E%3Crect width='150' height='150' filter='url(%23k)'/%3E%3C/svg%3E");
41
+
/* Journal paper — warm cream */
42
+
--journal-bg: #FFFDF5;
43
+
/* Kraft — slightly warmer/darker than card bg */
44
+
--kraft-bg: #F7F0E8;
38
45
}
39
46
40
47
/* ========================================
···
154
161
--surface-bg: rgba(36, 26, 22, 0.6);
155
162
--surface-border: #2E211B;
156
163
164
+
/* Journal/label page backgrounds */
165
+
--texture-dotgrid: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20'%3E%3Ccircle cx='10' cy='10' r='0.7' fill='%235A4A40' fill-opacity='0.4'/%3E%3C/svg%3E");
166
+
--texture-kraft: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='150' height='150'%3E%3Cfilter id='k'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='matrix' values='0.08 0 0 0 0.04 0.06 0 0 0 0.03 0.03 0 0 0 0.01 0 0 0 0.06 0'/%3E%3C/filter%3E%3Crect width='150' height='150' filter='url(%23k)'/%3E%3C/svg%3E");
167
+
--journal-bg: #1A1210;
168
+
--kraft-bg: #201814;
169
+
157
170
/* Feed board — noticeably lighter than cards for contrast */
158
171
--feed-board-bg: #3D2D22;
159
172
--feed-board-border: #4A3828;
···
316
329
--feed-board-bg: #3D2D22;
317
330
--feed-board-border: #4A3828;
318
331
--feed-card-bg: #1A1210;
332
+
--texture-dotgrid: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20'%3E%3Ccircle cx='10' cy='10' r='0.7' fill='%235A4A40' fill-opacity='0.4'/%3E%3C/svg%3E");
333
+
--texture-kraft: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='150' height='150'%3E%3Cfilter id='k'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='matrix' values='0.08 0 0 0 0.04 0.06 0 0 0 0.03 0.03 0 0 0 0.01 0 0 0 0.06 0'/%3E%3C/filter%3E%3Crect width='150' height='150' filter='url(%23k)'/%3E%3C/svg%3E");
334
+
--journal-bg: #1A1210;
335
+
--kraft-bg: #201814;
319
336
--header-bg-from: #0F0A08;
320
337
--header-bg-to: #0F0A08;
321
338
--header-border: #2E211B;
···
561
578
color: var(--text-muted);
562
579
}
563
580
564
-
/* Record view — typography-driven detail fields (no container boxes) */
581
+
/* ── Shared record view tokens ── */
565
582
.detail-label {
566
583
@apply text-xs font-medium uppercase tracking-wider;
567
584
color: var(--text-muted);
···
577
594
color: var(--text-primary);
578
595
}
579
596
580
-
.detail-value a {
581
-
color: var(--text-primary);
582
-
}
583
-
584
-
.detail-value a:hover {
585
-
text-decoration: underline;
586
-
}
597
+
.detail-value a { color: var(--text-primary); }
598
+
.detail-value a:hover { text-decoration: underline; }
587
599
588
-
/* Stacked: label on top, value below */
589
600
.detail-stacked {
590
601
@apply flex flex-col gap-1;
591
602
}
592
603
593
-
/* Inline: label and value side by side */
594
604
.detail-inline {
595
605
@apply flex items-baseline gap-2;
596
606
}
597
607
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;
608
+
.record-view-footer {
609
+
@apply flex justify-between items-center pt-4 mt-6;
601
610
}
602
611
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);
612
+
.record-stat-line {
613
+
@apply flex items-center gap-3 text-xs pt-3;
614
+
color: var(--text-muted);
608
615
}
609
616
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;
617
+
.brew-rating-hero {
618
+
@apply inline-flex items-center gap-2 py-1;
615
619
}
616
620
617
-
.brew-bean-ref:hover {
618
-
background: var(--type-bean-tint);
621
+
.brew-summary {
622
+
@apply flex flex-wrap items-center gap-x-8 gap-y-4 mb-6 pb-5;
623
+
border-bottom: 1px solid var(--surface-border);
619
624
}
620
625
621
-
/* Hero rating for brew view */
622
-
.brew-rating-hero {
623
-
@apply inline-flex items-center gap-2 py-1;
626
+
.brew-summary-stats {
627
+
@apply flex flex-wrap gap-x-6 gap-y-3;
628
+
flex: 1 1 auto;
629
+
}
630
+
631
+
.brew-summary-stat {
632
+
@apply flex flex-col gap-0.5;
633
+
}
634
+
635
+
.brew-summary-stat dt {
636
+
@apply uppercase tracking-wider;
637
+
font-size: 0.65rem;
638
+
color: var(--text-muted);
639
+
}
640
+
641
+
.brew-summary-stat dd {
642
+
@apply text-base font-semibold;
643
+
color: var(--text-primary);
644
+
font-variant-numeric: tabular-nums;
624
645
}
625
646
626
647
.brew-rating-value {
···
633
654
color: var(--text-muted);
634
655
}
635
656
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);
657
+
/* ── Treatment 1: Brew Journal ──
658
+
Dot-grid paper, ledger-style fields, for brews/recipes/equipment */
659
+
660
+
.record-journal {
661
+
background-color: var(--journal-bg);
662
+
background-image: var(--texture-dotgrid);
663
+
@apply rounded-lg;
640
664
}
641
665
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;
666
+
/* Ledger field: label left, value right, separated by faint line */
667
+
.journal-field {
668
+
@apply flex items-baseline justify-between py-3;
669
+
border-bottom: 1px solid var(--surface-border);
670
+
}
671
+
672
+
.journal-field:last-child {
673
+
border-bottom: none;
674
+
}
675
+
676
+
.journal-field .detail-label {
677
+
@apply flex-shrink-0;
678
+
width: 9rem;
679
+
}
680
+
681
+
.journal-field .detail-value,
682
+
.journal-field .detail-value-lg {
683
+
@apply text-right flex-1;
684
+
}
685
+
686
+
.journal-field a.detail-value {
687
+
@apply text-right;
688
+
}
689
+
690
+
/* On mobile, stack journal fields vertically */
691
+
@media (max-width: 480px) {
692
+
.journal-field {
693
+
@apply flex-col items-start gap-1;
694
+
}
695
+
.journal-field .detail-value,
696
+
.journal-field .detail-value-lg {
697
+
@apply text-left;
698
+
}
699
+
}
700
+
701
+
/* Prose section in journal context */
702
+
.journal-prose {
703
+
@apply whitespace-pre-wrap leading-relaxed;
704
+
color: var(--text-primary);
705
+
}
706
+
707
+
/* Bean reference inside a journal brew page */
708
+
.journal-bean-ref {
709
+
@apply rounded-lg p-4 mb-2;
710
+
background: var(--surface-bg);
711
+
transition: background 0.15s ease;
712
+
}
713
+
714
+
.journal-bean-ref:hover {
715
+
background: var(--type-bean-tint);
716
+
}
717
+
718
+
/* ── Treatment 2: Craft Label ──
719
+
Kraft paper texture, bold typographic hierarchy, for beans/roasters */
720
+
721
+
.record-label {
722
+
background-color: var(--kraft-bg);
723
+
background-image: var(--texture-kraft);
724
+
@apply rounded-lg;
725
+
}
726
+
727
+
/* Origin hero — the biggest element on a bean label */
728
+
.label-origin-hero {
729
+
@apply text-3xl sm:text-4xl font-bold tracking-tight leading-tight;
730
+
color: var(--text-primary);
731
+
}
732
+
733
+
/* Roaster byline under bean name */
734
+
.label-byline {
735
+
@apply text-sm mt-1;
645
736
color: var(--text-muted);
646
-
border-top: 1px solid var(--surface-border);
737
+
}
738
+
739
+
.label-byline a {
740
+
color: var(--text-secondary);
741
+
}
742
+
743
+
.label-byline a:hover {
744
+
text-decoration: underline;
745
+
color: var(--text-primary);
746
+
}
747
+
748
+
/* Inline tag strip: variety / process / roast level */
749
+
.label-tags {
750
+
@apply flex flex-wrap gap-2 mt-4;
751
+
}
752
+
753
+
.label-tag {
754
+
@apply text-xs font-medium px-2.5 py-1 rounded-full;
755
+
background: var(--surface-bg);
756
+
color: var(--text-secondary);
757
+
border: 1px solid var(--surface-border);
758
+
}
759
+
760
+
/* Description on a label — flowing paragraph, no indent */
761
+
.label-description {
762
+
@apply leading-relaxed mt-6;
763
+
color: var(--text-primary);
764
+
max-width: 65ch;
765
+
}
766
+
767
+
/* Stacked detail for label pages (location, website) */
768
+
.label-detail {
769
+
@apply flex flex-col gap-1 py-3;
770
+
border-bottom: 1px solid var(--surface-border);
771
+
}
772
+
773
+
.label-detail:last-child {
774
+
border-bottom: none;
647
775
}
648
776
649
777
/* Form field groups — semantic clusters within modals */
History
1 round
0 comments
pdewey.com
submitted
#0
1 commit
expand
collapse
feat: view page redesign cont.
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