inital commit

This commit is contained in:
Sebastian 2025-09-20 12:37:44 +02:00
commit 51fb6d2b4b
37 changed files with 2168 additions and 0 deletions

6
web/.dockerignore Normal file
View file

@ -0,0 +1,6 @@
node_modules
.svelte-kit
build
coverage
*.log
.env

23
web/.gitignore vendored Normal file
View 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
View file

@ -0,0 +1 @@
engine-strict=true

9
web/.prettierignore Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1 @@
@import 'tailwindcss';

13
web/src/app.d.ts vendored Normal file
View 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
View 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>

View 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
View file

@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

View 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 };
}

View 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
View 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;
}

View 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 };
};

View 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>

View 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
View 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>

View 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 });
};

View 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 });
};

View 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 });
};

View 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 });
};

View 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 });
};

View 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 });
};

View 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>

View 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>

View 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>

View 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>

View 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 {};
};

View 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
View file

@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:

16
web/svelte.config.js Normal file
View 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
View 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
View 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()]
});