inital commit
This commit is contained in:
commit
51fb6d2b4b
37 changed files with 2168 additions and 0 deletions
6
web/.dockerignore
Normal file
6
web/.dockerignore
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
node_modules
|
||||||
|
.svelte-kit
|
||||||
|
build
|
||||||
|
coverage
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
23
web/.gitignore
vendored
Normal file
23
web/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# Output
|
||||||
|
.output
|
||||||
|
.vercel
|
||||||
|
.netlify
|
||||||
|
.wrangler
|
||||||
|
/.svelte-kit
|
||||||
|
/build
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.test
|
||||||
|
|
||||||
|
# Vite
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
1
web/.npmrc
Normal file
1
web/.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
engine-strict=true
|
||||||
9
web/.prettierignore
Normal file
9
web/.prettierignore
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
# Package Managers
|
||||||
|
package-lock.json
|
||||||
|
pnpm-lock.yaml
|
||||||
|
yarn.lock
|
||||||
|
bun.lock
|
||||||
|
bun.lockb
|
||||||
|
|
||||||
|
# Miscellaneous
|
||||||
|
/static/
|
||||||
16
web/.prettierrc
Normal file
16
web/.prettierrc
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"useTabs": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "none",
|
||||||
|
"printWidth": 100,
|
||||||
|
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.svelte",
|
||||||
|
"options": {
|
||||||
|
"parser": "svelte"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tailwindStylesheet": "./src/app.css"
|
||||||
|
}
|
||||||
18
web/Dockerfile
Normal file
18
web/Dockerfile
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json ./
|
||||||
|
RUN npm install --no-save
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
RUN npm install --omit=dev --prefix build
|
||||||
|
|
||||||
|
FROM node:20-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
COPY --from=builder /app/build ./build
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "build/index.js"]
|
||||||
38
web/README.md
Normal file
38
web/README.md
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
# sv
|
||||||
|
|
||||||
|
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||||
|
|
||||||
|
## Creating a project
|
||||||
|
|
||||||
|
If you're seeing this, you've probably already done this step. Congrats!
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# create a new project in the current directory
|
||||||
|
npx sv create
|
||||||
|
|
||||||
|
# create a new project in my-app
|
||||||
|
npx sv create my-app
|
||||||
|
```
|
||||||
|
|
||||||
|
## Developing
|
||||||
|
|
||||||
|
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# or start the server and open the app in a new browser tab
|
||||||
|
npm run dev -- --open
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
To create a production version of your app:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
You can preview the production build with `npm run preview`.
|
||||||
|
|
||||||
|
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||||
351
web/bun.lock
Normal file
351
web/bun.lock
Normal file
|
|
@ -0,0 +1,351 @@
|
||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "frontend",
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/adapter-node": "^5.0.0",
|
||||||
|
"@sveltejs/kit": "^2.22.0",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
||||||
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"prettier": "^3.4.2",
|
||||||
|
"prettier-plugin-svelte": "^3.3.3",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||||
|
"svelte": "^5.0.0",
|
||||||
|
"svelte-check": "^4.0.0",
|
||||||
|
"tailwindcss": "^4.0.0",
|
||||||
|
"typescript": "^5.0.0",
|
||||||
|
"vite": "^7.0.4",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.10", "", { "os": "aix", "cpu": "ppc64" }, "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.10", "", { "os": "android", "cpu": "arm" }, "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.10", "", { "os": "android", "cpu": "arm64" }, "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg=="],
|
||||||
|
|
||||||
|
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.10", "", { "os": "android", "cpu": "x64" }, "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.10", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.10", "", { "os": "freebsd", "cpu": "x64" }, "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.10", "", { "os": "linux", "cpu": "arm" }, "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.10", "", { "os": "linux", "cpu": "ia32" }, "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.10", "", { "os": "linux", "cpu": "none" }, "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.10", "", { "os": "linux", "cpu": "none" }, "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.10", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.10", "", { "os": "linux", "cpu": "none" }, "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.10", "", { "os": "linux", "cpu": "s390x" }, "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.10", "", { "os": "linux", "cpu": "x64" }, "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA=="],
|
||||||
|
|
||||||
|
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.10", "", { "os": "none", "cpu": "arm64" }, "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A=="],
|
||||||
|
|
||||||
|
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.10", "", { "os": "none", "cpu": "x64" }, "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig=="],
|
||||||
|
|
||||||
|
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.10", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw=="],
|
||||||
|
|
||||||
|
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.10", "", { "os": "openbsd", "cpu": "x64" }, "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw=="],
|
||||||
|
|
||||||
|
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.10", "", { "os": "none", "cpu": "arm64" }, "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag=="],
|
||||||
|
|
||||||
|
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.10", "", { "os": "sunos", "cpu": "x64" }, "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.10", "", { "os": "win32", "cpu": "ia32" }, "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.10", "", { "os": "win32", "cpu": "x64" }, "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw=="],
|
||||||
|
|
||||||
|
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
|
||||||
|
|
||||||
|
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||||
|
|
||||||
|
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||||
|
|
||||||
|
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||||
|
|
||||||
|
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||||
|
|
||||||
|
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||||
|
|
||||||
|
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
|
||||||
|
|
||||||
|
"@rollup/plugin-commonjs": ["@rollup/plugin-commonjs@28.0.6", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", "estree-walker": "^2.0.2", "fdir": "^6.2.0", "is-reference": "1.2.1", "magic-string": "^0.30.3", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^2.68.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-XSQB1K7FUU5QP+3lOQmVCE3I0FcbbNvmNT4VJSj93iUjayaARrTQeoRdiYQoftAJBLrR9t2agwAd3ekaTgHNlw=="],
|
||||||
|
|
||||||
|
"@rollup/plugin-json": ["@rollup/plugin-json@6.1.0", "", { "dependencies": { "@rollup/pluginutils": "^5.1.0" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA=="],
|
||||||
|
|
||||||
|
"@rollup/plugin-node-resolve": ["@rollup/plugin-node-resolve@16.0.1", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "@types/resolve": "1.20.2", "deepmerge": "^4.2.2", "is-module": "^1.0.0", "resolve": "^1.22.1" }, "peerDependencies": { "rollup": "^2.78.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-tk5YCxJWIG81umIvNkSod2qK5KyQW19qcBF/B78n1bjtOON6gzKoVeSzAE8yHCZEDmqkHKkxplExA8KzdJLJpA=="],
|
||||||
|
|
||||||
|
"@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.52.0", "", { "os": "android", "cpu": "arm" }, "sha512-VxDYCDqOaR7NXzAtvRx7G1u54d2kEHopb28YH/pKzY6y0qmogP3gG7CSiWsq9WvDFxOQMpNEyjVAHZFXfH3o/A=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.52.0", "", { "os": "android", "cpu": "arm64" }, "sha512-pqDirm8koABIKvzL59YI9W9DWbRlTX7RWhN+auR8HXJxo89m4mjqbah7nJZjeKNTNYopqL+yGg+0mhCpf3xZtQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.52.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YCdWlY/8ltN6H78HnMsRHYlPiKvqKagBP1r+D7SSylxX+HnsgXGCmLiV3Y4nSyY9hW8qr8U9LDUx/Lo7M6MfmQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.52.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-z4nw6y1j+OOSGzuVbSWdIp1IUks9qNw4dc7z7lWuWDKojY38VMWBlEN7F9jk5UXOkUcp97vA1N213DF+Lz8BRg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.52.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-Q/dv9Yvyr5rKlK8WQJZVrp5g2SOYeZUs9u/t2f9cQ2E0gJjYB/BWoedXfUT0EcDJefi2zzVfhcOj8drWCzTviw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.52.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-kdBsLs4Uile/fbjZVvCRcKB4q64R+1mUq0Yd7oU1CMm1Av336ajIFqNFovByipciuUQjBCPMxwJhCgfG2re3rg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.52.0", "", { "os": "linux", "cpu": "arm" }, "sha512-aL6hRwu0k7MTUESgkg7QHY6CoqPgr6gdQXRJI1/VbFlUMwsSzPGSR7sG5d+MCbYnJmJwThc2ol3nixj1fvI/zQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.52.0", "", { "os": "linux", "cpu": "arm" }, "sha512-BTs0M5s1EJejgIBJhCeiFo7GZZ2IXWkFGcyZhxX4+8usnIo5Mti57108vjXFIQmmJaRyDwmV59Tw64Ap1dkwMw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.52.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-uj672IVOU9m08DBGvoPKPi/J8jlVgjh12C9GmjjBxCTQc3XtVmRkRKyeHSmIKQpvJ7fIm1EJieBUcnGSzDVFyw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.52.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-/+IVbeDMDCtB/HP/wiWsSzduD10SEGzIZX2945KSgZRNi4TSkjHqRJtNTVtVb8IRwhJ65ssI56krlLik+zFWkw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.52.0", "", { "os": "linux", "cpu": "none" }, "sha512-U1vVzvSWtSMWKKrGoROPBXMh3Vwn93TA9V35PldokHGqiUbF6erSzox/5qrSMKp6SzakvyjcPiVF8yB1xKr9Pg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.52.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-X/4WfuBAdQRH8cK3DYl8zC00XEE6aM472W+QCycpQJeLWVnHfkv7RyBFVaTqNUMsTgIX8ihMjCvFF9OUgeABzw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.52.0", "", { "os": "linux", "cpu": "none" }, "sha512-xIRYc58HfWDBZoLmWfWXg2Sq8VCa2iJ32B7mqfWnkx5mekekl0tMe7FHpY8I72RXEcUkaWawRvl3qA55og+cwQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.52.0", "", { "os": "linux", "cpu": "none" }, "sha512-mbsoUey05WJIOz8U1WzNdf+6UMYGwE3fZZnQqsM22FZ3wh1N887HT6jAOjXs6CNEK3Ntu2OBsyQDXfIjouI4dw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.52.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-qP6aP970bucEi5KKKR4AuPFd8aTx9EF6BvutvYxmZuWLJHmnq4LvBfp0U+yFDMGwJ+AIJEH5sIP+SNypauMWzg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.52.0", "", { "os": "linux", "cpu": "x64" }, "sha512-nmSVN+F2i1yKZ7rJNKO3G7ZzmxJgoQBQZ/6c4MuS553Grmr7WqR7LLDcYG53Z2m9409z3JLt4sCOhLdbKQ3HmA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.52.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2d0qRo33G6TfQVjaMR71P+yJVGODrt5V6+T0BDYH4EMfGgdC/2HWDVjSSFw888GSzAZUwuska3+zxNUCDco6rQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.52.0", "", { "os": "none", "cpu": "arm64" }, "sha512-A1JalX4MOaFAAyGgpO7XP5khquv/7xKzLIyLmhNrbiCxWpMlnsTYr8dnsWM7sEeotNmxvSOEL7F65j0HXFcFsw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.52.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-YQugafP/rH0eOOHGjmNgDURrpYHrIX0yuojOI8bwCyXwxC9ZdTd3vYkmddPX0oHONLXu9Rb1dDmT0VNpjkzGGw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.52.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zYdUYhi3Qe2fndujBqL5FjAFzvNeLxtIqfzNEVKD1I7C37/chv1VxhscWSQHTNfjPCrBFQMnynwA3kpZpZ8w4A=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.52.0", "", { "os": "win32", "cpu": "x64" }, "sha512-fGk03kQylNaCOQ96HDMeT7E2n91EqvCDd3RwvT5k+xNdFCeMGnj5b5hEgTGrQuyidqSsD3zJDQ21QIaxXqTBJw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.52.0", "", { "os": "win32", "cpu": "x64" }, "sha512-6iKDCVSIUQ8jPMoIV0OytRKniaYyy5EbY/RRydmLW8ZR3cEBhxbWl5ro0rkUNe0ef6sScvhbY79HrjRm8i3vDQ=="],
|
||||||
|
|
||||||
|
"@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="],
|
||||||
|
|
||||||
|
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.5", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ=="],
|
||||||
|
|
||||||
|
"@sveltejs/adapter-node": ["@sveltejs/adapter-node@5.3.2", "", { "dependencies": { "@rollup/plugin-commonjs": "^28.0.1", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.0", "rollup": "^4.9.5" }, "peerDependencies": { "@sveltejs/kit": "^2.4.0" } }, "sha512-nBJSipMb1KLjnAM7uzb+YpnA1VWKb+WdR+0mXEnXI6K1A3XYWbjkcjnW20ubg07sicK8XaGY/FAX3PItw39qBQ=="],
|
||||||
|
|
||||||
|
"@sveltejs/kit": ["@sveltejs/kit@2.42.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.3.2", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["@opentelemetry/api"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-FcNICFvlSYjPiAgk8BpqTEnXkaUj6I6wDwpQBxKMpsYhUc2Q5STgsVpXOG5LqwFpUAoLAXQ4wdWul7EcAG67JQ=="],
|
||||||
|
|
||||||
|
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@6.2.0", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "debug": "^4.4.1", "deepmerge": "^4.3.1", "magic-string": "^0.30.17", "vitefu": "^1.1.1" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-nJsV36+o7rZUDlrnSduMNl11+RoDE1cKqOI0yUEBCcqFoAZOk47TwD3dPKS2WmRutke9StXnzsPBslY7prDM9w=="],
|
||||||
|
|
||||||
|
"@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@5.0.1", "", { "dependencies": { "debug": "^4.4.1" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-ubWshlMk4bc8mkwWbg6vNvCeT7lGQojE3ijDh3QTR6Zr/R+GXxsGbyH4PExEPpiFmqPhYiVSVmHBjUcVc1JIrA=="],
|
||||||
|
|
||||||
|
"@tailwindcss/node": ["@tailwindcss/node@4.1.13", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.5.1", "lightningcss": "1.30.1", "magic-string": "^0.30.18", "source-map-js": "^1.2.1", "tailwindcss": "4.1.13" } }, "sha512-eq3ouolC1oEFOAvOMOBAmfCIqZBJuvWvvYWh5h5iOYfe1HFC6+GZ6EIL0JdM3/niGRJmnrOc+8gl9/HGUaaptw=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.13", "", { "dependencies": { "detect-libc": "^2.0.4", "tar": "^7.4.3" }, "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.13", "@tailwindcss/oxide-darwin-arm64": "4.1.13", "@tailwindcss/oxide-darwin-x64": "4.1.13", "@tailwindcss/oxide-freebsd-x64": "4.1.13", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.13", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.13", "@tailwindcss/oxide-linux-arm64-musl": "4.1.13", "@tailwindcss/oxide-linux-x64-gnu": "4.1.13", "@tailwindcss/oxide-linux-x64-musl": "4.1.13", "@tailwindcss/oxide-wasm32-wasi": "4.1.13", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.13", "@tailwindcss/oxide-win32-x64-msvc": "4.1.13" } }, "sha512-CPgsM1IpGRa880sMbYmG1s4xhAy3xEt1QULgTJGQmZUeNgXFR7s1YxYygmJyBGtou4SyEosGAGEeYqY7R53bIA=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.13", "", { "os": "android", "cpu": "arm64" }, "sha512-BrpTrVYyejbgGo57yc8ieE+D6VT9GOgnNdmh5Sac6+t0m+v+sKQevpFVpwX3pBrM2qKrQwJ0c5eDbtjouY/+ew=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YP+Jksc4U0KHcu76UhRDHq9bx4qtBftp9ShK/7UGfq0wpaP96YVnnjFnj3ZFrUAjc5iECzODl/Ts0AN7ZPOANQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-aAJ3bbwrn/PQHDxCto9sxwQfT30PzyYJFG0u/BWZGeVXi5Hx6uuUOQEI2Fa43qvmUjTRQNZnGqe9t0Zntexeuw=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.13", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Wt8KvASHwSXhKE/dJLCCWcTSVmBj3xhVhp/aF3RpAhGeZ3sVo7+NTfgiN8Vey/Fi8prRClDs6/f0KXPDTZE6nQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.13", "", { "os": "linux", "cpu": "arm" }, "sha512-mbVbcAsW3Gkm2MGwA93eLtWrwajz91aXZCNSkGTx/R5eb6KpKD5q8Ueckkh9YNboU8RH7jiv+ol/I7ZyQ9H7Bw=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-wdtfkmpXiwej/yoAkrCP2DNzRXCALq9NVLgLELgLim1QpSfhQM5+ZxQQF8fkOiEpuNoKLp4nKZ6RC4kmeFH0HQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-hZQrmtLdhyqzXHB7mkXfq0IYbxegaqTmfa1p9MBj72WPoDD3oNOh1Lnxf6xZLY9C3OV6qiCYkO1i/LrzEdW2mg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.13", "", { "os": "linux", "cpu": "x64" }, "sha512-uaZTYWxSXyMWDJZNY1Ul7XkJTCBRFZ5Fo6wtjrgBKzZLoJNrG+WderJwAjPzuNZOnmdrVg260DKwXCFtJ/hWRQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.13", "", { "os": "linux", "cpu": "x64" }, "sha512-oXiPj5mi4Hdn50v5RdnuuIms0PVPI/EG4fxAfFiIKQh5TgQgX7oSuDWntHW7WNIi/yVLAiS+CRGW4RkoGSSgVQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.13", "", { "dependencies": { "@emnapi/core": "^1.4.5", "@emnapi/runtime": "^1.4.5", "@emnapi/wasi-threads": "^1.0.4", "@napi-rs/wasm-runtime": "^0.2.12", "@tybys/wasm-util": "^0.10.0", "tslib": "^2.8.0" }, "cpu": "none" }, "sha512-+LC2nNtPovtrDwBc/nqnIKYh/W2+R69FA0hgoeOn64BdCX522u19ryLh3Vf3F8W49XBcMIxSe665kwy21FkhvA=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-dziTNeQXtoQ2KBXmrjCxsuPk3F3CQ/yb7ZNZNA+UkNTeiTGgfeh+gH5Pi7mRncVgcPD2xgHvkFCh/MhZWSgyQg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.13", "", { "os": "win32", "cpu": "x64" }, "sha512-3+LKesjXydTkHk5zXX01b5KMzLV1xl2mcktBJkje7rhFUpUlYJy7IMOLqjIRQncLTa1WZZiFY/foAeB5nmaiTw=="],
|
||||||
|
|
||||||
|
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.13", "", { "dependencies": { "@tailwindcss/node": "4.1.13", "@tailwindcss/oxide": "4.1.13", "tailwindcss": "4.1.13" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-0PmqLQ010N58SbMTJ7BVJ4I2xopiQn/5i6nlb4JmxzQf8zcS5+m2Cv6tqh+sfDwtIdjoEnOvwsGQ1hkUi8QEHQ=="],
|
||||||
|
|
||||||
|
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
|
||||||
|
|
||||||
|
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||||
|
|
||||||
|
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
|
||||||
|
|
||||||
|
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||||
|
|
||||||
|
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
|
||||||
|
|
||||||
|
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
|
||||||
|
|
||||||
|
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||||
|
|
||||||
|
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
|
||||||
|
|
||||||
|
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||||
|
|
||||||
|
"commondir": ["commondir@1.0.1", "", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="],
|
||||||
|
|
||||||
|
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
|
||||||
|
|
||||||
|
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
|
|
||||||
|
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
|
||||||
|
|
||||||
|
"detect-libc": ["detect-libc@2.1.0", "", {}, "sha512-vEtk+OcP7VBRtQZ1EJ3bdgzSfBjgnEalLTp5zjJrS+2Z1w2KZly4SBdac/WDU3hhsNAZ9E8SC96ME4Ey8MZ7cg=="],
|
||||||
|
|
||||||
|
"devalue": ["devalue@5.3.2", "", {}, "sha512-UDsjUbpQn9kvm68slnrs+mfxwFkIflOhkanmyabZ8zOYk8SMEIbJ3TK+88g70hSIeytu4y18f0z/hYHMTrXIWw=="],
|
||||||
|
|
||||||
|
"enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="],
|
||||||
|
|
||||||
|
"esbuild": ["esbuild@0.25.10", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.10", "@esbuild/android-arm": "0.25.10", "@esbuild/android-arm64": "0.25.10", "@esbuild/android-x64": "0.25.10", "@esbuild/darwin-arm64": "0.25.10", "@esbuild/darwin-x64": "0.25.10", "@esbuild/freebsd-arm64": "0.25.10", "@esbuild/freebsd-x64": "0.25.10", "@esbuild/linux-arm": "0.25.10", "@esbuild/linux-arm64": "0.25.10", "@esbuild/linux-ia32": "0.25.10", "@esbuild/linux-loong64": "0.25.10", "@esbuild/linux-mips64el": "0.25.10", "@esbuild/linux-ppc64": "0.25.10", "@esbuild/linux-riscv64": "0.25.10", "@esbuild/linux-s390x": "0.25.10", "@esbuild/linux-x64": "0.25.10", "@esbuild/netbsd-arm64": "0.25.10", "@esbuild/netbsd-x64": "0.25.10", "@esbuild/openbsd-arm64": "0.25.10", "@esbuild/openbsd-x64": "0.25.10", "@esbuild/openharmony-arm64": "0.25.10", "@esbuild/sunos-x64": "0.25.10", "@esbuild/win32-arm64": "0.25.10", "@esbuild/win32-ia32": "0.25.10", "@esbuild/win32-x64": "0.25.10" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ=="],
|
||||||
|
|
||||||
|
"esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
|
||||||
|
|
||||||
|
"esrap": ["esrap@2.1.0", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "sha512-yzmPNpl7TBbMRC5Lj2JlJZNPml0tzqoqP5B1JXycNUwtqma9AKCO0M2wHrdgsHcy1WRW7S9rJknAMtByg3usgA=="],
|
||||||
|
|
||||||
|
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||||
|
|
||||||
|
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||||
|
|
||||||
|
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||||
|
|
||||||
|
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||||
|
|
||||||
|
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||||
|
|
||||||
|
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||||
|
|
||||||
|
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
|
||||||
|
|
||||||
|
"is-module": ["is-module@1.0.0", "", {}, "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g=="],
|
||||||
|
|
||||||
|
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
|
||||||
|
|
||||||
|
"jiti": ["jiti@2.5.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w=="],
|
||||||
|
|
||||||
|
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||||
|
|
||||||
|
"lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="],
|
||||||
|
|
||||||
|
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="],
|
||||||
|
|
||||||
|
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA=="],
|
||||||
|
|
||||||
|
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.1", "", { "os": "linux", "cpu": "arm" }, "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ=="],
|
||||||
|
|
||||||
|
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA=="],
|
||||||
|
|
||||||
|
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.1", "", { "os": "win32", "cpu": "x64" }, "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg=="],
|
||||||
|
|
||||||
|
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
|
||||||
|
|
||||||
|
"magic-string": ["magic-string@0.30.19", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw=="],
|
||||||
|
|
||||||
|
"minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
|
||||||
|
|
||||||
|
"minizlib": ["minizlib@3.0.2", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA=="],
|
||||||
|
|
||||||
|
"mkdirp": ["mkdirp@3.0.1", "", { "bin": { "mkdirp": "dist/cjs/src/bin.js" } }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="],
|
||||||
|
|
||||||
|
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
|
||||||
|
|
||||||
|
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
|
||||||
|
|
||||||
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
|
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||||
|
|
||||||
|
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
||||||
|
|
||||||
|
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
|
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||||
|
|
||||||
|
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||||
|
|
||||||
|
"prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="],
|
||||||
|
|
||||||
|
"prettier-plugin-svelte": ["prettier-plugin-svelte@3.4.0", "", { "peerDependencies": { "prettier": "^3.0.0", "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" } }, "sha512-pn1ra/0mPObzqoIQn/vUTR3ZZI6UuZ0sHqMK5x2jMLGrs53h0sXhkVuDcrlssHwIMk7FYrMjHBPoUSyyEEDlBQ=="],
|
||||||
|
|
||||||
|
"prettier-plugin-tailwindcss": ["prettier-plugin-tailwindcss@0.6.14", "", { "peerDependencies": { "@ianvs/prettier-plugin-sort-imports": "*", "@prettier/plugin-hermes": "*", "@prettier/plugin-oxc": "*", "@prettier/plugin-pug": "*", "@shopify/prettier-plugin-liquid": "*", "@trivago/prettier-plugin-sort-imports": "*", "@zackad/prettier-plugin-twig": "*", "prettier": "^3.0", "prettier-plugin-astro": "*", "prettier-plugin-css-order": "*", "prettier-plugin-import-sort": "*", "prettier-plugin-jsdoc": "*", "prettier-plugin-marko": "*", "prettier-plugin-multiline-arrays": "*", "prettier-plugin-organize-attributes": "*", "prettier-plugin-organize-imports": "*", "prettier-plugin-sort-imports": "*", "prettier-plugin-style-order": "*", "prettier-plugin-svelte": "*" }, "optionalPeers": ["@ianvs/prettier-plugin-sort-imports", "@prettier/plugin-hermes", "@prettier/plugin-oxc", "@prettier/plugin-pug", "@shopify/prettier-plugin-liquid", "@trivago/prettier-plugin-sort-imports", "@zackad/prettier-plugin-twig", "prettier-plugin-astro", "prettier-plugin-css-order", "prettier-plugin-import-sort", "prettier-plugin-jsdoc", "prettier-plugin-marko", "prettier-plugin-multiline-arrays", "prettier-plugin-organize-attributes", "prettier-plugin-organize-imports", "prettier-plugin-sort-imports", "prettier-plugin-style-order", "prettier-plugin-svelte"] }, "sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg=="],
|
||||||
|
|
||||||
|
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||||
|
|
||||||
|
"resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="],
|
||||||
|
|
||||||
|
"rollup": ["rollup@4.52.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.52.0", "@rollup/rollup-android-arm64": "4.52.0", "@rollup/rollup-darwin-arm64": "4.52.0", "@rollup/rollup-darwin-x64": "4.52.0", "@rollup/rollup-freebsd-arm64": "4.52.0", "@rollup/rollup-freebsd-x64": "4.52.0", "@rollup/rollup-linux-arm-gnueabihf": "4.52.0", "@rollup/rollup-linux-arm-musleabihf": "4.52.0", "@rollup/rollup-linux-arm64-gnu": "4.52.0", "@rollup/rollup-linux-arm64-musl": "4.52.0", "@rollup/rollup-linux-loong64-gnu": "4.52.0", "@rollup/rollup-linux-ppc64-gnu": "4.52.0", "@rollup/rollup-linux-riscv64-gnu": "4.52.0", "@rollup/rollup-linux-riscv64-musl": "4.52.0", "@rollup/rollup-linux-s390x-gnu": "4.52.0", "@rollup/rollup-linux-x64-gnu": "4.52.0", "@rollup/rollup-linux-x64-musl": "4.52.0", "@rollup/rollup-openharmony-arm64": "4.52.0", "@rollup/rollup-win32-arm64-msvc": "4.52.0", "@rollup/rollup-win32-ia32-msvc": "4.52.0", "@rollup/rollup-win32-x64-gnu": "4.52.0", "@rollup/rollup-win32-x64-msvc": "4.52.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-+IuescNkTJQgX7AkIDtITipZdIGcWF0pnVvZTWStiazUmcGA2ag8dfg0urest2XlXUi9kuhfQ+qmdc5Stc3z7g=="],
|
||||||
|
|
||||||
|
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
|
||||||
|
|
||||||
|
"set-cookie-parser": ["set-cookie-parser@2.7.1", "", {}, "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="],
|
||||||
|
|
||||||
|
"sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],
|
||||||
|
|
||||||
|
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||||
|
|
||||||
|
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||||
|
|
||||||
|
"svelte": ["svelte@5.39.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "esm-env": "^1.2.1", "esrap": "^2.1.0", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-7Jwus6iXviGZAvhqbeYu3NNHA6LGyQ8EbmjdAhJUDade5rrW6g9VnBbRhUuYX4pMZLHozijsFolt88zvKPfsbQ=="],
|
||||||
|
|
||||||
|
"svelte-check": ["svelte-check@4.3.1", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "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" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-lkh8gff5gpHLjxIV+IaApMxQhTGnir2pNUAqcNgeKkvK5bT/30Ey/nzBxNLDlkztCH4dP7PixkMt9SWEKFPBWg=="],
|
||||||
|
|
||||||
|
"tailwindcss": ["tailwindcss@4.1.13", "", {}, "sha512-i+zidfmTqtwquj4hMEwdjshYYgMbOrPzb9a0M3ZgNa0JMoZeFC6bxZvO8yr8ozS6ix2SDz0+mvryPeBs2TFE+w=="],
|
||||||
|
|
||||||
|
"tapable": ["tapable@2.2.3", "", {}, "sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg=="],
|
||||||
|
|
||||||
|
"tar": ["tar@7.4.3", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.0.1", "mkdirp": "^3.0.1", "yallist": "^5.0.0" } }, "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw=="],
|
||||||
|
|
||||||
|
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||||
|
|
||||||
|
"totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
|
||||||
|
|
||||||
|
"typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="],
|
||||||
|
|
||||||
|
"vite": ["vite@7.1.6", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.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", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-SRYIB8t/isTwNn8vMB3MR6E+EQZM/WG1aKmmIUCfDXfVvKfc20ZpamngWHKzAmmu9ppsgxsg4b2I7c90JZudIQ=="],
|
||||||
|
|
||||||
|
"vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="],
|
||||||
|
|
||||||
|
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
|
||||||
|
|
||||||
|
"zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
|
||||||
|
|
||||||
|
"@rollup/plugin-commonjs/is-reference": ["is-reference@1.2.1", "", { "dependencies": { "@types/estree": "*" } }, "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.5.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" }, "bundled": true }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
30
web/package.json
Normal file
30
web/package.json
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"lint": "prettier --check ."
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/adapter-node": "^5.0.0",
|
||||||
|
"@sveltejs/kit": "^2.22.0",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
||||||
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"prettier": "^3.4.2",
|
||||||
|
"prettier-plugin-svelte": "^3.3.3",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||||
|
"svelte": "^5.0.0",
|
||||||
|
"svelte-check": "^4.0.0",
|
||||||
|
"tailwindcss": "^4.0.0",
|
||||||
|
"typescript": "^5.0.0",
|
||||||
|
"vite": "^7.0.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
web/src/app.css
Normal file
1
web/src/app.css
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
@import 'tailwindcss';
|
||||||
13
web/src/app.d.ts
vendored
Normal file
13
web/src/app.d.ts
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||||
|
// for information about these interfaces
|
||||||
|
declare global {
|
||||||
|
namespace App {
|
||||||
|
// interface Error {}
|
||||||
|
// interface Locals {}
|
||||||
|
// interface PageData {}
|
||||||
|
// interface PageState {}
|
||||||
|
// interface Platform {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
11
web/src/app.html
Normal file
11
web/src/app.html
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1
web/src/lib/assets/favicon.svg
Normal file
1
web/src/lib/assets/favicon.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
1
web/src/lib/index.ts
Normal file
1
web/src/lib/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
// place files you want to import through the `$lib` alias in this folder.
|
||||||
38
web/src/lib/server/backend.ts
Normal file
38
web/src/lib/server/backend.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import type { RequestEvent } from '@sveltejs/kit';
|
||||||
|
import { API_BASE_URL } from './config';
|
||||||
|
|
||||||
|
export async function proxyRequest(
|
||||||
|
event: RequestEvent,
|
||||||
|
path: string,
|
||||||
|
init: RequestInit = {}
|
||||||
|
): Promise<{ response: Response; setCookies: string[] }> {
|
||||||
|
const url = new URL(path, API_BASE_URL).toString();
|
||||||
|
const headers = new Headers(init.headers ?? {});
|
||||||
|
|
||||||
|
const cookie = event.request.headers.get('cookie');
|
||||||
|
if (cookie && !headers.has('cookie')) {
|
||||||
|
headers.set('cookie', cookie);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (init.body && typeof init.body === 'string' && !headers.has('content-type')) {
|
||||||
|
headers.set('content-type', 'application/json');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await event.fetch(url, {
|
||||||
|
method: init.method ?? event.request.method,
|
||||||
|
body: init.body,
|
||||||
|
headers,
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
const headerUtils = response.headers as unknown as {
|
||||||
|
getSetCookie?: () => string[];
|
||||||
|
raw?: () => Record<string, string[]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setCookies = typeof headerUtils.getSetCookie === 'function'
|
||||||
|
? headerUtils.getSetCookie()
|
||||||
|
: headerUtils.raw?.()['set-cookie'] ?? [];
|
||||||
|
|
||||||
|
return { response, setCookies };
|
||||||
|
}
|
||||||
4
web/src/lib/server/config.ts
Normal file
4
web/src/lib/server/config.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
|
||||||
|
export const API_BASE_URL = env.API_BASE_URL ?? 'http://api:8080';
|
||||||
|
export const AUTH_COOKIE_NAME = env.AUTH_COOKIE_NAME ?? 'auth_token';
|
||||||
9
web/src/lib/types.ts
Normal file
9
web/src/lib/types.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
export interface Person {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
age: number;
|
||||||
|
phone_number: string;
|
||||||
|
checked_in: boolean;
|
||||||
|
inside: boolean;
|
||||||
|
under_ten: boolean;
|
||||||
|
}
|
||||||
7
web/src/routes/+layout.server.ts
Normal file
7
web/src/routes/+layout.server.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
import { AUTH_COOKIE_NAME } from '$lib/server/config';
|
||||||
|
|
||||||
|
export const load: LayoutServerLoad = async ({ cookies }) => {
|
||||||
|
const isLoggedIn = Boolean(cookies.get(AUTH_COOKIE_NAME));
|
||||||
|
return { isLoggedIn };
|
||||||
|
};
|
||||||
125
web/src/routes/+layout.svelte
Normal file
125
web/src/routes/+layout.svelte
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import '../app.css';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import favicon from '$lib/assets/favicon.svg';
|
||||||
|
|
||||||
|
const props = $props();
|
||||||
|
const children = $derived(props.children);
|
||||||
|
const data = $derived(props.data);
|
||||||
|
let ui = $state({
|
||||||
|
loggedIn: false,
|
||||||
|
loggingOut: false,
|
||||||
|
message: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
ui.loggedIn = Boolean(data?.isLoggedIn);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
if (ui.loggingOut) return;
|
||||||
|
ui.loggingOut = true;
|
||||||
|
ui.message = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/logout', { method: 'POST' });
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.json().catch(() => ({}));
|
||||||
|
ui.message = body.message ?? 'Kunde inte logga ut. Försök igen.';
|
||||||
|
} else {
|
||||||
|
ui.loggedIn = false;
|
||||||
|
await goto('/login', { invalidateAll: true });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Logout failed', err);
|
||||||
|
ui.message = 'Ett oväntat fel uppstod vid utloggning.';
|
||||||
|
} finally {
|
||||||
|
ui.loggingOut = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<link rel="icon" href={favicon} />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="min-h-screen bg-slate-100 text-slate-900">
|
||||||
|
<header class="border-b border-slate-200 bg-white">
|
||||||
|
<div class="mx-auto flex max-w-5xl flex-col gap-4 px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium uppercase tracking-wide text-slate-500">VBytes</p>
|
||||||
|
<h1 class="text-xl font-semibold text-slate-900">Gästhantering</h1>
|
||||||
|
</div>
|
||||||
|
{#if ui.loggedIn}
|
||||||
|
<div class="flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:gap-4">
|
||||||
|
<nav class="flex items-center gap-2">
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class={`rounded-md px-3 py-2 text-sm font-medium transition-colors ${
|
||||||
|
$page.url.pathname === '/'
|
||||||
|
? 'bg-indigo-600 text-white shadow'
|
||||||
|
: 'text-slate-600 hover:bg-slate-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Checka in
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/checkout"
|
||||||
|
class={`rounded-md px-3 py-2 text-sm font-medium transition-colors ${
|
||||||
|
$page.url.pathname === '/checkout'
|
||||||
|
? 'bg-indigo-600 text-white shadow'
|
||||||
|
: 'text-slate-600 hover:bg-slate-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Checka ut
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/create"
|
||||||
|
class={`rounded-md px-3 py-2 text-sm font-medium transition-colors ${
|
||||||
|
$page.url.pathname === '/create'
|
||||||
|
? 'bg-indigo-600 text-white shadow'
|
||||||
|
: 'text-slate-600 hover:bg-slate-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Lägg till
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/inside-status"
|
||||||
|
class={`rounded-md px-3 py-2 text-sm font-medium transition-colors ${
|
||||||
|
$page.url.pathname === '/inside-status'
|
||||||
|
? 'bg-indigo-600 text-white shadow'
|
||||||
|
: 'text-slate-600 hover:bg-slate-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Inne/ute
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/checked-in"
|
||||||
|
class={`rounded-md px-3 py-2 text-sm font-medium transition-colors ${
|
||||||
|
$page.url.pathname === '/checked-in'
|
||||||
|
? 'bg-indigo-600 text-white shadow'
|
||||||
|
: 'text-slate-600 hover:bg-slate-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Översikt
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
<button
|
||||||
|
onclick={handleLogout}
|
||||||
|
disabled={ui.loggingOut}
|
||||||
|
class="rounded-md border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{ui.loggingOut ? 'Loggar ut…' : 'Logga ut'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if ui.message}
|
||||||
|
<p class="bg-red-50 px-4 py-2 text-center text-sm text-red-600">{ui.message}</p>
|
||||||
|
{/if}
|
||||||
|
</header>
|
||||||
|
<main class="mx-auto max-w-5xl px-4 py-6">
|
||||||
|
{@render children?.()}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
11
web/src/routes/+page.server.ts
Normal file
11
web/src/routes/+page.server.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { AUTH_COOKIE_NAME } from '$lib/server/config';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ cookies }) => {
|
||||||
|
if (!cookies.get(AUTH_COOKIE_NAME)) {
|
||||||
|
throw redirect(302, '/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
};
|
||||||
215
web/src/routes/+page.svelte
Normal file
215
web/src/routes/+page.svelte
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import type { Person } from '$lib/types';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
let searchQuery = $state('');
|
||||||
|
let searchResults = $state<Person[]>([]);
|
||||||
|
let visibleResults = $state<Person[]>([]);
|
||||||
|
let searchLoading = $state(false);
|
||||||
|
let searchError = $state('');
|
||||||
|
let searchInfo = $state('');
|
||||||
|
let actionInfo = $state('');
|
||||||
|
|
||||||
|
async function apiFetch(url: string, init?: RequestInit) {
|
||||||
|
const response = await fetch(url, init);
|
||||||
|
if (response.status === 401) {
|
||||||
|
await goto('/login');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDefaultList() {
|
||||||
|
searchLoading = true;
|
||||||
|
searchError = '';
|
||||||
|
actionInfo = '';
|
||||||
|
try {
|
||||||
|
const response = await apiFetch('/api/persons/checked-in?checked=false');
|
||||||
|
if (!response) return;
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(text);
|
||||||
|
searchError = body.message ?? 'Kunde inte hämta personer.';
|
||||||
|
} catch {
|
||||||
|
searchError = text || 'Kunde inte hämta personer.';
|
||||||
|
}
|
||||||
|
searchResults = [];
|
||||||
|
visibleResults = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
searchResults = data.persons ?? [];
|
||||||
|
updateVisibleResults();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Initial load failed', err);
|
||||||
|
searchError = 'Ett oväntat fel inträffade när listan skulle hämtas.';
|
||||||
|
searchResults = [];
|
||||||
|
visibleResults = [];
|
||||||
|
} finally {
|
||||||
|
searchLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSearch(event?: SubmitEvent) {
|
||||||
|
event?.preventDefault();
|
||||||
|
actionInfo = '';
|
||||||
|
searchError = '';
|
||||||
|
searchInfo = '';
|
||||||
|
|
||||||
|
const query = searchQuery.trim();
|
||||||
|
if (!query) {
|
||||||
|
await loadDefaultList();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
searchLoading = true;
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`/api/persons/search?q=${encodeURIComponent(query)}`);
|
||||||
|
if (!response) return;
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.json().catch(() => ({}));
|
||||||
|
searchError = body.message ?? 'Sökningen misslyckades.';
|
||||||
|
searchResults = [];
|
||||||
|
visibleResults = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
searchResults = data.persons ?? [];
|
||||||
|
updateVisibleResults();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Search failed', err);
|
||||||
|
searchError = 'Ett oväntat fel inträffade vid sökningen.';
|
||||||
|
searchResults = [];
|
||||||
|
visibleResults = [];
|
||||||
|
} finally {
|
||||||
|
searchLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePersonList(updated: Person) {
|
||||||
|
let found = false;
|
||||||
|
searchResults = searchResults.map((person) => {
|
||||||
|
if (person.id === updated.id) {
|
||||||
|
found = true;
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
return person;
|
||||||
|
});
|
||||||
|
if (!found && !updated.checked_in) {
|
||||||
|
searchResults = [updated, ...searchResults];
|
||||||
|
}
|
||||||
|
updateVisibleResults();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCheckIn(person: Person) {
|
||||||
|
actionInfo = '';
|
||||||
|
if (person.checked_in) return;
|
||||||
|
|
||||||
|
const response = await apiFetch(`/api/persons/${person.id}/checkin`, { method: 'POST' });
|
||||||
|
if (!response) return;
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.json().catch(() => ({}));
|
||||||
|
searchError = body.message ?? 'Kunde inte checka in personen.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
searchError = '';
|
||||||
|
updatePersonList(data.person);
|
||||||
|
actionInfo = 'Personen är nu incheckad.';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateVisibleResults() {
|
||||||
|
const filtered = searchResults.filter((person) => !person.checked_in);
|
||||||
|
visibleResults = filtered;
|
||||||
|
|
||||||
|
if (searchResults.length === 0) {
|
||||||
|
searchInfo = 'Ingen träff på sökningen.';
|
||||||
|
} else if (filtered.length === 0) {
|
||||||
|
searchInfo = 'Alla personer i listan är redan incheckade.';
|
||||||
|
} else {
|
||||||
|
searchInfo = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
void loadDefaultList();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<section class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
|
||||||
|
<h3 class="text-lg font-semibold text-slate-800">Checka in</h3>
|
||||||
|
<p class="mb-4 text-sm text-slate-500">Listan visar automatiskt alla som inte är incheckade. Sök för att begränsa listan.</p>
|
||||||
|
<form class="flex flex-col gap-3 sm:flex-row" onsubmit={handleSearch}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Exempel: Anna, 42 eller 0701234567"
|
||||||
|
bind:value={searchQuery}
|
||||||
|
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={searchLoading}
|
||||||
|
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-70"
|
||||||
|
>
|
||||||
|
{searchLoading ? 'Laddar…' : 'Sök'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{#if searchError}
|
||||||
|
<p class="mt-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-600">{searchError}</p>
|
||||||
|
{/if}
|
||||||
|
{#if searchInfo}
|
||||||
|
<p class="mt-4 rounded-md bg-amber-50 px-3 py-2 text-sm text-amber-700">{searchInfo}</p>
|
||||||
|
{/if}
|
||||||
|
{#if actionInfo}
|
||||||
|
<p class="mt-4 rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{actionInfo}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if visibleResults.length > 0}
|
||||||
|
<ul class="mt-6 space-y-4">
|
||||||
|
{#each visibleResults as person}
|
||||||
|
<li class="rounded-lg border border-slate-200 p-4 shadow-sm">
|
||||||
|
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h4 class="text-base font-semibold text-slate-800">{person.name}</h4>
|
||||||
|
<p class="text-sm text-slate-500">ID: {person.id} · Telefon: {person.phone_number}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<span class="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">Inte incheckad</span>
|
||||||
|
<span class={`rounded-full px-3 py-1 text-xs font-semibold ${
|
||||||
|
person.inside ? 'bg-blue-100 text-blue-700' : 'bg-slate-100 text-slate-600'
|
||||||
|
}`}>{person.inside ? 'Inne' : 'Ute'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-slate-600">Ålder: {person.age} år</p>
|
||||||
|
{#if person.under_ten}
|
||||||
|
<p class="mt-2 rounded-md bg-amber-100 px-3 py-2 text-sm font-semibold text-amber-800">
|
||||||
|
VARNING: Person under 10 år – kompletterande information krävs innan incheckning.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
<div class="mt-4 flex gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => handleCheckIn(person)}
|
||||||
|
disabled={person.checked_in || searchLoading}
|
||||||
|
class={`rounded-md px-4 py-2 text-sm font-semibold transition-colors ${
|
||||||
|
person.checked_in
|
||||||
|
? 'bg-slate-200 text-slate-600 cursor-not-allowed'
|
||||||
|
: 'bg-emerald-600 text-white hover:bg-emerald-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Checka in
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
24
web/src/routes/api/login/+server.ts
Normal file
24
web/src/routes/api/login/+server.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { proxyRequest } from '$lib/server/backend';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async (event) => {
|
||||||
|
const payload = await event.request.json();
|
||||||
|
const { response, setCookies } = await proxyRequest(event, '/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
const headers = new Headers();
|
||||||
|
for (const cookie of setCookies) {
|
||||||
|
headers.append('set-cookie', cookie);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.json().catch(() => ({}));
|
||||||
|
throw error(response.status, body.message ?? 'Inloggning misslyckades.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return json(data, { status: response.status, headers });
|
||||||
|
};
|
||||||
26
web/src/routes/api/logout/+server.ts
Normal file
26
web/src/routes/api/logout/+server.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { proxyRequest } from '$lib/server/backend';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async (event) => {
|
||||||
|
const { response, setCookies } = await proxyRequest(event, '/logout', {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
|
||||||
|
const headers = new Headers();
|
||||||
|
for (const cookie of setCookies) {
|
||||||
|
headers.append('set-cookie', cookie);
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = text || 'Utloggning misslyckades.';
|
||||||
|
return json({ message }, { status: response.status, headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
return new Response(null, { status: response.status, headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(text, { status: response.status, headers });
|
||||||
|
};
|
||||||
39
web/src/routes/api/persons/+server.ts
Normal file
39
web/src/routes/api/persons/+server.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { proxyRequest } from '$lib/server/backend';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async (event) => {
|
||||||
|
const payload = await event.request.json();
|
||||||
|
const { response, setCookies } = await proxyRequest(event, '/persons', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
const headers = new Headers();
|
||||||
|
for (const cookie of setCookies) {
|
||||||
|
headers.append('set-cookie', cookie);
|
||||||
|
}
|
||||||
|
const contentType = response.headers.get('content-type');
|
||||||
|
if (contentType) {
|
||||||
|
headers.set('content-type', contentType);
|
||||||
|
} else {
|
||||||
|
headers.set('content-type', 'application/json');
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
let message = 'Kunde inte skapa person.';
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(text);
|
||||||
|
message = body.message ?? message;
|
||||||
|
} catch {
|
||||||
|
if (text) {
|
||||||
|
message = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error(response.status, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(text, { status: response.status, headers });
|
||||||
|
};
|
||||||
44
web/src/routes/api/persons/[id]/[action]/+server.ts
Normal file
44
web/src/routes/api/persons/[id]/[action]/+server.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { proxyRequest } from '$lib/server/backend';
|
||||||
|
|
||||||
|
const allowedActions = new Set(['checkin', 'checkout', 'inside', 'outside']);
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async (event) => {
|
||||||
|
const { id, action } = event.params;
|
||||||
|
if (!id || !action || !allowedActions.has(action)) {
|
||||||
|
throw error(400, 'Ogiltig åtgärd.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { response, setCookies } = await proxyRequest(event, `/persons/${id}/${action}`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
|
||||||
|
const headers = new Headers();
|
||||||
|
for (const cookie of setCookies) {
|
||||||
|
headers.append('set-cookie', cookie);
|
||||||
|
}
|
||||||
|
const contentType = response.headers.get('content-type');
|
||||||
|
if (contentType) {
|
||||||
|
headers.set('content-type', contentType);
|
||||||
|
} else {
|
||||||
|
headers.set('content-type', 'application/json');
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
let message = 'Åtgärden misslyckades.';
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(text);
|
||||||
|
message = body.message ?? message;
|
||||||
|
} catch {
|
||||||
|
if (text) {
|
||||||
|
message = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error(response.status, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(text, { status: response.status, headers });
|
||||||
|
};
|
||||||
47
web/src/routes/api/persons/checked-in/+server.ts
Normal file
47
web/src/routes/api/persons/checked-in/+server.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { proxyRequest } from '$lib/server/backend';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async (event) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
const q = event.url.searchParams.get('q');
|
||||||
|
const status = event.url.searchParams.get('status');
|
||||||
|
|
||||||
|
if (q) {
|
||||||
|
params.set('q', q);
|
||||||
|
}
|
||||||
|
if (status) {
|
||||||
|
params.set('status', status);
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = params.toString() ? `/persons/checked-in?${params}` : '/persons/checked-in';
|
||||||
|
const { response, setCookies } = await proxyRequest(event, path, { method: 'GET' });
|
||||||
|
|
||||||
|
const headers = new Headers();
|
||||||
|
for (const cookie of setCookies) {
|
||||||
|
headers.append('set-cookie', cookie);
|
||||||
|
}
|
||||||
|
const contentType = response.headers.get('content-type');
|
||||||
|
if (contentType) {
|
||||||
|
headers.set('content-type', contentType);
|
||||||
|
} else {
|
||||||
|
headers.set('content-type', 'application/json');
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
let message = 'Kunde inte hämta listan över incheckade.';
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(text);
|
||||||
|
message = body.message ?? message;
|
||||||
|
} catch {
|
||||||
|
if (text) {
|
||||||
|
message = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error(response.status, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(text, { status: response.status, headers });
|
||||||
|
};
|
||||||
42
web/src/routes/api/persons/search/+server.ts
Normal file
42
web/src/routes/api/persons/search/+server.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { proxyRequest } from '$lib/server/backend';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async (event) => {
|
||||||
|
const query = event.url.searchParams.get('q');
|
||||||
|
if (!query) {
|
||||||
|
throw error(400, 'Söktext krävs.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { response, setCookies } = await proxyRequest(event, `/persons/search?q=${encodeURIComponent(query)}`, {
|
||||||
|
method: 'GET'
|
||||||
|
});
|
||||||
|
|
||||||
|
const headers = new Headers();
|
||||||
|
for (const cookie of setCookies) {
|
||||||
|
headers.append('set-cookie', cookie);
|
||||||
|
}
|
||||||
|
const contentType = response.headers.get('content-type');
|
||||||
|
if (contentType) {
|
||||||
|
headers.set('content-type', contentType);
|
||||||
|
} else {
|
||||||
|
headers.set('content-type', 'application/json');
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
let message = 'Sökningen misslyckades.';
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(text);
|
||||||
|
message = body.message ?? message;
|
||||||
|
} catch {
|
||||||
|
if (text) {
|
||||||
|
message = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error(response.status, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(text, { status: response.status, headers });
|
||||||
|
};
|
||||||
205
web/src/routes/checked-in/+page.svelte
Normal file
205
web/src/routes/checked-in/+page.svelte
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import type { Person } from '$lib/types';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
type StatusFilter = 'all' | 'inside' | 'outside';
|
||||||
|
type CheckedFilter = 'all' | 'checked-in' | 'not-checked-in';
|
||||||
|
|
||||||
|
let persons = $state<Person[]>([]);
|
||||||
|
let loading = $state(false);
|
||||||
|
let errorMessage = $state('');
|
||||||
|
let infoMessage = $state('');
|
||||||
|
let searchQuery = $state('');
|
||||||
|
let statusFilter = $state<StatusFilter>('all');
|
||||||
|
let checkedFilter = $state<CheckedFilter>('checked-in');
|
||||||
|
|
||||||
|
async function apiFetch(url: string) {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (response.status === 401) {
|
||||||
|
await goto('/login');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCheckedIn() {
|
||||||
|
loading = true;
|
||||||
|
errorMessage = '';
|
||||||
|
infoMessage = '';
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
const trimmedQuery = searchQuery.trim();
|
||||||
|
if (trimmedQuery) {
|
||||||
|
params.set('q', trimmedQuery);
|
||||||
|
}
|
||||||
|
if (statusFilter !== 'all') {
|
||||||
|
params.set('status', statusFilter);
|
||||||
|
}
|
||||||
|
if (checkedFilter !== 'all') {
|
||||||
|
params.set('checked', checkedFilter === 'checked-in' ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryString = params.toString();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`/api/persons/checked-in${queryString ? `?${queryString}` : ''}`
|
||||||
|
);
|
||||||
|
if (!response) return;
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(text);
|
||||||
|
errorMessage = body.message ?? 'Kunde inte hämta personer.';
|
||||||
|
} catch {
|
||||||
|
errorMessage = text || 'Kunde inte hämta personer.';
|
||||||
|
}
|
||||||
|
persons = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
let list: Person[] = data.persons ?? [];
|
||||||
|
|
||||||
|
list = list.filter((person) => {
|
||||||
|
if (checkedFilter === 'checked-in' && !person.checked_in) return false;
|
||||||
|
if (checkedFilter === 'not-checked-in' && person.checked_in) return false;
|
||||||
|
if (statusFilter === 'inside' && !person.inside) return false;
|
||||||
|
if (statusFilter === 'outside' && person.inside) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
persons = list;
|
||||||
|
if (persons.length === 0) {
|
||||||
|
infoMessage = 'Inga personer matchar kriterierna.';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Fetch checked-in failed', err);
|
||||||
|
errorMessage = 'Ett oväntat fel inträffade när listan skulle hämtas.';
|
||||||
|
persons = [];
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSearch(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
await fetchCheckedIn();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStatusChange() {
|
||||||
|
await fetchCheckedIn();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCheckedChange() {
|
||||||
|
await fetchCheckedIn();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
void fetchCheckedIn();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<section class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
|
||||||
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-slate-800">Översikt</h2>
|
||||||
|
<p class="text-sm text-slate-500">
|
||||||
|
Visar personer med status, både incheckade och inte incheckade. Sök efter namn, telefonnummer eller id och filtrera på inne/ute.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-slate-500">
|
||||||
|
{#if loading}
|
||||||
|
<span>Laddar…</span>
|
||||||
|
{:else}
|
||||||
|
<span>{persons.length} personer</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="mt-4 grid gap-4 md:grid-cols-[2fr_1fr_1fr_auto]" onsubmit={handleSearch}>
|
||||||
|
<div>
|
||||||
|
<label for="checked-query" class="mb-1 block text-sm font-medium text-slate-600">Sök</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="checked-query"
|
||||||
|
placeholder="Exempel: 42, Anna eller 0701234567"
|
||||||
|
bind:value={searchQuery}
|
||||||
|
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="status" class="mb-1 block text-sm font-medium text-slate-600">Status</label>
|
||||||
|
<select
|
||||||
|
id="status"
|
||||||
|
bind:value={statusFilter}
|
||||||
|
onchange={handleStatusChange}
|
||||||
|
class="w-full rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100"
|
||||||
|
>
|
||||||
|
<option value="all">Alla</option>
|
||||||
|
<option value="inside">Endast inne</option>
|
||||||
|
<option value="outside">Endast ute</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="checked" class="mb-1 block text-sm font-medium text-slate-600">Incheckning</label>
|
||||||
|
<select
|
||||||
|
id="checked"
|
||||||
|
bind:value={checkedFilter}
|
||||||
|
onchange={handleCheckedChange}
|
||||||
|
class="w-full rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100"
|
||||||
|
>
|
||||||
|
<option value="checked-in">Endast incheckade</option>
|
||||||
|
<option value="not-checked-in">Endast ej incheckade</option>
|
||||||
|
<option value="all">Alla</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
class="w-full rounded-md bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-70"
|
||||||
|
>
|
||||||
|
{loading ? 'Söker…' : 'Sök'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{#if errorMessage}
|
||||||
|
<p class="mt-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-600">{errorMessage}</p>
|
||||||
|
{/if}
|
||||||
|
{#if infoMessage && !errorMessage}
|
||||||
|
<p class="mt-4 rounded-md bg-amber-50 px-3 py-2 text-sm text-amber-700">{infoMessage}</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="space-y-4">
|
||||||
|
{#each persons as person}
|
||||||
|
<article class="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
|
<header class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-base font-semibold text-slate-800">{person.name}</h3>
|
||||||
|
<p class="text-sm text-slate-500">ID: {person.id} · Telefon: {person.phone_number}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<span class={`rounded-full px-3 py-1 text-xs font-semibold ${
|
||||||
|
person.checked_in ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-100 text-slate-600'
|
||||||
|
}`}>{person.checked_in ? 'Incheckad' : 'Inte incheckad'}</span>
|
||||||
|
<span class={`rounded-full px-3 py-1 text-xs font-semibold ${
|
||||||
|
person.inside ? 'bg-blue-100 text-blue-700' : 'bg-slate-100 text-slate-600'
|
||||||
|
}`}>{person.inside ? 'Inne' : 'Ute'}</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<p class="mt-3 text-sm text-slate-600">Ålder: {person.age} år</p>
|
||||||
|
{#if person.under_ten}
|
||||||
|
<p class="mt-3 rounded-md bg-amber-100 px-3 py-2 text-sm font-semibold text-amber-800">
|
||||||
|
VARNING: Person under 10 år – kompletterande information krävs.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
220
web/src/routes/checkout/+page.svelte
Normal file
220
web/src/routes/checkout/+page.svelte
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import type { Person } from '$lib/types';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
let searchQuery = $state('');
|
||||||
|
let searchResults = $state<Person[]>([]);
|
||||||
|
let visibleResults = $state<Person[]>([]);
|
||||||
|
let searchLoading = $state(false);
|
||||||
|
let searchError = $state('');
|
||||||
|
let searchInfo = $state('');
|
||||||
|
let actionInfo = $state('');
|
||||||
|
|
||||||
|
async function apiFetch(url: string, init?: RequestInit) {
|
||||||
|
const response = await fetch(url, init);
|
||||||
|
if (response.status === 401) {
|
||||||
|
await goto('/login');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDefaultList() {
|
||||||
|
searchLoading = true;
|
||||||
|
searchError = '';
|
||||||
|
actionInfo = '';
|
||||||
|
try {
|
||||||
|
const response = await apiFetch('/api/persons/checked-in?checked=true');
|
||||||
|
if (!response) return;
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(text);
|
||||||
|
searchError = body.message ?? 'Kunde inte hämta personer.';
|
||||||
|
} catch {
|
||||||
|
searchError = text || 'Kunde inte hämta personer.';
|
||||||
|
}
|
||||||
|
searchResults = [];
|
||||||
|
visibleResults = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
searchResults = data.persons ?? [];
|
||||||
|
updateVisibleResults();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Initial checkout load failed', err);
|
||||||
|
searchError = 'Ett oväntat fel inträffade när listan skulle hämtas.';
|
||||||
|
searchResults = [];
|
||||||
|
visibleResults = [];
|
||||||
|
} finally {
|
||||||
|
searchLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSearch(event?: SubmitEvent) {
|
||||||
|
event?.preventDefault();
|
||||||
|
actionInfo = '';
|
||||||
|
searchError = '';
|
||||||
|
searchInfo = '';
|
||||||
|
|
||||||
|
const query = searchQuery.trim();
|
||||||
|
if (!query) {
|
||||||
|
await loadDefaultList();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
searchLoading = true;
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`/api/persons/search?q=${encodeURIComponent(query)}`);
|
||||||
|
if (!response) return;
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.json().catch(() => ({}));
|
||||||
|
searchError = body.message ?? 'Sökningen misslyckades.';
|
||||||
|
searchResults = [];
|
||||||
|
visibleResults = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
searchResults = data.persons ?? [];
|
||||||
|
updateVisibleResults();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Search failed', err);
|
||||||
|
searchError = 'Ett oväntat fel inträffade vid sökningen.';
|
||||||
|
searchResults = [];
|
||||||
|
visibleResults = [];
|
||||||
|
} finally {
|
||||||
|
searchLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePersonList(updated: Person) {
|
||||||
|
let found = false;
|
||||||
|
searchResults = searchResults.map((person) => {
|
||||||
|
if (person.id === updated.id) {
|
||||||
|
found = true;
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
return person;
|
||||||
|
});
|
||||||
|
if (!found) {
|
||||||
|
searchResults = [updated, ...searchResults];
|
||||||
|
}
|
||||||
|
updateVisibleResults();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCheckout(person: Person) {
|
||||||
|
actionInfo = '';
|
||||||
|
if (!person.checked_in) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const response = await apiFetch(`/api/persons/${person.id}/checkout`, { method: 'POST' });
|
||||||
|
actionInfo = '';
|
||||||
|
if (!response) return;
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.json().catch(() => ({}));
|
||||||
|
searchError = body.message ?? 'Kunde inte checka ut personen.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
searchError = '';
|
||||||
|
updatePersonList(data.person);
|
||||||
|
actionInfo = 'Personen är nu utcheckad.';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateVisibleResults() {
|
||||||
|
const filtered = searchResults.filter((person) => person.checked_in);
|
||||||
|
visibleResults = filtered;
|
||||||
|
|
||||||
|
if (searchResults.length === 0) {
|
||||||
|
searchInfo = 'Ingen träff på sökningen.';
|
||||||
|
} else if (filtered.length === 0) {
|
||||||
|
searchInfo = 'Inga personer kan checkas ut just nu.';
|
||||||
|
} else {
|
||||||
|
searchInfo = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
void loadDefaultList();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<section class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
|
||||||
|
<h3 class="text-lg font-semibold text-slate-800">Checka ut</h3>
|
||||||
|
<p class="mb-4 text-sm text-slate-500">Sök på namn, id eller telefonnummer för att checka ut personer som är incheckade.</p>
|
||||||
|
<form class="flex flex-col gap-3 sm:flex-row" onsubmit={handleSearch}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Exempel: Anna, 42 eller 0701234567"
|
||||||
|
bind:value={searchQuery}
|
||||||
|
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={searchLoading}
|
||||||
|
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-70"
|
||||||
|
>
|
||||||
|
{searchLoading ? 'Söker…' : 'Sök'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{#if searchError}
|
||||||
|
<p class="mt-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-600">{searchError}</p>
|
||||||
|
{/if}
|
||||||
|
{#if searchInfo}
|
||||||
|
<p class="mt-4 rounded-md bg-amber-50 px-3 py-2 text-sm text-amber-700">{searchInfo}</p>
|
||||||
|
{/if}
|
||||||
|
{#if actionInfo}
|
||||||
|
<p class="mt-4 rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{actionInfo}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if visibleResults.length > 0}
|
||||||
|
<ul class="mt-6 space-y-4">
|
||||||
|
{#each visibleResults as person}
|
||||||
|
<li class="rounded-lg border border-slate-200 p-4 shadow-sm">
|
||||||
|
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h4 class="text-base font-semibold text-slate-800">{person.name}</h4>
|
||||||
|
<p class="text-sm text-slate-500">ID: {person.id} · Telefon: {person.phone_number}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<span class={`rounded-full px-3 py-1 text-xs font-semibold ${
|
||||||
|
person.checked_in ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-100 text-slate-600'
|
||||||
|
}`}>{person.checked_in ? 'Incheckad' : 'Inte incheckad'}</span>
|
||||||
|
<span class={`rounded-full px-3 py-1 text-xs font-semibold ${
|
||||||
|
person.inside ? 'bg-blue-100 text-blue-700' : 'bg-slate-100 text-slate-600'
|
||||||
|
}`}>{person.inside ? 'Inne' : 'Ute'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-slate-600">Ålder: {person.age} år</p>
|
||||||
|
{#if person.under_ten}
|
||||||
|
<p class="mt-2 rounded-md bg-amber-100 px-3 py-2 text-sm font-semibold text-amber-800">
|
||||||
|
VARNING: Person under 10 år – kompletterande information krävs innan utcheckning.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
<div class="mt-4 flex gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => handleCheckout(person)}
|
||||||
|
disabled={!person.checked_in || searchLoading}
|
||||||
|
class={`rounded-md px-4 py-2 text-sm font-semibold transition-colors ${
|
||||||
|
person.checked_in
|
||||||
|
? 'bg-red-600 text-white hover:bg-red-700'
|
||||||
|
: 'bg-slate-200 text-slate-600 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Checka ut
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
221
web/src/routes/create/+page.svelte
Normal file
221
web/src/routes/create/+page.svelte
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
'use client';
|
||||||
|
'use runes';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import type { Person } from '$lib/types';
|
||||||
|
|
||||||
|
let name = $state('');
|
||||||
|
let age = $state('');
|
||||||
|
let phoneNumber = $state('');
|
||||||
|
let manualId = $state('');
|
||||||
|
let checkedIn = $state(false);
|
||||||
|
let inside = $state(false);
|
||||||
|
let loading = $state(false);
|
||||||
|
let errorMessage = $state('');
|
||||||
|
let successMessage = $state('');
|
||||||
|
|
||||||
|
async function apiFetch(url: string, init?: RequestInit) {
|
||||||
|
const response = await fetch(url, init);
|
||||||
|
if (response.status === 401) {
|
||||||
|
await goto('/login');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
name = '';
|
||||||
|
age = '';
|
||||||
|
phoneNumber = '';
|
||||||
|
manualId = '';
|
||||||
|
checkedIn = false;
|
||||||
|
inside = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
errorMessage = '';
|
||||||
|
successMessage = '';
|
||||||
|
|
||||||
|
const trimmedName = name.trim();
|
||||||
|
const trimmedPhone = phoneNumber.trim();
|
||||||
|
const parsedAge = Number.parseInt(age, 10);
|
||||||
|
const trimmedId = manualId.trim();
|
||||||
|
|
||||||
|
if (!trimmedName) {
|
||||||
|
errorMessage = 'Ange ett namn.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Number.isNaN(parsedAge) || parsedAge < 0) {
|
||||||
|
errorMessage = 'Ålder måste vara ett heltal större än eller lika med 0.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!trimmedPhone) {
|
||||||
|
errorMessage = 'Ange ett telefonnummer.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
name: trimmedName,
|
||||||
|
age: parsedAge,
|
||||||
|
phone_number: trimmedPhone,
|
||||||
|
checked_in: checkedIn,
|
||||||
|
inside
|
||||||
|
};
|
||||||
|
|
||||||
|
if (trimmedId) {
|
||||||
|
const parsedId = Number.parseInt(trimmedId, 10);
|
||||||
|
if (Number.isNaN(parsedId) || parsedId < 0) {
|
||||||
|
errorMessage = 'ID måste vara ett positivt heltal om det anges.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that the ID is not already in use
|
||||||
|
try {
|
||||||
|
const idCheckResponse = await apiFetch(
|
||||||
|
`/api/persons/search?q=${encodeURIComponent(trimmedId)}`
|
||||||
|
);
|
||||||
|
if (idCheckResponse) {
|
||||||
|
if (idCheckResponse.ok) {
|
||||||
|
const existingData = await idCheckResponse.json();
|
||||||
|
const collision = existingData.persons?.some(
|
||||||
|
(person: Person) => person.id === parsedId
|
||||||
|
);
|
||||||
|
if (collision) {
|
||||||
|
errorMessage = 'Det angivna ID:t används redan.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const text = await idCheckResponse.text();
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(text);
|
||||||
|
errorMessage = body.message ?? 'Kontroll av ID misslyckades.';
|
||||||
|
} catch {
|
||||||
|
errorMessage = text || 'Kontroll av ID misslyckades.';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('ID uniqueness check failed', err);
|
||||||
|
errorMessage = 'Kunde inte kontrollera ID. Försök igen.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
payload.id = parsedId;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
const response = await apiFetch('/api/persons', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
if (!response) return;
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(text);
|
||||||
|
errorMessage = body.message ?? 'Kunde inte skapa personen.';
|
||||||
|
} catch {
|
||||||
|
errorMessage = text || 'Kunde inte skapa personen.';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
successMessage = 'Personen har lagts till.';
|
||||||
|
resetForm();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Create person failed', err);
|
||||||
|
errorMessage = 'Ett oväntat fel inträffade vid skapandet.';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<section class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
|
||||||
|
<h2 class="text-lg font-semibold text-slate-800">Lägg till person</h2>
|
||||||
|
<p class="mb-4 text-sm text-slate-500">Fyll i uppgifterna nedan. Om ID lämnas tomt skapas ett automatiskt.</p>
|
||||||
|
<form class="grid gap-4 md:grid-cols-2" onsubmit={handleSubmit}>
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label class="mb-1 block text-sm font-medium text-slate-600" for="name">Namn</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="name"
|
||||||
|
bind:value={name}
|
||||||
|
required
|
||||||
|
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium text-slate-600" for="age">Ålder</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="age"
|
||||||
|
min="0"
|
||||||
|
bind:value={age}
|
||||||
|
required
|
||||||
|
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium text-slate-600" for="phone">Telefonnummer</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
id="phone"
|
||||||
|
bind:value={phoneNumber}
|
||||||
|
required
|
||||||
|
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium text-slate-600" for="manual-id">ID (valfritt)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="manual-id"
|
||||||
|
min="0"
|
||||||
|
bind:value={manualId}
|
||||||
|
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input id="checked-in" type="checkbox" bind:checked={checkedIn} class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500" />
|
||||||
|
<label for="checked-in" class="text-sm text-slate-700">Markera som incheckad</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input id="inside" type="checkbox" bind:checked={inside} class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500" />
|
||||||
|
<label for="inside" class="text-sm text-slate-700">Markera som inne</label>
|
||||||
|
</div>
|
||||||
|
{#if errorMessage}
|
||||||
|
<p class="md:col-span-2 rounded-md bg-red-50 px-3 py-2 text-sm text-red-600">{errorMessage}</p>
|
||||||
|
{/if}
|
||||||
|
{#if successMessage}
|
||||||
|
<p class="md:col-span-2 rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{successMessage}</p>
|
||||||
|
{/if}
|
||||||
|
<div class="md:col-span-2 flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-70"
|
||||||
|
>
|
||||||
|
{loading ? 'Sparar…' : 'Spara person'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={resetForm}
|
||||||
|
disabled={loading}
|
||||||
|
class="rounded-md border border-slate-300 px-4 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Rensa fält
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
236
web/src/routes/inside-status/+page.svelte
Normal file
236
web/src/routes/inside-status/+page.svelte
Normal file
|
|
@ -0,0 +1,236 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import type { Person } from '$lib/types';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
type StatusFilter = 'all' | 'inside' | 'outside';
|
||||||
|
|
||||||
|
let persons = $state<Person[]>([]);
|
||||||
|
let loading = $state(false);
|
||||||
|
let errorMessage = $state('');
|
||||||
|
let infoMessage = $state('');
|
||||||
|
let actionMessage = $state('');
|
||||||
|
let searchQuery = $state('');
|
||||||
|
let statusFilter = $state<StatusFilter>('all');
|
||||||
|
|
||||||
|
async function apiFetch(url: string, init?: RequestInit) {
|
||||||
|
const response = await fetch(url, init);
|
||||||
|
if (response.status === 401) {
|
||||||
|
await goto('/login');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPersons() {
|
||||||
|
loading = true;
|
||||||
|
errorMessage = '';
|
||||||
|
infoMessage = '';
|
||||||
|
actionMessage = '';
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
const trimmedQuery = searchQuery.trim();
|
||||||
|
if (trimmedQuery) {
|
||||||
|
params.set('q', trimmedQuery);
|
||||||
|
}
|
||||||
|
if (statusFilter !== 'all') {
|
||||||
|
params.set('status', statusFilter);
|
||||||
|
}
|
||||||
|
params.set('checked', 'true');
|
||||||
|
|
||||||
|
const queryString = params.toString();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`/api/persons/checked-in${queryString ? `?${queryString}` : ''}`
|
||||||
|
);
|
||||||
|
if (!response) return;
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(text);
|
||||||
|
errorMessage = body.message ?? 'Kunde inte hämta personer.';
|
||||||
|
} catch {
|
||||||
|
errorMessage = text || 'Kunde inte hämta personer.';
|
||||||
|
}
|
||||||
|
persons = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
let list: Person[] = data.persons ?? [];
|
||||||
|
|
||||||
|
list = list.filter((person) => {
|
||||||
|
if (!person.checked_in) return false;
|
||||||
|
if (statusFilter === 'inside' && !person.inside) return false;
|
||||||
|
if (statusFilter === 'outside' && person.inside) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
persons = list;
|
||||||
|
if (persons.length === 0) {
|
||||||
|
infoMessage = 'Inga incheckade personer matchar kriterierna.';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Fetch inside status failed', err);
|
||||||
|
errorMessage = 'Ett oväntat fel inträffade när listan skulle hämtas.';
|
||||||
|
persons = [];
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSearch(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
await fetchPersons();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStatusChange() {
|
||||||
|
await fetchPersons();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleInside(person: Person) {
|
||||||
|
actionMessage = '';
|
||||||
|
errorMessage = '';
|
||||||
|
|
||||||
|
const action = person.inside ? 'outside' : 'inside';
|
||||||
|
const response = await apiFetch(`/api/persons/${person.id}/${action}`, { method: 'POST' });
|
||||||
|
if (!response) return;
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(text);
|
||||||
|
errorMessage = body.message ?? 'Kunde inte uppdatera inne/ute-status.';
|
||||||
|
} catch {
|
||||||
|
errorMessage = text || 'Kunde inte uppdatera inne/ute-status.';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(text) as { person?: Person };
|
||||||
|
if (body.person) {
|
||||||
|
const updated = body.person;
|
||||||
|
persons = persons.map((entry) => (entry.id === updated.id ? updated : entry));
|
||||||
|
actionMessage = updated.inside
|
||||||
|
? `${updated.name} är nu markerad som inne.`
|
||||||
|
: `${updated.name} är nu markerad som ute.`;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Parsing toggle response failed', err);
|
||||||
|
actionMessage = 'Status uppdaterades.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
void fetchPersons();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<section class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
|
||||||
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-slate-800">Inne/ute-status</h2>
|
||||||
|
<p class="text-sm text-slate-500">
|
||||||
|
Visa och uppdatera inne/ute-status för personer som redan är incheckade.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-slate-500">
|
||||||
|
{#if loading}
|
||||||
|
<span>Laddar…</span>
|
||||||
|
{:else}
|
||||||
|
<span>{persons.length} personer</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="mt-4 grid gap-4 md:grid-cols-[2fr_1fr_auto]" onsubmit={handleSearch}>
|
||||||
|
<div>
|
||||||
|
<label for="inside-query" class="mb-1 block text-sm font-medium text-slate-600">Sök</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="inside-query"
|
||||||
|
placeholder="Exempel: 42, Anna eller 0701234567"
|
||||||
|
bind:value={searchQuery}
|
||||||
|
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="inside-status" class="mb-1 block text-sm font-medium text-slate-600">Status</label>
|
||||||
|
<select
|
||||||
|
id="inside-status"
|
||||||
|
bind:value={statusFilter}
|
||||||
|
onchange={handleStatusChange}
|
||||||
|
class="w-full rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100"
|
||||||
|
>
|
||||||
|
<option value="all">Alla</option>
|
||||||
|
<option value="inside">Endast inne</option>
|
||||||
|
<option value="outside">Endast ute</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
class="w-full rounded-md bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-70"
|
||||||
|
>
|
||||||
|
{loading ? 'Söker…' : 'Sök'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{#if errorMessage}
|
||||||
|
<p class="mt-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-600">{errorMessage}</p>
|
||||||
|
{/if}
|
||||||
|
{#if infoMessage && !errorMessage}
|
||||||
|
<p class="mt-4 rounded-md bg-amber-50 px-3 py-2 text-sm text-amber-700">{infoMessage}</p>
|
||||||
|
{/if}
|
||||||
|
{#if actionMessage && !errorMessage}
|
||||||
|
<p class="mt-4 rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{actionMessage}</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="space-y-4">
|
||||||
|
{#each persons as person}
|
||||||
|
<article class="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
|
<header class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-base font-semibold text-slate-800">{person.name}</h3>
|
||||||
|
<p class="text-sm text-slate-500">ID: {person.id} · Telefon: {person.phone_number}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<span class={`rounded-full px-3 py-1 text-xs font-semibold ${
|
||||||
|
person.checked_in ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-100 text-slate-600'
|
||||||
|
}`}>{person.checked_in ? 'Incheckad' : 'Inte incheckad'}</span>
|
||||||
|
<span class={`rounded-full px-3 py-1 text-xs font-semibold ${
|
||||||
|
person.inside ? 'bg-blue-100 text-blue-700' : 'bg-slate-100 text-slate-600'
|
||||||
|
}`}>{person.inside ? 'Inne' : 'Ute'}</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<p class="mt-3 text-sm text-slate-600">Ålder: {person.age} år</p>
|
||||||
|
{#if person.under_ten}
|
||||||
|
<p class="mt-3 rounded-md bg-amber-100 px-3 py-2 text-sm font-semibold text-amber-800">
|
||||||
|
VARNING: Person under 10 år – kompletterande information krävs.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
<div class="mt-4 flex flex-wrap gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => toggleInside(person)}
|
||||||
|
disabled={loading}
|
||||||
|
class={`rounded-md px-4 py-2 text-sm font-semibold transition-colors ${
|
||||||
|
person.inside
|
||||||
|
? 'bg-orange-100 text-orange-700 hover:bg-orange-200'
|
||||||
|
: 'bg-blue-600 text-white hover:bg-blue-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{person.inside ? 'Markera ute' : 'Markera inne'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
11
web/src/routes/login/+page.server.ts
Normal file
11
web/src/routes/login/+page.server.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { AUTH_COOKIE_NAME } from '$lib/server/config';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ cookies }) => {
|
||||||
|
if (cookies.get(AUTH_COOKIE_NAME)) {
|
||||||
|
throw redirect(302, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
};
|
||||||
80
web/src/routes/login/+page.svelte
Normal file
80
web/src/routes/login/+page.svelte
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
|
let username = $state('');
|
||||||
|
let password = $state('');
|
||||||
|
let loading = $state(false);
|
||||||
|
let errorMessage = $state('');
|
||||||
|
|
||||||
|
async function handleSubmit(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
errorMessage = '';
|
||||||
|
|
||||||
|
if (!username.trim() || !password.trim()) {
|
||||||
|
errorMessage = 'Ange både användarnamn och lösenord.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.json().catch(() => ({}));
|
||||||
|
errorMessage = body.message ?? 'Felaktiga inloggningsuppgifter.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await goto('/', { invalidateAll: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Login failed', err);
|
||||||
|
errorMessage = 'Ett oväntat fel inträffade. Försök igen.';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mx-auto flex min-h-[60vh] max-w-md flex-col justify-center gap-6">
|
||||||
|
<h2 class="text-center text-2xl font-semibold text-slate-800">Logga in</h2>
|
||||||
|
<form class="space-y-4 rounded-lg border border-slate-200 bg-white p-6 shadow-sm" onsubmit={handleSubmit}>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="text-sm font-medium text-slate-600" for="username">Användarnamn</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
bind:value={username}
|
||||||
|
autocomplete="username"
|
||||||
|
required
|
||||||
|
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="text-sm font-medium text-slate-600" for="password">Lösenord</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
bind:value={password}
|
||||||
|
autocomplete="current-password"
|
||||||
|
required
|
||||||
|
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{#if errorMessage}
|
||||||
|
<p class="rounded-md bg-red-50 px-3 py-2 text-sm text-red-600">{errorMessage}</p>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
class="w-full rounded-md bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-70"
|
||||||
|
>
|
||||||
|
{loading ? 'Loggar in…' : 'Logga in'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
3
web/static/robots.txt
Normal file
3
web/static/robots.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# allow crawling everything by default
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
16
web/svelte.config.js
Normal file
16
web/svelte.config.js
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import adapter from '@sveltejs/adapter-node';
|
||||||
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
|
const config = {
|
||||||
|
preprocess: vitePreprocess(),
|
||||||
|
|
||||||
|
kit: {
|
||||||
|
adapter: adapter({ out: 'build' })
|
||||||
|
},
|
||||||
|
compilerOptions: {
|
||||||
|
runes: true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
19
web/tsconfig.json
Normal file
19
web/tsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"moduleResolution": "bundler"
|
||||||
|
}
|
||||||
|
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||||
|
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||||
|
//
|
||||||
|
// To make changes to top-level options such as include and exclude, we recommend extending
|
||||||
|
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
|
||||||
|
}
|
||||||
7
web/vite.config.ts
Normal file
7
web/vite.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [tailwindcss(), sveltekit()]
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue