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

refactor: svelte frontend rewrite #1

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

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:hm5f3dnm6jdhrc55qp2npdja/sh.tangled.repo.pull/3mdercwj5qh22
+7053 -185
Diff #0
+3
.gitignore
··· 48 48 49 49 # Development files 50 50 known-dids.txt 51 + 52 + node_modules 53 + web/static/app/assets
+1
BACKLOG.md
··· 16 16 - Private mode -- don't show in community feed (records are still public via pds api though) 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 + - Toggle for "for" and "at" in pours view 19 20 20 21 ## Far Future Considerations 21 22
+4 -4
CLAUDE.md
··· 14 14 ## Project Structure 15 15 16 16 ``` 17 - cmd/server/main.go # Application entry point 17 + cmd/arabica-server/main.go # Application entry point 18 18 internal/ 19 19 atproto/ # AT Protocol integration 20 20 client.go # Authenticated PDS client (XRPC calls) ··· 112 112 113 113 ```bash 114 114 # Run server (uses firehose mode by default) 115 - go run cmd/server/main.go 115 + go run cmd/arabica-server/main.go 116 116 117 117 # Backfill known DIDs on startup 118 - go run cmd/server/main.go --known-dids known-dids.txt 118 + go run cmd/arabica-server/main.go --known-dids known-dids.txt 119 119 120 120 # Using nix 121 121 nix run ··· 130 130 ### Build 131 131 132 132 ```bash 133 - go build -o arabica cmd/server/main.go 133 + go build -o arabica cmd/arabica-server/main.go 134 134 ``` 135 135 136 136 ## Command-Line Flags
+1 -1
Dockerfile
··· 14 14 COPY . . 15 15 16 16 # Build the binary 17 - RUN CGO_ENABLED=0 GOOS=linux go build -o arabica cmd/server/main.go 17 + RUN CGO_ENABLED=0 GOOS=linux go build -o arabica cmd/arabica-server/main.go 18 18 19 19 # Runtime stage 20 20 FROM alpine:3.23
+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 + └── web/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 `web/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 `web/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 + web/static/js/alpine.min.js 163 + web/static/js/manage-page.js 164 + web/static/js/brew-form.js 165 + web/static/js/data-cache.js 166 + web/static/js/handle-autocomplete.js 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 -3
README.md
··· 32 32 nix run 33 33 34 34 # Or with Go 35 - go run cmd/server/main.go 35 + go run cmd/arabica-server/main.go 36 36 ``` 37 37 38 38 Access at http://localhost:18910 ··· 93 93 nix develop 94 94 95 95 # Run server 96 - go run cmd/server/main.go 96 + go run cmd/arabica-server/main.go 97 97 98 98 # Run tests 99 99 go test ./... 100 100 101 101 # Build 102 - go build -o arabica cmd/server/main.go 102 + go build -o arabica cmd/arabica-server/main.go 103 103 ``` 104 104 105 105 ## Deployment
cmd/server/logging_test.go cmd/arabica-server/logging_test.go
cmd/server/main.go cmd/arabica-server/main.go
cmd/server/main_test.go cmd/arabica-server/main_test.go
+1 -1
default.nix
··· 14 14 15 15 buildPhase = '' 16 16 runHook preBuild 17 - go build -o arabica cmd/server/main.go 17 + go build -o arabica cmd/arabica-server/main.go 18 18 runHook postBuild 19 19 ''; 20 20
+26
frontend/index.html
··· 1 + <!DOCTYPE html> 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> 26 + </html>
+1326
frontend/package-lock.json
··· 1 + { 2 + "name": "arabica-frontend", 3 + "version": "0.1.0", 4 + "lockfileVersion": 3, 5 + "requires": true, 6 + "packages": { 7 + "": { 8 + "name": "arabica-frontend", 9 + "version": "0.1.0", 10 + "dependencies": { 11 + "navaid": "^1.0.9" 12 + }, 13 + "devDependencies": { 14 + "@sveltejs/vite-plugin-svelte": "^3.0.0", 15 + "svelte": "^4.2.0", 16 + "vite": "^5.0.0" 17 + } 18 + }, 19 + "node_modules/@ampproject/remapping": { 20 + "version": "2.3.0", 21 + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", 22 + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", 23 + "dev": true, 24 + "license": "Apache-2.0", 25 + "dependencies": { 26 + "@jridgewell/gen-mapping": "^0.3.5", 27 + "@jridgewell/trace-mapping": "^0.3.24" 28 + }, 29 + "engines": { 30 + "node": ">=6.0.0" 31 + } 32 + }, 33 + "node_modules/@esbuild/aix-ppc64": { 34 + "version": "0.21.5", 35 + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", 36 + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", 37 + "cpu": [ 38 + "ppc64" 39 + ], 40 + "dev": true, 41 + "license": "MIT", 42 + "optional": true, 43 + "os": [ 44 + "aix" 45 + ], 46 + "engines": { 47 + "node": ">=12" 48 + } 49 + }, 50 + "node_modules/@esbuild/android-arm": { 51 + "version": "0.21.5", 52 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", 53 + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", 54 + "cpu": [ 55 + "arm" 56 + ], 57 + "dev": true, 58 + "license": "MIT", 59 + "optional": true, 60 + "os": [ 61 + "android" 62 + ], 63 + "engines": { 64 + "node": ">=12" 65 + } 66 + }, 67 + "node_modules/@esbuild/android-arm64": { 68 + "version": "0.21.5", 69 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", 70 + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", 71 + "cpu": [ 72 + "arm64" 73 + ], 74 + "dev": true, 75 + "license": "MIT", 76 + "optional": true, 77 + "os": [ 78 + "android" 79 + ], 80 + "engines": { 81 + "node": ">=12" 82 + } 83 + }, 84 + "node_modules/@esbuild/android-x64": { 85 + "version": "0.21.5", 86 + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", 87 + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", 88 + "cpu": [ 89 + "x64" 90 + ], 91 + "dev": true, 92 + "license": "MIT", 93 + "optional": true, 94 + "os": [ 95 + "android" 96 + ], 97 + "engines": { 98 + "node": ">=12" 99 + } 100 + }, 101 + "node_modules/@esbuild/darwin-arm64": { 102 + "version": "0.21.5", 103 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", 104 + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", 105 + "cpu": [ 106 + "arm64" 107 + ], 108 + "dev": true, 109 + "license": "MIT", 110 + "optional": true, 111 + "os": [ 112 + "darwin" 113 + ], 114 + "engines": { 115 + "node": ">=12" 116 + } 117 + }, 118 + "node_modules/@esbuild/darwin-x64": { 119 + "version": "0.21.5", 120 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", 121 + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", 122 + "cpu": [ 123 + "x64" 124 + ], 125 + "dev": true, 126 + "license": "MIT", 127 + "optional": true, 128 + "os": [ 129 + "darwin" 130 + ], 131 + "engines": { 132 + "node": ">=12" 133 + } 134 + }, 135 + "node_modules/@esbuild/freebsd-arm64": { 136 + "version": "0.21.5", 137 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", 138 + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", 139 + "cpu": [ 140 + "arm64" 141 + ], 142 + "dev": true, 143 + "license": "MIT", 144 + "optional": true, 145 + "os": [ 146 + "freebsd" 147 + ], 148 + "engines": { 149 + "node": ">=12" 150 + } 151 + }, 152 + "node_modules/@esbuild/freebsd-x64": { 153 + "version": "0.21.5", 154 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", 155 + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", 156 + "cpu": [ 157 + "x64" 158 + ], 159 + "dev": true, 160 + "license": "MIT", 161 + "optional": true, 162 + "os": [ 163 + "freebsd" 164 + ], 165 + "engines": { 166 + "node": ">=12" 167 + } 168 + }, 169 + "node_modules/@esbuild/linux-arm": { 170 + "version": "0.21.5", 171 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", 172 + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", 173 + "cpu": [ 174 + "arm" 175 + ], 176 + "dev": true, 177 + "license": "MIT", 178 + "optional": true, 179 + "os": [ 180 + "linux" 181 + ], 182 + "engines": { 183 + "node": ">=12" 184 + } 185 + }, 186 + "node_modules/@esbuild/linux-arm64": { 187 + "version": "0.21.5", 188 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", 189 + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", 190 + "cpu": [ 191 + "arm64" 192 + ], 193 + "dev": true, 194 + "license": "MIT", 195 + "optional": true, 196 + "os": [ 197 + "linux" 198 + ], 199 + "engines": { 200 + "node": ">=12" 201 + } 202 + }, 203 + "node_modules/@esbuild/linux-ia32": { 204 + "version": "0.21.5", 205 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", 206 + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", 207 + "cpu": [ 208 + "ia32" 209 + ], 210 + "dev": true, 211 + "license": "MIT", 212 + "optional": true, 213 + "os": [ 214 + "linux" 215 + ], 216 + "engines": { 217 + "node": ">=12" 218 + } 219 + }, 220 + "node_modules/@esbuild/linux-loong64": { 221 + "version": "0.21.5", 222 + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", 223 + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", 224 + "cpu": [ 225 + "loong64" 226 + ], 227 + "dev": true, 228 + "license": "MIT", 229 + "optional": true, 230 + "os": [ 231 + "linux" 232 + ], 233 + "engines": { 234 + "node": ">=12" 235 + } 236 + }, 237 + "node_modules/@esbuild/linux-mips64el": { 238 + "version": "0.21.5", 239 + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", 240 + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", 241 + "cpu": [ 242 + "mips64el" 243 + ], 244 + "dev": true, 245 + "license": "MIT", 246 + "optional": true, 247 + "os": [ 248 + "linux" 249 + ], 250 + "engines": { 251 + "node": ">=12" 252 + } 253 + }, 254 + "node_modules/@esbuild/linux-ppc64": { 255 + "version": "0.21.5", 256 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", 257 + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", 258 + "cpu": [ 259 + "ppc64" 260 + ], 261 + "dev": true, 262 + "license": "MIT", 263 + "optional": true, 264 + "os": [ 265 + "linux" 266 + ], 267 + "engines": { 268 + "node": ">=12" 269 + } 270 + }, 271 + "node_modules/@esbuild/linux-riscv64": { 272 + "version": "0.21.5", 273 + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", 274 + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", 275 + "cpu": [ 276 + "riscv64" 277 + ], 278 + "dev": true, 279 + "license": "MIT", 280 + "optional": true, 281 + "os": [ 282 + "linux" 283 + ], 284 + "engines": { 285 + "node": ">=12" 286 + } 287 + }, 288 + "node_modules/@esbuild/linux-s390x": { 289 + "version": "0.21.5", 290 + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", 291 + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", 292 + "cpu": [ 293 + "s390x" 294 + ], 295 + "dev": true, 296 + "license": "MIT", 297 + "optional": true, 298 + "os": [ 299 + "linux" 300 + ], 301 + "engines": { 302 + "node": ">=12" 303 + } 304 + }, 305 + "node_modules/@esbuild/linux-x64": { 306 + "version": "0.21.5", 307 + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", 308 + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", 309 + "cpu": [ 310 + "x64" 311 + ], 312 + "dev": true, 313 + "license": "MIT", 314 + "optional": true, 315 + "os": [ 316 + "linux" 317 + ], 318 + "engines": { 319 + "node": ">=12" 320 + } 321 + }, 322 + "node_modules/@esbuild/netbsd-x64": { 323 + "version": "0.21.5", 324 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", 325 + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", 326 + "cpu": [ 327 + "x64" 328 + ], 329 + "dev": true, 330 + "license": "MIT", 331 + "optional": true, 332 + "os": [ 333 + "netbsd" 334 + ], 335 + "engines": { 336 + "node": ">=12" 337 + } 338 + }, 339 + "node_modules/@esbuild/openbsd-x64": { 340 + "version": "0.21.5", 341 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", 342 + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", 343 + "cpu": [ 344 + "x64" 345 + ], 346 + "dev": true, 347 + "license": "MIT", 348 + "optional": true, 349 + "os": [ 350 + "openbsd" 351 + ], 352 + "engines": { 353 + "node": ">=12" 354 + } 355 + }, 356 + "node_modules/@esbuild/sunos-x64": { 357 + "version": "0.21.5", 358 + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", 359 + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", 360 + "cpu": [ 361 + "x64" 362 + ], 363 + "dev": true, 364 + "license": "MIT", 365 + "optional": true, 366 + "os": [ 367 + "sunos" 368 + ], 369 + "engines": { 370 + "node": ">=12" 371 + } 372 + }, 373 + "node_modules/@esbuild/win32-arm64": { 374 + "version": "0.21.5", 375 + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", 376 + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", 377 + "cpu": [ 378 + "arm64" 379 + ], 380 + "dev": true, 381 + "license": "MIT", 382 + "optional": true, 383 + "os": [ 384 + "win32" 385 + ], 386 + "engines": { 387 + "node": ">=12" 388 + } 389 + }, 390 + "node_modules/@esbuild/win32-ia32": { 391 + "version": "0.21.5", 392 + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", 393 + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", 394 + "cpu": [ 395 + "ia32" 396 + ], 397 + "dev": true, 398 + "license": "MIT", 399 + "optional": true, 400 + "os": [ 401 + "win32" 402 + ], 403 + "engines": { 404 + "node": ">=12" 405 + } 406 + }, 407 + "node_modules/@esbuild/win32-x64": { 408 + "version": "0.21.5", 409 + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", 410 + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", 411 + "cpu": [ 412 + "x64" 413 + ], 414 + "dev": true, 415 + "license": "MIT", 416 + "optional": true, 417 + "os": [ 418 + "win32" 419 + ], 420 + "engines": { 421 + "node": ">=12" 422 + } 423 + }, 424 + "node_modules/@jridgewell/gen-mapping": { 425 + "version": "0.3.13", 426 + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", 427 + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", 428 + "dev": true, 429 + "license": "MIT", 430 + "dependencies": { 431 + "@jridgewell/sourcemap-codec": "^1.5.0", 432 + "@jridgewell/trace-mapping": "^0.3.24" 433 + } 434 + }, 435 + "node_modules/@jridgewell/resolve-uri": { 436 + "version": "3.1.2", 437 + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", 438 + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", 439 + "dev": true, 440 + "license": "MIT", 441 + "engines": { 442 + "node": ">=6.0.0" 443 + } 444 + }, 445 + "node_modules/@jridgewell/sourcemap-codec": { 446 + "version": "1.5.5", 447 + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", 448 + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", 449 + "dev": true, 450 + "license": "MIT" 451 + }, 452 + "node_modules/@jridgewell/trace-mapping": { 453 + "version": "0.3.31", 454 + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", 455 + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", 456 + "dev": true, 457 + "license": "MIT", 458 + "dependencies": { 459 + "@jridgewell/resolve-uri": "^3.1.0", 460 + "@jridgewell/sourcemap-codec": "^1.4.14" 461 + } 462 + }, 463 + "node_modules/@rollup/rollup-android-arm-eabi": { 464 + "version": "4.56.0", 465 + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.56.0.tgz", 466 + "integrity": "sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw==", 467 + "cpu": [ 468 + "arm" 469 + ], 470 + "dev": true, 471 + "license": "MIT", 472 + "optional": true, 473 + "os": [ 474 + "android" 475 + ] 476 + }, 477 + "node_modules/@rollup/rollup-android-arm64": { 478 + "version": "4.56.0", 479 + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.56.0.tgz", 480 + "integrity": "sha512-lfbVUbelYqXlYiU/HApNMJzT1E87UPGvzveGg2h0ktUNlOCxKlWuJ9jtfvs1sKHdwU4fzY7Pl8sAl49/XaEk6Q==", 481 + "cpu": [ 482 + "arm64" 483 + ], 484 + "dev": true, 485 + "license": "MIT", 486 + "optional": true, 487 + "os": [ 488 + "android" 489 + ] 490 + }, 491 + "node_modules/@rollup/rollup-darwin-arm64": { 492 + "version": "4.56.0", 493 + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.56.0.tgz", 494 + "integrity": "sha512-EgxD1ocWfhoD6xSOeEEwyE7tDvwTgZc8Bss7wCWe+uc7wO8G34HHCUH+Q6cHqJubxIAnQzAsyUsClt0yFLu06w==", 495 + "cpu": [ 496 + "arm64" 497 + ], 498 + "dev": true, 499 + "license": "MIT", 500 + "optional": true, 501 + "os": [ 502 + "darwin" 503 + ] 504 + }, 505 + "node_modules/@rollup/rollup-darwin-x64": { 506 + "version": "4.56.0", 507 + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.56.0.tgz", 508 + "integrity": "sha512-1vXe1vcMOssb/hOF8iv52A7feWW2xnu+c8BV4t1F//m9QVLTfNVpEdja5ia762j/UEJe2Z1jAmEqZAK42tVW3g==", 509 + "cpu": [ 510 + "x64" 511 + ], 512 + "dev": true, 513 + "license": "MIT", 514 + "optional": true, 515 + "os": [ 516 + "darwin" 517 + ] 518 + }, 519 + "node_modules/@rollup/rollup-freebsd-arm64": { 520 + "version": "4.56.0", 521 + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.56.0.tgz", 522 + "integrity": "sha512-bof7fbIlvqsyv/DtaXSck4VYQ9lPtoWNFCB/JY4snlFuJREXfZnm+Ej6yaCHfQvofJDXLDMTVxWscVSuQvVWUQ==", 523 + "cpu": [ 524 + "arm64" 525 + ], 526 + "dev": true, 527 + "license": "MIT", 528 + "optional": true, 529 + "os": [ 530 + "freebsd" 531 + ] 532 + }, 533 + "node_modules/@rollup/rollup-freebsd-x64": { 534 + "version": "4.56.0", 535 + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.56.0.tgz", 536 + "integrity": "sha512-KNa6lYHloW+7lTEkYGa37fpvPq+NKG/EHKM8+G/g9WDU7ls4sMqbVRV78J6LdNuVaeeK5WB9/9VAFbKxcbXKYg==", 537 + "cpu": [ 538 + "x64" 539 + ], 540 + "dev": true, 541 + "license": "MIT", 542 + "optional": true, 543 + "os": [ 544 + "freebsd" 545 + ] 546 + }, 547 + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { 548 + "version": "4.56.0", 549 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.56.0.tgz", 550 + "integrity": "sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==", 551 + "cpu": [ 552 + "arm" 553 + ], 554 + "dev": true, 555 + "license": "MIT", 556 + "optional": true, 557 + "os": [ 558 + "linux" 559 + ] 560 + }, 561 + "node_modules/@rollup/rollup-linux-arm-musleabihf": { 562 + "version": "4.56.0", 563 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.56.0.tgz", 564 + "integrity": "sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==", 565 + "cpu": [ 566 + "arm" 567 + ], 568 + "dev": true, 569 + "license": "MIT", 570 + "optional": true, 571 + "os": [ 572 + "linux" 573 + ] 574 + }, 575 + "node_modules/@rollup/rollup-linux-arm64-gnu": { 576 + "version": "4.56.0", 577 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.56.0.tgz", 578 + "integrity": "sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==", 579 + "cpu": [ 580 + "arm64" 581 + ], 582 + "dev": true, 583 + "license": "MIT", 584 + "optional": true, 585 + "os": [ 586 + "linux" 587 + ] 588 + }, 589 + "node_modules/@rollup/rollup-linux-arm64-musl": { 590 + "version": "4.56.0", 591 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.56.0.tgz", 592 + "integrity": "sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==", 593 + "cpu": [ 594 + "arm64" 595 + ], 596 + "dev": true, 597 + "license": "MIT", 598 + "optional": true, 599 + "os": [ 600 + "linux" 601 + ] 602 + }, 603 + "node_modules/@rollup/rollup-linux-loong64-gnu": { 604 + "version": "4.56.0", 605 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.56.0.tgz", 606 + "integrity": "sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==", 607 + "cpu": [ 608 + "loong64" 609 + ], 610 + "dev": true, 611 + "license": "MIT", 612 + "optional": true, 613 + "os": [ 614 + "linux" 615 + ] 616 + }, 617 + "node_modules/@rollup/rollup-linux-loong64-musl": { 618 + "version": "4.56.0", 619 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.56.0.tgz", 620 + "integrity": "sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==", 621 + "cpu": [ 622 + "loong64" 623 + ], 624 + "dev": true, 625 + "license": "MIT", 626 + "optional": true, 627 + "os": [ 628 + "linux" 629 + ] 630 + }, 631 + "node_modules/@rollup/rollup-linux-ppc64-gnu": { 632 + "version": "4.56.0", 633 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.56.0.tgz", 634 + "integrity": "sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==", 635 + "cpu": [ 636 + "ppc64" 637 + ], 638 + "dev": true, 639 + "license": "MIT", 640 + "optional": true, 641 + "os": [ 642 + "linux" 643 + ] 644 + }, 645 + "node_modules/@rollup/rollup-linux-ppc64-musl": { 646 + "version": "4.56.0", 647 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.56.0.tgz", 648 + "integrity": "sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==", 649 + "cpu": [ 650 + "ppc64" 651 + ], 652 + "dev": true, 653 + "license": "MIT", 654 + "optional": true, 655 + "os": [ 656 + "linux" 657 + ] 658 + }, 659 + "node_modules/@rollup/rollup-linux-riscv64-gnu": { 660 + "version": "4.56.0", 661 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.56.0.tgz", 662 + "integrity": "sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==", 663 + "cpu": [ 664 + "riscv64" 665 + ], 666 + "dev": true, 667 + "license": "MIT", 668 + "optional": true, 669 + "os": [ 670 + "linux" 671 + ] 672 + }, 673 + "node_modules/@rollup/rollup-linux-riscv64-musl": { 674 + "version": "4.56.0", 675 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.56.0.tgz", 676 + "integrity": "sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==", 677 + "cpu": [ 678 + "riscv64" 679 + ], 680 + "dev": true, 681 + "license": "MIT", 682 + "optional": true, 683 + "os": [ 684 + "linux" 685 + ] 686 + }, 687 + "node_modules/@rollup/rollup-linux-s390x-gnu": { 688 + "version": "4.56.0", 689 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.56.0.tgz", 690 + "integrity": "sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==", 691 + "cpu": [ 692 + "s390x" 693 + ], 694 + "dev": true, 695 + "license": "MIT", 696 + "optional": true, 697 + "os": [ 698 + "linux" 699 + ] 700 + }, 701 + "node_modules/@rollup/rollup-linux-x64-gnu": { 702 + "version": "4.56.0", 703 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.56.0.tgz", 704 + "integrity": "sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==", 705 + "cpu": [ 706 + "x64" 707 + ], 708 + "dev": true, 709 + "license": "MIT", 710 + "optional": true, 711 + "os": [ 712 + "linux" 713 + ] 714 + }, 715 + "node_modules/@rollup/rollup-linux-x64-musl": { 716 + "version": "4.56.0", 717 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.56.0.tgz", 718 + "integrity": "sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==", 719 + "cpu": [ 720 + "x64" 721 + ], 722 + "dev": true, 723 + "license": "MIT", 724 + "optional": true, 725 + "os": [ 726 + "linux" 727 + ] 728 + }, 729 + "node_modules/@rollup/rollup-openbsd-x64": { 730 + "version": "4.56.0", 731 + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.56.0.tgz", 732 + "integrity": "sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==", 733 + "cpu": [ 734 + "x64" 735 + ], 736 + "dev": true, 737 + "license": "MIT", 738 + "optional": true, 739 + "os": [ 740 + "openbsd" 741 + ] 742 + }, 743 + "node_modules/@rollup/rollup-openharmony-arm64": { 744 + "version": "4.56.0", 745 + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.56.0.tgz", 746 + "integrity": "sha512-LhN/Reh+7F3RCgQIRbgw8ZMwUwyqJM+8pXNT6IIJAqm2IdKkzpCh/V9EdgOMBKuebIrzswqy4ATlrDgiOwbRcQ==", 747 + "cpu": [ 748 + "arm64" 749 + ], 750 + "dev": true, 751 + "license": "MIT", 752 + "optional": true, 753 + "os": [ 754 + "openharmony" 755 + ] 756 + }, 757 + "node_modules/@rollup/rollup-win32-arm64-msvc": { 758 + "version": "4.56.0", 759 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.56.0.tgz", 760 + "integrity": "sha512-kbFsOObXp3LBULg1d3JIUQMa9Kv4UitDmpS+k0tinPBz3watcUiV2/LUDMMucA6pZO3WGE27P7DsfaN54l9ing==", 761 + "cpu": [ 762 + "arm64" 763 + ], 764 + "dev": true, 765 + "license": "MIT", 766 + "optional": true, 767 + "os": [ 768 + "win32" 769 + ] 770 + }, 771 + "node_modules/@rollup/rollup-win32-ia32-msvc": { 772 + "version": "4.56.0", 773 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.56.0.tgz", 774 + "integrity": "sha512-vSSgny54D6P4vf2izbtFm/TcWYedw7f8eBrOiGGecyHyQB9q4Kqentjaj8hToe+995nob/Wv48pDqL5a62EWtg==", 775 + "cpu": [ 776 + "ia32" 777 + ], 778 + "dev": true, 779 + "license": "MIT", 780 + "optional": true, 781 + "os": [ 782 + "win32" 783 + ] 784 + }, 785 + "node_modules/@rollup/rollup-win32-x64-gnu": { 786 + "version": "4.56.0", 787 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.56.0.tgz", 788 + "integrity": "sha512-FeCnkPCTHQJFbiGG49KjV5YGW/8b9rrXAM2Mz2kiIoktq2qsJxRD5giEMEOD2lPdgs72upzefaUvS+nc8E3UzQ==", 789 + "cpu": [ 790 + "x64" 791 + ], 792 + "dev": true, 793 + "license": "MIT", 794 + "optional": true, 795 + "os": [ 796 + "win32" 797 + ] 798 + }, 799 + "node_modules/@rollup/rollup-win32-x64-msvc": { 800 + "version": "4.56.0", 801 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.56.0.tgz", 802 + "integrity": "sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g==", 803 + "cpu": [ 804 + "x64" 805 + ], 806 + "dev": true, 807 + "license": "MIT", 808 + "optional": true, 809 + "os": [ 810 + "win32" 811 + ] 812 + }, 813 + "node_modules/@sveltejs/vite-plugin-svelte": { 814 + "version": "3.1.2", 815 + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-3.1.2.tgz", 816 + "integrity": "sha512-Txsm1tJvtiYeLUVRNqxZGKR/mI+CzuIQuc2gn+YCs9rMTowpNZ2Nqt53JdL8KF9bLhAf2ruR/dr9eZCwdTriRA==", 817 + "dev": true, 818 + "license": "MIT", 819 + "peer": true, 820 + "dependencies": { 821 + "@sveltejs/vite-plugin-svelte-inspector": "^2.1.0", 822 + "debug": "^4.3.4", 823 + "deepmerge": "^4.3.1", 824 + "kleur": "^4.1.5", 825 + "magic-string": "^0.30.10", 826 + "svelte-hmr": "^0.16.0", 827 + "vitefu": "^0.2.5" 828 + }, 829 + "engines": { 830 + "node": "^18.0.0 || >=20" 831 + }, 832 + "peerDependencies": { 833 + "svelte": "^4.0.0 || ^5.0.0-next.0", 834 + "vite": "^5.0.0" 835 + } 836 + }, 837 + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { 838 + "version": "2.1.0", 839 + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-2.1.0.tgz", 840 + "integrity": "sha512-9QX28IymvBlSCqsCll5t0kQVxipsfhFFL+L2t3nTWfXnddYwxBuAEtTtlaVQpRz9c37BhJjltSeY4AJSC03SSg==", 841 + "dev": true, 842 + "license": "MIT", 843 + "dependencies": { 844 + "debug": "^4.3.4" 845 + }, 846 + "engines": { 847 + "node": "^18.0.0 || >=20" 848 + }, 849 + "peerDependencies": { 850 + "@sveltejs/vite-plugin-svelte": "^3.0.0", 851 + "svelte": "^4.0.0 || ^5.0.0-next.0", 852 + "vite": "^5.0.0" 853 + } 854 + }, 855 + "node_modules/@types/estree": { 856 + "version": "1.0.8", 857 + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", 858 + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", 859 + "dev": true, 860 + "license": "MIT" 861 + }, 862 + "node_modules/acorn": { 863 + "version": "8.15.0", 864 + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", 865 + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", 866 + "dev": true, 867 + "license": "MIT", 868 + "bin": { 869 + "acorn": "bin/acorn" 870 + }, 871 + "engines": { 872 + "node": ">=0.4.0" 873 + } 874 + }, 875 + "node_modules/aria-query": { 876 + "version": "5.3.2", 877 + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", 878 + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", 879 + "dev": true, 880 + "license": "Apache-2.0", 881 + "engines": { 882 + "node": ">= 0.4" 883 + } 884 + }, 885 + "node_modules/axobject-query": { 886 + "version": "4.1.0", 887 + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", 888 + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", 889 + "dev": true, 890 + "license": "Apache-2.0", 891 + "engines": { 892 + "node": ">= 0.4" 893 + } 894 + }, 895 + "node_modules/code-red": { 896 + "version": "1.0.4", 897 + "resolved": "https://registry.npmjs.org/code-red/-/code-red-1.0.4.tgz", 898 + "integrity": "sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==", 899 + "dev": true, 900 + "license": "MIT", 901 + "dependencies": { 902 + "@jridgewell/sourcemap-codec": "^1.4.15", 903 + "@types/estree": "^1.0.1", 904 + "acorn": "^8.10.0", 905 + "estree-walker": "^3.0.3", 906 + "periscopic": "^3.1.0" 907 + } 908 + }, 909 + "node_modules/css-tree": { 910 + "version": "2.3.1", 911 + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", 912 + "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==", 913 + "dev": true, 914 + "license": "MIT", 915 + "dependencies": { 916 + "mdn-data": "2.0.30", 917 + "source-map-js": "^1.0.1" 918 + }, 919 + "engines": { 920 + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" 921 + } 922 + }, 923 + "node_modules/debug": { 924 + "version": "4.4.3", 925 + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", 926 + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", 927 + "dev": true, 928 + "license": "MIT", 929 + "dependencies": { 930 + "ms": "^2.1.3" 931 + }, 932 + "engines": { 933 + "node": ">=6.0" 934 + }, 935 + "peerDependenciesMeta": { 936 + "supports-color": { 937 + "optional": true 938 + } 939 + } 940 + }, 941 + "node_modules/deepmerge": { 942 + "version": "4.3.1", 943 + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", 944 + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", 945 + "dev": true, 946 + "license": "MIT", 947 + "engines": { 948 + "node": ">=0.10.0" 949 + } 950 + }, 951 + "node_modules/esbuild": { 952 + "version": "0.21.5", 953 + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", 954 + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", 955 + "dev": true, 956 + "hasInstallScript": true, 957 + "license": "MIT", 958 + "bin": { 959 + "esbuild": "bin/esbuild" 960 + }, 961 + "engines": { 962 + "node": ">=12" 963 + }, 964 + "optionalDependencies": { 965 + "@esbuild/aix-ppc64": "0.21.5", 966 + "@esbuild/android-arm": "0.21.5", 967 + "@esbuild/android-arm64": "0.21.5", 968 + "@esbuild/android-x64": "0.21.5", 969 + "@esbuild/darwin-arm64": "0.21.5", 970 + "@esbuild/darwin-x64": "0.21.5", 971 + "@esbuild/freebsd-arm64": "0.21.5", 972 + "@esbuild/freebsd-x64": "0.21.5", 973 + "@esbuild/linux-arm": "0.21.5", 974 + "@esbuild/linux-arm64": "0.21.5", 975 + "@esbuild/linux-ia32": "0.21.5", 976 + "@esbuild/linux-loong64": "0.21.5", 977 + "@esbuild/linux-mips64el": "0.21.5", 978 + "@esbuild/linux-ppc64": "0.21.5", 979 + "@esbuild/linux-riscv64": "0.21.5", 980 + "@esbuild/linux-s390x": "0.21.5", 981 + "@esbuild/linux-x64": "0.21.5", 982 + "@esbuild/netbsd-x64": "0.21.5", 983 + "@esbuild/openbsd-x64": "0.21.5", 984 + "@esbuild/sunos-x64": "0.21.5", 985 + "@esbuild/win32-arm64": "0.21.5", 986 + "@esbuild/win32-ia32": "0.21.5", 987 + "@esbuild/win32-x64": "0.21.5" 988 + } 989 + }, 990 + "node_modules/estree-walker": { 991 + "version": "3.0.3", 992 + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", 993 + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", 994 + "dev": true, 995 + "license": "MIT", 996 + "dependencies": { 997 + "@types/estree": "^1.0.0" 998 + } 999 + }, 1000 + "node_modules/fsevents": { 1001 + "version": "2.3.3", 1002 + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", 1003 + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", 1004 + "dev": true, 1005 + "hasInstallScript": true, 1006 + "license": "MIT", 1007 + "optional": true, 1008 + "os": [ 1009 + "darwin" 1010 + ], 1011 + "engines": { 1012 + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 1013 + } 1014 + }, 1015 + "node_modules/is-reference": { 1016 + "version": "3.0.3", 1017 + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", 1018 + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", 1019 + "dev": true, 1020 + "license": "MIT", 1021 + "dependencies": { 1022 + "@types/estree": "^1.0.6" 1023 + } 1024 + }, 1025 + "node_modules/kleur": { 1026 + "version": "4.1.5", 1027 + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", 1028 + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", 1029 + "dev": true, 1030 + "license": "MIT", 1031 + "engines": { 1032 + "node": ">=6" 1033 + } 1034 + }, 1035 + "node_modules/locate-character": { 1036 + "version": "3.0.0", 1037 + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", 1038 + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", 1039 + "dev": true, 1040 + "license": "MIT" 1041 + }, 1042 + "node_modules/magic-string": { 1043 + "version": "0.30.21", 1044 + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", 1045 + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", 1046 + "dev": true, 1047 + "license": "MIT", 1048 + "dependencies": { 1049 + "@jridgewell/sourcemap-codec": "^1.5.5" 1050 + } 1051 + }, 1052 + "node_modules/mdn-data": { 1053 + "version": "2.0.30", 1054 + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", 1055 + "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==", 1056 + "dev": true, 1057 + "license": "CC0-1.0" 1058 + }, 1059 + "node_modules/ms": { 1060 + "version": "2.1.3", 1061 + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", 1062 + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", 1063 + "dev": true, 1064 + "license": "MIT" 1065 + }, 1066 + "node_modules/nanoid": { 1067 + "version": "3.3.11", 1068 + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", 1069 + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", 1070 + "dev": true, 1071 + "funding": [ 1072 + { 1073 + "type": "github", 1074 + "url": "https://github.com/sponsors/ai" 1075 + } 1076 + ], 1077 + "license": "MIT", 1078 + "bin": { 1079 + "nanoid": "bin/nanoid.cjs" 1080 + }, 1081 + "engines": { 1082 + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" 1083 + } 1084 + }, 1085 + "node_modules/navaid": { 1086 + "version": "1.2.0", 1087 + "resolved": "https://registry.npmjs.org/navaid/-/navaid-1.2.0.tgz", 1088 + "integrity": "sha512-Yh5mix394WrT5go29GFeFD4Gp4W0Xj1Ejs0KHXXCA24KKW74pq3PY3fwP3o18KveYO/pjUI2zzcAAp8kY98aNA==", 1089 + "license": "MIT", 1090 + "dependencies": { 1091 + "regexparam": "^1.0.2" 1092 + }, 1093 + "engines": { 1094 + "node": ">= 6" 1095 + } 1096 + }, 1097 + "node_modules/periscopic": { 1098 + "version": "3.1.0", 1099 + "resolved": "https://registry.npmjs.org/periscopic/-/periscopic-3.1.0.tgz", 1100 + "integrity": "sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==", 1101 + "dev": true, 1102 + "license": "MIT", 1103 + "dependencies": { 1104 + "@types/estree": "^1.0.0", 1105 + "estree-walker": "^3.0.0", 1106 + "is-reference": "^3.0.0" 1107 + } 1108 + }, 1109 + "node_modules/picocolors": { 1110 + "version": "1.1.1", 1111 + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", 1112 + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", 1113 + "dev": true, 1114 + "license": "ISC" 1115 + }, 1116 + "node_modules/postcss": { 1117 + "version": "8.5.6", 1118 + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", 1119 + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", 1120 + "dev": true, 1121 + "funding": [ 1122 + { 1123 + "type": "opencollective", 1124 + "url": "https://opencollective.com/postcss/" 1125 + }, 1126 + { 1127 + "type": "tidelift", 1128 + "url": "https://tidelift.com/funding/github/npm/postcss" 1129 + }, 1130 + { 1131 + "type": "github", 1132 + "url": "https://github.com/sponsors/ai" 1133 + } 1134 + ], 1135 + "license": "MIT", 1136 + "dependencies": { 1137 + "nanoid": "^3.3.11", 1138 + "picocolors": "^1.1.1", 1139 + "source-map-js": "^1.2.1" 1140 + }, 1141 + "engines": { 1142 + "node": "^10 || ^12 || >=14" 1143 + } 1144 + }, 1145 + "node_modules/regexparam": { 1146 + "version": "1.3.0", 1147 + "resolved": "https://registry.npmjs.org/regexparam/-/regexparam-1.3.0.tgz", 1148 + "integrity": "sha512-6IQpFBv6e5vz1QAqI+V4k8P2e/3gRrqfCJ9FI+O1FLQTO+Uz6RXZEZOPmTJ6hlGj7gkERzY5BRCv09whKP96/g==", 1149 + "license": "MIT", 1150 + "engines": { 1151 + "node": ">=6" 1152 + } 1153 + }, 1154 + "node_modules/rollup": { 1155 + "version": "4.56.0", 1156 + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.56.0.tgz", 1157 + "integrity": "sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg==", 1158 + "dev": true, 1159 + "license": "MIT", 1160 + "dependencies": { 1161 + "@types/estree": "1.0.8" 1162 + }, 1163 + "bin": { 1164 + "rollup": "dist/bin/rollup" 1165 + }, 1166 + "engines": { 1167 + "node": ">=18.0.0", 1168 + "npm": ">=8.0.0" 1169 + }, 1170 + "optionalDependencies": { 1171 + "@rollup/rollup-android-arm-eabi": "4.56.0", 1172 + "@rollup/rollup-android-arm64": "4.56.0", 1173 + "@rollup/rollup-darwin-arm64": "4.56.0", 1174 + "@rollup/rollup-darwin-x64": "4.56.0", 1175 + "@rollup/rollup-freebsd-arm64": "4.56.0", 1176 + "@rollup/rollup-freebsd-x64": "4.56.0", 1177 + "@rollup/rollup-linux-arm-gnueabihf": "4.56.0", 1178 + "@rollup/rollup-linux-arm-musleabihf": "4.56.0", 1179 + "@rollup/rollup-linux-arm64-gnu": "4.56.0", 1180 + "@rollup/rollup-linux-arm64-musl": "4.56.0", 1181 + "@rollup/rollup-linux-loong64-gnu": "4.56.0", 1182 + "@rollup/rollup-linux-loong64-musl": "4.56.0", 1183 + "@rollup/rollup-linux-ppc64-gnu": "4.56.0", 1184 + "@rollup/rollup-linux-ppc64-musl": "4.56.0", 1185 + "@rollup/rollup-linux-riscv64-gnu": "4.56.0", 1186 + "@rollup/rollup-linux-riscv64-musl": "4.56.0", 1187 + "@rollup/rollup-linux-s390x-gnu": "4.56.0", 1188 + "@rollup/rollup-linux-x64-gnu": "4.56.0", 1189 + "@rollup/rollup-linux-x64-musl": "4.56.0", 1190 + "@rollup/rollup-openbsd-x64": "4.56.0", 1191 + "@rollup/rollup-openharmony-arm64": "4.56.0", 1192 + "@rollup/rollup-win32-arm64-msvc": "4.56.0", 1193 + "@rollup/rollup-win32-ia32-msvc": "4.56.0", 1194 + "@rollup/rollup-win32-x64-gnu": "4.56.0", 1195 + "@rollup/rollup-win32-x64-msvc": "4.56.0", 1196 + "fsevents": "~2.3.2" 1197 + } 1198 + }, 1199 + "node_modules/source-map-js": { 1200 + "version": "1.2.1", 1201 + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", 1202 + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", 1203 + "dev": true, 1204 + "license": "BSD-3-Clause", 1205 + "engines": { 1206 + "node": ">=0.10.0" 1207 + } 1208 + }, 1209 + "node_modules/svelte": { 1210 + "version": "4.2.20", 1211 + "resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.20.tgz", 1212 + "integrity": "sha512-eeEgGc2DtiUil5ANdtd8vPwt9AgaMdnuUFnPft9F5oMvU/FHu5IHFic+p1dR/UOB7XU2mX2yHW+NcTch4DCh5Q==", 1213 + "dev": true, 1214 + "license": "MIT", 1215 + "peer": true, 1216 + "dependencies": { 1217 + "@ampproject/remapping": "^2.2.1", 1218 + "@jridgewell/sourcemap-codec": "^1.4.15", 1219 + "@jridgewell/trace-mapping": "^0.3.18", 1220 + "@types/estree": "^1.0.1", 1221 + "acorn": "^8.9.0", 1222 + "aria-query": "^5.3.0", 1223 + "axobject-query": "^4.0.0", 1224 + "code-red": "^1.0.3", 1225 + "css-tree": "^2.3.1", 1226 + "estree-walker": "^3.0.3", 1227 + "is-reference": "^3.0.1", 1228 + "locate-character": "^3.0.0", 1229 + "magic-string": "^0.30.4", 1230 + "periscopic": "^3.1.0" 1231 + }, 1232 + "engines": { 1233 + "node": ">=16" 1234 + } 1235 + }, 1236 + "node_modules/svelte-hmr": { 1237 + "version": "0.16.0", 1238 + "resolved": "https://registry.npmjs.org/svelte-hmr/-/svelte-hmr-0.16.0.tgz", 1239 + "integrity": "sha512-Gyc7cOS3VJzLlfj7wKS0ZnzDVdv3Pn2IuVeJPk9m2skfhcu5bq3wtIZyQGggr7/Iim5rH5cncyQft/kRLupcnA==", 1240 + "dev": true, 1241 + "license": "ISC", 1242 + "engines": { 1243 + "node": "^12.20 || ^14.13.1 || >= 16" 1244 + }, 1245 + "peerDependencies": { 1246 + "svelte": "^3.19.0 || ^4.0.0" 1247 + } 1248 + }, 1249 + "node_modules/vite": { 1250 + "version": "5.4.21", 1251 + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", 1252 + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", 1253 + "dev": true, 1254 + "license": "MIT", 1255 + "peer": true, 1256 + "dependencies": { 1257 + "esbuild": "^0.21.3", 1258 + "postcss": "^8.4.43", 1259 + "rollup": "^4.20.0" 1260 + }, 1261 + "bin": { 1262 + "vite": "bin/vite.js" 1263 + }, 1264 + "engines": { 1265 + "node": "^18.0.0 || >=20.0.0" 1266 + }, 1267 + "funding": { 1268 + "url": "https://github.com/vitejs/vite?sponsor=1" 1269 + }, 1270 + "optionalDependencies": { 1271 + "fsevents": "~2.3.3" 1272 + }, 1273 + "peerDependencies": { 1274 + "@types/node": "^18.0.0 || >=20.0.0", 1275 + "less": "*", 1276 + "lightningcss": "^1.21.0", 1277 + "sass": "*", 1278 + "sass-embedded": "*", 1279 + "stylus": "*", 1280 + "sugarss": "*", 1281 + "terser": "^5.4.0" 1282 + }, 1283 + "peerDependenciesMeta": { 1284 + "@types/node": { 1285 + "optional": true 1286 + }, 1287 + "less": { 1288 + "optional": true 1289 + }, 1290 + "lightningcss": { 1291 + "optional": true 1292 + }, 1293 + "sass": { 1294 + "optional": true 1295 + }, 1296 + "sass-embedded": { 1297 + "optional": true 1298 + }, 1299 + "stylus": { 1300 + "optional": true 1301 + }, 1302 + "sugarss": { 1303 + "optional": true 1304 + }, 1305 + "terser": { 1306 + "optional": true 1307 + } 1308 + } 1309 + }, 1310 + "node_modules/vitefu": { 1311 + "version": "0.2.5", 1312 + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-0.2.5.tgz", 1313 + "integrity": "sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==", 1314 + "dev": true, 1315 + "license": "MIT", 1316 + "peerDependencies": { 1317 + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0" 1318 + }, 1319 + "peerDependenciesMeta": { 1320 + "vite": { 1321 + "optional": true 1322 + } 1323 + } 1324 + } 1325 + } 1326 + }
+18
frontend/package.json
··· 1 + { 2 + "name": "arabica-frontend", 3 + "version": "0.1.0", 4 + "type": "module", 5 + "scripts": { 6 + "dev": "vite", 7 + "build": "vite build", 8 + "preview": "vite preview" 9 + }, 10 + "devDependencies": { 11 + "@sveltejs/vite-plugin-svelte": "^3.0.0", 12 + "svelte": "^4.2.0", 13 + "vite": "^5.0.0" 14 + }, 15 + "dependencies": { 16 + "navaid": "^1.0.9" 17 + } 18 + }
+90
frontend/src/App.svelte
··· 1 + <script> 2 + import { onMount } from "svelte"; 3 + import router from "./lib/router.js"; 4 + import { authStore } from "./stores/auth.js"; 5 + 6 + // Import route components 7 + import Home from "./routes/Home.svelte"; 8 + import Login from "./routes/Login.svelte"; 9 + import Brews from "./routes/Brews.svelte"; 10 + import BrewView from "./routes/BrewView.svelte"; 11 + import BrewForm from "./routes/BrewForm.svelte"; 12 + import Manage from "./routes/Manage.svelte"; 13 + import Profile from "./routes/Profile.svelte"; 14 + import About from "./routes/About.svelte"; 15 + import Terms from "./routes/Terms.svelte"; 16 + import NotFound from "./routes/NotFound.svelte"; 17 + 18 + import Header from "./components/Header.svelte"; 19 + import Footer from "./components/Footer.svelte"; 20 + 21 + let currentRoute = null; 22 + let params = {}; 23 + 24 + onMount(() => { 25 + // Check auth status on mount 26 + authStore.checkAuth(); 27 + 28 + // Setup routes 29 + router 30 + .on("/", () => { 31 + currentRoute = Home; 32 + params = {}; 33 + }) 34 + .on("/login", () => { 35 + currentRoute = Login; 36 + params = {}; 37 + }) 38 + .on("/brews", () => { 39 + currentRoute = Brews; 40 + params = {}; 41 + }) 42 + .on("/brews/new", () => { 43 + currentRoute = BrewForm; 44 + params = { mode: "create" }; 45 + }) 46 + .on("/brews/:id", (routeParams) => { 47 + currentRoute = BrewView; 48 + params = routeParams; 49 + }) 50 + .on("/brews/:id/edit", (routeParams) => { 51 + currentRoute = BrewForm; 52 + params = { ...routeParams, mode: "edit" }; 53 + }) 54 + .on("/manage", () => { 55 + currentRoute = Manage; 56 + params = {}; 57 + }) 58 + .on("/profile/:actor", (routeParams) => { 59 + currentRoute = Profile; 60 + params = routeParams; 61 + }) 62 + .on("/about", () => { 63 + currentRoute = About; 64 + params = {}; 65 + }) 66 + .on("/terms", () => { 67 + currentRoute = Terms; 68 + params = {}; 69 + }) 70 + .on("*", () => { 71 + currentRoute = NotFound; 72 + params = {}; 73 + }); 74 + 75 + // Start router 76 + router.listen(); 77 + }); 78 + </script> 79 + 80 + <div class="flex flex-col min-h-screen"> 81 + <Header /> 82 + 83 + <main class="flex-1 container mx-auto px-4 py-8"> 84 + {#if currentRoute} 85 + <svelte:component this={currentRoute} {...params} /> 86 + {/if} 87 + </main> 88 + 89 + <Footer /> 90 + </div>
+112
frontend/src/components/FeedCard.svelte
··· 1 + <script> 2 + export let item; 3 + import { navigate } from '../lib/router.js'; 4 + 5 + function safeAvatarURL(url) { 6 + if (!url) return null; 7 + if (url.startsWith('https://') || url.startsWith('/static/')) { 8 + return url; 9 + } 10 + return null; 11 + } 12 + 13 + function hasValue(val) { 14 + return val !== null && val !== undefined && val !== ''; 15 + } 16 + </script> 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"> 19 + <!-- Author row --> 20 + <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"> 22 + {#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" /> 24 + {: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"> 26 + <span class="text-brown-600 text-sm">?</span> 27 + </div> 28 + {/if} 29 + </a> 30 + <div class="flex-1 min-w-0"> 31 + <div class="flex items-center gap-2"> 32 + {#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> 34 + {/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> 36 + </div> 37 + <span class="text-brown-500 text-sm">{item.TimeAgo}</span> 38 + </div> 39 + </div> 40 + 41 + <!-- Action header --> 42 + <div class="mb-2 text-sm text-brown-700">{item.Action}</div> 43 + 44 + <!-- Record content --> 45 + {#if item.RecordType === 'brew' && item.Brew} 46 + <div class="bg-white/60 backdrop-blur rounded-lg p-4 border border-brown-200"> 47 + <!-- Bean info with rating --> 48 + <div class="flex items-start justify-between gap-3 mb-3"> 49 + <div class="flex-1 min-w-0"> 50 + {#if item.Brew.bean} 51 + <div class="font-bold text-brown-900 text-base"> 52 + {item.Brew.bean.name || item.Brew.bean.origin} 53 + </div> 54 + {#if item.Brew.bean.roaster?.name} 55 + <div class="text-sm text-brown-700 mt-0.5"> 56 + <span class="font-medium">🏭 {item.Brew.bean.roaster.name}</span> 57 + </div> 58 + {/if} 59 + <div class="text-xs text-brown-600 mt-1 flex flex-wrap gap-x-2 gap-y-0.5"> 60 + {#if item.Brew.bean.origin}<span class="inline-flex items-center gap-0.5">📍 {item.Brew.bean.origin}</span>{/if} 61 + {#if item.Brew.bean.roast_level}<span class="inline-flex items-center gap-0.5">🔥 {item.Brew.bean.roast_level}</span>{/if} 62 + {#if item.Brew.bean.process}<span class="inline-flex items-center gap-0.5">🌱 {item.Brew.bean.process}</span>{/if} 63 + {#if hasValue(item.Brew.coffee_amount)}<span class="inline-flex items-center gap-0.5">⚖️ {item.Brew.coffee_amount}g</span>{/if} 64 + </div> 65 + {/if} 66 + </div> 67 + {#if hasValue(item.Brew.rating)} 68 + <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"> 69 + ⭐ {item.Brew.rating}/10 70 + </span> 71 + {/if} 72 + </div> 73 + 74 + <!-- Brewer --> 75 + {#if item.Brew.brewer_obj || item.Brew.method} 76 + <div class="mb-2"> 77 + <span class="text-xs text-brown-600">Brewer:</span> 78 + <span class="text-sm font-semibold text-brown-900"> 79 + {item.Brew.brewer_obj?.name || item.Brew.method} 80 + </span> 81 + </div> 82 + {/if} 83 + 84 + <!-- Notes --> 85 + {#if item.Brew.tasting_notes} 86 + <div class="mt-2 text-sm text-brown-800 italic border-l-2 border-brown-300 pl-3"> 87 + "{item.Brew.tasting_notes}" 88 + </div> 89 + {/if} 90 + </div> 91 + {:else if item.RecordType === 'bean' && item.Bean} 92 + <div class="bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200"> 93 + <div class="font-semibold text-brown-900">{item.Bean.name || item.Bean.origin}</div> 94 + {#if item.Bean.origin}<div class="text-sm text-brown-700">📍 {item.Bean.origin}</div>{/if} 95 + </div> 96 + {:else if item.RecordType === 'roaster' && item.Roaster} 97 + <div class="bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200"> 98 + <div class="font-semibold text-brown-900">🏭 {item.Roaster.name}</div> 99 + {#if item.Roaster.location}<div class="text-sm text-brown-700">📍 {item.Roaster.location}</div>{/if} 100 + </div> 101 + {:else if item.RecordType === 'grinder' && item.Grinder} 102 + <div class="bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200"> 103 + <div class="font-semibold text-brown-900">⚙️ {item.Grinder.name}</div> 104 + {#if item.Grinder.grinder_type}<div class="text-sm text-brown-700">{item.Grinder.grinder_type}</div>{/if} 105 + </div> 106 + {:else if item.RecordType === 'brewer' && item.Brewer} 107 + <div class="bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200"> 108 + <div class="font-semibold text-brown-900">☕ {item.Brewer.name}</div> 109 + {#if item.Brewer.brewer_type}<div class="text-sm text-brown-700">{item.Brewer.brewer_type}</div>{/if} 110 + </div> 111 + {/if} 112 + </div>
+51
frontend/src/components/Footer.svelte
··· 1 + <script> 2 + import { navigate } from '../lib/router.js'; 3 + </script> 4 + 5 + <footer class="bg-brown-800 text-brown-100 mt-12"> 6 + <div class="container mx-auto px-4 py-8"> 7 + <div class="grid grid-cols-1 md:grid-cols-3 gap-8"> 8 + <div> 9 + <h3 class="text-lg font-bold mb-3 flex items-center gap-2"> 10 + <span>☕</span> 11 + <span>Arabica</span> 12 + </h3> 13 + <p class="text-sm text-brown-300"> 14 + Track your coffee brewing journey with decentralized data storage powered by AT Protocol. 15 + </p> 16 + </div> 17 + 18 + <div> 19 + <h4 class="font-semibold mb-3">Links</h4> 20 + <ul class="space-y-2 text-sm"> 21 + <li> 22 + <a href="/about" on:click|preventDefault={() => navigate('/about')} class="text-brown-300 hover:text-white transition-colors"> 23 + About 24 + </a> 25 + </li> 26 + <li> 27 + <a href="/terms" on:click|preventDefault={() => navigate('/terms')} class="text-brown-300 hover:text-white transition-colors"> 28 + Terms of Service 29 + </a> 30 + </li> 31 + <li> 32 + <a href="https://github.com/arabica-social/arabica" target="_blank" rel="noopener noreferrer" class="text-brown-300 hover:text-white transition-colors"> 33 + GitHub 34 + </a> 35 + </li> 36 + </ul> 37 + </div> 38 + 39 + <div> 40 + <h4 class="font-semibold mb-3">AT Protocol</h4> 41 + <p class="text-sm text-brown-300"> 42 + Your data lives in your Personal Data Server (PDS), giving you full ownership and portability. 43 + </p> 44 + </div> 45 + </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> 49 + </div> 50 + </div> 51 + </footer>
+138
frontend/src/components/Header.svelte
··· 1 + <script> 2 + import { authStore } from '../stores/auth.js'; 3 + import { navigate } from '../lib/router.js'; 4 + 5 + let dropdownOpen = false; 6 + 7 + $: user = $authStore.user; 8 + $: isAuthenticated = $authStore.isAuthenticated; 9 + 10 + function toggleDropdown() { 11 + dropdownOpen = !dropdownOpen; 12 + } 13 + 14 + function closeDropdown() { 15 + dropdownOpen = false; 16 + } 17 + 18 + async function handleLogout() { 19 + await authStore.logout(); 20 + } 21 + 22 + // Close dropdown when clicking outside 23 + function handleClickOutside(event) { 24 + if (dropdownOpen && !event.target.closest('.user-menu')) { 25 + closeDropdown(); 26 + } 27 + } 28 + </script> 29 + 30 + <svelte:window on:click={handleClickOutside} /> 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"> 33 + <div class="container mx-auto px-4 py-4"> 34 + <div class="flex items-center justify-between"> 35 + <!-- Logo - always visible --> 36 + <a href="/" on:click|preventDefault={() => navigate('/')} class="flex items-center gap-2 hover:opacity-80 transition"> 37 + <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> 39 + </a> 40 + 41 + <!-- Navigation links --> 42 + <div class="flex items-center gap-4"> 43 + {#if isAuthenticated} 44 + <!-- User profile dropdown --> 45 + <div class="relative user-menu"> 46 + <button 47 + on:click|stopPropagation={toggleDropdown} 48 + class="flex items-center gap-2 hover:opacity-80 transition focus:outline-none" 49 + aria-label="User menu" 50 + > 51 + {#if user?.avatar} 52 + <img src={user.avatar} alt="" class="w-8 h-8 rounded-full object-cover ring-2 ring-brown-600" /> 53 + {: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> 56 + </div> 57 + {/if} 58 + <svg 59 + class="w-4 h-4 transition-transform {dropdownOpen ? 'rotate-180' : ''}" 60 + fill="none" 61 + stroke="currentColor" 62 + viewBox="0 0 24 24" 63 + > 64 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /> 65 + </svg> 66 + </button> 67 + 68 + {#if dropdownOpen} 69 + <div 70 + 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 + > 72 + {#if user?.handle} 73 + <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> 76 + </div> 77 + {/if} 78 + <a 79 + href="/profile/{user?.handle || user?.did}" 80 + on:click|preventDefault={() => { navigate(`/profile/${user?.handle || user?.did}`); closeDropdown(); }} 81 + class="block px-4 py-2 text-sm text-brown-700 hover:bg-brown-50 transition-colors" 82 + > 83 + View Profile 84 + </a> 85 + <a 86 + href="/brews" 87 + on:click|preventDefault={() => { navigate('/brews'); closeDropdown(); }} 88 + class="block px-4 py-2 text-sm text-brown-700 hover:bg-brown-50 transition-colors" 89 + > 90 + My Brews 91 + </a> 92 + <a 93 + href="/manage" 94 + on:click|preventDefault={() => { navigate('/manage'); closeDropdown(); }} 95 + class="block px-4 py-2 text-sm text-brown-700 hover:bg-brown-50 transition-colors" 96 + > 97 + Manage Records 98 + </a> 99 + <a 100 + href="/settings" 101 + on:click|preventDefault={() => { navigate('/settings'); closeDropdown(); }} 102 + class="block px-4 py-2 text-sm text-brown-400 cursor-not-allowed" 103 + > 104 + Settings (coming soon) 105 + </a> 106 + <div class="border-t border-brown-100 mt-1 pt-1"> 107 + <button 108 + on:click={() => { handleLogout(); closeDropdown(); }} 109 + class="w-full text-left px-4 py-2 text-sm text-brown-700 hover:bg-brown-50 transition-colors" 110 + > 111 + Logout 112 + </button> 113 + </div> 114 + </div> 115 + {/if} 116 + </div> 117 + {/if} 118 + </div> 119 + </div> 120 + </div> 121 + </nav> 122 + 123 + <style> 124 + @keyframes fade-in { 125 + from { 126 + opacity: 0; 127 + transform: translateY(-10px); 128 + } 129 + to { 130 + opacity: 1; 131 + transform: translateY(0); 132 + } 133 + } 134 + 135 + .animate-fade-in { 136 + animation: fade-in 0.2s ease-out; 137 + } 138 + </style>
+35
frontend/src/components/Modal.svelte
··· 1 + <script> 2 + export let label; 3 + export let onSave; 4 + export let onCancel; 5 + export let isOpen = false; 6 + export let title = 'Modal'; 7 + </script> 8 + 9 + {#if isOpen} 10 + <div class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 11 + <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"> 12 + <h3 class="text-xl font-semibold mb-4 text-brown-900">{title}</h3> 13 + <div class="space-y-4"> 14 + <slot /> 15 + 16 + <div class="flex gap-2"> 17 + <button 18 + type="button" 19 + on:click={onSave} 20 + 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" 21 + > 22 + Save 23 + </button> 24 + <button 25 + type="button" 26 + on:click={onCancel} 27 + class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors" 28 + > 29 + Cancel 30 + </button> 31 + </div> 32 + </div> 33 + </div> 34 + </div> 35 + {/if}
+92
frontend/src/lib/api.js
··· 1 + /** 2 + * API client for communicating with Go backend 3 + * Handles authentication, errors, and JSON serialization 4 + */ 5 + 6 + class APIError extends Error { 7 + constructor(message, status, response) { 8 + super(message); 9 + this.name = 'APIError'; 10 + this.status = status; 11 + this.response = response; 12 + } 13 + } 14 + 15 + /** 16 + * Make an authenticated API request 17 + * @param {string} endpoint - API endpoint (e.g., '/api/brews') 18 + * @param {object} options - Fetch options 19 + * @returns {Promise<any>} Response data 20 + */ 21 + async function request(endpoint, options = {}) { 22 + const config = { 23 + credentials: 'same-origin', // Send cookies 24 + headers: { 25 + 'Content-Type': 'application/json', 26 + ...options.headers, 27 + }, 28 + ...options, 29 + }; 30 + 31 + try { 32 + const response = await fetch(endpoint, config); 33 + 34 + // Handle 401/403 - but only redirect if not on public endpoints or pages 35 + if (response.status === 401 || response.status === 403) { 36 + // Don't redirect if: 37 + // 1. Already on public pages 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']; 41 + const currentPath = window.location.pathname; 42 + const isPublicEndpoint = publicEndpoints.some(path => endpoint.includes(path)); 43 + 44 + if (!publicPaths.includes(currentPath) && !isPublicEndpoint) { 45 + window.location.href = '/login'; 46 + } 47 + 48 + throw new APIError('Authentication required', response.status, response); 49 + } 50 + 51 + // Handle non-OK responses 52 + if (!response.ok) { 53 + const text = await response.text(); 54 + throw new APIError(text || `Request failed: ${response.statusText}`, response.status, response); 55 + } 56 + 57 + // Handle empty responses (e.g., 204 No Content) 58 + const contentType = response.headers.get('content-type'); 59 + if (!contentType || !contentType.includes('application/json')) { 60 + return null; 61 + } 62 + 63 + return await response.json(); 64 + } catch (error) { 65 + if (error instanceof APIError) { 66 + throw error; 67 + } 68 + throw new APIError(`Network error: ${error.message}`, 0, null); 69 + } 70 + } 71 + 72 + export const api = { 73 + // GET request 74 + get: (endpoint) => request(endpoint, { method: 'GET' }), 75 + 76 + // POST request 77 + post: (endpoint, data) => request(endpoint, { 78 + method: 'POST', 79 + body: JSON.stringify(data), 80 + }), 81 + 82 + // PUT request 83 + put: (endpoint, data) => request(endpoint, { 84 + method: 'PUT', 85 + body: JSON.stringify(data), 86 + }), 87 + 88 + // DELETE request 89 + delete: (endpoint) => request(endpoint, { method: 'DELETE' }), 90 + }; 91 + 92 + export { APIError };
+27
frontend/src/lib/router.js
··· 1 + import navaid from 'navaid'; 2 + 3 + /** 4 + * Simple client-side router using navaid 5 + * Handles browser history and navigation 6 + */ 7 + const router = navaid('/', () => { 8 + // Default handler (fallback to home) 9 + window.location.hash = '/'; 10 + }); 11 + 12 + /** 13 + * Navigate to a route programmatically 14 + * @param {string} path - Route path 15 + */ 16 + export function navigate(path) { 17 + router.route(path); 18 + } 19 + 20 + /** 21 + * Navigate back in history 22 + */ 23 + export function back() { 24 + window.history.back(); 25 + } 26 + 27 + export default router;
+7
frontend/src/main.js
··· 1 + import App from './App.svelte'; 2 + 3 + const app = new App({ 4 + target: document.getElementById('app'), 5 + }); 6 + 7 + export default app;
+55
frontend/src/routes/About.svelte
··· 1 + <script> 2 + import { navigate } from '../lib/router.js'; 3 + </script> 4 + 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"> 7 + <h1 class="text-3xl font-bold text-brown-900 mb-6">About Arabica</h1> 8 + 9 + <div class="prose prose-brown max-w-none"> 10 + <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. 12 + </p> 13 + 14 + <h2 class="text-2xl font-bold text-brown-900 mt-8 mb-4">Features</h2> 15 + <ul class="space-y-2 text-brown-800"> 16 + <li class="flex items-start"> 17 + <span class="mr-2">🔒</span> 18 + <span><strong>Decentralized:</strong> Your data lives in your Personal Data Server (PDS)</span> 19 + </li> 20 + <li class="flex items-start"> 21 + <span class="mr-2">🚀</span> 22 + <span><strong>Portable:</strong> Own your coffee brewing history</span> 23 + </li> 24 + <li class="flex items-start"> 25 + <span class="mr-2">📊</span> 26 + <span>Track brewing variables like temperature, time, and grind size</span> 27 + </li> 28 + <li class="flex items-start"> 29 + <span class="mr-2">🌍</span> 30 + <span>Organize beans by origin and roaster</span> 31 + </li> 32 + <li class="flex items-start"> 33 + <span class="mr-2">📝</span> 34 + <span>Add tasting notes and ratings to each brew</span> 35 + </li> 36 + </ul> 37 + 38 + <h2 class="text-2xl font-bold text-brown-900 mt-8 mb-4">AT Protocol</h2> 39 + <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. 43 + </p> 44 + 45 + <div class="mt-8"> 46 + <button 47 + on:click={() => navigate('/')} 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 + Get Started 51 + </button> 52 + </div> 53 + </div> 54 + </div> 55 + </div>
+557
frontend/src/routes/BrewForm.svelte
··· 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.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 || '', 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_amount: '', time_seconds: '' }]; 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 + bean_rkey: form.bean_rkey, 104 + method: form.method || '', 105 + temperature: form.water_temp ? parseFloat(form.water_temp) : 0, 106 + water_amount: form.water_amount ? parseFloat(form.water_amount) : 0, 107 + coffee_amount: form.coffee_amount ? parseFloat(form.coffee_amount) : 0, 108 + time_seconds: form.brew_time ? parseFloat(form.brew_time) : 0, 109 + grind_size: form.grind_size || '', 110 + grinder_rkey: form.grinder_rkey || '', 111 + brewer_rkey: form.brewer_rkey || '', 112 + tasting_notes: form.notes || '', 113 + rating: form.rating ? parseInt(form.rating) : 0, 114 + pours: pours.filter(p => p.water_amount && p.time_seconds), // Only include completed pours 115 + }; 116 + 117 + if (mode === 'edit') { 118 + await api.put(`/brews/${id}`, payload); 119 + } else { 120 + await api.post('/brews', payload); 121 + } 122 + 123 + await cacheStore.invalidate(); 124 + navigate('/brews'); 125 + } catch (err) { 126 + error = err.message; 127 + saving = false; 128 + } 129 + } 130 + 131 + // Entity creation handlers 132 + async function saveBeanModal() { 133 + try { 134 + const result = await api.post('/api/beans', beanForm); 135 + await cacheStore.invalidate(); 136 + form.bean_rkey = result.rkey; 137 + showBeanModal = false; 138 + beanForm = { name: '', origin: '', roast_level: '', process: '', description: '', roaster_rkey: '' }; 139 + } catch (err) { 140 + alert('Failed to create bean: ' + err.message); 141 + } 142 + } 143 + 144 + async function saveRoasterModal() { 145 + try { 146 + const result = await api.post('/api/roasters', roasterForm); 147 + await cacheStore.invalidate(); 148 + beanForm.roaster_rkey = result.rkey; 149 + showRoasterModal = false; 150 + roasterForm = { name: '', location: '', website: '', description: '' }; 151 + } catch (err) { 152 + alert('Failed to create roaster: ' + err.message); 153 + } 154 + } 155 + 156 + async function saveGrinderModal() { 157 + try { 158 + const result = await api.post('/api/grinders', grinderForm); 159 + await cacheStore.invalidate(); 160 + form.grinder_rkey = result.rkey; 161 + showGrinderModal = false; 162 + grinderForm = { name: '', grinder_type: '', burr_type: '', notes: '' }; 163 + } catch (err) { 164 + alert('Failed to create grinder: ' + err.message); 165 + } 166 + } 167 + 168 + async function saveBrewerModal() { 169 + try { 170 + const result = await api.post('/api/brewers', brewerForm); 171 + await cacheStore.invalidate(); 172 + form.brewer_rkey = result.rkey; 173 + showBrewerModal = false; 174 + brewerForm = { name: '', brewer_type: '', description: '' }; 175 + } catch (err) { 176 + alert('Failed to create brewer: ' + err.message); 177 + } 178 + } 179 + </script> 180 + 181 + <svelte:head> 182 + <title>{mode === 'edit' ? 'Edit Brew' : 'New Brew'} - Arabica</title> 183 + </svelte:head> 184 + 185 + <div class="max-w-2xl mx-auto"> 186 + {#if loading} 187 + <div class="text-center py-12"> 188 + <div class="animate-spin rounded-full h-12 w-12 border-b-2 border-brown-800 mx-auto"></div> 189 + <p class="mt-4 text-brown-700">Loading...</p> 190 + </div> 191 + {:else} 192 + <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 border border-brown-300"> 193 + <!-- Header with Back Button --> 194 + <div class="flex items-center gap-3 mb-6"> 195 + <button 196 + on:click={() => back()} 197 + class="inline-flex items-center text-brown-700 hover:text-brown-900 font-medium transition-colors cursor-pointer" 198 + > 199 + <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> 200 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path> 201 + </svg> 202 + </button> 203 + <h2 class="text-3xl font-bold text-brown-900"> 204 + {mode === 'edit' ? 'Edit Brew' : 'New Brew'} 205 + </h2> 206 + </div> 207 + 208 + {#if error} 209 + <div class="mb-4 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded"> 210 + {error} 211 + </div> 212 + {/if} 213 + 214 + <form on:submit|preventDefault={handleSubmit} class="space-y-6"> 215 + <!-- Bean Selection --> 216 + <div> 217 + <label class="block text-sm font-medium text-brown-900 mb-2">Coffee Bean *</label> 218 + <div class="flex gap-2"> 219 + <select 220 + bind:value={form.bean_rkey} 221 + required 222 + 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" 223 + > 224 + <option value="">Select a bean...</option> 225 + {#each beans as bean} 226 + <option value={bean.rkey}> 227 + {bean.name || bean.origin} ({bean.origin} - {bean.roast_level}) 228 + </option> 229 + {/each} 230 + </select> 231 + <button 232 + type="button" 233 + on:click={() => showBeanModal = true} 234 + class="bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors" 235 + > 236 + + New 237 + </button> 238 + </div> 239 + </div> 240 + 241 + <!-- Coffee Amount --> 242 + <div> 243 + <label class="block text-sm font-medium text-brown-900 mb-2">Coffee Amount (grams)</label> 244 + <input 245 + type="number" 246 + bind:value={form.coffee_amount} 247 + step="0.1" 248 + placeholder="e.g. 18" 249 + 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" 250 + /> 251 + <p class="text-sm text-brown-700 mt-1">Amount of ground coffee used</p> 252 + </div> 253 + 254 + <!-- Grinder --> 255 + <div> 256 + <label class="block text-sm font-medium text-brown-900 mb-2">Grinder</label> 257 + <div class="flex gap-2"> 258 + <select 259 + bind:value={form.grinder_rkey} 260 + 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" 261 + > 262 + <option value="">Select a grinder...</option> 263 + {#each grinders as grinder} 264 + <option value={grinder.rkey}>{grinder.name}</option> 265 + {/each} 266 + </select> 267 + <button 268 + type="button" 269 + on:click={() => showGrinderModal = true} 270 + class="bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors" 271 + > 272 + + New 273 + </button> 274 + </div> 275 + </div> 276 + 277 + <!-- Grind Size --> 278 + <div> 279 + <label class="block text-sm font-medium text-brown-900 mb-2">Grind Size</label> 280 + <input 281 + type="text" 282 + bind:value={form.grind_size} 283 + placeholder="e.g. 18, Medium, 3.5, Fine" 284 + 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" 285 + /> 286 + <p class="text-sm text-brown-700 mt-1">Enter a number (grinder setting) or description (e.g. "Medium", "Fine")</p> 287 + </div> 288 + 289 + <!-- Brew Method --> 290 + <div> 291 + <label class="block text-sm font-medium text-brown-900 mb-2">Brew Method</label> 292 + <div class="flex gap-2"> 293 + <select 294 + bind:value={form.brewer_rkey} 295 + 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" 296 + > 297 + <option value="">Select brew method...</option> 298 + {#each brewers as brewer} 299 + <option value={brewer.rkey}>{brewer.name}</option> 300 + {/each} 301 + </select> 302 + <button 303 + type="button" 304 + on:click={() => showBrewerModal = true} 305 + class="bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors" 306 + > 307 + + New 308 + </button> 309 + </div> 310 + </div> 311 + 312 + <!-- Water Amount --> 313 + <div> 314 + <label class="block text-sm font-medium text-brown-900 mb-2">Water Amount (ml)</label> 315 + <input 316 + type="number" 317 + bind:value={form.water_amount} 318 + step="1" 319 + placeholder="e.g. 300" 320 + 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" 321 + /> 322 + </div> 323 + 324 + <!-- Water Temperature --> 325 + <div> 326 + <label class="block text-sm font-medium text-brown-900 mb-2">Water Temperature (°C)</label> 327 + <input 328 + type="number" 329 + bind:value={form.water_temp} 330 + step="0.1" 331 + placeholder="e.g. 93" 332 + 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" 333 + /> 334 + </div> 335 + 336 + <!-- Brew Time --> 337 + <div> 338 + <label class="block text-sm font-medium text-brown-900 mb-2">Total Brew Time (seconds)</label> 339 + <input 340 + type="number" 341 + bind:value={form.brew_time} 342 + step="1" 343 + placeholder="e.g. 210" 344 + class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-3 px-4 bg-white" 345 + /> 346 + </div> 347 + 348 + <!-- Pours --> 349 + <div> 350 + <div class="flex items-center justify-between mb-2"> 351 + <label class="block text-sm font-medium text-brown-900">Pour Schedule (Optional)</label> 352 + <button 353 + type="button" 354 + on:click={addPour} 355 + class="text-sm bg-brown-300 text-brown-900 px-3 py-1 rounded hover:bg-brown-400 font-medium transition-colors" 356 + > 357 + + Add Pour 358 + </button> 359 + </div> 360 + 361 + {#if pours.length > 0} 362 + <div class="space-y-2"> 363 + {#each pours as pour, i} 364 + <div class="flex gap-2 items-center bg-brown-50 p-3 rounded-lg border border-brown-200"> 365 + <span class="text-sm font-medium text-brown-700 min-w-[60px]">Pour {i + 1}:</span> 366 + <input 367 + type="number" 368 + bind:value={pour.Water} 369 + placeholder="Water (g)" 370 + class="flex-1 rounded border border-brown-300 px-3 py-2 text-sm" 371 + /> 372 + <input 373 + type="number" 374 + bind:value={pour.Time} 375 + placeholder="Time (s)" 376 + class="flex-1 rounded border border-brown-300 px-3 py-2 text-sm" 377 + /> 378 + <button 379 + type="button" 380 + on:click={() => removePour(i)} 381 + class="text-red-600 hover:text-red-800 font-medium px-2" 382 + > 383 + 384 + </button> 385 + </div> 386 + {/each} 387 + </div> 388 + {/if} 389 + </div> 390 + 391 + <!-- Rating --> 392 + <div> 393 + <label class="block text-sm font-medium text-brown-900 mb-2"> 394 + Rating: <span class="font-bold">{form.rating}/10</span> 395 + </label> 396 + <input 397 + type="range" 398 + bind:value={form.rating} 399 + min="0" 400 + max="10" 401 + step="1" 402 + class="w-full h-2 bg-brown-200 rounded-lg appearance-none cursor-pointer accent-brown-700" 403 + /> 404 + <div class="flex justify-between text-xs text-brown-600 mt-1"> 405 + <span>0</span> 406 + <span>10</span> 407 + </div> 408 + </div> 409 + 410 + <!-- Notes --> 411 + <div> 412 + <label class="block text-sm font-medium text-brown-900 mb-2">Tasting Notes</label> 413 + <textarea 414 + bind:value={form.notes} 415 + rows="4" 416 + placeholder="Describe the flavor, aroma, body, etc." 417 + 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" 418 + ></textarea> 419 + </div> 420 + 421 + <!-- Submit Button --> 422 + <div class="flex gap-3"> 423 + <button 424 + type="submit" 425 + disabled={saving} 426 + 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" 427 + > 428 + {saving ? 'Saving...' : mode === 'edit' ? 'Update Brew' : 'Save Brew'} 429 + </button> 430 + <button 431 + type="button" 432 + on:click={() => back()} 433 + class="px-6 py-3 border-2 border-brown-300 text-brown-700 rounded-lg hover:bg-brown-100 font-semibold transition-colors" 434 + > 435 + Cancel 436 + </button> 437 + </div> 438 + </form> 439 + </div> 440 + {/if} 441 + </div> 442 + 443 + <!-- Modals --> 444 + <Modal 445 + bind:isOpen={showBeanModal} 446 + title="Add New Bean" 447 + onSave={saveBeanModal} 448 + onCancel={() => showBeanModal = false} 449 + > 450 + <div class="space-y-4"> 451 + <div> 452 + <label class="block text-sm font-medium text-gray-700 mb-1">Name</label> 453 + <input type="text" bind:value={beanForm.name} class="w-full rounded border-gray-300 px-3 py-2" /> 454 + </div> 455 + <div> 456 + <label class="block text-sm font-medium text-gray-700 mb-1">Origin *</label> 457 + <input type="text" bind:value={beanForm.origin} required class="w-full rounded border-gray-300 px-3 py-2" /> 458 + </div> 459 + <div> 460 + <label class="block text-sm font-medium text-gray-700 mb-1">Roast Level *</label> 461 + <select bind:value={beanForm.roast_level} required class="w-full rounded border-gray-300 px-3 py-2"> 462 + <option value="">Select...</option> 463 + <option value="Light">Light</option> 464 + <option value="Medium-Light">Medium-Light</option> 465 + <option value="Medium">Medium</option> 466 + <option value="Medium-Dark">Medium-Dark</option> 467 + <option value="Dark">Dark</option> 468 + </select> 469 + </div> 470 + <div> 471 + <label class="block text-sm font-medium text-gray-700 mb-1">Roaster</label> 472 + <div class="flex gap-2"> 473 + <select bind:value={beanForm.roaster_rkey} class="flex-1 rounded border-gray-300 px-3 py-2"> 474 + <option value="">Select...</option> 475 + {#each roasters as roaster} 476 + <option value={roaster.rkey}>{roaster.name}</option> 477 + {/each} 478 + </select> 479 + <button 480 + type="button" 481 + on:click={() => showRoasterModal = true} 482 + class="bg-gray-200 px-3 py-1 rounded hover:bg-gray-300 text-sm" 483 + > 484 + + New 485 + </button> 486 + </div> 487 + </div> 488 + </div> 489 + </Modal> 490 + 491 + <Modal 492 + bind:isOpen={showRoasterModal} 493 + title="Add New Roaster" 494 + onSave={saveRoasterModal} 495 + onCancel={() => showRoasterModal = false} 496 + > 497 + <div class="space-y-4"> 498 + <div> 499 + <label class="block text-sm font-medium text-gray-700 mb-1">Name *</label> 500 + <input type="text" bind:value={roasterForm.name} required class="w-full rounded border-gray-300 px-3 py-2" /> 501 + </div> 502 + <div> 503 + <label class="block text-sm font-medium text-gray-700 mb-1">Location</label> 504 + <input type="text" bind:value={roasterForm.location} class="w-full rounded border-gray-300 px-3 py-2" /> 505 + </div> 506 + </div> 507 + </Modal> 508 + 509 + <Modal 510 + bind:isOpen={showGrinderModal} 511 + title="Add New Grinder" 512 + onSave={saveGrinderModal} 513 + onCancel={() => showGrinderModal = false} 514 + > 515 + <div class="space-y-4"> 516 + <div> 517 + <label class="block text-sm font-medium text-gray-700 mb-1">Name *</label> 518 + <input type="text" bind:value={grinderForm.name} required class="w-full rounded border-gray-300 px-3 py-2" /> 519 + </div> 520 + <div> 521 + <label class="block text-sm font-medium text-gray-700 mb-1">Type</label> 522 + <select bind:value={grinderForm.grinder_type} class="w-full rounded border-gray-300 px-3 py-2"> 523 + <option value="">Select...</option> 524 + <option value="Manual">Manual</option> 525 + <option value="Electric">Electric</option> 526 + <option value="Blade">Blade</option> 527 + </select> 528 + </div> 529 + </div> 530 + </Modal> 531 + 532 + <Modal 533 + bind:isOpen={showBrewerModal} 534 + title="Add New Brewer" 535 + onSave={saveBrewerModal} 536 + onCancel={() => showBrewerModal = false} 537 + > 538 + <div class="space-y-4"> 539 + <div> 540 + <label class="block text-sm font-medium text-gray-700 mb-1">Name *</label> 541 + <input type="text" bind:value={brewerForm.name} required class="w-full rounded border-gray-300 px-3 py-2" /> 542 + </div> 543 + <div> 544 + <label class="block text-sm font-medium text-gray-700 mb-1">Type</label> 545 + <select bind:value={brewerForm.brewer_type} class="w-full rounded border-gray-300 px-3 py-2"> 546 + <option value="">Select...</option> 547 + <option value="Pour Over">Pour Over</option> 548 + <option value="French Press">French Press</option> 549 + <option value="Espresso">Espresso</option> 550 + <option value="Moka Pot">Moka Pot</option> 551 + <option value="Aeropress">Aeropress</option> 552 + <option value="Cold Brew">Cold Brew</option> 553 + <option value="Siphon">Siphon</option> 554 + </select> 555 + </div> 556 + </div> 557 + </Modal>
+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>
+246
frontend/src/routes/BrewView.svelte
··· 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 + // Calculate total water from pours if water_amount is 0 18 + $: totalWater = brew && (brew.water_amount || 0) === 0 && brew.pours && brew.pours.length > 0 19 + ? brew.pours.reduce((sum, pour) => sum + (pour.water_amount || 0), 0) 20 + : brew?.water_amount || 0; 21 + 22 + onMount(async () => { 23 + if (!isAuthenticated) { 24 + navigate('/login'); 25 + return; 26 + } 27 + 28 + // Load from cache first 29 + await cacheStore.load(); 30 + const brews = $cacheStore.brews || []; 31 + brew = brews.find(b => b.rkey === id); 32 + loading = false; 33 + isOwnProfile = true; // Currently viewing own profile 34 + }); 35 + 36 + async function deleteBrew() { 37 + if (!confirm('Are you sure you want to delete this brew?')) { 38 + return; 39 + } 40 + 41 + try { 42 + await api.delete(`/brews/${id}`); 43 + await cacheStore.invalidate(); 44 + navigate('/brews'); 45 + } catch (err) { 46 + alert('Failed to delete brew: ' + err.message); 47 + } 48 + } 49 + 50 + function hasValue(val) { 51 + return val !== null && val !== undefined && val !== ''; 52 + } 53 + 54 + function formatDate(dateStr) { 55 + if (!dateStr) return ''; 56 + const date = new Date(dateStr); 57 + return date.toLocaleDateString('en-US', { 58 + year: 'numeric', 59 + month: 'long', 60 + day: 'numeric', 61 + hour: 'numeric', 62 + minute: '2-digit' 63 + }); 64 + } 65 + </script> 66 + 67 + <svelte:head> 68 + <title>Brew Details - Arabica</title> 69 + </svelte:head> 70 + 71 + <div class="max-w-2xl mx-auto"> 72 + {#if loading} 73 + <div class="text-center py-12"> 74 + <div class="animate-spin rounded-full h-12 w-12 border-b-2 border-brown-800 mx-auto"></div> 75 + <p class="mt-4 text-brown-700">Loading brew...</p> 76 + </div> 77 + {:else if !brew} 78 + <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl p-12 text-center border border-brown-300"> 79 + <h2 class="text-2xl font-bold text-brown-900 mb-2">Brew Not Found</h2> 80 + <p class="text-brown-700 mb-6">The brew you're looking for doesn't exist.</p> 81 + <button 82 + on:click={() => navigate('/brews')} 83 + 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" 84 + > 85 + Back to Brews 86 + </button> 87 + </div> 88 + {:else} 89 + <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 border border-brown-300"> 90 + <!-- Header with title and actions --> 91 + <div class="flex justify-between items-start mb-6"> 92 + <div> 93 + <h2 class="text-3xl font-bold text-brown-900">Brew Details</h2> 94 + <p class="text-sm text-brown-600 mt-1">{formatDate(brew.created_at)}</p> 95 + </div> 96 + {#if isOwnProfile} 97 + <div class="flex gap-2"> 98 + <button 99 + on:click={() => navigate(`/brews/${brew.rkey}/edit`)} 100 + 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" 101 + > 102 + Edit 103 + </button> 104 + <button 105 + on:click={deleteBrew} 106 + 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" 107 + > 108 + Delete 109 + </button> 110 + </div> 111 + {/if} 112 + </div> 113 + 114 + <div class="space-y-6"> 115 + <!-- Rating (prominent at top) --> 116 + {#if hasValue(brew.rating)} 117 + <div class="text-center py-4 bg-brown-50 rounded-lg border border-brown-200"> 118 + <div class="text-4xl font-bold text-brown-800"> 119 + {brew.rating}/10 120 + </div> 121 + <div class="text-sm text-brown-600 mt-1">Rating</div> 122 + </div> 123 + {/if} 124 + 125 + <!-- Coffee Bean --> 126 + <div class="bg-brown-50 rounded-lg p-4 border border-brown-200"> 127 + <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">Coffee Bean</h3> 128 + {#if brew.bean} 129 + <div class="font-bold text-lg text-brown-900"> 130 + {brew.bean.name || brew.bean.origin} 131 + </div> 132 + {#if brew.bean.roaster?.Name} 133 + <div class="text-sm text-brown-700 mt-1"> 134 + by {brew.bean.roaster.name} 135 + </div> 136 + {/if} 137 + <div class="flex flex-wrap gap-3 mt-2 text-sm text-brown-600"> 138 + {#if brew.bean.origin}<span>Origin: {brew.bean.origin}</span>{/if} 139 + {#if brew.bean.roast_level}<span>Roast: {brew.bean.roast_level}</span>{/if} 140 + </div> 141 + {:else} 142 + <span class="text-brown-400">Not specified</span> 143 + {/if} 144 + </div> 145 + 146 + <!-- Brew Parameters --> 147 + <div class="grid grid-cols-2 gap-4"> 148 + <!-- Brew Method --> 149 + <div class="bg-brown-50 rounded-lg p-4 border border-brown-200"> 150 + <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">Brew Method</h3> 151 + {#if brew.brewer_obj} 152 + <div class="font-semibold text-brown-900">{brew.brewer_obj.name}</div> 153 + {:else if brew.method} 154 + <div class="font-semibold text-brown-900">{brew.method}</div> 155 + {:else} 156 + <span class="text-brown-400">Not specified</span> 157 + {/if} 158 + </div> 159 + 160 + <!-- Grinder --> 161 + <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">Grinder</h3> 163 + {#if brew.grinder_obj} 164 + <div class="font-semibold text-brown-900">{brew.grinder_obj.name}</div> 165 + {:else} 166 + <span class="text-brown-400">Not specified</span> 167 + {/if} 168 + </div> 169 + 170 + <!-- Coffee Amount --> 171 + <div class="bg-brown-50 rounded-lg p-4 border border-brown-200"> 172 + <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">Coffee</h3> 173 + {#if hasValue(brew.coffee_amount)} 174 + <div class="font-semibold text-brown-900">{brew.coffee_amount}g</div> 175 + {:else} 176 + <span class="text-brown-400">Not specified</span> 177 + {/if} 178 + </div> 179 + 180 + <!-- Water Amount --> 181 + <div class="bg-brown-50 rounded-lg p-4 border border-brown-200"> 182 + <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">Water</h3> 183 + {#if hasValue(totalWater)} 184 + <div class="font-semibold text-brown-900">{totalWater}g</div> 185 + {:else} 186 + <span class="text-brown-400">Not specified</span> 187 + {/if} 188 + </div> 189 + 190 + <!-- Grind Size --> 191 + <div class="bg-brown-50 rounded-lg p-4 border border-brown-200"> 192 + <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">Grind Size</h3> 193 + {#if brew.grind_size} 194 + <div class="font-semibold text-brown-900">{brew.grind_size}</div> 195 + {:else} 196 + <span class="text-brown-400">Not specified</span> 197 + {/if} 198 + </div> 199 + 200 + <!-- Water Temperature --> 201 + <div class="bg-brown-50 rounded-lg p-4 border border-brown-200"> 202 + <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">Water Temp</h3> 203 + {#if hasValue(brew.temperature)} 204 + <div class="font-semibold text-brown-900">{brew.temperature}°C</div> 205 + {:else} 206 + <span class="text-brown-400">Not specified</span> 207 + {/if} 208 + </div> 209 + </div> 210 + 211 + <!-- Pours (if any) --> 212 + {#if brew.pours && brew.pours.length > 0} 213 + <div class="bg-brown-50 rounded-lg p-4 border border-brown-200"> 214 + <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-3">Pour Schedule</h3> 215 + <div class="space-y-2"> 216 + {#each brew.pours as pour, i} 217 + <div class="flex justify-between text-sm"> 218 + <span class="text-brown-700">Pour {i + 1}:</span> 219 + <span class="font-semibold text-brown-900">{pour.water_amount}g at {pour.time_seconds}s</span> 220 + </div> 221 + {/each} 222 + </div> 223 + </div> 224 + {/if} 225 + 226 + <!-- Tasting Notes --> 227 + {#if brew.tasting_notes} 228 + <div class="bg-brown-50 rounded-lg p-4 border border-brown-200"> 229 + <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">Tasting Notes</h3> 230 + <p class="text-brown-900 italic">"{brew.tasting_notes}"</p> 231 + </div> 232 + {/if} 233 + </div> 234 + 235 + <!-- Back button --> 236 + <div class="mt-6"> 237 + <button 238 + on:click={() => navigate('/brews')} 239 + class="text-brown-700 hover:text-brown-900 font-medium hover:underline" 240 + > 241 + ← Back to Brews 242 + </button> 243 + </div> 244 + </div> 245 + {/if} 246 + </div>
+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>
+172
frontend/src/routes/Brews.svelte
··· 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 + 37 + function getWaterDisplay(brew) { 38 + if (hasValue(brew.water_amount) && brew.water_amount > 0) { 39 + return `💧 ${brew.water_amount}ml water`; 40 + } 41 + 42 + // If water_amount is 0 or not set, sum from pours 43 + if (brew.pours && brew.pours.length > 0) { 44 + const totalWater = brew.pours.reduce((sum, pour) => sum + (pour.water_amount || 0), 0); 45 + const pourCount = brew.pours.length; 46 + return `💧 ${totalWater}ml water (${pourCount} pour${pourCount !== 1 ? 's' : ''})`; 47 + } 48 + 49 + return null; 50 + } 51 + </script> 52 + 53 + <svelte:head> 54 + <title>My Brews - Arabica</title> 55 + </svelte:head> 56 + 57 + <div class="max-w-6xl mx-auto"> 58 + <div class="flex items-center justify-between mb-6"> 59 + <h1 class="text-3xl font-bold text-brown-900">My Brews</h1> 60 + <a 61 + href="/brews/new" 62 + on:click|preventDefault={() => navigate('/brews/new')} 63 + 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" 64 + > 65 + ☕ Add New Brew 66 + </a> 67 + </div> 68 + 69 + {#if loading} 70 + <div class="text-center py-12"> 71 + <div class="animate-spin rounded-full h-12 w-12 border-b-2 border-brown-800 mx-auto"></div> 72 + <p class="mt-4 text-brown-700">Loading brews...</p> 73 + </div> 74 + {:else if brews.length === 0} 75 + <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl p-12 text-center border border-brown-300"> 76 + <div class="text-6xl mb-4">☕</div> 77 + <h2 class="text-2xl font-bold text-brown-900 mb-2">No Brews Yet</h2> 78 + <p class="text-brown-700 mb-6">Start tracking your coffee journey by adding your first brew!</p> 79 + <button 80 + on:click={() => navigate('/brews/new')} 81 + 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" 82 + > 83 + Add Your First Brew 84 + </button> 85 + </div> 86 + {:else} 87 + <div class="space-y-4"> 88 + {#each brews as brew} 89 + <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"> 90 + <div class="flex items-start justify-between gap-4"> 91 + <div class="flex-1 min-w-0"> 92 + <!-- Bean info --> 93 + {#if brew.bean} 94 + <h3 class="text-xl font-bold text-brown-900 mb-1"> 95 + {brew.bean.name || brew.bean.origin || 'Unknown Bean'} 96 + </h3> 97 + {#if brew.bean.Roaster?.Name} 98 + <p class="text-sm text-brown-700 mb-2">🏭 {brew.bean.roaster.name}</p> 99 + {/if} 100 + {:else} 101 + <h3 class="text-xl font-bold text-brown-900 mb-1">Unknown Bean</h3> 102 + {/if} 103 + 104 + <!-- Brew details --> 105 + <div class="flex flex-wrap gap-x-4 gap-y-1 text-sm text-brown-600 mb-2"> 106 + {#if brew.brewer_obj} 107 + <span>☕ {brew.brewer_obj.name}</span> 108 + {:else if brew.method} 109 + <span>☕ {brew.method}</span> 110 + {/if} 111 + {#if hasValue(brew.temperature)} 112 + <span>🌡️ {brew.temperature}°C</span> 113 + {/if} 114 + {#if hasValue(brew.coffee_amount)} 115 + <span>⚖️ {brew.coffee_amount}g coffee</span> 116 + {/if} 117 + {#if getWaterDisplay(brew)} 118 + <span>{getWaterDisplay(brew)}</span> 119 + {/if} 120 + </div> 121 + 122 + <!-- Notes preview --> 123 + {#if brew.tasting_notes} 124 + <p class="text-sm text-brown-700 italic line-clamp-2">"{brew.tasting_notes}"</p> 125 + {/if} 126 + 127 + <!-- Date --> 128 + <p class="text-xs text-brown-500 mt-2"> 129 + {formatDate(brew.created_at || brew.created_at)} 130 + </p> 131 + </div> 132 + 133 + <div class="flex flex-col items-end gap-2"> 134 + {#if hasValue(brew.rating)} 135 + <span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-amber-100 text-amber-900"> 136 + ⭐ {brew.rating}/10 137 + </span> 138 + {/if} 139 + 140 + <div class="flex gap-2"> 141 + <a 142 + href="/brews/{brew.rkey}" 143 + on:click|preventDefault={() => navigate(`/brews/${brew.rkey}`)} 144 + class="text-brown-700 hover:text-brown-900 text-sm font-medium hover:underline" 145 + > 146 + View 147 + </a> 148 + <span class="text-brown-400">|</span> 149 + <a 150 + href="/brews/{brew.rkey}/edit" 151 + on:click|preventDefault={() => navigate(`/brews/${brew.rkey}/edit`)} 152 + class="text-brown-700 hover:text-brown-900 text-sm font-medium hover:underline" 153 + > 154 + Edit 155 + </a> 156 + </div> 157 + </div> 158 + </div> 159 + </div> 160 + {/each} 161 + </div> 162 + {/if} 163 + </div> 164 + 165 + <style> 166 + .line-clamp-2 { 167 + display: -webkit-box; 168 + -webkit-line-clamp: 2; 169 + -webkit-box-orient: vertical; 170 + overflow: hidden; 171 + } 172 + </style>
+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>
+125
frontend/src/routes/Home.svelte
··· 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 + 8 + let feedItems = []; 9 + let loading = true; 10 + let error = null; 11 + 12 + $: isAuthenticated = $authStore.isAuthenticated; 13 + $: user = $authStore.user; 14 + 15 + onMount(async () => { 16 + try { 17 + const data = await api.get('/api/feed-json'); 18 + feedItems = data.items || []; 19 + } catch (err) { 20 + // Feed might return 401 for unauthenticated users - that's okay 21 + // Just log it and show empty feed 22 + console.error('Failed to load feed:', err); 23 + if (err.status !== 401 && err.status !== 403) { 24 + error = err.message; 25 + } 26 + } finally { 27 + loading = false; 28 + } 29 + }); 30 + </script> 31 + 32 + <svelte:head> 33 + <title>Arabica - Coffee Brew Tracker</title> 34 + </svelte:head> 35 + 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"> 38 + <div class="flex items-center gap-3 mb-4"> 39 + <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> 41 + </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> 44 + 45 + {#if isAuthenticated} 46 + <!-- Authenticated: Show app actions --> 47 + <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> 49 + </div> 50 + <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"> 53 + <span class="text-xl font-semibold">☕ Add New Brew</span> 54 + </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"> 57 + <span class="text-xl font-semibold">📋 View All Brews</span> 58 + </a> 59 + </div> 60 + {:else} 61 + <!-- Not authenticated: Show login button --> 62 + <div class="text-center"> 63 + <button 64 + on:click={() => navigate('/login')} 65 + 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 + > 67 + Log In to Start Tracking 68 + </button> 69 + </div> 70 + {/if} 71 + </div> 72 + 73 + <!-- 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"> 75 + <h3 class="text-xl font-bold text-brown-900 mb-4">☕ Community Feed</h3> 76 + 77 + {#if loading} 78 + <!-- Loading state --> 79 + <div class="space-y-4"> 80 + {#each Array(3) as _} 81 + <div class="animate-pulse"> 82 + <div class="bg-brown-50 rounded-lg p-4 border border-brown-200"> 83 + <div class="flex items-center gap-3 mb-3"> 84 + <div class="w-10 h-10 rounded-full bg-brown-300"></div> 85 + <div class="flex-1"> 86 + <div class="h-4 bg-brown-300 rounded w-1/4 mb-2"></div> 87 + <div class="h-3 bg-brown-200 rounded w-1/6"></div> 88 + </div> 89 + </div> 90 + <div class="bg-brown-200 rounded-lg p-3"> 91 + <div class="h-4 bg-brown-300 rounded w-3/4 mb-2"></div> 92 + <div class="h-3 bg-brown-200 rounded w-1/2"></div> 93 + </div> 94 + </div> 95 + </div> 96 + {/each} 97 + </div> 98 + {:else if error} 99 + <div class="text-center py-8 text-brown-600"> 100 + Failed to load feed: {error} 101 + </div> 102 + {:else if feedItems.length === 0} 103 + <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} 105 + </div> 106 + {:else} 107 + <div class="space-y-4"> 108 + {#each feedItems as item (item.Timestamp)} 109 + <FeedCard {item} /> 110 + {/each} 111 + </div> 112 + {/if} 113 + </div> 114 + 115 + <div class="bg-gradient-to-br from-amber-50 to-brown-100 rounded-xl p-6 border-2 border-brown-300 shadow-lg"> 116 + <h3 class="text-lg font-bold text-brown-900 mb-3">✨ About Arabica</h3> 117 + <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> 123 + </ul> 124 + </div> 125 + </div>
+198
frontend/src/routes/Login.svelte
··· 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 = ''; 7 + let autocompleteResults = []; 8 + let showAutocomplete = false; 9 + let loading = false; 10 + let error = ''; 11 + let debounceTimeout; 12 + let abortController; 13 + 14 + // Redirect if already authenticated 15 + $: if ($authStore.isAuthenticated && !$authStore.loading) { 16 + navigate('/'); 17 + } 18 + 19 + async function searchActors(query) { 20 + // Need at least 3 characters to search 21 + if (query.length < 3) { 22 + autocompleteResults = []; 23 + showAutocomplete = false; 24 + return; 25 + } 26 + 27 + // Cancel previous request 28 + if (abortController) { 29 + abortController.abort(); 30 + } 31 + abortController = new AbortController(); 32 + 33 + try { 34 + const response = await fetch( 35 + `/api/search-actors?q=${encodeURIComponent(query)}`, 36 + { signal: abortController.signal } 37 + ); 38 + 39 + if (!response.ok) { 40 + autocompleteResults = []; 41 + showAutocomplete = false; 42 + return; 43 + } 44 + 45 + const data = await response.json(); 46 + autocompleteResults = data.actors || []; 47 + showAutocomplete = autocompleteResults.length > 0 || query.length >= 3; 48 + } catch (err) { 49 + if (err.name !== 'AbortError') { 50 + console.error('Error searching actors:', err); 51 + } 52 + } 53 + } 54 + 55 + function debounce(func, wait) { 56 + return (...args) => { 57 + clearTimeout(debounceTimeout); 58 + debounceTimeout = setTimeout(() => func(...args), wait); 59 + }; 60 + } 61 + 62 + const debouncedSearch = debounce(searchActors, 300); 63 + 64 + function handleInput(e) { 65 + handle = e.target.value; 66 + debouncedSearch(handle); 67 + } 68 + 69 + function selectActor(actor) { 70 + handle = actor.handle; 71 + autocompleteResults = []; 72 + showAutocomplete = false; 73 + } 74 + 75 + function handleClickOutside(e) { 76 + if (!e.target.closest('.autocomplete-container')) { 77 + showAutocomplete = false; 78 + } 79 + } 80 + 81 + async function handleSubmit(e) { 82 + e.preventDefault(); 83 + 84 + if (!handle) { 85 + error = 'Please enter your handle'; 86 + return; 87 + } 88 + 89 + loading = true; 90 + error = ''; 91 + 92 + // Submit form to Go backend for OAuth flow 93 + const form = e.target; 94 + form.submit(); 95 + } 96 + 97 + onMount(() => { 98 + document.addEventListener('click', handleClickOutside); 99 + return () => { 100 + document.removeEventListener('click', handleClickOutside); 101 + if (abortController) { 102 + abortController.abort(); 103 + } 104 + }; 105 + }); 106 + </script> 107 + 108 + <svelte:head> 109 + <title>Login - Arabica</title> 110 + </svelte:head> 111 + 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"> 114 + <div class="flex items-center gap-3 mb-4"> 115 + <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> 117 + </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 + 121 + <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"> 125 + <div class="relative autocomplete-container"> 126 + <label for="handle" class="block text-sm font-medium text-brown-900 mb-2">Your Handle</label> 127 + <input 128 + type="text" 129 + id="handle" 130 + name="handle" 131 + bind:value={handle} 132 + on:input={handleInput} 133 + on:focus={() => { if (autocompleteResults.length > 0 && handle.length >= 3) showAutocomplete = true; }} 134 + placeholder="alice.bsky.social" 135 + autocomplete="off" 136 + required 137 + disabled={loading} 138 + 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 + /> 140 + 141 + {#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"> 143 + {#if autocompleteResults.length === 0} 144 + <div class="px-4 py-3 text-sm text-brown-600">No accounts found</div> 145 + {:else} 146 + {#each autocompleteResults as actor} 147 + <button 148 + type="button" 149 + on:click={() => selectActor(actor)} 150 + class="w-full px-3 py-2 hover:bg-brown-100 cursor-pointer flex items-center gap-2 text-left" 151 + > 152 + <img 153 + src={actor.avatar || '/static/icon-placeholder.svg'} 154 + alt="" 155 + class="w-6 h-6 rounded-full object-cover flex-shrink-0" 156 + on:error={(e) => { e.target.src = '/static/icon-placeholder.svg'; }} 157 + /> 158 + <div class="flex-1 min-w-0"> 159 + <div class="font-medium text-sm text-brown-900 truncate"> 160 + {actor.displayName || actor.handle} 161 + </div> 162 + <div class="text-xs text-brown-600 truncate"> 163 + @{actor.handle} 164 + </div> 165 + </div> 166 + </button> 167 + {/each} 168 + {/if} 169 + </div> 170 + {/if} 171 + </div> 172 + 173 + {#if error} 174 + <div class="mt-3 text-red-600 text-sm">{error}</div> 175 + {/if} 176 + 177 + <button 178 + type="submit" 179 + disabled={loading} 180 + 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 + > 182 + {loading ? 'Logging in...' : 'Log In'} 183 + </button> 184 + </form> 185 + </div> 186 + </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"> 189 + <h3 class="text-lg font-bold text-brown-900 mb-3">✨ About Arabica</h3> 190 + <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> 196 + </ul> 197 + </div> 198 + </div>
+556
frontend/src/routes/Manage.svelte
··· 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.roast_level || '', 70 + process: bean.process || '', 71 + description: bean.description || '', 72 + roaster_rkey: bean.roaster_rkey || '', 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.grinder_type || '', 155 + burr_type: grinder.burr_type || '', 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.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.roast_level}</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.grinder_type || '-'}</td> 400 + <td class="px-4 py-3 text-sm text-brown-900">{grinder.burr_type || '-'}</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.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 + <input type="text" bind:value={beanForm.name} placeholder="Name *" 483 + class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 484 + <input type="text" bind:value={beanForm.origin} placeholder="Origin *" 485 + class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 486 + <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"> 487 + <option value="">Select Roaster (Optional)</option> 488 + {#each roasters as roaster} 489 + <option value={roaster.rkey}>{roaster.name}</option> 490 + {/each} 491 + </select> 492 + <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"> 493 + <option value="">Select Roast Level (Optional)</option> 494 + <option value="Ultra-Light">Ultra-Light</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 + <input type="text" bind:value={beanForm.process} placeholder="Process (e.g. Washed, Natural, Honey)" 502 + class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 503 + <textarea bind:value={beanForm.description} placeholder="Description" rows="3" 504 + class="w-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> 505 + </Modal> 506 + 507 + <Modal 508 + bind:isOpen={showRoasterModal} 509 + title={editingRoaster ? 'Edit Roaster' : 'Add Roaster'} 510 + onSave={saveRoaster} 511 + onCancel={() => showRoasterModal = false} 512 + > 513 + <input type="text" bind:value={roasterForm.name} placeholder="Name *" 514 + class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 515 + <input type="text" bind:value={roasterForm.location} placeholder="Location" 516 + class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 517 + <input type="url" bind:value={roasterForm.website} placeholder="Website" 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 + </Modal> 520 + 521 + <Modal 522 + bind:isOpen={showGrinderModal} 523 + title={editingGrinder ? 'Edit Grinder' : 'Add Grinder'} 524 + onSave={saveGrinder} 525 + onCancel={() => showGrinderModal = false} 526 + > 527 + <input type="text" bind:value={grinderForm.name} placeholder="Name *" 528 + class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 529 + <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"> 530 + <option value="">Select Grinder Type *</option> 531 + <option value="Hand">Hand</option> 532 + <option value="Electric">Electric</option> 533 + <option value="Portable Electric">Portable Electric</option> 534 + </select> 535 + <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"> 536 + <option value="">Select Burr Type (Optional)</option> 537 + <option value="Conical">Conical</option> 538 + <option value="Flat">Flat</option> 539 + </select> 540 + <textarea bind:value={grinderForm.notes} placeholder="Notes" rows="3" 541 + class="w-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> 542 + </Modal> 543 + 544 + <Modal 545 + bind:isOpen={showBrewerModal} 546 + title={editingBrewer ? 'Edit Brewer' : 'Add Brewer'} 547 + onSave={saveBrewer} 548 + onCancel={() => showBrewerModal = false} 549 + > 550 + <input type="text" bind:value={brewerForm.name} placeholder="Name *" 551 + class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 552 + <input type="text" bind:value={brewerForm.brewer_type} placeholder="Type (e.g., Pour-Over, Immersion, Espresso)" 553 + class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 554 + <textarea bind:value={brewerForm.description} placeholder="Description" rows="3" 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"></textarea> 556 + </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>
+8
frontend/src/routes/NotFound.svelte
··· 1 + <div class="text-center py-12"> 2 + <div class="text-6xl mb-4">☕</div> 3 + <h1 class="text-4xl font-bold text-brown-900 mb-4">404 - Not Found</h1> 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"> 6 + Go Home 7 + </a> 8 + </div>
+304
frontend/src/routes/Profile.svelte
··· 1 + <script> 2 + import { onMount } from 'svelte'; 3 + import { api } from '../lib/api.js'; 4 + import { navigate } from '../lib/router.js'; 5 + 6 + export let actor; 7 + 8 + let profile = null; 9 + let brews = []; 10 + let beans = []; 11 + let roasters = []; 12 + let grinders = []; 13 + let brewers = []; 14 + let isOwnProfile = false; 15 + let loading = true; 16 + let error = null; 17 + 18 + let activeTab = 'brews'; 19 + 20 + onMount(async () => { 21 + try { 22 + const data = await api.get(`/api/profile-json/${actor}`); 23 + profile = data.profile; 24 + brews = (data.brews || []).sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); 25 + beans = data.beans || []; 26 + roasters = data.roasters || []; 27 + grinders = data.grinders || []; 28 + brewers = data.brewers || []; 29 + isOwnProfile = data.isOwnProfile || false; 30 + } catch (err) { 31 + console.error('Failed to load profile:', err); 32 + error = err.message; 33 + } finally { 34 + loading = false; 35 + } 36 + }); 37 + 38 + function formatDate(dateStr) { 39 + const date = new Date(dateStr); 40 + return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); 41 + } 42 + </script> 43 + 44 + <svelte:head> 45 + <title>{profile?.displayName || profile?.handle || 'Profile'} - Arabica</title> 46 + </svelte:head> 47 + 48 + <div class="max-w-4xl mx-auto"> 49 + {#if loading} 50 + <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> 52 + <p class="mt-4 text-brown-700">Loading profile...</p> 53 + </div> 54 + {:else if error} 55 + <div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded"> 56 + Error: {error} 57 + </div> 58 + {:else if profile} 59 + <!-- 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"> 61 + <div class="flex items-center gap-4"> 62 + {#if profile.avatar} 63 + <img src={profile.avatar} alt="" class="w-20 h-20 rounded-full object-cover border-2 border-brown-300" /> 64 + {:else} 65 + <div class="w-20 h-20 rounded-full bg-brown-300 flex items-center justify-center"> 66 + <span class="text-brown-600 text-2xl">?</span> 67 + </div> 68 + {/if} 69 + <div> 70 + {#if profile.displayName} 71 + <h1 class="text-2xl font-bold text-brown-900">{profile.displayName}</h1> 72 + {/if} 73 + <p class="text-brown-700">@{profile.handle}</p> 74 + </div> 75 + </div> 76 + </div> 77 + 78 + <!-- Stats --> 79 + <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"> 81 + <div class="text-2xl font-bold text-brown-800">{brews.length}</div> 82 + <div class="text-sm text-brown-700">Brews</div> 83 + </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"> 85 + <div class="text-2xl font-bold text-brown-800">{beans.length}</div> 86 + <div class="text-sm text-brown-700">Beans</div> 87 + </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"> 89 + <div class="text-2xl font-bold text-brown-800">{roasters.length}</div> 90 + <div class="text-sm text-brown-700">Roasters</div> 91 + </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"> 93 + <div class="text-2xl font-bold text-brown-800">{grinders.length}</div> 94 + <div class="text-sm text-brown-700">Grinders</div> 95 + </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"> 97 + <div class="text-2xl font-bold text-brown-800">{brewers.length}</div> 98 + <div class="text-sm text-brown-700">Brewers</div> 99 + </div> 100 + </div> 101 + 102 + <!-- Tabs --> 103 + <div> 104 + <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-md mb-4 border border-brown-300"> 105 + <div class="flex border-b border-brown-300"> 106 + <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'}" 109 + > 110 + Brews 111 + </button> 112 + <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'}" 115 + > 116 + Beans 117 + </button> 118 + <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'}" 121 + > 122 + Gear 123 + </button> 124 + </div> 125 + </div> 126 + 127 + <!-- Tab Content --> 128 + {#if activeTab === 'brews'} 129 + {#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"> 131 + <p class="text-brown-800 text-lg font-medium">No brews yet.</p> 132 + </div> 133 + {: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"> 135 + <table class="min-w-full divide-y divide-brown-300"> 136 + <thead class="bg-brown-200/80"> 137 + <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> 143 + </tr> 144 + </thead> 145 + <tbody class="bg-brown-50/60 divide-y divide-brown-200"> 146 + {#each brews as brew} 147 + <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> 152 + <td class="px-4 py-3 text-sm text-brown-900"> 153 + {#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"> 155 + ⭐ {brew.rating}/10 156 + </span> 157 + {:else} 158 + <span class="text-brown-400">-</span> 159 + {/if} 160 + </td> 161 + </tr> 162 + {/each} 163 + </tbody> 164 + </table> 165 + </div> 166 + {/if} 167 + {:else if activeTab === 'beans'} 168 + <div class="space-y-6"> 169 + {#if beans.length > 0} 170 + <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"> 173 + <table class="min-w-full divide-y divide-brown-300"> 174 + <thead class="bg-brown-200/80"> 175 + <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> 182 + </tr> 183 + </thead> 184 + <tbody class="bg-brown-50/60 divide-y divide-brown-200"> 185 + {#each beans as bean} 186 + <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> 193 + </tr> 194 + {/each} 195 + </tbody> 196 + </table> 197 + </div> 198 + </div> 199 + {/if} 200 + 201 + {#if roasters.length > 0} 202 + <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"> 205 + <table class="min-w-full divide-y divide-brown-300"> 206 + <thead class="bg-brown-200/80"> 207 + <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> 211 + </tr> 212 + </thead> 213 + <tbody class="bg-brown-50/60 divide-y divide-brown-200"> 214 + {#each roasters as roaster} 215 + <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> 218 + <td class="px-6 py-4 text-sm text-brown-900"> 219 + {#if roaster.website} 220 + <a href={roaster.website} target="_blank" rel="noopener noreferrer" class="text-brown-700 hover:underline font-medium">Visit Site</a> 221 + {:else} 222 + - 223 + {/if} 224 + </td> 225 + </tr> 226 + {/each} 227 + </tbody> 228 + </table> 229 + </div> 230 + </div> 231 + {/if} 232 + 233 + {#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"> 235 + <p class="font-medium">No beans or roasters yet.</p> 236 + </div> 237 + {/if} 238 + </div> 239 + {:else if activeTab === 'gear'} 240 + <div class="space-y-6"> 241 + {#if grinders.length > 0} 242 + <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"> 245 + <table class="min-w-full divide-y divide-brown-300"> 246 + <thead class="bg-brown-200/80"> 247 + <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> 252 + </tr> 253 + </thead> 254 + <tbody class="bg-brown-50/60 divide-y divide-brown-200"> 255 + {#each grinders as grinder} 256 + <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> 261 + </tr> 262 + {/each} 263 + </tbody> 264 + </table> 265 + </div> 266 + </div> 267 + {/if} 268 + 269 + {#if brewers.length > 0} 270 + <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"> 273 + <table class="min-w-full divide-y divide-brown-300"> 274 + <thead class="bg-brown-200/80"> 275 + <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> 279 + </tr> 280 + </thead> 281 + <tbody class="bg-brown-50/60 divide-y divide-brown-200"> 282 + {#each brewers as brewer} 283 + <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> 287 + </tr> 288 + {/each} 289 + </tbody> 290 + </table> 291 + </div> 292 + </div> 293 + {/if} 294 + 295 + {#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"> 297 + <p class="font-medium">No gear added yet.</p> 298 + </div> 299 + {/if} 300 + </div> 301 + {/if} 302 + </div> 303 + {/if} 304 + </div>
+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>
+60
frontend/src/routes/Terms.svelte
··· 1 + <div class="max-w-4xl mx-auto"> 2 + <div class="bg-white rounded-xl p-8 shadow-lg"> 3 + <h1 class="text-3xl font-bold text-brown-900 mb-6">Terms of Service</h1> 4 + 5 + <div class="prose prose-brown max-w-none text-brown-800 space-y-4"> 6 + <p class="text-sm text-brown-600 italic"> 7 + Last updated: {new Date().toLocaleDateString()} 8 + </p> 9 + 10 + <h2 class="text-2xl font-bold text-brown-900 mt-8"> 11 + 1. Acceptance of Terms 12 + </h2> 13 + <p> 14 + By accessing and using Arabica, you accept and agree to be bound by the 15 + terms and provision of this agreement. 16 + </p> 17 + 18 + <h2 class="text-2xl font-bold text-brown-900 mt-8"> 19 + 2. Alpha Software Notice 20 + </h2> 21 + <p> 22 + Arabica is currently in alpha testing. Features, data structures, and 23 + functionality may change without notice. We recommend backing up your 24 + data regularly. 25 + </p> 26 + 27 + <h2 class="text-2xl font-bold text-brown-900 mt-8">3. Data Storage</h2> 28 + <p> 29 + Your brewing data is stored in your Personal Data Server (PDS) via the 30 + AT Protocol. Arabica does not store your brewing records on its servers. 31 + You are responsible for the security and backup of your PDS. 32 + </p> 33 + 34 + <h2 class="text-2xl font-bold text-brown-900 mt-8"> 35 + 4. User Responsibilities 36 + </h2> 37 + <p> 38 + You are responsible for maintaining the confidentiality of your account 39 + credentials and for all activities that occur under your account. 40 + </p> 41 + 42 + <h2 class="text-2xl font-bold text-brown-900 mt-8"> 43 + 5. Limitation of Liability 44 + </h2> 45 + <p> 46 + Arabica is provided "as is" without warranty of any kind. We are not 47 + liable for any data loss, service interruptions, or other damages 48 + arising from your use of the application. 49 + </p> 50 + 51 + <h2 class="text-2xl font-bold text-brown-900 mt-8"> 52 + 6. Changes to Terms 53 + </h2> 54 + <p> 55 + We reserve the right to modify these terms at any time. Continued use of 56 + Arabica after changes constitutes acceptance of the modified terms. 57 + </p> 58 + </div> 59 + </div> 60 + </div>
+67
frontend/src/stores/auth.js
··· 1 + import { writable } from 'svelte/store'; 2 + import { api } from '../lib/api.js'; 3 + 4 + /** 5 + * Auth store - tracks current user authentication state 6 + */ 7 + function createAuthStore() { 8 + const { subscribe, set, update } = writable({ 9 + isAuthenticated: false, 10 + user: null, 11 + loading: true, 12 + }); 13 + 14 + return { 15 + subscribe, 16 + 17 + /** 18 + * Check current authentication status 19 + */ 20 + async checkAuth() { 21 + try { 22 + const user = await api.get('/api/me'); 23 + set({ 24 + isAuthenticated: true, 25 + user, 26 + loading: false, 27 + }); 28 + } catch (error) { 29 + set({ 30 + isAuthenticated: false, 31 + user: null, 32 + loading: false, 33 + }); 34 + } 35 + }, 36 + 37 + /** 38 + * Log out current user 39 + */ 40 + async logout() { 41 + try { 42 + await api.post('/logout', {}); 43 + set({ 44 + isAuthenticated: false, 45 + user: null, 46 + loading: false, 47 + }); 48 + window.location.href = '/'; 49 + } catch (error) { 50 + console.error('Logout failed:', error); 51 + } 52 + }, 53 + 54 + /** 55 + * Clear auth state (used after logout) 56 + */ 57 + clear() { 58 + set({ 59 + isAuthenticated: false, 60 + user: null, 61 + loading: false, 62 + }); 63 + }, 64 + }; 65 + } 66 + 67 + export const authStore = createAuthStore();
+114
frontend/src/stores/cache.js
··· 1 + import { writable } from 'svelte/store'; 2 + import { api } from '../lib/api.js'; 3 + 4 + /** 5 + * Cache store - stale-while-revalidate pattern for user data 6 + * Replaces the old data-cache.js with reactive Svelte store 7 + */ 8 + function createCacheStore() { 9 + const { subscribe, set, update } = writable({ 10 + beans: [], 11 + roasters: [], 12 + grinders: [], 13 + brewers: [], 14 + brews: [], 15 + lastFetch: null, 16 + loading: false, 17 + }); 18 + 19 + const CACHE_KEY = 'arabica_data_cache'; 20 + const STALE_TIME = 5 * 60 * 1000; // 5 minutes 21 + 22 + return { 23 + subscribe, 24 + 25 + /** 26 + * Load data from cache or API 27 + * Uses stale-while-revalidate pattern 28 + */ 29 + async load(force = false) { 30 + // Try to load from localStorage first 31 + if (!force) { 32 + const cached = localStorage.getItem(CACHE_KEY); 33 + if (cached) { 34 + try { 35 + const data = JSON.parse(cached); 36 + const age = Date.now() - data.timestamp; 37 + 38 + if (age < STALE_TIME) { 39 + // Fresh cache, use it 40 + set({ 41 + ...data, 42 + lastFetch: data.timestamp, 43 + loading: false, 44 + }); 45 + return; 46 + } 47 + 48 + // Stale cache, show it but refetch in background 49 + set({ 50 + ...data, 51 + lastFetch: data.timestamp, 52 + loading: true, 53 + }); 54 + } catch (e) { 55 + console.error('Failed to parse cache:', e); 56 + } 57 + } 58 + } 59 + 60 + // Fetch fresh data 61 + try { 62 + update(state => ({ ...state, loading: true })); 63 + 64 + const data = await api.get('/api/data'); 65 + const newState = { 66 + beans: data.beans || [], 67 + roasters: data.roasters || [], 68 + grinders: data.grinders || [], 69 + brewers: data.brewers || [], 70 + brews: data.brews || [], 71 + lastFetch: Date.now(), 72 + loading: false, 73 + }; 74 + 75 + set(newState); 76 + 77 + // Save to localStorage 78 + localStorage.setItem(CACHE_KEY, JSON.stringify({ 79 + ...newState, 80 + timestamp: newState.lastFetch, 81 + })); 82 + } catch (error) { 83 + console.error('Failed to fetch data:', error); 84 + update(state => ({ ...state, loading: false })); 85 + } 86 + }, 87 + 88 + /** 89 + * Invalidate cache and refetch 90 + */ 91 + async invalidate() { 92 + localStorage.removeItem(CACHE_KEY); 93 + await this.load(true); 94 + }, 95 + 96 + /** 97 + * Clear cache completely 98 + */ 99 + clear() { 100 + localStorage.removeItem(CACHE_KEY); 101 + set({ 102 + beans: [], 103 + roasters: [], 104 + grinders: [], 105 + brewers: [], 106 + brews: [], 107 + lastFetch: null, 108 + loading: false, 109 + }); 110 + }, 111 + }; 112 + } 113 + 114 + export const cacheStore = createCacheStore();
+48
frontend/src/stores/ui.js
··· 1 + import { writable } from 'svelte/store'; 2 + 3 + /** 4 + * UI store - manages global UI state like modals, notifications, etc. 5 + */ 6 + function createUIStore() { 7 + const { subscribe, update } = writable({ 8 + notifications: [], 9 + }); 10 + 11 + return { 12 + subscribe, 13 + 14 + /** 15 + * Show a notification 16 + * @param {string} message - Notification message 17 + * @param {string} type - Type: 'success', 'error', 'info' 18 + * @param {number} duration - Duration in ms (0 = no auto-dismiss) 19 + */ 20 + notify(message, type = 'info', duration = 5000) { 21 + const id = Date.now(); 22 + update(state => ({ 23 + ...state, 24 + notifications: [...state.notifications, { id, message, type }], 25 + })); 26 + 27 + if (duration > 0) { 28 + setTimeout(() => { 29 + this.dismissNotification(id); 30 + }, duration); 31 + } 32 + 33 + return id; 34 + }, 35 + 36 + /** 37 + * Dismiss a notification by ID 38 + */ 39 + dismissNotification(id) { 40 + update(state => ({ 41 + ...state, 42 + notifications: state.notifications.filter(n => n.id !== id), 43 + })); 44 + }, 45 + }; 46 + } 47 + 48 + export const uiStore = createUIStore();
+52
frontend/vite.config.js
··· 1 + import { defineConfig } from 'vite'; 2 + import { svelte } from '@sveltejs/vite-plugin-svelte'; 3 + import path from 'path'; 4 + import { fileURLToPath } from 'url'; 5 + 6 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 7 + 8 + export default defineConfig({ 9 + plugins: [svelte()], 10 + root: __dirname, 11 + publicDir: 'public', 12 + server: { 13 + port: 5173, 14 + proxy: { 15 + '/api': { 16 + target: 'http://localhost:18910', 17 + changeOrigin: true, 18 + }, 19 + '/auth': { 20 + target: 'http://localhost:18910', 21 + changeOrigin: true, 22 + }, 23 + '/oauth': { 24 + target: 'http://localhost:18910', 25 + changeOrigin: true, 26 + }, 27 + '/login': { 28 + target: 'http://localhost:18910', 29 + changeOrigin: true, 30 + }, 31 + '/logout': { 32 + target: 'http://localhost:18910', 33 + changeOrigin: true, 34 + }, 35 + '/static': { 36 + target: 'http://localhost:18910', 37 + changeOrigin: true, 38 + }, 39 + }, 40 + }, 41 + base: '/static/app/', 42 + build: { 43 + outDir: path.resolve(__dirname, '../web/static/app'), 44 + emptyOutDir: true, 45 + assetsDir: 'assets', 46 + }, 47 + resolve: { 48 + alias: { 49 + '@': path.resolve(__dirname, './src'), 50 + }, 51 + }, 52 + });
+31 -2
internal/atproto/records.go
··· 91 91 if !ok || beanRef == "" { 92 92 return nil, fmt.Errorf("beanRef is required") 93 93 } 94 - // Store the beanRef for later resolution 95 - // For now, we'll just note it exists but won't resolve it here 94 + // Extract rkey from beanRef AT-URI 95 + if beanRef != "" { 96 + parsedBeanURI, err := syntax.ParseATURI(beanRef) 97 + if err == nil { 98 + brew.BeanRKey = parsedBeanURI.RecordKey().String() 99 + } 100 + } 101 + 102 + // Optional: grinderRef 103 + if grinderRef, ok := record["grinderRef"].(string); ok && grinderRef != "" { 104 + parsedGrinderURI, err := syntax.ParseATURI(grinderRef) 105 + if err == nil { 106 + brew.GrinderRKey = parsedGrinderURI.RecordKey().String() 107 + } 108 + } 109 + 110 + // Optional: brewerRef 111 + if brewerRef, ok := record["brewerRef"].(string); ok && brewerRef != "" { 112 + parsedBrewerURI, err := syntax.ParseATURI(brewerRef) 113 + if err == nil { 114 + brew.BrewerRKey = parsedBrewerURI.RecordKey().String() 115 + } 116 + } 96 117 97 118 // Required field: createdAt 98 119 createdAtStr, ok := record["createdAt"].(string) ··· 230 251 bean.Description = description 231 252 } 232 253 254 + // Optional: roasterRef 255 + if roasterRef, ok := record["roasterRef"].(string); ok && roasterRef != "" { 256 + parsedRoasterURI, err := syntax.ParseATURI(roasterRef) 257 + if err == nil { 258 + bean.RoasterRKey = parsedRoasterURI.RecordKey().String() 259 + } 260 + } 261 + 233 262 return bean, nil 234 263 } 235 264
+37
internal/atproto/records_test.go
··· 342 342 t.Error("RecordToBean() should error without name") 343 343 } 344 344 }) 345 + 346 + t.Run("extracts roasterRKey from roasterRef", func(t *testing.T) { 347 + record := map[string]interface{}{ 348 + "$type": NSIDBean, 349 + "name": "Ethiopian Yirgacheffe", 350 + "roasterRef": "at://did:plc:hm5f3dnm6jdhrc55qp2npdja/social.arabica.alpha.roaster/3mc6ixb5f3s2i", 351 + "createdAt": "2025-01-10T12:00:00Z", 352 + } 353 + 354 + atURI := "at://did:plc:test/social.arabica.alpha.bean/bean123" 355 + bean, err := RecordToBean(record, atURI) 356 + if err != nil { 357 + t.Fatalf("RecordToBean() error = %v", err) 358 + } 359 + 360 + if bean.RoasterRKey != "3mc6ixb5f3s2i" { 361 + t.Errorf("RoasterRKey = %v, want %v", bean.RoasterRKey, "3mc6ixb5f3s2i") 362 + } 363 + }) 364 + 365 + t.Run("handles missing roasterRef gracefully", func(t *testing.T) { 366 + record := map[string]interface{}{ 367 + "$type": NSIDBean, 368 + "name": "Ethiopian Yirgacheffe", 369 + "createdAt": "2025-01-10T12:00:00Z", 370 + } 371 + 372 + atURI := "at://did:plc:test/social.arabica.alpha.bean/bean123" 373 + bean, err := RecordToBean(record, atURI) 374 + if err != nil { 375 + t.Fatalf("RecordToBean() error = %v", err) 376 + } 377 + 378 + if bean.RoasterRKey != "" { 379 + t.Errorf("RoasterRKey = %v, want empty string", bean.RoasterRKey) 380 + } 381 + }) 345 382 } 346 383 347 384 func TestRoasterToRecord(t *testing.T) {
+6
internal/bff/__snapshots__/brew_with_minimal_data.snap
··· 38 38 </div> 39 39 </div> 40 40 <div class="grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-brown-700"></div> 41 + <div class="mt-3 border-t border-brown-200 pt-3"> 42 + <a href="/brews/brew456?owner=newbie" 43 + class="inline-flex items-center text-sm font-medium text-brown-700 hover:text-brown-900 hover:underline"> 44 + View full details → 45 + </a> 46 + </div> 41 47 </div> 42 48 </div> 43 49 </div>
+6
internal/bff/__snapshots__/brew_with_unicode_bean_name.snap
··· 48 48 </span> 49 49 </div> 50 50 <div class="grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-brown-700"></div> 51 + <div class="mt-3 border-t border-brown-200 pt-3"> 52 + <a href="/brews/brew789?owner=japan.coffee" 53 + class="inline-flex items-center text-sm font-medium text-brown-700 hover:text-brown-900 hover:underline"> 54 + View full details → 55 + </a> 56 + </div> 51 57 </div> 52 58 </div> 53 59 </div>
+6
internal/bff/__snapshots__/complete_brew_with_all_fields.snap
··· 102 102 <div class="mt-3 text-sm text-brown-800 italic border-t border-brown-200 pt-2"> 103 103 "Bright citrus notes with floral aroma" 104 104 </div> 105 + <div class="mt-3 border-t border-brown-200 pt-3"> 106 + <a href="/brews/brew123?owner=coffee.lover" 107 + class="inline-flex items-center text-sm font-medium text-brown-700 hover:text-brown-900 hover:underline"> 108 + View full details → 109 + </a> 110 + </div> 105 111 </div> 106 112 </div> 107 113 </div>
+6
internal/bff/__snapshots__/mixed_feed_all_types.snap
··· 106 106 <div class="mt-3 text-sm text-brown-800 italic border-t border-brown-200 pt-2"> 107 107 "Bright citrus notes with floral aroma" 108 108 </div> 109 + <div class="mt-3 border-t border-brown-200 pt-3"> 110 + <a href="/brews/brew123?owner=user1" 111 + class="inline-flex items-center text-sm font-medium text-brown-700 hover:text-brown-900 hover:underline"> 112 + View full details → 113 + </a> 114 + </div> 109 115 </div> 110 116 </div> 111 117 <div class="bg-gradient-to-br from-brown-50 to-brown-100 rounded-lg shadow-md border border-brown-200 p-4 hover:shadow-lg transition-shadow">
+6
internal/bff/__snapshots__/special_characters_in_content.snap
··· 47 47 <div class="mt-3 text-sm text-brown-800 italic border-t border-brown-200 pt-2"> 48 48 "Notes with &#34;quotes&#34; and &lt;html&gt;tags&lt;/html&gt; and &#39;single quotes&#39;" 49 49 </div> 50 + <div class="mt-3 border-t border-brown-200 pt-3"> 51 + <a href="/brews/brew999?owner=special.chars" 52 + class="inline-flex items-center text-sm font-medium text-brown-700 hover:text-brown-900 hover:underline"> 53 + View full details → 54 + </a> 55 + </div> 50 56 </div> 51 57 </div> 52 58 </div>
+3 -3
internal/bff/render.go
··· 117 117 118 118 // UserProfile contains user profile data for header display 119 119 type UserProfile struct { 120 - Handle string 121 - DisplayName string 122 - Avatar string 120 + Handle string `json:"handle"` 121 + DisplayName string `json:"displayName"` 122 + Avatar string `json:"avatar"` 123 123 } 124 124 125 125 // PageData contains data for rendering pages
+228
internal/handlers/handlers.go
··· 135 135 return store, true 136 136 } 137 137 138 + // SPA fallback handler - serves index.html for client-side routes 139 + func (h *Handler) HandleSPAFallback(w http.ResponseWriter, r *http.Request) { 140 + http.ServeFile(w, r, "web/static/app/index.html") 141 + } 142 + 138 143 // Home page 139 144 func (h *Handler) HandleHome(w http.ResponseWriter, r *http.Request) { 140 145 // Check if user is authenticated ··· 177 182 } 178 183 } 179 184 185 + // API endpoint for feed (JSON) 186 + func (h *Handler) HandleFeedAPI(w http.ResponseWriter, r *http.Request) { 187 + var feedItems []*feed.FeedItem 188 + 189 + // Check if user is authenticated 190 + _, err := atproto.GetAuthenticatedDID(r.Context()) 191 + isAuthenticated := err == nil 192 + 193 + if h.feedService != nil { 194 + if isAuthenticated { 195 + feedItems, _ = h.feedService.GetRecentRecords(r.Context(), feed.FeedLimit) 196 + } else { 197 + // Unauthenticated users get a limited feed from the cache 198 + feedItems, _ = h.feedService.GetCachedPublicFeed(r.Context()) 199 + } 200 + } 201 + 202 + w.Header().Set("Content-Type", "application/json") 203 + if err := json.NewEncoder(w).Encode(map[string]interface{}{ 204 + "items": feedItems, 205 + "isAuthenticated": isAuthenticated, 206 + }); err != nil { 207 + log.Error().Err(err).Msg("Failed to encode feed API response") 208 + } 209 + } 210 + 211 + // HandleProfileAPI returns profile data for a given actor (handle or DID) 212 + func (h *Handler) HandleProfileAPI(w http.ResponseWriter, r *http.Request) { 213 + actor := r.PathValue("actor") 214 + if actor == "" { 215 + http.Error(w, "Actor parameter required", http.StatusBadRequest) 216 + return 217 + } 218 + 219 + ctx := r.Context() 220 + 221 + // Check if current user is authenticated 222 + currentUserDID, err := atproto.GetAuthenticatedDID(ctx) 223 + isAuthenticated := err == nil && currentUserDID != "" 224 + 225 + // Resolve actor to DID 226 + var targetDID string 227 + if strings.HasPrefix(actor, "did:") { 228 + targetDID = actor 229 + } else { 230 + // Resolve handle to DID 231 + publicClient := atproto.NewPublicClient() 232 + resolvedDID, err := publicClient.ResolveHandle(ctx, actor) 233 + if err != nil { 234 + http.Error(w, "Failed to resolve handle", http.StatusNotFound) 235 + log.Error().Err(err).Str("actor", actor).Msg("Failed to resolve handle") 236 + return 237 + } 238 + targetDID = resolvedDID 239 + } 240 + 241 + // Check if viewing own profile 242 + isOwnProfile := isAuthenticated && currentUserDID == targetDID 243 + 244 + // Get profile info 245 + profile := h.getUserProfile(ctx, targetDID) 246 + if profile == nil { 247 + http.Error(w, "Profile not found", http.StatusNotFound) 248 + return 249 + } 250 + 251 + // Fetch user's data using public client (works for any user) 252 + publicClient := atproto.NewPublicClient() 253 + 254 + // Fetch all collections in parallel 255 + g, ctx := errgroup.WithContext(ctx) 256 + 257 + var brewRecords, beanRecords, roasterRecords, grinderRecords, brewerRecords *atproto.PublicListRecordsOutput 258 + 259 + g.Go(func() error { 260 + var err error 261 + brewRecords, err = publicClient.ListRecords(ctx, targetDID, atproto.NSIDBrew, 100) 262 + return err 263 + }) 264 + g.Go(func() error { 265 + var err error 266 + beanRecords, err = publicClient.ListRecords(ctx, targetDID, atproto.NSIDBean, 100) 267 + return err 268 + }) 269 + g.Go(func() error { 270 + var err error 271 + roasterRecords, err = publicClient.ListRecords(ctx, targetDID, atproto.NSIDRoaster, 100) 272 + return err 273 + }) 274 + g.Go(func() error { 275 + var err error 276 + grinderRecords, err = publicClient.ListRecords(ctx, targetDID, atproto.NSIDGrinder, 100) 277 + return err 278 + }) 279 + g.Go(func() error { 280 + var err error 281 + brewerRecords, err = publicClient.ListRecords(ctx, targetDID, atproto.NSIDBrewer, 100) 282 + return err 283 + }) 284 + 285 + if err := g.Wait(); err != nil { 286 + http.Error(w, "Failed to fetch profile data", http.StatusInternalServerError) 287 + log.Error().Err(err).Str("did", targetDID).Msg("Failed to fetch profile data") 288 + return 289 + } 290 + 291 + // Convert records to models 292 + brews := make([]*models.Brew, 0, len(brewRecords.Records)) 293 + for _, rec := range brewRecords.Records { 294 + brew, err := atproto.RecordToBrew(rec.Value, rec.URI) 295 + if err == nil { 296 + brews = append(brews, brew) 297 + } 298 + } 299 + 300 + beans := make([]*models.Bean, 0, len(beanRecords.Records)) 301 + for _, rec := range beanRecords.Records { 302 + bean, err := atproto.RecordToBean(rec.Value, rec.URI) 303 + if err == nil { 304 + beans = append(beans, bean) 305 + } 306 + } 307 + 308 + roasters := make([]*models.Roaster, 0, len(roasterRecords.Records)) 309 + for _, rec := range roasterRecords.Records { 310 + roaster, err := atproto.RecordToRoaster(rec.Value, rec.URI) 311 + if err == nil { 312 + roasters = append(roasters, roaster) 313 + } 314 + } 315 + 316 + grinders := make([]*models.Grinder, 0, len(grinderRecords.Records)) 317 + for _, rec := range grinderRecords.Records { 318 + grinder, err := atproto.RecordToGrinder(rec.Value, rec.URI) 319 + if err == nil { 320 + grinders = append(grinders, grinder) 321 + } 322 + } 323 + 324 + brewers := make([]*models.Brewer, 0, len(brewerRecords.Records)) 325 + for _, rec := range brewerRecords.Records { 326 + brewer, err := atproto.RecordToBrewer(rec.Value, rec.URI) 327 + if err == nil { 328 + brewers = append(brewers, brewer) 329 + } 330 + } 331 + 332 + // Link beans to roasters 333 + atproto.LinkBeansToRoasters(beans, roasters) 334 + 335 + // Resolve references in brews 336 + for _, brew := range brews { 337 + if brew.BeanRKey != "" { 338 + for _, bean := range beans { 339 + if bean.RKey == brew.BeanRKey { 340 + brew.Bean = bean 341 + break 342 + } 343 + } 344 + } 345 + if brew.GrinderRKey != "" { 346 + for _, grinder := range grinders { 347 + if grinder.RKey == brew.GrinderRKey { 348 + brew.GrinderObj = grinder 349 + break 350 + } 351 + } 352 + } 353 + if brew.BrewerRKey != "" { 354 + for _, brewer := range brewers { 355 + if brewer.RKey == brew.BrewerRKey { 356 + brew.BrewerObj = brewer 357 + break 358 + } 359 + } 360 + } 361 + } 362 + 363 + response := map[string]interface{}{ 364 + "profile": profile, 365 + "brews": brews, 366 + "beans": beans, 367 + "roasters": roasters, 368 + "grinders": grinders, 369 + "brewers": brewers, 370 + "isOwnProfile": isOwnProfile, 371 + } 372 + 373 + w.Header().Set("Content-Type", "application/json") 374 + if err := json.NewEncoder(w).Encode(response); err != nil { 375 + log.Error().Err(err).Msg("Failed to encode profile API response") 376 + } 377 + } 378 + 180 379 // Brew list partial (loaded async via HTMX) 181 380 func (h *Handler) HandleBrewListPartial(w http.ResponseWriter, r *http.Request) { 182 381 // Require authentication ··· 833 1032 } 834 1033 } 835 1034 1035 + // API endpoint to get current user info 1036 + func (h *Handler) HandleAPIMe(w http.ResponseWriter, r *http.Request) { 1037 + // Get authenticated DID from context 1038 + didStr, err := atproto.GetAuthenticatedDID(r.Context()) 1039 + if err != nil { 1040 + http.Error(w, "Authentication required", http.StatusUnauthorized) 1041 + return 1042 + } 1043 + 1044 + // Fetch user profile 1045 + userProfile := h.getUserProfile(r.Context(), didStr) 1046 + if userProfile == nil { 1047 + http.Error(w, "Failed to fetch user profile", http.StatusInternalServerError) 1048 + return 1049 + } 1050 + 1051 + response := map[string]interface{}{ 1052 + "did": didStr, 1053 + "handle": userProfile.Handle, 1054 + "displayName": userProfile.DisplayName, 1055 + "avatar": userProfile.Avatar, 1056 + } 1057 + 1058 + w.Header().Set("Content-Type", "application/json") 1059 + if err := json.NewEncoder(w).Encode(response); err != nil { 1060 + log.Error().Err(err).Msg("Failed to encode API response") 1061 + } 1062 + } 1063 + 836 1064 // API endpoint to create bean 837 1065 func (h *Handler) HandleBeanCreate(w http.ResponseWriter, r *http.Request) { 838 1066 var req models.CreateBeanRequest
+28 -13
internal/routing/routing.go
··· 42 42 // Auth-protected but accessible without HTMX header (called from JavaScript) 43 43 mux.HandleFunc("GET /api/data", h.HandleAPIListAll) 44 44 45 + // API route for current user info (used by Svelte auth store) 46 + mux.HandleFunc("GET /api/me", h.HandleAPIMe) 47 + 48 + // API endpoint for feed (JSON) 49 + mux.HandleFunc("GET /api/feed-json", h.HandleFeedAPI) 50 + 51 + // API endpoint for profile data (JSON for Svelte) 52 + mux.HandleFunc("GET /api/profile-json/{actor}", h.HandleProfileAPI) 53 + 45 54 // HTMX partials (loaded async via HTMX) 46 55 // These return HTML fragments and should only be accessed via HTMX 47 56 mux.Handle("GET /api/feed", middleware.RequireHTMXMiddleware(http.HandlerFunc(h.HandleFeedPartial))) ··· 49 58 mux.Handle("GET /api/manage", middleware.RequireHTMXMiddleware(http.HandlerFunc(h.HandleManagePartial))) 50 59 mux.Handle("GET /api/profile/{actor}", middleware.RequireHTMXMiddleware(http.HandlerFunc(h.HandleProfilePartial))) 51 60 52 - // Page routes (must come before static files) 53 - mux.HandleFunc("GET /{$}", h.HandleHome) // {$} means exact match 54 - mux.HandleFunc("GET /about", h.HandleAbout) 55 - mux.HandleFunc("GET /terms", h.HandleTerms) 56 - mux.HandleFunc("GET /manage", h.HandleManage) 57 - mux.HandleFunc("GET /brews", h.HandleBrewList) 58 - mux.HandleFunc("GET /brews/new", h.HandleBrewNew) 59 - mux.HandleFunc("GET /brews/{id}", h.HandleBrewView) 60 - mux.HandleFunc("GET /brews/{id}/edit", h.HandleBrewEdit) 61 + // Old page routes (commented out - now handled by Svelte SPA) 62 + // mux.HandleFunc("GET /{$}", h.HandleHome) // {$} means exact match 63 + // mux.HandleFunc("GET /about", h.HandleAbout) 64 + // mux.HandleFunc("GET /terms", h.HandleTerms) 65 + // mux.HandleFunc("GET /manage", h.HandleManage) 66 + // mux.HandleFunc("GET /brews", h.HandleBrewList) 67 + // mux.HandleFunc("GET /brews/new", h.HandleBrewNew) 68 + // mux.HandleFunc("GET /brews/{id}", h.HandleBrewView) 69 + // mux.HandleFunc("GET /brews/{id}/edit", h.HandleBrewEdit) 70 + 71 + // API routes for brews (POST/PUT/DELETE still needed by Svelte) 61 72 mux.Handle("POST /brews", cop.Handler(http.HandlerFunc(h.HandleBrewCreate))) 62 73 mux.Handle("PUT /brews/{id}", cop.Handler(http.HandlerFunc(h.HandleBrewUpdate))) 63 74 mux.Handle("DELETE /brews/{id}", cop.Handler(http.HandlerFunc(h.HandleBrewDelete))) 64 - mux.HandleFunc("GET /brews/export", h.HandleBrewExport) 75 + // mux.HandleFunc("GET /brews/export", h.HandleBrewExport) 65 76 66 77 // API routes for CRUD operations 67 78 mux.Handle("POST /api/beans", cop.Handler(http.HandlerFunc(h.HandleBeanCreate))) ··· 80 91 mux.Handle("PUT /api/brewers/{id}", cop.Handler(http.HandlerFunc(h.HandleBrewerUpdate))) 81 92 mux.Handle("DELETE /api/brewers/{id}", cop.Handler(http.HandlerFunc(h.HandleBrewerDelete))) 82 93 83 - // Profile routes (public user profiles) 84 - mux.HandleFunc("GET /profile/{actor}", h.HandleProfile) 94 + // Profile routes (public user profiles) - commented out, handled by SPA 95 + // mux.HandleFunc("GET /profile/{actor}", h.HandleProfile) 85 96 86 97 // Static files (must come after specific routes) 87 98 fs := http.FileServer(http.Dir("web/static")) 88 99 mux.Handle("GET /static/", http.StripPrefix("/static/", fs)) 89 100 90 - // Catch-all 404 handler - must be last, catches any unmatched routes 101 + // SPA fallback - serve index.html for all unmatched routes (client-side routing) 102 + // This must be after all API routes and static files 103 + mux.HandleFunc("GET /{path...}", h.HandleSPAFallback) 104 + 105 + // Catch-all 404 handler - now only used for non-GET requests 91 106 mux.HandleFunc("/", h.HandleNotFound) 92 107 93 108 // Apply middleware in order (outermost first, innermost last)
+5 -2
justfile
··· 1 1 run: 2 - @LOG_LEVEL=debug LOG_FORMAT=console go run cmd/server/main.go -known-dids known-dids.txt 2 + @LOG_LEVEL=debug LOG_FORMAT=console go run cmd/arabica-server/main.go -known-dids known-dids.txt 3 3 4 4 run-production: 5 - @LOG_FORMAT=json SECURE_COOKIES=true go run cmd/server/main.go 5 + @LOG_FORMAT=json SECURE_COOKIES=true go run cmd/arabica-server/main.go 6 6 7 7 test: 8 8 @go test ./... -cover -coverprofile=cover.out ··· 10 10 style: 11 11 @nix develop --command tailwindcss -i web/static/css/style.css -o web/static/css/output.css --minify 12 12 # @tailwindcss -i web/static/css/style.css -o web/static/css/output.css --minify 13 + 14 + build-ui: 15 + @pushd frontend || exit 1 && npm run build && popd || exit 1
+17 -13
templates/brew_list.tmpl
··· 14 14 <table class="min-w-full divide-y divide-brown-300"> 15 15 <thead class="bg-brown-200/80"> 16 16 <tr> 17 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">Date</th> 18 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">Bean</th> 19 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">Roaster</th> 20 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">Method</th> 21 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">Rating</th> 22 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">Actions</th> 17 + <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">📅 Date</th> 18 + <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">☕ Bean</th> 19 + <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">🫖 Brewer</th> 20 + <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">🔧 Variables</th> 21 + <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">📝 Notes</th> 22 + <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">⭐ Rating</th> 23 + <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">Actions</th> 23 24 </tr> 24 25 </thead> 25 26 <tbody class="bg-brown-50/60 divide-y divide-brown-200"> 26 27 {{range iterate 5}} 27 28 <tr class="animate-pulse"> 28 - <td class="px-6 py-4 whitespace-nowrap"> 29 + <td class="px-4 py-4 whitespace-nowrap"> 29 30 <div class="h-4 bg-brown-300 rounded w-20"></div> 30 31 </td> 31 - <td class="px-6 py-4"> 32 + <td class="px-4 py-4"> 32 33 <div class="h-4 bg-brown-300 rounded w-32 mb-2"></div> 33 34 <div class="h-3 bg-brown-200 rounded w-24"></div> 34 35 </td> 35 - <td class="px-6 py-4"> 36 + <td class="px-4 py-4"> 36 37 <div class="h-4 bg-brown-300 rounded w-24"></div> 37 38 </td> 38 - <td class="px-6 py-4"> 39 - <div class="h-4 bg-brown-300 rounded w-20 mb-2"></div> 39 + <td class="px-4 py-4"> 40 + <div class="h-3 bg-brown-200 rounded w-20 mb-1"></div> 40 41 <div class="h-3 bg-brown-200 rounded w-16"></div> 41 42 </td> 42 - <td class="px-6 py-4"> 43 + <td class="px-4 py-4"> 44 + <div class="h-3 bg-brown-200 rounded w-40"></div> 45 + </td> 46 + <td class="px-4 py-4"> 43 47 <div class="h-5 bg-amber-200 rounded-full w-14"></div> 44 48 </td> 45 - <td class="px-6 py-4"> 49 + <td class="px-4 py-4"> 46 50 <div class="flex gap-2"> 47 51 <div class="h-4 bg-brown-300 rounded w-10"></div> 48 52 <div class="h-4 bg-brown-300 rounded w-12"></div>
+55 -56
templates/manage.tmpl
··· 14 14 <h2 class="text-3xl font-bold text-brown-900">Manage</h2> 15 15 </div> 16 16 17 - <!-- Tabs --> 18 - <div class="mb-6 border-b-2 border-brown-300"> 19 - <nav class="-mb-px flex space-x-8"> 17 + <!-- Tab Navigation --> 18 + <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl border border-brown-300 mb-6"> 19 + <div class="flex border-b border-brown-300"> 20 20 <button @click="tab = 'beans'" 21 - :class="tab === 'beans' ? 'border-brown-700 text-brown-900' : 'border-transparent text-brown-600 hover:text-brown-800 hover:border-brown-400'" 22 - class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm"> 23 - Beans 21 + :class="tab === 'beans' ? 'bg-brown-50 text-brown-900 border-b-2 border-brown-700' : 'text-brown-700 hover:bg-brown-50'" 22 + class="flex-1 px-6 py-4 text-center font-medium transition-colors"> 23 + ☕ Beans 24 24 </button> 25 25 <button @click="tab = 'roasters'" 26 - :class="tab === 'roasters' ? 'border-brown-700 text-brown-900' : 'border-transparent text-brown-600 hover:text-brown-800 hover:border-brown-400'" 27 - class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm"> 28 - Roasters 26 + :class="tab === 'roasters' ? 'bg-brown-50 text-brown-900 border-b-2 border-brown-700' : 'text-brown-700 hover:bg-brown-50'" 27 + class="flex-1 px-6 py-4 text-center font-medium transition-colors"> 28 + 🏭 Roasters 29 29 </button> 30 30 <button @click="tab = 'grinders'" 31 - :class="tab === 'grinders' ? 'border-brown-700 text-brown-900' : 'border-transparent text-brown-600 hover:text-brown-800 hover:border-brown-400'" 32 - class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm"> 33 - Grinders 31 + :class="tab === 'grinders' ? 'bg-brown-50 text-brown-900 border-b-2 border-brown-700' : 'text-brown-700 hover:bg-brown-50'" 32 + class="flex-1 px-6 py-4 text-center font-medium transition-colors"> 33 + ⚙️ Grinders 34 34 </button> 35 35 <button @click="tab = 'brewers'" 36 - :class="tab === 'brewers' ? 'border-brown-700 text-brown-900' : 'border-transparent text-brown-600 hover:text-brown-800 hover:border-brown-400'" 37 - class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm"> 38 - Brewers 36 + :class="tab === 'brewers' ? 'bg-brown-50 text-brown-900 border-b-2 border-brown-700' : 'text-brown-700 hover:bg-brown-50'" 37 + class="flex-1 px-6 py-4 text-center font-medium transition-colors"> 38 + 🫖 Brewers 39 39 </button> 40 - </nav> 41 - </div> 42 - 43 - <div hx-get="/api/manage" hx-trigger="load" hx-swap="innerHTML"> 44 - <!-- Loading skeleton for the active tab --> 45 - <div class="animate-pulse"> 46 - <!-- Header skeleton --> 47 - <div class="mb-4 flex justify-between items-center"> 48 - <div class="h-6 bg-brown-300 rounded w-32"></div> 49 - <div class="h-10 bg-brown-300 rounded w-28"></div> 50 - </div> 51 - 52 - <!-- Table skeleton --> 53 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 shadow-xl rounded-xl overflow-hidden border border-brown-300"> 54 - <table class="min-w-full divide-y divide-brown-300"> 55 - <thead class="bg-brown-200/80"> 56 - <tr> 57 - <th class="px-6 py-3 text-left"><div class="h-3 bg-brown-300 rounded w-16"></div></th> 58 - <th class="px-6 py-3 text-left"><div class="h-3 bg-brown-300 rounded w-16"></div></th> 59 - <th class="px-6 py-3 text-left"><div class="h-3 bg-brown-300 rounded w-20"></div></th> 60 - <th class="px-6 py-3 text-left"><div class="h-3 bg-brown-300 rounded w-24"></div></th> 61 - <th class="px-6 py-3 text-left"><div class="h-3 bg-brown-300 rounded w-16"></div></th> 62 - </tr> 63 - </thead> 64 - <tbody class="bg-brown-50/60 divide-y divide-brown-200"> 65 - {{range iterate 4}} 66 - <tr> 67 - <td class="px-6 py-4"><div class="h-4 bg-brown-300 rounded w-24"></div></td> 68 - <td class="px-6 py-4"><div class="h-4 bg-brown-300 rounded w-20"></div></td> 69 - <td class="px-6 py-4"><div class="h-4 bg-brown-300 rounded w-28"></div></td> 70 - <td class="px-6 py-4"><div class="h-4 bg-brown-300 rounded w-16"></div></td> 71 - <td class="px-6 py-4"> 72 - <div class="flex gap-2"> 73 - <div class="h-4 bg-brown-300 rounded w-10"></div> 74 - <div class="h-4 bg-brown-300 rounded w-12"></div> 75 - </div> 76 - </td> 77 - </tr> 78 - {{end}} 79 - </tbody> 80 - </table> 40 + </div> 41 + 42 + <!-- Tab Content --> 43 + <div class="p-6" hx-get="/api/manage" hx-trigger="load" hx-swap="innerHTML"> 44 + <!-- Loading skeleton for the active tab --> 45 + <div class="animate-pulse"> 46 + <!-- Header skeleton --> 47 + <div class="mb-4 flex justify-between items-center"> 48 + <div class="h-6 bg-brown-300 rounded w-32"></div> 49 + <div class="h-10 bg-brown-300 rounded w-28"></div> 50 + </div> 51 + 52 + <!-- Table skeleton --> 53 + <div class="overflow-x-auto"> 54 + <table class="min-w-full divide-y divide-brown-300"> 55 + <thead class="bg-brown-50"> 56 + <tr> 57 + <th class="px-4 py-3 text-left"><div class="h-3 bg-brown-300 rounded w-16"></div></th> 58 + <th class="px-4 py-3 text-left"><div class="h-3 bg-brown-300 rounded w-16"></div></th> 59 + <th class="px-4 py-3 text-left"><div class="h-3 bg-brown-300 rounded w-20"></div></th> 60 + <th class="px-4 py-3 text-left"><div class="h-3 bg-brown-300 rounded w-16"></div></th> 61 + </tr> 62 + </thead> 63 + <tbody class="bg-white divide-y divide-brown-200"> 64 + {{range iterate 4}} 65 + <tr> 66 + <td class="px-4 py-3"><div class="h-4 bg-brown-300 rounded w-24"></div></td> 67 + <td class="px-4 py-3"><div class="h-4 bg-brown-300 rounded w-20"></div></td> 68 + <td class="px-4 py-3"><div class="h-4 bg-brown-300 rounded w-28"></div></td> 69 + <td class="px-4 py-3"> 70 + <div class="flex gap-2"> 71 + <div class="h-4 bg-brown-300 rounded w-10"></div> 72 + <div class="h-4 bg-brown-300 rounded w-12"></div> 73 + </div> 74 + </td> 75 + </tr> 76 + {{end}} 77 + </tbody> 78 + </table> 79 + </div> 81 80 </div> 82 81 </div> 83 82 </div>
+7 -6
templates/partials/brew_list_content.tmpl
··· 16 16 <table class="min-w-full divide-y divide-brown-300"> 17 17 <thead class="bg-brown-200/80"> 18 18 <tr> 19 - <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">Date</th> 20 - <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">Bean</th> 21 - <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">Brewer</th> 22 - <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">Variables</th> 23 - <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">Notes</th> 24 - <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">Rating</th> 19 + <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">📅 Date</th> 20 + <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">☕ Bean</th> 21 + <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">🫖 Brewer</th> 22 + <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">🔧 Variables</th> 23 + <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">📝 Notes</th> 24 + <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">⭐ Rating</th> 25 25 <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase tracking-wider">Actions</th> 26 26 </tr> 27 27 </thead> ··· 143 143 </div> 144 144 {{end}} 145 145 {{end}} 146 + {{end}}
+1 -1
templates/partials/brewer_form_modal.tmpl
··· 13 13 <div class="flex gap-2"> 14 14 <button @click="saveBrewer()" 15 15 class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md">Save</button> 16 - <button @click="showBrewerForm = false" 16 + <button @click="showBeanForm = false" 17 17 class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors">Cancel</button> 18 18 </div> 19 19 </div>
+64 -80
templates/partials/manage_content.tmpl
··· 2 2 <!-- Beans Tab --> 3 3 <div x-show="tab === 'beans'"> 4 4 <div class="mb-4 flex justify-between items-center"> 5 - <h3 class="text-xl font-semibold text-brown-900">Coffee Beans</h3> 5 + <h3 class="text-xl font-semibold text-brown-900">☕ Coffee Beans</h3> 6 6 <button 7 7 @click="showBeanForm = true; editingBean = null; beanForm = {name: '', origin: '', roast_level: '', process: '', description: '', roaster_rkey: ''}" 8 - class="bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-medium shadow-md hover:shadow-lg"> 8 + class="bg-brown-700 text-white px-4 py-2 rounded-lg hover:bg-brown-800 font-medium"> 9 9 + Add Bean 10 10 </button> 11 11 </div> 12 12 13 13 {{if not .Beans}} 14 14 <div class="bg-brown-100 rounded-lg p-8 text-center text-brown-700 border border-brown-200"> 15 - No beans yet. Add your first bean to get started! 15 + <p class="text-brown-600">No beans yet. Add your first bean!</p> 16 16 </div> 17 17 {{else}} 18 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 shadow-xl rounded-xl overflow-x-auto border border-brown-300"> 18 + <div class="overflow-x-auto"> 19 19 <table class="min-w-full divide-y divide-brown-300"> 20 - <thead class="bg-brown-200/80"> 20 + <thead class="bg-brown-50"> 21 21 <tr> 22 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase whitespace-nowrap">Name</th> 23 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase whitespace-nowrap">📍 Origin</th> 24 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase whitespace-nowrap">☕ Roaster</th> 25 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase whitespace-nowrap">🔥 Roast Level</th> 26 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase whitespace-nowrap">🌱 Process</th> 27 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase whitespace-nowrap">📝 Description</th> 28 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase whitespace-nowrap">Actions</th> 22 + <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Name</th> 23 + <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">📍 Origin</th> 24 + <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">🔥 Roast</th> 25 + <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">🏭 Roaster</th> 26 + <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Actions</th> 29 27 </tr> 30 28 </thead> 31 - <tbody class="bg-brown-50/60 divide-y divide-brown-200"> 29 + <tbody class="bg-white divide-y divide-brown-200"> 32 30 {{range .Beans}} 33 - <tr class="hover:bg-brown-100/60 transition-colors"> 34 - <td class="px-6 py-4 text-sm font-medium text-brown-900">{{.Name}}</td> 35 - <td class="px-6 py-4 text-sm text-brown-900">{{.Origin}}</td> 36 - <td class="px-6 py-4 text-sm text-brown-900"> 31 + <tr class="hover:bg-brown-50"> 32 + <td class="px-4 py-3 text-sm text-brown-900">{{if .Name}}{{.Name}}{{else}}-{{end}}</td> 33 + <td class="px-4 py-3 text-sm text-brown-900">{{.Origin}}</td> 34 + <td class="px-4 py-3 text-sm text-brown-900">{{.RoastLevel}}</td> 35 + <td class="px-4 py-3 text-sm text-brown-900"> 37 36 {{if and .Roaster .Roaster.Name}} 38 37 {{.Roaster.Name}} 39 38 {{else}} 40 - <span class="text-brown-400">-</span> 39 + - 41 40 {{end}} 42 41 </td> 43 - <td class="px-6 py-4 text-sm text-brown-900">{{.RoastLevel}}</td> 44 - <td class="px-6 py-4 text-sm text-brown-900">{{.Process}}</td> 45 - <td class="px-6 py-4 text-sm text-brown-700">{{.Description}}</td> 46 - <td class="px-6 py-4 text-sm font-medium space-x-2"> 42 + <td class="px-4 py-3 text-sm space-x-2"> 47 43 <button @click="editBean('{{.RKey}}', '{{escapeJS .Name}}', '{{escapeJS .Origin}}', '{{.RoastLevel}}', '{{.Process}}', '{{escapeJS .Description}}', '{{.RoasterRKey}}')" 48 44 class="text-brown-700 hover:text-brown-900 font-medium">Edit</button> 49 45 <button @click="deleteBean('{{.RKey}}')" 50 - class="text-brown-600 hover:text-brown-800 font-medium">Delete</button> 46 + class="text-red-600 hover:text-red-800 font-medium">Delete</button> 51 47 </td> 52 48 </tr> 53 49 {{end}} ··· 60 56 <!-- Roasters Tab --> 61 57 <div x-show="tab === 'roasters'"> 62 58 <div class="mb-4 flex justify-between items-center"> 63 - <h3 class="text-xl font-semibold text-brown-900">Roasters</h3> 59 + <h3 class="text-xl font-semibold text-brown-900">🏭 Roasters</h3> 64 60 <button 65 61 @click="showRoasterForm = true; editingRoaster = null; roasterForm = {name: '', location: '', website: ''}" 66 - class="bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-medium shadow-md hover:shadow-lg"> 62 + class="bg-brown-700 text-white px-4 py-2 rounded-lg hover:bg-brown-800 font-medium"> 67 63 + Add Roaster 68 64 </button> 69 65 </div> 70 66 71 67 {{if not .Roasters}} 72 68 <div class="bg-brown-100 rounded-lg p-8 text-center text-brown-700 border border-brown-200"> 73 - No roasters yet. Add your first roaster! 69 + <p class="text-brown-600">No roasters yet. Add your first roaster!</p> 74 70 </div> 75 71 {{else}} 76 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 shadow-xl rounded-xl overflow-x-auto border border-brown-300"> 72 + <div class="overflow-x-auto"> 77 73 <table class="min-w-full divide-y divide-brown-300"> 78 - <thead class="bg-brown-200/80"> 74 + <thead class="bg-brown-50"> 79 75 <tr> 80 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase">Name</th> 81 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase">📍 Location</th> 82 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase">🌐 Website</th> 83 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase">Actions</th> 76 + <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Name</th> 77 + <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">📍 Location</th> 78 + <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Actions</th> 84 79 </tr> 85 80 </thead> 86 - <tbody class="bg-brown-50/60 divide-y divide-brown-200"> 81 + <tbody class="bg-white divide-y divide-brown-200"> 87 82 {{range .Roasters}} 88 - <tr class="hover:bg-brown-100/60 transition-colors"> 89 - <td class="px-6 py-4 text-sm font-medium text-brown-900">{{.Name}}</td> 90 - <td class="px-6 py-4 text-sm text-brown-900">{{.Location}}</td> 91 - <td class="px-6 py-4 text-sm text-brown-900"> 92 - {{$safeWebsite := safeWebsiteURL .Website}} 93 - {{if $safeWebsite}} 94 - <a href="{{$safeWebsite}}" target="_blank" rel="noopener noreferrer" 95 - class="text-brown-700 hover:underline font-medium">{{$safeWebsite}}</a> 96 - {{end}} 97 - </td> 98 - <td class="px-6 py-4 text-sm font-medium space-x-2"> 83 + <tr class="hover:bg-brown-50"> 84 + <td class="px-4 py-3 text-sm text-brown-900">{{.Name}}</td> 85 + <td class="px-4 py-3 text-sm text-brown-900">{{if .Location}}{{.Location}}{{else}}-{{end}}</td> 86 + <td class="px-4 py-3 text-sm space-x-2"> 99 87 <button @click="editRoaster('{{.RKey}}', '{{escapeJS .Name}}', '{{escapeJS .Location}}', '{{escapeJS .Website}}')" 100 88 class="text-brown-700 hover:text-brown-900 font-medium">Edit</button> 101 89 <button @click="deleteRoaster('{{.RKey}}')" 102 - class="text-brown-600 hover:text-brown-800 font-medium">Delete</button> 90 + class="text-red-600 hover:text-red-800 font-medium">Delete</button> 103 91 </td> 104 92 </tr> 105 93 {{end}} ··· 112 100 <!-- Grinders Tab --> 113 101 <div x-show="tab === 'grinders'"> 114 102 <div class="mb-4 flex justify-between items-center"> 115 - <h3 class="text-xl font-semibold text-brown-900">Grinders</h3> 103 + <h3 class="text-xl font-semibold text-brown-900">⚙️ Grinders</h3> 116 104 <button 117 105 @click="showGrinderForm = true; editingGrinder = null; grinderForm = {name: '', grinder_type: '', burr_type: '', notes: ''}" 118 - class="bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-medium shadow-md hover:shadow-lg"> 106 + class="bg-brown-700 text-white px-4 py-2 rounded-lg hover:bg-brown-800 font-medium"> 119 107 + Add Grinder 120 108 </button> 121 109 </div> 122 110 123 111 {{if not .Grinders}} 124 112 <div class="bg-brown-100 rounded-lg p-8 text-center text-brown-700 border border-brown-200"> 125 - No grinders yet. Add your first grinder! 113 + <p class="text-brown-600">No grinders yet. Add your first grinder!</p> 126 114 </div> 127 115 {{else}} 128 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 shadow-xl rounded-xl overflow-x-auto border border-brown-300"> 116 + <div class="overflow-x-auto"> 129 117 <table class="min-w-full divide-y divide-brown-300"> 130 - <thead class="bg-brown-200/80"> 118 + <thead class="bg-brown-50"> 131 119 <tr> 132 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase">Name</th> 133 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase">🔧 Grinder Type</th> 134 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase">💎 Burr Type</th> 135 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase">📝 Notes</th> 136 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase">Actions</th> 120 + <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Name</th> 121 + <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">🔧 Type</th> 122 + <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">💎 Burr Type</th> 123 + <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Actions</th> 137 124 </tr> 138 125 </thead> 139 - <tbody class="bg-brown-50/60 divide-y divide-brown-200"> 126 + <tbody class="bg-white divide-y divide-brown-200"> 140 127 {{range .Grinders}} 141 - <tr class="hover:bg-brown-100/60 transition-colors"> 142 - <td class="px-6 py-4 text-sm font-medium text-brown-900">{{.Name}}</td> 143 - <td class="px-6 py-4 text-sm text-brown-900">{{.GrinderType}}</td> 144 - <td class="px-6 py-4 text-sm text-brown-900">{{.BurrType}}</td> 145 - <td class="px-6 py-4 text-sm text-brown-700">{{.Notes}}</td> 146 - <td class="px-6 py-4 text-sm font-medium space-x-2"> 128 + <tr class="hover:bg-brown-50"> 129 + <td class="px-4 py-3 text-sm text-brown-900">{{.Name}}</td> 130 + <td class="px-4 py-3 text-sm text-brown-900">{{if .GrinderType}}{{.GrinderType}}{{else}}-{{end}}</td> 131 + <td class="px-4 py-3 text-sm text-brown-900">{{if .BurrType}}{{.BurrType}}{{else}}-{{end}}</td> 132 + <td class="px-4 py-3 text-sm space-x-2"> 147 133 <button @click="editGrinder('{{.RKey}}', '{{escapeJS .Name}}', '{{.GrinderType}}', '{{.BurrType}}', '{{escapeJS .Notes}}')" 148 134 class="text-brown-700 hover:text-brown-900 font-medium">Edit</button> 149 135 <button @click="deleteGrinder('{{.RKey}}')" 150 - class="text-brown-600 hover:text-brown-800 font-medium">Delete</button> 136 + class="text-red-600 hover:text-red-800 font-medium">Delete</button> 151 137 </td> 152 138 </tr> 153 139 {{end}} ··· 160 146 <!-- Brewers Tab --> 161 147 <div x-show="tab === 'brewers'"> 162 148 <div class="mb-4 flex justify-between items-center"> 163 - <h3 class="text-xl font-semibold text-brown-900">Brewers</h3> 149 + <h3 class="text-xl font-semibold text-brown-900">🫖 Brewers</h3> 164 150 <button @click="showBrewerForm = true; editingBrewer = null; brewerForm = {name: '', brewer_type: '', description: ''}" 165 - class="bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 transition-all font-medium shadow-md hover:shadow-lg"> 151 + class="bg-brown-700 text-white px-4 py-2 rounded-lg hover:bg-brown-800 font-medium"> 166 152 + Add Brewer 167 153 </button> 168 154 </div> 169 155 170 156 {{if not .Brewers}} 171 157 <div class="bg-brown-100 rounded-lg p-8 text-center text-brown-700 border border-brown-200"> 172 - No brewers yet. Add your first brewer! 158 + <p class="text-brown-600">No brewers yet. Add your first brewer!</p> 173 159 </div> 174 160 {{else}} 175 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 shadow-xl rounded-xl overflow-x-auto border border-brown-300"> 161 + <div class="overflow-x-auto"> 176 162 <table class="min-w-full divide-y divide-brown-300"> 177 - <thead class="bg-brown-200/80"> 163 + <thead class="bg-brown-50"> 178 164 <tr> 179 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase">Name</th> 180 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase">🔧 Type</th> 181 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase">📝 Description</th> 182 - <th class="px-6 py-3 text-left text-xs font-medium text-brown-900 uppercase">Actions</th> 165 + <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Name</th> 166 + <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">🔧 Type</th> 167 + <th class="px-4 py-3 text-left text-xs font-medium text-brown-900 uppercase">Actions</th> 183 168 </tr> 184 169 </thead> 185 - <tbody class="bg-brown-50/60 divide-y divide-brown-200"> 170 + <tbody class="bg-white divide-y divide-brown-200"> 186 171 {{range .Brewers}} 187 - <tr class="hover:bg-brown-100/60 transition-colors" 172 + <tr class="hover:bg-brown-50" 188 173 data-rkey="{{.RKey}}" 189 174 data-name="{{escapeJS .Name}}" 190 175 data-brewer-type="{{escapeJS .BrewerType}}" 191 176 data-description="{{escapeJS .Description}}"> 192 - <td class="px-6 py-4 text-sm font-medium text-brown-900">{{.Name}}</td> 193 - <td class="px-6 py-4 text-sm text-brown-900"> 194 - {{if .BrewerType}}{{.BrewerType}}{{else}}<span class="text-brown-400">-</span>{{end}} 177 + <td class="px-4 py-3 text-sm text-brown-900">{{.Name}}</td> 178 + <td class="px-4 py-3 text-sm text-brown-900"> 179 + {{if .BrewerType}}{{.BrewerType}}{{else}}-{{end}} 195 180 </td> 196 - <td class="px-6 py-4 text-sm text-brown-700">{{.Description}}</td> 197 - <td class="px-6 py-4 text-sm font-medium space-x-2"> 181 + <td class="px-4 py-3 text-sm space-x-2"> 198 182 <button @click="editBrewerFromRow($el.closest('tr'))" 199 183 class="text-brown-700 hover:text-brown-900 font-medium">Edit</button> 200 184 <button @click="deleteBrewer($el.closest('tr').dataset.rkey)" 201 - class="text-brown-600 hover:text-brown-800 font-medium">Delete</button> 185 + class="text-red-600 hover:text-red-800 font-medium">Delete</button> 202 186 </td> 203 187 </tr> 204 188 {{end}}
+27
web/static/app/index.html
··· 1 + <!DOCTYPE html> 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-Du5RSqvf.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> 27 + </html>

History

1 round 0 comments
sign up or login to add to the discussion
pdewey.com submitted #0
1 commit
expand
refactor: svelte frontend rewrite
expand 0 comments
closed without merging