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

chore: formatting and cleanup

pdewey.com 26b505dc 50a26f51

verified
+1745 -4526
+1 -1
.gitignore
··· 50 50 known-dids.txt 51 51 52 52 node_modules 53 - web/static/app/assets 53 + static/app/assets
+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
Caddyfile deploy/Caddyfile
Dockerfile deploy/Dockerfile
-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
··· 39 39 40 40 ## Docker 41 41 42 + Build and run with Docker Compose: 43 + 42 44 ```bash 43 - # Build and run with Docker Compose 44 - docker compose up -d 45 - 46 - # Or build and run manually 47 - docker build -t arabica . 48 - docker run -p 18910:18910 -v arabica-data:/data arabica 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`:
compose.yml deploy/compose.yml
-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
··· 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
··· 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
··· 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
··· 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
··· 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>
+30 -11
frontend/src/components/Footer.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 <footer class="bg-brown-800 text-brown-100 mt-12"> ··· 11 11 <span>Arabica</span> 12 12 </h3> 13 13 <p class="text-sm text-brown-300"> 14 - Track your coffee brewing journey with decentralized data storage powered by AT Protocol. 14 + Track your coffee brewing journey with decentralized data storage 15 + powered by AT Protocol. 15 16 </p> 16 17 </div> 17 - 18 + 18 19 <div> 19 20 <h4 class="font-semibold mb-3">Links</h4> 20 21 <ul class="space-y-2 text-sm"> 21 22 <li> 22 - <a href="/about" on:click|preventDefault={() => navigate('/about')} class="text-brown-300 hover:text-white transition-colors"> 23 + <a 24 + href="/about" 25 + on:click|preventDefault={() => navigate("/about")} 26 + class="text-brown-300 hover:text-white transition-colors" 27 + > 23 28 About 24 29 </a> 25 30 </li> 26 31 <li> 27 - <a href="/terms" on:click|preventDefault={() => navigate('/terms')} class="text-brown-300 hover:text-white transition-colors"> 32 + <a 33 + href="/terms" 34 + on:click|preventDefault={() => navigate("/terms")} 35 + class="text-brown-300 hover:text-white transition-colors" 36 + > 28 37 Terms of Service 29 38 </a> 30 39 </li> 31 40 <li> 32 - <a href="https://github.com/arabica-social/arabica" target="_blank" rel="noopener noreferrer" class="text-brown-300 hover:text-white transition-colors"> 41 + <a 42 + href="https://github.com/arabica-social/arabica" 43 + target="_blank" 44 + rel="noopener noreferrer" 45 + class="text-brown-300 hover:text-white transition-colors" 46 + > 33 47 GitHub 34 48 </a> 35 49 </li> 36 50 </ul> 37 51 </div> 38 - 52 + 39 53 <div> 40 54 <h4 class="font-semibold mb-3">AT Protocol</h4> 41 55 <p class="text-sm text-brown-300"> 42 - Your data lives in your Personal Data Server (PDS), giving you full ownership and portability. 56 + Your data lives in your Personal Data Server (PDS), giving you full 57 + ownership and portability. 43 58 </p> 44 59 </div> 45 60 </div> 46 - 47 - <div class="border-t border-brown-700 mt-8 pt-6 text-center text-sm text-brown-400"> 48 - <p>&copy; {new Date().getFullYear()} Arabica Social. All rights reserved.</p> 61 + 62 + <div 63 + class="border-t border-brown-700 mt-8 pt-6 text-center text-sm text-brown-400" 64 + > 65 + <p> 66 + &copy; {new Date().getFullYear()} Arabica Social. All rights reserved. 67 + </p> 49 68 </div> 50 69 </div> 51 70 </footer>
+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
··· 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
··· 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
··· 1 - import navaid from 'navaid'; 1 + import navaid from "navaid"; 2 2 3 3 /** 4 4 * Simple client-side router using navaid 5 5 * Handles browser history and navigation 6 6 */ 7 - const router = navaid('/'); 7 + const router = navaid("/"); 8 8 9 9 /** 10 10 * Navigate to a route programmatically
+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
··· 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
··· 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
··· 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 !== ""; 87 91 } 88 - 92 + 93 + function formatTemperature(temp) { 94 + if (!hasValue(temp)) return null; 95 + const unit = temp <= 100 ? "C" : "F"; 96 + return `${temp}°${unit}`; 97 + } 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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 - .line-clamp-2.svelte-efadq{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}@keyframes svelte-1hp7v65-fade-in{0%{opacity:0;transform:translateY(-10px)}to{opacity:1;transform:translateY(0)}}.animate-fade-in.svelte-1hp7v65{animation:svelte-1hp7v65-fade-in .2s ease-out}
-13
static/app/assets/index-D8yIXtJi.js
··· 1 - var lr=Object.defineProperty;var rr=(n,e,t)=>e in n?lr(n,e,{enumerable:!0,configurable:!0,writable:!0,value:t}):n[e]=t;var $t=(n,e,t)=>rr(n,typeof e!="symbol"?e+"":e,t);(function(){const e=document.createElement("link").relList;if(e&&e.supports&&e.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))l(i);new MutationObserver(i=>{for(const r of i)if(r.type==="childList")for(const s of r.addedNodes)s.tagName==="LINK"&&s.rel==="modulepreload"&&l(s)}).observe(document,{childList:!0,subtree:!0});function t(i){const r={};return i.integrity&&(r.integrity=i.integrity),i.referrerPolicy&&(r.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?r.credentials="include":i.crossOrigin==="anonymous"?r.credentials="omit":r.credentials="same-origin",r}function l(i){if(i.ep)return;i.ep=!0;const r=t(i);fetch(i.href,r)}})();function W(){}function tn(n,e){for(const t in e)n[t]=e[t];return n}function $l(n){return n()}function pn(){return Object.create(null)}function ce(n){n.forEach($l)}function Vt(n){return typeof n=="function"}function We(n,e){return n!=n?e==e:n!==e||n&&typeof n=="object"||typeof n=="function"}let It;function gt(n,e){return n===e?!0:(It||(It=document.createElement("a")),It.href=e,n===It.href)}function or(n){return Object.keys(n).length===0}function ir(n,...e){if(n==null){for(const l of e)l(void 0);return W}const t=n.subscribe(...e);return t.unsubscribe?()=>t.unsubscribe():t}function ut(n,e,t){n.$$.on_destroy.push(ir(e,t))}function sr(n,e,t,l){if(n){const i=er(n,e,t,l);return n[0](i)}}function er(n,e,t,l){return n[1]&&l?tn(t.ctx.slice(),n[1](l(e))):t.ctx}function ar(n,e,t,l){return n[2],e.dirty}function ur(n,e,t,l,i,r){if(i){const s=er(e,t,l,r);n.p(s,i)}}function cr(n){if(n.ctx.length>32){const e=[],t=n.ctx.length/32;for(let l=0;l<t;l++)e[l]=-1;return e}return-1}const fr=typeof window<"u"?window:typeof globalThis<"u"?globalThis:global;function o(n,e){n.appendChild(e)}function y(n,e,t){n.insertBefore(e,t||null)}function k(n){n.parentNode&&n.parentNode.removeChild(n)}function Ge(n,e){for(let t=0;t<n.length;t+=1)n[t]&&n[t].d(e)}function f(n){return document.createElement(n)}function _n(n){return document.createElementNS("http://www.w3.org/2000/svg",n)}function C(n){return document.createTextNode(n)}function w(){return C(" ")}function ft(){return C("")}function z(n,e,t,l){return n.addEventListener(e,t,l),()=>n.removeEventListener(e,t,l)}function Ue(n){return function(e){return e.preventDefault(),n.call(this,e)}}function dr(n){return function(e){return e.stopPropagation(),n.call(this,e)}}function a(n,e,t){t==null?n.removeAttribute(e):n.getAttribute(e)!==t&&n.setAttribute(e,t)}function ot(n){return n===""?null:+n}function br(n){return Array.from(n.childNodes)}function j(n,e){e=""+e,n.data!==e&&(n.data=e)}function H(n,e){n.value=e??""}function Le(n,e,t){for(let l=0;l<n.options.length;l+=1){const i=n.options[l];if(i.__value===e){i.selected=!0;return}}(!t||e!==void 0)&&(n.selectedIndex=-1)}function at(n){const e=n.querySelector(":checked");return e&&e.__value}function mn(n,e){return new n(e)}let Rt;function Ft(n){Rt=n}function pr(){if(!Rt)throw new Error("Function called outside component initialization");return Rt}function vt(n){pr().$$.on_mount.push(n)}const Ot=[],ct=[];let Mt=[];const nn=[],_r=Promise.resolve();let ln=!1;function mr(){ln||(ln=!0,_r.then(tr))}function rt(n){Mt.push(n)}function mt(n){nn.push(n)}const en=new Set;let Tt=0;function tr(){if(Tt!==0)return;const n=Rt;do{try{for(;Tt<Ot.length;){const e=Ot[Tt];Tt++,Ft(e),wr(e.$$)}}catch(e){throw Ot.length=0,Tt=0,e}for(Ft(null),Ot.length=0,Tt=0;ct.length;)ct.pop()();for(let e=0;e<Mt.length;e+=1){const t=Mt[e];en.has(t)||(en.add(t),t())}Mt.length=0}while(Ot.length);for(;nn.length;)nn.pop()();ln=!1,en.clear(),Ft(n)}function wr(n){if(n.fragment!==null){n.update(),ce(n.before_update);const e=n.dirty;n.dirty=[-1],n.fragment&&n.fragment.p(n.ctx,e),n.after_update.forEach(rt)}}function hr(n){const e=[],t=[];Mt.forEach(l=>n.indexOf(l)===-1?e.push(l):t.push(l)),t.forEach(l=>l()),Mt=e}const qt=new Set;let xt;function jt(){xt={r:0,c:[],p:xt}}function Ht(){xt.r||ce(xt.c),xt=xt.p}function ve(n,e){n&&n.i&&(qt.delete(n),n.i(e))}function Oe(n,e,t,l){if(n&&n.o){if(qt.has(n))return;qt.add(n),xt.c.push(()=>{qt.delete(n),l&&(t&&n.d(1),l())}),n.o(e)}else l&&l()}function le(n){return(n==null?void 0:n.length)!==void 0?n:Array.from(n)}function gr(n,e){Oe(n,1,1,()=>{e.delete(n.key)})}function vr(n,e,t,l,i,r,s,c,u,b,d,p){let _=n.length,m=r.length,h=_;const g={};for(;h--;)g[n[h].key]=h;const B=[],v=new Map,x=new Map,S=[];for(h=m;h--;){const L=p(i,r,h),O=t(L);let D=s.get(O);D?S.push(()=>D.p(L,e)):(D=b(O,L),D.c()),v.set(O,B[h]=D),O in g&&x.set(O,Math.abs(h-g[O]))}const A=new Set,N=new Set;function P(L){ve(L,1),L.m(c,d),s.set(L.key,L),d=L.first,m--}for(;_&&m;){const L=B[m-1],O=n[_-1],D=L.key,F=O.key;L===O?(d=L.first,_--,m--):v.has(F)?!s.has(D)||A.has(D)?P(L):N.has(F)?_--:x.get(D)>x.get(F)?(N.add(D),P(L)):(A.add(F),_--):(u(O,s),_--)}for(;_--;){const L=n[_];v.has(L.key)||u(L,s)}for(;m;)P(B[m-1]);return ce(S),B}function wn(n,e){const t={},l={},i={$$scope:1};let r=n.length;for(;r--;){const s=n[r],c=e[r];if(c){for(const u in s)u in c||(l[u]=1);for(const u in c)i[u]||(t[u]=c[u],i[u]=1);n[r]=c}else for(const u in s)i[u]=1}for(const s in l)s in t||(t[s]=void 0);return t}function hn(n){return typeof n=="object"&&n!==null?n:{}}function wt(n,e,t){const l=n.$$.props[e];l!==void 0&&(n.$$.bound[l]=t,t(n.$$.ctx[l]))}function it(n){n&&n.c()}function nt(n,e,t){const{fragment:l,after_update:i}=n.$$;l&&l.m(e,t),rt(()=>{const r=n.$$.on_mount.map($l).filter(Vt);n.$$.on_destroy?n.$$.on_destroy.push(...r):ce(r),n.$$.on_mount=[]}),i.forEach(rt)}function lt(n,e){const t=n.$$;t.fragment!==null&&(hr(t.after_update),ce(t.on_destroy),t.fragment&&t.fragment.d(e),t.on_destroy=t.fragment=null,t.ctx=[])}function kr(n,e){n.$$.dirty[0]===-1&&(Ot.push(n),mr(),n.$$.dirty.fill(0)),n.$$.dirty[e/31|0]|=1<<e%31}function Qe(n,e,t,l,i,r,s=null,c=[-1]){const u=Rt;Ft(n);const b=n.$$={fragment:null,ctx:[],props:r,update:W,not_equal:i,bound:pn(),on_mount:[],on_destroy:[],on_disconnect:[],before_update:[],after_update:[],context:new Map(e.context||(u?u.$$.context:[])),callbacks:pn(),dirty:c,skip_bound:!1,root:e.target||u.$$.root};s&&s(b.root);let d=!1;if(b.ctx=t?t(n,e.props||{},(p,_,...m)=>{const h=m.length?m[0]:_;return b.ctx&&i(b.ctx[p],b.ctx[p]=h)&&(!b.skip_bound&&b.bound[p]&&b.bound[p](h),d&&kr(n,p)),_}):[],b.update(),d=!0,ce(b.before_update),b.fragment=l?l(b.ctx):!1,e.target){if(e.hydrate){const p=br(e.target);b.fragment&&b.fragment.l(p),p.forEach(k)}else b.fragment&&b.fragment.c();e.intro&&ve(n.$$.fragment),nt(n,e.target,e.anchor),tr()}Ft(u)}class Xe{constructor(){$t(this,"$$");$t(this,"$$set")}$destroy(){lt(this,1),this.$destroy=W}$on(e,t){if(!Vt(t))return W;const l=this.$$.callbacks[e]||(this.$$.callbacks[e]=[]);return l.push(t),()=>{const i=l.indexOf(t);i!==-1&&l.splice(i,1)}}$set(e){this.$$set&&!or(e)&&(this.$$.skip_bound=!0,this.$$set(e),this.$$.skip_bound=!1)}}const yr="4";typeof window<"u"&&(window.__svelte||(window.__svelte={v:new Set})).v.add(yr);function xr(n,e){if(n instanceof RegExp)return{keys:!1,pattern:n};var t,l,i,r,s=[],c="",u=n.split("/");for(u[0]||u.shift();i=u.shift();)t=i[0],t==="*"?(s.push("wild"),c+="/(.*)"):t===":"?(l=i.indexOf("?",1),r=i.indexOf(".",1),s.push(i.substring(1,~l?l:~r?r:i.length)),c+=~l&&!~r?"(?:/([^/]+?))?":"/([^/]+?)",~r&&(c+=(~l?"?":"")+"\\"+i.substring(r))):c+="/"+i;return{keys:s,pattern:new RegExp("^"+c+"/?$","i")}}function Cr(n,e){var t,l,i=[],r={},s=r.format=function(c){return c&&(c="/"+c.replace(/^\/|\/$/g,""),t.test(c)&&c.replace(t,"/"))};return n="/"+(n||"").replace(/^\/|\/$/g,""),t=n=="/"?/^\/+/:new RegExp("^\\"+n+"(?=\\/|$)\\/?","i"),r.route=function(c,u){c[0]=="/"&&!t.test(c)&&(c=n+c),history[(c===l||u?"replace":"push")+"State"](c,null,c)},r.on=function(c,u){return(c=xr(c)).fn=u,i.push(c),r},r.run=function(c){var u=0,b={},d,p;if(c=s(c||location.pathname)){for(c=c.match(/[^\?#]*/)[0],l=c;u<i.length;u++)if(d=(p=i[u]).pattern.exec(c)){for(u=0;u<p.keys.length;)b[p.keys[u]]=d[++u]||null;return p.fn(b),r}}return r},r.listen=function(c){gn("push"),gn("replace");function u(d){r.run()}function b(d){var p=d.target.closest("a"),_=p&&p.getAttribute("href");d.ctrlKey||d.metaKey||d.altKey||d.shiftKey||d.button||d.defaultPrevented||!_||p.target||p.host!==location.host||_[0]=="#"||(_[0]!="/"||t.test(_))&&(d.preventDefault(),r.route(_))}return addEventListener("popstate",u),addEventListener("replacestate",u),addEventListener("pushstate",u),addEventListener("click",b),r.unlisten=function(){removeEventListener("popstate",u),removeEventListener("replacestate",u),removeEventListener("pushstate",u),removeEventListener("click",b)},r.run(c)},r}function gn(n,e){history[n]||(history[n]=n,e=history[n+="State"],history[n]=function(t){var l=new Event(n.toLowerCase());return l.uri=t,e.apply(this,arguments),dispatchEvent(l)})}const Yt=Cr("/");function _e(n){Yt.route(n)}function vn(){window.history.back()}const Lt=[];function nr(n,e=W){let t;const l=new Set;function i(c){if(We(n,c)&&(n=c,t)){const u=!Lt.length;for(const b of l)b[1](),Lt.push(b,n);if(u){for(let b=0;b<Lt.length;b+=2)Lt[b][0](Lt[b+1]);Lt.length=0}}}function r(c){i(c(n))}function s(c,u=W){const b=[c,u];return l.add(b),l.size===1&&(t=e(i,r)||W),c(n),()=>{l.delete(b),l.size===0&&t&&(t(),t=null)}}return{set:i,update:r,subscribe:s}}class Ut extends Error{constructor(e,t,l){super(e),this.name="APIError",this.status=t,this.response=l}}async function Wt(n,e={}){const t={credentials:"same-origin",headers:{"Content-Type":"application/json",...e.headers},...e};try{const l=await fetch(n,t);if(l.status===401||l.status===403){const r=["/","/login","/about","/terms"],s=["/api/feed-json","/api/resolve-handle","/api/search-actors","/api/me"],c=window.location.pathname,u=s.some(b=>n.includes(b));throw!r.includes(c)&&!u&&(window.location.href="/login"),new Ut("Authentication required",l.status,l)}if(!l.ok){const r=await l.text();throw new Ut(r||`Request failed: ${l.statusText}`,l.status,l)}const i=l.headers.get("content-type");return!i||!i.includes("application/json")?null:await l.json()}catch(l){throw l instanceof Ut?l:new Ut(`Network error: ${l.message}`,0,null)}}const ge={get:n=>Wt(n,{method:"GET"}),post:(n,e)=>Wt(n,{method:"POST",body:JSON.stringify(e)}),put:(n,e)=>Wt(n,{method:"PUT",body:JSON.stringify(e)}),delete:n=>Wt(n,{method:"DELETE"})};function Br(){const{subscribe:n,set:e}=nr({isAuthenticated:!1,user:null,loading:!0});return{subscribe:n,async checkAuth(){try{const t=await ge.get("/api/me");e({isAuthenticated:!0,user:t,loading:!1})}catch{e({isAuthenticated:!1,user:null,loading:!1})}},async logout(){try{await ge.post("/logout",{}),e({isAuthenticated:!1,user:null,loading:!1}),window.location.href="/"}catch(t){console.error("Logout failed:",t)}},clear(){e({isAuthenticated:!1,user:null,loading:!1})}}}const pt=Br();function Ar(n){let e;return{c(){e=f("div"),e.innerHTML='<span class="text-brown-600 text-sm">?</span>',a(e,"class","w-10 h-10 rounded-full bg-brown-300 flex items-center justify-center hover:ring-2 hover:ring-brown-600 transition")},m(t,l){y(t,e,l)},p:W,d(t){t&&k(e)}}}function Sr(n){let e,t;return{c(){e=f("img"),gt(e.src,t=rn(n[0].Author.avatar))||a(e,"src",t),a(e,"alt",""),a(e,"class","w-10 h-10 rounded-full object-cover hover:ring-2 hover:ring-brown-600 transition")},m(l,i){y(l,e,i)},p(l,i){i&1&&!gt(e.src,t=rn(l[0].Author.avatar))&&a(e,"src",t)},d(l){l&&k(e)}}}function kn(n){let e,t=n[0].Author.displayName+"",l,i,r,s;return{c(){e=f("a"),l=C(t),a(e,"href",i="/profile/"+n[0].Author.handle),a(e,"class","font-medium text-brown-900 truncate hover:text-brown-700 hover:underline")},m(c,u){y(c,e,u),o(e,l),r||(s=z(e,"click",Ue(n[2])),r=!0)},p(c,u){u&1&&t!==(t=c[0].Author.displayName+"")&&j(l,t),u&1&&i!==(i="/profile/"+c[0].Author.handle)&&a(e,"href",i)},d(c){c&&k(e),r=!1,s()}}}function Nr(n){let e=n[0].Action+"",t;return{c(){t=C(e)},m(l,i){y(l,t,i)},p(l,i){i&1&&e!==(e=l[0].Action+"")&&j(t,e)},d(l){l&&k(t)}}}function Tr(n){let e,t,l,i,r,s,c;return{c(){e=f("span"),e.textContent="added a",t=w(),l=f("a"),i=C("new brew"),a(l,"href",r="/brews/"+n[0].Author.did+"/"+n[0].Brew.rkey),a(l,"class","font-semibold text-brown-800 hover:text-brown-900 hover:underline cursor-pointer")},m(u,b){y(u,e,b),y(u,t,b),y(u,l,b),o(l,i),s||(c=z(l,"click",Ue(n[4])),s=!0)},p(u,b){b&1&&r!==(r="/brews/"+u[0].Author.did+"/"+u[0].Brew.rkey)&&a(l,"href",r)},d(u){u&&(k(e),k(t),k(l)),s=!1,c()}}}function Lr(n){let e,t,l,i=n[0].Brewer.name+"",r,s,c=n[0].Brewer.brewer_type&&yn(n);return{c(){e=f("div"),t=f("div"),l=C("☕ "),r=C(i),s=w(),c&&c.c(),a(t,"class","font-semibold text-brown-900"),a(e,"class","bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200")},m(u,b){y(u,e,b),o(e,t),o(t,l),o(t,r),o(e,s),c&&c.m(e,null)},p(u,b){b&1&&i!==(i=u[0].Brewer.name+"")&&j(r,i),u[0].Brewer.brewer_type?c?c.p(u,b):(c=yn(u),c.c(),c.m(e,null)):c&&(c.d(1),c=null)},d(u){u&&k(e),c&&c.d()}}}function Or(n){let e,t,l,i=n[0].Grinder.name+"",r,s,c=n[0].Grinder.grinder_type&&xn(n);return{c(){e=f("div"),t=f("div"),l=C("⚙️ "),r=C(i),s=w(),c&&c.c(),a(t,"class","font-semibold text-brown-900"),a(e,"class","bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200")},m(u,b){y(u,e,b),o(e,t),o(t,l),o(t,r),o(e,s),c&&c.m(e,null)},p(u,b){b&1&&i!==(i=u[0].Grinder.name+"")&&j(r,i),u[0].Grinder.grinder_type?c?c.p(u,b):(c=xn(u),c.c(),c.m(e,null)):c&&(c.d(1),c=null)},d(u){u&&k(e),c&&c.d()}}}function Mr(n){let e,t,l,i=n[0].Roaster.name+"",r,s,c=n[0].Roaster.location&&Cn(n);return{c(){e=f("div"),t=f("div"),l=C("🏭 "),r=C(i),s=w(),c&&c.c(),a(t,"class","font-semibold text-brown-900"),a(e,"class","bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200")},m(u,b){y(u,e,b),o(e,t),o(t,l),o(t,r),o(e,s),c&&c.m(e,null)},p(u,b){b&1&&i!==(i=u[0].Roaster.name+"")&&j(r,i),u[0].Roaster.location?c?c.p(u,b):(c=Cn(u),c.c(),c.m(e,null)):c&&(c.d(1),c=null)},d(u){u&&k(e),c&&c.d()}}}function Er(n){let e,t,l=(n[0].Bean.name||n[0].Bean.origin)+"",i,r,s=n[0].Bean.origin&&Bn(n);return{c(){e=f("div"),t=f("div"),i=C(l),r=w(),s&&s.c(),a(t,"class","font-semibold text-brown-900"),a(e,"class","bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200")},m(c,u){y(c,e,u),o(e,t),o(t,i),o(e,r),s&&s.m(e,null)},p(c,u){u&1&&l!==(l=(c[0].Bean.name||c[0].Bean.origin)+"")&&j(i,l),c[0].Bean.origin?s?s.p(c,u):(s=Bn(c),s.c(),s.m(e,null)):s&&(s.d(1),s=null)},d(c){c&&k(e),s&&s.d()}}}function Pr(n){let e,t,l,i,r=Kt(n[0].Brew.rating),s,c,u=n[0].Brew.bean&&An(n),b=r&&Mn(n),d=(n[0].Brew.brewer_obj||n[0].Brew.method)&&En(n),p=n[0].Brew.tasting_notes&&Pn(n);return{c(){e=f("div"),t=f("div"),l=f("div"),u&&u.c(),i=w(),b&&b.c(),s=w(),d&&d.c(),c=w(),p&&p.c(),a(l,"class","flex-1 min-w-0"),a(t,"class","flex items-start justify-between gap-3 mb-3"),a(e,"class","bg-white/60 backdrop-blur rounded-lg p-4 border border-brown-200")},m(_,m){y(_,e,m),o(e,t),o(t,l),u&&u.m(l,null),o(t,i),b&&b.m(t,null),o(e,s),d&&d.m(e,null),o(e,c),p&&p.m(e,null)},p(_,m){_[0].Brew.bean?u?u.p(_,m):(u=An(_),u.c(),u.m(l,null)):u&&(u.d(1),u=null),m&1&&(r=Kt(_[0].Brew.rating)),r?b?b.p(_,m):(b=Mn(_),b.c(),b.m(t,null)):b&&(b.d(1),b=null),_[0].Brew.brewer_obj||_[0].Brew.method?d?d.p(_,m):(d=En(_),d.c(),d.m(e,c)):d&&(d.d(1),d=null),_[0].Brew.tasting_notes?p?p.p(_,m):(p=Pn(_),p.c(),p.m(e,null)):p&&(p.d(1),p=null)},d(_){_&&k(e),u&&u.d(),b&&b.d(),d&&d.d(),p&&p.d()}}}function yn(n){let e,t=n[0].Brewer.brewer_type+"",l;return{c(){e=f("div"),l=C(t),a(e,"class","text-sm text-brown-700")},m(i,r){y(i,e,r),o(e,l)},p(i,r){r&1&&t!==(t=i[0].Brewer.brewer_type+"")&&j(l,t)},d(i){i&&k(e)}}}function xn(n){let e,t=n[0].Grinder.grinder_type+"",l;return{c(){e=f("div"),l=C(t),a(e,"class","text-sm text-brown-700")},m(i,r){y(i,e,r),o(e,l)},p(i,r){r&1&&t!==(t=i[0].Grinder.grinder_type+"")&&j(l,t)},d(i){i&&k(e)}}}function Cn(n){let e,t,l=n[0].Roaster.location+"",i;return{c(){e=f("div"),t=C("📍 "),i=C(l),a(e,"class","text-sm text-brown-700")},m(r,s){y(r,e,s),o(e,t),o(e,i)},p(r,s){s&1&&l!==(l=r[0].Roaster.location+"")&&j(i,l)},d(r){r&&k(e)}}}function Bn(n){let e,t,l=n[0].Bean.origin+"",i;return{c(){e=f("div"),t=C("📍 "),i=C(l),a(e,"class","text-sm text-brown-700")},m(r,s){y(r,e,s),o(e,t),o(e,i)},p(r,s){s&1&&l!==(l=r[0].Bean.origin+"")&&j(i,l)},d(r){r&&k(e)}}}function An(n){var B;let e,t=(n[0].Brew.bean.name||n[0].Brew.bean.origin)+"",l,i,r,s,c,u,b,d=Kt(n[0].Brew.coffee_amount),p=((B=n[0].Brew.bean.roaster)==null?void 0:B.name)&&Sn(n),_=n[0].Brew.bean.origin&&Nn(n),m=n[0].Brew.bean.roast_level&&Tn(n),h=n[0].Brew.bean.process&&Ln(n),g=d&&On(n);return{c(){e=f("div"),l=C(t),i=w(),p&&p.c(),r=w(),s=f("div"),_&&_.c(),c=w(),m&&m.c(),u=w(),h&&h.c(),b=w(),g&&g.c(),a(e,"class","font-bold text-brown-900 text-base"),a(s,"class","text-xs text-brown-600 mt-1 flex flex-wrap gap-x-2 gap-y-0.5")},m(v,x){y(v,e,x),o(e,l),y(v,i,x),p&&p.m(v,x),y(v,r,x),y(v,s,x),_&&_.m(s,null),o(s,c),m&&m.m(s,null),o(s,u),h&&h.m(s,null),o(s,b),g&&g.m(s,null)},p(v,x){var S;x&1&&t!==(t=(v[0].Brew.bean.name||v[0].Brew.bean.origin)+"")&&j(l,t),(S=v[0].Brew.bean.roaster)!=null&&S.name?p?p.p(v,x):(p=Sn(v),p.c(),p.m(r.parentNode,r)):p&&(p.d(1),p=null),v[0].Brew.bean.origin?_?_.p(v,x):(_=Nn(v),_.c(),_.m(s,c)):_&&(_.d(1),_=null),v[0].Brew.bean.roast_level?m?m.p(v,x):(m=Tn(v),m.c(),m.m(s,u)):m&&(m.d(1),m=null),v[0].Brew.bean.process?h?h.p(v,x):(h=Ln(v),h.c(),h.m(s,b)):h&&(h.d(1),h=null),x&1&&(d=Kt(v[0].Brew.coffee_amount)),d?g?g.p(v,x):(g=On(v),g.c(),g.m(s,null)):g&&(g.d(1),g=null)},d(v){v&&(k(e),k(i),k(r),k(s)),p&&p.d(v),_&&_.d(),m&&m.d(),h&&h.d(),g&&g.d()}}}function Sn(n){let e,t,l,i=n[0].Brew.bean.roaster.name+"",r;return{c(){e=f("div"),t=f("span"),l=C("🏭 "),r=C(i),a(t,"class","font-medium"),a(e,"class","text-sm text-brown-700 mt-0.5")},m(s,c){y(s,e,c),o(e,t),o(t,l),o(t,r)},p(s,c){c&1&&i!==(i=s[0].Brew.bean.roaster.name+"")&&j(r,i)},d(s){s&&k(e)}}}function Nn(n){let e,t,l=n[0].Brew.bean.origin+"",i;return{c(){e=f("span"),t=C("📍 "),i=C(l),a(e,"class","inline-flex items-center gap-0.5")},m(r,s){y(r,e,s),o(e,t),o(e,i)},p(r,s){s&1&&l!==(l=r[0].Brew.bean.origin+"")&&j(i,l)},d(r){r&&k(e)}}}function Tn(n){let e,t,l=n[0].Brew.bean.roast_level+"",i;return{c(){e=f("span"),t=C("🔥 "),i=C(l),a(e,"class","inline-flex items-center gap-0.5")},m(r,s){y(r,e,s),o(e,t),o(e,i)},p(r,s){s&1&&l!==(l=r[0].Brew.bean.roast_level+"")&&j(i,l)},d(r){r&&k(e)}}}function Ln(n){let e,t,l=n[0].Brew.bean.process+"",i;return{c(){e=f("span"),t=C("🌱 "),i=C(l),a(e,"class","inline-flex items-center gap-0.5")},m(r,s){y(r,e,s),o(e,t),o(e,i)},p(r,s){s&1&&l!==(l=r[0].Brew.bean.process+"")&&j(i,l)},d(r){r&&k(e)}}}function On(n){let e,t,l=n[0].Brew.coffee_amount+"",i,r;return{c(){e=f("span"),t=C("⚖️ "),i=C(l),r=C("g"),a(e,"class","inline-flex items-center gap-0.5")},m(s,c){y(s,e,c),o(e,t),o(e,i),o(e,r)},p(s,c){c&1&&l!==(l=s[0].Brew.coffee_amount+"")&&j(i,l)},d(s){s&&k(e)}}}function Mn(n){let e,t,l=n[0].Brew.rating+"",i,r;return{c(){e=f("span"),t=C("⭐ "),i=C(l),r=C("/10"),a(e,"class","inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-amber-100 text-amber-900 flex-shrink-0")},m(s,c){y(s,e,c),o(e,t),o(e,i),o(e,r)},p(s,c){c&1&&l!==(l=s[0].Brew.rating+"")&&j(i,l)},d(s){s&&k(e)}}}function En(n){var c;let e,t,l,i,r=(((c=n[0].Brew.brewer_obj)==null?void 0:c.name)||n[0].Brew.method)+"",s;return{c(){e=f("div"),t=f("span"),t.textContent="Brewer:",l=w(),i=f("span"),s=C(r),a(t,"class","text-xs text-brown-600"),a(i,"class","text-sm font-semibold text-brown-900"),a(e,"class","mb-2")},m(u,b){y(u,e,b),o(e,t),o(e,l),o(e,i),o(i,s)},p(u,b){var d;b&1&&r!==(r=(((d=u[0].Brew.brewer_obj)==null?void 0:d.name)||u[0].Brew.method)+"")&&j(s,r)},d(u){u&&k(e)}}}function Pn(n){let e,t,l=n[0].Brew.tasting_notes+"",i,r;return{c(){e=f("div"),t=C('"'),i=C(l),r=C('"'),a(e,"class","mt-2 text-sm text-brown-800 italic border-l-2 border-brown-300 pl-3")},m(s,c){y(s,e,c),o(e,t),o(e,i),o(e,r)},p(s,c){c&1&&l!==(l=s[0].Brew.tasting_notes+"")&&j(i,l)},d(s){s&&k(e)}}}function Dr(n){let e,t,l,i,r,s,c,u,b,d,p,_=n[0].Author.handle+"",m,h,g,B,v=n[0].TimeAgo+"",x,S,A,N,P,L;function O(I,Y){return Y&1&&(i=null),i==null&&(i=!!rn(I[0].Author.avatar)),i?Sr:Ar}let D=O(n,-1),F=D(n),G=n[0].Author.displayName&&kn(n);function E(I,Y){return I[0].RecordType==="brew"&&I[0].Brew?Tr:Nr}let T=E(n),R=T(n);function V(I,Y){if(I[0].RecordType==="brew"&&I[0].Brew)return Pr;if(I[0].RecordType==="bean"&&I[0].Bean)return Er;if(I[0].RecordType==="roaster"&&I[0].Roaster)return Mr;if(I[0].RecordType==="grinder"&&I[0].Grinder)return Or;if(I[0].RecordType==="brewer"&&I[0].Brewer)return Lr}let X=V(n),J=X&&X(n);return{c(){e=f("div"),t=f("div"),l=f("a"),F.c(),s=w(),c=f("div"),u=f("div"),G&&G.c(),b=w(),d=f("a"),p=C("@"),m=C(_),g=w(),B=f("span"),x=C(v),S=w(),A=f("div"),R.c(),N=w(),J&&J.c(),a(l,"href",r="/profile/"+n[0].Author.handle),a(l,"class","flex-shrink-0"),a(d,"href",h="/profile/"+n[0].Author.handle),a(d,"class","text-brown-600 text-sm truncate hover:text-brown-700 hover:underline"),a(u,"class","flex items-center gap-2"),a(B,"class","text-brown-500 text-sm"),a(c,"class","flex-1 min-w-0"),a(t,"class","flex items-center gap-3 mb-3"),a(A,"class","mb-2 text-sm text-brown-700"),a(e,"class","bg-gradient-to-br from-brown-50 to-brown-100 rounded-lg shadow-md border border-brown-200 p-4 hover:shadow-lg transition-shadow")},m(I,Y){y(I,e,Y),o(e,t),o(t,l),F.m(l,null),o(t,s),o(t,c),o(c,u),G&&G.m(u,null),o(u,b),o(u,d),o(d,p),o(d,m),o(c,g),o(c,B),o(B,x),o(e,S),o(e,A),R.m(A,null),o(e,N),J&&J.m(e,null),P||(L=[z(l,"click",Ue(n[1])),z(d,"click",Ue(n[3]))],P=!0)},p(I,[Y]){D===(D=O(I,Y))&&F?F.p(I,Y):(F.d(1),F=D(I),F&&(F.c(),F.m(l,null))),Y&1&&r!==(r="/profile/"+I[0].Author.handle)&&a(l,"href",r),I[0].Author.displayName?G?G.p(I,Y):(G=kn(I),G.c(),G.m(u,b)):G&&(G.d(1),G=null),Y&1&&_!==(_=I[0].Author.handle+"")&&j(m,_),Y&1&&h!==(h="/profile/"+I[0].Author.handle)&&a(d,"href",h),Y&1&&v!==(v=I[0].TimeAgo+"")&&j(x,v),T===(T=E(I))&&R?R.p(I,Y):(R.d(1),R=T(I),R&&(R.c(),R.m(A,null))),X===(X=V(I))&&J?J.p(I,Y):(J&&J.d(1),J=X&&X(I),J&&(J.c(),J.m(e,null)))},i:W,o:W,d(I){I&&k(e),F.d(),G&&G.d(),R.d(),J&&J.d(),P=!1,ce(L)}}}function rn(n){return n&&(n.startsWith("https://")||n.startsWith("/static/"))?n:null}function Kt(n){return n!=null&&n!==""}function Fr(n,e,t){let{item:l}=e;const i=()=>_e(`/profile/${l.Author.handle}`),r=()=>_e(`/profile/${l.Author.handle}`),s=()=>_e(`/profile/${l.Author.handle}`),c=()=>_e(`/brews/${l.Author.did}/${l.Brew.rkey}`);return n.$$set=u=>{"item"in u&&t(0,l=u.item)},[l,i,r,s,c]}class Rr extends Xe{constructor(e){super(),Qe(this,e,Fr,Dr,We,{item:0})}}function Dn(n,e,t){const l=n.slice();return l[12]=e[t],l}function jr(n,e,t){const l=n.slice();return l[9]=e[t],l}function Hr(n){let e,t,l,i;return{c(){e=f("div"),t=f("button"),t.textContent="Log In to Start Tracking",a(t,"class","bg-gradient-to-r from-brown-700 to-brown-800 text-white py-3 px-8 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all text-lg font-semibold shadow-lg hover:shadow-xl inline-block"),a(e,"class","text-center")},m(r,s){y(r,e,s),o(e,t),l||(i=z(t,"click",n[8]),l=!0)},p:W,d(r){r&&k(e),l=!1,i()}}}function zr(n){var h;let e,t,l,i,r=((h=n[3])==null?void 0:h.did)+"",s,c,u,b,d,p,_,m;return{c(){e=f("div"),t=f("p"),l=C("Logged in as: "),i=f("span"),s=C(r),c=w(),u=f("div"),b=f("a"),b.innerHTML='<span class="text-xl font-semibold">☕ Add New Brew</span>',d=w(),p=f("a"),p.innerHTML='<span class="text-xl font-semibold">📋 View All Brews</span>',a(i,"class","font-mono text-brown-900 font-semibold"),a(t,"class","text-sm text-brown-700"),a(e,"class","mb-6"),a(b,"href","/brews/new"),a(b,"class","block bg-gradient-to-br from-brown-700 to-brown-800 text-white text-center py-4 px-6 rounded-xl hover:from-brown-800 hover:to-brown-900 transition-all shadow-lg hover:shadow-xl transform"),a(p,"href","/brews"),a(p,"class","block bg-gradient-to-br from-brown-500 to-brown-600 text-white text-center py-4 px-6 rounded-xl hover:from-brown-600 hover:to-brown-700 transition-all shadow-lg hover:shadow-xl"),a(u,"class","grid grid-cols-1 md:grid-cols-2 gap-4")},m(g,B){y(g,e,B),o(e,t),o(t,l),o(t,i),o(i,s),y(g,c,B),y(g,u,B),o(u,b),o(u,d),o(u,p),_||(m=[z(b,"click",Ue(n[6])),z(p,"click",Ue(n[7]))],_=!0)},p(g,B){var v;B&8&&r!==(r=((v=g[3])==null?void 0:v.did)+"")&&j(s,r)},d(g){g&&(k(e),k(c),k(u)),_=!1,ce(m)}}}function Gr(n){let e,t=[],l=new Map,i,r=le(n[0]);const s=c=>c[12].Timestamp;for(let c=0;c<r.length;c+=1){let u=Dn(n,r,c),b=s(u);l.set(b,t[c]=Fn(b,u))}return{c(){e=f("div");for(let c=0;c<t.length;c+=1)t[c].c();a(e,"class","space-y-4")},m(c,u){y(c,e,u);for(let b=0;b<t.length;b+=1)t[b]&&t[b].m(e,null);i=!0},p(c,u){u&1&&(r=le(c[0]),jt(),t=vr(t,u,s,1,c,r,l,e,gr,Fn,null,Dn),Ht())},i(c){if(!i){for(let u=0;u<r.length;u+=1)ve(t[u]);i=!0}},o(c){for(let u=0;u<t.length;u+=1)Oe(t[u]);i=!1},d(c){c&&k(e);for(let u=0;u<t.length;u+=1)t[u].d()}}}function Ir(n){let e,t;function l(s,c){return s[4]?Yr:qr}let i=l(n),r=i(n);return{c(){e=f("div"),t=C("No activity yet. "),r.c(),a(e,"class","text-center py-8 text-brown-600")},m(s,c){y(s,e,c),o(e,t),r.m(e,null)},p(s,c){i!==(i=l(s))&&(r.d(1),r=i(s),r&&(r.c(),r.m(e,null)))},i:W,o:W,d(s){s&&k(e),r.d()}}}function Ur(n){let e,t,l;return{c(){e=f("div"),t=C("Failed to load feed: "),l=C(n[2]),a(e,"class","text-center py-8 text-brown-600")},m(i,r){y(i,e,r),o(e,t),o(e,l)},p(i,r){r&4&&j(l,i[2])},i:W,o:W,d(i){i&&k(e)}}}function Wr(n){let e,t=le(Array(3)),l=[];for(let i=0;i<t.length;i+=1)l[i]=Vr(jr(n,t,i));return{c(){e=f("div");for(let i=0;i<l.length;i+=1)l[i].c();a(e,"class","space-y-4")},m(i,r){y(i,e,r);for(let s=0;s<l.length;s+=1)l[s]&&l[s].m(e,null)},p:W,i:W,o:W,d(i){i&&k(e),Ge(l,i)}}}function Fn(n,e){let t,l,i;return l=new Rr({props:{item:e[12]}}),{key:n,first:null,c(){t=ft(),it(l.$$.fragment),this.first=t},m(r,s){y(r,t,s),nt(l,r,s),i=!0},p(r,s){e=r;const c={};s&1&&(c.item=e[12]),l.$set(c)},i(r){i||(ve(l.$$.fragment,r),i=!0)},o(r){Oe(l.$$.fragment,r),i=!1},d(r){r&&k(t),lt(l,r)}}}function qr(n){let e;return{c(){e=C("Log in to see your feed.")},m(t,l){y(t,e,l)},d(t){t&&k(e)}}}function Yr(n){let e;return{c(){e=C("Start by adding your first brew!")},m(t,l){y(t,e,l)},d(t){t&&k(e)}}}function Vr(n){let e;return{c(){e=f("div"),e.innerHTML='<div class="bg-brown-50 rounded-lg p-4 border border-brown-200"><div class="flex items-center gap-3 mb-3"><div class="w-10 h-10 rounded-full bg-brown-300"></div> <div class="flex-1"><div class="h-4 bg-brown-300 rounded w-1/4 mb-2"></div> <div class="h-3 bg-brown-200 rounded w-1/6"></div></div></div> <div class="bg-brown-200 rounded-lg p-3"><div class="h-4 bg-brown-300 rounded w-3/4 mb-2"></div> <div class="h-3 bg-brown-200 rounded w-1/2"></div></div></div> ',a(e,"class","animate-pulse")},m(t,l){y(t,e,l)},p:W,d(t){t&&k(e)}}}function Kr(n){let e,t,l,i,r,s,c,u,b,d,p,_,m,h,g,B,v,x;function S(D,F){return D[4]?zr:Hr}let A=S(n),N=A(n);const P=[Wr,Ur,Ir,Gr],L=[];function O(D,F){return D[1]?0:D[2]?1:D[0].length===0?2:3}return h=O(n),g=L[h]=P[h](n),{c(){e=w(),t=f("div"),l=f("div"),i=f("div"),i.innerHTML='<h2 class="text-3xl font-bold text-brown-900">Welcome to Arabica</h2> <span class="text-xs bg-amber-400 text-brown-900 px-2 py-1 rounded-md font-semibold shadow-sm">ALPHA</span>',r=w(),s=f("p"),s.textContent="Track your coffee brewing journey with detailed logs of every cup.",c=w(),u=f("p"),u.textContent="Note: Arabica is currently in alpha. Features and data structures may change.",b=w(),N.c(),d=w(),p=f("div"),_=f("h3"),_.textContent="☕ Community Feed",m=w(),g.c(),B=w(),v=f("div"),v.innerHTML='<h3 class="text-lg font-bold text-brown-900 mb-3">✨ About Arabica</h3> <ul class="text-brown-800 space-y-2 leading-relaxed"><li class="flex items-start"><span class="mr-2">🔒</span><span><strong>Decentralized:</strong> Your data lives in your Personal Data Server (PDS)</span></li> <li class="flex items-start"><span class="mr-2">🚀</span><span><strong>Portable:</strong> Own your coffee brewing history</span></li> <li class="flex items-start"><span class="mr-2">📊</span><span>Track brewing variables like temperature, time, and grind size</span></li> <li class="flex items-start"><span class="mr-2">🌍</span><span>Organize beans by origin and roaster</span></li> <li class="flex items-start"><span class="mr-2">📝</span><span>Add tasting notes and ratings to each brew</span></li></ul>',document.title="Arabica - Coffee Brew Tracker",a(i,"class","flex items-center gap-3 mb-4"),a(s,"class","text-brown-800 mb-2 text-lg"),a(u,"class","text-sm text-brown-700 italic mb-6"),a(l,"class","bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 mb-8 border border-brown-300"),a(_,"class","text-xl font-bold text-brown-900 mb-4"),a(p,"class","bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-6 mb-8 border border-brown-300"),a(v,"class","bg-gradient-to-br from-amber-50 to-brown-100 rounded-xl p-6 border-2 border-brown-300 shadow-lg"),a(t,"class","max-w-4xl mx-auto")},m(D,F){y(D,e,F),y(D,t,F),o(t,l),o(l,i),o(l,r),o(l,s),o(l,c),o(l,u),o(l,b),N.m(l,null),o(t,d),o(t,p),o(p,_),o(p,m),L[h].m(p,null),o(t,B),o(t,v),x=!0},p(D,[F]){A===(A=S(D))&&N?N.p(D,F):(N.d(1),N=A(D),N&&(N.c(),N.m(l,null)));let G=h;h=O(D),h===G?L[h].p(D,F):(jt(),Oe(L[G],1,1,()=>{L[G]=null}),Ht(),g=L[h],g?g.p(D,F):(g=L[h]=P[h](D),g.c()),ve(g,1),g.m(p,null))},i(D){x||(ve(g),x=!0)},o(D){Oe(g),x=!1},d(D){D&&(k(e),k(t)),N.d(),L[h].d()}}}function Jr(n,e,t){let l,i,r;ut(n,pt,_=>t(5,r=_));let s=[],c=!0,u=null;vt(async()=>{try{const _=await ge.get("/api/feed-json");t(0,s=_.items||[])}catch(_){console.error("Failed to load feed:",_),_.status!==401&&_.status!==403&&t(2,u=_.message)}finally{t(1,c=!1)}});const b=()=>_e("/brews/new"),d=()=>_e("/brews"),p=()=>_e("/login");return n.$$.update=()=>{n.$$.dirty&32&&t(4,l=r.isAuthenticated),n.$$.dirty&32&&t(3,i=r.user)},[s,c,u,i,l,r,b,d,p]}class Qr extends Xe{constructor(e){super(),Qe(this,e,Jr,Kr,We,{})}}const{document:Xr}=fr;function Rn(n,e,t){const l=n.slice();return l[18]=e[t],l}function jn(n){let e;function t(r,s){return r[1].length===0?$r:Zr}let l=t(n),i=l(n);return{c(){e=f("div"),i.c(),a(e,"class","absolute z-10 w-full mt-1 bg-brown-50 border-2 border-brown-300 rounded-lg shadow-lg max-h-60 overflow-y-auto")},m(r,s){y(r,e,s),i.m(e,null)},p(r,s){l===(l=t(r))&&i?i.p(r,s):(i.d(1),i=l(r),i&&(i.c(),i.m(e,null)))},d(r){r&&k(e),i.d()}}}function Zr(n){let e,t=le(n[1]),l=[];for(let i=0;i<t.length;i+=1)l[i]=Hn(Rn(n,t,i));return{c(){for(let i=0;i<l.length;i+=1)l[i].c();e=ft()},m(i,r){for(let s=0;s<l.length;s+=1)l[s]&&l[s].m(i,r);y(i,e,r)},p(i,r){if(r&66){t=le(i[1]);let s;for(s=0;s<t.length;s+=1){const c=Rn(i,t,s);l[s]?l[s].p(c,r):(l[s]=Hn(c),l[s].c(),l[s].m(e.parentNode,e))}for(;s<l.length;s+=1)l[s].d(1);l.length=t.length}},d(i){i&&k(e),Ge(l,i)}}}function $r(n){let e;return{c(){e=f("div"),e.textContent="No accounts found",a(e,"class","px-4 py-3 text-sm text-brown-600")},m(t,l){y(t,e,l)},p:W,d(t){t&&k(e)}}}function Hn(n){let e,t,l,i,r,s,c=(n[18].displayName||n[18].handle)+"",u,b,d,p,_=n[18].handle+"",m,h,g,B;function v(){return n[11](n[18])}return{c(){e=f("button"),t=f("img"),i=w(),r=f("div"),s=f("div"),u=C(c),b=w(),d=f("div"),p=C("@"),m=C(_),h=w(),gt(t.src,l=n[18].avatar||"/static/icon-placeholder.svg")||a(t,"src",l),a(t,"alt",""),a(t,"class","w-6 h-6 rounded-full object-cover flex-shrink-0"),a(s,"class","font-medium text-sm text-brown-900 truncate"),a(d,"class","text-xs text-brown-600 truncate"),a(r,"class","flex-1 min-w-0"),a(e,"type","button"),a(e,"class","w-full px-3 py-2 hover:bg-brown-100 cursor-pointer flex items-center gap-2 text-left")},m(x,S){y(x,e,S),o(e,t),o(e,i),o(e,r),o(r,s),o(s,u),o(r,b),o(r,d),o(d,p),o(d,m),o(e,h),g||(B=[z(t,"error",to),z(e,"click",v)],g=!0)},p(x,S){n=x,S&2&&!gt(t.src,l=n[18].avatar||"/static/icon-placeholder.svg")&&a(t,"src",l),S&2&&c!==(c=(n[18].displayName||n[18].handle)+"")&&j(u,c),S&2&&_!==(_=n[18].handle+"")&&j(m,_)},d(x){x&&k(e),g=!1,ce(B)}}}function zn(n){let e,t;return{c(){e=f("div"),t=C(n[4]),a(e,"class","mt-3 text-red-600 text-sm")},m(l,i){y(l,e,i),o(e,t)},p(l,i){i&16&&j(t,l[4])},d(l){l&&k(e)}}}function eo(n){let e,t,l,i,r,s,c,u,b,d,p,_,m,h,g,B,v,x,S,A,N,P=n[3]?"Logging in...":"Log In",L,O,D,F,G,E=n[2]&&jn(n),T=n[4]&&zn(n);return{c(){e=w(),t=f("div"),l=f("div"),i=f("div"),i.innerHTML='<h2 class="text-3xl font-bold text-brown-900">Welcome to Arabica</h2> <span class="text-xs bg-amber-400 text-brown-900 px-2 py-1 rounded-md font-semibold shadow-sm">ALPHA</span>',r=w(),s=f("p"),s.textContent="Track your coffee brewing journey with detailed logs of every cup.",c=w(),u=f("p"),u.textContent="Note: Arabica is currently in alpha. Features and data structures may change.",b=w(),d=f("div"),p=f("p"),p.textContent="Please log in with your AT Protocol handle to start tracking your brews.",_=w(),m=f("form"),h=f("div"),g=f("label"),g.textContent="Your Handle",B=w(),v=f("input"),x=w(),E&&E.c(),S=w(),T&&T.c(),A=w(),N=f("button"),L=C(P),O=w(),D=f("div"),D.innerHTML='<h3 class="text-lg font-bold text-brown-900 mb-3">✨ About Arabica</h3> <ul class="text-brown-800 space-y-2 leading-relaxed"><li class="flex items-start"><span class="mr-2">🔒</span><span><strong>Decentralized:</strong> Your data lives in your Personal Data Server (PDS)</span></li> <li class="flex items-start"><span class="mr-2">🚀</span><span><strong>Portable:</strong> Own your coffee brewing history</span></li> <li class="flex items-start"><span class="mr-2">📊</span><span>Track brewing variables like temperature, time, and grind size</span></li> <li class="flex items-start"><span class="mr-2">🌍</span><span>Organize beans by origin and roaster</span></li> <li class="flex items-start"><span class="mr-2">📝</span><span>Add tasting notes and ratings to each brew</span></li></ul>',Xr.title="Login - Arabica",a(i,"class","flex items-center gap-3 mb-4"),a(s,"class","text-brown-800 mb-2 text-lg"),a(u,"class","text-sm text-brown-700 italic mb-6"),a(p,"class","text-brown-800 mb-6 text-center text-lg"),a(g,"for","handle"),a(g,"class","block text-sm font-medium text-brown-900 mb-2"),a(v,"type","text"),a(v,"id","handle"),a(v,"name","handle"),a(v,"placeholder","alice.bsky.social"),a(v,"autocomplete","off"),v.required=!0,v.disabled=n[3],a(v,"class","w-full px-4 py-3 border-2 border-brown-300 rounded-lg focus:ring-2 focus:ring-brown-600 focus:border-brown-600 bg-white disabled:opacity-50"),a(h,"class","relative autocomplete-container"),a(N,"type","submit"),N.disabled=n[3],a(N,"class","w-full mt-4 bg-gradient-to-r from-brown-700 to-brown-800 text-white py-3 px-8 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all text-lg font-semibold shadow-lg hover:shadow-xl disabled:opacity-50"),a(m,"method","POST"),a(m,"action","/auth/login"),a(m,"class","max-w-md mx-auto"),a(l,"class","bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 mb-8 border border-brown-300"),a(D,"class","bg-gradient-to-br from-amber-50 to-brown-100 rounded-xl p-6 border-2 border-brown-300 shadow-lg"),a(t,"class","max-w-4xl mx-auto")},m(R,V){y(R,e,V),y(R,t,V),o(t,l),o(l,i),o(l,r),o(l,s),o(l,c),o(l,u),o(l,b),o(l,d),o(d,p),o(d,_),o(d,m),o(m,h),o(h,g),o(h,B),o(h,v),H(v,n[0]),o(h,x),E&&E.m(h,null),o(m,S),T&&T.m(m,null),o(m,A),o(m,N),o(N,L),o(t,O),o(t,D),F||(G=[z(v,"input",n[9]),z(v,"input",n[5]),z(v,"focus",n[10]),z(m,"submit",n[7])],F=!0)},p(R,[V]){V&8&&(v.disabled=R[3]),V&1&&v.value!==R[0]&&H(v,R[0]),R[2]?E?E.p(R,V):(E=jn(R),E.c(),E.m(h,null)):E&&(E.d(1),E=null),R[4]?T?T.p(R,V):(T=zn(R),T.c(),T.m(m,A)):T&&(T.d(1),T=null),V&8&&P!==(P=R[3]?"Logging in...":"Log In")&&j(L,P),V&8&&(N.disabled=R[3])},i:W,o:W,d(R){R&&(k(e),k(t)),E&&E.d(),T&&T.d(),F=!1,ce(G)}}}const to=n=>{n.target.src="/static/icon-placeholder.svg"};function no(n,e,t){let l;ut(n,pt,N=>t(8,l=N));let i="",r=[],s=!1,c=!1,u="",b,d;async function p(N){if(N.length<3){t(1,r=[]),t(2,s=!1);return}d&&d.abort(),d=new AbortController;try{const P=await fetch(`/api/search-actors?q=${encodeURIComponent(N)}`,{signal:d.signal});if(!P.ok){t(1,r=[]),t(2,s=!1);return}const L=await P.json();t(1,r=L.actors||[]),t(2,s=r.length>0||N.length>=3)}catch(P){P.name!=="AbortError"&&console.error("Error searching actors:",P)}}function _(N,P){return(...L)=>{clearTimeout(b),b=setTimeout(()=>N(...L),P)}}const m=_(p,300);function h(N){t(0,i=N.target.value),m(i)}function g(N){t(0,i=N.handle),t(1,r=[]),t(2,s=!1)}function B(N){N.target.closest(".autocomplete-container")||t(2,s=!1)}async function v(N){if(N.preventDefault(),!i){t(4,u="Please enter your handle");return}t(3,c=!0),t(4,u=""),N.target.submit()}vt(()=>(document.addEventListener("click",B),()=>{document.removeEventListener("click",B),d&&d.abort()}));function x(){i=this.value,t(0,i)}const S=()=>{r.length>0&&i.length>=3&&t(2,s=!0)},A=N=>g(N);return n.$$.update=()=>{n.$$.dirty&256&&l.isAuthenticated&&!l.loading&&_e("/")},[i,r,s,c,u,h,g,v,l,x,S,A]}class lo extends Xe{constructor(e){super(),Qe(this,e,no,eo,We,{})}}function ro(){const{subscribe:n,set:e,update:t}=nr({beans:[],roasters:[],grinders:[],brewers:[],brews:[],lastFetch:null,loading:!1}),l="arabica_data_cache",i=5*60*1e3;return{subscribe:n,async load(r=!1){if(!r){const s=localStorage.getItem(l);if(s)try{const c=JSON.parse(s);if(Date.now()-c.timestamp<i){e({...c,lastFetch:c.timestamp,loading:!1});return}e({...c,lastFetch:c.timestamp,loading:!0})}catch(c){console.error("Failed to parse cache:",c)}}try{t(u=>({...u,loading:!0}));const s=await ge.get("/api/data"),c={beans:s.beans||[],roasters:s.roasters||[],grinders:s.grinders||[],brewers:s.brewers||[],brews:s.brews||[],lastFetch:Date.now(),loading:!1};e(c),localStorage.setItem(l,JSON.stringify({...c,timestamp:c.lastFetch}))}catch(s){console.error("Failed to fetch data:",s),t(c=>({...c,loading:!1}))}},async invalidate(){localStorage.removeItem(l),await this.load(!0)},clear(){localStorage.removeItem(l),e({beans:[],roasters:[],grinders:[],brewers:[],brews:[],lastFetch:null,loading:!1})}}}const Te=ro();function Gn(n,e,t){const l=n.slice();return l[12]=e[t],l}function oo(n){let e,t=le(n[0]),l=[];for(let i=0;i<t.length;i+=1)l[i]=Kn(Gn(n,t,i));return{c(){e=f("div");for(let i=0;i<l.length;i+=1)l[i].c();a(e,"class","space-y-4")},m(i,r){y(i,e,r);for(let s=0;s<l.length;s+=1)l[s]&&l[s].m(e,null)},p(i,r){if(r&13){t=le(i[0]);let s;for(s=0;s<t.length;s+=1){const c=Gn(i,t,s);l[s]?l[s].p(c,r):(l[s]=Kn(c),l[s].c(),l[s].m(e,null))}for(;s<l.length;s+=1)l[s].d(1);l.length=t.length}},d(i){i&&k(e),Ge(l,i)}}}function io(n){let e,t,l,i,r,s,c,u,b,d;return{c(){e=f("div"),t=f("div"),t.textContent="☕",l=w(),i=f("h2"),i.textContent="No Brews Yet",r=w(),s=f("p"),s.textContent="Start tracking your coffee journey by adding your first brew!",c=w(),u=f("button"),u.textContent="Add Your First Brew",a(t,"class","text-6xl mb-4"),a(i,"class","text-2xl font-bold text-brown-900 mb-2"),a(s,"class","text-brown-700 mb-6"),a(u,"class","bg-gradient-to-r from-brown-700 to-brown-800 text-white px-6 py-3 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-semibold shadow-lg inline-block"),a(e,"class","bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl p-12 text-center border border-brown-300")},m(p,_){y(p,e,_),o(e,t),o(e,l),o(e,i),o(e,r),o(e,s),o(e,c),o(e,u),b||(d=z(u,"click",n[6]),b=!0)},p:W,d(p){p&&k(e),b=!1,d()}}}function so(n){let e;return{c(){e=f("div"),e.innerHTML='<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-brown-800 mx-auto"></div> <p class="mt-4 text-brown-700">Loading brews...</p>',a(e,"class","text-center py-12")},m(t,l){y(t,e,l)},p:W,d(t){t&&k(e)}}}function ao(n){let e;return{c(){e=f("h3"),e.textContent="Unknown Bean",a(e,"class","text-xl font-bold text-brown-900 mb-1")},m(t,l){y(t,e,l)},p:W,d(t){t&&k(e)}}}function uo(n){var c;let e,t=(n[12].bean.name||n[12].bean.origin||"Unknown Bean")+"",l,i,r,s=((c=n[12].bean.Roaster)==null?void 0:c.Name)&&In(n);return{c(){e=f("h3"),l=C(t),i=w(),s&&s.c(),r=ft(),a(e,"class","text-xl font-bold text-brown-900 mb-1")},m(u,b){y(u,e,b),o(e,l),y(u,i,b),s&&s.m(u,b),y(u,r,b)},p(u,b){var d;b&1&&t!==(t=(u[12].bean.name||u[12].bean.origin||"Unknown Bean")+"")&&j(l,t),(d=u[12].bean.Roaster)!=null&&d.Name?s?s.p(u,b):(s=In(u),s.c(),s.m(r.parentNode,r)):s&&(s.d(1),s=null)},d(u){u&&(k(e),k(i),k(r)),s&&s.d(u)}}}function In(n){let e,t,l=n[12].bean.roaster.name+"",i;return{c(){e=f("p"),t=C("🏭 "),i=C(l),a(e,"class","text-sm text-brown-700 mb-2")},m(r,s){y(r,e,s),o(e,t),o(e,i)},p(r,s){s&1&&l!==(l=r[12].bean.roaster.name+"")&&j(i,l)},d(r){r&&k(e)}}}function co(n){let e,t,l=n[12].method+"",i;return{c(){e=f("span"),t=C("☕ "),i=C(l)},m(r,s){y(r,e,s),o(e,t),o(e,i)},p(r,s){s&1&&l!==(l=r[12].method+"")&&j(i,l)},d(r){r&&k(e)}}}function fo(n){let e,t,l=n[12].brewer_obj.name+"",i;return{c(){e=f("span"),t=C("☕ "),i=C(l)},m(r,s){y(r,e,s),o(e,t),o(e,i)},p(r,s){s&1&&l!==(l=r[12].brewer_obj.name+"")&&j(i,l)},d(r){r&&k(e)}}}function Un(n){let e,t,l=n[12].temperature+"",i,r;return{c(){e=f("span"),t=C("🌡️ "),i=C(l),r=C("°C")},m(s,c){y(s,e,c),o(e,t),o(e,i),o(e,r)},p(s,c){c&1&&l!==(l=s[12].temperature+"")&&j(i,l)},d(s){s&&k(e)}}}function Wn(n){let e,t,l=n[12].coffee_amount+"",i,r;return{c(){e=f("span"),t=C("⚖️ "),i=C(l),r=C("g coffee")},m(s,c){y(s,e,c),o(e,t),o(e,i),o(e,r)},p(s,c){c&1&&l!==(l=s[12].coffee_amount+"")&&j(i,l)},d(s){s&&k(e)}}}function qn(n){let e,t=Jt(n[12])+"",l;return{c(){e=f("span"),l=C(t)},m(i,r){y(i,e,r),o(e,l)},p(i,r){r&1&&t!==(t=Jt(i[12])+"")&&j(l,t)},d(i){i&&k(e)}}}function Yn(n){let e,t,l=n[12].tasting_notes+"",i,r;return{c(){e=f("p"),t=C('"'),i=C(l),r=C('"'),a(e,"class","text-sm text-brown-700 italic line-clamp-2 svelte-efadq")},m(s,c){y(s,e,c),o(e,t),o(e,i),o(e,r)},p(s,c){c&1&&l!==(l=s[12].tasting_notes+"")&&j(i,l)},d(s){s&&k(e)}}}function Vn(n){let e,t,l=n[12].rating+"",i,r;return{c(){e=f("span"),t=C("⭐ "),i=C(l),r=C("/10"),a(e,"class","inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-amber-100 text-amber-900")},m(s,c){y(s,e,c),o(e,t),o(e,i),o(e,r)},p(s,c){c&1&&l!==(l=s[12].rating+"")&&j(i,l)},d(s){s&&k(e)}}}function Kn(n){let e,t,l,i,r,s,c=yt(n[12].temperature),u,b=yt(n[12].coffee_amount),d,p=Jt(n[12]),_,m,h,g=Jn(n[12].created_at||n[12].created_at)+"",B,v,x,S=yt(n[12].rating),A,N,P,L,O,D,F,G,E,T,R,V,X,J,I,Y=n[2]===n[12].rkey?"Deleting...":"Delete",ue,$,Ae,ke,De;function ye(se,ee){return se[12].bean?uo:ao}let Me=ye(n),we=Me(n);function Fe(se,ee){if(se[12].brewer_obj)return fo;if(se[12].method)return co}let Ee=Fe(n),ae=Ee&&Ee(n),te=c&&Un(n),ie=b&&Wn(n),re=p&&qn(n),oe=n[12].tasting_notes&&Yn(n),he=S&&Vn(n);function fe(){return n[7](n[12])}function pe(){return n[8](n[12])}function Ie(){return n[9](n[12])}return{c(){e=f("div"),t=f("div"),l=f("div"),we.c(),i=w(),r=f("div"),ae&&ae.c(),s=w(),te&&te.c(),u=w(),ie&&ie.c(),d=w(),re&&re.c(),_=w(),oe&&oe.c(),m=w(),h=f("p"),B=C(g),v=w(),x=f("div"),he&&he.c(),A=w(),N=f("div"),P=f("a"),L=C("View"),D=w(),F=f("span"),F.textContent="|",G=w(),E=f("a"),T=C("Edit"),V=w(),X=f("span"),X.textContent="|",J=w(),I=f("button"),ue=C(Y),Ae=w(),a(r,"class","flex flex-wrap gap-x-4 gap-y-1 text-sm text-brown-600 mb-2"),a(h,"class","text-xs text-brown-500 mt-2"),a(l,"class","flex-1 min-w-0"),a(P,"href",O="/brews/"+n[12].rkey),a(P,"class","text-brown-700 hover:text-brown-900 text-sm font-medium hover:underline"),a(F,"class","text-brown-400"),a(E,"href",R="/brews/"+n[12].rkey+"/edit"),a(E,"class","text-brown-700 hover:text-brown-900 text-sm font-medium hover:underline"),a(X,"class","text-brown-400"),I.disabled=$=n[2]===n[12].rkey,a(I,"class","text-red-600 hover:text-red-800 text-sm font-medium hover:underline disabled:opacity-50"),a(N,"class","flex gap-2 items-center"),a(x,"class","flex flex-col items-end gap-2"),a(t,"class","flex items-start justify-between gap-4"),a(e,"class","bg-gradient-to-br from-brown-50 to-brown-100 rounded-lg shadow-md border border-brown-200 p-5 hover:shadow-lg transition-shadow")},m(se,ee){y(se,e,ee),o(e,t),o(t,l),we.m(l,null),o(l,i),o(l,r),ae&&ae.m(r,null),o(r,s),te&&te.m(r,null),o(r,u),ie&&ie.m(r,null),o(r,d),re&&re.m(r,null),o(l,_),oe&&oe.m(l,null),o(l,m),o(l,h),o(h,B),o(t,v),o(t,x),he&&he.m(x,null),o(x,A),o(x,N),o(N,P),o(P,L),o(N,D),o(N,F),o(N,G),o(N,E),o(E,T),o(N,V),o(N,X),o(N,J),o(N,I),o(I,ue),o(e,Ae),ke||(De=[z(P,"click",Ue(fe)),z(E,"click",Ue(pe)),z(I,"click",Ie)],ke=!0)},p(se,ee){n=se,Me===(Me=ye(n))&&we?we.p(n,ee):(we.d(1),we=Me(n),we&&(we.c(),we.m(l,i))),Ee===(Ee=Fe(n))&&ae?ae.p(n,ee):(ae&&ae.d(1),ae=Ee&&Ee(n),ae&&(ae.c(),ae.m(r,s))),ee&1&&(c=yt(n[12].temperature)),c?te?te.p(n,ee):(te=Un(n),te.c(),te.m(r,u)):te&&(te.d(1),te=null),ee&1&&(b=yt(n[12].coffee_amount)),b?ie?ie.p(n,ee):(ie=Wn(n),ie.c(),ie.m(r,d)):ie&&(ie.d(1),ie=null),ee&1&&(p=Jt(n[12])),p?re?re.p(n,ee):(re=qn(n),re.c(),re.m(r,null)):re&&(re.d(1),re=null),n[12].tasting_notes?oe?oe.p(n,ee):(oe=Yn(n),oe.c(),oe.m(l,m)):oe&&(oe.d(1),oe=null),ee&1&&g!==(g=Jn(n[12].created_at||n[12].created_at)+"")&&j(B,g),ee&1&&(S=yt(n[12].rating)),S?he?he.p(n,ee):(he=Vn(n),he.c(),he.m(x,A)):he&&(he.d(1),he=null),ee&1&&O!==(O="/brews/"+n[12].rkey)&&a(P,"href",O),ee&1&&R!==(R="/brews/"+n[12].rkey+"/edit")&&a(E,"href",R),ee&5&&Y!==(Y=n[2]===n[12].rkey?"Deleting...":"Delete")&&j(ue,Y),ee&5&&$!==($=n[2]===n[12].rkey)&&(I.disabled=$)},d(se){se&&k(e),we.d(),ae&&ae.d(),te&&te.d(),ie&&ie.d(),re&&re.d(),oe&&oe.d(),he&&he.d(),ke=!1,ce(De)}}}function bo(n){let e,t,l,i,r,s,c,u,b;function d(m,h){return m[1]?so:m[0].length===0?io:oo}let p=d(n),_=p(n);return{c(){e=w(),t=f("div"),l=f("div"),i=f("h1"),i.textContent="My Brews",r=w(),s=f("a"),s.textContent="☕ Add New Brew",c=w(),_.c(),document.title="My Brews - Arabica",a(i,"class","text-3xl font-bold text-brown-900"),a(s,"href","/brews/new"),a(s,"class","bg-gradient-to-r from-brown-700 to-brown-800 text-white px-6 py-3 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-semibold shadow-lg"),a(l,"class","flex items-center justify-between mb-6"),a(t,"class","max-w-6xl mx-auto")},m(m,h){y(m,e,h),y(m,t,h),o(t,l),o(l,i),o(l,r),o(l,s),o(t,c),_.m(t,null),u||(b=z(s,"click",Ue(n[5])),u=!0)},p(m,[h]){p===(p=d(m))&&_?_.p(m,h):(_.d(1),_=p(m),_&&(_.c(),_.m(t,null)))},i:W,o:W,d(m){m&&(k(e),k(t)),_.d(),u=!1,b()}}}function Jn(n){return n?new Date(n).toLocaleDateString("en-US",{year:"numeric",month:"short",day:"numeric"}):""}function yt(n){return n!=null&&n!==""}function Jt(n){if(yt(n.water_amount)&&n.water_amount>0)return`💧 ${n.water_amount}ml water`;if(n.pours&&n.pours.length>0){const e=n.pours.reduce((l,i)=>l+(i.water_amount||0),0),t=n.pours.length;return`💧 ${e}ml water (${t} pour${t!==1?"s":""})`}return null}function po(n,e,t){let l,i,r;ut(n,Te,g=>t(11,i=g)),ut(n,pt,g=>t(4,r=g));let s=[],c=!0,u=null;vt(async()=>{if(!l){_e("/login");return}await Te.load(),t(0,s=i.brews||[]),t(1,c=!1)});async function b(g){if(confirm("Are you sure you want to delete this brew?")){t(2,u=g);try{await ge.delete(`/brews/${g}`),await Te.invalidate(),t(0,s=i.brews||[])}catch(B){alert("Failed to delete brew: "+B.message)}finally{t(2,u=null)}}}const d=()=>_e("/brews/new"),p=()=>_e("/brews/new"),_=g=>_e(`/brews/${g.rkey}`),m=g=>_e(`/brews/${g.rkey}/edit`),h=g=>b(g.rkey);return n.$$.update=()=>{n.$$.dirty&16&&(l=r.isAuthenticated)},[s,c,u,b,r,d,p,_,m,h]}class _o extends Xe{constructor(e){super(),Qe(this,e,po,bo,We,{})}}function Qn(n,e,t){const l=n.slice();return l[18]=e[t],l[20]=t,l}function mo(n){let e,t,l,i,r,s,c=ol(n[2].created_at)+"",u,b,d,p,_=Dt(n[2].rating),m,h,g,B,v,x,S,A,N,P,L,O,D,F,G,E,T,R,V,X,J,I,Y,ue,$,Ae,ke,De,ye,Me,we,Fe,Ee,ae,te,ie,re,oe,he,fe=n[4]&&Xn(n),pe=_&&Zn(n);function Ie(M,ne){return M[2].bean?vo:go}let se=Ie(n),ee=se(n);function qe(M,ne){return M[2].brewer_obj?xo:M[2].method?yo:ko}let Pe=qe(n),Re=Pe(n);function Se(M,ne){return M[2].grinder_obj?Bo:Co}let Ze=Se(n),xe=Ze(n);function Ye(M,ne){return ne&4&&(R=null),R==null&&(R=!!Dt(M[2].coffee_amount)),R?So:Ao}let $e=Ye(n,-1),de=$e(n);function je(M,ne){return ne&32&&(Y=null),Y==null&&(Y=!!Dt(M[5])),Y?To:No}let be=je(n,-1),Ce=be(n);function q(M,ne){return M[2].grind_size?Oo:Lo}let Z=q(n),K=Z(n);function me(M,ne){return ne&4&&(Fe=null),Fe==null&&(Fe=!!Dt(M[2].temperature)),Fe?Eo:Mo}let dt=me(n,-1),He=dt(n),Ne=n[2].pours&&n[2].pours.length>0&&nl(n),ze=n[2].tasting_notes&&rl(n);return{c(){e=f("div"),t=f("div"),l=f("div"),i=f("h2"),i.textContent="Brew Details",r=w(),s=f("p"),u=C(c),b=w(),fe&&fe.c(),d=w(),p=f("div"),pe&&pe.c(),m=w(),h=f("div"),g=f("h3"),g.textContent="Coffee Bean",B=w(),ee.c(),v=w(),x=f("div"),S=f("div"),A=f("h3"),A.textContent="Brew Method",N=w(),Re.c(),P=w(),L=f("div"),O=f("h3"),O.textContent="Grinder",D=w(),xe.c(),F=w(),G=f("div"),E=f("h3"),E.textContent="Coffee",T=w(),de.c(),V=w(),X=f("div"),J=f("h3"),J.textContent="Water",I=w(),Ce.c(),ue=w(),$=f("div"),Ae=f("h3"),Ae.textContent="Grind Size",ke=w(),K.c(),De=w(),ye=f("div"),Me=f("h3"),Me.textContent="Water Temp",we=w(),He.c(),Ee=w(),Ne&&Ne.c(),ae=w(),ze&&ze.c(),te=w(),ie=f("div"),re=f("button"),re.textContent="← Back to Brews",a(i,"class","text-3xl font-bold text-brown-900"),a(s,"class","text-sm text-brown-600 mt-1"),a(t,"class","flex justify-between items-start mb-6"),a(g,"class","text-sm font-medium text-brown-600 uppercase tracking-wider mb-2"),a(h,"class","bg-brown-50 rounded-lg p-4 border border-brown-200"),a(A,"class","text-sm font-medium text-brown-600 uppercase tracking-wider mb-2"),a(S,"class","bg-brown-50 rounded-lg p-4 border border-brown-200"),a(O,"class","text-sm font-medium text-brown-600 uppercase tracking-wider mb-2"),a(L,"class","bg-brown-50 rounded-lg p-4 border border-brown-200"),a(E,"class","text-sm font-medium text-brown-600 uppercase tracking-wider mb-2"),a(G,"class","bg-brown-50 rounded-lg p-4 border border-brown-200"),a(J,"class","text-sm font-medium text-brown-600 uppercase tracking-wider mb-2"),a(X,"class","bg-brown-50 rounded-lg p-4 border border-brown-200"),a(Ae,"class","text-sm font-medium text-brown-600 uppercase tracking-wider mb-2"),a($,"class","bg-brown-50 rounded-lg p-4 border border-brown-200"),a(Me,"class","text-sm font-medium text-brown-600 uppercase tracking-wider mb-2"),a(ye,"class","bg-brown-50 rounded-lg p-4 border border-brown-200"),a(x,"class","grid grid-cols-2 gap-4"),a(p,"class","space-y-6"),a(re,"class","text-brown-700 hover:text-brown-900 font-medium hover:underline"),a(ie,"class","mt-6"),a(e,"class","bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 border border-brown-300")},m(M,ne){y(M,e,ne),o(e,t),o(t,l),o(l,i),o(l,r),o(l,s),o(s,u),o(t,b),fe&&fe.m(t,null),o(e,d),o(e,p),pe&&pe.m(p,null),o(p,m),o(p,h),o(h,g),o(h,B),ee.m(h,null),o(p,v),o(p,x),o(x,S),o(S,A),o(S,N),Re.m(S,null),o(x,P),o(x,L),o(L,O),o(L,D),xe.m(L,null),o(x,F),o(x,G),o(G,E),o(G,T),de.m(G,null),o(x,V),o(x,X),o(X,J),o(X,I),Ce.m(X,null),o(x,ue),o(x,$),o($,Ae),o($,ke),K.m($,null),o(x,De),o(x,ye),o(ye,Me),o(ye,we),He.m(ye,null),o(p,Ee),Ne&&Ne.m(p,null),o(p,ae),ze&&ze.m(p,null),o(e,te),o(e,ie),o(ie,re),oe||(he=z(re,"click",n[11]),oe=!0)},p(M,ne){ne&4&&c!==(c=ol(M[2].created_at)+"")&&j(u,c),M[4]?fe?fe.p(M,ne):(fe=Xn(M),fe.c(),fe.m(t,null)):fe&&(fe.d(1),fe=null),ne&4&&(_=Dt(M[2].rating)),_?pe?pe.p(M,ne):(pe=Zn(M),pe.c(),pe.m(p,m)):pe&&(pe.d(1),pe=null),se===(se=Ie(M))&&ee?ee.p(M,ne):(ee.d(1),ee=se(M),ee&&(ee.c(),ee.m(h,null))),Pe===(Pe=qe(M))&&Re?Re.p(M,ne):(Re.d(1),Re=Pe(M),Re&&(Re.c(),Re.m(S,null))),Ze===(Ze=Se(M))&&xe?xe.p(M,ne):(xe.d(1),xe=Ze(M),xe&&(xe.c(),xe.m(L,null))),$e===($e=Ye(M,ne))&&de?de.p(M,ne):(de.d(1),de=$e(M),de&&(de.c(),de.m(G,null))),be===(be=je(M,ne))&&Ce?Ce.p(M,ne):(Ce.d(1),Ce=be(M),Ce&&(Ce.c(),Ce.m(X,null))),Z===(Z=q(M))&&K?K.p(M,ne):(K.d(1),K=Z(M),K&&(K.c(),K.m($,null))),dt===(dt=me(M,ne))&&He?He.p(M,ne):(He.d(1),He=dt(M),He&&(He.c(),He.m(ye,null))),M[2].pours&&M[2].pours.length>0?Ne?Ne.p(M,ne):(Ne=nl(M),Ne.c(),Ne.m(p,ae)):Ne&&(Ne.d(1),Ne=null),M[2].tasting_notes?ze?ze.p(M,ne):(ze=rl(M),ze.c(),ze.m(p,null)):ze&&(ze.d(1),ze=null)},d(M){M&&k(e),fe&&fe.d(),pe&&pe.d(),ee.d(),Re.d(),xe.d(),de.d(),Ce.d(),K.d(),He.d(),Ne&&Ne.d(),ze&&ze.d(),oe=!1,he()}}}function wo(n){let e,t,l,i,r,s,c,u;return{c(){e=f("div"),t=f("h2"),t.textContent="Brew Not Found",l=w(),i=f("p"),i.textContent="The brew you're looking for doesn't exist.",r=w(),s=f("button"),s.textContent="Back to Brews",a(t,"class","text-2xl font-bold text-brown-900 mb-2"),a(i,"class","text-brown-700 mb-6"),a(s,"class","bg-gradient-to-r from-brown-700 to-brown-800 text-white px-6 py-3 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-semibold shadow-lg"),a(e,"class","bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl p-12 text-center border border-brown-300")},m(b,d){y(b,e,d),o(e,t),o(e,l),o(e,i),o(e,r),o(e,s),c||(u=z(s,"click",n[9]),c=!0)},p:W,d(b){b&&k(e),c=!1,u()}}}function ho(n){let e;return{c(){e=f("div"),e.innerHTML='<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-brown-800 mx-auto"></div> <p class="mt-4 text-brown-700">Loading brew...</p>',a(e,"class","text-center py-12")},m(t,l){y(t,e,l)},p:W,d(t){t&&k(e)}}}function Xn(n){let e,t,l,i,r,s;return{c(){e=f("div"),t=f("button"),t.textContent="Edit",l=w(),i=f("button"),i.textContent="Delete",a(t,"class","inline-flex items-center bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"),a(i,"class","inline-flex items-center bg-brown-200 text-brown-700 px-4 py-2 rounded-lg hover:bg-brown-300 font-medium transition-colors"),a(e,"class","flex gap-2")},m(c,u){y(c,e,u),o(e,t),o(e,l),o(e,i),r||(s=[z(t,"click",n[10]),z(i,"click",n[6])],r=!0)},p:W,d(c){c&&k(e),r=!1,ce(s)}}}function Zn(n){let e,t,l=n[2].rating+"",i,r,s,c;return{c(){e=f("div"),t=f("div"),i=C(l),r=C("/10"),s=w(),c=f("div"),c.textContent="Rating",a(t,"class","text-4xl font-bold text-brown-800"),a(c,"class","text-sm text-brown-600 mt-1"),a(e,"class","text-center py-4 bg-brown-50 rounded-lg border border-brown-200")},m(u,b){y(u,e,b),o(e,t),o(t,i),o(t,r),o(e,s),o(e,c)},p(u,b){b&4&&l!==(l=u[2].rating+"")&&j(i,l)},d(u){u&&k(e)}}}function go(n){let e;return{c(){e=f("span"),e.textContent="Not specified",a(e,"class","text-brown-400")},m(t,l){y(t,e,l)},p:W,d(t){t&&k(e)}}}function vo(n){var p;let e,t=(n[2].bean.name||n[2].bean.origin)+"",l,i,r,s,c,u=((p=n[2].bean.roaster)==null?void 0:p.Name)&&$n(n),b=n[2].bean.origin&&el(n),d=n[2].bean.roast_level&&tl(n);return{c(){e=f("div"),l=C(t),i=w(),u&&u.c(),r=w(),s=f("div"),b&&b.c(),c=w(),d&&d.c(),a(e,"class","font-bold text-lg text-brown-900"),a(s,"class","flex flex-wrap gap-3 mt-2 text-sm text-brown-600")},m(_,m){y(_,e,m),o(e,l),y(_,i,m),u&&u.m(_,m),y(_,r,m),y(_,s,m),b&&b.m(s,null),o(s,c),d&&d.m(s,null)},p(_,m){var h;m&4&&t!==(t=(_[2].bean.name||_[2].bean.origin)+"")&&j(l,t),(h=_[2].bean.roaster)!=null&&h.Name?u?u.p(_,m):(u=$n(_),u.c(),u.m(r.parentNode,r)):u&&(u.d(1),u=null),_[2].bean.origin?b?b.p(_,m):(b=el(_),b.c(),b.m(s,c)):b&&(b.d(1),b=null),_[2].bean.roast_level?d?d.p(_,m):(d=tl(_),d.c(),d.m(s,null)):d&&(d.d(1),d=null)},d(_){_&&(k(e),k(i),k(r),k(s)),u&&u.d(_),b&&b.d(),d&&d.d()}}}function $n(n){let e,t,l=n[2].bean.roaster.name+"",i;return{c(){e=f("div"),t=C("by "),i=C(l),a(e,"class","text-sm text-brown-700 mt-1")},m(r,s){y(r,e,s),o(e,t),o(e,i)},p(r,s){s&4&&l!==(l=r[2].bean.roaster.name+"")&&j(i,l)},d(r){r&&k(e)}}}function el(n){let e,t,l=n[2].bean.origin+"",i;return{c(){e=f("span"),t=C("Origin: "),i=C(l)},m(r,s){y(r,e,s),o(e,t),o(e,i)},p(r,s){s&4&&l!==(l=r[2].bean.origin+"")&&j(i,l)},d(r){r&&k(e)}}}function tl(n){let e,t,l=n[2].bean.roast_level+"",i;return{c(){e=f("span"),t=C("Roast: "),i=C(l)},m(r,s){y(r,e,s),o(e,t),o(e,i)},p(r,s){s&4&&l!==(l=r[2].bean.roast_level+"")&&j(i,l)},d(r){r&&k(e)}}}function ko(n){let e;return{c(){e=f("span"),e.textContent="Not specified",a(e,"class","text-brown-400")},m(t,l){y(t,e,l)},p:W,d(t){t&&k(e)}}}function yo(n){let e,t=n[2].method+"",l;return{c(){e=f("div"),l=C(t),a(e,"class","font-semibold text-brown-900")},m(i,r){y(i,e,r),o(e,l)},p(i,r){r&4&&t!==(t=i[2].method+"")&&j(l,t)},d(i){i&&k(e)}}}function xo(n){let e,t=n[2].brewer_obj.name+"",l;return{c(){e=f("div"),l=C(t),a(e,"class","font-semibold text-brown-900")},m(i,r){y(i,e,r),o(e,l)},p(i,r){r&4&&t!==(t=i[2].brewer_obj.name+"")&&j(l,t)},d(i){i&&k(e)}}}function Co(n){let e;return{c(){e=f("span"),e.textContent="Not specified",a(e,"class","text-brown-400")},m(t,l){y(t,e,l)},p:W,d(t){t&&k(e)}}}function Bo(n){let e,t=n[2].grinder_obj.name+"",l;return{c(){e=f("div"),l=C(t),a(e,"class","font-semibold text-brown-900")},m(i,r){y(i,e,r),o(e,l)},p(i,r){r&4&&t!==(t=i[2].grinder_obj.name+"")&&j(l,t)},d(i){i&&k(e)}}}function Ao(n){let e;return{c(){e=f("span"),e.textContent="Not specified",a(e,"class","text-brown-400")},m(t,l){y(t,e,l)},p:W,d(t){t&&k(e)}}}function So(n){let e,t=n[2].coffee_amount+"",l,i;return{c(){e=f("div"),l=C(t),i=C("g"),a(e,"class","font-semibold text-brown-900")},m(r,s){y(r,e,s),o(e,l),o(e,i)},p(r,s){s&4&&t!==(t=r[2].coffee_amount+"")&&j(l,t)},d(r){r&&k(e)}}}function No(n){let e;return{c(){e=f("span"),e.textContent="Not specified",a(e,"class","text-brown-400")},m(t,l){y(t,e,l)},p:W,d(t){t&&k(e)}}}function To(n){let e,t,l;return{c(){e=f("div"),t=C(n[5]),l=C("g"),a(e,"class","font-semibold text-brown-900")},m(i,r){y(i,e,r),o(e,t),o(e,l)},p(i,r){r&32&&j(t,i[5])},d(i){i&&k(e)}}}function Lo(n){let e;return{c(){e=f("span"),e.textContent="Not specified",a(e,"class","text-brown-400")},m(t,l){y(t,e,l)},p:W,d(t){t&&k(e)}}}function Oo(n){let e,t=n[2].grind_size+"",l;return{c(){e=f("div"),l=C(t),a(e,"class","font-semibold text-brown-900")},m(i,r){y(i,e,r),o(e,l)},p(i,r){r&4&&t!==(t=i[2].grind_size+"")&&j(l,t)},d(i){i&&k(e)}}}function Mo(n){let e;return{c(){e=f("span"),e.textContent="Not specified",a(e,"class","text-brown-400")},m(t,l){y(t,e,l)},p:W,d(t){t&&k(e)}}}function Eo(n){let e,t=n[2].temperature+"",l,i;return{c(){e=f("div"),l=C(t),i=C("°C"),a(e,"class","font-semibold text-brown-900")},m(r,s){y(r,e,s),o(e,l),o(e,i)},p(r,s){s&4&&t!==(t=r[2].temperature+"")&&j(l,t)},d(r){r&&k(e)}}}function nl(n){let e,t,l,i,r=le(n[2].pours),s=[];for(let c=0;c<r.length;c+=1)s[c]=ll(Qn(n,r,c));return{c(){e=f("div"),t=f("h3"),t.textContent="Pour Schedule",l=w(),i=f("div");for(let c=0;c<s.length;c+=1)s[c].c();a(t,"class","text-sm font-medium text-brown-600 uppercase tracking-wider mb-3"),a(i,"class","space-y-2"),a(e,"class","bg-brown-50 rounded-lg p-4 border border-brown-200")},m(c,u){y(c,e,u),o(e,t),o(e,l),o(e,i);for(let b=0;b<s.length;b+=1)s[b]&&s[b].m(i,null)},p(c,u){if(u&4){r=le(c[2].pours);let b;for(b=0;b<r.length;b+=1){const d=Qn(c,r,b);s[b]?s[b].p(d,u):(s[b]=ll(d),s[b].c(),s[b].m(i,null))}for(;b<s.length;b+=1)s[b].d(1);s.length=r.length}},d(c){c&&k(e),Ge(s,c)}}}function ll(n){let e,t,l,i,r=n[18].water_amount+"",s,c,u=n[18].time_seconds+"",b,d,p;return{c(){e=f("div"),t=f("span"),t.textContent=`Pour ${n[20]+1}:`,l=w(),i=f("span"),s=C(r),c=C("g at "),b=C(u),d=C("s"),p=w(),a(t,"class","text-brown-700"),a(i,"class","font-semibold text-brown-900"),a(e,"class","flex justify-between text-sm")},m(_,m){y(_,e,m),o(e,t),o(e,l),o(e,i),o(i,s),o(i,c),o(i,b),o(i,d),o(e,p)},p(_,m){m&4&&r!==(r=_[18].water_amount+"")&&j(s,r),m&4&&u!==(u=_[18].time_seconds+"")&&j(b,u)},d(_){_&&k(e)}}}function rl(n){let e,t,l,i,r,s=n[2].tasting_notes+"",c,u;return{c(){e=f("div"),t=f("h3"),t.textContent="Tasting Notes",l=w(),i=f("p"),r=C('"'),c=C(s),u=C('"'),a(t,"class","text-sm font-medium text-brown-600 uppercase tracking-wider mb-2"),a(i,"class","text-brown-900 italic"),a(e,"class","bg-brown-50 rounded-lg p-4 border border-brown-200")},m(b,d){y(b,e,d),o(e,t),o(e,l),o(e,i),o(i,r),o(i,c),o(i,u)},p(b,d){d&4&&s!==(s=b[2].tasting_notes+"")&&j(c,s)},d(b){b&&k(e)}}}function Po(n){let e,t;function l(s,c){return s[3]?ho:s[2]?mo:wo}let i=l(n),r=i(n);return{c(){e=w(),t=f("div"),r.c(),document.title="Brew Details - Arabica",a(t,"class","max-w-2xl mx-auto")},m(s,c){y(s,e,c),y(s,t,c),r.m(t,null)},p(s,[c]){i===(i=l(s))&&r?r.p(s,c):(r.d(1),r=i(s),r&&(r.c(),r.m(t,null)))},i:W,o:W,d(s){s&&(k(e),k(t)),r.d()}}}function Dt(n){return n!=null&&n!==""}function ol(n){return n?new Date(n).toLocaleDateString("en-US",{year:"numeric",month:"long",day:"numeric",hour:"numeric",minute:"2-digit"}):""}function Do(n,e,t){let l,i,r,s,c;ut(n,Te,A=>t(15,s=A)),ut(n,pt,A=>t(8,c=A));let{id:u=null}=e,{did:b=null}=e,{rkey:d=null}=e,p=null,_=!0,m=!1;vt(async()=>{if(!l){_e("/login");return}b&&d?(t(4,m=b===i),await g(b,d)):u&&(t(4,m=!0),await h(u)),t(3,_=!1)});async function h(A){await Te.load();const N=s.brews||[];t(2,p=N.find(P=>P.rkey===A))}async function g(A,N){try{const P=`at://${A}/social.arabica.alpha.brew/${N}`;t(2,p=await ge.get(`/api/brew?uri=${encodeURIComponent(P)}`))}catch(P){console.error("Failed to load brew:",P),P.message}}async function B(){if(!confirm("Are you sure you want to delete this brew?"))return;const A=d||u;if(!A){alert("Cannot delete brew: missing ID");return}try{await ge.delete(`/brews/${A}`),await Te.invalidate(),_e("/brews")}catch(N){alert("Failed to delete brew: "+N.message)}}const v=()=>_e("/brews"),x=()=>_e(`/brews/${d||u||p.rkey}/edit`),S=()=>_e("/brews");return n.$$set=A=>{"id"in A&&t(0,u=A.id),"did"in A&&t(7,b=A.did),"rkey"in A&&t(1,d=A.rkey)},n.$$.update=()=>{var A;n.$$.dirty&256&&(l=c.isAuthenticated),n.$$.dirty&256&&(i=(A=c.user)==null?void 0:A.did),n.$$.dirty&4&&t(5,r=p&&(p.water_amount||0)===0&&p.pours&&p.pours.length>0?p.pours.reduce((N,P)=>N+(P.water_amount||0),0):(p==null?void 0:p.water_amount)||0)},[u,d,p,_,m,r,B,b,c,v,x,S]}class il extends Xe{constructor(e){super(),Qe(this,e,Do,Po,We,{id:0,did:7,rkey:1})}}function sl(n){let e,t,l,i,r,s,c,u,b,d,p,_,m,h;const g=n[5].default,B=sr(g,n,n[4],null);return{c(){e=f("div"),t=f("div"),l=f("h3"),i=C(n[3]),r=w(),s=f("div"),B&&B.c(),c=w(),u=f("div"),b=f("button"),b.textContent="Save",d=w(),p=f("button"),p.textContent="Cancel",a(l,"class","text-xl font-semibold mb-4 text-brown-900"),a(b,"type","button"),a(b,"class","flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md"),a(p,"type","button"),a(p,"class","flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"),a(u,"class","flex gap-2"),a(s,"class","space-y-4"),a(t,"class","bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"),a(e,"class","fixed inset-0 bg-black/40 flex items-center justify-center z-50")},m(v,x){y(v,e,x),o(e,t),o(t,l),o(l,i),o(t,r),o(t,s),B&&B.m(s,null),o(s,c),o(s,u),o(u,b),o(u,d),o(u,p),_=!0,m||(h=[z(b,"click",function(){Vt(n[0])&&n[0].apply(this,arguments)}),z(p,"click",function(){Vt(n[1])&&n[1].apply(this,arguments)})],m=!0)},p(v,x){n=v,(!_||x&8)&&j(i,n[3]),B&&B.p&&(!_||x&16)&&ur(B,g,n,n[4],_?ar(g,n[4],x,null):cr(n[4]),null)},i(v){_||(ve(B,v),_=!0)},o(v){Oe(B,v),_=!1},d(v){v&&k(e),B&&B.d(v),m=!1,ce(h)}}}function Fo(n){let e,t,l=n[2]&&sl(n);return{c(){l&&l.c(),e=ft()},m(i,r){l&&l.m(i,r),y(i,e,r),t=!0},p(i,[r]){i[2]?l?(l.p(i,r),r&4&&ve(l,1)):(l=sl(i),l.c(),ve(l,1),l.m(e.parentNode,e)):l&&(jt(),Oe(l,1,1,()=>{l=null}),Ht())},i(i){t||(ve(l),t=!0)},o(i){Oe(l),t=!1},d(i){i&&k(e),l&&l.d(i)}}}function Ro(n,e,t){let{$$slots:l={},$$scope:i}=e,{onSave:r}=e,{onCancel:s}=e,{isOpen:c=!1}=e,{title:u="Modal"}=e;return n.$$set=b=>{"onSave"in b&&t(0,r=b.onSave),"onCancel"in b&&t(1,s=b.onCancel),"isOpen"in b&&t(2,c=b.isOpen),"title"in b&&t(3,u=b.title),"$$scope"in b&&t(4,i=b.$$scope)},[r,s,c,u,i,l]}class ht extends Xe{constructor(e){super(),Qe(this,e,Ro,Fo,We,{onSave:0,onCancel:1,isOpen:2,title:3})}}function al(n,e,t){const l=n.slice();return l[66]=e[t],l}function ul(n,e,t){const l=n.slice();return l[69]=e[t],l[70]=e,l[71]=t,l}function cl(n,e,t){const l=n.slice();return l[72]=e[t],l}function fl(n,e,t){const l=n.slice();return l[75]=e[t],l}function dl(n,e,t){const l=n.slice();return l[78]=e[t],l}function jo(n){let e,t,l,i,r,s=n[0]==="edit"?"Edit Brew":"New Brew",c,u,b,d,p,_,m,h,g,B,v,x,S,A,N,P,L,O,D,F,G,E,T,R,V,X,J,I,Y,ue,$,Ae,ke,De,ye,Me,we,Fe,Ee,ae,te,ie,re,oe,he,fe,pe,Ie,se,ee,qe,Pe,Re,Se,Ze,xe,Ye,$e,de,je,be,Ce,q,Z,K,me,dt,He,Ne,ze,M,ne=n[1].rating+"",Qt,on,sn,st,an,zt,un,Et,Pt,cn,bt,fn,Ct,kt,Gt=n[4]?"Saving...":n[0]==="edit"?"Update Brew":"Save Brew",Xt,dn,Bt,Zt,bn,et=n[5]&&bl(n),At=le(n[17]),Ve=[];for(let U=0;U<At.length;U+=1)Ve[U]=pl(dl(n,At,U));let St=le(n[15]),Ke=[];for(let U=0;U<St.length;U+=1)Ke[U]=_l(fl(n,St,U));let Nt=le(n[14]),Je=[];for(let U=0;U<Nt.length;U+=1)Je[U]=ml(cl(n,Nt,U));let tt=n[2].length>0&&wl(n);return{c(){e=f("div"),t=f("div"),l=f("button"),l.innerHTML='<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path></svg>',i=w(),r=f("h2"),c=C(s),u=w(),et&&et.c(),b=w(),d=f("form"),p=f("div"),_=f("label"),_.textContent="Coffee Bean *",m=w(),h=f("div"),g=f("select"),B=f("option"),B.textContent="Select a bean...";for(let U=0;U<Ve.length;U+=1)Ve[U].c();v=w(),x=f("button"),x.textContent="+ New",S=w(),A=f("div"),N=f("label"),N.textContent="Coffee Amount (grams)",P=w(),L=f("input"),O=w(),D=f("p"),D.textContent="Amount of ground coffee used",F=w(),G=f("div"),E=f("label"),E.textContent="Grinder",T=w(),R=f("div"),V=f("select"),X=f("option"),X.textContent="Select a grinder...";for(let U=0;U<Ke.length;U+=1)Ke[U].c();J=w(),I=f("button"),I.textContent="+ New",Y=w(),ue=f("div"),$=f("label"),$.textContent="Grind Size",Ae=w(),ke=f("input"),De=w(),ye=f("p"),ye.textContent='Enter a number (grinder setting) or description (e.g. "Medium", "Fine")',Me=w(),we=f("div"),Fe=f("label"),Fe.textContent="Brew Method",Ee=w(),ae=f("div"),te=f("select"),ie=f("option"),ie.textContent="Select brew method...";for(let U=0;U<Je.length;U+=1)Je[U].c();re=w(),oe=f("button"),oe.textContent="+ New",he=w(),fe=f("div"),pe=f("label"),pe.textContent="Water Amount (ml)",Ie=w(),se=f("input"),ee=w(),qe=f("div"),Pe=f("label"),Pe.textContent="Water Temperature (°C)",Re=w(),Se=f("input"),Ze=w(),xe=f("div"),Ye=f("label"),Ye.textContent="Total Brew Time (seconds)",$e=w(),de=f("input"),je=w(),be=f("div"),Ce=f("div"),q=f("span"),q.textContent="Pour Schedule (Optional)",Z=w(),K=f("button"),K.textContent="+ Add Pour",me=w(),tt&&tt.c(),dt=w(),He=f("div"),Ne=f("label"),ze=C("Rating: "),M=f("span"),Qt=C(ne),on=C("/10"),sn=w(),st=f("input"),an=w(),zt=f("div"),zt.innerHTML="<span>0</span> <span>10</span>",un=w(),Et=f("div"),Pt=f("label"),Pt.textContent="Tasting Notes",cn=w(),bt=f("textarea"),fn=w(),Ct=f("div"),kt=f("button"),Xt=C(Gt),dn=w(),Bt=f("button"),Bt.textContent="Cancel",a(l,"class","inline-flex items-center text-brown-700 hover:text-brown-900 font-medium transition-colors cursor-pointer"),a(r,"class","text-3xl font-bold text-brown-900"),a(t,"class","flex items-center gap-3 mb-6"),a(_,"for","bean-select"),a(_,"class","block text-sm font-medium text-brown-900 mb-2"),B.__value="",H(B,B.__value),a(g,"id","bean-select"),g.required=!0,a(g,"class","flex-1 rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 truncate max-w-full bg-white"),n[1].bean_rkey===void 0&&rt(()=>n[29].call(g)),a(x,"type","button"),a(x,"class","bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"),a(h,"class","flex gap-2"),a(N,"for","coffee-amount"),a(N,"class","block text-sm font-medium text-brown-900 mb-2"),a(L,"id","coffee-amount"),a(L,"type","number"),a(L,"step","0.1"),a(L,"placeholder","e.g. 18"),a(L,"class","w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"),a(D,"class","text-sm text-brown-700 mt-1"),a(E,"for","grinder-select"),a(E,"class","block text-sm font-medium text-brown-900 mb-2"),X.__value="",H(X,X.__value),a(V,"id","grinder-select"),a(V,"class","flex-1 rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 truncate max-w-full bg-white"),n[1].grinder_rkey===void 0&&rt(()=>n[32].call(V)),a(I,"type","button"),a(I,"class","bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"),a(R,"class","flex gap-2"),a($,"for","grind-size"),a($,"class","block text-sm font-medium text-brown-900 mb-2"),a(ke,"id","grind-size"),a(ke,"type","text"),a(ke,"placeholder","e.g. 18, Medium, 3.5, Fine"),a(ke,"class","w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"),a(ye,"class","text-sm text-brown-700 mt-1"),a(Fe,"for","brewer-select"),a(Fe,"class","block text-sm font-medium text-brown-900 mb-2"),ie.__value="",H(ie,ie.__value),a(te,"id","brewer-select"),a(te,"class","flex-1 rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 truncate max-w-full bg-white"),n[1].brewer_rkey===void 0&&rt(()=>n[35].call(te)),a(oe,"type","button"),a(oe,"class","bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"),a(ae,"class","flex gap-2"),a(pe,"for","water-amount"),a(pe,"class","block text-sm font-medium text-brown-900 mb-2"),a(se,"id","water-amount"),a(se,"type","number"),a(se,"step","1"),a(se,"placeholder","e.g. 300"),a(se,"class","w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"),a(Pe,"for","water-temp"),a(Pe,"class","block text-sm font-medium text-brown-900 mb-2"),a(Se,"id","water-temp"),a(Se,"type","number"),a(Se,"step","0.1"),a(Se,"placeholder","e.g. 93"),a(Se,"class","w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"),a(Ye,"for","brew-time"),a(Ye,"class","block text-sm font-medium text-brown-900 mb-2"),a(de,"id","brew-time"),a(de,"type","number"),a(de,"step","1"),a(de,"placeholder","e.g. 210"),a(de,"class","w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"),a(q,"class","block text-sm font-medium text-brown-900"),a(K,"type","button"),a(K,"class","text-sm bg-brown-300 text-brown-900 px-3 py-1 rounded hover:bg-brown-400 font-medium transition-colors"),a(Ce,"class","flex items-center justify-between mb-2"),a(M,"class","font-bold"),a(Ne,"for","rating"),a(Ne,"class","block text-sm font-medium text-brown-900 mb-2"),a(st,"id","rating"),a(st,"type","range"),a(st,"min","0"),a(st,"max","10"),a(st,"step","1"),a(st,"class","w-full h-2 bg-brown-200 rounded-lg appearance-none cursor-pointer accent-brown-700"),a(zt,"class","flex justify-between text-xs text-brown-600 mt-1"),a(Pt,"for","notes"),a(Pt,"class","block text-sm font-medium text-brown-900 mb-2"),a(bt,"id","notes"),a(bt,"rows","4"),a(bt,"placeholder","Describe the flavor, aroma, body, etc."),a(bt,"class","w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white"),a(kt,"type","submit"),kt.disabled=n[4],a(kt,"class","flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white py-3 px-6 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-semibold shadow-lg disabled:opacity-50"),a(Bt,"type","button"),a(Bt,"class","px-6 py-3 border-2 border-brown-300 text-brown-700 rounded-lg hover:bg-brown-100 font-semibold transition-colors"),a(Ct,"class","flex gap-3"),a(d,"class","space-y-6"),a(e,"class","bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 border border-brown-300")},m(U,Be){y(U,e,Be),o(e,t),o(t,l),o(t,i),o(t,r),o(r,c),o(e,u),et&&et.m(e,null),o(e,b),o(e,d),o(d,p),o(p,_),o(p,m),o(p,h),o(h,g),o(g,B);for(let Q=0;Q<Ve.length;Q+=1)Ve[Q]&&Ve[Q].m(g,null);Le(g,n[1].bean_rkey,!0),o(h,v),o(h,x),o(d,S),o(d,A),o(A,N),o(A,P),o(A,L),H(L,n[1].coffee_amount),o(A,O),o(A,D),o(d,F),o(d,G),o(G,E),o(G,T),o(G,R),o(R,V),o(V,X);for(let Q=0;Q<Ke.length;Q+=1)Ke[Q]&&Ke[Q].m(V,null);Le(V,n[1].grinder_rkey,!0),o(R,J),o(R,I),o(d,Y),o(d,ue),o(ue,$),o(ue,Ae),o(ue,ke),H(ke,n[1].grind_size),o(ue,De),o(ue,ye),o(d,Me),o(d,we),o(we,Fe),o(we,Ee),o(we,ae),o(ae,te),o(te,ie);for(let Q=0;Q<Je.length;Q+=1)Je[Q]&&Je[Q].m(te,null);Le(te,n[1].brewer_rkey,!0),o(ae,re),o(ae,oe),o(d,he),o(d,fe),o(fe,pe),o(fe,Ie),o(fe,se),H(se,n[1].water_amount),o(d,ee),o(d,qe),o(qe,Pe),o(qe,Re),o(qe,Se),H(Se,n[1].water_temp),o(d,Ze),o(d,xe),o(xe,Ye),o(xe,$e),o(xe,de),H(de,n[1].brew_time),o(d,je),o(d,be),o(be,Ce),o(Ce,q),o(Ce,Z),o(Ce,K),o(be,me),tt&&tt.m(be,null),o(d,dt),o(d,He),o(He,Ne),o(Ne,ze),o(Ne,M),o(M,Qt),o(M,on),o(He,sn),o(He,st),H(st,n[1].rating),o(He,an),o(He,zt),o(d,un),o(d,Et),o(Et,Pt),o(Et,cn),o(Et,bt),H(bt,n[1].notes),o(d,fn),o(d,Ct),o(Ct,kt),o(kt,Xt),o(Ct,dn),o(Ct,Bt),Zt||(bn=[z(l,"click",n[28]),z(g,"change",n[29]),z(x,"click",n[30]),z(L,"input",n[31]),z(V,"change",n[32]),z(I,"click",n[33]),z(ke,"input",n[34]),z(te,"change",n[35]),z(oe,"click",n[36]),z(se,"input",n[37]),z(Se,"input",n[38]),z(de,"input",n[39]),z(K,"click",n[18]),z(st,"change",n[43]),z(st,"input",n[43]),z(bt,"input",n[44]),z(Bt,"click",n[45]),z(d,"submit",Ue(n[20]))],Zt=!0)},p(U,Be){if(Be[0]&1&&s!==(s=U[0]==="edit"?"Edit Brew":"New Brew")&&j(c,s),U[5]?et?et.p(U,Be):(et=bl(U),et.c(),et.m(e,b)):et&&(et.d(1),et=null),Be[0]&131072){At=le(U[17]);let Q;for(Q=0;Q<At.length;Q+=1){const _t=dl(U,At,Q);Ve[Q]?Ve[Q].p(_t,Be):(Ve[Q]=pl(_t),Ve[Q].c(),Ve[Q].m(g,null))}for(;Q<Ve.length;Q+=1)Ve[Q].d(1);Ve.length=At.length}if(Be[0]&131074&&Le(g,U[1].bean_rkey),Be[0]&131074&&ot(L.value)!==U[1].coffee_amount&&H(L,U[1].coffee_amount),Be[0]&32768){St=le(U[15]);let Q;for(Q=0;Q<St.length;Q+=1){const _t=fl(U,St,Q);Ke[Q]?Ke[Q].p(_t,Be):(Ke[Q]=_l(_t),Ke[Q].c(),Ke[Q].m(V,null))}for(;Q<Ke.length;Q+=1)Ke[Q].d(1);Ke.length=St.length}if(Be[0]&131074&&Le(V,U[1].grinder_rkey),Be[0]&131074&&ke.value!==U[1].grind_size&&H(ke,U[1].grind_size),Be[0]&16384){Nt=le(U[14]);let Q;for(Q=0;Q<Nt.length;Q+=1){const _t=cl(U,Nt,Q);Je[Q]?Je[Q].p(_t,Be):(Je[Q]=ml(_t),Je[Q].c(),Je[Q].m(te,null))}for(;Q<Je.length;Q+=1)Je[Q].d(1);Je.length=Nt.length}Be[0]&131074&&Le(te,U[1].brewer_rkey),Be[0]&131074&&ot(se.value)!==U[1].water_amount&&H(se,U[1].water_amount),Be[0]&131074&&ot(Se.value)!==U[1].water_temp&&H(Se,U[1].water_temp),Be[0]&131074&&ot(de.value)!==U[1].brew_time&&H(de,U[1].brew_time),U[2].length>0?tt?tt.p(U,Be):(tt=wl(U),tt.c(),tt.m(be,null)):tt&&(tt.d(1),tt=null),Be[0]&2&&ne!==(ne=U[1].rating+"")&&j(Qt,ne),Be[0]&131074&&H(st,U[1].rating),Be[0]&131074&&H(bt,U[1].notes),Be[0]&17&&Gt!==(Gt=U[4]?"Saving...":U[0]==="edit"?"Update Brew":"Save Brew")&&j(Xt,Gt),Be[0]&16&&(kt.disabled=U[4])},d(U){U&&k(e),et&&et.d(),Ge(Ve,U),Ge(Ke,U),Ge(Je,U),tt&&tt.d(),Zt=!1,ce(bn)}}}function Ho(n){let e;return{c(){e=f("div"),e.innerHTML='<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-brown-800 mx-auto"></div> <p class="mt-4 text-brown-700">Loading...</p>',a(e,"class","text-center py-12")},m(t,l){y(t,e,l)},p:W,d(t){t&&k(e)}}}function bl(n){let e,t;return{c(){e=f("div"),t=C(n[5]),a(e,"class","mb-4 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded")},m(l,i){y(l,e,i),o(e,t)},p(l,i){i[0]&32&&j(t,l[5])},d(l){l&&k(e)}}}function pl(n){let e,t=(n[78].name||n[78].origin)+"",l,i,r=n[78].origin+"",s,c,u=n[78].roast_level+"",b,d,p;return{c(){e=f("option"),l=C(t),i=C(" ("),s=C(r),c=C(" - "),b=C(u),d=C(`) 2 - `),e.__value=p=n[78].rkey,H(e,e.__value)},m(_,m){y(_,e,m),o(e,l),o(e,i),o(e,s),o(e,c),o(e,b),o(e,d)},p(_,m){m[0]&131072&&t!==(t=(_[78].name||_[78].origin)+"")&&j(l,t),m[0]&131072&&r!==(r=_[78].origin+"")&&j(s,r),m[0]&131072&&u!==(u=_[78].roast_level+"")&&j(b,u),m[0]&131072&&p!==(p=_[78].rkey)&&(e.__value=p,H(e,e.__value))},d(_){_&&k(e)}}}function _l(n){let e,t=n[75].name+"",l,i;return{c(){e=f("option"),l=C(t),e.__value=i=n[75].rkey,H(e,e.__value)},m(r,s){y(r,e,s),o(e,l)},p(r,s){s[0]&32768&&t!==(t=r[75].name+"")&&j(l,t),s[0]&32768&&i!==(i=r[75].rkey)&&(e.__value=i,H(e,e.__value))},d(r){r&&k(e)}}}function ml(n){let e,t=n[72].name+"",l,i;return{c(){e=f("option"),l=C(t),e.__value=i=n[72].rkey,H(e,e.__value)},m(r,s){y(r,e,s),o(e,l)},p(r,s){s[0]&16384&&t!==(t=r[72].name+"")&&j(l,t),s[0]&16384&&i!==(i=r[72].rkey)&&(e.__value=i,H(e,e.__value))},d(r){r&&k(e)}}}function wl(n){let e,t=le(n[2]),l=[];for(let i=0;i<t.length;i+=1)l[i]=hl(ul(n,t,i));return{c(){e=f("div");for(let i=0;i<l.length;i+=1)l[i].c();a(e,"class","space-y-2")},m(i,r){y(i,e,r);for(let s=0;s<l.length;s+=1)l[s]&&l[s].m(e,null)},p(i,r){if(r[0]&524292){t=le(i[2]);let s;for(s=0;s<t.length;s+=1){const c=ul(i,t,s);l[s]?l[s].p(c,r):(l[s]=hl(c),l[s].c(),l[s].m(e,null))}for(;s<l.length;s+=1)l[s].d(1);l.length=t.length}},d(i){i&&k(e),Ge(l,i)}}}function hl(n){let e,t,l,i,r,s,c,u,b,d,p;function _(){n[40].call(i,n[70],n[71])}function m(){n[41].call(s,n[70],n[71])}function h(){return n[42](n[71])}return{c(){e=f("div"),t=f("span"),t.textContent=`Pour ${n[71]+1}:`,l=w(),i=f("input"),r=w(),s=f("input"),c=w(),u=f("button"),u.textContent="✕",b=w(),a(t,"class","text-sm font-medium text-brown-700 min-w-[60px]"),a(i,"type","number"),a(i,"placeholder","Water (g)"),a(i,"class","flex-1 rounded border border-brown-300 px-3 py-2 text-sm"),a(s,"type","number"),a(s,"placeholder","Time (s)"),a(s,"class","flex-1 rounded border border-brown-300 px-3 py-2 text-sm"),a(u,"type","button"),a(u,"class","text-red-600 hover:text-red-800 font-medium px-2"),a(e,"class","flex gap-2 items-center bg-brown-50 p-3 rounded-lg border border-brown-200")},m(g,B){y(g,e,B),o(e,t),o(e,l),o(e,i),H(i,n[69].water_amount),o(e,r),o(e,s),H(s,n[69].time_seconds),o(e,c),o(e,u),o(e,b),d||(p=[z(i,"input",_),z(s,"input",m),z(u,"click",h)],d=!0)},p(g,B){n=g,B[0]&4&&ot(i.value)!==n[69].water_amount&&H(i,n[69].water_amount),B[0]&4&&ot(s.value)!==n[69].time_seconds&&H(s,n[69].time_seconds)},d(g){g&&k(e),d=!1,ce(p)}}}function gl(n){let e,t=n[66].name+"",l,i;return{c(){e=f("option"),l=C(t),e.__value=i=n[66].rkey,H(e,e.__value)},m(r,s){y(r,e,s),o(e,l)},p(r,s){s[0]&65536&&t!==(t=r[66].name+"")&&j(l,t),s[0]&65536&&i!==(i=r[66].rkey)&&(e.__value=i,H(e,e.__value))},d(r){r&&k(e)}}}function zo(n){let e,t,l,i,r,s,c,u,b,d,p,_,m,h,g,B,v,x,S,A,N,P,L,O,D,F,G,E,T,R,V,X,J=le(n[16]),I=[];for(let Y=0;Y<J.length;Y+=1)I[Y]=gl(al(n,J,Y));return{c(){e=f("div"),t=f("div"),l=f("label"),l.textContent="Name",i=w(),r=f("input"),s=w(),c=f("div"),u=f("label"),u.textContent="Origin *",b=w(),d=f("input"),p=w(),_=f("div"),m=f("label"),m.textContent="Roast Level *",h=w(),g=f("select"),B=f("option"),B.textContent="Select...",v=f("option"),v.textContent="Light",x=f("option"),x.textContent="Medium-Light",S=f("option"),S.textContent="Medium",A=f("option"),A.textContent="Medium-Dark",N=f("option"),N.textContent="Dark",P=w(),L=f("div"),O=f("label"),O.textContent="Roaster",D=w(),F=f("div"),G=f("select"),E=f("option"),E.textContent="Select...";for(let Y=0;Y<I.length;Y+=1)I[Y].c();T=w(),R=f("button"),R.textContent="+ New",a(l,"for","bean-name"),a(l,"class","block text-sm font-medium text-gray-700 mb-1"),a(r,"id","bean-name"),a(r,"type","text"),a(r,"class","w-full rounded border-gray-300 px-3 py-2"),a(u,"for","bean-origin"),a(u,"class","block text-sm font-medium text-gray-700 mb-1"),a(d,"id","bean-origin"),a(d,"type","text"),d.required=!0,a(d,"class","w-full rounded border-gray-300 px-3 py-2"),a(m,"for","bean-roast-level"),a(m,"class","block text-sm font-medium text-gray-700 mb-1"),B.__value="",H(B,B.__value),v.__value="Light",H(v,v.__value),x.__value="Medium-Light",H(x,x.__value),S.__value="Medium",H(S,S.__value),A.__value="Medium-Dark",H(A,A.__value),N.__value="Dark",H(N,N.__value),a(g,"id","bean-roast-level"),g.required=!0,a(g,"class","w-full rounded border-gray-300 px-3 py-2"),n[10].roast_level===void 0&&rt(()=>n[48].call(g)),a(O,"for","bean-roaster"),a(O,"class","block text-sm font-medium text-gray-700 mb-1"),E.__value="",H(E,E.__value),a(G,"id","bean-roaster"),a(G,"class","flex-1 rounded border-gray-300 px-3 py-2"),n[10].roaster_rkey===void 0&&rt(()=>n[49].call(G)),a(R,"type","button"),a(R,"class","bg-gray-200 px-3 py-1 rounded hover:bg-gray-300 text-sm"),a(F,"class","flex gap-2"),a(e,"class","space-y-4")},m(Y,ue){y(Y,e,ue),o(e,t),o(t,l),o(t,i),o(t,r),H(r,n[10].name),o(e,s),o(e,c),o(c,u),o(c,b),o(c,d),H(d,n[10].origin),o(e,p),o(e,_),o(_,m),o(_,h),o(_,g),o(g,B),o(g,v),o(g,x),o(g,S),o(g,A),o(g,N),Le(g,n[10].roast_level,!0),o(e,P),o(e,L),o(L,O),o(L,D),o(L,F),o(F,G),o(G,E);for(let $=0;$<I.length;$+=1)I[$]&&I[$].m(G,null);Le(G,n[10].roaster_rkey,!0),o(F,T),o(F,R),V||(X=[z(r,"input",n[46]),z(d,"input",n[47]),z(g,"change",n[48]),z(G,"change",n[49]),z(R,"click",n[50])],V=!0)},p(Y,ue){if(ue[0]&1024&&r.value!==Y[10].name&&H(r,Y[10].name),ue[0]&1024&&d.value!==Y[10].origin&&H(d,Y[10].origin),ue[0]&1024&&Le(g,Y[10].roast_level),ue[0]&65536){J=le(Y[16]);let $;for($=0;$<J.length;$+=1){const Ae=al(Y,J,$);I[$]?I[$].p(Ae,ue):(I[$]=gl(Ae),I[$].c(),I[$].m(G,null))}for(;$<I.length;$+=1)I[$].d(1);I.length=J.length}ue[0]&1024&&Le(G,Y[10].roaster_rkey)},d(Y){Y&&k(e),Ge(I,Y),V=!1,ce(X)}}}function Go(n){let e,t,l,i,r,s,c,u,b,d,p,_;return{c(){e=f("div"),t=f("div"),l=f("label"),l.textContent="Name *",i=w(),r=f("input"),s=w(),c=f("div"),u=f("label"),u.textContent="Location",b=w(),d=f("input"),a(l,"for","roaster-name"),a(l,"class","block text-sm font-medium text-gray-700 mb-1"),a(r,"id","roaster-name"),a(r,"type","text"),r.required=!0,a(r,"class","w-full rounded border-gray-300 px-3 py-2"),a(u,"for","roaster-location"),a(u,"class","block text-sm font-medium text-gray-700 mb-1"),a(d,"id","roaster-location"),a(d,"type","text"),a(d,"class","w-full rounded border-gray-300 px-3 py-2"),a(e,"class","space-y-4")},m(m,h){y(m,e,h),o(e,t),o(t,l),o(t,i),o(t,r),H(r,n[11].name),o(e,s),o(e,c),o(c,u),o(c,b),o(c,d),H(d,n[11].location),p||(_=[z(r,"input",n[53]),z(d,"input",n[54])],p=!0)},p(m,h){h[0]&2048&&r.value!==m[11].name&&H(r,m[11].name),h[0]&2048&&d.value!==m[11].location&&H(d,m[11].location)},d(m){m&&k(e),p=!1,ce(_)}}}function Io(n){let e,t,l,i,r,s,c,u,b,d,p,_,m,h,g,B;return{c(){e=f("div"),t=f("div"),l=f("label"),l.textContent="Name *",i=w(),r=f("input"),s=w(),c=f("div"),u=f("label"),u.textContent="Type",b=w(),d=f("select"),p=f("option"),p.textContent="Select...",_=f("option"),_.textContent="Manual",m=f("option"),m.textContent="Electric",h=f("option"),h.textContent="Blade",a(l,"for","grinder-name"),a(l,"class","block text-sm font-medium text-gray-700 mb-1"),a(r,"id","grinder-name"),a(r,"type","text"),r.required=!0,a(r,"class","w-full rounded border-gray-300 px-3 py-2"),a(u,"for","grinder-type"),a(u,"class","block text-sm font-medium text-gray-700 mb-1"),p.__value="",H(p,p.__value),_.__value="Manual",H(_,_.__value),m.__value="Electric",H(m,m.__value),h.__value="Blade",H(h,h.__value),a(d,"id","grinder-type"),a(d,"class","w-full rounded border-gray-300 px-3 py-2"),n[12].grinder_type===void 0&&rt(()=>n[58].call(d)),a(e,"class","space-y-4")},m(v,x){y(v,e,x),o(e,t),o(t,l),o(t,i),o(t,r),H(r,n[12].name),o(e,s),o(e,c),o(c,u),o(c,b),o(c,d),o(d,p),o(d,_),o(d,m),o(d,h),Le(d,n[12].grinder_type,!0),g||(B=[z(r,"input",n[57]),z(d,"change",n[58])],g=!0)},p(v,x){x[0]&4096&&r.value!==v[12].name&&H(r,v[12].name),x[0]&4096&&Le(d,v[12].grinder_type)},d(v){v&&k(e),g=!1,ce(B)}}}function Uo(n){let e,t,l,i,r,s,c,u,b,d,p,_,m,h,g,B,v,x,S,A;return{c(){e=f("div"),t=f("div"),l=f("label"),l.textContent="Name *",i=w(),r=f("input"),s=w(),c=f("div"),u=f("label"),u.textContent="Type",b=w(),d=f("select"),p=f("option"),p.textContent="Select...",_=f("option"),_.textContent="Pour Over",m=f("option"),m.textContent="French Press",h=f("option"),h.textContent="Espresso",g=f("option"),g.textContent="Moka Pot",B=f("option"),B.textContent="Aeropress",v=f("option"),v.textContent="Cold Brew",x=f("option"),x.textContent="Siphon",a(l,"for","brewer-name"),a(l,"class","block text-sm font-medium text-gray-700 mb-1"),a(r,"id","brewer-name"),a(r,"type","text"),r.required=!0,a(r,"class","w-full rounded border-gray-300 px-3 py-2"),a(u,"for","brewer-type"),a(u,"class","block text-sm font-medium text-gray-700 mb-1"),p.__value="",H(p,p.__value),_.__value="Pour Over",H(_,_.__value),m.__value="French Press",H(m,m.__value),h.__value="Espresso",H(h,h.__value),g.__value="Moka Pot",H(g,g.__value),B.__value="Aeropress",H(B,B.__value),v.__value="Cold Brew",H(v,v.__value),x.__value="Siphon",H(x,x.__value),a(d,"id","brewer-type"),a(d,"class","w-full rounded border-gray-300 px-3 py-2"),n[13].brewer_type===void 0&&rt(()=>n[62].call(d)),a(e,"class","space-y-4")},m(N,P){y(N,e,P),o(e,t),o(t,l),o(t,i),o(t,r),H(r,n[13].name),o(e,s),o(e,c),o(c,u),o(c,b),o(c,d),o(d,p),o(d,_),o(d,m),o(d,h),o(d,g),o(d,B),o(d,v),o(d,x),Le(d,n[13].brewer_type,!0),S||(A=[z(r,"input",n[61]),z(d,"change",n[62])],S=!0)},p(N,P){P[0]&8192&&r.value!==N[13].name&&H(r,N[13].name),P[0]&8192&&Le(d,N[13].brewer_type)},d(N){N&&k(e),S=!1,ce(A)}}}function Wo(n){let e,t,l,i,r,s,c,u,b,d,p,_,m,h,g,B;document.title=e=(n[0]==="edit"?"Edit Brew":"New Brew")+" - Arabica";function v(E,T){return E[3]?Ho:jo}let x=v(n),S=x(n);function A(E){n[52](E)}let N={title:"Add New Bean",onSave:n[21],onCancel:n[51],$$slots:{default:[zo]},$$scope:{ctx:n}};n[6]!==void 0&&(N.isOpen=n[6]),r=new ht({props:N}),ct.push(()=>wt(r,"isOpen",A));function P(E){n[56](E)}let L={title:"Add New Roaster",onSave:n[22],onCancel:n[55],$$slots:{default:[Go]},$$scope:{ctx:n}};n[7]!==void 0&&(L.isOpen=n[7]),u=new ht({props:L}),ct.push(()=>wt(u,"isOpen",P));function O(E){n[60](E)}let D={title:"Add New Grinder",onSave:n[23],onCancel:n[59],$$slots:{default:[Io]},$$scope:{ctx:n}};n[8]!==void 0&&(D.isOpen=n[8]),p=new ht({props:D}),ct.push(()=>wt(p,"isOpen",O));function F(E){n[64](E)}let G={title:"Add New Brewer",onSave:n[24],onCancel:n[63],$$slots:{default:[Uo]},$$scope:{ctx:n}};return n[9]!==void 0&&(G.isOpen=n[9]),h=new ht({props:G}),ct.push(()=>wt(h,"isOpen",F)),{c(){t=w(),l=f("div"),S.c(),i=w(),it(r.$$.fragment),c=w(),it(u.$$.fragment),d=w(),it(p.$$.fragment),m=w(),it(h.$$.fragment),a(l,"class","max-w-2xl mx-auto")},m(E,T){y(E,t,T),y(E,l,T),S.m(l,null),y(E,i,T),nt(r,E,T),y(E,c,T),nt(u,E,T),y(E,d,T),nt(p,E,T),y(E,m,T),nt(h,E,T),B=!0},p(E,T){(!B||T[0]&1)&&e!==(e=(E[0]==="edit"?"Edit Brew":"New Brew")+" - Arabica")&&(document.title=e),x===(x=v(E))&&S?S.p(E,T):(S.d(1),S=x(E),S&&(S.c(),S.m(l,null)));const R={};T[0]&64&&(R.onCancel=E[51]),T[0]&66688|T[2]&524288&&(R.$$scope={dirty:T,ctx:E}),!s&&T[0]&64&&(s=!0,R.isOpen=E[6],mt(()=>s=!1)),r.$set(R);const V={};T[0]&128&&(V.onCancel=E[55]),T[0]&2048|T[2]&524288&&(V.$$scope={dirty:T,ctx:E}),!b&&T[0]&128&&(b=!0,V.isOpen=E[7],mt(()=>b=!1)),u.$set(V);const X={};T[0]&256&&(X.onCancel=E[59]),T[0]&4096|T[2]&524288&&(X.$$scope={dirty:T,ctx:E}),!_&&T[0]&256&&(_=!0,X.isOpen=E[8],mt(()=>_=!1)),p.$set(X);const J={};T[0]&512&&(J.onCancel=E[63]),T[0]&8192|T[2]&524288&&(J.$$scope={dirty:T,ctx:E}),!g&&T[0]&512&&(g=!0,J.isOpen=E[9],mt(()=>g=!1)),h.$set(J)},i(E){B||(ve(r.$$.fragment,E),ve(u.$$.fragment,E),ve(p.$$.fragment,E),ve(h.$$.fragment,E),B=!0)},o(E){Oe(r.$$.fragment,E),Oe(u.$$.fragment,E),Oe(p.$$.fragment,E),Oe(h.$$.fragment,E),B=!1},d(E){E&&(k(t),k(l),k(i),k(c),k(d),k(m)),S.d(),lt(r,E),lt(u,E),lt(p,E),lt(h,E)}}}function qo(n,e,t){let l,i,r,s,c,u,b;ut(n,Te,q=>t(26,u=q)),ut(n,pt,q=>t(27,b=q));let{id:d=null}=e,{mode:p="create"}=e,_={bean_rkey:"",coffee_amount:"",grinder_rkey:"",grind_size:"",brewer_rkey:"",water_amount:"",water_temp:"",brew_time:"",notes:"",rating:5},m=[],h=!0,g=!1,B=null,v=!1,x=!1,S=!1,A=!1,N={name:"",origin:"",roast_level:"",process:"",description:"",roaster_rkey:""},P={name:"",location:"",website:"",description:""},L={name:"",grinder_type:"",burr_type:"",notes:""},O={name:"",brewer_type:"",description:""};vt(async()=>{if(!c){_e("/login");return}if(await Te.load(),p==="edit"&&d){const Z=(u.brews||[]).find(K=>K.rkey===d);Z?(t(1,_={bean_rkey:Z.bean_rkey||"",coffee_amount:Z.coffee_amount||"",grinder_rkey:Z.grinder_rkey||"",grind_size:Z.grind_size||"",brewer_rkey:Z.brewer_rkey||"",water_amount:Z.water_amount||"",water_temp:Z.temperature||"",brew_time:Z.time_seconds||"",notes:Z.tasting_notes||"",rating:Z.rating||5}),t(2,m=Z.pours?JSON.parse(JSON.stringify(Z.pours)):[])):t(5,B="Brew not found")}t(3,h=!1)});function D(){t(2,m=[...m,{water_amount:0,time_seconds:0}])}function F(q){t(2,m=m.filter((Z,K)=>K!==q))}async function G(){if(!_.bean_rkey||_.bean_rkey===""){t(5,B="Please select a coffee bean");return}t(4,g=!0),t(5,B=null);try{const q={bean_rkey:_.bean_rkey,method:_.method||"",temperature:_.water_temp?parseFloat(_.water_temp):0,water_amount:_.water_amount?parseFloat(_.water_amount):0,coffee_amount:_.coffee_amount?parseFloat(_.coffee_amount):0,time_seconds:_.brew_time?parseFloat(_.brew_time):0,grind_size:_.grind_size||"",grinder_rkey:_.grinder_rkey||"",brewer_rkey:_.brewer_rkey||"",tasting_notes:_.notes||"",rating:_.rating?parseInt(_.rating):0,pours:m.filter(Z=>Z.water_amount&&Z.time_seconds)};p==="edit"?await ge.put(`/brews/${d}`,q):await ge.post("/brews",q),await Te.invalidate(),_e("/brews")}catch(q){t(5,B=q.message),t(4,g=!1)}}async function E(){try{const q=await ge.post("/api/beans",N);await Te.invalidate(),t(1,_.bean_rkey=q.rkey,_),t(6,v=!1),t(10,N={name:"",origin:"",roast_level:"",process:"",description:"",roaster_rkey:""})}catch(q){alert("Failed to create bean: "+q.message)}}async function T(){try{const q=await ge.post("/api/roasters",P);await Te.invalidate(),t(10,N.roaster_rkey=q.rkey,N),t(7,x=!1),t(11,P={name:"",location:"",website:"",description:""})}catch(q){alert("Failed to create roaster: "+q.message)}}async function R(){try{const q=await ge.post("/api/grinders",L);await Te.invalidate(),t(1,_.grinder_rkey=q.rkey,_),t(8,S=!1),t(12,L={name:"",grinder_type:"",burr_type:"",notes:""})}catch(q){alert("Failed to create grinder: "+q.message)}}async function V(){try{const q=await ge.post("/api/brewers",O);await Te.invalidate(),t(1,_.brewer_rkey=q.rkey,_),t(9,A=!1),t(13,O={name:"",brewer_type:"",description:""})}catch(q){alert("Failed to create brewer: "+q.message)}}const X=()=>vn();function J(){_.bean_rkey=at(this),t(1,_),t(17,l),t(26,u)}const I=()=>t(6,v=!0);function Y(){_.coffee_amount=ot(this.value),t(1,_),t(17,l),t(26,u)}function ue(){_.grinder_rkey=at(this),t(1,_),t(17,l),t(26,u)}const $=()=>t(8,S=!0);function Ae(){_.grind_size=this.value,t(1,_),t(17,l),t(26,u)}function ke(){_.brewer_rkey=at(this),t(1,_),t(17,l),t(26,u)}const De=()=>t(9,A=!0);function ye(){_.water_amount=ot(this.value),t(1,_),t(17,l),t(26,u)}function Me(){_.water_temp=ot(this.value),t(1,_),t(17,l),t(26,u)}function we(){_.brew_time=ot(this.value),t(1,_),t(17,l),t(26,u)}function Fe(q,Z){q[Z].water_amount=ot(this.value),t(2,m)}function Ee(q,Z){q[Z].time_seconds=ot(this.value),t(2,m)}const ae=q=>F(q);function te(){_.rating=ot(this.value),t(1,_),t(17,l),t(26,u)}function ie(){_.notes=this.value,t(1,_),t(17,l),t(26,u)}const re=()=>vn();function oe(){N.name=this.value,t(10,N)}function he(){N.origin=this.value,t(10,N)}function fe(){N.roast_level=at(this),t(10,N)}function pe(){N.roaster_rkey=at(this),t(10,N)}const Ie=()=>t(7,x=!0),se=()=>t(6,v=!1);function ee(q){v=q,t(6,v)}function qe(){P.name=this.value,t(11,P)}function Pe(){P.location=this.value,t(11,P)}const Re=()=>t(7,x=!1);function Se(q){x=q,t(7,x)}function Ze(){L.name=this.value,t(12,L)}function xe(){L.grinder_type=at(this),t(12,L)}const Ye=()=>t(8,S=!1);function $e(q){S=q,t(8,S)}function de(){O.name=this.value,t(13,O)}function je(){O.brewer_type=at(this),t(13,O)}const be=()=>t(9,A=!1);function Ce(q){A=q,t(9,A)}return n.$$set=q=>{"id"in q&&t(25,d=q.id),"mode"in q&&t(0,p=q.mode)},n.$$.update=()=>{n.$$.dirty[0]&67108864&&t(17,l=u.beans||[]),n.$$.dirty[0]&67108864&&t(16,i=u.roasters||[]),n.$$.dirty[0]&67108864&&t(15,r=u.grinders||[]),n.$$.dirty[0]&67108864&&t(14,s=u.brewers||[]),n.$$.dirty[0]&134217728&&(c=b.isAuthenticated)},[p,_,m,h,g,B,v,x,S,A,N,P,L,O,s,r,i,l,D,F,G,E,T,R,V,d,u,b,X,J,I,Y,ue,$,Ae,ke,De,ye,Me,we,Fe,Ee,ae,te,ie,re,oe,he,fe,pe,Ie,se,ee,qe,Pe,Re,Se,Ze,xe,Ye,$e,de,je,be,Ce]}class vl extends Xe{constructor(e){super(),Qe(this,e,qo,Wo,We,{id:25,mode:0},null,[-1,-1,-1])}}function kl(n,e,t){const l=n.slice();return l[74]=e[t],l}function yl(n,e,t){const l=n.slice();return l[85]=e[t],l}function xl(n,e,t){const l=n.slice();return l[82]=e[t],l}function Cl(n,e,t){const l=n.slice();return l[74]=e[t],l}function Bl(n,e,t){const l=n.slice();return l[77]=e[t],l}function Yo(n){let e,t,l,i,r,s,c,u,b,d,p,_,m,h,g,B,v,x,S,A,N;function P(D,F){if(D[0]==="beans")return Xo;if(D[0]==="roasters")return Qo;if(D[0]==="grinders")return Jo;if(D[0]==="brewers")return Ko}let L=P(n),O=L&&L(n);return{c(){e=f("div"),t=f("div"),l=f("button"),i=C("☕ Beans"),s=w(),c=f("button"),u=C("🏭 Roasters"),d=w(),p=f("button"),_=C("⚙️ Grinders"),h=w(),g=f("button"),B=C("🫖 Brewers"),x=w(),S=f("div"),O&&O.c(),a(l,"class",r="flex-1 px-6 py-4 text-center font-medium transition-colors "+(n[0]==="beans"?"bg-brown-50 text-brown-900 border-b-2 border-brown-700":"text-brown-700 hover:bg-brown-50")),a(c,"class",b="flex-1 px-6 py-4 text-center font-medium transition-colors "+(n[0]==="roasters"?"bg-brown-50 text-brown-900 border-b-2 border-brown-700":"text-brown-700 hover:bg-brown-50")),a(p,"class",m="flex-1 px-6 py-4 text-center font-medium transition-colors "+(n[0]==="grinders"?"bg-brown-50 text-brown-900 border-b-2 border-brown-700":"text-brown-700 hover:bg-brown-50")),a(g,"class",v="flex-1 px-6 py-4 text-center font-medium transition-colors "+(n[0]==="brewers"?"bg-brown-50 text-brown-900 border-b-2 border-brown-700":"text-brown-700 hover:bg-brown-50")),a(t,"class","flex border-b border-brown-300"),a(S,"class","p-6"),a(e,"class","bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300 mb-6")},m(D,F){y(D,e,F),o(e,t),o(t,l),o(l,i),o(t,s),o(t,c),o(c,u),o(t,d),o(t,p),o(p,_),o(t,h),o(t,g),o(g,B),o(e,x),o(e,S),O&&O.m(S,null),A||(N=[z(l,"click",n[37]),z(c,"click",n[38]),z(p,"click",n[39]),z(g,"click",n[40])],A=!0)},p(D,F){F[0]&1&&r!==(r="flex-1 px-6 py-4 text-center font-medium transition-colors "+(D[0]==="beans"?"bg-brown-50 text-brown-900 border-b-2 border-brown-700":"text-brown-700 hover:bg-brown-50"))&&a(l,"class",r),F[0]&1&&b!==(b="flex-1 px-6 py-4 text-center font-medium transition-colors "+(D[0]==="roasters"?"bg-brown-50 text-brown-900 border-b-2 border-brown-700":"text-brown-700 hover:bg-brown-50"))&&a(c,"class",b),F[0]&1&&m!==(m="flex-1 px-6 py-4 text-center font-medium transition-colors "+(D[0]==="grinders"?"bg-brown-50 text-brown-900 border-b-2 border-brown-700":"text-brown-700 hover:bg-brown-50"))&&a(p,"class",m),F[0]&1&&v!==(v="flex-1 px-6 py-4 text-center font-medium transition-colors "+(D[0]==="brewers"?"bg-brown-50 text-brown-900 border-b-2 border-brown-700":"text-brown-700 hover:bg-brown-50"))&&a(g,"class",v),L===(L=P(D))&&O?O.p(D,F):(O&&O.d(1),O=L&&L(D),O&&(O.c(),O.m(S,null)))},d(D){D&&k(e),O&&O.d(),A=!1,ce(N)}}}function Vo(n){let e;return{c(){e=f("div"),e.innerHTML='<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-brown-800 mx-auto"></div> <p class="mt-4 text-brown-700">Loading...</p>',a(e,"class","text-center py-12")},m(t,l){y(t,e,l)},p:W,d(t){t&&k(e)}}}function Ko(n){let e,t,l,i,r,s,c,u;function b(_,m){return _[14].length===0?$o:Zo}let d=b(n),p=d(n);return{c(){e=f("div"),t=f("h2"),t.textContent="Brewers",l=w(),i=f("button"),i.textContent="+ Add Brewer",r=w(),p.c(),s=ft(),a(t,"class","text-xl font-bold text-brown-900"),a(i,"class","bg-brown-700 text-white px-4 py-2 rounded-lg hover:bg-brown-800 font-medium"),a(e,"class","flex justify-between items-center mb-4")},m(_,m){y(_,e,m),o(e,t),o(e,l),o(e,i),y(_,r,m),p.m(_,m),y(_,s,m),c||(u=z(i,"click",n[31]),c=!0)},p(_,m){d===(d=b(_))&&p?p.p(_,m):(p.d(1),p=d(_),p&&(p.c(),p.m(s.parentNode,s)))},d(_){_&&(k(e),k(r),k(s)),p.d(_),c=!1,u()}}}function Jo(n){let e,t,l,i,r,s,c,u;function b(_,m){return _[15].length===0?ti:ei}let d=b(n),p=d(n);return{c(){e=f("div"),t=f("h2"),t.textContent="Grinders",l=w(),i=f("button"),i.textContent="+ Add Grinder",r=w(),p.c(),s=ft(),a(t,"class","text-xl font-bold text-brown-900"),a(i,"class","bg-brown-700 text-white px-4 py-2 rounded-lg hover:bg-brown-800 font-medium"),a(e,"class","flex justify-between items-center mb-4")},m(_,m){y(_,e,m),o(e,t),o(e,l),o(e,i),y(_,r,m),p.m(_,m),y(_,s,m),c||(u=z(i,"click",n[27]),c=!0)},p(_,m){d===(d=b(_))&&p?p.p(_,m):(p.d(1),p=d(_),p&&(p.c(),p.m(s.parentNode,s)))},d(_){_&&(k(e),k(r),k(s)),p.d(_),c=!1,u()}}}function Qo(n){let e,t,l,i,r,s,c,u;function b(_,m){return _[16].length===0?li:ni}let d=b(n),p=d(n);return{c(){e=f("div"),t=f("h2"),t.textContent="Roasters",l=w(),i=f("button"),i.textContent="+ Add Roaster",r=w(),p.c(),s=ft(),a(t,"class","text-xl font-bold text-brown-900"),a(i,"class","bg-brown-700 text-white px-4 py-2 rounded-lg hover:bg-brown-800 font-medium"),a(e,"class","flex justify-between items-center mb-4")},m(_,m){y(_,e,m),o(e,t),o(e,l),o(e,i),y(_,r,m),p.m(_,m),y(_,s,m),c||(u=z(i,"click",n[23]),c=!0)},p(_,m){d===(d=b(_))&&p?p.p(_,m):(p.d(1),p=d(_),p&&(p.c(),p.m(s.parentNode,s)))},d(_){_&&(k(e),k(r),k(s)),p.d(_),c=!1,u()}}}function Xo(n){let e,t,l,i,r,s,c,u;function b(_,m){return _[17].length===0?oi:ri}let d=b(n),p=d(n);return{c(){e=f("div"),t=f("h2"),t.textContent="Coffee Beans",l=w(),i=f("button"),i.textContent="+ Add Bean",r=w(),p.c(),s=ft(),a(t,"class","text-xl font-bold text-brown-900"),a(i,"class","bg-brown-700 text-white px-4 py-2 rounded-lg hover:bg-brown-800 font-medium"),a(e,"class","flex justify-between items-center mb-4")},m(_,m){y(_,e,m),o(e,t),o(e,l),o(e,i),y(_,r,m),p.m(_,m),y(_,s,m),c||(u=z(i,"click",n[19]),c=!0)},p(_,m){d===(d=b(_))&&p?p.p(_,m):(p.d(1),p=d(_),p&&(p.c(),p.m(s.parentNode,s)))},d(_){_&&(k(e),k(r),k(s)),p.d(_),c=!1,u()}}}function Zo(n){let e,t,l,i,r,s=le(n[14]),c=[];for(let u=0;u<s.length;u+=1)c[u]=Al(yl(n,s,u));return{c(){e=f("div"),t=f("table"),l=f("thead"),l.innerHTML='<tr><th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Name</th> <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">🔧 Type</th> <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Actions</th></tr>',i=w(),r=f("tbody");for(let u=0;u<c.length;u+=1)c[u].c();a(l,"class","bg-brown-50"),a(r,"class","bg-white divide-y divide-brown-200"),a(t,"class","min-w-full divide-y divide-brown-300"),a(e,"class","overflow-x-auto")},m(u,b){y(u,e,b),o(e,t),o(t,l),o(t,i),o(t,r);for(let d=0;d<c.length;d+=1)c[d]&&c[d].m(r,null)},p(u,b){if(b[0]&16384|b[1]&10){s=le(u[14]);let d;for(d=0;d<s.length;d+=1){const p=yl(u,s,d);c[d]?c[d].p(p,b):(c[d]=Al(p),c[d].c(),c[d].m(r,null))}for(;d<c.length;d+=1)c[d].d(1);c.length=s.length}},d(u){u&&k(e),Ge(c,u)}}}function $o(n){let e;return{c(){e=f("p"),e.textContent="No brewers yet. Add your first brewer!",a(e,"class","text-brown-600 text-center py-8")},m(t,l){y(t,e,l)},p:W,d(t){t&&k(e)}}}function Al(n){let e,t,l=n[85].name+"",i,r,s,c=(n[85].brewer_type||"-")+"",u,b,d,p,_,m,h,g,B;function v(){return n[47](n[85])}function x(){return n[48](n[85])}return{c(){e=f("tr"),t=f("td"),i=C(l),r=w(),s=f("td"),u=C(c),b=w(),d=f("td"),p=f("button"),p.textContent="Edit",_=w(),m=f("button"),m.textContent="Delete",h=w(),a(t,"class","px-4 py-3 text-sm text-brown-900"),a(s,"class","px-4 py-3 text-sm text-brown-900"),a(p,"class","text-brown-700 hover:text-brown-900 font-medium"),a(m,"class","text-red-600 hover:text-red-800 font-medium"),a(d,"class","px-4 py-3 text-sm space-x-2"),a(e,"class","hover:bg-brown-50")},m(S,A){y(S,e,A),o(e,t),o(t,i),o(e,r),o(e,s),o(s,u),o(e,b),o(e,d),o(d,p),o(d,_),o(d,m),o(e,h),g||(B=[z(p,"click",v),z(m,"click",x)],g=!0)},p(S,A){n=S,A[0]&16384&&l!==(l=n[85].name+"")&&j(i,l),A[0]&16384&&c!==(c=(n[85].brewer_type||"-")+"")&&j(u,c)},d(S){S&&k(e),g=!1,ce(B)}}}function ei(n){let e,t,l,i,r,s=le(n[15]),c=[];for(let u=0;u<s.length;u+=1)c[u]=Sl(xl(n,s,u));return{c(){e=f("div"),t=f("table"),l=f("thead"),l.innerHTML='<tr><th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Name</th> <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">🔧 Type</th> <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">💎 Burr Type</th> <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Actions</th></tr>',i=w(),r=f("tbody");for(let u=0;u<c.length;u+=1)c[u].c();a(l,"class","bg-brown-50"),a(r,"class","bg-white divide-y divide-brown-200"),a(t,"class","min-w-full divide-y divide-brown-300"),a(e,"class","overflow-x-auto")},m(u,b){y(u,e,b),o(e,t),o(t,l),o(t,i),o(t,r);for(let d=0;d<c.length;d+=1)c[d]&&c[d].m(r,null)},p(u,b){if(b[0]&1342210048){s=le(u[15]);let d;for(d=0;d<s.length;d+=1){const p=xl(u,s,d);c[d]?c[d].p(p,b):(c[d]=Sl(p),c[d].c(),c[d].m(r,null))}for(;d<c.length;d+=1)c[d].d(1);c.length=s.length}},d(u){u&&k(e),Ge(c,u)}}}function ti(n){let e;return{c(){e=f("p"),e.textContent="No grinders yet. Add your first grinder!",a(e,"class","text-brown-600 text-center py-8")},m(t,l){y(t,e,l)},p:W,d(t){t&&k(e)}}}function Sl(n){let e,t,l=n[82].name+"",i,r,s,c=(n[82].grinder_type||"-")+"",u,b,d,p=(n[82].burr_type||"-")+"",_,m,h,g,B,v,x,S,A;function N(){return n[45](n[82])}function P(){return n[46](n[82])}return{c(){e=f("tr"),t=f("td"),i=C(l),r=w(),s=f("td"),u=C(c),b=w(),d=f("td"),_=C(p),m=w(),h=f("td"),g=f("button"),g.textContent="Edit",B=w(),v=f("button"),v.textContent="Delete",x=w(),a(t,"class","px-4 py-3 text-sm text-brown-900"),a(s,"class","px-4 py-3 text-sm text-brown-900"),a(d,"class","px-4 py-3 text-sm text-brown-900"),a(g,"class","text-brown-700 hover:text-brown-900 font-medium"),a(v,"class","text-red-600 hover:text-red-800 font-medium"),a(h,"class","px-4 py-3 text-sm space-x-2"),a(e,"class","hover:bg-brown-50")},m(L,O){y(L,e,O),o(e,t),o(t,i),o(e,r),o(e,s),o(s,u),o(e,b),o(e,d),o(d,_),o(e,m),o(e,h),o(h,g),o(h,B),o(h,v),o(e,x),S||(A=[z(g,"click",N),z(v,"click",P)],S=!0)},p(L,O){n=L,O[0]&32768&&l!==(l=n[82].name+"")&&j(i,l),O[0]&32768&&c!==(c=(n[82].grinder_type||"-")+"")&&j(u,c),O[0]&32768&&p!==(p=(n[82].burr_type||"-")+"")&&j(_,p)},d(L){L&&k(e),S=!1,ce(A)}}}function ni(n){let e,t,l,i,r,s=le(n[16]),c=[];for(let u=0;u<s.length;u+=1)c[u]=Nl(Cl(n,s,u));return{c(){e=f("div"),t=f("table"),l=f("thead"),l.innerHTML='<tr><th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Name</th> <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">📍 Location</th> <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Actions</th></tr>',i=w(),r=f("tbody");for(let u=0;u<c.length;u+=1)c[u].c();a(l,"class","bg-brown-50"),a(r,"class","bg-white divide-y divide-brown-200"),a(t,"class","min-w-full divide-y divide-brown-300"),a(e,"class","overflow-x-auto")},m(u,b){y(u,e,b),o(e,t),o(t,l),o(t,i),o(t,r);for(let d=0;d<c.length;d+=1)c[d]&&c[d].m(r,null)},p(u,b){if(b[0]&83951616){s=le(u[16]);let d;for(d=0;d<s.length;d+=1){const p=Cl(u,s,d);c[d]?c[d].p(p,b):(c[d]=Nl(p),c[d].c(),c[d].m(r,null))}for(;d<c.length;d+=1)c[d].d(1);c.length=s.length}},d(u){u&&k(e),Ge(c,u)}}}function li(n){let e;return{c(){e=f("p"),e.textContent="No roasters yet. Add your first roaster!",a(e,"class","text-brown-600 text-center py-8")},m(t,l){y(t,e,l)},p:W,d(t){t&&k(e)}}}function Nl(n){let e,t,l=n[74].name+"",i,r,s,c=(n[74].location||"-")+"",u,b,d,p,_,m,h,g,B;function v(){return n[43](n[74])}function x(){return n[44](n[74])}return{c(){e=f("tr"),t=f("td"),i=C(l),r=w(),s=f("td"),u=C(c),b=w(),d=f("td"),p=f("button"),p.textContent="Edit",_=w(),m=f("button"),m.textContent="Delete",h=w(),a(t,"class","px-4 py-3 text-sm text-brown-900"),a(s,"class","px-4 py-3 text-sm text-brown-900"),a(p,"class","text-brown-700 hover:text-brown-900 font-medium"),a(m,"class","text-red-600 hover:text-red-800 font-medium"),a(d,"class","px-4 py-3 text-sm space-x-2"),a(e,"class","hover:bg-brown-50")},m(S,A){y(S,e,A),o(e,t),o(t,i),o(e,r),o(e,s),o(s,u),o(e,b),o(e,d),o(d,p),o(d,_),o(d,m),o(e,h),g||(B=[z(p,"click",v),z(m,"click",x)],g=!0)},p(S,A){n=S,A[0]&65536&&l!==(l=n[74].name+"")&&j(i,l),A[0]&65536&&c!==(c=(n[74].location||"-")+"")&&j(u,c)},d(S){S&&k(e),g=!1,ce(B)}}}function ri(n){let e,t,l,i,r,s=le(n[17]),c=[];for(let u=0;u<s.length;u+=1)c[u]=Tl(Bl(n,s,u));return{c(){e=f("div"),t=f("table"),l=f("thead"),l.innerHTML='<tr><th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Name</th> <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">📍 Origin</th> <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">🔥 Roast</th> <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">🏭 Roaster</th> <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Actions</th></tr>',i=w(),r=f("tbody");for(let u=0;u<c.length;u+=1)c[u].c();a(l,"class","bg-brown-50"),a(r,"class","bg-white divide-y divide-brown-200"),a(t,"class","min-w-full divide-y divide-brown-300"),a(e,"class","overflow-x-auto")},m(u,b){y(u,e,b),o(e,t),o(t,l),o(t,i),o(t,r);for(let d=0;d<c.length;d+=1)c[d]&&c[d].m(r,null)},p(u,b){if(b[0]&5373952){s=le(u[17]);let d;for(d=0;d<s.length;d+=1){const p=Bl(u,s,d);c[d]?c[d].p(p,b):(c[d]=Tl(p),c[d].c(),c[d].m(r,null))}for(;d<c.length;d+=1)c[d].d(1);c.length=s.length}},d(u){u&&k(e),Ge(c,u)}}}function oi(n){let e;return{c(){e=f("p"),e.textContent="No beans yet. Add your first bean!",a(e,"class","text-brown-600 text-center py-8")},m(t,l){y(t,e,l)},p:W,d(t){t&&k(e)}}}function Tl(n){var G;let e,t,l=(n[77].name||"-")+"",i,r,s,c=n[77].origin+"",u,b,d,p=n[77].roast_level+"",_,m,h,g=(((G=n[77].roaster)==null?void 0:G.name)||"-")+"",B,v,x,S,A,N,P,L,O;function D(){return n[41](n[77])}function F(){return n[42](n[77])}return{c(){e=f("tr"),t=f("td"),i=C(l),r=w(),s=f("td"),u=C(c),b=w(),d=f("td"),_=C(p),m=w(),h=f("td"),B=C(g),v=w(),x=f("td"),S=f("button"),S.textContent="Edit",A=w(),N=f("button"),N.textContent="Delete",P=w(),a(t,"class","px-4 py-3 text-sm text-brown-900"),a(s,"class","px-4 py-3 text-sm text-brown-900"),a(d,"class","px-4 py-3 text-sm text-brown-900"),a(h,"class","px-4 py-3 text-sm text-brown-900"),a(S,"class","text-brown-700 hover:text-brown-900 font-medium"),a(N,"class","text-red-600 hover:text-red-800 font-medium"),a(x,"class","px-4 py-3 text-sm space-x-2"),a(e,"class","hover:bg-brown-50")},m(E,T){y(E,e,T),o(e,t),o(t,i),o(e,r),o(e,s),o(s,u),o(e,b),o(e,d),o(d,_),o(e,m),o(e,h),o(h,B),o(e,v),o(e,x),o(x,S),o(x,A),o(x,N),o(e,P),L||(O=[z(S,"click",D),z(N,"click",F)],L=!0)},p(E,T){var R;n=E,T[0]&131072&&l!==(l=(n[77].name||"-")+"")&&j(i,l),T[0]&131072&&c!==(c=n[77].origin+"")&&j(u,c),T[0]&131072&&p!==(p=n[77].roast_level+"")&&j(_,p),T[0]&131072&&g!==(g=(((R=n[77].roaster)==null?void 0:R.name)||"-")+"")&&j(B,g)},d(E){E&&k(e),L=!1,ce(O)}}}function Ll(n){let e,t=n[74].name+"",l,i;return{c(){e=f("option"),l=C(t),e.__value=i=n[74].rkey,H(e,e.__value)},m(r,s){y(r,e,s),o(e,l)},p(r,s){s[0]&65536&&t!==(t=r[74].name+"")&&j(l,t),s[0]&65536&&i!==(i=r[74].rkey)&&(e.__value=i,H(e,e.__value))},d(r){r&&k(e)}}}function ii(n){let e,t,l,i,r,s,c,u,b,d,p,_,m,h,g,B,v,x,S,A,N,P=le(n[16]),L=[];for(let O=0;O<P.length;O+=1)L[O]=Ll(kl(n,P,O));return{c(){e=f("input"),t=w(),l=f("input"),i=w(),r=f("select"),s=f("option"),s.textContent="Select Roaster (Optional)";for(let O=0;O<L.length;O+=1)L[O].c();c=w(),u=f("select"),b=f("option"),b.textContent="Select Roast Level (Optional)",d=f("option"),d.textContent="Ultra-Light",p=f("option"),p.textContent="Light",_=f("option"),_.textContent="Medium-Light",m=f("option"),m.textContent="Medium",h=f("option"),h.textContent="Medium-Dark",g=f("option"),g.textContent="Dark",B=w(),v=f("input"),x=w(),S=f("textarea"),a(e,"type","text"),a(e,"placeholder","Name *"),a(e,"class","w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"),a(l,"type","text"),a(l,"placeholder","Origin *"),a(l,"class","w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"),s.__value="",H(s,s.__value),a(r,"class","w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"),n[10].roaster_rkey===void 0&&rt(()=>n[51].call(r)),b.__value="",H(b,b.__value),d.__value="Ultra-Light",H(d,d.__value),p.__value="Light",H(p,p.__value),_.__value="Medium-Light",H(_,_.__value),m.__value="Medium",H(m,m.__value),h.__value="Medium-Dark",H(h,h.__value),g.__value="Dark",H(g,g.__value),a(u,"class","w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"),n[10].roast_level===void 0&&rt(()=>n[52].call(u)),a(v,"type","text"),a(v,"placeholder","Process (e.g. Washed, Natural, Honey)"),a(v,"class","w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"),a(S,"placeholder","Description"),a(S,"rows","3"),a(S,"class","w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600")},m(O,D){y(O,e,D),H(e,n[10].name),y(O,t,D),y(O,l,D),H(l,n[10].origin),y(O,i,D),y(O,r,D),o(r,s);for(let F=0;F<L.length;F+=1)L[F]&&L[F].m(r,null);Le(r,n[10].roaster_rkey,!0),y(O,c,D),y(O,u,D),o(u,b),o(u,d),o(u,p),o(u,_),o(u,m),o(u,h),o(u,g),Le(u,n[10].roast_level,!0),y(O,B,D),y(O,v,D),H(v,n[10].process),y(O,x,D),y(O,S,D),H(S,n[10].description),A||(N=[z(e,"input",n[49]),z(l,"input",n[50]),z(r,"change",n[51]),z(u,"change",n[52]),z(v,"input",n[53]),z(S,"input",n[54])],A=!0)},p(O,D){if(D[0]&66560&&e.value!==O[10].name&&H(e,O[10].name),D[0]&66560&&l.value!==O[10].origin&&H(l,O[10].origin),D[0]&65536){P=le(O[16]);let F;for(F=0;F<P.length;F+=1){const G=kl(O,P,F);L[F]?L[F].p(G,D):(L[F]=Ll(G),L[F].c(),L[F].m(r,null))}for(;F<L.length;F+=1)L[F].d(1);L.length=P.length}D[0]&66560&&Le(r,O[10].roaster_rkey),D[0]&66560&&Le(u,O[10].roast_level),D[0]&66560&&v.value!==O[10].process&&H(v,O[10].process),D[0]&66560&&H(S,O[10].description)},d(O){O&&(k(e),k(t),k(l),k(i),k(r),k(c),k(u),k(B),k(v),k(x),k(S)),Ge(L,O),A=!1,ce(N)}}}function si(n){let e,t,l,i,r,s,c;return{c(){e=f("input"),t=w(),l=f("input"),i=w(),r=f("input"),a(e,"type","text"),a(e,"placeholder","Name *"),a(e,"class","w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"),a(l,"type","text"),a(l,"placeholder","Location"),a(l,"class","w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"),a(r,"type","url"),a(r,"placeholder","Website"),a(r,"class","w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600")},m(u,b){y(u,e,b),H(e,n[11].name),y(u,t,b),y(u,l,b),H(l,n[11].location),y(u,i,b),y(u,r,b),H(r,n[11].website),s||(c=[z(e,"input",n[57]),z(l,"input",n[58]),z(r,"input",n[59])],s=!0)},p(u,b){b[0]&2048&&e.value!==u[11].name&&H(e,u[11].name),b[0]&2048&&l.value!==u[11].location&&H(l,u[11].location),b[0]&2048&&r.value!==u[11].website&&H(r,u[11].website)},d(u){u&&(k(e),k(t),k(l),k(i),k(r)),s=!1,ce(c)}}}function ai(n){let e,t,l,i,r,s,c,u,b,d,p,_,m,h,g,B;return{c(){e=f("input"),t=w(),l=f("select"),i=f("option"),i.textContent="Select Grinder Type *",r=f("option"),r.textContent="Hand",s=f("option"),s.textContent="Electric",c=f("option"),c.textContent="Portable Electric",u=w(),b=f("select"),d=f("option"),d.textContent="Select Burr Type (Optional)",p=f("option"),p.textContent="Conical",_=f("option"),_.textContent="Flat",m=w(),h=f("textarea"),a(e,"type","text"),a(e,"placeholder","Name *"),a(e,"class","w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"),i.__value="",H(i,i.__value),r.__value="Hand",H(r,r.__value),s.__value="Electric",H(s,s.__value),c.__value="Portable Electric",H(c,c.__value),a(l,"class","w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"),n[12].grinder_type===void 0&&rt(()=>n[63].call(l)),d.__value="",H(d,d.__value),p.__value="Conical",H(p,p.__value),_.__value="Flat",H(_,_.__value),a(b,"class","w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"),n[12].burr_type===void 0&&rt(()=>n[64].call(b)),a(h,"placeholder","Notes"),a(h,"rows","3"),a(h,"class","w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600")},m(v,x){y(v,e,x),H(e,n[12].name),y(v,t,x),y(v,l,x),o(l,i),o(l,r),o(l,s),o(l,c),Le(l,n[12].grinder_type,!0),y(v,u,x),y(v,b,x),o(b,d),o(b,p),o(b,_),Le(b,n[12].burr_type,!0),y(v,m,x),y(v,h,x),H(h,n[12].notes),g||(B=[z(e,"input",n[62]),z(l,"change",n[63]),z(b,"change",n[64]),z(h,"input",n[65])],g=!0)},p(v,x){x[0]&4096&&e.value!==v[12].name&&H(e,v[12].name),x[0]&4096&&Le(l,v[12].grinder_type),x[0]&4096&&Le(b,v[12].burr_type),x[0]&4096&&H(h,v[12].notes)},d(v){v&&(k(e),k(t),k(l),k(u),k(b),k(m),k(h)),g=!1,ce(B)}}}function ui(n){let e,t,l,i,r,s,c;return{c(){e=f("input"),t=w(),l=f("input"),i=w(),r=f("textarea"),a(e,"type","text"),a(e,"placeholder","Name *"),a(e,"class","w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"),a(l,"type","text"),a(l,"placeholder","Type (e.g., Pour-Over, Immersion, Espresso)"),a(l,"class","w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"),a(r,"placeholder","Description"),a(r,"rows","3"),a(r,"class","w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600")},m(u,b){y(u,e,b),H(e,n[13].name),y(u,t,b),y(u,l,b),H(l,n[13].brewer_type),y(u,i,b),y(u,r,b),H(r,n[13].description),s||(c=[z(e,"input",n[68]),z(l,"input",n[69]),z(r,"input",n[70])],s=!0)},p(u,b){b[0]&8192&&e.value!==u[13].name&&H(e,u[13].name),b[0]&8192&&l.value!==u[13].brewer_type&&H(l,u[13].brewer_type),b[0]&8192&&H(r,u[13].description)},d(u){u&&(k(e),k(t),k(l),k(i),k(r)),s=!1,ce(c)}}}function ci(n){let e,t,l,i,r,s,c,u,b,d,p,_,m,h,g,B,v;function x(T,R){return T[1]?Vo:Yo}let S=x(n),A=S(n);function N(T){n[56](T)}let P={title:n[6]?"Edit Bean":"Add Bean",onSave:n[21],onCancel:n[55],$$slots:{default:[ii]},$$scope:{ctx:n}};n[2]!==void 0&&(P.isOpen=n[2]),s=new ht({props:P}),ct.push(()=>wt(s,"isOpen",N));function L(T){n[61](T)}let O={title:n[7]?"Edit Roaster":"Add Roaster",onSave:n[25],onCancel:n[60],$$slots:{default:[si]},$$scope:{ctx:n}};n[3]!==void 0&&(O.isOpen=n[3]),b=new ht({props:O}),ct.push(()=>wt(b,"isOpen",L));function D(T){n[67](T)}let F={title:n[8]?"Edit Grinder":"Add Grinder",onSave:n[29],onCancel:n[66],$$slots:{default:[ai]},$$scope:{ctx:n}};n[4]!==void 0&&(F.isOpen=n[4]),_=new ht({props:F}),ct.push(()=>wt(_,"isOpen",D));function G(T){n[72](T)}let E={title:n[9]?"Edit Brewer":"Add Brewer",onSave:n[33],onCancel:n[71],$$slots:{default:[ui]},$$scope:{ctx:n}};return n[5]!==void 0&&(E.isOpen=n[5]),g=new ht({props:E}),ct.push(()=>wt(g,"isOpen",G)),{c(){e=w(),t=f("div"),l=f("h1"),l.textContent="Manage Equipment & Beans",i=w(),A.c(),r=w(),it(s.$$.fragment),u=w(),it(b.$$.fragment),p=w(),it(_.$$.fragment),h=w(),it(g.$$.fragment),document.title="Manage - Arabica",a(l,"class","text-3xl font-bold text-brown-900 mb-6"),a(t,"class","max-w-6xl mx-auto")},m(T,R){y(T,e,R),y(T,t,R),o(t,l),o(t,i),A.m(t,null),y(T,r,R),nt(s,T,R),y(T,u,R),nt(b,T,R),y(T,p,R),nt(_,T,R),y(T,h,R),nt(g,T,R),v=!0},p(T,R){S===(S=x(T))&&A?A.p(T,R):(A.d(1),A=S(T),A&&(A.c(),A.m(t,null)));const V={};R[0]&64&&(V.title=T[6]?"Edit Bean":"Add Bean"),R[0]&4&&(V.onCancel=T[55]),R[0]&66560|R[2]&67108864&&(V.$$scope={dirty:R,ctx:T}),!c&&R[0]&4&&(c=!0,V.isOpen=T[2],mt(()=>c=!1)),s.$set(V);const X={};R[0]&128&&(X.title=T[7]?"Edit Roaster":"Add Roaster"),R[0]&8&&(X.onCancel=T[60]),R[0]&2048|R[2]&67108864&&(X.$$scope={dirty:R,ctx:T}),!d&&R[0]&8&&(d=!0,X.isOpen=T[3],mt(()=>d=!1)),b.$set(X);const J={};R[0]&256&&(J.title=T[8]?"Edit Grinder":"Add Grinder"),R[0]&16&&(J.onCancel=T[66]),R[0]&4096|R[2]&67108864&&(J.$$scope={dirty:R,ctx:T}),!m&&R[0]&16&&(m=!0,J.isOpen=T[4],mt(()=>m=!1)),_.$set(J);const I={};R[0]&512&&(I.title=T[9]?"Edit Brewer":"Add Brewer"),R[0]&32&&(I.onCancel=T[71]),R[0]&8192|R[2]&67108864&&(I.$$scope={dirty:R,ctx:T}),!B&&R[0]&32&&(B=!0,I.isOpen=T[5],mt(()=>B=!1)),g.$set(I)},i(T){v||(ve(s.$$.fragment,T),ve(b.$$.fragment,T),ve(_.$$.fragment,T),ve(g.$$.fragment,T),v=!0)},o(T){Oe(s.$$.fragment,T),Oe(b.$$.fragment,T),Oe(_.$$.fragment,T),Oe(g.$$.fragment,T),v=!1},d(T){T&&(k(e),k(t),k(r),k(u),k(p),k(h)),A.d(),lt(s,T),lt(b,T),lt(_,T),lt(g,T)}}}function fi(n,e,t){let l,i,r,s,c,u,b;ut(n,pt,M=>t(35,u=M)),ut(n,Te,M=>t(36,b=M));let d="beans",p=!0,_=!1,m=!1,h=!1,g=!1,B=null,v=null,x=null,S=null,A={name:"",origin:"",roast_level:"",process:"",description:"",roaster_rkey:""},N={name:"",location:"",website:"",description:""},P={name:"",grinder_type:"",burr_type:"",notes:""},L={name:"",brewer_type:"",description:""};vt(async()=>{if(!c){_e("/login");return}const M=localStorage.getItem("arabica_manage_tab");M&&t(0,d=M),await Te.load(),t(1,p=!1)});function O(M){t(0,d=M),localStorage.setItem("arabica_manage_tab",M)}function D(){t(6,B=null),t(10,A={name:"",origin:"",roast_level:"",process:"",description:"",roaster_rkey:""}),t(2,_=!0)}function F(M){t(6,B=M),t(10,A={name:M.name||"",origin:M.origin||"",roast_level:M.roast_level||"",process:M.process||"",description:M.description||"",roaster_rkey:M.roaster_rkey||""}),t(2,_=!0)}async function G(){try{console.log("Saving bean with data:",A),B?(console.log("Updating bean:",B.rkey),await ge.put(`/api/beans/${B.rkey}`,A)):(console.log("Creating new bean"),await ge.post("/api/beans",A)),await Te.invalidate(),t(2,_=!1)}catch(M){console.error("Bean save error:",M),alert("Failed to save bean: "+M.message)}}async function E(M){if(confirm("Are you sure you want to delete this bean?"))try{await ge.delete(`/api/beans/${M}`),await Te.invalidate()}catch(ne){alert("Failed to delete bean: "+ne.message)}}function T(){t(7,v=null),t(11,N={name:"",location:"",website:"",description:""}),t(3,m=!0)}function R(M){t(7,v=M),t(11,N={name:M.name||"",location:M.location||"",website:M.website||"",description:M.Description||""}),t(3,m=!0)}async function V(){try{v?await ge.put(`/api/roasters/${v.rkey}`,N):await ge.post("/api/roasters",N),await Te.invalidate(),t(3,m=!1)}catch(M){alert("Failed to save roaster: "+M.message)}}async function X(M){if(confirm("Are you sure you want to delete this roaster?"))try{await ge.delete(`/api/roasters/${M}`),await Te.invalidate()}catch(ne){alert("Failed to delete roaster: "+ne.message)}}function J(){t(8,x=null),t(12,P={name:"",grinder_type:"",burr_type:"",notes:""}),t(4,h=!0)}function I(M){t(8,x=M),t(12,P={name:M.name||"",grinder_type:M.grinder_type||"",burr_type:M.burr_type||"",notes:M.notes||""}),t(4,h=!0)}async function Y(){try{x?await ge.put(`/api/grinders/${x.rkey}`,P):await ge.post("/api/grinders",P),await Te.invalidate(),t(4,h=!1)}catch(M){alert("Failed to save grinder: "+M.message)}}async function ue(M){if(confirm("Are you sure you want to delete this grinder?"))try{await ge.delete(`/api/grinders/${M}`),await Te.invalidate()}catch(ne){alert("Failed to delete grinder: "+ne.message)}}function $(){t(9,S=null),t(13,L={name:"",brewer_type:"",description:""}),t(5,g=!0)}function Ae(M){t(9,S=M),t(13,L={name:M.name||"",brewer_type:M.brewer_type||"",description:M.description||""}),t(5,g=!0)}async function ke(){try{S?await ge.put(`/api/brewers/${S.rkey}`,L):await ge.post("/api/brewers",L),await Te.invalidate(),t(5,g=!1)}catch(M){alert("Failed to save brewer: "+M.message)}}async function De(M){if(confirm("Are you sure you want to delete this brewer?"))try{await ge.delete(`/api/brewers/${M}`),await Te.invalidate()}catch(ne){alert("Failed to delete brewer: "+ne.message)}}const ye=()=>O("beans"),Me=()=>O("roasters"),we=()=>O("grinders"),Fe=()=>O("brewers"),Ee=M=>F(M),ae=M=>E(M.rkey),te=M=>R(M),ie=M=>X(M.rkey),re=M=>I(M),oe=M=>ue(M.rkey),he=M=>Ae(M),fe=M=>De(M.rkey);function pe(){A.name=this.value,t(10,A),t(16,i),t(36,b)}function Ie(){A.origin=this.value,t(10,A),t(16,i),t(36,b)}function se(){A.roaster_rkey=at(this),t(10,A),t(16,i),t(36,b)}function ee(){A.roast_level=at(this),t(10,A),t(16,i),t(36,b)}function qe(){A.process=this.value,t(10,A),t(16,i),t(36,b)}function Pe(){A.description=this.value,t(10,A),t(16,i),t(36,b)}const Re=()=>t(2,_=!1);function Se(M){_=M,t(2,_)}function Ze(){N.name=this.value,t(11,N)}function xe(){N.location=this.value,t(11,N)}function Ye(){N.website=this.value,t(11,N)}const $e=()=>t(3,m=!1);function de(M){m=M,t(3,m)}function je(){P.name=this.value,t(12,P)}function be(){P.grinder_type=at(this),t(12,P)}function Ce(){P.burr_type=at(this),t(12,P)}function q(){P.notes=this.value,t(12,P)}const Z=()=>t(4,h=!1);function K(M){h=M,t(4,h)}function me(){L.name=this.value,t(13,L)}function dt(){L.brewer_type=this.value,t(13,L)}function He(){L.description=this.value,t(13,L)}const Ne=()=>t(5,g=!1);function ze(M){g=M,t(5,g)}return n.$$.update=()=>{n.$$.dirty[1]&32&&t(17,l=b.beans||[]),n.$$.dirty[1]&32&&t(16,i=b.roasters||[]),n.$$.dirty[1]&32&&t(15,r=b.grinders||[]),n.$$.dirty[1]&32&&t(14,s=b.brewers||[]),n.$$.dirty[1]&16&&(c=u.isAuthenticated)},[d,p,_,m,h,g,B,v,x,S,A,N,P,L,s,r,i,l,O,D,F,G,E,T,R,V,X,J,I,Y,ue,$,Ae,ke,De,u,b,ye,Me,we,Fe,Ee,ae,te,ie,re,oe,he,fe,pe,Ie,se,ee,qe,Pe,Re,Se,Ze,xe,Ye,$e,de,je,be,Ce,q,Z,K,me,dt,He,Ne,ze]}class di extends Xe{constructor(e){super(),Qe(this,e,fi,ci,We,{},null,[-1,-1,-1])}}function Ol(n,e,t){const l=n.slice();return l[23]=e[t],l}function Ml(n,e,t){const l=n.slice();return l[26]=e[t],l}function El(n,e,t){const l=n.slice();return l[17]=e[t],l}function Pl(n,e,t){const l=n.slice();return l[20]=e[t],l}function Dl(n,e,t){const l=n.slice();return l[14]=e[t],l}function bi(n){let e,t,l,i,r,s,c,u=n[0].handle+"",b,d,p,_,m,h=n[1].length+"",g,B,v,x,S,A,N=n[2].length+"",P,L,O,D,F,G,E=n[3].length+"",T,R,V,X,J,I,Y=n[4].length+"",ue,$,Ae,ke,De,ye,Me=n[5].length+"",we,Fe,Ee,ae,te,ie,re,oe,he,fe,pe,Ie,se,ee,qe,Pe,Re,Se,Ze,xe,Ye;function $e(K,me){return K[0].avatar?wi:mi}let de=$e(n),je=de(n),be=n[0].displayName&&Fl(n);function Ce(K,me){if(K[8]==="brews")return vi;if(K[8]==="beans")return gi;if(K[8]==="gear")return hi}let q=Ce(n),Z=q&&q(n);return{c(){e=f("div"),t=f("div"),je.c(),l=w(),i=f("div"),be&&be.c(),r=w(),s=f("p"),c=C("@"),b=C(u),d=w(),p=f("div"),_=f("div"),m=f("div"),g=C(h),B=w(),v=f("div"),v.textContent="Brews",x=w(),S=f("div"),A=f("div"),P=C(N),L=w(),O=f("div"),O.textContent="Beans",D=w(),F=f("div"),G=f("div"),T=C(E),R=w(),V=f("div"),V.textContent="Roasters",X=w(),J=f("div"),I=f("div"),ue=C(Y),$=w(),Ae=f("div"),Ae.textContent="Grinders",ke=w(),De=f("div"),ye=f("div"),we=C(Me),Fe=w(),Ee=f("div"),Ee.textContent="Brewers",ae=w(),te=f("div"),ie=f("div"),re=f("div"),oe=f("button"),he=C("Brews"),pe=w(),Ie=f("button"),se=C("Beans"),qe=w(),Pe=f("button"),Re=C("Gear"),Ze=w(),Z&&Z.c(),a(s,"class","text-brown-700"),a(t,"class","flex items-center gap-4"),a(e,"class","bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-6 mb-6 border border-brown-300"),a(m,"class","text-2xl font-bold text-brown-800"),a(v,"class","text-sm text-brown-700"),a(_,"class","bg-gradient-to-br from-brown-100 to-brown-200 rounded-lg shadow-md p-4 text-center border border-brown-300"),a(A,"class","text-2xl font-bold text-brown-800"),a(O,"class","text-sm text-brown-700"),a(S,"class","bg-gradient-to-br from-brown-100 to-brown-200 rounded-lg shadow-md p-4 text-center border border-brown-300"),a(G,"class","text-2xl font-bold text-brown-800"),a(V,"class","text-sm text-brown-700"),a(F,"class","bg-gradient-to-br from-brown-100 to-brown-200 rounded-lg shadow-md p-4 text-center border border-brown-300"),a(I,"class","text-2xl font-bold text-brown-800"),a(Ae,"class","text-sm text-brown-700"),a(J,"class","bg-gradient-to-br from-brown-100 to-brown-200 rounded-lg shadow-md p-4 text-center border border-brown-300"),a(ye,"class","text-2xl font-bold text-brown-800"),a(Ee,"class","text-sm text-brown-700"),a(De,"class","bg-gradient-to-br from-brown-100 to-brown-200 rounded-lg shadow-md p-4 text-center border border-brown-300"),a(p,"class","grid grid-cols-2 md:grid-cols-5 gap-4 mb-6"),a(oe,"class",fe="flex-1 py-3 px-4 text-center font-medium transition-colors "+(n[8]==="brews"?"border-b-2 border-brown-700 text-brown-900":"text-brown-600 hover:text-brown-800")),a(Ie,"class",ee="flex-1 py-3 px-4 text-center font-medium transition-colors "+(n[8]==="beans"?"border-b-2 border-brown-700 text-brown-900":"text-brown-600 hover:text-brown-800")),a(Pe,"class",Se="flex-1 py-3 px-4 text-center font-medium transition-colors "+(n[8]==="gear"?"border-b-2 border-brown-700 text-brown-900":"text-brown-600 hover:text-brown-800")),a(re,"class","flex border-b border-brown-300"),a(ie,"class","bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-md mb-4 border border-brown-300")},m(K,me){y(K,e,me),o(e,t),je.m(t,null),o(t,l),o(t,i),be&&be.m(i,null),o(i,r),o(i,s),o(s,c),o(s,b),y(K,d,me),y(K,p,me),o(p,_),o(_,m),o(m,g),o(_,B),o(_,v),o(p,x),o(p,S),o(S,A),o(A,P),o(S,L),o(S,O),o(p,D),o(p,F),o(F,G),o(G,T),o(F,R),o(F,V),o(p,X),o(p,J),o(J,I),o(I,ue),o(J,$),o(J,Ae),o(p,ke),o(p,De),o(De,ye),o(ye,we),o(De,Fe),o(De,Ee),y(K,ae,me),y(K,te,me),o(te,ie),o(ie,re),o(re,oe),o(oe,he),o(re,pe),o(re,Ie),o(Ie,se),o(re,qe),o(re,Pe),o(Pe,Re),o(te,Ze),Z&&Z.m(te,null),xe||(Ye=[z(oe,"click",n[10]),z(Ie,"click",n[11]),z(Pe,"click",n[12])],xe=!0)},p(K,me){de===(de=$e(K))&&je?je.p(K,me):(je.d(1),je=de(K),je&&(je.c(),je.m(t,l))),K[0].displayName?be?be.p(K,me):(be=Fl(K),be.c(),be.m(i,r)):be&&(be.d(1),be=null),me&1&&u!==(u=K[0].handle+"")&&j(b,u),me&2&&h!==(h=K[1].length+"")&&j(g,h),me&4&&N!==(N=K[2].length+"")&&j(P,N),me&8&&E!==(E=K[3].length+"")&&j(T,E),me&16&&Y!==(Y=K[4].length+"")&&j(ue,Y),me&32&&Me!==(Me=K[5].length+"")&&j(we,Me),me&256&&fe!==(fe="flex-1 py-3 px-4 text-center font-medium transition-colors "+(K[8]==="brews"?"border-b-2 border-brown-700 text-brown-900":"text-brown-600 hover:text-brown-800"))&&a(oe,"class",fe),me&256&&ee!==(ee="flex-1 py-3 px-4 text-center font-medium transition-colors "+(K[8]==="beans"?"border-b-2 border-brown-700 text-brown-900":"text-brown-600 hover:text-brown-800"))&&a(Ie,"class",ee),me&256&&Se!==(Se="flex-1 py-3 px-4 text-center font-medium transition-colors "+(K[8]==="gear"?"border-b-2 border-brown-700 text-brown-900":"text-brown-600 hover:text-brown-800"))&&a(Pe,"class",Se),q===(q=Ce(K))&&Z?Z.p(K,me):(Z&&Z.d(1),Z=q&&q(K),Z&&(Z.c(),Z.m(te,null)))},d(K){K&&(k(e),k(d),k(p),k(ae),k(te)),je.d(),be&&be.d(),Z&&Z.d(),xe=!1,ce(Ye)}}}function pi(n){let e,t,l;return{c(){e=f("div"),t=C("Error: "),l=C(n[7]),a(e,"class","bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded")},m(i,r){y(i,e,r),o(e,t),o(e,l)},p(i,r){r&128&&j(l,i[7])},d(i){i&&k(e)}}}function _i(n){let e;return{c(){e=f("div"),e.innerHTML='<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-brown-900"></div> <p class="mt-4 text-brown-700">Loading profile...</p>',a(e,"class","text-center py-12")},m(t,l){y(t,e,l)},p:W,d(t){t&&k(e)}}}function mi(n){let e;return{c(){e=f("div"),e.innerHTML='<span class="text-brown-600 text-2xl">?</span>',a(e,"class","w-20 h-20 rounded-full bg-brown-300 flex items-center justify-center")},m(t,l){y(t,e,l)},p:W,d(t){t&&k(e)}}}function wi(n){let e,t;return{c(){e=f("img"),gt(e.src,t=n[0].avatar)||a(e,"src",t),a(e,"alt",""),a(e,"class","w-20 h-20 rounded-full object-cover border-2 border-brown-300")},m(l,i){y(l,e,i)},p(l,i){i&1&&!gt(e.src,t=l[0].avatar)&&a(e,"src",t)},d(l){l&&k(e)}}}function Fl(n){let e,t=n[0].displayName+"",l;return{c(){e=f("h1"),l=C(t),a(e,"class","text-2xl font-bold text-brown-900")},m(i,r){y(i,e,r),o(e,l)},p(i,r){r&1&&t!==(t=i[0].displayName+"")&&j(l,t)},d(i){i&&k(e)}}}function hi(n){let e,t,l,i=n[4].length>0&&Rl(n),r=n[5].length>0&&Hl(n),s=n[4].length===0&&n[5].length===0&&Gl();return{c(){e=f("div"),i&&i.c(),t=w(),r&&r.c(),l=w(),s&&s.c(),a(e,"class","space-y-6")},m(c,u){y(c,e,u),i&&i.m(e,null),o(e,t),r&&r.m(e,null),o(e,l),s&&s.m(e,null)},p(c,u){c[4].length>0?i?i.p(c,u):(i=Rl(c),i.c(),i.m(e,t)):i&&(i.d(1),i=null),c[5].length>0?r?r.p(c,u):(r=Hl(c),r.c(),r.m(e,l)):r&&(r.d(1),r=null),c[4].length===0&&c[5].length===0?s||(s=Gl(),s.c(),s.m(e,null)):s&&(s.d(1),s=null)},d(c){c&&k(e),i&&i.d(),r&&r.d(),s&&s.d()}}}function gi(n){let e,t,l,i=n[2].length>0&&Il(n),r=n[3].length>0&&Wl(n),s=n[2].length===0&&n[3].length===0&&Yl();return{c(){e=f("div"),i&&i.c(),t=w(),r&&r.c(),l=w(),s&&s.c(),a(e,"class","space-y-6")},m(c,u){y(c,e,u),i&&i.m(e,null),o(e,t),r&&r.m(e,null),o(e,l),s&&s.m(e,null)},p(c,u){c[2].length>0?i?i.p(c,u):(i=Il(c),i.c(),i.m(e,t)):i&&(i.d(1),i=null),c[3].length>0?r?r.p(c,u):(r=Wl(c),r.c(),r.m(e,l)):r&&(r.d(1),r=null),c[2].length===0&&c[3].length===0?s||(s=Yl(),s.c(),s.m(e,null)):s&&(s.d(1),s=null)},d(c){c&&k(e),i&&i.d(),r&&r.d(),s&&s.d()}}}function vi(n){let e;function t(r,s){return r[1].length===0?Ci:xi}let l=t(n),i=l(n);return{c(){i.c(),e=ft()},m(r,s){i.m(r,s),y(r,e,s)},p(r,s){l===(l=t(r))&&i?i.p(r,s):(i.d(1),i=l(r),i&&(i.c(),i.m(e.parentNode,e)))},d(r){r&&k(e),i.d(r)}}}function Rl(n){let e,t,l,i,r,s,c,u,b=le(n[4]),d=[];for(let p=0;p<b.length;p+=1)d[p]=jl(Ml(n,b,p));return{c(){e=f("div"),t=f("h3"),t.textContent="⚙️ Grinders",l=w(),i=f("div"),r=f("table"),s=f("thead"),s.innerHTML='<tr><th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">Name</th> <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">🔧 Type</th> <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">💎 Burrs</th> <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">📝 Notes</th></tr>',c=w(),u=f("tbody");for(let p=0;p<d.length;p+=1)d[p].c();a(t,"class","text-lg font-semibold text-brown-900 mb-3"),a(s,"class","bg-brown-200/80"),a(u,"class","bg-brown-50/60 divide-y divide-brown-200"),a(r,"class","min-w-full divide-y divide-brown-300"),a(i,"class","overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300")},m(p,_){y(p,e,_),o(e,t),o(e,l),o(e,i),o(i,r),o(r,s),o(r,c),o(r,u);for(let m=0;m<d.length;m+=1)d[m]&&d[m].m(u,null)},p(p,_){if(_&16){b=le(p[4]);let m;for(m=0;m<b.length;m+=1){const h=Ml(p,b,m);d[m]?d[m].p(h,_):(d[m]=jl(h),d[m].c(),d[m].m(u,null))}for(;m<d.length;m+=1)d[m].d(1);d.length=b.length}},d(p){p&&k(e),Ge(d,p)}}}function jl(n){let e,t,l=n[26].name+"",i,r,s,c=(n[26].grinder_type||"-")+"",u,b,d,p=(n[26].burr_type||"-")+"",_,m,h,g=(n[26].notes||"-")+"",B,v;return{c(){e=f("tr"),t=f("td"),i=C(l),r=w(),s=f("td"),u=C(c),b=w(),d=f("td"),_=C(p),m=w(),h=f("td"),B=C(g),v=w(),a(t,"class","px-6 py-4 text-sm font-bold text-brown-900"),a(s,"class","px-6 py-4 text-sm text-brown-900"),a(d,"class","px-6 py-4 text-sm text-brown-900"),a(h,"class","px-6 py-4 text-sm text-brown-700 italic max-w-xs"),a(e,"class","hover:bg-brown-100/60 transition-colors")},m(x,S){y(x,e,S),o(e,t),o(t,i),o(e,r),o(e,s),o(s,u),o(e,b),o(e,d),o(d,_),o(e,m),o(e,h),o(h,B),o(e,v)},p(x,S){S&16&&l!==(l=x[26].name+"")&&j(i,l),S&16&&c!==(c=(x[26].grinder_type||"-")+"")&&j(u,c),S&16&&p!==(p=(x[26].burr_type||"-")+"")&&j(_,p),S&16&&g!==(g=(x[26].notes||"-")+"")&&j(B,g)},d(x){x&&k(e)}}}function Hl(n){let e,t,l,i,r,s,c,u,b=le(n[5]),d=[];for(let p=0;p<b.length;p+=1)d[p]=zl(Ol(n,b,p));return{c(){e=f("div"),t=f("h3"),t.textContent="☕ Brewers",l=w(),i=f("div"),r=f("table"),s=f("thead"),s.innerHTML='<tr><th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">Name</th> <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">🔧 Type</th> <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">📝 Description</th></tr>',c=w(),u=f("tbody");for(let p=0;p<d.length;p+=1)d[p].c();a(t,"class","text-lg font-semibold text-brown-900 mb-3"),a(s,"class","bg-brown-200/80"),a(u,"class","bg-brown-50/60 divide-y divide-brown-200"),a(r,"class","min-w-full divide-y divide-brown-300"),a(i,"class","overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300")},m(p,_){y(p,e,_),o(e,t),o(e,l),o(e,i),o(i,r),o(r,s),o(r,c),o(r,u);for(let m=0;m<d.length;m+=1)d[m]&&d[m].m(u,null)},p(p,_){if(_&32){b=le(p[5]);let m;for(m=0;m<b.length;m+=1){const h=Ol(p,b,m);d[m]?d[m].p(h,_):(d[m]=zl(h),d[m].c(),d[m].m(u,null))}for(;m<d.length;m+=1)d[m].d(1);d.length=b.length}},d(p){p&&k(e),Ge(d,p)}}}function zl(n){let e,t,l=n[23].name+"",i,r,s,c=(n[23].brewer_type||"-")+"",u,b,d,p=(n[23].description||"-")+"",_,m;return{c(){e=f("tr"),t=f("td"),i=C(l),r=w(),s=f("td"),u=C(c),b=w(),d=f("td"),_=C(p),m=w(),a(t,"class","px-6 py-4 text-sm font-bold text-brown-900"),a(s,"class","px-6 py-4 text-sm text-brown-900"),a(d,"class","px-6 py-4 text-sm text-brown-700 italic max-w-xs"),a(e,"class","hover:bg-brown-100/60 transition-colors")},m(h,g){y(h,e,g),o(e,t),o(t,i),o(e,r),o(e,s),o(s,u),o(e,b),o(e,d),o(d,_),o(e,m)},p(h,g){g&32&&l!==(l=h[23].name+"")&&j(i,l),g&32&&c!==(c=(h[23].brewer_type||"-")+"")&&j(u,c),g&32&&p!==(p=(h[23].description||"-")+"")&&j(_,p)},d(h){h&&k(e)}}}function Gl(n){let e;return{c(){e=f("div"),e.innerHTML='<p class="font-medium">No gear added yet.</p>',a(e,"class","bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center text-brown-800 border border-brown-300")},m(t,l){y(t,e,l)},d(t){t&&k(e)}}}function Il(n){let e,t,l,i,r,s,c,u,b=le(n[2]),d=[];for(let p=0;p<b.length;p+=1)d[p]=Ul(Pl(n,b,p));return{c(){e=f("div"),t=f("h3"),t.textContent="☕ Coffee Beans",l=w(),i=f("div"),r=f("table"),s=f("thead"),s.innerHTML='<tr><th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap">Name</th> <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap">☕ Roaster</th> <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap">📍 Origin</th> <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap">🔥 Roast</th> <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap">🌱 Process</th> <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider whitespace-nowrap">📝 Description</th></tr>',c=w(),u=f("tbody");for(let p=0;p<d.length;p+=1)d[p].c();a(t,"class","text-lg font-semibold text-brown-900 mb-3"),a(s,"class","bg-brown-200/80"),a(u,"class","bg-brown-50/60 divide-y divide-brown-200"),a(r,"class","min-w-full divide-y divide-brown-300"),a(i,"class","overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300")},m(p,_){y(p,e,_),o(e,t),o(e,l),o(e,i),o(i,r),o(r,s),o(r,c),o(r,u);for(let m=0;m<d.length;m+=1)d[m]&&d[m].m(u,null)},p(p,_){if(_&4){b=le(p[2]);let m;for(m=0;m<b.length;m+=1){const h=Pl(p,b,m);d[m]?d[m].p(h,_):(d[m]=Ul(h),d[m].c(),d[m].m(u,null))}for(;m<d.length;m+=1)d[m].d(1);d.length=b.length}},d(p){p&&k(e),Ge(d,p)}}}function Ul(n){var F;let e,t,l=(n[20].name||n[20].origin)+"",i,r,s,c=(((F=n[20].roaster)==null?void 0:F.name)||"-")+"",u,b,d,p=(n[20].origin||"-")+"",_,m,h,g=(n[20].roast_level||"-")+"",B,v,x,S=(n[20].process||"-")+"",A,N,P,L=(n[20].description||"-")+"",O,D;return{c(){e=f("tr"),t=f("td"),i=C(l),r=w(),s=f("td"),u=C(c),b=w(),d=f("td"),_=C(p),m=w(),h=f("td"),B=C(g),v=w(),x=f("td"),A=C(S),N=w(),P=f("td"),O=C(L),D=w(),a(t,"class","px-6 py-4 text-sm font-bold text-brown-900"),a(s,"class","px-6 py-4 text-sm text-brown-900"),a(d,"class","px-6 py-4 text-sm text-brown-900"),a(h,"class","px-6 py-4 text-sm text-brown-900"),a(x,"class","px-6 py-4 text-sm text-brown-900"),a(P,"class","px-6 py-4 text-sm text-brown-700 italic max-w-xs"),a(e,"class","hover:bg-brown-100/60 transition-colors")},m(G,E){y(G,e,E),o(e,t),o(t,i),o(e,r),o(e,s),o(s,u),o(e,b),o(e,d),o(d,_),o(e,m),o(e,h),o(h,B),o(e,v),o(e,x),o(x,A),o(e,N),o(e,P),o(P,O),o(e,D)},p(G,E){var T;E&4&&l!==(l=(G[20].name||G[20].origin)+"")&&j(i,l),E&4&&c!==(c=(((T=G[20].roaster)==null?void 0:T.name)||"-")+"")&&j(u,c),E&4&&p!==(p=(G[20].origin||"-")+"")&&j(_,p),E&4&&g!==(g=(G[20].roast_level||"-")+"")&&j(B,g),E&4&&S!==(S=(G[20].process||"-")+"")&&j(A,S),E&4&&L!==(L=(G[20].description||"-")+"")&&j(O,L)},d(G){G&&k(e)}}}function Wl(n){let e,t,l,i,r,s,c,u,b=le(n[3]),d=[];for(let p=0;p<b.length;p+=1)d[p]=ql(El(n,b,p));return{c(){e=f("div"),t=f("h3"),t.textContent="🏭 Favorite Roasters",l=w(),i=f("div"),r=f("table"),s=f("thead"),s.innerHTML='<tr><th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">Name</th> <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">📍 Location</th> <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">🌐 Website</th></tr>',c=w(),u=f("tbody");for(let p=0;p<d.length;p+=1)d[p].c();a(t,"class","text-lg font-semibold text-brown-900 mb-3"),a(s,"class","bg-brown-200/80"),a(u,"class","bg-brown-50/60 divide-y divide-brown-200"),a(r,"class","min-w-full divide-y divide-brown-300"),a(i,"class","overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300")},m(p,_){y(p,e,_),o(e,t),o(e,l),o(e,i),o(i,r),o(r,s),o(r,c),o(r,u);for(let m=0;m<d.length;m+=1)d[m]&&d[m].m(u,null)},p(p,_){if(_&8){b=le(p[3]);let m;for(m=0;m<b.length;m+=1){const h=El(p,b,m);d[m]?d[m].p(h,_):(d[m]=ql(h),d[m].c(),d[m].m(u,null))}for(;m<d.length;m+=1)d[m].d(1);d.length=b.length}},d(p){p&&k(e),Ge(d,p)}}}function ki(n){let e;return{c(){e=C("-")},m(t,l){y(t,e,l)},p:W,d(t){t&&k(e)}}}function yi(n){let e,t,l;return{c(){e=f("a"),t=C("Visit Site"),a(e,"href",l=n[17].website),a(e,"target","_blank"),a(e,"rel","noopener noreferrer"),a(e,"class","text-brown-700 hover:underline font-medium")},m(i,r){y(i,e,r),o(e,t)},p(i,r){r&8&&l!==(l=i[17].website)&&a(e,"href",l)},d(i){i&&k(e)}}}function ql(n){let e,t,l=n[17].name+"",i,r,s,c=(n[17].location||"-")+"",u,b,d,p;function _(g,B){return g[17].website?yi:ki}let m=_(n),h=m(n);return{c(){e=f("tr"),t=f("td"),i=C(l),r=w(),s=f("td"),u=C(c),b=w(),d=f("td"),h.c(),p=w(),a(t,"class","px-6 py-4 text-sm font-bold text-brown-900"),a(s,"class","px-6 py-4 text-sm text-brown-900"),a(d,"class","px-6 py-4 text-sm text-brown-900"),a(e,"class","hover:bg-brown-100/60 transition-colors")},m(g,B){y(g,e,B),o(e,t),o(t,i),o(e,r),o(e,s),o(s,u),o(e,b),o(e,d),h.m(d,null),o(e,p)},p(g,B){B&8&&l!==(l=g[17].name+"")&&j(i,l),B&8&&c!==(c=(g[17].location||"-")+"")&&j(u,c),m===(m=_(g))&&h?h.p(g,B):(h.d(1),h=m(g),h&&(h.c(),h.m(d,null)))},d(g){g&&k(e),h.d()}}}function Yl(n){let e;return{c(){e=f("div"),e.innerHTML='<p class="font-medium">No beans or roasters yet.</p>',a(e,"class","bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center text-brown-800 border border-brown-300")},m(t,l){y(t,e,l)},d(t){t&&k(e)}}}function xi(n){let e,t,l,i,r,s=le(n[1]),c=[];for(let u=0;u<s.length;u+=1)c[u]=Vl(Dl(n,s,u));return{c(){e=f("div"),t=f("table"),l=f("thead"),l.innerHTML='<tr><th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">📅 Date</th> <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">☕ Bean</th> <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">🫖 Method</th> <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">📝 Notes</th> <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">⭐ Rating</th></tr>',i=w(),r=f("tbody");for(let u=0;u<c.length;u+=1)c[u].c();a(l,"class","bg-brown-200/80"),a(r,"class","bg-brown-50/60 divide-y divide-brown-200"),a(t,"class","min-w-full divide-y divide-brown-300"),a(e,"class","overflow-x-auto bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300")},m(u,b){y(u,e,b),o(e,t),o(t,l),o(t,i),o(t,r);for(let d=0;d<c.length;d+=1)c[d]&&c[d].m(r,null)},p(u,b){if(b&2){s=le(u[1]);let d;for(d=0;d<s.length;d+=1){const p=Dl(u,s,d);c[d]?c[d].p(p,b):(c[d]=Vl(p),c[d].c(),c[d].m(r,null))}for(;d<c.length;d+=1)c[d].d(1);c.length=s.length}},d(u){u&&k(e),Ge(c,u)}}}function Ci(n){let e;return{c(){e=f("div"),e.innerHTML='<p class="text-brown-800 text-lg font-medium">No brews yet.</p>',a(e,"class","bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 text-center border border-brown-300")},m(t,l){y(t,e,l)},p:W,d(t){t&&k(e)}}}function Bi(n){let e;return{c(){e=f("span"),e.textContent="-",a(e,"class","text-brown-400")},m(t,l){y(t,e,l)},p:W,d(t){t&&k(e)}}}function Ai(n){let e,t,l=n[14].rating+"",i,r;return{c(){e=f("span"),t=C("⭐ "),i=C(l),r=C("/10"),a(e,"class","inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-900")},m(s,c){y(s,e,c),o(e,t),o(e,i),o(e,r)},p(s,c){c&2&&l!==(l=s[14].rating+"")&&j(i,l)},d(s){s&&k(e)}}}function Vl(n){var L,O,D;let e,t,l=Kl(n[14].created_at)+"",i,r,s,c=(((L=n[14].bean)==null?void 0:L.name)||((O=n[14].bean)==null?void 0:O.origin)||"Unknown")+"",u,b,d,p=(((D=n[14].brewer_obj)==null?void 0:D.name)||"-")+"",_,m,h,g=(n[14].tasting_notes||"-")+"",B,v,x,S;function A(F,G){return F[14].rating?Ai:Bi}let N=A(n),P=N(n);return{c(){e=f("tr"),t=f("td"),i=C(l),r=w(),s=f("td"),u=C(c),b=w(),d=f("td"),_=C(p),m=w(),h=f("td"),B=C(g),v=w(),x=f("td"),P.c(),S=w(),a(t,"class","px-4 py-3 text-sm text-brown-900"),a(s,"class","px-4 py-3 text-sm font-bold text-brown-900"),a(d,"class","px-4 py-3 text-sm text-brown-900"),a(h,"class","px-4 py-3 text-sm text-brown-700 truncate max-w-xs"),a(x,"class","px-4 py-3 text-sm text-brown-900"),a(e,"class","hover:bg-brown-100/60 transition-colors")},m(F,G){y(F,e,G),o(e,t),o(t,i),o(e,r),o(e,s),o(s,u),o(e,b),o(e,d),o(d,_),o(e,m),o(e,h),o(h,B),o(e,v),o(e,x),P.m(x,null),o(e,S)},p(F,G){var E,T,R;G&2&&l!==(l=Kl(F[14].created_at)+"")&&j(i,l),G&2&&c!==(c=(((E=F[14].bean)==null?void 0:E.name)||((T=F[14].bean)==null?void 0:T.origin)||"Unknown")+"")&&j(u,c),G&2&&p!==(p=(((R=F[14].brewer_obj)==null?void 0:R.name)||"-")+"")&&j(_,p),G&2&&g!==(g=(F[14].tasting_notes||"-")+"")&&j(B,g),N===(N=A(F))&&P?P.p(F,G):(P.d(1),P=N(F),P&&(P.c(),P.m(x,null)))},d(F){F&&k(e),P.d()}}}function Si(n){var c,u;let e,t,l;document.title=e=(((c=n[0])==null?void 0:c.displayName)||((u=n[0])==null?void 0:u.handle)||"Profile")+" - Arabica";function i(b,d){if(b[6])return _i;if(b[7])return pi;if(b[0])return bi}let r=i(n),s=r&&r(n);return{c(){t=w(),l=f("div"),s&&s.c(),a(l,"class","max-w-4xl mx-auto")},m(b,d){y(b,t,d),y(b,l,d),s&&s.m(l,null)},p(b,[d]){var p,_;d&1&&e!==(e=(((p=b[0])==null?void 0:p.displayName)||((_=b[0])==null?void 0:_.handle)||"Profile")+" - Arabica")&&(document.title=e),r===(r=i(b))&&s?s.p(b,d):(s&&s.d(1),s=r&&r(b),s&&(s.c(),s.m(l,null)))},i:W,o:W,d(b){b&&(k(t),k(l)),s&&s.d()}}}function Kl(n){return new Date(n).toLocaleDateString("en-US",{year:"numeric",month:"short",day:"numeric"})}function Ni(n,e,t){let{actor:l}=e,i=null,r=[],s=[],c=[],u=[],b=[],d=!1,p=!0,_=null,m="brews";vt(async()=>{try{const v=await ge.get(`/api/profile-json/${l}`);t(0,i=v.profile),t(1,r=(v.brews||[]).sort((x,S)=>new Date(S.created_at)-new Date(x.created_at))),t(2,s=v.beans||[]),t(3,c=v.roasters||[]),t(4,u=v.grinders||[]),t(5,b=v.brewers||[]),d=v.isOwnProfile||!1}catch(v){console.error("Failed to load profile:",v),t(7,_=v.message)}finally{t(6,p=!1)}});const h=()=>t(8,m="brews"),g=()=>t(8,m="beans"),B=()=>t(8,m="gear");return n.$$set=v=>{"actor"in v&&t(9,l=v.actor)},[i,r,s,c,u,b,p,_,m,l,h,g,B]}class Ti extends Xe{constructor(e){super(),Qe(this,e,Ni,Si,We,{actor:9})}}function Li(n){let e,t,l,i,r,s,c,u,b,d,p,_,m,h,g,B,v,x,S;return{c(){e=f("div"),t=f("div"),l=f("h1"),l.textContent="About Arabica",i=w(),r=f("div"),s=f("p"),s.textContent="Arabica is a coffee brew tracking application that leverages the AT Protocol for decentralized data storage.",c=w(),u=f("h2"),u.textContent="Features",b=w(),d=f("ul"),d.innerHTML='<li class="flex items-start"><span class="mr-2">🔒</span> <span><strong>Decentralized:</strong> Your data lives in your Personal Data Server (PDS)</span></li> <li class="flex items-start"><span class="mr-2">🚀</span> <span><strong>Portable:</strong> Own your coffee brewing history</span></li> <li class="flex items-start"><span class="mr-2">📊</span> <span>Track brewing variables like temperature, time, and grind size</span></li> <li class="flex items-start"><span class="mr-2">🌍</span> <span>Organize beans by origin and roaster</span></li> <li class="flex items-start"><span class="mr-2">📝</span> <span>Add tasting notes and ratings to each brew</span></li>',p=w(),_=f("h2"),_.textContent="AT Protocol",m=w(),h=f("p"),h.textContent=`The Authenticated Transfer Protocol (AT Protocol) is a decentralized social networking protocol 3 - that gives you full ownership of your data. Your brewing records are stored in your own PDS, 4 - not in Arabica's servers.`,g=w(),B=f("div"),v=f("button"),v.textContent="Get Started",a(l,"class","text-3xl font-bold text-brown-900 mb-6"),a(s,"class","text-lg text-brown-800 mb-4"),a(u,"class","text-2xl font-bold text-brown-900 mt-8 mb-4"),a(d,"class","space-y-2 text-brown-800"),a(_,"class","text-2xl font-bold text-brown-900 mt-8 mb-4"),a(h,"class","text-brown-800 mb-4"),a(v,"class","bg-gradient-to-r from-brown-700 to-brown-800 text-white px-6 py-3 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-semibold shadow-lg"),a(B,"class","mt-8"),a(r,"class","prose prose-brown max-w-none"),a(t,"class","bg-gradient-to-br from-amber-50 to-brown-100 rounded-xl p-8 border-2 border-brown-300 shadow-lg"),a(e,"class","max-w-4xl mx-auto")},m(A,N){y(A,e,N),o(e,t),o(t,l),o(t,i),o(t,r),o(r,s),o(r,c),o(r,u),o(r,b),o(r,d),o(r,p),o(r,_),o(r,m),o(r,h),o(r,g),o(r,B),o(B,v),x||(S=z(v,"click",n[0]),x=!0)},p:W,i:W,o:W,d(A){A&&k(e),x=!1,S()}}}function Oi(n){return[()=>_e("/")]}class Mi extends Xe{constructor(e){super(),Qe(this,e,Oi,Li,We,{})}}function Ei(n){let e,t,l,i,r,s,c,u,b,d,p,_,m,h,g,B,v,x,S,A,N,P,L,O,D,F,G,E,T,R;return{c(){e=f("div"),t=f("div"),l=f("h1"),l.textContent="Terms of Service",i=w(),r=f("div"),s=f("p"),s.textContent=`Last updated: ${new Date().toLocaleDateString()}`,c=w(),u=f("h2"),u.textContent="1. Acceptance of Terms",b=w(),d=f("p"),d.textContent=`By accessing and using Arabica, you accept and agree to be bound by the 5 - terms and provision of this agreement.`,p=w(),_=f("h2"),_.textContent="2. Alpha Software Notice",m=w(),h=f("p"),h.textContent=`Arabica is currently in alpha testing. Features, data structures, and 6 - functionality may change without notice. We recommend backing up your 7 - data regularly.`,g=w(),B=f("h2"),B.textContent="3. Data Storage",v=w(),x=f("p"),x.textContent=`Your brewing data is stored in your Personal Data Server (PDS) via the 8 - AT Protocol. Arabica does not store your brewing records on its servers. 9 - You are responsible for the security and backup of your PDS.`,S=w(),A=f("h2"),A.textContent="4. User Responsibilities",N=w(),P=f("p"),P.textContent=`You are responsible for maintaining the confidentiality of your account 10 - credentials and for all activities that occur under your account.`,L=w(),O=f("h2"),O.textContent="5. Limitation of Liability",D=w(),F=f("p"),F.textContent=`Arabica is provided "as is" without warranty of any kind. We are not 11 - liable for any data loss, service interruptions, or other damages 12 - arising from your use of the application.`,G=w(),E=f("h2"),E.textContent="6. Changes to Terms",T=w(),R=f("p"),R.textContent=`We reserve the right to modify these terms at any time. Continued use of 13 - Arabica after changes constitutes acceptance of the modified terms.`,a(l,"class","text-3xl font-bold text-brown-900 mb-6"),a(s,"class","text-sm text-brown-600 italic"),a(u,"class","text-2xl font-bold text-brown-900 mt-8"),a(_,"class","text-2xl font-bold text-brown-900 mt-8"),a(B,"class","text-2xl font-bold text-brown-900 mt-8"),a(A,"class","text-2xl font-bold text-brown-900 mt-8"),a(O,"class","text-2xl font-bold text-brown-900 mt-8"),a(E,"class","text-2xl font-bold text-brown-900 mt-8"),a(r,"class","prose prose-brown max-w-none text-brown-800 space-y-4"),a(t,"class","bg-white rounded-xl p-8 shadow-lg"),a(e,"class","max-w-4xl mx-auto")},m(V,X){y(V,e,X),o(e,t),o(t,l),o(t,i),o(t,r),o(r,s),o(r,c),o(r,u),o(r,b),o(r,d),o(r,p),o(r,_),o(r,m),o(r,h),o(r,g),o(r,B),o(r,v),o(r,x),o(r,S),o(r,A),o(r,N),o(r,P),o(r,L),o(r,O),o(r,D),o(r,F),o(r,G),o(r,E),o(r,T),o(r,R)},p:W,i:W,o:W,d(V){V&&k(e)}}}class Pi extends Xe{constructor(e){super(),Qe(this,e,null,Ei,We,{})}}function Di(n){let e;return{c(){e=f("div"),e.innerHTML='<div class="text-6xl mb-4">☕</div> <h1 class="text-4xl font-bold text-brown-900 mb-4">404 - Not Found</h1> <p class="text-brown-700 mb-8">The page you&#39;re looking for doesn&#39;t exist.</p> <a href="/" class="bg-gradient-to-r from-brown-700 to-brown-800 text-white px-6 py-3 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-semibold shadow-lg inline-block">Go Home</a>',a(e,"class","text-center py-12")},m(t,l){y(t,e,l)},p:W,i:W,o:W,d(t){t&&k(e)}}}class Fi extends Xe{constructor(e){super(),Qe(this,e,null,Di,We,{})}}function Jl(n){let e,t,l,i,r,s,c,u,b;function d(h,g){var B;return(B=h[2])!=null&&B.avatar?ji:Ri}let p=d(n),_=p(n),m=n[0]&&Ql(n);return{c(){e=f("div"),t=f("button"),_.c(),l=w(),i=_n("svg"),r=_n("path"),c=w(),m&&m.c(),a(r,"stroke-linecap","round"),a(r,"stroke-linejoin","round"),a(r,"stroke-width","2"),a(r,"d","M19 9l-7 7-7-7"),a(i,"class",s="w-4 h-4 transition-transform "+(n[0]?"rotate-180":"")),a(i,"fill","none"),a(i,"stroke","currentColor"),a(i,"viewBox","0 0 24 24"),a(t,"class","flex items-center gap-2 hover:opacity-80 transition focus:outline-none"),a(t,"aria-label","User menu"),a(e,"class","relative user-menu")},m(h,g){y(h,e,g),o(e,t),_.m(t,null),o(t,l),o(t,i),o(i,r),o(e,c),m&&m.m(e,null),u||(b=z(t,"click",dr(n[3])),u=!0)},p(h,g){p===(p=d(h))&&_?_.p(h,g):(_.d(1),_=p(h),_&&(_.c(),_.m(t,l))),g&1&&s!==(s="w-4 h-4 transition-transform "+(h[0]?"rotate-180":""))&&a(i,"class",s),h[0]?m?m.p(h,g):(m=Ql(h),m.c(),m.m(e,null)):m&&(m.d(1),m=null)},d(h){h&&k(e),_.d(),m&&m.d(),u=!1,b()}}}function Ri(n){var r;let e,t,l=((r=n[2])!=null&&r.displayName?n[2].displayName.charAt(0).toUpperCase():"?")+"",i;return{c(){e=f("div"),t=f("span"),i=C(l),a(t,"class","text-sm font-medium"),a(e,"class","w-8 h-8 rounded-full bg-brown-600 flex items-center justify-center ring-2 ring-brown-500")},m(s,c){y(s,e,c),o(e,t),o(t,i)},p(s,c){var u;c&4&&l!==(l=((u=s[2])!=null&&u.displayName?s[2].displayName.charAt(0).toUpperCase():"?")+"")&&j(i,l)},d(s){s&&k(e)}}}function ji(n){let e,t;return{c(){e=f("img"),gt(e.src,t=n[2].avatar)||a(e,"src",t),a(e,"alt",""),a(e,"class","w-8 h-8 rounded-full object-cover ring-2 ring-brown-600")},m(l,i){y(l,e,i)},p(l,i){i&4&&!gt(e.src,t=l[2].avatar)&&a(e,"src",t)},d(l){l&&k(e)}}}function Ql(n){var x;let e,t,l,i,r,s,c,u,b,d,p,_,m,h,g,B,v=((x=n[2])==null?void 0:x.handle)&&Xl(n);return{c(){var S,A;e=f("div"),v&&v.c(),t=w(),l=f("a"),i=C("View Profile"),s=w(),c=f("a"),c.textContent="My Brews",u=w(),b=f("a"),b.textContent="Manage Records",d=w(),p=f("a"),p.textContent="Settings (coming soon)",_=w(),m=f("div"),h=f("button"),h.textContent="Logout",a(l,"href",r="/profile/"+(((S=n[2])==null?void 0:S.handle)||((A=n[2])==null?void 0:A.did))),a(l,"class","block px-4 py-2 text-sm text-brown-700 hover:bg-brown-50 transition-colors"),a(c,"href","/brews"),a(c,"class","block px-4 py-2 text-sm text-brown-700 hover:bg-brown-50 transition-colors"),a(b,"href","/manage"),a(b,"class","block px-4 py-2 text-sm text-brown-700 hover:bg-brown-50 transition-colors"),a(p,"href","/settings"),a(p,"class","block px-4 py-2 text-sm text-brown-400 cursor-not-allowed"),a(h,"class","w-full text-left px-4 py-2 text-sm text-brown-700 hover:bg-brown-50 transition-colors"),a(m,"class","border-t border-brown-100 mt-1 pt-1"),a(e,"class","absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-brown-200 py-1 z-50 animate-fade-in svelte-1hp7v65")},m(S,A){y(S,e,A),v&&v.m(e,null),o(e,t),o(e,l),o(l,i),o(e,s),o(e,c),o(e,u),o(e,b),o(e,d),o(e,p),o(e,_),o(e,m),o(m,h),g||(B=[z(l,"click",Ue(n[9])),z(c,"click",Ue(n[10])),z(b,"click",Ue(n[11])),z(p,"click",Ue(n[12])),z(h,"click",n[13])],g=!0)},p(S,A){var N,P,L;(N=S[2])!=null&&N.handle?v?v.p(S,A):(v=Xl(S),v.c(),v.m(e,t)):v&&(v.d(1),v=null),A&4&&r!==(r="/profile/"+(((P=S[2])==null?void 0:P.handle)||((L=S[2])==null?void 0:L.did)))&&a(l,"href",r)},d(S){S&&k(e),v&&v.d(),g=!1,ce(B)}}}function Xl(n){let e,t,l=(n[2].displayName||n[2].handle)+"",i,r,s,c,u=n[2].handle+"",b;return{c(){e=f("div"),t=f("p"),i=C(l),r=w(),s=f("p"),c=C("@"),b=C(u),a(t,"class","text-sm font-medium text-brown-900 truncate"),a(s,"class","text-xs text-brown-500 truncate"),a(e,"class","px-4 py-2 border-b border-brown-100")},m(d,p){y(d,e,p),o(e,t),o(t,i),o(e,r),o(e,s),o(s,c),o(s,b)},p(d,p){p&4&&l!==(l=(d[2].displayName||d[2].handle)+"")&&j(i,l),p&4&&u!==(u=d[2].handle+"")&&j(b,u)},d(d){d&&k(e)}}}function Hi(n){let e,t,l,i,r,s,c,u,b=n[1]&&Jl(n);return{c(){e=f("nav"),t=f("div"),l=f("div"),i=f("a"),i.innerHTML='<h1 class="text-2xl font-bold">☕ Arabica</h1> <span class="text-xs bg-amber-400 text-brown-900 px-2 py-1 rounded-md font-semibold shadow-sm">ALPHA</span>',r=w(),s=f("div"),b&&b.c(),a(i,"href","/"),a(i,"class","flex items-center gap-2 hover:opacity-80 transition"),a(s,"class","flex items-center gap-4"),a(l,"class","flex items-center justify-between"),a(t,"class","container mx-auto px-4 py-4"),a(e,"class","sticky top-0 z-50 bg-gradient-to-br from-brown-800 to-brown-900 text-white shadow-xl border-b-2 border-brown-600")},m(d,p){y(d,e,p),o(e,t),o(t,l),o(l,i),o(l,r),o(l,s),b&&b.m(s,null),c||(u=[z(window,"click",n[6]),z(i,"click",Ue(n[8]))],c=!0)},p(d,[p]){d[1]?b?b.p(d,p):(b=Jl(d),b.c(),b.m(s,null)):b&&(b.d(1),b=null)},i:W,o:W,d(d){d&&k(e),b&&b.d(),c=!1,ce(u)}}}function zi(n,e,t){let l,i,r;ut(n,pt,v=>t(7,r=v));let s=!1;function c(){t(0,s=!s)}function u(){t(0,s=!1)}async function b(){await pt.logout()}function d(v){s&&!v.target.closest(".user-menu")&&u()}const p=()=>_e("/"),_=()=>{_e(`/profile/${(l==null?void 0:l.handle)||(l==null?void 0:l.did)}`),u()},m=()=>{_e("/brews"),u()},h=()=>{_e("/manage"),u()},g=()=>{_e("/settings"),u()},B=()=>{b(),u()};return n.$$.update=()=>{n.$$.dirty&128&&t(2,l=r.user),n.$$.dirty&128&&t(1,i=r.isAuthenticated)},[s,i,l,c,u,b,d,r,p,_,m,h,g,B]}class Gi extends Xe{constructor(e){super(),Qe(this,e,zi,Hi,We,{})}}function Ii(n){let e,t,l,i,r,s,c,u,b,d,p,_,m,h,g,B,v,x,S,A,N,P,L;return{c(){e=f("footer"),t=f("div"),l=f("div"),i=f("div"),i.innerHTML='<h3 class="text-lg font-bold mb-3 flex items-center gap-2"><span>☕</span> <span>Arabica</span></h3> <p class="text-sm text-brown-300">Track your coffee brewing journey with decentralized data storage powered by AT Protocol.</p>',r=w(),s=f("div"),c=f("h4"),c.textContent="Links",u=w(),b=f("ul"),d=f("li"),p=f("a"),p.textContent="About",_=w(),m=f("li"),h=f("a"),h.textContent="Terms of Service",g=w(),B=f("li"),B.innerHTML='<a href="https://github.com/arabica-social/arabica" target="_blank" rel="noopener noreferrer" class="text-brown-300 hover:text-white transition-colors">GitHub</a>',v=w(),x=f("div"),x.innerHTML='<h4 class="font-semibold mb-3">AT Protocol</h4> <p class="text-sm text-brown-300">Your data lives in your Personal Data Server (PDS), giving you full ownership and portability.</p>',S=w(),A=f("div"),N=f("p"),N.textContent=`© ${new Date().getFullYear()} Arabica Social. All rights reserved.`,a(c,"class","font-semibold mb-3"),a(p,"href","/about"),a(p,"class","text-brown-300 hover:text-white transition-colors"),a(h,"href","/terms"),a(h,"class","text-brown-300 hover:text-white transition-colors"),a(b,"class","space-y-2 text-sm"),a(l,"class","grid grid-cols-1 md:grid-cols-3 gap-8"),a(A,"class","border-t border-brown-700 mt-8 pt-6 text-center text-sm text-brown-400"),a(t,"class","container mx-auto px-4 py-8"),a(e,"class","bg-brown-800 text-brown-100 mt-12")},m(O,D){y(O,e,D),o(e,t),o(t,l),o(l,i),o(l,r),o(l,s),o(s,c),o(s,u),o(s,b),o(b,d),o(d,p),o(b,_),o(b,m),o(m,h),o(b,g),o(b,B),o(l,v),o(l,x),o(t,S),o(t,A),o(A,N),P||(L=[z(p,"click",Ue(n[0])),z(h,"click",Ue(n[1]))],P=!0)},p:W,i:W,o:W,d(O){O&&k(e),P=!1,ce(L)}}}function Ui(n){return[()=>_e("/about"),()=>_e("/terms")]}class Wi extends Xe{constructor(e){super(),Qe(this,e,Ui,Ii,We,{})}}function Zl(n){let e,t,l;const i=[n[1]];var r=n[0];function s(c,u){let b={};for(let d=0;d<i.length;d+=1)b=tn(b,i[d]);return u!==void 0&&u&2&&(b=tn(b,wn(i,[hn(c[1])]))),{props:b}}return r&&(e=mn(r,s(n))),{c(){e&&it(e.$$.fragment),t=ft()},m(c,u){e&&nt(e,c,u),y(c,t,u),l=!0},p(c,u){if(u&1&&r!==(r=c[0])){if(e){jt();const b=e;Oe(b.$$.fragment,1,0,()=>{lt(b,1)}),Ht()}r?(e=mn(r,s(c,u)),it(e.$$.fragment),ve(e.$$.fragment,1),nt(e,t.parentNode,t)):e=null}else if(r){const b=u&2?wn(i,[hn(c[1])]):{};e.$set(b)}},i(c){l||(e&&ve(e.$$.fragment,c),l=!0)},o(c){e&&Oe(e.$$.fragment,c),l=!1},d(c){c&&k(t),e&&lt(e,c)}}}function qi(n){let e,t,l,i,r,s,c;t=new Gi({});let u=n[0]&&Zl(n);return s=new Wi({}),{c(){e=f("div"),it(t.$$.fragment),l=w(),i=f("main"),u&&u.c(),r=w(),it(s.$$.fragment),a(i,"class","flex-1 container mx-auto px-4 py-8"),a(e,"class","flex flex-col min-h-screen")},m(b,d){y(b,e,d),nt(t,e,null),o(e,l),o(e,i),u&&u.m(i,null),o(e,r),nt(s,e,null),c=!0},p(b,[d]){b[0]?u?(u.p(b,d),d&1&&ve(u,1)):(u=Zl(b),u.c(),ve(u,1),u.m(i,null)):u&&(jt(),Oe(u,1,1,()=>{u=null}),Ht())},i(b){c||(ve(t.$$.fragment,b),ve(u),ve(s.$$.fragment,b),c=!0)},o(b){Oe(t.$$.fragment,b),Oe(u),Oe(s.$$.fragment,b),c=!1},d(b){b&&k(e),lt(t),u&&u.d(),lt(s)}}}function Yi(n,e,t){let l=null,i={};return vt(()=>{pt.checkAuth(),Yt.on("/",()=>{t(0,l=Qr),t(1,i={})}).on("/login",()=>{t(0,l=lo),t(1,i={})}).on("/brews",()=>{t(0,l=_o),t(1,i={})}).on("/brews/new",()=>{t(0,l=vl),t(1,i={mode:"create"})}).on("/brews/:id/edit",r=>{t(0,l=vl),t(1,i={...r,mode:"edit"})}).on("/brews/:did/:rkey",r=>{t(0,l=il),t(1,i=r)}).on("/brews/:id",r=>{t(0,l=il),t(1,i=r)}).on("/manage",()=>{t(0,l=di),t(1,i={})}).on("/profile/:actor",r=>{t(0,l=Ti),t(1,i=r)}).on("/about",()=>{t(0,l=Mi),t(1,i={})}).on("/terms",()=>{t(0,l=Pi),t(1,i={})}).on("*",()=>{t(0,l=Fi),t(1,i={})}),Yt.listen(),Yt.route(window.location.pathname)}),[l,i]}class Vi extends Xe{constructor(e){super(),Qe(this,e,Yi,qi,We,{})}}new Vi({target:document.getElementById("app")});
+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>
+1 -1
tailwind.config.js
··· 1 1 /** @type {import('tailwindcss').Config} */ 2 2 module.exports = { 3 - content: ["./templates/**/*.tmpl", "./static/**/*.{html,js}"], 3 + content: ["./static/**/*.{html,js}"], 4 4 theme: { 5 5 extend: { 6 6 colors: {