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

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

View File

@@ -61,6 +61,7 @@ services:
- BUDGET_BACKEND_URL=http://budget-service:3001 - BUDGET_BACKEND_URL=http://budget-service:3001
- TASKS_BACKEND_URL=http://tasks-service:8098 - TASKS_BACKEND_URL=http://tasks-service:8098
- TASKS_SERVICE_API_KEY=${TASKS_SERVICE_API_KEY} - TASKS_SERVICE_API_KEY=${TASKS_SERVICE_API_KEY}
- BRAIN_BACKEND_URL=http://brain-api:8200
- QBITTORRENT_HOST=${QBITTORRENT_HOST:-192.168.1.42} - QBITTORRENT_HOST=${QBITTORRENT_HOST:-192.168.1.42}
- QBITTORRENT_PORT=${QBITTORRENT_PORT:-8080} - QBITTORRENT_PORT=${QBITTORRENT_PORT:-8080}
- QBITTORRENT_USERNAME=${QBITTORRENT_USERNAME:-admin} - QBITTORRENT_USERNAME=${QBITTORRENT_USERNAME:-admin}

View File

@@ -12,6 +12,7 @@
"@sveltejs/adapter-node": "^5.5.4", "@sveltejs/adapter-node": "^5.5.4",
"@tailwindcss/vite": "^4.2.2", "@tailwindcss/vite": "^4.2.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"pdfjs-dist": "^5.6.205",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2" "tailwindcss": "^4.2.2"
}, },
@@ -495,6 +496,256 @@
"svelte": "^5" "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": { "node_modules/@polka/url": {
"version": "1.0.0-next.29", "version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", "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": "^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": { "node_modules/obug": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
@@ -1932,6 +2190,19 @@
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"license": "MIT" "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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",

View File

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

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import ImmichPicker from '$lib/components/shared/ImmichPicker.svelte'; import ImmichPicker from '$lib/components/shared/ImmichPicker.svelte';
import PdfInlinePreview from './PdfInlinePreview.svelte';
let { let {
entityType, entityType,
@@ -148,6 +149,12 @@
showImmich = false; showImmich = false;
onUpload(); 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> </script>
<div class="upload-section"> <div class="upload-section">
@@ -169,11 +176,22 @@
{#if documents && documents.length > 0} {#if documents && documents.length > 0}
<div class="doc-list"> <div class="doc-list">
{#each documents as doc} {#each documents as doc}
<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"> <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> <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> <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> <button class="doc-delete" onclick={() => deleteDoc(doc.id)} disabled={deletingDocId === doc.id}>×</button>
</div> </div>
{/if}
</div>
{/each} {/each}
</div> </div>
{/if} {/if}
@@ -241,13 +259,36 @@
border-radius: 50%; background: rgba(0,0,0,0.5); color: white; border: none; 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; 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-list { display: flex; flex-direction: column; gap: 12px; }
.doc-row { display: flex; align-items: center; gap: 6px; padding: 6px 8px; border-radius: 6px; background: var(--surface-secondary); } .doc-card {
.doc-icon { width: 14px; height: 14px; color: var(--text-4); flex-shrink: 0; } display: flex;
.doc-name { flex: 1; font-size: var(--text-sm); color: var(--text-2); text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } flex-direction: column;
.doc-name:hover { color: var(--accent); } gap: 10px;
.doc-delete { background: none; border: none; color: var(--text-4); cursor: pointer; font-size: var(--text-base); padding: 2px 4px; } padding: 0;
.doc-delete:hover { color: var(--error); } }
.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-actions { display: flex; flex-wrap: wrap; gap: 6px; }
.upload-btn { .upload-btn {

View File

@@ -20,6 +20,8 @@
let saving = $state(false); let saving = $state(false);
let confirmDelete = $state(false); let confirmDelete = $state(false);
let mediaImages = $state<any[]>([]);
let mediaDocuments = $state<any[]>([]);
// ── Form state ── // ── Form state ──
let name = $state(''); let name = $state('');
@@ -79,8 +81,12 @@
transportType = editItem.type || 'plane'; transportType = editItem.type || 'plane';
lodgingType = editItem.type || 'hotel'; lodgingType = editItem.type || 'hotel';
content = editItem.content || ''; 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) { } else if (open) {
resetForm(); resetForm();
mediaImages = [];
mediaDocuments = [];
} }
confirmDelete = false; confirmDelete = false;
}); });
@@ -96,6 +102,40 @@
function close() { open = false; resetForm(); confirmDelete = false; } 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) { function handlePlaceSelect(details: any) {
if (details.name) name = details.name; if (details.name) name = details.name;
address = details.address || ''; address = details.address || '';
@@ -168,14 +208,34 @@
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-sheet" onclick={(e) => e.stopPropagation()}> <div class="modal-sheet" onclick={(e) => e.stopPropagation()}>
<div class="modal-header"> <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}> <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> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button> </button>
</div> </div>
<div class="modal-body"> <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) --> <!-- Name (with Places search for location & lodging) -->
<section class="form-section">
<div class="section-title">Primary info</div>
{#if itemType === 'location' || itemType === 'lodging'} {#if itemType === 'location' || itemType === 'lodging'}
<div class="field"> <div class="field">
<label class="field-label">Name</label> <label class="field-label">Name</label>
@@ -263,8 +323,10 @@
<input class="field-input" type="date" bind:value={date} /> <input class="field-input" type="date" bind:value={date} />
</div> </div>
{/if} {/if}
</section>
<!-- Description / Content --> <section class="form-section">
<div class="section-title">{itemType === 'note' ? 'Content' : 'Details'}</div>
{#if itemType === 'note'} {#if itemType === 'note'}
<div class="field"> <div class="field">
<label class="field-label">Content</label> <label class="field-label">Content</label>
@@ -276,23 +338,12 @@
<textarea class="field-input field-textarea" bind:value={description} rows="3" placeholder="Details..."></textarea> <textarea class="field-input field-textarea" bind:value={description} rows="3" placeholder="Details..."></textarea>
</div> </div>
{/if} {/if}
</section>
<!-- 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 --> <!-- Link -->
{#if itemType !== 'note'} {#if itemType !== 'note'}
<section class="form-section">
<div class="section-title">Booking + cost</div>
<div class="field"> <div class="field">
<label class="field-label">Link</label> <label class="field-label">Link</label>
<input class="field-input" type="url" bind:value={link} placeholder="https://..." /> <input class="field-input" type="url" bind:value={link} placeholder="https://..." />
@@ -301,6 +352,24 @@
<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">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 class="field"><label class="field-label">Cash ($)</label><input class="field-input" type="number" step="0.01" bind:value={costCash} /></div>
</div> </div>
</section>
{/if}
<!-- Images & Documents (edit mode only) -->
{#if isEdit && itemType !== 'note'}
<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} {/if}
</div> </div>
@@ -312,15 +381,14 @@
<button class="btn-danger" onclick={doDelete} disabled={saving}>Yes, delete</button> <button class="btn-danger" onclick={doDelete} disabled={saving}>Yes, delete</button>
<button class="btn-cancel" onclick={() => confirmDelete = false}>Cancel</button> <button class="btn-cancel" onclick={() => confirmDelete = false}>Cancel</button>
</div> </div>
{:else} {/if}
{/if}
<div class="footer-right">
{#if isEdit && !confirmDelete}
<button class="btn-delete" onclick={() => confirmDelete = true}> <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> <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> </button>
{/if} {/if}
{:else}
<div></div>
{/if}
<div class="footer-right">
<button class="btn-cancel" onclick={close}>Cancel</button> <button class="btn-cancel" onclick={close}>Cancel</button>
<button class="btn-save" onclick={save} disabled={saving || !name.trim()}> <button class="btn-save" onclick={save} disabled={saving || !name.trim()}>
{saving ? 'Saving...' : isEdit ? 'Save' : 'Add'} {saving ? 'Saving...' : isEdit ? 'Save' : 'Add'}
@@ -332,37 +400,372 @@
{/if} {/if}
<style> <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; } .modal-overlay {
@keyframes modalFade { from { opacity: 0; } to { opacity: 1; } } position: fixed;
.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; } inset: 0;
@keyframes modalSlide { from { transform: translateX(100%); } to { transform: translateX(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); } @keyframes modalFade {
.modal-title { font-size: var(--text-md); font-weight: 600; color: var(--text-1); } from { opacity: 0; }
.modal-close { background: none; border: none; cursor: pointer; color: var(--text-3); padding: var(--sp-1); border-radius: var(--radius-sm); } to { opacity: 1; }
.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; } .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); } @keyframes modalSlide {
.field-label { font-size: var(--text-sm); font-weight: 500; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.04em; } from { transform: translateX(100%); }
.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); } to { transform: translateX(0); }
.field-input:focus { outline: none; border-color: var(--accent); } }
.field-textarea { resize: vertical; min-height: 60px; }
.field-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 10px; }
.modal-footer { display: flex; align-items: center; justify-content: space-between; padding: 14px 20px; border-top: 1px solid var(--border); } .modal-header {
.footer-right { display: flex; gap: var(--sp-2); } display: flex;
.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); } align-items: flex-start;
.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); } justify-content: space-between;
.btn-save:disabled { opacity: 0.4; cursor: default; } gap: 18px;
.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; } padding: 28px 28px 20px;
.btn-delete:hover { background: var(--error-bg); } border-bottom: 1px solid rgba(122, 96, 70, 0.14);
.btn-delete svg { width: 16px; height: 16px; } background:
.delete-confirm { display: flex; align-items: center; gap: var(--sp-2); } linear-gradient(180deg, rgba(255, 248, 237, 0.92), rgba(255, 248, 237, 0.74));
.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-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> </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 || ''; description = tripData.description || '';
startDate = tripData.start_date || ''; startDate = tripData.start_date || '';
endDate = tripData.end_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; confirmDelete = false;
copied = false; copied = false;
} }
}); });
function close() { open = false; } function close() {
open = false;
}
async function save() { async function save() {
saving = true; saving = true;
try { try {
await fetch('/api/trips/trip/update', { await fetch('/api/trips/trip/update', {
method: 'POST', credentials: 'include', method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' }, 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(); close();
onSaved(); onSaved();
} catch (e) { console.error('Save failed:', e); } } catch (e) {
finally { saving = false; } console.error('Save failed:', e);
} finally {
saving = false;
}
} }
async function doDelete() { async function doDelete() {
saving = true; saving = true;
try { try {
await fetch('/api/trips/trip/delete', { await fetch('/api/trips/trip/delete', {
method: 'POST', credentials: 'include', method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: tripData.id }) body: JSON.stringify({ id: tripData.id })
}); });
window.location.href = '/trips'; window.location.href = '/trips';
} catch (e) { console.error('Delete failed:', e); } } catch (e) {
finally { saving = false; } console.error('Delete failed:', e);
} finally {
saving = false;
}
} }
async function toggleShare() { async function toggleShare() {
@@ -66,28 +84,34 @@
try { try {
if (shareUrl) { if (shareUrl) {
await fetch('/api/trips/share/delete', { await fetch('/api/trips/share/delete', {
method: 'POST', credentials: 'include', method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ trip_id: tripData.id }) body: JSON.stringify({ trip_id: tripData.id })
}); });
shareUrl = ''; shareUrl = '';
} else { } else {
const res = await fetch('/api/trips/share/create', { const res = await fetch('/api/trips/share/create', {
method: 'POST', credentials: 'include', method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ trip_id: tripData.id }) body: JSON.stringify({ trip_id: tripData.id })
}); });
const data = await res.json(); const data = await res.json();
shareUrl = `${window.location.origin}/trips/view/${data.share_token}`; shareUrl = `${window.location.origin}/trips/view/${data.share_token}`;
} }
} catch { /* silent */ } } catch {
finally { sharing = false; } // keep modal stable
} finally {
sharing = false;
}
} }
async function copyUrl() { async function copyUrl() {
if (!shareUrl) return;
await navigator.clipboard.writeText(shareUrl); await navigator.clipboard.writeText(shareUrl);
copied = true; copied = true;
setTimeout(() => copied = false, 2000); setTimeout(() => (copied = false), 1800);
} }
</script> </script>
@@ -97,97 +121,404 @@
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-sheet" onclick={(e) => e.stopPropagation()}> <div class="modal-sheet" onclick={(e) => e.stopPropagation()}>
<div class="modal-header"> <div class="modal-header">
<div class="modal-head-copy">
<div class="modal-kicker">Trip settings</div>
<div class="modal-title">Edit Trip</div> <div class="modal-title">Edit Trip</div>
<button class="modal-close" onclick={close}> </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> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button> </button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<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>
<section class="form-section">
<div class="section-title">Primary info</div>
<div class="field"> <div class="field">
<label class="field-label">Trip Name</label> <label class="field-label">Trip name</label>
<input class="field-input" type="text" bind:value={name} /> <input class="field-input" type="text" bind:value={name} placeholder="Trip name" />
</div> </div>
<div class="field"> <div class="field">
<label class="field-label">Description</label> <label class="field-label">Description</label>
<textarea class="field-input field-textarea" bind:value={description} rows="3"></textarea> <textarea class="field-input field-textarea" bind:value={description} rows="4" placeholder="What is this trip about?"></textarea>
</div>
<div class="field-row">
<div class="field"><label class="field-label">Start Date</label><input class="field-input" type="date" bind:value={startDate} /></div>
<div class="field"><label class="field-label">End Date</label><input class="field-input" type="date" bind:value={endDate} /></div>
</div> </div>
</section>
<!-- Sharing --> <section class="form-section">
<div class="share-section"> <div class="section-title">Dates</div>
<div class="share-header"> <div class="field-row">
<span class="field-label">Sharing</span> <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}> <button class="share-toggle" onclick={toggleShare} disabled={sharing}>
{shareUrl ? 'Revoke Link' : 'Create Share Link'} {shareUrl ? 'Revoke link' : 'Create share link'}
</button> </button>
</div> </div>
{#if shareUrl} {#if shareUrl}
<div class="share-link-row"> <div class="share-stack">
<input class="field-input share-url" type="text" readonly value={shareUrl} /> <div class="share-pill">
<button class="copy-btn" onclick={copyUrl}>{copied ? 'Copied!' : 'Copy'}</button> <div class="share-label">Viewer link</div>
<div class="share-value">{shareUrl}</div>
</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} {/if}
</div> </section>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
{#if confirmDelete} {#if confirmDelete}
<div class="delete-confirm"> <div class="delete-confirm">
<span class="delete-msg">Delete this trip permanently?</span> <span class="delete-msg">Delete this trip permanently?</span>
<button class="btn-danger" onclick={doDelete} disabled={saving}>Yes, delete</button> </div>
<button class="btn-cancel" onclick={() => confirmDelete = false}>Cancel</button> <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> </div>
{:else} {:else}
<button class="btn-delete-text" onclick={() => confirmDelete = true}>Delete Trip</button> <button class="btn-delete-text" onclick={() => (confirmDelete = true)}>Delete Trip</button>
{/if}
<div class="footer-right"> <div class="footer-right">
<button class="btn-cancel" onclick={close}>Cancel</button> <button class="btn-cancel" onclick={close}>Cancel</button>
<button class="btn-save" onclick={save} disabled={saving || !name.trim()}> <button class="btn-save" onclick={save} disabled={saving || !name.trim()}>
{saving ? 'Saving...' : 'Save'} {saving ? 'Saving...' : 'Save'}
</button> </button>
</div> </div>
{/if}
</div> </div>
</div> </div>
</div> </div>
{/if} {/if}
<style> <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; } .modal-overlay {
@keyframes modalFade { from { opacity: 0; } to { opacity: 1; } } position: fixed;
.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; } inset: 0;
@keyframes modalSlide { from { transform: translateX(100%); } to { transform: translateX(0); } } display: flex;
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border); } justify-content: flex-end;
.modal-title { font-size: var(--text-md); font-weight: 600; color: var(--text-1); } background: rgba(26, 18, 11, 0.36);
.modal-close { background: none; border: none; cursor: pointer; color: var(--text-3); padding: var(--sp-1); border-radius: var(--radius-sm); } backdrop-filter: blur(10px);
.modal-close:hover { color: var(--text-1); background: var(--card-hover); } z-index: 70;
.modal-close svg { width: 18px; height: 18px; } animation: modalFade 150ms ease;
.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; } @keyframes modalFade {
.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); } from { opacity: 0; }
.field-input:focus { outline: none; border-color: var(--accent); } to { opacity: 1; }
.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); } .modal-sheet {
.share-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--sp-2); } width: 520px;
.share-toggle { font-size: var(--text-sm); font-weight: 500; color: var(--accent); background: none; border: none; cursor: pointer; font-family: var(--font); } max-width: 100%;
.share-toggle:hover { opacity: 0.7; } height: 100%;
.share-link-row { display: flex; gap: var(--sp-2); } display: flex;
.share-url { flex: 1; font-size: var(--text-sm); font-family: var(--mono); } flex-direction: column;
.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; } background: linear-gradient(180deg, rgba(252, 248, 242, 0.98), rgba(245, 237, 228, 0.98));
.modal-footer { display: flex; align-items: center; justify-content: space-between; padding: 14px var(--sp-5); border-top: 1px solid var(--border); } box-shadow: -18px 0 54px rgba(34, 23, 13, 0.14);
.footer-right { display: flex; gap: var(--sp-2); } animation: modalSlide 200ms ease;
.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; } @keyframes modalSlide {
.btn-delete-text { font-size: var(--text-sm); color: var(--error); background: none; border: none; cursor: pointer; font-weight: 500; font-family: var(--font); } from { transform: translateX(100%); }
.btn-delete-text:hover { opacity: 0.7; } to { transform: translateX(0); }
.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 {
@media (max-width: 768px) { .modal-sheet { width: 100%; } } 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> </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'} {#if editingField === 'Item'}
<input class="edit-input detail-title-edit" type="text" bind:value={editValue} onkeydown={handleEditKeydown} onblur={saveEdit} autofocus /> <input class="edit-input detail-title-edit" type="text" bind:value={editValue} onkeydown={handleEditKeydown} onblur={saveEdit} autofocus />
{:else} {: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} {/if}
<button class="detail-close" onclick={closeDetail}>Close</button> <button class="detail-close" onclick={closeDetail}>Close</button>
</div> </div>
@@ -909,12 +915,18 @@
flex: 1; flex: 1;
min-width: 0; 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-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 { 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="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="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="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="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 { .detail-hero {
margin-bottom: var(--sp-5); margin-bottom: var(--sp-5);
} }
@@ -980,6 +992,7 @@
.field-value { font-size: var(--text-base); color: #1e1812; text-align: right; } .field-value { font-size: var(--text-base); color: #1e1812; text-align: right; }
.detail-row.editable, .detail-title.editable { cursor: pointer; } .detail-row.editable, .detail-title.editable { cursor: pointer; }
.detail-row.editable:hover { background: rgba(255,248,242,0.62); } .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; } .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; } .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); } .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 currentCoverIdx = $state(0);
let expandedDays = $state<Set<number>>(new Set([1, 2, 3])); let expandedDays = $state<Set<number>>(new Set([1, 2, 3]));
let noteDraft = $state(''); let noteDraft = $state('');
const noteCount = $derived(notes.length);
const tripId = $derived(page.url.searchParams.get('id') || ''); const tripId = $derived(page.url.searchParams.get('id') || '');
const shareMode = $derived(page.url.searchParams.get('share') === 'true'); const shareMode = $derived(page.url.searchParams.get('share') === 'true');
@@ -600,9 +601,9 @@
<div class="event-time-column">{event.time || 'Anytime'}</div> <div class="event-time-column">{event.time || 'Anytime'}</div>
<div class="event-line"></div> <div class="event-line"></div>
<div class="event-card"> <div class="event-card">
<div class="event-stamp-mobile">{event.time || 'Anytime'}</div>
<div class="event-top"> <div class="event-top">
<div class="event-name">{event.name}</div> <div class="event-header-line">
<div class="event-stamp-mobile">{event.time || 'Anytime'}</div>
<span <span
class="event-kind" class="event-kind"
style={`background:${categoryColors[event.category]?.tint || 'rgba(117,98,75,0.14)'};color:${categoryColors[event.category]?.ink || '#6f6052'}`} style={`background:${categoryColors[event.category]?.tint || 'rgba(117,98,75,0.14)'};color:${categoryColors[event.category]?.ink || '#6f6052'}`}
@@ -610,6 +611,8 @@
{event.category} {event.category}
</span> </span>
</div> </div>
<div class="event-name">{event.name}</div>
</div>
{#if crispDescription(event)} {#if crispDescription(event)}
<div class="event-description">{crispDescription(event)}</div> <div class="event-description">{crispDescription(event)}</div>
{/if} {/if}
@@ -637,7 +640,10 @@
<aside class="detail-rail"> <aside class="detail-rail">
<section class="rail-block reveal"> <section class="rail-block reveal">
<div class="section-label">Notes</div> <div class="section-label">Notes</div>
<div class="rail-headline">
<h3>Trip desk</h3> <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> <p>Keep booking reminders, handoff notes, and quick journal lines beside the plan.</p>
<div class="notes-stack"> <div class="notes-stack">
{#if notes.length === 0} {#if notes.length === 0}
@@ -868,6 +874,26 @@
color: rgba(255, 242, 229, 0.86); 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 { .cover-nav {
width: 34px; width: 34px;
height: 34px; height: 34px;
@@ -1112,6 +1138,7 @@
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 14px; gap: 14px;
width: 100%;
padding: 0; padding: 0;
background: none; background: none;
border: none; border: none;
@@ -1171,28 +1198,6 @@
background: rgba(170, 108, 54, 0.16); 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 { .event-stamp-mobile {
display: none; display: none;
} }
@@ -1208,34 +1213,20 @@
box-shadow: 0 14px 34px rgba(87, 64, 43, 0.08); 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 { .event-top {
display: flex; display: flex;
align-items: start; align-items: start;
justify-content: space-between; justify-content: space-between;
gap: 10px; gap: 10px;
flex-direction: column;
}
.event-header-line {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
width: 100%;
} }
.event-kind { .event-kind {
@@ -1255,10 +1246,6 @@
font-weight: 600; font-weight: 600;
} }
.events-stack .event-row:first-child .event-name {
font-size: 1.14rem;
}
.event-description { .event-description {
margin-top: 7px; margin-top: 7px;
color: #6d5947; color: #6d5947;
@@ -1542,12 +1529,12 @@
} }
.hero-top { .hero-top {
flex-direction: column; align-items: flex-start;
align-items: stretch; margin-bottom: 22px;
} }
.hero-tools { .hero-tools {
justify-content: flex-start; display: none;
} }
.hero-copy h1 { .hero-copy h1 {
@@ -1565,13 +1552,7 @@
font-size: 0.92rem; font-size: 0.92rem;
} }
.board-head {
justify-content: center;
}
.board-tools { .board-tools {
justify-content: center;
grid-auto-flow: column;
width: 100%; width: 100%;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px; gap: 6px;
@@ -1646,15 +1627,18 @@
.event-stamp-mobile { .event-stamp-mobile {
display: inline-flex; display: inline-flex;
align-self: flex-start; align-items: center;
margin-bottom: 8px; min-height: 24px;
padding: 5px 8px; margin-bottom: 0;
padding: 0;
border-radius: 999px; border-radius: 999px;
background: rgba(96, 74, 53, 0.08); background: none;
color: #7b6652; color: #8f4c24;
font-size: 0.74rem; font-size: 0.76rem;
font-weight: 700;
line-height: 1;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.12em; letter-spacing: 0.1em;
} }
.event-card { .event-card {
@@ -1663,20 +1647,54 @@
box-shadow: none; box-shadow: none;
border-radius: 0; border-radius: 0;
padding: 14px 14px 16px; padding: 14px 14px 16px;
width: 100%;
box-sizing: border-box;
} }
.event-card::before { .event-card::before {
display: none; display: none;
} }
.events-stack .event-row:first-child { .event-top {
display: flex; 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 { .event-header-line {
height: 184px; display: grid;
min-height: 184px; 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 { .event-name {
@@ -1685,7 +1703,7 @@
} }
.events-stack .event-row:first-child .event-name { .events-stack .event-row:first-child .event-name {
font-size: 1.14rem; font-size: 1.02rem;
} }
.event-description { .event-description {
@@ -1696,6 +1714,16 @@
font-size: 0.72rem; 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 { .stay-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 8px; gap: 8px;

View File

@@ -38,9 +38,7 @@
: null : null
); );
const completedCount = $derived(past.length); const completedCount = $derived(past.length);
const activeCount = $derived(upcoming.filter((trip) => trip.status === 'active').length);
const openTripCount = $derived(upcoming.length); const openTripCount = $derived(upcoming.length);
function formatPoints(n: number): string { function formatPoints(n: number): string {
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'; if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
if (n >= 1000) return Math.round(n / 1000) + 'K'; if (n >= 1000) return Math.round(n / 1000) + 'K';
@@ -148,47 +146,30 @@
<div class="trips-page"> <div class="trips-page">
<section class="journey-hero reveal"> <section class="journey-hero reveal">
<div class="hero-copy"> <div class="hero-copy">
<div class="panel-kicker">Travel desk</div>
<h1>Trips</h1> <h1>Trips</h1>
<p>Keep the active run, the next departure, and the archive in one calmer travel surface.</p>
</div> </div>
<div class="hero-actions"> <div class="hero-actions">
<button class="hero-button primary" onclick={() => (createOpen = true)}>Plan trip</button> <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> </div>
</section> </section>
<section class="signal-strip reveal"> <section class="stats-grid reveal">
<div class="signal-line"> <div class="stat-tile">
<div> <div class="stat-value">{loading ? '…' : stats.trips}</div>
<div class="signal-label">Open itineraries</div> <div class="stat-label">Trips</div>
<div class="signal-note">{activeCount} active · {Math.max(0, upcoming.length - activeCount)} upcoming</div>
</div> </div>
<div class="signal-value">{loading ? '…' : upcoming.length}</div> <div class="stat-tile">
<div class="stat-value">{loading ? '…' : stats.cities}</div>
<div class="stat-label">Cities</div>
</div> </div>
<div class="signal-line"> <div class="stat-tile">
<div> <div class="stat-value">{loading ? '…' : stats.countries}</div>
<div class="signal-label">Coverage</div> <div class="stat-label">Countries</div>
<div class="signal-note">{stats.cities} cities across {stats.countries} countries</div>
</div> </div>
<div class="signal-value">{loading ? '…' : stats.countries}</div> <div class="stat-tile">
</div> <div class="stat-value">{loading ? '…' : formatPoints(stats.points)}</div>
<div class="signal-line"> <div class="stat-label">Points used</div>
<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>
<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> </div>
</section> </section>
@@ -202,17 +183,6 @@
</button> </button>
{/if} {/if}
</div> </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> </section>
<div class="trips-grid"> <div class="trips-grid">
@@ -248,12 +218,8 @@
{:else} {:else}
<div class="section-head"> <div class="section-head">
<div> <div>
<div class="section-label">Active board</div> <h2>Upcoming</h2>
<h2>Current and upcoming</h2>
</div> </div>
{#if nextTrip}
<a class="inline-link" href={`/trips/trip?id=${nextTrip.id}`}>Open next trip</a>
{/if}
</div> </div>
<div class="journey-list"> <div class="journey-list">
@@ -284,8 +250,7 @@
<div class="section-head archive"> <div class="section-head archive">
<div> <div>
<div class="section-label">Archive</div> <h2>Past trips</h2>
<h2>Past routes</h2>
</div> </div>
</div> </div>
@@ -311,23 +276,6 @@
{/if} {/if}
</section> </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>
</div> </div>
@@ -341,10 +289,7 @@
} }
.journey-hero, .journey-hero,
.signal-strip, .search-band {
.search-band,
.journey-column,
.travel-rail {
background: rgba(250, 244, 236, 0.72); background: rgba(250, 244, 236, 0.72);
border: 1px solid rgba(35, 26, 17, 0.08); border: 1px solid rgba(35, 26, 17, 0.08);
border-radius: 28px; border-radius: 28px;
@@ -364,11 +309,10 @@
.hero-copy { .hero-copy {
display: grid; display: grid;
gap: 8px; gap: 0;
max-width: 36rem; max-width: 42rem;
} }
.panel-kicker,
.section-label { .section-label {
font-size: 11px; font-size: 11px;
text-transform: uppercase; text-transform: uppercase;
@@ -377,8 +321,7 @@
} }
.journey-hero h1, .journey-hero h1,
.section-head h2, .section-head h2 {
.travel-rail h3 {
margin: 0; margin: 0;
letter-spacing: -0.055em; letter-spacing: -0.055em;
color: #1c140c; color: #1c140c;
@@ -389,14 +332,6 @@
line-height: 0.92; line-height: 0.92;
} }
.journey-hero p,
.rail-copy,
.standout p {
margin: 0;
color: #5f5347;
line-height: 1.55;
}
.hero-actions { .hero-actions {
display: flex; display: flex;
gap: 10px; gap: 10px;
@@ -425,53 +360,38 @@
color: #fff7ee; color: #fff7ee;
} }
.hero-button.ghost { .stats-grid {
background: rgba(255, 251, 246, 0.82);
color: #4f4338;
}
.signal-strip {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
overflow: hidden; gap: 10px;
} }
.signal-line { .stat-tile {
display: flex; display: grid;
justify-content: space-between; gap: 4px;
gap: 14px; padding: 14px 12px;
padding: 18px 20px; border-radius: 20px;
border-right: 1px solid rgba(35, 26, 17, 0.08); background: rgba(255, 251, 246, 0.7);
border: 1px solid rgba(35, 26, 17, 0.07);
text-align: center;
} }
.signal-line:last-child { .stat-value {
border-right: none; font-size: 1.3rem;
}
.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;
font-weight: 700; font-weight: 700;
color: #1b140d; line-height: 1;
letter-spacing: -0.04em; letter-spacing: -0.04em;
color: #1f1811;
}
.stat-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.12em;
color: #7c6b5c;
} }
.search-band { .search-band {
display: flex;
align-items: center;
gap: 16px;
padding: 14px 18px; padding: 14px 18px;
} }
@@ -516,29 +436,12 @@
height: 16px; height: 16px;
} }
.search-note {
flex-shrink: 0;
font-size: 0.88rem;
color: #65584b;
}
.trips-grid { .trips-grid {
display: grid; display: block;
grid-template-columns: minmax(0, 1.4fr) 320px;
gap: 18px;
align-items: start;
} }
.journey-column { .journey-column {
padding: 18px; padding: 0;
}
.travel-rail {
padding: 18px;
display: grid;
gap: 14px;
position: sticky;
top: 28px;
} }
.section-head { .section-head {
@@ -558,14 +461,6 @@
margin-top: 22px; 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 { .journey-list {
display: grid; display: grid;
gap: 12px; gap: 12px;
@@ -674,10 +569,7 @@
.journey-notes, .journey-notes,
.journey-open, .journey-open,
.journey-empty, .journey-empty {
.rail-meta,
.rail-stat span,
.coverage-row span {
color: #605447; color: #605447;
} }
@@ -693,76 +585,8 @@
border: 1px dashed rgba(35, 26, 17, 0.11); 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) { @media (max-width: 1100px) {
.signal-strip { .trips-grid { display: block; }
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));
}
} }
@media (max-width: 768px) { @media (max-width: 768px) {
@@ -772,52 +596,58 @@
} }
.journey-hero, .journey-hero,
.signal-strip, .search-band {
.search-band,
.journey-column,
.travel-rail {
border-radius: 24px; border-radius: 24px;
} }
.journey-hero { .journey-hero {
padding: 18px 16px; padding: 16px;
grid-template-columns: 1fr;
align-items: start; 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, .hero-actions,
.search-band, .search-band {
.signal-strip,
.travel-rail {
display: grid; display: grid;
} }
.hero-actions { .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 { .search-band {
padding: 14px 16px; 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, .journey-row,
@@ -841,9 +671,6 @@
gap: 10px; gap: 10px;
} }
.journey-column, .journey-column { padding: 0; }
.travel-rail {
padding: 16px;
}
} }
</style> </style>

View File

@@ -1,763 +1,12 @@
<script lang="ts"> <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 { data } = $props();
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> </script>
<div class="budget-page"> {#if data?.useAtelierShell}
<div class="budget-layout"> <AtelierBudgetPage />
<!-- Desktop sidebar --> {:else}
<aside class="budget-sidebar desktop-only"> <LegacyBudgetPage />
<div class="sidebar-header">Budget</div> {/if}
<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

@@ -22,6 +22,7 @@ SHELFMARK_URL = os.environ.get("SHELFMARK_URL", "http://shelfmark:8084")
SPOTIZERR_URL = os.environ.get("SPOTIZERR_URL", "http://spotizerr-app:7171") SPOTIZERR_URL = os.environ.get("SPOTIZERR_URL", "http://spotizerr-app:7171")
BUDGET_URL = os.environ.get("BUDGET_BACKEND_URL", "http://localhost:3001") BUDGET_URL = os.environ.get("BUDGET_BACKEND_URL", "http://localhost:3001")
TASKS_URL = os.environ.get("TASKS_BACKEND_URL", "http://tasks-service:8098") TASKS_URL = os.environ.get("TASKS_BACKEND_URL", "http://tasks-service:8098")
BRAIN_URL = os.environ.get("BRAIN_BACKEND_URL", "http://brain-api:8200")
# ── Service API keys (for internal service auth) ── # ── Service API keys (for internal service auth) ──
INVENTORY_SERVICE_API_KEY = os.environ.get("INVENTORY_SERVICE_API_KEY", "") INVENTORY_SERVICE_API_KEY = os.environ.get("INVENTORY_SERVICE_API_KEY", "")

View File

@@ -188,7 +188,7 @@ def handle_dashboard(handler, user):
conn.close() conn.close()
# Services that use gateway-injected API keys (not per-user tokens) # Services that use gateway-injected API keys (not per-user tokens)
GATEWAY_KEY_SERVICES = {"inventory", "reader", "books", "music", "budget", "tasks"} GATEWAY_KEY_SERVICES = {"inventory", "reader", "books", "music", "budget", "tasks", "brain"}
widgets = [] widgets = []
futures = {} futures = {}

View File

@@ -8,7 +8,7 @@ import bcrypt
from config import ( from config import (
DB_PATH, TRIPS_URL, FITNESS_URL, INVENTORY_URL, DB_PATH, TRIPS_URL, FITNESS_URL, INVENTORY_URL,
MINIFLUX_URL, SHELFMARK_URL, SPOTIZERR_URL, BUDGET_URL, TASKS_URL, MINIFLUX_URL, SHELFMARK_URL, SPOTIZERR_URL, BUDGET_URL, TASKS_URL, BRAIN_URL,
) )
@@ -129,6 +129,13 @@ def init_db():
conn.commit() conn.commit()
print("[Gateway] Added tasks app") print("[Gateway] Added tasks app")
# Ensure brain app exists
brain = c.execute("SELECT id FROM apps WHERE id = 'brain'").fetchone()
if not brain:
c.execute("INSERT INTO apps VALUES ('brain', 'Brain', 'brain', '/brain', ?, 9, 1, NULL)", (BRAIN_URL,))
conn.commit()
print("[Gateway] Added brain app")
# Seed admin user from env vars if no users exist # Seed admin user from env vars if no users exist
import os import os
user_count = c.execute("SELECT COUNT(*) FROM users").fetchone()[0] user_count = c.execute("SELECT COUNT(*) FROM users").fetchone()[0]

View File

@@ -303,6 +303,11 @@ class GatewayHandler(ResponseMixin, BaseHTTPRequestHandler):
if user: if user:
headers["X-Gateway-User-Id"] = str(user["id"]) headers["X-Gateway-User-Id"] = str(user["id"])
headers["X-Gateway-User-Name"] = user.get("display_name", user.get("username", "")) headers["X-Gateway-User-Name"] = user.get("display_name", user.get("username", ""))
elif service_id == "brain":
# Inject user identity for the brain service
if user:
headers["X-Gateway-User-Id"] = str(user["id"])
headers["X-Gateway-User-Name"] = user.get("display_name", user.get("username", ""))
elif user: elif user:
svc_token = get_service_token(user["id"], service_id) svc_token = get_service_token(user["id"], service_id)
if svc_token: if svc_token:

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,182 @@
<!DOCTYPE html><html lang="en" data-color-scheme="dark"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Noted</title>
<script>
window.Aspect = { version: '1.7.2' };
</script>
<script>
// Set color scheme from local storage.
(function() {
const colorScheme = localStorage.getItem('color-scheme');
if (colorScheme) {
document.documentElement.setAttribute('data-color-scheme', colorScheme);
}
})();
</script>
<link rel="preload" as="style" href="/assets/built/index.css?v=886c8949a9">
<link rel="preload" href="/assets/vendors/Geist-Variable.woff2?v=886c8949a9" as="font" type="font/woff2" crossorigin="">
<style>
@font-face {
font-family: 'Geist';
font-display: swap;
src: url(/assets/vendors/Geist-Variable.woff2?v=886c8949a9) format("woff2-variations");
font-weight: 100 900;
}
:root {
--color-dark-accent: #3f35e8;
--color-accent-foreground: #3f35e8;
--color-dark-accent-foreground: #ffffff;
}
</style>
<link rel="stylesheet" type="text/css" href="/assets/built/index.css?v=886c8949a9">
<script src="/assets/vendors/ivent.min.js?v=886c8949a9"></script>
<link rel="stylesheet" type="text/css" href="/assets/custom.css?v=886c8949a9">
<meta name="generator" content="Ghost 6.19">
<link rel="alternate" type="application/rss+xml" title="Noted" href="https://noted.lol/rss/">
<script defer="" src="https://cdn.jsdelivr.net/ghost/portal@~2.64/umd/portal.min.js" data-i18n="true" data-ghost="https://noted.lol/" data-key="f13d2fe72ef69fe8db444cc0e7" data-api="https://noted.lol/ghost/api/content/" data-locale="en" crossorigin="anonymous"></script><style id="gh-members-styles">.gh-post-upgrade-cta-content,
.gh-post-upgrade-cta {
display: flex;
flex-direction: column;
align-items: center;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
text-align: center;
width: 100%;
color: #ffffff;
font-size: 16px;
}
.gh-post-upgrade-cta-content {
border-radius: 8px;
padding: 40px 4vw;
}
.gh-post-upgrade-cta h2 {
color: #ffffff;
font-size: 28px;
letter-spacing: -0.2px;
margin: 0;
padding: 0;
}
.gh-post-upgrade-cta p {
margin: 20px 0 0;
padding: 0;
}
.gh-post-upgrade-cta small {
font-size: 16px;
letter-spacing: -0.2px;
}
.gh-post-upgrade-cta a {
color: #ffffff;
cursor: pointer;
font-weight: 500;
box-shadow: none;
text-decoration: underline;
}
.gh-post-upgrade-cta a:hover {
color: #ffffff;
opacity: 0.8;
box-shadow: none;
text-decoration: underline;
}
.gh-post-upgrade-cta a.gh-btn {
display: block;
background: #ffffff;
text-decoration: none;
margin: 28px 0 0;
padding: 8px 18px;
border-radius: 4px;
font-size: 16px;
font-weight: 600;
}
.gh-post-upgrade-cta a.gh-btn:hover {
opacity: 0.92;
}</style><script async="" src="https://js.stripe.com/v3/"></script>
<script defer="" src="https://cdn.jsdelivr.net/ghost/sodo-search@~1.8/umd/sodo-search.min.js" data-key="f13d2fe72ef69fe8db444cc0e7" data-styles="https://cdn.jsdelivr.net/ghost/sodo-search@~1.8/umd/main.css" data-sodo-search="https://noted.lol/" data-locale="en" crossorigin="anonymous"></script>
<script defer="" src="https://cdn.jsdelivr.net/ghost/announcement-bar@~1.1/umd/announcement-bar.min.js" data-announcement-bar="https://noted.lol/" data-api-url="https://noted.lol/members/api/announcement/" crossorigin="anonymous"></script>
<link href="https://noted.lol/webmentions/receive/" rel="webmention">
<script defer="" src="/public/cards.min.js?v=886c8949a9"></script>
<link rel="stylesheet" type="text/css" href="/public/cards.min.css?v=886c8949a9">
<script defer="" src="/public/comment-counts.min.js?v=886c8949a9" data-ghost-comments-counts-api="https://noted.lol/members/api/comments/counts/"></script>
<script defer="" src="/public/member-attribution.min.js?v=886c8949a9"></script><style>:root {--ghost-accent-color: #3d74e3;}</style>
<script defer="" src="https://analytics.noted.lol/script.js" data-website-id="ecf11630-aae4-4490-8f25-47c97fcb20b8"></script>
<script>
window.pvs?.addNavIcon?.(
'Sponsors',
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#e10000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-hand-heart"><path d="M11 14h2a2 2 0 1 0 0-4h-3c-.6 0-1.1.2-1.4.6L3 16"/><path d="m7 20 1.6-1.4c.3-.4.8-.6 1.4-.6h4c1.1 0 2.1-.4 2.8-1.2l4.6-4.4a2 2 0 0 0-2.75-2.91l-4.2 3.9"/><path d="m2 15 6 6"/><path d="M19.5 8.5c.7-.7 1.5-1.6 1.5-2.7A2.73 2.73 0 0 0 16 4a2.78 2.78 0 0 0-5 1.8c0 1.2.8 2 1.5 2.8L16 12Z"/></svg>'
);
window.pvs?.addNavIcon?.(
'About',
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-book-open-text"><path d="M12 7v14"/><path d="M16 12h2"/><path d="M16 8h2"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/><path d="M6 12h2"/><path d="M6 8h2"/></svg>'
);
window.pvs?.addNavIcon?.(
'Contribute',
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#61ba5c" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-pencil-line"><path d="M12 20h9"/><path d="M16.376 3.622a1 1 0 0 1 3.002 3.002L7.368 18.635a2 2 0 0 1-.855.506l-2.872.838a.5.5 0 0 1-.62-.62l.838-2.872a2 2 0 0 1 .506-.854z"/><path d="m15 5 3 3"/></svg>'
);
window.pvs?.addNavIcon?.(
'RSS',
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ee8511" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-rss"><path d="M4 11a9 9 0 0 1 9 9"/><path d="M4 4a16 16 0 0 1 16 16"/><circle cx="5" cy="19" r="1"/></svg>'
);
window.pvs?.addNavIcon?.(
'Discord',
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#8e36c9" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-bot-message-square"><path d="M12 6V2H8"/><path d="m8 18-4 4V8a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2Z"/><path d="M2 12h2"/><path d="M9 11v2"/><path d="M15 11v2"/><path d="M20 12h2"/></svg>'
);
window.pvs?.addNavIcon?.(
'Get Started',
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#3ca7db" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-list-start"><path d="M16 12H3"/><path d="M16 18H3"/><path d="M10 6H3"/><path d="M21 18V8a2 2 0 0 0-2-2h-5"/><path d="m16 8-2-2 2-2"/></svg>'
);
</script>
<style>.App{text-align:center}.App-logo{height:40vmin;pointer-events:none}@media (prefers-reduced-motion: no-preference){.App-logo{animation:App-logo-spin infinite 20s linear}}.App-header{background-color:#282c34;min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;font-size:calc(10px + 2vmin);color:#fff}.App-link{color:#61dafb}@keyframes App-logo-spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}
/*$vite$:1*/</style><style>.gh-announcement-bar,.gh-announcement-bar *{box-sizing:border-box!important}.gh-announcement-bar{position:relative;z-index:90;display:flex;align-items:center;justify-content:center;padding:12px 48px;min-height:48px;font-size:15px;line-height:23px;text-align:center}.gh-announcement-bar.light{background-color:#f0f0f0;color:#15171a}.gh-announcement-bar.accent{background-color:var(--ghost-accent-color);color:#fff}.gh-announcement-bar.dark{background-color:#15171a;color:#fff}.gh-announcement-bar *:not(path){all:unset}.gh-announcement-bar strong{font-weight:700}.gh-announcement-bar :is(i,em){font-style:italic}.gh-announcement-bar a{color:#fff;font-weight:700;text-decoration:underline;cursor:pointer}.gh-announcement-bar.light a{color:var(--ghost-accent-color)!important}.gh-announcement-bar button{position:absolute;top:50%;right:8px;display:flex;align-items:center;justify-content:center;margin-top:-16px;width:32px;height:32px;padding:0;background-color:transparent;border:0;color:#fff;cursor:pointer}.gh-announcement-bar.light button{color:#888}.gh-announcement-bar svg{width:10px;height:10px;fill:currentColor}
/*$vite$:1*/</style></head>
<body class="layout-error" style="--announcement-bar--height: 48px;"><div id="announcement-bar-root"><div class="gh-announcement-bar dark"><div class="gh-announcement-bar-content"><p dir="ltr"><span>Run self-hosted projects with a </span><a href="https://cloud.hosthatch.com/a/4687" rel="noreferrer"><span>HostHatch.com</span></a><span> VPS — starting at $4/month!</span></p></div><button aria-label="close"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="16" width="16"><path stroke-linecap="round" stroke-width="0.4" fill="currentColor" stroke="#000000" stroke-linejoin="round" d="M.44,21.44a1.49,1.49,0,0,0,0,2.12,1.5,1.5,0,0,0,2.12,0l9.26-9.26a.25.25,0,0,1,.36,0l9.26,9.26a1.5,1.5,0,0,0,2.12,0,1.49,1.49,0,0,0,0-2.12L14.3,12.18a.25.25,0,0,1,0-.36l9.26-9.26A1.5,1.5,0,0,0,21.44.44L12.18,9.7a.25.25,0,0,1-.36,0L2.56.44A1.5,1.5,0,0,0,.44,2.56L9.7,11.82a.25.25,0,0,1,0,.36Z"></path></svg></button></div></div>
<main class="main">
<article id="content" class="content">
<h1>404</h1>
<h2>Page not found</h2>
<a href="https://noted.lol" class="button">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.8333 10.0001H3.16667M3.16667 10.0001L7 6.16675M3.16667 10.0001L7 13.8334" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
<span class="label">Back to Home</span>
</a>
</article>
</main>
<script src="/assets/built/announcement-bar.js?v=886c8949a9"></script>
<script src="/assets/custom.js?v=886c8949a9"></script>
<script>
window.pvs?.addExternalLinkAttributes?.();
</script>
<script defer="" src="https://static.cloudflareinsights.com/beacon.min.js/v8c78df7c7c0f484497ecbca7046644da1771523124516" integrity="sha512-8DS7rgIrAmghBFwoOTujcf6D9rXvH8xm8JQ1Ja01h9QX8EzXldiszufYa4IFfKdLUKTTrnSFXLDkUEOTrZQ8Qg==" data-cf-beacon="{&quot;version&quot;:&quot;2024.11.0&quot;,&quot;token&quot;:&quot;39664123322044ffb2c160ffaced7653&quot;,&quot;r&quot;:1,&quot;server_timing&quot;:{&quot;name&quot;:{&quot;cfCacheStatus&quot;:true,&quot;cfEdge&quot;:true,&quot;cfExtPri&quot;:true,&quot;cfL4&quot;:true,&quot;cfOrigin&quot;:true,&quot;cfSpeedBrain&quot;:true},&quot;location_startswith&quot;:null}}" crossorigin="anonymous"></script>
<div id="ghost-portal-root" data-testid="portal-root"><iframe srcdoc="&lt;!DOCTYPE html&gt;" data-testid="portal-trigger-frame" title="portal-trigger" frameborder="0" dir="ltr" class="gh-portal-triggerbtn-iframe" style="z-index: 3999998; position: fixed; bottom: 0px; right: 0px; width: 500px; max-width: 500px; height: 98px; animation: 250ms ease 0s 1 normal none running animation-bhegco; transition: opacity 0.3s; overflow: hidden;"></iframe></div><div id="sodo-search-root"></div><iframe name="__privateStripeMetricsController7270" frameborder="0" allowtransparency="true" scrolling="no" role="presentation" allow="payment *" src="https://js.stripe.com/v3/m-outer-3437aaddcdf6922d623e172c2d6f9278.html#url=https%3A%2F%2Fnoted.lol%2Fwhat-are-the-mass-alternatives-to-google-photos%2F&amp;title=Noted&amp;referrer=&amp;muid=NA&amp;sid=NA&amp;version=6&amp;preview=false&amp;__shared_params__[version]=v3" aria-hidden="true" tabindex="-1" style="border-width: medium !important; border-style: none !important; border-color: currentcolor !important; border-image: initial !important; margin: 0px !important; padding: 0px !important; width: 1px !important; min-width: 100% !important; overflow: hidden !important; display: block !important; visibility: hidden !important; position: fixed !important; height: 1px !important; pointer-events: none !important; user-select: none !important;"></iframe></body></html>