Coffee journaling on ATProto (alpha) alpha.arabica.social
coffee

feat: new view pages for non-brew record types

pdewey.com 4a93fd0e 84afe0c8

verified
+1438 -39
+141
internal/atproto/store.go
··· 365 365 return nil 366 366 } 367 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 + 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 + 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 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 + 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) 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 + 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 38 if props.Brew.Rating > 0 { 39 39 @BrewRating(props.Brew.Rating) 40 40 } 41 - @BrewBeanSection(props.Brew) 42 - @BrewParametersGrid(props.Brew) 41 + @BrewBeanSection(props.Brew, getOwnerFromShareURL(props.ShareURL)) 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 - <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> 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 103 </div> 104 104 } 105 105 106 106 // BrewBeanSection renders the coffee bean information 107 - templ BrewBeanSection(brew *models.Brew) { 107 + templ BrewBeanSection(brew *models.Brew, owner string) { 108 108 <div class="section-box"> 109 - <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">Coffee Bean</h3> 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 - if brew.Bean.Name != "" { 113 - { brew.Bean.Name } 114 - } else { 115 - { brew.Bean.Origin } 116 - } 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> 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 - by { brew.Bean.Roaster.Name } 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> 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 - <span>Origin: { brew.Bean.Origin }</span> 130 + <span>📍 { brew.Bean.Origin }</span> 126 131 } 127 132 if brew.Bean.RoastLevel != "" { 128 - <span>Roast: { brew.Bean.RoastLevel }</span> 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 - templ BrewParametersGrid(brew *models.Brew) { 143 + templ BrewParametersGrid(brew *models.Brew, owner string) { 139 144 <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)) 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)) 146 151 <div class="col-span-2"> 147 - @BrewParameter("Brew Time", getBrewTimeDisplay(brew)) 152 + @BrewParameter("⏱️ Brew Time", getBrewTimeDisplay(brew)) 148 153 </div> 149 154 </div> 150 155 } ··· 161 166 </div> 162 167 } 163 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 + 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 + 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 + 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 - <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-3">Pours</h3> 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 - <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">Tasting Notes</h3> 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 + 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 73 case lexicons.RecordTypeBrew: 74 74 @FeedBrewContentClickable(item) 75 75 case lexicons.RecordTypeBean: 76 - @FeedBeanContent(item) 76 + @FeedEntityContentClickable(item, FeedBeanContent) 77 77 case lexicons.RecordTypeRoaster: 78 - @FeedRoasterContent(item) 78 + @FeedEntityContentClickable(item, FeedRoasterContent) 79 79 case lexicons.RecordTypeGrinder: 80 - @FeedGrinderContent(item) 80 + @FeedEntityContentClickable(item, FeedGrinderContent) 81 81 case lexicons.RecordTypeBrewer: 82 - @FeedBrewerContent(item) 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 - <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> 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> 135 158 } else { 136 159 { item.Action } 137 160 } 138 161 default: 139 162 { item.Action } 140 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> 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 + 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 + } 359 408 } 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 - // getEditURL returns the edit URL for a feed item (only for brews currently) 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 + // Beans, roasters, grinders, and brewers are edited via modals on the manage page 417 466 return "" 418 467 } 419 468 420 - // getDeleteURL returns the delete URL for a feed item (only for brews currently) 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 + } 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) 426 491 } 427 492 } 428 493 return ""
+120
internal/web/pages/grinder_view.templ
··· 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
··· 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 + }