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

Configure Feed

Select the types of activity you want to include in your feed.

fix: allow using recipes from other users when creating brews

Previously, recipe references in brew records always used the logged-in
user's DID to build the AT-URI, causing lookups to fail when the recipe
belonged to another user. This adds recipe_owner_did tracking through
the full flow: URL params, form hidden fields, JS state, API queries,
and AT-URI construction.

authored by

Patrick Dewey and committed by tangled.org 3a4b9381 677d4ac8

+438 -128
+10 -2
internal/atproto/store.go
··· 259 259 brewerURI = BuildATURI(s.did.String(), NSIDBrewer, brew.BrewerRKey) 260 260 } 261 261 if brew.RecipeRKey != "" { 262 - recipeURI = BuildATURI(s.did.String(), NSIDRecipe, brew.RecipeRKey) 262 + recipeOwner := s.did.String() 263 + if brew.RecipeOwnerDID != "" { 264 + recipeOwner = brew.RecipeOwnerDID 265 + } 266 + recipeURI = BuildATURI(recipeOwner, NSIDRecipe, brew.RecipeRKey) 263 267 } 264 268 265 269 // Convert to models.Brew for record conversion ··· 567 571 brewerURI = BuildATURI(s.did.String(), NSIDBrewer, brew.BrewerRKey) 568 572 } 569 573 if brew.RecipeRKey != "" { 570 - recipeURI = BuildATURI(s.did.String(), NSIDRecipe, brew.RecipeRKey) 574 + recipeOwner := s.did.String() 575 + if brew.RecipeOwnerDID != "" { 576 + recipeOwner = brew.RecipeOwnerDID 577 + } 578 + recipeURI = BuildATURI(recipeOwner, NSIDRecipe, brew.RecipeRKey) 571 579 } 572 580 573 581 // Get the existing record to preserve createdAt
+1 -1
internal/database/boltstore/session_store.go
··· 201 201 }) 202 202 203 203 return count, err 204 - } 204 + }
+1 -1
internal/feed/service.go
··· 42 42 type FeedItem struct { 43 43 // Record type and data (only one will be non-nil) 44 44 RecordType lexicons.RecordType // Use lexicons.RecordTypeBrew, lexicons.RecordTypeBean, etc. 45 - Action string // "added a new brew", "added a new bean", etc. 45 + Action string // "added a new brew", "added a new bean", etc. 46 46 47 47 Brew *models.Brew 48 48 Bean *models.Bean
+14 -14
internal/handlers/admin.go
··· 205 205 } 206 206 207 207 return pages.AdminProps{ 208 - HiddenRecords: hiddenRecords, 209 - AuditLog: auditLog, 210 - Reports: enrichedReports, 211 - BlockedUsers: blockedUsers, 212 - JoinRequests: joinRequests, 213 - Stats: stats, 214 - CanHide: canHide, 215 - CanUnhide: canUnhide, 216 - CanViewLogs: canViewLogs, 217 - CanViewReports: canViewReports, 218 - CanBlock: canBlock, 219 - CanUnblock: canUnblock, 220 - CanResetAutoHide: canResetAutoHide, 221 - IsAdmin: isAdmin, 208 + HiddenRecords: hiddenRecords, 209 + AuditLog: auditLog, 210 + Reports: enrichedReports, 211 + BlockedUsers: blockedUsers, 212 + JoinRequests: joinRequests, 213 + Stats: stats, 214 + CanHide: canHide, 215 + CanUnhide: canUnhide, 216 + CanViewLogs: canViewLogs, 217 + CanViewReports: canViewReports, 218 + CanBlock: canBlock, 219 + CanUnblock: canUnblock, 220 + CanResetAutoHide: canResetAutoHide, 221 + IsAdmin: isAdmin, 222 222 } 223 223 } 224 224
+31 -28
internal/handlers/brew.go
··· 135 135 layoutData, _, _ := h.layoutDataFromRequest(r, "New Brew") 136 136 137 137 brewFormProps := pages.BrewFormProps{ 138 - Brew: nil, 139 - RecipeRKey: r.URL.Query().Get("recipe"), 138 + Brew: nil, 139 + RecipeRKey: r.URL.Query().Get("recipe"), 140 + RecipeOwnerDID: r.URL.Query().Get("recipe_owner"), 140 141 } 141 142 142 143 if err := pages.BrewFormPage(layoutData, brewFormProps).Render(r.Context(), w); err != nil { ··· 657 658 } 658 659 659 660 req := &models.CreateBrewRequest{ 660 - BeanRKey: beanRKey, 661 - RecipeRKey: recipeRKey, 662 - Method: r.FormValue("method"), 663 - Temperature: temperature, 664 - WaterAmount: waterAmount, 665 - CoffeeAmount: coffeeAmount, 666 - TimeSeconds: timeSeconds, 667 - GrindSize: r.FormValue("grind_size"), 668 - GrinderRKey: grinderRKey, 669 - BrewerRKey: brewerRKey, 670 - TastingNotes: r.FormValue("tasting_notes"), 671 - Rating: rating, 672 - Pours: pours, 661 + BeanRKey: beanRKey, 662 + RecipeRKey: recipeRKey, 663 + RecipeOwnerDID: r.FormValue("recipe_owner_did"), 664 + Method: r.FormValue("method"), 665 + Temperature: temperature, 666 + WaterAmount: waterAmount, 667 + CoffeeAmount: coffeeAmount, 668 + TimeSeconds: timeSeconds, 669 + GrindSize: r.FormValue("grind_size"), 670 + GrinderRKey: grinderRKey, 671 + BrewerRKey: brewerRKey, 672 + TastingNotes: r.FormValue("tasting_notes"), 673 + Rating: rating, 674 + Pours: pours, 673 675 } 674 676 675 677 if err := req.Validate(); err != nil { ··· 754 756 } 755 757 756 758 req := &models.CreateBrewRequest{ 757 - BeanRKey: beanRKey, 758 - RecipeRKey: recipeRKey, 759 - Method: r.FormValue("method"), 760 - Temperature: temperature, 761 - WaterAmount: waterAmount, 762 - CoffeeAmount: coffeeAmount, 763 - TimeSeconds: timeSeconds, 764 - GrindSize: r.FormValue("grind_size"), 765 - GrinderRKey: grinderRKey, 766 - BrewerRKey: brewerRKey, 767 - TastingNotes: r.FormValue("tasting_notes"), 768 - Rating: rating, 769 - Pours: pours, 759 + BeanRKey: beanRKey, 760 + RecipeRKey: recipeRKey, 761 + RecipeOwnerDID: r.FormValue("recipe_owner_did"), 762 + Method: r.FormValue("method"), 763 + Temperature: temperature, 764 + WaterAmount: waterAmount, 765 + CoffeeAmount: coffeeAmount, 766 + TimeSeconds: timeSeconds, 767 + GrindSize: r.FormValue("grind_size"), 768 + GrinderRKey: grinderRKey, 769 + BrewerRKey: brewerRKey, 770 + TastingNotes: r.FormValue("tasting_notes"), 771 + Rating: rating, 772 + Pours: pours, 770 773 } 771 774 772 775 if err := req.Validate(); err != nil {
+2 -4
internal/handlers/handlers.go
··· 44 44 config Config 45 45 feedService *feed.Service 46 46 feedRegistry *feed.Registry 47 - feedIndex *firehose.FeedIndex 48 - witnessCache atproto.WitnessCache 47 + feedIndex *firehose.FeedIndex 48 + witnessCache atproto.WitnessCache 49 49 50 50 // Moderation dependencies (optional) 51 51 moderationService *moderation.Service ··· 56 56 joinStore *boltstore.JoinStore 57 57 pdsAdminURL string 58 58 pdsAdminToken string 59 - 60 59 } 61 60 62 61 // NewHandler creates a new Handler with all required dependencies. ··· 102 101 h.pdsAdminURL = pdsURL 103 102 h.pdsAdminToken = pdsAdminToken 104 103 } 105 - 106 104 107 105 // invalidateFeedCache clears the public feed cache after a mutation. 108 106 func (h *Handler) invalidateFeedCache() {
+14
internal/handlers/pages.go
··· 37 37 } 38 38 } 39 39 40 + // Settings page 41 + func (h *Handler) HandleSettings(w http.ResponseWriter, r *http.Request) { 42 + data, _, isAuthenticated := h.layoutDataFromRequest(r, "Settings") 43 + if !isAuthenticated { 44 + http.Redirect(w, r, "/", http.StatusSeeOther) 45 + return 46 + } 47 + 48 + if err := pages.Settings(data).Render(r.Context(), w); err != nil { 49 + http.Error(w, "Failed to render page", http.StatusInternalServerError) 50 + log.Error().Err(err).Msg("Failed to render settings page") 51 + } 52 + } 53 + 40 54 // HandleNotFound renders the 404 page 41 55 func (h *Handler) HandleNotFound(w http.ResponseWriter, r *http.Request) { 42 56 layoutData, _, _ := h.layoutDataFromRequest(r, "Page Not Found")
+55 -12
internal/handlers/recipe.go
··· 28 28 29 29 if err := decodeRequest(r, &req, func() error { 30 30 req = models.CreateRecipeRequest{ 31 - Name: r.FormValue("name"), 32 - BrewerRKey: r.FormValue("brewer_rkey"), 33 - BrewerType: r.FormValue("brewer_type"), 31 + Name: r.FormValue("name"), 32 + BrewerRKey: r.FormValue("brewer_rkey"), 33 + BrewerType: r.FormValue("brewer_type"), 34 34 Notes: r.FormValue("notes"), 35 - SourceRef: r.FormValue("source_ref"), 35 + SourceRef: r.FormValue("source_ref"), 36 36 } 37 37 if v := r.FormValue("coffee_amount"); v != "" { 38 38 if f, err := strconv.ParseFloat(v, 64); err == nil { ··· 91 91 92 92 if err := decodeRequest(r, &req, func() error { 93 93 req = models.UpdateRecipeRequest{ 94 - Name: r.FormValue("name"), 95 - BrewerRKey: r.FormValue("brewer_rkey"), 96 - BrewerType: r.FormValue("brewer_type"), 94 + Name: r.FormValue("name"), 95 + BrewerRKey: r.FormValue("brewer_rkey"), 96 + BrewerType: r.FormValue("brewer_type"), 97 97 Notes: r.FormValue("notes"), 98 98 } 99 99 if v := r.FormValue("coffee_amount"); v != "" { ··· 169 169 } 170 170 171 171 // HandleRecipeGet returns a single recipe as JSON (for autofill) 172 + // Accepts optional ?owner= query param to fetch from another user's PDS. 172 173 func (h *Handler) HandleRecipeGet(w http.ResponseWriter, r *http.Request) { 173 174 rkey := validateRKey(w, r.PathValue("id")) 174 175 if rkey == "" { ··· 181 182 return 182 183 } 183 184 184 - recipe, err := store.GetRecipeByRKey(r.Context(), rkey) 185 - if err != nil { 186 - http.Error(w, "Recipe not found", http.StatusNotFound) 187 - log.Warn().Err(err).Str("rkey", rkey).Msg("Failed to get recipe") 188 - return 185 + ownerDID := r.URL.Query().Get("owner") 186 + 187 + var recipe *models.Recipe 188 + if ownerDID != "" { 189 + // Fetch from the recipe owner's PDS via public client 190 + publicClient := atproto.NewPublicClient() 191 + record, err := publicClient.GetRecord(r.Context(), ownerDID, atproto.NSIDRecipe, rkey) 192 + if err != nil { 193 + http.Error(w, "Recipe not found", http.StatusNotFound) 194 + log.Warn().Err(err).Str("rkey", rkey).Str("owner", ownerDID).Msg("Failed to get recipe from owner PDS") 195 + return 196 + } 197 + 198 + recipe, err = atproto.RecordToRecipe(record.Value, record.URI) 199 + if err != nil { 200 + http.Error(w, "Failed to parse recipe", http.StatusInternalServerError) 201 + log.Error().Err(err).Str("rkey", rkey).Msg("Failed to parse recipe record") 202 + return 203 + } 204 + recipe.RKey = rkey 205 + recipe.AuthorDID = ownerDID 206 + 207 + // Resolve brewer reference if present 208 + if brewerRef, ok := record.Value["brewerRef"].(string); ok && brewerRef != "" { 209 + if c, err := atproto.ResolveATURI(brewerRef); err == nil { 210 + recipe.BrewerRKey = c.RKey 211 + } 212 + brewerRKey := atproto.ExtractRKeyFromURI(brewerRef) 213 + if brewerRKey != "" { 214 + brewerRecord, err := publicClient.GetRecord(r.Context(), ownerDID, atproto.NSIDBrewer, brewerRKey) 215 + if err == nil { 216 + if brewer, err := atproto.RecordToBrewer(brewerRecord.Value, brewerRecord.URI); err == nil { 217 + brewer.RKey = brewerRKey 218 + recipe.BrewerObj = brewer 219 + } 220 + } 221 + } 222 + } 223 + } else { 224 + // Fetch from the logged-in user's own PDS 225 + var err error 226 + recipe, err = store.GetRecipeByRKey(r.Context(), rkey) 227 + if err != nil { 228 + http.Error(w, "Recipe not found", http.StatusNotFound) 229 + log.Warn().Err(err).Str("rkey", rkey).Msg("Failed to get recipe") 230 + return 231 + } 189 232 } 190 233 191 234 recipe.Interpolate()
+1 -1
internal/metrics/collector.go
··· 5 5 "database/sql" 6 6 "time" 7 7 8 - bolt "go.etcd.io/bbolt" 9 8 "github.com/rs/zerolog/log" 9 + bolt "go.etcd.io/bbolt" 10 10 ) 11 11 12 12 // StatsSource provides functions to retrieve current counts for gauge metrics.
-1
internal/middleware/security.go
··· 205 205 } 206 206 } 207 207 208 - 209 208 // RequireHTMXMiddleware ensures that certain API routes are only accessible via HTMX requests. 210 209 // This prevents direct browser access to internal API endpoints that return fragments or JSON. 211 210 // Routes that need to be publicly accessible (like /api/resolve-handle) should not use this middleware.
+37 -36
internal/models/models.go
··· 7 7 8 8 // Field length limits for validation 9 9 const ( 10 - MaxNameLength = 200 11 - MaxLocationLength = 200 12 - MaxWebsiteLength = 500 13 - MaxDescriptionLength = 2000 14 - MaxNotesLength = 2000 15 - MaxOriginLength = 200 16 - MaxRoastLevelLength = 100 17 - MaxVarietyLength = 200 18 - MaxProcessLength = 100 19 - MaxMethodLength = 100 10 + MaxNameLength = 200 11 + MaxLocationLength = 200 12 + MaxWebsiteLength = 500 13 + MaxDescriptionLength = 2000 14 + MaxNotesLength = 2000 15 + MaxOriginLength = 200 16 + MaxRoastLevelLength = 100 17 + MaxVarietyLength = 200 18 + MaxProcessLength = 100 19 + MaxMethodLength = 100 20 20 MaxGrindSizeLength = 100 21 21 MaxTastingNotesLength = 2000 22 - MaxGrinderTypeLength = 50 23 - MaxBurrTypeLength = 50 24 - MaxBrewerTypeLength = 100 25 - MaxCommentLength = 1000 26 - MaxCommentGraphemes = 300 22 + MaxGrinderTypeLength = 50 23 + MaxBurrTypeLength = 50 24 + MaxBrewerTypeLength = 100 25 + MaxCommentLength = 1000 26 + MaxCommentGraphemes = 300 27 27 ) 28 28 29 29 // Validation errors ··· 121 121 SourceAuthorDisplay string `json:"source_author_display,omitempty"` 122 122 123 123 // Social stats (populated by handler for explore) 124 - ForkCount int `json:"fork_count,omitempty"` 125 - BrewCount int `json:"brew_count,omitempty"` 126 - ForkerAvatars []string `json:"forker_avatars,omitempty"` // up to N forker profile pics 124 + ForkCount int `json:"fork_count,omitempty"` 125 + BrewCount int `json:"brew_count,omitempty"` 126 + ForkerAvatars []string `json:"forker_avatars,omitempty"` // up to N forker profile pics 127 127 } 128 128 129 129 // Interpolate fills in computed/derived fields from existing data. ··· 169 169 } 170 170 171 171 type CreateBrewRequest struct { 172 - BeanRKey string `json:"bean_rkey"` 173 - RecipeRKey string `json:"recipe_rkey"` 174 - Method string `json:"method"` 175 - Temperature float64 `json:"temperature"` 176 - WaterAmount int `json:"water_amount"` 177 - CoffeeAmount int `json:"coffee_amount"` 178 - TimeSeconds int `json:"time_seconds"` 179 - GrindSize string `json:"grind_size"` 180 - GrinderRKey string `json:"grinder_rkey"` 181 - BrewerRKey string `json:"brewer_rkey"` 182 - TastingNotes string `json:"tasting_notes"` 183 - Rating int `json:"rating"` 184 - Pours []CreatePourData `json:"pours"` 172 + BeanRKey string `json:"bean_rkey"` 173 + RecipeRKey string `json:"recipe_rkey"` 174 + RecipeOwnerDID string `json:"recipe_owner_did"` // DID of the recipe owner (may differ from brew author) 175 + Method string `json:"method"` 176 + Temperature float64 `json:"temperature"` 177 + WaterAmount int `json:"water_amount"` 178 + CoffeeAmount int `json:"coffee_amount"` 179 + TimeSeconds int `json:"time_seconds"` 180 + GrindSize string `json:"grind_size"` 181 + GrinderRKey string `json:"grinder_rkey"` 182 + BrewerRKey string `json:"brewer_rkey"` 183 + TastingNotes string `json:"tasting_notes"` 184 + Rating int `json:"rating"` 185 + Pours []CreatePourData `json:"pours"` 185 186 } 186 187 187 188 type CreatePourData struct { ··· 296 297 // Comment represents a comment on an Arabica record 297 298 type Comment struct { 298 299 RKey string `json:"rkey"` 299 - CID string `json:"cid,omitempty"` // CID of this comment record 300 + CID string `json:"cid,omitempty"` // CID of this comment record 300 301 SubjectURI string `json:"subject_uri"` 301 302 SubjectCID string `json:"subject_cid"` 302 303 Text string `json:"text"` 303 304 CreatedAt time.Time `json:"created_at"` 304 305 ActorDID string `json:"actor_did,omitempty"` 305 - ParentURI string `json:"parent_uri,omitempty"` // AT-URI of parent comment for replies 306 - ParentCID string `json:"parent_cid,omitempty"` // CID of parent comment for replies 306 + ParentURI string `json:"parent_uri,omitempty"` // AT-URI of parent comment for replies 307 + ParentCID string `json:"parent_cid,omitempty"` // CID of parent comment for replies 307 308 } 308 309 309 310 // CreateCommentRequest contains the data needed to create a comment ··· 311 312 SubjectURI string `json:"subject_uri"` 312 313 SubjectCID string `json:"subject_cid"` 313 314 Text string `json:"text"` 314 - ParentURI string `json:"parent_uri,omitempty"` // AT-URI of parent comment for replies 315 - ParentCID string `json:"parent_cid,omitempty"` // CID of parent comment for replies 315 + ParentURI string `json:"parent_uri,omitempty"` // AT-URI of parent comment for replies 316 + ParentCID string `json:"parent_cid,omitempty"` // CID of parent comment for replies 316 317 } 317 318 318 319 // Validate checks that all fields are within acceptable limits
+3 -3
internal/models/recipe_filter.go
··· 15 15 16 16 // RecipeCategories maps category names to their filter criteria. 17 17 var RecipeCategories = map[string]RecipeFilter{ 18 - "small": {MaxCoffee: 12}, // espresso, small cups (≤12g) 18 + "small": {MaxCoffee: 12}, // espresso, small cups (≤12g) 19 19 "single": {MinCoffee: 12, MaxCoffee: 22, MaxWater: 400}, // typical single pour-over (12-22g) 20 - "large": {MinCoffee: 22}, // large brews (22g+) 21 - "batch": {MinWater: 500}, // batch brew by water volume (500g+) 20 + "large": {MinCoffee: 22}, // large brews (22g+) 21 + "batch": {MinWater: 500}, // batch brew by water volume (500g+) 22 22 } 23 23 24 24 // MatchesFilter returns true if the recipe satisfies all non-zero filter criteria.
+3 -3
internal/models/recipe_filter_test.go
··· 102 102 tooMuchWater := &Recipe{Name: "Party Brew", CoffeeAmount: 15, WaterAmount: 500} 103 103 tooBigDose := &Recipe{Name: "Large", CoffeeAmount: 25, WaterAmount: 300} 104 104 105 - assert.False(t, MatchesFilter(small, RecipeFilter{Category: "single"})) // coffee too low 106 - assert.True(t, MatchesFilter(single, RecipeFilter{Category: "single"})) // perfect fit 105 + assert.False(t, MatchesFilter(small, RecipeFilter{Category: "single"})) // coffee too low 106 + assert.True(t, MatchesFilter(single, RecipeFilter{Category: "single"})) // perfect fit 107 107 assert.False(t, MatchesFilter(tooMuchWater, RecipeFilter{Category: "single"})) // water too high 108 - assert.False(t, MatchesFilter(tooBigDose, RecipeFilter{Category: "single"})) // coffee too high 108 + assert.False(t, MatchesFilter(tooBigDose, RecipeFilter{Category: "single"})) // coffee too high 109 109 } 110 110 111 111 func TestMatchesFilter_CategoriesNoOverlap(t *testing.T) {
+13 -13
internal/moderation/models.go
··· 6 6 type Permission string 7 7 8 8 const ( 9 - PermissionHideRecord Permission = "hide_record" 10 - PermissionUnhideRecord Permission = "unhide_record" 11 - PermissionBlacklistUser Permission = "blacklist_user" 12 - PermissionUnblacklistUser Permission = "unblacklist_user" 13 - PermissionViewReports Permission = "view_reports" 14 - PermissionDismissReport Permission = "dismiss_report" 15 - PermissionViewAuditLog Permission = "view_audit_log" 16 - PermissionResetAutoHide Permission = "reset_autohide" 9 + PermissionHideRecord Permission = "hide_record" 10 + PermissionUnhideRecord Permission = "unhide_record" 11 + PermissionBlacklistUser Permission = "blacklist_user" 12 + PermissionUnblacklistUser Permission = "unblacklist_user" 13 + PermissionViewReports Permission = "view_reports" 14 + PermissionDismissReport Permission = "dismiss_report" 15 + PermissionViewAuditLog Permission = "view_audit_log" 16 + PermissionResetAutoHide Permission = "reset_autohide" 17 17 ) 18 18 19 19 // AllPermissions returns all available permissions ··· 107 107 type HiddenRecord struct { 108 108 ATURI string `json:"at_uri"` 109 109 HiddenAt time.Time `json:"hidden_at"` 110 - HiddenBy string `json:"hidden_by"` // DID of moderator 110 + HiddenBy string `json:"hidden_by"` // DID of moderator 111 111 Reason string `json:"reason"` 112 112 AutoHidden bool `json:"auto_hidden"` // true if hidden by automod 113 113 } ··· 146 146 type AuditAction string 147 147 148 148 const ( 149 - AuditActionHideRecord AuditAction = "hide_record" 150 - AuditActionUnhideRecord AuditAction = "unhide_record" 151 - AuditActionBlacklistUser AuditAction = "blacklist_user" 152 - AuditActionUnblacklistUser AuditAction = "unblacklist_user" 149 + AuditActionHideRecord AuditAction = "hide_record" 150 + AuditActionUnhideRecord AuditAction = "unhide_record" 151 + AuditActionBlacklistUser AuditAction = "blacklist_user" 152 + AuditActionUnblacklistUser AuditAction = "unblacklist_user" 153 153 AuditActionDismissReport AuditAction = "dismiss_report" 154 154 AuditActionActionReport AuditAction = "action_report" 155 155 AuditActionResetAutoHide AuditAction = "reset_autohide"
+1 -1
internal/moderation/service.go
··· 16 16 configPath string 17 17 18 18 // Quick lookup maps built from config 19 - userRoles map[string]*Role // DID -> Role 19 + userRoles map[string]*Role // DID -> Role 20 20 userInfos map[string]*ModeratorUser // DID -> ModeratorUser 21 21 } 22 22
+3
internal/routing/routing.go
··· 138 138 mux.HandleFunc("GET /notifications", h.HandleNotifications) 139 139 mux.Handle("POST /api/notifications/read", cop.Handler(http.HandlerFunc(h.HandleNotificationsMarkRead))) 140 140 141 + // Settings 142 + mux.HandleFunc("GET /settings", h.HandleSettings) 143 + 141 144 // Profile routes (public user profiles) 142 145 mux.HandleFunc("GET /profile/{actor}", h.HandleProfile) 143 146
+2 -1
internal/web/components/brew_list_table.templ
··· 143 143 <td class="px-4 py-4 whitespace-nowrap text-sm text-brown-900 align-top"> 144 144 if brew.Rating > 0 { 145 145 <span class="badge-rating-sm"> 146 - @IconStar() { fmt.Sprintf("%d/10", brew.Rating) } 146 + @IconStar() 147 + { fmt.Sprintf("%d/10", brew.Rating) } 147 148 </span> 148 149 } else { 149 150 <span class="text-brown-400">-</span>
+2 -2
internal/web/components/header.templ
··· 83 83 <a href="/manage" class="dropdown-item"> 84 84 Manage Records 85 85 </a> 86 - <a href="#" class="dropdown-item-disabled"> 87 - Settings (coming soon) 86 + <a href="/settings" class="dropdown-item"> 87 + Settings 88 88 </a> 89 89 if props.IsModerator { 90 90 <div class="dropdown-divider"></div>
+8
internal/web/components/layout.templ
··· 48 48 <!DOCTYPE html> 49 49 <html lang="en" class="h-full" style="background-color: var(--page-bg);"> 50 50 <head> 51 + <script nonce={ data.CSPNonce }> 52 + // Apply theme before render to prevent flash 53 + (function() { 54 + var t = localStorage.getItem('arabica-theme'); 55 + if (t === 'dark' || t === 'light') document.documentElement.setAttribute('data-theme', t); 56 + })(); 57 + </script> 51 58 <meta charset="UTF-8"/> 52 59 <meta name="viewport" content="width=device-width, initial-scale=1.0"/> 53 60 <meta name="description" content="Arabica is a coffee brew tracking app built on AT Protocol. Your brewing data is stored in your own Personal Data Server, giving you full ownership and portability."/> ··· 243 250 <script src="/static/js/entity-manager.js?v=0.1.0"></script> 244 251 <script src="/static/js/dropdown-manager.js?v=0.1.0"></script> 245 252 <!-- Load Alpine components BEFORE Alpine.js initializes --> 253 + <script src="/static/js/theme.js?v=0.1.0"></script> 246 254 <script src="/static/js/brew-form.js?v=0.3.2"></script> 247 255 <script src="/static/js/entity-suggest.js?v=0.1.0"></script> 248 256 <!-- Load Alpine.js core with defer (will initialize after DOM loads) -->
+13
internal/web/pages/brew_form.templ
··· 22 22 23 23 // Recipe rkey from URL param (for auto-applying recipe on new brew) 24 24 RecipeRKey string 25 + // Recipe owner DID from URL param (for cross-user recipe references) 26 + RecipeOwnerDID string 25 27 } 26 28 27 29 // BrewFormPage renders the full brew form page with layout ··· 70 72 return "" 71 73 } 72 74 75 + func getFormRecipeOwnerDID(props BrewFormProps) string { 76 + if props.RecipeOwnerDID != "" { 77 + return props.RecipeOwnerDID 78 + } 79 + return "" 80 + } 81 + 73 82 // BrewFormElement renders the form element with all fields 74 83 templ BrewFormElement(props BrewFormProps) { 75 84 <form ··· 88 97 } 89 98 if getFormRecipeRKey(props) != "" { 90 99 data-recipe-rkey={ getFormRecipeRKey(props) } 100 + } 101 + if getFormRecipeOwnerDID(props) != "" { 102 + data-recipe-owner={ getFormRecipeOwnerDID(props) } 91 103 } 92 104 > 93 105 @ModeChooser(props) ··· 127 139 // RecipeModeSection renders recipe select, summary bar, override fields, and non-recipe fields 128 140 templ RecipeModeSection(props BrewFormProps) { 129 141 <fieldset x-show="formMode === 'recipe'" :disabled="formMode !== 'recipe'" x-cloak class="space-y-6 border-0 p-0 m-0 min-w-0"> 142 + <input type="hidden" name="recipe_owner_did" :value="recipeOwnerDID"/> 130 143 @RecipeSelectField(props) 131 144 <!-- Recipe summary bar --> 132 145 <div class="section-box" x-show="activeRecipe" x-cloak>
+1 -1
internal/web/pages/recipe_explore.templ
··· 400 400 </template> 401 401 <div class="flex flex-col sm:flex-row gap-2 sm:gap-3"> 402 402 <a 403 - :href="'/brews/new?recipe=' + selectedRecipe.rkey" 403 + :href="'/brews/new?recipe=' + selectedRecipe.rkey + '&recipe_owner=' + (selectedRecipe.author_did || '')" 404 404 class="btn-primary text-sm text-center" 405 405 > 406 406 Use in Brew
+1 -1
internal/web/pages/recipe_view.templ
··· 94 94 <div class="flex flex-col sm:flex-row gap-2 sm:gap-3 sm:items-center"> 95 95 @components.BackButton() 96 96 if props.IsAuthenticated { 97 - <a href={ templ.SafeURL("/brews/new?recipe=" + props.Recipe.RKey) } class="btn-primary text-sm text-center">Use in Brew</a> 97 + <a href={ templ.SafeURL("/brews/new?recipe=" + props.Recipe.RKey + "&recipe_owner=" + props.Recipe.AuthorDID) } class="btn-primary text-sm text-center">Use in Brew</a> 98 98 } 99 99 if props.IsAuthenticated && !props.IsOwnProfile { 100 100 <button
+46
internal/web/pages/settings.templ
··· 1 + package pages 2 + 3 + import "arabica/internal/web/components" 4 + 5 + templ settingsContent() { 6 + <div class="page-container-sm py-6"> 7 + <h1 class="page-title mb-6">Settings</h1> 8 + <div class="card card-inner"> 9 + <h2 class="text-lg font-semibold mb-4" style="color: var(--text-primary);">Appearance</h2> 10 + <div x-data="themeSettings()"> 11 + <label class="form-label">Theme</label> 12 + <p class="text-sm mb-3" style="color: var(--text-muted);">Choose how Arabica looks for you. System will follow your OS setting.</p> 13 + <div class="flex flex-wrap gap-2"> 14 + <button 15 + type="button" 16 + class="filter-pill" 17 + :class="theme === 'system' ? 'filter-pill-active' : 'filter-pill'" 18 + @click="setTheme('system')" 19 + > 20 + System 21 + </button> 22 + <button 23 + type="button" 24 + class="filter-pill" 25 + :class="theme === 'light' ? 'filter-pill-active' : 'filter-pill'" 26 + @click="setTheme('light')" 27 + > 28 + Light 29 + </button> 30 + <button 31 + type="button" 32 + class="filter-pill" 33 + :class="theme === 'dark' ? 'filter-pill-active' : 'filter-pill'" 34 + @click="setTheme('dark')" 35 + > 36 + Dark 37 + </button> 38 + </div> 39 + </div> 40 + </div> 41 + </div> 42 + } 43 + 44 + templ Settings(data *components.LayoutData) { 45 + @components.Layout(data, settingsContent()) 46 + }
+108 -2
static/css/app.css
··· 111 111 } 112 112 113 113 /* ======================================== 114 - Dark theme (auto via prefers-color-scheme) 114 + Dark theme 115 + Activates via: OS preference OR data-theme="dark" on <html> 116 + data-theme="light" forces light even on dark OS 115 117 ======================================== */ 116 118 @media (prefers-color-scheme: dark) { 117 - :root { 119 + :root:not([data-theme="light"]) { 118 120 /* Page */ 119 121 --page-bg: #0F0A08; 120 122 --page-text: #FAF7F5; ··· 262 264 .decoration-brown-300 { text-decoration-color: var(--card-border) !important; } 263 265 .hover\:decoration-brown-500:hover { text-decoration-color: var(--text-faint) !important; } 264 266 } 267 + 268 + /* Manual dark theme override (data-theme="dark" on <html>) */ 269 + :root[data-theme="dark"] { 270 + --page-bg: #0F0A08; 271 + --page-text: #FAF7F5; 272 + --card-bg: #1C1210; 273 + --card-border: #2E211B; 274 + --card-shadow: rgba(0, 0, 0, 0.3); 275 + --card-shadow-hover: rgba(0, 0, 0, 0.4); 276 + --surface-bg: rgba(36, 26, 22, 0.6); 277 + --surface-border: #2E211B; 278 + --header-bg-from: #0F0A08; 279 + --header-bg-to: #0F0A08; 280 + --header-border: #2E211B; 281 + --header-text: #FAF7F5; 282 + --text-primary: #FAF7F5; 283 + --text-secondary: #E0CEC4; 284 + --text-muted: #C4A898; 285 + --text-faint: #8B7265; 286 + --text-placeholder: #5A4A40; 287 + --btn-primary-bg: #7f5539; 288 + --btn-primary-bg-hover: #6b4423; 289 + --btn-primary-text: #FAF7F5; 290 + --btn-secondary-bg: #241A16; 291 + --btn-secondary-border: #3D2D24; 292 + --btn-secondary-text: #E0CEC4; 293 + --btn-secondary-bg-hover: #2E211B; 294 + --input-bg: #241A16; 295 + --input-border: #3D2D24; 296 + --input-border-focus: #fbbf24; 297 + --input-ring-focus: rgba(251, 191, 36, 0.15); 298 + --input-bg-focus: rgba(36, 26, 22, 0.8); 299 + --table-bg: #1C1210; 300 + --table-header-bg: #241A16; 301 + --table-border: #2E211B; 302 + --table-row-hover: #241A16; 303 + --table-divider: #241A16; 304 + --modal-bg: #1C1210; 305 + --modal-border: #2E211B; 306 + --modal-backdrop: rgba(0, 0, 0, 0.6); 307 + --type-brew: #6b4423; 308 + --type-bean: #b45309; 309 + --type-recipe: #7f5539; 310 + --type-roaster: #92400e; 311 + --type-grinder: #6b4423; 312 + --type-brewer: #6b4423; 313 + --rating-bg: rgba(251, 191, 36, 0.15); 314 + --rating-text: #fbbf24; 315 + --alert-warning-bg: rgba(251, 191, 36, 0.08); 316 + --alert-warning-border: rgba(251, 191, 36, 0.3); 317 + --alert-warning-text: #fde68a; 318 + --alert-warning-text-muted: #fcd34d; 319 + --shadow-sm: 0 1px 3px var(--card-shadow); 320 + --shadow-md: 0 4px 12px var(--card-shadow-hover); 321 + --shadow-lg: 0 10px 25px var(--card-shadow-hover); 322 + --footer-bg: #0F0A08; 323 + --footer-border: #2E211B; 324 + } 325 + 326 + /* Tailwind class overrides for dark theme (both auto and manual) */ 327 + :root[data-theme="dark"] .text-brown-900, 328 + :root[data-theme="dark"] .text-brown-800 { color: var(--text-primary) !important; } 329 + :root[data-theme="dark"] .text-brown-700 { color: var(--text-secondary) !important; } 330 + :root[data-theme="dark"] .text-brown-600 { color: var(--text-muted) !important; } 331 + :root[data-theme="dark"] .text-brown-500 { color: var(--text-faint) !important; } 332 + :root[data-theme="dark"] .text-brown-400 { color: var(--text-placeholder) !important; } 333 + :root[data-theme="dark"] .bg-brown-50 { background-color: var(--page-bg) !important; } 334 + :root[data-theme="dark"] .bg-brown-100 { background-color: var(--surface-bg) !important; } 335 + :root[data-theme="dark"] .bg-brown-200 { background-color: var(--card-border) !important; } 336 + :root[data-theme="dark"] .border-brown-200, 337 + :root[data-theme="dark"] .border-brown-300 { border-color: var(--card-border) !important; } 338 + :root[data-theme="dark"] .bg-white { background-color: var(--card-bg) !important; } 339 + :root[data-theme="dark"] .bg-white\/60 { background-color: var(--surface-bg) !important; } 340 + :root[data-theme="dark"] .bg-amber-50 { background-color: rgba(251, 191, 36, 0.1) !important; } 341 + :root[data-theme="dark"] .bg-amber-100 { background-color: rgba(251, 191, 36, 0.15) !important; } 342 + :root[data-theme="dark"] .text-amber-900 { color: #fde68a !important; } 343 + :root[data-theme="dark"] .text-amber-700 { color: #fcd34d !important; } 344 + :root[data-theme="dark"] .text-amber-600 { color: #fbbf24 !important; } 345 + :root[data-theme="dark"] .border-amber-400 { border-color: rgba(251, 191, 36, 0.4) !important; } 346 + :root[data-theme="dark"] .bg-green-50 { background-color: rgba(251, 191, 36, 0.08) !important; } 347 + :root[data-theme="dark"] .hover\:bg-brown-200:hover, 348 + :root[data-theme="dark"] .hover\:bg-brown-100:hover, 349 + :root[data-theme="dark"] .hover\:bg-brown-50:hover { background-color: var(--surface-bg) !important; } 350 + :root[data-theme="dark"] .hover\:text-brown-900:hover, 351 + :root[data-theme="dark"] .hover\:text-brown-800:hover { color: var(--text-primary) !important; } 352 + :root[data-theme="dark"] .hover\:text-brown-700:hover { color: var(--text-secondary) !important; } 353 + :root[data-theme="dark"] .divide-brown-200 > :not([hidden]) ~ :not([hidden]), 354 + :root[data-theme="dark"] .divide-brown-300 > :not([hidden]) ~ :not([hidden]) { border-color: var(--card-border) !important; } 355 + :root[data-theme="dark"] .ring-brown-500 { --tw-ring-color: #3D2D24 !important; } 356 + :root[data-theme="dark"] .hover\:ring-brown-400:hover { --tw-ring-color: #5A4A40 !important; } 357 + :root[data-theme="dark"] .bg-brown-50\/60 { background-color: var(--surface-bg) !important; } 358 + :root[data-theme="dark"] .bg-brown-200\/60 { background-color: var(--card-border) !important; } 359 + :root[data-theme="dark"] .border-brown-200\/60 { border-color: var(--card-border) !important; } 360 + :root[data-theme="dark"] .border-white { border-color: var(--card-bg) !important; } 361 + :root[data-theme="dark"] .decoration-brown-300 { text-decoration-color: var(--card-border) !important; } 362 + :root[data-theme="dark"] .hover\:decoration-brown-500:hover { text-decoration-color: var(--text-faint) !important; } 265 363 266 364 @tailwind base; 267 365 @tailwind components; ··· 684 782 border-color: var(--input-border-focus); 685 783 background: var(--surface-bg); 686 784 } 785 + 786 + /* Type-aware hover: preview the active color on hover */ 787 + .filter-pill[data-tab="brew"]:hover { border-color: var(--type-brew); color: var(--type-brew); } 788 + .filter-pill[data-tab="bean"]:hover { border-color: var(--type-bean); color: var(--type-bean); } 789 + .filter-pill[data-tab="recipe"]:hover { border-color: var(--type-recipe); color: var(--type-recipe); } 790 + .filter-pill[data-tab="roaster"]:hover { border-color: var(--type-roaster); color: var(--type-roaster); } 791 + .filter-pill[data-tab="grinder"]:hover { border-color: var(--type-grinder); color: var(--type-grinder); } 792 + .filter-pill[data-tab="brewer"]:hover { border-color: var(--type-brewer); color: var(--type-brewer); } 687 793 688 794 .filter-pill-active { 689 795 @apply inline-flex items-center gap-1.5 px-3 py-1 text-xs font-medium rounded-full transition-all cursor-pointer;
+23 -1
static/js/brew-form.js
··· 25 25 totalCount: 0, 26 26 recipes: [], 27 27 28 + // Recipe owner DID (for cross-user recipe references) 29 + recipeOwnerDID: "", 30 + 28 31 // Dropdown manager instance 29 32 dropdownManager: null, 30 33 ··· 46 49 47 50 this.isEditing = formEl?.hasAttribute("data-editing") || false; 48 51 const recipeRKey = formEl?.getAttribute("data-recipe-rkey") || ""; 52 + this.recipeOwnerDID = formEl?.getAttribute("data-recipe-owner") || ""; 49 53 50 54 // Load existing pours if editing 51 55 const poursData = formEl?.getAttribute("data-pours"); ··· 274 278 if (!rkey) { 275 279 this.clearRecipeFields(form); 276 280 this.activeRecipe = null; 281 + this.recipeOwnerDID = ""; 277 282 this.recipeSummaryExpanded = false; 278 283 this.updatePoursVisibility(); 279 284 return; 280 285 } 281 286 287 + // Look up owner DID from cached recipes (for dropdown selections) 288 + const cachedRecipe = this.recipes.find( 289 + (r) => (r.rkey || r.RKey) === rkey, 290 + ); 291 + if (cachedRecipe && cachedRecipe.author_did) { 292 + this.recipeOwnerDID = cachedRecipe.author_did; 293 + } 294 + 282 295 try { 283 - const resp = await fetch(`/api/recipes/${rkey}`, { 296 + let url = `/api/recipes/${rkey}`; 297 + if (this.recipeOwnerDID) { 298 + url += `?owner=${encodeURIComponent(this.recipeOwnerDID)}`; 299 + } 300 + const resp = await fetch(url, { 284 301 credentials: "same-origin", 285 302 }); 286 303 if (!resp.ok) return; ··· 289 306 // Store recipe data for summary display 290 307 this.activeRecipe = recipe; 291 308 this.recipeSummaryExpanded = false; 309 + 310 + // Track owner DID from API response 311 + if (recipe.author_did) { 312 + this.recipeOwnerDID = recipe.author_did; 313 + } 292 314 293 315 // Set or clear each field based on recipe data 294 316 this.setFormField(
+30
static/js/theme.js
··· 1 + // Apply saved theme to document 2 + // Called on initial load (from head script), HTMX navigations, and history restores 3 + function applyTheme() { 4 + var t = localStorage.getItem('arabica-theme'); 5 + if (t === 'dark' || t === 'light') { 6 + document.documentElement.setAttribute('data-theme', t); 7 + } else { 8 + document.documentElement.removeAttribute('data-theme'); 9 + } 10 + } 11 + 12 + // Re-apply theme after HTMX swaps and history restores 13 + document.addEventListener('htmx:afterSettle', applyTheme); 14 + document.addEventListener('htmx:historyRestore', applyTheme); 15 + 16 + // Theme settings Alpine.js component 17 + function themeSettings() { 18 + return { 19 + theme: localStorage.getItem('arabica-theme') || 'system', 20 + setTheme(value) { 21 + this.theme = value; 22 + if (value === 'system') { 23 + localStorage.removeItem('arabica-theme'); 24 + } else { 25 + localStorage.setItem('arabica-theme', value); 26 + } 27 + applyTheme(); 28 + } 29 + }; 30 + }
+15
tailwind.config.js
··· 4 4 content: [ 5 5 "./internal/**/*.templ", 6 6 "./web/**/*.{html,js}", 7 + "./static/js/**/*.js", 8 + ], 9 + safelist: [ 10 + 'filter-pill-brew', 11 + 'filter-pill-bean', 12 + 'filter-pill-recipe', 13 + 'filter-pill-roaster', 14 + 'filter-pill-grinder', 15 + 'filter-pill-brewer', 16 + 'feed-card-brew', 17 + 'feed-card-bean', 18 + 'feed-card-recipe', 19 + 'feed-card-roaster', 20 + 'feed-card-grinder', 21 + 'feed-card-brewer', 7 22 ], 8 23 theme: { 9 24 extend: {