From 08c910c1934d871c3f1d68930e91ae032fd8c79f Mon Sep 17 00:00:00 2001 From: Maieul BOYER Date: Mon, 10 Nov 2025 17:00:21 +0100 Subject: [PATCH] feat(frontend): added frontend - Router: client side route handling with client side rendering - Toast: rought Toast handling for better UX and messaging - Auth: single point of truth for the Logged in user This commit doesnt not include the openapi generated code --- frontend/.dockerignore | 2 + frontend/.gitignore | 25 + frontend/Dockerfile | 17 + frontend/index.html | 51 ++ frontend/package.json | 21 + frontend/pnpm-lock.yaml | 1063 ++++++++++++++++++++++++ frontend/pnpm-workspace.yaml | 5 + frontend/public/vite.svg | 1 + frontend/run.sh | 9 + frontend/src/api/index.ts | 17 + frontend/src/auth/index.ts | 51 ++ frontend/src/carousel/carousel.css | 104 +++ frontend/src/carousel/index.ts | 17 + frontend/src/pages/about/about.html | 0 frontend/src/pages/about/about.ts | 12 + frontend/src/pages/chat/chat.ts | 5 + frontend/src/pages/index.ts | 8 + frontend/src/pages/login/login.html | 41 + frontend/src/pages/login/login.ts | 152 ++++ frontend/src/pages/root/root.html | 15 + frontend/src/pages/root/root.ts | 14 + frontend/src/routing/index.ts | 217 +++++ frontend/src/routing/special_routes.ts | 12 + frontend/src/toast/index.ts | 79 ++ frontend/src/typescript.svg | 1 + frontend/src/utils.ts | 8 + frontend/tsconfig.json | 29 + frontend/vite.config.js | 18 + 28 files changed, 1994 insertions(+) create mode 100644 frontend/.dockerignore create mode 100644 frontend/.gitignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/pnpm-lock.yaml create mode 100644 frontend/pnpm-workspace.yaml create mode 100644 frontend/public/vite.svg create mode 100644 frontend/run.sh create mode 100644 frontend/src/api/index.ts create mode 100644 frontend/src/auth/index.ts create mode 100644 frontend/src/carousel/carousel.css create mode 100644 frontend/src/carousel/index.ts create mode 100644 frontend/src/pages/about/about.html create mode 100644 frontend/src/pages/about/about.ts create mode 100644 frontend/src/pages/chat/chat.ts create mode 100644 frontend/src/pages/index.ts create mode 100644 frontend/src/pages/login/login.html create mode 100644 frontend/src/pages/login/login.ts create mode 100644 frontend/src/pages/root/root.html create mode 100644 frontend/src/pages/root/root.ts create mode 100644 frontend/src/routing/index.ts create mode 100644 frontend/src/routing/special_routes.ts create mode 100644 frontend/src/toast/index.ts create mode 100644 frontend/src/typescript.svg create mode 100644 frontend/src/utils.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.js diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..f06235c --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..b02a1ff --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,25 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local +package-lock.json + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..d772c9e --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,17 @@ +FROM node:22-alpine AS pnpm_base +RUN npm install --global pnpm@10; + +FROM pnpm_base AS builder + +COPY . /src +WORKDIR /src +RUN pnpm install --frozen-lockfile && pnpm run build; + +FROM node:22-alpine + +COPY --from=builder /src/dist /dist +COPY ./run.sh /bin/run.sh + +RUN chmod +x /bin/run.sh + +CMD [ "/bin/run.sh" ] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..ffb2ad2 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,51 @@ + + + + + + + Ft Boules + + + + + +
+ +
+
+
+ + + + + +
+
+ + +
+
+ + + + + + + + + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..fca330d --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,21 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "devDependencies": { + "typescript": "~5.9.3", + "vite": "^7.1.10", + "vite-tsconfig-paths": "^5.1.4" + }, + "dependencies": { + "@tailwindcss/vite": "^4.1.16", + "openapi-fetch": "^0.15.0", + "tailwindcss": "^4.1.16" + } +} diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml new file mode 100644 index 0000000..ba5d557 --- /dev/null +++ b/frontend/pnpm-lock.yaml @@ -0,0 +1,1063 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@tailwindcss/vite': + specifier: ^4.1.16 + version: 4.1.16(vite@7.1.12(jiti@2.6.1)(lightningcss@1.30.2)) + openapi-fetch: + specifier: ^0.15.0 + version: 0.15.0 + tailwindcss: + specifier: ^4.1.16 + version: 4.1.16 + devDependencies: + typescript: + specifier: ~5.9.3 + version: 5.9.3 + vite: + specifier: ^7.1.10 + version: 7.1.12(jiti@2.6.1)(lightningcss@1.30.2) + vite-tsconfig-paths: + specifier: ^5.1.4 + version: 5.1.4(typescript@5.9.3)(vite@7.1.12(jiti@2.6.1)(lightningcss@1.30.2)) + +packages: + + '@esbuild/aix-ppc64@0.25.11': + resolution: {integrity: sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.11': + resolution: {integrity: sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.11': + resolution: {integrity: sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.11': + resolution: {integrity: sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.11': + resolution: {integrity: sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.11': + resolution: {integrity: sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.11': + resolution: {integrity: sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.11': + resolution: {integrity: sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.11': + resolution: {integrity: sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.11': + resolution: {integrity: sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.11': + resolution: {integrity: sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.11': + resolution: {integrity: sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.11': + resolution: {integrity: sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.11': + resolution: {integrity: sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.11': + resolution: {integrity: sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.11': + resolution: {integrity: sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.11': + resolution: {integrity: sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.11': + resolution: {integrity: sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.11': + resolution: {integrity: sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.11': + resolution: {integrity: sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.11': + resolution: {integrity: sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.11': + resolution: {integrity: sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.11': + resolution: {integrity: sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.11': + resolution: {integrity: sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.11': + resolution: {integrity: sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.11': + resolution: {integrity: sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@rollup/rollup-android-arm-eabi@4.52.5': + resolution: {integrity: sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.52.5': + resolution: {integrity: sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.52.5': + resolution: {integrity: sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.52.5': + resolution: {integrity: sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.52.5': + resolution: {integrity: sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.52.5': + resolution: {integrity: sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.52.5': + resolution: {integrity: sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.52.5': + resolution: {integrity: sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.52.5': + resolution: {integrity: sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.52.5': + resolution: {integrity: sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.52.5': + resolution: {integrity: sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.52.5': + resolution: {integrity: sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.52.5': + resolution: {integrity: sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.52.5': + resolution: {integrity: sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.52.5': + resolution: {integrity: sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.52.5': + resolution: {integrity: sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.52.5': + resolution: {integrity: sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openharmony-arm64@4.52.5': + resolution: {integrity: sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.52.5': + resolution: {integrity: sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.52.5': + resolution: {integrity: sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.52.5': + resolution: {integrity: sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.52.5': + resolution: {integrity: sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg==} + cpu: [x64] + os: [win32] + + '@tailwindcss/node@4.1.16': + resolution: {integrity: sha512-BX5iaSsloNuvKNHRN3k2RcCuTEgASTo77mofW0vmeHkfrDWaoFAFvNHpEgtu0eqyypcyiBkDWzSMxJhp3AUVcw==} + + '@tailwindcss/oxide-android-arm64@4.1.16': + resolution: {integrity: sha512-8+ctzkjHgwDJ5caq9IqRSgsP70xhdhJvm+oueS/yhD5ixLhqTw9fSL1OurzMUhBwE5zK26FXLCz2f/RtkISqHA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.1.16': + resolution: {integrity: sha512-C3oZy5042v2FOALBZtY0JTDnGNdS6w7DxL/odvSny17ORUnaRKhyTse8xYi3yKGyfnTUOdavRCdmc8QqJYwFKA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.1.16': + resolution: {integrity: sha512-vjrl/1Ub9+JwU6BP0emgipGjowzYZMjbWCDqwA2Z4vCa+HBSpP4v6U2ddejcHsolsYxwL5r4bPNoamlV0xDdLg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.1.16': + resolution: {integrity: sha512-TSMpPYpQLm+aR1wW5rKuUuEruc/oOX3C7H0BTnPDn7W/eMw8W+MRMpiypKMkXZfwH8wqPIRKppuZoedTtNj2tg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.16': + resolution: {integrity: sha512-p0GGfRg/w0sdsFKBjMYvvKIiKy/LNWLWgV/plR4lUgrsxFAoQBFrXkZ4C0w8IOXfslB9vHK/JGASWD2IefIpvw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.1.16': + resolution: {integrity: sha512-DoixyMmTNO19rwRPdqviTrG1rYzpxgyYJl8RgQvdAQUzxC1ToLRqtNJpU/ATURSKgIg6uerPw2feW0aS8SNr/w==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.1.16': + resolution: {integrity: sha512-H81UXMa9hJhWhaAUca6bU2wm5RRFpuHImrwXBUvPbYb+3jo32I9VIwpOX6hms0fPmA6f2pGVlybO6qU8pF4fzQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.1.16': + resolution: {integrity: sha512-ZGHQxDtFC2/ruo7t99Qo2TTIvOERULPl5l0K1g0oK6b5PGqjYMga+FcY1wIUnrUxY56h28FxybtDEla+ICOyew==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.1.16': + resolution: {integrity: sha512-Oi1tAaa0rcKf1Og9MzKeINZzMLPbhxvm7rno5/zuP1WYmpiG0bEHq4AcRUiG2165/WUzvxkW4XDYCscZWbTLZw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-wasm32-wasi@4.1.16': + resolution: {integrity: sha512-B01u/b8LteGRwucIBmCQ07FVXLzImWESAIMcUU6nvFt/tYsQ6IHz8DmZ5KtvmwxD+iTYBtM1xwoGXswnlu9v0Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.1.16': + resolution: {integrity: sha512-zX+Q8sSkGj6HKRTMJXuPvOcP8XfYON24zJBRPlszcH1Np7xuHXhWn8qfFjIujVzvH3BHU+16jBXwgpl20i+v9A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.1.16': + resolution: {integrity: sha512-m5dDFJUEejbFqP+UXVstd4W/wnxA4F61q8SoL+mqTypId2T2ZpuxosNSgowiCnLp2+Z+rivdU0AqpfgiD7yCBg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.1.16': + resolution: {integrity: sha512-2OSv52FRuhdlgyOQqgtQHuCgXnS8nFSYRp2tJ+4WZXKgTxqPy7SMSls8c3mPT5pkZ17SBToGM5LHEJBO7miEdg==} + engines: {node: '>= 10'} + + '@tailwindcss/vite@4.1.16': + resolution: {integrity: sha512-bbguNBcDxsRmi9nnlWJxhfDWamY3lmcyACHcdO1crxfzuLpOhHLLtEIN/nCbbAtj5rchUgQD17QVAKi1f7IsKg==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + enhanced-resolve@5.18.3: + resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} + engines: {node: '>=10.13.0'} + + esbuild@0.25.11: + resolution: {integrity: sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==} + engines: {node: '>=18'} + hasBin: true + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + lightningcss-android-arm64@1.30.2: + resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.30.2: + resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.30.2: + resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.30.2: + resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.30.2: + resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.30.2: + resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.30.2: + resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.30.2: + resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.30.2: + resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.30.2: + resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.30.2: + resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.30.2: + resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} + engines: {node: '>= 12.0.0'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + openapi-fetch@0.15.0: + resolution: {integrity: sha512-OjQUdi61WO4HYhr9+byCPMj0+bgste/LtSBEcV6FzDdONTs7x0fWn8/ndoYwzqCsKWIxEZwo4FN/TG1c1rI8IQ==} + + openapi-typescript-helpers@0.0.15: + resolution: {integrity: sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + rollup@4.52.5: + resolution: {integrity: sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + tailwindcss@4.1.16: + resolution: {integrity: sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA==} + + tapable@2.3.0: + resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} + engines: {node: '>=6'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tsconfck@3.1.6: + resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} + engines: {node: ^18 || >=20} + hasBin: true + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + vite-tsconfig-paths@5.1.4: + resolution: {integrity: sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==} + peerDependencies: + vite: '*' + peerDependenciesMeta: + vite: + optional: true + + vite@7.1.12: + resolution: {integrity: sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + 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 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + +snapshots: + + '@esbuild/aix-ppc64@0.25.11': + optional: true + + '@esbuild/android-arm64@0.25.11': + optional: true + + '@esbuild/android-arm@0.25.11': + optional: true + + '@esbuild/android-x64@0.25.11': + optional: true + + '@esbuild/darwin-arm64@0.25.11': + optional: true + + '@esbuild/darwin-x64@0.25.11': + optional: true + + '@esbuild/freebsd-arm64@0.25.11': + optional: true + + '@esbuild/freebsd-x64@0.25.11': + optional: true + + '@esbuild/linux-arm64@0.25.11': + optional: true + + '@esbuild/linux-arm@0.25.11': + optional: true + + '@esbuild/linux-ia32@0.25.11': + optional: true + + '@esbuild/linux-loong64@0.25.11': + optional: true + + '@esbuild/linux-mips64el@0.25.11': + optional: true + + '@esbuild/linux-ppc64@0.25.11': + optional: true + + '@esbuild/linux-riscv64@0.25.11': + optional: true + + '@esbuild/linux-s390x@0.25.11': + optional: true + + '@esbuild/linux-x64@0.25.11': + optional: true + + '@esbuild/netbsd-arm64@0.25.11': + optional: true + + '@esbuild/netbsd-x64@0.25.11': + optional: true + + '@esbuild/openbsd-arm64@0.25.11': + optional: true + + '@esbuild/openbsd-x64@0.25.11': + optional: true + + '@esbuild/openharmony-arm64@0.25.11': + optional: true + + '@esbuild/sunos-x64@0.25.11': + optional: true + + '@esbuild/win32-arm64@0.25.11': + optional: true + + '@esbuild/win32-ia32@0.25.11': + optional: true + + '@esbuild/win32-x64@0.25.11': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@rollup/rollup-android-arm-eabi@4.52.5': + optional: true + + '@rollup/rollup-android-arm64@4.52.5': + optional: true + + '@rollup/rollup-darwin-arm64@4.52.5': + optional: true + + '@rollup/rollup-darwin-x64@4.52.5': + optional: true + + '@rollup/rollup-freebsd-arm64@4.52.5': + optional: true + + '@rollup/rollup-freebsd-x64@4.52.5': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.52.5': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.52.5': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.52.5': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.52.5': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.52.5': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.52.5': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.52.5': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.52.5': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.52.5': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.52.5': + optional: true + + '@rollup/rollup-linux-x64-musl@4.52.5': + optional: true + + '@rollup/rollup-openharmony-arm64@4.52.5': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.52.5': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.52.5': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.52.5': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.52.5': + optional: true + + '@tailwindcss/node@4.1.16': + dependencies: + '@jridgewell/remapping': 2.3.5 + 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.16 + + '@tailwindcss/oxide-android-arm64@4.1.16': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.1.16': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.1.16': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.1.16': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.16': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.1.16': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.1.16': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.1.16': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.1.16': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.1.16': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.1.16': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.1.16': + optional: true + + '@tailwindcss/oxide@4.1.16': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.1.16 + '@tailwindcss/oxide-darwin-arm64': 4.1.16 + '@tailwindcss/oxide-darwin-x64': 4.1.16 + '@tailwindcss/oxide-freebsd-x64': 4.1.16 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.16 + '@tailwindcss/oxide-linux-arm64-gnu': 4.1.16 + '@tailwindcss/oxide-linux-arm64-musl': 4.1.16 + '@tailwindcss/oxide-linux-x64-gnu': 4.1.16 + '@tailwindcss/oxide-linux-x64-musl': 4.1.16 + '@tailwindcss/oxide-wasm32-wasi': 4.1.16 + '@tailwindcss/oxide-win32-arm64-msvc': 4.1.16 + '@tailwindcss/oxide-win32-x64-msvc': 4.1.16 + + '@tailwindcss/vite@4.1.16(vite@7.1.12(jiti@2.6.1)(lightningcss@1.30.2))': + dependencies: + '@tailwindcss/node': 4.1.16 + '@tailwindcss/oxide': 4.1.16 + tailwindcss: 4.1.16 + vite: 7.1.12(jiti@2.6.1)(lightningcss@1.30.2) + + '@types/estree@1.0.8': {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + detect-libc@2.1.2: {} + + enhanced-resolve@5.18.3: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.0 + + esbuild@0.25.11: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.11 + '@esbuild/android-arm': 0.25.11 + '@esbuild/android-arm64': 0.25.11 + '@esbuild/android-x64': 0.25.11 + '@esbuild/darwin-arm64': 0.25.11 + '@esbuild/darwin-x64': 0.25.11 + '@esbuild/freebsd-arm64': 0.25.11 + '@esbuild/freebsd-x64': 0.25.11 + '@esbuild/linux-arm': 0.25.11 + '@esbuild/linux-arm64': 0.25.11 + '@esbuild/linux-ia32': 0.25.11 + '@esbuild/linux-loong64': 0.25.11 + '@esbuild/linux-mips64el': 0.25.11 + '@esbuild/linux-ppc64': 0.25.11 + '@esbuild/linux-riscv64': 0.25.11 + '@esbuild/linux-s390x': 0.25.11 + '@esbuild/linux-x64': 0.25.11 + '@esbuild/netbsd-arm64': 0.25.11 + '@esbuild/netbsd-x64': 0.25.11 + '@esbuild/openbsd-arm64': 0.25.11 + '@esbuild/openbsd-x64': 0.25.11 + '@esbuild/openharmony-arm64': 0.25.11 + '@esbuild/sunos-x64': 0.25.11 + '@esbuild/win32-arm64': 0.25.11 + '@esbuild/win32-ia32': 0.25.11 + '@esbuild/win32-x64': 0.25.11 + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + fsevents@2.3.3: + optional: true + + globrex@0.1.2: {} + + graceful-fs@4.2.11: {} + + jiti@2.6.1: {} + + lightningcss-android-arm64@1.30.2: + optional: true + + lightningcss-darwin-arm64@1.30.2: + optional: true + + lightningcss-darwin-x64@1.30.2: + optional: true + + lightningcss-freebsd-x64@1.30.2: + optional: true + + lightningcss-linux-arm-gnueabihf@1.30.2: + optional: true + + lightningcss-linux-arm64-gnu@1.30.2: + optional: true + + lightningcss-linux-arm64-musl@1.30.2: + optional: true + + lightningcss-linux-x64-gnu@1.30.2: + optional: true + + lightningcss-linux-x64-musl@1.30.2: + optional: true + + lightningcss-win32-arm64-msvc@1.30.2: + optional: true + + lightningcss-win32-x64-msvc@1.30.2: + optional: true + + lightningcss@1.30.2: + dependencies: + detect-libc: 2.1.2 + 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 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + openapi-fetch@0.15.0: + dependencies: + openapi-typescript-helpers: 0.0.15 + + openapi-typescript-helpers@0.0.15: {} + + picocolors@1.1.1: {} + + picomatch@4.0.3: {} + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rollup@4.52.5: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.52.5 + '@rollup/rollup-android-arm64': 4.52.5 + '@rollup/rollup-darwin-arm64': 4.52.5 + '@rollup/rollup-darwin-x64': 4.52.5 + '@rollup/rollup-freebsd-arm64': 4.52.5 + '@rollup/rollup-freebsd-x64': 4.52.5 + '@rollup/rollup-linux-arm-gnueabihf': 4.52.5 + '@rollup/rollup-linux-arm-musleabihf': 4.52.5 + '@rollup/rollup-linux-arm64-gnu': 4.52.5 + '@rollup/rollup-linux-arm64-musl': 4.52.5 + '@rollup/rollup-linux-loong64-gnu': 4.52.5 + '@rollup/rollup-linux-ppc64-gnu': 4.52.5 + '@rollup/rollup-linux-riscv64-gnu': 4.52.5 + '@rollup/rollup-linux-riscv64-musl': 4.52.5 + '@rollup/rollup-linux-s390x-gnu': 4.52.5 + '@rollup/rollup-linux-x64-gnu': 4.52.5 + '@rollup/rollup-linux-x64-musl': 4.52.5 + '@rollup/rollup-openharmony-arm64': 4.52.5 + '@rollup/rollup-win32-arm64-msvc': 4.52.5 + '@rollup/rollup-win32-ia32-msvc': 4.52.5 + '@rollup/rollup-win32-x64-gnu': 4.52.5 + '@rollup/rollup-win32-x64-msvc': 4.52.5 + fsevents: 2.3.3 + + source-map-js@1.2.1: {} + + tailwindcss@4.1.16: {} + + tapable@2.3.0: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tsconfck@3.1.6(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + + typescript@5.9.3: {} + + vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.1.12(jiti@2.6.1)(lightningcss@1.30.2)): + dependencies: + debug: 4.4.3 + globrex: 0.1.2 + tsconfck: 3.1.6(typescript@5.9.3) + optionalDependencies: + vite: 7.1.12(jiti@2.6.1)(lightningcss@1.30.2) + transitivePeerDependencies: + - supports-color + - typescript + + vite@7.1.12(jiti@2.6.1)(lightningcss@1.30.2): + dependencies: + esbuild: 0.25.11 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.52.5 + tinyglobby: 0.2.15 + optionalDependencies: + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.30.2 diff --git a/frontend/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml new file mode 100644 index 0000000..06ac931 --- /dev/null +++ b/frontend/pnpm-workspace.yaml @@ -0,0 +1,5 @@ +packages: + - . + +onlyBuiltDependencies: + - esbuild diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/run.sh b/frontend/run.sh new file mode 100644 index 0000000..4f2594d --- /dev/null +++ b/frontend/run.sh @@ -0,0 +1,9 @@ +#!/bin/sh + +set -x +set -e + +rm -rf /volumes/static/app +mkdir -p /volumes/static/app + +cp -r /dist/* /volumes/static/app/ diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts new file mode 100644 index 0000000..5821af5 --- /dev/null +++ b/frontend/src/api/index.ts @@ -0,0 +1,17 @@ +import { Configuration, OpenapiOtherApi } from './generated'; +export * from './generated' + + +const basePath = (() => { + let u = new URL(location.href); + u.pathname = ""; + u.hash = ""; + u.search = ""; + return u.toString().replace(/\/+$/, ''); + +})(); + +export const client = new OpenapiOtherApi(new Configuration({ basePath })); +export default client; + + diff --git a/frontend/src/auth/index.ts b/frontend/src/auth/index.ts new file mode 100644 index 0000000..0aa3fae --- /dev/null +++ b/frontend/src/auth/index.ts @@ -0,0 +1,51 @@ +import { showError } from "@app/toast"; +import client from '@app/api'; + +export type User = { + id: string; + guest: boolean; + name: string; +}; + +let currentUser: User | null = null; + +export function getUser(): Readonly | null { + return currentUser; +} + +export function isLogged(): boolean { + return currentUser === null; +} + +export function setUser(newUser: User | null) { + currentUser = newUser; +} + +export async function updateUser(): Promise | null> { + try { + let res = await client.getUser({ user: 'me' }); + + if (res.kind === "success") { + setUser(res.payload); + return res.payload; + } else if (res.kind === "failure") { + // well no user :D + setUser(null); + return null; + } else if (res.kind === "notLoggedIn") { + setUser(null); + return null; + } else { + setUser(null); + showError(`unknown response: ${JSON.stringify(res)}`); + return null; + } + } catch (e) { + setUser(null); + showError(`failed to get user: ${e}`); + return null; + } +} + +Object.assign(window as any, { getUser, setUser, updateUser, isLogged }); + diff --git a/frontend/src/carousel/carousel.css b/frontend/src/carousel/carousel.css new file mode 100644 index 0000000..3c5e995 --- /dev/null +++ b/frontend/src/carousel/carousel.css @@ -0,0 +1,104 @@ +.flip-btn { + position: relative; + width: 60px; + height: 40px; + border: none; + background: #333; + color: white; + font-size: 1.5rem; + border-radius: 8px; + cursor: pointer; + perspective: 600px; + /* Enables 3D effect */ +} + +.arrow { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + transition: transform 0.5s; + backface-visibility: hidden; + /* Hide the back when rotated */ +} + +.front { + transform: translate(-50%, -50%) rotateY(0deg); +} + +.back { + transform: translate(-50%, -50%) rotateY(180deg); +} + +.flip-btn.flipped .front { + transform: translate(-50%, -50%) rotateY(180deg); +} + +.flip-btn.flipped .back { + transform: translate(-50%, -50%) rotateY(360deg); +} + + +@import 'tailwindcss'; + +:root { + font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +#app { + max-width: 1280px; + margin: 0 auto; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.vanilla:hover { + filter: drop-shadow(0 0 2em #3178c6aa); +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/frontend/src/carousel/index.ts b/frontend/src/carousel/index.ts new file mode 100644 index 0000000..999c996 --- /dev/null +++ b/frontend/src/carousel/index.ts @@ -0,0 +1,17 @@ +import './carousel.css' + +const menuBtn = document.querySelector('#menuBtn')!; +const sidebar = document.querySelector('#sidebar')!; +const overlay = document.querySelector('#overlay')!; + +menuBtn.addEventListener('click', () => { + sidebar.classList.toggle('-translate-x-full') + overlay.classList.toggle('opacity-0'); + overlay.classList.toggle('pointer-events-none'); + menuBtn.classList.toggle('flipped'); +}); + +overlay.addEventListener('click', () => { + sidebar.classList.add('-translate-x-full'); + overlay.classList.add('opacity-0', 'pointer-events-none'); +}); diff --git a/frontend/src/pages/about/about.html b/frontend/src/pages/about/about.html new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/pages/about/about.ts b/frontend/src/pages/about/about.ts new file mode 100644 index 0000000..973fbbf --- /dev/null +++ b/frontend/src/pages/about/about.ts @@ -0,0 +1,12 @@ +import { addRoute, setTitle } from "@app/routing"; +import page from './about.html?raw' + + +async function route(_url: string, _args: { [k: string]: string }): Promise { + setTitle('About us') + return page; +} + + + +addRoute('/', route) diff --git a/frontend/src/pages/chat/chat.ts b/frontend/src/pages/chat/chat.ts new file mode 100644 index 0000000..7abb2a2 --- /dev/null +++ b/frontend/src/pages/chat/chat.ts @@ -0,0 +1,5 @@ +import { addRoute, type RouteHandlerParams } from "@app/routing"; + +addRoute('/chat', function (_url: string, _args: RouteHandlerParams) { + return "this is the chat page !" +}) diff --git a/frontend/src/pages/index.ts b/frontend/src/pages/index.ts new file mode 100644 index 0000000..dd4721c --- /dev/null +++ b/frontend/src/pages/index.ts @@ -0,0 +1,8 @@ +import { setTitle, handleRoute } from '@app/routing'; +import './root/root.ts' +import './chat/chat.ts' +import './login/login.ts' + +// ---- Initial load ---- +setTitle(""); +handleRoute(); diff --git a/frontend/src/pages/login/login.html b/frontend/src/pages/login/login.html new file mode 100644 index 0000000..34f60aa --- /dev/null +++ b/frontend/src/pages/login/login.html @@ -0,0 +1,41 @@ +
+
+

Welcome to ft boules

+ + + +
+
+ + +
+ +
+ + +
+ + +
+ +

+ Donโ€™t have an account? + Sign up +

+ +

+ You can also login with +

+ +
+
+
+
diff --git a/frontend/src/pages/login/login.ts b/frontend/src/pages/login/login.ts new file mode 100644 index 0000000..8f09e69 --- /dev/null +++ b/frontend/src/pages/login/login.ts @@ -0,0 +1,152 @@ +import { addRoute, setTitle, type RouteHandlerParams, type RouteHandlerReturn } from "@app/routing"; +import { showError, showInfo, showSuccess } from "@app/toast"; +import authHtml from './login.html?raw'; +import client from '@app/api' +import { updateUser } from "@app/auth"; + + +type Providers = { + name: string, + display_name: string, + icon_url?: string, + color?: { default: string, hover: string }, +}; + +function handleLogin(_url: string, _args: RouteHandlerParams): RouteHandlerReturn { + setTitle('Login') + return { + html: authHtml, postInsert: async (app) => { + const fLogin = document.querySelector('form#login-form'); + if (fLogin === null) + return showError('Error while rendering the page: no form found'); + showSuccess('got the form !') + fLogin.addEventListener('submit', async function(e: SubmitEvent) { + e.preventDefault(); + let form = e.target as (HTMLFormElement | null) + if (form === null) + return showError('Failed to send form...'); + let formData = Object.fromEntries((new FormData(form)).entries()); + if (!('login' in formData) || typeof formData['login'] !== 'string' || (formData['login'] as string).length === 0) + return showError('Please enter a Login'); + if (!('password' in formData) || typeof formData['password'] !== 'string' || (formData['password'] as string).length === 0) + return showError('Please enter a Password'); + try { + const res = await client.login({ loginRequest: { name: formData.login, password: formData.password } }); + switch (res.kind) { + case 'success': { + document.cookie = `token=${res.payload.token}`; + let user = await updateUser(); + if (user === null) + return showError('Failed to get user: no user ?'); + setTitle(`Welcome ${user.guest ? '[GUEST] ' : ''}${user.name}`); + break; + } + case 'otpRequired': { + showInfo('Got ask OTP, not yet implemented'); + break; + } + case 'failed': { + showError(`Failed to login: ${res.msg}`); + } + } + } catch (e) { + console.error("Login error:", e); + showError('Failed to login: Unknown error'); + } + }); + + const bLoginAsGuest = document.querySelector('#bGuestLogin'); + bLoginAsGuest?.addEventListener('click', async () => { + try { + const res = await client.guestLogin(); + switch (res.kind) { + case 'success': { + document.cookie = `token=${res.payload.token}`; + let user = await updateUser(); + if (user === null) + return showError('Failed to get user: no user ?'); + setTitle(`Welcome ${user.guest ? '[GUEST] ' : ''}${user.name}`); + break; + } + case 'failed': { + showError(`Failed to login: ${res.msg}`); + } + } + } catch (e) { + console.error("Login error:", e); + showError('Failed to login: Unknown error'); + } + }); + + const dOtherLoginArea = document.querySelector('#otherLogin'); + if (dOtherLoginArea) { + let styleSheetElement = document.createElement('style'); + styleSheetElement.innerText = ""; + // TODO: fetch all the providers from an API ? + const providers: Providers[] = [ + { name: 'discord', display_name: 'Discord', color: { default: 'bg-[#5865F2]', hover: '#FF65F2' } }, + { name: 'kanidm', display_name: 'Kanidm', color: { default: 'bg-red-500', hover: 'bg-red-700' } }, + { name: 'google', display_name: 'Google' }, + ] + let first = true; + for (const p of providers) { + let b = document.createElement('button'); + if (first) b.classList.add('last:col-span-2'); + first = false; + b.classList.add(...( + 'w-full text-white font-medium py-2 rounded-xl transition' + .split(' ') + )); + b.classList.add(`providerButton-${p.name}`) + + const col = { default: p.color?.default ?? "bg-gray-600", hover: p.color?.hover ?? "bg-gray-700" }; + + for (const k of Object.keys(col)) { + let c = (col as { [k: string]: string })[k].trim(); + if (c.startsWith('bg-')) { + c = c.replace(/^bg-/, ''); + const customProp = c.match(/^\((.+)\)$/); + const customVal = c.match(/^\[(.+)\]$/); + + if (customProp) + c = `var(${customProp[1]})` + else if (customVal) + c = customVal[1]; + else if (c === 'inherit') + c = 'inherit'; + else if (c === 'current') + c = 'currentColor'; + else if (c === 'transparent') + c = 'transparent'; + else + c = `var(--color-${c})` + + } + (col as { [k: string]: string })[k] = c; + } + + styleSheetElement.innerText += `.providerButton-${p.name} { background-color: ${col.default}; }\n`; + styleSheetElement.innerText += `.providerButton-${p.name}:hover { background-color: ${col.hover}; }\n`; + + b.dataset.display_name = p.display_name; + b.dataset.name = p.name; + if (p.icon_url) b.dataset.icon = p.icon_url; + + b.innerHTML = ` + ${p.icon_url ? `${p.display_name} Logo` : ''} ${p.display_name} + ` + b.addEventListener('click', () => { + location.href = `/api/auth/oauth2/${p.name}/login`; + }) + + dOtherLoginArea.insertAdjacentElement('afterbegin', b); + } + app?.appendChild(styleSheetElement); + } + } + }; + +} + + +addRoute('/login', handleLogin, { bypass_auth: true }) diff --git a/frontend/src/pages/root/root.html b/frontend/src/pages/root/root.html new file mode 100644 index 0000000..d62fa44 --- /dev/null +++ b/frontend/src/pages/root/root.html @@ -0,0 +1,15 @@ +
+ Welcome a to The Site de Boule +
+
+ Welcome a to The Site de Boule +
+
+ Welcome a to The Site de Boule +
+
+ Welcome a to The Site de Boule +
+
+ Welcome a to The Site de Boule +
diff --git a/frontend/src/pages/root/root.ts b/frontend/src/pages/root/root.ts new file mode 100644 index 0000000..02e137a --- /dev/null +++ b/frontend/src/pages/root/root.ts @@ -0,0 +1,14 @@ +import { addRoute, setTitle, type RouteHandlerParams } from "@app/routing"; +import page from './root.html?raw' + +addRoute('/', (_: string) => { + setTitle('ft boules') + return page; +}) + + +addRoute('/with_title/:title', (_: string, args: RouteHandlerParams) => { + setTitle(args.title) + console.log(`title should be '${args.title}'`); + return page; +}) diff --git a/frontend/src/routing/index.ts b/frontend/src/routing/index.ts new file mode 100644 index 0000000..9b5fc97 --- /dev/null +++ b/frontend/src/routing/index.ts @@ -0,0 +1,217 @@ +import { route_404 } from './special_routes' + +// ---- Router logic ---- +function navigateTo(url: string) { + history.pushState(null, "", `${url.startsWith('/') ? '/app' : ""}${url}`); + handleRoute(); +} + +type AsyncFunctionMaker any> = + (...args: Parameters) => Promise>; + +export type RouteHandlerParams = { [k: string]: string }; + + +export type SyncRouteHandlerPostInsertFn = (appNode?: HTMLElement) => void; +export type AsyncRouteHandlerPostInsertFn = AsyncFunctionMaker; +export type RouteHandlerReturn = { + html: string, + postInsert?: SyncRouteHandlerPostInsertFn | AsyncRouteHandlerPostInsertFn, +}; + +export type SyncRouteHandler = (url: string, args: RouteHandlerParams) => RouteHandlerReturn | string; +export type AsyncRouteHandler = AsyncFunctionMaker; +export type RouteHandler = string | SyncRouteHandler | AsyncRouteHandler; +export type Routes = Map +export type RouteHandlerSpecialArgs = { + bypass_auth: boolean, +}; + +export class RouteHandlerData { + public readonly handler: RouteHandler; + public readonly url: string; + public readonly parts: (string | null)[]; + public readonly args: (string | null)[]; + public readonly orignal_url: string; + public readonly special_args: RouteHandlerSpecialArgs; + + public static SPECIAL_ARGS_DEFAULT: RouteHandlerSpecialArgs = { + bypass_auth: false, + } + + constructor(url: string, handler: RouteHandler, special_args: Partial) { + this.special_args = RouteHandlerData.SPECIAL_ARGS_DEFAULT; + Object.assign(this.special_args, special_args); + + let parsed = RouteHandlerData.parseUrl(url); + this.handler = handler; + this.parts = parsed.parts; + this.url = parsed.parts.map((v, i) => v ?? `:${i}`).reduce((p, c) => `${p}/${c}`, ''); + this.args = parsed.args; + this.orignal_url = parsed.original; + } + + private static parseUrl(url: string): { parts: (string | null)[], original: string, args: (string | null)[] } { + const deduped = url.replace(RegExp('/+'), '/'); + const trimed = deduped.replace(RegExp('(^/)|(/$)'), ''); + + let parts = trimed.split('/'); + let s = parts.map((part, idx) => { + // then this is a parameter ! + if (part.startsWith(':')) { + let param_name = part.substring(1) // remove the : + // verifiy that the parameter name only contains character, underscores and numbers (not in fist char tho) + if (!param_name.match('^[a-zA-Z_][a-zA-Z_0-9]+$')) + throw `route parameter ${idx} for url '${url}' contains illegal character`; + return { idx, param_name, part: null } + } + else { + return { idx, param_name: null, part }; + } + }) + { + let dup = new Set(); + for (const { param_name } of s) { + if (param_name === null) continue; + if (dup.has(param_name)) + throw `route paramater '${param_name}' is a duplicate in route ${url}`; + dup.add(param_name); + } + } + let out_args = s.map(p => p.param_name); + let out_parts = s.map(p => p.part); + + return { + parts: out_parts, args: out_args, original: url, + } + } +} + +function urlToParts(url: string): string[] { + const deduped = url.replace(RegExp('/+'), '/'); + const trimed = deduped.replace(RegExp('(^/)|(/$)'), ''); + + let parts = trimed.split('/'); + if (parts.at(0) === 'app') + parts.shift(); + return parts; +} + +function setupRoutes(): [ + () => Routes, + (url: string, handler: RouteHandler, args?: Partial) => void +] { + const routes = new Map(); + + return [ + () => routes, + (url: string, handler: RouteHandler | string, args?: Partial) => { + let d = new RouteHandlerData(url, handler, args ?? {}); + if (routes.has(d.url)) + throw `Tried to insert route ${url}, but it already exists`; + routes.set(d.url, d); + } + ]; +} + +function setupTitle(): [ + () => string, + (title: string) => void, +] { + let title = ""; + let titleElem = document.querySelector('#header-title')!; + return [ + () => title, + (new_title) => { + title = new_title; + titleElem.innerText = title; + } + ] +} + + +export const [getRoute, addRoute] = setupRoutes(); + +export const [getTitle, setTitle] = setupTitle(); + +(window as any).getRoute = getRoute; + +const executeRouteHandler = async (handler: RouteHandlerData, ...args: Parameters): Promise => { + // handler may be a raw string literal, if yes => return it directly + if (typeof handler.handler === 'string') + return { html: handler.handler }; + + // now we know handler is a function. what does it return ? we don't know + // the two choices are either a string, or a Promise (needing an await to get the string) + const result = handler.handler(...args); + + + // if `result` is a promise, awaits it, otherwise do nothing + let ret = result instanceof Promise ? (await result) : result; + + // if ret is a string, then no postInsert function exists => return a well formed object + if (typeof ret === 'string') + return { html: ret }; + return ret; +} + +const route_404_handler = new RouteHandlerData('', route_404, { bypass_auth: true }); + +function parts_match(route_parts: (string | null)[], parts: string[]): boolean { + if (route_parts.length !== parts.length) return false; + + let zipped = route_parts.map((v, i) => [v ?? parts[i], parts[i]]); + return zipped.every(([lhs, rhs]) => lhs == rhs) +} + +export async function handleRoute() { + let routes = getRoute(); + + let parts = urlToParts(window.location.pathname); + let routes_all = routes.entries(); + + let route_handler: RouteHandlerData = route_404_handler; + let args: RouteHandlerParams = {}; + for (const [_, route_data] of routes_all) { + if (!parts_match(route_data.parts, parts)) continue; + + args = {}; + route_data.args.forEach((v, i) => { + if (v === null) return; + args[v] = decodeURIComponent(parts[i]); + }) + route_handler = route_data; + break; + } + + const app = document.getElementById('app')!; + let ret = await executeRouteHandler(route_handler, window.location.pathname, args) + app.innerHTML = ret.html; + if (ret.postInsert) { + let r = ret.postInsert(app); + if (r instanceof Promise) await r; + } +} + + +// ---- Intercept link clicks ---- +document.addEventListener('click', e => { + const target = e?.target; + if (!target) return; + if (!(target instanceof Element)) return; + let link = target.closest('a[href]') as HTMLAnchorElement; + if (!link) return; + + const url = new URL(link.href); + const sameOrigin = url.origin === window.location.origin; + + if (sameOrigin) { + e.preventDefault(); + navigateTo(url.pathname); + } +}); + +// ---- Handle browser navigation (back/forward) ---- +window.addEventListener('popstate', handleRoute); + +Object.assign((window as any), { getTitle, setTitle, getRoute, addRoute, navigateTo }) diff --git a/frontend/src/routing/special_routes.ts b/frontend/src/routing/special_routes.ts new file mode 100644 index 0000000..b0c5ee8 --- /dev/null +++ b/frontend/src/routing/special_routes.ts @@ -0,0 +1,12 @@ +import { escapeHTML } from "@app/utils"; +import { getRoute, type RouteHandlerParams } from "@app/routing"; + +export async function route_404(url: string, _args: RouteHandlerParams): Promise { + console.log(`asked about route '${url}: not found'`) + console.log(getRoute()) + return ` +
404 - Not Found
+
+
${escapeHTML(url)}
+ ` +} diff --git a/frontend/src/toast/index.ts b/frontend/src/toast/index.ts new file mode 100644 index 0000000..2d796ca --- /dev/null +++ b/frontend/src/toast/index.ts @@ -0,0 +1,79 @@ +type ToastType = 'success' | 'error' | 'info' | 'warning'; + +interface Toast { + message: string; + type: ToastType; + el: HTMLElement; +} + +class ToastManager { + private container: HTMLElement; + private toasts: Toast[] = []; + + + private static INSTANCE: ToastManager = new ToastManager(); + public static instance(): ToastManager { return ToastManager.INSTANCE; } + + private constructor() { + // Create container at bottom center + this.container = document.createElement('div'); + this.container.className = ` + fixed bottom-5 left-1/2 transform -translate-x-1/2 + flex flex-col-reverse items-center gap-2 z-50 + `; + document.body.appendChild(this.container); + } + + public show(message: string, type: ToastType = 'info', duration = 5000) { + const el = document.createElement('div'); + + const color = { + success: 'bg-green-600', + error: 'bg-red-600', + info: 'bg-blue-600', + warning: 'bg-yellow-600 text-black' + }[type]; + + el.className = ` + toast-item min-w-[200px] max-w-sm px-4 py-2 rounded-xl shadow-lg + text-white text-sm font-medium opacity-0 translate-y-4 + transition-all duration-300 ease-out ${color} + `; + el.innerText = message; + + this.container.prepend(el); + + // Animate in + requestAnimationFrame(() => { + el.classList.remove('opacity-0', 'translate-y-4'); + el.classList.add('opacity-100', 'translate-y-0'); + }); + let toast: Toast = { message, type, el }; + this.toasts.push(toast) + setTimeout(() => this.remove(toast), duration); + } + + + private remove(toast: Toast) { + const el = toast.el; + if (toast) { + // Animate out + el.classList.add('opacity-0', 'translate-y-4'); + setTimeout(() => el.remove(), 300); + } + this.toasts = this.toasts.filter(t => t !== toast); + } + +} + +// Export a singleton +export const toast = ToastManager.instance(); +export default toast; + +export function showError(message: string, duration: number = 5000) { toast.show(message, 'error', duration); } +export function showWarn(message: string, duration: number = 5000) { toast.show(message, 'warning', duration); } + +export function showInfo(message: string, duration: number = 5000) { toast.show(message, 'info', duration); } +export function showSuccess(message: string, duration: number = 5000) { toast.show(message, 'success', duration); } + +Object.assign((window as any), { toast, showError, showWarn, showInfo, showSuccess }) diff --git a/frontend/src/typescript.svg b/frontend/src/typescript.svg new file mode 100644 index 0000000..d91c910 --- /dev/null +++ b/frontend/src/typescript.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts new file mode 100644 index 0000000..d35bb00 --- /dev/null +++ b/frontend/src/utils.ts @@ -0,0 +1,8 @@ +export function escapeHTML(str: string): string { + const p = document.createElement("p"); + p.appendChild(document.createTextNode(str)); + return p.innerHTML; +} +export function isNullish(v: T | undefined | null): v is (null | undefined) { + return v === null || v === undefined; +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..b1f3181 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "erasableSyntaxOnly": false, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + "paths": { + "@app/*": ["./src/*"] + } + }, + "include": ["src"] +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..1415ddd --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,18 @@ +import { defineConfig } from 'vite'; +import tailwindcss from '@tailwindcss/vite' +import tsconfigPaths from 'vite-tsconfig-paths'; + +export default defineConfig({ + plugins: [ + tailwindcss(), + tsconfigPaths(), + ], + server: { + hmr: { + protocol: 'ws', + host: 'localhost', + port: '5137', + + } + } +});