diff --git a/.cells/cells.jsonl b/.cells/cells.jsonl index 04d8253..508315c 100644 --- a/.cells/cells.jsonl +++ b/.cells/cells.jsonl @@ -38,3 +38,8 @@ {"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"} {"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"}]} {"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"} +{"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"} +{"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"} +{"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"} +{"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"} +{"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"} diff --git a/.gitattributes b/.gitattributes index 1031799..858ac3a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +1,2 @@ -*.tmpl linguist-language=go-template - static/js/alpine.min.js linguist-vendored static/js/htmx.min.js linguist-vendored - diff --git a/CLAUDE.md b/CLAUDE.md index 85c7ac6..4452709 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -558,6 +558,55 @@ func (h *Handler) HandleBrewsPartial(w http.ResponseWriter, r *http.Request) { } ``` +### Testing Conventions + +**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()`. + +```go +// CORRECT: Use testify assert +import ( + "testing" + "github.com/stretchr/testify/assert" +) + +func TestFormatTemp(t *testing.T) { + got := FormatTemp(93.5) + assert.Equal(t, "93.5°C", got) +} + +func TestPtrEquals(t *testing.T) { + val := 42 + assert.True(t, PtrEquals(&val, 42)) + assert.False(t, PtrEquals(&val, 99)) +} + +func TestRenderedHTML(t *testing.T) { + html := renderComponent() + assert.Contains(t, html, "btn-primary") + assert.NotContains(t, html, "deprecated-class") +} + +// WRONG: Don't use if statements for assertions +func TestFormatTemp(t *testing.T) { + got := FormatTemp(93.5) + if got != "93.5°C" { // ❌ Don't do this + t.Errorf("got %q, want %q", got, "93.5°C") + } +} +``` + +**Common testify assertions:** +- `assert.Equal(t, expected, actual)` - Equality check +- `assert.NotEqual(t, expected, actual)` - Inequality check +- `assert.True(t, value)` - Boolean true +- `assert.False(t, value)` - Boolean false +- `assert.Nil(t, value)` - Nil check +- `assert.NotNil(t, value)` - Not nil check +- `assert.Contains(t, haystack, needle)` - Substring/element check +- `assert.NotContains(t, haystack, needle)` - Negative substring/element check +- `assert.NoError(t, err)` - No error occurred +- `assert.Error(t, err)` - Error occurred + ## Future Vision: Social Features The app currently has a basic community feed. Future plans expand social interactions leveraging AT Protocol's decentralized nature. diff --git a/cmd/server/main.go b/cmd/server/main.go index c6bfb10..373416f 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -292,6 +292,9 @@ func main() { }, ) + // Wire up the feed index for like functionality + h.SetFeedIndex(feedIndex) + // Setup router with middleware handler := routing.SetupRouter(routing.Config{ Handlers: h, diff --git a/internal/atproto/nsid.go b/internal/atproto/nsid.go index 24c47f5..8fe2bc5 100644 --- a/internal/atproto/nsid.go +++ b/internal/atproto/nsid.go @@ -17,6 +17,7 @@ const ( NSIDBrew = NSIDBase + ".brew" NSIDBrewer = NSIDBase + ".brewer" NSIDGrinder = NSIDBase + ".grinder" + NSIDLike = NSIDBase + ".like" NSIDRoaster = NSIDBase + ".roaster" // MaxRKeyLength is the maximum allowed length for a record key diff --git a/internal/atproto/oauth.go b/internal/atproto/oauth.go index 13667a3..447ca1e 100644 --- a/internal/atproto/oauth.go +++ b/internal/atproto/oauth.go @@ -17,6 +17,7 @@ var scopes = []string{ "repo:" + NSIDBrew, "repo:" + NSIDBrewer, "repo:" + NSIDGrinder, + "repo:" + NSIDLike, "repo:" + NSIDRoaster, } @@ -197,4 +198,5 @@ func GetSessionIDFromContext(ctx context.Context) (string, error) { // ParseDID is a helper to parse a DID string to syntax.DID func ParseDID(didStr string) (syntax.DID, error) { return syntax.ParseDID(didStr) -} \ No newline at end of file +} + diff --git a/internal/atproto/records.go b/internal/atproto/records.go index 9fd7f69..c9332a4 100644 --- a/internal/atproto/records.go +++ b/internal/atproto/records.go @@ -432,3 +432,71 @@ func RecordToBrewer(record map[string]interface{}, atURI string) (*models.Brewer return brewer, nil } + +// ========== Like Conversions ========== + +// LikeToRecord converts a models.Like to an atproto record map +// Uses com.atproto.repo.strongRef format for the subject +func LikeToRecord(like *models.Like) (map[string]interface{}, error) { + if like.SubjectURI == "" { + return nil, fmt.Errorf("subject URI is required") + } + if like.SubjectCID == "" { + return nil, fmt.Errorf("subject CID is required") + } + + record := map[string]interface{}{ + "$type": NSIDLike, + "subject": map[string]interface{}{ + "uri": like.SubjectURI, + "cid": like.SubjectCID, + }, + "createdAt": like.CreatedAt.Format(time.RFC3339), + } + + return record, nil +} + +// RecordToLike converts an atproto record map to a models.Like +func RecordToLike(record map[string]interface{}, atURI string) (*models.Like, error) { + like := &models.Like{} + + // Extract rkey from AT-URI + if atURI != "" { + parsedURI, err := syntax.ParseATURI(atURI) + if err != nil { + return nil, fmt.Errorf("invalid AT-URI: %w", err) + } + like.RKey = parsedURI.RecordKey().String() + } + + // Required field: subject (strongRef) + subject, ok := record["subject"].(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("subject is required") + } + subjectURI, ok := subject["uri"].(string) + if !ok || subjectURI == "" { + return nil, fmt.Errorf("subject.uri is required") + } + like.SubjectURI = subjectURI + + subjectCID, ok := subject["cid"].(string) + if !ok || subjectCID == "" { + return nil, fmt.Errorf("subject.cid is required") + } + like.SubjectCID = subjectCID + + // Required field: createdAt + createdAtStr, ok := record["createdAt"].(string) + if !ok { + return nil, fmt.Errorf("createdAt is required") + } + createdAt, err := time.Parse(time.RFC3339, createdAtStr) + if err != nil { + return nil, fmt.Errorf("invalid createdAt format: %w", err) + } + like.CreatedAt = createdAt + + return like, nil +} diff --git a/internal/atproto/store.go b/internal/atproto/store.go index 2961f74..50897f7 100644 --- a/internal/atproto/store.go +++ b/internal/atproto/store.go @@ -167,6 +167,69 @@ func (s *AtprotoStore) GetBrewByRKey(ctx context.Context, rkey string) (*models. return brew, nil } +// BrewRecord contains a brew with its AT Protocol metadata +type BrewRecord struct { + Brew *models.Brew + URI string + CID string +} + +// GetBrewRecordByRKey fetches a brew by rkey and returns it with its AT Protocol metadata +func (s *AtprotoStore) GetBrewRecordByRKey(ctx context.Context, rkey string) (*BrewRecord, error) { + output, err := s.client.GetRecord(ctx, s.did, s.sessionID, &GetRecordInput{ + Collection: NSIDBrew, + RKey: rkey, + }) + if err != nil { + return nil, fmt.Errorf("failed to get brew record: %w", err) + } + + // Build the AT-URI for this brew + atURI := BuildATURI(s.did.String(), NSIDBrew, rkey) + + // Convert to models.Brew + brew, err := RecordToBrew(output.Value, atURI) + if err != nil { + return nil, fmt.Errorf("failed to convert brew record: %w", err) + } + + // Set the rkey + brew.RKey = rkey + + // Extract and resolve references + beanRef, _ := output.Value["beanRef"].(string) + grinderRef, _ := output.Value["grinderRef"].(string) + brewerRef, _ := output.Value["brewerRef"].(string) + + // Extract rkeys from AT-URIs for the model + if beanRef != "" { + if components, err := ResolveATURI(beanRef); err == nil { + brew.BeanRKey = components.RKey + } + } + if grinderRef != "" { + if components, err := ResolveATURI(grinderRef); err == nil { + brew.GrinderRKey = components.RKey + } + } + if brewerRef != "" { + if components, err := ResolveATURI(brewerRef); err == nil { + brew.BrewerRKey = components.RKey + } + } + + err = ResolveBrewRefs(ctx, s.client, brew, beanRef, grinderRef, brewerRef, s.sessionID) + if err != nil { + log.Warn().Err(err).Str("brew_rkey", rkey).Msg("Failed to resolve brew references") + } + + return &BrewRecord{ + Brew: brew, + URI: output.URI, + CID: output.CID, + }, nil +} + func (s *AtprotoStore) ListBrews(ctx context.Context, userID int) ([]*models.Brew, error) { // Check cache first userCache := s.cache.Get(s.sessionID) @@ -984,6 +1047,98 @@ func (s *AtprotoStore) DeleteBrewerByRKey(ctx context.Context, rkey string) erro return nil } +// ========== Like Operations ========== + +func (s *AtprotoStore) CreateLike(ctx context.Context, req *models.CreateLikeRequest) (*models.Like, error) { + if req.SubjectURI == "" { + return nil, fmt.Errorf("subject_uri is required") + } + if req.SubjectCID == "" { + return nil, fmt.Errorf("subject_cid is required") + } + + likeModel := &models.Like{ + SubjectURI: req.SubjectURI, + SubjectCID: req.SubjectCID, + CreatedAt: time.Now(), + } + + record, err := LikeToRecord(likeModel) + if err != nil { + return nil, fmt.Errorf("failed to convert like to record: %w", err) + } + + output, err := s.client.CreateRecord(ctx, s.did, s.sessionID, &CreateRecordInput{ + Collection: NSIDLike, + Record: record, + }) + if err != nil { + return nil, fmt.Errorf("failed to create like record: %w", err) + } + + atURI, err := syntax.ParseATURI(output.URI) + if err != nil { + return nil, fmt.Errorf("failed to parse returned AT-URI: %w", err) + } + + likeModel.RKey = atURI.RecordKey().String() + + return likeModel, nil +} + +func (s *AtprotoStore) DeleteLikeByRKey(ctx context.Context, rkey string) error { + err := s.client.DeleteRecord(ctx, s.did, s.sessionID, &DeleteRecordInput{ + Collection: NSIDLike, + RKey: rkey, + }) + if err != nil { + return fmt.Errorf("failed to delete like record: %w", err) + } + return nil +} + +func (s *AtprotoStore) GetUserLikeForSubject(ctx context.Context, subjectURI string) (*models.Like, error) { + // List all likes and find the one matching the subject URI + likes, err := s.ListUserLikes(ctx) + if err != nil { + return nil, err + } + + for _, like := range likes { + if like.SubjectURI == subjectURI { + return like, nil + } + } + + return nil, nil // Not found (not an error) +} + +func (s *AtprotoStore) ListUserLikes(ctx context.Context) ([]*models.Like, error) { + output, err := s.client.ListAllRecords(ctx, s.did, s.sessionID, NSIDLike) + if err != nil { + return nil, fmt.Errorf("failed to list like records: %w", err) + } + + likes := make([]*models.Like, 0, len(output.Records)) + + for _, rec := range output.Records { + like, err := RecordToLike(rec.Value, rec.URI) + if err != nil { + log.Warn().Err(err).Str("uri", rec.URI).Msg("Failed to convert like record") + continue + } + + // Extract rkey from URI + if components, err := ResolveATURI(rec.URI); err == nil { + like.RKey = components.RKey + } + + likes = append(likes, like) + } + + return likes, nil +} + func (s *AtprotoStore) Close() error { // No persistent connection to close for atproto return nil diff --git a/internal/database/store.go b/internal/database/store.go index 5a757ed..68e7b10 100644 --- a/internal/database/store.go +++ b/internal/database/store.go @@ -48,6 +48,12 @@ type Store interface { UpdateBrewerByRKey(ctx context.Context, rkey string, brewer *models.UpdateBrewerRequest) error DeleteBrewerByRKey(ctx context.Context, rkey string) error + // Like operations + CreateLike(ctx context.Context, req *models.CreateLikeRequest) (*models.Like, error) + DeleteLikeByRKey(ctx context.Context, rkey string) error + GetUserLikeForSubject(ctx context.Context, subjectURI string) (*models.Like, error) + ListUserLikes(ctx context.Context) ([]*models.Like, error) + // Close the database connection Close() error } diff --git a/internal/feed/service.go b/internal/feed/service.go index 92588d8..a670c25 100644 --- a/internal/feed/service.go +++ b/internal/feed/service.go @@ -7,6 +7,7 @@ import ( "time" "arabica/internal/atproto" + "arabica/internal/lexicons" "arabica/internal/models" "github.com/rs/zerolog/log" @@ -29,8 +30,8 @@ const ( // FeedItem represents an activity in the social feed with author info type FeedItem struct { // Record type and data (only one will be non-nil) - RecordType string // "brew", "bean", "roaster", "grinder", "brewer" - Action string // "added a new brew", "added a new bean", etc. + RecordType lexicons.RecordType // Use lexicons.RecordTypeBrew, lexicons.RecordTypeBean, etc. + Action string // "added a new brew", "added a new bean", etc. Brew *models.Brew Bean *models.Bean @@ -41,6 +42,12 @@ type FeedItem struct { Author *atproto.Profile Timestamp time.Time TimeAgo string // "2 hours ago", "yesterday", etc. + + // Like-related fields + LikeCount int // Number of likes on this record + SubjectURI string // AT-URI of this record (for like button) + SubjectCID string // CID of this record (for like button) + IsLikedByViewer bool // Whether the current viewer has liked this record } // publicFeedCache holds cached feed items for unauthenticated users @@ -60,7 +67,7 @@ type FirehoseIndex interface { // FirehoseFeedItem matches the FeedItem structure from firehose package // This avoids import cycles type FirehoseFeedItem struct { - RecordType string + RecordType lexicons.RecordType Action string Brew *models.Brew Bean *models.Bean @@ -70,6 +77,9 @@ type FirehoseFeedItem struct { Author *atproto.Profile Timestamp time.Time TimeAgo string + LikeCount int + SubjectURI string + SubjectCID string } // Service fetches and aggregates brews from registered users @@ -199,6 +209,9 @@ func (s *Service) getRecentRecordsFromFirehose(ctx context.Context, limit int) ( Author: fi.Author, Timestamp: fi.Timestamp, TimeAgo: fi.TimeAgo, + LikeCount: fi.LikeCount, + SubjectURI: fi.SubjectURI, + SubjectCID: fi.SubjectCID, } } diff --git a/internal/firehose/adapter.go b/internal/firehose/adapter.go index 20b76d3..0aa6495 100644 --- a/internal/firehose/adapter.go +++ b/internal/firehose/adapter.go @@ -44,6 +44,9 @@ func (a *FeedIndexAdapter) GetRecentFeed(ctx context.Context, limit int) ([]*fee Author: item.Author, Timestamp: item.Timestamp, TimeAgo: item.TimeAgo, + LikeCount: item.LikeCount, + SubjectURI: item.SubjectURI, + SubjectCID: item.SubjectCID, } } diff --git a/internal/firehose/config.go b/internal/firehose/config.go index 7edc286..903912c 100644 --- a/internal/firehose/config.go +++ b/internal/firehose/config.go @@ -21,6 +21,7 @@ var ArabicaCollections = []string{ atproto.NSIDRoaster, atproto.NSIDGrinder, atproto.NSIDBrewer, + atproto.NSIDLike, } // Config holds configuration for the Jetstream consumer diff --git a/internal/firehose/consumer.go b/internal/firehose/consumer.go index 1114962..8732980 100644 --- a/internal/firehose/consumer.go +++ b/internal/firehose/consumer.go @@ -333,7 +333,40 @@ func (c *Consumer) processMessage(data []byte) error { return fmt.Errorf("failed to upsert record: %w", err) } + // Special handling for likes - index for counts + if commit.Collection == "social.arabica.alpha.like" { + var recordData map[string]interface{} + if err := json.Unmarshal(commit.Record, &recordData); err == nil { + if subject, ok := recordData["subject"].(map[string]interface{}); ok { + if subjectURI, ok := subject["uri"].(string); ok { + if err := c.index.UpsertLike(event.DID, commit.RKey, subjectURI); err != nil { + log.Warn().Err(err).Str("did", event.DID).Str("subject", subjectURI).Msg("failed to index like") + } + } + } + } + } + case "delete": + // Special handling for likes - need to look up subject URI before delete + if commit.Collection == "social.arabica.alpha.like" { + // Try to get the existing record to find its subject + if existingRecord, err := c.index.GetRecord( + fmt.Sprintf("at://%s/%s/%s", event.DID, commit.Collection, commit.RKey), + ); err == nil && existingRecord != nil { + var recordData map[string]interface{} + if err := json.Unmarshal(existingRecord.Record, &recordData); err == nil { + if subject, ok := recordData["subject"].(map[string]interface{}); ok { + if subjectURI, ok := subject["uri"].(string); ok { + if err := c.index.DeleteLike(event.DID, subjectURI); err != nil { + log.Warn().Err(err).Str("did", event.DID).Str("subject", subjectURI).Msg("failed to delete like index") + } + } + } + } + } + } + if err := c.index.DeleteRecord( event.DID, commit.Collection, diff --git a/internal/firehose/index.go b/internal/firehose/index.go index b224332..7dc870d 100644 --- a/internal/firehose/index.go +++ b/internal/firehose/index.go @@ -13,6 +13,7 @@ import ( "time" "arabica/internal/atproto" + "arabica/internal/lexicons" "arabica/internal/models" "github.com/rs/zerolog/log" @@ -44,8 +45,27 @@ var ( // BucketBackfilled stores DIDs that have been backfilled: {did} -> {timestamp} BucketBackfilled = []byte("backfilled") + + // BucketLikes stores like mappings: {subject_uri:actor_did} -> {rkey} + BucketLikes = []byte("likes") + + // BucketLikeCounts stores aggregated like counts: {subject_uri} -> {uint64 count} + BucketLikeCounts = []byte("like_counts") + + // BucketLikesByActor stores likes by actor for lookup: {actor_did:subject_uri} -> {rkey} + BucketLikesByActor = []byte("likes_by_actor") ) +// FeedableRecordTypes are the record types that should appear as feed items. +// Likes, comments, etc. are indexed but not displayed directly in the feed. +var FeedableRecordTypes = map[lexicons.RecordType]bool{ + lexicons.RecordTypeBrew: true, + lexicons.RecordTypeBean: true, + lexicons.RecordTypeRoaster: true, + lexicons.RecordTypeGrinder: true, + lexicons.RecordTypeBrewer: true, +} + // IndexedRecord represents a record stored in the index type IndexedRecord struct { URI string `json:"uri"` @@ -111,6 +131,9 @@ func NewFeedIndex(path string, profileTTL time.Duration) (*FeedIndex, error) { BucketMeta, BucketKnownDIDs, BucketBackfilled, + BucketLikes, + BucketLikeCounts, + BucketLikesByActor, } for _, bucket := range buckets { if _, err := tx.CreateBucketIfNotExists(bucket); err != nil { @@ -120,7 +143,7 @@ func NewFeedIndex(path string, profileTTL time.Duration) (*FeedIndex, error) { return nil }) if err != nil { - db.Close() + _ = db.Close() return nil, err } @@ -162,7 +185,7 @@ func (idx *FeedIndex) GetCursor() (int64, error) { err := idx.db.View(func(tx *bolt.Tx) error { b := tx.Bucket(BucketMeta) v := b.Get([]byte("cursor")) - if v != nil && len(v) == 8 { + if len(v) == 8 { cursor = int64(binary.BigEndian.Uint64(v)) } return nil @@ -185,7 +208,7 @@ func (idx *FeedIndex) UpsertRecord(did, collection, rkey, cid string, record jso uri := atproto.BuildATURI(did, collection, rkey) // Parse createdAt from record - var recordData map[string]interface{} + var recordData map[string]any createdAt := time.Now() if err := json.Unmarshal(record, &recordData); err == nil { if createdAtStr, ok := recordData["createdAt"].(string); ok { @@ -315,7 +338,7 @@ func (idx *FeedIndex) GetRecord(uri string) (*IndexedRecord, error) { // FeedItem represents an item in the feed (matches feed.FeedItem structure) type FeedItem struct { - RecordType string + RecordType lexicons.RecordType Action string Brew *models.Brew @@ -327,14 +350,16 @@ type FeedItem struct { Author *atproto.Profile Timestamp time.Time TimeAgo string + + // Like-related fields + LikeCount int // Number of likes on this record + SubjectURI string // AT-URI of this record (for like button) + SubjectCID string // CID of this record (for like button) } // GetRecentFeed returns recent feed items from the index func (idx *FeedIndex) GetRecentFeed(ctx context.Context, limit int) ([]*FeedItem, error) { var records []*IndexedRecord - - // FIX: this seems to show the first 20 records for main deployment - // - unclear why, but is likely an issue with the db being stale err := idx.db.View(func(tx *bolt.Tx) error { byTime := tx.Bucket(BucketByTime) recordsBucket := tx.Bucket(BucketRecords) @@ -408,6 +433,9 @@ func (idx *FeedIndex) GetRecentFeed(ctx context.Context, limit int) ([]*FeedItem log.Warn().Err(err).Str("uri", record.URI).Msg("failed to convert record to feed item") continue } + if !FeedableRecordTypes[item.RecordType] { + continue + } items = append(items, item) } @@ -426,7 +454,7 @@ func (idx *FeedIndex) GetRecentFeed(ctx context.Context, limit int) ([]*FeedItem // recordToFeedItem converts an IndexedRecord to a FeedItem func (idx *FeedIndex) recordToFeedItem(ctx context.Context, record *IndexedRecord, refMap map[string]*IndexedRecord) (*FeedItem, error) { - var recordData map[string]interface{} + var recordData map[string]any if err := json.Unmarshal(record.Record, &recordData); err != nil { return nil, err } @@ -458,7 +486,7 @@ func (idx *FeedIndex) recordToFeedItem(ctx context.Context, record *IndexedRecor // Resolve bean reference if beanRef, ok := recordData["beanRef"].(string); ok && beanRef != "" { if beanRecord, found := refMap[beanRef]; found { - var beanData map[string]interface{} + var beanData map[string]any if err := json.Unmarshal(beanRecord.Record, &beanData); err == nil { bean, _ := atproto.RecordToBean(beanData, beanRef) brew.Bean = bean @@ -466,7 +494,7 @@ func (idx *FeedIndex) recordToFeedItem(ctx context.Context, record *IndexedRecor // Resolve roaster reference for bean if roasterRef, ok := beanData["roasterRef"].(string); ok && roasterRef != "" { if roasterRecord, found := refMap[roasterRef]; found { - var roasterData map[string]interface{} + var roasterData map[string]any if err := json.Unmarshal(roasterRecord.Record, &roasterData); err == nil { roaster, _ := atproto.RecordToRoaster(roasterData, roasterRef) brew.Bean.Roaster = roaster @@ -480,7 +508,7 @@ func (idx *FeedIndex) recordToFeedItem(ctx context.Context, record *IndexedRecor // Resolve grinder reference if grinderRef, ok := recordData["grinderRef"].(string); ok && grinderRef != "" { if grinderRecord, found := refMap[grinderRef]; found { - var grinderData map[string]interface{} + var grinderData map[string]any if err := json.Unmarshal(grinderRecord.Record, &grinderData); err == nil { grinder, _ := atproto.RecordToGrinder(grinderData, grinderRef) brew.GrinderObj = grinder @@ -491,7 +519,7 @@ func (idx *FeedIndex) recordToFeedItem(ctx context.Context, record *IndexedRecor // Resolve brewer reference if brewerRef, ok := recordData["brewerRef"].(string); ok && brewerRef != "" { if brewerRecord, found := refMap[brewerRef]; found { - var brewerData map[string]interface{} + var brewerData map[string]any if err := json.Unmarshal(brewerRecord.Record, &brewerData); err == nil { brewer, _ := atproto.RecordToBrewer(brewerData, brewerRef) brew.BrewerObj = brewer @@ -499,7 +527,7 @@ func (idx *FeedIndex) recordToFeedItem(ctx context.Context, record *IndexedRecor } } - item.RecordType = "brew" + item.RecordType = lexicons.RecordTypeBrew item.Action = "added a new brew" item.Brew = brew @@ -512,7 +540,7 @@ func (idx *FeedIndex) recordToFeedItem(ctx context.Context, record *IndexedRecor // Resolve roaster reference if roasterRef, ok := recordData["roasterRef"].(string); ok && roasterRef != "" { if roasterRecord, found := refMap[roasterRef]; found { - var roasterData map[string]interface{} + var roasterData map[string]any if err := json.Unmarshal(roasterRecord.Record, &roasterData); err == nil { roaster, _ := atproto.RecordToRoaster(roasterData, roasterRef) bean.Roaster = roaster @@ -520,7 +548,7 @@ func (idx *FeedIndex) recordToFeedItem(ctx context.Context, record *IndexedRecor } } - item.RecordType = "bean" + item.RecordType = lexicons.RecordTypeBean item.Action = "added a new bean" item.Bean = bean @@ -529,7 +557,7 @@ func (idx *FeedIndex) recordToFeedItem(ctx context.Context, record *IndexedRecor if err != nil { return nil, err } - item.RecordType = "roaster" + item.RecordType = lexicons.RecordTypeRoaster item.Action = "added a new roaster" item.Roaster = roaster @@ -538,7 +566,7 @@ func (idx *FeedIndex) recordToFeedItem(ctx context.Context, record *IndexedRecor if err != nil { return nil, err } - item.RecordType = "grinder" + item.RecordType = lexicons.RecordTypeGrinder item.Action = "added a new grinder" item.Grinder = grinder @@ -547,14 +575,23 @@ func (idx *FeedIndex) recordToFeedItem(ctx context.Context, record *IndexedRecor if err != nil { return nil, err } - item.RecordType = "brewer" + item.RecordType = lexicons.RecordTypeBrewer item.Action = "added a new brewer" item.Brewer = brewer + case atproto.NSIDLike: + // Skip likes in the feed - they're indexed but not displayed as feed items + return nil, fmt.Errorf("likes are not displayed as feed items") + default: return nil, fmt.Errorf("unknown collection: %s", record.Collection) } + // Populate like-related fields for all record types + item.SubjectURI = record.URI + item.SubjectCID = record.CID + item.LikeCount = idx.GetLikeCount(record.URI) + return item, nil } @@ -766,3 +803,131 @@ func (idx *FeedIndex) BackfillUser(ctx context.Context, did string) error { log.Info().Str("did", did).Int("record_count", recordCount).Msg("backfill complete") return nil } + +// ========== Like Indexing Methods ========== + +// UpsertLike adds or updates a like in the index +func (idx *FeedIndex) UpsertLike(actorDID, rkey, subjectURI string) error { + return idx.db.Update(func(tx *bolt.Tx) error { + likes := tx.Bucket(BucketLikes) + likeCounts := tx.Bucket(BucketLikeCounts) + likesByActor := tx.Bucket(BucketLikesByActor) + + // Key format: {subject_uri}:{actor_did} + likeKey := []byte(subjectURI + ":" + actorDID) + + // Check if this like already exists + existingRKey := likes.Get(likeKey) + if existingRKey != nil { + // Already exists, nothing to do + return nil + } + + // Store the like mapping + if err := likes.Put(likeKey, []byte(rkey)); err != nil { + return err + } + + // Store by actor for reverse lookup + actorKey := []byte(actorDID + ":" + subjectURI) + if err := likesByActor.Put(actorKey, []byte(rkey)); err != nil { + return err + } + + // Increment the like count + countKey := []byte(subjectURI) + currentCount := uint64(0) + if countData := likeCounts.Get(countKey); len(countData) == 8 { + currentCount = binary.BigEndian.Uint64(countData) + } + currentCount++ + countBuf := make([]byte, 8) + binary.BigEndian.PutUint64(countBuf, currentCount) + return likeCounts.Put(countKey, countBuf) + }) +} + +// DeleteLike removes a like from the index +func (idx *FeedIndex) DeleteLike(actorDID, subjectURI string) error { + return idx.db.Update(func(tx *bolt.Tx) error { + likes := tx.Bucket(BucketLikes) + likeCounts := tx.Bucket(BucketLikeCounts) + likesByActor := tx.Bucket(BucketLikesByActor) + + // Key format: {subject_uri}:{actor_did} + likeKey := []byte(subjectURI + ":" + actorDID) + + // Check if like exists + if likes.Get(likeKey) == nil { + // Doesn't exist, nothing to do + return nil + } + + // Delete the like mapping + if err := likes.Delete(likeKey); err != nil { + return err + } + + // Delete by actor lookup + actorKey := []byte(actorDID + ":" + subjectURI) + if err := likesByActor.Delete(actorKey); err != nil { + return err + } + + // Decrement the like count + countKey := []byte(subjectURI) + currentCount := uint64(0) + if countData := likeCounts.Get(countKey); len(countData) == 8 { + currentCount = binary.BigEndian.Uint64(countData) + } + if currentCount > 0 { + currentCount-- + } + if currentCount == 0 { + return likeCounts.Delete(countKey) + } + countBuf := make([]byte, 8) + binary.BigEndian.PutUint64(countBuf, currentCount) + return likeCounts.Put(countKey, countBuf) + }) +} + +// GetLikeCount returns the number of likes for a record +func (idx *FeedIndex) GetLikeCount(subjectURI string) int { + var count uint64 + _ = idx.db.View(func(tx *bolt.Tx) error { + likeCounts := tx.Bucket(BucketLikeCounts) + countData := likeCounts.Get([]byte(subjectURI)) + if len(countData) == 8 { + count = binary.BigEndian.Uint64(countData) + } + return nil + }) + return int(count) +} + +// HasUserLiked checks if a user has liked a specific record +func (idx *FeedIndex) HasUserLiked(actorDID, subjectURI string) bool { + var exists bool + _ = idx.db.View(func(tx *bolt.Tx) error { + likesByActor := tx.Bucket(BucketLikesByActor) + actorKey := []byte(actorDID + ":" + subjectURI) + exists = likesByActor.Get(actorKey) != nil + return nil + }) + return exists +} + +// GetUserLikeRKey returns the rkey of a user's like for a specific record, or empty string if not found +func (idx *FeedIndex) GetUserLikeRKey(actorDID, subjectURI string) string { + var rkey string + _ = idx.db.View(func(tx *bolt.Tx) error { + likesByActor := tx.Bucket(BucketLikesByActor) + actorKey := []byte(actorDID + ":" + subjectURI) + if data := likesByActor.Get(actorKey); data != nil { + rkey = string(data) + } + return nil + }) + return rkey +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 97d2dfc..e5a28be 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -3,6 +3,7 @@ package handlers import ( "context" "encoding/json" + "fmt" "net/http" "sort" "strconv" @@ -11,6 +12,7 @@ import ( "arabica/internal/atproto" "arabica/internal/database" "arabica/internal/feed" + "arabica/internal/firehose" "arabica/internal/middleware" "arabica/internal/models" "arabica/internal/web/bff" @@ -37,6 +39,7 @@ type Handler struct { config Config feedService *feed.Service feedRegistry *feed.Registry + feedIndex *firehose.FeedIndex } // NewHandler creates a new Handler with all required dependencies. @@ -59,6 +62,11 @@ func NewHandler( } } +// SetFeedIndex configures the handler to use the firehose feed index for like lookups +func (h *Handler) SetFeedIndex(idx *firehose.FeedIndex) { + h.feedIndex = idx +} + // validateRKey validates and returns an rkey from a path parameter. // Returns the rkey if valid, or writes an error response and returns empty string if invalid. func validateRKey(w http.ResponseWriter, rkey string) string { @@ -402,7 +410,7 @@ func (h *Handler) HandleFeedPartial(w http.ResponseWriter, r *http.Request) { var feedItems []*feed.FeedItem // Check if user is authenticated - _, err := atproto.GetAuthenticatedDID(r.Context()) + viewerDID, err := atproto.GetAuthenticatedDID(r.Context()) isAuthenticated := err == nil if h.feedService != nil { @@ -414,6 +422,15 @@ func (h *Handler) HandleFeedPartial(w http.ResponseWriter, r *http.Request) { } } + // Populate IsLikedByViewer for each feed item if user is authenticated + if isAuthenticated && h.feedIndex != nil { + for _, item := range feedItems { + if item.SubjectURI != "" { + item.IsLikedByViewer = h.feedIndex.HasUserLiked(viewerDID, item.SubjectURI) + } + } + } + if err := pages.FeedPartial(feedItems, isAuthenticated).Render(r.Context(), w); err != nil { http.Error(w, "Failed to render feed", http.StatusInternalServerError) log.Error().Err(err).Msg("Failed to render feed partial") @@ -580,6 +597,7 @@ func (h *Handler) HandleBrewView(w http.ResponseWriter, r *http.Request) { var brew *models.Brew var brewOwnerDID string var isOwner bool + var subjectURI, subjectCID string if owner != "" { // Viewing someone else's brew - use public client @@ -606,6 +624,10 @@ func (h *Handler) HandleBrewView(w http.ResponseWriter, r *http.Request) { return } + // Store URI and CID for like button + subjectURI = record.URI + subjectCID = record.CID + // Convert record to brew brew, err = atproto.RecordToBrew(record.Value, record.URI) if err != nil { @@ -630,23 +652,58 @@ func (h *Handler) HandleBrewView(w http.ResponseWriter, r *http.Request) { return } - brew, err = store.GetBrewByRKey(r.Context(), rkey) + // Use type assertion to access GetBrewRecordByRKey + atprotoStore, ok := store.(*atproto.AtprotoStore) + if !ok { + http.Error(w, "Internal error", http.StatusInternalServerError) + log.Error().Msg("Failed to cast store to AtprotoStore") + return + } + + brewRecord, err := atprotoStore.GetBrewRecordByRKey(r.Context(), rkey) if err != nil { http.Error(w, "Brew not found", http.StatusNotFound) log.Error().Err(err).Str("rkey", rkey).Msg("Failed to get brew for view") return } + brew = brewRecord.Brew + subjectURI = brewRecord.URI + subjectCID = brewRecord.CID isOwner = true } // Create layout data layoutData := h.buildLayoutData(r, "Brew Details", isAuthenticated, didStr, userProfile) + // Get like data + var isLiked bool + var likeCount int + if h.feedIndex != nil && subjectURI != "" { + likeCount = h.feedIndex.GetLikeCount(subjectURI) + if isAuthenticated { + isLiked = h.feedIndex.HasUserLiked(didStr, subjectURI) + } + } + + // Construct share URL + var shareURL string + if owner != "" { + shareURL = fmt.Sprintf("/brews/%s?owner=%s", rkey, owner) + } else if userProfile != nil && userProfile.Handle != "" { + shareURL = fmt.Sprintf("/brews/%s?owner=%s", rkey, userProfile.Handle) + } + // Create brew view props brewViewProps := pages.BrewViewProps{ - Brew: brew, - IsOwnProfile: isOwner, + Brew: brew, + IsOwnProfile: isOwner, + IsAuthenticated: isAuthenticated, + SubjectURI: subjectURI, + SubjectCID: subjectCID, + IsLiked: isLiked, + LikeCount: likeCount, + ShareURL: shareURL, } // Render using templ component @@ -1040,6 +1097,88 @@ func (h *Handler) HandleBrewExport(w http.ResponseWriter, r *http.Request) { } } +// HandleLikeToggle handles creating or deleting a like on a record +func (h *Handler) HandleLikeToggle(w http.ResponseWriter, r *http.Request) { + // Require authentication + store, authenticated := h.getAtprotoStore(r) + if !authenticated { + http.Error(w, "Authentication required", http.StatusUnauthorized) + return + } + + didStr, _ := atproto.GetAuthenticatedDID(r.Context()) + + if err := r.ParseForm(); err != nil { + http.Error(w, "Invalid form data", http.StatusBadRequest) + return + } + + subjectURI := r.FormValue("subject_uri") + subjectCID := r.FormValue("subject_cid") + + if subjectURI == "" || subjectCID == "" { + http.Error(w, "subject_uri and subject_cid are required", http.StatusBadRequest) + return + } + + // Check if user already liked this record + existingLike, err := store.GetUserLikeForSubject(r.Context(), subjectURI) + if err != nil { + http.Error(w, "Failed to check like status", http.StatusInternalServerError) + log.Error().Err(err).Msg("Failed to check existing like") + return + } + + var isLiked bool + var likeCount int + + if existingLike != nil { + // Unlike: delete the existing like + if err := store.DeleteLikeByRKey(r.Context(), existingLike.RKey); err != nil { + http.Error(w, "Failed to unlike", http.StatusInternalServerError) + log.Error().Err(err).Msg("Failed to delete like") + return + } + isLiked = false + + // Update firehose index + if h.feedIndex != nil { + _ = h.feedIndex.DeleteLike(didStr, subjectURI) + likeCount = h.feedIndex.GetLikeCount(subjectURI) + } + } else { + // Like: create a new like + req := &models.CreateLikeRequest{ + SubjectURI: subjectURI, + SubjectCID: subjectCID, + } + like, err := store.CreateLike(r.Context(), req) + if err != nil { + http.Error(w, "Failed to like", http.StatusInternalServerError) + log.Error().Err(err).Msg("Failed to create like") + return + } + isLiked = true + + // Update firehose index + if h.feedIndex != nil { + _ = h.feedIndex.UpsertLike(didStr, like.RKey, subjectURI) + likeCount = h.feedIndex.GetLikeCount(subjectURI) + } + } + + // Return the updated like button component + if err := components.LikeButton(components.LikeButtonProps{ + SubjectURI: subjectURI, + SubjectCID: subjectCID, + IsLiked: isLiked, + LikeCount: likeCount, + }).Render(r.Context(), w); err != nil { + http.Error(w, "Failed to render", http.StatusInternalServerError) + log.Error().Err(err).Msg("Failed to render like button") + } +} + // API endpoint to list all user data (beans, roasters, grinders, brewers, brews) // Used by client-side cache for faster page loads func (h *Handler) HandleAPIListAll(w http.ResponseWriter, r *http.Request) { diff --git a/internal/lexicons/record_type.go b/internal/lexicons/record_type.go new file mode 100644 index 0000000..32b6bc1 --- /dev/null +++ b/internal/lexicons/record_type.go @@ -0,0 +1,40 @@ +// Package lexicons defines types for Arabica's AT Protocol lexicon schemas. +package lexicons + +// RecordType represents the type of a record in the feed. +// Use these constants instead of magic strings for type safety. +type RecordType string + +const ( + RecordTypeBean RecordType = "bean" + RecordTypeBrew RecordType = "brew" + RecordTypeBrewer RecordType = "brewer" + RecordTypeGrinder RecordType = "grinder" + RecordTypeLike RecordType = "like" + RecordTypeRoaster RecordType = "roaster" +) + +// String returns the string representation of the RecordType. +func (r RecordType) String() string { + return string(r) +} + +// DisplayName returns a human-readable name for the RecordType. +func (r RecordType) DisplayName() string { + switch r { + case RecordTypeBean: + return "Bean" + case RecordTypeBrew: + return "Brew" + case RecordTypeBrewer: + return "Brewer" + case RecordTypeGrinder: + return "Grinder" + case RecordTypeLike: + return "Like" + case RecordTypeRoaster: + return "Roaster" + default: + return string(r) + } +} diff --git a/internal/lexicons/record_type_test.go b/internal/lexicons/record_type_test.go new file mode 100644 index 0000000..9a58072 --- /dev/null +++ b/internal/lexicons/record_type_test.go @@ -0,0 +1,48 @@ +package lexicons + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRecordTypeString(t *testing.T) { + tests := []struct { + rt RecordType + expected string + }{ + {RecordTypeBean, "bean"}, + {RecordTypeBrew, "brew"}, + {RecordTypeBrewer, "brewer"}, + {RecordTypeGrinder, "grinder"}, + {RecordTypeLike, "like"}, + {RecordTypeRoaster, "roaster"}, + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + assert.Equal(t, tt.expected, tt.rt.String()) + }) + } +} + +func TestRecordTypeDisplayName(t *testing.T) { + tests := []struct { + rt RecordType + expected string + }{ + {RecordTypeBean, "Bean"}, + {RecordTypeBrew, "Brew"}, + {RecordTypeBrewer, "Brewer"}, + {RecordTypeGrinder, "Grinder"}, + {RecordTypeLike, "Like"}, + {RecordTypeRoaster, "Roaster"}, + {RecordType("unknown"), "unknown"}, // Fallback to string value + } + + for _, tt := range tests { + t.Run(string(tt.rt), func(t *testing.T) { + assert.Equal(t, tt.expected, tt.rt.DisplayName()) + }) + } +} diff --git a/internal/models/models.go b/internal/models/models.go index 1aecc62..c31f6eb 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -182,6 +182,21 @@ type UpdateBrewerRequest struct { Description string `json:"description"` } +// Like represents a like on an Arabica record +type Like struct { + RKey string `json:"rkey"` + SubjectURI string `json:"subject_uri"` + SubjectCID string `json:"subject_cid"` + CreatedAt time.Time `json:"created_at"` + ActorDID string `json:"actor_did,omitempty"` +} + +// CreateLikeRequest contains the data needed to create a like +type CreateLikeRequest struct { + SubjectURI string `json:"subject_uri"` + SubjectCID string `json:"subject_cid"` +} + // Validate checks that all fields are within acceptable limits func (r *CreateBeanRequest) Validate() error { if r.Name == "" { diff --git a/internal/routing/routing.go b/internal/routing/routing.go index c1d44e0..c046ddd 100644 --- a/internal/routing/routing.go +++ b/internal/routing/routing.go @@ -81,6 +81,8 @@ func SetupRouter(cfg Config) http.Handler { mux.Handle("PUT /api/brewers/{id}", cop.Handler(http.HandlerFunc(h.HandleBrewerUpdate))) mux.Handle("DELETE /api/brewers/{id}", cop.Handler(http.HandlerFunc(h.HandleBrewerDelete))) + mux.Handle("POST /api/likes/toggle", cop.Handler(http.HandlerFunc(h.HandleLikeToggle))) + // Modal routes for entity management (return dialog HTML) mux.HandleFunc("GET /api/modals/bean/new", h.HandleBeanModalNew) mux.HandleFunc("GET /api/modals/bean/{id}", h.HandleBeanModalEdit) @@ -99,6 +101,7 @@ func SetupRouter(cfg Config) http.Handler { mux.Handle("GET /static/", http.StripPrefix("/static/", fs)) // Serve favicon.ico for pdsls mux.HandleFunc("GET /favicon.ico", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/x-icon") http.ServeFile(w, r, "static/favicon.ico") }) diff --git a/internal/web/bff/helpers_test.go b/internal/web/bff/helpers_test.go index 440f895..e5f4e11 100644 --- a/internal/web/bff/helpers_test.go +++ b/internal/web/bff/helpers_test.go @@ -4,6 +4,7 @@ import ( "testing" "arabica/internal/models" + "github.com/stretchr/testify/assert" ) func TestFormatTemp(t *testing.T) { @@ -25,9 +26,7 @@ func TestFormatTemp(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := FormatTemp(tt.temp) - if got != tt.expected { - t.Errorf("FormatTemp(%v) = %q, want %q", tt.temp, got, tt.expected) - } + assert.Equal(t, tt.expected, got) }) } } @@ -47,9 +46,7 @@ func TestFormatTempValue(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := FormatTempValue(tt.temp) - if got != tt.expected { - t.Errorf("FormatTempValue(%v) = %q, want %q", tt.temp, got, tt.expected) - } + assert.Equal(t, tt.expected, got) }) } } @@ -72,9 +69,7 @@ func TestFormatTime(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := FormatTime(tt.seconds) - if got != tt.expected { - t.Errorf("FormatTime(%v) = %q, want %q", tt.seconds, got, tt.expected) - } + assert.Equal(t, tt.expected, got) }) } } @@ -94,9 +89,7 @@ func TestFormatRating(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := FormatRating(tt.rating) - if got != tt.expected { - t.Errorf("FormatRating(%v) = %q, want %q", tt.rating, got, tt.expected) - } + assert.Equal(t, tt.expected, got) }) } } @@ -115,9 +108,7 @@ func TestFormatID(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := FormatID(tt.id) - if got != tt.expected { - t.Errorf("FormatID(%v) = %q, want %q", tt.id, got, tt.expected) - } + assert.Equal(t, tt.expected, got) }) } } @@ -136,9 +127,7 @@ func TestFormatInt(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := FormatInt(tt.val) - if got != tt.expected { - t.Errorf("FormatInt(%v) = %q, want %q", tt.val, got, tt.expected) - } + assert.Equal(t, tt.expected, got) }) } } @@ -146,25 +135,19 @@ func TestFormatInt(t *testing.T) { func TestFormatRoasterID(t *testing.T) { t.Run("nil returns null", func(t *testing.T) { got := FormatRoasterID(nil) - if got != "null" { - t.Errorf("FormatRoasterID(nil) = %q, want %q", got, "null") - } + assert.Equal(t, "null", got) }) t.Run("valid pointer", func(t *testing.T) { id := 123 got := FormatRoasterID(&id) - if got != "123" { - t.Errorf("FormatRoasterID(&123) = %q, want %q", got, "123") - } + assert.Equal(t, "123", got) }) t.Run("zero pointer", func(t *testing.T) { id := 0 got := FormatRoasterID(&id) - if got != "0" { - t.Errorf("FormatRoasterID(&0) = %q, want %q", got, "0") - } + assert.Equal(t, "0", got) }) } @@ -212,9 +195,7 @@ func TestPoursToJSON(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := PoursToJSON(tt.pours) - if got != tt.expected { - t.Errorf("PoursToJSON() = %q, want %q", got, tt.expected) - } + assert.Equal(t, tt.expected, got) }) } } @@ -222,85 +203,61 @@ func TestPoursToJSON(t *testing.T) { func TestPtr(t *testing.T) { t.Run("int", func(t *testing.T) { p := Ptr(42) - if *p != 42 { - t.Errorf("Ptr(42) = %v, want 42", *p) - } + assert.Equal(t, 42, *p) }) t.Run("string", func(t *testing.T) { p := Ptr("hello") - if *p != "hello" { - t.Errorf("Ptr(\"hello\") = %v, want \"hello\"", *p) - } + assert.Equal(t, "hello", *p) }) t.Run("zero value", func(t *testing.T) { p := Ptr(0) - if *p != 0 { - t.Errorf("Ptr(0) = %v, want 0", *p) - } + assert.Equal(t, 0, *p) }) } func TestPtrEquals(t *testing.T) { t.Run("nil pointer returns false", func(t *testing.T) { var p *int = nil - if PtrEquals(p, 42) { - t.Error("PtrEquals(nil, 42) should be false") - } + assert.False(t, PtrEquals(p, 42)) }) t.Run("matching value returns true", func(t *testing.T) { val := 42 - if !PtrEquals(&val, 42) { - t.Error("PtrEquals(&42, 42) should be true") - } + assert.True(t, PtrEquals(&val, 42)) }) t.Run("non-matching value returns false", func(t *testing.T) { val := 42 - if PtrEquals(&val, 99) { - t.Error("PtrEquals(&42, 99) should be false") - } + assert.False(t, PtrEquals(&val, 99)) }) t.Run("string comparison", func(t *testing.T) { s := "hello" - if !PtrEquals(&s, "hello") { - t.Error("PtrEquals(&\"hello\", \"hello\") should be true") - } - if PtrEquals(&s, "world") { - t.Error("PtrEquals(&\"hello\", \"world\") should be false") - } + assert.True(t, PtrEquals(&s, "hello")) + assert.False(t, PtrEquals(&s, "world")) }) } func TestPtrValue(t *testing.T) { t.Run("nil int returns zero", func(t *testing.T) { var p *int = nil - if PtrValue(p) != 0 { - t.Errorf("PtrValue(nil) = %v, want 0", PtrValue(p)) - } + assert.Equal(t, 0, PtrValue(p)) }) t.Run("valid int returns value", func(t *testing.T) { val := 42 - if PtrValue(&val) != 42 { - t.Errorf("PtrValue(&42) = %v, want 42", PtrValue(&val)) - } + assert.Equal(t, 42, PtrValue(&val)) }) t.Run("nil string returns empty", func(t *testing.T) { var p *string = nil - if PtrValue(p) != "" { - t.Errorf("PtrValue(nil string) = %v, want \"\"", PtrValue(p)) - } + assert.Equal(t, "", PtrValue(p)) }) t.Run("valid string returns value", func(t *testing.T) { s := "hello" - if PtrValue(&s) != "hello" { - t.Errorf("PtrValue(&\"hello\") = %v, want \"hello\"", PtrValue(&s)) - } + assert.Equal(t, "hello", PtrValue(&s)) }) } diff --git a/internal/web/components/buttons.templ b/internal/web/components/buttons.templ index 5c482d4..7ec7bce 100644 --- a/internal/web/components/buttons.templ +++ b/internal/web/components/buttons.templ @@ -1,5 +1,7 @@ package components +import "fmt" + // ButtonProps defines common button properties type ButtonProps struct { Text string @@ -52,9 +54,72 @@ templ BackButton() { class="inline-flex items-center text-brown-700 hover:text-brown-900 font-medium transition-colors cursor-pointer" aria-label="Go back" > - + } + +// ShareButtonProps defines properties for the share button +type ShareButtonProps struct { + URL string // URL to share + Title string // Title for native share dialog + Text string // Text for native share dialog +} + +// ShareButton renders a share button using Web Share API with clipboard fallback +templ ShareButton(props ShareButtonProps) { + Copied! + +} + +// LikeButtonProps defines properties for the like button +type LikeButtonProps struct { + SubjectURI string // AT-URI of the record being liked + SubjectCID string // CID of the record being liked + IsLiked bool // Whether the current user has liked this record + LikeCount int // Number of likes on this record + IsAuthenticated bool // Whether the user is authenticated +} + +// LikeButton renders a like button with count, using HTMX for toggle behavior +// TODO: When unauthenticated user clicks, show a login prompt instead of doing nothing +templ LikeButton(props LikeButtonProps) { + +} diff --git a/internal/web/components/shared.templ b/internal/web/components/shared.templ index d0db939..f091c95 100644 --- a/internal/web/components/shared.templ +++ b/internal/web/components/shared.templ @@ -117,7 +117,6 @@ templ WelcomeCard(props WelcomeCardProps) { templ WelcomeAuthenticated(userDID string) {
- // TODO: maybe add a parenthetical here with "what is this?" linking to the atproto page

