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>
BIN
.ccgram-uploads/photo_20260401_203433_AQADXxBr.jpg
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
.ccgram-uploads/photo_20260401_203646_AQADYBBr.jpg
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
.ccgram-uploads/photo_20260401_204348_AQADYRBr.jpg
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
.ccgram-uploads/photo_20260401_204552_AQADYhBr.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
.ccgram-uploads/photo_20260401_210956_AQADYxBr.jpg
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
.ccgram-uploads/photo_20260401_211251_AQADZBBr.jpg
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
.ccgram-uploads/photo_20260401_211840_AQADZRBr.jpg
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
.ccgram-uploads/photo_20260401_212112_AQADZhBr.jpg
Normal file
|
After Width: | Height: | Size: 110 KiB |
@@ -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}
|
||||||
|
|||||||
271
frontend-v2/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
590
frontend-v2/src/lib/components/trips/PdfInlinePreview.svelte
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
1220
frontend-v2/src/lib/pages/budget/AtelierBudgetPage.svelte
Normal file
763
frontend-v2/src/lib/pages/budget/LegacyBudgetPage.svelte
Normal 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>
|
||||||
@@ -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); }
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 -->
|
|
||||||
<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}
|
{:else}
|
||||||
<div class="budget-label">Budget</div>
|
<LegacyBudgetPage />
|
||||||
<div class="budget-title">{currentMonthLabel} · <strong>{headerSpending}</strong> spent</div>
|
|
||||||
<div class="budget-meta">{headerIncome} income · {uncatCount} uncategorized</div>
|
|
||||||
{/if}
|
{/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>
|
|
||||||
|
|||||||
@@ -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", "")
|
||||||
|
|||||||
@@ -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 = {}
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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="{"version":"2024.11.0","token":"39664123322044ffb2c160ffaced7653","r":1,"server_timing":{"name":{"cfCacheStatus":true,"cfEdge":true,"cfExtPri":true,"cfL4":true,"cfOrigin":true,"cfSpeedBrain":true},"location_startswith":null}}" crossorigin="anonymous"></script>
|
||||||
|
|
||||||
|
<div id="ghost-portal-root" data-testid="portal-root"><iframe srcdoc="<!DOCTYPE html>" 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&title=Noted&referrer=&muid=NA&sid=NA&version=6&preview=false&__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>
|
||||||