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

Configure Feed

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

feat: add entity package for easier lexicon addition/editing #3

open opened by pdewey.com targeting main from push-wyqtrtsvtyyy
Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:hm5f3dnm6jdhrc55qp2npdja/sh.tangled.repo.pull/3mkm2vnrhib22
+3016 -1461
Diff #0
+210
docs/entity-descriptor-refactor.md
··· 1 + # Entity Descriptor Refactor — Spec 2 + 3 + **Status:** Phase 0 complete; phase 1 in progress 4 + **Author:** ptdewey 5 + **Created:** 2026-04-25 6 + **Last updated:** 2026-04-25 7 + 8 + ## Goal 9 + 10 + Replace the per-entity switch/method fan-out with a single `entities.Descriptor` 11 + registry. Adding a new entity (cafe, drink, etc.) should require ~10 edits 12 + instead of the ~27 documented in `CLAUDE.md`. Concretely: collapse the 13 + dozen-plus switches scattered across the feed templ, OG cards, modals, 14 + suggestions, and routing into one lookup table. 15 + 16 + ## Background 17 + 18 + Arabica supports seven record types today (bean, brew, brewer, grinder, like, 19 + recipe, roaster). The "Adding a New Entity Type" checklist in `CLAUDE.md` 20 + enumerates ~27 separate edits across as many files. Most of those edits are 21 + mechanical scaffolding rather than meaningful per-entity logic: 22 + 23 + - `internal/atproto/cache.go`: 12+ identical `SetX`/`InvalidateX` methods 24 + - `internal/atproto/store.go`: parallel `GetXByRKey`/`ListX` methods that all 25 + follow the same witness → convert → resolve refs → fallback-to-PDS chain 26 + - `internal/web/pages/feed.templ`: five separate switches over `RecordType` 27 + (card class, action text, share URL, delete URL, delete confirm copy) 28 + - `internal/web/components/dialog_modals.templ`: a nested type+field switch 29 + in `getStringValue`, plus five ~340-LOC modal components that share a 30 + common shell but differ in field bodies 31 + - `internal/handlers/entity_views.go`: four near-identical entity view handlers 32 + - `internal/ogcard/entities.go`: five Draw functions sharing one structure 33 + - `internal/firehose/index.go`: a 135-line `recordToFeedItem` switch 34 + - `internal/suggestions/suggestions.go` + `internal/handlers/suggestions.go`: 35 + parallel maps of per-entity config 36 + 37 + The pattern that ties all of these together is "given a `RecordType`, do X." 38 + A single registry keyed by `RecordType` removes the duplication. 39 + 40 + ## What we're optimizing for 41 + 42 + The maintainer's stated priorities, ranked: 43 + 44 + 1. **Adding new entity types** (cafe, drink, future). The 27-step checklist 45 + is the headline pain point. 46 + 2. **Adding/changing fields** on existing entities. Today this means editing 47 + ~6-8 files; the refactor can bring it down to ~4-5 in some cases. 48 + 3. **Preserving room for unique behavior** on entities like brew (multi-ref 49 + resolution, espresso/pourover variants) and recipe (pours, computed 50 + ratios). The refactor must NOT force these through a one-size-fits-all 51 + abstraction. 52 + 53 + LOC reduction is **not** a primary goal. Realistic net delta after the full 54 + rollout is ~600-1000 LOC removed minus ~300-500 LOC of new descriptor/helper 55 + code. That's meaningful but not the headline. 56 + 57 + ## Non-goals 58 + 59 + - Replacing per-entity record conversion (`RecordToBean`, `BeanToRecord`, 60 + etc.) — these carry validation and shape logic; leave them 61 + - Code generation from lexicon JSON — too much annotation overhead for the 62 + win, and the lexicons are not rich enough to drive Go types + templ markup 63 + - Restructuring `SessionCache` or the firehose pipeline — separate concerns, 64 + with their own audit findings to address later 65 + - Building cafe/drink scaffolding before the abstraction lands — avoid a 66 + moving target 67 + - A declarative form-spec DSL for modals (see "What we changed our minds 68 + about" below) 69 + 70 + ## What goes in the descriptor 71 + 72 + Pure data and small accessors that vary across entities: 73 + 74 + - `Type` (`RecordType`), `NSID` 75 + - `DisplayName` (`"Bean"`), `Noun` (`"bean"`), `URLPath` (`"beans"`) 76 + - `GetField(entity, field) (string, bool)` — for templ form prefill 77 + - (later phases) `CardClass`, `OGAccentColor`, `SuggestionConfig` 78 + 79 + What stays as code (NOT in the descriptor): 80 + 81 + - Record conversion (rich, hand-written, type-safe) 82 + - Templ form bodies (genuinely different layouts per entity) 83 + - Validation rules 84 + - Container-specific record accessors (e.g. `(*FeedItem).Record()`) — see 85 + "Design choice" below 86 + 87 + ## Design choice: descriptor describes records, containers know themselves 88 + 89 + Earlier versions of this spec put a `Record func(*feed.FeedItem) any` 90 + accessor on `Descriptor`. That coupled `entities` to `feed` and made 91 + FeedItem a privileged container — but most future descriptor consumers 92 + (OG cards, view handlers, store CRUD) operate on raw records, not feed 93 + items. 94 + 95 + The current shape: `entities` only depends on `lexicons` (and `models` 96 + for field accessors). Each container that holds typed record fields 97 + exposes its own way to retrieve a record by `RecordType`. For 98 + `FeedItem`, that's a `Record() any` method; the small switch lives next 99 + to the typed fields, where adding a new entity field obviously requires 100 + updating it. 101 + 102 + This keeps the descriptor reusable across every container without 103 + re-design. 104 + 105 + ## Phased rollout 106 + 107 + **Updated rollout** based on what the maintainer actually values. Phases 108 + 2-4 are deprioritized because they're invisible to day-to-day work; the 109 + focus is on changes that affect entity addition and field-level edits. 110 + 111 + Each phase ships independently. Stop anywhere if the win plateaus. 112 + 113 + | Phase | Scope | Status | Approx. LOC | 114 + |---|---|---|---| 115 + | **0. Foundation** | New `internal/entities` package + registry. Migrate `ActionText` and `getStringValue` as proof. | ✅ done | net 0 | 116 + | **1. Templ data switches** | Remaining four switches in `feed.templ` (card class, title, share URL, delete URL+confirm). OG card accent/label lookup. | ▶ next | −150 | 117 + | **5'. Modal shell extraction** *(replaces old phase 5)* | Extract a shared `EntityModalShell` component (dialog mechanics, header, error display, footer/buttons). Per-entity field bodies stay as their own templ files. | planned | −100 to −150 | 118 + | **2. Cache map** | `UserCache.Beans/Roasters/...` → `map[string]any` keyed by NSID. | deferred | −200 | 119 + | **3. Generic store CRUD** | `Get[T](ctx, nsid, rkey)`, `List[T]` on `AtprotoStore`. | deferred | −400 | 120 + | **4. View handler unification** | Collapse simple entity view handlers; brew/recipe stay bespoke. | deferred | −500 | 121 + | **6. Cleanup pass** | Modal route loop, suggestions config from descriptor, dirty-tracking → TTL, PublicClient resolver cache. | deferred | −80 | 122 + 123 + Phases 2-4 are still legitimate refactors, but their day-to-day value is 124 + low for the maintainer's stated priorities. They land if they fall out 125 + cheaply during other work, or if the storage layer is being refactored 126 + for another reason (e.g. quickslice integration). 127 + 128 + ## What we changed our minds about 129 + 130 + ### Original phase 5 (full FieldSpec consolidation): rejected 131 + 132 + The original plan was to replace all five dialog modals with a single 133 + `EntityModal(descriptor, []FieldSpec, entity)` driven by a declarative 134 + field spec. After looking at what the modals actually contain, this is 135 + the wrong scope: 136 + 137 + - Plain text/number/dropdown fields fit cleanly into FieldSpec (~70%) 138 + - The bean modal's roaster picker (~80 lines of Alpine state) doesn't 139 + - The brewer modal's espresso/pourover conditional sections don't 140 + - The bean rating slider (stateful Alpine widget) doesn't 141 + 142 + To absorb the bespoke widgets, FieldSpec would need predicates, slots, 143 + init-state passthrough, and raw-templ escape hatches. At that point 144 + it's a forms DSL, debugging means reading the spec interpreter, and 145 + adding a field requires reasoning about how the renderer interprets 146 + your spec. 147 + 148 + ### Replacement: modal shell extraction (phase 5') 149 + 150 + Extract only the cross-cutting consistency layer: dialog open/close 151 + mechanics, header, validation error display, footer with cancel/submit 152 + buttons, dirty-tracking. Each entity's modal still has its own templ 153 + file with its own field body — the body is where editing actually 154 + happens, and it stays readable as plain templ. 155 + 156 + Trade-offs vs the rejected design: 157 + 158 + - LOC saved: ~100-150 instead of ~300, but **zero DSL risk** 159 + - Adding a new entity: copy an existing modal, change the fields. Same 160 + friction as today for the body, but the shell is free. 161 + - Cross-cutting modal changes (e.g., new error display): single edit, 162 + same as the rejected design. 163 + - Field changes: same friction as today (edit the entity's modal templ 164 + directly). The "field changes get easier" pitch was always weak. 165 + 166 + ## Risks 167 + 168 + - **Templ ergonomics**: descriptors return primitives, not templ 169 + components. Switches that dispatch to *different rendering* must 170 + stay (we only flatten *data* switches). 171 + - **Over-applying the abstraction**: some entities (brew, recipe) are 172 + legitimately different. The discipline is to leave them bespoke, 173 + not strain the descriptor to fit them. 174 + - **Refactor stalling**: phases 0-1 + 5' ship the real win even if 2-4 175 + never happen. Don't gate the value on the deferred phases. 176 + 177 + ## Success criteria 178 + 179 + - Phase 0 lands with no behavior change and tests green. ✅ 180 + - Adding a hypothetical 8th entity (cafe) requires touching strictly 181 + fewer files than the current checklist documents. 182 + - The 27-step checklist in `CLAUDE.md` shrinks to reflect the new 183 + ceiling after each phase. 184 + - No new abstractions are introduced beyond the descriptor, the modal 185 + shell, and (later, if we get there) the generic CRUD helper. We are 186 + collapsing duplication, not building a framework. 187 + - Brew and recipe view/modal code remains bespoke and uncomplicated by 188 + the descriptor system. 189 + 190 + ## Decisions made 191 + 192 + 1. **Package location**: `internal/entities/` ✅ 193 + 2. **`Get` return shape**: `*Descriptor` (nil on miss) ✅ 194 + 3. **Descriptor scope**: record-centric, not container-centric. `entities` 195 + does not import `feed`. Containers expose their own record accessors. ✅ 196 + 4. **Like registration**: skipped (no entity page). Revisit if a feed 197 + migration needs it. 198 + 5. **Cafe & drink**: defer registration until phase 1 ships. Adding them 199 + should be the smoke test for the refactor. 200 + 6. **Phase 5**: rejected as originally specified. Replaced with shell 201 + extraction (phase 5'). ✅ 202 + 203 + ## Related work 204 + 205 + - `docs/cafe-and-drinks.md` — upcoming entity types this refactor unblocks 206 + - `docs/quickslice-implementation-plan.md` — separate read-path refactor; 207 + composes cleanly with this one 208 + - `docs/design-audit.md` — broader audit notes 209 + - `docs/plans/2026-04-25-entity-descriptor-phase-0.md` — phase 0 plan 210 + - `docs/plans/2026-04-25-entity-descriptor-phase-1.md` — phase 1 plan
+364
docs/plans/2026-04-25-entity-descriptor-phase-0.md
··· 1 + # Entity Descriptor — Phase 0 Foundation 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to 4 + > implement this plan task-by-task. 5 + 6 + **Goal:** Introduce the `internal/entities` package with a `Descriptor` type and 7 + registry, register all current record types, and migrate two representative call 8 + sites (one templ, one Go) as proof. No behavior change; pure refactor. 9 + 10 + **Parent spec:** `docs/entity-descriptor-refactor.md` 11 + 12 + **Tech Stack:** Go 1.26, Templ, no new dependencies. 13 + 14 + --- 15 + 16 + ## Major Design Changes 17 + 18 + ### The new abstraction 19 + 20 + A single struct, `entities.Descriptor`, captures the per-entity data that 21 + callers across the codebase dispatch on. One descriptor is registered per record 22 + type at package `init()`. Callers do `entities.Get(item.RecordType)` and read 23 + fields off the descriptor instead of writing a `switch`. 24 + 25 + The descriptor stores **data and small accessors** — not templ components, not 26 + record conversion, not validation. Anything that genuinely varies in _structure_ 27 + per entity (form layouts, conversion logic, ref resolution) stays as code. 28 + 29 + ### Why a registry instead of generics or interfaces 30 + 31 + - **Generics** force every caller to be parameterized on `T`, which doesn't work 32 + for templ files (templ has no type parameters) and would force the feed item 33 + to be generic too. 34 + - **An interface on the model** (e.g., `Bean implements EntityDescriptor`) works 35 + for Go callers but not for templ — templ needs to dispatch on `RecordType` (a 36 + string) coming off a `FeedItem`, where the typed model pointer may be nil. 37 + - **A registry keyed by `RecordType`** works in both Go and templ, requires zero 38 + changes to existing types, and stays opt-in: callers who don't need it ignore 39 + it. 40 + 41 + ### What is NOT changing in phase 0 42 + 43 + - The `Store` interface and `AtprotoStore` implementation 44 + - `UserCache` typed fields 45 + - Per-entity record conversion (`records.go`) 46 + - Per-entity templ form layouts 47 + - The 5-switch structure of `feed.templ` (only `ActionText` is migrated this 48 + phase; the others are phase 1) 49 + - Routing (entity routes still registered explicitly; loop comes in phase 6) 50 + 51 + ### What this phase enables 52 + 53 + After phase 0, every subsequent phase migrates additional call sites onto the 54 + same registry. The generic-store work in phase 3 reuses `Descriptor.NSID` for 55 + collection lookup. The OG card work in phase 1 reuses `Descriptor.DisplayName`. 56 + Phase 0's value is foundational, not direct LOC savings. 57 + 58 + ### Boundaries and invariants 59 + 60 + - **The descriptor never holds entity data, only metadata.** A `Descriptor` for 61 + "bean" is a singleton; there is one of it for the whole program. 62 + - **`any` is contained.** It appears on `Record` and `GetField` because templ 63 + can't be parameterized on `T`. Callers never store `any` — they extract a 64 + typed pointer or a string and move on. 65 + - **Registration panics on duplicates.** Catches double-registration bugs at 66 + startup, before serving traffic. 67 + - **Registration is package-private to `entities`.** No one outside the package 68 + can mutate the registry. 69 + 70 + --- 71 + 72 + ## Phase 0: Foundation 73 + 74 + ### Task 1: Create the `entities` package 75 + 76 + **Files:** 77 + 78 + - Create: `internal/entities/entities.go` 79 + - Create: `internal/entities/entities_test.go` 80 + 81 + **Step 1: Write the descriptor type and registry** 82 + 83 + Create `internal/entities/entities.go`: 84 + 85 + ```go 86 + // Package entities provides a registry of descriptors for each Arabica record 87 + // type. A descriptor captures the per-entity data that callers in feed, templ, 88 + // handlers, and ogcard dispatch on, replacing scattered switch statements with 89 + // a single lookup. 90 + package entities 91 + 92 + import ( 93 + "fmt" 94 + "sort" 95 + 96 + "tangled.org/arabica.social/arabica/internal/feed" 97 + "tangled.org/arabica.social/arabica/internal/lexicons" 98 + ) 99 + 100 + // Descriptor describes one Arabica record type. 101 + type Descriptor struct { 102 + Type lexicons.RecordType 103 + NSID string 104 + DisplayName string // "Bean" 105 + Noun string // "bean" — appears in copy: "added a new bean" 106 + URLPath string // "beans" — share URLs and routes 107 + 108 + // Record returns the typed record pointer from a FeedItem (e.g. item.Bean), 109 + // or nil if the FeedItem holds no record of this type. 110 + Record func(*feed.FeedItem) any 111 + 112 + // GetField extracts one named string field from a typed model pointer for 113 + // form prefill. Returns ("", false) if entity is nil or field is unknown. 114 + GetField func(entity any, field string) (string, bool) 115 + } 116 + 117 + var registry = map[lexicons.RecordType]*Descriptor{} 118 + 119 + // Register adds a descriptor. Called once per entity at package init. 120 + // Panics on duplicate registration to catch wiring bugs at startup. 121 + func Register(d *Descriptor) { 122 + if _, ok := registry[d.Type]; ok { 123 + panic(fmt.Sprintf("entities: duplicate descriptor for %s", d.Type)) 124 + } 125 + registry[d.Type] = d 126 + } 127 + 128 + // Get returns the descriptor for a record type, or nil if unregistered. 129 + func Get(rt lexicons.RecordType) *Descriptor { return registry[rt] } 130 + 131 + // All returns descriptors in stable order (by RecordType). Use for route loops. 132 + func All() []*Descriptor { 133 + out := make([]*Descriptor, 0, len(registry)) 134 + for _, d := range registry { 135 + out = append(out, d) 136 + } 137 + sort.Slice(out, func(i, j int) bool { return out[i].Type < out[j].Type }) 138 + return out 139 + } 140 + ``` 141 + 142 + **Step 2: Write the registry test** 143 + 144 + Create `internal/entities/entities_test.go` covering: 145 + 146 + - `Get` returns the right descriptor for each registered type 147 + - `Get` returns `nil` for an unknown type 148 + - `All()` returns descriptors in sorted order 149 + - Duplicate registration panics 150 + 151 + Use `testify/assert` (project convention). 152 + 153 + ### Task 2: Register all current record types 154 + 155 + **Files:** 156 + 157 + - Create: `internal/entities/register.go` 158 + - Create: `internal/entities/fields.go` 159 + 160 + **Step 1: Write registration** 161 + 162 + Create `internal/entities/register.go`: 163 + 164 + ```go 165 + package entities 166 + 167 + import ( 168 + "tangled.org/arabica.social/arabica/internal/atproto" 169 + "tangled.org/arabica.social/arabica/internal/feed" 170 + "tangled.org/arabica.social/arabica/internal/lexicons" 171 + ) 172 + 173 + func init() { 174 + Register(&Descriptor{ 175 + Type: lexicons.RecordTypeBean, NSID: atproto.NSIDBean, 176 + DisplayName: "Bean", Noun: "bean", URLPath: "beans", 177 + Record: func(i *feed.FeedItem) any { return i.Bean }, 178 + GetField: beanField, 179 + }) 180 + Register(&Descriptor{ 181 + Type: lexicons.RecordTypeRoaster, NSID: atproto.NSIDRoaster, 182 + DisplayName: "Roaster", Noun: "roaster", URLPath: "roasters", 183 + Record: func(i *feed.FeedItem) any { return i.Roaster }, 184 + GetField: roasterField, 185 + }) 186 + Register(&Descriptor{ 187 + Type: lexicons.RecordTypeGrinder, NSID: atproto.NSIDGrinder, 188 + DisplayName: "Grinder", Noun: "grinder", URLPath: "grinders", 189 + Record: func(i *feed.FeedItem) any { return i.Grinder }, 190 + GetField: grinderField, 191 + }) 192 + Register(&Descriptor{ 193 + Type: lexicons.RecordTypeBrewer, NSID: atproto.NSIDBrewer, 194 + DisplayName: "Brewer", Noun: "brewer", URLPath: "brewers", 195 + Record: func(i *feed.FeedItem) any { return i.Brewer }, 196 + GetField: brewerField, 197 + }) 198 + Register(&Descriptor{ 199 + Type: lexicons.RecordTypeRecipe, NSID: atproto.NSIDRecipe, 200 + DisplayName: "Recipe", Noun: "recipe", URLPath: "recipes", 201 + Record: func(i *feed.FeedItem) any { return i.Recipe }, 202 + GetField: recipeField, 203 + }) 204 + Register(&Descriptor{ 205 + Type: lexicons.RecordTypeBrew, NSID: atproto.NSIDBrew, 206 + DisplayName: "Brew", Noun: "brew", URLPath: "brews", 207 + Record: func(i *feed.FeedItem) any { return i.Brew }, 208 + GetField: nil, // brew has no edit modal that needs prefill 209 + }) 210 + // Like is intentionally omitted — has no entity page or modal. 211 + } 212 + ``` 213 + 214 + **Step 2: Write the per-entity field accessors** 215 + 216 + Create `internal/entities/fields.go` with `beanField`, `roasterField`, 217 + `grinderField`, `brewerField`, `recipeField`. Each is a small switch over the 218 + field-name strings used by `dialog_modals.templ`. Example: 219 + 220 + ```go 221 + func beanField(e any, field string) (string, bool) { 222 + b, ok := e.(*models.Bean) 223 + if !ok || b == nil { 224 + return "", false 225 + } 226 + switch field { 227 + case "name": return b.Name, true 228 + case "origin": return b.Origin, true 229 + case "variety": return b.Variety, true 230 + case "process": return b.Process, true 231 + case "description": return b.Description, true 232 + } 233 + return "", false 234 + } 235 + ``` 236 + 237 + The Recipe case in the original `getStringValue` formats `coffee_amount` and 238 + `water_amount` as `%.1f`. Move that formatting into `recipeField` — the 239 + descriptor is the right home for it. 240 + 241 + ### Task 3: Migrate `feed.templ` `ActionText` 242 + 243 + **Files:** 244 + 245 + - Modify: `internal/web/pages/feed.templ` 246 + 247 + **Step 1: Replace the switch** 248 + 249 + Lines `289–348` of `internal/web/pages/feed.templ` contain six near-identical 250 + cases that differ only in noun. Replace the whole `templ ActionText(...)` body 251 + with: 252 + 253 + ```templ 254 + templ ActionText(item *feed.FeedItem) { 255 + if d := entities.Get(item.RecordType); d != nil && d.Record(item) != nil { 256 + added a 257 + <a href={ templ.SafeURL(getFeedItemShareURL(item)) } 258 + class="underline hover:text-brown-900">new { d.Noun }</a> 259 + { " " } 260 + @components.TypeBadge(d.Noun) 261 + } else { 262 + { item.Action } 263 + } 264 + } 265 + ``` 266 + 267 + Add the `entities` import to the templ file. 268 + 269 + **Step 2: Regenerate** 270 + 271 + Run `templ generate -f internal/web/pages/feed.templ`. 272 + 273 + ### Task 4: Migrate `getStringValue` in `dialog_modals.templ` 274 + 275 + **Files:** 276 + 277 + - Modify: `internal/web/components/dialog_modals.templ` 278 + 279 + **Step 1: Replace the function** 280 + 281 + Lines `935–1016` of `internal/web/components/dialog_modals.templ` contain the 282 + nested type+field switch. Replace with: 283 + 284 + ```go 285 + func getStringValue(entity interface{}, field string) string { 286 + if entity == nil { 287 + return "" 288 + } 289 + rt := recordTypeOf(entity) 290 + d := entities.Get(rt) 291 + if d == nil || d.GetField == nil { 292 + return "" 293 + } 294 + v, _ := d.GetField(entity, field) 295 + return v 296 + } 297 + 298 + // recordTypeOf maps a typed model pointer to its RecordType. Local helper 299 + // because callers here already hold a typed pointer; pulling this onto the 300 + // model would require a phase-4 model unification. 301 + func recordTypeOf(entity any) lexicons.RecordType { 302 + switch entity.(type) { 303 + case *models.Bean: return lexicons.RecordTypeBean 304 + case *models.Roaster: return lexicons.RecordTypeRoaster 305 + case *models.Grinder: return lexicons.RecordTypeGrinder 306 + case *models.Brewer: return lexicons.RecordTypeBrewer 307 + case *models.Recipe: return lexicons.RecordTypeRecipe 308 + } 309 + return "" 310 + } 311 + ``` 312 + 313 + Add the `entities` import. 314 + 315 + **Step 2: Regenerate** 316 + 317 + Run `templ generate -f internal/web/components/dialog_modals.templ`. 318 + 319 + ### Task 5: Verify 320 + 321 + **Commands:** 322 + 323 + ```bash 324 + go vet ./... 325 + go build ./... 326 + go test ./... 327 + just run 328 + ``` 329 + 330 + **Manual smoke test:** 331 + 332 + - Load `/feed` — `ActionText` for each entity type should render the same as 333 + before 334 + - Open the bean edit modal from `/my-coffee` — fields should prefill identically 335 + - Repeat for roaster, grinder, brewer, recipe modals 336 + 337 + **Expected delta:** 338 + 339 + - ~110 LOC removed (`ActionText` switch: −48, `getStringValue` switch: −62) 340 + - ~90 LOC added (new `entities` package) 341 + - Net flat, but the foundation now exists for phases 1–6 to compound on. 342 + 343 + --- 344 + 345 + ## Out of scope for phase 0 346 + 347 + These belong to later phases (see parent spec for full rollout): 348 + 349 + - Remaining four switches in `feed.templ` (phase 1) 350 + - OG card consolidation (phase 1) 351 + - Cache typed fields → map (phase 2) 352 + - Generic store CRUD (phase 3) 353 + - View handler unification (phase 4) 354 + - Dialog modal consolidation (phase 5) 355 + - Routing loop, suggestions config, dirty-tracking TTL (phase 6) 356 + 357 + ## Decisions to confirm before starting 358 + 359 + 1. **Package location:** `internal/entities/` — confirmed unless you'd prefer 360 + `internal/lexicons/`. 361 + 2. **`Get` return shape:** `*Descriptor` (nil on miss) — idiomatic Go. Switch to 362 + `(*Descriptor, bool)` if you'd rather be explicit. 363 + 3. **Like registration:** skipped in this phase (no entity page); revisit if a 364 + feed migration needs it.
+319
docs/plans/2026-04-25-entity-descriptor-phase-1.md
··· 1 + # Entity Descriptor — Phase 1: Templ Data Switches 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to 4 + > implement this plan task-by-task. 5 + 6 + **Goal:** Migrate the remaining `RecordType` switches in `feed.templ` (share 7 + URL, share title, delete URL) onto the descriptor + new `FeedItem` accessor 8 + methods. Replace hardcoded entity labels in OG card constructors with 9 + `Descriptor.Noun`. No behavior change; pure refactor. 10 + 11 + **Parent spec:** `docs/entity-descriptor-refactor.md` 12 + **Previous phase:** `docs/plans/2026-04-25-entity-descriptor-phase-0.md` 13 + 14 + **Tech Stack:** Go 1.26, Templ, no new dependencies. 15 + 16 + --- 17 + 18 + ## Major Design Changes 19 + 20 + ### Container-side accessors on `FeedItem` 21 + 22 + Phase 0 added `(*FeedItem).Record()` to give callers a typed-pointer 23 + accessor without a switch. Phase 1 extends the same pattern with two 24 + more accessors: 25 + 26 + - `(*FeedItem).RKey() string` — the rkey of whichever record is set 27 + - `(*FeedItem).DisplayTitle() string` — a human-readable title for 28 + share UI; brew is special-cased (uses bean name) 29 + 30 + Both methods follow the same convention: a single switch in 31 + `feed/service.go`, co-located with the typed fields. Adding a new 32 + entity to FeedItem requires updating both methods, which is obvious 33 + because they live next to the field declarations. 34 + 35 + ### Rule of thumb confirmed 36 + 37 + We are flattening **data switches** (URLs, labels, titles) but keeping 38 + **rendering switches** (which templ component to invoke). The 39 + `feed.templ` content-rendering switch at line 212 (dispatching to 40 + `BeanContent`, `RoasterContent`, etc.) is **not** migrated — it 41 + dispatches to genuinely different templ components, which the 42 + descriptor pattern can't carry. 43 + 44 + ### What is NOT changing in phase 1 45 + 46 + - The `Store` / `AtprotoStore` interfaces 47 + - `UserCache` typed fields 48 + - Per-entity record conversion (`records.go`) 49 + - The content-rendering switch in `feed.templ` (line 212) 50 + - Per-entity `Draw*Card` functions in `ogcard/entities.go` (the field 51 + rendering is genuinely different per entity) 52 + - `getEditURL` (only handles brew today; not worth touching) 53 + 54 + ### What this phase enables 55 + 56 + After phase 1, the feed templ has no remaining data switches over 57 + `RecordType`. The next migration target (phase 5' modal shell 58 + extraction) is independent and can land at any time. 59 + 60 + --- 61 + 62 + ## Phase 1: Tasks 63 + 64 + ### Task 1: Add `RKey()` and `DisplayTitle()` accessors to `FeedItem` 65 + 66 + **Files:** 67 + 68 + - Modify: `internal/feed/service.go` 69 + 70 + **Step 1: Add `RKey()` method** 71 + 72 + After the existing `Record()` method, add: 73 + 74 + ```go 75 + // RKey returns the record key of whichever typed record is set on this 76 + // FeedItem, or "" if none. Lets callers build URLs without a type switch. 77 + func (f *FeedItem) RKey() string { 78 + switch f.RecordType { 79 + case lexicons.RecordTypeBean: 80 + if f.Bean != nil { 81 + return f.Bean.RKey 82 + } 83 + case lexicons.RecordTypeRoaster: 84 + if f.Roaster != nil { 85 + return f.Roaster.RKey 86 + } 87 + case lexicons.RecordTypeGrinder: 88 + if f.Grinder != nil { 89 + return f.Grinder.RKey 90 + } 91 + case lexicons.RecordTypeBrewer: 92 + if f.Brewer != nil { 93 + return f.Brewer.RKey 94 + } 95 + case lexicons.RecordTypeRecipe: 96 + if f.Recipe != nil { 97 + return f.Recipe.RKey 98 + } 99 + case lexicons.RecordTypeBrew: 100 + if f.Brew != nil { 101 + return f.Brew.RKey 102 + } 103 + } 104 + return "" 105 + } 106 + ``` 107 + 108 + **Step 2: Add `DisplayTitle()` method** 109 + 110 + ```go 111 + // DisplayTitle returns a human-readable title for share UI. Brew is 112 + // special-cased: brews don't have a name field, so we fall back to the 113 + // associated bean's name (or origin). 114 + func (f *FeedItem) DisplayTitle() string { 115 + switch f.RecordType { 116 + case lexicons.RecordTypeBrew: 117 + if f.Brew != nil && f.Brew.Bean != nil { 118 + if f.Brew.Bean.Name != "" { 119 + return f.Brew.Bean.Name 120 + } 121 + return f.Brew.Bean.Origin 122 + } 123 + return "Coffee Brew" 124 + case lexicons.RecordTypeBean: 125 + if f.Bean != nil { 126 + if f.Bean.Name != "" { 127 + return f.Bean.Name 128 + } 129 + return f.Bean.Origin 130 + } 131 + return "Coffee Bean" 132 + case lexicons.RecordTypeRoaster: 133 + if f.Roaster != nil { 134 + return f.Roaster.Name 135 + } 136 + return "Roaster" 137 + case lexicons.RecordTypeGrinder: 138 + if f.Grinder != nil { 139 + return f.Grinder.Name 140 + } 141 + return "Grinder" 142 + case lexicons.RecordTypeBrewer: 143 + if f.Brewer != nil { 144 + return f.Brewer.Name 145 + } 146 + return "Brewer" 147 + case lexicons.RecordTypeRecipe: 148 + if f.Recipe != nil { 149 + return f.Recipe.Name 150 + } 151 + return "Recipe" 152 + } 153 + return "Arabica" 154 + } 155 + ``` 156 + 157 + ### Task 2: Migrate `getFeedItemShareURL` in `feed.templ` 158 + 159 + **Files:** 160 + 161 + - Modify: `internal/web/pages/feed.templ` 162 + 163 + **Step 1: Replace the switch** 164 + 165 + Replace lines `471-499` (`getFeedItemShareURL`) with: 166 + 167 + ```go 168 + func getFeedItemShareURL(item *feed.FeedItem) string { 169 + if d := entities.Get(item.RecordType); d != nil { 170 + if rkey := item.RKey(); rkey != "" { 171 + return fmt.Sprintf("/%s/%s?owner=%s", d.URLPath, rkey, item.Author.DID) 172 + } 173 + } 174 + return fmt.Sprintf("/profile/%s", item.Author.DID) 175 + } 176 + ``` 177 + 178 + **Step 2: Regenerate** 179 + 180 + ```bash 181 + templ generate -f internal/web/pages/feed.templ 182 + ``` 183 + 184 + ### Task 3: Migrate `getFeedItemShareTitle` in `feed.templ` 185 + 186 + **Files:** 187 + 188 + - Modify: `internal/web/pages/feed.templ` 189 + 190 + **Step 1: Replace the switch** 191 + 192 + Replace lines `501-541` (`getFeedItemShareTitle`) with: 193 + 194 + ```go 195 + func getFeedItemShareTitle(item *feed.FeedItem) string { 196 + if title := item.DisplayTitle(); title != "" { 197 + return title 198 + } 199 + return "Arabica" 200 + } 201 + ``` 202 + 203 + The per-type fallbacks (e.g., "Coffee Bean", "Roaster") now live in 204 + `(*FeedItem).DisplayTitle()`. 205 + 206 + ### Task 4: Migrate `getDeleteURL` in `feed.templ` 207 + 208 + **Files:** 209 + 210 + - Modify: `internal/web/pages/feed.templ` 211 + 212 + **Step 1: Replace the switch** 213 + 214 + Brew has an asymmetric delete URL (`/brews/{rkey}` instead of 215 + `/api/brews/{rkey}`) — leave it as an explicit branch, don't smuggle 216 + the asymmetry into the descriptor. Replace lines `564-592` 217 + (`getDeleteURL`) with: 218 + 219 + ```go 220 + func getDeleteURL(item *feed.FeedItem) string { 221 + rkey := item.RKey() 222 + if rkey == "" { 223 + return "" 224 + } 225 + if item.RecordType == lexicons.RecordTypeBrew { 226 + return fmt.Sprintf("/brews/%s", rkey) 227 + } 228 + if d := entities.Get(item.RecordType); d != nil { 229 + return fmt.Sprintf("/api/%s/%s", d.URLPath, rkey) 230 + } 231 + return "" 232 + } 233 + ``` 234 + 235 + **Step 2: Regenerate** 236 + 237 + ```bash 238 + templ generate -f internal/web/pages/feed.templ 239 + ``` 240 + 241 + ### Task 5: OG card label uses `Descriptor.Noun` 242 + 243 + **Files:** 244 + 245 + - Modify: `internal/ogcard/entities.go` 246 + 247 + **Step 1: Replace hardcoded labels** 248 + 249 + In the four simple Draw functions (`DrawBeanCard`, `DrawRoasterCard`, 250 + `DrawGrinderCard`, `DrawBrewerCard`), replace the second argument to 251 + `newTypedCard` with a descriptor lookup. Example: 252 + 253 + Before: 254 + ```go 255 + card, err := newTypedCard(AccentBean, "bean") 256 + ``` 257 + 258 + After: 259 + ```go 260 + card, err := newTypedCard(AccentBean, entities.Get(lexicons.RecordTypeBean).Noun) 261 + ``` 262 + 263 + Apply to all four. Leave `DrawRecipeCard` as-is — its label is 264 + `recipeType+" recipe"` (e.g., "espresso recipe"), which is bespoke and 265 + can't be expressed by `Noun` alone. 266 + 267 + `DrawBrewCard` in `ogcard/brew.go` is also bespoke (brew is a unique 268 + case across the refactor); leave it alone. 269 + 270 + **Note on accent colors:** the `AccentX` constants stay in 271 + `ogcard/brew.go`. Putting `color.RGBA` on `Descriptor` would force 272 + `entities` to import `image/color`, and the colors are an OG-card 273 + implementation detail. If a future phase consolidates the Draw 274 + functions further, an `accentByType` map can live in `ogcard` itself. 275 + 276 + ### Task 6: Verify 277 + 278 + **Commands:** 279 + 280 + ```bash 281 + go vet ./... 282 + go build ./... 283 + go test ./... 284 + just run 285 + ``` 286 + 287 + **Manual smoke test:** 288 + 289 + - Load `/feed`: 290 + - Click each entity type's card — share URL should match the 291 + `/{entity}/{rkey}?owner={did}` pattern as before 292 + - Click the share button — title should match (e.g., bean's name) 293 + - Click delete on each entity (without confirming) — URL should be 294 + `/api/{entity}/{rkey}` for non-brew, `/brews/{rkey}` for brew 295 + - Hit each OG card endpoint (e.g., `/api/og/beans/{rkey}`) — labels 296 + in the corner should still read "bean", "roaster", etc. 297 + 298 + **Expected delta:** 299 + 300 + - `feed.templ`: ~120 LOC removed (three switches collapsed into helpers 301 + using descriptor + accessor methods) 302 + - `feed/service.go`: ~70 LOC added (RKey + DisplayTitle methods) 303 + - `ogcard/entities.go`: ~5 LOC changed (label arg) 304 + - Net: ~−45 LOC, plus the wins from killing three more switch sites 305 + and proving the descriptor + container-accessor pattern composes. 306 + 307 + --- 308 + 309 + ## Out of scope for phase 1 310 + 311 + These belong to later phases (see parent spec): 312 + 313 + - Modal shell extraction (phase 5') 314 + - Cache typed fields → map (phase 2, deferred) 315 + - Generic store CRUD (phase 3, deferred) 316 + - View handler unification (phase 4, deferred) 317 + - Routing loop, suggestions config, dirty-tracking TTL (phase 6, deferred) 318 + - Per-entity OG card Draw consolidation (intentionally not done — see 319 + phase 5 / FieldSpec discussion in parent spec)
+423
docs/plans/2026-04-25-entity-descriptor-phase-4.md
··· 1 + # Entity Descriptor — Phase 4: View Handler Unification 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to 4 + > implement this plan task-by-task. 5 + 6 + **Goal:** Collapse the 4 near-identical entity view handlers (Bean, Roaster, 7 + Grinder, Brewer) into a shared `handleEntityView` helper driven by a 8 + per-entity config. Unify 3 OG image handlers and 3 OG metadata helpers as 9 + a bonus. Recipe and brew stay bespoke. 10 + 11 + **Scope note:** The user wants to differentiate view pages visually later. 12 + This refactor only touches the *handler* layer (auth, cache, fallback, social 13 + data, author profile). The templ view pages themselves are untouched — they 14 + retain their per-entity layouts. 15 + 16 + **Parent spec:** `docs/entity-descriptor-refactor.md` 17 + 18 + --- 19 + 20 + ## Major Design Changes 21 + 22 + ### `EntityViewBase` — embedded social fields 23 + 24 + All 4 view props structs repeat 19 identical fields. Extract them into: 25 + 26 + ```go 27 + // EntityViewBase holds the social and auth fields shared by all simple 28 + // entity view pages. Embed this in XxxViewProps to use. 29 + type EntityViewBase struct { 30 + IsOwnProfile bool 31 + IsAuthenticated bool 32 + SubjectURI string 33 + SubjectCID string 34 + IsLiked bool 35 + LikeCount int 36 + CommentCount int 37 + Comments []firehose.IndexedComment 38 + CurrentUserDID string 39 + ShareURL string 40 + IsModerator bool 41 + CanHideRecord bool 42 + CanBlockUser bool 43 + IsRecordHidden bool 44 + AuthorDID string 45 + AuthorHandle string 46 + AuthorDisplayName string 47 + AuthorAvatar string 48 + } 49 + ``` 50 + 51 + After embedding, `props.IsAuthenticated` still works in Go and templ via 52 + promotion — zero changes needed in the templ page bodies. 53 + 54 + Count fields (`BrewCount`, `BeanCount`) stay per-entity; the label differs 55 + ("X brews" vs "X beans") so collapsing them would hurt readability. 56 + 57 + ### `entityViewConfig` — per-entity closures 58 + 59 + A config struct captures per-entity behavior as function fields. Configs are 60 + constructed as methods on `*Handler` so closures can capture `h.witnessCache` 61 + etc. naturally. 62 + 63 + ```go 64 + type entityViewConfig struct { 65 + descriptor *entities.Descriptor 66 + // fromWitness converts a witness map + rkey to the typed model. Closures 67 + // handle ref resolution (bean → roaster) where needed. 68 + fromWitness func(ctx context.Context, m map[string]any, uri, rkey, ownerDID string) (any, error) 69 + // fromPDS converts a PDS record entry to the typed model. 70 + fromPDS func(ctx context.Context, e *atproto.PublicRecordEntry, rkey, ownerDID string) (any, error) 71 + // fromStore fetches from the authenticated user's AtprotoStore. 72 + // Returns (record, subjectURI, subjectCID, error). 73 + fromStore func(ctx context.Context, s *atproto.AtprotoStore, rkey string) (any, string, string, error) 74 + // displayName extracts the page title from the record. 75 + displayName func(record any) string 76 + // ogSubtitle extracts the OG description subtitle from the record. 77 + ogSubtitle func(record any) string 78 + // countLookup returns the entity-specific count (brews, beans, etc.). 79 + // Nil is OK — returns 0 if feedIndex is nil or URI is empty. 80 + countLookup func(ctx context.Context, ownerDID, subjectURI string) int 81 + // render constructs entity-specific props from the shared base and renders the page. 82 + render func(ctx context.Context, w http.ResponseWriter, layoutData *components.LayoutData, record any, base EntityViewBase) error 83 + } 84 + ``` 85 + 86 + ### What stays bespoke 87 + 88 + - `HandleRecipeView` and `HandleBrewView` — richer ref resolution (brew: bean 89 + chain, recipe: brewer obj). Don't force through the config. 90 + - Bean's closures do roaster ref resolution; all others are 2-3 lines. 91 + - `HandleBeanOGImage` and `HandleRecipeOGImage` — both have nested ref 92 + resolution; stay bespoke. 93 + 94 + ### What gets collapsed in the OG layer 95 + 96 + - `populateRoasterOGMetadata`, `populateGrinderOGMetadata`, 97 + `populateBrewerOGMetadata` all call `populateOGFields(layoutData, 98 + entity.Name, noun, ...)`. One helper replaces three. 99 + - `HandleRoasterOGImage`, `HandleGrinderOGImage`, `HandleBrewerOGImage` are 100 + byte-for-byte identical except type names. One `handleSimpleOGImage` helper 101 + + an `ogImageConfig` struct replaces all three. 102 + 103 + --- 104 + 105 + ## Tasks 106 + 107 + ### Task 1: Define `EntityViewBase` in pages package 108 + 109 + **Files:** 110 + - Create: `internal/web/pages/entity_view_base.go` 111 + 112 + Define `EntityViewBase` (the struct above). This is a plain Go file, no templ. 113 + 114 + ### Task 2: Embed `EntityViewBase` in all 4 view props structs 115 + 116 + **Files:** 117 + - Modify: `internal/web/pages/bean_view.templ` 118 + - Modify: `internal/web/pages/roaster_view.templ` 119 + - Modify: `internal/web/pages/grinder_view.templ` 120 + - Modify: `internal/web/pages/brewer_view.templ` 121 + 122 + For each, replace the 19 repeated fields with `EntityViewBase`. Keep the 123 + entity pointer and count field. Example for bean: 124 + 125 + ```go 126 + type BeanViewProps struct { 127 + Bean *models.Bean 128 + BrewCount int 129 + EntityViewBase 130 + } 131 + ``` 132 + 133 + After this change, the templ page body is unchanged — promoted fields are 134 + accessed identically. 135 + 136 + Run `templ generate` for each modified file. Verify `go build ./...` still 137 + passes (promoted field assignments in the existing handlers still work). 138 + 139 + ### Task 3: Add `entityViewConfig`, `handleEntityView`, per-entity configs 140 + 141 + **Files:** 142 + - Modify: `internal/handlers/entity_views.go` 143 + 144 + **Step 1: Add `entityViewConfig` struct** (see design above). 145 + 146 + **Step 2: Add `handleEntityView` method:** 147 + 148 + ```go 149 + func (h *Handler) handleEntityView(w http.ResponseWriter, r *http.Request, cfg entityViewConfig) { 150 + rkey := validateRKey(w, r.PathValue("id")) 151 + if rkey == "" { return } 152 + 153 + owner := r.URL.Query().Get("owner") 154 + didStr, _ := atproto.GetAuthenticatedDID(r.Context()) 155 + isAuthenticated := didStr != "" 156 + 157 + var userProfile *bff.UserProfile 158 + if isAuthenticated { 159 + userProfile = h.getUserProfile(r.Context(), didStr) 160 + } 161 + 162 + var record any 163 + var subjectURI, subjectCID, entityOwnerDID string 164 + isOwnProfile := false 165 + 166 + if owner != "" { 167 + var err error 168 + entityOwnerDID, err = resolveOwnerDID(r.Context(), owner) 169 + if err != nil { 170 + http.Error(w, "User not found", http.StatusNotFound) 171 + return 172 + } 173 + 174 + entityURI := atproto.BuildATURI(entityOwnerDID, cfg.descriptor.NSID, rkey) 175 + if h.witnessCache != nil { 176 + if wr, _ := h.witnessCache.GetWitnessRecord(r.Context(), entityURI); wr != nil { 177 + if m, err := atproto.WitnessRecordToMap(wr); err == nil { 178 + if rec, err := cfg.fromWitness(r.Context(), m, wr.URI, rkey, entityOwnerDID); err == nil { 179 + metrics.WitnessCacheHitsTotal.WithLabelValues(cfg.descriptor.Noun).Inc() 180 + record = rec 181 + subjectURI = wr.URI 182 + subjectCID = wr.CID 183 + isOwnProfile = isAuthenticated && didStr == entityOwnerDID 184 + } 185 + } 186 + } 187 + } 188 + 189 + if record == nil { 190 + metrics.WitnessCacheMissesTotal.WithLabelValues(cfg.descriptor.Noun).Inc() 191 + pub := atproto.NewPublicClient() 192 + entry, err := pub.GetRecord(r.Context(), entityOwnerDID, cfg.descriptor.NSID, rkey) 193 + if err != nil { 194 + http.Error(w, cfg.descriptor.DisplayName+" not found", http.StatusNotFound) 195 + return 196 + } 197 + rec, err := cfg.fromPDS(r.Context(), entry, rkey, entityOwnerDID) 198 + if err != nil { 199 + http.Error(w, "Failed to load "+cfg.descriptor.Noun, http.StatusInternalServerError) 200 + return 201 + } 202 + record = rec 203 + subjectURI = entry.URI 204 + subjectCID = entry.CID 205 + isOwnProfile = isAuthenticated && didStr == entityOwnerDID 206 + } 207 + } else { 208 + store, authenticated := h.getAtprotoStore(r) 209 + if !authenticated { 210 + http.Redirect(w, r, "/login", http.StatusFound) 211 + return 212 + } 213 + atprotoStore, ok := store.(*atproto.AtprotoStore) 214 + if !ok { 215 + http.Error(w, "Internal error", http.StatusInternalServerError) 216 + return 217 + } 218 + rec, uri, cid, err := cfg.fromStore(r.Context(), atprotoStore, rkey) 219 + if err != nil { 220 + http.Error(w, cfg.descriptor.DisplayName+" not found", http.StatusNotFound) 221 + return 222 + } 223 + record, subjectURI, subjectCID = rec, uri, cid 224 + isOwnProfile = true 225 + } 226 + 227 + var shareURL string 228 + if owner != "" { 229 + shareURL = fmt.Sprintf("/%s/%s?owner=%s", cfg.descriptor.URLPath, rkey, owner) 230 + } else if userProfile != nil && userProfile.Handle != "" { 231 + shareURL = fmt.Sprintf("/%s/%s?owner=%s", cfg.descriptor.URLPath, rkey, userProfile.Handle) 232 + } 233 + 234 + ownerHandle := h.resolveOwnerHandle(r.Context(), owner) 235 + layoutData := h.buildLayoutData(r, cfg.displayName(record), isAuthenticated, didStr, userProfile) 236 + populateOGFields(layoutData, cfg.ogSubtitle(record), cfg.descriptor.Noun, ownerHandle, h.publicBaseURL(r), shareURL) 237 + 238 + sd := h.fetchSocialData(r.Context(), subjectURI, didStr, isAuthenticated) 239 + 240 + // Fetch author profile 241 + authorDID := entityOwnerDID 242 + if authorDID == "" { authorDID = didStr } 243 + base := EntityViewBase{ 244 + IsOwnProfile: isOwnProfile, 245 + IsAuthenticated: isAuthenticated, 246 + SubjectURI: subjectURI, 247 + SubjectCID: subjectCID, 248 + IsLiked: sd.IsLiked, 249 + LikeCount: sd.LikeCount, 250 + CommentCount: sd.CommentCount, 251 + Comments: sd.Comments, 252 + CurrentUserDID: didStr, 253 + ShareURL: shareURL, 254 + IsModerator: sd.IsModerator, 255 + CanHideRecord: sd.CanHideRecord, 256 + CanBlockUser: sd.CanBlockUser, 257 + IsRecordHidden: sd.IsRecordHidden, 258 + AuthorDID: entityOwnerDID, 259 + } 260 + if ap := h.getUserProfile(r.Context(), authorDID); ap != nil { 261 + base.AuthorHandle = ap.Handle 262 + base.AuthorDisplayName = ap.DisplayName 263 + base.AuthorAvatar = ap.Avatar 264 + } 265 + 266 + if err := cfg.render(r.Context(), w, layoutData, record, base); err != nil { 267 + http.Error(w, "Failed to render page", http.StatusInternalServerError) 268 + log.Error().Err(err).Msgf("Failed to render %s view", cfg.descriptor.Noun) 269 + } 270 + } 271 + ``` 272 + 273 + **Step 3: Define per-entity config methods** 274 + 275 + ```go 276 + func (h *Handler) roasterViewConfig() entityViewConfig { ... } 277 + func (h *Handler) grinderViewConfig() entityViewConfig { ... } 278 + func (h *Handler) brewerViewConfig() entityViewConfig { ... } 279 + func (h *Handler) beanViewConfig() entityViewConfig { ... } // includes roaster ref resolution 280 + ``` 281 + 282 + Each config's `render` closure: looks up its count (via a closure capturing 283 + `h.feedIndex`), constructs the typed props struct embedding `EntityViewBase`, 284 + and calls `pages.XView(layoutData, props).Render(ctx, w)`. 285 + 286 + ### Task 4: Replace 4 view handlers 287 + 288 + Each becomes a one-liner: 289 + 290 + ```go 291 + func (h *Handler) HandleBeanView(w http.ResponseWriter, r *http.Request) { 292 + h.handleEntityView(w, r, h.beanViewConfig()) 293 + } 294 + ``` 295 + 296 + ### Task 5: Collapse OG metadata and OG image handlers 297 + 298 + **Collapse 3 OG metadata helpers:** 299 + 300 + ```go 301 + // Replace populateRoasterOGMetadata, populateGrinderOGMetadata, populateBrewerOGMetadata 302 + func (h *Handler) populateSimpleEntityOGMetadata( 303 + d *entities.Descriptor, 304 + layoutData *components.LayoutData, 305 + name, owner, baseURL, shareURL string, 306 + ) { 307 + populateOGFields(layoutData, name, d.Noun, owner, baseURL, shareURL) 308 + } 309 + ``` 310 + 311 + Call from beanViewConfig's ogSubtitle and from roaster/grinder/brewer render 312 + closures. Or just call `populateOGFields` directly in `handleEntityView` via 313 + the `ogSubtitle` field — which the plan already does. 314 + 315 + Actually: with `handleEntityView` calling `populateOGFields(layoutData, 316 + cfg.ogSubtitle(record), ...)` directly, the `populateXOGMetadata` functions 317 + are no longer called at all. Delete them. 318 + 319 + Bean's custom subtitle ("Name from Roaster") lives in its `ogSubtitle` 320 + closure. 321 + 322 + **Unify 3 OG image handlers:** 323 + 324 + ```go 325 + type ogImageConfig struct { 326 + nsid string 327 + metricLabel string 328 + convert func(m map[string]any, uri string) (any, error) 329 + drawCard func(record any) (*ogcard.Card, error) 330 + } 331 + 332 + func (h *Handler) handleSimpleOGImage(w http.ResponseWriter, r *http.Request, cfg ogImageConfig) { 333 + rkey := validateRKey(w, r.PathValue("id")) 334 + if rkey == "" { return } 335 + owner := r.URL.Query().Get("owner") 336 + if owner == "" { 337 + http.Error(w, "owner parameter required", http.StatusBadRequest) 338 + return 339 + } 340 + ownerDID, err := resolveOwnerDID(r.Context(), owner) 341 + if err != nil { 342 + http.Error(w, "User not found", http.StatusNotFound) 343 + return 344 + } 345 + var record any 346 + entityURI := atproto.BuildATURI(ownerDID, cfg.nsid, rkey) 347 + if h.witnessCache != nil { 348 + if wr, _ := h.witnessCache.GetWitnessRecord(r.Context(), entityURI); wr != nil { 349 + if m, err := atproto.WitnessRecordToMap(wr); err == nil { 350 + if rec, err := cfg.convert(m, wr.URI); err == nil { 351 + metrics.WitnessCacheHitsTotal.WithLabelValues(cfg.metricLabel).Inc() 352 + record = rec 353 + setRKey(record, rkey) 354 + } 355 + } 356 + } 357 + } 358 + if record == nil { 359 + metrics.WitnessCacheMissesTotal.WithLabelValues(cfg.metricLabel).Inc() 360 + pub := atproto.NewPublicClient() 361 + pr, err := pub.GetRecord(r.Context(), ownerDID, cfg.nsid, rkey) 362 + if err != nil { 363 + http.Error(w, "Not found", http.StatusNotFound) 364 + return 365 + } 366 + rec, err := cfg.convert(pr.Value, pr.URI) 367 + if err != nil { 368 + http.Error(w, "Failed to load record", http.StatusInternalServerError) 369 + return 370 + } 371 + record = rec 372 + setRKey(record, rkey) 373 + } 374 + card, err := cfg.drawCard(record) 375 + if err != nil { 376 + log.Error().Err(err).Msgf("Failed to generate %s OG image", cfg.metricLabel) 377 + http.Error(w, "Failed to generate image", http.StatusInternalServerError) 378 + return 379 + } 380 + writeOGImage(w, card) 381 + } 382 + ``` 383 + 384 + Note: `setRKey` is a small helper that type-switches on record and sets `.RKey`. 385 + Or: change `convert` to also accept and set rkey. Simpler. 386 + 387 + The 3 handlers become: 388 + 389 + ```go 390 + func (h *Handler) HandleRoasterOGImage(w http.ResponseWriter, r *http.Request) { 391 + h.handleSimpleOGImage(w, r, ogImageConfig{ 392 + nsid: atproto.NSIDRoaster, metricLabel: "roaster_og", 393 + convert: func(m map[string]any, uri string) (any, error) { return atproto.RecordToRoaster(m, uri) }, 394 + drawCard: func(rec any) (*ogcard.Card, error) { return ogcard.DrawRoasterCard(rec.(*models.Roaster)) }, 395 + }) 396 + } 397 + ``` 398 + 399 + ### Task 6: Verify 400 + 401 + ```bash 402 + go vet ./... 403 + go build ./... 404 + go test ./... 405 + just run 406 + ``` 407 + 408 + Manual smoke test: load `/beans/{rkey}?owner=X`, `/roasters/...`, `/grinders/...`, 409 + `/brewers/...` — all should render identically to before. Check bean view 410 + shows roaster name. Check OG image endpoints. 411 + 412 + --- 413 + 414 + ## Expected delta 415 + 416 + - 4 view handlers (~180 LOC each): collapsed to 4 one-liners + 4 config methods 417 + (~25 LOC each) + 1 `handleEntityView` (~80 LOC) ≈ −430 LOC 418 + - `EntityViewBase` embedding: 19 fields × 4 structs → 19-field base struct 419 + ≈ −55 LOC 420 + - 3 OG metadata helpers → 0 (folded into config's `ogSubtitle`) ≈ −25 LOC 421 + - 3 OG image handlers (~50 LOC each) → 3 one-liners + 1 helper (~50 LOC) 422 + ≈ −100 LOC 423 + - **Total: ~−610 LOC** (largest phase by LOC reduction)
+267
docs/plans/2026-04-25-entity-descriptor-phase-5.md
··· 1 + # Entity Descriptor — Phase 5': Modal Shell Extraction 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to 4 + > implement this plan task-by-task. 5 + 6 + **Goal:** Extract the dialog/header/error/footer scaffolding shared across 7 + the five entity modals into a single `ModalShell` component. Each modal 8 + keeps its own templ body for the field markup. No DSL, no FieldSpec list — 9 + just a shared shell. 10 + 11 + **Parent spec:** `docs/entity-descriptor-refactor.md` 12 + **Previous phase:** `docs/plans/2026-04-25-entity-descriptor-phase-1.md` 13 + 14 + **Tech Stack:** Go 1.26, Templ. 15 + 16 + --- 17 + 18 + ## Major Design Changes 19 + 20 + ### What's actually duplicated across modals 21 + 22 + After auditing all five modals, the **shell** is identical (modulo entity 23 + name and URL): 24 + 25 + 1. `<dialog id="entity-modal" class="modal-dialog">` open 26 + 2. `<div class="modal-content">` wrapper 27 + 3. `<h3 class="modal-title">` with conditional "Edit X" / "Add X" 28 + 4. `<form>` with: 29 + - Conditional `hx-put` (edit) / `hx-post` (create) URL 30 + - Standard `hx-trigger`, `hx-swap`, `class` 31 + - `x-data="{ serverError: '' }"` (recipe extends this with pour state) 32 + - The big `hx-on::after-request` handler (identical, except recipe is 33 + **missing the 401 session-expired branch** — a latent bug we'll fix 34 + while we're here) 35 + 5. `<div x-show="serverError" ...>` server error display 36 + 6. **(per-entity field body — this is what stays in each modal's templ)** 37 + 7. Footer `<div class="flex gap-2 pt-2">` with Save/Cancel buttons 38 + 8. Close `</form></div></dialog>` 39 + 40 + ### What stays per-entity 41 + 42 + - The field body — fieldsets, inputs, dropdowns, the bean modal's roaster 43 + picker, the bean rating slider, the brewer modal's conditional sections, 44 + the recipe modal's pour repeater 45 + - Per-entity Alpine state — recipe needs `pours`, `addPour()`, 46 + `removePour()` plumbed through `ExtraXData` 47 + - The bean modal's "bag is closed" checkbox (only shown on edit) 48 + 49 + ### The component 50 + 51 + ```go 52 + type ModalShellProps struct { 53 + Type lexicons.RecordType // looked up via entities.Get() 54 + RKey string // "" → create (POST), non-empty → edit (PUT) 55 + ExtraXData string // optional Alpine x-data; defaults to `{ serverError: '' }` 56 + } 57 + ``` 58 + 59 + The shell looks up the descriptor, derives `DisplayName` and `URLPath`, 60 + builds the action URL (`/api/{URLPath}` for create, `/api/{URLPath}/{rkey}` 61 + for edit), and renders the wrapper. The body comes in as templ children. 62 + 63 + ### Why this beats the rejected FieldSpec design 64 + 65 + - **No DSL**: each modal's body is plain templ markup you can read 66 + top-to-bottom 67 + - **Bespoke widgets stay free**: the roaster picker, rating slider, 68 + conditional brewer fields, recipe pour repeater all stay where they are 69 + - **Cross-cutting changes are still cheap**: changing the error display, 70 + rewriting the after-request handler, retitling buttons — all single-edit 71 + - **LOC saved**: ~150 (5 modals × ~30 lines of shell each) 72 + - **Risk**: low. If a future modal needs a different shell, it can opt out 73 + by not using `ModalShell`. 74 + 75 + ### What is NOT changing 76 + 77 + - Field bodies (the entire point — they stay readable) 78 + - `getStringValue` / `recordTypeOf` (already migrated in phase 0) 79 + - Modal route registration (phase 6 work) 80 + - Modal handler functions 81 + - The `ConfirmDeleteModal` (different shape — confirmation, not edit form) 82 + 83 + --- 84 + 85 + ## Phase 5': Tasks 86 + 87 + ### Task 1: Create `ModalShell` component 88 + 89 + **Files:** 90 + 91 + - Create: `internal/web/components/modal_shell.templ` 92 + 93 + **Step 1: Define the shell** 94 + 95 + ```templ 96 + package components 97 + 98 + import ( 99 + "tangled.org/arabica.social/arabica/internal/entities" 100 + "tangled.org/arabica.social/arabica/internal/lexicons" 101 + ) 102 + 103 + // ModalShellProps configures the shared dialog wrapper used by entity 104 + // create/edit modals. Body content is passed as templ children. 105 + type ModalShellProps struct { 106 + Type lexicons.RecordType 107 + RKey string // "" = create, non-empty = edit 108 + ExtraXData string // optional Alpine x-data (defaults to `{ serverError: '' }`) 109 + } 110 + 111 + // modalAfterRequest is the htmx after-request handler shared across 112 + // entity modals. Closes the dialog on success, surfaces session-expired 113 + // on 401, and otherwise displays a generic error in the form. 114 + const modalAfterRequest = `if(event.detail.successful) { this.closest('dialog').close(); htmx.trigger('body', 'refreshManage'); } else if(event.detail.xhr && event.detail.xhr.status === 401) { this.closest('dialog').close(); window.__showSessionExpiredModal(); } else { this._x_dataStack[0].serverError = event.detail.xhr ? 'Something went wrong. Please try again.' : 'Connection error. Check your network.'; }` 115 + 116 + func modalShellXData(extra string) string { 117 + if extra != "" { 118 + return extra 119 + } 120 + return "{ serverError: '' }" 121 + } 122 + 123 + func modalActionURL(d *entities.Descriptor, rkey string) string { 124 + if rkey != "" { 125 + return "/api/" + d.URLPath + "/" + rkey 126 + } 127 + return "/api/" + d.URLPath 128 + } 129 + 130 + templ ModalShell(props ModalShellProps) { 131 + if d := entities.Get(props.Type); d != nil { 132 + <dialog id="entity-modal" class="modal-dialog"> 133 + <div class="modal-content"> 134 + <h3 class="modal-title"> 135 + if props.RKey != "" { 136 + Edit { d.DisplayName } 137 + } else { 138 + Add { d.DisplayName } 139 + } 140 + </h3> 141 + <form 142 + if props.RKey != "" { 143 + hx-put={ modalActionURL(d, props.RKey) } 144 + } else { 145 + hx-post={ modalActionURL(d, "") } 146 + } 147 + hx-trigger="submit" 148 + hx-swap="none" 149 + x-data={ modalShellXData(props.ExtraXData) } 150 + hx-on::after-request={ modalAfterRequest } 151 + class="space-y-5" 152 + > 153 + <div x-show="serverError" x-cloak class="bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-800" x-text="serverError"></div> 154 + { children... } 155 + <div class="flex gap-2 pt-2"> 156 + <button type="submit" class="flex-1 btn-primary">Save</button> 157 + <button 158 + type="button" 159 + @click="$el.closest('dialog').close()" 160 + class="flex-1 btn-secondary" 161 + > 162 + Cancel 163 + </button> 164 + </div> 165 + </form> 166 + </div> 167 + </dialog> 168 + } 169 + } 170 + ``` 171 + 172 + **Step 2: Generate** 173 + 174 + ```bash 175 + templ generate -f internal/web/components/modal_shell.templ 176 + ``` 177 + 178 + ### Task 2: Migrate `GrinderDialogModal` (proof of concept — simplest) 179 + 180 + **Files:** 181 + 182 + - Modify: `internal/web/components/dialog_modals.templ` 183 + 184 + **Step 1: Replace shell, keep body** 185 + 186 + Replace the `templ GrinderDialogModal(...)` definition. The body 187 + (everything between the form open tag and the Save button) stays 188 + verbatim, wrapped in a `@ModalShell(...) { ... }` call. 189 + 190 + **Step 2: Regenerate and verify** 191 + 192 + ```bash 193 + templ generate -f internal/web/components/dialog_modals.templ 194 + go build ./... 195 + ``` 196 + 197 + Open the grinder modal in the browser. Confirm: Edit/Add label, form 198 + submission, error display, footer buttons all work identically. 199 + 200 + ### Task 3: Migrate `RoasterDialogModal` 201 + 202 + Same shape as grinder. Single edit + regenerate. 203 + 204 + ### Task 4: Migrate `BrewerDialogModal` 205 + 206 + Same shape. The conditional espresso/pourover sections inside the body 207 + stay as-is — they're field-body concerns, not shell concerns. 208 + 209 + ### Task 5: Migrate `BeanDialogModal` 210 + 211 + The roaster picker (~80 lines of Alpine) and rating slider (Alpine state) 212 + stay inside the body verbatim. The bean's "bag is closed" checkbox also 213 + stays in the body. Only the shell scaffolding is extracted. 214 + 215 + ### Task 6: Migrate `RecipeDialogModal` 216 + 217 + Recipe is the special case: 218 + 219 + - **Fix the latent bug**: recipe's current `hx-on::after-request` is 220 + missing the 401 session-expired branch. Migrating to `ModalShell` 221 + fixes it automatically (the shell uses the canonical handler). 222 + - **Plumb pour state through `ExtraXData`**: pass 223 + `fmt.Sprintf("{ serverError: '', pours: %s, addPour() { this.pours.push({water: '', time: ''}); }, removePour(i) { this.pours.splice(i, 1); } }", recipePourJSON(recipe))` 224 + as `ExtraXData`. The shell uses it instead of the default. 225 + 226 + ### Task 7: Verify 227 + 228 + **Commands:** 229 + 230 + ```bash 231 + go vet ./... 232 + go build ./... 233 + go test ./... 234 + just run 235 + ``` 236 + 237 + **Manual smoke test (per modal):** 238 + 239 + For each of bean, grinder, brewer, roaster, recipe: 240 + 241 + 1. Open the create modal — title reads "Add X", submitting POSTs to `/api/{path}` 242 + 2. Open the edit modal for an existing record — title reads "Edit X", 243 + submitting PUTs to `/api/{path}/{rkey}` 244 + 3. Trigger a server error (e.g. invalid input) — error display appears 245 + 4. Cancel button closes the dialog 246 + 5. Successful submit closes the dialog and triggers `refreshManage` 247 + 248 + Bean-specific: roaster picker still works, rating slider still works. 249 + Brewer-specific: espresso/pourover conditional fields still toggle. 250 + Recipe-specific: pour add/remove still works; 401 now closes the dialog 251 + and surfaces the session-expired modal (previously didn't). 252 + 253 + **Expected delta:** 254 + 255 + - ~150 LOC removed from `dialog_modals.templ` (5 × ~30 lines of shell) 256 + - ~80 LOC added in `modal_shell.templ` 257 + - Net: ~−70 LOC, plus a latent recipe bug fixed and zero DSL risk. 258 + 259 + --- 260 + 261 + ## Out of scope for phase 5' 262 + 263 + - Field-body abstraction or FieldSpec DSL (rejected — see parent spec) 264 + - Modal route loop (phase 6) 265 + - Suggestions config from descriptor (phase 6) 266 + - Migrating `ConfirmDeleteModal` (different shape; not part of the 267 + create/edit family)
+145
docs/plans/2026-04-25-entity-descriptor-phase-6.md
··· 1 + # Entity Descriptor — Phase 6: Cleanup Pass 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to 4 + > implement this plan task-by-task. 5 + 6 + **Goal:** Three targeted cleanups that use the descriptor or remove manually 7 + maintained per-entity maps. No behavior change except PublicClient latency. 8 + 9 + **Parent spec:** `docs/entity-descriptor-refactor.md` 10 + **Previous phase:** `docs/plans/2026-04-25-entity-descriptor-phase-5.md` 11 + 12 + --- 13 + 14 + ## Scope (revised from original spec) 15 + 16 + | Item | Decision | Why | 17 + |---|---|---| 18 + | `entityTypeToNSID` map in suggestions handler | ✅ do it | Descriptor owns this data; the map must be updated when cafe/drink land | 19 + | PublicClient resolver cache | ✅ do it | Small, low-risk, reduces network calls in firehose hot path | 20 + | Modal route loop | ✅ do it (minor) | Removes 5 repetitive `HandleFunc` pairs | 21 + | Dirty-tracking → TTL | ❌ defer | Cache correctness concern; belongs to a future phase 2 | 22 + | Suggestions `entityConfigs` from descriptor | ❌ skip | Dedup functions and field lists are suggestions-specific, not descriptor data | 23 + 24 + --- 25 + 26 + ## Task 1: Replace `entityTypeToNSID` with descriptor lookup 27 + 28 + **Files:** 29 + 30 + - Modify: `internal/handlers/suggestions.go` 31 + 32 + **Current:** 33 + 34 + ```go 35 + var entityTypeToNSID = map[string]string{ 36 + "roasters": atproto.NSIDRoaster, 37 + "grinders": atproto.NSIDGrinder, 38 + "brewers": atproto.NSIDBrewer, 39 + "beans": atproto.NSIDBean, 40 + "recipes": atproto.NSIDRecipe, 41 + } 42 + ``` 43 + 44 + **After:** Build the map from the descriptor registry so new entities 45 + (cafe, drink) appear automatically when registered. 46 + 47 + ```go 48 + var entityTypeToNSID = func() map[string]string { 49 + m := make(map[string]string) 50 + for _, d := range entities.All() { 51 + if d.NSID != "" { 52 + m[d.URLPath] = d.NSID 53 + } 54 + } 55 + return m 56 + }() 57 + ``` 58 + 59 + Remove the `atproto` import from this file if it becomes unused. 60 + 61 + ## Task 2: Compact modal route registration 62 + 63 + **Files:** 64 + 65 + - Modify: `internal/routing/routing.go` 66 + 67 + **Current:** 10 individual `HandleFunc` calls for 5 entity modals (new + edit each). 68 + 69 + **After:** Loop over a compact slice of `{noun, new handler, edit handler}`: 70 + 71 + ```go 72 + for _, m := range []struct { 73 + noun string 74 + new http.HandlerFunc 75 + edit http.HandlerFunc 76 + }{ 77 + {"bean", h.HandleBeanModalNew, h.HandleBeanModalEdit}, 78 + {"grinder", h.HandleGrinderModalNew, h.HandleGrinderModalEdit}, 79 + {"brewer", h.HandleBrewerModalNew, h.HandleBrewerModalEdit}, 80 + {"roaster", h.HandleRoasterModalNew, h.HandleRoasterModalEdit}, 81 + {"recipe", h.HandleRecipeModalNew, h.HandleRecipeModalEdit}, 82 + } { 83 + mux.HandleFunc("GET /api/modals/"+m.noun+"/new", m.new) 84 + mux.HandleFunc("GET /api/modals/"+m.noun+"/{id}", m.edit) 85 + } 86 + ``` 87 + 88 + Note: handler references can't come from the descriptor (they're business 89 + logic, not metadata), so this still has 5 data entries. The win is that 90 + the pattern is explicit and each entity is one line. 91 + 92 + ## Task 3: Cache PublicClient resolver results 93 + 94 + **Files:** 95 + 96 + - Modify: `internal/atproto/public_client.go` 97 + 98 + **Current:** `GetPDSEndpoint` and `ResolveHandle` make raw network calls 99 + with no caching. In the firehose processing path these can be called 100 + repeatedly for the same DID or handle. 101 + 102 + **After:** Add a simple TTL cache (1 hour) using `sync.RWMutex` + a map of 103 + `cachedValue{value string, expiry time.Time}`. Cache miss falls through to 104 + the existing inner client call. No change to the public API. 105 + 106 + ```go 107 + type cachedValue struct { 108 + value string 109 + expiry time.Time 110 + } 111 + 112 + type PublicClient struct { 113 + inner *atp.PublicClient 114 + pdsMu sync.RWMutex 115 + pdsCache map[string]cachedValue // DID → PDS URL 116 + handleMu sync.RWMutex 117 + handleCache map[string]cachedValue // handle → DID 118 + } 119 + 120 + const resolverCacheTTL = time.Hour 121 + ``` 122 + 123 + Keep it simple: no explicit invalidation, just TTL expiry. A 1-hour TTL 124 + means stale PDS endpoints are served for at most an hour, which is 125 + acceptable (PDS migrations are rare). 126 + 127 + ## Task 4: Verify 128 + 129 + ```bash 130 + go vet ./... 131 + go build ./... 132 + go test ./... 133 + ``` 134 + 135 + Check: suggestions endpoint still returns results for bean/grinder/brewer/ 136 + roaster/recipe. Modal routes still open and submit correctly. 137 + 138 + --- 139 + 140 + ## Out of scope 141 + 142 + - Dirty-tracking → TTL (cache correctness; deferred to phase 2) 143 + - Suggestions `entityConfigs` from descriptor (field lists and dedup 144 + functions are suggestions-specific; descriptor is the wrong home) 145 + - Any new entity registrations (cafe/drink) — those belong to feature work
+51 -3
internal/atproto/public_client.go
··· 3 3 import ( 4 4 "context" 5 5 "net/http" 6 + "sync" 6 7 "time" 7 8 8 9 "tangled.org/pdewey.com/atp" ··· 14 15 // so existing callers continue to work without changes. 15 16 type Profile = atp.PublicProfile 16 17 18 + const resolverCacheTTL = time.Hour 19 + 20 + type cachedValue struct { 21 + value string 22 + expiry time.Time 23 + } 24 + 17 25 // PublicClient wraps atp.PublicClient and exposes the same method signatures 18 26 // that arabica callers already use (GetRecord, ListRecords, etc.). 19 27 type PublicClient struct { 20 28 inner *atp.PublicClient 29 + 30 + pdsMu sync.RWMutex 31 + pdsCache map[string]cachedValue // DID → PDS URL 32 + 33 + handleMu sync.RWMutex 34 + handleCache map[string]cachedValue // handle → DID 21 35 } 22 36 23 37 // NewPublicClient creates a PublicClient with OTel-instrumented HTTP transport. ··· 26 40 Timeout: 30 * time.Second, 27 41 Transport: &userAgentTransport{base: otelhttp.NewTransport(http.DefaultTransport)}, 28 42 } 29 - return &PublicClient{inner: atp.NewPublicClientWithHTTP(hc)} 43 + return &PublicClient{ 44 + inner: atp.NewPublicClientWithHTTP(hc), 45 + pdsCache: make(map[string]cachedValue), 46 + handleCache: make(map[string]cachedValue), 47 + } 30 48 } 31 49 32 50 // GetPDSEndpoint resolves a DID to the user's PDS base URL. 33 51 func (c *PublicClient) GetPDSEndpoint(ctx context.Context, did string) (string, error) { 34 - return c.inner.GetPDSEndpoint(ctx, did) 52 + c.pdsMu.RLock() 53 + if v, ok := c.pdsCache[did]; ok && time.Now().Before(v.expiry) { 54 + c.pdsMu.RUnlock() 55 + return v.value, nil 56 + } 57 + c.pdsMu.RUnlock() 58 + 59 + url, err := c.inner.GetPDSEndpoint(ctx, did) 60 + if err != nil { 61 + return "", err 62 + } 63 + 64 + c.pdsMu.Lock() 65 + c.pdsCache[did] = cachedValue{value: url, expiry: time.Now().Add(resolverCacheTTL)} 66 + c.pdsMu.Unlock() 67 + return url, nil 35 68 } 36 69 37 70 // GetProfile fetches a user's public profile by DID or handle. ··· 41 74 42 75 // ResolveHandle resolves an AT Protocol handle to a DID. 43 76 func (c *PublicClient) ResolveHandle(ctx context.Context, handle string) (string, error) { 44 - return c.inner.ResolveHandle(ctx, handle) 77 + c.handleMu.RLock() 78 + if v, ok := c.handleCache[handle]; ok && time.Now().Before(v.expiry) { 79 + c.handleMu.RUnlock() 80 + return v.value, nil 81 + } 82 + c.handleMu.RUnlock() 83 + 84 + did, err := c.inner.ResolveHandle(ctx, handle) 85 + if err != nil { 86 + return "", err 87 + } 88 + 89 + c.handleMu.Lock() 90 + c.handleCache[handle] = cachedValue{value: did, expiry: time.Now().Add(resolverCacheTTL)} 91 + c.handleMu.Unlock() 92 + return did, nil 45 93 } 46 94 47 95 // PublicListRecordsOutput represents the response from public listRecords API.
+49
internal/entities/entities.go
··· 1 + // Package entities provides a registry of descriptors for each Arabica record 2 + // type. A descriptor captures the per-entity data that callers in feed, templ, 3 + // handlers, and ogcard dispatch on, replacing scattered switch statements with 4 + // a single lookup. 5 + package entities 6 + 7 + import ( 8 + "fmt" 9 + "sort" 10 + 11 + "tangled.org/arabica.social/arabica/internal/lexicons" 12 + ) 13 + 14 + // Descriptor describes one Arabica record type. 15 + type Descriptor struct { 16 + Type lexicons.RecordType 17 + NSID string 18 + DisplayName string // "Bean" 19 + Noun string // "bean" — appears in copy: "added a new bean" 20 + URLPath string // "beans" — share URLs and routes 21 + 22 + // GetField extracts one named string field from a typed model pointer for 23 + // form prefill. Returns ("", false) if entity is nil or field is unknown. 24 + GetField func(entity any, field string) (string, bool) 25 + } 26 + 27 + var registry = map[lexicons.RecordType]*Descriptor{} 28 + 29 + // Register adds a descriptor. Called once per entity at package init. 30 + // Panics on duplicate registration to catch wiring bugs at startup. 31 + func Register(d *Descriptor) { 32 + if _, ok := registry[d.Type]; ok { 33 + panic(fmt.Sprintf("entities: duplicate descriptor for %s", d.Type)) 34 + } 35 + registry[d.Type] = d 36 + } 37 + 38 + // Get returns the descriptor for a record type, or nil if unregistered. 39 + func Get(rt lexicons.RecordType) *Descriptor { return registry[rt] } 40 + 41 + // All returns descriptors in stable order (by RecordType). Use for route loops. 42 + func All() []*Descriptor { 43 + out := make([]*Descriptor, 0, len(registry)) 44 + for _, d := range registry { 45 + out = append(out, d) 46 + } 47 + sort.Slice(out, func(i, j int) bool { return out[i].Type < out[j].Type }) 48 + return out 49 + }
+68
internal/entities/entities_test.go
··· 1 + package entities 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/stretchr/testify/assert" 7 + "tangled.org/arabica.social/arabica/internal/lexicons" 8 + ) 9 + 10 + func TestGetKnownTypes(t *testing.T) { 11 + for _, rt := range []lexicons.RecordType{ 12 + lexicons.RecordTypeBean, 13 + lexicons.RecordTypeBrew, 14 + lexicons.RecordTypeBrewer, 15 + lexicons.RecordTypeGrinder, 16 + lexicons.RecordTypeRecipe, 17 + lexicons.RecordTypeRoaster, 18 + } { 19 + d := Get(rt) 20 + assert.NotNil(t, d, "expected descriptor for %s", rt) 21 + assert.Equal(t, rt, d.Type) 22 + assert.NotEmpty(t, d.NSID) 23 + assert.NotEmpty(t, d.DisplayName) 24 + assert.NotEmpty(t, d.Noun) 25 + assert.NotEmpty(t, d.URLPath) 26 + } 27 + } 28 + 29 + func TestGetUnknownType(t *testing.T) { 30 + assert.Nil(t, Get("unknown-type")) 31 + assert.Nil(t, Get("")) 32 + } 33 + 34 + func TestAllReturnsSortedDescriptors(t *testing.T) { 35 + all := All() 36 + assert.NotEmpty(t, all) 37 + for i := 1; i < len(all); i++ { 38 + assert.Less(t, string(all[i-1].Type), string(all[i].Type)) 39 + } 40 + } 41 + 42 + func TestAllContainsAllRegisteredTypes(t *testing.T) { 43 + all := All() 44 + types := make(map[lexicons.RecordType]bool, len(all)) 45 + for _, d := range all { 46 + types[d.Type] = true 47 + } 48 + for _, rt := range []lexicons.RecordType{ 49 + lexicons.RecordTypeBean, 50 + lexicons.RecordTypeBrew, 51 + lexicons.RecordTypeBrewer, 52 + lexicons.RecordTypeGrinder, 53 + lexicons.RecordTypeRecipe, 54 + lexicons.RecordTypeRoaster, 55 + } { 56 + assert.True(t, types[rt], "expected %s in All()", rt) 57 + } 58 + } 59 + 60 + func TestRegisterDuplicatePanics(t *testing.T) { 61 + testType := lexicons.RecordType("_test_duplicate_sentinel") 62 + Register(&Descriptor{Type: testType, NSID: "test.nsid"}) 63 + defer delete(registry, testType) 64 + 65 + assert.Panics(t, func() { 66 + Register(&Descriptor{Type: testType, NSID: "test.nsid"}) 67 + }) 68 + }
+99
internal/entities/fields.go
··· 1 + package entities 2 + 3 + import ( 4 + "fmt" 5 + 6 + "tangled.org/arabica.social/arabica/internal/models" 7 + ) 8 + 9 + func beanField(e any, field string) (string, bool) { 10 + b, ok := e.(*models.Bean) 11 + if !ok || b == nil { 12 + return "", false 13 + } 14 + switch field { 15 + case "name": 16 + return b.Name, true 17 + case "origin": 18 + return b.Origin, true 19 + case "variety": 20 + return b.Variety, true 21 + case "process": 22 + return b.Process, true 23 + case "description": 24 + return b.Description, true 25 + } 26 + return "", false 27 + } 28 + 29 + func roasterField(e any, field string) (string, bool) { 30 + r, ok := e.(*models.Roaster) 31 + if !ok || r == nil { 32 + return "", false 33 + } 34 + switch field { 35 + case "name": 36 + return r.Name, true 37 + case "location": 38 + return r.Location, true 39 + case "website": 40 + return r.Website, true 41 + } 42 + return "", false 43 + } 44 + 45 + func grinderField(e any, field string) (string, bool) { 46 + g, ok := e.(*models.Grinder) 47 + if !ok || g == nil { 48 + return "", false 49 + } 50 + switch field { 51 + case "name": 52 + return g.Name, true 53 + case "notes": 54 + return g.Notes, true 55 + } 56 + return "", false 57 + } 58 + 59 + func brewerField(e any, field string) (string, bool) { 60 + b, ok := e.(*models.Brewer) 61 + if !ok || b == nil { 62 + return "", false 63 + } 64 + switch field { 65 + case "name": 66 + return b.Name, true 67 + case "brewer_type": 68 + return b.BrewerType, true 69 + case "description": 70 + return b.Description, true 71 + } 72 + return "", false 73 + } 74 + 75 + func recipeField(e any, field string) (string, bool) { 76 + r, ok := e.(*models.Recipe) 77 + if !ok || r == nil { 78 + return "", false 79 + } 80 + switch field { 81 + case "name": 82 + return r.Name, true 83 + case "brewer_type": 84 + return r.BrewerType, true 85 + case "notes": 86 + return r.Notes, true 87 + case "coffee_amount": 88 + if r.CoffeeAmount > 0 { 89 + return fmt.Sprintf("%.1f", r.CoffeeAmount), true 90 + } 91 + return "", false 92 + case "water_amount": 93 + if r.WaterAmount > 0 { 94 + return fmt.Sprintf("%.1f", r.WaterAmount), true 95 + } 96 + return "", false 97 + } 98 + return "", false 99 + }
+40
internal/entities/register.go
··· 1 + package entities 2 + 3 + import ( 4 + "tangled.org/arabica.social/arabica/internal/atproto" 5 + "tangled.org/arabica.social/arabica/internal/lexicons" 6 + ) 7 + 8 + func init() { 9 + Register(&Descriptor{ 10 + Type: lexicons.RecordTypeBean, NSID: atproto.NSIDBean, 11 + DisplayName: "Bean", Noun: "bean", URLPath: "beans", 12 + GetField: beanField, 13 + }) 14 + Register(&Descriptor{ 15 + Type: lexicons.RecordTypeRoaster, NSID: atproto.NSIDRoaster, 16 + DisplayName: "Roaster", Noun: "roaster", URLPath: "roasters", 17 + GetField: roasterField, 18 + }) 19 + Register(&Descriptor{ 20 + Type: lexicons.RecordTypeGrinder, NSID: atproto.NSIDGrinder, 21 + DisplayName: "Grinder", Noun: "grinder", URLPath: "grinders", 22 + GetField: grinderField, 23 + }) 24 + Register(&Descriptor{ 25 + Type: lexicons.RecordTypeBrewer, NSID: atproto.NSIDBrewer, 26 + DisplayName: "Brewer", Noun: "brewer", URLPath: "brewers", 27 + GetField: brewerField, 28 + }) 29 + Register(&Descriptor{ 30 + Type: lexicons.RecordTypeRecipe, NSID: atproto.NSIDRecipe, 31 + DisplayName: "Recipe", Noun: "recipe", URLPath: "recipes", 32 + GetField: recipeField, 33 + }) 34 + Register(&Descriptor{ 35 + Type: lexicons.RecordTypeBrew, NSID: atproto.NSIDBrew, 36 + DisplayName: "Brew", Noun: "brew", URLPath: "brews", 37 + GetField: nil, // brew has no edit modal that needs prefill 38 + }) 39 + // Like is intentionally omitted — has no entity page or modal. 40 + }
+109
internal/feed/service.go
··· 69 69 IsOwner bool // Whether the current viewer owns this record 70 70 } 71 71 72 + // Record returns the typed record pointer matching f.RecordType, or nil if 73 + // none is set. Lets callers dispatch on RecordType without a type switch. 74 + func (f *FeedItem) Record() any { 75 + switch f.RecordType { 76 + case lexicons.RecordTypeBean: 77 + if f.Bean != nil { 78 + return f.Bean 79 + } 80 + case lexicons.RecordTypeRoaster: 81 + if f.Roaster != nil { 82 + return f.Roaster 83 + } 84 + case lexicons.RecordTypeGrinder: 85 + if f.Grinder != nil { 86 + return f.Grinder 87 + } 88 + case lexicons.RecordTypeBrewer: 89 + if f.Brewer != nil { 90 + return f.Brewer 91 + } 92 + case lexicons.RecordTypeRecipe: 93 + if f.Recipe != nil { 94 + return f.Recipe 95 + } 96 + case lexicons.RecordTypeBrew: 97 + if f.Brew != nil { 98 + return f.Brew 99 + } 100 + } 101 + return nil 102 + } 103 + 104 + // RKey returns the record key of whichever typed record is set on this 105 + // FeedItem, or "" if none. Lets callers build URLs without a type switch. 106 + func (f *FeedItem) RKey() string { 107 + switch f.RecordType { 108 + case lexicons.RecordTypeBean: 109 + if f.Bean != nil { 110 + return f.Bean.RKey 111 + } 112 + case lexicons.RecordTypeRoaster: 113 + if f.Roaster != nil { 114 + return f.Roaster.RKey 115 + } 116 + case lexicons.RecordTypeGrinder: 117 + if f.Grinder != nil { 118 + return f.Grinder.RKey 119 + } 120 + case lexicons.RecordTypeBrewer: 121 + if f.Brewer != nil { 122 + return f.Brewer.RKey 123 + } 124 + case lexicons.RecordTypeRecipe: 125 + if f.Recipe != nil { 126 + return f.Recipe.RKey 127 + } 128 + case lexicons.RecordTypeBrew: 129 + if f.Brew != nil { 130 + return f.Brew.RKey 131 + } 132 + } 133 + return "" 134 + } 135 + 136 + // DisplayTitle returns a human-readable title for share UI. Brew is 137 + // special-cased: brews don't have a name field, so we fall back to the 138 + // associated bean's name (or origin). 139 + func (f *FeedItem) DisplayTitle() string { 140 + switch f.RecordType { 141 + case lexicons.RecordTypeBrew: 142 + if f.Brew != nil && f.Brew.Bean != nil { 143 + if f.Brew.Bean.Name != "" { 144 + return f.Brew.Bean.Name 145 + } 146 + return f.Brew.Bean.Origin 147 + } 148 + return "Coffee Brew" 149 + case lexicons.RecordTypeBean: 150 + if f.Bean != nil { 151 + if f.Bean.Name != "" { 152 + return f.Bean.Name 153 + } 154 + return f.Bean.Origin 155 + } 156 + return "Coffee Bean" 157 + case lexicons.RecordTypeRoaster: 158 + if f.Roaster != nil { 159 + return f.Roaster.Name 160 + } 161 + return "Roaster" 162 + case lexicons.RecordTypeGrinder: 163 + if f.Grinder != nil { 164 + return f.Grinder.Name 165 + } 166 + return "Grinder" 167 + case lexicons.RecordTypeBrewer: 168 + if f.Brewer != nil { 169 + return f.Brewer.Name 170 + } 171 + return "Brewer" 172 + case lexicons.RecordTypeRecipe: 173 + if f.Recipe != nil { 174 + return f.Recipe.Name 175 + } 176 + return "Recipe" 177 + } 178 + return "Arabica" 179 + } 180 + 72 181 // publicFeedCache holds cached feed items for unauthenticated users 73 182 type publicFeedCache struct { 74 183 items []*FeedItem
+389 -708
internal/handlers/entity_views.go
··· 7 7 "strings" 8 8 9 9 "tangled.org/arabica.social/arabica/internal/atproto" 10 + "tangled.org/arabica.social/arabica/internal/entities" 10 11 "tangled.org/arabica.social/arabica/internal/firehose" 12 + "tangled.org/arabica.social/arabica/internal/lexicons" 11 13 "tangled.org/arabica.social/arabica/internal/metrics" 12 14 "tangled.org/arabica.social/arabica/internal/models" 13 15 "tangled.org/arabica.social/arabica/internal/moderation" ··· 71 73 return resolved, nil 72 74 } 73 75 74 - // HandleBeanView shows a bean detail page with social features 75 - func (h *Handler) HandleBeanView(w http.ResponseWriter, r *http.Request) { 76 - rkey := validateRKey(w, r.PathValue("id")) 77 - if rkey == "" { 78 - return 79 - } 80 - 81 - owner := r.URL.Query().Get("owner") 82 - didStr, err := atproto.GetAuthenticatedDID(r.Context()) 83 - isAuthenticated := err == nil && didStr != "" 84 - 85 - var userProfile *bff.UserProfile 86 - if isAuthenticated { 87 - userProfile = h.getUserProfile(r.Context(), didStr) 88 - } 89 - 90 - var beanViewProps pages.BeanViewProps 91 - var subjectURI, subjectCID, entityOwnerDID string 92 - 93 - if owner != "" { 94 - entityOwnerDID, err = resolveOwnerDID(r.Context(), owner) 95 - if err != nil { 96 - log.Warn().Err(err).Str("handle", owner).Msg("Failed to resolve handle for bean view") 97 - http.Error(w, "User not found", http.StatusNotFound) 98 - return 99 - } 100 - 101 - // Try witness cache first 102 - beanURI := atproto.BuildATURI(entityOwnerDID, atproto.NSIDBean, rkey) 103 - if h.witnessCache != nil { 104 - if wr, _ := h.witnessCache.GetWitnessRecord(r.Context(), beanURI); wr != nil { 105 - if m, err := atproto.WitnessRecordToMap(wr); err == nil { 106 - if bean, err := atproto.RecordToBean(m, wr.URI); err == nil { 107 - metrics.WitnessCacheHitsTotal.WithLabelValues("bean").Inc() 108 - bean.RKey = rkey 109 - subjectURI = wr.URI 110 - subjectCID = wr.CID 111 - // Resolve roaster from witness 112 - if roasterRef, ok := m["roasterRef"].(string); ok && roasterRef != "" { 113 - if c, err := atproto.ResolveATURI(roasterRef); err == nil { 114 - bean.RoasterRKey = c.RKey 115 - } 116 - if rwr, _ := h.witnessCache.GetWitnessRecord(r.Context(), roasterRef); rwr != nil { 117 - if rm, err := atproto.WitnessRecordToMap(rwr); err == nil { 118 - if roaster, err := atproto.RecordToRoaster(rm, rwr.URI); err == nil { 119 - roaster.RKey = rwr.RKey 120 - bean.Roaster = roaster 121 - } 122 - } 123 - } 124 - } 125 - beanViewProps.Bean = bean 126 - beanViewProps.IsOwnProfile = isAuthenticated && didStr == entityOwnerDID 127 - } 128 - } 129 - } 130 - } 131 - 132 - if beanViewProps.Bean == nil { 133 - // PDS fallback 134 - metrics.WitnessCacheMissesTotal.WithLabelValues("bean").Inc() 135 - publicClient := atproto.NewPublicClient() 136 - record, err := publicClient.GetRecord(r.Context(), entityOwnerDID, atproto.NSIDBean, rkey) 137 - if err != nil { 138 - log.Error().Err(err).Str("did", entityOwnerDID).Str("rkey", rkey).Msg("Failed to get bean record") 139 - http.Error(w, "Bean not found", http.StatusNotFound) 140 - return 141 - } 142 - 143 - subjectURI = record.URI 144 - subjectCID = record.CID 145 - 146 - bean, err := atproto.RecordToBean(record.Value, record.URI) 147 - if err != nil { 148 - log.Error().Err(err).Msg("Failed to convert bean record") 149 - http.Error(w, "Failed to load bean", http.StatusInternalServerError) 150 - return 151 - } 152 - bean.RKey = rkey 153 - 154 - // Resolve roaster reference 155 - if roasterRef, ok := record.Value["roasterRef"].(string); ok && roasterRef != "" { 156 - if c, err := atproto.ResolveATURI(roasterRef); err == nil { 157 - bean.RoasterRKey = c.RKey 158 - } 159 - roasterRKey := atproto.ExtractRKeyFromURI(roasterRef) 160 - if roasterRKey != "" { 161 - roasterRecord, err := publicClient.GetRecord(r.Context(), entityOwnerDID, atproto.NSIDRoaster, roasterRKey) 162 - if err == nil { 163 - if roaster, err := atproto.RecordToRoaster(roasterRecord.Value, roasterRecord.URI); err == nil { 164 - roaster.RKey = roasterRKey 165 - bean.Roaster = roaster 166 - } 167 - } 168 - } 169 - } 170 - 171 - beanViewProps.Bean = bean 172 - beanViewProps.IsOwnProfile = isAuthenticated && didStr == entityOwnerDID 173 - } 174 - } else { 175 - store, authenticated := h.getAtprotoStore(r) 176 - if !authenticated { 177 - http.Redirect(w, r, "/login", http.StatusFound) 178 - return 179 - } 180 - 181 - atprotoStore, ok := store.(*atproto.AtprotoStore) 182 - if !ok { 183 - http.Error(w, "Internal error", http.StatusInternalServerError) 184 - return 185 - } 186 - 187 - beanRecord, err := atprotoStore.GetBeanRecordByRKey(r.Context(), rkey) 188 - if err != nil { 189 - http.Error(w, "Bean not found", http.StatusNotFound) 190 - log.Error().Err(err).Str("rkey", rkey).Msg("Failed to get bean for view") 191 - return 192 - } 193 - 194 - beanViewProps.Bean = beanRecord.Bean 195 - subjectURI = beanRecord.URI 196 - subjectCID = beanRecord.CID 197 - beanViewProps.IsOwnProfile = true 198 - } 199 - 200 - // Construct share URL 201 - var shareURL string 202 - if owner != "" { 203 - shareURL = fmt.Sprintf("/beans/%s?owner=%s", rkey, owner) 204 - } else if userProfile != nil && userProfile.Handle != "" { 205 - shareURL = fmt.Sprintf("/beans/%s?owner=%s", rkey, userProfile.Handle) 206 - } 207 - 208 - layoutData := h.buildLayoutData(r, beanViewProps.Bean.Name, isAuthenticated, didStr, userProfile) 209 - h.populateBeanOGMetadata(layoutData, beanViewProps.Bean, h.resolveOwnerHandle(r.Context(), owner), h.publicBaseURL(r), shareURL) 210 - 211 - sd := h.fetchSocialData(r.Context(), subjectURI, didStr, isAuthenticated) 212 - 213 - beanViewProps.IsAuthenticated = isAuthenticated 214 - beanViewProps.SubjectURI = subjectURI 215 - beanViewProps.SubjectCID = subjectCID 216 - beanViewProps.IsLiked = sd.IsLiked 217 - beanViewProps.LikeCount = sd.LikeCount 218 - beanViewProps.CommentCount = sd.CommentCount 219 - beanViewProps.Comments = sd.Comments 220 - beanViewProps.CurrentUserDID = didStr 221 - beanViewProps.ShareURL = shareURL 222 - beanViewProps.IsModerator = sd.IsModerator 223 - beanViewProps.CanHideRecord = sd.CanHideRecord 224 - beanViewProps.CanBlockUser = sd.CanBlockUser 225 - beanViewProps.IsRecordHidden = sd.IsRecordHidden 226 - beanViewProps.AuthorDID = entityOwnerDID 227 - 228 - // Fetch author profile for display 229 - var authorProfile *bff.UserProfile 230 - authorDIDForProfile := entityOwnerDID 231 - if authorDIDForProfile == "" { 232 - authorDIDForProfile = didStr 233 - } 234 - authorProfile = h.getUserProfile(r.Context(), authorDIDForProfile) 235 - if authorProfile != nil { 236 - beanViewProps.AuthorHandle = authorProfile.Handle 237 - beanViewProps.AuthorDisplayName = authorProfile.DisplayName 238 - beanViewProps.AuthorAvatar = authorProfile.Avatar 239 - } 240 - 241 - if h.feedIndex != nil && subjectURI != "" { 242 - ownerDID := entityOwnerDID 243 - if ownerDID == "" { 244 - ownerDID = didStr 245 - } 246 - counts := h.feedIndex.BrewCountsByBeanURI(r.Context(), ownerDID) 247 - beanViewProps.BrewCount = counts[subjectURI] 248 - } 249 - 250 - if err := pages.BeanView(layoutData, beanViewProps).Render(r.Context(), w); err != nil { 251 - http.Error(w, "Failed to render page", http.StatusInternalServerError) 252 - log.Error().Err(err).Msg("Failed to render bean view") 253 - } 254 - } 255 - 256 - // HandleRoasterView shows a roaster detail page with social features 257 - func (h *Handler) HandleRoasterView(w http.ResponseWriter, r *http.Request) { 258 - rkey := validateRKey(w, r.PathValue("id")) 259 - if rkey == "" { 260 - return 261 - } 262 - 263 - owner := r.URL.Query().Get("owner") 264 - didStr, err := atproto.GetAuthenticatedDID(r.Context()) 265 - isAuthenticated := err == nil && didStr != "" 266 - 267 - var userProfile *bff.UserProfile 268 - if isAuthenticated { 269 - userProfile = h.getUserProfile(r.Context(), didStr) 270 - } 271 - 272 - var props pages.RoasterViewProps 273 - var subjectURI, subjectCID, entityOwnerDID string 274 - 275 - if owner != "" { 276 - entityOwnerDID, err = resolveOwnerDID(r.Context(), owner) 277 - if err != nil { 278 - http.Error(w, "User not found", http.StatusNotFound) 279 - return 280 - } 281 - 282 - // Try witness cache first 283 - roasterURI := atproto.BuildATURI(entityOwnerDID, atproto.NSIDRoaster, rkey) 284 - if h.witnessCache != nil { 285 - if wr, _ := h.witnessCache.GetWitnessRecord(r.Context(), roasterURI); wr != nil { 286 - if m, err := atproto.WitnessRecordToMap(wr); err == nil { 287 - if roaster, err := atproto.RecordToRoaster(m, wr.URI); err == nil { 288 - metrics.WitnessCacheHitsTotal.WithLabelValues("roaster").Inc() 289 - roaster.RKey = rkey 290 - subjectURI = wr.URI 291 - subjectCID = wr.CID 292 - props.Roaster = roaster 293 - props.IsOwnProfile = isAuthenticated && didStr == entityOwnerDID 294 - } 295 - } 296 - } 297 - } 298 - 299 - if props.Roaster == nil { 300 - // PDS fallback 301 - metrics.WitnessCacheMissesTotal.WithLabelValues("roaster").Inc() 302 - publicClient := atproto.NewPublicClient() 303 - record, err := publicClient.GetRecord(r.Context(), entityOwnerDID, atproto.NSIDRoaster, rkey) 304 - if err != nil { 305 - http.Error(w, "Roaster not found", http.StatusNotFound) 306 - return 307 - } 308 - 309 - subjectURI = record.URI 310 - subjectCID = record.CID 311 - 312 - roaster, err := atproto.RecordToRoaster(record.Value, record.URI) 313 - if err != nil { 314 - http.Error(w, "Failed to load roaster", http.StatusInternalServerError) 315 - return 316 - } 317 - roaster.RKey = rkey 318 - props.Roaster = roaster 319 - props.IsOwnProfile = isAuthenticated && didStr == entityOwnerDID 320 - } 321 - } else { 322 - store, authenticated := h.getAtprotoStore(r) 323 - if !authenticated { 324 - http.Redirect(w, r, "/login", http.StatusFound) 325 - return 326 - } 327 - 328 - atprotoStore, ok := store.(*atproto.AtprotoStore) 329 - if !ok { 330 - http.Error(w, "Internal error", http.StatusInternalServerError) 331 - return 332 - } 333 - 334 - roasterRecord, err := atprotoStore.GetRoasterRecordByRKey(r.Context(), rkey) 335 - if err != nil { 336 - http.Error(w, "Roaster not found", http.StatusNotFound) 337 - return 338 - } 339 - 340 - props.Roaster = roasterRecord.Roaster 341 - subjectURI = roasterRecord.URI 342 - subjectCID = roasterRecord.CID 343 - props.IsOwnProfile = true 344 - } 345 - 346 - var shareURL string 347 - if owner != "" { 348 - shareURL = fmt.Sprintf("/roasters/%s?owner=%s", rkey, owner) 349 - } else if userProfile != nil && userProfile.Handle != "" { 350 - shareURL = fmt.Sprintf("/roasters/%s?owner=%s", rkey, userProfile.Handle) 351 - } 352 - 353 - layoutData := h.buildLayoutData(r, props.Roaster.Name, isAuthenticated, didStr, userProfile) 354 - h.populateRoasterOGMetadata(layoutData, props.Roaster, h.resolveOwnerHandle(r.Context(), owner), h.publicBaseURL(r), shareURL) 355 - 356 - sd := h.fetchSocialData(r.Context(), subjectURI, didStr, isAuthenticated) 357 - 358 - props.IsAuthenticated = isAuthenticated 359 - props.SubjectURI = subjectURI 360 - props.SubjectCID = subjectCID 361 - props.IsLiked = sd.IsLiked 362 - props.LikeCount = sd.LikeCount 363 - props.CommentCount = sd.CommentCount 364 - props.Comments = sd.Comments 365 - props.CurrentUserDID = didStr 366 - props.ShareURL = shareURL 367 - props.IsModerator = sd.IsModerator 368 - props.CanHideRecord = sd.CanHideRecord 369 - props.CanBlockUser = sd.CanBlockUser 370 - props.IsRecordHidden = sd.IsRecordHidden 371 - props.AuthorDID = entityOwnerDID 372 - 373 - // Fetch author profile for display 374 - var authorProfile *bff.UserProfile 375 - authorDIDForProfile := entityOwnerDID 376 - if authorDIDForProfile == "" { 377 - authorDIDForProfile = didStr 378 - } 379 - authorProfile = h.getUserProfile(r.Context(), authorDIDForProfile) 380 - if authorProfile != nil { 381 - props.AuthorHandle = authorProfile.Handle 382 - props.AuthorDisplayName = authorProfile.DisplayName 383 - props.AuthorAvatar = authorProfile.Avatar 384 - } 385 - 386 - if h.feedIndex != nil && subjectURI != "" { 387 - ownerDID := entityOwnerDID 388 - if ownerDID == "" { 389 - ownerDID = didStr 390 - } 391 - counts := h.feedIndex.BeanCountsByRoasterURI(r.Context(), ownerDID) 392 - props.BeanCount = counts[subjectURI] 393 - } 394 - 395 - if err := pages.RoasterView(layoutData, props).Render(r.Context(), w); err != nil { 396 - http.Error(w, "Failed to render page", http.StatusInternalServerError) 397 - log.Error().Err(err).Msg("Failed to render roaster view") 398 - } 399 - } 400 - 401 - // HandleGrinderView shows a grinder detail page with social features 402 - func (h *Handler) HandleGrinderView(w http.ResponseWriter, r *http.Request) { 403 - rkey := validateRKey(w, r.PathValue("id")) 404 - if rkey == "" { 405 - return 406 - } 407 - 408 - owner := r.URL.Query().Get("owner") 409 - didStr, err := atproto.GetAuthenticatedDID(r.Context()) 410 - isAuthenticated := err == nil && didStr != "" 411 - 412 - var userProfile *bff.UserProfile 413 - if isAuthenticated { 414 - userProfile = h.getUserProfile(r.Context(), didStr) 415 - } 416 - 417 - var props pages.GrinderViewProps 418 - var subjectURI, subjectCID, entityOwnerDID string 419 - 420 - if owner != "" { 421 - entityOwnerDID, err = resolveOwnerDID(r.Context(), owner) 422 - if err != nil { 423 - http.Error(w, "User not found", http.StatusNotFound) 424 - return 425 - } 426 - 427 - // Try witness cache first 428 - grinderURI := atproto.BuildATURI(entityOwnerDID, atproto.NSIDGrinder, rkey) 429 - if h.witnessCache != nil { 430 - if wr, _ := h.witnessCache.GetWitnessRecord(r.Context(), grinderURI); wr != nil { 431 - if m, err := atproto.WitnessRecordToMap(wr); err == nil { 432 - if grinder, err := atproto.RecordToGrinder(m, wr.URI); err == nil { 433 - metrics.WitnessCacheHitsTotal.WithLabelValues("grinder").Inc() 434 - grinder.RKey = rkey 435 - subjectURI = wr.URI 436 - subjectCID = wr.CID 437 - props.Grinder = grinder 438 - props.IsOwnProfile = isAuthenticated && didStr == entityOwnerDID 439 - } 440 - } 441 - } 442 - } 443 - 444 - if props.Grinder == nil { 445 - // PDS fallback 446 - metrics.WitnessCacheMissesTotal.WithLabelValues("grinder").Inc() 447 - publicClient := atproto.NewPublicClient() 448 - record, err := publicClient.GetRecord(r.Context(), entityOwnerDID, atproto.NSIDGrinder, rkey) 449 - if err != nil { 450 - http.Error(w, "Grinder not found", http.StatusNotFound) 451 - return 452 - } 453 - 454 - subjectURI = record.URI 455 - subjectCID = record.CID 456 - 457 - grinder, err := atproto.RecordToGrinder(record.Value, record.URI) 458 - if err != nil { 459 - http.Error(w, "Failed to load grinder", http.StatusInternalServerError) 460 - return 461 - } 462 - grinder.RKey = rkey 463 - props.Grinder = grinder 464 - props.IsOwnProfile = isAuthenticated && didStr == entityOwnerDID 465 - } 466 - } else { 467 - store, authenticated := h.getAtprotoStore(r) 468 - if !authenticated { 469 - http.Redirect(w, r, "/login", http.StatusFound) 470 - return 471 - } 472 - 473 - atprotoStore, ok := store.(*atproto.AtprotoStore) 474 - if !ok { 475 - http.Error(w, "Internal error", http.StatusInternalServerError) 476 - return 477 - } 478 - 479 - grinderRecord, err := atprotoStore.GetGrinderRecordByRKey(r.Context(), rkey) 480 - if err != nil { 481 - http.Error(w, "Grinder not found", http.StatusNotFound) 482 - return 483 - } 484 - 485 - props.Grinder = grinderRecord.Grinder 486 - subjectURI = grinderRecord.URI 487 - subjectCID = grinderRecord.CID 488 - props.IsOwnProfile = true 489 - } 490 - 491 - var shareURL string 492 - if owner != "" { 493 - shareURL = fmt.Sprintf("/grinders/%s?owner=%s", rkey, owner) 494 - } else if userProfile != nil && userProfile.Handle != "" { 495 - shareURL = fmt.Sprintf("/grinders/%s?owner=%s", rkey, userProfile.Handle) 496 - } 497 - 498 - layoutData := h.buildLayoutData(r, props.Grinder.Name, isAuthenticated, didStr, userProfile) 499 - h.populateGrinderOGMetadata(layoutData, props.Grinder, h.resolveOwnerHandle(r.Context(), owner), h.publicBaseURL(r), shareURL) 500 - 501 - sd := h.fetchSocialData(r.Context(), subjectURI, didStr, isAuthenticated) 502 - 503 - props.IsAuthenticated = isAuthenticated 504 - props.SubjectURI = subjectURI 505 - props.SubjectCID = subjectCID 506 - props.IsLiked = sd.IsLiked 507 - props.LikeCount = sd.LikeCount 508 - props.CommentCount = sd.CommentCount 509 - props.Comments = sd.Comments 510 - props.CurrentUserDID = didStr 511 - props.ShareURL = shareURL 512 - props.IsModerator = sd.IsModerator 513 - props.CanHideRecord = sd.CanHideRecord 514 - props.CanBlockUser = sd.CanBlockUser 515 - props.IsRecordHidden = sd.IsRecordHidden 516 - props.AuthorDID = entityOwnerDID 517 - 518 - // Fetch author profile for display 519 - { 520 - var authorProfile *bff.UserProfile 521 - authorDIDForProfile := entityOwnerDID 522 - if authorDIDForProfile == "" { 523 - authorDIDForProfile = didStr 524 - } 525 - authorProfile = h.getUserProfile(r.Context(), authorDIDForProfile) 526 - if authorProfile != nil { 527 - props.AuthorHandle = authorProfile.Handle 528 - props.AuthorDisplayName = authorProfile.DisplayName 529 - props.AuthorAvatar = authorProfile.Avatar 530 - } 531 - } 532 - 533 - if h.feedIndex != nil && subjectURI != "" { 534 - ownerDID := entityOwnerDID 535 - if ownerDID == "" { 536 - ownerDID = didStr 537 - } 538 - counts := h.feedIndex.BrewCountsByGrinderURI(r.Context(), ownerDID) 539 - props.BrewCount = counts[subjectURI] 540 - } 541 - 542 - if err := pages.GrinderView(layoutData, props).Render(r.Context(), w); err != nil { 543 - http.Error(w, "Failed to render page", http.StatusInternalServerError) 544 - log.Error().Err(err).Msg("Failed to render grinder view") 545 - } 76 + // entityViewConfig captures per-entity behavior for handleEntityView. 77 + // Construct via the h.xViewConfig() methods — closures capture h naturally. 78 + type entityViewConfig struct { 79 + descriptor *entities.Descriptor 80 + fromWitness func(ctx context.Context, m map[string]any, uri, rkey, ownerDID string) (any, error) 81 + fromPDS func(ctx context.Context, e *atproto.PublicRecordEntry, rkey, ownerDID string) (any, error) 82 + fromStore func(ctx context.Context, s *atproto.AtprotoStore, rkey string) (any, string, string, error) 83 + displayName func(record any) string 84 + ogSubtitle func(record any) string 85 + countLookup func(ctx context.Context, ownerDID, subjectURI string) int 86 + render func(ctx context.Context, w http.ResponseWriter, layoutData *components.LayoutData, record any, base pages.EntityViewBase) error 546 87 } 547 88 548 - // HandleBrewerView shows a brewer detail page with social features 549 - func (h *Handler) HandleBrewerView(w http.ResponseWriter, r *http.Request) { 89 + func (h *Handler) handleEntityView(w http.ResponseWriter, r *http.Request, cfg entityViewConfig) { 550 90 rkey := validateRKey(w, r.PathValue("id")) 551 91 if rkey == "" { 552 92 return 553 93 } 554 94 555 95 owner := r.URL.Query().Get("owner") 556 - didStr, err := atproto.GetAuthenticatedDID(r.Context()) 557 - isAuthenticated := err == nil && didStr != "" 96 + didStr, _ := atproto.GetAuthenticatedDID(r.Context()) 97 + isAuthenticated := didStr != "" 558 98 559 99 var userProfile *bff.UserProfile 560 100 if isAuthenticated { 561 101 userProfile = h.getUserProfile(r.Context(), didStr) 562 102 } 563 103 564 - var props pages.BrewerViewProps 104 + var record any 565 105 var subjectURI, subjectCID, entityOwnerDID string 106 + isOwnProfile := false 566 107 567 108 if owner != "" { 109 + var err error 568 110 entityOwnerDID, err = resolveOwnerDID(r.Context(), owner) 569 111 if err != nil { 112 + log.Warn().Err(err).Str("handle", owner).Msgf("Failed to resolve handle for %s view", cfg.descriptor.Noun) 570 113 http.Error(w, "User not found", http.StatusNotFound) 571 114 return 572 115 } 573 116 574 - // Try witness cache first 575 - brewerURI := atproto.BuildATURI(entityOwnerDID, atproto.NSIDBrewer, rkey) 117 + entityURI := atproto.BuildATURI(entityOwnerDID, cfg.descriptor.NSID, rkey) 576 118 if h.witnessCache != nil { 577 - if wr, _ := h.witnessCache.GetWitnessRecord(r.Context(), brewerURI); wr != nil { 119 + if wr, _ := h.witnessCache.GetWitnessRecord(r.Context(), entityURI); wr != nil { 578 120 if m, err := atproto.WitnessRecordToMap(wr); err == nil { 579 - if brewer, err := atproto.RecordToBrewer(m, wr.URI); err == nil { 580 - metrics.WitnessCacheHitsTotal.WithLabelValues("brewer").Inc() 581 - brewer.RKey = rkey 121 + if rec, err := cfg.fromWitness(r.Context(), m, wr.URI, rkey, entityOwnerDID); err == nil { 122 + metrics.WitnessCacheHitsTotal.WithLabelValues(cfg.descriptor.Noun).Inc() 123 + record = rec 582 124 subjectURI = wr.URI 583 125 subjectCID = wr.CID 584 - props.Brewer = brewer 585 - props.IsOwnProfile = isAuthenticated && didStr == entityOwnerDID 126 + isOwnProfile = isAuthenticated && didStr == entityOwnerDID 586 127 } 587 128 } 588 129 } 589 130 } 590 131 591 - if props.Brewer == nil { 592 - // PDS fallback 593 - metrics.WitnessCacheMissesTotal.WithLabelValues("brewer").Inc() 594 - publicClient := atproto.NewPublicClient() 595 - record, err := publicClient.GetRecord(r.Context(), entityOwnerDID, atproto.NSIDBrewer, rkey) 132 + if record == nil { 133 + metrics.WitnessCacheMissesTotal.WithLabelValues(cfg.descriptor.Noun).Inc() 134 + pub := atproto.NewPublicClient() 135 + entry, err := pub.GetRecord(r.Context(), entityOwnerDID, cfg.descriptor.NSID, rkey) 596 136 if err != nil { 597 - http.Error(w, "Brewer not found", http.StatusNotFound) 137 + log.Error().Err(err).Str("did", entityOwnerDID).Str("rkey", rkey).Msgf("Failed to get %s record", cfg.descriptor.Noun) 138 + http.Error(w, cfg.descriptor.DisplayName+" not found", http.StatusNotFound) 598 139 return 599 140 } 600 - 601 - subjectURI = record.URI 602 - subjectCID = record.CID 603 - 604 - brewer, err := atproto.RecordToBrewer(record.Value, record.URI) 141 + rec, err := cfg.fromPDS(r.Context(), entry, rkey, entityOwnerDID) 605 142 if err != nil { 606 - http.Error(w, "Failed to load brewer", http.StatusInternalServerError) 143 + log.Error().Err(err).Msgf("Failed to convert %s record", cfg.descriptor.Noun) 144 + http.Error(w, "Failed to load "+cfg.descriptor.Noun, http.StatusInternalServerError) 607 145 return 608 146 } 609 - brewer.RKey = rkey 610 - props.Brewer = brewer 611 - props.IsOwnProfile = isAuthenticated && didStr == entityOwnerDID 147 + record = rec 148 + subjectURI = entry.URI 149 + subjectCID = entry.CID 150 + isOwnProfile = isAuthenticated && didStr == entityOwnerDID 612 151 } 613 152 } else { 614 153 store, authenticated := h.getAtprotoStore(r) ··· 616 155 http.Redirect(w, r, "/login", http.StatusFound) 617 156 return 618 157 } 619 - 620 158 atprotoStore, ok := store.(*atproto.AtprotoStore) 621 159 if !ok { 622 160 http.Error(w, "Internal error", http.StatusInternalServerError) 623 161 return 624 162 } 625 - 626 - brewerRecord, err := atprotoStore.GetBrewerRecordByRKey(r.Context(), rkey) 163 + rec, uri, cid, err := cfg.fromStore(r.Context(), atprotoStore, rkey) 627 164 if err != nil { 628 - http.Error(w, "Brewer not found", http.StatusNotFound) 165 + http.Error(w, cfg.descriptor.DisplayName+" not found", http.StatusNotFound) 166 + log.Error().Err(err).Str("rkey", rkey).Msgf("Failed to get %s for view", cfg.descriptor.Noun) 629 167 return 630 168 } 631 - 632 - props.Brewer = brewerRecord.Brewer 633 - subjectURI = brewerRecord.URI 634 - subjectCID = brewerRecord.CID 635 - props.IsOwnProfile = true 169 + record, subjectURI, subjectCID = rec, uri, cid 170 + isOwnProfile = true 636 171 } 637 172 638 173 var shareURL string 639 174 if owner != "" { 640 - shareURL = fmt.Sprintf("/brewers/%s?owner=%s", rkey, owner) 175 + shareURL = fmt.Sprintf("/%s/%s?owner=%s", cfg.descriptor.URLPath, rkey, owner) 641 176 } else if userProfile != nil && userProfile.Handle != "" { 642 - shareURL = fmt.Sprintf("/brewers/%s?owner=%s", rkey, userProfile.Handle) 177 + shareURL = fmt.Sprintf("/%s/%s?owner=%s", cfg.descriptor.URLPath, rkey, userProfile.Handle) 643 178 } 644 179 645 - layoutData := h.buildLayoutData(r, props.Brewer.Name, isAuthenticated, didStr, userProfile) 646 - h.populateBrewerOGMetadata(layoutData, props.Brewer, h.resolveOwnerHandle(r.Context(), owner), h.publicBaseURL(r), shareURL) 180 + ownerHandle := h.resolveOwnerHandle(r.Context(), owner) 181 + layoutData := h.buildLayoutData(r, cfg.displayName(record), isAuthenticated, didStr, userProfile) 182 + populateOGFields(layoutData, cfg.ogSubtitle(record), cfg.descriptor.Noun, ownerHandle, h.publicBaseURL(r), shareURL) 647 183 648 184 sd := h.fetchSocialData(r.Context(), subjectURI, didStr, isAuthenticated) 649 185 650 - props.IsAuthenticated = isAuthenticated 651 - props.SubjectURI = subjectURI 652 - props.SubjectCID = subjectCID 653 - props.IsLiked = sd.IsLiked 654 - props.LikeCount = sd.LikeCount 655 - props.CommentCount = sd.CommentCount 656 - props.Comments = sd.Comments 657 - props.CurrentUserDID = didStr 658 - props.ShareURL = shareURL 659 - props.IsModerator = sd.IsModerator 660 - props.CanHideRecord = sd.CanHideRecord 661 - props.CanBlockUser = sd.CanBlockUser 662 - props.IsRecordHidden = sd.IsRecordHidden 663 - props.AuthorDID = entityOwnerDID 186 + authorDID := entityOwnerDID 187 + if authorDID == "" { 188 + authorDID = didStr 189 + } 190 + base := pages.EntityViewBase{ 191 + IsOwnProfile: isOwnProfile, 192 + IsAuthenticated: isAuthenticated, 193 + SubjectURI: subjectURI, 194 + SubjectCID: subjectCID, 195 + IsLiked: sd.IsLiked, 196 + LikeCount: sd.LikeCount, 197 + CommentCount: sd.CommentCount, 198 + Comments: sd.Comments, 199 + CurrentUserDID: didStr, 200 + ShareURL: shareURL, 201 + IsModerator: sd.IsModerator, 202 + CanHideRecord: sd.CanHideRecord, 203 + CanBlockUser: sd.CanBlockUser, 204 + IsRecordHidden: sd.IsRecordHidden, 205 + AuthorDID: entityOwnerDID, 206 + } 207 + if ap := h.getUserProfile(r.Context(), authorDID); ap != nil { 208 + base.AuthorHandle = ap.Handle 209 + base.AuthorDisplayName = ap.DisplayName 210 + base.AuthorAvatar = ap.Avatar 211 + } 212 + 213 + if err := cfg.render(r.Context(), w, layoutData, record, base); err != nil { 214 + http.Error(w, "Failed to render page", http.StatusInternalServerError) 215 + log.Error().Err(err).Msgf("Failed to render %s view", cfg.descriptor.Noun) 216 + } 217 + } 664 218 665 - // Fetch author profile for display 666 - { 667 - var authorProfile *bff.UserProfile 668 - authorDIDForProfile := entityOwnerDID 669 - if authorDIDForProfile == "" { 670 - authorDIDForProfile = didStr 671 - } 672 - authorProfile = h.getUserProfile(r.Context(), authorDIDForProfile) 673 - if authorProfile != nil { 674 - props.AuthorHandle = authorProfile.Handle 675 - props.AuthorDisplayName = authorProfile.DisplayName 676 - props.AuthorAvatar = authorProfile.Avatar 677 - } 219 + func (h *Handler) roasterViewConfig() entityViewConfig { 220 + return entityViewConfig{ 221 + descriptor: entities.Get(lexicons.RecordTypeRoaster), 222 + fromWitness: func(_ context.Context, m map[string]any, uri, rkey, _ string) (any, error) { 223 + r, err := atproto.RecordToRoaster(m, uri) 224 + if err != nil { 225 + return nil, err 226 + } 227 + r.RKey = rkey 228 + return r, nil 229 + }, 230 + fromPDS: func(_ context.Context, e *atproto.PublicRecordEntry, rkey, _ string) (any, error) { 231 + r, err := atproto.RecordToRoaster(e.Value, e.URI) 232 + if err != nil { 233 + return nil, err 234 + } 235 + r.RKey = rkey 236 + return r, nil 237 + }, 238 + fromStore: func(ctx context.Context, s *atproto.AtprotoStore, rkey string) (any, string, string, error) { 239 + rec, err := s.GetRoasterRecordByRKey(ctx, rkey) 240 + if err != nil { 241 + return nil, "", "", err 242 + } 243 + return rec.Roaster, rec.URI, rec.CID, nil 244 + }, 245 + displayName: func(record any) string { return record.(*models.Roaster).Name }, 246 + ogSubtitle: func(record any) string { return record.(*models.Roaster).Name }, 247 + countLookup: func(ctx context.Context, ownerDID, subjectURI string) int { 248 + if h.feedIndex == nil || subjectURI == "" { 249 + return 0 250 + } 251 + return h.feedIndex.BeanCountsByRoasterURI(ctx, ownerDID)[subjectURI] 252 + }, 253 + render: func(ctx context.Context, w http.ResponseWriter, layoutData *components.LayoutData, record any, base pages.EntityViewBase) error { 254 + roaster := record.(*models.Roaster) 255 + props := pages.RoasterViewProps{ 256 + Roaster: roaster, 257 + EntityViewBase: base, 258 + } 259 + if h.feedIndex != nil && base.SubjectURI != "" { 260 + ownerDID := base.AuthorDID 261 + if ownerDID == "" { 262 + ownerDID = base.CurrentUserDID 263 + } 264 + props.BeanCount = h.feedIndex.BeanCountsByRoasterURI(ctx, ownerDID)[base.SubjectURI] 265 + } 266 + return pages.RoasterView(layoutData, props).Render(ctx, w) 267 + }, 678 268 } 269 + } 679 270 680 - if h.feedIndex != nil && subjectURI != "" { 681 - ownerDID := entityOwnerDID 682 - if ownerDID == "" { 683 - ownerDID = didStr 684 - } 685 - counts := h.feedIndex.BrewCountsByBrewerURI(r.Context(), ownerDID) 686 - props.BrewCount = counts[subjectURI] 271 + func (h *Handler) grinderViewConfig() entityViewConfig { 272 + return entityViewConfig{ 273 + descriptor: entities.Get(lexicons.RecordTypeGrinder), 274 + fromWitness: func(_ context.Context, m map[string]any, uri, rkey, _ string) (any, error) { 275 + g, err := atproto.RecordToGrinder(m, uri) 276 + if err != nil { 277 + return nil, err 278 + } 279 + g.RKey = rkey 280 + return g, nil 281 + }, 282 + fromPDS: func(_ context.Context, e *atproto.PublicRecordEntry, rkey, _ string) (any, error) { 283 + g, err := atproto.RecordToGrinder(e.Value, e.URI) 284 + if err != nil { 285 + return nil, err 286 + } 287 + g.RKey = rkey 288 + return g, nil 289 + }, 290 + fromStore: func(ctx context.Context, s *atproto.AtprotoStore, rkey string) (any, string, string, error) { 291 + rec, err := s.GetGrinderRecordByRKey(ctx, rkey) 292 + if err != nil { 293 + return nil, "", "", err 294 + } 295 + return rec.Grinder, rec.URI, rec.CID, nil 296 + }, 297 + displayName: func(record any) string { return record.(*models.Grinder).Name }, 298 + ogSubtitle: func(record any) string { return record.(*models.Grinder).Name }, 299 + render: func(ctx context.Context, w http.ResponseWriter, layoutData *components.LayoutData, record any, base pages.EntityViewBase) error { 300 + grinder := record.(*models.Grinder) 301 + props := pages.GrinderViewProps{ 302 + Grinder: grinder, 303 + EntityViewBase: base, 304 + } 305 + if h.feedIndex != nil && base.SubjectURI != "" { 306 + ownerDID := base.AuthorDID 307 + if ownerDID == "" { 308 + ownerDID = base.CurrentUserDID 309 + } 310 + props.BrewCount = h.feedIndex.BrewCountsByGrinderURI(ctx, ownerDID)[base.SubjectURI] 311 + } 312 + return pages.GrinderView(layoutData, props).Render(ctx, w) 313 + }, 687 314 } 315 + } 688 316 689 - if err := pages.BrewerView(layoutData, props).Render(r.Context(), w); err != nil { 690 - http.Error(w, "Failed to render page", http.StatusInternalServerError) 691 - log.Error().Err(err).Msg("Failed to render brewer view") 317 + func (h *Handler) brewerViewConfig() entityViewConfig { 318 + return entityViewConfig{ 319 + descriptor: entities.Get(lexicons.RecordTypeBrewer), 320 + fromWitness: func(_ context.Context, m map[string]any, uri, rkey, _ string) (any, error) { 321 + b, err := atproto.RecordToBrewer(m, uri) 322 + if err != nil { 323 + return nil, err 324 + } 325 + b.RKey = rkey 326 + return b, nil 327 + }, 328 + fromPDS: func(_ context.Context, e *atproto.PublicRecordEntry, rkey, _ string) (any, error) { 329 + b, err := atproto.RecordToBrewer(e.Value, e.URI) 330 + if err != nil { 331 + return nil, err 332 + } 333 + b.RKey = rkey 334 + return b, nil 335 + }, 336 + fromStore: func(ctx context.Context, s *atproto.AtprotoStore, rkey string) (any, string, string, error) { 337 + rec, err := s.GetBrewerRecordByRKey(ctx, rkey) 338 + if err != nil { 339 + return nil, "", "", err 340 + } 341 + return rec.Brewer, rec.URI, rec.CID, nil 342 + }, 343 + displayName: func(record any) string { return record.(*models.Brewer).Name }, 344 + ogSubtitle: func(record any) string { return record.(*models.Brewer).Name }, 345 + render: func(ctx context.Context, w http.ResponseWriter, layoutData *components.LayoutData, record any, base pages.EntityViewBase) error { 346 + brewer := record.(*models.Brewer) 347 + props := pages.BrewerViewProps{ 348 + Brewer: brewer, 349 + EntityViewBase: base, 350 + } 351 + if h.feedIndex != nil && base.SubjectURI != "" { 352 + ownerDID := base.AuthorDID 353 + if ownerDID == "" { 354 + ownerDID = base.CurrentUserDID 355 + } 356 + props.BrewCount = h.feedIndex.BrewCountsByBrewerURI(ctx, ownerDID)[base.SubjectURI] 357 + } 358 + return pages.BrewerView(layoutData, props).Render(ctx, w) 359 + }, 360 + } 361 + } 362 + 363 + func (h *Handler) beanViewConfig() entityViewConfig { 364 + return entityViewConfig{ 365 + descriptor: entities.Get(lexicons.RecordTypeBean), 366 + fromWitness: func(ctx context.Context, m map[string]any, uri, rkey, _ string) (any, error) { 367 + bean, err := atproto.RecordToBean(m, uri) 368 + if err != nil { 369 + return nil, err 370 + } 371 + bean.RKey = rkey 372 + if roasterRef, ok := m["roasterRef"].(string); ok && roasterRef != "" { 373 + if c, err := atproto.ResolveATURI(roasterRef); err == nil { 374 + bean.RoasterRKey = c.RKey 375 + } 376 + if h.witnessCache != nil { 377 + if rwr, _ := h.witnessCache.GetWitnessRecord(ctx, roasterRef); rwr != nil { 378 + if rm, err := atproto.WitnessRecordToMap(rwr); err == nil { 379 + if roaster, err := atproto.RecordToRoaster(rm, rwr.URI); err == nil { 380 + roaster.RKey = rwr.RKey 381 + bean.Roaster = roaster 382 + } 383 + } 384 + } 385 + } 386 + } 387 + return bean, nil 388 + }, 389 + fromPDS: func(ctx context.Context, e *atproto.PublicRecordEntry, rkey, ownerDID string) (any, error) { 390 + bean, err := atproto.RecordToBean(e.Value, e.URI) 391 + if err != nil { 392 + return nil, err 393 + } 394 + bean.RKey = rkey 395 + if roasterRef, ok := e.Value["roasterRef"].(string); ok && roasterRef != "" { 396 + if c, err := atproto.ResolveATURI(roasterRef); err == nil { 397 + bean.RoasterRKey = c.RKey 398 + } 399 + roasterRKey := atproto.ExtractRKeyFromURI(roasterRef) 400 + if roasterRKey != "" { 401 + pub := atproto.NewPublicClient() 402 + if rr, err := pub.GetRecord(ctx, ownerDID, atproto.NSIDRoaster, roasterRKey); err == nil { 403 + if roaster, err := atproto.RecordToRoaster(rr.Value, rr.URI); err == nil { 404 + roaster.RKey = roasterRKey 405 + bean.Roaster = roaster 406 + } 407 + } 408 + } 409 + } 410 + return bean, nil 411 + }, 412 + fromStore: func(ctx context.Context, s *atproto.AtprotoStore, rkey string) (any, string, string, error) { 413 + rec, err := s.GetBeanRecordByRKey(ctx, rkey) 414 + if err != nil { 415 + return nil, "", "", err 416 + } 417 + return rec.Bean, rec.URI, rec.CID, nil 418 + }, 419 + displayName: func(record any) string { return record.(*models.Bean).Name }, 420 + ogSubtitle: func(record any) string { 421 + bean := record.(*models.Bean) 422 + sub := bean.Name 423 + if sub == "" { 424 + sub = bean.Origin 425 + } 426 + if bean.Roaster != nil && bean.Roaster.Name != "" { 427 + sub += " from " + bean.Roaster.Name 428 + } 429 + return sub 430 + }, 431 + render: func(ctx context.Context, w http.ResponseWriter, layoutData *components.LayoutData, record any, base pages.EntityViewBase) error { 432 + bean := record.(*models.Bean) 433 + props := pages.BeanViewProps{ 434 + Bean: bean, 435 + EntityViewBase: base, 436 + } 437 + if h.feedIndex != nil && base.SubjectURI != "" { 438 + ownerDID := base.AuthorDID 439 + if ownerDID == "" { 440 + ownerDID = base.CurrentUserDID 441 + } 442 + props.BrewCount = h.feedIndex.BrewCountsByBeanURI(ctx, ownerDID)[base.SubjectURI] 443 + } 444 + return pages.BeanView(layoutData, props).Render(ctx, w) 445 + }, 692 446 } 693 447 } 694 448 449 + // HandleBeanView shows a bean detail page with social features 450 + func (h *Handler) HandleBeanView(w http.ResponseWriter, r *http.Request) { 451 + h.handleEntityView(w, r, h.beanViewConfig()) 452 + } 453 + 454 + // HandleRoasterView shows a roaster detail page with social features 455 + func (h *Handler) HandleRoasterView(w http.ResponseWriter, r *http.Request) { 456 + h.handleEntityView(w, r, h.roasterViewConfig()) 457 + } 458 + 459 + // HandleGrinderView shows a grinder detail page with social features 460 + func (h *Handler) HandleGrinderView(w http.ResponseWriter, r *http.Request) { 461 + h.handleEntityView(w, r, h.grinderViewConfig()) 462 + } 463 + 464 + // HandleBrewerView shows a brewer detail page with social features 465 + func (h *Handler) HandleBrewerView(w http.ResponseWriter, r *http.Request) { 466 + h.handleEntityView(w, r, h.brewerViewConfig()) 467 + } 468 + 695 469 // HandleRecipeView displays a recipe detail page 696 470 func (h *Handler) HandleRecipeView(w http.ResponseWriter, r *http.Request) { 697 471 rkey := validateRKey(w, r.PathValue("id")) ··· 960 734 writeOGImage(w, card) 961 735 } 962 736 963 - // HandleRoasterOGImage generates a 1200x630 PNG preview card for a roaster. 964 - func (h *Handler) HandleRoasterOGImage(w http.ResponseWriter, r *http.Request) { 737 + // ogImageConfig captures per-entity behavior for handleSimpleOGImage. 738 + type ogImageConfig struct { 739 + nsid string 740 + metricLabel string 741 + convert func(m map[string]any, uri, rkey string) (any, error) 742 + drawCard func(record any) (*ogcard.Card, error) 743 + } 744 + 745 + // handleSimpleOGImage serves a simple entity OG image (no nested ref resolution). 746 + // Bean and Recipe have bespoke handlers due to nested ref resolution. 747 + func (h *Handler) handleSimpleOGImage(w http.ResponseWriter, r *http.Request, cfg ogImageConfig) { 965 748 rkey := validateRKey(w, r.PathValue("id")) 966 749 if rkey == "" { 967 750 return ··· 971 754 http.Error(w, "owner parameter required", http.StatusBadRequest) 972 755 return 973 756 } 974 - 975 757 ownerDID, err := resolveOwnerDID(r.Context(), owner) 976 758 if err != nil { 977 759 http.Error(w, "User not found", http.StatusNotFound) 978 760 return 979 761 } 980 - 981 - var roaster *models.Roaster 982 - roasterURI := atproto.BuildATURI(ownerDID, atproto.NSIDRoaster, rkey) 762 + var record any 763 + entityURI := atproto.BuildATURI(ownerDID, cfg.nsid, rkey) 983 764 if h.witnessCache != nil { 984 - if wr, _ := h.witnessCache.GetWitnessRecord(r.Context(), roasterURI); wr != nil { 765 + if wr, _ := h.witnessCache.GetWitnessRecord(r.Context(), entityURI); wr != nil { 985 766 if m, err := atproto.WitnessRecordToMap(wr); err == nil { 986 - if r, err := atproto.RecordToRoaster(m, wr.URI); err == nil { 987 - metrics.WitnessCacheHitsTotal.WithLabelValues("roaster_og").Inc() 988 - roaster = r 989 - roaster.RKey = rkey 767 + if rec, err := cfg.convert(m, wr.URI, rkey); err == nil { 768 + metrics.WitnessCacheHitsTotal.WithLabelValues(cfg.metricLabel).Inc() 769 + record = rec 990 770 } 991 771 } 992 772 } 993 773 } 994 - if roaster == nil { 995 - metrics.WitnessCacheMissesTotal.WithLabelValues("roaster_og").Inc() 996 - publicClient := atproto.NewPublicClient() 997 - record, err := publicClient.GetRecord(r.Context(), ownerDID, atproto.NSIDRoaster, rkey) 774 + if record == nil { 775 + metrics.WitnessCacheMissesTotal.WithLabelValues(cfg.metricLabel).Inc() 776 + pub := atproto.NewPublicClient() 777 + pr, err := pub.GetRecord(r.Context(), ownerDID, cfg.nsid, rkey) 998 778 if err != nil { 999 - http.Error(w, "Roaster not found", http.StatusNotFound) 779 + http.Error(w, "Not found", http.StatusNotFound) 1000 780 return 1001 781 } 1002 - roaster, err = atproto.RecordToRoaster(record.Value, record.URI) 782 + rec, err := cfg.convert(pr.Value, pr.URI, rkey) 1003 783 if err != nil { 1004 - http.Error(w, "Failed to load roaster", http.StatusInternalServerError) 784 + http.Error(w, "Failed to load record", http.StatusInternalServerError) 1005 785 return 1006 786 } 787 + record = rec 1007 788 } 1008 - 1009 - card, err := ogcard.DrawRoasterCard(roaster) 789 + card, err := cfg.drawCard(record) 1010 790 if err != nil { 1011 - log.Error().Err(err).Msg("Failed to generate roaster OG image") 791 + log.Error().Err(err).Msgf("Failed to generate %s OG image", cfg.metricLabel) 1012 792 http.Error(w, "Failed to generate image", http.StatusInternalServerError) 1013 793 return 1014 794 } 1015 795 writeOGImage(w, card) 1016 796 } 1017 797 798 + // HandleRoasterOGImage generates a 1200x630 PNG preview card for a roaster. 799 + func (h *Handler) HandleRoasterOGImage(w http.ResponseWriter, r *http.Request) { 800 + h.handleSimpleOGImage(w, r, ogImageConfig{ 801 + nsid: atproto.NSIDRoaster, metricLabel: "roaster_og", 802 + convert: func(m map[string]any, uri, rkey string) (any, error) { 803 + rec, err := atproto.RecordToRoaster(m, uri) 804 + if err != nil { 805 + return nil, err 806 + } 807 + rec.RKey = rkey 808 + return rec, nil 809 + }, 810 + drawCard: func(rec any) (*ogcard.Card, error) { return ogcard.DrawRoasterCard(rec.(*models.Roaster)) }, 811 + }) 812 + } 813 + 1018 814 // HandleGrinderOGImage generates a 1200x630 PNG preview card for a grinder. 1019 815 func (h *Handler) HandleGrinderOGImage(w http.ResponseWriter, r *http.Request) { 1020 - rkey := validateRKey(w, r.PathValue("id")) 1021 - if rkey == "" { 1022 - return 1023 - } 1024 - owner := r.URL.Query().Get("owner") 1025 - if owner == "" { 1026 - http.Error(w, "owner parameter required", http.StatusBadRequest) 1027 - return 1028 - } 1029 - 1030 - ownerDID, err := resolveOwnerDID(r.Context(), owner) 1031 - if err != nil { 1032 - http.Error(w, "User not found", http.StatusNotFound) 1033 - return 1034 - } 1035 - 1036 - var grinder *models.Grinder 1037 - grinderURI := atproto.BuildATURI(ownerDID, atproto.NSIDGrinder, rkey) 1038 - if h.witnessCache != nil { 1039 - if wr, _ := h.witnessCache.GetWitnessRecord(r.Context(), grinderURI); wr != nil { 1040 - if m, err := atproto.WitnessRecordToMap(wr); err == nil { 1041 - if g, err := atproto.RecordToGrinder(m, wr.URI); err == nil { 1042 - metrics.WitnessCacheHitsTotal.WithLabelValues("grinder_og").Inc() 1043 - grinder = g 1044 - grinder.RKey = rkey 1045 - } 816 + h.handleSimpleOGImage(w, r, ogImageConfig{ 817 + nsid: atproto.NSIDGrinder, metricLabel: "grinder_og", 818 + convert: func(m map[string]any, uri, rkey string) (any, error) { 819 + rec, err := atproto.RecordToGrinder(m, uri) 820 + if err != nil { 821 + return nil, err 1046 822 } 1047 - } 1048 - } 1049 - if grinder == nil { 1050 - metrics.WitnessCacheMissesTotal.WithLabelValues("grinder_og").Inc() 1051 - publicClient := atproto.NewPublicClient() 1052 - record, err := publicClient.GetRecord(r.Context(), ownerDID, atproto.NSIDGrinder, rkey) 1053 - if err != nil { 1054 - http.Error(w, "Grinder not found", http.StatusNotFound) 1055 - return 1056 - } 1057 - grinder, err = atproto.RecordToGrinder(record.Value, record.URI) 1058 - if err != nil { 1059 - http.Error(w, "Failed to load grinder", http.StatusInternalServerError) 1060 - return 1061 - } 1062 - } 1063 - 1064 - card, err := ogcard.DrawGrinderCard(grinder) 1065 - if err != nil { 1066 - log.Error().Err(err).Msg("Failed to generate grinder OG image") 1067 - http.Error(w, "Failed to generate image", http.StatusInternalServerError) 1068 - return 1069 - } 1070 - writeOGImage(w, card) 823 + rec.RKey = rkey 824 + return rec, nil 825 + }, 826 + drawCard: func(rec any) (*ogcard.Card, error) { return ogcard.DrawGrinderCard(rec.(*models.Grinder)) }, 827 + }) 1071 828 } 1072 829 1073 830 // HandleBrewerOGImage generates a 1200x630 PNG preview card for a brewer. 1074 831 func (h *Handler) HandleBrewerOGImage(w http.ResponseWriter, r *http.Request) { 1075 - rkey := validateRKey(w, r.PathValue("id")) 1076 - if rkey == "" { 1077 - return 1078 - } 1079 - owner := r.URL.Query().Get("owner") 1080 - if owner == "" { 1081 - http.Error(w, "owner parameter required", http.StatusBadRequest) 1082 - return 1083 - } 1084 - 1085 - ownerDID, err := resolveOwnerDID(r.Context(), owner) 1086 - if err != nil { 1087 - http.Error(w, "User not found", http.StatusNotFound) 1088 - return 1089 - } 1090 - 1091 - var brewer *models.Brewer 1092 - brewerURI := atproto.BuildATURI(ownerDID, atproto.NSIDBrewer, rkey) 1093 - if h.witnessCache != nil { 1094 - if wr, _ := h.witnessCache.GetWitnessRecord(r.Context(), brewerURI); wr != nil { 1095 - if m, err := atproto.WitnessRecordToMap(wr); err == nil { 1096 - if b, err := atproto.RecordToBrewer(m, wr.URI); err == nil { 1097 - metrics.WitnessCacheHitsTotal.WithLabelValues("brewer_og").Inc() 1098 - brewer = b 1099 - brewer.RKey = rkey 1100 - } 832 + h.handleSimpleOGImage(w, r, ogImageConfig{ 833 + nsid: atproto.NSIDBrewer, metricLabel: "brewer_og", 834 + convert: func(m map[string]any, uri, rkey string) (any, error) { 835 + rec, err := atproto.RecordToBrewer(m, uri) 836 + if err != nil { 837 + return nil, err 1101 838 } 1102 - } 1103 - } 1104 - if brewer == nil { 1105 - metrics.WitnessCacheMissesTotal.WithLabelValues("brewer_og").Inc() 1106 - publicClient := atproto.NewPublicClient() 1107 - record, err := publicClient.GetRecord(r.Context(), ownerDID, atproto.NSIDBrewer, rkey) 1108 - if err != nil { 1109 - http.Error(w, "Brewer not found", http.StatusNotFound) 1110 - return 1111 - } 1112 - brewer, err = atproto.RecordToBrewer(record.Value, record.URI) 1113 - if err != nil { 1114 - http.Error(w, "Failed to load brewer", http.StatusInternalServerError) 1115 - return 1116 - } 1117 - } 1118 - 1119 - card, err := ogcard.DrawBrewerCard(brewer) 1120 - if err != nil { 1121 - log.Error().Err(err).Msg("Failed to generate brewer OG image") 1122 - http.Error(w, "Failed to generate image", http.StatusInternalServerError) 1123 - return 1124 - } 1125 - writeOGImage(w, card) 839 + rec.RKey = rkey 840 + return rec, nil 841 + }, 842 + drawCard: func(rec any) (*ogcard.Card, error) { return ogcard.DrawBrewerCard(rec.(*models.Brewer)) }, 843 + }) 1126 844 } 1127 845 1128 846 // HandleRecipeOGImage generates a 1200x630 PNG preview card for a recipe. ··· 1212 930 } 1213 931 } 1214 932 1215 - // OG metadata helpers for entity types 1216 - 1217 - func (h *Handler) populateBeanOGMetadata(layoutData *components.LayoutData, bean *models.Bean, owner, baseURL, shareURL string) { 1218 - if bean == nil { 1219 - return 1220 - } 1221 - subtitle := bean.Name 1222 - if subtitle == "" { 1223 - subtitle = bean.Origin 1224 - } 1225 - if bean.Roaster != nil && bean.Roaster.Name != "" { 1226 - subtitle += " from " + bean.Roaster.Name 1227 - } 1228 - populateOGFields(layoutData, subtitle, "bean", owner, baseURL, shareURL) 1229 - } 1230 - 1231 - func (h *Handler) populateRoasterOGMetadata(layoutData *components.LayoutData, roaster *models.Roaster, owner, baseURL, shareURL string) { 1232 - if roaster == nil { 1233 - return 1234 - } 1235 - populateOGFields(layoutData, roaster.Name, "roaster", owner, baseURL, shareURL) 1236 - } 1237 - 1238 - func (h *Handler) populateGrinderOGMetadata(layoutData *components.LayoutData, grinder *models.Grinder, owner, baseURL, shareURL string) { 1239 - if grinder == nil { 1240 - return 1241 - } 1242 - populateOGFields(layoutData, grinder.Name, "grinder", owner, baseURL, shareURL) 1243 - } 1244 - 1245 - func (h *Handler) populateBrewerOGMetadata(layoutData *components.LayoutData, brewer *models.Brewer, owner, baseURL, shareURL string) { 1246 - if brewer == nil { 1247 - return 1248 - } 1249 - populateOGFields(layoutData, brewer.Name, "brewer", owner, baseURL, shareURL) 1250 - } 1251 - 1252 933 func (h *Handler) populateRecipeOGMetadata(layoutData *components.LayoutData, recipe *models.Recipe, owner, baseURL, shareURL string) { 1253 934 if recipe == nil { 1254 935 return
+12 -8
internal/handlers/suggestions.go
··· 6 6 "strconv" 7 7 8 8 "tangled.org/arabica.social/arabica/internal/atproto" 9 + "tangled.org/arabica.social/arabica/internal/entities" 9 10 "tangled.org/arabica.social/arabica/internal/suggestions" 10 11 11 12 "github.com/rs/zerolog/log" 12 13 ) 13 14 14 - // entityTypeToNSID maps URL path segments to collection NSIDs 15 - var entityTypeToNSID = map[string]string{ 16 - "roasters": atproto.NSIDRoaster, 17 - "grinders": atproto.NSIDGrinder, 18 - "brewers": atproto.NSIDBrewer, 19 - "beans": atproto.NSIDBean, 20 - "recipes": atproto.NSIDRecipe, 21 - } 15 + // entityTypeToNSID maps URL path segments to collection NSIDs. 16 + // Built from the descriptor registry so new entities appear automatically. 17 + var entityTypeToNSID = func() map[string]string { 18 + m := make(map[string]string) 19 + for _, d := range entities.All() { 20 + if d.NSID != "" { 21 + m[d.URLPath] = d.NSID 22 + } 23 + } 24 + return m 25 + }() 22 26 23 27 // HandleEntitySuggestions returns typeahead suggestions for entity creation 24 28 func (h *Handler) HandleEntitySuggestions(w http.ResponseWriter, r *http.Request) {
+6 -4
internal/ogcard/entities.go
··· 4 4 "fmt" 5 5 "strings" 6 6 7 + "tangled.org/arabica.social/arabica/internal/entities" 8 + "tangled.org/arabica.social/arabica/internal/lexicons" 7 9 "tangled.org/arabica.social/arabica/internal/models" 8 10 ) 9 11 ··· 18 20 19 21 // DrawBeanCard generates a 1200x630 OG image for a bean record. 20 22 func DrawBeanCard(bean *models.Bean) (*Card, error) { 21 - card, err := newTypedCard(AccentBean, "bean") 23 + card, err := newTypedCard(AccentBean, entities.Get(lexicons.RecordTypeBean).Noun) 22 24 if err != nil { 23 25 return nil, err 24 26 } ··· 103 105 104 106 // DrawRoasterCard generates a 1200x630 OG image for a roaster record. 105 107 func DrawRoasterCard(roaster *models.Roaster) (*Card, error) { 106 - card, err := newTypedCard(AccentRoaster, "roaster") 108 + card, err := newTypedCard(AccentRoaster, entities.Get(lexicons.RecordTypeRoaster).Noun) 107 109 if err != nil { 108 110 return nil, err 109 111 } ··· 146 148 147 149 // DrawGrinderCard generates a 1200x630 OG image for a grinder record. 148 150 func DrawGrinderCard(grinder *models.Grinder) (*Card, error) { 149 - card, err := newTypedCard(AccentGrinder, "grinder") 151 + card, err := newTypedCard(AccentGrinder, entities.Get(lexicons.RecordTypeGrinder).Noun) 150 152 if err != nil { 151 153 return nil, err 152 154 } ··· 197 199 198 200 // DrawBrewerCard generates a 1200x630 OG image for a brewer record. 199 201 func DrawBrewerCard(brewer *models.Brewer) (*Card, error) { 200 - card, err := newTypedCard(AccentBrewer, "brewer") 202 + card, err := newTypedCard(AccentBrewer, entities.Get(lexicons.RecordTypeBrewer).Noun) 201 203 if err != nil { 202 204 return nil, err 203 205 }
+14 -10
internal/routing/routing.go
··· 140 140 mux.Handle("DELETE /api/comments/{id}", cop.Handler(http.HandlerFunc(h.HandleCommentDelete))) 141 141 142 142 // Modal routes for entity management (return dialog HTML) 143 - mux.HandleFunc("GET /api/modals/bean/new", h.HandleBeanModalNew) 144 - mux.HandleFunc("GET /api/modals/bean/{id}", h.HandleBeanModalEdit) 145 - mux.HandleFunc("GET /api/modals/grinder/new", h.HandleGrinderModalNew) 146 - mux.HandleFunc("GET /api/modals/grinder/{id}", h.HandleGrinderModalEdit) 147 - mux.HandleFunc("GET /api/modals/brewer/new", h.HandleBrewerModalNew) 148 - mux.HandleFunc("GET /api/modals/brewer/{id}", h.HandleBrewerModalEdit) 149 - mux.HandleFunc("GET /api/modals/roaster/new", h.HandleRoasterModalNew) 150 - mux.HandleFunc("GET /api/modals/roaster/{id}", h.HandleRoasterModalEdit) 151 - mux.HandleFunc("GET /api/modals/recipe/new", h.HandleRecipeModalNew) 152 - mux.HandleFunc("GET /api/modals/recipe/{id}", h.HandleRecipeModalEdit) 143 + for _, m := range []struct { 144 + noun string 145 + new http.HandlerFunc 146 + edit http.HandlerFunc 147 + }{ 148 + {"bean", h.HandleBeanModalNew, h.HandleBeanModalEdit}, 149 + {"grinder", h.HandleGrinderModalNew, h.HandleGrinderModalEdit}, 150 + {"brewer", h.HandleBrewerModalNew, h.HandleBrewerModalEdit}, 151 + {"roaster", h.HandleRoasterModalNew, h.HandleRoasterModalEdit}, 152 + {"recipe", h.HandleRecipeModalNew, h.HandleRecipeModalEdit}, 153 + } { 154 + mux.HandleFunc("GET /api/modals/"+m.noun+"/new", m.new) 155 + mux.HandleFunc("GET /api/modals/"+m.noun+"/{id}", m.edit) 156 + } 153 157 154 158 // Notification routes 155 159 mux.HandleFunc("GET /notifications", h.HandleNotifications)
+321 -501
internal/web/components/dialog_modals.templ
··· 3 3 import ( 4 4 "fmt" 5 5 "strings" 6 + "tangled.org/arabica.social/arabica/internal/entities" 7 + "tangled.org/arabica.social/arabica/internal/lexicons" 6 8 "tangled.org/arabica.social/arabica/internal/models" 7 9 ) 8 10 ··· 14 16 15 17 // BeanDialogModal renders the bean creation/edit modal using native <dialog> 16 18 templ BeanDialogModal(bean *models.Bean, roasters []models.Roaster) { 17 - <dialog id="entity-modal" class="modal-dialog"> 18 - <div class="modal-content"> 19 - <h3 class="modal-title"> 20 - if bean != nil { 21 - Edit Bean 22 - } else { 23 - Add Bean 24 - } 25 - </h3> 26 - <form 27 - if bean != nil { 28 - hx-put={ "/api/beans/" + bean.RKey } 29 - } else { 30 - hx-post="/api/beans" 31 - } 32 - hx-trigger="submit" 33 - hx-swap="none" 34 - x-data="{ serverError: '' }" 35 - hx-on::after-request="if(event.detail.successful) { this.closest('dialog').close(); htmx.trigger('body', 'refreshManage'); } else if(event.detail.xhr && event.detail.xhr.status === 401) { this.closest('dialog').close(); window.__showSessionExpiredModal(); } else { this._x_dataStack[0].serverError = event.detail.xhr ? 'Something went wrong. Please try again.' : 'Connection error. Check your network.'; }" 36 - class="space-y-5" 37 - > 38 - <div x-show="serverError" x-cloak class="bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-800" x-text="serverError"></div> 39 - <!-- Essentials --> 19 + @ModalShell(ModalShellProps{ 20 + Type: lexicons.RecordTypeBean, 21 + RKey: modalEntityRKey(bean), 22 + }) { 23 + <!-- Essentials --> 40 24 <div class="form-fieldset"> 41 25 <div class="form-fieldset-label">Essentials</div> 42 26 if bean == nil { ··· 263 247 </label> 264 248 </div> 265 249 } 266 - <div class="flex gap-2 pt-2"> 267 - <button type="submit" class="flex-1 btn-primary"> 268 - Save 269 - </button> 270 - <button 271 - type="button" 272 - @click="$el.closest('dialog').close()" 273 - class="flex-1 btn-secondary" 274 - > 275 - Cancel 276 - </button> 277 - </div> 278 - </form> 279 - </div> 280 - </dialog> 250 + } 281 251 } 282 252 283 253 // GrinderDialogModal renders the grinder creation/edit modal using native <dialog> 284 254 templ GrinderDialogModal(grinder *models.Grinder) { 285 - <dialog id="entity-modal" class="modal-dialog"> 286 - <div class="modal-content"> 287 - <h3 class="modal-title"> 288 - if grinder != nil { 289 - Edit Grinder 290 - } else { 291 - Add Grinder 292 - } 293 - </h3> 294 - <form 295 - if grinder != nil { 296 - hx-put={ "/api/grinders/" + grinder.RKey } 297 - } else { 298 - hx-post="/api/grinders" 299 - } 300 - hx-trigger="submit" 301 - hx-swap="none" 302 - x-data="{ serverError: '' }" 303 - hx-on::after-request="if(event.detail.successful) { this.closest('dialog').close(); htmx.trigger('body', 'refreshManage'); } else if(event.detail.xhr && event.detail.xhr.status === 401) { this.closest('dialog').close(); window.__showSessionExpiredModal(); } else { this._x_dataStack[0].serverError = event.detail.xhr ? 'Something went wrong. Please try again.' : 'Connection error. Check your network.'; }" 304 - class="space-y-5" 305 - > 306 - <div x-show="serverError" x-cloak class="bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-800" x-text="serverError"></div> 307 - <!-- Essentials --> 308 - <div class="form-fieldset"> 309 - <div class="form-fieldset-label">Essentials</div> 310 - if grinder == nil { 311 - <div x-data="entitySuggest('/api/suggestions/grinders')" class="relative"> 312 - <input 313 - type="text" 314 - name="name" 315 - placeholder="Name *" 316 - required 317 - class="w-full form-input" 318 - x-model="query" 319 - @input.debounce.300ms="search()" 320 - @blur.debounce.200ms="showSuggestions = false" 321 - @focus="if (suggestions.length > 0) showSuggestions = true" 322 - autocomplete="off" 323 - /> 324 - <input type="hidden" name="source_ref" x-model="sourceRef"/> 325 - <template x-if="showSuggestions && suggestions.length > 0"> 326 - <div class="suggestions-dropdown"> 327 - <template x-for="s in suggestions" :key="s.source_uri"> 328 - <button 329 - type="button" 330 - class="suggestions-item" 331 - @mousedown.prevent="selectGrinderSuggestion(s)" 332 - > 333 - <span class="font-medium" x-text="s.name"></span> 334 - <template x-if="s.fields.grinderType"> 335 - <span class="text-xs text-brown-500" x-text="s.fields.grinderType"></span> 336 - </template> 337 - <template x-if="s.count > 1"> 338 - <span class="text-xs text-brown-400" x-text="s.count + ' users'"></span> 339 - </template> 340 - </button> 255 + @ModalShell(ModalShellProps{ 256 + Type: lexicons.RecordTypeGrinder, 257 + RKey: modalEntityRKey(grinder), 258 + }) { 259 + <!-- Essentials --> 260 + <div class="form-fieldset"> 261 + <div class="form-fieldset-label">Essentials</div> 262 + if grinder == nil { 263 + <div x-data="entitySuggest('/api/suggestions/grinders')" class="relative"> 264 + <input 265 + type="text" 266 + name="name" 267 + placeholder="Name *" 268 + required 269 + class="w-full form-input" 270 + x-model="query" 271 + @input.debounce.300ms="search()" 272 + @blur.debounce.200ms="showSuggestions = false" 273 + @focus="if (suggestions.length > 0) showSuggestions = true" 274 + autocomplete="off" 275 + /> 276 + <input type="hidden" name="source_ref" x-model="sourceRef"/> 277 + <template x-if="showSuggestions && suggestions.length > 0"> 278 + <div class="suggestions-dropdown"> 279 + <template x-for="s in suggestions" :key="s.source_uri"> 280 + <button 281 + type="button" 282 + class="suggestions-item" 283 + @mousedown.prevent="selectGrinderSuggestion(s)" 284 + > 285 + <span class="font-medium" x-text="s.name"></span> 286 + <template x-if="s.fields.grinderType"> 287 + <span class="text-xs text-brown-500" x-text="s.fields.grinderType"></span> 341 288 </template> 342 - </div> 289 + <template x-if="s.count > 1"> 290 + <span class="text-xs text-brown-400" x-text="s.count + ' users'"></span> 291 + </template> 292 + </button> 343 293 </template> 344 294 </div> 345 - } else { 346 - <input 347 - type="text" 348 - name="name" 349 - value={ getStringValue(grinder, "name") } 350 - placeholder="Name *" 351 - required 352 - class="w-full form-input" 353 - /> 354 - } 355 - <select 356 - name="grinder_type" 357 - class="w-full form-input" 358 - required 359 - > 360 - <option value="">Select Grinder Type *</option> 361 - for _, gType := range models.GrinderTypes { 362 - <option 363 - value={ gType } 364 - if grinder != nil && grinder.GrinderType == gType { 365 - selected 366 - } 367 - > 368 - { gType } 369 - </option> 370 - } 371 - </select> 295 + </template> 372 296 </div> 373 - <div class="form-divider"></div> 374 - <!-- Details --> 375 - <div class="form-fieldset"> 376 - <div class="form-fieldset-label">Details <span class="form-optional-hint">(optional)</span></div> 377 - <select 378 - name="burr_type" 379 - class="w-full form-input" 297 + } else { 298 + <input 299 + type="text" 300 + name="name" 301 + value={ getStringValue(grinder, "name") } 302 + placeholder="Name *" 303 + required 304 + class="w-full form-input" 305 + /> 306 + } 307 + <select 308 + name="grinder_type" 309 + class="w-full form-input" 310 + required 311 + > 312 + <option value="">Select Grinder Type *</option> 313 + for _, gType := range models.GrinderTypes { 314 + <option 315 + value={ gType } 316 + if grinder != nil && grinder.GrinderType == gType { 317 + selected 318 + } 380 319 > 381 - <option value="">Select Burr Type</option> 382 - for _, bType := range models.BurrTypes { 383 - <option 384 - value={ bType } 385 - if grinder != nil && grinder.BurrType == bType { 386 - selected 387 - } 388 - > 389 - { bType } 390 - </option> 320 + { gType } 321 + </option> 322 + } 323 + </select> 324 + </div> 325 + <div class="form-divider"></div> 326 + <!-- Details --> 327 + <div class="form-fieldset"> 328 + <div class="form-fieldset-label">Details <span class="form-optional-hint">(optional)</span></div> 329 + <select 330 + name="burr_type" 331 + class="w-full form-input" 332 + > 333 + <option value="">Select Burr Type</option> 334 + for _, bType := range models.BurrTypes { 335 + <option 336 + value={ bType } 337 + if grinder != nil && grinder.BurrType == bType { 338 + selected 391 339 } 392 - </select> 393 - <textarea 394 - name="notes" 395 - placeholder="Notes" 396 - rows="3" 397 - class="w-full form-textarea" 398 - >{ getStringValue(grinder, "notes") }</textarea> 399 - </div> 400 - <div class="flex gap-2 pt-2"> 401 - <button type="submit" class="flex-1 btn-primary"> 402 - Save 403 - </button> 404 - <button 405 - type="button" 406 - @click="$el.closest('dialog').close()" 407 - class="flex-1 btn-secondary" 408 340 > 409 - Cancel 410 - </button> 411 - </div> 412 - </form> 341 + { bType } 342 + </option> 343 + } 344 + </select> 345 + <textarea 346 + name="notes" 347 + placeholder="Notes" 348 + rows="3" 349 + class="w-full form-textarea" 350 + >{ getStringValue(grinder, "notes") }</textarea> 413 351 </div> 414 - </dialog> 352 + } 415 353 } 416 354 417 355 // BrewerDialogModal renders the brewer creation/edit modal using native <dialog> 418 356 templ BrewerDialogModal(brewer *models.Brewer) { 419 - <dialog id="entity-modal" class="modal-dialog"> 420 - <div class="modal-content"> 421 - <h3 class="modal-title"> 422 - if brewer != nil { 423 - Edit Brewer 424 - } else { 425 - Add Brewer 426 - } 427 - </h3> 428 - <form 429 - if brewer != nil { 430 - hx-put={ "/api/brewers/" + brewer.RKey } 431 - } else { 432 - hx-post="/api/brewers" 433 - } 434 - hx-trigger="submit" 435 - hx-swap="none" 436 - x-data="{ serverError: '' }" 437 - hx-on::after-request="if(event.detail.successful) { this.closest('dialog').close(); htmx.trigger('body', 'refreshManage'); } else if(event.detail.xhr && event.detail.xhr.status === 401) { this.closest('dialog').close(); window.__showSessionExpiredModal(); } else { this._x_dataStack[0].serverError = event.detail.xhr ? 'Something went wrong. Please try again.' : 'Connection error. Check your network.'; }" 438 - class="space-y-5" 439 - > 440 - <div x-show="serverError" x-cloak class="bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-800" x-text="serverError"></div> 441 - <!-- Essentials --> 442 - <div class="form-fieldset"> 443 - <div class="form-fieldset-label">Essentials</div> 444 - if brewer == nil { 445 - <div x-data="entitySuggest('/api/suggestions/brewers')" class="relative"> 446 - <input 447 - type="text" 448 - name="name" 449 - placeholder="Name *" 450 - required 451 - class="w-full form-input" 452 - x-model="query" 453 - @input.debounce.300ms="search()" 454 - @blur.debounce.200ms="showSuggestions = false" 455 - @focus="if (suggestions.length > 0) showSuggestions = true" 456 - autocomplete="off" 457 - /> 458 - <input type="hidden" name="source_ref" x-model="sourceRef"/> 459 - <template x-if="showSuggestions && suggestions.length > 0"> 460 - <div class="suggestions-dropdown"> 461 - <template x-for="s in suggestions" :key="s.source_uri"> 462 - <button 463 - type="button" 464 - class="suggestions-item" 465 - @mousedown.prevent="selectBrewerSuggestion(s)" 466 - > 467 - <span class="font-medium" x-text="s.name"></span> 468 - <template x-if="s.fields.brewerType"> 469 - <span class="text-xs text-brown-500" x-text="s.fields.brewerType"></span> 470 - </template> 471 - <template x-if="s.count > 1"> 472 - <span class="text-xs text-brown-400" x-text="s.count + ' users'"></span> 473 - </template> 474 - </button> 357 + @ModalShell(ModalShellProps{ 358 + Type: lexicons.RecordTypeBrewer, 359 + RKey: modalEntityRKey(brewer), 360 + }) { 361 + <!-- Essentials --> 362 + <div class="form-fieldset"> 363 + <div class="form-fieldset-label">Essentials</div> 364 + if brewer == nil { 365 + <div x-data="entitySuggest('/api/suggestions/brewers')" class="relative"> 366 + <input 367 + type="text" 368 + name="name" 369 + placeholder="Name *" 370 + required 371 + class="w-full form-input" 372 + x-model="query" 373 + @input.debounce.300ms="search()" 374 + @blur.debounce.200ms="showSuggestions = false" 375 + @focus="if (suggestions.length > 0) showSuggestions = true" 376 + autocomplete="off" 377 + /> 378 + <input type="hidden" name="source_ref" x-model="sourceRef"/> 379 + <template x-if="showSuggestions && suggestions.length > 0"> 380 + <div class="suggestions-dropdown"> 381 + <template x-for="s in suggestions" :key="s.source_uri"> 382 + <button 383 + type="button" 384 + class="suggestions-item" 385 + @mousedown.prevent="selectBrewerSuggestion(s)" 386 + > 387 + <span class="font-medium" x-text="s.name"></span> 388 + <template x-if="s.fields.brewerType"> 389 + <span class="text-xs text-brown-500" x-text="s.fields.brewerType"></span> 475 390 </template> 476 - </div> 391 + <template x-if="s.count > 1"> 392 + <span class="text-xs text-brown-400" x-text="s.count + ' users'"></span> 393 + </template> 394 + </button> 477 395 </template> 478 396 </div> 479 - } else { 480 - <input 481 - type="text" 482 - name="name" 483 - value={ getStringValue(brewer, "name") } 484 - placeholder="Name *" 485 - required 486 - class="w-full form-input" 487 - /> 488 - } 489 - <select 490 - name="brewer_type" 491 - class="w-full form-select" 492 - > 493 - <option value="">Select type...</option> 494 - <option 495 - value="pourover" 496 - if getStringValue(brewer, "brewer_type") == "pourover" || models.NormalizeBrewerType(getStringValue(brewer, "brewer_type")) == "pourover" { 497 - selected 498 - } 499 - >Pour-over</option> 500 - <option 501 - value="espresso" 502 - if getStringValue(brewer, "brewer_type") == "espresso" || models.NormalizeBrewerType(getStringValue(brewer, "brewer_type")) == "espresso" { 503 - selected 504 - } 505 - >Espresso</option> 506 - <option 507 - value="immersion" 508 - if getStringValue(brewer, "brewer_type") == "immersion" || models.NormalizeBrewerType(getStringValue(brewer, "brewer_type")) == "immersion" { 509 - selected 510 - } 511 - >Immersion</option> 512 - <option 513 - value="mokapot" 514 - if getStringValue(brewer, "brewer_type") == "mokapot" || models.NormalizeBrewerType(getStringValue(brewer, "brewer_type")) == "mokapot" { 515 - selected 516 - } 517 - >Moka Pot</option> 518 - <option 519 - value="coldbrew" 520 - if getStringValue(brewer, "brewer_type") == "coldbrew" || models.NormalizeBrewerType(getStringValue(brewer, "brewer_type")) == "coldbrew" { 521 - selected 522 - } 523 - >Cold Brew</option> 524 - <option 525 - value="cupping" 526 - if getStringValue(brewer, "brewer_type") == "cupping" || models.NormalizeBrewerType(getStringValue(brewer, "brewer_type")) == "cupping" { 527 - selected 528 - } 529 - >Cupping</option> 530 - <option 531 - value="other" 532 - if getStringValue(brewer, "brewer_type") == "other" || models.NormalizeBrewerType(getStringValue(brewer, "brewer_type")) == "other" { 533 - selected 534 - } 535 - >Other</option> 536 - </select> 397 + </template> 537 398 </div> 538 - <div class="form-divider"></div> 539 - <!-- Description --> 540 - <div class="form-fieldset"> 541 - <div class="form-fieldset-label">Description <span class="form-optional-hint">(optional)</span></div> 542 - <textarea 543 - name="description" 544 - placeholder="Description" 545 - rows="3" 546 - class="w-full form-textarea" 547 - >{ getStringValue(brewer, "description") }</textarea> 548 - </div> 549 - <div class="flex gap-2 pt-2"> 550 - <button type="submit" class="flex-1 btn-primary"> 551 - Save 552 - </button> 553 - <button 554 - type="button" 555 - @click="$el.closest('dialog').close()" 556 - class="flex-1 btn-secondary" 557 - > 558 - Cancel 559 - </button> 560 - </div> 561 - </form> 399 + } else { 400 + <input 401 + type="text" 402 + name="name" 403 + value={ getStringValue(brewer, "name") } 404 + placeholder="Name *" 405 + required 406 + class="w-full form-input" 407 + /> 408 + } 409 + <select 410 + name="brewer_type" 411 + class="w-full form-select" 412 + > 413 + <option value="">Select type...</option> 414 + <option 415 + value="pourover" 416 + if getStringValue(brewer, "brewer_type") == "pourover" || models.NormalizeBrewerType(getStringValue(brewer, "brewer_type")) == "pourover" { 417 + selected 418 + } 419 + >Pour-over</option> 420 + <option 421 + value="espresso" 422 + if getStringValue(brewer, "brewer_type") == "espresso" || models.NormalizeBrewerType(getStringValue(brewer, "brewer_type")) == "espresso" { 423 + selected 424 + } 425 + >Espresso</option> 426 + <option 427 + value="immersion" 428 + if getStringValue(brewer, "brewer_type") == "immersion" || models.NormalizeBrewerType(getStringValue(brewer, "brewer_type")) == "immersion" { 429 + selected 430 + } 431 + >Immersion</option> 432 + <option 433 + value="mokapot" 434 + if getStringValue(brewer, "brewer_type") == "mokapot" || models.NormalizeBrewerType(getStringValue(brewer, "brewer_type")) == "mokapot" { 435 + selected 436 + } 437 + >Moka Pot</option> 438 + <option 439 + value="coldbrew" 440 + if getStringValue(brewer, "brewer_type") == "coldbrew" || models.NormalizeBrewerType(getStringValue(brewer, "brewer_type")) == "coldbrew" { 441 + selected 442 + } 443 + >Cold Brew</option> 444 + <option 445 + value="cupping" 446 + if getStringValue(brewer, "brewer_type") == "cupping" || models.NormalizeBrewerType(getStringValue(brewer, "brewer_type")) == "cupping" { 447 + selected 448 + } 449 + >Cupping</option> 450 + <option 451 + value="other" 452 + if getStringValue(brewer, "brewer_type") == "other" || models.NormalizeBrewerType(getStringValue(brewer, "brewer_type")) == "other" { 453 + selected 454 + } 455 + >Other</option> 456 + </select> 457 + </div> 458 + <div class="form-divider"></div> 459 + <!-- Description --> 460 + <div class="form-fieldset"> 461 + <div class="form-fieldset-label">Description <span class="form-optional-hint">(optional)</span></div> 462 + <textarea 463 + name="description" 464 + placeholder="Description" 465 + rows="3" 466 + class="w-full form-textarea" 467 + >{ getStringValue(brewer, "description") }</textarea> 562 468 </div> 563 - </dialog> 469 + } 564 470 } 565 471 566 472 // RoasterDialogModal renders the roaster creation/edit modal using native <dialog> 567 473 templ RoasterDialogModal(roaster *models.Roaster) { 568 - <dialog id="entity-modal" class="modal-dialog"> 569 - <div class="modal-content"> 570 - <h3 class="modal-title"> 571 - if roaster != nil { 572 - Edit Roaster 573 - } else { 574 - Add Roaster 575 - } 576 - </h3> 577 - <form 578 - if roaster != nil { 579 - hx-put={ "/api/roasters/" + roaster.RKey } 580 - } else { 581 - hx-post="/api/roasters" 582 - } 583 - hx-trigger="submit" 584 - hx-swap="none" 585 - x-data="{ serverError: '' }" 586 - hx-on::after-request="if(event.detail.successful) { this.closest('dialog').close(); htmx.trigger('body', 'refreshManage'); } else if(event.detail.xhr && event.detail.xhr.status === 401) { this.closest('dialog').close(); window.__showSessionExpiredModal(); } else { this._x_dataStack[0].serverError = event.detail.xhr ? 'Something went wrong. Please try again.' : 'Connection error. Check your network.'; }" 587 - class="space-y-5" 588 - > 589 - <div x-show="serverError" x-cloak class="bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-800" x-text="serverError"></div> 590 - <!-- Essentials --> 591 - <div class="form-fieldset"> 592 - <div class="form-fieldset-label">Essentials</div> 593 - if roaster == nil { 594 - <div x-data="entitySuggest('/api/suggestions/roasters')" class="relative"> 595 - <input 596 - type="text" 597 - name="name" 598 - placeholder="Name *" 599 - required 600 - class="w-full form-input" 601 - x-model="query" 602 - @input.debounce.300ms="search()" 603 - @blur.debounce.200ms="showSuggestions = false" 604 - @focus="if (suggestions.length > 0) showSuggestions = true" 605 - autocomplete="off" 606 - /> 607 - <input type="hidden" name="source_ref" x-model="sourceRef"/> 608 - <template x-if="showSuggestions && suggestions.length > 0"> 609 - <div class="suggestions-dropdown"> 610 - <template x-for="s in suggestions" :key="s.source_uri"> 611 - <button 612 - type="button" 613 - class="suggestions-item" 614 - @mousedown.prevent="selectRoasterSuggestion(s)" 615 - > 616 - <span class="font-medium" x-text="s.name"></span> 617 - <template x-if="s.fields.location"> 618 - <span class="text-xs text-brown-500" x-text="s.fields.location"></span> 619 - </template> 620 - <template x-if="s.count > 1"> 621 - <span class="text-xs text-brown-400" x-text="s.count + ' users'"></span> 622 - </template> 623 - </button> 624 - </template> 625 - </div> 626 - </template> 627 - </div> 628 - } else { 629 - <input 630 - type="text" 631 - name="name" 632 - value={ getStringValue(roaster, "name") } 633 - placeholder="Name *" 634 - required 635 - class="w-full form-input" 636 - /> 637 - } 638 - </div> 639 - <div class="form-divider"></div> 640 - <!-- Details --> 641 - <div class="form-fieldset"> 642 - <div class="form-fieldset-label">Details <span class="form-optional-hint">(optional)</span></div> 474 + @ModalShell(ModalShellProps{ 475 + Type: lexicons.RecordTypeRoaster, 476 + RKey: modalEntityRKey(roaster), 477 + }) { 478 + <!-- Essentials --> 479 + <div class="form-fieldset"> 480 + <div class="form-fieldset-label">Essentials</div> 481 + if roaster == nil { 482 + <div x-data="entitySuggest('/api/suggestions/roasters')" class="relative"> 643 483 <input 644 484 type="text" 645 - name="location" 646 - value={ getStringValue(roaster, "location") } 647 - placeholder="Location" 648 - class="w-full form-input" 649 - /> 650 - <input 651 - type="url" 652 - name="website" 653 - value={ getStringValue(roaster, "website") } 654 - placeholder="Website" 485 + name="name" 486 + placeholder="Name *" 487 + required 655 488 class="w-full form-input" 489 + x-model="query" 490 + @input.debounce.300ms="search()" 491 + @blur.debounce.200ms="showSuggestions = false" 492 + @focus="if (suggestions.length > 0) showSuggestions = true" 493 + autocomplete="off" 656 494 /> 495 + <input type="hidden" name="source_ref" x-model="sourceRef"/> 496 + <template x-if="showSuggestions && suggestions.length > 0"> 497 + <div class="suggestions-dropdown"> 498 + <template x-for="s in suggestions" :key="s.source_uri"> 499 + <button 500 + type="button" 501 + class="suggestions-item" 502 + @mousedown.prevent="selectRoasterSuggestion(s)" 503 + > 504 + <span class="font-medium" x-text="s.name"></span> 505 + <template x-if="s.fields.location"> 506 + <span class="text-xs text-brown-500" x-text="s.fields.location"></span> 507 + </template> 508 + <template x-if="s.count > 1"> 509 + <span class="text-xs text-brown-400" x-text="s.count + ' users'"></span> 510 + </template> 511 + </button> 512 + </template> 513 + </div> 514 + </template> 657 515 </div> 658 - <div class="flex gap-2 pt-2"> 659 - <button type="submit" class="flex-1 btn-primary"> 660 - Save 661 - </button> 662 - <button 663 - type="button" 664 - @click="$el.closest('dialog').close()" 665 - class="flex-1 btn-secondary" 666 - > 667 - Cancel 668 - </button> 669 - </div> 670 - </form> 516 + } else { 517 + <input 518 + type="text" 519 + name="name" 520 + value={ getStringValue(roaster, "name") } 521 + placeholder="Name *" 522 + required 523 + class="w-full form-input" 524 + /> 525 + } 671 526 </div> 672 - </dialog> 527 + <div class="form-divider"></div> 528 + <!-- Details --> 529 + <div class="form-fieldset"> 530 + <div class="form-fieldset-label">Details <span class="form-optional-hint">(optional)</span></div> 531 + <input 532 + type="text" 533 + name="location" 534 + value={ getStringValue(roaster, "location") } 535 + placeholder="Location" 536 + class="w-full form-input" 537 + /> 538 + <input 539 + type="url" 540 + name="website" 541 + value={ getStringValue(roaster, "website") } 542 + placeholder="Website" 543 + class="w-full form-input" 544 + /> 545 + </div> 546 + } 673 547 } 674 548 675 549 // recipePourJSON returns a JSON array string of existing pours for Alpine.js init. ··· 686 560 687 561 // RecipeDialogModal renders the recipe creation/edit modal using native <dialog> 688 562 templ RecipeDialogModal(recipe *models.Recipe, brewers []models.Brewer) { 689 - <dialog id="entity-modal" class="modal-dialog"> 690 - <div class="modal-content"> 691 - <h3 class="modal-title"> 692 - if recipe != nil { 693 - Edit Recipe 694 - } else { 695 - Add Recipe 696 - } 697 - </h3> 698 - <form 699 - if recipe != nil { 700 - hx-put={ "/api/recipes/" + recipe.RKey } 701 - } else { 702 - hx-post="/api/recipes" 703 - } 704 - hx-trigger="submit" 705 - hx-swap="none" 706 - hx-on::after-request="if(event.detail.successful) { this.closest('dialog').close(); htmx.trigger('body', 'refreshManage'); } else { this._x_dataStack[0].serverError = event.detail.xhr ? 'Something went wrong. Please try again.' : 'Connection error. Check your network.'; }" 707 - class="space-y-5" 708 - x-data={ fmt.Sprintf("{ serverError: '', pours: %s, addPour() { this.pours.push({water: '', time: ''}); }, removePour(i) { this.pours.splice(i, 1); } }", recipePourJSON(recipe)) } 709 - > 710 - <div x-show="serverError" x-cloak class="bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-800" x-text="serverError"></div> 711 - <!-- Essentials --> 563 + @ModalShell(ModalShellProps{ 564 + Type: lexicons.RecordTypeRecipe, 565 + RKey: modalEntityRKey(recipe), 566 + ExtraXData: fmt.Sprintf("{ serverError: '', pours: %s, addPour() { this.pours.push({water: '', time: ''}); }, removePour(i) { this.pours.splice(i, 1); } }", recipePourJSON(recipe)), 567 + }) { 568 + <!-- Essentials --> 712 569 <div class="form-fieldset"> 713 570 <div class="form-fieldset-label">Essentials</div> 714 571 <input ··· 824 681 class="w-full form-textarea" 825 682 >{ getStringValue(recipe, "notes") }</textarea> 826 683 </div> 827 - <div class="flex gap-2 pt-2"> 828 - <button type="submit" class="flex-1 btn-primary"> 829 - Save 830 - </button> 831 - <button 832 - type="button" 833 - @click="$el.closest('dialog').close()" 834 - class="flex-1 btn-secondary" 835 - > 836 - Cancel 837 - </button> 838 - </div> 839 - </form> 840 - </div> 841 - </dialog> 684 + } 842 685 } 843 686 844 687 // roasterPickerInit generates Alpine.js x-data for the inline roaster picker. ··· 931 774 return "{ showRating: false, rating: 0 }" 932 775 } 933 776 934 - // Helper function to get string value from bean (handles nil case) 935 777 func getStringValue(entity interface{}, field string) string { 936 778 if entity == nil { 937 779 return "" 938 780 } 781 + rt := recordTypeOf(entity) 782 + d := entities.Get(rt) 783 + if d == nil || d.GetField == nil { 784 + return "" 785 + } 786 + v, _ := d.GetField(entity, field) 787 + return v 788 + } 939 789 790 + // modalEntityRKey extracts the record key from any of the typed model 791 + // pointers used as modal arguments. Returns "" for nil or unknown types, 792 + // which the modal shell interprets as "create" (POST) instead of "edit". 793 + func modalEntityRKey(entity any) string { 940 794 switch e := entity.(type) { 941 795 case *models.Bean: 942 - if e == nil { 943 - return "" 796 + if e != nil { 797 + return e.RKey 944 798 } 945 - switch field { 946 - case "name": 947 - return e.Name 948 - case "origin": 949 - return e.Origin 950 - case "variety": 951 - return e.Variety 952 - case "process": 953 - return e.Process 954 - case "description": 955 - return e.Description 799 + case *models.Roaster: 800 + if e != nil { 801 + return e.RKey 956 802 } 957 803 case *models.Grinder: 958 - if e == nil { 959 - return "" 960 - } 961 - switch field { 962 - case "name": 963 - return e.Name 964 - case "notes": 965 - return e.Notes 804 + if e != nil { 805 + return e.RKey 966 806 } 967 807 case *models.Brewer: 968 - if e == nil { 969 - return "" 970 - } 971 - switch field { 972 - case "name": 973 - return e.Name 974 - case "brewer_type": 975 - return e.BrewerType 976 - case "description": 977 - return e.Description 978 - } 979 - case *models.Roaster: 980 - if e == nil { 981 - return "" 982 - } 983 - switch field { 984 - case "name": 985 - return e.Name 986 - case "location": 987 - return e.Location 988 - case "website": 989 - return e.Website 808 + if e != nil { 809 + return e.RKey 990 810 } 991 811 case *models.Recipe: 992 - if e == nil { 993 - return "" 994 - } 995 - switch field { 996 - case "name": 997 - return e.Name 998 - case "brewer_type": 999 - return e.BrewerType 1000 - case "notes": 1001 - return e.Notes 1002 - case "coffee_amount": 1003 - if e.CoffeeAmount > 0 { 1004 - return fmt.Sprintf("%.1f", e.CoffeeAmount) 1005 - } 1006 - return "" 1007 - case "water_amount": 1008 - if e.WaterAmount > 0 { 1009 - return fmt.Sprintf("%.1f", e.WaterAmount) 1010 - } 1011 - return "" 812 + if e != nil { 813 + return e.RKey 1012 814 } 1013 815 } 816 + return "" 817 + } 1014 818 819 + // recordTypeOf maps a typed model pointer to its RecordType. Local helper 820 + // because callers here hold a typed pointer; moving this onto the model 821 + // is deferred to phase 4. 822 + func recordTypeOf(entity any) lexicons.RecordType { 823 + switch entity.(type) { 824 + case *models.Bean: 825 + return lexicons.RecordTypeBean 826 + case *models.Roaster: 827 + return lexicons.RecordTypeRoaster 828 + case *models.Grinder: 829 + return lexicons.RecordTypeGrinder 830 + case *models.Brewer: 831 + return lexicons.RecordTypeBrewer 832 + case *models.Recipe: 833 + return lexicons.RecordTypeRecipe 834 + } 1015 835 return "" 1016 836 }
+69
internal/web/components/modal_shell.templ
··· 1 + package components 2 + 3 + import ( 4 + "tangled.org/arabica.social/arabica/internal/entities" 5 + "tangled.org/arabica.social/arabica/internal/lexicons" 6 + ) 7 + 8 + // ModalShellProps configures the shared dialog wrapper used by entity 9 + // create/edit modals. Body content is passed as templ children. 10 + type ModalShellProps struct { 11 + Type lexicons.RecordType 12 + RKey string // "" = create, non-empty = edit 13 + ExtraXData string // optional Alpine x-data; if empty, "{ serverError: '' }" is used 14 + } 15 + 16 + func modalShellXData(extra string) string { 17 + if extra != "" { 18 + return extra 19 + } 20 + return "{ serverError: '' }" 21 + } 22 + 23 + func modalActionURL(d *entities.Descriptor, rkey string) string { 24 + if rkey != "" { 25 + return "/api/" + d.URLPath + "/" + rkey 26 + } 27 + return "/api/" + d.URLPath 28 + } 29 + 30 + templ ModalShell(props ModalShellProps) { 31 + if d := entities.Get(props.Type); d != nil { 32 + <dialog id="entity-modal" class="modal-dialog"> 33 + <div class="modal-content"> 34 + <h3 class="modal-title"> 35 + if props.RKey != "" { 36 + Edit { d.DisplayName } 37 + } else { 38 + Add { d.DisplayName } 39 + } 40 + </h3> 41 + <form 42 + if props.RKey != "" { 43 + hx-put={ modalActionURL(d, props.RKey) } 44 + } else { 45 + hx-post={ modalActionURL(d, "") } 46 + } 47 + hx-trigger="submit" 48 + hx-swap="none" 49 + x-data={ modalShellXData(props.ExtraXData) } 50 + hx-on::after-request="if(event.detail.successful) { this.closest('dialog').close(); htmx.trigger('body', 'refreshManage'); } else if(event.detail.xhr && event.detail.xhr.status === 401) { this.closest('dialog').close(); window.__showSessionExpiredModal(); } else { this._x_dataStack[0].serverError = event.detail.xhr ? 'Something went wrong. Please try again.' : 'Connection error. Check your network.'; }" 51 + class="space-y-5" 52 + > 53 + <div x-show="serverError" x-cloak class="bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-800" x-text="serverError"></div> 54 + { children... } 55 + <div class="flex gap-2 pt-2"> 56 + <button type="submit" class="flex-1 btn-primary">Save</button> 57 + <button 58 + type="button" 59 + @click="$el.closest('dialog').close()" 60 + class="flex-1 btn-secondary" 61 + > 62 + Cancel 63 + </button> 64 + </div> 65 + </form> 66 + </div> 67 + </dialog> 68 + } 69 + }
+3 -21
internal/web/pages/bean_view.templ
··· 3 3 import ( 4 4 "fmt" 5 5 "strings" 6 - "tangled.org/arabica.social/arabica/internal/firehose" 7 6 "tangled.org/arabica.social/arabica/internal/models" 8 7 "tangled.org/arabica.social/arabica/internal/web/bff" 9 8 "tangled.org/arabica.social/arabica/internal/web/components" 10 9 ) 11 10 12 11 type BeanViewProps struct { 13 - Bean *models.Bean 14 - IsOwnProfile bool 15 - IsAuthenticated bool 16 - SubjectURI string 17 - SubjectCID string 18 - IsLiked bool 19 - LikeCount int 20 - CommentCount int 21 - Comments []firehose.IndexedComment 22 - CurrentUserDID string 23 - ShareURL string 24 - IsModerator bool 25 - CanHideRecord bool 26 - CanBlockUser bool 27 - IsRecordHidden bool 28 - AuthorDID string 29 - AuthorHandle string 30 - AuthorDisplayName string 31 - AuthorAvatar string 32 - BrewCount int 12 + Bean *models.Bean 13 + BrewCount int 14 + EntityViewBase 33 15 } 34 16 35 17 templ BeanView(layout *components.LayoutData, props BeanViewProps) {
+3 -21
internal/web/pages/brewer_view.templ
··· 3 3 import ( 4 4 "fmt" 5 5 6 - "tangled.org/arabica.social/arabica/internal/firehose" 7 6 "tangled.org/arabica.social/arabica/internal/models" 8 7 "tangled.org/arabica.social/arabica/internal/web/bff" 9 8 "tangled.org/arabica.social/arabica/internal/web/components" 10 9 ) 11 10 12 11 type BrewerViewProps struct { 13 - Brewer *models.Brewer 14 - IsOwnProfile bool 15 - IsAuthenticated bool 16 - SubjectURI string 17 - SubjectCID string 18 - IsLiked bool 19 - LikeCount int 20 - CommentCount int 21 - Comments []firehose.IndexedComment 22 - CurrentUserDID string 23 - ShareURL string 24 - IsModerator bool 25 - CanHideRecord bool 26 - CanBlockUser bool 27 - IsRecordHidden bool 28 - AuthorDID string 29 - AuthorHandle string 30 - AuthorDisplayName string 31 - AuthorAvatar string 32 - BrewCount int 12 + Brewer *models.Brewer 13 + BrewCount int 14 + EntityViewBase 33 15 } 34 16 35 17 templ BrewerView(layout *components.LayoutData, props BrewerViewProps) {
+27
internal/web/pages/entity_view_base.go
··· 1 + package pages 2 + 3 + import "tangled.org/arabica.social/arabica/internal/firehose" 4 + 5 + // EntityViewBase holds the social and auth fields shared by all simple entity 6 + // view pages (bean, roaster, grinder, brewer). Embed this in XxxViewProps. 7 + // Fields are promoted so templ pages access them as props.IsAuthenticated etc. 8 + type EntityViewBase struct { 9 + IsOwnProfile bool 10 + IsAuthenticated bool 11 + SubjectURI string 12 + SubjectCID string 13 + IsLiked bool 14 + LikeCount int 15 + CommentCount int 16 + Comments []firehose.IndexedComment 17 + CurrentUserDID string 18 + ShareURL string 19 + IsModerator bool 20 + CanHideRecord bool 21 + CanBlockUser bool 22 + IsRecordHidden bool 23 + AuthorDID string 24 + AuthorHandle string 25 + AuthorDisplayName string 26 + AuthorAvatar string 27 + }
+22 -143
internal/web/pages/feed.templ
··· 2 2 3 3 import ( 4 4 "fmt" 5 + "tangled.org/arabica.social/arabica/internal/entities" 5 6 "tangled.org/arabica.social/arabica/internal/feed" 6 7 "tangled.org/arabica.social/arabica/internal/lexicons" 7 8 "tangled.org/arabica.social/arabica/internal/web/bff" ··· 287 288 288 289 // ActionText renders the action text with clickable links for record names 289 290 templ ActionText(item *feed.FeedItem) { 290 - switch item.RecordType { 291 - case lexicons.RecordTypeBrew: 292 - if item.Brew != nil { 293 - added a 294 - <a href={ templ.SafeURL(getFeedItemShareURL(item)) } class="underline hover:text-brown-900">new brew</a> 295 - { " " } 296 - @components.TypeBadge("brew") 297 - } else { 298 - { item.Action } 299 - } 300 - case lexicons.RecordTypeBean: 301 - if item.Bean != nil { 302 - added a 303 - <a href={ templ.SafeURL(getFeedItemShareURL(item)) } class="underline hover:text-brown-900">new bean</a> 304 - { " " } 305 - @components.TypeBadge("bean") 306 - } else { 307 - { item.Action } 308 - } 309 - case lexicons.RecordTypeRoaster: 310 - if item.Roaster != nil { 311 - added a 312 - <a href={ templ.SafeURL(getFeedItemShareURL(item)) } class="underline hover:text-brown-900">new roaster</a> 313 - { " " } 314 - @components.TypeBadge("roaster") 315 - } else { 316 - { item.Action } 317 - } 318 - case lexicons.RecordTypeGrinder: 319 - if item.Grinder != nil { 320 - added a 321 - <a href={ templ.SafeURL(getFeedItemShareURL(item)) } class="underline hover:text-brown-900">new grinder</a> 322 - { " " } 323 - @components.TypeBadge("grinder") 324 - } else { 325 - { item.Action } 326 - } 327 - case lexicons.RecordTypeBrewer: 328 - if item.Brewer != nil { 329 - added a 330 - <a href={ templ.SafeURL(getFeedItemShareURL(item)) } class="underline hover:text-brown-900">new brewer</a> 331 - { " " } 332 - @components.TypeBadge("brewer") 333 - } else { 334 - { item.Action } 335 - } 336 - case lexicons.RecordTypeRecipe: 337 - if item.Recipe != nil { 338 - added a 339 - <a href={ templ.SafeURL(getFeedItemShareURL(item)) } class="underline hover:text-brown-900">new recipe</a> 340 - { " " } 341 - @components.TypeBadge("recipe") 342 - } else { 343 - { item.Action } 344 - } 345 - default: 346 - { item.Action } 291 + if d := entities.Get(item.RecordType); d != nil && item.Record() != nil { 292 + added a 293 + <a href={ templ.SafeURL(getFeedItemShareURL(item)) } class="underline hover:text-brown-900">new { d.Noun }</a> 294 + { " " } 295 + @components.TypeBadge(d.Noun) 296 + } else { 297 + { item.Action } 347 298 } 348 299 } 349 300 ··· 518 469 519 470 // Helper functions for share button 520 471 func getFeedItemShareURL(item *feed.FeedItem) string { 521 - switch item.RecordType { 522 - case lexicons.RecordTypeBrew: 523 - if item.Brew != nil { 524 - return fmt.Sprintf("/brews/%s?owner=%s", item.Brew.RKey, item.Author.DID) 525 - } 526 - case lexicons.RecordTypeBean: 527 - if item.Bean != nil { 528 - return fmt.Sprintf("/beans/%s?owner=%s", item.Bean.RKey, item.Author.DID) 529 - } 530 - case lexicons.RecordTypeRoaster: 531 - if item.Roaster != nil { 532 - return fmt.Sprintf("/roasters/%s?owner=%s", item.Roaster.RKey, item.Author.DID) 533 - } 534 - case lexicons.RecordTypeGrinder: 535 - if item.Grinder != nil { 536 - return fmt.Sprintf("/grinders/%s?owner=%s", item.Grinder.RKey, item.Author.DID) 537 - } 538 - case lexicons.RecordTypeBrewer: 539 - if item.Brewer != nil { 540 - return fmt.Sprintf("/brewers/%s?owner=%s", item.Brewer.RKey, item.Author.DID) 541 - } 542 - case lexicons.RecordTypeRecipe: 543 - if item.Recipe != nil { 544 - return fmt.Sprintf("/recipes/%s?owner=%s", item.Recipe.RKey, item.Author.DID) 472 + if d := entities.Get(item.RecordType); d != nil { 473 + if rkey := item.RKey(); rkey != "" { 474 + return fmt.Sprintf("/%s/%s?owner=%s", d.URLPath, rkey, item.Author.DID) 545 475 } 546 476 } 547 477 return fmt.Sprintf("/profile/%s", item.Author.DID) 548 478 } 549 479 550 480 func getFeedItemShareTitle(item *feed.FeedItem) string { 551 - switch item.RecordType { 552 - case lexicons.RecordTypeBrew: 553 - if item.Brew != nil && item.Brew.Bean != nil { 554 - if item.Brew.Bean.Name != "" { 555 - return item.Brew.Bean.Name 556 - } 557 - return item.Brew.Bean.Origin 558 - } 559 - return "Coffee Brew" 560 - case lexicons.RecordTypeBean: 561 - if item.Bean != nil { 562 - if item.Bean.Name != "" { 563 - return item.Bean.Name 564 - } 565 - return item.Bean.Origin 566 - } 567 - return "Coffee Bean" 568 - case lexicons.RecordTypeRoaster: 569 - if item.Roaster != nil { 570 - return item.Roaster.Name 571 - } 572 - return "Roaster" 573 - case lexicons.RecordTypeGrinder: 574 - if item.Grinder != nil { 575 - return item.Grinder.Name 576 - } 577 - return "Grinder" 578 - case lexicons.RecordTypeBrewer: 579 - if item.Brewer != nil { 580 - return item.Brewer.Name 581 - } 582 - return "Brewer" 583 - case lexicons.RecordTypeRecipe: 584 - if item.Recipe != nil { 585 - return item.Recipe.Name 586 - } 587 - return "Recipe" 481 + if title := item.DisplayTitle(); title != "" { 482 + return title 588 483 } 589 484 return "Arabica" 590 485 } ··· 611 506 612 507 // getDeleteURL returns the delete URL for a feed item 613 508 func getDeleteURL(item *feed.FeedItem) string { 614 - switch item.RecordType { 615 - case lexicons.RecordTypeBrew: 616 - if item.Brew != nil { 617 - return fmt.Sprintf("/brews/%s", item.Brew.RKey) 618 - } 619 - case lexicons.RecordTypeBean: 620 - if item.Bean != nil { 621 - return fmt.Sprintf("/api/beans/%s", item.Bean.RKey) 622 - } 623 - case lexicons.RecordTypeRoaster: 624 - if item.Roaster != nil { 625 - return fmt.Sprintf("/api/roasters/%s", item.Roaster.RKey) 626 - } 627 - case lexicons.RecordTypeGrinder: 628 - if item.Grinder != nil { 629 - return fmt.Sprintf("/api/grinders/%s", item.Grinder.RKey) 630 - } 631 - case lexicons.RecordTypeBrewer: 632 - if item.Brewer != nil { 633 - return fmt.Sprintf("/api/brewers/%s", item.Brewer.RKey) 634 - } 635 - case lexicons.RecordTypeRecipe: 636 - if item.Recipe != nil { 637 - return fmt.Sprintf("/api/recipes/%s", item.Recipe.RKey) 638 - } 509 + rkey := item.RKey() 510 + if rkey == "" { 511 + return "" 512 + } 513 + if item.RecordType == lexicons.RecordTypeBrew { 514 + return fmt.Sprintf("/brews/%s", rkey) 515 + } 516 + if d := entities.Get(item.RecordType); d != nil { 517 + return fmt.Sprintf("/api/%s/%s", d.URLPath, rkey) 639 518 } 640 519 return "" 641 520 }
+3 -21
internal/web/pages/grinder_view.templ
··· 3 3 import ( 4 4 "fmt" 5 5 6 - "tangled.org/arabica.social/arabica/internal/firehose" 7 6 "tangled.org/arabica.social/arabica/internal/models" 8 7 "tangled.org/arabica.social/arabica/internal/web/bff" 9 8 "tangled.org/arabica.social/arabica/internal/web/components" 10 9 ) 11 10 12 11 type GrinderViewProps struct { 13 - Grinder *models.Grinder 14 - IsOwnProfile bool 15 - IsAuthenticated bool 16 - SubjectURI string 17 - SubjectCID string 18 - IsLiked bool 19 - LikeCount int 20 - CommentCount int 21 - Comments []firehose.IndexedComment 22 - CurrentUserDID string 23 - ShareURL string 24 - IsModerator bool 25 - CanHideRecord bool 26 - CanBlockUser bool 27 - IsRecordHidden bool 28 - AuthorDID string 29 - AuthorHandle string 30 - AuthorDisplayName string 31 - AuthorAvatar string 32 - BrewCount int 12 + Grinder *models.Grinder 13 + BrewCount int 14 + EntityViewBase 33 15 } 34 16 35 17 templ GrinderView(layout *components.LayoutData, props GrinderViewProps) {
+3 -21
internal/web/pages/roaster_view.templ
··· 3 3 import ( 4 4 "fmt" 5 5 6 - "tangled.org/arabica.social/arabica/internal/firehose" 7 6 "tangled.org/arabica.social/arabica/internal/models" 8 7 "tangled.org/arabica.social/arabica/internal/web/bff" 9 8 "tangled.org/arabica.social/arabica/internal/web/components" 10 9 ) 11 10 12 11 type RoasterViewProps struct { 13 - Roaster *models.Roaster 14 - IsOwnProfile bool 15 - IsAuthenticated bool 16 - SubjectURI string 17 - SubjectCID string 18 - IsLiked bool 19 - LikeCount int 20 - CommentCount int 21 - Comments []firehose.IndexedComment 22 - CurrentUserDID string 23 - ShareURL string 24 - IsModerator bool 25 - CanHideRecord bool 26 - CanBlockUser bool 27 - IsRecordHidden bool 28 - AuthorDID string 29 - AuthorHandle string 30 - AuthorDisplayName string 31 - AuthorAvatar string 32 - BeanCount int 12 + Roaster *models.Roaster 13 + BeanCount int 14 + EntityViewBase 33 15 } 34 16 35 17 templ RoasterView(layout *components.LayoutData, props RoasterViewProps) {

History

1 round 0 comments
sign up or login to add to the discussion
pdewey.com submitted #0
1 commit
expand
feat: add entity package for easier lexicon addition/editing
merge conflicts detected
expand
  • internal/web/components/layout.templ:115
  • internal/web/components/shared.templ:218
  • internal/web/pages/bean_view.templ:53
  • internal/web/pages/brew_view.templ:56
  • internal/web/pages/brewer_view.templ:53
  • internal/web/pages/grinder_view.templ:53
  • internal/web/pages/recipe_view.templ:66
  • internal/web/pages/roaster_view.templ:53
  • static/css/app.css:561
expand 0 comments