Logged in as: { userDID } (What is this?) diff --git a/internal/web/components/social_buttons.templ b/internal/web/components/social_buttons.templ new file mode 100644 index 0000000..d4c763e --- /dev/null +++ b/internal/web/components/social_buttons.templ @@ -0,0 +1,41 @@ +package components + +// SocialButtonsProps defines properties for the social buttons cluster +type SocialButtonsProps struct { + // Like button props + SubjectURI string // AT-URI of the record being liked + SubjectCID string // CID of the record being liked + IsLiked bool // Whether the current user has liked this record + LikeCount int // Number of likes on this record + + // Share button props + ShareURL string // URL to share + ShareTitle string // Title for native share dialog + ShareText string // Text for native share dialog + + // Display options + ShowLike bool // Whether to show the like button (requires authentication) + IsAuthenticated bool // Whether the user is authenticated (controls click behavior) +} + +// SocialButtons renders a cluster of social interaction buttons (like, share) +templ SocialButtons(props SocialButtonsProps) { +

+ if props.ShowLike && props.SubjectURI != "" && props.SubjectCID != "" { + @LikeButton(LikeButtonProps{ + SubjectURI: props.SubjectURI, + SubjectCID: props.SubjectCID, + IsLiked: props.IsLiked, + LikeCount: props.LikeCount, + IsAuthenticated: props.IsAuthenticated, + }) + } + if props.ShareURL != "" { + @ShareButton(ShareButtonProps{ + URL: props.ShareURL, + Title: props.ShareTitle, + Text: props.ShareText, + }) + } +
+} diff --git a/internal/web/pages/about_test.go b/internal/web/pages/about_test.go index b098094..7111a16 100644 --- a/internal/web/pages/about_test.go +++ b/internal/web/pages/about_test.go @@ -5,8 +5,9 @@ import ( "arabica/internal/web/components" "bytes" "context" - "strings" "testing" + + "github.com/stretchr/testify/assert" ) func TestAboutComponent(t *testing.T) { @@ -21,9 +22,7 @@ func TestAboutComponent(t *testing.T) { // Render the component var buf bytes.Buffer err := About(data).Render(context.Background(), &buf) - if err != nil { - t.Fatalf("Failed to render About component: %v", err) - } + assert.NoError(t, err) html := buf.String() @@ -42,9 +41,7 @@ func TestAboutComponent(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if !strings.Contains(html, tt.content) { - t.Errorf("Expected HTML to contain %q, but it was not found", tt.content) - } + assert.Contains(t, html, tt.content) }) } } @@ -65,18 +62,11 @@ func TestAboutComponentAuthenticated(t *testing.T) { // Render the component var buf bytes.Buffer err := About(data).Render(context.Background(), &buf) - if err != nil { - t.Fatalf("Failed to render About component: %v", err) - } + assert.NoError(t, err) html := buf.String() // When authenticated, should show "Log Your Next Brew" instead of "Get Started" - if !strings.Contains(html, "Log Your Next Brew") { - t.Error("Expected authenticated view to show 'Log Your Next Brew' button") - } - - if strings.Contains(html, "Get Started") { - t.Error("Expected authenticated view NOT to show 'Get Started' button") - } + assert.Contains(t, html, "Back to Home") + assert.NotContains(t, html, "Get Started") } diff --git a/internal/web/pages/brew_form.templ b/internal/web/pages/brew_form.templ index 6136c6a..0db1e15 100644 --- a/internal/web/pages/brew_form.templ +++ b/internal/web/pages/brew_form.templ @@ -182,7 +182,7 @@ templ GrinderSelectField(props BrewFormProps) { hx-get="/api/modals/grinder/new" hx-target="#modal-container" hx-swap="innerHTML" - class="btn-secondary" + class="btn-secondary" > + New @@ -240,7 +240,7 @@ templ BrewerSelectField(props BrewFormProps) { hx-get="/api/modals/brewer/new" hx-target="#modal-container" hx-swap="innerHTML" - class="btn-secondary" + class="btn-secondary" > + New diff --git a/internal/web/pages/brew_view.templ b/internal/web/pages/brew_view.templ index e4556d4..ab6f255 100644 --- a/internal/web/pages/brew_view.templ +++ b/internal/web/pages/brew_view.templ @@ -9,8 +9,14 @@ import ( // BrewViewProps defines the data for the brew view page type BrewViewProps struct { - Brew *models.Brew - IsOwnProfile bool + Brew *models.Brew + IsOwnProfile bool + IsAuthenticated bool + SubjectURI string // AT-URI of the brew (for like button) + SubjectCID string // CID of the brew (for like button) + IsLiked bool // Whether the current user has liked this brew + LikeCount int // Number of likes on this brew + ShareURL string // URL for sharing the brew } // BrewView renders the full brew view page @@ -40,7 +46,22 @@ templ BrewViewCard(props BrewViewProps) { if props.Brew.TastingNotes != "" { @BrewTastingNotes(props.Brew.TastingNotes) } - @components.BackButton() +
+ @components.BackButton() +
+ @components.SocialButtons(components.SocialButtonsProps{ + SubjectURI: props.SubjectURI, + SubjectCID: props.SubjectCID, + IsLiked: props.IsLiked, + LikeCount: props.LikeCount, + ShareURL: props.ShareURL, + ShareTitle: getBrewShareTitle(props.Brew), + ShareText: "Check out this brew on Arabica", + ShowLike: props.IsAuthenticated, + IsAuthenticated: props.IsAuthenticated, + }) +
+
} @@ -116,11 +137,11 @@ templ BrewBeanSection(brew *models.Brew) { // BrewParametersGrid renders the brew parameters in a grid templ BrewParametersGrid(brew *models.Brew) {
+ @BrewParameter("Coffee", getCoffeeAmountDisplay(brew)) @BrewParameter("Brew Method", getBrewerName(brew)) @BrewParameter("Grinder", getGrinderName(brew)) - @BrewParameter("Coffee", getCoffeeAmountDisplay(brew)) - @BrewParameter("Water", getWaterAmountDisplay(brew)) @BrewParameter("Grind Size", getGrindSizeDisplay(brew)) + @BrewParameter("Water", getWaterAmountDisplay(brew)) @BrewParameter("Temperature", getTemperatureDisplay(brew))
@BrewParameter("Brew Time", getBrewTimeDisplay(brew)) @@ -169,6 +190,16 @@ func getWaterAmountDisplay(brew *models.Brew) string { if brew.WaterAmount > 0 { return fmt.Sprintf("%dg", brew.WaterAmount) } + // If water amount not set, sum from pours + if len(brew.Pours) > 0 { + totalWater := 0 + for _, pour := range brew.Pours { + totalWater += pour.WaterAmount + } + if totalWater > 0 { + return fmt.Sprintf("%dg", totalWater) + } + } return "" } @@ -190,6 +221,16 @@ func getBrewTimeDisplay(brew *models.Brew) string { return "" } +func getBrewShareTitle(brew *models.Brew) string { + if brew.Bean != nil { + if brew.Bean.Name != "" { + return brew.Bean.Name + } + return brew.Bean.Origin + } + return "Coffee Brew" +} + // BrewPoursSection renders the pours section templ BrewPoursSection(pours []*models.Pour) {
@@ -199,7 +240,8 @@ templ BrewPoursSection(pours []*models.Pour) {
{ fmt.Sprintf("%dg", pour.WaterAmount) } - { "@ " + bff.FormatTime(pour.TimeSeconds) } + // TODO: add a setting to allow users to configure "at" vs "for" in pours display here + { "for " + bff.FormatTime(pour.TimeSeconds) }
} diff --git a/internal/web/pages/components_test.go b/internal/web/pages/components_test.go index 9830656..52e04b2 100644 --- a/internal/web/pages/components_test.go +++ b/internal/web/pages/components_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/a-h/templ" + "github.com/stretchr/testify/assert" ) // TestButtonComponents tests button component rendering @@ -21,15 +22,9 @@ func TestButtonComponents(t *testing.T) { html := renderToString(t, ctx, components.PrimaryButton(props)) - if !strings.Contains(html, "btn-primary") { - t.Error("Expected btn-primary class") - } - if !strings.Contains(html, "Click Me") { - t.Error("Expected button text 'Click Me'") - } - if !strings.Contains(html, `type="submit"`) { - t.Error("Expected type=submit") - } + assert.Contains(t, html, "btn-primary") + assert.Contains(t, html, "Click Me") + assert.Contains(t, html, `type="submit"`) }) t.Run("SecondaryButton", func(t *testing.T) { @@ -39,29 +34,17 @@ func TestButtonComponents(t *testing.T) { html := renderToString(t, ctx, components.SecondaryButton(props)) - if !strings.Contains(html, "btn-secondary") { - t.Error("Expected btn-secondary class") - } - if !strings.Contains(html, "Cancel") { - t.Error("Expected button text 'Cancel'") - } + assert.Contains(t, html, "btn-secondary") + assert.Contains(t, html, "Cancel") }) t.Run("BackButton", func(t *testing.T) { html := renderToString(t, ctx, components.BackButton()) - if !strings.Contains(html, `type="button"`) { - t.Error("Expected type=button to prevent form submission") - } - if !strings.Contains(html, `@click="history.back()"`) { - t.Error("Expected Alpine.js click handler for back button") - } - if !strings.Contains(html, " if len(items) > 0 { for _, item := range items { - @FeedCard(item) + @FeedCard(item, isAuthenticated) } } else {
@@ -24,7 +25,7 @@ templ FeedPartial(items []*feed.FeedItem, isAuthenticated bool) { } // FeedCard renders a single feed item card -templ FeedCard(item *feed.FeedItem) { +templ FeedCard(item *feed.FeedItem, isAuthenticated bool) {
@@ -44,6 +45,22 @@ templ FeedCard(item *feed.FeedItem) {
{ item.TimeAgo }
+ + if item.SubjectURI != "" && item.SubjectCID != "" { +
+ @components.SocialButtons(components.SocialButtonsProps{ + SubjectURI: item.SubjectURI, + SubjectCID: item.SubjectCID, + IsLiked: item.IsLikedByViewer, + LikeCount: item.LikeCount, + ShareURL: getFeedItemShareURL(item), + ShareTitle: getFeedItemShareTitle(item), + ShareText: getFeedItemShareText(item), + ShowLike: true, + IsAuthenticated: isAuthenticated, + }) +
+ }
@@ -51,15 +68,15 @@ templ FeedCard(item *feed.FeedItem) {
switch item.RecordType { - case "brew": + case lexicons.RecordTypeBrew: @FeedBrewContent(item) - case "bean": + case lexicons.RecordTypeBean: @FeedBeanContent(item) - case "roaster": + case lexicons.RecordTypeRoaster: @FeedRoasterContent(item) - case "grinder": + case lexicons.RecordTypeGrinder: @FeedGrinderContent(item) - case "brewer": + case lexicons.RecordTypeBrewer: @FeedBrewerContent(item) }
@@ -83,7 +100,7 @@ func getDisplayName(displayName *string) string { // ActionText renders the action text with clickable links for record names templ ActionText(item *feed.FeedItem) { switch item.RecordType { - case "brew": + case lexicons.RecordTypeBrew: if item.Brew != nil { added a 🌱 { item.Brew.Bean.Process } } - if item.Brew.CoffeeAmount > 0 { - ⚖️ { fmt.Sprintf("%dg", item.Brew.CoffeeAmount) } - } + if item.Brew.CoffeeAmount > 0 { + ⚖️ { fmt.Sprintf("%dg", item.Brew.CoffeeAmount) } + }
}
@@ -173,15 +190,15 @@ templ FeedBrewContent(item *feed.FeedItem) { if len(item.Brew.Pours) > 0 {
Pours: - for _, pour := range item.Brew.Pours { -
• { fmt.Sprintf("%dg @ %s", pour.WaterAmount, bff.FormatTime(pour.TimeSeconds)) }
- } + for _, pour := range item.Brew.Pours { +
• { fmt.Sprintf("%dg @ %s", pour.WaterAmount, bff.FormatTime(pour.TimeSeconds)) }
+ }
- } else if item.Brew.WaterAmount > 0 { -
- Water: { fmt.Sprintf("%dg", item.Brew.WaterAmount) } -
- } + } else if item.Brew.WaterAmount > 0 { +
+ Water: { fmt.Sprintf("%dg", item.Brew.WaterAmount) } +
+ } if bff.HasTemp(item.Brew.Temperature) {
Temp: { bff.FormatTemp(item.Brew.Temperature) } @@ -215,7 +232,7 @@ templ FeedBeanContent(item *feed.FeedItem) { } if item.Bean.Roaster != nil && item.Bean.Roaster.Name != "" { - from { item.Bean.Roaster.Name } + from { item.Bean.Roaster.Name } }
@@ -296,3 +313,60 @@ templ FeedBrewerContent(item *feed.FeedItem) {
} } + +// Helper functions for share button +func getFeedItemShareURL(item *feed.FeedItem) string { + switch item.RecordType { + case lexicons.RecordTypeBrew: + if item.Brew != nil { + return fmt.Sprintf("/brews/%s?owner=%s", item.Brew.RKey, item.Author.Handle) + } + } + // For other record types, link to the user's profile + return fmt.Sprintf("/profile/%s", item.Author.Handle) +} + +func getFeedItemShareTitle(item *feed.FeedItem) string { + switch item.RecordType { + case lexicons.RecordTypeBrew: + if item.Brew != nil && item.Brew.Bean != nil { + if item.Brew.Bean.Name != "" { + return item.Brew.Bean.Name + } + return item.Brew.Bean.Origin + } + return "Coffee Brew" + case lexicons.RecordTypeBean: + if item.Bean != nil { + if item.Bean.Name != "" { + return item.Bean.Name + } + return item.Bean.Origin + } + return "Coffee Bean" + case lexicons.RecordTypeRoaster: + if item.Roaster != nil { + return item.Roaster.Name + } + return "Roaster" + case lexicons.RecordTypeGrinder: + if item.Grinder != nil { + return item.Grinder.Name + } + return "Grinder" + case lexicons.RecordTypeBrewer: + if item.Brewer != nil { + return item.Brewer.Name + } + return "Brewer" + } + return "Arabica" +} + +func getFeedItemShareText(item *feed.FeedItem) string { + displayName := item.Author.Handle + if item.Author.DisplayName != nil && *item.Author.DisplayName != "" { + displayName = *item.Author.DisplayName + } + return fmt.Sprintf("Check out this %s by %s on Arabica", item.RecordType, displayName) +} diff --git a/lexicons/social.arabica.alpha.like.json b/lexicons/social.arabica.alpha.like.json new file mode 100644 index 0000000..694d1f8 --- /dev/null +++ b/lexicons/social.arabica.alpha.like.json @@ -0,0 +1,27 @@ +{ + "lexicon": 1, + "id": "social.arabica.alpha.like", + "defs": { + "main": { + "type": "record", + "key": "tid", + "description": "A like on an Arabica record (brew, bean, roaster, grinder, or brewer)", + "record": { + "type": "object", + "required": ["subject", "createdAt"], + "properties": { + "subject": { + "type": "ref", + "ref": "com.atproto.repo.strongRef", + "description": "The AT-URI and CID of the record being liked" + }, + "createdAt": { + "type": "string", + "format": "datetime", + "description": "Timestamp when the like was created" + } + } + } + } + } +} diff --git a/static/css/app.css b/static/css/app.css index f7f9425..aa8a7ed 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -198,6 +198,26 @@ .link-bold { @apply font-medium text-brown-700 hover:text-brown-900 hover:underline transition-colors; } + + /* Like Button */ + .like-btn { + @apply inline-flex items-center justify-center gap-1.5 px-2.5 py-1 rounded-md text-sm font-medium transition-colors; + } + + .like-btn-liked { + @apply like-btn bg-brown-100 text-red-600 hover:bg-brown-200; + animation: like-pop 400ms ease-out; + } + + .like-btn-unliked { + @apply like-btn bg-brown-100 text-brown-600 hover:bg-brown-200; + animation: like-shrink 200ms ease-out; + } + + /* Share Button */ + .share-btn { + @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; + } } /* ======================================== @@ -404,6 +424,44 @@ body.htmx-history-restoring .htmx-transitioning { } } +/* Like button pop animation */ +@keyframes like-pop { + 0% { + transform: scale(1); + } + 15% { + transform: scale(1.3); + } + 30% { + transform: scale(0.9); + } + 45% { + transform: scale(1.15); + } + 60% { + transform: scale(0.95); + } + 75% { + transform: scale(1.05); + } + 100% { + transform: scale(1); + } +} + +/* Like button shrink animation for unlike */ +@keyframes like-shrink { + 0% { + transform: scale(1); + } + 50% { + transform: scale(0.8); + } + 100% { + transform: scale(1); + } +} + /* ======================================== Loading States ======================================== */ diff --git a/static/service-worker.js b/static/service-worker.js index d60c1b7..68ab5d7 100644 --- a/static/service-worker.js +++ b/static/service-worker.js @@ -1,36 +1,33 @@ -const CACHE_NAME = 'arabica-v1'; +const CACHE_NAME = "arabica-v1"; const urlsToCache = [ - '/', - '/static/css/style.css', - 'https://unpkg.com/htmx.org@1.9.10', - 'https://unpkg.com/alpinejs@3.13.3/dist/cdn.min.js' + "/", + "/static/css/output.css", + "/static/js/alpine.min.js", + "/static/js/htmx.min.js", ]; // Install service worker and cache resources -self.addEventListener('install', (event) => { +self.addEventListener("install", (event) => { event.waitUntil( - caches.open(CACHE_NAME) - .then((cache) => cache.addAll(urlsToCache)) + caches.open(CACHE_NAME).then((cache) => cache.addAll(urlsToCache)), ); }); // Fetch from cache, fallback to network -self.addEventListener('fetch', (event) => { +self.addEventListener("fetch", (event) => { event.respondWith( - caches.match(event.request) - .then((response) => { - // Cache hit - return response - if (response) { - return response; - } - return fetch(event.request); + caches.match(event.request).then((response) => { + // Cache hit - return response + if (response) { + return response; } - ) + return fetch(event.request); + }), ); }); // Update service worker -self.addEventListener('activate', (event) => { +self.addEventListener("activate", (event) => { const cacheWhitelist = [CACHE_NAME]; event.waitUntil( caches.keys().then((cacheNames) => { @@ -39,8 +36,8 @@ self.addEventListener('activate', (event) => { if (cacheWhitelist.indexOf(cacheName) === -1) { return caches.delete(cacheName); } - }) + }), ); - }) + }), ); });