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

Configure Feed

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

feat: improved opengraph embeds

authored by

Patrick Dewey and committed by tangled.org 75444c4b b908a0d2

+1904 -30
+1 -22
README.md
··· 1 1 # Arabica 2 2 3 - Coffee brew tracking application build on ATProto 3 + Coffee brew logging application built on ATProto 4 4 5 5 Development is on Tangled, and is mirrored to GitHub: 6 6 7 7 - [Tangled](https://tangled.org/arabica.social/arabica) 8 8 - [GitHub](https://github.com/arabica-social/arabica) 9 - 10 - ## Features 11 - 12 - - Track coffee brews with detailed parameters 13 - - Store data in your AT Protocol Personal Data Server 14 - - Community feed of recent brews from registered users (polling or real-time 15 - firehose) 16 - - Manage beans, roasters, grinders, and brewers 17 - - Export brew data as JSON 18 - - Mobile-friendly PWA design 19 - 20 - ## Tech Stack 21 - 22 - - Backend: Go with stdlib HTTP router 23 - - Storage: AT Protocol Personal Data Servers + BoltDB for local cache 24 - - Templates: Templ (type-safe Go templates) 25 - - Frontend: HTMX + Alpine.js + Tailwind CSS 26 9 27 10 ## Quick Start 28 11 ··· 113 96 The `SERVER_PUBLIC_URL` is used for OAuth client metadata and callback URLs, 114 97 ensuring the AT Protocol OAuth flow works correctly when the server is accessed 115 98 via a different URL than it's running on. 116 - 117 - ### NixOS Deployment 118 - 119 - See docs/nix-install.md for NixOS deployment instructions. 120 99 121 100 ## License 122 101
+178
cmd/ogtest/main.go
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + 7 + "arabica/internal/models" 8 + "arabica/internal/ogcard" 9 + ) 10 + 11 + func main() { 12 + type testCase struct { 13 + path string 14 + gen func() (*ogcard.Card, error) 15 + } 16 + 17 + cases := []testCase{ 18 + // Brew - full V60 pourover with pours + pourover params 19 + {"/tmp/og-brew-pourover.png", func() (*ogcard.Card, error) { 20 + return ogcard.DrawBrewCard(&models.Brew{ 21 + Rating: 8, Temperature: 93, WaterAmount: 250, CoffeeAmount: 15, 22 + TimeSeconds: 195, GrindSize: "22 clicks", 23 + Bean: &models.Bean{ 24 + Name: "Ethiopia Yirgacheffe", Origin: "Ethiopia", RoastLevel: "Light", 25 + Roaster: &models.Roaster{Name: "Sweet Maria's"}, 26 + }, 27 + BrewerObj: &models.Brewer{Name: "V60", BrewerType: "pourover"}, 28 + GrinderObj: &models.Grinder{Name: "Comandante C40"}, 29 + Pours: []*models.Pour{ 30 + {WaterAmount: 50, TimeSeconds: 0}, 31 + {WaterAmount: 100, TimeSeconds: 45}, 32 + {WaterAmount: 100, TimeSeconds: 75}, 33 + }, 34 + PouroverParams: &models.PouroverParams{ 35 + BloomWater: 45, BloomSeconds: 30, DrawdownSeconds: 90, Filter: "paper", 36 + }, 37 + TastingNotes: "Bright and fruity with prominent blueberry and dark chocolate notes. Clean finish with a pleasant lingering sweetness.", 38 + }) 39 + }}, 40 + 41 + // Brew - espresso shot 42 + {"/tmp/og-brew-espresso.png", func() (*ogcard.Card, error) { 43 + return ogcard.DrawBrewCard(&models.Brew{ 44 + Rating: 9, Temperature: 94, WaterAmount: 36, CoffeeAmount: 18, 45 + TimeSeconds: 28, GrindSize: "8", 46 + Bean: &models.Bean{ 47 + Name: "Colombia Huila Supremo", Origin: "Colombia", RoastLevel: "Medium", 48 + Roaster: &models.Roaster{Name: "Onyx Coffee Lab"}, 49 + }, 50 + BrewerObj: &models.Brewer{Name: "Linea Mini", BrewerType: "espresso"}, 51 + GrinderObj: &models.Grinder{Name: "Niche Zero"}, 52 + EspressoParams: &models.EspressoParams{ 53 + YieldWeight: 36.5, Pressure: 9, PreInfusionSeconds: 5, 54 + }, 55 + TastingNotes: "Rich caramel, walnut, plum acidity", 56 + }) 57 + }}, 58 + 59 + // Brew - minimal (just name + rating) 60 + {"/tmp/og-brew-minimal.png", func() (*ogcard.Card, error) { 61 + return ogcard.DrawBrewCard(&models.Brew{ 62 + Rating: 6, 63 + Bean: &models.Bean{Name: "House Blend"}, 64 + }) 65 + }}, 66 + 67 + // Brew - French press immersion 68 + {"/tmp/og-brew-immersion.png", func() (*ogcard.Card, error) { 69 + return ogcard.DrawBrewCard(&models.Brew{ 70 + Rating: 7, Temperature: 100, WaterAmount: 350, CoffeeAmount: 22, 71 + TimeSeconds: 300, GrindSize: "30 clicks", 72 + Bean: &models.Bean{ 73 + Name: "Kenya AA", Origin: "Kenya", 74 + }, 75 + BrewerObj: &models.Brewer{Name: "French Press", BrewerType: "immersion"}, 76 + GrinderObj: &models.Grinder{Name: "Timemore C2"}, 77 + TastingNotes: "Bold, juicy, black currant", 78 + }) 79 + }}, 80 + 81 + // Brew - no rating, long name, lots of tasting notes 82 + {"/tmp/og-brew-long.png", func() (*ogcard.Card, error) { 83 + return ogcard.DrawBrewCard(&models.Brew{ 84 + Temperature: 96, WaterAmount: 400, CoffeeAmount: 25, TimeSeconds: 240, 85 + Bean: &models.Bean{ 86 + Name: "Finca El Paraiso Double Anaerobic Gesha Lot #147", Origin: "Colombia", RoastLevel: "Light", 87 + Roaster: &models.Roaster{Name: "Manhattan Coffee Roasters"}, 88 + }, 89 + BrewerObj: &models.Brewer{Name: "Chemex", BrewerType: "pourover"}, 90 + TastingNotes: "Incredibly complex. Layers of tropical fruit, jasmine, bergamot, and a wine-like body. The anaerobic process adds a distinctive fermented fruit character that evolves as the cup cools. One of the most memorable coffees this year.", 91 + }) 92 + }}, 93 + 94 + // Bean - full 95 + {"/tmp/og-bean.png", func() (*ogcard.Card, error) { 96 + rating := 9 97 + return ogcard.DrawBeanCard(&models.Bean{ 98 + Name: "Gesha Village Lot #74", Origin: "Ethiopia", Variety: "Gesha", 99 + RoastLevel: "Light", Process: "Washed", Rating: &rating, 100 + Roaster: &models.Roaster{Name: "Onyx Coffee Lab"}, 101 + Description: "Exceptional lot from the birthplace of Gesha. Floral jasmine and bergamot with layers of tropical fruit. Delicate tea-like body.", 102 + }) 103 + }}, 104 + 105 + // Bean - minimal 106 + {"/tmp/og-bean-minimal.png", func() (*ogcard.Card, error) { 107 + return ogcard.DrawBeanCard(&models.Bean{ 108 + Name: "Colombia Supremo", Origin: "Colombia", 109 + }) 110 + }}, 111 + 112 + // Roaster 113 + {"/tmp/og-roaster.png", func() (*ogcard.Card, error) { 114 + return ogcard.DrawRoasterCard(&models.Roaster{ 115 + Name: "Onyx Coffee Lab", Location: "Rogers, Arkansas", 116 + Website: "https://onyxcoffeelab.com", 117 + }) 118 + }}, 119 + 120 + // Grinder 121 + {"/tmp/og-grinder.png", func() (*ogcard.Card, error) { 122 + return ogcard.DrawGrinderCard(&models.Grinder{ 123 + Name: "Comandante C40 MK4", GrinderType: "Hand Grinder", BurrType: "Steel", 124 + Notes: "Outstanding grind consistency across all settings. The MK4 burrs are a significant improvement with faster grinding and better particle distribution.", 125 + }) 126 + }}, 127 + 128 + // Brewer 129 + {"/tmp/og-brewer.png", func() (*ogcard.Card, error) { 130 + return ogcard.DrawBrewerCard(&models.Brewer{ 131 + Name: "Hario V60 02", BrewerType: "pourover", 132 + Description: "Classic cone-shaped dripper with spiral ribs. Produces a clean, bright cup that highlights origin characteristics.", 133 + }) 134 + }}, 135 + 136 + // Recipe - full with pours 137 + {"/tmp/og-recipe.png", func() (*ogcard.Card, error) { 138 + return ogcard.DrawRecipeCard(&models.Recipe{ 139 + Name: "James Hoffmann V60 Method", CoffeeAmount: 15, WaterAmount: 250, 140 + BrewerType: "pourover", Ratio: 16.7, 141 + BrewerObj: &models.Brewer{Name: "Hario V60 02"}, 142 + Pours: []*models.Pour{ 143 + {WaterAmount: 50, TimeSeconds: 0}, 144 + {WaterAmount: 100, TimeSeconds: 45}, 145 + {WaterAmount: 100, TimeSeconds: 75}, 146 + }, 147 + Notes: "Start with a bloom, then two even pours. Gentle swirl after each pour for even extraction.", 148 + }) 149 + }}, 150 + 151 + // Recipe - minimal 152 + {"/tmp/og-recipe-minimal.png", func() (*ogcard.Card, error) { 153 + return ogcard.DrawRecipeCard(&models.Recipe{ 154 + Name: "Quick Aeropress", CoffeeAmount: 15, WaterAmount: 200, 155 + BrewerType: "immersion", 156 + }) 157 + }}, 158 + } 159 + 160 + for _, tc := range cases { 161 + card, err := tc.gen() 162 + if err != nil { 163 + fmt.Fprintf(os.Stderr, "Error generating %s: %v\n", tc.path, err) 164 + os.Exit(1) 165 + } 166 + f, err := os.Create(tc.path) 167 + if err != nil { 168 + fmt.Fprintf(os.Stderr, "Error creating %s: %v\n", tc.path, err) 169 + os.Exit(1) 170 + } 171 + if err := card.EncodePNG(f); err != nil { 172 + fmt.Fprintf(os.Stderr, "Error encoding %s: %v\n", tc.path, err) 173 + os.Exit(1) 174 + } 175 + f.Close() 176 + fmt.Printf("Generated %s\n", tc.path) 177 + } 178 + }
+314
docs/cafe-and-drinks.md
··· 1 + # Plan: Add Cafe and Drink Record Types 2 + 3 + ## Context 4 + 5 + Users have requested the ability to track cafe visits alongside home brews. 6 + This adds two new AT Protocol record types: 7 + - **Cafe** — an entity (like roaster) representing a coffee shop 8 + - **Drink** — an experience record (like brew but simpler) for cafe visits 9 + 10 + These are alpha lexicons (`social.arabica.alpha.cafe/drink`) designed so that 11 + field names match what a future v1 unified brew+drink record would use. The 12 + shared field names (`beanRef`, `tastingNotes`, `rating`, `createdAt`) are 13 + identical between brew and drink, making a v1 union-typed merge straightforward. 14 + 15 + ## Lexicon Design 16 + 17 + ### Cafe (`social.arabica.alpha.cafe`) 18 + | Field | Type | Required | Notes | 19 + |-------|------|----------|-------| 20 + | name | string (max 200) | yes | | 21 + | location | string (max 200) | no | | 22 + | website | string (uri, max 500) | no | | 23 + | roasterRef | string (at-uri) | no | Link to roaster this cafe serves | 24 + | sourceRef | string (at-uri) | no | Sourced-from reference | 25 + | createdAt | datetime | yes | | 26 + 27 + ### Drink (`social.arabica.alpha.drink`) 28 + | Field | Type | Required | Notes | 29 + |-------|------|----------|-------| 30 + | cafeRef | string (at-uri) | yes | Link to cafe | 31 + | beanRef | string (at-uri) | no | Same field name as brew (optional here) | 32 + | drinkStyle | string (max 100) | no | e.g. "cortado", "pour over", "latte" | 33 + | tastingNotes | string (max 2000) | no | Same field name as brew | 34 + | rating | integer (1-10) | no | Same field name as brew | 35 + | price | integer (min 0) | no | Price in cents | 36 + | createdAt | datetime | yes | | 37 + 38 + ## v1 Migration Strategy 39 + 40 + The field naming is intentional for a clean v1 migration path: 41 + 42 + 1. **Shared fields** (`beanRef`, `tastingNotes`, `rating`, `createdAt`) use 43 + identical names in both brew and drink. In v1, these become top-level fields 44 + on a unified `social.arabica.brew` record. 45 + 46 + 2. **Drink-specific fields** (`cafeRef`, `drinkStyle`, `price`) become a 47 + `cafeContext` union member in v1. 48 + 49 + 3. **Brew-specific fields** (`grinderRef`, `brewerRef`, `method`, 50 + `espressoParams`, `pouroverParams`, `pours`, etc.) become a `homeContext` 51 + union member in v1. 52 + 53 + 4. **Cafe entity** simply drops `alpha` from the NSID — no structural changes. 54 + 55 + 5. **Record conversion layer** (`internal/atproto/records.go`) is the only code 56 + that needs v1 schema awareness. Models and handlers stay the same. 57 + 58 + The v1 unified lexicon would look like: 59 + ```json 60 + { 61 + "id": "social.arabica.brew", 62 + "defs": { 63 + "main": { 64 + "record": { 65 + "properties": { 66 + "beanRef": { "type": "string", "format": "at-uri" }, 67 + "tastingNotes": { "type": "string" }, 68 + "rating": { "type": "integer" }, 69 + "createdAt": { "type": "string", "format": "datetime" }, 70 + "context": { 71 + "type": "union", 72 + "refs": ["#homeContext", "#cafeContext"] 73 + } 74 + } 75 + } 76 + }, 77 + "homeContext": { 78 + "type": "object", 79 + "properties": { 80 + "grinderRef": {}, 81 + "brewerRef": {}, 82 + "method": {}, 83 + "espressoParams": {}, 84 + "pouroverParams": {} 85 + } 86 + }, 87 + "cafeContext": { 88 + "type": "object", 89 + "properties": { 90 + "cafeRef": {}, 91 + "drinkStyle": {}, 92 + "price": {} 93 + } 94 + } 95 + } 96 + } 97 + ``` 98 + 99 + ## Implementation Phases 100 + 101 + Each phase is independently verifiable with `nix develop -c go vet ./...` and 102 + `nix develop -c go build ./...`. 103 + 104 + --- 105 + 106 + ### Phase 1: Foundation 107 + Lexicons, constants, models — everything else depends on these. 108 + 109 + #### Files to create 110 + - `lexicons/social.arabica.alpha.cafe.json` — modeled on roaster.json 111 + - `lexicons/social.arabica.alpha.drink.json` — new schema per table above 112 + 113 + #### Files to modify 114 + 115 + **`internal/atproto/nsid.go`** — add constants: 116 + - `NSIDCafe = NSIDBase + ".cafe"` (between NSIDBean and NSIDComment) 117 + - `NSIDDrink = NSIDBase + ".drink"` (between NSIDComment and NSIDGrinder) 118 + 119 + **`internal/lexicons/record_type.go`** — add: 120 + - `RecordTypeCafe RecordType = "cafe"` and `RecordTypeDrink RecordType = "drink"` 121 + - Add both to `ParseRecordType` switch 122 + - Add `DisplayName()` cases: "Cafe", "Drink" 123 + 124 + **`internal/models/models.go`** — add: 125 + - `MaxDrinkStyleLength = 100` constant 126 + - `Cafe` struct (follows Roaster pattern + `RoasterRKey` + joined `*Roaster`) 127 + - `Drink` struct (CafeRKey, BeanRKey, DrinkStyle, TastingNotes, Rating, Price + 128 + joined `*Cafe`, `*Bean`) 129 + - `CreateCafeRequest`, `UpdateCafeRequest` + `Validate()` methods 130 + - `CreateDrinkRequest`, `UpdateDrinkRequest` + `Validate()` methods 131 + 132 + --- 133 + 134 + ### Phase 2: Data Layer 135 + Record conversion, store interface, store implementation, cache. 136 + 137 + **`internal/atproto/records.go`** — add: 138 + - `CafeToRecord(cafe, roasterURI)` / `RecordToCafe(record, atURI)` — follows 139 + roaster pattern + roasterRef 140 + - `DrinkToRecord(drink, cafeURI, beanURI)` / `RecordToDrink(record, atURI)` — 141 + cafeRef required, beanRef optional 142 + 143 + **`internal/database/store.go`** — add to interface: 144 + - Cafe: `CreateCafe`, `GetCafeByRKey`, `ListCafes`, `UpdateCafeByRKey`, 145 + `DeleteCafeByRKey` 146 + - Drink: `CreateDrink`, `GetDrinkByRKey`, `ListDrinks`, `UpdateDrinkByRKey`, 147 + `DeleteDrinkByRKey` 148 + 149 + **`internal/atproto/store.go`** — implement all 10 methods: 150 + - Cafe methods follow roaster implementation pattern exactly 151 + - Drink methods follow brew implementation pattern (builds cafeRef AT-URI, 152 + optional beanRef AT-URI) 153 + - Add `LinkCafesToRoasters(cafes, roasters)` helper 154 + - Add `LinkDrinksToCafes(drinks, cafes)` and `LinkDrinksToBeans(drinks, beans)` 155 + helpers 156 + 157 + **`internal/atproto/cache.go`** — add: 158 + - `Cafes []*models.Cafe` and `Drinks []*models.Drink` to `UserCache` 159 + - Include in `clone()` method 160 + - `SetCafes`, `SetDrinks`, `InvalidateCafes`, `InvalidateDrinks` on 161 + `SessionCache` 162 + 163 + --- 164 + 165 + ### Phase 3: Protocol Layer 166 + OAuth scopes, firehose subscription and indexing. 167 + 168 + **`internal/atproto/oauth.go`** — add to `scopes` slice: 169 + - `"repo:" + NSIDCafe` (between NSIDBrewer and NSIDComment) 170 + - `"repo:" + NSIDDrink` (between NSIDComment and NSIDGrinder) 171 + 172 + **`internal/firehose/config.go`** — add to `ArabicaCollections`: 173 + - `atproto.NSIDCafe`, `atproto.NSIDDrink` 174 + 175 + **`internal/firehose/index.go`** — add: 176 + - `Cafe *models.Cafe` and `Drink *models.Drink` to `FeedItem` struct 177 + - `lexicons.RecordTypeCafe: true` and `lexicons.RecordTypeDrink: true` to 178 + `FeedableRecordTypes` 179 + - `lexicons.RecordTypeCafe: atproto.NSIDCafe` and 180 + `lexicons.RecordTypeDrink: atproto.NSIDDrink` to `recordTypeToNSID` 181 + - Switch cases in `recordToFeedItem`: cafe (simple, like roaster + resolve 182 + roasterRef), drink (resolve cafeRef + optional beanRef) 183 + 184 + **`internal/feed/service.go`** — add: 185 + - `Cafe *models.Cafe` and `Drink *models.Drink` to `FeedItem` struct 186 + - Map these fields in the firehose→feed adapter conversion 187 + 188 + --- 189 + 190 + ### Phase 4: API Layer 191 + Handlers and routing. 192 + 193 + **`internal/handlers/entities.go`** — add: 194 + - `HandleCafeCreate`, `HandleCafeUpdate`, `HandleCafeDelete` (follow roaster 195 + pattern) 196 + - `HandleDrinkCreate`, `HandleDrinkUpdate`, `HandleDrinkDelete` (follow brew 197 + pattern, simpler) 198 + - Update `HandleManagePartial` to fetch cafes + drinks in errgroup, link 199 + references, pass to props 200 + - Update `HandleAPIListAll` to include cafes and drinks 201 + 202 + **`internal/handlers/entity_views.go`** — add: 203 + - `HandleCafeView` (follows roaster view pattern) 204 + - `HandleDrinkView` (follows brew view pattern, simpler) 205 + - `HandleCafeOGImage`, `HandleDrinkOGImage` 206 + 207 + **`internal/handlers/modals.go`** — add: 208 + - `HandleCafeModalNew`, `HandleCafeModalEdit` (follow roaster modal pattern + 209 + roaster dropdown) 210 + 211 + **`internal/atproto/resolver.go`** — add: 212 + - `ResolveCafeRef` function (follows roaster ref resolution pattern) 213 + - Drink references resolved inline in handlers (cafeRef + optional beanRef) 214 + 215 + **`internal/routing/routing.go`** — add routes: 216 + ``` 217 + GET /cafes/{id}/og-image → HandleCafeOGImage 218 + GET /cafes/{id} → HandleCafeView 219 + GET /drinks/{id}/og-image → HandleDrinkOGImage 220 + GET /drinks/{id} → HandleDrinkView 221 + GET /drinks/new → HandleDrinkNew 222 + GET /drinks/{id}/edit → HandleDrinkEdit 223 + GET /drinks → HandleDrinkList 224 + POST /api/cafes → HandleCafeCreate (COP) 225 + PUT /api/cafes/{id} → HandleCafeUpdate (COP) 226 + DELETE /api/cafes/{id} → HandleCafeDelete (COP) 227 + POST /api/drinks → HandleDrinkCreate (COP) 228 + PUT /api/drinks/{id} → HandleDrinkUpdate (COP) 229 + DELETE /api/drinks/{id} → HandleDrinkDelete (COP) 230 + GET /api/modals/cafe/new → HandleCafeModalNew 231 + GET /api/modals/cafe/{id} → HandleCafeModalEdit 232 + ``` 233 + 234 + --- 235 + 236 + ### Phase 5: UI Layer 237 + Templates and components. 238 + 239 + #### Files to create 240 + - `internal/web/components/record_cafe.templ` — `CafeContent(cafe)`: name, 241 + location, website, linked roaster 242 + - `internal/web/components/record_drink.templ` — `DrinkContent(drink)`: cafe, 243 + drink style, rating, notes, price, bean 244 + - `internal/web/pages/cafe_view.templ` — detail page (follows 245 + roaster_view.templ) 246 + - `internal/web/pages/drink_view.templ` — detail page (follows brew_view.templ, 247 + simpler) 248 + - `internal/web/pages/drink_form.templ` — creation/edit form with cafe selector 249 + (required), bean selector (optional), drink style, tasting notes, rating, price 250 + - `internal/web/pages/drink_list.templ` — list page (follows brew_list.templ 251 + pattern) 252 + 253 + #### Files to modify 254 + 255 + **`internal/web/components/manage_partial.templ`** — add: 256 + - `Cafes []*models.Cafe`, `Drinks []*models.Drink`, 257 + `CafeDrinkCounts map[string]int` to props 258 + - New "Cafes" tab with cafe cards and "+ Add Cafe" button 259 + - New "Drinks" tab or section showing drink entries 260 + 261 + **`internal/web/components/entity_tables.templ`** — add: 262 + - `CafesTableProps` and `CafesTable` / `CafeCard` templ functions 263 + 264 + **`internal/web/components/dialog_modals.templ`** — add: 265 + - `CafeDialogModal(cafe, roasters)` — follows roaster modal + optional roaster 266 + `<select>` 267 + 268 + **`internal/web/pages/feed.templ`** — add: 269 + - Filter tabs: `{Label: "Cafes", Value: "cafe"}`, 270 + `{Label: "Drinks", Value: "drink"}` 271 + - Record content switch cases for `RecordTypeCafe` and `RecordTypeDrink` 272 + - Update `ActionText`, share URL, edit URL, delete URL switches 273 + 274 + **`internal/web/components/header.templ`** — add navigation link for drinks 275 + (like brews has) 276 + 277 + --- 278 + 279 + ### Phase 6: Suggestions 280 + Entity autocomplete for cafes. 281 + 282 + **`internal/handlers/suggestions.go`** — add `"cafes": atproto.NSIDCafe` to 283 + `entityTypeToNSID` 284 + 285 + **`internal/suggestions/suggestions.go`** — add cafe config: 286 + - allFields: name, location, website 287 + - searchFields: name, location 288 + - dedupKey: cafeDedupKey (fuzzy name + normalized location, same as roaster 289 + pattern) 290 + 291 + Suggestions route already handles arbitrary entity types via the map — no route 292 + change needed. 293 + 294 + --- 295 + 296 + ## Verification 297 + 298 + After each phase: 299 + ```bash 300 + nix develop -c go vet ./... 301 + nix develop -c go build ./... 302 + nix develop -c go test ./... 303 + ``` 304 + 305 + After Phase 5 (UI complete): 306 + ```bash 307 + nix develop -c templ generate 308 + nix develop -c go run cmd/server/main.go 309 + ``` 310 + - Visit `/manage` — verify Cafes and Drinks tabs appear 311 + - Create a cafe via the modal 312 + - Create a drink via `/drinks/new` 313 + - Check the community feed shows both new record types 314 + - Verify cafe/drink detail pages load at `/cafes/{id}` and `/drinks/{id}`
+3 -2
go.mod
··· 20 20 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 21 21 go.opentelemetry.io/otel/sdk v1.40.0 22 22 go.opentelemetry.io/otel/trace v1.40.0 23 - golang.org/x/sync v0.19.0 23 + golang.org/x/sync v0.20.0 24 24 modernc.org/sqlite v1.46.1 25 25 ) 26 26 ··· 87 87 go.yaml.in/yaml/v2 v2.4.2 // indirect 88 88 golang.org/x/crypto v0.47.0 // indirect 89 89 golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect 90 + golang.org/x/image v0.38.0 // indirect 90 91 golang.org/x/net v0.49.0 // indirect 91 92 golang.org/x/sys v0.40.0 // indirect 92 - golang.org/x/text v0.33.0 // indirect 93 + golang.org/x/text v0.35.0 // indirect 93 94 golang.org/x/time v0.3.0 // indirect 94 95 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect 95 96 google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect
+8
go.sum
··· 235 235 golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= 236 236 golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= 237 237 golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= 238 + golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE= 239 + golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY= 238 240 golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 239 241 golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 240 242 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= ··· 242 244 golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 243 245 golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= 244 246 golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= 247 + golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= 245 248 golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 246 249 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 247 250 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= ··· 256 259 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 257 260 golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= 258 261 golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 262 + golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= 263 + golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 259 264 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 260 265 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 261 266 golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= ··· 274 279 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 275 280 golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= 276 281 golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= 282 + golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= 283 + golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= 277 284 golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= 278 285 golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 279 286 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= ··· 288 295 golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 289 296 golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= 290 297 golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= 298 + golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= 291 299 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 292 300 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 293 301 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+86
internal/handlers/brew.go
··· 13 13 "arabica/internal/metrics" 14 14 "arabica/internal/models" 15 15 "arabica/internal/moderation" 16 + "arabica/internal/ogcard" 16 17 "arabica/internal/web/bff" 17 18 "arabica/internal/web/components" 18 19 "arabica/internal/web/pages" ··· 73 74 layoutData.OGDescription = ogDescription 74 75 layoutData.OGType = "article" 75 76 layoutData.OGUrl = ogURL 77 + 78 + // Set OG image URL for rich social media previews 79 + if h.config.PublicURL != "" && shareURL != "" { 80 + ogImageURL := strings.Replace(shareURL, "?", "/og-image?", 1) 81 + layoutData.OGImage = h.config.PublicURL + ogImageURL 82 + } 83 + } 84 + 85 + // HandleBrewOGImage generates a 1200x630 PNG preview card for a brew. 86 + // Used as the og:image for social media embeds. 87 + func (h *Handler) HandleBrewOGImage(w http.ResponseWriter, r *http.Request) { 88 + rkey := validateRKey(w, r.PathValue("id")) 89 + if rkey == "" { 90 + return 91 + } 92 + 93 + owner := r.URL.Query().Get("owner") 94 + if owner == "" { 95 + http.Error(w, "owner parameter required", http.StatusBadRequest) 96 + return 97 + } 98 + 99 + // Resolve owner to DID 100 + publicClient := atproto.NewPublicClient() 101 + var ownerDID string 102 + if strings.HasPrefix(owner, "did:") { 103 + ownerDID = owner 104 + } else { 105 + resolved, err := publicClient.ResolveHandle(r.Context(), owner) 106 + if err != nil { 107 + log.Warn().Err(err).Str("handle", owner).Msg("Failed to resolve handle for OG image") 108 + http.Error(w, "User not found", http.StatusNotFound) 109 + return 110 + } 111 + ownerDID = resolved 112 + } 113 + 114 + // Fetch brew (witness cache first, then PDS fallback) 115 + var brew *models.Brew 116 + brewURI := atproto.BuildATURI(ownerDID, atproto.NSIDBrew, rkey) 117 + if h.witnessCache != nil { 118 + if wr, _ := h.witnessCache.GetWitnessRecord(r.Context(), brewURI); wr != nil { 119 + if m, err := atproto.WitnessRecordToMap(wr); err == nil { 120 + if b, err := atproto.RecordToBrew(m, wr.URI); err == nil { 121 + metrics.WitnessCacheHitsTotal.WithLabelValues("brew_og").Inc() 122 + brew = b 123 + brew.RKey = rkey 124 + atproto.ExtractBrewRefRKeys(brew, m) 125 + h.resolveBrewRefsFromWitness(r.Context(), brew, ownerDID, m) 126 + } 127 + } 128 + } 129 + } 130 + if brew == nil { 131 + metrics.WitnessCacheMissesTotal.WithLabelValues("brew_og").Inc() 132 + record, err := publicClient.GetRecord(r.Context(), ownerDID, atproto.NSIDBrew, rkey) 133 + if err != nil { 134 + log.Error().Err(err).Str("did", ownerDID).Str("rkey", rkey).Msg("Failed to get brew for OG image") 135 + http.Error(w, "Brew not found", http.StatusNotFound) 136 + return 137 + } 138 + brew, err = atproto.RecordToBrew(record.Value, record.URI) 139 + if err != nil { 140 + log.Error().Err(err).Msg("Failed to convert brew record for OG image") 141 + http.Error(w, "Failed to load brew", http.StatusInternalServerError) 142 + return 143 + } 144 + if err := h.resolveBrewReferences(r.Context(), brew, ownerDID, record.Value); err != nil { 145 + log.Warn().Err(err).Msg("Failed to resolve some brew references for OG image") 146 + } 147 + } 148 + 149 + // Generate card 150 + card, err := ogcard.DrawBrewCard(brew) 151 + if err != nil { 152 + log.Error().Err(err).Msg("Failed to generate OG image") 153 + http.Error(w, "Failed to generate image", http.StatusInternalServerError) 154 + return 155 + } 156 + 157 + w.Header().Set("Content-Type", "image/png") 158 + w.Header().Set("Cache-Control", "public, max-age=86400") // 24 hours 159 + if err := card.EncodePNG(w); err != nil { 160 + log.Error().Err(err).Msg("Failed to encode OG image") 161 + } 76 162 } 77 163 78 164 // Brew list partial (loaded async via HTMX)
+351 -5
internal/handlers/entity_views.go
··· 11 11 "arabica/internal/metrics" 12 12 "arabica/internal/models" 13 13 "arabica/internal/moderation" 14 + "arabica/internal/ogcard" 14 15 "arabica/internal/web/bff" 15 16 "arabica/internal/web/components" 16 17 "arabica/internal/web/pages" ··· 810 811 } 811 812 } 812 813 814 + // OG image handlers for entity types 815 + 816 + // HandleBeanOGImage generates a 1200x630 PNG preview card for a bean. 817 + func (h *Handler) HandleBeanOGImage(w http.ResponseWriter, r *http.Request) { 818 + rkey := validateRKey(w, r.PathValue("id")) 819 + if rkey == "" { 820 + return 821 + } 822 + owner := r.URL.Query().Get("owner") 823 + if owner == "" { 824 + http.Error(w, "owner parameter required", http.StatusBadRequest) 825 + return 826 + } 827 + 828 + ownerDID, err := resolveOwnerDID(r.Context(), owner) 829 + if err != nil { 830 + http.Error(w, "User not found", http.StatusNotFound) 831 + return 832 + } 833 + 834 + var bean *models.Bean 835 + beanURI := atproto.BuildATURI(ownerDID, atproto.NSIDBean, rkey) 836 + if h.witnessCache != nil { 837 + if wr, _ := h.witnessCache.GetWitnessRecord(r.Context(), beanURI); wr != nil { 838 + if m, err := atproto.WitnessRecordToMap(wr); err == nil { 839 + if b, err := atproto.RecordToBean(m, wr.URI); err == nil { 840 + metrics.WitnessCacheHitsTotal.WithLabelValues("bean_og").Inc() 841 + bean = b 842 + bean.RKey = rkey 843 + // Resolve roaster 844 + if roasterRef, ok := m["roasterRef"].(string); ok && roasterRef != "" { 845 + if rwr, _ := h.witnessCache.GetWitnessRecord(r.Context(), roasterRef); rwr != nil { 846 + if rm, err := atproto.WitnessRecordToMap(rwr); err == nil { 847 + if roaster, err := atproto.RecordToRoaster(rm, rwr.URI); err == nil { 848 + bean.Roaster = roaster 849 + } 850 + } 851 + } 852 + } 853 + } 854 + } 855 + } 856 + } 857 + if bean == nil { 858 + metrics.WitnessCacheMissesTotal.WithLabelValues("bean_og").Inc() 859 + publicClient := atproto.NewPublicClient() 860 + record, err := publicClient.GetRecord(r.Context(), ownerDID, atproto.NSIDBean, rkey) 861 + if err != nil { 862 + http.Error(w, "Bean not found", http.StatusNotFound) 863 + return 864 + } 865 + bean, err = atproto.RecordToBean(record.Value, record.URI) 866 + if err != nil { 867 + http.Error(w, "Failed to load bean", http.StatusInternalServerError) 868 + return 869 + } 870 + // Resolve roaster reference 871 + if roasterRef, ok := record.Value["roasterRef"].(string); ok && roasterRef != "" { 872 + roasterRKey := atproto.ExtractRKeyFromURI(roasterRef) 873 + if roasterRKey != "" { 874 + if rr, err := publicClient.GetRecord(r.Context(), ownerDID, atproto.NSIDRoaster, roasterRKey); err == nil { 875 + if roaster, err := atproto.RecordToRoaster(rr.Value, rr.URI); err == nil { 876 + bean.Roaster = roaster 877 + } 878 + } 879 + } 880 + } 881 + } 882 + 883 + card, err := ogcard.DrawBeanCard(bean) 884 + if err != nil { 885 + log.Error().Err(err).Msg("Failed to generate bean OG image") 886 + http.Error(w, "Failed to generate image", http.StatusInternalServerError) 887 + return 888 + } 889 + writeOGImage(w, card) 890 + } 891 + 892 + // HandleRoasterOGImage generates a 1200x630 PNG preview card for a roaster. 893 + func (h *Handler) HandleRoasterOGImage(w http.ResponseWriter, r *http.Request) { 894 + rkey := validateRKey(w, r.PathValue("id")) 895 + if rkey == "" { 896 + return 897 + } 898 + owner := r.URL.Query().Get("owner") 899 + if owner == "" { 900 + http.Error(w, "owner parameter required", http.StatusBadRequest) 901 + return 902 + } 903 + 904 + ownerDID, err := resolveOwnerDID(r.Context(), owner) 905 + if err != nil { 906 + http.Error(w, "User not found", http.StatusNotFound) 907 + return 908 + } 909 + 910 + var roaster *models.Roaster 911 + roasterURI := atproto.BuildATURI(ownerDID, atproto.NSIDRoaster, rkey) 912 + if h.witnessCache != nil { 913 + if wr, _ := h.witnessCache.GetWitnessRecord(r.Context(), roasterURI); wr != nil { 914 + if m, err := atproto.WitnessRecordToMap(wr); err == nil { 915 + if r, err := atproto.RecordToRoaster(m, wr.URI); err == nil { 916 + metrics.WitnessCacheHitsTotal.WithLabelValues("roaster_og").Inc() 917 + roaster = r 918 + roaster.RKey = rkey 919 + } 920 + } 921 + } 922 + } 923 + if roaster == nil { 924 + metrics.WitnessCacheMissesTotal.WithLabelValues("roaster_og").Inc() 925 + publicClient := atproto.NewPublicClient() 926 + record, err := publicClient.GetRecord(r.Context(), ownerDID, atproto.NSIDRoaster, rkey) 927 + if err != nil { 928 + http.Error(w, "Roaster not found", http.StatusNotFound) 929 + return 930 + } 931 + roaster, err = atproto.RecordToRoaster(record.Value, record.URI) 932 + if err != nil { 933 + http.Error(w, "Failed to load roaster", http.StatusInternalServerError) 934 + return 935 + } 936 + } 937 + 938 + card, err := ogcard.DrawRoasterCard(roaster) 939 + if err != nil { 940 + log.Error().Err(err).Msg("Failed to generate roaster OG image") 941 + http.Error(w, "Failed to generate image", http.StatusInternalServerError) 942 + return 943 + } 944 + writeOGImage(w, card) 945 + } 946 + 947 + // HandleGrinderOGImage generates a 1200x630 PNG preview card for a grinder. 948 + func (h *Handler) HandleGrinderOGImage(w http.ResponseWriter, r *http.Request) { 949 + rkey := validateRKey(w, r.PathValue("id")) 950 + if rkey == "" { 951 + return 952 + } 953 + owner := r.URL.Query().Get("owner") 954 + if owner == "" { 955 + http.Error(w, "owner parameter required", http.StatusBadRequest) 956 + return 957 + } 958 + 959 + ownerDID, err := resolveOwnerDID(r.Context(), owner) 960 + if err != nil { 961 + http.Error(w, "User not found", http.StatusNotFound) 962 + return 963 + } 964 + 965 + var grinder *models.Grinder 966 + grinderURI := atproto.BuildATURI(ownerDID, atproto.NSIDGrinder, rkey) 967 + if h.witnessCache != nil { 968 + if wr, _ := h.witnessCache.GetWitnessRecord(r.Context(), grinderURI); wr != nil { 969 + if m, err := atproto.WitnessRecordToMap(wr); err == nil { 970 + if g, err := atproto.RecordToGrinder(m, wr.URI); err == nil { 971 + metrics.WitnessCacheHitsTotal.WithLabelValues("grinder_og").Inc() 972 + grinder = g 973 + grinder.RKey = rkey 974 + } 975 + } 976 + } 977 + } 978 + if grinder == nil { 979 + metrics.WitnessCacheMissesTotal.WithLabelValues("grinder_og").Inc() 980 + publicClient := atproto.NewPublicClient() 981 + record, err := publicClient.GetRecord(r.Context(), ownerDID, atproto.NSIDGrinder, rkey) 982 + if err != nil { 983 + http.Error(w, "Grinder not found", http.StatusNotFound) 984 + return 985 + } 986 + grinder, err = atproto.RecordToGrinder(record.Value, record.URI) 987 + if err != nil { 988 + http.Error(w, "Failed to load grinder", http.StatusInternalServerError) 989 + return 990 + } 991 + } 992 + 993 + card, err := ogcard.DrawGrinderCard(grinder) 994 + if err != nil { 995 + log.Error().Err(err).Msg("Failed to generate grinder OG image") 996 + http.Error(w, "Failed to generate image", http.StatusInternalServerError) 997 + return 998 + } 999 + writeOGImage(w, card) 1000 + } 1001 + 1002 + // HandleBrewerOGImage generates a 1200x630 PNG preview card for a brewer. 1003 + func (h *Handler) HandleBrewerOGImage(w http.ResponseWriter, r *http.Request) { 1004 + rkey := validateRKey(w, r.PathValue("id")) 1005 + if rkey == "" { 1006 + return 1007 + } 1008 + owner := r.URL.Query().Get("owner") 1009 + if owner == "" { 1010 + http.Error(w, "owner parameter required", http.StatusBadRequest) 1011 + return 1012 + } 1013 + 1014 + ownerDID, err := resolveOwnerDID(r.Context(), owner) 1015 + if err != nil { 1016 + http.Error(w, "User not found", http.StatusNotFound) 1017 + return 1018 + } 1019 + 1020 + var brewer *models.Brewer 1021 + brewerURI := atproto.BuildATURI(ownerDID, atproto.NSIDBrewer, rkey) 1022 + if h.witnessCache != nil { 1023 + if wr, _ := h.witnessCache.GetWitnessRecord(r.Context(), brewerURI); wr != nil { 1024 + if m, err := atproto.WitnessRecordToMap(wr); err == nil { 1025 + if b, err := atproto.RecordToBrewer(m, wr.URI); err == nil { 1026 + metrics.WitnessCacheHitsTotal.WithLabelValues("brewer_og").Inc() 1027 + brewer = b 1028 + brewer.RKey = rkey 1029 + } 1030 + } 1031 + } 1032 + } 1033 + if brewer == nil { 1034 + metrics.WitnessCacheMissesTotal.WithLabelValues("brewer_og").Inc() 1035 + publicClient := atproto.NewPublicClient() 1036 + record, err := publicClient.GetRecord(r.Context(), ownerDID, atproto.NSIDBrewer, rkey) 1037 + if err != nil { 1038 + http.Error(w, "Brewer not found", http.StatusNotFound) 1039 + return 1040 + } 1041 + brewer, err = atproto.RecordToBrewer(record.Value, record.URI) 1042 + if err != nil { 1043 + http.Error(w, "Failed to load brewer", http.StatusInternalServerError) 1044 + return 1045 + } 1046 + } 1047 + 1048 + card, err := ogcard.DrawBrewerCard(brewer) 1049 + if err != nil { 1050 + log.Error().Err(err).Msg("Failed to generate brewer OG image") 1051 + http.Error(w, "Failed to generate image", http.StatusInternalServerError) 1052 + return 1053 + } 1054 + writeOGImage(w, card) 1055 + } 1056 + 1057 + // HandleRecipeOGImage generates a 1200x630 PNG preview card for a recipe. 1058 + func (h *Handler) HandleRecipeOGImage(w http.ResponseWriter, r *http.Request) { 1059 + rkey := validateRKey(w, r.PathValue("id")) 1060 + if rkey == "" { 1061 + return 1062 + } 1063 + owner := r.URL.Query().Get("owner") 1064 + if owner == "" { 1065 + http.Error(w, "owner parameter required", http.StatusBadRequest) 1066 + return 1067 + } 1068 + 1069 + ownerDID, err := resolveOwnerDID(r.Context(), owner) 1070 + if err != nil { 1071 + http.Error(w, "User not found", http.StatusNotFound) 1072 + return 1073 + } 1074 + 1075 + var recipe *models.Recipe 1076 + recipeURI := atproto.BuildATURI(ownerDID, atproto.NSIDRecipe, rkey) 1077 + if h.witnessCache != nil { 1078 + if wr, _ := h.witnessCache.GetWitnessRecord(r.Context(), recipeURI); wr != nil { 1079 + if m, err := atproto.WitnessRecordToMap(wr); err == nil { 1080 + if rec, err := atproto.RecordToRecipe(m, wr.URI); err == nil { 1081 + metrics.WitnessCacheHitsTotal.WithLabelValues("recipe_og").Inc() 1082 + recipe = rec 1083 + recipe.RKey = rkey 1084 + // Resolve brewer from witness 1085 + if brewerRef, ok := m["brewerRef"].(string); ok && brewerRef != "" { 1086 + if bwr, _ := h.witnessCache.GetWitnessRecord(r.Context(), brewerRef); bwr != nil { 1087 + if bm, err := atproto.WitnessRecordToMap(bwr); err == nil { 1088 + if brewer, err := atproto.RecordToBrewer(bm, bwr.URI); err == nil { 1089 + recipe.BrewerObj = brewer 1090 + } 1091 + } 1092 + } 1093 + } 1094 + recipe.Interpolate() 1095 + } 1096 + } 1097 + } 1098 + } 1099 + if recipe == nil { 1100 + metrics.WitnessCacheMissesTotal.WithLabelValues("recipe_og").Inc() 1101 + publicClient := atproto.NewPublicClient() 1102 + record, err := publicClient.GetRecord(r.Context(), ownerDID, atproto.NSIDRecipe, rkey) 1103 + if err != nil { 1104 + http.Error(w, "Recipe not found", http.StatusNotFound) 1105 + return 1106 + } 1107 + recipe, err = atproto.RecordToRecipe(record.Value, record.URI) 1108 + if err != nil { 1109 + http.Error(w, "Failed to load recipe", http.StatusInternalServerError) 1110 + return 1111 + } 1112 + // Resolve brewer reference 1113 + if brewerRef, ok := record.Value["brewerRef"].(string); ok && brewerRef != "" { 1114 + brewerRKey := atproto.ExtractRKeyFromURI(brewerRef) 1115 + if brewerRKey != "" { 1116 + if br, err := publicClient.GetRecord(r.Context(), ownerDID, atproto.NSIDBrewer, brewerRKey); err == nil { 1117 + if brewer, err := atproto.RecordToBrewer(br.Value, br.URI); err == nil { 1118 + recipe.BrewerObj = brewer 1119 + } 1120 + } 1121 + } 1122 + } 1123 + recipe.Interpolate() 1124 + } 1125 + 1126 + card, err := ogcard.DrawRecipeCard(recipe) 1127 + if err != nil { 1128 + log.Error().Err(err).Msg("Failed to generate recipe OG image") 1129 + http.Error(w, "Failed to generate image", http.StatusInternalServerError) 1130 + return 1131 + } 1132 + writeOGImage(w, card) 1133 + } 1134 + 1135 + // writeOGImage encodes a card as PNG with appropriate cache headers. 1136 + func writeOGImage(w http.ResponseWriter, card *ogcard.Card) { 1137 + w.Header().Set("Content-Type", "image/png") 1138 + w.Header().Set("Cache-Control", "public, max-age=86400") 1139 + if err := card.EncodePNG(w); err != nil { 1140 + log.Error().Err(err).Msg("Failed to encode OG image") 1141 + } 1142 + } 1143 + 813 1144 // OG metadata helpers for entity types 814 1145 815 1146 func (h *Handler) populateBeanOGMetadata(layoutData *components.LayoutData, bean *models.Bean, shareURL string) { ··· 837 1168 if len(descParts) > 0 { 838 1169 ogDescription = strings.Join(descParts, " · ") 839 1170 } else { 840 - ogDescription = "A coffee bean tracked on Arabica" 1171 + ogDescription = "coffee bean" 841 1172 } 842 1173 843 1174 var ogURL string ··· 849 1180 layoutData.OGDescription = ogDescription 850 1181 layoutData.OGType = "article" 851 1182 layoutData.OGUrl = ogURL 1183 + if h.config.PublicURL != "" && shareURL != "" { 1184 + layoutData.OGImage = h.config.PublicURL + strings.Replace(shareURL, "?", "/og-image?", 1) 1185 + } 852 1186 } 853 1187 854 1188 func (h *Handler) populateRoasterOGMetadata(layoutData *components.LayoutData, roaster *models.Roaster, shareURL string) { ··· 865 1199 if len(descParts) > 0 { 866 1200 ogDescription = strings.Join(descParts, " · ") 867 1201 } else { 868 - ogDescription = "A coffee roaster tracked on Arabica" 1202 + ogDescription = "roaster" 869 1203 } 870 1204 871 1205 var ogURL string ··· 877 1211 layoutData.OGDescription = ogDescription 878 1212 layoutData.OGType = "article" 879 1213 layoutData.OGUrl = ogURL 1214 + if h.config.PublicURL != "" && shareURL != "" { 1215 + layoutData.OGImage = h.config.PublicURL + strings.Replace(shareURL, "?", "/og-image?", 1) 1216 + } 880 1217 } 881 1218 882 1219 func (h *Handler) populateGrinderOGMetadata(layoutData *components.LayoutData, grinder *models.Grinder, shareURL string) { ··· 896 1233 if len(descParts) > 0 { 897 1234 ogDescription = strings.Join(descParts, " · ") 898 1235 } else { 899 - ogDescription = "A coffee grinder tracked on Arabica" 1236 + ogDescription = "grinder" 900 1237 } 901 1238 902 1239 var ogURL string ··· 908 1245 layoutData.OGDescription = ogDescription 909 1246 layoutData.OGType = "article" 910 1247 layoutData.OGUrl = ogURL 1248 + if h.config.PublicURL != "" && shareURL != "" { 1249 + layoutData.OGImage = h.config.PublicURL + strings.Replace(shareURL, "?", "/og-image?", 1) 1250 + } 911 1251 } 912 1252 913 1253 func (h *Handler) populateBrewerOGMetadata(layoutData *components.LayoutData, brewer *models.Brewer, shareURL string) { ··· 924 1264 if len(descParts) > 0 { 925 1265 ogDescription = strings.Join(descParts, " · ") 926 1266 } else { 927 - ogDescription = "A brewing device tracked on Arabica" 1267 + ogDescription = "brewer" 928 1268 } 929 1269 930 1270 var ogURL string ··· 936 1276 layoutData.OGDescription = ogDescription 937 1277 layoutData.OGType = "article" 938 1278 layoutData.OGUrl = ogURL 1279 + if h.config.PublicURL != "" && shareURL != "" { 1280 + layoutData.OGImage = h.config.PublicURL + strings.Replace(shareURL, "?", "/og-image?", 1) 1281 + } 939 1282 } 940 1283 941 1284 func (h *Handler) populateRecipeOGMetadata(layoutData *components.LayoutData, recipe *models.Recipe, shareURL string) { ··· 954 1297 if len(descParts) > 0 { 955 1298 ogDescription = strings.Join(descParts, " · ") 956 1299 } else { 957 - ogDescription = "A coffee recipe on Arabica" 1300 + ogDescription = "coffee recipe" 958 1301 } 959 1302 960 1303 var ogURL string ··· 966 1309 layoutData.OGDescription = ogDescription 967 1310 layoutData.OGType = "article" 968 1311 layoutData.OGUrl = ogURL 1312 + if h.config.PublicURL != "" && shareURL != "" { 1313 + layoutData.OGImage = h.config.PublicURL + strings.Replace(shareURL, "?", "/og-image?", 1) 1314 + } 969 1315 }
internal/ogcard/arabica-logo.png

