-1
AGENTS.md
-1
AGENTS.md
···
1
-
CLAUDE.md
+362
AGENTS.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
+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
+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/
+1
-1
frontend/vite.config.js
+1
-1
frontend/vite.config.js
+2
-4
go.mod
+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
-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
-7
internal/bff/__snapshots__/all_used.snap
-7
internal/bff/__snapshots__/avatar_urls.snap
-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
-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
-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
-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
-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
-7
internal/bff/__snapshots__/bean_with_only_origin.snap
-68
internal/bff/__snapshots__/bean_with_roaster.snap
-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
-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
-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
-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
-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é <script>alert('xss')</script> 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 & Washed
63
-
</td>
64
-
<td class="px-6 py-4 text-sm text-brown-700">
65
-
"Amazing" coffee with <strong>bold</strong> flavor
66
-
</td>
67
-
<td class="px-6 py-4 text-sm font-medium space-x-2">
68
-
<button @click="editBean('bean1', 'Café <script>alert(\'xss\')</script> Especial', 'Costa Rica™', 'Medium', 'Honey & Washed', '\"Amazing\" coffee with <strong>bold</strong> 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
-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
-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
-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 <strong>Bean</strong> (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"><script>alert('xss')</script>Bright & fruity, "amazing" 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
-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
-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
-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
-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
-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
-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
-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
-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
-7
internal/bff/__snapshots__/celsius_temp.snap
-7
internal/bff/__snapshots__/complete_brew.snap
-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
-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
-7
internal/bff/__snapshots__/decimal_celsius.snap
-7
internal/bff/__snapshots__/decimal_fahrenheit.snap
-7
internal/bff/__snapshots__/decimal_fahrenheit.snap
-7
internal/bff/__snapshots__/different_values.snap
-7
internal/bff/__snapshots__/different_values.snap
-383
internal/bff/__snapshots__/edit_brew_with_complete_data.snap
-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='[{"pourNumber":1,"waterAmount":50,"timeSeconds":30},{"pourNumber":2,"waterAmount":100,"timeSeconds":45},{"pourNumber":3,"waterAmount":150,"timeSeconds":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
-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
-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='[{"pourNumber":1,"waterAmount":60,"timeSeconds":30},{"pourNumber":2,"waterAmount":120,"timeSeconds":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
-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
-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
-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
-8
internal/bff/__snapshots__/empty_dict.snap
-16
internal/bff/__snapshots__/empty_feed_authenticated.snap
-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
-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
-7
internal/bff/__snapshots__/empty_pours.snap
-7
internal/bff/__snapshots__/equal_values.snap
-7
internal/bff/__snapshots__/equal_values.snap
-7
internal/bff/__snapshots__/escape_js.snap
-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
-7
internal/bff/__snapshots__/fahrenheit_temp.snap
-7
internal/bff/__snapshots__/five_iterations.snap
-7
internal/bff/__snapshots__/five_iterations.snap
-7
internal/bff/__snapshots__/full_bean_data.snap
-7
internal/bff/__snapshots__/full_bean_data.snap
-7
internal/bff/__snapshots__/full_grinder_data.snap
-7
internal/bff/__snapshots__/full_grinder_data.snap
-48
internal/bff/__snapshots__/grinder_form_renders.snap
-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
-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
-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
-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
-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
-7
internal/bff/__snapshots__/half_used.snap
-7
internal/bff/__snapshots__/large_value.snap
-7
internal/bff/__snapshots__/large_value.snap
-7
internal/bff/__snapshots__/minimal_bean_data.snap
-7
internal/bff/__snapshots__/minimal_bean_data.snap
-7
internal/bff/__snapshots__/minimal_brew.snap
-7
internal/bff/__snapshots__/minimal_brew.snap
-7
internal/bff/__snapshots__/minimal_grinder_data.snap
-7
internal/bff/__snapshots__/minimal_grinder_data.snap
-308
internal/bff/__snapshots__/mixed_feed_all_types.snap
-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
-7
internal/bff/__snapshots__/mixed_valid_and_invalid.snap
-11
internal/bff/__snapshots__/multiple_key-values.snap
-11
internal/bff/__snapshots__/multiple_key-values.snap
-7
internal/bff/__snapshots__/multiple_pours.snap
-7
internal/bff/__snapshots__/multiple_pours.snap
-7
internal/bff/__snapshots__/negative_temperature.snap
-7
internal/bff/__snapshots__/negative_temperature.snap
-7
internal/bff/__snapshots__/negative_value.snap
-7
internal/bff/__snapshots__/negative_value.snap
-16
internal/bff/__snapshots__/nested_values.snap
-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
-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
-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
-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
-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
-7
internal/bff/__snapshots__/nil_int_pointer.snap
-7
internal/bff/__snapshots__/nil_pointer.snap
-7
internal/bff/__snapshots__/nil_pointer.snap
-7
internal/bff/__snapshots__/nil_pours.snap
-7
internal/bff/__snapshots__/nil_pours.snap
-7
internal/bff/__snapshots__/nil_string_pointer.snap
-7
internal/bff/__snapshots__/nil_string_pointer.snap
-7
internal/bff/__snapshots__/non-string_key.snap
-7
internal/bff/__snapshots__/non-string_key.snap
-7
internal/bff/__snapshots__/none_used.snap
-7
internal/bff/__snapshots__/none_used.snap
-7
internal/bff/__snapshots__/odd_number_of_arguments.snap
-7
internal/bff/__snapshots__/odd_number_of_arguments.snap
-7
internal/bff/__snapshots__/pointer_to_empty_vs_empty.snap
-7
internal/bff/__snapshots__/pointer_to_empty_vs_empty.snap
-7
internal/bff/__snapshots__/positive_temperature.snap
-7
internal/bff/__snapshots__/positive_temperature.snap
-7
internal/bff/__snapshots__/positive_value.snap
-7
internal/bff/__snapshots__/positive_value.snap
-68
internal/bff/__snapshots__/profile_roaster_with_invalid_url_protocol.snap
-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
-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
-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
-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
-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
-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 <html> & "quotes"
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 & 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 'single' and "double" 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 & 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 <script>alert('xss')</script>
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
-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
-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
-7
internal/bff/__snapshots__/rating_3_out_of_10.snap
-7
internal/bff/__snapshots__/rating_7_out_of_10.snap
-7
internal/bff/__snapshots__/rating_7_out_of_10.snap
-7
internal/bff/__snapshots__/rating_formatting.snap
-7
internal/bff/__snapshots__/rating_formatting.snap
-29
internal/bff/__snapshots__/roaster_form_renders.snap
-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
-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
-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
-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
-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
-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(\'xss\')')"
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
-7
internal/bff/__snapshots__/single_iteration.snap
-9
internal/bff/__snapshots__/single_key-value.snap
-9
internal/bff/__snapshots__/single_key-value.snap
-7
internal/bff/__snapshots__/single_pour.snap
-7
internal/bff/__snapshots__/single_pour.snap
-7
internal/bff/__snapshots__/small_positive.snap
-7
internal/bff/__snapshots__/small_positive.snap
-58
internal/bff/__snapshots__/special_characters_in_content.snap
-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 & 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 & 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 "quotes" and <html>tags</html> and 'single quotes'"
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
-7
internal/bff/__snapshots__/temperature_formatting.snap
-7
internal/bff/__snapshots__/ten_iterations.snap
-7
internal/bff/__snapshots__/ten_iterations.snap
-7
internal/bff/__snapshots__/time_formatting.snap
-7
internal/bff/__snapshots__/time_formatting.snap
-7
internal/bff/__snapshots__/valid_int_pointer.snap
-7
internal/bff/__snapshots__/valid_int_pointer.snap
-7
internal/bff/__snapshots__/valid_string_pointer.snap
-7
internal/bff/__snapshots__/valid_string_pointer.snap
-7
internal/bff/__snapshots__/website_urls.snap
-7
internal/bff/__snapshots__/website_urls.snap
-8
internal/bff/__snapshots__/zero_count.snap
-8
internal/bff/__snapshots__/zero_count.snap
-7
internal/bff/__snapshots__/zero_temp.snap
-7
internal/bff/__snapshots__/zero_temp.snap
-7
internal/bff/__snapshots__/zero_temperature.snap
-7
internal/bff/__snapshots__/zero_temperature.snap
-7
internal/bff/__snapshots__/zero_value.snap
-7
internal/bff/__snapshots__/zero_value.snap
-496
internal/bff/feed_template_snapshot_test.go
-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
-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
-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
-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
-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
-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
-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
-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
-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
+10
internal/handlers/__snapshots__/feed_api.snap
+425
internal/handlers/api_snapshot_test.go
+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
+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
-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
+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
+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
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
+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're looking for doesn'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&<(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/app/index.html
static/app/index.html
web/static/arabica-org.png
static/arabica-org.png
web/static/arabica-org.png
static/arabica-org.png
+1
static/css/output.css
+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/css/style.css
static/css/style.css
web/static/favicon-32.svg
static/favicon-32.svg
web/static/favicon-32.svg
static/favicon-32.svg
web/static/favicon.svg
static/favicon.svg
web/static/favicon.svg
static/favicon.svg
web/static/icon-192.svg
static/icon-192.svg
web/static/icon-192.svg
static/icon-192.svg
web/static/icon-512.svg
static/icon-512.svg
web/static/icon-512.svg
static/icon-512.svg
web/static/icon-placeholder.svg
static/icon-placeholder.svg
web/static/icon-placeholder.svg
static/icon-placeholder.svg
web/static/js/sw-register.js
static/js/sw-register.js
web/static/js/sw-register.js
static/js/sw-register.js
web/static/manifest.json
static/manifest.json
web/static/manifest.json
static/manifest.json
web/static/service-worker.js
static/service-worker.js
web/static/service-worker.js
static/service-worker.js
+1
-1
tailwind.config.js
+1
-1
tailwind.config.js
-86
templates/manage.tmpl
-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
-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
-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
-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
templates/partials/cards/_placeholder.tmpl
···
1
-
{{/* Placeholder to ensure the cards directory is not empty for ParseGlob */}}
-217
templates/partials/feed.tmpl
-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}}
-31
templates/partials/grinder_form_modal.tmpl
-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
-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
-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
-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
-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
pdewey.com
submitted
#0
1 commit
expand
collapse
refactor: remove old tmpl files
expand 0 comments
closed without merging