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}
+ `
+ 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',
+
+ }
+ }
+});