+1745
-4526
Diff
round #0
+1
-1
.gitignore
+1
-1
.gitignore
+20
-11
BACKLOG.md
+20
-11
BACKLOG.md
···
17
17
- Dev mode -- show did, copy did in profiles (remove "logged in as <did>" from home page)
18
18
- Toggle for table view vs future post-style view
19
19
- Toggle for "for" and "at" in pours view
20
+
- Pull bsky account management stuff in? (i.e. email verification, change password, enable two factor?)
21
+
22
+
- "Close bag" of coffee
23
+
- Remove it from the beans dropdown when adding a new brew
24
+
- Add a "closed"/"archived" field to the lexicon
25
+
- Maybe allow adding a rating?
26
+
- Question: Should it show up in the profile screen? (maybe change header to current beans? --
27
+
have a different list at bottom of previous beans -- show created date, maybe closed date?)
28
+
- should be below the brewers table
20
29
21
30
## Far Future Considerations
22
31
23
-
- Consider fully separating API backend from frontend service
24
-
- Currently using HTMX header checks to prevent direct browser access to internal API endpoints
25
-
- If adding mobile apps, third-party API consumers, or microservices architecture, revisit this
26
-
- For now, monolithic approach is appropriate for HTMX-based web app with decentralized storage
32
+
- Pivot to full svelte-kit?
27
33
28
34
- Maybe swap from boltdb to sqlite
29
35
- Use the non-cgo library
30
36
31
37
## Fixes
32
38
33
-
- Homepage still shows cached feed items on homepage when not authed. should show a cached version of firehose (last 5 entries, cache last 20) from the server.
34
-
This fetch should not try to backfill anything
39
+
- Migrate terms and about page text. Add links to about at top of non-authed home page
40
+
41
+
- Backfill on startup should be cache invalidated if time since last backfill exceeds some amount (set in code/env var maybe?)
35
42
36
-
- Feed database in prod seems to be showing outdated data -- not sure why, local dev seems to show most recent.
43
+
- Fix always using celcius for units, use settings (future state) or infer from number (maybe also future state)
37
44
38
-
- View button for somebody else's brew leads to an invalid page. need to show the same view brew page but w/o the edit and delete buttons.
39
-
- Back button in view should take user back to their previous page (not sure how to handle this exactly though)
45
+
- Make rating color nicer, but on white background for selector on new/edit brew page
40
46
41
-
- Header should probably always be attached to the top of the screen?
47
+
- Refactor: remove the `SECURE_COOKIES` env var, it should be unecessary
48
+
- For dev, we should know its running in dev mode by checking the root url env var I think?
49
+
- This just adds noise and feels like an antipattern
42
50
43
-
- Feed item "view details" button should go away, the "new brew" in "addded a new brew" should take to view page instead (underline this text)
51
+
- Fix styling of manage records page to use rounded tables like everything else
52
+
- Should also use tab selectors the same way as the profile uses
-215
MIGRATION.md
-215
MIGRATION.md
···
1
-
# Alpine.js → Svelte Migration Complete! 🎉
2
-
3
-
## What Changed
4
-
5
-
The entire frontend has been migrated from Alpine.js + HTMX + Go templates to a **Svelte SPA**.
6
-
7
-
### Before
8
-
- **Frontend**: Go HTML templates + Alpine.js + HTMX
9
-
- **State**: Alpine global components + DOM manipulation
10
-
- **Routing**: Server-side (Go mux)
11
-
- **Data**: Mixed (HTMX partials + JSON API)
12
-
13
-
### After
14
-
- **Frontend**: Svelte SPA (single-page application)
15
-
- **State**: Svelte stores (reactive)
16
-
- **Routing**: Client-side (navaid)
17
-
- **Data**: JSON API only
18
-
19
-
## Architecture
20
-
21
-
```
22
-
/
23
-
├── cmd/arabica-server/main.go # Go backend entry point
24
-
├── internal/ # Go backend (unchanged)
25
-
│ ├── handlers/
26
-
│ │ ├── handlers.go # Added /api/me and /api/feed-json
27
-
│ │ └── ...
28
-
│ └── routing/
29
-
│ └── routing.go # Added SPA fallback route
30
-
├── frontend/ # NEW: Svelte app
31
-
│ ├── src/
32
-
│ │ ├── App.svelte # Root component with router
33
-
│ │ ├── main.js # Entry point
34
-
│ │ ├── routes/ # Page components
35
-
│ │ │ ├── Home.svelte
36
-
│ │ │ ├── Login.svelte
37
-
│ │ │ ├── Brews.svelte
38
-
│ │ │ ├── BrewView.svelte
39
-
│ │ │ ├── BrewForm.svelte
40
-
│ │ │ ├── Manage.svelte
41
-
│ │ │ ├── Profile.svelte
42
-
│ │ │ ├── About.svelte
43
-
│ │ │ ├── Terms.svelte
44
-
│ │ │ └── NotFound.svelte
45
-
│ │ ├── components/ # Reusable components
46
-
│ │ │ ├── Header.svelte
47
-
│ │ │ ├── Footer.svelte
48
-
│ │ │ ├── FeedCard.svelte
49
-
│ │ │ └── Modal.svelte
50
-
│ │ ├── stores/ # Svelte stores
51
-
│ │ │ ├── auth.js # Authentication state
52
-
│ │ │ ├── cache.js # Data cache (replaces data-cache.js)
53
-
│ │ │ └── ui.js # UI state (notifications, etc.)
54
-
│ │ └── lib/
55
-
│ │ ├── api.js # Fetch wrapper
56
-
│ │ └── router.js # Client-side routing
57
-
│ ├── index.html
58
-
│ ├── vite.config.js
59
-
│ └── package.json
60
-
└── static/app/ # Built Svelte output (served by Go)
61
-
```
62
-
63
-
## Development
64
-
65
-
### Run Frontend Dev Server (with hot reload)
66
-
67
-
```bash
68
-
cd frontend
69
-
npm install
70
-
npm run dev
71
-
```
72
-
73
-
Frontend runs on http://localhost:5173 with Vite proxy to Go backend
74
-
75
-
### Run Go Backend
76
-
77
-
```bash
78
-
go run cmd/arabica-server/main.go
79
-
```
80
-
81
-
Backend runs on http://localhost:18910
82
-
83
-
### Build for Production
84
-
85
-
```bash
86
-
cd frontend
87
-
npm run build
88
-
```
89
-
90
-
This builds the Svelte app into `static/app/`
91
-
92
-
Then run the Go server normally:
93
-
94
-
```bash
95
-
go run cmd/arabica-server/main.go
96
-
```
97
-
98
-
The Go server will serve the built Svelte SPA from `static/app/`
99
-
100
-
## Key Features Implemented
101
-
102
-
### ✅ Authentication
103
-
- Login with AT Protocol handle
104
-
- Handle autocomplete
105
-
- User profile dropdown
106
-
- Persistent sessions
107
-
108
-
### ✅ Brews
109
-
- List all brews
110
-
- View brew details
111
-
- Create new brew
112
-
- Edit brew
113
-
- Delete brew
114
-
- Dynamic pours list
115
-
- Rating slider
116
-
117
-
### ✅ Equipment Management
118
-
- Tabs for beans, roasters, grinders, brewers
119
-
- CRUD operations for all entity types
120
-
- Inline entity creation from brew form
121
-
- Tab state persisted to localStorage
122
-
123
-
### ✅ Social Feed
124
-
- Community feed on homepage
125
-
- Feed items with author info
126
-
- Real-time updates (via API polling)
127
-
128
-
### ✅ Data Caching
129
-
- Stale-while-revalidate pattern
130
-
- localStorage persistence
131
-
- Automatic invalidation on writes
132
-
133
-
## API Changes
134
-
135
-
### New Endpoints
136
-
137
-
- `GET /api/me` - Current user info
138
-
- `GET /api/feed-json` - Feed items as JSON
139
-
140
-
### Existing Endpoints (unchanged)
141
-
142
-
- `GET /api/data` - All user data
143
-
- `POST /api/beans`, `PUT /api/beans/{id}`, `DELETE /api/beans/{id}`
144
-
- `POST /api/roasters`, `PUT /api/roasters/{id}`, `DELETE /api/roasters/{id}`
145
-
- `POST /api/grinders`, `PUT /api/grinders/{id}`, `DELETE /api/grinders/{id}`
146
-
- `POST /api/brewers`, `PUT /api/brewers/{id}`, `DELETE /api/brewers/{id}`
147
-
- `POST /brews`, `PUT /brews/{id}`, `DELETE /brews/{id}`
148
-
149
-
### Deprecated Endpoints (HTML partials, no longer needed)
150
-
151
-
- `GET /api/feed` (HTML)
152
-
- `GET /api/brews` (HTML)
153
-
- `GET /api/manage` (HTML)
154
-
- `GET /api/profile/{actor}` (HTML)
155
-
156
-
## Files to Delete (Future Cleanup)
157
-
158
-
These can be removed once you're confident the migration is complete:
159
-
160
-
```bash
161
-
# Old Alpine.js JavaScript
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
-
168
-
# Go templates (entire directory)
169
-
templates/
170
-
171
-
# Template rendering helpers
172
-
internal/bff/
173
-
```
174
-
175
-
## Testing Checklist
176
-
177
-
- [ ] Login with AT Protocol handle
178
-
- [ ] View homepage with feed
179
-
- [ ] Create new brew with dynamic pours
180
-
- [ ] Edit existing brew
181
-
- [ ] Delete brew
182
-
- [ ] Manage beans/roasters/grinders/brewers
183
-
- [ ] Tab navigation with localStorage persistence
184
-
- [ ] Inline entity creation from brew form
185
-
- [ ] Navigate between pages (client-side routing)
186
-
- [ ] Logout
187
-
188
-
## Browser Support
189
-
190
-
- Chrome/Edge (latest)
191
-
- Firefox (latest)
192
-
- Safari (latest)
193
-
194
-
## Performance
195
-
196
-
The Svelte bundle is **~136KB** (before gzip, ~35KB gzipped), which is excellent for a full-featured SPA.
197
-
198
-
Compared to Alpine.js (+ individual page scripts):
199
-
- **Before**: ~50KB Alpine + ~20KB per page = 70-90KB
200
-
- **After**: ~35KB gzipped for entire app
201
-
202
-
## Next Steps
203
-
204
-
1. Test thoroughly in development
205
-
2. Deploy to production
206
-
3. Monitor for any issues
207
-
4. Delete old template files once confident
208
-
5. Update documentation
209
-
210
-
## Notes
211
-
212
-
- OAuth flow still handled by Go backend
213
-
- Sessions stored in BoltDB (unchanged)
214
-
- User data stored in PDS via AT Protocol (unchanged)
215
-
- All existing Go handlers remain functional
+3
-6
README.md
+3
-6
README.md
···
39
39
40
40
## Docker
41
41
42
-
```bash
43
-
# Build and run with Docker Compose
44
-
docker compose up -d
42
+
Build and run with Docker Compose:
45
43
46
-
# Or build and run manually
47
-
docker build -t arabica .
48
-
docker run -p 18910:18910 -v arabica-data:/data arabica
44
+
```bash
45
+
docker compose -f deploy/compose.yml up -d
49
46
```
50
47
51
48
For production deployments, configure environment variables in `docker-compose.yml`:
Caddyfile
deploy/Caddyfile
Caddyfile
deploy/Caddyfile
Dockerfile
deploy/Dockerfile
Dockerfile
deploy/Dockerfile
compose.yml
deploy/compose.yml
compose.yml
deploy/compose.yml
-641
docs/firehose-plan.md
-641
docs/firehose-plan.md
···
1
-
# Firehose Integration Plan for Arabica
2
-
3
-
## Executive Summary
4
-
5
-
This document proposes refactoring Arabica's home page feed to consume events from the AT Protocol firehose via Jetstream, replacing the current polling-based approach. This will provide real-time updates, dramatically reduce API calls, and improve scalability.
6
-
7
-
**Recommendation:** Implement Jetstream consumer with local BoltDB index as Phase 1, with optional Slingshot/Constellation integration in Phase 2.
8
-
9
-
---
10
-
11
-
## Problem Statement
12
-
13
-
### Current Architecture
14
-
15
-
The feed service (`internal/feed/service.go`) polls each registered user's PDS directly:
16
-
17
-
```
18
-
For N registered users:
19
-
- N profile fetches
20
-
- N × 5 collection fetches (brew, bean, roaster, grinder, brewer)
21
-
- N × 4 reference resolution fetches
22
-
- Total: ~10N API calls per refresh
23
-
```
24
-
25
-
### Issues
26
-
27
-
| Problem | Impact |
28
-
| ------------------------ | ----------------------------------- |
29
-
| High API call volume | Risk of rate limiting as users grow |
30
-
| 5-minute cache staleness | Users don't see recent activity |
31
-
| N+1 query pattern | Linear scaling, O(N) per refresh |
32
-
| PDS dependency | Feed fails if any PDS is slow/down |
33
-
| No real-time updates | Requires manual refresh |
34
-
35
-
---
36
-
37
-
## Proposed Solution: Jetstream Consumer
38
-
39
-
### Architecture Overview
40
-
41
-
```
42
-
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
43
-
│ AT Protocol │ │ Jetstream │ │ Arabica │
44
-
│ Firehose │────▶│ (Public/Self) │────▶│ Consumer │
45
-
│ (all records) │ │ JSON over WS │ │ (background) │
46
-
└─────────────────┘ └──────────────────┘ └────────┬────────┘
47
-
│
48
-
▼
49
-
┌─────────────────┐
50
-
│ Feed Index │
51
-
│ (BoltDB) │
52
-
└────────┬────────┘
53
-
│
54
-
▼
55
-
┌─────────────────┐
56
-
│ HTTP Handler │
57
-
│ (instant) │
58
-
└─────────────────┘
59
-
```
60
-
61
-
### How It Works
62
-
63
-
1. **Background Consumer** connects to Jetstream WebSocket
64
-
2. **Filters** for `social.arabica.alpha.*` collections
65
-
3. **Indexes** incoming events into local BoltDB
66
-
4. **Serves** feed requests instantly from local index
67
-
5. **Fallback** to direct polling if consumer disconnects
68
-
69
-
### Benefits
70
-
71
-
| Metric | Current | With Jetstream |
72
-
| --------------------- | ---------------- | ----------------- |
73
-
| API calls per refresh | ~10N | 0 |
74
-
| Feed latency | 5 min cache | Real-time (<1s) |
75
-
| PDS dependency | High | None (after sync) |
76
-
| User discovery | Manual registry | Automatic |
77
-
| Scalability | O(N) per request | O(1) per request |
78
-
79
-
---
80
-
81
-
## Technical Design
82
-
83
-
### 1. Jetstream Client Configuration
84
-
85
-
```go
86
-
// internal/firehose/config.go
87
-
88
-
type JetstreamConfig struct {
89
-
// Public endpoints (fallback rotation)
90
-
Endpoints []string
91
-
92
-
// Filter to Arabica collections only
93
-
WantedCollections []string
94
-
95
-
// Optional: filter to registered DIDs only
96
-
// Leave empty to discover all Arabica users
97
-
WantedDids []string
98
-
99
-
// Enable zstd compression (~56% bandwidth reduction)
100
-
Compress bool
101
-
102
-
// Cursor file path for restart recovery
103
-
CursorFile string
104
-
}
105
-
106
-
func DefaultConfig() *JetstreamConfig {
107
-
return &JetstreamConfig{
108
-
Endpoints: []string{
109
-
"wss://jetstream1.us-east.bsky.network/subscribe",
110
-
"wss://jetstream2.us-east.bsky.network/subscribe",
111
-
"wss://jetstream1.us-west.bsky.network/subscribe",
112
-
"wss://jetstream2.us-west.bsky.network/subscribe",
113
-
},
114
-
WantedCollections: []string{
115
-
"social.arabica.alpha.brew",
116
-
"social.arabica.alpha.bean",
117
-
"social.arabica.alpha.roaster",
118
-
"social.arabica.alpha.grinder",
119
-
"social.arabica.alpha.brewer",
120
-
},
121
-
Compress: true,
122
-
CursorFile: "jetstream-cursor.txt",
123
-
}
124
-
}
125
-
```
126
-
127
-
### 2. Event Processing
128
-
129
-
```go
130
-
// internal/firehose/consumer.go
131
-
132
-
type Consumer struct {
133
-
config *JetstreamConfig
134
-
index *FeedIndex
135
-
client *jetstream.Client
136
-
cursor atomic.Int64
137
-
connected atomic.Bool
138
-
}
139
-
140
-
func (c *Consumer) handleEvent(ctx context.Context, event *models.Event) error {
141
-
if event.Kind != "commit" || event.Commit == nil {
142
-
return nil
143
-
}
144
-
145
-
commit := event.Commit
146
-
147
-
// Only process Arabica collections
148
-
if !strings.HasPrefix(commit.Collection, "social.arabica.alpha.") {
149
-
return nil
150
-
}
151
-
152
-
switch commit.Operation {
153
-
case "create", "update":
154
-
return c.index.UpsertRecord(ctx, event.Did, commit)
155
-
case "delete":
156
-
return c.index.DeleteRecord(ctx, event.Did, commit.Collection, commit.RKey)
157
-
}
158
-
159
-
// Update cursor for recovery
160
-
c.cursor.Store(event.TimeUS)
161
-
162
-
return nil
163
-
}
164
-
```
165
-
166
-
### 3. Feed Index Schema (BoltDB)
167
-
168
-
```go
169
-
// internal/firehose/index.go
170
-
171
-
// BoltDB Buckets:
172
-
// - "records" : {at-uri} -> {record JSON + metadata}
173
-
// - "by_time" : {timestamp:at-uri} -> {} (for chronological queries)
174
-
// - "by_did" : {did:at-uri} -> {} (for user-specific queries)
175
-
// - "by_type" : {collection:timestamp:at-uri} -> {} (for type filtering)
176
-
// - "profiles" : {did} -> {profile JSON} (cached profiles)
177
-
// - "cursor" : "jetstream" -> {cursor value}
178
-
179
-
type FeedIndex struct {
180
-
db *bbolt.DB
181
-
}
182
-
183
-
type IndexedRecord struct {
184
-
URI string `json:"uri"`
185
-
DID string `json:"did"`
186
-
Collection string `json:"collection"`
187
-
RKey string `json:"rkey"`
188
-
Record json.RawMessage `json:"record"`
189
-
CID string `json:"cid"`
190
-
IndexedAt time.Time `json:"indexed_at"`
191
-
}
192
-
193
-
func (idx *FeedIndex) GetRecentFeed(ctx context.Context, limit int) ([]*FeedItem, error) {
194
-
// Query by_time bucket in reverse order
195
-
// Hydrate with profile data from profiles bucket
196
-
// Return feed items instantly from local data
197
-
}
198
-
```
199
-
200
-
### 4. Profile Resolution
201
-
202
-
Profiles are not part of Arabica's lexicons, so we need a strategy:
203
-
204
-
**Option A: Lazy Loading (Recommended for Phase 1)**
205
-
206
-
```go
207
-
func (idx *FeedIndex) resolveProfile(ctx context.Context, did string) (*Profile, error) {
208
-
// Check local cache first
209
-
if profile := idx.getCachedProfile(did); profile != nil {
210
-
return profile, nil
211
-
}
212
-
213
-
// Fetch from public API and cache
214
-
profile, err := publicClient.GetProfile(ctx, did)
215
-
if err != nil {
216
-
return nil, err
217
-
}
218
-
219
-
idx.cacheProfile(did, profile, 1*time.Hour)
220
-
return profile, nil
221
-
}
222
-
```
223
-
224
-
**Option B: Slingshot Integration (Phase 2)**
225
-
226
-
```go
227
-
// Use Slingshot's resolveMiniDoc for faster profile resolution
228
-
func (idx *FeedIndex) resolveProfileViaSlingshot(ctx context.Context, did string) (*Profile, error) {
229
-
url := fmt.Sprintf("https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=%s", did)
230
-
// Returns {did, handle, pds} in one call
231
-
}
232
-
```
233
-
234
-
### 5. Reference Resolution
235
-
236
-
Brews reference beans, grinders, and brewers. The index already has these records:
237
-
238
-
```go
239
-
func (idx *FeedIndex) resolveBrew(ctx context.Context, brew *IndexedRecord) (*FeedItem, error) {
240
-
var record map[string]interface{}
241
-
json.Unmarshal(brew.Record, &record)
242
-
243
-
item := &FeedItem{RecordType: "brew"}
244
-
245
-
// Resolve bean reference from local index
246
-
if beanRef, ok := record["beanRef"].(string); ok {
247
-
if bean := idx.getRecord(beanRef); bean != nil {
248
-
item.Bean = recordToBean(bean)
249
-
}
250
-
}
251
-
252
-
// Similar for grinder, brewer references
253
-
// All from local index - no API calls
254
-
255
-
return item, nil
256
-
}
257
-
```
258
-
259
-
### 6. Fallback and Resilience
260
-
261
-
```go
262
-
// internal/firehose/consumer.go
263
-
264
-
func (c *Consumer) Run(ctx context.Context) error {
265
-
for {
266
-
select {
267
-
case <-ctx.Done():
268
-
return ctx.Err()
269
-
default:
270
-
if err := c.connectAndConsume(ctx); err != nil {
271
-
log.Warn().Err(err).Msg("jetstream connection lost, reconnecting...")
272
-
273
-
// Exponential backoff
274
-
time.Sleep(c.backoff.NextBackOff())
275
-
276
-
// Rotate to next endpoint
277
-
c.rotateEndpoint()
278
-
continue
279
-
}
280
-
}
281
-
}
282
-
}
283
-
284
-
func (c *Consumer) connectAndConsume(ctx context.Context) error {
285
-
cursor := c.loadCursor()
286
-
287
-
// Rewind cursor slightly to handle duplicates safely
288
-
if cursor > 0 {
289
-
cursor -= 5 * time.Second.Microseconds()
290
-
}
291
-
292
-
return c.client.ConnectAndRead(ctx, &cursor)
293
-
}
294
-
```
295
-
296
-
### 7. Feed Service Integration
297
-
298
-
```go
299
-
// internal/feed/service.go (modified)
300
-
301
-
type Service struct {
302
-
registry *Registry
303
-
publicClient *atproto.PublicClient
304
-
cache *publicFeedCache
305
-
306
-
// New: firehose index
307
-
firehoseIndex *firehose.FeedIndex
308
-
useFirehose bool
309
-
}
310
-
311
-
func (s *Service) GetRecentRecords(ctx context.Context, limit int) ([]*FeedItem, error) {
312
-
// Prefer firehose index if available and populated
313
-
if s.useFirehose && s.firehoseIndex.IsReady() {
314
-
return s.firehoseIndex.GetRecentFeed(ctx, limit)
315
-
}
316
-
317
-
// Fallback to polling (existing code)
318
-
return s.getRecentRecordsViaPolling(ctx, limit)
319
-
}
320
-
```
321
-
322
-
---
323
-
324
-
## Implementation Phases
325
-
326
-
### Phase 1: Core Jetstream Consumer (2 weeks)
327
-
328
-
**Goal:** Replace polling with firehose consumption for the feed.
329
-
330
-
**Tasks:**
331
-
332
-
1. Create `internal/firehose/` package
333
-
- `config.go` - Jetstream configuration
334
-
- `consumer.go` - WebSocket consumer with reconnection
335
-
- `index.go` - BoltDB-backed feed index
336
-
- `scheduler.go` - Event processing scheduler
337
-
338
-
2. Integrate with existing feed service
339
-
- Add feature flag: `ARABICA_USE_FIREHOSE=true` (just use a cli flag)
340
-
- Keep polling as fallback
341
-
342
-
3. Handle profile resolution
343
-
- Cache profiles locally with 1-hour TTL
344
-
- Lazy fetch on first access
345
-
- Background refresh for active users
346
-
347
-
4. Cursor management
348
-
- Persist cursor to survive restarts
349
-
- Rewind on reconnection for safety
350
-
351
-
**Deliverables:**
352
-
353
-
- Real-time feed updates
354
-
- Reduced API calls to near-zero
355
-
- Automatic user discovery (anyone using Arabica lexicons)
356
-
357
-
### Phase 2: Slingshot Optimization (1 week)
358
-
359
-
**Goal:** Faster profile and record hydration.
360
-
361
-
**Tasks:**
362
-
363
-
1. Add Slingshot client (`internal/atproto/slingshot.go`)
364
-
2. Use `resolveMiniDoc` for profile resolution
365
-
3. Use Slingshot as fallback for missing records
366
-
367
-
**Deliverables:**
368
-
369
-
- Faster profile loading
370
-
- Resilience to slow PDS endpoints
371
-
372
-
### Phase 3: Constellation for Social (1 week)
373
-
374
-
**Goal:** Enable like/comment counts when social features are added.
375
-
376
-
**Tasks:**
377
-
378
-
1. Add Constellation client (`internal/atproto/constellation.go`)
379
-
2. Query backlinks for interaction counts
380
-
3. Display counts on feed items
381
-
382
-
**Deliverables:**
383
-
384
-
- Like count on brews
385
-
- Comment count on brews
386
-
- Foundation for social features
387
-
388
-
### Phase 4: Spacedust for Real-time Notifications (Future)
389
-
390
-
**Goal:** Push notifications for interactions.
391
-
392
-
**Tasks:**
393
-
394
-
1. Subscribe to Spacedust for user's content interactions
395
-
2. Build notification storage and API
396
-
3. WebSocket to frontend for live updates
397
-
398
-
---
399
-
400
-
## Data Flow Comparison
401
-
402
-
### Before (Polling)
403
-
404
-
```
405
-
User Request → Check Cache → [Cache Miss] → Poll N PDSes → Build Feed → Return
406
-
↓
407
-
~10N API calls
408
-
5-10 second latency
409
-
```
410
-
411
-
### After (Jetstream)
412
-
413
-
```
414
-
Jetstream → Consumer → Index (BoltDB)
415
-
↓
416
-
User Request → Query Index → Return
417
-
↓
418
-
0 API calls
419
-
<10ms latency
420
-
```
421
-
422
-
---
423
-
424
-
## Automatic User Discovery
425
-
426
-
A major benefit of firehose consumption is automatic user discovery:
427
-
428
-
**Current:** Users must explicitly register via `/api/feed/register`
429
-
430
-
**With Jetstream:** Any user who creates an Arabica record is automatically indexed
431
-
432
-
```go
433
-
// When we see a new DID creating Arabica records
434
-
func (c *Consumer) handleNewUser(did string) {
435
-
// Auto-register for feed
436
-
c.registry.Register(did)
437
-
438
-
// Fetch and cache their profile
439
-
go c.index.fetchAndCacheProfile(did)
440
-
441
-
// Backfill their existing records
442
-
go c.backfillUser(did)
443
-
}
444
-
```
445
-
446
-
This could replace the manual registry entirely, or supplement it for "featured" users.
447
-
448
-
---
449
-
450
-
## Backfill Strategy
451
-
452
-
When starting fresh or discovering a new user, we need historical data:
453
-
454
-
**Option A: Direct PDS Fetch (Simple)**
455
-
456
-
```go
457
-
func (c *Consumer) backfillUser(ctx context.Context, did string) error {
458
-
for _, collection := range arabicaCollections {
459
-
records, _ := publicClient.ListRecords(ctx, did, collection, 100)
460
-
for _, record := range records {
461
-
c.index.UpsertFromPDS(record)
462
-
}
463
-
}
464
-
return nil
465
-
}
466
-
```
467
-
468
-
**Option B: Slingshot Fetch (Faster)**
469
-
470
-
```go
471
-
func (c *Consumer) backfillUserViaSlingshot(ctx context.Context, did string) error {
472
-
// Single endpoint, pre-cached records
473
-
// Same API as PDS but faster
474
-
}
475
-
```
476
-
477
-
**Option C: Jetstream Cursor Rewind (Events Only)**
478
-
479
-
- Rewind cursor to desired point in time
480
-
- Replay events (no records available before cursor)
481
-
- Limited to ~24h of history typically
482
-
483
-
**Recommendation:** Use Option A for Phase 1, add Option B in Phase 2.
484
-
485
-
---
486
-
487
-
## Configuration
488
-
489
-
```bash
490
-
# Environment variables
491
-
492
-
# Enable firehose-based feed (default: false during rollout)
493
-
ARABICA_USE_FIREHOSE=true
494
-
495
-
# Jetstream endpoint (default: public Bluesky instances)
496
-
JETSTREAM_URL=wss://jetstream1.us-east.bsky.network/subscribe
497
-
498
-
# Optional: self-hosted Jetstream
499
-
# JETSTREAM_URL=ws://localhost:6008/subscribe
500
-
501
-
# Feed index database path
502
-
ARABICA_FEED_INDEX_PATH=~/.local/share/arabica/feed-index.db
503
-
504
-
# Profile cache TTL (default: 1h)
505
-
ARABICA_PROFILE_CACHE_TTL=1h
506
-
507
-
# Optional: Slingshot endpoint for Phase 2
508
-
# SLINGSHOT_URL=https://slingshot.microcosm.blue
509
-
510
-
# Optional: Constellation endpoint for Phase 3
511
-
# CONSTELLATION_URL=https://constellation.microcosm.blue
512
-
```
513
-
514
-
---
515
-
516
-
## Monitoring and Metrics
517
-
518
-
```go
519
-
// Prometheus metrics to track firehose health
520
-
521
-
var (
522
-
eventsReceived = prometheus.NewCounterVec(
523
-
prometheus.CounterOpts{
524
-
Name: "arabica_firehose_events_total",
525
-
Help: "Total events received from Jetstream",
526
-
},
527
-
[]string{"collection", "operation"},
528
-
)
529
-
530
-
indexSize = prometheus.NewGauge(
531
-
prometheus.GaugeOpts{
532
-
Name: "arabica_feed_index_records",
533
-
Help: "Number of records in feed index",
534
-
},
535
-
)
536
-
537
-
consumerLag = prometheus.NewGauge(
538
-
prometheus.GaugeOpts{
539
-
Name: "arabica_firehose_lag_seconds",
540
-
Help: "Lag between event time and processing time",
541
-
},
542
-
)
543
-
544
-
connectionState = prometheus.NewGauge(
545
-
prometheus.GaugeOpts{
546
-
Name: "arabica_firehose_connected",
547
-
Help: "1 if connected to Jetstream, 0 otherwise",
548
-
},
549
-
)
550
-
)
551
-
```
552
-
553
-
---
554
-
555
-
## Risk Assessment
556
-
557
-
| Risk | Mitigation |
558
-
| ----------------------- | --------------------------------------------- |
559
-
| Jetstream unavailable | Fallback to polling, rotate endpoints |
560
-
| Index corruption | Rebuild from backfill, periodic snapshots |
561
-
| Duplicate events | Idempotent upserts using AT-URI as key |
562
-
| Missing historical data | Backfill on startup and new user discovery |
563
-
| High event volume | Filter to Arabica collections only (~0 noise) |
564
-
| Profile resolution lag | Local cache with background refresh |
565
-
566
-
---
567
-
568
-
## Open Questions
569
-
570
-
1. **Should we remove the registry entirely?**
571
-
- Pro: Simpler, automatic discovery
572
-
- Con: Lose ability to curate "featured" users
573
-
- Recommendation: Keep registry for admin features, but don't require it for feed inclusion
574
-
575
-
2. **Self-host Jetstream or use public?**
576
-
- Public is free and reliable
577
-
- Self-host gives control and removes dependency
578
-
- Recommendation: Start with public, evaluate self-hosting if issues arise
579
-
580
-
3. **How long to keep historical data?**
581
-
- Option: Rolling 30-day window
582
-
- Option: Keep everything (disk is cheap)
583
-
- Recommendation: Keep 90 days, prune older records
584
-
585
-
4. **Real-time feed updates to frontend?**
586
-
- Could push new items via WebSocket/SSE
587
-
- Or just reduce cache TTL to ~30 seconds
588
-
- Recommendation: Phase 1 just reduces staleness; real-time push is future enhancement
589
-
590
-
---
591
-
592
-
## Alternatives Considered
593
-
594
-
### 1. Tap (Bluesky's Full Sync Tool)
595
-
596
-
**Pros:** Full verification, automatic backfill, collection signal mode
597
-
**Cons:** Heavy operational overhead, overkill for current scale
598
-
**Verdict:** Revisit when user base exceeds 500+
599
-
600
-
### 2. Direct Firehose Consumption
601
-
602
-
**Pros:** No Jetstream dependency
603
-
**Cons:** Complex CBOR/CAR parsing, high bandwidth
604
-
**Verdict:** Jetstream provides the simplicity we need
605
-
606
-
### 3. Slingshot as Primary Data Source
607
-
608
-
**Pros:** Pre-cached records, single endpoint
609
-
**Cons:** Still polling-based, no real-time
610
-
**Verdict:** Use as optimization layer, not primary
611
-
612
-
### 4. Spacedust Instead of Jetstream
613
-
614
-
**Pros:** Link-focused, lightweight
615
-
**Cons:** Only links, no full records
616
-
**Verdict:** Use for notifications, not feed content
617
-
618
-
---
619
-
620
-
## Success Criteria
621
-
622
-
| Metric | Target |
623
-
| -------------------------- | ----------------------- |
624
-
| Feed latency | <100ms (from >5s) |
625
-
| API calls per feed request | 0 (from ~10N) |
626
-
| Time to see new content | <5s (from 5min) |
627
-
| Feed availability | 99.9% (with fallback) |
628
-
| New user discovery | Automatic (from manual) |
629
-
630
-
---
631
-
632
-
## References
633
-
634
-
- [Jetstream GitHub](https://github.com/bluesky-social/jetstream)
635
-
- [Jetstream Blog Post](https://docs.bsky.app/blog/jetstream)
636
-
- [Jetstream Go Client](https://pkg.go.dev/github.com/bluesky-social/jetstream/pkg/client)
637
-
- [Microcosm.blue Services](https://microcosm.blue/)
638
-
- [Constellation API](https://constellation.microcosm.blue/)
639
-
- [Slingshot API](https://slingshot.microcosm.blue/)
640
-
- [Existing Evaluation: Jetstream/Tap](./jetstream-tap-evaluation.md)
641
-
- [Existing Evaluation: Microcosm Tools](./microcosm-tools-evaluation.md)
-34
docs/indigo-research.md
-34
docs/indigo-research.md
···
1
-
# AT Protocol Integration
2
-
3
-
## Overview
4
-
5
-
Arabica uses the Bluesky indigo SDK for AT Protocol integration.
6
-
7
-
**Package:** `github.com/bluesky-social/indigo`
8
-
9
-
## Key Components
10
-
11
-
### OAuth Authentication
12
-
13
-
- Public OAuth client with PKCE
14
-
- DPOP-bound access tokens
15
-
- Scopes: `atproto`, `transition:generic`
16
-
- Session persistence via BoltDB
17
-
18
-
### Record Operations
19
-
20
-
Standard AT Protocol record CRUD operations:
21
-
- `com.atproto.repo.createRecord`
22
-
- `com.atproto.repo.getRecord`
23
-
- `com.atproto.repo.listRecords`
24
-
- `com.atproto.repo.putRecord`
25
-
- `com.atproto.repo.deleteRecord`
26
-
27
-
### Client Implementation
28
-
29
-
See `internal/atproto/client.go` for the XRPC client wrapper.
30
-
31
-
## References
32
-
33
-
- indigo SDK: https://github.com/bluesky-social/indigo
34
-
- AT Protocol docs: https://atproto.com
-314
docs/jetstream-tap-evaluation.md
-314
docs/jetstream-tap-evaluation.md
···
1
-
# Jetstream and Tap Evaluation for Arabica
2
-
3
-
## Executive Summary
4
-
5
-
This document evaluates two AT Protocol synchronization tools - **Jetstream** and **Tap** - for potential integration with Arabica. These tools could help reduce API requests for the community feed feature and simplify real-time data synchronization.
6
-
7
-
**Recommendation:** Consider Jetstream for community feed improvements in the near term; Tap is overkill for Arabica's current scale but valuable for future growth.
8
-
9
-
---
10
-
11
-
## Background: Current Arabica Architecture
12
-
13
-
Arabica currently interacts with AT Protocol in two ways:
14
-
15
-
1. **Authenticated User Operations** (`internal/atproto/store.go`)
16
-
- Direct XRPC calls to user's PDS for CRUD operations
17
-
- Per-session in-memory cache (5-minute TTL)
18
-
- Each user's data stored in their own PDS
19
-
20
-
2. **Community Feed** (`internal/feed/service.go`)
21
-
- Polls registered users' PDSes to aggregate recent activity
22
-
- Fetches profiles, brews, beans, roasters, grinders, brewers from each user
23
-
- Public feed cached for 5 minutes
24
-
- **Problem:** N+1 query pattern - each registered user requires multiple API calls
25
-
26
-
### Current Feed Inefficiency
27
-
28
-
For N registered users, the feed service makes approximately:
29
-
- N profile fetches
30
-
- N x 5 collection fetches (brew, bean, roaster, grinder, brewer) for recent items
31
-
- N x 4 collection fetches for reference resolution
32
-
- **Total: ~10N API calls per feed refresh**
33
-
34
-
---
35
-
36
-
## Tool 1: Jetstream
37
-
38
-
### What It Is
39
-
40
-
Jetstream is a streaming service that consumes the AT Protocol firehose (`com.atproto.sync.subscribeRepos`) and converts it into lightweight JSON events. It's operated by Bluesky at public endpoints.
41
-
42
-
**Public Instances:**
43
-
- `jetstream1.us-east.bsky.network`
44
-
- `jetstream2.us-east.bsky.network`
45
-
- `jetstream1.us-west.bsky.network`
46
-
- `jetstream2.us-west.bsky.network`
47
-
48
-
### Key Features
49
-
50
-
| Feature | Description |
51
-
|---------|-------------|
52
-
| JSON Output | Simple JSON instead of CBOR/CAR binary encoding |
53
-
| Filtering | Filter by collection (NSID) or repo (DID) |
54
-
| Compression | ~56% smaller messages with zstd compression |
55
-
| Low Latency | Real-time event delivery |
56
-
| Easy to Use | Standard WebSocket connection |
57
-
58
-
### Jetstream Event Example
59
-
60
-
```json
61
-
{
62
-
"did": "did:plc:eygmaihciaxprqvxpfvl6flk",
63
-
"time_us": 1725911162329308,
64
-
"kind": "commit",
65
-
"commit": {
66
-
"rev": "3l3qo2vutsw2b",
67
-
"operation": "create",
68
-
"collection": "social.arabica.alpha.brew",
69
-
"rkey": "3l3qo2vuowo2b",
70
-
"record": {
71
-
"$type": "social.arabica.alpha.brew",
72
-
"method": "pourover",
73
-
"rating": 4,
74
-
"createdAt": "2024-09-09T19:46:02.102Z"
75
-
},
76
-
"cid": "bafyreidwaivazkwu67xztlmuobx35hs2lnfh3kolmgfmucldvhd3sgzcqi"
77
-
}
78
-
}
79
-
```
80
-
81
-
### How Arabica Could Use Jetstream
82
-
83
-
**Use Case: Real-time Community Feed**
84
-
85
-
Instead of polling each user's PDS every 5 minutes, Arabica could:
86
-
87
-
1. Subscribe to Jetstream filtered by:
88
-
- `wantedCollections`: `social.arabica.alpha.*`
89
-
- `wantedDids`: List of registered feed users
90
-
91
-
2. Maintain a local feed index updated in real-time
92
-
93
-
3. Serve feed directly from local index (instant response, no API calls)
94
-
95
-
**Implementation Sketch:**
96
-
97
-
```go
98
-
// Subscribe to Jetstream for Arabica collections
99
-
ws, _ := websocket.Dial("wss://jetstream1.us-east.bsky.network/subscribe?" +
100
-
"wantedCollections=social.arabica.alpha.brew&" +
101
-
"wantedCollections=social.arabica.alpha.bean&" +
102
-
"wantedDids=" + strings.Join(registeredDids, "&wantedDids="))
103
-
104
-
// Process events in background goroutine
105
-
for {
106
-
var event JetstreamEvent
107
-
ws.ReadJSON(&event)
108
-
109
-
switch event.Commit.Collection {
110
-
case "social.arabica.alpha.brew":
111
-
feedIndex.AddBrew(event.DID, event.Commit.Record)
112
-
case "social.arabica.alpha.bean":
113
-
feedIndex.AddBean(event.DID, event.Commit.Record)
114
-
}
115
-
}
116
-
```
117
-
118
-
### Jetstream Tradeoffs
119
-
120
-
| Pros | Cons |
121
-
|------|------|
122
-
| Dramatically reduces API calls | No cryptographic verification of data |
123
-
| Real-time updates (sub-second latency) | Requires persistent WebSocket connection |
124
-
| Simple JSON format | Trust relationship with Jetstream operator |
125
-
| Can filter by collection/DID | Not part of formal AT Protocol spec |
126
-
| Free public instances available | No built-in backfill mechanism |
127
-
128
-
### Jetstream Verdict for Arabica
129
-
130
-
**Recommended for:** Community feed real-time updates
131
-
132
-
**Not suitable for:** Authenticated user operations (those need direct PDS calls)
133
-
134
-
**Effort estimate:** Medium (1-2 weeks)
135
-
- Add WebSocket client for Jetstream
136
-
- Build local feed index (could use BoltDB or in-memory)
137
-
- Handle reconnection/cursor management
138
-
- Still need initial backfill via direct API
139
-
140
-
---
141
-
142
-
## Tool 2: Tap
143
-
144
-
### What It Is
145
-
146
-
Tap is a synchronization tool for AT Protocol that handles the complexity of repo synchronization. It subscribes to a Relay and outputs filtered, verified events. Tap is more comprehensive than Jetstream but requires running your own instance.
147
-
148
-
**Repository:** `github.com/bluesky-social/indigo/cmd/tap`
149
-
150
-
### Key Features
151
-
152
-
| Feature | Description |
153
-
|---------|-------------|
154
-
| Automatic Backfill | Fetches complete history when tracking new repos |
155
-
| Verification | MST integrity checks, signature validation |
156
-
| Recovery | Auto-resyncs if repo becomes desynchronized |
157
-
| Flexible Delivery | WebSocket, fire-and-forget, or webhooks |
158
-
| Filtered Output | DID and collection filtering |
159
-
160
-
### Tap Operating Modes
161
-
162
-
1. **Dynamic (default):** Add DIDs via API as needed
163
-
2. **Collection Signal:** Auto-track repos with records in specified collection
164
-
3. **Full Network:** Mirror entire AT Protocol network (resource-intensive)
165
-
166
-
### How Arabica Could Use Tap
167
-
168
-
**Use Case: Complete Feed Infrastructure**
169
-
170
-
Tap could replace the entire feed polling mechanism:
171
-
172
-
1. Run Tap instance with `TAP_SIGNAL_COLLECTION=social.arabica.alpha.brew`
173
-
2. Tap automatically discovers and tracks users who create brew records
174
-
3. Feed service consumes events from local Tap instance
175
-
4. No manual user registration needed - Tap discovers users automatically
176
-
177
-
**Collection Signal Mode:**
178
-
179
-
```bash
180
-
# Start Tap to auto-track repos with Arabica records
181
-
TAP_SIGNAL_COLLECTION=social.arabica.alpha.brew \
182
-
go run ./cmd/tap --disable-acks=true
183
-
```
184
-
185
-
**Webhook Delivery (Serverless-friendly):**
186
-
187
-
Tap can POST events to an HTTP endpoint, making it compatible with serverless architectures:
188
-
189
-
```bash
190
-
# Tap sends events to Arabica webhook
191
-
TAP_WEBHOOK_URL=https://arabica.example/api/feed-webhook \
192
-
go run ./cmd/tap
193
-
```
194
-
195
-
### Tap Tradeoffs
196
-
197
-
| Pros | Cons |
198
-
|------|------|
199
-
| Automatic backfill when adding repos | Requires running your own service |
200
-
| Full cryptographic verification | More operational complexity |
201
-
| Handles cursor management | Resource requirements (DB, network) |
202
-
| Auto-discovers users via collection signal | Overkill for small user bases |
203
-
| Webhook support for serverless | Still in beta |
204
-
205
-
### Tap Verdict for Arabica
206
-
207
-
**Recommended for:** Future growth when feed has many users
208
-
209
-
**Not suitable for:** Current scale (< 100 registered users)
210
-
211
-
**Effort estimate:** High (2-4 weeks)
212
-
- Deploy and operate Tap service
213
-
- Integrate webhook or WebSocket consumer
214
-
- Migrate feed service to consume from Tap
215
-
- Handle Tap service reliability/monitoring
216
-
217
-
---
218
-
219
-
## Comparison Matrix
220
-
221
-
| Aspect | Current Polling | Jetstream | Tap |
222
-
|--------|----------------|-----------|-----|
223
-
| API Calls per Refresh | ~10N | 0 (after connection) | 0 (after backfill) |
224
-
| Latency | 5 min cache | Real-time | Real-time |
225
-
| Backfill | Full fetch each time | Manual | Automatic |
226
-
| Verification | Trusts PDS | Trusts Jetstream | Full verification |
227
-
| Operational Cost | None | None (public) | Run own service |
228
-
| Complexity | Low | Medium | High |
229
-
| User Discovery | Manual registry | Manual | Auto via collection |
230
-
| Recommended Scale | < 50 users | 50-1000 users | 1000+ users |
231
-
232
-
---
233
-
234
-
## Recommendation
235
-
236
-
### Short Term (Now - 6 months)
237
-
238
-
**Stick with current polling + caching approach**
239
-
240
-
Rationale:
241
-
- Current implementation works
242
-
- User base is small
243
-
- Polling N users with caching is acceptable
244
-
245
-
**Consider adding Jetstream for feed** if:
246
-
- Feed latency becomes user-visible issue
247
-
- Registered users exceed ~50
248
-
- API rate limiting becomes a problem
249
-
250
-
### Medium Term (6-12 months)
251
-
252
-
**Implement Jetstream integration**
253
-
254
-
1. Add background Jetstream consumer
255
-
2. Build local feed index (BoltDB or SQLite)
256
-
3. Serve feed from local index
257
-
4. Keep polling as fallback for backfill
258
-
259
-
### Long Term (12+ months)
260
-
261
-
**Evaluate Tap when:**
262
-
- User base exceeds 500+ registered users
263
-
- Want automatic user discovery
264
-
- Need cryptographic verification for social features (likes, comments)
265
-
- Building moderation/anti-abuse features
266
-
267
-
---
268
-
269
-
## Implementation Notes
270
-
271
-
### Jetstream Client Library
272
-
273
-
Bluesky provides a Go client library:
274
-
275
-
```go
276
-
import "github.com/bluesky-social/jetstream/pkg/client"
277
-
```
278
-
279
-
### Tap TypeScript Library
280
-
281
-
For frontend integration:
282
-
283
-
```typescript
284
-
import { TapClient } from '@atproto/tap';
285
-
```
286
-
287
-
### Connection Resilience
288
-
289
-
Both tools require handling:
290
-
- WebSocket reconnection
291
-
- Cursor persistence across restarts
292
-
- Backpressure when events arrive faster than processing
293
-
294
-
### Caching Integration
295
-
296
-
Can coexist with current `SessionCache`:
297
-
- Jetstream/Tap updates the local index
298
-
- Local index serves feed requests
299
-
- SessionCache continues for authenticated user operations
300
-
301
-
---
302
-
303
-
## Related Documentation
304
-
305
-
- Jetstream GitHub: https://github.com/bluesky-social/jetstream
306
-
- Tap README: https://github.com/bluesky-social/indigo/blob/main/cmd/tap/README.md
307
-
- Jetstream Blog Post: https://docs.bsky.app/blog/jetstream
308
-
- Tap Blog Post: https://docs.bsky.app/blog/introducing-tap
309
-
310
-
---
311
-
312
-
## Note on "Constellation" and "Slingshot"
313
-
314
-
These terms don't appear to correspond to official AT Protocol tools as of this evaluation. If these refer to specific community projects or internal codenames, please provide additional context for evaluation.
-505
docs/microcosm-tools-evaluation.md
-505
docs/microcosm-tools-evaluation.md
···
1
-
# Microcosm Tools Evaluation for Arabica
2
-
3
-
## Executive Summary
4
-
5
-
This document evaluates three community-built AT Protocol infrastructure tools from [microcosm.blue](https://microcosm.blue/) - **Constellation**, **Spacedust**, and **Slingshot** - for potential integration with Arabica's community feed feature.
6
-
7
-
**Recommendation:** Adopt Constellation immediately for future social features (likes/comments). Consider Slingshot as an optional optimization for feed performance. Spacedust is ideal for real-time notifications when social features are implemented.
8
-
9
-
---
10
-
11
-
## Background: Current Arabica Architecture
12
-
13
-
### The Problem
14
-
15
-
Arabica's community feed (`internal/feed/service.go`) currently polls each registered user's PDS directly. For N registered users:
16
-
17
-
| API Call Type | Count per Refresh |
18
-
|---------------|-------------------|
19
-
| Profile fetches | N |
20
-
| Brew collections | N |
21
-
| Bean collections | N |
22
-
| Roaster collections | N |
23
-
| Grinder collections | N |
24
-
| Brewer collections | N |
25
-
| Reference resolution | ~4N |
26
-
| **Total** | **~10N API calls** |
27
-
28
-
This approach has several issues:
29
-
- **Latency**: Feed refresh is slow with many users
30
-
- **Rate limits**: Risk of PDS rate limiting
31
-
- **Reliability**: Feed fails if any PDS is slow/down
32
-
- **Scalability**: Linear growth in API calls per user
33
-
34
-
### Future Social Features
35
-
36
-
Arabica plans to add likes, comments, and follows (see `AGENTS.md`). These interactions require **backlink queries** - given a brew, find all likes pointing at it. This is impossible with current polling approach.
37
-
38
-
---
39
-
40
-
## Tool 1: Constellation (Backlink Index)
41
-
42
-
### What It Is
43
-
44
-
Constellation is a **global backlink index** that crawls every record in the AT Protocol firehose and indexes all links (AT-URIs, DIDs, URLs). It answers "who/what points at this target?" queries.
45
-
46
-
**Public Instance:** `https://constellation.microcosm.blue`
47
-
48
-
### Key Capabilities
49
-
50
-
| Feature | Description |
51
-
|---------|-------------|
52
-
| Backlink queries | Find all records linking to a target |
53
-
| Like/follow counts | Get interaction counts instantly |
54
-
| Any lexicon support | Works with `social.arabica.alpha.*` |
55
-
| DID filtering | Filter links by specific users |
56
-
| Distinct DID counts | Count unique users, not just records |
57
-
58
-
### API Examples
59
-
60
-
**Get like count for a brew:**
61
-
```bash
62
-
curl "https://constellation.microcosm.blue/links/count/distinct-dids" \
63
-
-G --data-urlencode "target=at://did:plc:xxx/social.arabica.alpha.brew/abc123" \
64
-
--data-urlencode "collection=social.arabica.alpha.like" \
65
-
--data-urlencode "path=.subject.uri"
66
-
```
67
-
68
-
**Get all users who liked a brew:**
69
-
```bash
70
-
curl "https://constellation.microcosm.blue/links/distinct-dids" \
71
-
-G --data-urlencode "target=at://did:plc:xxx/social.arabica.alpha.brew/abc123" \
72
-
--data-urlencode "collection=social.arabica.alpha.like" \
73
-
--data-urlencode "path=.subject.uri"
74
-
```
75
-
76
-
**Get all comments on a brew:**
77
-
```bash
78
-
curl "https://constellation.microcosm.blue/links" \
79
-
-G --data-urlencode "target=at://did:plc:xxx/social.arabica.alpha.brew/abc123" \
80
-
--data-urlencode "collection=social.arabica.alpha.comment" \
81
-
--data-urlencode "path=.subject.uri"
82
-
```
83
-
84
-
### How Arabica Could Use Constellation
85
-
86
-
**Use Case 1: Social Interaction Counts**
87
-
88
-
When displaying a brew in the feed, fetch interaction counts:
89
-
90
-
```go
91
-
// Get like count for a brew
92
-
func (c *ConstellationClient) GetLikeCount(ctx context.Context, brewURI string) (int, error) {
93
-
url := fmt.Sprintf("%s/links/count/distinct-dids?target=%s&collection=%s&path=%s",
94
-
c.baseURL,
95
-
url.QueryEscape(brewURI),
96
-
"social.arabica.alpha.like",
97
-
url.QueryEscape(".subject.uri"))
98
-
99
-
// Returns {"total": 42}
100
-
var result struct { Total int `json:"total"` }
101
-
// ... fetch and decode
102
-
return result.Total, nil
103
-
}
104
-
```
105
-
106
-
**Use Case 2: Comment Threads**
107
-
108
-
Fetch all comments for a brew detail page:
109
-
110
-
```go
111
-
func (c *ConstellationClient) GetComments(ctx context.Context, brewURI string) ([]Comment, error) {
112
-
// Constellation returns the AT-URIs of comment records
113
-
// Then fetch each comment from Slingshot or user's PDS
114
-
}
115
-
```
116
-
117
-
**Use Case 3: "Who liked this" List**
118
-
119
-
```go
120
-
func (c *ConstellationClient) GetLikers(ctx context.Context, brewURI string) ([]string, error) {
121
-
// Returns list of DIDs who liked this brew
122
-
// Can hydrate with profile info from Slingshot
123
-
}
124
-
```
125
-
126
-
### Constellation Tradeoffs
127
-
128
-
| Pros | Cons |
129
-
|------|------|
130
-
| Instant interaction counts (no polling) | Third-party dependency |
131
-
| Works with any lexicon including Arabica's | Not self-hosted (yet) |
132
-
| Handles likes from any PDS globally | Slight index delay (~seconds) |
133
-
| 11B+ links indexed, production-ready | Trusts Constellation operator |
134
-
| Free public instance | Query limits may apply |
135
-
136
-
### Constellation Verdict
137
-
138
-
**Essential for:** Social features (likes, comments, follows)
139
-
140
-
**Not needed for:** Current feed polling (Constellation indexes interactions, not record listings)
141
-
142
-
**Effort estimate:** Low (1 week)
143
-
- Add HTTP client for Constellation API
144
-
- Integrate counts into brew display
145
-
- Cache counts locally (5-minute TTL)
146
-
147
-
---
148
-
149
-
## Tool 2: Spacedust (Interactions Firehose)
150
-
151
-
### What It Is
152
-
153
-
Spacedust extracts **links** from every record in the AT Protocol firehose and re-emits them over WebSocket. Unlike Jetstream (which emits full records), Spacedust emits just the link relationships.
154
-
155
-
**Public Instance:** `wss://spacedust.microcosm.blue`
156
-
157
-
### Key Capabilities
158
-
159
-
| Feature | Description |
160
-
|---------|-------------|
161
-
| Real-time link events | Instantly know when someone likes/follows |
162
-
| Filter by source/target | Subscribe to specific collections or targets |
163
-
| Any lexicon support | Works with `social.arabica.alpha.*` |
164
-
| Lightweight | Just links, not full records |
165
-
166
-
### Example: Subscribe to Likes on Your Brews
167
-
168
-
```javascript
169
-
// WebSocket connection to Spacedust
170
-
const ws = new WebSocket(
171
-
"wss://spacedust.microcosm.blue/subscribe" +
172
-
"?wantedSources=social.arabica.alpha.like:subject.uri" +
173
-
"&wantedSubjects=did:plc:your-did"
174
-
);
175
-
176
-
ws.onmessage = (event) => {
177
-
const link = JSON.parse(event.data);
178
-
// { source: "at://...", target: "at://...", ... }
179
-
console.log("Someone liked your brew!");
180
-
};
181
-
```
182
-
183
-
### How Arabica Could Use Spacedust
184
-
185
-
**Use Case: Real-time Notifications**
186
-
187
-
When social features are added, Spacedust enables instant notifications:
188
-
189
-
```go
190
-
// Background goroutine subscribes to Spacedust
191
-
func (s *NotificationService) subscribeToInteractions(userDID string) {
192
-
ws := dial("wss://spacedust.microcosm.blue/subscribe" +
193
-
"?wantedSources=social.arabica.alpha.like:subject.uri" +
194
-
"&wantedSubjects=" + userDID)
195
-
196
-
for {
197
-
link := readLink(ws)
198
-
// Someone liked a brew by userDID
199
-
s.notify(userDID, "Someone liked your brew!")
200
-
}
201
-
}
202
-
```
203
-
204
-
**Use Case: Live Feed Updates**
205
-
206
-
Push new brews to connected clients without polling:
207
-
208
-
```go
209
-
// Subscribe to all Arabica brew creations
210
-
ws := dial("wss://spacedust.microcosm.blue/subscribe" +
211
-
"?wantedSources=social.arabica.alpha.brew:beanRef")
212
-
213
-
// When a link event arrives, a new brew was created
214
-
// Fetch full record from Slingshot and push to feed
215
-
```
216
-
217
-
### Spacedust Tradeoffs
218
-
219
-
| Pros | Cons |
220
-
|------|------|
221
-
| Real-time, sub-second latency | Requires persistent WebSocket |
222
-
| Lightweight link-only events | Still in v0 (missing some features) |
223
-
| Filter by collection/target | No cursor replay yet |
224
-
| Perfect for notifications | Need to hydrate records separately |
225
-
226
-
### Spacedust Verdict
227
-
228
-
**Ideal for:** Real-time notifications, live feed updates
229
-
230
-
**Not suitable for:** Current feed needs (need full records, not just links)
231
-
232
-
**Effort estimate:** Medium (2-3 weeks)
233
-
- WebSocket client with reconnection
234
-
- Notification service for social interactions
235
-
- Integration with frontend for live updates
236
-
- Depends on social features being implemented first
237
-
238
-
---
239
-
240
-
## Tool 3: Slingshot (Records & Identities Cache)
241
-
242
-
### What It Is
243
-
244
-
Slingshot is an **edge cache** for AT Protocol records and identities. It pre-caches records from the firehose and provides fast, authenticated access. Also resolves handles to DIDs with bi-directional verification.
245
-
246
-
**Public Instance:** `https://slingshot.microcosm.blue`
247
-
248
-
### Key Capabilities
249
-
250
-
| Feature | Description |
251
-
|---------|-------------|
252
-
| Fast record fetching | Pre-cached from firehose |
253
-
| Identity resolution | `resolveMiniDoc` for handle/DID |
254
-
| Bi-directional verification | Only returns verified handles |
255
-
| Works with slow PDS | Cache serves even if PDS is down |
256
-
| Standard XRPC API | Drop-in replacement for PDS calls |
257
-
258
-
### API Examples
259
-
260
-
**Resolve identity:**
261
-
```bash
262
-
curl "https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=bad-example.com"
263
-
# Returns: { "did": "did:plc:...", "handle": "bad-example.com", "pds": "https://..." }
264
-
```
265
-
266
-
**Get record (standard XRPC):**
267
-
```bash
268
-
curl "https://slingshot.microcosm.blue/xrpc/com.atproto.repo.getRecord?repo=did:plc:xxx&collection=social.arabica.alpha.brew&rkey=abc123"
269
-
```
270
-
271
-
**List records:**
272
-
```bash
273
-
curl "https://slingshot.microcosm.blue/xrpc/com.atproto.repo.listRecords?repo=did:plc:xxx&collection=social.arabica.alpha.brew&limit=10"
274
-
```
275
-
276
-
### How Arabica Could Use Slingshot
277
-
278
-
**Use Case 1: Faster Feed Fetching**
279
-
280
-
Replace direct PDS calls with Slingshot for public data:
281
-
282
-
```go
283
-
// Before: Each user's PDS
284
-
pdsEndpoint, _ := c.GetPDSEndpoint(ctx, did)
285
-
url := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords...", pdsEndpoint)
286
-
287
-
// After: Single Slingshot endpoint
288
-
url := fmt.Sprintf("https://slingshot.microcosm.blue/xrpc/com.atproto.repo.listRecords...")
289
-
```
290
-
291
-
**Benefits:**
292
-
- Eliminates N DNS lookups for N user PDS endpoints
293
-
- Single, fast endpoint for all public record fetches
294
-
- Continues working even if individual PDS is slow/down
295
-
- Pre-cached records = faster response times
296
-
297
-
**Use Case 2: Identity Resolution**
298
-
299
-
Replace multiple API calls with single `resolveMiniDoc`:
300
-
301
-
```go
302
-
// Before: Two calls
303
-
handle := resolveHandle(did) // Call 1
304
-
pds := resolvePDSEndpoint(did) // Call 2
305
-
306
-
// After: One call
307
-
mini := resolveMiniDoc(did)
308
-
// { handle: "user.bsky.social", pds: "https://...", did: "did:plc:..." }
309
-
```
310
-
311
-
**Use Case 3: Hydrate Records from Constellation**
312
-
313
-
When Constellation returns AT-URIs (e.g., comments on a brew), fetch the actual records from Slingshot:
314
-
315
-
```go
316
-
// Constellation returns: ["at://did:plc:a/social.arabica.alpha.comment/123", ...]
317
-
commentURIs := constellation.GetComments(ctx, brewURI)
318
-
319
-
// Fetch each comment record from Slingshot
320
-
for _, uri := range commentURIs {
321
-
record := slingshot.GetRecord(ctx, uri)
322
-
// ...
323
-
}
324
-
```
325
-
326
-
### Implementation: Slingshot-Backed PublicClient
327
-
328
-
```go
329
-
// internal/atproto/slingshot_client.go
330
-
331
-
const SlingshotBaseURL = "https://slingshot.microcosm.blue"
332
-
333
-
type SlingshotClient struct {
334
-
baseURL string
335
-
httpClient *http.Client
336
-
}
337
-
338
-
func NewSlingshotClient() *SlingshotClient {
339
-
return &SlingshotClient{
340
-
baseURL: SlingshotBaseURL,
341
-
httpClient: &http.Client{Timeout: 10 * time.Second},
342
-
}
343
-
}
344
-
345
-
// ListRecords uses Slingshot instead of user's PDS
346
-
func (c *SlingshotClient) ListRecords(ctx context.Context, did, collection string, limit int) (*PublicListRecordsOutput, error) {
347
-
// Same XRPC API, different endpoint
348
-
url := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords?repo=%s&collection=%s&limit=%d&reverse=true",
349
-
c.baseURL, url.QueryEscape(did), url.QueryEscape(collection), limit)
350
-
// ... standard HTTP request
351
-
}
352
-
353
-
// ResolveMiniDoc gets handle + PDS in one call
354
-
func (c *SlingshotClient) ResolveMiniDoc(ctx context.Context, identifier string) (*MiniDoc, error) {
355
-
url := fmt.Sprintf("%s/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=%s",
356
-
c.baseURL, url.QueryEscape(identifier))
357
-
// ... returns { did, handle, pds }
358
-
}
359
-
```
360
-
361
-
### Slingshot Tradeoffs
362
-
363
-
| Pros | Cons |
364
-
|------|------|
365
-
| Faster than direct PDS calls | Third-party dependency |
366
-
| Single endpoint for all users | May not have custom lexicons cached |
367
-
| Identity verification built-in | Not all XRPC APIs implemented |
368
-
| Resilient to slow/down PDS | Trusts Slingshot operator |
369
-
| Pre-cached from firehose | Still in v0, some features missing |
370
-
371
-
### Slingshot Verdict
372
-
373
-
**Recommended for:** Feed performance optimization, identity resolution
374
-
375
-
**Not suitable for:** Authenticated user operations (still need direct PDS)
376
-
377
-
**Effort estimate:** Low (3-5 days)
378
-
- Add SlingshotClient as optional PublicClient backend
379
-
- Feature flag to toggle between direct PDS and Slingshot
380
-
- Test with Arabica collections to ensure they're indexed
381
-
382
-
---
383
-
384
-
## Comparison: Current vs. Microcosm Tools
385
-
386
-
| Aspect | Current Polling | + Slingshot | + Constellation | + Spacedust |
387
-
|--------|-----------------|-------------|-----------------|-------------|
388
-
| Feed refresh latency | Slow (N PDS calls) | Fast (1 endpoint) | N/A | Real-time |
389
-
| Like/comment counts | Impossible | Impossible | Instant | N/A |
390
-
| Rate limit risk | High | Low | Low | None |
391
-
| PDS failure resilience | Poor | Good | N/A | N/A |
392
-
| Real-time updates | No (5min cache) | No | No | Yes |
393
-
| Effort to integrate | N/A | Low | Low | Medium |
394
-
395
-
---
396
-
397
-
## Recommendation
398
-
399
-
### Immediate (Social Features Prerequisite)
400
-
401
-
**1. Integrate Constellation when adding likes/comments**
402
-
403
-
Constellation is essential for social features. When a brew is displayed, use Constellation to:
404
-
- Show like count
405
-
- Show comment count
406
-
- Power "who liked this" lists
407
-
- Power comment threads
408
-
409
-
**Implementation priority:** Do this alongside `social.arabica.alpha.like` and `social.arabica.alpha.comment` lexicon implementation.
410
-
411
-
### Short Term (Performance Optimization)
412
-
413
-
**2. Evaluate Slingshot for feed performance**
414
-
415
-
If feed latency becomes an issue:
416
-
- Add SlingshotClient as alternative to direct PDS calls
417
-
- A/B test performance improvement
418
-
- Use for public record fetches only (keep direct PDS for authenticated writes)
419
-
420
-
**Trigger:** When registered users exceed ~20-30, or feed refresh exceeds 5 seconds
421
-
422
-
### Medium Term (Real-time Features)
423
-
424
-
**3. Add Spacedust for notifications**
425
-
426
-
When social features are live and users want notifications:
427
-
- Subscribe to Spacedust for likes/comments on user's content
428
-
- Push notifications via WebSocket to connected clients
429
-
- Optional: background job for email notifications
430
-
431
-
**Trigger:** After social features launch, when users request notifications
432
-
433
-
---
434
-
435
-
## Comparison with Official Tools (Jetstream/Tap)
436
-
437
-
See `jetstream-tap-evaluation.md` for official Bluesky tools. Key differences:
438
-
439
-
| Aspect | Microcosm Tools | Official Tools |
440
-
|--------|-----------------|----------------|
441
-
| Focus | Links/interactions | Full records |
442
-
| Backlink queries | Constellation (yes) | Not available |
443
-
| Record caching | Slingshot | Not available |
444
-
| Real-time | Spacedust (links) | Jetstream (records) |
445
-
| Self-hosting | Not yet documented | Available |
446
-
| Community | Community-supported | Bluesky-supported |
447
-
448
-
**Recommendation:** Use Microcosm tools for social features (likes/comments/follows) where backlink queries are essential. Consider Jetstream for full feed real-time if needed later.
449
-
450
-
---
451
-
452
-
## Implementation Plan
453
-
454
-
### Phase 1: Constellation Integration (with social features)
455
-
456
-
```
457
-
1. Create internal/atproto/constellation.go
458
-
- ConstellationClient with HTTP client
459
-
- GetBacklinks(), GetLinkCount(), GetDistinctDIDs()
460
-
461
-
2. Create internal/social/interactions.go
462
-
- GetBrewLikeCount(brewURI)
463
-
- GetBrewComments(brewURI)
464
-
- GetBrewLikers(brewURI)
465
-
466
-
3. Update templates to show interaction counts
467
-
- Modify feed item display
468
-
- Add like button (when like lexicon ready)
469
-
```
470
-
471
-
### Phase 2: Slingshot Optimization (optional)
472
-
473
-
```
474
-
1. Create internal/atproto/slingshot.go
475
-
- SlingshotClient implementing same interface as PublicClient
476
-
477
-
2. Add feature flag: ARABICA_USE_SLINGSHOT=true
478
-
479
-
3. Modify feed/service.go to use SlingshotClient
480
-
- Keep PublicClient as fallback
481
-
```
482
-
483
-
### Phase 3: Spacedust Notifications (future)
484
-
485
-
```
486
-
1. Create internal/notifications/spacedust.go
487
-
- WebSocket client with reconnection
488
-
- Subscribe to user's content interactions
489
-
490
-
2. Create notification storage (BoltDB)
491
-
492
-
3. Add /api/notifications endpoint for frontend polling
493
-
494
-
4. Optional: WebSocket to frontend for real-time
495
-
```
496
-
497
-
---
498
-
499
-
## Related Documentation
500
-
501
-
- Microcosm Main: https://microcosm.blue/
502
-
- Constellation API: https://constellation.microcosm.blue/
503
-
- Source Code: https://github.com/at-microcosm/microcosm-rs
504
-
- Discord: https://discord.gg/tcDfe4PGVB
505
-
- See also: `jetstream-tap-evaluation.md` for official Bluesky tools
+41
-24
frontend/index.html
+41
-24
frontend/index.html
···
1
-
<!DOCTYPE html>
1
+
<!doctype html>
2
2
<html lang="en">
3
-
<head>
4
-
<meta charset="UTF-8">
5
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6
-
<title>Arabica - Coffee Brew Tracker</title>
7
-
<meta name="description" content="Track your coffee brewing journey with detailed logs stored in your Personal Data Server">
8
-
9
-
<!-- Tailwind CSS -->
10
-
<link rel="stylesheet" href="/static/css/output.css?v=0.1.3">
11
-
12
-
<!-- Favicon -->
13
-
<link rel="icon" type="image/svg+xml" href="/static/images/favicon.svg">
14
-
<link rel="icon" type="image/png" sizes="32x32" href="/static/images/favicon-32x32.png">
15
-
<link rel="icon" type="image/png" sizes="16x16" href="/static/images/favicon-16x16.png">
16
-
<link rel="apple-touch-icon" sizes="180x180" href="/static/images/apple-touch-icon.png">
17
-
18
-
<!-- Web Manifest -->
19
-
<link rel="manifest" href="/static/manifest.json">
20
-
<meta name="theme-color" content="#78350f">
21
-
</head>
22
-
<body class="bg-brown-50 text-brown-900 min-h-screen">
23
-
<div id="app"></div>
24
-
<script type="module" src="/src/main.js"></script>
25
-
</body>
3
+
<head>
4
+
<meta charset="UTF-8" />
5
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+
<title>Arabica - Coffee Brew Tracker</title>
7
+
<meta
8
+
name="description"
9
+
content="Track your coffee brewing journey with detailed logs stored in your Personal Data Server"
10
+
/>
11
+
12
+
<!-- Tailwind CSS -->
13
+
<link rel="stylesheet" href="/static/css/output.css?v=0.1.4" />
14
+
15
+
<!-- Favicon -->
16
+
<link rel="icon" type="image/svg+xml" href="/static/images/favicon.svg" />
17
+
<link
18
+
rel="icon"
19
+
type="image/png"
20
+
sizes="32x32"
21
+
href="/static/images/favicon-32x32.png"
22
+
/>
23
+
<link
24
+
rel="icon"
25
+
type="image/png"
26
+
sizes="16x16"
27
+
href="/static/images/favicon-16x16.png"
28
+
/>
29
+
<link
30
+
rel="apple-touch-icon"
31
+
sizes="180x180"
32
+
href="/static/images/apple-touch-icon.png"
33
+
/>
34
+
35
+
<!-- Web Manifest -->
36
+
<link rel="manifest" href="/static/manifest.json" />
37
+
<meta name="theme-color" content="#78350f" />
38
+
</head>
39
+
<body class="bg-brown-50 text-brown-900 min-h-screen">
40
+
<div id="app"></div>
41
+
<script type="module" src="/src/main.js"></script>
42
+
</body>
26
43
</html>
+103
-39
frontend/src/components/FeedCard.svelte
+103
-39
frontend/src/components/FeedCard.svelte
···
1
1
<script>
2
2
export let item;
3
-
import { navigate } from '../lib/router.js';
4
-
3
+
import { navigate } from "../lib/router.js";
4
+
5
5
function safeAvatarURL(url) {
6
6
if (!url) return null;
7
-
if (url.startsWith('https://') || url.startsWith('/static/')) {
7
+
if (url.startsWith("https://") || url.startsWith("/static/")) {
8
8
return url;
9
9
}
10
10
return null;
11
11
}
12
-
12
+
13
13
function hasValue(val) {
14
-
return val !== null && val !== undefined && val !== '';
14
+
return val !== null && val !== undefined && val !== "";
15
15
}
16
16
</script>
17
17
18
-
<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">
18
+
<div
19
+
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"
20
+
>
19
21
<!-- Author row -->
20
22
<div class="flex items-center gap-3 mb-3">
21
-
<a href="/profile/{item.Author.handle}" on:click|preventDefault={() => navigate(`/profile/${item.Author.handle}`)} class="flex-shrink-0">
23
+
<a
24
+
href="/profile/{item.Author.handle}"
25
+
on:click|preventDefault={() => navigate(`/profile/${item.Author.handle}`)}
26
+
class="flex-shrink-0"
27
+
>
22
28
{#if safeAvatarURL(item.Author.avatar)}
23
-
<img src={safeAvatarURL(item.Author.avatar)} alt="" class="w-10 h-10 rounded-full object-cover hover:ring-2 hover:ring-brown-600 transition" />
29
+
<img
30
+
src={safeAvatarURL(item.Author.avatar)}
31
+
alt=""
32
+
class="w-10 h-10 rounded-full object-cover hover:ring-2 hover:ring-brown-600 transition"
33
+
/>
24
34
{:else}
25
-
<div class="w-10 h-10 rounded-full bg-brown-300 flex items-center justify-center hover:ring-2 hover:ring-brown-600 transition">
35
+
<div
36
+
class="w-10 h-10 rounded-full bg-brown-300 flex items-center justify-center hover:ring-2 hover:ring-brown-600 transition"
37
+
>
26
38
<span class="text-brown-600 text-sm">?</span>
27
39
</div>
28
40
{/if}
···
30
42
<div class="flex-1 min-w-0">
31
43
<div class="flex items-center gap-2">
32
44
{#if item.Author.displayName}
33
-
<a href="/profile/{item.Author.handle}" on:click|preventDefault={() => navigate(`/profile/${item.Author.handle}`)} class="font-medium text-brown-900 truncate hover:text-brown-700 hover:underline">{item.Author.displayName}</a>
45
+
<a
46
+
href="/profile/{item.Author.handle}"
47
+
on:click|preventDefault={() =>
48
+
navigate(`/profile/${item.Author.handle}`)}
49
+
class="font-medium text-brown-900 truncate hover:text-brown-700 hover:underline"
50
+
>{item.Author.displayName}</a
51
+
>
34
52
{/if}
35
-
<a href="/profile/{item.Author.handle}" on:click|preventDefault={() => navigate(`/profile/${item.Author.handle}`)} class="text-brown-600 text-sm truncate hover:text-brown-700 hover:underline">@{item.Author.handle}</a>
53
+
<a
54
+
href="/profile/{item.Author.handle}"
55
+
on:click|preventDefault={() =>
56
+
navigate(`/profile/${item.Author.handle}`)}
57
+
class="text-brown-600 text-sm truncate hover:text-brown-700 hover:underline"
58
+
>@{item.Author.handle}</a
59
+
>
36
60
</div>
37
61
<span class="text-brown-500 text-sm">{item.TimeAgo}</span>
38
62
</div>
···
40
64
41
65
<!-- Action header -->
42
66
<div class="mb-2 text-sm text-brown-700">
43
-
{#if item.RecordType === 'brew' && item.Brew}
67
+
{#if item.RecordType === "brew" && item.Brew}
44
68
<span>added a </span>
45
-
<a
69
+
<a
46
70
href="/brews/{item.Author.did}/{item.Brew.rkey}"
47
-
on:click|preventDefault={() => navigate(`/brews/${item.Author.did}/${item.Brew.rkey}`)}
71
+
on:click|preventDefault={() =>
72
+
navigate(`/brews/${item.Author.did}/${item.Brew.rkey}`)}
48
73
class="font-semibold text-brown-800 hover:text-brown-900 hover:underline cursor-pointer"
49
74
>
50
75
new brew
···
55
80
</div>
56
81
57
82
<!-- Record content -->
58
-
{#if item.RecordType === 'brew' && item.Brew}
59
-
<div class="bg-white/60 backdrop-blur rounded-lg p-4 border border-brown-200">
83
+
{#if item.RecordType === "brew" && item.Brew}
84
+
<div
85
+
class="bg-white/60 backdrop-blur rounded-lg p-4 border border-brown-200"
86
+
>
60
87
<!-- Bean info with rating -->
61
88
<div class="flex items-start justify-between gap-3 mb-3">
62
89
<div class="flex-1 min-w-0">
···
66
93
</div>
67
94
{#if item.Brew.bean.roaster?.name}
68
95
<div class="text-sm text-brown-700 mt-0.5">
69
-
<span class="font-medium">🏭 {item.Brew.bean.roaster.name}</span>
96
+
<span class="font-medium">🏭 {item.Brew.bean.roaster.name}</span
97
+
>
70
98
</div>
71
99
{/if}
72
-
<div class="text-xs text-brown-600 mt-1 flex flex-wrap gap-x-2 gap-y-0.5">
73
-
{#if item.Brew.bean.origin}<span class="inline-flex items-center gap-0.5">📍 {item.Brew.bean.origin}</span>{/if}
74
-
{#if item.Brew.bean.roast_level}<span class="inline-flex items-center gap-0.5">🔥 {item.Brew.bean.roast_level}</span>{/if}
75
-
{#if item.Brew.bean.process}<span class="inline-flex items-center gap-0.5">🌱 {item.Brew.bean.process}</span>{/if}
76
-
{#if hasValue(item.Brew.coffee_amount)}<span class="inline-flex items-center gap-0.5">⚖️ {item.Brew.coffee_amount}g</span>{/if}
100
+
<div
101
+
class="text-xs text-brown-600 mt-1 flex flex-wrap gap-x-2 gap-y-0.5"
102
+
>
103
+
{#if item.Brew.bean.origin}<span
104
+
class="inline-flex items-center gap-0.5"
105
+
>📍 {item.Brew.bean.origin}</span
106
+
>{/if}
107
+
{#if item.Brew.bean.roast_level}<span
108
+
class="inline-flex items-center gap-0.5"
109
+
>🔥 {item.Brew.bean.roast_level}</span
110
+
>{/if}
111
+
{#if item.Brew.bean.process}<span
112
+
class="inline-flex items-center gap-0.5"
113
+
>🌱 {item.Brew.bean.process}</span
114
+
>{/if}
115
+
{#if hasValue(item.Brew.coffee_amount)}<span
116
+
class="inline-flex items-center gap-0.5"
117
+
>⚖️ {item.Brew.coffee_amount}g</span
118
+
>{/if}
77
119
</div>
78
120
{/if}
79
121
</div>
80
122
{#if hasValue(item.Brew.rating)}
81
-
<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">
123
+
<span
124
+
class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-amber-100 text-amber-900 flex-shrink-0"
125
+
>
82
126
⭐ {item.Brew.rating}/10
83
127
</span>
84
128
{/if}
85
129
</div>
86
-
130
+
87
131
<!-- Brewer -->
88
132
{#if item.Brew.brewer_obj || item.Brew.method}
89
133
<div class="mb-2">
···
93
137
</span>
94
138
</div>
95
139
{/if}
96
-
140
+
97
141
<!-- Notes -->
98
142
{#if item.Brew.tasting_notes}
99
-
<div class="mt-2 text-sm text-brown-800 italic border-l-2 border-brown-300 pl-3">
143
+
<div
144
+
class="mt-2 text-sm text-brown-800 italic border-l-2 border-brown-300 pl-3"
145
+
>
100
146
"{item.Brew.tasting_notes}"
101
147
</div>
102
148
{/if}
103
149
</div>
104
-
{:else if item.RecordType === 'bean' && item.Bean}
105
-
<div class="bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200">
106
-
<div class="font-semibold text-brown-900">{item.Bean.name || item.Bean.origin}</div>
107
-
{#if item.Bean.origin}<div class="text-sm text-brown-700">📍 {item.Bean.origin}</div>{/if}
150
+
{:else if item.RecordType === "bean" && item.Bean}
151
+
<div
152
+
class="bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200"
153
+
>
154
+
<div class="font-semibold text-brown-900">
155
+
{item.Bean.name || item.Bean.origin}
156
+
</div>
157
+
{#if item.Bean.origin}<div class="text-sm text-brown-700">
158
+
📍 {item.Bean.origin}
159
+
</div>{/if}
108
160
</div>
109
-
{:else if item.RecordType === 'roaster' && item.Roaster}
110
-
<div class="bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200">
161
+
{:else if item.RecordType === "roaster" && item.Roaster}
162
+
<div
163
+
class="bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200"
164
+
>
111
165
<div class="font-semibold text-brown-900">🏭 {item.Roaster.name}</div>
112
-
{#if item.Roaster.location}<div class="text-sm text-brown-700">📍 {item.Roaster.location}</div>{/if}
166
+
{#if item.Roaster.location}<div class="text-sm text-brown-700">
167
+
📍 {item.Roaster.location}
168
+
</div>{/if}
113
169
</div>
114
-
{:else if item.RecordType === 'grinder' && item.Grinder}
115
-
<div class="bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200">
170
+
{:else if item.RecordType === "grinder" && item.Grinder}
171
+
<div
172
+
class="bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200"
173
+
>
116
174
<div class="font-semibold text-brown-900">⚙️ {item.Grinder.name}</div>
117
-
{#if item.Grinder.grinder_type}<div class="text-sm text-brown-700">{item.Grinder.grinder_type}</div>{/if}
175
+
{#if item.Grinder.grinder_type}<div class="text-sm text-brown-700">
176
+
{item.Grinder.grinder_type}
177
+
</div>{/if}
118
178
</div>
119
-
{:else if item.RecordType === 'brewer' && item.Brewer}
120
-
<div class="bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200">
179
+
{:else if item.RecordType === "brewer" && item.Brewer}
180
+
<div
181
+
class="bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200"
182
+
>
121
183
<div class="font-semibold text-brown-900">☕ {item.Brewer.name}</div>
122
-
{#if item.Brewer.brewer_type}<div class="text-sm text-brown-700">{item.Brewer.brewer_type}</div>{/if}
184
+
{#if item.Brewer.brewer_type}<div class="text-sm text-brown-700">
185
+
{item.Brewer.brewer_type}
186
+
</div>{/if}
123
187
</div>
124
188
{/if}
125
189
</div>
+72
-27
frontend/src/components/Header.svelte
+72
-27
frontend/src/components/Header.svelte
···
1
1
<script>
2
-
import { authStore } from '../stores/auth.js';
3
-
import { navigate } from '../lib/router.js';
4
-
2
+
import { authStore } from "../stores/auth.js";
3
+
import { navigate } from "../lib/router.js";
4
+
5
5
let dropdownOpen = false;
6
-
6
+
7
7
$: user = $authStore.user;
8
8
$: isAuthenticated = $authStore.isAuthenticated;
9
-
9
+
10
10
function toggleDropdown() {
11
11
dropdownOpen = !dropdownOpen;
12
12
}
13
-
13
+
14
14
function closeDropdown() {
15
15
dropdownOpen = false;
16
16
}
17
-
17
+
18
18
async function handleLogout() {
19
19
await authStore.logout();
20
20
}
21
-
21
+
22
22
// Close dropdown when clicking outside
23
23
function handleClickOutside(event) {
24
-
if (dropdownOpen && !event.target.closest('.user-menu')) {
24
+
if (dropdownOpen && !event.target.closest(".user-menu")) {
25
25
closeDropdown();
26
26
}
27
27
}
···
29
29
30
30
<svelte:window on:click={handleClickOutside} />
31
31
32
-
<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">
32
+
<nav
33
+
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"
34
+
>
33
35
<div class="container mx-auto px-4 py-4">
34
36
<div class="flex items-center justify-between">
35
37
<!-- Logo - always visible -->
36
-
<a href="/" on:click|preventDefault={() => navigate('/')} class="flex items-center gap-2 hover:opacity-80 transition">
38
+
<a
39
+
href="/"
40
+
on:click|preventDefault={() => navigate("/")}
41
+
class="flex items-center gap-2 hover:opacity-80 transition"
42
+
>
37
43
<h1 class="text-2xl font-bold">☕ Arabica</h1>
38
-
<span class="text-xs bg-amber-400 text-brown-900 px-2 py-1 rounded-md font-semibold shadow-sm">ALPHA</span>
44
+
<span
45
+
class="text-xs bg-amber-400 text-brown-900 px-2 py-1 rounded-md font-semibold shadow-sm"
46
+
>ALPHA</span
47
+
>
39
48
</a>
40
-
49
+
41
50
<!-- Navigation links -->
42
51
<div class="flex items-center gap-4">
43
52
{#if isAuthenticated}
···
49
58
aria-label="User menu"
50
59
>
51
60
{#if user?.avatar}
52
-
<img src={user.avatar} alt="" class="w-8 h-8 rounded-full object-cover ring-2 ring-brown-600" />
61
+
<img
62
+
src={user.avatar}
63
+
alt=""
64
+
class="w-8 h-8 rounded-full object-cover ring-2 ring-brown-600"
65
+
/>
53
66
{:else}
54
-
<div class="w-8 h-8 rounded-full bg-brown-600 flex items-center justify-center ring-2 ring-brown-500">
55
-
<span class="text-sm font-medium">{user?.displayName ? user.displayName.charAt(0).toUpperCase() : '?'}</span>
67
+
<div
68
+
class="w-8 h-8 rounded-full bg-brown-600 flex items-center justify-center ring-2 ring-brown-500"
69
+
>
70
+
<span class="text-sm font-medium"
71
+
>{user?.displayName
72
+
? user.displayName.charAt(0).toUpperCase()
73
+
: "?"}</span
74
+
>
56
75
</div>
57
76
{/if}
58
77
<svg
59
-
class="w-4 h-4 transition-transform {dropdownOpen ? 'rotate-180' : ''}"
78
+
class="w-4 h-4 transition-transform {dropdownOpen
79
+
? 'rotate-180'
80
+
: ''}"
60
81
fill="none"
61
82
stroke="currentColor"
62
83
viewBox="0 0 24 24"
63
84
>
64
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
85
+
<path
86
+
stroke-linecap="round"
87
+
stroke-linejoin="round"
88
+
stroke-width="2"
89
+
d="M19 9l-7 7-7-7"
90
+
/>
65
91
</svg>
66
92
</button>
67
-
93
+
68
94
{#if dropdownOpen}
69
95
<div
70
96
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"
71
97
>
72
98
{#if user?.handle}
73
99
<div class="px-4 py-2 border-b border-brown-100">
74
-
<p class="text-sm font-medium text-brown-900 truncate">{user.displayName || user.handle}</p>
75
-
<p class="text-xs text-brown-500 truncate">@{user.handle}</p>
100
+
<p class="text-sm font-medium text-brown-900 truncate">
101
+
{user.displayName || user.handle}
102
+
</p>
103
+
<p class="text-xs text-brown-500 truncate">
104
+
@{user.handle}
105
+
</p>
76
106
</div>
77
107
{/if}
78
108
<a
79
109
href="/profile/{user?.handle || user?.did}"
80
-
on:click|preventDefault={() => { navigate(`/profile/${user?.handle || user?.did}`); closeDropdown(); }}
110
+
on:click|preventDefault={() => {
111
+
navigate(`/profile/${user?.handle || user?.did}`);
112
+
closeDropdown();
113
+
}}
81
114
class="block px-4 py-2 text-sm text-brown-700 hover:bg-brown-50 transition-colors"
82
115
>
83
116
View Profile
84
117
</a>
85
118
<a
86
119
href="/brews"
87
-
on:click|preventDefault={() => { navigate('/brews'); closeDropdown(); }}
120
+
on:click|preventDefault={() => {
121
+
navigate("/brews");
122
+
closeDropdown();
123
+
}}
88
124
class="block px-4 py-2 text-sm text-brown-700 hover:bg-brown-50 transition-colors"
89
125
>
90
126
My Brews
91
127
</a>
92
128
<a
93
129
href="/manage"
94
-
on:click|preventDefault={() => { navigate('/manage'); closeDropdown(); }}
130
+
on:click|preventDefault={() => {
131
+
navigate("/manage");
132
+
closeDropdown();
133
+
}}
95
134
class="block px-4 py-2 text-sm text-brown-700 hover:bg-brown-50 transition-colors"
96
135
>
97
136
Manage Records
98
137
</a>
99
138
<a
100
139
href="/settings"
101
-
on:click|preventDefault={() => { navigate('/settings'); closeDropdown(); }}
140
+
on:click|preventDefault={() => {
141
+
navigate("/settings");
142
+
closeDropdown();
143
+
}}
102
144
class="block px-4 py-2 text-sm text-brown-400 cursor-not-allowed"
103
145
>
104
146
Settings (coming soon)
105
147
</a>
106
148
<div class="border-t border-brown-100 mt-1 pt-1">
107
149
<button
108
-
on:click={() => { handleLogout(); closeDropdown(); }}
150
+
on:click={() => {
151
+
handleLogout();
152
+
closeDropdown();
153
+
}}
109
154
class="w-full text-left px-4 py-2 text-sm text-brown-700 hover:bg-brown-50 transition-colors"
110
155
>
111
156
Logout
···
131
176
transform: translateY(0);
132
177
}
133
178
}
134
-
179
+
135
180
.animate-fade-in {
136
181
animation: fade-in 0.2s ease-out;
137
182
}
+5
-3
frontend/src/components/Modal.svelte
+5
-3
frontend/src/components/Modal.svelte
···
2
2
export let onSave;
3
3
export let onCancel;
4
4
export let isOpen = false;
5
-
export let title = 'Modal';
5
+
export let title = "Modal";
6
6
</script>
7
7
8
8
{#if isOpen}
9
9
<div class="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
10
-
<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">
10
+
<div
11
+
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"
12
+
>
11
13
<h3 class="text-xl font-semibold mb-4 text-brown-900">{title}</h3>
12
14
<div class="space-y-4">
13
15
<slot />
14
-
16
+
15
17
<div class="flex gap-2">
16
18
<button
17
19
type="button"
+44
-31
frontend/src/lib/api.js
+44
-31
frontend/src/lib/api.js
···
6
6
class APIError extends Error {
7
7
constructor(message, status, response) {
8
8
super(message);
9
-
this.name = 'APIError';
9
+
this.name = "APIError";
10
10
this.status = status;
11
11
this.response = response;
12
12
}
···
20
20
*/
21
21
async function request(endpoint, options = {}) {
22
22
const config = {
23
-
credentials: 'same-origin', // Send cookies
23
+
credentials: "same-origin", // Send cookies
24
24
headers: {
25
-
'Content-Type': 'application/json',
25
+
"Content-Type": "application/json",
26
26
...options.headers,
27
27
},
28
28
...options,
29
29
};
30
-
30
+
31
31
try {
32
32
const response = await fetch(endpoint, config);
33
-
33
+
34
34
// Handle 401/403 - but only redirect if not on public endpoints or pages
35
35
if (response.status === 401 || response.status === 403) {
36
36
// Don't redirect if:
37
37
// 1. Already on public pages
38
38
// 2. Calling public API endpoints (feed, resolve-handle, search-actors, me)
39
-
const publicPaths = ['/', '/login', '/about', '/terms'];
40
-
const publicEndpoints = ['/api/feed-json', '/api/resolve-handle', '/api/search-actors', '/api/me'];
39
+
const publicPaths = ["/", "/login", "/about", "/terms"];
40
+
const publicEndpoints = [
41
+
"/api/feed-json",
42
+
"/api/resolve-handle",
43
+
"/api/search-actors",
44
+
"/api/me",
45
+
];
41
46
const currentPath = window.location.pathname;
42
-
const isPublicEndpoint = publicEndpoints.some(path => endpoint.includes(path));
43
-
47
+
const isPublicEndpoint = publicEndpoints.some((path) =>
48
+
endpoint.includes(path),
49
+
);
50
+
44
51
if (!publicPaths.includes(currentPath) && !isPublicEndpoint) {
45
-
window.location.href = '/login';
52
+
window.location.href = "/login";
46
53
}
47
-
48
-
throw new APIError('Authentication required', response.status, response);
54
+
55
+
throw new APIError("Authentication required", response.status, response);
49
56
}
50
-
57
+
51
58
// Handle non-OK responses
52
59
if (!response.ok) {
53
60
const text = await response.text();
54
-
throw new APIError(text || `Request failed: ${response.statusText}`, response.status, response);
61
+
throw new APIError(
62
+
text || `Request failed: ${response.statusText}`,
63
+
response.status,
64
+
response,
65
+
);
55
66
}
56
-
67
+
57
68
// Handle empty responses (e.g., 204 No Content)
58
-
const contentType = response.headers.get('content-type');
59
-
if (!contentType || !contentType.includes('application/json')) {
69
+
const contentType = response.headers.get("content-type");
70
+
if (!contentType || !contentType.includes("application/json")) {
60
71
return null;
61
72
}
62
-
73
+
63
74
return await response.json();
64
75
} catch (error) {
65
76
if (error instanceof APIError) {
···
71
82
72
83
export const api = {
73
84
// GET request
74
-
get: (endpoint) => request(endpoint, { method: 'GET' }),
75
-
85
+
get: (endpoint) => request(endpoint, { method: "GET" }),
86
+
76
87
// POST request
77
-
post: (endpoint, data) => request(endpoint, {
78
-
method: 'POST',
79
-
body: JSON.stringify(data),
80
-
}),
81
-
88
+
post: (endpoint, data) =>
89
+
request(endpoint, {
90
+
method: "POST",
91
+
body: JSON.stringify(data),
92
+
}),
93
+
82
94
// PUT request
83
-
put: (endpoint, data) => request(endpoint, {
84
-
method: 'PUT',
85
-
body: JSON.stringify(data),
86
-
}),
87
-
95
+
put: (endpoint, data) =>
96
+
request(endpoint, {
97
+
method: "PUT",
98
+
body: JSON.stringify(data),
99
+
}),
100
+
88
101
// DELETE request
89
-
delete: (endpoint) => request(endpoint, { method: 'DELETE' }),
102
+
delete: (endpoint) => request(endpoint, { method: "DELETE" }),
90
103
};
91
104
92
105
export { APIError };
+2
-2
frontend/src/lib/router.js
+2
-2
frontend/src/lib/router.js
+24
-14
frontend/src/routes/About.svelte
+24
-14
frontend/src/routes/About.svelte
···
1
1
<script>
2
-
import { navigate } from '../lib/router.js';
2
+
import { navigate } from "../lib/router.js";
3
3
</script>
4
4
5
5
<div class="max-w-4xl mx-auto">
6
-
<div class="bg-gradient-to-br from-amber-50 to-brown-100 rounded-xl p-8 border-2 border-brown-300 shadow-lg">
6
+
<div
7
+
class="bg-gradient-to-br from-amber-50 to-brown-100 rounded-xl p-8 border-2 border-brown-300 shadow-lg"
8
+
>
7
9
<h1 class="text-3xl font-bold text-brown-900 mb-6">About Arabica</h1>
8
-
10
+
9
11
<div class="prose prose-brown max-w-none">
10
12
<p class="text-lg text-brown-800 mb-4">
11
-
Arabica is a coffee brew tracking application that leverages the AT Protocol for decentralized data storage.
13
+
Arabica is a coffee brew tracking application that leverages the AT
14
+
Protocol for decentralized data storage.
12
15
</p>
13
-
16
+
14
17
<h2 class="text-2xl font-bold text-brown-900 mt-8 mb-4">Features</h2>
15
18
<ul class="space-y-2 text-brown-800">
16
19
<li class="flex items-start">
17
20
<span class="mr-2">🔒</span>
18
-
<span><strong>Decentralized:</strong> Your data lives in your Personal Data Server (PDS)</span>
21
+
<span
22
+
><strong>Decentralized:</strong> Your data lives in your Personal Data
23
+
Server (PDS)</span
24
+
>
19
25
</li>
20
26
<li class="flex items-start">
21
27
<span class="mr-2">🚀</span>
22
-
<span><strong>Portable:</strong> Own your coffee brewing history</span>
28
+
<span><strong>Portable:</strong> Own your coffee brewing history</span
29
+
>
23
30
</li>
24
31
<li class="flex items-start">
25
32
<span class="mr-2">📊</span>
26
-
<span>Track brewing variables like temperature, time, and grind size</span>
33
+
<span
34
+
>Track brewing variables like temperature, time, and grind size</span
35
+
>
27
36
</li>
28
37
<li class="flex items-start">
29
38
<span class="mr-2">🌍</span>
···
34
43
<span>Add tasting notes and ratings to each brew</span>
35
44
</li>
36
45
</ul>
37
-
46
+
38
47
<h2 class="text-2xl font-bold text-brown-900 mt-8 mb-4">AT Protocol</h2>
39
48
<p class="text-brown-800 mb-4">
40
-
The Authenticated Transfer Protocol (AT Protocol) is a decentralized social networking protocol
41
-
that gives you full ownership of your data. Your brewing records are stored in your own PDS,
42
-
not in Arabica's servers.
49
+
The Authenticated Transfer Protocol (AT Protocol) is a decentralized
50
+
social networking protocol that gives you full ownership of your data.
51
+
Your brewing records are stored in your own PDS, not in Arabica's
52
+
servers.
43
53
</p>
44
-
54
+
45
55
<div class="mt-8">
46
56
<button
47
-
on:click={() => navigate('/')}
57
+
on:click={() => navigate("/")}
48
58
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"
49
59
>
50
60
Get Started
+310
-144
frontend/src/routes/BrewForm.svelte
+310
-144
frontend/src/routes/BrewForm.svelte
···
1
1
<script>
2
-
import { onMount } from 'svelte';
3
-
import { authStore } from '../stores/auth.js';
4
-
import { cacheStore } from '../stores/cache.js';
5
-
import { navigate, back } from '../lib/router.js';
6
-
import { api } from '../lib/api.js';
7
-
import Modal from '../components/Modal.svelte';
8
-
2
+
import { onMount } from "svelte";
3
+
import { authStore } from "../stores/auth.js";
4
+
import { cacheStore } from "../stores/cache.js";
5
+
import { navigate, back } from "../lib/router.js";
6
+
import { api } from "../lib/api.js";
7
+
import Modal from "../components/Modal.svelte";
8
+
9
9
export let id = null; // RKey for edit mode
10
-
export let mode = 'create'; // 'create' or 'edit'
11
-
10
+
export let mode = "create"; // 'create' or 'edit'
11
+
12
12
let form = {
13
-
bean_rkey: '',
14
-
coffee_amount: '',
15
-
grinder_rkey: '',
16
-
grind_size: '',
17
-
brewer_rkey: '',
18
-
water_amount: '',
19
-
water_temp: '',
20
-
brew_time: '',
21
-
notes: '',
13
+
bean_rkey: "",
14
+
coffee_amount: "",
15
+
grinder_rkey: "",
16
+
grind_size: "",
17
+
brewer_rkey: "",
18
+
water_amount: "",
19
+
water_temp: "",
20
+
brew_time: "",
21
+
notes: "",
22
22
rating: 5,
23
23
};
24
-
24
+
25
25
let pours = [];
26
26
let loading = true;
27
27
let saving = false;
28
28
let error = null;
29
-
29
+
30
30
// Modal states
31
31
let showBeanModal = false;
32
32
let showRoasterModal = false;
33
33
let showGrinderModal = false;
34
34
let showBrewerModal = false;
35
-
35
+
36
36
// Modal forms
37
-
let beanForm = { name: '', origin: '', roast_level: '', process: '', description: '', roaster_rkey: '' };
38
-
let roasterForm = { name: '', location: '', website: '', description: '' };
39
-
let grinderForm = { name: '', grinder_type: '', burr_type: '', notes: '' };
40
-
let brewerForm = { name: '', brewer_type: '', description: '' };
41
-
37
+
let beanForm = {
38
+
name: "",
39
+
origin: "",
40
+
roast_level: "",
41
+
process: "",
42
+
description: "",
43
+
roaster_rkey: "",
44
+
};
45
+
let roasterForm = { name: "", location: "", website: "", description: "" };
46
+
let grinderForm = { name: "", grinder_type: "", burr_type: "", notes: "" };
47
+
let brewerForm = { name: "", brewer_type: "", description: "" };
48
+
42
49
$: beans = $cacheStore.beans || [];
43
50
$: roasters = $cacheStore.roasters || [];
44
51
$: grinders = $cacheStore.grinders || [];
45
52
$: brewers = $cacheStore.brewers || [];
46
53
$: isAuthenticated = $authStore.isAuthenticated;
47
-
54
+
48
55
onMount(async () => {
49
56
if (!isAuthenticated) {
50
-
navigate('/login');
57
+
navigate("/login");
51
58
return;
52
59
}
53
-
60
+
54
61
await cacheStore.load();
55
-
56
-
if (mode === 'edit' && id) {
62
+
63
+
if (mode === "edit" && id) {
57
64
// Load brew for editing
58
65
const brews = $cacheStore.brews || [];
59
-
const brew = brews.find(b => b.rkey === id);
60
-
66
+
const brew = brews.find((b) => b.rkey === id);
67
+
61
68
if (brew) {
62
69
form = {
63
-
bean_rkey: brew.bean_rkey || '',
64
-
coffee_amount: brew.coffee_amount || '',
65
-
grinder_rkey: brew.grinder_rkey || '',
66
-
grind_size: brew.grind_size || '',
67
-
brewer_rkey: brew.brewer_rkey || '',
68
-
water_amount: brew.water_amount || '',
69
-
water_temp: brew.temperature || '',
70
-
brew_time: brew.time_seconds || '',
71
-
notes: brew.tasting_notes || '',
70
+
bean_rkey: brew.bean_rkey || "",
71
+
coffee_amount: brew.coffee_amount || "",
72
+
grinder_rkey: brew.grinder_rkey || "",
73
+
grind_size: brew.grind_size || "",
74
+
brewer_rkey: brew.brewer_rkey || "",
75
+
water_amount: brew.water_amount || "",
76
+
water_temp: brew.temperature || "",
77
+
brew_time: brew.time_seconds || "",
78
+
notes: brew.tasting_notes || "",
72
79
rating: brew.rating || 5,
73
80
};
74
-
81
+
75
82
pours = brew.pours ? JSON.parse(JSON.stringify(brew.pours)) : [];
76
83
} else {
77
-
error = 'Brew not found';
84
+
error = "Brew not found";
78
85
}
79
86
}
80
-
87
+
81
88
loading = false;
82
89
});
83
-
90
+
84
91
function addPour() {
85
92
pours = [...pours, { water_amount: 0, time_seconds: 0 }];
86
93
}
87
-
94
+
88
95
function removePour(index) {
89
96
pours = pours.filter((_, i) => i !== index);
90
97
}
91
-
98
+
92
99
async function handleSubmit() {
93
100
// Validate required fields
94
-
if (!form.bean_rkey || form.bean_rkey === '') {
95
-
error = 'Please select a coffee bean';
101
+
if (!form.bean_rkey || form.bean_rkey === "") {
102
+
error = "Please select a coffee bean";
96
103
return;
97
104
}
98
-
105
+
99
106
saving = true;
100
107
error = null;
101
-
108
+
102
109
try {
103
110
const payload = {
104
111
bean_rkey: form.bean_rkey,
105
-
method: form.method || '',
112
+
method: form.method || "",
106
113
temperature: form.water_temp ? parseFloat(form.water_temp) : 0,
107
114
water_amount: form.water_amount ? parseFloat(form.water_amount) : 0,
108
115
coffee_amount: form.coffee_amount ? parseFloat(form.coffee_amount) : 0,
109
116
time_seconds: form.brew_time ? parseFloat(form.brew_time) : 0,
110
-
grind_size: form.grind_size || '',
111
-
grinder_rkey: form.grinder_rkey || '',
112
-
brewer_rkey: form.brewer_rkey || '',
113
-
tasting_notes: form.notes || '',
117
+
grind_size: form.grind_size || "",
118
+
grinder_rkey: form.grinder_rkey || "",
119
+
brewer_rkey: form.brewer_rkey || "",
120
+
tasting_notes: form.notes || "",
114
121
rating: form.rating ? parseInt(form.rating) : 0,
115
-
pours: pours.filter(p => p.water_amount && p.time_seconds), // Only include completed pours
122
+
pours: pours.filter((p) => p.water_amount && p.time_seconds), // Only include completed pours
116
123
};
117
-
118
-
if (mode === 'edit') {
124
+
125
+
if (mode === "edit") {
119
126
await api.put(`/brews/${id}`, payload);
120
127
} else {
121
-
await api.post('/brews', payload);
128
+
await api.post("/brews", payload);
122
129
}
123
-
130
+
124
131
await cacheStore.invalidate();
125
-
navigate('/brews');
132
+
navigate("/brews");
126
133
} catch (err) {
127
134
error = err.message;
128
135
saving = false;
129
136
}
130
137
}
131
-
138
+
132
139
// Entity creation handlers
133
140
async function saveBeanModal() {
134
141
try {
135
-
const result = await api.post('/api/beans', beanForm);
142
+
const result = await api.post("/api/beans", beanForm);
136
143
await cacheStore.invalidate();
137
144
form.bean_rkey = result.rkey;
138
145
showBeanModal = false;
139
-
beanForm = { name: '', origin: '', roast_level: '', process: '', description: '', roaster_rkey: '' };
146
+
beanForm = {
147
+
name: "",
148
+
origin: "",
149
+
roast_level: "",
150
+
process: "",
151
+
description: "",
152
+
roaster_rkey: "",
153
+
};
140
154
} catch (err) {
141
-
alert('Failed to create bean: ' + err.message);
155
+
alert("Failed to create bean: " + err.message);
142
156
}
143
157
}
144
-
158
+
145
159
async function saveRoasterModal() {
146
160
try {
147
-
const result = await api.post('/api/roasters', roasterForm);
161
+
const result = await api.post("/api/roasters", roasterForm);
148
162
await cacheStore.invalidate();
149
163
beanForm.roaster_rkey = result.rkey;
150
164
showRoasterModal = false;
151
-
roasterForm = { name: '', location: '', website: '', description: '' };
165
+
roasterForm = { name: "", location: "", website: "", description: "" };
152
166
} catch (err) {
153
-
alert('Failed to create roaster: ' + err.message);
167
+
alert("Failed to create roaster: " + err.message);
154
168
}
155
169
}
156
-
170
+
157
171
async function saveGrinderModal() {
158
172
try {
159
-
const result = await api.post('/api/grinders', grinderForm);
173
+
const result = await api.post("/api/grinders", grinderForm);
160
174
await cacheStore.invalidate();
161
175
form.grinder_rkey = result.rkey;
162
176
showGrinderModal = false;
163
-
grinderForm = { name: '', grinder_type: '', burr_type: '', notes: '' };
177
+
grinderForm = { name: "", grinder_type: "", burr_type: "", notes: "" };
164
178
} catch (err) {
165
-
alert('Failed to create grinder: ' + err.message);
179
+
alert("Failed to create grinder: " + err.message);
166
180
}
167
181
}
168
-
182
+
169
183
async function saveBrewerModal() {
170
184
try {
171
-
const result = await api.post('/api/brewers', brewerForm);
185
+
const result = await api.post("/api/brewers", brewerForm);
172
186
await cacheStore.invalidate();
173
187
form.brewer_rkey = result.rkey;
174
188
showBrewerModal = false;
175
-
brewerForm = { name: '', brewer_type: '', description: '' };
189
+
brewerForm = { name: "", brewer_type: "", description: "" };
176
190
} catch (err) {
177
-
alert('Failed to create brewer: ' + err.message);
191
+
alert("Failed to create brewer: " + err.message);
178
192
}
179
193
}
180
194
</script>
181
195
182
196
<svelte:head>
183
-
<title>{mode === 'edit' ? 'Edit Brew' : 'New Brew'} - Arabica</title>
197
+
<title>{mode === "edit" ? "Edit Brew" : "New Brew"} - Arabica</title>
184
198
</svelte:head>
185
199
186
200
<div class="max-w-2xl mx-auto">
187
201
{#if loading}
188
202
<div class="text-center py-12">
189
-
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-brown-800 mx-auto"></div>
203
+
<div
204
+
class="animate-spin rounded-full h-12 w-12 border-b-2 border-brown-800 mx-auto"
205
+
></div>
190
206
<p class="mt-4 text-brown-700">Loading...</p>
191
207
</div>
192
208
{:else}
193
-
<div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 border border-brown-300">
209
+
<div
210
+
class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 border border-brown-300"
211
+
>
194
212
<!-- Header with Back Button -->
195
213
<div class="flex items-center gap-3 mb-6">
196
214
<button
197
215
on:click={() => back()}
198
216
class="inline-flex items-center text-brown-700 hover:text-brown-900 font-medium transition-colors cursor-pointer"
199
217
>
200
-
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
201
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
218
+
<svg
219
+
class="w-5 h-5"
220
+
fill="none"
221
+
stroke="currentColor"
222
+
viewBox="0 0 24 24"
223
+
xmlns="http://www.w3.org/2000/svg"
224
+
>
225
+
<path
226
+
stroke-linecap="round"
227
+
stroke-linejoin="round"
228
+
stroke-width="2"
229
+
d="M10 19l-7-7m0 0l7-7m-7 7h18"
230
+
></path>
202
231
</svg>
203
232
</button>
204
233
<h2 class="text-3xl font-bold text-brown-900">
205
-
{mode === 'edit' ? 'Edit Brew' : 'New Brew'}
234
+
{mode === "edit" ? "Edit Brew" : "New Brew"}
206
235
</h2>
207
236
</div>
208
-
237
+
209
238
{#if error}
210
-
<div class="mb-4 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
239
+
<div
240
+
class="mb-4 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded"
241
+
>
211
242
{error}
212
243
</div>
213
244
{/if}
214
-
245
+
215
246
<form on:submit|preventDefault={handleSubmit} class="space-y-6">
216
247
<!-- Bean Selection -->
217
248
<div>
218
-
<label for="bean-select" class="block text-sm font-medium text-brown-900 mb-2">Coffee Bean *</label>
249
+
<label
250
+
for="bean-select"
251
+
class="block text-sm font-medium text-brown-900 mb-2"
252
+
>Coffee Bean *</label
253
+
>
219
254
<div class="flex gap-2">
220
255
<select
221
256
id="bean-select"
···
232
267
</select>
233
268
<button
234
269
type="button"
235
-
on:click={() => showBeanModal = true}
270
+
on:click={() => (showBeanModal = true)}
236
271
class="bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"
237
272
>
238
273
+ New
239
274
</button>
240
275
</div>
241
276
</div>
242
-
277
+
243
278
<!-- Coffee Amount -->
244
279
<div>
245
-
<label for="coffee-amount" class="block text-sm font-medium text-brown-900 mb-2">Coffee Amount (grams)</label>
280
+
<label
281
+
for="coffee-amount"
282
+
class="block text-sm font-medium text-brown-900 mb-2"
283
+
>Coffee Amount (grams)</label
284
+
>
246
285
<input
247
286
id="coffee-amount"
248
287
type="number"
···
251
290
placeholder="e.g. 18"
252
291
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"
253
292
/>
254
-
<p class="text-sm text-brown-700 mt-1">Amount of ground coffee used</p>
293
+
<p class="text-sm text-brown-700 mt-1">
294
+
Amount of ground coffee used
295
+
</p>
255
296
</div>
256
-
297
+
257
298
<!-- Grinder -->
258
299
<div>
259
-
<label for="grinder-select" class="block text-sm font-medium text-brown-900 mb-2">Grinder</label>
300
+
<label
301
+
for="grinder-select"
302
+
class="block text-sm font-medium text-brown-900 mb-2">Grinder</label
303
+
>
260
304
<div class="flex gap-2">
261
305
<select
262
306
id="grinder-select"
···
270
314
</select>
271
315
<button
272
316
type="button"
273
-
on:click={() => showGrinderModal = true}
317
+
on:click={() => (showGrinderModal = true)}
274
318
class="bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"
275
319
>
276
320
+ New
277
321
</button>
278
322
</div>
279
323
</div>
280
-
324
+
281
325
<!-- Grind Size -->
282
326
<div>
283
-
<label for="grind-size" class="block text-sm font-medium text-brown-900 mb-2">Grind Size</label>
327
+
<label
328
+
for="grind-size"
329
+
class="block text-sm font-medium text-brown-900 mb-2"
330
+
>Grind Size</label
331
+
>
284
332
<input
285
333
id="grind-size"
286
334
type="text"
···
288
336
placeholder="e.g. 18, Medium, 3.5, Fine"
289
337
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"
290
338
/>
291
-
<p class="text-sm text-brown-700 mt-1">Enter a number (grinder setting) or description (e.g. "Medium", "Fine")</p>
339
+
<p class="text-sm text-brown-700 mt-1">
340
+
Enter a number (grinder setting) or description (e.g. "Medium",
341
+
"Fine")
342
+
</p>
292
343
</div>
293
-
344
+
294
345
<!-- Brew Method -->
295
346
<div>
296
-
<label for="brewer-select" class="block text-sm font-medium text-brown-900 mb-2">Brew Method</label>
347
+
<label
348
+
for="brewer-select"
349
+
class="block text-sm font-medium text-brown-900 mb-2"
350
+
>Brew Method</label
351
+
>
297
352
<div class="flex gap-2">
298
353
<select
299
354
id="brewer-select"
···
307
362
</select>
308
363
<button
309
364
type="button"
310
-
on:click={() => showBrewerModal = true}
365
+
on:click={() => (showBrewerModal = true)}
311
366
class="bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"
312
367
>
313
368
+ New
314
369
</button>
315
370
</div>
316
371
</div>
317
-
372
+
318
373
<!-- Water Amount -->
319
374
<div>
320
-
<label for="water-amount" class="block text-sm font-medium text-brown-900 mb-2">Water Amount (ml)</label>
375
+
<label
376
+
for="water-amount"
377
+
class="block text-sm font-medium text-brown-900 mb-2"
378
+
>Water Amount (ml)</label
379
+
>
321
380
<input
322
381
id="water-amount"
323
382
type="number"
···
327
386
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"
328
387
/>
329
388
</div>
330
-
389
+
331
390
<!-- Water Temperature -->
332
391
<div>
333
-
<label for="water-temp" class="block text-sm font-medium text-brown-900 mb-2">Water Temperature (°C)</label>
392
+
<label
393
+
for="water-temp"
394
+
class="block text-sm font-medium text-brown-900 mb-2"
395
+
>Water Temperature (°C)</label
396
+
>
334
397
<input
335
398
id="water-temp"
336
399
type="number"
···
340
403
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"
341
404
/>
342
405
</div>
343
-
406
+
344
407
<!-- Brew Time -->
345
408
<div>
346
-
<label for="brew-time" class="block text-sm font-medium text-brown-900 mb-2">Total Brew Time (seconds)</label>
409
+
<label
410
+
for="brew-time"
411
+
class="block text-sm font-medium text-brown-900 mb-2"
412
+
>Total Brew Time (seconds)</label
413
+
>
347
414
<input
348
415
id="brew-time"
349
416
type="number"
···
353
420
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"
354
421
/>
355
422
</div>
356
-
423
+
357
424
<!-- Pours -->
358
425
<div>
359
426
<div class="flex items-center justify-between mb-2">
360
-
<span class="block text-sm font-medium text-brown-900">Pour Schedule (Optional)</span>
427
+
<span class="block text-sm font-medium text-brown-900"
428
+
>Pour Schedule (Optional)</span
429
+
>
361
430
<button
362
431
type="button"
363
432
on:click={addPour}
···
366
435
+ Add Pour
367
436
</button>
368
437
</div>
369
-
438
+
370
439
{#if pours.length > 0}
371
440
<div class="space-y-2">
372
441
{#each pours as pour, i}
373
-
<div class="flex gap-2 items-center bg-brown-50 p-3 rounded-lg border border-brown-200">
374
-
<span class="text-sm font-medium text-brown-700 min-w-[60px]">Pour {i + 1}:</span>
442
+
<div
443
+
class="flex gap-2 items-center bg-brown-50 p-3 rounded-lg border border-brown-200"
444
+
>
445
+
<span class="text-sm font-medium text-brown-700 min-w-[60px]"
446
+
>Pour {i + 1}:</span
447
+
>
375
448
<input
376
449
type="number"
377
450
bind:value={pour.water_amount}
···
396
469
</div>
397
470
{/if}
398
471
</div>
399
-
472
+
400
473
<!-- Rating -->
401
474
<div>
402
-
<label for="rating" class="block text-sm font-medium text-brown-900 mb-2">
475
+
<label
476
+
for="rating"
477
+
class="block text-sm font-medium text-brown-900 mb-2"
478
+
>
403
479
Rating: <span class="font-bold">{form.rating}/10</span>
404
480
</label>
405
481
<input
···
416
492
<span>10</span>
417
493
</div>
418
494
</div>
419
-
495
+
420
496
<!-- Notes -->
421
497
<div>
422
-
<label for="notes" class="block text-sm font-medium text-brown-900 mb-2">Tasting Notes</label>
498
+
<label
499
+
for="notes"
500
+
class="block text-sm font-medium text-brown-900 mb-2"
501
+
>Tasting Notes</label
502
+
>
423
503
<textarea
424
504
id="notes"
425
505
bind:value={form.notes}
···
428
508
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"
429
509
></textarea>
430
510
</div>
431
-
511
+
432
512
<!-- Submit Button -->
433
513
<div class="flex gap-3">
434
514
<button
···
436
516
disabled={saving}
437
517
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"
438
518
>
439
-
{saving ? 'Saving...' : mode === 'edit' ? 'Update Brew' : 'Save Brew'}
519
+
{saving
520
+
? "Saving..."
521
+
: mode === "edit"
522
+
? "Update Brew"
523
+
: "Save Brew"}
440
524
</button>
441
525
<button
442
526
type="button"
···
456
540
bind:isOpen={showBeanModal}
457
541
title="Add New Bean"
458
542
onSave={saveBeanModal}
459
-
onCancel={() => showBeanModal = false}
543
+
onCancel={() => (showBeanModal = false)}
460
544
>
461
545
<div class="space-y-4">
462
546
<div>
463
-
<label for="bean-name" class="block text-sm font-medium text-gray-700 mb-1">Name</label>
464
-
<input id="bean-name" type="text" bind:value={beanForm.name} class="w-full rounded border-gray-300 px-3 py-2" />
547
+
<label
548
+
for="bean-name"
549
+
class="block text-sm font-medium text-gray-700 mb-1">Name</label
550
+
>
551
+
<input
552
+
id="bean-name"
553
+
type="text"
554
+
bind:value={beanForm.name}
555
+
class="w-full rounded border-gray-300 px-3 py-2"
556
+
/>
465
557
</div>
466
558
<div>
467
-
<label for="bean-origin" class="block text-sm font-medium text-gray-700 mb-1">Origin *</label>
468
-
<input id="bean-origin" type="text" bind:value={beanForm.origin} required class="w-full rounded border-gray-300 px-3 py-2" />
559
+
<label
560
+
for="bean-origin"
561
+
class="block text-sm font-medium text-gray-700 mb-1">Origin *</label
562
+
>
563
+
<input
564
+
id="bean-origin"
565
+
type="text"
566
+
bind:value={beanForm.origin}
567
+
required
568
+
class="w-full rounded border-gray-300 px-3 py-2"
569
+
/>
469
570
</div>
470
571
<div>
471
-
<label for="bean-roast-level" class="block text-sm font-medium text-gray-700 mb-1">Roast Level *</label>
472
-
<select id="bean-roast-level" bind:value={beanForm.roast_level} required class="w-full rounded border-gray-300 px-3 py-2">
572
+
<label
573
+
for="bean-roast-level"
574
+
class="block text-sm font-medium text-gray-700 mb-1"
575
+
>Roast Level *</label
576
+
>
577
+
<select
578
+
id="bean-roast-level"
579
+
bind:value={beanForm.roast_level}
580
+
required
581
+
class="w-full rounded border-gray-300 px-3 py-2"
582
+
>
473
583
<option value="">Select...</option>
474
584
<option value="Light">Light</option>
475
585
<option value="Medium-Light">Medium-Light</option>
···
479
589
</select>
480
590
</div>
481
591
<div>
482
-
<label for="bean-roaster" class="block text-sm font-medium text-gray-700 mb-1">Roaster</label>
592
+
<label
593
+
for="bean-roaster"
594
+
class="block text-sm font-medium text-gray-700 mb-1">Roaster</label
595
+
>
483
596
<div class="flex gap-2">
484
-
<select id="bean-roaster" bind:value={beanForm.roaster_rkey} class="flex-1 rounded border-gray-300 px-3 py-2">
597
+
<select
598
+
id="bean-roaster"
599
+
bind:value={beanForm.roaster_rkey}
600
+
class="flex-1 rounded border-gray-300 px-3 py-2"
601
+
>
485
602
<option value="">Select...</option>
486
603
{#each roasters as roaster}
487
604
<option value={roaster.rkey}>{roaster.name}</option>
···
489
606
</select>
490
607
<button
491
608
type="button"
492
-
on:click={() => showRoasterModal = true}
609
+
on:click={() => (showRoasterModal = true)}
493
610
class="bg-gray-200 px-3 py-1 rounded hover:bg-gray-300 text-sm"
494
611
>
495
612
+ New
···
503
620
bind:isOpen={showRoasterModal}
504
621
title="Add New Roaster"
505
622
onSave={saveRoasterModal}
506
-
onCancel={() => showRoasterModal = false}
623
+
onCancel={() => (showRoasterModal = false)}
507
624
>
508
625
<div class="space-y-4">
509
626
<div>
510
-
<label for="roaster-name" class="block text-sm font-medium text-gray-700 mb-1">Name *</label>
511
-
<input id="roaster-name" type="text" bind:value={roasterForm.name} required class="w-full rounded border-gray-300 px-3 py-2" />
627
+
<label
628
+
for="roaster-name"
629
+
class="block text-sm font-medium text-gray-700 mb-1">Name *</label
630
+
>
631
+
<input
632
+
id="roaster-name"
633
+
type="text"
634
+
bind:value={roasterForm.name}
635
+
required
636
+
class="w-full rounded border-gray-300 px-3 py-2"
637
+
/>
512
638
</div>
513
639
<div>
514
-
<label for="roaster-location" class="block text-sm font-medium text-gray-700 mb-1">Location</label>
515
-
<input id="roaster-location" type="text" bind:value={roasterForm.location} class="w-full rounded border-gray-300 px-3 py-2" />
640
+
<label
641
+
for="roaster-location"
642
+
class="block text-sm font-medium text-gray-700 mb-1">Location</label
643
+
>
644
+
<input
645
+
id="roaster-location"
646
+
type="text"
647
+
bind:value={roasterForm.location}
648
+
class="w-full rounded border-gray-300 px-3 py-2"
649
+
/>
516
650
</div>
517
651
</div>
518
652
</Modal>
···
521
655
bind:isOpen={showGrinderModal}
522
656
title="Add New Grinder"
523
657
onSave={saveGrinderModal}
524
-
onCancel={() => showGrinderModal = false}
658
+
onCancel={() => (showGrinderModal = false)}
525
659
>
526
660
<div class="space-y-4">
527
661
<div>
528
-
<label for="grinder-name" class="block text-sm font-medium text-gray-700 mb-1">Name *</label>
529
-
<input id="grinder-name" type="text" bind:value={grinderForm.name} required class="w-full rounded border-gray-300 px-3 py-2" />
662
+
<label
663
+
for="grinder-name"
664
+
class="block text-sm font-medium text-gray-700 mb-1">Name *</label
665
+
>
666
+
<input
667
+
id="grinder-name"
668
+
type="text"
669
+
bind:value={grinderForm.name}
670
+
required
671
+
class="w-full rounded border-gray-300 px-3 py-2"
672
+
/>
530
673
</div>
531
674
<div>
532
-
<label for="grinder-type" class="block text-sm font-medium text-gray-700 mb-1">Type</label>
533
-
<select id="grinder-type" bind:value={grinderForm.grinder_type} class="w-full rounded border-gray-300 px-3 py-2">
675
+
<label
676
+
for="grinder-type"
677
+
class="block text-sm font-medium text-gray-700 mb-1">Type</label
678
+
>
679
+
<select
680
+
id="grinder-type"
681
+
bind:value={grinderForm.grinder_type}
682
+
class="w-full rounded border-gray-300 px-3 py-2"
683
+
>
534
684
<option value="">Select...</option>
535
685
<option value="Manual">Manual</option>
536
686
<option value="Electric">Electric</option>
···
544
694
bind:isOpen={showBrewerModal}
545
695
title="Add New Brewer"
546
696
onSave={saveBrewerModal}
547
-
onCancel={() => showBrewerModal = false}
697
+
onCancel={() => (showBrewerModal = false)}
548
698
>
549
699
<div class="space-y-4">
550
700
<div>
551
-
<label for="brewer-name" class="block text-sm font-medium text-gray-700 mb-1">Name *</label>
552
-
<input id="brewer-name" type="text" bind:value={brewerForm.name} required class="w-full rounded border-gray-300 px-3 py-2" />
701
+
<label
702
+
for="brewer-name"
703
+
class="block text-sm font-medium text-gray-700 mb-1">Name *</label
704
+
>
705
+
<input
706
+
id="brewer-name"
707
+
type="text"
708
+
bind:value={brewerForm.name}
709
+
required
710
+
class="w-full rounded border-gray-300 px-3 py-2"
711
+
/>
553
712
</div>
554
713
<div>
555
-
<label for="brewer-type" class="block text-sm font-medium text-gray-700 mb-1">Type</label>
556
-
<select id="brewer-type" bind:value={brewerForm.brewer_type} class="w-full rounded border-gray-300 px-3 py-2">
714
+
<label
715
+
for="brewer-type"
716
+
class="block text-sm font-medium text-gray-700 mb-1">Type</label
717
+
>
718
+
<select
719
+
id="brewer-type"
720
+
bind:value={brewerForm.brewer_type}
721
+
class="w-full rounded border-gray-300 px-3 py-2"
722
+
>
557
723
<option value="">Select...</option>
558
724
<option value="Pour Over">Pour Over</option>
559
725
<option value="French Press">French Press</option>
-554
frontend/src/routes/BrewForm.svelte.backup
-554
frontend/src/routes/BrewForm.svelte.backup
···
1
-
<script>
2
-
import { onMount } from 'svelte';
3
-
import { authStore } from '../stores/auth.js';
4
-
import { cacheStore } from '../stores/cache.js';
5
-
import { navigate, back } from '../lib/router.js';
6
-
import { api } from '../lib/api.js';
7
-
import Modal from '../components/Modal.svelte';
8
-
9
-
export let id = null; // RKey for edit mode
10
-
export let mode = 'create'; // 'create' or 'edit'
11
-
12
-
let form = {
13
-
bean_rkey: '',
14
-
coffee_amount: '',
15
-
grinder_rkey: '',
16
-
grind_size: '',
17
-
brewer_rkey: '',
18
-
water_amount: '',
19
-
water_temp: '',
20
-
brew_time: '',
21
-
notes: '',
22
-
rating: 5,
23
-
};
24
-
25
-
let pours = [];
26
-
let loading = true;
27
-
let saving = false;
28
-
let error = null;
29
-
30
-
// Modal states
31
-
let showBeanModal = false;
32
-
let showRoasterModal = false;
33
-
let showGrinderModal = false;
34
-
let showBrewerModal = false;
35
-
36
-
// Modal forms
37
-
let beanForm = { name: '', origin: '', roast_level: '', process: '', description: '', roaster_rkey: '' };
38
-
let roasterForm = { name: '', location: '', website: '', description: '' };
39
-
let grinderForm = { name: '', grinder_type: '', burr_type: '', notes: '' };
40
-
let brewerForm = { name: '', brewer_type: '', description: '' };
41
-
42
-
$: beans = $cacheStore.beans || [];
43
-
$: roasters = $cacheStore.roasters || [];
44
-
$: grinders = $cacheStore.grinders || [];
45
-
$: brewers = $cacheStore.brewers || [];
46
-
$: isAuthenticated = $authStore.isAuthenticated;
47
-
48
-
onMount(async () => {
49
-
if (!isAuthenticated) {
50
-
navigate('/login');
51
-
return;
52
-
}
53
-
54
-
await cacheStore.load();
55
-
56
-
if (mode === 'edit' && id) {
57
-
// Load brew for editing
58
-
const brews = $cacheStore.brews || [];
59
-
const brew = brews.find(b => b.RKey === id);
60
-
61
-
if (brew) {
62
-
form = {
63
-
bean_rkey: brew.BeanRKey || '',
64
-
coffee_amount: brew.CoffeeAmount || '',
65
-
grinder_rkey: brew.GrinderRKey || '',
66
-
grind_size: brew.GrindSize || '',
67
-
brewer_rkey: brew.BrewerRKey || '',
68
-
water_amount: brew.WaterAmount || '',
69
-
water_temp: brew.WaterTemp || '',
70
-
brew_time: brew.BrewTime || '',
71
-
notes: brew.Notes || '',
72
-
rating: brew.Rating || 5,
73
-
};
74
-
75
-
pours = brew.Pours ? JSON.parse(JSON.stringify(brew.Pours)) : [];
76
-
} else {
77
-
error = 'Brew not found';
78
-
}
79
-
}
80
-
81
-
loading = false;
82
-
});
83
-
84
-
function addPour() {
85
-
pours = [...pours, { Water: '', Time: '' }];
86
-
}
87
-
88
-
function removePour(index) {
89
-
pours = pours.filter((_, i) => i !== index);
90
-
}
91
-
92
-
async function handleSubmit() {
93
-
if (!form.bean_rkey) {
94
-
alert('Please select a coffee bean');
95
-
return;
96
-
}
97
-
98
-
saving = true;
99
-
error = null;
100
-
101
-
try {
102
-
const payload = {
103
-
...form,
104
-
pours: pours.filter(p => p.Water && p.Time), // Only include completed pours
105
-
};
106
-
107
-
// Convert numeric strings to numbers
108
-
if (payload.coffee_amount) payload.coffee_amount = parseFloat(payload.coffee_amount);
109
-
if (payload.water_amount) payload.water_amount = parseFloat(payload.water_amount);
110
-
if (payload.water_temp) payload.water_temp = parseFloat(payload.water_temp);
111
-
if (payload.brew_time) payload.brew_time = parseFloat(payload.brew_time);
112
-
if (payload.rating) payload.rating = parseInt(payload.rating);
113
-
114
-
if (mode === 'edit') {
115
-
await api.put(`/brews/${id}`, payload);
116
-
} else {
117
-
await api.post('/brews', payload);
118
-
}
119
-
120
-
await cacheStore.invalidate();
121
-
navigate('/brews');
122
-
} catch (err) {
123
-
error = err.message;
124
-
saving = false;
125
-
}
126
-
}
127
-
128
-
// Entity creation handlers
129
-
async function saveBeanModal() {
130
-
try {
131
-
const result = await api.post('/api/beans', beanForm);
132
-
await cacheStore.invalidate();
133
-
form.bean_rkey = result.rkey;
134
-
showBeanModal = false;
135
-
beanForm = { name: '', origin: '', roast_level: '', process: '', description: '', roaster_rkey: '' };
136
-
} catch (err) {
137
-
alert('Failed to create bean: ' + err.message);
138
-
}
139
-
}
140
-
141
-
async function saveRoasterModal() {
142
-
try {
143
-
const result = await api.post('/api/roasters', roasterForm);
144
-
await cacheStore.invalidate();
145
-
beanForm.roaster_rkey = result.rkey;
146
-
showRoasterModal = false;
147
-
roasterForm = { name: '', location: '', website: '', description: '' };
148
-
} catch (err) {
149
-
alert('Failed to create roaster: ' + err.message);
150
-
}
151
-
}
152
-
153
-
async function saveGrinderModal() {
154
-
try {
155
-
const result = await api.post('/api/grinders', grinderForm);
156
-
await cacheStore.invalidate();
157
-
form.grinder_rkey = result.rkey;
158
-
showGrinderModal = false;
159
-
grinderForm = { name: '', grinder_type: '', burr_type: '', notes: '' };
160
-
} catch (err) {
161
-
alert('Failed to create grinder: ' + err.message);
162
-
}
163
-
}
164
-
165
-
async function saveBrewerModal() {
166
-
try {
167
-
const result = await api.post('/api/brewers', brewerForm);
168
-
await cacheStore.invalidate();
169
-
form.brewer_rkey = result.rkey;
170
-
showBrewerModal = false;
171
-
brewerForm = { name: '', brewer_type: '', description: '' };
172
-
} catch (err) {
173
-
alert('Failed to create brewer: ' + err.message);
174
-
}
175
-
}
176
-
</script>
177
-
178
-
<svelte:head>
179
-
<title>{mode === 'edit' ? 'Edit Brew' : 'New Brew'} - Arabica</title>
180
-
</svelte:head>
181
-
182
-
<div class="max-w-2xl mx-auto">
183
-
{#if loading}
184
-
<div class="text-center py-12">
185
-
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-brown-800 mx-auto"></div>
186
-
<p class="mt-4 text-brown-700">Loading...</p>
187
-
</div>
188
-
{:else}
189
-
<div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 border border-brown-300">
190
-
<!-- Header with Back Button -->
191
-
<div class="flex items-center gap-3 mb-6">
192
-
<button
193
-
on:click={() => back()}
194
-
class="inline-flex items-center text-brown-700 hover:text-brown-900 font-medium transition-colors cursor-pointer"
195
-
>
196
-
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
197
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
198
-
</svg>
199
-
</button>
200
-
<h2 class="text-3xl font-bold text-brown-900">
201
-
{mode === 'edit' ? 'Edit Brew' : 'New Brew'}
202
-
</h2>
203
-
</div>
204
-
205
-
{#if error}
206
-
<div class="mb-4 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
207
-
{error}
208
-
</div>
209
-
{/if}
210
-
211
-
<form on:submit|preventDefault={handleSubmit} class="space-y-6">
212
-
<!-- Bean Selection -->
213
-
<div>
214
-
<label class="block text-sm font-medium text-brown-900 mb-2">Coffee Bean *</label>
215
-
<div class="flex gap-2">
216
-
<select
217
-
bind:value={form.bean_rkey}
218
-
required
219
-
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"
220
-
>
221
-
<option value="">Select a bean...</option>
222
-
{#each beans as bean}
223
-
<option value={bean.RKey}>
224
-
{bean.Name || bean.Origin} ({bean.Origin} - {bean.RoastLevel})
225
-
</option>
226
-
{/each}
227
-
</select>
228
-
<button
229
-
type="button"
230
-
on:click={() => showBeanModal = true}
231
-
class="bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"
232
-
>
233
-
+ New
234
-
</button>
235
-
</div>
236
-
</div>
237
-
238
-
<!-- Coffee Amount -->
239
-
<div>
240
-
<label class="block text-sm font-medium text-brown-900 mb-2">Coffee Amount (grams)</label>
241
-
<input
242
-
type="number"
243
-
bind:value={form.coffee_amount}
244
-
step="0.1"
245
-
placeholder="e.g. 18"
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
-
/>
248
-
<p class="text-sm text-brown-700 mt-1">Amount of ground coffee used</p>
249
-
</div>
250
-
251
-
<!-- Grinder -->
252
-
<div>
253
-
<label class="block text-sm font-medium text-brown-900 mb-2">Grinder</label>
254
-
<div class="flex gap-2">
255
-
<select
256
-
bind:value={form.grinder_rkey}
257
-
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"
258
-
>
259
-
<option value="">Select a grinder...</option>
260
-
{#each grinders as grinder}
261
-
<option value={grinder.RKey}>{grinder.Name}</option>
262
-
{/each}
263
-
</select>
264
-
<button
265
-
type="button"
266
-
on:click={() => showGrinderModal = true}
267
-
class="bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"
268
-
>
269
-
+ New
270
-
</button>
271
-
</div>
272
-
</div>
273
-
274
-
<!-- Grind Size -->
275
-
<div>
276
-
<label class="block text-sm font-medium text-brown-900 mb-2">Grind Size</label>
277
-
<input
278
-
type="text"
279
-
bind:value={form.grind_size}
280
-
placeholder="e.g. 18, Medium, 3.5, Fine"
281
-
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"
282
-
/>
283
-
<p class="text-sm text-brown-700 mt-1">Enter a number (grinder setting) or description (e.g. "Medium", "Fine")</p>
284
-
</div>
285
-
286
-
<!-- Brew Method -->
287
-
<div>
288
-
<label class="block text-sm font-medium text-brown-900 mb-2">Brew Method</label>
289
-
<div class="flex gap-2">
290
-
<select
291
-
bind:value={form.brewer_rkey}
292
-
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"
293
-
>
294
-
<option value="">Select brew method...</option>
295
-
{#each brewers as brewer}
296
-
<option value={brewer.RKey}>{brewer.Name}</option>
297
-
{/each}
298
-
</select>
299
-
<button
300
-
type="button"
301
-
on:click={() => showBrewerModal = true}
302
-
class="bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"
303
-
>
304
-
+ New
305
-
</button>
306
-
</div>
307
-
</div>
308
-
309
-
<!-- Water Amount -->
310
-
<div>
311
-
<label class="block text-sm font-medium text-brown-900 mb-2">Water Amount (ml)</label>
312
-
<input
313
-
type="number"
314
-
bind:value={form.water_amount}
315
-
step="1"
316
-
placeholder="e.g. 300"
317
-
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"
318
-
/>
319
-
</div>
320
-
321
-
<!-- Water Temperature -->
322
-
<div>
323
-
<label class="block text-sm font-medium text-brown-900 mb-2">Water Temperature (°C)</label>
324
-
<input
325
-
type="number"
326
-
bind:value={form.water_temp}
327
-
step="0.1"
328
-
placeholder="e.g. 93"
329
-
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"
330
-
/>
331
-
</div>
332
-
333
-
<!-- Brew Time -->
334
-
<div>
335
-
<label class="block text-sm font-medium text-brown-900 mb-2">Total Brew Time (seconds)</label>
336
-
<input
337
-
type="number"
338
-
bind:value={form.brew_time}
339
-
step="1"
340
-
placeholder="e.g. 210"
341
-
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"
342
-
/>
343
-
</div>
344
-
345
-
<!-- Pours -->
346
-
<div>
347
-
<div class="flex items-center justify-between mb-2">
348
-
<label class="block text-sm font-medium text-brown-900">Pour Schedule (Optional)</label>
349
-
<button
350
-
type="button"
351
-
on:click={addPour}
352
-
class="text-sm bg-brown-300 text-brown-900 px-3 py-1 rounded hover:bg-brown-400 font-medium transition-colors"
353
-
>
354
-
+ Add Pour
355
-
</button>
356
-
</div>
357
-
358
-
{#if pours.length > 0}
359
-
<div class="space-y-2">
360
-
{#each pours as pour, i}
361
-
<div class="flex gap-2 items-center bg-brown-50 p-3 rounded-lg border border-brown-200">
362
-
<span class="text-sm font-medium text-brown-700 min-w-[60px]">Pour {i + 1}:</span>
363
-
<input
364
-
type="number"
365
-
bind:value={pour.Water}
366
-
placeholder="Water (g)"
367
-
class="flex-1 rounded border border-brown-300 px-3 py-2 text-sm"
368
-
/>
369
-
<input
370
-
type="number"
371
-
bind:value={pour.Time}
372
-
placeholder="Time (s)"
373
-
class="flex-1 rounded border border-brown-300 px-3 py-2 text-sm"
374
-
/>
375
-
<button
376
-
type="button"
377
-
on:click={() => removePour(i)}
378
-
class="text-red-600 hover:text-red-800 font-medium px-2"
379
-
>
380
-
✕
381
-
</button>
382
-
</div>
383
-
{/each}
384
-
</div>
385
-
{/if}
386
-
</div>
387
-
388
-
<!-- Rating -->
389
-
<div>
390
-
<label class="block text-sm font-medium text-brown-900 mb-2">
391
-
Rating: <span class="font-bold">{form.rating}/10</span>
392
-
</label>
393
-
<input
394
-
type="range"
395
-
bind:value={form.rating}
396
-
min="0"
397
-
max="10"
398
-
step="1"
399
-
class="w-full h-2 bg-brown-200 rounded-lg appearance-none cursor-pointer accent-brown-700"
400
-
/>
401
-
<div class="flex justify-between text-xs text-brown-600 mt-1">
402
-
<span>0</span>
403
-
<span>10</span>
404
-
</div>
405
-
</div>
406
-
407
-
<!-- Notes -->
408
-
<div>
409
-
<label class="block text-sm font-medium text-brown-900 mb-2">Tasting Notes</label>
410
-
<textarea
411
-
bind:value={form.notes}
412
-
rows="4"
413
-
placeholder="Describe the flavor, aroma, body, etc."
414
-
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"
415
-
></textarea>
416
-
</div>
417
-
418
-
<!-- Submit Button -->
419
-
<div class="flex gap-3">
420
-
<button
421
-
type="submit"
422
-
disabled={saving}
423
-
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"
424
-
>
425
-
{saving ? 'Saving...' : mode === 'edit' ? 'Update Brew' : 'Save Brew'}
426
-
</button>
427
-
<button
428
-
type="button"
429
-
on:click={() => back()}
430
-
class="px-6 py-3 border-2 border-brown-300 text-brown-700 rounded-lg hover:bg-brown-100 font-semibold transition-colors"
431
-
>
432
-
Cancel
433
-
</button>
434
-
</div>
435
-
</form>
436
-
</div>
437
-
{/if}
438
-
</div>
439
-
440
-
<!-- Modals -->
441
-
<Modal
442
-
bind:isOpen={showBeanModal}
443
-
title="Add New Bean"
444
-
onSave={saveBeanModal}
445
-
onCancel={() => showBeanModal = false}
446
-
>
447
-
<div class="space-y-4">
448
-
<div>
449
-
<label class="block text-sm font-medium text-gray-700 mb-1">Name</label>
450
-
<input type="text" bind:value={beanForm.name} class="w-full rounded border-gray-300 px-3 py-2" />
451
-
</div>
452
-
<div>
453
-
<label class="block text-sm font-medium text-gray-700 mb-1">Origin *</label>
454
-
<input type="text" bind:value={beanForm.origin} required class="w-full rounded border-gray-300 px-3 py-2" />
455
-
</div>
456
-
<div>
457
-
<label class="block text-sm font-medium text-gray-700 mb-1">Roast Level *</label>
458
-
<select bind:value={beanForm.roast_level} required class="w-full rounded border-gray-300 px-3 py-2">
459
-
<option value="">Select...</option>
460
-
<option value="Light">Light</option>
461
-
<option value="Medium-Light">Medium-Light</option>
462
-
<option value="Medium">Medium</option>
463
-
<option value="Medium-Dark">Medium-Dark</option>
464
-
<option value="Dark">Dark</option>
465
-
</select>
466
-
</div>
467
-
<div>
468
-
<label class="block text-sm font-medium text-gray-700 mb-1">Roaster</label>
469
-
<div class="flex gap-2">
470
-
<select bind:value={beanForm.roaster_rkey} class="flex-1 rounded border-gray-300 px-3 py-2">
471
-
<option value="">Select...</option>
472
-
{#each roasters as roaster}
473
-
<option value={roaster.RKey}>{roaster.Name}</option>
474
-
{/each}
475
-
</select>
476
-
<button
477
-
type="button"
478
-
on:click={() => showRoasterModal = true}
479
-
class="bg-gray-200 px-3 py-1 rounded hover:bg-gray-300 text-sm"
480
-
>
481
-
+ New
482
-
</button>
483
-
</div>
484
-
</div>
485
-
</div>
486
-
</Modal>
487
-
488
-
<Modal
489
-
bind:isOpen={showRoasterModal}
490
-
title="Add New Roaster"
491
-
onSave={saveRoasterModal}
492
-
onCancel={() => showRoasterModal = false}
493
-
>
494
-
<div class="space-y-4">
495
-
<div>
496
-
<label class="block text-sm font-medium text-gray-700 mb-1">Name *</label>
497
-
<input type="text" bind:value={roasterForm.name} required class="w-full rounded border-gray-300 px-3 py-2" />
498
-
</div>
499
-
<div>
500
-
<label class="block text-sm font-medium text-gray-700 mb-1">Location</label>
501
-
<input type="text" bind:value={roasterForm.location} class="w-full rounded border-gray-300 px-3 py-2" />
502
-
</div>
503
-
</div>
504
-
</Modal>
505
-
506
-
<Modal
507
-
bind:isOpen={showGrinderModal}
508
-
title="Add New Grinder"
509
-
onSave={saveGrinderModal}
510
-
onCancel={() => showGrinderModal = false}
511
-
>
512
-
<div class="space-y-4">
513
-
<div>
514
-
<label class="block text-sm font-medium text-gray-700 mb-1">Name *</label>
515
-
<input type="text" bind:value={grinderForm.name} required class="w-full rounded border-gray-300 px-3 py-2" />
516
-
</div>
517
-
<div>
518
-
<label class="block text-sm font-medium text-gray-700 mb-1">Type</label>
519
-
<select bind:value={grinderForm.grinder_type} class="w-full rounded border-gray-300 px-3 py-2">
520
-
<option value="">Select...</option>
521
-
<option value="Manual">Manual</option>
522
-
<option value="Electric">Electric</option>
523
-
<option value="Blade">Blade</option>
524
-
</select>
525
-
</div>
526
-
</div>
527
-
</Modal>
528
-
529
-
<Modal
530
-
bind:isOpen={showBrewerModal}
531
-
title="Add New Brewer"
532
-
onSave={saveBrewerModal}
533
-
onCancel={() => showBrewerModal = false}
534
-
>
535
-
<div class="space-y-4">
536
-
<div>
537
-
<label class="block text-sm font-medium text-gray-700 mb-1">Name *</label>
538
-
<input type="text" bind:value={brewerForm.name} required class="w-full rounded border-gray-300 px-3 py-2" />
539
-
</div>
540
-
<div>
541
-
<label class="block text-sm font-medium text-gray-700 mb-1">Type</label>
542
-
<select bind:value={brewerForm.brewer_type} class="w-full rounded border-gray-300 px-3 py-2">
543
-
<option value="">Select...</option>
544
-
<option value="Pour Over">Pour Over</option>
545
-
<option value="French Press">French Press</option>
546
-
<option value="Espresso">Espresso</option>
547
-
<option value="Moka Pot">Moka Pot</option>
548
-
<option value="Aeropress">Aeropress</option>
549
-
<option value="Cold Brew">Cold Brew</option>
550
-
<option value="Siphon">Siphon</option>
551
-
</select>
552
-
</div>
553
-
</div>
554
-
</Modal>
+134
-63
frontend/src/routes/BrewView.svelte
+134
-63
frontend/src/routes/BrewView.svelte
···
1
1
<script>
2
-
import { onMount } from 'svelte';
3
-
import { authStore } from '../stores/auth.js';
4
-
import { cacheStore } from '../stores/cache.js';
5
-
import { navigate, back } from '../lib/router.js';
6
-
import { api } from '../lib/api.js';
7
-
2
+
import { onMount } from "svelte";
3
+
import { authStore } from "../stores/auth.js";
4
+
import { cacheStore } from "../stores/cache.js";
5
+
import { navigate, back } from "../lib/router.js";
6
+
import { api } from "../lib/api.js";
7
+
8
8
export let id = null; // RKey from route (for own brews)
9
9
export let did = null; // DID from route (for other users' brews)
10
10
export let rkey = null; // RKey from route (for other users' brews)
11
-
11
+
12
12
let brew = null;
13
13
let loading = true;
14
14
let error = null;
15
15
let isOwnProfile = false;
16
-
16
+
17
17
$: isAuthenticated = $authStore.isAuthenticated;
18
18
$: currentUserDID = $authStore.user?.did;
19
-
19
+
20
20
// Calculate total water from pours if water_amount is 0
21
-
$: totalWater = brew && (brew.water_amount || 0) === 0 && brew.pours && brew.pours.length > 0
22
-
? brew.pours.reduce((sum, pour) => sum + (pour.water_amount || 0), 0)
23
-
: brew?.water_amount || 0;
24
-
21
+
$: totalWater =
22
+
brew &&
23
+
(brew.water_amount || 0) === 0 &&
24
+
brew.pours &&
25
+
brew.pours.length > 0
26
+
? brew.pours.reduce((sum, pour) => sum + (pour.water_amount || 0), 0)
27
+
: brew?.water_amount || 0;
28
+
25
29
onMount(async () => {
26
30
if (!isAuthenticated) {
27
-
navigate('/login');
31
+
navigate("/login");
28
32
return;
29
33
}
30
-
34
+
31
35
// Determine if viewing own brew or someone else's
32
36
if (did && rkey) {
33
37
// Viewing another user's brew
···
38
42
isOwnProfile = true;
39
43
await loadBrewFromCache(id);
40
44
}
41
-
45
+
42
46
loading = false;
43
47
});
44
-
48
+
45
49
async function loadBrewFromCache(brewRKey) {
46
50
await cacheStore.load();
47
51
const brews = $cacheStore.brews || [];
48
-
brew = brews.find(b => b.rkey === brewRKey);
52
+
brew = brews.find((b) => b.rkey === brewRKey);
49
53
if (!brew) {
50
-
error = 'Brew not found';
54
+
error = "Brew not found";
51
55
}
52
56
}
53
-
57
+
54
58
async function loadBrewFromAPI(userDID, brewRKey) {
55
59
try {
56
60
// Fetch brew from API using AT-URI
57
61
const atURI = `at://${userDID}/social.arabica.alpha.brew/${brewRKey}`;
58
62
brew = await api.get(`/api/brew?uri=${encodeURIComponent(atURI)}`);
59
63
} catch (err) {
60
-
console.error('Failed to load brew:', err);
61
-
error = err.message || 'Failed to load brew';
64
+
console.error("Failed to load brew:", err);
65
+
error = err.message || "Failed to load brew";
62
66
}
63
67
}
64
-
68
+
65
69
async function deleteBrew() {
66
-
if (!confirm('Are you sure you want to delete this brew?')) {
70
+
if (!confirm("Are you sure you want to delete this brew?")) {
67
71
return;
68
72
}
69
-
73
+
70
74
const brewRKey = rkey || id;
71
75
if (!brewRKey) {
72
-
alert('Cannot delete brew: missing ID');
76
+
alert("Cannot delete brew: missing ID");
73
77
return;
74
78
}
75
-
79
+
76
80
try {
77
81
await api.delete(`/brews/${brewRKey}`);
78
82
await cacheStore.invalidate();
79
-
navigate('/brews');
83
+
navigate("/brews");
80
84
} catch (err) {
81
-
alert('Failed to delete brew: ' + err.message);
85
+
alert("Failed to delete brew: " + err.message);
82
86
}
83
87
}
84
-
88
+
85
89
function hasValue(val) {
86
-
return val !== null && val !== undefined && val !== '';
90
+
return val !== null && val !== undefined && val !== "";
91
+
}
92
+
93
+
function formatTemperature(temp) {
94
+
if (!hasValue(temp)) return null;
95
+
const unit = temp <= 100 ? "C" : "F";
96
+
return `${temp}°${unit}`;
87
97
}
88
-
98
+
89
99
function formatDate(dateStr) {
90
-
if (!dateStr) return '';
100
+
if (!dateStr) return "";
91
101
const date = new Date(dateStr);
92
-
return date.toLocaleDateString('en-US', {
93
-
year: 'numeric',
94
-
month: 'long',
95
-
day: 'numeric',
96
-
hour: 'numeric',
97
-
minute: '2-digit'
102
+
return date.toLocaleDateString("en-US", {
103
+
year: "numeric",
104
+
month: "long",
105
+
day: "numeric",
106
+
hour: "numeric",
107
+
minute: "2-digit",
98
108
});
99
109
}
100
110
</script>
···
106
116
<div class="max-w-2xl mx-auto">
107
117
{#if loading}
108
118
<div class="text-center py-12">
109
-
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-brown-800 mx-auto"></div>
119
+
<div
120
+
class="animate-spin rounded-full h-12 w-12 border-b-2 border-brown-800 mx-auto"
121
+
></div>
110
122
<p class="mt-4 text-brown-700">Loading brew...</p>
111
123
</div>
112
124
{:else if !brew}
113
-
<div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl p-12 text-center border border-brown-300">
125
+
<div
126
+
class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl p-12 text-center border border-brown-300"
127
+
>
114
128
<h2 class="text-2xl font-bold text-brown-900 mb-2">Brew Not Found</h2>
115
-
<p class="text-brown-700 mb-6">The brew you're looking for doesn't exist.</p>
129
+
<p class="text-brown-700 mb-6">
130
+
The brew you're looking for doesn't exist.
131
+
</p>
116
132
<button
117
-
on:click={() => navigate('/brews')}
133
+
on:click={() => navigate("/brews")}
118
134
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"
119
135
>
120
136
Back to Brews
121
137
</button>
122
138
</div>
123
139
{:else}
124
-
<div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 border border-brown-300">
140
+
<div
141
+
class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 border border-brown-300"
142
+
>
125
143
<!-- Header with title and actions -->
126
144
<div class="flex justify-between items-start mb-6">
127
145
<div>
128
146
<h2 class="text-3xl font-bold text-brown-900">Brew Details</h2>
129
-
<p class="text-sm text-brown-600 mt-1">{formatDate(brew.created_at)}</p>
147
+
<p class="text-sm text-brown-600 mt-1">
148
+
{formatDate(brew.created_at)}
149
+
</p>
130
150
</div>
131
151
{#if isOwnProfile}
132
152
<div class="flex gap-2">
133
153
<button
134
-
on:click={() => navigate(`/brews/${rkey || id || brew.rkey}/edit`)}
154
+
on:click={() =>
155
+
navigate(`/brews/${rkey || id || brew.rkey}/edit`)}
135
156
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"
136
157
>
137
158
Edit
···
149
170
<div class="space-y-6">
150
171
<!-- Rating (prominent at top) -->
151
172
{#if hasValue(brew.rating)}
152
-
<div class="text-center py-4 bg-brown-50 rounded-lg border border-brown-200">
173
+
<div
174
+
class="text-center py-4 bg-brown-50 rounded-lg border border-brown-200"
175
+
>
153
176
<div class="text-4xl font-bold text-brown-800">
154
177
{brew.rating}/10
155
178
</div>
···
159
182
160
183
<!-- Coffee Bean -->
161
184
<div class="bg-brown-50 rounded-lg p-4 border border-brown-200">
162
-
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">Coffee Bean</h3>
185
+
<h3
186
+
class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2"
187
+
>
188
+
Coffee Bean
189
+
</h3>
163
190
{#if brew.bean}
164
191
<div class="font-bold text-lg text-brown-900">
165
192
{brew.bean.name || brew.bean.origin}
···
171
198
{/if}
172
199
<div class="flex flex-wrap gap-3 mt-2 text-sm text-brown-600">
173
200
{#if brew.bean.origin}<span>Origin: {brew.bean.origin}</span>{/if}
174
-
{#if brew.bean.roast_level}<span>Roast: {brew.bean.roast_level}</span>{/if}
201
+
{#if brew.bean.roast_level}<span
202
+
>Roast: {brew.bean.roast_level}</span
203
+
>{/if}
175
204
</div>
176
205
{:else}
177
206
<span class="text-brown-400">Not specified</span>
···
182
211
<div class="grid grid-cols-2 gap-4">
183
212
<!-- Brew Method -->
184
213
<div class="bg-brown-50 rounded-lg p-4 border border-brown-200">
185
-
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">Brew Method</h3>
214
+
<h3
215
+
class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2"
216
+
>
217
+
Brew Method
218
+
</h3>
186
219
{#if brew.brewer_obj}
187
-
<div class="font-semibold text-brown-900">{brew.brewer_obj.name}</div>
220
+
<div class="font-semibold text-brown-900">
221
+
{brew.brewer_obj.name}
222
+
</div>
188
223
{:else if brew.method}
189
224
<div class="font-semibold text-brown-900">{brew.method}</div>
190
225
{:else}
···
194
229
195
230
<!-- Grinder -->
196
231
<div class="bg-brown-50 rounded-lg p-4 border border-brown-200">
197
-
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">Grinder</h3>
232
+
<h3
233
+
class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2"
234
+
>
235
+
Grinder
236
+
</h3>
198
237
{#if brew.grinder_obj}
199
-
<div class="font-semibold text-brown-900">{brew.grinder_obj.name}</div>
238
+
<div class="font-semibold text-brown-900">
239
+
{brew.grinder_obj.name}
240
+
</div>
200
241
{:else}
201
242
<span class="text-brown-400">Not specified</span>
202
243
{/if}
···
204
245
205
246
<!-- Coffee Amount -->
206
247
<div class="bg-brown-50 rounded-lg p-4 border border-brown-200">
207
-
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">Coffee</h3>
248
+
<h3
249
+
class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2"
250
+
>
251
+
Coffee
252
+
</h3>
208
253
{#if hasValue(brew.coffee_amount)}
209
-
<div class="font-semibold text-brown-900">{brew.coffee_amount}g</div>
254
+
<div class="font-semibold text-brown-900">
255
+
{brew.coffee_amount}g
256
+
</div>
210
257
{:else}
211
258
<span class="text-brown-400">Not specified</span>
212
259
{/if}
···
214
261
215
262
<!-- Water Amount -->
216
263
<div class="bg-brown-50 rounded-lg p-4 border border-brown-200">
217
-
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">Water</h3>
264
+
<h3
265
+
class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2"
266
+
>
267
+
Water
268
+
</h3>
218
269
{#if hasValue(totalWater)}
219
270
<div class="font-semibold text-brown-900">{totalWater}g</div>
220
271
{:else}
···
224
275
225
276
<!-- Grind Size -->
226
277
<div class="bg-brown-50 rounded-lg p-4 border border-brown-200">
227
-
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">Grind Size</h3>
278
+
<h3
279
+
class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2"
280
+
>
281
+
Grind Size
282
+
</h3>
228
283
{#if brew.grind_size}
229
284
<div class="font-semibold text-brown-900">{brew.grind_size}</div>
230
285
{:else}
···
234
289
235
290
<!-- Water Temperature -->
236
291
<div class="bg-brown-50 rounded-lg p-4 border border-brown-200">
237
-
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">Water Temp</h3>
292
+
<h3
293
+
class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2"
294
+
>
295
+
Water Temp
296
+
</h3>
238
297
{#if hasValue(brew.temperature)}
239
-
<div class="font-semibold text-brown-900">{brew.temperature}°C</div>
298
+
<div class="font-semibold text-brown-900">
299
+
{formatTemperature(brew.temperature)}
300
+
</div>
240
301
{:else}
241
302
<span class="text-brown-400">Not specified</span>
242
303
{/if}
···
246
307
<!-- Pours (if any) -->
247
308
{#if brew.pours && brew.pours.length > 0}
248
309
<div class="bg-brown-50 rounded-lg p-4 border border-brown-200">
249
-
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-3">Pour Schedule</h3>
310
+
<h3
311
+
class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-3"
312
+
>
313
+
Pour Schedule
314
+
</h3>
250
315
<div class="space-y-2">
251
316
{#each brew.pours as pour, i}
252
317
<div class="flex justify-between text-sm">
253
318
<span class="text-brown-700">Pour {i + 1}:</span>
254
-
<span class="font-semibold text-brown-900">{pour.water_amount}g at {pour.time_seconds}s</span>
319
+
<span class="font-semibold text-brown-900"
320
+
>{pour.water_amount}g at {pour.time_seconds}s</span
321
+
>
255
322
</div>
256
323
{/each}
257
324
</div>
···
261
328
<!-- Tasting Notes -->
262
329
{#if brew.tasting_notes}
263
330
<div class="bg-brown-50 rounded-lg p-4 border border-brown-200">
264
-
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">Tasting Notes</h3>
331
+
<h3
332
+
class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2"
333
+
>
334
+
Tasting Notes
335
+
</h3>
265
336
<p class="text-brown-900 italic">"{brew.tasting_notes}"</p>
266
337
</div>
267
338
{/if}
···
270
341
<!-- Back button -->
271
342
<div class="mt-6">
272
343
<button
273
-
on:click={() => navigate('/brews')}
344
+
on:click={() => navigate("/brews")}
274
345
class="text-brown-700 hover:text-brown-900 font-medium hover:underline"
275
346
>
276
347
← Back to Brews
-241
frontend/src/routes/BrewView.svelte.backup
-241
frontend/src/routes/BrewView.svelte.backup
···
1
-
<script>
2
-
import { onMount } from 'svelte';
3
-
import { authStore } from '../stores/auth.js';
4
-
import { cacheStore } from '../stores/cache.js';
5
-
import { navigate } from '../lib/router.js';
6
-
import { api } from '../lib/api.js';
7
-
8
-
export let id; // RKey from route
9
-
10
-
let brew = null;
11
-
let loading = true;
12
-
let error = null;
13
-
let isOwnProfile = false;
14
-
15
-
$: isAuthenticated = $authStore.isAuthenticated;
16
-
17
-
onMount(async () => {
18
-
if (!isAuthenticated) {
19
-
navigate('/login');
20
-
return;
21
-
}
22
-
23
-
// Load from cache first
24
-
await cacheStore.load();
25
-
const brews = $cacheStore.brews || [];
26
-
brew = brews.find(b => b.RKey === id);
27
-
loading = false;
28
-
isOwnProfile = true; // Currently viewing own profile
29
-
});
30
-
31
-
async function deleteBrew() {
32
-
if (!confirm('Are you sure you want to delete this brew?')) {
33
-
return;
34
-
}
35
-
36
-
try {
37
-
await api.delete(`/brews/${id}`);
38
-
await cacheStore.invalidate();
39
-
navigate('/brews');
40
-
} catch (err) {
41
-
alert('Failed to delete brew: ' + err.message);
42
-
}
43
-
}
44
-
45
-
function hasValue(val) {
46
-
return val !== null && val !== undefined && val !== '';
47
-
}
48
-
49
-
function formatDate(dateStr) {
50
-
if (!dateStr) return '';
51
-
const date = new Date(dateStr);
52
-
return date.toLocaleDateString('en-US', {
53
-
year: 'numeric',
54
-
month: 'long',
55
-
day: 'numeric',
56
-
hour: 'numeric',
57
-
minute: '2-digit'
58
-
});
59
-
}
60
-
</script>
61
-
62
-
<svelte:head>
63
-
<title>Brew Details - Arabica</title>
64
-
</svelte:head>
65
-
66
-
<div class="max-w-2xl mx-auto">
67
-
{#if loading}
68
-
<div class="text-center py-12">
69
-
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-brown-800 mx-auto"></div>
70
-
<p class="mt-4 text-brown-700">Loading brew...</p>
71
-
</div>
72
-
{:else if !brew}
73
-
<div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl p-12 text-center border border-brown-300">
74
-
<h2 class="text-2xl font-bold text-brown-900 mb-2">Brew Not Found</h2>
75
-
<p class="text-brown-700 mb-6">The brew you're looking for doesn't exist.</p>
76
-
<button
77
-
on:click={() => navigate('/brews')}
78
-
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"
79
-
>
80
-
Back to Brews
81
-
</button>
82
-
</div>
83
-
{:else}
84
-
<div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 border border-brown-300">
85
-
<!-- Header with title and actions -->
86
-
<div class="flex justify-between items-start mb-6">
87
-
<div>
88
-
<h2 class="text-3xl font-bold text-brown-900">Brew Details</h2>
89
-
<p class="text-sm text-brown-600 mt-1">{formatDate(brew.CreatedAt)}</p>
90
-
</div>
91
-
{#if isOwnProfile}
92
-
<div class="flex gap-2">
93
-
<button
94
-
on:click={() => navigate(`/brews/${brew.RKey}/edit`)}
95
-
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"
96
-
>
97
-
Edit
98
-
</button>
99
-
<button
100
-
on:click={deleteBrew}
101
-
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"
102
-
>
103
-
Delete
104
-
</button>
105
-
</div>
106
-
{/if}
107
-
</div>
108
-
109
-
<div class="space-y-6">
110
-
<!-- Rating (prominent at top) -->
111
-
{#if hasValue(brew.Rating)}
112
-
<div class="text-center py-4 bg-brown-50 rounded-lg border border-brown-200">
113
-
<div class="text-4xl font-bold text-brown-800">
114
-
{brew.Rating}/10
115
-
</div>
116
-
<div class="text-sm text-brown-600 mt-1">Rating</div>
117
-
</div>
118
-
{/if}
119
-
120
-
<!-- Coffee Bean -->
121
-
<div class="bg-brown-50 rounded-lg p-4 border border-brown-200">
122
-
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">Coffee Bean</h3>
123
-
{#if brew.Bean}
124
-
<div class="font-bold text-lg text-brown-900">
125
-
{brew.Bean.Name || brew.Bean.Origin}
126
-
</div>
127
-
{#if brew.Bean.Roaster?.Name}
128
-
<div class="text-sm text-brown-700 mt-1">
129
-
by {brew.Bean.Roaster.Name}
130
-
</div>
131
-
{/if}
132
-
<div class="flex flex-wrap gap-3 mt-2 text-sm text-brown-600">
133
-
{#if brew.Bean.Origin}<span>Origin: {brew.Bean.Origin}</span>{/if}
134
-
{#if brew.Bean.RoastLevel}<span>Roast: {brew.Bean.RoastLevel}</span>{/if}
135
-
</div>
136
-
{:else}
137
-
<span class="text-brown-400">Not specified</span>
138
-
{/if}
139
-
</div>
140
-
141
-
<!-- Brew Parameters -->
142
-
<div class="grid grid-cols-2 gap-4">
143
-
<!-- Brew Method -->
144
-
<div class="bg-brown-50 rounded-lg p-4 border border-brown-200">
145
-
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">Brew Method</h3>
146
-
{#if brew.BrewerObj}
147
-
<div class="font-semibold text-brown-900">{brew.BrewerObj.Name}</div>
148
-
{:else if brew.Method}
149
-
<div class="font-semibold text-brown-900">{brew.Method}</div>
150
-
{:else}
151
-
<span class="text-brown-400">Not specified</span>
152
-
{/if}
153
-
</div>
154
-
155
-
<!-- Grinder -->
156
-
<div class="bg-brown-50 rounded-lg p-4 border border-brown-200">
157
-
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">Grinder</h3>
158
-
{#if brew.GrinderObj}
159
-
<div class="font-semibold text-brown-900">{brew.GrinderObj.Name}</div>
160
-
{:else}
161
-
<span class="text-brown-400">Not specified</span>
162
-
{/if}
163
-
</div>
164
-
165
-
<!-- Coffee Amount -->
166
-
<div class="bg-brown-50 rounded-lg p-4 border border-brown-200">
167
-
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">Coffee</h3>
168
-
{#if hasValue(brew.CoffeeAmount)}
169
-
<div class="font-semibold text-brown-900">{brew.CoffeeAmount}g</div>
170
-
{:else}
171
-
<span class="text-brown-400">Not specified</span>
172
-
{/if}
173
-
</div>
174
-
175
-
<!-- Water Amount -->
176
-
<div class="bg-brown-50 rounded-lg p-4 border border-brown-200">
177
-
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">Water</h3>
178
-
{#if hasValue(brew.WaterAmount)}
179
-
<div class="font-semibold text-brown-900">{brew.WaterAmount}g</div>
180
-
{:else}
181
-
<span class="text-brown-400">Not specified</span>
182
-
{/if}
183
-
</div>
184
-
185
-
<!-- Grind Size -->
186
-
<div class="bg-brown-50 rounded-lg p-4 border border-brown-200">
187
-
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">Grind Size</h3>
188
-
{#if brew.GrindSize}
189
-
<div class="font-semibold text-brown-900">{brew.GrindSize}</div>
190
-
{:else}
191
-
<span class="text-brown-400">Not specified</span>
192
-
{/if}
193
-
</div>
194
-
195
-
<!-- Water Temperature -->
196
-
<div class="bg-brown-50 rounded-lg p-4 border border-brown-200">
197
-
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">Water Temp</h3>
198
-
{#if hasValue(brew.WaterTemp)}
199
-
<div class="font-semibold text-brown-900">{brew.WaterTemp}°C</div>
200
-
{:else}
201
-
<span class="text-brown-400">Not specified</span>
202
-
{/if}
203
-
</div>
204
-
</div>
205
-
206
-
<!-- Pours (if any) -->
207
-
{#if brew.Pours && brew.Pours.length > 0}
208
-
<div class="bg-brown-50 rounded-lg p-4 border border-brown-200">
209
-
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-3">Pour Schedule</h3>
210
-
<div class="space-y-2">
211
-
{#each brew.Pours as pour, i}
212
-
<div class="flex justify-between text-sm">
213
-
<span class="text-brown-700">Pour {i + 1}:</span>
214
-
<span class="font-semibold text-brown-900">{pour.Water}g at {pour.Time}s</span>
215
-
</div>
216
-
{/each}
217
-
</div>
218
-
</div>
219
-
{/if}
220
-
221
-
<!-- Tasting Notes -->
222
-
{#if brew.Notes}
223
-
<div class="bg-brown-50 rounded-lg p-4 border border-brown-200">
224
-
<h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">Tasting Notes</h3>
225
-
<p class="text-brown-900 italic">"{brew.Notes}"</p>
226
-
</div>
227
-
{/if}
228
-
</div>
229
-
230
-
<!-- Back button -->
231
-
<div class="mt-6">
232
-
<button
233
-
on:click={() => navigate('/brews')}
234
-
class="text-brown-700 hover:text-brown-900 font-medium hover:underline"
235
-
>
236
-
← Back to Brews
237
-
</button>
238
-
</div>
239
-
</div>
240
-
{/if}
241
-
</div>
+77
-48
frontend/src/routes/Brews.svelte
+77
-48
frontend/src/routes/Brews.svelte
···
1
1
<script>
2
-
import { onMount } from 'svelte';
3
-
import { authStore } from '../stores/auth.js';
4
-
import { cacheStore } from '../stores/cache.js';
5
-
import { navigate } from '../lib/router.js';
6
-
import { api } from '../lib/api.js';
7
-
2
+
import { onMount } from "svelte";
3
+
import { authStore } from "../stores/auth.js";
4
+
import { cacheStore } from "../stores/cache.js";
5
+
import { navigate } from "../lib/router.js";
6
+
import { api } from "../lib/api.js";
7
+
8
8
let brews = [];
9
9
let loading = true;
10
10
let deleting = null; // Track which brew is being deleted
11
-
11
+
12
12
$: isAuthenticated = $authStore.isAuthenticated;
13
-
13
+
14
14
onMount(async () => {
15
15
if (!isAuthenticated) {
16
-
navigate('/login');
16
+
navigate("/login");
17
17
return;
18
18
}
19
-
19
+
20
20
await cacheStore.load();
21
21
brews = $cacheStore.brews || [];
22
22
loading = false;
23
23
});
24
-
24
+
25
25
function formatDate(dateStr) {
26
-
if (!dateStr) return '';
26
+
if (!dateStr) return "";
27
27
const date = new Date(dateStr);
28
-
return date.toLocaleDateString('en-US', {
29
-
year: 'numeric',
30
-
month: 'short',
31
-
day: 'numeric'
28
+
return date.toLocaleDateString("en-US", {
29
+
year: "numeric",
30
+
month: "short",
31
+
day: "numeric",
32
32
});
33
33
}
34
-
34
+
35
35
function hasValue(val) {
36
-
return val !== null && val !== undefined && val !== '';
36
+
return val !== null && val !== undefined && val !== "";
37
37
}
38
-
38
+
39
+
function formatTemperature(temp) {
40
+
if (!hasValue(temp)) return null;
41
+
const unit = temp <= 100 ? "C" : "F";
42
+
return `${temp}°${unit}`;
43
+
}
44
+
39
45
function getWaterDisplay(brew) {
40
46
if (hasValue(brew.water_amount) && brew.water_amount > 0) {
41
47
return `💧 ${brew.water_amount}ml water`;
42
48
}
43
-
49
+
44
50
// If water_amount is 0 or not set, sum from pours
45
51
if (brew.pours && brew.pours.length > 0) {
46
-
const totalWater = brew.pours.reduce((sum, pour) => sum + (pour.water_amount || 0), 0);
52
+
const totalWater = brew.pours.reduce(
53
+
(sum, pour) => sum + (pour.water_amount || 0),
54
+
0,
55
+
);
47
56
const pourCount = brew.pours.length;
48
-
return `💧 ${totalWater}ml water (${pourCount} pour${pourCount !== 1 ? 's' : ''})`;
57
+
return `💧 ${totalWater}ml water (${pourCount} pour${pourCount !== 1 ? "s" : ""})`;
49
58
}
50
-
59
+
51
60
return null;
52
61
}
53
-
62
+
54
63
async function deleteBrew(rkey) {
55
-
if (!confirm('Are you sure you want to delete this brew?')) {
64
+
if (!confirm("Are you sure you want to delete this brew?")) {
56
65
return;
57
66
}
58
-
67
+
59
68
deleting = rkey;
60
69
try {
61
70
await api.delete(`/brews/${rkey}`);
62
71
await cacheStore.invalidate();
63
72
brews = $cacheStore.brews || [];
64
73
} catch (err) {
65
-
alert('Failed to delete brew: ' + err.message);
74
+
alert("Failed to delete brew: " + err.message);
66
75
} finally {
67
76
deleting = null;
68
77
}
···
78
87
<h1 class="text-3xl font-bold text-brown-900">My Brews</h1>
79
88
<a
80
89
href="/brews/new"
81
-
on:click|preventDefault={() => navigate('/brews/new')}
90
+
on:click|preventDefault={() => navigate("/brews/new")}
82
91
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"
83
92
>
84
93
☕ Add New Brew
···
87
96
88
97
{#if loading}
89
98
<div class="text-center py-12">
90
-
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-brown-800 mx-auto"></div>
99
+
<div
100
+
class="animate-spin rounded-full h-12 w-12 border-b-2 border-brown-800 mx-auto"
101
+
></div>
91
102
<p class="mt-4 text-brown-700">Loading brews...</p>
92
103
</div>
93
104
{:else if brews.length === 0}
94
-
<div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl p-12 text-center border border-brown-300">
105
+
<div
106
+
class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl p-12 text-center border border-brown-300"
107
+
>
95
108
<div class="text-6xl mb-4">☕</div>
96
109
<h2 class="text-2xl font-bold text-brown-900 mb-2">No Brews Yet</h2>
97
-
<p class="text-brown-700 mb-6">Start tracking your coffee journey by adding your first brew!</p>
110
+
<p class="text-brown-700 mb-6">
111
+
Start tracking your coffee journey by adding your first brew!
112
+
</p>
98
113
<button
99
-
on:click={() => navigate('/brews/new')}
114
+
on:click={() => navigate("/brews/new")}
100
115
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"
101
116
>
102
117
Add Your First Brew
···
105
120
{:else}
106
121
<div class="space-y-4">
107
122
{#each brews as brew}
108
-
<div 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">
123
+
<div
124
+
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"
125
+
>
109
126
<div class="flex items-start justify-between gap-4">
110
127
<div class="flex-1 min-w-0">
111
128
<!-- Bean info -->
112
129
{#if brew.bean}
113
130
<h3 class="text-xl font-bold text-brown-900 mb-1">
114
-
{brew.bean.name || brew.bean.origin || 'Unknown Bean'}
131
+
{brew.bean.name || brew.bean.origin || "Unknown Bean"}
115
132
</h3>
116
133
{#if brew.bean.Roaster?.Name}
117
-
<p class="text-sm text-brown-700 mb-2">🏭 {brew.bean.roaster.name}</p>
134
+
<p class="text-sm text-brown-700 mb-2">
135
+
🏭 {brew.bean.roaster.name}
136
+
</p>
118
137
{/if}
119
138
{:else}
120
-
<h3 class="text-xl font-bold text-brown-900 mb-1">Unknown Bean</h3>
139
+
<h3 class="text-xl font-bold text-brown-900 mb-1">
140
+
Unknown Bean
141
+
</h3>
121
142
{/if}
122
-
143
+
123
144
<!-- Brew details -->
124
-
<div class="flex flex-wrap gap-x-4 gap-y-1 text-sm text-brown-600 mb-2">
145
+
<div
146
+
class="flex flex-wrap gap-x-4 gap-y-1 text-sm text-brown-600 mb-2"
147
+
>
125
148
{#if brew.brewer_obj}
126
149
<span>☕ {brew.brewer_obj.name}</span>
127
150
{:else if brew.method}
128
151
<span>☕ {brew.method}</span>
129
152
{/if}
130
153
{#if hasValue(brew.temperature)}
131
-
<span>🌡️ {brew.temperature}°C</span>
154
+
<span>🌡️ {formatTemperature(brew.temperature)}</span>
132
155
{/if}
133
156
{#if hasValue(brew.coffee_amount)}
134
157
<span>⚖️ {brew.coffee_amount}g coffee</span>
···
137
160
<span>{getWaterDisplay(brew)}</span>
138
161
{/if}
139
162
</div>
140
-
163
+
141
164
<!-- Notes preview -->
142
165
{#if brew.tasting_notes}
143
-
<p class="text-sm text-brown-700 italic line-clamp-2">"{brew.tasting_notes}"</p>
166
+
<p class="text-sm text-brown-700 italic line-clamp-2">
167
+
"{brew.tasting_notes}"
168
+
</p>
144
169
{/if}
145
-
170
+
146
171
<!-- Date -->
147
172
<p class="text-xs text-brown-500 mt-2">
148
173
{formatDate(brew.created_at || brew.created_at)}
149
174
</p>
150
175
</div>
151
-
176
+
152
177
<div class="flex flex-col items-end gap-2">
153
178
{#if hasValue(brew.rating)}
154
-
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-amber-100 text-amber-900">
179
+
<span
180
+
class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-amber-100 text-amber-900"
181
+
>
155
182
⭐ {brew.rating}/10
156
183
</span>
157
184
{/if}
158
-
185
+
159
186
<div class="flex gap-2 items-center">
160
187
<a
161
188
href="/brews/{brew.rkey}"
162
-
on:click|preventDefault={() => navigate(`/brews/${brew.rkey}`)}
189
+
on:click|preventDefault={() =>
190
+
navigate(`/brews/${brew.rkey}`)}
163
191
class="text-brown-700 hover:text-brown-900 text-sm font-medium hover:underline"
164
192
>
165
193
View
···
167
195
<span class="text-brown-400">|</span>
168
196
<a
169
197
href="/brews/{brew.rkey}/edit"
170
-
on:click|preventDefault={() => navigate(`/brews/${brew.rkey}/edit`)}
198
+
on:click|preventDefault={() =>
199
+
navigate(`/brews/${brew.rkey}/edit`)}
171
200
class="text-brown-700 hover:text-brown-900 text-sm font-medium hover:underline"
172
201
>
173
202
Edit
···
178
207
disabled={deleting === brew.rkey}
179
208
class="text-red-600 hover:text-red-800 text-sm font-medium hover:underline disabled:opacity-50"
180
209
>
181
-
{deleting === brew.rkey ? 'Deleting...' : 'Delete'}
210
+
{deleting === brew.rkey ? "Deleting..." : "Delete"}
182
211
</button>
183
212
</div>
184
213
</div>
-157
frontend/src/routes/Brews.svelte.backup
-157
frontend/src/routes/Brews.svelte.backup
···
1
-
<script>
2
-
import { onMount } from 'svelte';
3
-
import { authStore } from '../stores/auth.js';
4
-
import { cacheStore } from '../stores/cache.js';
5
-
import { navigate } from '../lib/router.js';
6
-
7
-
let brews = [];
8
-
let loading = true;
9
-
10
-
$: isAuthenticated = $authStore.isAuthenticated;
11
-
12
-
onMount(async () => {
13
-
if (!isAuthenticated) {
14
-
navigate('/login');
15
-
return;
16
-
}
17
-
18
-
await cacheStore.load();
19
-
brews = $cacheStore.brews || [];
20
-
loading = false;
21
-
});
22
-
23
-
function formatDate(dateStr) {
24
-
if (!dateStr) return '';
25
-
const date = new Date(dateStr);
26
-
return date.toLocaleDateString('en-US', {
27
-
year: 'numeric',
28
-
month: 'short',
29
-
day: 'numeric'
30
-
});
31
-
}
32
-
33
-
function hasValue(val) {
34
-
return val !== null && val !== undefined && val !== '';
35
-
}
36
-
</script>
37
-
38
-
<svelte:head>
39
-
<title>My Brews - Arabica</title>
40
-
</svelte:head>
41
-
42
-
<div class="max-w-6xl mx-auto">
43
-
<div class="flex items-center justify-between mb-6">
44
-
<h1 class="text-3xl font-bold text-brown-900">My Brews</h1>
45
-
<a
46
-
href="/brews/new"
47
-
on:click|preventDefault={() => navigate('/brews/new')}
48
-
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"
49
-
>
50
-
☕ Add New Brew
51
-
</a>
52
-
</div>
53
-
54
-
{#if loading}
55
-
<div class="text-center py-12">
56
-
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-brown-800 mx-auto"></div>
57
-
<p class="mt-4 text-brown-700">Loading brews...</p>
58
-
</div>
59
-
{:else if brews.length === 0}
60
-
<div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl p-12 text-center border border-brown-300">
61
-
<div class="text-6xl mb-4">☕</div>
62
-
<h2 class="text-2xl font-bold text-brown-900 mb-2">No Brews Yet</h2>
63
-
<p class="text-brown-700 mb-6">Start tracking your coffee journey by adding your first brew!</p>
64
-
<button
65
-
on:click={() => navigate('/brews/new')}
66
-
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"
67
-
>
68
-
Add Your First Brew
69
-
</button>
70
-
</div>
71
-
{:else}
72
-
<div class="space-y-4">
73
-
{#each brews as brew}
74
-
<div 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">
75
-
<div class="flex items-start justify-between gap-4">
76
-
<div class="flex-1 min-w-0">
77
-
<!-- Bean info -->
78
-
{#if brew.Bean}
79
-
<h3 class="text-xl font-bold text-brown-900 mb-1">
80
-
{brew.Bean.Name || brew.Bean.Origin || 'Unknown Bean'}
81
-
</h3>
82
-
{#if brew.Bean.Roaster?.Name}
83
-
<p class="text-sm text-brown-700 mb-2">🏭 {brew.Bean.Roaster.Name}</p>
84
-
{/if}
85
-
{:else}
86
-
<h3 class="text-xl font-bold text-brown-900 mb-1">Unknown Bean</h3>
87
-
{/if}
88
-
89
-
<!-- Brew details -->
90
-
<div class="flex flex-wrap gap-x-4 gap-y-1 text-sm text-brown-600 mb-2">
91
-
{#if brew.BrewerObj}
92
-
<span>☕ {brew.BrewerObj.Name}</span>
93
-
{:else if brew.Method}
94
-
<span>☕ {brew.Method}</span>
95
-
{/if}
96
-
{#if hasValue(brew.WaterTemp)}
97
-
<span>🌡️ {brew.WaterTemp}°C</span>
98
-
{/if}
99
-
{#if hasValue(brew.CoffeeAmount)}
100
-
<span>⚖️ {brew.CoffeeAmount}g coffee</span>
101
-
{/if}
102
-
{#if hasValue(brew.WaterAmount)}
103
-
<span>💧 {brew.WaterAmount}ml water</span>
104
-
{/if}
105
-
</div>
106
-
107
-
<!-- Notes preview -->
108
-
{#if brew.Notes}
109
-
<p class="text-sm text-brown-700 italic line-clamp-2">"{brew.Notes}"</p>
110
-
{/if}
111
-
112
-
<!-- Date -->
113
-
<p class="text-xs text-brown-500 mt-2">
114
-
{formatDate(brew.BrewDate || brew.CreatedAt)}
115
-
</p>
116
-
</div>
117
-
118
-
<div class="flex flex-col items-end gap-2">
119
-
{#if hasValue(brew.Rating)}
120
-
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-amber-100 text-amber-900">
121
-
⭐ {brew.Rating}/10
122
-
</span>
123
-
{/if}
124
-
125
-
<div class="flex gap-2">
126
-
<a
127
-
href="/brews/{brew.RKey}"
128
-
on:click|preventDefault={() => navigate(`/brews/${brew.RKey}`)}
129
-
class="text-brown-700 hover:text-brown-900 text-sm font-medium hover:underline"
130
-
>
131
-
View
132
-
</a>
133
-
<span class="text-brown-400">|</span>
134
-
<a
135
-
href="/brews/{brew.RKey}/edit"
136
-
on:click|preventDefault={() => navigate(`/brews/${brew.RKey}/edit`)}
137
-
class="text-brown-700 hover:text-brown-900 text-sm font-medium hover:underline"
138
-
>
139
-
Edit
140
-
</a>
141
-
</div>
142
-
</div>
143
-
</div>
144
-
</div>
145
-
{/each}
146
-
</div>
147
-
{/if}
148
-
</div>
149
-
150
-
<style>
151
-
.line-clamp-2 {
152
-
display: -webkit-box;
153
-
-webkit-line-clamp: 2;
154
-
-webkit-box-orient: vertical;
155
-
overflow: hidden;
156
-
}
157
-
</style>
+75
-29
frontend/src/routes/Home.svelte
+75
-29
frontend/src/routes/Home.svelte
···
1
1
<script>
2
-
import { onMount } from 'svelte';
3
-
import { authStore } from '../stores/auth.js';
4
-
import { navigate } from '../lib/router.js';
5
-
import { api } from '../lib/api.js';
6
-
import FeedCard from '../components/FeedCard.svelte';
7
-
2
+
import { onMount } from "svelte";
3
+
import { authStore } from "../stores/auth.js";
4
+
import { navigate } from "../lib/router.js";
5
+
import { api } from "../lib/api.js";
6
+
import FeedCard from "../components/FeedCard.svelte";
7
+
8
8
let feedItems = [];
9
9
let loading = true;
10
10
let error = null;
11
-
11
+
12
12
$: isAuthenticated = $authStore.isAuthenticated;
13
13
$: user = $authStore.user;
14
-
14
+
15
15
onMount(async () => {
16
16
try {
17
-
const data = await api.get('/api/feed-json');
17
+
const data = await api.get("/api/feed-json");
18
18
feedItems = data.items || [];
19
19
} catch (err) {
20
20
// Feed might return 401 for unauthenticated users - that's okay
21
21
// Just log it and show empty feed
22
-
console.error('Failed to load feed:', err);
22
+
console.error("Failed to load feed:", err);
23
23
if (err.status !== 401 && err.status !== 403) {
24
24
error = err.message;
25
25
}
···
34
34
</svelte:head>
35
35
36
36
<div class="max-w-4xl mx-auto">
37
-
<div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 mb-8 border border-brown-300">
37
+
<div
38
+
class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 mb-8 border border-brown-300"
39
+
>
38
40
<div class="flex items-center gap-3 mb-4">
39
41
<h2 class="text-3xl font-bold text-brown-900">Welcome to Arabica</h2>
40
-
<span class="text-xs bg-amber-400 text-brown-900 px-2 py-1 rounded-md font-semibold shadow-sm">ALPHA</span>
42
+
<span
43
+
class="text-xs bg-amber-400 text-brown-900 px-2 py-1 rounded-md font-semibold shadow-sm"
44
+
>ALPHA</span
45
+
>
41
46
</div>
42
-
<p class="text-brown-800 mb-2 text-lg">Track your coffee brewing journey with detailed logs of every cup.</p>
43
-
<p class="text-sm text-brown-700 italic mb-6">Note: Arabica is currently in alpha. Features and data structures may change.</p>
47
+
<p class="text-brown-800 mb-2 text-lg">
48
+
Track your coffee brewing journey with detailed logs of every cup.
49
+
</p>
50
+
<p class="text-sm text-brown-700 italic mb-6">
51
+
Note: Arabica is currently in alpha. Features and data structures may
52
+
change.
53
+
</p>
44
54
45
55
{#if isAuthenticated}
46
56
<!-- Authenticated: Show app actions -->
47
57
<div class="mb-6">
48
-
<p class="text-sm text-brown-700">Logged in as: <span class="font-mono text-brown-900 font-semibold">{user?.did}</span></p>
58
+
<p class="text-sm text-brown-700">
59
+
Logged in as: <span class="font-mono text-brown-900 font-semibold"
60
+
>{user?.did}</span
61
+
>
62
+
</p>
49
63
</div>
50
64
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
51
-
<a href="/brews/new" on:click|preventDefault={() => navigate('/brews/new')}
52
-
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">
65
+
<a
66
+
href="/brews/new"
67
+
on:click|preventDefault={() => navigate("/brews/new")}
68
+
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"
69
+
>
53
70
<span class="text-xl font-semibold">☕ Add New Brew</span>
54
71
</a>
55
-
<a href="/brews" on:click|preventDefault={() => navigate('/brews')}
56
-
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">
72
+
<a
73
+
href="/brews"
74
+
on:click|preventDefault={() => navigate("/brews")}
75
+
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"
76
+
>
57
77
<span class="text-xl font-semibold">📋 View All Brews</span>
58
78
</a>
59
79
</div>
···
61
81
<!-- Not authenticated: Show login button -->
62
82
<div class="text-center">
63
83
<button
64
-
on:click={() => navigate('/login')}
84
+
on:click={() => navigate("/login")}
65
85
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"
66
86
>
67
87
Log In to Start Tracking
···
71
91
</div>
72
92
73
93
<!-- Community Feed -->
74
-
<div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-6 mb-8 border border-brown-300">
94
+
<div
95
+
class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-6 mb-8 border border-brown-300"
96
+
>
75
97
<h3 class="text-xl font-bold text-brown-900 mb-4">☕ Community Feed</h3>
76
-
98
+
77
99
{#if loading}
78
100
<!-- Loading state -->
79
101
<div class="space-y-4">
···
101
123
</div>
102
124
{:else if feedItems.length === 0}
103
125
<div class="text-center py-8 text-brown-600">
104
-
No activity yet. {#if isAuthenticated}Start by adding your first brew!{:else}Log in to see your feed.{/if}
126
+
No activity yet. {#if isAuthenticated}Start by adding your first brew!{:else}Log
127
+
in to see your feed.{/if}
105
128
</div>
106
129
{:else}
107
130
<div class="space-y-4">
···
112
135
{/if}
113
136
</div>
114
137
115
-
<div class="bg-gradient-to-br from-amber-50 to-brown-100 rounded-xl p-6 border-2 border-brown-300 shadow-lg">
138
+
<div
139
+
class="bg-gradient-to-br from-amber-50 to-brown-100 rounded-xl p-6 border-2 border-brown-300 shadow-lg"
140
+
>
116
141
<h3 class="text-lg font-bold text-brown-900 mb-3">✨ About Arabica</h3>
117
142
<ul class="text-brown-800 space-y-2 leading-relaxed">
118
-
<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>
119
-
<li class="flex items-start"><span class="mr-2">🚀</span><span><strong>Portable:</strong> Own your coffee brewing history</span></li>
120
-
<li class="flex items-start"><span class="mr-2">📊</span><span>Track brewing variables like temperature, time, and grind size</span></li>
121
-
<li class="flex items-start"><span class="mr-2">🌍</span><span>Organize beans by origin and roaster</span></li>
122
-
<li class="flex items-start"><span class="mr-2">📝</span><span>Add tasting notes and ratings to each brew</span></li>
143
+
<li class="flex items-start">
144
+
<span class="mr-2">🔒</span><span
145
+
><strong>Decentralized:</strong> Your data lives in your Personal Data
146
+
Server (PDS)</span
147
+
>
148
+
</li>
149
+
<li class="flex items-start">
150
+
<span class="mr-2">🚀</span><span
151
+
><strong>Portable:</strong> Own your coffee brewing history</span
152
+
>
153
+
</li>
154
+
<li class="flex items-start">
155
+
<span class="mr-2">📊</span><span
156
+
>Track brewing variables like temperature, time, and grind size</span
157
+
>
158
+
</li>
159
+
<li class="flex items-start">
160
+
<span class="mr-2">🌍</span><span
161
+
>Organize beans by origin and roaster</span
162
+
>
163
+
</li>
164
+
<li class="flex items-start">
165
+
<span class="mr-2">📝</span><span
166
+
>Add tasting notes and ratings to each brew</span
167
+
>
168
+
</li>
123
169
</ul>
124
170
</div>
125
171
</div>
+109
-56
frontend/src/routes/Login.svelte
+109
-56
frontend/src/routes/Login.svelte
···
1
1
<script>
2
-
import { onMount } from 'svelte';
3
-
import { authStore } from '../stores/auth.js';
4
-
import { navigate } from '../lib/router.js';
5
-
6
-
let handle = '';
2
+
import { onMount } from "svelte";
3
+
import { authStore } from "../stores/auth.js";
4
+
import { navigate } from "../lib/router.js";
5
+
6
+
let handle = "";
7
7
let autocompleteResults = [];
8
8
let showAutocomplete = false;
9
9
let loading = false;
10
-
let error = '';
10
+
let error = "";
11
11
let debounceTimeout;
12
12
let abortController;
13
-
13
+
14
14
// Redirect if already authenticated
15
15
$: if ($authStore.isAuthenticated && !$authStore.loading) {
16
-
navigate('/');
16
+
navigate("/");
17
17
}
18
-
18
+
19
19
async function searchActors(query) {
20
20
// Need at least 3 characters to search
21
21
if (query.length < 3) {
···
23
23
showAutocomplete = false;
24
24
return;
25
25
}
26
-
26
+
27
27
// Cancel previous request
28
28
if (abortController) {
29
29
abortController.abort();
30
30
}
31
31
abortController = new AbortController();
32
-
32
+
33
33
try {
34
34
const response = await fetch(
35
35
`/api/search-actors?q=${encodeURIComponent(query)}`,
36
-
{ signal: abortController.signal }
36
+
{ signal: abortController.signal },
37
37
);
38
-
38
+
39
39
if (!response.ok) {
40
40
autocompleteResults = [];
41
41
showAutocomplete = false;
42
42
return;
43
43
}
44
-
44
+
45
45
const data = await response.json();
46
46
autocompleteResults = data.actors || [];
47
47
showAutocomplete = autocompleteResults.length > 0 || query.length >= 3;
48
48
} catch (err) {
49
-
if (err.name !== 'AbortError') {
50
-
console.error('Error searching actors:', err);
49
+
if (err.name !== "AbortError") {
50
+
console.error("Error searching actors:", err);
51
51
}
52
52
}
53
53
}
54
-
54
+
55
55
function debounce(func, wait) {
56
56
return (...args) => {
57
57
clearTimeout(debounceTimeout);
58
58
debounceTimeout = setTimeout(() => func(...args), wait);
59
59
};
60
60
}
61
-
61
+
62
62
const debouncedSearch = debounce(searchActors, 300);
63
-
63
+
64
64
function handleInput(e) {
65
65
handle = e.target.value;
66
66
debouncedSearch(handle);
67
67
}
68
-
68
+
69
69
function selectActor(actor) {
70
70
handle = actor.handle;
71
71
autocompleteResults = [];
72
72
showAutocomplete = false;
73
73
}
74
-
74
+
75
75
function handleClickOutside(e) {
76
-
if (!e.target.closest('.autocomplete-container')) {
76
+
if (!e.target.closest(".autocomplete-container")) {
77
77
showAutocomplete = false;
78
78
}
79
79
}
80
-
80
+
81
81
async function handleSubmit(e) {
82
82
e.preventDefault();
83
-
83
+
84
84
if (!handle) {
85
-
error = 'Please enter your handle';
85
+
error = "Please enter your handle";
86
86
return;
87
87
}
88
-
88
+
89
89
loading = true;
90
-
error = '';
91
-
90
+
error = "";
91
+
92
92
// Submit form to Go backend for OAuth flow
93
93
const form = e.target;
94
94
form.submit();
95
95
}
96
-
96
+
97
97
onMount(() => {
98
-
document.addEventListener('click', handleClickOutside);
98
+
document.addEventListener("click", handleClickOutside);
99
99
return () => {
100
-
document.removeEventListener('click', handleClickOutside);
100
+
document.removeEventListener("click", handleClickOutside);
101
101
if (abortController) {
102
102
abortController.abort();
103
103
}
···
110
110
</svelte:head>
111
111
112
112
<div class="max-w-4xl mx-auto">
113
-
<div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 mb-8 border border-brown-300">
113
+
<div
114
+
class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 mb-8 border border-brown-300"
115
+
>
114
116
<div class="flex items-center gap-3 mb-4">
115
117
<h2 class="text-3xl font-bold text-brown-900">Welcome to Arabica</h2>
116
-
<span class="text-xs bg-amber-400 text-brown-900 px-2 py-1 rounded-md font-semibold shadow-sm">ALPHA</span>
118
+
<span
119
+
class="text-xs bg-amber-400 text-brown-900 px-2 py-1 rounded-md font-semibold shadow-sm"
120
+
>ALPHA</span
121
+
>
117
122
</div>
118
-
<p class="text-brown-800 mb-2 text-lg">Track your coffee brewing journey with detailed logs of every cup.</p>
119
-
<p class="text-sm text-brown-700 italic mb-6">Note: Arabica is currently in alpha. Features and data structures may change.</p>
120
-
123
+
<p class="text-brown-800 mb-2 text-lg">
124
+
Track your coffee brewing journey with detailed logs of every cup.
125
+
</p>
126
+
<p class="text-sm text-brown-700 italic mb-6">
127
+
Note: Arabica is currently in alpha. Features and data structures may
128
+
change.
129
+
</p>
130
+
121
131
<div>
122
-
<p class="text-brown-800 mb-6 text-center text-lg">Please log in with your AT Protocol handle to start tracking your brews.</p>
123
-
124
-
<form method="POST" action="/auth/login" on:submit={handleSubmit} class="max-w-md mx-auto">
132
+
<p class="text-brown-800 mb-6 text-center text-lg">
133
+
Please log in with your AT Protocol handle to start tracking your brews.
134
+
</p>
135
+
136
+
<form
137
+
method="POST"
138
+
action="/auth/login"
139
+
on:submit={handleSubmit}
140
+
class="max-w-md mx-auto"
141
+
>
125
142
<div class="relative autocomplete-container">
126
-
<label for="handle" class="block text-sm font-medium text-brown-900 mb-2">Your Handle</label>
143
+
<label
144
+
for="handle"
145
+
class="block text-sm font-medium text-brown-900 mb-2"
146
+
>Your Handle</label
147
+
>
127
148
<input
128
149
type="text"
129
150
id="handle"
130
151
name="handle"
131
152
bind:value={handle}
132
153
on:input={handleInput}
133
-
on:focus={() => { if (autocompleteResults.length > 0 && handle.length >= 3) showAutocomplete = true; }}
154
+
on:focus={() => {
155
+
if (autocompleteResults.length > 0 && handle.length >= 3)
156
+
showAutocomplete = true;
157
+
}}
134
158
placeholder="alice.bsky.social"
135
159
autocomplete="off"
136
160
required
137
161
disabled={loading}
138
162
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"
139
163
/>
140
-
164
+
141
165
{#if showAutocomplete}
142
-
<div 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">
166
+
<div
167
+
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"
168
+
>
143
169
{#if autocompleteResults.length === 0}
144
-
<div class="px-4 py-3 text-sm text-brown-600">No accounts found</div>
170
+
<div class="px-4 py-3 text-sm text-brown-600">
171
+
No accounts found
172
+
</div>
145
173
{:else}
146
174
{#each autocompleteResults as actor}
147
175
<button
···
150
178
class="w-full px-3 py-2 hover:bg-brown-100 cursor-pointer flex items-center gap-2 text-left"
151
179
>
152
180
<img
153
-
src={actor.avatar || '/static/icon-placeholder.svg'}
181
+
src={actor.avatar || "/static/icon-placeholder.svg"}
154
182
alt=""
155
183
class="w-6 h-6 rounded-full object-cover flex-shrink-0"
156
-
on:error={(e) => { e.target.src = '/static/icon-placeholder.svg'; }}
184
+
on:error={(e) => {
185
+
e.target.src = "/static/icon-placeholder.svg";
186
+
}}
157
187
/>
158
188
<div class="flex-1 min-w-0">
159
189
<div class="font-medium text-sm text-brown-900 truncate">
···
169
199
</div>
170
200
{/if}
171
201
</div>
172
-
202
+
173
203
{#if error}
174
204
<div class="mt-3 text-red-600 text-sm">{error}</div>
175
205
{/if}
176
-
206
+
177
207
<button
178
208
type="submit"
179
209
disabled={loading}
180
210
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"
181
211
>
182
-
{loading ? 'Logging in...' : 'Log In'}
212
+
{loading ? "Logging in..." : "Log In"}
183
213
</button>
184
214
</form>
185
215
</div>
186
216
</div>
187
-
188
-
<div class="bg-gradient-to-br from-amber-50 to-brown-100 rounded-xl p-6 border-2 border-brown-300 shadow-lg">
217
+
218
+
<div
219
+
class="bg-gradient-to-br from-amber-50 to-brown-100 rounded-xl p-6 border-2 border-brown-300 shadow-lg"
220
+
>
189
221
<h3 class="text-lg font-bold text-brown-900 mb-3">✨ About Arabica</h3>
190
222
<ul class="text-brown-800 space-y-2 leading-relaxed">
191
-
<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>
192
-
<li class="flex items-start"><span class="mr-2">🚀</span><span><strong>Portable:</strong> Own your coffee brewing history</span></li>
193
-
<li class="flex items-start"><span class="mr-2">📊</span><span>Track brewing variables like temperature, time, and grind size</span></li>
194
-
<li class="flex items-start"><span class="mr-2">🌍</span><span>Organize beans by origin and roaster</span></li>
195
-
<li class="flex items-start"><span class="mr-2">📝</span><span>Add tasting notes and ratings to each brew</span></li>
223
+
<li class="flex items-start">
224
+
<span class="mr-2">🔒</span><span
225
+
><strong>Decentralized:</strong> Your data lives in your Personal Data
226
+
Server (PDS)</span
227
+
>
228
+
</li>
229
+
<li class="flex items-start">
230
+
<span class="mr-2">🚀</span><span
231
+
><strong>Portable:</strong> Own your coffee brewing history</span
232
+
>
233
+
</li>
234
+
<li class="flex items-start">
235
+
<span class="mr-2">📊</span><span
236
+
>Track brewing variables like temperature, time, and grind size</span
237
+
>
238
+
</li>
239
+
<li class="flex items-start">
240
+
<span class="mr-2">🌍</span><span
241
+
>Organize beans by origin and roaster</span
242
+
>
243
+
</li>
244
+
<li class="flex items-start">
245
+
<span class="mr-2">📝</span><span
246
+
>Add tasting notes and ratings to each brew</span
247
+
>
248
+
</li>
196
249
</ul>
197
250
</div>
198
251
</div>
+334
-167
frontend/src/routes/Manage.svelte
+334
-167
frontend/src/routes/Manage.svelte
···
1
1
<script>
2
-
import { onMount } from 'svelte';
3
-
import { authStore } from '../stores/auth.js';
4
-
import { cacheStore } from '../stores/cache.js';
5
-
import { navigate } from '../lib/router.js';
6
-
import { api } from '../lib/api.js';
7
-
import Modal from '../components/Modal.svelte';
8
-
9
-
let activeTab = 'beans'; // beans, roasters, grinders, brewers
2
+
import { onMount } from "svelte";
3
+
import { authStore } from "../stores/auth.js";
4
+
import { cacheStore } from "../stores/cache.js";
5
+
import { navigate } from "../lib/router.js";
6
+
import { api } from "../lib/api.js";
7
+
import Modal from "../components/Modal.svelte";
8
+
9
+
let activeTab = "beans"; // beans, roasters, grinders, brewers
10
10
let loading = true;
11
-
11
+
12
12
// Modal states
13
13
let showBeanModal = false;
14
14
let showRoasterModal = false;
15
15
let showGrinderModal = false;
16
16
let showBrewerModal = false;
17
-
17
+
18
18
// Edit states
19
19
let editingBean = null;
20
20
let editingRoaster = null;
21
21
let editingGrinder = null;
22
22
let editingBrewer = null;
23
-
23
+
24
24
// Forms
25
-
let beanForm = { name: '', origin: '', roast_level: '', process: '', description: '', roaster_rkey: '' };
26
-
let roasterForm = { name: '', location: '', website: '', description: '' };
27
-
let grinderForm = { name: '', grinder_type: '', burr_type: '', notes: '' };
28
-
let brewerForm = { name: '', brewer_type: '', description: '' };
29
-
25
+
let beanForm = {
26
+
name: "",
27
+
origin: "",
28
+
roast_level: "",
29
+
process: "",
30
+
description: "",
31
+
roaster_rkey: "",
32
+
};
33
+
let roasterForm = { name: "", location: "", website: "", description: "" };
34
+
let grinderForm = { name: "", grinder_type: "", burr_type: "", notes: "" };
35
+
let brewerForm = { name: "", brewer_type: "", description: "" };
36
+
30
37
$: beans = $cacheStore.beans || [];
31
38
$: roasters = $cacheStore.roasters || [];
32
39
$: grinders = $cacheStore.grinders || [];
33
40
$: brewers = $cacheStore.brewers || [];
34
41
$: isAuthenticated = $authStore.isAuthenticated;
35
-
42
+
36
43
onMount(async () => {
37
44
if (!isAuthenticated) {
38
-
navigate('/login');
45
+
navigate("/login");
39
46
return;
40
47
}
41
-
48
+
42
49
// Load active tab from localStorage
43
-
const savedTab = localStorage.getItem('arabica_manage_tab');
50
+
const savedTab = localStorage.getItem("arabica_manage_tab");
44
51
if (savedTab) {
45
52
activeTab = savedTab;
46
53
}
47
-
54
+
48
55
await cacheStore.load();
49
56
loading = false;
50
57
});
51
-
58
+
52
59
function setTab(tab) {
53
60
activeTab = tab;
54
-
localStorage.setItem('arabica_manage_tab', tab);
61
+
localStorage.setItem("arabica_manage_tab", tab);
55
62
}
56
-
63
+
57
64
// Bean handlers
58
65
function addBean() {
59
66
editingBean = null;
60
-
beanForm = { name: '', origin: '', roast_level: '', process: '', description: '', roaster_rkey: '' };
67
+
beanForm = {
68
+
name: "",
69
+
origin: "",
70
+
roast_level: "",
71
+
process: "",
72
+
description: "",
73
+
roaster_rkey: "",
74
+
};
61
75
showBeanModal = true;
62
76
}
63
-
77
+
64
78
function editBean(bean) {
65
79
editingBean = bean;
66
80
beanForm = {
67
-
name: bean.name || '',
68
-
origin: bean.origin || '',
69
-
roast_level: bean.roast_level || '',
70
-
process: bean.process || '',
71
-
description: bean.description || '',
72
-
roaster_rkey: bean.roaster_rkey || '',
81
+
name: bean.name || "",
82
+
origin: bean.origin || "",
83
+
roast_level: bean.roast_level || "",
84
+
process: bean.process || "",
85
+
description: bean.description || "",
86
+
roaster_rkey: bean.roaster_rkey || "",
73
87
};
74
88
showBeanModal = true;
75
89
}
76
-
90
+
77
91
async function saveBean() {
78
92
try {
79
-
console.log('Saving bean with data:', beanForm);
93
+
console.log("Saving bean with data:", beanForm);
80
94
if (editingBean) {
81
-
console.log('Updating bean:', editingBean.rkey);
95
+
console.log("Updating bean:", editingBean.rkey);
82
96
await api.put(`/api/beans/${editingBean.rkey}`, beanForm);
83
97
} else {
84
-
console.log('Creating new bean');
85
-
await api.post('/api/beans', beanForm);
98
+
console.log("Creating new bean");
99
+
await api.post("/api/beans", beanForm);
86
100
}
87
101
await cacheStore.invalidate();
88
102
showBeanModal = false;
89
103
} catch (err) {
90
-
console.error('Bean save error:', err);
91
-
alert('Failed to save bean: ' + err.message);
104
+
console.error("Bean save error:", err);
105
+
alert("Failed to save bean: " + err.message);
92
106
}
93
107
}
94
-
108
+
95
109
async function deleteBean(rkey) {
96
-
if (!confirm('Are you sure you want to delete this bean?')) return;
110
+
if (!confirm("Are you sure you want to delete this bean?")) return;
97
111
try {
98
112
await api.delete(`/api/beans/${rkey}`);
99
113
await cacheStore.invalidate();
100
114
} catch (err) {
101
-
alert('Failed to delete bean: ' + err.message);
115
+
alert("Failed to delete bean: " + err.message);
102
116
}
103
117
}
104
-
118
+
105
119
// Roaster handlers
106
120
function addRoaster() {
107
121
editingRoaster = null;
108
-
roasterForm = { name: '', location: '', website: '', description: '' };
122
+
roasterForm = { name: "", location: "", website: "", description: "" };
109
123
showRoasterModal = true;
110
124
}
111
-
125
+
112
126
function editRoaster(roaster) {
113
127
editingRoaster = roaster;
114
128
roasterForm = {
115
-
name: roaster.name || '',
116
-
location: roaster.location || '',
117
-
website: roaster.website || '',
118
-
description: roaster.Description || '',
129
+
name: roaster.name || "",
130
+
location: roaster.location || "",
131
+
website: roaster.website || "",
132
+
description: roaster.Description || "",
119
133
};
120
134
showRoasterModal = true;
121
135
}
122
-
136
+
123
137
async function saveRoaster() {
124
138
try {
125
139
if (editingRoaster) {
126
140
await api.put(`/api/roasters/${editingRoaster.rkey}`, roasterForm);
127
141
} else {
128
-
await api.post('/api/roasters', roasterForm);
142
+
await api.post("/api/roasters", roasterForm);
129
143
}
130
144
await cacheStore.invalidate();
131
145
showRoasterModal = false;
132
146
} catch (err) {
133
-
alert('Failed to save roaster: ' + err.message);
147
+
alert("Failed to save roaster: " + err.message);
134
148
}
135
149
}
136
-
150
+
137
151
async function deleteRoaster(rkey) {
138
-
if (!confirm('Are you sure you want to delete this roaster?')) return;
152
+
if (!confirm("Are you sure you want to delete this roaster?")) return;
139
153
try {
140
154
await api.delete(`/api/roasters/${rkey}`);
141
155
await cacheStore.invalidate();
142
156
} catch (err) {
143
-
alert('Failed to delete roaster: ' + err.message);
157
+
alert("Failed to delete roaster: " + err.message);
144
158
}
145
159
}
146
-
160
+
147
161
// Grinder handlers
148
162
function addGrinder() {
149
163
editingGrinder = null;
150
-
grinderForm = { name: '', grinder_type: '', burr_type: '', notes: '' };
164
+
grinderForm = { name: "", grinder_type: "", burr_type: "", notes: "" };
151
165
showGrinderModal = true;
152
166
}
153
-
167
+
154
168
function editGrinder(grinder) {
155
169
editingGrinder = grinder;
156
170
grinderForm = {
157
-
name: grinder.name || '',
158
-
grinder_type: grinder.grinder_type || '',
159
-
burr_type: grinder.burr_type || '',
160
-
notes: grinder.notes || '',
171
+
name: grinder.name || "",
172
+
grinder_type: grinder.grinder_type || "",
173
+
burr_type: grinder.burr_type || "",
174
+
notes: grinder.notes || "",
161
175
};
162
176
showGrinderModal = true;
163
177
}
164
-
178
+
165
179
async function saveGrinder() {
166
180
try {
167
181
if (editingGrinder) {
168
182
await api.put(`/api/grinders/${editingGrinder.rkey}`, grinderForm);
169
183
} else {
170
-
await api.post('/api/grinders', grinderForm);
184
+
await api.post("/api/grinders", grinderForm);
171
185
}
172
186
await cacheStore.invalidate();
173
187
showGrinderModal = false;
174
188
} catch (err) {
175
-
alert('Failed to save grinder: ' + err.message);
189
+
alert("Failed to save grinder: " + err.message);
176
190
}
177
191
}
178
-
192
+
179
193
async function deleteGrinder(rkey) {
180
-
if (!confirm('Are you sure you want to delete this grinder?')) return;
194
+
if (!confirm("Are you sure you want to delete this grinder?")) return;
181
195
try {
182
196
await api.delete(`/api/grinders/${rkey}`);
183
197
await cacheStore.invalidate();
184
198
} catch (err) {
185
-
alert('Failed to delete grinder: ' + err.message);
199
+
alert("Failed to delete grinder: " + err.message);
186
200
}
187
201
}
188
-
202
+
189
203
// Brewer handlers
190
204
function addBrewer() {
191
205
editingBrewer = null;
192
-
brewerForm = { name: '', brewer_type: '', description: '' };
206
+
brewerForm = { name: "", brewer_type: "", description: "" };
193
207
showBrewerModal = true;
194
208
}
195
-
209
+
196
210
function editBrewer(brewer) {
197
211
editingBrewer = brewer;
198
212
brewerForm = {
199
-
name: brewer.name || '',
200
-
brewer_type: brewer.brewer_type || '',
201
-
description: brewer.description || '',
213
+
name: brewer.name || "",
214
+
brewer_type: brewer.brewer_type || "",
215
+
description: brewer.description || "",
202
216
};
203
217
showBrewerModal = true;
204
218
}
205
-
219
+
206
220
async function saveBrewer() {
207
221
try {
208
222
if (editingBrewer) {
209
223
await api.put(`/api/brewers/${editingBrewer.rkey}`, brewerForm);
210
224
} else {
211
-
await api.post('/api/brewers', brewerForm);
225
+
await api.post("/api/brewers", brewerForm);
212
226
}
213
227
await cacheStore.invalidate();
214
228
showBrewerModal = false;
215
229
} catch (err) {
216
-
alert('Failed to save brewer: ' + err.message);
230
+
alert("Failed to save brewer: " + err.message);
217
231
}
218
232
}
219
-
233
+
220
234
async function deleteBrewer(rkey) {
221
-
if (!confirm('Are you sure you want to delete this brewer?')) return;
235
+
if (!confirm("Are you sure you want to delete this brewer?")) return;
222
236
try {
223
237
await api.delete(`/api/brewers/${rkey}`);
224
238
await cacheStore.invalidate();
225
239
} catch (err) {
226
-
alert('Failed to delete brewer: ' + err.message);
240
+
alert("Failed to delete brewer: " + err.message);
227
241
}
228
242
}
229
243
</script>
···
233
247
</svelte:head>
234
248
235
249
<div class="max-w-6xl mx-auto">
236
-
<h1 class="text-3xl font-bold text-brown-900 mb-6">Manage Equipment & Beans</h1>
237
-
250
+
<h1 class="text-3xl font-bold text-brown-900 mb-6">
251
+
Manage Equipment & Beans
252
+
</h1>
253
+
238
254
{#if loading}
239
255
<div class="text-center py-12">
240
-
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-brown-800 mx-auto"></div>
256
+
<div
257
+
class="animate-spin rounded-full h-12 w-12 border-b-2 border-brown-800 mx-auto"
258
+
></div>
241
259
<p class="mt-4 text-brown-700">Loading...</p>
242
260
</div>
243
261
{:else}
244
262
<!-- Tab Navigation -->
245
-
<div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300 mb-6">
263
+
<div
264
+
class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300 mb-6"
265
+
>
246
266
<div class="flex border-b border-brown-300">
247
267
<button
248
-
on:click={() => setTab('beans')}
249
-
class="flex-1 px-6 py-4 text-center font-medium transition-colors {activeTab === 'beans' ? 'bg-brown-50 text-brown-900 border-b-2 border-brown-700' : 'text-brown-700 hover:bg-brown-50'}"
268
+
on:click={() => setTab("beans")}
269
+
class="flex-1 px-6 py-4 text-center font-medium transition-colors {activeTab ===
270
+
'beans'
271
+
? 'bg-brown-50 text-brown-900 border-b-2 border-brown-700'
272
+
: 'text-brown-700 hover:bg-brown-50'}"
250
273
>
251
274
☕ Beans
252
275
</button>
253
276
<button
254
-
on:click={() => setTab('roasters')}
255
-
class="flex-1 px-6 py-4 text-center font-medium transition-colors {activeTab === 'roasters' ? 'bg-brown-50 text-brown-900 border-b-2 border-brown-700' : 'text-brown-700 hover:bg-brown-50'}"
277
+
on:click={() => setTab("roasters")}
278
+
class="flex-1 px-6 py-4 text-center font-medium transition-colors {activeTab ===
279
+
'roasters'
280
+
? 'bg-brown-50 text-brown-900 border-b-2 border-brown-700'
281
+
: 'text-brown-700 hover:bg-brown-50'}"
256
282
>
257
283
🏭 Roasters
258
284
</button>
259
285
<button
260
-
on:click={() => setTab('grinders')}
261
-
class="flex-1 px-6 py-4 text-center font-medium transition-colors {activeTab === 'grinders' ? 'bg-brown-50 text-brown-900 border-b-2 border-brown-700' : 'text-brown-700 hover:bg-brown-50'}"
286
+
on:click={() => setTab("grinders")}
287
+
class="flex-1 px-6 py-4 text-center font-medium transition-colors {activeTab ===
288
+
'grinders'
289
+
? 'bg-brown-50 text-brown-900 border-b-2 border-brown-700'
290
+
: 'text-brown-700 hover:bg-brown-50'}"
262
291
>
263
292
⚙️ Grinders
264
293
</button>
265
294
<button
266
-
on:click={() => setTab('brewers')}
267
-
class="flex-1 px-6 py-4 text-center font-medium transition-colors {activeTab === 'brewers' ? 'bg-brown-50 text-brown-900 border-b-2 border-brown-700' : 'text-brown-700 hover:bg-brown-50'}"
295
+
on:click={() => setTab("brewers")}
296
+
class="flex-1 px-6 py-4 text-center font-medium transition-colors {activeTab ===
297
+
'brewers'
298
+
? 'bg-brown-50 text-brown-900 border-b-2 border-brown-700'
299
+
: 'text-brown-700 hover:bg-brown-50'}"
268
300
>
269
301
🫖 Brewers
270
302
</button>
271
303
</div>
272
-
304
+
273
305
<!-- Tab Content -->
274
306
<div class="p-6">
275
-
{#if activeTab === 'beans'}
307
+
{#if activeTab === "beans"}
276
308
<div class="flex justify-between items-center mb-4">
277
309
<h2 class="text-xl font-bold text-brown-900">Coffee Beans</h2>
278
310
<button
···
282
314
+ Add Bean
283
315
</button>
284
316
</div>
285
-
317
+
286
318
{#if beans.length === 0}
287
-
<p class="text-brown-600 text-center py-8">No beans yet. Add your first bean!</p>
319
+
<p class="text-brown-600 text-center py-8">
320
+
No beans yet. Add your first bean!
321
+
</p>
288
322
{:else}
289
323
<div class="overflow-x-auto">
290
324
<table class="min-w-full divide-y divide-brown-300">
291
325
<thead class="bg-brown-50">
292
326
<tr>
293
-
<th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Name</th>
294
-
<th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">📍 Origin</th>
295
-
<th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">🔥 Roast</th>
296
-
<th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">🏭 Roaster</th>
297
-
<th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Actions</th>
327
+
<th
328
+
class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase"
329
+
>Name</th
330
+
>
331
+
<th
332
+
class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase"
333
+
>📍 Origin</th
334
+
>
335
+
<th
336
+
class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase"
337
+
>🔥 Roast</th
338
+
>
339
+
<th
340
+
class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase"
341
+
>🏭 Roaster</th
342
+
>
343
+
<th
344
+
class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase"
345
+
>Actions</th
346
+
>
298
347
</tr>
299
348
</thead>
300
349
<tbody class="bg-white divide-y divide-brown-200">
301
350
{#each beans as bean}
302
351
<tr class="hover:bg-brown-50">
303
-
<td class="px-4 py-3 text-sm text-brown-900">{bean.name || '-'}</td>
304
-
<td class="px-4 py-3 text-sm text-brown-900">{bean.origin}</td>
305
-
<td class="px-4 py-3 text-sm text-brown-900">{bean.roast_level}</td>
306
-
<td class="px-4 py-3 text-sm text-brown-900">{bean.roaster?.name || '-'}</td>
352
+
<td class="px-4 py-3 text-sm text-brown-900"
353
+
>{bean.name || "-"}</td
354
+
>
355
+
<td class="px-4 py-3 text-sm text-brown-900"
356
+
>{bean.origin}</td
357
+
>
358
+
<td class="px-4 py-3 text-sm text-brown-900"
359
+
>{bean.roast_level}</td
360
+
>
361
+
<td class="px-4 py-3 text-sm text-brown-900"
362
+
>{bean.roaster?.name || "-"}</td
363
+
>
307
364
<td class="px-4 py-3 text-sm space-x-2">
308
365
<button
309
366
on:click={() => editBean(bean)}
···
324
381
</table>
325
382
</div>
326
383
{/if}
327
-
{:else if activeTab === 'roasters'}
384
+
{:else if activeTab === "roasters"}
328
385
<div class="flex justify-between items-center mb-4">
329
386
<h2 class="text-xl font-bold text-brown-900">Roasters</h2>
330
387
<button
···
334
391
+ Add Roaster
335
392
</button>
336
393
</div>
337
-
394
+
338
395
{#if roasters.length === 0}
339
-
<p class="text-brown-600 text-center py-8">No roasters yet. Add your first roaster!</p>
396
+
<p class="text-brown-600 text-center py-8">
397
+
No roasters yet. Add your first roaster!
398
+
</p>
340
399
{:else}
341
400
<div class="overflow-x-auto">
342
401
<table class="min-w-full divide-y divide-brown-300">
343
402
<thead class="bg-brown-50">
344
403
<tr>
345
-
<th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Name</th>
346
-
<th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">📍 Location</th>
347
-
<th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Actions</th>
404
+
<th
405
+
class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase"
406
+
>Name</th
407
+
>
408
+
<th
409
+
class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase"
410
+
>📍 Location</th
411
+
>
412
+
<th
413
+
class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase"
414
+
>Actions</th
415
+
>
348
416
</tr>
349
417
</thead>
350
418
<tbody class="bg-white divide-y divide-brown-200">
351
419
{#each roasters as roaster}
352
420
<tr class="hover:bg-brown-50">
353
-
<td class="px-4 py-3 text-sm text-brown-900">{roaster.name}</td>
354
-
<td class="px-4 py-3 text-sm text-brown-900">{roaster.location || '-'}</td>
421
+
<td class="px-4 py-3 text-sm text-brown-900"
422
+
>{roaster.name}</td
423
+
>
424
+
<td class="px-4 py-3 text-sm text-brown-900"
425
+
>{roaster.location || "-"}</td
426
+
>
355
427
<td class="px-4 py-3 text-sm space-x-2">
356
428
<button
357
429
on:click={() => editRoaster(roaster)}
···
372
444
</table>
373
445
</div>
374
446
{/if}
375
-
{:else if activeTab === 'grinders'}
447
+
{:else if activeTab === "grinders"}
376
448
<div class="flex justify-between items-center mb-4">
377
449
<h2 class="text-xl font-bold text-brown-900">Grinders</h2>
378
450
<button
···
382
454
+ Add Grinder
383
455
</button>
384
456
</div>
385
-
457
+
386
458
{#if grinders.length === 0}
387
-
<p class="text-brown-600 text-center py-8">No grinders yet. Add your first grinder!</p>
459
+
<p class="text-brown-600 text-center py-8">
460
+
No grinders yet. Add your first grinder!
461
+
</p>
388
462
{:else}
389
463
<div class="overflow-x-auto">
390
464
<table class="min-w-full divide-y divide-brown-300">
391
465
<thead class="bg-brown-50">
392
466
<tr>
393
-
<th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Name</th>
394
-
<th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">🔧 Type</th>
395
-
<th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">💎 Burr Type</th>
396
-
<th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Actions</th>
467
+
<th
468
+
class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase"
469
+
>Name</th
470
+
>
471
+
<th
472
+
class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase"
473
+
>🔧 Type</th
474
+
>
475
+
<th
476
+
class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase"
477
+
>💎 Burr Type</th
478
+
>
479
+
<th
480
+
class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase"
481
+
>Actions</th
482
+
>
397
483
</tr>
398
484
</thead>
399
485
<tbody class="bg-white divide-y divide-brown-200">
400
486
{#each grinders as grinder}
401
487
<tr class="hover:bg-brown-50">
402
-
<td class="px-4 py-3 text-sm text-brown-900">{grinder.name}</td>
403
-
<td class="px-4 py-3 text-sm text-brown-900">{grinder.grinder_type || '-'}</td>
404
-
<td class="px-4 py-3 text-sm text-brown-900">{grinder.burr_type || '-'}</td>
488
+
<td class="px-4 py-3 text-sm text-brown-900"
489
+
>{grinder.name}</td
490
+
>
491
+
<td class="px-4 py-3 text-sm text-brown-900"
492
+
>{grinder.grinder_type || "-"}</td
493
+
>
494
+
<td class="px-4 py-3 text-sm text-brown-900"
495
+
>{grinder.burr_type || "-"}</td
496
+
>
405
497
<td class="px-4 py-3 text-sm space-x-2">
406
498
<button
407
499
on:click={() => editGrinder(grinder)}
···
422
514
</table>
423
515
</div>
424
516
{/if}
425
-
{:else if activeTab === 'brewers'}
517
+
{:else if activeTab === "brewers"}
426
518
<div class="flex justify-between items-center mb-4">
427
519
<h2 class="text-xl font-bold text-brown-900">Brewers</h2>
428
520
<button
···
432
524
+ Add Brewer
433
525
</button>
434
526
</div>
435
-
527
+
436
528
{#if brewers.length === 0}
437
-
<p class="text-brown-600 text-center py-8">No brewers yet. Add your first brewer!</p>
529
+
<p class="text-brown-600 text-center py-8">
530
+
No brewers yet. Add your first brewer!
531
+
</p>
438
532
{:else}
439
533
<div class="overflow-x-auto">
440
534
<table class="min-w-full divide-y divide-brown-300">
441
535
<thead class="bg-brown-50">
442
536
<tr>
443
-
<th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Name</th>
444
-
<th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">🔧 Type</th>
445
-
<th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Actions</th>
537
+
<th
538
+
class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase"
539
+
>Name</th
540
+
>
541
+
<th
542
+
class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase"
543
+
>🔧 Type</th
544
+
>
545
+
<th
546
+
class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase"
547
+
>Actions</th
548
+
>
446
549
</tr>
447
550
</thead>
448
551
<tbody class="bg-white divide-y divide-brown-200">
449
552
{#each brewers as brewer}
450
553
<tr class="hover:bg-brown-50">
451
-
<td class="px-4 py-3 text-sm text-brown-900">{brewer.name}</td>
452
-
<td class="px-4 py-3 text-sm text-brown-900">{brewer.brewer_type || '-'}</td>
554
+
<td class="px-4 py-3 text-sm text-brown-900"
555
+
>{brewer.name}</td
556
+
>
557
+
<td class="px-4 py-3 text-sm text-brown-900"
558
+
>{brewer.brewer_type || "-"}</td
559
+
>
453
560
<td class="px-4 py-3 text-sm space-x-2">
454
561
<button
455
562
on:click={() => editBrewer(brewer)}
···
479
586
<!-- Modals -->
480
587
<Modal
481
588
bind:isOpen={showBeanModal}
482
-
title={editingBean ? 'Edit Bean' : 'Add Bean'}
589
+
title={editingBean ? "Edit Bean" : "Add Bean"}
483
590
onSave={saveBean}
484
-
onCancel={() => showBeanModal = false}
591
+
onCancel={() => (showBeanModal = false)}
485
592
>
486
-
<input type="text" bind:value={beanForm.name} placeholder="Name *"
487
-
class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" />
488
-
<input type="text" bind:value={beanForm.origin} placeholder="Origin *"
489
-
class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" />
490
-
<select bind:value={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">
593
+
<input
594
+
type="text"
595
+
bind:value={beanForm.name}
596
+
placeholder="Name *"
597
+
class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"
598
+
/>
599
+
<input
600
+
type="text"
601
+
bind:value={beanForm.origin}
602
+
placeholder="Origin *"
603
+
class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"
604
+
/>
605
+
<select
606
+
bind:value={beanForm.roaster_rkey}
607
+
class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"
608
+
>
491
609
<option value="">Select Roaster (Optional)</option>
492
610
{#each roasters as roaster}
493
611
<option value={roaster.rkey}>{roaster.name}</option>
494
612
{/each}
495
613
</select>
496
-
<select bind:value={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">
614
+
<select
615
+
bind:value={beanForm.roast_level}
616
+
class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"
617
+
>
497
618
<option value="">Select Roast Level (Optional)</option>
498
619
<option value="Ultra-Light">Ultra-Light</option>
499
620
<option value="Light">Light</option>
···
502
623
<option value="Medium-Dark">Medium-Dark</option>
503
624
<option value="Dark">Dark</option>
504
625
</select>
505
-
<input type="text" bind:value={beanForm.process} placeholder="Process (e.g. Washed, Natural, Honey)"
506
-
class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" />
507
-
<textarea bind:value={beanForm.description} placeholder="Description" rows="3"
508
-
class="w-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>
626
+
<input
627
+
type="text"
628
+
bind:value={beanForm.process}
629
+
placeholder="Process (e.g. Washed, Natural, Honey)"
630
+
class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"
631
+
/>
632
+
<textarea
633
+
bind:value={beanForm.description}
634
+
placeholder="Description"
635
+
rows="3"
636
+
class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"
637
+
></textarea>
509
638
</Modal>
510
639
511
640
<Modal
512
641
bind:isOpen={showRoasterModal}
513
-
title={editingRoaster ? 'Edit Roaster' : 'Add Roaster'}
642
+
title={editingRoaster ? "Edit Roaster" : "Add Roaster"}
514
643
onSave={saveRoaster}
515
-
onCancel={() => showRoasterModal = false}
644
+
onCancel={() => (showRoasterModal = false)}
516
645
>
517
-
<input type="text" bind:value={roasterForm.name} placeholder="Name *"
518
-
class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" />
519
-
<input type="text" bind:value={roasterForm.location} placeholder="Location"
520
-
class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" />
521
-
<input type="url" bind:value={roasterForm.website} placeholder="Website"
522
-
class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" />
646
+
<input
647
+
type="text"
648
+
bind:value={roasterForm.name}
649
+
placeholder="Name *"
650
+
class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"
651
+
/>
652
+
<input
653
+
type="text"
654
+
bind:value={roasterForm.location}
655
+
placeholder="Location"
656
+
class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"
657
+
/>
658
+
<input
659
+
type="url"
660
+
bind:value={roasterForm.website}
661
+
placeholder="Website"
662
+
class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"
663
+
/>
523
664
</Modal>
524
665
525
666
<Modal
526
667
bind:isOpen={showGrinderModal}
527
-
title={editingGrinder ? 'Edit Grinder' : 'Add Grinder'}
668
+
title={editingGrinder ? "Edit Grinder" : "Add Grinder"}
528
669
onSave={saveGrinder}
529
-
onCancel={() => showGrinderModal = false}
670
+
onCancel={() => (showGrinderModal = false)}
530
671
>
531
-
<input type="text" bind:value={grinderForm.name} placeholder="Name *"
532
-
class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" />
533
-
<select bind:value={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">
672
+
<input
673
+
type="text"
674
+
bind:value={grinderForm.name}
675
+
placeholder="Name *"
676
+
class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"
677
+
/>
678
+
<select
679
+
bind:value={grinderForm.grinder_type}
680
+
class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"
681
+
>
534
682
<option value="">Select Grinder Type *</option>
535
683
<option value="Hand">Hand</option>
536
684
<option value="Electric">Electric</option>
537
685
<option value="Portable Electric">Portable Electric</option>
538
686
</select>
539
-
<select bind:value={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">
687
+
<select
688
+
bind:value={grinderForm.burr_type}
689
+
class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"
690
+
>
540
691
<option value="">Select Burr Type (Optional)</option>
541
692
<option value="Conical">Conical</option>
542
693
<option value="Flat">Flat</option>
543
694
</select>
544
-
<textarea bind:value={grinderForm.notes} placeholder="Notes" rows="3"
545
-
class="w-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>
695
+
<textarea
696
+
bind:value={grinderForm.notes}
697
+
placeholder="Notes"
698
+
rows="3"
699
+
class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"
700
+
></textarea>
546
701
</Modal>
547
702
548
703
<Modal
549
704
bind:isOpen={showBrewerModal}
550
-
title={editingBrewer ? 'Edit Brewer' : 'Add Brewer'}
705
+
title={editingBrewer ? "Edit Brewer" : "Add Brewer"}
551
706
onSave={saveBrewer}
552
-
onCancel={() => showBrewerModal = false}
707
+
onCancel={() => (showBrewerModal = false)}
553
708
>
554
-
<input type="text" bind:value={brewerForm.name} placeholder="Name *"
555
-
class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" />
556
-
<input type="text" bind:value={brewerForm.brewer_type} placeholder="Type (e.g., Pour-Over, Immersion, Espresso)"
557
-
class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" />
558
-
<textarea bind:value={brewerForm.description} placeholder="Description" rows="3"
559
-
class="w-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>
709
+
<input
710
+
type="text"
711
+
bind:value={brewerForm.name}
712
+
placeholder="Name *"
713
+
class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"
714
+
/>
715
+
<input
716
+
type="text"
717
+
bind:value={brewerForm.brewer_type}
718
+
placeholder="Type (e.g., Pour-Over, Immersion, Espresso)"
719
+
class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"
720
+
/>
721
+
<textarea
722
+
bind:value={brewerForm.description}
723
+
placeholder="Description"
724
+
rows="3"
725
+
class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"
726
+
></textarea>
560
727
</Modal>
-596
frontend/src/routes/Manage.svelte.backup
-596
frontend/src/routes/Manage.svelte.backup
···
1
-
<script>
2
-
import { onMount } from 'svelte';
3
-
import { authStore } from '../stores/auth.js';
4
-
import { cacheStore } from '../stores/cache.js';
5
-
import { navigate } from '../lib/router.js';
6
-
import { api } from '../lib/api.js';
7
-
import Modal from '../components/Modal.svelte';
8
-
9
-
let activeTab = 'beans'; // beans, roasters, grinders, brewers
10
-
let loading = true;
11
-
12
-
// Modal states
13
-
let showBeanModal = false;
14
-
let showRoasterModal = false;
15
-
let showGrinderModal = false;
16
-
let showBrewerModal = false;
17
-
18
-
// Edit states
19
-
let editingBean = null;
20
-
let editingRoaster = null;
21
-
let editingGrinder = null;
22
-
let editingBrewer = null;
23
-
24
-
// Forms
25
-
let beanForm = { name: '', origin: '', roast_level: '', process: '', description: '', roaster_rkey: '' };
26
-
let roasterForm = { name: '', location: '', website: '', description: '' };
27
-
let grinderForm = { name: '', grinder_type: '', burr_type: '', notes: '' };
28
-
let brewerForm = { name: '', brewer_type: '', description: '' };
29
-
30
-
$: beans = $cacheStore.beans || [];
31
-
$: roasters = $cacheStore.roasters || [];
32
-
$: grinders = $cacheStore.grinders || [];
33
-
$: brewers = $cacheStore.brewers || [];
34
-
$: isAuthenticated = $authStore.isAuthenticated;
35
-
36
-
onMount(async () => {
37
-
if (!isAuthenticated) {
38
-
navigate('/login');
39
-
return;
40
-
}
41
-
42
-
// Load active tab from localStorage
43
-
const savedTab = localStorage.getItem('arabica_manage_tab');
44
-
if (savedTab) {
45
-
activeTab = savedTab;
46
-
}
47
-
48
-
await cacheStore.load();
49
-
loading = false;
50
-
});
51
-
52
-
function setTab(tab) {
53
-
activeTab = tab;
54
-
localStorage.setItem('arabica_manage_tab', tab);
55
-
}
56
-
57
-
// Bean handlers
58
-
function addBean() {
59
-
editingBean = null;
60
-
beanForm = { name: '', origin: '', roast_level: '', process: '', description: '', roaster_rkey: '' };
61
-
showBeanModal = true;
62
-
}
63
-
64
-
function editBean(bean) {
65
-
editingBean = bean;
66
-
beanForm = {
67
-
name: bean.Name || '',
68
-
origin: bean.Origin || '',
69
-
roast_level: bean.RoastLevel || '',
70
-
process: bean.Process || '',
71
-
description: bean.Description || '',
72
-
roaster_rkey: bean.RoasterRKey || '',
73
-
};
74
-
showBeanModal = true;
75
-
}
76
-
77
-
async function saveBean() {
78
-
try {
79
-
if (editingBean) {
80
-
await api.put(`/api/beans/${editingBean.RKey}`, beanForm);
81
-
} else {
82
-
await api.post('/api/beans', beanForm);
83
-
}
84
-
await cacheStore.invalidate();
85
-
showBeanModal = false;
86
-
} catch (err) {
87
-
alert('Failed to save bean: ' + err.message);
88
-
}
89
-
}
90
-
91
-
async function deleteBean(rkey) {
92
-
if (!confirm('Are you sure you want to delete this bean?')) return;
93
-
try {
94
-
await api.delete(`/api/beans/${rkey}`);
95
-
await cacheStore.invalidate();
96
-
} catch (err) {
97
-
alert('Failed to delete bean: ' + err.message);
98
-
}
99
-
}
100
-
101
-
// Roaster handlers
102
-
function addRoaster() {
103
-
editingRoaster = null;
104
-
roasterForm = { name: '', location: '', website: '', description: '' };
105
-
showRoasterModal = true;
106
-
}
107
-
108
-
function editRoaster(roaster) {
109
-
editingRoaster = roaster;
110
-
roasterForm = {
111
-
name: roaster.Name || '',
112
-
location: roaster.Location || '',
113
-
website: roaster.Website || '',
114
-
description: roaster.Description || '',
115
-
};
116
-
showRoasterModal = true;
117
-
}
118
-
119
-
async function saveRoaster() {
120
-
try {
121
-
if (editingRoaster) {
122
-
await api.put(`/api/roasters/${editingRoaster.RKey}`, roasterForm);
123
-
} else {
124
-
await api.post('/api/roasters', roasterForm);
125
-
}
126
-
await cacheStore.invalidate();
127
-
showRoasterModal = false;
128
-
} catch (err) {
129
-
alert('Failed to save roaster: ' + err.message);
130
-
}
131
-
}
132
-
133
-
async function deleteRoaster(rkey) {
134
-
if (!confirm('Are you sure you want to delete this roaster?')) return;
135
-
try {
136
-
await api.delete(`/api/roasters/${rkey}`);
137
-
await cacheStore.invalidate();
138
-
} catch (err) {
139
-
alert('Failed to delete roaster: ' + err.message);
140
-
}
141
-
}
142
-
143
-
// Grinder handlers
144
-
function addGrinder() {
145
-
editingGrinder = null;
146
-
grinderForm = { name: '', grinder_type: '', burr_type: '', notes: '' };
147
-
showGrinderModal = true;
148
-
}
149
-
150
-
function editGrinder(grinder) {
151
-
editingGrinder = grinder;
152
-
grinderForm = {
153
-
name: grinder.Name || '',
154
-
grinder_type: grinder.Type || '',
155
-
burr_type: grinder.BurrType || '',
156
-
notes: grinder.Notes || '',
157
-
};
158
-
showGrinderModal = true;
159
-
}
160
-
161
-
async function saveGrinder() {
162
-
try {
163
-
if (editingGrinder) {
164
-
await api.put(`/api/grinders/${editingGrinder.RKey}`, grinderForm);
165
-
} else {
166
-
await api.post('/api/grinders', grinderForm);
167
-
}
168
-
await cacheStore.invalidate();
169
-
showGrinderModal = false;
170
-
} catch (err) {
171
-
alert('Failed to save grinder: ' + err.message);
172
-
}
173
-
}
174
-
175
-
async function deleteGrinder(rkey) {
176
-
if (!confirm('Are you sure you want to delete this grinder?')) return;
177
-
try {
178
-
await api.delete(`/api/grinders/${rkey}`);
179
-
await cacheStore.invalidate();
180
-
} catch (err) {
181
-
alert('Failed to delete grinder: ' + err.message);
182
-
}
183
-
}
184
-
185
-
// Brewer handlers
186
-
function addBrewer() {
187
-
editingBrewer = null;
188
-
brewerForm = { name: '', brewer_type: '', description: '' };
189
-
showBrewerModal = true;
190
-
}
191
-
192
-
function editBrewer(brewer) {
193
-
editingBrewer = brewer;
194
-
brewerForm = {
195
-
name: brewer.Name || '',
196
-
brewer_type: brewer.Type || '',
197
-
description: brewer.Description || '',
198
-
};
199
-
showBrewerModal = true;
200
-
}
201
-
202
-
async function saveBrewer() {
203
-
try {
204
-
if (editingBrewer) {
205
-
await api.put(`/api/brewers/${editingBrewer.RKey}`, brewerForm);
206
-
} else {
207
-
await api.post('/api/brewers', brewerForm);
208
-
}
209
-
await cacheStore.invalidate();
210
-
showBrewerModal = false;
211
-
} catch (err) {
212
-
alert('Failed to save brewer: ' + err.message);
213
-
}
214
-
}
215
-
216
-
async function deleteBrewer(rkey) {
217
-
if (!confirm('Are you sure you want to delete this brewer?')) return;
218
-
try {
219
-
await api.delete(`/api/brewers/${rkey}`);
220
-
await cacheStore.invalidate();
221
-
} catch (err) {
222
-
alert('Failed to delete brewer: ' + err.message);
223
-
}
224
-
}
225
-
</script>
226
-
227
-
<svelte:head>
228
-
<title>Manage - Arabica</title>
229
-
</svelte:head>
230
-
231
-
<div class="max-w-6xl mx-auto">
232
-
<h1 class="text-3xl font-bold text-brown-900 mb-6">Manage Equipment & Beans</h1>
233
-
234
-
{#if loading}
235
-
<div class="text-center py-12">
236
-
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-brown-800 mx-auto"></div>
237
-
<p class="mt-4 text-brown-700">Loading...</p>
238
-
</div>
239
-
{:else}
240
-
<!-- Tab Navigation -->
241
-
<div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300 mb-6">
242
-
<div class="flex border-b border-brown-300">
243
-
<button
244
-
on:click={() => setTab('beans')}
245
-
class="flex-1 px-6 py-4 text-center font-medium transition-colors {activeTab === 'beans' ? 'bg-brown-50 text-brown-900 border-b-2 border-brown-700' : 'text-brown-700 hover:bg-brown-50'}"
246
-
>
247
-
☕ Beans
248
-
</button>
249
-
<button
250
-
on:click={() => setTab('roasters')}
251
-
class="flex-1 px-6 py-4 text-center font-medium transition-colors {activeTab === 'roasters' ? 'bg-brown-50 text-brown-900 border-b-2 border-brown-700' : 'text-brown-700 hover:bg-brown-50'}"
252
-
>
253
-
🏭 Roasters
254
-
</button>
255
-
<button
256
-
on:click={() => setTab('grinders')}
257
-
class="flex-1 px-6 py-4 text-center font-medium transition-colors {activeTab === 'grinders' ? 'bg-brown-50 text-brown-900 border-b-2 border-brown-700' : 'text-brown-700 hover:bg-brown-50'}"
258
-
>
259
-
⚙️ Grinders
260
-
</button>
261
-
<button
262
-
on:click={() => setTab('brewers')}
263
-
class="flex-1 px-6 py-4 text-center font-medium transition-colors {activeTab === 'brewers' ? 'bg-brown-50 text-brown-900 border-b-2 border-brown-700' : 'text-brown-700 hover:bg-brown-50'}"
264
-
>
265
-
🫖 Brewers
266
-
</button>
267
-
</div>
268
-
269
-
<!-- Tab Content -->
270
-
<div class="p-6">
271
-
{#if activeTab === 'beans'}
272
-
<div class="flex justify-between items-center mb-4">
273
-
<h2 class="text-xl font-bold text-brown-900">Coffee Beans</h2>
274
-
<button
275
-
on:click={addBean}
276
-
class="bg-brown-700 text-white px-4 py-2 rounded-lg hover:bg-brown-800 font-medium"
277
-
>
278
-
+ Add Bean
279
-
</button>
280
-
</div>
281
-
282
-
{#if beans.length === 0}
283
-
<p class="text-brown-600 text-center py-8">No beans yet. Add your first bean!</p>
284
-
{:else}
285
-
<div class="overflow-x-auto">
286
-
<table class="min-w-full divide-y divide-brown-300">
287
-
<thead class="bg-brown-50">
288
-
<tr>
289
-
<th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Name</th>
290
-
<th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Origin</th>
291
-
<th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Roast</th>
292
-
<th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Roaster</th>
293
-
<th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Actions</th>
294
-
</tr>
295
-
</thead>
296
-
<tbody class="bg-white divide-y divide-brown-200">
297
-
{#each beans as bean}
298
-
<tr class="hover:bg-brown-50">
299
-
<td class="px-4 py-3 text-sm text-brown-900">{bean.Name || '-'}</td>
300
-
<td class="px-4 py-3 text-sm text-brown-900">{bean.Origin}</td>
301
-
<td class="px-4 py-3 text-sm text-brown-900">{bean.RoastLevel}</td>
302
-
<td class="px-4 py-3 text-sm text-brown-900">{bean.Roaster?.Name || '-'}</td>
303
-
<td class="px-4 py-3 text-sm space-x-2">
304
-
<button
305
-
on:click={() => editBean(bean)}
306
-
class="text-brown-700 hover:text-brown-900 font-medium"
307
-
>
308
-
Edit
309
-
</button>
310
-
<button
311
-
on:click={() => deleteBean(bean.RKey)}
312
-
class="text-red-600 hover:text-red-800 font-medium"
313
-
>
314
-
Delete
315
-
</button>
316
-
</td>
317
-
</tr>
318
-
{/each}
319
-
</tbody>
320
-
</table>
321
-
</div>
322
-
{/if}
323
-
{:else if activeTab === 'roasters'}
324
-
<div class="flex justify-between items-center mb-4">
325
-
<h2 class="text-xl font-bold text-brown-900">Roasters</h2>
326
-
<button
327
-
on:click={addRoaster}
328
-
class="bg-brown-700 text-white px-4 py-2 rounded-lg hover:bg-brown-800 font-medium"
329
-
>
330
-
+ Add Roaster
331
-
</button>
332
-
</div>
333
-
334
-
{#if roasters.length === 0}
335
-
<p class="text-brown-600 text-center py-8">No roasters yet. Add your first roaster!</p>
336
-
{:else}
337
-
<div class="overflow-x-auto">
338
-
<table class="min-w-full divide-y divide-brown-300">
339
-
<thead class="bg-brown-50">
340
-
<tr>
341
-
<th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Name</th>
342
-
<th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Location</th>
343
-
<th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Actions</th>
344
-
</tr>
345
-
</thead>
346
-
<tbody class="bg-white divide-y divide-brown-200">
347
-
{#each roasters as roaster}
348
-
<tr class="hover:bg-brown-50">
349
-
<td class="px-4 py-3 text-sm text-brown-900">{roaster.Name}</td>
350
-
<td class="px-4 py-3 text-sm text-brown-900">{roaster.Location || '-'}</td>
351
-
<td class="px-4 py-3 text-sm space-x-2">
352
-
<button
353
-
on:click={() => editRoaster(roaster)}
354
-
class="text-brown-700 hover:text-brown-900 font-medium"
355
-
>
356
-
Edit
357
-
</button>
358
-
<button
359
-
on:click={() => deleteRoaster(roaster.RKey)}
360
-
class="text-red-600 hover:text-red-800 font-medium"
361
-
>
362
-
Delete
363
-
</button>
364
-
</td>
365
-
</tr>
366
-
{/each}
367
-
</tbody>
368
-
</table>
369
-
</div>
370
-
{/if}
371
-
{:else if activeTab === 'grinders'}
372
-
<div class="flex justify-between items-center mb-4">
373
-
<h2 class="text-xl font-bold text-brown-900">Grinders</h2>
374
-
<button
375
-
on:click={addGrinder}
376
-
class="bg-brown-700 text-white px-4 py-2 rounded-lg hover:bg-brown-800 font-medium"
377
-
>
378
-
+ Add Grinder
379
-
</button>
380
-
</div>
381
-
382
-
{#if grinders.length === 0}
383
-
<p class="text-brown-600 text-center py-8">No grinders yet. Add your first grinder!</p>
384
-
{:else}
385
-
<div class="overflow-x-auto">
386
-
<table class="min-w-full divide-y divide-brown-300">
387
-
<thead class="bg-brown-50">
388
-
<tr>
389
-
<th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Name</th>
390
-
<th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Type</th>
391
-
<th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Burr Type</th>
392
-
<th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Actions</th>
393
-
</tr>
394
-
</thead>
395
-
<tbody class="bg-white divide-y divide-brown-200">
396
-
{#each grinders as grinder}
397
-
<tr class="hover:bg-brown-50">
398
-
<td class="px-4 py-3 text-sm text-brown-900">{grinder.Name}</td>
399
-
<td class="px-4 py-3 text-sm text-brown-900">{grinder.Type || '-'}</td>
400
-
<td class="px-4 py-3 text-sm text-brown-900">{grinder.BurrType || '-'}</td>
401
-
<td class="px-4 py-3 text-sm space-x-2">
402
-
<button
403
-
on:click={() => editGrinder(grinder)}
404
-
class="text-brown-700 hover:text-brown-900 font-medium"
405
-
>
406
-
Edit
407
-
</button>
408
-
<button
409
-
on:click={() => deleteGrinder(grinder.RKey)}
410
-
class="text-red-600 hover:text-red-800 font-medium"
411
-
>
412
-
Delete
413
-
</button>
414
-
</td>
415
-
</tr>
416
-
{/each}
417
-
</tbody>
418
-
</table>
419
-
</div>
420
-
{/if}
421
-
{:else if activeTab === 'brewers'}
422
-
<div class="flex justify-between items-center mb-4">
423
-
<h2 class="text-xl font-bold text-brown-900">Brewers</h2>
424
-
<button
425
-
on:click={addBrewer}
426
-
class="bg-brown-700 text-white px-4 py-2 rounded-lg hover:bg-brown-800 font-medium"
427
-
>
428
-
+ Add Brewer
429
-
</button>
430
-
</div>
431
-
432
-
{#if brewers.length === 0}
433
-
<p class="text-brown-600 text-center py-8">No brewers yet. Add your first brewer!</p>
434
-
{:else}
435
-
<div class="overflow-x-auto">
436
-
<table class="min-w-full divide-y divide-brown-300">
437
-
<thead class="bg-brown-50">
438
-
<tr>
439
-
<th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Name</th>
440
-
<th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Type</th>
441
-
<th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Actions</th>
442
-
</tr>
443
-
</thead>
444
-
<tbody class="bg-white divide-y divide-brown-200">
445
-
{#each brewers as brewer}
446
-
<tr class="hover:bg-brown-50">
447
-
<td class="px-4 py-3 text-sm text-brown-900">{brewer.Name}</td>
448
-
<td class="px-4 py-3 text-sm text-brown-900">{brewer.Type || '-'}</td>
449
-
<td class="px-4 py-3 text-sm space-x-2">
450
-
<button
451
-
on:click={() => editBrewer(brewer)}
452
-
class="text-brown-700 hover:text-brown-900 font-medium"
453
-
>
454
-
Edit
455
-
</button>
456
-
<button
457
-
on:click={() => deleteBrewer(brewer.RKey)}
458
-
class="text-red-600 hover:text-red-800 font-medium"
459
-
>
460
-
Delete
461
-
</button>
462
-
</td>
463
-
</tr>
464
-
{/each}
465
-
</tbody>
466
-
</table>
467
-
</div>
468
-
{/if}
469
-
{/if}
470
-
</div>
471
-
</div>
472
-
{/if}
473
-
</div>
474
-
475
-
<!-- Modals -->
476
-
<Modal
477
-
bind:isOpen={showBeanModal}
478
-
title={editingBean ? 'Edit Bean' : 'Add Bean'}
479
-
onSave={saveBean}
480
-
onCancel={() => showBeanModal = false}
481
-
>
482
-
<div class="space-y-4">
483
-
<div>
484
-
<label class="block text-sm font-medium text-gray-700 mb-1">Name</label>
485
-
<input type="text" bind:value={beanForm.name} class="w-full rounded border-gray-300 px-3 py-2" />
486
-
</div>
487
-
<div>
488
-
<label class="block text-sm font-medium text-gray-700 mb-1">Origin *</label>
489
-
<input type="text" bind:value={beanForm.origin} required class="w-full rounded border-gray-300 px-3 py-2" />
490
-
</div>
491
-
<div>
492
-
<label class="block text-sm font-medium text-gray-700 mb-1">Roast Level *</label>
493
-
<select bind:value={beanForm.roast_level} required class="w-full rounded border-gray-300 px-3 py-2">
494
-
<option value="">Select...</option>
495
-
<option value="Light">Light</option>
496
-
<option value="Medium-Light">Medium-Light</option>
497
-
<option value="Medium">Medium</option>
498
-
<option value="Medium-Dark">Medium-Dark</option>
499
-
<option value="Dark">Dark</option>
500
-
</select>
501
-
</div>
502
-
<div>
503
-
<label class="block text-sm font-medium text-gray-700 mb-1">Process</label>
504
-
<input type="text" bind:value={beanForm.process} class="w-full rounded border-gray-300 px-3 py-2" />
505
-
</div>
506
-
<div>
507
-
<label class="block text-sm font-medium text-gray-700 mb-1">Roaster</label>
508
-
<select bind:value={beanForm.roaster_rkey} class="w-full rounded border-gray-300 px-3 py-2">
509
-
<option value="">Select...</option>
510
-
{#each roasters as roaster}
511
-
<option value={roaster.RKey}>{roaster.Name}</option>
512
-
{/each}
513
-
</select>
514
-
</div>
515
-
</div>
516
-
</Modal>
517
-
518
-
<Modal
519
-
bind:isOpen={showRoasterModal}
520
-
title={editingRoaster ? 'Edit Roaster' : 'Add Roaster'}
521
-
onSave={saveRoaster}
522
-
onCancel={() => showRoasterModal = false}
523
-
>
524
-
<div class="space-y-4">
525
-
<div>
526
-
<label class="block text-sm font-medium text-gray-700 mb-1">Name *</label>
527
-
<input type="text" bind:value={roasterForm.name} required class="w-full rounded border-gray-300 px-3 py-2" />
528
-
</div>
529
-
<div>
530
-
<label class="block text-sm font-medium text-gray-700 mb-1">Location</label>
531
-
<input type="text" bind:value={roasterForm.location} class="w-full rounded border-gray-300 px-3 py-2" />
532
-
</div>
533
-
<div>
534
-
<label class="block text-sm font-medium text-gray-700 mb-1">Website</label>
535
-
<input type="url" bind:value={roasterForm.website} class="w-full rounded border-gray-300 px-3 py-2" />
536
-
</div>
537
-
</div>
538
-
</Modal>
539
-
540
-
<Modal
541
-
bind:isOpen={showGrinderModal}
542
-
title={editingGrinder ? 'Edit Grinder' : 'Add Grinder'}
543
-
onSave={saveGrinder}
544
-
onCancel={() => showGrinderModal = false}
545
-
>
546
-
<div class="space-y-4">
547
-
<div>
548
-
<label class="block text-sm font-medium text-gray-700 mb-1">Name *</label>
549
-
<input type="text" bind:value={grinderForm.name} required class="w-full rounded border-gray-300 px-3 py-2" />
550
-
</div>
551
-
<div>
552
-
<label class="block text-sm font-medium text-gray-700 mb-1">Type</label>
553
-
<select bind:value={grinderForm.grinder_type} class="w-full rounded border-gray-300 px-3 py-2">
554
-
<option value="">Select...</option>
555
-
<option value="Manual">Manual</option>
556
-
<option value="Electric">Electric</option>
557
-
<option value="Blade">Blade</option>
558
-
</select>
559
-
</div>
560
-
<div>
561
-
<label class="block text-sm font-medium text-gray-700 mb-1">Burr Type</label>
562
-
<select bind:value={grinderForm.burr_type} class="w-full rounded border-gray-300 px-3 py-2">
563
-
<option value="">Select...</option>
564
-
<option value="Flat">Flat</option>
565
-
<option value="Conical">Conical</option>
566
-
</select>
567
-
</div>
568
-
</div>
569
-
</Modal>
570
-
571
-
<Modal
572
-
bind:isOpen={showBrewerModal}
573
-
title={editingBrewer ? 'Edit Brewer' : 'Add Brewer'}
574
-
onSave={saveBrewer}
575
-
onCancel={() => showBrewerModal = false}
576
-
>
577
-
<div class="space-y-4">
578
-
<div>
579
-
<label class="block text-sm font-medium text-gray-700 mb-1">Name *</label>
580
-
<input type="text" bind:value={brewerForm.name} required class="w-full rounded border-gray-300 px-3 py-2" />
581
-
</div>
582
-
<div>
583
-
<label class="block text-sm font-medium text-gray-700 mb-1">Type</label>
584
-
<select bind:value={brewerForm.brewer_type} class="w-full rounded border-gray-300 px-3 py-2">
585
-
<option value="">Select...</option>
586
-
<option value="Pour Over">Pour Over</option>
587
-
<option value="French Press">French Press</option>
588
-
<option value="Espresso">Espresso</option>
589
-
<option value="Moka Pot">Moka Pot</option>
590
-
<option value="Aeropress">Aeropress</option>
591
-
<option value="Cold Brew">Cold Brew</option>
592
-
<option value="Siphon">Siphon</option>
593
-
</select>
594
-
</div>
595
-
</div>
596
-
</Modal>
+4
-1
frontend/src/routes/NotFound.svelte
+4
-1
frontend/src/routes/NotFound.svelte
···
2
2
<div class="text-6xl mb-4">☕</div>
3
3
<h1 class="text-4xl font-bold text-brown-900 mb-4">404 - Not Found</h1>
4
4
<p class="text-brown-700 mb-8">The page you're looking for doesn't exist.</p>
5
-
<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">
5
+
<a
6
+
href="/"
7
+
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"
8
+
>
6
9
Go Home
7
10
</a>
8
11
</div>
+267
-88
frontend/src/routes/Profile.svelte
+267
-88
frontend/src/routes/Profile.svelte
···
1
1
<script>
2
-
import { onMount } from 'svelte';
3
-
import { api } from '../lib/api.js';
4
-
import { navigate } from '../lib/router.js';
5
-
2
+
import { onMount } from "svelte";
3
+
import { api } from "../lib/api.js";
4
+
import { navigate } from "../lib/router.js";
5
+
6
6
export let actor;
7
-
7
+
8
8
let profile = null;
9
9
let brews = [];
10
10
let beans = [];
···
14
14
let isOwnProfile = false;
15
15
let loading = true;
16
16
let error = null;
17
-
18
-
let activeTab = 'brews';
19
-
17
+
18
+
let activeTab = "brews";
19
+
20
20
onMount(async () => {
21
21
try {
22
22
const data = await api.get(`/api/profile-json/${actor}`);
23
23
profile = data.profile;
24
-
brews = (data.brews || []).sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
24
+
brews = (data.brews || []).sort(
25
+
(a, b) => new Date(b.created_at) - new Date(a.created_at),
26
+
);
25
27
beans = data.beans || [];
26
28
roasters = data.roasters || [];
27
29
grinders = data.grinders || [];
28
30
brewers = data.brewers || [];
29
31
isOwnProfile = data.isOwnProfile || false;
30
32
} catch (err) {
31
-
console.error('Failed to load profile:', err);
33
+
console.error("Failed to load profile:", err);
32
34
error = err.message;
33
35
} finally {
34
36
loading = false;
35
37
}
36
38
});
37
-
39
+
38
40
function formatDate(dateStr) {
39
41
const date = new Date(dateStr);
40
-
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
42
+
return date.toLocaleDateString("en-US", {
43
+
year: "numeric",
44
+
month: "short",
45
+
day: "numeric",
46
+
});
41
47
}
42
48
</script>
43
49
44
50
<svelte:head>
45
-
<title>{profile?.displayName || profile?.handle || 'Profile'} - Arabica</title>
51
+
<title>{profile?.displayName || profile?.handle || "Profile"} - Arabica</title
52
+
>
46
53
</svelte:head>
47
54
48
55
<div class="max-w-4xl mx-auto">
49
56
{#if loading}
50
57
<div class="text-center py-12">
51
-
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-brown-900"></div>
58
+
<div
59
+
class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-brown-900"
60
+
></div>
52
61
<p class="mt-4 text-brown-700">Loading profile...</p>
53
62
</div>
54
63
{:else if error}
55
-
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
64
+
<div
65
+
class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded"
66
+
>
56
67
Error: {error}
57
68
</div>
58
69
{:else if profile}
59
70
<!-- Profile Header -->
60
-
<div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-6 mb-6 border border-brown-300">
71
+
<div
72
+
class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-6 mb-6 border border-brown-300"
73
+
>
61
74
<div class="flex items-center gap-4">
62
75
{#if profile.avatar}
63
-
<img src={profile.avatar} alt="" class="w-20 h-20 rounded-full object-cover border-2 border-brown-300" />
76
+
<img
77
+
src={profile.avatar}
78
+
alt=""
79
+
class="w-20 h-20 rounded-full object-cover border-2 border-brown-300"
80
+
/>
64
81
{:else}
65
-
<div class="w-20 h-20 rounded-full bg-brown-300 flex items-center justify-center">
82
+
<div
83
+
class="w-20 h-20 rounded-full bg-brown-300 flex items-center justify-center"
84
+
>
66
85
<span class="text-brown-600 text-2xl">?</span>
67
86
</div>
68
87
{/if}
69
88
<div>
70
89
{#if profile.displayName}
71
-
<h1 class="text-2xl font-bold text-brown-900">{profile.displayName}</h1>
90
+
<h1 class="text-2xl font-bold text-brown-900">
91
+
{profile.displayName}
92
+
</h1>
72
93
{/if}
73
94
<p class="text-brown-700">@{profile.handle}</p>
74
95
</div>
···
77
98
78
99
<!-- Stats -->
79
100
<div class="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
80
-
<div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-lg shadow-md p-4 text-center border border-brown-300">
101
+
<div
102
+
class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-lg shadow-md p-4 text-center border border-brown-300"
103
+
>
81
104
<div class="text-2xl font-bold text-brown-800">{brews.length}</div>
82
105
<div class="text-sm text-brown-700">Brews</div>
83
106
</div>
84
-
<div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-lg shadow-md p-4 text-center border border-brown-300">
107
+
<div
108
+
class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-lg shadow-md p-4 text-center border border-brown-300"
109
+
>
85
110
<div class="text-2xl font-bold text-brown-800">{beans.length}</div>
86
111
<div class="text-sm text-brown-700">Beans</div>
87
112
</div>
88
-
<div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-lg shadow-md p-4 text-center border border-brown-300">
113
+
<div
114
+
class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-lg shadow-md p-4 text-center border border-brown-300"
115
+
>
89
116
<div class="text-2xl font-bold text-brown-800">{roasters.length}</div>
90
117
<div class="text-sm text-brown-700">Roasters</div>
91
118
</div>
92
-
<div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-lg shadow-md p-4 text-center border border-brown-300">
119
+
<div
120
+
class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-lg shadow-md p-4 text-center border border-brown-300"
121
+
>
93
122
<div class="text-2xl font-bold text-brown-800">{grinders.length}</div>
94
123
<div class="text-sm text-brown-700">Grinders</div>
95
124
</div>
96
-
<div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-lg shadow-md p-4 text-center border border-brown-300">
125
+
<div
126
+
class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-lg shadow-md p-4 text-center border border-brown-300"
127
+
>
97
128
<div class="text-2xl font-bold text-brown-800">{brewers.length}</div>
98
129
<div class="text-sm text-brown-700">Brewers</div>
99
130
</div>
···
101
132
102
133
<!-- Tabs -->
103
134
<div>
104
-
<div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-md mb-4 border border-brown-300">
135
+
<div
136
+
class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-md mb-4 border border-brown-300"
137
+
>
105
138
<div class="flex border-b border-brown-300">
106
139
<button
107
-
on:click={() => activeTab = 'brews'}
108
-
class="flex-1 py-3 px-4 text-center font-medium transition-colors {activeTab === 'brews' ? 'border-b-2 border-brown-700 text-brown-900' : 'text-brown-600 hover:text-brown-800'}"
140
+
on:click={() => (activeTab = "brews")}
141
+
class="flex-1 py-3 px-4 text-center font-medium transition-colors {activeTab ===
142
+
'brews'
143
+
? 'border-b-2 border-brown-700 text-brown-900'
144
+
: 'text-brown-600 hover:text-brown-800'}"
109
145
>
110
146
Brews
111
147
</button>
112
148
<button
113
-
on:click={() => activeTab = 'beans'}
114
-
class="flex-1 py-3 px-4 text-center font-medium transition-colors {activeTab === 'beans' ? 'border-b-2 border-brown-700 text-brown-900' : 'text-brown-600 hover:text-brown-800'}"
149
+
on:click={() => (activeTab = "beans")}
150
+
class="flex-1 py-3 px-4 text-center font-medium transition-colors {activeTab ===
151
+
'beans'
152
+
? 'border-b-2 border-brown-700 text-brown-900'
153
+
: 'text-brown-600 hover:text-brown-800'}"
115
154
>
116
155
Beans
117
156
</button>
118
157
<button
119
-
on:click={() => activeTab = 'gear'}
120
-
class="flex-1 py-3 px-4 text-center font-medium transition-colors {activeTab === 'gear' ? 'border-b-2 border-brown-700 text-brown-900' : 'text-brown-600 hover:text-brown-800'}"
158
+
on:click={() => (activeTab = "gear")}
159
+
class="flex-1 py-3 px-4 text-center font-medium transition-colors {activeTab ===
160
+
'gear'
161
+
? 'border-b-2 border-brown-700 text-brown-900'
162
+
: 'text-brown-600 hover:text-brown-800'}"
121
163
>
122
164
Gear
123
165
</button>
···
125
167
</div>
126
168
127
169
<!-- Tab Content -->
128
-
{#if activeTab === 'brews'}
170
+
{#if activeTab === "brews"}
129
171
{#if brews.length === 0}
130
-
<div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center border border-brown-300">
172
+
<div
173
+
class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center border border-brown-300"
174
+
>
131
175
<p class="text-brown-800 text-lg font-medium">No brews yet.</p>
132
176
</div>
133
177
{:else}
134
-
<div class="overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300">
178
+
<div
179
+
class="overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300"
180
+
>
135
181
<table class="min-w-full divide-y divide-brown-300">
136
182
<thead class="bg-brown-200/80">
137
183
<tr>
138
-
<th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">📅 Date</th>
139
-
<th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">☕ Bean</th>
140
-
<th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">🫖 Method</th>
141
-
<th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">📝 Notes</th>
142
-
<th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">⭐ Rating</th>
184
+
<th
185
+
class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider"
186
+
>📅 Date</th
187
+
>
188
+
<th
189
+
class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider"
190
+
>☕ Bean</th
191
+
>
192
+
<th
193
+
class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider"
194
+
>🫖 Method</th
195
+
>
196
+
<th
197
+
class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider"
198
+
>📝 Notes</th
199
+
>
200
+
<th
201
+
class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider"
202
+
>⭐ Rating</th
203
+
>
143
204
</tr>
144
205
</thead>
145
206
<tbody class="bg-brown-50/60 divide-y divide-brown-200">
146
207
{#each brews as brew}
147
208
<tr class="hover:bg-brown-100/60 transition-colors">
148
-
<td class="px-4 py-3 text-sm text-brown-900">{formatDate(brew.created_at)}</td>
149
-
<td class="px-4 py-3 text-sm font-bold text-brown-900">{brew.bean?.name || brew.bean?.origin || 'Unknown'}</td>
150
-
<td class="px-4 py-3 text-sm text-brown-900">{brew.brewer_obj?.name || '-'}</td>
151
-
<td class="px-4 py-3 text-sm text-brown-700 truncate max-w-xs">{brew.tasting_notes || '-'}</td>
209
+
<td class="px-4 py-3 text-sm text-brown-900"
210
+
>{formatDate(brew.created_at)}</td
211
+
>
212
+
<td class="px-4 py-3 text-sm font-bold text-brown-900"
213
+
>{brew.bean?.name || brew.bean?.origin || "Unknown"}</td
214
+
>
215
+
<td class="px-4 py-3 text-sm text-brown-900"
216
+
>{brew.brewer_obj?.name || "-"}</td
217
+
>
218
+
<td
219
+
class="px-4 py-3 text-sm text-brown-700 truncate max-w-xs"
220
+
>{brew.tasting_notes || "-"}</td
221
+
>
152
222
<td class="px-4 py-3 text-sm text-brown-900">
153
223
{#if brew.rating}
154
-
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-900">
224
+
<span
225
+
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-900"
226
+
>
155
227
⭐ {brew.rating}/10
156
228
</span>
157
229
{:else}
···
164
236
</table>
165
237
</div>
166
238
{/if}
167
-
{:else if activeTab === 'beans'}
239
+
{:else if activeTab === "beans"}
168
240
<div class="space-y-6">
169
241
{#if beans.length > 0}
170
242
<div>
171
-
<h3 class="text-lg font-semibold text-brown-900 mb-3">☕ Coffee Beans</h3>
172
-
<div class="overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300">
243
+
<h3 class="text-lg font-semibold text-brown-900 mb-3">
244
+
☕ Coffee Beans
245
+
</h3>
246
+
<div
247
+
class="overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300"
248
+
>
173
249
<table class="min-w-full divide-y divide-brown-300">
174
250
<thead class="bg-brown-200/80">
175
251
<tr>
176
-
<th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap">Name</th>
177
-
<th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap">☕ Roaster</th>
178
-
<th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap">📍 Origin</th>
179
-
<th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap">🔥 Roast</th>
180
-
<th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap">🌱 Process</th>
181
-
<th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap">📝 Description</th>
252
+
<th
253
+
class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap"
254
+
>Name</th
255
+
>
256
+
<th
257
+
class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap"
258
+
>☕ Roaster</th
259
+
>
260
+
<th
261
+
class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap"
262
+
>📍 Origin</th
263
+
>
264
+
<th
265
+
class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap"
266
+
>🔥 Roast</th
267
+
>
268
+
<th
269
+
class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap"
270
+
>🌱 Process</th
271
+
>
272
+
<th
273
+
class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap"
274
+
>📝 Description</th
275
+
>
182
276
</tr>
183
277
</thead>
184
278
<tbody class="bg-brown-50/60 divide-y divide-brown-200">
185
279
{#each beans as bean}
186
280
<tr class="hover:bg-brown-100/60 transition-colors">
187
-
<td class="px-6 py-4 text-sm font-bold text-brown-900">{bean.name || bean.origin}</td>
188
-
<td class="px-6 py-4 text-sm text-brown-900">{bean.roaster?.name || '-'}</td>
189
-
<td class="px-6 py-4 text-sm text-brown-900">{bean.origin || '-'}</td>
190
-
<td class="px-6 py-4 text-sm text-brown-900">{bean.roast_level || '-'}</td>
191
-
<td class="px-6 py-4 text-sm text-brown-900">{bean.process || '-'}</td>
192
-
<td class="px-6 py-4 text-sm text-brown-700 italic max-w-xs">{bean.description || '-'}</td>
281
+
<td class="px-6 py-4 text-sm font-bold text-brown-900"
282
+
>{bean.name || bean.origin}</td
283
+
>
284
+
<td class="px-6 py-4 text-sm text-brown-900"
285
+
>{bean.roaster?.name || "-"}</td
286
+
>
287
+
<td class="px-6 py-4 text-sm text-brown-900"
288
+
>{bean.origin || "-"}</td
289
+
>
290
+
<td class="px-6 py-4 text-sm text-brown-900"
291
+
>{bean.roast_level || "-"}</td
292
+
>
293
+
<td class="px-6 py-4 text-sm text-brown-900"
294
+
>{bean.process || "-"}</td
295
+
>
296
+
<td
297
+
class="px-6 py-4 text-sm text-brown-700 italic max-w-xs"
298
+
>{bean.description || "-"}</td
299
+
>
193
300
</tr>
194
301
{/each}
195
302
</tbody>
···
200
307
201
308
{#if roasters.length > 0}
202
309
<div>
203
-
<h3 class="text-lg font-semibold text-brown-900 mb-3">🏭 Favorite Roasters</h3>
204
-
<div class="overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300">
310
+
<h3 class="text-lg font-semibold text-brown-900 mb-3">
311
+
🏭 Favorite Roasters
312
+
</h3>
313
+
<div
314
+
class="overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300"
315
+
>
205
316
<table class="min-w-full divide-y divide-brown-300">
206
317
<thead class="bg-brown-200/80">
207
318
<tr>
208
-
<th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">Name</th>
209
-
<th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">📍 Location</th>
210
-
<th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">🌐 Website</th>
319
+
<th
320
+
class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider"
321
+
>Name</th
322
+
>
323
+
<th
324
+
class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider"
325
+
>📍 Location</th
326
+
>
327
+
<th
328
+
class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider"
329
+
>🌐 Website</th
330
+
>
211
331
</tr>
212
332
</thead>
213
333
<tbody class="bg-brown-50/60 divide-y divide-brown-200">
214
334
{#each roasters as roaster}
215
335
<tr class="hover:bg-brown-100/60 transition-colors">
216
-
<td class="px-6 py-4 text-sm font-bold text-brown-900">{roaster.name}</td>
217
-
<td class="px-6 py-4 text-sm text-brown-900">{roaster.location || '-'}</td>
336
+
<td class="px-6 py-4 text-sm font-bold text-brown-900"
337
+
>{roaster.name}</td
338
+
>
339
+
<td class="px-6 py-4 text-sm text-brown-900"
340
+
>{roaster.location || "-"}</td
341
+
>
218
342
<td class="px-6 py-4 text-sm text-brown-900">
219
343
{#if roaster.website}
220
-
<a href={roaster.website} target="_blank" rel="noopener noreferrer" class="text-brown-700 hover:underline font-medium">Visit Site</a>
344
+
<a
345
+
href={roaster.website}
346
+
target="_blank"
347
+
rel="noopener noreferrer"
348
+
class="text-brown-700 hover:underline font-medium"
349
+
>Visit Site</a
350
+
>
221
351
{:else}
222
352
-
223
353
{/if}
···
231
361
{/if}
232
362
233
363
{#if beans.length === 0 && roasters.length === 0}
234
-
<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">
364
+
<div
365
+
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"
366
+
>
235
367
<p class="font-medium">No beans or roasters yet.</p>
236
368
</div>
237
369
{/if}
238
370
</div>
239
-
{:else if activeTab === 'gear'}
371
+
{:else if activeTab === "gear"}
240
372
<div class="space-y-6">
241
373
{#if grinders.length > 0}
242
374
<div>
243
-
<h3 class="text-lg font-semibold text-brown-900 mb-3">⚙️ Grinders</h3>
244
-
<div class="overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300">
375
+
<h3 class="text-lg font-semibold text-brown-900 mb-3">
376
+
⚙️ Grinders
377
+
</h3>
378
+
<div
379
+
class="overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300"
380
+
>
245
381
<table class="min-w-full divide-y divide-brown-300">
246
382
<thead class="bg-brown-200/80">
247
383
<tr>
248
-
<th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">Name</th>
249
-
<th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">🔧 Type</th>
250
-
<th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">💎 Burrs</th>
251
-
<th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">📝 Notes</th>
384
+
<th
385
+
class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider"
386
+
>Name</th
387
+
>
388
+
<th
389
+
class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider"
390
+
>🔧 Type</th
391
+
>
392
+
<th
393
+
class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider"
394
+
>💎 Burrs</th
395
+
>
396
+
<th
397
+
class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider"
398
+
>📝 Notes</th
399
+
>
252
400
</tr>
253
401
</thead>
254
402
<tbody class="bg-brown-50/60 divide-y divide-brown-200">
255
403
{#each grinders as grinder}
256
404
<tr class="hover:bg-brown-100/60 transition-colors">
257
-
<td class="px-6 py-4 text-sm font-bold text-brown-900">{grinder.name}</td>
258
-
<td class="px-6 py-4 text-sm text-brown-900">{grinder.grinder_type || '-'}</td>
259
-
<td class="px-6 py-4 text-sm text-brown-900">{grinder.burr_type || '-'}</td>
260
-
<td class="px-6 py-4 text-sm text-brown-700 italic max-w-xs">{grinder.notes || '-'}</td>
405
+
<td class="px-6 py-4 text-sm font-bold text-brown-900"
406
+
>{grinder.name}</td
407
+
>
408
+
<td class="px-6 py-4 text-sm text-brown-900"
409
+
>{grinder.grinder_type || "-"}</td
410
+
>
411
+
<td class="px-6 py-4 text-sm text-brown-900"
412
+
>{grinder.burr_type || "-"}</td
413
+
>
414
+
<td
415
+
class="px-6 py-4 text-sm text-brown-700 italic max-w-xs"
416
+
>{grinder.notes || "-"}</td
417
+
>
261
418
</tr>
262
419
{/each}
263
420
</tbody>
···
268
425
269
426
{#if brewers.length > 0}
270
427
<div>
271
-
<h3 class="text-lg font-semibold text-brown-900 mb-3">☕ Brewers</h3>
272
-
<div class="overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300">
428
+
<h3 class="text-lg font-semibold text-brown-900 mb-3">
429
+
☕ Brewers
430
+
</h3>
431
+
<div
432
+
class="overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300"
433
+
>
273
434
<table class="min-w-full divide-y divide-brown-300">
274
435
<thead class="bg-brown-200/80">
275
436
<tr>
276
-
<th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">Name</th>
277
-
<th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">🔧 Type</th>
278
-
<th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">📝 Description</th>
437
+
<th
438
+
class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider"
439
+
>Name</th
440
+
>
441
+
<th
442
+
class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider"
443
+
>🔧 Type</th
444
+
>
445
+
<th
446
+
class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider"
447
+
>📝 Description</th
448
+
>
279
449
</tr>
280
450
</thead>
281
451
<tbody class="bg-brown-50/60 divide-y divide-brown-200">
282
452
{#each brewers as brewer}
283
453
<tr class="hover:bg-brown-100/60 transition-colors">
284
-
<td class="px-6 py-4 text-sm font-bold text-brown-900">{brewer.name}</td>
285
-
<td class="px-6 py-4 text-sm text-brown-900">{brewer.brewer_type || '-'}</td>
286
-
<td class="px-6 py-4 text-sm text-brown-700 italic max-w-xs">{brewer.description || '-'}</td>
454
+
<td class="px-6 py-4 text-sm font-bold text-brown-900"
455
+
>{brewer.name}</td
456
+
>
457
+
<td class="px-6 py-4 text-sm text-brown-900"
458
+
>{brewer.brewer_type || "-"}</td
459
+
>
460
+
<td
461
+
class="px-6 py-4 text-sm text-brown-700 italic max-w-xs"
462
+
>{brewer.description || "-"}</td
463
+
>
287
464
</tr>
288
465
{/each}
289
466
</tbody>
···
293
470
{/if}
294
471
295
472
{#if grinders.length === 0 && brewers.length === 0}
296
-
<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">
473
+
<div
474
+
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"
475
+
>
297
476
<p class="font-medium">No gear added yet.</p>
298
477
</div>
299
478
{/if}
-260
frontend/src/routes/Profile.svelte.backup
-260
frontend/src/routes/Profile.svelte.backup
···
1
-
<script>
2
-
import { onMount } from 'svelte';
3
-
import { authStore } from '../stores/auth.js';
4
-
import { cacheStore } from '../stores/cache.js';
5
-
import { navigate } from '../lib/router.js';
6
-
7
-
export let actor;
8
-
9
-
let profile = null;
10
-
let brews = [];
11
-
let beans = [];
12
-
let roasters = [];
13
-
let grinders = [];
14
-
let brewers = [];
15
-
let isOwnProfile = false;
16
-
let loading = true;
17
-
let error = null;
18
-
19
-
let activeTab = 'brews';
20
-
21
-
$: user = $authStore.user;
22
-
23
-
onMount(async () => {
24
-
try {
25
-
// For now, only support viewing own profile
26
-
// TODO: Implement HandleProfileAPI for viewing other users' profiles
27
-
if (!user) {
28
-
error = 'Not authenticated';
29
-
loading = false;
30
-
return;
31
-
}
32
-
33
-
// Check if viewing own profile
34
-
isOwnProfile = (actor === user.handle || actor === user.did);
35
-
36
-
if (!isOwnProfile) {
37
-
error = 'Viewing other profiles not yet supported';
38
-
loading = false;
39
-
return;
40
-
}
41
-
42
-
// Load own profile from cache
43
-
await cacheStore.load();
44
-
profile = user;
45
-
brews = $cacheStore.brews || [];
46
-
beans = $cacheStore.beans || [];
47
-
roasters = $cacheStore.roasters || [];
48
-
grinders = $cacheStore.grinders || [];
49
-
brewers = $cacheStore.brewers || [];
50
-
} catch (err) {
51
-
console.error('Failed to load profile:', err);
52
-
error = err.message;
53
-
} finally {
54
-
loading = false;
55
-
}
56
-
});
57
-
58
-
function formatDate(dateStr) {
59
-
const date = new Date(dateStr);
60
-
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
61
-
}
62
-
</script>
63
-
64
-
<svelte:head>
65
-
<title>{profile?.displayName || profile?.handle || 'Profile'} - Arabica</title>
66
-
</svelte:head>
67
-
68
-
<div class="max-w-4xl mx-auto">
69
-
{#if loading}
70
-
<div class="text-center py-12">
71
-
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-brown-900"></div>
72
-
<p class="mt-4 text-brown-700">Loading profile...</p>
73
-
</div>
74
-
{:else if error}
75
-
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
76
-
Error: {error}
77
-
</div>
78
-
{:else if profile}
79
-
<!-- Profile Header -->
80
-
<div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-6 mb-6 border border-brown-300">
81
-
<div class="flex items-center gap-4">
82
-
{#if profile.avatar}
83
-
<img src={profile.avatar} alt="" class="w-20 h-20 rounded-full object-cover border-2 border-brown-300" />
84
-
{:else}
85
-
<div class="w-20 h-20 rounded-full bg-brown-300 flex items-center justify-center">
86
-
<span class="text-brown-600 text-2xl">?</span>
87
-
</div>
88
-
{/if}
89
-
<div>
90
-
{#if profile.displayName}
91
-
<h1 class="text-2xl font-bold text-brown-900">{profile.displayName}</h1>
92
-
{/if}
93
-
<p class="text-brown-700">@{profile.handle}</p>
94
-
</div>
95
-
</div>
96
-
</div>
97
-
98
-
<!-- Stats -->
99
-
<div class="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
100
-
<div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-lg shadow-md p-4 text-center border border-brown-300">
101
-
<div class="text-2xl font-bold text-brown-800">{brews.length}</div>
102
-
<div class="text-sm text-brown-700">Brews</div>
103
-
</div>
104
-
<div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-lg shadow-md p-4 text-center border border-brown-300">
105
-
<div class="text-2xl font-bold text-brown-800">{beans.length}</div>
106
-
<div class="text-sm text-brown-700">Beans</div>
107
-
</div>
108
-
<div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-lg shadow-md p-4 text-center border border-brown-300">
109
-
<div class="text-2xl font-bold text-brown-800">{roasters.length}</div>
110
-
<div class="text-sm text-brown-700">Roasters</div>
111
-
</div>
112
-
<div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-lg shadow-md p-4 text-center border border-brown-300">
113
-
<div class="text-2xl font-bold text-brown-800">{grinders.length}</div>
114
-
<div class="text-sm text-brown-700">Grinders</div>
115
-
</div>
116
-
<div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-lg shadow-md p-4 text-center border border-brown-300">
117
-
<div class="text-2xl font-bold text-brown-800">{brewers.length}</div>
118
-
<div class="text-sm text-brown-700">Brewers</div>
119
-
</div>
120
-
</div>
121
-
122
-
<!-- Tabs -->
123
-
<div>
124
-
<div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-md mb-4 border border-brown-300">
125
-
<div class="flex border-b border-brown-300">
126
-
<button
127
-
on:click={() => activeTab = 'brews'}
128
-
class="flex-1 py-3 px-4 text-center font-medium transition-colors {activeTab === 'brews' ? 'border-b-2 border-brown-700 text-brown-900' : 'text-brown-600 hover:text-brown-800'}"
129
-
>
130
-
Brews
131
-
</button>
132
-
<button
133
-
on:click={() => activeTab = 'beans'}
134
-
class="flex-1 py-3 px-4 text-center font-medium transition-colors {activeTab === 'beans' ? 'border-b-2 border-brown-700 text-brown-900' : 'text-brown-600 hover:text-brown-800'}"
135
-
>
136
-
Beans
137
-
</button>
138
-
<button
139
-
on:click={() => activeTab = 'gear'}
140
-
class="flex-1 py-3 px-4 text-center font-medium transition-colors {activeTab === 'gear' ? 'border-b-2 border-brown-700 text-brown-900' : 'text-brown-600 hover:text-brown-800'}"
141
-
>
142
-
Gear
143
-
</button>
144
-
</div>
145
-
</div>
146
-
147
-
<!-- Tab Content -->
148
-
<div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300 p-6">
149
-
{#if activeTab === 'brews'}
150
-
{#if brews.length === 0}
151
-
<p class="text-center text-brown-600 py-8">No brews yet.</p>
152
-
{:else}
153
-
<div class="overflow-x-auto">
154
-
<table class="min-w-full">
155
-
<thead>
156
-
<tr class="border-b border-brown-300">
157
-
<th class="px-4 py-2 text-left text-sm font-medium text-brown-900">Date</th>
158
-
<th class="px-4 py-2 text-left text-sm font-medium text-brown-900">Bean</th>
159
-
<th class="px-4 py-2 text-left text-sm font-medium text-brown-900">Method</th>
160
-
<th class="px-4 py-2 text-left text-sm font-medium text-brown-900">Rating</th>
161
-
<th class="px-4 py-2 text-left text-sm font-medium text-brown-900">Notes</th>
162
-
</tr>
163
-
</thead>
164
-
<tbody>
165
-
{#each brews as brew}
166
-
<tr class="border-b border-brown-200 hover:bg-brown-50">
167
-
<td class="px-4 py-3 text-sm text-brown-800">{formatDate(brew.CreatedAt)}</td>
168
-
<td class="px-4 py-3 text-sm text-brown-800">{brew.Bean?.Name || brew.Bean?.Origin || 'Unknown'}</td>
169
-
<td class="px-4 py-3 text-sm text-brown-800">{brew.BrewerObj?.Name || 'N/A'}</td>
170
-
<td class="px-4 py-3 text-sm text-brown-800">{brew.Rating ? `${brew.Rating}/10` : 'N/A'}</td>
171
-
<td class="px-4 py-3 text-sm text-brown-700 truncate max-w-xs">{brew.Notes || 'No notes'}</td>
172
-
</tr>
173
-
{/each}
174
-
</tbody>
175
-
</table>
176
-
</div>
177
-
{/if}
178
-
{:else if activeTab === 'beans'}
179
-
{#if beans.length === 0}
180
-
<p class="text-center text-brown-600 py-8">No beans yet.</p>
181
-
{:else}
182
-
<div class="grid gap-4">
183
-
{#each beans as bean}
184
-
<div class="bg-brown-50 rounded-lg p-4 border border-brown-200">
185
-
<h3 class="font-semibold text-brown-900">{bean.Name || bean.Origin}</h3>
186
-
<p class="text-sm text-brown-700">Origin: {bean.Origin}</p>
187
-
{#if bean.RoastLevel}
188
-
<p class="text-sm text-brown-700">Roast: {bean.RoastLevel}</p>
189
-
{/if}
190
-
{#if bean.Roaster}
191
-
<p class="text-sm text-brown-600">Roaster: {bean.Roaster.Name}</p>
192
-
{/if}
193
-
</div>
194
-
{/each}
195
-
</div>
196
-
{/if}
197
-
{:else if activeTab === 'gear'}
198
-
<div class="space-y-6">
199
-
<!-- Roasters -->
200
-
<div>
201
-
<h3 class="text-lg font-bold text-brown-900 mb-2">Roasters</h3>
202
-
{#if roasters.length === 0}
203
-
<p class="text-brown-600">No roasters yet.</p>
204
-
{:else}
205
-
<div class="grid gap-2">
206
-
{#each roasters as roaster}
207
-
<div class="bg-brown-50 rounded p-3 border border-brown-200">
208
-
<p class="font-medium text-brown-900">{roaster.Name}</p>
209
-
{#if roaster.Location}
210
-
<p class="text-sm text-brown-700">{roaster.Location}</p>
211
-
{/if}
212
-
</div>
213
-
{/each}
214
-
</div>
215
-
{/if}
216
-
</div>
217
-
218
-
<!-- Grinders -->
219
-
<div>
220
-
<h3 class="text-lg font-bold text-brown-900 mb-2">Grinders</h3>
221
-
{#if grinders.length === 0}
222
-
<p class="text-brown-600">No grinders yet.</p>
223
-
{:else}
224
-
<div class="grid gap-2">
225
-
{#each grinders as grinder}
226
-
<div class="bg-brown-50 rounded p-3 border border-brown-200">
227
-
<p class="font-medium text-brown-900">{grinder.Name}</p>
228
-
{#if grinder.GrinderType}
229
-
<p class="text-sm text-brown-700">{grinder.GrinderType}</p>
230
-
{/if}
231
-
</div>
232
-
{/each}
233
-
</div>
234
-
{/if}
235
-
</div>
236
-
237
-
<!-- Brewers -->
238
-
<div>
239
-
<h3 class="text-lg font-bold text-brown-900 mb-2">Brewers</h3>
240
-
{#if brewers.length === 0}
241
-
<p class="text-brown-600">No brewers yet.</p>
242
-
{:else}
243
-
<div class="grid gap-2">
244
-
{#each brewers as brewer}
245
-
<div class="bg-brown-50 rounded p-3 border border-brown-200">
246
-
<p class="font-medium text-brown-900">{brewer.Name}</p>
247
-
{#if brewer.BrewerType}
248
-
<p class="text-sm text-brown-700">{brewer.BrewerType}</p>
249
-
{/if}
250
-
</div>
251
-
{/each}
252
-
</div>
253
-
{/if}
254
-
</div>
255
-
</div>
256
-
{/if}
257
-
</div>
258
-
</div>
259
-
{/if}
260
-
</div>
+10
-10
frontend/src/stores/auth.js
+10
-10
frontend/src/stores/auth.js
···
1
-
import { writable } from 'svelte/store';
2
-
import { api } from '../lib/api.js';
1
+
import { writable } from "svelte/store";
2
+
import { api } from "../lib/api.js";
3
3
4
4
/**
5
5
* Auth store - tracks current user authentication state
···
10
10
user: null,
11
11
loading: true,
12
12
});
13
-
13
+
14
14
return {
15
15
subscribe,
16
-
16
+
17
17
/**
18
18
* Check current authentication status
19
19
*/
20
20
async checkAuth() {
21
21
try {
22
-
const user = await api.get('/api/me');
22
+
const user = await api.get("/api/me");
23
23
set({
24
24
isAuthenticated: true,
25
25
user,
···
33
33
});
34
34
}
35
35
},
36
-
36
+
37
37
/**
38
38
* Log out current user
39
39
*/
40
40
async logout() {
41
41
try {
42
-
await api.post('/logout', {});
42
+
await api.post("/logout", {});
43
43
set({
44
44
isAuthenticated: false,
45
45
user: null,
46
46
loading: false,
47
47
});
48
-
window.location.href = '/';
48
+
window.location.href = "/";
49
49
} catch (error) {
50
-
console.error('Logout failed:', error);
50
+
console.error("Logout failed:", error);
51
51
}
52
52
},
53
-
53
+
54
54
/**
55
55
* Clear auth state (used after logout)
56
56
*/
+26
-23
frontend/src/stores/cache.js
+26
-23
frontend/src/stores/cache.js
···
1
-
import { writable } from 'svelte/store';
2
-
import { api } from '../lib/api.js';
1
+
import { writable } from "svelte/store";
2
+
import { api } from "../lib/api.js";
3
3
4
4
/**
5
5
* Cache store - stale-while-revalidate pattern for user data
···
15
15
lastFetch: null,
16
16
loading: false,
17
17
});
18
-
19
-
const CACHE_KEY = 'arabica_data_cache';
18
+
19
+
const CACHE_KEY = "arabica_data_cache";
20
20
const STALE_TIME = 5 * 60 * 1000; // 5 minutes
21
-
21
+
22
22
return {
23
23
subscribe,
24
-
24
+
25
25
/**
26
26
* Load data from cache or API
27
27
* Uses stale-while-revalidate pattern
···
34
34
try {
35
35
const data = JSON.parse(cached);
36
36
const age = Date.now() - data.timestamp;
37
-
37
+
38
38
if (age < STALE_TIME) {
39
39
// Fresh cache, use it
40
40
set({
···
44
44
});
45
45
return;
46
46
}
47
-
47
+
48
48
// Stale cache, show it but refetch in background
49
49
set({
50
50
...data,
···
52
52
loading: true,
53
53
});
54
54
} catch (e) {
55
-
console.error('Failed to parse cache:', e);
55
+
console.error("Failed to parse cache:", e);
56
56
}
57
57
}
58
58
}
59
-
59
+
60
60
// Fetch fresh data
61
61
try {
62
-
update(state => ({ ...state, loading: true }));
63
-
64
-
const data = await api.get('/api/data');
62
+
update((state) => ({ ...state, loading: true }));
63
+
64
+
const data = await api.get("/api/data");
65
65
const newState = {
66
66
beans: data.beans || [],
67
67
roasters: data.roasters || [],
···
71
71
lastFetch: Date.now(),
72
72
loading: false,
73
73
};
74
-
74
+
75
75
set(newState);
76
-
76
+
77
77
// Save to localStorage
78
-
localStorage.setItem(CACHE_KEY, JSON.stringify({
79
-
...newState,
80
-
timestamp: newState.lastFetch,
81
-
}));
78
+
localStorage.setItem(
79
+
CACHE_KEY,
80
+
JSON.stringify({
81
+
...newState,
82
+
timestamp: newState.lastFetch,
83
+
}),
84
+
);
82
85
} catch (error) {
83
-
console.error('Failed to fetch data:', error);
84
-
update(state => ({ ...state, loading: false }));
86
+
console.error("Failed to fetch data:", error);
87
+
update((state) => ({ ...state, loading: false }));
85
88
}
86
89
},
87
-
90
+
88
91
/**
89
92
* Invalidate cache and refetch
90
93
*/
···
92
95
localStorage.removeItem(CACHE_KEY);
93
96
await this.load(true);
94
97
},
95
-
98
+
96
99
/**
97
100
* Clear cache completely
98
101
*/
+10
-10
frontend/src/stores/ui.js
+10
-10
frontend/src/stores/ui.js
···
1
-
import { writable } from 'svelte/store';
1
+
import { writable } from "svelte/store";
2
2
3
3
/**
4
4
* UI store - manages global UI state like modals, notifications, etc.
···
7
7
const { subscribe, update } = writable({
8
8
notifications: [],
9
9
});
10
-
10
+
11
11
return {
12
12
subscribe,
13
-
13
+
14
14
/**
15
15
* Show a notification
16
16
* @param {string} message - Notification message
17
17
* @param {string} type - Type: 'success', 'error', 'info'
18
18
* @param {number} duration - Duration in ms (0 = no auto-dismiss)
19
19
*/
20
-
notify(message, type = 'info', duration = 5000) {
20
+
notify(message, type = "info", duration = 5000) {
21
21
const id = Date.now();
22
-
update(state => ({
22
+
update((state) => ({
23
23
...state,
24
24
notifications: [...state.notifications, { id, message, type }],
25
25
}));
26
-
26
+
27
27
if (duration > 0) {
28
28
setTimeout(() => {
29
29
this.dismissNotification(id);
30
30
}, duration);
31
31
}
32
-
32
+
33
33
return id;
34
34
},
35
-
35
+
36
36
/**
37
37
* Dismiss a notification by ID
38
38
*/
39
39
dismissNotification(id) {
40
-
update(state => ({
40
+
update((state) => ({
41
41
...state,
42
-
notifications: state.notifications.filter(n => n.id !== id),
42
+
notifications: state.notifications.filter((n) => n.id !== id),
43
43
}));
44
44
},
45
45
};
+1
-1
go.mod
+1
-1
go.mod
···
6
6
github.com/bluesky-social/indigo v0.0.0-20260106221649-6fcd9317e725
7
7
github.com/gorilla/websocket v1.5.3
8
8
github.com/klauspost/compress v1.18.3
9
+
github.com/ptdewey/shutter v0.1.4
9
10
github.com/rs/zerolog v1.34.0
10
11
github.com/stretchr/testify v1.10.0
11
12
go.etcd.io/bbolt v1.3.8
···
31
32
github.com/prometheus/client_model v0.5.0 // indirect
32
33
github.com/prometheus/common v0.45.0 // indirect
33
34
github.com/prometheus/procfs v0.12.0 // indirect
34
-
github.com/ptdewey/shutter v0.1.4 // indirect
35
35
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
36
36
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
37
37
golang.org/x/crypto v0.21.0 // indirect
-160
scripts/diagnose-feed-db.sh
-160
scripts/diagnose-feed-db.sh
···
1
-
#!/bin/bash
2
-
# Diagnostic script to check feed database status
3
-
4
-
set -e
5
-
6
-
DB_PATH="${ARABICA_FEED_INDEX_PATH:-$HOME/.local/share/arabica/feed-index.db}"
7
-
8
-
echo "=== Feed Database Diagnostics ==="
9
-
echo "Database path: $DB_PATH"
10
-
echo ""
11
-
12
-
if [ ! -f "$DB_PATH" ]; then
13
-
echo "ERROR: Database file does not exist at $DB_PATH"
14
-
exit 1
15
-
fi
16
-
17
-
echo "Database file size: $(du -h "$DB_PATH" | cut -f1)"
18
-
echo "Last modified: $(stat -c %y "$DB_PATH" 2>/dev/null || stat -f "%Sm" "$DB_PATH")"
19
-
echo ""
20
-
21
-
# Create a simple Go program to inspect the database
22
-
cat > /tmp/inspect-feed-db.go << 'EOF'
23
-
package main
24
-
25
-
import (
26
-
"encoding/binary"
27
-
"encoding/json"
28
-
"fmt"
29
-
"os"
30
-
"time"
31
-
32
-
bolt "go.etcd.io/bbolt"
33
-
)
34
-
35
-
type IndexedRecord struct {
36
-
URI string `json:"uri"`
37
-
DID string `json:"did"`
38
-
Collection string `json:"collection"`
39
-
RKey string `json:"rkey"`
40
-
Record json.RawMessage `json:"record"`
41
-
CID string `json:"cid"`
42
-
IndexedAt time.Time `json:"indexed_at"`
43
-
CreatedAt time.Time `json:"created_at"`
44
-
}
45
-
46
-
func main() {
47
-
dbPath := os.Args[1]
48
-
49
-
db, err := bolt.Open(dbPath, 0600, &bolt.Options{ReadOnly: true, Timeout: 5 * time.Second})
50
-
if err != nil {
51
-
fmt.Printf("ERROR: Failed to open database: %v\n", err)
52
-
os.Exit(1)
53
-
}
54
-
defer db.Close()
55
-
56
-
err = db.View(func(tx *bolt.Tx) error {
57
-
// Check buckets
58
-
records := tx.Bucket([]byte("records"))
59
-
byTime := tx.Bucket([]byte("by_time"))
60
-
meta := tx.Bucket([]byte("meta"))
61
-
knownDIDs := tx.Bucket([]byte("known_dids"))
62
-
backfilled := tx.Bucket([]byte("backfilled"))
63
-
64
-
if records == nil {
65
-
fmt.Println("ERROR: 'records' bucket does not exist")
66
-
return nil
67
-
}
68
-
69
-
recordCount := records.Stats().KeyN
70
-
fmt.Printf("Total records: %d\n", recordCount)
71
-
72
-
if byTime != nil {
73
-
timeIndexCount := byTime.Stats().KeyN
74
-
fmt.Printf("Time index entries: %d\n", timeIndexCount)
75
-
}
76
-
77
-
if knownDIDs != nil {
78
-
didCount := knownDIDs.Stats().KeyN
79
-
fmt.Printf("Known DIDs: %d\n", didCount)
80
-
knownDIDs.ForEach(func(k, v []byte) error {
81
-
fmt.Printf(" - %s\n", string(k))
82
-
return nil
83
-
})
84
-
}
85
-
86
-
if backfilled != nil {
87
-
backfilledCount := backfilled.Stats().KeyN
88
-
fmt.Printf("Backfilled DIDs: %d\n", backfilledCount)
89
-
}
90
-
91
-
// Check cursor
92
-
if meta != nil {
93
-
cursorBytes := meta.Get([]byte("cursor"))
94
-
if cursorBytes != nil && len(cursorBytes) == 8 {
95
-
cursor := int64(binary.BigEndian.Uint64(cursorBytes))
96
-
cursorTime := time.UnixMicro(cursor)
97
-
fmt.Printf("\nCursor position: %d (%s)\n", cursor, cursorTime.Format(time.RFC3339))
98
-
} else {
99
-
fmt.Println("\nNo cursor found in database")
100
-
}
101
-
}
102
-
103
-
// Get first 5 and last 5 records by time
104
-
if byTime != nil && records != nil {
105
-
fmt.Println("\n=== First 5 records (oldest) ===")
106
-
c := byTime.Cursor()
107
-
count := 0
108
-
for k, _ := c.First(); k != nil && count < 5; k, _ = c.Next() {
109
-
uri := extractURI(k)
110
-
if record := getRecord(records, uri); record != nil {
111
-
fmt.Printf("%s - %s - %s\n", record.CreatedAt.Format("2006-01-02 15:04:05"), record.Collection, uri)
112
-
}
113
-
count++
114
-
}
115
-
116
-
fmt.Println("\n=== Last 5 records (newest with inverted timestamps) ===")
117
-
c = byTime.Cursor()
118
-
count = 0
119
-
for k, _ := c.Last(); k != nil && count < 5; k, _ = c.Prev() {
120
-
uri := extractURI(k)
121
-
if record := getRecord(records, uri); record != nil {
122
-
fmt.Printf("%s - %s - %s\n", record.CreatedAt.Format("2006-01-02 15:04:05"), record.Collection, uri)
123
-
}
124
-
count++
125
-
}
126
-
}
127
-
128
-
return nil
129
-
})
130
-
131
-
if err != nil {
132
-
fmt.Printf("ERROR: %v\n", err)
133
-
os.Exit(1)
134
-
}
135
-
}
136
-
137
-
func extractURI(key []byte) string {
138
-
if len(key) < 10 {
139
-
return ""
140
-
}
141
-
return string(key[9:])
142
-
}
143
-
144
-
func getRecord(bucket *bolt.Bucket, uri string) *IndexedRecord {
145
-
data := bucket.Get([]byte(uri))
146
-
if data == nil {
147
-
return nil
148
-
}
149
-
var record IndexedRecord
150
-
if err := json.Unmarshal(data, &record); err != nil {
151
-
return nil
152
-
}
153
-
return &record
154
-
}
155
-
EOF
156
-
157
-
cd "$(dirname "$0")/.."
158
-
go run /tmp/inspect-feed-db.go "$DB_PATH"
159
-
160
-
rm -f /tmp/inspect-feed-db.go
-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")});
+42
-25
static/app/index.html
+42
-25
static/app/index.html
···
1
-
<!DOCTYPE html>
1
+
<!doctype html>
2
2
<html lang="en">
3
-
<head>
4
-
<meta charset="UTF-8">
5
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6
-
<title>Arabica - Coffee Brew Tracker</title>
7
-
<meta name="description" content="Track your coffee brewing journey with detailed logs stored in your Personal Data Server">
8
-
9
-
<!-- Tailwind CSS -->
10
-
<link rel="stylesheet" href="/static/css/output.css?v=0.1.3">
11
-
12
-
<!-- Favicon -->
13
-
<link rel="icon" type="image/svg+xml" href="/static/images/favicon.svg">
14
-
<link rel="icon" type="image/png" sizes="32x32" href="/static/images/favicon-32x32.png">
15
-
<link rel="icon" type="image/png" sizes="16x16" href="/static/images/favicon-16x16.png">
16
-
<link rel="apple-touch-icon" sizes="180x180" href="/static/images/apple-touch-icon.png">
17
-
18
-
<!-- Web Manifest -->
19
-
<link rel="manifest" href="/static/manifest.json">
20
-
<meta name="theme-color" content="#78350f">
21
-
<script type="module" crossorigin src="/static/app/assets/index-D8yIXtJi.js"></script>
22
-
<link rel="stylesheet" crossorigin href="/static/app/assets/index-C3lHx5fe.css">
23
-
</head>
24
-
<body class="bg-brown-50 text-brown-900 min-h-screen">
25
-
<div id="app"></div>
26
-
</body>
3
+
<head>
4
+
<meta charset="UTF-8" />
5
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+
<title>Arabica - Coffee Brew Tracker</title>
7
+
<meta
8
+
name="description"
9
+
content="Track your coffee brewing journey with detailed logs stored in your Personal Data Server"
10
+
/>
11
+
12
+
<!-- Tailwind CSS -->
13
+
<link rel="stylesheet" href="/static/css/output.css?v=0.1.4" />
14
+
15
+
<!-- Favicon -->
16
+
<link rel="icon" type="image/svg+xml" href="/static/images/favicon.svg" />
17
+
<link
18
+
rel="icon"
19
+
type="image/png"
20
+
sizes="32x32"
21
+
href="/static/images/favicon-32x32.png"
22
+
/>
23
+
<link
24
+
rel="icon"
25
+
type="image/png"
26
+
sizes="16x16"
27
+
href="/static/images/favicon-16x16.png"
28
+
/>
29
+
<link
30
+
rel="apple-touch-icon"
31
+
sizes="180x180"
32
+
href="/static/images/apple-touch-icon.png"
33
+
/>
34
+
35
+
<!-- Web Manifest -->
36
+
<link rel="manifest" href="/static/manifest.json" />
37
+
<meta name="theme-color" content="#78350f" />
38
+
<script type="module" crossorigin src="/static/app/assets/index-DFzdzahB.js"></script>
39
+
<link rel="stylesheet" crossorigin href="/static/app/assets/index-CUgWAAmN.css">
40
+
</head>
41
+
<body class="bg-brown-50 text-brown-900 min-h-screen">
42
+
<div id="app"></div>
43
+
</body>
27
44
</html>
History
1 round
0 comments
pdewey.com
submitted
#0
1 commit
expand
collapse
chore: formatting and cleanup
expand 0 comments
closed without merging