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
feat: new view pages for non-brew record types
pdewey.com
2 weeks ago
4a93fd0e
84afe0c8
verified
This commit was signed with the committer's
known signature
.
pdewey.com
SSH Key Fingerprint:
SHA256:ePOVkJstqVLchGK8m9/OGQG+aFNHD5XN3xjvW9wKCA4=
+1438
-39
9 changed files
expand all
collapse all
unified
split
internal
atproto
store.go
handlers
entity_views.go
routing
routing.go
web
pages
bean_view.templ
brew_view.templ
brewer_view.templ
feed.templ
grinder_view.templ
roaster_view.templ
+141
internal/atproto/store.go
···
365
365
return nil
366
366
}
367
367
368
368
+
// BeanRecord contains a bean with its AT Protocol metadata
369
369
+
type BeanRecord struct {
370
370
+
Bean *models.Bean
371
371
+
URI string
372
372
+
CID string
373
373
+
}
374
374
+
375
375
+
// GetBeanRecordByRKey fetches a bean by rkey and returns it with its AT Protocol metadata
376
376
+
func (s *AtprotoStore) GetBeanRecordByRKey(ctx context.Context, rkey string) (*BeanRecord, error) {
377
377
+
output, err := s.client.GetRecord(ctx, s.did, s.sessionID, &GetRecordInput{
378
378
+
Collection: NSIDBean,
379
379
+
RKey: rkey,
380
380
+
})
381
381
+
if err != nil {
382
382
+
return nil, fmt.Errorf("failed to get bean record: %w", err)
383
383
+
}
384
384
+
385
385
+
atURI := BuildATURI(s.did.String(), NSIDBean, rkey)
386
386
+
bean, err := RecordToBean(output.Value, atURI)
387
387
+
if err != nil {
388
388
+
return nil, fmt.Errorf("failed to convert bean record: %w", err)
389
389
+
}
390
390
+
391
391
+
bean.RKey = rkey
392
392
+
393
393
+
// Resolve roaster reference if present
394
394
+
if roasterRef, ok := output.Value["roasterRef"].(string); ok && roasterRef != "" {
395
395
+
if components, err := ResolveATURI(roasterRef); err == nil {
396
396
+
bean.RoasterRKey = components.RKey
397
397
+
}
398
398
+
if len(roasterRef) > 10 && (roasterRef[:5] == "at://" || roasterRef[:4] == "did:") {
399
399
+
bean.Roaster, err = ResolveRoasterRef(ctx, s.client, roasterRef, s.sessionID)
400
400
+
if err != nil {
401
401
+
log.Warn().Err(err).Str("bean_rkey", rkey).Str("roaster_ref", roasterRef).Msg("Failed to resolve roaster reference")
402
402
+
}
403
403
+
}
404
404
+
}
405
405
+
406
406
+
return &BeanRecord{
407
407
+
Bean: bean,
408
408
+
URI: output.URI,
409
409
+
CID: output.CID,
410
410
+
}, nil
411
411
+
}
412
412
+
413
413
+
// RoasterRecord contains a roaster with its AT Protocol metadata
414
414
+
type RoasterRecord struct {
415
415
+
Roaster *models.Roaster
416
416
+
URI string
417
417
+
CID string
418
418
+
}
419
419
+
420
420
+
// GetRoasterRecordByRKey fetches a roaster by rkey and returns it with its AT Protocol metadata
421
421
+
func (s *AtprotoStore) GetRoasterRecordByRKey(ctx context.Context, rkey string) (*RoasterRecord, error) {
422
422
+
output, err := s.client.GetRecord(ctx, s.did, s.sessionID, &GetRecordInput{
423
423
+
Collection: NSIDRoaster,
424
424
+
RKey: rkey,
425
425
+
})
426
426
+
if err != nil {
427
427
+
return nil, fmt.Errorf("failed to get roaster record: %w", err)
428
428
+
}
429
429
+
430
430
+
atURI := BuildATURI(s.did.String(), NSIDRoaster, rkey)
431
431
+
roaster, err := RecordToRoaster(output.Value, atURI)
432
432
+
if err != nil {
433
433
+
return nil, fmt.Errorf("failed to convert roaster record: %w", err)
434
434
+
}
435
435
+
436
436
+
roaster.RKey = rkey
437
437
+
438
438
+
return &RoasterRecord{
439
439
+
Roaster: roaster,
440
440
+
URI: output.URI,
441
441
+
CID: output.CID,
442
442
+
}, nil
443
443
+
}
444
444
+
445
445
+
// GrinderRecord contains a grinder with its AT Protocol metadata
446
446
+
type GrinderRecord struct {
447
447
+
Grinder *models.Grinder
448
448
+
URI string
449
449
+
CID string
450
450
+
}
451
451
+
452
452
+
// GetGrinderRecordByRKey fetches a grinder by rkey and returns it with its AT Protocol metadata
453
453
+
func (s *AtprotoStore) GetGrinderRecordByRKey(ctx context.Context, rkey string) (*GrinderRecord, error) {
454
454
+
output, err := s.client.GetRecord(ctx, s.did, s.sessionID, &GetRecordInput{
455
455
+
Collection: NSIDGrinder,
456
456
+
RKey: rkey,
457
457
+
})
458
458
+
if err != nil {
459
459
+
return nil, fmt.Errorf("failed to get grinder record: %w", err)
460
460
+
}
461
461
+
462
462
+
atURI := BuildATURI(s.did.String(), NSIDGrinder, rkey)
463
463
+
grinder, err := RecordToGrinder(output.Value, atURI)
464
464
+
if err != nil {
465
465
+
return nil, fmt.Errorf("failed to convert grinder record: %w", err)
466
466
+
}
467
467
+
468
468
+
grinder.RKey = rkey
469
469
+
470
470
+
return &GrinderRecord{
471
471
+
Grinder: grinder,
472
472
+
URI: output.URI,
473
473
+
CID: output.CID,
474
474
+
}, nil
475
475
+
}
476
476
+
477
477
+
// BrewerRecord contains a brewer with its AT Protocol metadata
478
478
+
type BrewerRecord struct {
479
479
+
Brewer *models.Brewer
480
480
+
URI string
481
481
+
CID string
482
482
+
}
483
483
+
484
484
+
// GetBrewerRecordByRKey fetches a brewer by rkey and returns it with its AT Protocol metadata
485
485
+
func (s *AtprotoStore) GetBrewerRecordByRKey(ctx context.Context, rkey string) (*BrewerRecord, error) {
486
486
+
output, err := s.client.GetRecord(ctx, s.did, s.sessionID, &GetRecordInput{
487
487
+
Collection: NSIDBrewer,
488
488
+
RKey: rkey,
489
489
+
})
490
490
+
if err != nil {
491
491
+
return nil, fmt.Errorf("failed to get brewer record: %w", err)
492
492
+
}
493
493
+
494
494
+
atURI := BuildATURI(s.did.String(), NSIDBrewer, rkey)
495
495
+
brewer, err := RecordToBrewer(output.Value, atURI)
496
496
+
if err != nil {
497
497
+
return nil, fmt.Errorf("failed to convert brewer record: %w", err)
498
498
+
}
499
499
+
500
500
+
brewer.RKey = rkey
501
501
+
502
502
+
return &BrewerRecord{
503
503
+
Brewer: brewer,
504
504
+
URI: output.URI,
505
505
+
CID: output.CID,
506
506
+
}, nil
507
507
+
}
508
508
+
368
509
// ========== Bean Operations ==========
369
510
370
511
func (s *AtprotoStore) CreateBean(ctx context.Context, bean *models.CreateBeanRequest) (*models.Bean, error) {
+629
internal/handlers/entity_views.go
···
1
1
+
package handlers
2
2
+
3
3
+
import (
4
4
+
"context"
5
5
+
"fmt"
6
6
+
"net/http"
7
7
+
"strings"
8
8
+
9
9
+
"arabica/internal/atproto"
10
10
+
"arabica/internal/firehose"
11
11
+
"arabica/internal/models"
12
12
+
"arabica/internal/moderation"
13
13
+
"arabica/internal/web/bff"
14
14
+
"arabica/internal/web/components"
15
15
+
"arabica/internal/web/pages"
16
16
+
17
17
+
"github.com/rs/zerolog/log"
18
18
+
)
19
19
+
20
20
+
// socialData holds the social interaction data shared across all entity view handlers
21
21
+
type socialData struct {
22
22
+
IsLiked bool
23
23
+
LikeCount int
24
24
+
CommentCount int
25
25
+
Comments []firehose.IndexedComment
26
26
+
IsModerator bool
27
27
+
CanHideRecord bool
28
28
+
CanBlockUser bool
29
29
+
IsRecordHidden bool
30
30
+
}
31
31
+
32
32
+
// fetchSocialData retrieves likes, comments, and moderation state for a record
33
33
+
func (h *Handler) fetchSocialData(ctx context.Context, subjectURI, didStr string, isAuthenticated bool) socialData {
34
34
+
var sd socialData
35
35
+
36
36
+
if h.feedIndex != nil && subjectURI != "" {
37
37
+
sd.LikeCount = h.feedIndex.GetLikeCount(subjectURI)
38
38
+
sd.CommentCount = h.feedIndex.GetCommentCount(subjectURI)
39
39
+
sd.Comments = h.feedIndex.GetThreadedCommentsForSubject(ctx, subjectURI, 100, didStr)
40
40
+
sd.Comments = h.filterHiddenComments(ctx, sd.Comments)
41
41
+
if isAuthenticated {
42
42
+
sd.IsLiked = h.feedIndex.HasUserLiked(didStr, subjectURI)
43
43
+
}
44
44
+
}
45
45
+
46
46
+
if h.moderationService != nil && isAuthenticated {
47
47
+
sd.IsModerator = h.moderationService.IsModerator(didStr)
48
48
+
sd.CanHideRecord = h.moderationService.HasPermission(didStr, moderation.PermissionHideRecord)
49
49
+
sd.CanBlockUser = h.moderationService.HasPermission(didStr, moderation.PermissionBlacklistUser)
50
50
+
}
51
51
+
if h.moderationStore != nil && sd.IsModerator && subjectURI != "" {
52
52
+
sd.IsRecordHidden = h.moderationStore.IsRecordHidden(ctx, subjectURI)
53
53
+
}
54
54
+
55
55
+
return sd
56
56
+
}
57
57
+
58
58
+
// resolveOwnerDID resolves an owner parameter (DID or handle) to a DID string.
59
59
+
// Returns the DID and nil error on success, or empty string and error on failure.
60
60
+
func resolveOwnerDID(ctx context.Context, owner string) (string, error) {
61
61
+
if strings.HasPrefix(owner, "did:") {
62
62
+
return owner, nil
63
63
+
}
64
64
+
publicClient := atproto.NewPublicClient()
65
65
+
resolved, err := publicClient.ResolveHandle(ctx, owner)
66
66
+
if err != nil {
67
67
+
return "", err
68
68
+
}
69
69
+
return resolved, nil
70
70
+
}
71
71
+
72
72
+
// HandleBeanView shows a bean detail page with social features
73
73
+
func (h *Handler) HandleBeanView(w http.ResponseWriter, r *http.Request) {
74
74
+
rkey := validateRKey(w, r.PathValue("id"))
75
75
+
if rkey == "" {
76
76
+
return
77
77
+
}
78
78
+
79
79
+
owner := r.URL.Query().Get("owner")
80
80
+
didStr, err := atproto.GetAuthenticatedDID(r.Context())
81
81
+
isAuthenticated := err == nil && didStr != ""
82
82
+
83
83
+
var userProfile *bff.UserProfile
84
84
+
if isAuthenticated {
85
85
+
userProfile = h.getUserProfile(r.Context(), didStr)
86
86
+
}
87
87
+
88
88
+
var beanViewProps pages.BeanViewProps
89
89
+
var subjectURI, subjectCID, entityOwnerDID string
90
90
+
91
91
+
if owner != "" {
92
92
+
entityOwnerDID, err = resolveOwnerDID(r.Context(), owner)
93
93
+
if err != nil {
94
94
+
log.Warn().Err(err).Str("handle", owner).Msg("Failed to resolve handle for bean view")
95
95
+
http.Error(w, "User not found", http.StatusNotFound)
96
96
+
return
97
97
+
}
98
98
+
99
99
+
publicClient := atproto.NewPublicClient()
100
100
+
record, err := publicClient.GetRecord(r.Context(), entityOwnerDID, atproto.NSIDBean, rkey)
101
101
+
if err != nil {
102
102
+
log.Error().Err(err).Str("did", entityOwnerDID).Str("rkey", rkey).Msg("Failed to get bean record")
103
103
+
http.Error(w, "Bean not found", http.StatusNotFound)
104
104
+
return
105
105
+
}
106
106
+
107
107
+
subjectURI = record.URI
108
108
+
subjectCID = record.CID
109
109
+
110
110
+
bean, err := atproto.RecordToBean(record.Value, record.URI)
111
111
+
if err != nil {
112
112
+
log.Error().Err(err).Msg("Failed to convert bean record")
113
113
+
http.Error(w, "Failed to load bean", http.StatusInternalServerError)
114
114
+
return
115
115
+
}
116
116
+
bean.RKey = rkey
117
117
+
118
118
+
// Resolve roaster reference
119
119
+
if roasterRef, ok := record.Value["roasterRef"].(string); ok && roasterRef != "" {
120
120
+
if components, err := atproto.ResolveATURI(roasterRef); err == nil {
121
121
+
bean.RoasterRKey = components.RKey
122
122
+
}
123
123
+
roasterRKey := atproto.ExtractRKeyFromURI(roasterRef)
124
124
+
if roasterRKey != "" {
125
125
+
roasterRecord, err := publicClient.GetRecord(r.Context(), entityOwnerDID, atproto.NSIDRoaster, roasterRKey)
126
126
+
if err == nil {
127
127
+
if roaster, err := atproto.RecordToRoaster(roasterRecord.Value, roasterRecord.URI); err == nil {
128
128
+
roaster.RKey = roasterRKey
129
129
+
bean.Roaster = roaster
130
130
+
}
131
131
+
}
132
132
+
}
133
133
+
}
134
134
+
135
135
+
beanViewProps.Bean = bean
136
136
+
beanViewProps.IsOwnProfile = isAuthenticated && didStr == entityOwnerDID
137
137
+
} else {
138
138
+
store, authenticated := h.getAtprotoStore(r)
139
139
+
if !authenticated {
140
140
+
http.Redirect(w, r, "/login", http.StatusFound)
141
141
+
return
142
142
+
}
143
143
+
144
144
+
atprotoStore, ok := store.(*atproto.AtprotoStore)
145
145
+
if !ok {
146
146
+
http.Error(w, "Internal error", http.StatusInternalServerError)
147
147
+
return
148
148
+
}
149
149
+
150
150
+
beanRecord, err := atprotoStore.GetBeanRecordByRKey(r.Context(), rkey)
151
151
+
if err != nil {
152
152
+
http.Error(w, "Bean not found", http.StatusNotFound)
153
153
+
log.Error().Err(err).Str("rkey", rkey).Msg("Failed to get bean for view")
154
154
+
return
155
155
+
}
156
156
+
157
157
+
beanViewProps.Bean = beanRecord.Bean
158
158
+
subjectURI = beanRecord.URI
159
159
+
subjectCID = beanRecord.CID
160
160
+
beanViewProps.IsOwnProfile = true
161
161
+
}
162
162
+
163
163
+
// Construct share URL
164
164
+
var shareURL string
165
165
+
if owner != "" {
166
166
+
shareURL = fmt.Sprintf("/beans/%s?owner=%s", rkey, owner)
167
167
+
} else if userProfile != nil && userProfile.Handle != "" {
168
168
+
shareURL = fmt.Sprintf("/beans/%s?owner=%s", rkey, userProfile.Handle)
169
169
+
}
170
170
+
171
171
+
layoutData := h.buildLayoutData(r, beanViewProps.Bean.Name, isAuthenticated, didStr, userProfile)
172
172
+
h.populateBeanOGMetadata(layoutData, beanViewProps.Bean, shareURL)
173
173
+
174
174
+
sd := h.fetchSocialData(r.Context(), subjectURI, didStr, isAuthenticated)
175
175
+
176
176
+
beanViewProps.IsAuthenticated = isAuthenticated
177
177
+
beanViewProps.SubjectURI = subjectURI
178
178
+
beanViewProps.SubjectCID = subjectCID
179
179
+
beanViewProps.IsLiked = sd.IsLiked
180
180
+
beanViewProps.LikeCount = sd.LikeCount
181
181
+
beanViewProps.CommentCount = sd.CommentCount
182
182
+
beanViewProps.Comments = sd.Comments
183
183
+
beanViewProps.CurrentUserDID = didStr
184
184
+
beanViewProps.ShareURL = shareURL
185
185
+
beanViewProps.IsModerator = sd.IsModerator
186
186
+
beanViewProps.CanHideRecord = sd.CanHideRecord
187
187
+
beanViewProps.CanBlockUser = sd.CanBlockUser
188
188
+
beanViewProps.IsRecordHidden = sd.IsRecordHidden
189
189
+
beanViewProps.AuthorDID = entityOwnerDID
190
190
+
191
191
+
if err := pages.BeanView(layoutData, beanViewProps).Render(r.Context(), w); err != nil {
192
192
+
http.Error(w, "Failed to render page", http.StatusInternalServerError)
193
193
+
log.Error().Err(err).Msg("Failed to render bean view")
194
194
+
}
195
195
+
}
196
196
+
197
197
+
// HandleRoasterView shows a roaster detail page with social features
198
198
+
func (h *Handler) HandleRoasterView(w http.ResponseWriter, r *http.Request) {
199
199
+
rkey := validateRKey(w, r.PathValue("id"))
200
200
+
if rkey == "" {
201
201
+
return
202
202
+
}
203
203
+
204
204
+
owner := r.URL.Query().Get("owner")
205
205
+
didStr, err := atproto.GetAuthenticatedDID(r.Context())
206
206
+
isAuthenticated := err == nil && didStr != ""
207
207
+
208
208
+
var userProfile *bff.UserProfile
209
209
+
if isAuthenticated {
210
210
+
userProfile = h.getUserProfile(r.Context(), didStr)
211
211
+
}
212
212
+
213
213
+
var props pages.RoasterViewProps
214
214
+
var subjectURI, subjectCID, entityOwnerDID string
215
215
+
216
216
+
if owner != "" {
217
217
+
entityOwnerDID, err = resolveOwnerDID(r.Context(), owner)
218
218
+
if err != nil {
219
219
+
http.Error(w, "User not found", http.StatusNotFound)
220
220
+
return
221
221
+
}
222
222
+
223
223
+
publicClient := atproto.NewPublicClient()
224
224
+
record, err := publicClient.GetRecord(r.Context(), entityOwnerDID, atproto.NSIDRoaster, rkey)
225
225
+
if err != nil {
226
226
+
http.Error(w, "Roaster not found", http.StatusNotFound)
227
227
+
return
228
228
+
}
229
229
+
230
230
+
subjectURI = record.URI
231
231
+
subjectCID = record.CID
232
232
+
233
233
+
roaster, err := atproto.RecordToRoaster(record.Value, record.URI)
234
234
+
if err != nil {
235
235
+
http.Error(w, "Failed to load roaster", http.StatusInternalServerError)
236
236
+
return
237
237
+
}
238
238
+
roaster.RKey = rkey
239
239
+
props.Roaster = roaster
240
240
+
props.IsOwnProfile = isAuthenticated && didStr == entityOwnerDID
241
241
+
} else {
242
242
+
store, authenticated := h.getAtprotoStore(r)
243
243
+
if !authenticated {
244
244
+
http.Redirect(w, r, "/login", http.StatusFound)
245
245
+
return
246
246
+
}
247
247
+
248
248
+
atprotoStore, ok := store.(*atproto.AtprotoStore)
249
249
+
if !ok {
250
250
+
http.Error(w, "Internal error", http.StatusInternalServerError)
251
251
+
return
252
252
+
}
253
253
+
254
254
+
roasterRecord, err := atprotoStore.GetRoasterRecordByRKey(r.Context(), rkey)
255
255
+
if err != nil {
256
256
+
http.Error(w, "Roaster not found", http.StatusNotFound)
257
257
+
return
258
258
+
}
259
259
+
260
260
+
props.Roaster = roasterRecord.Roaster
261
261
+
subjectURI = roasterRecord.URI
262
262
+
subjectCID = roasterRecord.CID
263
263
+
props.IsOwnProfile = true
264
264
+
}
265
265
+
266
266
+
var shareURL string
267
267
+
if owner != "" {
268
268
+
shareURL = fmt.Sprintf("/roasters/%s?owner=%s", rkey, owner)
269
269
+
} else if userProfile != nil && userProfile.Handle != "" {
270
270
+
shareURL = fmt.Sprintf("/roasters/%s?owner=%s", rkey, userProfile.Handle)
271
271
+
}
272
272
+
273
273
+
layoutData := h.buildLayoutData(r, props.Roaster.Name, isAuthenticated, didStr, userProfile)
274
274
+
h.populateRoasterOGMetadata(layoutData, props.Roaster, shareURL)
275
275
+
276
276
+
sd := h.fetchSocialData(r.Context(), subjectURI, didStr, isAuthenticated)
277
277
+
278
278
+
props.IsAuthenticated = isAuthenticated
279
279
+
props.SubjectURI = subjectURI
280
280
+
props.SubjectCID = subjectCID
281
281
+
props.IsLiked = sd.IsLiked
282
282
+
props.LikeCount = sd.LikeCount
283
283
+
props.CommentCount = sd.CommentCount
284
284
+
props.Comments = sd.Comments
285
285
+
props.CurrentUserDID = didStr
286
286
+
props.ShareURL = shareURL
287
287
+
props.IsModerator = sd.IsModerator
288
288
+
props.CanHideRecord = sd.CanHideRecord
289
289
+
props.CanBlockUser = sd.CanBlockUser
290
290
+
props.IsRecordHidden = sd.IsRecordHidden
291
291
+
props.AuthorDID = entityOwnerDID
292
292
+
293
293
+
if err := pages.RoasterView(layoutData, props).Render(r.Context(), w); err != nil {
294
294
+
http.Error(w, "Failed to render page", http.StatusInternalServerError)
295
295
+
log.Error().Err(err).Msg("Failed to render roaster view")
296
296
+
}
297
297
+
}
298
298
+
299
299
+
// HandleGrinderView shows a grinder detail page with social features
300
300
+
func (h *Handler) HandleGrinderView(w http.ResponseWriter, r *http.Request) {
301
301
+
rkey := validateRKey(w, r.PathValue("id"))
302
302
+
if rkey == "" {
303
303
+
return
304
304
+
}
305
305
+
306
306
+
owner := r.URL.Query().Get("owner")
307
307
+
didStr, err := atproto.GetAuthenticatedDID(r.Context())
308
308
+
isAuthenticated := err == nil && didStr != ""
309
309
+
310
310
+
var userProfile *bff.UserProfile
311
311
+
if isAuthenticated {
312
312
+
userProfile = h.getUserProfile(r.Context(), didStr)
313
313
+
}
314
314
+
315
315
+
var props pages.GrinderViewProps
316
316
+
var subjectURI, subjectCID, entityOwnerDID string
317
317
+
318
318
+
if owner != "" {
319
319
+
entityOwnerDID, err = resolveOwnerDID(r.Context(), owner)
320
320
+
if err != nil {
321
321
+
http.Error(w, "User not found", http.StatusNotFound)
322
322
+
return
323
323
+
}
324
324
+
325
325
+
publicClient := atproto.NewPublicClient()
326
326
+
record, err := publicClient.GetRecord(r.Context(), entityOwnerDID, atproto.NSIDGrinder, rkey)
327
327
+
if err != nil {
328
328
+
http.Error(w, "Grinder not found", http.StatusNotFound)
329
329
+
return
330
330
+
}
331
331
+
332
332
+
subjectURI = record.URI
333
333
+
subjectCID = record.CID
334
334
+
335
335
+
grinder, err := atproto.RecordToGrinder(record.Value, record.URI)
336
336
+
if err != nil {
337
337
+
http.Error(w, "Failed to load grinder", http.StatusInternalServerError)
338
338
+
return
339
339
+
}
340
340
+
grinder.RKey = rkey
341
341
+
props.Grinder = grinder
342
342
+
props.IsOwnProfile = isAuthenticated && didStr == entityOwnerDID
343
343
+
} else {
344
344
+
store, authenticated := h.getAtprotoStore(r)
345
345
+
if !authenticated {
346
346
+
http.Redirect(w, r, "/login", http.StatusFound)
347
347
+
return
348
348
+
}
349
349
+
350
350
+
atprotoStore, ok := store.(*atproto.AtprotoStore)
351
351
+
if !ok {
352
352
+
http.Error(w, "Internal error", http.StatusInternalServerError)
353
353
+
return
354
354
+
}
355
355
+
356
356
+
grinderRecord, err := atprotoStore.GetGrinderRecordByRKey(r.Context(), rkey)
357
357
+
if err != nil {
358
358
+
http.Error(w, "Grinder not found", http.StatusNotFound)
359
359
+
return
360
360
+
}
361
361
+
362
362
+
props.Grinder = grinderRecord.Grinder
363
363
+
subjectURI = grinderRecord.URI
364
364
+
subjectCID = grinderRecord.CID
365
365
+
props.IsOwnProfile = true
366
366
+
}
367
367
+
368
368
+
var shareURL string
369
369
+
if owner != "" {
370
370
+
shareURL = fmt.Sprintf("/grinders/%s?owner=%s", rkey, owner)
371
371
+
} else if userProfile != nil && userProfile.Handle != "" {
372
372
+
shareURL = fmt.Sprintf("/grinders/%s?owner=%s", rkey, userProfile.Handle)
373
373
+
}
374
374
+
375
375
+
layoutData := h.buildLayoutData(r, props.Grinder.Name, isAuthenticated, didStr, userProfile)
376
376
+
h.populateGrinderOGMetadata(layoutData, props.Grinder, shareURL)
377
377
+
378
378
+
sd := h.fetchSocialData(r.Context(), subjectURI, didStr, isAuthenticated)
379
379
+
380
380
+
props.IsAuthenticated = isAuthenticated
381
381
+
props.SubjectURI = subjectURI
382
382
+
props.SubjectCID = subjectCID
383
383
+
props.IsLiked = sd.IsLiked
384
384
+
props.LikeCount = sd.LikeCount
385
385
+
props.CommentCount = sd.CommentCount
386
386
+
props.Comments = sd.Comments
387
387
+
props.CurrentUserDID = didStr
388
388
+
props.ShareURL = shareURL
389
389
+
props.IsModerator = sd.IsModerator
390
390
+
props.CanHideRecord = sd.CanHideRecord
391
391
+
props.CanBlockUser = sd.CanBlockUser
392
392
+
props.IsRecordHidden = sd.IsRecordHidden
393
393
+
props.AuthorDID = entityOwnerDID
394
394
+
395
395
+
if err := pages.GrinderView(layoutData, props).Render(r.Context(), w); err != nil {
396
396
+
http.Error(w, "Failed to render page", http.StatusInternalServerError)
397
397
+
log.Error().Err(err).Msg("Failed to render grinder view")
398
398
+
}
399
399
+
}
400
400
+
401
401
+
// HandleBrewerView shows a brewer detail page with social features
402
402
+
func (h *Handler) HandleBrewerView(w http.ResponseWriter, r *http.Request) {
403
403
+
rkey := validateRKey(w, r.PathValue("id"))
404
404
+
if rkey == "" {
405
405
+
return
406
406
+
}
407
407
+
408
408
+
owner := r.URL.Query().Get("owner")
409
409
+
didStr, err := atproto.GetAuthenticatedDID(r.Context())
410
410
+
isAuthenticated := err == nil && didStr != ""
411
411
+
412
412
+
var userProfile *bff.UserProfile
413
413
+
if isAuthenticated {
414
414
+
userProfile = h.getUserProfile(r.Context(), didStr)
415
415
+
}
416
416
+
417
417
+
var props pages.BrewerViewProps
418
418
+
var subjectURI, subjectCID, entityOwnerDID string
419
419
+
420
420
+
if owner != "" {
421
421
+
entityOwnerDID, err = resolveOwnerDID(r.Context(), owner)
422
422
+
if err != nil {
423
423
+
http.Error(w, "User not found", http.StatusNotFound)
424
424
+
return
425
425
+
}
426
426
+
427
427
+
publicClient := atproto.NewPublicClient()
428
428
+
record, err := publicClient.GetRecord(r.Context(), entityOwnerDID, atproto.NSIDBrewer, rkey)
429
429
+
if err != nil {
430
430
+
http.Error(w, "Brewer not found", http.StatusNotFound)
431
431
+
return
432
432
+
}
433
433
+
434
434
+
subjectURI = record.URI
435
435
+
subjectCID = record.CID
436
436
+
437
437
+
brewer, err := atproto.RecordToBrewer(record.Value, record.URI)
438
438
+
if err != nil {
439
439
+
http.Error(w, "Failed to load brewer", http.StatusInternalServerError)
440
440
+
return
441
441
+
}
442
442
+
brewer.RKey = rkey
443
443
+
props.Brewer = brewer
444
444
+
props.IsOwnProfile = isAuthenticated && didStr == entityOwnerDID
445
445
+
} else {
446
446
+
store, authenticated := h.getAtprotoStore(r)
447
447
+
if !authenticated {
448
448
+
http.Redirect(w, r, "/login", http.StatusFound)
449
449
+
return
450
450
+
}
451
451
+
452
452
+
atprotoStore, ok := store.(*atproto.AtprotoStore)
453
453
+
if !ok {
454
454
+
http.Error(w, "Internal error", http.StatusInternalServerError)
455
455
+
return
456
456
+
}
457
457
+
458
458
+
brewerRecord, err := atprotoStore.GetBrewerRecordByRKey(r.Context(), rkey)
459
459
+
if err != nil {
460
460
+
http.Error(w, "Brewer not found", http.StatusNotFound)
461
461
+
return
462
462
+
}
463
463
+
464
464
+
props.Brewer = brewerRecord.Brewer
465
465
+
subjectURI = brewerRecord.URI
466
466
+
subjectCID = brewerRecord.CID
467
467
+
props.IsOwnProfile = true
468
468
+
}
469
469
+
470
470
+
var shareURL string
471
471
+
if owner != "" {
472
472
+
shareURL = fmt.Sprintf("/brewers/%s?owner=%s", rkey, owner)
473
473
+
} else if userProfile != nil && userProfile.Handle != "" {
474
474
+
shareURL = fmt.Sprintf("/brewers/%s?owner=%s", rkey, userProfile.Handle)
475
475
+
}
476
476
+
477
477
+
layoutData := h.buildLayoutData(r, props.Brewer.Name, isAuthenticated, didStr, userProfile)
478
478
+
h.populateBrewerOGMetadata(layoutData, props.Brewer, shareURL)
479
479
+
480
480
+
sd := h.fetchSocialData(r.Context(), subjectURI, didStr, isAuthenticated)
481
481
+
482
482
+
props.IsAuthenticated = isAuthenticated
483
483
+
props.SubjectURI = subjectURI
484
484
+
props.SubjectCID = subjectCID
485
485
+
props.IsLiked = sd.IsLiked
486
486
+
props.LikeCount = sd.LikeCount
487
487
+
props.CommentCount = sd.CommentCount
488
488
+
props.Comments = sd.Comments
489
489
+
props.CurrentUserDID = didStr
490
490
+
props.ShareURL = shareURL
491
491
+
props.IsModerator = sd.IsModerator
492
492
+
props.CanHideRecord = sd.CanHideRecord
493
493
+
props.CanBlockUser = sd.CanBlockUser
494
494
+
props.IsRecordHidden = sd.IsRecordHidden
495
495
+
props.AuthorDID = entityOwnerDID
496
496
+
497
497
+
if err := pages.BrewerView(layoutData, props).Render(r.Context(), w); err != nil {
498
498
+
http.Error(w, "Failed to render page", http.StatusInternalServerError)
499
499
+
log.Error().Err(err).Msg("Failed to render brewer view")
500
500
+
}
501
501
+
}
502
502
+
503
503
+
// OG metadata helpers for entity types
504
504
+
505
505
+
func (h *Handler) populateBeanOGMetadata(layoutData *components.LayoutData, bean *models.Bean, shareURL string) {
506
506
+
if bean == nil {
507
507
+
return
508
508
+
}
509
509
+
510
510
+
ogTitle := bean.Name
511
511
+
if ogTitle == "" {
512
512
+
ogTitle = bean.Origin
513
513
+
}
514
514
+
515
515
+
var descParts []string
516
516
+
if bean.Origin != "" {
517
517
+
descParts = append(descParts, "Origin: "+bean.Origin)
518
518
+
}
519
519
+
if bean.RoastLevel != "" {
520
520
+
descParts = append(descParts, "Roast: "+bean.RoastLevel)
521
521
+
}
522
522
+
if bean.Roaster != nil {
523
523
+
descParts = append(descParts, "by "+bean.Roaster.Name)
524
524
+
}
525
525
+
526
526
+
var ogDescription string
527
527
+
if len(descParts) > 0 {
528
528
+
ogDescription = strings.Join(descParts, " · ")
529
529
+
} else {
530
530
+
ogDescription = "A coffee bean tracked on Arabica"
531
531
+
}
532
532
+
533
533
+
var ogURL string
534
534
+
if h.config.PublicURL != "" && shareURL != "" {
535
535
+
ogURL = h.config.PublicURL + shareURL
536
536
+
}
537
537
+
538
538
+
layoutData.OGTitle = ogTitle
539
539
+
layoutData.OGDescription = ogDescription
540
540
+
layoutData.OGType = "article"
541
541
+
layoutData.OGUrl = ogURL
542
542
+
}
543
543
+
544
544
+
func (h *Handler) populateRoasterOGMetadata(layoutData *components.LayoutData, roaster *models.Roaster, shareURL string) {
545
545
+
if roaster == nil {
546
546
+
return
547
547
+
}
548
548
+
549
549
+
var descParts []string
550
550
+
if roaster.Location != "" {
551
551
+
descParts = append(descParts, roaster.Location)
552
552
+
}
553
553
+
554
554
+
var ogDescription string
555
555
+
if len(descParts) > 0 {
556
556
+
ogDescription = strings.Join(descParts, " · ")
557
557
+
} else {
558
558
+
ogDescription = "A coffee roaster tracked on Arabica"
559
559
+
}
560
560
+
561
561
+
var ogURL string
562
562
+
if h.config.PublicURL != "" && shareURL != "" {
563
563
+
ogURL = h.config.PublicURL + shareURL
564
564
+
}
565
565
+
566
566
+
layoutData.OGTitle = roaster.Name
567
567
+
layoutData.OGDescription = ogDescription
568
568
+
layoutData.OGType = "article"
569
569
+
layoutData.OGUrl = ogURL
570
570
+
}
571
571
+
572
572
+
func (h *Handler) populateGrinderOGMetadata(layoutData *components.LayoutData, grinder *models.Grinder, shareURL string) {
573
573
+
if grinder == nil {
574
574
+
return
575
575
+
}
576
576
+
577
577
+
var descParts []string
578
578
+
if grinder.GrinderType != "" {
579
579
+
descParts = append(descParts, grinder.GrinderType)
580
580
+
}
581
581
+
if grinder.BurrType != "" {
582
582
+
descParts = append(descParts, grinder.BurrType+" burrs")
583
583
+
}
584
584
+
585
585
+
var ogDescription string
586
586
+
if len(descParts) > 0 {
587
587
+
ogDescription = strings.Join(descParts, " · ")
588
588
+
} else {
589
589
+
ogDescription = "A coffee grinder tracked on Arabica"
590
590
+
}
591
591
+
592
592
+
var ogURL string
593
593
+
if h.config.PublicURL != "" && shareURL != "" {
594
594
+
ogURL = h.config.PublicURL + shareURL
595
595
+
}
596
596
+
597
597
+
layoutData.OGTitle = grinder.Name
598
598
+
layoutData.OGDescription = ogDescription
599
599
+
layoutData.OGType = "article"
600
600
+
layoutData.OGUrl = ogURL
601
601
+
}
602
602
+
603
603
+
func (h *Handler) populateBrewerOGMetadata(layoutData *components.LayoutData, brewer *models.Brewer, shareURL string) {
604
604
+
if brewer == nil {
605
605
+
return
606
606
+
}
607
607
+
608
608
+
var descParts []string
609
609
+
if brewer.BrewerType != "" {
610
610
+
descParts = append(descParts, brewer.BrewerType)
611
611
+
}
612
612
+
613
613
+
var ogDescription string
614
614
+
if len(descParts) > 0 {
615
615
+
ogDescription = strings.Join(descParts, " · ")
616
616
+
} else {
617
617
+
ogDescription = "A brewing device tracked on Arabica"
618
618
+
}
619
619
+
620
620
+
var ogURL string
621
621
+
if h.config.PublicURL != "" && shareURL != "" {
622
622
+
ogURL = h.config.PublicURL + shareURL
623
623
+
}
624
624
+
625
625
+
layoutData.OGTitle = brewer.Name
626
626
+
layoutData.OGDescription = ogDescription
627
627
+
layoutData.OGType = "article"
628
628
+
layoutData.OGUrl = ogURL
629
629
+
}
+4
internal/routing/routing.go
···
62
62
mux.HandleFunc("GET /brews", h.HandleBrewList)
63
63
mux.HandleFunc("GET /brews/new", h.HandleBrewNew)
64
64
mux.HandleFunc("GET /brews/{id}", h.HandleBrewView)
65
65
+
mux.HandleFunc("GET /beans/{id}", h.HandleBeanView)
66
66
+
mux.HandleFunc("GET /roasters/{id}", h.HandleRoasterView)
67
67
+
mux.HandleFunc("GET /grinders/{id}", h.HandleGrinderView)
68
68
+
mux.HandleFunc("GET /brewers/{id}", h.HandleBrewerView)
65
69
mux.HandleFunc("GET /brews/{id}/edit", h.HandleBrewEdit)
66
70
mux.Handle("POST /brews", cop.Handler(http.HandlerFunc(h.HandleBrewCreate)))
67
71
mux.Handle("PUT /brews/{id}", cop.Handler(http.HandlerFunc(h.HandleBrewUpdate)))
+170
internal/web/pages/bean_view.templ
···
1
1
+
package pages
2
2
+
3
3
+
import (
4
4
+
"arabica/internal/firehose"
5
5
+
"arabica/internal/models"
6
6
+
"arabica/internal/web/components"
7
7
+
"fmt"
8
8
+
"strings"
9
9
+
)
10
10
+
11
11
+
type BeanViewProps struct {
12
12
+
Bean *models.Bean
13
13
+
IsOwnProfile bool
14
14
+
IsAuthenticated bool
15
15
+
SubjectURI string
16
16
+
SubjectCID string
17
17
+
IsLiked bool
18
18
+
LikeCount int
19
19
+
CommentCount int
20
20
+
Comments []firehose.IndexedComment
21
21
+
CurrentUserDID string
22
22
+
ShareURL string
23
23
+
IsModerator bool
24
24
+
CanHideRecord bool
25
25
+
CanBlockUser bool
26
26
+
IsRecordHidden bool
27
27
+
AuthorDID string
28
28
+
}
29
29
+
30
30
+
templ BeanView(layout *components.LayoutData, props BeanViewProps) {
31
31
+
@components.Layout(layout, BeanViewContent(props))
32
32
+
}
33
33
+
34
34
+
templ BeanViewContent(props BeanViewProps) {
35
35
+
<div class="page-container-sm">
36
36
+
@components.Card(components.CardProps{InnerCard: true}, BeanViewCard(props))
37
37
+
</div>
38
38
+
}
39
39
+
40
40
+
templ BeanViewCard(props BeanViewProps) {
41
41
+
@BeanViewHeader(props)
42
42
+
<div class="space-y-6">
43
43
+
if props.Bean.Roaster != nil && props.Bean.Roaster.Name != "" {
44
44
+
<div class="section-box">
45
45
+
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">🏭 Roaster</h3>
46
46
+
<div class="font-semibold text-brown-900">
47
47
+
<a
48
48
+
href={ templ.SafeURL(fmt.Sprintf("/roasters/%s?owner=%s", props.Bean.Roaster.RKey, getOwnerFromShareURL(props.ShareURL))) }
49
49
+
class="hover:underline"
50
50
+
>
51
51
+
{ props.Bean.Roaster.Name }
52
52
+
</a>
53
53
+
</div>
54
54
+
if props.Bean.Roaster.Location != "" {
55
55
+
<div class="text-sm text-brown-600 mt-1">📍 { props.Bean.Roaster.Location }</div>
56
56
+
}
57
57
+
</div>
58
58
+
}
59
59
+
<div class="grid grid-cols-2 gap-4">
60
60
+
@BeanDetailField("📍 Origin", props.Bean.Origin)
61
61
+
@BeanDetailField("🔥 Roast Level", props.Bean.RoastLevel)
62
62
+
@BeanDetailField("🌱 Process", props.Bean.Process)
63
63
+
if props.Bean.Closed {
64
64
+
<div class="section-box">
65
65
+
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">Status</h3>
66
66
+
<span class="text-sm bg-brown-200 text-brown-700 px-2 py-1 rounded-md font-medium">Closed</span>
67
67
+
</div>
68
68
+
}
69
69
+
</div>
70
70
+
if props.Bean.Description != "" {
71
71
+
<div class="section-box">
72
72
+
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">📝 Description</h3>
73
73
+
<div class="text-brown-900 whitespace-pre-wrap">{ props.Bean.Description }</div>
74
74
+
</div>
75
75
+
}
76
76
+
<div class="flex justify-between items-center">
77
77
+
@components.BackButton()
78
78
+
<div class="bg-brown-50 rounded-lg px-3 py-2 border border-brown-200 brew-view-actions">
79
79
+
@components.ActionBar(components.ActionBarProps{
80
80
+
SubjectURI: props.SubjectURI,
81
81
+
SubjectCID: props.SubjectCID,
82
82
+
IsLiked: props.IsLiked,
83
83
+
LikeCount: props.LikeCount,
84
84
+
CommentCount: props.CommentCount,
85
85
+
ShowComments: true,
86
86
+
ShareURL: props.ShareURL,
87
87
+
ShareTitle: getBeanShareTitle(props.Bean),
88
88
+
ShareText: "Check out this bean on Arabica",
89
89
+
IsOwner: props.IsOwnProfile,
90
90
+
IsAuthenticated: props.IsAuthenticated,
91
91
+
IsModerator: props.IsModerator,
92
92
+
CanHideRecord: props.CanHideRecord,
93
93
+
CanBlockUser: props.CanBlockUser,
94
94
+
IsRecordHidden: props.IsRecordHidden,
95
95
+
AuthorDID: props.AuthorDID,
96
96
+
})
97
97
+
</div>
98
98
+
</div>
99
99
+
@components.CommentSection(components.CommentSectionProps{
100
100
+
SubjectURI: props.SubjectURI,
101
101
+
SubjectCID: props.SubjectCID,
102
102
+
Comments: props.Comments,
103
103
+
IsAuthenticated: props.IsAuthenticated,
104
104
+
CurrentUserDID: props.CurrentUserDID,
105
105
+
ModCtx: components.CommentModerationContext{
106
106
+
IsModerator: props.IsModerator,
107
107
+
CanHideRecord: props.CanHideRecord,
108
108
+
CanBlockUser: props.CanBlockUser,
109
109
+
},
110
110
+
ViewURL: props.ShareURL,
111
111
+
})
112
112
+
</div>
113
113
+
}
114
114
+
115
115
+
templ BeanViewHeader(props BeanViewProps) {
116
116
+
<div class="flex justify-between items-start mb-6">
117
117
+
<div>
118
118
+
<h2 class="text-3xl font-bold text-brown-900">
119
119
+
if props.Bean.Name != "" {
120
120
+
{ props.Bean.Name }
121
121
+
} else {
122
122
+
{ props.Bean.Origin }
123
123
+
}
124
124
+
</h2>
125
125
+
<p class="text-sm text-brown-600 mt-1">{ props.Bean.CreatedAt.Format("January 2, 2006 at 3:04 PM") }</p>
126
126
+
</div>
127
127
+
if props.IsOwnProfile {
128
128
+
<div class="flex gap-2">
129
129
+
<button
130
130
+
hx-delete={ "/api/beans/" + props.Bean.RKey }
131
131
+
hx-confirm="Are you sure you want to delete this bean?"
132
132
+
hx-target="body"
133
133
+
class="inline-flex items-center bg-brown-200 text-brown-700 px-4 py-2 rounded-lg hover:bg-brown-300 font-medium transition-colors"
134
134
+
>
135
135
+
Delete
136
136
+
</button>
137
137
+
</div>
138
138
+
}
139
139
+
</div>
140
140
+
}
141
141
+
142
142
+
templ BeanDetailField(label, value string) {
143
143
+
<div class="section-box">
144
144
+
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">{ label }</h3>
145
145
+
if value != "" {
146
146
+
<div class="font-semibold text-brown-900">{ value }</div>
147
147
+
} else {
148
148
+
<span class="text-brown-400">Not specified</span>
149
149
+
}
150
150
+
</div>
151
151
+
}
152
152
+
153
153
+
func getBeanShareTitle(bean *models.Bean) string {
154
154
+
if bean.Name != "" {
155
155
+
return bean.Name
156
156
+
}
157
157
+
return bean.Origin
158
158
+
}
159
159
+
160
160
+
func getOwnerFromShareURL(shareURL string) string {
161
161
+
// Extract owner from URLs like "/beans/rkey?owner=handle"
162
162
+
if idx := strings.Index(shareURL, "owner="); idx >= 0 {
163
163
+
owner := shareURL[idx+len("owner="):]
164
164
+
if ampIdx := strings.Index(owner, "&"); ampIdx >= 0 {
165
165
+
return owner[:ampIdx]
166
166
+
}
167
167
+
return owner
168
168
+
}
169
169
+
return ""
170
170
+
}
+59
-26
internal/web/pages/brew_view.templ
···
38
38
if props.Brew.Rating > 0 {
39
39
@BrewRating(props.Brew.Rating)
40
40
}
41
41
-
@BrewBeanSection(props.Brew)
42
42
-
@BrewParametersGrid(props.Brew)
41
41
+
@BrewBeanSection(props.Brew, getOwnerFromShareURL(props.ShareURL))
42
42
+
@BrewParametersGrid(props.Brew, getOwnerFromShareURL(props.ShareURL))
43
43
if props.Brew.Pours != nil && len(props.Brew.Pours) > 0 {
44
44
@BrewPoursSection(props.Brew.Pours)
45
45
}
···
96
96
// BrewRating renders the prominent rating display
97
97
templ BrewRating(rating int) {
98
98
<div class="section-box text-center py-4">
99
99
-
<div class="text-4xl font-bold text-brown-800">
100
100
-
{ fmt.Sprintf("%d/10", rating) }
101
101
-
</div>
102
102
-
<div class="text-sm text-brown-600 mt-1">Rating</div>
99
99
+
<span class="badge-rating text-2xl !font-bold px-5 py-2">
100
100
+
⭐ { fmt.Sprintf("%d/10", rating) }
101
101
+
</span>
102
102
+
<div class="text-sm text-brown-600 mt-2">Rating</div>
103
103
</div>
104
104
}
105
105
106
106
// BrewBeanSection renders the coffee bean information
107
107
-
templ BrewBeanSection(brew *models.Brew) {
107
107
+
templ BrewBeanSection(brew *models.Brew, owner string) {
108
108
<div class="section-box">
109
109
-
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">Coffee Bean</h3>
109
109
+
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">☕ Coffee Bean</h3>
110
110
if brew.Bean != nil {
111
111
<div class="font-bold text-lg text-brown-900">
112
112
-
if brew.Bean.Name != "" {
113
113
-
{ brew.Bean.Name }
114
114
-
} else {
115
115
-
{ brew.Bean.Origin }
116
116
-
}
112
112
+
<a href={ templ.SafeURL(fmt.Sprintf("/beans/%s?owner=%s", brew.Bean.RKey, owner)) } class="hover:underline">
113
113
+
if brew.Bean.Name != "" {
114
114
+
{ brew.Bean.Name }
115
115
+
} else {
116
116
+
{ brew.Bean.Origin }
117
117
+
}
118
118
+
</a>
117
119
</div>
118
120
if brew.Bean.Roaster != nil && brew.Bean.Roaster.Name != "" {
119
121
<div class="text-sm text-brown-700 mt-1">
120
120
-
by { brew.Bean.Roaster.Name }
122
122
+
🏭
123
123
+
<a href={ templ.SafeURL(fmt.Sprintf("/roasters/%s?owner=%s", brew.Bean.Roaster.RKey, owner)) } class="hover:underline">
124
124
+
{ brew.Bean.Roaster.Name }
125
125
+
</a>
121
126
</div>
122
127
}
123
128
<div class="flex flex-wrap gap-3 mt-2 text-sm text-brown-600">
124
129
if brew.Bean.Origin != "" {
125
125
-
<span>Origin: { brew.Bean.Origin }</span>
130
130
+
<span>📍 { brew.Bean.Origin }</span>
126
131
}
127
132
if brew.Bean.RoastLevel != "" {
128
128
-
<span>Roast: { brew.Bean.RoastLevel }</span>
133
133
+
<span>🔥 { brew.Bean.RoastLevel }</span>
129
134
}
130
135
</div>
131
136
} else {
···
135
140
}
136
141
137
142
// BrewParametersGrid renders the brew parameters in a grid
138
138
-
templ BrewParametersGrid(brew *models.Brew) {
143
143
+
templ BrewParametersGrid(brew *models.Brew, owner string) {
139
144
<div class="grid grid-cols-2 gap-4">
140
140
-
@BrewParameter("Coffee", getCoffeeAmountDisplay(brew))
141
141
-
@BrewParameter("Brew Method", getBrewerName(brew))
142
142
-
@BrewParameter("Grinder", getGrinderName(brew))
143
143
-
@BrewParameter("Grind Size", getGrindSizeDisplay(brew))
144
144
-
@BrewParameter("Water", getWaterAmountDisplay(brew))
145
145
-
@BrewParameter("Temperature", getTemperatureDisplay(brew))
145
145
+
@BrewParameter("⚖️ Coffee", getCoffeeAmountDisplay(brew))
146
146
+
@BrewLinkedParameter("☕ Brew Method", getBrewerName(brew), getBrewerViewURL(brew, owner))
147
147
+
@BrewLinkedParameter("⚙️ Grinder", getGrinderName(brew), getGrinderViewURL(brew, owner))
148
148
+
@BrewParameter("🔩 Grind Size", getGrindSizeDisplay(brew))
149
149
+
@BrewParameter("💧 Water", getWaterAmountDisplay(brew))
150
150
+
@BrewParameter("🌡️ Temperature", getTemperatureDisplay(brew))
146
151
<div class="col-span-2">
147
147
-
@BrewParameter("Brew Time", getBrewTimeDisplay(brew))
152
152
+
@BrewParameter("⏱️ Brew Time", getBrewTimeDisplay(brew))
148
153
</div>
149
154
</div>
150
155
}
···
161
166
</div>
162
167
}
163
168
169
169
+
// BrewLinkedParameter renders a parameter with a clickable link to the entity view page
170
170
+
templ BrewLinkedParameter(label string, value string, href string) {
171
171
+
<div class="section-box">
172
172
+
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">{ label }</h3>
173
173
+
if value != "" && href != "" {
174
174
+
<a href={ templ.SafeURL(href) } class="font-semibold text-brown-900 hover:underline">{ value }</a>
175
175
+
} else if value != "" {
176
176
+
<div class="font-semibold text-brown-900">{ value }</div>
177
177
+
} else {
178
178
+
<span class="text-brown-400">Not specified</span>
179
179
+
}
180
180
+
</div>
181
181
+
}
182
182
+
164
183
// Helper functions for brew view display
165
184
func getBrewerName(brew *models.Brew) string {
166
185
if brew.BrewerObj != nil {
···
221
240
return ""
222
241
}
223
242
243
243
+
func getGrinderViewURL(brew *models.Brew, owner string) string {
244
244
+
if brew.GrinderObj != nil && brew.GrinderObj.RKey != "" && owner != "" {
245
245
+
return fmt.Sprintf("/grinders/%s?owner=%s", brew.GrinderObj.RKey, owner)
246
246
+
}
247
247
+
return ""
248
248
+
}
249
249
+
250
250
+
func getBrewerViewURL(brew *models.Brew, owner string) string {
251
251
+
if brew.BrewerObj != nil && brew.BrewerObj.RKey != "" && owner != "" {
252
252
+
return fmt.Sprintf("/brewers/%s?owner=%s", brew.BrewerObj.RKey, owner)
253
253
+
}
254
254
+
return ""
255
255
+
}
256
256
+
224
257
func getBrewShareTitle(brew *models.Brew) string {
225
258
if brew.Bean != nil {
226
259
if brew.Bean.Name != "" {
···
234
267
// BrewPoursSection renders the pours section
235
268
templ BrewPoursSection(pours []*models.Pour) {
236
269
<div class="section-box">
237
237
-
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-3">Pours</h3>
270
270
+
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-3">💧 Pours</h3>
238
271
<div class="space-y-2">
239
272
for _, pour := range pours {
240
273
<div class="flex justify-between items-center bg-white p-3 rounded-lg border border-brown-200">
···
252
285
// BrewTastingNotes renders the tasting notes section
253
286
templ BrewTastingNotes(notes string) {
254
287
<div class="section-box">
255
255
-
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">Tasting Notes</h3>
288
288
+
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">📝 Tasting Notes</h3>
256
289
<div class="text-brown-900 whitespace-pre-wrap">{ notes }</div>
257
290
</div>
258
291
}
+111
internal/web/pages/brewer_view.templ
···
1
1
+
package pages
2
2
+
3
3
+
import (
4
4
+
"arabica/internal/firehose"
5
5
+
"arabica/internal/models"
6
6
+
"arabica/internal/web/components"
7
7
+
)
8
8
+
9
9
+
type BrewerViewProps struct {
10
10
+
Brewer *models.Brewer
11
11
+
IsOwnProfile bool
12
12
+
IsAuthenticated bool
13
13
+
SubjectURI string
14
14
+
SubjectCID string
15
15
+
IsLiked bool
16
16
+
LikeCount int
17
17
+
CommentCount int
18
18
+
Comments []firehose.IndexedComment
19
19
+
CurrentUserDID string
20
20
+
ShareURL string
21
21
+
IsModerator bool
22
22
+
CanHideRecord bool
23
23
+
CanBlockUser bool
24
24
+
IsRecordHidden bool
25
25
+
AuthorDID string
26
26
+
}
27
27
+
28
28
+
templ BrewerView(layout *components.LayoutData, props BrewerViewProps) {
29
29
+
@components.Layout(layout, BrewerViewContent(props))
30
30
+
}
31
31
+
32
32
+
templ BrewerViewContent(props BrewerViewProps) {
33
33
+
<div class="page-container-sm">
34
34
+
@components.Card(components.CardProps{InnerCard: true}, BrewerViewCard(props))
35
35
+
</div>
36
36
+
}
37
37
+
38
38
+
templ BrewerViewCard(props BrewerViewProps) {
39
39
+
@BrewerViewHeader(props)
40
40
+
<div class="space-y-6">
41
41
+
if props.Brewer.BrewerType != "" {
42
42
+
<div class="section-box">
43
43
+
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">☕ Type</h3>
44
44
+
<div class="font-semibold text-brown-900">{ props.Brewer.BrewerType }</div>
45
45
+
</div>
46
46
+
}
47
47
+
if props.Brewer.Description != "" {
48
48
+
<div class="section-box">
49
49
+
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">📝 Description</h3>
50
50
+
<div class="text-brown-900 whitespace-pre-wrap">{ props.Brewer.Description }</div>
51
51
+
</div>
52
52
+
}
53
53
+
<div class="flex justify-between items-center">
54
54
+
@components.BackButton()
55
55
+
<div class="bg-brown-50 rounded-lg px-3 py-2 border border-brown-200 brew-view-actions">
56
56
+
@components.ActionBar(components.ActionBarProps{
57
57
+
SubjectURI: props.SubjectURI,
58
58
+
SubjectCID: props.SubjectCID,
59
59
+
IsLiked: props.IsLiked,
60
60
+
LikeCount: props.LikeCount,
61
61
+
CommentCount: props.CommentCount,
62
62
+
ShowComments: true,
63
63
+
ShareURL: props.ShareURL,
64
64
+
ShareTitle: props.Brewer.Name,
65
65
+
ShareText: "Check out this brewer on Arabica",
66
66
+
IsOwner: props.IsOwnProfile,
67
67
+
IsAuthenticated: props.IsAuthenticated,
68
68
+
IsModerator: props.IsModerator,
69
69
+
CanHideRecord: props.CanHideRecord,
70
70
+
CanBlockUser: props.CanBlockUser,
71
71
+
IsRecordHidden: props.IsRecordHidden,
72
72
+
AuthorDID: props.AuthorDID,
73
73
+
})
74
74
+
</div>
75
75
+
</div>
76
76
+
@components.CommentSection(components.CommentSectionProps{
77
77
+
SubjectURI: props.SubjectURI,
78
78
+
SubjectCID: props.SubjectCID,
79
79
+
Comments: props.Comments,
80
80
+
IsAuthenticated: props.IsAuthenticated,
81
81
+
CurrentUserDID: props.CurrentUserDID,
82
82
+
ModCtx: components.CommentModerationContext{
83
83
+
IsModerator: props.IsModerator,
84
84
+
CanHideRecord: props.CanHideRecord,
85
85
+
CanBlockUser: props.CanBlockUser,
86
86
+
},
87
87
+
ViewURL: props.ShareURL,
88
88
+
})
89
89
+
</div>
90
90
+
}
91
91
+
92
92
+
templ BrewerViewHeader(props BrewerViewProps) {
93
93
+
<div class="flex justify-between items-start mb-6">
94
94
+
<div>
95
95
+
<h2 class="text-3xl font-bold text-brown-900">{ props.Brewer.Name }</h2>
96
96
+
<p class="text-sm text-brown-600 mt-1">{ props.Brewer.CreatedAt.Format("January 2, 2006 at 3:04 PM") }</p>
97
97
+
</div>
98
98
+
if props.IsOwnProfile {
99
99
+
<div class="flex gap-2">
100
100
+
<button
101
101
+
hx-delete={ "/api/brewers/" + props.Brewer.RKey }
102
102
+
hx-confirm="Are you sure you want to delete this brewer?"
103
103
+
hx-target="body"
104
104
+
class="inline-flex items-center bg-brown-200 text-brown-700 px-4 py-2 rounded-lg hover:bg-brown-300 font-medium transition-colors"
105
105
+
>
106
106
+
Delete
107
107
+
</button>
108
108
+
</div>
109
109
+
}
110
110
+
</div>
111
111
+
}
+78
-13
internal/web/pages/feed.templ
···
73
73
case lexicons.RecordTypeBrew:
74
74
@FeedBrewContentClickable(item)
75
75
case lexicons.RecordTypeBean:
76
76
-
@FeedBeanContent(item)
76
76
+
@FeedEntityContentClickable(item, FeedBeanContent)
77
77
case lexicons.RecordTypeRoaster:
78
78
-
@FeedRoasterContent(item)
78
78
+
@FeedEntityContentClickable(item, FeedRoasterContent)
79
79
case lexicons.RecordTypeGrinder:
80
80
-
@FeedGrinderContent(item)
80
80
+
@FeedEntityContentClickable(item, FeedGrinderContent)
81
81
case lexicons.RecordTypeBrewer:
82
82
-
@FeedBrewerContent(item)
82
82
+
@FeedEntityContentClickable(item, FeedBrewerContent)
83
83
}
84
84
<!-- Action bar -->
85
85
if item.SubjectURI != "" && item.SubjectCID != "" {
···
126
126
case lexicons.RecordTypeBrew:
127
127
if item.Brew != nil {
128
128
added a
129
129
-
<a
130
130
-
href={ templ.SafeURL(fmt.Sprintf("/brews/%s?owner=%s", item.Brew.RKey, item.Author.Handle)) }
131
131
-
class="underline hover:text-brown-900"
132
132
-
>
133
133
-
new brew
134
134
-
</a>
129
129
+
<a href={ templ.SafeURL(getFeedItemShareURL(item)) } class="underline hover:text-brown-900">new brew</a>
130
130
+
} else {
131
131
+
{ item.Action }
132
132
+
}
133
133
+
case lexicons.RecordTypeBean:
134
134
+
if item.Bean != nil {
135
135
+
added a
136
136
+
<a href={ templ.SafeURL(getFeedItemShareURL(item)) } class="underline hover:text-brown-900">new bean</a>
137
137
+
} else {
138
138
+
{ item.Action }
139
139
+
}
140
140
+
case lexicons.RecordTypeRoaster:
141
141
+
if item.Roaster != nil {
142
142
+
added a
143
143
+
<a href={ templ.SafeURL(getFeedItemShareURL(item)) } class="underline hover:text-brown-900">new roaster</a>
144
144
+
} else {
145
145
+
{ item.Action }
146
146
+
}
147
147
+
case lexicons.RecordTypeGrinder:
148
148
+
if item.Grinder != nil {
149
149
+
added a
150
150
+
<a href={ templ.SafeURL(getFeedItemShareURL(item)) } class="underline hover:text-brown-900">new grinder</a>
151
151
+
} else {
152
152
+
{ item.Action }
153
153
+
}
154
154
+
case lexicons.RecordTypeBrewer:
155
155
+
if item.Brewer != nil {
156
156
+
added a
157
157
+
<a href={ templ.SafeURL(getFeedItemShareURL(item)) } class="underline hover:text-brown-900">new brewer</a>
135
158
} else {
136
159
{ item.Action }
137
160
}
138
161
default:
139
162
{ item.Action }
140
163
}
164
164
+
}
165
165
+
166
166
+
// FeedEntityContentClickable wraps any entity content component in a clickable link
167
167
+
templ FeedEntityContentClickable(item *feed.FeedItem, content func(*feed.FeedItem) templ.Component) {
168
168
+
<a
169
169
+
href={ templ.SafeURL(getFeedItemShareURL(item)) }
170
170
+
class="block hover:opacity-90 transition-opacity"
171
171
+
>
172
172
+
@content(item)
173
173
+
</a>
141
174
}
142
175
143
176
// FeedBrewContentClickable renders brew content wrapped in a clickable link
···
356
389
if item.Brew != nil {
357
390
return fmt.Sprintf("/brews/%s?owner=%s", item.Brew.RKey, item.Author.Handle)
358
391
}
392
392
+
case lexicons.RecordTypeBean:
393
393
+
if item.Bean != nil {
394
394
+
return fmt.Sprintf("/beans/%s?owner=%s", item.Bean.RKey, item.Author.Handle)
395
395
+
}
396
396
+
case lexicons.RecordTypeRoaster:
397
397
+
if item.Roaster != nil {
398
398
+
return fmt.Sprintf("/roasters/%s?owner=%s", item.Roaster.RKey, item.Author.Handle)
399
399
+
}
400
400
+
case lexicons.RecordTypeGrinder:
401
401
+
if item.Grinder != nil {
402
402
+
return fmt.Sprintf("/grinders/%s?owner=%s", item.Grinder.RKey, item.Author.Handle)
403
403
+
}
404
404
+
case lexicons.RecordTypeBrewer:
405
405
+
if item.Brewer != nil {
406
406
+
return fmt.Sprintf("/brewers/%s?owner=%s", item.Brewer.RKey, item.Author.Handle)
407
407
+
}
359
408
}
360
360
-
// For other record types, link to the user's profile
361
409
return fmt.Sprintf("/profile/%s", item.Author.Handle)
362
410
}
363
411
···
406
454
return fmt.Sprintf("Check out this %s by %s on Arabica", item.RecordType, displayName)
407
455
}
408
456
409
409
-
// getEditURL returns the edit URL for a feed item (only for brews currently)
457
457
+
// getEditURL returns the edit URL for a feed item
410
458
func getEditURL(item *feed.FeedItem) string {
411
459
switch item.RecordType {
412
460
case lexicons.RecordTypeBrew:
···
414
462
return fmt.Sprintf("/brews/%s/edit", item.Brew.RKey)
415
463
}
416
464
}
465
465
+
// Beans, roasters, grinders, and brewers are edited via modals on the manage page
417
466
return ""
418
467
}
419
468
420
420
-
// getDeleteURL returns the delete URL for a feed item (only for brews currently)
469
469
+
// getDeleteURL returns the delete URL for a feed item
421
470
func getDeleteURL(item *feed.FeedItem) string {
422
471
switch item.RecordType {
423
472
case lexicons.RecordTypeBrew:
424
473
if item.Brew != nil {
425
474
return fmt.Sprintf("/brews/%s", item.Brew.RKey)
475
475
+
}
476
476
+
case lexicons.RecordTypeBean:
477
477
+
if item.Bean != nil {
478
478
+
return fmt.Sprintf("/api/beans/%s", item.Bean.RKey)
479
479
+
}
480
480
+
case lexicons.RecordTypeRoaster:
481
481
+
if item.Roaster != nil {
482
482
+
return fmt.Sprintf("/api/roasters/%s", item.Roaster.RKey)
483
483
+
}
484
484
+
case lexicons.RecordTypeGrinder:
485
485
+
if item.Grinder != nil {
486
486
+
return fmt.Sprintf("/api/grinders/%s", item.Grinder.RKey)
487
487
+
}
488
488
+
case lexicons.RecordTypeBrewer:
489
489
+
if item.Brewer != nil {
490
490
+
return fmt.Sprintf("/api/brewers/%s", item.Brewer.RKey)
426
491
}
427
492
}
428
493
return ""
+120
internal/web/pages/grinder_view.templ
···
1
1
+
package pages
2
2
+
3
3
+
import (
4
4
+
"arabica/internal/firehose"
5
5
+
"arabica/internal/models"
6
6
+
"arabica/internal/web/components"
7
7
+
)
8
8
+
9
9
+
type GrinderViewProps struct {
10
10
+
Grinder *models.Grinder
11
11
+
IsOwnProfile bool
12
12
+
IsAuthenticated bool
13
13
+
SubjectURI string
14
14
+
SubjectCID string
15
15
+
IsLiked bool
16
16
+
LikeCount int
17
17
+
CommentCount int
18
18
+
Comments []firehose.IndexedComment
19
19
+
CurrentUserDID string
20
20
+
ShareURL string
21
21
+
IsModerator bool
22
22
+
CanHideRecord bool
23
23
+
CanBlockUser bool
24
24
+
IsRecordHidden bool
25
25
+
AuthorDID string
26
26
+
}
27
27
+
28
28
+
templ GrinderView(layout *components.LayoutData, props GrinderViewProps) {
29
29
+
@components.Layout(layout, GrinderViewContent(props))
30
30
+
}
31
31
+
32
32
+
templ GrinderViewContent(props GrinderViewProps) {
33
33
+
<div class="page-container-sm">
34
34
+
@components.Card(components.CardProps{InnerCard: true}, GrinderViewCard(props))
35
35
+
</div>
36
36
+
}
37
37
+
38
38
+
templ GrinderViewCard(props GrinderViewProps) {
39
39
+
@GrinderViewHeader(props)
40
40
+
<div class="space-y-6">
41
41
+
<div class="grid grid-cols-2 gap-4">
42
42
+
@GrinderDetailField("⚙️ Type", props.Grinder.GrinderType)
43
43
+
@GrinderDetailField("🔩 Burr Type", props.Grinder.BurrType)
44
44
+
</div>
45
45
+
if props.Grinder.Notes != "" {
46
46
+
<div class="section-box">
47
47
+
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">📝 Notes</h3>
48
48
+
<div class="text-brown-900 whitespace-pre-wrap">{ props.Grinder.Notes }</div>
49
49
+
</div>
50
50
+
}
51
51
+
<div class="flex justify-between items-center">
52
52
+
@components.BackButton()
53
53
+
<div class="bg-brown-50 rounded-lg px-3 py-2 border border-brown-200 brew-view-actions">
54
54
+
@components.ActionBar(components.ActionBarProps{
55
55
+
SubjectURI: props.SubjectURI,
56
56
+
SubjectCID: props.SubjectCID,
57
57
+
IsLiked: props.IsLiked,
58
58
+
LikeCount: props.LikeCount,
59
59
+
CommentCount: props.CommentCount,
60
60
+
ShowComments: true,
61
61
+
ShareURL: props.ShareURL,
62
62
+
ShareTitle: props.Grinder.Name,
63
63
+
ShareText: "Check out this grinder on Arabica",
64
64
+
IsOwner: props.IsOwnProfile,
65
65
+
IsAuthenticated: props.IsAuthenticated,
66
66
+
IsModerator: props.IsModerator,
67
67
+
CanHideRecord: props.CanHideRecord,
68
68
+
CanBlockUser: props.CanBlockUser,
69
69
+
IsRecordHidden: props.IsRecordHidden,
70
70
+
AuthorDID: props.AuthorDID,
71
71
+
})
72
72
+
</div>
73
73
+
</div>
74
74
+
@components.CommentSection(components.CommentSectionProps{
75
75
+
SubjectURI: props.SubjectURI,
76
76
+
SubjectCID: props.SubjectCID,
77
77
+
Comments: props.Comments,
78
78
+
IsAuthenticated: props.IsAuthenticated,
79
79
+
CurrentUserDID: props.CurrentUserDID,
80
80
+
ModCtx: components.CommentModerationContext{
81
81
+
IsModerator: props.IsModerator,
82
82
+
CanHideRecord: props.CanHideRecord,
83
83
+
CanBlockUser: props.CanBlockUser,
84
84
+
},
85
85
+
ViewURL: props.ShareURL,
86
86
+
})
87
87
+
</div>
88
88
+
}
89
89
+
90
90
+
templ GrinderViewHeader(props GrinderViewProps) {
91
91
+
<div class="flex justify-between items-start mb-6">
92
92
+
<div>
93
93
+
<h2 class="text-3xl font-bold text-brown-900">{ props.Grinder.Name }</h2>
94
94
+
<p class="text-sm text-brown-600 mt-1">{ props.Grinder.CreatedAt.Format("January 2, 2006 at 3:04 PM") }</p>
95
95
+
</div>
96
96
+
if props.IsOwnProfile {
97
97
+
<div class="flex gap-2">
98
98
+
<button
99
99
+
hx-delete={ "/api/grinders/" + props.Grinder.RKey }
100
100
+
hx-confirm="Are you sure you want to delete this grinder?"
101
101
+
hx-target="body"
102
102
+
class="inline-flex items-center bg-brown-200 text-brown-700 px-4 py-2 rounded-lg hover:bg-brown-300 font-medium transition-colors"
103
103
+
>
104
104
+
Delete
105
105
+
</button>
106
106
+
</div>
107
107
+
}
108
108
+
</div>
109
109
+
}
110
110
+
111
111
+
templ GrinderDetailField(label, value string) {
112
112
+
<div class="section-box">
113
113
+
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">{ label }</h3>
114
114
+
if value != "" {
115
115
+
<div class="font-semibold text-brown-900">{ value }</div>
116
116
+
} else {
117
117
+
<span class="text-brown-400">Not specified</span>
118
118
+
}
119
119
+
</div>
120
120
+
}
+126
internal/web/pages/roaster_view.templ
···
1
1
+
package pages
2
2
+
3
3
+
import (
4
4
+
"arabica/internal/firehose"
5
5
+
"arabica/internal/models"
6
6
+
"arabica/internal/web/bff"
7
7
+
"arabica/internal/web/components"
8
8
+
)
9
9
+
10
10
+
type RoasterViewProps struct {
11
11
+
Roaster *models.Roaster
12
12
+
IsOwnProfile bool
13
13
+
IsAuthenticated bool
14
14
+
SubjectURI string
15
15
+
SubjectCID string
16
16
+
IsLiked bool
17
17
+
LikeCount int
18
18
+
CommentCount int
19
19
+
Comments []firehose.IndexedComment
20
20
+
CurrentUserDID string
21
21
+
ShareURL string
22
22
+
IsModerator bool
23
23
+
CanHideRecord bool
24
24
+
CanBlockUser bool
25
25
+
IsRecordHidden bool
26
26
+
AuthorDID string
27
27
+
}
28
28
+
29
29
+
templ RoasterView(layout *components.LayoutData, props RoasterViewProps) {
30
30
+
@components.Layout(layout, RoasterViewContent(props))
31
31
+
}
32
32
+
33
33
+
templ RoasterViewContent(props RoasterViewProps) {
34
34
+
<div class="page-container-sm">
35
35
+
@components.Card(components.CardProps{InnerCard: true}, RoasterViewCard(props))
36
36
+
</div>
37
37
+
}
38
38
+
39
39
+
templ RoasterViewCard(props RoasterViewProps) {
40
40
+
@RoasterViewHeader(props)
41
41
+
<div class="space-y-6">
42
42
+
<div class="grid grid-cols-2 gap-4">
43
43
+
@RoasterDetailField("📍 Location", props.Roaster.Location)
44
44
+
if props.Roaster.Website != "" {
45
45
+
<div class="section-box">
46
46
+
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">🔗 Website</h3>
47
47
+
if safeWebsite := bff.SafeWebsiteURL(props.Roaster.Website); safeWebsite != "" {
48
48
+
<a href={ templ.SafeURL(safeWebsite) } target="_blank" rel="noopener noreferrer" class="font-semibold text-brown-900 hover:underline">
49
49
+
{ safeWebsite }
50
50
+
</a>
51
51
+
} else {
52
52
+
<span class="text-brown-400">Invalid URL</span>
53
53
+
}
54
54
+
</div>
55
55
+
}
56
56
+
</div>
57
57
+
<div class="flex justify-between items-center">
58
58
+
@components.BackButton()
59
59
+
<div class="bg-brown-50 rounded-lg px-3 py-2 border border-brown-200 brew-view-actions">
60
60
+
@components.ActionBar(components.ActionBarProps{
61
61
+
SubjectURI: props.SubjectURI,
62
62
+
SubjectCID: props.SubjectCID,
63
63
+
IsLiked: props.IsLiked,
64
64
+
LikeCount: props.LikeCount,
65
65
+
CommentCount: props.CommentCount,
66
66
+
ShowComments: true,
67
67
+
ShareURL: props.ShareURL,
68
68
+
ShareTitle: props.Roaster.Name,
69
69
+
ShareText: "Check out this roaster on Arabica",
70
70
+
IsOwner: props.IsOwnProfile,
71
71
+
IsAuthenticated: props.IsAuthenticated,
72
72
+
IsModerator: props.IsModerator,
73
73
+
CanHideRecord: props.CanHideRecord,
74
74
+
CanBlockUser: props.CanBlockUser,
75
75
+
IsRecordHidden: props.IsRecordHidden,
76
76
+
AuthorDID: props.AuthorDID,
77
77
+
})
78
78
+
</div>
79
79
+
</div>
80
80
+
@components.CommentSection(components.CommentSectionProps{
81
81
+
SubjectURI: props.SubjectURI,
82
82
+
SubjectCID: props.SubjectCID,
83
83
+
Comments: props.Comments,
84
84
+
IsAuthenticated: props.IsAuthenticated,
85
85
+
CurrentUserDID: props.CurrentUserDID,
86
86
+
ModCtx: components.CommentModerationContext{
87
87
+
IsModerator: props.IsModerator,
88
88
+
CanHideRecord: props.CanHideRecord,
89
89
+
CanBlockUser: props.CanBlockUser,
90
90
+
},
91
91
+
ViewURL: props.ShareURL,
92
92
+
})
93
93
+
</div>
94
94
+
}
95
95
+
96
96
+
templ RoasterViewHeader(props RoasterViewProps) {
97
97
+
<div class="flex justify-between items-start mb-6">
98
98
+
<div>
99
99
+
<h2 class="text-3xl font-bold text-brown-900">{ props.Roaster.Name }</h2>
100
100
+
<p class="text-sm text-brown-600 mt-1">{ props.Roaster.CreatedAt.Format("January 2, 2006 at 3:04 PM") }</p>
101
101
+
</div>
102
102
+
if props.IsOwnProfile {
103
103
+
<div class="flex gap-2">
104
104
+
<button
105
105
+
hx-delete={ "/api/roasters/" + props.Roaster.RKey }
106
106
+
hx-confirm="Are you sure you want to delete this roaster?"
107
107
+
hx-target="body"
108
108
+
class="inline-flex items-center bg-brown-200 text-brown-700 px-4 py-2 rounded-lg hover:bg-brown-300 font-medium transition-colors"
109
109
+
>
110
110
+
Delete
111
111
+
</button>
112
112
+
</div>
113
113
+
}
114
114
+
</div>
115
115
+
}
116
116
+
117
117
+
templ RoasterDetailField(label, value string) {
118
118
+
<div class="section-box">
119
119
+
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">{ label }</h3>
120
120
+
if value != "" {
121
121
+
<div class="font-semibold text-brown-900">{ value }</div>
122
122
+
} else {
123
123
+
<span class="text-brown-400">Not specified</span>
124
124
+
}
125
125
+
</div>
126
126
+
}