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

refactor: remove old tmpl files #4

closed opened by pdewey.com targeting main from refactor-svelte
Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:hm5f3dnm6jdhrc55qp2npdja/sh.tangled.repo.pull/3mdercwj6le22
+850 -13600
Diff #0
-1
AGENTS.md
··· 1 - CLAUDE.md
+362
AGENTS.md
··· 1 + # Arabica - Project Context for AI Agents 2 + 3 + Coffee brew tracking application using AT Protocol for decentralized storage. 4 + 5 + ## Tech Stack 6 + 7 + - **Language:** Go 1.21+ 8 + - **HTTP:** stdlib `net/http` with Go 1.22 routing 9 + - **Storage:** AT Protocol PDS (user data), BoltDB (sessions/feed registry) 10 + - **Frontend:** Svelte SPA with client-side routing 11 + - **Testing:** Standard library testing + [shutter](https://github.com/ptdewey/shutter) for snapshot tests 12 + - **Logging:** zerolog 13 + 14 + ## Project Structure 15 + 16 + ``` 17 + cmd/arabica-server/main.go # Application entry point 18 + internal/ 19 + atproto/ # AT Protocol integration 20 + client.go # Authenticated PDS client (XRPC calls) 21 + oauth.go # OAuth flow with PKCE/DPOP 22 + store.go # database.Store implementation using PDS 23 + cache.go # Per-session in-memory cache 24 + records.go # Model <-> ATProto record conversion 25 + resolver.go # AT-URI parsing and reference resolution 26 + public_client.go # Unauthenticated public API access 27 + nsid.go # Collection NSIDs and AT-URI builders 28 + handlers/ 29 + handlers.go # HTTP handlers (API endpoints) 30 + auth.go # OAuth login/logout/callback 31 + api_snapshot_test.go # Snapshot tests for API responses 32 + testutil.go # Test helpers and fixtures 33 + __snapshots__/ # Snapshot files for regression testing 34 + database/ 35 + store.go # Store interface definition 36 + store_mock.go # Mock implementation for testing 37 + boltstore/ # BoltDB implementation for sessions 38 + feed/ 39 + service.go # Community feed aggregation 40 + registry.go # User registration for feed 41 + models/ 42 + models.go # Domain models and request types 43 + middleware/ 44 + logging.go # Request logging middleware 45 + routing/ 46 + routing.go # Router setup and middleware chain 47 + frontend/ # Svelte SPA source code 48 + src/ 49 + routes/ # Page components 50 + components/ # Reusable components 51 + stores/ # Svelte stores (auth, cache) 52 + lib/ # Utilities (router, API client) 53 + public/ # Built SPA assets 54 + lexicons/ # AT Protocol lexicon definitions (JSON) 55 + static/ # Static assets (CSS, icons, service worker) 56 + app/ # Built Svelte SPA 57 + ``` 58 + 59 + ## Key Concepts 60 + 61 + ### AT Protocol Integration 62 + 63 + User data stored in their Personal Data Server (PDS), not locally. The app: 64 + 65 + 1. Authenticates via OAuth (indigo SDK handles PKCE/DPOP) 66 + 2. Gets access token scoped to user's DID 67 + 3. Performs CRUD via XRPC calls to user's PDS 68 + 69 + **Collections (NSIDs):** 70 + 71 + - `social.arabica.alpha.bean` - Coffee beans 72 + - `social.arabica.alpha.roaster` - Roasters 73 + - `social.arabica.alpha.grinder` - Grinders 74 + - `social.arabica.alpha.brewer` - Brewing devices 75 + - `social.arabica.alpha.brew` - Brew sessions (references bean, grinder, brewer) 76 + 77 + **Record keys:** TID format (timestamp-based identifiers) 78 + 79 + **References:** Records reference each other via AT-URIs (`at://did/collection/rkey`) 80 + 81 + ### Store Interface 82 + 83 + `internal/database/store.go` defines the `Store` interface. Two implementations: 84 + 85 + - `AtprotoStore` - Production, stores in user's PDS 86 + - BoltDB stores only sessions and feed registry (not user data) 87 + 88 + All Store methods take `context.Context` as first parameter. 89 + 90 + ### Request Flow 91 + 92 + 1. Request hits middleware (logging, auth check) 93 + 2. Auth middleware extracts DID + session ID from cookies 94 + 3. For SPA routes: Serve index.html (client-side routing) 95 + 4. For API routes: Handler creates `AtprotoStore` scoped to user 96 + 5. Store methods make XRPC calls to user's PDS 97 + 6. Results returned as JSON 98 + 99 + ### Caching 100 + 101 + `SessionCache` caches user data in memory (5-minute TTL): 102 + 103 + - Avoids repeated PDS calls for same data 104 + - Invalidated on writes 105 + - Background cleanup removes expired entries 106 + 107 + ### Backfill Strategy 108 + 109 + User records are backfilled from their PDS once per DID: 110 + 111 + - **On startup**: Backfills registered users + known-dids file 112 + - **On first login**: Backfills the user's historical records 113 + - **Deduplication**: Tracks backfilled DIDs in `BucketBackfilled` to prevent redundant fetches 114 + - **Idempotent**: Safe to call multiple times (checks backfill status first) 115 + 116 + This prevents excessive PDS requests while ensuring new users' historical data is indexed. 117 + 118 + ## Common Tasks 119 + 120 + ### Run Development Server 121 + 122 + ```bash 123 + # Run server (uses firehose mode by default) 124 + go run cmd/arabica-server/main.go 125 + 126 + # Backfill known DIDs on startup 127 + go run cmd/arabica-server/main.go --known-dids known-dids.txt 128 + 129 + # Using nix 130 + nix run 131 + ``` 132 + 133 + ### Run Tests 134 + 135 + ```bash 136 + go test ./... 137 + ``` 138 + 139 + #### Snapshot Testing 140 + 141 + Backend API responses are tested using snapshot tests with the [shutter](https://github.com/ptdewey/shutter) library. Snapshot tests capture the JSON response format and verify it remains consistent across changes. 142 + 143 + **Location:** `internal/handlers/api_snapshot_test.go` 144 + 145 + **Covered endpoints:** 146 + - Authentication: `/api/me`, `/client-metadata.json` 147 + - Data fetching: `/api/data`, `/api/feed-json`, `/api/profile-json/{actor}` 148 + - CRUD operations: Create/Update/Delete for beans, roasters, grinders, brewers, brews 149 + 150 + **Running snapshot tests:** 151 + ```bash 152 + cd internal/handlers && go test -v -run "Snapshot" 153 + ``` 154 + 155 + **Working with snapshots:** 156 + ```bash 157 + # Accept all new/changed snapshots 158 + shutter accept-all 159 + 160 + # Reject all pending snapshots 161 + shutter reject-all 162 + 163 + # Review snapshots interactively 164 + shutter review 165 + ``` 166 + 167 + **Snapshot patterns used:** 168 + - `shutter.ScrubTimestamp()` - Removes timestamp values for deterministic tests 169 + - `shutter.IgnoreKey("created_at")` - Ignores specific JSON keys 170 + - `shutter.IgnoreKey("rkey")` - Ignores AT Protocol record keys (TIDs are time-based) 171 + 172 + Snapshots are stored in `internal/handlers/__snapshots__/` and should be committed to version control. 173 + 174 + ### Build 175 + 176 + ```bash 177 + go build -o arabica cmd/arabica-server/main.go 178 + ``` 179 + 180 + ## Command-Line Flags 181 + 182 + | Flag | Type | Default | Description | 183 + | --------------- | ------ | ------- | ----------------------------------------------------- | 184 + | `--firehose` | bool | true | [DEPRECATED] Firehose is now the default (ignored) | 185 + | `--known-dids` | string | "" | Path to file with DIDs to backfill (one per line) | 186 + 187 + **Known DIDs File Format:** 188 + - One DID per line (e.g., `did:plc:abc123xyz`) 189 + - Lines starting with `#` are comments 190 + - Empty lines are ignored 191 + - See `known-dids.txt.example` for reference 192 + 193 + ## Environment Variables 194 + 195 + | Variable | Default | Description | 196 + | --------------------------- | --------------------------------- | ---------------------------------- | 197 + | `PORT` | 18910 | HTTP server port | 198 + | `SERVER_PUBLIC_URL` | - | Public URL for reverse proxy | 199 + | `ARABICA_DB_PATH` | ~/.local/share/arabica/arabica.db | BoltDB path (sessions, registry) | 200 + | `ARABICA_FEED_INDEX_PATH` | ~/.local/share/arabica/feed-index.db | Firehose index BoltDB path | 201 + | `ARABICA_PROFILE_CACHE_TTL` | 1h | Profile cache duration | 202 + | `SECURE_COOKIES` | false | Set true for HTTPS | 203 + | `LOG_LEVEL` | info | debug/info/warn/error | 204 + | `LOG_FORMAT` | console | console/json | 205 + 206 + ## Code Patterns 207 + 208 + ### Creating a Store 209 + 210 + ```go 211 + // In handlers, store is created per-request 212 + store, authenticated := h.getAtprotoStore(r) 213 + if !authenticated { 214 + http.Error(w, "Authentication required", http.StatusUnauthorized) 215 + return 216 + } 217 + 218 + // Use store with request context 219 + brews, err := store.ListBrews(r.Context(), userID) 220 + ``` 221 + 222 + ### Record Conversion 223 + 224 + ```go 225 + // Model -> ATProto record 226 + record, err := BrewToRecord(brew, beanURI, grinderURI, brewerURI) 227 + 228 + // ATProto record -> Model 229 + brew, err := RecordToBrew(record, atURI) 230 + ``` 231 + 232 + ### AT-URI Handling 233 + 234 + ```go 235 + // Build AT-URI 236 + uri := BuildATURI(did, NSIDBean, rkey) // at://did:plc:xxx/social.arabica.alpha.bean/abc 237 + 238 + // Parse AT-URI 239 + components, err := ResolveATURI(uri) 240 + // components.DID, components.Collection, components.RKey 241 + ``` 242 + 243 + ## Future Vision: Social Features 244 + 245 + The app currently has a basic community feed. Future plans expand social interactions leveraging AT Protocol's decentralized nature. 246 + 247 + ### Planned Lexicons 248 + 249 + ``` 250 + social.arabica.alpha.like - Like a brew (references brew AT-URI) 251 + social.arabica.alpha.comment - Comment on a brew 252 + social.arabica.alpha.follow - Follow another user 253 + social.arabica.alpha.share - Re-share a brew to your feed 254 + ``` 255 + 256 + ### Like Record (Planned) 257 + 258 + ```json 259 + { 260 + "lexicon": 1, 261 + "id": "social.arabica.alpha.like", 262 + "defs": { 263 + "main": { 264 + "type": "record", 265 + "key": "tid", 266 + "record": { 267 + "type": "object", 268 + "required": ["subject", "createdAt"], 269 + "properties": { 270 + "subject": { 271 + "type": "ref", 272 + "ref": "com.atproto.repo.strongRef", 273 + "description": "The brew being liked" 274 + }, 275 + "createdAt": { "type": "string", "format": "datetime" } 276 + } 277 + } 278 + } 279 + } 280 + } 281 + ``` 282 + 283 + ### Comment Record (Planned) 284 + 285 + ```json 286 + { 287 + "lexicon": 1, 288 + "id": "social.arabica.alpha.comment", 289 + "defs": { 290 + "main": { 291 + "type": "record", 292 + "key": "tid", 293 + "record": { 294 + "type": "object", 295 + "required": ["subject", "text", "createdAt"], 296 + "properties": { 297 + "subject": { 298 + "type": "ref", 299 + "ref": "com.atproto.repo.strongRef", 300 + "description": "The brew being commented on" 301 + }, 302 + "text": { 303 + "type": "string", 304 + "maxLength": 1000, 305 + "maxGraphemes": 300 306 + }, 307 + "createdAt": { "type": "string", "format": "datetime" } 308 + } 309 + } 310 + } 311 + } 312 + } 313 + ``` 314 + 315 + ### Implementation Approach 316 + 317 + **Cross-user interactions:** 318 + 319 + - Likes/comments stored in the actor's PDS (not the brew owner's) 320 + - Use `public_client.go` to read other users' brews 321 + - Aggregate likes/comments via relay/firehose or direct PDS queries 322 + 323 + **Feed aggregation:** 324 + 325 + - Current: Poll registered users' PDS for brews 326 + - Future: Subscribe to firehose for real-time updates 327 + - Index social interactions in local DB for fast queries 328 + 329 + **UI patterns:** 330 + 331 + - Like button on brew cards in feed 332 + - Comment thread below brew detail view 333 + - Share button to re-post with optional note 334 + - Notification system for interactions on your brews 335 + 336 + ### Key Design Decisions 337 + 338 + 1. **Strong references** - Likes/comments use `com.atproto.repo.strongRef` (URI + CID) to ensure the referenced brew hasn't changed 339 + 2. **Actor-owned data** - Your likes live in your PDS, not the brew owner's 340 + 3. **Public by default** - Social interactions are public records, readable by anyone 341 + 4. **Portable identity** - Users can switch PDS and keep their social graph 342 + 343 + ## Deployment Notes 344 + 345 + ### CSS Cache Busting 346 + 347 + When making CSS/style changes, bump the version query parameter in `templates/layout.tmpl`: 348 + 349 + ```html 350 + <link rel="stylesheet" href="/static/css/output.css?v=0.1.3" /> 351 + ``` 352 + 353 + Cloudflare caches static assets, so incrementing the version ensures users get the updated styles. 354 + 355 + ## Known Issues / TODOs 356 + 357 + Key areas: 358 + 359 + - Context should flow through methods (some fixed, verify all paths) 360 + - Cache race conditions need copy-on-write pattern 361 + - Missing CID validation on record updates (AT Protocol best practice) 362 + - Rate limiting for PDS calls not implemented
+1 -1
CLAUDE.md
··· 52 52 public/ # Built SPA assets 53 53 lexicons/ # AT Protocol lexicon definitions (JSON) 54 54 templates/partials/ # Legacy HTMX partial templates (being phased out) 55 - web/static/ # Static assets (CSS, icons, service worker) 55 + static/ # Static assets (CSS, icons, service worker) 56 56 app/ # Built Svelte SPA 57 57 ``` 58 58
+8 -8
MIGRATION.md
··· 57 57 │ ├── index.html 58 58 │ ├── vite.config.js 59 59 │ └── package.json 60 - └── web/static/app/ # Built Svelte output (served by Go) 60 + └── static/app/ # Built Svelte output (served by Go) 61 61 ``` 62 62 63 63 ## Development ··· 87 87 npm run build 88 88 ``` 89 89 90 - This builds the Svelte app into `web/static/app/` 90 + This builds the Svelte app into `static/app/` 91 91 92 92 Then run the Go server normally: 93 93 ··· 95 95 go run cmd/arabica-server/main.go 96 96 ``` 97 97 98 - The Go server will serve the built Svelte SPA from `web/static/app/` 98 + The Go server will serve the built Svelte SPA from `static/app/` 99 99 100 100 ## Key Features Implemented 101 101 ··· 159 159 160 160 ```bash 161 161 # Old Alpine.js JavaScript 162 - web/static/js/alpine.min.js 163 - web/static/js/manage-page.js 164 - web/static/js/brew-form.js 165 - web/static/js/data-cache.js 166 - web/static/js/handle-autocomplete.js 162 + static/js/alpine.min.js 163 + static/js/manage-page.js 164 + static/js/brew-form.js 165 + static/js/data-cache.js 166 + static/js/handle-autocomplete.js 167 167 168 168 # Go templates (entire directory) 169 169 templates/
+2 -2
docs/back-button-implementation.md
··· 17 17 18 18 The implementation consists of: 19 19 20 - 1. **JavaScript Module** (`web/static/js/back-button.js`): 20 + 1. **JavaScript Module** (`static/js/back-button.js`): 21 21 - Detects if the user came from within the app (same-origin referrer) 22 22 - Uses `history.back()` for internal navigation (preserves history stack) 23 23 - Falls back to a specified URL for external/direct navigation ··· 80 80 81 81 ### New Files 82 82 83 - 1. **`web/static/js/back-button.js`** 83 + 1. **`static/js/back-button.js`** 84 84 - Core back button logic 85 85 - Initialization and event handling 86 86 - HTMX integration
+1 -1
frontend/vite.config.js
··· 40 40 }, 41 41 base: '/static/app/', 42 42 build: { 43 - outDir: path.resolve(__dirname, '../web/static/app'), 43 + outDir: path.resolve(__dirname, '../static/app'), 44 44 emptyOutDir: true, 45 45 assetsDir: 'assets', 46 46 },
+2 -4
go.mod
··· 4 4 5 5 require ( 6 6 github.com/bluesky-social/indigo v0.0.0-20260106221649-6fcd9317e725 7 + github.com/gorilla/websocket v1.5.3 8 + github.com/klauspost/compress v1.18.3 7 9 github.com/rs/zerolog v1.34.0 8 10 github.com/stretchr/testify v1.10.0 9 11 go.etcd.io/bbolt v1.3.8 ··· 18 20 github.com/golang-jwt/jwt/v5 v5.2.2 // indirect 19 21 github.com/google/go-cmp v0.6.0 // indirect 20 22 github.com/google/go-querystring v1.1.0 // indirect 21 - github.com/gorilla/websocket v1.5.3 // indirect 22 23 github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 23 - github.com/klauspost/compress v1.18.3 // indirect 24 24 github.com/kortschak/utter v1.7.0 // indirect 25 25 github.com/mattn/go-colorable v0.1.13 // indirect 26 26 github.com/mattn/go-isatty v0.0.20 // indirect ··· 32 32 github.com/prometheus/common v0.45.0 // indirect 33 33 github.com/prometheus/procfs v0.12.0 // indirect 34 34 github.com/ptdewey/shutter v0.1.4 // indirect 35 - github.com/yosssi/gohtml v0.0.0-20201013000340-ee4748c638f4 // indirect 36 35 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 37 36 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 38 37 golang.org/x/crypto v0.21.0 // indirect 39 - golang.org/x/net v0.23.0 // indirect 40 38 golang.org/x/sys v0.36.0 // indirect 41 39 golang.org/x/time v0.3.0 // indirect 42 40 google.golang.org/protobuf v1.33.0 // indirect
-4
go.sum
··· 79 79 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 80 80 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e h1:28X54ciEwwUxyHn9yrZfl5ojgF4CBNLWX7LR0rvBkf4= 81 81 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 82 - github.com/yosssi/gohtml v0.0.0-20201013000340-ee4748c638f4 h1:0sw0nJM544SpsihWx1bkXdYLQDlzRflMgFJQ4Yih9ts= 83 - github.com/yosssi/gohtml v0.0.0-20201013000340-ee4748c638f4/go.mod h1:+ccdNT0xMY1dtc5XBxumbYfOUhmduiGudqaDgD2rVRE= 84 82 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= 85 83 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= 86 84 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= ··· 89 87 go.etcd.io/bbolt v1.3.8/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw= 90 88 golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= 91 89 golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= 92 - golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= 93 - golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= 94 90 golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= 95 91 golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 96 92 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-7
internal/bff/__snapshots__/all_used.snap
··· 1 - --- 2 - title: all used 3 - test_name: TestIterateRemaining_Snapshot/all_used 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - []int(nil)
-7
internal/bff/__snapshots__/avatar_urls.snap
··· 1 - --- 2 - title: avatar_urls 3 - test_name: TestSafeURL_Snapshot/avatar_URLs 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - []string{"", "/static/icon-placeholder.svg", "https://cdn.bsky.app/avatar.jpg", "https://av-cdn.bsky.app/img/avatar/plain/did:plc:test/abc@jpeg", "", "", "", ""}
-53
internal/bff/__snapshots__/bean_form_with_nil_roasters.snap
··· 1 - --- 2 - title: bean form with nil roasters 3 - test_name: TestNewBeanForm_Snapshot/bean_form_with_nil_roasters 4 - file_name: form_template_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - <div x-show="showNewBean" class="mt-4 p-4 bg-brown-100 rounded border border-brown-300"> 8 - <h4 class="font-medium mb-3 text-gray-800"> 9 - Add New Bean 10 - </h4> 11 - <div class="space-y-3"> 12 - <input type="text" x-model="newBean.name" placeholder="Name (e.g. Morning Blend, House Espresso) *" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 13 - <input type="text" x-model="newBean.origin" placeholder="Origin (e.g. Ethiopia) *" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 14 - <select x-model="newBean.roasterRKey" name="roaster_rkey_modal" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 15 - <option value=""> 16 - Select Roaster (Optional) 17 - </option> 18 - </select> 19 - <select x-model="newBean.roastLevel" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 20 - <option value=""> 21 - Select Roast Level (Optional) 22 - </option> 23 - <option value="Ultra-Light"> 24 - Ultra-Light 25 - </option> 26 - <option value="Light"> 27 - Light 28 - </option> 29 - <option value="Medium-Light"> 30 - Medium-Light 31 - </option> 32 - <option value="Medium"> 33 - Medium 34 - </option> 35 - <option value="Medium-Dark"> 36 - Medium-Dark 37 - </option> 38 - <option value="Dark"> 39 - Dark 40 - </option> 41 - </select> 42 - <input type="text" x-model="newBean.process" placeholder="Process (e.g. Washed, Natural, Honey)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 43 - <input type="text" x-model="newBean.description" placeholder="Description (optional)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 44 - <div class="flex gap-2"> 45 - <button type="button" @click="addBean()" class="bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700"> 46 - Add 47 - </button> 48 - <button type="button" @click="showNewBean = false" class="bg-gray-300 px-4 py-2 rounded hover:bg-gray-400"> 49 - Cancel 50 - </button> 51 - </div> 52 - </div> 53 - </div>
-62
internal/bff/__snapshots__/bean_form_with_roasters.snap
··· 1 - --- 2 - title: bean form with roasters 3 - test_name: TestNewBeanForm_Snapshot/bean_form_with_roasters 4 - file_name: form_template_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - <div x-show="showNewBean" class="mt-4 p-4 bg-brown-100 rounded border border-brown-300"> 8 - <h4 class="font-medium mb-3 text-gray-800"> 9 - Add New Bean 10 - </h4> 11 - <div class="space-y-3"> 12 - <input type="text" x-model="newBean.name" placeholder="Name (e.g. Morning Blend, House Espresso) *" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 13 - <input type="text" x-model="newBean.origin" placeholder="Origin (e.g. Ethiopia) *" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 14 - <select x-model="newBean.roasterRKey" name="roaster_rkey_modal" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 15 - <option value=""> 16 - Select Roaster (Optional) 17 - </option> 18 - <option value="roaster1"> 19 - Blue Bottle Coffee 20 - </option> 21 - <option value="roaster2"> 22 - Counter Culture Coffee 23 - </option> 24 - <option value="roaster3"> 25 - Stumptown Coffee Roasters 26 - </option> 27 - </select> 28 - <select x-model="newBean.roastLevel" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 29 - <option value=""> 30 - Select Roast Level (Optional) 31 - </option> 32 - <option value="Ultra-Light"> 33 - Ultra-Light 34 - </option> 35 - <option value="Light"> 36 - Light 37 - </option> 38 - <option value="Medium-Light"> 39 - Medium-Light 40 - </option> 41 - <option value="Medium"> 42 - Medium 43 - </option> 44 - <option value="Medium-Dark"> 45 - Medium-Dark 46 - </option> 47 - <option value="Dark"> 48 - Dark 49 - </option> 50 - </select> 51 - <input type="text" x-model="newBean.process" placeholder="Process (e.g. Washed, Natural, Honey)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 52 - <input type="text" x-model="newBean.description" placeholder="Description (optional)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 53 - <div class="flex gap-2"> 54 - <button type="button" @click="addBean()" class="bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700"> 55 - Add 56 - </button> 57 - <button type="button" @click="showNewBean = false" class="bg-gray-300 px-4 py-2 rounded hover:bg-gray-400"> 58 - Cancel 59 - </button> 60 - </div> 61 - </div> 62 - </div>
-53
internal/bff/__snapshots__/bean_form_without_roasters.snap
··· 1 - --- 2 - title: bean form without roasters 3 - test_name: TestNewBeanForm_Snapshot/bean_form_without_roasters 4 - file_name: form_template_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - <div x-show="showNewBean" class="mt-4 p-4 bg-brown-100 rounded border border-brown-300"> 8 - <h4 class="font-medium mb-3 text-gray-800"> 9 - Add New Bean 10 - </h4> 11 - <div class="space-y-3"> 12 - <input type="text" x-model="newBean.name" placeholder="Name (e.g. Morning Blend, House Espresso) *" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 13 - <input type="text" x-model="newBean.origin" placeholder="Origin (e.g. Ethiopia) *" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 14 - <select x-model="newBean.roasterRKey" name="roaster_rkey_modal" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 15 - <option value=""> 16 - Select Roaster (Optional) 17 - </option> 18 - </select> 19 - <select x-model="newBean.roastLevel" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 20 - <option value=""> 21 - Select Roast Level (Optional) 22 - </option> 23 - <option value="Ultra-Light"> 24 - Ultra-Light 25 - </option> 26 - <option value="Light"> 27 - Light 28 - </option> 29 - <option value="Medium-Light"> 30 - Medium-Light 31 - </option> 32 - <option value="Medium"> 33 - Medium 34 - </option> 35 - <option value="Medium-Dark"> 36 - Medium-Dark 37 - </option> 38 - <option value="Dark"> 39 - Dark 40 - </option> 41 - </select> 42 - <input type="text" x-model="newBean.process" placeholder="Process (e.g. Washed, Natural, Honey)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 43 - <input type="text" x-model="newBean.description" placeholder="Description (optional)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 44 - <div class="flex gap-2"> 45 - <button type="button" @click="addBean()" class="bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700"> 46 - Add 47 - </button> 48 - <button type="button" @click="showNewBean = false" class="bg-gray-300 px-4 py-2 rounded hover:bg-gray-400"> 49 - Cancel 50 - </button> 51 - </div> 52 - </div> 53 - </div>
-106
internal/bff/__snapshots__/bean_with_missing_optional_fields.snap
··· 1 - --- 2 - title: bean with missing optional fields 3 - test_name: TestProfileContent_BeansTab_Snapshot/bean_with_missing_optional_fields 4 - file_name: profile_template_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - <div id="profile-stats-data" 8 - data-brews="0" 9 - data-beans="1" 10 - data-roasters="0" 11 - data-grinders="0" 12 - data-brewers="0" 13 - style="display: none;"></div> 14 - <div x-show="activeTab === 'brews'"> 15 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center border border-brown-300"> 16 - <p class="text-brown-800 text-lg mb-4 font-medium"> 17 - No brews yet! Start tracking your coffee journey. 18 - </p> 19 - <a href="/brews/new" 20 - class="inline-block bg-gradient-to-r from-brown-700 to-brown-800 text-white py-3 px-6 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all shadow-lg hover:shadow-xl font-medium"> 21 - Add Your First Brew 22 - </a> 23 - </div> 24 - </div> 25 - <div x-show="activeTab === 'beans'" x-cloak class="space-y-6"> 26 - <div> 27 - <h3 class="text-lg font-semibold text-brown-900 mb-3"> 28 - ☕ Coffee Beans 29 - </h3> 30 - <div class="overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300"> 31 - <table class="min-w-full divide-y divide-brown-300"> 32 - <thead class="bg-brown-200/80"> 33 - <tr> 34 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap"> 35 - Name 36 - </th> 37 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap"> 38 - ☕ Roaster 39 - </th> 40 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap"> 41 - 📍 Origin 42 - </th> 43 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap"> 44 - 🔥 Roast 45 - </th> 46 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap"> 47 - 🌱 Process 48 - </th> 49 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap"> 50 - 📝 Description 51 - </th> 52 - </tr> 53 - </thead> 54 - <tbody class="bg-brown-50/60 divide-y divide-brown-200"> 55 - <tr class="hover:bg-brown-100/60 transition-colors"> 56 - <td class="px-6 py-4 text-sm font-bold text-brown-900"> 57 - Mystery Bean 58 - </td> 59 - <td class="px-6 py-4 text-sm text-brown-900"> 60 - <span class="text-brown-400"> 61 - - 62 - </span> 63 - </td> 64 - <td class="px-6 py-4 text-sm text-brown-900"> 65 - <span class="text-brown-400"> 66 - - 67 - </span> 68 - </td> 69 - <td class="px-6 py-4 text-sm text-brown-900"> 70 - <span class="text-brown-400"> 71 - - 72 - </span> 73 - </td> 74 - <td class="px-6 py-4 text-sm text-brown-900"> 75 - <span class="text-brown-400"> 76 - - 77 - </span> 78 - </td> 79 - <td class="px-6 py-4 text-sm text-brown-700 italic max-w-xs"> 80 - <span class="text-brown-400 not-italic"> 81 - - 82 - </span> 83 - </td> 84 - </tr> 85 - </tbody> 86 - </table> 87 - </div> 88 - <div class="mt-3 text-center"> 89 - <button @click="editBean('', '', '', '', '', '', '')" class="inline-flex items-center gap-2 bg-brown-600 text-white px-4 py-2 rounded-lg hover:bg-brown-700 transition-all shadow-md hover:shadow-lg text-sm font-medium"> 90 - <span> 91 - + 92 - </span> 93 - <span> 94 - Add New Bean 95 - </span> 96 - </button> 97 - </div> 98 - </div> 99 - </div> 100 - <div x-show="activeTab === 'gear'" x-cloak class="space-y-6"> 101 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center text-brown-800 border border-brown-300"> 102 - <p class="font-medium"> 103 - No gear added yet. 104 - </p> 105 - </div> 106 - </div>
-7
internal/bff/__snapshots__/bean_with_only_origin.snap
··· 1 - --- 2 - title: bean with only origin 3 - test_name: TestTemplateRendering_BeanCard_Snapshot/bean_with_only_origin 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - "\n<div class=\"bean-card\">\n <h3></h3>\n <p>Origin: Colombia</p>\n \n</div>\n"
-68
internal/bff/__snapshots__/bean_with_roaster.snap
··· 1 - --- 2 - title: bean with roaster 3 - test_name: TestFeedTemplate_BeanItem_Snapshot/bean_with_roaster 4 - file_name: feed_template_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - <div class="space-y-4"> 8 - <div class="bg-gradient-to-br from-brown-50 to-brown-100 rounded-lg shadow-md border border-brown-200 p-4 hover:shadow-lg transition-shadow"> 9 - <div class="flex items-center gap-3 mb-3"> 10 - <a href="/profile/roaster.pro" class="flex-shrink-0"> 11 - <div class="w-10 h-10 rounded-full bg-brown-300 flex items-center justify-center hover:ring-2 hover:ring-brown-600 transition"> 12 - <span class="text-brown-600 text-sm"> 13 - ? 14 - </span> 15 - </div> 16 - </a> 17 - <div class="flex-1 min-w-0"> 18 - <div class="flex items-center gap-2"> 19 - <a href="/profile/roaster.pro" class="font-medium text-brown-900 truncate hover:text-brown-700 hover:underline"> 20 - Pro Roaster 21 - </a> 22 - <a href="/profile/roaster.pro" class="text-brown-600 text-sm truncate hover:text-brown-700 hover:underline"> 23 - @roaster.pro 24 - </a> 25 - </div> 26 - <span class="text-brown-500 text-sm"> 27 - 5 minutes ago 28 - </span> 29 - </div> 30 - </div> 31 - <div class="mb-2 text-sm text-brown-700"> 32 - 🫘 added a new bean 33 - </div> 34 - <div class="bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200"> 35 - <div class="text-base mb-2"> 36 - <span class="font-bold text-brown-900"> 37 - Kenya AA 38 - </span> 39 - <span class="text-brown-700"> 40 - from Onyx Coffee Lab 41 - </span> 42 - </div> 43 - <div class="text-sm text-brown-700 space-y-1"> 44 - <div> 45 - <span class="text-brown-600"> 46 - Origin: 47 - </span> 48 - Kenya 49 - </div> 50 - <div> 51 - <span class="text-brown-600"> 52 - Roast: 53 - </span> 54 - Medium 55 - </div> 56 - <div> 57 - <span class="text-brown-600"> 58 - Process: 59 - </span> 60 - Natural 61 - </div> 62 - <div class="mt-2 text-brown-800 italic"> 63 - "Sweet and fruity with notes of blueberry" 64 - </div> 65 - </div> 66 - </div> 67 - </div> 68 - </div>
-65
internal/bff/__snapshots__/bean_without_roaster.snap
··· 1 - --- 2 - title: bean without roaster 3 - test_name: TestFeedTemplate_BeanItem_Snapshot/bean_without_roaster 4 - file_name: feed_template_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - <div class="space-y-4"> 8 - <div class="bg-gradient-to-br from-brown-50 to-brown-100 rounded-lg shadow-md border border-brown-200 p-4 hover:shadow-lg transition-shadow"> 9 - <div class="flex items-center gap-3 mb-3"> 10 - <a href="/profile/homebrewer" class="flex-shrink-0"> 11 - <div class="w-10 h-10 rounded-full bg-brown-300 flex items-center justify-center hover:ring-2 hover:ring-brown-600 transition"> 12 - <span class="text-brown-600 text-sm"> 13 - ? 14 - </span> 15 - </div> 16 - </a> 17 - <div class="flex-1 min-w-0"> 18 - <div class="flex items-center gap-2"> 19 - <a href="/profile/homebrewer" class="font-medium text-brown-900 truncate hover:text-brown-700 hover:underline"> 20 - Home Brewer 21 - </a> 22 - <a href="/profile/homebrewer" class="text-brown-600 text-sm truncate hover:text-brown-700 hover:underline"> 23 - @homebrewer 24 - </a> 25 - </div> 26 - <span class="text-brown-500 text-sm"> 27 - 1 day ago 28 - </span> 29 - </div> 30 - </div> 31 - <div class="mb-2 text-sm text-brown-700"> 32 - 🫘 added a new bean 33 - </div> 34 - <div class="bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200"> 35 - <div class="text-base mb-2"> 36 - <span class="font-bold text-brown-900"> 37 - Colombian Supremo 38 - </span> 39 - </div> 40 - <div class="text-sm text-brown-700 space-y-1"> 41 - <div> 42 - <span class="text-brown-600"> 43 - Origin: 44 - </span> 45 - Colombia 46 - </div> 47 - <div> 48 - <span class="text-brown-600"> 49 - Roast: 50 - </span> 51 - Medium 52 - </div> 53 - <div> 54 - <span class="text-brown-600"> 55 - Process: 56 - </span> 57 - Natural 58 - </div> 59 - <div class="mt-2 text-brown-800 italic"> 60 - "Sweet and fruity with notes of blueberry" 61 - </div> 62 - </div> 63 - </div> 64 - </div> 65 - </div>
-210
internal/bff/__snapshots__/beans_empty.snap
··· 1 - --- 2 - title: beans empty 3 - test_name: TestManageContent_BeansTab_Snapshot/beans_empty 4 - file_name: partial_template_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - <div x-show="tab === 'beans'"> 8 - <div class="mb-4 flex justify-between items-center"> 9 - <h3 class="text-xl font-semibold text-brown-900"> 10 - Coffee Beans 11 - </h3> 12 - <button 13 - @click="showBeanForm = true; editingBean = null; beanForm = {name: '', origin: '', roast_level: '', process: '', description: '', roaster_rkey: ''}" 14 - class="bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-medium shadow-md hover:shadow-lg"> 15 - + Add Bean 16 - </button> 17 - </div> 18 - <div class="bg-brown-100 rounded-lg p-8 text-center text-brown-700 border border-brown-200"> 19 - No beans yet. Add your first bean to get started! 20 - </div> 21 - </div> 22 - <div x-show="tab === 'roasters'"> 23 - <div class="mb-4 flex justify-between items-center"> 24 - <h3 class="text-xl font-semibold text-brown-900"> 25 - Roasters 26 - </h3> 27 - <button 28 - @click="showRoasterForm = true; editingRoaster = null; roasterForm = {name: '', location: '', website: ''}" 29 - class="bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-medium shadow-md hover:shadow-lg"> 30 - + Add Roaster 31 - </button> 32 - </div> 33 - <div class="bg-brown-100 rounded-lg p-8 text-center text-brown-700 border border-brown-200"> 34 - No roasters yet. Add your first roaster! 35 - </div> 36 - </div> 37 - <div x-show="tab === 'grinders'"> 38 - <div class="mb-4 flex justify-between items-center"> 39 - <h3 class="text-xl font-semibold text-brown-900"> 40 - Grinders 41 - </h3> 42 - <button 43 - @click="showGrinderForm = true; editingGrinder = null; grinderForm = {name: '', grinder_type: '', burr_type: '', notes: ''}" 44 - class="bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-medium shadow-md hover:shadow-lg"> 45 - + Add Grinder 46 - </button> 47 - </div> 48 - <div class="bg-brown-100 rounded-lg p-8 text-center text-brown-700 border border-brown-200"> 49 - No grinders yet. Add your first grinder! 50 - </div> 51 - </div> 52 - <div x-show="tab === 'brewers'"> 53 - <div class="mb-4 flex justify-between items-center"> 54 - <h3 class="text-xl font-semibold text-brown-900"> 55 - Brewers 56 - </h3> 57 - <button @click="showBrewerForm = true; editingBrewer = null; brewerForm = {name: '', brewer_type: '', description: ''}" 58 - class="bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-medium shadow-md hover:shadow-lg"> 59 - + Add Brewer 60 - </button> 61 - </div> 62 - <div class="bg-brown-100 rounded-lg p-8 text-center text-brown-700 border border-brown-200"> 63 - No brewers yet. Add your first brewer! 64 - </div> 65 - </div> 66 - <div x-cloak x-show="showBeanForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 67 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 68 - <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingBean ? 'Edit Bean' : 'Add Bean'"></h3> 69 - <div class="space-y-4"> 70 - <input type="text" x-model="beanForm.name" placeholder="Name *" 71 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 72 - <input type="text" x-model="beanForm.origin" placeholder="Origin *" 73 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 74 - <select x-model="beanForm.roaster_rkey" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 75 - <option value=""> 76 - Select Roaster (Optional) 77 - </option> 78 - </select> 79 - <select x-model="beanForm.roast_level" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 80 - <option value=""> 81 - Select Roast Level (Optional) 82 - </option> 83 - <option value="Ultra-Light"> 84 - Ultra-Light 85 - </option> 86 - <option value="Light"> 87 - Light 88 - </option> 89 - <option value="Medium-Light"> 90 - Medium-Light 91 - </option> 92 - <option value="Medium"> 93 - Medium 94 - </option> 95 - <option value="Medium-Dark"> 96 - Medium-Dark 97 - </option> 98 - <option value="Dark"> 99 - Dark 100 - </option> 101 - </select> 102 - <input type="text" x-model="beanForm.process" placeholder="Process (e.g. Washed, Natural, Honey)" 103 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 104 - <textarea x-model="beanForm.description" placeholder="Description" rows="3" 105 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"></textarea> 106 - <div class="flex gap-2"> 107 - <button @click="saveBean()" 108 - class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md"> 109 - Save 110 - </button> 111 - <button @click="showBeanForm = false" 112 - class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 113 - Cancel 114 - </button> 115 - </div> 116 - </div> 117 - </div> 118 - </div> 119 - <div x-cloak x-show="showRoasterForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 120 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 121 - <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingRoaster ? 'Edit Roaster' : 'Add Roaster'"></h3> 122 - <div class="space-y-4"> 123 - <input type="text" x-model="roasterForm.name" placeholder="Name *" 124 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 125 - <input type="text" x-model="roasterForm.location" placeholder="Location" 126 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 127 - <input type="url" x-model="roasterForm.website" placeholder="Website" 128 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 129 - <div class="flex gap-2"> 130 - <button @click="saveRoaster()" 131 - class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md"> 132 - Save 133 - </button> 134 - <button @click="showRoasterForm = false" 135 - class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 136 - Cancel 137 - </button> 138 - </div> 139 - </div> 140 - </div> 141 - </div> 142 - <div x-cloak x-show="showGrinderForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 143 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 144 - <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingGrinder ? 'Edit Grinder' : 'Add Grinder'"></h3> 145 - <div class="space-y-4"> 146 - <input type="text" x-model="grinderForm.name" placeholder="Name *" 147 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 148 - <select x-model="grinderForm.grinder_type" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 149 - <option value=""> 150 - Select Grinder Type * 151 - </option> 152 - <option value="Hand"> 153 - Hand 154 - </option> 155 - <option value="Electric"> 156 - Electric 157 - </option> 158 - <option value="Portable Electric"> 159 - Portable Electric 160 - </option> 161 - </select> 162 - <select x-model="grinderForm.burr_type" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 163 - <option value=""> 164 - Select Burr Type (Optional) 165 - </option> 166 - <option value="Conical"> 167 - Conical 168 - </option> 169 - <option value="Flat"> 170 - Flat 171 - </option> 172 - </select> 173 - <textarea x-model="grinderForm.notes" placeholder="Notes" rows="3" 174 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"></textarea> 175 - <div class="flex gap-2"> 176 - <button @click="saveGrinder()" 177 - class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md"> 178 - Save 179 - </button> 180 - <button @click="showGrinderForm = false" 181 - class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 182 - Cancel 183 - </button> 184 - </div> 185 - </div> 186 - </div> 187 - </div> 188 - <div x-cloak x-show="showBrewerForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 189 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 190 - <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingBrewer ? 'Edit Brewer' : 'Add Brewer'"></h3> 191 - <div class="space-y-4"> 192 - <input type="text" x-model="brewerForm.name" placeholder="Name *" 193 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 194 - <input type="text" x-model="brewerForm.brewer_type" placeholder="Type (e.g., Pour-Over, Immersion, Espresso)" 195 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 196 - <textarea x-model="brewerForm.description" placeholder="Description" rows="3" 197 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"></textarea> 198 - <div class="flex gap-2"> 199 - <button @click="saveBrewer()" 200 - class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md"> 201 - Save 202 - </button> 203 - <button @click="showBrewerForm = false" 204 - class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 205 - Cancel 206 - </button> 207 - </div> 208 - </div> 209 - </div> 210 - </div>
-353
internal/bff/__snapshots__/beans_with_roaster.snap
··· 1 - --- 2 - title: beans with roaster 3 - test_name: TestManageContent_BeansTab_Snapshot/beans_with_roaster 4 - file_name: partial_template_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - <div x-show="tab === 'beans'"> 8 - <div class="mb-4 flex justify-between items-center"> 9 - <h3 class="text-xl font-semibold text-brown-900"> 10 - Coffee Beans 11 - </h3> 12 - <button 13 - @click="showBeanForm = true; editingBean = null; beanForm = {name: '', origin: '', roast_level: '', process: '', description: '', roaster_rkey: ''}" 14 - class="bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-medium shadow-md hover:shadow-lg"> 15 - + Add Bean 16 - </button> 17 - </div> 18 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 shadow-xl rounded-xl overflow-x-auto border border-brown-300"> 19 - <table class="min-w-full divide-y divide-brown-300"> 20 - <thead class="bg-brown-200/80"> 21 - <tr> 22 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase whitespace-nowrap"> 23 - Name 24 - </th> 25 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase whitespace-nowrap"> 26 - 📍 Origin 27 - </th> 28 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase whitespace-nowrap"> 29 - ☕ Roaster 30 - </th> 31 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase whitespace-nowrap"> 32 - 🔥 Roast Level 33 - </th> 34 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase whitespace-nowrap"> 35 - 🌱 Process 36 - </th> 37 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase whitespace-nowrap"> 38 - 📝 Description 39 - </th> 40 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase whitespace-nowrap"> 41 - Actions 42 - </th> 43 - </tr> 44 - </thead> 45 - <tbody class="bg-brown-50/60 divide-y divide-brown-200"> 46 - <tr class="hover:bg-brown-100/60 transition-colors"> 47 - <td class="px-6 py-4 text-sm font-medium text-brown-900"> 48 - Ethiopian Yirgacheffe 49 - </td> 50 - <td class="px-6 py-4 text-sm text-brown-900"> 51 - Ethiopia 52 - </td> 53 - <td class="px-6 py-4 text-sm text-brown-900"> 54 - Onyx Coffee Lab 55 - </td> 56 - <td class="px-6 py-4 text-sm text-brown-900"> 57 - Light 58 - </td> 59 - <td class="px-6 py-4 text-sm text-brown-900"> 60 - Washed 61 - </td> 62 - <td class="px-6 py-4 text-sm text-brown-700"> 63 - Bright and fruity with notes of blueberry 64 - </td> 65 - <td class="px-6 py-4 text-sm font-medium space-x-2"> 66 - <button @click="editBean('bean1', 'Ethiopian Yirgacheffe', 'Ethiopia', 'Light', 'Washed', 'Bright and fruity with notes of blueberry', 'roaster1')" 67 - class="text-brown-700 hover:text-brown-900 font-medium"> 68 - Edit 69 - </button> 70 - <button @click="deleteBean('bean1')" 71 - class="text-brown-600 hover:text-brown-800 font-medium"> 72 - Delete 73 - </button> 74 - </td> 75 - </tr> 76 - <tr class="hover:bg-brown-100/60 transition-colors"> 77 - <td class="px-6 py-4 text-sm font-medium text-brown-900"></td> 78 - <td class="px-6 py-4 text-sm text-brown-900"> 79 - Colombia 80 - </td> 81 - <td class="px-6 py-4 text-sm text-brown-900"> 82 - <span class="text-brown-400"> 83 - - 84 - </span> 85 - </td> 86 - <td class="px-6 py-4 text-sm text-brown-900"> 87 - Medium 88 - </td> 89 - <td class="px-6 py-4 text-sm text-brown-900"></td> 90 - <td class="px-6 py-4 text-sm text-brown-700"></td> 91 - <td class="px-6 py-4 text-sm font-medium space-x-2"> 92 - <button @click="editBean('bean2', '', 'Colombia', 'Medium', '', '', '')" 93 - class="text-brown-700 hover:text-brown-900 font-medium"> 94 - Edit 95 - </button> 96 - <button @click="deleteBean('bean2')" 97 - class="text-brown-600 hover:text-brown-800 font-medium"> 98 - Delete 99 - </button> 100 - </td> 101 - </tr> 102 - </tbody> 103 - </table> 104 - </div> 105 - </div> 106 - <div x-show="tab === 'roasters'"> 107 - <div class="mb-4 flex justify-between items-center"> 108 - <h3 class="text-xl font-semibold text-brown-900"> 109 - Roasters 110 - </h3> 111 - <button 112 - @click="showRoasterForm = true; editingRoaster = null; roasterForm = {name: '', location: '', website: ''}" 113 - class="bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-medium shadow-md hover:shadow-lg"> 114 - + Add Roaster 115 - </button> 116 - </div> 117 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 shadow-xl rounded-xl overflow-x-auto border border-brown-300"> 118 - <table class="min-w-full divide-y divide-brown-300"> 119 - <thead class="bg-brown-200/80"> 120 - <tr> 121 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase"> 122 - Name 123 - </th> 124 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase"> 125 - 📍 Location 126 - </th> 127 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase"> 128 - 🌐 Website 129 - </th> 130 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase"> 131 - Actions 132 - </th> 133 - </tr> 134 - </thead> 135 - <tbody class="bg-brown-50/60 divide-y divide-brown-200"> 136 - <tr class="hover:bg-brown-100/60 transition-colors"> 137 - <td class="px-6 py-4 text-sm font-medium text-brown-900"> 138 - Onyx Coffee Lab 139 - </td> 140 - <td class="px-6 py-4 text-sm text-brown-900"></td> 141 - <td class="px-6 py-4 text-sm text-brown-900"></td> 142 - <td class="px-6 py-4 text-sm font-medium space-x-2"> 143 - <button @click="editRoaster('roaster1', 'Onyx Coffee Lab', '', '')" 144 - class="text-brown-700 hover:text-brown-900 font-medium"> 145 - Edit 146 - </button> 147 - <button @click="deleteRoaster('roaster1')" 148 - class="text-brown-600 hover:text-brown-800 font-medium"> 149 - Delete 150 - </button> 151 - </td> 152 - </tr> 153 - <tr class="hover:bg-brown-100/60 transition-colors"> 154 - <td class="px-6 py-4 text-sm font-medium text-brown-900"> 155 - Counter Culture 156 - </td> 157 - <td class="px-6 py-4 text-sm text-brown-900"></td> 158 - <td class="px-6 py-4 text-sm text-brown-900"></td> 159 - <td class="px-6 py-4 text-sm font-medium space-x-2"> 160 - <button @click="editRoaster('roaster2', 'Counter Culture', '', '')" 161 - class="text-brown-700 hover:text-brown-900 font-medium"> 162 - Edit 163 - </button> 164 - <button @click="deleteRoaster('roaster2')" 165 - class="text-brown-600 hover:text-brown-800 font-medium"> 166 - Delete 167 - </button> 168 - </td> 169 - </tr> 170 - </tbody> 171 - </table> 172 - </div> 173 - </div> 174 - <div x-show="tab === 'grinders'"> 175 - <div class="mb-4 flex justify-between items-center"> 176 - <h3 class="text-xl font-semibold text-brown-900"> 177 - Grinders 178 - </h3> 179 - <button 180 - @click="showGrinderForm = true; editingGrinder = null; grinderForm = {name: '', grinder_type: '', burr_type: '', notes: ''}" 181 - class="bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-medium shadow-md hover:shadow-lg"> 182 - + Add Grinder 183 - </button> 184 - </div> 185 - <div class="bg-brown-100 rounded-lg p-8 text-center text-brown-700 border border-brown-200"> 186 - No grinders yet. Add your first grinder! 187 - </div> 188 - </div> 189 - <div x-show="tab === 'brewers'"> 190 - <div class="mb-4 flex justify-between items-center"> 191 - <h3 class="text-xl font-semibold text-brown-900"> 192 - Brewers 193 - </h3> 194 - <button @click="showBrewerForm = true; editingBrewer = null; brewerForm = {name: '', brewer_type: '', description: ''}" 195 - class="bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-medium shadow-md hover:shadow-lg"> 196 - + Add Brewer 197 - </button> 198 - </div> 199 - <div class="bg-brown-100 rounded-lg p-8 text-center text-brown-700 border border-brown-200"> 200 - No brewers yet. Add your first brewer! 201 - </div> 202 - </div> 203 - <div x-cloak x-show="showBeanForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 204 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 205 - <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingBean ? 'Edit Bean' : 'Add Bean'"></h3> 206 - <div class="space-y-4"> 207 - <input type="text" x-model="beanForm.name" placeholder="Name *" 208 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 209 - <input type="text" x-model="beanForm.origin" placeholder="Origin *" 210 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 211 - <select x-model="beanForm.roaster_rkey" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 212 - <option value=""> 213 - Select Roaster (Optional) 214 - </option> 215 - <option value="roaster1"> 216 - Onyx Coffee Lab 217 - </option> 218 - <option value="roaster2"> 219 - Counter Culture 220 - </option> 221 - </select> 222 - <select x-model="beanForm.roast_level" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 223 - <option value=""> 224 - Select Roast Level (Optional) 225 - </option> 226 - <option value="Ultra-Light"> 227 - Ultra-Light 228 - </option> 229 - <option value="Light"> 230 - Light 231 - </option> 232 - <option value="Medium-Light"> 233 - Medium-Light 234 - </option> 235 - <option value="Medium"> 236 - Medium 237 - </option> 238 - <option value="Medium-Dark"> 239 - Medium-Dark 240 - </option> 241 - <option value="Dark"> 242 - Dark 243 - </option> 244 - </select> 245 - <input type="text" x-model="beanForm.process" placeholder="Process (e.g. Washed, Natural, Honey)" 246 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 247 - <textarea x-model="beanForm.description" placeholder="Description" rows="3" 248 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"></textarea> 249 - <div class="flex gap-2"> 250 - <button @click="saveBean()" 251 - class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md"> 252 - Save 253 - </button> 254 - <button @click="showBeanForm = false" 255 - class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 256 - Cancel 257 - </button> 258 - </div> 259 - </div> 260 - </div> 261 - </div> 262 - <div x-cloak x-show="showRoasterForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 263 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 264 - <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingRoaster ? 'Edit Roaster' : 'Add Roaster'"></h3> 265 - <div class="space-y-4"> 266 - <input type="text" x-model="roasterForm.name" placeholder="Name *" 267 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 268 - <input type="text" x-model="roasterForm.location" placeholder="Location" 269 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 270 - <input type="url" x-model="roasterForm.website" placeholder="Website" 271 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 272 - <div class="flex gap-2"> 273 - <button @click="saveRoaster()" 274 - class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md"> 275 - Save 276 - </button> 277 - <button @click="showRoasterForm = false" 278 - class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 279 - Cancel 280 - </button> 281 - </div> 282 - </div> 283 - </div> 284 - </div> 285 - <div x-cloak x-show="showGrinderForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 286 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 287 - <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingGrinder ? 'Edit Grinder' : 'Add Grinder'"></h3> 288 - <div class="space-y-4"> 289 - <input type="text" x-model="grinderForm.name" placeholder="Name *" 290 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 291 - <select x-model="grinderForm.grinder_type" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 292 - <option value=""> 293 - Select Grinder Type * 294 - </option> 295 - <option value="Hand"> 296 - Hand 297 - </option> 298 - <option value="Electric"> 299 - Electric 300 - </option> 301 - <option value="Portable Electric"> 302 - Portable Electric 303 - </option> 304 - </select> 305 - <select x-model="grinderForm.burr_type" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 306 - <option value=""> 307 - Select Burr Type (Optional) 308 - </option> 309 - <option value="Conical"> 310 - Conical 311 - </option> 312 - <option value="Flat"> 313 - Flat 314 - </option> 315 - </select> 316 - <textarea x-model="grinderForm.notes" placeholder="Notes" rows="3" 317 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"></textarea> 318 - <div class="flex gap-2"> 319 - <button @click="saveGrinder()" 320 - class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md"> 321 - Save 322 - </button> 323 - <button @click="showGrinderForm = false" 324 - class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 325 - Cancel 326 - </button> 327 - </div> 328 - </div> 329 - </div> 330 - </div> 331 - <div x-cloak x-show="showBrewerForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 332 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 333 - <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingBrewer ? 'Edit Brewer' : 'Add Brewer'"></h3> 334 - <div class="space-y-4"> 335 - <input type="text" x-model="brewerForm.name" placeholder="Name *" 336 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 337 - <input type="text" x-model="brewerForm.brewer_type" placeholder="Type (e.g., Pour-Over, Immersion, Espresso)" 338 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 339 - <textarea x-model="brewerForm.description" placeholder="Description" rows="3" 340 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"></textarea> 341 - <div class="flex gap-2"> 342 - <button @click="saveBrewer()" 343 - class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md"> 344 - Save 345 - </button> 346 - <button @click="showBrewerForm = false" 347 - class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 348 - Cancel 349 - </button> 350 - </div> 351 - </div> 352 - </div> 353 - </div>
-270
internal/bff/__snapshots__/beans_with_special_characters_and_html.snap
··· 1 - --- 2 - title: beans with special characters and html 3 - test_name: TestManageContent_SpecialCharacters_Snapshot/beans_with_special_characters_and_html 4 - file_name: partial_template_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - <div x-show="tab === 'beans'"> 8 - <div class="mb-4 flex justify-between items-center"> 9 - <h3 class="text-xl font-semibold text-brown-900"> 10 - Coffee Beans 11 - </h3> 12 - <button 13 - @click="showBeanForm = true; editingBean = null; beanForm = {name: '', origin: '', roast_level: '', process: '', description: '', roaster_rkey: ''}" 14 - class="bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-medium shadow-md hover:shadow-lg"> 15 - + Add Bean 16 - </button> 17 - </div> 18 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 shadow-xl rounded-xl overflow-x-auto border border-brown-300"> 19 - <table class="min-w-full divide-y divide-brown-300"> 20 - <thead class="bg-brown-200/80"> 21 - <tr> 22 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase whitespace-nowrap"> 23 - Name 24 - </th> 25 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase whitespace-nowrap"> 26 - 📍 Origin 27 - </th> 28 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase whitespace-nowrap"> 29 - ☕ Roaster 30 - </th> 31 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase whitespace-nowrap"> 32 - 🔥 Roast Level 33 - </th> 34 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase whitespace-nowrap"> 35 - 🌱 Process 36 - </th> 37 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase whitespace-nowrap"> 38 - 📝 Description 39 - </th> 40 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase whitespace-nowrap"> 41 - Actions 42 - </th> 43 - </tr> 44 - </thead> 45 - <tbody class="bg-brown-50/60 divide-y divide-brown-200"> 46 - <tr class="hover:bg-brown-100/60 transition-colors"> 47 - <td class="px-6 py-4 text-sm font-medium text-brown-900"> 48 - Café &lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt; Especial 49 - </td> 50 - <td class="px-6 py-4 text-sm text-brown-900"> 51 - Costa Rica™ 52 - </td> 53 - <td class="px-6 py-4 text-sm text-brown-900"> 54 - <span class="text-brown-400"> 55 - - 56 - </span> 57 - </td> 58 - <td class="px-6 py-4 text-sm text-brown-900"> 59 - Medium 60 - </td> 61 - <td class="px-6 py-4 text-sm text-brown-900"> 62 - Honey &amp; Washed 63 - </td> 64 - <td class="px-6 py-4 text-sm text-brown-700"> 65 - &#34;Amazing&#34; coffee with &lt;strong&gt;bold&lt;/strong&gt; flavor 66 - </td> 67 - <td class="px-6 py-4 text-sm font-medium space-x-2"> 68 - <button @click="editBean('bean1', 'Café &lt;script&gt;alert(\&#39;xss\&#39;)&lt;/script&gt; Especial', 'Costa Rica™', 'Medium', 'Honey &amp; Washed', '\&#34;Amazing\&#34; coffee with &lt;strong&gt;bold&lt;/strong&gt; flavor', '')" 69 - class="text-brown-700 hover:text-brown-900 font-medium"> 70 - Edit 71 - </button> 72 - <button @click="deleteBean('bean1')" 73 - class="text-brown-600 hover:text-brown-800 font-medium"> 74 - Delete 75 - </button> 76 - </td> 77 - </tr> 78 - </tbody> 79 - </table> 80 - </div> 81 - </div> 82 - <div x-show="tab === 'roasters'"> 83 - <div class="mb-4 flex justify-between items-center"> 84 - <h3 class="text-xl font-semibold text-brown-900"> 85 - Roasters 86 - </h3> 87 - <button 88 - @click="showRoasterForm = true; editingRoaster = null; roasterForm = {name: '', location: '', website: ''}" 89 - class="bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-medium shadow-md hover:shadow-lg"> 90 - + Add Roaster 91 - </button> 92 - </div> 93 - <div class="bg-brown-100 rounded-lg p-8 text-center text-brown-700 border border-brown-200"> 94 - No roasters yet. Add your first roaster! 95 - </div> 96 - </div> 97 - <div x-show="tab === 'grinders'"> 98 - <div class="mb-4 flex justify-between items-center"> 99 - <h3 class="text-xl font-semibold text-brown-900"> 100 - Grinders 101 - </h3> 102 - <button 103 - @click="showGrinderForm = true; editingGrinder = null; grinderForm = {name: '', grinder_type: '', burr_type: '', notes: ''}" 104 - class="bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-medium shadow-md hover:shadow-lg"> 105 - + Add Grinder 106 - </button> 107 - </div> 108 - <div class="bg-brown-100 rounded-lg p-8 text-center text-brown-700 border border-brown-200"> 109 - No grinders yet. Add your first grinder! 110 - </div> 111 - </div> 112 - <div x-show="tab === 'brewers'"> 113 - <div class="mb-4 flex justify-between items-center"> 114 - <h3 class="text-xl font-semibold text-brown-900"> 115 - Brewers 116 - </h3> 117 - <button @click="showBrewerForm = true; editingBrewer = null; brewerForm = {name: '', brewer_type: '', description: ''}" 118 - class="bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-medium shadow-md hover:shadow-lg"> 119 - + Add Brewer 120 - </button> 121 - </div> 122 - <div class="bg-brown-100 rounded-lg p-8 text-center text-brown-700 border border-brown-200"> 123 - No brewers yet. Add your first brewer! 124 - </div> 125 - </div> 126 - <div x-cloak x-show="showBeanForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 127 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 128 - <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingBean ? 'Edit Bean' : 'Add Bean'"></h3> 129 - <div class="space-y-4"> 130 - <input type="text" x-model="beanForm.name" placeholder="Name *" 131 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 132 - <input type="text" x-model="beanForm.origin" placeholder="Origin *" 133 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 134 - <select x-model="beanForm.roaster_rkey" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 135 - <option value=""> 136 - Select Roaster (Optional) 137 - </option> 138 - </select> 139 - <select x-model="beanForm.roast_level" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 140 - <option value=""> 141 - Select Roast Level (Optional) 142 - </option> 143 - <option value="Ultra-Light"> 144 - Ultra-Light 145 - </option> 146 - <option value="Light"> 147 - Light 148 - </option> 149 - <option value="Medium-Light"> 150 - Medium-Light 151 - </option> 152 - <option value="Medium"> 153 - Medium 154 - </option> 155 - <option value="Medium-Dark"> 156 - Medium-Dark 157 - </option> 158 - <option value="Dark"> 159 - Dark 160 - </option> 161 - </select> 162 - <input type="text" x-model="beanForm.process" placeholder="Process (e.g. Washed, Natural, Honey)" 163 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 164 - <textarea x-model="beanForm.description" placeholder="Description" rows="3" 165 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"></textarea> 166 - <div class="flex gap-2"> 167 - <button @click="saveBean()" 168 - class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md"> 169 - Save 170 - </button> 171 - <button @click="showBeanForm = false" 172 - class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 173 - Cancel 174 - </button> 175 - </div> 176 - </div> 177 - </div> 178 - </div> 179 - <div x-cloak x-show="showRoasterForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 180 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 181 - <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingRoaster ? 'Edit Roaster' : 'Add Roaster'"></h3> 182 - <div class="space-y-4"> 183 - <input type="text" x-model="roasterForm.name" placeholder="Name *" 184 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 185 - <input type="text" x-model="roasterForm.location" placeholder="Location" 186 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 187 - <input type="url" x-model="roasterForm.website" placeholder="Website" 188 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 189 - <div class="flex gap-2"> 190 - <button @click="saveRoaster()" 191 - class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md"> 192 - Save 193 - </button> 194 - <button @click="showRoasterForm = false" 195 - class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 196 - Cancel 197 - </button> 198 - </div> 199 - </div> 200 - </div> 201 - </div> 202 - <div x-cloak x-show="showGrinderForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 203 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 204 - <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingGrinder ? 'Edit Grinder' : 'Add Grinder'"></h3> 205 - <div class="space-y-4"> 206 - <input type="text" x-model="grinderForm.name" placeholder="Name *" 207 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 208 - <select x-model="grinderForm.grinder_type" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 209 - <option value=""> 210 - Select Grinder Type * 211 - </option> 212 - <option value="Hand"> 213 - Hand 214 - </option> 215 - <option value="Electric"> 216 - Electric 217 - </option> 218 - <option value="Portable Electric"> 219 - Portable Electric 220 - </option> 221 - </select> 222 - <select x-model="grinderForm.burr_type" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 223 - <option value=""> 224 - Select Burr Type (Optional) 225 - </option> 226 - <option value="Conical"> 227 - Conical 228 - </option> 229 - <option value="Flat"> 230 - Flat 231 - </option> 232 - </select> 233 - <textarea x-model="grinderForm.notes" placeholder="Notes" rows="3" 234 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"></textarea> 235 - <div class="flex gap-2"> 236 - <button @click="saveGrinder()" 237 - class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md"> 238 - Save 239 - </button> 240 - <button @click="showGrinderForm = false" 241 - class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 242 - Cancel 243 - </button> 244 - </div> 245 - </div> 246 - </div> 247 - </div> 248 - <div x-cloak x-show="showBrewerForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 249 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 250 - <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingBrewer ? 'Edit Brewer' : 'Add Brewer'"></h3> 251 - <div class="space-y-4"> 252 - <input type="text" x-model="brewerForm.name" placeholder="Name *" 253 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 254 - <input type="text" x-model="brewerForm.brewer_type" placeholder="Type (e.g., Pour-Over, Immersion, Espresso)" 255 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 256 - <textarea x-model="brewerForm.description" placeholder="Description" rows="3" 257 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"></textarea> 258 - <div class="flex gap-2"> 259 - <button @click="saveBrewer()" 260 - class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md"> 261 - Save 262 - </button> 263 - <button @click="showBrewerForm = false" 264 - class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 265 - Cancel 266 - </button> 267 - </div> 268 - </div> 269 - </div> 270 - </div>
-76
internal/bff/__snapshots__/brew_list_minimal_data.snap
··· 1 - --- 2 - title: brew list minimal data 3 - test_name: TestBrewListContent_Snapshot/brew_list_minimal_data 4 - file_name: partial_template_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - <div class="overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300"> 8 - <table class="min-w-full divide-y divide-brown-300"> 9 - <thead class="bg-brown-200/80"> 10 - <tr> 11 - <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider"> 12 - Date 13 - </th> 14 - <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider"> 15 - Bean 16 - </th> 17 - <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider"> 18 - Brewer 19 - </th> 20 - <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider"> 21 - Variables 22 - </th> 23 - <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider"> 24 - Notes 25 - </th> 26 - <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider"> 27 - Rating 28 - </th> 29 - <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider"> 30 - Actions 31 - </th> 32 - </tr> 33 - </thead> 34 - <tbody class="bg-brown-50/60 divide-y divide-brown-200"> 35 - <tr class="hover:bg-brown-100/60 transition-colors"> 36 - <td class="px-4 py-4 whitespace-nowrap text-sm text-brown-900 font-medium align-top"> 37 - <div> 38 - Jan 15 39 - </div> 40 - <div class="text-xs text-brown-600"> 41 - 2024 42 - </div> 43 - </td> 44 - <td class="px-4 py-4 text-sm text-brown-900 align-top"> 45 - <span class="text-brown-400"> 46 - - 47 - </span> 48 - </td> 49 - <td class="px-4 py-4 text-sm text-brown-900 align-top"> 50 - <span class="text-brown-400"> 51 - - 52 - </span> 53 - </td> 54 - <td class="px-4 py-4 text-xs text-brown-700 align-top"> 55 - <div class="space-y-1"></div> 56 - </td> 57 - <td class="px-4 py-4 text-xs text-brown-800 align-top max-w-xs"> 58 - <span class="text-brown-400"> 59 - - 60 - </span> 61 - </td> 62 - <td class="px-4 py-4 whitespace-nowrap text-sm text-brown-900 align-top"> 63 - <span class="text-brown-400"> 64 - - 65 - </span> 66 - </td> 67 - <td class="px-4 py-4 whitespace-nowrap text-sm font-medium space-x-2 align-top"> 68 - <a href="/brews/brew3" 69 - class="text-brown-700 hover:text-brown-900 font-medium"> 70 - View 71 - </a> 72 - </td> 73 - </tr> 74 - </tbody> 75 - </table> 76 - </div>
-190
internal/bff/__snapshots__/brew_list_with_complete_data.snap
··· 1 - --- 2 - title: brew list with complete data 3 - test_name: TestBrewListContent_Snapshot/brew_list_with_complete_data 4 - file_name: partial_template_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - <div class="overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300"> 8 - <table class="min-w-full divide-y divide-brown-300"> 9 - <thead class="bg-brown-200/80"> 10 - <tr> 11 - <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider"> 12 - Date 13 - </th> 14 - <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider"> 15 - Bean 16 - </th> 17 - <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider"> 18 - Brewer 19 - </th> 20 - <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider"> 21 - Variables 22 - </th> 23 - <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider"> 24 - Notes 25 - </th> 26 - <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider"> 27 - Rating 28 - </th> 29 - <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider"> 30 - Actions 31 - </th> 32 - </tr> 33 - </thead> 34 - <tbody class="bg-brown-50/60 divide-y divide-brown-200"> 35 - <tr class="hover:bg-brown-100/60 transition-colors"> 36 - <td class="px-4 py-4 whitespace-nowrap text-sm text-brown-900 font-medium align-top"> 37 - <div> 38 - Jan 15 39 - </div> 40 - <div class="text-xs text-brown-600"> 41 - 2024 42 - </div> 43 - </td> 44 - <td class="px-4 py-4 text-sm text-brown-900 align-top"> 45 - <div class="font-bold text-brown-900"> 46 - Ethiopian Yirgacheffe 47 - </div> 48 - <div class="text-xs text-brown-700 mt-0.5"> 49 - <span class="font-medium"> 50 - Onyx Coffee Lab 51 - </span> 52 - </div> 53 - <div class="text-xs text-brown-600 mt-0.5 flex flex-wrap gap-x-2 gap-y-0.5"> 54 - <span class="inline-flex items-center gap-0.5"> 55 - 📍 Ethiopia 56 - </span> 57 - <span class="inline-flex items-center gap-0.5"> 58 - 🔥 Light 59 - </span> 60 - <span class="inline-flex items-center gap-0.5"> 61 - ⚖️ 18g 62 - </span> 63 - </div> 64 - </td> 65 - <td class="px-4 py-4 text-sm text-brown-900 align-top"> 66 - <div class="font-medium text-brown-900"> 67 - Hario V60 68 - </div> 69 - </td> 70 - <td class="px-4 py-4 text-xs text-brown-700 align-top"> 71 - <div class="space-y-1"> 72 - <div> 73 - <span class="text-brown-600"> 74 - Grinder: 75 - </span> 76 - Comandante C40 (Medium-fine) 77 - </div> 78 - <div> 79 - <span class="text-brown-600"> 80 - Temp: 81 - </span> 82 - 93.0°C 83 - </div> 84 - <div> 85 - <span class="text-brown-600"> 86 - Pours: 87 - </span> 88 - </div> 89 - <div class="pl-2 text-brown-600"> 90 - • 50g @ 30s 91 - </div> 92 - <div class="pl-2 text-brown-600"> 93 - • 100g @ 45s 94 - </div> 95 - <div class="pl-2 text-brown-600"> 96 - • 100g @ 1m 97 - </div> 98 - <div> 99 - <span class="text-brown-600"> 100 - Time: 101 - </span> 102 - 3m 103 - </div> 104 - </div> 105 - </td> 106 - <td class="px-4 py-4 text-xs text-brown-800 align-top max-w-xs"> 107 - <div class="italic line-clamp-3"> 108 - Bright citrus notes with floral aroma. Clean finish. 109 - </div> 110 - </td> 111 - <td class="px-4 py-4 whitespace-nowrap text-sm text-brown-900 align-top"> 112 - <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-900"> 113 - ⭐ 8/10 114 - </span> 115 - </td> 116 - <td class="px-4 py-4 whitespace-nowrap text-sm font-medium space-x-2 align-top"> 117 - <a href="/brews/brew1" 118 - class="text-brown-700 hover:text-brown-900 font-medium"> 119 - View 120 - </a> 121 - <a href="/brews/brew1/edit" 122 - class="text-brown-700 hover:text-brown-900 font-medium"> 123 - Edit 124 - </a> 125 - <button hx-delete="/brews/brew1" 126 - hx-confirm="Are you sure you want to delete this brew?" hx-target="closest tr" 127 - hx-swap="outerHTML swap:1s" class="text-brown-600 hover:text-brown-800 font-medium"> 128 - Delete 129 - </button> 130 - </td> 131 - </tr> 132 - <tr class="hover:bg-brown-100/60 transition-colors"> 133 - <td class="px-4 py-4 whitespace-nowrap text-sm text-brown-900 font-medium align-top"> 134 - <div> 135 - Jan 14 136 - </div> 137 - <div class="text-xs text-brown-600"> 138 - 2024 139 - </div> 140 - </td> 141 - <td class="px-4 py-4 text-sm text-brown-900 align-top"> 142 - <div class="font-bold text-brown-900"> 143 - Colombia 144 - </div> 145 - <div class="text-xs text-brown-600 mt-0.5 flex flex-wrap gap-x-2 gap-y-0.5"> 146 - <span class="inline-flex items-center gap-0.5"> 147 - 📍 Colombia 148 - </span> 149 - <span class="inline-flex items-center gap-0.5"> 150 - 🔥 Medium 151 - </span> 152 - </div> 153 - </td> 154 - <td class="px-4 py-4 text-sm text-brown-900 align-top"> 155 - <div class="font-medium text-brown-900"> 156 - AeroPress 157 - </div> 158 - </td> 159 - <td class="px-4 py-4 text-xs text-brown-700 align-top"> 160 - <div class="space-y-1"></div> 161 - </td> 162 - <td class="px-4 py-4 text-xs text-brown-800 align-top max-w-xs"> 163 - <span class="text-brown-400"> 164 - - 165 - </span> 166 - </td> 167 - <td class="px-4 py-4 whitespace-nowrap text-sm text-brown-900 align-top"> 168 - <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-900"> 169 - ⭐ 6/10 170 - </span> 171 - </td> 172 - <td class="px-4 py-4 whitespace-nowrap text-sm font-medium space-x-2 align-top"> 173 - <a href="/brews/brew2" 174 - class="text-brown-700 hover:text-brown-900 font-medium"> 175 - View 176 - </a> 177 - <a href="/brews/brew2/edit" 178 - class="text-brown-700 hover:text-brown-900 font-medium"> 179 - Edit 180 - </a> 181 - <button hx-delete="/brews/brew2" 182 - hx-confirm="Are you sure you want to delete this brew?" hx-target="closest tr" 183 - hx-swap="outerHTML swap:1s" class="text-brown-600 hover:text-brown-800 font-medium"> 184 - Delete 185 - </button> 186 - </td> 187 - </tr> 188 - </tbody> 189 - </table> 190 - </div>
-348
internal/bff/__snapshots__/brew_with_html_in_tasting_notes.snap
··· 1 - --- 2 - title: brew with html in tasting notes 3 - test_name: TestBrewForm_SpecialCharacters_Snapshot/brew_with_html_in_tasting_notes 4 - file_name: form_template_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - <script src="/static/js/brew-form.js"></script> 8 - <div class="max-w-2xl mx-auto"> 9 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 border border-brown-300"> 10 - <h2 class="text-3xl font-bold text-brown-900 mb-6"> 11 - Edit Brew 12 - </h2> 13 - <form 14 - 15 - hx-put="/brews/brew1" 16 - 17 - hx-target="body" 18 - class="space-y-6" 19 - x-data="brewForm()" 20 - > 21 - <div> 22 - <label class="block text-sm font-medium text-brown-900 mb-2"> 23 - Coffee Bean 24 - </label> 25 - <div class="flex gap-2"> 26 - <select 27 - name="bean_rkey" 28 - required 29 - class="flex-1 rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 truncate max-w-full bg-white"> 30 - <option value=""> 31 - Select a bean... 32 - </option> 33 - <option 34 - value="bean1" 35 - selected 36 - class="truncate"> 37 - Test &lt;strong&gt;Bean&lt;/strong&gt; (Ethiopia - Light) 38 - </option> 39 - </select> 40 - <button 41 - type="button" 42 - @click="showNewBean = true" 43 - class="bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 44 - + New 45 - </button> 46 - </div> 47 - <div x-show="showNewBean" class="mt-4 p-4 bg-brown-100 rounded border border-brown-300"> 48 - <h4 class="font-medium mb-3 text-gray-800"> 49 - Add New Bean 50 - </h4> 51 - <div class="space-y-3"> 52 - <input type="text" x-model="newBean.name" placeholder="Name (e.g. Morning Blend, House Espresso) *" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 53 - <input type="text" x-model="newBean.origin" placeholder="Origin (e.g. Ethiopia) *" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 54 - <select x-model="newBean.roasterRKey" name="roaster_rkey_modal" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 55 - <option value=""> 56 - Select Roaster (Optional) 57 - </option> 58 - </select> 59 - <select x-model="newBean.roastLevel" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 60 - <option value=""> 61 - Select Roast Level (Optional) 62 - </option> 63 - <option value="Ultra-Light"> 64 - Ultra-Light 65 - </option> 66 - <option value="Light"> 67 - Light 68 - </option> 69 - <option value="Medium-Light"> 70 - Medium-Light 71 - </option> 72 - <option value="Medium"> 73 - Medium 74 - </option> 75 - <option value="Medium-Dark"> 76 - Medium-Dark 77 - </option> 78 - <option value="Dark"> 79 - Dark 80 - </option> 81 - </select> 82 - <input type="text" x-model="newBean.process" placeholder="Process (e.g. Washed, Natural, Honey)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 83 - <input type="text" x-model="newBean.description" placeholder="Description (optional)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 84 - <div class="flex gap-2"> 85 - <button type="button" @click="addBean()" class="bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700"> 86 - Add 87 - </button> 88 - <button type="button" @click="showNewBean = false" class="bg-gray-300 px-4 py-2 rounded hover:bg-gray-400"> 89 - Cancel 90 - </button> 91 - </div> 92 - </div> 93 - </div> 94 - </div> 95 - <div> 96 - <label class="block text-sm font-medium text-brown-900 mb-2"> 97 - Coffee Amount (grams) 98 - </label> 99 - <input 100 - type="number" 101 - name="coffee_amount" 102 - step="0.1" 103 - 104 - placeholder="e.g. 18" 105 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"/> 106 - <p class="text-sm text-brown-700 mt-1"> 107 - Amount of ground coffee used 108 - </p> 109 - </div> 110 - <div> 111 - <label class="block text-sm font-medium text-brown-900 mb-2"> 112 - Grinder 113 - </label> 114 - <div class="flex gap-2"> 115 - <select 116 - name="grinder_rkey" 117 - class="flex-1 rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 truncate max-w-full bg-white"> 118 - <option value=""> 119 - Select a grinder... 120 - </option> 121 - </select> 122 - <button 123 - type="button" 124 - @click="showNewGrinder = true" 125 - class="bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 126 - + New 127 - </button> 128 - </div> 129 - <div x-show="showNewGrinder" class="mt-4 p-4 bg-brown-100 rounded border border-brown-300"> 130 - <h4 class="font-medium mb-3 text-gray-800"> 131 - Add New Grinder 132 - </h4> 133 - <div class="space-y-3"> 134 - <input type="text" x-model="newGrinder.name" placeholder="Name (e.g. Baratza Encore) *" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 135 - <select x-model="newGrinder.grinderType" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 136 - <option value=""> 137 - Grinder Type (Optional) 138 - </option> 139 - <option value="Hand"> 140 - Hand 141 - </option> 142 - <option value="Electric"> 143 - Electric 144 - </option> 145 - <option value="Electric Hand"> 146 - Electric Hand 147 - </option> 148 - </select> 149 - <select x-model="newGrinder.burrType" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 150 - <option value=""> 151 - Burr Type (Optional) 152 - </option> 153 - <option value="Conical"> 154 - Conical 155 - </option> 156 - <option value="Flat"> 157 - Flat 158 - </option> 159 - </select> 160 - <input type="text" x-model="newGrinder.notes" placeholder="Notes (optional)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 161 - <div class="flex gap-2"> 162 - <button type="button" @click="addGrinder()" class="bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700"> 163 - Add 164 - </button> 165 - <button type="button" @click="showNewGrinder = false" class="bg-gray-300 px-4 py-2 rounded hover:bg-gray-400"> 166 - Cancel 167 - </button> 168 - </div> 169 - </div> 170 - </div> 171 - </div> 172 - <div> 173 - <label class="block text-sm font-medium text-brown-900 mb-2"> 174 - Grind Size 175 - </label> 176 - <input 177 - type="text" 178 - name="grind_size" 179 - value="" 180 - placeholder="e.g. 18, Medium, 3.5, Fine" 181 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"/> 182 - <p class="text-sm text-brown-700 mt-1"> 183 - Enter a number (grinder setting) or description (e.g. "Medium", "Fine") 184 - </p> 185 - </div> 186 - <div> 187 - <label class="block text-sm font-medium text-brown-900 mb-2"> 188 - Brew Method 189 - </label> 190 - <div class="flex gap-2"> 191 - <select 192 - name="brewer_rkey" 193 - class="flex-1 rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 truncate max-w-full bg-white"> 194 - <option value=""> 195 - Select brew method... 196 - </option> 197 - </select> 198 - <button 199 - type="button" 200 - @click="showNewBrewer = true" 201 - class="bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 202 - + New 203 - </button> 204 - </div> 205 - <div x-show="showNewBrewer" class="mt-4 p-4 bg-brown-100 rounded border border-brown-300"> 206 - <h4 class="font-medium mb-3 text-gray-800"> 207 - Add New Brewer 208 - </h4> 209 - <div class="space-y-3"> 210 - <input type="text" x-model="newBrewer.name" placeholder="Name (e.g. V60, AeroPress) *" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 211 - <input type="text" x-model="newBrewer.brewer_type" placeholder="Type (e.g. Pour-Over, Immersion)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 212 - <input type="text" x-model="newBrewer.description" placeholder="Description (optional)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 213 - <div class="flex gap-2"> 214 - <button type="button" @click="addBrewer()" class="bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700"> 215 - Add 216 - </button> 217 - <button type="button" @click="showNewBrewer = false" class="bg-gray-300 px-4 py-2 rounded hover:bg-gray-400"> 218 - Cancel 219 - </button> 220 - </div> 221 - </div> 222 - </div> 223 - </div> 224 - <div> 225 - <label class="block text-sm font-medium text-brown-900 mb-2"> 226 - Water Amount (grams) 227 - </label> 228 - <input 229 - type="number" 230 - name="water_amount" 231 - step="1" 232 - 233 - placeholder="e.g. 250" 234 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"/> 235 - <p class="text-sm text-brown-700 mt-1"> 236 - Total water used (or leave empty if using pours below) 237 - </p> 238 - </div> 239 - <div> 240 - <div class="flex items-center justify-between mb-2"> 241 - <label class="block text-sm font-medium text-brown-900"> 242 - Pours (Optional) 243 - </label> 244 - <button 245 - type="button" 246 - @click="addPour()" 247 - class="text-sm bg-brown-300 text-brown-900 px-3 py-1 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 248 - + Add Pour 249 - </button> 250 - </div> 251 - <p class="text-sm text-brown-700 mb-3"> 252 - Track individual pours for bloom and subsequent additions 253 - </p> 254 - <div class="space-y-3"> 255 - <template x-for="(pour, index) in pours" :key="index"> 256 - <div class="flex gap-2 items-center bg-brown-50 p-3 rounded-lg border border-brown-200"> 257 - <div class="flex-1"> 258 - <label class="text-xs text-brown-700 font-medium" x-text="'Pour ' + (index + 1)"></label> 259 - <input 260 - type="number" 261 - :name="'pour_water_' + index" 262 - x-model="pour.water" 263 - placeholder="Water (g)" 264 - class="w-full rounded-md border-brown-300 text-sm py-2 px-3 mt-1 bg-white"/> 265 - </div> 266 - <div class="flex-1"> 267 - <label class="text-xs text-brown-700 font-medium"> 268 - Time (sec) 269 - </label> 270 - <input 271 - type="number" 272 - :name="'pour_time_' + index" 273 - x-model="pour.time" 274 - placeholder="e.g. 45" 275 - class="w-full rounded-md border-brown-300 text-sm py-2 px-3 mt-1 bg-white"/> 276 - </div> 277 - <button 278 - type="button" 279 - @click="removePour(index)" 280 - class="text-brown-700 hover:text-brown-900 mt-5 font-bold" 281 - x-show="pours.length > 0"> 282 - 283 - </button> 284 - </div> 285 - </template> 286 - </div> 287 - </div> 288 - <div> 289 - <label class="block text-sm font-medium text-brown-900 mb-2"> 290 - Temperature 291 - </label> 292 - <input 293 - type="number" 294 - name="temperature" 295 - step="0.1" 296 - 297 - placeholder="e.g. 93.5" 298 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"/> 299 - </div> 300 - <div> 301 - <label class="block text-sm font-medium text-brown-900 mb-2"> 302 - Brew Time (seconds) 303 - </label> 304 - <input 305 - type="number" 306 - name="time_seconds" 307 - 308 - placeholder="e.g. 180" 309 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"/> 310 - </div> 311 - <div> 312 - <label class="block text-sm font-medium text-brown-900 mb-2"> 313 - Tasting Notes 314 - </label> 315 - <textarea 316 - name="tasting_notes" 317 - rows="4" 318 - placeholder="Describe the flavors, aroma, and your thoughts..." 319 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white">&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;Bright &amp; fruity, &#34;amazing&#34; taste</textarea> 320 - </div> 321 - <div> 322 - <label class="block text-sm font-medium text-brown-900 mb-2"> 323 - Rating 324 - </label> 325 - <input 326 - type="range" 327 - name="rating" 328 - min="1" 329 - max="10" 330 - value="8" 331 - x-model="rating" 332 - x-init="rating = $el.value" 333 - class="w-full accent-brown-700"/> 334 - <div class="text-center text-2xl font-bold text-brown-800"> 335 - <span x-text="rating"></span> 336 - /10 337 - </div> 338 - </div> 339 - <div> 340 - <button 341 - type="submit" 342 - class="w-full bg-gradient-to-r from-brown-700 to-brown-800 text-white py-3 px-6 rounded-xl hover:from-brown-800 hover:to-brown-900 transition-all font-semibold text-lg shadow-lg hover:shadow-xl"> 343 - Update Brew 344 - </button> 345 - </div> 346 - </form> 347 - </div> 348 - </div>
-49
internal/bff/__snapshots__/brew_with_minimal_data.snap
··· 1 - --- 2 - title: brew with minimal data 3 - test_name: TestFeedTemplate_BrewItem_Snapshot/brew_with_minimal_data 4 - file_name: feed_template_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - <div class="space-y-4"> 8 - <div class="bg-gradient-to-br from-brown-50 to-brown-100 rounded-lg shadow-md border border-brown-200 p-4 hover:shadow-lg transition-shadow"> 9 - <div class="flex items-center gap-3 mb-3"> 10 - <a href="/profile/newbie" class="flex-shrink-0"> 11 - <div class="w-10 h-10 rounded-full bg-brown-300 flex items-center justify-center hover:ring-2 hover:ring-brown-600 transition"> 12 - <span class="text-brown-600 text-sm"> 13 - ? 14 - </span> 15 - </div> 16 - </a> 17 - <div class="flex-1 min-w-0"> 18 - <div class="flex items-center gap-2"> 19 - <a href="/profile/newbie" class="text-brown-600 text-sm truncate hover:text-brown-700 hover:underline"> 20 - @newbie 21 - </a> 22 - </div> 23 - <span class="text-brown-500 text-sm"> 24 - 1 minute ago 25 - </span> 26 - </div> 27 - </div> 28 - <div class="mb-2 text-sm text-brown-700"> 29 - ☕ added a new brew 30 - </div> 31 - <div class="bg-white/60 backdrop-blur rounded-lg p-4 border border-brown-200"> 32 - <div class="flex items-start justify-between gap-3 mb-3"> 33 - <div class="flex-1 min-w-0"> 34 - <div class="font-bold text-brown-900 text-base"> 35 - House Blend 36 - </div> 37 - <div class="text-xs text-brown-600 mt-1 flex flex-wrap gap-x-2 gap-y-0.5"></div> 38 - </div> 39 - </div> 40 - <div class="grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-brown-700"></div> 41 - <div class="mt-3 border-t border-brown-200 pt-3"> 42 - <a href="/brews/brew456?owner=newbie" 43 - class="inline-flex items-center text-sm font-medium text-brown-700 hover:text-brown-900 hover:underline"> 44 - View full details → 45 - </a> 46 - </div> 47 - </div> 48 - </div> 49 - </div>
-59
internal/bff/__snapshots__/brew_with_unicode_bean_name.snap
··· 1 - --- 2 - title: brew with unicode bean name 3 - test_name: TestFeedTemplate_BrewItem_Snapshot/brew_with_unicode_bean_name 4 - file_name: feed_template_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - <div class="space-y-4"> 8 - <div class="bg-gradient-to-br from-brown-50 to-brown-100 rounded-lg shadow-md border border-brown-200 p-4 hover:shadow-lg transition-shadow"> 9 - <div class="flex items-center gap-3 mb-3"> 10 - <a href="/profile/japan.coffee" class="flex-shrink-0"> 11 - <div class="w-10 h-10 rounded-full bg-brown-300 flex items-center justify-center hover:ring-2 hover:ring-brown-600 transition"> 12 - <span class="text-brown-600 text-sm"> 13 - ? 14 - </span> 15 - </div> 16 - </a> 17 - <div class="flex-1 min-w-0"> 18 - <div class="flex items-center gap-2"> 19 - <a href="/profile/japan.coffee" class="font-medium text-brown-900 truncate hover:text-brown-700 hover:underline"> 20 - 日本のコーヒー 21 - </a> 22 - <a href="/profile/japan.coffee" class="text-brown-600 text-sm truncate hover:text-brown-700 hover:underline"> 23 - @japan.coffee 24 - </a> 25 - </div> 26 - <span class="text-brown-500 text-sm"> 27 - 3 hours ago 28 - </span> 29 - </div> 30 - </div> 31 - <div class="mb-2 text-sm text-brown-700"> 32 - ☕ added a new brew 33 - </div> 34 - <div class="bg-white/60 backdrop-blur rounded-lg p-4 border border-brown-200"> 35 - <div class="flex items-start justify-between gap-3 mb-3"> 36 - <div class="flex-1 min-w-0"> 37 - <div class="font-bold text-brown-900 text-base"> 38 - コーヒー豆 39 - </div> 40 - <div class="text-xs text-brown-600 mt-1 flex flex-wrap gap-x-2 gap-y-0.5"> 41 - <span class="inline-flex items-center gap-0.5"> 42 - 📍 日本 43 - </span> 44 - </div> 45 - </div> 46 - <span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-amber-100 text-amber-900 flex-shrink-0"> 47 - ⭐ 8/10 48 - </span> 49 - </div> 50 - <div class="grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-brown-700"></div> 51 - <div class="mt-3 border-t border-brown-200 pt-3"> 52 - <a href="/brews/brew789?owner=japan.coffee" 53 - class="inline-flex items-center text-sm font-medium text-brown-700 hover:text-brown-900 hover:underline"> 54 - View full details → 55 - </a> 56 - </div> 57 - </div> 58 - </div> 59 - </div>
-364
internal/bff/__snapshots__/brew_with_unicode_characters.snap
··· 1 - --- 2 - title: brew with unicode characters 3 - test_name: TestBrewForm_SpecialCharacters_Snapshot/brew_with_unicode_characters 4 - file_name: form_template_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - <script src="/static/js/brew-form.js"></script> 8 - <div class="max-w-2xl mx-auto"> 9 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 border border-brown-300"> 10 - <h2 class="text-3xl font-bold text-brown-900 mb-6"> 11 - Edit Brew 12 - </h2> 13 - <form 14 - 15 - hx-put="/brews/brew2" 16 - 17 - hx-target="body" 18 - class="space-y-6" 19 - x-data="brewForm()" 20 - > 21 - <div> 22 - <label class="block text-sm font-medium text-brown-900 mb-2"> 23 - Coffee Bean 24 - </label> 25 - <div class="flex gap-2"> 26 - <select 27 - name="bean_rkey" 28 - required 29 - class="flex-1 rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 truncate max-w-full bg-white"> 30 - <option value=""> 31 - Select a bean... 32 - </option> 33 - <option 34 - value="bean1" 35 - selected 36 - class="truncate"> 37 - Café Especial™ (Costa Rica - Medium) 38 - </option> 39 - </select> 40 - <button 41 - type="button" 42 - @click="showNewBean = true" 43 - class="bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 44 - + New 45 - </button> 46 - </div> 47 - <div x-show="showNewBean" class="mt-4 p-4 bg-brown-100 rounded border border-brown-300"> 48 - <h4 class="font-medium mb-3 text-gray-800"> 49 - Add New Bean 50 - </h4> 51 - <div class="space-y-3"> 52 - <input type="text" x-model="newBean.name" placeholder="Name (e.g. Morning Blend, House Espresso) *" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 53 - <input type="text" x-model="newBean.origin" placeholder="Origin (e.g. Ethiopia) *" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 54 - <select x-model="newBean.roasterRKey" name="roaster_rkey_modal" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 55 - <option value=""> 56 - Select Roaster (Optional) 57 - </option> 58 - </select> 59 - <select x-model="newBean.roastLevel" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 60 - <option value=""> 61 - Select Roast Level (Optional) 62 - </option> 63 - <option value="Ultra-Light"> 64 - Ultra-Light 65 - </option> 66 - <option value="Light"> 67 - Light 68 - </option> 69 - <option value="Medium-Light"> 70 - Medium-Light 71 - </option> 72 - <option value="Medium"> 73 - Medium 74 - </option> 75 - <option value="Medium-Dark"> 76 - Medium-Dark 77 - </option> 78 - <option value="Dark"> 79 - Dark 80 - </option> 81 - </select> 82 - <input type="text" x-model="newBean.process" placeholder="Process (e.g. Washed, Natural, Honey)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 83 - <input type="text" x-model="newBean.description" placeholder="Description (optional)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 84 - <div class="flex gap-2"> 85 - <button type="button" @click="addBean()" class="bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700"> 86 - Add 87 - </button> 88 - <button type="button" @click="showNewBean = false" class="bg-gray-300 px-4 py-2 rounded hover:bg-gray-400"> 89 - Cancel 90 - </button> 91 - </div> 92 - </div> 93 - </div> 94 - </div> 95 - <div> 96 - <label class="block text-sm font-medium text-brown-900 mb-2"> 97 - Coffee Amount (grams) 98 - </label> 99 - <input 100 - type="number" 101 - name="coffee_amount" 102 - step="0.1" 103 - 104 - placeholder="e.g. 18" 105 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"/> 106 - <p class="text-sm text-brown-700 mt-1"> 107 - Amount of ground coffee used 108 - </p> 109 - </div> 110 - <div> 111 - <label class="block text-sm font-medium text-brown-900 mb-2"> 112 - Grinder 113 - </label> 114 - <div class="flex gap-2"> 115 - <select 116 - name="grinder_rkey" 117 - class="flex-1 rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 truncate max-w-full bg-white"> 118 - <option value=""> 119 - Select a grinder... 120 - </option> 121 - <option 122 - value="grinder1" 123 - 124 - class="truncate"> 125 - Comandante® C40 MK3 126 - </option> 127 - </select> 128 - <button 129 - type="button" 130 - @click="showNewGrinder = true" 131 - class="bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 132 - + New 133 - </button> 134 - </div> 135 - <div x-show="showNewGrinder" class="mt-4 p-4 bg-brown-100 rounded border border-brown-300"> 136 - <h4 class="font-medium mb-3 text-gray-800"> 137 - Add New Grinder 138 - </h4> 139 - <div class="space-y-3"> 140 - <input type="text" x-model="newGrinder.name" placeholder="Name (e.g. Baratza Encore) *" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 141 - <select x-model="newGrinder.grinderType" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 142 - <option value=""> 143 - Grinder Type (Optional) 144 - </option> 145 - <option value="Hand"> 146 - Hand 147 - </option> 148 - <option value="Electric"> 149 - Electric 150 - </option> 151 - <option value="Electric Hand"> 152 - Electric Hand 153 - </option> 154 - </select> 155 - <select x-model="newGrinder.burrType" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 156 - <option value=""> 157 - Burr Type (Optional) 158 - </option> 159 - <option value="Conical"> 160 - Conical 161 - </option> 162 - <option value="Flat"> 163 - Flat 164 - </option> 165 - </select> 166 - <input type="text" x-model="newGrinder.notes" placeholder="Notes (optional)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 167 - <div class="flex gap-2"> 168 - <button type="button" @click="addGrinder()" class="bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700"> 169 - Add 170 - </button> 171 - <button type="button" @click="showNewGrinder = false" class="bg-gray-300 px-4 py-2 rounded hover:bg-gray-400"> 172 - Cancel 173 - </button> 174 - </div> 175 - </div> 176 - </div> 177 - </div> 178 - <div> 179 - <label class="block text-sm font-medium text-brown-900 mb-2"> 180 - Grind Size 181 - </label> 182 - <input 183 - type="text" 184 - name="grind_size" 185 - value="中挽き (medium)" 186 - placeholder="e.g. 18, Medium, 3.5, Fine" 187 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"/> 188 - <p class="text-sm text-brown-700 mt-1"> 189 - Enter a number (grinder setting) or description (e.g. "Medium", "Fine") 190 - </p> 191 - </div> 192 - <div> 193 - <label class="block text-sm font-medium text-brown-900 mb-2"> 194 - Brew Method 195 - </label> 196 - <div class="flex gap-2"> 197 - <select 198 - name="brewer_rkey" 199 - class="flex-1 rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 truncate max-w-full bg-white"> 200 - <option value=""> 201 - Select brew method... 202 - </option> 203 - <option 204 - value="brewer1" 205 - 206 - class="truncate"> 207 - Hario V60 (02) 208 - </option> 209 - </select> 210 - <button 211 - type="button" 212 - @click="showNewBrewer = true" 213 - class="bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 214 - + New 215 - </button> 216 - </div> 217 - <div x-show="showNewBrewer" class="mt-4 p-4 bg-brown-100 rounded border border-brown-300"> 218 - <h4 class="font-medium mb-3 text-gray-800"> 219 - Add New Brewer 220 - </h4> 221 - <div class="space-y-3"> 222 - <input type="text" x-model="newBrewer.name" placeholder="Name (e.g. V60, AeroPress) *" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 223 - <input type="text" x-model="newBrewer.brewer_type" placeholder="Type (e.g. Pour-Over, Immersion)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 224 - <input type="text" x-model="newBrewer.description" placeholder="Description (optional)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 225 - <div class="flex gap-2"> 226 - <button type="button" @click="addBrewer()" class="bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700"> 227 - Add 228 - </button> 229 - <button type="button" @click="showNewBrewer = false" class="bg-gray-300 px-4 py-2 rounded hover:bg-gray-400"> 230 - Cancel 231 - </button> 232 - </div> 233 - </div> 234 - </div> 235 - </div> 236 - <div> 237 - <label class="block text-sm font-medium text-brown-900 mb-2"> 238 - Water Amount (grams) 239 - </label> 240 - <input 241 - type="number" 242 - name="water_amount" 243 - step="1" 244 - 245 - placeholder="e.g. 250" 246 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"/> 247 - <p class="text-sm text-brown-700 mt-1"> 248 - Total water used (or leave empty if using pours below) 249 - </p> 250 - </div> 251 - <div> 252 - <div class="flex items-center justify-between mb-2"> 253 - <label class="block text-sm font-medium text-brown-900"> 254 - Pours (Optional) 255 - </label> 256 - <button 257 - type="button" 258 - @click="addPour()" 259 - class="text-sm bg-brown-300 text-brown-900 px-3 py-1 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 260 - + Add Pour 261 - </button> 262 - </div> 263 - <p class="text-sm text-brown-700 mb-3"> 264 - Track individual pours for bloom and subsequent additions 265 - </p> 266 - <div class="space-y-3"> 267 - <template x-for="(pour, index) in pours" :key="index"> 268 - <div class="flex gap-2 items-center bg-brown-50 p-3 rounded-lg border border-brown-200"> 269 - <div class="flex-1"> 270 - <label class="text-xs text-brown-700 font-medium" x-text="'Pour ' + (index + 1)"></label> 271 - <input 272 - type="number" 273 - :name="'pour_water_' + index" 274 - x-model="pour.water" 275 - placeholder="Water (g)" 276 - class="w-full rounded-md border-brown-300 text-sm py-2 px-3 mt-1 bg-white"/> 277 - </div> 278 - <div class="flex-1"> 279 - <label class="text-xs text-brown-700 font-medium"> 280 - Time (sec) 281 - </label> 282 - <input 283 - type="number" 284 - :name="'pour_time_' + index" 285 - x-model="pour.time" 286 - placeholder="e.g. 45" 287 - class="w-full rounded-md border-brown-300 text-sm py-2 px-3 mt-1 bg-white"/> 288 - </div> 289 - <button 290 - type="button" 291 - @click="removePour(index)" 292 - class="text-brown-700 hover:text-brown-900 mt-5 font-bold" 293 - x-show="pours.length > 0"> 294 - 295 - </button> 296 - </div> 297 - </template> 298 - </div> 299 - </div> 300 - <div> 301 - <label class="block text-sm font-medium text-brown-900 mb-2"> 302 - Temperature 303 - </label> 304 - <input 305 - type="number" 306 - name="temperature" 307 - step="0.1" 308 - 309 - placeholder="e.g. 93.5" 310 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"/> 311 - </div> 312 - <div> 313 - <label class="block text-sm font-medium text-brown-900 mb-2"> 314 - Brew Time (seconds) 315 - </label> 316 - <input 317 - type="number" 318 - name="time_seconds" 319 - 320 - placeholder="e.g. 180" 321 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"/> 322 - </div> 323 - <div> 324 - <label class="block text-sm font-medium text-brown-900 mb-2"> 325 - Tasting Notes 326 - </label> 327 - <textarea 328 - name="tasting_notes" 329 - rows="4" 330 - placeholder="Describe the flavors, aroma, and your thoughts..." 331 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white">日本のコーヒー 🇯🇵 - フルーティーで酸味が強い 332 - 333 - Яркий вкус с цитрусовыми нотами 334 - 335 - Café con notas de caramelo</textarea> 336 - </div> 337 - <div> 338 - <label class="block text-sm font-medium text-brown-900 mb-2"> 339 - Rating 340 - </label> 341 - <input 342 - type="range" 343 - name="rating" 344 - min="1" 345 - max="10" 346 - value="9" 347 - x-model="rating" 348 - x-init="rating = $el.value" 349 - class="w-full accent-brown-700"/> 350 - <div class="text-center text-2xl font-bold text-brown-800"> 351 - <span x-text="rating"></span> 352 - /10 353 - </div> 354 - </div> 355 - <div> 356 - <button 357 - type="submit" 358 - class="w-full bg-gradient-to-r from-brown-700 to-brown-800 text-white py-3 px-6 rounded-xl hover:from-brown-800 hover:to-brown-900 transition-all font-semibold text-lg shadow-lg hover:shadow-xl"> 359 - Update Brew 360 - </button> 361 - </div> 362 - </form> 363 - </div> 364 - </div>
-7
internal/bff/__snapshots__/brew_with_zero_rating.snap
··· 1 - --- 2 - title: brew with zero rating 3 - test_name: TestTemplateRendering_BrewCard_Snapshot/brew_with_zero_rating 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - "\n<div class=\"brew-card\">\n <div class=\"date\">Jan 15, 2024</div>\n \n <div class=\"bean\">House Blend</div>\n \n \n \n</div>\n"
-24
internal/bff/__snapshots__/brewer_form_renders.snap
··· 1 - --- 2 - title: brewer_form_renders 3 - test_name: TestNewBrewerForm_Snapshot/brewer_form_renders 4 - file_name: form_template_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - <div x-show="showNewBrewer" class="mt-4 p-4 bg-brown-100 rounded border border-brown-300"> 8 - <h4 class="font-medium mb-3 text-gray-800"> 9 - Add New Brewer 10 - </h4> 11 - <div class="space-y-3"> 12 - <input type="text" x-model="newBrewer.name" placeholder="Name (e.g. V60, AeroPress) *" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 13 - <input type="text" x-model="newBrewer.brewer_type" placeholder="Type (e.g. Pour-Over, Immersion)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 14 - <input type="text" x-model="newBrewer.description" placeholder="Description (optional)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 15 - <div class="flex gap-2"> 16 - <button type="button" @click="addBrewer()" class="bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700"> 17 - Add 18 - </button> 19 - <button type="button" @click="showNewBrewer = false" class="bg-gray-300 px-4 py-2 rounded hover:bg-gray-400"> 20 - Cancel 21 - </button> 22 - </div> 23 - </div> 24 - </div>
-45
internal/bff/__snapshots__/brewer_item.snap
··· 1 - --- 2 - title: brewer item 3 - test_name: TestFeedTemplate_BrewerItem_Snapshot 4 - file_name: feed_template_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - <div class="space-y-4"> 8 - <div class="bg-gradient-to-br from-brown-50 to-brown-100 rounded-lg shadow-md border border-brown-200 p-4 hover:shadow-lg transition-shadow"> 9 - <div class="flex items-center gap-3 mb-3"> 10 - <a href="/profile/pourover.fan" class="flex-shrink-0"> 11 - <div class="w-10 h-10 rounded-full bg-brown-300 flex items-center justify-center hover:ring-2 hover:ring-brown-600 transition"> 12 - <span class="text-brown-600 text-sm"> 13 - ? 14 - </span> 15 - </div> 16 - </a> 17 - <div class="flex-1 min-w-0"> 18 - <div class="flex items-center gap-2"> 19 - <a href="/profile/pourover.fan" class="font-medium text-brown-900 truncate hover:text-brown-700 hover:underline"> 20 - Pour Over Fan 21 - </a> 22 - <a href="/profile/pourover.fan" class="text-brown-600 text-sm truncate hover:text-brown-700 hover:underline"> 23 - @pourover.fan 24 - </a> 25 - </div> 26 - <span class="text-brown-500 text-sm"> 27 - 2 days ago 28 - </span> 29 - </div> 30 - </div> 31 - <div class="mb-2 text-sm text-brown-700"> 32 - ☕ added a new brewer 33 - </div> 34 - <div class="bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200"> 35 - <div class="text-base mb-2"> 36 - <span class="font-bold text-brown-900"> 37 - Kalita Wave 185 38 - </span> 39 - </div> 40 - <div class="text-sm text-brown-800 italic"> 41 - "Flat-bottom dripper with wave filters" 42 - </div> 43 - </div> 44 - </div> 45 - </div>
-210
internal/bff/__snapshots__/brewers_empty.snap
··· 1 - --- 2 - title: brewers empty 3 - test_name: TestManageContent_BrewersTab_Snapshot/brewers_empty 4 - file_name: partial_template_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - <div x-show="tab === 'beans'"> 8 - <div class="mb-4 flex justify-between items-center"> 9 - <h3 class="text-xl font-semibold text-brown-900"> 10 - Coffee Beans 11 - </h3> 12 - <button 13 - @click="showBeanForm = true; editingBean = null; beanForm = {name: '', origin: '', roast_level: '', process: '', description: '', roaster_rkey: ''}" 14 - class="bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-medium shadow-md hover:shadow-lg"> 15 - + Add Bean 16 - </button> 17 - </div> 18 - <div class="bg-brown-100 rounded-lg p-8 text-center text-brown-700 border border-brown-200"> 19 - No beans yet. Add your first bean to get started! 20 - </div> 21 - </div> 22 - <div x-show="tab === 'roasters'"> 23 - <div class="mb-4 flex justify-between items-center"> 24 - <h3 class="text-xl font-semibold text-brown-900"> 25 - Roasters 26 - </h3> 27 - <button 28 - @click="showRoasterForm = true; editingRoaster = null; roasterForm = {name: '', location: '', website: ''}" 29 - class="bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-medium shadow-md hover:shadow-lg"> 30 - + Add Roaster 31 - </button> 32 - </div> 33 - <div class="bg-brown-100 rounded-lg p-8 text-center text-brown-700 border border-brown-200"> 34 - No roasters yet. Add your first roaster! 35 - </div> 36 - </div> 37 - <div x-show="tab === 'grinders'"> 38 - <div class="mb-4 flex justify-between items-center"> 39 - <h3 class="text-xl font-semibold text-brown-900"> 40 - Grinders 41 - </h3> 42 - <button 43 - @click="showGrinderForm = true; editingGrinder = null; grinderForm = {name: '', grinder_type: '', burr_type: '', notes: ''}" 44 - class="bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-medium shadow-md hover:shadow-lg"> 45 - + Add Grinder 46 - </button> 47 - </div> 48 - <div class="bg-brown-100 rounded-lg p-8 text-center text-brown-700 border border-brown-200"> 49 - No grinders yet. Add your first grinder! 50 - </div> 51 - </div> 52 - <div x-show="tab === 'brewers'"> 53 - <div class="mb-4 flex justify-between items-center"> 54 - <h3 class="text-xl font-semibold text-brown-900"> 55 - Brewers 56 - </h3> 57 - <button @click="showBrewerForm = true; editingBrewer = null; brewerForm = {name: '', brewer_type: '', description: ''}" 58 - class="bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-medium shadow-md hover:shadow-lg"> 59 - + Add Brewer 60 - </button> 61 - </div> 62 - <div class="bg-brown-100 rounded-lg p-8 text-center text-brown-700 border border-brown-200"> 63 - No brewers yet. Add your first brewer! 64 - </div> 65 - </div> 66 - <div x-cloak x-show="showBeanForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 67 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 68 - <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingBean ? 'Edit Bean' : 'Add Bean'"></h3> 69 - <div class="space-y-4"> 70 - <input type="text" x-model="beanForm.name" placeholder="Name *" 71 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 72 - <input type="text" x-model="beanForm.origin" placeholder="Origin *" 73 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 74 - <select x-model="beanForm.roaster_rkey" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 75 - <option value=""> 76 - Select Roaster (Optional) 77 - </option> 78 - </select> 79 - <select x-model="beanForm.roast_level" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 80 - <option value=""> 81 - Select Roast Level (Optional) 82 - </option> 83 - <option value="Ultra-Light"> 84 - Ultra-Light 85 - </option> 86 - <option value="Light"> 87 - Light 88 - </option> 89 - <option value="Medium-Light"> 90 - Medium-Light 91 - </option> 92 - <option value="Medium"> 93 - Medium 94 - </option> 95 - <option value="Medium-Dark"> 96 - Medium-Dark 97 - </option> 98 - <option value="Dark"> 99 - Dark 100 - </option> 101 - </select> 102 - <input type="text" x-model="beanForm.process" placeholder="Process (e.g. Washed, Natural, Honey)" 103 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 104 - <textarea x-model="beanForm.description" placeholder="Description" rows="3" 105 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"></textarea> 106 - <div class="flex gap-2"> 107 - <button @click="saveBean()" 108 - class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md"> 109 - Save 110 - </button> 111 - <button @click="showBeanForm = false" 112 - class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 113 - Cancel 114 - </button> 115 - </div> 116 - </div> 117 - </div> 118 - </div> 119 - <div x-cloak x-show="showRoasterForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 120 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 121 - <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingRoaster ? 'Edit Roaster' : 'Add Roaster'"></h3> 122 - <div class="space-y-4"> 123 - <input type="text" x-model="roasterForm.name" placeholder="Name *" 124 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 125 - <input type="text" x-model="roasterForm.location" placeholder="Location" 126 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 127 - <input type="url" x-model="roasterForm.website" placeholder="Website" 128 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 129 - <div class="flex gap-2"> 130 - <button @click="saveRoaster()" 131 - class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md"> 132 - Save 133 - </button> 134 - <button @click="showRoasterForm = false" 135 - class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 136 - Cancel 137 - </button> 138 - </div> 139 - </div> 140 - </div> 141 - </div> 142 - <div x-cloak x-show="showGrinderForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 143 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 144 - <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingGrinder ? 'Edit Grinder' : 'Add Grinder'"></h3> 145 - <div class="space-y-4"> 146 - <input type="text" x-model="grinderForm.name" placeholder="Name *" 147 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 148 - <select x-model="grinderForm.grinder_type" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 149 - <option value=""> 150 - Select Grinder Type * 151 - </option> 152 - <option value="Hand"> 153 - Hand 154 - </option> 155 - <option value="Electric"> 156 - Electric 157 - </option> 158 - <option value="Portable Electric"> 159 - Portable Electric 160 - </option> 161 - </select> 162 - <select x-model="grinderForm.burr_type" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 163 - <option value=""> 164 - Select Burr Type (Optional) 165 - </option> 166 - <option value="Conical"> 167 - Conical 168 - </option> 169 - <option value="Flat"> 170 - Flat 171 - </option> 172 - </select> 173 - <textarea x-model="grinderForm.notes" placeholder="Notes" rows="3" 174 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"></textarea> 175 - <div class="flex gap-2"> 176 - <button @click="saveGrinder()" 177 - class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md"> 178 - Save 179 - </button> 180 - <button @click="showGrinderForm = false" 181 - class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 182 - Cancel 183 - </button> 184 - </div> 185 - </div> 186 - </div> 187 - </div> 188 - <div x-cloak x-show="showBrewerForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 189 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 190 - <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingBrewer ? 'Edit Brewer' : 'Add Brewer'"></h3> 191 - <div class="space-y-4"> 192 - <input type="text" x-model="brewerForm.name" placeholder="Name *" 193 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 194 - <input type="text" x-model="brewerForm.brewer_type" placeholder="Type (e.g., Pour-Over, Immersion, Espresso)" 195 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 196 - <textarea x-model="brewerForm.description" placeholder="Description" rows="3" 197 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"></textarea> 198 - <div class="flex gap-2"> 199 - <button @click="saveBrewer()" 200 - class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md"> 201 - Save 202 - </button> 203 - <button @click="showBrewerForm = false" 204 - class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 205 - Cancel 206 - </button> 207 - </div> 208 - </div> 209 - </div> 210 - </div>
-279
internal/bff/__snapshots__/brewers_with_data.snap
··· 1 - --- 2 - title: brewers with data 3 - test_name: TestManageContent_BrewersTab_Snapshot/brewers_with_data 4 - file_name: partial_template_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - <div x-show="tab === 'beans'"> 8 - <div class="mb-4 flex justify-between items-center"> 9 - <h3 class="text-xl font-semibold text-brown-900"> 10 - Coffee Beans 11 - </h3> 12 - <button 13 - @click="showBeanForm = true; editingBean = null; beanForm = {name: '', origin: '', roast_level: '', process: '', description: '', roaster_rkey: ''}" 14 - class="bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-medium shadow-md hover:shadow-lg"> 15 - + Add Bean 16 - </button> 17 - </div> 18 - <div class="bg-brown-100 rounded-lg p-8 text-center text-brown-700 border border-brown-200"> 19 - No beans yet. Add your first bean to get started! 20 - </div> 21 - </div> 22 - <div x-show="tab === 'roasters'"> 23 - <div class="mb-4 flex justify-between items-center"> 24 - <h3 class="text-xl font-semibold text-brown-900"> 25 - Roasters 26 - </h3> 27 - <button 28 - @click="showRoasterForm = true; editingRoaster = null; roasterForm = {name: '', location: '', website: ''}" 29 - class="bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-medium shadow-md hover:shadow-lg"> 30 - + Add Roaster 31 - </button> 32 - </div> 33 - <div class="bg-brown-100 rounded-lg p-8 text-center text-brown-700 border border-brown-200"> 34 - No roasters yet. Add your first roaster! 35 - </div> 36 - </div> 37 - <div x-show="tab === 'grinders'"> 38 - <div class="mb-4 flex justify-between items-center"> 39 - <h3 class="text-xl font-semibold text-brown-900"> 40 - Grinders 41 - </h3> 42 - <button 43 - @click="showGrinderForm = true; editingGrinder = null; grinderForm = {name: '', grinder_type: '', burr_type: '', notes: ''}" 44 - class="bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-medium shadow-md hover:shadow-lg"> 45 - + Add Grinder 46 - </button> 47 - </div> 48 - <div class="bg-brown-100 rounded-lg p-8 text-center text-brown-700 border border-brown-200"> 49 - No grinders yet. Add your first grinder! 50 - </div> 51 - </div> 52 - <div x-show="tab === 'brewers'"> 53 - <div class="mb-4 flex justify-between items-center"> 54 - <h3 class="text-xl font-semibold text-brown-900"> 55 - Brewers 56 - </h3> 57 - <button @click="showBrewerForm = true; editingBrewer = null; brewerForm = {name: '', brewer_type: '', description: ''}" 58 - class="bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-medium shadow-md hover:shadow-lg"> 59 - + Add Brewer 60 - </button> 61 - </div> 62 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 shadow-xl rounded-xl overflow-x-auto border border-brown-300"> 63 - <table class="min-w-full divide-y divide-brown-300"> 64 - <thead class="bg-brown-200/80"> 65 - <tr> 66 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase"> 67 - Name 68 - </th> 69 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase"> 70 - 🔧 Type 71 - </th> 72 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase"> 73 - 📝 Description 74 - </th> 75 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase"> 76 - Actions 77 - </th> 78 - </tr> 79 - </thead> 80 - <tbody class="bg-brown-50/60 divide-y divide-brown-200"> 81 - <tr class="hover:bg-brown-100/60 transition-colors" 82 - data-rkey="brewer1" 83 - data-name="Hario V60" 84 - data-brewer-type="Pour-Over" 85 - data-description="Cone-shaped dripper for clean, bright brews"> 86 - <td class="px-6 py-4 text-sm font-medium text-brown-900"> 87 - Hario V60 88 - </td> 89 - <td class="px-6 py-4 text-sm text-brown-900"> 90 - Pour-Over 91 - </td> 92 - <td class="px-6 py-4 text-sm text-brown-700"> 93 - Cone-shaped dripper for clean, bright brews 94 - </td> 95 - <td class="px-6 py-4 text-sm font-medium space-x-2"> 96 - <button @click="editBrewerFromRow($el.closest('tr'))" 97 - class="text-brown-700 hover:text-brown-900 font-medium"> 98 - Edit 99 - </button> 100 - <button @click="deleteBrewer($el.closest('tr').dataset.rkey)" 101 - class="text-brown-600 hover:text-brown-800 font-medium"> 102 - Delete 103 - </button> 104 - </td> 105 - </tr> 106 - <tr class="hover:bg-brown-100/60 transition-colors" 107 - data-rkey="brewer2" 108 - data-name="AeroPress" 109 - data-brewer-type="" 110 - data-description=""> 111 - <td class="px-6 py-4 text-sm font-medium text-brown-900"> 112 - AeroPress 113 - </td> 114 - <td class="px-6 py-4 text-sm text-brown-900"> 115 - <span class="text-brown-400"> 116 - - 117 - </span> 118 - </td> 119 - <td class="px-6 py-4 text-sm text-brown-700"></td> 120 - <td class="px-6 py-4 text-sm font-medium space-x-2"> 121 - <button @click="editBrewerFromRow($el.closest('tr'))" 122 - class="text-brown-700 hover:text-brown-900 font-medium"> 123 - Edit 124 - </button> 125 - <button @click="deleteBrewer($el.closest('tr').dataset.rkey)" 126 - class="text-brown-600 hover:text-brown-800 font-medium"> 127 - Delete 128 - </button> 129 - </td> 130 - </tr> 131 - </tbody> 132 - </table> 133 - </div> 134 - </div> 135 - <div x-cloak x-show="showBeanForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 136 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 137 - <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingBean ? 'Edit Bean' : 'Add Bean'"></h3> 138 - <div class="space-y-4"> 139 - <input type="text" x-model="beanForm.name" placeholder="Name *" 140 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 141 - <input type="text" x-model="beanForm.origin" placeholder="Origin *" 142 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 143 - <select x-model="beanForm.roaster_rkey" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 144 - <option value=""> 145 - Select Roaster (Optional) 146 - </option> 147 - </select> 148 - <select x-model="beanForm.roast_level" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 149 - <option value=""> 150 - Select Roast Level (Optional) 151 - </option> 152 - <option value="Ultra-Light"> 153 - Ultra-Light 154 - </option> 155 - <option value="Light"> 156 - Light 157 - </option> 158 - <option value="Medium-Light"> 159 - Medium-Light 160 - </option> 161 - <option value="Medium"> 162 - Medium 163 - </option> 164 - <option value="Medium-Dark"> 165 - Medium-Dark 166 - </option> 167 - <option value="Dark"> 168 - Dark 169 - </option> 170 - </select> 171 - <input type="text" x-model="beanForm.process" placeholder="Process (e.g. Washed, Natural, Honey)" 172 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 173 - <textarea x-model="beanForm.description" placeholder="Description" rows="3" 174 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"></textarea> 175 - <div class="flex gap-2"> 176 - <button @click="saveBean()" 177 - class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md"> 178 - Save 179 - </button> 180 - <button @click="showBeanForm = false" 181 - class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 182 - Cancel 183 - </button> 184 - </div> 185 - </div> 186 - </div> 187 - </div> 188 - <div x-cloak x-show="showRoasterForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 189 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 190 - <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingRoaster ? 'Edit Roaster' : 'Add Roaster'"></h3> 191 - <div class="space-y-4"> 192 - <input type="text" x-model="roasterForm.name" placeholder="Name *" 193 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 194 - <input type="text" x-model="roasterForm.location" placeholder="Location" 195 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 196 - <input type="url" x-model="roasterForm.website" placeholder="Website" 197 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 198 - <div class="flex gap-2"> 199 - <button @click="saveRoaster()" 200 - class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md"> 201 - Save 202 - </button> 203 - <button @click="showRoasterForm = false" 204 - class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 205 - Cancel 206 - </button> 207 - </div> 208 - </div> 209 - </div> 210 - </div> 211 - <div x-cloak x-show="showGrinderForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 212 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 213 - <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingGrinder ? 'Edit Grinder' : 'Add Grinder'"></h3> 214 - <div class="space-y-4"> 215 - <input type="text" x-model="grinderForm.name" placeholder="Name *" 216 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 217 - <select x-model="grinderForm.grinder_type" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 218 - <option value=""> 219 - Select Grinder Type * 220 - </option> 221 - <option value="Hand"> 222 - Hand 223 - </option> 224 - <option value="Electric"> 225 - Electric 226 - </option> 227 - <option value="Portable Electric"> 228 - Portable Electric 229 - </option> 230 - </select> 231 - <select x-model="grinderForm.burr_type" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 232 - <option value=""> 233 - Select Burr Type (Optional) 234 - </option> 235 - <option value="Conical"> 236 - Conical 237 - </option> 238 - <option value="Flat"> 239 - Flat 240 - </option> 241 - </select> 242 - <textarea x-model="grinderForm.notes" placeholder="Notes" rows="3" 243 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"></textarea> 244 - <div class="flex gap-2"> 245 - <button @click="saveGrinder()" 246 - class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md"> 247 - Save 248 - </button> 249 - <button @click="showGrinderForm = false" 250 - class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 251 - Cancel 252 - </button> 253 - </div> 254 - </div> 255 - </div> 256 - </div> 257 - <div x-cloak x-show="showBrewerForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 258 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 259 - <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingBrewer ? 'Edit Brewer' : 'Add Brewer'"></h3> 260 - <div class="space-y-4"> 261 - <input type="text" x-model="brewerForm.name" placeholder="Name *" 262 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 263 - <input type="text" x-model="brewerForm.brewer_type" placeholder="Type (e.g., Pour-Over, Immersion, Espresso)" 264 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 265 - <textarea x-model="brewerForm.description" placeholder="Description" rows="3" 266 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"></textarea> 267 - <div class="flex gap-2"> 268 - <button @click="saveBrewer()" 269 - class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md"> 270 - Save 271 - </button> 272 - <button @click="showBrewerForm = false" 273 - class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 274 - Cancel 275 - </button> 276 - </div> 277 - </div> 278 - </div> 279 - </div>
-7
internal/bff/__snapshots__/celsius_temp.snap
··· 1 - --- 2 - title: celsius temp 3 - test_name: TestFormatTempValue_Snapshot/celsius_temp 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - "93.0"
-7
internal/bff/__snapshots__/complete_brew.snap
··· 1 - --- 2 - title: complete brew 3 - test_name: TestTemplateRendering_BrewCard_Snapshot/complete_brew 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - "\n<div class=\"brew-card\">\n <div class=\"date\">Jan 15, 2024</div>\n \n <div class=\"bean\">Ethiopian Yirgacheffe</div>\n \n \n <div class=\"rating\">9/10</div>\n \n \n <div class=\"notes\">Bright citrus notes with floral aroma</div>\n \n</div>\n"
-113
internal/bff/__snapshots__/complete_brew_with_all_fields.snap
··· 1 - --- 2 - title: complete brew with all fields 3 - test_name: TestFeedTemplate_BrewItem_Snapshot/complete_brew_with_all_fields 4 - file_name: feed_template_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - <div class="space-y-4"> 8 - <div class="bg-gradient-to-br from-brown-50 to-brown-100 rounded-lg shadow-md border border-brown-200 p-4 hover:shadow-lg transition-shadow"> 9 - <div class="flex items-center gap-3 mb-3"> 10 - <a href="/profile/coffee.lover" class="flex-shrink-0"> 11 - <img src="https://cdn.bsky.app/avatar.jpg" alt="" class="w-10 h-10 rounded-full object-cover hover:ring-2 hover:ring-brown-600 transition" /> 12 - </a> 13 - <div class="flex-1 min-w-0"> 14 - <div class="flex items-center gap-2"> 15 - <a href="/profile/coffee.lover" class="font-medium text-brown-900 truncate hover:text-brown-700 hover:underline"> 16 - Coffee Enthusiast 17 - </a> 18 - <a href="/profile/coffee.lover" class="text-brown-600 text-sm truncate hover:text-brown-700 hover:underline"> 19 - @coffee.lover 20 - </a> 21 - </div> 22 - <span class="text-brown-500 text-sm"> 23 - 2 hours ago 24 - </span> 25 - </div> 26 - </div> 27 - <div class="mb-2 text-sm text-brown-700"> 28 - ☕ added a new brew 29 - </div> 30 - <div class="bg-white/60 backdrop-blur rounded-lg p-4 border border-brown-200"> 31 - <div class="flex items-start justify-between gap-3 mb-3"> 32 - <div class="flex-1 min-w-0"> 33 - <div class="font-bold text-brown-900 text-base"> 34 - Ethiopian Yirgacheffe 35 - </div> 36 - <div class="text-sm text-brown-700 mt-0.5"> 37 - <span class="font-medium"> 38 - 🏭 Onyx Coffee Lab 39 - </span> 40 - </div> 41 - <div class="text-xs text-brown-600 mt-1 flex flex-wrap gap-x-2 gap-y-0.5"> 42 - <span class="inline-flex items-center gap-0.5"> 43 - 📍 Ethiopia 44 - </span> 45 - <span class="inline-flex items-center gap-0.5"> 46 - 🔥 Light 47 - </span> 48 - <span class="inline-flex items-center gap-0.5"> 49 - 🌱 Washed 50 - </span> 51 - <span class="inline-flex items-center gap-0.5"> 52 - ⚖️ 16g 53 - </span> 54 - </div> 55 - </div> 56 - <span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-amber-100 text-amber-900 flex-shrink-0"> 57 - ⭐ 9/10 58 - </span> 59 - </div> 60 - <div class="mb-2"> 61 - <span class="text-xs text-brown-600"> 62 - Brewer: 63 - </span> 64 - <span class="text-sm font-semibold text-brown-900"> 65 - Hario V60 66 - </span> 67 - </div> 68 - <div class="grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-brown-700"> 69 - <div> 70 - <span class="text-brown-600"> 71 - Grinder: 72 - </span> 73 - 1Zpresso JX-Pro (Medium-fine) 74 - </div> 75 - <div class="col-span-2"> 76 - <span class="text-brown-600"> 77 - Pours: 78 - </span> 79 - <div class="pl-2 text-brown-600"> 80 - • 50g @ 30s 81 - </div> 82 - <div class="pl-2 text-brown-600"> 83 - • 100g @ 45s 84 - </div> 85 - <div class="pl-2 text-brown-600"> 86 - • 100g @ 1m 87 - </div> 88 - </div> 89 - <div> 90 - <span class="text-brown-600"> 91 - Temp: 92 - </span> 93 - 93.0°C 94 - </div> 95 - <div> 96 - <span class="text-brown-600"> 97 - Time: 98 - </span> 99 - 3m 100 - </div> 101 - </div> 102 - <div class="mt-3 text-sm text-brown-800 italic border-t border-brown-200 pt-2"> 103 - "Bright citrus notes with floral aroma" 104 - </div> 105 - <div class="mt-3 border-t border-brown-200 pt-3"> 106 - <a href="/brews/brew123?owner=coffee.lover" 107 - class="inline-flex items-center text-sm font-medium text-brown-700 hover:text-brown-900 hover:underline"> 108 - View full details → 109 - </a> 110 - </div> 111 - </div> 112 - </div> 113 - </div>
-7
internal/bff/__snapshots__/decimal_celsius.snap
··· 1 - --- 2 - title: decimal celsius 3 - test_name: TestFormatTempValue_Snapshot/decimal_celsius 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - "92.5"
-7
internal/bff/__snapshots__/decimal_fahrenheit.snap
··· 1 - --- 2 - title: decimal fahrenheit 3 - test_name: TestFormatTempValue_Snapshot/decimal_fahrenheit 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - "201.8"
-7
internal/bff/__snapshots__/different_values.snap
··· 1 - --- 2 - title: different values 3 - test_name: TestPtrEquals_Snapshot/different_values 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - false
-383
internal/bff/__snapshots__/edit_brew_with_complete_data.snap
··· 1 - --- 2 - title: edit brew with complete data 3 - test_name: TestBrewForm_EditBrew_Snapshot/edit_brew_with_complete_data 4 - file_name: form_template_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - <script src="/static/js/brew-form.js"></script> 8 - <div class="max-w-2xl mx-auto"> 9 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 border border-brown-300"> 10 - <h2 class="text-3xl font-bold text-brown-900 mb-6"> 11 - Edit Brew 12 - </h2> 13 - <form 14 - 15 - hx-put="/brews/brew123" 16 - 17 - hx-target="body" 18 - class="space-y-6" 19 - x-data="brewForm()" 20 - 21 - data-pours='[{&#34;pourNumber&#34;:1,&#34;waterAmount&#34;:50,&#34;timeSeconds&#34;:30},{&#34;pourNumber&#34;:2,&#34;waterAmount&#34;:100,&#34;timeSeconds&#34;:45},{&#34;pourNumber&#34;:3,&#34;waterAmount&#34;:150,&#34;timeSeconds&#34;:60}]' 22 - > 23 - <div> 24 - <label class="block text-sm font-medium text-brown-900 mb-2"> 25 - Coffee Bean 26 - </label> 27 - <div class="flex gap-2"> 28 - <select 29 - name="bean_rkey" 30 - required 31 - class="flex-1 rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 truncate max-w-full bg-white"> 32 - <option value=""> 33 - Select a bean... 34 - </option> 35 - <option 36 - value="bean1" 37 - selected 38 - class="truncate"> 39 - Ethiopian Yirgacheffe (Ethiopia - Light) 40 - </option> 41 - <option 42 - value="bean2" 43 - 44 - class="truncate"> 45 - Colombian Supremo (Colombia - Medium) 46 - </option> 47 - </select> 48 - <button 49 - type="button" 50 - @click="showNewBean = true" 51 - class="bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 52 - + New 53 - </button> 54 - </div> 55 - <div x-show="showNewBean" class="mt-4 p-4 bg-brown-100 rounded border border-brown-300"> 56 - <h4 class="font-medium mb-3 text-gray-800"> 57 - Add New Bean 58 - </h4> 59 - <div class="space-y-3"> 60 - <input type="text" x-model="newBean.name" placeholder="Name (e.g. Morning Blend, House Espresso) *" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 61 - <input type="text" x-model="newBean.origin" placeholder="Origin (e.g. Ethiopia) *" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 62 - <select x-model="newBean.roasterRKey" name="roaster_rkey_modal" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 63 - <option value=""> 64 - Select Roaster (Optional) 65 - </option> 66 - <option value="roaster1"> 67 - Blue Bottle 68 - </option> 69 - </select> 70 - <select x-model="newBean.roastLevel" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 71 - <option value=""> 72 - Select Roast Level (Optional) 73 - </option> 74 - <option value="Ultra-Light"> 75 - Ultra-Light 76 - </option> 77 - <option value="Light"> 78 - Light 79 - </option> 80 - <option value="Medium-Light"> 81 - Medium-Light 82 - </option> 83 - <option value="Medium"> 84 - Medium 85 - </option> 86 - <option value="Medium-Dark"> 87 - Medium-Dark 88 - </option> 89 - <option value="Dark"> 90 - Dark 91 - </option> 92 - </select> 93 - <input type="text" x-model="newBean.process" placeholder="Process (e.g. Washed, Natural, Honey)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 94 - <input type="text" x-model="newBean.description" placeholder="Description (optional)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 95 - <div class="flex gap-2"> 96 - <button type="button" @click="addBean()" class="bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700"> 97 - Add 98 - </button> 99 - <button type="button" @click="showNewBean = false" class="bg-gray-300 px-4 py-2 rounded hover:bg-gray-400"> 100 - Cancel 101 - </button> 102 - </div> 103 - </div> 104 - </div> 105 - </div> 106 - <div> 107 - <label class="block text-sm font-medium text-brown-900 mb-2"> 108 - Coffee Amount (grams) 109 - </label> 110 - <input 111 - type="number" 112 - name="coffee_amount" 113 - step="0.1" 114 - value="18" 115 - placeholder="e.g. 18" 116 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"/> 117 - <p class="text-sm text-brown-700 mt-1"> 118 - Amount of ground coffee used 119 - </p> 120 - </div> 121 - <div> 122 - <label class="block text-sm font-medium text-brown-900 mb-2"> 123 - Grinder 124 - </label> 125 - <div class="flex gap-2"> 126 - <select 127 - name="grinder_rkey" 128 - class="flex-1 rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 truncate max-w-full bg-white"> 129 - <option value=""> 130 - Select a grinder... 131 - </option> 132 - <option 133 - value="grinder1" 134 - selected 135 - class="truncate"> 136 - Baratza Encore 137 - </option> 138 - <option 139 - value="grinder2" 140 - 141 - class="truncate"> 142 - Comandante C40 143 - </option> 144 - </select> 145 - <button 146 - type="button" 147 - @click="showNewGrinder = true" 148 - class="bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 149 - + New 150 - </button> 151 - </div> 152 - <div x-show="showNewGrinder" class="mt-4 p-4 bg-brown-100 rounded border border-brown-300"> 153 - <h4 class="font-medium mb-3 text-gray-800"> 154 - Add New Grinder 155 - </h4> 156 - <div class="space-y-3"> 157 - <input type="text" x-model="newGrinder.name" placeholder="Name (e.g. Baratza Encore) *" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 158 - <select x-model="newGrinder.grinderType" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 159 - <option value=""> 160 - Grinder Type (Optional) 161 - </option> 162 - <option value="Hand"> 163 - Hand 164 - </option> 165 - <option value="Electric"> 166 - Electric 167 - </option> 168 - <option value="Electric Hand"> 169 - Electric Hand 170 - </option> 171 - </select> 172 - <select x-model="newGrinder.burrType" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 173 - <option value=""> 174 - Burr Type (Optional) 175 - </option> 176 - <option value="Conical"> 177 - Conical 178 - </option> 179 - <option value="Flat"> 180 - Flat 181 - </option> 182 - </select> 183 - <input type="text" x-model="newGrinder.notes" placeholder="Notes (optional)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 184 - <div class="flex gap-2"> 185 - <button type="button" @click="addGrinder()" class="bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700"> 186 - Add 187 - </button> 188 - <button type="button" @click="showNewGrinder = false" class="bg-gray-300 px-4 py-2 rounded hover:bg-gray-400"> 189 - Cancel 190 - </button> 191 - </div> 192 - </div> 193 - </div> 194 - </div> 195 - <div> 196 - <label class="block text-sm font-medium text-brown-900 mb-2"> 197 - Grind Size 198 - </label> 199 - <input 200 - type="text" 201 - name="grind_size" 202 - value="18" 203 - placeholder="e.g. 18, Medium, 3.5, Fine" 204 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"/> 205 - <p class="text-sm text-brown-700 mt-1"> 206 - Enter a number (grinder setting) or description (e.g. "Medium", "Fine") 207 - </p> 208 - </div> 209 - <div> 210 - <label class="block text-sm font-medium text-brown-900 mb-2"> 211 - Brew Method 212 - </label> 213 - <div class="flex gap-2"> 214 - <select 215 - name="brewer_rkey" 216 - class="flex-1 rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 truncate max-w-full bg-white"> 217 - <option value=""> 218 - Select brew method... 219 - </option> 220 - <option 221 - value="brewer1" 222 - selected 223 - class="truncate"> 224 - Hario V60 225 - </option> 226 - <option 227 - value="brewer2" 228 - 229 - class="truncate"> 230 - AeroPress 231 - </option> 232 - </select> 233 - <button 234 - type="button" 235 - @click="showNewBrewer = true" 236 - class="bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 237 - + New 238 - </button> 239 - </div> 240 - <div x-show="showNewBrewer" class="mt-4 p-4 bg-brown-100 rounded border border-brown-300"> 241 - <h4 class="font-medium mb-3 text-gray-800"> 242 - Add New Brewer 243 - </h4> 244 - <div class="space-y-3"> 245 - <input type="text" x-model="newBrewer.name" placeholder="Name (e.g. V60, AeroPress) *" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 246 - <input type="text" x-model="newBrewer.brewer_type" placeholder="Type (e.g. Pour-Over, Immersion)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 247 - <input type="text" x-model="newBrewer.description" placeholder="Description (optional)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 248 - <div class="flex gap-2"> 249 - <button type="button" @click="addBrewer()" class="bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700"> 250 - Add 251 - </button> 252 - <button type="button" @click="showNewBrewer = false" class="bg-gray-300 px-4 py-2 rounded hover:bg-gray-400"> 253 - Cancel 254 - </button> 255 - </div> 256 - </div> 257 - </div> 258 - </div> 259 - <div> 260 - <label class="block text-sm font-medium text-brown-900 mb-2"> 261 - Water Amount (grams) 262 - </label> 263 - <input 264 - type="number" 265 - name="water_amount" 266 - step="1" 267 - value="300" 268 - placeholder="e.g. 250" 269 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"/> 270 - <p class="text-sm text-brown-700 mt-1"> 271 - Total water used (or leave empty if using pours below) 272 - </p> 273 - </div> 274 - <div> 275 - <div class="flex items-center justify-between mb-2"> 276 - <label class="block text-sm font-medium text-brown-900"> 277 - Pours (Optional) 278 - </label> 279 - <button 280 - type="button" 281 - @click="addPour()" 282 - class="text-sm bg-brown-300 text-brown-900 px-3 py-1 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 283 - + Add Pour 284 - </button> 285 - </div> 286 - <p class="text-sm text-brown-700 mb-3"> 287 - Track individual pours for bloom and subsequent additions 288 - </p> 289 - <div class="space-y-3"> 290 - <template x-for="(pour, index) in pours" :key="index"> 291 - <div class="flex gap-2 items-center bg-brown-50 p-3 rounded-lg border border-brown-200"> 292 - <div class="flex-1"> 293 - <label class="text-xs text-brown-700 font-medium" x-text="'Pour ' + (index + 1)"></label> 294 - <input 295 - type="number" 296 - :name="'pour_water_' + index" 297 - x-model="pour.water" 298 - placeholder="Water (g)" 299 - class="w-full rounded-md border-brown-300 text-sm py-2 px-3 mt-1 bg-white"/> 300 - </div> 301 - <div class="flex-1"> 302 - <label class="text-xs text-brown-700 font-medium"> 303 - Time (sec) 304 - </label> 305 - <input 306 - type="number" 307 - :name="'pour_time_' + index" 308 - x-model="pour.time" 309 - placeholder="e.g. 45" 310 - class="w-full rounded-md border-brown-300 text-sm py-2 px-3 mt-1 bg-white"/> 311 - </div> 312 - <button 313 - type="button" 314 - @click="removePour(index)" 315 - class="text-brown-700 hover:text-brown-900 mt-5 font-bold" 316 - x-show="pours.length > 0"> 317 - 318 - </button> 319 - </div> 320 - </template> 321 - </div> 322 - </div> 323 - <div> 324 - <label class="block text-sm font-medium text-brown-900 mb-2"> 325 - Temperature 326 - </label> 327 - <input 328 - type="number" 329 - name="temperature" 330 - step="0.1" 331 - value="93.5" 332 - placeholder="e.g. 93.5" 333 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"/> 334 - </div> 335 - <div> 336 - <label class="block text-sm font-medium text-brown-900 mb-2"> 337 - Brew Time (seconds) 338 - </label> 339 - <input 340 - type="number" 341 - name="time_seconds" 342 - value="180" 343 - placeholder="e.g. 180" 344 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"/> 345 - </div> 346 - <div> 347 - <label class="block text-sm font-medium text-brown-900 mb-2"> 348 - Tasting Notes 349 - </label> 350 - <textarea 351 - name="tasting_notes" 352 - rows="4" 353 - placeholder="Describe the flavors, aroma, and your thoughts..." 354 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white">Bright citrus notes with floral aroma. Clean finish.</textarea> 355 - </div> 356 - <div> 357 - <label class="block text-sm font-medium text-brown-900 mb-2"> 358 - Rating 359 - </label> 360 - <input 361 - type="range" 362 - name="rating" 363 - min="1" 364 - max="10" 365 - value="8" 366 - x-model="rating" 367 - x-init="rating = $el.value" 368 - class="w-full accent-brown-700"/> 369 - <div class="text-center text-2xl font-bold text-brown-800"> 370 - <span x-text="rating"></span> 371 - /10 372 - </div> 373 - </div> 374 - <div> 375 - <button 376 - type="submit" 377 - class="w-full bg-gradient-to-r from-brown-700 to-brown-800 text-white py-3 px-6 rounded-xl hover:from-brown-800 hover:to-brown-900 transition-all font-semibold text-lg shadow-lg hover:shadow-xl"> 378 - Update Brew 379 - </button> 380 - </div> 381 - </form> 382 - </div> 383 - </div>
-348
internal/bff/__snapshots__/edit_brew_with_minimal_data.snap
··· 1 - --- 2 - title: edit brew with minimal data 3 - test_name: TestBrewForm_EditBrew_Snapshot/edit_brew_with_minimal_data 4 - file_name: form_template_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - <script src="/static/js/brew-form.js"></script> 8 - <div class="max-w-2xl mx-auto"> 9 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 border border-brown-300"> 10 - <h2 class="text-3xl font-bold text-brown-900 mb-6"> 11 - Edit Brew 12 - </h2> 13 - <form 14 - 15 - hx-put="/brews/brew456" 16 - 17 - hx-target="body" 18 - class="space-y-6" 19 - x-data="brewForm()" 20 - > 21 - <div> 22 - <label class="block text-sm font-medium text-brown-900 mb-2"> 23 - Coffee Bean 24 - </label> 25 - <div class="flex gap-2"> 26 - <select 27 - name="bean_rkey" 28 - required 29 - class="flex-1 rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 truncate max-w-full bg-white"> 30 - <option value=""> 31 - Select a bean... 32 - </option> 33 - <option 34 - value="bean1" 35 - selected 36 - class="truncate"> 37 - House Blend (Brazil - Medium) 38 - </option> 39 - </select> 40 - <button 41 - type="button" 42 - @click="showNewBean = true" 43 - class="bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 44 - + New 45 - </button> 46 - </div> 47 - <div x-show="showNewBean" class="mt-4 p-4 bg-brown-100 rounded border border-brown-300"> 48 - <h4 class="font-medium mb-3 text-gray-800"> 49 - Add New Bean 50 - </h4> 51 - <div class="space-y-3"> 52 - <input type="text" x-model="newBean.name" placeholder="Name (e.g. Morning Blend, House Espresso) *" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 53 - <input type="text" x-model="newBean.origin" placeholder="Origin (e.g. Ethiopia) *" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 54 - <select x-model="newBean.roasterRKey" name="roaster_rkey_modal" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 55 - <option value=""> 56 - Select Roaster (Optional) 57 - </option> 58 - </select> 59 - <select x-model="newBean.roastLevel" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 60 - <option value=""> 61 - Select Roast Level (Optional) 62 - </option> 63 - <option value="Ultra-Light"> 64 - Ultra-Light 65 - </option> 66 - <option value="Light"> 67 - Light 68 - </option> 69 - <option value="Medium-Light"> 70 - Medium-Light 71 - </option> 72 - <option value="Medium"> 73 - Medium 74 - </option> 75 - <option value="Medium-Dark"> 76 - Medium-Dark 77 - </option> 78 - <option value="Dark"> 79 - Dark 80 - </option> 81 - </select> 82 - <input type="text" x-model="newBean.process" placeholder="Process (e.g. Washed, Natural, Honey)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 83 - <input type="text" x-model="newBean.description" placeholder="Description (optional)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 84 - <div class="flex gap-2"> 85 - <button type="button" @click="addBean()" class="bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700"> 86 - Add 87 - </button> 88 - <button type="button" @click="showNewBean = false" class="bg-gray-300 px-4 py-2 rounded hover:bg-gray-400"> 89 - Cancel 90 - </button> 91 - </div> 92 - </div> 93 - </div> 94 - </div> 95 - <div> 96 - <label class="block text-sm font-medium text-brown-900 mb-2"> 97 - Coffee Amount (grams) 98 - </label> 99 - <input 100 - type="number" 101 - name="coffee_amount" 102 - step="0.1" 103 - 104 - placeholder="e.g. 18" 105 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"/> 106 - <p class="text-sm text-brown-700 mt-1"> 107 - Amount of ground coffee used 108 - </p> 109 - </div> 110 - <div> 111 - <label class="block text-sm font-medium text-brown-900 mb-2"> 112 - Grinder 113 - </label> 114 - <div class="flex gap-2"> 115 - <select 116 - name="grinder_rkey" 117 - class="flex-1 rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 truncate max-w-full bg-white"> 118 - <option value=""> 119 - Select a grinder... 120 - </option> 121 - </select> 122 - <button 123 - type="button" 124 - @click="showNewGrinder = true" 125 - class="bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 126 - + New 127 - </button> 128 - </div> 129 - <div x-show="showNewGrinder" class="mt-4 p-4 bg-brown-100 rounded border border-brown-300"> 130 - <h4 class="font-medium mb-3 text-gray-800"> 131 - Add New Grinder 132 - </h4> 133 - <div class="space-y-3"> 134 - <input type="text" x-model="newGrinder.name" placeholder="Name (e.g. Baratza Encore) *" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 135 - <select x-model="newGrinder.grinderType" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 136 - <option value=""> 137 - Grinder Type (Optional) 138 - </option> 139 - <option value="Hand"> 140 - Hand 141 - </option> 142 - <option value="Electric"> 143 - Electric 144 - </option> 145 - <option value="Electric Hand"> 146 - Electric Hand 147 - </option> 148 - </select> 149 - <select x-model="newGrinder.burrType" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 150 - <option value=""> 151 - Burr Type (Optional) 152 - </option> 153 - <option value="Conical"> 154 - Conical 155 - </option> 156 - <option value="Flat"> 157 - Flat 158 - </option> 159 - </select> 160 - <input type="text" x-model="newGrinder.notes" placeholder="Notes (optional)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 161 - <div class="flex gap-2"> 162 - <button type="button" @click="addGrinder()" class="bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700"> 163 - Add 164 - </button> 165 - <button type="button" @click="showNewGrinder = false" class="bg-gray-300 px-4 py-2 rounded hover:bg-gray-400"> 166 - Cancel 167 - </button> 168 - </div> 169 - </div> 170 - </div> 171 - </div> 172 - <div> 173 - <label class="block text-sm font-medium text-brown-900 mb-2"> 174 - Grind Size 175 - </label> 176 - <input 177 - type="text" 178 - name="grind_size" 179 - value="" 180 - placeholder="e.g. 18, Medium, 3.5, Fine" 181 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"/> 182 - <p class="text-sm text-brown-700 mt-1"> 183 - Enter a number (grinder setting) or description (e.g. "Medium", "Fine") 184 - </p> 185 - </div> 186 - <div> 187 - <label class="block text-sm font-medium text-brown-900 mb-2"> 188 - Brew Method 189 - </label> 190 - <div class="flex gap-2"> 191 - <select 192 - name="brewer_rkey" 193 - class="flex-1 rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 truncate max-w-full bg-white"> 194 - <option value=""> 195 - Select brew method... 196 - </option> 197 - </select> 198 - <button 199 - type="button" 200 - @click="showNewBrewer = true" 201 - class="bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 202 - + New 203 - </button> 204 - </div> 205 - <div x-show="showNewBrewer" class="mt-4 p-4 bg-brown-100 rounded border border-brown-300"> 206 - <h4 class="font-medium mb-3 text-gray-800"> 207 - Add New Brewer 208 - </h4> 209 - <div class="space-y-3"> 210 - <input type="text" x-model="newBrewer.name" placeholder="Name (e.g. V60, AeroPress) *" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 211 - <input type="text" x-model="newBrewer.brewer_type" placeholder="Type (e.g. Pour-Over, Immersion)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 212 - <input type="text" x-model="newBrewer.description" placeholder="Description (optional)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 213 - <div class="flex gap-2"> 214 - <button type="button" @click="addBrewer()" class="bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700"> 215 - Add 216 - </button> 217 - <button type="button" @click="showNewBrewer = false" class="bg-gray-300 px-4 py-2 rounded hover:bg-gray-400"> 218 - Cancel 219 - </button> 220 - </div> 221 - </div> 222 - </div> 223 - </div> 224 - <div> 225 - <label class="block text-sm font-medium text-brown-900 mb-2"> 226 - Water Amount (grams) 227 - </label> 228 - <input 229 - type="number" 230 - name="water_amount" 231 - step="1" 232 - 233 - placeholder="e.g. 250" 234 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"/> 235 - <p class="text-sm text-brown-700 mt-1"> 236 - Total water used (or leave empty if using pours below) 237 - </p> 238 - </div> 239 - <div> 240 - <div class="flex items-center justify-between mb-2"> 241 - <label class="block text-sm font-medium text-brown-900"> 242 - Pours (Optional) 243 - </label> 244 - <button 245 - type="button" 246 - @click="addPour()" 247 - class="text-sm bg-brown-300 text-brown-900 px-3 py-1 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 248 - + Add Pour 249 - </button> 250 - </div> 251 - <p class="text-sm text-brown-700 mb-3"> 252 - Track individual pours for bloom and subsequent additions 253 - </p> 254 - <div class="space-y-3"> 255 - <template x-for="(pour, index) in pours" :key="index"> 256 - <div class="flex gap-2 items-center bg-brown-50 p-3 rounded-lg border border-brown-200"> 257 - <div class="flex-1"> 258 - <label class="text-xs text-brown-700 font-medium" x-text="'Pour ' + (index + 1)"></label> 259 - <input 260 - type="number" 261 - :name="'pour_water_' + index" 262 - x-model="pour.water" 263 - placeholder="Water (g)" 264 - class="w-full rounded-md border-brown-300 text-sm py-2 px-3 mt-1 bg-white"/> 265 - </div> 266 - <div class="flex-1"> 267 - <label class="text-xs text-brown-700 font-medium"> 268 - Time (sec) 269 - </label> 270 - <input 271 - type="number" 272 - :name="'pour_time_' + index" 273 - x-model="pour.time" 274 - placeholder="e.g. 45" 275 - class="w-full rounded-md border-brown-300 text-sm py-2 px-3 mt-1 bg-white"/> 276 - </div> 277 - <button 278 - type="button" 279 - @click="removePour(index)" 280 - class="text-brown-700 hover:text-brown-900 mt-5 font-bold" 281 - x-show="pours.length > 0"> 282 - 283 - </button> 284 - </div> 285 - </template> 286 - </div> 287 - </div> 288 - <div> 289 - <label class="block text-sm font-medium text-brown-900 mb-2"> 290 - Temperature 291 - </label> 292 - <input 293 - type="number" 294 - name="temperature" 295 - step="0.1" 296 - 297 - placeholder="e.g. 93.5" 298 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"/> 299 - </div> 300 - <div> 301 - <label class="block text-sm font-medium text-brown-900 mb-2"> 302 - Brew Time (seconds) 303 - </label> 304 - <input 305 - type="number" 306 - name="time_seconds" 307 - 308 - placeholder="e.g. 180" 309 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"/> 310 - </div> 311 - <div> 312 - <label class="block text-sm font-medium text-brown-900 mb-2"> 313 - Tasting Notes 314 - </label> 315 - <textarea 316 - name="tasting_notes" 317 - rows="4" 318 - placeholder="Describe the flavors, aroma, and your thoughts..." 319 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"></textarea> 320 - </div> 321 - <div> 322 - <label class="block text-sm font-medium text-brown-900 mb-2"> 323 - Rating 324 - </label> 325 - <input 326 - type="range" 327 - name="rating" 328 - min="1" 329 - max="10" 330 - value="5" 331 - x-model="rating" 332 - x-init="rating = $el.value" 333 - class="w-full accent-brown-700"/> 334 - <div class="text-center text-2xl font-bold text-brown-800"> 335 - <span x-text="rating"></span> 336 - /10 337 - </div> 338 - </div> 339 - <div> 340 - <button 341 - type="submit" 342 - class="w-full bg-gradient-to-r from-brown-700 to-brown-800 text-white py-3 px-6 rounded-xl hover:from-brown-800 hover:to-brown-900 transition-all font-semibold text-lg shadow-lg hover:shadow-xl"> 343 - Update Brew 344 - </button> 345 - </div> 346 - </form> 347 - </div> 348 - </div>
-350
internal/bff/__snapshots__/edit_brew_with_pours_json.snap
··· 1 - --- 2 - title: edit brew with pours json 3 - test_name: TestBrewForm_EditBrew_Snapshot/edit_brew_with_pours_json 4 - file_name: form_template_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - <script src="/static/js/brew-form.js"></script> 8 - <div class="max-w-2xl mx-auto"> 9 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 border border-brown-300"> 10 - <h2 class="text-3xl font-bold text-brown-900 mb-6"> 11 - Edit Brew 12 - </h2> 13 - <form 14 - 15 - hx-put="/brews/brew789" 16 - 17 - hx-target="body" 18 - class="space-y-6" 19 - x-data="brewForm()" 20 - 21 - data-pours='[{&#34;pourNumber&#34;:1,&#34;waterAmount&#34;:60,&#34;timeSeconds&#34;:30},{&#34;pourNumber&#34;:2,&#34;waterAmount&#34;:120,&#34;timeSeconds&#34;:60}]' 22 - > 23 - <div> 24 - <label class="block text-sm font-medium text-brown-900 mb-2"> 25 - Coffee Bean 26 - </label> 27 - <div class="flex gap-2"> 28 - <select 29 - name="bean_rkey" 30 - required 31 - class="flex-1 rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 truncate max-w-full bg-white"> 32 - <option value=""> 33 - Select a bean... 34 - </option> 35 - <option 36 - value="bean1" 37 - selected 38 - class="truncate"> 39 - Kenya - Light 40 - </option> 41 - </select> 42 - <button 43 - type="button" 44 - @click="showNewBean = true" 45 - class="bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 46 - + New 47 - </button> 48 - </div> 49 - <div x-show="showNewBean" class="mt-4 p-4 bg-brown-100 rounded border border-brown-300"> 50 - <h4 class="font-medium mb-3 text-gray-800"> 51 - Add New Bean 52 - </h4> 53 - <div class="space-y-3"> 54 - <input type="text" x-model="newBean.name" placeholder="Name (e.g. Morning Blend, House Espresso) *" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 55 - <input type="text" x-model="newBean.origin" placeholder="Origin (e.g. Ethiopia) *" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 56 - <select x-model="newBean.roasterRKey" name="roaster_rkey_modal" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 57 - <option value=""> 58 - Select Roaster (Optional) 59 - </option> 60 - </select> 61 - <select x-model="newBean.roastLevel" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 62 - <option value=""> 63 - Select Roast Level (Optional) 64 - </option> 65 - <option value="Ultra-Light"> 66 - Ultra-Light 67 - </option> 68 - <option value="Light"> 69 - Light 70 - </option> 71 - <option value="Medium-Light"> 72 - Medium-Light 73 - </option> 74 - <option value="Medium"> 75 - Medium 76 - </option> 77 - <option value="Medium-Dark"> 78 - Medium-Dark 79 - </option> 80 - <option value="Dark"> 81 - Dark 82 - </option> 83 - </select> 84 - <input type="text" x-model="newBean.process" placeholder="Process (e.g. Washed, Natural, Honey)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 85 - <input type="text" x-model="newBean.description" placeholder="Description (optional)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 86 - <div class="flex gap-2"> 87 - <button type="button" @click="addBean()" class="bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700"> 88 - Add 89 - </button> 90 - <button type="button" @click="showNewBean = false" class="bg-gray-300 px-4 py-2 rounded hover:bg-gray-400"> 91 - Cancel 92 - </button> 93 - </div> 94 - </div> 95 - </div> 96 - </div> 97 - <div> 98 - <label class="block text-sm font-medium text-brown-900 mb-2"> 99 - Coffee Amount (grams) 100 - </label> 101 - <input 102 - type="number" 103 - name="coffee_amount" 104 - step="0.1" 105 - 106 - placeholder="e.g. 18" 107 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"/> 108 - <p class="text-sm text-brown-700 mt-1"> 109 - Amount of ground coffee used 110 - </p> 111 - </div> 112 - <div> 113 - <label class="block text-sm font-medium text-brown-900 mb-2"> 114 - Grinder 115 - </label> 116 - <div class="flex gap-2"> 117 - <select 118 - name="grinder_rkey" 119 - class="flex-1 rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 truncate max-w-full bg-white"> 120 - <option value=""> 121 - Select a grinder... 122 - </option> 123 - </select> 124 - <button 125 - type="button" 126 - @click="showNewGrinder = true" 127 - class="bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 128 - + New 129 - </button> 130 - </div> 131 - <div x-show="showNewGrinder" class="mt-4 p-4 bg-brown-100 rounded border border-brown-300"> 132 - <h4 class="font-medium mb-3 text-gray-800"> 133 - Add New Grinder 134 - </h4> 135 - <div class="space-y-3"> 136 - <input type="text" x-model="newGrinder.name" placeholder="Name (e.g. Baratza Encore) *" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 137 - <select x-model="newGrinder.grinderType" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 138 - <option value=""> 139 - Grinder Type (Optional) 140 - </option> 141 - <option value="Hand"> 142 - Hand 143 - </option> 144 - <option value="Electric"> 145 - Electric 146 - </option> 147 - <option value="Electric Hand"> 148 - Electric Hand 149 - </option> 150 - </select> 151 - <select x-model="newGrinder.burrType" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 152 - <option value=""> 153 - Burr Type (Optional) 154 - </option> 155 - <option value="Conical"> 156 - Conical 157 - </option> 158 - <option value="Flat"> 159 - Flat 160 - </option> 161 - </select> 162 - <input type="text" x-model="newGrinder.notes" placeholder="Notes (optional)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 163 - <div class="flex gap-2"> 164 - <button type="button" @click="addGrinder()" class="bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700"> 165 - Add 166 - </button> 167 - <button type="button" @click="showNewGrinder = false" class="bg-gray-300 px-4 py-2 rounded hover:bg-gray-400"> 168 - Cancel 169 - </button> 170 - </div> 171 - </div> 172 - </div> 173 - </div> 174 - <div> 175 - <label class="block text-sm font-medium text-brown-900 mb-2"> 176 - Grind Size 177 - </label> 178 - <input 179 - type="text" 180 - name="grind_size" 181 - value="" 182 - placeholder="e.g. 18, Medium, 3.5, Fine" 183 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"/> 184 - <p class="text-sm text-brown-700 mt-1"> 185 - Enter a number (grinder setting) or description (e.g. "Medium", "Fine") 186 - </p> 187 - </div> 188 - <div> 189 - <label class="block text-sm font-medium text-brown-900 mb-2"> 190 - Brew Method 191 - </label> 192 - <div class="flex gap-2"> 193 - <select 194 - name="brewer_rkey" 195 - class="flex-1 rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 truncate max-w-full bg-white"> 196 - <option value=""> 197 - Select brew method... 198 - </option> 199 - </select> 200 - <button 201 - type="button" 202 - @click="showNewBrewer = true" 203 - class="bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 204 - + New 205 - </button> 206 - </div> 207 - <div x-show="showNewBrewer" class="mt-4 p-4 bg-brown-100 rounded border border-brown-300"> 208 - <h4 class="font-medium mb-3 text-gray-800"> 209 - Add New Brewer 210 - </h4> 211 - <div class="space-y-3"> 212 - <input type="text" x-model="newBrewer.name" placeholder="Name (e.g. V60, AeroPress) *" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 213 - <input type="text" x-model="newBrewer.brewer_type" placeholder="Type (e.g. Pour-Over, Immersion)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 214 - <input type="text" x-model="newBrewer.description" placeholder="Description (optional)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 215 - <div class="flex gap-2"> 216 - <button type="button" @click="addBrewer()" class="bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700"> 217 - Add 218 - </button> 219 - <button type="button" @click="showNewBrewer = false" class="bg-gray-300 px-4 py-2 rounded hover:bg-gray-400"> 220 - Cancel 221 - </button> 222 - </div> 223 - </div> 224 - </div> 225 - </div> 226 - <div> 227 - <label class="block text-sm font-medium text-brown-900 mb-2"> 228 - Water Amount (grams) 229 - </label> 230 - <input 231 - type="number" 232 - name="water_amount" 233 - step="1" 234 - 235 - placeholder="e.g. 250" 236 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"/> 237 - <p class="text-sm text-brown-700 mt-1"> 238 - Total water used (or leave empty if using pours below) 239 - </p> 240 - </div> 241 - <div> 242 - <div class="flex items-center justify-between mb-2"> 243 - <label class="block text-sm font-medium text-brown-900"> 244 - Pours (Optional) 245 - </label> 246 - <button 247 - type="button" 248 - @click="addPour()" 249 - class="text-sm bg-brown-300 text-brown-900 px-3 py-1 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 250 - + Add Pour 251 - </button> 252 - </div> 253 - <p class="text-sm text-brown-700 mb-3"> 254 - Track individual pours for bloom and subsequent additions 255 - </p> 256 - <div class="space-y-3"> 257 - <template x-for="(pour, index) in pours" :key="index"> 258 - <div class="flex gap-2 items-center bg-brown-50 p-3 rounded-lg border border-brown-200"> 259 - <div class="flex-1"> 260 - <label class="text-xs text-brown-700 font-medium" x-text="'Pour ' + (index + 1)"></label> 261 - <input 262 - type="number" 263 - :name="'pour_water_' + index" 264 - x-model="pour.water" 265 - placeholder="Water (g)" 266 - class="w-full rounded-md border-brown-300 text-sm py-2 px-3 mt-1 bg-white"/> 267 - </div> 268 - <div class="flex-1"> 269 - <label class="text-xs text-brown-700 font-medium"> 270 - Time (sec) 271 - </label> 272 - <input 273 - type="number" 274 - :name="'pour_time_' + index" 275 - x-model="pour.time" 276 - placeholder="e.g. 45" 277 - class="w-full rounded-md border-brown-300 text-sm py-2 px-3 mt-1 bg-white"/> 278 - </div> 279 - <button 280 - type="button" 281 - @click="removePour(index)" 282 - class="text-brown-700 hover:text-brown-900 mt-5 font-bold" 283 - x-show="pours.length > 0"> 284 - 285 - </button> 286 - </div> 287 - </template> 288 - </div> 289 - </div> 290 - <div> 291 - <label class="block text-sm font-medium text-brown-900 mb-2"> 292 - Temperature 293 - </label> 294 - <input 295 - type="number" 296 - name="temperature" 297 - step="0.1" 298 - 299 - placeholder="e.g. 93.5" 300 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"/> 301 - </div> 302 - <div> 303 - <label class="block text-sm font-medium text-brown-900 mb-2"> 304 - Brew Time (seconds) 305 - </label> 306 - <input 307 - type="number" 308 - name="time_seconds" 309 - 310 - placeholder="e.g. 180" 311 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"/> 312 - </div> 313 - <div> 314 - <label class="block text-sm font-medium text-brown-900 mb-2"> 315 - Tasting Notes 316 - </label> 317 - <textarea 318 - name="tasting_notes" 319 - rows="4" 320 - placeholder="Describe the flavors, aroma, and your thoughts..." 321 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"></textarea> 322 - </div> 323 - <div> 324 - <label class="block text-sm font-medium text-brown-900 mb-2"> 325 - Rating 326 - </label> 327 - <input 328 - type="range" 329 - name="rating" 330 - min="1" 331 - max="10" 332 - value="7" 333 - x-model="rating" 334 - x-init="rating = $el.value" 335 - class="w-full accent-brown-700"/> 336 - <div class="text-center text-2xl font-bold text-brown-800"> 337 - <span x-text="rating"></span> 338 - /10 339 - </div> 340 - </div> 341 - <div> 342 - <button 343 - type="submit" 344 - class="w-full bg-gradient-to-r from-brown-700 to-brown-800 text-white py-3 px-6 rounded-xl hover:from-brown-800 hover:to-brown-900 transition-all font-semibold text-lg shadow-lg hover:shadow-xl"> 345 - Update Brew 346 - </button> 347 - </div> 348 - </form> 349 - </div> 350 - </div>
-351
internal/bff/__snapshots__/edit_brew_without_loaded_collections.snap
··· 1 - --- 2 - title: edit brew without loaded collections 3 - test_name: TestBrewForm_EditBrew_Snapshot/edit_brew_without_loaded_collections 4 - file_name: form_template_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - <script src="/static/js/brew-form.js"></script> 8 - <div class="max-w-2xl mx-auto"> 9 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 border border-brown-300"> 10 - <h2 class="text-3xl font-bold text-brown-900 mb-6"> 11 - Edit Brew 12 - </h2> 13 - <form 14 - 15 - hx-put="/brews/brew999" 16 - 17 - hx-target="body" 18 - class="space-y-6" 19 - x-data="brewForm()" 20 - > 21 - <div> 22 - <label class="block text-sm font-medium text-brown-900 mb-2"> 23 - Coffee Bean 24 - </label> 25 - <div class="flex gap-2"> 26 - <select 27 - name="bean_rkey" 28 - required 29 - class="flex-1 rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 truncate max-w-full bg-white"> 30 - <option value=""> 31 - Select a bean... 32 - </option> 33 - <option value="bean1" selected> 34 - Loading... 35 - </option> 36 - </select> 37 - <button 38 - type="button" 39 - @click="showNewBean = true" 40 - class="bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 41 - + New 42 - </button> 43 - </div> 44 - <div x-show="showNewBean" class="mt-4 p-4 bg-brown-100 rounded border border-brown-300"> 45 - <h4 class="font-medium mb-3 text-gray-800"> 46 - Add New Bean 47 - </h4> 48 - <div class="space-y-3"> 49 - <input type="text" x-model="newBean.name" placeholder="Name (e.g. Morning Blend, House Espresso) *" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 50 - <input type="text" x-model="newBean.origin" placeholder="Origin (e.g. Ethiopia) *" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 51 - <select x-model="newBean.roasterRKey" name="roaster_rkey_modal" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 52 - <option value=""> 53 - Select Roaster (Optional) 54 - </option> 55 - </select> 56 - <select x-model="newBean.roastLevel" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 57 - <option value=""> 58 - Select Roast Level (Optional) 59 - </option> 60 - <option value="Ultra-Light"> 61 - Ultra-Light 62 - </option> 63 - <option value="Light"> 64 - Light 65 - </option> 66 - <option value="Medium-Light"> 67 - Medium-Light 68 - </option> 69 - <option value="Medium"> 70 - Medium 71 - </option> 72 - <option value="Medium-Dark"> 73 - Medium-Dark 74 - </option> 75 - <option value="Dark"> 76 - Dark 77 - </option> 78 - </select> 79 - <input type="text" x-model="newBean.process" placeholder="Process (e.g. Washed, Natural, Honey)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 80 - <input type="text" x-model="newBean.description" placeholder="Description (optional)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 81 - <div class="flex gap-2"> 82 - <button type="button" @click="addBean()" class="bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700"> 83 - Add 84 - </button> 85 - <button type="button" @click="showNewBean = false" class="bg-gray-300 px-4 py-2 rounded hover:bg-gray-400"> 86 - Cancel 87 - </button> 88 - </div> 89 - </div> 90 - </div> 91 - </div> 92 - <div> 93 - <label class="block text-sm font-medium text-brown-900 mb-2"> 94 - Coffee Amount (grams) 95 - </label> 96 - <input 97 - type="number" 98 - name="coffee_amount" 99 - step="0.1" 100 - 101 - placeholder="e.g. 18" 102 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"/> 103 - <p class="text-sm text-brown-700 mt-1"> 104 - Amount of ground coffee used 105 - </p> 106 - </div> 107 - <div> 108 - <label class="block text-sm font-medium text-brown-900 mb-2"> 109 - Grinder 110 - </label> 111 - <div class="flex gap-2"> 112 - <select 113 - name="grinder_rkey" 114 - class="flex-1 rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 truncate max-w-full bg-white"> 115 - <option value=""> 116 - Select a grinder... 117 - </option> 118 - <option value="grinder1" selected> 119 - Loading... 120 - </option> 121 - </select> 122 - <button 123 - type="button" 124 - @click="showNewGrinder = true" 125 - class="bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 126 - + New 127 - </button> 128 - </div> 129 - <div x-show="showNewGrinder" class="mt-4 p-4 bg-brown-100 rounded border border-brown-300"> 130 - <h4 class="font-medium mb-3 text-gray-800"> 131 - Add New Grinder 132 - </h4> 133 - <div class="space-y-3"> 134 - <input type="text" x-model="newGrinder.name" placeholder="Name (e.g. Baratza Encore) *" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 135 - <select x-model="newGrinder.grinderType" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 136 - <option value=""> 137 - Grinder Type (Optional) 138 - </option> 139 - <option value="Hand"> 140 - Hand 141 - </option> 142 - <option value="Electric"> 143 - Electric 144 - </option> 145 - <option value="Electric Hand"> 146 - Electric Hand 147 - </option> 148 - </select> 149 - <select x-model="newGrinder.burrType" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 150 - <option value=""> 151 - Burr Type (Optional) 152 - </option> 153 - <option value="Conical"> 154 - Conical 155 - </option> 156 - <option value="Flat"> 157 - Flat 158 - </option> 159 - </select> 160 - <input type="text" x-model="newGrinder.notes" placeholder="Notes (optional)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 161 - <div class="flex gap-2"> 162 - <button type="button" @click="addGrinder()" class="bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700"> 163 - Add 164 - </button> 165 - <button type="button" @click="showNewGrinder = false" class="bg-gray-300 px-4 py-2 rounded hover:bg-gray-400"> 166 - Cancel 167 - </button> 168 - </div> 169 - </div> 170 - </div> 171 - </div> 172 - <div> 173 - <label class="block text-sm font-medium text-brown-900 mb-2"> 174 - Grind Size 175 - </label> 176 - <input 177 - type="text" 178 - name="grind_size" 179 - value="" 180 - placeholder="e.g. 18, Medium, 3.5, Fine" 181 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"/> 182 - <p class="text-sm text-brown-700 mt-1"> 183 - Enter a number (grinder setting) or description (e.g. "Medium", "Fine") 184 - </p> 185 - </div> 186 - <div> 187 - <label class="block text-sm font-medium text-brown-900 mb-2"> 188 - Brew Method 189 - </label> 190 - <div class="flex gap-2"> 191 - <select 192 - name="brewer_rkey" 193 - class="flex-1 rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 truncate max-w-full bg-white"> 194 - <option value=""> 195 - Select brew method... 196 - </option> 197 - <option value="brewer1" selected> 198 - Loading... 199 - </option> 200 - </select> 201 - <button 202 - type="button" 203 - @click="showNewBrewer = true" 204 - class="bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 205 - + New 206 - </button> 207 - </div> 208 - <div x-show="showNewBrewer" class="mt-4 p-4 bg-brown-100 rounded border border-brown-300"> 209 - <h4 class="font-medium mb-3 text-gray-800"> 210 - Add New Brewer 211 - </h4> 212 - <div class="space-y-3"> 213 - <input type="text" x-model="newBrewer.name" placeholder="Name (e.g. V60, AeroPress) *" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 214 - <input type="text" x-model="newBrewer.brewer_type" placeholder="Type (e.g. Pour-Over, Immersion)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 215 - <input type="text" x-model="newBrewer.description" placeholder="Description (optional)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 216 - <div class="flex gap-2"> 217 - <button type="button" @click="addBrewer()" class="bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700"> 218 - Add 219 - </button> 220 - <button type="button" @click="showNewBrewer = false" class="bg-gray-300 px-4 py-2 rounded hover:bg-gray-400"> 221 - Cancel 222 - </button> 223 - </div> 224 - </div> 225 - </div> 226 - </div> 227 - <div> 228 - <label class="block text-sm font-medium text-brown-900 mb-2"> 229 - Water Amount (grams) 230 - </label> 231 - <input 232 - type="number" 233 - name="water_amount" 234 - step="1" 235 - 236 - placeholder="e.g. 250" 237 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"/> 238 - <p class="text-sm text-brown-700 mt-1"> 239 - Total water used (or leave empty if using pours below) 240 - </p> 241 - </div> 242 - <div> 243 - <div class="flex items-center justify-between mb-2"> 244 - <label class="block text-sm font-medium text-brown-900"> 245 - Pours (Optional) 246 - </label> 247 - <button 248 - type="button" 249 - @click="addPour()" 250 - class="text-sm bg-brown-300 text-brown-900 px-3 py-1 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 251 - + Add Pour 252 - </button> 253 - </div> 254 - <p class="text-sm text-brown-700 mb-3"> 255 - Track individual pours for bloom and subsequent additions 256 - </p> 257 - <div class="space-y-3"> 258 - <template x-for="(pour, index) in pours" :key="index"> 259 - <div class="flex gap-2 items-center bg-brown-50 p-3 rounded-lg border border-brown-200"> 260 - <div class="flex-1"> 261 - <label class="text-xs text-brown-700 font-medium" x-text="'Pour ' + (index + 1)"></label> 262 - <input 263 - type="number" 264 - :name="'pour_water_' + index" 265 - x-model="pour.water" 266 - placeholder="Water (g)" 267 - class="w-full rounded-md border-brown-300 text-sm py-2 px-3 mt-1 bg-white"/> 268 - </div> 269 - <div class="flex-1"> 270 - <label class="text-xs text-brown-700 font-medium"> 271 - Time (sec) 272 - </label> 273 - <input 274 - type="number" 275 - :name="'pour_time_' + index" 276 - x-model="pour.time" 277 - placeholder="e.g. 45" 278 - class="w-full rounded-md border-brown-300 text-sm py-2 px-3 mt-1 bg-white"/> 279 - </div> 280 - <button 281 - type="button" 282 - @click="removePour(index)" 283 - class="text-brown-700 hover:text-brown-900 mt-5 font-bold" 284 - x-show="pours.length > 0"> 285 - 286 - </button> 287 - </div> 288 - </template> 289 - </div> 290 - </div> 291 - <div> 292 - <label class="block text-sm font-medium text-brown-900 mb-2"> 293 - Temperature 294 - </label> 295 - <input 296 - type="number" 297 - name="temperature" 298 - step="0.1" 299 - 300 - placeholder="e.g. 93.5" 301 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"/> 302 - </div> 303 - <div> 304 - <label class="block text-sm font-medium text-brown-900 mb-2"> 305 - Brew Time (seconds) 306 - </label> 307 - <input 308 - type="number" 309 - name="time_seconds" 310 - 311 - placeholder="e.g. 180" 312 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"/> 313 - </div> 314 - <div> 315 - <label class="block text-sm font-medium text-brown-900 mb-2"> 316 - Tasting Notes 317 - </label> 318 - <textarea 319 - name="tasting_notes" 320 - rows="4" 321 - placeholder="Describe the flavors, aroma, and your thoughts..." 322 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"></textarea> 323 - </div> 324 - <div> 325 - <label class="block text-sm font-medium text-brown-900 mb-2"> 326 - Rating 327 - </label> 328 - <input 329 - type="range" 330 - name="rating" 331 - min="1" 332 - max="10" 333 - value="6" 334 - x-model="rating" 335 - x-init="rating = $el.value" 336 - class="w-full accent-brown-700"/> 337 - <div class="text-center text-2xl font-bold text-brown-800"> 338 - <span x-text="rating"></span> 339 - /10 340 - </div> 341 - </div> 342 - <div> 343 - <button 344 - type="submit" 345 - class="w-full bg-gradient-to-r from-brown-700 to-brown-800 text-white py-3 px-6 rounded-xl hover:from-brown-800 hover:to-brown-900 transition-all font-semibold text-lg shadow-lg hover:shadow-xl"> 346 - Update Brew 347 - </button> 348 - </div> 349 - </form> 350 - </div> 351 - </div>
-11
internal/bff/__snapshots__/empty_brew_list_other_profile.snap
··· 1 - --- 2 - title: empty brew list other profile 3 - test_name: TestBrewListContent_Snapshot/empty_brew_list_other_profile 4 - file_name: partial_template_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center border border-brown-300"> 8 - <p class="text-brown-800 text-lg font-medium"> 9 - No brews yet. 10 - </p> 11 - </div>
-15
internal/bff/__snapshots__/empty_brew_list_own_profile.snap
··· 1 - --- 2 - title: empty brew list own profile 3 - test_name: TestBrewListContent_Snapshot/empty_brew_list_own_profile 4 - file_name: partial_template_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center border border-brown-300"> 8 - <p class="text-brown-800 text-lg mb-4 font-medium"> 9 - No brews yet! Start tracking your coffee journey. 10 - </p> 11 - <a href="/brews/new" 12 - class="inline-block bg-gradient-to-r from-brown-700 to-brown-800 text-white py-3 px-6 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all shadow-lg hover:shadow-xl font-medium"> 13 - Add Your First Brew 14 - </a> 15 - </div>
-8
internal/bff/__snapshots__/empty_dict.snap
··· 1 - --- 2 - title: empty dict 3 - test_name: TestDict_Snapshot/empty_dict 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - map[string]interface{}{ 8 - }
-16
internal/bff/__snapshots__/empty_feed_authenticated.snap
··· 1 - --- 2 - title: empty feed authenticated 3 - test_name: TestFeedTemplate_EmptyFeed_Snapshot/empty_feed_authenticated 4 - file_name: feed_template_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - <div class="space-y-4"> 8 - <div class="bg-brown-100 rounded-lg p-6 text-center text-brown-700 border border-brown-200"> 9 - <p class="mb-2 font-medium"> 10 - No activity in the feed yet. 11 - </p> 12 - <p class="text-sm"> 13 - Be the first to add something! 14 - </p> 15 - </div> 16 - </div>
-16
internal/bff/__snapshots__/empty_feed_unauthenticated.snap
··· 1 - --- 2 - title: empty feed unauthenticated 3 - test_name: TestFeedTemplate_EmptyFeed_Snapshot/empty_feed_unauthenticated 4 - file_name: feed_template_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - <div class="space-y-4"> 8 - <div class="bg-brown-100 rounded-lg p-6 text-center text-brown-700 border border-brown-200"> 9 - <p class="mb-2 font-medium"> 10 - No activity in the feed yet. 11 - </p> 12 - <p class="text-sm"> 13 - Be the first to add something! 14 - </p> 15 - </div> 16 - </div>
-7
internal/bff/__snapshots__/empty_pours.snap
··· 1 - --- 2 - title: empty pours 3 - test_name: TestPoursToJSON_Snapshot/empty_pours 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - "[]"
-7
internal/bff/__snapshots__/equal_values.snap
··· 1 - --- 2 - title: equal values 3 - test_name: TestPtrEquals_Snapshot/equal_values 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - true
-7
internal/bff/__snapshots__/escape_js.snap
··· 1 - --- 2 - title: escape_js 3 - test_name: TestEscapeJS_Snapshot 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - []string{"simple string", "string with \\'single quotes\\'", "string with \\\"double quotes\\\"", "line1\\nline2", "tab\\there", "backslash\\\\test", "mixed: \\'quotes\\', \\\"quotes\\\", \\n newlines \\t tabs", ""}
-7
internal/bff/__snapshots__/fahrenheit_temp.snap
··· 1 - --- 2 - title: fahrenheit temp 3 - test_name: TestFormatTempValue_Snapshot/fahrenheit_temp 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - "205.0"
-7
internal/bff/__snapshots__/five_iterations.snap
··· 1 - --- 2 - title: five iterations 3 - test_name: TestIterate_Snapshot/five_iterations 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - []int{0, 1, 2, 3, 4}
-7
internal/bff/__snapshots__/full_bean_data.snap
··· 1 - --- 2 - title: full bean data 3 - test_name: TestTemplateRendering_BeanCard_Snapshot/full_bean_data 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - "\n<div class=\"bean-card\">\n <h3>Ethiopian Yirgacheffe</h3>\n <p>Origin: Ethiopia</p>\n <p>Roast: Light</p>\n</div>\n"
-7
internal/bff/__snapshots__/full_grinder_data.snap
··· 1 - --- 2 - title: full grinder data 3 - test_name: TestTemplateRendering_GearCards_Snapshot/full_grinder_data 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - "\n<div class=\"grinder-card\">\n <h3>1Zpresso JX-Pro</h3>\n <p>Type: Hand</p>\n <p>Burr: Conical</p>\n</div>\n"
-48
internal/bff/__snapshots__/grinder_form_renders.snap
··· 1 - --- 2 - title: grinder_form_renders 3 - test_name: TestNewGrinderForm_Snapshot/grinder_form_renders 4 - file_name: form_template_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - <div x-show="showNewGrinder" class="mt-4 p-4 bg-brown-100 rounded border border-brown-300"> 8 - <h4 class="font-medium mb-3 text-gray-800"> 9 - Add New Grinder 10 - </h4> 11 - <div class="space-y-3"> 12 - <input type="text" x-model="newGrinder.name" placeholder="Name (e.g. Baratza Encore) *" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 13 - <select x-model="newGrinder.grinderType" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 14 - <option value=""> 15 - Grinder Type (Optional) 16 - </option> 17 - <option value="Hand"> 18 - Hand 19 - </option> 20 - <option value="Electric"> 21 - Electric 22 - </option> 23 - <option value="Electric Hand"> 24 - Electric Hand 25 - </option> 26 - </select> 27 - <select x-model="newGrinder.burrType" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 28 - <option value=""> 29 - Burr Type (Optional) 30 - </option> 31 - <option value="Conical"> 32 - Conical 33 - </option> 34 - <option value="Flat"> 35 - Flat 36 - </option> 37 - </select> 38 - <input type="text" x-model="newGrinder.notes" placeholder="Notes (optional)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 39 - <div class="flex gap-2"> 40 - <button type="button" @click="addGrinder()" class="bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700"> 41 - Add 42 - </button> 43 - <button type="button" @click="showNewGrinder = false" class="bg-gray-300 px-4 py-2 rounded hover:bg-gray-400"> 44 - Cancel 45 - </button> 46 - </div> 47 - </div> 48 - </div>
-59
internal/bff/__snapshots__/grinder_item.snap
··· 1 - --- 2 - title: grinder item 3 - test_name: TestFeedTemplate_GrinderItem_Snapshot 4 - file_name: feed_template_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - <div class="space-y-4"> 8 - <div class="bg-gradient-to-br from-brown-50 to-brown-100 rounded-lg shadow-md border border-brown-200 p-4 hover:shadow-lg transition-shadow"> 9 - <div class="flex items-center gap-3 mb-3"> 10 - <a href="/profile/gearhead" class="flex-shrink-0"> 11 - <div class="w-10 h-10 rounded-full bg-brown-300 flex items-center justify-center hover:ring-2 hover:ring-brown-600 transition"> 12 - <span class="text-brown-600 text-sm"> 13 - ? 14 - </span> 15 - </div> 16 - </a> 17 - <div class="flex-1 min-w-0"> 18 - <div class="flex items-center gap-2"> 19 - <a href="/profile/gearhead" class="font-medium text-brown-900 truncate hover:text-brown-700 hover:underline"> 20 - Coffee Gear Head 21 - </a> 22 - <a href="/profile/gearhead" class="text-brown-600 text-sm truncate hover:text-brown-700 hover:underline"> 23 - @gearhead 24 - </a> 25 - </div> 26 - <span class="text-brown-500 text-sm"> 27 - 30 minutes ago 28 - </span> 29 - </div> 30 - </div> 31 - <div class="mb-2 text-sm text-brown-700"> 32 - ⚙️ added a new grinder 33 - </div> 34 - <div class="bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200"> 35 - <div class="text-base mb-2"> 36 - <span class="font-bold text-brown-900"> 37 - Comandante C40 38 - </span> 39 - </div> 40 - <div class="text-sm text-brown-700 space-y-1"> 41 - <div> 42 - <span class="text-brown-600"> 43 - Type: 44 - </span> 45 - Hand 46 - </div> 47 - <div> 48 - <span class="text-brown-600"> 49 - Burr: 50 - </span> 51 - Conical 52 - </div> 53 - <div class="mt-2 text-brown-800 italic"> 54 - "Excellent for pour over" 55 - </div> 56 - </div> 57 - </div> 58 - </div> 59 - </div>
-210
internal/bff/__snapshots__/grinders_empty.snap
··· 1 - --- 2 - title: grinders empty 3 - test_name: TestManageContent_GrindersTab_Snapshot/grinders_empty 4 - file_name: partial_template_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - <div x-show="tab === 'beans'"> 8 - <div class="mb-4 flex justify-between items-center"> 9 - <h3 class="text-xl font-semibold text-brown-900"> 10 - Coffee Beans 11 - </h3> 12 - <button 13 - @click="showBeanForm = true; editingBean = null; beanForm = {name: '', origin: '', roast_level: '', process: '', description: '', roaster_rkey: ''}" 14 - class="bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-medium shadow-md hover:shadow-lg"> 15 - + Add Bean 16 - </button> 17 - </div> 18 - <div class="bg-brown-100 rounded-lg p-8 text-center text-brown-700 border border-brown-200"> 19 - No beans yet. Add your first bean to get started! 20 - </div> 21 - </div> 22 - <div x-show="tab === 'roasters'"> 23 - <div class="mb-4 flex justify-between items-center"> 24 - <h3 class="text-xl font-semibold text-brown-900"> 25 - Roasters 26 - </h3> 27 - <button 28 - @click="showRoasterForm = true; editingRoaster = null; roasterForm = {name: '', location: '', website: ''}" 29 - class="bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-medium shadow-md hover:shadow-lg"> 30 - + Add Roaster 31 - </button> 32 - </div> 33 - <div class="bg-brown-100 rounded-lg p-8 text-center text-brown-700 border border-brown-200"> 34 - No roasters yet. Add your first roaster! 35 - </div> 36 - </div> 37 - <div x-show="tab === 'grinders'"> 38 - <div class="mb-4 flex justify-between items-center"> 39 - <h3 class="text-xl font-semibold text-brown-900"> 40 - Grinders 41 - </h3> 42 - <button 43 - @click="showGrinderForm = true; editingGrinder = null; grinderForm = {name: '', grinder_type: '', burr_type: '', notes: ''}" 44 - class="bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-medium shadow-md hover:shadow-lg"> 45 - + Add Grinder 46 - </button> 47 - </div> 48 - <div class="bg-brown-100 rounded-lg p-8 text-center text-brown-700 border border-brown-200"> 49 - No grinders yet. Add your first grinder! 50 - </div> 51 - </div> 52 - <div x-show="tab === 'brewers'"> 53 - <div class="mb-4 flex justify-between items-center"> 54 - <h3 class="text-xl font-semibold text-brown-900"> 55 - Brewers 56 - </h3> 57 - <button @click="showBrewerForm = true; editingBrewer = null; brewerForm = {name: '', brewer_type: '', description: ''}" 58 - class="bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-medium shadow-md hover:shadow-lg"> 59 - + Add Brewer 60 - </button> 61 - </div> 62 - <div class="bg-brown-100 rounded-lg p-8 text-center text-brown-700 border border-brown-200"> 63 - No brewers yet. Add your first brewer! 64 - </div> 65 - </div> 66 - <div x-cloak x-show="showBeanForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 67 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 68 - <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingBean ? 'Edit Bean' : 'Add Bean'"></h3> 69 - <div class="space-y-4"> 70 - <input type="text" x-model="beanForm.name" placeholder="Name *" 71 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 72 - <input type="text" x-model="beanForm.origin" placeholder="Origin *" 73 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 74 - <select x-model="beanForm.roaster_rkey" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 75 - <option value=""> 76 - Select Roaster (Optional) 77 - </option> 78 - </select> 79 - <select x-model="beanForm.roast_level" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 80 - <option value=""> 81 - Select Roast Level (Optional) 82 - </option> 83 - <option value="Ultra-Light"> 84 - Ultra-Light 85 - </option> 86 - <option value="Light"> 87 - Light 88 - </option> 89 - <option value="Medium-Light"> 90 - Medium-Light 91 - </option> 92 - <option value="Medium"> 93 - Medium 94 - </option> 95 - <option value="Medium-Dark"> 96 - Medium-Dark 97 - </option> 98 - <option value="Dark"> 99 - Dark 100 - </option> 101 - </select> 102 - <input type="text" x-model="beanForm.process" placeholder="Process (e.g. Washed, Natural, Honey)" 103 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 104 - <textarea x-model="beanForm.description" placeholder="Description" rows="3" 105 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"></textarea> 106 - <div class="flex gap-2"> 107 - <button @click="saveBean()" 108 - class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md"> 109 - Save 110 - </button> 111 - <button @click="showBeanForm = false" 112 - class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 113 - Cancel 114 - </button> 115 - </div> 116 - </div> 117 - </div> 118 - </div> 119 - <div x-cloak x-show="showRoasterForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 120 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 121 - <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingRoaster ? 'Edit Roaster' : 'Add Roaster'"></h3> 122 - <div class="space-y-4"> 123 - <input type="text" x-model="roasterForm.name" placeholder="Name *" 124 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 125 - <input type="text" x-model="roasterForm.location" placeholder="Location" 126 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 127 - <input type="url" x-model="roasterForm.website" placeholder="Website" 128 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 129 - <div class="flex gap-2"> 130 - <button @click="saveRoaster()" 131 - class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md"> 132 - Save 133 - </button> 134 - <button @click="showRoasterForm = false" 135 - class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 136 - Cancel 137 - </button> 138 - </div> 139 - </div> 140 - </div> 141 - </div> 142 - <div x-cloak x-show="showGrinderForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 143 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 144 - <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingGrinder ? 'Edit Grinder' : 'Add Grinder'"></h3> 145 - <div class="space-y-4"> 146 - <input type="text" x-model="grinderForm.name" placeholder="Name *" 147 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 148 - <select x-model="grinderForm.grinder_type" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 149 - <option value=""> 150 - Select Grinder Type * 151 - </option> 152 - <option value="Hand"> 153 - Hand 154 - </option> 155 - <option value="Electric"> 156 - Electric 157 - </option> 158 - <option value="Portable Electric"> 159 - Portable Electric 160 - </option> 161 - </select> 162 - <select x-model="grinderForm.burr_type" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 163 - <option value=""> 164 - Select Burr Type (Optional) 165 - </option> 166 - <option value="Conical"> 167 - Conical 168 - </option> 169 - <option value="Flat"> 170 - Flat 171 - </option> 172 - </select> 173 - <textarea x-model="grinderForm.notes" placeholder="Notes" rows="3" 174 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"></textarea> 175 - <div class="flex gap-2"> 176 - <button @click="saveGrinder()" 177 - class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md"> 178 - Save 179 - </button> 180 - <button @click="showGrinderForm = false" 181 - class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 182 - Cancel 183 - </button> 184 - </div> 185 - </div> 186 - </div> 187 - </div> 188 - <div x-cloak x-show="showBrewerForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 189 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 190 - <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingBrewer ? 'Edit Brewer' : 'Add Brewer'"></h3> 191 - <div class="space-y-4"> 192 - <input type="text" x-model="brewerForm.name" placeholder="Name *" 193 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 194 - <input type="text" x-model="brewerForm.brewer_type" placeholder="Type (e.g., Pour-Over, Immersion, Espresso)" 195 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 196 - <textarea x-model="brewerForm.description" placeholder="Description" rows="3" 197 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"></textarea> 198 - <div class="flex gap-2"> 199 - <button @click="saveBrewer()" 200 - class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md"> 201 - Save 202 - </button> 203 - <button @click="showBrewerForm = false" 204 - class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 205 - Cancel 206 - </button> 207 - </div> 208 - </div> 209 - </div> 210 - </div>
-278
internal/bff/__snapshots__/grinders_with_data.snap
··· 1 - --- 2 - title: grinders with data 3 - test_name: TestManageContent_GrindersTab_Snapshot/grinders_with_data 4 - file_name: partial_template_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - <div x-show="tab === 'beans'"> 8 - <div class="mb-4 flex justify-between items-center"> 9 - <h3 class="text-xl font-semibold text-brown-900"> 10 - Coffee Beans 11 - </h3> 12 - <button 13 - @click="showBeanForm = true; editingBean = null; beanForm = {name: '', origin: '', roast_level: '', process: '', description: '', roaster_rkey: ''}" 14 - class="bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-medium shadow-md hover:shadow-lg"> 15 - + Add Bean 16 - </button> 17 - </div> 18 - <div class="bg-brown-100 rounded-lg p-8 text-center text-brown-700 border border-brown-200"> 19 - No beans yet. Add your first bean to get started! 20 - </div> 21 - </div> 22 - <div x-show="tab === 'roasters'"> 23 - <div class="mb-4 flex justify-between items-center"> 24 - <h3 class="text-xl font-semibold text-brown-900"> 25 - Roasters 26 - </h3> 27 - <button 28 - @click="showRoasterForm = true; editingRoaster = null; roasterForm = {name: '', location: '', website: ''}" 29 - class="bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-medium shadow-md hover:shadow-lg"> 30 - + Add Roaster 31 - </button> 32 - </div> 33 - <div class="bg-brown-100 rounded-lg p-8 text-center text-brown-700 border border-brown-200"> 34 - No roasters yet. Add your first roaster! 35 - </div> 36 - </div> 37 - <div x-show="tab === 'grinders'"> 38 - <div class="mb-4 flex justify-between items-center"> 39 - <h3 class="text-xl font-semibold text-brown-900"> 40 - Grinders 41 - </h3> 42 - <button 43 - @click="showGrinderForm = true; editingGrinder = null; grinderForm = {name: '', grinder_type: '', burr_type: '', notes: ''}" 44 - class="bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-medium shadow-md hover:shadow-lg"> 45 - + Add Grinder 46 - </button> 47 - </div> 48 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 shadow-xl rounded-xl overflow-x-auto border border-brown-300"> 49 - <table class="min-w-full divide-y divide-brown-300"> 50 - <thead class="bg-brown-200/80"> 51 - <tr> 52 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase"> 53 - Name 54 - </th> 55 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase"> 56 - 🔧 Grinder Type 57 - </th> 58 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase"> 59 - 💎 Burr Type 60 - </th> 61 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase"> 62 - 📝 Notes 63 - </th> 64 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase"> 65 - Actions 66 - </th> 67 - </tr> 68 - </thead> 69 - <tbody class="bg-brown-50/60 divide-y divide-brown-200"> 70 - <tr class="hover:bg-brown-100/60 transition-colors"> 71 - <td class="px-6 py-4 text-sm font-medium text-brown-900"> 72 - Comandante C40 MK3 73 - </td> 74 - <td class="px-6 py-4 text-sm text-brown-900"> 75 - Hand 76 - </td> 77 - <td class="px-6 py-4 text-sm text-brown-900"> 78 - Conical 79 - </td> 80 - <td class="px-6 py-4 text-sm text-brown-700"> 81 - Excellent consistency, great for pour-over 82 - </td> 83 - <td class="px-6 py-4 text-sm font-medium space-x-2"> 84 - <button @click="editGrinder('grinder1', 'Comandante C40 MK3', 'Hand', 'Conical', 'Excellent consistency, great for pour-over')" 85 - class="text-brown-700 hover:text-brown-900 font-medium"> 86 - Edit 87 - </button> 88 - <button @click="deleteGrinder('grinder1')" 89 - class="text-brown-600 hover:text-brown-800 font-medium"> 90 - Delete 91 - </button> 92 - </td> 93 - </tr> 94 - <tr class="hover:bg-brown-100/60 transition-colors"> 95 - <td class="px-6 py-4 text-sm font-medium text-brown-900"> 96 - Baratza Encore 97 - </td> 98 - <td class="px-6 py-4 text-sm text-brown-900"> 99 - Electric 100 - </td> 101 - <td class="px-6 py-4 text-sm text-brown-900"> 102 - Conical 103 - </td> 104 - <td class="px-6 py-4 text-sm text-brown-700"></td> 105 - <td class="px-6 py-4 text-sm font-medium space-x-2"> 106 - <button @click="editGrinder('grinder2', 'Baratza Encore', 'Electric', 'Conical', '')" 107 - class="text-brown-700 hover:text-brown-900 font-medium"> 108 - Edit 109 - </button> 110 - <button @click="deleteGrinder('grinder2')" 111 - class="text-brown-600 hover:text-brown-800 font-medium"> 112 - Delete 113 - </button> 114 - </td> 115 - </tr> 116 - </tbody> 117 - </table> 118 - </div> 119 - </div> 120 - <div x-show="tab === 'brewers'"> 121 - <div class="mb-4 flex justify-between items-center"> 122 - <h3 class="text-xl font-semibold text-brown-900"> 123 - Brewers 124 - </h3> 125 - <button @click="showBrewerForm = true; editingBrewer = null; brewerForm = {name: '', brewer_type: '', description: ''}" 126 - class="bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-medium shadow-md hover:shadow-lg"> 127 - + Add Brewer 128 - </button> 129 - </div> 130 - <div class="bg-brown-100 rounded-lg p-8 text-center text-brown-700 border border-brown-200"> 131 - No brewers yet. Add your first brewer! 132 - </div> 133 - </div> 134 - <div x-cloak x-show="showBeanForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 135 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 136 - <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingBean ? 'Edit Bean' : 'Add Bean'"></h3> 137 - <div class="space-y-4"> 138 - <input type="text" x-model="beanForm.name" placeholder="Name *" 139 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 140 - <input type="text" x-model="beanForm.origin" placeholder="Origin *" 141 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 142 - <select x-model="beanForm.roaster_rkey" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 143 - <option value=""> 144 - Select Roaster (Optional) 145 - </option> 146 - </select> 147 - <select x-model="beanForm.roast_level" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 148 - <option value=""> 149 - Select Roast Level (Optional) 150 - </option> 151 - <option value="Ultra-Light"> 152 - Ultra-Light 153 - </option> 154 - <option value="Light"> 155 - Light 156 - </option> 157 - <option value="Medium-Light"> 158 - Medium-Light 159 - </option> 160 - <option value="Medium"> 161 - Medium 162 - </option> 163 - <option value="Medium-Dark"> 164 - Medium-Dark 165 - </option> 166 - <option value="Dark"> 167 - Dark 168 - </option> 169 - </select> 170 - <input type="text" x-model="beanForm.process" placeholder="Process (e.g. Washed, Natural, Honey)" 171 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 172 - <textarea x-model="beanForm.description" placeholder="Description" rows="3" 173 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"></textarea> 174 - <div class="flex gap-2"> 175 - <button @click="saveBean()" 176 - class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md"> 177 - Save 178 - </button> 179 - <button @click="showBeanForm = false" 180 - class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 181 - Cancel 182 - </button> 183 - </div> 184 - </div> 185 - </div> 186 - </div> 187 - <div x-cloak x-show="showRoasterForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 188 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 189 - <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingRoaster ? 'Edit Roaster' : 'Add Roaster'"></h3> 190 - <div class="space-y-4"> 191 - <input type="text" x-model="roasterForm.name" placeholder="Name *" 192 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 193 - <input type="text" x-model="roasterForm.location" placeholder="Location" 194 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 195 - <input type="url" x-model="roasterForm.website" placeholder="Website" 196 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 197 - <div class="flex gap-2"> 198 - <button @click="saveRoaster()" 199 - class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md"> 200 - Save 201 - </button> 202 - <button @click="showRoasterForm = false" 203 - class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 204 - Cancel 205 - </button> 206 - </div> 207 - </div> 208 - </div> 209 - </div> 210 - <div x-cloak x-show="showGrinderForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 211 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 212 - <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingGrinder ? 'Edit Grinder' : 'Add Grinder'"></h3> 213 - <div class="space-y-4"> 214 - <input type="text" x-model="grinderForm.name" placeholder="Name *" 215 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 216 - <select x-model="grinderForm.grinder_type" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 217 - <option value=""> 218 - Select Grinder Type * 219 - </option> 220 - <option value="Hand"> 221 - Hand 222 - </option> 223 - <option value="Electric"> 224 - Electric 225 - </option> 226 - <option value="Portable Electric"> 227 - Portable Electric 228 - </option> 229 - </select> 230 - <select x-model="grinderForm.burr_type" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 231 - <option value=""> 232 - Select Burr Type (Optional) 233 - </option> 234 - <option value="Conical"> 235 - Conical 236 - </option> 237 - <option value="Flat"> 238 - Flat 239 - </option> 240 - </select> 241 - <textarea x-model="grinderForm.notes" placeholder="Notes" rows="3" 242 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"></textarea> 243 - <div class="flex gap-2"> 244 - <button @click="saveGrinder()" 245 - class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md"> 246 - Save 247 - </button> 248 - <button @click="showGrinderForm = false" 249 - class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 250 - Cancel 251 - </button> 252 - </div> 253 - </div> 254 - </div> 255 - </div> 256 - <div x-cloak x-show="showBrewerForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 257 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 258 - <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingBrewer ? 'Edit Brewer' : 'Add Brewer'"></h3> 259 - <div class="space-y-4"> 260 - <input type="text" x-model="brewerForm.name" placeholder="Name *" 261 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 262 - <input type="text" x-model="brewerForm.brewer_type" placeholder="Type (e.g., Pour-Over, Immersion, Espresso)" 263 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 264 - <textarea x-model="brewerForm.description" placeholder="Description" rows="3" 265 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"></textarea> 266 - <div class="flex gap-2"> 267 - <button @click="saveBrewer()" 268 - class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md"> 269 - Save 270 - </button> 271 - <button @click="showBrewerForm = false" 272 - class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 273 - Cancel 274 - </button> 275 - </div> 276 - </div> 277 - </div> 278 - </div>
-256
internal/bff/__snapshots__/grinders_with_unicode.snap
··· 1 - --- 2 - title: grinders with unicode 3 - test_name: TestManageContent_SpecialCharacters_Snapshot/grinders_with_unicode 4 - file_name: partial_template_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - <div x-show="tab === 'beans'"> 8 - <div class="mb-4 flex justify-between items-center"> 9 - <h3 class="text-xl font-semibold text-brown-900"> 10 - Coffee Beans 11 - </h3> 12 - <button 13 - @click="showBeanForm = true; editingBean = null; beanForm = {name: '', origin: '', roast_level: '', process: '', description: '', roaster_rkey: ''}" 14 - class="bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-medium shadow-md hover:shadow-lg"> 15 - + Add Bean 16 - </button> 17 - </div> 18 - <div class="bg-brown-100 rounded-lg p-8 text-center text-brown-700 border border-brown-200"> 19 - No beans yet. Add your first bean to get started! 20 - </div> 21 - </div> 22 - <div x-show="tab === 'roasters'"> 23 - <div class="mb-4 flex justify-between items-center"> 24 - <h3 class="text-xl font-semibold text-brown-900"> 25 - Roasters 26 - </h3> 27 - <button 28 - @click="showRoasterForm = true; editingRoaster = null; roasterForm = {name: '', location: '', website: ''}" 29 - class="bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-medium shadow-md hover:shadow-lg"> 30 - + Add Roaster 31 - </button> 32 - </div> 33 - <div class="bg-brown-100 rounded-lg p-8 text-center text-brown-700 border border-brown-200"> 34 - No roasters yet. Add your first roaster! 35 - </div> 36 - </div> 37 - <div x-show="tab === 'grinders'"> 38 - <div class="mb-4 flex justify-between items-center"> 39 - <h3 class="text-xl font-semibold text-brown-900"> 40 - Grinders 41 - </h3> 42 - <button 43 - @click="showGrinderForm = true; editingGrinder = null; grinderForm = {name: '', grinder_type: '', burr_type: '', notes: ''}" 44 - class="bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-medium shadow-md hover:shadow-lg"> 45 - + Add Grinder 46 - </button> 47 - </div> 48 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 shadow-xl rounded-xl overflow-x-auto border border-brown-300"> 49 - <table class="min-w-full divide-y divide-brown-300"> 50 - <thead class="bg-brown-200/80"> 51 - <tr> 52 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase"> 53 - Name 54 - </th> 55 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase"> 56 - 🔧 Grinder Type 57 - </th> 58 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase"> 59 - 💎 Burr Type 60 - </th> 61 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase"> 62 - 📝 Notes 63 - </th> 64 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase"> 65 - Actions 66 - </th> 67 - </tr> 68 - </thead> 69 - <tbody class="bg-brown-50/60 divide-y divide-brown-200"> 70 - <tr class="hover:bg-brown-100/60 transition-colors"> 71 - <td class="px-6 py-4 text-sm font-medium text-brown-900"> 72 - 手動コーヒーミル Comandante® C40 73 - </td> 74 - <td class="px-6 py-4 text-sm text-brown-900"> 75 - Hand 76 - </td> 77 - <td class="px-6 py-4 text-sm text-brown-900"> 78 - Conical 79 - </td> 80 - <td class="px-6 py-4 text-sm text-brown-700"> 81 - 日本語のノート - Отличная кофемолка 🇯🇵 82 - </td> 83 - <td class="px-6 py-4 text-sm font-medium space-x-2"> 84 - <button @click="editGrinder('grinder1', '手動コーヒーミル Comandante® C40', 'Hand', 'Conical', '日本語のノート - Отличная кофемолка 🇯🇵')" 85 - class="text-brown-700 hover:text-brown-900 font-medium"> 86 - Edit 87 - </button> 88 - <button @click="deleteGrinder('grinder1')" 89 - class="text-brown-600 hover:text-brown-800 font-medium"> 90 - Delete 91 - </button> 92 - </td> 93 - </tr> 94 - </tbody> 95 - </table> 96 - </div> 97 - </div> 98 - <div x-show="tab === 'brewers'"> 99 - <div class="mb-4 flex justify-between items-center"> 100 - <h3 class="text-xl font-semibold text-brown-900"> 101 - Brewers 102 - </h3> 103 - <button @click="showBrewerForm = true; editingBrewer = null; brewerForm = {name: '', brewer_type: '', description: ''}" 104 - class="bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-medium shadow-md hover:shadow-lg"> 105 - + Add Brewer 106 - </button> 107 - </div> 108 - <div class="bg-brown-100 rounded-lg p-8 text-center text-brown-700 border border-brown-200"> 109 - No brewers yet. Add your first brewer! 110 - </div> 111 - </div> 112 - <div x-cloak x-show="showBeanForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 113 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 114 - <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingBean ? 'Edit Bean' : 'Add Bean'"></h3> 115 - <div class="space-y-4"> 116 - <input type="text" x-model="beanForm.name" placeholder="Name *" 117 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 118 - <input type="text" x-model="beanForm.origin" placeholder="Origin *" 119 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 120 - <select x-model="beanForm.roaster_rkey" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 121 - <option value=""> 122 - Select Roaster (Optional) 123 - </option> 124 - </select> 125 - <select x-model="beanForm.roast_level" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 126 - <option value=""> 127 - Select Roast Level (Optional) 128 - </option> 129 - <option value="Ultra-Light"> 130 - Ultra-Light 131 - </option> 132 - <option value="Light"> 133 - Light 134 - </option> 135 - <option value="Medium-Light"> 136 - Medium-Light 137 - </option> 138 - <option value="Medium"> 139 - Medium 140 - </option> 141 - <option value="Medium-Dark"> 142 - Medium-Dark 143 - </option> 144 - <option value="Dark"> 145 - Dark 146 - </option> 147 - </select> 148 - <input type="text" x-model="beanForm.process" placeholder="Process (e.g. Washed, Natural, Honey)" 149 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 150 - <textarea x-model="beanForm.description" placeholder="Description" rows="3" 151 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"></textarea> 152 - <div class="flex gap-2"> 153 - <button @click="saveBean()" 154 - class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md"> 155 - Save 156 - </button> 157 - <button @click="showBeanForm = false" 158 - class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 159 - Cancel 160 - </button> 161 - </div> 162 - </div> 163 - </div> 164 - </div> 165 - <div x-cloak x-show="showRoasterForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 166 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 167 - <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingRoaster ? 'Edit Roaster' : 'Add Roaster'"></h3> 168 - <div class="space-y-4"> 169 - <input type="text" x-model="roasterForm.name" placeholder="Name *" 170 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 171 - <input type="text" x-model="roasterForm.location" placeholder="Location" 172 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 173 - <input type="url" x-model="roasterForm.website" placeholder="Website" 174 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 175 - <div class="flex gap-2"> 176 - <button @click="saveRoaster()" 177 - class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md"> 178 - Save 179 - </button> 180 - <button @click="showRoasterForm = false" 181 - class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 182 - Cancel 183 - </button> 184 - </div> 185 - </div> 186 - </div> 187 - </div> 188 - <div x-cloak x-show="showGrinderForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 189 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 190 - <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingGrinder ? 'Edit Grinder' : 'Add Grinder'"></h3> 191 - <div class="space-y-4"> 192 - <input type="text" x-model="grinderForm.name" placeholder="Name *" 193 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 194 - <select x-model="grinderForm.grinder_type" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 195 - <option value=""> 196 - Select Grinder Type * 197 - </option> 198 - <option value="Hand"> 199 - Hand 200 - </option> 201 - <option value="Electric"> 202 - Electric 203 - </option> 204 - <option value="Portable Electric"> 205 - Portable Electric 206 - </option> 207 - </select> 208 - <select x-model="grinderForm.burr_type" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 209 - <option value=""> 210 - Select Burr Type (Optional) 211 - </option> 212 - <option value="Conical"> 213 - Conical 214 - </option> 215 - <option value="Flat"> 216 - Flat 217 - </option> 218 - </select> 219 - <textarea x-model="grinderForm.notes" placeholder="Notes" rows="3" 220 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"></textarea> 221 - <div class="flex gap-2"> 222 - <button @click="saveGrinder()" 223 - class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md"> 224 - Save 225 - </button> 226 - <button @click="showGrinderForm = false" 227 - class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 228 - Cancel 229 - </button> 230 - </div> 231 - </div> 232 - </div> 233 - </div> 234 - <div x-cloak x-show="showBrewerForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 235 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 236 - <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingBrewer ? 'Edit Brewer' : 'Add Brewer'"></h3> 237 - <div class="space-y-4"> 238 - <input type="text" x-model="brewerForm.name" placeholder="Name *" 239 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 240 - <input type="text" x-model="brewerForm.brewer_type" placeholder="Type (e.g., Pour-Over, Immersion, Espresso)" 241 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 242 - <textarea x-model="brewerForm.description" placeholder="Description" rows="3" 243 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"></textarea> 244 - <div class="flex gap-2"> 245 - <button @click="saveBrewer()" 246 - class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md"> 247 - Save 248 - </button> 249 - <button @click="showBrewerForm = false" 250 - class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 251 - Cancel 252 - </button> 253 - </div> 254 - </div> 255 - </div> 256 - </div>
-7
internal/bff/__snapshots__/half_used.snap
··· 1 - --- 2 - title: half used 3 - test_name: TestIterateRemaining_Snapshot/half_used 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - []int(nil)
-7
internal/bff/__snapshots__/large_value.snap
··· 1 - --- 2 - title: large value 3 - test_name: TestHasValue_Snapshot/large_value 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - true
-7
internal/bff/__snapshots__/minimal_bean_data.snap
··· 1 - --- 2 - title: minimal bean data 3 - test_name: TestTemplateRendering_BeanCard_Snapshot/minimal_bean_data 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - "\n<div class=\"bean-card\">\n <h3>House Blend</h3>\n \n \n</div>\n"
-7
internal/bff/__snapshots__/minimal_brew.snap
··· 1 - --- 2 - title: minimal brew 3 - test_name: TestTemplateRendering_BrewCard_Snapshot/minimal_brew 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - "\n<div class=\"brew-card\">\n <div class=\"date\">Jan 15, 2024</div>\n \n \n \n</div>\n"
-7
internal/bff/__snapshots__/minimal_grinder_data.snap
··· 1 - --- 2 - title: minimal grinder data 3 - test_name: TestTemplateRendering_GearCards_Snapshot/minimal_grinder_data 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - "\n<div class=\"grinder-card\">\n <h3>Generic Grinder</h3>\n \n \n</div>\n"
-308
internal/bff/__snapshots__/mixed_feed_all_types.snap
··· 1 - --- 2 - title: mixed feed all types 3 - test_name: TestFeedTemplate_MixedFeed_Snapshot 4 - file_name: feed_template_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - <div class="space-y-4"> 8 - <div class="bg-gradient-to-br from-brown-50 to-brown-100 rounded-lg shadow-md border border-brown-200 p-4 hover:shadow-lg transition-shadow"> 9 - <div class="flex items-center gap-3 mb-3"> 10 - <a href="/profile/user1" class="flex-shrink-0"> 11 - <div class="w-10 h-10 rounded-full bg-brown-300 flex items-center justify-center hover:ring-2 hover:ring-brown-600 transition"> 12 - <span class="text-brown-600 text-sm"> 13 - ? 14 - </span> 15 - </div> 16 - </a> 17 - <div class="flex-1 min-w-0"> 18 - <div class="flex items-center gap-2"> 19 - <a href="/profile/user1" class="font-medium text-brown-900 truncate hover:text-brown-700 hover:underline"> 20 - User One 21 - </a> 22 - <a href="/profile/user1" class="text-brown-600 text-sm truncate hover:text-brown-700 hover:underline"> 23 - @user1 24 - </a> 25 - </div> 26 - <span class="text-brown-500 text-sm"> 27 - 1 hour ago 28 - </span> 29 - </div> 30 - </div> 31 - <div class="mb-2 text-sm text-brown-700"> 32 - ☕ added a new brew 33 - </div> 34 - <div class="bg-white/60 backdrop-blur rounded-lg p-4 border border-brown-200"> 35 - <div class="flex items-start justify-between gap-3 mb-3"> 36 - <div class="flex-1 min-w-0"> 37 - <div class="font-bold text-brown-900 text-base"> 38 - Ethiopian Yirgacheffe 39 - </div> 40 - <div class="text-sm text-brown-700 mt-0.5"> 41 - <span class="font-medium"> 42 - 🏭 Onyx 43 - </span> 44 - </div> 45 - <div class="text-xs text-brown-600 mt-1 flex flex-wrap gap-x-2 gap-y-0.5"> 46 - <span class="inline-flex items-center gap-0.5"> 47 - 📍 Ethiopia 48 - </span> 49 - <span class="inline-flex items-center gap-0.5"> 50 - 🔥 Light 51 - </span> 52 - <span class="inline-flex items-center gap-0.5"> 53 - 🌱 Washed 54 - </span> 55 - <span class="inline-flex items-center gap-0.5"> 56 - ⚖️ 16g 57 - </span> 58 - </div> 59 - </div> 60 - <span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-amber-100 text-amber-900 flex-shrink-0"> 61 - ⭐ 9/10 62 - </span> 63 - </div> 64 - <div class="mb-2"> 65 - <span class="text-xs text-brown-600"> 66 - Brewer: 67 - </span> 68 - <span class="text-sm font-semibold text-brown-900"> 69 - Hario V60 70 - </span> 71 - </div> 72 - <div class="grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-brown-700"> 73 - <div> 74 - <span class="text-brown-600"> 75 - Grinder: 76 - </span> 77 - 1Zpresso JX-Pro (Medium-fine) 78 - </div> 79 - <div class="col-span-2"> 80 - <span class="text-brown-600"> 81 - Pours: 82 - </span> 83 - <div class="pl-2 text-brown-600"> 84 - • 50g @ 30s 85 - </div> 86 - <div class="pl-2 text-brown-600"> 87 - • 100g @ 45s 88 - </div> 89 - <div class="pl-2 text-brown-600"> 90 - • 100g @ 1m 91 - </div> 92 - </div> 93 - <div> 94 - <span class="text-brown-600"> 95 - Temp: 96 - </span> 97 - 93.0°C 98 - </div> 99 - <div> 100 - <span class="text-brown-600"> 101 - Time: 102 - </span> 103 - 3m 104 - </div> 105 - </div> 106 - <div class="mt-3 text-sm text-brown-800 italic border-t border-brown-200 pt-2"> 107 - "Bright citrus notes with floral aroma" 108 - </div> 109 - <div class="mt-3 border-t border-brown-200 pt-3"> 110 - <a href="/brews/brew123?owner=user1" 111 - class="inline-flex items-center text-sm font-medium text-brown-700 hover:text-brown-900 hover:underline"> 112 - View full details → 113 - </a> 114 - </div> 115 - </div> 116 - </div> 117 - <div class="bg-gradient-to-br from-brown-50 to-brown-100 rounded-lg shadow-md border border-brown-200 p-4 hover:shadow-lg transition-shadow"> 118 - <div class="flex items-center gap-3 mb-3"> 119 - <a href="/profile/user2" class="flex-shrink-0"> 120 - <img src="https://cdn.bsky.app/avatar.jpg" alt="" class="w-10 h-10 rounded-full object-cover hover:ring-2 hover:ring-brown-600 transition" /> 121 - </a> 122 - <div class="flex-1 min-w-0"> 123 - <div class="flex items-center gap-2"> 124 - <a href="/profile/user2" class="font-medium text-brown-900 truncate hover:text-brown-700 hover:underline"> 125 - User Two 126 - </a> 127 - <a href="/profile/user2" class="text-brown-600 text-sm truncate hover:text-brown-700 hover:underline"> 128 - @user2 129 - </a> 130 - </div> 131 - <span class="text-brown-500 text-sm"> 132 - 1.5 hours ago 133 - </span> 134 - </div> 135 - </div> 136 - <div class="mb-2 text-sm text-brown-700"> 137 - 🫘 added a new bean 138 - </div> 139 - <div class="bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200"> 140 - <div class="text-base mb-2"> 141 - <span class="font-bold text-brown-900"> 142 - Kenya AA 143 - </span> 144 - <span class="text-brown-700"> 145 - from Onyx Coffee Lab 146 - </span> 147 - </div> 148 - <div class="text-sm text-brown-700 space-y-1"> 149 - <div> 150 - <span class="text-brown-600"> 151 - Origin: 152 - </span> 153 - Kenya 154 - </div> 155 - <div> 156 - <span class="text-brown-600"> 157 - Roast: 158 - </span> 159 - Medium 160 - </div> 161 - <div> 162 - <span class="text-brown-600"> 163 - Process: 164 - </span> 165 - Natural 166 - </div> 167 - <div class="mt-2 text-brown-800 italic"> 168 - "Sweet and fruity with notes of blueberry" 169 - </div> 170 - </div> 171 - </div> 172 - </div> 173 - <div class="bg-gradient-to-br from-brown-50 to-brown-100 rounded-lg shadow-md border border-brown-200 p-4 hover:shadow-lg transition-shadow"> 174 - <div class="flex items-center gap-3 mb-3"> 175 - <a href="/profile/user3" class="flex-shrink-0"> 176 - <div class="w-10 h-10 rounded-full bg-brown-300 flex items-center justify-center hover:ring-2 hover:ring-brown-600 transition"> 177 - <span class="text-brown-600 text-sm"> 178 - ? 179 - </span> 180 - </div> 181 - </a> 182 - <div class="flex-1 min-w-0"> 183 - <div class="flex items-center gap-2"> 184 - <a href="/profile/user3" class="font-medium text-brown-900 truncate hover:text-brown-700 hover:underline"> 185 - User Three 186 - </a> 187 - <a href="/profile/user3" class="text-brown-600 text-sm truncate hover:text-brown-700 hover:underline"> 188 - @user3 189 - </a> 190 - </div> 191 - <span class="text-brown-500 text-sm"> 192 - 2 hours ago 193 - </span> 194 - </div> 195 - </div> 196 - <div class="mb-2 text-sm text-brown-700"> 197 - 🏪 added a new roaster 198 - </div> 199 - <div class="bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200"> 200 - <div class="text-base mb-2"> 201 - <span class="font-bold text-brown-900"> 202 - Heart Coffee Roasters 203 - </span> 204 - </div> 205 - <div class="text-sm text-brown-700 space-y-1"> 206 - <div> 207 - <span class="text-brown-600"> 208 - Location: 209 - </span> 210 - Portland, OR 211 - </div> 212 - <div> 213 - <span class="text-brown-600"> 214 - Website: 215 - </span> 216 - <a href="https://heartroasters.com" target="_blank" rel="noopener noreferrer" class="text-brown-800 hover:underline"> 217 - https://heartroasters.com 218 - </a> 219 - </div> 220 - </div> 221 - </div> 222 - </div> 223 - <div class="bg-gradient-to-br from-brown-50 to-brown-100 rounded-lg shadow-md border border-brown-200 p-4 hover:shadow-lg transition-shadow"> 224 - <div class="flex items-center gap-3 mb-3"> 225 - <a href="/profile/user4" class="flex-shrink-0"> 226 - <div class="w-10 h-10 rounded-full bg-brown-300 flex items-center justify-center hover:ring-2 hover:ring-brown-600 transition"> 227 - <span class="text-brown-600 text-sm"> 228 - ? 229 - </span> 230 - </div> 231 - </a> 232 - <div class="flex-1 min-w-0"> 233 - <div class="flex items-center gap-2"> 234 - <a href="/profile/user4" class="text-brown-600 text-sm truncate hover:text-brown-700 hover:underline"> 235 - @user4 236 - </a> 237 - </div> 238 - <span class="text-brown-500 text-sm"> 239 - 2.5 hours ago 240 - </span> 241 - </div> 242 - </div> 243 - <div class="mb-2 text-sm text-brown-700"> 244 - ⚙️ added a new grinder 245 - </div> 246 - <div class="bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200"> 247 - <div class="text-base mb-2"> 248 - <span class="font-bold text-brown-900"> 249 - Comandante C40 250 - </span> 251 - </div> 252 - <div class="text-sm text-brown-700 space-y-1"> 253 - <div> 254 - <span class="text-brown-600"> 255 - Type: 256 - </span> 257 - Hand 258 - </div> 259 - <div> 260 - <span class="text-brown-600"> 261 - Burr: 262 - </span> 263 - Conical 264 - </div> 265 - <div class="mt-2 text-brown-800 italic"> 266 - "Excellent for pour over" 267 - </div> 268 - </div> 269 - </div> 270 - </div> 271 - <div class="bg-gradient-to-br from-brown-50 to-brown-100 rounded-lg shadow-md border border-brown-200 p-4 hover:shadow-lg transition-shadow"> 272 - <div class="flex items-center gap-3 mb-3"> 273 - <a href="/profile/user5" class="flex-shrink-0"> 274 - <div class="w-10 h-10 rounded-full bg-brown-300 flex items-center justify-center hover:ring-2 hover:ring-brown-600 transition"> 275 - <span class="text-brown-600 text-sm"> 276 - ? 277 - </span> 278 - </div> 279 - </a> 280 - <div class="flex-1 min-w-0"> 281 - <div class="flex items-center gap-2"> 282 - <a href="/profile/user5" class="font-medium text-brown-900 truncate hover:text-brown-700 hover:underline"> 283 - User Five 284 - </a> 285 - <a href="/profile/user5" class="text-brown-600 text-sm truncate hover:text-brown-700 hover:underline"> 286 - @user5 287 - </a> 288 - </div> 289 - <span class="text-brown-500 text-sm"> 290 - 3 hours ago 291 - </span> 292 - </div> 293 - </div> 294 - <div class="mb-2 text-sm text-brown-700"> 295 - ☕ added a new brewer 296 - </div> 297 - <div class="bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200"> 298 - <div class="text-base mb-2"> 299 - <span class="font-bold text-brown-900"> 300 - Kalita Wave 185 301 - </span> 302 - </div> 303 - <div class="text-sm text-brown-800 italic"> 304 - "Flat-bottom dripper with wave filters" 305 - </div> 306 - </div> 307 - </div> 308 - </div>
-7
internal/bff/__snapshots__/mixed_valid_and_invalid.snap
··· 1 - --- 2 - title: mixed valid and invalid 3 - test_name: TestDict_ErrorCases_Snapshot/mixed_valid_and_invalid 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - "dict keys must be strings"
-11
internal/bff/__snapshots__/multiple_key-values.snap
··· 1 - --- 2 - title: multiple key-values 3 - test_name: TestDict_Snapshot/multiple_key-values 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - map[string]interface{}{ 8 - "name": "Ethiopian", 9 - "rating": 9, 10 - "roast": "Light", 11 - }
-7
internal/bff/__snapshots__/multiple_pours.snap
··· 1 - --- 2 - title: multiple pours 3 - test_name: TestPoursToJSON_Snapshot/multiple_pours 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - "[{\"water\":50,\"time\":30},{\"water\":100,\"time\":45},{\"water\":80,\"time\":60}]"
-7
internal/bff/__snapshots__/negative_temperature.snap
··· 1 - --- 2 - title: negative temperature 3 - test_name: TestHasTemp_Snapshot/negative_temperature 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - false
-7
internal/bff/__snapshots__/negative_value.snap
··· 1 - --- 2 - title: negative value 3 - test_name: TestHasValue_Snapshot/negative_value 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - false
-16
internal/bff/__snapshots__/nested_values.snap
··· 1 - --- 2 - title: nested values 3 - test_name: TestDict_Snapshot/nested_values 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - map[string]interface{}{ 8 - "bean": map[string]interface{}{ 9 - "name": "Ethiopian", 10 - "origin": "Yirgacheffe", 11 - }, 12 - "brew": map[string]interface{}{ 13 - "method": "V60", 14 - "temp": 93.0, 15 - }, 16 - }
-342
internal/bff/__snapshots__/new_brew_with_empty_selects.snap
··· 1 - --- 2 - title: new brew with empty selects 3 - test_name: TestBrewForm_NewBrew_Snapshot/new_brew_with_empty_selects 4 - file_name: form_template_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - <script src="/static/js/brew-form.js"></script> 8 - <div class="max-w-2xl mx-auto"> 9 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 border border-brown-300"> 10 - <h2 class="text-3xl font-bold text-brown-900 mb-6"> 11 - New Brew 12 - </h2> 13 - <form 14 - 15 - hx-post="/brews" 16 - 17 - hx-target="body" 18 - class="space-y-6" 19 - x-data="brewForm()" 20 - > 21 - <div> 22 - <label class="block text-sm font-medium text-brown-900 mb-2"> 23 - Coffee Bean 24 - </label> 25 - <div class="flex gap-2"> 26 - <select 27 - name="bean_rkey" 28 - required 29 - class="flex-1 rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 truncate max-w-full bg-white"> 30 - <option value=""> 31 - Select a bean... 32 - </option> 33 - </select> 34 - <button 35 - type="button" 36 - @click="showNewBean = true" 37 - class="bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 38 - + New 39 - </button> 40 - </div> 41 - <div x-show="showNewBean" class="mt-4 p-4 bg-brown-100 rounded border border-brown-300"> 42 - <h4 class="font-medium mb-3 text-gray-800"> 43 - Add New Bean 44 - </h4> 45 - <div class="space-y-3"> 46 - <input type="text" x-model="newBean.name" placeholder="Name (e.g. Morning Blend, House Espresso) *" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 47 - <input type="text" x-model="newBean.origin" placeholder="Origin (e.g. Ethiopia) *" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 48 - <select x-model="newBean.roasterRKey" name="roaster_rkey_modal" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 49 - <option value=""> 50 - Select Roaster (Optional) 51 - </option> 52 - </select> 53 - <select x-model="newBean.roastLevel" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 54 - <option value=""> 55 - Select Roast Level (Optional) 56 - </option> 57 - <option value="Ultra-Light"> 58 - Ultra-Light 59 - </option> 60 - <option value="Light"> 61 - Light 62 - </option> 63 - <option value="Medium-Light"> 64 - Medium-Light 65 - </option> 66 - <option value="Medium"> 67 - Medium 68 - </option> 69 - <option value="Medium-Dark"> 70 - Medium-Dark 71 - </option> 72 - <option value="Dark"> 73 - Dark 74 - </option> 75 - </select> 76 - <input type="text" x-model="newBean.process" placeholder="Process (e.g. Washed, Natural, Honey)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 77 - <input type="text" x-model="newBean.description" placeholder="Description (optional)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 78 - <div class="flex gap-2"> 79 - <button type="button" @click="addBean()" class="bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700"> 80 - Add 81 - </button> 82 - <button type="button" @click="showNewBean = false" class="bg-gray-300 px-4 py-2 rounded hover:bg-gray-400"> 83 - Cancel 84 - </button> 85 - </div> 86 - </div> 87 - </div> 88 - </div> 89 - <div> 90 - <label class="block text-sm font-medium text-brown-900 mb-2"> 91 - Coffee Amount (grams) 92 - </label> 93 - <input 94 - type="number" 95 - name="coffee_amount" 96 - step="0.1" 97 - 98 - placeholder="e.g. 18" 99 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"/> 100 - <p class="text-sm text-brown-700 mt-1"> 101 - Amount of ground coffee used 102 - </p> 103 - </div> 104 - <div> 105 - <label class="block text-sm font-medium text-brown-900 mb-2"> 106 - Grinder 107 - </label> 108 - <div class="flex gap-2"> 109 - <select 110 - name="grinder_rkey" 111 - class="flex-1 rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 truncate max-w-full bg-white"> 112 - <option value=""> 113 - Select a grinder... 114 - </option> 115 - </select> 116 - <button 117 - type="button" 118 - @click="showNewGrinder = true" 119 - class="bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 120 - + New 121 - </button> 122 - </div> 123 - <div x-show="showNewGrinder" class="mt-4 p-4 bg-brown-100 rounded border border-brown-300"> 124 - <h4 class="font-medium mb-3 text-gray-800"> 125 - Add New Grinder 126 - </h4> 127 - <div class="space-y-3"> 128 - <input type="text" x-model="newGrinder.name" placeholder="Name (e.g. Baratza Encore) *" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 129 - <select x-model="newGrinder.grinderType" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 130 - <option value=""> 131 - Grinder Type (Optional) 132 - </option> 133 - <option value="Hand"> 134 - Hand 135 - </option> 136 - <option value="Electric"> 137 - Electric 138 - </option> 139 - <option value="Electric Hand"> 140 - Electric Hand 141 - </option> 142 - </select> 143 - <select x-model="newGrinder.burrType" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 144 - <option value=""> 145 - Burr Type (Optional) 146 - </option> 147 - <option value="Conical"> 148 - Conical 149 - </option> 150 - <option value="Flat"> 151 - Flat 152 - </option> 153 - </select> 154 - <input type="text" x-model="newGrinder.notes" placeholder="Notes (optional)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 155 - <div class="flex gap-2"> 156 - <button type="button" @click="addGrinder()" class="bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700"> 157 - Add 158 - </button> 159 - <button type="button" @click="showNewGrinder = false" class="bg-gray-300 px-4 py-2 rounded hover:bg-gray-400"> 160 - Cancel 161 - </button> 162 - </div> 163 - </div> 164 - </div> 165 - </div> 166 - <div> 167 - <label class="block text-sm font-medium text-brown-900 mb-2"> 168 - Grind Size 169 - </label> 170 - <input 171 - type="text" 172 - name="grind_size" 173 - 174 - placeholder="e.g. 18, Medium, 3.5, Fine" 175 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"/> 176 - <p class="text-sm text-brown-700 mt-1"> 177 - Enter a number (grinder setting) or description (e.g. "Medium", "Fine") 178 - </p> 179 - </div> 180 - <div> 181 - <label class="block text-sm font-medium text-brown-900 mb-2"> 182 - Brew Method 183 - </label> 184 - <div class="flex gap-2"> 185 - <select 186 - name="brewer_rkey" 187 - class="flex-1 rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 truncate max-w-full bg-white"> 188 - <option value=""> 189 - Select brew method... 190 - </option> 191 - </select> 192 - <button 193 - type="button" 194 - @click="showNewBrewer = true" 195 - class="bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 196 - + New 197 - </button> 198 - </div> 199 - <div x-show="showNewBrewer" class="mt-4 p-4 bg-brown-100 rounded border border-brown-300"> 200 - <h4 class="font-medium mb-3 text-gray-800"> 201 - Add New Brewer 202 - </h4> 203 - <div class="space-y-3"> 204 - <input type="text" x-model="newBrewer.name" placeholder="Name (e.g. V60, AeroPress) *" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 205 - <input type="text" x-model="newBrewer.brewer_type" placeholder="Type (e.g. Pour-Over, Immersion)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 206 - <input type="text" x-model="newBrewer.description" placeholder="Description (optional)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 207 - <div class="flex gap-2"> 208 - <button type="button" @click="addBrewer()" class="bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700"> 209 - Add 210 - </button> 211 - <button type="button" @click="showNewBrewer = false" class="bg-gray-300 px-4 py-2 rounded hover:bg-gray-400"> 212 - Cancel 213 - </button> 214 - </div> 215 - </div> 216 - </div> 217 - </div> 218 - <div> 219 - <label class="block text-sm font-medium text-brown-900 mb-2"> 220 - Water Amount (grams) 221 - </label> 222 - <input 223 - type="number" 224 - name="water_amount" 225 - step="1" 226 - 227 - placeholder="e.g. 250" 228 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"/> 229 - <p class="text-sm text-brown-700 mt-1"> 230 - Total water used (or leave empty if using pours below) 231 - </p> 232 - </div> 233 - <div> 234 - <div class="flex items-center justify-between mb-2"> 235 - <label class="block text-sm font-medium text-brown-900"> 236 - Pours (Optional) 237 - </label> 238 - <button 239 - type="button" 240 - @click="addPour()" 241 - class="text-sm bg-brown-300 text-brown-900 px-3 py-1 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 242 - + Add Pour 243 - </button> 244 - </div> 245 - <p class="text-sm text-brown-700 mb-3"> 246 - Track individual pours for bloom and subsequent additions 247 - </p> 248 - <div class="space-y-3"> 249 - <template x-for="(pour, index) in pours" :key="index"> 250 - <div class="flex gap-2 items-center bg-brown-50 p-3 rounded-lg border border-brown-200"> 251 - <div class="flex-1"> 252 - <label class="text-xs text-brown-700 font-medium" x-text="'Pour ' + (index + 1)"></label> 253 - <input 254 - type="number" 255 - :name="'pour_water_' + index" 256 - x-model="pour.water" 257 - placeholder="Water (g)" 258 - class="w-full rounded-md border-brown-300 text-sm py-2 px-3 mt-1 bg-white"/> 259 - </div> 260 - <div class="flex-1"> 261 - <label class="text-xs text-brown-700 font-medium"> 262 - Time (sec) 263 - </label> 264 - <input 265 - type="number" 266 - :name="'pour_time_' + index" 267 - x-model="pour.time" 268 - placeholder="e.g. 45" 269 - class="w-full rounded-md border-brown-300 text-sm py-2 px-3 mt-1 bg-white"/> 270 - </div> 271 - <button 272 - type="button" 273 - @click="removePour(index)" 274 - class="text-brown-700 hover:text-brown-900 mt-5 font-bold" 275 - x-show="pours.length > 0"> 276 - 277 - </button> 278 - </div> 279 - </template> 280 - </div> 281 - </div> 282 - <div> 283 - <label class="block text-sm font-medium text-brown-900 mb-2"> 284 - Temperature 285 - </label> 286 - <input 287 - type="number" 288 - name="temperature" 289 - step="0.1" 290 - 291 - placeholder="e.g. 93.5" 292 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"/> 293 - </div> 294 - <div> 295 - <label class="block text-sm font-medium text-brown-900 mb-2"> 296 - Brew Time (seconds) 297 - </label> 298 - <input 299 - type="number" 300 - name="time_seconds" 301 - 302 - placeholder="e.g. 180" 303 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"/> 304 - </div> 305 - <div> 306 - <label class="block text-sm font-medium text-brown-900 mb-2"> 307 - Tasting Notes 308 - </label> 309 - <textarea 310 - name="tasting_notes" 311 - rows="4" 312 - placeholder="Describe the flavors, aroma, and your thoughts..." 313 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"></textarea> 314 - </div> 315 - <div> 316 - <label class="block text-sm font-medium text-brown-900 mb-2"> 317 - Rating 318 - </label> 319 - <input 320 - type="range" 321 - name="rating" 322 - min="1" 323 - max="10" 324 - value="5" 325 - x-model="rating" 326 - x-init="rating = $el.value" 327 - class="w-full accent-brown-700"/> 328 - <div class="text-center text-2xl font-bold text-brown-800"> 329 - <span x-text="rating"></span> 330 - /10 331 - </div> 332 - </div> 333 - <div> 334 - <button 335 - type="submit" 336 - class="w-full bg-gradient-to-r from-brown-700 to-brown-800 text-white py-3 px-6 rounded-xl hover:from-brown-800 hover:to-brown-900 transition-all font-semibold text-lg shadow-lg hover:shadow-xl"> 337 - Save Brew 338 - </button> 339 - </div> 340 - </form> 341 - </div> 342 - </div>
-342
internal/bff/__snapshots__/new_brew_with_nil_collections.snap
··· 1 - --- 2 - title: new brew with nil collections 3 - test_name: TestBrewForm_NewBrew_Snapshot/new_brew_with_nil_collections 4 - file_name: form_template_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - <script src="/static/js/brew-form.js"></script> 8 - <div class="max-w-2xl mx-auto"> 9 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 border border-brown-300"> 10 - <h2 class="text-3xl font-bold text-brown-900 mb-6"> 11 - New Brew 12 - </h2> 13 - <form 14 - 15 - hx-post="/brews" 16 - 17 - hx-target="body" 18 - class="space-y-6" 19 - x-data="brewForm()" 20 - > 21 - <div> 22 - <label class="block text-sm font-medium text-brown-900 mb-2"> 23 - Coffee Bean 24 - </label> 25 - <div class="flex gap-2"> 26 - <select 27 - name="bean_rkey" 28 - required 29 - class="flex-1 rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 truncate max-w-full bg-white"> 30 - <option value=""> 31 - Select a bean... 32 - </option> 33 - </select> 34 - <button 35 - type="button" 36 - @click="showNewBean = true" 37 - class="bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 38 - + New 39 - </button> 40 - </div> 41 - <div x-show="showNewBean" class="mt-4 p-4 bg-brown-100 rounded border border-brown-300"> 42 - <h4 class="font-medium mb-3 text-gray-800"> 43 - Add New Bean 44 - </h4> 45 - <div class="space-y-3"> 46 - <input type="text" x-model="newBean.name" placeholder="Name (e.g. Morning Blend, House Espresso) *" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 47 - <input type="text" x-model="newBean.origin" placeholder="Origin (e.g. Ethiopia) *" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 48 - <select x-model="newBean.roasterRKey" name="roaster_rkey_modal" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 49 - <option value=""> 50 - Select Roaster (Optional) 51 - </option> 52 - </select> 53 - <select x-model="newBean.roastLevel" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 54 - <option value=""> 55 - Select Roast Level (Optional) 56 - </option> 57 - <option value="Ultra-Light"> 58 - Ultra-Light 59 - </option> 60 - <option value="Light"> 61 - Light 62 - </option> 63 - <option value="Medium-Light"> 64 - Medium-Light 65 - </option> 66 - <option value="Medium"> 67 - Medium 68 - </option> 69 - <option value="Medium-Dark"> 70 - Medium-Dark 71 - </option> 72 - <option value="Dark"> 73 - Dark 74 - </option> 75 - </select> 76 - <input type="text" x-model="newBean.process" placeholder="Process (e.g. Washed, Natural, Honey)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 77 - <input type="text" x-model="newBean.description" placeholder="Description (optional)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 78 - <div class="flex gap-2"> 79 - <button type="button" @click="addBean()" class="bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700"> 80 - Add 81 - </button> 82 - <button type="button" @click="showNewBean = false" class="bg-gray-300 px-4 py-2 rounded hover:bg-gray-400"> 83 - Cancel 84 - </button> 85 - </div> 86 - </div> 87 - </div> 88 - </div> 89 - <div> 90 - <label class="block text-sm font-medium text-brown-900 mb-2"> 91 - Coffee Amount (grams) 92 - </label> 93 - <input 94 - type="number" 95 - name="coffee_amount" 96 - step="0.1" 97 - 98 - placeholder="e.g. 18" 99 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"/> 100 - <p class="text-sm text-brown-700 mt-1"> 101 - Amount of ground coffee used 102 - </p> 103 - </div> 104 - <div> 105 - <label class="block text-sm font-medium text-brown-900 mb-2"> 106 - Grinder 107 - </label> 108 - <div class="flex gap-2"> 109 - <select 110 - name="grinder_rkey" 111 - class="flex-1 rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 truncate max-w-full bg-white"> 112 - <option value=""> 113 - Select a grinder... 114 - </option> 115 - </select> 116 - <button 117 - type="button" 118 - @click="showNewGrinder = true" 119 - class="bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 120 - + New 121 - </button> 122 - </div> 123 - <div x-show="showNewGrinder" class="mt-4 p-4 bg-brown-100 rounded border border-brown-300"> 124 - <h4 class="font-medium mb-3 text-gray-800"> 125 - Add New Grinder 126 - </h4> 127 - <div class="space-y-3"> 128 - <input type="text" x-model="newGrinder.name" placeholder="Name (e.g. Baratza Encore) *" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 129 - <select x-model="newGrinder.grinderType" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 130 - <option value=""> 131 - Grinder Type (Optional) 132 - </option> 133 - <option value="Hand"> 134 - Hand 135 - </option> 136 - <option value="Electric"> 137 - Electric 138 - </option> 139 - <option value="Electric Hand"> 140 - Electric Hand 141 - </option> 142 - </select> 143 - <select x-model="newGrinder.burrType" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 144 - <option value=""> 145 - Burr Type (Optional) 146 - </option> 147 - <option value="Conical"> 148 - Conical 149 - </option> 150 - <option value="Flat"> 151 - Flat 152 - </option> 153 - </select> 154 - <input type="text" x-model="newGrinder.notes" placeholder="Notes (optional)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 155 - <div class="flex gap-2"> 156 - <button type="button" @click="addGrinder()" class="bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700"> 157 - Add 158 - </button> 159 - <button type="button" @click="showNewGrinder = false" class="bg-gray-300 px-4 py-2 rounded hover:bg-gray-400"> 160 - Cancel 161 - </button> 162 - </div> 163 - </div> 164 - </div> 165 - </div> 166 - <div> 167 - <label class="block text-sm font-medium text-brown-900 mb-2"> 168 - Grind Size 169 - </label> 170 - <input 171 - type="text" 172 - name="grind_size" 173 - 174 - placeholder="e.g. 18, Medium, 3.5, Fine" 175 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"/> 176 - <p class="text-sm text-brown-700 mt-1"> 177 - Enter a number (grinder setting) or description (e.g. "Medium", "Fine") 178 - </p> 179 - </div> 180 - <div> 181 - <label class="block text-sm font-medium text-brown-900 mb-2"> 182 - Brew Method 183 - </label> 184 - <div class="flex gap-2"> 185 - <select 186 - name="brewer_rkey" 187 - class="flex-1 rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 truncate max-w-full bg-white"> 188 - <option value=""> 189 - Select brew method... 190 - </option> 191 - </select> 192 - <button 193 - type="button" 194 - @click="showNewBrewer = true" 195 - class="bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 196 - + New 197 - </button> 198 - </div> 199 - <div x-show="showNewBrewer" class="mt-4 p-4 bg-brown-100 rounded border border-brown-300"> 200 - <h4 class="font-medium mb-3 text-gray-800"> 201 - Add New Brewer 202 - </h4> 203 - <div class="space-y-3"> 204 - <input type="text" x-model="newBrewer.name" placeholder="Name (e.g. V60, AeroPress) *" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 205 - <input type="text" x-model="newBrewer.brewer_type" placeholder="Type (e.g. Pour-Over, Immersion)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 206 - <input type="text" x-model="newBrewer.description" placeholder="Description (optional)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 207 - <div class="flex gap-2"> 208 - <button type="button" @click="addBrewer()" class="bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700"> 209 - Add 210 - </button> 211 - <button type="button" @click="showNewBrewer = false" class="bg-gray-300 px-4 py-2 rounded hover:bg-gray-400"> 212 - Cancel 213 - </button> 214 - </div> 215 - </div> 216 - </div> 217 - </div> 218 - <div> 219 - <label class="block text-sm font-medium text-brown-900 mb-2"> 220 - Water Amount (grams) 221 - </label> 222 - <input 223 - type="number" 224 - name="water_amount" 225 - step="1" 226 - 227 - placeholder="e.g. 250" 228 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"/> 229 - <p class="text-sm text-brown-700 mt-1"> 230 - Total water used (or leave empty if using pours below) 231 - </p> 232 - </div> 233 - <div> 234 - <div class="flex items-center justify-between mb-2"> 235 - <label class="block text-sm font-medium text-brown-900"> 236 - Pours (Optional) 237 - </label> 238 - <button 239 - type="button" 240 - @click="addPour()" 241 - class="text-sm bg-brown-300 text-brown-900 px-3 py-1 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 242 - + Add Pour 243 - </button> 244 - </div> 245 - <p class="text-sm text-brown-700 mb-3"> 246 - Track individual pours for bloom and subsequent additions 247 - </p> 248 - <div class="space-y-3"> 249 - <template x-for="(pour, index) in pours" :key="index"> 250 - <div class="flex gap-2 items-center bg-brown-50 p-3 rounded-lg border border-brown-200"> 251 - <div class="flex-1"> 252 - <label class="text-xs text-brown-700 font-medium" x-text="'Pour ' + (index + 1)"></label> 253 - <input 254 - type="number" 255 - :name="'pour_water_' + index" 256 - x-model="pour.water" 257 - placeholder="Water (g)" 258 - class="w-full rounded-md border-brown-300 text-sm py-2 px-3 mt-1 bg-white"/> 259 - </div> 260 - <div class="flex-1"> 261 - <label class="text-xs text-brown-700 font-medium"> 262 - Time (sec) 263 - </label> 264 - <input 265 - type="number" 266 - :name="'pour_time_' + index" 267 - x-model="pour.time" 268 - placeholder="e.g. 45" 269 - class="w-full rounded-md border-brown-300 text-sm py-2 px-3 mt-1 bg-white"/> 270 - </div> 271 - <button 272 - type="button" 273 - @click="removePour(index)" 274 - class="text-brown-700 hover:text-brown-900 mt-5 font-bold" 275 - x-show="pours.length > 0"> 276 - 277 - </button> 278 - </div> 279 - </template> 280 - </div> 281 - </div> 282 - <div> 283 - <label class="block text-sm font-medium text-brown-900 mb-2"> 284 - Temperature 285 - </label> 286 - <input 287 - type="number" 288 - name="temperature" 289 - step="0.1" 290 - 291 - placeholder="e.g. 93.5" 292 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"/> 293 - </div> 294 - <div> 295 - <label class="block text-sm font-medium text-brown-900 mb-2"> 296 - Brew Time (seconds) 297 - </label> 298 - <input 299 - type="number" 300 - name="time_seconds" 301 - 302 - placeholder="e.g. 180" 303 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"/> 304 - </div> 305 - <div> 306 - <label class="block text-sm font-medium text-brown-900 mb-2"> 307 - Tasting Notes 308 - </label> 309 - <textarea 310 - name="tasting_notes" 311 - rows="4" 312 - placeholder="Describe the flavors, aroma, and your thoughts..." 313 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"></textarea> 314 - </div> 315 - <div> 316 - <label class="block text-sm font-medium text-brown-900 mb-2"> 317 - Rating 318 - </label> 319 - <input 320 - type="range" 321 - name="rating" 322 - min="1" 323 - max="10" 324 - value="5" 325 - x-model="rating" 326 - x-init="rating = $el.value" 327 - class="w-full accent-brown-700"/> 328 - <div class="text-center text-2xl font-bold text-brown-800"> 329 - <span x-text="rating"></span> 330 - /10 331 - </div> 332 - </div> 333 - <div> 334 - <button 335 - type="submit" 336 - class="w-full bg-gradient-to-r from-brown-700 to-brown-800 text-white py-3 px-6 rounded-xl hover:from-brown-800 hover:to-brown-900 transition-all font-semibold text-lg shadow-lg hover:shadow-xl"> 337 - Save Brew 338 - </button> 339 - </div> 340 - </form> 341 - </div> 342 - </div>
-384
internal/bff/__snapshots__/new_brew_with_populated_selects.snap
··· 1 - --- 2 - title: new brew with populated selects 3 - test_name: TestBrewForm_NewBrew_Snapshot/new_brew_with_populated_selects 4 - file_name: form_template_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - <script src="/static/js/brew-form.js"></script> 8 - <div class="max-w-2xl mx-auto"> 9 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 border border-brown-300"> 10 - <h2 class="text-3xl font-bold text-brown-900 mb-6"> 11 - New Brew 12 - </h2> 13 - <form 14 - 15 - hx-post="/brews" 16 - 17 - hx-target="body" 18 - class="space-y-6" 19 - x-data="brewForm()" 20 - > 21 - <div> 22 - <label class="block text-sm font-medium text-brown-900 mb-2"> 23 - Coffee Bean 24 - </label> 25 - <div class="flex gap-2"> 26 - <select 27 - name="bean_rkey" 28 - required 29 - class="flex-1 rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 truncate max-w-full bg-white"> 30 - <option value=""> 31 - Select a bean... 32 - </option> 33 - <option 34 - value="bean1" 35 - 36 - class="truncate"> 37 - Ethiopian Yirgacheffe (Ethiopia - Light) 38 - </option> 39 - <option 40 - value="bean2" 41 - 42 - class="truncate"> 43 - Colombia - Medium 44 - </option> 45 - </select> 46 - <button 47 - type="button" 48 - @click="showNewBean = true" 49 - class="bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 50 - + New 51 - </button> 52 - </div> 53 - <div x-show="showNewBean" class="mt-4 p-4 bg-brown-100 rounded border border-brown-300"> 54 - <h4 class="font-medium mb-3 text-gray-800"> 55 - Add New Bean 56 - </h4> 57 - <div class="space-y-3"> 58 - <input type="text" x-model="newBean.name" placeholder="Name (e.g. Morning Blend, House Espresso) *" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 59 - <input type="text" x-model="newBean.origin" placeholder="Origin (e.g. Ethiopia) *" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 60 - <select x-model="newBean.roasterRKey" name="roaster_rkey_modal" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 61 - <option value=""> 62 - Select Roaster (Optional) 63 - </option> 64 - <option value="roaster1"> 65 - Blue Bottle 66 - </option> 67 - <option value="roaster2"> 68 - Counter Culture 69 - </option> 70 - </select> 71 - <select x-model="newBean.roastLevel" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 72 - <option value=""> 73 - Select Roast Level (Optional) 74 - </option> 75 - <option value="Ultra-Light"> 76 - Ultra-Light 77 - </option> 78 - <option value="Light"> 79 - Light 80 - </option> 81 - <option value="Medium-Light"> 82 - Medium-Light 83 - </option> 84 - <option value="Medium"> 85 - Medium 86 - </option> 87 - <option value="Medium-Dark"> 88 - Medium-Dark 89 - </option> 90 - <option value="Dark"> 91 - Dark 92 - </option> 93 - </select> 94 - <input type="text" x-model="newBean.process" placeholder="Process (e.g. Washed, Natural, Honey)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 95 - <input type="text" x-model="newBean.description" placeholder="Description (optional)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 96 - <div class="flex gap-2"> 97 - <button type="button" @click="addBean()" class="bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700"> 98 - Add 99 - </button> 100 - <button type="button" @click="showNewBean = false" class="bg-gray-300 px-4 py-2 rounded hover:bg-gray-400"> 101 - Cancel 102 - </button> 103 - </div> 104 - </div> 105 - </div> 106 - </div> 107 - <div> 108 - <label class="block text-sm font-medium text-brown-900 mb-2"> 109 - Coffee Amount (grams) 110 - </label> 111 - <input 112 - type="number" 113 - name="coffee_amount" 114 - step="0.1" 115 - 116 - placeholder="e.g. 18" 117 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"/> 118 - <p class="text-sm text-brown-700 mt-1"> 119 - Amount of ground coffee used 120 - </p> 121 - </div> 122 - <div> 123 - <label class="block text-sm font-medium text-brown-900 mb-2"> 124 - Grinder 125 - </label> 126 - <div class="flex gap-2"> 127 - <select 128 - name="grinder_rkey" 129 - class="flex-1 rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 truncate max-w-full bg-white"> 130 - <option value=""> 131 - Select a grinder... 132 - </option> 133 - <option 134 - value="grinder1" 135 - 136 - class="truncate"> 137 - Baratza Encore 138 - </option> 139 - <option 140 - value="grinder2" 141 - 142 - class="truncate"> 143 - Comandante C40 144 - </option> 145 - </select> 146 - <button 147 - type="button" 148 - @click="showNewGrinder = true" 149 - class="bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 150 - + New 151 - </button> 152 - </div> 153 - <div x-show="showNewGrinder" class="mt-4 p-4 bg-brown-100 rounded border border-brown-300"> 154 - <h4 class="font-medium mb-3 text-gray-800"> 155 - Add New Grinder 156 - </h4> 157 - <div class="space-y-3"> 158 - <input type="text" x-model="newGrinder.name" placeholder="Name (e.g. Baratza Encore) *" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 159 - <select x-model="newGrinder.grinderType" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 160 - <option value=""> 161 - Grinder Type (Optional) 162 - </option> 163 - <option value="Hand"> 164 - Hand 165 - </option> 166 - <option value="Electric"> 167 - Electric 168 - </option> 169 - <option value="Electric Hand"> 170 - Electric Hand 171 - </option> 172 - </select> 173 - <select x-model="newGrinder.burrType" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 174 - <option value=""> 175 - Burr Type (Optional) 176 - </option> 177 - <option value="Conical"> 178 - Conical 179 - </option> 180 - <option value="Flat"> 181 - Flat 182 - </option> 183 - </select> 184 - <input type="text" x-model="newGrinder.notes" placeholder="Notes (optional)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 185 - <div class="flex gap-2"> 186 - <button type="button" @click="addGrinder()" class="bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700"> 187 - Add 188 - </button> 189 - <button type="button" @click="showNewGrinder = false" class="bg-gray-300 px-4 py-2 rounded hover:bg-gray-400"> 190 - Cancel 191 - </button> 192 - </div> 193 - </div> 194 - </div> 195 - </div> 196 - <div> 197 - <label class="block text-sm font-medium text-brown-900 mb-2"> 198 - Grind Size 199 - </label> 200 - <input 201 - type="text" 202 - name="grind_size" 203 - 204 - placeholder="e.g. 18, Medium, 3.5, Fine" 205 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"/> 206 - <p class="text-sm text-brown-700 mt-1"> 207 - Enter a number (grinder setting) or description (e.g. "Medium", "Fine") 208 - </p> 209 - </div> 210 - <div> 211 - <label class="block text-sm font-medium text-brown-900 mb-2"> 212 - Brew Method 213 - </label> 214 - <div class="flex gap-2"> 215 - <select 216 - name="brewer_rkey" 217 - class="flex-1 rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 truncate max-w-full bg-white"> 218 - <option value=""> 219 - Select brew method... 220 - </option> 221 - <option 222 - value="brewer1" 223 - 224 - class="truncate"> 225 - Hario V60 226 - </option> 227 - <option 228 - value="brewer2" 229 - 230 - class="truncate"> 231 - AeroPress 232 - </option> 233 - </select> 234 - <button 235 - type="button" 236 - @click="showNewBrewer = true" 237 - class="bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 238 - + New 239 - </button> 240 - </div> 241 - <div x-show="showNewBrewer" class="mt-4 p-4 bg-brown-100 rounded border border-brown-300"> 242 - <h4 class="font-medium mb-3 text-gray-800"> 243 - Add New Brewer 244 - </h4> 245 - <div class="space-y-3"> 246 - <input type="text" x-model="newBrewer.name" placeholder="Name (e.g. V60, AeroPress) *" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 247 - <input type="text" x-model="newBrewer.brewer_type" placeholder="Type (e.g. Pour-Over, Immersion)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 248 - <input type="text" x-model="newBrewer.description" placeholder="Description (optional)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 249 - <div class="flex gap-2"> 250 - <button type="button" @click="addBrewer()" class="bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700"> 251 - Add 252 - </button> 253 - <button type="button" @click="showNewBrewer = false" class="bg-gray-300 px-4 py-2 rounded hover:bg-gray-400"> 254 - Cancel 255 - </button> 256 - </div> 257 - </div> 258 - </div> 259 - </div> 260 - <div> 261 - <label class="block text-sm font-medium text-brown-900 mb-2"> 262 - Water Amount (grams) 263 - </label> 264 - <input 265 - type="number" 266 - name="water_amount" 267 - step="1" 268 - 269 - placeholder="e.g. 250" 270 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"/> 271 - <p class="text-sm text-brown-700 mt-1"> 272 - Total water used (or leave empty if using pours below) 273 - </p> 274 - </div> 275 - <div> 276 - <div class="flex items-center justify-between mb-2"> 277 - <label class="block text-sm font-medium text-brown-900"> 278 - Pours (Optional) 279 - </label> 280 - <button 281 - type="button" 282 - @click="addPour()" 283 - class="text-sm bg-brown-300 text-brown-900 px-3 py-1 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 284 - + Add Pour 285 - </button> 286 - </div> 287 - <p class="text-sm text-brown-700 mb-3"> 288 - Track individual pours for bloom and subsequent additions 289 - </p> 290 - <div class="space-y-3"> 291 - <template x-for="(pour, index) in pours" :key="index"> 292 - <div class="flex gap-2 items-center bg-brown-50 p-3 rounded-lg border border-brown-200"> 293 - <div class="flex-1"> 294 - <label class="text-xs text-brown-700 font-medium" x-text="'Pour ' + (index + 1)"></label> 295 - <input 296 - type="number" 297 - :name="'pour_water_' + index" 298 - x-model="pour.water" 299 - placeholder="Water (g)" 300 - class="w-full rounded-md border-brown-300 text-sm py-2 px-3 mt-1 bg-white"/> 301 - </div> 302 - <div class="flex-1"> 303 - <label class="text-xs text-brown-700 font-medium"> 304 - Time (sec) 305 - </label> 306 - <input 307 - type="number" 308 - :name="'pour_time_' + index" 309 - x-model="pour.time" 310 - placeholder="e.g. 45" 311 - class="w-full rounded-md border-brown-300 text-sm py-2 px-3 mt-1 bg-white"/> 312 - </div> 313 - <button 314 - type="button" 315 - @click="removePour(index)" 316 - class="text-brown-700 hover:text-brown-900 mt-5 font-bold" 317 - x-show="pours.length > 0"> 318 - 319 - </button> 320 - </div> 321 - </template> 322 - </div> 323 - </div> 324 - <div> 325 - <label class="block text-sm font-medium text-brown-900 mb-2"> 326 - Temperature 327 - </label> 328 - <input 329 - type="number" 330 - name="temperature" 331 - step="0.1" 332 - 333 - placeholder="e.g. 93.5" 334 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"/> 335 - </div> 336 - <div> 337 - <label class="block text-sm font-medium text-brown-900 mb-2"> 338 - Brew Time (seconds) 339 - </label> 340 - <input 341 - type="number" 342 - name="time_seconds" 343 - 344 - placeholder="e.g. 180" 345 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"/> 346 - </div> 347 - <div> 348 - <label class="block text-sm font-medium text-brown-900 mb-2"> 349 - Tasting Notes 350 - </label> 351 - <textarea 352 - name="tasting_notes" 353 - rows="4" 354 - placeholder="Describe the flavors, aroma, and your thoughts..." 355 - class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"></textarea> 356 - </div> 357 - <div> 358 - <label class="block text-sm font-medium text-brown-900 mb-2"> 359 - Rating 360 - </label> 361 - <input 362 - type="range" 363 - name="rating" 364 - min="1" 365 - max="10" 366 - value="5" 367 - x-model="rating" 368 - x-init="rating = $el.value" 369 - class="w-full accent-brown-700"/> 370 - <div class="text-center text-2xl font-bold text-brown-800"> 371 - <span x-text="rating"></span> 372 - /10 373 - </div> 374 - </div> 375 - <div> 376 - <button 377 - type="submit" 378 - class="w-full bg-gradient-to-r from-brown-700 to-brown-800 text-white py-3 px-6 rounded-xl hover:from-brown-800 hover:to-brown-900 transition-all font-semibold text-lg shadow-lg hover:shadow-xl"> 379 - Save Brew 380 - </button> 381 - </div> 382 - </form> 383 - </div> 384 - </div>
-16
internal/bff/__snapshots__/nil_feed.snap
··· 1 - --- 2 - title: nil feed 3 - test_name: TestFeedTemplate_EmptyFeed_Snapshot/nil_feed 4 - file_name: feed_template_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - <div class="space-y-4"> 8 - <div class="bg-brown-100 rounded-lg p-6 text-center text-brown-700 border border-brown-200"> 9 - <p class="mb-2 font-medium"> 10 - No activity in the feed yet. 11 - </p> 12 - <p class="text-sm"> 13 - Be the first to add something! 14 - </p> 15 - </div> 16 - </div>
-7
internal/bff/__snapshots__/nil_int_pointer.snap
··· 1 - --- 2 - title: nil int pointer 3 - test_name: TestPtrValue_Snapshot/nil_int_pointer 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - 0
-7
internal/bff/__snapshots__/nil_pointer.snap
··· 1 - --- 2 - title: nil pointer 3 - test_name: TestPtrEquals_Snapshot/nil_pointer 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - false
-7
internal/bff/__snapshots__/nil_pours.snap
··· 1 - --- 2 - title: nil pours 3 - test_name: TestPoursToJSON_Snapshot/nil_pours 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - "[]"
-7
internal/bff/__snapshots__/nil_string_pointer.snap
··· 1 - --- 2 - title: nil string pointer 3 - test_name: TestPtrValue_Snapshot/nil_string_pointer 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - ""
-7
internal/bff/__snapshots__/non-string_key.snap
··· 1 - --- 2 - title: non-string key 3 - test_name: TestDict_ErrorCases_Snapshot/non-string_key 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - "dict keys must be strings"
-7
internal/bff/__snapshots__/none_used.snap
··· 1 - --- 2 - title: none used 3 - test_name: TestIterateRemaining_Snapshot/none_used 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - []int(nil)
-7
internal/bff/__snapshots__/odd_number_of_arguments.snap
··· 1 - --- 2 - title: odd number of arguments 3 - test_name: TestDict_ErrorCases_Snapshot/odd_number_of_arguments 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - "dict requires an even number of arguments"
-7
internal/bff/__snapshots__/pointer_to_empty_vs_empty.snap
··· 1 - --- 2 - title: pointer to empty vs empty 3 - test_name: TestPtrEquals_Snapshot/pointer_to_empty_vs_empty 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - true
-7
internal/bff/__snapshots__/positive_temperature.snap
··· 1 - --- 2 - title: positive temperature 3 - test_name: TestHasTemp_Snapshot/positive_temperature 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - true
-7
internal/bff/__snapshots__/positive_value.snap
··· 1 - --- 2 - title: positive value 3 - test_name: TestHasValue_Snapshot/positive_value 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - true
-68
internal/bff/__snapshots__/profile_roaster_with_invalid_url_protocol.snap
··· 1 - --- 2 - title: profile roaster with invalid URL protocol 3 - test_name: TestProfileContent_URLSecurity_Snapshot/profile_roaster_with_invalid_URL_protocol 4 - file_name: profile_template_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - <div id="profile-stats-data" 8 - data-brews="0" 9 - data-beans="0" 10 - data-roasters="1" 11 - data-grinders="0" 12 - data-brewers="0" 13 - style="display: none;"></div> 14 - <div x-show="activeTab === 'brews'"> 15 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center border border-brown-300"> 16 - <p class="text-brown-800 text-lg font-medium"> 17 - No brews yet. 18 - </p> 19 - </div> 20 - </div> 21 - <div x-show="activeTab === 'beans'" x-cloak class="space-y-6"> 22 - <div> 23 - <h3 class="text-lg font-semibold text-brown-900 mb-3"> 24 - 🏭 Favorite Roasters 25 - </h3> 26 - <div class="overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300"> 27 - <table class="min-w-full divide-y divide-brown-300"> 28 - <thead class="bg-brown-200/80"> 29 - <tr> 30 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider"> 31 - Name 32 - </th> 33 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider"> 34 - 📍 Location 35 - </th> 36 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider"> 37 - 🌐 Website 38 - </th> 39 - </tr> 40 - </thead> 41 - <tbody class="bg-brown-50/60 divide-y divide-brown-200"> 42 - <tr class="hover:bg-brown-100/60 transition-colors"> 43 - <td class="px-6 py-4 text-sm font-bold text-brown-900"> 44 - FTP Roaster 45 - </td> 46 - <td class="px-6 py-4 text-sm text-brown-900"> 47 - <span class="text-brown-400"> 48 - - 49 - </span> 50 - </td> 51 - <td class="px-6 py-4 text-sm text-brown-900"> 52 - <span class="text-brown-400"> 53 - - 54 - </span> 55 - </td> 56 - </tr> 57 - </tbody> 58 - </table> 59 - </div> 60 - </div> 61 - </div> 62 - <div x-show="activeTab === 'gear'" x-cloak class="space-y-6"> 63 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center text-brown-800 border border-brown-300"> 64 - <p class="font-medium"> 65 - No gear added yet. 66 - </p> 67 - </div> 68 - </div>
-66
internal/bff/__snapshots__/profile_roaster_with_unsafe_website_url.snap
··· 1 - --- 2 - title: profile roaster with unsafe website URL 3 - test_name: TestProfileContent_URLSecurity_Snapshot/profile_roaster_with_unsafe_website_URL 4 - file_name: profile_template_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - <div id="profile-stats-data" 8 - data-brews="0" 9 - data-beans="0" 10 - data-roasters="1" 11 - data-grinders="0" 12 - data-brewers="0" 13 - style="display: none;"></div> 14 - <div x-show="activeTab === 'brews'"> 15 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center border border-brown-300"> 16 - <p class="text-brown-800 text-lg font-medium"> 17 - No brews yet. 18 - </p> 19 - </div> 20 - </div> 21 - <div x-show="activeTab === 'beans'" x-cloak class="space-y-6"> 22 - <div> 23 - <h3 class="text-lg font-semibold text-brown-900 mb-3"> 24 - 🏭 Favorite Roasters 25 - </h3> 26 - <div class="overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300"> 27 - <table class="min-w-full divide-y divide-brown-300"> 28 - <thead class="bg-brown-200/80"> 29 - <tr> 30 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider"> 31 - Name 32 - </th> 33 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider"> 34 - 📍 Location 35 - </th> 36 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider"> 37 - 🌐 Website 38 - </th> 39 - </tr> 40 - </thead> 41 - <tbody class="bg-brown-50/60 divide-y divide-brown-200"> 42 - <tr class="hover:bg-brown-100/60 transition-colors"> 43 - <td class="px-6 py-4 text-sm font-bold text-brown-900"> 44 - Sketchy Roaster 45 - </td> 46 - <td class="px-6 py-4 text-sm text-brown-900"> 47 - Unknown 48 - </td> 49 - <td class="px-6 py-4 text-sm text-brown-900"> 50 - <span class="text-brown-400"> 51 - - 52 - </span> 53 - </td> 54 - </tr> 55 - </tbody> 56 - </table> 57 - </div> 58 - </div> 59 - </div> 60 - <div x-show="activeTab === 'gear'" x-cloak class="space-y-6"> 61 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center text-brown-800 border border-brown-300"> 62 - <p class="font-medium"> 63 - No gear added yet. 64 - </p> 65 - </div> 66 - </div>
-34
internal/bff/__snapshots__/profile_with_empty_beans.snap
··· 1 - --- 2 - title: profile with empty beans 3 - test_name: TestProfileContent_BeansTab_Snapshot/profile_with_empty_beans 4 - file_name: profile_template_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - <div id="profile-stats-data" 8 - data-brews="0" 9 - data-beans="0" 10 - data-roasters="0" 11 - data-grinders="0" 12 - data-brewers="0" 13 - style="display: none;"></div> 14 - <div x-show="activeTab === 'brews'"> 15 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center border border-brown-300"> 16 - <p class="text-brown-800 text-lg font-medium"> 17 - No brews yet. 18 - </p> 19 - </div> 20 - </div> 21 - <div x-show="activeTab === 'beans'" x-cloak class="space-y-6"> 22 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center text-brown-800 border border-brown-300"> 23 - <p class="font-medium"> 24 - No beans or roasters yet. 25 - </p> 26 - </div> 27 - </div> 28 - <div x-show="activeTab === 'gear'" x-cloak class="space-y-6"> 29 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center text-brown-800 border border-brown-300"> 30 - <p class="font-medium"> 31 - No gear added yet. 32 - </p> 33 - </div> 34 - </div>
-187
internal/bff/__snapshots__/profile_with_gear_collection.snap
··· 1 - --- 2 - title: profile with gear collection 3 - test_name: TestProfileContent_GearTabs_Snapshot 4 - file_name: profile_template_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - <div id="profile-stats-data" 8 - data-brews="0" 9 - data-beans="0" 10 - data-roasters="1" 11 - data-grinders="2" 12 - data-brewers="1" 13 - style="display: none;"></div> 14 - <div x-show="activeTab === 'brews'"> 15 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center border border-brown-300"> 16 - <p class="text-brown-800 text-lg mb-4 font-medium"> 17 - No brews yet! Start tracking your coffee journey. 18 - </p> 19 - <a href="/brews/new" 20 - class="inline-block bg-gradient-to-r from-brown-700 to-brown-800 text-white py-3 px-6 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all shadow-lg hover:shadow-xl font-medium"> 21 - Add Your First Brew 22 - </a> 23 - </div> 24 - </div> 25 - <div x-show="activeTab === 'beans'" x-cloak class="space-y-6"> 26 - <div> 27 - <h3 class="text-lg font-semibold text-brown-900 mb-3"> 28 - 🏭 Favorite Roasters 29 - </h3> 30 - <div class="overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300"> 31 - <table class="min-w-full divide-y divide-brown-300"> 32 - <thead class="bg-brown-200/80"> 33 - <tr> 34 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider"> 35 - Name 36 - </th> 37 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider"> 38 - 📍 Location 39 - </th> 40 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider"> 41 - 🌐 Website 42 - </th> 43 - </tr> 44 - </thead> 45 - <tbody class="bg-brown-50/60 divide-y divide-brown-200"> 46 - <tr class="hover:bg-brown-100/60 transition-colors"> 47 - <td class="px-6 py-4 text-sm font-bold text-brown-900"> 48 - Heart Coffee 49 - </td> 50 - <td class="px-6 py-4 text-sm text-brown-900"> 51 - Portland, OR 52 - </td> 53 - <td class="px-6 py-4 text-sm text-brown-900"> 54 - <a href="https://heartroasters.com" target="_blank" rel="noopener noreferrer" class="text-brown-700 hover:underline font-medium"> 55 - Visit Site 56 - </a> 57 - </td> 58 - </tr> 59 - </tbody> 60 - </table> 61 - </div> 62 - <div class="mt-3 text-center"> 63 - <button @click="editRoaster('', '', '', '')" class="inline-flex items-center gap-2 bg-brown-600 text-white px-4 py-2 rounded-lg hover:bg-brown-700 transition-all shadow-md hover:shadow-lg text-sm font-medium"> 64 - <span> 65 - + 66 - </span> 67 - <span> 68 - Add New Roaster 69 - </span> 70 - </button> 71 - </div> 72 - </div> 73 - </div> 74 - <div x-show="activeTab === 'gear'" x-cloak class="space-y-6"> 75 - <div> 76 - <h3 class="text-lg font-semibold text-brown-900 mb-3"> 77 - ⚙️ Grinders 78 - </h3> 79 - <div class="overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300"> 80 - <table class="min-w-full divide-y divide-brown-300"> 81 - <thead class="bg-brown-200/80"> 82 - <tr> 83 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider"> 84 - Name 85 - </th> 86 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider"> 87 - 🔧 Type 88 - </th> 89 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider"> 90 - 💎 Burrs 91 - </th> 92 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider"> 93 - 📝 Notes 94 - </th> 95 - </tr> 96 - </thead> 97 - <tbody class="bg-brown-50/60 divide-y divide-brown-200"> 98 - <tr class="hover:bg-brown-100/60 transition-colors"> 99 - <td class="px-6 py-4 text-sm font-bold text-brown-900"> 100 - Comandante C40 101 - </td> 102 - <td class="px-6 py-4 text-sm text-brown-900"> 103 - Hand 104 - </td> 105 - <td class="px-6 py-4 text-sm text-brown-900"> 106 - Conical 107 - </td> 108 - <td class="px-6 py-4 text-sm text-brown-700 italic max-w-xs"> 109 - Perfect for pour over 110 - </td> 111 - </tr> 112 - <tr class="hover:bg-brown-100/60 transition-colors"> 113 - <td class="px-6 py-4 text-sm font-bold text-brown-900"> 114 - Niche Zero 115 - </td> 116 - <td class="px-6 py-4 text-sm text-brown-900"> 117 - Electric 118 - </td> 119 - <td class="px-6 py-4 text-sm text-brown-900"> 120 - Conical 121 - </td> 122 - <td class="px-6 py-4 text-sm text-brown-700 italic max-w-xs"> 123 - <span class="text-brown-400 not-italic"> 124 - - 125 - </span> 126 - </td> 127 - </tr> 128 - </tbody> 129 - </table> 130 - </div> 131 - <div class="mt-3 text-center"> 132 - <button @click="editGrinder('', '', '', '', '')" class="inline-flex items-center gap-2 bg-brown-600 text-white px-4 py-2 rounded-lg hover:bg-brown-700 transition-all shadow-md hover:shadow-lg text-sm font-medium"> 133 - <span> 134 - + 135 - </span> 136 - <span> 137 - Add New Grinder 138 - </span> 139 - </button> 140 - </div> 141 - </div> 142 - <div> 143 - <h3 class="text-lg font-semibold text-brown-900 mb-3"> 144 - ☕ Brewers 145 - </h3> 146 - <div class="overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300"> 147 - <table class="min-w-full divide-y divide-brown-300"> 148 - <thead class="bg-brown-200/80"> 149 - <tr> 150 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider"> 151 - Name 152 - </th> 153 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider"> 154 - 🔧 Type 155 - </th> 156 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider"> 157 - 📝 Description 158 - </th> 159 - </tr> 160 - </thead> 161 - <tbody class="bg-brown-50/60 divide-y divide-brown-200"> 162 - <tr class="hover:bg-brown-100/60 transition-colors"> 163 - <td class="px-6 py-4 text-sm font-bold text-brown-900"> 164 - Hario V60 165 - </td> 166 - <td class="px-6 py-4 text-sm text-brown-900"> 167 - Pour Over 168 - </td> 169 - <td class="px-6 py-4 text-sm text-brown-700 italic max-w-xs"> 170 - Classic pour over cone 171 - </td> 172 - </tr> 173 - </tbody> 174 - </table> 175 - </div> 176 - <div class="mt-3 text-center"> 177 - <button @click="editBrewer('', '', '', '')" class="inline-flex items-center gap-2 bg-brown-600 text-white px-4 py-2 rounded-lg hover:bg-brown-700 transition-all shadow-md hover:shadow-lg text-sm font-medium"> 178 - <span> 179 - + 180 - </span> 181 - <span> 182 - Add New Brewer 183 - </span> 184 - </button> 185 - </div> 186 - </div> 187 - </div>
-120
internal/bff/__snapshots__/profile_with_multiple_beans.snap
··· 1 - --- 2 - title: profile with multiple beans 3 - test_name: TestProfileContent_BeansTab_Snapshot/profile_with_multiple_beans 4 - file_name: profile_template_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - <div id="profile-stats-data" 8 - data-brews="0" 9 - data-beans="2" 10 - data-roasters="0" 11 - data-grinders="0" 12 - data-brewers="0" 13 - style="display: none;"></div> 14 - <div x-show="activeTab === 'brews'"> 15 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center border border-brown-300"> 16 - <p class="text-brown-800 text-lg mb-4 font-medium"> 17 - No brews yet! Start tracking your coffee journey. 18 - </p> 19 - <a href="/brews/new" 20 - class="inline-block bg-gradient-to-r from-brown-700 to-brown-800 text-white py-3 px-6 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all shadow-lg hover:shadow-xl font-medium"> 21 - Add Your First Brew 22 - </a> 23 - </div> 24 - </div> 25 - <div x-show="activeTab === 'beans'" x-cloak class="space-y-6"> 26 - <div> 27 - <h3 class="text-lg font-semibold text-brown-900 mb-3"> 28 - ☕ Coffee Beans 29 - </h3> 30 - <div class="overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300"> 31 - <table class="min-w-full divide-y divide-brown-300"> 32 - <thead class="bg-brown-200/80"> 33 - <tr> 34 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap"> 35 - Name 36 - </th> 37 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap"> 38 - ☕ Roaster 39 - </th> 40 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap"> 41 - 📍 Origin 42 - </th> 43 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap"> 44 - 🔥 Roast 45 - </th> 46 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap"> 47 - 🌱 Process 48 - </th> 49 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap"> 50 - 📝 Description 51 - </th> 52 - </tr> 53 - </thead> 54 - <tbody class="bg-brown-50/60 divide-y divide-brown-200"> 55 - <tr class="hover:bg-brown-100/60 transition-colors"> 56 - <td class="px-6 py-4 text-sm font-bold text-brown-900"> 57 - Ethiopian Yirgacheffe 58 - </td> 59 - <td class="px-6 py-4 text-sm text-brown-900"> 60 - Onyx Coffee Lab 61 - </td> 62 - <td class="px-6 py-4 text-sm text-brown-900"> 63 - Ethiopia 64 - </td> 65 - <td class="px-6 py-4 text-sm text-brown-900"> 66 - Light 67 - </td> 68 - <td class="px-6 py-4 text-sm text-brown-900"> 69 - Washed 70 - </td> 71 - <td class="px-6 py-4 text-sm text-brown-700 italic max-w-xs"> 72 - Bright and floral with citrus notes 73 - </td> 74 - </tr> 75 - <tr class="hover:bg-brown-100/60 transition-colors"> 76 - <td class="px-6 py-4 text-sm font-bold text-brown-900"> 77 - Colombia Supremo 78 - </td> 79 - <td class="px-6 py-4 text-sm text-brown-900"> 80 - <span class="text-brown-400"> 81 - - 82 - </span> 83 - </td> 84 - <td class="px-6 py-4 text-sm text-brown-900"> 85 - Colombia 86 - </td> 87 - <td class="px-6 py-4 text-sm text-brown-900"> 88 - Medium 89 - </td> 90 - <td class="px-6 py-4 text-sm text-brown-900"> 91 - Natural 92 - </td> 93 - <td class="px-6 py-4 text-sm text-brown-700 italic max-w-xs"> 94 - <span class="text-brown-400 not-italic"> 95 - - 96 - </span> 97 - </td> 98 - </tr> 99 - </tbody> 100 - </table> 101 - </div> 102 - <div class="mt-3 text-center"> 103 - <button @click="editBean('', '', '', '', '', '', '')" class="inline-flex items-center gap-2 bg-brown-600 text-white px-4 py-2 rounded-lg hover:bg-brown-700 transition-all shadow-md hover:shadow-lg text-sm font-medium"> 104 - <span> 105 - + 106 - </span> 107 - <span> 108 - Add New Bean 109 - </span> 110 - </button> 111 - </div> 112 - </div> 113 - </div> 114 - <div x-show="activeTab === 'gear'" x-cloak class="space-y-6"> 115 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center text-brown-800 border border-brown-300"> 116 - <p class="font-medium"> 117 - No gear added yet. 118 - </p> 119 - </div> 120 - </div>
-152
internal/bff/__snapshots__/profile_with_special_characters.snap
··· 1 - --- 2 - title: profile with special characters 3 - test_name: TestProfileContent_SpecialCharacters_Snapshot 4 - file_name: profile_template_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - <div id="profile-stats-data" 8 - data-brews="0" 9 - data-beans="1" 10 - data-roasters="0" 11 - data-grinders="1" 12 - data-brewers="0" 13 - style="display: none;"></div> 14 - <div x-show="activeTab === 'brews'"> 15 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center border border-brown-300"> 16 - <p class="text-brown-800 text-lg mb-4 font-medium"> 17 - No brews yet! Start tracking your coffee journey. 18 - </p> 19 - <a href="/brews/new" 20 - class="inline-block bg-gradient-to-r from-brown-700 to-brown-800 text-white py-3 px-6 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all shadow-lg hover:shadow-xl font-medium"> 21 - Add Your First Brew 22 - </a> 23 - </div> 24 - </div> 25 - <div x-show="activeTab === 'beans'" x-cloak class="space-y-6"> 26 - <div> 27 - <h3 class="text-lg font-semibold text-brown-900 mb-3"> 28 - ☕ Coffee Beans 29 - </h3> 30 - <div class="overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300"> 31 - <table class="min-w-full divide-y divide-brown-300"> 32 - <thead class="bg-brown-200/80"> 33 - <tr> 34 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap"> 35 - Name 36 - </th> 37 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap"> 38 - ☕ Roaster 39 - </th> 40 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap"> 41 - 📍 Origin 42 - </th> 43 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap"> 44 - 🔥 Roast 45 - </th> 46 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap"> 47 - 🌱 Process 48 - </th> 49 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap"> 50 - 📝 Description 51 - </th> 52 - </tr> 53 - </thead> 54 - <tbody class="bg-brown-50/60 divide-y divide-brown-200"> 55 - <tr class="hover:bg-brown-100/60 transition-colors"> 56 - <td class="px-6 py-4 text-sm font-bold text-brown-900"> 57 - Bean with &lt;html&gt; &amp; &#34;quotes&#34; 58 - </td> 59 - <td class="px-6 py-4 text-sm text-brown-900"> 60 - <span class="text-brown-400"> 61 - - 62 - </span> 63 - </td> 64 - <td class="px-6 py-4 text-sm text-brown-900"> 65 - Colombia &amp; Peru 66 - </td> 67 - <td class="px-6 py-4 text-sm text-brown-900"> 68 - <span class="text-brown-400"> 69 - - 70 - </span> 71 - </td> 72 - <td class="px-6 py-4 text-sm text-brown-900"> 73 - <span class="text-brown-400"> 74 - - 75 - </span> 76 - </td> 77 - <td class="px-6 py-4 text-sm text-brown-700 italic max-w-xs"> 78 - Description with &#39;single&#39; and &#34;double&#34; quotes 79 - </td> 80 - </tr> 81 - </tbody> 82 - </table> 83 - </div> 84 - <div class="mt-3 text-center"> 85 - <button @click="editBean('', '', '', '', '', '', '')" class="inline-flex items-center gap-2 bg-brown-600 text-white px-4 py-2 rounded-lg hover:bg-brown-700 transition-all shadow-md hover:shadow-lg text-sm font-medium"> 86 - <span> 87 - + 88 - </span> 89 - <span> 90 - Add New Bean 91 - </span> 92 - </button> 93 - </div> 94 - </div> 95 - </div> 96 - <div x-show="activeTab === 'gear'" x-cloak class="space-y-6"> 97 - <div> 98 - <h3 class="text-lg font-semibold text-brown-900 mb-3"> 99 - ⚙️ Grinders 100 - </h3> 101 - <div class="overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300"> 102 - <table class="min-w-full divide-y divide-brown-300"> 103 - <thead class="bg-brown-200/80"> 104 - <tr> 105 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider"> 106 - Name 107 - </th> 108 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider"> 109 - 🔧 Type 110 - </th> 111 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider"> 112 - 💎 Burrs 113 - </th> 114 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider"> 115 - 📝 Notes 116 - </th> 117 - </tr> 118 - </thead> 119 - <tbody class="bg-brown-50/60 divide-y divide-brown-200"> 120 - <tr class="hover:bg-brown-100/60 transition-colors"> 121 - <td class="px-6 py-4 text-sm font-bold text-brown-900"> 122 - Grinder &amp; Co. 123 - </td> 124 - <td class="px-6 py-4 text-sm text-brown-900"> 125 - <span class="text-brown-400"> 126 - - 127 - </span> 128 - </td> 129 - <td class="px-6 py-4 text-sm text-brown-900"> 130 - <span class="text-brown-400"> 131 - - 132 - </span> 133 - </td> 134 - <td class="px-6 py-4 text-sm text-brown-700 italic max-w-xs"> 135 - Notes with &lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt; 136 - </td> 137 - </tr> 138 - </tbody> 139 - </table> 140 - </div> 141 - <div class="mt-3 text-center"> 142 - <button @click="editGrinder('', '', '', '', '')" class="inline-flex items-center gap-2 bg-brown-600 text-white px-4 py-2 rounded-lg hover:bg-brown-700 transition-all shadow-md hover:shadow-lg text-sm font-medium"> 143 - <span> 144 - + 145 - </span> 146 - <span> 147 - Add New Grinder 148 - </span> 149 - </button> 150 - </div> 151 - </div> 152 - </div>
-151
internal/bff/__snapshots__/profile_with_unicode_content.snap
··· 1 - --- 2 - title: profile with unicode content 3 - test_name: TestProfileContent_Unicode_Snapshot 4 - file_name: profile_template_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - <div id="profile-stats-data" 8 - data-brews="0" 9 - data-beans="2" 10 - data-roasters="1" 11 - data-grinders="0" 12 - data-brewers="0" 13 - style="display: none;"></div> 14 - <div x-show="activeTab === 'brews'"> 15 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center border border-brown-300"> 16 - <p class="text-brown-800 text-lg font-medium"> 17 - No brews yet. 18 - </p> 19 - </div> 20 - </div> 21 - <div x-show="activeTab === 'beans'" x-cloak class="space-y-6"> 22 - <div> 23 - <h3 class="text-lg font-semibold text-brown-900 mb-3"> 24 - ☕ Coffee Beans 25 - </h3> 26 - <div class="overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300"> 27 - <table class="min-w-full divide-y divide-brown-300"> 28 - <thead class="bg-brown-200/80"> 29 - <tr> 30 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap"> 31 - Name 32 - </th> 33 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap"> 34 - ☕ Roaster 35 - </th> 36 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap"> 37 - 📍 Origin 38 - </th> 39 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap"> 40 - 🔥 Roast 41 - </th> 42 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap"> 43 - 🌱 Process 44 - </th> 45 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap"> 46 - 📝 Description 47 - </th> 48 - </tr> 49 - </thead> 50 - <tbody class="bg-brown-50/60 divide-y divide-brown-200"> 51 - <tr class="hover:bg-brown-100/60 transition-colors"> 52 - <td class="px-6 py-4 text-sm font-bold text-brown-900"> 53 - エチオピア イルガチェフェ 54 - </td> 55 - <td class="px-6 py-4 text-sm text-brown-900"> 56 - <span class="text-brown-400"> 57 - - 58 - </span> 59 - </td> 60 - <td class="px-6 py-4 text-sm text-brown-900"> 61 - 日本 62 - </td> 63 - <td class="px-6 py-4 text-sm text-brown-900"> 64 - <span class="text-brown-400"> 65 - - 66 - </span> 67 - </td> 68 - <td class="px-6 py-4 text-sm text-brown-900"> 69 - <span class="text-brown-400"> 70 - - 71 - </span> 72 - </td> 73 - <td class="px-6 py-4 text-sm text-brown-700 italic max-w-xs"> 74 - 明るく花のような香り 75 - </td> 76 - </tr> 77 - <tr class="hover:bg-brown-100/60 transition-colors"> 78 - <td class="px-6 py-4 text-sm font-bold text-brown-900"> 79 - Café de Colombia 80 - </td> 81 - <td class="px-6 py-4 text-sm text-brown-900"> 82 - <span class="text-brown-400"> 83 - - 84 - </span> 85 - </td> 86 - <td class="px-6 py-4 text-sm text-brown-900"> 87 - Bogotá 88 - </td> 89 - <td class="px-6 py-4 text-sm text-brown-900"> 90 - <span class="text-brown-400"> 91 - - 92 - </span> 93 - </td> 94 - <td class="px-6 py-4 text-sm text-brown-900"> 95 - <span class="text-brown-400"> 96 - - 97 - </span> 98 - </td> 99 - <td class="px-6 py-4 text-sm text-brown-700 italic max-w-xs"> 100 - Suave y aromático 101 - </td> 102 - </tr> 103 - </tbody> 104 - </table> 105 - </div> 106 - </div> 107 - <div> 108 - <h3 class="text-lg font-semibold text-brown-900 mb-3"> 109 - 🏭 Favorite Roasters 110 - </h3> 111 - <div class="overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300"> 112 - <table class="min-w-full divide-y divide-brown-300"> 113 - <thead class="bg-brown-200/80"> 114 - <tr> 115 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider"> 116 - Name 117 - </th> 118 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider"> 119 - 📍 Location 120 - </th> 121 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider"> 122 - 🌐 Website 123 - </th> 124 - </tr> 125 - </thead> 126 - <tbody class="bg-brown-50/60 divide-y divide-brown-200"> 127 - <tr class="hover:bg-brown-100/60 transition-colors"> 128 - <td class="px-6 py-4 text-sm font-bold text-brown-900"> 129 - Кофейня Москва 130 - </td> 131 - <td class="px-6 py-4 text-sm text-brown-900"> 132 - Москва, Россия 133 - </td> 134 - <td class="px-6 py-4 text-sm text-brown-900"> 135 - <span class="text-brown-400"> 136 - - 137 - </span> 138 - </td> 139 - </tr> 140 - </tbody> 141 - </table> 142 - </div> 143 - </div> 144 - </div> 145 - <div x-show="activeTab === 'gear'" x-cloak class="space-y-6"> 146 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center text-brown-800 border border-brown-300"> 147 - <p class="font-medium"> 148 - No gear added yet. 149 - </p> 150 - </div> 151 - </div>
-65
internal/bff/__snapshots__/profile_with_unsafe_avatar_url.snap
··· 1 - --- 2 - title: profile with unsafe avatar URL 3 - test_name: TestFeedTemplate_SecurityURLs_Snapshot/profile_with_unsafe_avatar_URL 4 - file_name: feed_template_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - <div class="space-y-4"> 8 - <div class="bg-gradient-to-br from-brown-50 to-brown-100 rounded-lg shadow-md border border-brown-200 p-4 hover:shadow-lg transition-shadow"> 9 - <div class="flex items-center gap-3 mb-3"> 10 - <a href="/profile/badavatar" class="flex-shrink-0"> 11 - <div class="w-10 h-10 rounded-full bg-brown-300 flex items-center justify-center hover:ring-2 hover:ring-brown-600 transition"> 12 - <span class="text-brown-600 text-sm"> 13 - ? 14 - </span> 15 - </div> 16 - </a> 17 - <div class="flex-1 min-w-0"> 18 - <div class="flex items-center gap-2"> 19 - <a href="/profile/badavatar" class="font-medium text-brown-900 truncate hover:text-brown-700 hover:underline"> 20 - Bad Avatar 21 - </a> 22 - <a href="/profile/badavatar" class="text-brown-600 text-sm truncate hover:text-brown-700 hover:underline"> 23 - @badavatar 24 - </a> 25 - </div> 26 - <span class="text-brown-500 text-sm"> 27 - 2 minutes ago 28 - </span> 29 - </div> 30 - </div> 31 - <div class="mb-2 text-sm text-brown-700"> 32 - 🫘 added a new bean 33 - </div> 34 - <div class="bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200"> 35 - <div class="text-base mb-2"> 36 - <span class="font-bold text-brown-900"> 37 - Test Bean 38 - </span> 39 - </div> 40 - <div class="text-sm text-brown-700 space-y-1"> 41 - <div> 42 - <span class="text-brown-600"> 43 - Origin: 44 - </span> 45 - Test Origin 46 - </div> 47 - <div> 48 - <span class="text-brown-600"> 49 - Roast: 50 - </span> 51 - Medium 52 - </div> 53 - <div> 54 - <span class="text-brown-600"> 55 - Process: 56 - </span> 57 - Natural 58 - </div> 59 - <div class="mt-2 text-brown-800 italic"> 60 - "Sweet and fruity with notes of blueberry" 61 - </div> 62 - </div> 63 - </div> 64 - </div> 65 - </div>
-7
internal/bff/__snapshots__/rating_3_out_of_10.snap
··· 1 - --- 2 - title: rating 3 out of 10 3 - test_name: TestIterateRemaining_Snapshot/rating_3_out_of_10 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - []int(nil)
-7
internal/bff/__snapshots__/rating_7_out_of_10.snap
··· 1 - --- 2 - title: rating 7 out of 10 3 - test_name: TestIterateRemaining_Snapshot/rating_7_out_of_10 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - []int(nil)
-7
internal/bff/__snapshots__/rating_formatting.snap
··· 1 - --- 2 - title: rating_formatting 3 - test_name: TestFormatHelpers_Snapshot/rating_formatting 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - []string{"N/A", "1/10", "5/10", "7/10", "10/10"}
-29
internal/bff/__snapshots__/roaster_form_renders.snap
··· 1 - --- 2 - title: roaster_form_renders 3 - test_name: TestNewRoasterForm_Snapshot/roaster_form_renders 4 - file_name: form_template_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - <div x-cloak x-show="showRoasterForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 8 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 9 - <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingRoaster ? 'Edit Roaster' : 'Add Roaster'"></h3> 10 - <div class="space-y-4"> 11 - <input type="text" x-model="roasterForm.name" placeholder="Name *" 12 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 13 - <input type="text" x-model="roasterForm.location" placeholder="Location" 14 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 15 - <input type="url" x-model="roasterForm.website" placeholder="Website" 16 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 17 - <div class="flex gap-2"> 18 - <button @click="saveRoaster()" 19 - class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md"> 20 - Save 21 - </button> 22 - <button @click="showRoasterForm = false" 23 - class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 24 - Cancel 25 - </button> 26 - </div> 27 - </div> 28 - </div> 29 - </div>
-54
internal/bff/__snapshots__/roaster_item.snap
··· 1 - --- 2 - title: roaster item 3 - test_name: TestFeedTemplate_RoasterItem_Snapshot 4 - file_name: feed_template_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - <div class="space-y-4"> 8 - <div class="bg-gradient-to-br from-brown-50 to-brown-100 rounded-lg shadow-md border border-brown-200 p-4 hover:shadow-lg transition-shadow"> 9 - <div class="flex items-center gap-3 mb-3"> 10 - <a href="/profile/roastmaster" class="flex-shrink-0"> 11 - <img src="https://cdn.bsky.app/avatar2.jpg" alt="" class="w-10 h-10 rounded-full object-cover hover:ring-2 hover:ring-brown-600 transition" /> 12 - </a> 13 - <div class="flex-1 min-w-0"> 14 - <div class="flex items-center gap-2"> 15 - <a href="/profile/roastmaster" class="font-medium text-brown-900 truncate hover:text-brown-700 hover:underline"> 16 - Roast Master 17 - </a> 18 - <a href="/profile/roastmaster" class="text-brown-600 text-sm truncate hover:text-brown-700 hover:underline"> 19 - @roastmaster 20 - </a> 21 - </div> 22 - <span class="text-brown-500 text-sm"> 23 - 10 minutes ago 24 - </span> 25 - </div> 26 - </div> 27 - <div class="mb-2 text-sm text-brown-700"> 28 - 🏪 added a new roaster 29 - </div> 30 - <div class="bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200"> 31 - <div class="text-base mb-2"> 32 - <span class="font-bold text-brown-900"> 33 - Heart Coffee Roasters 34 - </span> 35 - </div> 36 - <div class="text-sm text-brown-700 space-y-1"> 37 - <div> 38 - <span class="text-brown-600"> 39 - Location: 40 - </span> 41 - Portland, OR 42 - </div> 43 - <div> 44 - <span class="text-brown-600"> 45 - Website: 46 - </span> 47 - <a href="https://heartroasters.com" target="_blank" rel="noopener noreferrer" class="text-brown-800 hover:underline"> 48 - https://heartroasters.com 49 - </a> 50 - </div> 51 - </div> 52 - </div> 53 - </div> 54 - </div>
-43
internal/bff/__snapshots__/roaster_with_unsafe_website_url.snap
··· 1 - --- 2 - title: roaster with unsafe website URL 3 - test_name: TestFeedTemplate_SecurityURLs_Snapshot/roaster_with_unsafe_website_URL 4 - file_name: feed_template_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - <div class="space-y-4"> 8 - <div class="bg-gradient-to-br from-brown-50 to-brown-100 rounded-lg shadow-md border border-brown-200 p-4 hover:shadow-lg transition-shadow"> 9 - <div class="flex items-center gap-3 mb-3"> 10 - <a href="/profile/hacker" class="flex-shrink-0"> 11 - <div class="w-10 h-10 rounded-full bg-brown-300 flex items-center justify-center hover:ring-2 hover:ring-brown-600 transition"> 12 - <span class="text-brown-600 text-sm"> 13 - ? 14 - </span> 15 - </div> 16 - </a> 17 - <div class="flex-1 min-w-0"> 18 - <div class="flex items-center gap-2"> 19 - <a href="/profile/hacker" class="font-medium text-brown-900 truncate hover:text-brown-700 hover:underline"> 20 - Hacker 21 - </a> 22 - <a href="/profile/hacker" class="text-brown-600 text-sm truncate hover:text-brown-700 hover:underline"> 23 - @hacker 24 - </a> 25 - </div> 26 - <span class="text-brown-500 text-sm"> 27 - 1 minute ago 28 - </span> 29 - </div> 30 - </div> 31 - <div class="mb-2 text-sm text-brown-700"> 32 - 🏪 added a new roaster 33 - </div> 34 - <div class="bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200"> 35 - <div class="text-base mb-2"> 36 - <span class="font-bold text-brown-900"> 37 - Sketchy Roaster 38 - </span> 39 - </div> 40 - <div class="text-sm text-brown-700 space-y-1"></div> 41 - </div> 42 - </div> 43 - </div>
-210
internal/bff/__snapshots__/roasters_empty.snap
··· 1 - --- 2 - title: roasters empty 3 - test_name: TestManageContent_RoastersTab_Snapshot/roasters_empty 4 - file_name: partial_template_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - <div x-show="tab === 'beans'"> 8 - <div class="mb-4 flex justify-between items-center"> 9 - <h3 class="text-xl font-semibold text-brown-900"> 10 - Coffee Beans 11 - </h3> 12 - <button 13 - @click="showBeanForm = true; editingBean = null; beanForm = {name: '', origin: '', roast_level: '', process: '', description: '', roaster_rkey: ''}" 14 - class="bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-medium shadow-md hover:shadow-lg"> 15 - + Add Bean 16 - </button> 17 - </div> 18 - <div class="bg-brown-100 rounded-lg p-8 text-center text-brown-700 border border-brown-200"> 19 - No beans yet. Add your first bean to get started! 20 - </div> 21 - </div> 22 - <div x-show="tab === 'roasters'"> 23 - <div class="mb-4 flex justify-between items-center"> 24 - <h3 class="text-xl font-semibold text-brown-900"> 25 - Roasters 26 - </h3> 27 - <button 28 - @click="showRoasterForm = true; editingRoaster = null; roasterForm = {name: '', location: '', website: ''}" 29 - class="bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-medium shadow-md hover:shadow-lg"> 30 - + Add Roaster 31 - </button> 32 - </div> 33 - <div class="bg-brown-100 rounded-lg p-8 text-center text-brown-700 border border-brown-200"> 34 - No roasters yet. Add your first roaster! 35 - </div> 36 - </div> 37 - <div x-show="tab === 'grinders'"> 38 - <div class="mb-4 flex justify-between items-center"> 39 - <h3 class="text-xl font-semibold text-brown-900"> 40 - Grinders 41 - </h3> 42 - <button 43 - @click="showGrinderForm = true; editingGrinder = null; grinderForm = {name: '', grinder_type: '', burr_type: '', notes: ''}" 44 - class="bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-medium shadow-md hover:shadow-lg"> 45 - + Add Grinder 46 - </button> 47 - </div> 48 - <div class="bg-brown-100 rounded-lg p-8 text-center text-brown-700 border border-brown-200"> 49 - No grinders yet. Add your first grinder! 50 - </div> 51 - </div> 52 - <div x-show="tab === 'brewers'"> 53 - <div class="mb-4 flex justify-between items-center"> 54 - <h3 class="text-xl font-semibold text-brown-900"> 55 - Brewers 56 - </h3> 57 - <button @click="showBrewerForm = true; editingBrewer = null; brewerForm = {name: '', brewer_type: '', description: ''}" 58 - class="bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-medium shadow-md hover:shadow-lg"> 59 - + Add Brewer 60 - </button> 61 - </div> 62 - <div class="bg-brown-100 rounded-lg p-8 text-center text-brown-700 border border-brown-200"> 63 - No brewers yet. Add your first brewer! 64 - </div> 65 - </div> 66 - <div x-cloak x-show="showBeanForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 67 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 68 - <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingBean ? 'Edit Bean' : 'Add Bean'"></h3> 69 - <div class="space-y-4"> 70 - <input type="text" x-model="beanForm.name" placeholder="Name *" 71 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 72 - <input type="text" x-model="beanForm.origin" placeholder="Origin *" 73 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 74 - <select x-model="beanForm.roaster_rkey" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 75 - <option value=""> 76 - Select Roaster (Optional) 77 - </option> 78 - </select> 79 - <select x-model="beanForm.roast_level" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 80 - <option value=""> 81 - Select Roast Level (Optional) 82 - </option> 83 - <option value="Ultra-Light"> 84 - Ultra-Light 85 - </option> 86 - <option value="Light"> 87 - Light 88 - </option> 89 - <option value="Medium-Light"> 90 - Medium-Light 91 - </option> 92 - <option value="Medium"> 93 - Medium 94 - </option> 95 - <option value="Medium-Dark"> 96 - Medium-Dark 97 - </option> 98 - <option value="Dark"> 99 - Dark 100 - </option> 101 - </select> 102 - <input type="text" x-model="beanForm.process" placeholder="Process (e.g. Washed, Natural, Honey)" 103 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 104 - <textarea x-model="beanForm.description" placeholder="Description" rows="3" 105 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"></textarea> 106 - <div class="flex gap-2"> 107 - <button @click="saveBean()" 108 - class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md"> 109 - Save 110 - </button> 111 - <button @click="showBeanForm = false" 112 - class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 113 - Cancel 114 - </button> 115 - </div> 116 - </div> 117 - </div> 118 - </div> 119 - <div x-cloak x-show="showRoasterForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 120 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 121 - <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingRoaster ? 'Edit Roaster' : 'Add Roaster'"></h3> 122 - <div class="space-y-4"> 123 - <input type="text" x-model="roasterForm.name" placeholder="Name *" 124 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 125 - <input type="text" x-model="roasterForm.location" placeholder="Location" 126 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 127 - <input type="url" x-model="roasterForm.website" placeholder="Website" 128 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 129 - <div class="flex gap-2"> 130 - <button @click="saveRoaster()" 131 - class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md"> 132 - Save 133 - </button> 134 - <button @click="showRoasterForm = false" 135 - class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 136 - Cancel 137 - </button> 138 - </div> 139 - </div> 140 - </div> 141 - </div> 142 - <div x-cloak x-show="showGrinderForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 143 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 144 - <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingGrinder ? 'Edit Grinder' : 'Add Grinder'"></h3> 145 - <div class="space-y-4"> 146 - <input type="text" x-model="grinderForm.name" placeholder="Name *" 147 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 148 - <select x-model="grinderForm.grinder_type" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 149 - <option value=""> 150 - Select Grinder Type * 151 - </option> 152 - <option value="Hand"> 153 - Hand 154 - </option> 155 - <option value="Electric"> 156 - Electric 157 - </option> 158 - <option value="Portable Electric"> 159 - Portable Electric 160 - </option> 161 - </select> 162 - <select x-model="grinderForm.burr_type" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 163 - <option value=""> 164 - Select Burr Type (Optional) 165 - </option> 166 - <option value="Conical"> 167 - Conical 168 - </option> 169 - <option value="Flat"> 170 - Flat 171 - </option> 172 - </select> 173 - <textarea x-model="grinderForm.notes" placeholder="Notes" rows="3" 174 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"></textarea> 175 - <div class="flex gap-2"> 176 - <button @click="saveGrinder()" 177 - class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md"> 178 - Save 179 - </button> 180 - <button @click="showGrinderForm = false" 181 - class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 182 - Cancel 183 - </button> 184 - </div> 185 - </div> 186 - </div> 187 - </div> 188 - <div x-cloak x-show="showBrewerForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 189 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 190 - <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingBrewer ? 'Edit Brewer' : 'Add Brewer'"></h3> 191 - <div class="space-y-4"> 192 - <input type="text" x-model="brewerForm.name" placeholder="Name *" 193 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 194 - <input type="text" x-model="brewerForm.brewer_type" placeholder="Type (e.g., Pour-Over, Immersion, Espresso)" 195 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 196 - <textarea x-model="brewerForm.description" placeholder="Description" rows="3" 197 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"></textarea> 198 - <div class="flex gap-2"> 199 - <button @click="saveBrewer()" 200 - class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md"> 201 - Save 202 - </button> 203 - <button @click="showBrewerForm = false" 204 - class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 205 - Cancel 206 - </button> 207 - </div> 208 - </div> 209 - </div> 210 - </div>
-276
internal/bff/__snapshots__/roasters_with_data.snap
··· 1 - --- 2 - title: roasters with data 3 - test_name: TestManageContent_RoastersTab_Snapshot/roasters_with_data 4 - file_name: partial_template_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - <div x-show="tab === 'beans'"> 8 - <div class="mb-4 flex justify-between items-center"> 9 - <h3 class="text-xl font-semibold text-brown-900"> 10 - Coffee Beans 11 - </h3> 12 - <button 13 - @click="showBeanForm = true; editingBean = null; beanForm = {name: '', origin: '', roast_level: '', process: '', description: '', roaster_rkey: ''}" 14 - class="bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-medium shadow-md hover:shadow-lg"> 15 - + Add Bean 16 - </button> 17 - </div> 18 - <div class="bg-brown-100 rounded-lg p-8 text-center text-brown-700 border border-brown-200"> 19 - No beans yet. Add your first bean to get started! 20 - </div> 21 - </div> 22 - <div x-show="tab === 'roasters'"> 23 - <div class="mb-4 flex justify-between items-center"> 24 - <h3 class="text-xl font-semibold text-brown-900"> 25 - Roasters 26 - </h3> 27 - <button 28 - @click="showRoasterForm = true; editingRoaster = null; roasterForm = {name: '', location: '', website: ''}" 29 - class="bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-medium shadow-md hover:shadow-lg"> 30 - + Add Roaster 31 - </button> 32 - </div> 33 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 shadow-xl rounded-xl overflow-x-auto border border-brown-300"> 34 - <table class="min-w-full divide-y divide-brown-300"> 35 - <thead class="bg-brown-200/80"> 36 - <tr> 37 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase"> 38 - Name 39 - </th> 40 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase"> 41 - 📍 Location 42 - </th> 43 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase"> 44 - 🌐 Website 45 - </th> 46 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase"> 47 - Actions 48 - </th> 49 - </tr> 50 - </thead> 51 - <tbody class="bg-brown-50/60 divide-y divide-brown-200"> 52 - <tr class="hover:bg-brown-100/60 transition-colors"> 53 - <td class="px-6 py-4 text-sm font-medium text-brown-900"> 54 - Onyx Coffee Lab 55 - </td> 56 - <td class="px-6 py-4 text-sm text-brown-900"> 57 - Bentonville, AR 58 - </td> 59 - <td class="px-6 py-4 text-sm text-brown-900"> 60 - <a href="https://onyxcoffeelab.com" target="_blank" rel="noopener noreferrer" 61 - class="text-brown-700 hover:underline font-medium"> 62 - https://onyxcoffeelab.com 63 - </a> 64 - </td> 65 - <td class="px-6 py-4 text-sm font-medium space-x-2"> 66 - <button @click="editRoaster('roaster1', 'Onyx Coffee Lab', 'Bentonville, AR', 'https://onyxcoffeelab.com')" 67 - class="text-brown-700 hover:text-brown-900 font-medium"> 68 - Edit 69 - </button> 70 - <button @click="deleteRoaster('roaster1')" 71 - class="text-brown-600 hover:text-brown-800 font-medium"> 72 - Delete 73 - </button> 74 - </td> 75 - </tr> 76 - <tr class="hover:bg-brown-100/60 transition-colors"> 77 - <td class="px-6 py-4 text-sm font-medium text-brown-900"> 78 - Counter Culture Coffee 79 - </td> 80 - <td class="px-6 py-4 text-sm text-brown-900"></td> 81 - <td class="px-6 py-4 text-sm text-brown-900"></td> 82 - <td class="px-6 py-4 text-sm font-medium space-x-2"> 83 - <button @click="editRoaster('roaster2', 'Counter Culture Coffee', '', '')" 84 - class="text-brown-700 hover:text-brown-900 font-medium"> 85 - Edit 86 - </button> 87 - <button @click="deleteRoaster('roaster2')" 88 - class="text-brown-600 hover:text-brown-800 font-medium"> 89 - Delete 90 - </button> 91 - </td> 92 - </tr> 93 - </tbody> 94 - </table> 95 - </div> 96 - </div> 97 - <div x-show="tab === 'grinders'"> 98 - <div class="mb-4 flex justify-between items-center"> 99 - <h3 class="text-xl font-semibold text-brown-900"> 100 - Grinders 101 - </h3> 102 - <button 103 - @click="showGrinderForm = true; editingGrinder = null; grinderForm = {name: '', grinder_type: '', burr_type: '', notes: ''}" 104 - class="bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-medium shadow-md hover:shadow-lg"> 105 - + Add Grinder 106 - </button> 107 - </div> 108 - <div class="bg-brown-100 rounded-lg p-8 text-center text-brown-700 border border-brown-200"> 109 - No grinders yet. Add your first grinder! 110 - </div> 111 - </div> 112 - <div x-show="tab === 'brewers'"> 113 - <div class="mb-4 flex justify-between items-center"> 114 - <h3 class="text-xl font-semibold text-brown-900"> 115 - Brewers 116 - </h3> 117 - <button @click="showBrewerForm = true; editingBrewer = null; brewerForm = {name: '', brewer_type: '', description: ''}" 118 - class="bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-medium shadow-md hover:shadow-lg"> 119 - + Add Brewer 120 - </button> 121 - </div> 122 - <div class="bg-brown-100 rounded-lg p-8 text-center text-brown-700 border border-brown-200"> 123 - No brewers yet. Add your first brewer! 124 - </div> 125 - </div> 126 - <div x-cloak x-show="showBeanForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 127 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 128 - <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingBean ? 'Edit Bean' : 'Add Bean'"></h3> 129 - <div class="space-y-4"> 130 - <input type="text" x-model="beanForm.name" placeholder="Name *" 131 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 132 - <input type="text" x-model="beanForm.origin" placeholder="Origin *" 133 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 134 - <select x-model="beanForm.roaster_rkey" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 135 - <option value=""> 136 - Select Roaster (Optional) 137 - </option> 138 - <option value="roaster1"> 139 - Onyx Coffee Lab 140 - </option> 141 - <option value="roaster2"> 142 - Counter Culture Coffee 143 - </option> 144 - </select> 145 - <select x-model="beanForm.roast_level" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 146 - <option value=""> 147 - Select Roast Level (Optional) 148 - </option> 149 - <option value="Ultra-Light"> 150 - Ultra-Light 151 - </option> 152 - <option value="Light"> 153 - Light 154 - </option> 155 - <option value="Medium-Light"> 156 - Medium-Light 157 - </option> 158 - <option value="Medium"> 159 - Medium 160 - </option> 161 - <option value="Medium-Dark"> 162 - Medium-Dark 163 - </option> 164 - <option value="Dark"> 165 - Dark 166 - </option> 167 - </select> 168 - <input type="text" x-model="beanForm.process" placeholder="Process (e.g. Washed, Natural, Honey)" 169 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 170 - <textarea x-model="beanForm.description" placeholder="Description" rows="3" 171 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"></textarea> 172 - <div class="flex gap-2"> 173 - <button @click="saveBean()" 174 - class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md"> 175 - Save 176 - </button> 177 - <button @click="showBeanForm = false" 178 - class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 179 - Cancel 180 - </button> 181 - </div> 182 - </div> 183 - </div> 184 - </div> 185 - <div x-cloak x-show="showRoasterForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 186 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 187 - <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingRoaster ? 'Edit Roaster' : 'Add Roaster'"></h3> 188 - <div class="space-y-4"> 189 - <input type="text" x-model="roasterForm.name" placeholder="Name *" 190 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 191 - <input type="text" x-model="roasterForm.location" placeholder="Location" 192 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 193 - <input type="url" x-model="roasterForm.website" placeholder="Website" 194 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 195 - <div class="flex gap-2"> 196 - <button @click="saveRoaster()" 197 - class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md"> 198 - Save 199 - </button> 200 - <button @click="showRoasterForm = false" 201 - class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 202 - Cancel 203 - </button> 204 - </div> 205 - </div> 206 - </div> 207 - </div> 208 - <div x-cloak x-show="showGrinderForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 209 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 210 - <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingGrinder ? 'Edit Grinder' : 'Add Grinder'"></h3> 211 - <div class="space-y-4"> 212 - <input type="text" x-model="grinderForm.name" placeholder="Name *" 213 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 214 - <select x-model="grinderForm.grinder_type" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 215 - <option value=""> 216 - Select Grinder Type * 217 - </option> 218 - <option value="Hand"> 219 - Hand 220 - </option> 221 - <option value="Electric"> 222 - Electric 223 - </option> 224 - <option value="Portable Electric"> 225 - Portable Electric 226 - </option> 227 - </select> 228 - <select x-model="grinderForm.burr_type" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 229 - <option value=""> 230 - Select Burr Type (Optional) 231 - </option> 232 - <option value="Conical"> 233 - Conical 234 - </option> 235 - <option value="Flat"> 236 - Flat 237 - </option> 238 - </select> 239 - <textarea x-model="grinderForm.notes" placeholder="Notes" rows="3" 240 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"></textarea> 241 - <div class="flex gap-2"> 242 - <button @click="saveGrinder()" 243 - class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md"> 244 - Save 245 - </button> 246 - <button @click="showGrinderForm = false" 247 - class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 248 - Cancel 249 - </button> 250 - </div> 251 - </div> 252 - </div> 253 - </div> 254 - <div x-cloak x-show="showBrewerForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 255 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 256 - <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingBrewer ? 'Edit Brewer' : 'Add Brewer'"></h3> 257 - <div class="space-y-4"> 258 - <input type="text" x-model="brewerForm.name" placeholder="Name *" 259 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 260 - <input type="text" x-model="brewerForm.brewer_type" placeholder="Type (e.g., Pour-Over, Immersion, Espresso)" 261 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 262 - <textarea x-model="brewerForm.description" placeholder="Description" rows="3" 263 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"></textarea> 264 - <div class="flex gap-2"> 265 - <button @click="saveBrewer()" 266 - class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md"> 267 - Save 268 - </button> 269 - <button @click="showBrewerForm = false" 270 - class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 271 - Cancel 272 - </button> 273 - </div> 274 - </div> 275 - </div> 276 - </div>
-251
internal/bff/__snapshots__/roasters_with_unsafe_url.snap
··· 1 - --- 2 - title: roasters with unsafe url 3 - test_name: TestManageContent_RoastersTab_Snapshot/roasters_with_unsafe_url 4 - file_name: partial_template_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - <div x-show="tab === 'beans'"> 8 - <div class="mb-4 flex justify-between items-center"> 9 - <h3 class="text-xl font-semibold text-brown-900"> 10 - Coffee Beans 11 - </h3> 12 - <button 13 - @click="showBeanForm = true; editingBean = null; beanForm = {name: '', origin: '', roast_level: '', process: '', description: '', roaster_rkey: ''}" 14 - class="bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-medium shadow-md hover:shadow-lg"> 15 - + Add Bean 16 - </button> 17 - </div> 18 - <div class="bg-brown-100 rounded-lg p-8 text-center text-brown-700 border border-brown-200"> 19 - No beans yet. Add your first bean to get started! 20 - </div> 21 - </div> 22 - <div x-show="tab === 'roasters'"> 23 - <div class="mb-4 flex justify-between items-center"> 24 - <h3 class="text-xl font-semibold text-brown-900"> 25 - Roasters 26 - </h3> 27 - <button 28 - @click="showRoasterForm = true; editingRoaster = null; roasterForm = {name: '', location: '', website: ''}" 29 - class="bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-medium shadow-md hover:shadow-lg"> 30 - + Add Roaster 31 - </button> 32 - </div> 33 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 shadow-xl rounded-xl overflow-x-auto border border-brown-300"> 34 - <table class="min-w-full divide-y divide-brown-300"> 35 - <thead class="bg-brown-200/80"> 36 - <tr> 37 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase"> 38 - Name 39 - </th> 40 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase"> 41 - 📍 Location 42 - </th> 43 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase"> 44 - 🌐 Website 45 - </th> 46 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase"> 47 - Actions 48 - </th> 49 - </tr> 50 - </thead> 51 - <tbody class="bg-brown-50/60 divide-y divide-brown-200"> 52 - <tr class="hover:bg-brown-100/60 transition-colors"> 53 - <td class="px-6 py-4 text-sm font-medium text-brown-900"> 54 - Test Roaster 55 - </td> 56 - <td class="px-6 py-4 text-sm text-brown-900"> 57 - Test Location 58 - </td> 59 - <td class="px-6 py-4 text-sm text-brown-900"></td> 60 - <td class="px-6 py-4 text-sm font-medium space-x-2"> 61 - <button @click="editRoaster('roaster1', 'Test Roaster', 'Test Location', 'javascript:alert(\&#39;xss\&#39;)')" 62 - class="text-brown-700 hover:text-brown-900 font-medium"> 63 - Edit 64 - </button> 65 - <button @click="deleteRoaster('roaster1')" 66 - class="text-brown-600 hover:text-brown-800 font-medium"> 67 - Delete 68 - </button> 69 - </td> 70 - </tr> 71 - </tbody> 72 - </table> 73 - </div> 74 - </div> 75 - <div x-show="tab === 'grinders'"> 76 - <div class="mb-4 flex justify-between items-center"> 77 - <h3 class="text-xl font-semibold text-brown-900"> 78 - Grinders 79 - </h3> 80 - <button 81 - @click="showGrinderForm = true; editingGrinder = null; grinderForm = {name: '', grinder_type: '', burr_type: '', notes: ''}" 82 - class="bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-medium shadow-md hover:shadow-lg"> 83 - + Add Grinder 84 - </button> 85 - </div> 86 - <div class="bg-brown-100 rounded-lg p-8 text-center text-brown-700 border border-brown-200"> 87 - No grinders yet. Add your first grinder! 88 - </div> 89 - </div> 90 - <div x-show="tab === 'brewers'"> 91 - <div class="mb-4 flex justify-between items-center"> 92 - <h3 class="text-xl font-semibold text-brown-900"> 93 - Brewers 94 - </h3> 95 - <button @click="showBrewerForm = true; editingBrewer = null; brewerForm = {name: '', brewer_type: '', description: ''}" 96 - class="bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-medium shadow-md hover:shadow-lg"> 97 - + Add Brewer 98 - </button> 99 - </div> 100 - <div class="bg-brown-100 rounded-lg p-8 text-center text-brown-700 border border-brown-200"> 101 - No brewers yet. Add your first brewer! 102 - </div> 103 - </div> 104 - <div x-cloak x-show="showBeanForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 105 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 106 - <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingBean ? 'Edit Bean' : 'Add Bean'"></h3> 107 - <div class="space-y-4"> 108 - <input type="text" x-model="beanForm.name" placeholder="Name *" 109 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 110 - <input type="text" x-model="beanForm.origin" placeholder="Origin *" 111 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 112 - <select x-model="beanForm.roaster_rkey" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 113 - <option value=""> 114 - Select Roaster (Optional) 115 - </option> 116 - <option value="roaster1"> 117 - Test Roaster 118 - </option> 119 - </select> 120 - <select x-model="beanForm.roast_level" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 121 - <option value=""> 122 - Select Roast Level (Optional) 123 - </option> 124 - <option value="Ultra-Light"> 125 - Ultra-Light 126 - </option> 127 - <option value="Light"> 128 - Light 129 - </option> 130 - <option value="Medium-Light"> 131 - Medium-Light 132 - </option> 133 - <option value="Medium"> 134 - Medium 135 - </option> 136 - <option value="Medium-Dark"> 137 - Medium-Dark 138 - </option> 139 - <option value="Dark"> 140 - Dark 141 - </option> 142 - </select> 143 - <input type="text" x-model="beanForm.process" placeholder="Process (e.g. Washed, Natural, Honey)" 144 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 145 - <textarea x-model="beanForm.description" placeholder="Description" rows="3" 146 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"></textarea> 147 - <div class="flex gap-2"> 148 - <button @click="saveBean()" 149 - class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md"> 150 - Save 151 - </button> 152 - <button @click="showBeanForm = false" 153 - class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 154 - Cancel 155 - </button> 156 - </div> 157 - </div> 158 - </div> 159 - </div> 160 - <div x-cloak x-show="showRoasterForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 161 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 162 - <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingRoaster ? 'Edit Roaster' : 'Add Roaster'"></h3> 163 - <div class="space-y-4"> 164 - <input type="text" x-model="roasterForm.name" placeholder="Name *" 165 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 166 - <input type="text" x-model="roasterForm.location" placeholder="Location" 167 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 168 - <input type="url" x-model="roasterForm.website" placeholder="Website" 169 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 170 - <div class="flex gap-2"> 171 - <button @click="saveRoaster()" 172 - class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md"> 173 - Save 174 - </button> 175 - <button @click="showRoasterForm = false" 176 - class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 177 - Cancel 178 - </button> 179 - </div> 180 - </div> 181 - </div> 182 - </div> 183 - <div x-cloak x-show="showGrinderForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 184 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 185 - <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingGrinder ? 'Edit Grinder' : 'Add Grinder'"></h3> 186 - <div class="space-y-4"> 187 - <input type="text" x-model="grinderForm.name" placeholder="Name *" 188 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 189 - <select x-model="grinderForm.grinder_type" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 190 - <option value=""> 191 - Select Grinder Type * 192 - </option> 193 - <option value="Hand"> 194 - Hand 195 - </option> 196 - <option value="Electric"> 197 - Electric 198 - </option> 199 - <option value="Portable Electric"> 200 - Portable Electric 201 - </option> 202 - </select> 203 - <select x-model="grinderForm.burr_type" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 204 - <option value=""> 205 - Select Burr Type (Optional) 206 - </option> 207 - <option value="Conical"> 208 - Conical 209 - </option> 210 - <option value="Flat"> 211 - Flat 212 - </option> 213 - </select> 214 - <textarea x-model="grinderForm.notes" placeholder="Notes" rows="3" 215 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"></textarea> 216 - <div class="flex gap-2"> 217 - <button @click="saveGrinder()" 218 - class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md"> 219 - Save 220 - </button> 221 - <button @click="showGrinderForm = false" 222 - class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 223 - Cancel 224 - </button> 225 - </div> 226 - </div> 227 - </div> 228 - </div> 229 - <div x-cloak x-show="showBrewerForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 230 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 231 - <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingBrewer ? 'Edit Brewer' : 'Add Brewer'"></h3> 232 - <div class="space-y-4"> 233 - <input type="text" x-model="brewerForm.name" placeholder="Name *" 234 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 235 - <input type="text" x-model="brewerForm.brewer_type" placeholder="Type (e.g., Pour-Over, Immersion, Espresso)" 236 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 237 - <textarea x-model="brewerForm.description" placeholder="Description" rows="3" 238 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"></textarea> 239 - <div class="flex gap-2"> 240 - <button @click="saveBrewer()" 241 - class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md"> 242 - Save 243 - </button> 244 - <button @click="showBrewerForm = false" 245 - class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 246 - Cancel 247 - </button> 248 - </div> 249 - </div> 250 - </div> 251 - </div>
-7
internal/bff/__snapshots__/single_iteration.snap
··· 1 - --- 2 - title: single iteration 3 - test_name: TestIterate_Snapshot/single_iteration 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - []int{0}
-9
internal/bff/__snapshots__/single_key-value.snap
··· 1 - --- 2 - title: single key-value 3 - test_name: TestDict_Snapshot/single_key-value 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - map[string]interface{}{ 8 - "key1": "value1", 9 - }
-7
internal/bff/__snapshots__/single_pour.snap
··· 1 - --- 2 - title: single pour 3 - test_name: TestPoursToJSON_Snapshot/single_pour 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - "[{\"water\":50,\"time\":30}]"
-7
internal/bff/__snapshots__/small_positive.snap
··· 1 - --- 2 - title: small positive 3 - test_name: TestHasTemp_Snapshot/small_positive 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - true
-58
internal/bff/__snapshots__/special_characters_in_content.snap
··· 1 - --- 2 - title: special characters in content 3 - test_name: TestFeedTemplate_SpecialCharacters_Snapshot 4 - file_name: feed_template_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - <div class="space-y-4"> 8 - <div class="bg-gradient-to-br from-brown-50 to-brown-100 rounded-lg shadow-md border border-brown-200 p-4 hover:shadow-lg transition-shadow"> 9 - <div class="flex items-center gap-3 mb-3"> 10 - <a href="/profile/special.chars" class="flex-shrink-0"> 11 - <div class="w-10 h-10 rounded-full bg-brown-300 flex items-center justify-center hover:ring-2 hover:ring-brown-600 transition"> 12 - <span class="text-brown-600 text-sm"> 13 - ? 14 - </span> 15 - </div> 16 - </a> 17 - <div class="flex-1 min-w-0"> 18 - <div class="flex items-center gap-2"> 19 - <a href="/profile/special.chars" class="font-medium text-brown-900 truncate hover:text-brown-700 hover:underline"> 20 - User &amp; Co. 21 - </a> 22 - <a href="/profile/special.chars" class="text-brown-600 text-sm truncate hover:text-brown-700 hover:underline"> 23 - @special.chars 24 - </a> 25 - </div> 26 - <span class="text-brown-500 text-sm"> 27 - 5 seconds ago 28 - </span> 29 - </div> 30 - </div> 31 - <div class="mb-2 text-sm text-brown-700"> 32 - ☕ added a new brew 33 - </div> 34 - <div class="bg-white/60 backdrop-blur rounded-lg p-4 border border-brown-200"> 35 - <div class="flex items-start justify-between gap-3 mb-3"> 36 - <div class="flex-1 min-w-0"> 37 - <div class="font-bold text-brown-900 text-base"> 38 - Bean with &amp; ampersand 39 - </div> 40 - <div class="text-xs text-brown-600 mt-1 flex flex-wrap gap-x-2 gap-y-0.5"></div> 41 - </div> 42 - <span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-amber-100 text-amber-900 flex-shrink-0"> 43 - ⭐ 8/10 44 - </span> 45 - </div> 46 - <div class="grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-brown-700"></div> 47 - <div class="mt-3 text-sm text-brown-800 italic border-t border-brown-200 pt-2"> 48 - "Notes with &#34;quotes&#34; and &lt;html&gt;tags&lt;/html&gt; and &#39;single quotes&#39;" 49 - </div> 50 - <div class="mt-3 border-t border-brown-200 pt-3"> 51 - <a href="/brews/brew999?owner=special.chars" 52 - class="inline-flex items-center text-sm font-medium text-brown-700 hover:text-brown-900 hover:underline"> 53 - View full details → 54 - </a> 55 - </div> 56 - </div> 57 - </div> 58 - </div>
-7
internal/bff/__snapshots__/temperature_formatting.snap
··· 1 - --- 2 - title: temperature_formatting 3 - test_name: TestFormatHelpers_Snapshot/temperature_formatting 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - []string{"N/A", "20.5°C", "93.0°C", "100.0°C", "200.5°F", "212.0°F"}
-7
internal/bff/__snapshots__/ten_iterations.snap
··· 1 - --- 2 - title: ten iterations 3 - test_name: TestIterate_Snapshot/ten_iterations 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
-7
internal/bff/__snapshots__/time_formatting.snap
··· 1 - --- 2 - title: time_formatting 3 - test_name: TestFormatHelpers_Snapshot/time_formatting 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - []string{"N/A", "15s", "1m", "1m 30s", "3m", "4m 5s"}
-7
internal/bff/__snapshots__/valid_int_pointer.snap
··· 1 - --- 2 - title: valid int pointer 3 - test_name: TestPtrValue_Snapshot/valid_int_pointer 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - 42
-7
internal/bff/__snapshots__/valid_string_pointer.snap
··· 1 - --- 2 - title: valid string pointer 3 - test_name: TestPtrValue_Snapshot/valid_string_pointer 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - "test value"
-7
internal/bff/__snapshots__/website_urls.snap
··· 1 - --- 2 - title: website_urls 3 - test_name: TestSafeURL_Snapshot/website_URLs 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - []string{"", "https://example.com", "http://example.com", "https://roastery.coffee/beans", "", "", "", ""}
-8
internal/bff/__snapshots__/zero_count.snap
··· 1 - --- 2 - title: zero count 3 - test_name: TestIterate_Snapshot/zero_count 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - []int{ 8 - }
-7
internal/bff/__snapshots__/zero_temp.snap
··· 1 - --- 2 - title: zero temp 3 - test_name: TestFormatTempValue_Snapshot/zero_temp 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - "0.0"
-7
internal/bff/__snapshots__/zero_temperature.snap
··· 1 - --- 2 - title: zero temperature 3 - test_name: TestHasTemp_Snapshot/zero_temperature 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - false
-7
internal/bff/__snapshots__/zero_value.snap
··· 1 - --- 2 - title: zero value 3 - test_name: TestHasValue_Snapshot/zero_value 4 - file_name: render_snapshot_test.go 5 - version: 0.1.0 6 - --- 7 - false
-496
internal/bff/feed_template_snapshot_test.go
··· 1 - package bff 2 - 3 - import ( 4 - "bytes" 5 - "html/template" 6 - "testing" 7 - "time" 8 - 9 - "github.com/ptdewey/shutter" 10 - 11 - "arabica/internal/atproto" 12 - "arabica/internal/feed" 13 - "arabica/internal/models" 14 - ) 15 - 16 - // Helper functions for creating test data 17 - 18 - func mockProfile(handle string, displayName string, avatar string) *atproto.Profile { 19 - var dn *string 20 - if displayName != "" { 21 - dn = &displayName 22 - } 23 - var av *string 24 - if avatar != "" { 25 - av = &avatar 26 - } 27 - return &atproto.Profile{ 28 - DID: "did:plc:" + handle, 29 - Handle: handle, 30 - DisplayName: dn, 31 - Avatar: av, 32 - } 33 - } 34 - 35 - func mockBrew(beanName string, roasterName string, rating int) *models.Brew { 36 - testTime := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) 37 - brew := &models.Brew{ 38 - RKey: "brew123", 39 - BeanRKey: "bean123", 40 - CreatedAt: testTime, 41 - Rating: rating, 42 - Temperature: 93.0, 43 - WaterAmount: 250, 44 - CoffeeAmount: 16, 45 - TimeSeconds: 180, 46 - GrindSize: "Medium-fine", 47 - Method: "V60", 48 - TastingNotes: "Bright citrus notes with floral aroma", 49 - } 50 - 51 - if beanName != "" { 52 - brew.Bean = &models.Bean{ 53 - RKey: "bean123", 54 - Name: beanName, 55 - Origin: "Ethiopia", 56 - RoastLevel: "Light", 57 - Process: "Washed", 58 - CreatedAt: testTime, 59 - } 60 - if roasterName != "" { 61 - brew.Bean.Roaster = &models.Roaster{ 62 - RKey: "roaster123", 63 - Name: roasterName, 64 - Location: "Portland, OR", 65 - Website: "https://example.com", 66 - CreatedAt: testTime, 67 - } 68 - } 69 - } 70 - 71 - brew.GrinderObj = &models.Grinder{ 72 - RKey: "grinder123", 73 - Name: "1Zpresso JX-Pro", 74 - GrinderType: "Hand", 75 - BurrType: "Conical", 76 - CreatedAt: testTime, 77 - } 78 - 79 - brew.BrewerObj = &models.Brewer{ 80 - RKey: "brewer123", 81 - Name: "Hario V60", 82 - BrewerType: "Pour Over", 83 - Description: "Ceramic dripper", 84 - CreatedAt: testTime, 85 - } 86 - 87 - brew.Pours = []*models.Pour{ 88 - {PourNumber: 1, WaterAmount: 50, TimeSeconds: 30, CreatedAt: testTime}, 89 - {PourNumber: 2, WaterAmount: 100, TimeSeconds: 45, CreatedAt: testTime}, 90 - {PourNumber: 3, WaterAmount: 100, TimeSeconds: 60, CreatedAt: testTime}, 91 - } 92 - 93 - return brew 94 - } 95 - 96 - func mockBean(name string, origin string, hasRoaster bool) *models.Bean { 97 - testTime := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) 98 - bean := &models.Bean{ 99 - RKey: "bean456", 100 - Name: name, 101 - Origin: origin, 102 - RoastLevel: "Medium", 103 - Process: "Natural", 104 - Description: "Sweet and fruity with notes of blueberry", 105 - CreatedAt: testTime, 106 - } 107 - 108 - if hasRoaster { 109 - bean.Roaster = &models.Roaster{ 110 - RKey: "roaster456", 111 - Name: "Onyx Coffee Lab", 112 - Location: "Bentonville, AR", 113 - Website: "https://onyxcoffeelab.com", 114 - CreatedAt: testTime, 115 - } 116 - } 117 - 118 - return bean 119 - } 120 - 121 - func mockRoaster() *models.Roaster { 122 - testTime := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) 123 - return &models.Roaster{ 124 - RKey: "roaster789", 125 - Name: "Heart Coffee Roasters", 126 - Location: "Portland, OR", 127 - Website: "https://heartroasters.com", 128 - CreatedAt: testTime, 129 - } 130 - } 131 - 132 - func mockGrinder() *models.Grinder { 133 - testTime := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) 134 - return &models.Grinder{ 135 - RKey: "grinder789", 136 - Name: "Comandante C40", 137 - GrinderType: "Hand", 138 - BurrType: "Conical", 139 - Notes: "Excellent for pour over", 140 - CreatedAt: testTime, 141 - } 142 - } 143 - 144 - func mockBrewer() *models.Brewer { 145 - testTime := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) 146 - return &models.Brewer{ 147 - RKey: "brewer789", 148 - Name: "Kalita Wave 185", 149 - BrewerType: "Pour Over", 150 - Description: "Flat-bottom dripper with wave filters", 151 - CreatedAt: testTime, 152 - } 153 - } 154 - 155 - // Template execution helper 156 - func execFeedTemplate(feedItems []*feed.FeedItem, isAuthenticated bool) (string, error) { 157 - tmpl := template.New("test").Funcs(getTemplateFuncs()) 158 - tmpl, err := tmpl.ParseFiles("../../templates/partials/feed.tmpl") 159 - if err != nil { 160 - return "", err 161 - } 162 - 163 - data := map[string]interface{}{ 164 - "FeedItems": feedItems, 165 - "IsAuthenticated": isAuthenticated, 166 - } 167 - 168 - var buf bytes.Buffer 169 - err = tmpl.ExecuteTemplate(&buf, "feed", data) 170 - if err != nil { 171 - return "", err 172 - } 173 - 174 - return buf.String(), nil 175 - } 176 - 177 - // Test individual record types 178 - 179 - func TestFeedTemplate_BrewItem_Snapshot(t *testing.T) { 180 - tests := []struct { 181 - name string 182 - feedItem *feed.FeedItem 183 - }{ 184 - { 185 - name: "complete brew with all fields", 186 - feedItem: &feed.FeedItem{ 187 - RecordType: "brew", 188 - Action: "☕ added a new brew", 189 - Brew: mockBrew("Ethiopian Yirgacheffe", "Onyx Coffee Lab", 9), 190 - Author: mockProfile("coffee.lover", "Coffee Enthusiast", "https://cdn.bsky.app/avatar.jpg"), 191 - Timestamp: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), 192 - TimeAgo: "2 hours ago", 193 - }, 194 - }, 195 - { 196 - name: "brew with minimal data", 197 - feedItem: &feed.FeedItem{ 198 - RecordType: "brew", 199 - Action: "☕ added a new brew", 200 - Brew: &models.Brew{ 201 - RKey: "brew456", 202 - CreatedAt: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), 203 - Rating: 0, // no rating 204 - Bean: &models.Bean{ 205 - Name: "House Blend", 206 - }, 207 - }, 208 - Author: mockProfile("newbie", "", ""), 209 - Timestamp: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), 210 - TimeAgo: "1 minute ago", 211 - }, 212 - }, 213 - { 214 - name: "brew with unicode bean name", 215 - feedItem: &feed.FeedItem{ 216 - RecordType: "brew", 217 - Action: "☕ added a new brew", 218 - Brew: &models.Brew{ 219 - RKey: "brew789", 220 - CreatedAt: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), 221 - Rating: 8, 222 - Bean: &models.Bean{ 223 - Name: "コーヒー豆", 224 - Origin: "日本", 225 - }, 226 - }, 227 - Author: mockProfile("japan.coffee", "日本のコーヒー", ""), 228 - Timestamp: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), 229 - TimeAgo: "3 hours ago", 230 - }, 231 - }, 232 - } 233 - 234 - for _, tt := range tests { 235 - t.Run(tt.name, func(t *testing.T) { 236 - result, err := execFeedTemplate([]*feed.FeedItem{tt.feedItem}, true) 237 - if err != nil { 238 - t.Fatalf("Failed to execute template: %v", err) 239 - } 240 - shutter.SnapString(t, tt.name, formatHTML(result)) 241 - }) 242 - } 243 - } 244 - 245 - func TestFeedTemplate_BeanItem_Snapshot(t *testing.T) { 246 - tests := []struct { 247 - name string 248 - feedItem *feed.FeedItem 249 - }{ 250 - { 251 - name: "bean with roaster", 252 - feedItem: &feed.FeedItem{ 253 - RecordType: "bean", 254 - Action: "🫘 added a new bean", 255 - Bean: mockBean("Kenya AA", "Kenya", true), 256 - Author: mockProfile("roaster.pro", "Pro Roaster", ""), 257 - Timestamp: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), 258 - TimeAgo: "5 minutes ago", 259 - }, 260 - }, 261 - { 262 - name: "bean without roaster", 263 - feedItem: &feed.FeedItem{ 264 - RecordType: "bean", 265 - Action: "🫘 added a new bean", 266 - Bean: mockBean("Colombian Supremo", "Colombia", false), 267 - Author: mockProfile("homebrewer", "Home Brewer", ""), 268 - Timestamp: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), 269 - TimeAgo: "1 day ago", 270 - }, 271 - }, 272 - } 273 - 274 - for _, tt := range tests { 275 - t.Run(tt.name, func(t *testing.T) { 276 - result, err := execFeedTemplate([]*feed.FeedItem{tt.feedItem}, true) 277 - if err != nil { 278 - t.Fatalf("Failed to execute template: %v", err) 279 - } 280 - shutter.SnapString(t, tt.name, formatHTML(result)) 281 - }) 282 - } 283 - } 284 - 285 - func TestFeedTemplate_RoasterItem_Snapshot(t *testing.T) { 286 - feedItem := &feed.FeedItem{ 287 - RecordType: "roaster", 288 - Action: "🏪 added a new roaster", 289 - Roaster: mockRoaster(), 290 - Author: mockProfile("roastmaster", "Roast Master", "https://cdn.bsky.app/avatar2.jpg"), 291 - Timestamp: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), 292 - TimeAgo: "10 minutes ago", 293 - } 294 - 295 - result, err := execFeedTemplate([]*feed.FeedItem{feedItem}, true) 296 - if err != nil { 297 - t.Fatalf("Failed to execute template: %v", err) 298 - } 299 - shutter.SnapString(t, "roaster item", formatHTML(result)) 300 - } 301 - 302 - func TestFeedTemplate_GrinderItem_Snapshot(t *testing.T) { 303 - feedItem := &feed.FeedItem{ 304 - RecordType: "grinder", 305 - Action: "⚙️ added a new grinder", 306 - Grinder: mockGrinder(), 307 - Author: mockProfile("gearhead", "Coffee Gear Head", ""), 308 - Timestamp: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), 309 - TimeAgo: "30 minutes ago", 310 - } 311 - 312 - result, err := execFeedTemplate([]*feed.FeedItem{feedItem}, true) 313 - if err != nil { 314 - t.Fatalf("Failed to execute template: %v", err) 315 - } 316 - shutter.SnapString(t, "grinder item", formatHTML(result)) 317 - } 318 - 319 - func TestFeedTemplate_BrewerItem_Snapshot(t *testing.T) { 320 - feedItem := &feed.FeedItem{ 321 - RecordType: "brewer", 322 - Action: "☕ added a new brewer", 323 - Brewer: mockBrewer(), 324 - Author: mockProfile("pourover.fan", "Pour Over Fan", ""), 325 - Timestamp: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), 326 - TimeAgo: "2 days ago", 327 - } 328 - 329 - result, err := execFeedTemplate([]*feed.FeedItem{feedItem}, true) 330 - if err != nil { 331 - t.Fatalf("Failed to execute template: %v", err) 332 - } 333 - shutter.SnapString(t, "brewer item", formatHTML(result)) 334 - } 335 - 336 - // Test mixed feeds and edge cases 337 - 338 - func TestFeedTemplate_MixedFeed_Snapshot(t *testing.T) { 339 - feedItems := []*feed.FeedItem{ 340 - { 341 - RecordType: "brew", 342 - Action: "☕ added a new brew", 343 - Brew: mockBrew("Ethiopian Yirgacheffe", "Onyx", 9), 344 - Author: mockProfile("user1", "User One", ""), 345 - Timestamp: time.Date(2024, 1, 15, 12, 0, 0, 0, time.UTC), 346 - TimeAgo: "1 hour ago", 347 - }, 348 - { 349 - RecordType: "bean", 350 - Action: "🫘 added a new bean", 351 - Bean: mockBean("Kenya AA", "Kenya", true), 352 - Author: mockProfile("user2", "User Two", "https://cdn.bsky.app/avatar.jpg"), 353 - Timestamp: time.Date(2024, 1, 15, 11, 30, 0, 0, time.UTC), 354 - TimeAgo: "1.5 hours ago", 355 - }, 356 - { 357 - RecordType: "roaster", 358 - Action: "🏪 added a new roaster", 359 - Roaster: mockRoaster(), 360 - Author: mockProfile("user3", "User Three", ""), 361 - Timestamp: time.Date(2024, 1, 15, 11, 0, 0, 0, time.UTC), 362 - TimeAgo: "2 hours ago", 363 - }, 364 - { 365 - RecordType: "grinder", 366 - Action: "⚙️ added a new grinder", 367 - Grinder: mockGrinder(), 368 - Author: mockProfile("user4", "", ""), 369 - Timestamp: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), 370 - TimeAgo: "2.5 hours ago", 371 - }, 372 - { 373 - RecordType: "brewer", 374 - Action: "☕ added a new brewer", 375 - Brewer: mockBrewer(), 376 - Author: mockProfile("user5", "User Five", ""), 377 - Timestamp: time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC), 378 - TimeAgo: "3 hours ago", 379 - }, 380 - } 381 - 382 - result, err := execFeedTemplate(feedItems, true) 383 - if err != nil { 384 - t.Fatalf("Failed to execute template: %v", err) 385 - } 386 - shutter.SnapString(t, "mixed feed all types", formatHTML(result)) 387 - } 388 - 389 - func TestFeedTemplate_EmptyFeed_Snapshot(t *testing.T) { 390 - tests := []struct { 391 - name string 392 - feedItems []*feed.FeedItem 393 - isAuthenticated bool 394 - }{ 395 - { 396 - name: "empty feed authenticated", 397 - feedItems: []*feed.FeedItem{}, 398 - isAuthenticated: true, 399 - }, 400 - { 401 - name: "empty feed unauthenticated", 402 - feedItems: []*feed.FeedItem{}, 403 - isAuthenticated: false, 404 - }, 405 - { 406 - name: "nil feed", 407 - feedItems: nil, 408 - isAuthenticated: true, 409 - }, 410 - } 411 - 412 - for _, tt := range tests { 413 - t.Run(tt.name, func(t *testing.T) { 414 - result, err := execFeedTemplate(tt.feedItems, tt.isAuthenticated) 415 - if err != nil { 416 - t.Fatalf("Failed to execute template: %v", err) 417 - } 418 - shutter.SnapString(t, tt.name, formatHTML(result)) 419 - }) 420 - } 421 - } 422 - 423 - // Test security (URL sanitization) 424 - 425 - func TestFeedTemplate_SecurityURLs_Snapshot(t *testing.T) { 426 - tests := []struct { 427 - name string 428 - feedItem *feed.FeedItem 429 - }{ 430 - { 431 - name: "roaster with unsafe website URL", 432 - feedItem: &feed.FeedItem{ 433 - RecordType: "roaster", 434 - Action: "🏪 added a new roaster", 435 - Roaster: &models.Roaster{ 436 - RKey: "roaster999", 437 - Name: "Sketchy Roaster", 438 - Website: "javascript:alert('xss')", // Should be sanitized 439 - CreatedAt: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), 440 - }, 441 - Author: mockProfile("hacker", "Hacker", ""), 442 - Timestamp: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), 443 - TimeAgo: "1 minute ago", 444 - }, 445 - }, 446 - { 447 - name: "profile with unsafe avatar URL", 448 - feedItem: &feed.FeedItem{ 449 - RecordType: "bean", 450 - Action: "🫘 added a new bean", 451 - Bean: mockBean("Test Bean", "Test Origin", false), 452 - Author: mockProfile("badavatar", "Bad Avatar", "javascript:alert('xss')"), // Should be sanitized 453 - Timestamp: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), 454 - TimeAgo: "2 minutes ago", 455 - }, 456 - }, 457 - } 458 - 459 - for _, tt := range tests { 460 - t.Run(tt.name, func(t *testing.T) { 461 - result, err := execFeedTemplate([]*feed.FeedItem{tt.feedItem}, true) 462 - if err != nil { 463 - t.Fatalf("Failed to execute template: %v", err) 464 - } 465 - shutter.SnapString(t, tt.name, formatHTML(result)) 466 - }) 467 - } 468 - } 469 - 470 - // Test special characters 471 - 472 - func TestFeedTemplate_SpecialCharacters_Snapshot(t *testing.T) { 473 - feedItem := &feed.FeedItem{ 474 - RecordType: "brew", 475 - Action: "☕ added a new brew", 476 - Brew: &models.Brew{ 477 - RKey: "brew999", 478 - CreatedAt: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), 479 - Rating: 8, 480 - TastingNotes: "Notes with \"quotes\" and <html>tags</html> and 'single quotes'", 481 - Bean: &models.Bean{ 482 - Name: "Bean with & ampersand", 483 - Description: "Description with <script>alert('xss')</script>", 484 - }, 485 - }, 486 - Author: mockProfile("special.chars", "User & Co.", ""), 487 - Timestamp: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), 488 - TimeAgo: "5 seconds ago", 489 - } 490 - 491 - result, err := execFeedTemplate([]*feed.FeedItem{feedItem}, true) 492 - if err != nil { 493 - t.Fatalf("Failed to execute template: %v", err) 494 - } 495 - shutter.SnapString(t, "special characters in content", formatHTML(result)) 496 - }
-380
internal/bff/form_template_snapshot_test.go
··· 1 - package bff 2 - 3 - import ( 4 - "bytes" 5 - "html/template" 6 - "testing" 7 - "time" 8 - 9 - "arabica/internal/models" 10 - 11 - "github.com/ptdewey/shutter" 12 - ) 13 - 14 - func TestBrewForm_NewBrew_Snapshot(t *testing.T) { 15 - tests := []struct { 16 - name string 17 - data map[string]interface{} 18 - }{ 19 - { 20 - name: "new brew with populated selects", 21 - data: map[string]interface{}{ 22 - "Brew": nil, 23 - "Beans": []*models.Bean{ 24 - {RKey: "bean1", Name: "Ethiopian Yirgacheffe", Origin: "Ethiopia", RoastLevel: "Light"}, 25 - {RKey: "bean2", Name: "", Origin: "Colombia", RoastLevel: "Medium"}, 26 - }, 27 - "Grinders": []*models.Grinder{ 28 - {RKey: "grinder1", Name: "Baratza Encore"}, 29 - {RKey: "grinder2", Name: "Comandante C40"}, 30 - }, 31 - "Brewers": []*models.Brewer{ 32 - {RKey: "brewer1", Name: "Hario V60"}, 33 - {RKey: "brewer2", Name: "AeroPress"}, 34 - }, 35 - "Roasters": []*models.Roaster{ 36 - {RKey: "roaster1", Name: "Blue Bottle"}, 37 - {RKey: "roaster2", Name: "Counter Culture"}, 38 - }, 39 - }, 40 - }, 41 - { 42 - name: "new brew with empty selects", 43 - data: map[string]interface{}{ 44 - "Brew": nil, 45 - "Beans": []*models.Bean{}, 46 - "Grinders": []*models.Grinder{}, 47 - "Brewers": []*models.Brewer{}, 48 - "Roasters": []*models.Roaster{}, 49 - }, 50 - }, 51 - { 52 - name: "new brew with nil collections", 53 - data: map[string]interface{}{ 54 - "Brew": nil, 55 - "Beans": nil, 56 - "Grinders": nil, 57 - "Brewers": nil, 58 - "Roasters": nil, 59 - }, 60 - }, 61 - } 62 - 63 - tmpl := template.Must(template.New("test").Funcs(getTemplateFuncs()).ParseFiles( 64 - "../../templates/brew_form.tmpl", 65 - "../../templates/partials/new_bean_form.tmpl", 66 - "../../templates/partials/new_grinder_form.tmpl", 67 - "../../templates/partials/new_brewer_form.tmpl", 68 - )) 69 - 70 - for _, tt := range tests { 71 - t.Run(tt.name, func(t *testing.T) { 72 - var buf bytes.Buffer 73 - err := tmpl.ExecuteTemplate(&buf, "content", tt.data) 74 - if err != nil { 75 - t.Fatalf("template execution failed: %v", err) 76 - } 77 - shutter.SnapString(t, tt.name, formatHTML(buf.String())) 78 - }) 79 - } 80 - } 81 - 82 - func TestBrewForm_EditBrew_Snapshot(t *testing.T) { 83 - timestamp := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) 84 - 85 - tests := []struct { 86 - name string 87 - data map[string]interface{} 88 - }{ 89 - { 90 - name: "edit brew with complete data", 91 - data: map[string]interface{}{ 92 - "Brew": &BrewData{ 93 - Brew: &models.Brew{ 94 - RKey: "brew123", 95 - BeanRKey: "bean1", 96 - GrinderRKey: "grinder1", 97 - BrewerRKey: "brewer1", 98 - CoffeeAmount: 18, 99 - WaterAmount: 300, 100 - GrindSize: "18", 101 - Temperature: 93.5, 102 - TimeSeconds: 180, 103 - TastingNotes: "Bright citrus notes with floral aroma. Clean finish.", 104 - Rating: 8, 105 - CreatedAt: timestamp, 106 - Pours: []*models.Pour{ 107 - {PourNumber: 1, WaterAmount: 50, TimeSeconds: 30}, 108 - {PourNumber: 2, WaterAmount: 100, TimeSeconds: 45}, 109 - {PourNumber: 3, WaterAmount: 150, TimeSeconds: 60}, 110 - }, 111 - }, 112 - PoursJSON: `[{"pourNumber":1,"waterAmount":50,"timeSeconds":30},{"pourNumber":2,"waterAmount":100,"timeSeconds":45},{"pourNumber":3,"waterAmount":150,"timeSeconds":60}]`, 113 - }, 114 - "Beans": []*models.Bean{ 115 - {RKey: "bean1", Name: "Ethiopian Yirgacheffe", Origin: "Ethiopia", RoastLevel: "Light"}, 116 - {RKey: "bean2", Name: "Colombian Supremo", Origin: "Colombia", RoastLevel: "Medium"}, 117 - }, 118 - "Grinders": []*models.Grinder{ 119 - {RKey: "grinder1", Name: "Baratza Encore"}, 120 - {RKey: "grinder2", Name: "Comandante C40"}, 121 - }, 122 - "Brewers": []*models.Brewer{ 123 - {RKey: "brewer1", Name: "Hario V60"}, 124 - {RKey: "brewer2", Name: "AeroPress"}, 125 - }, 126 - "Roasters": []*models.Roaster{ 127 - {RKey: "roaster1", Name: "Blue Bottle"}, 128 - }, 129 - }, 130 - }, 131 - { 132 - name: "edit brew with minimal data", 133 - data: map[string]interface{}{ 134 - "Brew": &BrewData{ 135 - Brew: &models.Brew{ 136 - RKey: "brew456", 137 - BeanRKey: "bean1", 138 - Rating: 5, 139 - CreatedAt: timestamp, 140 - Pours: nil, 141 - }, 142 - PoursJSON: "", 143 - }, 144 - "Beans": []*models.Bean{ 145 - {RKey: "bean1", Name: "House Blend", Origin: "Brazil", RoastLevel: "Medium"}, 146 - }, 147 - "Grinders": nil, 148 - "Brewers": nil, 149 - "Roasters": nil, 150 - }, 151 - }, 152 - { 153 - name: "edit brew with pours json", 154 - data: map[string]interface{}{ 155 - "Brew": &BrewData{ 156 - Brew: &models.Brew{ 157 - RKey: "brew789", 158 - BeanRKey: "bean1", 159 - Rating: 7, 160 - CreatedAt: timestamp, 161 - Pours: []*models.Pour{ 162 - {PourNumber: 1, WaterAmount: 60, TimeSeconds: 30}, 163 - {PourNumber: 2, WaterAmount: 120, TimeSeconds: 60}, 164 - }, 165 - }, 166 - PoursJSON: `[{"pourNumber":1,"waterAmount":60,"timeSeconds":30},{"pourNumber":2,"waterAmount":120,"timeSeconds":60}]`, 167 - }, 168 - "Beans": []*models.Bean{ 169 - {RKey: "bean1", Origin: "Kenya", RoastLevel: "Light"}, 170 - }, 171 - "Grinders": []*models.Grinder{}, 172 - "Brewers": []*models.Brewer{}, 173 - "Roasters": []*models.Roaster{}, 174 - }, 175 - }, 176 - { 177 - name: "edit brew without loaded collections", 178 - data: map[string]interface{}{ 179 - "Brew": &BrewData{ 180 - Brew: &models.Brew{ 181 - RKey: "brew999", 182 - BeanRKey: "bean1", 183 - GrinderRKey: "grinder1", 184 - BrewerRKey: "brewer1", 185 - Rating: 6, 186 - CreatedAt: timestamp, 187 - }, 188 - PoursJSON: "", 189 - }, 190 - "Beans": nil, 191 - "Grinders": nil, 192 - "Brewers": nil, 193 - "Roasters": nil, 194 - }, 195 - }, 196 - } 197 - 198 - tmpl := template.Must(template.New("test").Funcs(getTemplateFuncs()).ParseFiles( 199 - "../../templates/brew_form.tmpl", 200 - "../../templates/partials/new_bean_form.tmpl", 201 - "../../templates/partials/new_grinder_form.tmpl", 202 - "../../templates/partials/new_brewer_form.tmpl", 203 - )) 204 - 205 - for _, tt := range tests { 206 - t.Run(tt.name, func(t *testing.T) { 207 - var buf bytes.Buffer 208 - err := tmpl.ExecuteTemplate(&buf, "content", tt.data) 209 - if err != nil { 210 - t.Fatalf("template execution failed: %v", err) 211 - } 212 - shutter.SnapString(t, tt.name, formatHTML(buf.String())) 213 - }) 214 - } 215 - } 216 - 217 - func TestNewBeanForm_Snapshot(t *testing.T) { 218 - tests := []struct { 219 - name string 220 - data map[string]interface{} 221 - }{ 222 - { 223 - name: "bean form with roasters", 224 - data: map[string]interface{}{ 225 - "Roasters": []*models.Roaster{ 226 - {RKey: "roaster1", Name: "Blue Bottle Coffee"}, 227 - {RKey: "roaster2", Name: "Counter Culture Coffee"}, 228 - {RKey: "roaster3", Name: "Stumptown Coffee Roasters"}, 229 - }, 230 - }, 231 - }, 232 - { 233 - name: "bean form without roasters", 234 - data: map[string]interface{}{ 235 - "Roasters": []*models.Roaster{}, 236 - }, 237 - }, 238 - { 239 - name: "bean form with nil roasters", 240 - data: map[string]interface{}{ 241 - "Roasters": nil, 242 - }, 243 - }, 244 - } 245 - 246 - tmpl := template.Must(template.New("test").Funcs(getTemplateFuncs()).ParseFiles( 247 - "../../templates/partials/new_bean_form.tmpl", 248 - )) 249 - 250 - for _, tt := range tests { 251 - t.Run(tt.name, func(t *testing.T) { 252 - var buf bytes.Buffer 253 - err := tmpl.ExecuteTemplate(&buf, "new_bean_form", tt.data) 254 - if err != nil { 255 - t.Fatalf("template execution failed: %v", err) 256 - } 257 - shutter.SnapString(t, tt.name, formatHTML(buf.String())) 258 - }) 259 - } 260 - } 261 - 262 - func TestNewGrinderForm_Snapshot(t *testing.T) { 263 - tmpl := template.Must(template.New("test").Funcs(getTemplateFuncs()).ParseFiles( 264 - "../../templates/partials/new_grinder_form.tmpl", 265 - )) 266 - 267 - t.Run("grinder form renders", func(t *testing.T) { 268 - var buf bytes.Buffer 269 - err := tmpl.ExecuteTemplate(&buf, "new_grinder_form", nil) 270 - if err != nil { 271 - t.Fatalf("template execution failed: %v", err) 272 - } 273 - shutter.SnapString(t, "grinder_form_renders", formatHTML(buf.String())) 274 - }) 275 - } 276 - 277 - func TestNewBrewerForm_Snapshot(t *testing.T) { 278 - tmpl := template.Must(template.New("test").Funcs(getTemplateFuncs()).ParseFiles( 279 - "../../templates/partials/new_brewer_form.tmpl", 280 - )) 281 - 282 - t.Run("brewer form renders", func(t *testing.T) { 283 - var buf bytes.Buffer 284 - err := tmpl.ExecuteTemplate(&buf, "new_brewer_form", nil) 285 - if err != nil { 286 - t.Fatalf("template execution failed: %v", err) 287 - } 288 - shutter.SnapString(t, "brewer_form_renders", formatHTML(buf.String())) 289 - }) 290 - } 291 - 292 - func TestNewRoasterForm_Snapshot(t *testing.T) { 293 - tmpl := template.Must(template.New("test").Funcs(getTemplateFuncs()).ParseFiles( 294 - "../../templates/partials/new_roaster_form.tmpl", 295 - )) 296 - 297 - t.Run("roaster form renders", func(t *testing.T) { 298 - var buf bytes.Buffer 299 - err := tmpl.ExecuteTemplate(&buf, "new_roaster_form", nil) 300 - if err != nil { 301 - t.Fatalf("template execution failed: %v", err) 302 - } 303 - shutter.SnapString(t, "roaster_form_renders", formatHTML(buf.String())) 304 - }) 305 - } 306 - 307 - func TestBrewForm_SpecialCharacters_Snapshot(t *testing.T) { 308 - timestamp := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) 309 - 310 - tests := []struct { 311 - name string 312 - data map[string]interface{} 313 - }{ 314 - { 315 - name: "brew with html in tasting notes", 316 - data: map[string]interface{}{ 317 - "Brew": &BrewData{ 318 - Brew: &models.Brew{ 319 - RKey: "brew1", 320 - BeanRKey: "bean1", 321 - TastingNotes: "<script>alert('xss')</script>Bright & fruity, \"amazing\" taste", 322 - Rating: 8, 323 - CreatedAt: timestamp, 324 - }, 325 - PoursJSON: "", 326 - }, 327 - "Beans": []*models.Bean{ 328 - {RKey: "bean1", Name: "Test <strong>Bean</strong>", Origin: "Ethiopia", RoastLevel: "Light"}, 329 - }, 330 - "Grinders": []*models.Grinder{}, 331 - "Brewers": []*models.Brewer{}, 332 - "Roasters": []*models.Roaster{}, 333 - }, 334 - }, 335 - { 336 - name: "brew with unicode characters", 337 - data: map[string]interface{}{ 338 - "Brew": &BrewData{ 339 - Brew: &models.Brew{ 340 - RKey: "brew2", 341 - BeanRKey: "bean1", 342 - TastingNotes: "日本のコーヒー 🇯🇵 - フルーティーで酸味が強い\n\nЯркий вкус с цитрусовыми нотами\n\nCafé con notas de caramelo", 343 - GrindSize: "中挽き (medium)", 344 - Rating: 9, 345 - CreatedAt: timestamp, 346 - }, 347 - PoursJSON: "", 348 - }, 349 - "Beans": []*models.Bean{ 350 - {RKey: "bean1", Name: "Café Especial™", Origin: "Costa Rica", RoastLevel: "Medium"}, 351 - }, 352 - "Grinders": []*models.Grinder{ 353 - {RKey: "grinder1", Name: "Comandante® C40 MK3"}, 354 - }, 355 - "Brewers": []*models.Brewer{ 356 - {RKey: "brewer1", Name: "Hario V60 (02)"}, 357 - }, 358 - "Roasters": []*models.Roaster{}, 359 - }, 360 - }, 361 - } 362 - 363 - tmpl := template.Must(template.New("test").Funcs(getTemplateFuncs()).ParseFiles( 364 - "../../templates/brew_form.tmpl", 365 - "../../templates/partials/new_bean_form.tmpl", 366 - "../../templates/partials/new_grinder_form.tmpl", 367 - "../../templates/partials/new_brewer_form.tmpl", 368 - )) 369 - 370 - for _, tt := range tests { 371 - t.Run(tt.name, func(t *testing.T) { 372 - var buf bytes.Buffer 373 - err := tmpl.ExecuteTemplate(&buf, "content", tt.data) 374 - if err != nil { 375 - t.Fatalf("template execution failed: %v", err) 376 - } 377 - shutter.SnapString(t, tt.name, formatHTML(buf.String())) 378 - }) 379 - } 380 - }
-266
internal/bff/helpers.go
··· 1 - // Package bff provides Backend-For-Frontend functionality including 2 - // template rendering and helper functions for the web UI. 3 - package bff 4 - 5 - import ( 6 - "encoding/json" 7 - "fmt" 8 - "net/url" 9 - "strings" 10 - 11 - "arabica/internal/models" 12 - ) 13 - 14 - // FormatTemp formats a temperature value with unit detection. 15 - // Returns "N/A" if temp is 0, otherwise determines C/F based on >100 threshold. 16 - func FormatTemp(temp float64) string { 17 - if temp == 0 { 18 - return "N/A" 19 - } 20 - 21 - // REFACTOR: This probably isn't the best way to deal with units 22 - unit := 'C' 23 - if temp > 100 { 24 - unit = 'F' 25 - } 26 - 27 - return fmt.Sprintf("%.1f°%c", temp, unit) 28 - } 29 - 30 - // FormatTempValue formats a temperature for use in input fields (numeric value only). 31 - func FormatTempValue(temp float64) string { 32 - return fmt.Sprintf("%.1f", temp) 33 - } 34 - 35 - // FormatTime formats seconds into a human-readable time string (e.g., "3m 30s"). 36 - // Returns "N/A" if seconds is 0. 37 - func FormatTime(seconds int) string { 38 - if seconds == 0 { 39 - return "N/A" 40 - } 41 - if seconds < 60 { 42 - return fmt.Sprintf("%ds", seconds) 43 - } 44 - minutes := seconds / 60 45 - remaining := seconds % 60 46 - if remaining == 0 { 47 - return fmt.Sprintf("%dm", minutes) 48 - } 49 - return fmt.Sprintf("%dm %ds", minutes, remaining) 50 - } 51 - 52 - // FormatRating formats a rating as "X/10". 53 - // Returns "N/A" if rating is 0. 54 - func FormatRating(rating int) string { 55 - if rating == 0 { 56 - return "N/A" 57 - } 58 - return fmt.Sprintf("%d/10", rating) 59 - } 60 - 61 - // FormatID converts an int to string. 62 - func FormatID(id int) string { 63 - return fmt.Sprintf("%d", id) 64 - } 65 - 66 - // FormatInt converts an int to string. 67 - func FormatInt(val int) string { 68 - return fmt.Sprintf("%d", val) 69 - } 70 - 71 - // FormatRoasterID formats a nullable roaster ID. 72 - // Returns "null" if id is nil, otherwise the ID as a string. 73 - func FormatRoasterID(id *int) string { 74 - if id == nil { 75 - return "null" 76 - } 77 - return fmt.Sprintf("%d", *id) 78 - } 79 - 80 - // PoursToJSON serializes a slice of pours to JSON for use in JavaScript. 81 - func PoursToJSON(pours []*models.Pour) string { 82 - if len(pours) == 0 { 83 - return "[]" 84 - } 85 - 86 - type pourData struct { 87 - Water int `json:"water"` 88 - Time int `json:"time"` 89 - } 90 - 91 - data := make([]pourData, len(pours)) 92 - for i, p := range pours { 93 - data[i] = pourData{ 94 - Water: p.WaterAmount, 95 - Time: p.TimeSeconds, 96 - } 97 - } 98 - 99 - jsonBytes, err := json.Marshal(data) 100 - if err != nil { 101 - return "[]" 102 - } 103 - 104 - return string(jsonBytes) 105 - } 106 - 107 - // Ptr returns a pointer to the given value. 108 - func Ptr[T any](v T) *T { 109 - return &v 110 - } 111 - 112 - // PtrEquals checks if a pointer equals a value. 113 - // Returns false if the pointer is nil. 114 - func PtrEquals[T comparable](p *T, val T) bool { 115 - if p == nil { 116 - return false 117 - } 118 - return *p == val 119 - } 120 - 121 - // PtrValue returns the dereferenced value of a pointer, or zero value if nil. 122 - func PtrValue[T any](p *T) T { 123 - if p == nil { 124 - var zero T 125 - return zero 126 - } 127 - return *p 128 - } 129 - 130 - // Iterate returns a slice of ints from 0 to n-1, useful for range loops in templates. 131 - func Iterate(n int) []int { 132 - result := make([]int, n) 133 - for i := range result { 134 - result[i] = i 135 - } 136 - return result 137 - } 138 - 139 - // IterateRemaining returns a slice of ints for the remaining count, useful for star ratings. 140 - // For example, IterateRemaining(3, 5) returns [0, 1] for the 2 remaining empty stars. 141 - func IterateRemaining(current, total int) []int { 142 - remaining := total - current 143 - if remaining <= 0 { 144 - return nil 145 - } 146 - result := make([]int, remaining) 147 - for i := range result { 148 - result[i] = i 149 - } 150 - return result 151 - } 152 - 153 - // HasTemp returns true if temperature is greater than zero 154 - func HasTemp(temp float64) bool { 155 - return temp > 0 156 - } 157 - 158 - // HasValue returns true if the int value is greater than zero 159 - func HasValue(val int) bool { 160 - return val > 0 161 - } 162 - 163 - // SafeAvatarURL validates and sanitizes avatar URLs to prevent XSS and other attacks. 164 - // Only allows HTTPS URLs from trusted domains (Bluesky CDN) or relative paths. 165 - // Returns a safe URL or empty string if invalid. 166 - func SafeAvatarURL(avatarURL string) string { 167 - if avatarURL == "" { 168 - return "" 169 - } 170 - 171 - // Allow relative paths (e.g., /static/icon-placeholder.svg) 172 - if strings.HasPrefix(avatarURL, "/") { 173 - // Basic validation - must start with /static/ 174 - if strings.HasPrefix(avatarURL, "/static/") { 175 - return avatarURL 176 - } 177 - return "" 178 - } 179 - 180 - // Parse the URL 181 - parsedURL, err := url.Parse(avatarURL) 182 - if err != nil { 183 - return "" 184 - } 185 - 186 - // Only allow HTTPS scheme 187 - if parsedURL.Scheme != "https" { 188 - return "" 189 - } 190 - 191 - // Whitelist trusted domains for avatar images 192 - // Bluesky uses cdn.bsky.app for avatars 193 - trustedDomains := []string{ 194 - "cdn.bsky.app", 195 - "av-cdn.bsky.app", 196 - } 197 - 198 - hostLower := strings.ToLower(parsedURL.Host) 199 - for _, domain := range trustedDomains { 200 - if hostLower == domain || strings.HasSuffix(hostLower, "."+domain) { 201 - return avatarURL 202 - } 203 - } 204 - 205 - // URL is not from a trusted domain 206 - return "" 207 - } 208 - 209 - // SafeWebsiteURL validates and sanitizes website URLs for display. 210 - // Only allows HTTP/HTTPS URLs and performs basic validation. 211 - // Returns a safe URL or empty string if invalid. 212 - func SafeWebsiteURL(websiteURL string) string { 213 - if websiteURL == "" { 214 - return "" 215 - } 216 - 217 - // Parse the URL 218 - parsedURL, err := url.Parse(websiteURL) 219 - if err != nil { 220 - return "" 221 - } 222 - 223 - // Only allow HTTP and HTTPS schemes 224 - scheme := strings.ToLower(parsedURL.Scheme) 225 - if scheme != "http" && scheme != "https" { 226 - return "" 227 - } 228 - 229 - // Basic hostname validation - must have at least one dot 230 - if !strings.Contains(parsedURL.Host, ".") { 231 - return "" 232 - } 233 - 234 - return websiteURL 235 - } 236 - 237 - // EscapeJS escapes a string for safe use in JavaScript string literals. 238 - // Handles newlines, quotes, backslashes, and other special characters. 239 - func EscapeJS(s string) string { 240 - // Replace special characters that would break JavaScript strings 241 - s = strings.ReplaceAll(s, "\\", "\\\\") // Must be first 242 - s = strings.ReplaceAll(s, "'", "\\'") 243 - s = strings.ReplaceAll(s, "\"", "\\\"") 244 - s = strings.ReplaceAll(s, "\n", "\\n") 245 - s = strings.ReplaceAll(s, "\r", "\\r") 246 - s = strings.ReplaceAll(s, "\t", "\\t") 247 - return s 248 - } 249 - 250 - // Dict creates a map from alternating key-value arguments. 251 - // Useful for passing multiple parameters to sub-templates in Go templates. 252 - // Example: {{template "foo" dict "Key1" .Value1 "Key2" .Value2}} 253 - func Dict(values ...interface{}) (map[string]interface{}, error) { 254 - if len(values)%2 != 0 { 255 - return nil, fmt.Errorf("dict requires an even number of arguments") 256 - } 257 - dict := make(map[string]interface{}, len(values)/2) 258 - for i := 0; i < len(values); i += 2 { 259 - key, ok := values[i].(string) 260 - if !ok { 261 - return nil, fmt.Errorf("dict keys must be strings") 262 - } 263 - dict[key] = values[i+1] 264 - } 265 - return dict, nil 266 - }
-306
internal/bff/helpers_test.go
··· 1 - package bff 2 - 3 - import ( 4 - "testing" 5 - 6 - "arabica/internal/models" 7 - ) 8 - 9 - func TestFormatTemp(t *testing.T) { 10 - tests := []struct { 11 - name string 12 - temp float64 13 - expected string 14 - }{ 15 - {"zero returns N/A", 0, "N/A"}, 16 - {"celsius range", 93.5, "93.5°C"}, 17 - {"celsius whole number", 90.0, "90.0°C"}, 18 - {"celsius at 100", 100.0, "100.0°C"}, 19 - {"fahrenheit range", 200.0, "200.0°F"}, 20 - {"fahrenheit at 212", 212.0, "212.0°F"}, 21 - {"low temp celsius", 20.5, "20.5°C"}, 22 - {"just over 100 is fahrenheit", 100.1, "100.1°F"}, 23 - } 24 - 25 - for _, tt := range tests { 26 - t.Run(tt.name, func(t *testing.T) { 27 - got := FormatTemp(tt.temp) 28 - if got != tt.expected { 29 - t.Errorf("FormatTemp(%v) = %q, want %q", tt.temp, got, tt.expected) 30 - } 31 - }) 32 - } 33 - } 34 - 35 - func TestFormatTempValue(t *testing.T) { 36 - tests := []struct { 37 - name string 38 - temp float64 39 - expected string 40 - }{ 41 - {"zero", 0, "0.0"}, 42 - {"whole number", 93.0, "93.0"}, 43 - {"decimal", 93.5, "93.5"}, 44 - {"high precision rounds", 93.55, "93.5"}, 45 - } 46 - 47 - for _, tt := range tests { 48 - t.Run(tt.name, func(t *testing.T) { 49 - got := FormatTempValue(tt.temp) 50 - if got != tt.expected { 51 - t.Errorf("FormatTempValue(%v) = %q, want %q", tt.temp, got, tt.expected) 52 - } 53 - }) 54 - } 55 - } 56 - 57 - func TestFormatTime(t *testing.T) { 58 - tests := []struct { 59 - name string 60 - seconds int 61 - expected string 62 - }{ 63 - {"zero returns N/A", 0, "N/A"}, 64 - {"seconds only", 30, "30s"}, 65 - {"exactly one minute", 60, "1m"}, 66 - {"minutes and seconds", 90, "1m 30s"}, 67 - {"multiple minutes", 180, "3m"}, 68 - {"multiple minutes and seconds", 185, "3m 5s"}, 69 - {"large time", 3661, "61m 1s"}, 70 - } 71 - 72 - for _, tt := range tests { 73 - t.Run(tt.name, func(t *testing.T) { 74 - got := FormatTime(tt.seconds) 75 - if got != tt.expected { 76 - t.Errorf("FormatTime(%v) = %q, want %q", tt.seconds, got, tt.expected) 77 - } 78 - }) 79 - } 80 - } 81 - 82 - func TestFormatRating(t *testing.T) { 83 - tests := []struct { 84 - name string 85 - rating int 86 - expected string 87 - }{ 88 - {"zero returns N/A", 0, "N/A"}, 89 - {"rating 1", 1, "1/10"}, 90 - {"rating 5", 5, "5/10"}, 91 - {"rating 10", 10, "10/10"}, 92 - } 93 - 94 - for _, tt := range tests { 95 - t.Run(tt.name, func(t *testing.T) { 96 - got := FormatRating(tt.rating) 97 - if got != tt.expected { 98 - t.Errorf("FormatRating(%v) = %q, want %q", tt.rating, got, tt.expected) 99 - } 100 - }) 101 - } 102 - } 103 - 104 - func TestFormatID(t *testing.T) { 105 - tests := []struct { 106 - name string 107 - id int 108 - expected string 109 - }{ 110 - {"zero", 0, "0"}, 111 - {"positive", 123, "123"}, 112 - {"large number", 99999, "99999"}, 113 - } 114 - 115 - for _, tt := range tests { 116 - t.Run(tt.name, func(t *testing.T) { 117 - got := FormatID(tt.id) 118 - if got != tt.expected { 119 - t.Errorf("FormatID(%v) = %q, want %q", tt.id, got, tt.expected) 120 - } 121 - }) 122 - } 123 - } 124 - 125 - func TestFormatInt(t *testing.T) { 126 - tests := []struct { 127 - name string 128 - val int 129 - expected string 130 - }{ 131 - {"zero", 0, "0"}, 132 - {"positive", 42, "42"}, 133 - {"negative", -5, "-5"}, 134 - } 135 - 136 - for _, tt := range tests { 137 - t.Run(tt.name, func(t *testing.T) { 138 - got := FormatInt(tt.val) 139 - if got != tt.expected { 140 - t.Errorf("FormatInt(%v) = %q, want %q", tt.val, got, tt.expected) 141 - } 142 - }) 143 - } 144 - } 145 - 146 - func TestFormatRoasterID(t *testing.T) { 147 - t.Run("nil returns null", func(t *testing.T) { 148 - got := FormatRoasterID(nil) 149 - if got != "null" { 150 - t.Errorf("FormatRoasterID(nil) = %q, want %q", got, "null") 151 - } 152 - }) 153 - 154 - t.Run("valid pointer", func(t *testing.T) { 155 - id := 123 156 - got := FormatRoasterID(&id) 157 - if got != "123" { 158 - t.Errorf("FormatRoasterID(&123) = %q, want %q", got, "123") 159 - } 160 - }) 161 - 162 - t.Run("zero pointer", func(t *testing.T) { 163 - id := 0 164 - got := FormatRoasterID(&id) 165 - if got != "0" { 166 - t.Errorf("FormatRoasterID(&0) = %q, want %q", got, "0") 167 - } 168 - }) 169 - } 170 - 171 - func TestPoursToJSON(t *testing.T) { 172 - tests := []struct { 173 - name string 174 - pours []*models.Pour 175 - expected string 176 - }{ 177 - { 178 - name: "empty pours", 179 - pours: []*models.Pour{}, 180 - expected: "[]", 181 - }, 182 - { 183 - name: "nil pours", 184 - pours: nil, 185 - expected: "[]", 186 - }, 187 - { 188 - name: "single pour", 189 - pours: []*models.Pour{ 190 - {WaterAmount: 50, TimeSeconds: 30}, 191 - }, 192 - expected: `[{"water":50,"time":30}]`, 193 - }, 194 - { 195 - name: "multiple pours", 196 - pours: []*models.Pour{ 197 - {WaterAmount: 50, TimeSeconds: 30}, 198 - {WaterAmount: 100, TimeSeconds: 60}, 199 - {WaterAmount: 150, TimeSeconds: 90}, 200 - }, 201 - expected: `[{"water":50,"time":30},{"water":100,"time":60},{"water":150,"time":90}]`, 202 - }, 203 - { 204 - name: "zero values", 205 - pours: []*models.Pour{ 206 - {WaterAmount: 0, TimeSeconds: 0}, 207 - }, 208 - expected: `[{"water":0,"time":0}]`, 209 - }, 210 - } 211 - 212 - for _, tt := range tests { 213 - t.Run(tt.name, func(t *testing.T) { 214 - got := PoursToJSON(tt.pours) 215 - if got != tt.expected { 216 - t.Errorf("PoursToJSON() = %q, want %q", got, tt.expected) 217 - } 218 - }) 219 - } 220 - } 221 - 222 - func TestPtr(t *testing.T) { 223 - t.Run("int", func(t *testing.T) { 224 - p := Ptr(42) 225 - if *p != 42 { 226 - t.Errorf("Ptr(42) = %v, want 42", *p) 227 - } 228 - }) 229 - 230 - t.Run("string", func(t *testing.T) { 231 - p := Ptr("hello") 232 - if *p != "hello" { 233 - t.Errorf("Ptr(\"hello\") = %v, want \"hello\"", *p) 234 - } 235 - }) 236 - 237 - t.Run("zero value", func(t *testing.T) { 238 - p := Ptr(0) 239 - if *p != 0 { 240 - t.Errorf("Ptr(0) = %v, want 0", *p) 241 - } 242 - }) 243 - } 244 - 245 - func TestPtrEquals(t *testing.T) { 246 - t.Run("nil pointer returns false", func(t *testing.T) { 247 - var p *int = nil 248 - if PtrEquals(p, 42) { 249 - t.Error("PtrEquals(nil, 42) should be false") 250 - } 251 - }) 252 - 253 - t.Run("matching value returns true", func(t *testing.T) { 254 - val := 42 255 - if !PtrEquals(&val, 42) { 256 - t.Error("PtrEquals(&42, 42) should be true") 257 - } 258 - }) 259 - 260 - t.Run("non-matching value returns false", func(t *testing.T) { 261 - val := 42 262 - if PtrEquals(&val, 99) { 263 - t.Error("PtrEquals(&42, 99) should be false") 264 - } 265 - }) 266 - 267 - t.Run("string comparison", func(t *testing.T) { 268 - s := "hello" 269 - if !PtrEquals(&s, "hello") { 270 - t.Error("PtrEquals(&\"hello\", \"hello\") should be true") 271 - } 272 - if PtrEquals(&s, "world") { 273 - t.Error("PtrEquals(&\"hello\", \"world\") should be false") 274 - } 275 - }) 276 - } 277 - 278 - func TestPtrValue(t *testing.T) { 279 - t.Run("nil int returns zero", func(t *testing.T) { 280 - var p *int = nil 281 - if PtrValue(p) != 0 { 282 - t.Errorf("PtrValue(nil) = %v, want 0", PtrValue(p)) 283 - } 284 - }) 285 - 286 - t.Run("valid int returns value", func(t *testing.T) { 287 - val := 42 288 - if PtrValue(&val) != 42 { 289 - t.Errorf("PtrValue(&42) = %v, want 42", PtrValue(&val)) 290 - } 291 - }) 292 - 293 - t.Run("nil string returns empty", func(t *testing.T) { 294 - var p *string = nil 295 - if PtrValue(p) != "" { 296 - t.Errorf("PtrValue(nil string) = %v, want \"\"", PtrValue(p)) 297 - } 298 - }) 299 - 300 - t.Run("valid string returns value", func(t *testing.T) { 301 - s := "hello" 302 - if PtrValue(&s) != "hello" { 303 - t.Errorf("PtrValue(&\"hello\") = %v, want \"hello\"", PtrValue(&s)) 304 - } 305 - }) 306 - }
-380
internal/bff/partial_template_snapshot_test.go
··· 1 - package bff 2 - 3 - import ( 4 - "bytes" 5 - "html/template" 6 - "testing" 7 - "time" 8 - 9 - "arabica/internal/models" 10 - 11 - "github.com/ptdewey/shutter" 12 - ) 13 - 14 - func TestBrewListContent_Snapshot(t *testing.T) { 15 - timestamp := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) 16 - 17 - tests := []struct { 18 - name string 19 - data map[string]interface{} 20 - }{ 21 - { 22 - name: "empty brew list own profile", 23 - data: map[string]interface{}{ 24 - "Brews": []*models.Brew{}, 25 - "IsOwnProfile": true, 26 - }, 27 - }, 28 - { 29 - name: "empty brew list other profile", 30 - data: map[string]interface{}{ 31 - "Brews": []*models.Brew{}, 32 - "IsOwnProfile": false, 33 - }, 34 - }, 35 - { 36 - name: "brew list with complete data", 37 - data: map[string]interface{}{ 38 - "Brews": []*models.Brew{ 39 - { 40 - RKey: "brew1", 41 - BeanRKey: "bean1", 42 - CoffeeAmount: 18, 43 - WaterAmount: 250, 44 - Temperature: 93.0, 45 - TimeSeconds: 180, 46 - GrindSize: "Medium-fine", 47 - Rating: 8, 48 - TastingNotes: "Bright citrus notes with floral aroma. Clean finish.", 49 - CreatedAt: timestamp, 50 - Bean: &models.Bean{ 51 - Name: "Ethiopian Yirgacheffe", 52 - Origin: "Ethiopia", 53 - RoastLevel: "Light", 54 - Roaster: &models.Roaster{ 55 - Name: "Onyx Coffee Lab", 56 - }, 57 - }, 58 - GrinderObj: &models.Grinder{ 59 - Name: "Comandante C40", 60 - }, 61 - BrewerObj: &models.Brewer{ 62 - Name: "Hario V60", 63 - }, 64 - Pours: []*models.Pour{ 65 - {PourNumber: 1, WaterAmount: 50, TimeSeconds: 30}, 66 - {PourNumber: 2, WaterAmount: 100, TimeSeconds: 45}, 67 - {PourNumber: 3, WaterAmount: 100, TimeSeconds: 60}, 68 - }, 69 - }, 70 - { 71 - RKey: "brew2", 72 - BeanRKey: "bean2", 73 - Rating: 6, 74 - CreatedAt: timestamp.Add(-24 * time.Hour), 75 - Bean: &models.Bean{ 76 - Origin: "Colombia", 77 - RoastLevel: "Medium", 78 - }, 79 - Method: "AeroPress", 80 - }, 81 - }, 82 - "IsOwnProfile": true, 83 - }, 84 - }, 85 - { 86 - name: "brew list minimal data", 87 - data: map[string]interface{}{ 88 - "Brews": []*models.Brew{ 89 - { 90 - RKey: "brew3", 91 - BeanRKey: "bean3", 92 - CreatedAt: timestamp, 93 - }, 94 - }, 95 - "IsOwnProfile": false, 96 - }, 97 - }, 98 - } 99 - 100 - tmpl := template.Must(template.New("test").Funcs(getTemplateFuncs()).ParseFiles( 101 - "../../templates/partials/brew_list_content.tmpl", 102 - )) 103 - 104 - for _, tt := range tests { 105 - t.Run(tt.name, func(t *testing.T) { 106 - var buf bytes.Buffer 107 - err := tmpl.ExecuteTemplate(&buf, "brew_list_content", tt.data) 108 - if err != nil { 109 - t.Fatalf("template execution failed: %v", err) 110 - } 111 - shutter.SnapString(t, tt.name, formatHTML(buf.String())) 112 - }) 113 - } 114 - } 115 - 116 - func TestManageContent_BeansTab_Snapshot(t *testing.T) { 117 - tests := []struct { 118 - name string 119 - data map[string]interface{} 120 - }{ 121 - { 122 - name: "beans empty", 123 - data: map[string]interface{}{ 124 - "Beans": []*models.Bean{}, 125 - "Roasters": []*models.Roaster{}, 126 - }, 127 - }, 128 - { 129 - name: "beans with roaster", 130 - data: map[string]interface{}{ 131 - "Beans": []*models.Bean{ 132 - { 133 - RKey: "bean1", 134 - Name: "Ethiopian Yirgacheffe", 135 - Origin: "Ethiopia", 136 - RoastLevel: "Light", 137 - Process: "Washed", 138 - Description: "Bright and fruity with notes of blueberry", 139 - RoasterRKey: "roaster1", 140 - Roaster: &models.Roaster{ 141 - RKey: "roaster1", 142 - Name: "Onyx Coffee Lab", 143 - }, 144 - }, 145 - { 146 - RKey: "bean2", 147 - Origin: "Colombia", 148 - RoastLevel: "Medium", 149 - }, 150 - }, 151 - "Roasters": []*models.Roaster{ 152 - {RKey: "roaster1", Name: "Onyx Coffee Lab"}, 153 - {RKey: "roaster2", Name: "Counter Culture"}, 154 - }, 155 - }, 156 - }, 157 - } 158 - 159 - tmpl := template.Must(template.New("test").Funcs(getTemplateFuncs()).ParseFiles( 160 - "../../templates/partials/manage_content.tmpl", 161 - )) 162 - 163 - for _, tt := range tests { 164 - t.Run(tt.name, func(t *testing.T) { 165 - var buf bytes.Buffer 166 - err := tmpl.ExecuteTemplate(&buf, "manage_content", tt.data) 167 - if err != nil { 168 - t.Fatalf("template execution failed: %v", err) 169 - } 170 - shutter.SnapString(t, tt.name, formatHTML(buf.String())) 171 - }) 172 - } 173 - } 174 - 175 - func TestManageContent_RoastersTab_Snapshot(t *testing.T) { 176 - tests := []struct { 177 - name string 178 - data map[string]interface{} 179 - }{ 180 - { 181 - name: "roasters empty", 182 - data: map[string]interface{}{ 183 - "Roasters": []*models.Roaster{}, 184 - }, 185 - }, 186 - { 187 - name: "roasters with data", 188 - data: map[string]interface{}{ 189 - "Roasters": []*models.Roaster{ 190 - { 191 - RKey: "roaster1", 192 - Name: "Onyx Coffee Lab", 193 - Location: "Bentonville, AR", 194 - Website: "https://onyxcoffeelab.com", 195 - }, 196 - { 197 - RKey: "roaster2", 198 - Name: "Counter Culture Coffee", 199 - }, 200 - }, 201 - }, 202 - }, 203 - { 204 - name: "roasters with unsafe url", 205 - data: map[string]interface{}{ 206 - "Roasters": []*models.Roaster{ 207 - { 208 - RKey: "roaster1", 209 - Name: "Test Roaster", 210 - Location: "Test Location", 211 - Website: "javascript:alert('xss')", 212 - }, 213 - }, 214 - }, 215 - }, 216 - } 217 - 218 - tmpl := template.Must(template.New("test").Funcs(getTemplateFuncs()).ParseFiles( 219 - "../../templates/partials/manage_content.tmpl", 220 - )) 221 - 222 - for _, tt := range tests { 223 - t.Run(tt.name, func(t *testing.T) { 224 - var buf bytes.Buffer 225 - err := tmpl.ExecuteTemplate(&buf, "manage_content", tt.data) 226 - if err != nil { 227 - t.Fatalf("template execution failed: %v", err) 228 - } 229 - shutter.SnapString(t, tt.name, formatHTML(buf.String())) 230 - }) 231 - } 232 - } 233 - 234 - func TestManageContent_GrindersTab_Snapshot(t *testing.T) { 235 - tests := []struct { 236 - name string 237 - data map[string]interface{} 238 - }{ 239 - { 240 - name: "grinders empty", 241 - data: map[string]interface{}{ 242 - "Grinders": []*models.Grinder{}, 243 - }, 244 - }, 245 - { 246 - name: "grinders with data", 247 - data: map[string]interface{}{ 248 - "Grinders": []*models.Grinder{ 249 - { 250 - RKey: "grinder1", 251 - Name: "Comandante C40 MK3", 252 - GrinderType: "Hand", 253 - BurrType: "Conical", 254 - Notes: "Excellent consistency, great for pour-over", 255 - }, 256 - { 257 - RKey: "grinder2", 258 - Name: "Baratza Encore", 259 - GrinderType: "Electric", 260 - BurrType: "Conical", 261 - }, 262 - }, 263 - }, 264 - }, 265 - } 266 - 267 - tmpl := template.Must(template.New("test").Funcs(getTemplateFuncs()).ParseFiles( 268 - "../../templates/partials/manage_content.tmpl", 269 - )) 270 - 271 - for _, tt := range tests { 272 - t.Run(tt.name, func(t *testing.T) { 273 - var buf bytes.Buffer 274 - err := tmpl.ExecuteTemplate(&buf, "manage_content", tt.data) 275 - if err != nil { 276 - t.Fatalf("template execution failed: %v", err) 277 - } 278 - shutter.SnapString(t, tt.name, formatHTML(buf.String())) 279 - }) 280 - } 281 - } 282 - 283 - func TestManageContent_BrewersTab_Snapshot(t *testing.T) { 284 - tests := []struct { 285 - name string 286 - data map[string]interface{} 287 - }{ 288 - { 289 - name: "brewers empty", 290 - data: map[string]interface{}{ 291 - "Brewers": []*models.Brewer{}, 292 - }, 293 - }, 294 - { 295 - name: "brewers with data", 296 - data: map[string]interface{}{ 297 - "Brewers": []*models.Brewer{ 298 - { 299 - RKey: "brewer1", 300 - Name: "Hario V60", 301 - BrewerType: "Pour-Over", 302 - Description: "Cone-shaped dripper for clean, bright brews", 303 - }, 304 - { 305 - RKey: "brewer2", 306 - Name: "AeroPress", 307 - }, 308 - }, 309 - }, 310 - }, 311 - } 312 - 313 - tmpl := template.Must(template.New("test").Funcs(getTemplateFuncs()).ParseFiles( 314 - "../../templates/partials/manage_content.tmpl", 315 - )) 316 - 317 - for _, tt := range tests { 318 - t.Run(tt.name, func(t *testing.T) { 319 - var buf bytes.Buffer 320 - err := tmpl.ExecuteTemplate(&buf, "manage_content", tt.data) 321 - if err != nil { 322 - t.Fatalf("template execution failed: %v", err) 323 - } 324 - shutter.SnapString(t, tt.name, formatHTML(buf.String())) 325 - }) 326 - } 327 - } 328 - 329 - func TestManageContent_SpecialCharacters_Snapshot(t *testing.T) { 330 - tests := []struct { 331 - name string 332 - data map[string]interface{} 333 - }{ 334 - { 335 - name: "beans with special characters and html", 336 - data: map[string]interface{}{ 337 - "Beans": []*models.Bean{ 338 - { 339 - RKey: "bean1", 340 - Name: "Café <script>alert('xss')</script> Especial", 341 - Origin: "Costa Rica™", 342 - RoastLevel: "Medium", 343 - Process: "Honey & Washed", 344 - Description: "\"Amazing\" coffee with <strong>bold</strong> flavor", 345 - }, 346 - }, 347 - "Roasters": []*models.Roaster{}, 348 - }, 349 - }, 350 - { 351 - name: "grinders with unicode", 352 - data: map[string]interface{}{ 353 - "Grinders": []*models.Grinder{ 354 - { 355 - RKey: "grinder1", 356 - Name: "手動コーヒーミル Comandante® C40", 357 - GrinderType: "Hand", 358 - BurrType: "Conical", 359 - Notes: "日本語のノート - Отличная кофемолка 🇯🇵", 360 - }, 361 - }, 362 - }, 363 - }, 364 - } 365 - 366 - tmpl := template.Must(template.New("test").Funcs(getTemplateFuncs()).ParseFiles( 367 - "../../templates/partials/manage_content.tmpl", 368 - )) 369 - 370 - for _, tt := range tests { 371 - t.Run(tt.name, func(t *testing.T) { 372 - var buf bytes.Buffer 373 - err := tmpl.ExecuteTemplate(&buf, "manage_content", tt.data) 374 - if err != nil { 375 - t.Fatalf("template execution failed: %v", err) 376 - } 377 - shutter.SnapString(t, tt.name, formatHTML(buf.String())) 378 - }) 379 - } 380 - }
-331
internal/bff/profile_template_snapshot_test.go
··· 1 - package bff 2 - 3 - import ( 4 - "bytes" 5 - "html/template" 6 - "testing" 7 - "time" 8 - 9 - "github.com/ptdewey/shutter" 10 - 11 - "arabica/internal/models" 12 - ) 13 - 14 - // Test profile content partial rendering 15 - 16 - func TestProfileContent_BeansTab_Snapshot(t *testing.T) { 17 - tmpl := template.New("test").Funcs(getTemplateFuncs()) 18 - tmpl, err := tmpl.ParseFiles( 19 - "../../templates/partials/profile_content.tmpl", 20 - "../../templates/partials/brew_list_content.tmpl", 21 - ) 22 - if err != nil { 23 - t.Fatalf("Failed to parse template: %v", err) 24 - } 25 - 26 - testTime := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) 27 - 28 - tests := []struct { 29 - name string 30 - data map[string]interface{} 31 - }{ 32 - { 33 - name: "profile with multiple beans", 34 - data: map[string]interface{}{ 35 - "Beans": []*models.Bean{ 36 - { 37 - RKey: "bean1", 38 - Name: "Ethiopian Yirgacheffe", 39 - Origin: "Ethiopia", 40 - RoastLevel: "Light", 41 - Process: "Washed", 42 - Description: "Bright and floral with citrus notes", 43 - Roaster: &models.Roaster{ 44 - RKey: "roaster1", 45 - Name: "Onyx Coffee Lab", 46 - Location: "Arkansas", 47 - Website: "https://onyxcoffeelab.com", 48 - }, 49 - CreatedAt: testTime, 50 - }, 51 - { 52 - RKey: "bean2", 53 - Name: "Colombia Supremo", 54 - Origin: "Colombia", 55 - RoastLevel: "Medium", 56 - Process: "Natural", 57 - Description: "", 58 - CreatedAt: testTime, 59 - }, 60 - }, 61 - "Roasters": []*models.Roaster{}, 62 - "Grinders": []*models.Grinder{}, 63 - "Brewers": []*models.Brewer{}, 64 - "Brews": []*models.Brew{}, 65 - "IsOwnProfile": true, 66 - }, 67 - }, 68 - { 69 - name: "profile with empty beans", 70 - data: map[string]interface{}{ 71 - "Beans": []*models.Bean{}, 72 - "Roasters": []*models.Roaster{}, 73 - "Grinders": []*models.Grinder{}, 74 - "Brewers": []*models.Brewer{}, 75 - "Brews": []*models.Brew{}, 76 - "IsOwnProfile": false, 77 - }, 78 - }, 79 - { 80 - name: "bean with missing optional fields", 81 - data: map[string]interface{}{ 82 - "Beans": []*models.Bean{ 83 - { 84 - RKey: "bean3", 85 - Name: "Mystery Bean", 86 - CreatedAt: testTime, 87 - }, 88 - }, 89 - "Roasters": []*models.Roaster{}, 90 - "Grinders": []*models.Grinder{}, 91 - "Brewers": []*models.Brewer{}, 92 - "Brews": []*models.Brew{}, 93 - "IsOwnProfile": true, 94 - }, 95 - }, 96 - } 97 - 98 - for _, tt := range tests { 99 - t.Run(tt.name, func(t *testing.T) { 100 - var buf bytes.Buffer 101 - err := tmpl.ExecuteTemplate(&buf, "profile_content", tt.data) 102 - if err != nil { 103 - t.Fatalf("Failed to execute template: %v", err) 104 - } 105 - shutter.SnapString(t, tt.name, formatHTML(buf.String())) 106 - }) 107 - } 108 - } 109 - 110 - func TestProfileContent_GearTabs_Snapshot(t *testing.T) { 111 - tmpl := template.New("test").Funcs(getTemplateFuncs()) 112 - tmpl, err := tmpl.ParseFiles( 113 - "../../templates/partials/profile_content.tmpl", 114 - "../../templates/partials/brew_list_content.tmpl", 115 - ) 116 - if err != nil { 117 - t.Fatalf("Failed to parse template: %v", err) 118 - } 119 - 120 - testTime := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) 121 - 122 - data := map[string]interface{}{ 123 - "Beans": []*models.Bean{}, 124 - "Roasters": []*models.Roaster{ 125 - { 126 - RKey: "roaster1", 127 - Name: "Heart Coffee", 128 - Location: "Portland, OR", 129 - Website: "https://heartroasters.com", 130 - CreatedAt: testTime, 131 - }, 132 - }, 133 - "Grinders": []*models.Grinder{ 134 - { 135 - RKey: "grinder1", 136 - Name: "Comandante C40", 137 - GrinderType: "Hand", 138 - BurrType: "Conical", 139 - Notes: "Perfect for pour over", 140 - CreatedAt: testTime, 141 - }, 142 - { 143 - RKey: "grinder2", 144 - Name: "Niche Zero", 145 - GrinderType: "Electric", 146 - BurrType: "Conical", 147 - CreatedAt: testTime, 148 - }, 149 - }, 150 - "Brewers": []*models.Brewer{ 151 - { 152 - RKey: "brewer1", 153 - Name: "Hario V60", 154 - BrewerType: "Pour Over", 155 - Description: "Classic pour over cone", 156 - CreatedAt: testTime, 157 - }, 158 - }, 159 - "Brews": []*models.Brew{}, 160 - "IsOwnProfile": true, 161 - } 162 - 163 - var buf bytes.Buffer 164 - err = tmpl.ExecuteTemplate(&buf, "profile_content", data) 165 - if err != nil { 166 - t.Fatalf("Failed to execute template: %v", err) 167 - } 168 - shutter.SnapString(t, "profile with gear collection", formatHTML(buf.String())) 169 - } 170 - 171 - func TestProfileContent_URLSecurity_Snapshot(t *testing.T) { 172 - tmpl := template.New("test").Funcs(getTemplateFuncs()) 173 - tmpl, err := tmpl.ParseFiles( 174 - "../../templates/partials/profile_content.tmpl", 175 - "../../templates/partials/brew_list_content.tmpl", 176 - ) 177 - if err != nil { 178 - t.Fatalf("Failed to parse template: %v", err) 179 - } 180 - 181 - testTime := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) 182 - 183 - tests := []struct { 184 - name string 185 - data map[string]interface{} 186 - }{ 187 - { 188 - name: "profile roaster with unsafe website URL", 189 - data: map[string]interface{}{ 190 - "Beans": []*models.Bean{}, 191 - "Roasters": []*models.Roaster{ 192 - { 193 - RKey: "roaster1", 194 - Name: "Sketchy Roaster", 195 - Location: "Unknown", 196 - Website: "javascript:alert('xss')", // Should be sanitized 197 - CreatedAt: testTime, 198 - }, 199 - }, 200 - "Grinders": []*models.Grinder{}, 201 - "Brewers": []*models.Brewer{}, 202 - "Brews": []*models.Brew{}, 203 - "IsOwnProfile": false, 204 - }, 205 - }, 206 - { 207 - name: "profile roaster with invalid URL protocol", 208 - data: map[string]interface{}{ 209 - "Beans": []*models.Bean{}, 210 - "Roasters": []*models.Roaster{ 211 - { 212 - RKey: "roaster2", 213 - Name: "FTP Roaster", 214 - Website: "ftp://example.com", // Should be rejected 215 - CreatedAt: testTime, 216 - }, 217 - }, 218 - "Grinders": []*models.Grinder{}, 219 - "Brewers": []*models.Brewer{}, 220 - "Brews": []*models.Brew{}, 221 - "IsOwnProfile": false, 222 - }, 223 - }, 224 - } 225 - 226 - for _, tt := range tests { 227 - t.Run(tt.name, func(t *testing.T) { 228 - var buf bytes.Buffer 229 - err := tmpl.ExecuteTemplate(&buf, "profile_content", tt.data) 230 - if err != nil { 231 - t.Fatalf("Failed to execute template: %v", err) 232 - } 233 - shutter.SnapString(t, tt.name, formatHTML(buf.String())) 234 - }) 235 - } 236 - } 237 - 238 - func TestProfileContent_SpecialCharacters_Snapshot(t *testing.T) { 239 - tmpl := template.New("test").Funcs(getTemplateFuncs()) 240 - tmpl, err := tmpl.ParseFiles( 241 - "../../templates/partials/profile_content.tmpl", 242 - "../../templates/partials/brew_list_content.tmpl", 243 - ) 244 - if err != nil { 245 - t.Fatalf("Failed to parse template: %v", err) 246 - } 247 - 248 - testTime := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) 249 - 250 - data := map[string]interface{}{ 251 - "Beans": []*models.Bean{ 252 - { 253 - RKey: "bean1", 254 - Name: "Bean with <html> & \"quotes\"", 255 - Origin: "Colombia & Peru", 256 - Description: "Description with 'single' and \"double\" quotes", 257 - CreatedAt: testTime, 258 - }, 259 - }, 260 - "Roasters": []*models.Roaster{}, 261 - "Grinders": []*models.Grinder{ 262 - { 263 - RKey: "grinder1", 264 - Name: "Grinder & Co.", 265 - Notes: "Notes with <script>alert('xss')</script>", 266 - CreatedAt: testTime, 267 - }, 268 - }, 269 - "Brewers": []*models.Brewer{}, 270 - "Brews": []*models.Brew{}, 271 - "IsOwnProfile": true, 272 - } 273 - 274 - var buf bytes.Buffer 275 - err = tmpl.ExecuteTemplate(&buf, "profile_content", data) 276 - if err != nil { 277 - t.Fatalf("Failed to execute template: %v", err) 278 - } 279 - shutter.SnapString(t, "profile with special characters", formatHTML(buf.String())) 280 - } 281 - 282 - func TestProfileContent_Unicode_Snapshot(t *testing.T) { 283 - tmpl := template.New("test").Funcs(getTemplateFuncs()) 284 - tmpl, err := tmpl.ParseFiles( 285 - "../../templates/partials/profile_content.tmpl", 286 - "../../templates/partials/brew_list_content.tmpl", 287 - ) 288 - if err != nil { 289 - t.Fatalf("Failed to parse template: %v", err) 290 - } 291 - 292 - testTime := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) 293 - 294 - data := map[string]interface{}{ 295 - "Beans": []*models.Bean{ 296 - { 297 - RKey: "bean1", 298 - Name: "エチオピア イルガチェフェ", // Japanese 299 - Origin: "日本", 300 - Description: "明るく花のような香り", 301 - CreatedAt: testTime, 302 - }, 303 - { 304 - RKey: "bean2", 305 - Name: "Café de Colombia", 306 - Origin: "Bogotá", 307 - Description: "Suave y aromático", 308 - CreatedAt: testTime, 309 - }, 310 - }, 311 - "Roasters": []*models.Roaster{ 312 - { 313 - RKey: "roaster1", 314 - Name: "Кофейня Москва", // Russian 315 - Location: "Москва, Россия", 316 - CreatedAt: testTime, 317 - }, 318 - }, 319 - "Grinders": []*models.Grinder{}, 320 - "Brewers": []*models.Brewer{}, 321 - "Brews": []*models.Brew{}, 322 - "IsOwnProfile": false, 323 - } 324 - 325 - var buf bytes.Buffer 326 - err = tmpl.ExecuteTemplate(&buf, "profile_content", data) 327 - if err != nil { 328 - t.Fatalf("Failed to execute template: %v", err) 329 - } 330 - shutter.SnapString(t, "profile with unicode content", formatHTML(buf.String())) 331 - }
-298
internal/bff/render.go
··· 1 - package bff 2 - 3 - import ( 4 - "html/template" 5 - "net/http" 6 - "os" 7 - "sync" 8 - 9 - "arabica/internal/atproto" 10 - "arabica/internal/feed" 11 - "arabica/internal/models" 12 - ) 13 - 14 - var ( 15 - templateFuncs template.FuncMap 16 - funcsOnce sync.Once 17 - templateDir string 18 - templateDirMu sync.Once 19 - ) 20 - 21 - // getTemplateFuncs returns the function map used by all templates 22 - func getTemplateFuncs() template.FuncMap { 23 - funcsOnce.Do(func() { 24 - templateFuncs = template.FuncMap{ 25 - "formatTemp": FormatTemp, 26 - "formatTime": FormatTime, 27 - "formatRating": FormatRating, 28 - "formatID": FormatID, 29 - "formatInt": FormatInt, 30 - "formatRoasterID": FormatRoasterID, 31 - "poursToJSON": PoursToJSON, 32 - "ptrEquals": PtrEquals[int], 33 - "ptrValue": PtrValue[int], 34 - "iterate": Iterate, 35 - "iterateRemaining": IterateRemaining, 36 - "hasTemp": HasTemp, 37 - "hasValue": HasValue, 38 - "safeAvatarURL": SafeAvatarURL, 39 - "safeWebsiteURL": SafeWebsiteURL, 40 - "escapeJS": EscapeJS, 41 - "dict": Dict, 42 - } 43 - }) 44 - return templateFuncs 45 - } 46 - 47 - // getTemplateDir finds the template directory 48 - func getTemplateDir() string { 49 - templateDirMu.Do(func() { 50 - dirs := []string{ 51 - "templates", 52 - "../../templates", 53 - "../../../templates", 54 - } 55 - for _, dir := range dirs { 56 - if _, err := os.Stat(dir); err == nil { 57 - templateDir = dir 58 - return 59 - } 60 - } 61 - templateDir = "templates" // fallback 62 - }) 63 - return templateDir 64 - } 65 - 66 - // parsePageTemplate parses a complete page template with layout and partials 67 - func parsePageTemplate(pageName string) (*template.Template, error) { 68 - dir := getTemplateDir() 69 - t := template.New("").Funcs(getTemplateFuncs()) 70 - 71 - // Parse layout first 72 - t, err := t.ParseFiles(dir + "/layout.tmpl") 73 - if err != nil { 74 - return nil, err 75 - } 76 - 77 - // Parse all partials 78 - t, err = t.ParseGlob(dir + "/partials/*.tmpl") 79 - if err != nil { 80 - return nil, err 81 - } 82 - 83 - // Parse card templates 84 - t, err = t.ParseGlob(dir + "/partials/cards/*.tmpl") 85 - if err != nil { 86 - return nil, err 87 - } 88 - 89 - // Parse the specific page template 90 - t, err = t.ParseFiles(dir + "/" + pageName) 91 - if err != nil { 92 - return nil, err 93 - } 94 - 95 - return t, nil 96 - } 97 - 98 - // parsePartialTemplate parses just the partials (for partial-only renders) 99 - func parsePartialTemplate() (*template.Template, error) { 100 - dir := getTemplateDir() 101 - t := template.New("").Funcs(getTemplateFuncs()) 102 - 103 - // Parse all partials 104 - t, err := t.ParseGlob(dir + "/partials/*.tmpl") 105 - if err != nil { 106 - return nil, err 107 - } 108 - 109 - // Parse card templates 110 - t, err = t.ParseGlob(dir + "/partials/cards/*.tmpl") 111 - if err != nil { 112 - return nil, err 113 - } 114 - 115 - return t, nil 116 - } 117 - 118 - // UserProfile contains user profile data for header display 119 - type UserProfile struct { 120 - Handle string `json:"handle"` 121 - DisplayName string `json:"displayName"` 122 - Avatar string `json:"avatar"` 123 - } 124 - 125 - // PageData contains data for rendering pages 126 - type PageData struct { 127 - Title string 128 - Beans []*models.Bean 129 - Roasters []*models.Roaster 130 - Grinders []*models.Grinder 131 - Brewers []*models.Brewer 132 - Brew *BrewData 133 - Brews []*BrewListData 134 - FeedItems []*feed.FeedItem 135 - IsAuthenticated bool 136 - IsOwnProfile bool 137 - UserDID string 138 - UserProfile *UserProfile 139 - } 140 - 141 - // BrewData wraps a brew with pre-serialized JSON for pours 142 - type BrewData struct { 143 - *models.Brew 144 - PoursJSON string 145 - } 146 - 147 - // BrewListData wraps a brew with pre-formatted display values 148 - type BrewListData struct { 149 - *models.Brew 150 - TempFormatted string 151 - TimeFormatted string 152 - RatingFormatted string 153 - } 154 - 155 - // RenderTemplate renders a template with layout 156 - func RenderTemplate(w http.ResponseWriter, r *http.Request, tmpl string, data *PageData) error { 157 - t, err := parsePageTemplate(tmpl) 158 - if err != nil { 159 - return err 160 - } 161 - return t.ExecuteTemplate(w, "layout", data) 162 - } 163 - 164 - // RenderTemplateWithProfile renders a template with layout and user profile 165 - func RenderTemplateWithProfile(w http.ResponseWriter, r *http.Request, tmpl string, data *PageData, userProfile *UserProfile) error { 166 - data.UserProfile = userProfile 167 - return RenderTemplate(w, r, tmpl, data) 168 - } 169 - 170 - // RenderHome renders the home page 171 - 172 - // RenderBrewList renders the brew list page 173 - 174 - // RenderBrewForm renders the brew form page 175 - 176 - // RenderBrewView renders the brew view page 177 - 178 - // RenderManage renders the manage page 179 - 180 - // RenderFeedPartial renders just the feed partial (for HTMX async loading) 181 - func RenderFeedPartial(w http.ResponseWriter, feedItems []*feed.FeedItem, isAuthenticated bool) error { 182 - t, err := parsePartialTemplate() 183 - if err != nil { 184 - return err 185 - } 186 - data := &PageData{ 187 - FeedItems: feedItems, 188 - IsAuthenticated: isAuthenticated, 189 - } 190 - return t.ExecuteTemplate(w, "feed", data) 191 - } 192 - 193 - // RenderBrewListPartial renders just the brew list partial (for HTMX async loading) 194 - func RenderBrewListPartial(w http.ResponseWriter, brews []*models.Brew) error { 195 - t, err := parsePartialTemplate() 196 - if err != nil { 197 - return err 198 - } 199 - brewList := make([]*BrewListData, len(brews)) 200 - for i, brew := range brews { 201 - brewList[i] = &BrewListData{ 202 - Brew: brew, 203 - TempFormatted: FormatTemp(brew.Temperature), 204 - TimeFormatted: FormatTime(brew.TimeSeconds), 205 - RatingFormatted: FormatRating(brew.Rating), 206 - } 207 - } 208 - 209 - data := &PageData{ 210 - Brews: brewList, 211 - IsOwnProfile: true, // This endpoint is only used for viewing own brews 212 - } 213 - return t.ExecuteTemplate(w, "brew_list_content", data) 214 - } 215 - 216 - // RenderManagePartial renders just the manage partial (for HTMX async loading) 217 - func RenderManagePartial(w http.ResponseWriter, beans []*models.Bean, roasters []*models.Roaster, grinders []*models.Grinder, brewers []*models.Brewer) error { 218 - t, err := parsePartialTemplate() 219 - if err != nil { 220 - return err 221 - } 222 - data := &PageData{ 223 - Beans: beans, 224 - Roasters: roasters, 225 - Grinders: grinders, 226 - Brewers: brewers, 227 - } 228 - return t.ExecuteTemplate(w, "manage_content", data) 229 - } 230 - 231 - // findTemplatePath finds the correct path to a template file 232 - func findTemplatePath(name string) string { 233 - dir := getTemplateDir() 234 - return dir + "/" + name 235 - } 236 - 237 - // ProfilePageData contains data for rendering the profile page 238 - type ProfilePageData struct { 239 - Title string 240 - Profile *atproto.Profile 241 - Brews []*models.Brew 242 - Beans []*models.Bean 243 - Roasters []*models.Roaster 244 - Grinders []*models.Grinder 245 - Brewers []*models.Brewer 246 - IsAuthenticated bool 247 - UserDID string 248 - UserProfile *UserProfile 249 - IsOwnProfile bool // Whether viewing user is the profile owner 250 - } 251 - 252 - // ProfileContentData contains data for rendering the profile content partial 253 - type ProfileContentData struct { 254 - Brews []*models.Brew 255 - Beans []*models.Bean 256 - Roasters []*models.Roaster 257 - Grinders []*models.Grinder 258 - Brewers []*models.Brewer 259 - IsOwnProfile bool 260 - ProfileHandle string // The handle of the profile being viewed 261 - } 262 - 263 - // RenderProfile renders a user's public profile page 264 - 265 - // RenderProfilePartial renders just the profile content partial (for HTMX async loading) 266 - func RenderProfilePartial(w http.ResponseWriter, brews []*models.Brew, beans []*models.Bean, roasters []*models.Roaster, grinders []*models.Grinder, brewers []*models.Brewer, isOwnProfile bool, profileHandle string) error { 267 - t, err := parsePartialTemplate() 268 - if err != nil { 269 - return err 270 - } 271 - 272 - data := &ProfileContentData{ 273 - Brews: brews, 274 - Beans: beans, 275 - Roasters: roasters, 276 - Grinders: grinders, 277 - Brewers: brewers, 278 - IsOwnProfile: isOwnProfile, 279 - ProfileHandle: profileHandle, 280 - } 281 - return t.ExecuteTemplate(w, "profile_content", data) 282 - } 283 - 284 - // Render404 renders the 404 not found page 285 - func Render404(w http.ResponseWriter, isAuthenticated bool, userDID string, userProfile *UserProfile) error { 286 - t, err := parsePageTemplate("404.tmpl") 287 - if err != nil { 288 - return err 289 - } 290 - data := &PageData{ 291 - Title: "Page Not Found", 292 - IsAuthenticated: isAuthenticated, 293 - UserDID: userDID, 294 - UserProfile: userProfile, 295 - } 296 - w.WriteHeader(http.StatusNotFound) 297 - return t.ExecuteTemplate(w, "layout", data) 298 - }
-552
internal/bff/render_snapshot_test.go
··· 1 - package bff 2 - 3 - import ( 4 - "bytes" 5 - "html/template" 6 - "testing" 7 - "time" 8 - 9 - "github.com/ptdewey/shutter" 10 - 11 - "arabica/internal/models" 12 - ) 13 - 14 - func TestDict_Snapshot(t *testing.T) { 15 - tests := []struct { 16 - name string 17 - args []interface{} 18 - }{ 19 - { 20 - name: "empty dict", 21 - args: []interface{}{}, 22 - }, 23 - { 24 - name: "single key-value", 25 - args: []interface{}{"key1", "value1"}, 26 - }, 27 - { 28 - name: "multiple key-values", 29 - args: []interface{}{"name", "Ethiopian", "roast", "Light", "rating", 9}, 30 - }, 31 - { 32 - name: "nested values", 33 - args: []interface{}{ 34 - "bean", map[string]interface{}{"name": "Ethiopian", "origin": "Yirgacheffe"}, 35 - "brew", map[string]interface{}{"method": "V60", "temp": 93.0}, 36 - }, 37 - }, 38 - } 39 - 40 - for _, tt := range tests { 41 - t.Run(tt.name, func(t *testing.T) { 42 - result, err := Dict(tt.args...) 43 - if err != nil { 44 - shutter.Snap(t, tt.name+"_error", err.Error()) 45 - } else { 46 - shutter.Snap(t, tt.name, result) 47 - } 48 - }) 49 - } 50 - } 51 - 52 - func TestDict_ErrorCases_Snapshot(t *testing.T) { 53 - tests := []struct { 54 - name string 55 - args []interface{} 56 - }{ 57 - { 58 - name: "odd number of arguments", 59 - args: []interface{}{"key1", "value1", "key2"}, 60 - }, 61 - { 62 - name: "non-string key", 63 - args: []interface{}{123, "value1"}, 64 - }, 65 - { 66 - name: "mixed valid and invalid", 67 - args: []interface{}{"key1", "value1", 456, "value2"}, 68 - }, 69 - } 70 - 71 - for _, tt := range tests { 72 - t.Run(tt.name, func(t *testing.T) { 73 - _, err := Dict(tt.args...) 74 - if err != nil { 75 - shutter.Snap(t, tt.name, err.Error()) 76 - } else { 77 - shutter.Snap(t, tt.name, "no error") 78 - } 79 - }) 80 - } 81 - } 82 - 83 - func TestTemplateRendering_BeanCard_Snapshot(t *testing.T) { 84 - // Create a minimal template with the bean_card template 85 - tmplStr := `{{define "bean_card"}} 86 - <div class="bean-card"> 87 - <h3>{{.Bean.Name}}</h3> 88 - {{if .Bean.Origin}}<p>Origin: {{.Bean.Origin}}</p>{{end}} 89 - {{if .Bean.RoastLevel}}<p>Roast: {{.Bean.RoastLevel}}</p>{{end}} 90 - </div> 91 - {{end}}` 92 - 93 - tmpl := template.New("test").Funcs(getTemplateFuncs()) 94 - tmpl, err := tmpl.Parse(tmplStr) 95 - if err != nil { 96 - t.Fatalf("Failed to parse template: %v", err) 97 - } 98 - 99 - tests := []struct { 100 - name string 101 - data map[string]interface{} 102 - }{ 103 - { 104 - name: "full bean data", 105 - data: map[string]interface{}{ 106 - "Bean": &models.Bean{ 107 - Name: "Ethiopian Yirgacheffe", 108 - Origin: "Ethiopia", 109 - RoastLevel: "Light", 110 - Process: "Washed", 111 - }, 112 - "IsOwnProfile": true, 113 - }, 114 - }, 115 - { 116 - name: "minimal bean data", 117 - data: map[string]interface{}{ 118 - "Bean": &models.Bean{ 119 - Name: "House Blend", 120 - }, 121 - "IsOwnProfile": false, 122 - }, 123 - }, 124 - { 125 - name: "bean with only origin", 126 - data: map[string]interface{}{ 127 - "Bean": &models.Bean{ 128 - Origin: "Colombia", 129 - }, 130 - "IsOwnProfile": true, 131 - }, 132 - }, 133 - } 134 - 135 - for _, tt := range tests { 136 - t.Run(tt.name, func(t *testing.T) { 137 - var buf bytes.Buffer 138 - err := tmpl.ExecuteTemplate(&buf, "bean_card", tt.data) 139 - if err != nil { 140 - t.Fatalf("Failed to execute template: %v", err) 141 - } 142 - shutter.Snap(t, tt.name, buf.String()) 143 - }) 144 - } 145 - } 146 - 147 - func TestTemplateRendering_BrewCard_Snapshot(t *testing.T) { 148 - // Simplified brew card template for testing 149 - tmplStr := `{{define "brew_card"}} 150 - <div class="brew-card"> 151 - <div class="date">{{.Brew.CreatedAt.Format "Jan 2, 2006"}}</div> 152 - {{if .Brew.Bean}} 153 - <div class="bean">{{.Brew.Bean.Name}}</div> 154 - {{end}} 155 - {{if hasValue .Brew.Rating}} 156 - <div class="rating">{{formatRating .Brew.Rating}}</div> 157 - {{end}} 158 - {{if .Brew.TastingNotes}} 159 - <div class="notes">{{.Brew.TastingNotes}}</div> 160 - {{end}} 161 - </div> 162 - {{end}}` 163 - 164 - tmpl := template.New("test").Funcs(getTemplateFuncs()) 165 - tmpl, err := tmpl.Parse(tmplStr) 166 - if err != nil { 167 - t.Fatalf("Failed to parse template: %v", err) 168 - } 169 - 170 - testTime := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) 171 - 172 - tests := []struct { 173 - name string 174 - data map[string]interface{} 175 - }{ 176 - { 177 - name: "complete brew", 178 - data: map[string]interface{}{ 179 - "Brew": &models.Brew{ 180 - CreatedAt: testTime, 181 - Bean: &models.Bean{ 182 - Name: "Ethiopian Yirgacheffe", 183 - Origin: "Ethiopia", 184 - }, 185 - Rating: 9, 186 - TastingNotes: "Bright citrus notes with floral aroma", 187 - }, 188 - "IsOwnProfile": true, 189 - }, 190 - }, 191 - { 192 - name: "minimal brew", 193 - data: map[string]interface{}{ 194 - "Brew": &models.Brew{ 195 - CreatedAt: testTime, 196 - }, 197 - "IsOwnProfile": false, 198 - }, 199 - }, 200 - { 201 - name: "brew with zero rating", 202 - data: map[string]interface{}{ 203 - "Brew": &models.Brew{ 204 - CreatedAt: testTime, 205 - Bean: &models.Bean{ 206 - Name: "House Blend", 207 - }, 208 - Rating: 0, 209 - }, 210 - "IsOwnProfile": true, 211 - }, 212 - }, 213 - } 214 - 215 - for _, tt := range tests { 216 - t.Run(tt.name, func(t *testing.T) { 217 - var buf bytes.Buffer 218 - err := tmpl.ExecuteTemplate(&buf, "brew_card", tt.data) 219 - if err != nil { 220 - t.Fatalf("Failed to execute template: %v", err) 221 - } 222 - shutter.Snap(t, tt.name, buf.String()) 223 - }) 224 - } 225 - } 226 - 227 - func TestTemplateRendering_GearCards_Snapshot(t *testing.T) { 228 - // Simplified grinder card template 229 - tmplStr := `{{define "grinder_card"}} 230 - <div class="grinder-card"> 231 - <h3>{{.Grinder.Name}}</h3> 232 - {{if .Grinder.GrinderType}}<p>Type: {{.Grinder.GrinderType}}</p>{{end}} 233 - {{if .Grinder.BurrType}}<p>Burr: {{.Grinder.BurrType}}</p>{{end}} 234 - </div> 235 - {{end}}` 236 - 237 - tmpl := template.New("test").Funcs(getTemplateFuncs()) 238 - tmpl, err := tmpl.Parse(tmplStr) 239 - if err != nil { 240 - t.Fatalf("Failed to parse template: %v", err) 241 - } 242 - 243 - tests := []struct { 244 - name string 245 - data map[string]interface{} 246 - }{ 247 - { 248 - name: "full grinder data", 249 - data: map[string]interface{}{ 250 - "Grinder": &models.Grinder{ 251 - Name: "1Zpresso JX-Pro", 252 - GrinderType: "Hand", 253 - BurrType: "Conical", 254 - }, 255 - "IsOwnProfile": true, 256 - }, 257 - }, 258 - { 259 - name: "minimal grinder data", 260 - data: map[string]interface{}{ 261 - "Grinder": &models.Grinder{ 262 - Name: "Generic Grinder", 263 - }, 264 - "IsOwnProfile": false, 265 - }, 266 - }, 267 - } 268 - 269 - for _, tt := range tests { 270 - t.Run(tt.name, func(t *testing.T) { 271 - var buf bytes.Buffer 272 - err := tmpl.ExecuteTemplate(&buf, "grinder_card", tt.data) 273 - if err != nil { 274 - t.Fatalf("Failed to execute template: %v", err) 275 - } 276 - shutter.Snap(t, tt.name, buf.String()) 277 - }) 278 - } 279 - } 280 - 281 - func TestFormatHelpers_Snapshot(t *testing.T) { 282 - t.Run("temperature formatting", func(t *testing.T) { 283 - temps := []float64{0, 20.5, 93.0, 100.0, 200.5, 212.0} 284 - results := make([]string, len(temps)) 285 - for i, temp := range temps { 286 - results[i] = FormatTemp(temp) 287 - } 288 - shutter.Snap(t, "temperature_formatting", results) 289 - }) 290 - 291 - t.Run("time formatting", func(t *testing.T) { 292 - times := []int{0, 15, 60, 90, 180, 245} 293 - results := make([]string, len(times)) 294 - for i, sec := range times { 295 - results[i] = FormatTime(sec) 296 - } 297 - shutter.Snap(t, "time_formatting", results) 298 - }) 299 - 300 - t.Run("rating formatting", func(t *testing.T) { 301 - ratings := []int{0, 1, 5, 7, 10} 302 - results := make([]string, len(ratings)) 303 - for i, rating := range ratings { 304 - results[i] = FormatRating(rating) 305 - } 306 - shutter.Snap(t, "rating_formatting", results) 307 - }) 308 - } 309 - 310 - func TestSafeURL_Snapshot(t *testing.T) { 311 - t.Run("avatar URLs", func(t *testing.T) { 312 - urls := []string{ 313 - "", 314 - "/static/icon-placeholder.svg", 315 - "https://cdn.bsky.app/avatar.jpg", 316 - "https://av-cdn.bsky.app/img/avatar/plain/did:plc:test/abc@jpeg", 317 - "http://cdn.bsky.app/avatar.jpg", 318 - "https://evil.com/xss.jpg", 319 - "/../../etc/passwd", 320 - "javascript:alert('xss')", 321 - } 322 - results := make([]string, len(urls)) 323 - for i, url := range urls { 324 - results[i] = SafeAvatarURL(url) 325 - } 326 - shutter.Snap(t, "avatar_urls", results) 327 - }) 328 - 329 - t.Run("website URLs", func(t *testing.T) { 330 - urls := []string{ 331 - "", 332 - "https://example.com", 333 - "http://example.com", 334 - "https://roastery.coffee/beans", 335 - "javascript:alert('xss')", 336 - "ftp://example.com", 337 - "https://", 338 - "example.com", 339 - } 340 - results := make([]string, len(urls)) 341 - for i, url := range urls { 342 - results[i] = SafeWebsiteURL(url) 343 - } 344 - shutter.Snap(t, "website_urls", results) 345 - }) 346 - } 347 - 348 - func TestEscapeJS_Snapshot(t *testing.T) { 349 - inputs := []string{ 350 - "simple string", 351 - "string with 'single quotes'", 352 - "string with \"double quotes\"", 353 - "line1\nline2", 354 - "tab\there", 355 - "backslash\\test", 356 - "mixed: 'quotes', \"quotes\", \n newlines \t tabs", 357 - "", 358 - } 359 - 360 - results := make([]string, len(inputs)) 361 - for i, input := range inputs { 362 - results[i] = EscapeJS(input) 363 - } 364 - 365 - shutter.Snap(t, "escape_js", results) 366 - } 367 - 368 - func TestPoursToJSON_Snapshot(t *testing.T) { 369 - tests := []struct { 370 - name string 371 - pours []*models.Pour 372 - }{ 373 - { 374 - name: "empty pours", 375 - pours: []*models.Pour{}, 376 - }, 377 - { 378 - name: "single pour", 379 - pours: []*models.Pour{ 380 - {PourNumber: 1, WaterAmount: 50, TimeSeconds: 30}, 381 - }, 382 - }, 383 - { 384 - name: "multiple pours", 385 - pours: []*models.Pour{ 386 - {PourNumber: 1, WaterAmount: 50, TimeSeconds: 30}, 387 - {PourNumber: 2, WaterAmount: 100, TimeSeconds: 45}, 388 - {PourNumber: 3, WaterAmount: 80, TimeSeconds: 60}, 389 - }, 390 - }, 391 - { 392 - name: "nil pours", 393 - pours: nil, 394 - }, 395 - } 396 - 397 - for _, tt := range tests { 398 - t.Run(tt.name, func(t *testing.T) { 399 - result := PoursToJSON(tt.pours) 400 - shutter.Snap(t, tt.name, result) 401 - }) 402 - } 403 - } 404 - 405 - func TestIterate_Snapshot(t *testing.T) { 406 - tests := []struct { 407 - name string 408 - count int 409 - }{ 410 - {name: "zero count", count: 0}, 411 - {name: "single iteration", count: 1}, 412 - {name: "five iterations", count: 5}, 413 - {name: "ten iterations", count: 10}, 414 - } 415 - 416 - for _, tt := range tests { 417 - t.Run(tt.name, func(t *testing.T) { 418 - result := Iterate(tt.count) 419 - shutter.Snap(t, tt.name, result) 420 - }) 421 - } 422 - } 423 - 424 - func TestIterateRemaining_Snapshot(t *testing.T) { 425 - tests := []struct { 426 - name string 427 - total int 428 - used int 429 - }{ 430 - {name: "all used", total: 10, used: 10}, 431 - {name: "none used", total: 10, used: 0}, 432 - {name: "half used", total: 10, used: 5}, 433 - {name: "rating 7 out of 10", total: 10, used: 7}, 434 - {name: "rating 3 out of 10", total: 10, used: 3}, 435 - } 436 - 437 - for _, tt := range tests { 438 - t.Run(tt.name, func(t *testing.T) { 439 - result := IterateRemaining(tt.total, tt.used) 440 - shutter.Snap(t, tt.name, result) 441 - }) 442 - } 443 - } 444 - 445 - func TestHasTemp_Snapshot(t *testing.T) { 446 - tests := []struct { 447 - name string 448 - temp float64 449 - }{ 450 - {name: "zero temperature", temp: 0}, 451 - {name: "positive temperature", temp: 93.0}, 452 - {name: "negative temperature", temp: -5.0}, 453 - {name: "small positive", temp: 0.1}, 454 - } 455 - 456 - for _, tt := range tests { 457 - t.Run(tt.name, func(t *testing.T) { 458 - result := HasTemp(tt.temp) 459 - shutter.Snap(t, tt.name, result) 460 - }) 461 - } 462 - } 463 - 464 - func TestHasValue_Snapshot(t *testing.T) { 465 - tests := []struct { 466 - name string 467 - value int 468 - }{ 469 - {name: "zero value", value: 0}, 470 - {name: "positive value", value: 5}, 471 - {name: "negative value", value: -3}, 472 - {name: "large value", value: 1000}, 473 - } 474 - 475 - for _, tt := range tests { 476 - t.Run(tt.name, func(t *testing.T) { 477 - result := HasValue(tt.value) 478 - shutter.Snap(t, tt.name, result) 479 - }) 480 - } 481 - } 482 - 483 - func TestPtrEquals_Snapshot(t *testing.T) { 484 - str1 := "test" 485 - str2 := "different" 486 - 487 - tests := []struct { 488 - name string 489 - ptr *string 490 - val string 491 - }{ 492 - {name: "nil pointer", ptr: nil, val: "test"}, 493 - {name: "equal values", ptr: &str1, val: "test"}, 494 - {name: "different values", ptr: &str1, val: str2}, 495 - {name: "pointer to empty vs empty", ptr: &([]string{""}[0]), val: ""}, 496 - } 497 - 498 - for _, tt := range tests { 499 - t.Run(tt.name, func(t *testing.T) { 500 - result := PtrEquals(tt.ptr, tt.val) 501 - shutter.Snap(t, tt.name, result) 502 - }) 503 - } 504 - } 505 - 506 - func TestPtrValue_Snapshot(t *testing.T) { 507 - str1 := "test value" 508 - num1 := 42 509 - 510 - tests := []struct { 511 - name string 512 - ptr interface{} 513 - }{ 514 - {name: "nil string pointer", ptr: (*string)(nil)}, 515 - {name: "valid string pointer", ptr: &str1}, 516 - {name: "nil int pointer", ptr: (*int)(nil)}, 517 - {name: "valid int pointer", ptr: &num1}, 518 - } 519 - 520 - for _, tt := range tests { 521 - t.Run(tt.name, func(t *testing.T) { 522 - var result interface{} 523 - switch v := tt.ptr.(type) { 524 - case *string: 525 - result = PtrValue(v) 526 - case *int: 527 - result = PtrValue(v) 528 - } 529 - shutter.Snap(t, tt.name, result) 530 - }) 531 - } 532 - } 533 - 534 - func TestFormatTempValue_Snapshot(t *testing.T) { 535 - tests := []struct { 536 - name string 537 - temp float64 538 - }{ 539 - {name: "zero temp", temp: 0}, 540 - {name: "celsius temp", temp: 93.0}, 541 - {name: "fahrenheit temp", temp: 205.0}, 542 - {name: "decimal celsius", temp: 92.5}, 543 - {name: "decimal fahrenheit", temp: 201.8}, 544 - } 545 - 546 - for _, tt := range tests { 547 - t.Run(tt.name, func(t *testing.T) { 548 - result := FormatTempValue(tt.temp) 549 - shutter.Snap(t, tt.name, result) 550 - }) 551 - } 552 - }
-57
internal/bff/testutil.go
··· 1 - package bff 2 - 3 - import ( 4 - "bytes" 5 - "strings" 6 - 7 - "github.com/yosssi/gohtml" 8 - ) 9 - 10 - // formatHTML formats HTML for snapshot testing with 2-space indentation 11 - func formatHTML(html string) string { 12 - // Configure gohtml for 2-space indentation 13 - formatted := gohtml.Format(html) 14 - 15 - // Post-process to ensure consistent formatting: 16 - // 1. Remove excessive blank lines 17 - lines := strings.Split(formatted, "\n") 18 - var result []string 19 - prevBlank := false 20 - 21 - for _, line := range lines { 22 - isBlank := strings.TrimSpace(line) == "" 23 - if isBlank && prevBlank { 24 - // Skip consecutive blank lines 25 - continue 26 - } 27 - result = append(result, line) 28 - prevBlank = isBlank 29 - } 30 - 31 - // Join and trim 32 - output := strings.Join(result, "\n") 33 - output = strings.TrimSpace(output) 34 - 35 - return output 36 - } 37 - 38 - // execTemplate is a helper for executing templates and formatting the output 39 - func execTemplate(tmpl interface{}, templateName string, data interface{}) (string, error) { 40 - var buf bytes.Buffer 41 - 42 - type executor interface { 43 - ExecuteTemplate(*bytes.Buffer, string, interface{}) error 44 - } 45 - 46 - t, ok := tmpl.(executor) 47 - if !ok { 48 - panic("template does not implement ExecuteTemplate") 49 - } 50 - 51 - err := t.ExecuteTemplate(&buf, templateName, data) 52 - if err != nil { 53 - return "", err 54 - } 55 - 56 - return formatHTML(buf.String()), nil 57 - }
+10
internal/handlers/__snapshots__/feed_api.snap
··· 1 + --- 2 + title: feed_api 3 + test_name: TestFeedAPI_Snapshot 4 + file_name: api_snapshot_test.go 5 + version: 0.1.0 6 + --- 7 + { 8 + "isAuthenticated": false, 9 + "items": null 10 + }
+425
internal/handlers/api_snapshot_test.go
··· 1 + package handlers 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "net/http" 8 + "net/http/httptest" 9 + "testing" 10 + 11 + "arabica/internal/models" 12 + 13 + "github.com/ptdewey/shutter" 14 + ) 15 + 16 + // TestAPIMe_Snapshot tests the /api/me endpoint response format 17 + func TestAPIMe_Snapshot(t *testing.T) { 18 + tc := NewTestContext() 19 + 20 + req := NewAuthenticatedRequest("GET", "/api/me", nil) 21 + rec := httptest.NewRecorder() 22 + 23 + tc.Handler.HandleAPIMe(rec, req) 24 + 25 + // For unauthenticated scenario, just verify status code 26 + if rec.Code != http.StatusUnauthorized { 27 + t.Errorf("Expected status 401, got %d", rec.Code) 28 + } 29 + } 30 + 31 + // TestAPIListAll_Snapshot tests the /api/data endpoint response format 32 + func TestAPIListAll_Snapshot(t *testing.T) { 33 + tc := NewTestContext() 34 + 35 + // Mock store to return test data 36 + tc.MockStore.ListBeansFunc = func(ctx context.Context) ([]*models.Bean, error) { 37 + return []*models.Bean{tc.Fixtures.Bean}, nil 38 + } 39 + tc.MockStore.ListRoastersFunc = func(ctx context.Context) ([]*models.Roaster, error) { 40 + return []*models.Roaster{tc.Fixtures.Roaster}, nil 41 + } 42 + tc.MockStore.ListGrindersFunc = func(ctx context.Context) ([]*models.Grinder, error) { 43 + return []*models.Grinder{tc.Fixtures.Grinder}, nil 44 + } 45 + tc.MockStore.ListBrewersFunc = func(ctx context.Context) ([]*models.Brewer, error) { 46 + return []*models.Brewer{tc.Fixtures.Brewer}, nil 47 + } 48 + tc.MockStore.ListBrewsFunc = func(ctx context.Context, userID int) ([]*models.Brew, error) { 49 + return []*models.Brew{tc.Fixtures.Brew}, nil 50 + } 51 + 52 + req := NewAuthenticatedRequest("GET", "/api/data", nil) 53 + rec := httptest.NewRecorder() 54 + 55 + tc.Handler.HandleAPIListAll(rec, req) 56 + 57 + // For unauthenticated scenario, status will be 401 58 + if rec.Code == http.StatusUnauthorized { 59 + return 60 + } 61 + 62 + // Snapshot the JSON response 63 + if rec.Code == http.StatusOK { 64 + shutter.SnapJSON(t, "api_list_all", rec.Body.String(), 65 + shutter.ScrubTimestamp(), 66 + shutter.IgnoreKey("created_at"), 67 + ) 68 + } 69 + } 70 + 71 + // TestBeanCreate_Success_Snapshot tests successful bean creation response 72 + func TestBeanCreate_Success_Snapshot(t *testing.T) { 73 + tc := NewTestContext() 74 + 75 + // Mock successful bean creation 76 + tc.MockStore.CreateBeanFunc = func(ctx context.Context, bean *models.CreateBeanRequest) (*models.Bean, error) { 77 + return &models.Bean{ 78 + RKey: "test-bean-rkey", 79 + Name: bean.Name, 80 + Origin: bean.Origin, 81 + RoastLevel: bean.RoastLevel, 82 + Process: bean.Process, 83 + }, nil 84 + } 85 + 86 + reqBody := models.CreateBeanRequest{ 87 + Name: "Test Bean", 88 + Origin: "Ethiopia", 89 + RoastLevel: "Medium", 90 + Process: "Washed", 91 + } 92 + body, _ := json.Marshal(reqBody) 93 + 94 + req := NewAuthenticatedRequest("POST", "/api/beans", bytes.NewReader(body)) 95 + req.Header.Set("Content-Type", "application/json") 96 + rec := httptest.NewRecorder() 97 + 98 + tc.Handler.HandleBeanCreate(rec, req) 99 + 100 + // For unauthenticated, will be 401 101 + if rec.Code == http.StatusUnauthorized { 102 + return 103 + } 104 + 105 + // Snapshot the JSON response 106 + if rec.Code == http.StatusCreated { 107 + shutter.SnapJSON(t, "bean_create_success", rec.Body.String(), 108 + shutter.ScrubTimestamp(), 109 + shutter.IgnoreKey("created_at"), 110 + shutter.IgnoreKey("rkey"), 111 + ) 112 + } 113 + } 114 + 115 + // TestBeanUpdate_Success_Snapshot tests successful bean update response 116 + func TestBeanUpdate_Success_Snapshot(t *testing.T) { 117 + tc := NewTestContext() 118 + 119 + // Mock successful bean update 120 + tc.MockStore.UpdateBeanByRKeyFunc = func(ctx context.Context, rkey string, bean *models.UpdateBeanRequest) error { 121 + return nil 122 + } 123 + 124 + reqBody := models.UpdateBeanRequest{ 125 + Name: "Updated Bean", 126 + Origin: "Colombia", 127 + RoastLevel: "Dark", 128 + } 129 + body, _ := json.Marshal(reqBody) 130 + 131 + req := NewAuthenticatedRequest("PUT", "/api/beans/test-rkey", bytes.NewReader(body)) 132 + req.Header.Set("Content-Type", "application/json") 133 + req.SetPathValue("id", "test-rkey") 134 + rec := httptest.NewRecorder() 135 + 136 + tc.Handler.HandleBeanUpdate(rec, req) 137 + 138 + if rec.Code == http.StatusUnauthorized { 139 + return 140 + } 141 + 142 + // Snapshot the JSON response 143 + if rec.Code == http.StatusOK { 144 + shutter.SnapJSON(t, "bean_update_success", rec.Body.String()) 145 + } 146 + } 147 + 148 + // TestRoasterCreate_Success_Snapshot tests successful roaster creation response 149 + func TestRoasterCreate_Success_Snapshot(t *testing.T) { 150 + tc := NewTestContext() 151 + 152 + // Mock successful roaster creation 153 + tc.MockStore.CreateRoasterFunc = func(ctx context.Context, roaster *models.CreateRoasterRequest) (*models.Roaster, error) { 154 + return &models.Roaster{ 155 + RKey: "test-roaster-rkey", 156 + Name: roaster.Name, 157 + Location: roaster.Location, 158 + Website: roaster.Website, 159 + }, nil 160 + } 161 + 162 + reqBody := models.CreateRoasterRequest{ 163 + Name: "Test Roaster", 164 + Location: "Portland, OR", 165 + Website: "https://example.com", 166 + } 167 + body, _ := json.Marshal(reqBody) 168 + 169 + req := NewAuthenticatedRequest("POST", "/api/roasters", bytes.NewReader(body)) 170 + req.Header.Set("Content-Type", "application/json") 171 + rec := httptest.NewRecorder() 172 + 173 + tc.Handler.HandleRoasterCreate(rec, req) 174 + 175 + if rec.Code == http.StatusUnauthorized { 176 + return 177 + } 178 + 179 + // Snapshot the JSON response 180 + if rec.Code == http.StatusCreated { 181 + shutter.SnapJSON(t, "roaster_create_success", rec.Body.String(), 182 + shutter.ScrubTimestamp(), 183 + shutter.IgnoreKey("created_at"), 184 + shutter.IgnoreKey("rkey"), 185 + ) 186 + } 187 + } 188 + 189 + // TestGrinderCreate_Success_Snapshot tests successful grinder creation response 190 + func TestGrinderCreate_Success_Snapshot(t *testing.T) { 191 + tc := NewTestContext() 192 + 193 + // Mock successful grinder creation 194 + tc.MockStore.CreateGrinderFunc = func(ctx context.Context, grinder *models.CreateGrinderRequest) (*models.Grinder, error) { 195 + return &models.Grinder{ 196 + RKey: "test-grinder-rkey", 197 + Name: grinder.Name, 198 + GrinderType: grinder.GrinderType, 199 + BurrType: grinder.BurrType, 200 + }, nil 201 + } 202 + 203 + reqBody := models.CreateGrinderRequest{ 204 + Name: "Test Grinder", 205 + GrinderType: "Manual", 206 + BurrType: "Conical", 207 + } 208 + body, _ := json.Marshal(reqBody) 209 + 210 + req := NewAuthenticatedRequest("POST", "/api/grinders", bytes.NewReader(body)) 211 + req.Header.Set("Content-Type", "application/json") 212 + rec := httptest.NewRecorder() 213 + 214 + tc.Handler.HandleGrinderCreate(rec, req) 215 + 216 + if rec.Code == http.StatusUnauthorized { 217 + return 218 + } 219 + 220 + // Snapshot the JSON response 221 + if rec.Code == http.StatusCreated { 222 + shutter.SnapJSON(t, "grinder_create_success", rec.Body.String(), 223 + shutter.ScrubTimestamp(), 224 + shutter.IgnoreKey("created_at"), 225 + shutter.IgnoreKey("rkey"), 226 + ) 227 + } 228 + } 229 + 230 + // TestBrewerCreate_Success_Snapshot tests successful brewer creation response 231 + func TestBrewerCreate_Success_Snapshot(t *testing.T) { 232 + tc := NewTestContext() 233 + 234 + // Mock successful brewer creation 235 + tc.MockStore.CreateBrewerFunc = func(ctx context.Context, brewer *models.CreateBrewerRequest) (*models.Brewer, error) { 236 + return &models.Brewer{ 237 + RKey: "test-brewer-rkey", 238 + Name: brewer.Name, 239 + BrewerType: brewer.BrewerType, 240 + Description: brewer.Description, 241 + }, nil 242 + } 243 + 244 + reqBody := models.CreateBrewerRequest{ 245 + Name: "Test Brewer", 246 + BrewerType: "Pour Over", 247 + Description: "V60", 248 + } 249 + body, _ := json.Marshal(reqBody) 250 + 251 + req := NewAuthenticatedRequest("POST", "/api/brewers", bytes.NewReader(body)) 252 + req.Header.Set("Content-Type", "application/json") 253 + rec := httptest.NewRecorder() 254 + 255 + tc.Handler.HandleBrewerCreate(rec, req) 256 + 257 + if rec.Code == http.StatusUnauthorized { 258 + return 259 + } 260 + 261 + // Snapshot the JSON response 262 + if rec.Code == http.StatusCreated { 263 + shutter.SnapJSON(t, "brewer_create_success", rec.Body.String(), 264 + shutter.ScrubTimestamp(), 265 + shutter.IgnoreKey("created_at"), 266 + shutter.IgnoreKey("rkey"), 267 + ) 268 + } 269 + } 270 + 271 + // TestBrewCreate_Success_Snapshot tests successful brew creation response 272 + func TestBrewCreate_Success_Snapshot(t *testing.T) { 273 + tc := NewTestContext() 274 + 275 + // Mock successful brew creation 276 + tc.MockStore.CreateBrewFunc = func(ctx context.Context, brew *models.CreateBrewRequest, userID int) (*models.Brew, error) { 277 + return &models.Brew{ 278 + RKey: "test-brew-rkey", 279 + BeanRKey: brew.BeanRKey, 280 + Method: brew.Method, 281 + Temperature: brew.Temperature, 282 + WaterAmount: brew.WaterAmount, 283 + CoffeeAmount: brew.CoffeeAmount, 284 + TimeSeconds: brew.TimeSeconds, 285 + GrindSize: brew.GrindSize, 286 + GrinderRKey: brew.GrinderRKey, 287 + BrewerRKey: brew.BrewerRKey, 288 + TastingNotes: brew.TastingNotes, 289 + Rating: brew.Rating, 290 + }, nil 291 + } 292 + 293 + reqBody := models.CreateBrewRequest{ 294 + BeanRKey: "bean-rkey", 295 + Method: "Pour Over", 296 + Temperature: 93.0, 297 + WaterAmount: 250, 298 + CoffeeAmount: 15.0, 299 + TimeSeconds: 180, 300 + GrindSize: "Medium-Fine", 301 + GrinderRKey: "grinder-rkey", 302 + BrewerRKey: "brewer-rkey", 303 + TastingNotes: "Bright and fruity", 304 + Rating: 8, 305 + } 306 + body, _ := json.Marshal(reqBody) 307 + 308 + req := NewAuthenticatedRequest("POST", "/brews", bytes.NewReader(body)) 309 + req.Header.Set("Content-Type", "application/json") 310 + rec := httptest.NewRecorder() 311 + 312 + tc.Handler.HandleBrewCreate(rec, req) 313 + 314 + if rec.Code == http.StatusUnauthorized { 315 + return 316 + } 317 + 318 + // Snapshot the JSON response 319 + if rec.Code == http.StatusCreated { 320 + shutter.SnapJSON(t, "brew_create_success", rec.Body.String(), 321 + shutter.ScrubTimestamp(), 322 + shutter.IgnoreKey("created_at"), 323 + shutter.IgnoreKey("rkey"), 324 + ) 325 + } 326 + } 327 + 328 + // TestBrewDelete_Success_Snapshot tests successful brew deletion response 329 + func TestBrewDelete_Success_Snapshot(t *testing.T) { 330 + tc := NewTestContext() 331 + 332 + // Mock successful brew deletion 333 + tc.MockStore.DeleteBrewByRKeyFunc = func(ctx context.Context, rkey string) error { 334 + return nil 335 + } 336 + 337 + req := NewAuthenticatedRequest("DELETE", "/brews/test-rkey", nil) 338 + req.SetPathValue("id", "test-rkey") 339 + rec := httptest.NewRecorder() 340 + 341 + tc.Handler.HandleBrewDelete(rec, req) 342 + 343 + if rec.Code == http.StatusUnauthorized { 344 + return 345 + } 346 + 347 + // Snapshot the JSON response 348 + if rec.Code == http.StatusOK { 349 + shutter.SnapJSON(t, "brew_delete_success", rec.Body.String()) 350 + } 351 + } 352 + 353 + // TestBeanDelete_Success_Snapshot tests successful bean deletion response 354 + func TestBeanDelete_Success_Snapshot(t *testing.T) { 355 + tc := NewTestContext() 356 + 357 + // Mock successful bean deletion 358 + tc.MockStore.DeleteBeanByRKeyFunc = func(ctx context.Context, rkey string) error { 359 + return nil 360 + } 361 + 362 + req := NewAuthenticatedRequest("DELETE", "/api/beans/test-rkey", nil) 363 + req.SetPathValue("id", "test-rkey") 364 + rec := httptest.NewRecorder() 365 + 366 + tc.Handler.HandleBeanDelete(rec, req) 367 + 368 + if rec.Code == http.StatusUnauthorized { 369 + return 370 + } 371 + 372 + // Snapshot the JSON response 373 + if rec.Code == http.StatusOK { 374 + shutter.SnapJSON(t, "bean_delete_success", rec.Body.String()) 375 + } 376 + } 377 + 378 + // TestFeedAPI_Snapshot tests the /api/feed-json endpoint response format 379 + func TestFeedAPI_Snapshot(t *testing.T) { 380 + tc := NewTestContext() 381 + 382 + req := NewUnauthenticatedRequest("GET", "/api/feed-json") 383 + rec := httptest.NewRecorder() 384 + 385 + tc.Handler.HandleFeedAPI(rec, req) 386 + 387 + // Snapshot the JSON response 388 + if rec.Code == http.StatusOK { 389 + shutter.SnapJSON(t, "feed_api", rec.Body.String(), 390 + shutter.ScrubTimestamp(), 391 + shutter.IgnoreKey("created_at"), 392 + shutter.IgnoreKey("indexed_at"), 393 + ) 394 + } 395 + } 396 + 397 + // TestResolveHandle_Success_Snapshot tests handle resolution response 398 + func TestResolveHandle_Success_Snapshot(t *testing.T) { 399 + tc := NewTestContext() 400 + 401 + req := NewUnauthenticatedRequest("GET", "/api/resolve-handle?handle=test.bsky.social") 402 + rec := httptest.NewRecorder() 403 + 404 + tc.Handler.HandleResolveHandle(rec, req) 405 + 406 + // This will fail without proper setup, but we can snapshot the error response 407 + if rec.Code == http.StatusOK { 408 + shutter.SnapJSON(t, "resolve_handle_success", rec.Body.String()) 409 + } 410 + } 411 + 412 + // TestClientMetadata_Snapshot tests OAuth client metadata endpoint 413 + func TestClientMetadata_Snapshot(t *testing.T) { 414 + tc := NewTestContext() 415 + 416 + req := NewUnauthenticatedRequest("GET", "/client-metadata.json") 417 + rec := httptest.NewRecorder() 418 + 419 + tc.Handler.HandleClientMetadata(rec, req) 420 + 421 + // Snapshot the JSON response 422 + if rec.Code == http.StatusOK { 423 + shutter.SnapJSON(t, "client_metadata", rec.Body.String()) 424 + } 425 + }
+21 -377
internal/handlers/handlers.go
··· 4 4 "context" 5 5 "encoding/json" 6 6 "net/http" 7 - "sort" 8 7 "strconv" 9 8 "strings" 10 9 11 10 "arabica/internal/atproto" 12 - "arabica/internal/bff" 13 11 "arabica/internal/database" 14 12 "arabica/internal/feed" 15 13 "arabica/internal/models" ··· 82 80 return "" 83 81 } 84 82 85 - // getUserProfile fetches the profile for an authenticated user. 86 - // Returns nil if unable to fetch profile (non-fatal error). 87 - func (h *Handler) getUserProfile(ctx context.Context, did string) *bff.UserProfile { 88 - if did == "" { 89 - return nil 90 - } 91 - 92 - publicClient := atproto.NewPublicClient() 93 - profile, err := publicClient.GetProfile(ctx, did) 94 - if err != nil { 95 - log.Warn().Err(err).Str("did", did).Msg("Failed to fetch user profile for header") 96 - return nil 97 - } 98 - 99 - userProfile := &bff.UserProfile{ 100 - Handle: profile.Handle, 101 - } 102 - if profile.DisplayName != nil { 103 - userProfile.DisplayName = *profile.DisplayName 104 - } 105 - if profile.Avatar != nil { 106 - userProfile.Avatar = *profile.Avatar 107 - } 108 - 109 - return userProfile 110 - } 111 - 112 83 // getAtprotoStore creates a user-scoped atproto store from the request context. 113 84 // Returns the store and true if authenticated, or nil and false if not authenticated. 114 85 func (h *Handler) getAtprotoStore(r *http.Request) (database.Store, bool) { ··· 137 108 138 109 // SPA fallback handler - serves index.html for client-side routes 139 110 func (h *Handler) HandleSPAFallback(w http.ResponseWriter, r *http.Request) { 140 - http.ServeFile(w, r, "web/static/app/index.html") 111 + http.ServeFile(w, r, "static/app/index.html") 141 112 } 142 113 143 114 // Home page 144 115 145 - // Community feed partial (loaded async via HTMX) 146 - func (h *Handler) HandleFeedPartial(w http.ResponseWriter, r *http.Request) { 147 - var feedItems []*feed.FeedItem 148 - 149 - // Check if user is authenticated 150 - _, err := atproto.GetAuthenticatedDID(r.Context()) 151 - isAuthenticated := err == nil 152 - 153 - if h.feedService != nil { 154 - if isAuthenticated { 155 - feedItems, _ = h.feedService.GetRecentRecords(r.Context(), feed.FeedLimit) 156 - } else { 157 - // Unauthenticated users get a limited feed from the cache 158 - feedItems, _ = h.feedService.GetCachedPublicFeed(r.Context()) 159 - } 160 - } 161 - 162 - if err := bff.RenderFeedPartial(w, feedItems, isAuthenticated); err != nil { 163 - http.Error(w, "Failed to render feed", http.StatusInternalServerError) 164 - log.Error().Err(err).Msg("Failed to render feed partial") 165 - } 166 - } 167 - 168 116 // API endpoint for feed (JSON) 169 117 func (h *Handler) HandleFeedAPI(w http.ResponseWriter, r *http.Request) { 170 118 var feedItems []*feed.FeedItem ··· 225 173 isOwnProfile := isAuthenticated && currentUserDID == targetDID 226 174 227 175 // Get profile info 228 - profile := h.getUserProfile(ctx, targetDID) 229 - if profile == nil { 176 + publicClient := atproto.NewPublicClient() 177 + profile, err := publicClient.GetProfile(ctx, targetDID) 178 + if err != nil { 179 + log.Warn().Err(err).Str("did", targetDID).Msg("Failed to fetch profile") 230 180 http.Error(w, "Profile not found", http.StatusNotFound) 231 181 return 232 182 } 233 183 234 184 // Fetch user's data using public client (works for any user) 235 - publicClient := atproto.NewPublicClient() 236 185 237 186 // Fetch all collections in parallel 238 187 g, ctx := errgroup.WithContext(ctx) ··· 360 309 } 361 310 362 311 // Brew list partial (loaded async via HTMX) 363 - func (h *Handler) HandleBrewListPartial(w http.ResponseWriter, r *http.Request) { 364 - // Require authentication 365 - store, authenticated := h.getAtprotoStore(r) 366 - if !authenticated { 367 - http.Error(w, "Authentication required", http.StatusUnauthorized) 368 - return 369 - } 370 - 371 - brews, err := store.ListBrews(r.Context(), 1) // User ID is not used with atproto 372 - if err != nil { 373 - http.Error(w, "Failed to fetch brews", http.StatusInternalServerError) 374 - log.Error().Err(err).Msg("Failed to fetch brews") 375 - return 376 - } 377 - 378 - if err := bff.RenderBrewListPartial(w, brews); err != nil { 379 - http.Error(w, "Failed to render content", http.StatusInternalServerError) 380 - log.Error().Err(err).Msg("Failed to render brew list partial") 381 - } 382 - } 383 - 384 - // Manage page partial (loaded async via HTMX) 385 - func (h *Handler) HandleManagePartial(w http.ResponseWriter, r *http.Request) { 386 - // Require authentication 387 - store, authenticated := h.getAtprotoStore(r) 388 - if !authenticated { 389 - http.Error(w, "Authentication required", http.StatusUnauthorized) 390 - return 391 - } 392 - 393 - ctx := r.Context() 394 - 395 - // Fetch all collections in parallel using errgroup for proper error handling 396 - // and automatic context cancellation on first error 397 - g, ctx := errgroup.WithContext(ctx) 398 - 399 - var beans []*models.Bean 400 - var roasters []*models.Roaster 401 - var grinders []*models.Grinder 402 - var brewers []*models.Brewer 403 - 404 - g.Go(func() error { 405 - var err error 406 - beans, err = store.ListBeans(ctx) 407 - return err 408 - }) 409 - g.Go(func() error { 410 - var err error 411 - roasters, err = store.ListRoasters(ctx) 412 - return err 413 - }) 414 - g.Go(func() error { 415 - var err error 416 - grinders, err = store.ListGrinders(ctx) 417 - return err 418 - }) 419 - g.Go(func() error { 420 - var err error 421 - brewers, err = store.ListBrewers(ctx) 422 - return err 423 - }) 424 - 425 - if err := g.Wait(); err != nil { 426 - http.Error(w, "Failed to fetch data", http.StatusInternalServerError) 427 - log.Error().Err(err).Msg("Failed to fetch manage page data") 428 - return 429 - } 430 - 431 - // Link beans to their roasters 432 - atproto.LinkBeansToRoasters(beans, roasters) 433 - 434 - if err := bff.RenderManagePartial(w, beans, roasters, grinders, brewers); err != nil { 435 - http.Error(w, "Failed to render content", http.StatusInternalServerError) 436 - log.Error().Err(err).Msg("Failed to render manage partial") 437 - } 438 - } 439 - 440 - // List all brews 441 - 442 - // Show new brew form 443 - 444 - // Show brew view page 445 - 446 312 // resolveBrewReferences resolves bean, grinder, and brewer references for a brew 447 313 func (h *Handler) resolveBrewReferences(ctx context.Context, brew *models.Brew, ownerDID string, record map[string]interface{}) error { 448 314 publicClient := atproto.NewPublicClient() ··· 1015 881 } 1016 882 1017 883 // Fetch user profile 1018 - userProfile := h.getUserProfile(r.Context(), didStr) 1019 - if userProfile == nil { 884 + publicClient := atproto.NewPublicClient() 885 + profile, err := publicClient.GetProfile(r.Context(), didStr) 886 + if err != nil { 887 + log.Warn().Err(err).Str("did", didStr).Msg("Failed to fetch user profile") 1020 888 http.Error(w, "Failed to fetch user profile", http.StatusInternalServerError) 1021 889 return 1022 890 } 1023 891 892 + displayName := "" 893 + if profile.DisplayName != nil { 894 + displayName = *profile.DisplayName 895 + } 896 + avatar := "" 897 + if profile.Avatar != nil { 898 + avatar = *profile.Avatar 899 + } 900 + 1024 901 response := map[string]interface{}{ 1025 902 "did": didStr, 1026 - "handle": userProfile.Handle, 1027 - "displayName": userProfile.DisplayName, 1028 - "avatar": userProfile.Avatar, 903 + "handle": profile.Handle, 904 + "displayName": displayName, 905 + "avatar": avatar, 1029 906 } 1030 907 1031 908 w.Header().Set("Content-Type", "application/json") ··· 1491 1368 } 1492 1369 1493 1370 // HandleProfile displays a user's public profile with their brews and gear 1494 - 1495 - // HandleProfilePartial returns profile data content (loaded async via HTMX) 1496 - func (h *Handler) HandleProfilePartial(w http.ResponseWriter, r *http.Request) { 1497 - actor := r.PathValue("actor") 1498 - if actor == "" { 1499 - http.Error(w, "Actor parameter is required", http.StatusBadRequest) 1500 - return 1501 - } 1502 - 1503 - ctx := r.Context() 1504 - publicClient := atproto.NewPublicClient() 1505 - 1506 - // Determine if actor is a DID or handle 1507 - var did string 1508 - var err error 1509 - 1510 - if strings.HasPrefix(actor, "did:") { 1511 - did = actor 1512 - } else { 1513 - did, err = publicClient.ResolveHandle(ctx, actor) 1514 - if err != nil { 1515 - log.Warn().Err(err).Str("handle", actor).Msg("Failed to resolve handle") 1516 - http.Error(w, "User not found", http.StatusNotFound) 1517 - return 1518 - } 1519 - } 1520 - 1521 - // Fetch all user data in parallel 1522 - g, gCtx := errgroup.WithContext(ctx) 1523 - 1524 - var brews []*models.Brew 1525 - var beans []*models.Bean 1526 - var roasters []*models.Roaster 1527 - var grinders []*models.Grinder 1528 - var brewers []*models.Brewer 1529 - 1530 - // Maps for resolving references 1531 - var beanMap map[string]*models.Bean 1532 - var beanRoasterRefMap map[string]string 1533 - var roasterMap map[string]*models.Roaster 1534 - var brewerMap map[string]*models.Brewer 1535 - var grinderMap map[string]*models.Grinder 1536 - 1537 - // Fetch beans 1538 - g.Go(func() error { 1539 - output, err := publicClient.ListRecords(gCtx, did, atproto.NSIDBean, 100) 1540 - if err != nil { 1541 - return err 1542 - } 1543 - beanMap = make(map[string]*models.Bean) 1544 - beanRoasterRefMap = make(map[string]string) 1545 - beans = make([]*models.Bean, 0, len(output.Records)) 1546 - for _, record := range output.Records { 1547 - bean, err := atproto.RecordToBean(record.Value, record.URI) 1548 - if err != nil { 1549 - continue 1550 - } 1551 - beans = append(beans, bean) 1552 - beanMap[record.URI] = bean 1553 - if roasterRef, ok := record.Value["roasterRef"].(string); ok && roasterRef != "" { 1554 - beanRoasterRefMap[record.URI] = roasterRef 1555 - } 1556 - } 1557 - return nil 1558 - }) 1559 - 1560 - // Fetch roasters 1561 - g.Go(func() error { 1562 - output, err := publicClient.ListRecords(gCtx, did, atproto.NSIDRoaster, 100) 1563 - if err != nil { 1564 - return err 1565 - } 1566 - roasterMap = make(map[string]*models.Roaster) 1567 - roasters = make([]*models.Roaster, 0, len(output.Records)) 1568 - for _, record := range output.Records { 1569 - roaster, err := atproto.RecordToRoaster(record.Value, record.URI) 1570 - if err != nil { 1571 - continue 1572 - } 1573 - roasters = append(roasters, roaster) 1574 - roasterMap[record.URI] = roaster 1575 - } 1576 - return nil 1577 - }) 1578 - 1579 - // Fetch grinders 1580 - g.Go(func() error { 1581 - output, err := publicClient.ListRecords(gCtx, did, atproto.NSIDGrinder, 100) 1582 - if err != nil { 1583 - return err 1584 - } 1585 - grinderMap = make(map[string]*models.Grinder) 1586 - grinders = make([]*models.Grinder, 0, len(output.Records)) 1587 - for _, record := range output.Records { 1588 - grinder, err := atproto.RecordToGrinder(record.Value, record.URI) 1589 - if err != nil { 1590 - continue 1591 - } 1592 - grinders = append(grinders, grinder) 1593 - grinderMap[record.URI] = grinder 1594 - } 1595 - return nil 1596 - }) 1597 - 1598 - // Fetch brewers 1599 - g.Go(func() error { 1600 - output, err := publicClient.ListRecords(gCtx, did, atproto.NSIDBrewer, 100) 1601 - if err != nil { 1602 - return err 1603 - } 1604 - brewerMap = make(map[string]*models.Brewer) 1605 - brewers = make([]*models.Brewer, 0, len(output.Records)) 1606 - for _, record := range output.Records { 1607 - brewer, err := atproto.RecordToBrewer(record.Value, record.URI) 1608 - if err != nil { 1609 - continue 1610 - } 1611 - brewers = append(brewers, brewer) 1612 - brewerMap[record.URI] = brewer 1613 - } 1614 - return nil 1615 - }) 1616 - 1617 - // Fetch brews 1618 - g.Go(func() error { 1619 - output, err := publicClient.ListRecords(gCtx, did, atproto.NSIDBrew, 100) 1620 - if err != nil { 1621 - return err 1622 - } 1623 - brews = make([]*models.Brew, 0, len(output.Records)) 1624 - for _, record := range output.Records { 1625 - brew, err := atproto.RecordToBrew(record.Value, record.URI) 1626 - if err != nil { 1627 - continue 1628 - } 1629 - // Store the raw record for reference resolution later 1630 - brew.BeanRKey = "" 1631 - if beanRef, ok := record.Value["beanRef"].(string); ok { 1632 - brew.BeanRKey = beanRef 1633 - } 1634 - if grinderRef, ok := record.Value["grinderRef"].(string); ok { 1635 - brew.GrinderRKey = grinderRef 1636 - } 1637 - if brewerRef, ok := record.Value["brewerRef"].(string); ok { 1638 - brew.BrewerRKey = brewerRef 1639 - } 1640 - brews = append(brews, brew) 1641 - } 1642 - return nil 1643 - }) 1644 - 1645 - if err := g.Wait(); err != nil { 1646 - log.Error().Err(err).Str("did", did).Msg("Failed to fetch user data for profile partial") 1647 - http.Error(w, "Failed to load profile data", http.StatusInternalServerError) 1648 - return 1649 - } 1650 - 1651 - // Resolve references for beans (roaster refs) 1652 - for _, bean := range beans { 1653 - if roasterRef, found := beanRoasterRefMap[atproto.BuildATURI(did, atproto.NSIDBean, bean.RKey)]; found { 1654 - if roaster, found := roasterMap[roasterRef]; found { 1655 - bean.Roaster = roaster 1656 - } 1657 - } 1658 - } 1659 - 1660 - // Resolve references for brews 1661 - for _, brew := range brews { 1662 - // Resolve bean reference 1663 - if brew.BeanRKey != "" { 1664 - if bean, found := beanMap[brew.BeanRKey]; found { 1665 - brew.Bean = bean 1666 - } 1667 - } 1668 - // Resolve grinder reference 1669 - if brew.GrinderRKey != "" { 1670 - if grinder, found := grinderMap[brew.GrinderRKey]; found { 1671 - brew.GrinderObj = grinder 1672 - } 1673 - } 1674 - // Resolve brewer reference 1675 - if brew.BrewerRKey != "" { 1676 - if brewer, found := brewerMap[brew.BrewerRKey]; found { 1677 - brew.BrewerObj = brewer 1678 - } 1679 - } 1680 - } 1681 - 1682 - // Sort brews in reverse chronological order (newest first) 1683 - sort.Slice(brews, func(i, j int) bool { 1684 - return brews[i].CreatedAt.After(brews[j].CreatedAt) 1685 - }) 1686 - 1687 - // Check if the viewing user is the profile owner 1688 - didStr, err := atproto.GetAuthenticatedDID(ctx) 1689 - isAuthenticated := err == nil && didStr != "" 1690 - isOwnProfile := isAuthenticated && didStr == did 1691 - 1692 - // Render profile content partial (use actor as handle, which is already the handle if provided as such) 1693 - profileHandle := actor 1694 - if strings.HasPrefix(actor, "did:") { 1695 - // If actor was a DID, we need to resolve it to a handle 1696 - // We can get it from the first brew's author if available, or fetch profile 1697 - profile, err := publicClient.GetProfile(ctx, did) 1698 - if err == nil { 1699 - profileHandle = profile.Handle 1700 - } else { 1701 - profileHandle = did // Fallback to DID if we can't get handle 1702 - } 1703 - } 1704 - 1705 - if err := bff.RenderProfilePartial(w, brews, beans, roasters, grinders, brewers, isOwnProfile, profileHandle); err != nil { 1706 - http.Error(w, "Failed to render content", http.StatusInternalServerError) 1707 - log.Error().Err(err).Msg("Failed to render profile partial") 1708 - } 1709 - } 1710 - 1711 - // HandleNotFound renders the 404 page 1712 - func (h *Handler) HandleNotFound(w http.ResponseWriter, r *http.Request) { 1713 - // Check if current user is authenticated (for nav bar state) 1714 - didStr, err := atproto.GetAuthenticatedDID(r.Context()) 1715 - isAuthenticated := err == nil && didStr != "" 1716 - 1717 - var userProfile *bff.UserProfile 1718 - if isAuthenticated { 1719 - userProfile = h.getUserProfile(r.Context(), didStr) 1720 - } 1721 - 1722 - if err := bff.Render404(w, isAuthenticated, didStr, userProfile); err != nil { 1723 - http.Error(w, "Page not found", http.StatusNotFound) 1724 - log.Error().Err(err).Msg("Failed to render 404 page") 1725 - } 1726 - }
-125
internal/handlers/handlers_test.go
··· 16 16 "github.com/stretchr/testify/assert" 17 17 ) 18 18 19 - // TestHandleBrewListPartial_Success tests successful brew list retrieval 20 - func TestHandleBrewListPartial_Success(t *testing.T) { 21 - tc := NewTestContext() 22 - fixtures := tc.Fixtures 23 - 24 - // Mock store to return test brews 25 - tc.MockStore.ListBrewsFunc = func(ctx context.Context, userID int) ([]*models.Brew, error) { 26 - return []*models.Brew{fixtures.Brew}, nil 27 - } 28 - 29 - // Create handler with injected mock store dependency 30 - handler := tc.Handler 31 - 32 - // We need to modify the handler to use our mock store 33 - // Since getAtprotoStore creates a new store, we'll need to test this differently 34 - // For now, let's test the authentication flow 35 - 36 - req := NewAuthenticatedRequest("GET", "/api/brews/list", nil) 37 - rec := httptest.NewRecorder() 38 19 39 - handler.HandleBrewListPartial(rec, req) 40 - 41 - // The handler will try to create an atproto store which will fail without proper setup 42 - // This shows we need architectural changes to make handlers testable 43 - assert.Equal(t, http.StatusUnauthorized, rec.Code, "Expected unauthorized when OAuth is nil") 44 - } 45 - 46 - // TestHandleBrewListPartial_Unauthenticated tests unauthenticated access 47 - func TestHandleBrewListPartial_Unauthenticated(t *testing.T) { 48 - tc := NewTestContext() 49 - 50 - req := NewUnauthenticatedRequest("GET", "/api/brews/list") 51 - rec := httptest.NewRecorder() 52 - 53 - tc.Handler.HandleBrewListPartial(rec, req) 54 - 55 - assert.Equal(t, http.StatusUnauthorized, rec.Code) 56 - assert.Contains(t, rec.Body.String(), "Authentication required") 57 - } 58 20 59 21 // TestHandleBrewDelete_Success tests successful brew deletion 60 22 func TestHandleBrewDelete_Success(t *testing.T) { ··· 193 155 } 194 156 } 195 157 196 - // TestHandleBrewExport tests brew export functionality 197 - func TestHandleBrewExport(t *testing.T) { 198 - tc := NewTestContext() 199 - fixtures := tc.Fixtures 200 - 201 - tc.MockStore.ListBrewsFunc = func(ctx context.Context, userID int) ([]*models.Brew, error) { 202 - return []*models.Brew{fixtures.Brew}, nil 203 - } 204 - 205 - req := NewAuthenticatedRequest("GET", "/brews/export", nil) 206 - rec := httptest.NewRecorder() 207 - 208 - tc.Handler.HandleBrewExport(rec, req) 209 - 210 - // Will be unauthorized due to OAuth being nil 211 - assert.Equal(t, http.StatusUnauthorized, rec.Code) 212 - } 213 158 214 159 // TestHandleAPIListAll tests the API endpoint for listing all user data 215 160 func TestHandleAPIListAll(t *testing.T) { ··· 260 205 assert.Contains(t, []int{http.StatusInternalServerError, http.StatusUnauthorized}, rec.Code) 261 206 } 262 207 263 - // TestHandleHome tests home page rendering 264 - func TestHandleHome(t *testing.T) { 265 - tests := []struct { 266 - name string 267 - authenticated bool 268 - wantStatus int 269 - }{ 270 - {"authenticated user", true, http.StatusOK}, 271 - {"unauthenticated user", false, http.StatusOK}, 272 - } 273 - 274 - for _, tt := range tests { 275 - t.Run(tt.name, func(t *testing.T) { 276 - tc := NewTestContext() 277 - 278 - var req *http.Request 279 - if tt.authenticated { 280 - req = NewAuthenticatedRequest("GET", "/", nil) 281 - } else { 282 - req = NewUnauthenticatedRequest("GET", "/") 283 - } 284 - rec := httptest.NewRecorder() 285 - 286 - tc.Handler.HandleHome(rec, req) 287 - 288 - // Home page should render regardless of auth status 289 - // Will fail due to template rendering without proper setup 290 - // but should not panic 291 - assert.NotEqual(t, 0, rec.Code) 292 - }) 293 - } 294 - } 295 - 296 - // TestHandleManagePartial tests manage page data fetching 297 - func TestHandleManagePartial(t *testing.T) { 298 - tc := NewTestContext() 299 - fixtures := tc.Fixtures 300 208 301 - // Mock all the data fetches 302 - tc.MockStore.ListBeansFunc = func(ctx context.Context) ([]*models.Bean, error) { 303 - return []*models.Bean{fixtures.Bean}, nil 304 - } 305 - tc.MockStore.ListRoastersFunc = func(ctx context.Context) ([]*models.Roaster, error) { 306 - return []*models.Roaster{fixtures.Roaster}, nil 307 - } 308 - tc.MockStore.ListGrindersFunc = func(ctx context.Context) ([]*models.Grinder, error) { 309 - return []*models.Grinder{fixtures.Grinder}, nil 310 - } 311 - tc.MockStore.ListBrewersFunc = func(ctx context.Context) ([]*models.Brewer, error) { 312 - return []*models.Brewer{fixtures.Brewer}, nil 313 - } 314 209 315 - req := NewAuthenticatedRequest("GET", "/manage/content", nil) 316 - rec := httptest.NewRecorder() 317 - 318 - tc.Handler.HandleManagePartial(rec, req) 319 - 320 - // Will be unauthorized due to OAuth being nil 321 - assert.Equal(t, http.StatusUnauthorized, rec.Code) 322 - } 323 - 324 - // TestHandleManagePartial_Unauthenticated tests unauthenticated access to manage 325 - func TestHandleManagePartial_Unauthenticated(t *testing.T) { 326 - tc := NewTestContext() 327 - 328 - req := NewUnauthenticatedRequest("GET", "/manage/content") 329 - rec := httptest.NewRecorder() 330 - 331 - tc.Handler.HandleManagePartial(rec, req) 332 - 333 - assert.Equal(t, http.StatusUnauthorized, rec.Code) 334 - } 335 210 336 211 // TestParsePours tests pour parsing from form data 337 212 func TestParsePours(t *testing.T) {
+1 -12
internal/routing/routing.go
··· 54 54 // API endpoint for profile data (JSON for Svelte) 55 55 mux.HandleFunc("GET /api/profile-json/{actor}", h.HandleProfileAPI) 56 56 57 - // HTMX partials (legacy - being phased out) 58 - // These return HTML fragments and should only be accessed via HTMX 59 - // Still used by manage page and some dynamic content 60 - mux.Handle("GET /api/feed", middleware.RequireHTMXMiddleware(http.HandlerFunc(h.HandleFeedPartial))) 61 - mux.Handle("GET /api/brews", middleware.RequireHTMXMiddleware(http.HandlerFunc(h.HandleBrewListPartial))) 62 - mux.Handle("GET /api/manage", middleware.RequireHTMXMiddleware(http.HandlerFunc(h.HandleManagePartial))) 63 - mux.Handle("GET /api/profile/{actor}", middleware.RequireHTMXMiddleware(http.HandlerFunc(h.HandleProfilePartial))) 64 - 65 57 // Brew CRUD API routes (used by Svelte SPA) 66 58 mux.Handle("POST /brews", cop.Handler(http.HandlerFunc(h.HandleBrewCreate))) 67 59 mux.Handle("PUT /brews/{id}", cop.Handler(http.HandlerFunc(h.HandleBrewUpdate))) ··· 85 77 mux.Handle("DELETE /api/brewers/{id}", cop.Handler(http.HandlerFunc(h.HandleBrewerDelete))) 86 78 87 79 // Static files (must come after specific routes) 88 - fs := http.FileServer(http.Dir("web/static")) 80 + fs := http.FileServer(http.Dir("static")) 89 81 mux.Handle("GET /static/", http.StripPrefix("/static/", fs)) 90 82 91 83 // SPA fallback - serve index.html for all unmatched routes (client-side routing) 92 84 // This must be after all API routes and static files 93 85 mux.HandleFunc("GET /{path...}", h.HandleSPAFallback) 94 86 95 - // Catch-all 404 handler - now only used for non-GET requests 96 - mux.HandleFunc("/", h.HandleNotFound) 97 - 98 87 // Apply middleware in order (outermost first, innermost last) 99 88 var handler http.Handler = mux 100 89
+1 -2
justfile
··· 8 8 @go test ./... -cover -coverprofile=cover.out 9 9 10 10 style: 11 - @nix develop --command tailwindcss -i web/static/css/style.css -o web/static/css/output.css --minify 12 - # @tailwindcss -i web/static/css/style.css -o web/static/css/output.css --minify 11 + @nix develop --command tailwindcss -i static/css/style.css -o static/css/output.css --minify 13 12 14 13 build-ui: 15 14 @pushd frontend || exit 1 && npm run build && popd || exit 1
+1
static/app/assets/index-C3lHx5fe.css
··· 1 + .line-clamp-2.svelte-efadq{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}@keyframes svelte-1hp7v65-fade-in{0%{opacity:0;transform:translateY(-10px)}to{opacity:1;transform:translateY(0)}}.animate-fade-in.svelte-1hp7v65{animation:svelte-1hp7v65-fade-in .2s ease-out}
+13
static/app/assets/index-D8yIXtJi.js
··· 1 + var lr=Object.defineProperty;var rr=(n,e,t)=>e in n?lr(n,e,{enumerable:!0,configurable:!0,writable:!0,value:t}):n[e]=t;var $t=(n,e,t)=>rr(n,typeof e!="symbol"?e+"":e,t);(function(){const e=document.createElement("link").relList;if(e&&e.supports&&e.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))l(i);new MutationObserver(i=>{for(const r of i)if(r.type==="childList")for(const s of r.addedNodes)s.tagName==="LINK"&&s.rel==="modulepreload"&&l(s)}).observe(document,{childList:!0,subtree:!0});function t(i){const r={};return i.integrity&&(r.integrity=i.integrity),i.referrerPolicy&&(r.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?r.credentials="include":i.crossOrigin==="anonymous"?r.credentials="omit":r.credentials="same-origin",r}function l(i){if(i.ep)return;i.ep=!0;const r=t(i);fetch(i.href,r)}})();function W(){}function tn(n,e){for(const t in e)n[t]=e[t];return n}function $l(n){return n()}function pn(){return Object.create(null)}function ce(n){n.forEach($l)}function Vt(n){return typeof n=="function"}function We(n,e){return n!=n?e==e:n!==e||n&&typeof n=="object"||typeof n=="function"}let It;function gt(n,e){return n===e?!0:(It||(It=document.createElement("a")),It.href=e,n===It.href)}function or(n){return Object.keys(n).length===0}function ir(n,...e){if(n==null){for(const l of e)l(void 0);return W}const t=n.subscribe(...e);return t.unsubscribe?()=>t.unsubscribe():t}function ut(n,e,t){n.$$.on_destroy.push(ir(e,t))}function sr(n,e,t,l){if(n){const i=er(n,e,t,l);return n[0](i)}}function er(n,e,t,l){return n[1]&&l?tn(t.ctx.slice(),n[1](l(e))):t.ctx}function ar(n,e,t,l){return n[2],e.dirty}function ur(n,e,t,l,i,r){if(i){const s=er(e,t,l,r);n.p(s,i)}}function cr(n){if(n.ctx.length>32){const e=[],t=n.ctx.length/32;for(let l=0;l<t;l++)e[l]=-1;return e}return-1}const fr=typeof window<"u"?window:typeof globalThis<"u"?globalThis:global;function o(n,e){n.appendChild(e)}function y(n,e,t){n.insertBefore(e,t||null)}function k(n){n.parentNode&&n.parentNode.removeChild(n)}function Ge(n,e){for(let t=0;t<n.length;t+=1)n[t]&&n[t].d(e)}function f(n){return document.createElement(n)}function _n(n){return document.createElementNS("http://www.w3.org/2000/svg",n)}function C(n){return document.createTextNode(n)}function w(){return C(" ")}function ft(){return C("")}function z(n,e,t,l){return n.addEventListener(e,t,l),()=>n.removeEventListener(e,t,l)}function Ue(n){return function(e){return e.preventDefault(),n.call(this,e)}}function dr(n){return function(e){return e.stopPropagation(),n.call(this,e)}}function a(n,e,t){t==null?n.removeAttribute(e):n.getAttribute(e)!==t&&n.setAttribute(e,t)}function ot(n){return n===""?null:+n}function br(n){return Array.from(n.childNodes)}function j(n,e){e=""+e,n.data!==e&&(n.data=e)}function H(n,e){n.value=e??""}function Le(n,e,t){for(let l=0;l<n.options.length;l+=1){const i=n.options[l];if(i.__value===e){i.selected=!0;return}}(!t||e!==void 0)&&(n.selectedIndex=-1)}function at(n){const e=n.querySelector(":checked");return e&&e.__value}function mn(n,e){return new n(e)}let Rt;function Ft(n){Rt=n}function pr(){if(!Rt)throw new Error("Function called outside component initialization");return Rt}function vt(n){pr().$$.on_mount.push(n)}const Ot=[],ct=[];let Mt=[];const nn=[],_r=Promise.resolve();let ln=!1;function mr(){ln||(ln=!0,_r.then(tr))}function rt(n){Mt.push(n)}function mt(n){nn.push(n)}const en=new Set;let Tt=0;function tr(){if(Tt!==0)return;const n=Rt;do{try{for(;Tt<Ot.length;){const e=Ot[Tt];Tt++,Ft(e),wr(e.$$)}}catch(e){throw Ot.length=0,Tt=0,e}for(Ft(null),Ot.length=0,Tt=0;ct.length;)ct.pop()();for(let e=0;e<Mt.length;e+=1){const t=Mt[e];en.has(t)||(en.add(t),t())}Mt.length=0}while(Ot.length);for(;nn.length;)nn.pop()();ln=!1,en.clear(),Ft(n)}function wr(n){if(n.fragment!==null){n.update(),ce(n.before_update);const e=n.dirty;n.dirty=[-1],n.fragment&&n.fragment.p(n.ctx,e),n.after_update.forEach(rt)}}function hr(n){const e=[],t=[];Mt.forEach(l=>n.indexOf(l)===-1?e.push(l):t.push(l)),t.forEach(l=>l()),Mt=e}const qt=new Set;let xt;function jt(){xt={r:0,c:[],p:xt}}function Ht(){xt.r||ce(xt.c),xt=xt.p}function ve(n,e){n&&n.i&&(qt.delete(n),n.i(e))}function Oe(n,e,t,l){if(n&&n.o){if(qt.has(n))return;qt.add(n),xt.c.push(()=>{qt.delete(n),l&&(t&&n.d(1),l())}),n.o(e)}else l&&l()}function le(n){return(n==null?void 0:n.length)!==void 0?n:Array.from(n)}function gr(n,e){Oe(n,1,1,()=>{e.delete(n.key)})}function vr(n,e,t,l,i,r,s,c,u,b,d,p){let _=n.length,m=r.length,h=_;const g={};for(;h--;)g[n[h].key]=h;const B=[],v=new Map,x=new Map,S=[];for(h=m;h--;){const L=p(i,r,h),O=t(L);let D=s.get(O);D?S.push(()=>D.p(L,e)):(D=b(O,L),D.c()),v.set(O,B[h]=D),O in g&&x.set(O,Math.abs(h-g[O]))}const A=new Set,N=new Set;function P(L){ve(L,1),L.m(c,d),s.set(L.key,L),d=L.first,m--}for(;_&&m;){const L=B[m-1],O=n[_-1],D=L.key,F=O.key;L===O?(d=L.first,_--,m--):v.has(F)?!s.has(D)||A.has(D)?P(L):N.has(F)?_--:x.get(D)>x.get(F)?(N.add(D),P(L)):(A.add(F),_--):(u(O,s),_--)}for(;_--;){const L=n[_];v.has(L.key)||u(L,s)}for(;m;)P(B[m-1]);return ce(S),B}function wn(n,e){const t={},l={},i={$$scope:1};let r=n.length;for(;r--;){const s=n[r],c=e[r];if(c){for(const u in s)u in c||(l[u]=1);for(const u in c)i[u]||(t[u]=c[u],i[u]=1);n[r]=c}else for(const u in s)i[u]=1}for(const s in l)s in t||(t[s]=void 0);return t}function hn(n){return typeof n=="object"&&n!==null?n:{}}function wt(n,e,t){const l=n.$$.props[e];l!==void 0&&(n.$$.bound[l]=t,t(n.$$.ctx[l]))}function it(n){n&&n.c()}function nt(n,e,t){const{fragment:l,after_update:i}=n.$$;l&&l.m(e,t),rt(()=>{const r=n.$$.on_mount.map($l).filter(Vt);n.$$.on_destroy?n.$$.on_destroy.push(...r):ce(r),n.$$.on_mount=[]}),i.forEach(rt)}function lt(n,e){const t=n.$$;t.fragment!==null&&(hr(t.after_update),ce(t.on_destroy),t.fragment&&t.fragment.d(e),t.on_destroy=t.fragment=null,t.ctx=[])}function kr(n,e){n.$$.dirty[0]===-1&&(Ot.push(n),mr(),n.$$.dirty.fill(0)),n.$$.dirty[e/31|0]|=1<<e%31}function Qe(n,e,t,l,i,r,s=null,c=[-1]){const u=Rt;Ft(n);const b=n.$$={fragment:null,ctx:[],props:r,update:W,not_equal:i,bound:pn(),on_mount:[],on_destroy:[],on_disconnect:[],before_update:[],after_update:[],context:new Map(e.context||(u?u.$$.context:[])),callbacks:pn(),dirty:c,skip_bound:!1,root:e.target||u.$$.root};s&&s(b.root);let d=!1;if(b.ctx=t?t(n,e.props||{},(p,_,...m)=>{const h=m.length?m[0]:_;return b.ctx&&i(b.ctx[p],b.ctx[p]=h)&&(!b.skip_bound&&b.bound[p]&&b.bound[p](h),d&&kr(n,p)),_}):[],b.update(),d=!0,ce(b.before_update),b.fragment=l?l(b.ctx):!1,e.target){if(e.hydrate){const p=br(e.target);b.fragment&&b.fragment.l(p),p.forEach(k)}else b.fragment&&b.fragment.c();e.intro&&ve(n.$$.fragment),nt(n,e.target,e.anchor),tr()}Ft(u)}class Xe{constructor(){$t(this,"$$");$t(this,"$$set")}$destroy(){lt(this,1),this.$destroy=W}$on(e,t){if(!Vt(t))return W;const l=this.$$.callbacks[e]||(this.$$.callbacks[e]=[]);return l.push(t),()=>{const i=l.indexOf(t);i!==-1&&l.splice(i,1)}}$set(e){this.$$set&&!or(e)&&(this.$$.skip_bound=!0,this.$$set(e),this.$$.skip_bound=!1)}}const yr="4";typeof window<"u"&&(window.__svelte||(window.__svelte={v:new Set})).v.add(yr);function xr(n,e){if(n instanceof RegExp)return{keys:!1,pattern:n};var t,l,i,r,s=[],c="",u=n.split("/");for(u[0]||u.shift();i=u.shift();)t=i[0],t==="*"?(s.push("wild"),c+="/(.*)"):t===":"?(l=i.indexOf("?",1),r=i.indexOf(".",1),s.push(i.substring(1,~l?l:~r?r:i.length)),c+=~l&&!~r?"(?:/([^/]+?))?":"/([^/]+?)",~r&&(c+=(~l?"?":"")+"\\"+i.substring(r))):c+="/"+i;return{keys:s,pattern:new RegExp("^"+c+"/?$","i")}}function Cr(n,e){var t,l,i=[],r={},s=r.format=function(c){return c&&(c="/"+c.replace(/^\/|\/$/g,""),t.test(c)&&c.replace(t,"/"))};return n="/"+(n||"").replace(/^\/|\/$/g,""),t=n=="/"?/^\/+/:new RegExp("^\\"+n+"(?=\\/|$)\\/?","i"),r.route=function(c,u){c[0]=="/"&&!t.test(c)&&(c=n+c),history[(c===l||u?"replace":"push")+"State"](c,null,c)},r.on=function(c,u){return(c=xr(c)).fn=u,i.push(c),r},r.run=function(c){var u=0,b={},d,p;if(c=s(c||location.pathname)){for(c=c.match(/[^\?#]*/)[0],l=c;u<i.length;u++)if(d=(p=i[u]).pattern.exec(c)){for(u=0;u<p.keys.length;)b[p.keys[u]]=d[++u]||null;return p.fn(b),r}}return r},r.listen=function(c){gn("push"),gn("replace");function u(d){r.run()}function b(d){var p=d.target.closest("a"),_=p&&p.getAttribute("href");d.ctrlKey||d.metaKey||d.altKey||d.shiftKey||d.button||d.defaultPrevented||!_||p.target||p.host!==location.host||_[0]=="#"||(_[0]!="/"||t.test(_))&&(d.preventDefault(),r.route(_))}return addEventListener("popstate",u),addEventListener("replacestate",u),addEventListener("pushstate",u),addEventListener("click",b),r.unlisten=function(){removeEventListener("popstate",u),removeEventListener("replacestate",u),removeEventListener("pushstate",u),removeEventListener("click",b)},r.run(c)},r}function gn(n,e){history[n]||(history[n]=n,e=history[n+="State"],history[n]=function(t){var l=new Event(n.toLowerCase());return l.uri=t,e.apply(this,arguments),dispatchEvent(l)})}const Yt=Cr("/");function _e(n){Yt.route(n)}function vn(){window.history.back()}const Lt=[];function nr(n,e=W){let t;const l=new Set;function i(c){if(We(n,c)&&(n=c,t)){const u=!Lt.length;for(const b of l)b[1](),Lt.push(b,n);if(u){for(let b=0;b<Lt.length;b+=2)Lt[b][0](Lt[b+1]);Lt.length=0}}}function r(c){i(c(n))}function s(c,u=W){const b=[c,u];return l.add(b),l.size===1&&(t=e(i,r)||W),c(n),()=>{l.delete(b),l.size===0&&t&&(t(),t=null)}}return{set:i,update:r,subscribe:s}}class Ut extends Error{constructor(e,t,l){super(e),this.name="APIError",this.status=t,this.response=l}}async function Wt(n,e={}){const t={credentials:"same-origin",headers:{"Content-Type":"application/json",...e.headers},...e};try{const l=await fetch(n,t);if(l.status===401||l.status===403){const r=["/","/login","/about","/terms"],s=["/api/feed-json","/api/resolve-handle","/api/search-actors","/api/me"],c=window.location.pathname,u=s.some(b=>n.includes(b));throw!r.includes(c)&&!u&&(window.location.href="/login"),new Ut("Authentication required",l.status,l)}if(!l.ok){const r=await l.text();throw new Ut(r||`Request failed: ${l.statusText}`,l.status,l)}const i=l.headers.get("content-type");return!i||!i.includes("application/json")?null:await l.json()}catch(l){throw l instanceof Ut?l:new Ut(`Network error: ${l.message}`,0,null)}}const ge={get:n=>Wt(n,{method:"GET"}),post:(n,e)=>Wt(n,{method:"POST",body:JSON.stringify(e)}),put:(n,e)=>Wt(n,{method:"PUT",body:JSON.stringify(e)}),delete:n=>Wt(n,{method:"DELETE"})};function Br(){const{subscribe:n,set:e}=nr({isAuthenticated:!1,user:null,loading:!0});return{subscribe:n,async checkAuth(){try{const t=await ge.get("/api/me");e({isAuthenticated:!0,user:t,loading:!1})}catch{e({isAuthenticated:!1,user:null,loading:!1})}},async logout(){try{await ge.post("/logout",{}),e({isAuthenticated:!1,user:null,loading:!1}),window.location.href="/"}catch(t){console.error("Logout failed:",t)}},clear(){e({isAuthenticated:!1,user:null,loading:!1})}}}const pt=Br();function Ar(n){let e;return{c(){e=f("div"),e.innerHTML='<span class="text-brown-600 text-sm">?</span>',a(e,"class","w-10 h-10 rounded-full bg-brown-300 flex items-center justify-center hover:ring-2 hover:ring-brown-600 transition")},m(t,l){y(t,e,l)},p:W,d(t){t&&k(e)}}}function Sr(n){let e,t;return{c(){e=f("img"),gt(e.src,t=rn(n[0].Author.avatar))||a(e,"src",t),a(e,"alt",""),a(e,"class","w-10 h-10 rounded-full object-cover hover:ring-2 hover:ring-brown-600 transition")},m(l,i){y(l,e,i)},p(l,i){i&1&&!gt(e.src,t=rn(l[0].Author.avatar))&&a(e,"src",t)},d(l){l&&k(e)}}}function kn(n){let e,t=n[0].Author.displayName+"",l,i,r,s;return{c(){e=f("a"),l=C(t),a(e,"href",i="/profile/"+n[0].Author.handle),a(e,"class","font-medium text-brown-900 truncate hover:text-brown-700 hover:underline")},m(c,u){y(c,e,u),o(e,l),r||(s=z(e,"click",Ue(n[2])),r=!0)},p(c,u){u&1&&t!==(t=c[0].Author.displayName+"")&&j(l,t),u&1&&i!==(i="/profile/"+c[0].Author.handle)&&a(e,"href",i)},d(c){c&&k(e),r=!1,s()}}}function Nr(n){let e=n[0].Action+"",t;return{c(){t=C(e)},m(l,i){y(l,t,i)},p(l,i){i&1&&e!==(e=l[0].Action+"")&&j(t,e)},d(l){l&&k(t)}}}function Tr(n){let e,t,l,i,r,s,c;return{c(){e=f("span"),e.textContent="added a",t=w(),l=f("a"),i=C("new brew"),a(l,"href",r="/brews/"+n[0].Author.did+"/"+n[0].Brew.rkey),a(l,"class","font-semibold text-brown-800 hover:text-brown-900 hover:underline cursor-pointer")},m(u,b){y(u,e,b),y(u,t,b),y(u,l,b),o(l,i),s||(c=z(l,"click",Ue(n[4])),s=!0)},p(u,b){b&1&&r!==(r="/brews/"+u[0].Author.did+"/"+u[0].Brew.rkey)&&a(l,"href",r)},d(u){u&&(k(e),k(t),k(l)),s=!1,c()}}}function Lr(n){let e,t,l,i=n[0].Brewer.name+"",r,s,c=n[0].Brewer.brewer_type&&yn(n);return{c(){e=f("div"),t=f("div"),l=C("☕ "),r=C(i),s=w(),c&&c.c(),a(t,"class","font-semibold text-brown-900"),a(e,"class","bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200")},m(u,b){y(u,e,b),o(e,t),o(t,l),o(t,r),o(e,s),c&&c.m(e,null)},p(u,b){b&1&&i!==(i=u[0].Brewer.name+"")&&j(r,i),u[0].Brewer.brewer_type?c?c.p(u,b):(c=yn(u),c.c(),c.m(e,null)):c&&(c.d(1),c=null)},d(u){u&&k(e),c&&c.d()}}}function Or(n){let e,t,l,i=n[0].Grinder.name+"",r,s,c=n[0].Grinder.grinder_type&&xn(n);return{c(){e=f("div"),t=f("div"),l=C("⚙️ "),r=C(i),s=w(),c&&c.c(),a(t,"class","font-semibold text-brown-900"),a(e,"class","bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200")},m(u,b){y(u,e,b),o(e,t),o(t,l),o(t,r),o(e,s),c&&c.m(e,null)},p(u,b){b&1&&i!==(i=u[0].Grinder.name+"")&&j(r,i),u[0].Grinder.grinder_type?c?c.p(u,b):(c=xn(u),c.c(),c.m(e,null)):c&&(c.d(1),c=null)},d(u){u&&k(e),c&&c.d()}}}function Mr(n){let e,t,l,i=n[0].Roaster.name+"",r,s,c=n[0].Roaster.location&&Cn(n);return{c(){e=f("div"),t=f("div"),l=C("🏭 "),r=C(i),s=w(),c&&c.c(),a(t,"class","font-semibold text-brown-900"),a(e,"class","bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200")},m(u,b){y(u,e,b),o(e,t),o(t,l),o(t,r),o(e,s),c&&c.m(e,null)},p(u,b){b&1&&i!==(i=u[0].Roaster.name+"")&&j(r,i),u[0].Roaster.location?c?c.p(u,b):(c=Cn(u),c.c(),c.m(e,null)):c&&(c.d(1),c=null)},d(u){u&&k(e),c&&c.d()}}}function Er(n){let e,t,l=(n[0].Bean.name||n[0].Bean.origin)+"",i,r,s=n[0].Bean.origin&&Bn(n);return{c(){e=f("div"),t=f("div"),i=C(l),r=w(),s&&s.c(),a(t,"class","font-semibold text-brown-900"),a(e,"class","bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200")},m(c,u){y(c,e,u),o(e,t),o(t,i),o(e,r),s&&s.m(e,null)},p(c,u){u&1&&l!==(l=(c[0].Bean.name||c[0].Bean.origin)+"")&&j(i,l),c[0].Bean.origin?s?s.p(c,u):(s=Bn(c),s.c(),s.m(e,null)):s&&(s.d(1),s=null)},d(c){c&&k(e),s&&s.d()}}}function Pr(n){let e,t,l,i,r=Kt(n[0].Brew.rating),s,c,u=n[0].Brew.bean&&An(n),b=r&&Mn(n),d=(n[0].Brew.brewer_obj||n[0].Brew.method)&&En(n),p=n[0].Brew.tasting_notes&&Pn(n);return{c(){e=f("div"),t=f("div"),l=f("div"),u&&u.c(),i=w(),b&&b.c(),s=w(),d&&d.c(),c=w(),p&&p.c(),a(l,"class","flex-1 min-w-0"),a(t,"class","flex items-start justify-between gap-3 mb-3"),a(e,"class","bg-white/60 backdrop-blur rounded-lg p-4 border border-brown-200")},m(_,m){y(_,e,m),o(e,t),o(t,l),u&&u.m(l,null),o(t,i),b&&b.m(t,null),o(e,s),d&&d.m(e,null),o(e,c),p&&p.m(e,null)},p(_,m){_[0].Brew.bean?u?u.p(_,m):(u=An(_),u.c(),u.m(l,null)):u&&(u.d(1),u=null),m&1&&(r=Kt(_[0].Brew.rating)),r?b?b.p(_,m):(b=Mn(_),b.c(),b.m(t,null)):b&&(b.d(1),b=null),_[0].Brew.brewer_obj||_[0].Brew.method?d?d.p(_,m):(d=En(_),d.c(),d.m(e,c)):d&&(d.d(1),d=null),_[0].Brew.tasting_notes?p?p.p(_,m):(p=Pn(_),p.c(),p.m(e,null)):p&&(p.d(1),p=null)},d(_){_&&k(e),u&&u.d(),b&&b.d(),d&&d.d(),p&&p.d()}}}function yn(n){let e,t=n[0].Brewer.brewer_type+"",l;return{c(){e=f("div"),l=C(t),a(e,"class","text-sm text-brown-700")},m(i,r){y(i,e,r),o(e,l)},p(i,r){r&1&&t!==(t=i[0].Brewer.brewer_type+"")&&j(l,t)},d(i){i&&k(e)}}}function xn(n){let e,t=n[0].Grinder.grinder_type+"",l;return{c(){e=f("div"),l=C(t),a(e,"class","text-sm text-brown-700")},m(i,r){y(i,e,r),o(e,l)},p(i,r){r&1&&t!==(t=i[0].Grinder.grinder_type+"")&&j(l,t)},d(i){i&&k(e)}}}function Cn(n){let e,t,l=n[0].Roaster.location+"",i;return{c(){e=f("div"),t=C("📍 "),i=C(l),a(e,"class","text-sm text-brown-700")},m(r,s){y(r,e,s),o(e,t),o(e,i)},p(r,s){s&1&&l!==(l=r[0].Roaster.location+"")&&j(i,l)},d(r){r&&k(e)}}}function Bn(n){let e,t,l=n[0].Bean.origin+"",i;return{c(){e=f("div"),t=C("📍 "),i=C(l),a(e,"class","text-sm text-brown-700")},m(r,s){y(r,e,s),o(e,t),o(e,i)},p(r,s){s&1&&l!==(l=r[0].Bean.origin+"")&&j(i,l)},d(r){r&&k(e)}}}function An(n){var B;let e,t=(n[0].Brew.bean.name||n[0].Brew.bean.origin)+"",l,i,r,s,c,u,b,d=Kt(n[0].Brew.coffee_amount),p=((B=n[0].Brew.bean.roaster)==null?void 0:B.name)&&Sn(n),_=n[0].Brew.bean.origin&&Nn(n),m=n[0].Brew.bean.roast_level&&Tn(n),h=n[0].Brew.bean.process&&Ln(n),g=d&&On(n);return{c(){e=f("div"),l=C(t),i=w(),p&&p.c(),r=w(),s=f("div"),_&&_.c(),c=w(),m&&m.c(),u=w(),h&&h.c(),b=w(),g&&g.c(),a(e,"class","font-bold text-brown-900 text-base"),a(s,"class","text-xs text-brown-600 mt-1 flex flex-wrap gap-x-2 gap-y-0.5")},m(v,x){y(v,e,x),o(e,l),y(v,i,x),p&&p.m(v,x),y(v,r,x),y(v,s,x),_&&_.m(s,null),o(s,c),m&&m.m(s,null),o(s,u),h&&h.m(s,null),o(s,b),g&&g.m(s,null)},p(v,x){var S;x&1&&t!==(t=(v[0].Brew.bean.name||v[0].Brew.bean.origin)+"")&&j(l,t),(S=v[0].Brew.bean.roaster)!=null&&S.name?p?p.p(v,x):(p=Sn(v),p.c(),p.m(r.parentNode,r)):p&&(p.d(1),p=null),v[0].Brew.bean.origin?_?_.p(v,x):(_=Nn(v),_.c(),_.m(s,c)):_&&(_.d(1),_=null),v[0].Brew.bean.roast_level?m?m.p(v,x):(m=Tn(v),m.c(),m.m(s,u)):m&&(m.d(1),m=null),v[0].Brew.bean.process?h?h.p(v,x):(h=Ln(v),h.c(),h.m(s,b)):h&&(h.d(1),h=null),x&1&&(d=Kt(v[0].Brew.coffee_amount)),d?g?g.p(v,x):(g=On(v),g.c(),g.m(s,null)):g&&(g.d(1),g=null)},d(v){v&&(k(e),k(i),k(r),k(s)),p&&p.d(v),_&&_.d(),m&&m.d(),h&&h.d(),g&&g.d()}}}function Sn(n){let e,t,l,i=n[0].Brew.bean.roaster.name+"",r;return{c(){e=f("div"),t=f("span"),l=C("🏭 "),r=C(i),a(t,"class","font-medium"),a(e,"class","text-sm text-brown-700 mt-0.5")},m(s,c){y(s,e,c),o(e,t),o(t,l),o(t,r)},p(s,c){c&1&&i!==(i=s[0].Brew.bean.roaster.name+"")&&j(r,i)},d(s){s&&k(e)}}}function Nn(n){let e,t,l=n[0].Brew.bean.origin+"",i;return{c(){e=f("span"),t=C("📍 "),i=C(l),a(e,"class","inline-flex items-center gap-0.5")},m(r,s){y(r,e,s),o(e,t),o(e,i)},p(r,s){s&1&&l!==(l=r[0].Brew.bean.origin+"")&&j(i,l)},d(r){r&&k(e)}}}function Tn(n){let e,t,l=n[0].Brew.bean.roast_level+"",i;return{c(){e=f("span"),t=C("🔥 "),i=C(l),a(e,"class","inline-flex items-center gap-0.5")},m(r,s){y(r,e,s),o(e,t),o(e,i)},p(r,s){s&1&&l!==(l=r[0].Brew.bean.roast_level+"")&&j(i,l)},d(r){r&&k(e)}}}function Ln(n){let e,t,l=n[0].Brew.bean.process+"",i;return{c(){e=f("span"),t=C("🌱 "),i=C(l),a(e,"class","inline-flex items-center gap-0.5")},m(r,s){y(r,e,s),o(e,t),o(e,i)},p(r,s){s&1&&l!==(l=r[0].Brew.bean.process+"")&&j(i,l)},d(r){r&&k(e)}}}function On(n){let e,t,l=n[0].Brew.coffee_amount+"",i,r;return{c(){e=f("span"),t=C("⚖️ "),i=C(l),r=C("g"),a(e,"class","inline-flex items-center gap-0.5")},m(s,c){y(s,e,c),o(e,t),o(e,i),o(e,r)},p(s,c){c&1&&l!==(l=s[0].Brew.coffee_amount+"")&&j(i,l)},d(s){s&&k(e)}}}function Mn(n){let e,t,l=n[0].Brew.rating+"",i,r;return{c(){e=f("span"),t=C("⭐ "),i=C(l),r=C("/10"),a(e,"class","inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-amber-100 text-amber-900 flex-shrink-0")},m(s,c){y(s,e,c),o(e,t),o(e,i),o(e,r)},p(s,c){c&1&&l!==(l=s[0].Brew.rating+"")&&j(i,l)},d(s){s&&k(e)}}}function En(n){var c;let e,t,l,i,r=(((c=n[0].Brew.brewer_obj)==null?void 0:c.name)||n[0].Brew.method)+"",s;return{c(){e=f("div"),t=f("span"),t.textContent="Brewer:",l=w(),i=f("span"),s=C(r),a(t,"class","text-xs text-brown-600"),a(i,"class","text-sm font-semibold text-brown-900"),a(e,"class","mb-2")},m(u,b){y(u,e,b),o(e,t),o(e,l),o(e,i),o(i,s)},p(u,b){var d;b&1&&r!==(r=(((d=u[0].Brew.brewer_obj)==null?void 0:d.name)||u[0].Brew.method)+"")&&j(s,r)},d(u){u&&k(e)}}}function Pn(n){let e,t,l=n[0].Brew.tasting_notes+"",i,r;return{c(){e=f("div"),t=C('"'),i=C(l),r=C('"'),a(e,"class","mt-2 text-sm text-brown-800 italic border-l-2 border-brown-300 pl-3")},m(s,c){y(s,e,c),o(e,t),o(e,i),o(e,r)},p(s,c){c&1&&l!==(l=s[0].Brew.tasting_notes+"")&&j(i,l)},d(s){s&&k(e)}}}function Dr(n){let e,t,l,i,r,s,c,u,b,d,p,_=n[0].Author.handle+"",m,h,g,B,v=n[0].TimeAgo+"",x,S,A,N,P,L;function O(I,Y){return Y&1&&(i=null),i==null&&(i=!!rn(I[0].Author.avatar)),i?Sr:Ar}let D=O(n,-1),F=D(n),G=n[0].Author.displayName&&kn(n);function E(I,Y){return I[0].RecordType==="brew"&&I[0].Brew?Tr:Nr}let T=E(n),R=T(n);function V(I,Y){if(I[0].RecordType==="brew"&&I[0].Brew)return Pr;if(I[0].RecordType==="bean"&&I[0].Bean)return Er;if(I[0].RecordType==="roaster"&&I[0].Roaster)return Mr;if(I[0].RecordType==="grinder"&&I[0].Grinder)return Or;if(I[0].RecordType==="brewer"&&I[0].Brewer)return Lr}let X=V(n),J=X&&X(n);return{c(){e=f("div"),t=f("div"),l=f("a"),F.c(),s=w(),c=f("div"),u=f("div"),G&&G.c(),b=w(),d=f("a"),p=C("@"),m=C(_),g=w(),B=f("span"),x=C(v),S=w(),A=f("div"),R.c(),N=w(),J&&J.c(),a(l,"href",r="/profile/"+n[0].Author.handle),a(l,"class","flex-shrink-0"),a(d,"href",h="/profile/"+n[0].Author.handle),a(d,"class","text-brown-600 text-sm truncate hover:text-brown-700 hover:underline"),a(u,"class","flex items-center gap-2"),a(B,"class","text-brown-500 text-sm"),a(c,"class","flex-1 min-w-0"),a(t,"class","flex items-center gap-3 mb-3"),a(A,"class","mb-2 text-sm text-brown-700"),a(e,"class","bg-gradient-to-br from-brown-50 to-brown-100 rounded-lg shadow-md border border-brown-200 p-4 hover:shadow-lg transition-shadow")},m(I,Y){y(I,e,Y),o(e,t),o(t,l),F.m(l,null),o(t,s),o(t,c),o(c,u),G&&G.m(u,null),o(u,b),o(u,d),o(d,p),o(d,m),o(c,g),o(c,B),o(B,x),o(e,S),o(e,A),R.m(A,null),o(e,N),J&&J.m(e,null),P||(L=[z(l,"click",Ue(n[1])),z(d,"click",Ue(n[3]))],P=!0)},p(I,[Y]){D===(D=O(I,Y))&&F?F.p(I,Y):(F.d(1),F=D(I),F&&(F.c(),F.m(l,null))),Y&1&&r!==(r="/profile/"+I[0].Author.handle)&&a(l,"href",r),I[0].Author.displayName?G?G.p(I,Y):(G=kn(I),G.c(),G.m(u,b)):G&&(G.d(1),G=null),Y&1&&_!==(_=I[0].Author.handle+"")&&j(m,_),Y&1&&h!==(h="/profile/"+I[0].Author.handle)&&a(d,"href",h),Y&1&&v!==(v=I[0].TimeAgo+"")&&j(x,v),T===(T=E(I))&&R?R.p(I,Y):(R.d(1),R=T(I),R&&(R.c(),R.m(A,null))),X===(X=V(I))&&J?J.p(I,Y):(J&&J.d(1),J=X&&X(I),J&&(J.c(),J.m(e,null)))},i:W,o:W,d(I){I&&k(e),F.d(),G&&G.d(),R.d(),J&&J.d(),P=!1,ce(L)}}}function rn(n){return n&&(n.startsWith("https://")||n.startsWith("/static/"))?n:null}function Kt(n){return n!=null&&n!==""}function Fr(n,e,t){let{item:l}=e;const i=()=>_e(`/profile/${l.Author.handle}`),r=()=>_e(`/profile/${l.Author.handle}`),s=()=>_e(`/profile/${l.Author.handle}`),c=()=>_e(`/brews/${l.Author.did}/${l.Brew.rkey}`);return n.$$set=u=>{"item"in u&&t(0,l=u.item)},[l,i,r,s,c]}class Rr extends Xe{constructor(e){super(),Qe(this,e,Fr,Dr,We,{item:0})}}function Dn(n,e,t){const l=n.slice();return l[12]=e[t],l}function jr(n,e,t){const l=n.slice();return l[9]=e[t],l}function Hr(n){let e,t,l,i;return{c(){e=f("div"),t=f("button"),t.textContent="Log In to Start Tracking",a(t,"class","bg-gradient-to-r from-brown-700 to-brown-800 text-white py-3 px-8 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all text-lg font-semibold shadow-lg hover:shadow-xl inline-block"),a(e,"class","text-center")},m(r,s){y(r,e,s),o(e,t),l||(i=z(t,"click",n[8]),l=!0)},p:W,d(r){r&&k(e),l=!1,i()}}}function zr(n){var h;let e,t,l,i,r=((h=n[3])==null?void 0:h.did)+"",s,c,u,b,d,p,_,m;return{c(){e=f("div"),t=f("p"),l=C("Logged in as: "),i=f("span"),s=C(r),c=w(),u=f("div"),b=f("a"),b.innerHTML='<span class="text-xl font-semibold">☕ Add New Brew</span>',d=w(),p=f("a"),p.innerHTML='<span class="text-xl font-semibold">📋 View All Brews</span>',a(i,"class","font-mono text-brown-900 font-semibold"),a(t,"class","text-sm text-brown-700"),a(e,"class","mb-6"),a(b,"href","/brews/new"),a(b,"class","block bg-gradient-to-br from-brown-700 to-brown-800 text-white text-center py-4 px-6 rounded-xl hover:from-brown-800 hover:to-brown-900 transition-all shadow-lg hover:shadow-xl transform"),a(p,"href","/brews"),a(p,"class","block bg-gradient-to-br from-brown-500 to-brown-600 text-white text-center py-4 px-6 rounded-xl hover:from-brown-600 hover:to-brown-700 transition-all shadow-lg hover:shadow-xl"),a(u,"class","grid grid-cols-1 md:grid-cols-2 gap-4")},m(g,B){y(g,e,B),o(e,t),o(t,l),o(t,i),o(i,s),y(g,c,B),y(g,u,B),o(u,b),o(u,d),o(u,p),_||(m=[z(b,"click",Ue(n[6])),z(p,"click",Ue(n[7]))],_=!0)},p(g,B){var v;B&8&&r!==(r=((v=g[3])==null?void 0:v.did)+"")&&j(s,r)},d(g){g&&(k(e),k(c),k(u)),_=!1,ce(m)}}}function Gr(n){let e,t=[],l=new Map,i,r=le(n[0]);const s=c=>c[12].Timestamp;for(let c=0;c<r.length;c+=1){let u=Dn(n,r,c),b=s(u);l.set(b,t[c]=Fn(b,u))}return{c(){e=f("div");for(let c=0;c<t.length;c+=1)t[c].c();a(e,"class","space-y-4")},m(c,u){y(c,e,u);for(let b=0;b<t.length;b+=1)t[b]&&t[b].m(e,null);i=!0},p(c,u){u&1&&(r=le(c[0]),jt(),t=vr(t,u,s,1,c,r,l,e,gr,Fn,null,Dn),Ht())},i(c){if(!i){for(let u=0;u<r.length;u+=1)ve(t[u]);i=!0}},o(c){for(let u=0;u<t.length;u+=1)Oe(t[u]);i=!1},d(c){c&&k(e);for(let u=0;u<t.length;u+=1)t[u].d()}}}function Ir(n){let e,t;function l(s,c){return s[4]?Yr:qr}let i=l(n),r=i(n);return{c(){e=f("div"),t=C("No activity yet. "),r.c(),a(e,"class","text-center py-8 text-brown-600")},m(s,c){y(s,e,c),o(e,t),r.m(e,null)},p(s,c){i!==(i=l(s))&&(r.d(1),r=i(s),r&&(r.c(),r.m(e,null)))},i:W,o:W,d(s){s&&k(e),r.d()}}}function Ur(n){let e,t,l;return{c(){e=f("div"),t=C("Failed to load feed: "),l=C(n[2]),a(e,"class","text-center py-8 text-brown-600")},m(i,r){y(i,e,r),o(e,t),o(e,l)},p(i,r){r&4&&j(l,i[2])},i:W,o:W,d(i){i&&k(e)}}}function Wr(n){let e,t=le(Array(3)),l=[];for(let i=0;i<t.length;i+=1)l[i]=Vr(jr(n,t,i));return{c(){e=f("div");for(let i=0;i<l.length;i+=1)l[i].c();a(e,"class","space-y-4")},m(i,r){y(i,e,r);for(let s=0;s<l.length;s+=1)l[s]&&l[s].m(e,null)},p:W,i:W,o:W,d(i){i&&k(e),Ge(l,i)}}}function Fn(n,e){let t,l,i;return l=new Rr({props:{item:e[12]}}),{key:n,first:null,c(){t=ft(),it(l.$$.fragment),this.first=t},m(r,s){y(r,t,s),nt(l,r,s),i=!0},p(r,s){e=r;const c={};s&1&&(c.item=e[12]),l.$set(c)},i(r){i||(ve(l.$$.fragment,r),i=!0)},o(r){Oe(l.$$.fragment,r),i=!1},d(r){r&&k(t),lt(l,r)}}}function qr(n){let e;return{c(){e=C("Log in to see your feed.")},m(t,l){y(t,e,l)},d(t){t&&k(e)}}}function Yr(n){let e;return{c(){e=C("Start by adding your first brew!")},m(t,l){y(t,e,l)},d(t){t&&k(e)}}}function Vr(n){let e;return{c(){e=f("div"),e.innerHTML='<div class="bg-brown-50 rounded-lg p-4 border border-brown-200"><div class="flex items-center gap-3 mb-3"><div class="w-10 h-10 rounded-full bg-brown-300"></div> <div class="flex-1"><div class="h-4 bg-brown-300 rounded w-1/4 mb-2"></div> <div class="h-3 bg-brown-200 rounded w-1/6"></div></div></div> <div class="bg-brown-200 rounded-lg p-3"><div class="h-4 bg-brown-300 rounded w-3/4 mb-2"></div> <div class="h-3 bg-brown-200 rounded w-1/2"></div></div></div> ',a(e,"class","animate-pulse")},m(t,l){y(t,e,l)},p:W,d(t){t&&k(e)}}}function Kr(n){let e,t,l,i,r,s,c,u,b,d,p,_,m,h,g,B,v,x;function S(D,F){return D[4]?zr:Hr}let A=S(n),N=A(n);const P=[Wr,Ur,Ir,Gr],L=[];function O(D,F){return D[1]?0:D[2]?1:D[0].length===0?2:3}return h=O(n),g=L[h]=P[h](n),{c(){e=w(),t=f("div"),l=f("div"),i=f("div"),i.innerHTML='<h2 class="text-3xl font-bold text-brown-900">Welcome to Arabica</h2> <span class="text-xs bg-amber-400 text-brown-900 px-2 py-1 rounded-md font-semibold shadow-sm">ALPHA</span>',r=w(),s=f("p"),s.textContent="Track your coffee brewing journey with detailed logs of every cup.",c=w(),u=f("p"),u.textContent="Note: Arabica is currently in alpha. Features and data structures may change.",b=w(),N.c(),d=w(),p=f("div"),_=f("h3"),_.textContent="☕ Community Feed",m=w(),g.c(),B=w(),v=f("div"),v.innerHTML='<h3 class="text-lg font-bold text-brown-900 mb-3">✨ About Arabica</h3> <ul class="text-brown-800 space-y-2 leading-relaxed"><li class="flex items-start"><span class="mr-2">🔒</span><span><strong>Decentralized:</strong> Your data lives in your Personal Data Server (PDS)</span></li> <li class="flex items-start"><span class="mr-2">🚀</span><span><strong>Portable:</strong> Own your coffee brewing history</span></li> <li class="flex items-start"><span class="mr-2">📊</span><span>Track brewing variables like temperature, time, and grind size</span></li> <li class="flex items-start"><span class="mr-2">🌍</span><span>Organize beans by origin and roaster</span></li> <li class="flex items-start"><span class="mr-2">📝</span><span>Add tasting notes and ratings to each brew</span></li></ul>',document.title="Arabica - Coffee Brew Tracker",a(i,"class","flex items-center gap-3 mb-4"),a(s,"class","text-brown-800 mb-2 text-lg"),a(u,"class","text-sm text-brown-700 italic mb-6"),a(l,"class","bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 mb-8 border border-brown-300"),a(_,"class","text-xl font-bold text-brown-900 mb-4"),a(p,"class","bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-6 mb-8 border border-brown-300"),a(v,"class","bg-gradient-to-br from-amber-50 to-brown-100 rounded-xl p-6 border-2 border-brown-300 shadow-lg"),a(t,"class","max-w-4xl mx-auto")},m(D,F){y(D,e,F),y(D,t,F),o(t,l),o(l,i),o(l,r),o(l,s),o(l,c),o(l,u),o(l,b),N.m(l,null),o(t,d),o(t,p),o(p,_),o(p,m),L[h].m(p,null),o(t,B),o(t,v),x=!0},p(D,[F]){A===(A=S(D))&&N?N.p(D,F):(N.d(1),N=A(D),N&&(N.c(),N.m(l,null)));let G=h;h=O(D),h===G?L[h].p(D,F):(jt(),Oe(L[G],1,1,()=>{L[G]=null}),Ht(),g=L[h],g?g.p(D,F):(g=L[h]=P[h](D),g.c()),ve(g,1),g.m(p,null))},i(D){x||(ve(g),x=!0)},o(D){Oe(g),x=!1},d(D){D&&(k(e),k(t)),N.d(),L[h].d()}}}function Jr(n,e,t){let l,i,r;ut(n,pt,_=>t(5,r=_));let s=[],c=!0,u=null;vt(async()=>{try{const _=await ge.get("/api/feed-json");t(0,s=_.items||[])}catch(_){console.error("Failed to load feed:",_),_.status!==401&&_.status!==403&&t(2,u=_.message)}finally{t(1,c=!1)}});const b=()=>_e("/brews/new"),d=()=>_e("/brews"),p=()=>_e("/login");return n.$$.update=()=>{n.$$.dirty&32&&t(4,l=r.isAuthenticated),n.$$.dirty&32&&t(3,i=r.user)},[s,c,u,i,l,r,b,d,p]}class Qr extends Xe{constructor(e){super(),Qe(this,e,Jr,Kr,We,{})}}const{document:Xr}=fr;function Rn(n,e,t){const l=n.slice();return l[18]=e[t],l}function jn(n){let e;function t(r,s){return r[1].length===0?$r:Zr}let l=t(n),i=l(n);return{c(){e=f("div"),i.c(),a(e,"class","absolute z-10 w-full mt-1 bg-brown-50 border-2 border-brown-300 rounded-lg shadow-lg max-h-60 overflow-y-auto")},m(r,s){y(r,e,s),i.m(e,null)},p(r,s){l===(l=t(r))&&i?i.p(r,s):(i.d(1),i=l(r),i&&(i.c(),i.m(e,null)))},d(r){r&&k(e),i.d()}}}function Zr(n){let e,t=le(n[1]),l=[];for(let i=0;i<t.length;i+=1)l[i]=Hn(Rn(n,t,i));return{c(){for(let i=0;i<l.length;i+=1)l[i].c();e=ft()},m(i,r){for(let s=0;s<l.length;s+=1)l[s]&&l[s].m(i,r);y(i,e,r)},p(i,r){if(r&66){t=le(i[1]);let s;for(s=0;s<t.length;s+=1){const c=Rn(i,t,s);l[s]?l[s].p(c,r):(l[s]=Hn(c),l[s].c(),l[s].m(e.parentNode,e))}for(;s<l.length;s+=1)l[s].d(1);l.length=t.length}},d(i){i&&k(e),Ge(l,i)}}}function $r(n){let e;return{c(){e=f("div"),e.textContent="No accounts found",a(e,"class","px-4 py-3 text-sm text-brown-600")},m(t,l){y(t,e,l)},p:W,d(t){t&&k(e)}}}function Hn(n){let e,t,l,i,r,s,c=(n[18].displayName||n[18].handle)+"",u,b,d,p,_=n[18].handle+"",m,h,g,B;function v(){return n[11](n[18])}return{c(){e=f("button"),t=f("img"),i=w(),r=f("div"),s=f("div"),u=C(c),b=w(),d=f("div"),p=C("@"),m=C(_),h=w(),gt(t.src,l=n[18].avatar||"/static/icon-placeholder.svg")||a(t,"src",l),a(t,"alt",""),a(t,"class","w-6 h-6 rounded-full object-cover flex-shrink-0"),a(s,"class","font-medium text-sm text-brown-900 truncate"),a(d,"class","text-xs text-brown-600 truncate"),a(r,"class","flex-1 min-w-0"),a(e,"type","button"),a(e,"class","w-full px-3 py-2 hover:bg-brown-100 cursor-pointer flex items-center gap-2 text-left")},m(x,S){y(x,e,S),o(e,t),o(e,i),o(e,r),o(r,s),o(s,u),o(r,b),o(r,d),o(d,p),o(d,m),o(e,h),g||(B=[z(t,"error",to),z(e,"click",v)],g=!0)},p(x,S){n=x,S&2&&!gt(t.src,l=n[18].avatar||"/static/icon-placeholder.svg")&&a(t,"src",l),S&2&&c!==(c=(n[18].displayName||n[18].handle)+"")&&j(u,c),S&2&&_!==(_=n[18].handle+"")&&j(m,_)},d(x){x&&k(e),g=!1,ce(B)}}}function zn(n){let e,t;return{c(){e=f("div"),t=C(n[4]),a(e,"class","mt-3 text-red-600 text-sm")},m(l,i){y(l,e,i),o(e,t)},p(l,i){i&16&&j(t,l[4])},d(l){l&&k(e)}}}function eo(n){let e,t,l,i,r,s,c,u,b,d,p,_,m,h,g,B,v,x,S,A,N,P=n[3]?"Logging in...":"Log In",L,O,D,F,G,E=n[2]&&jn(n),T=n[4]&&zn(n);return{c(){e=w(),t=f("div"),l=f("div"),i=f("div"),i.innerHTML='<h2 class="text-3xl font-bold text-brown-900">Welcome to Arabica</h2> <span class="text-xs bg-amber-400 text-brown-900 px-2 py-1 rounded-md font-semibold shadow-sm">ALPHA</span>',r=w(),s=f("p"),s.textContent="Track your coffee brewing journey with detailed logs of every cup.",c=w(),u=f("p"),u.textContent="Note: Arabica is currently in alpha. Features and data structures may change.",b=w(),d=f("div"),p=f("p"),p.textContent="Please log in with your AT Protocol handle to start tracking your brews.",_=w(),m=f("form"),h=f("div"),g=f("label"),g.textContent="Your Handle",B=w(),v=f("input"),x=w(),E&&E.c(),S=w(),T&&T.c(),A=w(),N=f("button"),L=C(P),O=w(),D=f("div"),D.innerHTML='<h3 class="text-lg font-bold text-brown-900 mb-3">✨ About Arabica</h3> <ul class="text-brown-800 space-y-2 leading-relaxed"><li class="flex items-start"><span class="mr-2">🔒</span><span><strong>Decentralized:</strong> Your data lives in your Personal Data Server (PDS)</span></li> <li class="flex items-start"><span class="mr-2">🚀</span><span><strong>Portable:</strong> Own your coffee brewing history</span></li> <li class="flex items-start"><span class="mr-2">📊</span><span>Track brewing variables like temperature, time, and grind size</span></li> <li class="flex items-start"><span class="mr-2">🌍</span><span>Organize beans by origin and roaster</span></li> <li class="flex items-start"><span class="mr-2">📝</span><span>Add tasting notes and ratings to each brew</span></li></ul>',Xr.title="Login - Arabica",a(i,"class","flex items-center gap-3 mb-4"),a(s,"class","text-brown-800 mb-2 text-lg"),a(u,"class","text-sm text-brown-700 italic mb-6"),a(p,"class","text-brown-800 mb-6 text-center text-lg"),a(g,"for","handle"),a(g,"class","block text-sm font-medium text-brown-900 mb-2"),a(v,"type","text"),a(v,"id","handle"),a(v,"name","handle"),a(v,"placeholder","alice.bsky.social"),a(v,"autocomplete","off"),v.required=!0,v.disabled=n[3],a(v,"class","w-full px-4 py-3 border-2 border-brown-300 rounded-lg focus:ring-2 focus:ring-brown-600 focus:border-brown-600 bg-white disabled:opacity-50"),a(h,"class","relative autocomplete-container"),a(N,"type","submit"),N.disabled=n[3],a(N,"class","w-full mt-4 bg-gradient-to-r from-brown-700 to-brown-800 text-white py-3 px-8 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all text-lg font-semibold shadow-lg hover:shadow-xl disabled:opacity-50"),a(m,"method","POST"),a(m,"action","/auth/login"),a(m,"class","max-w-md mx-auto"),a(l,"class","bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 mb-8 border border-brown-300"),a(D,"class","bg-gradient-to-br from-amber-50 to-brown-100 rounded-xl p-6 border-2 border-brown-300 shadow-lg"),a(t,"class","max-w-4xl mx-auto")},m(R,V){y(R,e,V),y(R,t,V),o(t,l),o(l,i),o(l,r),o(l,s),o(l,c),o(l,u),o(l,b),o(l,d),o(d,p),o(d,_),o(d,m),o(m,h),o(h,g),o(h,B),o(h,v),H(v,n[0]),o(h,x),E&&E.m(h,null),o(m,S),T&&T.m(m,null),o(m,A),o(m,N),o(N,L),o(t,O),o(t,D),F||(G=[z(v,"input",n[9]),z(v,"input",n[5]),z(v,"focus",n[10]),z(m,"submit",n[7])],F=!0)},p(R,[V]){V&8&&(v.disabled=R[3]),V&1&&v.value!==R[0]&&H(v,R[0]),R[2]?E?E.p(R,V):(E=jn(R),E.c(),E.m(h,null)):E&&(E.d(1),E=null),R[4]?T?T.p(R,V):(T=zn(R),T.c(),T.m(m,A)):T&&(T.d(1),T=null),V&8&&P!==(P=R[3]?"Logging in...":"Log In")&&j(L,P),V&8&&(N.disabled=R[3])},i:W,o:W,d(R){R&&(k(e),k(t)),E&&E.d(),T&&T.d(),F=!1,ce(G)}}}const to=n=>{n.target.src="/static/icon-placeholder.svg"};function no(n,e,t){let l;ut(n,pt,N=>t(8,l=N));let i="",r=[],s=!1,c=!1,u="",b,d;async function p(N){if(N.length<3){t(1,r=[]),t(2,s=!1);return}d&&d.abort(),d=new AbortController;try{const P=await fetch(`/api/search-actors?q=${encodeURIComponent(N)}`,{signal:d.signal});if(!P.ok){t(1,r=[]),t(2,s=!1);return}const L=await P.json();t(1,r=L.actors||[]),t(2,s=r.length>0||N.length>=3)}catch(P){P.name!=="AbortError"&&console.error("Error searching actors:",P)}}function _(N,P){return(...L)=>{clearTimeout(b),b=setTimeout(()=>N(...L),P)}}const m=_(p,300);function h(N){t(0,i=N.target.value),m(i)}function g(N){t(0,i=N.handle),t(1,r=[]),t(2,s=!1)}function B(N){N.target.closest(".autocomplete-container")||t(2,s=!1)}async function v(N){if(N.preventDefault(),!i){t(4,u="Please enter your handle");return}t(3,c=!0),t(4,u=""),N.target.submit()}vt(()=>(document.addEventListener("click",B),()=>{document.removeEventListener("click",B),d&&d.abort()}));function x(){i=this.value,t(0,i)}const S=()=>{r.length>0&&i.length>=3&&t(2,s=!0)},A=N=>g(N);return n.$$.update=()=>{n.$$.dirty&256&&l.isAuthenticated&&!l.loading&&_e("/")},[i,r,s,c,u,h,g,v,l,x,S,A]}class lo extends Xe{constructor(e){super(),Qe(this,e,no,eo,We,{})}}function ro(){const{subscribe:n,set:e,update:t}=nr({beans:[],roasters:[],grinders:[],brewers:[],brews:[],lastFetch:null,loading:!1}),l="arabica_data_cache",i=5*60*1e3;return{subscribe:n,async load(r=!1){if(!r){const s=localStorage.getItem(l);if(s)try{const c=JSON.parse(s);if(Date.now()-c.timestamp<i){e({...c,lastFetch:c.timestamp,loading:!1});return}e({...c,lastFetch:c.timestamp,loading:!0})}catch(c){console.error("Failed to parse cache:",c)}}try{t(u=>({...u,loading:!0}));const s=await ge.get("/api/data"),c={beans:s.beans||[],roasters:s.roasters||[],grinders:s.grinders||[],brewers:s.brewers||[],brews:s.brews||[],lastFetch:Date.now(),loading:!1};e(c),localStorage.setItem(l,JSON.stringify({...c,timestamp:c.lastFetch}))}catch(s){console.error("Failed to fetch data:",s),t(c=>({...c,loading:!1}))}},async invalidate(){localStorage.removeItem(l),await this.load(!0)},clear(){localStorage.removeItem(l),e({beans:[],roasters:[],grinders:[],brewers:[],brews:[],lastFetch:null,loading:!1})}}}const Te=ro();function Gn(n,e,t){const l=n.slice();return l[12]=e[t],l}function oo(n){let e,t=le(n[0]),l=[];for(let i=0;i<t.length;i+=1)l[i]=Kn(Gn(n,t,i));return{c(){e=f("div");for(let i=0;i<l.length;i+=1)l[i].c();a(e,"class","space-y-4")},m(i,r){y(i,e,r);for(let s=0;s<l.length;s+=1)l[s]&&l[s].m(e,null)},p(i,r){if(r&13){t=le(i[0]);let s;for(s=0;s<t.length;s+=1){const c=Gn(i,t,s);l[s]?l[s].p(c,r):(l[s]=Kn(c),l[s].c(),l[s].m(e,null))}for(;s<l.length;s+=1)l[s].d(1);l.length=t.length}},d(i){i&&k(e),Ge(l,i)}}}function io(n){let e,t,l,i,r,s,c,u,b,d;return{c(){e=f("div"),t=f("div"),t.textContent="☕",l=w(),i=f("h2"),i.textContent="No Brews Yet",r=w(),s=f("p"),s.textContent="Start tracking your coffee journey by adding your first brew!",c=w(),u=f("button"),u.textContent="Add Your First Brew",a(t,"class","text-6xl mb-4"),a(i,"class","text-2xl font-bold text-brown-900 mb-2"),a(s,"class","text-brown-700 mb-6"),a(u,"class","bg-gradient-to-r from-brown-700 to-brown-800 text-white px-6 py-3 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-semibold shadow-lg inline-block"),a(e,"class","bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl p-12 text-center border border-brown-300")},m(p,_){y(p,e,_),o(e,t),o(e,l),o(e,i),o(e,r),o(e,s),o(e,c),o(e,u),b||(d=z(u,"click",n[6]),b=!0)},p:W,d(p){p&&k(e),b=!1,d()}}}function so(n){let e;return{c(){e=f("div"),e.innerHTML='<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-brown-800 mx-auto"></div> <p class="mt-4 text-brown-700">Loading brews...</p>',a(e,"class","text-center py-12")},m(t,l){y(t,e,l)},p:W,d(t){t&&k(e)}}}function ao(n){let e;return{c(){e=f("h3"),e.textContent="Unknown Bean",a(e,"class","text-xl font-bold text-brown-900 mb-1")},m(t,l){y(t,e,l)},p:W,d(t){t&&k(e)}}}function uo(n){var c;let e,t=(n[12].bean.name||n[12].bean.origin||"Unknown Bean")+"",l,i,r,s=((c=n[12].bean.Roaster)==null?void 0:c.Name)&&In(n);return{c(){e=f("h3"),l=C(t),i=w(),s&&s.c(),r=ft(),a(e,"class","text-xl font-bold text-brown-900 mb-1")},m(u,b){y(u,e,b),o(e,l),y(u,i,b),s&&s.m(u,b),y(u,r,b)},p(u,b){var d;b&1&&t!==(t=(u[12].bean.name||u[12].bean.origin||"Unknown Bean")+"")&&j(l,t),(d=u[12].bean.Roaster)!=null&&d.Name?s?s.p(u,b):(s=In(u),s.c(),s.m(r.parentNode,r)):s&&(s.d(1),s=null)},d(u){u&&(k(e),k(i),k(r)),s&&s.d(u)}}}function In(n){let e,t,l=n[12].bean.roaster.name+"",i;return{c(){e=f("p"),t=C("🏭 "),i=C(l),a(e,"class","text-sm text-brown-700 mb-2")},m(r,s){y(r,e,s),o(e,t),o(e,i)},p(r,s){s&1&&l!==(l=r[12].bean.roaster.name+"")&&j(i,l)},d(r){r&&k(e)}}}function co(n){let e,t,l=n[12].method+"",i;return{c(){e=f("span"),t=C("☕ "),i=C(l)},m(r,s){y(r,e,s),o(e,t),o(e,i)},p(r,s){s&1&&l!==(l=r[12].method+"")&&j(i,l)},d(r){r&&k(e)}}}function fo(n){let e,t,l=n[12].brewer_obj.name+"",i;return{c(){e=f("span"),t=C("☕ "),i=C(l)},m(r,s){y(r,e,s),o(e,t),o(e,i)},p(r,s){s&1&&l!==(l=r[12].brewer_obj.name+"")&&j(i,l)},d(r){r&&k(e)}}}function Un(n){let e,t,l=n[12].temperature+"",i,r;return{c(){e=f("span"),t=C("🌡️ "),i=C(l),r=C("°C")},m(s,c){y(s,e,c),o(e,t),o(e,i),o(e,r)},p(s,c){c&1&&l!==(l=s[12].temperature+"")&&j(i,l)},d(s){s&&k(e)}}}function Wn(n){let e,t,l=n[12].coffee_amount+"",i,r;return{c(){e=f("span"),t=C("⚖️ "),i=C(l),r=C("g coffee")},m(s,c){y(s,e,c),o(e,t),o(e,i),o(e,r)},p(s,c){c&1&&l!==(l=s[12].coffee_amount+"")&&j(i,l)},d(s){s&&k(e)}}}function qn(n){let e,t=Jt(n[12])+"",l;return{c(){e=f("span"),l=C(t)},m(i,r){y(i,e,r),o(e,l)},p(i,r){r&1&&t!==(t=Jt(i[12])+"")&&j(l,t)},d(i){i&&k(e)}}}function Yn(n){let e,t,l=n[12].tasting_notes+"",i,r;return{c(){e=f("p"),t=C('"'),i=C(l),r=C('"'),a(e,"class","text-sm text-brown-700 italic line-clamp-2 svelte-efadq")},m(s,c){y(s,e,c),o(e,t),o(e,i),o(e,r)},p(s,c){c&1&&l!==(l=s[12].tasting_notes+"")&&j(i,l)},d(s){s&&k(e)}}}function Vn(n){let e,t,l=n[12].rating+"",i,r;return{c(){e=f("span"),t=C("⭐ "),i=C(l),r=C("/10"),a(e,"class","inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-amber-100 text-amber-900")},m(s,c){y(s,e,c),o(e,t),o(e,i),o(e,r)},p(s,c){c&1&&l!==(l=s[12].rating+"")&&j(i,l)},d(s){s&&k(e)}}}function Kn(n){let e,t,l,i,r,s,c=yt(n[12].temperature),u,b=yt(n[12].coffee_amount),d,p=Jt(n[12]),_,m,h,g=Jn(n[12].created_at||n[12].created_at)+"",B,v,x,S=yt(n[12].rating),A,N,P,L,O,D,F,G,E,T,R,V,X,J,I,Y=n[2]===n[12].rkey?"Deleting...":"Delete",ue,$,Ae,ke,De;function ye(se,ee){return se[12].bean?uo:ao}let Me=ye(n),we=Me(n);function Fe(se,ee){if(se[12].brewer_obj)return fo;if(se[12].method)return co}let Ee=Fe(n),ae=Ee&&Ee(n),te=c&&Un(n),ie=b&&Wn(n),re=p&&qn(n),oe=n[12].tasting_notes&&Yn(n),he=S&&Vn(n);function fe(){return n[7](n[12])}function pe(){return n[8](n[12])}function Ie(){return n[9](n[12])}return{c(){e=f("div"),t=f("div"),l=f("div"),we.c(),i=w(),r=f("div"),ae&&ae.c(),s=w(),te&&te.c(),u=w(),ie&&ie.c(),d=w(),re&&re.c(),_=w(),oe&&oe.c(),m=w(),h=f("p"),B=C(g),v=w(),x=f("div"),he&&he.c(),A=w(),N=f("div"),P=f("a"),L=C("View"),D=w(),F=f("span"),F.textContent="|",G=w(),E=f("a"),T=C("Edit"),V=w(),X=f("span"),X.textContent="|",J=w(),I=f("button"),ue=C(Y),Ae=w(),a(r,"class","flex flex-wrap gap-x-4 gap-y-1 text-sm text-brown-600 mb-2"),a(h,"class","text-xs text-brown-500 mt-2"),a(l,"class","flex-1 min-w-0"),a(P,"href",O="/brews/"+n[12].rkey),a(P,"class","text-brown-700 hover:text-brown-900 text-sm font-medium hover:underline"),a(F,"class","text-brown-400"),a(E,"href",R="/brews/"+n[12].rkey+"/edit"),a(E,"class","text-brown-700 hover:text-brown-900 text-sm font-medium hover:underline"),a(X,"class","text-brown-400"),I.disabled=$=n[2]===n[12].rkey,a(I,"class","text-red-600 hover:text-red-800 text-sm font-medium hover:underline disabled:opacity-50"),a(N,"class","flex gap-2 items-center"),a(x,"class","flex flex-col items-end gap-2"),a(t,"class","flex items-start justify-between gap-4"),a(e,"class","bg-gradient-to-br from-brown-50 to-brown-100 rounded-lg shadow-md border border-brown-200 p-5 hover:shadow-lg transition-shadow")},m(se,ee){y(se,e,ee),o(e,t),o(t,l),we.m(l,null),o(l,i),o(l,r),ae&&ae.m(r,null),o(r,s),te&&te.m(r,null),o(r,u),ie&&ie.m(r,null),o(r,d),re&&re.m(r,null),o(l,_),oe&&oe.m(l,null),o(l,m),o(l,h),o(h,B),o(t,v),o(t,x),he&&he.m(x,null),o(x,A),o(x,N),o(N,P),o(P,L),o(N,D),o(N,F),o(N,G),o(N,E),o(E,T),o(N,V),o(N,X),o(N,J),o(N,I),o(I,ue),o(e,Ae),ke||(De=[z(P,"click",Ue(fe)),z(E,"click",Ue(pe)),z(I,"click",Ie)],ke=!0)},p(se,ee){n=se,Me===(Me=ye(n))&&we?we.p(n,ee):(we.d(1),we=Me(n),we&&(we.c(),we.m(l,i))),Ee===(Ee=Fe(n))&&ae?ae.p(n,ee):(ae&&ae.d(1),ae=Ee&&Ee(n),ae&&(ae.c(),ae.m(r,s))),ee&1&&(c=yt(n[12].temperature)),c?te?te.p(n,ee):(te=Un(n),te.c(),te.m(r,u)):te&&(te.d(1),te=null),ee&1&&(b=yt(n[12].coffee_amount)),b?ie?ie.p(n,ee):(ie=Wn(n),ie.c(),ie.m(r,d)):ie&&(ie.d(1),ie=null),ee&1&&(p=Jt(n[12])),p?re?re.p(n,ee):(re=qn(n),re.c(),re.m(r,null)):re&&(re.d(1),re=null),n[12].tasting_notes?oe?oe.p(n,ee):(oe=Yn(n),oe.c(),oe.m(l,m)):oe&&(oe.d(1),oe=null),ee&1&&g!==(g=Jn(n[12].created_at||n[12].created_at)+"")&&j(B,g),ee&1&&(S=yt(n[12].rating)),S?he?he.p(n,ee):(he=Vn(n),he.c(),he.m(x,A)):he&&(he.d(1),he=null),ee&1&&O!==(O="/brews/"+n[12].rkey)&&a(P,"href",O),ee&1&&R!==(R="/brews/"+n[12].rkey+"/edit")&&a(E,"href",R),ee&5&&Y!==(Y=n[2]===n[12].rkey?"Deleting...":"Delete")&&j(ue,Y),ee&5&&$!==($=n[2]===n[12].rkey)&&(I.disabled=$)},d(se){se&&k(e),we.d(),ae&&ae.d(),te&&te.d(),ie&&ie.d(),re&&re.d(),oe&&oe.d(),he&&he.d(),ke=!1,ce(De)}}}function bo(n){let e,t,l,i,r,s,c,u,b;function d(m,h){return m[1]?so:m[0].length===0?io:oo}let p=d(n),_=p(n);return{c(){e=w(),t=f("div"),l=f("div"),i=f("h1"),i.textContent="My Brews",r=w(),s=f("a"),s.textContent="☕ Add New Brew",c=w(),_.c(),document.title="My Brews - Arabica",a(i,"class","text-3xl font-bold text-brown-900"),a(s,"href","/brews/new"),a(s,"class","bg-gradient-to-r from-brown-700 to-brown-800 text-white px-6 py-3 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-semibold shadow-lg"),a(l,"class","flex items-center justify-between mb-6"),a(t,"class","max-w-6xl mx-auto")},m(m,h){y(m,e,h),y(m,t,h),o(t,l),o(l,i),o(l,r),o(l,s),o(t,c),_.m(t,null),u||(b=z(s,"click",Ue(n[5])),u=!0)},p(m,[h]){p===(p=d(m))&&_?_.p(m,h):(_.d(1),_=p(m),_&&(_.c(),_.m(t,null)))},i:W,o:W,d(m){m&&(k(e),k(t)),_.d(),u=!1,b()}}}function Jn(n){return n?new Date(n).toLocaleDateString("en-US",{year:"numeric",month:"short",day:"numeric"}):""}function yt(n){return n!=null&&n!==""}function Jt(n){if(yt(n.water_amount)&&n.water_amount>0)return`💧 ${n.water_amount}ml water`;if(n.pours&&n.pours.length>0){const e=n.pours.reduce((l,i)=>l+(i.water_amount||0),0),t=n.pours.length;return`💧 ${e}ml water (${t} pour${t!==1?"s":""})`}return null}function po(n,e,t){let l,i,r;ut(n,Te,g=>t(11,i=g)),ut(n,pt,g=>t(4,r=g));let s=[],c=!0,u=null;vt(async()=>{if(!l){_e("/login");return}await Te.load(),t(0,s=i.brews||[]),t(1,c=!1)});async function b(g){if(confirm("Are you sure you want to delete this brew?")){t(2,u=g);try{await ge.delete(`/brews/${g}`),await Te.invalidate(),t(0,s=i.brews||[])}catch(B){alert("Failed to delete brew: "+B.message)}finally{t(2,u=null)}}}const d=()=>_e("/brews/new"),p=()=>_e("/brews/new"),_=g=>_e(`/brews/${g.rkey}`),m=g=>_e(`/brews/${g.rkey}/edit`),h=g=>b(g.rkey);return n.$$.update=()=>{n.$$.dirty&16&&(l=r.isAuthenticated)},[s,c,u,b,r,d,p,_,m,h]}class _o extends Xe{constructor(e){super(),Qe(this,e,po,bo,We,{})}}function Qn(n,e,t){const l=n.slice();return l[18]=e[t],l[20]=t,l}function mo(n){let e,t,l,i,r,s,c=ol(n[2].created_at)+"",u,b,d,p,_=Dt(n[2].rating),m,h,g,B,v,x,S,A,N,P,L,O,D,F,G,E,T,R,V,X,J,I,Y,ue,$,Ae,ke,De,ye,Me,we,Fe,Ee,ae,te,ie,re,oe,he,fe=n[4]&&Xn(n),pe=_&&Zn(n);function Ie(M,ne){return M[2].bean?vo:go}let se=Ie(n),ee=se(n);function qe(M,ne){return M[2].brewer_obj?xo:M[2].method?yo:ko}let Pe=qe(n),Re=Pe(n);function Se(M,ne){return M[2].grinder_obj?Bo:Co}let Ze=Se(n),xe=Ze(n);function Ye(M,ne){return ne&4&&(R=null),R==null&&(R=!!Dt(M[2].coffee_amount)),R?So:Ao}let $e=Ye(n,-1),de=$e(n);function je(M,ne){return ne&32&&(Y=null),Y==null&&(Y=!!Dt(M[5])),Y?To:No}let be=je(n,-1),Ce=be(n);function q(M,ne){return M[2].grind_size?Oo:Lo}let Z=q(n),K=Z(n);function me(M,ne){return ne&4&&(Fe=null),Fe==null&&(Fe=!!Dt(M[2].temperature)),Fe?Eo:Mo}let dt=me(n,-1),He=dt(n),Ne=n[2].pours&&n[2].pours.length>0&&nl(n),ze=n[2].tasting_notes&&rl(n);return{c(){e=f("div"),t=f("div"),l=f("div"),i=f("h2"),i.textContent="Brew Details",r=w(),s=f("p"),u=C(c),b=w(),fe&&fe.c(),d=w(),p=f("div"),pe&&pe.c(),m=w(),h=f("div"),g=f("h3"),g.textContent="Coffee Bean",B=w(),ee.c(),v=w(),x=f("div"),S=f("div"),A=f("h3"),A.textContent="Brew Method",N=w(),Re.c(),P=w(),L=f("div"),O=f("h3"),O.textContent="Grinder",D=w(),xe.c(),F=w(),G=f("div"),E=f("h3"),E.textContent="Coffee",T=w(),de.c(),V=w(),X=f("div"),J=f("h3"),J.textContent="Water",I=w(),Ce.c(),ue=w(),$=f("div"),Ae=f("h3"),Ae.textContent="Grind Size",ke=w(),K.c(),De=w(),ye=f("div"),Me=f("h3"),Me.textContent="Water Temp",we=w(),He.c(),Ee=w(),Ne&&Ne.c(),ae=w(),ze&&ze.c(),te=w(),ie=f("div"),re=f("button"),re.textContent="← Back to Brews",a(i,"class","text-3xl font-bold text-brown-900"),a(s,"class","text-sm text-brown-600 mt-1"),a(t,"class","flex justify-between items-start mb-6"),a(g,"class","text-sm font-medium text-brown-600 uppercase tracking-wider mb-2"),a(h,"class","bg-brown-50 rounded-lg p-4 border border-brown-200"),a(A,"class","text-sm font-medium text-brown-600 uppercase tracking-wider mb-2"),a(S,"class","bg-brown-50 rounded-lg p-4 border border-brown-200"),a(O,"class","text-sm font-medium text-brown-600 uppercase tracking-wider mb-2"),a(L,"class","bg-brown-50 rounded-lg p-4 border border-brown-200"),a(E,"class","text-sm font-medium text-brown-600 uppercase tracking-wider mb-2"),a(G,"class","bg-brown-50 rounded-lg p-4 border border-brown-200"),a(J,"class","text-sm font-medium text-brown-600 uppercase tracking-wider mb-2"),a(X,"class","bg-brown-50 rounded-lg p-4 border border-brown-200"),a(Ae,"class","text-sm font-medium text-brown-600 uppercase tracking-wider mb-2"),a($,"class","bg-brown-50 rounded-lg p-4 border border-brown-200"),a(Me,"class","text-sm font-medium text-brown-600 uppercase tracking-wider mb-2"),a(ye,"class","bg-brown-50 rounded-lg p-4 border border-brown-200"),a(x,"class","grid grid-cols-2 gap-4"),a(p,"class","space-y-6"),a(re,"class","text-brown-700 hover:text-brown-900 font-medium hover:underline"),a(ie,"class","mt-6"),a(e,"class","bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 border border-brown-300")},m(M,ne){y(M,e,ne),o(e,t),o(t,l),o(l,i),o(l,r),o(l,s),o(s,u),o(t,b),fe&&fe.m(t,null),o(e,d),o(e,p),pe&&pe.m(p,null),o(p,m),o(p,h),o(h,g),o(h,B),ee.m(h,null),o(p,v),o(p,x),o(x,S),o(S,A),o(S,N),Re.m(S,null),o(x,P),o(x,L),o(L,O),o(L,D),xe.m(L,null),o(x,F),o(x,G),o(G,E),o(G,T),de.m(G,null),o(x,V),o(x,X),o(X,J),o(X,I),Ce.m(X,null),o(x,ue),o(x,$),o($,Ae),o($,ke),K.m($,null),o(x,De),o(x,ye),o(ye,Me),o(ye,we),He.m(ye,null),o(p,Ee),Ne&&Ne.m(p,null),o(p,ae),ze&&ze.m(p,null),o(e,te),o(e,ie),o(ie,re),oe||(he=z(re,"click",n[11]),oe=!0)},p(M,ne){ne&4&&c!==(c=ol(M[2].created_at)+"")&&j(u,c),M[4]?fe?fe.p(M,ne):(fe=Xn(M),fe.c(),fe.m(t,null)):fe&&(fe.d(1),fe=null),ne&4&&(_=Dt(M[2].rating)),_?pe?pe.p(M,ne):(pe=Zn(M),pe.c(),pe.m(p,m)):pe&&(pe.d(1),pe=null),se===(se=Ie(M))&&ee?ee.p(M,ne):(ee.d(1),ee=se(M),ee&&(ee.c(),ee.m(h,null))),Pe===(Pe=qe(M))&&Re?Re.p(M,ne):(Re.d(1),Re=Pe(M),Re&&(Re.c(),Re.m(S,null))),Ze===(Ze=Se(M))&&xe?xe.p(M,ne):(xe.d(1),xe=Ze(M),xe&&(xe.c(),xe.m(L,null))),$e===($e=Ye(M,ne))&&de?de.p(M,ne):(de.d(1),de=$e(M),de&&(de.c(),de.m(G,null))),be===(be=je(M,ne))&&Ce?Ce.p(M,ne):(Ce.d(1),Ce=be(M),Ce&&(Ce.c(),Ce.m(X,null))),Z===(Z=q(M))&&K?K.p(M,ne):(K.d(1),K=Z(M),K&&(K.c(),K.m($,null))),dt===(dt=me(M,ne))&&He?He.p(M,ne):(He.d(1),He=dt(M),He&&(He.c(),He.m(ye,null))),M[2].pours&&M[2].pours.length>0?Ne?Ne.p(M,ne):(Ne=nl(M),Ne.c(),Ne.m(p,ae)):Ne&&(Ne.d(1),Ne=null),M[2].tasting_notes?ze?ze.p(M,ne):(ze=rl(M),ze.c(),ze.m(p,null)):ze&&(ze.d(1),ze=null)},d(M){M&&k(e),fe&&fe.d(),pe&&pe.d(),ee.d(),Re.d(),xe.d(),de.d(),Ce.d(),K.d(),He.d(),Ne&&Ne.d(),ze&&ze.d(),oe=!1,he()}}}function wo(n){let e,t,l,i,r,s,c,u;return{c(){e=f("div"),t=f("h2"),t.textContent="Brew Not Found",l=w(),i=f("p"),i.textContent="The brew you're looking for doesn't exist.",r=w(),s=f("button"),s.textContent="Back to Brews",a(t,"class","text-2xl font-bold text-brown-900 mb-2"),a(i,"class","text-brown-700 mb-6"),a(s,"class","bg-gradient-to-r from-brown-700 to-brown-800 text-white px-6 py-3 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-semibold shadow-lg"),a(e,"class","bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl p-12 text-center border border-brown-300")},m(b,d){y(b,e,d),o(e,t),o(e,l),o(e,i),o(e,r),o(e,s),c||(u=z(s,"click",n[9]),c=!0)},p:W,d(b){b&&k(e),c=!1,u()}}}function ho(n){let e;return{c(){e=f("div"),e.innerHTML='<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-brown-800 mx-auto"></div> <p class="mt-4 text-brown-700">Loading brew...</p>',a(e,"class","text-center py-12")},m(t,l){y(t,e,l)},p:W,d(t){t&&k(e)}}}function Xn(n){let e,t,l,i,r,s;return{c(){e=f("div"),t=f("button"),t.textContent="Edit",l=w(),i=f("button"),i.textContent="Delete",a(t,"class","inline-flex items-center bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"),a(i,"class","inline-flex items-center bg-brown-200 text-brown-700 px-4 py-2 rounded-lg hover:bg-brown-300 font-medium transition-colors"),a(e,"class","flex gap-2")},m(c,u){y(c,e,u),o(e,t),o(e,l),o(e,i),r||(s=[z(t,"click",n[10]),z(i,"click",n[6])],r=!0)},p:W,d(c){c&&k(e),r=!1,ce(s)}}}function Zn(n){let e,t,l=n[2].rating+"",i,r,s,c;return{c(){e=f("div"),t=f("div"),i=C(l),r=C("/10"),s=w(),c=f("div"),c.textContent="Rating",a(t,"class","text-4xl font-bold text-brown-800"),a(c,"class","text-sm text-brown-600 mt-1"),a(e,"class","text-center py-4 bg-brown-50 rounded-lg border border-brown-200")},m(u,b){y(u,e,b),o(e,t),o(t,i),o(t,r),o(e,s),o(e,c)},p(u,b){b&4&&l!==(l=u[2].rating+"")&&j(i,l)},d(u){u&&k(e)}}}function go(n){let e;return{c(){e=f("span"),e.textContent="Not specified",a(e,"class","text-brown-400")},m(t,l){y(t,e,l)},p:W,d(t){t&&k(e)}}}function vo(n){var p;let e,t=(n[2].bean.name||n[2].bean.origin)+"",l,i,r,s,c,u=((p=n[2].bean.roaster)==null?void 0:p.Name)&&$n(n),b=n[2].bean.origin&&el(n),d=n[2].bean.roast_level&&tl(n);return{c(){e=f("div"),l=C(t),i=w(),u&&u.c(),r=w(),s=f("div"),b&&b.c(),c=w(),d&&d.c(),a(e,"class","font-bold text-lg text-brown-900"),a(s,"class","flex flex-wrap gap-3 mt-2 text-sm text-brown-600")},m(_,m){y(_,e,m),o(e,l),y(_,i,m),u&&u.m(_,m),y(_,r,m),y(_,s,m),b&&b.m(s,null),o(s,c),d&&d.m(s,null)},p(_,m){var h;m&4&&t!==(t=(_[2].bean.name||_[2].bean.origin)+"")&&j(l,t),(h=_[2].bean.roaster)!=null&&h.Name?u?u.p(_,m):(u=$n(_),u.c(),u.m(r.parentNode,r)):u&&(u.d(1),u=null),_[2].bean.origin?b?b.p(_,m):(b=el(_),b.c(),b.m(s,c)):b&&(b.d(1),b=null),_[2].bean.roast_level?d?d.p(_,m):(d=tl(_),d.c(),d.m(s,null)):d&&(d.d(1),d=null)},d(_){_&&(k(e),k(i),k(r),k(s)),u&&u.d(_),b&&b.d(),d&&d.d()}}}function $n(n){let e,t,l=n[2].bean.roaster.name+"",i;return{c(){e=f("div"),t=C("by "),i=C(l),a(e,"class","text-sm text-brown-700 mt-1")},m(r,s){y(r,e,s),o(e,t),o(e,i)},p(r,s){s&4&&l!==(l=r[2].bean.roaster.name+"")&&j(i,l)},d(r){r&&k(e)}}}function el(n){let e,t,l=n[2].bean.origin+"",i;return{c(){e=f("span"),t=C("Origin: "),i=C(l)},m(r,s){y(r,e,s),o(e,t),o(e,i)},p(r,s){s&4&&l!==(l=r[2].bean.origin+"")&&j(i,l)},d(r){r&&k(e)}}}function tl(n){let e,t,l=n[2].bean.roast_level+"",i;return{c(){e=f("span"),t=C("Roast: "),i=C(l)},m(r,s){y(r,e,s),o(e,t),o(e,i)},p(r,s){s&4&&l!==(l=r[2].bean.roast_level+"")&&j(i,l)},d(r){r&&k(e)}}}function ko(n){let e;return{c(){e=f("span"),e.textContent="Not specified",a(e,"class","text-brown-400")},m(t,l){y(t,e,l)},p:W,d(t){t&&k(e)}}}function yo(n){let e,t=n[2].method+"",l;return{c(){e=f("div"),l=C(t),a(e,"class","font-semibold text-brown-900")},m(i,r){y(i,e,r),o(e,l)},p(i,r){r&4&&t!==(t=i[2].method+"")&&j(l,t)},d(i){i&&k(e)}}}function xo(n){let e,t=n[2].brewer_obj.name+"",l;return{c(){e=f("div"),l=C(t),a(e,"class","font-semibold text-brown-900")},m(i,r){y(i,e,r),o(e,l)},p(i,r){r&4&&t!==(t=i[2].brewer_obj.name+"")&&j(l,t)},d(i){i&&k(e)}}}function Co(n){let e;return{c(){e=f("span"),e.textContent="Not specified",a(e,"class","text-brown-400")},m(t,l){y(t,e,l)},p:W,d(t){t&&k(e)}}}function Bo(n){let e,t=n[2].grinder_obj.name+"",l;return{c(){e=f("div"),l=C(t),a(e,"class","font-semibold text-brown-900")},m(i,r){y(i,e,r),o(e,l)},p(i,r){r&4&&t!==(t=i[2].grinder_obj.name+"")&&j(l,t)},d(i){i&&k(e)}}}function Ao(n){let e;return{c(){e=f("span"),e.textContent="Not specified",a(e,"class","text-brown-400")},m(t,l){y(t,e,l)},p:W,d(t){t&&k(e)}}}function So(n){let e,t=n[2].coffee_amount+"",l,i;return{c(){e=f("div"),l=C(t),i=C("g"),a(e,"class","font-semibold text-brown-900")},m(r,s){y(r,e,s),o(e,l),o(e,i)},p(r,s){s&4&&t!==(t=r[2].coffee_amount+"")&&j(l,t)},d(r){r&&k(e)}}}function No(n){let e;return{c(){e=f("span"),e.textContent="Not specified",a(e,"class","text-brown-400")},m(t,l){y(t,e,l)},p:W,d(t){t&&k(e)}}}function To(n){let e,t,l;return{c(){e=f("div"),t=C(n[5]),l=C("g"),a(e,"class","font-semibold text-brown-900")},m(i,r){y(i,e,r),o(e,t),o(e,l)},p(i,r){r&32&&j(t,i[5])},d(i){i&&k(e)}}}function Lo(n){let e;return{c(){e=f("span"),e.textContent="Not specified",a(e,"class","text-brown-400")},m(t,l){y(t,e,l)},p:W,d(t){t&&k(e)}}}function Oo(n){let e,t=n[2].grind_size+"",l;return{c(){e=f("div"),l=C(t),a(e,"class","font-semibold text-brown-900")},m(i,r){y(i,e,r),o(e,l)},p(i,r){r&4&&t!==(t=i[2].grind_size+"")&&j(l,t)},d(i){i&&k(e)}}}function Mo(n){let e;return{c(){e=f("span"),e.textContent="Not specified",a(e,"class","text-brown-400")},m(t,l){y(t,e,l)},p:W,d(t){t&&k(e)}}}function Eo(n){let e,t=n[2].temperature+"",l,i;return{c(){e=f("div"),l=C(t),i=C("°C"),a(e,"class","font-semibold text-brown-900")},m(r,s){y(r,e,s),o(e,l),o(e,i)},p(r,s){s&4&&t!==(t=r[2].temperature+"")&&j(l,t)},d(r){r&&k(e)}}}function nl(n){let e,t,l,i,r=le(n[2].pours),s=[];for(let c=0;c<r.length;c+=1)s[c]=ll(Qn(n,r,c));return{c(){e=f("div"),t=f("h3"),t.textContent="Pour Schedule",l=w(),i=f("div");for(let c=0;c<s.length;c+=1)s[c].c();a(t,"class","text-sm font-medium text-brown-600 uppercase tracking-wider mb-3"),a(i,"class","space-y-2"),a(e,"class","bg-brown-50 rounded-lg p-4 border border-brown-200")},m(c,u){y(c,e,u),o(e,t),o(e,l),o(e,i);for(let b=0;b<s.length;b+=1)s[b]&&s[b].m(i,null)},p(c,u){if(u&4){r=le(c[2].pours);let b;for(b=0;b<r.length;b+=1){const d=Qn(c,r,b);s[b]?s[b].p(d,u):(s[b]=ll(d),s[b].c(),s[b].m(i,null))}for(;b<s.length;b+=1)s[b].d(1);s.length=r.length}},d(c){c&&k(e),Ge(s,c)}}}function ll(n){let e,t,l,i,r=n[18].water_amount+"",s,c,u=n[18].time_seconds+"",b,d,p;return{c(){e=f("div"),t=f("span"),t.textContent=`Pour ${n[20]+1}:`,l=w(),i=f("span"),s=C(r),c=C("g at "),b=C(u),d=C("s"),p=w(),a(t,"class","text-brown-700"),a(i,"class","font-semibold text-brown-900"),a(e,"class","flex justify-between text-sm")},m(_,m){y(_,e,m),o(e,t),o(e,l),o(e,i),o(i,s),o(i,c),o(i,b),o(i,d),o(e,p)},p(_,m){m&4&&r!==(r=_[18].water_amount+"")&&j(s,r),m&4&&u!==(u=_[18].time_seconds+"")&&j(b,u)},d(_){_&&k(e)}}}function rl(n){let e,t,l,i,r,s=n[2].tasting_notes+"",c,u;return{c(){e=f("div"),t=f("h3"),t.textContent="Tasting Notes",l=w(),i=f("p"),r=C('"'),c=C(s),u=C('"'),a(t,"class","text-sm font-medium text-brown-600 uppercase tracking-wider mb-2"),a(i,"class","text-brown-900 italic"),a(e,"class","bg-brown-50 rounded-lg p-4 border border-brown-200")},m(b,d){y(b,e,d),o(e,t),o(e,l),o(e,i),o(i,r),o(i,c),o(i,u)},p(b,d){d&4&&s!==(s=b[2].tasting_notes+"")&&j(c,s)},d(b){b&&k(e)}}}function Po(n){let e,t;function l(s,c){return s[3]?ho:s[2]?mo:wo}let i=l(n),r=i(n);return{c(){e=w(),t=f("div"),r.c(),document.title="Brew Details - Arabica",a(t,"class","max-w-2xl mx-auto")},m(s,c){y(s,e,c),y(s,t,c),r.m(t,null)},p(s,[c]){i===(i=l(s))&&r?r.p(s,c):(r.d(1),r=i(s),r&&(r.c(),r.m(t,null)))},i:W,o:W,d(s){s&&(k(e),k(t)),r.d()}}}function Dt(n){return n!=null&&n!==""}function ol(n){return n?new Date(n).toLocaleDateString("en-US",{year:"numeric",month:"long",day:"numeric",hour:"numeric",minute:"2-digit"}):""}function Do(n,e,t){let l,i,r,s,c;ut(n,Te,A=>t(15,s=A)),ut(n,pt,A=>t(8,c=A));let{id:u=null}=e,{did:b=null}=e,{rkey:d=null}=e,p=null,_=!0,m=!1;vt(async()=>{if(!l){_e("/login");return}b&&d?(t(4,m=b===i),await g(b,d)):u&&(t(4,m=!0),await h(u)),t(3,_=!1)});async function h(A){await Te.load();const N=s.brews||[];t(2,p=N.find(P=>P.rkey===A))}async function g(A,N){try{const P=`at://${A}/social.arabica.alpha.brew/${N}`;t(2,p=await ge.get(`/api/brew?uri=${encodeURIComponent(P)}`))}catch(P){console.error("Failed to load brew:",P),P.message}}async function B(){if(!confirm("Are you sure you want to delete this brew?"))return;const A=d||u;if(!A){alert("Cannot delete brew: missing ID");return}try{await ge.delete(`/brews/${A}`),await Te.invalidate(),_e("/brews")}catch(N){alert("Failed to delete brew: "+N.message)}}const v=()=>_e("/brews"),x=()=>_e(`/brews/${d||u||p.rkey}/edit`),S=()=>_e("/brews");return n.$$set=A=>{"id"in A&&t(0,u=A.id),"did"in A&&t(7,b=A.did),"rkey"in A&&t(1,d=A.rkey)},n.$$.update=()=>{var A;n.$$.dirty&256&&(l=c.isAuthenticated),n.$$.dirty&256&&(i=(A=c.user)==null?void 0:A.did),n.$$.dirty&4&&t(5,r=p&&(p.water_amount||0)===0&&p.pours&&p.pours.length>0?p.pours.reduce((N,P)=>N+(P.water_amount||0),0):(p==null?void 0:p.water_amount)||0)},[u,d,p,_,m,r,B,b,c,v,x,S]}class il extends Xe{constructor(e){super(),Qe(this,e,Do,Po,We,{id:0,did:7,rkey:1})}}function sl(n){let e,t,l,i,r,s,c,u,b,d,p,_,m,h;const g=n[5].default,B=sr(g,n,n[4],null);return{c(){e=f("div"),t=f("div"),l=f("h3"),i=C(n[3]),r=w(),s=f("div"),B&&B.c(),c=w(),u=f("div"),b=f("button"),b.textContent="Save",d=w(),p=f("button"),p.textContent="Cancel",a(l,"class","text-xl font-semibold mb-4 text-brown-900"),a(b,"type","button"),a(b,"class","flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md"),a(p,"type","button"),a(p,"class","flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"),a(u,"class","flex gap-2"),a(s,"class","space-y-4"),a(t,"class","bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"),a(e,"class","fixed inset-0 bg-black/40 flex items-center justify-center z-50")},m(v,x){y(v,e,x),o(e,t),o(t,l),o(l,i),o(t,r),o(t,s),B&&B.m(s,null),o(s,c),o(s,u),o(u,b),o(u,d),o(u,p),_=!0,m||(h=[z(b,"click",function(){Vt(n[0])&&n[0].apply(this,arguments)}),z(p,"click",function(){Vt(n[1])&&n[1].apply(this,arguments)})],m=!0)},p(v,x){n=v,(!_||x&8)&&j(i,n[3]),B&&B.p&&(!_||x&16)&&ur(B,g,n,n[4],_?ar(g,n[4],x,null):cr(n[4]),null)},i(v){_||(ve(B,v),_=!0)},o(v){Oe(B,v),_=!1},d(v){v&&k(e),B&&B.d(v),m=!1,ce(h)}}}function Fo(n){let e,t,l=n[2]&&sl(n);return{c(){l&&l.c(),e=ft()},m(i,r){l&&l.m(i,r),y(i,e,r),t=!0},p(i,[r]){i[2]?l?(l.p(i,r),r&4&&ve(l,1)):(l=sl(i),l.c(),ve(l,1),l.m(e.parentNode,e)):l&&(jt(),Oe(l,1,1,()=>{l=null}),Ht())},i(i){t||(ve(l),t=!0)},o(i){Oe(l),t=!1},d(i){i&&k(e),l&&l.d(i)}}}function Ro(n,e,t){let{$$slots:l={},$$scope:i}=e,{onSave:r}=e,{onCancel:s}=e,{isOpen:c=!1}=e,{title:u="Modal"}=e;return n.$$set=b=>{"onSave"in b&&t(0,r=b.onSave),"onCancel"in b&&t(1,s=b.onCancel),"isOpen"in b&&t(2,c=b.isOpen),"title"in b&&t(3,u=b.title),"$$scope"in b&&t(4,i=b.$$scope)},[r,s,c,u,i,l]}class ht extends Xe{constructor(e){super(),Qe(this,e,Ro,Fo,We,{onSave:0,onCancel:1,isOpen:2,title:3})}}function al(n,e,t){const l=n.slice();return l[66]=e[t],l}function ul(n,e,t){const l=n.slice();return l[69]=e[t],l[70]=e,l[71]=t,l}function cl(n,e,t){const l=n.slice();return l[72]=e[t],l}function fl(n,e,t){const l=n.slice();return l[75]=e[t],l}function dl(n,e,t){const l=n.slice();return l[78]=e[t],l}function jo(n){let e,t,l,i,r,s=n[0]==="edit"?"Edit Brew":"New Brew",c,u,b,d,p,_,m,h,g,B,v,x,S,A,N,P,L,O,D,F,G,E,T,R,V,X,J,I,Y,ue,$,Ae,ke,De,ye,Me,we,Fe,Ee,ae,te,ie,re,oe,he,fe,pe,Ie,se,ee,qe,Pe,Re,Se,Ze,xe,Ye,$e,de,je,be,Ce,q,Z,K,me,dt,He,Ne,ze,M,ne=n[1].rating+"",Qt,on,sn,st,an,zt,un,Et,Pt,cn,bt,fn,Ct,kt,Gt=n[4]?"Saving...":n[0]==="edit"?"Update Brew":"Save Brew",Xt,dn,Bt,Zt,bn,et=n[5]&&bl(n),At=le(n[17]),Ve=[];for(let U=0;U<At.length;U+=1)Ve[U]=pl(dl(n,At,U));let St=le(n[15]),Ke=[];for(let U=0;U<St.length;U+=1)Ke[U]=_l(fl(n,St,U));let Nt=le(n[14]),Je=[];for(let U=0;U<Nt.length;U+=1)Je[U]=ml(cl(n,Nt,U));let tt=n[2].length>0&&wl(n);return{c(){e=f("div"),t=f("div"),l=f("button"),l.innerHTML='<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path></svg>',i=w(),r=f("h2"),c=C(s),u=w(),et&&et.c(),b=w(),d=f("form"),p=f("div"),_=f("label"),_.textContent="Coffee Bean *",m=w(),h=f("div"),g=f("select"),B=f("option"),B.textContent="Select a bean...";for(let U=0;U<Ve.length;U+=1)Ve[U].c();v=w(),x=f("button"),x.textContent="+ New",S=w(),A=f("div"),N=f("label"),N.textContent="Coffee Amount (grams)",P=w(),L=f("input"),O=w(),D=f("p"),D.textContent="Amount of ground coffee used",F=w(),G=f("div"),E=f("label"),E.textContent="Grinder",T=w(),R=f("div"),V=f("select"),X=f("option"),X.textContent="Select a grinder...";for(let U=0;U<Ke.length;U+=1)Ke[U].c();J=w(),I=f("button"),I.textContent="+ New",Y=w(),ue=f("div"),$=f("label"),$.textContent="Grind Size",Ae=w(),ke=f("input"),De=w(),ye=f("p"),ye.textContent='Enter a number (grinder setting) or description (e.g. "Medium", "Fine")',Me=w(),we=f("div"),Fe=f("label"),Fe.textContent="Brew Method",Ee=w(),ae=f("div"),te=f("select"),ie=f("option"),ie.textContent="Select brew method...";for(let U=0;U<Je.length;U+=1)Je[U].c();re=w(),oe=f("button"),oe.textContent="+ New",he=w(),fe=f("div"),pe=f("label"),pe.textContent="Water Amount (ml)",Ie=w(),se=f("input"),ee=w(),qe=f("div"),Pe=f("label"),Pe.textContent="Water Temperature (°C)",Re=w(),Se=f("input"),Ze=w(),xe=f("div"),Ye=f("label"),Ye.textContent="Total Brew Time (seconds)",$e=w(),de=f("input"),je=w(),be=f("div"),Ce=f("div"),q=f("span"),q.textContent="Pour Schedule (Optional)",Z=w(),K=f("button"),K.textContent="+ Add Pour",me=w(),tt&&tt.c(),dt=w(),He=f("div"),Ne=f("label"),ze=C("Rating: "),M=f("span"),Qt=C(ne),on=C("/10"),sn=w(),st=f("input"),an=w(),zt=f("div"),zt.innerHTML="<span>0</span> <span>10</span>",un=w(),Et=f("div"),Pt=f("label"),Pt.textContent="Tasting Notes",cn=w(),bt=f("textarea"),fn=w(),Ct=f("div"),kt=f("button"),Xt=C(Gt),dn=w(),Bt=f("button"),Bt.textContent="Cancel",a(l,"class","inline-flex items-center text-brown-700 hover:text-brown-900 font-medium transition-colors cursor-pointer"),a(r,"class","text-3xl font-bold text-brown-900"),a(t,"class","flex items-center gap-3 mb-6"),a(_,"for","bean-select"),a(_,"class","block text-sm font-medium text-brown-900 mb-2"),B.__value="",H(B,B.__value),a(g,"id","bean-select"),g.required=!0,a(g,"class","flex-1 rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 truncate max-w-full bg-white"),n[1].bean_rkey===void 0&&rt(()=>n[29].call(g)),a(x,"type","button"),a(x,"class","bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"),a(h,"class","flex gap-2"),a(N,"for","coffee-amount"),a(N,"class","block text-sm font-medium text-brown-900 mb-2"),a(L,"id","coffee-amount"),a(L,"type","number"),a(L,"step","0.1"),a(L,"placeholder","e.g. 18"),a(L,"class","w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"),a(D,"class","text-sm text-brown-700 mt-1"),a(E,"for","grinder-select"),a(E,"class","block text-sm font-medium text-brown-900 mb-2"),X.__value="",H(X,X.__value),a(V,"id","grinder-select"),a(V,"class","flex-1 rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 truncate max-w-full bg-white"),n[1].grinder_rkey===void 0&&rt(()=>n[32].call(V)),a(I,"type","button"),a(I,"class","bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"),a(R,"class","flex gap-2"),a($,"for","grind-size"),a($,"class","block text-sm font-medium text-brown-900 mb-2"),a(ke,"id","grind-size"),a(ke,"type","text"),a(ke,"placeholder","e.g. 18, Medium, 3.5, Fine"),a(ke,"class","w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"),a(ye,"class","text-sm text-brown-700 mt-1"),a(Fe,"for","brewer-select"),a(Fe,"class","block text-sm font-medium text-brown-900 mb-2"),ie.__value="",H(ie,ie.__value),a(te,"id","brewer-select"),a(te,"class","flex-1 rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 truncate max-w-full bg-white"),n[1].brewer_rkey===void 0&&rt(()=>n[35].call(te)),a(oe,"type","button"),a(oe,"class","bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"),a(ae,"class","flex gap-2"),a(pe,"for","water-amount"),a(pe,"class","block text-sm font-medium text-brown-900 mb-2"),a(se,"id","water-amount"),a(se,"type","number"),a(se,"step","1"),a(se,"placeholder","e.g. 300"),a(se,"class","w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"),a(Pe,"for","water-temp"),a(Pe,"class","block text-sm font-medium text-brown-900 mb-2"),a(Se,"id","water-temp"),a(Se,"type","number"),a(Se,"step","0.1"),a(Se,"placeholder","e.g. 93"),a(Se,"class","w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"),a(Ye,"for","brew-time"),a(Ye,"class","block text-sm font-medium text-brown-900 mb-2"),a(de,"id","brew-time"),a(de,"type","number"),a(de,"step","1"),a(de,"placeholder","e.g. 210"),a(de,"class","w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"),a(q,"class","block text-sm font-medium text-brown-900"),a(K,"type","button"),a(K,"class","text-sm bg-brown-300 text-brown-900 px-3 py-1 rounded hover:bg-brown-400 font-medium transition-colors"),a(Ce,"class","flex items-center justify-between mb-2"),a(M,"class","font-bold"),a(Ne,"for","rating"),a(Ne,"class","block text-sm font-medium text-brown-900 mb-2"),a(st,"id","rating"),a(st,"type","range"),a(st,"min","0"),a(st,"max","10"),a(st,"step","1"),a(st,"class","w-full h-2 bg-brown-200 rounded-lg appearance-none cursor-pointer accent-brown-700"),a(zt,"class","flex justify-between text-xs text-brown-600 mt-1"),a(Pt,"for","notes"),a(Pt,"class","block text-sm font-medium text-brown-900 mb-2"),a(bt,"id","notes"),a(bt,"rows","4"),a(bt,"placeholder","Describe the flavor, aroma, body, etc."),a(bt,"class","w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"),a(kt,"type","submit"),kt.disabled=n[4],a(kt,"class","flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white py-3 px-6 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-semibold shadow-lg disabled:opacity-50"),a(Bt,"type","button"),a(Bt,"class","px-6 py-3 border-2 border-brown-300 text-brown-700 rounded-lg hover:bg-brown-100 font-semibold transition-colors"),a(Ct,"class","flex gap-3"),a(d,"class","space-y-6"),a(e,"class","bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 border border-brown-300")},m(U,Be){y(U,e,Be),o(e,t),o(t,l),o(t,i),o(t,r),o(r,c),o(e,u),et&&et.m(e,null),o(e,b),o(e,d),o(d,p),o(p,_),o(p,m),o(p,h),o(h,g),o(g,B);for(let Q=0;Q<Ve.length;Q+=1)Ve[Q]&&Ve[Q].m(g,null);Le(g,n[1].bean_rkey,!0),o(h,v),o(h,x),o(d,S),o(d,A),o(A,N),o(A,P),o(A,L),H(L,n[1].coffee_amount),o(A,O),o(A,D),o(d,F),o(d,G),o(G,E),o(G,T),o(G,R),o(R,V),o(V,X);for(let Q=0;Q<Ke.length;Q+=1)Ke[Q]&&Ke[Q].m(V,null);Le(V,n[1].grinder_rkey,!0),o(R,J),o(R,I),o(d,Y),o(d,ue),o(ue,$),o(ue,Ae),o(ue,ke),H(ke,n[1].grind_size),o(ue,De),o(ue,ye),o(d,Me),o(d,we),o(we,Fe),o(we,Ee),o(we,ae),o(ae,te),o(te,ie);for(let Q=0;Q<Je.length;Q+=1)Je[Q]&&Je[Q].m(te,null);Le(te,n[1].brewer_rkey,!0),o(ae,re),o(ae,oe),o(d,he),o(d,fe),o(fe,pe),o(fe,Ie),o(fe,se),H(se,n[1].water_amount),o(d,ee),o(d,qe),o(qe,Pe),o(qe,Re),o(qe,Se),H(Se,n[1].water_temp),o(d,Ze),o(d,xe),o(xe,Ye),o(xe,$e),o(xe,de),H(de,n[1].brew_time),o(d,je),o(d,be),o(be,Ce),o(Ce,q),o(Ce,Z),o(Ce,K),o(be,me),tt&&tt.m(be,null),o(d,dt),o(d,He),o(He,Ne),o(Ne,ze),o(Ne,M),o(M,Qt),o(M,on),o(He,sn),o(He,st),H(st,n[1].rating),o(He,an),o(He,zt),o(d,un),o(d,Et),o(Et,Pt),o(Et,cn),o(Et,bt),H(bt,n[1].notes),o(d,fn),o(d,Ct),o(Ct,kt),o(kt,Xt),o(Ct,dn),o(Ct,Bt),Zt||(bn=[z(l,"click",n[28]),z(g,"change",n[29]),z(x,"click",n[30]),z(L,"input",n[31]),z(V,"change",n[32]),z(I,"click",n[33]),z(ke,"input",n[34]),z(te,"change",n[35]),z(oe,"click",n[36]),z(se,"input",n[37]),z(Se,"input",n[38]),z(de,"input",n[39]),z(K,"click",n[18]),z(st,"change",n[43]),z(st,"input",n[43]),z(bt,"input",n[44]),z(Bt,"click",n[45]),z(d,"submit",Ue(n[20]))],Zt=!0)},p(U,Be){if(Be[0]&1&&s!==(s=U[0]==="edit"?"Edit Brew":"New Brew")&&j(c,s),U[5]?et?et.p(U,Be):(et=bl(U),et.c(),et.m(e,b)):et&&(et.d(1),et=null),Be[0]&131072){At=le(U[17]);let Q;for(Q=0;Q<At.length;Q+=1){const _t=dl(U,At,Q);Ve[Q]?Ve[Q].p(_t,Be):(Ve[Q]=pl(_t),Ve[Q].c(),Ve[Q].m(g,null))}for(;Q<Ve.length;Q+=1)Ve[Q].d(1);Ve.length=At.length}if(Be[0]&131074&&Le(g,U[1].bean_rkey),Be[0]&131074&&ot(L.value)!==U[1].coffee_amount&&H(L,U[1].coffee_amount),Be[0]&32768){St=le(U[15]);let Q;for(Q=0;Q<St.length;Q+=1){const _t=fl(U,St,Q);Ke[Q]?Ke[Q].p(_t,Be):(Ke[Q]=_l(_t),Ke[Q].c(),Ke[Q].m(V,null))}for(;Q<Ke.length;Q+=1)Ke[Q].d(1);Ke.length=St.length}if(Be[0]&131074&&Le(V,U[1].grinder_rkey),Be[0]&131074&&ke.value!==U[1].grind_size&&H(ke,U[1].grind_size),Be[0]&16384){Nt=le(U[14]);let Q;for(Q=0;Q<Nt.length;Q+=1){const _t=cl(U,Nt,Q);Je[Q]?Je[Q].p(_t,Be):(Je[Q]=ml(_t),Je[Q].c(),Je[Q].m(te,null))}for(;Q<Je.length;Q+=1)Je[Q].d(1);Je.length=Nt.length}Be[0]&131074&&Le(te,U[1].brewer_rkey),Be[0]&131074&&ot(se.value)!==U[1].water_amount&&H(se,U[1].water_amount),Be[0]&131074&&ot(Se.value)!==U[1].water_temp&&H(Se,U[1].water_temp),Be[0]&131074&&ot(de.value)!==U[1].brew_time&&H(de,U[1].brew_time),U[2].length>0?tt?tt.p(U,Be):(tt=wl(U),tt.c(),tt.m(be,null)):tt&&(tt.d(1),tt=null),Be[0]&2&&ne!==(ne=U[1].rating+"")&&j(Qt,ne),Be[0]&131074&&H(st,U[1].rating),Be[0]&131074&&H(bt,U[1].notes),Be[0]&17&&Gt!==(Gt=U[4]?"Saving...":U[0]==="edit"?"Update Brew":"Save Brew")&&j(Xt,Gt),Be[0]&16&&(kt.disabled=U[4])},d(U){U&&k(e),et&&et.d(),Ge(Ve,U),Ge(Ke,U),Ge(Je,U),tt&&tt.d(),Zt=!1,ce(bn)}}}function Ho(n){let e;return{c(){e=f("div"),e.innerHTML='<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-brown-800 mx-auto"></div> <p class="mt-4 text-brown-700">Loading...</p>',a(e,"class","text-center py-12")},m(t,l){y(t,e,l)},p:W,d(t){t&&k(e)}}}function bl(n){let e,t;return{c(){e=f("div"),t=C(n[5]),a(e,"class","mb-4 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded")},m(l,i){y(l,e,i),o(e,t)},p(l,i){i[0]&32&&j(t,l[5])},d(l){l&&k(e)}}}function pl(n){let e,t=(n[78].name||n[78].origin)+"",l,i,r=n[78].origin+"",s,c,u=n[78].roast_level+"",b,d,p;return{c(){e=f("option"),l=C(t),i=C(" ("),s=C(r),c=C(" - "),b=C(u),d=C(`) 2 + `),e.__value=p=n[78].rkey,H(e,e.__value)},m(_,m){y(_,e,m),o(e,l),o(e,i),o(e,s),o(e,c),o(e,b),o(e,d)},p(_,m){m[0]&131072&&t!==(t=(_[78].name||_[78].origin)+"")&&j(l,t),m[0]&131072&&r!==(r=_[78].origin+"")&&j(s,r),m[0]&131072&&u!==(u=_[78].roast_level+"")&&j(b,u),m[0]&131072&&p!==(p=_[78].rkey)&&(e.__value=p,H(e,e.__value))},d(_){_&&k(e)}}}function _l(n){let e,t=n[75].name+"",l,i;return{c(){e=f("option"),l=C(t),e.__value=i=n[75].rkey,H(e,e.__value)},m(r,s){y(r,e,s),o(e,l)},p(r,s){s[0]&32768&&t!==(t=r[75].name+"")&&j(l,t),s[0]&32768&&i!==(i=r[75].rkey)&&(e.__value=i,H(e,e.__value))},d(r){r&&k(e)}}}function ml(n){let e,t=n[72].name+"",l,i;return{c(){e=f("option"),l=C(t),e.__value=i=n[72].rkey,H(e,e.__value)},m(r,s){y(r,e,s),o(e,l)},p(r,s){s[0]&16384&&t!==(t=r[72].name+"")&&j(l,t),s[0]&16384&&i!==(i=r[72].rkey)&&(e.__value=i,H(e,e.__value))},d(r){r&&k(e)}}}function wl(n){let e,t=le(n[2]),l=[];for(let i=0;i<t.length;i+=1)l[i]=hl(ul(n,t,i));return{c(){e=f("div");for(let i=0;i<l.length;i+=1)l[i].c();a(e,"class","space-y-2")},m(i,r){y(i,e,r);for(let s=0;s<l.length;s+=1)l[s]&&l[s].m(e,null)},p(i,r){if(r[0]&524292){t=le(i[2]);let s;for(s=0;s<t.length;s+=1){const c=ul(i,t,s);l[s]?l[s].p(c,r):(l[s]=hl(c),l[s].c(),l[s].m(e,null))}for(;s<l.length;s+=1)l[s].d(1);l.length=t.length}},d(i){i&&k(e),Ge(l,i)}}}function hl(n){let e,t,l,i,r,s,c,u,b,d,p;function _(){n[40].call(i,n[70],n[71])}function m(){n[41].call(s,n[70],n[71])}function h(){return n[42](n[71])}return{c(){e=f("div"),t=f("span"),t.textContent=`Pour ${n[71]+1}:`,l=w(),i=f("input"),r=w(),s=f("input"),c=w(),u=f("button"),u.textContent="✕",b=w(),a(t,"class","text-sm font-medium text-brown-700 min-w-[60px]"),a(i,"type","number"),a(i,"placeholder","Water (g)"),a(i,"class","flex-1 rounded border border-brown-300 px-3 py-2 text-sm"),a(s,"type","number"),a(s,"placeholder","Time (s)"),a(s,"class","flex-1 rounded border border-brown-300 px-3 py-2 text-sm"),a(u,"type","button"),a(u,"class","text-red-600 hover:text-red-800 font-medium px-2"),a(e,"class","flex gap-2 items-center bg-brown-50 p-3 rounded-lg border border-brown-200")},m(g,B){y(g,e,B),o(e,t),o(e,l),o(e,i),H(i,n[69].water_amount),o(e,r),o(e,s),H(s,n[69].time_seconds),o(e,c),o(e,u),o(e,b),d||(p=[z(i,"input",_),z(s,"input",m),z(u,"click",h)],d=!0)},p(g,B){n=g,B[0]&4&&ot(i.value)!==n[69].water_amount&&H(i,n[69].water_amount),B[0]&4&&ot(s.value)!==n[69].time_seconds&&H(s,n[69].time_seconds)},d(g){g&&k(e),d=!1,ce(p)}}}function gl(n){let e,t=n[66].name+"",l,i;return{c(){e=f("option"),l=C(t),e.__value=i=n[66].rkey,H(e,e.__value)},m(r,s){y(r,e,s),o(e,l)},p(r,s){s[0]&65536&&t!==(t=r[66].name+"")&&j(l,t),s[0]&65536&&i!==(i=r[66].rkey)&&(e.__value=i,H(e,e.__value))},d(r){r&&k(e)}}}function zo(n){let e,t,l,i,r,s,c,u,b,d,p,_,m,h,g,B,v,x,S,A,N,P,L,O,D,F,G,E,T,R,V,X,J=le(n[16]),I=[];for(let Y=0;Y<J.length;Y+=1)I[Y]=gl(al(n,J,Y));return{c(){e=f("div"),t=f("div"),l=f("label"),l.textContent="Name",i=w(),r=f("input"),s=w(),c=f("div"),u=f("label"),u.textContent="Origin *",b=w(),d=f("input"),p=w(),_=f("div"),m=f("label"),m.textContent="Roast Level *",h=w(),g=f("select"),B=f("option"),B.textContent="Select...",v=f("option"),v.textContent="Light",x=f("option"),x.textContent="Medium-Light",S=f("option"),S.textContent="Medium",A=f("option"),A.textContent="Medium-Dark",N=f("option"),N.textContent="Dark",P=w(),L=f("div"),O=f("label"),O.textContent="Roaster",D=w(),F=f("div"),G=f("select"),E=f("option"),E.textContent="Select...";for(let Y=0;Y<I.length;Y+=1)I[Y].c();T=w(),R=f("button"),R.textContent="+ New",a(l,"for","bean-name"),a(l,"class","block text-sm font-medium text-gray-700 mb-1"),a(r,"id","bean-name"),a(r,"type","text"),a(r,"class","w-full rounded border-gray-300 px-3 py-2"),a(u,"for","bean-origin"),a(u,"class","block text-sm font-medium text-gray-700 mb-1"),a(d,"id","bean-origin"),a(d,"type","text"),d.required=!0,a(d,"class","w-full rounded border-gray-300 px-3 py-2"),a(m,"for","bean-roast-level"),a(m,"class","block text-sm font-medium text-gray-700 mb-1"),B.__value="",H(B,B.__value),v.__value="Light",H(v,v.__value),x.__value="Medium-Light",H(x,x.__value),S.__value="Medium",H(S,S.__value),A.__value="Medium-Dark",H(A,A.__value),N.__value="Dark",H(N,N.__value),a(g,"id","bean-roast-level"),g.required=!0,a(g,"class","w-full rounded border-gray-300 px-3 py-2"),n[10].roast_level===void 0&&rt(()=>n[48].call(g)),a(O,"for","bean-roaster"),a(O,"class","block text-sm font-medium text-gray-700 mb-1"),E.__value="",H(E,E.__value),a(G,"id","bean-roaster"),a(G,"class","flex-1 rounded border-gray-300 px-3 py-2"),n[10].roaster_rkey===void 0&&rt(()=>n[49].call(G)),a(R,"type","button"),a(R,"class","bg-gray-200 px-3 py-1 rounded hover:bg-gray-300 text-sm"),a(F,"class","flex gap-2"),a(e,"class","space-y-4")},m(Y,ue){y(Y,e,ue),o(e,t),o(t,l),o(t,i),o(t,r),H(r,n[10].name),o(e,s),o(e,c),o(c,u),o(c,b),o(c,d),H(d,n[10].origin),o(e,p),o(e,_),o(_,m),o(_,h),o(_,g),o(g,B),o(g,v),o(g,x),o(g,S),o(g,A),o(g,N),Le(g,n[10].roast_level,!0),o(e,P),o(e,L),o(L,O),o(L,D),o(L,F),o(F,G),o(G,E);for(let $=0;$<I.length;$+=1)I[$]&&I[$].m(G,null);Le(G,n[10].roaster_rkey,!0),o(F,T),o(F,R),V||(X=[z(r,"input",n[46]),z(d,"input",n[47]),z(g,"change",n[48]),z(G,"change",n[49]),z(R,"click",n[50])],V=!0)},p(Y,ue){if(ue[0]&1024&&r.value!==Y[10].name&&H(r,Y[10].name),ue[0]&1024&&d.value!==Y[10].origin&&H(d,Y[10].origin),ue[0]&1024&&Le(g,Y[10].roast_level),ue[0]&65536){J=le(Y[16]);let $;for($=0;$<J.length;$+=1){const Ae=al(Y,J,$);I[$]?I[$].p(Ae,ue):(I[$]=gl(Ae),I[$].c(),I[$].m(G,null))}for(;$<I.length;$+=1)I[$].d(1);I.length=J.length}ue[0]&1024&&Le(G,Y[10].roaster_rkey)},d(Y){Y&&k(e),Ge(I,Y),V=!1,ce(X)}}}function Go(n){let e,t,l,i,r,s,c,u,b,d,p,_;return{c(){e=f("div"),t=f("div"),l=f("label"),l.textContent="Name *",i=w(),r=f("input"),s=w(),c=f("div"),u=f("label"),u.textContent="Location",b=w(),d=f("input"),a(l,"for","roaster-name"),a(l,"class","block text-sm font-medium text-gray-700 mb-1"),a(r,"id","roaster-name"),a(r,"type","text"),r.required=!0,a(r,"class","w-full rounded border-gray-300 px-3 py-2"),a(u,"for","roaster-location"),a(u,"class","block text-sm font-medium text-gray-700 mb-1"),a(d,"id","roaster-location"),a(d,"type","text"),a(d,"class","w-full rounded border-gray-300 px-3 py-2"),a(e,"class","space-y-4")},m(m,h){y(m,e,h),o(e,t),o(t,l),o(t,i),o(t,r),H(r,n[11].name),o(e,s),o(e,c),o(c,u),o(c,b),o(c,d),H(d,n[11].location),p||(_=[z(r,"input",n[53]),z(d,"input",n[54])],p=!0)},p(m,h){h[0]&2048&&r.value!==m[11].name&&H(r,m[11].name),h[0]&2048&&d.value!==m[11].location&&H(d,m[11].location)},d(m){m&&k(e),p=!1,ce(_)}}}function Io(n){let e,t,l,i,r,s,c,u,b,d,p,_,m,h,g,B;return{c(){e=f("div"),t=f("div"),l=f("label"),l.textContent="Name *",i=w(),r=f("input"),s=w(),c=f("div"),u=f("label"),u.textContent="Type",b=w(),d=f("select"),p=f("option"),p.textContent="Select...",_=f("option"),_.textContent="Manual",m=f("option"),m.textContent="Electric",h=f("option"),h.textContent="Blade",a(l,"for","grinder-name"),a(l,"class","block text-sm font-medium text-gray-700 mb-1"),a(r,"id","grinder-name"),a(r,"type","text"),r.required=!0,a(r,"class","w-full rounded border-gray-300 px-3 py-2"),a(u,"for","grinder-type"),a(u,"class","block text-sm font-medium text-gray-700 mb-1"),p.__value="",H(p,p.__value),_.__value="Manual",H(_,_.__value),m.__value="Electric",H(m,m.__value),h.__value="Blade",H(h,h.__value),a(d,"id","grinder-type"),a(d,"class","w-full rounded border-gray-300 px-3 py-2"),n[12].grinder_type===void 0&&rt(()=>n[58].call(d)),a(e,"class","space-y-4")},m(v,x){y(v,e,x),o(e,t),o(t,l),o(t,i),o(t,r),H(r,n[12].name),o(e,s),o(e,c),o(c,u),o(c,b),o(c,d),o(d,p),o(d,_),o(d,m),o(d,h),Le(d,n[12].grinder_type,!0),g||(B=[z(r,"input",n[57]),z(d,"change",n[58])],g=!0)},p(v,x){x[0]&4096&&r.value!==v[12].name&&H(r,v[12].name),x[0]&4096&&Le(d,v[12].grinder_type)},d(v){v&&k(e),g=!1,ce(B)}}}function Uo(n){let e,t,l,i,r,s,c,u,b,d,p,_,m,h,g,B,v,x,S,A;return{c(){e=f("div"),t=f("div"),l=f("label"),l.textContent="Name *",i=w(),r=f("input"),s=w(),c=f("div"),u=f("label"),u.textContent="Type",b=w(),d=f("select"),p=f("option"),p.textContent="Select...",_=f("option"),_.textContent="Pour Over",m=f("option"),m.textContent="French Press",h=f("option"),h.textContent="Espresso",g=f("option"),g.textContent="Moka Pot",B=f("option"),B.textContent="Aeropress",v=f("option"),v.textContent="Cold Brew",x=f("option"),x.textContent="Siphon",a(l,"for","brewer-name"),a(l,"class","block text-sm font-medium text-gray-700 mb-1"),a(r,"id","brewer-name"),a(r,"type","text"),r.required=!0,a(r,"class","w-full rounded border-gray-300 px-3 py-2"),a(u,"for","brewer-type"),a(u,"class","block text-sm font-medium text-gray-700 mb-1"),p.__value="",H(p,p.__value),_.__value="Pour Over",H(_,_.__value),m.__value="French Press",H(m,m.__value),h.__value="Espresso",H(h,h.__value),g.__value="Moka Pot",H(g,g.__value),B.__value="Aeropress",H(B,B.__value),v.__value="Cold Brew",H(v,v.__value),x.__value="Siphon",H(x,x.__value),a(d,"id","brewer-type"),a(d,"class","w-full rounded border-gray-300 px-3 py-2"),n[13].brewer_type===void 0&&rt(()=>n[62].call(d)),a(e,"class","space-y-4")},m(N,P){y(N,e,P),o(e,t),o(t,l),o(t,i),o(t,r),H(r,n[13].name),o(e,s),o(e,c),o(c,u),o(c,b),o(c,d),o(d,p),o(d,_),o(d,m),o(d,h),o(d,g),o(d,B),o(d,v),o(d,x),Le(d,n[13].brewer_type,!0),S||(A=[z(r,"input",n[61]),z(d,"change",n[62])],S=!0)},p(N,P){P[0]&8192&&r.value!==N[13].name&&H(r,N[13].name),P[0]&8192&&Le(d,N[13].brewer_type)},d(N){N&&k(e),S=!1,ce(A)}}}function Wo(n){let e,t,l,i,r,s,c,u,b,d,p,_,m,h,g,B;document.title=e=(n[0]==="edit"?"Edit Brew":"New Brew")+" - Arabica";function v(E,T){return E[3]?Ho:jo}let x=v(n),S=x(n);function A(E){n[52](E)}let N={title:"Add New Bean",onSave:n[21],onCancel:n[51],$$slots:{default:[zo]},$$scope:{ctx:n}};n[6]!==void 0&&(N.isOpen=n[6]),r=new ht({props:N}),ct.push(()=>wt(r,"isOpen",A));function P(E){n[56](E)}let L={title:"Add New Roaster",onSave:n[22],onCancel:n[55],$$slots:{default:[Go]},$$scope:{ctx:n}};n[7]!==void 0&&(L.isOpen=n[7]),u=new ht({props:L}),ct.push(()=>wt(u,"isOpen",P));function O(E){n[60](E)}let D={title:"Add New Grinder",onSave:n[23],onCancel:n[59],$$slots:{default:[Io]},$$scope:{ctx:n}};n[8]!==void 0&&(D.isOpen=n[8]),p=new ht({props:D}),ct.push(()=>wt(p,"isOpen",O));function F(E){n[64](E)}let G={title:"Add New Brewer",onSave:n[24],onCancel:n[63],$$slots:{default:[Uo]},$$scope:{ctx:n}};return n[9]!==void 0&&(G.isOpen=n[9]),h=new ht({props:G}),ct.push(()=>wt(h,"isOpen",F)),{c(){t=w(),l=f("div"),S.c(),i=w(),it(r.$$.fragment),c=w(),it(u.$$.fragment),d=w(),it(p.$$.fragment),m=w(),it(h.$$.fragment),a(l,"class","max-w-2xl mx-auto")},m(E,T){y(E,t,T),y(E,l,T),S.m(l,null),y(E,i,T),nt(r,E,T),y(E,c,T),nt(u,E,T),y(E,d,T),nt(p,E,T),y(E,m,T),nt(h,E,T),B=!0},p(E,T){(!B||T[0]&1)&&e!==(e=(E[0]==="edit"?"Edit Brew":"New Brew")+" - Arabica")&&(document.title=e),x===(x=v(E))&&S?S.p(E,T):(S.d(1),S=x(E),S&&(S.c(),S.m(l,null)));const R={};T[0]&64&&(R.onCancel=E[51]),T[0]&66688|T[2]&524288&&(R.$$scope={dirty:T,ctx:E}),!s&&T[0]&64&&(s=!0,R.isOpen=E[6],mt(()=>s=!1)),r.$set(R);const V={};T[0]&128&&(V.onCancel=E[55]),T[0]&2048|T[2]&524288&&(V.$$scope={dirty:T,ctx:E}),!b&&T[0]&128&&(b=!0,V.isOpen=E[7],mt(()=>b=!1)),u.$set(V);const X={};T[0]&256&&(X.onCancel=E[59]),T[0]&4096|T[2]&524288&&(X.$$scope={dirty:T,ctx:E}),!_&&T[0]&256&&(_=!0,X.isOpen=E[8],mt(()=>_=!1)),p.$set(X);const J={};T[0]&512&&(J.onCancel=E[63]),T[0]&8192|T[2]&524288&&(J.$$scope={dirty:T,ctx:E}),!g&&T[0]&512&&(g=!0,J.isOpen=E[9],mt(()=>g=!1)),h.$set(J)},i(E){B||(ve(r.$$.fragment,E),ve(u.$$.fragment,E),ve(p.$$.fragment,E),ve(h.$$.fragment,E),B=!0)},o(E){Oe(r.$$.fragment,E),Oe(u.$$.fragment,E),Oe(p.$$.fragment,E),Oe(h.$$.fragment,E),B=!1},d(E){E&&(k(t),k(l),k(i),k(c),k(d),k(m)),S.d(),lt(r,E),lt(u,E),lt(p,E),lt(h,E)}}}function qo(n,e,t){let l,i,r,s,c,u,b;ut(n,Te,q=>t(26,u=q)),ut(n,pt,q=>t(27,b=q));let{id:d=null}=e,{mode:p="create"}=e,_={bean_rkey:"",coffee_amount:"",grinder_rkey:"",grind_size:"",brewer_rkey:"",water_amount:"",water_temp:"",brew_time:"",notes:"",rating:5},m=[],h=!0,g=!1,B=null,v=!1,x=!1,S=!1,A=!1,N={name:"",origin:"",roast_level:"",process:"",description:"",roaster_rkey:""},P={name:"",location:"",website:"",description:""},L={name:"",grinder_type:"",burr_type:"",notes:""},O={name:"",brewer_type:"",description:""};vt(async()=>{if(!c){_e("/login");return}if(await Te.load(),p==="edit"&&d){const Z=(u.brews||[]).find(K=>K.rkey===d);Z?(t(1,_={bean_rkey:Z.bean_rkey||"",coffee_amount:Z.coffee_amount||"",grinder_rkey:Z.grinder_rkey||"",grind_size:Z.grind_size||"",brewer_rkey:Z.brewer_rkey||"",water_amount:Z.water_amount||"",water_temp:Z.temperature||"",brew_time:Z.time_seconds||"",notes:Z.tasting_notes||"",rating:Z.rating||5}),t(2,m=Z.pours?JSON.parse(JSON.stringify(Z.pours)):[])):t(5,B="Brew not found")}t(3,h=!1)});function D(){t(2,m=[...m,{water_amount:0,time_seconds:0}])}function F(q){t(2,m=m.filter((Z,K)=>K!==q))}async function G(){if(!_.bean_rkey||_.bean_rkey===""){t(5,B="Please select a coffee bean");return}t(4,g=!0),t(5,B=null);try{const q={bean_rkey:_.bean_rkey,method:_.method||"",temperature:_.water_temp?parseFloat(_.water_temp):0,water_amount:_.water_amount?parseFloat(_.water_amount):0,coffee_amount:_.coffee_amount?parseFloat(_.coffee_amount):0,time_seconds:_.brew_time?parseFloat(_.brew_time):0,grind_size:_.grind_size||"",grinder_rkey:_.grinder_rkey||"",brewer_rkey:_.brewer_rkey||"",tasting_notes:_.notes||"",rating:_.rating?parseInt(_.rating):0,pours:m.filter(Z=>Z.water_amount&&Z.time_seconds)};p==="edit"?await ge.put(`/brews/${d}`,q):await ge.post("/brews",q),await Te.invalidate(),_e("/brews")}catch(q){t(5,B=q.message),t(4,g=!1)}}async function E(){try{const q=await ge.post("/api/beans",N);await Te.invalidate(),t(1,_.bean_rkey=q.rkey,_),t(6,v=!1),t(10,N={name:"",origin:"",roast_level:"",process:"",description:"",roaster_rkey:""})}catch(q){alert("Failed to create bean: "+q.message)}}async function T(){try{const q=await ge.post("/api/roasters",P);await Te.invalidate(),t(10,N.roaster_rkey=q.rkey,N),t(7,x=!1),t(11,P={name:"",location:"",website:"",description:""})}catch(q){alert("Failed to create roaster: "+q.message)}}async function R(){try{const q=await ge.post("/api/grinders",L);await Te.invalidate(),t(1,_.grinder_rkey=q.rkey,_),t(8,S=!1),t(12,L={name:"",grinder_type:"",burr_type:"",notes:""})}catch(q){alert("Failed to create grinder: "+q.message)}}async function V(){try{const q=await ge.post("/api/brewers",O);await Te.invalidate(),t(1,_.brewer_rkey=q.rkey,_),t(9,A=!1),t(13,O={name:"",brewer_type:"",description:""})}catch(q){alert("Failed to create brewer: "+q.message)}}const X=()=>vn();function J(){_.bean_rkey=at(this),t(1,_),t(17,l),t(26,u)}const I=()=>t(6,v=!0);function Y(){_.coffee_amount=ot(this.value),t(1,_),t(17,l),t(26,u)}function ue(){_.grinder_rkey=at(this),t(1,_),t(17,l),t(26,u)}const $=()=>t(8,S=!0);function Ae(){_.grind_size=this.value,t(1,_),t(17,l),t(26,u)}function ke(){_.brewer_rkey=at(this),t(1,_),t(17,l),t(26,u)}const De=()=>t(9,A=!0);function ye(){_.water_amount=ot(this.value),t(1,_),t(17,l),t(26,u)}function Me(){_.water_temp=ot(this.value),t(1,_),t(17,l),t(26,u)}function we(){_.brew_time=ot(this.value),t(1,_),t(17,l),t(26,u)}function Fe(q,Z){q[Z].water_amount=ot(this.value),t(2,m)}function Ee(q,Z){q[Z].time_seconds=ot(this.value),t(2,m)}const ae=q=>F(q);function te(){_.rating=ot(this.value),t(1,_),t(17,l),t(26,u)}function ie(){_.notes=this.value,t(1,_),t(17,l),t(26,u)}const re=()=>vn();function oe(){N.name=this.value,t(10,N)}function he(){N.origin=this.value,t(10,N)}function fe(){N.roast_level=at(this),t(10,N)}function pe(){N.roaster_rkey=at(this),t(10,N)}const Ie=()=>t(7,x=!0),se=()=>t(6,v=!1);function ee(q){v=q,t(6,v)}function qe(){P.name=this.value,t(11,P)}function Pe(){P.location=this.value,t(11,P)}const Re=()=>t(7,x=!1);function Se(q){x=q,t(7,x)}function Ze(){L.name=this.value,t(12,L)}function xe(){L.grinder_type=at(this),t(12,L)}const Ye=()=>t(8,S=!1);function $e(q){S=q,t(8,S)}function de(){O.name=this.value,t(13,O)}function je(){O.brewer_type=at(this),t(13,O)}const be=()=>t(9,A=!1);function Ce(q){A=q,t(9,A)}return n.$$set=q=>{"id"in q&&t(25,d=q.id),"mode"in q&&t(0,p=q.mode)},n.$$.update=()=>{n.$$.dirty[0]&67108864&&t(17,l=u.beans||[]),n.$$.dirty[0]&67108864&&t(16,i=u.roasters||[]),n.$$.dirty[0]&67108864&&t(15,r=u.grinders||[]),n.$$.dirty[0]&67108864&&t(14,s=u.brewers||[]),n.$$.dirty[0]&134217728&&(c=b.isAuthenticated)},[p,_,m,h,g,B,v,x,S,A,N,P,L,O,s,r,i,l,D,F,G,E,T,R,V,d,u,b,X,J,I,Y,ue,$,Ae,ke,De,ye,Me,we,Fe,Ee,ae,te,ie,re,oe,he,fe,pe,Ie,se,ee,qe,Pe,Re,Se,Ze,xe,Ye,$e,de,je,be,Ce]}class vl extends Xe{constructor(e){super(),Qe(this,e,qo,Wo,We,{id:25,mode:0},null,[-1,-1,-1])}}function kl(n,e,t){const l=n.slice();return l[74]=e[t],l}function yl(n,e,t){const l=n.slice();return l[85]=e[t],l}function xl(n,e,t){const l=n.slice();return l[82]=e[t],l}function Cl(n,e,t){const l=n.slice();return l[74]=e[t],l}function Bl(n,e,t){const l=n.slice();return l[77]=e[t],l}function Yo(n){let e,t,l,i,r,s,c,u,b,d,p,_,m,h,g,B,v,x,S,A,N;function P(D,F){if(D[0]==="beans")return Xo;if(D[0]==="roasters")return Qo;if(D[0]==="grinders")return Jo;if(D[0]==="brewers")return Ko}let L=P(n),O=L&&L(n);return{c(){e=f("div"),t=f("div"),l=f("button"),i=C("☕ Beans"),s=w(),c=f("button"),u=C("🏭 Roasters"),d=w(),p=f("button"),_=C("⚙️ Grinders"),h=w(),g=f("button"),B=C("🫖 Brewers"),x=w(),S=f("div"),O&&O.c(),a(l,"class",r="flex-1 px-6 py-4 text-center font-medium transition-colors "+(n[0]==="beans"?"bg-brown-50 text-brown-900 border-b-2 border-brown-700":"text-brown-700 hover:bg-brown-50")),a(c,"class",b="flex-1 px-6 py-4 text-center font-medium transition-colors "+(n[0]==="roasters"?"bg-brown-50 text-brown-900 border-b-2 border-brown-700":"text-brown-700 hover:bg-brown-50")),a(p,"class",m="flex-1 px-6 py-4 text-center font-medium transition-colors "+(n[0]==="grinders"?"bg-brown-50 text-brown-900 border-b-2 border-brown-700":"text-brown-700 hover:bg-brown-50")),a(g,"class",v="flex-1 px-6 py-4 text-center font-medium transition-colors "+(n[0]==="brewers"?"bg-brown-50 text-brown-900 border-b-2 border-brown-700":"text-brown-700 hover:bg-brown-50")),a(t,"class","flex border-b border-brown-300"),a(S,"class","p-6"),a(e,"class","bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300 mb-6")},m(D,F){y(D,e,F),o(e,t),o(t,l),o(l,i),o(t,s),o(t,c),o(c,u),o(t,d),o(t,p),o(p,_),o(t,h),o(t,g),o(g,B),o(e,x),o(e,S),O&&O.m(S,null),A||(N=[z(l,"click",n[37]),z(c,"click",n[38]),z(p,"click",n[39]),z(g,"click",n[40])],A=!0)},p(D,F){F[0]&1&&r!==(r="flex-1 px-6 py-4 text-center font-medium transition-colors "+(D[0]==="beans"?"bg-brown-50 text-brown-900 border-b-2 border-brown-700":"text-brown-700 hover:bg-brown-50"))&&a(l,"class",r),F[0]&1&&b!==(b="flex-1 px-6 py-4 text-center font-medium transition-colors "+(D[0]==="roasters"?"bg-brown-50 text-brown-900 border-b-2 border-brown-700":"text-brown-700 hover:bg-brown-50"))&&a(c,"class",b),F[0]&1&&m!==(m="flex-1 px-6 py-4 text-center font-medium transition-colors "+(D[0]==="grinders"?"bg-brown-50 text-brown-900 border-b-2 border-brown-700":"text-brown-700 hover:bg-brown-50"))&&a(p,"class",m),F[0]&1&&v!==(v="flex-1 px-6 py-4 text-center font-medium transition-colors "+(D[0]==="brewers"?"bg-brown-50 text-brown-900 border-b-2 border-brown-700":"text-brown-700 hover:bg-brown-50"))&&a(g,"class",v),L===(L=P(D))&&O?O.p(D,F):(O&&O.d(1),O=L&&L(D),O&&(O.c(),O.m(S,null)))},d(D){D&&k(e),O&&O.d(),A=!1,ce(N)}}}function Vo(n){let e;return{c(){e=f("div"),e.innerHTML='<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-brown-800 mx-auto"></div> <p class="mt-4 text-brown-700">Loading...</p>',a(e,"class","text-center py-12")},m(t,l){y(t,e,l)},p:W,d(t){t&&k(e)}}}function Ko(n){let e,t,l,i,r,s,c,u;function b(_,m){return _[14].length===0?$o:Zo}let d=b(n),p=d(n);return{c(){e=f("div"),t=f("h2"),t.textContent="Brewers",l=w(),i=f("button"),i.textContent="+ Add Brewer",r=w(),p.c(),s=ft(),a(t,"class","text-xl font-bold text-brown-900"),a(i,"class","bg-brown-700 text-white px-4 py-2 rounded-lg hover:bg-brown-800 font-medium"),a(e,"class","flex justify-between items-center mb-4")},m(_,m){y(_,e,m),o(e,t),o(e,l),o(e,i),y(_,r,m),p.m(_,m),y(_,s,m),c||(u=z(i,"click",n[31]),c=!0)},p(_,m){d===(d=b(_))&&p?p.p(_,m):(p.d(1),p=d(_),p&&(p.c(),p.m(s.parentNode,s)))},d(_){_&&(k(e),k(r),k(s)),p.d(_),c=!1,u()}}}function Jo(n){let e,t,l,i,r,s,c,u;function b(_,m){return _[15].length===0?ti:ei}let d=b(n),p=d(n);return{c(){e=f("div"),t=f("h2"),t.textContent="Grinders",l=w(),i=f("button"),i.textContent="+ Add Grinder",r=w(),p.c(),s=ft(),a(t,"class","text-xl font-bold text-brown-900"),a(i,"class","bg-brown-700 text-white px-4 py-2 rounded-lg hover:bg-brown-800 font-medium"),a(e,"class","flex justify-between items-center mb-4")},m(_,m){y(_,e,m),o(e,t),o(e,l),o(e,i),y(_,r,m),p.m(_,m),y(_,s,m),c||(u=z(i,"click",n[27]),c=!0)},p(_,m){d===(d=b(_))&&p?p.p(_,m):(p.d(1),p=d(_),p&&(p.c(),p.m(s.parentNode,s)))},d(_){_&&(k(e),k(r),k(s)),p.d(_),c=!1,u()}}}function Qo(n){let e,t,l,i,r,s,c,u;function b(_,m){return _[16].length===0?li:ni}let d=b(n),p=d(n);return{c(){e=f("div"),t=f("h2"),t.textContent="Roasters",l=w(),i=f("button"),i.textContent="+ Add Roaster",r=w(),p.c(),s=ft(),a(t,"class","text-xl font-bold text-brown-900"),a(i,"class","bg-brown-700 text-white px-4 py-2 rounded-lg hover:bg-brown-800 font-medium"),a(e,"class","flex justify-between items-center mb-4")},m(_,m){y(_,e,m),o(e,t),o(e,l),o(e,i),y(_,r,m),p.m(_,m),y(_,s,m),c||(u=z(i,"click",n[23]),c=!0)},p(_,m){d===(d=b(_))&&p?p.p(_,m):(p.d(1),p=d(_),p&&(p.c(),p.m(s.parentNode,s)))},d(_){_&&(k(e),k(r),k(s)),p.d(_),c=!1,u()}}}function Xo(n){let e,t,l,i,r,s,c,u;function b(_,m){return _[17].length===0?oi:ri}let d=b(n),p=d(n);return{c(){e=f("div"),t=f("h2"),t.textContent="Coffee Beans",l=w(),i=f("button"),i.textContent="+ Add Bean",r=w(),p.c(),s=ft(),a(t,"class","text-xl font-bold text-brown-900"),a(i,"class","bg-brown-700 text-white px-4 py-2 rounded-lg hover:bg-brown-800 font-medium"),a(e,"class","flex justify-between items-center mb-4")},m(_,m){y(_,e,m),o(e,t),o(e,l),o(e,i),y(_,r,m),p.m(_,m),y(_,s,m),c||(u=z(i,"click",n[19]),c=!0)},p(_,m){d===(d=b(_))&&p?p.p(_,m):(p.d(1),p=d(_),p&&(p.c(),p.m(s.parentNode,s)))},d(_){_&&(k(e),k(r),k(s)),p.d(_),c=!1,u()}}}function Zo(n){let e,t,l,i,r,s=le(n[14]),c=[];for(let u=0;u<s.length;u+=1)c[u]=Al(yl(n,s,u));return{c(){e=f("div"),t=f("table"),l=f("thead"),l.innerHTML='<tr><th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Name</th> <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">🔧 Type</th> <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Actions</th></tr>',i=w(),r=f("tbody");for(let u=0;u<c.length;u+=1)c[u].c();a(l,"class","bg-brown-50"),a(r,"class","bg-white divide-y divide-brown-200"),a(t,"class","min-w-full divide-y divide-brown-300"),a(e,"class","overflow-x-auto")},m(u,b){y(u,e,b),o(e,t),o(t,l),o(t,i),o(t,r);for(let d=0;d<c.length;d+=1)c[d]&&c[d].m(r,null)},p(u,b){if(b[0]&16384|b[1]&10){s=le(u[14]);let d;for(d=0;d<s.length;d+=1){const p=yl(u,s,d);c[d]?c[d].p(p,b):(c[d]=Al(p),c[d].c(),c[d].m(r,null))}for(;d<c.length;d+=1)c[d].d(1);c.length=s.length}},d(u){u&&k(e),Ge(c,u)}}}function $o(n){let e;return{c(){e=f("p"),e.textContent="No brewers yet. Add your first brewer!",a(e,"class","text-brown-600 text-center py-8")},m(t,l){y(t,e,l)},p:W,d(t){t&&k(e)}}}function Al(n){let e,t,l=n[85].name+"",i,r,s,c=(n[85].brewer_type||"-")+"",u,b,d,p,_,m,h,g,B;function v(){return n[47](n[85])}function x(){return n[48](n[85])}return{c(){e=f("tr"),t=f("td"),i=C(l),r=w(),s=f("td"),u=C(c),b=w(),d=f("td"),p=f("button"),p.textContent="Edit",_=w(),m=f("button"),m.textContent="Delete",h=w(),a(t,"class","px-4 py-3 text-sm text-brown-900"),a(s,"class","px-4 py-3 text-sm text-brown-900"),a(p,"class","text-brown-700 hover:text-brown-900 font-medium"),a(m,"class","text-red-600 hover:text-red-800 font-medium"),a(d,"class","px-4 py-3 text-sm space-x-2"),a(e,"class","hover:bg-brown-50")},m(S,A){y(S,e,A),o(e,t),o(t,i),o(e,r),o(e,s),o(s,u),o(e,b),o(e,d),o(d,p),o(d,_),o(d,m),o(e,h),g||(B=[z(p,"click",v),z(m,"click",x)],g=!0)},p(S,A){n=S,A[0]&16384&&l!==(l=n[85].name+"")&&j(i,l),A[0]&16384&&c!==(c=(n[85].brewer_type||"-")+"")&&j(u,c)},d(S){S&&k(e),g=!1,ce(B)}}}function ei(n){let e,t,l,i,r,s=le(n[15]),c=[];for(let u=0;u<s.length;u+=1)c[u]=Sl(xl(n,s,u));return{c(){e=f("div"),t=f("table"),l=f("thead"),l.innerHTML='<tr><th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Name</th> <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">🔧 Type</th> <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">💎 Burr Type</th> <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Actions</th></tr>',i=w(),r=f("tbody");for(let u=0;u<c.length;u+=1)c[u].c();a(l,"class","bg-brown-50"),a(r,"class","bg-white divide-y divide-brown-200"),a(t,"class","min-w-full divide-y divide-brown-300"),a(e,"class","overflow-x-auto")},m(u,b){y(u,e,b),o(e,t),o(t,l),o(t,i),o(t,r);for(let d=0;d<c.length;d+=1)c[d]&&c[d].m(r,null)},p(u,b){if(b[0]&1342210048){s=le(u[15]);let d;for(d=0;d<s.length;d+=1){const p=xl(u,s,d);c[d]?c[d].p(p,b):(c[d]=Sl(p),c[d].c(),c[d].m(r,null))}for(;d<c.length;d+=1)c[d].d(1);c.length=s.length}},d(u){u&&k(e),Ge(c,u)}}}function ti(n){let e;return{c(){e=f("p"),e.textContent="No grinders yet. Add your first grinder!",a(e,"class","text-brown-600 text-center py-8")},m(t,l){y(t,e,l)},p:W,d(t){t&&k(e)}}}function Sl(n){let e,t,l=n[82].name+"",i,r,s,c=(n[82].grinder_type||"-")+"",u,b,d,p=(n[82].burr_type||"-")+"",_,m,h,g,B,v,x,S,A;function N(){return n[45](n[82])}function P(){return n[46](n[82])}return{c(){e=f("tr"),t=f("td"),i=C(l),r=w(),s=f("td"),u=C(c),b=w(),d=f("td"),_=C(p),m=w(),h=f("td"),g=f("button"),g.textContent="Edit",B=w(),v=f("button"),v.textContent="Delete",x=w(),a(t,"class","px-4 py-3 text-sm text-brown-900"),a(s,"class","px-4 py-3 text-sm text-brown-900"),a(d,"class","px-4 py-3 text-sm text-brown-900"),a(g,"class","text-brown-700 hover:text-brown-900 font-medium"),a(v,"class","text-red-600 hover:text-red-800 font-medium"),a(h,"class","px-4 py-3 text-sm space-x-2"),a(e,"class","hover:bg-brown-50")},m(L,O){y(L,e,O),o(e,t),o(t,i),o(e,r),o(e,s),o(s,u),o(e,b),o(e,d),o(d,_),o(e,m),o(e,h),o(h,g),o(h,B),o(h,v),o(e,x),S||(A=[z(g,"click",N),z(v,"click",P)],S=!0)},p(L,O){n=L,O[0]&32768&&l!==(l=n[82].name+"")&&j(i,l),O[0]&32768&&c!==(c=(n[82].grinder_type||"-")+"")&&j(u,c),O[0]&32768&&p!==(p=(n[82].burr_type||"-")+"")&&j(_,p)},d(L){L&&k(e),S=!1,ce(A)}}}function ni(n){let e,t,l,i,r,s=le(n[16]),c=[];for(let u=0;u<s.length;u+=1)c[u]=Nl(Cl(n,s,u));return{c(){e=f("div"),t=f("table"),l=f("thead"),l.innerHTML='<tr><th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Name</th> <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">📍 Location</th> <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Actions</th></tr>',i=w(),r=f("tbody");for(let u=0;u<c.length;u+=1)c[u].c();a(l,"class","bg-brown-50"),a(r,"class","bg-white divide-y divide-brown-200"),a(t,"class","min-w-full divide-y divide-brown-300"),a(e,"class","overflow-x-auto")},m(u,b){y(u,e,b),o(e,t),o(t,l),o(t,i),o(t,r);for(let d=0;d<c.length;d+=1)c[d]&&c[d].m(r,null)},p(u,b){if(b[0]&83951616){s=le(u[16]);let d;for(d=0;d<s.length;d+=1){const p=Cl(u,s,d);c[d]?c[d].p(p,b):(c[d]=Nl(p),c[d].c(),c[d].m(r,null))}for(;d<c.length;d+=1)c[d].d(1);c.length=s.length}},d(u){u&&k(e),Ge(c,u)}}}function li(n){let e;return{c(){e=f("p"),e.textContent="No roasters yet. Add your first roaster!",a(e,"class","text-brown-600 text-center py-8")},m(t,l){y(t,e,l)},p:W,d(t){t&&k(e)}}}function Nl(n){let e,t,l=n[74].name+"",i,r,s,c=(n[74].location||"-")+"",u,b,d,p,_,m,h,g,B;function v(){return n[43](n[74])}function x(){return n[44](n[74])}return{c(){e=f("tr"),t=f("td"),i=C(l),r=w(),s=f("td"),u=C(c),b=w(),d=f("td"),p=f("button"),p.textContent="Edit",_=w(),m=f("button"),m.textContent="Delete",h=w(),a(t,"class","px-4 py-3 text-sm text-brown-900"),a(s,"class","px-4 py-3 text-sm text-brown-900"),a(p,"class","text-brown-700 hover:text-brown-900 font-medium"),a(m,"class","text-red-600 hover:text-red-800 font-medium"),a(d,"class","px-4 py-3 text-sm space-x-2"),a(e,"class","hover:bg-brown-50")},m(S,A){y(S,e,A),o(e,t),o(t,i),o(e,r),o(e,s),o(s,u),o(e,b),o(e,d),o(d,p),o(d,_),o(d,m),o(e,h),g||(B=[z(p,"click",v),z(m,"click",x)],g=!0)},p(S,A){n=S,A[0]&65536&&l!==(l=n[74].name+"")&&j(i,l),A[0]&65536&&c!==(c=(n[74].location||"-")+"")&&j(u,c)},d(S){S&&k(e),g=!1,ce(B)}}}function ri(n){let e,t,l,i,r,s=le(n[17]),c=[];for(let u=0;u<s.length;u+=1)c[u]=Tl(Bl(n,s,u));return{c(){e=f("div"),t=f("table"),l=f("thead"),l.innerHTML='<tr><th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Name</th> <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">📍 Origin</th> <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">🔥 Roast</th> <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">🏭 Roaster</th> <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Actions</th></tr>',i=w(),r=f("tbody");for(let u=0;u<c.length;u+=1)c[u].c();a(l,"class","bg-brown-50"),a(r,"class","bg-white divide-y divide-brown-200"),a(t,"class","min-w-full divide-y divide-brown-300"),a(e,"class","overflow-x-auto")},m(u,b){y(u,e,b),o(e,t),o(t,l),o(t,i),o(t,r);for(let d=0;d<c.length;d+=1)c[d]&&c[d].m(r,null)},p(u,b){if(b[0]&5373952){s=le(u[17]);let d;for(d=0;d<s.length;d+=1){const p=Bl(u,s,d);c[d]?c[d].p(p,b):(c[d]=Tl(p),c[d].c(),c[d].m(r,null))}for(;d<c.length;d+=1)c[d].d(1);c.length=s.length}},d(u){u&&k(e),Ge(c,u)}}}function oi(n){let e;return{c(){e=f("p"),e.textContent="No beans yet. Add your first bean!",a(e,"class","text-brown-600 text-center py-8")},m(t,l){y(t,e,l)},p:W,d(t){t&&k(e)}}}function Tl(n){var G;let e,t,l=(n[77].name||"-")+"",i,r,s,c=n[77].origin+"",u,b,d,p=n[77].roast_level+"",_,m,h,g=(((G=n[77].roaster)==null?void 0:G.name)||"-")+"",B,v,x,S,A,N,P,L,O;function D(){return n[41](n[77])}function F(){return n[42](n[77])}return{c(){e=f("tr"),t=f("td"),i=C(l),r=w(),s=f("td"),u=C(c),b=w(),d=f("td"),_=C(p),m=w(),h=f("td"),B=C(g),v=w(),x=f("td"),S=f("button"),S.textContent="Edit",A=w(),N=f("button"),N.textContent="Delete",P=w(),a(t,"class","px-4 py-3 text-sm text-brown-900"),a(s,"class","px-4 py-3 text-sm text-brown-900"),a(d,"class","px-4 py-3 text-sm text-brown-900"),a(h,"class","px-4 py-3 text-sm text-brown-900"),a(S,"class","text-brown-700 hover:text-brown-900 font-medium"),a(N,"class","text-red-600 hover:text-red-800 font-medium"),a(x,"class","px-4 py-3 text-sm space-x-2"),a(e,"class","hover:bg-brown-50")},m(E,T){y(E,e,T),o(e,t),o(t,i),o(e,r),o(e,s),o(s,u),o(e,b),o(e,d),o(d,_),o(e,m),o(e,h),o(h,B),o(e,v),o(e,x),o(x,S),o(x,A),o(x,N),o(e,P),L||(O=[z(S,"click",D),z(N,"click",F)],L=!0)},p(E,T){var R;n=E,T[0]&131072&&l!==(l=(n[77].name||"-")+"")&&j(i,l),T[0]&131072&&c!==(c=n[77].origin+"")&&j(u,c),T[0]&131072&&p!==(p=n[77].roast_level+"")&&j(_,p),T[0]&131072&&g!==(g=(((R=n[77].roaster)==null?void 0:R.name)||"-")+"")&&j(B,g)},d(E){E&&k(e),L=!1,ce(O)}}}function Ll(n){let e,t=n[74].name+"",l,i;return{c(){e=f("option"),l=C(t),e.__value=i=n[74].rkey,H(e,e.__value)},m(r,s){y(r,e,s),o(e,l)},p(r,s){s[0]&65536&&t!==(t=r[74].name+"")&&j(l,t),s[0]&65536&&i!==(i=r[74].rkey)&&(e.__value=i,H(e,e.__value))},d(r){r&&k(e)}}}function ii(n){let e,t,l,i,r,s,c,u,b,d,p,_,m,h,g,B,v,x,S,A,N,P=le(n[16]),L=[];for(let O=0;O<P.length;O+=1)L[O]=Ll(kl(n,P,O));return{c(){e=f("input"),t=w(),l=f("input"),i=w(),r=f("select"),s=f("option"),s.textContent="Select Roaster (Optional)";for(let O=0;O<L.length;O+=1)L[O].c();c=w(),u=f("select"),b=f("option"),b.textContent="Select Roast Level (Optional)",d=f("option"),d.textContent="Ultra-Light",p=f("option"),p.textContent="Light",_=f("option"),_.textContent="Medium-Light",m=f("option"),m.textContent="Medium",h=f("option"),h.textContent="Medium-Dark",g=f("option"),g.textContent="Dark",B=w(),v=f("input"),x=w(),S=f("textarea"),a(e,"type","text"),a(e,"placeholder","Name *"),a(e,"class","w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"),a(l,"type","text"),a(l,"placeholder","Origin *"),a(l,"class","w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"),s.__value="",H(s,s.__value),a(r,"class","w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"),n[10].roaster_rkey===void 0&&rt(()=>n[51].call(r)),b.__value="",H(b,b.__value),d.__value="Ultra-Light",H(d,d.__value),p.__value="Light",H(p,p.__value),_.__value="Medium-Light",H(_,_.__value),m.__value="Medium",H(m,m.__value),h.__value="Medium-Dark",H(h,h.__value),g.__value="Dark",H(g,g.__value),a(u,"class","w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"),n[10].roast_level===void 0&&rt(()=>n[52].call(u)),a(v,"type","text"),a(v,"placeholder","Process (e.g. Washed, Natural, Honey)"),a(v,"class","w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"),a(S,"placeholder","Description"),a(S,"rows","3"),a(S,"class","w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600")},m(O,D){y(O,e,D),H(e,n[10].name),y(O,t,D),y(O,l,D),H(l,n[10].origin),y(O,i,D),y(O,r,D),o(r,s);for(let F=0;F<L.length;F+=1)L[F]&&L[F].m(r,null);Le(r,n[10].roaster_rkey,!0),y(O,c,D),y(O,u,D),o(u,b),o(u,d),o(u,p),o(u,_),o(u,m),o(u,h),o(u,g),Le(u,n[10].roast_level,!0),y(O,B,D),y(O,v,D),H(v,n[10].process),y(O,x,D),y(O,S,D),H(S,n[10].description),A||(N=[z(e,"input",n[49]),z(l,"input",n[50]),z(r,"change",n[51]),z(u,"change",n[52]),z(v,"input",n[53]),z(S,"input",n[54])],A=!0)},p(O,D){if(D[0]&66560&&e.value!==O[10].name&&H(e,O[10].name),D[0]&66560&&l.value!==O[10].origin&&H(l,O[10].origin),D[0]&65536){P=le(O[16]);let F;for(F=0;F<P.length;F+=1){const G=kl(O,P,F);L[F]?L[F].p(G,D):(L[F]=Ll(G),L[F].c(),L[F].m(r,null))}for(;F<L.length;F+=1)L[F].d(1);L.length=P.length}D[0]&66560&&Le(r,O[10].roaster_rkey),D[0]&66560&&Le(u,O[10].roast_level),D[0]&66560&&v.value!==O[10].process&&H(v,O[10].process),D[0]&66560&&H(S,O[10].description)},d(O){O&&(k(e),k(t),k(l),k(i),k(r),k(c),k(u),k(B),k(v),k(x),k(S)),Ge(L,O),A=!1,ce(N)}}}function si(n){let e,t,l,i,r,s,c;return{c(){e=f("input"),t=w(),l=f("input"),i=w(),r=f("input"),a(e,"type","text"),a(e,"placeholder","Name *"),a(e,"class","w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"),a(l,"type","text"),a(l,"placeholder","Location"),a(l,"class","w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"),a(r,"type","url"),a(r,"placeholder","Website"),a(r,"class","w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600")},m(u,b){y(u,e,b),H(e,n[11].name),y(u,t,b),y(u,l,b),H(l,n[11].location),y(u,i,b),y(u,r,b),H(r,n[11].website),s||(c=[z(e,"input",n[57]),z(l,"input",n[58]),z(r,"input",n[59])],s=!0)},p(u,b){b[0]&2048&&e.value!==u[11].name&&H(e,u[11].name),b[0]&2048&&l.value!==u[11].location&&H(l,u[11].location),b[0]&2048&&r.value!==u[11].website&&H(r,u[11].website)},d(u){u&&(k(e),k(t),k(l),k(i),k(r)),s=!1,ce(c)}}}function ai(n){let e,t,l,i,r,s,c,u,b,d,p,_,m,h,g,B;return{c(){e=f("input"),t=w(),l=f("select"),i=f("option"),i.textContent="Select Grinder Type *",r=f("option"),r.textContent="Hand",s=f("option"),s.textContent="Electric",c=f("option"),c.textContent="Portable Electric",u=w(),b=f("select"),d=f("option"),d.textContent="Select Burr Type (Optional)",p=f("option"),p.textContent="Conical",_=f("option"),_.textContent="Flat",m=w(),h=f("textarea"),a(e,"type","text"),a(e,"placeholder","Name *"),a(e,"class","w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"),i.__value="",H(i,i.__value),r.__value="Hand",H(r,r.__value),s.__value="Electric",H(s,s.__value),c.__value="Portable Electric",H(c,c.__value),a(l,"class","w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"),n[12].grinder_type===void 0&&rt(()=>n[63].call(l)),d.__value="",H(d,d.__value),p.__value="Conical",H(p,p.__value),_.__value="Flat",H(_,_.__value),a(b,"class","w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"),n[12].burr_type===void 0&&rt(()=>n[64].call(b)),a(h,"placeholder","Notes"),a(h,"rows","3"),a(h,"class","w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600")},m(v,x){y(v,e,x),H(e,n[12].name),y(v,t,x),y(v,l,x),o(l,i),o(l,r),o(l,s),o(l,c),Le(l,n[12].grinder_type,!0),y(v,u,x),y(v,b,x),o(b,d),o(b,p),o(b,_),Le(b,n[12].burr_type,!0),y(v,m,x),y(v,h,x),H(h,n[12].notes),g||(B=[z(e,"input",n[62]),z(l,"change",n[63]),z(b,"change",n[64]),z(h,"input",n[65])],g=!0)},p(v,x){x[0]&4096&&e.value!==v[12].name&&H(e,v[12].name),x[0]&4096&&Le(l,v[12].grinder_type),x[0]&4096&&Le(b,v[12].burr_type),x[0]&4096&&H(h,v[12].notes)},d(v){v&&(k(e),k(t),k(l),k(u),k(b),k(m),k(h)),g=!1,ce(B)}}}function ui(n){let e,t,l,i,r,s,c;return{c(){e=f("input"),t=w(),l=f("input"),i=w(),r=f("textarea"),a(e,"type","text"),a(e,"placeholder","Name *"),a(e,"class","w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"),a(l,"type","text"),a(l,"placeholder","Type (e.g., Pour-Over, Immersion, Espresso)"),a(l,"class","w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"),a(r,"placeholder","Description"),a(r,"rows","3"),a(r,"class","w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600")},m(u,b){y(u,e,b),H(e,n[13].name),y(u,t,b),y(u,l,b),H(l,n[13].brewer_type),y(u,i,b),y(u,r,b),H(r,n[13].description),s||(c=[z(e,"input",n[68]),z(l,"input",n[69]),z(r,"input",n[70])],s=!0)},p(u,b){b[0]&8192&&e.value!==u[13].name&&H(e,u[13].name),b[0]&8192&&l.value!==u[13].brewer_type&&H(l,u[13].brewer_type),b[0]&8192&&H(r,u[13].description)},d(u){u&&(k(e),k(t),k(l),k(i),k(r)),s=!1,ce(c)}}}function ci(n){let e,t,l,i,r,s,c,u,b,d,p,_,m,h,g,B,v;function x(T,R){return T[1]?Vo:Yo}let S=x(n),A=S(n);function N(T){n[56](T)}let P={title:n[6]?"Edit Bean":"Add Bean",onSave:n[21],onCancel:n[55],$$slots:{default:[ii]},$$scope:{ctx:n}};n[2]!==void 0&&(P.isOpen=n[2]),s=new ht({props:P}),ct.push(()=>wt(s,"isOpen",N));function L(T){n[61](T)}let O={title:n[7]?"Edit Roaster":"Add Roaster",onSave:n[25],onCancel:n[60],$$slots:{default:[si]},$$scope:{ctx:n}};n[3]!==void 0&&(O.isOpen=n[3]),b=new ht({props:O}),ct.push(()=>wt(b,"isOpen",L));function D(T){n[67](T)}let F={title:n[8]?"Edit Grinder":"Add Grinder",onSave:n[29],onCancel:n[66],$$slots:{default:[ai]},$$scope:{ctx:n}};n[4]!==void 0&&(F.isOpen=n[4]),_=new ht({props:F}),ct.push(()=>wt(_,"isOpen",D));function G(T){n[72](T)}let E={title:n[9]?"Edit Brewer":"Add Brewer",onSave:n[33],onCancel:n[71],$$slots:{default:[ui]},$$scope:{ctx:n}};return n[5]!==void 0&&(E.isOpen=n[5]),g=new ht({props:E}),ct.push(()=>wt(g,"isOpen",G)),{c(){e=w(),t=f("div"),l=f("h1"),l.textContent="Manage Equipment & Beans",i=w(),A.c(),r=w(),it(s.$$.fragment),u=w(),it(b.$$.fragment),p=w(),it(_.$$.fragment),h=w(),it(g.$$.fragment),document.title="Manage - Arabica",a(l,"class","text-3xl font-bold text-brown-900 mb-6"),a(t,"class","max-w-6xl mx-auto")},m(T,R){y(T,e,R),y(T,t,R),o(t,l),o(t,i),A.m(t,null),y(T,r,R),nt(s,T,R),y(T,u,R),nt(b,T,R),y(T,p,R),nt(_,T,R),y(T,h,R),nt(g,T,R),v=!0},p(T,R){S===(S=x(T))&&A?A.p(T,R):(A.d(1),A=S(T),A&&(A.c(),A.m(t,null)));const V={};R[0]&64&&(V.title=T[6]?"Edit Bean":"Add Bean"),R[0]&4&&(V.onCancel=T[55]),R[0]&66560|R[2]&67108864&&(V.$$scope={dirty:R,ctx:T}),!c&&R[0]&4&&(c=!0,V.isOpen=T[2],mt(()=>c=!1)),s.$set(V);const X={};R[0]&128&&(X.title=T[7]?"Edit Roaster":"Add Roaster"),R[0]&8&&(X.onCancel=T[60]),R[0]&2048|R[2]&67108864&&(X.$$scope={dirty:R,ctx:T}),!d&&R[0]&8&&(d=!0,X.isOpen=T[3],mt(()=>d=!1)),b.$set(X);const J={};R[0]&256&&(J.title=T[8]?"Edit Grinder":"Add Grinder"),R[0]&16&&(J.onCancel=T[66]),R[0]&4096|R[2]&67108864&&(J.$$scope={dirty:R,ctx:T}),!m&&R[0]&16&&(m=!0,J.isOpen=T[4],mt(()=>m=!1)),_.$set(J);const I={};R[0]&512&&(I.title=T[9]?"Edit Brewer":"Add Brewer"),R[0]&32&&(I.onCancel=T[71]),R[0]&8192|R[2]&67108864&&(I.$$scope={dirty:R,ctx:T}),!B&&R[0]&32&&(B=!0,I.isOpen=T[5],mt(()=>B=!1)),g.$set(I)},i(T){v||(ve(s.$$.fragment,T),ve(b.$$.fragment,T),ve(_.$$.fragment,T),ve(g.$$.fragment,T),v=!0)},o(T){Oe(s.$$.fragment,T),Oe(b.$$.fragment,T),Oe(_.$$.fragment,T),Oe(g.$$.fragment,T),v=!1},d(T){T&&(k(e),k(t),k(r),k(u),k(p),k(h)),A.d(),lt(s,T),lt(b,T),lt(_,T),lt(g,T)}}}function fi(n,e,t){let l,i,r,s,c,u,b;ut(n,pt,M=>t(35,u=M)),ut(n,Te,M=>t(36,b=M));let d="beans",p=!0,_=!1,m=!1,h=!1,g=!1,B=null,v=null,x=null,S=null,A={name:"",origin:"",roast_level:"",process:"",description:"",roaster_rkey:""},N={name:"",location:"",website:"",description:""},P={name:"",grinder_type:"",burr_type:"",notes:""},L={name:"",brewer_type:"",description:""};vt(async()=>{if(!c){_e("/login");return}const M=localStorage.getItem("arabica_manage_tab");M&&t(0,d=M),await Te.load(),t(1,p=!1)});function O(M){t(0,d=M),localStorage.setItem("arabica_manage_tab",M)}function D(){t(6,B=null),t(10,A={name:"",origin:"",roast_level:"",process:"",description:"",roaster_rkey:""}),t(2,_=!0)}function F(M){t(6,B=M),t(10,A={name:M.name||"",origin:M.origin||"",roast_level:M.roast_level||"",process:M.process||"",description:M.description||"",roaster_rkey:M.roaster_rkey||""}),t(2,_=!0)}async function G(){try{console.log("Saving bean with data:",A),B?(console.log("Updating bean:",B.rkey),await ge.put(`/api/beans/${B.rkey}`,A)):(console.log("Creating new bean"),await ge.post("/api/beans",A)),await Te.invalidate(),t(2,_=!1)}catch(M){console.error("Bean save error:",M),alert("Failed to save bean: "+M.message)}}async function E(M){if(confirm("Are you sure you want to delete this bean?"))try{await ge.delete(`/api/beans/${M}`),await Te.invalidate()}catch(ne){alert("Failed to delete bean: "+ne.message)}}function T(){t(7,v=null),t(11,N={name:"",location:"",website:"",description:""}),t(3,m=!0)}function R(M){t(7,v=M),t(11,N={name:M.name||"",location:M.location||"",website:M.website||"",description:M.Description||""}),t(3,m=!0)}async function V(){try{v?await ge.put(`/api/roasters/${v.rkey}`,N):await ge.post("/api/roasters",N),await Te.invalidate(),t(3,m=!1)}catch(M){alert("Failed to save roaster: "+M.message)}}async function X(M){if(confirm("Are you sure you want to delete this roaster?"))try{await ge.delete(`/api/roasters/${M}`),await Te.invalidate()}catch(ne){alert("Failed to delete roaster: "+ne.message)}}function J(){t(8,x=null),t(12,P={name:"",grinder_type:"",burr_type:"",notes:""}),t(4,h=!0)}function I(M){t(8,x=M),t(12,P={name:M.name||"",grinder_type:M.grinder_type||"",burr_type:M.burr_type||"",notes:M.notes||""}),t(4,h=!0)}async function Y(){try{x?await ge.put(`/api/grinders/${x.rkey}`,P):await ge.post("/api/grinders",P),await Te.invalidate(),t(4,h=!1)}catch(M){alert("Failed to save grinder: "+M.message)}}async function ue(M){if(confirm("Are you sure you want to delete this grinder?"))try{await ge.delete(`/api/grinders/${M}`),await Te.invalidate()}catch(ne){alert("Failed to delete grinder: "+ne.message)}}function $(){t(9,S=null),t(13,L={name:"",brewer_type:"",description:""}),t(5,g=!0)}function Ae(M){t(9,S=M),t(13,L={name:M.name||"",brewer_type:M.brewer_type||"",description:M.description||""}),t(5,g=!0)}async function ke(){try{S?await ge.put(`/api/brewers/${S.rkey}`,L):await ge.post("/api/brewers",L),await Te.invalidate(),t(5,g=!1)}catch(M){alert("Failed to save brewer: "+M.message)}}async function De(M){if(confirm("Are you sure you want to delete this brewer?"))try{await ge.delete(`/api/brewers/${M}`),await Te.invalidate()}catch(ne){alert("Failed to delete brewer: "+ne.message)}}const ye=()=>O("beans"),Me=()=>O("roasters"),we=()=>O("grinders"),Fe=()=>O("brewers"),Ee=M=>F(M),ae=M=>E(M.rkey),te=M=>R(M),ie=M=>X(M.rkey),re=M=>I(M),oe=M=>ue(M.rkey),he=M=>Ae(M),fe=M=>De(M.rkey);function pe(){A.name=this.value,t(10,A),t(16,i),t(36,b)}function Ie(){A.origin=this.value,t(10,A),t(16,i),t(36,b)}function se(){A.roaster_rkey=at(this),t(10,A),t(16,i),t(36,b)}function ee(){A.roast_level=at(this),t(10,A),t(16,i),t(36,b)}function qe(){A.process=this.value,t(10,A),t(16,i),t(36,b)}function Pe(){A.description=this.value,t(10,A),t(16,i),t(36,b)}const Re=()=>t(2,_=!1);function Se(M){_=M,t(2,_)}function Ze(){N.name=this.value,t(11,N)}function xe(){N.location=this.value,t(11,N)}function Ye(){N.website=this.value,t(11,N)}const $e=()=>t(3,m=!1);function de(M){m=M,t(3,m)}function je(){P.name=this.value,t(12,P)}function be(){P.grinder_type=at(this),t(12,P)}function Ce(){P.burr_type=at(this),t(12,P)}function q(){P.notes=this.value,t(12,P)}const Z=()=>t(4,h=!1);function K(M){h=M,t(4,h)}function me(){L.name=this.value,t(13,L)}function dt(){L.brewer_type=this.value,t(13,L)}function He(){L.description=this.value,t(13,L)}const Ne=()=>t(5,g=!1);function ze(M){g=M,t(5,g)}return n.$$.update=()=>{n.$$.dirty[1]&32&&t(17,l=b.beans||[]),n.$$.dirty[1]&32&&t(16,i=b.roasters||[]),n.$$.dirty[1]&32&&t(15,r=b.grinders||[]),n.$$.dirty[1]&32&&t(14,s=b.brewers||[]),n.$$.dirty[1]&16&&(c=u.isAuthenticated)},[d,p,_,m,h,g,B,v,x,S,A,N,P,L,s,r,i,l,O,D,F,G,E,T,R,V,X,J,I,Y,ue,$,Ae,ke,De,u,b,ye,Me,we,Fe,Ee,ae,te,ie,re,oe,he,fe,pe,Ie,se,ee,qe,Pe,Re,Se,Ze,xe,Ye,$e,de,je,be,Ce,q,Z,K,me,dt,He,Ne,ze]}class di extends Xe{constructor(e){super(),Qe(this,e,fi,ci,We,{},null,[-1,-1,-1])}}function Ol(n,e,t){const l=n.slice();return l[23]=e[t],l}function Ml(n,e,t){const l=n.slice();return l[26]=e[t],l}function El(n,e,t){const l=n.slice();return l[17]=e[t],l}function Pl(n,e,t){const l=n.slice();return l[20]=e[t],l}function Dl(n,e,t){const l=n.slice();return l[14]=e[t],l}function bi(n){let e,t,l,i,r,s,c,u=n[0].handle+"",b,d,p,_,m,h=n[1].length+"",g,B,v,x,S,A,N=n[2].length+"",P,L,O,D,F,G,E=n[3].length+"",T,R,V,X,J,I,Y=n[4].length+"",ue,$,Ae,ke,De,ye,Me=n[5].length+"",we,Fe,Ee,ae,te,ie,re,oe,he,fe,pe,Ie,se,ee,qe,Pe,Re,Se,Ze,xe,Ye;function $e(K,me){return K[0].avatar?wi:mi}let de=$e(n),je=de(n),be=n[0].displayName&&Fl(n);function Ce(K,me){if(K[8]==="brews")return vi;if(K[8]==="beans")return gi;if(K[8]==="gear")return hi}let q=Ce(n),Z=q&&q(n);return{c(){e=f("div"),t=f("div"),je.c(),l=w(),i=f("div"),be&&be.c(),r=w(),s=f("p"),c=C("@"),b=C(u),d=w(),p=f("div"),_=f("div"),m=f("div"),g=C(h),B=w(),v=f("div"),v.textContent="Brews",x=w(),S=f("div"),A=f("div"),P=C(N),L=w(),O=f("div"),O.textContent="Beans",D=w(),F=f("div"),G=f("div"),T=C(E),R=w(),V=f("div"),V.textContent="Roasters",X=w(),J=f("div"),I=f("div"),ue=C(Y),$=w(),Ae=f("div"),Ae.textContent="Grinders",ke=w(),De=f("div"),ye=f("div"),we=C(Me),Fe=w(),Ee=f("div"),Ee.textContent="Brewers",ae=w(),te=f("div"),ie=f("div"),re=f("div"),oe=f("button"),he=C("Brews"),pe=w(),Ie=f("button"),se=C("Beans"),qe=w(),Pe=f("button"),Re=C("Gear"),Ze=w(),Z&&Z.c(),a(s,"class","text-brown-700"),a(t,"class","flex items-center gap-4"),a(e,"class","bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-6 mb-6 border border-brown-300"),a(m,"class","text-2xl font-bold text-brown-800"),a(v,"class","text-sm text-brown-700"),a(_,"class","bg-gradient-to-br from-brown-100 to-brown-200 rounded-lg shadow-md p-4 text-center border border-brown-300"),a(A,"class","text-2xl font-bold text-brown-800"),a(O,"class","text-sm text-brown-700"),a(S,"class","bg-gradient-to-br from-brown-100 to-brown-200 rounded-lg shadow-md p-4 text-center border border-brown-300"),a(G,"class","text-2xl font-bold text-brown-800"),a(V,"class","text-sm text-brown-700"),a(F,"class","bg-gradient-to-br from-brown-100 to-brown-200 rounded-lg shadow-md p-4 text-center border border-brown-300"),a(I,"class","text-2xl font-bold text-brown-800"),a(Ae,"class","text-sm text-brown-700"),a(J,"class","bg-gradient-to-br from-brown-100 to-brown-200 rounded-lg shadow-md p-4 text-center border border-brown-300"),a(ye,"class","text-2xl font-bold text-brown-800"),a(Ee,"class","text-sm text-brown-700"),a(De,"class","bg-gradient-to-br from-brown-100 to-brown-200 rounded-lg shadow-md p-4 text-center border border-brown-300"),a(p,"class","grid grid-cols-2 md:grid-cols-5 gap-4 mb-6"),a(oe,"class",fe="flex-1 py-3 px-4 text-center font-medium transition-colors "+(n[8]==="brews"?"border-b-2 border-brown-700 text-brown-900":"text-brown-600 hover:text-brown-800")),a(Ie,"class",ee="flex-1 py-3 px-4 text-center font-medium transition-colors "+(n[8]==="beans"?"border-b-2 border-brown-700 text-brown-900":"text-brown-600 hover:text-brown-800")),a(Pe,"class",Se="flex-1 py-3 px-4 text-center font-medium transition-colors "+(n[8]==="gear"?"border-b-2 border-brown-700 text-brown-900":"text-brown-600 hover:text-brown-800")),a(re,"class","flex border-b border-brown-300"),a(ie,"class","bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-md mb-4 border border-brown-300")},m(K,me){y(K,e,me),o(e,t),je.m(t,null),o(t,l),o(t,i),be&&be.m(i,null),o(i,r),o(i,s),o(s,c),o(s,b),y(K,d,me),y(K,p,me),o(p,_),o(_,m),o(m,g),o(_,B),o(_,v),o(p,x),o(p,S),o(S,A),o(A,P),o(S,L),o(S,O),o(p,D),o(p,F),o(F,G),o(G,T),o(F,R),o(F,V),o(p,X),o(p,J),o(J,I),o(I,ue),o(J,$),o(J,Ae),o(p,ke),o(p,De),o(De,ye),o(ye,we),o(De,Fe),o(De,Ee),y(K,ae,me),y(K,te,me),o(te,ie),o(ie,re),o(re,oe),o(oe,he),o(re,pe),o(re,Ie),o(Ie,se),o(re,qe),o(re,Pe),o(Pe,Re),o(te,Ze),Z&&Z.m(te,null),xe||(Ye=[z(oe,"click",n[10]),z(Ie,"click",n[11]),z(Pe,"click",n[12])],xe=!0)},p(K,me){de===(de=$e(K))&&je?je.p(K,me):(je.d(1),je=de(K),je&&(je.c(),je.m(t,l))),K[0].displayName?be?be.p(K,me):(be=Fl(K),be.c(),be.m(i,r)):be&&(be.d(1),be=null),me&1&&u!==(u=K[0].handle+"")&&j(b,u),me&2&&h!==(h=K[1].length+"")&&j(g,h),me&4&&N!==(N=K[2].length+"")&&j(P,N),me&8&&E!==(E=K[3].length+"")&&j(T,E),me&16&&Y!==(Y=K[4].length+"")&&j(ue,Y),me&32&&Me!==(Me=K[5].length+"")&&j(we,Me),me&256&&fe!==(fe="flex-1 py-3 px-4 text-center font-medium transition-colors "+(K[8]==="brews"?"border-b-2 border-brown-700 text-brown-900":"text-brown-600 hover:text-brown-800"))&&a(oe,"class",fe),me&256&&ee!==(ee="flex-1 py-3 px-4 text-center font-medium transition-colors "+(K[8]==="beans"?"border-b-2 border-brown-700 text-brown-900":"text-brown-600 hover:text-brown-800"))&&a(Ie,"class",ee),me&256&&Se!==(Se="flex-1 py-3 px-4 text-center font-medium transition-colors "+(K[8]==="gear"?"border-b-2 border-brown-700 text-brown-900":"text-brown-600 hover:text-brown-800"))&&a(Pe,"class",Se),q===(q=Ce(K))&&Z?Z.p(K,me):(Z&&Z.d(1),Z=q&&q(K),Z&&(Z.c(),Z.m(te,null)))},d(K){K&&(k(e),k(d),k(p),k(ae),k(te)),je.d(),be&&be.d(),Z&&Z.d(),xe=!1,ce(Ye)}}}function pi(n){let e,t,l;return{c(){e=f("div"),t=C("Error: "),l=C(n[7]),a(e,"class","bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded")},m(i,r){y(i,e,r),o(e,t),o(e,l)},p(i,r){r&128&&j(l,i[7])},d(i){i&&k(e)}}}function _i(n){let e;return{c(){e=f("div"),e.innerHTML='<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-brown-900"></div> <p class="mt-4 text-brown-700">Loading profile...</p>',a(e,"class","text-center py-12")},m(t,l){y(t,e,l)},p:W,d(t){t&&k(e)}}}function mi(n){let e;return{c(){e=f("div"),e.innerHTML='<span class="text-brown-600 text-2xl">?</span>',a(e,"class","w-20 h-20 rounded-full bg-brown-300 flex items-center justify-center")},m(t,l){y(t,e,l)},p:W,d(t){t&&k(e)}}}function wi(n){let e,t;return{c(){e=f("img"),gt(e.src,t=n[0].avatar)||a(e,"src",t),a(e,"alt",""),a(e,"class","w-20 h-20 rounded-full object-cover border-2 border-brown-300")},m(l,i){y(l,e,i)},p(l,i){i&1&&!gt(e.src,t=l[0].avatar)&&a(e,"src",t)},d(l){l&&k(e)}}}function Fl(n){let e,t=n[0].displayName+"",l;return{c(){e=f("h1"),l=C(t),a(e,"class","text-2xl font-bold text-brown-900")},m(i,r){y(i,e,r),o(e,l)},p(i,r){r&1&&t!==(t=i[0].displayName+"")&&j(l,t)},d(i){i&&k(e)}}}function hi(n){let e,t,l,i=n[4].length>0&&Rl(n),r=n[5].length>0&&Hl(n),s=n[4].length===0&&n[5].length===0&&Gl();return{c(){e=f("div"),i&&i.c(),t=w(),r&&r.c(),l=w(),s&&s.c(),a(e,"class","space-y-6")},m(c,u){y(c,e,u),i&&i.m(e,null),o(e,t),r&&r.m(e,null),o(e,l),s&&s.m(e,null)},p(c,u){c[4].length>0?i?i.p(c,u):(i=Rl(c),i.c(),i.m(e,t)):i&&(i.d(1),i=null),c[5].length>0?r?r.p(c,u):(r=Hl(c),r.c(),r.m(e,l)):r&&(r.d(1),r=null),c[4].length===0&&c[5].length===0?s||(s=Gl(),s.c(),s.m(e,null)):s&&(s.d(1),s=null)},d(c){c&&k(e),i&&i.d(),r&&r.d(),s&&s.d()}}}function gi(n){let e,t,l,i=n[2].length>0&&Il(n),r=n[3].length>0&&Wl(n),s=n[2].length===0&&n[3].length===0&&Yl();return{c(){e=f("div"),i&&i.c(),t=w(),r&&r.c(),l=w(),s&&s.c(),a(e,"class","space-y-6")},m(c,u){y(c,e,u),i&&i.m(e,null),o(e,t),r&&r.m(e,null),o(e,l),s&&s.m(e,null)},p(c,u){c[2].length>0?i?i.p(c,u):(i=Il(c),i.c(),i.m(e,t)):i&&(i.d(1),i=null),c[3].length>0?r?r.p(c,u):(r=Wl(c),r.c(),r.m(e,l)):r&&(r.d(1),r=null),c[2].length===0&&c[3].length===0?s||(s=Yl(),s.c(),s.m(e,null)):s&&(s.d(1),s=null)},d(c){c&&k(e),i&&i.d(),r&&r.d(),s&&s.d()}}}function vi(n){let e;function t(r,s){return r[1].length===0?Ci:xi}let l=t(n),i=l(n);return{c(){i.c(),e=ft()},m(r,s){i.m(r,s),y(r,e,s)},p(r,s){l===(l=t(r))&&i?i.p(r,s):(i.d(1),i=l(r),i&&(i.c(),i.m(e.parentNode,e)))},d(r){r&&k(e),i.d(r)}}}function Rl(n){let e,t,l,i,r,s,c,u,b=le(n[4]),d=[];for(let p=0;p<b.length;p+=1)d[p]=jl(Ml(n,b,p));return{c(){e=f("div"),t=f("h3"),t.textContent="⚙️ Grinders",l=w(),i=f("div"),r=f("table"),s=f("thead"),s.innerHTML='<tr><th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">Name</th> <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">🔧 Type</th> <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">💎 Burrs</th> <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">📝 Notes</th></tr>',c=w(),u=f("tbody");for(let p=0;p<d.length;p+=1)d[p].c();a(t,"class","text-lg font-semibold text-brown-900 mb-3"),a(s,"class","bg-brown-200/80"),a(u,"class","bg-brown-50/60 divide-y divide-brown-200"),a(r,"class","min-w-full divide-y divide-brown-300"),a(i,"class","overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300")},m(p,_){y(p,e,_),o(e,t),o(e,l),o(e,i),o(i,r),o(r,s),o(r,c),o(r,u);for(let m=0;m<d.length;m+=1)d[m]&&d[m].m(u,null)},p(p,_){if(_&16){b=le(p[4]);let m;for(m=0;m<b.length;m+=1){const h=Ml(p,b,m);d[m]?d[m].p(h,_):(d[m]=jl(h),d[m].c(),d[m].m(u,null))}for(;m<d.length;m+=1)d[m].d(1);d.length=b.length}},d(p){p&&k(e),Ge(d,p)}}}function jl(n){let e,t,l=n[26].name+"",i,r,s,c=(n[26].grinder_type||"-")+"",u,b,d,p=(n[26].burr_type||"-")+"",_,m,h,g=(n[26].notes||"-")+"",B,v;return{c(){e=f("tr"),t=f("td"),i=C(l),r=w(),s=f("td"),u=C(c),b=w(),d=f("td"),_=C(p),m=w(),h=f("td"),B=C(g),v=w(),a(t,"class","px-6 py-4 text-sm font-bold text-brown-900"),a(s,"class","px-6 py-4 text-sm text-brown-900"),a(d,"class","px-6 py-4 text-sm text-brown-900"),a(h,"class","px-6 py-4 text-sm text-brown-700 italic max-w-xs"),a(e,"class","hover:bg-brown-100/60 transition-colors")},m(x,S){y(x,e,S),o(e,t),o(t,i),o(e,r),o(e,s),o(s,u),o(e,b),o(e,d),o(d,_),o(e,m),o(e,h),o(h,B),o(e,v)},p(x,S){S&16&&l!==(l=x[26].name+"")&&j(i,l),S&16&&c!==(c=(x[26].grinder_type||"-")+"")&&j(u,c),S&16&&p!==(p=(x[26].burr_type||"-")+"")&&j(_,p),S&16&&g!==(g=(x[26].notes||"-")+"")&&j(B,g)},d(x){x&&k(e)}}}function Hl(n){let e,t,l,i,r,s,c,u,b=le(n[5]),d=[];for(let p=0;p<b.length;p+=1)d[p]=zl(Ol(n,b,p));return{c(){e=f("div"),t=f("h3"),t.textContent="☕ Brewers",l=w(),i=f("div"),r=f("table"),s=f("thead"),s.innerHTML='<tr><th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">Name</th> <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">🔧 Type</th> <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">📝 Description</th></tr>',c=w(),u=f("tbody");for(let p=0;p<d.length;p+=1)d[p].c();a(t,"class","text-lg font-semibold text-brown-900 mb-3"),a(s,"class","bg-brown-200/80"),a(u,"class","bg-brown-50/60 divide-y divide-brown-200"),a(r,"class","min-w-full divide-y divide-brown-300"),a(i,"class","overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300")},m(p,_){y(p,e,_),o(e,t),o(e,l),o(e,i),o(i,r),o(r,s),o(r,c),o(r,u);for(let m=0;m<d.length;m+=1)d[m]&&d[m].m(u,null)},p(p,_){if(_&32){b=le(p[5]);let m;for(m=0;m<b.length;m+=1){const h=Ol(p,b,m);d[m]?d[m].p(h,_):(d[m]=zl(h),d[m].c(),d[m].m(u,null))}for(;m<d.length;m+=1)d[m].d(1);d.length=b.length}},d(p){p&&k(e),Ge(d,p)}}}function zl(n){let e,t,l=n[23].name+"",i,r,s,c=(n[23].brewer_type||"-")+"",u,b,d,p=(n[23].description||"-")+"",_,m;return{c(){e=f("tr"),t=f("td"),i=C(l),r=w(),s=f("td"),u=C(c),b=w(),d=f("td"),_=C(p),m=w(),a(t,"class","px-6 py-4 text-sm font-bold text-brown-900"),a(s,"class","px-6 py-4 text-sm text-brown-900"),a(d,"class","px-6 py-4 text-sm text-brown-700 italic max-w-xs"),a(e,"class","hover:bg-brown-100/60 transition-colors")},m(h,g){y(h,e,g),o(e,t),o(t,i),o(e,r),o(e,s),o(s,u),o(e,b),o(e,d),o(d,_),o(e,m)},p(h,g){g&32&&l!==(l=h[23].name+"")&&j(i,l),g&32&&c!==(c=(h[23].brewer_type||"-")+"")&&j(u,c),g&32&&p!==(p=(h[23].description||"-")+"")&&j(_,p)},d(h){h&&k(e)}}}function Gl(n){let e;return{c(){e=f("div"),e.innerHTML='<p class="font-medium">No gear added yet.</p>',a(e,"class","bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center text-brown-800 border border-brown-300")},m(t,l){y(t,e,l)},d(t){t&&k(e)}}}function Il(n){let e,t,l,i,r,s,c,u,b=le(n[2]),d=[];for(let p=0;p<b.length;p+=1)d[p]=Ul(Pl(n,b,p));return{c(){e=f("div"),t=f("h3"),t.textContent="☕ Coffee Beans",l=w(),i=f("div"),r=f("table"),s=f("thead"),s.innerHTML='<tr><th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap">Name</th> <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap">☕ Roaster</th> <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap">📍 Origin</th> <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap">🔥 Roast</th> <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap">🌱 Process</th> <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap">📝 Description</th></tr>',c=w(),u=f("tbody");for(let p=0;p<d.length;p+=1)d[p].c();a(t,"class","text-lg font-semibold text-brown-900 mb-3"),a(s,"class","bg-brown-200/80"),a(u,"class","bg-brown-50/60 divide-y divide-brown-200"),a(r,"class","min-w-full divide-y divide-brown-300"),a(i,"class","overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300")},m(p,_){y(p,e,_),o(e,t),o(e,l),o(e,i),o(i,r),o(r,s),o(r,c),o(r,u);for(let m=0;m<d.length;m+=1)d[m]&&d[m].m(u,null)},p(p,_){if(_&4){b=le(p[2]);let m;for(m=0;m<b.length;m+=1){const h=Pl(p,b,m);d[m]?d[m].p(h,_):(d[m]=Ul(h),d[m].c(),d[m].m(u,null))}for(;m<d.length;m+=1)d[m].d(1);d.length=b.length}},d(p){p&&k(e),Ge(d,p)}}}function Ul(n){var F;let e,t,l=(n[20].name||n[20].origin)+"",i,r,s,c=(((F=n[20].roaster)==null?void 0:F.name)||"-")+"",u,b,d,p=(n[20].origin||"-")+"",_,m,h,g=(n[20].roast_level||"-")+"",B,v,x,S=(n[20].process||"-")+"",A,N,P,L=(n[20].description||"-")+"",O,D;return{c(){e=f("tr"),t=f("td"),i=C(l),r=w(),s=f("td"),u=C(c),b=w(),d=f("td"),_=C(p),m=w(),h=f("td"),B=C(g),v=w(),x=f("td"),A=C(S),N=w(),P=f("td"),O=C(L),D=w(),a(t,"class","px-6 py-4 text-sm font-bold text-brown-900"),a(s,"class","px-6 py-4 text-sm text-brown-900"),a(d,"class","px-6 py-4 text-sm text-brown-900"),a(h,"class","px-6 py-4 text-sm text-brown-900"),a(x,"class","px-6 py-4 text-sm text-brown-900"),a(P,"class","px-6 py-4 text-sm text-brown-700 italic max-w-xs"),a(e,"class","hover:bg-brown-100/60 transition-colors")},m(G,E){y(G,e,E),o(e,t),o(t,i),o(e,r),o(e,s),o(s,u),o(e,b),o(e,d),o(d,_),o(e,m),o(e,h),o(h,B),o(e,v),o(e,x),o(x,A),o(e,N),o(e,P),o(P,O),o(e,D)},p(G,E){var T;E&4&&l!==(l=(G[20].name||G[20].origin)+"")&&j(i,l),E&4&&c!==(c=(((T=G[20].roaster)==null?void 0:T.name)||"-")+"")&&j(u,c),E&4&&p!==(p=(G[20].origin||"-")+"")&&j(_,p),E&4&&g!==(g=(G[20].roast_level||"-")+"")&&j(B,g),E&4&&S!==(S=(G[20].process||"-")+"")&&j(A,S),E&4&&L!==(L=(G[20].description||"-")+"")&&j(O,L)},d(G){G&&k(e)}}}function Wl(n){let e,t,l,i,r,s,c,u,b=le(n[3]),d=[];for(let p=0;p<b.length;p+=1)d[p]=ql(El(n,b,p));return{c(){e=f("div"),t=f("h3"),t.textContent="🏭 Favorite Roasters",l=w(),i=f("div"),r=f("table"),s=f("thead"),s.innerHTML='<tr><th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">Name</th> <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">📍 Location</th> <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">🌐 Website</th></tr>',c=w(),u=f("tbody");for(let p=0;p<d.length;p+=1)d[p].c();a(t,"class","text-lg font-semibold text-brown-900 mb-3"),a(s,"class","bg-brown-200/80"),a(u,"class","bg-brown-50/60 divide-y divide-brown-200"),a(r,"class","min-w-full divide-y divide-brown-300"),a(i,"class","overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300")},m(p,_){y(p,e,_),o(e,t),o(e,l),o(e,i),o(i,r),o(r,s),o(r,c),o(r,u);for(let m=0;m<d.length;m+=1)d[m]&&d[m].m(u,null)},p(p,_){if(_&8){b=le(p[3]);let m;for(m=0;m<b.length;m+=1){const h=El(p,b,m);d[m]?d[m].p(h,_):(d[m]=ql(h),d[m].c(),d[m].m(u,null))}for(;m<d.length;m+=1)d[m].d(1);d.length=b.length}},d(p){p&&k(e),Ge(d,p)}}}function ki(n){let e;return{c(){e=C("-")},m(t,l){y(t,e,l)},p:W,d(t){t&&k(e)}}}function yi(n){let e,t,l;return{c(){e=f("a"),t=C("Visit Site"),a(e,"href",l=n[17].website),a(e,"target","_blank"),a(e,"rel","noopener noreferrer"),a(e,"class","text-brown-700 hover:underline font-medium")},m(i,r){y(i,e,r),o(e,t)},p(i,r){r&8&&l!==(l=i[17].website)&&a(e,"href",l)},d(i){i&&k(e)}}}function ql(n){let e,t,l=n[17].name+"",i,r,s,c=(n[17].location||"-")+"",u,b,d,p;function _(g,B){return g[17].website?yi:ki}let m=_(n),h=m(n);return{c(){e=f("tr"),t=f("td"),i=C(l),r=w(),s=f("td"),u=C(c),b=w(),d=f("td"),h.c(),p=w(),a(t,"class","px-6 py-4 text-sm font-bold text-brown-900"),a(s,"class","px-6 py-4 text-sm text-brown-900"),a(d,"class","px-6 py-4 text-sm text-brown-900"),a(e,"class","hover:bg-brown-100/60 transition-colors")},m(g,B){y(g,e,B),o(e,t),o(t,i),o(e,r),o(e,s),o(s,u),o(e,b),o(e,d),h.m(d,null),o(e,p)},p(g,B){B&8&&l!==(l=g[17].name+"")&&j(i,l),B&8&&c!==(c=(g[17].location||"-")+"")&&j(u,c),m===(m=_(g))&&h?h.p(g,B):(h.d(1),h=m(g),h&&(h.c(),h.m(d,null)))},d(g){g&&k(e),h.d()}}}function Yl(n){let e;return{c(){e=f("div"),e.innerHTML='<p class="font-medium">No beans or roasters yet.</p>',a(e,"class","bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center text-brown-800 border border-brown-300")},m(t,l){y(t,e,l)},d(t){t&&k(e)}}}function xi(n){let e,t,l,i,r,s=le(n[1]),c=[];for(let u=0;u<s.length;u+=1)c[u]=Vl(Dl(n,s,u));return{c(){e=f("div"),t=f("table"),l=f("thead"),l.innerHTML='<tr><th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">📅 Date</th> <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">☕ Bean</th> <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">🫖 Method</th> <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">📝 Notes</th> <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">⭐ Rating</th></tr>',i=w(),r=f("tbody");for(let u=0;u<c.length;u+=1)c[u].c();a(l,"class","bg-brown-200/80"),a(r,"class","bg-brown-50/60 divide-y divide-brown-200"),a(t,"class","min-w-full divide-y divide-brown-300"),a(e,"class","overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300")},m(u,b){y(u,e,b),o(e,t),o(t,l),o(t,i),o(t,r);for(let d=0;d<c.length;d+=1)c[d]&&c[d].m(r,null)},p(u,b){if(b&2){s=le(u[1]);let d;for(d=0;d<s.length;d+=1){const p=Dl(u,s,d);c[d]?c[d].p(p,b):(c[d]=Vl(p),c[d].c(),c[d].m(r,null))}for(;d<c.length;d+=1)c[d].d(1);c.length=s.length}},d(u){u&&k(e),Ge(c,u)}}}function Ci(n){let e;return{c(){e=f("div"),e.innerHTML='<p class="text-brown-800 text-lg font-medium">No brews yet.</p>',a(e,"class","bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center border border-brown-300")},m(t,l){y(t,e,l)},p:W,d(t){t&&k(e)}}}function Bi(n){let e;return{c(){e=f("span"),e.textContent="-",a(e,"class","text-brown-400")},m(t,l){y(t,e,l)},p:W,d(t){t&&k(e)}}}function Ai(n){let e,t,l=n[14].rating+"",i,r;return{c(){e=f("span"),t=C("⭐ "),i=C(l),r=C("/10"),a(e,"class","inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-900")},m(s,c){y(s,e,c),o(e,t),o(e,i),o(e,r)},p(s,c){c&2&&l!==(l=s[14].rating+"")&&j(i,l)},d(s){s&&k(e)}}}function Vl(n){var L,O,D;let e,t,l=Kl(n[14].created_at)+"",i,r,s,c=(((L=n[14].bean)==null?void 0:L.name)||((O=n[14].bean)==null?void 0:O.origin)||"Unknown")+"",u,b,d,p=(((D=n[14].brewer_obj)==null?void 0:D.name)||"-")+"",_,m,h,g=(n[14].tasting_notes||"-")+"",B,v,x,S;function A(F,G){return F[14].rating?Ai:Bi}let N=A(n),P=N(n);return{c(){e=f("tr"),t=f("td"),i=C(l),r=w(),s=f("td"),u=C(c),b=w(),d=f("td"),_=C(p),m=w(),h=f("td"),B=C(g),v=w(),x=f("td"),P.c(),S=w(),a(t,"class","px-4 py-3 text-sm text-brown-900"),a(s,"class","px-4 py-3 text-sm font-bold text-brown-900"),a(d,"class","px-4 py-3 text-sm text-brown-900"),a(h,"class","px-4 py-3 text-sm text-brown-700 truncate max-w-xs"),a(x,"class","px-4 py-3 text-sm text-brown-900"),a(e,"class","hover:bg-brown-100/60 transition-colors")},m(F,G){y(F,e,G),o(e,t),o(t,i),o(e,r),o(e,s),o(s,u),o(e,b),o(e,d),o(d,_),o(e,m),o(e,h),o(h,B),o(e,v),o(e,x),P.m(x,null),o(e,S)},p(F,G){var E,T,R;G&2&&l!==(l=Kl(F[14].created_at)+"")&&j(i,l),G&2&&c!==(c=(((E=F[14].bean)==null?void 0:E.name)||((T=F[14].bean)==null?void 0:T.origin)||"Unknown")+"")&&j(u,c),G&2&&p!==(p=(((R=F[14].brewer_obj)==null?void 0:R.name)||"-")+"")&&j(_,p),G&2&&g!==(g=(F[14].tasting_notes||"-")+"")&&j(B,g),N===(N=A(F))&&P?P.p(F,G):(P.d(1),P=N(F),P&&(P.c(),P.m(x,null)))},d(F){F&&k(e),P.d()}}}function Si(n){var c,u;let e,t,l;document.title=e=(((c=n[0])==null?void 0:c.displayName)||((u=n[0])==null?void 0:u.handle)||"Profile")+" - Arabica";function i(b,d){if(b[6])return _i;if(b[7])return pi;if(b[0])return bi}let r=i(n),s=r&&r(n);return{c(){t=w(),l=f("div"),s&&s.c(),a(l,"class","max-w-4xl mx-auto")},m(b,d){y(b,t,d),y(b,l,d),s&&s.m(l,null)},p(b,[d]){var p,_;d&1&&e!==(e=(((p=b[0])==null?void 0:p.displayName)||((_=b[0])==null?void 0:_.handle)||"Profile")+" - Arabica")&&(document.title=e),r===(r=i(b))&&s?s.p(b,d):(s&&s.d(1),s=r&&r(b),s&&(s.c(),s.m(l,null)))},i:W,o:W,d(b){b&&(k(t),k(l)),s&&s.d()}}}function Kl(n){return new Date(n).toLocaleDateString("en-US",{year:"numeric",month:"short",day:"numeric"})}function Ni(n,e,t){let{actor:l}=e,i=null,r=[],s=[],c=[],u=[],b=[],d=!1,p=!0,_=null,m="brews";vt(async()=>{try{const v=await ge.get(`/api/profile-json/${l}`);t(0,i=v.profile),t(1,r=(v.brews||[]).sort((x,S)=>new Date(S.created_at)-new Date(x.created_at))),t(2,s=v.beans||[]),t(3,c=v.roasters||[]),t(4,u=v.grinders||[]),t(5,b=v.brewers||[]),d=v.isOwnProfile||!1}catch(v){console.error("Failed to load profile:",v),t(7,_=v.message)}finally{t(6,p=!1)}});const h=()=>t(8,m="brews"),g=()=>t(8,m="beans"),B=()=>t(8,m="gear");return n.$$set=v=>{"actor"in v&&t(9,l=v.actor)},[i,r,s,c,u,b,p,_,m,l,h,g,B]}class Ti extends Xe{constructor(e){super(),Qe(this,e,Ni,Si,We,{actor:9})}}function Li(n){let e,t,l,i,r,s,c,u,b,d,p,_,m,h,g,B,v,x,S;return{c(){e=f("div"),t=f("div"),l=f("h1"),l.textContent="About Arabica",i=w(),r=f("div"),s=f("p"),s.textContent="Arabica is a coffee brew tracking application that leverages the AT Protocol for decentralized data storage.",c=w(),u=f("h2"),u.textContent="Features",b=w(),d=f("ul"),d.innerHTML='<li class="flex items-start"><span class="mr-2">🔒</span> <span><strong>Decentralized:</strong> Your data lives in your Personal Data Server (PDS)</span></li> <li class="flex items-start"><span class="mr-2">🚀</span> <span><strong>Portable:</strong> Own your coffee brewing history</span></li> <li class="flex items-start"><span class="mr-2">📊</span> <span>Track brewing variables like temperature, time, and grind size</span></li> <li class="flex items-start"><span class="mr-2">🌍</span> <span>Organize beans by origin and roaster</span></li> <li class="flex items-start"><span class="mr-2">📝</span> <span>Add tasting notes and ratings to each brew</span></li>',p=w(),_=f("h2"),_.textContent="AT Protocol",m=w(),h=f("p"),h.textContent=`The Authenticated Transfer Protocol (AT Protocol) is a decentralized social networking protocol 3 + that gives you full ownership of your data. Your brewing records are stored in your own PDS, 4 + not in Arabica's servers.`,g=w(),B=f("div"),v=f("button"),v.textContent="Get Started",a(l,"class","text-3xl font-bold text-brown-900 mb-6"),a(s,"class","text-lg text-brown-800 mb-4"),a(u,"class","text-2xl font-bold text-brown-900 mt-8 mb-4"),a(d,"class","space-y-2 text-brown-800"),a(_,"class","text-2xl font-bold text-brown-900 mt-8 mb-4"),a(h,"class","text-brown-800 mb-4"),a(v,"class","bg-gradient-to-r from-brown-700 to-brown-800 text-white px-6 py-3 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-semibold shadow-lg"),a(B,"class","mt-8"),a(r,"class","prose prose-brown max-w-none"),a(t,"class","bg-gradient-to-br from-amber-50 to-brown-100 rounded-xl p-8 border-2 border-brown-300 shadow-lg"),a(e,"class","max-w-4xl mx-auto")},m(A,N){y(A,e,N),o(e,t),o(t,l),o(t,i),o(t,r),o(r,s),o(r,c),o(r,u),o(r,b),o(r,d),o(r,p),o(r,_),o(r,m),o(r,h),o(r,g),o(r,B),o(B,v),x||(S=z(v,"click",n[0]),x=!0)},p:W,i:W,o:W,d(A){A&&k(e),x=!1,S()}}}function Oi(n){return[()=>_e("/")]}class Mi extends Xe{constructor(e){super(),Qe(this,e,Oi,Li,We,{})}}function Ei(n){let e,t,l,i,r,s,c,u,b,d,p,_,m,h,g,B,v,x,S,A,N,P,L,O,D,F,G,E,T,R;return{c(){e=f("div"),t=f("div"),l=f("h1"),l.textContent="Terms of Service",i=w(),r=f("div"),s=f("p"),s.textContent=`Last updated: ${new Date().toLocaleDateString()}`,c=w(),u=f("h2"),u.textContent="1. Acceptance of Terms",b=w(),d=f("p"),d.textContent=`By accessing and using Arabica, you accept and agree to be bound by the 5 + terms and provision of this agreement.`,p=w(),_=f("h2"),_.textContent="2. Alpha Software Notice",m=w(),h=f("p"),h.textContent=`Arabica is currently in alpha testing. Features, data structures, and 6 + functionality may change without notice. We recommend backing up your 7 + data regularly.`,g=w(),B=f("h2"),B.textContent="3. Data Storage",v=w(),x=f("p"),x.textContent=`Your brewing data is stored in your Personal Data Server (PDS) via the 8 + AT Protocol. Arabica does not store your brewing records on its servers. 9 + You are responsible for the security and backup of your PDS.`,S=w(),A=f("h2"),A.textContent="4. User Responsibilities",N=w(),P=f("p"),P.textContent=`You are responsible for maintaining the confidentiality of your account 10 + credentials and for all activities that occur under your account.`,L=w(),O=f("h2"),O.textContent="5. Limitation of Liability",D=w(),F=f("p"),F.textContent=`Arabica is provided "as is" without warranty of any kind. We are not 11 + liable for any data loss, service interruptions, or other damages 12 + arising from your use of the application.`,G=w(),E=f("h2"),E.textContent="6. Changes to Terms",T=w(),R=f("p"),R.textContent=`We reserve the right to modify these terms at any time. Continued use of 13 + Arabica after changes constitutes acceptance of the modified terms.`,a(l,"class","text-3xl font-bold text-brown-900 mb-6"),a(s,"class","text-sm text-brown-600 italic"),a(u,"class","text-2xl font-bold text-brown-900 mt-8"),a(_,"class","text-2xl font-bold text-brown-900 mt-8"),a(B,"class","text-2xl font-bold text-brown-900 mt-8"),a(A,"class","text-2xl font-bold text-brown-900 mt-8"),a(O,"class","text-2xl font-bold text-brown-900 mt-8"),a(E,"class","text-2xl font-bold text-brown-900 mt-8"),a(r,"class","prose prose-brown max-w-none text-brown-800 space-y-4"),a(t,"class","bg-white rounded-xl p-8 shadow-lg"),a(e,"class","max-w-4xl mx-auto")},m(V,X){y(V,e,X),o(e,t),o(t,l),o(t,i),o(t,r),o(r,s),o(r,c),o(r,u),o(r,b),o(r,d),o(r,p),o(r,_),o(r,m),o(r,h),o(r,g),o(r,B),o(r,v),o(r,x),o(r,S),o(r,A),o(r,N),o(r,P),o(r,L),o(r,O),o(r,D),o(r,F),o(r,G),o(r,E),o(r,T),o(r,R)},p:W,i:W,o:W,d(V){V&&k(e)}}}class Pi extends Xe{constructor(e){super(),Qe(this,e,null,Ei,We,{})}}function Di(n){let e;return{c(){e=f("div"),e.innerHTML='<div class="text-6xl mb-4">☕</div> <h1 class="text-4xl font-bold text-brown-900 mb-4">404 - Not Found</h1> <p class="text-brown-700 mb-8">The page you&#39;re looking for doesn&#39;t exist.</p> <a href="/" class="bg-gradient-to-r from-brown-700 to-brown-800 text-white px-6 py-3 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-semibold shadow-lg inline-block">Go Home</a>',a(e,"class","text-center py-12")},m(t,l){y(t,e,l)},p:W,i:W,o:W,d(t){t&&k(e)}}}class Fi extends Xe{constructor(e){super(),Qe(this,e,null,Di,We,{})}}function Jl(n){let e,t,l,i,r,s,c,u,b;function d(h,g){var B;return(B=h[2])!=null&&B.avatar?ji:Ri}let p=d(n),_=p(n),m=n[0]&&Ql(n);return{c(){e=f("div"),t=f("button"),_.c(),l=w(),i=_n("svg"),r=_n("path"),c=w(),m&&m.c(),a(r,"stroke-linecap","round"),a(r,"stroke-linejoin","round"),a(r,"stroke-width","2"),a(r,"d","M19 9l-7 7-7-7"),a(i,"class",s="w-4 h-4 transition-transform "+(n[0]?"rotate-180":"")),a(i,"fill","none"),a(i,"stroke","currentColor"),a(i,"viewBox","0 0 24 24"),a(t,"class","flex items-center gap-2 hover:opacity-80 transition focus:outline-none"),a(t,"aria-label","User menu"),a(e,"class","relative user-menu")},m(h,g){y(h,e,g),o(e,t),_.m(t,null),o(t,l),o(t,i),o(i,r),o(e,c),m&&m.m(e,null),u||(b=z(t,"click",dr(n[3])),u=!0)},p(h,g){p===(p=d(h))&&_?_.p(h,g):(_.d(1),_=p(h),_&&(_.c(),_.m(t,l))),g&1&&s!==(s="w-4 h-4 transition-transform "+(h[0]?"rotate-180":""))&&a(i,"class",s),h[0]?m?m.p(h,g):(m=Ql(h),m.c(),m.m(e,null)):m&&(m.d(1),m=null)},d(h){h&&k(e),_.d(),m&&m.d(),u=!1,b()}}}function Ri(n){var r;let e,t,l=((r=n[2])!=null&&r.displayName?n[2].displayName.charAt(0).toUpperCase():"?")+"",i;return{c(){e=f("div"),t=f("span"),i=C(l),a(t,"class","text-sm font-medium"),a(e,"class","w-8 h-8 rounded-full bg-brown-600 flex items-center justify-center ring-2 ring-brown-500")},m(s,c){y(s,e,c),o(e,t),o(t,i)},p(s,c){var u;c&4&&l!==(l=((u=s[2])!=null&&u.displayName?s[2].displayName.charAt(0).toUpperCase():"?")+"")&&j(i,l)},d(s){s&&k(e)}}}function ji(n){let e,t;return{c(){e=f("img"),gt(e.src,t=n[2].avatar)||a(e,"src",t),a(e,"alt",""),a(e,"class","w-8 h-8 rounded-full object-cover ring-2 ring-brown-600")},m(l,i){y(l,e,i)},p(l,i){i&4&&!gt(e.src,t=l[2].avatar)&&a(e,"src",t)},d(l){l&&k(e)}}}function Ql(n){var x;let e,t,l,i,r,s,c,u,b,d,p,_,m,h,g,B,v=((x=n[2])==null?void 0:x.handle)&&Xl(n);return{c(){var S,A;e=f("div"),v&&v.c(),t=w(),l=f("a"),i=C("View Profile"),s=w(),c=f("a"),c.textContent="My Brews",u=w(),b=f("a"),b.textContent="Manage Records",d=w(),p=f("a"),p.textContent="Settings (coming soon)",_=w(),m=f("div"),h=f("button"),h.textContent="Logout",a(l,"href",r="/profile/"+(((S=n[2])==null?void 0:S.handle)||((A=n[2])==null?void 0:A.did))),a(l,"class","block px-4 py-2 text-sm text-brown-700 hover:bg-brown-50 transition-colors"),a(c,"href","/brews"),a(c,"class","block px-4 py-2 text-sm text-brown-700 hover:bg-brown-50 transition-colors"),a(b,"href","/manage"),a(b,"class","block px-4 py-2 text-sm text-brown-700 hover:bg-brown-50 transition-colors"),a(p,"href","/settings"),a(p,"class","block px-4 py-2 text-sm text-brown-400 cursor-not-allowed"),a(h,"class","w-full text-left px-4 py-2 text-sm text-brown-700 hover:bg-brown-50 transition-colors"),a(m,"class","border-t border-brown-100 mt-1 pt-1"),a(e,"class","absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-brown-200 py-1 z-50 animate-fade-in svelte-1hp7v65")},m(S,A){y(S,e,A),v&&v.m(e,null),o(e,t),o(e,l),o(l,i),o(e,s),o(e,c),o(e,u),o(e,b),o(e,d),o(e,p),o(e,_),o(e,m),o(m,h),g||(B=[z(l,"click",Ue(n[9])),z(c,"click",Ue(n[10])),z(b,"click",Ue(n[11])),z(p,"click",Ue(n[12])),z(h,"click",n[13])],g=!0)},p(S,A){var N,P,L;(N=S[2])!=null&&N.handle?v?v.p(S,A):(v=Xl(S),v.c(),v.m(e,t)):v&&(v.d(1),v=null),A&4&&r!==(r="/profile/"+(((P=S[2])==null?void 0:P.handle)||((L=S[2])==null?void 0:L.did)))&&a(l,"href",r)},d(S){S&&k(e),v&&v.d(),g=!1,ce(B)}}}function Xl(n){let e,t,l=(n[2].displayName||n[2].handle)+"",i,r,s,c,u=n[2].handle+"",b;return{c(){e=f("div"),t=f("p"),i=C(l),r=w(),s=f("p"),c=C("@"),b=C(u),a(t,"class","text-sm font-medium text-brown-900 truncate"),a(s,"class","text-xs text-brown-500 truncate"),a(e,"class","px-4 py-2 border-b border-brown-100")},m(d,p){y(d,e,p),o(e,t),o(t,i),o(e,r),o(e,s),o(s,c),o(s,b)},p(d,p){p&4&&l!==(l=(d[2].displayName||d[2].handle)+"")&&j(i,l),p&4&&u!==(u=d[2].handle+"")&&j(b,u)},d(d){d&&k(e)}}}function Hi(n){let e,t,l,i,r,s,c,u,b=n[1]&&Jl(n);return{c(){e=f("nav"),t=f("div"),l=f("div"),i=f("a"),i.innerHTML='<h1 class="text-2xl font-bold">☕ Arabica</h1> <span class="text-xs bg-amber-400 text-brown-900 px-2 py-1 rounded-md font-semibold shadow-sm">ALPHA</span>',r=w(),s=f("div"),b&&b.c(),a(i,"href","/"),a(i,"class","flex items-center gap-2 hover:opacity-80 transition"),a(s,"class","flex items-center gap-4"),a(l,"class","flex items-center justify-between"),a(t,"class","container mx-auto px-4 py-4"),a(e,"class","sticky top-0 z-50 bg-gradient-to-br from-brown-800 to-brown-900 text-white shadow-xl border-b-2 border-brown-600")},m(d,p){y(d,e,p),o(e,t),o(t,l),o(l,i),o(l,r),o(l,s),b&&b.m(s,null),c||(u=[z(window,"click",n[6]),z(i,"click",Ue(n[8]))],c=!0)},p(d,[p]){d[1]?b?b.p(d,p):(b=Jl(d),b.c(),b.m(s,null)):b&&(b.d(1),b=null)},i:W,o:W,d(d){d&&k(e),b&&b.d(),c=!1,ce(u)}}}function zi(n,e,t){let l,i,r;ut(n,pt,v=>t(7,r=v));let s=!1;function c(){t(0,s=!s)}function u(){t(0,s=!1)}async function b(){await pt.logout()}function d(v){s&&!v.target.closest(".user-menu")&&u()}const p=()=>_e("/"),_=()=>{_e(`/profile/${(l==null?void 0:l.handle)||(l==null?void 0:l.did)}`),u()},m=()=>{_e("/brews"),u()},h=()=>{_e("/manage"),u()},g=()=>{_e("/settings"),u()},B=()=>{b(),u()};return n.$$.update=()=>{n.$$.dirty&128&&t(2,l=r.user),n.$$.dirty&128&&t(1,i=r.isAuthenticated)},[s,i,l,c,u,b,d,r,p,_,m,h,g,B]}class Gi extends Xe{constructor(e){super(),Qe(this,e,zi,Hi,We,{})}}function Ii(n){let e,t,l,i,r,s,c,u,b,d,p,_,m,h,g,B,v,x,S,A,N,P,L;return{c(){e=f("footer"),t=f("div"),l=f("div"),i=f("div"),i.innerHTML='<h3 class="text-lg font-bold mb-3 flex items-center gap-2"><span>☕</span> <span>Arabica</span></h3> <p class="text-sm text-brown-300">Track your coffee brewing journey with decentralized data storage powered by AT Protocol.</p>',r=w(),s=f("div"),c=f("h4"),c.textContent="Links",u=w(),b=f("ul"),d=f("li"),p=f("a"),p.textContent="About",_=w(),m=f("li"),h=f("a"),h.textContent="Terms of Service",g=w(),B=f("li"),B.innerHTML='<a href="https://github.com/arabica-social/arabica" target="_blank" rel="noopener noreferrer" class="text-brown-300 hover:text-white transition-colors">GitHub</a>',v=w(),x=f("div"),x.innerHTML='<h4 class="font-semibold mb-3">AT Protocol</h4> <p class="text-sm text-brown-300">Your data lives in your Personal Data Server (PDS), giving you full ownership and portability.</p>',S=w(),A=f("div"),N=f("p"),N.textContent=`© ${new Date().getFullYear()} Arabica Social. All rights reserved.`,a(c,"class","font-semibold mb-3"),a(p,"href","/about"),a(p,"class","text-brown-300 hover:text-white transition-colors"),a(h,"href","/terms"),a(h,"class","text-brown-300 hover:text-white transition-colors"),a(b,"class","space-y-2 text-sm"),a(l,"class","grid grid-cols-1 md:grid-cols-3 gap-8"),a(A,"class","border-t border-brown-700 mt-8 pt-6 text-center text-sm text-brown-400"),a(t,"class","container mx-auto px-4 py-8"),a(e,"class","bg-brown-800 text-brown-100 mt-12")},m(O,D){y(O,e,D),o(e,t),o(t,l),o(l,i),o(l,r),o(l,s),o(s,c),o(s,u),o(s,b),o(b,d),o(d,p),o(b,_),o(b,m),o(m,h),o(b,g),o(b,B),o(l,v),o(l,x),o(t,S),o(t,A),o(A,N),P||(L=[z(p,"click",Ue(n[0])),z(h,"click",Ue(n[1]))],P=!0)},p:W,i:W,o:W,d(O){O&&k(e),P=!1,ce(L)}}}function Ui(n){return[()=>_e("/about"),()=>_e("/terms")]}class Wi extends Xe{constructor(e){super(),Qe(this,e,Ui,Ii,We,{})}}function Zl(n){let e,t,l;const i=[n[1]];var r=n[0];function s(c,u){let b={};for(let d=0;d<i.length;d+=1)b=tn(b,i[d]);return u!==void 0&&u&2&&(b=tn(b,wn(i,[hn(c[1])]))),{props:b}}return r&&(e=mn(r,s(n))),{c(){e&&it(e.$$.fragment),t=ft()},m(c,u){e&&nt(e,c,u),y(c,t,u),l=!0},p(c,u){if(u&1&&r!==(r=c[0])){if(e){jt();const b=e;Oe(b.$$.fragment,1,0,()=>{lt(b,1)}),Ht()}r?(e=mn(r,s(c,u)),it(e.$$.fragment),ve(e.$$.fragment,1),nt(e,t.parentNode,t)):e=null}else if(r){const b=u&2?wn(i,[hn(c[1])]):{};e.$set(b)}},i(c){l||(e&&ve(e.$$.fragment,c),l=!0)},o(c){e&&Oe(e.$$.fragment,c),l=!1},d(c){c&&k(t),e&&lt(e,c)}}}function qi(n){let e,t,l,i,r,s,c;t=new Gi({});let u=n[0]&&Zl(n);return s=new Wi({}),{c(){e=f("div"),it(t.$$.fragment),l=w(),i=f("main"),u&&u.c(),r=w(),it(s.$$.fragment),a(i,"class","flex-1 container mx-auto px-4 py-8"),a(e,"class","flex flex-col min-h-screen")},m(b,d){y(b,e,d),nt(t,e,null),o(e,l),o(e,i),u&&u.m(i,null),o(e,r),nt(s,e,null),c=!0},p(b,[d]){b[0]?u?(u.p(b,d),d&1&&ve(u,1)):(u=Zl(b),u.c(),ve(u,1),u.m(i,null)):u&&(jt(),Oe(u,1,1,()=>{u=null}),Ht())},i(b){c||(ve(t.$$.fragment,b),ve(u),ve(s.$$.fragment,b),c=!0)},o(b){Oe(t.$$.fragment,b),Oe(u),Oe(s.$$.fragment,b),c=!1},d(b){b&&k(e),lt(t),u&&u.d(),lt(s)}}}function Yi(n,e,t){let l=null,i={};return vt(()=>{pt.checkAuth(),Yt.on("/",()=>{t(0,l=Qr),t(1,i={})}).on("/login",()=>{t(0,l=lo),t(1,i={})}).on("/brews",()=>{t(0,l=_o),t(1,i={})}).on("/brews/new",()=>{t(0,l=vl),t(1,i={mode:"create"})}).on("/brews/:id/edit",r=>{t(0,l=vl),t(1,i={...r,mode:"edit"})}).on("/brews/:did/:rkey",r=>{t(0,l=il),t(1,i=r)}).on("/brews/:id",r=>{t(0,l=il),t(1,i=r)}).on("/manage",()=>{t(0,l=di),t(1,i={})}).on("/profile/:actor",r=>{t(0,l=Ti),t(1,i=r)}).on("/about",()=>{t(0,l=Mi),t(1,i={})}).on("/terms",()=>{t(0,l=Pi),t(1,i={})}).on("*",()=>{t(0,l=Fi),t(1,i={})}),Yt.listen(),Yt.route(window.location.pathname)}),[l,i]}class Vi extends Xe{constructor(e){super(),Qe(this,e,Yi,qi,We,{})}}new Vi({target:document.getElementById("app")});
web/static/app/index.html static/app/index.html
web/static/arabica-org.png static/arabica-org.png
+1
static/css/output.css
··· 1 + *,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}button,input[type=button],input[type=submit]{min-height:44px;min-width:44px}@media (max-width:768px){input,select,textarea{font-size:16px}}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.right-0{right:0}.top-0{top:0}.z-10{z-index:10}.z-50{z-index:50}.m-1{margin:.25rem}.mx-4{margin-left:1rem;margin-right:1rem}.mx-auto{margin-left:auto;margin-right:auto}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.mr-2{margin-right:.5rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-12{margin-top:3rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.line-clamp-2{display:-webkit-box;overflow:hidden;-webkit-box-orient:vertical;-webkit-line-clamp:2}.block{display:block}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.h-10{height:2.5rem}.h-12{height:3rem}.h-2{height:.5rem}.h-20{height:5rem}.h-3{height:.75rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.max-h-60{max-height:15rem}.min-h-screen{min-height:100vh}.w-1\/2{width:50%}.w-1\/4{width:25%}.w-1\/6{width:16.666667%}.w-10{width:2.5rem}.w-12{width:3rem}.w-20{width:5rem}.w-3\/4{width:75%}.w-4{width:1rem}.w-48{width:12rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-full{width:100%}.min-w-0{min-width:0}.min-w-\[60px\]{min-width:60px}.min-w-full{min-width:100%}.max-w-2xl{max-width:42rem}.max-w-4xl{max-width:56rem}.max-w-6xl{max-width:72rem}.max-w-full{max-width:100%}.max-w-md{max-width:28rem}.max-w-none{max-width:none}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.flex-shrink-0{flex-shrink:0}.rotate-180{--tw-rotate:180deg}.rotate-180,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.appearance-none{-webkit-appearance:none;-moz-appearance:none;appearance:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-8{gap:2rem}.gap-x-2{-moz-column-gap:.5rem;column-gap:.5rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-y-0\.5{row-gap:.125rem}.gap-y-1{row-gap:.25rem}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.5rem*var(--tw-space-x-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1.5rem*var(--tw-space-y-reverse));margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-bottom-width:calc(1px*var(--tw-divide-y-reverse));border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)))}.divide-brown-200>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(234 221 215/var(--tw-divide-opacity,1))}.divide-brown-300>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(224 206 199/var(--tw-divide-opacity,1))}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-l-2{border-left-width:2px}.border-t{border-top-width:1px}.border-brown-100{--tw-border-opacity:1;border-color:rgb(242 232 229/var(--tw-border-opacity,1))}.border-brown-200{--tw-border-opacity:1;border-color:rgb(234 221 215/var(--tw-border-opacity,1))}.border-brown-300{--tw-border-opacity:1;border-color:rgb(224 206 199/var(--tw-border-opacity,1))}.border-brown-600{--tw-border-opacity:1;border-color:rgb(127 85 57/var(--tw-border-opacity,1))}.border-brown-700{--tw-border-opacity:1;border-color:rgb(107 68 35/var(--tw-border-opacity,1))}.border-brown-800{--tw-border-opacity:1;border-color:rgb(74 44 42/var(--tw-border-opacity,1))}.border-brown-900{--tw-border-opacity:1;border-color:rgb(61 35 25/var(--tw-border-opacity,1))}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.border-red-200{--tw-border-opacity:1;border-color:rgb(254 202 202/var(--tw-border-opacity,1))}.border-red-400{--tw-border-opacity:1;border-color:rgb(248 113 113/var(--tw-border-opacity,1))}.bg-amber-100{--tw-bg-opacity:1;background-color:rgb(254 243 199/var(--tw-bg-opacity,1))}.bg-amber-400{--tw-bg-opacity:1;background-color:rgb(251 191 36/var(--tw-bg-opacity,1))}.bg-black\/40{background-color:rgba(0,0,0,.4)}.bg-brown-200{--tw-bg-opacity:1;background-color:rgb(234 221 215/var(--tw-bg-opacity,1))}.bg-brown-200\/80{background-color:hsla(19,31%,88%,.8)}.bg-brown-300{--tw-bg-opacity:1;background-color:rgb(224 206 199/var(--tw-bg-opacity,1))}.bg-brown-50{--tw-bg-opacity:1;background-color:rgb(253 248 246/var(--tw-bg-opacity,1))}.bg-brown-50\/60{background-color:hsla(17,64%,98%,.6)}.bg-brown-600{--tw-bg-opacity:1;background-color:rgb(127 85 57/var(--tw-bg-opacity,1))}.bg-brown-700{--tw-bg-opacity:1;background-color:rgb(107 68 35/var(--tw-bg-opacity,1))}.bg-brown-800{--tw-bg-opacity:1;background-color:rgb(74 44 42/var(--tw-bg-opacity,1))}.bg-gray-200{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity,1))}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity,1))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-white\/60{background-color:hsla(0,0%,100%,.6)}.bg-gradient-to-br{background-image:linear-gradient(to bottom right,var(--tw-gradient-stops))}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.from-amber-50{--tw-gradient-from:#fffbeb var(--tw-gradient-from-position);--tw-gradient-to:rgba(255,251,235,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-brown-100{--tw-gradient-from:#f2e8e5 var(--tw-gradient-from-position);--tw-gradient-to:hsla(14,33%,92%,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-brown-50{--tw-gradient-from:#fdf8f6 var(--tw-gradient-from-position);--tw-gradient-to:hsla(17,64%,98%,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-brown-500{--tw-gradient-from:#bfa094 var(--tw-gradient-from-position);--tw-gradient-to:hsla(17,25%,66%,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-brown-700{--tw-gradient-from:#6b4423 var(--tw-gradient-from-position);--tw-gradient-to:rgba(107,68,35,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-brown-800{--tw-gradient-from:#4a2c2a var(--tw-gradient-from-position);--tw-gradient-to:rgba(74,44,42,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.to-brown-100{--tw-gradient-to:#f2e8e5 var(--tw-gradient-to-position)}.to-brown-200{--tw-gradient-to:#eaddd7 var(--tw-gradient-to-position)}.to-brown-600{--tw-gradient-to:#7f5539 var(--tw-gradient-to-position)}.to-brown-800{--tw-gradient-to:#4a2c2a var(--tw-gradient-to-position)}.to-brown-900{--tw-gradient-to:#3d2319 var(--tw-gradient-to-position)}.object-cover{-o-object-fit:cover;object-fit:cover}.p-12{padding:3rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-bottom:.125rem;padding-top:.125rem}.py-1{padding-bottom:.25rem;padding-top:.25rem}.py-12{padding-bottom:3rem;padding-top:3rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-8{padding-bottom:2rem;padding-top:2rem}.pl-3{padding-left:.75rem}.pt-1{padding-top:.25rem}.pt-6{padding-top:1.5rem}.text-left{text-align:left}.text-center{text-align:center}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-6xl{font-size:3.75rem;line-height:1}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.italic{font-style:italic}.leading-relaxed{line-height:1.625}.tracking-wider{letter-spacing:.05em}.text-amber-900{--tw-text-opacity:1;color:rgb(120 53 15/var(--tw-text-opacity,1))}.text-brown-100{--tw-text-opacity:1;color:rgb(242 232 229/var(--tw-text-opacity,1))}.text-brown-300{--tw-text-opacity:1;color:rgb(224 206 199/var(--tw-text-opacity,1))}.text-brown-400{--tw-text-opacity:1;color:rgb(210 186 176/var(--tw-text-opacity,1))}.text-brown-500{--tw-text-opacity:1;color:rgb(191 160 148/var(--tw-text-opacity,1))}.text-brown-600{--tw-text-opacity:1;color:rgb(127 85 57/var(--tw-text-opacity,1))}.text-brown-700{--tw-text-opacity:1;color:rgb(107 68 35/var(--tw-text-opacity,1))}.text-brown-800{--tw-text-opacity:1;color:rgb(74 44 42/var(--tw-text-opacity,1))}.text-brown-900{--tw-text-opacity:1;color:rgb(61 35 25/var(--tw-text-opacity,1))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.accent-brown-700{accent-color:#6b4423}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-2xl,.shadow-lg{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-md,.shadow-sm{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.ring-2{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-brown-500{--tw-ring-opacity:1;--tw-ring-color:rgb(191 160 148/var(--tw-ring-opacity,1))}.ring-brown-600{--tw-ring-opacity:1;--tw-ring-color:rgb(127 85 57/var(--tw-ring-opacity,1))}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur{--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-all{transition-duration:.15s;transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-colors{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-shadow{transition-duration:.15s;transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-transform{transition-duration:.15s;transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1)}.htmx-swapping{opacity:0;transition:opacity .3s ease-out}.hover\:bg-brown-100:hover{--tw-bg-opacity:1;background-color:rgb(242 232 229/var(--tw-bg-opacity,1))}.hover\:bg-brown-100\/60:hover{background-color:hsla(14,33%,92%,.6)}.hover\:bg-brown-300:hover{--tw-bg-opacity:1;background-color:rgb(224 206 199/var(--tw-bg-opacity,1))}.hover\:bg-brown-400:hover{--tw-bg-opacity:1;background-color:rgb(210 186 176/var(--tw-bg-opacity,1))}.hover\:bg-brown-50:hover{--tw-bg-opacity:1;background-color:rgb(253 248 246/var(--tw-bg-opacity,1))}.hover\:bg-brown-800:hover{--tw-bg-opacity:1;background-color:rgb(74 44 42/var(--tw-bg-opacity,1))}.hover\:bg-gray-300:hover{--tw-bg-opacity:1;background-color:rgb(209 213 219/var(--tw-bg-opacity,1))}.hover\:from-brown-600:hover{--tw-gradient-from:#7f5539 var(--tw-gradient-from-position);--tw-gradient-to:rgba(127,85,57,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.hover\:from-brown-800:hover{--tw-gradient-from:#4a2c2a var(--tw-gradient-from-position);--tw-gradient-to:rgba(74,44,42,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.hover\:to-brown-700:hover{--tw-gradient-to:#6b4423 var(--tw-gradient-to-position)}.hover\:to-brown-900:hover{--tw-gradient-to:#3d2319 var(--tw-gradient-to-position)}.hover\:text-brown-700:hover{--tw-text-opacity:1;color:rgb(107 68 35/var(--tw-text-opacity,1))}.hover\:text-brown-800:hover{--tw-text-opacity:1;color:rgb(74 44 42/var(--tw-text-opacity,1))}.hover\:text-brown-900:hover{--tw-text-opacity:1;color:rgb(61 35 25/var(--tw-text-opacity,1))}.hover\:text-red-800:hover{--tw-text-opacity:1;color:rgb(153 27 27/var(--tw-text-opacity,1))}.hover\:text-white:hover{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-80:hover{opacity:.8}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.hover\:shadow-lg:hover,.hover\:shadow-xl:hover{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.hover\:shadow-xl:hover{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color)}.hover\:ring-2:hover{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.hover\:ring-brown-600:hover{--tw-ring-opacity:1;--tw-ring-color:rgb(127 85 57/var(--tw-ring-opacity,1))}.focus\:border-brown-600:focus{--tw-border-opacity:1;border-color:rgb(127 85 57/var(--tw-border-opacity,1))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-2:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-brown-600:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(127 85 57/var(--tw-ring-opacity,1))}.disabled\:opacity-50:disabled{opacity:.5}@media (min-width:768px){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}}
web/static/css/style.css static/css/style.css
web/static/favicon-32.svg static/favicon-32.svg
web/static/favicon.svg static/favicon.svg
web/static/icon-192.svg static/icon-192.svg
web/static/icon-512.svg static/icon-512.svg
web/static/icon-placeholder.svg static/icon-placeholder.svg
web/static/js/sw-register.js static/js/sw-register.js
web/static/manifest.json static/manifest.json
web/static/service-worker.js static/service-worker.js
+1 -1
tailwind.config.js
··· 1 1 /** @type {import('tailwindcss').Config} */ 2 2 module.exports = { 3 - content: ["./templates/**/*.tmpl", "./web/**/*.{html,js}"], 3 + content: ["./templates/**/*.tmpl", "./static/**/*.{html,js}"], 4 4 theme: { 5 5 extend: { 6 6 colors: {
-86
templates/manage.tmpl
··· 1 - {{define "content"}} 2 - <script src="/static/js/manage-page.js?v=0.2.0"></script> 3 - 4 - <div class="max-w-6xl mx-auto" x-data="managePage()"> 5 - <div class="flex items-center gap-3 mb-6"> 6 - <button 7 - data-back-button 8 - data-fallback="/brews" 9 - class="inline-flex items-center text-brown-700 hover:text-brown-900 font-medium transition-colors cursor-pointer"> 10 - <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> 11 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path> 12 - </svg> 13 - </button> 14 - <h2 class="text-3xl font-bold text-brown-900">Manage</h2> 15 - </div> 16 - 17 - <!-- Tab Navigation --> 18 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300 mb-6"> 19 - <div class="flex border-b border-brown-300"> 20 - <button @click="tab = 'beans'" 21 - :class="tab === 'beans' ? 'bg-brown-50 text-brown-900 border-b-2 border-brown-700' : 'text-brown-700 hover:bg-brown-50'" 22 - class="flex-1 px-6 py-4 text-center font-medium transition-colors"> 23 - ☕ Beans 24 - </button> 25 - <button @click="tab = 'roasters'" 26 - :class="tab === 'roasters' ? 'bg-brown-50 text-brown-900 border-b-2 border-brown-700' : 'text-brown-700 hover:bg-brown-50'" 27 - class="flex-1 px-6 py-4 text-center font-medium transition-colors"> 28 - 🏭 Roasters 29 - </button> 30 - <button @click="tab = 'grinders'" 31 - :class="tab === 'grinders' ? 'bg-brown-50 text-brown-900 border-b-2 border-brown-700' : 'text-brown-700 hover:bg-brown-50'" 32 - class="flex-1 px-6 py-4 text-center font-medium transition-colors"> 33 - ⚙️ Grinders 34 - </button> 35 - <button @click="tab = 'brewers'" 36 - :class="tab === 'brewers' ? 'bg-brown-50 text-brown-900 border-b-2 border-brown-700' : 'text-brown-700 hover:bg-brown-50'" 37 - class="flex-1 px-6 py-4 text-center font-medium transition-colors"> 38 - 🫖 Brewers 39 - </button> 40 - </div> 41 - 42 - <!-- Tab Content --> 43 - <div class="p-6" hx-get="/api/manage" hx-trigger="load" hx-swap="innerHTML"> 44 - <!-- Loading skeleton for the active tab --> 45 - <div class="animate-pulse"> 46 - <!-- Header skeleton --> 47 - <div class="mb-4 flex justify-between items-center"> 48 - <div class="h-6 bg-brown-300 rounded w-32"></div> 49 - <div class="h-10 bg-brown-300 rounded w-28"></div> 50 - </div> 51 - 52 - <!-- Table skeleton --> 53 - <div class="overflow-x-auto"> 54 - <table class="min-w-full divide-y divide-brown-300"> 55 - <thead class="bg-brown-50"> 56 - <tr> 57 - <th class="px-4 py-3 text-left"><div class="h-3 bg-brown-300 rounded w-16"></div></th> 58 - <th class="px-4 py-3 text-left"><div class="h-3 bg-brown-300 rounded w-16"></div></th> 59 - <th class="px-4 py-3 text-left"><div class="h-3 bg-brown-300 rounded w-20"></div></th> 60 - <th class="px-4 py-3 text-left"><div class="h-3 bg-brown-300 rounded w-16"></div></th> 61 - </tr> 62 - </thead> 63 - <tbody class="bg-white divide-y divide-brown-200"> 64 - {{range iterate 4}} 65 - <tr> 66 - <td class="px-4 py-3"><div class="h-4 bg-brown-300 rounded w-24"></div></td> 67 - <td class="px-4 py-3"><div class="h-4 bg-brown-300 rounded w-20"></div></td> 68 - <td class="px-4 py-3"><div class="h-4 bg-brown-300 rounded w-28"></div></td> 69 - <td class="px-4 py-3"> 70 - <div class="flex gap-2"> 71 - <div class="h-4 bg-brown-300 rounded w-10"></div> 72 - <div class="h-4 bg-brown-300 rounded w-12"></div> 73 - </div> 74 - </td> 75 - </tr> 76 - {{end}} 77 - </tbody> 78 - </table> 79 - </div> 80 - </div> 81 - </div> 82 - </div> 83 - </div> 84 - 85 - 86 - {{end}}
-39
templates/partials/bean_form_modal.tmpl
··· 1 - {{define "bean_form_modal"}} 2 - <!-- Bean Form Modal --> 3 - <div x-cloak x-show="showBeanForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 4 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 5 - <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingBean ? 'Edit Bean' : 'Add Bean'"></h3> 6 - <div class="space-y-4"> 7 - <input type="text" x-model="beanForm.name" placeholder="Name *" 8 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 9 - <input type="text" x-model="beanForm.origin" placeholder="Origin *" 10 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 11 - <select x-model="beanForm.roaster_rkey" name="roaster_rkey_modal" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 12 - <option value="">Select Roaster (Optional)</option> 13 - {{range .Roasters}} 14 - <option value="{{.RKey}}">{{.Name}}</option> 15 - {{end}} 16 - </select> 17 - <select x-model="beanForm.roast_level" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 18 - <option value="">Select Roast Level (Optional)</option> 19 - <option value="Ultra-Light">Ultra-Light</option> 20 - <option value="Light">Light</option> 21 - <option value="Medium-Light">Medium-Light</option> 22 - <option value="Medium">Medium</option> 23 - <option value="Medium-Dark">Medium-Dark</option> 24 - <option value="Dark">Dark</option> 25 - </select> 26 - <input type="text" x-model="beanForm.process" placeholder="Process (e.g. Washed, Natural, Honey)" 27 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 28 - <textarea x-model="beanForm.description" placeholder="Description" rows="3" 29 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"></textarea> 30 - <div class="flex gap-2"> 31 - <button @click="saveBean()" 32 - class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md">Save</button> 33 - <button @click="showBeanForm = false" 34 - class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors">Cancel</button> 35 - </div> 36 - </div> 37 - </div> 38 - </div> 39 - {{end}}
-146
templates/partials/brew_list_content.tmpl
··· 1 - {{define "brew_list_content"}} 2 - {{if not .Brews}} 3 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center border border-brown-300"> 4 - {{if $.IsOwnProfile}} 5 - <p class="text-brown-800 text-lg mb-4 font-medium">No brews yet! Start tracking your coffee journey.</p> 6 - <a href="/brews/new" 7 - class="inline-block bg-gradient-to-r from-brown-700 to-brown-800 text-white py-3 px-6 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all shadow-lg hover:shadow-xl font-medium"> 8 - Add Your First Brew 9 - </a> 10 - {{else}} 11 - <p class="text-brown-800 text-lg font-medium">No brews yet.</p> 12 - {{end}} 13 - </div> 14 - {{else}} 15 - <div class="overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300"> 16 - <table class="min-w-full divide-y divide-brown-300"> 17 - <thead class="bg-brown-200/80"> 18 - <tr> 19 - <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">📅 Date</th> 20 - <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">☕ Bean</th> 21 - <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">🫖 Brewer</th> 22 - <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">🔧 Variables</th> 23 - <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">📝 Notes</th> 24 - <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">⭐ Rating</th> 25 - <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">Actions</th> 26 - </tr> 27 - </thead> 28 - <tbody class="bg-brown-50/60 divide-y divide-brown-200"> 29 - {{range .Brews}} 30 - <tr class="hover:bg-brown-100/60 transition-colors"> 31 - <!-- Date --> 32 - <td class="px-4 py-4 whitespace-nowrap text-sm text-brown-900 font-medium align-top"> 33 - <div>{{.CreatedAt.Format "Jan 2"}}</div> 34 - <div class="text-xs text-brown-600">{{.CreatedAt.Format "2006"}}</div> 35 - </td> 36 - 37 - <!-- Bean (with all details) --> 38 - <td class="px-4 py-4 text-sm text-brown-900 align-top"> 39 - {{if .Bean}} 40 - <div class="font-bold text-brown-900"> 41 - {{if .Bean.Name}}{{.Bean.Name}}{{else}}{{.Bean.Origin}}{{end}} 42 - </div> 43 - {{if and .Bean.Roaster .Bean.Roaster.Name}} 44 - <div class="text-xs text-brown-700 mt-0.5"> 45 - <span class="font-medium">{{.Bean.Roaster.Name}}</span> 46 - </div> 47 - {{end}} 48 - <div class="text-xs text-brown-600 mt-0.5 flex flex-wrap gap-x-2 gap-y-0.5"> 49 - {{if .Bean.Origin}}<span class="inline-flex items-center gap-0.5">📍 {{.Bean.Origin}}</span>{{end}} 50 - {{if .Bean.RoastLevel}}<span class="inline-flex items-center gap-0.5">🔥 {{.Bean.RoastLevel}}</span>{{end}} 51 - {{if hasValue .CoffeeAmount}}<span class="inline-flex items-center gap-0.5">⚖️ {{.CoffeeAmount}}g</span>{{end}} 52 - </div> 53 - {{else}} 54 - <span class="text-brown-400">-</span> 55 - {{end}} 56 - </td> 57 - 58 - <!-- Brewer --> 59 - <td class="px-4 py-4 text-sm text-brown-900 align-top"> 60 - {{if .BrewerObj}} 61 - <div class="font-medium text-brown-900">{{.BrewerObj.Name}}</div> 62 - {{else if .Method}} 63 - <div class="font-medium text-brown-900">{{.Method}}</div> 64 - {{else}} 65 - <span class="text-brown-400">-</span> 66 - {{end}} 67 - </td> 68 - 69 - <!-- Variables (grouped) --> 70 - <td class="px-4 py-4 text-xs text-brown-700 align-top"> 71 - <div class="space-y-1"> 72 - {{if .GrinderObj}} 73 - <div><span class="text-brown-600">Grinder:</span> {{.GrinderObj.Name}}{{if .GrindSize}} ({{.GrindSize}}){{end}}</div> 74 - {{else if .GrindSize}} 75 - <div><span class="text-brown-600">Grind:</span> {{.GrindSize}}</div> 76 - {{end}} 77 - 78 - {{if hasTemp .Temperature}} 79 - <div><span class="text-brown-600">Temp:</span> {{formatTemp .Temperature}}</div> 80 - {{end}} 81 - 82 - {{if .Pours}} 83 - <div><span class="text-brown-600">Pours:</span></div> 84 - {{range .Pours}} 85 - <div class="pl-2 text-brown-600">• {{.WaterAmount}}g @ {{formatTime .TimeSeconds}}</div> 86 - {{end}} 87 - {{else if hasValue .WaterAmount}} 88 - <div><span class="text-brown-600">Water:</span> {{.WaterAmount}}g</div> 89 - {{end}} 90 - 91 - {{if hasValue .TimeSeconds}} 92 - <div><span class="text-brown-600">Time:</span> {{formatTime .TimeSeconds}}</div> 93 - {{end}} 94 - </div> 95 - </td> 96 - 97 - <!-- Tasting Notes --> 98 - <td class="px-4 py-4 text-xs text-brown-800 align-top max-w-xs"> 99 - {{if .TastingNotes}} 100 - <div class="italic line-clamp-3">{{.TastingNotes}}</div> 101 - {{else}} 102 - <span class="text-brown-400">-</span> 103 - {{end}} 104 - </td> 105 - 106 - <!-- Rating --> 107 - <td class="px-4 py-4 whitespace-nowrap text-sm text-brown-900 align-top"> 108 - {{if hasValue .Rating}} 109 - <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-900"> 110 - ⭐ {{formatRating .Rating}} 111 - </span> 112 - {{else}} 113 - <span class="text-brown-400">-</span> 114 - {{end}} 115 - </td> 116 - 117 - <!-- Actions --> 118 - <td class="px-4 py-4 whitespace-nowrap text-sm font-medium space-x-2 align-top"> 119 - {{if $.IsOwnProfile}} 120 - <a href="/brews/{{.RKey}}" 121 - class="text-brown-700 hover:text-brown-900 font-medium">View</a> 122 - {{else if $.ProfileHandle}} 123 - <a href="/brews/{{.RKey}}?owner={{$.ProfileHandle}}" 124 - class="text-brown-700 hover:text-brown-900 font-medium">View</a> 125 - {{else}} 126 - <a href="/brews/{{.RKey}}" 127 - class="text-brown-700 hover:text-brown-900 font-medium">View</a> 128 - {{end}} 129 - {{if $.IsOwnProfile}} 130 - <a href="/brews/{{.RKey}}/edit" 131 - class="text-brown-700 hover:text-brown-900 font-medium">Edit</a> 132 - <button hx-delete="/brews/{{.RKey}}" 133 - hx-confirm="Are you sure you want to delete this brew?" hx-target="closest tr" 134 - hx-swap="outerHTML swap:1s" class="text-brown-600 hover:text-brown-800 font-medium"> 135 - Delete 136 - </button> 137 - {{end}} 138 - </td> 139 - </tr> 140 - {{end}} 141 - </tbody> 142 - </table> 143 - </div> 144 - {{end}} 145 - {{end}} 146 - {{end}}
-22
templates/partials/brewer_form_modal.tmpl
··· 1 - {{define "brewer_form_modal"}} 2 - <!-- Brewer Form Modal --> 3 - <div x-cloak x-show="showBrewerForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 4 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 5 - <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingBrewer ? 'Edit Brewer' : 'Add Brewer'"></h3> 6 - <div class="space-y-4"> 7 - <input type="text" x-model="brewerForm.name" placeholder="Name *" 8 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 9 - <input type="text" x-model="brewerForm.brewer_type" placeholder="Type (e.g., Pour-Over, Immersion, Espresso)" 10 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 11 - <textarea x-model="brewerForm.description" placeholder="Description" rows="3" 12 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"></textarea> 13 - <div class="flex gap-2"> 14 - <button @click="saveBrewer()" 15 - class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md">Save</button> 16 - <button @click="showBeanForm = false" 17 - class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors">Cancel</button> 18 - </div> 19 - </div> 20 - </div> 21 - </div> 22 - {{end}}
-1
templates/partials/cards/_placeholder.tmpl
··· 1 - {{/* Placeholder to ensure the cards directory is not empty for ParseGlob */}}
-217
templates/partials/feed.tmpl
··· 1 - {{define "feed"}} 2 - <div class="space-y-4"> 3 - {{if .FeedItems}} 4 - {{range .FeedItems}} 5 - <div class="bg-gradient-to-br from-brown-50 to-brown-100 rounded-lg shadow-md border border-brown-200 p-4 hover:shadow-lg transition-shadow"> 6 - <!-- Author row --> 7 - <div class="flex items-center gap-3 mb-3"> 8 - <a href="/profile/{{.Author.Handle}}" class="flex-shrink-0"> 9 - {{if .Author.Avatar}} 10 - {{$safeAvatar := safeAvatarURL .Author.Avatar}} 11 - {{if $safeAvatar}} 12 - <img src="{{$safeAvatar}}" alt="" class="w-10 h-10 rounded-full object-cover hover:ring-2 hover:ring-brown-600 transition" /> 13 - {{else}} 14 - <div class="w-10 h-10 rounded-full bg-brown-300 flex items-center justify-center hover:ring-2 hover:ring-brown-600 transition"> 15 - <span class="text-brown-600 text-sm">?</span> 16 - </div> 17 - {{end}} 18 - {{else}} 19 - <div class="w-10 h-10 rounded-full bg-brown-300 flex items-center justify-center hover:ring-2 hover:ring-brown-600 transition"> 20 - <span class="text-brown-600 text-sm">?</span> 21 - </div> 22 - {{end}} 23 - </a> 24 - <div class="flex-1 min-w-0"> 25 - <div class="flex items-center gap-2"> 26 - {{if .Author.DisplayName}} 27 - <a href="/profile/{{.Author.Handle}}" class="font-medium text-brown-900 truncate hover:text-brown-700 hover:underline">{{.Author.DisplayName}}</a> 28 - {{end}} 29 - <a href="/profile/{{.Author.Handle}}" class="text-brown-600 text-sm truncate hover:text-brown-700 hover:underline">@{{.Author.Handle}}</a> 30 - </div> 31 - <span class="text-brown-500 text-sm">{{.TimeAgo}}</span> 32 - </div> 33 - </div> 34 - 35 - <!-- Action header --> 36 - <div class="mb-2 text-sm text-brown-700"> 37 - {{.Action}} 38 - </div> 39 - 40 - <!-- Record content --> 41 - {{if eq .RecordType "brew"}} 42 - <!-- Brew info --> 43 - <div class="bg-white/60 backdrop-blur rounded-lg p-4 border border-brown-200"> 44 - <!-- Bean info with rating --> 45 - <div class="flex items-start justify-between gap-3 mb-3"> 46 - <div class="flex-1 min-w-0"> 47 - {{if .Brew.Bean}} 48 - <div class="font-bold text-brown-900 text-base"> 49 - {{if .Brew.Bean.Name}}{{.Brew.Bean.Name}}{{else}}{{.Brew.Bean.Origin}}{{end}} 50 - </div> 51 - {{if and .Brew.Bean.Roaster .Brew.Bean.Roaster.Name}} 52 - <div class="text-sm text-brown-700 mt-0.5"> 53 - <span class="font-medium">🏭 {{.Brew.Bean.Roaster.Name}}</span> 54 - </div> 55 - {{end}} 56 - <div class="text-xs text-brown-600 mt-1 flex flex-wrap gap-x-2 gap-y-0.5"> 57 - {{if .Brew.Bean.Origin}}<span class="inline-flex items-center gap-0.5">📍 {{.Brew.Bean.Origin}}</span>{{end}} 58 - {{if .Brew.Bean.RoastLevel}}<span class="inline-flex items-center gap-0.5">🔥 {{.Brew.Bean.RoastLevel}}</span>{{end}} 59 - {{if .Brew.Bean.Process}}<span class="inline-flex items-center gap-0.5">🌱 {{.Brew.Bean.Process}}</span>{{end}} 60 - {{if hasValue .Brew.CoffeeAmount}}<span class="inline-flex items-center gap-0.5">⚖️ {{.Brew.CoffeeAmount}}g</span>{{end}} 61 - </div> 62 - {{end}} 63 - </div> 64 - {{if hasValue .Brew.Rating}} 65 - <span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-amber-100 text-amber-900 flex-shrink-0"> 66 - ⭐ {{.Brew.Rating}}/10 67 - </span> 68 - {{end}} 69 - </div> 70 - 71 - <!-- Brewer --> 72 - {{if or .Brew.BrewerObj .Brew.Method}} 73 - <div class="mb-2"> 74 - <span class="text-xs text-brown-600">Brewer:</span> 75 - <span class="text-sm font-semibold text-brown-900"> 76 - {{if .Brew.BrewerObj}}{{.Brew.BrewerObj.Name}}{{else if .Brew.Method}}{{.Brew.Method}}{{end}} 77 - </span> 78 - </div> 79 - {{end}} 80 - 81 - <!-- Brew parameters in compact grid --> 82 - <div class="grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-brown-700"> 83 - {{if .Brew.GrinderObj}} 84 - <div> 85 - <span class="text-brown-600">Grinder:</span> {{.Brew.GrinderObj.Name}}{{if .Brew.GrindSize}} ({{.Brew.GrindSize}}){{end}} 86 - </div> 87 - {{else if .Brew.GrindSize}} 88 - <div> 89 - <span class="text-brown-600">Grind:</span> {{.Brew.GrindSize}} 90 - </div> 91 - {{end}} 92 - {{if .Brew.Pours}} 93 - <div class="col-span-2"> 94 - <span class="text-brown-600">Pours:</span> 95 - {{range .Brew.Pours}} 96 - <div class="pl-2 text-brown-600">• {{.WaterAmount}}g @ {{formatTime .TimeSeconds}}</div> 97 - {{end}} 98 - </div> 99 - {{else if hasValue .Brew.WaterAmount}} 100 - <div> 101 - <span class="text-brown-600">Water:</span> {{.Brew.WaterAmount}}g 102 - </div> 103 - {{end}} 104 - {{if hasTemp .Brew.Temperature}} 105 - <div> 106 - <span class="text-brown-600">Temp:</span> {{formatTemp .Brew.Temperature}} 107 - </div> 108 - {{end}} 109 - {{if hasValue .Brew.TimeSeconds}} 110 - <div> 111 - <span class="text-brown-600">Time:</span> {{formatTime .Brew.TimeSeconds}} 112 - </div> 113 - {{end}} 114 - </div> 115 - 116 - {{if .Brew.TastingNotes}} 117 - <div class="mt-3 text-sm text-brown-800 italic border-t border-brown-200 pt-2"> 118 - "{{.Brew.TastingNotes}}" 119 - </div> 120 - {{end}} 121 - 122 - <!-- View button --> 123 - <div class="mt-3 border-t border-brown-200 pt-3"> 124 - <a href="/brews/{{.Brew.RKey}}?owner={{.Author.Handle}}" 125 - class="inline-flex items-center text-sm font-medium text-brown-700 hover:text-brown-900 hover:underline"> 126 - View full details → 127 - </a> 128 - </div> 129 - </div> 130 - {{else if eq .RecordType "bean"}} 131 - <!-- Bean info --> 132 - <div class="bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200"> 133 - <div class="text-base mb-2"> 134 - <span class="font-bold text-brown-900"> 135 - {{if .Bean.Name}}{{.Bean.Name}}{{else}}{{.Bean.Origin}}{{end}} 136 - </span> 137 - {{if and .Bean.Roaster .Bean.Roaster.Name}} 138 - <span class="text-brown-700"> from {{.Bean.Roaster.Name}}</span> 139 - {{end}} 140 - </div> 141 - <div class="text-sm text-brown-700 space-y-1"> 142 - {{if .Bean.Origin}} 143 - <div><span class="text-brown-600">Origin:</span> {{.Bean.Origin}}</div> 144 - {{end}} 145 - {{if .Bean.RoastLevel}} 146 - <div><span class="text-brown-600">Roast:</span> {{.Bean.RoastLevel}}</div> 147 - {{end}} 148 - {{if .Bean.Process}} 149 - <div><span class="text-brown-600">Process:</span> {{.Bean.Process}}</div> 150 - {{end}} 151 - {{if .Bean.Description}} 152 - <div class="mt-2 text-brown-800 italic">"{{.Bean.Description}}"</div> 153 - {{end}} 154 - </div> 155 - </div> 156 - {{else if eq .RecordType "roaster"}} 157 - <!-- Roaster info --> 158 - <div class="bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200"> 159 - <div class="text-base mb-2"> 160 - <span class="font-bold text-brown-900">{{.Roaster.Name}}</span> 161 - </div> 162 - <div class="text-sm text-brown-700 space-y-1"> 163 - {{if .Roaster.Location}} 164 - <div><span class="text-brown-600">Location:</span> {{.Roaster.Location}}</div> 165 - {{end}} 166 - {{if .Roaster.Website}} 167 - {{$safeWebsite := safeWebsiteURL .Roaster.Website}} 168 - {{if $safeWebsite}} 169 - <div><span class="text-brown-600">Website:</span> <a href="{{$safeWebsite}}" target="_blank" rel="noopener noreferrer" class="text-brown-800 hover:underline">{{$safeWebsite}}</a></div> 170 - {{end}} 171 - {{end}} 172 - </div> 173 - </div> 174 - {{else if eq .RecordType "grinder"}} 175 - <!-- Grinder info --> 176 - <div class="bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200"> 177 - <div class="text-base mb-2"> 178 - <span class="font-bold text-brown-900">{{.Grinder.Name}}</span> 179 - </div> 180 - <div class="text-sm text-brown-700 space-y-1"> 181 - {{if .Grinder.GrinderType}} 182 - <div><span class="text-brown-600">Type:</span> {{.Grinder.GrinderType}}</div> 183 - {{end}} 184 - {{if .Grinder.BurrType}} 185 - <div><span class="text-brown-600">Burr:</span> {{.Grinder.BurrType}}</div> 186 - {{end}} 187 - {{if .Grinder.Notes}} 188 - <div class="mt-2 text-brown-800 italic">"{{.Grinder.Notes}}"</div> 189 - {{end}} 190 - </div> 191 - </div> 192 - {{else if eq .RecordType "brewer"}} 193 - <!-- Brewer info --> 194 - <div class="bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200"> 195 - <div class="text-base mb-2"> 196 - <span class="font-bold text-brown-900">{{.Brewer.Name}}</span> 197 - </div> 198 - {{if .Brewer.Description}} 199 - <div class="text-sm text-brown-800 italic">"{{.Brewer.Description}}"</div> 200 - {{end}} 201 - </div> 202 - {{end}} 203 - </div> 204 - {{end}} 205 - <!-- {{if not $.IsAuthenticated}} --> 206 - <!-- <div class="text-center text-brown-600 text-sm py-4"> --> 207 - <!-- Sign in to see more --> 208 - <!-- </div> --> 209 - <!-- {{end}} --> 210 - {{else}} 211 - <div class="bg-brown-100 rounded-lg p-6 text-center text-brown-700 border border-brown-200"> 212 - <p class="mb-2 font-medium">No activity in the feed yet.</p> 213 - <p class="text-sm">Be the first to add something!</p> 214 - </div> 215 - {{end}} 216 - </div> 217 - {{end}}
-22
templates/partials/footer.tmpl
··· 1 - {{define "footer"}} 2 - <footer class="mt-auto border-t border-brown-200 bg-brown-50"> 3 - <div class="container mx-auto px-4 py-8"> 4 - <div class="flex flex-col md:flex-row justify-between items-center space-y-4 md:space-y-0"> 5 - <div class="text-sm text-brown-700"> 6 - <span class="font-semibold text-brown-900">Arabica</span> - Your brew, your data 7 - </div> 8 - <nav class="flex flex-wrap justify-center gap-6 text-sm"> 9 - <a href="/about" class="text-brown-700 hover:text-brown-900 transition-colors">About</a> 10 - <a href="/terms" class="text-brown-700 hover:text-brown-900 transition-colors">Terms of Service</a> 11 - <a href="https://github.com/ptdewey/arabica" target="_blank" rel="noopener noreferrer" class="text-brown-700 hover:text-brown-900 transition-colors">Source Code</a> 12 - <a href="https://atproto.com" target="_blank" rel="noopener noreferrer" class="text-brown-700 hover:text-brown-900 transition-colors">AT Protocol</a> 13 - </nav> 14 - </div> 15 - <!-- 16 - <div class="mt-4 text-center text-xs text-brown-600"> 17 - Built on the AT Protocol - Your data lives in your Personal Data Server 18 - </div> 19 - --> 20 - </div> 21 - </footer> 22 - {{end}}
-31
templates/partials/grinder_form_modal.tmpl
··· 1 - {{define "grinder_form_modal"}} 2 - <!-- Grinder Form Modal --> 3 - <div x-cloak x-show="showGrinderForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 4 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 5 - <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingGrinder ? 'Edit Grinder' : 'Add Grinder'"></h3> 6 - <div class="space-y-4"> 7 - <input type="text" x-model="grinderForm.name" placeholder="Name *" 8 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 9 - <select x-model="grinderForm.grinder_type" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 10 - <option value="">Select Grinder Type *</option> 11 - <option value="Hand">Hand</option> 12 - <option value="Electric">Electric</option> 13 - <option value="Portable Electric">Portable Electric</option> 14 - </select> 15 - <select x-model="grinderForm.burr_type" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 16 - <option value="">Select Burr Type (Optional)</option> 17 - <option value="Conical">Conical</option> 18 - <option value="Flat">Flat</option> 19 - </select> 20 - <textarea x-model="grinderForm.notes" placeholder="Notes" rows="3" 21 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"></textarea> 22 - <div class="flex gap-2"> 23 - <button @click="saveGrinder()" 24 - class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md">Save</button> 25 - <button @click="showGrinderForm = false" 26 - class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors">Cancel</button> 27 - </div> 28 - </div> 29 - </div> 30 - </div> 31 - {{end}}
-73
templates/partials/header.tmpl
··· 1 - {{define "header"}} 2 - <nav class="sticky top-0 z-50 bg-gradient-to-br from-brown-800 to-brown-900 text-white shadow-xl border-b-2 border-brown-600"> 3 - <div class="container mx-auto px-4 py-4"> 4 - <div class="flex items-center justify-between"> 5 - <!-- Logo - always visible --> 6 - <a href="/" class="flex items-center gap-2 hover:opacity-80 transition"> 7 - <h1 class="text-2xl font-bold">☕ Arabica</h1> 8 - <span class="text-xs bg-amber-400 text-brown-900 px-2 py-1 rounded-md font-semibold shadow-sm">ALPHA</span> 9 - </a> 10 - 11 - <!-- Navigation links --> 12 - <div class="flex items-center gap-4"> 13 - <!-- Home link - mobile only --> 14 - <!-- <a href="/" class="md:hidden hover:text-brown-100 transition-colors font-medium">Home</a> --> 15 - 16 - {{if .IsAuthenticated}} 17 - <!-- User profile dropdown --> 18 - <div x-data="{ open: false }" class="relative"> 19 - <button @click="open = !open" @click.outside="open = false" class="flex items-center gap-2 hover:opacity-80 transition focus:outline-none"> 20 - {{if and .UserProfile .UserProfile.Avatar}} 21 - {{$safeAvatar := safeAvatarURL .UserProfile.Avatar}} 22 - {{if $safeAvatar}} 23 - <img src="{{$safeAvatar}}" alt="" class="w-8 h-8 rounded-full object-cover ring-2 ring-brown-600" /> 24 - {{else}} 25 - <div class="w-8 h-8 rounded-full bg-brown-600 flex items-center justify-center ring-2 ring-brown-500"> 26 - <span class="text-sm font-medium">{{if and .UserProfile .UserProfile.DisplayName}}{{slice .UserProfile.DisplayName 0 1}}{{else}}?{{end}}</span> 27 - </div> 28 - {{end}} 29 - {{else}} 30 - <div class="w-8 h-8 rounded-full bg-brown-600 flex items-center justify-center ring-2 ring-brown-500"> 31 - <span class="text-sm font-medium">{{if and .UserProfile .UserProfile.DisplayName}}{{slice .UserProfile.DisplayName 0 1}}{{else}}?{{end}}</span> 32 - </div> 33 - {{end}} 34 - <svg class="w-4 h-4 transition-transform" :class="{ 'rotate-180': open }" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 35 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path> 36 - </svg> 37 - </button> 38 - 39 - <!-- Dropdown menu --> 40 - <div x-show="open" x-cloak x-transition:enter="transition ease-out duration-100" x-transition:enter-start="transform opacity-0 scale-95" x-transition:enter-end="transform opacity-100 scale-100" x-transition:leave="transition ease-in duration-75" x-transition:leave-start="transform opacity-100 scale-100" x-transition:leave-end="transform opacity-0 scale-95" class="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-brown-200 py-1 z-50"> 41 - {{if and .UserProfile .UserProfile.Handle}} 42 - <div class="px-4 py-2 border-b border-brown-100"> 43 - <p class="text-sm font-medium text-brown-900 truncate">{{if .UserProfile.DisplayName}}{{.UserProfile.DisplayName}}{{else}}{{.UserProfile.Handle}}{{end}}</p> 44 - <p class="text-xs text-brown-500 truncate">@{{.UserProfile.Handle}}</p> 45 - </div> 46 - {{end}} 47 - <a href="/profile/{{if and .UserProfile .UserProfile.Handle}}{{.UserProfile.Handle}}{{else}}{{.UserDID}}{{end}}" class="block px-4 py-2 text-sm text-brown-700 hover:bg-brown-50 transition-colors"> 48 - View Profile 49 - </a> 50 - <a href="/brews" class="block px-4 py-2 text-sm text-brown-700 hover:bg-brown-50 transition-colors"> 51 - My Brews 52 - </a> 53 - <a href="/manage" class="block px-4 py-2 text-sm text-brown-700 hover:bg-brown-50 transition-colors"> 54 - Manage Records 55 - </a> 56 - <a href="#" class="block px-4 py-2 text-sm text-brown-400 cursor-not-allowed"> 57 - Settings (coming soon) 58 - </a> 59 - <div class="border-t border-brown-100 mt-1 pt-1"> 60 - <form action="/logout" method="POST" onsubmit="if(window.ArabicaCache){window.ArabicaCache.invalidateCache()}"> 61 - <button type="submit" class="w-full text-left px-4 py-2 text-sm text-brown-700 hover:bg-brown-50 transition-colors"> 62 - Logout 63 - </button> 64 - </form> 65 - </div> 66 - </div> 67 - </div> 68 - {{end}} 69 - </div> 70 - </div> 71 - </div> 72 - </nav> 73 - {{end}}
-221
templates/partials/manage_content.tmpl
··· 1 - {{define "manage_content"}} 2 - <!-- Beans Tab --> 3 - <div x-show="tab === 'beans'"> 4 - <div class="mb-4 flex justify-between items-center"> 5 - <h3 class="text-xl font-semibold text-brown-900">☕ Coffee Beans</h3> 6 - <button 7 - @click="showBeanForm = true; editingBean = null; beanForm = {name: '', origin: '', roast_level: '', process: '', description: '', roaster_rkey: ''}" 8 - class="bg-brown-700 text-white px-4 py-2 rounded-lg hover:bg-brown-800 font-medium"> 9 - + Add Bean 10 - </button> 11 - </div> 12 - 13 - {{if not .Beans}} 14 - <div class="bg-brown-100 rounded-lg p-8 text-center text-brown-700 border border-brown-200"> 15 - <p class="text-brown-600">No beans yet. Add your first bean!</p> 16 - </div> 17 - {{else}} 18 - <div class="overflow-x-auto"> 19 - <table class="min-w-full divide-y divide-brown-300"> 20 - <thead class="bg-brown-50"> 21 - <tr> 22 - <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Name</th> 23 - <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">📍 Origin</th> 24 - <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">🔥 Roast</th> 25 - <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">🏭 Roaster</th> 26 - <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Actions</th> 27 - </tr> 28 - </thead> 29 - <tbody class="bg-white divide-y divide-brown-200"> 30 - {{range .Beans}} 31 - <tr class="hover:bg-brown-50"> 32 - <td class="px-4 py-3 text-sm text-brown-900">{{if .Name}}{{.Name}}{{else}}-{{end}}</td> 33 - <td class="px-4 py-3 text-sm text-brown-900">{{.Origin}}</td> 34 - <td class="px-4 py-3 text-sm text-brown-900">{{.RoastLevel}}</td> 35 - <td class="px-4 py-3 text-sm text-brown-900"> 36 - {{if and .Roaster .Roaster.Name}} 37 - {{.Roaster.Name}} 38 - {{else}} 39 - - 40 - {{end}} 41 - </td> 42 - <td class="px-4 py-3 text-sm space-x-2"> 43 - <button @click="editBean('{{.RKey}}', '{{escapeJS .Name}}', '{{escapeJS .Origin}}', '{{.RoastLevel}}', '{{.Process}}', '{{escapeJS .Description}}', '{{.RoasterRKey}}')" 44 - class="text-brown-700 hover:text-brown-900 font-medium">Edit</button> 45 - <button @click="deleteBean('{{.RKey}}')" 46 - class="text-red-600 hover:text-red-800 font-medium">Delete</button> 47 - </td> 48 - </tr> 49 - {{end}} 50 - </tbody> 51 - </table> 52 - </div> 53 - {{end}} 54 - </div> 55 - 56 - <!-- Roasters Tab --> 57 - <div x-show="tab === 'roasters'"> 58 - <div class="mb-4 flex justify-between items-center"> 59 - <h3 class="text-xl font-semibold text-brown-900">🏭 Roasters</h3> 60 - <button 61 - @click="showRoasterForm = true; editingRoaster = null; roasterForm = {name: '', location: '', website: ''}" 62 - class="bg-brown-700 text-white px-4 py-2 rounded-lg hover:bg-brown-800 font-medium"> 63 - + Add Roaster 64 - </button> 65 - </div> 66 - 67 - {{if not .Roasters}} 68 - <div class="bg-brown-100 rounded-lg p-8 text-center text-brown-700 border border-brown-200"> 69 - <p class="text-brown-600">No roasters yet. Add your first roaster!</p> 70 - </div> 71 - {{else}} 72 - <div class="overflow-x-auto"> 73 - <table class="min-w-full divide-y divide-brown-300"> 74 - <thead class="bg-brown-50"> 75 - <tr> 76 - <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Name</th> 77 - <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">📍 Location</th> 78 - <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Actions</th> 79 - </tr> 80 - </thead> 81 - <tbody class="bg-white divide-y divide-brown-200"> 82 - {{range .Roasters}} 83 - <tr class="hover:bg-brown-50"> 84 - <td class="px-4 py-3 text-sm text-brown-900">{{.Name}}</td> 85 - <td class="px-4 py-3 text-sm text-brown-900">{{if .Location}}{{.Location}}{{else}}-{{end}}</td> 86 - <td class="px-4 py-3 text-sm space-x-2"> 87 - <button @click="editRoaster('{{.RKey}}', '{{escapeJS .Name}}', '{{escapeJS .Location}}', '{{escapeJS .Website}}')" 88 - class="text-brown-700 hover:text-brown-900 font-medium">Edit</button> 89 - <button @click="deleteRoaster('{{.RKey}}')" 90 - class="text-red-600 hover:text-red-800 font-medium">Delete</button> 91 - </td> 92 - </tr> 93 - {{end}} 94 - </tbody> 95 - </table> 96 - </div> 97 - {{end}} 98 - </div> 99 - 100 - <!-- Grinders Tab --> 101 - <div x-show="tab === 'grinders'"> 102 - <div class="mb-4 flex justify-between items-center"> 103 - <h3 class="text-xl font-semibold text-brown-900">⚙️ Grinders</h3> 104 - <button 105 - @click="showGrinderForm = true; editingGrinder = null; grinderForm = {name: '', grinder_type: '', burr_type: '', notes: ''}" 106 - class="bg-brown-700 text-white px-4 py-2 rounded-lg hover:bg-brown-800 font-medium"> 107 - + Add Grinder 108 - </button> 109 - </div> 110 - 111 - {{if not .Grinders}} 112 - <div class="bg-brown-100 rounded-lg p-8 text-center text-brown-700 border border-brown-200"> 113 - <p class="text-brown-600">No grinders yet. Add your first grinder!</p> 114 - </div> 115 - {{else}} 116 - <div class="overflow-x-auto"> 117 - <table class="min-w-full divide-y divide-brown-300"> 118 - <thead class="bg-brown-50"> 119 - <tr> 120 - <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Name</th> 121 - <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">🔧 Type</th> 122 - <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">💎 Burr Type</th> 123 - <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Actions</th> 124 - </tr> 125 - </thead> 126 - <tbody class="bg-white divide-y divide-brown-200"> 127 - {{range .Grinders}} 128 - <tr class="hover:bg-brown-50"> 129 - <td class="px-4 py-3 text-sm text-brown-900">{{.Name}}</td> 130 - <td class="px-4 py-3 text-sm text-brown-900">{{if .GrinderType}}{{.GrinderType}}{{else}}-{{end}}</td> 131 - <td class="px-4 py-3 text-sm text-brown-900">{{if .BurrType}}{{.BurrType}}{{else}}-{{end}}</td> 132 - <td class="px-4 py-3 text-sm space-x-2"> 133 - <button @click="editGrinder('{{.RKey}}', '{{escapeJS .Name}}', '{{.GrinderType}}', '{{.BurrType}}', '{{escapeJS .Notes}}')" 134 - class="text-brown-700 hover:text-brown-900 font-medium">Edit</button> 135 - <button @click="deleteGrinder('{{.RKey}}')" 136 - class="text-red-600 hover:text-red-800 font-medium">Delete</button> 137 - </td> 138 - </tr> 139 - {{end}} 140 - </tbody> 141 - </table> 142 - </div> 143 - {{end}} 144 - </div> 145 - 146 - <!-- Brewers Tab --> 147 - <div x-show="tab === 'brewers'"> 148 - <div class="mb-4 flex justify-between items-center"> 149 - <h3 class="text-xl font-semibold text-brown-900">🫖 Brewers</h3> 150 - <button @click="showBrewerForm = true; editingBrewer = null; brewerForm = {name: '', brewer_type: '', description: ''}" 151 - class="bg-brown-700 text-white px-4 py-2 rounded-lg hover:bg-brown-800 font-medium"> 152 - + Add Brewer 153 - </button> 154 - </div> 155 - 156 - {{if not .Brewers}} 157 - <div class="bg-brown-100 rounded-lg p-8 text-center text-brown-700 border border-brown-200"> 158 - <p class="text-brown-600">No brewers yet. Add your first brewer!</p> 159 - </div> 160 - {{else}} 161 - <div class="overflow-x-auto"> 162 - <table class="min-w-full divide-y divide-brown-300"> 163 - <thead class="bg-brown-50"> 164 - <tr> 165 - <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Name</th> 166 - <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">🔧 Type</th> 167 - <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Actions</th> 168 - </tr> 169 - </thead> 170 - <tbody class="bg-white divide-y divide-brown-200"> 171 - {{range .Brewers}} 172 - <tr class="hover:bg-brown-50" 173 - data-rkey="{{.RKey}}" 174 - data-name="{{escapeJS .Name}}" 175 - data-brewer-type="{{escapeJS .BrewerType}}" 176 - data-description="{{escapeJS .Description}}"> 177 - <td class="px-4 py-3 text-sm text-brown-900">{{.Name}}</td> 178 - <td class="px-4 py-3 text-sm text-brown-900"> 179 - {{if .BrewerType}}{{.BrewerType}}{{else}}-{{end}} 180 - </td> 181 - <td class="px-4 py-3 text-sm space-x-2"> 182 - <button @click="editBrewerFromRow($el.closest('tr'))" 183 - class="text-brown-700 hover:text-brown-900 font-medium">Edit</button> 184 - <button @click="deleteBrewer($el.closest('tr').dataset.rkey)" 185 - class="text-red-600 hover:text-red-800 font-medium">Delete</button> 186 - </td> 187 - </tr> 188 - {{end}} 189 - </tbody> 190 - </table> 191 - </div> 192 - {{end}} 193 - </div> 194 - 195 - {{template "bean_form_modal" .}} 196 - 197 - <!-- Roaster Form Modal --> 198 - <div x-cloak x-show="showRoasterForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 199 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 200 - <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingRoaster ? 'Edit Roaster' : 'Add Roaster'"></h3> 201 - <div class="space-y-4"> 202 - <input type="text" x-model="roasterForm.name" placeholder="Name *" 203 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 204 - <input type="text" x-model="roasterForm.location" placeholder="Location" 205 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 206 - <input type="url" x-model="roasterForm.website" placeholder="Website" 207 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 208 - <div class="flex gap-2"> 209 - <button @click="saveRoaster()" 210 - class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md">Save</button> 211 - <button @click="showRoasterForm = false" 212 - class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors">Cancel</button> 213 - </div> 214 - </div> 215 - </div> 216 - </div> 217 - 218 - {{template "grinder_form_modal" .}} 219 - 220 - {{template "brewer_form_modal" .}} 221 - {{end}}
-22
templates/partials/new_roaster_form.tmpl
··· 1 - {{define "new_roaster_form"}} 2 - <!-- Roaster Form Modal --> 3 - <div x-cloak x-show="showRoasterForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 4 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 5 - <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingRoaster ? 'Edit Roaster' : 'Add Roaster'"></h3> 6 - <div class="space-y-4"> 7 - <input type="text" x-model="roasterForm.name" placeholder="Name *" 8 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 9 - <input type="text" x-model="roasterForm.location" placeholder="Location" 10 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 11 - <input type="url" x-model="roasterForm.website" placeholder="Website" 12 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 13 - <div class="flex gap-2"> 14 - <button @click="saveRoaster()" 15 - class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md">Save</button> 16 - <button @click="showRoasterForm = false" 17 - class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors">Cancel</button> 18 - </div> 19 - </div> 20 - </div> 21 - </div> 22 - {{end}}
-220
templates/partials/profile_content.tmpl
··· 1 - {{define "profile_content"}} 2 - <!-- Stats data to be read by JavaScript --> 3 - <div id="profile-stats-data" 4 - data-brews="{{len .Brews}}" 5 - data-beans="{{len .Beans}}" 6 - data-roasters="{{len .Roasters}}" 7 - data-grinders="{{len .Grinders}}" 8 - data-brewers="{{len .Brewers}}" 9 - style="display: none;"></div> 10 - 11 - <!-- Brews Tab --> 12 - <div x-show="activeTab === 'brews'"> 13 - {{template "brew_list_content" .}} 14 - </div> 15 - 16 - <!-- Beans Tab --> 17 - <div x-show="activeTab === 'beans'" x-cloak class="space-y-6"> 18 - <!-- Coffee Beans --> 19 - {{if .Beans}} 20 - <div> 21 - <h3 class="text-lg font-semibold text-brown-900 mb-3">☕ Coffee Beans</h3> 22 - <div class="overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300"> 23 - <table class="min-w-full divide-y divide-brown-300"> 24 - <thead class="bg-brown-200/80"> 25 - <tr> 26 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap">Name</th> 27 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap">☕ Roaster</th> 28 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap">📍 Origin</th> 29 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap">🔥 Roast</th> 30 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap">🌱 Process</th> 31 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap">📝 Description</th> 32 - </tr> 33 - </thead> 34 - <tbody class="bg-brown-50/60 divide-y divide-brown-200"> 35 - {{range .Beans}} 36 - <tr class="hover:bg-brown-100/60 transition-colors"> 37 - <td class="px-6 py-4 text-sm font-bold text-brown-900"> 38 - {{if .Name}}{{.Name}}{{else}}{{.Origin}}{{end}} 39 - </td> 40 - <td class="px-6 py-4 text-sm text-brown-900"> 41 - {{if and .Roaster .Roaster.Name}} 42 - {{.Roaster.Name}} 43 - {{else}} 44 - <span class="text-brown-400">-</span> 45 - {{end}} 46 - </td> 47 - <td class="px-6 py-4 text-sm text-brown-900"> 48 - {{if .Origin}}{{.Origin}}{{else}}<span class="text-brown-400">-</span>{{end}} 49 - </td> 50 - <td class="px-6 py-4 text-sm text-brown-900"> 51 - {{if .RoastLevel}}{{.RoastLevel}}{{else}}<span class="text-brown-400">-</span>{{end}} 52 - </td> 53 - <td class="px-6 py-4 text-sm text-brown-900"> 54 - {{if .Process}}{{.Process}}{{else}}<span class="text-brown-400">-</span>{{end}} 55 - </td> 56 - <td class="px-6 py-4 text-sm text-brown-700 italic max-w-xs"> 57 - {{if .Description}}{{.Description}}{{else}}<span class="text-brown-400 not-italic">-</span>{{end}} 58 - </td> 59 - </tr> 60 - {{end}} 61 - </tbody> 62 - </table> 63 - </div> 64 - {{if $.IsOwnProfile}} 65 - <div class="mt-3 text-center"> 66 - <button @click="editBean('', '', '', '', '', '', '')" class="inline-flex items-center gap-2 bg-brown-600 text-white px-4 py-2 rounded-lg hover:bg-brown-700 transition-all shadow-md hover:shadow-lg text-sm font-medium"> 67 - <span>+</span> 68 - <span>Add New Bean</span> 69 - </button> 70 - </div> 71 - {{end}} 72 - </div> 73 - {{end}} 74 - 75 - <!-- Roasters --> 76 - {{if .Roasters}} 77 - <div> 78 - <h3 class="text-lg font-semibold text-brown-900 mb-3">🏭 Favorite Roasters</h3> 79 - <div class="overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300"> 80 - <table class="min-w-full divide-y divide-brown-300"> 81 - <thead class="bg-brown-200/80"> 82 - <tr> 83 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">Name</th> 84 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">📍 Location</th> 85 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">🌐 Website</th> 86 - </tr> 87 - </thead> 88 - <tbody class="bg-brown-50/60 divide-y divide-brown-200"> 89 - {{range .Roasters}} 90 - <tr class="hover:bg-brown-100/60 transition-colors"> 91 - <td class="px-6 py-4 text-sm font-bold text-brown-900">{{.Name}}</td> 92 - <td class="px-6 py-4 text-sm text-brown-900"> 93 - {{if .Location}}{{.Location}}{{else}}<span class="text-brown-400">-</span>{{end}} 94 - </td> 95 - <td class="px-6 py-4 text-sm text-brown-900"> 96 - {{if .Website}} 97 - {{$safeWebsite := safeWebsiteURL .Website}} 98 - {{if $safeWebsite}} 99 - <a href="{{$safeWebsite}}" target="_blank" rel="noopener noreferrer" class="text-brown-700 hover:underline font-medium">Visit Site</a> 100 - {{else}} 101 - <span class="text-brown-400">-</span> 102 - {{end}} 103 - {{else}} 104 - <span class="text-brown-400">-</span> 105 - {{end}} 106 - </td> 107 - </tr> 108 - {{end}} 109 - </tbody> 110 - </table> 111 - </div> 112 - {{if $.IsOwnProfile}} 113 - <div class="mt-3 text-center"> 114 - <button @click="editRoaster('', '', '', '')" class="inline-flex items-center gap-2 bg-brown-600 text-white px-4 py-2 rounded-lg hover:bg-brown-700 transition-all shadow-md hover:shadow-lg text-sm font-medium"> 115 - <span>+</span> 116 - <span>Add New Roaster</span> 117 - </button> 118 - </div> 119 - {{end}} 120 - </div> 121 - {{end}} 122 - 123 - {{if and (not .Beans) (not .Roasters)}} 124 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center text-brown-800 border border-brown-300"> 125 - <p class="font-medium">No beans or roasters yet.</p> 126 - </div> 127 - {{end}} 128 - </div> 129 - 130 - <!-- Gear Tab --> 131 - <div x-show="activeTab === 'gear'" x-cloak class="space-y-6"> 132 - <!-- Grinders --> 133 - {{if .Grinders}} 134 - <div> 135 - <h3 class="text-lg font-semibold text-brown-900 mb-3">⚙️ Grinders</h3> 136 - <div class="overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300"> 137 - <table class="min-w-full divide-y divide-brown-300"> 138 - <thead class="bg-brown-200/80"> 139 - <tr> 140 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">Name</th> 141 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">🔧 Type</th> 142 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">💎 Burrs</th> 143 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">📝 Notes</th> 144 - </tr> 145 - </thead> 146 - <tbody class="bg-brown-50/60 divide-y divide-brown-200"> 147 - {{range .Grinders}} 148 - <tr class="hover:bg-brown-100/60 transition-colors"> 149 - <td class="px-6 py-4 text-sm font-bold text-brown-900">{{.Name}}</td> 150 - <td class="px-6 py-4 text-sm text-brown-900"> 151 - {{if .GrinderType}}{{.GrinderType}}{{else}}<span class="text-brown-400">-</span>{{end}} 152 - </td> 153 - <td class="px-6 py-4 text-sm text-brown-900"> 154 - {{if .BurrType}}{{.BurrType}}{{else}}<span class="text-brown-400">-</span>{{end}} 155 - </td> 156 - <td class="px-6 py-4 text-sm text-brown-700 italic max-w-xs"> 157 - {{if .Notes}}{{.Notes}}{{else}}<span class="text-brown-400 not-italic">-</span>{{end}} 158 - </td> 159 - </tr> 160 - {{end}} 161 - </tbody> 162 - </table> 163 - </div> 164 - {{if $.IsOwnProfile}} 165 - <div class="mt-3 text-center"> 166 - <button @click="editGrinder('', '', '', '', '')" class="inline-flex items-center gap-2 bg-brown-600 text-white px-4 py-2 rounded-lg hover:bg-brown-700 transition-all shadow-md hover:shadow-lg text-sm font-medium"> 167 - <span>+</span> 168 - <span>Add New Grinder</span> 169 - </button> 170 - </div> 171 - {{end}} 172 - </div> 173 - {{end}} 174 - 175 - <!-- Brewers --> 176 - {{if .Brewers}} 177 - <div> 178 - <h3 class="text-lg font-semibold text-brown-900 mb-3">☕ Brewers</h3> 179 - <div class="overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300"> 180 - <table class="min-w-full divide-y divide-brown-300"> 181 - <thead class="bg-brown-200/80"> 182 - <tr> 183 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">Name</th> 184 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">🔧 Type</th> 185 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">📝 Description</th> 186 - </tr> 187 - </thead> 188 - <tbody class="bg-brown-50/60 divide-y divide-brown-200"> 189 - {{range .Brewers}} 190 - <tr class="hover:bg-brown-100/60 transition-colors"> 191 - <td class="px-6 py-4 text-sm font-bold text-brown-900">{{.Name}}</td> 192 - <td class="px-6 py-4 text-sm text-brown-900"> 193 - {{if .BrewerType}}{{.BrewerType}}{{else}}<span class="text-brown-400">-</span>{{end}} 194 - </td> 195 - <td class="px-6 py-4 text-sm text-brown-700 italic max-w-xs"> 196 - {{if .Description}}{{.Description}}{{else}}<span class="text-brown-400 not-italic">-</span>{{end}} 197 - </td> 198 - </tr> 199 - {{end}} 200 - </tbody> 201 - </table> 202 - </div> 203 - {{if $.IsOwnProfile}} 204 - <div class="mt-3 text-center"> 205 - <button @click="editBrewer('', '', '', '')" class="inline-flex items-center gap-2 bg-brown-600 text-white px-4 py-2 rounded-lg hover:bg-brown-700 transition-all shadow-md hover:shadow-lg text-sm font-medium"> 206 - <span>+</span> 207 - <span>Add New Brewer</span> 208 - </button> 209 - </div> 210 - {{end}} 211 - </div> 212 - {{end}} 213 - 214 - {{if and (not .Grinders) (not .Brewers)}} 215 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center text-brown-800 border border-brown-300"> 216 - <p class="font-medium">No gear added yet.</p> 217 - </div> 218 - {{end}} 219 - </div> 220 - {{end}}

History

1 round 0 comments
sign up or login to add to the discussion
pdewey.com submitted #0
1 commit
expand
refactor: remove old tmpl files
expand 0 comments
closed without merging