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

Configure Feed

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

refactor: extract combo-select into shared component

+396 -715
+158 -704
CLAUDE.md
··· 1 - # Arabica - Project Context for AI Agents 1 + # CLAUDE.md 2 2 3 - Coffee brew tracking application using AT Protocol for decentralized storage. 3 + This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 4 5 - ## Work Management 5 + ## Project Overview 6 6 7 - This project uses **cells** for task tracking and coordination. Cells are 8 - atomic, dependency-aware units of work designed for coordination between humans 9 - and AI agents. 7 + Arabica is a coffee brew tracking application built on AT Protocol. User data 8 + (beans, brews, cafes, drinks, etc.) lives in each user's Personal Data Server 9 + (PDS), not locally. The app authenticates via OAuth, then performs CRUD through 10 + XRPC calls to the user's PDS. 10 11 11 - **For usage instructions, see:** `.cells/AGENTS.md` 12 + ## Build & Development Commands 12 13 13 - Quick reference for AI agents: 14 + ```bash 15 + # Run development server (debug logging, moderator config, known-dids backfill) 16 + just run 14 17 15 - - `./cells list` - View all active cells 16 - - `./cells list --status open` - Show available work 17 - - `./cells show <cell-id>` - View cell details 18 + # Run tests 19 + go test ./... 18 20 19 - **Note:** Do NOT use `./cells run` - this spawns a new agent session and should 20 - only be used by humans. AI agents should inspect cells using the commands above 21 - and perform work directly. 21 + # Run a single test 22 + go test ./internal/models/... -run TestBeanIsIncomplete 22 23 23 - All work items are tracked as cells. When starting new work, check for existing 24 - cells first. 24 + # After editing .templ files — regenerate Go code 25 + templ generate # all files 26 + templ generate -f <file> # single file 27 + 28 + # Rebuild Tailwind CSS (required after CSS/class changes) 29 + just style 30 + 31 + # Verify after changes 32 + go vet ./... 33 + go build ./... 34 + ``` 25 35 26 36 ## Workflow Rules 27 37 ··· 30 40 clarifying questions rather than endlessly reading the codebase. When given a 31 41 specific implementation task, produce code changes in the same session. 32 42 33 - ## Dependencies 43 + ## Work Management 34 44 35 - When implementing features, prefer standard library solutions over external 36 - dependencies. Only add a third-party dependency if the standard library 37 - genuinely cannot handle the requirement. For Go: check stdlib first (e.g., 38 - os.Stdout for TTY detection). For JS/TS: check built-in APIs before npm 39 - packages. 45 + This project uses **cells** for task tracking. See `.cells/AGENTS.md` for usage. 40 46 41 - ## Task Agents 42 - 43 - When spawning task agents, set a hard limit of 3 agents maximum. Each agent must 44 - have a clearly scoped deliverable and file output path. Do not poll agents in a 45 - loop—instead, give each agent its full instructions upfront and collect results 46 - at the end. If agents aren't producing results within 5 minutes, fall back to 47 - doing the work directly. 47 + - `./cells list` / `./cells list --status open` / `./cells show <cell-id>` 48 + - Do NOT use `./cells run` (spawns new agent session, humans only) 48 49 49 - ## Testing & Verification 50 + ## Dependencies 50 51 51 - For Go projects: always run `go vet ./...` and `go build ./...` after making 52 - changes. For JavaScript/CSS projects: verify template field names match backend 53 - struct fields before considering a task complete. Always test form submissions 54 - to verify content-type handling (JSON vs form-encoded). 52 + Prefer standard library solutions over external dependencies. Only add a 53 + third-party dependency if stdlib genuinely cannot handle the requirement. 55 54 56 - ### Using Go Tooling Effectively 55 + ## Task Agents 57 56 58 - - To see source files from a dependency, or to answer questions about a 59 - dependency, run `go mod download -json MODULE` and use the returned `Dir` path 60 - to read the files. 61 - - Use `go doc foo.Bar` or `go doc -all foo` to read documentation for packages, 62 - types, functions, etc. 63 - - Use `go run .` or `go run ./cmd/foo` instead of `go build` to run programs, to 64 - avoid leaving behind build artifacts. 57 + Hard limit of 3 agents maximum. Each agent must have a clearly scoped 58 + deliverable. Do not poll agents in a loop. If agents aren't producing results 59 + within 5 minutes, fall back to doing the work directly. 65 60 66 61 ## Tech Stack 67 62 68 - - **Language:** Go 1.21+ 69 - - **HTTP:** stdlib `net/http` with Go 1.22 routing 70 - - **Storage:** AT Protocol PDS (user data), BoltDB (sessions/feed registry) 63 + - **Language:** Go 1.21+, stdlib `net/http` with Go 1.22 routing 64 + - **Storage:** AT Protocol PDS (user data), BoltDB (sessions), SQLite (firehose index) 71 65 - **Frontend:** HTMX + Alpine.js + Tailwind CSS 72 - - **Templates:** Templ (type-safe Go templates) 66 + - **Templates:** [Templ](https://templ.guide/) (type-safe Go templates) 73 67 - **Logging:** zerolog 74 68 75 - ## Project Structure 76 - 77 - ``` 78 - cmd/server/main.go # Application entry point 79 - internal/ 80 - atproto/ # AT Protocol integration 81 - client.go # Authenticated PDS client (XRPC calls) 82 - oauth.go # OAuth flow with PKCE/DPOP 83 - store.go # database.Store implementation using PDS 84 - cache.go # Per-session in-memory cache 85 - records.go # Model <-> ATProto record conversion 86 - resolver.go # AT-URI parsing and reference resolution 87 - public_client.go # Unauthenticated public API access 88 - nsid.go # Collection NSIDs and AT-URI builders 89 - handlers/ 90 - handlers.go # HTTP handlers for all routes 91 - auth.go # OAuth login/logout/callback 92 - web/ 93 - bff/ 94 - helpers.go # View helpers (formatting, UserProfile) 95 - components/ # Reusable Templ components 96 - layout.templ # Base HTML layout component 97 - header.templ # Navigation header 98 - footer.templ # Site footer 99 - shared.templ # Shared UI components (EmptyState, PageHeader, etc.) 100 - buttons.templ # Button components 101 - forms.templ # Form input components 102 - modal.templ # Modal dialog components 103 - card.templ # Card container components 104 - entity_modals.templ # Entity creation/edit modals 105 - *_templ.go # Generated Go code (auto-generated by templ) 106 - pages/ # Full-page Templ components 107 - home.templ # Home page 108 - about.templ # About page 109 - brew_list.templ # Brew list page 110 - brew_view.templ # Brew detail page 111 - brew_form.templ # Brew creation/edit form 112 - manage.templ # Entity management page 113 - profile.templ # User profile page 114 - feed.templ # Community feed page 115 - *_templ.go # Generated Go code 116 - database/ 117 - store.go # Store interface definition 118 - boltstore/ # BoltDB implementation for sessions 119 - feed/ 120 - service.go # Community feed aggregation 121 - registry.go # User registration for feed 122 - moderation/ 123 - models.go # Moderation types (roles, permissions, reports) 124 - service.go # Role-based moderation service 125 - models/ 126 - models.go # Domain models and request types 127 - middleware/ 128 - logging.go # Request logging middleware 129 - routing/ 130 - routing.go # Router setup and middleware chain 131 - lexicons/ # AT Protocol lexicon definitions (JSON) 132 - config/ # Configuration files (moderators.json.example) 133 - static/ # CSS, JS, manifest 134 - ``` 135 - 136 - ## Key Concepts 69 + ## Architecture 137 70 138 71 ### AT Protocol Integration 139 72 140 - User data stored in their Personal Data Server (PDS), not locally. The app: 73 + 1. User authenticates via OAuth (indigo SDK handles PKCE/DPOP) 74 + 2. Handler creates `AtprotoStore` scoped to user's DID + session 75 + 3. Store methods make XRPC calls to user's PDS 76 + 4. Results rendered via Templ components or returned as JSON 141 77 142 - 1. Authenticates via OAuth (indigo SDK handles PKCE/DPOP) 143 - 2. Gets access token scoped to user's DID 144 - 3. Performs CRUD via XRPC calls to user's PDS 145 - 146 - **Collections (NSIDs):** 147 - 148 - - `social.arabica.alpha.bean` - Coffee beans 149 - - `social.arabica.alpha.roaster` - Roasters 150 - - `social.arabica.alpha.grinder` - Grinders 151 - - `social.arabica.alpha.brewer` - Brewing devices 152 - - `social.arabica.alpha.brew` - Brew sessions (references bean, grinder, brewer) 78 + **Collections (NSIDs)** — defined in `internal/atproto/nsid.go`: 153 79 154 - **Record keys:** TID format (timestamp-based identifiers) 80 + - `social.arabica.alpha.bean` — Coffee beans (references roaster) 81 + - `social.arabica.alpha.roaster` — Roasters 82 + - `social.arabica.alpha.grinder` — Grinders 83 + - `social.arabica.alpha.brewer` — Brewing devices 84 + - `social.arabica.alpha.cafe` — Cafes (references roaster) 85 + - `social.arabica.alpha.brew` — Brew sessions (references bean, grinder, brewer, recipe) 86 + - `social.arabica.alpha.drink` — Drinks at cafes (references cafe, bean) 87 + - `social.arabica.alpha.recipe` — Recipes (references brewer) 88 + - `social.arabica.alpha.like` — Likes (strongRef to any record) 89 + - `social.arabica.alpha.comment` — Comments (strongRef to any record, optional parent for threads) 155 90 156 - **References:** Records reference each other via AT-URIs 157 - (`at://did/collection/rkey`) 91 + Records reference each other via AT-URIs (`at://did/collection/rkey`). Record 92 + keys use TID format (timestamp-based identifiers). 158 93 159 94 ### Store Interface 160 95 161 - `internal/database/store.go` defines the `Store` interface. Two implementations: 162 - 163 - - `AtprotoStore` - Production, stores in user's PDS 164 - - BoltDB stores only sessions and feed registry (not user data) 165 - 166 - All Store methods take `context.Context` as first parameter. 167 - 168 - ### Request Flow 169 - 170 - 1. Request hits middleware (logging, auth check) 171 - 2. Auth middleware extracts DID + session ID from cookies 172 - 3. Handler creates `AtprotoStore` scoped to user 173 - 4. Store methods make XRPC calls to user's PDS 174 - 5. Results rendered via Templ components or returned as JSON 175 - 176 - ### Caching 177 - 178 - `SessionCache` caches user data in memory (5-minute TTL): 179 - 180 - - Avoids repeated PDS calls for same data 181 - - Invalidated on writes 182 - - Background cleanup removes expired entries 183 - 184 - ### Backfill Strategy 185 - 186 - User records are backfilled from their PDS once per DID: 187 - 188 - - **On startup**: Backfills registered users + known-dids file 189 - - **On first login**: Backfills the user's historical records 190 - - **Deduplication**: Tracks backfilled DIDs in `BucketBackfilled` to prevent 191 - redundant fetches 192 - - **Idempotent**: Safe to call multiple times (checks backfill status first) 193 - 194 - This prevents excessive PDS requests while ensuring new users' historical data 195 - is indexed. 196 - 197 - ## Templ Architecture 198 - 199 - The application uses [Templ](https://templ.guide/) for type-safe, 200 - component-based HTML templating. Templ generates Go code at compile time, 201 - providing full type safety and IDE support. 202 - 203 - ### Component Structure 204 - 205 - Components are organized into two categories: 206 - 207 - **Pages** (`internal/web/pages/`) - Full-page components that compose the layout 208 - with content: 209 - 210 - - Each page component accepts `LayoutData` and page-specific props 211 - - Pattern: `PageName(layoutData *components.LayoutData, props PageProps)` 212 - - Examples: `Home()`, `BrewList()`, `BrewView()`, `Profile()` 213 - 214 - **Components** (`internal/web/components/`) - Reusable UI building blocks: 215 - 216 - - `layout.templ` - Base HTML layout with head, body, header, footer 217 - - `shared.templ` - Common UI elements (EmptyState, PageHeader, 218 - LoadingSkeletonTable, etc.) 219 - - `buttons.templ` - PrimaryButton, SecondaryButton, BackButton 220 - - `forms.templ` - TextInput, NumberInput, TextArea, Select, FormField 221 - - `modal.templ` - Modal dialogs with Alpine.js integration 222 - - `card.templ` - Card containers 223 - - `entity_modals.templ` - Entity creation/edit forms 224 - 225 - ### LayoutData Pattern 226 - 227 - Every page shares a common `LayoutData` struct that contains authentication 228 - state and user information: 229 - 230 - ```go 231 - type LayoutData struct { 232 - Title string 233 - IsAuthenticated bool 234 - UserDID string 235 - UserProfile *bff.UserProfile 236 - CSPNonce string 237 - } 238 - ``` 239 - 240 - This data flows from handlers to the layout component, ensuring consistent 241 - header/footer rendering across all pages. 242 - 243 - ### Handler Integration 244 - 245 - Handlers follow a consistent pattern: 246 - 247 - ```go 248 - func (h *Handler) HandlePageName(w http.ResponseWriter, r *http.Request) { 249 - // 1. Get authentication state and user data 250 - store, authenticated := h.getAtprotoStore(r) 251 - userProfile, _ := h.getUserProfile(r, store) 252 - 253 - // 2. Fetch page-specific data from store 254 - data, err := store.GetData(r.Context()) 255 - 256 - // 3. Create LayoutData 257 - layoutData := &components.LayoutData{ 258 - Title: "Page Title", 259 - IsAuthenticated: authenticated, 260 - UserDID: userDID, 261 - UserProfile: userProfile, 262 - CSPNonce: nonce, 263 - } 264 - 265 - // 4. Create page-specific props 266 - pageProps := pages.PageProps{ 267 - Data: data, 268 - // ... other props 269 - } 270 - 271 - // 5. Render templ component 272 - if err := pages.PageName(layoutData, pageProps).Render(r.Context(), w); err != nil { 273 - http.Error(w, "Failed to render page", http.StatusInternalServerError) 274 - } 275 - } 276 - ``` 277 - 278 - ### The Render(r.Context(), w) Pattern 279 - 280 - All templ components implement the `templ.Component` interface, which includes a 281 - `Render(ctx context.Context, w io.Writer)` method. This method: 282 - 283 - - Takes the request context for cancellation/timeout support 284 - - Writes directly to the http.ResponseWriter 285 - - Returns an error if rendering fails 286 - - Is type-safe at compile time 287 - 288 - Example: 289 - 290 - ```go 291 - if err := pages.Home(layoutData, homeProps).Render(r.Context(), w); err != nil { 292 - http.Error(w, "Failed to render page", http.StatusInternalServerError) 293 - } 294 - ``` 295 - 296 - ### Component Composition 297 - 298 - Templ components compose naturally using the `@` syntax: 299 - 300 - ```templ 301 - // Page component wraps layout 302 - templ Home(layout *components.LayoutData, props HomeProps) { 303 - @components.Layout(layout, HomeContent(props)) 304 - } 305 - 306 - // Content component uses shared components 307 - templ HomeContent(props HomeProps) { 308 - <div class="max-w-4xl mx-auto"> 309 - @components.WelcomeCard(components.WelcomeCardProps{ 310 - IsAuthenticated: props.IsAuthenticated, 311 - }) 312 - @CommunityFeedSection() 313 - </div> 314 - } 315 - ``` 316 - 317 - ### Component Reusability 318 - 319 - Shared components accept props structs for configuration: 320 - 321 - ```templ 322 - // EmptyState component with props 323 - templ EmptyState(props EmptyStateProps) { 324 - <div class="card card-inner text-center"> 325 - <p class="text-brown-800 text-lg mb-4 font-medium">{ props.Message }</p> 326 - if props.SubMessage != "" { 327 - <p class="text-sm text-brown-700 mb-4">{ props.SubMessage }</p> 328 - } 329 - if props.ActionURL != "" && props.ActionText != "" { 330 - <a href={ templ.SafeURL(props.ActionURL) } class="btn-primary"> 331 - { props.ActionText } 332 - </a> 333 - } 334 - </div> 335 - } 336 - ``` 337 - 338 - Used in pages: 339 - 340 - ```templ 341 - @components.EmptyState(components.EmptyStateProps{ 342 - Message: "No brews yet", 343 - SubMessage: "Start tracking your coffee journey", 344 - ActionURL: "/brews/new", 345 - ActionText: "Log Your First Brew", 346 - }) 347 - ``` 348 - 349 - ### HTMX Integration 350 - 351 - Templ works seamlessly with HTMX for dynamic content loading: 352 - 353 - ```templ 354 - // Page with HTMX loading 355 - <div hx-get="/api/brews" hx-trigger="load" hx-swap="innerHTML"> 356 - @LoadingSkeletonTable(LoadingSkeletonTableProps{Columns: 5, Rows: 3}) 357 - </div> 358 - ``` 359 - 360 - HTMX responses can render partial components: 361 - 362 - ```go 363 - func (h *Handler) HandleBrewsPartial(w http.ResponseWriter, r *http.Request) { 364 - brews, _ := store.ListBrews(r.Context()) 365 - if err := components.BrewListTable(brews).Render(r.Context(), w); err != nil { 366 - http.Error(w, "Failed to render", http.StatusInternalServerError) 367 - } 368 - } 369 - ``` 370 - 371 - ### Alpine.js Compatibility 372 - 373 - Alpine.js directives work natively in templ templates: 374 - 375 - ```templ 376 - <div x-data="{ open: false }"> 377 - <button @click="open = !open">Toggle</button> 378 - <div x-show="open" x-transition> 379 - Content 380 - </div> 381 - </div> 382 - ``` 383 - 384 - Modal components use Alpine.js for state management: 385 - 386 - ```templ 387 - @Modal(ModalProps{ 388 - Show: "showBeanForm", 389 - TitleExpr: "editingBean ? 'Edit Bean' : 'Add Bean'", 390 - }, FormContent()) 391 - ``` 392 - 393 - ### Type Safety Benefits 394 - 395 - Templ provides compile-time type checking: 396 - 397 - ```go 398 - // Props are strongly typed 399 - type BrewViewProps struct { 400 - Brew *models.Brew 401 - Bean *models.Bean 402 - Grinder *models.Grinder 403 - Brewer *models.Brewer 404 - IsOwner bool 405 - } 406 - 407 - // Compiler catches missing/wrong fields 408 - pages.BrewView(layoutData, pages.BrewViewProps{ 409 - Brew: brew, 410 - Bean: bean, 411 - IsOwner: isOwner, 412 - // Compile error: missing Grinder, Brewer fields 413 - }) 414 - ``` 415 - 416 - ## Common Tasks 417 - 418 - ### Run Development Server 419 - 420 - ```bash 421 - # Run server (uses firehose mode by default) 422 - go run cmd/server/main.go 423 - 424 - # Backfill known DIDs on startup 425 - go run cmd/server/main.go --known-dids known-dids.txt 426 - 427 - # Using nix 428 - nix run 429 - ``` 430 - 431 - ### Run Tests 432 - 433 - ```bash 434 - go test ./... 435 - ``` 436 - 437 - ### Build 438 - 439 - ```bash 440 - go build -o arabica cmd/server/main.go 441 - ``` 442 - 443 - ### Generate Templ Components 96 + `internal/database/store.go` defines the `Store` interface with CRUD methods for 97 + all entity types. `AtprotoStore` is the production implementation backed by the 98 + user's PDS with witness cache and session cache layers. 444 99 445 - When you modify `.templ` files, you need to regenerate the Go code: 100 + ### Three-Layer Caching 446 101 447 - ```bash 448 - # Generate Go code from .templ files 449 - templ generate 450 - 451 - # Or in Nix environment 452 - nix develop -c templ generate 453 - 454 - # Watch mode for development (auto-regenerate on changes) 455 - templ generate --watch 456 - ``` 457 - 458 - The generated `*_templ.go` should be regenerated whenever `.templ` files change. 459 - 460 - Templ files must use tabs rather than spaces. **Never use spaces for indentation 461 - in `.templ` files** — the templ parser will error with a parse failure. This 462 - applies when writing new components, editing existing ones, and constructing 463 - multi-line template strings. A post-edit hook runs `templ fmt` automatically to 464 - catch any accidental spaces. 102 + 1. **SessionCache** (`internal/atproto/cache.go`) — per-user in-memory cache 103 + (2-min TTL). Copy-on-write pattern, invalidated on writes. Dirty-collection 104 + tracking skips witness cache after local writes until firehose catches up. 465 105 466 - ## Command-Line Flags 106 + 2. **WitnessCache** (`internal/firehose/index.go`) — SQLite-backed local index 107 + populated by the Jetstream firehose consumer. Provides fast reads without PDS 108 + calls. Used as fallback when session cache misses. 467 109 468 - | Flag | Type | Default | Description | 469 - | -------------- | ------ | ------- | -------------------------------------------------- | 470 - | `--firehose` | bool | true | [DEPRECATED] Firehose is now the default (ignored) | 471 - | `--known-dids` | string | "" | Path to file with DIDs to backfill (one per line) | 110 + 3. **PDS fallback** — direct XRPC calls to the user's PDS when both caches miss. 472 111 473 - **Known DIDs File Format:** 112 + Write path: PDS write -> write-through to witness cache -> invalidate session 113 + cache (mark dirty). 474 114 475 - - One DID per line (e.g., `did:plc:abc123xyz`) 476 - - Lines starting with `#` are comments 477 - - Empty lines are ignored 478 - - See `known-dids.txt.example` for reference 115 + ### Firehose & Feed Pipeline 479 116 480 - ## Environment Variables 117 + `internal/firehose/` subscribes to AT Protocol's Jetstream relay for real-time 118 + events. Records are indexed into the SQLite feed index. The feed pipeline: 481 119 482 - | Variable | Default | Description | 483 - | ----------------------------- | ------------------------------------ | --------------------------------------------- | 484 - | `PORT` | 18910 | HTTP server port | 485 - | `SERVER_PUBLIC_URL` | - | Public URL for reverse proxy | 486 - | `ARABICA_DB_PATH` | ~/.local/share/arabica/arabica.db | BoltDB path (sessions, registry) | 487 - | `ARABICA_FEED_INDEX_PATH` | ~/.local/share/arabica/feed-index.db | Firehose index BoltDB path | 488 - | `ARABICA_MODERATORS_CONFIG` | - | Path to moderators JSON config | 489 - | `ARABICA_PROFILE_CACHE_TTL` | 1h | Profile cache duration | 490 - | `OTEL_EXPORTER_OTLP_ENDPOINT` | localhost:4318 | OTLP HTTP endpoint for traces | 491 - | `METRICS_PORT` | 9101 | Internal metrics server port (localhost only) | 492 - | `SECURE_COOKIES` | false | Set true for HTTPS | 493 - | `LOG_LEVEL` | info | debug/info/warn/error | 494 - | `LOG_FORMAT` | console | console/json | 120 + 1. **FeedIndex** (`firehose/index.go`) — SQLite store, `recordToFeedItem()` 121 + converts indexed records to `FeedItem` structs with resolved references. 122 + 2. **FeedIndexAdapter** (`firehose/adapter.go`) — converts firehose `FeedItem` 123 + to feed `FirehoseFeedItem` to avoid import cycles. 124 + 3. **Feed Service** (`feed/service.go`) — converts to `feed.FeedItem`, applies 125 + moderation filtering, caching, and pagination. 495 126 496 - ## Code Patterns 127 + When adding a new entity type, all three layers need the new fields added. 497 128 498 - ### Creating a Store 129 + ### Adding a New Entity Type (Checklist) 499 130 500 - ```go 501 - // In handlers, store is created per-request 502 - store, authenticated := h.getAtprotoStore(r) 503 - if !authenticated { 504 - http.Error(w, "Authentication required", http.StatusUnauthorized) 505 - return 506 - } 131 + The full stack for a new entity requires changes across many files. Follow the 132 + pattern of an existing entity (e.g., roaster for simple entities, brew for 133 + entities with references): 507 134 508 - // Use store with request context 509 - brews, err := store.ListBrews(r.Context(), userID) 510 - ``` 135 + 1. **Lexicon JSON** in `lexicons/` 136 + 2. **NSID constant** in `internal/atproto/nsid.go` 137 + 3. **RecordType constant** in `internal/lexicons/record_type.go` (const + ParseRecordType + DisplayName) 138 + 4. **Model + request types + validation** in `internal/models/models.go` 139 + 5. **Record conversion** (`XToRecord`/`RecordToX`) in `internal/atproto/records.go` 140 + 6. **Store interface methods** in `internal/database/store.go` 141 + 7. **AtprotoStore implementation** in `internal/atproto/store.go` (CRUD + witness + cache) 142 + 8. **Cache fields + Set/Invalidate methods** in `internal/atproto/cache.go` 143 + 9. **OAuth scope** in `internal/atproto/oauth.go` 144 + 10. **Firehose config** (collection list) in `internal/firehose/config.go` 145 + 11. **Firehose FeedItem** fields + `recordToFeedItem` switch case in `internal/firehose/index.go` 146 + 12. **Feed adapter** mapping in `internal/firehose/adapter.go` 147 + 13. **Feed service** `FeedItem` + `FirehoseFeedItem` fields and both mapper sites in `internal/feed/service.go` 148 + 14. **CRUD handlers** in `internal/handlers/entities.go` (also update `HandleManagePartial`, `HandleAPIListAll`, `HandleManageRefresh`) 149 + 15. **View + OG image handlers** in `internal/handlers/entity_views.go` 150 + 16. **Modal handlers** in `internal/handlers/modals.go` 151 + 17. **Routes** in `internal/routing/routing.go` (page views, API CRUD, modals, OG images) 152 + 18. **Templ view page** in `internal/web/pages/` (e.g., `cafe_view.templ`) 153 + 19. **Templ record content** in `internal/web/components/` (e.g., `record_cafe.templ`) 154 + 20. **Entity table component** in `internal/web/components/entity_tables.templ` 155 + 21. **Dialog modal** in `internal/web/components/dialog_modals.templ` (+ `getStringValue` cases) 156 + 22. **Manage partial** tab in `internal/web/components/manage_partial.templ` 157 + 23. **My Coffee tab** in `internal/web/pages/my_coffee.templ` 158 + 24. **Feed card** switch cases in `internal/web/pages/feed.templ` (card class, content, ActionText, share URL, title, delete URL) 159 + 25. **OG card** function in `internal/ogcard/entities.go` (+ accent color in `brew.go`) 160 + 26. **Suggestions** config in `internal/suggestions/suggestions.go` + handler map in `internal/handlers/suggestions.go` 161 + 27. **Client-side cache** entity case in `static/js/combo-select.js` `getUserEntities()` 511 162 512 - ### Record Conversion 163 + ### Templ Architecture 513 164 514 - ```go 515 - // Model -> ATProto record 516 - record, err := BrewToRecord(brew, beanURI, grinderURI, brewerURI) 165 + **Tabs only in `.templ` files** — never use spaces for indentation. A post-edit 166 + hook runs `templ fmt` automatically. After editing `.templ` files, run 167 + `templ generate` to regenerate Go code. 517 168 518 - // ATProto record -> Model 519 - brew, err := RecordToBrew(record, atURI) 520 - ``` 169 + Pages (`internal/web/pages/`) accept `*components.LayoutData` + page-specific 170 + props. Components (`internal/web/components/`) are reusable building blocks. 521 171 522 - ### AT-URI Handling 172 + Pattern: `pages.PageName(layoutData, props).Render(r.Context(), w)` 523 173 524 - ```go 525 - // Build AT-URI 526 - uri := BuildATURI(did, NSIDBean, rkey) // at://did:plc:xxx/social.arabica.alpha.bean/abc 174 + ### Combo-Select Component System 527 175 528 - // Parse AT-URI 529 - components, err := ResolveATURI(uri) 530 - // components.DID, components.Collection, components.RKey 531 - ``` 176 + Entity selection dropdowns (bean, grinder, brewer, roaster, cafe) use a shared 177 + combo-select pattern with typeahead search, community suggestions, and inline 178 + creation: 532 179 533 - ### Rendering Pages with Templ 180 + - **Go config**: `components.ComboSelectConfig()` in `components/combo_select.templ` 181 + generates Alpine.js `x-data` with entity-specific label formatting and create 182 + data mapping. 183 + - **Templ markup**: `components.ComboSelectInput()` renders the shared dropdown UI. 184 + - **JS behavior**: `static/js/combo-select.js` — Alpine.js component that 185 + searches user records (from client-side cache), community suggestions (from 186 + `/api/suggestions/{entity}`), and creates new entities inline via POST. 187 + - **Suggestions backend**: `internal/suggestions/suggestions.go` — entity configs 188 + define searchable fields and dedup keys. 534 189 535 - ```go 536 - // Standard page rendering pattern 537 - func (h *Handler) HandleBrewList(w http.ResponseWriter, r *http.Request) { 538 - store, authenticated := h.getAtprotoStore(r) 539 - if !authenticated { 540 - http.Redirect(w, r, "/", http.StatusSeeOther) 541 - return 542 - } 190 + To add a new entity to combo-select: add a case to `ComboSelectConfig`, add to 191 + `getUserEntities()` in `combo-select.js`, add entity config to 192 + `suggestions.go`, and add to the entity-to-NSID map in 193 + `handlers/suggestions.go`. 543 194 544 - // Fetch data 545 - brews, err := store.ListBrews(r.Context(), userDID) 546 - if err != nil { 547 - http.Error(w, "Failed to load brews", http.StatusInternalServerError) 548 - return 549 - } 195 + ### Entity View Handler Pattern 550 196 551 - // Create layout data 552 - layoutData := &components.LayoutData{ 553 - Title: "My Brews", 554 - IsAuthenticated: authenticated, 555 - UserDID: userDID, 556 - UserProfile: userProfile, 557 - CSPNonce: nonce, 558 - } 197 + View handlers (`HandleXView`) support both authenticated (own records) and 198 + public (via `?owner=` parameter) access. They: 559 199 560 - // Create page props 561 - brewListProps := pages.BrewListProps{ 562 - Brews: brews, 563 - } 564 - 565 - // Render templ component 566 - if err := pages.BrewList(layoutData, brewListProps).Render(r.Context(), w); err != nil { 567 - http.Error(w, "Failed to render page", http.StatusInternalServerError) 568 - } 569 - } 570 - ``` 571 - 572 - ### Rendering HTMX Partials with Templ 573 - 574 - ```go 575 - // Partial component for HTMX responses 576 - func (h *Handler) HandleBrewsPartial(w http.ResponseWriter, r *http.Request) { 577 - store, authenticated := h.getAtprotoStore(r) 578 - if !authenticated { 579 - http.Error(w, "Unauthorized", http.StatusUnauthorized) 580 - return 581 - } 582 - 583 - brews, err := store.ListBrews(r.Context(), userDID) 584 - if err != nil { 585 - http.Error(w, "Failed to load brews", http.StatusInternalServerError) 586 - return 587 - } 588 - 589 - // Render just the table component (no layout) 590 - if err := components.BrewListTable(brews, userDID).Render(r.Context(), w); err != nil { 591 - http.Error(w, "Failed to render", http.StatusInternalServerError) 592 - } 593 - } 594 - ``` 595 - 596 - ### Testing Conventions 597 - 598 - **IMPORTANT:** All tests in this codebase MUST use 599 - [testify/assert](https://github.com/stretchr/testify) for assertions. Do NOT use 600 - `if` statements with `t.Error()` or `t.Errorf()`. 601 - 602 - ```go 603 - // CORRECT: Use testify assert 604 - import ( 605 - "testing" 606 - "github.com/stretchr/testify/assert" 607 - ) 608 - 609 - func TestFormatTemp(t *testing.T) { 610 - got := FormatTemp(93.5) 611 - assert.Equal(t, "93.5°C", got) 612 - } 613 - 614 - func TestPtrEquals(t *testing.T) { 615 - val := 42 616 - assert.True(t, PtrEquals(&val, 42)) 617 - assert.False(t, PtrEquals(&val, 99)) 618 - } 619 - 620 - func TestRenderedHTML(t *testing.T) { 621 - html := renderComponent() 622 - assert.Contains(t, html, "btn-primary") 623 - assert.NotContains(t, html, "deprecated-class") 624 - } 625 - 626 - // WRONG: Don't use if statements for assertions 627 - func TestFormatTemp(t *testing.T) { 628 - got := FormatTemp(93.5) 629 - if got != "93.5°C" { // ❌ Don't do this 630 - t.Errorf("got %q, want %q", got, "93.5°C") 631 - } 632 - } 633 - ``` 634 - 635 - **Common testify assertions:** 636 - 637 - - `assert.Equal(t, expected, actual)` - Equality check 638 - - `assert.NotEqual(t, expected, actual)` - Inequality check 639 - - `assert.True(t, value)` - Boolean true 640 - - `assert.False(t, value)` - Boolean false 641 - - `assert.Nil(t, value)` - Nil check 642 - - `assert.NotNil(t, value)` - Not nil check 643 - - `assert.Contains(t, haystack, needle)` - Substring/element check 644 - - `assert.NotContains(t, haystack, needle)` - Negative substring/element check 645 - - `assert.NoError(t, err)` - No error occurred 646 - - `assert.Error(t, err)` - Error occurred 647 - 648 - ## Future Vision: Social Features 649 - 650 - The app currently has a basic community feed. Future plans expand social 651 - interactions leveraging AT Protocol's decentralized nature. 652 - 653 - ### Planned Lexicons 654 - 655 - ``` 656 - social.arabica.alpha.like - Like a brew (references brew AT-URI) 657 - social.arabica.alpha.comment - Comment on a brew 658 - social.arabica.alpha.follow - Follow another user 659 - social.arabica.alpha.share - Re-share a brew to your feed 660 - ``` 661 - 662 - ### Like Record (Planned) 663 - 664 - ```json 665 - { 666 - "lexicon": 1, 667 - "id": "social.arabica.alpha.like", 668 - "defs": { 669 - "main": { 670 - "type": "record", 671 - "key": "tid", 672 - "record": { 673 - "type": "object", 674 - "required": ["subject", "createdAt"], 675 - "properties": { 676 - "subject": { 677 - "type": "ref", 678 - "ref": "com.atproto.repo.strongRef", 679 - "description": "The brew being liked" 680 - }, 681 - "createdAt": { "type": "string", "format": "datetime" } 682 - } 683 - } 684 - } 685 - } 686 - } 687 - ``` 688 - 689 - ### Comment Record (Planned) 690 - 691 - ```json 692 - { 693 - "lexicon": 1, 694 - "id": "social.arabica.alpha.comment", 695 - "defs": { 696 - "main": { 697 - "type": "record", 698 - "key": "tid", 699 - "record": { 700 - "type": "object", 701 - "required": ["subject", "text", "createdAt"], 702 - "properties": { 703 - "subject": { 704 - "type": "ref", 705 - "ref": "com.atproto.repo.strongRef", 706 - "description": "The brew being commented on" 707 - }, 708 - "text": { 709 - "type": "string", 710 - "maxLength": 1000, 711 - "maxGraphemes": 300 712 - }, 713 - "createdAt": { "type": "string", "format": "datetime" } 714 - } 715 - } 716 - } 717 - } 718 - } 719 - ``` 720 - 721 - ### Implementation Approach 722 - 723 - **Cross-user interactions:** 724 - 725 - - Likes/comments stored in the actor's PDS (not the brew owner's) 726 - - Use `public_client.go` to read other users' brews 727 - - Aggregate likes/comments via relay/firehose or direct PDS queries 728 - 729 - **Feed aggregation:** 730 - 731 - - Current: Poll registered users' PDS for brews 732 - - Future: Subscribe to firehose for real-time updates 733 - - Index social interactions in local DB for fast queries 734 - 735 - **UI patterns:** 736 - 737 - - Like button on brew cards in feed 738 - - Comment thread below brew detail view 739 - - Share button to re-post with optional note 740 - - Notification system for interactions on your brews 741 - 742 - ### Key Design Decisions 743 - 744 - 1. **Strong references** - Likes/comments use `com.atproto.repo.strongRef` 745 - (URI + CID) to ensure the referenced brew hasn't changed 746 - 2. **Actor-owned data** - Your likes live in your PDS, not the brew owner's 747 - 3. **Public by default** - Social interactions are public records, readable by 748 - anyone 749 - 4. **Portable identity** - Users can switch PDS and keep their social graph 750 - 751 - ## Deployment Notes 200 + 1. Try witness cache first, fall back to PDS 201 + 2. Resolve references (e.g., roaster for cafe) 202 + 3. Populate OG metadata for social sharing 203 + 4. Fetch social data (likes, comments, moderation state) 204 + 5. Render the templ page with all props 752 205 753 206 ### CSS Cache Busting 754 207 ··· 759 212 <link rel="stylesheet" href="/static/css/output.css?v=0.1.3" /> 760 213 ``` 761 214 762 - Cloudflare caches static assets, so incrementing the version ensures users get 763 - the updated styles. 215 + ## Testing Conventions 764 216 765 - ### Templ Code Generation 766 - 767 - Templ templates must be compiled to Go code before building: 217 + All tests MUST use [testify/assert](https://github.com/stretchr/testify). Do 218 + NOT use `if` statements with `t.Error()`. 768 219 769 - ```bash 770 - # Generate Go code from .templ files 771 - templ generate 772 - 773 - # Or in Nix environment 774 - nix develop -c templ generate 220 + ```go 221 + assert.Equal(t, expected, actual) 222 + assert.NoError(t, err) 223 + assert.Contains(t, haystack, needle) 224 + assert.True(t, value) 225 + assert.Nil(t, value) 775 226 ``` 776 227 777 - This is automatically handled by the build process, but you may need to run it 778 - manually during development. 228 + ## Using Go Tooling 229 + 230 + - `go mod download -json MODULE` — get dependency source path 231 + - `go doc foo.Bar` — read package/type/function docs 232 + - `go run ./cmd/server` instead of `go build` to avoid artifacts
+1 -1
internal/models/models_test.go
··· 362 362 363 363 stub := &Bean{Name: "Test"} 364 364 assert.True(t, stub.IsIncomplete()) 365 - assert.Len(t, stub.MissingFields(), 3) 365 + assert.Len(t, stub.MissingFields(), 2) 366 366 } 367 367 368 368 func TestGrinderIsIncomplete(t *testing.T) {
+226
internal/web/components/combo_select.templ
··· 1 + package components 2 + 3 + import "fmt" 4 + 5 + // ComboSelectConfig generates the x-data attribute for a combo-select Alpine.js component. 6 + // entityType: one of "bean", "brewer", "grinder", "recipe", "roaster", "cafe" 7 + // apiEndpoint: URL to list user's own records (e.g. "/api/beans") 8 + // suggestEndpoint: URL for community suggestions (e.g. "/api/suggestions/beans") 9 + // inputName: the hidden input name for the selected rkey 10 + // placeholder: input placeholder text 11 + // required: whether the field is required 12 + // passthrough: if true, value is passed through without create (for recipe refs) 13 + func ComboSelectConfig(entityType, apiEndpoint, suggestEndpoint, inputName, placeholder string, required bool, passthrough ...bool) string { 14 + requiredStr := "false" 15 + if required { 16 + requiredStr = "true" 17 + } 18 + passthroughStr := "false" 19 + if len(passthrough) > 0 && passthrough[0] { 20 + passthroughStr = "true" 21 + } 22 + 23 + var formatLabel, formatCreateData, extraFields string 24 + switch entityType { 25 + case "bean": 26 + formatLabel = `(e) => { const n = e.name || e.Name || ''; const o = e.origin || e.Origin || ''; const r = e.roast_level || e.RoastLevel || ''; if (o && r) return n + ' (' + o + ' - ' + r + ')'; if (o) return n + ' (' + o + ')'; return n; }` 27 + formatCreateData = `(name, s) => { const d = { name }; if (s && s.fields) { if (s.fields.origin) d.origin = s.fields.origin; if (s.fields.roastLevel) d.roast_level = s.fields.roastLevel; if (s.fields.process) d.process = s.fields.process; } return d; }` 28 + extraFields = `[{name:'origin',label:'Origin',type:'text',placeholder:'e.g. Ethiopia, Colombia'},{name:'roast_level',label:'Roast Level',type:'select',options:['Ultra-Light','Light','Medium-Light','Medium','Medium-Dark','Dark']},{name:'process',label:'Process',type:'text',placeholder:'e.g. Washed, Natural, Honey'}]` 29 + case "brewer": 30 + formatLabel = `(e) => e.name || e.Name || ''` 31 + formatCreateData = `(name, s) => { const d = { name }; if (s && s.fields && s.fields.brewerType) d.brewer_type = s.fields.brewerType; return d; }` 32 + extraFields = `[{name:'brewer_type',label:'Type',type:'select',options:['pourover','espresso','immersion','mokapot','coldbrew','cupping','other']}]` 33 + case "grinder": 34 + formatLabel = `(e) => e.name || e.Name || ''` 35 + formatCreateData = `(name, s) => { const d = { name }; if (s && s.fields) { if (s.fields.grinderType) d.grinder_type = s.fields.grinderType; if (s.fields.burrType) d.burr_type = s.fields.burrType; } return d; }` 36 + extraFields = `[{name:'grinder_type',label:'Type',type:'select',options:['Hand','Electric','Portable Electric']},{name:'burr_type',label:'Burr Type',type:'select',options:['Conical','Flat']}]` 37 + case "recipe": 38 + formatLabel = `(e) => { const n = e.name || e.Name || ''; const bt = e.brewer_type || e.BrewerType || (e.fields && e.fields.brewerType) || ''; return bt ? n + ' (' + bt + ')' : n; }` 39 + formatCreateData = `(name) => ({ name })` 40 + extraFields = `[]` 41 + case "roaster": 42 + formatLabel = `(e) => e.name || e.Name || ''` 43 + formatCreateData = `(name, s) => { const d = { name }; if (s && s.fields) { if (s.fields.location) d.location = s.fields.location; if (s.fields.website) d.website = s.fields.website; } return d; }` 44 + extraFields = `[{name:'location',label:'Location',type:'text',placeholder:'e.g. Portland, OR'},{name:'website',label:'Website',type:'text',placeholder:'https://...'}]` 45 + case "cafe": 46 + formatLabel = `(e) => { const n = e.name || e.Name || ''; const l = e.location || e.Location || ''; return l ? n + ' (' + l + ')' : n; }` 47 + formatCreateData = `(name, s) => { const d = { name }; if (s && s.fields) { if (s.fields.location) d.location = s.fields.location; if (s.fields.website) d.website = s.fields.website; } return d; }` 48 + extraFields = `[{name:'location',label:'Location',type:'text',placeholder:'e.g. Portland, OR'},{name:'website',label:'Website',type:'text',placeholder:'https://...'}]` 49 + default: 50 + formatLabel = `(e) => e.name || e.Name || ''` 51 + formatCreateData = `(name) => ({ name })` 52 + extraFields = `[]` 53 + } 54 + 55 + return fmt.Sprintf(`comboSelect({ entityType: '%s', apiEndpoint: '%s', suggestEndpoint: '%s', inputName: '%s', placeholder: '%s', required: %s, passthrough: %s, formatLabel: %s, formatCreateData: %s, extraFields: %s })`, 56 + entityType, apiEndpoint, suggestEndpoint, inputName, placeholder, requiredStr, passthroughStr, formatLabel, formatCreateData, extraFields) 57 + } 58 + 59 + // ComboSelectInput renders the shared input + dropdown markup for combo-select fields. 60 + // sectionLabel is the heading shown above the user's own records (e.g. "Your beans"). 61 + templ ComboSelectInput(sectionLabel string) { 62 + <div class="relative"> 63 + <input 64 + type="text" 65 + x-model="query" 66 + @input="search()" 67 + @focus="open()" 68 + @blur="close()" 69 + @keydown.escape.prevent="close()" 70 + @keydown.arrow-down.prevent="moveDown()" 71 + @keydown.arrow-up.prevent="moveUp()" 72 + @keydown.enter.prevent="selectHighlighted()" 73 + :placeholder="placeholder" 74 + class="w-full form-input-lg" 75 + autocomplete="off" 76 + /> 77 + <button 78 + type="button" 79 + x-show="selectedRKey" 80 + @click="clear()" 81 + class="absolute right-2 top-1/2 -translate-y-1/2 text-brown-400 hover:text-brown-600" 82 + x-cloak 83 + > 84 + @IconX() 85 + </button> 86 + </div> 87 + <div x-show="isOpen && (allItems.length > 0 || query.trim())" x-cloak class="combo-dropdown" @mousedown.prevent> 88 + <div x-show="isCreating" x-cloak class="combo-creating">Creating...</div> 89 + <template x-if="!isCreating"> 90 + <div> 91 + <template x-if="userResults.length > 0"> 92 + <div> 93 + <div class="combo-section-label">{ sectionLabel }</div> 94 + <template x-for="(entity, i) in userResults" :key="entity.rkey || entity.RKey"> 95 + <div 96 + class="combo-item" 97 + :data-highlighted="highlightIndex === i" 98 + @click="selectEntity(entity)" 99 + @mouseenter="highlightIndex = i" 100 + > 101 + <span x-text="formatLabel(entity)"></span> 102 + </div> 103 + </template> 104 + </div> 105 + </template> 106 + <template x-if="closedResults.length > 0"> 107 + <div> 108 + <div class="combo-section-label">Closed bags</div> 109 + <template x-for="(entity, ci) in closedResults" :key="entity.rkey || entity.RKey"> 110 + <div 111 + class="combo-item opacity-60" 112 + :data-highlighted="highlightIndex === userResults.length + ci" 113 + @click="selectEntity(entity)" 114 + @mouseenter="highlightIndex = userResults.length + ci" 115 + > 116 + <span x-text="formatLabel(entity)"></span> 117 + </div> 118 + </template> 119 + </div> 120 + </template> 121 + <template x-if="communityResults.length > 0"> 122 + <div> 123 + <div class="combo-section-label">Community</div> 124 + <template x-for="(s, j) in communityResults" :key="s.source_uri || j"> 125 + <div 126 + class="combo-item" 127 + :data-highlighted="highlightIndex === userResults.length + closedResults.length + j" 128 + @click="selectSuggestion(s)" 129 + @mouseenter="highlightIndex = userResults.length + closedResults.length + j" 130 + > 131 + <div x-text="s.name"></div> 132 + <div class="combo-item-sub"> 133 + <span x-show="s.fields?.origin" x-text="s.fields?.origin"></span> 134 + <span x-show="s.fields?.origin && s.fields?.roastLevel">· </span> 135 + <span x-show="s.fields?.roastLevel" x-text="s.fields?.roastLevel"></span> 136 + <span x-show="s.fields?.location" x-text="s.fields?.location"></span> 137 + <span x-show="s.count > 1" x-text="' · ' + s.count + ' users'"></span> 138 + </div> 139 + </div> 140 + </template> 141 + </div> 142 + </template> 143 + <template x-if="query.trim() && !exactMatch"> 144 + <div 145 + class="combo-item-create" 146 + :data-highlighted="highlightIndex === userResults.length + closedResults.length + communityResults.length" 147 + @click="createNew()" 148 + @mouseenter="highlightIndex = userResults.length + closedResults.length + communityResults.length" 149 + > 150 + Create "<span x-text="query.trim()"></span>" 151 + </div> 152 + </template> 153 + <template x-if="allItems.length === 0 && query.trim()"> 154 + <div class="combo-creating">No matches found</div> 155 + </template> 156 + </div> 157 + </template> 158 + </div> 159 + <!-- Inline create form with extra details --> 160 + <div x-show="showCreateForm" x-transition x-cloak class="mt-2 p-3 rounded-lg" style="background: var(--surface-bg); border: 1px solid var(--surface-border);"> 161 + <p class="text-sm font-medium text-brown-900 mb-2"> 162 + Creating: <span x-text="createFormData.name" class="font-semibold"></span> 163 + </p> 164 + <template x-if="extraFields.length > 0"> 165 + <div class="space-y-2"> 166 + <template x-for="field in extraFields" :key="field.name"> 167 + <div> 168 + <template x-if="field.type === 'select'"> 169 + <select 170 + x-model="createFormData[field.name]" 171 + class="w-full form-input text-sm" 172 + > 173 + <option value="" x-text="field.label + ' (optional)'"></option> 174 + <template x-for="opt in field.options" :key="opt"> 175 + <option :value="opt" x-text="opt"></option> 176 + </template> 177 + </select> 178 + </template> 179 + <template x-if="field.type === 'text'"> 180 + <input 181 + type="text" 182 + :placeholder="field.placeholder || field.label" 183 + x-model="createFormData[field.name]" 184 + class="w-full form-input text-sm" 185 + /> 186 + </template> 187 + </div> 188 + </template> 189 + <div class="flex gap-2 mt-2"> 190 + <button 191 + type="button" 192 + @click="submitCreateForm()" 193 + class="flex-1 btn-primary text-sm py-1.5" 194 + :disabled="isCreating" 195 + > 196 + <span x-show="!isCreating">Save</span> 197 + <span x-show="isCreating">Saving...</span> 198 + </button> 199 + <button 200 + type="button" 201 + @click="skipCreateDetails()" 202 + class="flex-1 btn-secondary text-sm py-1.5" 203 + :disabled="isCreating" 204 + > 205 + Skip details 206 + </button> 207 + </div> 208 + </div> 209 + </template> 210 + </div> 211 + } 212 + 213 + // EscapeJS escapes a string for safe embedding in JavaScript string literals. 214 + func EscapeJS(s string) string { 215 + result := "" 216 + for _, c := range s { 217 + if c == '\'' { 218 + result += "\\'" 219 + } else if c == '\\' { 220 + result += "\\\\" 221 + } else { 222 + result += string(c) 223 + } 224 + } 225 + return result 226 + }
+9 -10
internal/web/pages/brew_form.templ
··· 149 149 </fieldset> 150 150 } 151 151 152 - 153 152 // RecipeSelectField renders the recipe selection with combo-select typeahead 154 153 templ RecipeSelectField(props BrewFormProps) { 155 154 <div 156 155 class="combo-select" 157 - x-data={ comboSelectConfig("recipe", "/api/recipes", "/api/suggestions/recipes", "recipe_rkey", "Search recipes...", false, true) } 156 + x-data={ components.ComboSelectConfig("recipe", "/api/recipes", "/api/suggestions/recipes", "recipe_rkey", "Search recipes...", false, true) } 158 157 if props.Brew != nil && props.Brew.RecipeRKey != "" { 159 158 x-init={ fmt.Sprintf("selectedRKey = '%s'; query = '%s'; selectedLabel = query", props.Brew.RecipeRKey, escapeJS(getRecipeLabel(props))) } 160 159 } ··· 167 166 <input type="hidden" :name="inputName" :value="selectedRKey"/> 168 167 <div class="flex gap-2"> 169 168 <div class="flex-1"> 170 - @comboSelectInput("Your recipes") 169 + @components.ComboSelectInput("Your recipes") 171 170 </div> 172 171 <button 173 172 type="button" ··· 186 185 templ BeanSelectField(props BrewFormProps) { 187 186 <div 188 187 class="combo-select" 189 - x-data={ comboSelectConfig("bean", "/api/beans", "/api/suggestions/beans", "bean_rkey", "Search or create a bean...", true) } 188 + x-data={ components.ComboSelectConfig("bean", "/api/beans", "/api/suggestions/beans", "bean_rkey", "Search or create a bean...", true) } 190 189 if props.Brew != nil && props.Brew.BeanRKey != "" { 191 190 x-init={ fmt.Sprintf("selectedRKey = '%s'; query = '%s'; selectedLabel = query", props.Brew.BeanRKey, escapeJS(getBeanLabel(props))) } 192 191 } ··· 196 195 <span class="text-red-500">*</span> 197 196 </label> 198 197 <input type="hidden" :name="inputName" :value="selectedRKey"/> 199 - @comboSelectInput("Your beans") 198 + @components.ComboSelectInput("Your beans") 200 199 </div> 201 200 } 202 201 ··· 368 367 <div x-text="s.name"></div> 369 368 <div class="combo-item-sub"> 370 369 <span x-show="s.fields?.origin" x-text="s.fields?.origin"></span> 371 - <span x-show="s.fields?.origin && s.fields?.roastLevel"> · </span> 370 + <span x-show="s.fields?.origin && s.fields?.roastLevel">· </span> 372 371 <span x-show="s.fields?.roastLevel" x-text="s.fields?.roastLevel"></span> 373 372 <span x-show="s.count > 1" x-text="' · ' + s.count + ' users'"></span> 374 373 </div> ··· 474 473 templ GrinderSelectField(props BrewFormProps) { 475 474 <div 476 475 class="combo-select" 477 - x-data={ comboSelectConfig("grinder", "/api/grinders", "/api/suggestions/grinders", "grinder_rkey", "Search or create a grinder...", false) } 476 + x-data={ components.ComboSelectConfig("grinder", "/api/grinders", "/api/suggestions/grinders", "grinder_rkey", "Search or create a grinder...", false) } 478 477 if props.Brew != nil && props.Brew.GrinderRKey != "" { 479 478 x-init={ fmt.Sprintf("selectedRKey = '%s'; query = '%s'; selectedLabel = query", props.Brew.GrinderRKey, escapeJS(getGrinderLabel(props))) } 480 479 } 481 480 > 482 481 <label class="form-label">Grinder</label> 483 482 <input type="hidden" :name="inputName" :value="selectedRKey"/> 484 - @comboSelectInput("Your grinders") 483 + @components.ComboSelectInput("Your grinders") 485 484 </div> 486 485 } 487 486 ··· 512 511 templ BrewerSelectField(props BrewFormProps) { 513 512 <div 514 513 class="combo-select" 515 - x-data={ comboSelectConfig("brewer", "/api/brewers", "/api/suggestions/brewers", "brewer_rkey", "Search or create a brew method...", false) } 514 + x-data={ components.ComboSelectConfig("brewer", "/api/brewers", "/api/suggestions/brewers", "brewer_rkey", "Search or create a brew method...", false) } 516 515 if props.Brew != nil && props.Brew.BrewerRKey != "" { 517 516 x-init={ fmt.Sprintf("selectedRKey = '%s'; query = '%s'; selectedLabel = query", props.Brew.BrewerRKey, escapeJS(getBrewerLabel(props))) } 518 517 } 519 518 > 520 519 <label class="form-label">Brew Method</label> 521 520 <input type="hidden" :name="inputName" :value="selectedRKey"/> 522 - @comboSelectInput("Your brewers") 521 + @components.ComboSelectInput("Your brewers") 523 522 </div> 524 523 } 525 524
+2
static/js/combo-select.js
··· 169 169 return dm.grinders || []; 170 170 case "recipe": 171 171 return dm.recipes || []; 172 + case "roaster": 173 + return dm.roasters || []; 172 174 default: 173 175 return []; 174 176 }