feat(shell,api,keys): firefly v1 shell over the api with cookie auth, period, palette and keymap
This commit is contained in:
1 parent
ae4de7911b
commit
c74dac3e00
37 files changed
+58163
-79
No files matched your search
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
+26561
File diff suppressed because it is too large.
Load diff
+7
-1
@@ -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",
|
||||||
|
|||||||
@@ -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=="],
|
||||||
|
|||||||
@@ -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
@@ -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),
|
|
||||||
};
|
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
Vendored
+28401
File diff suppressed because it is too large.
Load diff
@@ -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";
|
||||||
@@ -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
|
||||||
|
>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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 />
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 ? "" : "+")
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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}/`)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -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),
|
||||||
|
});
|
||||||
@@ -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
@@ -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>
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "@sveltejs/kit";
|
||||||
|
|
||||||
|
export function load() {
|
||||||
|
redirect(307, "/");
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -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:
|
||||||
+37
-21
@@ -2,33 +2,49 @@ 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)";
|
||||||
plugins: [
|
|
||||||
tailwindcss(),
|
export default defineConfig(({ mode }) => {
|
||||||
sveltekit({
|
const env = { ...loadEnv(mode, process.cwd(), ""), ...process.env };
|
||||||
adapter: adapter({
|
const firefly = env.FIREFLY_URL ?? "http://127.0.0.1:8080";
|
||||||
assets: "build",
|
return {
|
||||||
fallback: "index.html",
|
plugins: [
|
||||||
pages: "build",
|
tailwindcss(),
|
||||||
precompress: false,
|
sveltekit({
|
||||||
strict: false,
|
adapter: adapter({
|
||||||
|
assets: "build",
|
||||||
|
fallback: "index.html",
|
||||||
|
pages: "build",
|
||||||
|
precompress: false,
|
||||||
|
strict: false,
|
||||||
|
}),
|
||||||
|
preprocess: [vitePreprocess()],
|
||||||
}),
|
}),
|
||||||
preprocess: [vitePreprocess()],
|
],
|
||||||
}),
|
server: {
|
||||||
],
|
allowedHosts,
|
||||||
server: {
|
proxy: {
|
||||||
allowedHosts,
|
[VITE_OWNED]: {
|
||||||
proxy: {
|
bypass(req) {
|
||||||
"/api": {
|
const url = new URL(req.url ?? "/", "http://dev.local");
|
||||||
changeOrigin: true,
|
const ffui = url.searchParams.get("ffui");
|
||||||
target: process.env.API_PROXY_TARGET ?? "http://localhost:8080",
|
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,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
};
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user