feat(shell,api,keys): firefly v1 shell over the api with cookie auth, period, palette and keymap

This commit is contained in:
hh committed 2026-10-16 02:21:45 +00:00
1 parent ae4de7911b
commit c74dac3e00
37 files changed
+58147 -63

No files matched your search

+1
View File
@@ -2,3 +2,4 @@ COMPOSE_PROFILES=services
PUBLIC_API_BASE_URL=/api PUBLIC_API_BASE_URL=/api
ALLOWED_HOSTS= ALLOWED_HOSTS=
FIREFLY_URL=http://127.0.0.1:8080
+2 -2
View File
@@ -3,11 +3,11 @@ COMPOSE := docker compose
.PHONY: env recreate rebuild restart logs down fmt check build dev .PHONY: env recreate rebuild restart logs down fmt check build dev
fmt: fmt:
bun run fix bun --bun run fix
check: check:
bun run check bun run check
bun run lint bun --bun run lint
build: build:
$(COMPOSE) build frontend $(COMPOSE) build frontend
File diff suppressed because it is too large. Load diff
+7 -1
View File
@@ -9,7 +9,9 @@
"!.mcp.json", "!.mcp.json",
"!.impeccable", "!.impeccable",
"!src/lib/components/ui", "!src/lib/components/ui",
"!src/routes/claddcn.css" "!src/routes/claddcn.css",
"!src/lib/api/schema.d.ts",
"!api"
] ]
}, },
"javascript": { "globals": ["Bun"] }, "javascript": { "globals": ["Bun"] },
@@ -28,6 +30,10 @@
"linter": { "linter": {
"rules": { "rules": {
"a11y": { "useAnchorContent": "off" }, "a11y": { "useAnchorContent": "off" },
"suspicious": {
"noReturnAssign": "off",
"noAssignInExpressions": "off"
},
"style": { "style": {
"useFilenamingConvention": { "useFilenamingConvention": {
"level": "error", "level": "error",
+107
View File
@@ -14,10 +14,19 @@
"@sveltejs/kit": "^3.0.1", "@sveltejs/kit": "^3.0.1",
"@sveltejs/vite-plugin-svelte": "^7.1.2", "@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tailwindcss/vite": "^4.3.0", "@tailwindcss/vite": "^4.3.0",
"@tanstack/svelte-query": "^6.3.3",
"@types/bun": "^1.3.0", "@types/bun": "^1.3.0",
"@types/d3-array": "^3.2.2",
"@types/d3-scale": "^4.0.9",
"@types/d3-shape": "^3.2.0",
"bits-ui": "^2.16.3", "bits-ui": "^2.16.3",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"d3-array": "^3.2.4",
"d3-scale": "^4.0.2",
"d3-shape": "^3.2.0",
"mode-watcher": "^1.1.0", "mode-watcher": "^1.1.0",
"openapi-fetch": "^0.17.0",
"openapi-typescript": "^7.13.0",
"shadcn-svelte": "^1.3.0", "shadcn-svelte": "^1.3.0",
"svelte": "^5.57.1", "svelte": "^5.57.1",
"svelte-check": "^4.7.6", "svelte-check": "^4.7.6",
@@ -33,6 +42,8 @@
}, },
}, },
"packages": { "packages": {
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
@@ -97,6 +108,12 @@
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
"@redocly/ajv": ["@redocly/ajv@8.11.2", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js-replace": "^1.0.1" } }, "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg=="],
"@redocly/config": ["@redocly/config@0.22.0", "", {}, "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ=="],
"@redocly/openapi-core": ["@redocly/openapi-core@1.34.21", "", { "dependencies": { "@redocly/ajv": "8.11.2", "@redocly/config": "0.22.0", "colorette": "1.4.0", "https-proxy-agent": "7.0.6", "js-levenshtein": "1.1.6", "js-yaml": "4.3.2", "minimatch": "5.1.9", "pluralize": "8.0.0", "yaml-ast-parser": "0.0.43" } }, "sha512-yXMiBFYJbP5JOaxPzx2tJhK+v8h2Wu2uhad1Ro6GzVT8E3F4GcQ7sbLPJ9w4zvGjfxroD0bIh5B0pDtnLV/qUA=="],
"@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.14", "", { "os": "android", "cpu": "arm" }, "sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg=="], "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.14", "", { "os": "android", "cpu": "arm" }, "sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.14", "", { "os": "android", "cpu": "arm64" }, "sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ=="], "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.14", "", { "os": "android", "cpu": "arm64" }, "sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ=="],
@@ -177,30 +194,56 @@
"@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="], "@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="],
"@tanstack/query-core": ["@tanstack/query-core@5.104.3", "", {}, "sha512-DZY9TuJAOgU3UyHUhXb4CcTYEHNZszlwkPg4VViIwTxOKzLprPUU+rbaPZQd3srqkGOtpaDdKpW972UV/LKlcg=="],
"@tanstack/svelte-query": ["@tanstack/svelte-query@6.3.3", "", { "dependencies": { "@tanstack/query-core": "5.104.3" }, "peerDependencies": { "svelte": "^5.25.0" } }, "sha512-yPCaSm53NGgFM8gLgJUPZNrTMGNqjcJ8eguh0zFrqqmJDNr6lxhgqN2szmsrgViJ64DC9NYnOminmLlw8IxgXw=="],
"@types/bun": ["@types/bun@1.4.3", "", { "dependencies": { "bun-types": "1.4.3" } }, "sha512-N67Pqy8JXbAY/j0tNraS3V5mubGvc6rrrNDmDCtZLlFzccbUJPDb0tvgUBC5q4m8ybkP93Jcn1QB+RYQ6PvBhw=="], "@types/bun": ["@types/bun@1.4.3", "", { "dependencies": { "bun-types": "1.4.3" } }, "sha512-N67Pqy8JXbAY/j0tNraS3V5mubGvc6rrrNDmDCtZLlFzccbUJPDb0tvgUBC5q4m8ybkP93Jcn1QB+RYQ6PvBhw=="],
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
"@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
"@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
"@types/d3-shape": ["@types/d3-shape@3.2.0", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw=="],
"@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/node": ["@types/node@26.6.5", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg=="], "@types/node": ["@types/node@26.6.5", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg=="],
"acorn": ["acorn@8.19.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw=="], "acorn": ["acorn@8.19.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw=="],
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="],
"ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="],
"ansi-regex": ["ansi-regex@6.4.0", "", {}, "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw=="], "ansi-regex": ["ansi-regex@6.4.0", "", {}, "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw=="],
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="], "aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="],
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"bits-ui": ["bits-ui@2.19.5", "", { "dependencies": { "@floating-ui/core": "^1.7.1", "@floating-ui/dom": "^1.7.1", "esm-env": "^1.1.2", "runed": "^0.35.1", "svelte-toolbelt": "^0.10.6", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-TEwkLSYElE5SE0fIdcItiFPfyg9vrR67N1Mt6VvZCqZo24dwTA1MpGVV3WeyIg7rfVAaZ0So8a0/dcnPngCoew=="], "bits-ui": ["bits-ui@2.19.5", "", { "dependencies": { "@floating-ui/core": "^1.7.1", "@floating-ui/dom": "^1.7.1", "esm-env": "^1.1.2", "runed": "^0.35.1", "svelte-toolbelt": "^0.10.6", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-TEwkLSYElE5SE0fIdcItiFPfyg9vrR67N1Mt6VvZCqZo24dwTA1MpGVV3WeyIg7rfVAaZ0So8a0/dcnPngCoew=="],
"brace-expansion": ["brace-expansion@2.1.7", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"bun-types": ["bun-types@1.4.3", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-tinRJNGOmUN+tEAsn2y2e942n9JHRHR42iUYzDH75NmhJqVBokGSQkkfSFdNWwsANIdrK6ACH9t01M452YyTwg=="], "bun-types": ["bun-types@1.4.3", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-tinRJNGOmUN+tEAsn2y2e942n9JHRHR42iUYzDH75NmhJqVBokGSQkkfSFdNWwsANIdrK6ACH9t01M452YyTwg=="],
"change-case": ["change-case@5.4.4", "", {}, "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w=="],
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="], "citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="],
@@ -213,12 +256,34 @@
"cn": ["cn@0.3.3", "", { "bin": { "cn": "bin/cn.mjs" } }, "sha512-OEiyJkSpYm2kOuNOKQ+c2wmzZRG8orrb9fnObtHTbCGNx/E9KLnGcmcocItOzREKk5KzQN5I2e3BUCj563KEpw=="], "cn": ["cn@0.3.3", "", { "bin": { "cn": "bin/cn.mjs" } }, "sha512-OEiyJkSpYm2kOuNOKQ+c2wmzZRG8orrb9fnObtHTbCGNx/E9KLnGcmcocItOzREKk5KzQN5I2e3BUCj563KEpw=="],
"colorette": ["colorette@1.4.0", "", {}, "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g=="],
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
"confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], "confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="],
"cookie": ["cookie@2.0.1", "", {}, "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w=="], "cookie": ["cookie@2.0.1", "", {}, "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w=="],
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
"d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="],
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
"d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
"d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
"d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
"d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
"d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
@@ -239,6 +304,8 @@
"execa": ["execa@10.1.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "figures": "^6.1.0", "get-stream": "^9.0.1", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.3.1", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "which-command": "^0.1.0", "yoctocolors": "^2.2.0" } }, "sha512-yJAC9ulsE6fU0K6wzJBIH+wI+KAtMeu6Ck8rs23OVzvEhqXjGADdI6K+YNIDAjcQQ6AwXTD96cKExJo6xFJXyg=="], "execa": ["execa@10.1.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "figures": "^6.1.0", "get-stream": "^9.0.1", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.3.1", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "which-command": "^0.1.0", "yoctocolors": "^2.2.0" } }, "sha512-yJAC9ulsE6fU0K6wzJBIH+wI+KAtMeu6Ck8rs23OVzvEhqXjGADdI6K+YNIDAjcQQ6AwXTD96cKExJo6xFJXyg=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
"fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="],
@@ -267,10 +334,16 @@
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], "human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="],
"index-to-position": ["index-to-position@1.2.0", "", {}, "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw=="],
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], "is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
@@ -289,6 +362,14 @@
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
"js-levenshtein": ["js-levenshtein@1.1.6", "", {}, "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@4.3.2", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="],
"lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
@@ -331,6 +412,8 @@
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
"minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="],
"mlly": ["mlly@1.8.2", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="], "mlly": ["mlly@1.8.2", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="],
"mode-watcher": ["mode-watcher@1.1.0", "", { "dependencies": { "runed": "^0.25.0", "svelte-toolbelt": "^0.7.1" }, "peerDependencies": { "svelte": "^5.27.0" } }, "sha512-mUT9RRGPDYenk59qJauN1rhsIMKBmWA3xMF+uRwE8MW/tjhaDSCCARqkSuDTq8vr4/2KcAxIGVjACxTjdk5C3g=="], "mode-watcher": ["mode-watcher@1.1.0", "", { "dependencies": { "runed": "^0.25.0", "svelte-toolbelt": "^0.7.1" }, "peerDependencies": { "svelte": "^5.27.0" } }, "sha512-mUT9RRGPDYenk59qJauN1rhsIMKBmWA3xMF+uRwE8MW/tjhaDSCCARqkSuDTq8vr4/2KcAxIGVjACxTjdk5C3g=="],
@@ -339,6 +422,8 @@
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="], "nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="],
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
@@ -351,6 +436,14 @@
"onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], "onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
"openapi-fetch": ["openapi-fetch@0.17.0", "", { "dependencies": { "openapi-typescript-helpers": "^0.1.0" } }, "sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig=="],
"openapi-typescript": ["openapi-typescript@7.13.0", "", { "dependencies": { "@redocly/openapi-core": "^1.34.6", "ansi-colors": "^4.1.3", "change-case": "^5.4.4", "parse-json": "^8.3.0", "supports-color": "^10.2.2", "yargs-parser": "^21.1.1" }, "peerDependencies": { "typescript": "^5.x" }, "bin": { "openapi-typescript": "bin/cli.js" } }, "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ=="],
"openapi-typescript-helpers": ["openapi-typescript-helpers@0.1.0", "", {}, "sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw=="],
"parse-json": ["parse-json@8.3.0", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "index-to-position": "^1.1.0", "type-fest": "^4.39.1" } }, "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ=="],
"parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="],
"path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], "path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
@@ -363,6 +456,8 @@
"pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], "pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
"pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="],
"postcss": ["postcss@8.5.30", "", { "dependencies": { "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA=="], "postcss": ["postcss@8.5.30", "", { "dependencies": { "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA=="],
"pretty-ms": ["pretty-ms@9.3.1", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA=="], "pretty-ms": ["pretty-ms@9.3.1", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA=="],
@@ -371,6 +466,8 @@
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"resolve.exports": ["resolve.exports@2.0.4", "", {}, "sha512-zepuGL0ulf+nt+XaFrlKeaiTJeyPFPLLY62+ncq1/VTHBdt3HsfXOH60priZ6tnXQozdGcDeIF5JVTanvIcYWQ=="], "resolve.exports": ["resolve.exports@2.0.4", "", {}, "sha512-zepuGL0ulf+nt+XaFrlKeaiTJeyPFPLLY62+ncq1/VTHBdt3HsfXOH60priZ6tnXQozdGcDeIF5JVTanvIcYWQ=="],
"restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
@@ -407,6 +504,8 @@
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
"supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
"svelte": ["svelte@5.57.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "@jridgewell/sourcemap-codec": "^1.6.0", "@sveltejs/acorn-typescript": "^1.0.13", "@types/estree": "^1.0.9", "acorn": "^8.18.0", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.9.2", "esm-env": "^1.2.1", "esrap": "^2.3.6", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-1YiPCAXT0Ct6g1vOMmSLa0Nsc3QRMvtDF32ihgglGVrugIuIK38RyDiFNXVDhtQ81TJjSO57NdMw6DyYLqSGDQ=="], "svelte": ["svelte@5.57.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "@jridgewell/sourcemap-codec": "^1.6.0", "@sveltejs/acorn-typescript": "^1.0.13", "@types/estree": "^1.0.9", "acorn": "^8.18.0", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.9.2", "esm-env": "^1.2.1", "esrap": "^2.3.6", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-1YiPCAXT0Ct6g1vOMmSLa0Nsc3QRMvtDF32ihgglGVrugIuIK38RyDiFNXVDhtQ81TJjSO57NdMw6DyYLqSGDQ=="],
"svelte-check": ["svelte-check@4.7.6", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "@sveltejs/load-config": "^0.2.3", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.0.0 || ^6.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw=="], "svelte-check": ["svelte-check@4.7.6", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "@sveltejs/load-config": "^0.2.3", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.0.0 || ^6.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw=="],
@@ -437,6 +536,8 @@
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
"ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="], "ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="],
@@ -447,6 +548,8 @@
"unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="],
"uri-js-replace": ["uri-js-replace@1.0.1", "", {}, "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g=="],
"vite": ["vite@8.3.4", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.29", "rolldown": "~1.2.12", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ=="], "vite": ["vite@8.3.4", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.29", "rolldown": "~1.2.12", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ=="],
"vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="],
@@ -457,6 +560,10 @@
"yaml": ["yaml@2.9.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw=="], "yaml": ["yaml@2.9.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw=="],
"yaml-ast-parser": ["yaml-ast-parser@0.0.43", "", {}, "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A=="],
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
"yoctocolors": ["yoctocolors@2.2.0", "", {}, "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg=="], "yoctocolors": ["yoctocolors@2.2.0", "", {}, "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg=="],
"zimmerframe": ["zimmerframe@1.1.5", "", {}, "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA=="], "zimmerframe": ["zimmerframe@1.1.5", "", {}, "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA=="],
+9
View File
@@ -27,10 +27,19 @@
"@sveltejs/kit": "^3.0.1", "@sveltejs/kit": "^3.0.1",
"@sveltejs/vite-plugin-svelte": "^7.1.2", "@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tailwindcss/vite": "^4.3.0", "@tailwindcss/vite": "^4.3.0",
"@tanstack/svelte-query": "^6.3.3",
"@types/bun": "^1.3.0", "@types/bun": "^1.3.0",
"@types/d3-array": "^3.2.2",
"@types/d3-scale": "^4.0.9",
"@types/d3-shape": "^3.2.0",
"bits-ui": "^2.16.3", "bits-ui": "^2.16.3",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"d3-array": "^3.2.4",
"d3-scale": "^4.0.2",
"d3-shape": "^3.2.0",
"mode-watcher": "^1.1.0", "mode-watcher": "^1.1.0",
"openapi-fetch": "^0.17.0",
"openapi-typescript": "^7.13.0",
"shadcn-svelte": "^1.3.0", "shadcn-svelte": "^1.3.0",
"svelte": "^5.57.1", "svelte": "^5.57.1",
"svelte-check": "^4.7.6", "svelte-check": "^4.7.6",
+202 -42
View File
@@ -1,56 +1,216 @@
import { PUBLIC_API_BASE_URL } from "$app/env/public"; import createClient, { type Middleware } from "openapi-fetch";
import type { components, paths } from "./schema.js";
const BASE = PUBLIC_API_BASE_URL; export type Schemas = components["schemas"];
const XSRF_COOKIE = /(?:^|;\s*)XSRF-TOKEN=([^;]*)/;
const RETURN_KEY = "ffui.returnTo";
const LOOP_GUARD_MS = 10_000;
const PING_EVERY_MS = 15 * 60_000;
const PING_STALE_MS = 5 * 60_000;
export type SessionState = "ok" | "unauthenticated" | "mfa";
export class ApiError extends Error { export class ApiError extends Error {
status: number; status: number;
errors: Record<string, string[]>;
constructor(status: number, message: string) { constructor(
status: number,
message: string,
errors: Record<string, string[]> = {}
) {
super(message); super(message);
this.status = status;
this.name = "ApiError"; this.name = "ApiError";
this.status = status;
this.errors = errors;
} }
} }
type Json = Record<string, unknown> | unknown[]; export function xsrfToken(): string {
const match = document.cookie.match(XSRF_COOKIE);
return match ? decodeURIComponent(match[1]) : "";
}
async function handle<T>(res: Response): Promise<T> { export function apiHeaders(json = true): Record<string, string> {
if (res.status === 204) { const headers: Record<string, string> = {
Accept: "application/json",
"X-Requested-With": "XMLHttpRequest",
"X-XSRF-TOKEN": xsrfToken(),
};
if (json) {
headers["Content-Type"] = "application/json";
}
return headers;
}
let lastPing = 0;
export async function ping(): Promise<SessionState> {
lastPing = Date.now();
const res = await fetch("/json/intro/ffui", {
credentials: "same-origin",
headers: apiHeaders(false),
});
if (res.status === 401 || res.redirected) {
return "unauthenticated";
}
const body = await res.json().catch(() => null);
return Array.isArray(body) ? "ok" : "mfa";
}
export function leaveTo(state: Exclude<SessionState, "ok">) {
const raw = sessionStorage.getItem(RETURN_KEY);
const previous = raw ? (JSON.parse(raw) as { at: number }) : null;
if (previous && Date.now() - previous.at < LOOP_GUARD_MS) {
throw new ApiError(401, "Session expired");
}
const path = location.pathname + location.search + location.hash;
sessionStorage.setItem(RETURN_KEY, JSON.stringify({ at: Date.now(), path }));
location.assign(state === "mfa" ? "/profile" : "/login");
}
export function takeReturnPath(): string | null {
const raw = sessionStorage.getItem(RETURN_KEY);
if (!raw) {
return null;
}
sessionStorage.removeItem(RETURN_KEY);
const { path } = JSON.parse(raw) as { path: string };
return path === location.pathname + location.search ? null : path;
}
export function keepAlive() {
const refresh = () => {
if (Date.now() - lastPing > PING_STALE_MS) {
ping().catch(() => undefined);
}
};
const timer = setInterval(() => ping().catch(() => undefined), PING_EVERY_MS);
document.addEventListener("visibilitychange", refresh);
window.addEventListener("focus", refresh);
window.addEventListener("online", refresh);
return () => {
clearInterval(timer);
document.removeEventListener("visibilitychange", refresh);
window.removeEventListener("focus", refresh);
window.removeEventListener("online", refresh);
};
}
export async function logout() {
await fetch("/logout", {
credentials: "same-origin",
headers: apiHeaders(false),
method: "POST",
});
location.assign("/login");
}
async function toError(res: Response): Promise<ApiError> {
const body = (await res
.clone()
.json()
.catch(() => null)) as {
message?: string;
errors?: Record<string, string[] | string>;
} | null;
const errors: Record<string, string[]> = {};
for (const [key, value] of Object.entries(body?.errors ?? {})) {
errors[key] = Array.isArray(value) ? value : [value];
}
return new ApiError(res.status, body?.message ?? res.statusText, errors);
}
function withHeaders(request: Request): Request {
const json = !(request.body instanceof Blob) && request.method !== "GET";
for (const [key, value] of Object.entries(apiHeaders(json))) {
if (!request.headers.has(key) || key === "X-XSRF-TOKEN") {
request.headers.set(key, value);
}
}
return request;
}
async function recover(request: Request, response: Response) {
if (response.status !== 401) {
return response;
}
const state = await ping();
if (state === "ok" && request.method === "GET") {
const retry = await fetch(withHeaders(request));
if (retry.status !== 401) {
return retry;
}
}
if (state !== "ok") {
leaveTo(state);
}
return response;
}
const auth: Middleware = {
onRequest: ({ request }) => withHeaders(request),
onResponse: ({ request, response }) => recover(request, response),
};
export const client = createClient<paths>({
baseUrl: "/api",
credentials: "same-origin",
});
client.use(auth);
interface Result<T> {
data?: T;
error?: unknown;
response: Response;
}
export async function unwrap<T>(promise: Promise<Result<T>>): Promise<T> {
const { data, response } = await promise;
if (!response.ok) {
throw await toError(response);
}
return data as T;
}
export type Query = Record<string, unknown>;
export function apiUrl(path: string, query: Query = {}): URL {
const url = new URL(`/api${path}`, location.origin);
for (const [key, value] of Object.entries(query)) {
for (const item of Array.isArray(value) ? value : [value]) {
if (item !== undefined && item !== null && item !== "") {
url.searchParams.append(key, String(item));
}
}
}
return url;
}
export async function api<T>(
path: string,
{
query,
body,
...init
}: Omit<RequestInit, "body"> & { query?: Query; body?: unknown } = {}
): Promise<T> {
const payload =
body === undefined || body instanceof Blob ? body : JSON.stringify(body);
const request = withHeaders(
new Request(apiUrl(path, query), {
credentials: "same-origin",
...init,
body: payload as BodyInit | undefined,
})
);
const response = await recover(request.clone(), await fetch(request));
if (!response.ok) {
throw await toError(response);
}
if (response.status === 204) {
return undefined as T; return undefined as T;
} }
if (res.ok) { return (await response.json()) as T;
return (await res.json()) as T;
}
let detail = res.statusText;
try {
const { detail: parsed } = (await res.json()) as { detail?: string };
if (parsed) {
detail = parsed;
}
} catch {
detail = res.statusText;
}
throw new ApiError(res.status, detail);
} }
async function request<T>(
method: string,
path: string,
body?: Json
): Promise<T> {
const init: RequestInit = { credentials: "include", method };
if (body !== undefined) {
init.headers = { "Content-Type": "application/json" };
init.body = JSON.stringify(body);
}
const res = await fetch(BASE + path, init);
return handle<T>(res);
}
export const api = {
del: <T>(path: string) => request<T>("DELETE", path),
get: <T>(path: string) => request<T>("GET", path),
patch: <T>(path: string, body?: Json) => request<T>("PATCH", path, body),
post: <T>(path: string, body?: Json) => request<T>("POST", path, body),
put: <T>(path: string, body?: Json) => request<T>("PUT", path, body),
};
+80
View File
@@ -0,0 +1,80 @@
import { QueryClient } from "@tanstack/svelte-query";
import { api, type Query } from "./client.js";
import type {
Account,
Bill,
Budget,
Category,
Item,
ObjectGroup,
Page,
PiggyBank,
Tag,
} from "./types.js";
const STALE_MS = 60_000;
const ALL_LIMIT = 1000;
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: 1,
staleTime: STALE_MS,
},
},
});
export async function all<T>(
path: string,
query: Query = {}
): Promise<Item<T>[]> {
const first = await api<Page<T>>(path, {
query: { ...query, limit: ALL_LIMIT, page: 1 },
});
const pages = first.meta.pagination?.total_pages ?? 1;
if (pages <= 1) {
return first.data;
}
const rest = await Promise.all(
Array.from({ length: pages - 1 }, (_, i) =>
api<Page<T>>(path, { query: { ...query, limit: ALL_LIMIT, page: i + 2 } })
)
);
return [...first.data, ...rest.flatMap((page) => page.data)];
}
export const lists = {
accounts: (type = "all") => ({
queryFn: () => all<Account>("/v1/accounts", { type }),
queryKey: ["accounts", type],
}),
bills: () => ({
queryFn: () => all<Bill>("/v1/bills"),
queryKey: ["bills"],
}),
budgets: () => ({
queryFn: () => all<Budget>("/v1/budgets"),
queryKey: ["budgets"],
}),
categories: () => ({
queryFn: () => all<Category>("/v1/categories"),
queryKey: ["categories"],
}),
objectGroups: () => ({
queryFn: () => all<ObjectGroup>("/v1/object-groups"),
queryKey: ["object-groups"],
}),
piggyBanks: () => ({
queryFn: () => all<PiggyBank>("/v1/piggy-banks"),
queryKey: ["piggy-banks"],
}),
tags: () => ({
queryFn: () => all<Tag>("/v1/tags"),
queryKey: ["tags"],
}),
};
export function invalidate() {
return queryClient.invalidateQueries();
}
+28401
View File
File diff suppressed because it is too large. Load diff
+65
View File
@@ -0,0 +1,65 @@
import type { components } from "./schema.js";
type S = components["schemas"];
export interface Item<T> {
attributes: T;
id: string;
type: string;
}
export interface Page<T> {
data: Item<T>[];
meta: {
pagination?: {
total: number;
count: number;
per_page: number;
current_page: number;
total_pages: number;
};
};
}
export interface One<T> {
data: Item<T>;
}
export type Account = S["AccountProperties"];
export type Attachment = S["AttachmentProperties"];
export type AvailableBudget = S["AvailableBudgetProperties"];
export type Bill = S["BillProperties"];
export type Budget = S["BudgetProperties"];
export type BudgetLimit = S["BudgetLimitProperties"];
export type Category = S["CategoryProperties"];
export type Currency = S["CurrencyProperties"];
export type ExchangeRate = S["CurrencyExchangeProperties"];
export type LinkType = S["LinkType"];
export type ObjectGroup = S["ObjectGroup"];
export type PiggyBank = S["PiggyBankProperties"];
export type PiggyBankEvent = S["PiggyBankEventProperties"];
export type Preference = S["Preference"];
export type Recurrence = S["RecurrenceProperties"];
export type Rule = S["Rule"];
export type RuleGroup = S["RuleGroup"];
export type Tag = S["TagModel"];
export type TransactionGroup = S["Transaction"];
export type Split = S["TransactionSplit"];
export type SplitStore = S["TransactionSplitStore"];
export type TransactionStore = S["TransactionStore"];
export type TransactionLink = S["TransactionLink"];
export type UserGroup = S["UserGroupReadAttributes"];
export type Webhook = S["WebhookProperties"];
export type ChartDataSet = S["ChartDataSet"];
export type InsightGroupEntry = S["InsightGroupEntry"];
export type InsightTotalEntry = S["InsightTotalEntry"];
export type BasicSummary = S["BasicSummary"];
export type BasicSummaryEntry = S["BasicSummaryEntry"];
export type SystemInfo = S["SystemInfo"];
export type TransactionType =
| "withdrawal"
| "deposit"
| "transfer"
| "reconciliation"
| "opening balance";
export type AccountType = "asset" | "expense" | "revenue" | "liabilities";
+38
View File
@@ -0,0 +1,38 @@
<script lang="ts">
import { formatMoney, type Money, toNumber } from "#lib/format.js";
export type Tone = "income" | "expense" | "transfer" | "neutral" | "auto";
let {
amount,
code,
symbol,
decimals,
tone = "auto",
signed = false,
class: className = "",
}: Money & { tone?: Tone; signed?: boolean; class?: string } = $props();
const value = $derived(toNumber(amount));
const resolved = $derived.by(() => {
if (tone !== "auto") {
return tone;
}
if (value > 0) {
return "income";
}
return value < 0 ? "expense" : "neutral";
});
const color = $derived(
{
expense: "text-expense",
income: "text-income",
neutral: "text-cladd-fg-softer",
transfer: "text-transfer",
}[resolved]
);
</script>
<span class="num {color} {className}"
>{formatMoney({ amount, code, decimals, symbol }, signed)}</span
>
+54
View File
@@ -0,0 +1,54 @@
<script lang="ts">
import type { Snippet } from "svelte";
let {
title,
href,
tools,
footer,
children,
class: className = "",
body = "p-3",
}: {
title?: string;
href?: string;
tools?: Snippet;
footer?: Snippet;
children: Snippet;
class?: string;
body?: string;
} = $props();
</script>
<section
class="cladd-surface cladd-outline flex min-w-0 flex-col rounded-cladd-lg {className}"
>
{#if title || tools}
<header
class="flex min-h-10 items-center justify-between gap-3 border-cladd-outline border-b px-3 py-1.5"
>
{#if title}
<h2 class="truncate font-semibold text-[13px] text-cladd-fg">
{#if href}
<a class="hover:underline" {href}>{title}</a>
{:else}
{title}
{/if}
</h2>
{/if}
{#if tools}
<div class="flex shrink-0 items-center gap-1.5">{@render tools()}</div>
{/if}
</header>
{/if}
<div class="min-h-0 flex-1 {body}">
{@render children()}
</div>
{#if footer}
<footer
class="flex items-center gap-2 border-cladd-outline border-t px-3 py-2 text-[12px]"
>
{@render footer()}
</footer>
{/if}
</section>
+61
View File
@@ -0,0 +1,61 @@
<script lang="ts">
import type { Snippet } from "svelte";
const ARM_MS = 3000;
let {
onconfirm,
children,
armed = $bindable(false),
confirmLabel = "Press again to delete",
class: className = "",
disabled = false,
}: {
onconfirm: () => void | Promise<void>;
children: Snippet;
armed?: boolean;
confirmLabel?: string;
class?: string;
disabled?: boolean;
} = $props();
let busy = $state(false);
let timer: ReturnType<typeof setTimeout> | undefined;
export async function press() {
if (busy) {
return;
}
if (!armed) {
armed = true;
clearTimeout(timer);
timer = setTimeout(() => (armed = false), ARM_MS);
return;
}
clearTimeout(timer);
busy = true;
try {
await onconfirm();
} finally {
busy = false;
armed = false;
}
}
</script>
<button
aria-live="polite"
class="cladd-surface cladd-gradient cladd-outline cladd-hoverable inline-flex h-cladd-sm items-center gap-1.5 rounded-cladd-sm px-2.5 font-semibold text-[12px] {armed
? "cladd-color-red text-cladd-primary"
: "text-cladd-fg"} {className}"
disabled={disabled || busy}
onblur={() => (armed = false)}
onclick={press}
type="button"
>
{#if armed}
{confirmLabel}
{:else}
{@render children()}
{/if}
</button>
+9
View File
@@ -0,0 +1,9 @@
<script lang="ts">
import { ui } from "#lib/ui.svelte.js";
</script>
<div
class="border-cladd-outline border-b bg-cladd-surface px-6 py-3 text-[13px]"
>
<button onclick={() => (ui.quickEntry = false)} type="button">Close</button>
</div>
@@ -0,0 +1,69 @@
<script lang="ts">
import { keys, prettyKeys } from "#lib/keys.svelte.js";
let dialog = $state<HTMLDialogElement>();
const groups = $derived.by(() => {
const map = new Map<string, { keys: string; label: string }[]>();
const seen = new Set<string>();
for (const binding of [...keys.all].reverse()) {
if (binding.hidden || seen.has(binding.keys)) {
continue;
}
seen.add(binding.keys);
const group = binding.group ?? "Page";
map.set(group, [...(map.get(group) ?? []), binding]);
}
return [...map.entries()].reverse();
});
$effect(() => {
if (keys.sheetOpen && dialog && !dialog.open) {
dialog.showModal();
} else if (!keys.sheetOpen && dialog?.open) {
dialog.close();
}
});
</script>
<dialog
aria-label="Keyboard shortcuts"
class="cladd-surface cladd-outline m-auto w-[min(920px,calc(100vw-2rem))] rounded-cladd-panel p-0 text-cladd-fg shadow-cladd-popover backdrop:bg-black/30"
onclick={(event) => event.target === dialog && (keys.sheetOpen = false)}
onclose={() => (keys.sheetOpen = false)}
onkeydown={(event) => event.key === "?" && (keys.sheetOpen = false)}
bind:this={dialog}
>
<div
class="flex items-center justify-between border-cladd-outline border-b px-5 py-3"
>
<h2 class="font-semibold text-[15px]">Keyboard shortcuts</h2>
<span class="text-[12px] text-cladd-fg-soft"
>Every shortcut is also in the palette,
<span class="kbd">{prettyKeys("mod+k")[0]}</span></span
>
</div>
<div
class="grid max-h-[70vh] grid-cols-1 gap-x-8 gap-y-5 overflow-y-auto px-5 py-4 sm:grid-cols-2 lg:grid-cols-3"
>
{#each groups as [group, bindings] (group)}
<section class="flex flex-col gap-1">
<h3
class="pb-1 font-semibold text-[11px] text-cladd-fg-softer uppercase tracking-[0.08em]"
>
{group}
</h3>
{#each bindings as binding (binding.keys)}
<div class="flex h-7 items-center justify-between gap-3 text-[13px]">
<span class="truncate">{binding.label}</span>
<span class="flex shrink-0 gap-1">
{#each prettyKeys(binding.keys) as key, i (i)}
<span class="kbd">{key}</span>
{/each}
</span>
</div>
{/each}
</section>
{/each}
</div>
</dialog>
@@ -0,0 +1,58 @@
<script lang="ts">
import type { Snippet } from "svelte";
export interface Crumb {
href?: string;
label: string;
}
let {
title,
subtitle,
crumbs = [],
actions,
}: {
title: string;
subtitle?: string;
crumbs?: Crumb[];
actions?: Snippet;
} = $props();
</script>
<svelte:head>
<title>{title} · Firefly III</title>
</svelte:head>
<header class="flex flex-wrap items-end justify-between gap-x-6 gap-y-2 pb-4">
<div class="flex min-w-0 items-baseline gap-3">
<h1
class="truncate font-semibold text-[22px] text-cladd-fg tracking-[-0.02em]"
>
{title}
</h1>
{#if subtitle}
<span class="truncate text-[13px] text-cladd-fg-soft">{subtitle}</span>
{/if}
</div>
<div class="flex items-center gap-3">
{@render actions?.()}
<ol
aria-label="Breadcrumbs"
class="flex items-center gap-1.5 text-[12px] text-cladd-fg-soft"
>
<li><a class="hover:text-cladd-fg hover:underline" href="/">Home</a></li>
{#each crumbs as crumb (crumb.label)}
<li aria-hidden="true" class="text-cladd-fg-softest">/</li>
<li>
{#if crumb.href}
<a class="hover:text-cladd-fg hover:underline" href={crumb.href}
>{crumb.label}</a
>
{:else}
<span class="text-cladd-fg">{crumb.label}</span>
{/if}
</li>
{/each}
</ol>
</div>
</header>
+291
View File
@@ -0,0 +1,291 @@
<script lang="ts">
import Search from "@lucide/svelte/icons/search";
import { createQuery } from "@tanstack/svelte-query";
import { lists } from "#lib/api/lists.js";
import { fuzzyFilter } from "#lib/fuzzy.js";
import { keys, prettyKeys } from "#lib/keys.svelte.js";
import { allLinks, CREATE_LINKS } from "#lib/nav.js";
import { ui } from "#lib/ui.svelte.js";
import { goto } from "$app/navigation";
interface Entry {
group: string;
hint?: string;
id: string;
keys?: string;
label: string;
run: () => void;
}
let dialog = $state<HTMLDialogElement>();
let input = $state<HTMLInputElement>();
let cursor = $state(0);
const accounts = createQuery(() => ({
...lists.accounts("all"),
enabled: ui.palette,
}));
const budgets = createQuery(() => ({
...lists.budgets(),
enabled: ui.palette,
}));
const categories = createQuery(() => ({
...lists.categories(),
enabled: ui.palette,
}));
const tags = createQuery(() => ({ ...lists.tags(), enabled: ui.palette }));
const bills = createQuery(() => ({ ...lists.bills(), enabled: ui.palette }));
const piggies = createQuery(() => ({
...lists.piggyBanks(),
enabled: ui.palette,
}));
function link(href: string, old = false) {
return () => {
if (old) {
location.assign(href);
} else {
goto(href);
}
};
}
const ACCOUNT_GROUP: Record<string, string> = {
asset: "Asset account",
expense: "Expense account",
liabilities: "Liability",
revenue: "Revenue account",
};
function entity(
kind: string,
group: string,
href: string,
id: string,
label: string | undefined,
hint?: string
): Entry {
return {
group,
hint,
id: `${kind}:${id}`,
label: label ?? "",
run: link(`${href}/${id}`),
};
}
const staticEntries: Entry[] = [
...allLinks().map((nav) => ({
group: "Page",
id: `page:${nav.href}`,
keys: nav.keys,
label: nav.label,
run: link(nav.href, nav.old),
})),
...CREATE_LINKS.map((create) => ({
group: "Create",
id: `create:${create.href}`,
label: create.label,
run: link(create.href, create.old),
})),
];
const commandEntries = $derived<Entry[]>(
keys.all
.filter((binding) => !binding.hidden && binding.group !== "Navigation")
.map((binding) => ({
group: binding.group ?? "Command",
id: `key:${binding.keys}:${binding.label}`,
keys: binding.keys,
label: binding.label,
run: () => binding.run(new KeyboardEvent("keydown")),
}))
);
const entityEntries = $derived<Entry[]>([
...(accounts.data ?? []).map((item) =>
entity(
"account",
ACCOUNT_GROUP[item.attributes.type] ?? "Account",
"/accounts/show",
item.id,
item.attributes.name,
item.attributes.active ? undefined : "inactive"
)
),
...(budgets.data ?? []).map((item) =>
entity("budget", "Budget", "/budgets/show", item.id, item.attributes.name)
),
...(categories.data ?? []).map((item) =>
entity(
"category",
"Category",
"/categories/show",
item.id,
item.attributes.name
)
),
...(tags.data ?? []).map((item) =>
entity("tag", "Tag", "/tags/show", item.id, item.attributes.tag)
),
...(bills.data ?? []).map((item) =>
entity(
"bill",
"Subscription",
"/subscriptions/show",
item.id,
item.attributes.name
)
),
...(piggies.data ?? []).map((item) =>
entity(
"piggy",
"Piggy bank",
"/piggy-banks/show",
item.id,
item.attributes.name
)
),
]);
const entries = $derived([
...staticEntries,
...commandEntries,
...entityEntries,
]);
const results = $derived.by<Entry[]>(() => {
const query = ui.paletteQuery;
const found = fuzzyFilter(
entries,
query,
(e) => `${e.label} ${e.group}`,
60
);
if (query.trim()) {
found.push({
group: "Search",
id: "search",
label: `Search transactions for “${query.trim()}”`,
run: link(`/search?search=${encodeURIComponent(query.trim())}`),
});
}
return found;
});
$effect(() => {
if (ui.palette && dialog && !dialog.open) {
cursor = 0;
dialog.showModal();
input?.select();
} else if (!ui.palette && dialog?.open) {
dialog.close();
}
});
$effect(() => {
if (ui.paletteQuery !== undefined) {
cursor = 0;
}
});
function choose(entry: Entry | undefined) {
if (!entry) {
return;
}
ui.palette = false;
entry.run();
}
function move(delta: number) {
if (results.length === 0) {
return;
}
cursor = (cursor + delta + results.length) % results.length;
document
.getElementById(`palette-${cursor}`)
?.scrollIntoView({ block: "nearest" });
}
function onkeydown(event: KeyboardEvent) {
const ctrl = event.ctrlKey || event.metaKey;
if (
event.key === "ArrowDown" ||
(ctrl && event.key === "j") ||
(ctrl && event.key === "n")
) {
event.preventDefault();
move(1);
} else if (
event.key === "ArrowUp" ||
(ctrl && event.key === "k") ||
(ctrl && event.key === "p")
) {
event.preventDefault();
move(-1);
} else if (event.key === "Enter") {
event.preventDefault();
choose(results[cursor]);
}
}
</script>
<dialog
aria-label="Command palette"
class="cladd-surface cladd-outline m-auto mt-[12vh] w-[min(640px,calc(100vw-2rem))] rounded-cladd-panel p-0 text-cladd-fg shadow-cladd-popover backdrop:bg-black/30"
onclose={() => (ui.palette = false)}
bind:this={dialog}
>
<div class="flex items-center gap-2 border-cladd-outline border-b px-4">
<Search class="size-4 text-cladd-fg-softer" />
<input
aria-activedescendant={`palette-${cursor}`}
aria-controls="palette-list"
aria-expanded="true"
class="h-12 flex-1 bg-transparent text-[15px] outline-none placeholder:text-cladd-fg-softer"
{onkeydown}
placeholder="Go to page, account, budget, category, tag, or run a command"
role="combobox"
bind:this={input}
bind:value={ui.paletteQuery}
>
<span class="kbd">Esc</span>
</div>
<ul class="max-h-[56vh] overflow-y-auto p-1.5" id="palette-list">
{#each results as entry, index (entry.id)}
<li>
<button
class="flex h-9 w-full cursor-pointer items-center gap-3 rounded-cladd-md px-3 text-left {index ===
cursor
? "bg-row-cursor"
: ""}"
id={`palette-${index}`}
onclick={() => choose(entry)}
onmousemove={() => (cursor = index)}
tabindex="-1"
type="button"
>
<span class="min-w-0 flex-1 truncate text-[13px]">{entry.label}</span>
{#if entry.hint}
<span class="text-[11px] text-cladd-fg-softer">{entry.hint}</span>
{/if}
<span
class="w-28 shrink-0 text-right text-[11px] text-cladd-fg-softer"
>{entry.group}</span
>
<span class="flex w-16 shrink-0 justify-end gap-1">
{#if entry.keys}
{#each prettyKeys(entry.keys) as key, i (i)}
<span class="kbd">{key}</span>
{/each}
{/if}
</span>
</button>
</li>
{:else}
<li class="px-3 py-6 text-center text-[13px] text-cladd-fg-soft">
Nothing matches. Try fewer letters.
</li>
{/each}
</ul>
</dialog>
@@ -0,0 +1,111 @@
<script lang="ts">
import CalendarRange from "@lucide/svelte/icons/calendar-range";
import ChevronLeft from "@lucide/svelte/icons/chevron-left";
import ChevronRight from "@lucide/svelte/icons/chevron-right";
import { Popover } from "bits-ui";
import { period } from "#lib/period.svelte.js";
let open = $state(false);
let start = $state(period.start);
let end = $state(period.end);
$effect(() => {
if (open) {
({ start, end } = period);
}
});
function apply(event: SubmitEvent) {
event.preventDefault();
if (start && end && start <= end) {
period.set({ end, start });
open = false;
}
}
</script>
<div class="flex items-center rounded-cladd-md bg-black/10 text-on-brand">
<button
aria-label="Previous period"
class="grid size-8 place-items-center rounded-l-cladd-md hover:bg-black/10"
onclick={() => period.shift(-1)}
title="Previous period ["
type="button"
>
<ChevronLeft class="size-4" />
</button>
<Popover.Root bind:open>
<Popover.Trigger
class="flex h-8 items-center gap-2 px-2 font-medium text-[13px] hover:bg-black/10"
title="Choose period"
>
<CalendarRange class="size-3.5 opacity-80" />
<span class="num">{period.label}</span>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
align="end"
class="cladd-surface cladd-gradient cladd-outline cladd-level-1 cladd-anim-popover z-50 w-72 rounded-cladd-lg p-2 text-cladd-fg shadow-cladd-popover outline-none"
sideOffset={6}
>
<div class="flex flex-col">
{#each period.presets() as preset (preset.label)}
{@const current =
preset.range.start === period.start &&
preset.range.end === period.end}
<button
class="flex h-8 items-center justify-between rounded-cladd-sm px-2.5 text-left text-[13px] hover:bg-cladd-surface-next {current
? "font-semibold"
: ""}"
onclick={() => {
period.set(preset.range);
open = false;
}}
type="button"
>
{preset.label}
</button>
{/each}
</div>
<form
class="mt-2 flex flex-col gap-2 border-cladd-outline border-t px-1 pt-3"
onsubmit={apply}
>
<div class="grid grid-cols-2 gap-2">
<label class="flex flex-col gap-1 text-[11px] text-cladd-fg-soft">
Start
<input
class="cladd-cut cladd-focusable-input h-8 rounded-cladd-sm px-2 text-[13px] text-cladd-fg"
type="date"
bind:value={start}
>
</label>
<label class="flex flex-col gap-1 text-[11px] text-cladd-fg-soft">
End
<input
class="cladd-cut cladd-focusable-input h-8 rounded-cladd-sm px-2 text-[13px] text-cladd-fg"
type="date"
bind:value={end}
>
</label>
</div>
<button
class="cladd-surface cladd-gradient cladd-outline cladd-hoverable cladd-color-brand h-8 rounded-cladd-sm font-semibold text-[12px] text-cladd-primary"
type="submit"
>
Apply custom range
</button>
</form>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
<button
aria-label="Next period"
class="grid size-8 place-items-center rounded-r-cladd-md hover:bg-black/10"
onclick={() => period.shift(1)}
title="Next period ]"
type="button"
>
<ChevronRight class="size-4" />
</button>
</div>
+322
View File
@@ -0,0 +1,322 @@
<script lang="ts">
import Eye from "@lucide/svelte/icons/eye";
import EyeOff from "@lucide/svelte/icons/eye-off";
import History from "@lucide/svelte/icons/history";
import Keyboard from "@lucide/svelte/icons/keyboard";
import PanelLeft from "@lucide/svelte/icons/panel-left";
import Plus from "@lucide/svelte/icons/plus";
import { DropdownMenu } from "bits-ui";
import { setMode, userPrefersMode } from "mode-watcher";
import type { Snippet } from "svelte";
import { logout } from "#lib/api/client.js";
import { invalidate } from "#lib/api/lists.js";
import DropdownMenuContent from "#lib/components/ui/dropdown-menu/dropdown-menu-content.svelte";
import DropdownMenuItem from "#lib/components/ui/dropdown-menu/dropdown-menu-item.svelte";
import { isEditable, keys, useKeys } from "#lib/keys.svelte.js";
import { allLinks, CREATE_LINKS } from "#lib/nav.js";
import { period } from "#lib/period.svelte.js";
import { session, TESTED_VERSION } from "#lib/session.svelte.js";
import { oldUiUrl } from "#lib/spa-paths.js";
import { ui } from "#lib/ui.svelte.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import Cheatsheet from "./Cheatsheet.svelte";
import Palette from "./Palette.svelte";
import PeriodPicker from "./PeriodPicker.svelte";
import Sidebar from "./Sidebar.svelte";
let { children, quick }: { children: Snippet; quick?: Snippet } = $props();
function openOld(sticky = false) {
location.assign(oldUiUrl(page.url.href, sticky));
}
async function togglePrivacy() {
await session.setPreference("anonymous", !session.anonymous);
await invalidate();
}
function cycleTheme() {
const order = ["system", "light", "dark"] as const;
const current = userPrefersMode.current ?? "system";
setMode(order[(order.indexOf(current) + 1) % order.length]);
}
function focusSearch() {
const input = document.getElementById("sidebar-search");
if (input && input.offsetParent !== null) {
input.focus();
} else {
goto("/search");
}
}
useKeys(() => [
{
group: "Global",
input: true,
keys: "mod+k",
label: "Command palette",
run: () => ui.openPalette(),
},
{
group: "Global",
keys: "?",
label: "Keyboard shortcuts",
run: () => (keys.sheetOpen = !keys.sheetOpen),
},
{ group: "Global", keys: "/", label: "Search", run: focusSearch },
{
group: "Global",
keys: "n",
label: "Quick entry",
run: () => (ui.quickEntry = true),
},
{
group: "Global",
keys: "N",
label: "New transaction (full editor)",
run: () => goto("/transactions/create/withdrawal"),
},
{
group: "Global",
keys: "O",
label: "Open this page in the old interface",
run: () => openOld(),
},
{
group: "Global",
keys: "g O",
label: "Stay in the old interface",
run: () => openOld(true),
},
{
group: "Global",
keys: "\\",
label: "Toggle sidebar",
run: () => ui.toggleSidebar(),
},
{
group: "Period",
keys: "[",
label: "Previous period",
run: () => period.shift(-1),
},
{
group: "Period",
keys: "]",
label: "Next period",
run: () => period.shift(1),
},
{
group: "Period",
keys: ".",
label: "Current period",
run: () => period.reset(),
},
{
group: "Global",
hidden: true,
keys: "g g",
label: "Top of page",
run: () => window.scrollTo({ top: 0 }),
},
{
group: "Global",
hidden: true,
keys: "G",
label: "Bottom of page",
run: () => window.scrollTo({ top: document.body.scrollHeight }),
},
{
group: "Global",
hidden: true,
input: true,
keys: "Escape",
label: "Leave field",
run: (event) => {
if (isEditable(event.target)) {
(event.target as HTMLElement).blur();
}
ui.mobileNav = false;
ui.quickEntry = false;
},
},
{
group: "Settings",
keys: "g T",
label: "Cycle theme: system, light, dark",
run: cycleTheme,
},
{
group: "Settings",
keys: "g P",
label: "Toggle privacy mode",
run: togglePrivacy,
},
{ group: "Global", keys: "g q", label: "Logout", run: logout },
...allLinks()
.filter((link) => link.keys)
.map((link) => ({
group: "Navigation",
keys: link.keys as string,
label: `Go to ${link.label.toLowerCase()}`,
run: () => (link.old ? location.assign(link.href) : goto(link.href)),
})),
{
group: "Navigation",
keys: "g f",
label: "Go to search",
run: () => goto("/search"),
},
]);
</script>
<div class="min-h-dvh bg-cladd-bg">
<header
class="sticky top-0 z-40 flex h-12 items-center bg-brand text-on-brand shadow-[0_1px_0_rgb(0_0_0/0.08)]"
>
<a
class="flex h-full shrink-0 items-center gap-2 px-4 text-[17px] tracking-[-0.01em] {ui.collapsed
? "lg:w-14 lg:justify-center lg:px-0"
: "lg:w-60"}"
href="/"
>
{#if ui.collapsed}
<strong class="hidden lg:inline">FF</strong>
<span class="lg:hidden"><strong>Firefly</strong>III</span>
{:else}
<span><strong>Firefly</strong>III</span>
{/if}
</a>
<button
aria-label="Toggle sidebar"
class="grid size-12 shrink-0 place-items-center hover:bg-black/10"
onclick={() => ui.toggleSidebar()}
title="Toggle sidebar \"
type="button"
>
<PanelLeft class="size-4" />
</button>
<button
class="ml-1 hidden h-8 min-w-0 items-center gap-2 rounded-cladd-md bg-black/10 px-3 text-[13px] text-on-brand/80 hover:bg-black/15 md:flex md:w-64 xl:w-80"
onclick={() => ui.openPalette()}
type="button"
>
<span class="flex-1 truncate text-left">Jump to anything</span>
<span class="rounded bg-black/15 px-1.5 py-0.5 font-mono text-[11px]"
>⌘K</span
>
</button>
<div class="ml-auto flex items-center gap-1 pr-2">
<button
aria-label={session.anonymous ? "Show amounts" : "Hide amounts"}
aria-pressed={session.anonymous}
class="hidden size-8 place-items-center rounded-cladd-md hover:bg-black/10 sm:grid"
onclick={togglePrivacy}
title={session.anonymous
? "Amounts hidden. Show amounts"
: "Hide amounts"}
type="button"
>
{#if session.anonymous}
<EyeOff class="size-4" />
{:else}
<Eye class="size-4" />
{/if}
</button>
<button
aria-label="Keyboard shortcuts"
class="hidden size-8 place-items-center rounded-cladd-md hover:bg-black/10 sm:grid"
onclick={() => (keys.sheetOpen = true)}
title="Keyboard shortcuts ?"
type="button"
>
<Keyboard class="size-4" />
</button>
<button
aria-label="Open this page in the old interface"
class="hidden size-8 place-items-center rounded-cladd-md hover:bg-black/10 sm:grid"
onclick={() => openOld()}
title="Open this page in the old interface Shift+O"
type="button"
>
<History class="size-4" />
</button>
<PeriodPicker />
<span class="hidden px-2 text-[12px] text-on-brand/85 xl:inline"
>{session.email}</span
>
<DropdownMenu.Root>
<DropdownMenu.Trigger
aria-label="Create new"
class="grid size-8 place-items-center rounded-cladd-md hover:bg-black/10"
title="Create new"
>
<Plus class="size-4" />
</DropdownMenu.Trigger>
<DropdownMenuContent align="end" class="w-60">
{#each CREATE_LINKS as link (link.href)}
<DropdownMenuItem
onSelect={() =>
link.old ? location.assign(link.href) : goto(link.href)}
>
{link.label}
</DropdownMenuItem>
{/each}
</DropdownMenuContent>
</DropdownMenu.Root>
</div>
</header>
<div class="flex">
<aside
class="fixed top-12 bottom-0 z-30 w-60 shrink-0 border-cladd-outline border-r bg-cladd-surface transition-transform lg:sticky lg:h-[calc(100dvh-3rem)] lg:translate-x-0 {ui.collapsed
? "lg:w-14"
: "lg:w-60"} {ui.mobileNav ? "translate-x-0" : "-translate-x-full"}"
>
<Sidebar
collapsed={ui.collapsed && !ui.mobileNav}
onnavigate={() => (ui.mobileNav = false)}
/>
</aside>
{#if ui.mobileNav}
<button
aria-label="Close menu"
class="fixed inset-0 top-12 z-20 bg-black/30 lg:hidden"
onclick={() => (ui.mobileNav = false)}
type="button"
></button>
{/if}
<div class="flex min-h-[calc(100dvh-3rem)] min-w-0 flex-1 flex-col">
{#if ui.quickEntry && quick}
{@render quick()}
{/if}
<main class="mx-auto w-full max-w-[1920px] flex-1 px-4 py-4 lg:px-6">
{@render children()}
</main>
<footer
class="flex flex-wrap items-center justify-between gap-2 border-cladd-outline border-t px-4 py-2.5 text-[12px] text-cladd-fg-soft lg:px-6"
>
<span>
Firefly III {session.version}
{#if session.drift}
<span class="text-warn"
>· this interface was tested with {TESTED_VERSION}, some screens
may misbehave</span
>
{/if}
</span>
<span class="flex items-center gap-1.5"
>Press <span class="kbd">?</span> for shortcuts,
<span class="kbd">Shift+O</span>
for the old interface</span
>
</footer>
</div>
</div>
</div>
<Palette />
<Cheatsheet />
+150
View File
@@ -0,0 +1,150 @@
<script lang="ts">
import ChevronRight from "@lucide/svelte/icons/chevron-right";
import LogOut from "@lucide/svelte/icons/log-out";
import Search from "@lucide/svelte/icons/search";
import { logout } from "#lib/api/client.js";
import { isActive, NAV, type NavItem } from "#lib/nav.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
let {
collapsed = false,
onnavigate,
}: { collapsed?: boolean; onnavigate?: () => void } = $props();
let query = $state("");
let opened = $state<Record<string, boolean>>({});
const pathname = $derived(page.url.pathname);
function isOpen(item: NavItem) {
return opened[item.label] ?? isActive(item, pathname);
}
function toggle(item: NavItem) {
opened[item.label] = !isOpen(item);
}
function search(event: SubmitEvent) {
event.preventDefault();
goto(`/search?search=${encodeURIComponent(query)}`);
onnavigate?.();
}
</script>
<nav
aria-label="Main"
class="flex h-full flex-col gap-3 overflow-y-auto px-2 py-3"
>
{#if !collapsed}
<search>
<form class="relative px-1" onsubmit={search}>
<Search
class="pointer-events-none absolute top-1/2 left-3.5 size-3.5 -translate-y-1/2 text-cladd-fg-softer"
/>
<input
class="cladd-cut cladd-focusable-input h-8 w-full rounded-cladd-md pr-2 pl-8 text-[13px] text-cladd-fg placeholder:text-cladd-fg-softer"
id="sidebar-search"
name="search"
placeholder="Search..."
type="search"
bind:value={query}
>
</form>
</search>
{/if}
{#each NAV as section, index (index)}
<div class="flex flex-col gap-px">
{#if section.title && !collapsed}
<div
class="px-2.5 pt-1 pb-1 font-semibold text-[10.5px] text-cladd-fg-softer uppercase tracking-[0.08em]"
>
{section.title}
</div>
{/if}
{#each section.items as item (item.label)}
{@const active = isActive(item, pathname)}
{#if item.children}
<button
aria-expanded={isOpen(item)}
class="group flex h-8 w-full items-center gap-2.5 rounded-cladd-sm px-2.5 text-left text-cladd-fg hover:bg-cladd-surface-next {active
? "font-semibold"
: ""}"
onclick={() => (collapsed ? goto(item.href) : toggle(item))}
title={collapsed ? item.label : undefined}
type="button"
>
<item.icon
class="size-4 shrink-0 {active
? "text-brand dark:text-(--ff-series-1)"
: "text-cladd-fg-soft"}"
/>
{#if !collapsed}
<span class="flex-1 truncate">{item.label}</span>
<ChevronRight
class="size-3.5 text-cladd-fg-softer transition-transform {isOpen(
item
)
? "rotate-90"
: ""}"
/>
{/if}
</button>
{#if isOpen(item) && !collapsed}
<div
class="mb-1 ml-[1.15rem] flex flex-col gap-px border-cladd-outline border-l pl-2"
>
{#each item.children as child (child.href)}
{@const childActive = isActive(child, pathname)}
<a
aria-current={childActive ? "page" : undefined}
class="flex h-7 items-center justify-between gap-2 rounded-cladd-sm px-2.5 text-[13px] hover:bg-cladd-surface-next {childActive
? "bg-cladd-surface-next font-semibold text-cladd-fg"
: "text-cladd-fg-soft"}"
data-sveltekit-reload={child.old ? "" : undefined}
href={child.href}
onclick={onnavigate}
>
<span class="truncate">{child.label}</span>
</a>
{/each}
</div>
{/if}
{:else}
<a
aria-current={active ? "page" : undefined}
class="flex h-8 items-center gap-2.5 rounded-cladd-sm px-2.5 hover:bg-cladd-surface-next {active
? "bg-cladd-surface-next font-semibold text-cladd-fg"
: "text-cladd-fg"}"
data-sveltekit-reload={item.old ? "" : undefined}
href={item.href}
onclick={onnavigate}
title={collapsed ? item.label : undefined}
>
<item.icon
class="size-4 shrink-0 {active
? "text-brand dark:text-(--ff-series-1)"
: "text-cladd-fg-soft"}"
/>
{#if !collapsed}
<span class="truncate">{item.label}</span>
{/if}
</a>
{/if}
{/each}
</div>
{/each}
<button
class="mt-auto flex h-8 items-center gap-2.5 rounded-cladd-sm px-2.5 text-left text-cladd-fg-soft hover:bg-cladd-surface-next"
onclick={logout}
title={collapsed ? "Logout" : undefined}
type="button"
>
<LogOut class="size-4 shrink-0" />
{#if !collapsed}
<span>Logout</span>
{/if}
</button>
</nav>
+150
View File
@@ -0,0 +1,150 @@
export interface Range {
end: string;
start: string;
}
export type ViewRange =
| "1D"
| "1W"
| "last7"
| "1M"
| "last30"
| "MTD"
| "3M"
| "last90"
| "QTD"
| "6M"
| "1Y"
| "YTD"
| "last365";
const DYNAMIC = new Set<ViewRange>([
"last7",
"last30",
"last90",
"last365",
"MTD",
"QTD",
"YTD",
]);
const pad = (n: number) => String(n).padStart(2, "0");
export const iso = (d: Date) =>
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
export function parse(value: string): Date {
const [y, m, d] = value.slice(0, 10).split("-").map(Number);
return new Date(y, m - 1, d);
}
export const today = () => iso(new Date());
export function addDays(value: string, days: number): string {
const d = parse(value);
d.setDate(d.getDate() + days);
return iso(d);
}
export function addMonths(value: string, months: number): string {
const d = parse(value);
const day = d.getDate();
d.setDate(1);
d.setMonth(d.getMonth() + months);
const last = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
d.setDate(Math.min(day, last));
return iso(d);
}
export const isDynamic = (view: ViewRange) => DYNAMIC.has(view);
function startOf(value: string, view: ViewRange): string {
const d = parse(value);
switch (view) {
case "1D":
return value;
case "1W": {
const shift = (d.getDay() + 6) % 7;
return addDays(value, -shift);
}
case "3M":
return iso(
new Date(d.getFullYear(), Math.floor(d.getMonth() / 3) * 3, 1)
);
case "6M":
return iso(new Date(d.getFullYear(), d.getMonth() < 6 ? 0 : 6, 1));
case "1Y":
return iso(new Date(d.getFullYear(), 0, 1));
default:
return iso(new Date(d.getFullYear(), d.getMonth(), 1));
}
}
const LENGTH_MONTHS: Partial<Record<ViewRange, number>> = {
"1M": 1,
"1Y": 12,
"3M": 3,
"6M": 6,
};
export function periodOf(value: string, view: ViewRange): Range {
const now = today();
switch (view) {
case "last7":
return { end: now, start: addDays(now, -7) };
case "last30":
return { end: now, start: addDays(now, -30) };
case "last90":
return { end: now, start: addDays(now, -90) };
case "last365":
return { end: now, start: addDays(now, -365) };
case "MTD":
return { end: now, start: startOf(now, "1M") };
case "QTD":
return { end: now, start: startOf(now, "3M") };
case "YTD":
return { end: now, start: startOf(now, "1Y") };
case "1D":
return { end: value, start: value };
case "1W": {
const start = startOf(value, "1W");
return { end: addDays(start, 6), start };
}
default: {
const start = startOf(value, view);
const months = LENGTH_MONTHS[view] ?? 1;
return { end: addDays(addMonths(start, months), -1), start };
}
}
}
export function shiftPeriod(range: Range, view: ViewRange, dir: 1 | -1): Range {
const fixed = isDynamic(view) ? "1M" : view;
const aligned = periodOf(range.start, fixed);
if (aligned.start === range.start && aligned.end === range.end) {
const anchor = dir === 1 ? addDays(range.end, 1) : addDays(range.start, -1);
return periodOf(anchor, fixed);
}
const days = daysBetween(range.start, range.end) + 1;
return {
end: addDays(range.end, dir * days),
start: addDays(range.start, dir * days),
};
}
export function daysBetween(a: string, b: string): number {
return Math.round((parse(b).getTime() - parse(a).getTime()) / 86_400_000);
}
export function eachDay(range: Range): string[] {
const days: string[] = [];
for (let d = range.start; d <= range.end; d = addDays(d, 1)) {
days.push(d);
}
return days;
}
export function contains(range: Range, value: string): boolean {
const day = value.slice(0, 10);
return day >= range.start && day <= range.end;
}
+143
View File
@@ -0,0 +1,143 @@
export interface Money {
amount: string | number | null | undefined;
code?: string | null;
decimals?: number | null;
symbol?: string | null;
}
const settings: { anonymous: boolean; locale: string } = {
anonymous: false,
locale: "en-GB",
};
export function configureFormat(locale: string, anonymous: boolean) {
settings.locale = locale;
settings.anonymous = anonymous;
}
export const currentLocale = () => settings.locale;
const numberFormats = new Map<string, Intl.NumberFormat>();
function numberFormat(decimals: number, signed: boolean): Intl.NumberFormat {
const key = `${settings.locale}|${decimals}|${signed}`;
let format = numberFormats.get(key);
if (!format) {
format = new Intl.NumberFormat(settings.locale, {
maximumFractionDigits: decimals,
minimumFractionDigits: decimals,
signDisplay: signed ? "exceptZero" : "auto",
});
numberFormats.set(key, format);
}
return format;
}
export function toNumber(value: string | number | null | undefined): number {
if (value === null || value === undefined || value === "") {
return 0;
}
return typeof value === "number" ? value : Number.parseFloat(value);
}
export function formatNumber(
value: string | number | null | undefined,
decimals = 2,
signed = false
): string {
if (settings.anonymous) {
return numberFormat(decimals, false).format(0);
}
return numberFormat(decimals, signed).format(toNumber(value));
}
export function formatMoney(money: Money, signed = false): string {
const number = formatNumber(money.amount, money.decimals ?? 2, signed);
const symbol = money.symbol ?? money.code ?? "";
return symbol ? `${symbol}${number}` : number;
}
export function compactMoney(value: number, symbol = ""): string {
if (settings.anonymous) {
return `${symbol}0`;
}
const format = new Intl.NumberFormat(settings.locale, {
maximumFractionDigits: 1,
notation: "compact",
});
return `${symbol}${format.format(value)}`;
}
const dateFormats = new Map<string, Intl.DateTimeFormat>();
function dateFormat(options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {
const key = `${settings.locale}|${JSON.stringify(options)}`;
let format = dateFormats.get(key);
if (!format) {
format = new Intl.DateTimeFormat(settings.locale, options);
dateFormats.set(key, format);
}
return format;
}
function toDate(value: string | Date): Date {
if (value instanceof Date) {
return value;
}
if (value.length === 10) {
const [y, m, d] = value.split("-").map(Number);
return new Date(y, m - 1, d);
}
return new Date(value);
}
export function formatDate(
value: string | Date | null | undefined,
style: "short" | "medium" | "long" | "day" | "month" | "year" = "medium"
): string {
if (!value) {
return "";
}
const date = toDate(value);
switch (style) {
case "short":
return dateFormat({ day: "2-digit", month: "short" }).format(date);
case "day":
return dateFormat({
day: "numeric",
month: "short",
weekday: "short",
}).format(date);
case "month":
return dateFormat({ month: "long", year: "numeric" }).format(date);
case "year":
return dateFormat({ year: "numeric" }).format(date);
case "long":
return dateFormat({ dateStyle: "long" }).format(date);
default:
return dateFormat({ dateStyle: "medium" }).format(date);
}
}
export function formatDateTime(value: string | null | undefined): string {
if (!value) {
return "";
}
return dateFormat({ dateStyle: "medium", timeStyle: "short" }).format(
new Date(value)
);
}
export function formatRange(start: string, end: string): string {
return dateFormat({ dateStyle: "medium" }).formatRange(
toDate(start),
toDate(end)
);
}
export function percent(value: number, digits = 0): string {
return new Intl.NumberFormat(settings.locale, {
maximumFractionDigits: digits,
style: "percent",
}).format(value);
}
+47
View File
@@ -0,0 +1,47 @@
const SEPARATOR = /[\s/·-]/;
export function fuzzyScore(query: string, text: string): number {
const q = query.toLowerCase().trim();
if (!q) {
return 1;
}
const t = text.toLowerCase();
const direct = t.indexOf(q);
if (direct !== -1) {
const wordStart = direct === 0 || SEPARATOR.test(t[direct - 1]);
return 1000 - direct + (wordStart ? 200 : 0) - t.length * 0.1;
}
let score = 0;
let position = -1;
let streak = 0;
for (const char of q) {
if (char === " ") {
continue;
}
const next = t.indexOf(char, position + 1);
if (next === -1) {
return 0;
}
streak = next === position + 1 ? streak + 1 : 0;
score += 10 + streak * 5 - (next - position) * 0.5;
position = next;
}
return Math.max(score, 1);
}
export function fuzzyFilter<T>(
items: T[],
query: string,
text: (item: T) => string,
limit = 50
): T[] {
if (!query.trim()) {
return items.slice(0, limit);
}
return items
.map((item) => ({ item, score: fuzzyScore(query, text(item)) }))
.filter((entry) => entry.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, limit)
.map((entry) => entry.item);
}
+125
View File
@@ -0,0 +1,125 @@
export interface Binding {
group?: string;
hidden?: boolean;
input?: boolean;
keys: string;
label: string;
run: (event: KeyboardEvent) => void;
}
const SEQUENCE_MS = 1200;
const EDITABLE =
"input, textarea, select, [contenteditable=''], [contenteditable='true']";
function token(event: KeyboardEvent): string {
const parts: string[] = [];
if (event.ctrlKey || event.metaKey) {
parts.push("mod");
}
if (event.altKey) {
parts.push("alt");
}
const key = event.key === " " ? "Space" : event.key;
if (event.shiftKey && key.length > 1) {
parts.push("shift");
}
parts.push(key.length === 1 && parts.length > 0 ? key.toLowerCase() : key);
return parts.join("+");
}
export function isEditable(target: EventTarget | null): boolean {
return target instanceof Element && target.closest(EDITABLE) !== null;
}
class Keys {
layers = $state<Binding[][]>([]);
pending = $state("");
sheetOpen = $state(false);
private timer: ReturnType<typeof setTimeout> | undefined;
all = $derived(this.layers.flat());
register(bindings: Binding[]): () => void {
const layer = bindings;
this.layers = [...this.layers, layer];
return () => {
this.layers = this.layers.filter((l) => l !== layer);
};
}
private find(combo: string, editable: boolean): Binding | undefined {
for (let i = this.layers.length - 1; i >= 0; i -= 1) {
const match = this.layers[i].find(
(b) => b.keys === combo && (!editable || b.input)
);
if (match) {
return match;
}
}
}
private isPrefix(combo: string): boolean {
return this.all.some((b) => b.keys.startsWith(`${combo} `));
}
private clear() {
this.pending = "";
clearTimeout(this.timer);
}
handle = (event: KeyboardEvent) => {
if (event.defaultPrevented || event.isComposing) {
return;
}
if (["Shift", "Control", "Meta", "Alt"].includes(event.key)) {
return;
}
const editable = isEditable(event.target);
const current = token(event);
const combo = this.pending ? `${this.pending} ${current}` : current;
const binding =
this.find(combo, editable) ??
(this.pending ? this.find(current, editable) : undefined);
if (binding) {
event.preventDefault();
this.clear();
binding.run(event);
return;
}
if (!editable && this.isPrefix(current)) {
event.preventDefault();
this.clear();
this.pending = current;
this.timer = setTimeout(() => this.clear(), SEQUENCE_MS);
return;
}
this.clear();
};
}
export const keys = new Keys();
export function useKeys(bindings: () => Binding[]) {
$effect(() => keys.register(bindings()));
}
const APPLE = /Mac|iPhone|iPad/;
const PC_NAMES: Record<string, string> = {
alt: "Alt",
Enter: "↵",
Escape: "Esc",
mod: "Ctrl",
shift: "Shift",
};
const MAC_NAMES: Record<string, string> = { ...PC_NAMES, alt: "⌥", mod: "⌘" };
export function prettyKeys(combo: string): string[] {
const mac =
typeof navigator !== "undefined" && APPLE.test(navigator.platform);
return combo.split(" ").map((part) =>
part
.split("+")
.map((piece) => (mac ? MAC_NAMES : PC_NAMES)[piece] ?? piece)
.join(mac ? "" : "+")
);
}
+255
View File
@@ -0,0 +1,255 @@
import { type Binding, useKeys } from "#lib/keys.svelte.js";
interface Options<T> {
edit?: (item: T) => void;
extra?: () => Binding[];
id?: string;
items: () => T[];
key: (item: T) => string;
open?: (item: T) => void;
selectable?: boolean;
}
export class ListNav<T> {
cursor = $state(-1);
selected = $state<Set<string>>(new Set());
anchor = -1;
private readonly options: Options<T>;
constructor(options: Options<T>) {
this.options = options;
}
get items() {
return this.options.items();
}
get current(): T | undefined {
return this.items[this.cursor];
}
get selection(): T[] {
return this.items.filter((item) =>
this.selected.has(this.options.key(item))
);
}
get targets(): T[] {
if (this.selected.size > 0) {
return this.selection;
}
return this.current ? [this.current] : [];
}
rowId(index: number) {
return `${this.options.id ?? "list"}-row-${index}`;
}
isSelected(item: T) {
return this.selected.has(this.options.key(item));
}
move(to: number) {
const count = this.items.length;
if (count === 0) {
this.cursor = -1;
return;
}
this.cursor = Math.max(0, Math.min(count - 1, to));
document
.getElementById(this.rowId(this.cursor))
?.scrollIntoView({ block: "nearest" });
}
toggle(item: T | undefined = this.current) {
if (!item) {
return;
}
const key = this.options.key(item);
const next = new Set(this.selected);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
this.selected = next;
this.anchor = this.items.indexOf(item);
}
extend(delta: number) {
if (this.cursor < 0) {
this.move(0);
}
const next = new Set(this.selected);
if (this.current) {
next.add(this.options.key(this.current));
}
this.move(this.cursor + delta);
if (this.current) {
next.add(this.options.key(this.current));
}
this.selected = next;
}
selectRange(index: number) {
const from = this.anchor < 0 ? index : this.anchor;
const [a, b] = from < index ? [from, index] : [index, from];
const next = new Set(this.selected);
for (const item of this.items.slice(a, b + 1)) {
next.add(this.options.key(item));
}
this.selected = next;
}
selectAll() {
const allSelected = this.selected.size === this.items.length;
this.selected = allSelected
? new Set()
: new Set(this.items.map((item) => this.options.key(item)));
}
clear() {
this.selected = new Set();
}
click(index: number, event: MouseEvent) {
this.cursor = index;
if (!this.options.selectable) {
return;
}
if (event.shiftKey) {
this.selectRange(index);
} else if (event.metaKey || event.ctrlKey) {
this.toggle(this.items[index]);
}
}
bindings(): Binding[] {
const list: Binding[] = [
{
group: "List",
keys: "j",
label: "Next row",
run: () => this.move(this.cursor + 1),
},
{
group: "List",
keys: "k",
label: "Previous row",
run: () => this.move(this.cursor - 1),
},
{
group: "List",
hidden: true,
keys: "ArrowDown",
label: "Next row",
run: () => this.move(this.cursor + 1),
},
{
group: "List",
hidden: true,
keys: "ArrowUp",
label: "Previous row",
run: () => this.move(this.cursor - 1),
},
{
group: "List",
keys: "g g",
label: "First row",
run: () => this.move(0),
},
{
group: "List",
keys: "G",
label: "Last row",
run: () => this.move(this.items.length - 1),
},
{
group: "List",
hidden: true,
keys: "mod+d",
label: "Half page down",
run: () => this.move(this.cursor + 15),
},
{
group: "List",
hidden: true,
keys: "mod+u",
label: "Half page up",
run: () => this.move(this.cursor - 15),
},
];
const { open, edit, selectable } = this.options;
if (open) {
list.push(
{
group: "List",
keys: "Enter",
label: "Open row",
run: () => this.current && open(this.current),
},
{
group: "List",
hidden: true,
keys: "o",
label: "Open row",
run: () => this.current && open(this.current),
}
);
}
if (edit) {
list.push({
group: "List",
keys: "e",
label: "Edit row",
run: () => this.current && edit(this.current),
});
}
if (selectable) {
list.push(
{
group: "Selection",
keys: "x",
label: "Select row",
run: () => this.toggle(),
},
{
group: "Selection",
hidden: true,
keys: "Space",
label: "Select row",
run: () => this.toggle(),
},
{
group: "Selection",
keys: "J",
label: "Extend selection down",
run: () => this.extend(1),
},
{
group: "Selection",
keys: "K",
label: "Extend selection up",
run: () => this.extend(-1),
},
{
group: "Selection",
keys: "*",
label: "Select all / none",
run: () => this.selectAll(),
},
{
group: "Selection",
keys: "u",
label: "Clear selection",
run: () => this.clear(),
}
);
}
return [...list, ...(this.options.extra?.() ?? [])];
}
use() {
useKeys(() => this.bindings());
}
}
+197
View File
@@ -0,0 +1,197 @@
import ArrowLeftRight from "@lucide/svelte/icons/arrow-left-right";
import CalendarClock from "@lucide/svelte/icons/calendar-clock";
import ChartColumn from "@lucide/svelte/icons/chart-column";
import ChartPie from "@lucide/svelte/icons/chart-pie";
import Cpu from "@lucide/svelte/icons/cpu";
import CreditCard from "@lucide/svelte/icons/credit-card";
import LayoutDashboard from "@lucide/svelte/icons/layout-dashboard";
import PiggyBank from "@lucide/svelte/icons/piggy-bank";
import SlidersHorizontal from "@lucide/svelte/icons/sliders-horizontal";
import Tags from "@lucide/svelte/icons/tags";
import Upload from "@lucide/svelte/icons/upload";
import type { Component } from "svelte";
export interface NavLink {
href: string;
keys?: string;
label: string;
match?: string[];
old?: boolean;
}
export type NavItem = NavLink & {
icon: Component;
children?: NavLink[];
};
export interface NavSection {
items: NavItem[];
title?: string;
}
export const NAV: NavSection[] = [
{
items: [
{
href: "/",
icon: LayoutDashboard,
keys: "g d",
label: "Dashboard",
match: ["/", "/home"],
},
],
},
{
items: [
{ href: "/budgets", icon: ChartPie, keys: "g b", label: "Budgets" },
{
href: "/subscriptions",
icon: CalendarClock,
keys: "g s",
label: "Subscriptions",
match: ["/subscriptions", "/bills"],
},
{
href: "/piggy-banks",
icon: PiggyBank,
keys: "g p",
label: "Piggy banks",
},
],
title: "Financial control",
},
{
items: [
{
children: [
{
href: "/transactions/all",
keys: "g t",
label: "All transactions",
},
{
href: "/transactions/withdrawal",
keys: "g w",
label: "Expenses",
},
{
href: "/transactions/deposit",
keys: "g i",
label: "Revenue / income",
},
{
href: "/transactions/transfers",
keys: "g x",
label: "Transfers",
match: ["/transactions/transfers", "/transactions/transfer"],
},
],
href: "/transactions/all",
icon: ArrowLeftRight,
label: "Transactions",
match: ["/transactions"],
},
{
children: [
{ href: "/rules", label: "Rules", match: ["/rules", "/rule-groups"] },
{ href: "/recurring", label: "Recurring transactions" },
{ href: "/webhooks/index", label: "Webhooks", match: ["/webhooks"] },
],
href: "/rules",
icon: Cpu,
label: "Automation",
match: ["/rules", "/rule-groups", "/recurring", "/webhooks"],
},
],
title: "Accounting",
},
{
items: [
{
children: [
{ href: "/accounts/asset", keys: "g a", label: "Asset accounts" },
{ href: "/accounts/expense", keys: "g e", label: "Expense accounts" },
{ href: "/accounts/revenue", keys: "g v", label: "Revenue accounts" },
{ href: "/accounts/liabilities", keys: "g l", label: "Liabilities" },
],
href: "/accounts/asset",
icon: CreditCard,
label: "Accounts",
match: ["/accounts", "/inactive-accounts"],
},
{
children: [
{ href: "/categories", keys: "g c", label: "Categories" },
{ href: "/tags", keys: "g m", label: "Tags" },
{ href: "/groups", keys: "g o", label: "Groups" },
],
href: "/categories",
icon: Tags,
label: "Classification",
match: ["/categories", "/tags", "/groups"],
},
{ href: "/reports", icon: ChartColumn, keys: "g r", label: "Reports" },
{ href: "/export", icon: Upload, label: "Export data" },
{
children: [
{ href: "/profile", label: "Profile", old: true },
{
href: "/profile/oauth",
label: "Remote access and tokens",
old: true,
},
{ href: "/preferences", keys: "g ,", label: "Preferences" },
{ href: "/currencies", label: "Currencies" },
{ href: "/exchange-rates", label: "Exchange rates" },
{ href: "/administrations", label: "Financial administrations" },
{ href: "/settings", label: "System settings", old: true },
],
href: "/preferences",
icon: SlidersHorizontal,
label: "Options",
match: [
"/profile",
"/preferences",
"/currencies",
"/exchange-rates",
"/administrations",
"/settings",
],
},
],
title: "Others",
},
];
export const CREATE_LINKS: NavLink[] = [
{ href: "/transactions/create/withdrawal", label: "New withdrawal" },
{ href: "/transactions/create/deposit", label: "New deposit" },
{ href: "/transactions/create/transfer", label: "New transfer" },
{ href: "/accounts/create/asset", label: "New asset account" },
{ href: "/accounts/create/expense", label: "New expense account" },
{ href: "/accounts/create/revenue", label: "New revenue account" },
{ href: "/accounts/create/liabilities", label: "New liability" },
{ href: "/budgets/create", label: "New budget" },
{ href: "/categories/create", label: "New category" },
{ href: "/piggy-banks/create", label: "New piggy bank" },
{ href: "/subscriptions/create", label: "New subscription" },
{ href: "/rules/create", label: "New rule", old: true },
{ href: "/recurring/create", label: "New recurring transaction", old: true },
];
export function allLinks(): NavLink[] {
return NAV.flatMap((section) =>
section.items.flatMap((item) =>
item.children ? item.children : [item as NavLink]
)
);
}
export function isActive(link: NavLink, pathname: string): boolean {
const prefixes = link.match ?? [link.href];
return prefixes.some((prefix) =>
prefix === "/"
? pathname === "/"
: pathname === prefix || pathname.startsWith(`${prefix}/`)
);
}
+130
View File
@@ -0,0 +1,130 @@
import {
addDays,
isDynamic,
periodOf,
type Range,
shiftPeriod,
today,
type ViewRange,
} from "#lib/dates.js";
import { formatDate, formatRange } from "#lib/format.js";
const STORAGE_KEY = "ffui.period";
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
export interface Preset {
label: string;
range: Range;
}
function stored(): Range | null {
try {
const value = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "null");
return ISO_DATE.test(value?.start) && ISO_DATE.test(value?.end)
? value
: null;
} catch {
return null;
}
}
export function rangeFromUrl(url: {
searchParams: { get: (name: string) => string | null };
}): Range | null {
const start = url.searchParams.get("start") ?? "";
const end = url.searchParams.get("end") ?? "";
return ISO_DATE.test(start) && ISO_DATE.test(end) && start <= end
? { end, start }
: null;
}
export function periodLabel(range: Range, view: ViewRange): string {
const aligned = periodOf(range.start, isDynamic(view) ? "1M" : view);
if (aligned.start === range.start && aligned.end === range.end) {
if (view === "1M" || isDynamic(view)) {
return formatDate(range.start, "month");
}
if (view === "1Y") {
return formatDate(range.start, "year");
}
}
return formatRange(range.start, range.end);
}
class Period {
view = $state<ViewRange>("1M");
start = $state(today());
end = $state(today());
first = $state<string | null>(null);
ready = $state(false);
range = $derived<Range>({ end: this.end, start: this.start });
label = $derived(periodLabel(this.range, this.view));
isCurrent = $derived(this.start <= today() && today() <= this.end);
init(view: ViewRange, url: Parameters<typeof rangeFromUrl>[0]) {
this.view = view;
const range = rangeFromUrl(url) ?? stored() ?? periodOf(today(), view);
this.start = range.start;
this.end = range.end;
this.ready = true;
}
set(range: Range) {
if (range.start === this.start && range.end === this.end) {
return;
}
this.start = range.start;
this.end = range.end;
localStorage.setItem(STORAGE_KEY, JSON.stringify(range));
}
shift(dir: 1 | -1) {
this.set(shiftPeriod(this.range, this.view, dir));
}
reset() {
this.set(periodOf(today(), this.view));
}
presets(): Preset[] {
const now = today();
const { view } = this;
const presets: Preset[] = [];
if (!isDynamic(view)) {
const prev = shiftPeriod(this.range, view, -1);
const next = shiftPeriod(this.range, view, 1);
presets.push({ label: periodLabel(prev, view), range: prev });
presets.push({ label: periodLabel(next, view), range: next });
}
const current = periodOf(now, view);
presets.push({
label: `Today (${periodLabel(current, view)})`,
range: current,
});
presets.push({
label: "Last seven days",
range: { end: now, start: addDays(now, -7) },
});
presets.push({
label: "Last thirty days",
range: { end: now, start: addDays(now, -30) },
});
presets.push({ label: "Month to date", range: periodOf(now, "MTD") });
presets.push({ label: "Year to date", range: periodOf(now, "YTD") });
const year = Number(now.slice(0, 4)) - 1;
presets.push({
label: `Previous year (${year})`,
range: { end: `${year}-12-31`, start: `${year}-01-01` },
});
if (this.first) {
presets.push({
label: "Everything",
range: { end: now, start: this.first },
});
}
return presets;
}
}
export const period = new Period();
+122
View File
@@ -0,0 +1,122 @@
import {
api,
keepAlive,
leaveTo,
ping,
takeReturnPath,
} from "#lib/api/client.js";
import type {
Currency,
Item,
Page,
Preference,
SystemInfo,
} from "#lib/api/types.js";
import type { ViewRange } from "#lib/dates.js";
import { configureFormat } from "#lib/format.js";
export const TESTED_VERSION = "6.7.9";
type About = SystemInfo;
interface User {
data: { attributes: { email: string } };
}
function toLocale(locale: unknown, language: unknown): string {
const raw = locale && locale !== "equal" ? locale : language;
return typeof raw === "string" && raw ? raw.replace("_", "-") : "en-GB";
}
const VERSION_PREFIX = /^v/;
const VERSION_SPLIT = /[.-]/;
const versionParts = (value: string) =>
value.replace(VERSION_PREFIX, "").split(VERSION_SPLIT).map(Number);
export function newer(a: string, b: string): boolean {
const pa = versionParts(a);
const pb = versionParts(b);
for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) {
const diff = (pa[i] || 0) - (pb[i] || 0);
if (diff !== 0) {
return diff > 0;
}
}
return false;
}
class Session {
ready = $state(false);
failed = $state("");
version = $state("");
apiVersion = $state("");
email = $state("");
prefs = $state<Record<string, unknown>>({});
currencies = $state<Item<Currency>[]>([]);
locale = $derived(toLocale(this.prefs.locale, this.prefs.language));
viewRange = $derived((this.prefs.viewRange as ViewRange) ?? "1M");
pageSize = $derived(Number(this.prefs.listPageSize) || 50);
anonymous = $derived(this.prefs.anonymous === true);
frontpageAccounts = $derived(
((this.prefs.frontpageAccounts ?? []) as (number | string)[]).map(String)
);
primary = $derived(
this.currencies.find((c) => c.attributes.primary)?.attributes ??
this.currencies[0]?.attributes
);
drift = $derived(
this.apiVersion !== "" && newer(this.apiVersion, TESTED_VERSION)
);
async loadPreferences() {
const page = await api<Page<Preference>>("/v1/preferences", {
query: { limit: 500 },
});
const prefs: Record<string, unknown> = {};
for (const item of page.data) {
prefs[item.attributes.name] = item.attributes.data;
}
this.prefs = prefs;
configureFormat(this.locale, this.anonymous);
}
async loadCurrencies() {
const page = await api<Page<Currency>>("/v1/currencies", {
query: { limit: 500 },
});
this.currencies = page.data;
}
async setPreference(name: string, data: unknown) {
const exists = name in this.prefs;
await api(exists ? `/v1/preferences/${name}` : "/v1/preferences", {
body: exists ? { data } : { data, name },
method: exists ? "PUT" : "POST",
});
this.prefs = { ...this.prefs, [name]: data };
configureFormat(this.locale, this.anonymous);
}
async boot(): Promise<string | null> {
const state = await ping();
if (state !== "ok") {
leaveTo(state);
return null;
}
const [about, user] = await Promise.all([
api<About>("/v1/about"),
api<User>("/v1/about/user"),
this.loadPreferences(),
this.loadCurrencies(),
]);
this.version = about.data?.version ?? "";
this.apiVersion = about.data?.api_version ?? "";
this.email = user.data.attributes.email;
this.ready = true;
keepAlive();
return takeReturnPath();
}
}
export const session = new Session();
+75
View File
@@ -0,0 +1,75 @@
export const SPA_EXACT = [
"/",
"/home",
"/accounts",
"/budgets",
"/categories",
"/tags",
"/bills",
"/subscriptions",
"/piggy-banks",
"/groups",
"/reports",
"/search",
"/currencies",
"/exchange-rates",
"/attachments",
"/preferences",
"/export",
"/administrations",
"/recurring",
"/webhooks/index",
"/settings/links",
];
export const SPA_PREFIXES = [
"/accounts/",
"/inactive-accounts/",
"/transactions/",
"/budgets/",
"/categories/",
"/tags/",
"/bills/",
"/subscriptions/",
"/piggy-banks/",
"/groups/",
"/reports/",
"/currencies/",
"/exchange-rates/",
"/attachments/",
"/administrations/",
"/recurring/show/",
"/webhooks/show/",
"/settings/links/show/",
];
export const FIREFLY_PREFIXES = [
"/accounts/reconcile/",
"/attachments/download/",
"/attachments/view/",
"/transactions/convert/",
"/transactions/debug/",
"/transactions/link/",
"/transactions/mass/",
"/transactions/bulk/",
"/transactions/show-by-journal/",
"/piggy-banks/add/",
"/piggy-banks/remove/",
"/reports/options/",
];
export function isSpaPath(pathname: string): boolean {
if (FIREFLY_PREFIXES.some((prefix) => pathname.startsWith(prefix))) {
return false;
}
return (
SPA_EXACT.includes(pathname) ||
SPA_PREFIXES.some((prefix) => pathname.startsWith(prefix))
);
}
export function oldUiUrl(href: string, sticky = false): string {
const target = new URL(href);
target.searchParams.set("ffui", sticky ? "off" : "old");
return target.pathname + target.search + target.hash;
}
+28
View File
@@ -0,0 +1,28 @@
const SIDEBAR_KEY = "ffui.sidebar.collapsed";
class Ui {
palette = $state(false);
paletteQuery = $state("");
quickEntry = $state(false);
mobileNav = $state(false);
collapsed = $state(
typeof localStorage !== "undefined" &&
localStorage.getItem(SIDEBAR_KEY) === "1"
);
openPalette(query = "") {
this.paletteQuery = query;
this.palette = true;
}
toggleSidebar() {
if (window.matchMedia("(max-width: 1023px)").matches) {
this.mobileNav = !this.mobileNav;
return;
}
this.collapsed = !this.collapsed;
localStorage.setItem(SIDEBAR_KEY, this.collapsed ? "1" : "0");
}
}
export const ui = new Ui();
+19
View File
@@ -0,0 +1,19 @@
import { defineParams } from "@sveltejs/kit/params";
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
const ID = /^\d+$/;
const ACCOUNT_TYPES = new Set(["asset", "expense", "revenue", "liabilities"]);
const TRANSACTION_TYPES = new Set([
"withdrawal",
"deposit",
"transfers",
"transfer",
"all",
]);
export const params = defineParams({
accounttype: (value) => (ACCOUNT_TYPES.has(value) ? value : undefined),
date: (value) => (ISO_DATE.test(value) ? value : undefined),
id: (value) => (ID.test(value) ? value : undefined),
txtype: (value) => (TRANSACTION_TYPES.has(value) ? value : undefined),
});
+84 -1
View File
@@ -1,10 +1,93 @@
<script lang="ts"> <script lang="ts">
import "./layout.css"; import "./layout.css";
import { QueryClientProvider } from "@tanstack/svelte-query";
import { ModeWatcher } from "mode-watcher"; import { ModeWatcher } from "mode-watcher";
import { onMount, untrack } from "svelte";
import { queryClient } from "#lib/api/lists.js";
import QuickEntry from "#lib/components/QuickEntry.svelte";
import Shell from "#lib/components/shell/Shell.svelte";
import { keys } from "#lib/keys.svelte.js";
import { period, rangeFromUrl } from "#lib/period.svelte.js";
import { session } from "#lib/session.svelte.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
let { children } = $props(); let { children } = $props();
let lastHref = "";
onMount(async () => {
try {
const back = await session.boot();
period.init(session.viewRange, page.url);
if (back) {
await goto(back, { replace: true });
}
} catch (error) {
session.failed = error instanceof Error ? error.message : String(error);
}
});
$effect(() => {
if (!period.ready) {
return;
}
const url = page.url;
const start = period.start;
const end = period.end;
untrack(() => {
const fromUrl = rangeFromUrl(url);
if (url.href !== lastHref && fromUrl) {
lastHref = url.href;
period.set(fromUrl);
return;
}
lastHref = url.href;
if (fromUrl?.start === start && fromUrl?.end === end) {
return;
}
const next = new URL(url.href);
next.searchParams.set("start", start);
next.searchParams.set("end", end);
lastHref = next.href;
goto(next, { replace: true, shallow: true, state: page.state });
});
});
</script> </script>
<ModeWatcher /> <ModeWatcher />
<svelte:window onkeydown={keys.handle} />
{@render children()} <QueryClientProvider client={queryClient}>
{#if session.ready}
<Shell>
{@render children()}
{#snippet quick()}
<QuickEntry />
{/snippet}
</Shell>
{:else if session.failed}
<main class="grid min-h-dvh place-items-center p-6 text-center">
<div class="flex max-w-md flex-col gap-3">
<h1 class="font-semibold text-[18px]">Firefly III did not answer</h1>
<p class="text-cladd-fg-soft">{session.failed}</p>
<p class="flex justify-center gap-4">
<a class="text-brand underline" href={page.url.pathname}>Try again</a>
<a class="text-brand underline" data-sveltekit-reload href="/login"
>Log in</a
>
<a
class="text-brand underline"
data-sveltekit-reload
href={`${page.url.pathname}?ffui=old`}
>Open the old interface</a
>
</p>
</div>
</main>
{:else}
<main class="grid min-h-dvh place-items-center text-cladd-fg-soft">
<span>Connecting to Firefly III</span>
</main>
{/if}
</QueryClientProvider>
+4 -12
View File
@@ -1,13 +1,5 @@
<svelte:head> <script lang="ts">
<title>Firefly UI</title> import PageHeader from "#lib/components/shell/PageHeader.svelte";
</svelte:head> </script>
<main <PageHeader subtitle="What's playing?" title="Dashboard" />
class="flex min-h-dvh flex-col items-center justify-center gap-4 px-6 text-center bg-background text-foreground"
>
<h1 class="font-bold text-4xl tracking-tight sm:text-5xl">Firefly UI</h1>
<p class="max-w-prose text-base text-muted-foreground">
Edit
<code class="font-mono">src/routes/+page.svelte</code>.
</p>
</main>
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "@sveltejs/kit";
export function load() {
redirect(307, "/");
}
+100
View File
@@ -5,11 +5,111 @@
@import "./claddcn.css"; @import "./claddcn.css";
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
:root {
--ff-brand: oklch(0.57 0.105 240);
--ff-brand-deep: oklch(0.5 0.1 240);
--ff-on-brand: oklch(0.99 0.01 240);
--ff-income: oklch(0.5 0.12 145);
--ff-expense: oklch(0.52 0.15 28);
--ff-transfer: oklch(0.5 0.085 225);
--ff-warn: oklch(0.62 0.14 70);
--ff-row-hover: oklch(0.96 0.012 240);
--ff-row-cursor: oklch(0.93 0.03 240);
--ff-row-selected: oklch(0.9 0.05 240);
--ff-series-1: oklch(0.57 0.105 240);
--ff-series-2: oklch(0.62 0.13 160);
--ff-series-3: oklch(0.7 0.14 70);
--ff-series-4: oklch(0.58 0.15 20);
--ff-series-5: oklch(0.55 0.12 300);
--ff-series-6: oklch(0.65 0.1 200);
--ff-series-7: oklch(0.6 0.13 110);
--ff-series-8: oklch(0.6 0.08 260);
}
.cladd-color-brand {
--cladd-theme: #3c8dbc;
}
.dark {
--ff-brand: oklch(0.42 0.085 240);
--ff-brand-deep: oklch(0.36 0.075 240);
--ff-on-brand: oklch(0.97 0.01 240);
--ff-income: oklch(0.78 0.15 140);
--ff-expense: oklch(0.76 0.1 35);
--ff-transfer: oklch(0.76 0.08 220);
--ff-warn: oklch(0.8 0.13 80);
--ff-row-hover: oklch(0.25 0.012 240);
--ff-row-cursor: oklch(0.29 0.03 240);
--ff-row-selected: oklch(0.33 0.06 240);
--ff-series-1: oklch(0.7 0.1 240);
--ff-series-2: oklch(0.72 0.12 160);
--ff-series-3: oklch(0.8 0.13 75);
--ff-series-4: oklch(0.7 0.13 25);
--ff-series-5: oklch(0.7 0.11 300);
--ff-series-6: oklch(0.75 0.09 200);
--ff-series-7: oklch(0.75 0.12 115);
--ff-series-8: oklch(0.72 0.07 260);
}
@theme inline {
--color-brand: var(--ff-brand);
--color-brand-deep: var(--ff-brand-deep);
--color-on-brand: var(--ff-on-brand);
--color-income: var(--ff-income);
--color-expense: var(--ff-expense);
--color-transfer: var(--ff-transfer);
--color-warn: var(--ff-warn);
--color-row-hover: var(--ff-row-hover);
--color-row-cursor: var(--ff-row-cursor);
--color-row-selected: var(--ff-row-selected);
}
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
} }
html {
scrollbar-color: var(--cladd-fg-softest) transparent;
}
body { body {
font-size: 13px;
line-height: 1.45;
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
::selection {
background: color-mix(in oklch, var(--ff-brand) 28%, transparent);
}
:focus-visible {
outline: 1.5px solid var(--ff-brand);
outline-offset: 1px;
}
.dark :focus-visible {
outline-color: var(--ff-series-1);
}
a {
text-underline-offset: 3px;
}
}
@utility num {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
white-space: nowrap;
}
@utility kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.35rem;
height: 1.25rem;
padding: 0 0.3rem;
font-family: var(--font-mono);
font-size: 11px;
line-height: 1;
color: var(--cladd-fg-soft);
background: var(--cladd-surface-cut);
border-radius: 4px;
box-shadow: inset 0 -1px 0 var(--cladd-outline);
} }
+44
View File
@@ -0,0 +1,44 @@
name: ffui-stand
x-firefly-env: &firefly-env
APP_ENV: local
APP_DEBUG: "false"
SITE_OWNER: stand@ffui.local
DB_CONNECTION: sqlite
TZ: Europe/Warsaw
TRUSTED_PROXIES: "**"
DEFAULT_LANGUAGE: en_US
DEFAULT_LOCALE: en_GB
STATIC_CRON_TOKEN: ffuistandcronffuistandcron123456
services:
v1:
image: fireflyiii/core:version-6.6.6
environment:
<<: *firefly-env
APP_KEY: ffuistandv1ffuistandv1ffuistand1
APP_URL: http://localhost:${FFUI_V1_PORT:-30072}
FIREFLY_III_LAYOUT: v1
volumes:
- v1_db:/var/www/html/storage/database
- v1_upload:/var/www/html/storage/upload
ports:
- "127.0.0.1:${FFUI_V1_PORT:-30072}:8080"
v3:
image: fireflyiii/core:latest
environment:
<<: *firefly-env
APP_KEY: ffuistandv3ffuistandv3ffuistand3
APP_URL: http://localhost:${FFUI_V3_PORT:-40869}
volumes:
- v3_db:/var/www/html/storage/database
- v3_upload:/var/www/html/storage/upload
ports:
- "127.0.0.1:${FFUI_V3_PORT:-40869}:8080"
volumes:
v1_db:
v1_upload:
v3_db:
v3_upload:
+21 -5
View File
@@ -2,13 +2,19 @@ import adapter from "@sveltejs/adapter-static";
import { sveltekit } from "@sveltejs/kit/vite"; import { sveltekit } from "@sveltejs/kit/vite";
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite"; import { defineConfig, loadEnv } from "vite";
import { isSpaPath } from "./src/lib/spa-paths";
const allowedHosts = process.env.ALLOWED_HOSTS const allowedHosts = process.env.ALLOWED_HOSTS
? process.env.ALLOWED_HOSTS.split(",") ? process.env.ALLOWED_HOSTS.split(",")
: undefined; : undefined;
export default defineConfig({ const VITE_OWNED = "^/(?!@|src/|node_modules/|\\.svelte-kit/|__|_app/|favicon)";
export default defineConfig(({ mode }) => {
const env = { ...loadEnv(mode, process.cwd(), ""), ...process.env };
const firefly = env.FIREFLY_URL ?? "http://127.0.0.1:8080";
return {
plugins: [ plugins: [
tailwindcss(), tailwindcss(),
sveltekit({ sveltekit({
@@ -25,10 +31,20 @@ export default defineConfig({
server: { server: {
allowedHosts, allowedHosts,
proxy: { proxy: {
"/api": { [VITE_OWNED]: {
changeOrigin: true, bypass(req) {
target: process.env.API_PROXY_TARGET ?? "http://localhost:8080", const url = new URL(req.url ?? "/", "http://dev.local");
const ffui = url.searchParams.get("ffui");
const read = req.method === "GET" || req.method === "HEAD";
const old = ffui === "old" || ffui === "off";
return read && !old && isSpaPath(url.pathname)
? req.url
: undefined;
},
target: firefly,
xfwd: true,
}, },
}, },
}, },
};
}); });