This is a binary file and will not be displayed.

+282
internal/ogcard/brew.go
··· 1 + package ogcard 2 + 3 + import ( 4 + "fmt" 5 + "image/color" 6 + "strings" 7 + 8 + "arabica/internal/models" 9 + ) 10 + 11 + // Arabica color palette (warm coffee tones) 12 + var ( 13 + ColorBg = color.RGBA{0xFD, 0xFA, 0xF5, 0xFF} // warm cream background 14 + ColorDark = color.RGBA{0x2D, 0x1A, 0x0E, 0xFF} // dark brown headings 15 + ColorBody = color.RGBA{0x5C, 0x3D, 0x2E, 0xFF} // medium brown body text 16 + ColorMuted = color.RGBA{0x8B, 0x6F, 0x5A, 0xFF} // muted brown secondary 17 + ColorBrand = color.RGBA{0x4A, 0x2C, 0x2A, 0xFF} // brand dark bar 18 + ColorWhite = color.RGBA{0xFF, 0xFF, 0xFF, 0xFF} 19 + ColorGold = color.RGBA{0xD9, 0x7A, 0x06, 0xFF} // rating fill 20 + ColorBarEmpty = color.RGBA{0xE8, 0xDD, 0xD3, 0xFF} // rating bar background 21 + ColorDivider = color.RGBA{0xE0, 0xD5, 0xCA, 0xFF} // divider line 22 + ColorBrandSub = color.RGBA{0xBB, 0xA0, 0x90, 0xFF} // brand subtitle text 23 + 24 + // Type accent colors (match CSS --type-* variables) 25 + AccentBrew = color.RGBA{0x6B, 0x44, 0x23, 0xFF} // #6b4423 26 + AccentBean = color.RGBA{0xB4, 0x53, 0x09, 0xFF} // #b45309 27 + AccentRecipe = color.RGBA{0x7F, 0x55, 0x39, 0xFF} // #7f5539 28 + AccentRoaster = color.RGBA{0x92, 0x40, 0x0E, 0xFF} // #92400e 29 + AccentGrinder = color.RGBA{0x6B, 0x44, 0x23, 0xFF} // #6b4423 30 + AccentBrewer = color.RGBA{0x6B, 0x44, 0x23, 0xFF} // #6b4423 31 + ) 32 + 33 + const ( 34 + cardWidth = 1200 35 + cardHeight = 630 36 + leftPad = 60 37 + stripeW = 8 38 + brandBarH = 60 39 + brandBarY = cardHeight - brandBarH // 570 40 + contentW = 850 // width available for text (left of logo) 41 + logoSize = 160 42 + logoX = 980 43 + dot = " \u00b7 " // spaced middle dot separator 44 + ) 45 + 46 + // newTypedCard creates a 1200x630 card with accent stripe, brand bar, and logo. 47 + func newTypedCard(accent color.RGBA, typeLabel string) (*Card, error) { 48 + card, err := NewCard(cardWidth, cardHeight, ColorBg) 49 + if err != nil { 50 + return nil, err 51 + } 52 + // Left accent stripe 53 + card.DrawRect(0, 0, stripeW, cardHeight, accent) 54 + 55 + // Logo on right side 56 + if logo := GetLogo(); logo != nil { 57 + logoY := (brandBarY - logoSize) / 2 58 + card.DrawImageScaled(logo, logoX, logoY, logoSize, logoSize) 59 + } 60 + 61 + // Bottom brand bar 62 + card.DrawRect(0, brandBarY, cardWidth, cardHeight, ColorBrand) 63 + w := card.DrawBoldText("arabica.social", leftPad, brandBarY+18, ColorWhite, 26) 64 + card.DrawText(typeLabel, leftPad+w+16, brandBarY+22, ColorBrandSub, 18) 65 + return card, nil 66 + } 67 + 68 + // drawRatingBar renders a horizontal rating bar with filled/empty portions. 69 + func drawRatingBar(card *Card, x, y, rating int) int { 70 + barWidth := 240 71 + barHeight := 16 72 + radius := 8 73 + card.DrawRoundedRect(x, y, barWidth, barHeight, radius, ColorBarEmpty) 74 + fillWidth := barWidth * rating / 10 75 + if fillWidth > 0 { 76 + card.DrawRoundedRect(x, y, fillWidth, barHeight, radius, ColorGold) 77 + } 78 + card.DrawBoldText(fmt.Sprintf("%d / 10", rating), x+barWidth+20, y-4, ColorGold, 24) 79 + return y + 36 80 + } 81 + 82 + // truncate shortens s to maxLen, appending "..." if truncated. 83 + func truncate(s string, maxLen int) string { 84 + if len(s) > maxLen { 85 + return s[:maxLen-3] + "..." 86 + } 87 + return s 88 + } 89 + 90 + // truncateLine shortens s to fit within maxWidth pixels at sizePt, appending "..." if truncated. 91 + func truncateLine(card *Card, s string, maxWidth int, sizePt float64, bold bool) string { 92 + if card.MeasureText(s, sizePt, bold) <= maxWidth { 93 + return s 94 + } 95 + for len(s) > 0 { 96 + candidate := s + "..." 97 + if card.MeasureText(candidate, sizePt, bold) <= maxWidth { 98 + return candidate 99 + } 100 + // trim one rune at a time 101 + s = s[:len(s)-1] 102 + } 103 + return "..." 104 + } 105 + 106 + // brewContentHeight calculates the total height of all brew content lines 107 + // so we can vertically center the content. 108 + func brewContentHeight(card *Card, brew *models.Brew) int { 109 + h := 0 110 + 111 + // Bean name (1 or 2 lines) 112 + beanNameForHeight := "Coffee Brew" 113 + if brew.Bean != nil && brew.Bean.Name != "" { 114 + beanNameForHeight = brew.Bean.Name 115 + } 116 + if card.MeasureText(beanNameForHeight, 44, true) > contentW-leftPad { 117 + h += 116 118 + } else { 119 + h += 58 120 + } 121 + 122 + // Roaster line 123 + if brew.Bean != nil && brew.Bean.Roaster != nil && brew.Bean.Roaster.Name != "" { 124 + h += 38 125 + } 126 + 127 + // Details line (origin / roast level) 128 + if brew.Bean != nil && (brew.Bean.Origin != "" || brew.Bean.RoastLevel != "") { 129 + h += 38 130 + } 131 + 132 + // Rating bar 133 + if brew.Rating > 0 { 134 + h += 8 + 36 135 + } 136 + 137 + // Divider 138 + h += 8 + 2 + 22 139 + 140 + // // Method line 141 + // if (brew.BrewerObj != nil && (brew.BrewerObj.BrewerType != "" || brew.BrewerObj.Name != "")) || brew.Method != "" { 142 + // h += 40 143 + // } 144 + 145 + // Params line 146 + if brew.CoffeeAmount > 0 || brew.WaterAmount > 0 || brew.Temperature > 0 || brew.TimeSeconds > 0 { 147 + h += 38 148 + } 149 + 150 + // // Grinder info 151 + // if brew.GrinderObj != nil && brew.GrinderObj.Name != "" { 152 + // h += 34 153 + // } 154 + 155 + // Tasting notes (2 lines) 156 + if brew.TastingNotes != "" { 157 + lh := card.FontHeight(24) 158 + h += 10 + 2*(lh+lh/4) 159 + } 160 + 161 + return h 162 + } 163 + 164 + // DrawBrewCard generates a 1200x630 OG image for a brew record. 165 + func DrawBrewCard(brew *models.Brew) (*Card, error) { 166 + card, err := newTypedCard(AccentBrew, "brew") 167 + if err != nil { 168 + return nil, err 169 + } 170 + 171 + x := leftPad 172 + maxTextW := contentW - leftPad 173 + 174 + // Calculate vertical centering 175 + contentH := brewContentHeight(card, brew) 176 + availableH := brandBarY // total space above brand bar 177 + y := max((availableH-contentH)/2, 30) 178 + 179 + // Bean name (wraps to second line if needed) 180 + beanName := "Coffee Brew" 181 + if brew.Bean != nil && brew.Bean.Name != "" { 182 + beanName = brew.Bean.Name 183 + } 184 + y = card.DrawWrappedText(beanName, x, y, maxTextW, ColorDark, 44, true) 185 + y += 6 186 + 187 + // Roaster line 188 + if brew.Bean != nil && brew.Bean.Roaster != nil && brew.Bean.Roaster.Name != "" { 189 + card.DrawText("by "+brew.Bean.Roaster.Name, x, y, ColorBody, 26) 190 + y += 38 191 + } 192 + 193 + // Details line: origin / roast level 194 + var details []string 195 + if brew.Bean != nil { 196 + if brew.Bean.Origin != "" { 197 + details = append(details, brew.Bean.Origin) 198 + } 199 + if brew.Bean.RoastLevel != "" { 200 + details = append(details, brew.Bean.RoastLevel+" Roast") 201 + } 202 + } 203 + if len(details) > 0 { 204 + card.DrawText(strings.Join(details, dot), x, y, ColorBody, 24) 205 + y += 38 206 + } 207 + 208 + // Rating bar 209 + if brew.Rating > 0 { 210 + y += 8 211 + y = drawRatingBar(card, x, y, brew.Rating) 212 + } 213 + 214 + // Divider 215 + y += 8 216 + card.DrawRect(x, y, x+maxTextW, y+2, ColorDivider) 217 + y += 22 218 + 219 + // // Method line (commented out — too cramped) 220 + // var methodParts []string 221 + // if brew.BrewerObj != nil { 222 + // if brew.BrewerObj.BrewerType != "" { 223 + // if label, ok := models.BrewerTypeLabels[brew.BrewerObj.BrewerType]; ok { 224 + // methodParts = append(methodParts, label) 225 + // } 226 + // } 227 + // if brew.BrewerObj.Name != "" { 228 + // methodParts = append(methodParts, brew.BrewerObj.Name) 229 + // } 230 + // } else if brew.Method != "" { 231 + // methodParts = append(methodParts, brew.Method) 232 + // } 233 + // if len(methodParts) > 0 { 234 + // card.DrawBoldText(strings.Join(methodParts, dot), x, y, ColorDark, 28) 235 + // y += 40 236 + // } 237 + 238 + // Params line 239 + var params []string 240 + if brew.CoffeeAmount > 0 { 241 + params = append(params, fmt.Sprintf("%dg coffee", brew.CoffeeAmount)) 242 + } 243 + if brew.WaterAmount > 0 { 244 + waterText := fmt.Sprintf("%dg water", brew.WaterAmount) 245 + if len(brew.Pours) > 0 { 246 + waterText += fmt.Sprintf(" (%d pours)", len(brew.Pours)) 247 + } 248 + params = append(params, waterText) 249 + } 250 + if brew.Temperature > 0 { 251 + params = append(params, fmt.Sprintf("%.0f\u00b0C", brew.Temperature)) 252 + } 253 + if brew.TimeSeconds > 0 { 254 + params = append(params, fmt.Sprintf("%d:%02d", brew.TimeSeconds/60, brew.TimeSeconds%60)) 255 + } 256 + if len(params) > 0 { 257 + card.DrawText(strings.Join(params, dot), x, y, ColorBody, 24) 258 + y += 38 259 + } 260 + 261 + // // Grinder info with grind size in parens 262 + // if brew.GrinderObj != nil && brew.GrinderObj.Name != "" { 263 + // grinderText := "Ground with " + brew.GrinderObj.Name 264 + // if brew.GrindSize != "" { 265 + // grinderText += " (" + brew.GrindSize + ")" 266 + // } 267 + // card.DrawText(grinderText, x, y, ColorMuted, 22) 268 + // y += 34 269 + // } else if brew.GrindSize != "" { 270 + // card.DrawText("Grind: "+brew.GrindSize, x, y, ColorMuted, 22) 271 + // y += 34 272 + // } 273 + 274 + // Tasting notes (2 lines with ellipsis) 275 + if brew.TastingNotes != "" { 276 + y += 10 277 + noteText := "\"" + brew.TastingNotes + "\"" 278 + card.DrawWrappedTextCapped(noteText, x, y, maxTextW, ColorMuted, 24, false, 2) 279 + } 280 + 281 + return card, nil 282 + }
+322
internal/ogcard/card.go
··· 1 + package ogcard 2 + 3 + import ( 4 + "bytes" 5 + _ "embed" 6 + "image" 7 + "image/color" 8 + "image/draw" 9 + _ "image/jpeg" 10 + "image/png" 11 + "io" 12 + "strings" 13 + "sync" 14 + 15 + xdraw "golang.org/x/image/draw" 16 + "golang.org/x/image/font" 17 + "golang.org/x/image/font/opentype" 18 + "golang.org/x/image/math/fixed" 19 + ) 20 + 21 + //go:embed fonts/patricks-iosevka-regular.ttf 22 + var regularTTF []byte 23 + 24 + //go:embed fonts/patricks-iosevka-semibold.ttf 25 + var semiboldTTF []byte 26 + 27 + //go:embed arabica-logo.png 28 + var logoPNG []byte 29 + 30 + var logoImage image.Image 31 + var logoOnce sync.Once 32 + 33 + // Card represents a drawable image canvas for generating OG card images. 34 + type Card struct { 35 + Img *image.RGBA 36 + Width int 37 + Height int 38 + Margin int 39 + } 40 + 41 + var ( 42 + parsedRegular *opentype.Font 43 + parsedBold *opentype.Font 44 + parseOnce sync.Once 45 + parseErr error 46 + 47 + faceCache sync.Map // map[faceKey]font.Face 48 + ) 49 + 50 + type faceKey struct { 51 + bold bool 52 + size float64 53 + } 54 + 55 + func initFonts() { 56 + parseOnce.Do(func() { 57 + parsedRegular, parseErr = opentype.Parse(regularTTF) 58 + if parseErr != nil { 59 + return 60 + } 61 + parsedBold, parseErr = opentype.Parse(semiboldTTF) 62 + }) 63 + } 64 + 65 + func getFace(bold bool, sizePt float64) (font.Face, error) { 66 + key := faceKey{bold, sizePt} 67 + if v, ok := faceCache.Load(key); ok { 68 + return v.(font.Face), nil 69 + } 70 + f := parsedRegular 71 + if bold { 72 + f = parsedBold 73 + } 74 + face, err := opentype.NewFace(f, &opentype.FaceOptions{ 75 + Size: sizePt, 76 + DPI: 72, 77 + Hinting: font.HintingFull, 78 + }) 79 + if err != nil { 80 + return nil, err 81 + } 82 + faceCache.Store(key, face) 83 + return face, nil 84 + } 85 + 86 + // NewCard creates a new card filled with the given background color. 87 + func NewCard(width, height int, bg color.Color) (*Card, error) { 88 + initFonts() 89 + if parseErr != nil { 90 + return nil, parseErr 91 + } 92 + img := image.NewRGBA(image.Rect(0, 0, width, height)) 93 + draw.Draw(img, img.Bounds(), &image.Uniform{bg}, image.Point{}, draw.Src) 94 + return &Card{Img: img, Width: width, Height: height}, nil 95 + } 96 + 97 + // SetMargin sets inner margins used by Split. 98 + func (c *Card) SetMargin(m int) { c.Margin = m } 99 + 100 + // Split divides the card. If vertical, splits left/right; otherwise top/bottom. 101 + // The first returned card gets the given percentage. 102 + func (c *Card) Split(vertical bool, percentage int) (*Card, *Card) { 103 + b := c.Img.Bounds() 104 + inner := image.Rect(b.Min.X+c.Margin, b.Min.Y+c.Margin, b.Max.X-c.Margin, b.Max.Y-c.Margin) 105 + if vertical { 106 + mid := inner.Min.X + inner.Dx()*percentage/100 107 + l := c.Img.SubImage(image.Rect(inner.Min.X, inner.Min.Y, mid, inner.Max.Y)).(*image.RGBA) 108 + r := c.Img.SubImage(image.Rect(mid, inner.Min.Y, inner.Max.X, inner.Max.Y)).(*image.RGBA) 109 + return &Card{Img: l, Width: l.Bounds().Dx(), Height: l.Bounds().Dy()}, 110 + &Card{Img: r, Width: r.Bounds().Dx(), Height: r.Bounds().Dy()} 111 + } 112 + mid := inner.Min.Y + inner.Dy()*percentage/100 113 + t := c.Img.SubImage(image.Rect(inner.Min.X, inner.Min.Y, inner.Max.X, mid)).(*image.RGBA) 114 + bot := c.Img.SubImage(image.Rect(inner.Min.X, mid, inner.Max.X, inner.Max.Y)).(*image.RGBA) 115 + return &Card{Img: t, Width: t.Bounds().Dx(), Height: t.Bounds().Dy()}, 116 + &Card{Img: bot, Width: bot.Bounds().Dx(), Height: bot.Bounds().Dy()} 117 + } 118 + 119 + // DrawText draws regular text. x,y is the top-left of the text bounding box. 120 + // Returns the advance width in pixels. 121 + func (c *Card) DrawText(text string, x, y int, clr color.Color, sizePt float64) int { 122 + return c.drawString(text, x, y, clr, sizePt, false) 123 + } 124 + 125 + // DrawBoldText draws bold text. x,y is the top-left. Returns advance width. 126 + func (c *Card) DrawBoldText(text string, x, y int, clr color.Color, sizePt float64) int { 127 + return c.drawString(text, x, y, clr, sizePt, true) 128 + } 129 + 130 + func (c *Card) drawString(text string, x, y int, clr color.Color, sizePt float64, bold bool) int { 131 + fc, err := getFace(bold, sizePt) 132 + if err != nil { 133 + return 0 134 + } 135 + baseline := y + fc.Metrics().Ascent.Ceil() 136 + d := &font.Drawer{ 137 + Dst: c.Img, 138 + Src: image.NewUniform(clr), 139 + Face: fc, 140 + Dot: fixed.P(x, baseline), 141 + } 142 + start := d.Dot.X 143 + d.DrawString(text) 144 + return (d.Dot.X - start).Ceil() 145 + } 146 + 147 + // MeasureText returns the pixel width of text at the given size. 148 + func (c *Card) MeasureText(text string, sizePt float64, bold bool) int { 149 + fc, err := getFace(bold, sizePt) 150 + if err != nil { 151 + return 0 152 + } 153 + return font.MeasureString(fc, text).Ceil() 154 + } 155 + 156 + // FontHeight returns the line height for the given point size. 157 + func (c *Card) FontHeight(sizePt float64) int { 158 + fc, err := getFace(false, sizePt) 159 + if err != nil { 160 + return int(sizePt) 161 + } 162 + m := fc.Metrics() 163 + return (m.Ascent + m.Descent).Ceil() 164 + } 165 + 166 + // DrawWrappedText draws word-wrapped text within maxWidth pixels. 167 + // Returns the Y position after the last line. 168 + func (c *Card) DrawWrappedText(text string, x, y, maxWidth int, clr color.Color, sizePt float64, bold bool) int { 169 + fc, err := getFace(bold, sizePt) 170 + if err != nil { 171 + return y 172 + } 173 + m := fc.Metrics() 174 + lineHeight := (m.Ascent + m.Descent).Ceil() 175 + lineSpacing := lineHeight + lineHeight/4 176 + 177 + words := strings.Fields(text) 178 + if len(words) == 0 { 179 + return y 180 + } 181 + 182 + currentLine := "" 183 + currentY := y 184 + 185 + for _, word := range words { 186 + proposed := currentLine 187 + if proposed != "" { 188 + proposed += " " 189 + } 190 + proposed += word 191 + 192 + if font.MeasureString(fc, proposed).Ceil() > maxWidth && currentLine != "" { 193 + c.drawString(currentLine, x, currentY, clr, sizePt, bold) 194 + currentY += lineSpacing 195 + currentLine = word 196 + } else { 197 + currentLine = proposed 198 + } 199 + } 200 + if currentLine != "" { 201 + c.drawString(currentLine, x, currentY, clr, sizePt, bold) 202 + currentY += lineSpacing 203 + } 204 + return currentY 205 + } 206 + 207 + // DrawWrappedTextCapped draws word-wrapped text within maxWidth, capped at maxLines. 208 + // If the text overflows, the last line is truncated with "…". 209 + // Returns the Y position after the last line. 210 + func (c *Card) DrawWrappedTextCapped(text string, x, y, maxWidth int, clr color.Color, sizePt float64, bold bool, maxLines int) int { 211 + fc, err := getFace(bold, sizePt) 212 + if err != nil { 213 + return y 214 + } 215 + m := fc.Metrics() 216 + lineHeight := (m.Ascent + m.Descent).Ceil() 217 + lineSpacing := lineHeight + lineHeight/4 218 + 219 + words := strings.Fields(text) 220 + if len(words) == 0 { 221 + return y 222 + } 223 + 224 + var lines []string 225 + currentLine := "" 226 + for _, word := range words { 227 + proposed := currentLine 228 + if proposed != "" { 229 + proposed += " " 230 + } 231 + proposed += word 232 + if font.MeasureString(fc, proposed).Ceil() > maxWidth && currentLine != "" { 233 + lines = append(lines, currentLine) 234 + currentLine = word 235 + } else { 236 + currentLine = proposed 237 + } 238 + } 239 + if currentLine != "" { 240 + lines = append(lines, currentLine) 241 + } 242 + 243 + if len(lines) > maxLines { 244 + lines = lines[:maxLines] 245 + last := []rune(lines[maxLines-1]) 246 + for len(last) > 0 && font.MeasureString(fc, string(last)+"…").Ceil() > maxWidth { 247 + last = last[:len(last)-1] 248 + } 249 + lines[maxLines-1] = string(last) + "…" 250 + } 251 + 252 + currentY := y 253 + for _, line := range lines { 254 + c.drawString(line, x, currentY, clr, sizePt, bold) 255 + currentY += lineSpacing 256 + } 257 + return currentY 258 + } 259 + 260 + // DrawRect fills a rectangle with the given color. 261 + func (c *Card) DrawRect(x1, y1, x2, y2 int, clr color.Color) { 262 + draw.Draw(c.Img, image.Rect(x1, y1, x2, y2), &image.Uniform{clr}, image.Point{}, draw.Src) 263 + } 264 + 265 + // DrawRoundedRect fills a rounded rectangle. 266 + func (c *Card) DrawRoundedRect(x, y, w, h, radius int, clr color.RGBA) { 267 + bounds := c.Img.Bounds() 268 + for py := y; py < y+h; py++ { 269 + for px := x; px < x+w; px++ { 270 + dx, dy := 0, 0 271 + switch { 272 + case px < x+radius && py < y+radius: 273 + dx, dy = x+radius-px, y+radius-py 274 + case px >= x+w-radius && py < y+radius: 275 + dx, dy = px-(x+w-radius-1), y+radius-py 276 + case px < x+radius && py >= y+h-radius: 277 + dx, dy = x+radius-px, py-(y+h-radius-1) 278 + case px >= x+w-radius && py >= y+h-radius: 279 + dx, dy = px-(x+w-radius-1), py-(y+h-radius-1) 280 + } 281 + inCorner := dx > 0 || dy > 0 282 + withinRadius := dx*dx+dy*dy <= radius*radius 283 + if (!inCorner || withinRadius) && px >= bounds.Min.X && px < bounds.Max.X && py >= bounds.Min.Y && py < bounds.Max.Y { 284 + c.Img.Set(px, py, clr) 285 + } 286 + } 287 + } 288 + } 289 + 290 + // DrawImageScaled draws an image scaled to fit within the given rectangle, 291 + // centered and maintaining aspect ratio. 292 + func (c *Card) DrawImageScaled(img image.Image, x, y, w, h int) { 293 + srcB := img.Bounds() 294 + srcW, srcH := float64(srcB.Dx()), float64(srcB.Dy()) 295 + targetW, targetH := float64(w), float64(h) 296 + 297 + scale := targetW / srcW 298 + if s := targetH / srcH; s < scale { 299 + scale = s 300 + } 301 + 302 + newW := int(srcW * scale) 303 + newH := int(srcH * scale) 304 + offX := x + (w-newW)/2 305 + offY := y + (h-newH)/2 306 + 307 + dst := image.Rect(offX, offY, offX+newW, offY+newH) 308 + xdraw.CatmullRom.Scale(c.Img, dst, img, srcB, xdraw.Over, nil) 309 + } 310 + 311 + // GetLogo returns the embedded arabica logo image. 312 + func GetLogo() image.Image { 313 + logoOnce.Do(func() { 314 + logoImage, _ = png.Decode(bytes.NewReader(logoPNG)) 315 + }) 316 + return logoImage 317 + } 318 + 319 + // EncodePNG writes the card image as PNG. 320 + func (c *Card) EncodePNG(w io.Writer) error { 321 + return png.Encode(w, c.Img) 322 + }
+341
internal/ogcard/entities.go
··· 1 + package ogcard 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + 7 + "arabica/internal/models" 8 + ) 9 + 10 + // entityStartY computes the starting Y to vertically center contentH within the card. 11 + func entityStartY(contentH int) int { 12 + y := (brandBarY - contentH) / 2 13 + if y < 30 { 14 + y = 30 15 + } 16 + return y 17 + } 18 + 19 + // maxTextWidth is the text area width, leaving room for the logo on the right. 20 + var maxTextWidth = contentW - leftPad 21 + 22 + // DrawBeanCard generates a 1200x630 OG image for a bean record. 23 + func DrawBeanCard(bean *models.Bean) (*Card, error) { 24 + card, err := newTypedCard(AccentBean, "bean") 25 + if err != nil { 26 + return nil, err 27 + } 28 + 29 + x := leftPad 30 + 31 + // Calculate content height for centering 32 + h := 58 // name 33 + hasDetails := bean.Origin != "" || bean.RoastLevel != "" || bean.Process != "" 34 + if hasDetails { 35 + h += 42 36 + } 37 + if bean.Roaster != nil && bean.Roaster.Name != "" { 38 + h += 42 39 + } 40 + if bean.Rating != nil && *bean.Rating > 0 { 41 + h += 44 42 + } 43 + h += 32 // divider 44 + if bean.Variety != "" { 45 + h += 34 46 + } 47 + if bean.Description != "" { 48 + h += 10 + 30 // single line 49 + } 50 + 51 + y := entityStartY(h) 52 + 53 + // Bean name 54 + card.DrawBoldText(truncate(bean.Name, 50), x, y, ColorDark, 44) 55 + y += 58 56 + 57 + // Origin / roast / process 58 + var details []string 59 + if bean.Origin != "" { 60 + details = append(details, bean.Origin) 61 + } 62 + if bean.RoastLevel != "" { 63 + details = append(details, bean.RoastLevel+" Roast") 64 + } 65 + if bean.Process != "" { 66 + details = append(details, bean.Process) 67 + } 68 + if len(details) > 0 { 69 + card.DrawText(strings.Join(details, dot), x, y, ColorBody, 26) 70 + y += 42 71 + } 72 + 73 + // Roaster 74 + if bean.Roaster != nil && bean.Roaster.Name != "" { 75 + card.DrawText("by "+bean.Roaster.Name, x, y, ColorBody, 26) 76 + y += 42 77 + } 78 + 79 + // Rating bar 80 + if bean.Rating != nil && *bean.Rating > 0 { 81 + y += 8 82 + y = drawRatingBar(card, x, y, *bean.Rating) 83 + } 84 + 85 + // Divider 86 + y += 8 87 + card.DrawRect(x, y, x+maxTextWidth, y+2, ColorDivider) 88 + y += 22 89 + 90 + // Variety 91 + if bean.Variety != "" { 92 + card.DrawBoldText("Variety", x, y, ColorDark, 22) 93 + card.DrawText(bean.Variety, x+120, y, ColorBody, 22) 94 + y += 34 95 + } 96 + 97 + // Description 98 + if bean.Description != "" { 99 + y += 10 100 + card.DrawText(truncateLine(card, bean.Description, maxTextWidth, 24, false), x, y, ColorMuted, 24) 101 + } 102 + 103 + return card, nil 104 + } 105 + 106 + // DrawRoasterCard generates a 1200x630 OG image for a roaster record. 107 + func DrawRoasterCard(roaster *models.Roaster) (*Card, error) { 108 + card, err := newTypedCard(AccentRoaster, "roaster") 109 + if err != nil { 110 + return nil, err 111 + } 112 + 113 + x := leftPad 114 + 115 + h := 58 // name 116 + if roaster.Location != "" { 117 + h += 44 118 + } 119 + h += 32 // divider 120 + if roaster.Website != "" { 121 + h += 34 122 + } 123 + 124 + y := entityStartY(h) 125 + 126 + // Roaster name 127 + card.DrawBoldText(truncate(roaster.Name, 50), x, y, ColorDark, 44) 128 + y += 58 129 + 130 + // Location 131 + if roaster.Location != "" { 132 + card.DrawText(roaster.Location, x, y, ColorBody, 28) 133 + y += 44 134 + } 135 + 136 + // Divider 137 + y += 8 138 + card.DrawRect(x, y, x+maxTextWidth, y+2, ColorDivider) 139 + y += 22 140 + 141 + // Website 142 + if roaster.Website != "" { 143 + card.DrawText(roaster.Website, x, y, ColorMuted, 22) 144 + } 145 + 146 + return card, nil 147 + } 148 + 149 + // DrawGrinderCard generates a 1200x630 OG image for a grinder record. 150 + func DrawGrinderCard(grinder *models.Grinder) (*Card, error) { 151 + card, err := newTypedCard(AccentGrinder, "grinder") 152 + if err != nil { 153 + return nil, err 154 + } 155 + 156 + x := leftPad 157 + 158 + h := 58 // name 159 + hasDetails := grinder.GrinderType != "" || grinder.BurrType != "" 160 + if hasDetails { 161 + h += 44 162 + } 163 + h += 32 // divider 164 + if grinder.Notes != "" { 165 + h += 10 + 60 166 + } 167 + 168 + y := entityStartY(h) 169 + 170 + // Grinder name 171 + card.DrawBoldText(truncate(grinder.Name, 50), x, y, ColorDark, 44) 172 + y += 58 173 + 174 + // Type and burr type 175 + var details []string 176 + if grinder.GrinderType != "" { 177 + details = append(details, grinder.GrinderType) 178 + } 179 + if grinder.BurrType != "" { 180 + details = append(details, grinder.BurrType+" burrs") 181 + } 182 + if len(details) > 0 { 183 + card.DrawText(strings.Join(details, dot), x, y, ColorBody, 28) 184 + y += 44 185 + } 186 + 187 + // Divider 188 + y += 8 189 + card.DrawRect(x, y, x+maxTextWidth, y+2, ColorDivider) 190 + y += 22 191 + 192 + // Notes 193 + if grinder.Notes != "" { 194 + card.DrawText(truncateLine(card, grinder.Notes, maxTextWidth, 24, false), x, y, ColorMuted, 24) 195 + } 196 + 197 + return card, nil 198 + } 199 + 200 + // DrawBrewerCard generates a 1200x630 OG image for a brewer record. 201 + func DrawBrewerCard(brewer *models.Brewer) (*Card, error) { 202 + card, err := newTypedCard(AccentBrewer, "brewer") 203 + if err != nil { 204 + return nil, err 205 + } 206 + 207 + x := leftPad 208 + 209 + h := 58 // name 210 + if brewer.BrewerType != "" { 211 + h += 44 212 + } 213 + h += 32 // divider 214 + if brewer.Description != "" { 215 + h += 10 + 60 216 + } 217 + 218 + y := entityStartY(h) 219 + 220 + // Brewer name 221 + card.DrawBoldText(truncate(brewer.Name, 50), x, y, ColorDark, 44) 222 + y += 58 223 + 224 + // Type label 225 + if brewer.BrewerType != "" { 226 + typeLabel := brewer.BrewerType 227 + if label, ok := models.BrewerTypeLabels[brewer.BrewerType]; ok { 228 + typeLabel = label 229 + } 230 + card.DrawText(typeLabel, x, y, ColorBody, 28) 231 + y += 44 232 + } 233 + 234 + // Divider 235 + y += 8 236 + card.DrawRect(x, y, x+maxTextWidth, y+2, ColorDivider) 237 + y += 22 238 + 239 + // Description 240 + if brewer.Description != "" { 241 + card.DrawText(truncateLine(card, brewer.Description, maxTextWidth, 24, false), x, y, ColorMuted, 24) 242 + } 243 + 244 + return card, nil 245 + } 246 + 247 + // DrawRecipeCard generates a 1200x630 OG image for a recipe record. 248 + func DrawRecipeCard(recipe *models.Recipe) (*Card, error) { 249 + var recipeType string 250 + if recipe.BrewerType != "" { 251 + recipeType = recipe.BrewerType 252 + } else { 253 + recipeType = "brew" 254 + } 255 + 256 + card, err := newTypedCard(AccentRecipe, recipeType+" recipe") 257 + if err != nil { 258 + return nil, err 259 + } 260 + 261 + x := leftPad 262 + 263 + h := 58 // name 264 + hasMethod := recipe.BrewerType != "" || (recipe.BrewerObj != nil && recipe.BrewerObj.Name != "") 265 + if hasMethod { 266 + h += 44 267 + } 268 + h += 32 // divider 269 + if recipe.CoffeeAmount > 0 || recipe.WaterAmount > 0 { 270 + h += 40 271 + } 272 + if len(recipe.Pours) > 0 { 273 + h += 34 274 + } 275 + if recipe.Notes != "" { 276 + h += 10 + 60 277 + } 278 + 279 + y := entityStartY(h) 280 + 281 + // Recipe name 282 + card.DrawBoldText(truncate(recipe.Name, 50), x, y, ColorDark, 44) 283 + y += 58 284 + 285 + // Brewer type + brewer name 286 + var methodParts []string 287 + if recipe.BrewerType != "" { 288 + if label, ok := models.BrewerTypeLabels[recipe.BrewerType]; ok { 289 + methodParts = append(methodParts, label) 290 + } else { 291 + methodParts = append(methodParts, recipe.BrewerType) 292 + } 293 + } 294 + if recipe.BrewerObj != nil && recipe.BrewerObj.Name != "" { 295 + methodParts = append(methodParts, recipe.BrewerObj.Name) 296 + } 297 + if len(methodParts) > 0 { 298 + card.DrawText(strings.Join(methodParts, dot), x, y, ColorBody, 28) 299 + y += 44 300 + } 301 + 302 + // Divider 303 + y += 8 304 + card.DrawRect(x, y, x+maxTextWidth, y+2, ColorDivider) 305 + y += 22 306 + 307 + // Params line: coffee / water / ratio 308 + var params []string 309 + if recipe.CoffeeAmount > 0 { 310 + params = append(params, fmt.Sprintf("%.0fg coffee", recipe.CoffeeAmount)) 311 + } 312 + if recipe.WaterAmount > 0 { 313 + params = append(params, fmt.Sprintf("%.0fg water", recipe.WaterAmount)) 314 + } 315 + if recipe.Ratio > 0 { 316 + params = append(params, fmt.Sprintf("1:%.1f ratio", recipe.Ratio)) 317 + } 318 + if len(params) > 0 { 319 + card.DrawBoldText(strings.Join(params, dot), x, y, ColorDark, 26) 320 + y += 40 321 + } 322 + 323 + // Pours 324 + if len(recipe.Pours) > 0 { 325 + var pourTexts []string 326 + for _, p := range recipe.Pours { 327 + pourTexts = append(pourTexts, fmt.Sprintf("%dg \u00b7 %d:%02d", p.WaterAmount, p.TimeSeconds/60, p.TimeSeconds%60)) 328 + } 329 + pourText := fmt.Sprintf("%d pours: %s", len(recipe.Pours), strings.Join(pourTexts, ", ")) 330 + card.DrawText(truncate(pourText, 100), x, y, ColorBody, 22) 331 + y += 34 332 + } 333 + 334 + // Notes 335 + if recipe.Notes != "" { 336 + y += 10 337 + card.DrawText(truncateLine(card, recipe.Notes, maxTextWidth, 24, false), x, y, ColorMuted, 24) 338 + } 339 + 340 + return card, nil 341 + }
internal/ogcard/fonts/patricks-iosevka-regular.ttf

