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

feat: non-brew records in community feed

pdewey.com 2d7c59cd 4c57d13b

verified
+579 -59
+1
AGENTS.md
··· 1 + CLAUDE.md
+284
CLAUDE.md
··· 1 + # Arabica - Project Context for AI Agents 2 + 3 + Coffee brew tracking application using AT Protocol for decentralized storage. 4 + 5 + ## Tech Stack 6 + 7 + - **Language:** Go 1.21+ 8 + - **HTTP:** stdlib `net/http` with Go 1.22 routing 9 + - **Storage:** AT Protocol PDS (user data), BoltDB (sessions/feed registry) 10 + - **Frontend:** HTMX + Alpine.js + Tailwind CSS 11 + - **Templates:** html/template 12 + - **Logging:** zerolog 13 + 14 + ## Project Structure 15 + 16 + ``` 17 + cmd/server/main.go # Application entry point 18 + internal/ 19 + atproto/ # AT Protocol integration 20 + client.go # Authenticated PDS client (XRPC calls) 21 + oauth.go # OAuth flow with PKCE/DPOP 22 + store.go # database.Store implementation using PDS 23 + cache.go # Per-session in-memory cache 24 + records.go # Model <-> ATProto record conversion 25 + resolver.go # AT-URI parsing and reference resolution 26 + public_client.go # Unauthenticated public API access 27 + nsid.go # Collection NSIDs and AT-URI builders 28 + handlers/ 29 + handlers.go # HTTP handlers for all routes 30 + auth.go # OAuth login/logout/callback 31 + bff/ 32 + render.go # Template rendering helpers 33 + helpers.go # View helpers (formatting, etc.) 34 + database/ 35 + store.go # Store interface definition 36 + boltstore/ # BoltDB implementation for sessions 37 + feed/ 38 + service.go # Community feed aggregation 39 + registry.go # User registration for feed 40 + models/ 41 + models.go # Domain models and request types 42 + middleware/ 43 + logging.go # Request logging middleware 44 + routing/ 45 + routing.go # Router setup and middleware chain 46 + lexicons/ # AT Protocol lexicon definitions (JSON) 47 + templates/ # HTML templates 48 + web/static/ # CSS, JS, manifest 49 + ``` 50 + 51 + ## Key Concepts 52 + 53 + ### AT Protocol Integration 54 + 55 + User data stored in their Personal Data Server (PDS), not locally. The app: 56 + 57 + 1. Authenticates via OAuth (indigo SDK handles PKCE/DPOP) 58 + 2. Gets access token scoped to user's DID 59 + 3. Performs CRUD via XRPC calls to user's PDS 60 + 61 + **Collections (NSIDs):** 62 + 63 + - `social.arabica.alpha.bean` - Coffee beans 64 + - `social.arabica.alpha.roaster` - Roasters 65 + - `social.arabica.alpha.grinder` - Grinders 66 + - `social.arabica.alpha.brewer` - Brewing devices 67 + - `social.arabica.alpha.brew` - Brew sessions (references bean, grinder, brewer) 68 + 69 + **Record keys:** TID format (timestamp-based identifiers) 70 + 71 + **References:** Records reference each other via AT-URIs (`at://did/collection/rkey`) 72 + 73 + ### Store Interface 74 + 75 + `internal/database/store.go` defines the `Store` interface. Two implementations: 76 + 77 + - `AtprotoStore` - Production, stores in user's PDS 78 + - BoltDB stores only sessions and feed registry (not user data) 79 + 80 + All Store methods take `context.Context` as first parameter. 81 + 82 + ### Request Flow 83 + 84 + 1. Request hits middleware (logging, auth check) 85 + 2. Auth middleware extracts DID + session ID from cookies 86 + 3. Handler creates `AtprotoStore` scoped to user 87 + 4. Store methods make XRPC calls to user's PDS 88 + 5. Results rendered via BFF templates or returned as JSON 89 + 90 + ### Caching 91 + 92 + `SessionCache` caches user data in memory (5-minute TTL): 93 + 94 + - Avoids repeated PDS calls for same data 95 + - Invalidated on writes 96 + - Background cleanup removes expired entries 97 + 98 + ## Common Tasks 99 + 100 + ### Run Development Server 101 + 102 + ```bash 103 + go run cmd/server/main.go 104 + # or 105 + nix run 106 + ``` 107 + 108 + ### Run Tests 109 + 110 + ```bash 111 + go test ./... 112 + ``` 113 + 114 + ### Build 115 + 116 + ```bash 117 + go build -o arabica cmd/server/main.go 118 + ``` 119 + 120 + ## Environment Variables 121 + 122 + | Variable | Default | Description | 123 + | ------------------- | --------------------------------- | ---------------------------- | 124 + | `PORT` | 18910 | HTTP server port | 125 + | `SERVER_PUBLIC_URL` | - | Public URL for reverse proxy | 126 + | `ARABICA_DB_PATH` | ~/.local/share/arabica/arabica.db | BoltDB path | 127 + | `SECURE_COOKIES` | false | Set true for HTTPS | 128 + | `LOG_LEVEL` | info | debug/info/warn/error | 129 + | `LOG_FORMAT` | console | console/json | 130 + 131 + ## Code Patterns 132 + 133 + ### Creating a Store 134 + 135 + ```go 136 + // In handlers, store is created per-request 137 + store, authenticated := h.getAtprotoStore(r) 138 + if !authenticated { 139 + http.Error(w, "Authentication required", http.StatusUnauthorized) 140 + return 141 + } 142 + 143 + // Use store with request context 144 + brews, err := store.ListBrews(r.Context(), userID) 145 + ``` 146 + 147 + ### Record Conversion 148 + 149 + ```go 150 + // Model -> ATProto record 151 + record, err := BrewToRecord(brew, beanURI, grinderURI, brewerURI) 152 + 153 + // ATProto record -> Model 154 + brew, err := RecordToBrew(record, atURI) 155 + ``` 156 + 157 + ### AT-URI Handling 158 + 159 + ```go 160 + // Build AT-URI 161 + uri := BuildATURI(did, NSIDBean, rkey) // at://did:plc:xxx/social.arabica.alpha.bean/abc 162 + 163 + // Parse AT-URI 164 + components, err := ResolveATURI(uri) 165 + // components.DID, components.Collection, components.RKey 166 + ``` 167 + 168 + ## Future Vision: Social Features 169 + 170 + The app currently has a basic community feed. Future plans expand social interactions leveraging AT Protocol's decentralized nature. 171 + 172 + ### Planned Lexicons 173 + 174 + ``` 175 + social.arabica.alpha.like - Like a brew (references brew AT-URI) 176 + social.arabica.alpha.comment - Comment on a brew 177 + social.arabica.alpha.follow - Follow another user 178 + social.arabica.alpha.share - Re-share a brew to your feed 179 + ``` 180 + 181 + ### Like Record (Planned) 182 + ```json 183 + { 184 + "lexicon": 1, 185 + "id": "social.arabica.alpha.like", 186 + "defs": { 187 + "main": { 188 + "type": "record", 189 + "key": "tid", 190 + "record": { 191 + "type": "object", 192 + "required": ["subject", "createdAt"], 193 + "properties": { 194 + "subject": { 195 + "type": "ref", 196 + "ref": "com.atproto.repo.strongRef", 197 + "description": "The brew being liked" 198 + }, 199 + "createdAt": { "type": "string", "format": "datetime" } 200 + } 201 + } 202 + } 203 + } 204 + } 205 + ``` 206 + 207 + ### Comment Record (Planned) 208 + ```json 209 + { 210 + "lexicon": 1, 211 + "id": "social.arabica.alpha.comment", 212 + "defs": { 213 + "main": { 214 + "type": "record", 215 + "key": "tid", 216 + "record": { 217 + "type": "object", 218 + "required": ["subject", "text", "createdAt"], 219 + "properties": { 220 + "subject": { 221 + "type": "ref", 222 + "ref": "com.atproto.repo.strongRef", 223 + "description": "The brew being commented on" 224 + }, 225 + "text": { 226 + "type": "string", 227 + "maxLength": 1000, 228 + "maxGraphemes": 300 229 + }, 230 + "createdAt": { "type": "string", "format": "datetime" } 231 + } 232 + } 233 + } 234 + } 235 + } 236 + ``` 237 + 238 + ### Implementation Approach 239 + 240 + **Cross-user interactions:** 241 + - Likes/comments stored in the actor's PDS (not the brew owner's) 242 + - Use `public_client.go` to read other users' brews 243 + - Aggregate likes/comments via relay/firehose or direct PDS queries 244 + 245 + **Feed aggregation:** 246 + - Current: Poll registered users' PDS for brews 247 + - Future: Subscribe to firehose for real-time updates 248 + - Index social interactions in local DB for fast queries 249 + 250 + **UI patterns:** 251 + - Like button on brew cards in feed 252 + - Comment thread below brew detail view 253 + - Share button to re-post with optional note 254 + - Notification system for interactions on your brews 255 + 256 + ### Key Design Decisions 257 + 258 + 1. **Strong references** - Likes/comments use `com.atproto.repo.strongRef` (URI + CID) to ensure the referenced brew hasn't changed 259 + 2. **Actor-owned data** - Your likes live in your PDS, not the brew owner's 260 + 3. **Public by default** - Social interactions are public records, readable by anyone 261 + 4. **Portable identity** - Users can switch PDS and keep their social graph 262 + 263 + ## Known Issues / TODOs 264 + 265 + See todo list in conversation for tracked issues. Key areas: 266 + 267 + - Context should flow through methods (some fixed, verify all paths) 268 + - Cache race conditions need copy-on-write pattern 269 + - Missing CID validation on record updates (AT Protocol best practice) 270 + - Rate limiting for PDS calls not implemented 271 + 272 + ## Testing 273 + 274 + Tests exist for: 275 + 276 + - `internal/atproto/` - Record conversion, NSID parsing, resolver 277 + - `internal/bff/` - Template helpers 278 + - `internal/middleware/` - Logging 279 + 280 + Missing coverage: 281 + 282 + - HTTP handlers 283 + - OAuth flow 284 + - Feed service
+27 -28
default.nix
··· 4 4 pname = "arabica"; 5 5 version = "0.1.0"; 6 6 src = ./.; 7 - vendorHash = "sha256-5TKLHMHWaOTJqZKS+UtALR+bYh9gl6wroN/eRWauxY4="; 7 + vendorHash = "sha256-phKYuiA0Lh7wy/JHcTOWFce9MwPrkz/yjxq4Z8bbEnQ="; 8 8 9 9 nativeBuildInputs = [ tailwindcss ]; 10 10 ··· 18 18 runHook postBuild 19 19 ''; 20 20 21 - installPhase = 22 - let 23 - wrapperScript = '' 24 - #!/bin/sh 25 - SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" 26 - SHARE_DIR="$SCRIPT_DIR/../share/arabica" 21 + installPhase = let 22 + wrapperScript = '' 23 + #!/bin/sh 24 + SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" 25 + SHARE_DIR="$SCRIPT_DIR/../share/arabica" 27 26 28 - # Set default database path if not specified 29 - # Uses XDG_DATA_HOME or falls back to ~/.local/share 30 - if [ -z "$ARABICA_DB_PATH" ]; then 31 - DATA_DIR="''${XDG_DATA_HOME:-$HOME/.local/share}/arabica" 32 - mkdir -p "$DATA_DIR" 33 - export ARABICA_DB_PATH="$DATA_DIR/arabica.db" 34 - fi 27 + # Set default database path if not specified 28 + # Uses XDG_DATA_HOME or falls back to ~/.local/share 29 + if [ -z "$ARABICA_DB_PATH" ]; then 30 + DATA_DIR="''${XDG_DATA_HOME:-$HOME/.local/share}/arabica" 31 + mkdir -p "$DATA_DIR" 32 + export ARABICA_DB_PATH="$DATA_DIR/arabica.db" 33 + fi 35 34 36 - cd "$SHARE_DIR" 37 - exec "$SCRIPT_DIR/arabica-unwrapped" "$@" 38 - ''; 39 - in '' 40 - mkdir -p $out/bin 41 - mkdir -p $out/share/arabica 35 + cd "$SHARE_DIR" 36 + exec "$SCRIPT_DIR/arabica-unwrapped" "$@" 37 + ''; 38 + in '' 39 + mkdir -p $out/bin 40 + mkdir -p $out/share/arabica 42 41 43 - # Copy static files and templates 44 - cp -r web $out/share/arabica/ 45 - cp -r templates $out/share/arabica/ 46 - cp arabica $out/bin/arabica-unwrapped 47 - cat > $out/bin/arabica <<'WRAPPER' 48 - ${wrapperScript} 49 - WRAPPER 50 - chmod +x $out/bin/arabica 42 + # Copy static files and templates 43 + cp -r web $out/share/arabica/ 44 + cp -r templates $out/share/arabica/ 45 + cp arabica $out/bin/arabica-unwrapped 46 + cat > $out/bin/arabica <<'WRAPPER' 47 + ${wrapperScript} 48 + WRAPPER 49 + chmod +x $out/bin/arabica 51 50 ''; 52 51 53 52 meta = with lib; {
+188 -29
internal/feed/service.go
··· 13 13 "github.com/rs/zerolog/log" 14 14 ) 15 15 16 - // FeedItem represents a brew in the social feed with author info 16 + // FeedItem represents an activity in the social feed with author info 17 17 type FeedItem struct { 18 - Brew *models.Brew 18 + // Record type and data (only one will be non-nil) 19 + RecordType string // "brew", "bean", "roaster", "grinder", "brewer" 20 + Action string // "added a new brew", "added a new bean", etc. 21 + 22 + Brew *models.Brew 23 + Bean *models.Bean 24 + Roaster *models.Roaster 25 + Grinder *models.Grinder 26 + Brewer *models.Brewer 27 + 19 28 Author *atproto.Profile 20 29 Timestamp time.Time 21 30 TimeAgo string // "2 hours ago", "yesterday", etc. ··· 35 44 } 36 45 } 37 46 38 - // GetRecentBrews fetches recent brews from all registered users 47 + // GetRecentBrews fetches recent activity (brews and other records) from all registered users 39 48 // Returns up to `limit` items sorted by most recent first 40 49 func (s *Service) GetRecentBrews(ctx context.Context, limit int) ([]*FeedItem, error) { 41 50 dids := s.registry.List() ··· 44 53 return nil, nil 45 54 } 46 55 47 - log.Debug().Int("user_count", len(dids)).Msg("feed: fetching brews from registered users") 56 + log.Debug().Int("user_count", len(dids)).Msg("feed: fetching activity from registered users") 48 57 49 - // Fetch brews from all users in parallel 50 - type userBrews struct { 51 - did string 52 - profile *atproto.Profile 53 - brews []*models.Brew 54 - err error 58 + // Fetch all records from all users in parallel 59 + type userActivity struct { 60 + did string 61 + profile *atproto.Profile 62 + brews []*models.Brew 63 + beans []*models.Bean 64 + roasters []*models.Roaster 65 + grinders []*models.Grinder 66 + brewers []*models.Brewer 67 + err error 55 68 } 56 69 57 - results := make(chan userBrews, len(dids)) 70 + results := make(chan userActivity, len(dids)) 58 71 var wg sync.WaitGroup 59 72 60 73 for _, did := range dids { ··· 62 75 go func(did string) { 63 76 defer wg.Done() 64 77 65 - result := userBrews{did: did} 78 + result := userActivity{did: did} 66 79 67 80 // Fetch profile 68 81 profile, err := s.publicClient.GetProfile(ctx, did) ··· 83 96 return 84 97 } 85 98 99 + // Fetch recent beans 100 + beansOutput, err := s.publicClient.ListRecords(ctx, did, atproto.NSIDBean, 10) 101 + if err != nil { 102 + log.Warn().Err(err).Str("did", did).Msg("failed to fetch beans for feed") 103 + } 104 + 105 + // Fetch recent roasters 106 + roastersOutput, err := s.publicClient.ListRecords(ctx, did, atproto.NSIDRoaster, 10) 107 + if err != nil { 108 + log.Warn().Err(err).Str("did", did).Msg("failed to fetch roasters for feed") 109 + } 110 + 111 + // Fetch recent grinders 112 + grindersOutput, err := s.publicClient.ListRecords(ctx, did, atproto.NSIDGrinder, 10) 113 + if err != nil { 114 + log.Warn().Err(err).Str("did", did).Msg("failed to fetch grinders for feed") 115 + } 116 + 117 + // Fetch recent brewers 118 + brewersOutput, err := s.publicClient.ListRecords(ctx, did, atproto.NSIDBrewer, 10) 119 + if err != nil { 120 + log.Warn().Err(err).Str("did", did).Msg("failed to fetch brewers for feed") 121 + } 122 + 86 123 // Fetch all beans, roasters, brewers, and grinders for this user to resolve references 87 - beansOutput, _ := s.publicClient.ListRecords(ctx, did, atproto.NSIDBean, 100) 88 - roastersOutput, _ := s.publicClient.ListRecords(ctx, did, atproto.NSIDRoaster, 100) 89 - brewersOutput, _ := s.publicClient.ListRecords(ctx, did, atproto.NSIDBrewer, 100) 90 - grindersOutput, _ := s.publicClient.ListRecords(ctx, did, atproto.NSIDGrinder, 100) 124 + allBeansOutput, _ := s.publicClient.ListRecords(ctx, did, atproto.NSIDBean, 100) 125 + allRoastersOutput, _ := s.publicClient.ListRecords(ctx, did, atproto.NSIDRoaster, 100) 126 + allBrewersOutput, _ := s.publicClient.ListRecords(ctx, did, atproto.NSIDBrewer, 100) 127 + allGrindersOutput, _ := s.publicClient.ListRecords(ctx, did, atproto.NSIDGrinder, 100) 91 128 92 129 // Build lookup maps (keyed by AT-URI) 93 130 beanMap := make(map[string]*models.Bean) ··· 97 134 grinderMap := make(map[string]*models.Grinder) 98 135 99 136 // Populate bean map 100 - if beansOutput != nil { 101 - for _, beanRecord := range beansOutput.Records { 137 + if allBeansOutput != nil { 138 + for _, beanRecord := range allBeansOutput.Records { 102 139 bean, err := atproto.RecordToBean(beanRecord.Value, beanRecord.URI) 103 140 if err == nil { 104 141 beanMap[beanRecord.URI] = bean ··· 111 148 } 112 149 113 150 // Populate roaster map 114 - if roastersOutput != nil { 115 - for _, roasterRecord := range roastersOutput.Records { 151 + if allRoastersOutput != nil { 152 + for _, roasterRecord := range allRoastersOutput.Records { 116 153 roaster, err := atproto.RecordToRoaster(roasterRecord.Value, roasterRecord.URI) 117 154 if err == nil { 118 155 roasterMap[roasterRecord.URI] = roaster ··· 121 158 } 122 159 123 160 // Populate brewer map 124 - if brewersOutput != nil { 125 - for _, brewerRecord := range brewersOutput.Records { 161 + if allBrewersOutput != nil { 162 + for _, brewerRecord := range allBrewersOutput.Records { 126 163 brewer, err := atproto.RecordToBrewer(brewerRecord.Value, brewerRecord.URI) 127 164 if err == nil { 128 165 brewerMap[brewerRecord.URI] = brewer ··· 131 168 } 132 169 133 170 // Populate grinder map 134 - if grindersOutput != nil { 135 - for _, grinderRecord := range grindersOutput.Records { 171 + if allGrindersOutput != nil { 172 + for _, grinderRecord := range allGrindersOutput.Records { 136 173 grinder, err := atproto.RecordToGrinder(grinderRecord.Value, grinderRecord.URI) 137 174 if err == nil { 138 175 grinderMap[grinderRecord.URI] = grinder ··· 181 218 } 182 219 result.brews = brews 183 220 221 + // Convert beans to models and resolve roaster references 222 + beans := make([]*models.Bean, 0) 223 + if beansOutput != nil { 224 + for _, record := range beansOutput.Records { 225 + bean, err := atproto.RecordToBean(record.Value, record.URI) 226 + if err != nil { 227 + log.Warn().Err(err).Str("uri", record.URI).Msg("failed to parse bean record") 228 + continue 229 + } 230 + 231 + // Resolve roaster reference 232 + if roasterRef, found := beanRoasterRefMap[record.URI]; found { 233 + if roaster, found := roasterMap[roasterRef]; found { 234 + bean.Roaster = roaster 235 + } 236 + } 237 + 238 + beans = append(beans, bean) 239 + } 240 + } 241 + result.beans = beans 242 + 243 + // Convert roasters to models 244 + roasters := make([]*models.Roaster, 0) 245 + if roastersOutput != nil { 246 + for _, record := range roastersOutput.Records { 247 + roaster, err := atproto.RecordToRoaster(record.Value, record.URI) 248 + if err != nil { 249 + log.Warn().Err(err).Str("uri", record.URI).Msg("failed to parse roaster record") 250 + continue 251 + } 252 + roasters = append(roasters, roaster) 253 + } 254 + } 255 + result.roasters = roasters 256 + 257 + // Convert grinders to models 258 + grinders := make([]*models.Grinder, 0) 259 + if grindersOutput != nil { 260 + for _, record := range grindersOutput.Records { 261 + grinder, err := atproto.RecordToGrinder(record.Value, record.URI) 262 + if err != nil { 263 + log.Warn().Err(err).Str("uri", record.URI).Msg("failed to parse grinder record") 264 + continue 265 + } 266 + grinders = append(grinders, grinder) 267 + } 268 + } 269 + result.grinders = grinders 270 + 271 + // Convert brewers to models 272 + brewers := make([]*models.Brewer, 0) 273 + if brewersOutput != nil { 274 + for _, record := range brewersOutput.Records { 275 + brewer, err := atproto.RecordToBrewer(record.Value, record.URI) 276 + if err != nil { 277 + log.Warn().Err(err).Str("uri", record.URI).Msg("failed to parse brewer record") 278 + continue 279 + } 280 + brewers = append(brewers, brewer) 281 + } 282 + } 283 + result.brewers = brewers 284 + 184 285 results <- result 185 286 }(did) 186 287 } ··· 198 299 continue 199 300 } 200 301 302 + totalRecords := len(result.brews) + len(result.beans) + len(result.roasters) + len(result.grinders) + len(result.brewers) 303 + 201 304 log.Debug(). 202 305 Str("did", result.did). 203 306 Str("handle", result.profile.Handle). 204 307 Int("brew_count", len(result.brews)). 205 - Msg("feed: collected brews from user") 308 + Int("bean_count", len(result.beans)). 309 + Int("roaster_count", len(result.roasters)). 310 + Int("grinder_count", len(result.grinders)). 311 + Int("brewer_count", len(result.brewers)). 312 + Int("total_records", totalRecords). 313 + Msg("feed: collected records from user") 206 314 315 + // Add brews to feed 207 316 for _, brew := range result.brews { 208 317 items = append(items, &FeedItem{ 209 - Brew: brew, 210 - Author: result.profile, 211 - Timestamp: brew.CreatedAt, 212 - TimeAgo: FormatTimeAgo(brew.CreatedAt), 318 + RecordType: "brew", 319 + Action: "added a new brew", 320 + Brew: brew, 321 + Author: result.profile, 322 + Timestamp: brew.CreatedAt, 323 + TimeAgo: FormatTimeAgo(brew.CreatedAt), 324 + }) 325 + } 326 + 327 + // Add beans to feed 328 + for _, bean := range result.beans { 329 + items = append(items, &FeedItem{ 330 + RecordType: "bean", 331 + Action: "added a new bean", 332 + Bean: bean, 333 + Author: result.profile, 334 + Timestamp: bean.CreatedAt, 335 + TimeAgo: FormatTimeAgo(bean.CreatedAt), 336 + }) 337 + } 338 + 339 + // Add roasters to feed 340 + for _, roaster := range result.roasters { 341 + items = append(items, &FeedItem{ 342 + RecordType: "roaster", 343 + Action: "added a new roaster", 344 + Roaster: roaster, 345 + Author: result.profile, 346 + Timestamp: roaster.CreatedAt, 347 + TimeAgo: FormatTimeAgo(roaster.CreatedAt), 348 + }) 349 + } 350 + 351 + // Add grinders to feed 352 + for _, grinder := range result.grinders { 353 + items = append(items, &FeedItem{ 354 + RecordType: "grinder", 355 + Action: "added a new grinder", 356 + Grinder: grinder, 357 + Author: result.profile, 358 + Timestamp: grinder.CreatedAt, 359 + TimeAgo: FormatTimeAgo(grinder.CreatedAt), 360 + }) 361 + } 362 + 363 + // Add brewers to feed 364 + for _, brewer := range result.brewers { 365 + items = append(items, &FeedItem{ 366 + RecordType: "brewer", 367 + Action: "added a new brewer", 368 + Brewer: brewer, 369 + Author: result.profile, 370 + Timestamp: brewer.CreatedAt, 371 + TimeAgo: FormatTimeAgo(brewer.CreatedAt), 213 372 }) 214 373 } 215 374 }
+79 -2
templates/partials/feed.tmpl
··· 23 23 </div> 24 24 </div> 25 25 26 + <!-- Action header --> 27 + <div class="mb-2 text-sm text-gray-600 italic"> 28 + {{.Action}} 29 + </div> 30 + 31 + <!-- Record content --> 32 + {{if eq .RecordType "brew"}} 26 33 <!-- Brew info --> 27 34 <div class="bg-gray-50 rounded-lg p-3"> 28 35 <!-- Bean and roaster info --> ··· 87 94 </div> 88 95 {{end}} 89 96 </div> 97 + {{else if eq .RecordType "bean"}} 98 + <!-- Bean info --> 99 + <div class="bg-gray-50 rounded-lg p-3"> 100 + <div class="text-base mb-2"> 101 + <span class="font-bold text-gray-900"> 102 + {{if .Bean.Name}}{{.Bean.Name}}{{else}}{{.Bean.Origin}}{{end}} 103 + </span> 104 + {{if and .Bean.Roaster .Bean.Roaster.Name}} 105 + <span class="text-gray-600"> from {{.Bean.Roaster.Name}}</span> 106 + {{end}} 107 + </div> 108 + <div class="text-sm text-gray-600 space-y-1"> 109 + {{if .Bean.Origin}} 110 + <div><span class="text-gray-500">Origin:</span> {{.Bean.Origin}}</div> 111 + {{end}} 112 + {{if .Bean.RoastLevel}} 113 + <div><span class="text-gray-500">Roast:</span> {{.Bean.RoastLevel}}</div> 114 + {{end}} 115 + {{if .Bean.Process}} 116 + <div><span class="text-gray-500">Process:</span> {{.Bean.Process}}</div> 117 + {{end}} 118 + {{if .Bean.Description}} 119 + <div class="mt-2 text-gray-700 italic">"{{.Bean.Description}}"</div> 120 + {{end}} 121 + </div> 122 + </div> 123 + {{else if eq .RecordType "roaster"}} 124 + <!-- Roaster info --> 125 + <div class="bg-gray-50 rounded-lg p-3"> 126 + <div class="text-base mb-2"> 127 + <span class="font-bold text-gray-900">{{.Roaster.Name}}</span> 128 + </div> 129 + <div class="text-sm text-gray-600 space-y-1"> 130 + {{if .Roaster.Location}} 131 + <div><span class="text-gray-500">Location:</span> {{.Roaster.Location}}</div> 132 + {{end}} 133 + {{if .Roaster.Website}} 134 + <div><span class="text-gray-500">Website:</span> <a href="{{.Roaster.Website}}" target="_blank" class="text-blue-600 hover:underline">{{.Roaster.Website}}</a></div> 135 + {{end}} 136 + </div> 137 + </div> 138 + {{else if eq .RecordType "grinder"}} 139 + <!-- Grinder info --> 140 + <div class="bg-gray-50 rounded-lg p-3"> 141 + <div class="text-base mb-2"> 142 + <span class="font-bold text-gray-900">{{.Grinder.Name}}</span> 143 + </div> 144 + <div class="text-sm text-gray-600 space-y-1"> 145 + {{if .Grinder.GrinderType}} 146 + <div><span class="text-gray-500">Type:</span> {{.Grinder.GrinderType}}</div> 147 + {{end}} 148 + {{if .Grinder.BurrType}} 149 + <div><span class="text-gray-500">Burr:</span> {{.Grinder.BurrType}}</div> 150 + {{end}} 151 + {{if .Grinder.Notes}} 152 + <div class="mt-2 text-gray-700 italic">"{{.Grinder.Notes}}"</div> 153 + {{end}} 154 + </div> 155 + </div> 156 + {{else if eq .RecordType "brewer"}} 157 + <!-- Brewer info --> 158 + <div class="bg-gray-50 rounded-lg p-3"> 159 + <div class="text-base mb-2"> 160 + <span class="font-bold text-gray-900">{{.Brewer.Name}}</span> 161 + </div> 162 + {{if .Brewer.Description}} 163 + <div class="text-sm text-gray-700 italic">"{{.Brewer.Description}}"</div> 164 + {{end}} 165 + </div> 166 + {{end}} 90 167 </div> 91 168 {{end}} 92 169 {{else}} 93 170 <div class="bg-gray-50 rounded-lg p-6 text-center text-gray-500"> 94 - <p class="mb-2">No brews in the feed yet.</p> 95 - <p class="text-sm">Be the first to log a brew!</p> 171 + <p class="mb-2">No activity in the feed yet.</p> 172 + <p class="text-sm">Be the first to add something!</p> 96 173 </div> 97 174 {{end}} 98 175 </div>