+3
.gitignore
+3
.gitignore
+1
BACKLOG.md
+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
+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
+1
-1
Dockerfile
+215
MIGRATION.md
+215
MIGRATION.md
···
1
+
# Alpine.js → Svelte Migration Complete! 🎉
2
+
3
+
## What Changed
4
+
5
+
The entire frontend has been migrated from Alpine.js + HTMX + Go templates to a **Svelte SPA**.
6
+
7
+
### Before
8
+
- **Frontend**: Go HTML templates + Alpine.js + HTMX
9
+
- **State**: Alpine global components + DOM manipulation
10
+
- **Routing**: Server-side (Go mux)
11
+
- **Data**: Mixed (HTMX partials + JSON API)
12
+
13
+
### After
14
+
- **Frontend**: Svelte SPA (single-page application)
15
+
- **State**: Svelte stores (reactive)
16
+
- **Routing**: Client-side (navaid)
17
+
- **Data**: JSON API only
18
+
19
+
## Architecture
20
+
21
+
```
22
+
/
23
+
├── cmd/arabica-server/main.go # Go backend entry point
24
+
├── internal/ # Go backend (unchanged)
25
+
│ ├── handlers/
26
+
│ │ ├── handlers.go # Added /api/me and /api/feed-json
27
+
│ │ └── ...
28
+
│ └── routing/
29
+
│ └── routing.go # Added SPA fallback route
30
+
├── frontend/ # NEW: Svelte app
31
+
│ ├── src/
32
+
│ │ ├── App.svelte # Root component with router
33
+
│ │ ├── main.js # Entry point
34
+
│ │ ├── routes/ # Page components
35
+
│ │ │ ├── Home.svelte
36
+
│ │ │ ├── Login.svelte
37
+
│ │ │ ├── Brews.svelte
38
+
│ │ │ ├── BrewView.svelte
39
+
│ │ │ ├── BrewForm.svelte
40
+
│ │ │ ├── Manage.svelte
41
+
│ │ │ ├── Profile.svelte
42
+
│ │ │ ├── About.svelte
43
+
│ │ │ ├── Terms.svelte
44
+
│ │ │ └── NotFound.svelte
45
+
│ │ ├── components/ # Reusable components
46
+
│ │ │ ├── Header.svelte
47
+
│ │ │ ├── Footer.svelte
48
+
│ │ │ ├── FeedCard.svelte
49
+
│ │ │ └── Modal.svelte
50
+
│ │ ├── stores/ # Svelte stores
51
+
│ │ │ ├── auth.js # Authentication state
52
+
│ │ │ ├── cache.js # Data cache (replaces data-cache.js)
53
+
│ │ │ └── ui.js # UI state (notifications, etc.)
54
+
│ │ └── lib/
55
+
│ │ ├── api.js # Fetch wrapper
56
+
│ │ └── router.js # Client-side routing
57
+
│ ├── index.html
58
+
│ ├── vite.config.js
59
+
│ └── package.json
60
+
└── 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
+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/logging_test.go
cmd/arabica-server/logging_test.go
cmd/server/main.go
cmd/arabica-server/main.go
cmd/server/main.go
cmd/arabica-server/main.go
cmd/server/main_test.go
cmd/arabica-server/main_test.go
cmd/server/main_test.go
cmd/arabica-server/main_test.go
+1
-1
default.nix
+1
-1
default.nix
+26
frontend/index.html
+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
+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
+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
+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
+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>
+138
frontend/src/components/Header.svelte
+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
+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
+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
+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
+7
frontend/src/main.js
+55
frontend/src/routes/About.svelte
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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 "quotes" and <html>tags</html> and 'single quotes'"
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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
pdewey.com
submitted
#0
1 commit
expand
collapse
refactor: svelte frontend rewrite
expand 0 comments
closed without merging