+3016
-1461
Diff
round #0
+210
docs/entity-descriptor-refactor.md
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
-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
-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
+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
+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
-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
-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
pdewey.com
submitted
#0
1 commit
expand
collapse
feat: add entity package for easier lexicon addition/editing
merge conflicts detected
expand
collapse
expand
collapse
- 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