This is a binary file and will not be displayed.

internal/ogcard/fonts/patricks-iosevka-semibold.ttf

This is a binary file and will not be displayed.

+6
internal/routing/routing.go
··· 81 81 mux.HandleFunc("GET /manage", h.HandleManage) 82 82 mux.HandleFunc("GET /brews", h.HandleBrewList) 83 83 mux.HandleFunc("GET /brews/new", h.HandleBrewNew) 84 + mux.HandleFunc("GET /brews/{id}/og-image", h.HandleBrewOGImage) 84 85 mux.HandleFunc("GET /brews/{id}", h.HandleBrewView) 86 + mux.HandleFunc("GET /beans/{id}/og-image", h.HandleBeanOGImage) 85 87 mux.HandleFunc("GET /beans/{id}", h.HandleBeanView) 88 + mux.HandleFunc("GET /roasters/{id}/og-image", h.HandleRoasterOGImage) 86 89 mux.HandleFunc("GET /roasters/{id}", h.HandleRoasterView) 90 + mux.HandleFunc("GET /grinders/{id}/og-image", h.HandleGrinderOGImage) 87 91 mux.HandleFunc("GET /grinders/{id}", h.HandleGrinderView) 92 + mux.HandleFunc("GET /brewers/{id}/og-image", h.HandleBrewerOGImage) 88 93 mux.HandleFunc("GET /brewers/{id}", h.HandleBrewerView) 89 94 mux.HandleFunc("GET /brews/{id}/edit", h.HandleBrewEdit) 90 95 mux.Handle("POST /brews", cop.Handler(http.HandlerFunc(h.HandleBrewCreate))) ··· 92 97 mux.Handle("DELETE /brews/{id}", cop.Handler(http.HandlerFunc(h.HandleBrewDelete))) 93 98 mux.HandleFunc("GET /brews/export", h.HandleBrewExport) 94 99 mux.HandleFunc("GET /recipes", h.HandleRecipeExplore) 100 + mux.HandleFunc("GET /recipes/{id}/og-image", h.HandleRecipeOGImage) 95 101 mux.HandleFunc("GET /recipes/{id}", h.HandleRecipeView) 96 102 97 103 // API routes for CRUD operations
+12 -1
internal/web/components/layout.templ
··· 44 44 return "website" 45 45 } 46 46 47 + // twitterCardType returns "summary_large_image" when an OG image is set, 48 + // otherwise "summary" for the default compact card. 49 + func (d *LayoutData) twitterCardType() string { 50 + if d.OGImage != "" { 51 + return "summary_large_image" 52 + } 53 + return "summary" 54 + } 55 + 47 56 templ Layout(data *LayoutData, content templ.Component) { 48 57 <!DOCTYPE html> 49 58 <html lang="en" class="h-full" style="background-color: var(--page-bg);"> ··· 68 77 } 69 78 if data.OGImage != "" { 70 79 <meta property="og:image" content={ data.OGImage }/> 80 + <meta property="og:image:width" content="1200"/> 81 + <meta property="og:image:height" content="630"/> 71 82 <meta property="og:image:alt" content={ data.ogTitle() }/> 72 83 } 73 84 <!-- Twitter Card metadata --> 74 - <meta name="twitter:card" content="summary"/> 85 + <meta name="twitter:card" content={ data.twitterCardType() }/> 75 86 <meta name="twitter:title" content={ data.ogTitle() }/> 76 87 <meta name="twitter:description" content={ data.ogDescription() }/> 77 88 if data.OGImage != "" {