tangled
alpha
login
or
join now
arabica.social
/
arabica
7
fork
atom
Coffee journaling on ATProto (alpha)
alpha.arabica.social
coffee
7
fork
atom
overview
issues
pulls
pipelines
refactor: moderation details compoment
pdewey.com
2 weeks ago
6c3fa527
bea02985
verified
This commit was signed with the committer's
known signature
.
pdewey.com
SSH Key Fingerprint:
SHA256:ePOVkJstqVLchGK8m9/OGQG+aFNHD5XN3xjvW9wKCA4=
+94
-170
10 changed files
expand all
collapse all
unified
split
internal
handlers
admin.go
report.go
web
components
action_bar.templ
dialog_modals.templ
shared.templ
pages
bean_view.templ
brew_view.templ
brewer_view.templ
grinder_view.templ
roaster_view.templ
+38
-68
internal/handlers/admin.go
···
2
3
import (
4
"context"
5
-
"encoding/json"
6
"net/http"
7
"time"
8
···
13
"arabica/internal/web/components"
14
"arabica/internal/web/pages"
15
0
16
"github.com/rs/zerolog/log"
17
)
18
···
33
34
// Check permission
35
if h.moderationService == nil || !h.moderationService.HasPermission(userDID, moderation.PermissionHideRecord) {
0
36
http.Error(w, "Permission denied", http.StatusForbidden)
37
return
38
}
39
40
-
// Parse request - support both JSON and form data
41
-
var req hideRequest
42
-
contentType := r.Header.Get("Content-Type")
43
-
if contentType == "application/json" {
44
-
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
45
-
http.Error(w, "Invalid request body", http.StatusBadRequest)
46
-
return
47
-
}
48
-
} else {
49
-
// Parse as form data (HTMX default)
50
-
if err := r.ParseForm(); err != nil {
51
-
http.Error(w, "Invalid request body", http.StatusBadRequest)
52
-
return
53
-
}
54
-
req.URI = r.FormValue("uri")
55
-
req.Reason = r.FormValue("reason")
56
}
0
0
0
57
58
if req.URI == "" {
59
http.Error(w, "URI is required", http.StatusBadRequest)
···
110
111
// Check permission
112
if h.moderationService == nil || !h.moderationService.HasPermission(userDID, moderation.PermissionUnhideRecord) {
0
113
http.Error(w, "Permission denied", http.StatusForbidden)
114
return
115
}
116
117
-
// Parse request - support both JSON and form data
0
0
0
0
118
var req hideRequest
119
-
contentType := r.Header.Get("Content-Type")
120
-
if contentType == "application/json" {
121
-
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
122
-
http.Error(w, "Invalid request body", http.StatusBadRequest)
123
-
return
124
-
}
125
-
} else {
126
-
// Parse as form data (HTMX default)
127
-
if err := r.ParseForm(); err != nil {
128
-
http.Error(w, "Invalid request body", http.StatusBadRequest)
129
-
return
130
-
}
131
-
req.URI = r.FormValue("uri")
132
-
req.Reason = r.FormValue("reason")
133
-
}
134
135
if req.URI == "" {
136
http.Error(w, "URI is required", http.StatusBadRequest)
···
167
w.WriteHeader(http.StatusOK)
168
}
169
170
-
// generateTID generates a TID (timestamp-based identifier)
171
func generateTID() string {
172
-
// Simple implementation using unix nano timestamp
173
-
// In production, you might want a more sophisticated TID generator
174
-
return time.Now().Format("20060102150405.000000000")
175
}
176
177
// buildAdminProps builds the admin dashboard props for the given moderator.
···
241
242
// Check if user is a moderator
243
if h.moderationService == nil || !h.moderationService.IsModerator(userDID) {
0
244
http.Error(w, "Access denied", http.StatusForbidden)
245
return
246
}
···
272
}
273
274
if h.moderationService == nil || !h.moderationService.IsModerator(userDID) {
0
275
http.Error(w, "Access denied", http.StatusForbidden)
276
return
277
}
···
382
383
// Check permission
384
if h.moderationService == nil || !h.moderationService.HasPermission(userDID, moderation.PermissionBlacklistUser) {
0
385
http.Error(w, "Permission denied", http.StatusForbidden)
386
return
387
}
388
389
-
// Parse request - support both JSON and form data
0
0
0
0
390
var req blockRequest
391
-
contentType := r.Header.Get("Content-Type")
392
-
if contentType == "application/json" {
393
-
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
394
-
http.Error(w, "Invalid request body", http.StatusBadRequest)
395
-
return
396
-
}
397
-
} else {
398
-
// Parse as form data (HTMX default)
399
-
if err := r.ParseForm(); err != nil {
400
-
http.Error(w, "Invalid request body", http.StatusBadRequest)
401
-
return
402
-
}
403
-
req.DID = r.FormValue("did")
404
-
req.Reason = r.FormValue("reason")
405
-
}
406
407
if req.DID == "" {
408
http.Error(w, "DID is required", http.StatusBadRequest)
···
457
458
// Check permission
459
if h.moderationService == nil || !h.moderationService.HasPermission(userDID, moderation.PermissionUnblacklistUser) {
0
460
http.Error(w, "Permission denied", http.StatusForbidden)
461
return
462
}
463
464
-
// Parse request - support both JSON and form data
465
-
var req blockRequest
466
-
contentType := r.Header.Get("Content-Type")
467
-
if contentType == "application/json" {
468
-
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
469
-
http.Error(w, "Invalid request body", http.StatusBadRequest)
470
-
return
471
-
}
472
-
} else {
473
-
// Parse as form data (HTMX default)
474
-
if err := r.ParseForm(); err != nil {
475
-
http.Error(w, "Invalid request body", http.StatusBadRequest)
476
-
return
477
-
}
478
-
req.DID = r.FormValue("did")
479
}
0
0
480
481
if req.DID == "" {
482
http.Error(w, "DID is required", http.StatusBadRequest)
···
522
}
523
524
if h.moderationService == nil || !h.moderationService.HasPermission(userDID, moderation.PermissionResetAutoHide) {
0
525
http.Error(w, "Permission denied", http.StatusForbidden)
526
return
527
}
···
576
577
// Check permission
578
if h.moderationService == nil || !h.moderationService.HasPermission(userDID, moderation.PermissionDismissReport) {
0
579
http.Error(w, "Permission denied", http.StatusForbidden)
580
return
581
}
···
2
3
import (
4
"context"
0
5
"net/http"
6
"time"
7
···
12
"arabica/internal/web/components"
13
"arabica/internal/web/pages"
14
15
+
"github.com/bluesky-social/indigo/atproto/syntax"
16
"github.com/rs/zerolog/log"
17
)
18
···
33
34
// Check permission
35
if h.moderationService == nil || !h.moderationService.HasPermission(userDID, moderation.PermissionHideRecord) {
36
+
log.Warn().Str("did", userDID).Str("endpoint", "/_mod/hide").Msg("Denied: insufficient permissions")
37
http.Error(w, "Permission denied", http.StatusForbidden)
38
return
39
}
40
41
+
// Parse form data only (JSON is rejected to prevent CSRF bypass)
42
+
if err := r.ParseForm(); err != nil {
43
+
http.Error(w, "Invalid request body", http.StatusBadRequest)
44
+
return
0
0
0
0
0
0
0
0
0
0
0
0
45
}
46
+
var req hideRequest
47
+
req.URI = r.FormValue("uri")
48
+
req.Reason = r.FormValue("reason")
49
50
if req.URI == "" {
51
http.Error(w, "URI is required", http.StatusBadRequest)
···
102
103
// Check permission
104
if h.moderationService == nil || !h.moderationService.HasPermission(userDID, moderation.PermissionUnhideRecord) {
105
+
log.Warn().Str("did", userDID).Str("endpoint", "/_mod/unhide").Msg("Denied: insufficient permissions")
106
http.Error(w, "Permission denied", http.StatusForbidden)
107
return
108
}
109
110
+
// Parse form data only (JSON is rejected to prevent CSRF bypass)
111
+
if err := r.ParseForm(); err != nil {
112
+
http.Error(w, "Invalid request body", http.StatusBadRequest)
113
+
return
114
+
}
115
var req hideRequest
116
+
req.URI = r.FormValue("uri")
117
+
req.Reason = r.FormValue("reason")
0
0
0
0
0
0
0
0
0
0
0
0
0
118
119
if req.URI == "" {
120
http.Error(w, "URI is required", http.StatusBadRequest)
···
151
w.WriteHeader(http.StatusOK)
152
}
153
154
+
// generateTID generates a TID (timestamp-based identifier) using the AT Protocol TID format.
155
func generateTID() string {
156
+
return syntax.NewTIDNow(0).String()
0
0
157
}
158
159
// buildAdminProps builds the admin dashboard props for the given moderator.
···
223
224
// Check if user is a moderator
225
if h.moderationService == nil || !h.moderationService.IsModerator(userDID) {
226
+
log.Warn().Str("did", userDID).Str("endpoint", "/_mod").Msg("Denied: not a moderator")
227
http.Error(w, "Access denied", http.StatusForbidden)
228
return
229
}
···
255
}
256
257
if h.moderationService == nil || !h.moderationService.IsModerator(userDID) {
258
+
log.Warn().Str("did", userDID).Str("endpoint", "/_mod/content").Msg("Denied: not a moderator")
259
http.Error(w, "Access denied", http.StatusForbidden)
260
return
261
}
···
366
367
// Check permission
368
if h.moderationService == nil || !h.moderationService.HasPermission(userDID, moderation.PermissionBlacklistUser) {
369
+
log.Warn().Str("did", userDID).Str("endpoint", "/_mod/block").Msg("Denied: insufficient permissions")
370
http.Error(w, "Permission denied", http.StatusForbidden)
371
return
372
}
373
374
+
// Parse form data only (JSON is rejected to prevent CSRF bypass)
375
+
if err := r.ParseForm(); err != nil {
376
+
http.Error(w, "Invalid request body", http.StatusBadRequest)
377
+
return
378
+
}
379
var req blockRequest
380
+
req.DID = r.FormValue("did")
381
+
req.Reason = r.FormValue("reason")
0
0
0
0
0
0
0
0
0
0
0
0
0
382
383
if req.DID == "" {
384
http.Error(w, "DID is required", http.StatusBadRequest)
···
433
434
// Check permission
435
if h.moderationService == nil || !h.moderationService.HasPermission(userDID, moderation.PermissionUnblacklistUser) {
436
+
log.Warn().Str("did", userDID).Str("endpoint", "/_mod/unblock").Msg("Denied: insufficient permissions")
437
http.Error(w, "Permission denied", http.StatusForbidden)
438
return
439
}
440
441
+
// Parse form data only (JSON is rejected to prevent CSRF bypass)
442
+
if err := r.ParseForm(); err != nil {
443
+
http.Error(w, "Invalid request body", http.StatusBadRequest)
444
+
return
0
0
0
0
0
0
0
0
0
0
0
445
}
446
+
var req blockRequest
447
+
req.DID = r.FormValue("did")
448
449
if req.DID == "" {
450
http.Error(w, "DID is required", http.StatusBadRequest)
···
490
}
491
492
if h.moderationService == nil || !h.moderationService.HasPermission(userDID, moderation.PermissionResetAutoHide) {
493
+
log.Warn().Str("did", userDID).Str("endpoint", "/_mod/reset-autohide").Msg("Denied: insufficient permissions")
494
http.Error(w, "Permission denied", http.StatusForbidden)
495
return
496
}
···
545
546
// Check permission
547
if h.moderationService == nil || !h.moderationService.HasPermission(userDID, moderation.PermissionDismissReport) {
548
+
log.Warn().Str("did", userDID).Str("endpoint", "/_mod/dismiss-report").Msg("Denied: insufficient permissions")
549
http.Error(w, "Permission denied", http.StatusForbidden)
550
return
551
}
+7
-14
internal/handlers/report.go
···
60
return
61
}
62
63
-
// Parse request (supports both JSON and form data)
64
var req ReportRequest
65
-
if isJSONRequest(r) {
66
-
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
67
-
writeReportError(w, "Invalid JSON", http.StatusBadRequest)
68
-
return
69
-
}
70
-
} else {
71
-
if err := r.ParseForm(); err != nil {
72
-
writeReportError(w, "Invalid form data", http.StatusBadRequest)
73
-
return
74
-
}
75
-
req.SubjectURI = r.FormValue("subject_uri")
76
-
req.SubjectCID = r.FormValue("subject_cid")
77
-
req.Reason = r.FormValue("reason")
78
}
0
0
0
79
80
// Validate subject URI
81
if req.SubjectURI == "" {
···
60
return
61
}
62
63
+
// Parse form data only (JSON is rejected to prevent CSRF bypass)
64
var req ReportRequest
65
+
if err := r.ParseForm(); err != nil {
66
+
writeReportError(w, "Invalid form data", http.StatusBadRequest)
67
+
return
0
0
0
0
0
0
0
0
0
0
68
}
69
+
req.SubjectURI = r.FormValue("subject_uri")
70
+
req.SubjectCID = r.FormValue("subject_cid")
71
+
req.Reason = r.FormValue("reason")
72
73
// Validate subject URI
74
if req.SubjectURI == "" {
+7
-6
internal/web/components/action_bar.templ
···
277
submitting = true;
278
error = '';
279
const dialog = document.getElementById('%s');
0
0
0
0
0
280
fetch('/api/report', {
281
method: 'POST',
282
-
headers: {'Content-Type': 'application/json'},
283
-
body: JSON.stringify({
284
-
subject_uri: '%s',
285
-
subject_cid: '%s',
286
-
reason: reason
287
-
})
288
})
289
.then(r => r.json().then(data => ({ok: r.ok, data})))
290
.then(({ok, data}) => {
···
277
submitting = true;
278
error = '';
279
const dialog = document.getElementById('%s');
280
+
const body = new URLSearchParams({
281
+
subject_uri: '%s',
282
+
subject_cid: '%s',
283
+
reason: reason
284
+
});
285
fetch('/api/report', {
286
method: 'POST',
287
+
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
288
+
body: body
0
0
0
0
289
})
290
.then(r => r.json().then(data => ({ok: r.ok, data})))
291
.then(({ok, data}) => {
+7
-6
internal/web/components/dialog_modals.templ
···
343
submitting = true;
344
error = '';
345
const dialog = document.getElementById('report-modal');
0
0
0
0
0
346
fetch('/api/report', {
347
method: 'POST',
348
-
headers: {'Content-Type': 'application/json'},
349
-
body: JSON.stringify({
350
-
subject_uri: $el.querySelector('[name=subject_uri]').value,
351
-
subject_cid: $el.querySelector('[name=subject_cid]').value,
352
-
reason: reason
353
-
})
354
})
355
.then(r => r.json().then(data => ({ok: r.ok, data})))
356
.then(({ok, data}) => {
···
343
submitting = true;
344
error = '';
345
const dialog = document.getElementById('report-modal');
346
+
const body = new URLSearchParams({
347
+
subject_uri: $el.querySelector('[name=subject_uri]').value,
348
+
subject_cid: $el.querySelector('[name=subject_cid]').value,
349
+
reason: reason
350
+
});
351
fetch('/api/report', {
352
method: 'POST',
353
+
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
354
+
body: body
0
0
0
0
355
})
356
.then(r => r.json().then(data => ({ok: r.ok, data})))
357
.then(({ok, data}) => {
+21
internal/web/components/shared.templ
···
30
}
31
}
32
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
33
type PageHeaderProps struct {
34
Title string
35
BackURL string
···
30
}
31
}
32
33
+
// DetailFieldProps defines properties for a labeled detail field
34
+
type DetailFieldProps struct {
35
+
Label string
36
+
Value string
37
+
LinkHref string // Optional: wraps value in a link
38
+
}
39
+
40
+
// DetailField renders a labeled value with a "Not specified" fallback
41
+
templ DetailField(props DetailFieldProps) {
42
+
<div class="section-box">
43
+
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">{ props.Label }</h3>
44
+
if props.Value != "" && props.LinkHref != "" {
45
+
<a href={ templ.SafeURL(props.LinkHref) } class="font-semibold text-brown-900 hover:underline">{ props.Value }</a>
46
+
} else if props.Value != "" {
47
+
<div class="font-semibold text-brown-900">{ props.Value }</div>
48
+
} else {
49
+
<span class="text-brown-400">Not specified</span>
50
+
}
51
+
</div>
52
+
}
53
+
54
type PageHeaderProps struct {
55
Title string
56
BackURL string
+3
-14
internal/web/pages/bean_view.templ
···
57
</div>
58
}
59
<div class="grid grid-cols-2 gap-4">
60
-
@BeanDetailField("๐ Origin", props.Bean.Origin)
61
-
@BeanDetailField("๐ฅ Roast Level", props.Bean.RoastLevel)
62
-
@BeanDetailField("๐ฑ Process", props.Bean.Process)
63
if props.Bean.Closed {
64
<div class="section-box">
65
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">Status</h3>
···
125
}
126
</h2>
127
<p class="text-sm text-brown-600 mt-1">{ props.Bean.CreatedAt.Format("January 2, 2006 at 3:04 PM") }</p>
128
-
</div>
129
-
}
130
-
131
-
templ BeanDetailField(label, value string) {
132
-
<div class="section-box">
133
-
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">{ label }</h3>
134
-
if value != "" {
135
-
<div class="font-semibold text-brown-900">{ value }</div>
136
-
} else {
137
-
<span class="text-brown-400">Not specified</span>
138
-
}
139
</div>
140
}
141
···
57
</div>
58
}
59
<div class="grid grid-cols-2 gap-4">
60
+
@components.DetailField(components.DetailFieldProps{Label: "๐ Origin", Value: props.Bean.Origin})
61
+
@components.DetailField(components.DetailFieldProps{Label: "๐ฅ Roast Level", Value: props.Bean.RoastLevel})
62
+
@components.DetailField(components.DetailFieldProps{Label: "๐ฑ Process", Value: props.Bean.Process})
63
if props.Bean.Closed {
64
<div class="section-box">
65
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">Status</h3>
···
125
}
126
</h2>
127
<p class="text-sm text-brown-600 mt-1">{ props.Bean.CreatedAt.Format("January 2, 2006 at 3:04 PM") }</p>
0
0
0
0
0
0
0
0
0
0
0
128
</div>
129
}
130
+7
-33
internal/web/pages/brew_view.templ
···
155
// BrewParametersGrid renders the brew parameters in a grid
156
templ BrewParametersGrid(brew *models.Brew, owner string) {
157
<div class="grid grid-cols-2 gap-4">
158
-
@BrewParameter("โ๏ธ Coffee", getCoffeeAmountDisplay(brew))
159
-
@BrewLinkedParameter("โ Brew Method", getBrewerName(brew), getBrewerViewURL(brew, owner))
160
-
@BrewLinkedParameter("โ๏ธ Grinder", getGrinderName(brew), getGrinderViewURL(brew, owner))
161
-
@BrewParameter("๐ฉ Grind Size", getGrindSizeDisplay(brew))
162
-
@BrewParameter("๐ง Water", getWaterAmountDisplay(brew))
163
-
@BrewParameter("๐ก๏ธ Temperature", getTemperatureDisplay(brew))
164
<div class="col-span-2">
165
-
@BrewParameter("โฑ๏ธ Brew Time", getBrewTimeDisplay(brew))
166
</div>
167
-
</div>
168
-
}
169
-
170
-
// BrewParameter renders a single parameter in the grid
171
-
templ BrewParameter(label string, value string) {
172
-
<div class="section-box">
173
-
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">{ label }</h3>
174
-
if value != "" {
175
-
<div class="font-semibold text-brown-900">{ value }</div>
176
-
} else {
177
-
<span class="text-brown-400">Not specified</span>
178
-
}
179
-
</div>
180
-
}
181
-
182
-
// BrewLinkedParameter renders a parameter with a clickable link to the entity view page
183
-
templ BrewLinkedParameter(label string, value string, href string) {
184
-
<div class="section-box">
185
-
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">{ label }</h3>
186
-
if value != "" && href != "" {
187
-
<a href={ templ.SafeURL(href) } class="font-semibold text-brown-900 hover:underline">{ value }</a>
188
-
} else if value != "" {
189
-
<div class="font-semibold text-brown-900">{ value }</div>
190
-
} else {
191
-
<span class="text-brown-400">Not specified</span>
192
-
}
193
</div>
194
}
195
···
155
// BrewParametersGrid renders the brew parameters in a grid
156
templ BrewParametersGrid(brew *models.Brew, owner string) {
157
<div class="grid grid-cols-2 gap-4">
158
+
@components.DetailField(components.DetailFieldProps{Label: "โ๏ธ Coffee", Value: getCoffeeAmountDisplay(brew)})
159
+
@components.DetailField(components.DetailFieldProps{Label: "โ Brew Method", Value: getBrewerName(brew), LinkHref: getBrewerViewURL(brew, owner)})
160
+
@components.DetailField(components.DetailFieldProps{Label: "โ๏ธ Grinder", Value: getGrinderName(brew), LinkHref: getGrinderViewURL(brew, owner)})
161
+
@components.DetailField(components.DetailFieldProps{Label: "๐ฉ Grind Size", Value: getGrindSizeDisplay(brew)})
162
+
@components.DetailField(components.DetailFieldProps{Label: "๐ง Water", Value: getWaterAmountDisplay(brew)})
163
+
@components.DetailField(components.DetailFieldProps{Label: "๐ก๏ธ Temperature", Value: getTemperatureDisplay(brew)})
164
<div class="col-span-2">
165
+
@components.DetailField(components.DetailFieldProps{Label: "โฑ๏ธ Brew Time", Value: getBrewTimeDisplay(brew)})
166
</div>
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
167
</div>
168
}
169
+1
-6
internal/web/pages/brewer_view.templ
···
38
templ BrewerViewCard(props BrewerViewProps) {
39
@BrewerViewHeader(props)
40
<div class="space-y-6">
41
-
if props.Brewer.BrewerType != "" {
42
-
<div class="section-box">
43
-
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">โ Type</h3>
44
-
<div class="font-semibold text-brown-900">{ props.Brewer.BrewerType }</div>
45
-
</div>
46
-
}
47
if props.Brewer.Description != "" {
48
<div class="section-box">
49
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">๐ Description</h3>
···
38
templ BrewerViewCard(props BrewerViewProps) {
39
@BrewerViewHeader(props)
40
<div class="space-y-6">
41
+
@components.DetailField(components.DetailFieldProps{Label: "โ Type", Value: props.Brewer.BrewerType})
0
0
0
0
0
42
if props.Brewer.Description != "" {
43
<div class="section-box">
44
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">๐ Description</h3>
+2
-12
internal/web/pages/grinder_view.templ
···
39
@GrinderViewHeader(props)
40
<div class="space-y-6">
41
<div class="grid grid-cols-2 gap-4">
42
-
@GrinderDetailField("โ๏ธ Type", props.Grinder.GrinderType)
43
-
@GrinderDetailField("๐ฉ Burr Type", props.Grinder.BurrType)
44
</div>
45
if props.Grinder.Notes != "" {
46
<div class="section-box">
···
97
</div>
98
}
99
100
-
templ GrinderDetailField(label, value string) {
101
-
<div class="section-box">
102
-
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">{ label }</h3>
103
-
if value != "" {
104
-
<div class="font-semibold text-brown-900">{ value }</div>
105
-
} else {
106
-
<span class="text-brown-400">Not specified</span>
107
-
}
108
-
</div>
109
-
}
···
39
@GrinderViewHeader(props)
40
<div class="space-y-6">
41
<div class="grid grid-cols-2 gap-4">
42
+
@components.DetailField(components.DetailFieldProps{Label: "โ๏ธ Type", Value: props.Grinder.GrinderType})
43
+
@components.DetailField(components.DetailFieldProps{Label: "๐ฉ Burr Type", Value: props.Grinder.BurrType})
44
</div>
45
if props.Grinder.Notes != "" {
46
<div class="section-box">
···
97
</div>
98
}
99
0
0
0
0
0
0
0
0
0
0
+1
-11
internal/web/pages/roaster_view.templ
···
40
@RoasterViewHeader(props)
41
<div class="space-y-6">
42
<div class="grid grid-cols-2 gap-4">
43
-
@RoasterDetailField("๐ Location", props.Roaster.Location)
44
if props.Roaster.Website != "" {
45
<div class="section-box">
46
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">๐ Website</h3>
···
103
</div>
104
}
105
106
-
templ RoasterDetailField(label, value string) {
107
-
<div class="section-box">
108
-
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">{ label }</h3>
109
-
if value != "" {
110
-
<div class="font-semibold text-brown-900">{ value }</div>
111
-
} else {
112
-
<span class="text-brown-400">Not specified</span>
113
-
}
114
-
</div>
115
-
}
···
40
@RoasterViewHeader(props)
41
<div class="space-y-6">
42
<div class="grid grid-cols-2 gap-4">
43
+
@components.DetailField(components.DetailFieldProps{Label: "๐ Location", Value: props.Roaster.Location})
44
if props.Roaster.Website != "" {
45
<div class="section-box">
46
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">๐ Website</h3>
···
103
</div>
104
}
105
0
0
0
0
0
0
0
0
0
0