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

feat: likes on brews and other record types (#5)

* feat: like records -- added ability to "like" feed items

* fix: sum water from pours when not set explicitly

When viewing a brew, if the water amount field is 0 (not set explicitly),
the display now calculates the total by summing water amounts from all pours.

This provides a more accurate display when users log individual pours but
don't set a total water amount.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

feat: add like button to brew view page

Added a like button on the brew view page, positioned in the same row
as the back button but on the right side. The button:
- Shows the current like count
- Allows authenticated users to toggle likes via HTMX
- Uses the existing LikeButton component

Added GetBrewRecordByRKey method to AtprotoStore to fetch brew records
with their AT Protocol metadata (URI and CID) needed for the like button.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: move like and share buttons

* refactor: move RecordType to lexicons package

- Create new internal/lexicons package for AT Protocol lexicon types
- Add RecordType type with constants for bean, brew, brewer, grinder, like, roaster
- Add String() and DisplayName() helper methods
- Update feed/service.go, firehose/index.go, and feed.templ to use lexicons.RecordType
- Remove magic strings throughout the codebase for type safety

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* feat: prevent like button requests when unauthenticated

- Modified LikeButton to conditionally add HTMX attributes based on IsAuthenticated
- Added IsAuthenticated prop to LikeButtonProps and SocialButtonsProps
- Updated FeedCard and FeedPartial to pass authentication state through component hierarchy
- Added TODO comment to implement login prompt instead of doing nothing when unauthenticated users click like
- Edit buttons already check isOwnProfile, so they only appear for authenticated owners

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

authored by

Patrick Dewey
Claude Sonnet 4.5
and committed by
GitHub
7fac9eaa 64185390

+1185 -241
+5
.cells/cells.jsonl
··· 38 38 {"id":"01KGB2WBMP539SFSKZCKJAYH0C","title":"Prevent line wrap between emoji and text in table headers","description":"Add whitespace-nowrap to table header cells to prevent the emoji and column name from wrapping onto separate lines.","status":"completed","priority":"normal","assignee":"patrick","labels":["frontend"],"created_at":"2026-01-31T22:30:51.286845962Z","updated_at":"2026-01-31T22:31:23.380048647Z","completed_at":"2026-01-31T22:31:23.369060496Z"} 39 39 {"id":"01KGBF5Q2KJ8PZNSMJPFFF31C8","title":"Add AT Protocol explanation page","description":"Create a page explaining what the AT Protocol is that the footer link redirects to instead of atproto.com.\n\nAcceptance criteria:\n- New /atproto page explaining the AT Protocol\n- Footer AT Protocol link redirects to /atproto instead of external site\n- Page matches site design system\n- Explains what AT Protocol is and how Arabica uses it\n- Links to atproto.com for those who want more info","status":"claimed","priority":"normal","assignee":"patrick","created_at":"2026-02-01T02:05:40.819263564Z","updated_at":"2026-02-01T02:10:19.29363426Z","notes":[{"timestamp":"2026-02-01T02:10:19.279321594Z","author":"patrick","message":"Initial implementation complete:\n- Created /atproto page with AT Protocol explanation\n- Updated footer link to point to /atproto instead of external site\n- Added route and handler\n- Page covers: PDS, DIDs, Lexicons, AT-URIs, how Arabica uses ATProto\n- Includes links to atproto.com for more info\nReady for review and final polish"}]} 40 40 {"id":"01KGBM1YCGZRNTVCJDZFV3100R","title":"Move web/static to static directory","description":"Move web/static directory to top-level static directory and update all references across the codebase.\n\nFiles to update:\n- flake.nix (tailwindcss paths)\n- .gitignore (output.css path)\n- justfile (tailwindcss paths)\n- default.nix (tailwindcss paths)\n- .gitattributes (vendored JS paths)\n- internal/routing/routing.go (file server path)\n- deploy/Dockerfile (COPY command)\n- CLAUDE.md (documentation)\n\nAcceptance criteria:\n- web/static moved to static/\n- All references updated\n- Server still serves static files correctly\n- Tailwind CSS build still works","status":"completed","priority":"normal","assignee":"patrick","created_at":"2026-02-01T03:31:00.112467261Z","updated_at":"2026-02-01T03:32:59.754877212Z","completed_at":"2026-02-01T03:32:59.739794374Z"} 41 + {"id":"01KGDXVE566WVDG8TC9DWPSYXF","title":"Implement likes for social interactions","description":"Add a like lexicon and implement like functionality across the app.\n\n## Lexicon: social.arabica.alpha.like\n\nThe like record should:\n- Use `com.atproto.repo.strongRef` for the subject (URI + CID for immutability)\n- Support liking any arabica.social lexicon type (beans, roasters, grinders, brewers, brews, and comments once implemented)\n- Include createdAt timestamp\n- Use TID as record key\n\n## Backend Implementation\n\n1. Create lexicon file: `lexicons/social.arabica.alpha.like.json`\n2. Add NSID constant in `internal/atproto/nsid.go`\n3. Add Like model in `internal/models/models.go`\n4. Add record conversion functions in `internal/atproto/records.go`\n5. Add Store interface methods: CreateLike, DeleteLike, GetLikesForSubject, GetUserLikes\n6. Implement in AtprotoStore\n7. Update firehose indexing to track likes\n\n## Frontend Implementation\n\n1. Add like button component\n2. Show like counts on brews/beans/etc in feed\n3. Update like counts in response to firehose events (real-time updates)\n4. Toggle like state based on current user's likes\n\n## Acceptance Criteria\n\n- Users can like any arabica.social record\n- Likes are stored in the user's PDS (actor-owned data)\n- Like counts display on records in the feed\n- Real-time like count updates via firehose\n- Optimistic UI updates for likes","status":"open","priority":"high","labels":["backend","frontend","atproto"],"created_at":"2026-02-02T01:00:41.510646368Z","updated_at":"2026-02-02T01:00:41.510646368Z"} 42 + {"id":"01KGDXVR86CB2H44DJHKN4Z9M0","title":"Implement comments for social interactions","description":"Add a comment lexicon and implement comment functionality across the app.\n\n## Lexicon: social.arabica.alpha.comment\n\nThe comment record should:\n- Use `com.atproto.repo.strongRef` for the subject (URI + CID for immutability)\n- Support commenting on any arabica.social lexicon type (beans, roasters, grinders, brewers, brews, and other comments)\n- Include text field (max 1000 chars / 300 graphemes as per AT Protocol conventions)\n- Include createdAt timestamp\n- Use TID as record key\n- NOTE: This cell implements flat comments only. Threaded/nested comments are handled in a separate cell.\n\n## Backend Implementation\n\n1. Create lexicon file: `lexicons/social.arabica.alpha.comment.json`\n2. Add NSID constant in `internal/atproto/nsid.go`\n3. Add Comment model in `internal/models/models.go`\n4. Add record conversion functions in `internal/atproto/records.go`\n5. Add Store interface methods: CreateComment, DeleteComment, GetCommentsForSubject, GetUserComments\n6. Implement in AtprotoStore\n7. Update firehose indexing to track comments\n\n## Frontend Implementation\n\n1. Add comment section component below brew detail view\n2. Add comment form (authenticated users only)\n3. Display comment count on records in feed\n4. Update comment counts in response to firehose events\n5. Show commenter profile info (avatar, handle)\n\n## Acceptance Criteria\n\n- Users can comment on any arabica.social record\n- Comments are stored in the user's PDS (actor-owned data)\n- Comment counts display on records in the feed\n- Comments visible on record detail views\n- Real-time comment count updates via firehose","status":"open","priority":"normal","labels":["backend","frontend","atproto"],"created_at":"2026-02-02T01:00:51.846427126Z","updated_at":"2026-02-02T01:00:51.846427126Z"} 43 + {"id":"01KGDXW31PHFMBE3WTV83HPET1","title":"Implement comment threading","description":"Add support for threaded/nested comments, building on the flat comment system.\n\n## Lexicon Changes\n\nUpdate `social.arabica.alpha.comment` to add optional threading fields:\n- `parent`: Optional strongRef to parent comment (for replies)\n- `root`: Optional strongRef to root subject (maintains context when replying to comments)\n\nAlternatively, consider a separate reply field or keeping comments flat with UI-level threading.\n\n## Backend Implementation\n\n1. Update comment model to include parent/root references\n2. Add methods to fetch comment threads: GetCommentThread, GetReplies\n3. Update firehose indexing to track parent-child relationships\n4. Add depth limits for threading (prevent infinite nesting)\n\n## Frontend Implementation\n\n1. Design threaded comment UI (indentation, collapse/expand)\n2. Add 'reply' button on comments\n3. Show reply context when replying\n4. Consider max nesting depth for display (e.g., 3-4 levels)\n5. Mobile-friendly thread navigation\n\n## Design Considerations\n\n- How deep should threading go? (Recommend max 3-4 levels visible, then flatten)\n- How to handle deleted parent comments?\n- Should users be notified when someone replies to their comment?\n- Performance: lazy-load deep threads vs eager-load\n\n## Acceptance Criteria\n\n- Users can reply directly to comments\n- Thread structure is visually clear\n- Threads can be collapsed/expanded\n- Works well on mobile devices\n- Parent context shown when replying","status":"blocked","priority":"low","blocked_by":["01KGDXVR86CB2H44DJHKN4Z9M0"],"labels":["frontend","atproto"],"created_at":"2026-02-02T01:01:02.902692829Z","updated_at":"2026-02-02T01:01:06.361891137Z"} 44 + {"id":"01KGGG8EHG9Y6VQ8PZM9W74584","title":"Refactor lexicon record types to use concrete RecordType","description":"Replace magic strings for lexicon record types with a concrete RecordType type and defined constants.\n\n## Current State\nMagic strings like \"brew\", \"bean\", \"roaster\", \"grinder\", \"brewer\" are used throughout the codebase:\n- `internal/feed/service.go` - FeedItem.RecordType field uses string type\n- `internal/firehose/index.go` - Sets RecordType to string literals\n- `internal/web/pages/feed.templ` - Switch cases on string values\n- `internal/handlers/handlers.go` - writeJSON calls with string type names\n\n## Proposed Changes\n1. Define a `RecordType` type in `internal/atproto/nsid.go` (or a new file)\n2. Create constants: `RecordTypeBrew`, `RecordTypeBean`, `RecordTypeRoaster`, `RecordTypeGrinder`, `RecordTypeBrewer`, `RecordTypeLike`\n3. Update `FeedItem.RecordType` in `feed/service.go` to use the new type\n4. Update all string literal usages to use the constants\n5. Consider adding helper methods (e.g., `RecordType.String()`, `RecordType.DisplayName()`)\n\n## Acceptance Criteria\n- No magic strings for record types remain in Go code\n- Template switch statements updated to use constants\n- Type safety enforced at compile time\n- All tests pass","status":"completed","priority":"normal","labels":["refactoring","tech-debt"],"created_at":"2026-02-03T01:00:51.120049807Z","updated_at":"2026-02-03T01:06:55.448414863Z","completed_at":"2026-02-03T01:06:55.435337779Z"} 45 + {"id":"01KGGHX6R13BR2S5K4WZM79F6K","title":"Design profile brews with social features","description":"## Problem\n\nThe profile page currently displays brews in a table format, but we need to add likes and shares which don't fit ergonomically into table columns. Additionally, the table design doesn't work well on mobile.\n\n## Options to Evaluate\n\n### Option A: Add columns to existing table\n- Add Like count and Share button columns\n- Pros: Minimal change, consistent with current design\n- Cons: Tables already cramped, poor mobile experience, doesn't match feed UX\n\n### Option B: Convert brews to card-based layout (like feed)\n- Reuse/adapt FeedBrewContent component for profile brews\n- Keep tables for equipment/beans (less social, more data-dense)\n- Pros: Better mobile experience, natural fit for social buttons, consistent with feed\n- Cons: More work, different UX between profile and brew list\n\n### Option C: Hybrid responsive approach\n- Cards on mobile, table on desktop\n- Pros: Best of both worlds\n- Cons: More complexity, two layouts to maintain\n\n## Acceptance Criteria\n\n1. Research complete: Document the chosen approach with rationale\n2. Design spec: Mockup or clear description of the new layout\n3. If cards: Define how profile brew cards differ from feed cards (actions, ownership indicators)\n4. Consider: Should beans/equipment also get social features eventually?\n\n## Implementation Subtasks (create as separate cells)\n\nAfter design is approved:\n- [ ] Create ProfileBrewCard component (if card approach)\n- [ ] Update profile brews tab to use new layout \n- [ ] Add like count display to brew entries\n- [ ] Add share button to brew entries\n- [ ] Mobile responsiveness testing\n- [ ] Update any related styles in app.css\n\n## Notes\n\n- Social buttons already exist in components/social_buttons.templ\n- Feed cards provide a good reference: pages/feed.templ\n- Current brew table: components/brew_list_table.templ\n- Profile page: pages/profile.templ","status":"open","priority":"high","labels":["frontend","design","ux"],"created_at":"2026-02-03T01:29:39.841071728Z","updated_at":"2026-02-03T01:29:39.841071728Z"}
-3
.gitattributes
··· 1 - *.tmpl linguist-language=go-template 2 - 3 1 static/js/alpine.min.js linguist-vendored 4 2 static/js/htmx.min.js linguist-vendored 5 -
+49
CLAUDE.md
··· 558 558 } 559 559 ``` 560 560 561 + ### Testing Conventions 562 + 563 + **IMPORTANT:** All tests in this codebase MUST use [testify/assert](https://github.com/stretchr/testify) for assertions. Do NOT use `if` statements with `t.Error()` or `t.Errorf()`. 564 + 565 + ```go 566 + // CORRECT: Use testify assert 567 + import ( 568 + "testing" 569 + "github.com/stretchr/testify/assert" 570 + ) 571 + 572 + func TestFormatTemp(t *testing.T) { 573 + got := FormatTemp(93.5) 574 + assert.Equal(t, "93.5°C", got) 575 + } 576 + 577 + func TestPtrEquals(t *testing.T) { 578 + val := 42 579 + assert.True(t, PtrEquals(&val, 42)) 580 + assert.False(t, PtrEquals(&val, 99)) 581 + } 582 + 583 + func TestRenderedHTML(t *testing.T) { 584 + html := renderComponent() 585 + assert.Contains(t, html, "btn-primary") 586 + assert.NotContains(t, html, "deprecated-class") 587 + } 588 + 589 + // WRONG: Don't use if statements for assertions 590 + func TestFormatTemp(t *testing.T) { 591 + got := FormatTemp(93.5) 592 + if got != "93.5°C" { // ❌ Don't do this 593 + t.Errorf("got %q, want %q", got, "93.5°C") 594 + } 595 + } 596 + ``` 597 + 598 + **Common testify assertions:** 599 + - `assert.Equal(t, expected, actual)` - Equality check 600 + - `assert.NotEqual(t, expected, actual)` - Inequality check 601 + - `assert.True(t, value)` - Boolean true 602 + - `assert.False(t, value)` - Boolean false 603 + - `assert.Nil(t, value)` - Nil check 604 + - `assert.NotNil(t, value)` - Not nil check 605 + - `assert.Contains(t, haystack, needle)` - Substring/element check 606 + - `assert.NotContains(t, haystack, needle)` - Negative substring/element check 607 + - `assert.NoError(t, err)` - No error occurred 608 + - `assert.Error(t, err)` - Error occurred 609 + 561 610 ## Future Vision: Social Features 562 611 563 612 The app currently has a basic community feed. Future plans expand social interactions leveraging AT Protocol's decentralized nature.
+3
cmd/server/main.go
··· 292 292 }, 293 293 ) 294 294 295 + // Wire up the feed index for like functionality 296 + h.SetFeedIndex(feedIndex) 297 + 295 298 // Setup router with middleware 296 299 handler := routing.SetupRouter(routing.Config{ 297 300 Handlers: h,
+1
internal/atproto/nsid.go
··· 17 17 NSIDBrew = NSIDBase + ".brew" 18 18 NSIDBrewer = NSIDBase + ".brewer" 19 19 NSIDGrinder = NSIDBase + ".grinder" 20 + NSIDLike = NSIDBase + ".like" 20 21 NSIDRoaster = NSIDBase + ".roaster" 21 22 22 23 // MaxRKeyLength is the maximum allowed length for a record key
+3 -1
internal/atproto/oauth.go
··· 17 17 "repo:" + NSIDBrew, 18 18 "repo:" + NSIDBrewer, 19 19 "repo:" + NSIDGrinder, 20 + "repo:" + NSIDLike, 20 21 "repo:" + NSIDRoaster, 21 22 } 22 23 ··· 197 198 // ParseDID is a helper to parse a DID string to syntax.DID 198 199 func ParseDID(didStr string) (syntax.DID, error) { 199 200 return syntax.ParseDID(didStr) 200 - } 201 + } 202 +
+68
internal/atproto/records.go
··· 432 432 433 433 return brewer, nil 434 434 } 435 + 436 + // ========== Like Conversions ========== 437 + 438 + // LikeToRecord converts a models.Like to an atproto record map 439 + // Uses com.atproto.repo.strongRef format for the subject 440 + func LikeToRecord(like *models.Like) (map[string]interface{}, error) { 441 + if like.SubjectURI == "" { 442 + return nil, fmt.Errorf("subject URI is required") 443 + } 444 + if like.SubjectCID == "" { 445 + return nil, fmt.Errorf("subject CID is required") 446 + } 447 + 448 + record := map[string]interface{}{ 449 + "$type": NSIDLike, 450 + "subject": map[string]interface{}{ 451 + "uri": like.SubjectURI, 452 + "cid": like.SubjectCID, 453 + }, 454 + "createdAt": like.CreatedAt.Format(time.RFC3339), 455 + } 456 + 457 + return record, nil 458 + } 459 + 460 + // RecordToLike converts an atproto record map to a models.Like 461 + func RecordToLike(record map[string]interface{}, atURI string) (*models.Like, error) { 462 + like := &models.Like{} 463 + 464 + // Extract rkey from AT-URI 465 + if atURI != "" { 466 + parsedURI, err := syntax.ParseATURI(atURI) 467 + if err != nil { 468 + return nil, fmt.Errorf("invalid AT-URI: %w", err) 469 + } 470 + like.RKey = parsedURI.RecordKey().String() 471 + } 472 + 473 + // Required field: subject (strongRef) 474 + subject, ok := record["subject"].(map[string]interface{}) 475 + if !ok { 476 + return nil, fmt.Errorf("subject is required") 477 + } 478 + subjectURI, ok := subject["uri"].(string) 479 + if !ok || subjectURI == "" { 480 + return nil, fmt.Errorf("subject.uri is required") 481 + } 482 + like.SubjectURI = subjectURI 483 + 484 + subjectCID, ok := subject["cid"].(string) 485 + if !ok || subjectCID == "" { 486 + return nil, fmt.Errorf("subject.cid is required") 487 + } 488 + like.SubjectCID = subjectCID 489 + 490 + // Required field: createdAt 491 + createdAtStr, ok := record["createdAt"].(string) 492 + if !ok { 493 + return nil, fmt.Errorf("createdAt is required") 494 + } 495 + createdAt, err := time.Parse(time.RFC3339, createdAtStr) 496 + if err != nil { 497 + return nil, fmt.Errorf("invalid createdAt format: %w", err) 498 + } 499 + like.CreatedAt = createdAt 500 + 501 + return like, nil 502 + }
+155
internal/atproto/store.go
··· 167 167 return brew, nil 168 168 } 169 169 170 + // BrewRecord contains a brew with its AT Protocol metadata 171 + type BrewRecord struct { 172 + Brew *models.Brew 173 + URI string 174 + CID string 175 + } 176 + 177 + // GetBrewRecordByRKey fetches a brew by rkey and returns it with its AT Protocol metadata 178 + func (s *AtprotoStore) GetBrewRecordByRKey(ctx context.Context, rkey string) (*BrewRecord, error) { 179 + output, err := s.client.GetRecord(ctx, s.did, s.sessionID, &GetRecordInput{ 180 + Collection: NSIDBrew, 181 + RKey: rkey, 182 + }) 183 + if err != nil { 184 + return nil, fmt.Errorf("failed to get brew record: %w", err) 185 + } 186 + 187 + // Build the AT-URI for this brew 188 + atURI := BuildATURI(s.did.String(), NSIDBrew, rkey) 189 + 190 + // Convert to models.Brew 191 + brew, err := RecordToBrew(output.Value, atURI) 192 + if err != nil { 193 + return nil, fmt.Errorf("failed to convert brew record: %w", err) 194 + } 195 + 196 + // Set the rkey 197 + brew.RKey = rkey 198 + 199 + // Extract and resolve references 200 + beanRef, _ := output.Value["beanRef"].(string) 201 + grinderRef, _ := output.Value["grinderRef"].(string) 202 + brewerRef, _ := output.Value["brewerRef"].(string) 203 + 204 + // Extract rkeys from AT-URIs for the model 205 + if beanRef != "" { 206 + if components, err := ResolveATURI(beanRef); err == nil { 207 + brew.BeanRKey = components.RKey 208 + } 209 + } 210 + if grinderRef != "" { 211 + if components, err := ResolveATURI(grinderRef); err == nil { 212 + brew.GrinderRKey = components.RKey 213 + } 214 + } 215 + if brewerRef != "" { 216 + if components, err := ResolveATURI(brewerRef); err == nil { 217 + brew.BrewerRKey = components.RKey 218 + } 219 + } 220 + 221 + err = ResolveBrewRefs(ctx, s.client, brew, beanRef, grinderRef, brewerRef, s.sessionID) 222 + if err != nil { 223 + log.Warn().Err(err).Str("brew_rkey", rkey).Msg("Failed to resolve brew references") 224 + } 225 + 226 + return &BrewRecord{ 227 + Brew: brew, 228 + URI: output.URI, 229 + CID: output.CID, 230 + }, nil 231 + } 232 + 170 233 func (s *AtprotoStore) ListBrews(ctx context.Context, userID int) ([]*models.Brew, error) { 171 234 // Check cache first 172 235 userCache := s.cache.Get(s.sessionID) ··· 982 1045 s.cache.InvalidateBrewers(s.sessionID) 983 1046 984 1047 return nil 1048 + } 1049 + 1050 + // ========== Like Operations ========== 1051 + 1052 + func (s *AtprotoStore) CreateLike(ctx context.Context, req *models.CreateLikeRequest) (*models.Like, error) { 1053 + if req.SubjectURI == "" { 1054 + return nil, fmt.Errorf("subject_uri is required") 1055 + } 1056 + if req.SubjectCID == "" { 1057 + return nil, fmt.Errorf("subject_cid is required") 1058 + } 1059 + 1060 + likeModel := &models.Like{ 1061 + SubjectURI: req.SubjectURI, 1062 + SubjectCID: req.SubjectCID, 1063 + CreatedAt: time.Now(), 1064 + } 1065 + 1066 + record, err := LikeToRecord(likeModel) 1067 + if err != nil { 1068 + return nil, fmt.Errorf("failed to convert like to record: %w", err) 1069 + } 1070 + 1071 + output, err := s.client.CreateRecord(ctx, s.did, s.sessionID, &CreateRecordInput{ 1072 + Collection: NSIDLike, 1073 + Record: record, 1074 + }) 1075 + if err != nil { 1076 + return nil, fmt.Errorf("failed to create like record: %w", err) 1077 + } 1078 + 1079 + atURI, err := syntax.ParseATURI(output.URI) 1080 + if err != nil { 1081 + return nil, fmt.Errorf("failed to parse returned AT-URI: %w", err) 1082 + } 1083 + 1084 + likeModel.RKey = atURI.RecordKey().String() 1085 + 1086 + return likeModel, nil 1087 + } 1088 + 1089 + func (s *AtprotoStore) DeleteLikeByRKey(ctx context.Context, rkey string) error { 1090 + err := s.client.DeleteRecord(ctx, s.did, s.sessionID, &DeleteRecordInput{ 1091 + Collection: NSIDLike, 1092 + RKey: rkey, 1093 + }) 1094 + if err != nil { 1095 + return fmt.Errorf("failed to delete like record: %w", err) 1096 + } 1097 + return nil 1098 + } 1099 + 1100 + func (s *AtprotoStore) GetUserLikeForSubject(ctx context.Context, subjectURI string) (*models.Like, error) { 1101 + // List all likes and find the one matching the subject URI 1102 + likes, err := s.ListUserLikes(ctx) 1103 + if err != nil { 1104 + return nil, err 1105 + } 1106 + 1107 + for _, like := range likes { 1108 + if like.SubjectURI == subjectURI { 1109 + return like, nil 1110 + } 1111 + } 1112 + 1113 + return nil, nil // Not found (not an error) 1114 + } 1115 + 1116 + func (s *AtprotoStore) ListUserLikes(ctx context.Context) ([]*models.Like, error) { 1117 + output, err := s.client.ListAllRecords(ctx, s.did, s.sessionID, NSIDLike) 1118 + if err != nil { 1119 + return nil, fmt.Errorf("failed to list like records: %w", err) 1120 + } 1121 + 1122 + likes := make([]*models.Like, 0, len(output.Records)) 1123 + 1124 + for _, rec := range output.Records { 1125 + like, err := RecordToLike(rec.Value, rec.URI) 1126 + if err != nil { 1127 + log.Warn().Err(err).Str("uri", rec.URI).Msg("Failed to convert like record") 1128 + continue 1129 + } 1130 + 1131 + // Extract rkey from URI 1132 + if components, err := ResolveATURI(rec.URI); err == nil { 1133 + like.RKey = components.RKey 1134 + } 1135 + 1136 + likes = append(likes, like) 1137 + } 1138 + 1139 + return likes, nil 985 1140 } 986 1141 987 1142 func (s *AtprotoStore) Close() error {
+6
internal/database/store.go
··· 48 48 UpdateBrewerByRKey(ctx context.Context, rkey string, brewer *models.UpdateBrewerRequest) error 49 49 DeleteBrewerByRKey(ctx context.Context, rkey string) error 50 50 51 + // Like operations 52 + CreateLike(ctx context.Context, req *models.CreateLikeRequest) (*models.Like, error) 53 + DeleteLikeByRKey(ctx context.Context, rkey string) error 54 + GetUserLikeForSubject(ctx context.Context, subjectURI string) (*models.Like, error) 55 + ListUserLikes(ctx context.Context) ([]*models.Like, error) 56 + 51 57 // Close the database connection 52 58 Close() error 53 59 }
+16 -3
internal/feed/service.go
··· 7 7 "time" 8 8 9 9 "arabica/internal/atproto" 10 + "arabica/internal/lexicons" 10 11 "arabica/internal/models" 11 12 12 13 "github.com/rs/zerolog/log" ··· 29 30 // FeedItem represents an activity in the social feed with author info 30 31 type FeedItem struct { 31 32 // Record type and data (only one will be non-nil) 32 - RecordType string // "brew", "bean", "roaster", "grinder", "brewer" 33 - Action string // "added a new brew", "added a new bean", etc. 33 + RecordType lexicons.RecordType // Use lexicons.RecordTypeBrew, lexicons.RecordTypeBean, etc. 34 + Action string // "added a new brew", "added a new bean", etc. 34 35 35 36 Brew *models.Brew 36 37 Bean *models.Bean ··· 41 42 Author *atproto.Profile 42 43 Timestamp time.Time 43 44 TimeAgo string // "2 hours ago", "yesterday", etc. 45 + 46 + // Like-related fields 47 + LikeCount int // Number of likes on this record 48 + SubjectURI string // AT-URI of this record (for like button) 49 + SubjectCID string // CID of this record (for like button) 50 + IsLikedByViewer bool // Whether the current viewer has liked this record 44 51 } 45 52 46 53 // publicFeedCache holds cached feed items for unauthenticated users ··· 60 67 // FirehoseFeedItem matches the FeedItem structure from firehose package 61 68 // This avoids import cycles 62 69 type FirehoseFeedItem struct { 63 - RecordType string 70 + RecordType lexicons.RecordType 64 71 Action string 65 72 Brew *models.Brew 66 73 Bean *models.Bean ··· 70 77 Author *atproto.Profile 71 78 Timestamp time.Time 72 79 TimeAgo string 80 + LikeCount int 81 + SubjectURI string 82 + SubjectCID string 73 83 } 74 84 75 85 // Service fetches and aggregates brews from registered users ··· 199 209 Author: fi.Author, 200 210 Timestamp: fi.Timestamp, 201 211 TimeAgo: fi.TimeAgo, 212 + LikeCount: fi.LikeCount, 213 + SubjectURI: fi.SubjectURI, 214 + SubjectCID: fi.SubjectCID, 202 215 } 203 216 } 204 217
+3
internal/firehose/adapter.go
··· 44 44 Author: item.Author, 45 45 Timestamp: item.Timestamp, 46 46 TimeAgo: item.TimeAgo, 47 + LikeCount: item.LikeCount, 48 + SubjectURI: item.SubjectURI, 49 + SubjectCID: item.SubjectCID, 47 50 } 48 51 } 49 52
+1
internal/firehose/config.go
··· 21 21 atproto.NSIDRoaster, 22 22 atproto.NSIDGrinder, 23 23 atproto.NSIDBrewer, 24 + atproto.NSIDLike, 24 25 } 25 26 26 27 // Config holds configuration for the Jetstream consumer
+33
internal/firehose/consumer.go
··· 333 333 return fmt.Errorf("failed to upsert record: %w", err) 334 334 } 335 335 336 + // Special handling for likes - index for counts 337 + if commit.Collection == "social.arabica.alpha.like" { 338 + var recordData map[string]interface{} 339 + if err := json.Unmarshal(commit.Record, &recordData); err == nil { 340 + if subject, ok := recordData["subject"].(map[string]interface{}); ok { 341 + if subjectURI, ok := subject["uri"].(string); ok { 342 + if err := c.index.UpsertLike(event.DID, commit.RKey, subjectURI); err != nil { 343 + log.Warn().Err(err).Str("did", event.DID).Str("subject", subjectURI).Msg("failed to index like") 344 + } 345 + } 346 + } 347 + } 348 + } 349 + 336 350 case "delete": 351 + // Special handling for likes - need to look up subject URI before delete 352 + if commit.Collection == "social.arabica.alpha.like" { 353 + // Try to get the existing record to find its subject 354 + if existingRecord, err := c.index.GetRecord( 355 + fmt.Sprintf("at://%s/%s/%s", event.DID, commit.Collection, commit.RKey), 356 + ); err == nil && existingRecord != nil { 357 + var recordData map[string]interface{} 358 + if err := json.Unmarshal(existingRecord.Record, &recordData); err == nil { 359 + if subject, ok := recordData["subject"].(map[string]interface{}); ok { 360 + if subjectURI, ok := subject["uri"].(string); ok { 361 + if err := c.index.DeleteLike(event.DID, subjectURI); err != nil { 362 + log.Warn().Err(err).Str("did", event.DID).Str("subject", subjectURI).Msg("failed to delete like index") 363 + } 364 + } 365 + } 366 + } 367 + } 368 + } 369 + 337 370 if err := c.index.DeleteRecord( 338 371 event.DID, 339 372 commit.Collection,
+183 -18
internal/firehose/index.go
··· 13 13 "time" 14 14 15 15 "arabica/internal/atproto" 16 + "arabica/internal/lexicons" 16 17 "arabica/internal/models" 17 18 18 19 "github.com/rs/zerolog/log" ··· 44 45 45 46 // BucketBackfilled stores DIDs that have been backfilled: {did} -> {timestamp} 46 47 BucketBackfilled = []byte("backfilled") 48 + 49 + // BucketLikes stores like mappings: {subject_uri:actor_did} -> {rkey} 50 + BucketLikes = []byte("likes") 51 + 52 + // BucketLikeCounts stores aggregated like counts: {subject_uri} -> {uint64 count} 53 + BucketLikeCounts = []byte("like_counts") 54 + 55 + // BucketLikesByActor stores likes by actor for lookup: {actor_did:subject_uri} -> {rkey} 56 + BucketLikesByActor = []byte("likes_by_actor") 47 57 ) 48 58 59 + // FeedableRecordTypes are the record types that should appear as feed items. 60 + // Likes, comments, etc. are indexed but not displayed directly in the feed. 61 + var FeedableRecordTypes = map[lexicons.RecordType]bool{ 62 + lexicons.RecordTypeBrew: true, 63 + lexicons.RecordTypeBean: true, 64 + lexicons.RecordTypeRoaster: true, 65 + lexicons.RecordTypeGrinder: true, 66 + lexicons.RecordTypeBrewer: true, 67 + } 68 + 49 69 // IndexedRecord represents a record stored in the index 50 70 type IndexedRecord struct { 51 71 URI string `json:"uri"` ··· 111 131 BucketMeta, 112 132 BucketKnownDIDs, 113 133 BucketBackfilled, 134 + BucketLikes, 135 + BucketLikeCounts, 136 + BucketLikesByActor, 114 137 } 115 138 for _, bucket := range buckets { 116 139 if _, err := tx.CreateBucketIfNotExists(bucket); err != nil { ··· 120 143 return nil 121 144 }) 122 145 if err != nil { 123 - db.Close() 146 + _ = db.Close() 124 147 return nil, err 125 148 } 126 149 ··· 162 185 err := idx.db.View(func(tx *bolt.Tx) error { 163 186 b := tx.Bucket(BucketMeta) 164 187 v := b.Get([]byte("cursor")) 165 - if v != nil && len(v) == 8 { 188 + if len(v) == 8 { 166 189 cursor = int64(binary.BigEndian.Uint64(v)) 167 190 } 168 191 return nil ··· 185 208 uri := atproto.BuildATURI(did, collection, rkey) 186 209 187 210 // Parse createdAt from record 188 - var recordData map[string]interface{} 211 + var recordData map[string]any 189 212 createdAt := time.Now() 190 213 if err := json.Unmarshal(record, &recordData); err == nil { 191 214 if createdAtStr, ok := recordData["createdAt"].(string); ok { ··· 315 338 316 339 // FeedItem represents an item in the feed (matches feed.FeedItem structure) 317 340 type FeedItem struct { 318 - RecordType string 341 + RecordType lexicons.RecordType 319 342 Action string 320 343 321 344 Brew *models.Brew ··· 327 350 Author *atproto.Profile 328 351 Timestamp time.Time 329 352 TimeAgo string 353 + 354 + // Like-related fields 355 + LikeCount int // Number of likes on this record 356 + SubjectURI string // AT-URI of this record (for like button) 357 + SubjectCID string // CID of this record (for like button) 330 358 } 331 359 332 360 // GetRecentFeed returns recent feed items from the index 333 361 func (idx *FeedIndex) GetRecentFeed(ctx context.Context, limit int) ([]*FeedItem, error) { 334 362 var records []*IndexedRecord 335 - 336 - // FIX: this seems to show the first 20 records for main deployment 337 - // - unclear why, but is likely an issue with the db being stale 338 363 err := idx.db.View(func(tx *bolt.Tx) error { 339 364 byTime := tx.Bucket(BucketByTime) 340 365 recordsBucket := tx.Bucket(BucketRecords) ··· 408 433 log.Warn().Err(err).Str("uri", record.URI).Msg("failed to convert record to feed item") 409 434 continue 410 435 } 436 + if !FeedableRecordTypes[item.RecordType] { 437 + continue 438 + } 411 439 items = append(items, item) 412 440 } 413 441 ··· 426 454 427 455 // recordToFeedItem converts an IndexedRecord to a FeedItem 428 456 func (idx *FeedIndex) recordToFeedItem(ctx context.Context, record *IndexedRecord, refMap map[string]*IndexedRecord) (*FeedItem, error) { 429 - var recordData map[string]interface{} 457 + var recordData map[string]any 430 458 if err := json.Unmarshal(record.Record, &recordData); err != nil { 431 459 return nil, err 432 460 } ··· 458 486 // Resolve bean reference 459 487 if beanRef, ok := recordData["beanRef"].(string); ok && beanRef != "" { 460 488 if beanRecord, found := refMap[beanRef]; found { 461 - var beanData map[string]interface{} 489 + var beanData map[string]any 462 490 if err := json.Unmarshal(beanRecord.Record, &beanData); err == nil { 463 491 bean, _ := atproto.RecordToBean(beanData, beanRef) 464 492 brew.Bean = bean ··· 466 494 // Resolve roaster reference for bean 467 495 if roasterRef, ok := beanData["roasterRef"].(string); ok && roasterRef != "" { 468 496 if roasterRecord, found := refMap[roasterRef]; found { 469 - var roasterData map[string]interface{} 497 + var roasterData map[string]any 470 498 if err := json.Unmarshal(roasterRecord.Record, &roasterData); err == nil { 471 499 roaster, _ := atproto.RecordToRoaster(roasterData, roasterRef) 472 500 brew.Bean.Roaster = roaster ··· 480 508 // Resolve grinder reference 481 509 if grinderRef, ok := recordData["grinderRef"].(string); ok && grinderRef != "" { 482 510 if grinderRecord, found := refMap[grinderRef]; found { 483 - var grinderData map[string]interface{} 511 + var grinderData map[string]any 484 512 if err := json.Unmarshal(grinderRecord.Record, &grinderData); err == nil { 485 513 grinder, _ := atproto.RecordToGrinder(grinderData, grinderRef) 486 514 brew.GrinderObj = grinder ··· 491 519 // Resolve brewer reference 492 520 if brewerRef, ok := recordData["brewerRef"].(string); ok && brewerRef != "" { 493 521 if brewerRecord, found := refMap[brewerRef]; found { 494 - var brewerData map[string]interface{} 522 + var brewerData map[string]any 495 523 if err := json.Unmarshal(brewerRecord.Record, &brewerData); err == nil { 496 524 brewer, _ := atproto.RecordToBrewer(brewerData, brewerRef) 497 525 brew.BrewerObj = brewer ··· 499 527 } 500 528 } 501 529 502 - item.RecordType = "brew" 530 + item.RecordType = lexicons.RecordTypeBrew 503 531 item.Action = "added a new brew" 504 532 item.Brew = brew 505 533 ··· 512 540 // Resolve roaster reference 513 541 if roasterRef, ok := recordData["roasterRef"].(string); ok && roasterRef != "" { 514 542 if roasterRecord, found := refMap[roasterRef]; found { 515 - var roasterData map[string]interface{} 543 + var roasterData map[string]any 516 544 if err := json.Unmarshal(roasterRecord.Record, &roasterData); err == nil { 517 545 roaster, _ := atproto.RecordToRoaster(roasterData, roasterRef) 518 546 bean.Roaster = roaster ··· 520 548 } 521 549 } 522 550 523 - item.RecordType = "bean" 551 + item.RecordType = lexicons.RecordTypeBean 524 552 item.Action = "added a new bean" 525 553 item.Bean = bean 526 554 ··· 529 557 if err != nil { 530 558 return nil, err 531 559 } 532 - item.RecordType = "roaster" 560 + item.RecordType = lexicons.RecordTypeRoaster 533 561 item.Action = "added a new roaster" 534 562 item.Roaster = roaster 535 563 ··· 538 566 if err != nil { 539 567 return nil, err 540 568 } 541 - item.RecordType = "grinder" 569 + item.RecordType = lexicons.RecordTypeGrinder 542 570 item.Action = "added a new grinder" 543 571 item.Grinder = grinder 544 572 ··· 547 575 if err != nil { 548 576 return nil, err 549 577 } 550 - item.RecordType = "brewer" 578 + item.RecordType = lexicons.RecordTypeBrewer 551 579 item.Action = "added a new brewer" 552 580 item.Brewer = brewer 581 + 582 + case atproto.NSIDLike: 583 + // Skip likes in the feed - they're indexed but not displayed as feed items 584 + return nil, fmt.Errorf("likes are not displayed as feed items") 553 585 554 586 default: 555 587 return nil, fmt.Errorf("unknown collection: %s", record.Collection) 556 588 } 589 + 590 + // Populate like-related fields for all record types 591 + item.SubjectURI = record.URI 592 + item.SubjectCID = record.CID 593 + item.LikeCount = idx.GetLikeCount(record.URI) 557 594 558 595 return item, nil 559 596 } ··· 766 803 log.Info().Str("did", did).Int("record_count", recordCount).Msg("backfill complete") 767 804 return nil 768 805 } 806 + 807 + // ========== Like Indexing Methods ========== 808 + 809 + // UpsertLike adds or updates a like in the index 810 + func (idx *FeedIndex) UpsertLike(actorDID, rkey, subjectURI string) error { 811 + return idx.db.Update(func(tx *bolt.Tx) error { 812 + likes := tx.Bucket(BucketLikes) 813 + likeCounts := tx.Bucket(BucketLikeCounts) 814 + likesByActor := tx.Bucket(BucketLikesByActor) 815 + 816 + // Key format: {subject_uri}:{actor_did} 817 + likeKey := []byte(subjectURI + ":" + actorDID) 818 + 819 + // Check if this like already exists 820 + existingRKey := likes.Get(likeKey) 821 + if existingRKey != nil { 822 + // Already exists, nothing to do 823 + return nil 824 + } 825 + 826 + // Store the like mapping 827 + if err := likes.Put(likeKey, []byte(rkey)); err != nil { 828 + return err 829 + } 830 + 831 + // Store by actor for reverse lookup 832 + actorKey := []byte(actorDID + ":" + subjectURI) 833 + if err := likesByActor.Put(actorKey, []byte(rkey)); err != nil { 834 + return err 835 + } 836 + 837 + // Increment the like count 838 + countKey := []byte(subjectURI) 839 + currentCount := uint64(0) 840 + if countData := likeCounts.Get(countKey); len(countData) == 8 { 841 + currentCount = binary.BigEndian.Uint64(countData) 842 + } 843 + currentCount++ 844 + countBuf := make([]byte, 8) 845 + binary.BigEndian.PutUint64(countBuf, currentCount) 846 + return likeCounts.Put(countKey, countBuf) 847 + }) 848 + } 849 + 850 + // DeleteLike removes a like from the index 851 + func (idx *FeedIndex) DeleteLike(actorDID, subjectURI string) error { 852 + return idx.db.Update(func(tx *bolt.Tx) error { 853 + likes := tx.Bucket(BucketLikes) 854 + likeCounts := tx.Bucket(BucketLikeCounts) 855 + likesByActor := tx.Bucket(BucketLikesByActor) 856 + 857 + // Key format: {subject_uri}:{actor_did} 858 + likeKey := []byte(subjectURI + ":" + actorDID) 859 + 860 + // Check if like exists 861 + if likes.Get(likeKey) == nil { 862 + // Doesn't exist, nothing to do 863 + return nil 864 + } 865 + 866 + // Delete the like mapping 867 + if err := likes.Delete(likeKey); err != nil { 868 + return err 869 + } 870 + 871 + // Delete by actor lookup 872 + actorKey := []byte(actorDID + ":" + subjectURI) 873 + if err := likesByActor.Delete(actorKey); err != nil { 874 + return err 875 + } 876 + 877 + // Decrement the like count 878 + countKey := []byte(subjectURI) 879 + currentCount := uint64(0) 880 + if countData := likeCounts.Get(countKey); len(countData) == 8 { 881 + currentCount = binary.BigEndian.Uint64(countData) 882 + } 883 + if currentCount > 0 { 884 + currentCount-- 885 + } 886 + if currentCount == 0 { 887 + return likeCounts.Delete(countKey) 888 + } 889 + countBuf := make([]byte, 8) 890 + binary.BigEndian.PutUint64(countBuf, currentCount) 891 + return likeCounts.Put(countKey, countBuf) 892 + }) 893 + } 894 + 895 + // GetLikeCount returns the number of likes for a record 896 + func (idx *FeedIndex) GetLikeCount(subjectURI string) int { 897 + var count uint64 898 + _ = idx.db.View(func(tx *bolt.Tx) error { 899 + likeCounts := tx.Bucket(BucketLikeCounts) 900 + countData := likeCounts.Get([]byte(subjectURI)) 901 + if len(countData) == 8 { 902 + count = binary.BigEndian.Uint64(countData) 903 + } 904 + return nil 905 + }) 906 + return int(count) 907 + } 908 + 909 + // HasUserLiked checks if a user has liked a specific record 910 + func (idx *FeedIndex) HasUserLiked(actorDID, subjectURI string) bool { 911 + var exists bool 912 + _ = idx.db.View(func(tx *bolt.Tx) error { 913 + likesByActor := tx.Bucket(BucketLikesByActor) 914 + actorKey := []byte(actorDID + ":" + subjectURI) 915 + exists = likesByActor.Get(actorKey) != nil 916 + return nil 917 + }) 918 + return exists 919 + } 920 + 921 + // GetUserLikeRKey returns the rkey of a user's like for a specific record, or empty string if not found 922 + func (idx *FeedIndex) GetUserLikeRKey(actorDID, subjectURI string) string { 923 + var rkey string 924 + _ = idx.db.View(func(tx *bolt.Tx) error { 925 + likesByActor := tx.Bucket(BucketLikesByActor) 926 + actorKey := []byte(actorDID + ":" + subjectURI) 927 + if data := likesByActor.Get(actorKey); data != nil { 928 + rkey = string(data) 929 + } 930 + return nil 931 + }) 932 + return rkey 933 + }
+143 -4
internal/handlers/handlers.go
··· 3 3 import ( 4 4 "context" 5 5 "encoding/json" 6 + "fmt" 6 7 "net/http" 7 8 "sort" 8 9 "strconv" ··· 11 12 "arabica/internal/atproto" 12 13 "arabica/internal/database" 13 14 "arabica/internal/feed" 15 + "arabica/internal/firehose" 14 16 "arabica/internal/middleware" 15 17 "arabica/internal/models" 16 18 "arabica/internal/web/bff" ··· 37 39 config Config 38 40 feedService *feed.Service 39 41 feedRegistry *feed.Registry 42 + feedIndex *firehose.FeedIndex 40 43 } 41 44 42 45 // NewHandler creates a new Handler with all required dependencies. ··· 57 60 feedService: feedService, 58 61 feedRegistry: feedRegistry, 59 62 } 63 + } 64 + 65 + // SetFeedIndex configures the handler to use the firehose feed index for like lookups 66 + func (h *Handler) SetFeedIndex(idx *firehose.FeedIndex) { 67 + h.feedIndex = idx 60 68 } 61 69 62 70 // validateRKey validates and returns an rkey from a path parameter. ··· 402 410 var feedItems []*feed.FeedItem 403 411 404 412 // Check if user is authenticated 405 - _, err := atproto.GetAuthenticatedDID(r.Context()) 413 + viewerDID, err := atproto.GetAuthenticatedDID(r.Context()) 406 414 isAuthenticated := err == nil 407 415 408 416 if h.feedService != nil { ··· 411 419 } else { 412 420 // Unauthenticated users get a limited feed from the cache 413 421 feedItems, _ = h.feedService.GetCachedPublicFeed(r.Context()) 422 + } 423 + } 424 + 425 + // Populate IsLikedByViewer for each feed item if user is authenticated 426 + if isAuthenticated && h.feedIndex != nil { 427 + for _, item := range feedItems { 428 + if item.SubjectURI != "" { 429 + item.IsLikedByViewer = h.feedIndex.HasUserLiked(viewerDID, item.SubjectURI) 430 + } 414 431 } 415 432 } 416 433 ··· 580 597 var brew *models.Brew 581 598 var brewOwnerDID string 582 599 var isOwner bool 600 + var subjectURI, subjectCID string 583 601 584 602 if owner != "" { 585 603 // Viewing someone else's brew - use public client ··· 605 623 http.Error(w, "Brew not found", http.StatusNotFound) 606 624 return 607 625 } 626 + 627 + // Store URI and CID for like button 628 + subjectURI = record.URI 629 + subjectCID = record.CID 608 630 609 631 // Convert record to brew 610 632 brew, err = atproto.RecordToBrew(record.Value, record.URI) ··· 630 652 return 631 653 } 632 654 633 - brew, err = store.GetBrewByRKey(r.Context(), rkey) 655 + // Use type assertion to access GetBrewRecordByRKey 656 + atprotoStore, ok := store.(*atproto.AtprotoStore) 657 + if !ok { 658 + http.Error(w, "Internal error", http.StatusInternalServerError) 659 + log.Error().Msg("Failed to cast store to AtprotoStore") 660 + return 661 + } 662 + 663 + brewRecord, err := atprotoStore.GetBrewRecordByRKey(r.Context(), rkey) 634 664 if err != nil { 635 665 http.Error(w, "Brew not found", http.StatusNotFound) 636 666 log.Error().Err(err).Str("rkey", rkey).Msg("Failed to get brew for view") 637 667 return 638 668 } 639 669 670 + brew = brewRecord.Brew 671 + subjectURI = brewRecord.URI 672 + subjectCID = brewRecord.CID 640 673 isOwner = true 641 674 } 642 675 643 676 // Create layout data 644 677 layoutData := h.buildLayoutData(r, "Brew Details", isAuthenticated, didStr, userProfile) 645 678 679 + // Get like data 680 + var isLiked bool 681 + var likeCount int 682 + if h.feedIndex != nil && subjectURI != "" { 683 + likeCount = h.feedIndex.GetLikeCount(subjectURI) 684 + if isAuthenticated { 685 + isLiked = h.feedIndex.HasUserLiked(didStr, subjectURI) 686 + } 687 + } 688 + 689 + // Construct share URL 690 + var shareURL string 691 + if owner != "" { 692 + shareURL = fmt.Sprintf("/brews/%s?owner=%s", rkey, owner) 693 + } else if userProfile != nil && userProfile.Handle != "" { 694 + shareURL = fmt.Sprintf("/brews/%s?owner=%s", rkey, userProfile.Handle) 695 + } 696 + 646 697 // Create brew view props 647 698 brewViewProps := pages.BrewViewProps{ 648 - Brew: brew, 649 - IsOwnProfile: isOwner, 699 + Brew: brew, 700 + IsOwnProfile: isOwner, 701 + IsAuthenticated: isAuthenticated, 702 + SubjectURI: subjectURI, 703 + SubjectCID: subjectCID, 704 + IsLiked: isLiked, 705 + LikeCount: likeCount, 706 + ShareURL: shareURL, 650 707 } 651 708 652 709 // Render using templ component ··· 1037 1094 encoder.SetIndent("", " ") 1038 1095 if err := encoder.Encode(brews); err != nil { 1039 1096 log.Error().Err(err).Msg("Failed to encode brews for export") 1097 + } 1098 + } 1099 + 1100 + // HandleLikeToggle handles creating or deleting a like on a record 1101 + func (h *Handler) HandleLikeToggle(w http.ResponseWriter, r *http.Request) { 1102 + // Require authentication 1103 + store, authenticated := h.getAtprotoStore(r) 1104 + if !authenticated { 1105 + http.Error(w, "Authentication required", http.StatusUnauthorized) 1106 + return 1107 + } 1108 + 1109 + didStr, _ := atproto.GetAuthenticatedDID(r.Context()) 1110 + 1111 + if err := r.ParseForm(); err != nil { 1112 + http.Error(w, "Invalid form data", http.StatusBadRequest) 1113 + return 1114 + } 1115 + 1116 + subjectURI := r.FormValue("subject_uri") 1117 + subjectCID := r.FormValue("subject_cid") 1118 + 1119 + if subjectURI == "" || subjectCID == "" { 1120 + http.Error(w, "subject_uri and subject_cid are required", http.StatusBadRequest) 1121 + return 1122 + } 1123 + 1124 + // Check if user already liked this record 1125 + existingLike, err := store.GetUserLikeForSubject(r.Context(), subjectURI) 1126 + if err != nil { 1127 + http.Error(w, "Failed to check like status", http.StatusInternalServerError) 1128 + log.Error().Err(err).Msg("Failed to check existing like") 1129 + return 1130 + } 1131 + 1132 + var isLiked bool 1133 + var likeCount int 1134 + 1135 + if existingLike != nil { 1136 + // Unlike: delete the existing like 1137 + if err := store.DeleteLikeByRKey(r.Context(), existingLike.RKey); err != nil { 1138 + http.Error(w, "Failed to unlike", http.StatusInternalServerError) 1139 + log.Error().Err(err).Msg("Failed to delete like") 1140 + return 1141 + } 1142 + isLiked = false 1143 + 1144 + // Update firehose index 1145 + if h.feedIndex != nil { 1146 + _ = h.feedIndex.DeleteLike(didStr, subjectURI) 1147 + likeCount = h.feedIndex.GetLikeCount(subjectURI) 1148 + } 1149 + } else { 1150 + // Like: create a new like 1151 + req := &models.CreateLikeRequest{ 1152 + SubjectURI: subjectURI, 1153 + SubjectCID: subjectCID, 1154 + } 1155 + like, err := store.CreateLike(r.Context(), req) 1156 + if err != nil { 1157 + http.Error(w, "Failed to like", http.StatusInternalServerError) 1158 + log.Error().Err(err).Msg("Failed to create like") 1159 + return 1160 + } 1161 + isLiked = true 1162 + 1163 + // Update firehose index 1164 + if h.feedIndex != nil { 1165 + _ = h.feedIndex.UpsertLike(didStr, like.RKey, subjectURI) 1166 + likeCount = h.feedIndex.GetLikeCount(subjectURI) 1167 + } 1168 + } 1169 + 1170 + // Return the updated like button component 1171 + if err := components.LikeButton(components.LikeButtonProps{ 1172 + SubjectURI: subjectURI, 1173 + SubjectCID: subjectCID, 1174 + IsLiked: isLiked, 1175 + LikeCount: likeCount, 1176 + }).Render(r.Context(), w); err != nil { 1177 + http.Error(w, "Failed to render", http.StatusInternalServerError) 1178 + log.Error().Err(err).Msg("Failed to render like button") 1040 1179 } 1041 1180 } 1042 1181
+40
internal/lexicons/record_type.go
··· 1 + // Package lexicons defines types for Arabica's AT Protocol lexicon schemas. 2 + package lexicons 3 + 4 + // RecordType represents the type of a record in the feed. 5 + // Use these constants instead of magic strings for type safety. 6 + type RecordType string 7 + 8 + const ( 9 + RecordTypeBean RecordType = "bean" 10 + RecordTypeBrew RecordType = "brew" 11 + RecordTypeBrewer RecordType = "brewer" 12 + RecordTypeGrinder RecordType = "grinder" 13 + RecordTypeLike RecordType = "like" 14 + RecordTypeRoaster RecordType = "roaster" 15 + ) 16 + 17 + // String returns the string representation of the RecordType. 18 + func (r RecordType) String() string { 19 + return string(r) 20 + } 21 + 22 + // DisplayName returns a human-readable name for the RecordType. 23 + func (r RecordType) DisplayName() string { 24 + switch r { 25 + case RecordTypeBean: 26 + return "Bean" 27 + case RecordTypeBrew: 28 + return "Brew" 29 + case RecordTypeBrewer: 30 + return "Brewer" 31 + case RecordTypeGrinder: 32 + return "Grinder" 33 + case RecordTypeLike: 34 + return "Like" 35 + case RecordTypeRoaster: 36 + return "Roaster" 37 + default: 38 + return string(r) 39 + } 40 + }
+48
internal/lexicons/record_type_test.go
··· 1 + package lexicons 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/stretchr/testify/assert" 7 + ) 8 + 9 + func TestRecordTypeString(t *testing.T) { 10 + tests := []struct { 11 + rt RecordType 12 + expected string 13 + }{ 14 + {RecordTypeBean, "bean"}, 15 + {RecordTypeBrew, "brew"}, 16 + {RecordTypeBrewer, "brewer"}, 17 + {RecordTypeGrinder, "grinder"}, 18 + {RecordTypeLike, "like"}, 19 + {RecordTypeRoaster, "roaster"}, 20 + } 21 + 22 + for _, tt := range tests { 23 + t.Run(tt.expected, func(t *testing.T) { 24 + assert.Equal(t, tt.expected, tt.rt.String()) 25 + }) 26 + } 27 + } 28 + 29 + func TestRecordTypeDisplayName(t *testing.T) { 30 + tests := []struct { 31 + rt RecordType 32 + expected string 33 + }{ 34 + {RecordTypeBean, "Bean"}, 35 + {RecordTypeBrew, "Brew"}, 36 + {RecordTypeBrewer, "Brewer"}, 37 + {RecordTypeGrinder, "Grinder"}, 38 + {RecordTypeLike, "Like"}, 39 + {RecordTypeRoaster, "Roaster"}, 40 + {RecordType("unknown"), "unknown"}, // Fallback to string value 41 + } 42 + 43 + for _, tt := range tests { 44 + t.Run(string(tt.rt), func(t *testing.T) { 45 + assert.Equal(t, tt.expected, tt.rt.DisplayName()) 46 + }) 47 + } 48 + }
+15
internal/models/models.go
··· 182 182 Description string `json:"description"` 183 183 } 184 184 185 + // Like represents a like on an Arabica record 186 + type Like struct { 187 + RKey string `json:"rkey"` 188 + SubjectURI string `json:"subject_uri"` 189 + SubjectCID string `json:"subject_cid"` 190 + CreatedAt time.Time `json:"created_at"` 191 + ActorDID string `json:"actor_did,omitempty"` 192 + } 193 + 194 + // CreateLikeRequest contains the data needed to create a like 195 + type CreateLikeRequest struct { 196 + SubjectURI string `json:"subject_uri"` 197 + SubjectCID string `json:"subject_cid"` 198 + } 199 + 185 200 // Validate checks that all fields are within acceptable limits 186 201 func (r *CreateBeanRequest) Validate() error { 187 202 if r.Name == "" {
+3
internal/routing/routing.go
··· 81 81 mux.Handle("PUT /api/brewers/{id}", cop.Handler(http.HandlerFunc(h.HandleBrewerUpdate))) 82 82 mux.Handle("DELETE /api/brewers/{id}", cop.Handler(http.HandlerFunc(h.HandleBrewerDelete))) 83 83 84 + mux.Handle("POST /api/likes/toggle", cop.Handler(http.HandlerFunc(h.HandleLikeToggle))) 85 + 84 86 // Modal routes for entity management (return dialog HTML) 85 87 mux.HandleFunc("GET /api/modals/bean/new", h.HandleBeanModalNew) 86 88 mux.HandleFunc("GET /api/modals/bean/{id}", h.HandleBeanModalEdit) ··· 99 101 mux.Handle("GET /static/", http.StripPrefix("/static/", fs)) 100 102 // Serve favicon.ico for pdsls 101 103 mux.HandleFunc("GET /favicon.ico", func(w http.ResponseWriter, r *http.Request) { 104 + w.Header().Set("Content-Type", "image/x-icon") 102 105 http.ServeFile(w, r, "static/favicon.ico") 103 106 }) 104 107
+23 -66
internal/web/bff/helpers_test.go
··· 4 4 "testing" 5 5 6 6 "arabica/internal/models" 7 + "github.com/stretchr/testify/assert" 7 8 ) 8 9 9 10 func TestFormatTemp(t *testing.T) { ··· 25 26 for _, tt := range tests { 26 27 t.Run(tt.name, func(t *testing.T) { 27 28 got := FormatTemp(tt.temp) 28 - if got != tt.expected { 29 - t.Errorf("FormatTemp(%v) = %q, want %q", tt.temp, got, tt.expected) 30 - } 29 + assert.Equal(t, tt.expected, got) 31 30 }) 32 31 } 33 32 } ··· 47 46 for _, tt := range tests { 48 47 t.Run(tt.name, func(t *testing.T) { 49 48 got := FormatTempValue(tt.temp) 50 - if got != tt.expected { 51 - t.Errorf("FormatTempValue(%v) = %q, want %q", tt.temp, got, tt.expected) 52 - } 49 + assert.Equal(t, tt.expected, got) 53 50 }) 54 51 } 55 52 } ··· 72 69 for _, tt := range tests { 73 70 t.Run(tt.name, func(t *testing.T) { 74 71 got := FormatTime(tt.seconds) 75 - if got != tt.expected { 76 - t.Errorf("FormatTime(%v) = %q, want %q", tt.seconds, got, tt.expected) 77 - } 72 + assert.Equal(t, tt.expected, got) 78 73 }) 79 74 } 80 75 } ··· 94 89 for _, tt := range tests { 95 90 t.Run(tt.name, func(t *testing.T) { 96 91 got := FormatRating(tt.rating) 97 - if got != tt.expected { 98 - t.Errorf("FormatRating(%v) = %q, want %q", tt.rating, got, tt.expected) 99 - } 92 + assert.Equal(t, tt.expected, got) 100 93 }) 101 94 } 102 95 } ··· 115 108 for _, tt := range tests { 116 109 t.Run(tt.name, func(t *testing.T) { 117 110 got := FormatID(tt.id) 118 - if got != tt.expected { 119 - t.Errorf("FormatID(%v) = %q, want %q", tt.id, got, tt.expected) 120 - } 111 + assert.Equal(t, tt.expected, got) 121 112 }) 122 113 } 123 114 } ··· 136 127 for _, tt := range tests { 137 128 t.Run(tt.name, func(t *testing.T) { 138 129 got := FormatInt(tt.val) 139 - if got != tt.expected { 140 - t.Errorf("FormatInt(%v) = %q, want %q", tt.val, got, tt.expected) 141 - } 130 + assert.Equal(t, tt.expected, got) 142 131 }) 143 132 } 144 133 } ··· 146 135 func TestFormatRoasterID(t *testing.T) { 147 136 t.Run("nil returns null", func(t *testing.T) { 148 137 got := FormatRoasterID(nil) 149 - if got != "null" { 150 - t.Errorf("FormatRoasterID(nil) = %q, want %q", got, "null") 151 - } 138 + assert.Equal(t, "null", got) 152 139 }) 153 140 154 141 t.Run("valid pointer", func(t *testing.T) { 155 142 id := 123 156 143 got := FormatRoasterID(&id) 157 - if got != "123" { 158 - t.Errorf("FormatRoasterID(&123) = %q, want %q", got, "123") 159 - } 144 + assert.Equal(t, "123", got) 160 145 }) 161 146 162 147 t.Run("zero pointer", func(t *testing.T) { 163 148 id := 0 164 149 got := FormatRoasterID(&id) 165 - if got != "0" { 166 - t.Errorf("FormatRoasterID(&0) = %q, want %q", got, "0") 167 - } 150 + assert.Equal(t, "0", got) 168 151 }) 169 152 } 170 153 ··· 212 195 for _, tt := range tests { 213 196 t.Run(tt.name, func(t *testing.T) { 214 197 got := PoursToJSON(tt.pours) 215 - if got != tt.expected { 216 - t.Errorf("PoursToJSON() = %q, want %q", got, tt.expected) 217 - } 198 + assert.Equal(t, tt.expected, got) 218 199 }) 219 200 } 220 201 } ··· 222 203 func TestPtr(t *testing.T) { 223 204 t.Run("int", func(t *testing.T) { 224 205 p := Ptr(42) 225 - if *p != 42 { 226 - t.Errorf("Ptr(42) = %v, want 42", *p) 227 - } 206 + assert.Equal(t, 42, *p) 228 207 }) 229 208 230 209 t.Run("string", func(t *testing.T) { 231 210 p := Ptr("hello") 232 - if *p != "hello" { 233 - t.Errorf("Ptr(\"hello\") = %v, want \"hello\"", *p) 234 - } 211 + assert.Equal(t, "hello", *p) 235 212 }) 236 213 237 214 t.Run("zero value", func(t *testing.T) { 238 215 p := Ptr(0) 239 - if *p != 0 { 240 - t.Errorf("Ptr(0) = %v, want 0", *p) 241 - } 216 + assert.Equal(t, 0, *p) 242 217 }) 243 218 } 244 219 245 220 func TestPtrEquals(t *testing.T) { 246 221 t.Run("nil pointer returns false", func(t *testing.T) { 247 222 var p *int = nil 248 - if PtrEquals(p, 42) { 249 - t.Error("PtrEquals(nil, 42) should be false") 250 - } 223 + assert.False(t, PtrEquals(p, 42)) 251 224 }) 252 225 253 226 t.Run("matching value returns true", func(t *testing.T) { 254 227 val := 42 255 - if !PtrEquals(&val, 42) { 256 - t.Error("PtrEquals(&42, 42) should be true") 257 - } 228 + assert.True(t, PtrEquals(&val, 42)) 258 229 }) 259 230 260 231 t.Run("non-matching value returns false", func(t *testing.T) { 261 232 val := 42 262 - if PtrEquals(&val, 99) { 263 - t.Error("PtrEquals(&42, 99) should be false") 264 - } 233 + assert.False(t, PtrEquals(&val, 99)) 265 234 }) 266 235 267 236 t.Run("string comparison", func(t *testing.T) { 268 237 s := "hello" 269 - if !PtrEquals(&s, "hello") { 270 - t.Error("PtrEquals(&\"hello\", \"hello\") should be true") 271 - } 272 - if PtrEquals(&s, "world") { 273 - t.Error("PtrEquals(&\"hello\", \"world\") should be false") 274 - } 238 + assert.True(t, PtrEquals(&s, "hello")) 239 + assert.False(t, PtrEquals(&s, "world")) 275 240 }) 276 241 } 277 242 278 243 func TestPtrValue(t *testing.T) { 279 244 t.Run("nil int returns zero", func(t *testing.T) { 280 245 var p *int = nil 281 - if PtrValue(p) != 0 { 282 - t.Errorf("PtrValue(nil) = %v, want 0", PtrValue(p)) 283 - } 246 + assert.Equal(t, 0, PtrValue(p)) 284 247 }) 285 248 286 249 t.Run("valid int returns value", func(t *testing.T) { 287 250 val := 42 288 - if PtrValue(&val) != 42 { 289 - t.Errorf("PtrValue(&42) = %v, want 42", PtrValue(&val)) 290 - } 251 + assert.Equal(t, 42, PtrValue(&val)) 291 252 }) 292 253 293 254 t.Run("nil string returns empty", func(t *testing.T) { 294 255 var p *string = nil 295 - if PtrValue(p) != "" { 296 - t.Errorf("PtrValue(nil string) = %v, want \"\"", PtrValue(p)) 297 - } 256 + assert.Equal(t, "", PtrValue(p)) 298 257 }) 299 258 300 259 t.Run("valid string returns value", func(t *testing.T) { 301 260 s := "hello" 302 - if PtrValue(&s) != "hello" { 303 - t.Errorf("PtrValue(&\"hello\") = %v, want \"hello\"", PtrValue(&s)) 304 - } 261 + assert.Equal(t, "hello", PtrValue(&s)) 305 262 }) 306 263 }
+66 -1
internal/web/components/buttons.templ
··· 1 1 package components 2 2 3 + import "fmt" 4 + 3 5 // ButtonProps defines common button properties 4 6 type ButtonProps struct { 5 7 Text string ··· 52 54 class="inline-flex items-center text-brown-700 hover:text-brown-900 font-medium transition-colors cursor-pointer" 53 55 aria-label="Go back" 54 56 > 55 - <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> 57 + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> 56 58 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path> 57 59 </svg> 58 60 </button> 59 61 </div> 60 62 } 63 + 64 + // ShareButtonProps defines properties for the share button 65 + type ShareButtonProps struct { 66 + URL string // URL to share 67 + Title string // Title for native share dialog 68 + Text string // Text for native share dialog 69 + } 70 + 71 + // ShareButton renders a share button using Web Share API with clipboard fallback 72 + templ ShareButton(props ShareButtonProps) { 73 + <button 74 + type="button" 75 + x-data={ "{ copied: false, share() { const fullUrl = window.location.origin + '" + props.URL + "'; if (navigator.share) { navigator.share({ title: '" + props.Title + "', text: '" + props.Text + "', url: fullUrl }).catch(() => {}); } else { navigator.clipboard.writeText(fullUrl).then(() => { this.copied = true; setTimeout(() => this.copied = false, 2000); }); } } }" } 76 + @click="share()" 77 + class="share-btn" 78 + aria-label="Share" 79 + > 80 + <svg x-show="!copied" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> 81 + <path stroke-linecap="round" stroke-linejoin="round" d="M7.217 10.907a2.25 2.25 0 1 0 0 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186 9.566-5.314m-9.566 7.5 9.566 5.314m0 0a2.25 2.25 0 1 0 3.935 2.186 2.25 2.25 0 0 0-3.935-2.186Zm0-12.814a2.25 2.25 0 1 0 3.933-2.185 2.25 2.25 0 0 0-3.933 2.185Z"></path> 82 + </svg> 83 + <svg x-show="copied" x-cloak class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> 84 + <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5"></path> 85 + </svg> 86 + <span x-show="copied" x-cloak>Copied!</span> 87 + </button> 88 + } 89 + 90 + // LikeButtonProps defines properties for the like button 91 + type LikeButtonProps struct { 92 + SubjectURI string // AT-URI of the record being liked 93 + SubjectCID string // CID of the record being liked 94 + IsLiked bool // Whether the current user has liked this record 95 + LikeCount int // Number of likes on this record 96 + IsAuthenticated bool // Whether the user is authenticated 97 + } 98 + 99 + // LikeButton renders a like button with count, using HTMX for toggle behavior 100 + // TODO: When unauthenticated user clicks, show a login prompt instead of doing nothing 101 + templ LikeButton(props LikeButtonProps) { 102 + <button 103 + type="button" 104 + if props.IsAuthenticated { 105 + hx-post="/api/likes/toggle" 106 + hx-vals={ fmt.Sprintf(`{"subject_uri": "%s", "subject_cid": "%s"}`, props.SubjectURI, props.SubjectCID) } 107 + hx-swap="outerHTML" 108 + hx-trigger="click" 109 + } 110 + class={ templ.KV("like-btn-liked", props.IsLiked), templ.KV("like-btn-unliked", !props.IsLiked) } 111 + > 112 + if props.IsLiked { 113 + <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> 114 + <path d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001a.752.752 0 01-.704 0l-.003-.001z"></path> 115 + </svg> 116 + } else { 117 + <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> 118 + <path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z"></path> 119 + </svg> 120 + } 121 + if props.LikeCount > 0 { 122 + <span>{ fmt.Sprintf("%d", props.LikeCount) }</span> 123 + } 124 + </button> 125 + }
-1
internal/web/components/shared.templ
··· 117 117 118 118 templ WelcomeAuthenticated(userDID string) { 119 119 <div class="mb-6"> 120 - // TODO: maybe add a parenthetical here with "what is this?" linking to the atproto page 121 120 <p class="text-sm text-brown-700"> 122 121 Logged in as: <span class="font-mono text-brown-900 font-semibold">{ userDID }</span> 123 122 <a href="/atproto" class="text-brown-700 hover:text-brown-900 transition-colors">(What is this?)</a>
+41
internal/web/components/social_buttons.templ
··· 1 + package components 2 + 3 + // SocialButtonsProps defines properties for the social buttons cluster 4 + type SocialButtonsProps struct { 5 + // Like button props 6 + SubjectURI string // AT-URI of the record being liked 7 + SubjectCID string // CID of the record being liked 8 + IsLiked bool // Whether the current user has liked this record 9 + LikeCount int // Number of likes on this record 10 + 11 + // Share button props 12 + ShareURL string // URL to share 13 + ShareTitle string // Title for native share dialog 14 + ShareText string // Text for native share dialog 15 + 16 + // Display options 17 + ShowLike bool // Whether to show the like button (requires authentication) 18 + IsAuthenticated bool // Whether the user is authenticated (controls click behavior) 19 + } 20 + 21 + // SocialButtons renders a cluster of social interaction buttons (like, share) 22 + templ SocialButtons(props SocialButtonsProps) { 23 + <div class="flex items-center gap-2"> 24 + if props.ShowLike && props.SubjectURI != "" && props.SubjectCID != "" { 25 + @LikeButton(LikeButtonProps{ 26 + SubjectURI: props.SubjectURI, 27 + SubjectCID: props.SubjectCID, 28 + IsLiked: props.IsLiked, 29 + LikeCount: props.LikeCount, 30 + IsAuthenticated: props.IsAuthenticated, 31 + }) 32 + } 33 + if props.ShareURL != "" { 34 + @ShareButton(ShareButtonProps{ 35 + URL: props.ShareURL, 36 + Title: props.ShareTitle, 37 + Text: props.ShareText, 38 + }) 39 + } 40 + </div> 41 + }
+7 -17
internal/web/pages/about_test.go
··· 5 5 "arabica/internal/web/components" 6 6 "bytes" 7 7 "context" 8 - "strings" 9 8 "testing" 9 + 10 + "github.com/stretchr/testify/assert" 10 11 ) 11 12 12 13 func TestAboutComponent(t *testing.T) { ··· 21 22 // Render the component 22 23 var buf bytes.Buffer 23 24 err := About(data).Render(context.Background(), &buf) 24 - if err != nil { 25 - t.Fatalf("Failed to render About component: %v", err) 26 - } 25 + assert.NoError(t, err) 27 26 28 27 html := buf.String() 29 28 ··· 42 41 43 42 for _, tt := range tests { 44 43 t.Run(tt.name, func(t *testing.T) { 45 - if !strings.Contains(html, tt.content) { 46 - t.Errorf("Expected HTML to contain %q, but it was not found", tt.content) 47 - } 44 + assert.Contains(t, html, tt.content) 48 45 }) 49 46 } 50 47 } ··· 65 62 // Render the component 66 63 var buf bytes.Buffer 67 64 err := About(data).Render(context.Background(), &buf) 68 - if err != nil { 69 - t.Fatalf("Failed to render About component: %v", err) 70 - } 65 + assert.NoError(t, err) 71 66 72 67 html := buf.String() 73 68 74 69 // When authenticated, should show "Log Your Next Brew" instead of "Get Started" 75 - if !strings.Contains(html, "Log Your Next Brew") { 76 - t.Error("Expected authenticated view to show 'Log Your Next Brew' button") 77 - } 78 - 79 - if strings.Contains(html, "Get Started") { 80 - t.Error("Expected authenticated view NOT to show 'Get Started' button") 81 - } 70 + assert.Contains(t, html, "Back to Home") 71 + assert.NotContains(t, html, "Get Started") 82 72 }
+2 -2
internal/web/pages/brew_form.templ
··· 182 182 hx-get="/api/modals/grinder/new" 183 183 hx-target="#modal-container" 184 184 hx-swap="innerHTML" 185 - class="btn-secondary" 185 + class="btn-secondary" 186 186 > 187 187 + New 188 188 </button> ··· 240 240 hx-get="/api/modals/brewer/new" 241 241 hx-target="#modal-container" 242 242 hx-swap="innerHTML" 243 - class="btn-secondary" 243 + class="btn-secondary" 244 244 > 245 245 + New 246 246 </button>
+48 -6
internal/web/pages/brew_view.templ
··· 9 9 10 10 // BrewViewProps defines the data for the brew view page 11 11 type BrewViewProps struct { 12 - Brew *models.Brew 13 - IsOwnProfile bool 12 + Brew *models.Brew 13 + IsOwnProfile bool 14 + IsAuthenticated bool 15 + SubjectURI string // AT-URI of the brew (for like button) 16 + SubjectCID string // CID of the brew (for like button) 17 + IsLiked bool // Whether the current user has liked this brew 18 + LikeCount int // Number of likes on this brew 19 + ShareURL string // URL for sharing the brew 14 20 } 15 21 16 22 // BrewView renders the full brew view page ··· 40 46 if props.Brew.TastingNotes != "" { 41 47 @BrewTastingNotes(props.Brew.TastingNotes) 42 48 } 43 - @components.BackButton() 49 + <div class="flex justify-between items-center"> 50 + @components.BackButton() 51 + <div class="bg-brown-50 rounded-lg px-3 py-2 border border-brown-200"> 52 + @components.SocialButtons(components.SocialButtonsProps{ 53 + SubjectURI: props.SubjectURI, 54 + SubjectCID: props.SubjectCID, 55 + IsLiked: props.IsLiked, 56 + LikeCount: props.LikeCount, 57 + ShareURL: props.ShareURL, 58 + ShareTitle: getBrewShareTitle(props.Brew), 59 + ShareText: "Check out this brew on Arabica", 60 + ShowLike: props.IsAuthenticated, 61 + IsAuthenticated: props.IsAuthenticated, 62 + }) 63 + </div> 64 + </div> 44 65 </div> 45 66 } 46 67 ··· 116 137 // BrewParametersGrid renders the brew parameters in a grid 117 138 templ BrewParametersGrid(brew *models.Brew) { 118 139 <div class="grid grid-cols-2 gap-4"> 140 + @BrewParameter("Coffee", getCoffeeAmountDisplay(brew)) 119 141 @BrewParameter("Brew Method", getBrewerName(brew)) 120 142 @BrewParameter("Grinder", getGrinderName(brew)) 121 - @BrewParameter("Coffee", getCoffeeAmountDisplay(brew)) 122 - @BrewParameter("Water", getWaterAmountDisplay(brew)) 123 143 @BrewParameter("Grind Size", getGrindSizeDisplay(brew)) 144 + @BrewParameter("Water", getWaterAmountDisplay(brew)) 124 145 @BrewParameter("Temperature", getTemperatureDisplay(brew)) 125 146 <div class="col-span-2"> 126 147 @BrewParameter("Brew Time", getBrewTimeDisplay(brew)) ··· 168 189 func getWaterAmountDisplay(brew *models.Brew) string { 169 190 if brew.WaterAmount > 0 { 170 191 return fmt.Sprintf("%dg", brew.WaterAmount) 192 + } 193 + // If water amount not set, sum from pours 194 + if len(brew.Pours) > 0 { 195 + totalWater := 0 196 + for _, pour := range brew.Pours { 197 + totalWater += pour.WaterAmount 198 + } 199 + if totalWater > 0 { 200 + return fmt.Sprintf("%dg", totalWater) 201 + } 171 202 } 172 203 return "" 173 204 } ··· 190 221 return "" 191 222 } 192 223 224 + func getBrewShareTitle(brew *models.Brew) string { 225 + if brew.Bean != nil { 226 + if brew.Bean.Name != "" { 227 + return brew.Bean.Name 228 + } 229 + return brew.Bean.Origin 230 + } 231 + return "Coffee Brew" 232 + } 233 + 193 234 // BrewPoursSection renders the pours section 194 235 templ BrewPoursSection(pours []*models.Pour) { 195 236 <div class="bg-brown-50 rounded-lg p-4 border border-brown-200"> ··· 199 240 <div class="flex justify-between items-center bg-white p-3 rounded-lg border border-brown-200"> 200 241 <div class="flex gap-4 text-sm"> 201 242 <span class="font-semibold text-brown-800">{ fmt.Sprintf("%dg", pour.WaterAmount) }</span> 202 - <span class="text-brown-600">{ "@ " + bff.FormatTime(pour.TimeSeconds) }</span> 243 + // TODO: add a setting to allow users to configure "at" vs "for" in pours display here 244 + <span class="text-brown-600">{ "for " + bff.FormatTime(pour.TimeSeconds) }</span> 203 245 </div> 204 246 </div> 205 247 }
+27 -79
internal/web/pages/components_test.go
··· 7 7 "testing" 8 8 9 9 "github.com/a-h/templ" 10 + "github.com/stretchr/testify/assert" 10 11 ) 11 12 12 13 // TestButtonComponents tests button component rendering ··· 21 22 22 23 html := renderToString(t, ctx, components.PrimaryButton(props)) 23 24 24 - if !strings.Contains(html, "btn-primary") { 25 - t.Error("Expected btn-primary class") 26 - } 27 - if !strings.Contains(html, "Click Me") { 28 - t.Error("Expected button text 'Click Me'") 29 - } 30 - if !strings.Contains(html, `type="submit"`) { 31 - t.Error("Expected type=submit") 32 - } 25 + assert.Contains(t, html, "btn-primary") 26 + assert.Contains(t, html, "Click Me") 27 + assert.Contains(t, html, `type="submit"`) 33 28 }) 34 29 35 30 t.Run("SecondaryButton", func(t *testing.T) { ··· 39 34 40 35 html := renderToString(t, ctx, components.SecondaryButton(props)) 41 36 42 - if !strings.Contains(html, "btn-secondary") { 43 - t.Error("Expected btn-secondary class") 44 - } 45 - if !strings.Contains(html, "Cancel") { 46 - t.Error("Expected button text 'Cancel'") 47 - } 37 + assert.Contains(t, html, "btn-secondary") 38 + assert.Contains(t, html, "Cancel") 48 39 }) 49 40 50 41 t.Run("BackButton", func(t *testing.T) { 51 42 html := renderToString(t, ctx, components.BackButton()) 52 43 53 - if !strings.Contains(html, `type="button"`) { 54 - t.Error("Expected type=button to prevent form submission") 55 - } 56 - if !strings.Contains(html, `@click="history.back()"`) { 57 - t.Error("Expected Alpine.js click handler for back button") 58 - } 59 - if !strings.Contains(html, "<svg") { 60 - t.Error("Expected SVG icon") 61 - } 62 - if !strings.Contains(html, "Go back") { 63 - t.Error("Expected aria-label for accessibility") 64 - } 44 + assert.Contains(t, html, `type="button"`) 45 + assert.Contains(t, html, `@click="history.back()"`) 46 + assert.Contains(t, html, "<svg") 47 + assert.Contains(t, html, "Go back") 65 48 }) 66 49 } 67 50 ··· 79 62 80 63 html := renderToString(t, ctx, components.TextInput(props)) 81 64 82 - if !strings.Contains(html, `name="username"`) { 83 - t.Error("Expected name attribute") 84 - } 85 - if !strings.Contains(html, `value="john"`) { 86 - t.Error("Expected value attribute") 87 - } 88 - if !strings.Contains(html, `placeholder="Enter username"`) { 89 - t.Error("Expected placeholder attribute") 90 - } 91 - if !strings.Contains(html, "required") { 92 - t.Error("Expected required attribute") 93 - } 65 + assert.Contains(t, html, `name="username"`) 66 + assert.Contains(t, html, `value="john"`) 67 + assert.Contains(t, html, `placeholder="Enter username"`) 68 + assert.Contains(t, html, "required") 94 69 }) 95 70 96 71 t.Run("NumberInput", func(t *testing.T) { ··· 104 79 105 80 html := renderToString(t, ctx, components.NumberInput(props)) 106 81 107 - if !strings.Contains(html, `type="number"`) { 108 - t.Error("Expected type=number") 109 - } 110 - if !strings.Contains(html, `step="0.1"`) { 111 - t.Error("Expected step attribute") 112 - } 113 - if !strings.Contains(html, `min="0"`) { 114 - t.Error("Expected min attribute") 115 - } 82 + assert.Contains(t, html, `type="number"`) 83 + assert.Contains(t, html, `step="0.1"`) 84 + assert.Contains(t, html, `min="0"`) 116 85 }) 117 86 118 87 t.Run("TextArea", func(t *testing.T) { ··· 125 94 126 95 html := renderToString(t, ctx, components.TextArea(props)) 127 96 128 - if !strings.Contains(html, `name="notes"`) { 129 - t.Error("Expected name attribute") 130 - } 131 - if !strings.Contains(html, "Test notes") { 132 - t.Error("Expected textarea value") 133 - } 134 - if !strings.Contains(html, `rows="5"`) { 135 - t.Error("Expected rows attribute") 136 - } 97 + assert.Contains(t, html, `name="notes"`) 98 + assert.Contains(t, html, "Test notes") 99 + assert.Contains(t, html, `rows="5"`) 137 100 }) 138 101 139 102 t.Run("Select", func(t *testing.T) { ··· 148 111 149 112 html := renderToString(t, ctx, components.Select(props)) 150 113 151 - if !strings.Contains(html, `name="choice"`) { 152 - t.Error("Expected name attribute") 153 - } 154 - if !strings.Contains(html, "Option 1") { 155 - t.Error("Expected Option 1") 156 - } 157 - if !strings.Contains(html, "Option 2") { 158 - t.Error("Expected Option 2") 159 - } 160 - // Check that option 2 has selected attribute 161 - if !strings.Contains(html, "selected") { 162 - t.Error("Expected selected attribute on option 2") 163 - } 114 + assert.Contains(t, html, `name="choice"`) 115 + assert.Contains(t, html, "Option 1") 116 + assert.Contains(t, html, "Option 2") 117 + assert.Contains(t, html, "selected") 164 118 }) 165 119 } 166 120 ··· 177 131 178 132 html := renderToString(t, ctx, components.Card(props, content)) 179 133 180 - if !strings.Contains(html, "card") { 181 - t.Error("Expected card class") 182 - } 183 - if !strings.Contains(html, "Card content") { 184 - t.Error("Expected card content") 185 - } 134 + assert.Contains(t, html, "card") 135 + assert.Contains(t, html, "Card content") 186 136 }) 187 137 188 138 t.Run("Inner card", func(t *testing.T) { ··· 194 144 195 145 html := renderToString(t, ctx, components.Card(props, content)) 196 146 197 - if !strings.Contains(html, "card-inner") { 198 - t.Error("Expected card-inner class") 199 - } 147 + assert.Contains(t, html, "card-inner") 200 148 }) 201 149 } 202 150
+94 -20
internal/web/pages/feed.templ
··· 2 2 3 3 import ( 4 4 "arabica/internal/feed" 5 + "arabica/internal/lexicons" 5 6 "arabica/internal/web/bff" 6 7 "arabica/internal/web/components" 7 8 "fmt" ··· 12 13 <div class="space-y-4"> 13 14 if len(items) > 0 { 14 15 for _, item := range items { 15 - @FeedCard(item) 16 + @FeedCard(item, isAuthenticated) 16 17 } 17 18 } else { 18 19 <div class="bg-brown-100 rounded-lg p-6 text-center text-brown-700 border border-brown-200"> ··· 24 25 } 25 26 26 27 // FeedCard renders a single feed item card 27 - templ FeedCard(item *feed.FeedItem) { 28 + templ FeedCard(item *feed.FeedItem, isAuthenticated bool) { 28 29 <div class="feed-card"> 29 30 <!-- Author row --> 30 31 <div class="flex items-center gap-3 mb-3"> ··· 44 45 </div> 45 46 <span class="text-brown-500 text-sm">{ item.TimeAgo }</span> 46 47 </div> 48 + <!-- Social buttons --> 49 + if item.SubjectURI != "" && item.SubjectCID != "" { 50 + <div class="flex-shrink-0"> 51 + @components.SocialButtons(components.SocialButtonsProps{ 52 + SubjectURI: item.SubjectURI, 53 + SubjectCID: item.SubjectCID, 54 + IsLiked: item.IsLikedByViewer, 55 + LikeCount: item.LikeCount, 56 + ShareURL: getFeedItemShareURL(item), 57 + ShareTitle: getFeedItemShareTitle(item), 58 + ShareText: getFeedItemShareText(item), 59 + ShowLike: true, 60 + IsAuthenticated: isAuthenticated, 61 + }) 62 + </div> 63 + } 47 64 </div> 48 65 <!-- Action header --> 49 66 <div class="mb-2 text-sm text-brown-700"> ··· 51 68 </div> 52 69 <!-- Record content --> 53 70 switch item.RecordType { 54 - case "brew": 71 + case lexicons.RecordTypeBrew: 55 72 @FeedBrewContent(item) 56 - case "bean": 73 + case lexicons.RecordTypeBean: 57 74 @FeedBeanContent(item) 58 - case "roaster": 75 + case lexicons.RecordTypeRoaster: 59 76 @FeedRoasterContent(item) 60 - case "grinder": 77 + case lexicons.RecordTypeGrinder: 61 78 @FeedGrinderContent(item) 62 - case "brewer": 79 + case lexicons.RecordTypeBrewer: 63 80 @FeedBrewerContent(item) 64 81 } 65 82 </div> ··· 83 100 // ActionText renders the action text with clickable links for record names 84 101 templ ActionText(item *feed.FeedItem) { 85 102 switch item.RecordType { 86 - case "brew": 103 + case lexicons.RecordTypeBrew: 87 104 if item.Brew != nil { 88 105 added a 89 106 <a ··· 130 147 if item.Brew.Bean.Process != "" { 131 148 <span class="inline-flex items-center gap-0.5">🌱 { item.Brew.Bean.Process }</span> 132 149 } 133 - if item.Brew.CoffeeAmount > 0 { 134 - <span class="inline-flex items-center gap-0.5">⚖️ { fmt.Sprintf("%dg", item.Brew.CoffeeAmount) }</span> 135 - } 150 + if item.Brew.CoffeeAmount > 0 { 151 + <span class="inline-flex items-center gap-0.5">⚖️ { fmt.Sprintf("%dg", item.Brew.CoffeeAmount) }</span> 152 + } 136 153 </div> 137 154 } 138 155 </div> ··· 173 190 if len(item.Brew.Pours) > 0 { 174 191 <div class="col-span-2"> 175 192 <span class="text-label">Pours:</span> 176 - for _, pour := range item.Brew.Pours { 177 - <div class="pl-2 text-brown-600">• { fmt.Sprintf("%dg @ %s", pour.WaterAmount, bff.FormatTime(pour.TimeSeconds)) }</div> 178 - } 193 + for _, pour := range item.Brew.Pours { 194 + <div class="pl-2 text-brown-600">• { fmt.Sprintf("%dg @ %s", pour.WaterAmount, bff.FormatTime(pour.TimeSeconds)) }</div> 195 + } 179 196 </div> 180 - } else if item.Brew.WaterAmount > 0 { 181 - <div> 182 - <span class="text-label">Water:</span> { fmt.Sprintf("%dg", item.Brew.WaterAmount) } 183 - </div> 184 - } 197 + } else if item.Brew.WaterAmount > 0 { 198 + <div> 199 + <span class="text-label">Water:</span> { fmt.Sprintf("%dg", item.Brew.WaterAmount) } 200 + </div> 201 + } 185 202 if bff.HasTemp(item.Brew.Temperature) { 186 203 <div> 187 204 <span class="text-label">Temp:</span> { bff.FormatTemp(item.Brew.Temperature) } ··· 215 232 } 216 233 </span> 217 234 if item.Bean.Roaster != nil && item.Bean.Roaster.Name != "" { 218 - <span class="text-brown-700"> from { item.Bean.Roaster.Name }</span> 235 + <span class="text-brown-700">from { item.Bean.Roaster.Name }</span> 219 236 } 220 237 </div> 221 238 <div class="text-sm text-brown-700 space-y-1"> ··· 296 313 </div> 297 314 } 298 315 } 316 + 317 + // Helper functions for share button 318 + func getFeedItemShareURL(item *feed.FeedItem) string { 319 + switch item.RecordType { 320 + case lexicons.RecordTypeBrew: 321 + if item.Brew != nil { 322 + return fmt.Sprintf("/brews/%s?owner=%s", item.Brew.RKey, item.Author.Handle) 323 + } 324 + } 325 + // For other record types, link to the user's profile 326 + return fmt.Sprintf("/profile/%s", item.Author.Handle) 327 + } 328 + 329 + func getFeedItemShareTitle(item *feed.FeedItem) string { 330 + switch item.RecordType { 331 + case lexicons.RecordTypeBrew: 332 + if item.Brew != nil && item.Brew.Bean != nil { 333 + if item.Brew.Bean.Name != "" { 334 + return item.Brew.Bean.Name 335 + } 336 + return item.Brew.Bean.Origin 337 + } 338 + return "Coffee Brew" 339 + case lexicons.RecordTypeBean: 340 + if item.Bean != nil { 341 + if item.Bean.Name != "" { 342 + return item.Bean.Name 343 + } 344 + return item.Bean.Origin 345 + } 346 + return "Coffee Bean" 347 + case lexicons.RecordTypeRoaster: 348 + if item.Roaster != nil { 349 + return item.Roaster.Name 350 + } 351 + return "Roaster" 352 + case lexicons.RecordTypeGrinder: 353 + if item.Grinder != nil { 354 + return item.Grinder.Name 355 + } 356 + return "Grinder" 357 + case lexicons.RecordTypeBrewer: 358 + if item.Brewer != nil { 359 + return item.Brewer.Name 360 + } 361 + return "Brewer" 362 + } 363 + return "Arabica" 364 + } 365 + 366 + func getFeedItemShareText(item *feed.FeedItem) string { 367 + displayName := item.Author.Handle 368 + if item.Author.DisplayName != nil && *item.Author.DisplayName != "" { 369 + displayName = *item.Author.DisplayName 370 + } 371 + return fmt.Sprintf("Check out this %s by %s on Arabica", item.RecordType, displayName) 372 + }
+27
lexicons/social.arabica.alpha.like.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.arabica.alpha.like", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "description": "A like on an Arabica record (brew, bean, roaster, grinder, or brewer)", 9 + "record": { 10 + "type": "object", 11 + "required": ["subject", "createdAt"], 12 + "properties": { 13 + "subject": { 14 + "type": "ref", 15 + "ref": "com.atproto.repo.strongRef", 16 + "description": "The AT-URI and CID of the record being liked" 17 + }, 18 + "createdAt": { 19 + "type": "string", 20 + "format": "datetime", 21 + "description": "Timestamp when the like was created" 22 + } 23 + } 24 + } 25 + } 26 + } 27 + }
+58
static/css/app.css
··· 198 198 .link-bold { 199 199 @apply font-medium text-brown-700 hover:text-brown-900 hover:underline transition-colors; 200 200 } 201 + 202 + /* Like Button */ 203 + .like-btn { 204 + @apply inline-flex items-center justify-center gap-1.5 px-2.5 py-1 rounded-md text-sm font-medium transition-colors; 205 + } 206 + 207 + .like-btn-liked { 208 + @apply like-btn bg-brown-100 text-red-600 hover:bg-brown-200; 209 + animation: like-pop 400ms ease-out; 210 + } 211 + 212 + .like-btn-unliked { 213 + @apply like-btn bg-brown-100 text-brown-600 hover:bg-brown-200; 214 + animation: like-shrink 200ms ease-out; 215 + } 216 + 217 + /* Share Button */ 218 + .share-btn { 219 + @apply inline-flex items-center justify-center gap-1.5 px-2.5 py-1 rounded-md text-sm font-medium transition-colors bg-brown-100 text-brown-600 hover:bg-brown-200; 220 + } 201 221 } 202 222 203 223 /* ======================================== ··· 401 421 to { 402 422 opacity: 1; 403 423 transform: translateY(0); 424 + } 425 + } 426 + 427 + /* Like button pop animation */ 428 + @keyframes like-pop { 429 + 0% { 430 + transform: scale(1); 431 + } 432 + 15% { 433 + transform: scale(1.3); 434 + } 435 + 30% { 436 + transform: scale(0.9); 437 + } 438 + 45% { 439 + transform: scale(1.15); 440 + } 441 + 60% { 442 + transform: scale(0.95); 443 + } 444 + 75% { 445 + transform: scale(1.05); 446 + } 447 + 100% { 448 + transform: scale(1); 449 + } 450 + } 451 + 452 + /* Like button shrink animation for unlike */ 453 + @keyframes like-shrink { 454 + 0% { 455 + transform: scale(1); 456 + } 457 + 50% { 458 + transform: scale(0.8); 459 + } 460 + 100% { 461 + transform: scale(1); 404 462 } 405 463 } 406 464
+17 -20
static/service-worker.js
··· 1 - const CACHE_NAME = 'arabica-v1'; 1 + const CACHE_NAME = "arabica-v1"; 2 2 const urlsToCache = [ 3 - '/', 4 - '/static/css/style.css', 5 - 'https://unpkg.com/htmx.org@1.9.10', 6 - 'https://unpkg.com/alpinejs@3.13.3/dist/cdn.min.js' 3 + "/", 4 + "/static/css/output.css", 5 + "/static/js/alpine.min.js", 6 + "/static/js/htmx.min.js", 7 7 ]; 8 8 9 9 // Install service worker and cache resources 10 - self.addEventListener('install', (event) => { 10 + self.addEventListener("install", (event) => { 11 11 event.waitUntil( 12 - caches.open(CACHE_NAME) 13 - .then((cache) => cache.addAll(urlsToCache)) 12 + caches.open(CACHE_NAME).then((cache) => cache.addAll(urlsToCache)), 14 13 ); 15 14 }); 16 15 17 16 // Fetch from cache, fallback to network 18 - self.addEventListener('fetch', (event) => { 17 + self.addEventListener("fetch", (event) => { 19 18 event.respondWith( 20 - caches.match(event.request) 21 - .then((response) => { 22 - // Cache hit - return response 23 - if (response) { 24 - return response; 25 - } 26 - return fetch(event.request); 19 + caches.match(event.request).then((response) => { 20 + // Cache hit - return response 21 + if (response) { 22 + return response; 27 23 } 28 - ) 24 + return fetch(event.request); 25 + }), 29 26 ); 30 27 }); 31 28 32 29 // Update service worker 33 - self.addEventListener('activate', (event) => { 30 + self.addEventListener("activate", (event) => { 34 31 const cacheWhitelist = [CACHE_NAME]; 35 32 event.waitUntil( 36 33 caches.keys().then((cacheNames) => { ··· 39 36 if (cacheWhitelist.indexOf(cacheName) === -1) { 40 37 return caches.delete(cacheName); 41 38 } 42 - }) 39 + }), 43 40 ); 44 - }) 41 + }), 45 42 ); 46 43 });