feat: implement SvelteKit web frontend MVP
SvelteKit 2 (Svelte 5 runes) + Tailwind 4 + TypeScript frontend for the Marktvogt medieval market platform. - Market search with keyword FTS, location/radius, date range, sorting - List/map toggle (Leaflet with OSM tiles, dynamic import) - Market detail pages with SSR, SEO meta/OG tags, opening hours, admission prices, embedded map - Auth: login (password + magic link + OAuth), register, logout - Profile: view/edit display name + avatar, account deletion - 2FA: TOTP setup, verify, disable - httpOnly cookie auth with JWT access + session token refresh - Responsive layout with mobile hamburger nav - German UI text for DACH audience
This commit is contained in:
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
|
||||
317
web/bun.lock
Normal file
317
web/bun.lock
Normal file
@@ -0,0 +1,317 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "marktvogt-web",
|
||||
"dependencies": {
|
||||
"leaflet": "^1.9.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^7.0.0",
|
||||
"@sveltejs/kit": "^2.50.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/leaflet": "^1.9.0",
|
||||
"svelte": "^5.49.2",
|
||||
"svelte-check": "^4.3.6",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
|
||||
|
||||
"@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/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.1", "", { "os": "android", "cpu": "arm" }, "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.1", "", { "os": "android", "cpu": "arm64" }, "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.57.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.57.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.57.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.57.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.57.1", "", { "os": "linux", "cpu": "arm" }, "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.57.1", "", { "os": "linux", "cpu": "arm" }, "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.57.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.57.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.57.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.57.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.57.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.57.1", "", { "os": "linux", "cpu": "x64" }, "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.57.1", "", { "os": "linux", "cpu": "x64" }, "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw=="],
|
||||
|
||||
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.57.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw=="],
|
||||
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.57.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.57.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.57.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.9", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA=="],
|
||||
|
||||
"@sveltejs/adapter-auto": ["@sveltejs/adapter-auto@7.0.1", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-dvuPm1E7M9NI/+canIQ6KKQDU2AkEefEZ2Dp7cY6uKoPq9Z/PhOXABe526UdW2mN986gjVkuSLkOYIBnS/M2LQ=="],
|
||||
|
||||
"@sveltejs/kit": ["@sveltejs/kit@2.52.0", "", { "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.6.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": "^3.0.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", "typescript": "^5.3.3", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-zG+HmJuSF7eC0e7xt2htlOcEMAdEtlVdb7+gAr+ef08EhtwUsjLxcAwBgUCJY3/5p08OVOxVZti91WfXeuLvsg=="],
|
||||
|
||||
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@6.2.4", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.1" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA=="],
|
||||
|
||||
"@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@5.0.2", "", { "dependencies": { "obug": "^2.1.0" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
|
||||
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="],
|
||||
|
||||
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.18", "", { "os": "android", "cpu": "arm64" }, "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.18", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw=="],
|
||||
|
||||
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.18", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18", "", { "os": "linux", "cpu": "arm" }, "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.18", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.0", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.18", "", { "os": "win32", "cpu": "arm64" }, "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.18", "", { "os": "win32", "cpu": "x64" }, "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q=="],
|
||||
|
||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="],
|
||||
|
||||
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="],
|
||||
|
||||
"@types/leaflet": ["@types/leaflet@1.9.21", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w=="],
|
||||
|
||||
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
|
||||
|
||||
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"devalue": ["devalue@5.6.2", "", {}, "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="],
|
||||
|
||||
"esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
|
||||
|
||||
"esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
|
||||
|
||||
"esrap": ["esrap@2.2.3", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "sha512-8fOS+GIGCQZl/ZIlhl59htOlms6U8NvX6ZYgYHpRU/b6tVSh3uHkOHZikl3D4cMbYM0JlpBe+p/BkZEi8J9XIQ=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
|
||||
|
||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||
|
||||
"leaflet": ["leaflet@1.9.4", "", {}, "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],
|
||||
|
||||
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="],
|
||||
|
||||
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="],
|
||||
|
||||
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="],
|
||||
|
||||
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="],
|
||||
|
||||
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="],
|
||||
|
||||
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="],
|
||||
|
||||
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="],
|
||||
|
||||
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="],
|
||||
|
||||
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="],
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
|
||||
|
||||
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
|
||||
|
||||
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||
|
||||
"rollup": ["rollup@4.57.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.1", "@rollup/rollup-android-arm64": "4.57.1", "@rollup/rollup-darwin-arm64": "4.57.1", "@rollup/rollup-darwin-x64": "4.57.1", "@rollup/rollup-freebsd-arm64": "4.57.1", "@rollup/rollup-freebsd-x64": "4.57.1", "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", "@rollup/rollup-linux-arm-musleabihf": "4.57.1", "@rollup/rollup-linux-arm64-gnu": "4.57.1", "@rollup/rollup-linux-arm64-musl": "4.57.1", "@rollup/rollup-linux-loong64-gnu": "4.57.1", "@rollup/rollup-linux-loong64-musl": "4.57.1", "@rollup/rollup-linux-ppc64-gnu": "4.57.1", "@rollup/rollup-linux-ppc64-musl": "4.57.1", "@rollup/rollup-linux-riscv64-gnu": "4.57.1", "@rollup/rollup-linux-riscv64-musl": "4.57.1", "@rollup/rollup-linux-s390x-gnu": "4.57.1", "@rollup/rollup-linux-x64-gnu": "4.57.1", "@rollup/rollup-linux-x64-musl": "4.57.1", "@rollup/rollup-openbsd-x64": "4.57.1", "@rollup/rollup-openharmony-arm64": "4.57.1", "@rollup/rollup-win32-arm64-msvc": "4.57.1", "@rollup/rollup-win32-ia32-msvc": "4.57.1", "@rollup/rollup-win32-x64-gnu": "4.57.1", "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A=="],
|
||||
|
||||
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
|
||||
|
||||
"set-cookie-parser": ["set-cookie-parser@3.0.1", "", {}, "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"svelte": ["svelte@5.51.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.2", "esm-env": "^1.2.1", "esrap": "^2.2.2", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-3+ni7BMjiEQeMCa1fDQzHy2ESAebgQDVOTuE4jlj2/QOAB2grRta8ew80p95miWE+ZmimpL7B3t9SSO4rv0aqQ=="],
|
||||
|
||||
"svelte-check": ["svelte-check@4.4.0", "", { "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-gB3FdEPb8tPO3Y7Dzc6d/Pm/KrXAhK+0Fk+LkcysVtupvAh6Y/IrBCEZNupq57oh0hcwlxCUamu/rq7GtvfSEg=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
|
||||
|
||||
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
||||
|
||||
"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.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.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-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
||||
|
||||
"@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@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
|
||||
|
||||
"@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=="],
|
||||
}
|
||||
}
|
||||
29
web/package.json
Normal file
29
web/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "marktvogt-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^7.0.0",
|
||||
"@sveltejs/kit": "^2.50.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/leaflet": "^1.9.0",
|
||||
"svelte": "^5.49.2",
|
||||
"svelte-check": "^4.3.6",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"leaflet": "^1.9.0"
|
||||
}
|
||||
}
|
||||
29
web/src/app.css
Normal file
29
web/src/app.css
Normal file
@@ -0,0 +1,29 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@theme {
|
||||
--color-primary-50: oklch(0.97 0.02 145);
|
||||
--color-primary-100: oklch(0.94 0.04 145);
|
||||
--color-primary-200: oklch(0.88 0.08 145);
|
||||
--color-primary-300: oklch(0.80 0.12 145);
|
||||
--color-primary-400: oklch(0.70 0.16 145);
|
||||
--color-primary-500: oklch(0.60 0.18 145);
|
||||
--color-primary-600: oklch(0.50 0.16 145);
|
||||
--color-primary-700: oklch(0.42 0.14 145);
|
||||
--color-primary-800: oklch(0.35 0.10 145);
|
||||
--color-primary-900: oklch(0.28 0.08 145);
|
||||
--color-primary-950: oklch(0.20 0.06 145);
|
||||
|
||||
--color-accent-50: oklch(0.97 0.02 85);
|
||||
--color-accent-100: oklch(0.94 0.05 85);
|
||||
--color-accent-200: oklch(0.88 0.10 85);
|
||||
--color-accent-300: oklch(0.80 0.14 85);
|
||||
--color-accent-400: oklch(0.72 0.16 85);
|
||||
--color-accent-500: oklch(0.65 0.16 85);
|
||||
--color-accent-600: oklch(0.55 0.14 85);
|
||||
--color-accent-700: oklch(0.45 0.12 85);
|
||||
--color-accent-800: oklch(0.38 0.08 85);
|
||||
--color-accent-900: oklch(0.30 0.06 85);
|
||||
--color-accent-950: oklch(0.22 0.04 85);
|
||||
|
||||
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
18
web/src/app.d.ts
vendored
Normal file
18
web/src/app.d.ts
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { ProfileData } from '$lib/api/types.js';
|
||||
|
||||
declare global {
|
||||
namespace App {
|
||||
interface Error {
|
||||
code?: string;
|
||||
message: string;
|
||||
}
|
||||
interface Locals {
|
||||
user: ProfileData | null;
|
||||
}
|
||||
interface PageData {
|
||||
user: ProfileData | null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
12
web/src/app.html
Normal file
12
web/src/app.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
58
web/src/hooks.server.ts
Normal file
58
web/src/hooks.server.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
import { apiFetch } from '$lib/api/client.js';
|
||||
import { refreshTokens } from '$lib/api/client.server.js';
|
||||
import type { ProfileData } from '$lib/api/types.js';
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
const accessToken = event.cookies.get('access_token');
|
||||
const sessionToken = event.cookies.get('session_token');
|
||||
|
||||
event.locals.user = null;
|
||||
|
||||
if (accessToken) {
|
||||
try {
|
||||
const res = await apiFetch<ProfileData>('/users/me', {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
fetch: event.fetch
|
||||
});
|
||||
event.locals.user = res.data;
|
||||
} catch {
|
||||
// Access token expired — try refresh
|
||||
if (sessionToken) {
|
||||
const refreshed = await refreshTokens(event.cookies, event.fetch);
|
||||
if (refreshed) {
|
||||
const newToken = event.cookies.get('access_token');
|
||||
if (newToken) {
|
||||
try {
|
||||
const res = await apiFetch<ProfileData>('/users/me', {
|
||||
headers: { Authorization: `Bearer ${newToken}` },
|
||||
fetch: event.fetch
|
||||
});
|
||||
event.locals.user = res.data;
|
||||
} catch {
|
||||
// Token invalid even after refresh
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (sessionToken) {
|
||||
const refreshed = await refreshTokens(event.cookies, event.fetch);
|
||||
if (refreshed) {
|
||||
const newToken = event.cookies.get('access_token');
|
||||
if (newToken) {
|
||||
try {
|
||||
const res = await apiFetch<ProfileData>('/users/me', {
|
||||
headers: { Authorization: `Bearer ${newToken}` },
|
||||
fetch: event.fetch
|
||||
});
|
||||
event.locals.user = res.data;
|
||||
} catch {
|
||||
// Failed after refresh
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resolve(event);
|
||||
};
|
||||
45
web/src/lib/api/client.server.ts
Normal file
45
web/src/lib/api/client.server.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { Cookies } from '@sveltejs/kit';
|
||||
import { apiFetch, type ApiClientError } from './client.js';
|
||||
import type { ApiResponse, AuthData } from './types.js';
|
||||
import { setAuthCookies } from '$lib/auth/cookies.js';
|
||||
|
||||
export async function serverFetch<T>(
|
||||
path: string,
|
||||
cookies: Cookies,
|
||||
init?: RequestInit & { fetch?: typeof globalThis.fetch }
|
||||
): Promise<ApiResponse<T>> {
|
||||
const accessToken = cookies.get('access_token');
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
if (accessToken) {
|
||||
headers['Authorization'] = `Bearer ${accessToken}`;
|
||||
}
|
||||
|
||||
return apiFetch<T>(path, {
|
||||
...init,
|
||||
headers: {
|
||||
...headers,
|
||||
...init?.headers
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function refreshTokens(
|
||||
cookies: Cookies,
|
||||
fetchFn: typeof globalThis.fetch
|
||||
): Promise<boolean> {
|
||||
const sessionToken = cookies.get('session_token');
|
||||
if (!sessionToken) return false;
|
||||
|
||||
try {
|
||||
const res = await apiFetch<AuthData>('/auth/refresh', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ session_token: sessionToken }),
|
||||
fetch: fetchFn
|
||||
});
|
||||
setAuthCookies(cookies, res.data);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
53
web/src/lib/api/client.ts
Normal file
53
web/src/lib/api/client.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { ApiError, ApiResponse } from './types.js';
|
||||
|
||||
const API_BASE = 'http://localhost:8080/api/v1';
|
||||
|
||||
export class ApiClientError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
public readonly code: string,
|
||||
message: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiClientError';
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(
|
||||
path: string,
|
||||
init?: RequestInit & { fetch?: typeof globalThis.fetch }
|
||||
): Promise<ApiResponse<T>> {
|
||||
const fetchFn = init?.fetch ?? globalThis.fetch;
|
||||
const { fetch: _, ...restInit } = init ?? {};
|
||||
|
||||
const res = await fetchFn(`${API_BASE}${path}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...restInit.headers
|
||||
},
|
||||
...restInit
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
const err = body.error as ApiError | undefined;
|
||||
throw new ApiClientError(
|
||||
res.status,
|
||||
err?.code ?? 'unknown',
|
||||
err?.message ?? `Request failed with status ${res.status}`
|
||||
);
|
||||
}
|
||||
|
||||
return body as ApiResponse<T>;
|
||||
}
|
||||
|
||||
export function buildSearchQuery(params: Record<string, unknown>): string {
|
||||
const sp = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
sp.set(key, String(value));
|
||||
}
|
||||
}
|
||||
return sp.toString();
|
||||
}
|
||||
111
web/src/lib/api/types.ts
Normal file
111
web/src/lib/api/types.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
// API response envelope
|
||||
export interface ApiResponse<T> {
|
||||
data: T;
|
||||
meta?: PaginationMeta;
|
||||
error?: ApiError;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface PaginationMeta {
|
||||
page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
// Market types
|
||||
export interface MarketSummary {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
city: string;
|
||||
state: string;
|
||||
zip: string;
|
||||
country: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
image_url: string;
|
||||
organizer_name: string;
|
||||
distance?: number; // meters, only in geo queries
|
||||
}
|
||||
|
||||
export interface MarketDetail {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string;
|
||||
street: string;
|
||||
city: string;
|
||||
state: string;
|
||||
zip: string;
|
||||
country: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
opening_hours: OpeningHoursEntry[] | null;
|
||||
admission_info: AdmissionInfo | null;
|
||||
website: string;
|
||||
organizer_name: string;
|
||||
image_url: string;
|
||||
}
|
||||
|
||||
export interface OpeningHoursEntry {
|
||||
day: string;
|
||||
open: string;
|
||||
close: string;
|
||||
}
|
||||
|
||||
export interface AdmissionInfo {
|
||||
adult_cents: number;
|
||||
child_cents: number;
|
||||
reduced_cents: number;
|
||||
free_under_age: number;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
// Auth types
|
||||
export interface AuthData {
|
||||
access_token: string;
|
||||
session_token: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
export interface TOTPSetupData {
|
||||
secret: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface MessageData {
|
||||
message: string;
|
||||
}
|
||||
|
||||
// User types
|
||||
export interface ProfileData {
|
||||
id: string;
|
||||
email: string;
|
||||
email_verified: boolean;
|
||||
display_name: string;
|
||||
avatar_url: string;
|
||||
role: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// Search params (mirrors backend SearchParams)
|
||||
export interface MarketSearchParams {
|
||||
lat?: number;
|
||||
lon?: number;
|
||||
radius?: number;
|
||||
q?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
sort?: 'distance' | 'date' | 'name';
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
}
|
||||
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 |
25
web/src/lib/auth/cookies.ts
Normal file
25
web/src/lib/auth/cookies.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { Cookies } from '@sveltejs/kit';
|
||||
import type { AuthData } from '$lib/api/types.js';
|
||||
|
||||
const COOKIE_OPTS = {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure: false, // TODO: set to true in production
|
||||
sameSite: 'lax' as const
|
||||
};
|
||||
|
||||
export function setAuthCookies(cookies: Cookies, auth: AuthData): void {
|
||||
cookies.set('access_token', auth.access_token, {
|
||||
...COOKIE_OPTS,
|
||||
maxAge: auth.expires_in
|
||||
});
|
||||
cookies.set('session_token', auth.session_token, {
|
||||
...COOKIE_OPTS,
|
||||
maxAge: 30 * 24 * 60 * 60 // 30 days
|
||||
});
|
||||
}
|
||||
|
||||
export function clearAuthCookies(cookies: Cookies): void {
|
||||
cookies.delete('access_token', { path: '/' });
|
||||
cookies.delete('session_token', { path: '/' });
|
||||
}
|
||||
8
web/src/lib/auth/guard.ts
Normal file
8
web/src/lib/auth/guard.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { ServerLoadEvent } from '@sveltejs/kit';
|
||||
|
||||
export function requireAuth(event: ServerLoadEvent): void {
|
||||
if (!event.locals.user) {
|
||||
redirect(302, `/auth/anmelden?redirect=${encodeURIComponent(event.url.pathname)}`);
|
||||
}
|
||||
}
|
||||
40
web/src/lib/components/auth/LoginForm.svelte
Normal file
40
web/src/lib/components/auth/LoginForm.svelte
Normal file
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import Input from '$lib/components/ui/Input.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Alert from '$lib/components/ui/Alert.svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
interface Props {
|
||||
error?: string;
|
||||
requireTotp?: boolean;
|
||||
}
|
||||
|
||||
let { error, requireTotp = false }: Props = $props();
|
||||
let loading = $state(false);
|
||||
</script>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="/auth/anmelden"
|
||||
use:enhance={() => {
|
||||
loading = true;
|
||||
return async ({ update }) => {
|
||||
loading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="space-y-4"
|
||||
>
|
||||
{#if error}
|
||||
<Alert variant="error">{error}</Alert>
|
||||
{/if}
|
||||
|
||||
<Input name="email" label="E-Mail" type="email" required autocomplete="email" />
|
||||
<Input name="password" label="Passwort" type="password" required autocomplete="current-password" />
|
||||
|
||||
{#if requireTotp}
|
||||
<Input name="totp_code" label="2FA-Code" type="text" inputmode="numeric" maxlength={6} pattern="[0-9]{6}" required autocomplete="one-time-code" />
|
||||
{/if}
|
||||
|
||||
<Button type="submit" {loading} class="w-full">Anmelden</Button>
|
||||
</form>
|
||||
40
web/src/lib/components/auth/MagicLinkForm.svelte
Normal file
40
web/src/lib/components/auth/MagicLinkForm.svelte
Normal file
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import Input from '$lib/components/ui/Input.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Alert from '$lib/components/ui/Alert.svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
interface Props {
|
||||
error?: string;
|
||||
success?: string;
|
||||
}
|
||||
|
||||
let { error, success }: Props = $props();
|
||||
let loading = $state(false);
|
||||
</script>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="/auth/anmelden?/magicLink"
|
||||
use:enhance={() => {
|
||||
loading = true;
|
||||
return async ({ update }) => {
|
||||
loading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="space-y-4"
|
||||
>
|
||||
{#if error}
|
||||
<Alert variant="error">{error}</Alert>
|
||||
{/if}
|
||||
{#if success}
|
||||
<Alert variant="success">{success}</Alert>
|
||||
{/if}
|
||||
|
||||
<Input name="email" label="E-Mail" type="email" required autocomplete="email" />
|
||||
|
||||
<Button type="submit" variant="secondary" {loading} class="w-full">
|
||||
Magic Link senden
|
||||
</Button>
|
||||
</form>
|
||||
17
web/src/lib/components/auth/OAuthButtons.svelte
Normal file
17
web/src/lib/components/auth/OAuthButtons.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
const providers = [
|
||||
{ id: 'google', label: 'Google' },
|
||||
{ id: 'github', label: 'GitHub' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="space-y-2">
|
||||
{#each providers as provider}
|
||||
<a
|
||||
href="/api/v1/auth/oauth/{provider.id}/start"
|
||||
class="flex w-full items-center justify-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50"
|
||||
>
|
||||
Mit {provider.label} anmelden
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
36
web/src/lib/components/auth/RegisterForm.svelte
Normal file
36
web/src/lib/components/auth/RegisterForm.svelte
Normal file
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import Input from '$lib/components/ui/Input.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Alert from '$lib/components/ui/Alert.svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
interface Props {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
let { error }: Props = $props();
|
||||
let loading = $state(false);
|
||||
</script>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="/auth/registrieren"
|
||||
use:enhance={() => {
|
||||
loading = true;
|
||||
return async ({ update }) => {
|
||||
loading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="space-y-4"
|
||||
>
|
||||
{#if error}
|
||||
<Alert variant="error">{error}</Alert>
|
||||
{/if}
|
||||
|
||||
<Input name="display_name" label="Anzeigename" required autocomplete="name" />
|
||||
<Input name="email" label="E-Mail" type="email" required autocomplete="email" />
|
||||
<Input name="password" label="Passwort" type="password" required minlength={8} autocomplete="new-password" />
|
||||
|
||||
<Button type="submit" {loading} class="w-full">Registrieren</Button>
|
||||
</form>
|
||||
72
web/src/lib/components/auth/TOTPSetup.svelte
Normal file
72
web/src/lib/components/auth/TOTPSetup.svelte
Normal file
@@ -0,0 +1,72 @@
|
||||
<script lang="ts">
|
||||
import Input from '$lib/components/ui/Input.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Alert from '$lib/components/ui/Alert.svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
interface Props {
|
||||
secret?: string;
|
||||
url?: string;
|
||||
error?: string;
|
||||
success?: string;
|
||||
}
|
||||
|
||||
let { secret, url, error, success }: Props = $props();
|
||||
let loading = $state(false);
|
||||
</script>
|
||||
|
||||
{#if error}
|
||||
<Alert variant="error">{error}</Alert>
|
||||
{/if}
|
||||
{#if success}
|
||||
<Alert variant="success">{success}</Alert>
|
||||
{/if}
|
||||
|
||||
{#if secret && url}
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-gray-600">
|
||||
Scanne den QR-Code mit deiner Authenticator-App oder gib den Schlüssel manuell ein.
|
||||
</p>
|
||||
|
||||
<div class="flex justify-center">
|
||||
<img
|
||||
src="https://api.qrserver.com/v1/create-qr-code/?size=200x200&data={encodeURIComponent(url)}"
|
||||
alt="TOTP QR-Code"
|
||||
class="rounded-lg"
|
||||
width="200"
|
||||
height="200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-gray-50 p-3 text-center">
|
||||
<p class="text-xs text-gray-500">Schlüssel</p>
|
||||
<p class="mt-1 font-mono text-sm font-medium text-gray-900 select-all">{secret}</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="/profile/security?/verify"
|
||||
use:enhance={() => {
|
||||
loading = true;
|
||||
return async ({ update }) => {
|
||||
loading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="space-y-4"
|
||||
>
|
||||
<Input
|
||||
name="code"
|
||||
label="Bestätigungscode"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength={6}
|
||||
pattern="[0-9]{6}"
|
||||
placeholder="123456"
|
||||
required
|
||||
autocomplete="one-time-code"
|
||||
/>
|
||||
<Button type="submit" {loading}>Bestätigen</Button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
11
web/src/lib/components/layout/Footer.svelte
Normal file
11
web/src/lib/components/layout/Footer.svelte
Normal file
@@ -0,0 +1,11 @@
|
||||
<footer class="border-t border-gray-200 bg-white">
|
||||
<div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div class="flex flex-col items-center justify-between gap-4 sm:flex-row">
|
||||
<p class="text-sm text-gray-500">© {new Date().getFullYear()} Marktvogt</p>
|
||||
<nav class="flex gap-6">
|
||||
<a href="/impressum" class="text-sm text-gray-500 hover:text-gray-700">Impressum</a>
|
||||
<a href="/datenschutz" class="text-sm text-gray-500 hover:text-gray-700">Datenschutz</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
57
web/src/lib/components/layout/Header.svelte
Normal file
57
web/src/lib/components/layout/Header.svelte
Normal file
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import type { ProfileData } from '$lib/api/types.js';
|
||||
import MobileNav from './MobileNav.svelte';
|
||||
|
||||
interface Props {
|
||||
user: ProfileData | null;
|
||||
}
|
||||
|
||||
let { user }: Props = $props();
|
||||
let mobileOpen = $state(false);
|
||||
</script>
|
||||
|
||||
<header class="border-b border-gray-200 bg-white">
|
||||
<div class="mx-auto flex h-16 max-w-7xl items-center justify-between px-4 sm:px-6 lg:px-8">
|
||||
<a href="/" class="flex items-center gap-2 text-xl font-bold text-primary-700">
|
||||
Marktvogt
|
||||
</a>
|
||||
|
||||
<!-- Desktop nav -->
|
||||
<nav class="hidden items-center gap-6 md:flex">
|
||||
<a href="/" class="text-sm font-medium text-gray-600 hover:text-gray-900">Suche</a>
|
||||
{#if user}
|
||||
<a href="/profile" class="text-sm font-medium text-gray-600 hover:text-gray-900">Profil</a>
|
||||
<form method="POST" action="/auth/abmelden">
|
||||
<button type="submit" class="text-sm font-medium text-gray-600 hover:text-gray-900">
|
||||
Abmelden
|
||||
</button>
|
||||
</form>
|
||||
<span class="text-sm text-gray-500">{user.display_name}</span>
|
||||
{:else}
|
||||
<a href="/auth/anmelden" class="text-sm font-medium text-primary-600 hover:text-primary-700">
|
||||
Anmelden
|
||||
</a>
|
||||
{/if}
|
||||
</nav>
|
||||
|
||||
<!-- Mobile menu button -->
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md p-2 text-gray-500 hover:text-gray-700 md:hidden"
|
||||
onclick={() => (mobileOpen = !mobileOpen)}
|
||||
aria-label="Menu"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
{#if mobileOpen}
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
{:else}
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||
{/if}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if mobileOpen}
|
||||
<MobileNav {user} onclose={() => (mobileOpen = false)} />
|
||||
{/if}
|
||||
</header>
|
||||
33
web/src/lib/components/layout/MobileNav.svelte
Normal file
33
web/src/lib/components/layout/MobileNav.svelte
Normal file
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import type { ProfileData } from '$lib/api/types.js';
|
||||
|
||||
interface Props {
|
||||
user: ProfileData | null;
|
||||
onclose: () => void;
|
||||
}
|
||||
|
||||
let { user, onclose }: Props = $props();
|
||||
</script>
|
||||
|
||||
<nav class="border-t border-gray-200 bg-white px-4 py-4 md:hidden">
|
||||
<div class="flex flex-col gap-3">
|
||||
<a href="/" class="text-sm font-medium text-gray-600 hover:text-gray-900" onclick={onclose}>
|
||||
Suche
|
||||
</a>
|
||||
{#if user}
|
||||
<a href="/profile" class="text-sm font-medium text-gray-600 hover:text-gray-900" onclick={onclose}>
|
||||
Profil
|
||||
</a>
|
||||
<form method="POST" action="/auth/abmelden">
|
||||
<button type="submit" class="text-sm font-medium text-gray-600 hover:text-gray-900">
|
||||
Abmelden
|
||||
</button>
|
||||
</form>
|
||||
<span class="text-sm text-gray-500">{user.display_name}</span>
|
||||
{:else}
|
||||
<a href="/auth/anmelden" class="text-sm font-medium text-primary-600 hover:text-primary-700" onclick={onclose}>
|
||||
Anmelden
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</nav>
|
||||
66
web/src/lib/components/market/MarketCard.svelte
Normal file
66
web/src/lib/components/market/MarketCard.svelte
Normal file
@@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
import type { MarketSummary } from '$lib/api/types.js';
|
||||
|
||||
interface Props {
|
||||
market: MarketSummary;
|
||||
}
|
||||
|
||||
let { market }: Props = $props();
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
function formatDistance(meters: number): string {
|
||||
if (meters < 1000) return `${Math.round(meters)} m`;
|
||||
return `${(meters / 1000).toFixed(1)} km`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<a
|
||||
href="/markt/{market.slug}"
|
||||
class="group block rounded-xl border border-gray-200 bg-white shadow-sm transition-shadow hover:shadow-md"
|
||||
>
|
||||
{#if market.image_url}
|
||||
<div class="aspect-[16/9] overflow-hidden rounded-t-xl">
|
||||
<img
|
||||
src={market.image_url}
|
||||
alt={market.name}
|
||||
class="h-full w-full object-cover transition-transform group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="p-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 group-hover:text-primary-600">
|
||||
{market.name}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
{market.city}{#if market.state}, {market.state}{/if}
|
||||
</p>
|
||||
<div class="mt-3 flex flex-wrap items-center gap-3 text-sm text-gray-600">
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" />
|
||||
</svg>
|
||||
{formatDate(market.start_date)} – {formatDate(market.end_date)}
|
||||
</span>
|
||||
{#if market.distance !== undefined}
|
||||
<span class="flex items-center gap-1 text-primary-600">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
|
||||
</svg>
|
||||
{formatDistance(market.distance)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if market.organizer_name}
|
||||
<p class="mt-2 text-xs text-gray-400">von {market.organizer_name}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
81
web/src/lib/components/market/MarketMap.svelte
Normal file
81
web/src/lib/components/market/MarketMap.svelte
Normal file
@@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import type { MarketSummary } from '$lib/api/types.js';
|
||||
|
||||
interface Props {
|
||||
markets: MarketSummary[];
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { markets, class: className = '' }: Props = $props();
|
||||
|
||||
let mapContainer: HTMLDivElement;
|
||||
let map: L.Map | undefined;
|
||||
|
||||
onMount(() => {
|
||||
let link: HTMLLinkElement;
|
||||
|
||||
(async () => {
|
||||
const L = await import('leaflet');
|
||||
|
||||
// Leaflet CSS
|
||||
link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
|
||||
document.head.appendChild(link);
|
||||
|
||||
// Fix default icon paths
|
||||
// @ts-expect-error — Leaflet icon path workaround
|
||||
delete L.Icon.Default.prototype._getIconUrl;
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
|
||||
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
||||
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png'
|
||||
});
|
||||
|
||||
map = L.map(mapContainer).setView([51.1657, 10.4515], 6); // Germany center
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
}).addTo(map);
|
||||
|
||||
updateMarkers(L, markets);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
map?.remove();
|
||||
link?.remove();
|
||||
};
|
||||
});
|
||||
|
||||
function updateMarkers(L: typeof import('leaflet'), items: MarketSummary[]) {
|
||||
if (!map) return;
|
||||
|
||||
// Clear existing markers
|
||||
map.eachLayer((layer) => {
|
||||
if (layer instanceof L.Marker) map!.removeLayer(layer);
|
||||
});
|
||||
|
||||
if (items.length === 0) return;
|
||||
|
||||
const bounds = L.latLngBounds([]);
|
||||
|
||||
for (const m of items) {
|
||||
const marker = L.marker([m.latitude, m.longitude]).addTo(map);
|
||||
marker.bindPopup(
|
||||
`<strong><a href="/markt/${m.slug}">${m.name}</a></strong><br>${m.city}`
|
||||
);
|
||||
bounds.extend([m.latitude, m.longitude]);
|
||||
}
|
||||
|
||||
map.fitBounds(bounds, { padding: [40, 40], maxZoom: 12 });
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (map) {
|
||||
import('leaflet').then((L) => updateMarkers(L, markets));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div bind:this={mapContainer} class="h-[400px] w-full rounded-xl border border-gray-200 {className}"></div>
|
||||
73
web/src/lib/components/market/Pagination.svelte
Normal file
73
web/src/lib/components/market/Pagination.svelte
Normal file
@@ -0,0 +1,73 @@
|
||||
<script lang="ts">
|
||||
import type { PaginationMeta } from '$lib/api/types.js';
|
||||
|
||||
interface Props {
|
||||
meta: PaginationMeta;
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
let { meta, baseUrl = '/' }: Props = $props();
|
||||
|
||||
function pageUrl(page: number): string {
|
||||
const url = new URL(baseUrl, 'http://localhost');
|
||||
const params = new URLSearchParams(url.search);
|
||||
params.set('page', String(page));
|
||||
return `${url.pathname}?${params.toString()}`;
|
||||
}
|
||||
|
||||
const pages = $derived.by(() => {
|
||||
const result: (number | '...')[] = [];
|
||||
const total = meta.total_pages;
|
||||
const current = meta.page;
|
||||
|
||||
if (total <= 7) {
|
||||
for (let i = 1; i <= total; i++) result.push(i);
|
||||
} else {
|
||||
result.push(1);
|
||||
if (current > 3) result.push('...');
|
||||
for (let i = Math.max(2, current - 1); i <= Math.min(total - 1, current + 1); i++) {
|
||||
result.push(i);
|
||||
}
|
||||
if (current < total - 2) result.push('...');
|
||||
result.push(total);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if meta.total_pages > 1}
|
||||
<nav class="flex items-center justify-center gap-1" aria-label="Seitennavigation">
|
||||
{#if meta.page > 1}
|
||||
<a
|
||||
href={pageUrl(meta.page - 1)}
|
||||
class="rounded-lg px-3 py-2 text-sm text-gray-600 hover:bg-gray-100"
|
||||
>
|
||||
Zurück
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#each pages as p}
|
||||
{#if p === '...'}
|
||||
<span class="px-2 text-gray-400">...</span>
|
||||
{:else}
|
||||
<a
|
||||
href={pageUrl(p)}
|
||||
class="rounded-lg px-3 py-2 text-sm {p === meta.page
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'text-gray-600 hover:bg-gray-100'}"
|
||||
>
|
||||
{p}
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#if meta.page < meta.total_pages}
|
||||
<a
|
||||
href={pageUrl(meta.page + 1)}
|
||||
class="rounded-lg px-3 py-2 text-sm text-gray-600 hover:bg-gray-100"
|
||||
>
|
||||
Weiter
|
||||
</a>
|
||||
{/if}
|
||||
</nav>
|
||||
{/if}
|
||||
123
web/src/lib/components/market/SearchForm.svelte
Normal file
123
web/src/lib/components/market/SearchForm.svelte
Normal file
@@ -0,0 +1,123 @@
|
||||
<script lang="ts">
|
||||
import Input from '$lib/components/ui/Input.svelte';
|
||||
import Select from '$lib/components/ui/Select.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
|
||||
interface Props {
|
||||
q?: string;
|
||||
plz?: string;
|
||||
radius?: number;
|
||||
from?: string;
|
||||
to?: string;
|
||||
sort?: string;
|
||||
lat?: number;
|
||||
lon?: number;
|
||||
}
|
||||
|
||||
let {
|
||||
q = '',
|
||||
plz = '',
|
||||
radius = 25,
|
||||
from = '',
|
||||
to = '',
|
||||
sort = '',
|
||||
lat,
|
||||
lon
|
||||
}: Props = $props();
|
||||
|
||||
let locating = $state(false);
|
||||
let locationError = $state('');
|
||||
let gpsLat = $state<number | undefined>(undefined);
|
||||
let gpsLon = $state<number | undefined>(undefined);
|
||||
|
||||
const currentLat = $derived(gpsLat ?? lat);
|
||||
const currentLon = $derived(gpsLon ?? lon);
|
||||
|
||||
function useGPS() {
|
||||
if (!navigator.geolocation) {
|
||||
locationError = 'Geolocation wird nicht unterstützt.';
|
||||
return;
|
||||
}
|
||||
locating = true;
|
||||
locationError = '';
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
gpsLat = pos.coords.latitude;
|
||||
gpsLon = pos.coords.longitude;
|
||||
locating = false;
|
||||
},
|
||||
(err) => {
|
||||
locationError = 'Standort konnte nicht ermittelt werden.';
|
||||
locating = false;
|
||||
},
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<form method="GET" action="/" class="space-y-4 rounded-xl border border-gray-200 bg-white p-4 shadow-sm sm:p-6">
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Input name="q" label="Suchbegriff" placeholder="z.B. Ritterturnier" value={q} />
|
||||
|
||||
<div class="space-y-1">
|
||||
<label for="plz" class="block text-sm font-medium text-gray-700">PLZ / Standort</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
id="plz"
|
||||
name="plz"
|
||||
type="text"
|
||||
placeholder="z.B. 80331"
|
||||
value={plz}
|
||||
class="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={useGPS}
|
||||
disabled={locating}
|
||||
class="shrink-0 rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50"
|
||||
title="Meinen Standort verwenden"
|
||||
>
|
||||
{#if locating}
|
||||
...
|
||||
{:else}
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{#if locationError}
|
||||
<p class="text-sm text-red-600">{locationError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Select name="radius" label="Umkreis" value={String(radius)}>
|
||||
<option value="10">10 km</option>
|
||||
<option value="25">25 km</option>
|
||||
<option value="50">50 km</option>
|
||||
<option value="100">100 km</option>
|
||||
<option value="200">200 km</option>
|
||||
</Select>
|
||||
|
||||
<Select name="sort" label="Sortierung" value={sort}>
|
||||
<option value="">Standard</option>
|
||||
<option value="distance">Entfernung</option>
|
||||
<option value="date">Datum</option>
|
||||
<option value="name">Name</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Input name="from" label="Von" type="date" value={from} />
|
||||
<Input name="to" label="Bis" type="date" value={to} />
|
||||
<div class="flex items-end lg:col-span-2">
|
||||
<Button type="submit" class="w-full sm:w-auto">Suchen</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if currentLat !== undefined && currentLon !== undefined}
|
||||
<input type="hidden" name="lat" value={currentLat} />
|
||||
<input type="hidden" name="lon" value={currentLon} />
|
||||
{/if}
|
||||
</form>
|
||||
21
web/src/lib/components/ui/Alert.svelte
Normal file
21
web/src/lib/components/ui/Alert.svelte
Normal file
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
variant?: 'info' | 'success' | 'warning' | 'error';
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { variant = 'info', children }: Props = $props();
|
||||
|
||||
const styles = {
|
||||
info: 'bg-blue-50 text-blue-800 border-blue-200',
|
||||
success: 'bg-green-50 text-green-800 border-green-200',
|
||||
warning: 'bg-amber-50 text-amber-800 border-amber-200',
|
||||
error: 'bg-red-50 text-red-800 border-red-200'
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="rounded-lg border p-4 text-sm {styles[variant]}" role="alert">
|
||||
{@render children()}
|
||||
</div>
|
||||
50
web/src/lib/components/ui/Button.svelte
Normal file
50
web/src/lib/components/ui/Button.svelte
Normal file
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLButtonAttributes } from 'svelte/elements';
|
||||
|
||||
interface Props extends HTMLButtonAttributes {
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
loading?: boolean;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
loading = false,
|
||||
children,
|
||||
class: className = '',
|
||||
disabled,
|
||||
...rest
|
||||
}: Props = $props();
|
||||
|
||||
const base = 'inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
|
||||
const variants = {
|
||||
primary: 'bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500',
|
||||
secondary: 'bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 focus:ring-primary-500',
|
||||
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
|
||||
ghost: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100 focus:ring-primary-500'
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: 'px-3 py-1.5 text-sm',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-6 py-3 text-base'
|
||||
};
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="{base} {variants[variant]} {sizes[size]} {className}"
|
||||
disabled={disabled || loading}
|
||||
{...rest}
|
||||
>
|
||||
{#if loading}
|
||||
<svg class="animate-spin -ml-1 mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
{/if}
|
||||
{@render children()}
|
||||
</button>
|
||||
32
web/src/lib/components/ui/Input.svelte
Normal file
32
web/src/lib/components/ui/Input.svelte
Normal file
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLInputAttributes } from 'svelte/elements';
|
||||
|
||||
interface Props extends HTMLInputAttributes {
|
||||
label?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
label,
|
||||
error,
|
||||
id,
|
||||
class: className = '',
|
||||
...rest
|
||||
}: Props = $props();
|
||||
|
||||
const inputId = $derived(id ?? label?.toLowerCase().replace(/\s+/g, '-'));
|
||||
</script>
|
||||
|
||||
<div class="space-y-1">
|
||||
{#if label}
|
||||
<label for={inputId} class="block text-sm font-medium text-gray-700">{label}</label>
|
||||
{/if}
|
||||
<input
|
||||
id={inputId}
|
||||
class="block w-full rounded-lg border px-3 py-2 text-sm shadow-sm transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 {error ? 'border-red-300 text-red-900 placeholder-red-300' : 'border-gray-300 text-gray-900 placeholder-gray-400'} {className}"
|
||||
{...rest}
|
||||
/>
|
||||
{#if error}
|
||||
<p class="text-sm text-red-600">{error}</p>
|
||||
{/if}
|
||||
</div>
|
||||
37
web/src/lib/components/ui/Select.svelte
Normal file
37
web/src/lib/components/ui/Select.svelte
Normal file
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLSelectAttributes } from 'svelte/elements';
|
||||
|
||||
interface Props extends HTMLSelectAttributes {
|
||||
label?: string;
|
||||
error?: string;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
label,
|
||||
error,
|
||||
id,
|
||||
children,
|
||||
class: className = '',
|
||||
...rest
|
||||
}: Props = $props();
|
||||
|
||||
const selectId = $derived(id ?? label?.toLowerCase().replace(/\s+/g, '-'));
|
||||
</script>
|
||||
|
||||
<div class="space-y-1">
|
||||
{#if label}
|
||||
<label for={selectId} class="block text-sm font-medium text-gray-700">{label}</label>
|
||||
{/if}
|
||||
<select
|
||||
id={selectId}
|
||||
class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 {className}"
|
||||
{...rest}
|
||||
>
|
||||
{@render children()}
|
||||
</select>
|
||||
{#if error}
|
||||
<p class="text-sm text-red-600">{error}</p>
|
||||
{/if}
|
||||
</div>
|
||||
25
web/src/lib/components/ui/Spinner.svelte
Normal file
25
web/src/lib/components/ui/Spinner.svelte
Normal file
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
let { size = 'md' }: Props = $props();
|
||||
|
||||
const sizes = {
|
||||
sm: 'h-4 w-4',
|
||||
md: 'h-8 w-8',
|
||||
lg: 'h-12 w-12'
|
||||
};
|
||||
</script>
|
||||
|
||||
<svg
|
||||
class="animate-spin text-primary-600 {sizes[size]}"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
role="status"
|
||||
aria-label="Laden..."
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
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.
|
||||
27
web/src/routes/+error.svelte
Normal file
27
web/src/routes/+error.svelte
Normal file
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Fehler {$page.status} - Marktvogt</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto flex max-w-lg flex-col items-center justify-center px-4 py-24 text-center">
|
||||
<h1 class="text-6xl font-bold text-gray-300">{$page.status}</h1>
|
||||
<p class="mt-4 text-lg text-gray-600">
|
||||
{#if $page.status === 404}
|
||||
Die gesuchte Seite wurde nicht gefunden.
|
||||
{:else}
|
||||
Ein Fehler ist aufgetreten.
|
||||
{/if}
|
||||
</p>
|
||||
{#if $page.error?.message}
|
||||
<p class="mt-2 text-sm text-gray-500">{$page.error.message}</p>
|
||||
{/if}
|
||||
<div class="mt-8">
|
||||
<a href="/">
|
||||
<Button variant="primary">Zur Startseite</Button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
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.js';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
return {
|
||||
user: locals.user
|
||||
};
|
||||
};
|
||||
26
web/src/routes/+layout.svelte
Normal file
26
web/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import Header from '$lib/components/layout/Header.svelte';
|
||||
import Footer from '$lib/components/layout/Footer.svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
data: { user: import('$lib/api/types.js').ProfileData | null };
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { data, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Marktvogt - Mittelaltermärkte finden</title>
|
||||
<meta name="description" content="Finde Mittelaltermärkte, Ritterturniere und historische Feste in deiner Nähe." />
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex min-h-screen flex-col bg-gray-50">
|
||||
<Header user={data.user} />
|
||||
<main class="flex-1">
|
||||
{@render children()}
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
59
web/src/routes/+page.server.ts
Normal file
59
web/src/routes/+page.server.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { PageServerLoad } from './$types.js';
|
||||
import { apiFetch, buildSearchQuery } from '$lib/api/client.js';
|
||||
import type { MarketSummary, PaginationMeta } from '$lib/api/types.js';
|
||||
|
||||
export const load: PageServerLoad = async ({ url, fetch }) => {
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
const q = url.searchParams.get('q');
|
||||
const lat = url.searchParams.get('lat');
|
||||
const lon = url.searchParams.get('lon');
|
||||
const radius = url.searchParams.get('radius');
|
||||
const from = url.searchParams.get('from');
|
||||
const to = url.searchParams.get('to');
|
||||
const sort = url.searchParams.get('sort');
|
||||
const page = url.searchParams.get('page');
|
||||
|
||||
if (q) params.q = q;
|
||||
if (lat) params.lat = lat;
|
||||
if (lon) params.lon = lon;
|
||||
if (radius) params.radius = radius;
|
||||
if (from) params.from = from;
|
||||
if (to) params.to = to;
|
||||
if (sort) params.sort = sort;
|
||||
if (page) params.page = page;
|
||||
|
||||
const query = buildSearchQuery(params);
|
||||
const path = `/markets${query ? `?${query}` : ''}`;
|
||||
|
||||
try {
|
||||
const res = await apiFetch<MarketSummary[]>(path, { fetch });
|
||||
return {
|
||||
markets: res.data,
|
||||
meta: res.meta as PaginationMeta,
|
||||
searchParams: {
|
||||
q: q ?? '',
|
||||
lat: lat ? Number(lat) : undefined,
|
||||
lon: lon ? Number(lon) : undefined,
|
||||
radius: radius ? Number(radius) : 25,
|
||||
from: from ?? '',
|
||||
to: to ?? '',
|
||||
sort: sort ?? ''
|
||||
}
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
markets: [] as MarketSummary[],
|
||||
meta: { page: 1, per_page: 20, total: 0, total_pages: 0 } as PaginationMeta,
|
||||
searchParams: {
|
||||
q: q ?? '',
|
||||
lat: lat ? Number(lat) : undefined,
|
||||
lon: lon ? Number(lon) : undefined,
|
||||
radius: radius ? Number(radius) : 25,
|
||||
from: from ?? '',
|
||||
to: to ?? '',
|
||||
sort: sort ?? ''
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
81
web/src/routes/+page.svelte
Normal file
81
web/src/routes/+page.svelte
Normal file
@@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
import SearchForm from '$lib/components/market/SearchForm.svelte';
|
||||
import MarketCard from '$lib/components/market/MarketCard.svelte';
|
||||
import MarketMap from '$lib/components/market/MarketMap.svelte';
|
||||
import Pagination from '$lib/components/market/Pagination.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let view = $state<'list' | 'map'>('list');
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Marktvogt - Mittelaltermärkte finden</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div class="mb-8 text-center">
|
||||
<h1 class="text-3xl font-bold text-gray-900 sm:text-4xl">Mittelaltermärkte finden</h1>
|
||||
<p class="mt-2 text-lg text-gray-600">Entdecke Mittelaltermärkte, Ritterturniere und historische Feste in deiner Nähe.</p>
|
||||
</div>
|
||||
|
||||
<SearchForm
|
||||
q={data.searchParams.q}
|
||||
radius={data.searchParams.radius}
|
||||
from={data.searchParams.from}
|
||||
to={data.searchParams.to}
|
||||
sort={data.searchParams.sort}
|
||||
lat={data.searchParams.lat}
|
||||
lon={data.searchParams.lon}
|
||||
/>
|
||||
|
||||
<div class="mt-8">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<p class="text-sm text-gray-500">
|
||||
{data.meta.total} {data.meta.total === 1 ? 'Markt' : 'Märkte'} gefunden
|
||||
</p>
|
||||
<div class="flex gap-1 rounded-lg border border-gray-200 bg-white p-1">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md px-3 py-1.5 text-sm font-medium transition-colors {view === 'list'
|
||||
? 'bg-primary-100 text-primary-700'
|
||||
: 'text-gray-500 hover:text-gray-700'}"
|
||||
onclick={() => (view = 'list')}
|
||||
>
|
||||
Liste
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md px-3 py-1.5 text-sm font-medium transition-colors {view === 'map'
|
||||
? 'bg-primary-100 text-primary-700'
|
||||
: 'text-gray-500 hover:text-gray-700'}"
|
||||
onclick={() => (view = 'map')}
|
||||
>
|
||||
Karte
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if view === 'list'}
|
||||
{#if data.markets.length > 0}
|
||||
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each data.markets as market (market.id)}
|
||||
<MarketCard {market} />
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-xl border border-gray-200 bg-white py-16 text-center">
|
||||
<p class="text-gray-500">Keine Märkte gefunden. Versuche andere Suchkriterien.</p>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<MarketMap markets={data.markets} />
|
||||
{/if}
|
||||
|
||||
{#if data.meta.total_pages > 1}
|
||||
<div class="mt-8">
|
||||
<Pagination meta={data.meta} baseUrl="/?{new URLSearchParams(Object.entries(data.searchParams).filter(([, v]) => v !== undefined && v !== '').map(([k, v]) => [k, String(v)])).toString()}" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
25
web/src/routes/auth/abmelden/+page.server.ts
Normal file
25
web/src/routes/auth/abmelden/+page.server.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { Actions } from './$types.js';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { apiFetch } from '$lib/api/client.js';
|
||||
import { clearAuthCookies } from '$lib/auth/cookies.js';
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ cookies, fetch }) => {
|
||||
const accessToken = cookies.get('access_token');
|
||||
|
||||
if (accessToken) {
|
||||
try {
|
||||
await apiFetch('/auth/logout', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
fetch
|
||||
});
|
||||
} catch {
|
||||
// Best-effort logout
|
||||
}
|
||||
}
|
||||
|
||||
clearAuthCookies(cookies);
|
||||
redirect(302, '/');
|
||||
}
|
||||
};
|
||||
66
web/src/routes/auth/anmelden/+page.server.ts
Normal file
66
web/src/routes/auth/anmelden/+page.server.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { Actions, PageServerLoad } from './$types.js';
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { apiFetch, ApiClientError } from '$lib/api/client.js';
|
||||
import type { AuthData, MessageData } from '$lib/api/types.js';
|
||||
import { setAuthCookies } from '$lib/auth/cookies.js';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, url }) => {
|
||||
if (locals.user) redirect(302, '/');
|
||||
return {
|
||||
redirectTo: url.searchParams.get('redirect') ?? '/'
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request, cookies, fetch }) => {
|
||||
const form = await request.formData();
|
||||
const email = form.get('email') as string;
|
||||
const password = form.get('password') as string;
|
||||
const totpCode = form.get('totp_code') as string | null;
|
||||
const redirectTo = form.get('redirect_to') as string | '/';
|
||||
|
||||
try {
|
||||
const res = await apiFetch<AuthData>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
password,
|
||||
...(totpCode ? { totp_code: totpCode } : {})
|
||||
}),
|
||||
fetch
|
||||
});
|
||||
|
||||
setAuthCookies(cookies, res.data);
|
||||
} catch (e) {
|
||||
if (e instanceof ApiClientError) {
|
||||
if (e.code === 'totp_required') {
|
||||
return fail(400, { error: e.message, requireTotp: true as const });
|
||||
}
|
||||
return fail(e.status, { error: e.message, requireTotp: false as const });
|
||||
}
|
||||
return fail(500, { error: 'Ein unerwarteter Fehler ist aufgetreten.', requireTotp: false as const });
|
||||
}
|
||||
|
||||
redirect(302, redirectTo || '/');
|
||||
},
|
||||
|
||||
magicLink: async ({ request, fetch }) => {
|
||||
const form = await request.formData();
|
||||
const email = form.get('email') as string;
|
||||
|
||||
try {
|
||||
await apiFetch<MessageData>('/auth/magic-link/request', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email }),
|
||||
fetch
|
||||
});
|
||||
|
||||
return { magicLinkSuccess: 'Ein Login-Link wurde an deine E-Mail gesendet.' };
|
||||
} catch (e) {
|
||||
if (e instanceof ApiClientError) {
|
||||
return fail(e.status, { magicLinkError: e.message });
|
||||
}
|
||||
return fail(500, { magicLinkError: 'Ein Fehler ist aufgetreten.' });
|
||||
}
|
||||
}
|
||||
};
|
||||
62
web/src/routes/auth/anmelden/+page.svelte
Normal file
62
web/src/routes/auth/anmelden/+page.svelte
Normal file
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
import LoginForm from '$lib/components/auth/LoginForm.svelte';
|
||||
import MagicLinkForm from '$lib/components/auth/MagicLinkForm.svelte';
|
||||
import OAuthButtons from '$lib/components/auth/OAuthButtons.svelte';
|
||||
|
||||
let { data, form } = $props();
|
||||
let tab = $state<'password' | 'magic'>('password');
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Anmelden - Marktvogt</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-md px-4 py-16">
|
||||
<h1 class="mb-8 text-center text-2xl font-bold text-gray-900">Anmelden</h1>
|
||||
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
|
||||
<div class="mb-6 flex gap-1 rounded-lg border border-gray-200 bg-gray-50 p-1">
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 rounded-md px-3 py-2 text-sm font-medium transition-colors {tab === 'password'
|
||||
? 'bg-white text-gray-900 shadow-sm'
|
||||
: 'text-gray-500 hover:text-gray-700'}"
|
||||
onclick={() => (tab = 'password')}
|
||||
>
|
||||
Passwort
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 rounded-md px-3 py-2 text-sm font-medium transition-colors {tab === 'magic'
|
||||
? 'bg-white text-gray-900 shadow-sm'
|
||||
: 'text-gray-500 hover:text-gray-700'}"
|
||||
onclick={() => (tab = 'magic')}
|
||||
>
|
||||
Magic Link
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if tab === 'password'}
|
||||
<LoginForm error={form?.error} requireTotp={form?.requireTotp ?? false} />
|
||||
<input type="hidden" name="redirect_to" value={data.redirectTo} form="login" />
|
||||
{:else}
|
||||
<MagicLinkForm error={form?.magicLinkError} success={form?.magicLinkSuccess} />
|
||||
{/if}
|
||||
|
||||
<div class="relative my-6">
|
||||
<div class="absolute inset-0 flex items-center">
|
||||
<div class="w-full border-t border-gray-200"></div>
|
||||
</div>
|
||||
<div class="relative flex justify-center text-sm">
|
||||
<span class="bg-white px-2 text-gray-500">oder</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OAuthButtons />
|
||||
</div>
|
||||
|
||||
<p class="mt-6 text-center text-sm text-gray-500">
|
||||
Noch kein Konto?
|
||||
<a href="/auth/registrieren" class="font-medium text-primary-600 hover:text-primary-700">Registrieren</a>
|
||||
</p>
|
||||
</div>
|
||||
28
web/src/routes/auth/magic-link/verify/+page.server.ts
Normal file
28
web/src/routes/auth/magic-link/verify/+page.server.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { PageServerLoad } from './$types.js';
|
||||
import { redirect, error } from '@sveltejs/kit';
|
||||
import { apiFetch, ApiClientError } from '$lib/api/client.js';
|
||||
import type { AuthData } from '$lib/api/types.js';
|
||||
import { setAuthCookies } from '$lib/auth/cookies.js';
|
||||
|
||||
export const load: PageServerLoad = async ({ url, cookies, fetch }) => {
|
||||
const token = url.searchParams.get('token');
|
||||
|
||||
if (!token) {
|
||||
error(400, { message: 'Kein Token angegeben.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await apiFetch<AuthData>(`/auth/magic-link/verify?token=${token}`, {
|
||||
fetch
|
||||
});
|
||||
|
||||
setAuthCookies(cookies, res.data);
|
||||
} catch (e) {
|
||||
if (e instanceof ApiClientError) {
|
||||
error(e.status, { message: e.message });
|
||||
}
|
||||
error(500, { message: 'Ein Fehler ist aufgetreten.' });
|
||||
}
|
||||
|
||||
redirect(302, '/');
|
||||
};
|
||||
22
web/src/routes/auth/oauth/callback/+page.server.ts
Normal file
22
web/src/routes/auth/oauth/callback/+page.server.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { PageServerLoad } from './$types.js';
|
||||
import { redirect, error } from '@sveltejs/kit';
|
||||
import { setAuthCookies } from '$lib/auth/cookies.js';
|
||||
|
||||
export const load: PageServerLoad = async ({ url, cookies }) => {
|
||||
const accessToken = url.searchParams.get('access_token');
|
||||
const sessionToken = url.searchParams.get('session_token');
|
||||
const expiresIn = url.searchParams.get('expires_in');
|
||||
|
||||
if (!accessToken || !sessionToken) {
|
||||
const errMsg = url.searchParams.get('error');
|
||||
error(400, { message: errMsg ?? 'OAuth-Anmeldung fehlgeschlagen.' });
|
||||
}
|
||||
|
||||
setAuthCookies(cookies, {
|
||||
access_token: accessToken,
|
||||
session_token: sessionToken,
|
||||
expires_in: expiresIn ? parseInt(expiresIn, 10) : 900
|
||||
});
|
||||
|
||||
redirect(302, '/');
|
||||
};
|
||||
39
web/src/routes/auth/registrieren/+page.server.ts
Normal file
39
web/src/routes/auth/registrieren/+page.server.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { Actions, PageServerLoad } from './$types.js';
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { apiFetch, ApiClientError } from '$lib/api/client.js';
|
||||
import type { AuthData } from '$lib/api/types.js';
|
||||
import { setAuthCookies } from '$lib/auth/cookies.js';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
if (locals.user) redirect(302, '/');
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request, cookies, fetch }) => {
|
||||
const form = await request.formData();
|
||||
const email = form.get('email') as string;
|
||||
const password = form.get('password') as string;
|
||||
const displayName = form.get('display_name') as string;
|
||||
|
||||
try {
|
||||
const res = await apiFetch<AuthData>('/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
password,
|
||||
display_name: displayName
|
||||
}),
|
||||
fetch
|
||||
});
|
||||
|
||||
setAuthCookies(cookies, res.data);
|
||||
} catch (e) {
|
||||
if (e instanceof ApiClientError) {
|
||||
return fail(e.status, { error: e.message });
|
||||
}
|
||||
return fail(500, { error: 'Ein unerwarteter Fehler ist aufgetreten.' });
|
||||
}
|
||||
|
||||
redirect(302, '/');
|
||||
}
|
||||
};
|
||||
22
web/src/routes/auth/registrieren/+page.svelte
Normal file
22
web/src/routes/auth/registrieren/+page.svelte
Normal file
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import RegisterForm from '$lib/components/auth/RegisterForm.svelte';
|
||||
|
||||
let { form } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Registrieren - Marktvogt</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-md px-4 py-16">
|
||||
<h1 class="mb-8 text-center text-2xl font-bold text-gray-900">Konto erstellen</h1>
|
||||
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
|
||||
<RegisterForm error={form?.error} />
|
||||
</div>
|
||||
|
||||
<p class="mt-6 text-center text-sm text-gray-500">
|
||||
Bereits ein Konto?
|
||||
<a href="/auth/anmelden" class="font-medium text-primary-600 hover:text-primary-700">Anmelden</a>
|
||||
</p>
|
||||
</div>
|
||||
16
web/src/routes/markt/[slug]/+page.server.ts
Normal file
16
web/src/routes/markt/[slug]/+page.server.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { PageServerLoad } from './$types.js';
|
||||
import { apiFetch, ApiClientError } from '$lib/api/client.js';
|
||||
import type { MarketDetail } from '$lib/api/types.js';
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, fetch }) => {
|
||||
try {
|
||||
const res = await apiFetch<MarketDetail>(`/markets/${params.slug}`, { fetch });
|
||||
return { market: res.data };
|
||||
} catch (e) {
|
||||
if (e instanceof ApiClientError && e.status === 404) {
|
||||
error(404, { message: 'Markt nicht gefunden.' });
|
||||
}
|
||||
error(500, { message: 'Fehler beim Laden des Marktes.' });
|
||||
}
|
||||
};
|
||||
180
web/src/routes/markt/[slug]/+page.svelte
Normal file
180
web/src/routes/markt/[slug]/+page.svelte
Normal file
@@ -0,0 +1,180 @@
|
||||
<script lang="ts">
|
||||
import MarketMap from '$lib/components/market/MarketMap.svelte';
|
||||
import type { MarketDetail, OpeningHoursEntry, AdmissionInfo } from '$lib/api/types.js';
|
||||
|
||||
let { data } = $props();
|
||||
const market: MarketDetail = $derived(data.market);
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('de-DE', {
|
||||
weekday: 'long',
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
function centsToEuro(cents: number): string {
|
||||
return (cents / 100).toFixed(2).replace('.', ',') + ' \u20AC';
|
||||
}
|
||||
|
||||
const openingHours: OpeningHoursEntry[] = $derived(
|
||||
Array.isArray(market.opening_hours) ? market.opening_hours : []
|
||||
);
|
||||
|
||||
const admission: AdmissionInfo | null = $derived(
|
||||
market.admission_info && typeof market.admission_info === 'object'
|
||||
? market.admission_info
|
||||
: null
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{market.name} - Marktvogt</title>
|
||||
<meta name="description" content="{market.name} in {market.city}. {market.description?.slice(0, 150)}" />
|
||||
<meta property="og:title" content="{market.name} - Marktvogt" />
|
||||
<meta property="og:description" content="{market.description?.slice(0, 200)}" />
|
||||
{#if market.image_url}
|
||||
<meta property="og:image" content={market.image_url} />
|
||||
{/if}
|
||||
<meta property="og:type" content="event" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<a href="/" class="mb-6 inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
Zurück zur Suche
|
||||
</a>
|
||||
|
||||
{#if market.image_url}
|
||||
<div class="mb-8 overflow-hidden rounded-xl">
|
||||
<img
|
||||
src={market.image_url}
|
||||
alt={market.name}
|
||||
class="h-64 w-full object-cover sm:h-80"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<h1 class="text-3xl font-bold text-gray-900">{market.name}</h1>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-4 text-sm text-gray-600">
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" />
|
||||
</svg>
|
||||
{formatDate(market.start_date)} – {formatDate(market.end_date)}
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
|
||||
</svg>
|
||||
{market.street}, {market.zip} {market.city}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if market.organizer_name}
|
||||
<p class="mt-2 text-sm text-gray-500">Veranstalter: {market.organizer_name}</p>
|
||||
{/if}
|
||||
|
||||
{#if market.description}
|
||||
<div class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Beschreibung</h2>
|
||||
<p class="mt-2 whitespace-pre-line text-gray-700">{market.description}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-8 grid gap-8 sm:grid-cols-2">
|
||||
{#if openingHours.length > 0}
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900">Öffnungszeiten</h2>
|
||||
<table class="mt-3 w-full text-sm">
|
||||
<tbody>
|
||||
{#each openingHours as entry}
|
||||
<tr class="border-b border-gray-100">
|
||||
<td class="py-2 font-medium text-gray-700">{entry.day}</td>
|
||||
<td class="py-2 text-right text-gray-600">{entry.open} – {entry.close}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if admission}
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900">Eintrittspreise</h2>
|
||||
<table class="mt-3 w-full text-sm">
|
||||
<tbody>
|
||||
<tr class="border-b border-gray-100">
|
||||
<td class="py-2 font-medium text-gray-700">Erwachsene</td>
|
||||
<td class="py-2 text-right text-gray-600">{centsToEuro(admission.adult_cents)}</td>
|
||||
</tr>
|
||||
{#if admission.reduced_cents > 0}
|
||||
<tr class="border-b border-gray-100">
|
||||
<td class="py-2 font-medium text-gray-700">Ermäßigt</td>
|
||||
<td class="py-2 text-right text-gray-600">{centsToEuro(admission.reduced_cents)}</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{#if admission.child_cents > 0}
|
||||
<tr class="border-b border-gray-100">
|
||||
<td class="py-2 font-medium text-gray-700">Kinder</td>
|
||||
<td class="py-2 text-right text-gray-600">{centsToEuro(admission.child_cents)}</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{#if admission.free_under_age > 0}
|
||||
<tr class="border-b border-gray-100">
|
||||
<td class="py-2 font-medium text-gray-700">Frei unter</td>
|
||||
<td class="py-2 text-right text-gray-600">{admission.free_under_age} Jahre</td>
|
||||
</tr>
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
{#if admission.notes}
|
||||
<p class="mt-2 text-sm text-gray-500">{admission.notes}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if market.website}
|
||||
<div class="mt-8">
|
||||
<a
|
||||
href={market.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1 text-sm font-medium text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
Website besuchen
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-8">
|
||||
<h2 class="mb-3 text-lg font-semibold text-gray-900">Standort</h2>
|
||||
<MarketMap
|
||||
markets={[{
|
||||
id: market.id,
|
||||
slug: market.slug,
|
||||
name: market.name,
|
||||
city: market.city,
|
||||
state: market.state,
|
||||
zip: market.zip,
|
||||
country: market.country,
|
||||
latitude: market.latitude,
|
||||
longitude: market.longitude,
|
||||
start_date: market.start_date,
|
||||
end_date: market.end_date,
|
||||
image_url: market.image_url,
|
||||
organizer_name: market.organizer_name
|
||||
}]}
|
||||
class="h-[300px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
58
web/src/routes/profile/+page.server.ts
Normal file
58
web/src/routes/profile/+page.server.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { Actions, PageServerLoad } from './$types.js';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { requireAuth } from '$lib/auth/guard.js';
|
||||
import { serverFetch } from '$lib/api/client.server.js';
|
||||
import { ApiClientError } from '$lib/api/client.js';
|
||||
import { clearAuthCookies } from '$lib/auth/cookies.js';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { ProfileData } from '$lib/api/types.js';
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
requireAuth(event);
|
||||
|
||||
const res = await serverFetch<ProfileData>('/users/me', event.cookies, { fetch: event.fetch });
|
||||
return { profile: res.data };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
update: async ({ request, cookies, fetch }) => {
|
||||
const form = await request.formData();
|
||||
const displayName = form.get('display_name') as string;
|
||||
const avatarUrl = form.get('avatar_url') as string;
|
||||
|
||||
const body: Record<string, string> = {};
|
||||
if (displayName) body.display_name = displayName;
|
||||
if (avatarUrl) body.avatar_url = avatarUrl;
|
||||
|
||||
try {
|
||||
await serverFetch('/users/me', cookies, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(body),
|
||||
fetch
|
||||
});
|
||||
return { success: 'Profil aktualisiert.' };
|
||||
} catch (e) {
|
||||
if (e instanceof ApiClientError) {
|
||||
return fail(e.status, { error: e.message });
|
||||
}
|
||||
return fail(500, { error: 'Ein Fehler ist aufgetreten.' });
|
||||
}
|
||||
},
|
||||
|
||||
delete: async ({ cookies, fetch }) => {
|
||||
try {
|
||||
await serverFetch('/users/me', cookies, {
|
||||
method: 'DELETE',
|
||||
fetch
|
||||
});
|
||||
clearAuthCookies(cookies);
|
||||
} catch (e) {
|
||||
if (e instanceof ApiClientError) {
|
||||
return fail(e.status, { error: e.message });
|
||||
}
|
||||
return fail(500, { error: 'Ein Fehler ist aufgetreten.' });
|
||||
}
|
||||
|
||||
redirect(302, '/');
|
||||
}
|
||||
};
|
||||
119
web/src/routes/profile/+page.svelte
Normal file
119
web/src/routes/profile/+page.svelte
Normal file
@@ -0,0 +1,119 @@
|
||||
<script lang="ts">
|
||||
import Input from '$lib/components/ui/Input.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Alert from '$lib/components/ui/Alert.svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
let showDeleteConfirm = $state(false);
|
||||
let updateLoading = $state(false);
|
||||
let deleteLoading = $state(false);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Profil - Marktvogt</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-2xl px-4 py-8 sm:px-6">
|
||||
<h1 class="mb-8 text-2xl font-bold text-gray-900">Profil</h1>
|
||||
|
||||
<div class="space-y-8">
|
||||
<!-- Profile info -->
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
|
||||
<h2 class="mb-4 text-lg font-semibold text-gray-900">Kontoinformationen</h2>
|
||||
|
||||
{#if form?.success}
|
||||
<Alert variant="success">{form.success}</Alert>
|
||||
{/if}
|
||||
{#if form?.error}
|
||||
<Alert variant="error">{form.error}</Alert>
|
||||
{/if}
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/update"
|
||||
use:enhance={() => {
|
||||
updateLoading = true;
|
||||
return async ({ update }) => {
|
||||
updateLoading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="mt-4 space-y-4"
|
||||
>
|
||||
<div class="text-sm text-gray-500">
|
||||
<span class="font-medium text-gray-700">E-Mail:</span> {data.profile.email}
|
||||
</div>
|
||||
|
||||
<Input
|
||||
name="display_name"
|
||||
label="Anzeigename"
|
||||
value={data.profile.display_name}
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
name="avatar_url"
|
||||
label="Avatar-URL"
|
||||
type="url"
|
||||
value={data.profile.avatar_url}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
|
||||
<Button type="submit" loading={updateLoading}>Speichern</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Security -->
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
|
||||
<h2 class="mb-4 text-lg font-semibold text-gray-900">Sicherheit</h2>
|
||||
<a
|
||||
href="/profile/security"
|
||||
class="text-sm font-medium text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
Zwei-Faktor-Authentifizierung verwalten
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Danger zone -->
|
||||
<div class="rounded-xl border border-red-200 bg-white p-6 shadow-sm">
|
||||
<h2 class="mb-4 text-lg font-semibold text-red-600">Konto löschen</h2>
|
||||
<p class="mb-4 text-sm text-gray-600">
|
||||
Dein Konto wird deaktiviert und nach 30 Tagen endgültig gelöscht.
|
||||
</p>
|
||||
|
||||
{#if !showDeleteConfirm}
|
||||
<Button variant="danger" onclick={() => (showDeleteConfirm = true)}>
|
||||
Konto löschen
|
||||
</Button>
|
||||
{:else}
|
||||
<div class="rounded-lg border border-red-200 bg-red-50 p-4">
|
||||
<p class="mb-4 text-sm font-medium text-red-800">
|
||||
Bist du sicher? Diese Aktion kann innerhalb von 30 Tagen rückgängig gemacht werden.
|
||||
</p>
|
||||
<div class="flex gap-3">
|
||||
<form
|
||||
method="POST"
|
||||
action="?/delete"
|
||||
use:enhance={() => {
|
||||
deleteLoading = true;
|
||||
return async ({ update }) => {
|
||||
deleteLoading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<Button type="submit" variant="danger" loading={deleteLoading}>
|
||||
Endgültig löschen
|
||||
</Button>
|
||||
</form>
|
||||
<Button variant="secondary" onclick={() => (showDeleteConfirm = false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
65
web/src/routes/profile/security/+page.server.ts
Normal file
65
web/src/routes/profile/security/+page.server.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { Actions, PageServerLoad } from './$types.js';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { requireAuth } from '$lib/auth/guard.js';
|
||||
import { serverFetch } from '$lib/api/client.server.js';
|
||||
import { ApiClientError } from '$lib/api/client.js';
|
||||
import type { TOTPSetupData, MessageData } from '$lib/api/types.js';
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
requireAuth(event);
|
||||
return {};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
setup: async ({ cookies, fetch }) => {
|
||||
try {
|
||||
const res = await serverFetch<TOTPSetupData>('/auth/2fa/setup', cookies, {
|
||||
method: 'POST',
|
||||
fetch
|
||||
});
|
||||
return {
|
||||
totpSecret: res.data.secret,
|
||||
totpUrl: res.data.url
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof ApiClientError) {
|
||||
return fail(e.status, { error: e.message });
|
||||
}
|
||||
return fail(500, { error: 'Ein Fehler ist aufgetreten.' });
|
||||
}
|
||||
},
|
||||
|
||||
verify: async ({ request, cookies, fetch }) => {
|
||||
const form = await request.formData();
|
||||
const code = form.get('code') as string;
|
||||
|
||||
try {
|
||||
await serverFetch<MessageData>('/auth/2fa/verify', cookies, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ code }),
|
||||
fetch
|
||||
});
|
||||
return { success: '2FA wurde erfolgreich aktiviert.' };
|
||||
} catch (e) {
|
||||
if (e instanceof ApiClientError) {
|
||||
return fail(e.status, { error: e.message });
|
||||
}
|
||||
return fail(500, { error: 'Ein Fehler ist aufgetreten.' });
|
||||
}
|
||||
},
|
||||
|
||||
disable: async ({ cookies, fetch }) => {
|
||||
try {
|
||||
await serverFetch<MessageData>('/auth/2fa', cookies, {
|
||||
method: 'DELETE',
|
||||
fetch
|
||||
});
|
||||
return { success: '2FA wurde deaktiviert.' };
|
||||
} catch (e) {
|
||||
if (e instanceof ApiClientError) {
|
||||
return fail(e.status, { error: e.message });
|
||||
}
|
||||
return fail(500, { error: 'Ein Fehler ist aufgetreten.' });
|
||||
}
|
||||
}
|
||||
};
|
||||
100
web/src/routes/profile/security/+page.svelte
Normal file
100
web/src/routes/profile/security/+page.svelte
Normal file
@@ -0,0 +1,100 @@
|
||||
<script lang="ts">
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Alert from '$lib/components/ui/Alert.svelte';
|
||||
import TOTPSetup from '$lib/components/auth/TOTPSetup.svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
let { form } = $props();
|
||||
|
||||
let setupLoading = $state(false);
|
||||
let disableLoading = $state(false);
|
||||
let showDisableConfirm = $state(false);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Sicherheit - Marktvogt</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-2xl px-4 py-8 sm:px-6">
|
||||
<div class="mb-6">
|
||||
<a href="/profile" class="text-sm text-gray-500 hover:text-gray-700">
|
||||
← Zurück zum Profil
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<h1 class="mb-8 text-2xl font-bold text-gray-900">Zwei-Faktor-Authentifizierung</h1>
|
||||
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
|
||||
{#if form?.success}
|
||||
<Alert variant="success">{form.success}</Alert>
|
||||
{/if}
|
||||
|
||||
{#if form?.totpSecret}
|
||||
<TOTPSetup
|
||||
secret={form.totpSecret}
|
||||
url={form.totpUrl}
|
||||
error={form?.error}
|
||||
/>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-gray-600">
|
||||
Schütze dein Konto mit einer Authenticator-App (z.B. Google Authenticator, Authy).
|
||||
</p>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<form
|
||||
method="POST"
|
||||
action="?/setup"
|
||||
use:enhance={() => {
|
||||
setupLoading = true;
|
||||
return async ({ update }) => {
|
||||
setupLoading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<Button type="submit" loading={setupLoading}>2FA einrichten</Button>
|
||||
</form>
|
||||
|
||||
{#if !showDisableConfirm}
|
||||
<Button variant="secondary" onclick={() => (showDisableConfirm = true)}>
|
||||
2FA deaktivieren
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showDisableConfirm}
|
||||
<div class="rounded-lg border border-red-200 bg-red-50 p-4">
|
||||
<p class="mb-3 text-sm text-red-800">
|
||||
Bist du sicher? Dein Konto wird weniger sicher sein.
|
||||
</p>
|
||||
<div class="flex gap-3">
|
||||
<form
|
||||
method="POST"
|
||||
action="?/disable"
|
||||
use:enhance={() => {
|
||||
disableLoading = true;
|
||||
return async ({ update }) => {
|
||||
disableLoading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<Button type="submit" variant="danger" loading={disableLoading}>
|
||||
Deaktivieren
|
||||
</Button>
|
||||
</form>
|
||||
<Button variant="secondary" onclick={() => (showDisableConfirm = false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if form?.error && !form?.totpSecret}
|
||||
<Alert variant="error">{form.error}</Alert>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</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:
|
||||
13
web/svelte.config.js
Normal file
13
web/svelte.config.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import adapter from '@sveltejs/adapter-auto';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
kit: {
|
||||
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
|
||||
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
|
||||
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
|
||||
adapter: adapter()
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
20
web/tsconfig.json
Normal file
20
web/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"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 { sveltekit } from '@sveltejs/kit/vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()]
|
||||
});
|
||||
Reference in New Issue
Block a user