···36{"id":"01KGAYXR359TWZPS2AJF9ZB4S5","title":"Extract profile data fetching into reusable function","description":"Two handlers contain nearly identical ~100-line blocks for fetching user profile data:\n\n**Duplicated in:**\n1. HandleProfile (lines 1628-1733)\n2. HandleProfilePartial (lines 1875-1980)\n\nBoth do:\n- Create errgroup context\n- Fetch beans, roasters, grinders, brewers, brews in parallel\n- Parse records and build lookup maps\n- Resolve references between entities\n\n**Proposed refactoring:**\nExtract into a single helper function:\n\nfunc (h *Handler) fetchUserData(ctx context.Context, did string, publicClient *atproto.PublicClient) (*UserDataBundle, error)\n\nThis function would return a struct containing:\n- Beans (with roasters resolved)\n- Roasters\n- Grinders\n- Brewers\n- Brews (with all refs resolved)\n\nAcceptance criteria:\n- Create fetchUserData helper function\n- Refactor both handlers to use it\n- Reduce duplicated code by ~80-100 lines\n- All profile functionality works as before","status":"completed","priority":"low","created_at":"2026-01-31T21:21:42.501437644Z","updated_at":"2026-01-31T21:32:30.483213969Z","completed_at":"2026-01-31T21:32:30.468389045Z"}
37{"id":"01KGB1KDNNZZ7ZDA5NCK0T4E1G","title":"Create shared entity table components for profile and manage pages","description":"## Summary\nRefactor the profile and manage page tables to use shared components, reducing duplication and improving maintainability.\n\n## Current State\n- `manage_partial.templ` has tables for beans, roasters, grinders, brewers with Edit/Delete actions\n- `profile_partial.templ` has similar tables but without actions\n- Beans on profile are split into \"Open Bags\" and \"Closed Bags\" sections\n- Significant HTML/structure duplication between the two files\n\n## Proposed Solution\nCreate shared table components with configurable props:\n\n### Shared Components to Create\n1. **BeansTable** - shared beans table component\n2. **RoastersTable** - shared roasters table component \n3. **GrindersTable** - shared grinders table component\n4. **BrewersTable** - shared brewers table component\n\n### Props Pattern\nEach component should accept props that control behavior:\n\n```go\ntype EntityTableProps struct {\n Items []*models.Entity\n ShowActions bool // Whether to render Edit/Delete column\n // Other entity-specific options as needed\n}\n```\n\n### Beans Table Special Handling\nThe beans table has unique requirements:\n- Manage page: single table with Status column, shows all beans\n- Profile page: split into Open/Closed sections, no Status column\n\nOptions to handle this:\n1. **Single component with `ShowStatus` prop** - manage uses it, profile calls it twice with filtered lists\n2. **`GroupByStatus` prop** - component internally groups and renders sections\n3. Keep beans separate if complexity doesn't warrant sharing\n\nRecommend option 1 - simpler, profile already filters beans via `filterOpenBeans`/`filterClosedBeans`.\n\n### Actions Implementation\nWhen `ShowActions: true`:\n- Render action column header\n- Render Edit button with `hx-get=\"/api/modals/{entity}/{rkey}\"`\n- Render Delete button with `hx-delete=\"/api/{entities}/{rkey}\"`\n\n### Files to Modify\n- `internal/web/components/manage_partial.templ` - use shared components\n- `internal/web/components/profile_partial.templ` - use shared components\n- `internal/web/components/entity_tables.templ` (new) - shared table components\n\n### Acceptance Criteria\n- [ ] Shared table components created in `entity_tables.templ`\n- [ ] `manage_partial.templ` refactored to use shared components\n- [ ] `profile_partial.templ` refactored to use shared components\n- [ ] Actions can be enabled/disabled via props\n- [ ] No visual regressions on manage page\n- [ ] No visual regressions on profile page\n- [ ] `templ generate` runs successfully\n- [ ] Tests pass (if any)","status":"completed","priority":"normal","assignee":"patrick","labels":["frontend","refactoring"],"created_at":"2026-01-31T22:08:29.877786981Z","updated_at":"2026-01-31T22:16:42.669635082Z","completed_at":"2026-01-31T22:16:42.657711384Z"}
38{"id":"01KGB2WBMP539SFSKZCKJAYH0C","title":"Prevent line wrap between emoji and text in table headers","description":"Add whitespace-nowrap to table header cells to prevent the emoji and column name from wrapping onto separate lines.","status":"completed","priority":"normal","assignee":"patrick","labels":["frontend"],"created_at":"2026-01-31T22:30:51.286845962Z","updated_at":"2026-01-31T22:31:23.380048647Z","completed_at":"2026-01-31T22:31:23.369060496Z"}
39-{"id":"01KGBF5Q2KJ8PZNSMJPFFF31C8","title":"Add AT Protocol explanation page","description":"Create a page explaining what the AT Protocol is that the footer link redirects to instead of atproto.com.\n\nAcceptance criteria:\n- New /atproto page explaining the AT Protocol\n- Footer AT Protocol link redirects to /atproto instead of external site\n- Page matches site design system\n- Explains what AT Protocol is and how Arabica uses it\n- Links to atproto.com for those who want more info","status":"claimed","priority":"normal","assignee":"patrick","created_at":"2026-02-01T02:05:40.819263564Z","updated_at":"2026-02-01T02:10:19.29363426Z","notes":[{"timestamp":"2026-02-01T02:10:19.279321594Z","author":"patrick","message":"Initial implementation complete:\n- Created /atproto page with AT Protocol explanation\n- Updated footer link to point to /atproto instead of external site\n- Added route and handler\n- Page covers: PDS, DIDs, Lexicons, AT-URIs, how Arabica uses ATProto\n- Includes links to atproto.com for more info\nReady for review and final polish"}]}
40{"id":"01KGBM1YCGZRNTVCJDZFV3100R","title":"Move web/static to static directory","description":"Move web/static directory to top-level static directory and update all references across the codebase.\n\nFiles to update:\n- flake.nix (tailwindcss paths)\n- .gitignore (output.css path)\n- justfile (tailwindcss paths)\n- default.nix (tailwindcss paths)\n- .gitattributes (vendored JS paths)\n- internal/routing/routing.go (file server path)\n- deploy/Dockerfile (COPY command)\n- CLAUDE.md (documentation)\n\nAcceptance criteria:\n- web/static moved to static/\n- All references updated\n- Server still serves static files correctly\n- Tailwind CSS build still works","status":"completed","priority":"normal","assignee":"patrick","created_at":"2026-02-01T03:31:00.112467261Z","updated_at":"2026-02-01T03:32:59.754877212Z","completed_at":"2026-02-01T03:32:59.739794374Z"}
41-{"id":"01KGDXVE566WVDG8TC9DWPSYXF","title":"Implement likes for social interactions","description":"Add a like lexicon and implement like functionality across the app.\n\n## Lexicon: social.arabica.alpha.like\n\nThe like record should:\n- Use `com.atproto.repo.strongRef` for the subject (URI + CID for immutability)\n- Support liking any arabica.social lexicon type (beans, roasters, grinders, brewers, brews, and comments once implemented)\n- Include createdAt timestamp\n- Use TID as record key\n\n## Backend Implementation\n\n1. Create lexicon file: `lexicons/social.arabica.alpha.like.json`\n2. Add NSID constant in `internal/atproto/nsid.go`\n3. Add Like model in `internal/models/models.go`\n4. Add record conversion functions in `internal/atproto/records.go`\n5. Add Store interface methods: CreateLike, DeleteLike, GetLikesForSubject, GetUserLikes\n6. Implement in AtprotoStore\n7. Update firehose indexing to track likes\n\n## Frontend Implementation\n\n1. Add like button component\n2. Show like counts on brews/beans/etc in feed\n3. Update like counts in response to firehose events (real-time updates)\n4. Toggle like state based on current user's likes\n\n## Acceptance Criteria\n\n- Users can like any arabica.social record\n- Likes are stored in the user's PDS (actor-owned data)\n- Like counts display on records in the feed\n- Real-time like count updates via firehose\n- Optimistic UI updates for likes","status":"open","priority":"high","labels":["backend","frontend","atproto"],"created_at":"2026-02-02T01:00:41.510646368Z","updated_at":"2026-02-02T01:00:41.510646368Z"}
42{"id":"01KGDXVR86CB2H44DJHKN4Z9M0","title":"Implement comments for social interactions","description":"Add a comment lexicon and implement comment functionality across the app.\n\n## Lexicon: social.arabica.alpha.comment\n\nThe comment record should:\n- Use `com.atproto.repo.strongRef` for the subject (URI + CID for immutability)\n- Support commenting on any arabica.social lexicon type (beans, roasters, grinders, brewers, brews, and other comments)\n- Include text field (max 1000 chars / 300 graphemes as per AT Protocol conventions)\n- Include createdAt timestamp\n- Use TID as record key\n- NOTE: This cell implements flat comments only. Threaded/nested comments are handled in a separate cell.\n\n## Backend Implementation\n\n1. Create lexicon file: `lexicons/social.arabica.alpha.comment.json`\n2. Add NSID constant in `internal/atproto/nsid.go`\n3. Add Comment model in `internal/models/models.go`\n4. Add record conversion functions in `internal/atproto/records.go`\n5. Add Store interface methods: CreateComment, DeleteComment, GetCommentsForSubject, GetUserComments\n6. Implement in AtprotoStore\n7. Update firehose indexing to track comments\n\n## Frontend Implementation\n\n1. Add comment section component below brew detail view\n2. Add comment form (authenticated users only)\n3. Display comment count on records in feed\n4. Update comment counts in response to firehose events\n5. Show commenter profile info (avatar, handle)\n\n## Acceptance Criteria\n\n- Users can comment on any arabica.social record\n- Comments are stored in the user's PDS (actor-owned data)\n- Comment counts display on records in the feed\n- Comments visible on record detail views\n- Real-time comment count updates via firehose","status":"open","priority":"normal","labels":["backend","frontend","atproto"],"created_at":"2026-02-02T01:00:51.846427126Z","updated_at":"2026-02-02T01:00:51.846427126Z"}
43{"id":"01KGDXW31PHFMBE3WTV83HPET1","title":"Implement comment threading","description":"Add support for threaded/nested comments, building on the flat comment system.\n\n## Lexicon Changes\n\nUpdate `social.arabica.alpha.comment` to add optional threading fields:\n- `parent`: Optional strongRef to parent comment (for replies)\n- `root`: Optional strongRef to root subject (maintains context when replying to comments)\n\nAlternatively, consider a separate reply field or keeping comments flat with UI-level threading.\n\n## Backend Implementation\n\n1. Update comment model to include parent/root references\n2. Add methods to fetch comment threads: GetCommentThread, GetReplies\n3. Update firehose indexing to track parent-child relationships\n4. Add depth limits for threading (prevent infinite nesting)\n\n## Frontend Implementation\n\n1. Design threaded comment UI (indentation, collapse/expand)\n2. Add 'reply' button on comments\n3. Show reply context when replying\n4. Consider max nesting depth for display (e.g., 3-4 levels)\n5. Mobile-friendly thread navigation\n\n## Design Considerations\n\n- How deep should threading go? (Recommend max 3-4 levels visible, then flatten)\n- How to handle deleted parent comments?\n- Should users be notified when someone replies to their comment?\n- Performance: lazy-load deep threads vs eager-load\n\n## Acceptance Criteria\n\n- Users can reply directly to comments\n- Thread structure is visually clear\n- Threads can be collapsed/expanded\n- Works well on mobile devices\n- Parent context shown when replying","status":"blocked","priority":"low","blocked_by":["01KGDXVR86CB2H44DJHKN4Z9M0"],"labels":["frontend","atproto"],"created_at":"2026-02-02T01:01:02.902692829Z","updated_at":"2026-02-02T01:01:06.361891137Z"}
44{"id":"01KGGG8EHG9Y6VQ8PZM9W74584","title":"Refactor lexicon record types to use concrete RecordType","description":"Replace magic strings for lexicon record types with a concrete RecordType type and defined constants.\n\n## Current State\nMagic strings like \"brew\", \"bean\", \"roaster\", \"grinder\", \"brewer\" are used throughout the codebase:\n- `internal/feed/service.go` - FeedItem.RecordType field uses string type\n- `internal/firehose/index.go` - Sets RecordType to string literals\n- `internal/web/pages/feed.templ` - Switch cases on string values\n- `internal/handlers/handlers.go` - writeJSON calls with string type names\n\n## Proposed Changes\n1. Define a `RecordType` type in `internal/atproto/nsid.go` (or a new file)\n2. Create constants: `RecordTypeBrew`, `RecordTypeBean`, `RecordTypeRoaster`, `RecordTypeGrinder`, `RecordTypeBrewer`, `RecordTypeLike`\n3. Update `FeedItem.RecordType` in `feed/service.go` to use the new type\n4. Update all string literal usages to use the constants\n5. Consider adding helper methods (e.g., `RecordType.String()`, `RecordType.DisplayName()`)\n\n## Acceptance Criteria\n- No magic strings for record types remain in Go code\n- Template switch statements updated to use constants\n- Type safety enforced at compile time\n- All tests pass","status":"completed","priority":"normal","labels":["refactoring","tech-debt"],"created_at":"2026-02-03T01:00:51.120049807Z","updated_at":"2026-02-03T01:06:55.448414863Z","completed_at":"2026-02-03T01:06:55.435337779Z"}
···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"}
51{"id":"01KGGNPG25M2AJ619P65FPMEH4","title":"Add OpenGraph metadata to brew pages","description":"Add dynamic OpenGraph metadata support for brew pages to enable rich social sharing previews.\n\n## Background\n\nCurrently, the site uses static OpenGraph tags in layout.templ:\n- og:title: \"Arabica - Coffee Brew Tracker\" (static)\n- og:description: \"Track your coffee brewing journey...\" (static) \n- og:type: \"website\" (static)\n- og:image: NOT PRESENT\n\nWhen users share brew links on social media, all previews look identical and provide no context about the specific brew.\n\n## Requirements\n\n### 1. Extend LayoutData struct\n\nAdd optional OpenGraph fields to `internal/web/components/layout.templ`:\n\n```go\ntype LayoutData struct {\n Title string\n IsAuthenticated bool\n UserDID string\n UserProfile *bff.UserProfile\n CSPNonce string\n \n // OpenGraph metadata (optional, falls back to defaults)\n OGTitle string\n OGDescription string\n OGImage string\n OGType string // \"website\", \"article\"\n OGUrl string // Canonical URL\n}\n```\n\n### 2. Update layout.templ head section\n\nModify meta tag rendering to use dynamic values with fallbacks:\n- If OGTitle set, use it; otherwise use site default\n- If OGDescription set, use it; otherwise use site default\n- If OGImage set, render og:image tag (currently missing entirely)\n- If OGUrl set, render og:url tag\n- Support og:type (default \"website\", brew pages use \"article\")\n\n### 3. Update buildLayoutData helper\n\nExtend `handlers.go` buildLayoutData() to accept optional OG parameters, or create a new helper `buildLayoutDataWithOG()`.\n\n### 4. Update HandleBrewView handler\n\nConstruct descriptive OG metadata from brew data:\n- **og:title**: \"{Bean Name} from {Origin} - Arabica\" or similar\n- **og:description**: \"{Rating}/10 - {Tasting Notes preview}\" or brew summary\n- **og:type**: \"article\"\n- **og:url**: The share URL (already computed as `shareURL`)\n- **og:image**: Static default for now (e.g., /static/og-brew-default.png)\n\nAvailable data for description construction:\n- brew.Rating, brew.TastingNotes\n- brew.Bean.Name, brew.Bean.Origin, brew.Bean.RoastLevel\n- brew.Bean.Roaster.Name\n- brew.CoffeeAmount, brew.WaterAmount, brew.Temperature\n\n### 5. Add Twitter Card support\n\nAdd Twitter-specific meta tags:\n- twitter:card = \"summary\" (or \"summary_large_image\" if we have images)\n- twitter:title, twitter:description (same as OG values)\n\n### 6. Static fallback image\n\nCreate or source a default OG image for brews:\n- Location: /static/og-brew-default.png or similar\n- Size: 1200x630px (recommended OG image size)\n- Design: Coffee-themed, includes Arabica branding\n\n## Acceptance Criteria\n\n- [ ] LayoutData extended with optional OG fields\n- [ ] layout.templ renders dynamic OG tags with fallbacks\n- [ ] Brew view pages include descriptive OG metadata\n- [ ] Twitter Card tags included\n- [ ] Static default OG image created and served\n- [ ] Existing pages (home, about, etc.) continue working with defaults\n- [ ] templ generate runs successfully\n- [ ] Manual testing: share brew URL to social platform preview tool\n\n## Implementation Notes\n\n- Follow existing patterns in handlers.go for data flow\n- Keep backwards compatible - pages not setting OG fields should use defaults\n- Consider creating a helper function to build OG description from brew data\n- The shareURL is already constructed in HandleBrewView (lines 695-701)\n\n## Future Enhancements (out of scope)\n\n- Dynamic image generation showing brew stats\n- Profile page OG metadata\n- Feed page OG metadata with recent brew preview","status":"completed","priority":"normal","assignee":"patrick","labels":["frontend","social"],"created_at":"2026-02-03T02:35:54.309356038Z","updated_at":"2026-02-03T02:53:43.170537883Z","completed_at":"2026-02-03T02:53:43.15817599Z","notes":[{"timestamp":"2026-02-03T02:53:36.100130152Z","author":"patrick","message":"Implementation complete:\n- Extended LayoutData with OG fields (OGTitle, OGDescription, OGImage, OGType, OGUrl)\n- Added helper methods ogTitle(), ogDescription(), ogType() with fallbacks\n- Updated layout.templ to render dynamic OG and Twitter Card meta tags\n- Added populateBrewOGMetadata() helper in handlers.go\n- Updated HandleBrewView to populate OG metadata from brew data\n- Added PublicURL to handler Config for absolute URLs\n- Added comprehensive tests for OG metadata generation\n- All tests pass"}]}
52{"id":"01KGM9QB86N5RX50042DT1YN1N","title":"Consolidate Tailwind CSS usage","description":"Reduce Tailwind class duplication by leveraging existing CSS abstractions and adding missing utility classes.\n\n## Changes Required\n\n### 1. Add new utility classes to app.css\n- `.page-container` variants (sm, md, lg, xl) for max-width containers\n- `.avatar-sm`, `.avatar-md`, `.avatar-lg` size classes\n- `.section-box` for bg-brown-50 rounded sections\n\n### 2. Update templ components to use existing abstractions\n- WelcomeCard (shared.templ) - use `.card` instead of inline gradient\n- ProfileHeader, ProfileStat, ProfileTabs (profile.templ) - use `.card`\n- Header dropdown (header.templ) - use `.action-menu`\n- Login/CTA buttons (shared.templ) - use `.btn-primary`\n- Avatar component (shared.templ) - use new CSS classes instead of templ.KV\n\n### 3. Replace inline page containers\n- Replace `max-w-4xl mx-auto` etc. with `.page-container-*` classes across all pages\n\n## Acceptance Criteria\n- No visual changes to the site\n- Reduced class verbosity in templ files\n- Avatar component simplified\n- Consistent use of existing abstractions","status":"completed","priority":"normal","assignee":"patrick","labels":["css","refactor"],"created_at":"2026-02-04T12:23:36.966151046Z","updated_at":"2026-02-04T12:27:59.86186814Z","completed_at":"2026-02-04T12:27:59.850499942Z"}
53-\\\\\\\ to: kxrlzxyu 98a353d2 "fix: action menu divider fix" (rebased revision)
54- {"id":"01KGK913NFHX2MMAXHCXT5S8HM","title":"Implement moderation service core with role-based permissions","description":"Create the core moderation service with JSON-based role configuration.\n\n## Requirements\n\n### JSON Config File (config/moderators.json)\n```json\n{\n \"roles\": {\n \"admin\": {\n \"description\": \"Full platform control\",\n \"permissions\": [\"hide_record\", \"unhide_record\", \"blacklist_user\", \"unblacklist_user\", \"view_reports\", \"dismiss_report\", \"view_audit_log\"]\n },\n \"moderator\": {\n \"description\": \"Content moderation\",\n \"permissions\": [\"hide_record\", \"unhide_record\", \"view_reports\", \"dismiss_report\"]\n }\n },\n \"users\": [\n {\"did\": \"did:plc:xxx\", \"handle\": \"arabica.social\", \"role\": \"admin\", \"note\": \"Platform owner\"}\n ]\n}\n```\n\n### ModerationService (internal/moderation/service.go)\n- Load config from JSON file (path from env var or flag)\n- `IsAdmin(did string) bool`\n- `IsModerator(did string) bool` (includes admins)\n- `HasPermission(did string, permission string) bool`\n- `GetRole(did string) (Role, bool)`\n- `ListModerators() []ModeratorUser`\n\n### Models (internal/moderation/models.go)\n- Role struct with permissions\n- ModeratorUser struct\n- Config struct\n\nAcceptance criteria:\n- Config file is validated on load (unknown roles, missing required fields)\n- Service is nil-safe (returns false/empty if not configured)\n- Reload capability for config changes without restart (nice to have)\n- Unit tests for permission checking","status":"completed","priority":"high","assignee":"patrick","labels":["moderation","backend"],"created_at":"2026-02-04T02:52:13.871235963Z","updated_at":"2026-02-04T03:07:38.338966523Z","completed_at":"2026-02-04T03:07:38.323669324Z","notes":[{"timestamp":"2026-02-04T03:07:38.24039103Z","author":"patrick","message":"Implemented core moderation service:\n- Created internal/moderation/models.go with Role, Permission, ModeratorUser, and Config types\n- Created internal/moderation/service.go with role-based permission checking\n- Added 17 unit tests covering all functionality\n- Created config/moderators.json.example with example configuration\n- Updated CLAUDE.md with new env var ARABICA_MODERATORS_CONFIG and project structure"}]}
55- {"id":"01KGK913QKZ96RKRH37VB99C7D","title":"Implement moderation storage in BoltDB","description":"Add BoltDB buckets and operations for moderation data.\n\n## Storage Requirements\n\n### Buckets\n- `moderation_hidden_records` - AT-URIs of hidden records\n- `moderation_blacklisted_users` - Blacklisted DIDs with metadata\n- `moderation_reports` - User reports on content\n- `moderation_audit_log` - All moderation actions\n\n### Models (internal/moderation/models.go additions)\n\n```go\ntype HiddenRecord struct {\n ATURI string `json:\"at_uri\"`\n HiddenAt time.Time `json:\"hidden_at\"`\n HiddenBy string `json:\"hidden_by\"` // DID of moderator\n Reason string `json:\"reason\"`\n AutoHidden bool `json:\"auto_hidden\"` // true if automod\n}\n\ntype BlacklistedUser struct {\n DID string `json:\"did\"`\n BlacklistedAt time.Time `json:\"blacklisted_at\"`\n BlacklistedBy string `json:\"blacklisted_by\"`\n Reason string `json:\"reason\"`\n}\n\ntype Report struct {\n ID string `json:\"id\"` // TID\n SubjectURI string `json:\"subject_uri\"` // AT-URI of reported content\n SubjectDID string `json:\"subject_did\"` // DID of content owner\n ReporterDID string `json:\"reporter_did\"`\n Reason string `json:\"reason\"`\n CreatedAt time.Time `json:\"created_at\"`\n Status string `json:\"status\"` // pending, dismissed, actioned\n ResolvedBy string `json:\"resolved_by\"`\n ResolvedAt *time.Time `json:\"resolved_at\"`\n}\n\ntype AuditEntry struct {\n ID string `json:\"id\"`\n Action string `json:\"action\"` // hide_record, blacklist_user, etc.\n ActorDID string `json:\"actor_did\"`\n TargetURI string `json:\"target_uri\"` // AT-URI or DID\n Reason string `json:\"reason\"`\n Timestamp time.Time `json:\"timestamp\"`\n AutoMod bool `json:\"auto_mod\"`\n}\n```\n\n### Store Interface (internal/moderation/store.go)\n```go\ntype Store interface {\n // Hidden records\n HideRecord(ctx context.Context, entry HiddenRecord) error\n UnhideRecord(ctx context.Context, atURI string) error\n IsRecordHidden(ctx context.Context, atURI string) bool\n ListHiddenRecords(ctx context.Context) ([]HiddenRecord, error)\n \n // Blacklist\n BlacklistUser(ctx context.Context, entry BlacklistedUser) error\n UnblacklistUser(ctx context.Context, did string) error\n IsBlacklisted(ctx context.Context, did string) bool\n ListBlacklistedUsers(ctx context.Context) ([]BlacklistedUser, error)\n \n // Reports\n CreateReport(ctx context.Context, report Report) error\n GetReport(ctx context.Context, id string) (*Report, error)\n ListPendingReports(ctx context.Context) ([]Report, error)\n ResolveReport(ctx context.Context, id string, status string, resolvedBy string) error\n CountReportsForURI(ctx context.Context, atURI string) (int, error)\n CountReportsForDID(ctx context.Context, did string) (int, error)\n \n // Audit\n LogAction(ctx context.Context, entry AuditEntry) error\n ListAuditLog(ctx context.Context, limit int) ([]AuditEntry, error)\n}\n```\n\nAcceptance criteria:\n- All operations are atomic\n- Audit logging happens automatically on moderation actions\n- Efficient lookups for IsRecordHidden and IsBlacklisted (called on every feed item)\n- Unit tests for store operations","status":"completed","priority":"high","assignee":"patrick","labels":["moderation","backend","database"],"created_at":"2026-02-04T02:52:13.939164045Z","updated_at":"2026-02-04T03:12:29.407349995Z","completed_at":"2026-02-04T03:12:29.395926199Z","notes":[{"timestamp":"2026-02-04T03:12:29.301269019Z","author":"patrick","message":"Implemented BoltDB storage for moderation:\n- Added 6 new buckets for moderation data in store.go\n- Created moderation_store.go with full CRUD operations:\n - Hidden records: HideRecord, UnhideRecord, IsRecordHidden, GetHiddenRecord, ListHiddenRecords\n - Blacklist: BlacklistUser, UnblacklistUser, IsBlacklisted, GetBlacklistedUser, ListBlacklistedUsers\n - Reports: CreateReport, GetReport, ListPendingReports, ResolveReport, CountReportsForURI/DID, HasReportedURI\n - Audit: LogAction, ListAuditLog\n- Added indexes for efficient report counting by URI and DID\n- Created comprehensive tests (20 test cases)"}]}
56- {"id":"01KGK91R3NWQEPPSSK9035JFZQ","title":"Integrate moderation filtering into feed service","description":"Hook moderation service into feed aggregation to filter hidden content and blacklisted users.\n\n## Requirements\n\n### Feed Service Changes (internal/feed/service.go)\n- Accept ModerationService dependency\n- Filter out hidden records before returning feed items\n- Filter out all records from blacklisted users\n- Consider performance: batch lookups vs per-item checks\n\n### Integration Points\n```go\ntype FeedService struct {\n // existing fields...\n moderation *moderation.Service\n}\n\nfunc (f *FeedService) GetFeed(ctx context.Context) ([]FeedItem, error) {\n items := f.getRawFeed(ctx)\n return f.filterModerated(ctx, items), nil\n}\n\nfunc (f *FeedService) filterModerated(ctx context.Context, items []FeedItem) []FeedItem {\n var filtered []FeedItem\n for _, item := range items {\n if f.moderation.IsBlacklisted(ctx, item.AuthorDID) {\n continue\n }\n if f.moderation.IsRecordHidden(ctx, item.ATURI) {\n continue\n }\n filtered = append(filtered, item)\n }\n return filtered\n}\n```\n\n### Public Brew View\n- Also filter on individual brew view endpoint (/brews/{did}/{rkey})\n- Return 404 for hidden records (or 451 Unavailable For Legal Reasons?)\n- Return 404 for blacklisted users' content\n\n### Handler Updates (internal/handlers/handlers.go)\n- Inject moderation service\n- Check moderation status on brew view routes\n\nAcceptance criteria:\n- Hidden records don't appear in feed\n- Blacklisted users' content doesn't appear anywhere\n- Minimal performance impact (consider caching blacklist/hidden sets)\n- Graceful degradation if moderation service unavailable","status":"completed","priority":"high","assignee":"patrick","labels":["moderation","backend","feed"],"created_at":"2026-02-04T02:52:34.805956493Z","updated_at":"2026-02-04T12:45:54.18942141Z","completed_at":"2026-02-04T12:45:54.173769314Z"}
57- {"id":"01KGK91R5RCE0B0PASS301ZZQT","title":"Implement user report submission system","description":"Allow authenticated users to report content with basic automod thresholds.\n\n## Requirements\n\n### Report Endpoint\n- POST /api/reports\n- Requires authentication\n- Rate limited (max 10 reports per user per hour)\n\n### Request/Response\n```go\ntype ReportRequest struct {\n SubjectURI string `json:\"subject_uri\"` // AT-URI of content\n Reason string `json:\"reason\"` // free text, max 500 chars\n}\n\ntype ReportResponse struct {\n ID string `json:\"id\"`\n Message string `json:\"message\"`\n}\n```\n\n### Automod Thresholds\nConfigurable via JSON config or constants:\n```go\nconst (\n AutoHideThreshold = 3 // reports on single record\n AutoHideUserThreshold = 5 // total reports across user's records\n NewUserDays = 7 // users \u003c 7 days old\n NewUserAutoHideThreshold = 2 // lower threshold for new users\n)\n```\n\nWhen threshold reached:\n1. Auto-hide the record (mark AutoHidden: true)\n2. Create audit log entry with AutoMod: true\n3. Keep report in pending state for moderator review\n\n### UI Component\n- Report button on brew cards in feed\n- Report button on brew detail page\n- Simple modal: textarea for reason + submit\n- Confirmation message after submission\n\n### Handler (internal/handlers/moderation.go)\n```go\nfunc (h *Handler) HandleSubmitReport(w http.ResponseWriter, r *http.Request)\n```\n\nAcceptance criteria:\n- Users can report any content (not their own)\n- Duplicate reports from same user are rejected\n- Automod triggers when thresholds reached\n- Rate limiting prevents abuse\n- Success/error feedback to user","status":"open","priority":"high","labels":["moderation","backend","frontend"],"created_at":"2026-02-04T02:52:34.872474383Z","updated_at":"2026-02-04T03:12:29.413298406Z"}
58- {"id":"01KGK926RAW8Q3SQHFH4KKKM3K","title":"Implement admin/moderator dashboard UI","description":"Create the admin dashboard for moderators and admins to take moderation actions.\n\n## Requirements\n\n### Routes\n- GET /admin - Dashboard (moderators + admins)\n- GET /admin/reports - Pending reports view\n- GET /admin/audit - Audit log (admins only)\n- POST /admin/hide - Hide a record\n- POST /admin/unhide - Unhide a record\n- POST /admin/blacklist - Blacklist a user (admins only)\n- POST /admin/unblacklist - Unblacklist a user (admins only)\n- POST /admin/reports/{id}/resolve - Resolve a report\n\n### Access Control\n- All /admin routes require authentication\n- Check HasPermission() for each action\n- Return 403 if insufficient permissions\n- Hide UI elements user can't access\n\n### Dashboard Page (internal/web/pages/admin.templ)\n```\n┌─────────────────────────────────────────────────────┐\n│ Moderation Dashboard [Your Role] │\n├─────────────────────────────────────────────────────┤\n│ │\n│ Pending Reports (3) │\n│ ┌─────────────────────────────────────────────────┐ │\n│ │ @user reported brew by @other │ │\n│ │ \"Spam content\" [View] [Hide] [Dismiss]│ │\n│ └─────────────────────────────────────────────────┘ │\n│ │\n│ Recently Hidden (auto-hidden marked) │\n│ ┌─────────────────────────────────────────────────┐ │\n│ │ brew/abc by @user [AUTO] - 3 reports [Unhide] │ │\n│ └─────────────────────────────────────────────────┘ │\n│ │\n│ Blacklisted Users (admin only) │\n│ ┌─────────────────────────────────────────────────┐ │\n│ │ @baduser - \"repeated spam\" [Unblacklist] │ │\n│ └─────────────────────────────────────────────────┘ │\n│ │\n│ [View Audit Log] (admin only) │\n└─────────────────────────────────────────────────────┘\n```\n\n### Components\n- AdminLayout component (reuses main layout but adds admin nav)\n- ReportCard component\n- HiddenRecordRow component\n- BlacklistedUserRow component\n- AuditLogTable component\n\n### HTMX Integration\n- Actions (hide/unhide/resolve) via HTMX POST\n- Optimistic UI updates\n- Error toast on failure\n\n### Handler (internal/handlers/admin.go)\nNew handler file for admin routes, or add to existing handlers.go\n\nAcceptance criteria:\n- Only accessible to moderators/admins\n- All actions require appropriate permissions\n- Actions create audit log entries\n- Responsive design\n- Confirmation dialogs for destructive actions (blacklist)","status":"completed","priority":"high","assignee":"patrick","blocked_by":["01KGK91R3NWQEPPSSK9035JFZQ","01KGK91R5RCE0B0PASS301ZZQT"],"labels":["moderation","frontend","backend"],"created_at":"2026-02-04T02:52:49.80269115Z","updated_at":"2026-02-05T02:05:48.756110333Z","completed_at":"2026-02-05T02:05:48.744596981Z"}
59- {"id":"01KGMAQBRS5J9N0JHBJAAZ518Q","title":"Add moderator actions to feed item more menu","description":"Add moderation actions (hide/unhide) to the existing 'more' dropdown menu on feed items for users with appropriate permissions.\n\n## Requirements\n\n### UI Changes\n- Extend the existing 'more' menu (three dots) on feed/brew cards\n- Show moderation actions only if user has permission\n- Actions to add:\n - 'Hide from feed' (if not hidden, requires hide_record permission)\n - 'Unhide' (if hidden, requires unhide_record permission)\n - Visual indicator if record is currently hidden (for mods viewing hidden content)\n\n### Backend Changes\n- Pass moderation context to feed/brew templates:\n - `IsModerator bool`\n - `CanHideRecord bool`\n - `IsRecordHidden bool` (per item)\n- Add HTMX endpoints:\n - POST /admin/hide (accepts AT-URI, returns updated menu)\n - POST /admin/unhide (accepts AT-URI, returns updated menu)\n\n### Integration Points\n- Feed page (feed.templ) - brew cards in community feed\n- Profile page (profile.templ) - brew cards on user profiles\n- Brew view page (brew_view.templ) - single brew detail view\n\n### Behavior\n- Actions trigger HTMX POST requests\n- On success: swap the menu to show updated state\n- On error: show toast notification\n- Create audit log entry for each action\n\n### Permissions Check\n- Check ModerationService.HasPermission() before rendering actions\n- Check again server-side before executing action\n- Return 403 if permission denied\n\nAcceptance criteria:\n- Moderators see hide/unhide options in more menu\n- Regular users don't see moderation options\n- Actions work via HTMX without full page reload\n- Audit log entries created for all actions\n- Hidden records show visual indicator to moderators","status":"completed","priority":"high","assignee":"patrick","labels":["moderation","frontend","backend"],"created_at":"2026-02-04T12:41:06.073520449Z","updated_at":"2026-02-04T13:06:16.292634304Z","completed_at":"2026-02-04T13:06:16.279224398Z"}
60-+{"id":"01KGZQ1Q1CSCBNXY9AS6GQJK2B","title":"Fix more actions menu z-index on feed cards","description":"The action bar border-t divider line visually paints on top of the popup menu instead of behind it. Root cause: .action-menu has z-10 while .dropdown-menu has z-50, and .feed-card animation creates a new stacking context.\n\nFix:\n- Bump .action-menu z-index from z-10 to z-50 (matching .dropdown-menu) in static/css/app.css\n- Add isolation: isolate or relative z-index to action-bar wrapper if needed\n- Remove the two // FIX: comments in internal/web/components/action_bar.templ\n- Regenerate templ code\n- Rebuild CSS with Tailwind\n\nKey files:\n- static/css/app.css:309-311 — .action-menu definition\n- internal/web/components/action_bar.templ:92-93 — FIX comments\n- static/css/app.css:410-411 — .feed-card animation\n\nVerify on homepage feed and profile brews tab.","status":"open","priority":"normal","created_at":"2026-02-08T22:48:06.956664383Z","updated_at":"2026-02-08T22:48:06.956664383Z"}
···36{"id":"01KGAYXR359TWZPS2AJF9ZB4S5","title":"Extract profile data fetching into reusable function","description":"Two handlers contain nearly identical ~100-line blocks for fetching user profile data:\n\n**Duplicated in:**\n1. HandleProfile (lines 1628-1733)\n2. HandleProfilePartial (lines 1875-1980)\n\nBoth do:\n- Create errgroup context\n- Fetch beans, roasters, grinders, brewers, brews in parallel\n- Parse records and build lookup maps\n- Resolve references between entities\n\n**Proposed refactoring:**\nExtract into a single helper function:\n\nfunc (h *Handler) fetchUserData(ctx context.Context, did string, publicClient *atproto.PublicClient) (*UserDataBundle, error)\n\nThis function would return a struct containing:\n- Beans (with roasters resolved)\n- Roasters\n- Grinders\n- Brewers\n- Brews (with all refs resolved)\n\nAcceptance criteria:\n- Create fetchUserData helper function\n- Refactor both handlers to use it\n- Reduce duplicated code by ~80-100 lines\n- All profile functionality works as before","status":"completed","priority":"low","created_at":"2026-01-31T21:21:42.501437644Z","updated_at":"2026-01-31T21:32:30.483213969Z","completed_at":"2026-01-31T21:32:30.468389045Z"}
37{"id":"01KGB1KDNNZZ7ZDA5NCK0T4E1G","title":"Create shared entity table components for profile and manage pages","description":"## Summary\nRefactor the profile and manage page tables to use shared components, reducing duplication and improving maintainability.\n\n## Current State\n- `manage_partial.templ` has tables for beans, roasters, grinders, brewers with Edit/Delete actions\n- `profile_partial.templ` has similar tables but without actions\n- Beans on profile are split into \"Open Bags\" and \"Closed Bags\" sections\n- Significant HTML/structure duplication between the two files\n\n## Proposed Solution\nCreate shared table components with configurable props:\n\n### Shared Components to Create\n1. **BeansTable** - shared beans table component\n2. **RoastersTable** - shared roasters table component \n3. **GrindersTable** - shared grinders table component\n4. **BrewersTable** - shared brewers table component\n\n### Props Pattern\nEach component should accept props that control behavior:\n\n```go\ntype EntityTableProps struct {\n Items []*models.Entity\n ShowActions bool // Whether to render Edit/Delete column\n // Other entity-specific options as needed\n}\n```\n\n### Beans Table Special Handling\nThe beans table has unique requirements:\n- Manage page: single table with Status column, shows all beans\n- Profile page: split into Open/Closed sections, no Status column\n\nOptions to handle this:\n1. **Single component with `ShowStatus` prop** - manage uses it, profile calls it twice with filtered lists\n2. **`GroupByStatus` prop** - component internally groups and renders sections\n3. Keep beans separate if complexity doesn't warrant sharing\n\nRecommend option 1 - simpler, profile already filters beans via `filterOpenBeans`/`filterClosedBeans`.\n\n### Actions Implementation\nWhen `ShowActions: true`:\n- Render action column header\n- Render Edit button with `hx-get=\"/api/modals/{entity}/{rkey}\"`\n- Render Delete button with `hx-delete=\"/api/{entities}/{rkey}\"`\n\n### Files to Modify\n- `internal/web/components/manage_partial.templ` - use shared components\n- `internal/web/components/profile_partial.templ` - use shared components\n- `internal/web/components/entity_tables.templ` (new) - shared table components\n\n### Acceptance Criteria\n- [ ] Shared table components created in `entity_tables.templ`\n- [ ] `manage_partial.templ` refactored to use shared components\n- [ ] `profile_partial.templ` refactored to use shared components\n- [ ] Actions can be enabled/disabled via props\n- [ ] No visual regressions on manage page\n- [ ] No visual regressions on profile page\n- [ ] `templ generate` runs successfully\n- [ ] Tests pass (if any)","status":"completed","priority":"normal","assignee":"patrick","labels":["frontend","refactoring"],"created_at":"2026-01-31T22:08:29.877786981Z","updated_at":"2026-01-31T22:16:42.669635082Z","completed_at":"2026-01-31T22:16:42.657711384Z"}
38{"id":"01KGB2WBMP539SFSKZCKJAYH0C","title":"Prevent line wrap between emoji and text in table headers","description":"Add whitespace-nowrap to table header cells to prevent the emoji and column name from wrapping onto separate lines.","status":"completed","priority":"normal","assignee":"patrick","labels":["frontend"],"created_at":"2026-01-31T22:30:51.286845962Z","updated_at":"2026-01-31T22:31:23.380048647Z","completed_at":"2026-01-31T22:31:23.369060496Z"}
39+{"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":"cancelled","priority":"normal","assignee":"patrick","created_at":"2026-02-01T02:05:40.819263564Z","updated_at":"2026-02-06T01:44:49.962642705Z","notes":[{"timestamp":"2026-02-01T02:10:19.279321594Z","author":"patrick","message":"Initial implementation complete:\n- Created /atproto page with AT Protocol explanation\n- Updated footer link to point to /atproto instead of external site\n- Added route and handler\n- Page covers: PDS, DIDs, Lexicons, AT-URIs, how Arabica uses ATProto\n- Includes links to atproto.com for more info\nReady for review and final polish"}]}
40{"id":"01KGBM1YCGZRNTVCJDZFV3100R","title":"Move web/static to static directory","description":"Move web/static directory to top-level static directory and update all references across the codebase.\n\nFiles to update:\n- flake.nix (tailwindcss paths)\n- .gitignore (output.css path)\n- justfile (tailwindcss paths)\n- default.nix (tailwindcss paths)\n- .gitattributes (vendored JS paths)\n- internal/routing/routing.go (file server path)\n- deploy/Dockerfile (COPY command)\n- CLAUDE.md (documentation)\n\nAcceptance criteria:\n- web/static moved to static/\n- All references updated\n- Server still serves static files correctly\n- Tailwind CSS build still works","status":"completed","priority":"normal","assignee":"patrick","created_at":"2026-02-01T03:31:00.112467261Z","updated_at":"2026-02-01T03:32:59.754877212Z","completed_at":"2026-02-01T03:32:59.739794374Z"}
41+{"id":"01KGDXVE566WVDG8TC9DWPSYXF","title":"Implement likes for social interactions","description":"Add a like lexicon and implement like functionality across the app.\n\n## Lexicon: social.arabica.alpha.like\n\nThe like record should:\n- Use `com.atproto.repo.strongRef` for the subject (URI + CID for immutability)\n- Support liking any arabica.social lexicon type (beans, roasters, grinders, brewers, brews, and comments once implemented)\n- Include createdAt timestamp\n- Use TID as record key\n\n## Backend Implementation\n\n1. Create lexicon file: `lexicons/social.arabica.alpha.like.json`\n2. Add NSID constant in `internal/atproto/nsid.go`\n3. Add Like model in `internal/models/models.go`\n4. Add record conversion functions in `internal/atproto/records.go`\n5. Add Store interface methods: CreateLike, DeleteLike, GetLikesForSubject, GetUserLikes\n6. Implement in AtprotoStore\n7. Update firehose indexing to track likes\n\n## Frontend Implementation\n\n1. Add like button component\n2. Show like counts on brews/beans/etc in feed\n3. Update like counts in response to firehose events (real-time updates)\n4. Toggle like state based on current user's likes\n\n## Acceptance Criteria\n\n- Users can like any arabica.social record\n- Likes are stored in the user's PDS (actor-owned data)\n- Like counts display on records in the feed\n- Real-time like count updates via firehose\n- Optimistic UI updates for likes","status":"cancelled","priority":"high","labels":["backend","frontend","atproto"],"created_at":"2026-02-02T01:00:41.510646368Z","updated_at":"2026-02-06T01:45:10.223739659Z"}
42{"id":"01KGDXVR86CB2H44DJHKN4Z9M0","title":"Implement comments for social interactions","description":"Add a comment lexicon and implement comment functionality across the app.\n\n## Lexicon: social.arabica.alpha.comment\n\nThe comment record should:\n- Use `com.atproto.repo.strongRef` for the subject (URI + CID for immutability)\n- Support commenting on any arabica.social lexicon type (beans, roasters, grinders, brewers, brews, and other comments)\n- Include text field (max 1000 chars / 300 graphemes as per AT Protocol conventions)\n- Include createdAt timestamp\n- Use TID as record key\n- NOTE: This cell implements flat comments only. Threaded/nested comments are handled in a separate cell.\n\n## Backend Implementation\n\n1. Create lexicon file: `lexicons/social.arabica.alpha.comment.json`\n2. Add NSID constant in `internal/atproto/nsid.go`\n3. Add Comment model in `internal/models/models.go`\n4. Add record conversion functions in `internal/atproto/records.go`\n5. Add Store interface methods: CreateComment, DeleteComment, GetCommentsForSubject, GetUserComments\n6. Implement in AtprotoStore\n7. Update firehose indexing to track comments\n\n## Frontend Implementation\n\n1. Add comment section component below brew detail view\n2. Add comment form (authenticated users only)\n3. Display comment count on records in feed\n4. Update comment counts in response to firehose events\n5. Show commenter profile info (avatar, handle)\n\n## Acceptance Criteria\n\n- Users can comment on any arabica.social record\n- Comments are stored in the user's PDS (actor-owned data)\n- Comment counts display on records in the feed\n- Comments visible on record detail views\n- Real-time comment count updates via firehose","status":"open","priority":"normal","labels":["backend","frontend","atproto"],"created_at":"2026-02-02T01:00:51.846427126Z","updated_at":"2026-02-02T01:00:51.846427126Z"}
43{"id":"01KGDXW31PHFMBE3WTV83HPET1","title":"Implement comment threading","description":"Add support for threaded/nested comments, building on the flat comment system.\n\n## Lexicon Changes\n\nUpdate `social.arabica.alpha.comment` to add optional threading fields:\n- `parent`: Optional strongRef to parent comment (for replies)\n- `root`: Optional strongRef to root subject (maintains context when replying to comments)\n\nAlternatively, consider a separate reply field or keeping comments flat with UI-level threading.\n\n## Backend Implementation\n\n1. Update comment model to include parent/root references\n2. Add methods to fetch comment threads: GetCommentThread, GetReplies\n3. Update firehose indexing to track parent-child relationships\n4. Add depth limits for threading (prevent infinite nesting)\n\n## Frontend Implementation\n\n1. Design threaded comment UI (indentation, collapse/expand)\n2. Add 'reply' button on comments\n3. Show reply context when replying\n4. Consider max nesting depth for display (e.g., 3-4 levels)\n5. Mobile-friendly thread navigation\n\n## Design Considerations\n\n- How deep should threading go? (Recommend max 3-4 levels visible, then flatten)\n- How to handle deleted parent comments?\n- Should users be notified when someone replies to their comment?\n- Performance: lazy-load deep threads vs eager-load\n\n## Acceptance Criteria\n\n- Users can reply directly to comments\n- Thread structure is visually clear\n- Threads can be collapsed/expanded\n- Works well on mobile devices\n- Parent context shown when replying","status":"blocked","priority":"low","blocked_by":["01KGDXVR86CB2H44DJHKN4Z9M0"],"labels":["frontend","atproto"],"created_at":"2026-02-02T01:01:02.902692829Z","updated_at":"2026-02-02T01:01:06.361891137Z"}
44{"id":"01KGGG8EHG9Y6VQ8PZM9W74584","title":"Refactor lexicon record types to use concrete RecordType","description":"Replace magic strings for lexicon record types with a concrete RecordType type and defined constants.\n\n## Current State\nMagic strings like \"brew\", \"bean\", \"roaster\", \"grinder\", \"brewer\" are used throughout the codebase:\n- `internal/feed/service.go` - FeedItem.RecordType field uses string type\n- `internal/firehose/index.go` - Sets RecordType to string literals\n- `internal/web/pages/feed.templ` - Switch cases on string values\n- `internal/handlers/handlers.go` - writeJSON calls with string type names\n\n## Proposed Changes\n1. Define a `RecordType` type in `internal/atproto/nsid.go` (or a new file)\n2. Create constants: `RecordTypeBrew`, `RecordTypeBean`, `RecordTypeRoaster`, `RecordTypeGrinder`, `RecordTypeBrewer`, `RecordTypeLike`\n3. Update `FeedItem.RecordType` in `feed/service.go` to use the new type\n4. Update all string literal usages to use the constants\n5. Consider adding helper methods (e.g., `RecordType.String()`, `RecordType.DisplayName()`)\n\n## Acceptance Criteria\n- No magic strings for record types remain in Go code\n- Template switch statements updated to use constants\n- Type safety enforced at compile time\n- All tests pass","status":"completed","priority":"normal","labels":["refactoring","tech-debt"],"created_at":"2026-02-03T01:00:51.120049807Z","updated_at":"2026-02-03T01:06:55.448414863Z","completed_at":"2026-02-03T01:06:55.435337779Z"}
···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"}
51{"id":"01KGGNPG25M2AJ619P65FPMEH4","title":"Add OpenGraph metadata to brew pages","description":"Add dynamic OpenGraph metadata support for brew pages to enable rich social sharing previews.\n\n## Background\n\nCurrently, the site uses static OpenGraph tags in layout.templ:\n- og:title: \"Arabica - Coffee Brew Tracker\" (static)\n- og:description: \"Track your coffee brewing journey...\" (static) \n- og:type: \"website\" (static)\n- og:image: NOT PRESENT\n\nWhen users share brew links on social media, all previews look identical and provide no context about the specific brew.\n\n## Requirements\n\n### 1. Extend LayoutData struct\n\nAdd optional OpenGraph fields to `internal/web/components/layout.templ`:\n\n```go\ntype LayoutData struct {\n Title string\n IsAuthenticated bool\n UserDID string\n UserProfile *bff.UserProfile\n CSPNonce string\n \n // OpenGraph metadata (optional, falls back to defaults)\n OGTitle string\n OGDescription string\n OGImage string\n OGType string // \"website\", \"article\"\n OGUrl string // Canonical URL\n}\n```\n\n### 2. Update layout.templ head section\n\nModify meta tag rendering to use dynamic values with fallbacks:\n- If OGTitle set, use it; otherwise use site default\n- If OGDescription set, use it; otherwise use site default\n- If OGImage set, render og:image tag (currently missing entirely)\n- If OGUrl set, render og:url tag\n- Support og:type (default \"website\", brew pages use \"article\")\n\n### 3. Update buildLayoutData helper\n\nExtend `handlers.go` buildLayoutData() to accept optional OG parameters, or create a new helper `buildLayoutDataWithOG()`.\n\n### 4. Update HandleBrewView handler\n\nConstruct descriptive OG metadata from brew data:\n- **og:title**: \"{Bean Name} from {Origin} - Arabica\" or similar\n- **og:description**: \"{Rating}/10 - {Tasting Notes preview}\" or brew summary\n- **og:type**: \"article\"\n- **og:url**: The share URL (already computed as `shareURL`)\n- **og:image**: Static default for now (e.g., /static/og-brew-default.png)\n\nAvailable data for description construction:\n- brew.Rating, brew.TastingNotes\n- brew.Bean.Name, brew.Bean.Origin, brew.Bean.RoastLevel\n- brew.Bean.Roaster.Name\n- brew.CoffeeAmount, brew.WaterAmount, brew.Temperature\n\n### 5. Add Twitter Card support\n\nAdd Twitter-specific meta tags:\n- twitter:card = \"summary\" (or \"summary_large_image\" if we have images)\n- twitter:title, twitter:description (same as OG values)\n\n### 6. Static fallback image\n\nCreate or source a default OG image for brews:\n- Location: /static/og-brew-default.png or similar\n- Size: 1200x630px (recommended OG image size)\n- Design: Coffee-themed, includes Arabica branding\n\n## Acceptance Criteria\n\n- [ ] LayoutData extended with optional OG fields\n- [ ] layout.templ renders dynamic OG tags with fallbacks\n- [ ] Brew view pages include descriptive OG metadata\n- [ ] Twitter Card tags included\n- [ ] Static default OG image created and served\n- [ ] Existing pages (home, about, etc.) continue working with defaults\n- [ ] templ generate runs successfully\n- [ ] Manual testing: share brew URL to social platform preview tool\n\n## Implementation Notes\n\n- Follow existing patterns in handlers.go for data flow\n- Keep backwards compatible - pages not setting OG fields should use defaults\n- Consider creating a helper function to build OG description from brew data\n- The shareURL is already constructed in HandleBrewView (lines 695-701)\n\n## Future Enhancements (out of scope)\n\n- Dynamic image generation showing brew stats\n- Profile page OG metadata\n- Feed page OG metadata with recent brew preview","status":"completed","priority":"normal","assignee":"patrick","labels":["frontend","social"],"created_at":"2026-02-03T02:35:54.309356038Z","updated_at":"2026-02-03T02:53:43.170537883Z","completed_at":"2026-02-03T02:53:43.15817599Z","notes":[{"timestamp":"2026-02-03T02:53:36.100130152Z","author":"patrick","message":"Implementation complete:\n- Extended LayoutData with OG fields (OGTitle, OGDescription, OGImage, OGType, OGUrl)\n- Added helper methods ogTitle(), ogDescription(), ogType() with fallbacks\n- Updated layout.templ to render dynamic OG and Twitter Card meta tags\n- Added populateBrewOGMetadata() helper in handlers.go\n- Updated HandleBrewView to populate OG metadata from brew data\n- Added PublicURL to handler Config for absolute URLs\n- Added comprehensive tests for OG metadata generation\n- All tests pass"}]}
52{"id":"01KGM9QB86N5RX50042DT1YN1N","title":"Consolidate Tailwind CSS usage","description":"Reduce Tailwind class duplication by leveraging existing CSS abstractions and adding missing utility classes.\n\n## Changes Required\n\n### 1. Add new utility classes to app.css\n- `.page-container` variants (sm, md, lg, xl) for max-width containers\n- `.avatar-sm`, `.avatar-md`, `.avatar-lg` size classes\n- `.section-box` for bg-brown-50 rounded sections\n\n### 2. Update templ components to use existing abstractions\n- WelcomeCard (shared.templ) - use `.card` instead of inline gradient\n- ProfileHeader, ProfileStat, ProfileTabs (profile.templ) - use `.card`\n- Header dropdown (header.templ) - use `.action-menu`\n- Login/CTA buttons (shared.templ) - use `.btn-primary`\n- Avatar component (shared.templ) - use new CSS classes instead of templ.KV\n\n### 3. Replace inline page containers\n- Replace `max-w-4xl mx-auto` etc. with `.page-container-*` classes across all pages\n\n## Acceptance Criteria\n- No visual changes to the site\n- Reduced class verbosity in templ files\n- Avatar component simplified\n- Consistent use of existing abstractions","status":"completed","priority":"normal","assignee":"patrick","labels":["css","refactor"],"created_at":"2026-02-04T12:23:36.966151046Z","updated_at":"2026-02-04T12:27:59.86186814Z","completed_at":"2026-02-04T12:27:59.850499942Z"}
00000000
+1-1
.gitignore
···5051# Development files
52known-dids.txt
53-54roles.json
···5051# Development files
52known-dids.txt
53+moderators.json
54roles.json
+22-1
BACKLOG.md
···23## Far Future Considerations
2425- Maybe swap from boltdb to sqlite
26- - Use the non-cgo library
0000000000000000000002728## Fixes
29
···23## Far Future Considerations
2425- Maybe swap from boltdb to sqlite
26+ - Use the non-cgo library?
27+ - Is there a compelling reason to do this?
28+ - Might be good as a sort of witness-cache type thing (record refs to avoid hitting PDS's as often?)
29+ - Probably not worth unless we keep a copy of all (or all recent) network data
30+31+- The profile, manage, and brews list pages all function in a similar fashion,
32+ should one or more of them be consolidated?
33+ - Manage + brews list together probably makes sense
34+35+- IMPORTANT: If this platform gains any traction, we will need some form of content moderation
36+ - Due to the nature of arabica, this will only really need to be text based (text and hyperlinks)
37+ - Malicious link scanning may be reasonable, not sure about deeper text analysis
38+ - Need to do more research into security
39+ - Need admin tooling at the app level that will allow deleting records (may not be possible),
40+ removing from appview, blacklisting users (and maybe IPs?), possibly more
41+ - Having accounts with admin rights may be an approach to this (configured with flags at startup time?)
42+ @arabica.social, @pdewey.com, maybe others? (need trusted users in other time zones probably)
43+ - Add on some piece to the TOS that mentions I reserve the right to de-list content from the platform
44+ - Continue limiting firehose posts to users who have been previously authenticated (keep a permanent record of "trusted" users)
45+ - By logging in users agree to TOS -- can create records to be displayed on the appview ("signal" records)
46+ Attestation signature from appview (or pds -- use key from pds) was source of record being created
47+ - This is a pretty important consideration going forward, lots to consider
4849## Fixes
50
+27
CLAUDE.md
···4243All work items are tracked as cells. When starting new work, check for existing cells first.
44000000000000000000000045## Tech Stack
4647- **Language:** Go 1.21+
···98 feed/
99 service.go # Community feed aggregation
100 registry.go # User registration for feed
000101 models/
102 models.go # Domain models and request types
103 middleware/
···105 routing/
106 routing.go # Router setup and middleware chain
107lexicons/ # AT Protocol lexicon definitions (JSON)
0108static/ # CSS, JS, manifest
109```
110···453| `SERVER_PUBLIC_URL` | - | Public URL for reverse proxy |
454| `ARABICA_DB_PATH` | ~/.local/share/arabica/arabica.db | BoltDB path (sessions, registry) |
455| `ARABICA_FEED_INDEX_PATH` | ~/.local/share/arabica/feed-index.db | Firehose index BoltDB path |
0456| `ARABICA_PROFILE_CACHE_TTL` | 1h | Profile cache duration |
457| `SECURE_COOKIES` | false | Set true for HTTPS |
458| `LOG_LEVEL` | info | debug/info/warn/error |
···4243All work items are tracked as cells. When starting new work, check for existing cells first.
4445+## Workflow Rules
46+47+Do NOT spend more than 2-3 minutes exploring/reading files before beginning implementation. If the task is clear, start writing code immediately. Ask clarifying questions rather than endlessly reading the codebase. When given a specific implementation task, produce code changes in the same session.
48+49+## Dependencies
50+51+When implementing features, prefer standard library solutions over external dependencies. Only add a third-party dependency if the standard library genuinely cannot handle the requirement. For Go: check stdlib first (e.g., os.Stdout for TTY detection). For JS/TS: check built-in APIs before npm packages.
52+53+## Task Agents
54+55+When spawning task agents, set a hard limit of 3 agents maximum. Each agent must have a clearly scoped deliverable and file output path. Do not poll agents in a loop—instead, give each agent its full instructions upfront and collect results at the end. If agents aren't producing results within 5 minutes, fall back to doing the work directly.
56+57+## Testing & Verification
58+59+For Go projects: always run `go vet ./...` and `go build ./...` after making changes. For JavaScript/CSS projects: verify template field names match backend struct fields before considering a task complete. Always test form submissions to verify content-type handling (JSON vs form-encoded).
60+61+### Using Go Tooling Effectively
62+63+- To see source files from a dependency, or to answer questions about a dependency, run `go mod download -json MODULE` and use the returned `Dir` path to read the files.
64+- Use `go doc foo.Bar` or `go doc -all foo` to read documentation for packages, types, functions, etc.
65+- Use `go run .` or `go run ./cmd/foo` instead of `go build` to run programs, to avoid leaving behind build artifacts.
66+67## Tech Stack
6869- **Language:** Go 1.21+
···120 feed/
121 service.go # Community feed aggregation
122 registry.go # User registration for feed
123+ moderation/
124+ models.go # Moderation types (roles, permissions, reports)
125+ service.go # Role-based moderation service
126 models/
127 models.go # Domain models and request types
128 middleware/
···130 routing/
131 routing.go # Router setup and middleware chain
132lexicons/ # AT Protocol lexicon definitions (JSON)
133+config/ # Configuration files (moderators.json.example)
134static/ # CSS, JS, manifest
135```
136···479| `SERVER_PUBLIC_URL` | - | Public URL for reverse proxy |
480| `ARABICA_DB_PATH` | ~/.local/share/arabica/arabica.db | BoltDB path (sessions, registry) |
481| `ARABICA_FEED_INDEX_PATH` | ~/.local/share/arabica/feed-index.db | Firehose index BoltDB path |
482+| `ARABICA_MODERATORS_CONFIG` | - | Path to moderators JSON config |
483| `ARABICA_PROFILE_CACHE_TTL` | 1h | Profile cache duration |
484| `SECURE_COOKIES` | false | Set true for HTTPS |
485| `LOG_LEVEL` | info | debug/info/warn/error |
+14
cmd/server/main.go
···18 "arabica/internal/feed"
19 "arabica/internal/firehose"
20 "arabica/internal/handlers"
021 "arabica/internal/routing"
2223 "github.com/rs/zerolog"
···184 adapter := firehose.NewFeedIndexAdapter(feedIndex)
185 feedService.SetFirehoseIndex(adapter)
1860000187 log.Info().Msg("Firehose consumer started")
188189 // Log known DIDs from database (DIDs discovered via firehose)
···295296 // Wire up the feed index for like functionality
297 h.SetFeedIndex(feedIndex)
000000000298299 // Setup router with middleware
300 handler := routing.SetupRouter(routing.Config{
···18 "arabica/internal/feed"
19 "arabica/internal/firehose"
20 "arabica/internal/handlers"
21+ "arabica/internal/moderation"
22 "arabica/internal/routing"
2324 "github.com/rs/zerolog"
···185 adapter := firehose.NewFeedIndexAdapter(feedIndex)
186 feedService.SetFirehoseIndex(adapter)
187188+ // Wire up moderation filtering for the feed
189+ moderationStore := store.ModerationStore()
190+ feedService.SetModerationFilter(moderationStore)
191+192 log.Info().Msg("Firehose consumer started")
193194 // Log known DIDs from database (DIDs discovered via firehose)
···300301 // Wire up the feed index for like functionality
302 h.SetFeedIndex(feedIndex)
303+304+ // Initialize moderation service and wire up to handler
305+ moderatorsConfigPath := os.Getenv("ARABICA_MODERATORS_CONFIG")
306+ moderationSvc, err := moderation.NewService(moderatorsConfigPath)
307+ if err != nil {
308+ log.Warn().Err(err).Msg("Failed to initialize moderation service, moderation disabled")
309+ } else {
310+ h.SetModeration(moderationSvc, moderationStore)
311+ }
312313 // Setup router with middleware
314 handler := routing.SetupRouter(routing.Config{
···2223 // BucketFeedRegistry stores registered user DIDs for the community feed
24 BucketFeedRegistry = []byte("feed_registry")
00000000000000000025)
2627// Store wraps a BoltDB database and provides access to specialized stores.
···87 BucketSessions,
88 BucketAuthRequests,
89 BucketFeedRegistry,
00000090 }
9192 for _, bucket := range buckets {
···127// FeedStore returns a feed registry store backed by this database.
128func (s *Store) FeedStore() *FeedStore {
129 return &FeedStore{db: s.db}
00000130}
131132// Stats returns database statistics.
···2223 // BucketFeedRegistry stores registered user DIDs for the community feed
24 BucketFeedRegistry = []byte("feed_registry")
25+26+ // BucketModerationHiddenRecords stores AT-URIs of hidden records
27+ BucketModerationHiddenRecords = []byte("moderation_hidden_records")
28+29+ // BucketModerationBlacklist stores blacklisted user DIDs
30+ BucketModerationBlacklist = []byte("moderation_blacklist")
31+32+ // BucketModerationReports stores user reports on content
33+ BucketModerationReports = []byte("moderation_reports")
34+35+ // BucketModerationReportsByURI indexes reports by subject AT-URI
36+ BucketModerationReportsByURI = []byte("moderation_reports_by_uri")
37+38+ // BucketModerationReportsByDID indexes reports by subject DID
39+ BucketModerationReportsByDID = []byte("moderation_reports_by_did")
40+41+ // BucketModerationAuditLog stores moderation action audit trail
42+ BucketModerationAuditLog = []byte("moderation_audit_log")
43)
4445// Store wraps a BoltDB database and provides access to specialized stores.
···105 BucketSessions,
106 BucketAuthRequests,
107 BucketFeedRegistry,
108+ BucketModerationHiddenRecords,
109+ BucketModerationBlacklist,
110+ BucketModerationReports,
111+ BucketModerationReportsByURI,
112+ BucketModerationReportsByDID,
113+ BucketModerationAuditLog,
114 }
115116 for _, bucket := range buckets {
···151// FeedStore returns a feed registry store backed by this database.
152func (s *Store) FeedStore() *FeedStore {
153 return &FeedStore{db: s.db}
154+}
155+156+// ModerationStore returns a moderation store backed by this database.
157+func (s *Store) ModerationStore() *ModerationStore {
158+ return &ModerationStore{db: s.db}
159}
160161// Stats returns database statistics.
+89-4
internal/feed/service.go
···13 "github.com/rs/zerolog/log"
14)
15000000016const (
17 // PublicFeedCacheTTL is the duration for which the public feed cache is valid.
18 // This value can be adjusted based on desired freshness vs. performance tradeoff.
···8788// Service fetches and aggregates brews from registered users
89type Service struct {
90- registry *Registry
91- cache *publicFeedCache
92- firehoseIndex FirehoseIndex
093}
9495// NewService creates a new feed service
···106 log.Info().Msg("feed: firehose index configured")
107}
1080000000000000000000000000000000000000000000000000109// GetCachedPublicFeed returns cached feed items for unauthenticated users.
110// It returns up to PublicFeedLimit items from the cache, refreshing if expired.
111// The cache stores PublicFeedCacheSize items internally but only returns PublicFeedLimit.
00112func (s *Service) GetCachedPublicFeed(ctx context.Context) ([]*FeedItem, error) {
113 s.cache.mu.RLock()
114 cacheValid := time.Now().Before(s.cache.expiresAt) && len(s.cache.items) > 0
···116 s.cache.mu.RUnlock()
117118 if cacheValid {
0000119 // Return only the first PublicFeedLimit items from the cache
120 if len(items) > PublicFeedLimit {
121 items = items[:PublicFeedLimit]
···180181// GetRecentRecords fetches recent activity (brews and other records) from firehose index
182// Returns up to `limit` items sorted by most recent first
0183func (s *Service) GetRecentRecords(ctx context.Context, limit int) ([]*FeedItem, error) {
184 if s.firehoseIndex == nil || !s.firehoseIndex.IsReady() {
185 log.Warn().Msg("feed: firehose index not ready")
···187 }
188189 log.Debug().Msg("feed: using firehose index")
190- return s.getRecentRecordsFromFirehose(ctx, limit)
000000000000000000000191}
192193// getRecentRecordsFromFirehose fetches feed items from the firehose index
···13 "github.com/rs/zerolog/log"
14)
1516+// ModerationFilter provides content filtering for moderation.
17+// This interface allows the feed service to filter hidden/blacklisted content.
18+type ModerationFilter interface {
19+ IsRecordHidden(ctx context.Context, atURI string) bool
20+ IsBlacklisted(ctx context.Context, did string) bool
21+}
22+23const (
24 // PublicFeedCacheTTL is the duration for which the public feed cache is valid.
25 // This value can be adjusted based on desired freshness vs. performance tradeoff.
···9495// Service fetches and aggregates brews from registered users
96type Service struct {
97+ registry *Registry
98+ cache *publicFeedCache
99+ firehoseIndex FirehoseIndex
100+ moderationFilter ModerationFilter
101}
102103// NewService creates a new feed service
···114 log.Info().Msg("feed: firehose index configured")
115}
116117+// SetModerationFilter configures the service to filter moderated content
118+func (s *Service) SetModerationFilter(filter ModerationFilter) {
119+ s.moderationFilter = filter
120+ log.Info().Msg("feed: moderation filter configured")
121+}
122+123+// filterModeratedItems removes hidden records and content from blacklisted users
124+func (s *Service) filterModeratedItems(ctx context.Context, items []*FeedItem) []*FeedItem {
125+ if s.moderationFilter == nil {
126+ return items
127+ }
128+129+ filtered := make([]*FeedItem, 0, len(items))
130+ for _, item := range items {
131+ // Get author DID from the item
132+ authorDID := s.getAuthorDID(item)
133+ if authorDID != "" && s.moderationFilter.IsBlacklisted(ctx, authorDID) {
134+ log.Debug().Str("author", authorDID).Msg("feed: filtering blacklisted user's content")
135+ continue
136+ }
137+138+ // Check if the record is hidden
139+ if item.SubjectURI != "" && s.moderationFilter.IsRecordHidden(ctx, item.SubjectURI) {
140+ log.Debug().Str("uri", item.SubjectURI).Msg("feed: filtering hidden record")
141+ continue
142+ }
143+144+ filtered = append(filtered, item)
145+ }
146+147+ if len(items) != len(filtered) {
148+ log.Debug().
149+ Int("original", len(items)).
150+ Int("filtered", len(filtered)).
151+ Msg("feed: moderation filtering applied")
152+ }
153+154+ return filtered
155+}
156+157+// getAuthorDID extracts the author DID from a feed item
158+func (s *Service) getAuthorDID(item *FeedItem) string {
159+ if item.Author != nil {
160+ return item.Author.DID
161+ }
162+ // Author should always be set on feed items, but handle gracefully
163+ return ""
164+}
165+166// GetCachedPublicFeed returns cached feed items for unauthenticated users.
167// It returns up to PublicFeedLimit items from the cache, refreshing if expired.
168// The cache stores PublicFeedCacheSize items internally but only returns PublicFeedLimit.
169+// Moderated content is filtered even from cached items to ensure hidden content
170+// doesn't appear if it was hidden after caching.
171func (s *Service) GetCachedPublicFeed(ctx context.Context) ([]*FeedItem, error) {
172 s.cache.mu.RLock()
173 cacheValid := time.Now().Before(s.cache.expiresAt) && len(s.cache.items) > 0
···175 s.cache.mu.RUnlock()
176177 if cacheValid {
178+ // Apply moderation filtering to cached items
179+ // This ensures recently hidden content doesn't appear
180+ items = s.filterModeratedItems(ctx, items)
181+182 // Return only the first PublicFeedLimit items from the cache
183 if len(items) > PublicFeedLimit {
184 items = items[:PublicFeedLimit]
···243244// GetRecentRecords fetches recent activity (brews and other records) from firehose index
245// Returns up to `limit` items sorted by most recent first
246+// Moderated content (hidden records, blacklisted users) is filtered out
247func (s *Service) GetRecentRecords(ctx context.Context, limit int) ([]*FeedItem, error) {
248 if s.firehoseIndex == nil || !s.firehoseIndex.IsReady() {
249 log.Warn().Msg("feed: firehose index not ready")
···251 }
252253 log.Debug().Msg("feed: using firehose index")
254+255+ // Fetch more items than requested to account for filtered content
256+ // This ensures we can still return `limit` items after filtering
257+ fetchLimit := limit
258+ if s.moderationFilter != nil {
259+ fetchLimit = limit + 10 // Buffer for filtered items
260+ }
261+262+ items, err := s.getRecentRecordsFromFirehose(ctx, fetchLimit)
263+ if err != nil {
264+ return nil, err
265+ }
266+267+ // Apply moderation filtering
268+ items = s.filterModeratedItems(ctx, items)
269+270+ // Trim to requested limit
271+ if len(items) > limit {
272+ items = items[:limit]
273+ }
274+275+ return items, nil
276}
277278// getRecentRecordsFromFirehose fetches feed items from the firehose index
···1package components
23+import (
4+ "fmt"
5+ "strings"
6+)
78// ActionBarProps defines properties for the action bar below feed/profile cards
9type ActionBarProps struct {
···2728 // Auth state
29 IsAuthenticated bool
30+31+ // Moderation state
32+ IsModerator bool // User has moderator role
33+ CanHideRecord bool // User has hide_record permission
34+ CanBlockUser bool // User has blacklist_user permission
35+ IsRecordHidden bool // This record is currently hidden
36+ AuthorDID string // DID of the content author (for block action)
37}
3839// ActionBar renders the action bar with Comments, Like, Share, and More menu
···52 </svg>
53 <span>0</span>
54 </button>
55+ <!-- Hidden indicator (visible to moderators) -->
56+ if props.IsModerator && props.IsRecordHidden {
57+ <span class="hidden-badge" title="This record is hidden from the public feed">
58+ <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
59+ <path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88"></path>
60+ </svg>
61+ Hidden
62+ </span>
63+ }
64 <!-- Like -->
65 if props.SubjectURI != "" && props.SubjectCID != "" {
66 @LikeButton(LikeButtonProps{
···89 </svg>
90 </button>
91 <!-- Dropdown menu -->
92+ // FIX: the line shows above the popup modal in many cases
93+ // FIX: the modal should open in the other direction if it would extend past the edge of the viewport
94 <div
95 x-show="moreOpen"
96 x-transition:enter="transition ease-out duration-100"
···129 }
130 <div class="action-menu-divider"></div>
131 }
132+ <!-- Moderation actions (for moderators/admins) -->
133+ if props.CanHideRecord && props.SubjectURI != "" {
134+ if props.IsRecordHidden {
135+ <button
136+ type="button"
137+ hx-post="/_mod/unhide"
138+ hx-vals={ fmt.Sprintf(`{"uri": "%s"}`, props.SubjectURI) }
139+ hx-swap="none"
140+ @click="moreOpen = false; $dispatch('notify', {message: 'Record unhidden'})"
141+ class="action-menu-item"
142+ >
143+ <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
144+ <path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z"></path>
145+ <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"></path>
146+ </svg>
147+ Unhide from feed
148+ </button>
149+ } else {
150+ <button
151+ type="button"
152+ hx-post="/_mod/hide"
153+ hx-vals={ fmt.Sprintf(`{"uri": "%s"}`, props.SubjectURI) }
154+ hx-swap="none"
155+ hx-confirm="Hide this record from the public feed?"
156+ @click="moreOpen = false; $dispatch('notify', {message: 'Record hidden from feed'})"
157+ class="action-menu-item action-menu-item-warning"
158+ >
159+ <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
160+ <path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88"></path>
161+ </svg>
162+ Hide from feed
163+ </button>
164+ }
165+ <div class="action-menu-divider"></div>
166+ }
167+ if props.CanBlockUser && props.AuthorDID != "" && !props.IsOwner {
168+ <button
169+ type="button"
170+ hx-post="/_mod/block"
171+ hx-vals={ fmt.Sprintf(`{"did": "%s"}`, props.AuthorDID) }
172+ hx-swap="none"
173+ hx-confirm="Block this user? All their content will be hidden from the feed."
174+ @click="moreOpen = false; $dispatch('notify', {message: 'User blocked'})"
175+ class="action-menu-item action-menu-item-danger"
176+ >
177+ <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
178+ <path stroke-linecap="round" stroke-linejoin="round" d="M18.364 18.364A9 9 0 0 0 5.636 5.636m12.728 12.728A9 9 0 0 1 5.636 5.636m12.728 12.728L5.636 5.636"></path>
179+ </svg>
180+ Block user
181+ </button>
182+ <div class="action-menu-divider"></div>
183+ }
184+ <!-- Report (available to authenticated users) -->
185+ if props.IsAuthenticated && !props.IsOwner {
186+ <button
187+ type="button"
188+ @click={ fmt.Sprintf("moreOpen = false; document.getElementById('report-modal-%s').showModal()", escapeForAlpine(props.SubjectURI)) }
189+ class="action-menu-item"
190+ >
191+ <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
192+ <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>
193+ </svg>
194+ Report
195+ </button>
196+ }
197 </div>
198 </div>
199+ <!-- Report modal (only for authenticated non-owners) -->
200+ if props.IsAuthenticated && !props.IsOwner && props.SubjectURI != "" {
201+ @ReportModal(ReportModalProps{
202+ ID: fmt.Sprintf("report-modal-%s", escapeForAlpine(props.SubjectURI)),
203+ SubjectURI: props.SubjectURI,
204+ SubjectCID: props.SubjectCID,
205+ })
206+ }
207 </div>
208}
209···225 <span x-show="copied" x-cloak>Copied!</span>
226 </button>
227}
228+229+// ReportModalProps defines properties for the report modal
230+type ReportModalProps struct {
231+ ID string // Unique ID for the dialog
232+ SubjectURI string
233+ SubjectCID string
234+}
235+236+// ReportModal renders an inline report modal for the action bar
237+templ ReportModal(props ReportModalProps) {
238+ <dialog id={ props.ID } class="modal-dialog" x-data="{ reason: '', charCount: 0, submitting: false, error: '', success: false }">
239+ <div class="modal-content">
240+ <h3 class="modal-title">Report Content</h3>
241+ <template x-if="!success">
242+ <form
243+ @submit.prevent={ fmt.Sprintf(`
244+ submitting = true;
245+ error = '';
246+ const dialog = document.getElementById('%s');
247+ fetch('/api/report', {
248+ method: 'POST',
249+ headers: {'Content-Type': 'application/json'},
250+ body: JSON.stringify({
251+ subject_uri: '%s',
252+ subject_cid: '%s',
253+ reason: reason
254+ })
255+ })
256+ .then(r => r.json().then(data => ({ok: r.ok, data})))
257+ .then(({ok, data}) => {
258+ submitting = false;
259+ if (ok) {
260+ success = true;
261+ setTimeout(() => dialog.close(), 2000);
262+ } else {
263+ error = data.message || 'Failed to submit report';
264+ }
265+ })
266+ .catch(() => {
267+ submitting = false;
268+ error = 'Network error. Please try again.';
269+ });
270+ `, props.ID, escapeForJS(props.SubjectURI), escapeForJS(props.SubjectCID)) }
271+ class="space-y-4"
272+ >
273+ <p class="text-sm text-brown-700">
274+ Please describe why you're reporting this content. Reports are reviewed by moderators.
275+ </p>
276+ <div>
277+ <textarea
278+ x-model="reason"
279+ @input="charCount = reason.length"
280+ name="reason"
281+ placeholder="Describe the issue (optional)"
282+ rows="4"
283+ maxlength="500"
284+ class="w-full form-textarea"
285+ ></textarea>
286+ <div class="flex justify-between text-xs text-brown-500 mt-1">
287+ <span>Optional, but helpful for moderators</span>
288+ <span x-text="charCount + '/500'"></span>
289+ </div>
290+ </div>
291+ <template x-if="error">
292+ <div class="bg-red-100 border border-red-300 text-red-800 px-3 py-2 rounded-lg text-sm" x-text="error"></div>
293+ </template>
294+ <div class="flex gap-2">
295+ <button
296+ type="submit"
297+ class="flex-1 btn-primary"
298+ x-bind:disabled="submitting"
299+ >
300+ <span x-show="!submitting">Submit Report</span>
301+ <span x-show="submitting">Submitting...</span>
302+ </button>
303+ <button
304+ type="button"
305+ @click="$el.closest('dialog').close()"
306+ class="flex-1 btn-secondary"
307+ x-bind:disabled="submitting"
308+ >
309+ Cancel
310+ </button>
311+ </div>
312+ </form>
313+ </template>
314+ <template x-if="success">
315+ <div class="text-center py-4">
316+ <div class="text-green-600 mb-2">
317+ <svg class="w-12 h-12 mx-auto" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
318+ <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"></path>
319+ </svg>
320+ </div>
321+ <p class="font-medium text-brown-900">Report Submitted</p>
322+ <p class="text-sm text-brown-600 mt-1">Thank you for helping keep our community safe.</p>
323+ </div>
324+ </template>
325+ </div>
326+ </dialog>
327+}
328+329+// escapeForAlpine escapes special characters in a string for use in Alpine.js expressions.
330+// This creates a safe ID by replacing problematic characters.
331+func escapeForAlpine(s string) string {
332+ // Replace characters that are problematic in HTML IDs and Alpine expressions
333+ s = strings.ReplaceAll(s, ":", "_")
334+ s = strings.ReplaceAll(s, "/", "_")
335+ s = strings.ReplaceAll(s, ".", "_")
336+ return s
337+}
338+339+// escapeForJS escapes special characters in a string for use in JavaScript string literals.
340+func escapeForJS(s string) string {
341+ s = strings.ReplaceAll(s, "\\", "\\\\")
342+ s = strings.ReplaceAll(s, "'", "\\'")
343+ s = strings.ReplaceAll(s, "\n", "\\n")
344+ s = strings.ReplaceAll(s, "\r", "\\r")
345+ return s
346+}
+101
internal/web/components/dialog_modals.templ
···326 </dialog>
327}
32800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000329// Helper function to get string value from bean (handles nil case)
330func getStringValue(entity interface{}, field string) string {
331 if entity == nil {