Initial commit: Second Brain Platform

Complete platform with unified design system and real API integration.

Apps: Dashboard, Fitness, Budget, Inventory, Trips, Reader, Media, Settings
Infrastructure: SvelteKit + Python gateway + Docker Compose
This commit is contained in:
Yusuf Suleman
2026-03-28 23:20:40 -05:00
commit d3e250e361
159 changed files with 44797 additions and 0 deletions

View File

@@ -0,0 +1,17 @@
{
"permissions": {
"allow": [
"Bash(npx playwright:*)",
"Bash(find /media/yusiboyz/Media/Scripts/platform/frontend -name *.ts -o -name *.js -o -name *.svelte)",
"Bash(curl:*)",
"Bash(sudo chown:*)",
"Bash(node:*)"
]
},
"mcpServers": {
"shadcn-svelte": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://shadcn-svelte.mastra.cloud/api/mcp/shadcn/mcp"]
}
}
}

12
.env.example Normal file
View File

@@ -0,0 +1,12 @@
TZ=America/Chicago
PLATFORM_ORIGIN=http://localhost:3000
TRIPS_BACKEND_URL=http://trips-service:8087
TRIPS_FRONTEND_LEGACY_URL=http://trips-legacy-frontend:3000
TRIPS_API_TOKEN_KEY=api_token
FITNESS_BACKEND_URL=http://fitness-service:8095
FITNESS_FRONTEND_LEGACY_URL=http://fitness-legacy-frontend:3000
FITNESS_API_TOKEN_KEY=session_token

18
.gitignore vendored Normal file
View File

@@ -0,0 +1,18 @@
node_modules/
.svelte-kit/
build/
.env
*.db
*.db-journal
*.db-wal
data/
__pycache__/
*.pyc
.DS_Store
services/fitness/data/
services/trips/data/
gateway/data/
frontend-v2/.svelte-kit/
frontend-v2/build/
frontend-v2/node_modules/
*.png

144
docker-compose.yml Normal file
View File

@@ -0,0 +1,144 @@
services:
platform-frontend-v2:
build:
context: ./frontend-v2
dockerfile: Dockerfile
container_name: platform-frontend-v2
restart: unless-stopped
ports:
- "3211:3000"
environment:
- ORIGIN=${PLATFORM_V2_ORIGIN:-http://localhost:3211}
- GATEWAY_URL=http://gateway:8100
- IMMICH_URL=${IMMICH_URL}
- IMMICH_API_KEY=${IMMICH_API_KEY}
- KARAKEEP_URL=${KARAKEEP_URL:-http://192.168.1.42:3005}
- KARAKEEP_API_KEY=${KARAKEEP_API_KEY}
- BODY_SIZE_LIMIT=52428800
- TZ=${TZ:-America/Chicago}
depends_on:
- gateway
gateway:
build:
context: ./gateway
dockerfile: Dockerfile
container_name: platform-gateway
restart: unless-stopped
volumes:
- ./gateway/data:/app/data
- /media/yusiboyz/Media/Scripts/booklore/booklore/books:/booklore-books:ro
- /media/yusiboyz/Media/Scripts/shelfmark/books:/bookdrop:ro
environment:
- PORT=8100
- TRIPS_BACKEND_URL=http://trips-service:8087
- FITNESS_BACKEND_URL=http://fitness-service:8095
- INVENTORY_BACKEND_URL=http://inventory-service:3000
- MINIFLUX_URL=${MINIFLUX_URL:-http://miniflux:8080}
- MINIFLUX_API_KEY=${MINIFLUX_API_KEY}
- TRIPS_API_TOKEN=${TRIPS_API_TOKEN}
- NOCODB_API_TOKEN=${NOCODB_API_TOKEN}
- OPENAI_API_KEY=${OPENAI_API_KEY}
- OPENAI_MODEL=${OPENAI_MODEL:-gpt-5.2}
- SHELFMARK_URL=${SHELFMARK_URL:-http://shelfmark:8084}
- BOOKLORE_URL=${BOOKLORE_URL:-http://booklore:6060}
- BOOKLORE_USER=${BOOKLORE_USER}
- BOOKLORE_PASS=${BOOKLORE_PASS}
- KARAKEEP_URL=${KARAKEEP_URL:-http://192.168.1.42:3005}
- KARAKEEP_API_KEY=${KARAKEEP_API_KEY}
- SPOTIZERR_URL=${SPOTIZERR_URL:-http://spotizerr-app:7171}
- BUDGET_BACKEND_URL=http://budget-service:3001
- QBITTORRENT_HOST=${QBITTORRENT_HOST:-192.168.1.42}
- QBITTORRENT_PORT=${QBITTORRENT_PORT:-8080}
- QBITTORRENT_USERNAME=${QBITTORRENT_USERNAME:-admin}
- QBITTORRENT_PASSWORD=${QBITTORRENT_PASSWORD}
- SMTP2GO_API_KEY=${SMTP2GO_API_KEY}
- SMTP2GO_FROM_EMAIL=${SMTP2GO_FROM_EMAIL}
- SMTP2GO_FROM_NAME=${SMTP2GO_FROM_NAME:-Platform}
- KINDLE_EMAIL_1=${KINDLE_EMAIL_1}
- KINDLE_EMAIL_2=${KINDLE_EMAIL_2}
- KINDLE_LABELS=${KINDLE_LABELS:-Madiha,Hafsa}
- TZ=${TZ:-America/Chicago}
networks:
- default
- pangolin
depends_on:
- trips-service
- fitness-service
- inventory-service
- budget-service
trips-service:
build:
context: ./services/trips
dockerfile: Dockerfile
container_name: platform-trips-service
restart: unless-stopped
volumes:
- ./services/trips/data:/app/data
env_file:
- ./services/trips/.env
environment:
- TZ=${TZ:-America/Chicago}
fitness-service:
build:
context: ./services/fitness
dockerfile: Dockerfile.backend
container_name: platform-fitness-service
restart: unless-stopped
volumes:
- ./services/fitness/data:/app/data
env_file:
- ./services/fitness/.env
environment:
- PORT=8095
- DATA_DIR=/app/data
- TZ=${TZ:-America/Chicago}
inventory-service:
build:
context: ./services/inventory
dockerfile: Dockerfile
container_name: platform-inventory-service
restart: unless-stopped
environment:
- PORT=3000
- NOCODB_URL=${NOCODB_URL:-http://nocodb:8080}
- NOCODB_PUBLIC_URL=${NOCODB_PUBLIC_URL:-https://noco.quadjourney.com}
- NOCODB_API_TOKEN=${NOCODB_API_TOKEN}
- NOCODB_BASE_ID=${NOCODB_BASE_ID:-pava9q9zccyihpt}
- NOCODB_TABLE_ID=${NOCODB_TABLE_ID:-mash7c5nx4unukc}
- NOCODB_COLUMN_NAME=${NOCODB_COLUMN_NAME:-photos}
- DISCORD_WEBHOOK_URL=${DISCORD_WEBHOOK_URL}
- PUBLIC_APP_URL=${PLATFORM_ORIGIN}/inventory
- IMMICH_URL=${IMMICH_URL}
- IMMICH_API_KEY=${IMMICH_API_KEY}
- TZ=${TZ:-America/Chicago}
networks:
- default
- nocodb_default
budget-service:
build:
context: ./services/budget
dockerfile: Dockerfile
container_name: platform-budget-service
restart: unless-stopped
environment:
- PORT=3001
- ACTUAL_SERVER_URL=http://actualbudget:5006
- ACTUAL_PASSWORD=${ACTUAL_PASSWORD}
- ACTUAL_SYNC_ID=${BUDGET_SYNC_ID}
- TZ=${TZ:-America/Chicago}
networks:
- default
- actualbudget_default
networks:
nocodb_default:
external: true
pangolin:
external: true
actualbudget_default:
external: true

39
docs/architecture.md Normal file
View File

@@ -0,0 +1,39 @@
# Platform Architecture
## Goal
One frontend product with two sections:
- `/trips`
- `/fitness`
Separate backend services remain in place:
- `services/trips`
- `services/fitness`
The legacy frontends stay runnable during migration for fallback and comparison.
## Service Boundaries
- Trips backend remains independent.
- Fitness backend remains independent.
- Trips and Fitness keep separate SQLite databases.
- Platform frontend owns the shared shell and explicit API proxy layer.
## Proxy Contract
- `/api/trips/*` -> Trips backend
- `/api/fitness/*` -> Fitness backend
Frontend code should call these proxy paths, not raw backend URLs.
## Migration Order
1. Keep both legacy frontends alive.
2. Build platform shell.
3. Add explicit proxy/API clients.
4. Migrate one product area first.
5. Migrate the second area.
6. Retire legacy frontends only after full coverage.

View File

@@ -0,0 +1,4 @@
node_modules
.svelte-kit
build
.env

23
frontend-v2/.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
frontend-v2/.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict=true

3
frontend-v2/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode"]
}

View File

@@ -0,0 +1,324 @@
# Design System — Token Reference
> Source of truth: `src/app.css`
> Last updated: 2026-03-27
All UI values must come from these tokens unless listed under [Intentional Raw Values](#intentional-raw-values).
---
## Spacing
4px grid. Token name = multiplier (`--sp-3` = 3 × 4px = 12px).
| Token | Value | Use for |
|-------|-------|---------|
| `--sp-0` | 0px | Explicit zero |
| `--sp-px` | 1px | Borders, hairlines |
| `--sp-0.5` | 2px | Micro-nudge (margin-top on meta text) |
| `--sp-1` | 4px | Tight gap, field label gap, small padding |
| `--sp-1.5` | 6px | Badge gap, icon gap, footer gap |
| `--sp-2` | 8px | Compact gap, button group gap, inner padding |
| `--sp-3` | 12px | Standard gap, row padding, list item gap |
| `--sp-4` | 16px | Card padding (mobile), section margin, tab margin |
| `--sp-5` | 20px | Card padding (desktop), module gap, sidebar gap |
| `--sp-6` | 24px | Large padding, overlay padding, page container |
| `--sp-7` | 28px | Primary card padding, section group margin |
| `--sp-8` | 32px | Page top padding, empty state, desktop grid gap |
| `--sp-10` | 40px | Large elements (avatar width), empty list padding |
| `--sp-12` | 48px | Empty state padding, large spacing |
| `--sp-16` | 64px | Reserved |
| `--sp-20` | 80px | Page bottom padding (scroll clearance) |
### Semantic spacing aliases
| Token | Resolves to | Use for |
|-------|-------------|---------|
| `--section-gap` | `--sp-7` (28px) | Gap between major page sections |
| `--card-pad` | `--sp-5` (20px) | Default module/card padding |
| `--card-pad-primary` | `--sp-7` (28px) | Hero/primary module padding |
| `--card-pad-secondary` | `--sp-4` (16px) | Compact card padding |
| `--row-gap` | `--sp-3` (12px) | Gap between list rows |
| `--module-gap` | `--sp-5` (20px) | Gap between dashboard modules |
| `--row-pad-y` | 14px | Vertical padding inside data rows (off-grid, intentional) |
| `--row-pad-x` | `--sp-4` (16px) | Horizontal padding inside data rows |
| `--inner-gap` | `--sp-3` (12px) | Gap between items within a row |
### Mobile overrides (≤768px)
| Token | Desktop | Mobile |
|-------|---------|--------|
| `--card-pad` | 20px | 16px |
| `--card-pad-primary` | 28px | 20px |
| `--row-pad-y` | 14px | 16px |
| `--section-gap` | 28px | 20px |
---
## Radius
| Token | Value | Use for |
|-------|-------|---------|
| `--radius-xs` | 4px | Tiny pills, skeleton placeholders, kbd hints |
| `--radius-sm` | 6px | Badges, chips, nav links, danger buttons |
| `--radius-md` | 8px | Buttons, inputs, tabs, entry rows, icon containers |
| `--radius` | 12px | Cards, modals, panels, main containers |
| `--radius-lg` | 16px | Hero cards, action cards, pill chips, ImmichPicker modal |
| `--radius-full` | 9999px | Circles, toggles, avatars |
---
## Elevation (Shadows)
Light and dark mode have separate values. Dark mode uses higher opacity.
| Token | Light | Use for |
|-------|-------|---------|
| `--shadow-xs` | `0 1px 2px rgba(0,0,0,0.03)` | Row hover, active tabs, inner elements |
| `--shadow-sm` | `0 1px 3px rgba(0,0,0,0.04), 0 4px 12px rgba(0,0,0,0.04)` | Secondary cards, inputs, budget tables |
| `--shadow-md` | `0 2px 6px rgba(0,0,0,0.04), 0 8px 24px rgba(0,0,0,0.06)` | Standard card elevation (default `.module`) |
| `--shadow-lg` | `0 4px 12px rgba(0,0,0,0.06), 0 16px 40px rgba(0,0,0,0.1)` | Hero cards, primary modules, dropdowns |
| `--shadow-xl` | `0 8px 24px rgba(0,0,0,0.08), 0 24px 60px rgba(0,0,0,0.15)` | Modals, overlay panels |
**Legacy aliases**: `--card-shadow``--shadow-md`, `--card-shadow-sm``--shadow-sm`
---
## Colors
### Surfaces (3-layer depth)
| Token | Light | Dark | Layer |
|-------|-------|------|-------|
| `--canvas` | `#F5F6F8` | `#09090b` | Page background — everything sits on this |
| `--surface` | `#FFFFFF` | `#0f0f12` | Sidebars, panels, slide-out sheets |
| `--surface-secondary` | `#FAFAFB` | `#111114` | Input backgrounds, secondary panels |
| `--card` | `#FFFFFF` | `#161619` | Content containers, elevated with shadow |
| `--card-secondary` | `#FAFAFB` | `#111114` | Secondary cards, button backgrounds |
| `--card-hover` | `#f0f0f3` | `#1c1c20` | Row hover, interactive feedback |
### Borders
| Token | Light | Dark |
|-------|-------|------|
| `--border` | `rgba(0,0,0,0.06)` | `rgba(255,255,255,0.06)` |
| `--border-strong` | `rgba(0,0,0,0.1)` | `rgba(255,255,255,0.1)` |
### Text hierarchy
| Token | Light | Dark | Use for |
|-------|-------|------|---------|
| `--text-1` | `#1a1a1f` | `#fafafa` | Headings, names, amounts — read first |
| `--text-2` | `#4a4a55` | `#a1a1aa` | Body text, descriptions — read second |
| `--text-3` | `#6b6b76` | `#71717a` | Labels, metadata, captions — supporting |
| `--text-4` | `#b4b4bd` | `#3f3f46` | Placeholders, disabled, timestamps — background |
### Accent (indigo / blue)
| Token | Light | Dark | Use for |
|-------|-------|------|---------|
| `--accent` | `#4F46E5` | `#3b82f6` | Primary actions, links, active states |
| `--accent-bg` | `#EEF2FF` | `rgba(59,130,246,0.1)` | Icon wells, strong highlight backgrounds |
| `--accent-dim` | `rgba(79,70,229,0.06)` | `rgba(59,130,246,0.08)` | Subtle hover, selection backgrounds, focus rings |
| `--accent-border` | `rgba(79,70,229,0.10)` | `rgba(59,130,246,0.12)` | Accent-tinted borders |
| `--accent-focus` | `rgba(79,70,229,0.12)` | `rgba(59,130,246,0.15)` | Active states, selection bars |
### Semantic colors
| Token | Light | Dark | Use for |
|-------|-------|------|---------|
| `--success` | `#16A34A` | `#22c55e` | Positive values, income, completed |
| `--success-bg` | `#F0FDF4` | `rgba(34,197,94,0.1)` | Icon wells |
| `--success-dim` | `rgba(34,197,94,0.08)` | `rgba(34,197,94,0.08)` | Badge backgrounds |
| `--error` | `#DC2626` | `#ef4444` | Errors, issues, delete actions |
| `--error-bg` | `#FEF2F2` | `rgba(239,68,68,0.1)` | Icon wells |
| `--error-dim` | `rgba(239,68,68,0.08)` | `rgba(239,68,68,0.08)` | Badge backgrounds |
| `--warning` | `#d97706` | `#f59e0b` | Warnings, pending states |
| `--warning-bg` | `rgba(245,158,11,0.08)` | `rgba(245,158,11,0.1)` | Badge backgrounds |
### Overlay
| Token | Light | Dark | Use for |
|-------|-------|------|---------|
| `--overlay` | `rgba(0,0,0,0.3)` | `rgba(0,0,0,0.6)` | Modal backdrop, reading pane overlay |
| `--overlay-strong` | `rgba(0,0,0,0.5)` | `rgba(0,0,0,0.75)` | Heavy overlays |
| `--nav-bg` | `rgba(255,255,255,0.9)` | `rgba(15,15,18,0.9)` | Navbar blur background |
---
## Typography
| Token | Desktop | Mobile (≤768px) | Use for |
|-------|---------|-----------------|---------|
| `--text-xs` | 11px | 12px | Badges, pills, tiny counters |
| `--text-sm` | 13px | 15px | Labels, meta, captions, button text |
| `--text-base` | 14px | 16px | Body text, list items, inputs |
| `--text-md` | 15px | 17px | Card titles, important rows (16px+ avoids iOS zoom) |
| `--text-lg` | 17px | 18px | Section headers, modal titles |
| `--text-xl` | 22px | 22px | Page titles |
| `--text-2xl` | 28px | 26px | Hero headings |
| `--text-3xl` | 36px | 32px | Large hero numbers |
### Line heights
| Token | Value | Use for |
|-------|-------|---------|
| `--leading-tight` | 1.2 | Headings, hero numbers |
| `--leading-snug` | 1.35 | Card titles, compact text |
| `--leading-normal` | 1.5 | Body text |
| `--leading-relaxed` | 1.65 | Article content |
| `--leading-loose` | 1.8 | Long-form reading |
### Fonts
| Token | Value |
|-------|-------|
| `--font` | `'Inter', -apple-system, system-ui, sans-serif` |
| `--mono` | `'JetBrains Mono', ui-monospace, monospace` |
---
## Global Component Classes
Defined in `app.css`, usable in any component without local `<style>` duplication.
| Class | Description |
|-------|-------------|
| `.module` | Card container (bg, border, shadow, padding) |
| `.module.primary` | Hero card (more padding + elevation) |
| `.module.flush` | No padding (for flush content) |
| `.module-header` | Flex header row (title left, action right) |
| `.module-title` | Uppercase label |
| `.module-action` | Accent link → "View all →" |
| `.data-row` | Standard list item with hover + zebra striping |
| `.badge` + `.error/.success/.warning/.accent/.muted` | Semantic status badges |
| `.tab-bar` + `.tab` + `.tab-badge` | Pill-style tab navigation |
| `.section-label` | Uppercase group header |
| `.btn-primary` / `.btn-primary.full` | Primary action buttons |
| `.btn-secondary` | Secondary action buttons |
| `.btn-icon` | Square icon button (36×36) |
| `.input` | Standard text input |
| `.skeleton` | Shimmer loading placeholder |
| `.page` / `.page-header` / `.page-title` / `.page-greeting` | Page-level layout |
| `.app-surface` | Centered max-width container |
---
## Intentional Raw Values
These values exist outside the token system by design. Do not convert them.
### Non-scale spacing
Values that don't land on the 4px grid. Used for optical tuning where grid steps are too coarse.
| Value | Where | Why |
|-------|-------|-----|
| `1px` | `margin-top` on meta text, feed separators | Sub-pixel nudge for vertical alignment |
| `3px` | Badge padding-y, kbd padding | Optical centering within small elements |
| `5px` | Pill padding-y, chip padding | Between sp-1 (4px) and sp-1.5 (6px), tuned per element |
| `7px` | View-btn padding-y, sidebar gap | Between sp-1.5 (6px) and sp-2 (8px) |
| `9px` | Nav item padding-y, suggestion row padding-y | Between sp-2 (8px) and 10px |
| `10px` | Input padding-y, dropdown padding, various gaps | Common "comfortable touch" size, between sp-2 and sp-3 |
| `11px` | Entry row padding-y, toggle btn padding-bottom | Asymmetric optical alignment |
| `13px` | FAB action padding-y, list row padding | Between sp-3 (12px) and 14px |
| `14px` | Button padding-x, row padding-x, modal gap, field row gap | Most common off-grid value. Used as standard horizontal rhythm for interactive elements. Also `--row-pad-y` for data rows. |
| `15px` | Transaction row padding-y | Between sp-3.5 and sp-4, tuned for readability |
| `18px` | Detail header margin-bottom, AI guide padding-top | Between sp-4 (16px) and sp-5 (20px) |
| `22px` | Trip stats padding-x, modal body padding | Between sp-5 (20px) and sp-6 (24px) |
### Non-scale border-radius
| Value | Where | Why |
|-------|-------|-----|
| `9px` | Status segment control inner radius | Between radius-md (8px) and radius (12px) |
| `10px` | Event cards, photo thumbnails, notes, modals, unscheduled items, food rows | Heavily used "soft card" radius. Between radius-md (8px) and radius (12px). |
| `14px` | CommandPalette box, toggle track | Large interactive controls |
| `20px` | Bottom sheet top corners | Extra-round for sheet feel |
| `50%` | Circular elements (cover nav dots, meal-number, image-delete) | True circle, differs from radius-full on non-square elements |
### Data visualization colors
These are visual category identifiers, not semantic UI colors. They must remain consistent within their visualization set, independent of theme.
**Fitness macros:**
| Color | Hex | Use |
|-------|-----|-----|
| Protein | `#8B5CF6` | Purple macro bar |
| Carbs | `#F59E0B` | Amber macro bar |
| Fat | `#3B82F6` | Blue macro bar |
**Fitness meal weights:**
| Color | Background | Text | Meaning |
|-------|------------|------|---------|
| Heavy | `rgba(239,68,68,0.1)` | `#DC2626` | High-calorie meal |
| Moderate | `rgba(245,158,11,0.1)` | `#B45309` | Medium-calorie meal |
| Light | `rgba(34,197,94,0.1)` | `#15803D` | Low-calorie meal |
**Trip categories:**
| Color | Background | Text | Category |
|-------|------------|------|----------|
| Hotel | `rgba(168,85,247,0.1)` | `#a855f7` | Lodging |
| Restaurant | `rgba(249,115,22,0.1)` | `#f97316` | Food & dining |
| Hike | `rgba(34,197,94,0.15)` | `#16a34a` | Outdoor activity |
| Logistics | `rgba(161,161,170,0.1)` | `--text-3` | Transport, other |
**Other:**
| Color | Hex | Use |
|-------|-----|-----|
| Favorite star | `#F59E0B` | Star icon fill (reader, fitness) |
| AI badge | `rgba(59,130,246,0.1)` / `#3B82F6` | AI-logged entry indicator |
| Transfer pill | `rgba(59,130,246,0.1)` / `#3b82f6` | Budget transfer indicator |
### Glass & overlay effects
Applied to elements layered over images. Not theme-switchable because they depend on photo content, not UI surface.
| Value | Where |
|-------|-------|
| `rgba(0,0,0,0.7)..0.1` | Cover image gradient (trips) |
| `rgba(255,255,255,0.15)` | Cover nav button background |
| `rgba(255,255,255,0.3)` | Cover nav button hover |
| `rgba(0,0,0,0.3)` / `rgba(0,0,0,0.5)` | Cover share button / hover |
| `rgba(0,0,0,0.35)` | Modal overlays (trips), detail overlay (inventory) |
| `rgba(0,0,0,0.5)` | Image delete button, search saving overlay |
### Directional panel shadows
Non-standard shadow directions for slide-in panels and bottom sheets. Cannot use elevation tokens.
| Value | Where |
|-------|-------|
| `0 -8px 32px rgba(0,0,0,0.12)` | FAB bottom sheet (fitness, trips) |
| `-12px 0 40px rgba(0,0,0,0.12)` | Detail slide-in sheet (inventory) |
| `-6px 0 28px rgba(0,0,0,0.08)` | Reading pane (reader) |
| `-8px 0 32px rgba(0,0,0,0.1)` | Edit sheet (trips) |
| `8px 0 24px rgba(0,0,0,0.08)` | Mobile sidebar (reader) |
### Accent-tinted FAB shadows
Colored shadows for floating action buttons. Not in the elevation scale because they use accent color, not black.
| Value | Where |
|-------|-------|
| `0 8px 24px rgba(79,70,229,0.3)` | FAB resting (fitness) |
| `0 12px 32px rgba(79,70,229,0.4)` | FAB hover (fitness) |
| `0 6px 20px rgba(79,70,229,0.3)` | FAB resting (trips) |
| `0 8px 28px rgba(79,70,229,0.4)` | FAB hover (trips) |
### Near-match shadows
Shadows that are close to tokens but intentionally differ in blur radius or opacity.
| Value | Nearest token | Diff |
|-------|--------------|------|
| `0 2px 6px rgba(0,0,0,0.05)` | `--shadow-xs` | Larger blur, higher opacity |
| `0 1px 2px rgba(0,0,0,0.04)` | `--shadow-xs` | Opacity 0.04 vs 0.03 |
| `0 1px 4px rgba(0,0,0,0.04)` | `--shadow-xs` | Larger blur |
| `0 1px 4px rgba(0,0,0,0.08)` | `--shadow-xs` | Larger blur + opacity |
| `0 16px 48px rgba(0,0,0,0.15)` | `--shadow-xl` | Single layer vs dual |
| `0 1px 3px rgba(0,0,0,0.06)` | `--shadow-xs` | Higher opacity |
| `0 8px 24px rgba(0,0,0,0.12)` | `--shadow-md` | Higher opacity |
| `0 20px 60px rgba(0,0,0,0.15)` | `--shadow-xl` | Single layer |
| `0 20px 60px rgba(0,0,0,0.2)` | `--shadow-xl` | Single layer, higher opacity |
| `0 1px 3px rgba(0,0,0,0.15)` | `--shadow-sm` | Toggle thumb, much higher opacity |

15
frontend-v2/Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["node", "build"]

42
frontend-v2/README.md Normal file
View File

@@ -0,0 +1,42 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project
npx sv create my-app
```
To recreate this project with the same configuration:
```sh
# recreate this project
npx sv@0.12.8 create --template minimal --types ts --no-install frontend-v2
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

2332
frontend-v2/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
frontend-v2/package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "frontend-v2",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"svelte": "^5.51.0",
"svelte-check": "^4.4.2",
"typescript": "^5.9.3",
"vite": "^7.3.1"
},
"dependencies": {
"@lucide/svelte": "^1.6.0",
"@sveltejs/adapter-node": "^5.5.4",
"@tailwindcss/vite": "^4.2.2",
"clsx": "^2.1.1",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2"
}
}

461
frontend-v2/src/app.css Normal file
View File

@@ -0,0 +1,461 @@
@import 'tailwindcss';
/* ═══════════════════════════════════════════════
DESIGN SYSTEM — Single source of truth
RULES:
1. No raw px in component <style> blocks — use tokens
2. No raw rgba/hex colors — use semantic tokens
3. No copy-pasting base component styles — use globals
4. Surface hierarchy: canvas → surface → card
5. Text hierarchy: text-1 → text-2 → text-3 → text-4
═══════════════════════════════════════════════ */
@layer base {
:root {
/* ── Fonts ── */
--font: 'Inter', -apple-system, system-ui, sans-serif;
--mono: 'JetBrains Mono', ui-monospace, monospace;
--transition: 180ms ease;
/* ── Spacing scale (4px grid) ──
* Use these everywhere: padding, margin, gap.
* Naming: --sp-{n} where value = n × 4px
*/
--sp-0: 0px;
--sp-px: 1px;
--sp-0.5: 2px;
--sp-1: 4px;
--sp-1.5: 6px;
--sp-2: 8px;
--sp-3: 12px;
--sp-4: 16px;
--sp-5: 20px;
--sp-6: 24px;
--sp-7: 28px;
--sp-8: 32px;
--sp-10: 40px;
--sp-12: 48px;
--sp-16: 64px;
--sp-20: 80px;
/* Semantic spacing aliases */
--section-gap: var(--sp-7);
--card-pad: var(--sp-5);
--card-pad-primary: var(--sp-7);
--card-pad-secondary: var(--sp-4);
--row-gap: var(--sp-3);
--module-gap: var(--sp-5);
--row-pad-y: 14px;
--row-pad-x: var(--sp-4);
--inner-gap: var(--sp-3);
/* ── Radius scale ── */
--radius-xs: 4px;
--radius-sm: 6px;
--radius-md: 8px;
--radius: 12px;
--radius-lg: 16px;
--radius-full: 9999px;
/* ── Elevation scale ──
* xs: barely visible (row hover, inner elements)
* sm: subtle lift (secondary cards, inputs)
* md: standard card elevation
* lg: elevated (dropdowns, popovers, hero cards)
* xl: overlay (modals, slide-out panels)
*/
--shadow-xs: 0 1px 2px rgba(0,0,0,0.03);
--shadow-sm: 0 1px 3px rgba(0,0,0,0.04), 0 4px 12px rgba(0,0,0,0.04);
--shadow-md: 0 2px 6px rgba(0,0,0,0.04), 0 8px 24px rgba(0,0,0,0.06);
--shadow-lg: 0 4px 12px rgba(0,0,0,0.06), 0 16px 40px rgba(0,0,0,0.1);
--shadow-xl: 0 8px 24px rgba(0,0,0,0.08), 0 24px 60px rgba(0,0,0,0.15);
/* Legacy aliases */
--card-shadow: var(--shadow-md);
--card-shadow-sm: var(--shadow-sm);
/* ── Typography scale ──
* xs: badges, pills, tiny counters
* sm: labels, meta, captions, button text
* base: body text, list items, inputs
* md: card titles, important rows (16px avoids iOS zoom)
* lg: section headers, modal titles
* xl: page titles
* 2xl: hero headings
* 3xl: large hero numbers
*/
--text-xs: 11px;
--text-sm: 13px;
--text-base: 14px;
--text-md: 15px;
--text-lg: 17px;
--text-xl: 22px;
--text-2xl: 28px;
--text-3xl: 36px;
/* Line heights */
--leading-tight: 1.2;
--leading-snug: 1.35;
--leading-normal: 1.5;
--leading-relaxed: 1.65;
--leading-loose: 1.8;
}
/* ── LIGHT MODE ── */
:root {
/* Surface hierarchy: canvas (page bg) → surface (sidebars, panels) → card (content containers) */
--canvas: #F5F6F8;
--surface: #FFFFFF;
--surface-secondary: #FAFAFB;
--card: #FFFFFF;
--card-secondary: #FAFAFB;
--card-hover: #f0f0f3;
/* Borders */
--border: rgba(0,0,0,0.06);
--border-strong: rgba(0,0,0,0.1);
/* Text hierarchy: 1 (headings/names) → 2 (body) → 3 (labels/meta) → 4 (placeholder/disabled) */
--text-1: #1a1a1f;
--text-2: #4a4a55;
--text-3: #6b6b76;
--text-4: #b4b4bd;
/* Accent — indigo */
--accent: #4F46E5;
--accent-bg: #EEF2FF;
--accent-dim: rgba(79,70,229,0.06);
--accent-border: rgba(79,70,229,0.10);
--accent-focus: rgba(79,70,229,0.12);
/* Semantic: success */
--success: #16A34A;
--success-bg: #F0FDF4;
--success-dim: rgba(34,197,94,0.08);
/* Semantic: error */
--error: #DC2626;
--error-bg: #FEF2F2;
--error-dim: rgba(239,68,68,0.08);
/* Semantic: warning */
--warning: #d97706;
--warning-bg: rgba(245,158,11,0.08);
/* Overlay */
--overlay: rgba(0,0,0,0.3);
--overlay-strong: rgba(0,0,0,0.5);
--nav-bg: rgba(255,255,255,0.9);
}
/* ── DARK MODE ── */
.dark {
--canvas: #09090b;
--surface: #0f0f12;
--surface-secondary: #111114;
--card: #161619;
--card-secondary: #111114;
--card-hover: #1c1c20;
--border: rgba(255,255,255,0.06);
--border-strong: rgba(255,255,255,0.1);
--text-1: #fafafa;
--text-2: #a1a1aa;
--text-3: #71717a;
--text-4: #3f3f46;
--accent: #3b82f6;
--accent-bg: rgba(59,130,246,0.1);
--accent-dim: rgba(59,130,246,0.08);
--accent-border: rgba(59,130,246,0.12);
--accent-focus: rgba(59,130,246,0.15);
--success: #22c55e;
--success-bg: rgba(34,197,94,0.1);
--success-dim: rgba(34,197,94,0.08);
--error: #ef4444;
--error-bg: rgba(239,68,68,0.1);
--error-dim: rgba(239,68,68,0.08);
--warning: #f59e0b;
--warning-bg: rgba(245,158,11,0.1);
--overlay: rgba(0,0,0,0.6);
--overlay-strong: rgba(0,0,0,0.75);
--nav-bg: rgba(15,15,18,0.9);
/* Dark shadows need higher opacity */
--shadow-xs: 0 1px 2px rgba(0,0,0,0.1);
--shadow-sm: 0 1px 3px rgba(0,0,0,0.15), 0 4px 12px rgba(0,0,0,0.1);
--shadow-md: 0 2px 6px rgba(0,0,0,0.15), 0 8px 24px rgba(0,0,0,0.12);
--shadow-lg: 0 4px 12px rgba(0,0,0,0.2), 0 16px 40px rgba(0,0,0,0.2);
--shadow-xl: 0 8px 24px rgba(0,0,0,0.25), 0 24px 60px rgba(0,0,0,0.3);
}
/* ── Base resets ── */
html, body {
font-family: var(--font);
background: var(--canvas);
color: var(--text-1);
min-height: 100vh;
transition: background var(--transition), color var(--transition);
-webkit-font-smoothing: antialiased;
}
* { border-color: var(--border); }
a { color: inherit; text-decoration: none; }
button { font-family: var(--font); cursor: pointer; }
input { font-family: var(--font); }
}
/* ═══════════════════════════════════════════════
GLOBAL COMPONENT CLASSES
Use these in any component without re-declaring
═══════════════════════════════════════════════ */
/* ── Skeleton loader ── */
.skeleton {
background: linear-gradient(90deg, var(--card) 25%, var(--card-hover) 50%, var(--card) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: var(--radius-xs);
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* ── Buttons ── */
.btn-primary {
padding: var(--sp-2) var(--sp-4);
border-radius: var(--radius-md);
background: var(--accent);
color: white;
border: none;
font-size: var(--text-sm);
font-weight: 500;
cursor: pointer;
transition: all var(--transition);
}
.btn-primary:hover { opacity: 0.9; }
.btn-primary.full {
width: 100%;
padding: var(--sp-3) var(--sp-4);
font-size: var(--text-md);
font-weight: 600;
border-radius: var(--radius);
}
.btn-secondary {
padding: var(--sp-2) var(--sp-4);
border-radius: var(--radius-md);
background: var(--card-secondary);
color: var(--text-2);
border: 1px solid var(--border);
font-size: var(--text-sm);
font-weight: 500;
cursor: pointer;
transition: all var(--transition);
}
.btn-secondary:hover { background: var(--card-hover); color: var(--text-1); }
.btn-icon {
width: 36px;
height: 36px;
border-radius: var(--radius-md);
background: var(--card-secondary);
border: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--text-3);
transition: all var(--transition);
flex-shrink: 0;
}
.btn-icon:hover { color: var(--text-1); background: var(--card-hover); }
.btn-icon svg { width: var(--sp-4); height: var(--sp-4); }
/* ── Inputs ── */
.input {
width: 100%;
padding: 10px 14px;
border-radius: var(--radius-md);
background: var(--surface-secondary);
border: 1px solid var(--border);
color: var(--text-1);
font-size: var(--text-base);
font-family: var(--font);
outline: none;
transition: border-color var(--transition);
}
.input:focus { border-color: var(--accent); }
.input::placeholder { color: var(--text-4); }
/* ── Module (card container) ──
* Use for any content block: dashboard widgets, data tables, etc.
* Variants: .primary (hero, more padding + elevation), .flush (no padding)
*/
.module {
background: var(--card);
border-radius: var(--radius);
border: 1px solid var(--border);
box-shadow: var(--shadow-md);
padding: var(--card-pad);
}
.module.primary {
padding: var(--card-pad-primary);
box-shadow: var(--shadow-lg);
}
.module.flush { padding: 0; }
.module-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--sp-5);
}
.module-title {
font-size: var(--text-sm);
font-weight: 600;
color: var(--text-3);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.module-action {
font-size: var(--text-sm);
color: var(--accent);
font-weight: 500;
cursor: pointer;
text-decoration: none;
}
.module-action:hover { text-decoration: underline; }
/* ── Data row ──
* Standard list item pattern: name + meta on left, value/badge on right.
* Use in transactions, inventory items, feed entries, etc.
*/
.data-row {
display: flex;
align-items: center;
gap: var(--inner-gap);
padding: var(--row-pad-y) var(--row-pad-x);
transition: background var(--transition);
}
.data-row:hover { background: var(--card-hover); }
.data-row + .data-row { border-top: 1px solid var(--border); }
.data-row:nth-child(even) { background: color-mix(in srgb, var(--surface) 68%, var(--card)); }
.data-row:nth-child(even):hover { background: var(--card-hover); }
/* ── Badges ──
* Semantic status badges. Variants: error, success, warning, accent, muted.
*/
.badge {
font-size: var(--text-xs);
font-weight: 500;
padding: 3px 10px;
border-radius: var(--radius-sm);
flex-shrink: 0;
}
.badge.error { background: var(--error-dim); color: var(--error); }
.badge.success { background: var(--success-dim); color: var(--success); }
.badge.warning { background: var(--warning-bg); color: var(--warning); }
.badge.accent { background: var(--accent-dim); color: var(--accent); }
.badge.muted { background: var(--card-hover); color: var(--text-4); }
/* ── Tabs ──
* Pill-style tab bar.
*/
.tab-bar { display: flex; gap: var(--sp-1); }
.tab {
padding: var(--sp-2) 14px;
border-radius: var(--radius-md);
font-size: var(--text-base);
font-weight: 500;
color: var(--text-3);
background: none;
border: none;
cursor: pointer;
transition: all var(--transition);
font-family: var(--font);
display: flex;
align-items: center;
gap: var(--sp-1.5);
}
.tab:hover { color: var(--text-1); background: var(--card-hover); }
.tab.active { color: var(--text-1); background: var(--card); box-shadow: var(--shadow-xs); }
.tab-badge {
font-size: var(--text-xs);
font-family: var(--mono);
background: var(--accent-dim);
color: var(--accent);
padding: 1px 6px;
border-radius: var(--radius-xs);
margin-left: var(--sp-1);
}
/* ── Section header ──
* Uppercase label above a group of content.
*/
.section-label {
font-size: var(--text-xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-3);
margin-bottom: var(--sp-1.5);
}
/* ── Page wrapper ── */
.page { padding: var(--sp-8) 0 var(--sp-20); }
.page-header { margin-bottom: var(--section-gap); }
.page-title {
font-size: var(--text-sm);
font-weight: 500;
color: var(--text-3);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: var(--sp-1);
}
.page-greeting {
font-size: var(--text-2xl);
font-weight: 300;
color: var(--text-1);
line-height: var(--leading-tight);
}
.page-greeting strong { font-weight: 600; }
/* ── App surface (centered container) ── */
.app-surface {
max-width: 1200px;
width: 100%;
margin: 0 auto;
padding: 0 var(--sp-6);
}
/* ── Responsive ── */
@media (max-width: 768px) {
:root {
--text-xs: 12px;
--text-sm: 15px;
--text-base: 16px;
--text-md: 17px;
--text-lg: 18px;
--text-xl: 22px;
--text-2xl: 26px;
--text-3xl: 32px;
--card-pad: var(--sp-4);
--card-pad-primary: var(--sp-5);
--row-pad-y: var(--sp-4);
--section-gap: var(--sp-5);
}
.page-greeting { font-size: var(--text-xl); }
.page { padding: var(--sp-5) 0 var(--sp-20); }
.app-surface { padding: 0 var(--sp-5); }
}

13
frontend-v2/src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

11
frontend-v2/src/app.html Normal file
View File

@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -0,0 +1,194 @@
import type { Handle } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
const gatewayUrl = env.GATEWAY_URL || 'http://localhost:8100';
const immichUrl = env.IMMICH_URL || '';
const immichApiKey = env.IMMICH_API_KEY || '';
const karakeepUrl = env.KARAKEEP_URL || '';
const karakeepApiKey = env.KARAKEEP_API_KEY || '';
export const handle: Handle = async ({ event, resolve }) => {
// ── Immich API proxy (shared across all pages) ──
if (event.url.pathname.startsWith('/api/immich/') && immichUrl && immichApiKey) {
const immichPath = event.url.pathname.replace('/api/immich', '/api');
// Thumbnail/original image proxy — cache-friendly binary response
if (immichPath.includes('/thumbnail') || immichPath.includes('/original')) {
const assetId = event.url.pathname.split('/')[4]; // /api/immich/assets/{id}/thumbnail
const type = immichPath.includes('/original') ? 'original' : 'thumbnail';
try {
const response = await fetch(`${immichUrl}/api/assets/${assetId}/${type}`, {
headers: { 'x-api-key': immichApiKey }
});
return new Response(response.body, {
status: response.status,
headers: {
'Content-Type': response.headers.get('Content-Type') || 'image/webp',
'Cache-Control': 'public, max-age=86400'
}
});
} catch {
return new Response('', { status: 502 });
}
}
// JSON API proxy (search, timeline, etc.)
try {
const targetUrl = `${immichUrl}${immichPath}${event.url.search}`;
const reqHeaders: Record<string, string> = { 'x-api-key': immichApiKey };
const ct = event.request.headers.get('content-type');
if (ct) reqHeaders['content-type'] = ct;
const response = await fetch(targetUrl, {
method: event.request.method,
headers: reqHeaders,
body: event.request.method !== 'GET' && event.request.method !== 'HEAD'
? await event.request.arrayBuffer()
: undefined
});
// For search results, strip down to minimal fields to avoid huge payloads
if (immichPath.includes('/search/') && response.ok) {
const data = await response.json();
if (data.assets?.items) {
data.assets.items = data.assets.items.map((a: Record<string, unknown>) => ({
id: a.id,
type: a.type,
fileCreatedAt: a.fileCreatedAt,
originalFileName: a.originalFileName
}));
}
return new Response(JSON.stringify(data), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
const responseHeaders = new Headers();
for (const [key, value] of response.headers.entries()) {
responseHeaders.append(key, value);
}
return new Response(response.body, { status: response.status, headers: responseHeaders });
} catch {
return new Response(JSON.stringify({ error: 'Immich unavailable' }), {
status: 502, headers: { 'Content-Type': 'application/json' }
});
}
}
// ── Karakeep API proxy (server-to-server) ──
if (event.url.pathname.startsWith('/api/karakeep/') && karakeepUrl && karakeepApiKey) {
const action = event.url.pathname.split('/').pop(); // 'save' or 'delete'
try {
const body = await event.request.json();
if (action === 'save' && body.url) {
const response = await fetch(`${karakeepUrl}/api/v1/bookmarks`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${karakeepApiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ type: 'link', url: body.url })
});
const data = await response.json();
if (response.ok) {
return new Response(JSON.stringify({ ok: true, id: data.id || '' }), {
status: 200, headers: { 'Content-Type': 'application/json' }
});
}
return new Response(JSON.stringify({ error: 'Save failed', status: response.status }), {
status: response.status, headers: { 'Content-Type': 'application/json' }
});
}
if (action === 'delete' && body.id) {
const response = await fetch(`${karakeepUrl}/api/v1/bookmarks/${body.id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${karakeepApiKey}` }
});
return new Response(JSON.stringify({ ok: response.ok }), {
status: 200, headers: { 'Content-Type': 'application/json' }
});
}
return new Response(JSON.stringify({ error: 'Invalid request' }), {
status: 400, headers: { 'Content-Type': 'application/json' }
});
} catch (err) {
console.error('Karakeep proxy error:', err);
return new Response(JSON.stringify({ error: 'Karakeep unavailable', detail: String(err) }), {
status: 502, headers: { 'Content-Type': 'application/json' }
});
}
}
// Legacy trips-specific Immich thumbnail (keep for backward compat)
if (event.url.pathname.startsWith('/api/trips/immich/thumb/') && immichUrl && immichApiKey) {
const assetId = event.url.pathname.split('/').pop();
try {
const response = await fetch(`${immichUrl}/api/assets/${assetId}/thumbnail`, {
headers: { 'x-api-key': immichApiKey }
});
return new Response(response.body, {
status: response.status,
headers: {
'Content-Type': response.headers.get('Content-Type') || 'image/webp',
'Cache-Control': 'public, max-age=86400'
}
});
} catch {
return new Response('', { status: 502 });
}
}
// Proxy all /api/* requests to the gateway
if (event.url.pathname.startsWith('/api/') || event.url.pathname.startsWith('/images/')) {
// Rewrite /images/trips/* to /api/trips/images/* for gateway routing
let targetPath = event.url.pathname;
if (targetPath.startsWith('/images/trips/')) {
targetPath = '/api/trips' + targetPath.replace('/images/trips', '/images');
} else if (targetPath.startsWith('/images/fitness/')) {
targetPath = '/api/fitness' + targetPath.replace('/images/fitness', '/images');
}
const targetUrl = `${gatewayUrl}${targetPath}${event.url.search}`;
const headers = new Headers();
// Forward all relevant headers
for (const [key, value] of event.request.headers.entries()) {
if (['authorization', 'content-type', 'cookie', 'x-api-key', 'x-telegram-user-id'].includes(key.toLowerCase())) {
headers.set(key, value);
}
}
try {
const response = await fetch(targetUrl, {
method: event.request.method,
headers,
body: event.request.method !== 'GET' && event.request.method !== 'HEAD'
? await event.request.arrayBuffer()
: undefined
});
// Forward set-cookie headers from gateway
const responseHeaders = new Headers();
for (const [key, value] of response.headers.entries()) {
responseHeaders.append(key, value);
}
return new Response(response.body, {
status: response.status,
headers: responseHeaders
});
} catch (error) {
console.error('Gateway proxy error:', error);
return new Response(JSON.stringify({ error: 'Gateway unavailable' }), {
status: 502,
headers: { 'Content-Type': 'application/json' }
});
}
}
return resolve(event);
};

View File

@@ -0,0 +1,78 @@
export type ApiClientOptions = {
basePath: string;
loginPath?: string;
};
export function createApiClient({ basePath, loginPath = '/login' }: ApiClientOptions) {
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const headers: Record<string, string> = {
...((options.headers as Record<string, string>) || {})
};
if (options.body && typeof options.body === 'string') {
headers['Content-Type'] = 'application/json';
}
const response = await fetch(`${basePath}${path}`, {
...options,
headers,
credentials: 'include' // sends platform_session cookie
});
if (response.status === 401) {
if (typeof window !== 'undefined') {
window.location.href = loginPath;
}
throw new Error('Unauthorized');
}
if (!response.ok) {
const err = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(err.error || `HTTP ${response.status}`);
}
return response.json();
}
return {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, data: unknown) => request<T>(path, { method: 'POST', body: JSON.stringify(data) }),
patch: <T>(path: string, data: unknown) => request<T>(path, { method: 'PATCH', body: JSON.stringify(data) }),
put: <T>(path: string, data: unknown) => request<T>(path, { method: 'PUT', body: JSON.stringify(data) }),
del: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
// For raw fetch (file uploads etc)
fetch: (path: string, options: RequestInit = {}) =>
fetch(`${basePath}${path}`, { ...options, credentials: 'include' })
};
}
// Platform auth helpers
export const platformAuth = {
async login(username: string, password: string) {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
credentials: 'include'
});
return res.json();
},
async logout() {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
},
async me() {
const res = await fetch('/api/auth/me', { credentials: 'include' });
return res.json();
},
async isAuthenticated(): Promise<boolean> {
try {
const data = await this.me();
return data.authenticated === true;
} catch {
return false;
}
}
};

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,114 @@
<script lang="ts">
import { onMount } from 'svelte';
let spending = $state('...');
let month = $state('');
let topCats = $state<{ name: string; amount: string; isIncome: boolean }[]>([]);
onMount(async () => {
try {
const res = await fetch('/api/budget/summary', { credentials: 'include' });
if (res.ok) {
const data = await res.json();
spending = '$' + Math.abs(data.spendingDollars || 0).toLocaleString('en-US');
const m = data.month || '';
if (m) {
const d = new Date(m + '-01');
month = d.toLocaleDateString('en-US', { month: 'long' });
}
const income = data.incomeDollars || 0;
const cats = (data.topCategories || []).map((c: any) => ({
name: c.name,
amount: '$' + Math.abs(c.amountDollars || 0).toLocaleString('en-US'),
isIncome: false
}));
if (income > 0) {
topCats = [{ name: 'Income', amount: '+$' + Math.abs(income).toLocaleString('en-US'), isIncome: true }, ...cats];
} else {
topCats = cats;
}
}
} catch { /* silent */ }
});
</script>
<div class="module primary">
<div class="module-header">
<div class="module-title">Budget{month ? ' · ' + month : ''}</div>
<a href="/budget" class="module-action">View all &rarr;</a>
</div>
<div class="budget-hero">
<div class="budget-amount">{spending}</div>
<div class="budget-label">spent this month</div>
</div>
<div class="budget-rows">
{#each topCats as cat}
<div class="budget-row">
<span class="budget-row-name">{cat.name}</span>
<span class="budget-row-amount" class:income={cat.isIncome}>{cat.amount}</span>
</div>
{/each}
</div>
</div>
<style>
/* No .module/.module-header/.module-title/.module-action — using globals from app.css */
.budget-hero {
margin-bottom: var(--sp-5);
}
.budget-amount {
font-size: var(--text-3xl);
font-weight: 500;
font-family: var(--mono);
color: var(--text-1);
line-height: 1;
}
.budget-label {
font-size: var(--text-base);
color: var(--text-3);
margin-top: var(--sp-1.5);
}
.budget-rows {
display: flex;
flex-direction: column;
}
.budget-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--sp-3) 0;
border-bottom: 1px solid var(--border);
}
.budget-row:last-child {
border-bottom: none;
}
.budget-row-name {
font-size: var(--text-base);
color: var(--text-2);
}
.budget-row-amount {
font-size: var(--text-base);
font-family: var(--mono);
color: var(--text-1);
}
.budget-row-amount.income {
color: var(--success);
}
@media (max-width: 768px) {
.budget-amount {
font-size: var(--text-2xl);
}
}
</style>

View File

@@ -0,0 +1,157 @@
<script lang="ts">
let {
title,
description,
action,
variant,
size = 'secondary',
href
}: {
title: string;
description: string;
action: string;
variant: 'budget' | 'inventory' | 'fitness';
size?: 'primary' | 'secondary';
href: string;
} = $props();
</script>
<a {href} class="action-card {size}">
<div class="action-card-left">
<div class="action-card-icon {variant}">
{#if variant === 'budget'}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
{:else if variant === 'inventory'}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16.5 9.4l-9-5.19"/><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>
{:else if variant === 'fitness'}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
{/if}
</div>
<div class="action-card-text">
<div class="action-card-title">{title}</div>
<div class="action-card-desc">{description}</div>
</div>
</div>
<div class="action-card-right">
{action}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"/></svg>
</div>
</a>
<style>
.action-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--sp-5) var(--sp-5);
border-radius: var(--radius-lg);
background: var(--card);
border: 1px solid var(--border);
box-shadow: var(--shadow-md);
transition: all var(--transition);
cursor: pointer;
text-decoration: none;
}
.action-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.action-card.primary {
box-shadow: var(--shadow-lg);
transform: translateY(-1px);
}
.action-card.primary:hover {
transform: translateY(-3px);
box-shadow: var(--shadow-lg);
}
.action-card-left {
display: flex;
align-items: center;
gap: var(--inner-gap);
flex: 1;
min-width: 0;
}
.action-card-icon {
width: 36px;
height: 36px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
/* Icon variants — using semantic tokens for both themes */
.action-card-icon.budget { background: var(--accent-bg); color: var(--accent); }
.action-card-icon.inventory { background: var(--error-bg); color: var(--error); }
.action-card-icon.fitness { background: var(--success-bg); color: var(--success); }
.action-card-icon :global(svg) {
width: 18px;
height: 18px;
}
.action-card-text {
flex: 1;
min-width: 0;
}
.action-card-title {
font-size: var(--text-md);
font-weight: 600;
color: var(--text-1);
line-height: var(--leading-snug);
}
.action-card-desc {
font-size: var(--text-sm);
color: var(--text-3);
margin-top: var(--sp-0.5);
line-height: 1.4;
}
.action-card-right {
font-size: var(--text-sm);
font-weight: 500;
color: var(--text-3);
display: flex;
align-items: center;
gap: 1px;
flex-shrink: 0;
padding: var(--sp-1) var(--sp-2);
border-radius: var(--radius-md);
transition: all var(--transition);
white-space: nowrap;
}
.action-card:hover .action-card-right {
color: var(--accent);
background: var(--accent-dim);
}
.action-card-right :global(svg) {
width: var(--sp-4);
height: var(--sp-4);
transition: transform var(--transition);
}
.action-card:hover .action-card-right :global(svg) {
transform: translateX(2px);
}
@media (max-width: 768px) {
.action-card {
padding: var(--sp-4);
gap: var(--sp-2);
}
.action-card-left {
gap: var(--sp-3);
}
}
</style>

View File

@@ -0,0 +1,199 @@
<script lang="ts">
import { onMount } from 'svelte';
let loading = $state(true);
let error = $state(false);
let notConnected = $state(false);
let eaten = $state(0);
let remaining = $state(0);
let percent = $state(0);
let calorieGoal = $state(0);
let proteinCurrent = $state(0);
let proteinGoal = $state(0);
let carbsCurrent = $state(0);
let carbsGoal = $state(0);
let fatCurrent = $state(0);
let fatGoal = $state(0);
function fmt(n: number): string {
return Math.round(n).toLocaleString();
}
onMount(async () => {
const n = new Date();
const today = `${n.getFullYear()}-${String(n.getMonth() + 1).padStart(2, '0')}-${String(n.getDate()).padStart(2, '0')}`;
try {
const [totalsRes, goalsRes] = await Promise.all([
fetch(`/api/fitness/entries/totals?date=${today}`),
fetch(`/api/fitness/goals/for-date?date=${today}`)
]);
if (totalsRes.status === 401 || goalsRes.status === 401) {
notConnected = true;
loading = false;
return;
}
if (!totalsRes.ok || (!goalsRes.ok && goalsRes.status !== 404)) {
error = true;
loading = false;
return;
}
const totals = await totalsRes.json();
eaten = totals.total_calories ?? 0;
proteinCurrent = totals.total_protein ?? 0;
carbsCurrent = totals.total_carbs ?? 0;
fatCurrent = totals.total_fat ?? 0;
if (goalsRes.ok) {
const goals = await goalsRes.json();
calorieGoal = goals.calories ?? 0;
proteinGoal = goals.protein ?? 0;
carbsGoal = goals.carbs ?? 0;
fatGoal = goals.fat ?? 0;
}
remaining = Math.max(0, calorieGoal - eaten);
percent = calorieGoal > 0 ? Math.min(100, Math.round((eaten / calorieGoal) * 100)) : 0;
loading = false;
} catch {
error = true;
loading = false;
}
});
</script>
<div class="module">
<div class="module-header">
<div class="module-title">Fitness &middot; Today</div>
<a href="/fitness" class="module-action">Details &rarr;</a>
</div>
{#if notConnected}
<div class="fitness-top">
<div class="fitness-avatar">Y</div>
<div>
<div class="fitness-name">Yusuf</div>
<div class="fitness-sub">Connect fitness to get started</div>
</div>
</div>
{:else}
<div class="fitness-top">
<div class="fitness-avatar">Y</div>
<div>
<div class="fitness-name">Yusuf</div>
<div class="fitness-sub">
{#if loading}...{:else if error}&mdash;{:else}{fmt(eaten)} cal &middot; {fmt(remaining)} remaining{/if}
</div>
</div>
</div>
<div class="fitness-bar">
<div class="fitness-bar-fill" style="width: {loading || error ? 0 : percent}%"></div>
</div>
<div class="fitness-macros">
<div class="fitness-macro">
<div class="fitness-macro-val">
{#if loading}...{:else if error}&mdash;{:else}{Math.round(proteinCurrent)}<span class="fitness-macro-unit">/{Math.round(proteinGoal)}g</span>{/if}
</div>
<div class="fitness-macro-label">protein</div>
</div>
<div class="fitness-macro">
<div class="fitness-macro-val">
{#if loading}...{:else if error}&mdash;{:else}{Math.round(carbsCurrent)}<span class="fitness-macro-unit">/{Math.round(carbsGoal)}g</span>{/if}
</div>
<div class="fitness-macro-label">carbs</div>
</div>
<div class="fitness-macro">
<div class="fitness-macro-val">
{#if loading}...{:else if error}&mdash;{:else}{Math.round(fatCurrent)}<span class="fitness-macro-unit">/{Math.round(fatGoal)}g</span>{/if}
</div>
<div class="fitness-macro-label">fat</div>
</div>
</div>
{/if}
</div>
<style>
/* No .module/.module-header/.module-title/.module-action — using globals from app.css */
.fitness-top {
display: flex;
align-items: center;
gap: var(--sp-3);
margin-bottom: var(--sp-4);
}
.fitness-avatar {
width: var(--sp-10);
height: var(--sp-10);
border-radius: var(--radius-full);
background: var(--accent-dim);
display: flex;
align-items: center;
justify-content: center;
font-size: var(--text-base);
font-weight: 600;
color: var(--accent);
flex-shrink: 0;
}
.fitness-name {
font-size: var(--text-md);
font-weight: 500;
color: var(--text-1);
}
.fitness-sub {
font-size: var(--text-sm);
color: var(--text-3);
margin-top: 1px;
}
.fitness-bar {
height: var(--sp-1.5);
background: var(--border);
border-radius: 3px;
overflow: hidden;
margin-bottom: var(--sp-4);
}
.fitness-bar-fill {
height: 100%;
background: var(--accent);
border-radius: 3px;
transition: width 0.5s ease;
}
.fitness-macros {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: var(--row-gap);
}
.fitness-macro-val {
font-size: var(--text-md);
font-weight: 500;
font-family: var(--mono);
color: var(--text-1);
}
.fitness-macro-unit {
font-size: var(--text-sm);
font-weight: 400;
color: var(--text-3);
}
.fitness-macro-label {
font-size: var(--text-sm);
color: var(--text-3);
margin-top: var(--sp-0.5);
}
</style>

View File

@@ -0,0 +1,113 @@
<script lang="ts">
import { onMount } from 'svelte';
interface Issue {
id: number;
item: string;
orderNumber: string;
}
let issues = $state<Issue[]>([]);
let loading = $state(true);
onMount(async () => {
try {
const res = await fetch('/api/inventory/summary', { credentials: 'include' });
if (res.ok) {
const data = await res.json();
issues = (data.issues || []).slice(0, 5);
}
} catch { /* silent */ }
loading = false;
});
</script>
<div class="module">
<div class="module-header">
<div class="module-title">Issues</div>
<a href="/inventory" class="module-action">View all &rarr;</a>
</div>
<div class="issue-rows">
{#if loading}
{#each [1, 2, 3] as _}
<div class="issue-row">
<div class="issue-info">
<div class="skeleton" style="width:160px;height:14px"></div>
<div class="skeleton" style="width:120px;height:12px;margin-top:var(--sp-1)"></div>
</div>
</div>
{/each}
{:else if issues.length === 0}
<div class="issue-empty">No issues found</div>
{:else}
{#each issues as issue}
<a href="/inventory?item={issue.id}" class="issue-row">
<div class="issue-info">
<div class="issue-name">{issue.item}</div>
<div class="issue-meta">Order #{issue.orderNumber || '—'}</div>
</div>
<span class="badge error" style="text-transform:uppercase;letter-spacing:0.02em;font-weight:600">Issue</span>
</a>
{/each}
{/if}
</div>
</div>
<style>
/* No .module/.module-header/.module-title/.module-action — using globals from app.css */
.issue-rows {
display: flex;
flex-direction: column;
}
.issue-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--sp-3) var(--sp-3) var(--sp-3) var(--sp-4);
border-bottom: 1px solid var(--border);
border-left: 3px solid var(--error);
margin-left: calc(-1 * var(--sp-1));
transition: background var(--transition);
border-radius: 0 var(--radius-xs) var(--radius-xs) 0;
text-decoration: none;
color: inherit;
}
.issue-row:last-child {
border-bottom: none;
}
.issue-row:hover {
background: var(--card-hover);
}
.issue-info {
min-width: 0;
flex: 1;
}
.issue-name {
font-size: var(--text-base);
font-weight: 500;
color: var(--text-1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.issue-meta {
font-size: var(--text-sm);
color: var(--text-3);
margin-top: var(--sp-0.5);
}
.issue-empty {
padding: var(--sp-5);
text-align: center;
font-size: var(--text-sm);
color: var(--text-3);
}
</style>

View File

@@ -0,0 +1,187 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { Search, Plus, Package, Clock } from '@lucide/svelte';
interface Props {
open: boolean;
onclose: () => void;
}
let { open = $bindable(), onclose }: Props = $props();
let query = $state('');
let selectedIndex = $state(0);
let inputEl: HTMLInputElement | undefined = $state();
const quickActions = [
{ icon: Plus, label: 'Log food', desc: 'Quick add calories', href: '/fitness' },
{ icon: Package, label: 'New inventory item', desc: 'Add a new item to track', href: '/inventory' },
{ icon: Clock, label: 'Go to Budget', desc: 'View transactions', href: '/budget' },
];
let filteredActions = $derived(
query.trim()
? quickActions.filter(a =>
a.label.toLowerCase().includes(query.toLowerCase()) ||
a.desc.toLowerCase().includes(query.toLowerCase())
)
: quickActions
);
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
onclose();
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
selectedIndex = Math.min(selectedIndex + 1, filteredActions.length - 1);
}
if (e.key === 'ArrowUp') {
e.preventDefault();
selectedIndex = Math.max(selectedIndex - 1, 0);
}
if (e.key === 'Enter' && filteredActions[selectedIndex]) {
goto(filteredActions[selectedIndex].href);
onclose();
}
}
$effect(() => {
if (open) {
query = '';
selectedIndex = 0;
// Focus input after render
requestAnimationFrame(() => inputEl?.focus());
}
});
</script>
{#if open}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="cmd-overlay open" onclick={(e) => { if (e.target === e.currentTarget) onclose(); }} onkeydown={handleKeydown}>
<div class="cmd-box" role="dialog" aria-label="Command palette">
<div class="cmd-input-wrap">
<Search size={18} />
<input
class="cmd-input"
placeholder="Search transactions, items, or type a command..."
bind:value={query}
bind:this={inputEl}
onkeydown={handleKeydown}
/>
</div>
<div class="cmd-results">
{#each filteredActions as action, i}
<button
class="cmd-item"
class:selected={i === selectedIndex}
onclick={() => { goto(action.href); onclose(); }}
onmouseenter={() => selectedIndex = i}
>
<div class="cmd-item-icon">
<action.icon size={16} />
</div>
<div>
<div class="cmd-item-text">{action.label}</div>
<div class="cmd-item-desc">{action.desc}</div>
</div>
</button>
{/each}
</div>
<div class="cmd-footer">
<div class="cmd-hint"><kbd>&uarr;&darr;</kbd> navigate</div>
<div class="cmd-hint"><kbd>&crarr;</kbd> select</div>
<div class="cmd-hint"><kbd>esc</kbd> close</div>
</div>
</div>
</div>
{/if}
<style>
.cmd-overlay {
position: fixed;
inset: 0;
background: var(--overlay);
z-index: 100;
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 20vh;
}
.cmd-box {
width: 560px;
max-width: 90vw;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
box-shadow: 0 24px 48px rgba(0,0,0,0.2);
overflow: hidden;
}
.cmd-input-wrap {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
}
.cmd-input-wrap :global(svg) { color: var(--text-3); flex-shrink: 0; }
.cmd-input {
flex: 1;
background: none;
border: none;
outline: none;
font-size: var(--text-md);
color: var(--text-1);
font-family: var(--font);
}
.cmd-input::placeholder { color: var(--text-4); }
.cmd-results { max-height: 300px; overflow-y: auto; }
.cmd-item {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: 10px var(--sp-4);
cursor: pointer;
transition: background var(--transition);
width: 100%;
background: none;
border: none;
text-align: left;
}
.cmd-item:hover, .cmd-item.selected { background: var(--accent-dim); }
.cmd-item-icon {
width: 32px;
height: 32px;
border-radius: var(--radius-md);
background: var(--card-secondary);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.cmd-item-icon :global(svg) { color: var(--text-3); }
.cmd-item-text { font-size: var(--text-base); color: var(--text-1); }
.cmd-item-desc { font-size: var(--text-sm); color: var(--text-3); }
.cmd-footer {
padding: var(--sp-2) var(--sp-4);
border-top: 1px solid var(--border);
display: flex;
gap: var(--sp-3);
}
.cmd-hint {
font-size: var(--text-xs);
color: var(--text-4);
display: flex;
align-items: center;
gap: var(--sp-1);
}
.cmd-hint kbd {
font-family: var(--mono);
font-size: var(--text-xs);
padding: 1px 4px;
border-radius: 3px;
background: var(--card-secondary);
border: 1px solid var(--border);
}
</style>

View File

@@ -0,0 +1,165 @@
<script lang="ts">
import { page } from '$app/state';
import { LayoutDashboard, DollarSign, Package, Activity, MoreVertical, MapPin, BookOpen, Library, Settings } from '@lucide/svelte';
let moreOpen = $state(false);
function isActive(path: string): boolean {
if (path === '/') return page.url.pathname === '/';
return page.url.pathname.startsWith(path);
}
function closeMore() {
moreOpen = false;
}
</script>
<div class="mobile-tabbar">
<div class="mobile-tabbar-inner">
<a href="/" class="mobile-tab" class:active={isActive('/')}>
<LayoutDashboard size={22} />
Dashboard
</a>
<a href="/budget" class="mobile-tab" class:active={isActive('/budget')}>
<DollarSign size={22} />
Budget
</a>
<a href="/inventory" class="mobile-tab" class:active={isActive('/inventory')}>
<Package size={22} />
Inventory
</a>
<a href="/fitness" class="mobile-tab" class:active={isActive('/fitness')}>
<Activity size={22} />
Fitness
</a>
<button class="mobile-tab" class:active={moreOpen} onclick={() => moreOpen = true}>
<MoreVertical size={22} />
More
</button>
</div>
</div>
<!-- More sheet overlay -->
{#if moreOpen}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="more-sheet-overlay open" onclick={(e) => { if (e.target === e.currentTarget) closeMore(); }} onkeydown={() => {}}>
<div class="more-sheet">
<div class="more-sheet-handle"></div>
<a href="/trips" class="more-sheet-item" onclick={closeMore}>
<MapPin size={20} />
Trips
</a>
<a href="/reader" class="more-sheet-item" onclick={closeMore}>
<BookOpen size={20} />
Reader
</a>
<a href="/media" class="more-sheet-item" onclick={closeMore}>
<Library size={20} />
Media
</a>
<a href="/settings" class="more-sheet-item" onclick={closeMore}>
<Settings size={20} />
Settings
</a>
</div>
</div>
{/if}
<style>
.mobile-tabbar {
display: none;
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 80;
background: var(--nav-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-top: 1px solid var(--border);
padding-bottom: env(safe-area-inset-bottom, 0px);
}
.mobile-tabbar-inner {
display: flex;
align-items: center;
justify-content: space-around;
height: 56px;
max-width: 500px;
margin: 0 auto;
}
.mobile-tab {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--sp-0.5);
background: none;
border: none;
color: var(--text-3);
font-size: var(--text-xs);
font-weight: 500;
padding: var(--sp-1) var(--sp-2);
border-radius: var(--radius-md);
transition: all var(--transition);
min-width: 56px;
text-decoration: none;
}
.mobile-tab.active { color: var(--accent); }
.mobile-tab:hover { color: var(--text-1); }
/* More sheet */
.more-sheet-overlay {
position: fixed;
inset: 0;
background: var(--overlay);
z-index: 90;
}
.more-sheet {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 91;
background: var(--surface);
border-top-left-radius: 16px;
border-top-right-radius: 16px;
padding: var(--sp-3) var(--sp-4) var(--sp-8);
animation: slideUp 0.3s ease;
}
@keyframes slideUp {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
.more-sheet-handle {
width: 36px;
height: 4px;
border-radius: 2px;
background: var(--text-4);
margin: 0 auto var(--sp-4);
}
.more-sheet-item {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: 14px var(--sp-3);
border-radius: var(--radius-sm);
font-size: var(--text-md);
font-weight: 500;
color: var(--text-1);
background: none;
border: none;
width: 100%;
text-align: left;
transition: background var(--transition);
text-decoration: none;
}
.more-sheet-item:hover { background: var(--card-hover); }
.more-sheet-item :global(svg) { color: var(--text-3); }
@media (max-width: 768px) {
.mobile-tabbar { display: block; }
}
@media (min-width: 769px) {
.mobile-tabbar { display: none !important; }
.more-sheet-overlay { display: none !important; }
}
</style>

View File

@@ -0,0 +1,215 @@
<script lang="ts">
import { page } from '$app/state';
import { toggleTheme, isDark } from '$lib/stores/theme.svelte';
import { Map, Sun, Moon, User, Search } from '@lucide/svelte';
interface Props {
onOpenCommand?: () => void;
}
let { onOpenCommand }: Props = $props();
let tripsOpen = $state(false);
let fitnessOpen = $state(false);
function isActive(path: string): boolean {
return page.url.pathname === path || page.url.pathname.startsWith(path + '/');
}
function closeDropdowns() {
tripsOpen = false;
fitnessOpen = false;
}
</script>
<svelte:window onclick={closeDropdowns} />
<nav class="navbar">
<div class="navbar-inner">
<a href="/" class="navbar-logo">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7l6-3 6 3 6-3v13l-6 3-6-3-6 3V7z"/><path d="M9 4v13"/><path d="M15 7v13"/></svg>
Platform
</a>
<div class="navbar-links">
<a href="/" class="navbar-link" class:active={page.url.pathname === '/'}>Dashboard</a>
<!-- Trips dropdown -->
<a href="/trips" class="navbar-link" class:active={isActive('/trips')}>Trips</a>
<!-- Fitness dropdown -->
<div class="nav-dropdown" role="menu">
<button
class="navbar-link"
class:active={isActive('/fitness')}
onclick={(e) => { e.stopPropagation(); fitnessOpen = !fitnessOpen; tripsOpen = false; }}
>Fitness</button>
{#if fitnessOpen}
<div class="nav-dropdown-menu" onclick={(e) => e.stopPropagation()}>
<a href="/fitness" class="nav-dropdown-item" onclick={closeDropdowns}>Dashboard</a>
<a href="/fitness/foods" class="nav-dropdown-item" onclick={closeDropdowns}>Foods</a>
<a href="/fitness/goals" class="nav-dropdown-item" onclick={closeDropdowns}>Goals</a>
<a href="/fitness/templates" class="nav-dropdown-item" onclick={closeDropdowns}>Templates</a>
</div>
{/if}
</div>
<a href="/inventory" class="navbar-link" class:active={isActive('/inventory')}>Inventory</a>
<a href="/budget" class="navbar-link" class:active={isActive('/budget')}>Budget</a>
<a href="/reader" class="navbar-link" class:active={isActive('/reader')}>Reader</a>
<a href="/media" class="navbar-link" class:active={isActive('/media')}>Media</a>
</div>
<button class="search-trigger" onclick={onOpenCommand}>
<Search size={14} />
Search...
<kbd>&#8984;K</kbd>
</button>
<div class="navbar-right">
<button class="navbar-icon" onclick={toggleTheme} title="Toggle theme">
{#if isDark()}
<Sun size={18} />
{:else}
<Moon size={18} />
{/if}
</button>
<a href="/settings" class="navbar-icon" title="Settings">
<User size={18} />
</a>
</div>
</div>
</nav>
<style>
.navbar {
position: sticky;
top: 0;
z-index: 50;
background: var(--nav-bg);
border-bottom: 1px solid var(--border);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
.navbar-inner {
max-width: 1200px;
margin: 0 auto;
display: flex;
align-items: center;
height: 56px;
padding: 0 var(--sp-6);
gap: var(--sp-6);
}
.navbar-logo {
font-weight: 600;
font-size: var(--text-md);
display: flex;
align-items: center;
gap: var(--sp-2);
color: var(--text-1);
flex-shrink: 0;
}
.navbar-logo svg { width: 20px; height: 20px; }
.navbar-links {
display: flex;
align-items: center;
gap: var(--sp-0.5);
flex: 1;
}
.navbar-link {
padding: 6px var(--sp-3);
border-radius: var(--radius-sm);
font-size: var(--text-base);
font-weight: 500;
color: var(--text-3);
transition: all var(--transition);
background: none;
border: none;
position: relative;
text-decoration: none;
}
.navbar-link:hover { color: var(--text-1); background: var(--accent-dim); }
.navbar-link.active { color: var(--text-1); background: var(--accent-dim); }
.nav-dropdown { position: relative; }
.nav-dropdown-menu {
position: absolute;
top: 100%;
left: 0;
min-width: 160px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--card-shadow);
padding: var(--sp-1);
z-index: 60;
margin-top: var(--sp-1);
}
.nav-dropdown-item {
display: block;
width: 100%;
padding: var(--sp-2) var(--sp-3);
border-radius: var(--radius-sm);
font-size: var(--text-sm);
font-weight: 500;
color: var(--text-2);
background: none;
border: none;
text-align: left;
transition: all var(--transition);
cursor: pointer;
text-decoration: none;
}
.nav-dropdown-item:hover { background: var(--accent-dim); color: var(--text-1); }
.navbar-right {
display: flex;
align-items: center;
gap: var(--sp-1);
margin-left: auto;
}
.navbar-icon {
width: 36px;
height: 36px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
color: var(--text-3);
transition: all var(--transition);
}
.navbar-icon:hover { color: var(--text-1); background: var(--accent-dim); }
.search-trigger {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: 6px var(--sp-3);
border-radius: var(--radius-md);
background: var(--surface-secondary);
border: 1px solid var(--border);
color: var(--text-3);
font-size: var(--text-sm);
cursor: pointer;
transition: all var(--transition);
min-width: 200px;
}
.search-trigger:hover { border-color: var(--accent); color: var(--text-2); }
.search-trigger kbd {
margin-left: auto;
font-size: var(--text-xs);
font-family: var(--mono);
background: var(--canvas);
padding: 2px 6px;
border-radius: var(--radius-xs);
color: var(--text-4);
border: 1px solid var(--border);
}
@media (max-width: 768px) {
.navbar-links, .search-trigger { display: none !important; }
}
</style>

View File

@@ -0,0 +1,362 @@
<script lang="ts">
import { onMount } from 'svelte';
interface BookloreBook {
id: number; title: string; authors: string[]; libraryId: number; libraryName: string;
categories: string[]; pageCount: number | null; publisher: string | null; addedOn: string;
isbn13: string | null; isbn10: string | null; googleId: string | null; format: string | null;
}
interface Library { id: number; name: string; }
interface KindleTarget { id: string; label: string; email: string; }
let books = $state<BookloreBook[]>([]);
let libraries = $state<Library[]>([]);
let loading = $state(true);
let searchQuery = $state('');
let selectedLibrary = $state<number | null>(null);
// Detail / Kindle
let selectedBook = $state<BookloreBook | null>(null);
let kindleTargets = $state<KindleTarget[]>([]);
let kindleConfigured = $state(false);
let sendingKindle = $state(false);
let kindleResult = $state<string | null>(null);
const filtered = $derived(() => {
let list = books;
if (selectedLibrary !== null) list = list.filter(b => b.libraryId === selectedLibrary);
if (searchQuery) {
const q = searchQuery.toLowerCase();
list = list.filter(b => b.title.toLowerCase().includes(q) || b.authors.some(a => a.toLowerCase().includes(q)));
}
return list;
});
onMount(async () => {
try {
const [booksRes, libsRes] = await Promise.all([
fetch('/api/booklore/books', { credentials: 'include' }),
fetch('/api/booklore/libraries', { credentials: 'include' }),
]);
if (booksRes.ok) { const data = await booksRes.json(); books = data.books || []; }
if (libsRes.ok) { const data = await libsRes.json(); libraries = (data.libraries || []).map((l: any) => ({ id: l.id, name: l.name })); }
} catch { /* silent */ }
loading = false;
// Lazy-resolve covers for books without ISBNs
const needsCover = books.filter(b => !b.isbn13 && !b.isbn10 && !b.googleId);
if (needsCover.length > 0) resolveCoversLazy(needsCover);
// Load Kindle targets
try {
const kRes = await fetch('/api/kindle/targets', { credentials: 'include' });
if (kRes.ok) {
const kData = await kRes.json();
kindleTargets = kData.targets || [];
kindleConfigured = kData.configured || false;
}
} catch { /* silent */ }
});
function openBook(book: BookloreBook) {
selectedBook = book;
kindleResult = null;
}
function closeBook() {
selectedBook = null;
kindleResult = null;
}
let lastKindleSendTime = 0;
async function sendToKindle(targetId: string) {
const now = Date.now();
if (!selectedBook || sendingKindle || now - lastKindleSendTime < 5000) return;
lastKindleSendTime = now;
sendingKindle = true;
kindleResult = null;
try {
const res = await fetch(`/api/booklore/books/${selectedBook.id}/send-to-kindle`, {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target: targetId }),
});
const data = await res.json();
if (res.ok && data.success) {
kindleResult = `Sent "${data.title}" (${data.format}) to ${data.sentTo}`;
} else {
kindleResult = `Error: ${data.error || 'Failed to send'}`;
}
} catch {
kindleResult = 'Network error';
}
sendingKindle = false;
}
// Cover cache: bookId → cover URL, persisted in localStorage
const COVER_CACHE_KEY = 'booklore_covers';
const COVER_CACHE_MISS = 'booklore_covers_miss'; // IDs we already tried and found nothing
function loadCoverCache(): Record<string, string> {
try { return JSON.parse(localStorage.getItem(COVER_CACHE_KEY) || '{}'); } catch { return {}; }
}
function loadMissCache(): Record<string, boolean> {
try { return JSON.parse(localStorage.getItem(COVER_CACHE_MISS) || '{}'); } catch { return {}; }
}
function saveCoverCache(cache: Record<string, string>) {
try { localStorage.setItem(COVER_CACHE_KEY, JSON.stringify(cache)); } catch { /* silent */ }
}
function saveMissCache(cache: Record<string, boolean>) {
try { localStorage.setItem(COVER_CACHE_MISS, JSON.stringify(cache)); } catch { /* silent */ }
}
let resolvedCovers = $state<Record<string, string>>(loadCoverCache());
let missCache = loadMissCache();
function coverUrl(book: BookloreBook): string | null {
const isbn = book.isbn13 || book.isbn10;
if (isbn) return `https://covers.openlibrary.org/b/isbn/${isbn}-M.jpg`;
if (book.googleId) return `https://books.google.com/books/content?id=${book.googleId}&printsec=frontcover&img=1&zoom=1`;
if (resolvedCovers[book.id]) return resolvedCovers[book.id];
return null;
}
async function resolveCoversLazy(booksToResolve: BookloreBook[]) {
// Filter out books we already cached or tried
const todo = booksToResolve.filter(b => !resolvedCovers[b.id] && !missCache[b.id]);
if (todo.length === 0) return;
for (let i = 0; i < todo.length; i += 5) {
const batch = todo.slice(i, i + 5);
await Promise.all(batch.map(async (book) => {
try {
const q = encodeURIComponent(book.title);
const author = book.authors[0] ? `&author=${encodeURIComponent(book.authors[0])}` : '';
const res = await fetch(`https://openlibrary.org/search.json?title=${q}${author}&limit=1&fields=isbn,cover_edition_key`);
if (res.ok) {
const data = await res.json();
const doc = data.docs?.[0];
if (doc) {
const isbn = doc.isbn?.[0];
if (isbn) {
resolvedCovers = { ...resolvedCovers, [book.id]: `https://covers.openlibrary.org/b/isbn/${isbn}-M.jpg` };
saveCoverCache(resolvedCovers);
return;
}
const olid = doc.cover_edition_key;
if (olid) {
resolvedCovers = { ...resolvedCovers, [book.id]: `https://covers.openlibrary.org/b/olid/${olid}-M.jpg` };
saveCoverCache(resolvedCovers);
return;
}
}
}
// No cover found — mark as miss so we don't retry
missCache[book.id] = true;
saveMissCache(missCache);
} catch { /* silent */ }
}));
}
}
</script>
<div class="filter-row">
<div class="search-wrap">
<svg class="s-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
<input class="s-input" type="text" placeholder="Search your library..." bind:value={searchQuery} />
</div>
{#if libraries.length > 1}
<div class="lib-pills">
<button class="lib-pill" class:active={selectedLibrary === null} onclick={() => selectedLibrary = null}>All ({books.length})</button>
{#each libraries as lib}
<button class="lib-pill" class:active={selectedLibrary === lib.id} onclick={() => selectedLibrary = selectedLibrary === lib.id ? null : lib.id}>{lib.name}</button>
{/each}
</div>
{/if}
</div>
{#if loading}
<div class="book-grid">
{#each Array(8) as _}
<div class="book-card"><div class="book-cover skeleton"></div><div class="book-info"><div class="skeleton" style="width:80%;height:14px"></div><div class="skeleton" style="width:60%;height:12px;margin-top:6px"></div></div></div>
{/each}
</div>
{:else if filtered().length === 0}
<div class="empty">{searchQuery ? `No books found for "${searchQuery}"` : 'No books in library'}</div>
{:else}
<div class="book-grid">
{#each filtered() as book (book.id)}
{@const cover = coverUrl(book)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="book-card" onclick={() => openBook(book)}>
<div class="book-cover">
{#if cover}
<img src={cover} alt="" loading="lazy" onerror={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; (e.currentTarget as HTMLImageElement).nextElementSibling?.removeAttribute('style'); }} />
<span class="book-letter" style="display:none">{book.title.charAt(0)}</span>
{:else}
<span class="book-letter">{book.title.charAt(0)}</span>
{/if}
{#if book.format}
<span class="fmt-badge {book.format === 'EPUB' ? 'epub' : book.format === 'PDF' ? 'pdf' : 'other'}">{book.format}</span>
{/if}
</div>
<div class="book-info">
<div class="book-title">{book.title}</div>
<div class="book-author">{book.authors.join(', ') || 'Unknown'}</div>
<div class="book-meta-row">
<span class="book-lib">{book.libraryName}</span>
{#if book.pageCount}<span class="book-pages">{book.pageCount}p</span>{/if}
</div>
</div>
</div>
{/each}
</div>
{/if}
<!-- Book detail modal -->
{#if selectedBook}
{@const cover = coverUrl(selectedBook)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-overlay" onclick={closeBook}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-sheet" onclick={(e) => e.stopPropagation()}>
<div class="modal-header">
<div class="modal-title">Book Details</div>
<button class="modal-close" onclick={closeBook}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="modal-body">
<div class="detail-top">
{#if cover}
<img class="detail-cover" src={cover} alt="" />
{:else}
<div class="detail-cover-placeholder">{selectedBook.title.charAt(0)}</div>
{/if}
<div class="detail-info">
<div class="detail-title">{selectedBook.title}</div>
<div class="detail-author">{selectedBook.authors.join(', ') || 'Unknown'}</div>
<div class="detail-meta">
{#if selectedBook.publisher}<span>{selectedBook.publisher}</span>{/if}
{#if selectedBook.pageCount}<span>{selectedBook.pageCount} pages</span>{/if}
</div>
<div class="detail-badges">
{#if selectedBook.format}
<span class="fmt-badge-inline {selectedBook.format === 'EPUB' ? 'epub' : selectedBook.format === 'PDF' ? 'pdf' : 'other'}">{selectedBook.format}</span>
{/if}
<span class="detail-lib">{selectedBook.libraryName}</span>
{#if selectedBook.categories.length > 0}
{#each selectedBook.categories.slice(0, 2) as cat}
<span class="detail-cat">{cat}</span>
{/each}
{/if}
</div>
</div>
</div>
{#if kindleConfigured && kindleTargets.length > 0}
<div class="kindle-section">
<div class="kindle-label">Send to Kindle</div>
<div class="kindle-actions">
{#each kindleTargets as target}
<button class="kindle-btn" onclick={() => sendToKindle(target.id)} disabled={sendingKindle}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
{sendingKindle ? 'Sending...' : target.label}
</button>
{/each}
</div>
{#if kindleResult}
<div class="kindle-result" class:error={kindleResult.startsWith('Error')}>{kindleResult}</div>
{/if}
</div>
{/if}
</div>
</div>
</div>
{/if}
<style>
.filter-row { margin-bottom: var(--sp-5); }
.search-wrap { position: relative; margin-bottom: var(--sp-3); }
.s-icon { position: absolute; left: 14px; top: 50%; transform: translateY(-50%); width: 16px; height: 16px; color: var(--text-4); pointer-events: none; }
.s-input { width: 100%; padding: 10px 14px 10px 40px; border-radius: var(--radius); border: 1.5px solid var(--border); background: var(--card); color: var(--text-1); font-size: var(--text-md); font-family: var(--font); }
.s-input:focus { outline: none; border-color: var(--accent); }
.s-input::placeholder { color: var(--text-4); }
.lib-pills { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
.lib-pill { padding: var(--sp-2) 14px; border-radius: var(--radius-md); font-size: var(--text-base); font-weight: 500; color: var(--text-3); background: none; border: none; cursor: pointer; font-family: var(--font); transition: all var(--transition); }
.lib-pill:hover { color: var(--text-1); background: var(--card-hover); }
.lib-pill.active { color: var(--text-1); background: var(--card); box-shadow: var(--shadow-xs); }
.book-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: var(--module-gap); }
.book-card { background: var(--card); border-radius: var(--radius); border: 1px solid var(--border); box-shadow: var(--shadow-sm); overflow: hidden; transition: all var(--transition); }
.book-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-md); }
.book-cover { width: 100%; aspect-ratio: 2/3; background: var(--surface-secondary); display: flex; align-items: center; justify-content: center; overflow: hidden; }
.book-cover img { width: 100%; height: 100%; object-fit: cover; }
.book-letter { font-size: var(--text-3xl); font-weight: 300; color: var(--text-4); user-select: none; }
.book-info { padding: var(--sp-3) 14px 14px; }
.book-title { font-size: var(--text-base); font-weight: 600; color: var(--text-1); line-height: 1.3; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.book-author { font-size: var(--text-sm); color: var(--text-3); margin-top: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.book-meta-row { display: flex; align-items: center; gap: var(--sp-2); margin-top: var(--sp-2); }
.book-lib { font-size: var(--text-xs); font-weight: 500; padding: 2px var(--sp-2); border-radius: var(--radius-full); background: var(--accent-dim); color: var(--accent); }
.book-pages { font-size: var(--text-xs); color: var(--text-4); font-family: var(--mono); }
.empty { padding: var(--sp-12); text-align: center; color: var(--text-3); font-size: var(--text-base); }
.skeleton { background: linear-gradient(90deg, var(--card) 25%, var(--card-hover) 50%, var(--card) 75%); background-size: 200% 100%; animation: shimmer 1.5s infinite; border-radius: var(--radius-xs); }
@keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
.book-card { cursor: pointer; }
.book-cover { position: relative; }
.fmt-badge { position: absolute; top: var(--sp-2); right: var(--sp-2); font-size: var(--text-xs); font-weight: 700; padding: 2px var(--sp-2); border-radius: var(--radius-xs); text-transform: uppercase; letter-spacing: 0.03em; }
.fmt-badge.epub { background: var(--accent-dim); color: var(--accent); }
.fmt-badge.pdf { background: var(--error-dim); color: var(--error); }
.fmt-badge.other { background: var(--card-hover); color: var(--text-3); }
.fmt-badge-inline { font-size: var(--text-xs); font-weight: 700; padding: 2px var(--sp-2); border-radius: var(--radius-xs); text-transform: uppercase; }
.fmt-badge-inline.epub { background: var(--accent-dim); color: var(--accent); }
.fmt-badge-inline.pdf { background: var(--error-dim); color: var(--error); }
.fmt-badge-inline.other { background: var(--card-hover); color: var(--text-3); }
/* Modal */
.modal-overlay { position: fixed; inset: 0; background: var(--overlay); z-index: 70; display: flex; align-items: center; justify-content: center; animation: fadeIn 150ms ease; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
.modal-sheet { width: 480px; max-width: 92vw; background: var(--surface); border-radius: var(--radius); box-shadow: var(--shadow-xl); animation: slideUp 200ms ease; }
@keyframes slideUp { from { transform: translateY(12px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border); }
.modal-title { font-size: var(--text-md); font-weight: 600; color: var(--text-1); }
.modal-close { background: none; border: none; cursor: pointer; color: var(--text-3); padding: var(--sp-1); border-radius: var(--radius-sm); }
.modal-close:hover { color: var(--text-1); background: var(--card-hover); }
.modal-close svg { width: 18px; height: 18px; }
.modal-body { padding: var(--sp-5); }
.detail-top { display: flex; gap: var(--sp-4); }
.detail-cover { width: 100px; height: 150px; border-radius: var(--radius-md); object-fit: cover; flex-shrink: 0; }
.detail-cover-placeholder { width: 100px; height: 150px; border-radius: var(--radius-md); background: var(--surface-secondary); display: flex; align-items: center; justify-content: center; font-size: var(--text-3xl); font-weight: 300; color: var(--text-4); flex-shrink: 0; }
.detail-info { flex: 1; min-width: 0; }
.detail-title { font-size: var(--text-lg); font-weight: 600; color: var(--text-1); line-height: 1.3; }
.detail-author { font-size: var(--text-sm); color: var(--text-3); margin-top: var(--sp-1); }
.detail-meta { display: flex; gap: var(--sp-3); margin-top: var(--sp-2); font-size: var(--text-xs); color: var(--text-4); }
.detail-badges { display: flex; flex-wrap: wrap; gap: var(--sp-1); margin-top: var(--sp-3); }
.detail-lib { font-size: var(--text-xs); font-weight: 500; padding: 2px var(--sp-2); border-radius: var(--radius-full); background: var(--accent-dim); color: var(--accent); }
.detail-cat { font-size: var(--text-xs); padding: 2px var(--sp-2); border-radius: var(--radius-full); background: var(--card-hover); color: var(--text-3); }
.kindle-section { margin-top: var(--sp-5); padding-top: var(--sp-4); border-top: 1px solid var(--border); }
.kindle-label { font-size: var(--text-sm); font-weight: 600; color: var(--text-3); margin-bottom: var(--sp-3); text-transform: uppercase; letter-spacing: 0.04em; }
.kindle-actions { display: flex; gap: var(--sp-2); }
.kindle-btn { display: flex; align-items: center; gap: var(--sp-1.5); padding: var(--sp-2) var(--sp-4); border-radius: var(--radius-md); background: var(--accent); color: white; border: none; font-size: var(--text-sm); font-weight: 600; cursor: pointer; font-family: var(--font); transition: opacity var(--transition); }
.kindle-btn:disabled { opacity: 0.5; cursor: default; }
.kindle-btn:hover:not(:disabled) { opacity: 0.9; }
.kindle-btn svg { width: 14px; height: 14px; }
.kindle-result { font-size: var(--text-sm); margin-top: var(--sp-3); color: var(--success); font-weight: 500; }
.kindle-result.error { color: var(--error); }
@media (max-width: 1024px) { .book-grid { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 768px) {
.book-grid { grid-template-columns: repeat(2, 1fr); }
.detail-top { flex-direction: column; align-items: center; text-align: center; }
.detail-info { text-align: center; }
.detail-badges { justify-content: center; }
.kindle-actions { flex-direction: column; }
}
</style>

View File

@@ -0,0 +1,434 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
interface Release {
title: string; content_type: string; format: string; size: string;
source: string; source_id: string; info_url: string; language: string;
extra: { author?: string; preview?: string; publisher?: string; year?: string };
}
interface Download {
id: string; title: string; author: string; format: string; source: string;
status: string; status_message: string; progress: number; download_path: string;
content_type: string; added_time: string;
}
interface Library { id: number; name: string; paths: { id: number; path: string }[]; }
let query = $state('');
let results = $state<Release[]>([]);
let bookMeta = $state<{ title: string; authors: string[]; cover_url: string | null } | null>(null);
let searching = $state(false);
let searched = $state(false);
let downloads = $state<Record<string, Download>>({});
let libraries = $state<Library[]>([]);
let defaultLibraryId = $state<number | null>(null);
let downloadingIds = $state<Set<string>>(new Set());
let importingIds = $state<Set<string>>(new Set());
let importedIds = $state<Set<string>>(new Set());
let perBookLibrary = $state<Record<string, number>>({});
let prevCompleted = $state<Set<string>>(new Set());
let activeView = $state<'search' | 'downloads'>('search');
let poll: ReturnType<typeof setInterval> | null = null;
// Kindle auto-send
interface KindleTarget { id: string; label: string; email: string; }
let kindleTargets = $state<KindleTarget[]>([]);
let kindleConfigured = $state(false);
let autoKindleTarget = $state<string>('none');
let kindleSending = $state<Set<string>>(new Set());
let kindleSent = $state<Set<string>>(new Set());
const downloadCount = $derived(Object.keys(downloads).length);
const activeDownloads = $derived(
Object.values(downloads).filter(d => ['queued', 'downloading', 'locating', 'resolving'].includes(d.status))
);
async function search() {
if (!query.trim()) return;
searching = true; searched = true; results = []; bookMeta = null;
try {
const res = await fetch(`/api/books/releases?source=direct_download&query=${encodeURIComponent(query.trim())}&limit=40`, { credentials: 'include' });
if (res.ok) { const data = await res.json(); results = data.releases || []; bookMeta = data.book || null; }
} catch { /* silent */ }
searching = false;
}
async function download(release: Release) {
downloadingIds = new Set([...downloadingIds, release.source_id]);
try {
const res = await fetch('/api/books/releases/download', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source: release.source, source_id: release.source_id, title: release.title, format: release.format, size: release.size, extra: release.extra })
});
if (res.ok) fetchStatus();
} catch { /* silent */ }
}
async function cancelDownload(id: string) {
try { await fetch(`/api/books/download/${id}/cancel`, { method: 'DELETE', credentials: 'include' }); fetchStatus(); } catch { /* silent */ }
}
async function retryDownload(id: string) {
try { await fetch(`/api/books/download/${id}/retry`, { method: 'POST', credentials: 'include' }); fetchStatus(); } catch { /* silent */ }
}
function getLibraryId(key: string): number | null {
return perBookLibrary[key] ?? defaultLibraryId;
}
async function importToBooklore(dl: Download, libraryId?: number | null) {
const libId = libraryId ?? defaultLibraryId;
if (!libId || importingIds.has(dl.id) || importedIds.has(dl.id)) return;
const lib = libraries.find(l => l.id === libId);
if (!lib || lib.paths.length === 0) return;
importingIds = new Set([...importingIds, dl.id]);
const fileName = dl.download_path?.split('/').pop() || dl.title;
try {
const res = await fetch('/api/booklore/import', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileName, libraryId: lib.id, pathId: lib.paths[0].id })
});
if (res.ok) {
importedIds = new Set([...importedIds, dl.id]);
// Auto-send to Kindle if configured
if (autoKindleTarget !== 'none' && fileName && !kindleSent.has(dl.id)) {
sendFileToKindle(dl.id, fileName, dl.title || fileName, autoKindleTarget);
}
}
} catch { /* silent */ }
const next = new Set(importingIds); next.delete(dl.id); importingIds = next;
}
async function sendFileToKindle(dlId: string, filename: string, title: string, target: string) {
if (kindleSending.has(dlId) || kindleSent.has(dlId)) return;
kindleSending = new Set([...kindleSending, dlId]);
try {
const res = await fetch('/api/kindle/send-file', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename, title, target }),
});
if (res.ok) { kindleSent = new Set([...kindleSent, dlId]); }
} catch { /* silent */ }
const next = new Set(kindleSending); next.delete(dlId); kindleSending = next;
}
async function fetchStatus() {
try {
const res = await fetch('/api/books/status', { credentials: 'include' });
if (res.ok) {
const data = await res.json();
const all: Record<string, Download> = {};
for (const [groupName, group] of Object.entries(data) as [string, Record<string, Download>][]) {
if (group && typeof group === 'object') {
for (const [id, dl] of Object.entries(group)) {
if (['complete', 'error', 'cancelled'].includes(groupName)) dl.status = groupName;
else if (groupName === 'active') dl.status = dl.status_message?.toLowerCase().includes('download') ? 'downloading' : (dl.status || 'downloading');
all[id] = dl;
}
}
}
if (defaultLibraryId) {
for (const [id, dl] of Object.entries(all)) {
if (dl.status === 'complete' && !prevCompleted.has(id) && !importingIds.has(id) && !importedIds.has(id)) {
importToBooklore(dl, getLibraryId(id));
}
}
}
prevCompleted = new Set(Object.entries(all).filter(([_, d]) => d.status === 'complete').map(([id]) => id));
downloads = all;
}
} catch { /* silent */ }
}
async function fetchLibraries() {
try {
const res = await fetch('/api/booklore/libraries', { credentials: 'include' });
if (res.ok) {
const data = await res.json();
libraries = data.libraries || [];
if (libraries.length > 0 && defaultLibraryId === null) defaultLibraryId = libraries[0].id;
}
} catch { /* silent */ }
}
function coverUrl(release: Release): string | null {
const preview = release.extra?.preview;
if (!preview) return null;
if (preview.startsWith('/api/')) {
return `/api/books${preview.slice(4)}`;
}
return preview;
}
function fmtBadge(fmt: string): string {
const f = fmt?.toLowerCase();
if (f === 'epub') return 'accent'; if (f === 'pdf') return 'error'; if (f === 'mobi' || f === 'azw3') return 'warning'; return 'muted';
}
function statusClass(s: string): string {
if (s === 'complete') return 'success'; if (s === 'downloading' || s === 'locating' || s === 'resolving' || s === 'queued') return 'accent'; if (s === 'error') return 'error'; return 'muted';
}
onMount(async () => {
await fetchLibraries();
// Load Kindle targets
try {
const kRes = await fetch('/api/kindle/targets', { credentials: 'include' });
if (kRes.ok) { const kData = await kRes.json(); kindleTargets = kData.targets || []; kindleConfigured = kData.configured || false; }
} catch { /* silent */ }
const res = await fetch('/api/books/status', { credentials: 'include' }).catch(() => null);
if (res?.ok) {
const data = await res.json();
const ids: string[] = [];
for (const group of Object.values(data) as Record<string, Download>[]) {
if (group && typeof group === 'object') { for (const [id, dl] of Object.entries(group)) { if (dl.status === 'complete') ids.push(id); } }
}
prevCompleted = new Set(ids);
}
fetchStatus();
poll = setInterval(fetchStatus, 3000);
});
onDestroy(() => { if (poll) clearInterval(poll); });
</script>
<!-- Sub-tabs -->
<div class="sub-tabs">
<button class="sub-tab" class:active={activeView === 'search'} onclick={() => activeView = 'search'}>Search</button>
<button class="sub-tab" class:active={activeView === 'downloads'} onclick={() => activeView = 'downloads'}>
Downloads
{#if activeDownloads.length > 0}<span class="sub-badge">{activeDownloads.length}</span>{/if}
</button>
</div>
{#if kindleConfigured && kindleTargets.length > 0}
<div class="kindle-auto-row">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
<span class="kindle-auto-label">After download, also send to</span>
<select class="kindle-auto-select" bind:value={autoKindleTarget}>
<option value="none">No one (just download)</option>
{#each kindleTargets as t}
<option value={t.id}>{t.label}</option>
{/each}
</select>
</div>
{/if}
{#if activeView === 'search'}
<!-- Search -->
<div class="search-bar">
<div class="search-wrap">
<svg class="s-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
<input class="s-input" type="text" placeholder="Search books, authors, ISBNs..." bind:value={query} onkeydown={(e) => { if (e.key === 'Enter') search(); }} />
{#if query}
<button class="s-clear" onclick={() => { query = ''; results = []; searched = false; bookMeta = null; }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
{/if}
</div>
<button class="s-btn" onclick={search} disabled={searching || !query.trim()}>
{#if searching}<span class="spinner"></span>{:else}Search{/if}
</button>
</div>
{#if searching}
<div class="empty">Searching Anna's Archive, Libgen, Z-Library...</div>
{:else if searched && results.length === 0}
<div class="empty">No results found for "{query}"</div>
{:else if results.length > 0}
{#if bookMeta}<div class="result-count">{results.length} releases found</div>{/if}
<div class="results-grid">
{#each results as release (release.source_id)}
{@const cover = coverUrl(release)}
{@const isDl = downloadingIds.has(release.source_id) || !!downloads[release.source_id]}
<div class="r-card">
<div class="r-cover">
{#if cover}
<img src={cover} alt="" loading="lazy" onerror={(e) => (e.currentTarget as HTMLImageElement).style.display='none'} />
{/if}
<span class="fmt-badge {fmtBadge(release.format)}">{release.format.toUpperCase()}</span>
</div>
<div class="r-info">
<div class="r-title">{release.title}</div>
{#if release.extra?.author}<div class="r-author">{release.extra.author}</div>{/if}
<div class="r-meta">
{#if release.extra?.year}<span>{release.extra.year}</span>{/if}
{#if release.size}<span>{release.size}</span>{/if}
{#if release.language}<span>{release.language.toUpperCase()}</span>{/if}
</div>
</div>
<div class="r-actions">
{#if libraries.length > 0}
<select class="lib-select" value={perBookLibrary[release.source_id] ?? defaultLibraryId} onchange={(e) => { perBookLibrary[release.source_id] = Number((e.currentTarget as HTMLSelectElement).value); perBookLibrary = {...perBookLibrary}; }}>
{#each libraries as lib}<option value={lib.id}>{lib.name}</option>{/each}
</select>
{/if}
{#if isDl}
{@const dl = downloads[release.source_id]}
{#if dl}
<span class="dl-badge {statusClass(dl.status)}">{dl.status}</span>
{#if dl.progress > 0 && dl.progress < 100}
<div class="dl-bar"><div class="dl-fill" style="width:{dl.progress}%"></div></div>
{/if}
{:else}
<span class="dl-badge muted">Queued...</span>
{/if}
{:else}
<button class="dl-btn" onclick={() => download(release)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Download
</button>
{/if}
</div>
</div>
{/each}
</div>
{:else}
<div class="empty">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" style="width:64px;height:64px;opacity:0.2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
<div>Search for books</div>
<div class="empty-sub">Anna's Archive, Libgen, Z-Library</div>
</div>
{/if}
{:else}
<!-- Downloads -->
{#if downloadCount === 0}
<div class="empty">No downloads yet</div>
{:else}
<div class="dl-list">
{#each Object.entries(downloads) as [id, dl] (id)}
<div class="dl-row">
<div class="dl-info">
<div class="dl-title">{dl.title || id}</div>
{#if dl.author}<div class="dl-author">{dl.author}</div>{/if}
<div class="dl-meta-row">
{#if dl.format}<span class="fmt-badge sm {fmtBadge(dl.format)}">{dl.format.toUpperCase()}</span>{/if}
<span class="dl-badge {statusClass(dl.status)}">{dl.status}</span>
{#if dl.status_message}<span class="dl-msg">{dl.status_message}</span>{/if}
</div>
{#if dl.status === 'downloading' && dl.progress > 0}
<div class="dl-bar full"><div class="dl-fill" style="width:{dl.progress}%"></div></div>
{/if}
</div>
<div class="dl-actions">
{#if dl.status === 'complete'}
{#if kindleSent.has(id)}
<span class="dl-badge success">Sent to Kindle ✓</span>
{:else if kindleSending.has(id)}
<span class="dl-badge accent">Sending to Kindle...</span>
{/if}
{#if importedIds.has(id)}
<span class="dl-badge success">Imported ✓</span>
{:else if importingIds.has(id)}
<span class="dl-badge accent">Importing...</span>
{:else}
{#if libraries.length > 0}
<select class="lib-select" value={perBookLibrary[id] ?? defaultLibraryId} onchange={(e) => { perBookLibrary[id] = Number((e.currentTarget as HTMLSelectElement).value); perBookLibrary = {...perBookLibrary}; }}>
{#each libraries as lib}<option value={lib.id}>{lib.name}</option>{/each}
</select>
{/if}
<button class="action-btn" onclick={() => importToBooklore(dl, getLibraryId(id))}>Import</button>
{/if}
{:else if dl.status === 'error'}
<button class="action-btn" onclick={() => retryDownload(id)}>Retry</button>
<button class="action-btn danger" onclick={() => cancelDownload(id)}>Remove</button>
{:else if ['queued', 'downloading', 'locating', 'resolving'].includes(dl.status)}
<button class="action-btn danger" onclick={() => cancelDownload(id)}>Cancel</button>
{:else}
<button class="action-btn danger" onclick={() => cancelDownload(id)}>Remove</button>
{/if}
</div>
</div>
{/each}
</div>
{/if}
{/if}
<style>
.sub-tabs { display: flex; gap: var(--sp-1); margin-bottom: var(--sp-4); }
.sub-tab { padding: var(--sp-2) 14px; border-radius: var(--radius-md); font-size: var(--text-base); font-weight: 500; color: var(--text-3); background: none; border: none; cursor: pointer; font-family: var(--font); transition: all var(--transition); display: flex; align-items: center; gap: var(--sp-1.5); }
.sub-tab:hover { color: var(--text-1); background: var(--card-hover); }
.sub-tab.active { color: var(--text-1); background: var(--card); box-shadow: var(--shadow-xs); }
.sub-badge { font-size: var(--text-xs); font-family: var(--mono); background: var(--accent); color: white; padding: 1px 6px; border-radius: var(--radius-xs); }
.search-bar { display: flex; gap: var(--sp-2); margin-bottom: var(--sp-5); align-items: stretch; }
.search-wrap { flex: 1; position: relative; }
.s-icon { position: absolute; left: 14px; top: 50%; transform: translateY(-50%); width: 16px; height: 16px; color: var(--text-4); pointer-events: none; }
.s-input { width: 100%; height: 42px; padding: 0 36px 0 40px; border-radius: var(--radius-md); border: 1px solid var(--border); background: var(--card); color: var(--text-1); font-size: var(--text-base); font-family: var(--font); transition: all var(--transition); box-sizing: border-box; }
.s-input:focus { outline: none; border-color: var(--accent); }
.s-input::placeholder { color: var(--text-4); }
.s-clear { position: absolute; right: 10px; top: 50%; transform: translateY(-50%); background: none; border: none; cursor: pointer; color: var(--text-4); padding: var(--sp-1); }
.s-clear svg { width: 14px; height: 14px; }
.s-btn { height: 42px; padding: 0 var(--sp-5); border-radius: var(--radius-md); background: var(--accent); color: white; border: none; font-size: var(--text-sm); font-weight: 600; cursor: pointer; font-family: var(--font); transition: opacity var(--transition); white-space: nowrap; }
.s-btn:disabled { opacity: 0.4; cursor: default; }
.s-btn:hover:not(:disabled) { opacity: 0.9; }
.spinner { width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.3); border-top-color: white; border-radius: 50%; animation: spin 0.6s linear infinite; display: inline-block; }
@keyframes spin { to { transform: rotate(360deg); } }
.empty { padding: var(--sp-12); text-align: center; color: var(--text-3); font-size: var(--text-base); display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); }
.empty-sub { font-size: var(--text-sm); color: var(--text-4); }
.result-count { font-size: var(--text-sm); color: var(--text-3); margin-bottom: var(--sp-3); }
.results-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: var(--sp-4); }
.r-card { background: var(--card); border-radius: var(--radius); border: 1px solid var(--border); box-shadow: var(--shadow-sm); overflow: hidden; display: flex; flex-direction: column; }
.r-cover { position: relative; width: 100%; aspect-ratio: 3/2; background: var(--surface-secondary); display: flex; align-items: center; justify-content: center; overflow: hidden; }
.r-cover img { width: 100%; height: 100%; object-fit: cover; }
.fmt-badge { position: absolute; top: var(--sp-2); right: var(--sp-2); font-size: var(--text-xs); font-weight: 700; padding: 2px var(--sp-2); border-radius: var(--radius-xs); text-transform: uppercase; letter-spacing: 0.03em; }
.fmt-badge.accent { background: var(--accent-dim); color: var(--accent); }
.fmt-badge.error { background: var(--error-dim); color: var(--error); }
.fmt-badge.warning { background: var(--warning-bg); color: var(--warning); }
.fmt-badge.muted { background: var(--card-hover); color: var(--text-4); }
.fmt-badge.sm { position: static; }
.r-info { padding: var(--sp-3) var(--sp-4); flex: 1; }
.r-title { font-size: var(--text-base); font-weight: 600; color: var(--text-1); line-height: 1.3; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.r-author { font-size: var(--text-sm); color: var(--text-3); margin-top: 2px; }
.r-meta { display: flex; flex-wrap: wrap; gap: var(--sp-2); margin-top: var(--sp-1); font-size: var(--text-xs); color: var(--text-4); }
.r-actions { padding: var(--sp-2) var(--sp-4) var(--sp-3); display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
.lib-select { padding: var(--sp-1) var(--sp-2); border-radius: var(--radius-sm); border: 1px solid var(--border); background: var(--card); color: var(--text-2); font-size: var(--text-xs); font-family: var(--font); cursor: pointer; max-width: 120px; }
.dl-btn { display: flex; align-items: center; gap: var(--sp-1); padding: var(--sp-1.5) var(--sp-3); border-radius: var(--radius-md); background: var(--accent); color: white; border: none; font-size: var(--text-sm); font-weight: 600; cursor: pointer; font-family: var(--font); transition: opacity var(--transition); }
.dl-btn:hover { opacity: 0.9; }
.dl-btn svg { width: 14px; height: 14px; }
.dl-badge { font-size: var(--text-xs); font-weight: 600; padding: 2px var(--sp-2); border-radius: var(--radius-xs); text-transform: uppercase; }
.dl-badge.success { background: var(--success-dim); color: var(--success); }
.dl-badge.accent { background: var(--accent-dim); color: var(--accent); }
.dl-badge.error { background: var(--error-dim); color: var(--error); }
.dl-badge.muted { background: var(--card-hover); color: var(--text-4); }
.dl-bar { width: 80px; height: 4px; background: var(--border); border-radius: 2px; overflow: hidden; }
.dl-bar.full { width: 100%; margin-top: var(--sp-2); }
.dl-fill { height: 100%; background: var(--accent); border-radius: 2px; transition: width 0.3s ease; }
.dl-list { background: var(--card); border-radius: var(--radius); border: 1px solid var(--border); box-shadow: var(--shadow-sm); overflow: hidden; }
.dl-row { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-4); padding: var(--sp-4); border-bottom: 1px solid var(--border); }
.dl-row:last-child { border-bottom: none; }
.dl-info { flex: 1; min-width: 0; }
.dl-title { font-size: var(--text-base); font-weight: 500; color: var(--text-1); }
.dl-author { font-size: var(--text-sm); color: var(--text-3); margin-top: 2px; }
.dl-meta-row { display: flex; align-items: center; gap: var(--sp-2); margin-top: var(--sp-2); flex-wrap: wrap; }
.dl-msg { font-size: var(--text-xs); color: var(--text-4); }
.dl-actions { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; flex-wrap: wrap; }
.action-btn { padding: var(--sp-1.5) var(--sp-3); border-radius: var(--radius-md); background: var(--accent); color: white; border: none; font-size: var(--text-sm); font-weight: 500; cursor: pointer; font-family: var(--font); }
.action-btn.danger { background: none; border: 1px solid var(--error); color: var(--error); }
.action-btn.danger:hover { background: var(--error-dim); }
.kindle-auto-row {
display: flex; align-items: center; gap: var(--sp-2); margin-bottom: var(--sp-4);
padding: var(--sp-2) var(--sp-3); background: var(--surface-secondary); border-radius: var(--radius-md); border: 1px solid var(--border);
}
.kindle-auto-row svg { width: 14px; height: 14px; color: var(--text-3); flex-shrink: 0; }
.kindle-auto-label { font-size: var(--text-sm); color: var(--text-2); white-space: nowrap; }
.kindle-auto-select {
height: 32px; padding: 0 var(--sp-3); border-radius: var(--radius-md); border: 1px solid var(--border);
background: var(--card); color: var(--text-1); font-size: var(--text-sm); font-weight: 500; font-family: var(--font);
cursor: pointer; -webkit-appearance: none; appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236b6b76' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 8px center; padding-right: 24px;
}
@media (max-width: 768px) {
.results-grid { grid-template-columns: 1fr; }
.dl-row { flex-direction: column; }
.dl-actions { width: 100%; }
}
</style>

View File

@@ -0,0 +1,276 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
interface MusicTrack {
id: string; name: string; artists: { name: string }[];
album: { name: string; images: { url: string }[] };
duration_ms: number; type: string;
images?: { url: string }[]; owner?: { display_name: string };
tracks?: { total: number }; total_tracks?: number; release_date?: string; genres?: string[];
}
interface MusicTask {
task_id: string; status: string; download_type: string;
name?: string; artist?: string; progress?: number;
total_items?: number; completed_items?: number; speed?: string; eta?: string; error_message?: string;
}
let query = $state('');
let searchType = $state<'track' | 'album' | 'artist' | 'playlist'>('track');
let results = $state<MusicTrack[]>([]);
let tasks = $state<MusicTask[]>([]);
let searching = $state(false);
let searched = $state(false);
let downloading = $state<Set<string>>(new Set());
let activeView = $state<'search' | 'downloads'>('search');
let playingId = $state<string | null>(null);
let playingEmbed = $state<string | null>(null);
let poll: ReturnType<typeof setInterval> | null = null;
const activeTasks = $derived(tasks.filter(t => ['downloading', 'queued'].includes(t.status)));
function artistNames(t: MusicTrack): string { return t.artists?.map(a => a.name).join(', ') || ''; }
function albumArt(t: MusicTrack): string | null { return t.album?.images?.[0]?.url || t.images?.[0]?.url || null; }
function fmtDuration(ms: number): string { const m = Math.floor(ms / 60000); const s = Math.floor((ms % 60000) / 1000); return `${m}:${s.toString().padStart(2, '0')}`; }
async function search() {
if (!query.trim()) return;
searching = true; searched = true; results = [];
try {
const res = await fetch(`/api/music/api/search?q=${encodeURIComponent(query.trim())}&search_type=${searchType}&limit=30`, { credentials: 'include' });
if (res.ok) { const data = await res.json(); results = data.items || []; }
} catch { /* silent */ }
searching = false;
}
async function downloadItem(id: string, type: string) {
downloading = new Set([...downloading, id]);
try {
await fetch(`/api/music/api/${type}/download/${id}`, { credentials: 'include' });
fetchTasks();
} catch { /* silent */ }
}
async function fetchTasks() {
try {
const res = await fetch('/api/music/api/prgs/list', { credentials: 'include' });
if (res.ok) { const data = await res.json(); tasks = Array.isArray(data) ? data : (data.tasks || []); }
} catch { /* silent */ }
}
async function cancelTask(taskId: string) {
try { await fetch(`/api/music/api/prgs/cancel/${taskId}`, { method: 'POST', credentials: 'include' }); fetchTasks(); } catch { /* silent */ }
}
function togglePlay(id: string) {
if (playingId === id) { playingId = null; playingEmbed = null; }
else { playingId = id; playingEmbed = `https://open.spotify.com/embed/track/${id}?utm_source=generator&theme=0`; }
}
onMount(() => { fetchTasks(); poll = setInterval(fetchTasks, 3000); });
onDestroy(() => { if (poll) clearInterval(poll); });
</script>
<!-- Sub-tabs -->
<div class="sub-tabs">
<button class="sub-tab" class:active={activeView === 'search'} onclick={() => activeView = 'search'}>Search</button>
<button class="sub-tab" class:active={activeView === 'downloads'} onclick={() => activeView = 'downloads'}>
Downloads
{#if activeTasks.length > 0}<span class="sub-badge">{activeTasks.length}</span>{/if}
</button>
</div>
{#if activeView === 'search'}
<div class="search-bar">
<select class="type-select" bind:value={searchType}>
<option value="track">Tracks</option>
<option value="album">Albums</option>
<option value="artist">Artists</option>
<option value="playlist">Playlists</option>
</select>
<div class="search-wrap">
<svg class="s-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
<input class="s-input" type="text" placeholder="Search songs, albums, artists..." bind:value={query} onkeydown={(e) => { if (e.key === 'Enter') search(); }} />
</div>
<button class="s-btn" onclick={search} disabled={searching || !query.trim()}>
{#if searching}<span class="spinner"></span>{:else}Search{/if}
</button>
</div>
{#if searching}
<div class="empty">Searching Spotify...</div>
{:else if searched && results.length === 0}
<div class="empty">No results for "{query}"</div>
{:else if results.length > 0}
{#if searchType === 'track'}
{#if playingEmbed}
<div class="player-wrap">
<iframe src={playingEmbed} width="100%" height="80" frameborder="0" allow="autoplay; clipboard-write; encrypted-media" title="Spotify player"></iframe>
</div>
{/if}
<div class="track-list">
{#each results as track (track.id)}
{@const art = albumArt(track)}
<div class="track-row">
<button class="play-btn" onclick={() => togglePlay(track.id)}>
{#if playingId === track.id}
<svg viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/></svg>
{:else}
<svg viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>
{/if}
</button>
{#if art}<img class="track-art" src={art} alt="" loading="lazy" />{/if}
<div class="track-info">
<div class="track-name">{track.name}</div>
<div class="track-artist">{artistNames(track)}{track.album?.name ? ` · ${track.album.name}` : ''}</div>
</div>
<span class="track-dur">{track.duration_ms ? fmtDuration(track.duration_ms) : ''}</span>
{#if downloading.has(track.id)}
<span class="dl-badge accent">Queued</span>
{:else}
<button class="dl-sm-btn" onclick={() => downloadItem(track.id, 'track')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</button>
{/if}
</div>
{/each}
</div>
{:else}
<div class="card-grid">
{#each results as item (item.id)}
{@const art = albumArt(item)}
<div class="m-card">
<div class="m-card-img" class:round={searchType === 'artist'}>
{#if art}<img src={art} alt="" loading="lazy" />{:else}<div class="m-card-placeholder">♪</div>{/if}
</div>
<div class="m-card-name">{item.name}</div>
<div class="m-card-sub">
{#if searchType === 'playlist'}{item.owner?.display_name || ''}{item.tracks?.total ? ` · ${item.tracks.total} tracks` : ''}
{:else if searchType === 'album'}{artistNames(item)}{item.release_date ? ` · ${item.release_date.slice(0, 4)}` : ''}
{:else}{item.genres?.slice(0, 2).join(', ') || 'Artist'}{/if}
</div>
<button class="dl-card-btn" onclick={() => downloadItem(item.id, searchType)} disabled={downloading.has(item.id)}>
{downloading.has(item.id) ? 'Queued' : 'Download'}
</button>
</div>
{/each}
</div>
{/if}
{:else}
<div class="empty">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" style="width:64px;height:64px;opacity:0.2"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
<div>Search for music</div>
<div class="empty-sub">Spotify tracks, albums, playlists</div>
</div>
{/if}
{:else}
<!-- Download Tasks -->
{#if tasks.length === 0}
<div class="empty">No music downloads</div>
{:else}
<div class="task-list">
{#each tasks as task (task.task_id)}
<div class="task-row">
<div class="task-info">
<div class="task-name">{task.name || task.task_id}</div>
{#if task.artist}<div class="task-artist">{task.artist}</div>{/if}
<div class="task-meta">
<span class="dl-badge {task.status === 'downloading' ? 'accent' : task.status === 'completed' ? 'success' : task.status === 'error' ? 'error' : 'muted'}">{task.status}</span>
{#if task.completed_items != null && task.total_items}<span class="task-progress-text">{task.completed_items}/{task.total_items} tracks</span>{/if}
{#if task.speed}<span class="task-speed">{task.speed}</span>{/if}
{#if task.eta}<span class="task-eta">ETA {task.eta}</span>{/if}
{#if task.error_message}<span class="task-error">{task.error_message}</span>{/if}
</div>
{#if task.progress != null && task.progress > 0}
<div class="dl-bar full"><div class="dl-fill" style="width:{task.progress}%"></div></div>
{/if}
</div>
{#if ['downloading', 'queued'].includes(task.status)}
<button class="action-btn danger" onclick={() => cancelTask(task.task_id)}>Cancel</button>
{/if}
</div>
{/each}
</div>
{/if}
{/if}
<style>
.sub-tabs { display: flex; gap: var(--sp-1); margin-bottom: var(--sp-4); }
.sub-tab { padding: var(--sp-2) 14px; border-radius: var(--radius-md); font-size: var(--text-base); font-weight: 500; color: var(--text-3); background: none; border: none; cursor: pointer; font-family: var(--font); transition: all var(--transition); display: flex; align-items: center; gap: var(--sp-1.5); }
.sub-tab:hover { color: var(--text-1); background: var(--card-hover); }
.sub-tab.active { color: var(--text-1); background: var(--card); box-shadow: var(--shadow-xs); }
.sub-badge { font-size: var(--text-xs); font-family: var(--mono); background: var(--accent); color: white; padding: 1px 6px; border-radius: var(--radius-xs); }
.search-bar { display: flex; gap: var(--sp-2); margin-bottom: var(--sp-5); align-items: stretch; }
.type-select { height: 42px; padding: 0 var(--sp-3); border-radius: var(--radius-md); border: 1px solid var(--border); background: var(--card); color: var(--text-1); font-size: var(--text-sm); font-weight: 500; font-family: var(--font); cursor: pointer; -webkit-appearance: none; appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236b6b76' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 10px center; padding-right: 30px; }
.type-select:focus { outline: none; border-color: var(--accent); }
.search-wrap { flex: 1; position: relative; }
.s-icon { position: absolute; left: 14px; top: 50%; transform: translateY(-50%); width: 16px; height: 16px; color: var(--text-4); pointer-events: none; }
.s-input { width: 100%; height: 42px; padding: 0 14px 0 40px; border-radius: var(--radius-md); border: 1px solid var(--border); background: var(--card); color: var(--text-1); font-size: var(--text-base); font-family: var(--font); box-sizing: border-box; }
.s-input:focus { outline: none; border-color: var(--accent); }
.s-input::placeholder { color: var(--text-4); }
.s-btn { height: 42px; padding: 0 var(--sp-5); border-radius: var(--radius-md); background: var(--accent); color: white; border: none; font-size: var(--text-sm); font-weight: 600; cursor: pointer; font-family: var(--font); white-space: nowrap; }
.s-btn:disabled { opacity: 0.4; cursor: default; }
.spinner { width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.3); border-top-color: white; border-radius: 50%; animation: spin 0.6s linear infinite; display: inline-block; }
@keyframes spin { to { transform: rotate(360deg); } }
.empty { padding: var(--sp-12); text-align: center; color: var(--text-3); font-size: var(--text-base); display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); }
.empty-sub { font-size: var(--text-sm); color: var(--text-4); }
.player-wrap { margin-bottom: var(--sp-3); border-radius: var(--radius); overflow: hidden; }
.track-list { background: var(--card); border-radius: var(--radius); border: 1px solid var(--border); box-shadow: var(--shadow-sm); overflow: hidden; }
.track-row { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border); transition: background var(--transition); }
.track-row:last-child { border-bottom: none; }
.track-row:hover { background: var(--card-hover); }
.play-btn { width: 32px; height: 32px; border-radius: var(--radius-full); background: none; border: none; cursor: pointer; color: var(--text-3); display: flex; align-items: center; justify-content: center; flex-shrink: 0; transition: color var(--transition); }
.play-btn:hover { color: var(--accent); }
.play-btn svg { width: 16px; height: 16px; }
.track-art { width: 40px; height: 40px; border-radius: var(--radius-xs); object-fit: cover; flex-shrink: 0; }
.track-info { flex: 1; min-width: 0; }
.track-name { font-size: var(--text-base); font-weight: 500; color: var(--text-1); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.track-artist { font-size: var(--text-sm); color: var(--text-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.track-dur { font-size: var(--text-sm); font-family: var(--mono); color: var(--text-4); flex-shrink: 0; }
.dl-sm-btn { width: 32px; height: 32px; border-radius: var(--radius-md); background: none; border: 1px solid var(--border); cursor: pointer; color: var(--text-3); display: flex; align-items: center; justify-content: center; flex-shrink: 0; transition: all var(--transition); }
.dl-sm-btn:hover { color: var(--accent); border-color: var(--accent); }
.dl-sm-btn svg { width: 14px; height: 14px; }
.card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: var(--sp-4); }
.m-card { text-align: center; }
.m-card-img { width: 100%; aspect-ratio: 1; border-radius: var(--radius); overflow: hidden; background: var(--surface-secondary); margin-bottom: var(--sp-2); }
.m-card-img.round { border-radius: var(--radius-full); }
.m-card-img img { width: 100%; height: 100%; object-fit: cover; }
.m-card-placeholder { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; font-size: var(--text-3xl); color: var(--text-4); }
.m-card-name { font-size: var(--text-sm); font-weight: 600; color: var(--text-1); line-height: 1.3; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.m-card-sub { font-size: var(--text-xs); color: var(--text-3); margin-top: 2px; }
.dl-card-btn { margin-top: var(--sp-2); padding: var(--sp-1) var(--sp-3); border-radius: var(--radius-md); background: var(--accent); color: white; border: none; font-size: var(--text-xs); font-weight: 600; cursor: pointer; font-family: var(--font); }
.dl-card-btn:disabled { opacity: 0.4; }
.dl-badge { font-size: var(--text-xs); font-weight: 600; padding: 2px var(--sp-2); border-radius: var(--radius-xs); text-transform: uppercase; }
.dl-badge.success { background: var(--success-dim); color: var(--success); }
.dl-badge.accent { background: var(--accent-dim); color: var(--accent); }
.dl-badge.error { background: var(--error-dim); color: var(--error); }
.dl-badge.muted { background: var(--card-hover); color: var(--text-4); }
.task-list { background: var(--card); border-radius: var(--radius); border: 1px solid var(--border); box-shadow: var(--shadow-sm); overflow: hidden; }
.task-row { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-4); padding: var(--sp-4); border-bottom: 1px solid var(--border); }
.task-row:last-child { border-bottom: none; }
.task-info { flex: 1; min-width: 0; }
.task-name { font-size: var(--text-base); font-weight: 500; color: var(--text-1); }
.task-artist { font-size: var(--text-sm); color: var(--text-3); margin-top: 2px; }
.task-meta { display: flex; align-items: center; gap: var(--sp-2); margin-top: var(--sp-2); flex-wrap: wrap; }
.task-progress-text { font-size: var(--text-xs); font-family: var(--mono); color: var(--text-3); }
.task-speed { font-size: var(--text-xs); color: var(--text-4); }
.task-eta { font-size: var(--text-xs); color: var(--text-4); }
.task-error { font-size: var(--text-xs); color: var(--error); }
.dl-bar { width: 100%; height: 4px; background: var(--border); border-radius: 2px; overflow: hidden; margin-top: var(--sp-2); }
.dl-bar.full { width: 100%; }
.dl-fill { height: 100%; background: var(--accent); border-radius: 2px; transition: width 0.3s ease; }
.action-btn { padding: var(--sp-1.5) var(--sp-3); border-radius: var(--radius-md); font-size: var(--text-sm); font-weight: 500; cursor: pointer; font-family: var(--font); flex-shrink: 0; }
.action-btn.danger { background: none; border: 1px solid var(--error); color: var(--error); }
@media (max-width: 768px) {
.search-bar { flex-wrap: wrap; }
.type-select { width: 100%; }
.card-grid { grid-template-columns: repeat(2, 1fr); }
}
</style>

View File

@@ -0,0 +1,161 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { cn } from '$lib/utils';
type Variant = 'budget' | 'inventory' | 'fitness';
type Size = 'primary' | 'secondary';
let {
title,
description = '',
action,
variant,
size = 'primary' as Size,
icon,
onclick
}: {
title: string;
description?: string;
action: string;
variant: Variant;
size?: Size;
icon?: Snippet;
onclick?: () => void;
} = $props();
const variantClasses: Record<Variant, string> = {
budget: 'action-card-icon--budget',
inventory: 'action-card-icon--inventory',
fitness: 'action-card-icon--fitness'
};
</script>
<button
class={cn('action-card', size === 'secondary' && 'action-card--secondary')}
onclick={onclick}
type="button"
>
<div class="action-card-left">
<div class={cn('action-card-icon', variantClasses[variant])}>
{#if icon}
{@render icon()}
{/if}
</div>
<div>
<div class={cn('action-card-title', size === 'secondary' && 'action-card-title--sm')}>
{title}
</div>
{#if description}
<div class={cn('action-card-desc', size === 'secondary' && 'action-card-desc--sm')}>
{description}
</div>
{/if}
</div>
</div>
<div class="action-card-right">
<span>{action}</span>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m9 18 6-6-6-6"/>
</svg>
</div>
</button>
<style>
.action-card {
display: flex;
align-items: center;
justify-content: space-between;
border-radius: var(--radius, 12px);
background: var(--card);
box-shadow: var(--card-shadow);
transition: all var(--transition, 180ms ease);
cursor: pointer;
border: 1px solid var(--border);
padding: 20px 24px;
width: 100%;
text-align: left;
font-family: var(--font);
}
.action-card:hover {
transform: translateY(-1px);
box-shadow: var(--card-shadow), 0 2px 8px rgba(0, 0, 0, 0.04);
}
.action-card--secondary {
padding: 14px 20px;
background: var(--card-secondary);
box-shadow: var(--card-shadow-sm);
}
.action-card-left {
display: flex;
align-items: center;
gap: 14px;
}
.action-card-icon {
width: 40px;
height: 40px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.action-card-icon :global(svg) {
width: 20px;
height: 20px;
}
.action-card-icon--budget {
background: var(--accent-bg);
color: var(--accent);
}
.action-card-icon--inventory {
background: var(--error-bg);
color: var(--error);
}
.action-card-icon--fitness {
background: var(--success-bg);
color: var(--success);
}
.action-card-title {
font-size: var(--text-md);
font-weight: 600;
color: var(--text-1);
}
.action-card-title--sm {
font-size: var(--text-base);
font-weight: 500;
}
.action-card-desc {
font-size: var(--text-sm);
color: var(--text-3);
margin-top: 2px;
}
.action-card-desc--sm {
font-size: var(--text-sm);
}
.action-card-right {
font-size: var(--text-sm);
color: var(--text-3);
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.action-card-right svg {
width: 16px;
height: 16px;
}
</style>

View File

@@ -0,0 +1,61 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let {
icon,
title,
description = ''
}: {
icon?: Snippet;
title: string;
description?: string;
} = $props();
</script>
<div class="empty-state">
{#if icon}
<div class="empty-state-icon">
{@render icon()}
</div>
{/if}
<h3 class="empty-state-title">{title}</h3>
{#if description}
<p class="empty-state-desc">{description}</p>
{/if}
</div>
<style>
.empty-state {
text-align: center;
padding: var(--sp-12) var(--sp-6);
}
.empty-state-icon {
margin: 0 auto var(--sp-4);
width: 48px;
height: 48px;
border-radius: var(--radius);
background: var(--card-secondary);
display: flex;
align-items: center;
justify-content: center;
}
.empty-state-icon :global(svg) {
width: 24px;
height: 24px;
color: var(--text-4);
}
.empty-state-title {
font-size: var(--text-md);
font-weight: 500;
color: var(--text-2);
margin-bottom: 4px;
}
.empty-state-desc {
font-size: var(--text-sm);
color: var(--text-3);
}
</style>

View File

@@ -0,0 +1,250 @@
<script lang="ts">
import { onMount } from 'svelte';
interface ImmichAsset {
id: string;
type: string;
fileCreatedAt: string;
}
let {
open = $bindable(false),
multiple = true,
onselect
}: {
open: boolean;
multiple?: boolean;
onselect: (assetIds: string[]) => void;
} = $props();
let assets = $state<ImmichAsset[]>([]);
let loading = $state(true);
let currentPage = $state(1);
let hasMore = $state(true);
let selected = $state<Set<string>>(new Set());
let searchQuery = $state('');
let searchTimer: ReturnType<typeof setTimeout>;
let errorMsg = $state('');
async function loadAssets(reset = false) {
if (reset) { currentPage = 1; hasMore = true; assets = []; }
loading = true;
errorMsg = '';
try {
const body: Record<string, unknown> = {
type: 'IMAGE',
size: 20,
page: reset ? 1 : currentPage,
order: 'desc'
};
if (searchQuery.trim()) {
body.originalFileName = searchQuery.trim();
}
const res = await fetch('/api/immich/search/metadata', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(body)
});
if (res.ok) {
const data = await res.json();
const items = data.assets?.items || [];
assets = reset ? items : [...assets, ...items];
hasMore = items.length === 20;
currentPage = (reset ? 1 : currentPage) + 1;
} else {
errorMsg = 'Failed to load photos (' + res.status + ')';
}
} catch {
errorMsg = 'Could not connect to photo library';
}
finally { loading = false; }
}
function toggleSelect(id: string) {
const next = new Set(selected);
if (next.has(id)) next.delete(id);
else { if (!multiple) next.clear(); next.add(id); }
selected = next;
}
function confirmSelection() {
onselect(Array.from(selected));
selected = new Set();
open = false;
}
function close() {
selected = new Set();
open = false;
}
function onSearch() {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => loadAssets(true), 400);
}
// Load photos as soon as this component mounts
onMount(() => { loadAssets(true); });
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="immich-overlay" onclick={close}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="immich-modal" onclick={(e) => e.stopPropagation()}>
<div class="immich-header">
<div class="immich-title">Choose from Photos</div>
<button class="immich-close" onclick={close} aria-label="Close">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="immich-search">
<input type="text" class="immich-search-input" placeholder="Search photos..." bind:value={searchQuery} oninput={onSearch} />
</div>
<div class="immich-grid">
{#each assets as asset (asset.id)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="immich-thumb" class:selected={selected.has(asset.id)} onclick={() => toggleSelect(asset.id)}>
<img src="/api/immich/assets/{asset.id}/thumbnail" alt="" loading="lazy" />
{#if selected.has(asset.id)}
<div class="immich-check">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>
</div>
{/if}
</div>
{/each}
{#if loading}
{#each Array(8) as _}
<div class="immich-thumb skeleton"></div>
{/each}
{/if}
{#if !loading && assets.length === 0 && !errorMsg}
<div class="immich-empty">No photos found</div>
{/if}
{#if errorMsg}
<div class="immich-empty">{errorMsg}</div>
{/if}
</div>
{#if hasMore && !loading && assets.length > 0}
<button class="immich-load-more" onclick={() => loadAssets()}>Load more</button>
{/if}
<div class="immich-footer">
<span class="immich-count">{selected.size} selected</span>
<div class="immich-actions">
<button class="immich-btn-cancel" onclick={close}>Cancel</button>
<button class="immich-btn-confirm" onclick={confirmSelection} disabled={selected.size === 0}>
Use {selected.size} photo{selected.size !== 1 ? 's' : ''}
</button>
</div>
</div>
</div>
</div>
<style>
.immich-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 70;
display: flex; align-items: center; justify-content: center;
animation: imFadeIn 150ms ease;
}
@keyframes imFadeIn { from { opacity: 0; } to { opacity: 1; } }
.immich-modal {
background: var(--surface); border-radius: 16px;
width: 560px; max-width: 95vw; max-height: 85vh;
display: flex; flex-direction: column;
box-shadow: 0 20px 60px rgba(0,0,0,0.2);
animation: imSlideUp 200ms ease;
}
@keyframes imSlideUp { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
.immich-header {
display: flex; align-items: center; justify-content: space-between;
padding: 18px 20px; border-bottom: 1px solid var(--border);
}
.immich-title { font-size: var(--text-md); font-weight: 600; color: var(--text-1); }
.immich-close { background: none; border: none; cursor: pointer; color: var(--text-3); padding: 4px; border-radius: 6px; transition: all 0.15s; }
.immich-close:hover { color: var(--text-1); background: var(--card-hover); }
.immich-close svg { width: 18px; height: 18px; }
.immich-search { padding: 12px 20px 0; }
.immich-search-input {
width: 100%; padding: 10px 14px; border-radius: 8px;
border: 1px solid var(--border); background: var(--surface-secondary);
color: var(--text-1); font-size: var(--text-md); font-family: var(--font);
}
.immich-search-input:focus { outline: none; border-color: var(--accent); }
.immich-search-input::placeholder { color: var(--text-4); }
.immich-grid {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 4px;
padding: 12px 20px; overflow-y: auto; flex: 1; min-height: 200px;
}
.immich-thumb {
aspect-ratio: 1; border-radius: 8px; overflow: hidden;
cursor: pointer; position: relative; background: var(--card-hover);
transition: transform 0.1s;
}
.immich-thumb:hover { transform: scale(0.97); }
.immich-thumb.selected { outline: 3px solid var(--accent); outline-offset: -3px; }
.immich-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.immich-check {
position: absolute; top: 6px; right: 6px;
width: 24px; height: 24px; border-radius: 50%;
background: var(--accent); color: white;
display: flex; align-items: center; justify-content: center;
}
.immich-check svg { width: 14px; height: 14px; }
.immich-thumb.skeleton {
background: linear-gradient(90deg, var(--card) 25%, var(--card-hover) 50%, var(--card) 75%);
background-size: 200% 100%; animation: shimmer 1.5s infinite;
}
@keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
.immich-empty {
grid-column: 1 / -1; text-align: center; padding: 40px;
color: var(--text-3); font-size: var(--text-base);
}
.immich-load-more {
display: block; margin: 0 auto 8px; padding: 8px 20px;
border-radius: 8px; background: none; border: 1px solid var(--border);
font-size: var(--text-sm); font-weight: 500; color: var(--text-2);
cursor: pointer; font-family: var(--font); transition: all 0.15s;
}
.immich-load-more:hover { background: var(--card-hover); }
.immich-footer {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 20px; border-top: 1px solid var(--border);
}
.immich-count { font-size: var(--text-sm); color: var(--text-3); }
.immich-actions { display: flex; gap: 8px; }
.immich-btn-cancel {
padding: 8px 16px; border-radius: 8px; background: var(--card-secondary);
color: var(--text-2); border: 1px solid var(--border); font-size: var(--text-sm);
font-weight: 500; cursor: pointer; font-family: var(--font); transition: all 0.15s;
}
.immich-btn-cancel:hover { background: var(--card-hover); }
.immich-btn-confirm {
padding: 8px 16px; border-radius: 8px; background: var(--accent);
color: white; border: none; font-size: var(--text-sm); font-weight: 600;
cursor: pointer; font-family: var(--font); transition: opacity 0.15s;
}
.immich-btn-confirm:hover { opacity: 0.9; }
.immich-btn-confirm:disabled { opacity: 0.4; cursor: default; }
@media (max-width: 768px) {
.immich-modal { max-height: 90vh; border-radius: 16px 16px 0 0; align-self: flex-end; width: 100%; max-width: 100%; }
.immich-grid { grid-template-columns: repeat(3, 1fr); }
}
</style>

View File

@@ -0,0 +1,69 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let {
title,
action = '',
onaction,
children
}: {
title: string;
action?: string;
onaction?: () => void;
children: Snippet;
} = $props();
</script>
<div class="module">
<div class="module-header">
<h3 class="module-title">{title}</h3>
{#if action}
<button class="module-action" onclick={onaction} type="button">
{action}
</button>
{/if}
</div>
<div class="module-content">
{@render children()}
</div>
</div>
<style>
.module {
background: var(--card);
border-radius: var(--radius, 12px);
padding: var(--card-pad-primary, 20px);
box-shadow: var(--card-shadow);
border: 1px solid var(--border);
}
.module-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--sp-5);
}
.module-title {
font-size: var(--text-sm);
font-weight: 600;
color: var(--text-3);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.module-action {
font-size: var(--text-sm);
color: var(--accent);
font-weight: 500;
cursor: pointer;
background: none;
border: none;
font-family: var(--font);
padding: 0;
}
.module-action:hover {
text-decoration: underline;
}
</style>

View File

@@ -0,0 +1,47 @@
<script lang="ts">
let {
label,
title,
subtitle = ''
}: {
label: string;
title: string;
subtitle?: string;
} = $props();
</script>
<div class="page-header">
<p class="page-label">{label}</p>
<h1 class="page-title">{title}</h1>
{#if subtitle}
<p class="page-subtitle">{subtitle}</p>
{/if}
</div>
<style>
.page-header {
margin-bottom: var(--section-gap, 24px);
}
.page-label {
font-size: var(--text-sm);
font-weight: 500;
color: var(--text-3);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: var(--sp-1);
}
.page-title {
font-size: var(--text-2xl);
font-weight: 300;
color: var(--text-1);
line-height: 1.2;
}
.page-subtitle {
font-size: var(--text-base);
color: var(--text-3);
margin-top: var(--sp-1);
}
</style>

View File

@@ -0,0 +1,76 @@
<script lang="ts">
import { cn } from '$lib/utils';
type Tab = {
label: string;
badge?: number;
active?: boolean;
};
let {
tabs,
onselect
}: {
tabs: Tab[];
onselect?: (index: number) => void;
} = $props();
</script>
<div class="page-tabs">
{#each tabs as tab, index}
<button
class={cn('page-tab', tab.active && 'page-tab--active')}
onclick={() => onselect?.(index)}
type="button"
>
{tab.label}
{#if tab.badge !== undefined}
<span class="badge">{tab.badge}</span>
{/if}
</button>
{/each}
</div>
<style>
.page-tabs {
display: flex;
gap: var(--sp-1);
margin-bottom: var(--section-gap, 24px);
}
.page-tab {
padding: var(--sp-2) var(--sp-4);
border-radius: var(--radius-sm, 8px);
font-size: var(--text-base);
font-weight: 500;
color: var(--text-3);
background: none;
border: none;
transition: all var(--transition, 180ms ease);
cursor: pointer;
font-family: var(--font);
display: inline-flex;
align-items: center;
}
.page-tab:hover {
color: var(--text-1);
background: var(--card-secondary);
}
.page-tab--active {
color: var(--text-1);
background: var(--card);
box-shadow: var(--card-shadow-sm);
}
.badge {
font-size: var(--text-xs);
font-family: var(--mono);
background: var(--accent-dim);
color: var(--accent);
padding: 1px 6px;
border-radius: var(--radius-xs);
margin-left: var(--sp-1.5);
}
</style>

View File

@@ -0,0 +1,66 @@
<script lang="ts">
let {
placeholder = 'Search...',
value = $bindable('')
}: {
placeholder?: string;
value?: string;
} = $props();
</script>
<div class="search-bar">
<div class="input-with-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"/>
<path d="m21 21-4.3-4.3"/>
</svg>
<input
class="input"
type="text"
{placeholder}
bind:value
/>
</div>
</div>
<style>
.search-bar {
margin-bottom: var(--section-gap, 24px);
}
.input-with-icon {
position: relative;
}
.input-with-icon svg {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
width: 16px;
height: 16px;
color: var(--text-3);
pointer-events: none;
}
.input {
width: 100%;
padding: 10px 14px 10px 38px;
border-radius: var(--radius-sm, 8px);
background: var(--surface-secondary);
border: 1px solid var(--border);
color: var(--text-1);
font-size: var(--text-base);
font-family: var(--font);
outline: none;
transition: border-color var(--transition, 180ms ease);
}
.input:focus {
border-color: var(--accent);
}
.input::placeholder {
color: var(--text-4);
}
</style>

View File

@@ -0,0 +1,93 @@
<script lang="ts">
let {
rows = 3,
variant = 'default' as 'default' | 'card'
}: {
rows?: number;
variant?: 'default' | 'card';
} = $props();
</script>
<div class="skeleton-rows">
{#each Array(rows) as _, i}
<div class="skeleton-row">
<div class="skeleton skeleton-circle"></div>
<div class="skeleton-text">
<div class="skeleton skeleton-line w-2-3"></div>
<div class="skeleton skeleton-line w-1-3"></div>
</div>
<div class="skeleton skeleton-line skeleton-amount"></div>
</div>
{/each}
</div>
<style>
.skeleton-rows {
display: flex;
flex-direction: column;
}
.skeleton-row {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 16px;
}
.skeleton-row + .skeleton-row {
border-top: 1px solid var(--border);
}
.skeleton {
background: linear-gradient(
90deg,
var(--card, #161619) 25%,
var(--card-hover, #1c1c20) 50%,
var(--card, #161619) 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: var(--radius-sm);
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.skeleton-circle {
width: 40px;
height: 40px;
border-radius: 50%;
flex-shrink: 0;
}
.skeleton-text {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--sp-1.5);
}
.skeleton-line {
height: 14px;
}
.w-2-3 {
width: 66%;
}
.w-1-3 {
width: 33%;
}
.skeleton-amount {
width: 64px;
height: 14px;
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,95 @@
<script lang="ts" module>
type ToastItem = {
id: number;
message: string;
variant: 'success' | 'error';
};
let toasts = $state<ToastItem[]>([]);
let nextId = 0;
export function toast(message: string, variant: 'success' | 'error' = 'success') {
const id = nextId++;
toasts.push({ id, message, variant });
setTimeout(() => {
toasts = toasts.filter((t) => t.id !== id);
}, 3000);
}
</script>
<script lang="ts">
import { cn } from '$lib/utils';
</script>
{#if toasts.length > 0}
<div class="toast-container">
{#each toasts as item (item.id)}
<div class={cn('toast', `toast--${item.variant}`)}>
{#if item.variant === 'success'}
<svg class="toast-icon toast-icon--success" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
{:else}
<svg class="toast-icon toast-icon--error" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="15" y1="9" x2="9" y2="15"/>
<line x1="9" y1="9" x2="15" y2="15"/>
</svg>
{/if}
<span>{item.message}</span>
</div>
{/each}
</div>
{/if}
<style>
.toast-container {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 90;
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
.toast {
padding: var(--sp-3) var(--sp-4);
border-radius: var(--radius-sm, 8px);
background: var(--card);
border: 1px solid var(--border);
box-shadow: var(--card-shadow);
font-size: var(--text-sm);
color: var(--text-1);
display: flex;
align-items: center;
gap: var(--sp-2);
animation: slideUp 0.3s ease;
}
@keyframes slideUp {
from {
transform: translateY(10px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.toast-icon {
width: 18px;
height: 18px;
flex-shrink: 0;
}
.toast-icon--success {
color: var(--success);
}
.toast-icon--error {
color: var(--error);
}
</style>

View File

@@ -0,0 +1,148 @@
<script lang="ts">
import { cn } from '$lib/utils';
type CategoryVariant = 'default' | 'uncat' | 'transfer';
let {
date,
payeeName,
payeeNote = '',
account,
category,
categoryVariant = 'default' as CategoryVariant,
amount,
isPositive = false
}: {
date: string;
payeeName: string;
payeeNote?: string;
account: string;
category: string;
categoryVariant?: CategoryVariant;
amount: string;
isPositive?: boolean;
} = $props();
const pillVariantClass: Record<CategoryVariant, string> = {
default: 'txn-cat-pill--default',
uncat: 'txn-cat-pill--uncat',
transfer: 'txn-cat-pill--transfer'
};
</script>
<div class="txn-row">
<div class="txn-date">{date}</div>
<div class="txn-payee">
<div class="txn-payee-name">{payeeName}</div>
{#if payeeNote}
<div class="txn-payee-note">{payeeNote}</div>
{/if}
</div>
<div class="txn-account">{account}</div>
<div class="txn-category">
<span class={cn('txn-cat-pill', pillVariantClass[categoryVariant])}>
{category}
</span>
</div>
<div class={cn('txn-amount', isPositive ? 'txn-amount--pos' : 'txn-amount--neg')}>
{amount}
</div>
</div>
<style>
.txn-row {
display: flex;
align-items: center;
gap: 16px;
padding: 14px 16px;
transition: background var(--transition, 180ms ease);
cursor: default;
}
.txn-row:hover {
background: var(--card-hover);
}
.txn-row + :global(.txn-row) {
border-top: 1px solid var(--border);
}
.txn-date {
font-size: var(--text-sm);
color: var(--text-3);
width: 56px;
flex-shrink: 0;
}
.txn-payee {
flex: 1.5;
min-width: 0;
}
.txn-payee-name {
font-size: var(--text-base);
font-weight: 500;
color: var(--text-1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.txn-payee-note {
font-size: var(--text-sm);
color: var(--text-4);
margin-top: 1px;
}
.txn-account {
flex: 1;
font-size: var(--text-sm);
color: var(--text-3);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.txn-category {
flex: 1;
}
.txn-cat-pill {
display: inline-block;
padding: 4px 10px;
border-radius: 6px;
font-size: var(--text-sm);
font-weight: 500;
}
.txn-cat-pill--default {
background: var(--accent-dim);
color: var(--accent);
}
.txn-cat-pill--uncat {
background: var(--warning-bg);
color: var(--warning);
}
.txn-cat-pill--transfer {
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
.txn-amount {
font-family: var(--mono);
font-size: var(--text-base);
font-weight: 500;
text-align: right;
min-width: 80px;
}
.txn-amount--pos {
color: var(--success);
}
.txn-amount--neg {
color: var(--error);
}
</style>

View File

@@ -0,0 +1,101 @@
<script lang="ts">
let {
open = $bindable(false),
onCreated
}: {
open: boolean;
onCreated: (tripId: string) => void;
} = $props();
let name = $state('');
let description = $state('');
let startDate = $state('');
let endDate = $state('');
let saving = $state(false);
$effect(() => {
if (open) { name = ''; description = ''; startDate = ''; endDate = ''; }
});
function close() { open = false; }
async function create() {
if (!name.trim()) return;
saving = true;
try {
const res = await fetch('/api/trips/trip', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description, start_date: startDate, end_date: endDate })
});
if (res.ok) {
const data = await res.json();
close();
onCreated(data.id);
}
} catch (e) { console.error('Create failed:', e); }
finally { saving = false; }
}
</script>
{#if open}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-overlay" onclick={close}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-sheet" onclick={(e) => e.stopPropagation()}>
<div class="modal-header">
<div class="modal-title">Plan a Trip</div>
<button class="modal-close" onclick={close}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="modal-body">
<div class="field">
<label class="field-label">Trip Name</label>
<input class="field-input" type="text" bind:value={name} placeholder="e.g. Tokyo 2027" autofocus />
</div>
<div class="field">
<label class="field-label">Description</label>
<textarea class="field-input field-textarea" bind:value={description} rows="2" placeholder="Optional..."></textarea>
</div>
<div class="field-row">
<div class="field"><label class="field-label">Start Date</label><input class="field-input" type="date" bind:value={startDate} /></div>
<div class="field"><label class="field-label">End Date</label><input class="field-input" type="date" bind:value={endDate} /></div>
</div>
</div>
<div class="modal-footer">
<div></div>
<div class="footer-right">
<button class="btn-cancel" onclick={close}>Cancel</button>
<button class="btn-save" onclick={create} disabled={saving || !name.trim()}>
{saving ? 'Creating...' : 'Create Trip'}
</button>
</div>
</div>
</div>
</div>
{/if}
<style>
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.35); z-index: 70; display: flex; align-items: center; justify-content: center; animation: fade 150ms ease; }
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
.modal-sheet { width: 440px; max-width: 92vw; background: var(--surface); border-radius: var(--radius); box-shadow: 0 20px 60px rgba(0,0,0,0.15); animation: slideUp 200ms ease; }
@keyframes slideUp { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 18px 22px; border-bottom: 1px solid var(--border); }
.modal-title { font-size: var(--text-lg); font-weight: 600; color: var(--text-1); }
.modal-close { background: none; border: none; cursor: pointer; color: var(--text-3); padding: var(--sp-1); border-radius: var(--radius-sm); }
.modal-close:hover { color: var(--text-1); background: var(--card-hover); }
.modal-close svg { width: 18px; height: 18px; }
.modal-body { padding: 22px; display: flex; flex-direction: column; gap: 14px; }
.field { display: flex; flex-direction: column; gap: var(--sp-1); }
.field-label { font-size: var(--text-sm); font-weight: 500; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.04em; }
.field-input { padding: 10px 12px; border-radius: var(--radius-md); border: 1px solid var(--border); background: var(--surface-secondary); color: var(--text-1); font-size: var(--text-md); font-family: var(--font); }
.field-input:focus { outline: none; border-color: var(--accent); }
.field-textarea { resize: vertical; min-height: 50px; }
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.modal-footer { display: flex; align-items: center; justify-content: space-between; padding: 14px 22px; border-top: 1px solid var(--border); }
.footer-right { display: flex; gap: var(--sp-2); }
.btn-cancel { padding: 8px 14px; border-radius: var(--radius-md); background: var(--card-secondary); color: var(--text-2); border: 1px solid var(--border); font-size: var(--text-sm); font-weight: 500; cursor: pointer; font-family: var(--font); }
.btn-save { padding: 8px 18px; border-radius: var(--radius-md); background: var(--accent); color: white; border: none; font-size: var(--text-sm); font-weight: 600; cursor: pointer; font-family: var(--font); }
.btn-save:disabled { opacity: 0.4; }
</style>

View File

@@ -0,0 +1,274 @@
<script lang="ts">
import ImmichPicker from '$lib/components/shared/ImmichPicker.svelte';
let {
entityType,
entityId,
images = [],
documents = [],
onUpload
}: {
entityType: string;
entityId: string;
images: any[];
documents?: any[];
onUpload: () => void;
} = $props();
let uploading = $state(false);
let deletingId = $state('');
let uploadingDoc = $state(false);
let deletingDocId = $state('');
let showImmich = $state(false);
// Image search
let showSearch = $state(false);
let searchQuery = $state('');
let searchResults = $state<{ url: string; thumbnail: string; title: string }[]>([]);
let searching = $state(false);
let savingUrl = $state('');
async function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement;
if (!input.files?.length || !entityId) return;
uploading = true;
try {
for (const file of input.files) {
const base64 = await new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.readAsDataURL(file);
});
await fetch('/api/trips/image/upload', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entity_type: entityType, entity_id: entityId, image_data: base64, filename: file.name })
});
}
onUpload();
} catch (e) { console.error('Upload failed:', e); }
finally { uploading = false; input.value = ''; }
}
async function deleteImage(imageId: string) {
deletingId = imageId;
try {
await fetch('/api/trips/image/delete', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: imageId })
});
onUpload();
} catch { /* silent */ }
finally { deletingId = ''; }
}
async function searchImages() {
if (!searchQuery.trim()) return;
searching = true;
try {
const res = await fetch('/api/trips/image/search', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: searchQuery })
});
if (res.ok) {
const data = await res.json();
searchResults = data.images || [];
}
} catch { searchResults = []; }
finally { searching = false; }
}
async function saveFromUrl(url: string) {
savingUrl = url;
try {
await fetch('/api/trips/image/upload-from-url', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entity_type: entityType, entity_id: entityId, url })
});
onUpload();
showSearch = false; searchResults = [];
} catch { /* silent */ }
finally { savingUrl = ''; }
}
async function handleDocSelect(e: Event) {
const input = e.target as HTMLInputElement;
if (!input.files?.length || !entityId) return;
uploadingDoc = true;
try {
for (const file of input.files) {
const formData = new FormData();
formData.append('entity_type', entityType);
formData.append('entity_id', entityId);
formData.append('file', file);
await fetch('/api/trips/document/upload', {
method: 'POST', credentials: 'include', body: formData
});
}
onUpload();
} catch { /* silent */ }
finally { uploadingDoc = false; input.value = ''; }
}
async function deleteDoc(docId: string) {
deletingDocId = docId;
try {
await fetch('/api/trips/document/delete', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ document_id: docId })
});
onUpload();
} catch { /* silent */ }
finally { deletingDocId = ''; }
}
async function handleImmichSelect(assetIds: string[]) {
// Download from Immich and upload to trips
for (const assetId of assetIds) {
try {
const imgRes = await fetch(`/api/immich/assets/${assetId}/thumbnail`);
if (!imgRes.ok) continue;
const blob = await imgRes.blob();
const base64 = await new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.readAsDataURL(blob);
});
await fetch('/api/trips/image/upload', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entity_type: entityType, entity_id: entityId, image_data: base64, filename: `immich-${assetId}.webp` })
});
} catch { /* silent */ }
}
showImmich = false;
onUpload();
}
</script>
<div class="upload-section">
<!-- Existing images -->
{#if images.length > 0}
<div class="image-strip">
{#each images as img}
<div class="image-thumb-wrap">
<img src={img.url || `/images/${img.file_path}`} alt="" class="image-thumb" />
<button class="image-delete" onclick={() => deleteImage(img.id)} disabled={deletingId === img.id}>
{#if deletingId === img.id}...{:else}×{/if}
</button>
</div>
{/each}
</div>
{/if}
<!-- Existing documents -->
{#if documents && documents.length > 0}
<div class="doc-list">
{#each documents as doc}
<div class="doc-row">
<svg class="doc-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>
<a href={doc.url || `/api/trips/documents/${doc.file_path}`} target="_blank" class="doc-name">{doc.file_name || doc.original_name || 'Document'}</a>
<button class="doc-delete" onclick={() => deleteDoc(doc.id)} disabled={deletingDocId === doc.id}>×</button>
</div>
{/each}
</div>
{/if}
<!-- Action buttons -->
{#if entityId}
<div class="upload-actions">
<label class="upload-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>
{uploading ? 'Uploading...' : 'Photo'}
<input type="file" accept="image/*" multiple class="hidden-input" onchange={handleFileSelect} disabled={uploading} />
</label>
<button class="upload-btn" onclick={() => { showSearch = !showSearch; if (showSearch) searchImages(); }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
Search
</button>
<button class="upload-btn" onclick={() => showImmich = true}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>
Immich
</button>
<label class="upload-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>
{uploadingDoc ? 'Uploading...' : 'Document'}
<input type="file" accept=".pdf,.doc,.docx,.txt,.jpg,.jpeg,.png" multiple class="hidden-input" onchange={handleDocSelect} disabled={uploadingDoc} />
</label>
</div>
{:else}
<div class="upload-hint">Save first to add photos and documents</div>
{/if}
<!-- Search panel -->
{#if showSearch}
<div class="search-panel">
<div class="search-row">
<input class="search-input" type="text" placeholder="Search images..." bind:value={searchQuery}
onkeydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); searchImages(); } }} />
<button class="search-btn" onclick={searchImages} disabled={searching}>{searching ? '...' : 'Search'}</button>
<button class="search-close" onclick={() => { showSearch = false; searchResults = []; }}>×</button>
</div>
{#if searchResults.length > 0}
<div class="search-grid">
{#each searchResults as result}
<button class="search-thumb" onclick={() => saveFromUrl(result.url)} disabled={savingUrl === result.url}>
<img src={result.thumbnail} alt={result.title} />
{#if savingUrl === result.url}<div class="search-saving">Saving...</div>{/if}
</button>
{/each}
</div>
{/if}
</div>
{/if}
</div>
{#if showImmich}
<ImmichPicker bind:open={showImmich} onselect={handleImmichSelect} />
{/if}
<style>
.upload-section { display: flex; flex-direction: column; gap: 10px; }
.image-strip { display: flex; gap: 6px; overflow-x: auto; padding-bottom: 4px; }
.image-thumb-wrap { position: relative; flex-shrink: 0; }
.image-thumb { width: 96px; height: 72px; object-fit: cover; border-radius: 8px; }
.image-delete {
position: absolute; top: 3px; right: 3px; width: 20px; height: 20px;
border-radius: 50%; background: rgba(0,0,0,0.5); color: white; border: none;
font-size: var(--text-sm); cursor: pointer; display: flex; align-items: center; justify-content: center;
}
.doc-list { display: flex; flex-direction: column; gap: 4px; }
.doc-row { display: flex; align-items: center; gap: 6px; padding: 6px 8px; border-radius: 6px; background: var(--surface-secondary); }
.doc-icon { width: 14px; height: 14px; color: var(--text-4); flex-shrink: 0; }
.doc-name { flex: 1; font-size: var(--text-sm); color: var(--text-2); text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.doc-name:hover { color: var(--accent); }
.doc-delete { background: none; border: none; color: var(--text-4); cursor: pointer; font-size: var(--text-base); padding: 2px 4px; }
.doc-delete:hover { color: var(--error); }
.upload-actions { display: flex; flex-wrap: wrap; gap: 6px; }
.upload-btn {
display: flex; align-items: center; gap: 4px; padding: 6px 10px;
border-radius: 6px; background: none; border: 1px solid var(--border);
font-size: var(--text-sm); font-weight: 500; color: var(--text-3); cursor: pointer;
font-family: var(--font); transition: all var(--transition);
}
.upload-btn:hover { color: var(--text-1); border-color: var(--text-4); }
.upload-btn svg { width: 14px; height: 14px; }
.hidden-input { display: none; }
.upload-hint { font-size: var(--text-sm); color: var(--text-4); text-align: center; padding: 8px; }
.search-panel { border: 1px solid var(--border); border-radius: 8px; padding: 10px; background: var(--surface-secondary); }
.search-row { display: flex; gap: 6px; margin-bottom: 8px; }
.search-input { flex: 1; padding: 6px 10px; border-radius: 6px; border: 1px solid var(--border); background: var(--card); color: var(--text-1); font-size: var(--text-sm); font-family: var(--font); }
.search-input:focus { outline: none; border-color: var(--accent); }
.search-btn { padding: 6px 12px; border-radius: 6px; background: var(--accent); color: white; border: none; font-size: var(--text-sm); font-weight: 600; cursor: pointer; font-family: var(--font); }
.search-close { background: none; border: none; color: var(--text-3); font-size: var(--text-md); cursor: pointer; padding: 4px 8px; }
.search-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4px; max-height: 160px; overflow-y: auto; }
.search-thumb { position: relative; aspect-ratio: 1; border-radius: 6px; overflow: hidden; border: none; cursor: pointer; padding: 0; background: var(--card-hover); }
.search-thumb img { width: 100%; height: 100%; object-fit: cover; }
.search-saving { position: absolute; inset: 0; background: rgba(0,0,0,0.5); color: white; display: flex; align-items: center; justify-content: center; font-size: var(--text-xs); }
</style>

View File

@@ -0,0 +1,368 @@
<script lang="ts">
import PlacesAutocomplete from './PlacesAutocomplete.svelte';
import ImageUpload from './ImageUpload.svelte';
type ItemType = 'transportation' | 'lodging' | 'location' | 'note';
let {
open = $bindable(false),
tripId,
itemType = 'location',
editItem = null,
onSaved
}: {
open: boolean;
tripId: string;
itemType: ItemType;
editItem?: any;
onSaved: () => void;
} = $props();
let saving = $state(false);
let confirmDelete = $state(false);
// ── Form state ──
let name = $state('');
let description = $state('');
let category = $state('');
let date = $state('');
let endDate = $state('');
let startTime = $state('');
let endTime = $state('');
let flightNumber = $state('');
let fromLocation = $state('');
let toLocation = $state('');
let reservationNumber = $state('');
let link = $state('');
let costPoints = $state(0);
let costCash = $state(0);
let address = $state('');
let placeId = $state('');
let latitude = $state<number | null>(null);
let longitude = $state<number | null>(null);
let hikeDistance = $state('');
let hikeDifficulty = $state('');
let hikeTime = $state('');
let transportType = $state('plane');
let lodgingType = $state('hotel');
let content = $state('');
let isEdit = $derived(!!editItem?.id);
const locationCategories = ['restaurant', 'cafe', 'bar', 'attraction', 'hike', 'shopping', 'beach', 'museum', 'park'];
const transportTypes = ['plane', 'train', 'car', 'bus', 'ferry'];
const lodgingTypes = ['hotel', 'airbnb', 'hostel', 'resort', 'camping'];
$effect(() => {
if (open && editItem) {
name = editItem.name || '';
description = editItem.description || '';
date = editItem.date || editItem.visit_date || editItem.check_in?.slice(0, 10) || '';
endDate = editItem.end_date || editItem.check_out?.slice(0, 10) || '';
startTime = editItem.start_time || editItem.date || '';
endTime = editItem.end_time || editItem.end_date || '';
category = editItem.category || '';
flightNumber = editItem.flight_number || '';
fromLocation = editItem.from_location || '';
toLocation = editItem.to_location || '';
reservationNumber = editItem.reservation_number || '';
link = editItem.link || '';
costPoints = editItem.cost_points || 0;
costCash = editItem.cost_cash || 0;
address = editItem.address || editItem.location || '';
placeId = editItem.place_id || '';
latitude = editItem.latitude || editItem.from_lat || null;
longitude = editItem.longitude || editItem.from_lng || null;
hikeDistance = editItem.hike_distance || '';
hikeDifficulty = editItem.hike_difficulty || '';
hikeTime = editItem.hike_time || '';
transportType = editItem.type || 'plane';
lodgingType = editItem.type || 'hotel';
content = editItem.content || '';
} else if (open) {
resetForm();
}
confirmDelete = false;
});
function resetForm() {
name = ''; description = ''; category = ''; date = ''; endDate = '';
startTime = ''; endTime = ''; flightNumber = ''; fromLocation = '';
toLocation = ''; reservationNumber = ''; link = ''; costPoints = 0;
costCash = 0; address = ''; placeId = ''; latitude = null; longitude = null;
hikeDistance = ''; hikeDifficulty = ''; hikeTime = '';
transportType = 'plane'; lodgingType = 'hotel'; content = '';
}
function close() { open = false; resetForm(); confirmDelete = false; }
function handlePlaceSelect(details: any) {
if (details.name) name = details.name;
address = details.address || '';
placeId = details.place_id || '';
latitude = details.latitude;
longitude = details.longitude;
if (details.category && !category) category = details.category;
if (itemType === 'lodging') address = details.address || details.name || '';
}
async function save() {
saving = true;
try {
let endpoint: string;
let payload: Record<string, any> = { trip_id: tripId };
if (itemType === 'transportation') {
payload = { ...payload, name, description, type: transportType, flight_number: flightNumber, from_location: fromLocation, to_location: toLocation, date: startTime || date, end_date: endTime || endDate, link, cost_points: costPoints, cost_cash: costCash, from_place_id: placeId, from_lat: latitude, from_lng: longitude };
endpoint = isEdit ? '/api/trips/transportation/update' : '/api/trips/transportation';
} else if (itemType === 'lodging') {
payload = { ...payload, name, description, type: lodgingType, location: address, check_in: date, check_out: endDate, reservation_number: reservationNumber, link, cost_points: costPoints, cost_cash: costCash, place_id: placeId, latitude, longitude };
endpoint = isEdit ? '/api/trips/lodging/update' : '/api/trips/lodging';
} else if (itemType === 'note') {
payload = { ...payload, name, content: description || content, date };
endpoint = isEdit ? '/api/trips/note/update' : '/api/trips/note';
} else {
payload = { ...payload, name, description, category, visit_date: date, start_time: startTime, end_time: endTime, link, cost_points: costPoints, cost_cash: costCash, address, place_id: placeId, latitude, longitude, hike_distance: hikeDistance, hike_difficulty: hikeDifficulty, hike_time: hikeTime };
endpoint = isEdit ? '/api/trips/location/update' : '/api/trips/location';
}
if (isEdit) payload.id = editItem.id;
await fetch(endpoint, {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
close();
onSaved();
} catch (e) { console.error('Save failed:', e); }
finally { saving = false; }
}
async function doDelete() {
if (!isEdit) return;
saving = true;
try {
await fetch(`/api/trips/${itemType}/delete`, {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: editItem.id })
});
close();
onSaved();
} catch (e) { console.error('Delete failed:', e); }
finally { saving = false; }
}
const titles: Record<ItemType, string> = {
transportation: 'Transportation',
lodging: 'Accommodation',
location: 'Activity',
note: 'Note'
};
</script>
{#if open}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-overlay" onclick={close}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-sheet" onclick={(e) => e.stopPropagation()}>
<div class="modal-header">
<div class="modal-title">{isEdit ? 'Edit' : 'Add'} {titles[itemType]}</div>
<button class="modal-close" onclick={close}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="modal-body">
<!-- Name (with Places search for location & lodging) -->
{#if itemType === 'location' || itemType === 'lodging'}
<div class="field">
<label class="field-label">Name</label>
<PlacesAutocomplete bind:value={name} placeholder={itemType === 'lodging' ? 'Search hotel or address...' : 'Search place or type name...'} onSelect={handlePlaceSelect} />
</div>
{:else}
<div class="field">
<label class="field-label">Name</label>
<input class="field-input" type="text" bind:value={name} placeholder={itemType === 'note' ? 'Note title' : 'Name'} />
</div>
{/if}
<!-- Type selectors -->
{#if itemType === 'transportation'}
<div class="field">
<label class="field-label">Type</label>
<select class="field-input" bind:value={transportType}>
{#each transportTypes as t}<option value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}</option>{/each}
</select>
</div>
{#if transportType === 'plane'}
<div class="field">
<label class="field-label">Flight Number</label>
<input class="field-input" type="text" bind:value={flightNumber} placeholder="UA 2341" />
</div>
{/if}
<div class="field-row">
<div class="field"><label class="field-label">From</label><input class="field-input" type="text" bind:value={fromLocation} /></div>
<div class="field"><label class="field-label">To</label><input class="field-input" type="text" bind:value={toLocation} /></div>
</div>
<div class="field-row">
<div class="field"><label class="field-label">Departure</label><input class="field-input" type="datetime-local" bind:value={startTime} /></div>
<div class="field"><label class="field-label">Arrival</label><input class="field-input" type="datetime-local" bind:value={endTime} /></div>
</div>
{:else if itemType === 'lodging'}
<div class="field">
<label class="field-label">Type</label>
<select class="field-input" bind:value={lodgingType}>
{#each lodgingTypes as t}<option value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}</option>{/each}
</select>
</div>
<div class="field-row">
<div class="field"><label class="field-label">Check-in</label><input class="field-input" type="date" bind:value={date} /></div>
<div class="field"><label class="field-label">Check-out</label><input class="field-input" type="date" bind:value={endDate} /></div>
</div>
<div class="field">
<label class="field-label">Reservation #</label>
<input class="field-input" type="text" bind:value={reservationNumber} />
</div>
{:else if itemType === 'location'}
<div class="field">
<label class="field-label">Category</label>
<select class="field-input" bind:value={category}>
<option value="">Select...</option>
{#each locationCategories as c}<option value={c}>{c.charAt(0).toUpperCase() + c.slice(1)}</option>{/each}
</select>
</div>
<div class="field">
<label class="field-label">Visit Date</label>
<input class="field-input" type="date" bind:value={date} />
</div>
<div class="field-row">
<div class="field"><label class="field-label">Start Time</label><input class="field-input" type="datetime-local" bind:value={startTime} /></div>
<div class="field"><label class="field-label">End Time</label><input class="field-input" type="datetime-local" bind:value={endTime} /></div>
</div>
{#if category === 'hike'}
<div class="field-row">
<div class="field"><label class="field-label">Distance</label><input class="field-input" type="text" bind:value={hikeDistance} placeholder="5.2 miles" /></div>
<div class="field">
<label class="field-label">Difficulty</label>
<select class="field-input" bind:value={hikeDifficulty}>
<option value="">Select...</option>
<option value="easy">Easy</option>
<option value="moderate">Moderate</option>
<option value="hard">Hard</option>
<option value="strenuous">Strenuous</option>
</select>
</div>
<div class="field"><label class="field-label">Duration</label><input class="field-input" type="text" bind:value={hikeTime} placeholder="3 hours" /></div>
</div>
{/if}
{:else if itemType === 'note'}
<div class="field">
<label class="field-label">Date</label>
<input class="field-input" type="date" bind:value={date} />
</div>
{/if}
<!-- Description / Content -->
{#if itemType === 'note'}
<div class="field">
<label class="field-label">Content</label>
<textarea class="field-input field-textarea" bind:value={content} rows="6" placeholder="Write your note..."></textarea>
</div>
{:else}
<div class="field">
<label class="field-label">Description</label>
<textarea class="field-input field-textarea" bind:value={description} rows="3" placeholder="Details..."></textarea>
</div>
{/if}
<!-- Images & Documents (edit mode only) -->
{#if isEdit && itemType !== 'note'}
<div class="field">
<label class="field-label">Photos & Documents</label>
<ImageUpload
entityType={itemType}
entityId={editItem.id}
images={(editItem.images || []).map((i: any) => ({ ...i, url: `/images/${i.file_path}` }))}
documents={(editItem.documents || []).map((d: any) => ({ ...d, url: `/api/trips/documents/${d.file_path}` }))}
onUpload={onSaved}
/>
</div>
{/if}
<!-- Link -->
{#if itemType !== 'note'}
<div class="field">
<label class="field-label">Link</label>
<input class="field-input" type="url" bind:value={link} placeholder="https://..." />
</div>
<div class="field-row">
<div class="field"><label class="field-label">Points</label><input class="field-input" type="number" bind:value={costPoints} /></div>
<div class="field"><label class="field-label">Cash ($)</label><input class="field-input" type="number" step="0.01" bind:value={costCash} /></div>
</div>
{/if}
</div>
<div class="modal-footer">
{#if isEdit}
{#if confirmDelete}
<div class="delete-confirm">
<span class="delete-msg">Delete this item?</span>
<button class="btn-danger" onclick={doDelete} disabled={saving}>Yes, delete</button>
<button class="btn-cancel" onclick={() => confirmDelete = false}>Cancel</button>
</div>
{:else}
<button class="btn-delete" onclick={() => confirmDelete = true}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>
{/if}
{:else}
<div></div>
{/if}
<div class="footer-right">
<button class="btn-cancel" onclick={close}>Cancel</button>
<button class="btn-save" onclick={save} disabled={saving || !name.trim()}>
{saving ? 'Saving...' : isEdit ? 'Save' : 'Add'}
</button>
</div>
</div>
</div>
</div>
{/if}
<style>
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.35); z-index: 70; display: flex; justify-content: flex-end; animation: modalFade 150ms ease; }
@keyframes modalFade { from { opacity: 0; } to { opacity: 1; } }
.modal-sheet { width: 480px; max-width: 100%; height: 100%; background: var(--surface); display: flex; flex-direction: column; box-shadow: -8px 0 32px rgba(0,0,0,0.1); animation: modalSlide 200ms ease; }
@keyframes modalSlide { from { transform: translateX(100%); } to { transform: translateX(0); } }
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border); }
.modal-title { font-size: var(--text-md); font-weight: 600; color: var(--text-1); }
.modal-close { background: none; border: none; cursor: pointer; color: var(--text-3); padding: var(--sp-1); border-radius: var(--radius-sm); }
.modal-close:hover { color: var(--text-1); background: var(--card-hover); }
.modal-close svg { width: 18px; height: 18px; }
.modal-body { flex: 1; overflow-y: auto; padding: var(--sp-5); display: flex; flex-direction: column; gap: 14px; }
.field { display: flex; flex-direction: column; gap: var(--sp-1); }
.field-label { font-size: var(--text-sm); font-weight: 500; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.04em; }
.field-input { padding: 10px 12px; border-radius: var(--radius-md); border: 1px solid var(--border); background: var(--surface-secondary); color: var(--text-1); font-size: var(--text-base); font-family: var(--font); }
.field-input:focus { outline: none; border-color: var(--accent); }
.field-textarea { resize: vertical; min-height: 60px; }
.field-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 10px; }
.modal-footer { display: flex; align-items: center; justify-content: space-between; padding: 14px 20px; border-top: 1px solid var(--border); }
.footer-right { display: flex; gap: var(--sp-2); }
.btn-cancel { padding: 8px 14px; border-radius: var(--radius-md); background: var(--card-secondary); color: var(--text-2); border: 1px solid var(--border); font-size: var(--text-sm); font-weight: 500; cursor: pointer; font-family: var(--font); }
.btn-save { padding: var(--sp-2) var(--sp-4); border-radius: var(--radius-md); background: var(--accent); color: white; border: none; font-size: var(--text-sm); font-weight: 600; cursor: pointer; font-family: var(--font); }
.btn-save:disabled { opacity: 0.4; cursor: default; }
.btn-delete { width: 34px; height: 34px; border-radius: var(--radius-md); background: none; border: 1px solid var(--error); color: var(--error); display: flex; align-items: center; justify-content: center; cursor: pointer; }
.btn-delete:hover { background: var(--error-bg); }
.btn-delete svg { width: 16px; height: 16px; }
.delete-confirm { display: flex; align-items: center; gap: var(--sp-2); }
.delete-msg { font-size: var(--text-sm); color: var(--error); font-weight: 500; }
.btn-danger { padding: 6px var(--sp-3); border-radius: var(--radius-sm); background: var(--error); color: white; border: none; font-size: var(--text-sm); font-weight: 600; cursor: pointer; font-family: var(--font); }
@media (max-width: 768px) { .modal-sheet { width: 100%; } }
</style>

View File

@@ -0,0 +1,95 @@
<script lang="ts">
interface Prediction {
place_id: string;
name: string;
address: string;
types: string[];
}
let {
value = $bindable(''),
placeholder = 'Search for a place...',
onSelect
}: {
value: string;
placeholder?: string;
onSelect: (details: { place_id: string; name: string; address: string; latitude: number | null; longitude: number | null; category: string }) => void;
} = $props();
let predictions = $state<Prediction[]>([]);
let showDropdown = $state(false);
let debounceTimer: ReturnType<typeof setTimeout>;
async function search(query: string) {
if (query.length < 2) { predictions = []; showDropdown = false; return; }
try {
const res = await fetch('/api/trips/places/autocomplete', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query })
});
if (res.ok) {
const data = await res.json();
predictions = data.predictions || [];
showDropdown = predictions.length > 0;
}
} catch { predictions = []; }
}
function onInput() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => search(value), 250);
}
async function selectPlace(pred: Prediction) {
showDropdown = false;
value = pred.name;
try {
const res = await fetch(`/api/trips/places/details?place_id=${encodeURIComponent(pred.place_id)}`, { credentials: 'include' });
const details = await res.json();
onSelect({ ...details, place_id: pred.place_id });
} catch {
onSelect({ place_id: pred.place_id, name: pred.name, address: pred.address, latitude: null, longitude: null, category: '' });
}
}
</script>
<div class="places-wrap">
<input type="text" class="places-input" {placeholder} bind:value oninput={onInput}
onfocus={() => { if (predictions.length > 0) showDropdown = true; }}
onblur={() => setTimeout(() => showDropdown = false, 200)} />
{#if showDropdown}
<div class="places-dropdown">
{#each predictions as pred}
<button class="places-option" onmousedown={() => selectPlace(pred)}>
<span class="places-name">{pred.name}</span>
{#if pred.address}<span class="places-addr">{pred.address}</span>{/if}
</button>
{/each}
</div>
{/if}
</div>
<style>
.places-wrap { position: relative; }
.places-input {
width: 100%; padding: 10px 12px; border-radius: var(--radius-md);
border: 1px solid var(--border); background: var(--surface-secondary);
color: var(--text-1); font-size: var(--text-base); font-family: var(--font);
}
.places-input:focus { outline: none; border-color: var(--accent); }
.places-dropdown {
position: absolute; top: 100%; left: 0; right: 0; margin-top: var(--sp-1);
background: var(--card); border: 1px solid var(--border); border-radius: 10px;
box-shadow: var(--card-shadow); z-index: 50; max-height: 200px; overflow-y: auto;
}
.places-option {
display: flex; flex-direction: column; width: 100%; padding: 10px 14px;
background: none; border: none; border-bottom: 1px solid var(--border);
text-align: left; cursor: pointer; transition: background var(--transition); font-family: var(--font);
}
.places-option:last-child { border-bottom: none; }
.places-option:hover { background: var(--card-hover); }
.places-name { font-size: var(--text-sm); font-weight: 500; color: var(--text-1); }
.places-addr { font-size: var(--text-xs); color: var(--text-3); margin-top: 1px; }
</style>

View File

@@ -0,0 +1,193 @@
<script lang="ts">
let {
open = $bindable(false),
tripData,
onSaved
}: {
open: boolean;
tripData: any;
onSaved: () => void;
} = $props();
let saving = $state(false);
let confirmDelete = $state(false);
let sharing = $state(false);
let shareUrl = $state('');
let copied = $state(false);
let name = $state('');
let description = $state('');
let startDate = $state('');
let endDate = $state('');
$effect(() => {
if (open && tripData) {
name = tripData.name || '';
description = tripData.description || '';
startDate = tripData.start_date || '';
endDate = tripData.end_date || '';
shareUrl = tripData.share_token ? `${typeof window !== 'undefined' ? window.location.origin : ''}/trips/view/${tripData.share_token}` : '';
confirmDelete = false;
copied = false;
}
});
function close() { open = false; }
async function save() {
saving = true;
try {
await fetch('/api/trips/trip/update', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: tripData.id, name, description, start_date: startDate, end_date: endDate })
});
close();
onSaved();
} catch (e) { console.error('Save failed:', e); }
finally { saving = false; }
}
async function doDelete() {
saving = true;
try {
await fetch('/api/trips/trip/delete', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: tripData.id })
});
window.location.href = '/trips';
} catch (e) { console.error('Delete failed:', e); }
finally { saving = false; }
}
async function toggleShare() {
sharing = true;
try {
if (shareUrl) {
await fetch('/api/trips/share/delete', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ trip_id: tripData.id })
});
shareUrl = '';
} else {
const res = await fetch('/api/trips/share/create', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ trip_id: tripData.id })
});
const data = await res.json();
shareUrl = `${window.location.origin}/trips/view/${data.share_token}`;
}
} catch { /* silent */ }
finally { sharing = false; }
}
async function copyUrl() {
await navigator.clipboard.writeText(shareUrl);
copied = true;
setTimeout(() => copied = false, 2000);
}
</script>
{#if open}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-overlay" onclick={close}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-sheet" onclick={(e) => e.stopPropagation()}>
<div class="modal-header">
<div class="modal-title">Edit Trip</div>
<button class="modal-close" onclick={close}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="modal-body">
<div class="field">
<label class="field-label">Trip Name</label>
<input class="field-input" type="text" bind:value={name} />
</div>
<div class="field">
<label class="field-label">Description</label>
<textarea class="field-input field-textarea" bind:value={description} rows="3"></textarea>
</div>
<div class="field-row">
<div class="field"><label class="field-label">Start Date</label><input class="field-input" type="date" bind:value={startDate} /></div>
<div class="field"><label class="field-label">End Date</label><input class="field-input" type="date" bind:value={endDate} /></div>
</div>
<!-- Sharing -->
<div class="share-section">
<div class="share-header">
<span class="field-label">Sharing</span>
<button class="share-toggle" onclick={toggleShare} disabled={sharing}>
{shareUrl ? 'Revoke Link' : 'Create Share Link'}
</button>
</div>
{#if shareUrl}
<div class="share-link-row">
<input class="field-input share-url" type="text" readonly value={shareUrl} />
<button class="copy-btn" onclick={copyUrl}>{copied ? 'Copied!' : 'Copy'}</button>
</div>
{/if}
</div>
</div>
<div class="modal-footer">
{#if confirmDelete}
<div class="delete-confirm">
<span class="delete-msg">Delete this trip permanently?</span>
<button class="btn-danger" onclick={doDelete} disabled={saving}>Yes, delete</button>
<button class="btn-cancel" onclick={() => confirmDelete = false}>Cancel</button>
</div>
{:else}
<button class="btn-delete-text" onclick={() => confirmDelete = true}>Delete Trip</button>
{/if}
<div class="footer-right">
<button class="btn-cancel" onclick={close}>Cancel</button>
<button class="btn-save" onclick={save} disabled={saving || !name.trim()}>
{saving ? 'Saving...' : 'Save'}
</button>
</div>
</div>
</div>
</div>
{/if}
<style>
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.35); z-index: 70; display: flex; justify-content: flex-end; animation: modalFade 150ms ease; }
@keyframes modalFade { from { opacity: 0; } to { opacity: 1; } }
.modal-sheet { width: 480px; max-width: 100%; height: 100%; background: var(--surface); display: flex; flex-direction: column; box-shadow: -8px 0 32px rgba(0,0,0,0.1); animation: modalSlide 200ms ease; }
@keyframes modalSlide { from { transform: translateX(100%); } to { transform: translateX(0); } }
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border); }
.modal-title { font-size: var(--text-md); font-weight: 600; color: var(--text-1); }
.modal-close { background: none; border: none; cursor: pointer; color: var(--text-3); padding: var(--sp-1); border-radius: var(--radius-sm); }
.modal-close:hover { color: var(--text-1); background: var(--card-hover); }
.modal-close svg { width: 18px; height: 18px; }
.modal-body { flex: 1; overflow-y: auto; padding: var(--sp-5); display: flex; flex-direction: column; gap: 14px; }
.field { display: flex; flex-direction: column; gap: var(--sp-1); }
.field-label { font-size: var(--text-sm); font-weight: 500; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.04em; }
.field-input { padding: 10px 12px; border-radius: var(--radius-md); border: 1px solid var(--border); background: var(--surface-secondary); color: var(--text-1); font-size: var(--text-base); font-family: var(--font); }
.field-input:focus { outline: none; border-color: var(--accent); }
.field-textarea { resize: vertical; min-height: 60px; }
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.share-section { border-top: 1px solid var(--border); padding-top: 14px; margin-top: var(--sp-1); }
.share-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--sp-2); }
.share-toggle { font-size: var(--text-sm); font-weight: 500; color: var(--accent); background: none; border: none; cursor: pointer; font-family: var(--font); }
.share-toggle:hover { opacity: 0.7; }
.share-link-row { display: flex; gap: var(--sp-2); }
.share-url { flex: 1; font-size: var(--text-sm); font-family: var(--mono); }
.copy-btn { padding: 8px 14px; border-radius: var(--radius-md); background: var(--accent); color: white; border: none; font-size: var(--text-sm); font-weight: 600; cursor: pointer; font-family: var(--font); white-space: nowrap; }
.modal-footer { display: flex; align-items: center; justify-content: space-between; padding: 14px var(--sp-5); border-top: 1px solid var(--border); }
.footer-right { display: flex; gap: var(--sp-2); }
.btn-cancel { padding: 8px 14px; border-radius: var(--radius-md); background: var(--card-secondary); color: var(--text-2); border: 1px solid var(--border); font-size: var(--text-sm); font-weight: 500; cursor: pointer; font-family: var(--font); }
.btn-save { padding: var(--sp-2) var(--sp-4); border-radius: var(--radius-md); background: var(--accent); color: white; border: none; font-size: var(--text-sm); font-weight: 600; cursor: pointer; font-family: var(--font); }
.btn-save:disabled { opacity: 0.4; }
.btn-delete-text { font-size: var(--text-sm); color: var(--error); background: none; border: none; cursor: pointer; font-weight: 500; font-family: var(--font); }
.btn-delete-text:hover { opacity: 0.7; }
.delete-confirm { display: flex; align-items: center; gap: var(--sp-2); }
.delete-msg { font-size: var(--text-sm); color: var(--error); font-weight: 500; }
.btn-danger { padding: 6px var(--sp-3); border-radius: var(--radius-sm); background: var(--error); color: white; border: none; font-size: var(--text-sm); font-weight: 600; cursor: pointer; font-family: var(--font); }
@media (max-width: 768px) { .modal-sheet { width: 100%; } }
</style>

View File

@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

View File

@@ -0,0 +1,26 @@
let theme = $state<'light' | 'dark'>('light');
export function initTheme() {
if (typeof window === 'undefined') return;
const stored = localStorage.getItem('theme') as 'light' | 'dark' | null;
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
theme = stored ?? (prefersDark ? 'dark' : 'light');
applyTheme();
}
function applyTheme() {
if (typeof document === 'undefined') return;
const root = document.documentElement;
root.classList.remove('light', 'dark');
root.classList.add(theme);
}
export function toggleTheme() {
theme = theme === 'light' ? 'dark' : 'light';
localStorage.setItem('theme', theme);
applyTheme();
}
export function isDark(): boolean {
return theme === 'dark';
}

View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -0,0 +1,28 @@
import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
import { env } from '$env/dynamic/private';
const gatewayUrl = env.GATEWAY_URL || 'http://localhost:8100';
export const load: LayoutServerLoad = async ({ cookies, url }) => {
const session = cookies.get('platform_session');
if (!session) {
throw redirect(302, `/login?redirect=${encodeURIComponent(url.pathname)}`);
}
// Validate session is still active
try {
const res = await fetch(`${gatewayUrl}/api/auth/me`, {
headers: { Cookie: `platform_session=${session}` }
});
if (res.ok) {
const data = await res.json();
if (data.authenticated) {
return { user: data.user };
}
}
} catch { /* gateway down — let client handle */ }
// Invalid/expired session
throw redirect(302, `/login?redirect=${encodeURIComponent(url.pathname)}`);
};

View File

@@ -0,0 +1,58 @@
<script lang="ts">
import Navbar from '$lib/components/layout/Navbar.svelte';
import MobileTabBar from '$lib/components/layout/MobileTabBar.svelte';
import CommandPalette from '$lib/components/layout/CommandPalette.svelte';
let { children } = $props();
let commandOpen = $state(false);
function openCommand() {
commandOpen = true;
}
function closeCommand() {
commandOpen = false;
}
function handleKeydown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
commandOpen = !commandOpen;
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
<div class="app">
<Navbar onOpenCommand={openCommand} />
<main>
{@render children()}
</main>
<MobileTabBar />
<CommandPalette bind:open={commandOpen} onclose={closeCommand} />
</div>
<style>
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
main {
flex: 1;
padding: 16px;
width: 100%;
max-width: 640px;
margin: 0 auto;
}
@media (min-width: 768px) {
main {
padding: 20px;
max-width: 1200px;
}
}
</style>

View File

@@ -0,0 +1,127 @@
<script lang="ts">
import { onMount } from 'svelte';
import DashboardActionCard from '$lib/components/dashboard/DashboardActionCard.svelte';
import BudgetModule from '$lib/components/dashboard/BudgetModule.svelte';
import FitnessModule from '$lib/components/dashboard/FitnessModule.svelte';
import IssuesModule from '$lib/components/dashboard/IssuesModule.svelte';
let inventoryIssueCount = $state(0);
let inventoryReviewCount = $state(0);
let budgetUncatCount = $state(0);
let budgetSpending = $state('');
let budgetIncome = $state('');
let fitnessCalRemaining = $state(0);
let fitnessCalLogged = $state(0);
let fitnessProtein = $state(0);
let fitnessCarbs = $state(0);
onMount(async () => {
const n = new Date();
const today = `${n.getFullYear()}-${String(n.getMonth() + 1).padStart(2, '0')}-${String(n.getDate()).padStart(2, '0')}`;
try {
const [invRes, budgetRes, uncatRes, fitTotalsRes, fitGoalsRes] = await Promise.all([
fetch('/api/inventory/summary', { credentials: 'include' }),
fetch('/api/budget/summary', { credentials: 'include' }),
fetch('/api/budget/uncategorized-count', { credentials: 'include' }),
fetch(`/api/fitness/entries/totals?date=${today}`, { credentials: 'include' }),
fetch(`/api/fitness/goals/for-date?date=${today}`, { credentials: 'include' }),
]);
if (invRes.ok) {
const data = await invRes.json();
inventoryIssueCount = data.issueCount || 0;
inventoryReviewCount = data.reviewCount || 0;
}
if (budgetRes.ok) {
const data = await budgetRes.json();
budgetSpending = '$' + Math.abs(data.spendingDollars || 0).toLocaleString('en-US');
budgetIncome = '$' + Math.abs(data.incomeDollars || 0).toLocaleString('en-US');
}
if (uncatRes.ok) {
const data = await uncatRes.json();
budgetUncatCount = data.count || 0;
}
if (fitTotalsRes.ok) {
const t = await fitTotalsRes.json();
fitnessCalLogged = Math.round(t.total_calories || 0);
fitnessProtein = Math.round(t.total_protein || 0);
fitnessCarbs = Math.round(t.total_carbs || 0);
}
if (fitGoalsRes.ok) {
const g = await fitGoalsRes.json();
fitnessCalRemaining = Math.max(0, (g.calories || 2000) - fitnessCalLogged);
}
} catch { /* silent */ }
});
</script>
<div class="page">
<div class="app-surface">
<div class="page-header">
<div class="page-title">Dashboard</div>
<div class="page-greeting">Good to see you, <strong>Yusuf</strong></div>
</div>
<div class="action-cards">
<DashboardActionCard
title="{budgetUncatCount} uncategorized transactions"
description="{budgetSpending} spent &middot; {budgetIncome} income"
action="Review"
variant="budget"
size="primary"
href="/budget"
/>
<DashboardActionCard
title="{inventoryIssueCount} issue{inventoryIssueCount !== 1 ? 's' : ''} · {inventoryReviewCount} needs review"
description="{inventoryIssueCount + inventoryReviewCount} items need attention"
action="View"
variant="inventory"
href="/inventory"
/>
<DashboardActionCard
title="{fitnessCalRemaining.toLocaleString()} calories remaining today"
description="{fitnessCalLogged.toLocaleString()} cal logged &middot; {fitnessProtein}g protein &middot; {fitnessCarbs}g carbs"
action="Log food"
variant="fitness"
href="/fitness"
/>
</div>
<div class="modules-grid">
<BudgetModule />
<div class="right-stack">
<FitnessModule />
<IssuesModule />
</div>
</div>
</div>
</div>
<style>
.action-cards {
display: flex;
flex-direction: column;
gap: var(--section-gap);
margin-bottom: calc(var(--section-gap) + 8px);
padding-top: 4px;
}
.modules-grid {
display: grid;
grid-template-columns: 1.3fr 0.7fr;
gap: var(--module-gap);
align-items: start;
}
.right-stack {
display: flex;
flex-direction: column;
gap: var(--module-gap);
}
@media (max-width: 768px) {
.modules-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,763 @@
<script lang="ts">
import { onMount } from 'svelte';
// ── State (same names as template binds) ──
let activeView = $state<'transactions' | 'budget'>('transactions');
let activeTab = $state('all');
let accountsOpen = $state(false);
let selected = $state<Set<string>>(new Set());
let lastCategory = $state('');
let bulkCategoryOpen = $state(false);
let focusedRowId = $state('');
let loading = $state(true);
let saving = $state(false);
// ── Data (populated from API) ──
let budgetGroups = $state<{ name: string; categories: { name: string; budgeted: number; spent: number; available: number }[] }[]>([]);
let categories = $state<string[]>([]);
let categoryMap = $state<Record<string, string>>({}); // name → id
let accounts = $state<{ name: string; balance: number; positive: boolean; id?: string }[]>([]);
let offBudgetAccounts = $state<{ name: string; balance: number; positive: boolean; id?: string }[]>([]);
let suggestedTransfers = $state<any[]>([]);
let transactions = $state<{ id: string; date: string; payee: string; note: string; account: string; category: string; categoryType: string; amount: number; categoryId?: string; accountId?: string }[]>([]);
// ── Pagination ──
let hasMore = $state(true);
let loadingMore = $state(false);
let activeAccountId = $state<string | null>(null);
const PAGE_SIZE = 100;
// ── Header stats ──
let headerSpending = $state('...');
let headerIncome = $state('...');
let currentMonthLabel = $state('');
// Sort categories with last-used first
let sortedCategories = $derived(() => {
if (!lastCategory) return categories;
const rest = categories.filter(c => c !== lastCategory);
return [lastCategory, ...rest];
});
let canTransfer = $derived(selected.size === 2);
const filteredTransactions = $derived(() => {
if (activeTab === 'uncategorized') return transactions.filter(t => t.categoryType === 'uncat');
if (activeTab === 'categorized') return transactions.filter(t => t.categoryType !== 'uncat');
return transactions;
});
let totalUncatCount = $state(0);
const uncatCount = $derived(activeAccountId
? transactions.filter(t => t.categoryType === 'uncat').length
: totalUncatCount
);
// ── API helper ──
async function api(path: string, opts: RequestInit = {}) {
const res = await fetch(`/api/budget${path}`, { credentials: 'include', ...opts });
if (!res.ok) throw new Error(`${res.status}`);
return res.json();
}
// ── Load data ──
async function loadAccounts() {
try {
const data = await api('/accounts');
const onBudget: typeof accounts = [];
const offBudget: typeof offBudgetAccounts = [];
for (const a of data) {
if (a.closed) continue;
const entry = {
name: a.name,
balance: Math.round(a.balanceDollars),
positive: a.balanceDollars >= 0,
id: a.id
};
if (a.offbudget) offBudget.push(entry);
else onBudget.push(entry);
}
accounts = onBudget;
offBudgetAccounts = offBudget;
} catch { /* silent */ }
}
async function loadCategories() {
try {
const data = await api('/categories');
const allCats: string[] = [];
const map: Record<string, string> = {};
for (const group of data) {
for (const cat of group.categories) {
if (cat.name !== 'Starting Balances') {
allCats.push(cat.name);
map[cat.name] = cat.id;
}
}
}
categories = allCats;
categoryMap = map;
if (!lastCategory && allCats.length > 0) lastCategory = allCats[0];
} catch { /* silent */ }
}
function mapTransaction(t: any) {
return {
id: t.id,
date: formatDateShort(t.date),
payee: t.payeeName || t.payee || '',
note: t.notes || '',
account: t.accountName || '',
accountId: t.accountId || '',
category: t.categoryName || '',
categoryType: t.transfer_id ? 'transfer' : (t.categoryName ? 'normal' : 'uncat'),
amount: t.amountDollars || 0,
categoryId: t.categoryId || ''
};
}
async function loadTransactions(append = false) {
if (loadingMore) return;
if (!append) { loading = true; transactions = []; hasMore = true; }
else loadingMore = true;
try {
let data: any[];
if (activeAccountId) {
// Per-account: supports offset/limit pagination
const offset = append ? transactions.length : 0;
const resp = await api(`/transactions?accountId=${activeAccountId}&limit=${PAGE_SIZE}&offset=${offset}`);
data = resp.transactions || resp || [];
} else {
// All accounts: /recent doesn't support offset
// Load more when viewing uncategorized to capture all of them
const limit = activeTab === 'uncategorized'
? Math.max(500, (append ? transactions.length + PAGE_SIZE : PAGE_SIZE))
: (append ? transactions.length + PAGE_SIZE : PAGE_SIZE);
data = await api(`/transactions/recent?limit=${limit}`);
if (append) {
const existingIds = new Set(transactions.map(t => t.id));
data = data.filter((t: any) => !existingIds.has(t.id));
}
}
const mapped = (Array.isArray(data) ? data : []).map(mapTransaction);
if (append) {
transactions = [...transactions, ...mapped];
} else {
transactions = mapped;
}
hasMore = mapped.length >= PAGE_SIZE;
} catch { /* silent */ }
finally { loading = false; loadingMore = false; }
}
function loadMore() {
if (hasMore && !loadingMore) loadTransactions(true);
}
function selectAccount(accountId: string | null) {
activeAccountId = accountId;
loadTransactions();
}
async function loadBudget() {
try {
const now = new Date();
const month = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
const data = await api(`/budget/${month}`);
budgetGroups = (data.categoryGroups || [])
.filter((g: any) => g.categories?.length > 0)
.map((g: any) => ({
name: g.name,
categories: g.categories
.filter((c: any) => c.name !== 'Starting Balances')
.map((c: any) => ({
name: c.name,
budgeted: Math.round(c.budgeted / 100),
spent: Math.round(Math.abs(c.spent) / 100),
available: Math.round(c.balance / 100)
}))
}))
.filter((g: any) => g.categories.length > 0);
} catch { /* silent */ }
}
async function loadSuggested() {
try {
const data = await api('/suggested-transfers');
suggestedTransfers = data.map((s: any) => ({
id: s.from.id + '-' + s.to.id,
from: { account: s.from.account, payee: s.from.payee },
to: { account: s.to.account, payee: s.to.payee },
amount: s.amountDollars,
confidence: s.confidence,
fromId: s.from.id,
toId: s.to.id
}));
} catch { /* silent */ }
}
// ── Actions ──
async function categorize(id: string, category: string) {
if (!category) return;
lastCategory = category;
let catId = categoryMap[category];
// If category ID not found, refresh category map and retry
if (!catId) {
await loadCategories();
catId = categoryMap[category];
if (!catId) return; // Still not found, bail
}
// Save previous state for revert
const prev = transactions.find(t => t.id === id);
const prevCat = prev?.category || '';
const prevType = prev?.categoryType || 'uncat';
// Optimistic update
const wasUncat = prevType === 'uncat';
transactions = transactions.map(t =>
t.id === id ? { ...t, category, categoryType: 'normal', categoryId: catId } : t
);
if (wasUncat) totalUncatCount = Math.max(0, totalUncatCount - 1);
// Persist to backend
try {
await api(`/transactions/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ category: catId })
});
} catch {
// Revert on failure
transactions = transactions.map(t =>
t.id === id ? { ...t, category: prevCat, categoryType: prevType, categoryId: '' } : t
);
if (wasUncat) totalUncatCount++;
}
}
async function bulkCategorize(category: string) {
if (!category) return;
lastCategory = category;
const catId = categoryMap[category];
const ids = Array.from(selected).filter(id => {
const t = transactions.find(tx => tx.id === id);
return t && t.categoryType === 'uncat';
});
// Optimistic update
transactions = transactions.map(t =>
ids.includes(t.id) ? { ...t, category, categoryType: 'normal', categoryId: catId } : t
);
totalUncatCount = Math.max(0, totalUncatCount - ids.length);
selected = new Set();
bulkCategoryOpen = false;
// Persist each to backend
for (const id of ids) {
try {
await api(`/transactions/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ category: catId })
});
} catch { /* silent - optimistic already applied */ }
}
}
async function makeTransfer(fromId: string, toId: string) {
saving = true;
try {
await api('/make-transfer', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ transactionId1: fromId, transactionId2: toId })
});
// Reload transactions to reflect transfer state
await loadTransactions();
} catch { /* silent */ }
finally { saving = false; }
}
async function linkSelectedAsTransfer() {
if (selected.size !== 2) return;
const ids = Array.from(selected);
await makeTransfer(ids[0], ids[1]);
selected = new Set();
}
async function linkSuggestedTransfer(suggestion: any) {
await makeTransfer(suggestion.fromId, suggestion.toId);
suggestedTransfers = suggestedTransfers.filter(s => s.id !== suggestion.id);
}
function dismissSuggestion(id: string) {
suggestedTransfers = suggestedTransfers.filter(s => s.id !== id);
}
function toggleSelect(id: string) {
const next = new Set(selected);
if (next.has(id)) next.delete(id); else next.add(id);
selected = next;
}
function handleRowKeydown(e: KeyboardEvent, txnId: string) {
if (e.key === 'c' || e.key === 'C') {
e.preventDefault();
const row = (e.target as HTMLElement).closest('.txn-row');
const select = row?.querySelector('.cat-select') as HTMLSelectElement | null;
if (select) { select.focus(); select.click(); }
}
}
// ── Formatters ──
function formatDateShort(dateStr: string): string {
const d = new Date(dateStr + 'T00:00:00');
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
function formatBudgetAmount(amount: number): string {
return '$' + Math.abs(amount).toLocaleString('en-US');
}
function formatBalance(balance: number): string {
const abs = Math.abs(balance);
return (balance < 0 ? '-' : '') + '$' + abs.toLocaleString('en-US');
}
function formatAmount(amount: number): string {
const abs = Math.abs(amount);
const formatted = '$' + abs.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
return amount >= 0 ? '+' + formatted : '-' + formatted;
}
async function loadSummary() {
try {
const [summary, uncat] = await Promise.all([
api('/summary'),
api('/uncategorized-count')
]);
headerSpending = '$' + Math.abs(summary.spendingDollars || 0).toLocaleString('en-US');
headerIncome = '$' + Math.abs(summary.incomeDollars || 0).toLocaleString('en-US');
totalUncatCount = uncat.count || 0;
const m = summary.month || '';
if (m) {
const d = new Date(m + '-01');
currentMonthLabel = d.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
}
} catch { /* silent */ }
}
// ── Init ──
onMount(async () => {
await Promise.all([loadAccounts(), loadCategories(), loadTransactions(), loadBudget(), loadSuggested(), loadSummary()]);
loading = false;
});
</script>
<div class="budget-page">
<div class="budget-layout">
<!-- Desktop sidebar -->
<aside class="budget-sidebar desktop-only">
<div class="sidebar-header">Budget</div>
<div class="sidebar-nav">
<button class="sidebar-nav-item" class:active={activeView === 'transactions' && !activeAccountId} onclick={() => { activeView = 'transactions'; selectAccount(null); }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
All Transactions
{#if uncatCount > 0}<span class="sidebar-badge">{uncatCount}</span>{/if}
</button>
<button class="sidebar-nav-item" class:active={activeView === 'budget'} onclick={() => activeView = 'budget'}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M2 10h20"/></svg>
Budget
</button>
</div>
<div class="sidebar-accounts">
<div class="acct-group-header"><span>Budget</span><span class="acct-group-total positive">{formatBalance(accounts.reduce((s, a) => s + a.balance, 0))}</span></div>
{#each accounts as acct}
<button class="acct-row" class:active={activeAccountId === acct.id} onclick={() => { activeView = 'transactions'; selectAccount(acct.id || null); }}><span class="acct-name">{acct.name}</span><span class="acct-bal" class:positive={acct.positive} class:negative={!acct.positive}>{formatBalance(acct.balance)}</span></button>
{/each}
<div class="acct-group-header" style="margin-top:8px"><span>Off Budget</span><span class="acct-group-total">{formatBalance(offBudgetAccounts.reduce((s, a) => s + a.balance, 0))}</span></div>
{#each offBudgetAccounts as acct}
<button class="acct-row" class:active={activeAccountId === acct.id} onclick={() => { activeView = 'transactions'; selectAccount(acct.id || null); }}><span class="acct-name">{acct.name}</span><span class="acct-bal positive">{formatBalance(acct.balance)}</span></button>
{/each}
</div>
</aside>
<!-- Main workspace -->
<div class="budget-main">
<div class="budget-header">
<div>
{#if activeAccountId}
<button class="back-to-all" onclick={() => selectAccount(null)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 18l-6-6 6-6"/></svg>
All Transactions
</button>
<div class="budget-title">{accounts.find(a => a.id === activeAccountId)?.name || offBudgetAccounts.find(a => a.id === activeAccountId)?.name || 'Account'}</div>
{:else}
<div class="budget-label">Budget</div>
<div class="budget-title">{currentMonthLabel} · <strong>{headerSpending}</strong> spent</div>
<div class="budget-meta">{headerIncome} income · {uncatCount} uncategorized</div>
{/if}
</div>
<button class="accounts-trigger mobile-only" onclick={() => accountsOpen = !accountsOpen}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M2 10h20"/></svg>
Accounts
<svg viewBox="0 0 10 6" fill="none" class="chevron" class:open={accountsOpen}><path d="M1 1l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</div>
<!-- Mobile view toggle -->
<div class="view-toggle mobile-only">
<button class="view-btn" class:active={activeView === 'transactions'} onclick={() => activeView = 'transactions'}>Transactions</button>
<button class="view-btn" class:active={activeView === 'budget'} onclick={() => activeView = 'budget'}>Budget</button>
</div>
{#if accountsOpen}
<div class="mobile-accounts">
<div class="acct-group-header"><span>Budget</span><span class="acct-group-total positive">{formatBalance(accounts.reduce((s, a) => s + a.balance, 0))}</span></div>
{#each accounts as acct}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="mobile-acct-row" onclick={() => { selectAccount(acct.id || null); accountsOpen = false; }}><span class="acct-name">{acct.name}</span><span class="acct-bal" class:positive={acct.positive} class:negative={!acct.positive}>{formatBalance(acct.balance)}</span></div>
{/each}
<div class="acct-group-header" style="margin-top:8px"><span>Off Budget</span><span class="acct-group-total">{formatBalance(offBudgetAccounts.reduce((s, a) => s + a.balance, 0))}</span></div>
{#each offBudgetAccounts as acct}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="mobile-acct-row" onclick={() => { selectAccount(acct.id || null); accountsOpen = false; }}><span class="acct-name">{acct.name}</span><span class="acct-bal positive">{formatBalance(acct.balance)}</span></div>
{/each}
</div>
{/if}
{#if activeView === 'transactions'}
<!-- Suggested Transfers -->
{#if suggestedTransfers.length > 0}
<div class="suggestions">
<div class="suggestions-header">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:16px;height:16px;color:var(--accent)"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
<span class="suggestions-title">Suggested Transfers</span>
<span class="suggestions-count">{suggestedTransfers.length}</span>
</div>
{#each suggestedTransfers as s}
<div class="suggestion-row">
<div class="suggestion-pair">
<div class="suggestion-acct">{s.from.account}</div>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;color:var(--text-4);flex-shrink:0"><path d="M5 12h14"/><polyline points="12 5 19 12 12 19"/></svg>
<div class="suggestion-acct">{s.to.account}</div>
</div>
<div class="suggestion-amount">${s.amount.toLocaleString('en-US', { minimumFractionDigits: 2 })}</div>
<div class="suggestion-actions">
<button class="sug-btn link" onclick={() => linkSuggestedTransfer(s)}>Link</button>
<button class="sug-btn skip" onclick={() => dismissSuggestion(s.id)}>Skip</button>
</div>
</div>
{/each}
</div>
{/if}
<!-- Selection bar -->
{#if selected.size > 0}
<div class="selection-bar">
<span class="selection-count">{selected.size} selected</span>
{#if canTransfer}
<button class="transfer-btn" onclick={linkSelectedAsTransfer}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
Make Transfer
</button>
{/if}
<div class="bulk-cat">
{#if bulkCategoryOpen}
<select class="bulk-cat-select" onchange={(e) => bulkCategorize((e.target as HTMLSelectElement).value)}>
<option value="">Apply category...</option>
{#each sortedCategories() as cat}
<option value={cat}>{cat}</option>
{/each}
</select>
{:else}
<button class="bulk-cat-btn" onclick={() => bulkCategoryOpen = true}>
Set Category
</button>
{/if}
</div>
<button class="clear-btn" onclick={() => { selected = new Set(); bulkCategoryOpen = false; }}>Clear</button>
</div>
{/if}
<!-- Tabs -->
<div class="budget-tabs">
<button class="tab" class:active={activeTab === 'all'} onclick={() => { activeTab = 'all'; loadTransactions(); }}>All</button>
<button class="tab" class:active={activeTab === 'uncategorized'} onclick={() => { activeTab = 'uncategorized'; loadTransactions(); }}>Uncategorized <span class="tab-badge">{uncatCount}</span></button>
<button class="tab" class:active={activeTab === 'categorized'} onclick={() => { activeTab = 'categorized'; loadTransactions(); }}>Categorized</button>
</div>
<!-- Transactions -->
<div class="txn-card">
{#each filteredTransactions() as txn (txn.id)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="txn-row"
class:txn-uncat={txn.categoryType === 'uncat'}
class:txn-selected={selected.has(txn.id)}
tabindex="0"
onkeydown={(e) => handleRowKeydown(e, txn.id)}
>
<input type="checkbox" class="txn-check" checked={selected.has(txn.id)} onchange={() => toggleSelect(txn.id)} />
<div class="txn-date">{txn.date}</div>
<div class="txn-payee">
{#if txn.categoryType === 'transfer'}
<div class="txn-name transfer-name">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:13px;height:13px;flex-shrink:0"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
{txn.payee}
</div>
{:else}
<div class="txn-name">{txn.payee}</div>
{#if txn.note}<div class="txn-note">{txn.note}</div>{/if}
{/if}
</div>
<div class="txn-account">{txn.account}</div>
<div class="txn-category">
{#if txn.categoryType === 'transfer'}
<span class="cat-pill transfer">Transfer</span>
{:else if txn.categoryType === 'uncat'}
<select class="cat-select" onchange={(e) => categorize(txn.id, (e.target as HTMLSelectElement).value)}>
<option value="">Select category</option>
{#each sortedCategories() as cat}
<option value={cat}>{cat}</option>
{/each}
</select>
{:else}
<span class="cat-pill">{txn.category}</span>
{/if}
</div>
<div class="txn-amount" class:pos={txn.amount >= 0} class:neg={txn.amount < 0}>{formatAmount(txn.amount)}</div>
</div>
{/each}
{#if hasMore}
<button class="load-more-btn" onclick={loadMore} disabled={loadingMore}>
{loadingMore ? 'Loading...' : 'Load more transactions'}
</button>
{/if}
</div>
{:else}
<!-- Budget Overview -->
<div class="budget-overview">
{#each budgetGroups as group}
<div class="budget-group">
<div class="budget-group-header">{group.name}</div>
<div class="budget-table">
<div class="budget-table-header">
<span class="bt-name">Category</span>
<span class="bt-val">Budgeted</span>
<span class="bt-val">Spent</span>
<span class="bt-val">Available</span>
</div>
{#each group.categories as cat}
<div class="budget-table-row" class:overspent={cat.available < 0}>
<span class="bt-name">{cat.name}</span>
<span class="bt-val">{formatBudgetAmount(cat.budgeted)}</span>
<span class="bt-val spent">{formatBudgetAmount(cat.spent)}</span>
<span class="bt-val" class:positive={cat.available > 0} class:negative={cat.available < 0}>{cat.available < 0 ? '-' : ''}{formatBudgetAmount(Math.abs(cat.available))}</span>
</div>
{/each}
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
<style>
.budget-page { padding: 0; margin: -16px; }
.budget-layout { display: flex; min-height: calc(100vh - 56px); }
.desktop-only { display: none; }
@media (min-width: 768px) { .desktop-only { display: flex; } }
.mobile-only { display: flex; }
@media (min-width: 768px) { .mobile-only { display: none; } }
/* ── Sidebar ── */
.budget-sidebar { width: 250px; flex-shrink: 0; border-right: 1px solid var(--border); background: var(--surface); flex-direction: column; overflow-y: auto; }
.sidebar-header { font-size: var(--text-md); font-weight: 600; padding: var(--sp-5) var(--sp-4) var(--sp-3); color: var(--text-1); }
.sidebar-nav { padding: 0 var(--sp-2) var(--sp-2); }
.sidebar-nav-item { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 9px 12px; background: none; border: none; border-radius: var(--radius-md); font-size: var(--text-sm); font-weight: 500; color: var(--text-3); cursor: pointer; transition: all 150ms; text-align: left; font-family: var(--font); }
.sidebar-nav-item:hover { color: var(--text-1); background: var(--card-hover); }
.sidebar-nav-item.active { color: var(--text-1); background: var(--accent-dim); }
.sidebar-nav-item :global(svg) { width: 16px; height: 16px; flex-shrink: 0; }
.sidebar-badge { margin-left: auto; font-size: var(--text-xs); font-family: var(--mono); background: var(--accent-dim); color: var(--accent); padding: 1px 6px; border-radius: var(--radius-xs); }
.sidebar-accounts { padding: var(--sp-2) 0; border-top: 1px solid var(--border); flex: 1; }
.acct-group-header { display: flex; justify-content: space-between; padding: var(--sp-2) var(--sp-4) var(--sp-1); font-size: var(--text-xs); font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-4); }
.acct-group-total { font-family: var(--mono); font-size: var(--text-xs); color: var(--text-3); }
.acct-group-total.positive { color: var(--success); }
.acct-row { display: flex; align-items: center; width: 100%; padding: 6px 16px; background: none; border: none; font-size: var(--text-sm); color: var(--text-2); cursor: pointer; transition: background 150ms; text-align: left; font-family: var(--font); }
.acct-row:hover { background: var(--card-hover); }
.acct-row.active { background: var(--accent-dim); color: var(--accent); }
.load-more-btn {
display: block; width: 100%; padding: var(--sp-3); margin-top: var(--sp-1);
background: none; border: 1px dashed var(--border); border-radius: var(--radius-sm);
font-size: var(--text-sm); font-weight: 500; color: var(--text-3); cursor: pointer;
font-family: var(--font); transition: all var(--transition);
}
.load-more-btn:hover { background: var(--card-hover); color: var(--text-1); }
.load-more-btn:disabled { opacity: 0.5; cursor: default; }
.acct-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.acct-bal { margin-left: auto; font-family: var(--mono); font-size: var(--text-sm); flex-shrink: 0; }
.acct-bal.positive { color: var(--success); }
.acct-bal.negative { color: var(--error); }
/* ── Main ── */
.budget-main { flex: 1; min-width: 0; padding: var(--sp-6); overflow-y: auto; }
.budget-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: var(--sp-4); }
.budget-label { font-size: var(--text-sm); font-weight: 600; color: var(--text-4); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: var(--sp-1); }
.back-to-all {
display: inline-flex; align-items: center; gap: var(--sp-1);
font-size: var(--text-sm); color: var(--text-3); background: none; border: none;
cursor: pointer; font-family: var(--font); padding: 0; margin-bottom: var(--sp-1);
transition: color var(--transition);
}
.back-to-all:hover { color: var(--accent); }
.back-to-all svg { width: 14px; height: 14px; }
.budget-title { font-size: var(--text-xl); font-weight: 300; color: var(--text-1); line-height: 1.2; }
.budget-meta { font-size: var(--text-sm); color: var(--text-3); margin-top: var(--sp-1); }
/* Mobile trigger */
.accounts-trigger { align-items: center; gap: var(--sp-1.5); padding: var(--sp-2) var(--sp-3); border-radius: var(--radius-md); background: var(--card); border: 1px solid var(--border); font-size: var(--text-sm); font-weight: 500; color: var(--text-2); cursor: pointer; font-family: var(--font); transition: all var(--transition); flex-shrink: 0; }
.accounts-trigger:hover { background: var(--card-hover); }
.accounts-trigger :global(svg) { width: 16px; height: 16px; }
.chevron { width: 10px; height: 10px; transition: transform 200ms; }
.chevron.open { transform: rotate(180deg); }
.mobile-accounts { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); padding: var(--sp-3) var(--sp-4); margin-bottom: var(--sp-4); }
.mobile-acct-row { display: flex; justify-content: space-between; padding: var(--sp-2) 0; font-size: var(--text-sm); color: var(--text-2); cursor: pointer; transition: color var(--transition); }
.mobile-acct-row:hover { color: var(--text-1); }
/* ── Suggested Transfers ── */
.suggestions { margin-bottom: var(--sp-4); padding: 14px; border-radius: var(--radius); background: color-mix(in srgb, var(--accent-dim) 60%, transparent); border: 1px solid var(--accent-border); }
.suggestions-header { display: flex; align-items: center; gap: var(--sp-2); margin-bottom: 10px; }
.suggestions-title { font-size: var(--text-sm); font-weight: 600; color: var(--text-2); }
.suggestions-count { font-size: var(--text-xs); font-family: var(--mono); background: var(--accent); color: white; padding: 1px 5px; border-radius: var(--radius-xs); }
.suggestion-row { display: flex; align-items: center; gap: var(--sp-3); padding: 9px 12px; border-radius: var(--radius-md); background: var(--card); margin-bottom: var(--sp-1); transition: background var(--transition); }
.suggestion-row:last-child { margin-bottom: 0; }
.suggestion-row:hover { background: var(--card-hover); }
.suggestion-pair { display: flex; align-items: center; gap: var(--sp-2); flex: 1; min-width: 0; }
.suggestion-acct { font-size: var(--text-sm); font-weight: 500; color: var(--text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.suggestion-amount { font-family: var(--mono); font-size: var(--text-sm); font-weight: 500; color: var(--text-3); flex-shrink: 0; }
.suggestion-actions { display: flex; gap: var(--sp-1); flex-shrink: 0; }
.sug-btn { padding: 5px 11px; border-radius: var(--radius-sm); font-size: var(--text-sm); font-weight: 600; border: none; cursor: pointer; font-family: var(--font); transition: all var(--transition); }
.sug-btn.link { background: var(--accent); color: white; }
.sug-btn.link:hover { opacity: 0.9; }
.sug-btn.skip { background: none; color: var(--text-4); }
.sug-btn.skip:hover { color: var(--text-2); }
/* ── Selection bar ── */
.selection-bar { display: flex; align-items: center; gap: var(--sp-3); padding: 10px var(--sp-4); margin-bottom: var(--sp-3); border-radius: var(--radius); background: var(--accent-dim); border: 1px solid var(--accent-focus); }
.selection-count { font-size: var(--text-base); font-weight: 600; color: var(--text-1); }
.selection-hint { font-size: var(--text-sm); color: var(--text-3); }
.transfer-btn { display: flex; align-items: center; gap: var(--sp-1.5); padding: 6px 14px; border-radius: var(--radius-sm); background: var(--accent); color: white; border: none; font-size: var(--text-sm); font-weight: 600; cursor: pointer; font-family: var(--font); }
.bulk-cat { display: flex; align-items: center; }
.bulk-cat-btn { padding: 6px 14px; border-radius: var(--radius-sm); background: var(--card); border: 1px solid var(--border); font-size: var(--text-sm); font-weight: 500; color: var(--text-2); cursor: pointer; font-family: var(--font); transition: all var(--transition); }
.bulk-cat-btn:hover { border-color: var(--accent); color: var(--accent); }
.bulk-cat-select { padding: 6px 10px; border-radius: var(--radius-sm); font-size: var(--text-sm); font-weight: 500; border: 1px solid var(--accent); background: var(--card); color: var(--text-1); font-family: var(--font); cursor: pointer; min-width: 140px; }
.bulk-cat-select:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
.clear-btn { margin-left: auto; background: none; border: none; font-size: var(--text-sm); color: var(--text-3); cursor: pointer; font-family: var(--font); }
.clear-btn:hover { color: var(--text-1); }
/* ── Tabs ── */
.budget-tabs { display: flex; gap: var(--sp-1); margin-bottom: var(--sp-4); }
.tab { padding: var(--sp-2) 14px; border-radius: var(--radius-md); font-size: var(--text-base); font-weight: 500; color: var(--text-3); background: none; border: none; cursor: pointer; transition: all var(--transition); font-family: var(--font); }
.tab:hover { color: var(--text-1); background: var(--card-hover); }
.tab.active { color: var(--text-1); background: var(--card); box-shadow: var(--shadow-xs); }
.tab-badge { font-size: var(--text-xs); font-family: var(--mono); background: var(--accent-dim); color: var(--accent); padding: 1px 6px; border-radius: var(--radius-xs); margin-left: var(--sp-1); }
/* ── Transaction card ── */
.txn-card { background: var(--card); border-radius: var(--radius); border: 1px solid var(--border); box-shadow: var(--shadow-md); overflow: hidden; }
.txn-row { display: flex; align-items: center; gap: 14px; padding: 15px 16px; transition: background var(--transition); }
.txn-row:hover { background: var(--card-hover); }
.txn-row + .txn-row { border-top: 1px solid var(--border); }
.txn-row:nth-child(even) { background: color-mix(in srgb, var(--surface) 68%, var(--card)); }
.txn-row:nth-child(even):hover { background: var(--card-hover); }
.txn-row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; z-index: 1; }
.txn-uncat { border-left: 4px solid var(--warning); }
.txn-selected { background: color-mix(in srgb, var(--accent) 6%, var(--card)) !important; border-left: 4px solid var(--accent); }
.txn-check { width: 16px; height: 16px; accent-color: var(--accent); cursor: pointer; flex-shrink: 0; }
.txn-date { font-size: var(--text-sm); color: var(--text-4); width: 48px; flex-shrink: 0; }
.txn-payee { flex: 1.5; min-width: 0; }
.txn-name { font-size: var(--text-base); font-weight: 600; color: var(--text-1); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.transfer-name { display: flex; align-items: center; gap: var(--sp-1.5); color: var(--accent); font-weight: 500; }
.txn-note { font-size: var(--text-xs); color: var(--text-4); margin-top: var(--sp-0.5); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.txn-account { flex: 0.7; font-size: var(--text-sm); color: var(--text-4); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.txn-category { flex: 0.9; padding-right: var(--sp-2); }
.cat-pill { display: inline-block; padding: var(--sp-1) 10px; border-radius: var(--radius-sm); font-size: var(--text-sm); font-weight: 500; background: var(--accent-dim); color: var(--accent); }
.cat-pill.transfer { background: var(--accent-dim); color: var(--accent); font-weight: 500; }
.cat-select {
padding: 8px 10px;
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 500;
border: 1px solid color-mix(in srgb, var(--warning) 50%, var(--border));
background: color-mix(in srgb, var(--warning) 2%, var(--card));
color: var(--text-1);
font-family: var(--font);
cursor: pointer;
width: 100%;
max-width: 160px;
min-height: 38px;
transition: all var(--transition);
}
.cat-select:hover { border-color: var(--accent); background: var(--card); }
.cat-select:focus { outline: 2px solid var(--accent); outline-offset: 1px; border-color: var(--accent); background: var(--card); }
.txn-amount { font-family: var(--mono); font-size: var(--text-base); font-weight: 600; text-align: right; min-width: 90px; padding-left: var(--sp-2); }
.txn-amount.pos { color: var(--success); }
.txn-amount.neg { color: var(--error); }
/* ── Sidebar refinement ── */
.acct-row { padding: 5px 16px; font-size: var(--text-xs); }
.acct-bal { font-size: var(--text-xs); }
/* ── View toggle (mobile) ── */
.view-toggle { gap: var(--sp-0.5); padding: 3px; background: var(--surface); border-radius: 10px; border: 1px solid var(--border); margin-bottom: var(--sp-4); }
.view-btn { flex: 1; padding: 7px 0; border-radius: var(--radius-md); font-size: var(--text-sm); font-weight: 500; border: none; background: none; color: var(--text-3); cursor: pointer; font-family: var(--font); transition: all var(--transition); }
.view-btn.active { background: var(--card); color: var(--text-1); box-shadow: var(--shadow-xs); }
/* ── Budget Overview ── */
.budget-overview { display: flex; flex-direction: column; gap: var(--sp-6); }
.budget-group-header { font-size: var(--text-xs); font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-4); margin-bottom: var(--sp-2); }
.budget-table { background: var(--card); border-radius: var(--radius); border: 1px solid var(--border); box-shadow: var(--shadow-sm); overflow: hidden; }
.budget-table-header { display: flex; padding: 10px 16px; font-size: var(--text-xs); font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-4); border-bottom: 1px solid var(--border); }
.budget-table-row { display: flex; padding: 14px 16px; cursor: default; }
.budget-table-row + .budget-table-row { border-top: 1px solid var(--border); }
.budget-table-row:nth-child(even) { background: color-mix(in srgb, var(--surface) 55%, var(--card)); }
.budget-table-row.overspent { border-left: 3px solid var(--error); }
.bt-name { flex: 1.5; font-size: var(--text-base); font-weight: 400; color: var(--text-2); }
.budget-table-header .bt-name { font-size: var(--text-xs); font-weight: 600; color: var(--text-4); }
.bt-val { flex: 1; text-align: right; font-family: var(--mono); font-size: var(--text-base); font-weight: 600; color: var(--text-1); }
.budget-table-header .bt-val { font-family: var(--font); font-size: var(--text-xs); font-weight: 600; color: var(--text-4); }
.bt-val.spent { color: var(--error); }
.bt-val.positive { color: var(--success); font-weight: 600; }
.bt-val.negative { color: var(--error); font-weight: 600; }
/* ── Mobile ── */
@media (max-width: 767px) {
.budget-page { margin: -16px; }
.budget-main { padding: var(--sp-4) var(--sp-4) var(--sp-20); }
.txn-account { display: none; }
.txn-row { gap: 10px; padding: 16px 14px; }
.txn-payee { flex: 1.2; }
.txn-category { flex: 1; }
.txn-date { width: 42px; font-size: var(--text-sm); }
.txn-name { font-size: var(--text-base); }
.txn-amount { min-width: 75px; font-size: var(--text-sm); }
.cat-select { max-width: none; min-height: 40px; font-size: var(--text-base); }
.suggestion-pair { flex-direction: column; align-items: flex-start; gap: var(--sp-0.5); }
.suggestion-row { flex-wrap: wrap; }
.selection-bar { flex-wrap: wrap; gap: var(--sp-2); }
}
@media (min-width: 768px) {
.budget-page { margin: -20px; }
.mobile-accounts { display: none; }
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,149 @@
<script lang="ts">
import { onMount } from 'svelte';
interface FoodItem { id: string; name: string; info: string; calories: number; }
let searchQuery = $state('');
let foods = $state<FoodItem[]>([]);
let loading = $state(true);
function mapFood(f: any): FoodItem {
const id = f.id || f.food_id || '';
const name = f.name || f.snapshot_food_name || 'Unknown';
const brand = f.brand ? `${f.brand} · ` : '';
const unit = f.base_unit === '100g' ? '100g' : (f.servings?.[0]?.label || f.base_unit || 'serving');
return { id, name, info: `${brand}${unit}`, calories: Math.round(f.calories_per_base || 0) };
}
async function loadFoods(query?: string) {
loading = true;
try {
const url = query
? `/api/fitness/foods/search?q=${encodeURIComponent(query)}&limit=50`
: `/api/fitness/foods?limit=50`;
const res = await fetch(url, { credentials: 'include' });
if (res.ok) {
const raw = await res.json();
foods = (Array.isArray(raw) ? raw : []).map(mapFood);
}
} catch { /* silent */ }
loading = false;
}
let debounceTimer: ReturnType<typeof setTimeout>;
$effect(() => {
const q = searchQuery;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => loadFoods(q || undefined), q ? 300 : 0);
});
const filteredFoods = $derived(foods);
</script>
<div class="page">
<div class="app-surface">
<div class="page-header">
<div class="page-title">FITNESS</div>
<div class="page-subtitle">Food Library</div>
</div>
<div class="search-bar">
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input
class="input search-input"
type="text"
placeholder="Search foods..."
bind:value={searchQuery}
/>
</div>
<div class="food-list">
{#each filteredFoods as food (food.name)}
<div class="food-row">
<div class="food-info">
<div class="food-name">{food.name}</div>
<div class="food-meta">{food.info}</div>
</div>
<div class="food-right">
<span class="food-cal">{food.calories}</span>
<button class="btn-icon add-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
</div>
</div>
{/each}
</div>
</div>
</div>
<style>
.page-subtitle {
font-size: var(--text-2xl);
font-weight: 300;
color: var(--text-1);
line-height: 1.2;
}
.search-bar {
position: relative;
margin-bottom: var(--section-gap);
}
.search-icon {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
width: 16px;
height: 16px;
color: var(--text-3);
pointer-events: none;
}
.search-input {
padding-left: 40px;
}
.food-list {
display: flex;
flex-direction: column;
gap: 0;
}
.food-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 0;
border-bottom: 1px solid var(--border);
}
.food-row:last-child { border-bottom: none; }
.food-name {
font-size: var(--text-base);
font-weight: 500;
color: var(--text-1);
}
.food-meta {
font-size: var(--text-sm);
color: var(--text-3);
margin-top: var(--sp-0.5);
}
.food-right {
display: flex;
align-items: center;
gap: var(--sp-3);
flex-shrink: 0;
}
.food-cal {
font-size: var(--text-base);
font-family: var(--mono);
color: var(--text-1);
}
.add-btn {
width: 32px;
height: 32px;
}
@media (max-width: 768px) {
.page-subtitle { font-size: var(--text-xl); }
}
</style>

View File

@@ -0,0 +1,157 @@
<script lang="ts">
import { onMount } from 'svelte';
let goals = $state([
{ label: 'Calories', value: '...', unit: 'kcal/day' },
{ label: 'Protein', value: '...', unit: 'grams/day' },
{ label: 'Carbs', value: '...', unit: 'grams/day' },
{ label: 'Fat', value: '...', unit: 'grams/day' }
]);
let startDate = $state('');
onMount(async () => {
try {
const n = new Date();
const today = `${n.getFullYear()}-${String(n.getMonth() + 1).padStart(2, '0')}-${String(n.getDate()).padStart(2, '0')}`;
const res = await fetch(`/api/fitness/goals/for-date?date=${today}`, { credentials: 'include' });
if (res.ok) {
const g = await res.json();
goals = [
{ label: 'Calories', value: (g.calories || 0).toLocaleString(), unit: 'kcal/day' },
{ label: 'Protein', value: String(g.protein || 0), unit: 'grams/day' },
{ label: 'Carbs', value: String(g.carbs || 0), unit: 'grams/day' },
{ label: 'Fat', value: String(g.fat || 0), unit: 'grams/day' },
];
if (g.start_date) {
const d = new Date(g.start_date + 'T00:00:00');
startDate = d.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
}
}
} catch { /* silent */ }
});
</script>
<div class="page">
<div class="app-surface">
<div class="page-header">
<div class="page-title">FITNESS</div>
<div class="page-subtitle">Daily Goals</div>
</div>
<div class="module">
<div class="module-header">
<div class="module-title">CURRENT GOALS</div>
<button class="module-action">Edit Goals</button>
</div>
<div class="goals-grid">
{#each goals as goal}
<div class="goal-card">
<div class="goal-label">{goal.label}</div>
<div class="goal-value">{goal.value}</div>
<div class="goal-unit">{goal.unit}</div>
</div>
{/each}
</div>
<div class="start-date">
<span class="start-date-label">Start date</span>
<span class="start-date-value">{startDate || '—'}</span>
</div>
</div>
</div>
</div>
<style>
.page-subtitle {
font-size: var(--text-2xl);
font-weight: 300;
color: var(--text-1);
line-height: 1.2;
}
.module {
background: var(--card);
border-radius: var(--radius);
padding: var(--card-pad-primary);
box-shadow: var(--card-shadow);
border: 1px solid var(--border);
}
.module-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--sp-5);
}
.module-title {
font-size: var(--text-sm);
font-weight: 600;
color: var(--text-3);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.module-action {
font-size: var(--text-sm);
color: var(--accent);
font-weight: 500;
cursor: pointer;
background: none;
border: none;
padding: 0;
}
.module-action:hover { text-decoration: underline; }
.goals-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--row-gap);
margin-bottom: var(--sp-5);
}
.goal-card {
background: var(--surface-secondary);
border-radius: var(--radius-sm);
padding: var(--sp-4);
border: 1px solid var(--border);
}
.goal-label {
font-size: var(--text-sm);
font-weight: 500;
color: var(--text-3);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: var(--sp-2);
}
.goal-value {
font-size: var(--text-xl);
font-weight: 500;
font-family: var(--mono);
color: var(--text-1);
line-height: 1;
}
.goal-unit {
font-size: var(--text-sm);
color: var(--text-3);
margin-top: var(--sp-1);
}
.start-date {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: var(--sp-4);
border-top: 1px solid var(--border);
}
.start-date-label {
font-size: var(--text-sm);
color: var(--text-3);
}
.start-date-value {
font-size: var(--text-sm);
color: var(--text-2);
font-weight: 500;
}
@media (max-width: 768px) {
.page-subtitle { font-size: var(--text-xl); }
}
</style>

View File

@@ -0,0 +1,136 @@
<script lang="ts">
import { onMount } from 'svelte';
interface Template { id: string; name: string; calories: number; items: number; meal: string; }
let templates = $state<Template[]>([]);
let loading = $state(true);
function mealIcon(meal: string): string {
if (meal === 'breakfast') return '🥣';
if (meal === 'lunch') return '🍗';
if (meal === 'dinner') return '🍽️';
return '🥤';
}
onMount(async () => {
try {
const res = await fetch('/api/fitness/templates', { credentials: 'include' });
if (res.ok) {
const raw = await res.json();
templates = (Array.isArray(raw) ? raw : []).map((t: any) => {
const items = t.items || [];
const totalCal = items.reduce((s: number, i: any) => s + (i.snapshot_calories || 0) * (i.quantity || 1), 0);
return { id: t.id, name: t.name, calories: Math.round(totalCal), items: items.length, meal: t.meal_type || 'snack' };
});
}
} catch { /* silent */ }
loading = false;
});
</script>
<div class="page">
<div class="app-surface">
<div class="page-header">
<div class="header-row">
<div>
<div class="page-title">FITNESS</div>
<div class="page-subtitle">Meal Templates</div>
</div>
<button class="btn-primary">+ New Template</button>
</div>
</div>
<div class="template-list">
{#if loading}
<div class="template-empty">Loading...</div>
{:else if templates.length === 0}
<div class="template-empty">No meal templates yet</div>
{:else}
{#each templates as tpl}
<div class="template-row">
<div class="template-icon">{mealIcon(tpl.meal)}</div>
<div class="template-info">
<div class="template-name">{tpl.name}</div>
<div class="template-meta">{tpl.calories} cal &middot; {tpl.items} items</div>
</div>
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"/></svg>
</div>
{/each}
{/if}
</div>
</div>
</div>
<style>
.header-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--sp-4);
}
.page-subtitle {
font-size: var(--text-2xl);
font-weight: 300;
color: var(--text-1);
line-height: 1.2;
}
.template-list {
display: flex;
flex-direction: column;
gap: var(--row-gap);
}
.template-row {
display: flex;
align-items: center;
gap: 14px;
padding: var(--sp-4) var(--sp-5);
background: var(--card);
border-radius: var(--radius);
border: 1px solid var(--border);
box-shadow: var(--card-shadow-sm);
cursor: pointer;
transition: all var(--transition);
}
.template-row:hover {
transform: translateY(-1px);
box-shadow: var(--card-shadow);
}
.template-icon {
width: 40px;
height: 40px;
border-radius: 10px;
background: var(--surface-secondary);
display: flex;
align-items: center;
justify-content: center;
font-size: var(--text-xl);
flex-shrink: 0;
}
.template-info {
flex: 1;
min-width: 0;
}
.template-name {
font-size: var(--text-base);
font-weight: 500;
color: var(--text-1);
}
.template-meta {
font-size: var(--text-sm);
color: var(--text-3);
margin-top: var(--sp-0.5);
}
.template-empty { padding: var(--sp-8); text-align: center; color: var(--text-3); font-size: var(--text-base); }
.chevron {
width: 16px;
height: 16px;
color: var(--text-3);
flex-shrink: 0;
}
@media (max-width: 768px) {
.page-subtitle { font-size: var(--text-xl); }
}
</style>

View File

@@ -0,0 +1,902 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import ImmichPicker from '$lib/components/shared/ImmichPicker.svelte';
// ── Types matching NocoDB fields ──
interface InventoryItem {
id: number;
name: string;
order: string;
sku: string;
serial: string;
status: string;
price: number;
tax: number;
total: number;
qty: number;
tracking: string;
vendor: string;
buyerName: string;
date: string;
notes: string;
photos: number;
photoUrls: string[];
}
interface ItemDetailRaw {
Id: number;
Item: string;
'Order Number': string;
'Serial Numbers': string;
SKU: string;
Received: string;
'Price Per Item': number;
Tax: number;
Total: number;
QTY: number;
'Tracking Number': string;
Source: string;
Name: string;
Date: string;
Notes: string;
photos: any[];
[key: string]: any;
}
// ── State ──
let activeTab = $state<'issues' | 'review' | 'all'>('issues');
let searchQuery = $state('');
let detailOpen = $state(false);
let selectedItem = $state<InventoryItem | null>(null);
let selectedDetail = $state<ItemDetailRaw | null>(null);
let nocodbUrl = $state('');
let recentItems = $state<InventoryItem[]>([]);
let issueItems = $state<InventoryItem[]>([]);
let reviewItems = $state<InventoryItem[]>([]);
let recentLoaded = $state(false);
let loading = $state(true);
let searching = $state(false);
let searchResults = $state<InventoryItem[] | null>(null);
let saving = $state(false);
let debounceTimer: ReturnType<typeof setTimeout>;
// ── Derived ──
const issueCount = $derived(issueItems.length);
const reviewCount = $derived(reviewItems.length);
const displayedItems = $derived(() => {
if (searchResults !== null) return searchResults;
if (activeTab === 'issues') return issueItems;
if (activeTab === 'review') return reviewItems;
return recentItems;
});
// ── Map API response to our item shape ──
function mapIssue(raw: any): InventoryItem {
return {
id: raw.id,
name: raw.item || '',
order: raw.orderNumber || '',
sku: raw.sku || '',
serial: raw.serialNumbers || '',
status: normalizeStatus(raw.received || ''),
price: 0,
tax: 0,
total: 0,
qty: 1,
tracking: raw.trackingNumber || '',
vendor: '',
buyerName: '',
date: '',
notes: raw.notes || '',
photos: 0,
photoUrls: []
};
}
const NOCODB_BASE = 'https://noco.quadjourney.com';
function extractPhotoUrls(photos: any[]): string[] {
if (!Array.isArray(photos)) return [];
return photos
.filter((p: any) => p && p.signedPath)
.map((p: any) => `${NOCODB_BASE}/${p.signedPath}`);
}
function mapDetail(raw: ItemDetailRaw): InventoryItem {
const photoUrls = extractPhotoUrls(raw.photos);
return {
id: raw.Id,
name: raw.Item || '',
order: raw['Order Number'] || '',
sku: raw.SKU || '',
serial: raw['Serial Numbers'] || '',
status: normalizeStatus(raw.Received || ''),
price: raw['Price Per Item'] || 0,
tax: raw.Tax || 0,
total: raw.Total || 0,
qty: raw.QTY || 1,
tracking: raw['Tracking Number'] || '',
vendor: raw.Source || '',
buyerName: raw.Name || '',
date: raw.Date || '',
notes: raw.Notes || '',
photos: Array.isArray(raw.photos) ? raw.photos.length : 0,
photoUrls
};
}
function normalizeStatus(received: string): string {
if (!received) return 'Pending';
const lower = received.toLowerCase();
if (lower === 'issue' || lower === 'issues') return 'Issue';
if (lower === 'needs review') return 'Needs Review';
if (lower === 'pending') return 'Pending';
if (lower === 'closed') return 'Closed';
// Any date string or other value = Received
return 'Received';
}
// ── API calls ──
async function loadSummary() {
try {
const res = await fetch('/api/inventory/summary', { credentials: 'include' });
if (res.ok) {
const data = await res.json();
issueItems = (data.issues || []).map(mapIssue);
reviewItems = (data.needsReview || []).map(mapIssue);
}
} catch { /* silent */ }
}
function mapSearchResult(r: any): InventoryItem {
return {
id: r.id,
name: r.item || '',
order: '', sku: '', serial: '',
status: normalizeStatus(r.received || ''),
price: 0, tax: 0, total: 0, qty: 1,
tracking: '', vendor: '', buyerName: '', date: '',
notes: '', photos: 0, photoUrls: []
};
}
async function loadRecent() {
if (recentLoaded) return;
try {
const res = await fetch('/api/inventory/recent?limit=30', { credentials: 'include' });
if (res.ok) {
const data = await res.json();
recentItems = (data.items || []).map(mapSearchResult);
recentLoaded = true;
}
} catch { /* silent */ }
}
async function searchItems(query: string) {
if (!query.trim()) { searchResults = null; return; }
searching = true;
try {
const res = await fetch(`/api/inventory/search-records?q=${encodeURIComponent(query)}`, { credentials: 'include' });
if (res.ok) {
const data = await res.json();
searchResults = (data.results || []).map((r: any) => ({
id: r.id,
name: r.item || '',
order: '', sku: '', serial: '',
status: normalizeStatus(r.received || ''),
price: 0, tax: 0, total: 0, qty: 1,
tracking: '', vendor: '', buyerName: '', date: '',
notes: '', photos: 0
}));
}
} catch { searchResults = []; }
finally { searching = false; }
}
async function loadItemDetail(id: number) {
try {
const res = await fetch(`/api/inventory/item-details/${id}`, { credentials: 'include' });
if (res.ok) {
const data = await res.json();
selectedDetail = data.item;
selectedItem = mapDetail(data.item);
nocodbUrl = data.nocodb_url || '';
detailOpen = true;
}
} catch { /* silent */ }
}
async function updateField(field: string, value: string | number) {
if (!selectedItem) return;
saving = true;
try {
const res = await fetch(`/api/inventory/item/${selectedItem.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [field]: value }),
credentials: 'include'
});
if (res.ok && selectedDetail) {
(selectedDetail as any)[field] = value;
selectedItem = mapDetail(selectedDetail);
// Update in lists
updateItemInLists(selectedItem);
}
} catch { /* silent */ }
finally { saving = false; }
}
function updateItemInLists(item: InventoryItem) {
const issueIdx = issueItems.findIndex(i => i.id === item.id);
const allIdx = allItems.findIndex(i => i.id === item.id);
if (allIdx >= 0) allItems[allIdx] = item;
// Add/remove from issues based on status
if (item.status === 'Issue') {
if (issueIdx < 0) issueItems = [...issueItems, item];
else issueItems[issueIdx] = item;
} else {
if (issueIdx >= 0) issueItems = issueItems.filter(i => i.id !== item.id);
}
}
// ── Event handlers ──
function onSearchInput() {
clearTimeout(debounceTimer);
if (!searchQuery.trim()) { searchResults = null; return; }
debounceTimer = setTimeout(() => searchItems(searchQuery), 300);
}
function openDetail(item: InventoryItem) {
loadItemDetail(item.id);
}
function closeDetail() {
detailOpen = false;
selectedItem = null;
selectedDetail = null;
}
const statusOptions = ['Issue', 'Needs Review', 'Pending', 'Received', 'Closed'];
// Maps our status to NocoDB "Received" field values
function statusToReceived(status: string): string {
if (status === 'Issue') return 'Issues';
if (status === 'Needs Review') return 'Needs Review';
if (status === 'Pending') return 'Pending';
if (status === 'Closed') return 'Closed';
// For "Received", we set today's date
return new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
async function changeStatus(newStatus: string) {
if (!selectedItem) return;
const receivedValue = statusToReceived(newStatus);
await updateField('Received', receivedValue);
}
function statusColor(status: string) {
if (status === 'Issue' || status === 'Issues') return 'error';
if (status === 'Needs Review') return 'warning';
if (status === 'Received') return 'success';
if (status === 'Pending') return 'warning';
if (status === 'Closed') return 'muted';
return 'muted';
}
function formatPrice(n: number) {
return '$' + n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
// ── Inline editing ──
let editingField = $state('');
let editValue = $state('');
// NocoDB field name → our display label + item property
const editableFields: Record<string, { nocoField: string; type: 'text' | 'number' }> = {
'Item': { nocoField: 'Item', type: 'text' },
'Price Per Item': { nocoField: 'Price Per Item', type: 'number' },
'Tax': { nocoField: 'Tax', type: 'number' },
'Total': { nocoField: 'Total', type: 'number' },
'QTY': { nocoField: 'QTY', type: 'number' },
'SKU': { nocoField: 'SKU', type: 'text' },
'Serial Numbers': { nocoField: 'Serial Numbers', type: 'text' },
'Order Number': { nocoField: 'Order Number', type: 'text' },
'Source': { nocoField: 'Source', type: 'text' },
'Name': { nocoField: 'Name', type: 'text' },
'Date': { nocoField: 'Date', type: 'text' },
'Tracking Number': { nocoField: 'Tracking Number', type: 'text' },
'Notes': { nocoField: 'Notes', type: 'text' },
};
function startEdit(nocoField: string, currentValue: any) {
editingField = nocoField;
editValue = currentValue != null ? String(currentValue) : '';
}
async function saveEdit() {
if (!editingField || !selectedItem) return;
const field = editableFields[editingField];
const value = field?.type === 'number' ? Number(editValue) || 0 : editValue;
await updateField(editingField, value);
editingField = '';
editValue = '';
}
function cancelEdit() {
editingField = '';
editValue = '';
}
function handleEditKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') saveEdit();
if (e.key === 'Escape') cancelEdit();
}
// Get raw NocoDB value for a field
function rawField(field: string): any {
return selectedDetail ? (selectedDetail as any)[field] : '';
}
// ── Action handlers ──
async function duplicateItem() {
if (!selectedItem) return;
saving = true;
try {
const res = await fetch(`/api/inventory/duplicate/${selectedItem.id}`, {
method: 'POST', credentials: 'include'
});
if (res.ok) {
const data = await res.json();
const newId = data.newId || data.id;
if (newId) loadItemDetail(newId);
}
} catch { /* silent */ }
finally { saving = false; }
}
async function sendToPhone() {
if (!selectedItem) return;
saving = true;
try {
await fetch('/api/inventory/send-to-phone', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rowId: selectedItem.id }),
credentials: 'include'
});
} catch { /* silent */ }
finally { saving = false; }
}
// ── Photo upload ──
let fileInput: HTMLInputElement;
let uploading = $state(false);
let uploadMenuOpen = $state(false);
function triggerUpload() {
fileInput?.click();
}
async function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement;
if (!input.files?.length || !selectedItem) return;
uploading = true;
try {
const formData = new FormData();
formData.append('rowId', String(selectedItem.id));
for (const file of input.files) {
formData.append('photos', file);
}
const res = await fetch('/api/inventory/upload', {
method: 'POST',
body: formData,
credentials: 'include'
});
if (res.ok) {
// Reload item to get updated photos
loadItemDetail(selectedItem.id);
}
} catch { /* silent */ }
finally {
uploading = false;
input.value = '';
}
}
// ── Immich picker ──
let immichOpen = $state(false);
async function handleImmichSelect(assetIds: string[]) {
if (!selectedItem || !assetIds.length) return;
uploading = true;
try {
// Server-to-server: inventory service downloads from Immich and uploads to NocoDB
const res = await fetch('/api/inventory/upload-from-immich', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
rowId: selectedItem.id,
assetIds,
deleteAfter: true
})
});
if (res.ok) {
const data = await res.json();
console.log(`Uploaded ${data.uploadedCount} photos, deleted ${data.deletedCount} from Immich`);
}
// Reload to show new photos
loadItemDetail(selectedItem.id);
} catch { /* silent */ }
finally { uploading = false; }
}
async function createNewItem() {
saving = true;
try {
const res = await fetch('/api/inventory/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ Item: 'New Item' }),
credentials: 'include'
});
if (res.ok) {
const data = await res.json();
if (data.id) loadItemDetail(data.id);
}
} catch { /* silent */ }
finally { saving = false; }
}
// ── Init ──
onMount(async () => {
await loadSummary();
loading = false;
// Auto-open from query param
const itemId = page.url.searchParams.get('item');
if (itemId) loadItemDetail(Number(itemId));
});
</script>
{#snippet editableRow(nocoField: string, displayValue: string, classes: string)}
{#if editingField === nocoField}
<div class="detail-row editing">
<span class="field-label">{nocoField}</span>
<input
class="edit-input {classes}"
type={editableFields[nocoField]?.type === 'number' ? 'number' : 'text'}
bind:value={editValue}
onkeydown={handleEditKeydown}
onblur={saveEdit}
autofocus
/>
</div>
{:else}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="detail-row editable" onclick={() => startEdit(nocoField, rawField(nocoField))}>
<span class="field-label">{nocoField}</span>
<span class="field-value {classes}">{displayValue}</span>
</div>
{/if}
{/snippet}
<div class="page">
<div class="app-surface">
<div class="page-header">
<div class="header-row">
<div>
<div class="page-title">INVENTORY</div>
<div class="page-subtitle">{loading ? 'Loading...' : ''}<strong>{issueCount} issues</strong> · {reviewCount} needs review</div>
</div>
<button class="btn-primary" onclick={createNewItem}>+ New Item</button>
</div>
</div>
<!-- Search -->
<div class="search-wrap">
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
<input
type="text"
class="search-input"
placeholder="Search by item name, order number, serial number, SKU..."
bind:value={searchQuery}
oninput={onSearchInput}
/>
{#if searchQuery}
<button class="search-clear" onclick={() => { searchQuery = ''; searchResults = null; }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
{/if}
</div>
<!-- Tabs (hidden during search) -->
{#if !searchQuery && searchResults === null}
<div class="tabs">
<button class="tab" class:active={activeTab === 'issues'} onclick={() => activeTab = 'issues'}>
Issues <span class="tab-badge">{issueCount}</span>
</button>
<button class="tab" class:active={activeTab === 'review'} onclick={() => activeTab = 'review'}>
Needs Review <span class="tab-badge review">{reviewCount}</span>
</button>
<button class="tab" class:active={activeTab === 'all'} onclick={() => { activeTab = 'all'; loadRecent(); }}>
Recent
</button>
</div>
{:else}
<div class="search-results-label">{displayedItems().length} result{displayedItems().length !== 1 ? 's' : ''} for "{searchQuery}"</div>
{/if}
<!-- Item rows -->
<div class="items-card">
{#each displayedItems() as item (item.id)}
<button class="item-row" class:has-issue={item.status === 'Issue'} class:has-review={item.status === 'Needs Review'} onclick={() => openDetail(item)}>
<div class="item-info">
<div class="item-name">{item.name}</div>
<div class="item-meta">Order #{item.order} · SKU: {item.sku}</div>
</div>
<span class="status-badge {statusColor(item.status)}">{item.status}</span>
<svg class="row-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
</button>
{/each}
{#if displayedItems().length === 0}
<div class="empty">No items found</div>
{/if}
</div>
</div>
</div>
<!-- Detail sheet/modal -->
{#if detailOpen && selectedItem}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="detail-overlay" onclick={closeDetail}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="detail-sheet" onclick={(e) => e.stopPropagation()}>
<!-- 1. Title (editable) -->
<div class="detail-header">
{#if editingField === 'Item'}
<input
class="edit-input detail-title-edit"
type="text"
bind:value={editValue}
onkeydown={handleEditKeydown}
onblur={saveEdit}
autofocus
/>
{:else}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="detail-title editable" onclick={() => startEdit('Item', rawField('Item'))}>{selectedItem.name}</div>
{/if}
<button class="detail-close" onclick={closeDetail}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<!-- 2. Status control (segmented) -->
<div class="status-control">
{#each statusOptions as status}
<button
class="status-seg"
class:active={selectedItem.status === status}
data-status={status}
onclick={() => changeStatus(status)}
>
{status}
</button>
{/each}
</div>
<!-- 3. Photos -->
<div class="detail-photos">
{#if selectedItem.photoUrls.length > 0}
{#each selectedItem.photoUrls as url}
<img class="photo-img" src={url} alt="Item photo" loading="lazy" />
{/each}
{:else}
<div class="photo-placeholder empty-photo">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>
<span>No photos yet</span>
</div>
{/if}
</div>
<!-- 4. Actions -->
<div class="actions-group">
<div class="actions-row">
<input type="file" accept="image/*" multiple class="hidden-input" bind:this={fileInput} onchange={handleFileSelect} />
<button class="action-btn primary" onclick={() => uploadMenuOpen = !uploadMenuOpen} disabled={uploading}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
{uploading ? 'Uploading...' : 'Upload Photos'}
</button>
{#if uploadMenuOpen}
<div class="upload-menu">
<button class="upload-option" onclick={() => { uploadMenuOpen = false; triggerUpload(); }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
From device
</button>
<button class="upload-option" onclick={() => { uploadMenuOpen = false; immichOpen = true; }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>
From Immich
</button>
</div>
{/if}
<button class="action-btn" onclick={sendToPhone}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="5" y="2" width="14" height="20" rx="2"/><line x1="12" y1="18" x2="12.01" y2="18"/></svg>
Phone
</button>
</div>
<div class="actions-row secondary">
<button class="action-btn sm" onclick={duplicateItem}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
Duplicate
</button>
<a class="action-btn sm ghost" href={nocodbUrl || '#'} target="_blank" rel="noopener">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
NocoDB
</a>
</div>
</div>
<!-- Purchase -->
<div class="section-group">
<div class="section-label">Purchase</div>
<div class="detail-fields">
{@render editableRow('Price Per Item', formatPrice(selectedItem.price), 'mono')}
{@render editableRow('Tax', formatPrice(selectedItem.tax), 'mono')}
{@render editableRow('Total', formatPrice(selectedItem.total), 'mono strong')}
{@render editableRow('QTY', String(selectedItem.qty), '')}
</div>
</div>
<!-- Item Info -->
<div class="section-group">
<div class="section-label">Item Info</div>
<div class="detail-fields">
{@render editableRow('SKU', selectedItem.sku || '—', 'mono')}
{@render editableRow('Serial Numbers', selectedItem.serial || '—', 'mono')}
</div>
</div>
<!-- Order -->
<div class="section-group">
<div class="section-label">Order</div>
<div class="detail-fields">
{@render editableRow('Order Number', selectedItem.order || '—', 'mono')}
{@render editableRow('Source', selectedItem.vendor || '—', '')}
{@render editableRow('Name', selectedItem.buyerName || '—', '')}
{@render editableRow('Date', selectedItem.date || '—', '')}
</div>
</div>
<!-- Shipping -->
<div class="section-group">
<div class="section-label">Shipping</div>
<div class="detail-fields">
{@render editableRow('Tracking Number', selectedItem.tracking || '—', 'mono')}
</div>
</div>
<!-- Notes -->
<div class="section-group">
<div class="section-label">Notes</div>
<div class="detail-fields">
{#if editingField === 'Notes'}
<div class="detail-row">
<textarea
class="edit-input edit-textarea"
bind:value={editValue}
onkeydown={handleEditKeydown}
onblur={saveEdit}
rows="3"
></textarea>
</div>
{:else}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="detail-row editable" onclick={() => startEdit('Notes', rawField('Notes'))}>
<span class="field-value" style="text-align:left;font-weight:400;color:var(--text-2);width:100%">{selectedItem.notes || 'Add notes...'}</span>
</div>
{/if}
</div>
</div>
</div>
</div>
{/if}
{#if immichOpen}
<ImmichPicker bind:open={immichOpen} onselect={handleImmichSelect} />
{/if}
<style>
.header-row { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-4); }
.page-subtitle { font-size: var(--text-2xl); font-weight: 300; color: var(--text-1); line-height: 1.2; }
.btn-primary { padding: var(--sp-2) var(--sp-4); border-radius: var(--radius-md); background: var(--accent); color: white; border: none; font-size: var(--text-sm); font-weight: 600; cursor: pointer; font-family: var(--font); transition: opacity var(--transition); }
.btn-primary:hover { opacity: 0.9; }
/* ── Search ── */
.search-wrap { position: relative; margin-bottom: var(--sp-4); }
.search-icon { position: absolute; left: 14px; top: 50%; transform: translateY(-50%); width: 18px; height: 18px; color: var(--text-4); pointer-events: none; transition: color var(--transition); }
.search-input { width: 100%; padding: var(--sp-3) var(--sp-10) var(--sp-3) 42px; border-radius: var(--radius); border: 1.5px solid var(--border); background: var(--card); color: var(--text-1); font-size: var(--text-md); font-family: var(--font); transition: all var(--transition); box-shadow: var(--shadow-xs); }
.search-input::placeholder { color: var(--text-4); }
.search-input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 4px var(--accent-dim), var(--shadow-sm); }
.search-input:focus ~ .search-icon { color: var(--accent); }
.search-clear { position: absolute; right: 12px; top: 50%; transform: translateY(-50%); background: none; border: none; cursor: pointer; color: var(--text-4); padding: var(--sp-1); border-radius: var(--radius-xs); }
.search-clear:hover { color: var(--text-2); background: var(--card-hover); }
.search-clear svg { width: 16px; height: 16px; }
.search-results-label { font-size: var(--text-sm); color: var(--text-3); margin-bottom: var(--sp-3); }
/* ── Tabs ── */
.tabs { display: flex; gap: var(--sp-1); margin-bottom: var(--sp-4); }
.tab { padding: var(--sp-2) 14px; border-radius: var(--radius-md); font-size: var(--text-base); font-weight: 500; color: var(--text-3); background: none; border: none; cursor: pointer; transition: all var(--transition); font-family: var(--font); display: flex; align-items: center; gap: var(--sp-1.5); }
.tab:hover { color: var(--text-1); background: var(--card-hover); }
.tab.active { color: var(--text-1); background: var(--card); box-shadow: var(--shadow-xs); }
.tab-badge { font-size: var(--text-xs); font-weight: 600; background: var(--error); color: white; padding: 1px 7px; border-radius: 10px; }
.tab-badge.review { background: var(--warning); }
.tab-count { font-size: var(--text-xs); color: var(--text-4); }
/* ── Item rows ── */
.items-card { background: var(--card); border-radius: var(--radius); border: 1px solid var(--border); box-shadow: var(--shadow-md); overflow: hidden; }
.item-row { display: flex; align-items: center; gap: 14px; padding: 15px 16px; width: 100%; background: none; border: none; cursor: pointer; transition: background var(--transition); text-align: left; font-family: var(--font); color: inherit; }
.item-row:hover { background: var(--card-hover); }
.item-row + .item-row { border-top: 1px solid var(--border); }
.item-row:nth-child(even) { background: color-mix(in srgb, var(--surface) 68%, var(--card)); }
.item-row:nth-child(even):hover { background: var(--card-hover); }
.item-row.has-issue { border-left: 4px solid var(--error); }
.item-row.has-review { border-left: 4px solid var(--warning); }
.item-info { flex: 1; min-width: 0; }
.item-name { font-size: var(--text-base); font-weight: 600; color: var(--text-1); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.item-meta { font-size: var(--text-sm); color: var(--text-2); margin-top: 3px; letter-spacing: 0.01em; }
.status-badge { font-size: var(--text-xs); font-weight: 500; padding: 3px 10px; border-radius: var(--radius-sm); flex-shrink: 0; }
.status-badge.error { background: var(--error-dim); color: var(--error); }
.status-badge.success { background: var(--success-dim); color: var(--success); }
.status-badge.warning { background: var(--warning-bg); color: var(--warning); }
.status-badge.muted { background: var(--card-hover); color: var(--text-4); }
.row-chevron { width: 14px; height: 14px; color: var(--text-4); flex-shrink: 0; opacity: 0.5; }
.item-row:hover .row-chevron { opacity: 1; }
.empty { padding: var(--sp-12); text-align: center; color: var(--text-3); font-size: var(--text-base); }
/* ── Detail sheet ── */
.detail-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.35); z-index: 60; display: flex; justify-content: flex-end; animation: fadeIn 150ms ease; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
.detail-sheet { width: 520px; max-width: 100%; height: 100%; background: var(--surface); overflow-y: auto; padding: var(--sp-7); box-shadow: -12px 0 40px rgba(0,0,0,0.12); animation: slideIn 200ms ease; }
@keyframes slideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }
.detail-header { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--sp-3); margin-bottom: 18px; }
.detail-title { font-size: var(--text-lg); font-weight: 600; color: var(--text-1); line-height: 1.35; flex: 1; min-width: 0; }
/* ── Status segmented control ── */
.status-control {
display: flex;
justify-content: center;
gap: 0;
margin: 0 8px 22px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 3px;
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
}
.status-seg {
flex: 1;
padding: 8px 0;
font-size: var(--text-sm);
font-weight: 500;
color: var(--text-3);
background: none;
border: none;
border-radius: 9px;
cursor: pointer;
font-family: var(--font);
transition: all var(--transition);
text-align: center;
}
.status-seg:hover { color: var(--text-2); }
.status-seg.active { color: var(--text-1); box-shadow: 0 1px 4px rgba(0,0,0,0.08); }
.status-seg.active[data-status="Issue"] { background: var(--error-dim); color: var(--error); }
.status-seg.active[data-status="Pending"] { background: var(--warning-bg); color: var(--warning); }
.status-seg.active[data-status="Received"] { background: var(--success-dim); color: var(--success); }
.status-seg.active[data-status="Closed"] { background: var(--card); color: var(--text-2); }
.detail-close { background: none; border: none; cursor: pointer; color: var(--text-4); padding: var(--sp-1.5); border-radius: var(--radius-md); transition: all var(--transition); }
.detail-close:hover { color: var(--text-1); background: var(--card-hover); }
.detail-close svg { width: 18px; height: 18px; }
/* ── Photos ── */
.detail-photos { display: flex; gap: 10px; margin-bottom: var(--sp-5); overflow-x: auto; padding-bottom: var(--sp-1); }
.detail-photos::-webkit-scrollbar { height: 4px; }
.detail-photos::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
.photo-img { width: 140px; height: 105px; border-radius: 10px; object-fit: cover; flex-shrink: 0; background: var(--card-hover); }
.photo-placeholder { width: 140px; height: 105px; border-radius: 10px; background: color-mix(in srgb, var(--surface) 70%, var(--card)); border: 1px dashed var(--border); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-1.5); flex-shrink: 0; color: var(--text-4); }
.photo-placeholder svg { width: 22px; height: 22px; opacity: 0.5; }
.empty-photo { width: 100%; height: 90px; border-radius: 10px; }
.empty-photo span { font-size: var(--text-sm); color: var(--text-4); }
/* ── Actions ── */
.actions-group { display: flex; flex-direction: column; gap: var(--sp-2); margin-bottom: var(--sp-7); }
.actions-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--sp-2); }
.actions-row.secondary { grid-template-columns: repeat(2, 1fr); }
.action-btn {
display: flex; align-items: center; justify-content: center; gap: 5px;
padding: 8px 14px; border-radius: var(--radius-md); height: 34px; width: 100%;
background: var(--card); border: 1px solid var(--border);
font-size: var(--text-sm); font-weight: 500; color: var(--text-3);
cursor: pointer; font-family: var(--font);
transition: all var(--transition);
}
.action-btn:hover { border-color: var(--text-4); color: var(--text-1); }
.action-btn:active { transform: scale(0.97); }
.action-btn.primary { background: var(--accent); border-color: var(--accent); color: white; }
.action-btn.primary:hover { opacity: 0.9; border-color: var(--accent); color: white; }
.action-btn.sm { padding: 6px 11px; font-size: var(--text-sm); height: 30px; }
.action-btn.ghost { border-color: transparent; color: var(--text-4); background: none; }
.action-btn.ghost:hover { color: var(--text-2); background: var(--card-hover); border-color: transparent; }
.action-btn svg { width: 14px; height: 14px; }
.action-btn.sm svg { width: 13px; height: 13px; }
/* ── Section groups ── */
.section-group { margin-bottom: var(--sp-7); }
.section-label { font-size: var(--text-xs); font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-2); margin-bottom: var(--sp-1.5); }
/* ── Detail fields ── */
.detail-fields { background: var(--card); border-radius: var(--radius); border: 1px solid var(--border); box-shadow: var(--shadow-sm); overflow: hidden; }
.detail-row { display: flex; justify-content: space-between; align-items: center; padding: 14px 16px; }
.detail-row + .detail-row { border-top: 1px solid var(--border); }
.detail-row:nth-child(even) { background: color-mix(in srgb, var(--surface) 70%, var(--card)); }
.field-label { font-size: var(--text-sm); color: var(--text-1); opacity: 0.65; letter-spacing: 0.01em; }
.field-value { font-size: var(--text-base); font-weight: 400; color: var(--text-1); text-align: right; letter-spacing: -0.01em; }
/* ── Inline editing ── */
.detail-row.editable { cursor: pointer; }
.detail-row.editable:hover { background: var(--card-hover); }
.detail-title.editable { cursor: pointer; border-radius: var(--radius-sm); padding: 2px 4px; margin: -2px -4px; }
.detail-title.editable:hover { background: var(--card-hover); }
.edit-input {
width: 100%; padding: 6px 10px; border-radius: var(--radius-sm);
border: 1px solid var(--accent); background: var(--surface-secondary);
color: var(--text-1); font-size: var(--text-base); font-family: var(--font);
text-align: right; outline: none;
box-shadow: 0 0 0 3px var(--accent-border);
}
.edit-input.mono { font-family: var(--mono); }
.edit-input[type="number"] { font-family: var(--mono); }
.detail-title-edit {
font-size: var(--text-lg); font-weight: 600; text-align: left;
flex: 1; min-width: 0;
}
.edit-textarea {
text-align: left; resize: vertical; min-height: 60px;
width: 100%; font-size: var(--text-sm); line-height: 1.5;
}
.detail-row.editing { background: var(--accent-dim); }
.hidden-input { display: none; }
.upload-menu {
position: absolute; left: 0; right: 0; top: 100%; margin-top: var(--sp-1); z-index: 10;
background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
box-shadow: 0 8px 24px rgba(0,0,0,0.12); overflow: hidden;
}
.upload-option {
display: flex; align-items: center; gap: 10px; width: 100%;
padding: 12px 16px; background: none; border: none; border-bottom: 1px solid var(--border);
font-size: var(--text-base); font-weight: 500; color: var(--text-1); cursor: pointer;
font-family: var(--font); transition: background var(--transition); text-align: left;
}
.upload-option:last-child { border-bottom: none; }
.upload-option:hover { background: var(--card-hover); }
.upload-option svg { width: 18px; height: 18px; color: var(--text-3); }
.actions-row { position: relative; }
.field-value.mono { font-family: var(--mono); }
.field-value.strong { font-weight: 600; color: var(--text-1); }
/* ── Mobile ── */
@media (max-width: 768px) {
.page-subtitle { font-size: var(--text-xl); }
.detail-sheet { width: 100%; padding: var(--sp-5); }
.detail-photos { gap: var(--sp-2); }
.photo-img { width: 120px; height: 90px; }
.photo-placeholder { width: 120px; height: 90px; }
.detail-title { font-size: var(--text-lg); }
}
</style>

View File

@@ -0,0 +1,170 @@
<script lang="ts">
const details = [
{ label: 'Order #', value: '33A77277RK' },
{ label: 'Serial #', value: '019284756301' },
{ label: 'SKU', value: 'SURF-LT-1TB' },
{ label: 'Purchase Price', value: '$423.00' },
{ label: 'List Price', value: '$899.99' },
{ label: 'Status', value: 'Used - Like New' },
{ label: 'Condition', value: '—' },
{ label: 'Tracking #', value: '—' },
{ label: 'Notes', value: '—' }
];
</script>
<div class="page">
<div class="app-surface">
<a href="/inventory" class="back-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"/></svg>
Back to Inventory
</a>
<div class="detail-header">
<h1 class="detail-title">Microsoft Surface Laptop Studio - 1TB TBolt</h1>
<span class="badge-error">Needs Attention</span>
</div>
<!-- Photos -->
<div class="photos">
{#each [1, 2, 3] as _}
<div class="photo-placeholder">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
</div>
{/each}
</div>
<!-- Actions -->
<div class="actions">
<button class="btn-secondary">Upload Photos</button>
<button class="btn-secondary">Pin</button>
<button class="btn-secondary">Send to Phone</button>
<button class="btn-secondary">Duplicate</button>
<button class="btn-secondary">Open in NocoDB</button>
</div>
<!-- Details -->
<div class="module">
<div class="module-header">
<div class="module-title">Details</div>
</div>
{#each details as row}
<div class="detail-row">
<span class="detail-label">{row.label}</span>
<span class="detail-value">{row.value}</span>
</div>
{/each}
</div>
</div>
</div>
<style>
.back-link {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
font-size: var(--text-sm);
color: var(--text-3);
margin-bottom: var(--sp-5);
transition: color var(--transition);
}
.back-link:hover { color: var(--text-1); }
.back-link svg { width: 16px; height: 16px; }
.detail-header {
display: flex;
align-items: center;
gap: var(--sp-3);
flex-wrap: wrap;
margin-bottom: var(--section-gap);
}
.detail-title {
font-size: var(--text-xl);
font-weight: 600;
color: var(--text-1);
margin: 0;
}
.badge-error {
font-size: var(--text-sm);
font-weight: 600;
color: var(--error);
background: var(--error-bg);
padding: 4px 10px;
border-radius: var(--radius-sm);
}
/* Photos */
.photos {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--row-gap);
margin-bottom: var(--section-gap);
}
.photo-placeholder {
aspect-ratio: 4/3;
background: var(--card-secondary);
border: 1px dashed var(--border-strong);
border-radius: var(--radius);
display: flex;
align-items: center;
justify-content: center;
}
.photo-placeholder svg {
width: 32px;
height: 32px;
color: var(--text-4);
}
/* Actions */
.actions {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
margin-bottom: var(--section-gap);
}
/* Module */
.module {
background: var(--card);
border-radius: var(--radius);
padding: var(--card-pad-primary);
box-shadow: var(--card-shadow);
border: 1px solid var(--border);
}
.module-header {
margin-bottom: var(--sp-4);
}
.module-title {
font-size: var(--text-sm);
font-weight: 600;
color: var(--text-3);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.detail-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 0;
border-bottom: 1px solid var(--border);
}
.detail-row:last-child { border-bottom: none; }
.detail-label {
font-size: var(--text-base);
color: var(--text-3);
}
.detail-value {
font-size: var(--text-base);
font-family: var(--mono);
color: var(--text-1);
}
@media (max-width: 768px) {
.photos { grid-template-columns: repeat(2, 1fr); }
.detail-title { font-size: var(--text-xl); }
}
</style>

View File

@@ -0,0 +1,75 @@
<script lang="ts">
import { page } from '$app/state';
import BookSearch from '$lib/components/media/BookSearch.svelte';
import MusicSearch from '$lib/components/media/MusicSearch.svelte';
import BookLibrary from '$lib/components/media/BookLibrary.svelte';
type MediaTab = 'books' | 'music' | 'library';
const urlMode = page.url.searchParams.get('mode');
let activeTab = $state<MediaTab>(urlMode === 'music' ? 'music' : urlMode === 'library' ? 'library' : 'books');
</script>
<div class="page">
<div class="app-surface">
<div class="page-header">
<div class="page-title">MEDIA</div>
<div class="page-subtitle">
{#if activeTab === 'books'}Book Downloads
{:else if activeTab === 'music'}Music Downloads
{:else}Book Library{/if}
</div>
</div>
<div class="media-tabs">
<button class="tab" class:active={activeTab === 'books'} onclick={() => activeTab = 'books'}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
Books
</button>
<button class="tab" class:active={activeTab === 'music'} onclick={() => activeTab = 'music'}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
Music
</button>
<button class="tab" class:active={activeTab === 'library'} onclick={() => activeTab = 'library'}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>
Library
</button>
</div>
{#if activeTab === 'books'}
<BookSearch />
{:else if activeTab === 'music'}
<MusicSearch />
{:else}
<BookLibrary />
{/if}
</div>
</div>
<style>
.page-subtitle {
font-size: var(--text-2xl);
font-weight: 300;
color: var(--text-1);
line-height: 1.2;
}
.media-tabs {
display: flex; gap: var(--sp-1); margin-bottom: var(--sp-5);
border-bottom: 1px solid var(--border); padding-bottom: 0;
}
.tab {
display: flex; align-items: center; gap: var(--sp-2);
flex: 1; padding: 10px var(--sp-2) 12px; font-size: var(--text-base); font-weight: 500;
color: var(--text-3); background: none; border: none; border-bottom: 2px solid transparent;
cursor: pointer; font-family: var(--font); transition: all var(--transition);
text-align: center; justify-content: center; margin-bottom: -1px;
}
.tab:hover { color: var(--text-2); }
.tab.active { color: var(--text-1); border-bottom-color: var(--accent); font-weight: 600; }
.tab svg { width: 16px; height: 16px; flex-shrink: 0; }
@media (max-width: 768px) {
.page-subtitle { font-size: var(--text-xl); }
}
</style>

View File

@@ -0,0 +1,880 @@
<script lang="ts">
import { onMount } from 'svelte';
// ── Types ──
interface Feed { name: string; count: number; id?: number; }
interface FeedCategory { name: string; feeds: Feed[]; expanded: boolean; id?: number; }
interface Article {
id: number; title: string; feed: string; timeAgo: string; readingTime: string;
content: string; thumbnail?: string; starred: boolean; read: boolean;
author?: string; url?: string;
}
// ── State ──
let navItems = $state([
{ label: 'Today', count: 0, icon: 'calendar' },
{ label: 'Starred', count: 0, icon: 'star' },
{ label: 'History', count: 0, icon: 'clock' }
]);
let feedCategories = $state<FeedCategory[]>([]);
let articles = $state<Article[]>([]);
let activeNav = $state('Today');
let activeFeedId = $state<number | null>(null);
let selectedArticle = $state<Article | null>(null);
let filterMode = $state<'unread' | 'all'>('unread');
let sidebarOpen = $state(false);
let autoScrollActive = $state(false);
let autoScrollSpeed = $state(1.5);
let articleListEl: HTMLDivElement;
let scrollRAF: number | null = null;
let loading = $state(true);
let loadingMore = $state(false);
let hasMore = $state(true);
let totalUnread = $state(0);
const LIMIT = 50;
let feedCounters: Record<string, number> = {};
// ── Helpers ──
function timeAgo(dateStr: string): string {
const mins = Math.floor((Date.now() - new Date(dateStr).getTime()) / 60000);
if (mins < 1) return 'now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
return `${days}d ago`;
}
function extractThumb(html: string): string | null {
const match = html?.match(/<img[^>]+src=["']([^"']+)["']/i);
if (!match) return null;
return match[1].replace(/&amp;/g, '&');
}
function stripHtml(html: string): string {
if (!html) return '';
return html.replace(/<[^>]*>/g, '').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/\s+/g, ' ').trim();
}
function mapEntry(e: any): Article {
return {
id: e.id, title: e.title, feed: e.feed?.title || '', url: e.url,
timeAgo: timeAgo(e.published_at), readingTime: `${e.reading_time || 1} min`,
content: e.content || '', thumbnail: extractThumb(e.content),
starred: e.starred || false, read: e.status === 'read',
author: e.author || ''
};
}
// ── API ──
async function api(path: string, opts: RequestInit = {}) {
const res = await fetch(`/api/reader${path}`, { credentials: 'include', ...opts });
if (!res.ok) throw new Error(`${res.status}`);
return res.json();
}
async function loadSidebar() {
try {
const [cats, feeds, counters] = await Promise.all([
api('/categories'), api('/feeds'), api('/feeds/counters')
]);
feedCounters = counters.unreads || {};
totalUnread = Object.values(feedCounters).reduce((s: number, c: any) => s + (c as number), 0);
feedCategories = cats.map((c: any) => ({
id: c.id, name: c.title,
expanded: false,
feeds: feeds
.filter((f: any) => f.category.id === c.id)
.map((f: any) => ({ id: f.id, name: f.title, count: feedCounters[String(f.id)] || 0 }))
})).filter((c: FeedCategory) => c.feeds.length > 0);
// Expand first category
if (feedCategories.length > 0) feedCategories[0].expanded = true;
// Update nav counts
navItems[0].count = totalUnread;
try {
const [starred, history] = await Promise.all([
api('/entries?starred=true&limit=1'),
api('/entries?status=read&limit=1')
]);
navItems[1].count = starred.total || 0;
navItems[2].count = history.total || 0;
} catch { /* silent */ }
} catch { /* silent */ }
}
async function loadEntries(append = false) {
if (loadingMore) return;
if (!append) { loading = true; articles = []; hasMore = true; }
else loadingMore = true;
try {
let params = `limit=${LIMIT}&direction=desc&order=published_at`;
if (!append) params += '&offset=0';
else params += `&offset=${articles.length}`;
if (activeFeedId) {
params += `&feed_id=${activeFeedId}`;
}
if (activeNav === 'Today') {
params += '&status=unread';
} else if (activeNav === 'Starred') {
params += '&starred=true';
} else if (activeNav === 'History') {
params += '&status=read';
}
const data = await api(`/entries?${params}`);
const mapped = (data.entries || []).map(mapEntry);
if (append) {
articles = [...articles, ...mapped];
} else {
articles = mapped;
}
hasMore = mapped.length === LIMIT;
} catch { /* silent */ }
finally { loading = false; loadingMore = false; }
}
async function markEntryRead(id: number) {
try {
await api('/entries', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entry_ids: [id], status: 'read' })
});
} catch { /* silent */ }
}
async function markEntryUnread(id: number) {
try {
await api('/entries', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entry_ids: [id], status: 'unread' })
});
} catch { /* silent */ }
}
async function toggleStarAPI(id: number) {
try {
await api(`/entries/${id}/bookmark`, { method: 'PUT' });
} catch { /* silent */ }
}
async function markAllReadAPI() {
const ids = articles.filter(a => !a.read).map(a => a.id);
if (!ids.length) return;
try {
await api('/entries', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entry_ids: ids, status: 'read' })
});
} catch { /* silent */ }
}
// ── Karakeep ──
let karakeepIds = $state<Record<number, string>>({});
let savingToKarakeep = $state<Set<number>>(new Set());
async function toggleKarakeep(article: Article, e?: Event) {
e?.stopPropagation();
e?.preventDefault();
if (savingToKarakeep.has(article.id)) return;
const articleUrl = article.url || '';
console.log('Karakeep: saving', article.id, articleUrl);
if (!articleUrl && !karakeepIds[article.id]) {
console.log('Karakeep: no URL, skipping');
return;
}
savingToKarakeep = new Set([...savingToKarakeep, article.id]);
try {
if (karakeepIds[article.id]) {
const res = await fetch('/api/karakeep/delete', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: karakeepIds[article.id] })
});
console.log('Karakeep delete:', res.status);
const next = { ...karakeepIds };
delete next[article.id];
karakeepIds = next;
} else {
const res = await fetch('/api/karakeep/save', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: articleUrl })
});
const text = await res.text();
console.log('Karakeep save:', res.status, text);
try {
const data = JSON.parse(text);
if (res.ok && data.ok) {
karakeepIds = { ...karakeepIds, [article.id]: data.id };
console.log('Karakeep: saved as', data.id);
}
} catch { console.error('Karakeep: bad JSON response'); }
}
} catch (err) {
console.error('Karakeep error:', err);
} finally {
const next = new Set(savingToKarakeep);
next.delete(article.id);
savingToKarakeep = next;
}
}
// ── Touch to stop auto-scroll ──
function handleScrollInterrupt() {
if (autoScrollActive) stopAutoScroll();
}
// ── Event handlers ──
function decrementUnread(count = 1) {
totalUnread = Math.max(0, totalUnread - count);
navItems[0].count = totalUnread;
navItems = [...navItems];
}
const filteredArticles = $derived(articles);
const currentIndex = $derived(
selectedArticle ? filteredArticles.findIndex(a => a.id === selectedArticle!.id) : -1
);
function selectArticle(article: Article) {
selectedArticle = article;
if (!article.read) {
article.read = true;
articles = [...articles];
markEntryRead(article.id);
decrementUnread();
}
}
function closeArticle() { selectedArticle = null; }
function toggleStar(article: Article, e?: Event) {
e?.stopPropagation();
article.starred = !article.starred;
articles = [...articles];
toggleStarAPI(article.id);
}
function toggleRead(article: Article) {
const wasRead = article.read;
article.read = !article.read;
articles = [...articles];
if (article.read) { markEntryRead(article.id); decrementUnread(); }
else { markEntryUnread(article.id); totalUnread++; navItems[0].count = totalUnread; navItems = [...navItems]; }
}
function markAllRead() {
const unreadCount = articles.filter(a => !a.read).length;
markAllReadAPI();
articles = articles.map(a => ({ ...a, read: true }));
decrementUnread(unreadCount);
}
function goNext() {
if (currentIndex < filteredArticles.length - 1) selectArticle(filteredArticles[currentIndex + 1]);
}
function goPrev() {
if (currentIndex > 0) selectArticle(filteredArticles[currentIndex - 1]);
}
function toggleCategory(index: number) {
feedCategories[index].expanded = !feedCategories[index].expanded;
}
function selectFeed(feedId: number) {
activeFeedId = feedId;
activeNav = '';
sidebarOpen = false;
loadEntries();
}
function selectNav(label: string) {
activeNav = label;
activeFeedId = null;
sidebarOpen = false;
loadEntries();
}
// ── Auto-scroll (requestAnimationFrame for smoothness) ──
function startAutoScroll() {
if (scrollRAF) cancelAnimationFrame(scrollRAF);
if (!articleListEl) return;
autoScrollActive = true;
function step() {
if (!autoScrollActive || !articleListEl) return;
articleListEl.scrollTop += autoScrollSpeed;
scrollRAF = requestAnimationFrame(step);
}
scrollRAF = requestAnimationFrame(step);
}
function stopAutoScroll() {
autoScrollActive = false;
if (scrollRAF) { cancelAnimationFrame(scrollRAF); scrollRAF = null; }
}
function toggleAutoScroll() {
if (autoScrollActive) stopAutoScroll();
else startAutoScroll();
}
function adjustSpeed(delta: number) {
autoScrollSpeed = Math.max(0.5, Math.min(5, +(autoScrollSpeed + delta).toFixed(1)));
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'j' || e.key === 'ArrowDown') { e.preventDefault(); goNext(); }
if (e.key === 'k' || e.key === 'ArrowUp') { e.preventDefault(); goPrev(); }
if (e.key === 'Escape' && selectedArticle) { closeArticle(); }
if (e.key === 's' && selectedArticle) { toggleStar(selectedArticle); }
if (e.key === 'm' && selectedArticle) { toggleRead(selectedArticle); }
}
// ── Mark-as-read on scroll (throttled to avoid jitter) ──
let pendingReadIds: number[] = [];
let flushTimer: ReturnType<typeof setTimeout> | null = null;
let scrollCheckTimer: ReturnType<typeof setTimeout> | null = null;
function handleListScroll() {
// Throttle: only check every 400ms
if (scrollCheckTimer) return;
scrollCheckTimer = setTimeout(() => {
scrollCheckTimer = null;
checkScrolledCards();
}, 400);
}
function checkScrolledCards() {
if (!articleListEl) return;
// Infinite scroll — load more when near bottom
const { scrollTop, scrollHeight, clientHeight } = articleListEl;
if (hasMore && !loadingMore && scrollHeight - scrollTop - clientHeight < 300) {
loadEntries(true);
}
const listTop = articleListEl.getBoundingClientRect().top;
const cards = articleListEl.querySelectorAll('[data-entry-id]');
let newlyRead = 0;
cards.forEach(card => {
if (card.getBoundingClientRect().bottom < listTop + 20) {
const id = Number(card.getAttribute('data-entry-id'));
if (!id) return;
const article = articles.find(a => a.id === id);
if (article && !article.read) {
article.read = true;
pendingReadIds.push(id);
newlyRead++;
}
}
});
if (newlyRead > 0) {
articles = [...articles];
decrementUnread(newlyRead);
if (flushTimer) clearTimeout(flushTimer);
flushTimer = setTimeout(flushPendingReads, 1000);
}
}
async function flushPendingReads() {
if (!pendingReadIds.length) return;
const ids = [...pendingReadIds];
pendingReadIds = [];
try {
await api('/entries', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entry_ids: ids, status: 'read' })
});
} catch { /* silent */ }
}
// ── Init ──
onMount(() => {
loadSidebar();
loadEntries();
return () => {
if (flushTimer) clearTimeout(flushTimer);
};
});
</script>
<svelte:window onkeydown={handleKeydown} />
<div class="reader-layout">
<!-- Left Sidebar -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<aside class="reader-sidebar" class:open={sidebarOpen}>
<div class="sidebar-header">
<svg class="rss-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 11a9 9 0 0 1 9 9"/><path d="M4 4a16 16 0 0 1 16 16"/><circle cx="5" cy="19" r="1"/></svg>
<span class="sidebar-title">Reader</span>
</div>
<nav class="sidebar-nav">
{#each navItems as item}
<button
class="nav-item"
class:active={activeNav === item.label}
onclick={() => selectNav(item.label)}
>
<div class="nav-icon">
{#if item.icon === 'inbox'}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>
{:else if item.icon === 'calendar'}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
{:else if item.icon === 'star'}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
{:else}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
{/if}
</div>
<span class="nav-label">{item.label}</span>
{#if item.count > 0}
<span class="nav-count">{item.count}</span>
{/if}
</button>
{/each}
</nav>
<div class="sidebar-separator"></div>
<div class="feeds-section">
<div class="feeds-header">Feeds</div>
{#each feedCategories as cat, i}
<div class="feed-category">
<button class="category-toggle" onclick={() => toggleCategory(i)}>
<svg class="expand-icon" class:expanded={cat.expanded} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
<span class="category-name">{cat.name}</span>
<span class="category-count">{cat.feeds.reduce((s, f) => s + f.count, 0)}</span>
</button>
{#if cat.expanded}
<div class="feed-list">
{#each cat.feeds as feed}
<button class="feed-item" onclick={() => selectFeed(feed.id || 0)}>
<span class="feed-name">{feed.name}</span>
{#if feed.count > 0}
<span class="feed-count">{feed.count}</span>
{/if}
</button>
{/each}
</div>
{/if}
</div>
{/each}
</div>
</aside>
<!-- Sidebar overlay for mobile -->
{#if sidebarOpen}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="sidebar-overlay" onclick={() => sidebarOpen = false}></div>
{/if}
<!-- Middle Panel: Article List -->
<div class="reader-list">
<div class="list-header">
<div class="list-header-top">
<button class="mobile-menu" onclick={() => sidebarOpen = !sidebarOpen} aria-label="Toggle sidebar">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<div class="list-view-name">{activeFeedId ? feedCategories.flatMap(c => c.feeds).find(f => f.id === activeFeedId)?.name || 'Feed' : activeNav} <span class="list-count">{activeNav === 'Today' && !activeFeedId ? totalUnread : filteredArticles.length}</span></div>
<div class="list-actions">
<button class="btn-icon" title="Refresh">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
</button>
<button class="btn-icon" title="Mark all read" onclick={markAllRead}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>
</button>
<button class="btn-icon" class:active-icon={autoScrollActive} onclick={toggleAutoScroll} title="Auto-scroll feed">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14"/><path d="M19 12l-7 7-7-7"/></svg>
</button>
{#if autoScrollActive}
<div class="speed-control">
<button class="speed-btn" onclick={() => adjustSpeed(-0.5)}>-</button>
<span class="speed-value">{autoScrollSpeed}x</span>
<button class="speed-btn" onclick={() => adjustSpeed(0.5)}>+</button>
</div>
{/if}
</div>
</div>
</div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="article-list" bind:this={articleListEl} ontouchstart={handleScrollInterrupt} onwheel={handleScrollInterrupt} onscroll={handleListScroll}>
{#each filteredArticles as article (article.id)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="article-card"
class:selected={selectedArticle?.id === article.id}
class:is-read={article.read}
data-entry-id={article.id}
onclick={() => selectArticle(article)}
>
<!-- Card header: source + time + actions -->
<div class="card-header">
<div class="card-source">
<svg class="card-rss" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 11a9 9 0 0 1 9 9"/><path d="M4 4a16 16 0 0 1 16 16"/><circle cx="5" cy="19" r="1"/></svg>
<span class="card-feed-name">{article.feed}</span>
{#if article.author}
<span class="card-author">· {article.author}</span>
{/if}
</div>
<div class="card-header-right">
<button class="bookmark-btn" class:saved={!!karakeepIds[article.id]} onclick={(e) => toggleKarakeep(article, e)} title={karakeepIds[article.id] ? 'Saved to Karakeep' : 'Save to Karakeep'}>
{#if savingToKarakeep.has(article.id)}
<svg class="spinning" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
{:else}
<svg viewBox="0 0 24 24" fill={karakeepIds[article.id] ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="2"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>
{/if}
</button>
<span class="card-time">{article.timeAgo}</span>
</div>
</div>
<!-- Title -->
<div class="card-title">{article.title}</div>
<!-- Banner image -->
{#if article.thumbnail}
<div class="card-banner" style="background-image:url('{article.thumbnail}')"></div>
{/if}
<!-- Preview text -->
<div class="card-preview">{stripHtml(article.content).slice(0, 200)}</div>
<!-- Footer -->
<div class="card-footer">
<span class="card-reading-time">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
{article.readingTime}
</span>
</div>
</div>
{/each}
{#if filteredArticles.length === 0}
<div class="list-empty">No articles to show</div>
{/if}
</div>
</div>
</div>
<!-- Reading pane — slide-in overlay -->
{#if selectedArticle}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="pane-overlay" onclick={closeArticle}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="pane-sheet" onclick={(e) => e.stopPropagation()}>
<div class="pane-toolbar">
<div class="pane-nav">
<button class="pane-nav-btn" onclick={closeArticle} title="Close (Esc)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
<button class="pane-nav-btn" onclick={goPrev} disabled={currentIndex <= 0} title="Previous (k)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 18l-6-6 6-6"/></svg>
</button>
<span class="pane-nav-pos">{currentIndex + 1} / {filteredArticles.length}</span>
<button class="pane-nav-btn" onclick={goNext} disabled={currentIndex >= filteredArticles.length - 1} title="Next (j)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
</button>
</div>
<div class="pane-actions">
<button class="pane-action-btn" class:saved-karakeep={!!karakeepIds[selectedArticle.id]} onclick={() => toggleKarakeep(selectedArticle!)} title={karakeepIds[selectedArticle.id] ? 'Saved to Karakeep' : 'Save to Karakeep'}>
{#if savingToKarakeep.has(selectedArticle.id)}
<svg class="spinning" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
{:else}
<svg viewBox="0 0 24 24" fill={karakeepIds[selectedArticle.id] ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="2"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>
{/if}
</button>
<button class="pane-action-btn" onclick={() => toggleRead(selectedArticle!)} title="Toggle read (m)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
{#if selectedArticle.url}
<a href={selectedArticle.url} target="_blank" rel="noopener" class="pane-action-btn" title="Open original">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
</a>
{/if}
</div>
</div>
<div class="pane-scroll">
<div class="pane-content">
<h1 class="pane-title">{selectedArticle.title}</h1>
<div class="pane-meta">
<span class="pane-source">{selectedArticle.feed}</span>
<span class="pane-dot"></span>
<span>{selectedArticle.timeAgo}</span>
<span class="pane-dot"></span>
<span>{selectedArticle.readingTime} read</span>
{#if selectedArticle.author}
<span class="pane-dot"></span>
<span class="pane-author">by {selectedArticle.author}</span>
{/if}
</div>
<div class="pane-body">
{@html selectedArticle.content}
</div>
</div>
</div>
</div>
</div>
{/if}
<style>
.reader-layout {
display: flex; height: calc(100vh - 56px);
overflow: hidden; position: relative;
}
/* ── Mobile menu ── */
.mobile-menu {
display: none; width: 30px; height: 30px; border-radius: var(--radius-sm);
background: none; border: 1px solid var(--border);
align-items: center; justify-content: center; cursor: pointer;
flex-shrink: 0; transition: all var(--transition);
}
.mobile-menu:hover { background: var(--card-hover); }
.mobile-menu svg { width: 16px; height: 16px; color: var(--text-3); }
/* ── Sidebar ── */
.reader-sidebar {
width: 220px; flex-shrink: 0; background: var(--surface);
border-right: 1px solid var(--border); display: flex;
flex-direction: column; overflow-y: auto; padding: var(--sp-4) 0 var(--sp-3);
}
.sidebar-header { display: flex; align-items: center; gap: 7px; padding: 0 16px 12px; }
.rss-icon { width: 16px; height: 16px; color: var(--accent); }
.sidebar-title { font-size: var(--text-base); font-weight: 600; color: var(--text-1); }
.sidebar-nav { display: flex; flex-direction: column; gap: var(--sp-0.5); padding: 0 10px; }
.nav-item {
display: flex; align-items: center; gap: var(--sp-2);
padding: var(--sp-2) 10px; border-radius: var(--radius-sm); background: none; border: none;
font-size: var(--text-sm); color: var(--text-3); cursor: pointer;
transition: all var(--transition); text-align: left; width: 100%; font-family: var(--font);
}
.nav-item:hover { background: var(--card-hover); color: var(--text-1); }
.nav-item.active { background: none; color: var(--accent); font-weight: 600; border-left: 2px solid var(--accent); border-radius: 0 var(--radius-sm) var(--radius-sm) 0; padding-left: 8px; }
.nav-icon { width: 15px; height: 15px; flex-shrink: 0; }
.nav-icon svg { width: 15px; height: 15px; }
.nav-label { flex: 1; }
.nav-count { font-size: var(--text-xs); color: var(--text-4); font-family: var(--mono); }
.nav-item.active .nav-count { color: var(--accent); opacity: 0.7; }
.sidebar-separator { height: 1px; background: var(--border); margin: 10px 16px; }
.feeds-section { padding: 0 10px; flex: 1; }
.feeds-header { font-size: var(--text-xs); font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-4); padding: 4px 10px 6px; }
.feed-category { margin-bottom: 1px; }
.category-toggle {
display: flex; align-items: center; gap: 5px; width: 100%;
padding: 5px 10px; background: none; border: none; font-size: var(--text-sm);
font-weight: 500; color: var(--text-2); cursor: pointer; border-radius: var(--radius-sm);
transition: all var(--transition); font-family: var(--font);
}
.category-toggle:hover { background: var(--card-hover); }
.category-name { flex: 1; text-align: left; }
.category-count { font-size: var(--text-xs); color: var(--text-4); font-family: var(--mono); }
.expand-icon { width: 11px; height: 11px; transition: transform var(--transition); flex-shrink: 0; color: var(--text-4); }
.expand-icon.expanded { transform: rotate(90deg); }
.feed-list { padding-left: var(--sp-4); }
.feed-item {
display: flex; align-items: center; justify-content: space-between; width: 100%;
padding: 4px 10px; background: none; border: none; font-size: var(--text-sm);
color: var(--text-3); cursor: pointer; border-radius: var(--radius-sm);
transition: all var(--transition); text-align: left; font-family: var(--font);
}
.feed-item:hover { background: var(--card-hover); color: var(--text-1); }
.feed-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.feed-count { font-size: var(--text-xs); font-family: var(--mono); color: var(--text-4); flex-shrink: 0; margin-left: var(--sp-1.5); }
.sidebar-overlay { position: fixed; inset: 0; background: var(--overlay); z-index: 39; }
/* ── Feed list panel ── */
.reader-list {
flex: 1; min-width: 0;
display: flex; flex-direction: column; background: var(--canvas);
}
.list-header { padding: 12px 20px 10px; background: var(--surface); border-bottom: 1px solid var(--border); flex-shrink: 0; }
.list-header-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--sp-2); }
.list-view-name { font-size: var(--text-base); font-weight: 600; color: var(--text-1); display: flex; align-items: center; gap: var(--sp-1.5); }
.list-count { font-size: var(--text-xs); font-weight: 400; color: var(--text-4); font-family: var(--mono); }
.list-actions { display: flex; gap: var(--sp-1); }
.btn-icon { width: 30px; height: 30px; border-radius: var(--radius-sm); background: none; border: 1px solid var(--border); display: flex; align-items: center; justify-content: center; cursor: pointer; color: var(--text-3); transition: all var(--transition); }
.btn-icon:hover { color: var(--text-1); background: var(--card-hover); }
.btn-icon.active-icon { color: var(--accent); border-color: var(--accent); background: var(--accent-dim); }
.btn-icon svg { width: 14px; height: 14px; }
.list-filters { display: flex; gap: var(--sp-1); }
.filter-btn {
padding: 4px 10px 6px; border-radius: 0; background: none;
border: none; border-bottom: 2px solid transparent; font-size: var(--text-sm); font-weight: 500;
color: var(--text-3); cursor: pointer; transition: all var(--transition); font-family: var(--font);
position: relative;
}
.filter-btn:hover { color: var(--text-2); }
.filter-btn.active { color: var(--text-1); font-weight: 600; border-bottom-color: var(--accent); }
.speed-control { display: flex; align-items: center; gap: var(--sp-0.5); margin-left: var(--sp-1); }
.speed-btn {
width: 22px; height: 22px; border-radius: var(--radius-xs); background: var(--card);
border: 1px solid var(--border); font-size: var(--text-sm); font-weight: 600;
color: var(--text-3); cursor: pointer; display: flex; align-items: center; justify-content: center;
}
.speed-btn:hover { color: var(--text-1); }
.speed-value { font-size: var(--text-xs); font-family: var(--mono); color: var(--text-2); min-width: 24px; text-align: center; }
/* ── Article cards (feed style) ── */
.article-list { flex: 1; overflow-y: auto; padding: var(--sp-2) var(--sp-5) var(--sp-20); display: flex; flex-direction: column; gap: var(--sp-2); }
.article-card {
padding: 14px 16px; border-radius: var(--radius);
background: var(--card); border: none;
box-shadow: var(--card-shadow-sm);
cursor: pointer; transition: all var(--transition);
}
.article-card:hover { box-shadow: var(--card-shadow); transform: translateY(-1px); }
.article-card.selected { box-shadow: 0 0 0 1.5px var(--accent); }
.article-card.is-read { opacity: 0.6; }
.article-card.is-read .card-title { color: var(--text-3); }
.article-card.is-read .card-preview { color: var(--text-4); }
.article-card.is-read:hover { opacity: 0.8; }
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 7px; }
.card-source { display: flex; align-items: center; gap: var(--sp-1); font-size: var(--text-xs); color: var(--text-3); font-weight: 500; min-width: 0; }
.card-rss { width: 12px; height: 12px; color: var(--text-4); flex-shrink: 0; }
.card-feed-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.card-author { color: var(--text-4); font-weight: 400; white-space: nowrap; }
.card-header-right { display: flex; align-items: center; gap: var(--sp-1); flex-shrink: 0; }
.card-time { font-size: var(--text-xs); color: var(--text-4); }
.star-btn {
width: 26px; height: 26px; display: flex; align-items: center; justify-content: center;
background: none; border: none; cursor: pointer; color: var(--text-4); border-radius: var(--radius-sm);
transition: all var(--transition); opacity: 0;
}
.article-card:hover .star-btn { opacity: 1; }
.star-btn.starred { opacity: 1; color: #F59E0B; }
.star-btn:hover { color: #F59E0B; }
.star-btn svg { width: 13px; height: 13px; }
.bookmark-btn {
width: 26px; height: 26px; display: flex; align-items: center; justify-content: center;
background: none; border: none; cursor: pointer; color: var(--text-4); border-radius: var(--radius-sm);
transition: all var(--transition); opacity: 0.5;
}
.article-card:hover .bookmark-btn { opacity: 0.8; }
.bookmark-btn.saved { opacity: 1; color: #F59E0B; }
.bookmark-btn:hover { opacity: 1; color: #F59E0B; }
.bookmark-btn svg { width: 13px; height: 13px; }
.spinning { animation: spin 0.8s linear infinite; }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
.card-title { font-size: var(--text-md); font-weight: 600; color: var(--text-1); line-height: 1.35; margin-bottom: var(--sp-1.5); }
.card-banner { width: 100%; height: 160px; border-radius: var(--radius-md); background: var(--card-hover) center/cover no-repeat; margin-bottom: var(--sp-2); }
.card-preview { font-size: var(--text-sm); color: var(--text-3); line-height: 1.55; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; margin-bottom: var(--sp-1); }
.card-footer { display: flex; align-items: center; gap: var(--sp-2); }
.card-reading-time { display: flex; align-items: center; gap: 3px; font-size: var(--text-xs); color: var(--text-4); }
.card-reading-time svg { width: 11px; height: 11px; }
.list-empty { padding: var(--sp-10); text-align: center; color: var(--text-3); font-size: var(--text-sm); }
/* ── Reading pane (slide-in sheet) ── */
.pane-overlay {
position: fixed; inset: 0; background: var(--overlay); z-index: 60;
display: flex; justify-content: flex-end; animation: paneFadeIn 150ms ease;
}
@keyframes paneFadeIn { from { opacity: 0; } to { opacity: 1; } }
.pane-sheet {
width: 620px; max-width: 100%; height: 100%; background: var(--card);
display: flex; flex-direction: column;
border-left: 1px solid var(--border);
box-shadow: -6px 0 28px rgba(0,0,0,0.08);
animation: paneSlideIn 200ms ease;
}
@keyframes paneSlideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }
.pane-scroll { flex: 1; overflow-y: auto; }
.pane-toolbar {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 20px; border-bottom: 1px solid color-mix(in srgb, var(--border) 70%, transparent); flex-shrink: 0;
}
.pane-nav { display: flex; align-items: center; gap: var(--sp-1); }
.pane-nav-btn {
width: 32px; height: 32px; border-radius: var(--radius-sm); background: none;
border: 1px solid var(--border); display: flex; align-items: center; justify-content: center;
cursor: pointer; color: var(--text-3); transition: all var(--transition);
}
.pane-nav-btn:hover { color: var(--text-1); background: var(--card-hover); }
.pane-nav-btn:disabled { opacity: 0.25; cursor: default; }
.pane-nav-btn svg { width: 14px; height: 14px; }
.pane-nav-pos { font-size: var(--text-xs); color: var(--text-4); font-family: var(--mono); min-width: 44px; text-align: center; }
.pane-actions { display: flex; align-items: center; gap: var(--sp-0.5); }
.pane-action-btn {
width: 32px; height: 32px; border-radius: var(--radius-sm); background: none;
border: none; display: flex; align-items: center; justify-content: center;
cursor: pointer; color: var(--text-3); transition: all var(--transition); text-decoration: none;
}
.pane-action-btn:hover { color: var(--text-1); background: var(--card-hover); }
.pane-action-btn.active { color: var(--accent); }
.pane-action-btn.saved-karakeep { color: #F59E0B; }
.pane-action-btn svg { width: 15px; height: 15px; }
.pane-content { max-width: 640px; margin: 0 auto; padding: var(--sp-8) 36px var(--sp-20); }
.pane-title { font-size: var(--text-xl); font-weight: 600; line-height: 1.3; color: var(--text-1); margin: 0 0 var(--sp-3); letter-spacing: -0.01em; }
.pane-meta {
display: flex; align-items: center; gap: var(--sp-1.5); flex-wrap: wrap;
font-size: var(--text-sm); color: var(--text-3); margin-bottom: var(--sp-6);
padding-bottom: var(--sp-4); border-bottom: 1px solid var(--border);
}
.pane-source { font-weight: 500; color: var(--text-2); }
.pane-author { font-style: italic; }
.pane-dot { width: 3px; height: 3px; border-radius: 50%; background: var(--text-4); }
.pane-body { font-size: var(--text-md); line-height: 1.85; color: var(--text-3); }
.pane-body :global(p) { margin-bottom: 18px; }
.pane-body :global(img) { max-width: 100%; height: auto; border-radius: var(--radius-md); margin: var(--sp-3) 0; }
.pane-body :global(a) { color: var(--accent); text-decoration: underline; }
.pane-body :global(a:hover) { opacity: 0.8; }
.pane-body :global(blockquote) { border-left: 3px solid var(--border); padding-left: var(--sp-4); margin: var(--sp-4) 0; color: var(--text-3); font-style: italic; }
.pane-body :global(pre) { background: var(--surface-secondary); padding: 14px; border-radius: var(--radius-md); overflow-x: auto; font-size: var(--text-sm); font-family: var(--mono); margin: var(--sp-4) 0; }
.pane-body :global(code) { font-family: var(--mono); font-size: 0.9em; }
.pane-body :global(h1), .pane-body :global(h2), .pane-body :global(h3) { color: var(--text-1); margin: 20px 0 10px; }
.pane-body :global(ul), .pane-body :global(ol) { padding-left: var(--sp-5); margin-bottom: var(--sp-4); }
.pane-body :global(li) { margin-bottom: var(--sp-1.5); }
/* ── Responsive ── */
@media (max-width: 1024px) {
.reader-sidebar { display: none; }
.reader-sidebar.open { display: flex; position: fixed; left: 0; top: 56px; bottom: 0; z-index: 40; box-shadow: 8px 0 24px rgba(0,0,0,0.08); }
.mobile-menu { display: flex; }
}
@media (max-width: 768px) {
.reader-layout { height: calc(100vh - 56px); }
.reader-sidebar { display: none; }
.reader-sidebar.open { display: flex; position: fixed; left: 0; top: 56px; bottom: 0; z-index: 40; box-shadow: 8px 0 24px rgba(0,0,0,0.08); width: 260px; }
.mobile-menu { display: flex; }
.article-list { padding: var(--sp-1.5) var(--sp-3) var(--sp-20); gap: var(--sp-1.5); }
.article-card { padding: 12px 14px; }
.card-title { font-size: var(--text-base); }
.card-banner { height: 130px; border-radius: var(--radius-sm); }
.card-preview { -webkit-line-clamp: 2; }
.pane-sheet { width: 100%; }
.pane-content { padding: 20px 18px 80px; }
.pane-title { font-size: var(--text-lg); }
.pane-body { font-size: var(--text-md); line-height: 1.75; }
.pane-toolbar { padding: 8px 14px; }
.list-header { padding: 10px 14px 8px; }
}
</style>

View File

@@ -0,0 +1,328 @@
<script lang="ts">
import { onMount } from 'svelte';
interface App {
id: string;
name: string;
icon: string;
route_prefix: string;
connected: boolean;
}
let user = $state<{ username: string; display_name: string } | null>(null);
let apps = $state<App[]>([]);
let loading = $state(true);
let darkMode = $state(false);
// Connect form state
let showConnectForm = $state('');
let connectToken = $state('');
let connectUser = $state('');
let connectPass = $state('');
let connectError = $state('');
let connectingService = $state('');
const iconMap: Record<string, string> = {
'map': '🗺️', 'bar-chart': '📊', 'package': '📦', 'book': '📚',
'music': '🎵', 'rss': '📰', 'wallet': '💰',
};
onMount(async () => {
// Check theme
darkMode = document.documentElement.classList.contains('dark');
try {
const [meRes, appsRes] = await Promise.all([
fetch('/api/auth/me', { credentials: 'include' }),
fetch('/api/apps', { credentials: 'include' }),
]);
if (meRes.ok) {
const data = await meRes.json();
if (data.authenticated) user = data.user;
}
if (appsRes.ok) {
const data = await appsRes.json();
apps = data.apps || [];
}
} catch { /* silent */ }
loading = false;
});
async function connectService(serviceId: string) {
connectError = '';
connectingService = serviceId;
let token = '';
if (serviceId === 'fitness' && connectUser && connectPass) {
try {
const res = await fetch(`/api/${serviceId}/auth/login`, {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: connectUser, password: connectPass }),
});
const data = await res.json();
if (data.token) { token = data.token; }
else { connectError = data.error || 'Login failed'; connectingService = ''; return; }
} catch { connectError = 'Connection failed'; connectingService = ''; return; }
} else {
token = connectToken;
}
if (!token) { connectError = 'Token required'; connectingService = ''; return; }
try {
const res = await fetch('/api/me/connections', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ service: serviceId, token, auth_type: 'bearer' }),
});
const data = await res.json();
if (data.success) {
apps = apps.map(a => a.id === serviceId ? { ...a, connected: true } : a);
showConnectForm = ''; connectToken = ''; connectUser = ''; connectPass = '';
} else { connectError = data.error || 'Failed to connect'; }
} catch { connectError = 'Connection failed'; }
connectingService = '';
}
async function disconnectService(serviceId: string) {
await fetch('/api/me/connections', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ service: serviceId, action: 'disconnect' }),
});
apps = apps.map(a => a.id === serviceId ? { ...a, connected: false } : a);
}
function toggleTheme() {
darkMode = !darkMode;
document.documentElement.classList.toggle('dark', darkMode);
}
async function logout() {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
window.location.href = '/login';
}
</script>
<div class="page">
<div class="app-surface">
<div class="page-header">
<div class="page-title">SETTINGS</div>
<div class="page-subtitle">Preferences</div>
</div>
{#if loading}
<div class="loading-state">Loading...</div>
{:else}
<!-- Account -->
<section class="settings-section">
<div class="section-title">Account</div>
<div class="settings-card">
<div class="setting-row">
<div class="setting-label">Username</div>
<div class="setting-value">{user?.username || '—'}</div>
</div>
<div class="setting-row">
<div class="setting-label">Display Name</div>
<div class="setting-value">{user?.display_name || '—'}</div>
</div>
<div class="setting-row">
<div></div>
<button class="btn-danger" onclick={logout}>Sign Out</button>
</div>
</div>
</section>
<!-- Appearance -->
<section class="settings-section">
<div class="section-title">Appearance</div>
<div class="settings-card">
<div class="setting-row">
<div>
<div class="setting-label">Theme</div>
<div class="setting-hint">Switch between light and dark mode</div>
</div>
<button class="theme-toggle" class:dark={darkMode} onclick={toggleTheme} aria-label="Toggle theme">
<div class="toggle-track">
<div class="toggle-thumb">
{#if darkMode}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
{:else}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
{/if}
</div>
</div>
</button>
</div>
</div>
</section>
<!-- Service Connections -->
<section class="settings-section">
<div class="section-title">Service Connections</div>
<div class="settings-card">
{#each apps as app}
<div class="setting-row">
<div class="service-info">
<span class="service-icon">{iconMap[app.icon] || '📱'}</span>
<div>
<div class="setting-label">{app.name}</div>
<div class="setting-hint">{app.route_prefix}</div>
</div>
</div>
{#if app.connected}
<div class="service-actions">
<span class="conn-badge connected">Connected</span>
<button class="btn-text-danger" onclick={() => disconnectService(app.id)}>Disconnect</button>
</div>
{:else}
<button class="btn-connect" onclick={() => { showConnectForm = app.id; connectError = ''; }}>Connect</button>
{/if}
</div>
{#if showConnectForm === app.id && !app.connected}
<div class="connect-form">
{#if connectError}
<div class="connect-error">{connectError}</div>
{/if}
{#if app.id === 'fitness'}
<input type="text" class="connect-input" placeholder="Fitness username" bind:value={connectUser} />
<input type="password" class="connect-input" placeholder="Fitness password" bind:value={connectPass} />
{:else}
<input type="password" class="connect-input" placeholder="API Token" bind:value={connectToken} />
{/if}
<div class="connect-actions">
<button class="btn-connect" onclick={() => connectService(app.id)} disabled={connectingService === app.id}>
{connectingService === app.id ? 'Connecting...' : 'Connect'}
</button>
<button class="btn-cancel" onclick={() => showConnectForm = ''}>Cancel</button>
</div>
</div>
{/if}
{/each}
{#if apps.length === 0}
<div class="empty-state">No services configured</div>
{/if}
</div>
</section>
{/if}
</div>
</div>
<style>
.page-subtitle {
font-size: var(--text-2xl);
font-weight: 300;
color: var(--text-1);
line-height: 1.2;
}
.loading-state { padding: var(--sp-12); text-align: center; color: var(--text-3); }
.settings-section { margin-bottom: var(--section-gap); }
.section-title {
font-size: var(--text-sm); font-weight: 600; color: var(--text-3);
text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: var(--sp-3);
}
.settings-card {
background: var(--card); border-radius: var(--radius);
border: 1px solid var(--border); box-shadow: var(--shadow-sm); overflow: hidden;
}
.setting-row {
display: flex; align-items: center; justify-content: space-between;
padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border);
}
.setting-row:last-child { border-bottom: none; }
.setting-label { font-size: var(--text-base); font-weight: 500; color: var(--text-1); }
.setting-value { font-size: var(--text-base); color: var(--text-2); }
.setting-hint { font-size: var(--text-sm); color: var(--text-3); margin-top: var(--sp-0.5); }
.service-info { display: flex; align-items: center; gap: var(--sp-3); }
.service-icon { font-size: var(--text-xl); }
.service-actions { display: flex; align-items: center; gap: var(--sp-2); }
.conn-badge {
font-size: var(--text-sm); font-weight: 600; padding: 3px var(--sp-3);
border-radius: 10px; line-height: 1.5;
}
.conn-badge.connected { background: var(--success-bg); color: var(--success); }
.btn-connect {
padding: var(--sp-1.5) var(--sp-4); border-radius: var(--radius-md);
background: var(--accent); color: white; border: none;
font-size: var(--text-sm); font-weight: 600; cursor: pointer; font-family: var(--font);
transition: opacity var(--transition);
}
.btn-connect:hover { opacity: 0.9; }
.btn-connect:disabled { opacity: 0.5; }
.btn-cancel {
padding: var(--sp-1.5) var(--sp-4); border-radius: var(--radius-md);
background: var(--card-secondary); color: var(--text-2); border: 1px solid var(--border);
font-size: var(--text-sm); font-weight: 500; cursor: pointer; font-family: var(--font);
}
.btn-text-danger {
background: none; border: none; color: var(--error); font-size: var(--text-sm);
font-weight: 500; cursor: pointer; font-family: var(--font);
transition: opacity var(--transition);
}
.btn-text-danger:hover { opacity: 0.7; }
.btn-danger {
padding: var(--sp-2) var(--sp-4); border-radius: var(--radius-md);
background: none; border: 1px solid var(--error); color: var(--error);
font-size: var(--text-sm); font-weight: 500; cursor: pointer; font-family: var(--font);
transition: all var(--transition);
}
.btn-danger:hover { background: var(--error-dim); }
.connect-form {
padding: var(--sp-3) var(--sp-5) var(--sp-4);
border-bottom: 1px solid var(--border);
display: flex; flex-direction: column; gap: var(--sp-2);
}
.connect-input {
width: 100%; padding: var(--sp-2) var(--sp-3); border-radius: var(--radius-md);
border: 1px solid var(--border); background: var(--surface-secondary);
color: var(--text-1); font-size: var(--text-sm); font-family: var(--font);
}
.connect-input:focus { outline: none; border-color: var(--accent); }
.connect-input::placeholder { color: var(--text-4); }
.connect-actions { display: flex; gap: var(--sp-2); }
.connect-error {
font-size: var(--text-sm); color: var(--error); background: var(--error-dim);
padding: var(--sp-2) var(--sp-3); border-radius: var(--radius-sm);
}
.empty-state { padding: var(--sp-8); text-align: center; color: var(--text-3); font-size: var(--text-sm); }
/* Theme Toggle */
.theme-toggle { background: none; border: none; padding: 0; cursor: pointer; }
.toggle-track {
width: 48px; height: 28px; border-radius: 14px;
background: var(--border-strong); position: relative; transition: background var(--transition);
}
.theme-toggle.dark .toggle-track { background: var(--accent); }
.toggle-thumb {
width: 22px; height: 22px; border-radius: 50%; background: white;
position: absolute; top: 3px; left: 3px;
transition: transform var(--transition);
display: flex; align-items: center; justify-content: center;
box-shadow: 0 1px 3px rgba(0,0,0,0.15);
}
.theme-toggle.dark .toggle-thumb { transform: translateX(20px); }
.toggle-thumb svg { width: 14px; height: 14px; color: var(--text-3); }
.theme-toggle.dark .toggle-thumb svg { color: var(--accent); }
@media (max-width: 768px) {
.page-subtitle { font-size: var(--text-xl); }
.setting-row { flex-wrap: wrap; gap: var(--sp-2); }
}
</style>

View File

@@ -0,0 +1,368 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import CreateTripModal from '$lib/components/trips/CreateTripModal.svelte';
let createOpen = $state(false);
interface Trip {
id: string;
name: string;
dates: string;
daysAway: string;
status: 'active' | 'upcoming' | 'completed';
cover: string;
duration: string;
cities: string[];
points?: number;
cash?: number;
shareToken?: string;
sortDate: string;
}
let searchQuery = $state('');
let upcoming = $state<Trip[]>([]);
let past = $state<Trip[]>([]);
let stats = $state({ trips: 0, cities: 0, countries: 0, points: 0 });
let loading = $state(true);
const allTrips = $derived([...upcoming, ...past]);
const filteredTrips = $derived(
searchQuery.trim()
? allTrips.filter(t =>
t.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
t.cities.some(c => c.toLowerCase().includes(searchQuery.toLowerCase()))
)
: null
);
// ── Helpers ──
function formatPoints(n: number): string {
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
if (n >= 1000) return Math.round(n / 1000) + 'K';
return n.toString();
}
function formatDateRange(start: string, end: string): string {
if (!start) return '';
const s = new Date(start + 'T00:00:00');
const e = new Date(end + 'T00:00:00');
const sMonth = s.toLocaleDateString('en-US', { month: 'short' });
const eMonth = e.toLocaleDateString('en-US', { month: 'short' });
const sYear = s.getFullYear();
const eYear = e.getFullYear();
if (sMonth === eMonth && sYear === eYear) {
return `${sMonth} ${s.getDate()} ${e.getDate()}, ${sYear}`;
}
if (sYear === eYear) {
return `${sMonth} ${s.getDate()} ${eMonth} ${e.getDate()}, ${sYear}`;
}
return `${sMonth} ${s.getDate()}, ${sYear} ${eMonth} ${e.getDate()}, ${eYear}`;
}
function daysBetween(dateStr: string): string {
const d = new Date(dateStr + 'T00:00:00');
const now = new Date();
now.setHours(0, 0, 0, 0);
const diff = Math.ceil((d.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
if (diff <= 0) return '';
if (diff === 1) return '1 day';
return `${diff} days`;
}
function durationDays(start: string, end: string): string {
if (!start || !end) return '';
const s = new Date(start + 'T00:00:00');
const e = new Date(end + 'T00:00:00');
const days = Math.ceil((e.getTime() - s.getTime()) / (1000 * 60 * 60 * 24)) + 1;
return `${days} days`;
}
function coverUrl(trip: any): string {
if (trip.cover_image) return trip.cover_image;
return '';
}
function mapTrip(raw: any): Trip {
const now = new Date().toISOString().slice(0, 10);
const isActive = raw.start_date <= now && raw.end_date >= now;
const isUpcoming = raw.start_date > now;
return {
id: raw.id,
name: raw.name,
dates: formatDateRange(raw.start_date, raw.end_date),
daysAway: isUpcoming ? daysBetween(raw.start_date) : '',
status: isActive ? 'active' : isUpcoming ? 'upcoming' : 'completed',
cover: coverUrl(raw),
duration: durationDays(raw.start_date, raw.end_date),
cities: [],
points: 0,
cash: 0,
shareToken: raw.share_token || '',
sortDate: raw.start_date || ''
};
}
// ── Load ──
onMount(async () => {
try {
const [tripsRes, statsRes] = await Promise.all([
fetch('/api/trips/trips', { credentials: 'include' }),
fetch('/api/trips/stats', { credentials: 'include' })
]);
if (tripsRes.ok) {
const data = await tripsRes.json();
const now = new Date().toISOString().slice(0, 10);
const all = (data.trips || []).map(mapTrip);
upcoming = all.filter(t => t.status === 'active' || t.status === 'upcoming')
.sort((a, b) => a.sortDate.localeCompare(b.sortDate));
past = all.filter(t => t.status === 'completed')
.sort((a, b) => b.sortDate.localeCompare(a.sortDate));
}
if (statsRes.ok) {
const data = await statsRes.json();
stats = {
trips: data.total_trips || 0,
cities: data.cities_visited || 0,
countries: data.countries_visited || 0,
points: data.total_points_redeemed || 0
};
// Enrich trips with cities from stats
const citiesByCountry = data.cities_by_country || {};
// Can't easily map cities to individual trips from stats alone
// Cities will come from trip detail when opened
}
} catch { /* silent */ }
finally { loading = false; }
});
</script>
<div class="page">
<div class="app-surface">
<!-- Header -->
<div class="header-row">
<div>
<div class="page-title">TRIPS</div>
<div class="page-subtitle">Your Adventures</div>
</div>
<button class="btn-primary" onclick={() => createOpen = true}>+ Plan Trip</button>
</div>
<!-- Stats Bar -->
<div class="stats-bar">
<div class="stat-box">
<div class="stat-value">{stats.trips}</div>
<div class="stat-label">Trips</div>
</div>
<div class="stat-box">
<div class="stat-value">{stats.cities}</div>
<div class="stat-label">Cities</div>
</div>
<div class="stat-box">
<div class="stat-value">{stats.countries}</div>
<div class="stat-label">Countries</div>
</div>
<div class="stat-box">
<div class="stat-value">{formatPoints(stats.points)}</div>
<div class="stat-label">Points Used</div>
</div>
</div>
<!-- Search -->
<div class="search-wrap">
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
<input class="input search-input" type="text" placeholder="Search trips, cities, hotels, flights..." bind:value={searchQuery} />
{#if searchQuery}
<button class="search-clear" onclick={() => searchQuery = ''}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
{/if}
</div>
{#if filteredTrips}
<!-- Search results -->
<div class="search-results-label">{filteredTrips.length} result{filteredTrips.length !== 1 ? 's' : ''}</div>
<div class="trip-grid">
{#each filteredTrips as trip (trip.id)}
<a href="/trips/trip?id={trip.id}" class="trip-card" class:muted={trip.status === 'completed'}>
<div class="trip-photo" style={trip.cover ? `background-image:url('${trip.cover}')` : ''}>
{#if !trip.cover}
<div class="photo-empty">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>
</div>
{/if}
</div>
<div class="trip-body">
<div class="trip-name">{trip.name}</div>
{#if trip.dates}<div class="trip-dates">{trip.dates}</div>{/if}
{#if trip.cities.length > 0}<div class="trip-cities">{trip.cities.join(' · ')}</div>{/if}
<div class="trip-footer">
{#if trip.status === 'active'}<span class="badge active">Active</span>{/if}
{#if trip.status === 'completed'}<span class="badge completed">Completed</span>{/if}
{#if trip.daysAway}<span class="trip-countdown">{trip.daysAway}</span>{/if}
{#if trip.points}
<span class="trip-points">{formatPoints(trip.points)} pts</span>
{/if}
</div>
</div>
</a>
{/each}
</div>
{:else}
<!-- Upcoming -->
<section class="section">
<div class="section-title">UPCOMING</div>
<div class="trip-grid">
{#each upcoming as trip (trip.id)}
<a href="/trips/trip?id={trip.id}" class="trip-card">
<div class="trip-photo" style="background-image:url('{trip.cover}')">
{#if trip.shareToken}
<button class="share-btn" title="Share trip">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><polyline points="16 6 12 2 8 6"/><line x1="12" y1="2" x2="12" y2="15"/></svg>
</button>
{/if}
</div>
<div class="trip-body">
<div class="trip-name">{trip.name}</div>
<div class="trip-dates">{trip.dates} · {trip.duration}</div>
<div class="trip-cities">{trip.cities.join(' · ')}</div>
<div class="trip-footer">
{#if trip.status === 'active'}<span class="badge active">Active</span>{/if}
<span class="trip-countdown">{trip.daysAway}</span>
{#if trip.points}
<span class="trip-points">{formatPoints(trip.points)} pts</span>
{/if}
</div>
</div>
</a>
{/each}
</div>
</section>
<!-- Past -->
<section class="section">
<div class="section-title">PAST ADVENTURES</div>
<div class="trip-grid">
{#each past as trip (trip.id)}
<a href="/trips/trip?id={trip.id}" class="trip-card muted">
<div class="trip-photo" style={trip.cover ? `background-image:url('${trip.cover}')` : ''}>
{#if !trip.cover}
<div class="photo-empty">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>
</div>
{/if}
{#if trip.shareToken}
<button class="share-btn" title="Share trip">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><polyline points="16 6 12 2 8 6"/><line x1="12" y1="2" x2="12" y2="15"/></svg>
</button>
{/if}
</div>
<div class="trip-body">
<div class="trip-name">{trip.name}</div>
{#if trip.dates}<div class="trip-dates">{trip.dates} · {trip.duration}</div>{/if}
{#if trip.cities.length > 0}<div class="trip-cities">{trip.cities.join(' · ')}</div>{/if}
<div class="trip-footer">
<span class="badge completed">Completed</span>
{#if trip.points}
<span class="trip-points">{formatPoints(trip.points)} pts</span>
{/if}
{#if trip.cash}
<span class="trip-cash">${trip.cash.toLocaleString()}</span>
{/if}
</div>
</div>
</a>
{/each}
</div>
</section>
{/if}
</div>
</div>
<CreateTripModal bind:open={createOpen} onCreated={(id) => goto(`/trips/trip?id=${id}`)} />
<style>
/* ── Stats Bar ── */
.stats-bar { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--sp-2); margin-bottom: 14px; }
.stat-box { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: var(--sp-2) 6px; text-align: center; }
.stat-value { font-size: var(--text-md); font-weight: 700; font-family: var(--mono); color: var(--text-1); line-height: 1.1; }
.stat-label { font-size: var(--text-xs); color: var(--text-3); margin-top: var(--sp-0.5); text-transform: uppercase; letter-spacing: 0.05em; }
/* ── Header ── */
.header-row { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-4); margin-bottom: var(--sp-3); }
.page-subtitle { font-size: var(--text-2xl); font-weight: 300; color: var(--text-1); line-height: 1.15; margin-top: -1px; }
.btn-primary { padding: var(--sp-2) var(--sp-4); border-radius: var(--radius-md); background: var(--accent); color: white; border: none; font-size: var(--text-sm); font-weight: 600; cursor: pointer; font-family: var(--font); transition: opacity var(--transition); }
.btn-primary:hover { opacity: 0.9; }
/* ── Search ── */
.search-wrap { position: relative; margin-bottom: var(--sp-4); }
.search-icon { position: absolute; left: 14px; top: 50%; transform: translateY(-50%); width: 16px; height: 16px; color: var(--text-4); pointer-events: none; }
.search-input { padding-left: 40px; font-size: var(--text-md); }
.search-clear { position: absolute; right: 12px; top: 50%; transform: translateY(-50%); background: none; border: none; cursor: pointer; color: var(--text-4); padding: var(--sp-1); }
.search-clear svg { width: 16px; height: 16px; }
.search-results-label { font-size: var(--text-sm); color: var(--text-3); margin-bottom: var(--sp-3); }
/* ── Sections ── */
.section { margin-bottom: var(--section-gap); }
.section-title { font-size: var(--text-xs); font-weight: 600; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: var(--sp-3); }
/* ── Trip Grid ── */
.trip-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--sp-5); }
/* ── Trip Card ── */
.trip-card {
background: var(--card); border-radius: var(--radius); border: 1px solid var(--border);
box-shadow: var(--card-shadow-sm); overflow: hidden; transition: all var(--transition);
text-decoration: none; color: inherit; display: block;
}
.trip-card:hover { transform: translateY(-2px); box-shadow: var(--card-shadow); }
.trip-card.muted { opacity: 0.6; }
.trip-card.muted:hover { opacity: 0.85; }
.trip-photo {
width: 100%; height: 180px; background: var(--card-hover) center/cover no-repeat;
position: relative;
}
.photo-empty {
width: 100%; height: 100%; display: flex; align-items: center; justify-content: center;
color: var(--text-4);
}
.photo-empty svg { width: 28px; height: 28px; opacity: 0.3; }
.share-btn {
position: absolute; top: var(--sp-2); right: var(--sp-2);
width: 28px; height: 28px; border-radius: 50%;
background: rgba(0,0,0,0.35); backdrop-filter: blur(6px);
border: none; color: white; display: flex; align-items: center; justify-content: center;
cursor: pointer; transition: all var(--transition); opacity: 0;
}
.trip-card:hover .share-btn { opacity: 1; }
.share-btn:hover { background: rgba(0,0,0,0.55); }
.share-btn svg { width: 13px; height: 13px; }
.trip-body { padding: 8px 14px 10px; }
.trip-name { font-size: var(--text-base); font-weight: 600; color: var(--text-1); line-height: 1.3; }
.trip-dates { font-size: var(--text-xs); color: var(--text-3); margin-top: 3px; }
.trip-cities { font-size: var(--text-xs); color: var(--text-3); margin-top: 1px; opacity: 0.7; }
.trip-footer { display: flex; align-items: center; gap: var(--sp-1.5); margin-top: var(--sp-1.5); flex-wrap: wrap; border-top: 1px solid var(--border); padding-top: var(--sp-1.5); }
.trip-countdown { font-size: var(--text-xs); color: var(--text-3); font-family: var(--mono); }
.trip-points { font-size: var(--text-xs); font-family: var(--mono); color: var(--accent); font-weight: 500; }
.trip-cash { font-size: var(--text-xs); font-family: var(--mono); color: var(--text-3); }
/* ── Badges ── */
.badge { font-size: var(--text-xs); font-weight: 600; padding: 2px var(--sp-2); border-radius: var(--radius-sm); line-height: 1.5; }
.badge.active { background: var(--accent-bg); color: var(--accent); }
.badge.completed { background: var(--surface-secondary); color: var(--text-3); }
@media (max-width: 768px) {
.stats-bar { grid-template-columns: repeat(2, 1fr); }
.trip-grid { grid-template-columns: 1fr; }
.page-subtitle { font-size: var(--text-xl); }
.trip-photo { height: 160px; }
.trip-body { padding: 10px 16px 12px; }
.trip-name { font-size: var(--text-md); }
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,9 @@
<script lang="ts">
let { children } = $props();
</script>
<svelte:head>
<title>Login — Platform</title>
</svelte:head>
{@render children()}

View File

@@ -0,0 +1,128 @@
<script lang="ts">
import { platformAuth } from '$lib/api/client';
import { page } from '$app/state';
let username = $state('');
let password = $state('');
let error = $state('');
let loading = $state(false);
async function handleLogin(e: Event) {
e.preventDefault();
error = '';
loading = true;
try {
const res = await platformAuth.login(username, password);
if (res.error) {
error = res.error;
} else {
const redirectTo = page.url.searchParams.get('redirect') || '/';
window.location.href = redirectTo;
}
} catch {
error = 'Unable to connect to server';
} finally {
loading = false;
}
}
</script>
<div class="login-wrapper">
<div class="login-card">
<div class="login-logo">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="24" height="24"><path d="M3 7l6-3 6 3 6-3v13l-6 3-6-3-6 3V7z"/><path d="M9 4v13"/><path d="M15 7v13"/></svg>
Platform
</div>
<div class="login-subtitle">Sign in to your account</div>
<form onsubmit={handleLogin}>
{#if error}
<div class="login-error">{error}</div>
{/if}
<div class="login-field">
<label class="login-label" for="username">Username</label>
<input
id="username"
class="input"
type="text"
placeholder="Enter username"
bind:value={username}
autocomplete="username"
required
/>
</div>
<div class="login-field">
<label class="login-label" for="password">Password</label>
<input
id="password"
class="input"
type="password"
placeholder="Enter password"
bind:value={password}
autocomplete="current-password"
required
/>
</div>
<button class="btn-primary full" type="submit" disabled={loading}>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
</div>
</div>
<style>
.login-wrapper {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: var(--canvas);
}
.login-card {
width: 100%;
max-width: 380px;
background: var(--card);
border-radius: var(--radius);
padding: 32px;
box-shadow: var(--card-shadow);
border: 1px solid var(--border);
}
.login-logo {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
margin-bottom: 8px;
font-size: var(--text-lg);
font-weight: 600;
color: var(--text-1);
}
.login-subtitle {
text-align: center;
font-size: var(--text-base);
color: var(--text-3);
margin-bottom: 28px;
}
.login-field {
margin-bottom: 16px;
}
.login-label {
display: block;
font-size: var(--text-sm);
font-weight: 500;
color: var(--text-2);
margin-bottom: 6px;
}
.login-error {
font-size: var(--text-sm);
color: var(--error);
background: var(--error-bg);
padding: 10px 14px;
border-radius: var(--radius-sm);
margin-bottom: 16px;
}
</style>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import '../app.css';
import { onMount } from 'svelte';
import { initTheme } from '$lib/stores/theme.svelte';
let { children } = $props();
onMount(() => {
initTheme();
});
</script>
<svelte:head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<title>Platform</title>
</svelte:head>
{@render children()}

View File

@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:

View File

@@ -0,0 +1,14 @@
import adapter from '@sveltejs/adapter-node';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
adapter: adapter()
},
vitePlugin: {
dynamicCompileOptions: ({ filename }) =>
filename.includes('node_modules') ? undefined : { runes: true }
}
};
export default config;

20
frontend-v2/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}

View File

@@ -0,0 +1,10 @@
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
server: {
allowedHosts: ['test.quadjourney.com']
}
});

5
gateway/Dockerfile Normal file
View File

@@ -0,0 +1,5 @@
FROM python:3.12-slim
WORKDIR /app
COPY server.py .
EXPOSE 8100
CMD ["python3", "server.py"]

1878
gateway/server.py Normal file

File diff suppressed because it is too large Load Diff

2658
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

17
package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "platform",
"private": true,
"version": "0.1.0",
"workspaces": [
"frontend",
"packages/*"
],
"scripts": {
"dev": "npm --workspace frontend run dev",
"build": "npm --workspace frontend run build",
"check": "npm --workspace frontend run check"
},
"devDependencies": {
"playwright": "^1.58.2"
}
}

View File

@@ -0,0 +1,7 @@
{
"name": "@platform/api-utils",
"private": true,
"version": "0.1.0",
"type": "module"
}

View File

@@ -0,0 +1,6 @@
{
"name": "@platform/ui-components",
"private": true,
"version": "0.1.0",
"type": "module"
}

View File

@@ -0,0 +1,14 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --production
COPY server.js ./
RUN mkdir -p /app/data
EXPOSE 3001
CMD ["node", "server.js"]

1443
services/budget/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,14 @@
{
"name": "budget-service",
"version": "1.0.0",
"description": "REST API wrapper for Actual Budget",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"@actual-app/api": "^26.3.0",
"cors": "^2.8.5",
"express": "^4.21.2"
}
}

653
services/budget/server.js Normal file
View File

@@ -0,0 +1,653 @@
// Polyfill browser globals that @actual-app/api v26 expects in Node.js
if (typeof globalThis.navigator === 'undefined') {
globalThis.navigator = { platform: 'linux', userAgent: 'node' };
}
const express = require('express');
const cors = require('cors');
const api = require('@actual-app/api');
const app = express();
app.use(cors());
app.use(express.json());
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
const ACTUAL_SERVER_URL = process.env.ACTUAL_SERVER_URL || 'http://actualbudget:5006';
const ACTUAL_PASSWORD = process.env.ACTUAL_PASSWORD;
const ACTUAL_SYNC_ID = process.env.ACTUAL_SYNC_ID;
const PORT = process.env.PORT || 3001;
const DATA_DIR = process.env.ACTUAL_DATA_DIR || '/app/data';
// How often (ms) to silently re-sync with the server in the background.
const SYNC_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
let ready = false;
// ---------------------------------------------------------------------------
// Actual Budget connection lifecycle
// ---------------------------------------------------------------------------
async function initActual() {
console.log(`[budget] Connecting to Actual server at ${ACTUAL_SERVER_URL}`);
await api.init({
serverURL: ACTUAL_SERVER_URL,
password: ACTUAL_PASSWORD,
dataDir: DATA_DIR,
});
console.log(`[budget] Downloading budget ${ACTUAL_SYNC_ID}`);
await api.downloadBudget(ACTUAL_SYNC_ID);
ready = true;
console.log('[budget] Budget loaded and ready');
}
async function syncBudget() {
try {
await api.sync();
console.log(`[budget] Background sync completed at ${new Date().toISOString()}`);
} catch (err) {
console.error('[budget] Background sync failed:', err.message);
}
}
// ---------------------------------------------------------------------------
// Middleware - reject requests until the budget is loaded
// ---------------------------------------------------------------------------
function requireReady(req, res, next) {
if (!ready) {
return res.status(503).json({ error: 'Budget not loaded yet' });
}
next();
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Convert cents (integer) to a human-readable dollar amount. */
function centsToDollars(cents) {
return cents / 100;
}
/** Get the first and last day of a YYYY-MM month string. */
function monthBounds(month) {
const [year, m] = month.split('-').map(Number);
const start = `${month}-01`;
const lastDay = new Date(year, m, 0).getDate();
const end = `${month}-${String(lastDay).padStart(2, '0')}`;
return { start, end };
}
/** Current month as YYYY-MM. */
function currentMonth() {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
}
/** Build lookup maps for payees, accounts, and categories. */
async function buildLookups() {
const [payees, accounts, categories] = await Promise.all([
api.getPayees(),
api.getAccounts(),
api.getCategories(),
]);
const payeeMap = {};
for (const p of payees) payeeMap[p.id] = p.name;
const accountMap = {};
for (const a of accounts) accountMap[a.id] = a.name;
const categoryMap = {};
for (const c of categories) categoryMap[c.id] = c.name;
return { payeeMap, accountMap, categoryMap };
}
/** Enrich a transaction with resolved names. */
function enrichTransaction(t, payeeMap, accountMap, categoryMap) {
return {
...t,
amountDollars: centsToDollars(t.amount),
payeeName: payeeMap[t.payee] || t.imported_payee || t.notes || t.payee || '',
accountName: t.accountName || accountMap[t.account] || t.account || '',
categoryName: categoryMap[t.category] || null,
};
}
// ---------------------------------------------------------------------------
// Routes
// ---------------------------------------------------------------------------
app.get('/health', (_req, res) => {
res.json({ status: 'ok', ready });
});
// ---- Accounts -------------------------------------------------------------
app.get('/accounts', requireReady, async (_req, res) => {
try {
const accounts = await api.getAccounts();
const enriched = await Promise.all(
accounts.map(async (acct) => {
const balance = await api.getAccountBalance(acct.id);
return {
...acct,
balance,
balanceDollars: centsToDollars(balance),
};
}),
);
res.json(enriched);
} catch (err) {
console.error('[budget] GET /accounts error:', err);
res.status(500).json({ error: err.message });
}
});
// ---- Transactions (filtered) ----------------------------------------------
app.get('/transactions', requireReady, async (req, res) => {
try {
const { accountId, startDate, endDate, limit, offset } = req.query;
if (!accountId) {
return res.status(400).json({ error: 'accountId query parameter is required' });
}
const start = startDate || '1970-01-01';
const end = endDate || '2099-12-31';
const { payeeMap, accountMap, categoryMap } = await buildLookups();
let txns = await api.getTransactions(accountId, start, end);
// Sort newest first
txns.sort((a, b) => (b.date > a.date ? 1 : b.date < a.date ? -1 : 0));
const total = txns.length;
const pageOffset = parseInt(offset, 10) || 0;
const pageLimit = parseInt(limit, 10) || 50;
txns = txns.slice(pageOffset, pageOffset + pageLimit);
res.json({
total,
offset: pageOffset,
limit: pageLimit,
transactions: txns.map((t) => enrichTransaction(t, payeeMap, accountMap, categoryMap)),
});
} catch (err) {
console.error('[budget] GET /transactions error:', err);
res.status(500).json({ error: err.message });
}
});
// ---- Recent transactions across all accounts ------------------------------
app.get('/transactions/recent', requireReady, async (_req, res) => {
try {
const limit = parseInt(_req.query.limit, 10) || 20;
const accounts = await api.getAccounts();
const { payeeMap, accountMap, categoryMap } = await buildLookups();
const daysBack = new Date();
daysBack.setDate(daysBack.getDate() - 90);
const startDate = daysBack.toISOString().slice(0, 10);
const endDate = new Date().toISOString().slice(0, 10);
let all = [];
for (const acct of accounts) {
const txns = await api.getTransactions(acct.id, startDate, endDate);
all.push(...txns.map((t) => ({ ...t, accountName: acct.name })));
}
all.sort((a, b) => (b.date > a.date ? 1 : b.date < a.date ? -1 : 0));
all = all.slice(0, limit);
res.json(all.map((t) => enrichTransaction(t, payeeMap, accountMap, categoryMap)));
} catch (err) {
console.error('[budget] GET /transactions/recent error:', err);
res.status(500).json({ error: err.message });
}
});
// ---- Categories -----------------------------------------------------------
app.get('/categories', requireReady, async (_req, res) => {
try {
const groups = await api.getCategoryGroups();
const categories = await api.getCategories();
const grouped = groups.map((g) => ({
...g,
categories: categories.filter((c) => c.group_id === g.id),
}));
res.json(grouped);
} catch (err) {
console.error('[budget] GET /categories error:', err);
res.status(500).json({ error: err.message });
}
});
// ---- Budget for a month ---------------------------------------------------
app.get('/budget/:month', requireReady, async (req, res) => {
try {
const { month } = req.params;
if (!/^\d{4}-\d{2}$/.test(month)) {
return res.status(400).json({ error: 'month must be YYYY-MM format' });
}
const data = await api.getBudgetMonth(month);
res.json(data);
} catch (err) {
console.error(`[budget] GET /budget/${req.params.month} error:`, err);
res.status(500).json({ error: err.message });
}
});
// ---- Update a transaction -------------------------------------------------
app.patch('/transactions/:id', requireReady, async (req, res) => {
try {
const { id } = req.params;
const updates = req.body;
if (!updates || Object.keys(updates).length === 0) {
return res.status(400).json({ error: 'Request body must contain fields to update' });
}
await api.updateTransaction(id, updates);
await api.sync();
res.json({ success: true, id, updated: updates });
} catch (err) {
console.error(`[budget] PATCH /transactions/${req.params.id} error:`, err);
res.status(500).json({ error: err.message });
}
});
// ---- Make Transfer (link two existing transactions) -----------------------
app.post('/make-transfer', requireReady, async (req, res) => {
try {
const { transactionId1, transactionId2 } = req.body;
if (!transactionId1 || !transactionId2) {
return res.status(400).json({ error: 'transactionId1 and transactionId2 are required' });
}
// Get payees to find transfer payees for each account
const payees = await api.getPayees();
const accounts = await api.getAccounts();
// We need to figure out which account each transaction belongs to
// Fetch both transactions by getting all recent + searching
const allAccounts = await api.getAccounts();
let tx1 = null;
let tx2 = null;
for (const acct of allAccounts) {
const txns = await api.getTransactions(acct.id, '1970-01-01', '2099-12-31');
if (!tx1) tx1 = txns.find((t) => t.id === transactionId1);
if (!tx2) tx2 = txns.find((t) => t.id === transactionId2);
if (tx1 && tx2) break;
}
if (!tx1 || !tx2) {
return res.status(404).json({ error: 'Could not find one or both transactions' });
}
if (tx1.account === tx2.account) {
return res.status(400).json({ error: 'Both transactions are from the same account' });
}
// Per Actual docs: set the transfer payee on ONE transaction.
// Actual auto-creates the counterpart on the other account.
// Then delete the other original transaction (it's now replaced).
// Pick the outgoing (negative) transaction to keep, delete the incoming one
const keepTx = tx1.amount < 0 ? tx1 : tx2;
const deleteTx = tx1.amount < 0 ? tx2 : tx1;
const keepId = keepTx === tx1 ? transactionId1 : transactionId2;
const deleteId = deleteTx === tx1 ? transactionId1 : transactionId2;
// Find the transfer payee for the account we're transferring TO (the deleted tx's account)
const targetTransferPayee = payees.find((p) => p.transfer_acct === deleteTx.account);
if (!targetTransferPayee) {
return res.status(400).json({ error: 'Could not find transfer payee for destination account' });
}
// Step 1: Set transfer payee on the kept transaction → Actual creates counterpart
await api.updateTransaction(keepId, { payee: targetTransferPayee.id });
// Step 2: Delete the original other transaction (Actual already made a new linked one)
await api.deleteTransaction(deleteId);
await api.sync();
const acctName1 = accounts.find((a) => a.id === keepTx.account)?.name || keepTx.account;
const acctName2 = accounts.find((a) => a.id === deleteTx.account)?.name || deleteTx.account;
console.log(`[budget] Linked transfer: ${acctName1} <-> ${acctName2} (kept ${keepId}, deleted ${deleteId})`);
res.json({
success: true,
linked: { transactionId1: keepId, transactionId2: deleteId, account1: acctName1, account2: acctName2 },
});
} catch (err) {
console.error('[budget] POST /make-transfer error:', err);
res.status(500).json({ error: err.message });
}
});
// ---- Uncategorized count (total across all accounts) ----------------------
app.get('/uncategorized-count', requireReady, async (_req, res) => {
try {
const accounts = await api.getAccounts();
const startDate = '2000-01-01';
const endDate = new Date().toISOString().slice(0, 10);
let total = 0;
for (const acct of accounts) {
if (acct.closed) continue;
const txns = await api.getTransactions(acct.id, startDate, endDate);
total += txns.filter((t) => !t.category && !t.transfer_id && t.amount !== 0).length;
}
res.json({ count: total });
} catch (err) {
console.error('[budget] GET /uncategorized-count error:', err);
res.status(500).json({ error: err.message });
}
});
// ---- Suggested transfers --------------------------------------------------
app.get('/suggested-transfers', requireReady, async (_req, res) => {
try {
// Sync first to get latest state
await api.sync().catch(() => {});
const accounts = await api.getAccounts();
const { payeeMap, accountMap, categoryMap } = await buildLookups();
// Get last 90 days of transactions across all accounts
const daysBack = new Date();
daysBack.setDate(daysBack.getDate() - 90);
const startDate = daysBack.toISOString().slice(0, 10);
const endDate = new Date().toISOString().slice(0, 10);
let allTxns = [];
for (const acct of accounts) {
if (acct.closed) continue;
const txns = await api.getTransactions(acct.id, startDate, endDate);
allTxns.push(
...txns.map((t) => ({
...t,
accountName: acct.name,
payeeName: payeeMap[t.payee] || t.imported_payee || t.notes || '',
categoryName: categoryMap[t.category] || null,
amountDollars: centsToDollars(t.amount),
})),
);
}
// Only consider transactions that aren't already transfers and aren't categorized
const candidates = allTxns.filter(
(t) => !t.transfer_id && t.amount !== 0 && !t.starting_balance_flag,
);
// Payment-related keywords
const paymentKeywords = [
'payment', 'credit card', 'crd epay', 'online transfer',
'transfer from', 'transfer to', 'autopay', 'bill pay',
'pymt', 'payment received', 'payment thank',
];
function looksLikePayment(t) {
const text = ((t.payeeName || '') + ' ' + (t.notes || '')).toLowerCase();
return paymentKeywords.some((kw) => text.includes(kw));
}
// Build index by absolute amount
const byAmount = {};
for (const t of candidates) {
const key = Math.abs(t.amount);
if (!byAmount[key]) byAmount[key] = [];
byAmount[key].push(t);
}
const suggestions = [];
for (const [amt, group] of Object.entries(byAmount)) {
if (group.length < 2) continue;
// Find negative (outgoing) and positive (incoming) transactions
const negatives = group.filter((t) => t.amount < 0);
const positives = group.filter((t) => t.amount > 0);
for (const neg of negatives) {
for (const pos of positives) {
// Must be different accounts
if (neg.account === pos.account) continue;
// At least one should look like a payment
if (!looksLikePayment(neg) && !looksLikePayment(pos)) continue;
// Dates should be within 3 days
const d1 = new Date(neg.date);
const d2 = new Date(pos.date);
const daysDiff = Math.abs(d1 - d2) / 86400000;
if (daysDiff > 3) continue;
// Calculate confidence
let confidence = 60; // base: matching amount + different accounts
if (looksLikePayment(neg) && looksLikePayment(pos)) confidence += 20;
else if (looksLikePayment(neg) || looksLikePayment(pos)) confidence += 10;
if (daysDiff === 0) confidence += 15;
else if (daysDiff <= 1) confidence += 10;
else if (daysDiff <= 2) confidence += 5;
// Check if payee name matches an account name (strong signal)
const negPayeeLower = (neg.payeeName || '').toLowerCase();
const posPayeeLower = (pos.payeeName || '').toLowerCase();
const negAcctLower = (neg.accountName || '').toLowerCase();
const posAcctLower = (pos.accountName || '').toLowerCase();
if (
negPayeeLower.includes(posAcctLower.slice(0, 10)) ||
posPayeeLower.includes(negAcctLower.slice(0, 10)) ||
negAcctLower.includes(negPayeeLower.slice(0, 10)) ||
posAcctLower.includes(posPayeeLower.slice(0, 10))
) {
confidence += 15;
}
suggestions.push({
confidence: Math.min(confidence, 100),
amount: Math.abs(neg.amount),
amountDollars: Math.abs(neg.amountDollars),
from: {
id: neg.id,
account: neg.accountName,
accountId: neg.account,
payee: neg.payeeName,
date: neg.date,
notes: neg.notes,
},
to: {
id: pos.id,
account: pos.accountName,
accountId: pos.account,
payee: pos.payeeName,
date: pos.date,
notes: pos.notes,
},
});
}
}
}
// Sort by confidence desc, then amount desc
suggestions.sort((a, b) => b.confidence - a.confidence || b.amount - a.amount);
// Deduplicate — each transaction should only appear in one suggestion
const used = new Set();
const deduped = [];
for (const s of suggestions) {
if (used.has(s.from.id) || used.has(s.to.id)) continue;
used.add(s.from.id);
used.add(s.to.id);
deduped.push(s);
}
res.json(deduped);
} catch (err) {
console.error('[budget] GET /suggested-transfers error:', err);
res.status(500).json({ error: err.message });
}
});
// ---- Transfer payees (for "Make Transfer" feature) ------------------------
app.get('/transfer-payees', requireReady, async (_req, res) => {
try {
const payees = await api.getPayees();
const accounts = await api.getAccounts();
const accountMap = {};
for (const a of accounts) accountMap[a.id] = a.name;
// Transfer payees have transfer_acct set — they map to an account
const transferPayees = payees
.filter((p) => p.transfer_acct)
.map((p) => ({
payeeId: p.id,
payeeName: p.name,
accountId: p.transfer_acct,
accountName: accountMap[p.transfer_acct] || p.name,
}));
res.json(transferPayees);
} catch (err) {
console.error('[budget] GET /transfer-payees error:', err);
res.status(500).json({ error: err.message });
}
});
// ---- Dashboard summary ----------------------------------------------------
app.get('/summary', requireReady, async (_req, res) => {
try {
// Total balance across all accounts
const accounts = await api.getAccounts();
let totalBalance = 0;
for (const acct of accounts) {
totalBalance += await api.getAccountBalance(acct.id);
}
// Spending this month
const month = currentMonth();
const { start, end } = monthBounds(month);
let allTxns = [];
for (const acct of accounts) {
const txns = await api.getTransactions(acct.id, start, end);
allTxns.push(...txns);
}
// Spending = sum of negative amounts (expenses)
const spending = allTxns
.filter((t) => t.amount < 0)
.reduce((sum, t) => sum + t.amount, 0);
const income = allTxns
.filter((t) => t.amount > 0)
.reduce((sum, t) => sum + t.amount, 0);
// Top spending categories
const categories = await api.getCategories();
const catMap = new Map(categories.map((c) => [c.id, c.name]));
const byCat = {};
for (const t of allTxns) {
if (t.amount < 0 && t.category) {
const name = catMap.get(t.category) || 'Uncategorized';
byCat[name] = (byCat[name] || 0) + t.amount;
}
}
const topCategories = Object.entries(byCat)
.map(([name, amount]) => ({ name, amount, amountDollars: centsToDollars(amount) }))
.sort((a, b) => a.amount - b.amount) // most negative first
.slice(0, 10);
res.json({
month,
totalBalance,
totalBalanceDollars: centsToDollars(totalBalance),
spending,
spendingDollars: centsToDollars(spending),
income,
incomeDollars: centsToDollars(income),
topCategories,
accountCount: accounts.length,
transactionCount: allTxns.length,
});
} catch (err) {
console.error('[budget] GET /summary error:', err);
res.status(500).json({ error: err.message });
}
});
// ---- Force sync -----------------------------------------------------------
app.post('/sync', requireReady, async (_req, res) => {
try {
await api.sync();
res.json({ success: true, syncedAt: new Date().toISOString() });
} catch (err) {
console.error('[budget] POST /sync error:', err);
res.status(500).json({ error: err.message });
}
});
// ---------------------------------------------------------------------------
// Startup
// ---------------------------------------------------------------------------
async function start() {
if (!ACTUAL_PASSWORD) {
console.error('[budget] ACTUAL_PASSWORD environment variable is required');
process.exit(1);
}
if (!ACTUAL_SYNC_ID) {
console.error('[budget] ACTUAL_SYNC_ID environment variable is required');
process.exit(1);
}
try {
await initActual();
} catch (err) {
console.error('[budget] Failed to initialise Actual connection:', err);
process.exit(1);
}
// Periodic background sync
setInterval(syncBudget, SYNC_INTERVAL_MS);
app.listen(PORT, () => {
console.log(`[budget] Server listening on port ${PORT}`);
});
}
// Graceful shutdown
async function shutdown() {
console.log('[budget] Shutting down...');
try {
await api.shutdown();
} catch (_) {
// ignore
}
process.exit(0);
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
start();

View File

@@ -0,0 +1,5 @@
FROM python:3.12-slim
WORKDIR /app
COPY server.py .
EXPOSE 8095
CMD ["python3", "server.py"]

View File

@@ -0,0 +1,28 @@
services:
backend:
build:
context: .
dockerfile: Dockerfile.backend
container_name: calorietracker-backend
restart: unless-stopped
ports:
- "8095:8095"
volumes:
- ./data:/app/data
env_file: .env
environment:
- PORT=8095
- DATA_DIR=/app/data
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: calorietracker-frontend
restart: unless-stopped
ports:
- "8096:3000"
environment:
- VITE_API_URL=http://calorietracker-backend:8095
depends_on:
- backend

View File

@@ -0,0 +1,16 @@
FROM node:22-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-slim
WORKDIR /app
COPY --from=build /app/build ./build
COPY --from=build /app/package.json ./
COPY --from=build /app/node_modules ./node_modules
ENV NODE_ENV=production
ENV PORT=3000
EXPOSE 3000
CMD ["node", "build"]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
{
"name": "calorietracker-frontend",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"svelte": "^5.51.0",
"svelte-check": "^4.4.2",
"typescript": "^5.9.3",
"vite": "^7.3.1"
},
"dependencies": {
"@sveltejs/adapter-node": "^5.5.4",
"@tailwindcss/vite": "^4.2.2",
"daisyui": "^5.5.19",
"tailwindcss": "^4.2.2"
}
}

View File

@@ -0,0 +1,25 @@
@import 'tailwindcss';
@plugin 'daisyui' {
themes: night, dim, light;
}
:root {
--header-height: 4rem;
}
* {
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
}
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: oklch(0.2 0.02 260); }
::-webkit-scrollbar-thumb { background: oklch(0.4 0.02 260); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: oklch(0.5 0.02 260); }
/* Card hover elevation */
.card-hover {
transition: box-shadow 0.3s ease, border-color 0.3s ease;
}
.card-hover:hover {
box-shadow: 0 10px 25px -5px rgb(0 0 0 / 0.15), 0 8px 10px -6px rgb(0 0 0 / 0.1);
}

View File

@@ -0,0 +1,5 @@
declare global {
namespace App {}
}
export {};

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en" data-theme="night">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22%237c3aed%22 stroke-width=%222%22><path d=%22M12 20V10%22/><path d=%22M18 20V4%22/><path d=%22M6 20v-4%22/></svg>" />
%sveltekit.head%
</head>
<body data-sveltekit-prerender="true">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -0,0 +1,39 @@
import type { Handle } from '@sveltejs/kit';
const API_BACKEND = process.env.VITE_API_URL || 'http://localhost:8095';
export const handle: Handle = async ({ event, resolve }) => {
if (event.url.pathname.startsWith('/api/') || event.url.pathname.startsWith('/images/')) {
const targetUrl = `${API_BACKEND}${event.url.pathname}${event.url.search}`;
const headers = new Headers();
for (const [key, value] of event.request.headers.entries()) {
if (['authorization', 'content-type', 'cookie', 'x-api-key', 'x-telegram-user-id'].includes(key.toLowerCase())) {
headers.set(key, value);
}
}
try {
const response = await fetch(targetUrl, {
method: event.request.method,
headers,
body: event.request.method !== 'GET' && event.request.method !== 'HEAD'
? await event.request.arrayBuffer()
: undefined,
});
return new Response(response.body, {
status: response.status,
headers: response.headers,
});
} catch (err) {
console.error('Proxy error:', err);
return new Response(JSON.stringify({ error: 'Backend unavailable' }), {
status: 502,
headers: { 'Content-Type': 'application/json' },
});
}
}
return resolve(event);
};

View File

@@ -0,0 +1,81 @@
const API_BASE = import.meta.env.VITE_API_URL || '';
export function getToken(): string | null {
return typeof window !== 'undefined' ? localStorage.getItem('session_token') : null;
}
export function hasToken(): boolean {
return !!getToken();
}
export async function api<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = getToken();
if (!token && typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) {
window.location.href = '/login';
throw new Error('No token');
}
const headers: Record<string, string> = {
...(options.headers as Record<string, string> || {})
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
if (options.body && typeof options.body === 'string') {
headers['Content-Type'] = 'application/json';
}
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers,
credentials: 'include'
});
if (res.status === 401) {
if (typeof window !== 'undefined') {
localStorage.removeItem('session_token');
window.location.href = '/login';
}
throw new Error('Unauthorized');
}
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || `HTTP ${res.status}`);
}
return res.json();
}
export function get<T>(path: string) {
return api<T>(path);
}
export function post<T>(path: string, data: unknown) {
return api<T>(path, { method: 'POST', body: JSON.stringify(data) });
}
export function patch<T>(path: string, data: unknown) {
return api<T>(path, { method: 'PATCH', body: JSON.stringify(data) });
}
export function put<T>(path: string, data: unknown) {
return api<T>(path, { method: 'PUT', body: JSON.stringify(data) });
}
export function del<T>(path: string) {
return api<T>(path, { method: 'DELETE' });
}
export function today(): string {
return new Date().toISOString().split('T')[0];
}
export function formatDate(d: string): string {
return new Date(d + 'T00:00:00').toLocaleDateString('en-US', {
weekday: 'short', month: 'short', day: 'numeric'
});
}

View File

@@ -0,0 +1,165 @@
export interface User {
id: string;
username: string;
display_name: string;
telegram_user_id?: string;
}
export interface Food {
id: string;
name: string;
brand?: string;
barcode?: string;
base_unit: string;
calories_per_base: number;
protein_per_base: number;
carbs_per_base: number;
fat_per_base: number;
status: string;
image_path?: string;
servings: FoodServing[];
aliases?: FoodAlias[];
score?: number;
match_type?: string;
}
export interface FoodServing {
id: string;
food_id: string;
name: string;
amount_in_base: number;
is_default: number;
}
export interface FoodAlias {
id: string;
food_id: string;
alias: string;
alias_normalized: string;
}
export interface FoodEntry {
id: string;
user_id: string;
food_id?: string;
meal_type: string;
entry_date: string;
entry_type: string;
quantity: number;
unit: string;
serving_description?: string;
snapshot_food_name: string;
snapshot_serving_label?: string;
snapshot_grams?: number;
snapshot_calories: number;
snapshot_protein: number;
snapshot_carbs: number;
snapshot_fat: number;
source: string;
entry_method: string;
raw_text?: string;
confidence_score?: number;
note?: string;
food_image_path?: string;
created_at: string;
}
export interface DailyTotals {
total_calories: number;
total_protein: number;
total_carbs: number;
total_fat: number;
entry_count: number;
}
export interface Goal {
id: string;
user_id: string;
start_date: string;
end_date?: string;
calories: number;
protein: number;
carbs: number;
fat: number;
is_active: number;
}
export interface MealTemplate {
id: string;
user_id: string;
name: string;
meal_type?: string;
is_favorite: number;
items: MealTemplateItem[];
}
export interface MealTemplateItem {
id: string;
food_id: string;
quantity: number;
unit: string;
snapshot_food_name: string;
snapshot_calories: number;
snapshot_protein: number;
snapshot_carbs: number;
snapshot_fat: number;
}
export interface QueueItem {
id: string;
user_id: string;
raw_text: string;
proposed_food_id?: string;
candidates_json?: string;
confidence: number;
meal_type?: string;
entry_date?: string;
source?: string;
created_at: string;
}
export interface ExternalFood {
name: string;
brand?: string;
barcode?: string;
calories_per_100g: number;
protein_per_100g: number;
carbs_per_100g: number;
fat_per_100g: number;
serving_size_text?: string;
serving_grams?: number;
source: string;
relevance_score?: number;
}
export interface ResolveResult {
resolution_type: 'matched' | 'confirm' | 'queued' | 'quick_add' | 'ai_estimated' | 'external_match';
confidence: number;
matched_food?: Food;
candidate_foods: Food[];
external_results: ExternalFood[];
ai_estimate?: Record<string, unknown>;
parsed: {
quantity: number;
unit: string;
food_description: string;
meal_type?: string;
brand?: string;
modifiers?: string;
exclusions?: string;
};
raw_text: string;
queue_id?: string;
reason?: string;
}
export type MealType = 'breakfast' | 'lunch' | 'dinner' | 'snack';
export const MEAL_TYPES: MealType[] = ['breakfast', 'lunch', 'dinner', 'snack'];
export const MEAL_LABELS: Record<MealType, string> = {
breakfast: 'B',
lunch: 'L',
dinner: 'D',
snack: 'S'
};

View File

@@ -0,0 +1,270 @@
<script lang="ts">
import { get, post } from '$lib/api/client.ts';
import type { Food, MealType, ResolveResult } from '$lib/api/types.ts';
import { MEAL_TYPES } from '$lib/api/types.ts';
let { date = '', defaultMeal = 'snack' as MealType, onSave = () => {}, onClose = () => {} }:
{ date: string; defaultMeal: MealType; onSave: () => void; onClose: () => void } = $props();
let query = $state('');
let results = $state<Food[]>([]);
let recentFoods = $state<Food[]>([]);
let searching = $state(false);
let saving = $state(false);
let selectedFood = $state<Food | null>(null);
let selectedServing = $state('');
let quantity = $state(1);
let mealType = $state<MealType>(defaultMeal);
let mode = $state<'search' | 'quick' | 'ai'>('search');
let quickCalories = $state(0);
let quickName = $state('Quick add');
let aiQuery = $state('');
let aiResolving = $state(false);
let aiResult = $state<ResolveResult & { snapshot_name_override?: string; note?: string } | null>(null);
let searchTimeout: ReturnType<typeof setTimeout>;
$effect(() => { loadRecent(); });
async function loadRecent() {
try { recentFoods = await get<Food[]>('/api/foods/recent?limit=15'); } catch {}
}
async function doSearch() {
if (!query.trim()) { results = []; return; }
searching = true;
try { results = await get<Food[]>(`/api/foods/search?q=${encodeURIComponent(query)}&limit=10`); }
catch {} finally { searching = false; }
}
function onInput() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(doSearch, 300);
}
function selectFood(food: Food) {
selectedFood = food;
const defaultServing = food.servings?.find(s => s.is_default) || food.servings?.[0];
selectedServing = defaultServing?.id || '';
quantity = 1;
}
async function aiResolve() {
if (!aiQuery.trim()) return;
aiResolving = true;
aiResult = null;
try {
const result = await post<ResolveResult>('/api/foods/resolve', {
raw_phrase: aiQuery,
meal_type: mealType,
entry_date: date,
source: 'web',
});
aiResult = result;
// If matched or AI estimated, auto-select the food
if ((result.resolution_type === 'matched' || result.resolution_type === 'ai_estimated') && result.matched_food) {
selectFood(result.matched_food);
quantity = result.parsed?.quantity || 1;
}
} catch {} finally { aiResolving = false; }
}
async function save() {
saving = true;
try {
if (mode === 'quick') {
await post('/api/entries', {
entry_type: 'quick_add',
meal_type: mealType,
entry_date: date,
snapshot_food_name: quickName,
snapshot_calories: quickCalories,
source: 'web',
entry_method: 'quick_add',
});
} else if (selectedFood) {
const entryData: Record<string, unknown> = {
food_id: selectedFood.id,
meal_type: mealType,
entry_date: date,
quantity,
serving_id: selectedServing || undefined,
source: 'web',
entry_method: mode === 'ai' ? 'ai_plate' : 'search',
raw_text: aiQuery || undefined,
};
if (aiResult?.snapshot_name_override) {
entryData.snapshot_food_name_override = aiResult.snapshot_name_override;
}
if (aiResult?.note) {
entryData.note = aiResult.note;
}
await post('/api/entries', entryData);
}
onSave();
} catch {} finally { saving = false; }
}
function calcNutrition(food: Food, qty: number, servingId: string) {
const serving = food.servings?.find(s => s.id === servingId);
const mult = serving ? qty * serving.amount_in_base : qty;
return {
cal: Math.round(food.calories_per_base * mult),
pro: Math.round(food.protein_per_base * mult),
carb: Math.round(food.carbs_per_base * mult),
fat: Math.round(food.fat_per_base * mult),
};
}
</script>
<div class="modal modal-open" role="dialog">
<div class="modal-box max-w-lg max-h-[90vh]">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick={onClose}>X</button>
<h3 class="font-bold text-lg mb-3">Add Food</h3>
<!-- Meal type pills -->
<div class="flex gap-1 mb-3">
{#each MEAL_TYPES as mt}
<button class="btn btn-xs" class:btn-primary={mealType === mt} onclick={() => mealType = mt}>
{mt}
</button>
{/each}
</div>
<!-- Mode tabs -->
<div role="tablist" class="tabs tabs-boxed mb-3">
<button role="tab" class="tab" class:tab-active={mode === 'search'} onclick={() => { mode = 'search'; selectedFood = null; }}>Search</button>
<button role="tab" class="tab" class:tab-active={mode === 'ai'} onclick={() => { mode = 'ai'; selectedFood = null; }}>AI Describe</button>
<button role="tab" class="tab" class:tab-active={mode === 'quick'} onclick={() => { mode = 'quick'; selectedFood = null; }}>Quick Add</button>
</div>
{#if mode === 'quick'}
<div class="flex flex-col gap-3">
<input class="input input-bordered w-full" placeholder="Label (optional)" bind:value={quickName} />
<input class="input input-bordered w-full" type="number" placeholder="Calories" bind:value={quickCalories} />
<button class="btn btn-primary w-full" onclick={save} disabled={saving || quickCalories <= 0}>
{#if saving}<span class="loading loading-spinner loading-sm"></span>{/if}
Add {quickCalories} cal to {mealType}
</button>
</div>
{:else if mode === 'ai' && !selectedFood}
<!-- AI describe mode -->
<div class="flex flex-col gap-3">
<textarea
class="textarea textarea-bordered w-full"
rows="3"
placeholder="Describe what you ate, e.g.&#10;2 mince tacos, no sour cream&#10;A scoop of vanilla ice cream&#10;Homemade smash burger"
bind:value={aiQuery}
></textarea>
<button class="btn btn-primary w-full" onclick={aiResolve} disabled={aiResolving || !aiQuery.trim()}>
{#if aiResolving}<span class="loading loading-spinner loading-sm"></span> Estimating...{:else}Estimate with AI{/if}
</button>
{#if aiResult && aiResult.resolution_type === 'queued'}
<div class="alert alert-warning text-sm">
<span>Could not estimate. Queued for review.</span>
</div>
{/if}
{#if aiResult && aiResult.resolution_type === 'confirm' && aiResult.candidate_foods?.length}
<div class="text-sm font-medium">Did you mean one of these?</div>
{#each aiResult.candidate_foods as c}
<button class="btn btn-sm btn-outline w-full justify-between" onclick={() => selectFood(c)}>
<span>{c.name}</span>
<span class="text-xs">{c.calories_per_base} cal/{c.base_unit}</span>
</button>
{/each}
{/if}
</div>
{:else if selectedFood}
<!-- Selected food detail -->
<div class="card bg-base-200 p-3 mb-3">
<div class="flex justify-between items-start">
<div>
<div class="font-semibold">{selectedFood.name}</div>
{#if selectedFood.brand}<div class="text-xs text-base-content/50">{selectedFood.brand}</div>{/if}
{#if selectedFood.status === 'ai_created'}<span class="badge badge-xs badge-info">AI estimated</span>{/if}
</div>
<button class="btn btn-ghost btn-xs" onclick={() => selectedFood = null}>Change</button>
</div>
<div class="flex gap-3 mt-2">
<div class="form-control flex-1">
<label class="label py-0"><span class="label-text text-xs">Qty</span></label>
<input class="input input-bordered input-sm w-full" type="number" min="0.25" step="0.25" bind:value={quantity} />
</div>
{#if selectedFood.servings?.length > 0}
<div class="form-control flex-[2]">
<label class="label py-0"><span class="label-text text-xs">Serving</span></label>
<select class="select select-bordered select-sm w-full" bind:value={selectedServing}>
{#each selectedFood.servings as s}
<option value={s.id}>{s.name}</option>
{/each}
</select>
</div>
{/if}
</div>
{#if selectedFood}
{@const n = calcNutrition(selectedFood, quantity, selectedServing)}
<div class="grid grid-cols-4 gap-2 mt-3 text-center text-sm">
<div><div class="font-bold text-primary">{n.cal}</div><div class="text-xs text-base-content/50">cal</div></div>
<div><div class="font-bold text-secondary">{n.pro}g</div><div class="text-xs text-base-content/50">protein</div></div>
<div><div class="font-bold text-accent">{n.carb}g</div><div class="text-xs text-base-content/50">carbs</div></div>
<div><div class="font-bold text-info">{n.fat}g</div><div class="text-xs text-base-content/50">fat</div></div>
</div>
{/if}
</div>
<button class="btn btn-primary w-full" onclick={save} disabled={saving}>
{#if saving}<span class="loading loading-spinner loading-sm"></span>{/if}
Add to {mealType}
</button>
{:else}
<!-- Search mode -->
<input
class="input input-bordered w-full mb-2"
placeholder="Search your foods..."
bind:value={query}
oninput={onInput}
/>
{#if searching}
<div class="flex justify-center py-4"><span class="loading loading-spinner loading-md"></span></div>
{:else}
<div class="overflow-y-auto max-h-60">
{#if query.trim() && results.length > 0}
{#each results as food}
<button class="w-full text-left px-3 py-2 hover:bg-base-200 rounded-lg flex justify-between items-center" onclick={() => selectFood(food)}>
<div>
<div class="text-sm font-medium">{food.name}</div>
{#if food.brand}<span class="text-xs text-base-content/40">{food.brand}</span>{/if}
</div>
<div class="text-xs text-base-content/50">{food.calories_per_base} cal/{food.base_unit}</div>
</button>
{/each}
{:else if query.trim()}
<div class="text-center py-4">
<div class="text-sm text-base-content/50 mb-2">No matches found</div>
<button class="btn btn-sm btn-outline" onclick={() => { mode = 'ai'; aiQuery = query; }}>
Try AI estimate for "{query}"
</button>
</div>
{:else}
{#if recentFoods.length > 0}
<div class="text-xs text-base-content/50 mb-1">Recent</div>
{/if}
{#each recentFoods as food}
<button class="w-full text-left px-3 py-2 hover:bg-base-200 rounded-lg flex justify-between items-center" onclick={() => selectFood(food)}>
<div>
<div class="text-sm font-medium">{food.name}</div>
<div class="text-xs text-base-content/50">{food.calories_per_base} cal/{food.base_unit}</div>
</div>
</button>
{/each}
{#if recentFoods.length === 0}
<div class="text-center text-sm text-base-content/50 py-4">No foods yet. Use AI Describe to add your first food.</div>
{/if}
{/if}
</div>
{/if}
{/if}
</div>
<form method="dialog" class="modal-backdrop"><button onclick={onClose}>close</button></form>
</div>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
let { value = 0, goal = 0, label = '', unit = '', color = 'primary' }:
{ value: number; goal: number; label: string; unit: string; color: string } = $props();
let pct = $derived(goal > 0 ? Math.min((value / goal) * 100, 100) : 0);
let remaining = $derived(Math.max(goal - value, 0));
let over = $derived(value > goal && goal > 0);
let borderColor = $derived(over ? 'border-l-error' : color === 'primary' ? 'border-l-primary' : color === 'secondary' ? 'border-l-secondary' : color === 'accent' ? 'border-l-accent' : 'border-l-info');
let progressClass = $derived(over ? 'progress-error' : color === 'primary' ? 'progress-primary' : color === 'secondary' ? 'progress-secondary' : color === 'accent' ? 'progress-accent' : 'progress-info');
</script>
<div class="bg-base-200 rounded-xl p-4 border-l-4 {borderColor}">
<div class="text-xs text-base-content/50 font-medium uppercase tracking-wide">{label}</div>
<div class="flex items-baseline gap-1.5 mt-1">
<span class="text-2xl font-bold">{Math.round(value)}</span>
<span class="text-sm text-base-content/40">/ {Math.round(goal)}{unit}</span>
</div>
<progress class="progress w-full h-1.5 mt-2 {progressClass}" value={pct} max="100"></progress>
<div class="text-xs mt-1 {over ? 'text-error' : 'text-base-content/40'}">
{#if over}{Math.round(value - goal)}{unit} over{:else}{Math.round(remaining)}{unit} left{/if}
</div>
</div>

View File

@@ -0,0 +1,84 @@
<script lang="ts">
let theme = $state('night');
let { user = null, currentPage = '' }: { user?: { display_name: string } | null; currentPage?: string } = $props();
function toggleTheme() {
theme = theme === 'night' ? 'light' : 'night';
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
}
function logout() {
localStorage.removeItem('session_token');
window.location.href = '/login';
}
$effect(() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('theme');
if (saved) {
theme = saved;
document.documentElement.setAttribute('data-theme', saved);
}
}
});
let mobileOpen = $state(false);
const navItems = [
{ href: '/', label: 'Dashboard', page: 'dashboard' },
{ href: '/foods', label: 'Foods', page: 'foods' },
{ href: '/goals', label: 'Goals', page: 'goals' },
{ href: '/templates', label: 'Templates', page: 'templates' },
{ href: '/admin', label: 'Admin', page: 'admin' },
];
</script>
<div class="bg-base-200/80 backdrop-blur-md sticky top-0 z-50 border-b border-base-300">
<div class="navbar px-4">
<div class="flex-1 flex items-center gap-1">
<!-- Mobile hamburger -->
<button class="btn btn-ghost btn-sm btn-circle sm:hidden" onclick={() => mobileOpen = !mobileOpen}>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" /></svg>
</button>
<a href="/" class="flex items-center gap-2 text-xl font-bold text-primary hover:opacity-80 transition-opacity mr-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-7 w-7" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20V10"/>
<path d="M18 20V4"/>
<path d="M6 20v-4"/>
</svg>
<span class="hidden sm:inline">Calories</span>
</a>
{#each navItems as item}
<a href={item.href} class="btn btn-ghost btn-sm hidden sm:inline-flex" class:btn-active={currentPage === item.page}>{item.label}</a>
{/each}
</div>
<div class="flex-none flex items-center gap-2">
<!-- Theme toggle -->
<label class="swap swap-rotate btn btn-ghost btn-circle btn-sm">
<input type="checkbox" checked={theme === 'light'} onchange={toggleTheme} />
<svg class="swap-on h-5 w-5 fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M5.64,17l-.71.71a1,1,0,0,0,0,1.41,1,1,0,0,0,1.41,0l.71-.71A1,1,0,0,0,5.64,17ZM5,12a1,1,0,0,0-1-1H3a1,1,0,0,0,0,2H4A1,1,0,0,0,5,12Zm7-7a1,1,0,0,0,1-1V3a1,1,0,0,0-2,0V4A1,1,0,0,0,12,5ZM5.64,7.05a1,1,0,0,0,.7.29,1,1,0,0,0,.71-.29,1,1,0,0,0,0-1.41l-.71-.71A1,1,0,0,0,4.93,6.34Zm12,.29a1,1,0,0,0,.7-.29l.71-.71a1,1,0,1,0-1.41-1.41L17,5.64a1,1,0,0,0,0,1.41A1,1,0,0,0,17.66,7.34ZM21,11H20a1,1,0,0,0,0,2h1a1,1,0,0,0,0-2Zm-9,8a1,1,0,0,0-1,1v1a1,1,0,0,0,2,0V20A1,1,0,0,0,12,19ZM18.36,17A1,1,0,0,0,17,18.36l.71.71a1,1,0,0,0,1.41,0,1,1,0,0,0,0-1.41ZM12,6.5A5.5,5.5,0,1,0,17.5,12,5.51,5.51,0,0,0,12,6.5Zm0,9A3.5,3.5,0,1,1,15.5,12,3.5,3.5,0,0,1,12,15.5Z"/></svg>
<svg class="swap-off h-5 w-5 fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M21.64,13a1,1,0,0,0-1.05-.14,8.05,8.05,0,0,1-3.37.73A8.15,8.15,0,0,1,9.08,5.49a8.59,8.59,0,0,1,.25-2A1,1,0,0,0,8,2.36,10.14,10.14,0,1,0,22,14.05,1,1,0,0,0,21.64,13Zm-9.5,6.69A8.14,8.14,0,0,1,7.08,5.22v.27A10.15,10.15,0,0,0,17.22,15.63a9.79,9.79,0,0,0,2.1-.22A8.11,8.11,0,0,1,12.14,19.73Z"/></svg>
</label>
<!-- User dropdown -->
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm gap-1">
<div class="w-7 h-7 rounded-full bg-primary/20 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
</div>
<span class="hidden sm:inline text-sm">{user?.display_name || 'User'}</span>
</div>
<ul class="dropdown-content menu bg-base-200 rounded-box z-[1] w-52 p-2 shadow-lg border border-base-300 mt-2">
<li><button onclick={logout}>Logout</button></li>
</ul>
</div>
</div>
</div>
{#if mobileOpen}
<div class="sm:hidden border-t border-base-300 px-4 py-2">
{#each navItems as item}
<a href={item.href} class="block py-2 px-3 rounded-lg hover:bg-base-300 transition-colors {currentPage === item.page ? 'text-primary font-semibold' : 'text-base-content'}" onclick={() => mobileOpen = false}>{item.label}</a>
{/each}
</div>
{/if}
</div>

View File

@@ -0,0 +1,37 @@
<script lang="ts">
import '../app.css';
import Navbar from '$lib/components/Navbar.svelte';
import { get, hasToken } from '$lib/api/client.ts';
import type { User } from '$lib/api/types.ts';
import { page } from '$app/state';
let { children } = $props();
let user = $state<User | null>(null);
let currentPage = $derived(
page.url.pathname === '/' ? 'dashboard' :
page.url.pathname.startsWith('/foods') ? 'foods' :
page.url.pathname.startsWith('/goals') ? 'goals' :
page.url.pathname.startsWith('/templates') ? 'templates' :
page.url.pathname.startsWith('/admin') ? 'admin' : ''
);
$effect(() => {
if (typeof window !== 'undefined' && hasToken() && !page.url.pathname.startsWith('/login')) {
get<User>('/api/user').then(u => user = u).catch(() => {});
}
});
</script>
<svelte:head>
<title>Calorie Tracker</title>
</svelte:head>
<div class="min-h-screen bg-base-100 flex flex-col">
{#if !page.url.pathname.startsWith('/login')}
<Navbar {user} {currentPage} />
{/if}
<main class="flex-1">
{@render children()}
</main>
</div>

View File

@@ -0,0 +1,297 @@
<script lang="ts">
import { get, del, today, formatDate } from '$lib/api/client.ts';
import type { FoodEntry, DailyTotals, Goal, User, MealType, QueueItem } from '$lib/api/types.ts';
import { MEAL_TYPES } from '$lib/api/types.ts';
import AddFoodModal from '$lib/components/AddFoodModal.svelte';
let selectedDate = $state(today());
let entries = $state<FoodEntry[]>([]);
let totals = $state<DailyTotals>({ total_calories: 0, total_protein: 0, total_carbs: 0, total_fat: 0, entry_count: 0 });
let goal = $state<Goal | null>(null);
let users = $state<User[]>([]);
let selectedUser = $state('');
let showAddModal = $state(false);
let addMealType = $state<MealType>('snack');
let queueCount = $state(0);
let loading = $state(true);
let expandedMeals = $state<Set<string>>(new Set(['breakfast', 'lunch', 'dinner', 'snack']));
let isToday = $derived(selectedDate === today());
$effect(() => { loadUsers(); });
$effect(() => { if (selectedUser) loadDay(); });
async function loadUsers() {
try {
users = await get<User[]>('/api/users');
const me = await get<User>('/api/user');
selectedUser = me.id;
} catch {}
}
async function loadDay() {
loading = true;
try {
const [e, t, g, q] = await Promise.all([
get<FoodEntry[]>(`/api/entries?date=${selectedDate}&user_id=${selectedUser}`),
get<DailyTotals>(`/api/entries/totals?date=${selectedDate}&user_id=${selectedUser}`),
get<Goal>(`/api/goals/for-date?date=${selectedDate}&user_id=${selectedUser}`).catch(() => null),
get<QueueItem[]>('/api/resolution-queue').catch(() => []),
]);
entries = e; totals = t; goal = g; queueCount = q.length;
} catch {} finally { loading = false; }
}
function entriesByMeal(meal: MealType) { return entries.filter(e => e.meal_type === meal); }
function mealCalories(meal: MealType) { return entriesByMeal(meal).reduce((s, e) => s + e.snapshot_calories, 0); }
function mealProtein(meal: MealType) { return entriesByMeal(meal).reduce((s, e) => s + e.snapshot_protein, 0); }
async function deleteEntry(id: string) { await del(`/api/entries/${id}`); loadDay(); }
function openAdd(meal: MealType) { addMealType = meal; showAddModal = true; }
function shiftDate(days: number) {
const d = new Date(selectedDate + 'T00:00:00');
d.setDate(d.getDate() + days);
selectedDate = d.toISOString().split('T')[0];
}
function toggleMeal(meal: string) {
const next = new Set(expandedMeals);
if (next.has(meal)) next.delete(meal); else next.add(meal);
expandedMeals = next;
}
function userName(id: string) { return users.find(u => u.id === id)?.display_name || ''; }
</script>
<div class="max-w-4xl mx-auto px-4 py-8">
<!-- Header row: title, date picker, user — all aligned -->
<div class="flex items-center justify-between gap-4 mb-8">
<h1 class="text-3xl font-bold">
{#if isToday}Today{:else}{formatDate(selectedDate)}{/if}
</h1>
<div class="flex items-center gap-2">
<button class="btn btn-ghost btn-sm btn-circle" onclick={() => shiftDate(-1)}>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
</button>
<input type="date" class="input input-bordered input-sm" bind:value={selectedDate} />
<button class="btn btn-ghost btn-sm btn-circle" onclick={() => shiftDate(1)}>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg>
</button>
{#if !isToday}
<button class="btn btn-ghost btn-xs" onclick={() => selectedDate = today()}>Today</button>
{/if}
<div class="divider divider-horizontal mx-0"></div>
<select class="select select-bordered select-sm" bind:value={selectedUser}>
{#each users as u}<option value={u.id}>{u.display_name}</option>{/each}
</select>
</div>
</div>
{#if loading}
<div class="flex justify-center py-16"><span class="loading loading-spinner loading-lg text-primary"></span></div>
{:else}
<!-- Review queue -->
{#if queueCount > 0}
<a href="/admin" class="flex items-center justify-between bg-base-200 rounded-xl p-4 mb-6 border-l-4 border-l-warning">
<div>
<div class="font-medium text-sm">{queueCount} food{queueCount > 1 ? 's' : ''} need review</div>
<div class="text-xs text-base-content/50">Tap to resolve</div>
</div>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-base-content/30" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg>
</a>
{/if}
<!-- Stats cards (trips StatsBar style, with goal progress built in) -->
{@const g = goal}
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<div class="card bg-gradient-to-br from-primary/10 to-primary/5 border border-primary/20 shadow-md hover:shadow-xl transition-shadow duration-300">
<div class="card-body p-4">
<div class="flex flex-row items-center gap-3">
<div class="p-3 rounded-2xl bg-primary/15">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20V10"/><path d="M18 20V4"/><path d="M6 20v-4"/></svg>
</div>
<div>
<div class="text-2xl font-bold text-base-content">{Math.round(totals.total_calories)}</div>
<div class="text-xs text-base-content/50 font-medium uppercase tracking-wide">Calories</div>
</div>
</div>
{#if g}
<progress class="progress progress-primary w-full h-1.5 mt-3" value={Math.min(totals.total_calories / g.calories * 100, 100)} max="100"></progress>
<div class="text-xs text-base-content/40 mt-1">{Math.round(Math.max(g.calories - totals.total_calories, 0))} left of {Math.round(g.calories)}</div>
{/if}
</div>
</div>
<div class="card bg-gradient-to-br from-secondary/10 to-secondary/5 border border-secondary/20 shadow-md hover:shadow-xl transition-shadow duration-300">
<div class="card-body p-4">
<div class="flex flex-row items-center gap-3">
<div class="p-3 rounded-2xl bg-secondary/15">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-secondary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6.5 6.5a3.5 3.5 0 1 0 7 0 3.5 3.5 0 1 0-7 0"/><path d="M1.5 21v-1a7 7 0 0 1 7-7"/><path d="M17.5 12l2 2 4-4"/></svg>
</div>
<div>
<div class="text-2xl font-bold text-base-content">{Math.round(totals.total_protein)}g</div>
<div class="text-xs text-base-content/50 font-medium uppercase tracking-wide">Protein</div>
</div>
</div>
{#if g}
<progress class="progress progress-secondary w-full h-1.5 mt-3" value={Math.min(totals.total_protein / g.protein * 100, 100)} max="100"></progress>
<div class="text-xs text-base-content/40 mt-1">{Math.round(Math.max(g.protein - totals.total_protein, 0))}g left of {Math.round(g.protein)}g</div>
{/if}
</div>
</div>
<div class="card bg-gradient-to-br from-accent/10 to-accent/5 border border-accent/20 shadow-md hover:shadow-xl transition-shadow duration-300">
<div class="card-body p-4">
<div class="flex flex-row items-center gap-3">
<div class="p-3 rounded-2xl bg-accent/15">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-accent" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 22 16 8"/><path d="M3.47 12.53 5 11l1.53 1.53a3.5 3.5 0 0 1 0 4.94L5 19l-1.53-1.53a3.5 3.5 0 0 1 0-4.94Z"/><path d="M7.47 8.53 9 7l1.53 1.53a3.5 3.5 0 0 1 0 4.94L9 15l-1.53-1.53a3.5 3.5 0 0 1 0-4.94Z"/><path d="M11.47 4.53 13 3l1.53 1.53a3.5 3.5 0 0 1 0 4.94L13 11l-1.53-1.53a3.5 3.5 0 0 1 0-4.94Z"/><path d="M20 2h2v2a4 4 0 0 1-4 4h-2V6a4 4 0 0 1 4-4Z"/></svg>
</div>
<div>
<div class="text-2xl font-bold text-base-content">{Math.round(totals.total_carbs)}g</div>
<div class="text-xs text-base-content/50 font-medium uppercase tracking-wide">Carbs</div>
</div>
</div>
{#if g}
<progress class="progress progress-accent w-full h-1.5 mt-3" value={Math.min(totals.total_carbs / g.carbs * 100, 100)} max="100"></progress>
<div class="text-xs text-base-content/40 mt-1">{Math.round(Math.max(g.carbs - totals.total_carbs, 0))}g left of {Math.round(g.carbs)}g</div>
{/if}
</div>
</div>
<div class="card bg-gradient-to-br from-info/10 to-info/5 border border-info/20 shadow-md hover:shadow-xl transition-shadow duration-300">
<div class="card-body p-4">
<div class="flex flex-row items-center gap-3">
<div class="p-3 rounded-2xl bg-info/15">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-info" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a10 10 0 1 0 10 10 4 4 0 0 1-5-5 4 4 0 0 1-5-5"/><path d="M8.5 8.5v.01"/><path d="M16 15.5v.01"/><path d="M12 12v.01"/><path d="M11 17v.01"/><path d="M7 14v.01"/></svg>
</div>
<div>
<div class="text-2xl font-bold text-base-content">{Math.round(totals.total_fat)}g</div>
<div class="text-xs text-base-content/50 font-medium uppercase tracking-wide">Fat</div>
</div>
</div>
{#if g}
<progress class="progress progress-info w-full h-1.5 mt-3" value={Math.min(totals.total_fat / g.fat * 100, 100)} max="100"></progress>
<div class="text-xs text-base-content/40 mt-1">{Math.round(Math.max(g.fat - totals.total_fat, 0))}g left of {Math.round(g.fat)}g</div>
{/if}
</div>
</div>
</div>
{#if !g}
<div class="text-center text-sm text-base-content/40 mb-8">
<a href="/goals" class="link link-primary">Set goals</a> to see progress bars
</div>
{/if}
<!-- Meals (trip day-view style: collapsible sections with timeline) -->
{#each MEAL_TYPES as meal}
{@const mealEntries = entriesByMeal(meal)}
{@const mCal = mealCalories(meal)}
{@const mPro = mealProtein(meal)}
{@const expanded = expandedMeals.has(meal)}
<div class="mb-4">
<!-- Meal header (like trip day header) -->
<button class="w-full flex items-center gap-3 py-3 group" onclick={() => toggleMeal(meal)}>
<div class="w-10 h-10 rounded-full {mealEntries.length > 0 ? 'bg-primary/15' : 'bg-base-300'} flex items-center justify-center shrink-0">
<span class="text-sm font-bold {mealEntries.length > 0 ? 'text-primary' : 'text-base-content/30'}">{MEAL_TYPES.indexOf(meal) + 1}</span>
</div>
<div class="flex-1 text-left">
<span class="font-bold capitalize">{meal}</span>
{#if mCal > 0}
<span class="text-base-content/40 text-sm ml-2">{Math.round(mCal)} cal</span>
<span class="text-base-content/30 text-xs ml-1">· {Math.round(mPro)}g protein</span>
{/if}
</div>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-base-content/30 transition-transform {expanded ? 'rotate-180' : ''}" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>
</button>
{#if expanded}
<div class="ml-4 border-l-2 border-base-300 pl-7">
{#if mealEntries.length > 0}
{#each mealEntries as entry}
<div class="bg-base-200 rounded-xl mb-2 overflow-hidden group hover:shadow-md transition-shadow {entry.food_image_path ? '' : 'p-4'}">
{#if entry.food_image_path}
<div class="relative h-28">
<img src="/images/{entry.food_image_path}" alt="" class="w-full h-full object-cover" />
<div class="absolute inset-0 bg-gradient-to-t from-base-300/90 via-base-300/30 to-transparent"></div>
<div class="absolute bottom-0 left-0 right-0 p-3 flex items-end justify-between">
<div class="flex-1 min-w-0">
<div class="font-medium text-base-content">{entry.snapshot_food_name}</div>
<div class="text-xs text-base-content/60">
{entry.serving_description || `${entry.quantity} ${entry.unit}`}
{#if entry.entry_method === 'ai_plate' || entry.entry_method === 'ai_label'}
<span class="badge badge-xs badge-info">AI</span>
{/if}
</div>
{#if entry.note}
<div class="text-xs text-base-content/40 italic">{entry.note}</div>
{/if}
</div>
<div class="text-right ml-3">
<div class="font-bold text-base-content">{Math.round(entry.snapshot_calories)}</div>
<div class="text-xs text-base-content/60">cal</div>
</div>
</div>
<button
class="btn btn-ghost btn-xs btn-circle absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity bg-base-300/50"
onclick={() => deleteEntry(entry.id)}
title="Delete"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-base-content/50" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>
</button>
</div>
{:else}
<div class="flex items-center justify-between">
<div class="flex-1 min-w-0">
<div class="font-medium">{entry.snapshot_food_name}</div>
<div class="text-xs text-base-content/40 mt-0.5 flex items-center gap-2">
<span>{entry.serving_description || `${entry.quantity} ${entry.unit}`}</span>
{#if entry.entry_method === 'ai_plate' || entry.entry_method === 'ai_label'}
<span class="badge badge-xs badge-info">AI</span>
{/if}
{#if entry.entry_method === 'quick_add'}
<span class="badge badge-xs badge-ghost">quick</span>
{/if}
</div>
{#if entry.note}
<div class="text-xs text-base-content/30 italic mt-0.5">{entry.note}</div>
{/if}
</div>
<div class="flex items-center gap-4">
<div class="text-right">
<div class="font-bold">{Math.round(entry.snapshot_calories)}</div>
<div class="text-xs text-base-content/40">cal</div>
</div>
<button
class="btn btn-ghost btn-xs btn-circle opacity-0 group-hover:opacity-100 transition-opacity"
onclick={() => deleteEntry(entry.id)}
title="Delete"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-base-content/30" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>
</button>
</div>
</div>
{/if}
</div>
{/each}
{:else}
<div class="py-3 text-sm text-base-content/30">No entries</div>
{/if}
<button class="btn btn-ghost btn-sm gap-1 text-primary mt-1 mb-4" onclick={() => openAdd(meal)}>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/></svg>
Add food
</button>
</div>
{/if}
</div>
{/each}
{/if}
</div>
<!-- FAB for quick add (like trips) -->
<button class="btn btn-primary btn-circle btn-lg shadow-lg fixed bottom-6 right-6 z-40" onclick={() => openAdd('snack')}>
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/></svg>
</button>
{#if showAddModal}
<AddFoodModal date={selectedDate} defaultMeal={addMealType} onSave={() => { showAddModal = false; loadDay(); }} onClose={() => showAddModal = false} />
{/if}

View File

@@ -0,0 +1,171 @@
<script lang="ts">
import { get, post } from '$lib/api/client.ts';
import type { QueueItem, Food } from '$lib/api/types.ts';
let queue = $state<QueueItem[]>([]);
let loading = $state(true);
let tab = $state<'queue' | 'merge'>('queue');
let mergeSourceQuery = $state('');
let mergeTargetQuery = $state('');
let mergeSource = $state<Food | null>(null);
let mergeTarget = $state<Food | null>(null);
let mergeSourceResults = $state<Food[]>([]);
let mergeTargetResults = $state<Food[]>([]);
let merging = $state(false);
$effect(() => { loadQueue(); });
async function loadQueue() {
loading = true;
try { queue = await get<QueueItem[]>('/api/resolution-queue'); }
catch {} finally { loading = false; }
}
function parseCandidates(json: string | undefined): Array<{ food_id: string; name: string; score: number }> {
if (!json) return [];
try { return JSON.parse(json); } catch { return []; }
}
async function resolveItem(queueId: string, action: string, foodId?: string) {
await post(`/api/resolution-queue/${queueId}/resolve`, { action, food_id: foodId });
loadQueue();
}
async function searchMerge(query: string, which: 'source' | 'target') {
if (!query.trim()) return;
const results = await get<Food[]>(`/api/foods/search?q=${encodeURIComponent(query)}&limit=5`);
if (which === 'source') mergeSourceResults = results;
else mergeTargetResults = results;
}
async function doMerge() {
if (!mergeSource || !mergeTarget) return;
if (!confirm(`Merge "${mergeSource.name}" into "${mergeTarget.name}"? Source will be archived.`)) return;
merging = true;
try {
await post('/api/foods/merge', { source_id: mergeSource.id, target_id: mergeTarget.id });
mergeSource = null; mergeTarget = null;
mergeSourceQuery = ''; mergeTargetQuery = '';
alert('Merged successfully');
} catch {} finally { merging = false; }
}
</script>
<div class="max-w-3xl mx-auto px-4 py-6">
<div class="mb-6">
<h1 class="text-3xl font-bold">Admin</h1>
<p class="text-base-content/50 text-sm mt-0.5">Review queue and manage duplicates</p>
</div>
<div role="tablist" class="tabs tabs-boxed mb-6">
<button role="tab" class="tab gap-2" class:tab-active={tab === 'queue'} onclick={() => tab = 'queue'}>
Review Queue
{#if queue.length > 0}<span class="badge badge-sm badge-warning">{queue.length}</span>{/if}
</button>
<button role="tab" class="tab" class:tab-active={tab === 'merge'} onclick={() => tab = 'merge'}>Merge Foods</button>
</div>
{#if tab === 'queue'}
{#if loading}
<div class="flex justify-center py-12"><span class="loading loading-spinner loading-lg text-primary"></span></div>
{:else if queue.length === 0}
<div class="text-center py-16">
<div class="p-4 rounded-2xl bg-success/15 inline-block mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 text-success" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
</div>
<div class="text-base-content/50">No items to review</div>
<div class="text-sm text-base-content/30 mt-1">All caught up!</div>
</div>
{:else}
<div class="space-y-3">
{#each queue as item}
{@const candidates = parseCandidates(item.candidates_json)}
<div class="rounded-xl border border-warning/20 bg-gradient-to-br from-warning/5 to-warning/0 p-5 shadow-sm card-hover">
<div class="flex justify-between items-start mb-3">
<div>
<div class="font-bold text-lg">"{item.raw_text}"</div>
<div class="flex gap-2 mt-1 text-xs text-base-content/40">
{#if item.source}<span class="badge badge-xs badge-ghost">{item.source}</span>{/if}
<span>confidence: {(item.confidence * 100).toFixed(0)}%</span>
{#if item.meal_type}<span>{item.meal_type}</span>{/if}
{#if item.entry_date}<span>{item.entry_date}</span>{/if}
</div>
</div>
<button class="btn btn-ghost btn-sm" onclick={() => resolveItem(item.id, 'dismissed')}>Dismiss</button>
</div>
{#if candidates.length > 0}
<div class="text-xs text-base-content/50 font-medium uppercase tracking-wide mb-2">Match to existing</div>
<div class="space-y-1.5">
{#each candidates as c}
<button
class="btn btn-sm btn-outline w-full justify-between"
onclick={() => resolveItem(item.id, 'matched', c.food_id)}
>
<span>{c.name}</span>
<span class="badge badge-sm badge-ghost">{(c.score * 100).toFixed(0)}%</span>
</button>
{/each}
</div>
{:else}
<div class="text-sm text-base-content/30">No candidates — create a new food manually from the Foods page</div>
{/if}
</div>
{/each}
</div>
{/if}
{:else}
<div class="rounded-xl border border-base-300 bg-base-200/50 p-6 shadow-md">
<div class="flex items-center gap-3 mb-4">
<div class="p-2 rounded-xl bg-secondary/15">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-secondary" viewBox="0 0 20 20" fill="currentColor"><path d="M8 5a1 1 0 100 2h5.586l-1.293 1.293a1 1 0 001.414 1.414l3-3a1 1 0 000-1.414l-3-3a1 1 0 10-1.414 1.414L13.586 5H8zM12 15a1 1 0 100-2H6.414l1.293-1.293a1 1 0 10-1.414-1.414l-3 3a1 1 0 000 1.414l3 3a1 1 0 001.414-1.414L6.414 15H12z"/></svg>
</div>
<div>
<h2 class="font-bold text-lg">Merge Duplicate Foods</h2>
<p class="text-xs text-base-content/40">Source gets archived. Entries and aliases move to target.</p>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div>
<div class="text-xs text-error font-medium uppercase tracking-wide mb-2">Source (will be archived)</div>
<div class="flex gap-1 mb-2">
<input class="input input-bordered input-sm flex-1" placeholder="Search..." bind:value={mergeSourceQuery} />
<button class="btn btn-sm btn-ghost" onclick={() => searchMerge(mergeSourceQuery, 'source')}>Go</button>
</div>
{#if mergeSource}
<div class="rounded-lg border border-error/30 bg-error/5 p-3 text-sm font-medium">{mergeSource.name}</div>
{/if}
{#each mergeSourceResults as f}
<button class="btn btn-sm btn-ghost w-full justify-start mt-1 text-left" onclick={() => { mergeSource = f; mergeSourceResults = []; }}>
{f.name}
</button>
{/each}
</div>
<div>
<div class="text-xs text-success font-medium uppercase tracking-wide mb-2">Target (will keep)</div>
<div class="flex gap-1 mb-2">
<input class="input input-bordered input-sm flex-1" placeholder="Search..." bind:value={mergeTargetQuery} />
<button class="btn btn-sm btn-ghost" onclick={() => searchMerge(mergeTargetQuery, 'target')}>Go</button>
</div>
{#if mergeTarget}
<div class="rounded-lg border border-success/30 bg-success/5 p-3 text-sm font-medium">{mergeTarget.name}</div>
{/if}
{#each mergeTargetResults as f}
<button class="btn btn-sm btn-ghost w-full justify-start mt-1 text-left" onclick={() => { mergeTarget = f; mergeTargetResults = []; }}>
{f.name}
</button>
{/each}
</div>
</div>
<button class="btn btn-warning w-full mt-6 gap-2" onclick={doMerge} disabled={!mergeSource || !mergeTarget || merging}>
{#if merging}<span class="loading loading-spinner loading-sm"></span>{/if}
Merge Foods
</button>
</div>
{/if}
</div>

View File

@@ -0,0 +1,338 @@
<script lang="ts">
import { get, post, patch, del } from '$lib/api/client.ts';
import type { Food } from '$lib/api/types.ts';
let query = $state('');
let allFoods = $state<Food[]>([]);
let favorites = $state<Food[]>([]);
let loading = $state(true);
let editFood = $state<Food | null>(null);
let showCreate = $state(false);
let tab = $state<'all' | 'favorites'>('all');
// Image picker state
let imageQuery = $state('');
let imageResults = $state<Array<{url: string; thumbnail: string; title: string}>>([]);
let imageSearching = $state(false);
let imageSettingUrl = $state('');
async function searchImages() {
if (!imageQuery.trim() || !editFood) return;
imageSearching = true;
try {
const res = await post<{images: Array<{url: string; thumbnail: string; title: string}>}>('/api/images/search', { query: imageQuery + ' food', num: 9 });
imageResults = res.images || [];
} catch {} finally { imageSearching = false; }
}
async function pickImage(fullUrl: string, thumbnailUrl: string) {
if (!editFood) return;
imageSettingUrl = fullUrl;
const foodId = editFood.id;
const urls = [fullUrl, thumbnailUrl];
for (const url of urls) {
try {
const res = await post<{success: boolean; image_path?: string}>(`/api/foods/${foodId}/image`, { url });
if (res.success && res.image_path) {
if (editFood) editFood.image_path = res.image_path;
imageResults = [];
imageSettingUrl = '';
loadAll();
return;
}
} catch {
continue;
}
}
imageSettingUrl = '';
}
let newName = $state('');
let newBrand = $state('');
let newCal = $state(0);
let newProtein = $state(0);
let newCarbs = $state(0);
let newFat = $state(0);
let newUnit = $state('100g');
let newServingName = $state('');
let newServingAmount = $state(1);
let filtered = $derived(
query.trim()
? allFoods.filter(f => f.name.toLowerCase().includes(query.toLowerCase()) || (f.brand || '').toLowerCase().includes(query.toLowerCase()))
: allFoods
);
$effect(() => { loadAll(); });
async function loadAll() {
loading = true;
try {
[allFoods, favorites] = await Promise.all([
get<Food[]>('/api/foods'),
get<Food[]>('/api/favorites'),
]);
} catch {} finally { loading = false; }
}
async function toggleFavorite(food: Food) {
const isFav = favorites.some(f => f.id === food.id);
if (isFav) { await del(`/api/favorites/${food.id}`); }
else { await post('/api/favorites', { food_id: food.id }); }
loadAll();
}
async function createFood() {
const servings = [{ name: `1 ${newUnit}`, amount_in_base: 1.0, is_default: true }];
if (newServingName) servings.push({ name: newServingName, amount_in_base: newServingAmount, is_default: false });
await post('/api/foods', {
name: newName, brand: newBrand || undefined,
calories_per_base: newCal, protein_per_base: newProtein,
carbs_per_base: newCarbs, fat_per_base: newFat,
base_unit: newUnit, servings,
});
showCreate = false;
newName = ''; newBrand = ''; newCal = 0; newProtein = 0; newCarbs = 0; newFat = 0;
loadAll();
}
async function deleteFood() {
if (!editFood) return;
if (!confirm(`Delete "${editFood.name}"? This will archive it.`)) return;
await del(`/api/foods/${editFood.id}`);
editFood = null;
loadAll();
}
async function updateFood() {
if (!editFood) return;
await patch(`/api/foods/${editFood.id}`, {
name: editFood.name, brand: editFood.brand,
calories_per_base: editFood.calories_per_base,
protein_per_base: editFood.protein_per_base,
carbs_per_base: editFood.carbs_per_base,
fat_per_base: editFood.fat_per_base,
status: editFood.status,
});
editFood = null;
loadAll();
}
async function addAlias(foodId: string) {
const alias = prompt('Add alias:');
if (alias) {
await post(`/api/foods/${foodId}/aliases`, { alias });
if (editFood?.id === foodId) editFood = await get<Food>(`/api/foods/${foodId}`);
}
}
</script>
<div class="max-w-3xl mx-auto px-4 py-6">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-3xl font-bold">Food Library</h1>
<p class="text-base-content/50 text-sm mt-0.5">{allFoods.length} foods saved</p>
</div>
<button class="btn btn-primary btn-sm gap-2" onclick={() => showCreate = true}>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/></svg>
New Food
</button>
</div>
<div role="tablist" class="tabs tabs-boxed mb-4">
<button role="tab" class="tab" class:tab-active={tab === 'all'} onclick={() => tab = 'all'}>All ({allFoods.length})</button>
<button role="tab" class="tab" class:tab-active={tab === 'favorites'} onclick={() => tab = 'favorites'}>Favorites ({favorites.length})</button>
</div>
{#if tab === 'all'}
<div class="relative mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 absolute left-3 top-1/2 -translate-y-1/2 text-base-content/30" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd"/></svg>
<input class="input input-bordered w-full pl-10" placeholder="Filter foods..." bind:value={query} />
</div>
{#if loading}
<div class="flex justify-center py-12"><span class="loading loading-spinner loading-lg text-primary"></span></div>
{:else}
<div class="space-y-2">
{#each filtered as food}
<div class="rounded-xl border border-base-300 bg-base-200/50 p-4 shadow-sm card-hover flex items-center justify-between">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="font-semibold">{food.name}</span>
{#if food.status === 'ai_created'}<span class="badge badge-xs badge-info badge-outline">AI</span>{/if}
{#if food.status === 'needs_review'}<span class="badge badge-xs badge-warning">Review</span>{/if}
</div>
{#if food.brand}<div class="text-xs text-base-content/40">{food.brand}</div>{/if}
<div class="flex gap-3 mt-1 text-xs text-base-content/50">
<span>{food.calories_per_base} cal</span>
<span>{food.protein_per_base}g P</span>
<span>{food.carbs_per_base}g C</span>
<span>{food.fat_per_base}g F</span>
<span class="text-base-content/30">per {food.base_unit}</span>
</div>
</div>
<div class="flex gap-1 ml-2">
<button class="btn btn-ghost btn-sm btn-circle" onclick={() => toggleFavorite(food)} title="Favorite">
{#if favorites.some(f => f.id === food.id)}
<span class="text-warning"></span>
{:else}
<span class="text-base-content/30"></span>
{/if}
</button>
<button class="btn btn-ghost btn-sm btn-circle" onclick={() => { editFood = food; imageResults = []; imageQuery = ''; }} title="Edit">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor"><path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"/></svg>
</button>
</div>
</div>
{/each}
{#if filtered.length === 0}
<div class="text-center py-12 text-base-content/30">
{#if query.trim()}No foods match "{query}"{:else}No foods yet{/if}
</div>
{/if}
</div>
{/if}
{:else}
<div class="space-y-2">
{#each favorites as food}
<div class="rounded-xl border border-warning/20 bg-gradient-to-br from-warning/5 to-warning/0 p-4 shadow-sm card-hover flex items-center justify-between">
<div>
<div class="font-semibold">{food.name}</div>
<div class="text-xs text-base-content/50">{food.calories_per_base} cal/{food.base_unit}</div>
</div>
<button class="btn btn-ghost btn-sm btn-circle" onclick={() => toggleFavorite(food)}>
<span class="text-warning"></span>
</button>
</div>
{/each}
{#if favorites.length === 0}
<div class="text-center py-12 text-base-content/30">No favorites yet</div>
{/if}
</div>
{/if}
</div>
<!-- Create food modal -->
{#if showCreate}
<div class="modal modal-open">
<div class="modal-box max-w-lg">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick={() => showCreate = false}>X</button>
<div class="flex items-center gap-3 mb-4">
<div class="p-2 rounded-xl bg-primary/15">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-primary" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/></svg>
</div>
<h3 class="font-bold text-lg">New Food</h3>
</div>
<div class="flex flex-col gap-3">
<input class="input input-bordered" placeholder="Food name *" bind:value={newName} />
<input class="input input-bordered" placeholder="Brand (optional)" bind:value={newBrand} />
<select class="select select-bordered" bind:value={newUnit}>
<option value="100g">Per 100g</option>
<option value="piece">Per piece</option>
<option value="serving">Per serving</option>
<option value="scoop">Per scoop</option>
<option value="slice">Per slice</option>
</select>
<div class="grid grid-cols-2 gap-2">
<input class="input input-bordered" type="number" placeholder="Calories" bind:value={newCal} />
<input class="input input-bordered" type="number" placeholder="Protein (g)" bind:value={newProtein} />
<input class="input input-bordered" type="number" placeholder="Carbs (g)" bind:value={newCarbs} />
<input class="input input-bordered" type="number" placeholder="Fat (g)" bind:value={newFat} />
</div>
<div class="divider text-xs my-0">Optional Serving</div>
<div class="grid grid-cols-2 gap-2">
<input class="input input-bordered input-sm" placeholder="e.g. 1 cup (240g)" bind:value={newServingName} />
<input class="input input-bordered input-sm" type="number" step="0.01" placeholder="Base multiplier" bind:value={newServingAmount} />
</div>
<button class="btn btn-primary mt-1" onclick={createFood} disabled={!newName}>Create</button>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button onclick={() => showCreate = false}>close</button></form>
</div>
{/if}
<!-- Edit food modal -->
{#if editFood}
<div class="modal modal-open">
<div class="modal-box max-w-lg">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick={() => editFood = null}>X</button>
<div class="flex items-center gap-3 mb-4">
<div class="p-2 rounded-xl bg-info/15">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-info" viewBox="0 0 20 20" fill="currentColor"><path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"/></svg>
</div>
<h3 class="font-bold text-lg">Edit: {editFood.name}</h3>
</div>
<div class="flex flex-col gap-3">
<input class="input input-bordered" bind:value={editFood.name} />
<input class="input input-bordered" placeholder="Brand" bind:value={editFood.brand} />
<div class="grid grid-cols-2 gap-2">
<input class="input input-bordered" type="number" bind:value={editFood.calories_per_base} />
<input class="input input-bordered" type="number" bind:value={editFood.protein_per_base} />
<input class="input input-bordered" type="number" bind:value={editFood.carbs_per_base} />
<input class="input input-bordered" type="number" bind:value={editFood.fat_per_base} />
</div>
<select class="select select-bordered" bind:value={editFood.status}>
<option value="confirmed">Confirmed</option>
<option value="ai_created">AI Created</option>
<option value="needs_review">Needs Review</option>
<option value="archived">Archived</option>
</select>
{#if editFood.aliases}
<div class="text-sm font-medium">Aliases</div>
<div class="flex flex-wrap gap-1">
{#each editFood.aliases as a}
<span class="badge badge-sm badge-outline">{a.alias}</span>
{/each}
<button class="badge badge-sm badge-primary badge-outline cursor-pointer" onclick={() => addAlias(editFood!.id)}>+ add</button>
</div>
{/if}
<!-- Image picker -->
<div class="divider text-xs my-1">Image</div>
{#if editFood.image_path}
<div class="relative rounded-lg overflow-hidden h-32">
<img src="/images/{editFood.image_path}" alt="" class="w-full h-full object-cover" />
<div class="absolute bottom-2 right-2">
<button class="btn btn-xs btn-ghost bg-base-300/70" onclick={() => { imageQuery = editFood!.name; searchImages(); }}>Change</button>
</div>
</div>
{:else}
<button class="btn btn-sm btn-outline w-full" onclick={() => { imageQuery = editFood!.name; searchImages(); }}>
Search for image
</button>
{/if}
{#if imageResults.length > 0 || imageSearching}
<div class="flex gap-1 mt-2">
<input class="input input-bordered input-sm flex-1" placeholder="Search images..." bind:value={imageQuery} onkeydown={(e) => { if (e.key === 'Enter') searchImages(); }} />
<button class="btn btn-sm" onclick={searchImages} disabled={imageSearching}>
{#if imageSearching}<span class="loading loading-spinner loading-xs"></span>{:else}Search{/if}
</button>
</div>
<div class="grid grid-cols-3 gap-2 mt-2 max-h-48 overflow-y-auto">
{#each imageResults as img}
<button
class="relative rounded-lg overflow-hidden h-20 hover:ring-2 hover:ring-primary transition-all {imageSettingUrl === img.url ? 'opacity-50' : ''}"
onclick={() => pickImage(img.url, img.thumbnail)}
disabled={!!imageSettingUrl}
>
<img src={img.thumbnail} alt={img.title} class="w-full h-full object-cover" />
{#if imageSettingUrl === img.url}
<div class="absolute inset-0 flex items-center justify-center bg-base-300/50">
<span class="loading loading-spinner loading-sm"></span>
</div>
{/if}
</button>
{/each}
</div>
{/if}
<div class="flex gap-2 mt-2">
<button class="btn btn-primary flex-1" onclick={updateFood}>Save</button>
<button class="btn btn-error btn-outline" onclick={deleteFood}>Delete</button>
</div>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button onclick={() => editFood = null}>close</button></form>
</div>
{/if}

View File

@@ -0,0 +1,136 @@
<script lang="ts">
import { get, put } from '$lib/api/client.ts';
import type { Goal, User } from '$lib/api/types.ts';
import { today } from '$lib/api/client.ts';
let users = $state<User[]>([]);
let selectedUser = $state('');
let goals = $state<Goal[]>([]);
let loading = $state(true);
let calories = $state(2000);
let protein = $state(150);
let carbs = $state(200);
let fat = $state(65);
let startDate = $state(today());
let saving = $state(false);
$effect(() => { loadUsers(); });
$effect(() => { if (selectedUser) loadGoals(); });
async function loadUsers() {
users = await get<User[]>('/api/users');
const me = await get<User>('/api/user');
selectedUser = me.id;
}
async function loadGoals() {
loading = true;
try {
goals = await get<Goal[]>(`/api/goals?user_id=${selectedUser}`);
const current = goals.find(g => g.is_active);
if (current) {
calories = current.calories; protein = current.protein;
carbs = current.carbs; fat = current.fat;
}
} catch {} finally { loading = false; }
}
async function saveGoal() {
saving = true;
try {
await put('/api/goals', { user_id: selectedUser, start_date: startDate, calories, protein, carbs, fat });
loadGoals();
} catch {} finally { saving = false; }
}
function userName(id: string) { return users.find(u => u.id === id)?.display_name || ''; }
</script>
<div class="max-w-2xl mx-auto px-4 py-6">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-3xl font-bold">Goals</h1>
<p class="text-base-content/50 text-sm mt-0.5">Daily nutrition targets</p>
</div>
<select class="select select-bordered select-sm" bind:value={selectedUser}>
{#each users as u}
<option value={u.id}>{u.display_name}</option>
{/each}
</select>
</div>
{#if loading}
<div class="flex justify-center py-12"><span class="loading loading-spinner loading-lg text-primary"></span></div>
{:else}
<!-- Set goals card -->
<div class="rounded-xl border border-primary/20 bg-gradient-to-br from-primary/10 to-primary/5 p-6 mb-8 shadow-md">
<div class="flex items-center gap-3 mb-4">
<div class="p-2 rounded-xl bg-primary/15">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-primary" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" clip-rule="evenodd"/></svg>
</div>
<h2 class="font-bold text-lg">Set Goals for {userName(selectedUser)}</h2>
</div>
<div class="flex flex-col gap-3">
<div>
<div class="text-xs text-base-content/50 font-medium uppercase tracking-wide mb-1">Start Date</div>
<input type="date" class="input input-bordered input-sm w-full max-w-xs" bind:value={startDate} />
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<div class="text-xs text-base-content/50 font-medium uppercase tracking-wide mb-1">Calories</div>
<input type="number" class="input input-bordered w-full" bind:value={calories} />
</div>
<div>
<div class="text-xs text-base-content/50 font-medium uppercase tracking-wide mb-1">Protein (g)</div>
<input type="number" class="input input-bordered w-full" bind:value={protein} />
</div>
<div>
<div class="text-xs text-base-content/50 font-medium uppercase tracking-wide mb-1">Carbs (g)</div>
<input type="number" class="input input-bordered w-full" bind:value={carbs} />
</div>
<div>
<div class="text-xs text-base-content/50 font-medium uppercase tracking-wide mb-1">Fat (g)</div>
<input type="number" class="input input-bordered w-full" bind:value={fat} />
</div>
</div>
<button class="btn btn-primary gap-2 mt-1" onclick={saveGoal} disabled={saving}>
{#if saving}<span class="loading loading-spinner loading-sm"></span>{/if}
Save Goals
</button>
</div>
</div>
<!-- Goal history -->
{#if goals.length > 0}
<div class="flex items-center gap-2 mb-4">
<div class="w-2 h-2 rounded-full bg-info"></div>
<h2 class="text-xl font-bold">History</h2>
</div>
<div class="space-y-2">
{#each goals as g}
<div class="rounded-xl border p-4 shadow-sm card-hover {g.is_active ? 'border-primary/30 bg-gradient-to-br from-primary/5 to-transparent' : 'border-base-300 bg-base-200/50'}">
<div class="flex justify-between items-center">
<div>
<div class="text-sm font-medium">
{g.start_date}{g.end_date ? ` — ${g.end_date}` : ''}
</div>
<div class="flex gap-3 text-xs text-base-content/50 mt-1">
<span>{g.calories} cal</span>
<span>{g.protein}g P</span>
<span>{g.carbs}g C</span>
<span>{g.fat}g F</span>
</div>
</div>
{#if g.is_active}
<span class="badge badge-primary badge-sm">Active</span>
{:else}
<span class="badge badge-ghost badge-sm">Ended</span>
{/if}
</div>
</div>
{/each}
</div>
{/if}
{/if}
</div>

View File

@@ -0,0 +1,65 @@
<script lang="ts">
import { goto } from '$app/navigation';
let username = $state('');
let password = $state('');
let error = $state('');
let loading = $state(false);
async function handleLogin() {
if (!username.trim() || !password) { error = 'Enter username and password'; return; }
loading = true;
error = '';
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: username.trim(), password }),
credentials: 'include'
});
if (res.ok) {
const data = await res.json();
localStorage.setItem('session_token', data.token);
goto('/');
} else { error = 'Invalid credentials'; }
} catch { error = 'Connection failed'; }
finally { loading = false; }
}
</script>
<div class="min-h-screen flex items-center justify-center px-4">
<div class="card bg-base-200 shadow-xl w-full max-w-md border border-base-300">
<div class="card-body">
<div class="text-center mb-6">
<div class="p-4 rounded-2xl bg-primary/15 inline-block mb-3">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20V10"/>
<path d="M18 20V4"/>
<path d="M6 20v-4"/>
</svg>
</div>
<h2 class="text-2xl font-bold text-base-content">Calorie Tracker</h2>
<p class="text-base-content/50 text-sm mt-1">Sign in to continue</p>
</div>
{#if error}
<div class="alert alert-error text-sm py-2"><span>{error}</span></div>
{/if}
<form onsubmit={(e) => { e.preventDefault(); handleLogin(); }}>
<div class="form-control">
<div class="text-xs text-base-content/50 font-medium uppercase tracking-wide mb-1">Username</div>
<input type="text" class="input input-bordered w-full" bind:value={username} />
</div>
<div class="form-control mt-3">
<div class="text-xs text-base-content/50 font-medium uppercase tracking-wide mb-1">Password</div>
<input type="password" class="input input-bordered w-full" bind:value={password} />
</div>
<button type="submit" class="btn btn-primary w-full mt-5 gap-2" disabled={loading}>
{#if loading}<span class="loading loading-spinner loading-sm"></span>{/if}
Sign In
</button>
</form>
</div>
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More