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

feat: wip: card styling refactor

pdewey.com af46f7c4 b0f30efb

verified
+731 -43
+6 -1
.cells/cells.jsonl
··· 42 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 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 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"} 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":"completed","priority":"high","assignee":"patrick","labels":["frontend","design","ux"],"created_at":"2026-02-03T01:29:39.841071728Z","updated_at":"2026-02-03T01:40:12.593195795Z","completed_at":"2026-02-03T01:40:12.57909568Z","notes":[{"timestamp":"2026-02-03T01:39:17.359358581Z","author":"patrick","message":"## Final Design Approved\n\n### Card Structure\n- Reuse feed cards as-is with author header (username/avatar)\n- Content area clickable → navigates to /brews/{id}\n- New action bar below content (outside record div)\n\n### Action Bar Order\n[💬 Comments] [♡ Like] [↗ Share] [⋯ More]\n\n- Comments: placeholder/disabled for future\n- Like/Share: moved from top-right to action bar\n- More menu (shows for ALL users):\n - Edit (owner only)\n - Delete (owner only) \n - Report (all users - placeholder endpoint + TODO)"}]} 46 + {"id":"01KGGJF66PJV9SSKCTGJP4ETWP","title":"Create ActionBar component for feed/profile cards","description":"## Task\n\nCreate a new ActionBar component for use below feed card content.\n\n## Component: ActionBar\n\n**Location:** `internal/web/components/action_bar.templ`\n\n**Props:**\n- SubjectURI string - AT-URI of the record\n- SubjectCID string - CID for strong reference\n- IsLiked bool - current user liked state\n- LikeCount int - number of likes\n- IsOwner bool - whether current user owns this record\n- ViewURL string - URL for the detail view (card click)\n- EditURL string - URL for edit action (owner only)\n- DeleteURL string - URL/handler for delete (owner only)\n- IsAuthenticated bool - whether user is logged in\n\n**Layout:**\n`[💬 Comments] [♡ Like] [↗ Share] [⋯ More]`\n\n- Comments: disabled placeholder icon + '0' count\n- Like: reuse existing like button logic (HTMX toggle)\n- Share: reuse existing share button logic\n- More: Alpine.js dropdown menu with Edit/Delete/Report\n\n**Styling:**\n- Horizontal flex row with gap\n- Icons with counts where applicable\n- More menu as dropdown positioned bottom-right\n\n## Acceptance Criteria\n- [ ] ActionBar component created with all props\n- [ ] Comments placeholder (disabled, shows 0)\n- [ ] Like button functional (HTMX toggle)\n- [ ] Share button functional (Web Share API)\n- [ ] More dropdown with Edit/Delete (owner) and Report (all)\n- [ ] templ generate runs successfully","status":"completed","priority":"high","assignee":"patrick","labels":["frontend","component"],"created_at":"2026-02-03T01:39:29.110143411Z","updated_at":"2026-02-03T01:43:15.859354984Z","completed_at":"2026-02-03T01:43:15.845204627Z"} 47 + {"id":"01KGGJFDK0S6VBVY51HJWDS6CF","title":"Update feed cards to use ActionBar","description":"## Task\n\nUpdate the feed page to use the new ActionBar component instead of inline social buttons.\n\n## Changes to feed.templ\n\n1. Remove social buttons from author row (top-right)\n2. Make content area clickable (link to /brews/{rkey} or appropriate detail URL)\n3. Add ActionBar below the content div\n4. Pass appropriate props (SubjectURI, SubjectCID, like state, ownership, URLs)\n\n## Card Click Behavior\n\n- Wrap .feed-content-box in an anchor tag\n- Link to brew detail: /brews/{rkey}\n- For other record types (beans, equipment): link to appropriate manage section or skip\n\n## Acceptance Criteria\n- [ ] Social buttons removed from author row\n- [ ] Content area clickable for brews\n- [ ] ActionBar rendered below content\n- [ ] Like/Share still functional via ActionBar\n- [ ] More menu shows Edit/Delete for owner, Report for all\n- [ ] templ generate runs successfully\n- [ ] Visual regression check on feed page","status":"completed","priority":"high","assignee":"patrick","labels":["frontend"],"created_at":"2026-02-03T01:39:36.672372228Z","updated_at":"2026-02-03T01:44:45.86254562Z","completed_at":"2026-02-03T01:44:45.851397847Z"} 48 + {"id":"01KGGJFSZ4YDNF6ZE8F9WRT79F","title":"Replace profile brew table with cards","description":"## Task\n\nReplace the table-based brew list on the profile page with card-based layout matching the feed.\n\n## Changes\n\n### profile.templ / profile_partial.templ\n1. Replace BrewListTablePartial with card-based layout\n2. Reuse FeedItem/FeedBrewContent components or create ProfileBrewCard\n3. Include ActionBar with owner actions enabled\n4. Maintain grid/list layout for multiple brews\n\n### Layout Considerations\n- Cards should stack vertically (like feed)\n- Consider max-width for readability\n- Mobile: single column\n- Desktop: could be single column or 2-column grid\n\n### Props to Pass\n- Full brew data\n- Bean/roaster/grinder/brewer references\n- IsOwner: true (it's the user's profile)\n- Like/share state for each brew\n\n## Acceptance Criteria\n- [ ] Brew table replaced with cards on profile\n- [ ] Cards show all brew info (bean, method, variables, notes, rating)\n- [ ] ActionBar with Edit/Delete functional for owner\n- [ ] Cards link to brew detail view\n- [ ] Mobile responsive\n- [ ] templ generate runs successfully","status":"completed","priority":"high","assignee":"patrick","labels":["frontend"],"created_at":"2026-02-03T01:39:49.348915829Z","updated_at":"2026-02-03T01:49:13.11140332Z","completed_at":"2026-02-03T01:49:13.098282125Z"} 49 + {"id":"01KGGJG2FDK2NN8H059PTHNME0","title":"Add placeholder report endpoint","description":"## Task\n\nAdd a placeholder report endpoint for the More menu Report action.\n\n## Endpoint\n\n**Route:** POST /api/report\n**Handler:** HandleReport\n\n## Request Body\n```json\n{\n \"subject_uri\": \"at://did:plc:xxx/social.arabica.alpha.brew/rkey\",\n \"subject_cid\": \"bafyrei...\",\n \"reason\": \"spam|inappropriate|other\"\n}\n```\n\n## Response\n- 200 OK with `{\"status\": \"received\"}`\n- Log the report for now\n- TODO comment indicating future implementation needs\n\n## Implementation Notes\n- Add route to routing.go\n- Create minimal handler that logs and returns success\n- No actual moderation system yet - just capture the intent\n\n## Acceptance Criteria\n- [ ] POST /api/report endpoint exists\n- [ ] Accepts subject_uri, subject_cid, reason\n- [ ] Logs report details\n- [ ] Returns success response\n- [ ] TODO comment for future moderation system","status":"completed","priority":"normal","assignee":"patrick","labels":["backend","api"],"created_at":"2026-02-03T01:39:58.061904095Z","updated_at":"2026-02-03T01:43:12.681518812Z","completed_at":"2026-02-03T01:43:12.667221097Z"} 50 + {"id":"01KGGK6V4WMC3X67E72JTE27A7","title":"Fix 'failed to convert record to feed item' warning for likes","description":"The firehose index logs a warning 'failed to convert record to feed item' when processing like records. This is expected behavior since likes are not displayed as standalone feed items - they're indexed for like counts but shouldn't appear in the feed.\n\nThe warning appears in internal/firehose/index.go:433 when recordToFeedItem returns an error for NSIDLike records (line 582-584).\n\nFix: Handle the like case silently without logging a warning, since this is expected behavior. Either:\n1. Skip like records before calling recordToFeedItem\n2. Return a special sentinel error from recordToFeedItem that indicates 'skip without warning'\n3. Check the collection type at the call site and skip likes there\n\nThis applies to both authenticated and unauthenticated users.","status":"completed","priority":"normal","labels":["bug"],"created_at":"2026-02-03T01:52:24.220349056Z","updated_at":"2026-02-03T01:54:54.698106017Z","completed_at":"2026-02-03T01:54:54.686934901Z"}
+3
internal/feed/service.go
··· 48 48 SubjectURI string // AT-URI of this record (for like button) 49 49 SubjectCID string // CID of this record (for like button) 50 50 IsLikedByViewer bool // Whether the current viewer has liked this record 51 + 52 + // Ownership 53 + IsOwner bool // Whether the current viewer owns this record 51 54 } 52 55 53 56 // publicFeedCache holds cached feed items for unauthenticated users
+121 -19
internal/handlers/handlers.go
··· 8 8 "sort" 9 9 "strconv" 10 10 "strings" 11 + "time" 11 12 12 13 "arabica/internal/atproto" 13 14 "arabica/internal/database" ··· 422 423 } 423 424 } 424 425 425 - // Populate IsLikedByViewer for each feed item if user is authenticated 426 - if isAuthenticated && h.feedIndex != nil { 426 + // Populate IsLikedByViewer and IsOwner for each feed item if user is authenticated 427 + if isAuthenticated { 427 428 for _, item := range feedItems { 428 - if item.SubjectURI != "" { 429 + // Check if viewer owns this record 430 + if item.Author != nil { 431 + item.IsOwner = item.Author.DID == viewerDID 432 + } 433 + // Check if viewer liked this record 434 + if h.feedIndex != nil && item.SubjectURI != "" { 429 435 item.IsLikedByViewer = h.feedIndex.HasUserLiked(viewerDID, item.SubjectURI) 430 436 } 431 437 } ··· 1962 1968 isAuthenticated := err == nil && didStr != "" 1963 1969 isOwnProfile := isAuthenticated && didStr == did 1964 1970 1965 - // Render profile content partial (use actor as handle, which is already the handle if provided as such) 1971 + // Get profile for card rendering 1972 + profile, err := publicClient.GetProfile(ctx, did) 1973 + if err != nil { 1974 + log.Warn().Err(err).Str("did", did).Msg("Failed to fetch profile for profile partial") 1975 + // Continue without profile - cards will show limited info 1976 + } 1977 + 1978 + // Use handle from profile or fallback 1966 1979 profileHandle := actor 1967 - if strings.HasPrefix(actor, "did:") { 1968 - // If actor was a DID, we need to resolve it to a handle 1969 - // We can get it from the first brew's author if available, or fetch profile 1970 - profile, err := publicClient.GetProfile(ctx, did) 1971 - if err == nil { 1972 - profileHandle = profile.Handle 1973 - } else { 1974 - profileHandle = did // Fallback to DID if we can't get handle 1980 + if profile != nil { 1981 + profileHandle = profile.Handle 1982 + } else if strings.HasPrefix(actor, "did:") { 1983 + profileHandle = did // Fallback to DID if we can't get handle 1984 + } 1985 + 1986 + // Get like counts and CIDs for brews from firehose index 1987 + brewLikeCounts := make(map[string]int) 1988 + brewLikedByUser := make(map[string]bool) 1989 + brewCIDs := make(map[string]string) 1990 + if h.feedIndex != nil && profile != nil { 1991 + for _, brew := range profileData.Brews { 1992 + subjectURI := atproto.BuildATURI(profile.DID, atproto.NSIDBrew, brew.RKey) 1993 + brewLikeCounts[brew.RKey] = h.feedIndex.GetLikeCount(subjectURI) 1994 + if isAuthenticated { 1995 + brewLikedByUser[brew.RKey] = h.feedIndex.HasUserLiked(didStr, subjectURI) 1996 + } 1997 + // Get CID from the firehose index record 1998 + if record, err := h.feedIndex.GetRecord(subjectURI); err == nil && record != nil { 1999 + brewCIDs[brew.RKey] = record.CID 2000 + } 1975 2001 } 1976 2002 } 1977 2003 1978 2004 if err := components.ProfileContentPartial(components.ProfileContentPartialProps{ 1979 - Brews: profileData.Brews, 1980 - Beans: profileData.Beans, 1981 - Roasters: profileData.Roasters, 1982 - Grinders: profileData.Grinders, 1983 - Brewers: profileData.Brewers, 1984 - IsOwnProfile: isOwnProfile, 1985 - ProfileHandle: profileHandle, 2005 + Brews: profileData.Brews, 2006 + Beans: profileData.Beans, 2007 + Roasters: profileData.Roasters, 2008 + Grinders: profileData.Grinders, 2009 + Brewers: profileData.Brewers, 2010 + IsOwnProfile: isOwnProfile, 2011 + ProfileHandle: profileHandle, 2012 + Profile: profile, 2013 + BrewLikeCounts: brewLikeCounts, 2014 + BrewLikedByUser: brewLikedByUser, 2015 + BrewCIDs: brewCIDs, 2016 + IsAuthenticated: isAuthenticated, 1986 2017 }).Render(r.Context(), w); err != nil { 1987 2018 http.Error(w, "Failed to render content", http.StatusInternalServerError) 1988 2019 log.Error().Err(err).Msg("Failed to render profile partial") ··· 2208 2239 log.Error().Err(err).Msg("Failed to render 404 page") 2209 2240 } 2210 2241 } 2242 + 2243 + // HandleReport handles content report submissions 2244 + // 2245 + // TODO: Implement actual moderation system: 2246 + // - Store reports in database (BoltDB bucket or SQLite table) 2247 + // - Add admin interface to review reports 2248 + // - Implement report status workflow (pending -> reviewed -> dismissed/actioned) 2249 + // 2250 + // TODO: Reports should be rate limited more strictly by IP than other requests. 2251 + // Consider implementing a separate, stricter rate limit for this endpoint 2252 + // (e.g., 5 reports per hour per IP) to prevent abuse and report flooding. 2253 + func (h *Handler) HandleReport(w http.ResponseWriter, r *http.Request) { 2254 + if err := r.ParseForm(); err != nil { 2255 + http.Error(w, "Invalid form data", http.StatusBadRequest) 2256 + return 2257 + } 2258 + 2259 + subjectURI := r.FormValue("subject_uri") 2260 + subjectCID := r.FormValue("subject_cid") 2261 + reason := r.FormValue("reason") 2262 + 2263 + if subjectURI == "" { 2264 + http.Error(w, "subject_uri is required", http.StatusBadRequest) 2265 + return 2266 + } 2267 + 2268 + // Validate reason 2269 + validReasons := map[string]bool{"spam": true, "inappropriate": true, "other": true} 2270 + if reason == "" || !validReasons[reason] { 2271 + reason = "other" 2272 + } 2273 + 2274 + // Get reporter info if authenticated 2275 + reporterDID := "anonymous" 2276 + if didStr, err := atproto.GetAuthenticatedDID(r.Context()); err == nil && didStr != "" { 2277 + reporterDID = didStr 2278 + } 2279 + 2280 + // Get reporter IP for rate limiting tracking 2281 + reporterIP := r.RemoteAddr 2282 + if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { 2283 + // Use first IP in chain (original client) 2284 + reporterIP = strings.Split(forwarded, ",")[0] 2285 + } 2286 + 2287 + // Create report record (not persisted yet - just for structured logging) 2288 + report := &models.Report{ 2289 + SubjectURI: subjectURI, 2290 + SubjectCID: subjectCID, 2291 + Reason: reason, 2292 + ReporterDID: reporterDID, 2293 + ReporterIP: reporterIP, 2294 + CreatedAt: time.Now(), 2295 + Status: "pending", 2296 + } 2297 + 2298 + // TODO: Persist report to database 2299 + // For now, log the report for manual review 2300 + log.Info(). 2301 + Str("subject_uri", report.SubjectURI). 2302 + Str("subject_cid", report.SubjectCID). 2303 + Str("reason", report.Reason). 2304 + Str("reporter_did", report.ReporterDID). 2305 + Str("reporter_ip", report.ReporterIP). 2306 + Time("created_at", report.CreatedAt). 2307 + Msg("Content report received") 2308 + 2309 + w.Header().Set("Content-Type", "application/json") 2310 + w.WriteHeader(http.StatusOK) 2311 + w.Write([]byte(`{"status": "received"}`)) 2312 + }
+13
internal/models/models.go
··· 350 350 } 351 351 return nil 352 352 } 353 + 354 + // Report represents a user-submitted content report 355 + // TODO: Store reports in database (BoltDB or SQLite) for moderation review 356 + type Report struct { 357 + ID string `json:"id"` 358 + SubjectURI string `json:"subject_uri"` // AT-URI of the reported content 359 + SubjectCID string `json:"subject_cid"` // CID of the reported content 360 + Reason string `json:"reason"` // spam, inappropriate, other 361 + ReporterDID string `json:"reporter_did"` // DID of reporter, or "anonymous" 362 + ReporterIP string `json:"reporter_ip"` // IP address for rate limiting 363 + CreatedAt time.Time `json:"created_at"` 364 + Status string `json:"status"` // pending, reviewed, dismissed, actioned 365 + }
+1
internal/routing/routing.go
··· 82 82 mux.Handle("DELETE /api/brewers/{id}", cop.Handler(http.HandlerFunc(h.HandleBrewerDelete))) 83 83 84 84 mux.Handle("POST /api/likes/toggle", cop.Handler(http.HandlerFunc(h.HandleLikeToggle))) 85 + mux.Handle("POST /api/report", cop.Handler(http.HandlerFunc(h.HandleReport))) 85 86 86 87 // Modal routes for entity management (return dialog HTML) 87 88 mux.HandleFunc("GET /api/modals/bean/new", h.HandleBeanModalNew)
+47
internal/web/bff/helpers.go
··· 8 8 "fmt" 9 9 "net/url" 10 10 "strings" 11 + "time" 11 12 ) 12 13 13 14 // UserProfile contains user profile data for header display ··· 270 271 } 271 272 return dict, nil 272 273 } 274 + 275 + // FormatTimeAgo returns a human-readable relative time string 276 + func FormatTimeAgo(t time.Time) string { 277 + now := time.Now() 278 + diff := now.Sub(t) 279 + 280 + switch { 281 + case diff < time.Minute: 282 + return "just now" 283 + case diff < time.Hour: 284 + mins := int(diff.Minutes()) 285 + if mins == 1 { 286 + return "1 minute ago" 287 + } 288 + return fmt.Sprintf("%d minutes ago", mins) 289 + case diff < 24*time.Hour: 290 + hours := int(diff.Hours()) 291 + if hours == 1 { 292 + return "1 hour ago" 293 + } 294 + return fmt.Sprintf("%d hours ago", hours) 295 + case diff < 48*time.Hour: 296 + return "yesterday" 297 + case diff < 7*24*time.Hour: 298 + days := int(diff.Hours() / 24) 299 + return fmt.Sprintf("%d days ago", days) 300 + case diff < 30*24*time.Hour: 301 + weeks := int(diff.Hours() / 24 / 7) 302 + if weeks == 1 { 303 + return "1 week ago" 304 + } 305 + return fmt.Sprintf("%d weeks ago", weeks) 306 + case diff < 365*24*time.Hour: 307 + months := int(diff.Hours() / 24 / 30) 308 + if months == 1 { 309 + return "1 month ago" 310 + } 311 + return fmt.Sprintf("%d months ago", months) 312 + default: 313 + years := int(diff.Hours() / 24 / 365) 314 + if years == 1 { 315 + return "1 year ago" 316 + } 317 + return fmt.Sprintf("%d years ago", years) 318 + } 319 + }
+146
internal/web/components/action_bar.templ
··· 1 + package components 2 + 3 + import "fmt" 4 + 5 + // ActionBarProps defines properties for the action bar below feed/profile cards 6 + type ActionBarProps struct { 7 + // Subject reference for likes 8 + SubjectURI string 9 + SubjectCID string 10 + 11 + // Like state 12 + IsLiked bool 13 + LikeCount int 14 + 15 + // Share props 16 + ShareURL string 17 + ShareTitle string 18 + ShareText string 19 + 20 + // Ownership and actions 21 + IsOwner bool 22 + EditURL string 23 + DeleteURL string 24 + 25 + // Auth state 26 + IsAuthenticated bool 27 + } 28 + 29 + // ActionBar renders the action bar with Comments, Like, Share, and More menu 30 + // Order: [💬 Comments] [♡ Like] [↗ Share] [⋯ More] 31 + templ ActionBar(props ActionBarProps) { 32 + <div class="action-bar" x-data="{ moreOpen: false }"> 33 + <!-- Comments (placeholder) --> 34 + <button 35 + type="button" 36 + class="action-btn action-btn-disabled" 37 + disabled 38 + title="Comments coming soon" 39 + > 40 + <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 41 + <path stroke-linecap="round" stroke-linejoin="round" d="M8.625 12a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z"></path> 42 + </svg> 43 + <span>0</span> 44 + </button> 45 + <!-- Like --> 46 + if props.SubjectURI != "" && props.SubjectCID != "" { 47 + @LikeButton(LikeButtonProps{ 48 + SubjectURI: props.SubjectURI, 49 + SubjectCID: props.SubjectCID, 50 + IsLiked: props.IsLiked, 51 + LikeCount: props.LikeCount, 52 + IsAuthenticated: props.IsAuthenticated, 53 + }) 54 + } 55 + <!-- Share --> 56 + if props.ShareURL != "" { 57 + @ActionBarShareButton(props) 58 + } 59 + <!-- More menu --> 60 + <div class="relative"> 61 + <button 62 + type="button" 63 + @click="moreOpen = !moreOpen" 64 + @click.away="moreOpen = false" 65 + class="action-btn" 66 + aria-label="More options" 67 + > 68 + <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 69 + <path stroke-linecap="round" stroke-linejoin="round" d="M6.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM12.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM18.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z"></path> 70 + </svg> 71 + </button> 72 + <!-- Dropdown menu --> 73 + <div 74 + x-show="moreOpen" 75 + x-transition:enter="transition ease-out duration-100" 76 + x-transition:enter-start="transform opacity-0 scale-95" 77 + x-transition:enter-end="transform opacity-100 scale-100" 78 + x-transition:leave="transition ease-in duration-75" 79 + x-transition:leave-start="transform opacity-100 scale-100" 80 + x-transition:leave-end="transform opacity-0 scale-95" 81 + class="action-menu" 82 + x-cloak 83 + > 84 + if props.IsOwner { 85 + if props.EditURL != "" { 86 + <a href={ templ.SafeURL(props.EditURL) } class="action-menu-item"> 87 + <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 88 + <path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125"></path> 89 + </svg> 90 + Edit 91 + </a> 92 + } 93 + if props.DeleteURL != "" { 94 + <button 95 + type="button" 96 + hx-delete={ props.DeleteURL } 97 + hx-confirm="Are you sure you want to delete this?" 98 + hx-target="closest .feed-card" 99 + hx-swap="outerHTML" 100 + class="action-menu-item action-menu-item-danger" 101 + > 102 + <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 103 + <path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"></path> 104 + </svg> 105 + Delete 106 + </button> 107 + } 108 + <div class="action-menu-divider"></div> 109 + } 110 + <!-- Report (available to all users) --> 111 + <button 112 + type="button" 113 + hx-post="/api/report" 114 + hx-vals={ fmt.Sprintf(`{"subject_uri": "%s", "subject_cid": "%s", "reason": "inappropriate"}`, props.SubjectURI, props.SubjectCID) } 115 + hx-swap="none" 116 + @click="moreOpen = false; $dispatch('notify', {message: 'Report submitted'})" 117 + class="action-menu-item" 118 + > 119 + <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 120 + <path stroke-linecap="round" stroke-linejoin="round" d="M3 3v1.5M3 21v-6m0 0 2.77-.693a9 9 0 0 1 6.208.682l.108.054a9 9 0 0 0 6.086.71l3.114-.732a48.524 48.524 0 0 1-.005-10.499l-3.11.732a9 9 0 0 1-6.085-.711l-.108-.054a9 9 0 0 0-6.208-.682L3 4.5M3 15V4.5"></path> 121 + </svg> 122 + Report 123 + </button> 124 + </div> 125 + </div> 126 + </div> 127 + } 128 + 129 + // ActionBarShareButton renders the share button for the action bar 130 + templ ActionBarShareButton(props ActionBarProps) { 131 + <button 132 + type="button" 133 + x-data={ fmt.Sprintf("{ copied: false, share() { const fullUrl = window.location.origin + '%s'; if (navigator.share) { navigator.share({ title: '%s', text: '%s', url: fullUrl }).catch(() => {}); } else { navigator.clipboard.writeText(fullUrl).then(() => { this.copied = true; setTimeout(() => this.copied = false, 2000); }); } } }", props.ShareURL, props.ShareTitle, props.ShareText) } 134 + @click="share()" 135 + class="action-btn" 136 + aria-label="Share" 137 + > 138 + <svg x-show="!copied" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 139 + <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> 140 + </svg> 141 + <svg x-show="copied" x-cloak class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 142 + <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5"></path> 143 + </svg> 144 + <span x-show="copied" x-cloak>Copied!</span> 145 + </button> 146 + }
+1 -1
internal/web/components/layout.templ
··· 26 26 <link rel="icon" href="/static/favicon.svg" type="image/svg+xml"/> 27 27 <link rel="icon" href="/static/favicon-32.svg" type="image/svg+xml" sizes="32x32"/> 28 28 <link rel="apple-touch-icon" href="/static/icon-192.svg"/> 29 - <link rel="stylesheet" href="/static/css/output.css?v=0.4.2"/> 29 + <link rel="stylesheet" href="/static/css/output.css?v=0.5.0"/> 30 30 <style> 31 31 [x-cloak] { display: none !important; } 32 32 </style>
+225
internal/web/components/profile_brew_card.templ
··· 1 + package components 2 + 3 + import ( 4 + "arabica/internal/atproto" 5 + "arabica/internal/models" 6 + "arabica/internal/web/bff" 7 + "fmt" 8 + ) 9 + 10 + // ProfileBrewCardProps defines props for a single brew card on profile 11 + type ProfileBrewCardProps struct { 12 + Brew *models.Brew 13 + IsOwnProfile bool 14 + ProfileHandle string 15 + Profile *atproto.Profile 16 + LikeCount int 17 + IsLiked bool 18 + IsAuthenticated bool 19 + SubjectCID string // CID for like functionality (empty if not available) 20 + } 21 + 22 + // ProfileBrewCard renders a single brew as a feed-style card 23 + templ ProfileBrewCard(props ProfileBrewCardProps) { 24 + <div class="feed-card"> 25 + <!-- Author row --> 26 + <div class="flex items-center gap-3 mb-3"> 27 + <a href={ templ.SafeURL("/profile/" + props.ProfileHandle) } class="flex-shrink-0"> 28 + @Avatar(AvatarProps{ 29 + AvatarURL: getProfileAvatarURL(props.Profile), 30 + DisplayName: getProfileDisplayName(props.Profile), 31 + Size: "md", 32 + }) 33 + </a> 34 + <div class="flex-1 min-w-0"> 35 + <div class="flex items-center gap-2"> 36 + if props.Profile != nil && props.Profile.DisplayName != nil && *props.Profile.DisplayName != "" { 37 + <a href={ templ.SafeURL("/profile/" + props.ProfileHandle) } class="link-bold text-brown-900 truncate">{ *props.Profile.DisplayName }</a> 38 + } 39 + <a href={ templ.SafeURL("/profile/" + props.ProfileHandle) } class="link text-sm truncate">{ "@" + props.ProfileHandle }</a> 40 + </div> 41 + <span class="text-brown-500 text-sm">{ bff.FormatTimeAgo(props.Brew.CreatedAt) }</span> 42 + </div> 43 + </div> 44 + <!-- Action text --> 45 + <div class="mb-2 text-sm text-brown-700"> 46 + added a 47 + <a 48 + href={ templ.SafeURL(fmt.Sprintf("/brews/%s?owner=%s", props.Brew.RKey, props.ProfileHandle)) } 49 + class="underline hover:text-brown-900" 50 + > 51 + new brew 52 + </a> 53 + </div> 54 + <!-- Brew content (clickable) --> 55 + <a 56 + href={ templ.SafeURL(fmt.Sprintf("/brews/%s?owner=%s", props.Brew.RKey, props.ProfileHandle)) } 57 + class="block hover:opacity-90 transition-opacity" 58 + > 59 + @ProfileBrewContent(props.Brew) 60 + </a> 61 + <!-- Action bar --> 62 + @ActionBar(ActionBarProps{ 63 + SubjectURI: buildBrewURI(props.Profile, props.Brew.RKey), 64 + SubjectCID: props.SubjectCID, 65 + IsLiked: props.IsLiked, 66 + LikeCount: props.LikeCount, 67 + ShareURL: fmt.Sprintf("/brews/%s?owner=%s", props.Brew.RKey, props.ProfileHandle), 68 + ShareTitle: getBrewShareTitle(props.Brew), 69 + ShareText: getBrewShareText(props.Brew, props.ProfileHandle), 70 + IsOwner: props.IsOwnProfile, 71 + EditURL: getBrewEditURL(props.Brew.RKey, props.IsOwnProfile), 72 + DeleteURL: getBrewDeleteURL(props.Brew.RKey, props.IsOwnProfile), 73 + IsAuthenticated: props.IsAuthenticated, 74 + }) 75 + </div> 76 + } 77 + 78 + func getProfileAvatarURL(profile *atproto.Profile) string { 79 + if profile != nil && profile.Avatar != nil { 80 + return *profile.Avatar 81 + } 82 + return "" 83 + } 84 + 85 + func getProfileDisplayName(profile *atproto.Profile) string { 86 + if profile != nil && profile.DisplayName != nil { 87 + return *profile.DisplayName 88 + } 89 + return "" 90 + } 91 + 92 + func buildBrewURI(profile *atproto.Profile, rkey string) string { 93 + if profile == nil { 94 + return "" 95 + } 96 + return fmt.Sprintf("at://%s/social.arabica.alpha.brew/%s", profile.DID, rkey) 97 + } 98 + 99 + func getBrewShareTitle(brew *models.Brew) string { 100 + if brew.Bean != nil { 101 + if brew.Bean.Name != "" { 102 + return brew.Bean.Name 103 + } 104 + return brew.Bean.Origin 105 + } 106 + return "Coffee Brew" 107 + } 108 + 109 + func getBrewShareText(brew *models.Brew, handle string) string { 110 + return fmt.Sprintf("Check out this brew by @%s on Arabica", handle) 111 + } 112 + 113 + func getBrewEditURL(rkey string, isOwner bool) string { 114 + if !isOwner { 115 + return "" 116 + } 117 + return fmt.Sprintf("/brews/%s/edit", rkey) 118 + } 119 + 120 + func getBrewDeleteURL(rkey string, isOwner bool) string { 121 + if !isOwner { 122 + return "" 123 + } 124 + return fmt.Sprintf("/brews/%s", rkey) 125 + } 126 + 127 + // ProfileBrewContent renders the brew details inside the card 128 + templ ProfileBrewContent(brew *models.Brew) { 129 + <div class="feed-content-box"> 130 + <!-- Bean info with rating --> 131 + <div class="flex items-start justify-between gap-3 mb-3"> 132 + <div class="flex-1 min-w-0"> 133 + if brew.Bean != nil { 134 + <div class="font-bold text-brown-900 text-base"> 135 + if brew.Bean.Name != "" { 136 + { brew.Bean.Name } 137 + } else { 138 + { brew.Bean.Origin } 139 + } 140 + </div> 141 + if brew.Bean.Roaster != nil && brew.Bean.Roaster.Name != "" { 142 + <div class="text-sm text-brown-700 mt-0.5"> 143 + <span class="font-medium">🏭 { brew.Bean.Roaster.Name }</span> 144 + </div> 145 + } 146 + <div class="text-xs text-brown-600 mt-1 flex flex-wrap gap-x-2 gap-y-0.5"> 147 + if brew.Bean.Origin != "" { 148 + <span class="inline-flex items-center gap-0.5">📍 { brew.Bean.Origin }</span> 149 + } 150 + if brew.Bean.RoastLevel != "" { 151 + <span class="inline-flex items-center gap-0.5">🔥 { brew.Bean.RoastLevel }</span> 152 + } 153 + if brew.Bean.Process != "" { 154 + <span class="inline-flex items-center gap-0.5">🌱 { brew.Bean.Process }</span> 155 + } 156 + if brew.CoffeeAmount > 0 { 157 + <span class="inline-flex items-center gap-0.5">⚖️ { fmt.Sprintf("%dg", brew.CoffeeAmount) }</span> 158 + } 159 + </div> 160 + } 161 + </div> 162 + if brew.Rating > 0 { 163 + <span class="badge-rating"> 164 + ⭐ { fmt.Sprintf("%d/10", brew.Rating) } 165 + </span> 166 + } 167 + </div> 168 + <!-- Brewer --> 169 + if brew.BrewerObj != nil || brew.Method != "" { 170 + <div class="mb-2"> 171 + <span class="text-meta">Brewer:</span> 172 + <span class="text-sm font-semibold text-brown-900"> 173 + if brew.BrewerObj != nil { 174 + { brew.BrewerObj.Name } 175 + } else if brew.Method != "" { 176 + { brew.Method } 177 + } 178 + </span> 179 + </div> 180 + } 181 + <!-- Brew parameters in compact grid --> 182 + <div class="grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-brown-700"> 183 + if brew.GrinderObj != nil { 184 + <div> 185 + <span class="text-label">Grinder:</span> 186 + { " " }{ brew.GrinderObj.Name } 187 + if brew.GrindSize != "" { 188 + ({ brew.GrindSize }) 189 + } 190 + </div> 191 + } else if brew.GrindSize != "" { 192 + <div> 193 + <span class="text-label">Grind:</span> { brew.GrindSize } 194 + </div> 195 + } 196 + if len(brew.Pours) > 0 { 197 + <div class="col-span-2"> 198 + <span class="text-label">Pours:</span> 199 + for _, pour := range brew.Pours { 200 + <div class="pl-2 text-brown-600">• { fmt.Sprintf("%dg @ %s", pour.WaterAmount, bff.FormatTime(pour.TimeSeconds)) }</div> 201 + } 202 + </div> 203 + } else if brew.WaterAmount > 0 { 204 + <div> 205 + <span class="text-label">Water:</span> { fmt.Sprintf("%dg", brew.WaterAmount) } 206 + </div> 207 + } 208 + if bff.HasTemp(brew.Temperature) { 209 + <div> 210 + <span class="text-label">Temp:</span> { bff.FormatTemp(brew.Temperature) } 211 + </div> 212 + } 213 + if brew.TimeSeconds > 0 { 214 + <div> 215 + <span class="text-label">Time:</span> { bff.FormatTime(brew.TimeSeconds) } 216 + </div> 217 + } 218 + </div> 219 + if brew.TastingNotes != "" { 220 + <div class="mt-3 text-sm text-brown-800 italic border-t border-brown-200 pt-2"> 221 + "{ brew.TastingNotes }" 222 + </div> 223 + } 224 + </div> 225 + }
+81 -4
internal/web/components/profile_partial.templ
··· 1 1 package components 2 2 3 3 import ( 4 + "arabica/internal/atproto" 4 5 "arabica/internal/models" 5 6 "strconv" 6 7 ) ··· 14 15 Brewers []*models.Brewer 15 16 IsOwnProfile bool 16 17 ProfileHandle string 18 + Profile *atproto.Profile 19 + // Like state for brews (keyed by brew RKey) 20 + BrewLikeCounts map[string]int 21 + BrewLikedByUser map[string]bool 22 + BrewCIDs map[string]string // CIDs keyed by brew RKey 23 + IsAuthenticated bool 17 24 } 18 25 19 26 // ProfileContentPartial renders the profile tabs content (for HTMX loading) ··· 22 29 <div id="profile-stats-data" class="hidden" data-brews={ strconv.Itoa(len(props.Brews)) } data-beans={ strconv.Itoa(len(props.Beans)) } data-roasters={ strconv.Itoa(len(props.Roasters)) } data-grinders={ strconv.Itoa(len(props.Grinders)) } data-brewers={ strconv.Itoa(len(props.Brewers)) }></div> 23 30 <!-- Brews Tab --> 24 31 <div x-show="activeTab === 'brews'"> 25 - @BrewListTablePartial(BrewListTableProps{ 26 - Brews: props.Brews, 27 - IsOwnProfile: props.IsOwnProfile, 28 - ProfileHandle: props.ProfileHandle, 32 + @ProfileBrewCards(ProfileBrewCardsProps{ 33 + Brews: props.Brews, 34 + IsOwnProfile: props.IsOwnProfile, 35 + ProfileHandle: props.ProfileHandle, 36 + Profile: props.Profile, 37 + LikeCounts: props.BrewLikeCounts, 38 + LikedByUser: props.BrewLikedByUser, 39 + BrewCIDs: props.BrewCIDs, 40 + IsAuthenticated: props.IsAuthenticated, 29 41 }) 30 42 </div> 31 43 <!-- Beans Tab --> ··· 118 130 } 119 131 } 120 132 return closedBeans 133 + } 134 + 135 + // ProfileBrewCardsProps defines props for the profile brew cards component 136 + type ProfileBrewCardsProps struct { 137 + Brews []*models.Brew 138 + IsOwnProfile bool 139 + ProfileHandle string 140 + Profile *atproto.Profile 141 + LikeCounts map[string]int 142 + LikedByUser map[string]bool 143 + IsAuthenticated bool 144 + BrewCIDs map[string]string // CIDs keyed by brew RKey 145 + } 146 + 147 + // ProfileBrewCards renders brews as feed-style cards 148 + templ ProfileBrewCards(props ProfileBrewCardsProps) { 149 + if len(props.Brews) == 0 { 150 + if props.IsOwnProfile { 151 + @EmptyState(EmptyStateProps{ 152 + Message: "No brews recorded yet! Start tracking your coffee journey.", 153 + ActionURL: "/brews/new", 154 + ActionText: "Add Your First Brew", 155 + }) 156 + } else { 157 + @EmptyState(EmptyStateProps{ 158 + Message: "No brews yet.", 159 + }) 160 + } 161 + } else { 162 + <div class="space-y-4"> 163 + for _, brew := range props.Brews { 164 + @ProfileBrewCard(ProfileBrewCardProps{ 165 + Brew: brew, 166 + IsOwnProfile: props.IsOwnProfile, 167 + ProfileHandle: props.ProfileHandle, 168 + Profile: props.Profile, 169 + LikeCount: getLikeCount(props.LikeCounts, brew.RKey), 170 + IsLiked: isLikedByUser(props.LikedByUser, brew.RKey), 171 + IsAuthenticated: props.IsAuthenticated, 172 + SubjectCID: getBrewCID(props.BrewCIDs, brew.RKey), 173 + }) 174 + } 175 + </div> 176 + } 177 + } 178 + 179 + func getLikeCount(counts map[string]int, rkey string) int { 180 + if counts == nil { 181 + return 0 182 + } 183 + return counts[rkey] 184 + } 185 + 186 + func isLikedByUser(liked map[string]bool, rkey string) bool { 187 + if liked == nil { 188 + return false 189 + } 190 + return liked[rkey] 191 + } 192 + 193 + func getBrewCID(cids map[string]string, rkey string) string { 194 + if cids == nil { 195 + return "" 196 + } 197 + return cids[rkey] 121 198 } 122 199 123 200 // ProfileEquipmentTab renders the equipment tab for profile (grinders and brewers only)
+52 -18
internal/web/pages/feed.templ
··· 45 45 </div> 46 46 <span class="text-brown-500 text-sm">{ item.TimeAgo }</span> 47 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 - } 64 48 </div> 65 49 <!-- Action header --> 66 50 <div class="mb-2 text-sm text-brown-700"> 67 51 @ActionText(item) 68 52 </div> 69 - <!-- Record content --> 53 + <!-- Record content (clickable for brews) --> 70 54 switch item.RecordType { 71 55 case lexicons.RecordTypeBrew: 72 - @FeedBrewContent(item) 56 + @FeedBrewContentClickable(item) 73 57 case lexicons.RecordTypeBean: 74 58 @FeedBeanContent(item) 75 59 case lexicons.RecordTypeRoaster: ··· 79 63 case lexicons.RecordTypeBrewer: 80 64 @FeedBrewerContent(item) 81 65 } 66 + <!-- Action bar --> 67 + if item.SubjectURI != "" && item.SubjectCID != "" { 68 + @components.ActionBar(components.ActionBarProps{ 69 + SubjectURI: item.SubjectURI, 70 + SubjectCID: item.SubjectCID, 71 + IsLiked: item.IsLikedByViewer, 72 + LikeCount: item.LikeCount, 73 + ShareURL: getFeedItemShareURL(item), 74 + ShareTitle: getFeedItemShareTitle(item), 75 + ShareText: getFeedItemShareText(item), 76 + IsOwner: item.IsOwner, 77 + EditURL: getEditURL(item), 78 + DeleteURL: getDeleteURL(item), 79 + IsAuthenticated: isAuthenticated, 80 + }) 81 + } 82 82 </div> 83 83 } 84 84 ··· 114 114 } 115 115 default: 116 116 { item.Action } 117 + } 118 + } 119 + 120 + // FeedBrewContentClickable renders brew content wrapped in a clickable link 121 + templ FeedBrewContentClickable(item *feed.FeedItem) { 122 + if item.Brew != nil { 123 + <a 124 + href={ templ.SafeURL(fmt.Sprintf("/brews/%s?owner=%s", item.Brew.RKey, item.Author.Handle)) } 125 + class="block hover:opacity-90 transition-opacity" 126 + > 127 + @FeedBrewContent(item) 128 + </a> 117 129 } 118 130 } 119 131 ··· 370 382 } 371 383 return fmt.Sprintf("Check out this %s by %s on Arabica", item.RecordType, displayName) 372 384 } 385 + 386 + // getEditURL returns the edit URL for a feed item (only for brews currently) 387 + func getEditURL(item *feed.FeedItem) string { 388 + switch item.RecordType { 389 + case lexicons.RecordTypeBrew: 390 + if item.Brew != nil { 391 + return fmt.Sprintf("/brews/%s/edit", item.Brew.RKey) 392 + } 393 + } 394 + return "" 395 + } 396 + 397 + // getDeleteURL returns the delete URL for a feed item (only for brews currently) 398 + func getDeleteURL(item *feed.FeedItem) string { 399 + switch item.RecordType { 400 + case lexicons.RecordTypeBrew: 401 + if item.Brew != nil { 402 + return fmt.Sprintf("/brews/%s", item.Brew.RKey) 403 + } 404 + } 405 + return "" 406 + }
+35
static/css/app.css
··· 218 218 .share-btn { 219 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 220 } 221 + 222 + /* Action Bar */ 223 + .action-bar { 224 + @apply flex items-center gap-2 mt-3 pt-3 border-t border-brown-200; 225 + } 226 + 227 + .action-btn { 228 + @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 cursor-pointer; 229 + } 230 + 231 + .action-btn-liked { 232 + @apply text-red-600; 233 + animation: like-pop 400ms ease-out; 234 + } 235 + 236 + .action-btn-disabled { 237 + @apply opacity-50 cursor-not-allowed hover:bg-brown-100; 238 + } 239 + 240 + /* Action Menu (More dropdown) */ 241 + .action-menu { 242 + @apply absolute right-0 bottom-full mb-1 w-36 bg-white rounded-lg shadow-lg border border-brown-200 py-1 z-10; 243 + } 244 + 245 + .action-menu-item { 246 + @apply flex items-center gap-2 w-full px-3 py-2 text-sm text-brown-700 hover:bg-brown-100 transition-colors cursor-pointer text-left; 247 + } 248 + 249 + .action-menu-item-danger { 250 + @apply text-red-600 hover:bg-red-50; 251 + } 252 + 253 + .action-menu-divider { 254 + @apply border-t border-brown-200 my-1; 255 + } 221 256 } 222 257 223 258 /* ========================================