feat: wire brain service to platform gateway

- Gateway proxies /api/brain/* to brain-api:8200/api/* via pangolin network
- User identity injected via X-Gateway-User-Id header
- Brain app registered in gateway database (sort_order 9)
- Added to GATEWAY_KEY_SERVICES for dashboard integration
- Tested: health, config, list, create all working through gateway

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-01 16:32:53 -05:00
parent c9e776df59
commit 2072c359aa
34 changed files with 16745 additions and 1379 deletions

View File

@@ -12,6 +12,7 @@
"@sveltejs/adapter-node": "^5.5.4",
"@tailwindcss/vite": "^4.2.2",
"clsx": "^2.1.1",
"pdfjs-dist": "^5.6.205",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2"
},
@@ -495,6 +496,256 @@
"svelte": "^5"
}
},
"node_modules/@napi-rs/canvas": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.97.tgz",
"integrity": "sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ==",
"license": "MIT",
"optional": true,
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "0.1.97",
"@napi-rs/canvas-darwin-arm64": "0.1.97",
"@napi-rs/canvas-darwin-x64": "0.1.97",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.97",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.97",
"@napi-rs/canvas-linux-arm64-musl": "0.1.97",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.97",
"@napi-rs/canvas-linux-x64-gnu": "0.1.97",
"@napi-rs/canvas-linux-x64-musl": "0.1.97",
"@napi-rs/canvas-win32-arm64-msvc": "0.1.97",
"@napi-rs/canvas-win32-x64-msvc": "0.1.97"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.97.tgz",
"integrity": "sha512-V1c/WVw+NzH8vk7ZK/O8/nyBSCQimU8sfMsB/9qeSvdkGKNU7+mxy/bIF0gTgeBFmHpj30S4E9WHMSrxXGQuVQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.97.tgz",
"integrity": "sha512-ok+SCEF4YejcxuJ9Rm+WWunHHpf2HmiPxfz6z1a/NFQECGXtsY7A4B8XocK1LmT1D7P174MzwPF9Wy3AUAwEPw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.97.tgz",
"integrity": "sha512-PUP6e6/UGlclUvAQNnuXCcnkpdUou6VYZfQOQxExLp86epOylmiwLkqXIvpFmjoTEDmPmXrI+coL/9EFU1gKPA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.97.tgz",
"integrity": "sha512-XyXH2L/cic8eTNtbrXCcvqHtMX/nEOxN18+7rMrAM2XtLYC/EB5s0wnO1FsLMWmK+04ZSLN9FBGipo7kpIkcOw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.97.tgz",
"integrity": "sha512-Kuq/M3djq0K8ktgz6nPlK7Ne5d4uWeDxPpyKWOjWDK2RIOhHVtLtyLiJw2fuldw7Vn4mhw05EZXCEr4Q76rs9w==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.97.tgz",
"integrity": "sha512-kKmSkQVnWeqg7qdsiXvYxKhAFuHz3tkBjW/zyQv5YKUPhotpaVhpBGv5LqCngzyuRV85SXoe+OFj+Tv0a0QXkQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.97.tgz",
"integrity": "sha512-Jc7I3A51jnEOIAXeLsN/M/+Z28LUeakcsXs07FLq9prXc0eYOtVwsDEv913Gr+06IRo34gJJVgT0TXvmz+N2VA==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.97.tgz",
"integrity": "sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.97.tgz",
"integrity": "sha512-AKLFd/v0Z5fvgqBDqhvqtAdx+fHMJ5t9JcUNKq4FIZ5WH+iegGm8HPdj00NFlCSnm83Fp3Ln8I2f7uq1aIiWaA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.97.tgz",
"integrity": "sha512-u883Yr6A6fO7Vpsy9YE4FVCIxzzo5sO+7pIUjjoDLjS3vQaNMkVzx5bdIpEL+ob+gU88WDK4VcxYMZ6nmnoX9A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.97.tgz",
"integrity": "sha512-sWtD2EE3fV0IzN+iiQUqr/Q1SwqWhs2O1FKItFlxtdDkikpEj5g7DKQpY3x55H/MAOnL8iomnlk3mcEeGiUMoQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@polka/url": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
@@ -1916,6 +2167,13 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/node-readable-to-web-readable-stream": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz",
"integrity": "sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==",
"license": "MIT",
"optional": true
},
"node_modules/obug": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
@@ -1932,6 +2190,19 @@
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"license": "MIT"
},
"node_modules/pdfjs-dist": {
"version": "5.6.205",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.6.205.tgz",
"integrity": "sha512-tlUj+2IDa7G1SbvBNN74UHRLJybZDWYom+k6p5KIZl7huBvsA4APi6mKL+zCxd3tLjN5hOOEE9Tv7VdzO88pfg==",
"license": "Apache-2.0",
"engines": {
"node": ">=20.19.0 || >=22.13.0 || >=24"
},
"optionalDependencies": {
"@napi-rs/canvas": "^0.1.96",
"node-readable-to-web-readable-stream": "^0.4.2"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",

View File

@@ -25,6 +25,7 @@
"@sveltejs/adapter-node": "^5.5.4",
"@tailwindcss/vite": "^4.2.2",
"clsx": "^2.1.1",
"pdfjs-dist": "^5.6.205",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2"
}

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import ImmichPicker from '$lib/components/shared/ImmichPicker.svelte';
import PdfInlinePreview from './PdfInlinePreview.svelte';
let {
entityType,
@@ -148,6 +149,12 @@
showImmich = false;
onUpload();
}
function isPdfDocument(doc: any) {
const fileName = String(doc.file_name || doc.original_name || '').toLowerCase();
const mimeType = String(doc.mime_type || '').toLowerCase();
return mimeType.includes('pdf') || fileName.endsWith('.pdf');
}
</script>
<div class="upload-section">
@@ -169,10 +176,21 @@
{#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 class="doc-card">
{#if isPdfDocument(doc)}
<PdfInlinePreview
url={doc.url || `/api/trips/documents/${doc.file_path}`}
name={doc.file_name || doc.original_name || 'PDF document'}
onDelete={() => deleteDoc(doc.id)}
deleting={deletingDocId === doc.id}
/>
{:else}
<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>
{/if}
</div>
{/each}
</div>
@@ -241,13 +259,36 @@
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); }
.doc-list { display: flex; flex-direction: column; gap: 12px; }
.doc-card {
display: flex;
flex-direction: column;
gap: 10px;
padding: 0;
}
.doc-row { display: flex; align-items: center; gap: 8px; min-width: 0; }
.doc-icon { width: 16px; height: 16px; color: rgba(111, 88, 64, 0.7); flex-shrink: 0; }
.doc-name {
flex: 1;
min-width: 0;
font-size: 0.92rem;
font-weight: 600;
color: #3a291b;
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.doc-name:hover { color: #1f1510; }
.doc-delete {
background: none;
border: none;
color: rgba(112, 86, 62, 0.64);
cursor: pointer;
font-size: 1.1rem;
padding: 2px 4px;
}
.doc-delete:hover { color: #8f3928; }
.upload-actions { display: flex; flex-wrap: wrap; gap: 6px; }
.upload-btn {

View File

@@ -20,6 +20,8 @@
let saving = $state(false);
let confirmDelete = $state(false);
let mediaImages = $state<any[]>([]);
let mediaDocuments = $state<any[]>([]);
// ── Form state ──
let name = $state('');
@@ -79,8 +81,12 @@
transportType = editItem.type || 'plane';
lodgingType = editItem.type || 'hotel';
content = editItem.content || '';
mediaImages = (editItem.images || []).map((i: any) => ({ ...i, url: `/images/${i.file_path}` }));
mediaDocuments = (editItem.documents || []).map((d: any) => ({ ...d, url: `/api/trips/documents/${d.file_path}` }));
} else if (open) {
resetForm();
mediaImages = [];
mediaDocuments = [];
}
confirmDelete = false;
});
@@ -96,6 +102,40 @@
function close() { open = false; resetForm(); confirmDelete = false; }
async function refreshMedia() {
if (!editItem?.id) {
onSaved();
return;
}
try {
const res = await fetch(`/api/trips/trip/${tripId}`, { credentials: 'include' });
if (!res.ok) return;
const data = await res.json();
const collection =
itemType === 'transportation' ? data.transportations :
itemType === 'lodging' ? data.lodging :
itemType === 'location' ? data.locations :
data.notes;
const nextItem = (collection || []).find((item: any) => item.id === editItem.id);
if (!nextItem) return;
mediaImages = (nextItem.images || []).map((image: any) => ({
...image,
url: `/images/${image.file_path}`
}));
mediaDocuments = (nextItem.documents || []).map((doc: any) => ({
...doc,
url: `/api/trips/documents/${doc.file_path}`
}));
onSaved();
} catch (e) {
console.error('Media refresh failed:', e);
}
}
function handlePlaceSelect(details: any) {
if (details.name) name = details.name;
address = details.address || '';
@@ -168,139 +208,168 @@
<!-- 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>
<div class="modal-head-copy">
<div class="modal-kicker">{isEdit ? 'Edit' : 'Add'}</div>
<div class="modal-title">{titles[itemType]}</div>
</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="modal-intro">
<div class="intro-line">{titles[itemType]} details</div>
<div class="intro-copy">
{#if itemType === 'transportation'}
Keep timing, route, and booking details in one clean travel leg.
{:else if itemType === 'lodging'}
Track where you are staying, when you arrive, and what confirms the stay.
{:else if itemType === 'location'}
Capture the stop, why it matters, and when it belongs in the trip.
{:else}
Save a reminder, handoff note, or journal line for this trip.
{/if}
</div>
</div>
<!-- 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'}
<section class="form-section">
<div class="section-title">Primary info</div>
{#if itemType === 'location' || itemType === 'lodging'}
<div class="field">
<label class="field-label">Flight Number</label>
<input class="field-input" type="text" bind:value={flightNumber} placeholder="UA 2341" />
<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}
<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>
<!-- 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}
</section>
<section class="form-section">
<div class="section-title">{itemType === 'note' ? 'Content' : 'Details'}</div>
{#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}
</section>
<!-- Link -->
{#if itemType !== 'note'}
<section class="form-section">
<div class="section-title">Booking + cost</div>
<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>
</section>
{/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>
<section class="form-section">
<div class="section-title">Attachments</div>
<div class="field">
<label class="field-label">Photos & Documents</label>
<ImageUpload
entityType={itemType}
entityId={editItem.id}
images={mediaImages}
documents={mediaDocuments}
onUpload={refreshMedia}
/>
</div>
</section>
{/if}
</div>
@@ -312,15 +381,14 @@
<button class="btn-danger" onclick={doDelete} disabled={saving}>Yes, delete</button>
<button class="btn-cancel" onclick={() => confirmDelete = false}>Cancel</button>
</div>
{:else}
{/if}
{/if}
<div class="footer-right">
{#if isEdit && !confirmDelete}
<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'}
@@ -332,37 +400,372 @@
{/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-overlay {
position: fixed;
inset: 0;
z-index: 70;
display: flex;
justify-content: flex-end;
background:
linear-gradient(180deg, rgba(53, 40, 31, 0.1), rgba(24, 17, 12, 0.42)),
rgba(17, 11, 7, 0.28);
backdrop-filter: blur(10px);
animation: modalFade 180ms ease;
}
.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; }
@keyframes modalFade {
from { opacity: 0; }
to { opacity: 1; }
}
.modal-body { flex: 1; overflow-y: auto; padding: var(--sp-5); display: flex; flex-direction: column; gap: 14px; }
.modal-sheet {
width: min(560px, 100%);
height: 100%;
display: flex;
flex-direction: column;
background:
linear-gradient(180deg, rgba(255, 250, 242, 0.98), rgba(247, 239, 229, 0.98));
border-left: 1px solid rgba(122, 96, 70, 0.18);
box-shadow: -24px 0 64px rgba(39, 26, 16, 0.18);
animation: modalSlide 220ms cubic-bezier(0.22, 1, 0.36, 1);
}
.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; }
@keyframes modalSlide {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
.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); }
.modal-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
padding: 28px 28px 20px;
border-bottom: 1px solid rgba(122, 96, 70, 0.14);
background:
linear-gradient(180deg, rgba(255, 248, 237, 0.92), rgba(255, 248, 237, 0.74));
}
@media (max-width: 768px) { .modal-sheet { width: 100%; } }
.modal-head-copy {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.modal-kicker {
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
color: rgba(124, 94, 61, 0.7);
}
.modal-title {
font-size: clamp(1.35rem, 1.15rem + 0.55vw, 1.82rem);
line-height: 1.04;
font-weight: 600;
letter-spacing: -0.03em;
color: #27190f;
}
.modal-close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
padding: 0;
border: 1px solid rgba(122, 96, 70, 0.16);
border-radius: 999px;
background: rgba(255, 252, 247, 0.82);
color: rgba(90, 69, 50, 0.72);
cursor: pointer;
transition: background 160ms ease, color 160ms ease, transform 160ms ease;
}
.modal-close:hover {
background: rgba(255, 252, 247, 1);
color: #3d2b1d;
transform: translateY(-1px);
}
.modal-close svg {
width: 16px;
height: 16px;
}
.modal-body {
flex: 1;
overflow-y: auto;
padding: 24px 28px 32px;
display: flex;
flex-direction: column;
gap: 18px;
}
.modal-intro {
padding: 18px 18px 16px;
border-radius: 24px;
background: rgba(252, 245, 236, 0.92);
border: 1px solid rgba(140, 112, 82, 0.13);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5);
}
.intro-line {
font-size: 0.92rem;
font-weight: 600;
color: #3f2c1d;
letter-spacing: -0.02em;
}
.intro-copy {
margin-top: 8px;
max-width: 42ch;
font-size: 0.92rem;
line-height: 1.55;
color: rgba(92, 72, 53, 0.84);
}
.form-section {
display: flex;
flex-direction: column;
gap: 14px;
padding: 18px;
border-radius: 24px;
background: rgba(255, 252, 248, 0.78);
border: 1px solid rgba(140, 112, 82, 0.12);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.5),
0 12px 28px rgba(75, 49, 27, 0.04);
}
.section-title {
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: rgba(124, 94, 61, 0.72);
}
.field {
display: flex;
flex-direction: column;
gap: 7px;
min-width: 0;
}
.field-label {
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: rgba(110, 82, 57, 0.74);
}
.field-input {
width: 100%;
padding: 12px 14px;
border-radius: 16px;
border: 1px solid rgba(128, 103, 79, 0.14);
background: rgba(255, 255, 255, 0.86);
color: #281a11;
font-size: 0.96rem;
line-height: 1.35;
font-family: var(--font);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.46);
transition: border-color 150ms ease, box-shadow 150ms ease, background 150ms ease;
}
.field-input::placeholder {
color: rgba(123, 102, 85, 0.66);
}
.field-input:focus {
outline: none;
border-color: rgba(184, 134, 82, 0.58);
background: rgba(255, 255, 255, 0.96);
box-shadow:
0 0 0 4px rgba(184, 134, 82, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.55);
}
.field-textarea {
resize: vertical;
min-height: 104px;
}
.field-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.modal-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 18px 28px 22px;
border-top: 1px solid rgba(122, 96, 70, 0.14);
background:
linear-gradient(180deg, rgba(255, 250, 242, 0.68), rgba(253, 246, 238, 0.92));
}
.footer-right {
display: flex;
align-items: center;
gap: 10px;
}
.btn-cancel,
.btn-save,
.btn-danger {
font-family: var(--font);
font-size: 0.92rem;
font-weight: 600;
cursor: pointer;
transition: transform 150ms ease, background 150ms ease, border-color 150ms ease, color 150ms ease, box-shadow 150ms ease;
}
.btn-cancel {
padding: 10px 16px;
border-radius: 999px;
background: rgba(255, 251, 247, 0.8);
color: #5c4837;
border: 1px solid rgba(128, 103, 79, 0.18);
}
.btn-cancel:hover {
background: rgba(255, 251, 247, 1);
color: #352418;
}
.btn-save {
padding: 10px 18px;
border-radius: 999px;
background: linear-gradient(135deg, #2f2013, #59412c);
color: #fff8f1;
border: none;
box-shadow: 0 12px 24px rgba(55, 37, 23, 0.18);
}
.btn-save:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 14px 26px rgba(55, 37, 23, 0.22);
}
.btn-save:disabled {
opacity: 0.45;
cursor: default;
box-shadow: none;
}
.btn-delete {
width: 40px;
height: 40px;
padding: 0;
border-radius: 999px;
background: rgba(137, 41, 27, 0.08);
border: 1px solid rgba(137, 41, 27, 0.18);
color: #8f3928;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: transform 150ms ease, background 150ms ease;
}
.btn-delete:hover {
transform: translateY(-1px);
background: rgba(137, 41, 27, 0.12);
}
.btn-delete svg {
width: 16px;
height: 16px;
}
.delete-confirm {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.delete-msg {
font-size: 0.88rem;
font-weight: 600;
color: #8f3928;
}
.btn-danger {
padding: 9px 14px;
border-radius: 999px;
background: #8f3928;
color: #fff7f3;
border: none;
}
.btn-danger:hover {
transform: translateY(-1px);
background: #7e2e1f;
}
@media (max-width: 768px) {
.modal-sheet {
width: 100%;
}
.modal-header {
padding: 24px 18px 18px;
}
.modal-body {
padding: 18px 18px 28px;
gap: 14px;
}
.modal-intro,
.form-section {
padding: 16px;
border-radius: 20px;
}
.field-row {
grid-template-columns: minmax(0, 1fr);
}
.modal-footer {
padding: 10px 14px 12px;
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.delete-confirm {
justify-content: center;
}
.btn-delete {
width: 42px;
height: 42px;
align-self: flex-start;
}
.footer-right {
width: 100%;
display: grid;
grid-template-columns: auto 1fr 1fr;
gap: 8px;
}
.btn-cancel,
.btn-save {
justify-content: center;
text-align: center;
padding-top: 9px;
padding-bottom: 9px;
}
}
</style>

View File

@@ -0,0 +1,590 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
let {
url,
name = 'Document',
onDelete = () => {},
deleting = false
}: {
url: string;
name?: string;
onDelete?: () => void;
deleting?: boolean;
} = $props();
let inlinePagesHost = $state<HTMLDivElement | null>(null);
let pagesHost = $state<HTMLDivElement | null>(null);
let loading = $state(true);
let expandedLoading = $state(false);
let error = $state('');
let isExpanded = $state(false);
let isInlineOpen = $state(false);
let pageCount = $state(0);
let pdfModulePromise: Promise<any> | null = null;
let pdfDocumentPromise: Promise<any> | null = null;
async function getPdfModule() {
if (!pdfModulePromise) {
pdfModulePromise = import('pdfjs-dist').then((mod) => {
const pdfjs = mod.default ?? mod;
if (!pdfjs.GlobalWorkerOptions.workerSrc) {
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString();
}
return pdfjs;
});
}
return pdfModulePromise;
}
async function getPdfDocument() {
if (!pdfDocumentPromise) {
pdfDocumentPromise = getPdfModule().then((pdfjs) =>
pdfjs.getDocument({
url,
withCredentials: true
}).promise
);
}
return pdfDocumentPromise;
}
async function renderPageToCanvas(canvas: HTMLCanvasElement, pageNumber: number, width: number) {
const pdf = await getPdfDocument();
const page = await pdf.getPage(pageNumber);
const baseViewport = page.getViewport({ scale: 1 });
const scale = width / baseViewport.width;
const viewport = page.getViewport({ scale });
const context = canvas.getContext('2d');
const pixelRatio = typeof window !== 'undefined' ? Math.max(window.devicePixelRatio || 1, 1) : 1;
if (!context) return;
canvas.width = Math.floor(viewport.width * pixelRatio);
canvas.height = Math.floor(viewport.height * pixelRatio);
canvas.style.width = `${viewport.width}px`;
canvas.style.height = `${viewport.height}px`;
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
await page.render({
canvasContext: context,
viewport
}).promise;
}
async function renderPages(host: HTMLDivElement, width: number, pageClass = 'expanded-page', canvasClass = 'expanded-canvas') {
host.replaceChildren();
const pdf = await getPdfDocument();
pageCount = pdf.numPages;
for (let pageNumber = 1; pageNumber <= pageCount; pageNumber += 1) {
const wrapper = document.createElement('div');
wrapper.className = pageClass;
const label = document.createElement('div');
label.className = 'expanded-page-label';
label.textContent = `Page ${pageNumber}`;
const canvas = document.createElement('canvas');
canvas.className = canvasClass;
wrapper.append(label, canvas);
host.appendChild(wrapper);
await renderPageToCanvas(canvas, pageNumber, width);
}
}
async function renderInline() {
if (!inlinePagesHost) return;
loading = true;
error = '';
try {
await renderPages(inlinePagesHost, 420, 'inline-page', 'inline-canvas');
} catch (err) {
console.error('PDF preview failed', err);
error = 'Preview unavailable';
} finally {
loading = false;
}
}
async function openInline() {
isInlineOpen = true;
await tick();
await renderInline();
}
function closeInline() {
isInlineOpen = false;
}
async function openExpanded() {
isExpanded = true;
expandedLoading = true;
error = '';
await tick();
if (!pagesHost) {
expandedLoading = false;
return;
}
try {
const hostWidth = Math.max(280, Math.min(860, pagesHost.clientWidth - 8 || 860));
await renderPages(pagesHost, hostWidth);
} catch (err) {
console.error('Expanded PDF render failed', err);
error = 'Unable to load PDF';
} finally {
expandedLoading = false;
}
}
function closeExpanded() {
isExpanded = false;
}
onMount(() => {
renderInline();
});
</script>
<div class="pdf-preview-shell">
<div class="preview-frame">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="preview-head"
role="button"
tabindex="0"
onclick={isInlineOpen ? closeInline : openInline}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (isInlineOpen) closeInline();
else openInline();
}
}}
>
<div class="preview-docline">
<svg class="preview-docicon" 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>
<div class="preview-name">{name}</div>
</div>
<div class="preview-head-actions">
<button class="doc-delete" onclick={(e) => { e.stopPropagation(); onDelete(); }} disabled={deleting}>{deleting ? '…' : '×'}</button>
</div>
</div>
{#if isInlineOpen}
<div class="preview-topline">
<div class="preview-badge">PDF preview</div>
<div class="preview-actions">
<button class="preview-action" onclick={openExpanded}>Full view</button>
<a class="preview-action preview-link" href={url} target="_blank" rel="noreferrer">New tab</a>
</div>
</div>
<div class="preview-card">
{#if error}
<div class="preview-state preview-error">{error}</div>
{:else}
<div class="preview-stage">
<div bind:this={inlinePagesHost} class="inline-pages"></div>
{#if loading}
<div class="preview-loading">Rendering pages…</div>
{/if}
</div>
<div class="preview-footer">
<div class="preview-pages">{pageCount} page{pageCount === 1 ? '' : 's'}</div>
</div>
{/if}
</div>
{:else}
<div class="preview-collapsed-meta">
<div class="preview-pages">{pageCount > 0 ? `${pageCount} page${pageCount === 1 ? '' : 's'}` : 'PDF document'}</div>
<a class="preview-inline-link" href={url} target="_blank" rel="noreferrer">New tab</a>
</div>
{/if}
</div>
</div>
{#if isExpanded}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="expanded-overlay" onclick={closeExpanded}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="expanded-sheet" onclick={(e: MouseEvent) => e.stopPropagation()}>
<div class="expanded-head">
<div class="expanded-copy">
<div class="expanded-kicker">Inline document</div>
<div class="expanded-title">{name}</div>
</div>
<div class="expanded-head-actions">
<a class="preview-action preview-link" href={url} target="_blank" rel="noreferrer">Open</a>
<button class="expanded-close" onclick={closeExpanded}>Close</button>
</div>
</div>
<div class="expanded-body">
{#if expandedLoading}
<div class="expanded-state">Loading full document…</div>
{:else if error}
<div class="expanded-state preview-error">{error}</div>
{/if}
<div bind:this={pagesHost} class="expanded-pages"></div>
</div>
</div>
</div>
{/if}
<style>
.pdf-preview-shell {
display: block;
}
.preview-frame {
display: flex;
flex-direction: column;
gap: 10px;
padding-top: 6px;
border-top: 1px solid rgba(127, 101, 74, 0.12);
}
.preview-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0;
border: none;
background: none;
cursor: pointer;
text-align: left;
}
.preview-docline {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.preview-docicon {
width: 16px;
height: 16px;
flex-shrink: 0;
color: rgba(111, 88, 64, 0.7);
}
.preview-head-actions {
display: flex;
align-items: center;
gap: 8px;
}
.preview-topline {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.preview-badge {
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: rgba(122, 94, 66, 0.7);
}
.preview-actions {
display: flex;
align-items: center;
gap: 8px;
}
.preview-action {
padding: 7px 12px;
border-radius: 999px;
border: 1px solid rgba(123, 97, 71, 0.16);
background: rgba(255, 251, 246, 0.9);
color: #5d4737;
font: inherit;
font-size: 0.84rem;
font-weight: 600;
cursor: pointer;
text-decoration: none;
}
.preview-card {
display: flex;
flex-direction: column;
gap: 10px;
}
.preview-state {
display: flex;
align-items: center;
justify-content: center;
min-height: 240px;
border-radius: 16px;
background: rgba(247, 239, 229, 0.78);
color: rgba(90, 71, 54, 0.8);
font-size: 0.92rem;
}
.preview-stage {
position: relative;
border-radius: 16px;
overflow: hidden;
background: rgba(250, 243, 235, 0.72);
border: 1px solid rgba(127, 101, 74, 0.14);
}
.preview-loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(247, 239, 229, 0.82);
color: rgba(90, 71, 54, 0.8);
font-size: 0.92rem;
}
.preview-error {
color: #8c3c2d;
}
.inline-pages {
display: flex;
flex-direction: column;
gap: 14px;
max-height: 520px;
overflow: auto;
padding: 10px;
}
.preview-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.preview-name {
font-size: 0.92rem;
font-weight: 600;
color: #2f2116;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.preview-pages {
font-size: 0.82rem;
color: rgba(93, 72, 55, 0.76);
}
.preview-collapsed-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.preview-inline-link {
font-size: 0.82rem;
font-weight: 600;
color: #5d4737;
text-decoration: none;
}
.doc-delete {
background: none;
border: none;
color: rgba(112, 86, 62, 0.64);
cursor: pointer;
font-size: 1.1rem;
padding: 2px 4px;
}
.doc-delete:hover {
color: #8f3928;
}
.expanded-overlay {
position: fixed;
inset: 0;
z-index: 85;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(19, 13, 10, 0.46);
backdrop-filter: blur(10px);
}
.expanded-sheet {
width: min(980px, 100%);
max-height: min(92vh, 100%);
display: flex;
flex-direction: column;
border-radius: 28px;
overflow: hidden;
background: linear-gradient(180deg, rgba(255, 250, 242, 0.98), rgba(247, 239, 229, 0.98));
box-shadow: 0 28px 64px rgba(35, 24, 15, 0.28);
}
.expanded-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
padding: 22px 24px 18px;
border-bottom: 1px solid rgba(123, 97, 71, 0.12);
}
.expanded-copy {
display: flex;
flex-direction: column;
gap: 6px;
}
.expanded-kicker {
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
color: rgba(122, 94, 66, 0.72);
}
.expanded-title {
font-size: 1.4rem;
font-weight: 600;
letter-spacing: -0.03em;
color: #27190f;
}
.expanded-head-actions {
display: flex;
align-items: center;
gap: 8px;
}
.expanded-close {
padding: 7px 12px;
border-radius: 999px;
border: 1px solid rgba(123, 97, 71, 0.16);
background: rgba(255, 251, 246, 0.9);
color: #5d4737;
font: inherit;
font-size: 0.84rem;
font-weight: 600;
cursor: pointer;
}
.expanded-body {
flex: 1;
overflow: auto;
padding: 18px 24px 24px;
}
.expanded-state {
padding: 24px;
border-radius: 18px;
background: rgba(247, 239, 229, 0.74);
color: rgba(90, 71, 54, 0.8);
text-align: center;
}
.expanded-pages {
display: flex;
flex-direction: column;
gap: 20px;
}
:global(.inline-page) {
display: flex;
flex-direction: column;
gap: 8px;
}
:global(.inline-canvas) {
display: block;
max-width: 100%;
height: auto;
border-radius: 14px;
background: white;
border: 1px solid rgba(111, 89, 65, 0.12);
box-shadow: 0 10px 20px rgba(63, 42, 22, 0.05);
}
:global(.expanded-page) {
display: flex;
flex-direction: column;
gap: 10px;
}
:global(.expanded-page-label) {
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: rgba(122, 94, 66, 0.68);
}
:global(.expanded-canvas) {
display: block;
max-width: 100%;
height: auto;
border-radius: 18px;
background: white;
border: 1px solid rgba(111, 89, 65, 0.12);
box-shadow: 0 14px 28px rgba(63, 42, 22, 0.06);
}
@media (max-width: 768px) {
.preview-topline {
align-items: flex-start;
flex-direction: column;
}
.preview-actions {
width: 100%;
}
.preview-action {
flex: 1;
text-align: center;
}
.expanded-overlay {
padding: 10px;
}
.expanded-sheet {
max-height: 96vh;
border-radius: 22px;
}
.expanded-head,
.expanded-body {
padding-left: 16px;
padding-right: 16px;
}
.expanded-head {
flex-direction: column;
}
}
</style>

View File

@@ -26,39 +26,57 @@
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}` : '';
shareUrl = tripData.share_token
? `${typeof window !== 'undefined' ? window.location.origin : ''}/trips/view/${tripData.share_token}`
: '';
confirmDelete = false;
copied = false;
}
});
function close() { open = false; }
function close() {
open = false;
}
async function save() {
saving = true;
try {
await fetch('/api/trips/trip/update', {
method: 'POST', credentials: 'include',
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: tripData.id, name, description, start_date: startDate, end_date: endDate })
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; }
} 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',
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; }
} catch (e) {
console.error('Delete failed:', e);
} finally {
saving = false;
}
}
async function toggleShare() {
@@ -66,28 +84,34 @@
try {
if (shareUrl) {
await fetch('/api/trips/share/delete', {
method: 'POST', credentials: 'include',
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',
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; }
} catch {
// keep modal stable
} finally {
sharing = false;
}
}
async function copyUrl() {
if (!shareUrl) return;
await navigator.clipboard.writeText(shareUrl);
copied = true;
setTimeout(() => copied = false, 2000);
setTimeout(() => (copied = false), 1800);
}
</script>
@@ -97,97 +121,404 @@
<!-- 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}>
<div class="modal-head-copy">
<div class="modal-kicker">Trip settings</div>
<div class="modal-title">Edit Trip</div>
</div>
<button class="modal-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="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 class="modal-intro">
<div class="intro-line">Trip profile</div>
<div class="intro-copy">Keep the trip name, date range, and sharing state in one calmer control surface.</div>
</div>
<!-- Sharing -->
<div class="share-section">
<div class="share-header">
<span class="field-label">Sharing</span>
<section class="form-section">
<div class="section-title">Primary info</div>
<div class="field">
<label class="field-label">Trip name</label>
<input class="field-input" type="text" bind:value={name} placeholder="Trip name" />
</div>
<div class="field">
<label class="field-label">Description</label>
<textarea class="field-input field-textarea" bind:value={description} rows="4" placeholder="What is this trip about?"></textarea>
</div>
</section>
<section class="form-section">
<div class="section-title">Dates</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>
</section>
<section class="form-section">
<div class="section-row">
<div class="share-copy">
<div class="section-title">Sharing</div>
<div class="section-copy">Create a viewer link for this trip, or revoke it when you want the itinerary private again.</div>
</div>
<button class="share-toggle" onclick={toggleShare} disabled={sharing}>
{shareUrl ? 'Revoke Link' : 'Create Share Link'}
{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 class="share-stack">
<div class="share-pill">
<div class="share-label">Viewer link</div>
<div class="share-value">{shareUrl}</div>
</div>
<div class="share-actions">
<button class="copy-btn" onclick={copyUrl}>{copied ? 'Copied' : 'Copy link'}</button>
</div>
</div>
{:else}
<div class="share-empty">No public trip link yet.</div>
{/if}
</div>
</section>
</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>
<div class="footer-right">
<button class="btn-danger" onclick={doDelete} disabled={saving}>Delete trip</button>
<button class="btn-cancel" onclick={() => (confirmDelete = false)}>Keep trip</button>
</div>
{:else}
<button class="btn-delete-text" onclick={() => confirmDelete = true}>Delete Trip</button>
<button class="btn-delete-text" onclick={() => (confirmDelete = true)}>Delete Trip</button>
<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>
{/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%; } }
.modal-overlay {
position: fixed;
inset: 0;
display: flex;
justify-content: flex-end;
background: rgba(26, 18, 11, 0.36);
backdrop-filter: blur(10px);
z-index: 70;
animation: modalFade 150ms ease;
}
@keyframes modalFade {
from { opacity: 0; }
to { opacity: 1; }
}
.modal-sheet {
width: 520px;
max-width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: linear-gradient(180deg, rgba(252, 248, 242, 0.98), rgba(245, 237, 228, 0.98));
box-shadow: -18px 0 54px rgba(34, 23, 13, 0.14);
animation: modalSlide 200ms ease;
}
@keyframes modalSlide {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
.modal-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 22px 24px 18px;
border-bottom: 1px solid rgba(150, 123, 95, 0.12);
}
.modal-head-copy {
display: grid;
gap: 6px;
}
.modal-kicker,
.intro-line,
.section-title,
.share-label,
.field-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.16em;
color: #8f7861;
}
.modal-title {
font-size: 1.85rem;
font-weight: 650;
line-height: 0.98;
letter-spacing: -0.04em;
color: #24180f;
}
.modal-close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border-radius: 999px;
border: 1px solid rgba(150, 123, 95, 0.12);
background: rgba(255, 255, 255, 0.6);
color: #6a5644;
cursor: pointer;
}
.modal-close svg {
width: 18px;
height: 18px;
}
.modal-body {
flex: 1;
overflow-y: auto;
padding: 22px 24px 28px;
display: flex;
flex-direction: column;
gap: 16px;
}
.modal-intro {
display: grid;
gap: 6px;
padding: 0 2px;
}
.intro-copy,
.section-copy {
color: #655444;
line-height: 1.55;
font-size: 0.96rem;
}
.form-section {
display: grid;
gap: 14px;
padding: 18px;
border-radius: 24px;
background: rgba(255, 251, 246, 0.68);
border: 1px solid rgba(150, 123, 95, 0.12);
}
.section-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.share-copy {
display: grid;
gap: 6px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field-label {
font-weight: 600;
letter-spacing: 0.12em;
}
.field-input {
padding: 12px 14px;
border-radius: 16px;
border: 1px solid rgba(150, 123, 95, 0.14);
background: rgba(255, 251, 246, 0.96);
color: #24180f;
font-size: 0.98rem;
font-family: var(--font);
}
.field-input:focus {
outline: none;
border-color: rgba(110, 80, 48, 0.42);
box-shadow: 0 0 0 4px rgba(167, 127, 76, 0.12);
}
.field-textarea {
resize: vertical;
min-height: 88px;
}
.field-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.share-toggle,
.copy-btn,
.btn-cancel,
.btn-save,
.btn-danger {
min-height: 42px;
padding: 0 16px;
border-radius: 999px;
font: inherit;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
}
.share-toggle,
.btn-save {
background: #2f2218;
color: #fff7ef;
border: none;
}
.share-toggle:disabled,
.btn-save:disabled,
.btn-danger:disabled {
opacity: 0.55;
cursor: default;
}
.share-stack {
display: grid;
gap: 12px;
}
.share-pill {
display: grid;
gap: 6px;
padding: 14px 16px;
border-radius: 18px;
background: rgba(246, 237, 226, 0.82);
border: 1px solid rgba(150, 123, 95, 0.12);
}
.share-value {
font-size: 0.92rem;
line-height: 1.45;
color: #3d2b1c;
word-break: break-all;
}
.share-actions {
display: flex;
justify-content: flex-start;
}
.copy-btn,
.btn-cancel {
background: rgba(255, 251, 246, 0.96);
color: #5e4d3d;
border: 1px solid rgba(150, 123, 95, 0.14);
}
.share-empty {
padding: 14px 16px;
border-radius: 18px;
background: rgba(246, 237, 226, 0.62);
border: 1px dashed rgba(150, 123, 95, 0.18);
color: #705e4f;
}
.modal-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 24px 18px;
border-top: 1px solid rgba(150, 123, 95, 0.12);
background: rgba(251, 246, 240, 0.82);
}
.footer-right {
display: flex;
gap: 8px;
}
.btn-delete-text {
background: none;
border: none;
padding: 0;
font: inherit;
font-size: 0.9rem;
font-weight: 600;
color: #a23e26;
cursor: pointer;
}
.delete-confirm {
display: flex;
align-items: center;
gap: 10px;
}
.delete-msg {
font-size: 0.92rem;
font-weight: 600;
color: #a23e26;
}
.btn-danger {
background: #a23e26;
color: white;
border: none;
}
@media (max-width: 768px) {
.modal-sheet {
width: 100%;
}
.modal-header {
padding: 18px 16px 14px;
}
.modal-body {
padding: 16px 16px 22px;
}
.section-row,
.field-row {
grid-template-columns: 1fr;
display: grid;
}
.modal-footer {
padding: 14px 16px calc(12px + env(safe-area-inset-bottom, 0px));
flex-wrap: wrap;
}
.footer-right {
width: 100%;
}
.footer-right :global(button) {
flex: 1;
}
}
</style>

File diff suppressed because it is too large Load Diff

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>

View File

@@ -622,7 +622,13 @@
{#if editingField === 'Item'}
<input class="edit-input detail-title-edit" type="text" bind:value={editValue} onkeydown={handleEditKeydown} onblur={saveEdit} autofocus />
{:else}
<div class="detail-title editable" onclick={() => startEdit('Item', rawField('Item'))}>{selectedItem.name}</div>
<div
class="detail-title editable"
class:is-empty={!selectedItem.name}
onclick={() => startEdit('Item', rawField('Item'))}
>
{selectedItem.name || 'Add item title'}
</div>
{/if}
<button class="detail-close" onclick={closeDetail}>Close</button>
</div>
@@ -909,12 +915,18 @@
flex: 1;
min-width: 0;
}
.detail-title.is-empty {
color: #8a7a69;
font-weight: 600;
font-style: italic;
}
.status-control { display: flex; justify-content: center; gap: 0; margin: 0 8px 22px; background: rgba(255,255,255,0.72); border: 1px solid rgba(35,26,17,0.09); border-radius: var(--radius); padding: 3px; }
.status-seg { flex: 1; padding: 8px 0; font-size: var(--text-sm); background: none; border: none; border-radius: 10px; }
.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="Needs Review"] { background: rgba(217,119,6,0.12); color: #9a5d09; }
.status-seg.active[data-status="Closed"] { background: rgba(78, 67, 58, 0.14); color: #4f453d; }
.detail-hero {
margin-bottom: var(--sp-5);
}
@@ -980,6 +992,7 @@
.field-value { font-size: var(--text-base); color: #1e1812; text-align: right; }
.detail-row.editable, .detail-title.editable { cursor: pointer; }
.detail-row.editable:hover { background: rgba(255,248,242,0.62); }
.detail-title.editable:hover { color: #17120d; }
.edit-input { width: 100%; padding: 6px 10px; border-radius: var(--radius-sm); border: 1px solid var(--accent); background: rgba(255,255,255,0.9); color: #1e1812; }
.detail-title-edit { font-size: var(--text-lg); font-weight: 600; text-align: left; flex: 1; min-width: 0; }
.upload-menu { position: absolute; left: 0; right: 0; top: 100%; margin-top: var(--sp-1); z-index: 10; background: rgba(255,250,244,0.96); border: 1px solid rgba(35,26,17,0.09); border-radius: 10px; overflow: hidden; box-shadow: 0 10px 30px rgba(18,13,10,0.1); }

View File

@@ -49,6 +49,7 @@
let currentCoverIdx = $state(0);
let expandedDays = $state<Set<number>>(new Set([1, 2, 3]));
let noteDraft = $state('');
const noteCount = $derived(notes.length);
const tripId = $derived(page.url.searchParams.get('id') || '');
const shareMode = $derived(page.url.searchParams.get('share') === 'true');
@@ -600,15 +601,17 @@
<div class="event-time-column">{event.time || 'Anytime'}</div>
<div class="event-line"></div>
<div class="event-card">
<div class="event-stamp-mobile">{event.time || 'Anytime'}</div>
<div class="event-top">
<div class="event-header-line">
<div class="event-stamp-mobile">{event.time || 'Anytime'}</div>
<span
class="event-kind"
style={`background:${categoryColors[event.category]?.tint || 'rgba(117,98,75,0.14)'};color:${categoryColors[event.category]?.ink || '#6f6052'}`}
>
{event.category}
</span>
</div>
<div class="event-name">{event.name}</div>
<span
class="event-kind"
style={`background:${categoryColors[event.category]?.tint || 'rgba(117,98,75,0.14)'};color:${categoryColors[event.category]?.ink || '#6f6052'}`}
>
{event.category}
</span>
</div>
{#if crispDescription(event)}
<div class="event-description">{crispDescription(event)}</div>
@@ -637,7 +640,10 @@
<aside class="detail-rail">
<section class="rail-block reveal">
<div class="section-label">Notes</div>
<h3>Trip desk</h3>
<div class="rail-headline">
<h3>Trip desk</h3>
<div class="rail-count">{noteCount} note{noteCount === 1 ? '' : 's'}</div>
</div>
<p>Keep booking reminders, handoff notes, and quick journal lines beside the plan.</p>
<div class="notes-stack">
{#if notes.length === 0}
@@ -868,6 +874,26 @@
color: rgba(255, 242, 229, 0.86);
}
.rail-headline {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.rail-count {
display: inline-flex;
align-items: center;
min-height: 30px;
padding: 0 10px;
border-radius: 999px;
background: rgba(255, 251, 245, 0.82);
border: 1px solid rgba(153, 129, 102, 0.16);
color: #6d5b4a;
font-size: 0.78rem;
font-weight: 600;
}
.cover-nav {
width: 34px;
height: 34px;
@@ -1112,6 +1138,7 @@
display: flex;
align-items: flex-start;
gap: 14px;
width: 100%;
padding: 0;
background: none;
border: none;
@@ -1171,28 +1198,6 @@
background: rgba(170, 108, 54, 0.16);
}
.events-stack .event-row:first-child .event-card {
background: linear-gradient(180deg, rgba(253, 249, 244, 0.98), rgba(247, 240, 231, 0.92));
border-color: rgba(153, 129, 102, 0.18);
box-shadow: 0 16px 40px rgba(87, 64, 43, 0.1);
}
.events-stack .event-row:first-child {
display: grid;
grid-template-columns: 72px 1px minmax(0, 1fr);
gap: 14px;
}
.events-stack .event-row:first-child .event-card {
display: flex;
flex-direction: column;
padding: 0;
}
.events-stack .event-row:first-child .event-card::before {
display: none;
}
.event-stamp-mobile {
display: none;
}
@@ -1208,34 +1213,20 @@
box-shadow: 0 14px 34px rgba(87, 64, 43, 0.08);
}
.events-stack .event-row:first-child .event-thumb {
width: 100%;
min-height: 214px;
border-radius: 20px 20px 0 0;
box-shadow: none;
}
.events-stack .event-row:first-child .event-card .event-top,
.events-stack .event-row:first-child .event-card .event-description,
.events-stack .event-row:first-child .event-card .event-meta,
.events-stack .event-row:first-child .event-card .event-stamp-mobile {
margin-left: 16px;
margin-right: 16px;
}
.events-stack .event-row:first-child .event-card .event-top {
margin-top: 14px;
}
.events-stack .event-row:first-child .event-card .event-meta {
margin-bottom: 16px;
}
.event-top {
display: flex;
align-items: start;
justify-content: space-between;
gap: 10px;
flex-direction: column;
}
.event-header-line {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
width: 100%;
}
.event-kind {
@@ -1255,10 +1246,6 @@
font-weight: 600;
}
.events-stack .event-row:first-child .event-name {
font-size: 1.14rem;
}
.event-description {
margin-top: 7px;
color: #6d5947;
@@ -1542,12 +1529,12 @@
}
.hero-top {
flex-direction: column;
align-items: stretch;
align-items: flex-start;
margin-bottom: 22px;
}
.hero-tools {
justify-content: flex-start;
display: none;
}
.hero-copy h1 {
@@ -1565,13 +1552,7 @@
font-size: 0.92rem;
}
.board-head {
justify-content: center;
}
.board-tools {
justify-content: center;
grid-auto-flow: column;
width: 100%;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
@@ -1646,15 +1627,18 @@
.event-stamp-mobile {
display: inline-flex;
align-self: flex-start;
margin-bottom: 8px;
padding: 5px 8px;
align-items: center;
min-height: 24px;
margin-bottom: 0;
padding: 0;
border-radius: 999px;
background: rgba(96, 74, 53, 0.08);
color: #7b6652;
font-size: 0.74rem;
background: none;
color: #8f4c24;
font-size: 0.76rem;
font-weight: 700;
line-height: 1;
text-transform: uppercase;
letter-spacing: 0.12em;
letter-spacing: 0.1em;
}
.event-card {
@@ -1663,20 +1647,54 @@
box-shadow: none;
border-radius: 0;
padding: 14px 14px 16px;
width: 100%;
box-sizing: border-box;
}
.event-card::before {
display: none;
}
.events-stack .event-row:first-child {
.event-top {
display: flex;
box-shadow: 0 14px 34px rgba(87, 64, 43, 0.08);
flex-direction: column;
gap: 8px;
align-items: stretch;
width: 100%;
box-sizing: border-box;
}
.events-stack .event-row:first-child .event-thumb {
height: 184px;
min-height: 184px;
.event-header-line {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
width: 100%;
box-sizing: border-box;
min-height: 24px;
column-gap: 10px;
}
.event-stamp-mobile {
justify-self: start;
}
.event-top .event-kind {
min-height: 24px;
display: inline-flex;
align-items: center;
position: static;
margin: 0;
white-space: nowrap;
text-align: right;
justify-self: end;
align-self: center;
max-width: none;
}
.event-kind {
padding: 3px 7px;
font-size: 0.64rem;
letter-spacing: 0.08em;
}
.event-name {
@@ -1685,7 +1703,7 @@
}
.events-stack .event-row:first-child .event-name {
font-size: 1.14rem;
font-size: 1.02rem;
}
.event-description {
@@ -1696,6 +1714,16 @@
font-size: 0.72rem;
}
.events-stack .event-row:first-child {
display: flex;
box-shadow: 0 10px 26px rgba(87, 64, 43, 0.05);
}
.events-stack .event-row:first-child .event-thumb {
height: 156px;
min-height: 156px;
}
.stay-row {
grid-template-columns: 1fr;
gap: 8px;

View File

@@ -38,9 +38,7 @@
: null
);
const completedCount = $derived(past.length);
const activeCount = $derived(upcoming.filter((trip) => trip.status === 'active').length);
const openTripCount = $derived(upcoming.length);
function formatPoints(n: number): string {
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
if (n >= 1000) return Math.round(n / 1000) + 'K';
@@ -148,47 +146,30 @@
<div class="trips-page">
<section class="journey-hero reveal">
<div class="hero-copy">
<div class="panel-kicker">Travel desk</div>
<h1>Trips</h1>
<p>Keep the active run, the next departure, and the archive in one calmer travel surface.</p>
</div>
<div class="hero-actions">
<button class="hero-button primary" onclick={() => (createOpen = true)}>Plan trip</button>
<a class="hero-button ghost" href={leadTrip ? `/trips/trip?id=${leadTrip.id}` : '/trips'}>
{activeTrip ? 'Open active' : 'Open next'}
</a>
</div>
</section>
<section class="signal-strip reveal">
<div class="signal-line">
<div>
<div class="signal-label">Open itineraries</div>
<div class="signal-note">{activeCount} active · {Math.max(0, upcoming.length - activeCount)} upcoming</div>
</div>
<div class="signal-value">{loading ? '…' : upcoming.length}</div>
<section class="stats-grid reveal">
<div class="stat-tile">
<div class="stat-value">{loading ? '…' : stats.trips}</div>
<div class="stat-label">Trips</div>
</div>
<div class="signal-line">
<div>
<div class="signal-label">Coverage</div>
<div class="signal-note">{stats.cities} cities across {stats.countries} countries</div>
</div>
<div class="signal-value">{loading ? '…' : stats.countries}</div>
<div class="stat-tile">
<div class="stat-value">{loading ? '…' : stats.cities}</div>
<div class="stat-label">Cities</div>
</div>
<div class="signal-line">
<div>
<div class="signal-label">Archive</div>
<div class="signal-note">{completedCount} completed journeys on record</div>
</div>
<div class="signal-value">{loading ? '…' : completedCount}</div>
<div class="stat-tile">
<div class="stat-value">{loading ? '…' : stats.countries}</div>
<div class="stat-label">Countries</div>
</div>
<div class="signal-line">
<div>
<div class="signal-label">Points redeemed</div>
<div class="signal-note">Long-range travel cost pulled from live stats</div>
</div>
<div class="signal-value">{loading ? '…' : formatPoints(stats.points)}</div>
<div class="stat-tile">
<div class="stat-value">{loading ? '…' : formatPoints(stats.points)}</div>
<div class="stat-label">Points used</div>
</div>
</section>
@@ -202,17 +183,6 @@
</button>
{/if}
</div>
<div class="search-note">
{#if searchResults}
{searchResults.length} result{searchResults.length !== 1 ? 's' : ''}
{:else if activeTrip}
Active now: {activeTrip.name}
{:else if nextTrip}
Next departure: {nextTrip.daysAway || nextTrip.dates}
{:else}
No active itinerary right now.
{/if}
</div>
</section>
<div class="trips-grid">
@@ -248,12 +218,8 @@
{:else}
<div class="section-head">
<div>
<div class="section-label">Active board</div>
<h2>Current and upcoming</h2>
<h2>Upcoming</h2>
</div>
{#if nextTrip}
<a class="inline-link" href={`/trips/trip?id=${nextTrip.id}`}>Open next trip</a>
{/if}
</div>
<div class="journey-list">
@@ -284,8 +250,7 @@
<div class="section-head archive">
<div>
<div class="section-label">Archive</div>
<h2>Past routes</h2>
<h2>Past trips</h2>
</div>
</div>
@@ -311,23 +276,6 @@
{/if}
</section>
<aside class="travel-rail reveal">
<div class="rail-block standout">
<div class="section-label">Next move</div>
<h3>{leadTrip ? leadTrip.name : 'No trip queued'}</h3>
<p>
{#if leadTrip}
{activeTrip ? 'Currently in motion. Keep the itinerary, bookings, and notes close.' : `${leadTrip.daysAway} until departure.`}
{:else}
Open a new itinerary to track dates, bookings, and notes here.
{/if}
</p>
{#if leadTrip}
<div class="rail-meta">{leadTrip.dates} · {leadTrip.duration}</div>
<a class="rail-link" href={`/trips/trip?id=${leadTrip.id}`}>{activeTrip ? 'Resume trip' : 'Open itinerary'}</a>
{/if}
</div>
</aside>
</div>
</div>
@@ -341,10 +289,7 @@
}
.journey-hero,
.signal-strip,
.search-band,
.journey-column,
.travel-rail {
.search-band {
background: rgba(250, 244, 236, 0.72);
border: 1px solid rgba(35, 26, 17, 0.08);
border-radius: 28px;
@@ -364,11 +309,10 @@
.hero-copy {
display: grid;
gap: 8px;
max-width: 36rem;
gap: 0;
max-width: 42rem;
}
.panel-kicker,
.section-label {
font-size: 11px;
text-transform: uppercase;
@@ -377,8 +321,7 @@
}
.journey-hero h1,
.section-head h2,
.travel-rail h3 {
.section-head h2 {
margin: 0;
letter-spacing: -0.055em;
color: #1c140c;
@@ -389,14 +332,6 @@
line-height: 0.92;
}
.journey-hero p,
.rail-copy,
.standout p {
margin: 0;
color: #5f5347;
line-height: 1.55;
}
.hero-actions {
display: flex;
gap: 10px;
@@ -425,53 +360,38 @@
color: #fff7ee;
}
.hero-button.ghost {
background: rgba(255, 251, 246, 0.82);
color: #4f4338;
}
.signal-strip {
.stats-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
overflow: hidden;
gap: 10px;
}
.signal-line {
display: flex;
justify-content: space-between;
gap: 14px;
padding: 18px 20px;
border-right: 1px solid rgba(35, 26, 17, 0.08);
.stat-tile {
display: grid;
gap: 4px;
padding: 14px 12px;
border-radius: 20px;
background: rgba(255, 251, 246, 0.7);
border: 1px solid rgba(35, 26, 17, 0.07);
text-align: center;
}
.signal-line:last-child {
border-right: none;
}
.signal-label {
font-size: 0.84rem;
font-weight: 600;
color: #30261d;
}
.signal-note {
margin-top: 4px;
font-size: 0.84rem;
color: #6f6255;
line-height: 1.45;
}
.signal-value {
font-size: 1.4rem;
.stat-value {
font-size: 1.3rem;
font-weight: 700;
color: #1b140d;
line-height: 1;
letter-spacing: -0.04em;
color: #1f1811;
}
.stat-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.12em;
color: #7c6b5c;
}
.search-band {
display: flex;
align-items: center;
gap: 16px;
padding: 14px 18px;
}
@@ -516,29 +436,12 @@
height: 16px;
}
.search-note {
flex-shrink: 0;
font-size: 0.88rem;
color: #65584b;
}
.trips-grid {
display: grid;
grid-template-columns: minmax(0, 1.4fr) 320px;
gap: 18px;
align-items: start;
display: block;
}
.journey-column {
padding: 18px;
}
.travel-rail {
padding: 18px;
display: grid;
gap: 14px;
position: sticky;
top: 28px;
padding: 0;
}
.section-head {
@@ -558,14 +461,6 @@
margin-top: 22px;
}
.inline-link {
padding: 10px 14px;
background: rgba(255, 251, 246, 0.84);
border: 1px solid rgba(35, 26, 17, 0.08);
color: #4f4338;
font-size: 0.88rem;
}
.journey-list {
display: grid;
gap: 12px;
@@ -674,10 +569,7 @@
.journey-notes,
.journey-open,
.journey-empty,
.rail-meta,
.rail-stat span,
.coverage-row span {
.journey-empty {
color: #605447;
}
@@ -693,76 +585,8 @@
border: 1px dashed rgba(35, 26, 17, 0.11);
}
.rail-block {
padding: 16px;
border-radius: 22px;
background: rgba(255, 249, 243, 0.84);
border: 1px solid rgba(35, 26, 17, 0.07);
}
.rail-block.standout {
background: linear-gradient(140deg, rgba(255, 247, 240, 0.96), rgba(244, 231, 217, 0.9));
}
.travel-rail h3 {
font-size: 1.5rem;
line-height: 1.02;
margin-top: 6px;
}
.rail-meta {
margin-top: 10px;
font-size: 0.88rem;
}
.rail-link {
display: inline-flex;
align-items: center;
justify-content: center;
margin-top: 14px;
padding: 10px 14px;
border-radius: 999px;
background: rgba(255, 251, 246, 0.88);
border: 1px solid rgba(35, 26, 17, 0.08);
color: #4f4338;
font-size: 0.88rem;
font-weight: 600;
text-decoration: none;
}
.rail-stat,
.coverage-row {
display: flex;
justify-content: space-between;
gap: 12px;
padding-top: 10px;
margin-top: 10px;
border-top: 1px solid rgba(35, 26, 17, 0.08);
}
.rail-stat strong,
.coverage-row strong {
color: #201810;
font-family: var(--mono);
}
@media (max-width: 1100px) {
.signal-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.signal-line:nth-child(2) {
border-right: none;
}
.trips-grid {
grid-template-columns: 1fr;
}
.travel-rail {
position: static;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.trips-grid { display: block; }
}
@media (max-width: 768px) {
@@ -772,52 +596,58 @@
}
.journey-hero,
.signal-strip,
.search-band,
.journey-column,
.travel-rail {
.search-band {
border-radius: 24px;
}
.journey-hero {
padding: 18px 16px;
grid-template-columns: 1fr;
padding: 16px;
align-items: start;
gap: 14px;
}
.stats-grid {
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.stat-tile {
padding: 12px 10px;
border-radius: 18px;
}
.stat-value {
font-size: 1.12rem;
}
.hero-actions,
.search-band,
.signal-strip,
.travel-rail {
.search-band {
display: grid;
}
.hero-actions {
grid-template-columns: 1fr 1fr;
width: auto;
gap: 8px;
}
.hero-copy {
gap: 0;
max-width: none;
}
.journey-hero h1 {
font-size: clamp(2.2rem, 10vw, 3rem);
line-height: 0.95;
}
.hero-button {
min-height: 44px;
padding: 0 14px;
font-size: 0.88rem;
}
.search-band {
padding: 14px 16px;
gap: 10px;
}
.search-note {
font-size: 0.82rem;
}
.signal-strip,
.travel-rail {
grid-template-columns: 1fr;
}
.signal-line {
padding: 16px;
border-right: none;
border-bottom: 1px solid rgba(35, 26, 17, 0.08);
}
.signal-line:last-child {
border-bottom: none;
}
.journey-row,
@@ -841,9 +671,6 @@
gap: 10px;
}
.journey-column,
.travel-rail {
padding: 16px;
}
.journey-column { padding: 0; }
}
</style>

View File

@@ -1,763 +1,12 @@
<script lang="ts">
import { onMount } from 'svelte';
import AtelierBudgetPage from '$lib/pages/budget/AtelierBudgetPage.svelte';
import LegacyBudgetPage from '$lib/pages/budget/LegacyBudgetPage.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;
});
let { data } = $props();
</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>
{#if data?.useAtelierShell}
<AtelierBudgetPage />
{:else}
<LegacyBudgetPage />
{/if}