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