diff --git a/retrocast-web/.dockerignore b/retrocast-web/.dockerignore
new file mode 100644
index 0000000..62b26e1
--- /dev/null
+++ b/retrocast-web/.dockerignore
@@ -0,0 +1,4 @@
+node_modules
+dist
+.git
+*.md
diff --git a/retrocast-web/.gitignore b/retrocast-web/.gitignore
new file mode 100644
index 0000000..a547bf3
--- /dev/null
+++ b/retrocast-web/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/retrocast-web/Dockerfile b/retrocast-web/Dockerfile
new file mode 100644
index 0000000..d4292e6
--- /dev/null
+++ b/retrocast-web/Dockerfile
@@ -0,0 +1,13 @@
+FROM node:22-alpine AS builder
+WORKDIR /app
+RUN corepack enable
+COPY package.json pnpm-lock.yaml ./
+RUN pnpm install --frozen-lockfile
+COPY . .
+RUN pnpm build
+
+FROM nginx:alpine
+COPY --from=builder /app/dist /usr/share/nginx/html
+COPY nginx.conf /etc/nginx/conf.d/default.conf
+EXPOSE 80
+CMD ["nginx", "-g", "daemon off;"]
diff --git a/retrocast-web/README.md b/retrocast-web/README.md
new file mode 100644
index 0000000..d2e7761
--- /dev/null
+++ b/retrocast-web/README.md
@@ -0,0 +1,73 @@
+# React + TypeScript + Vite
+
+This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
+
+Currently, two official plugins are available:
+
+- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
+- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
+
+## React Compiler
+
+The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
+
+## Expanding the ESLint configuration
+
+If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
+
+```js
+export default defineConfig([
+ globalIgnores(['dist']),
+ {
+ files: ['**/*.{ts,tsx}'],
+ extends: [
+ // Other configs...
+
+ // Remove tseslint.configs.recommended and replace with this
+ tseslint.configs.recommendedTypeChecked,
+ // Alternatively, use this for stricter rules
+ tseslint.configs.strictTypeChecked,
+ // Optionally, add this for stylistic rules
+ tseslint.configs.stylisticTypeChecked,
+
+ // Other configs...
+ ],
+ languageOptions: {
+ parserOptions: {
+ project: ['./tsconfig.node.json', './tsconfig.app.json'],
+ tsconfigRootDir: import.meta.dirname,
+ },
+ // other options...
+ },
+ },
+])
+```
+
+You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
+
+```js
+// eslint.config.js
+import reactX from 'eslint-plugin-react-x'
+import reactDom from 'eslint-plugin-react-dom'
+
+export default defineConfig([
+ globalIgnores(['dist']),
+ {
+ files: ['**/*.{ts,tsx}'],
+ extends: [
+ // Other configs...
+ // Enable lint rules for React
+ reactX.configs['recommended-typescript'],
+ // Enable lint rules for React DOM
+ reactDom.configs.recommended,
+ ],
+ languageOptions: {
+ parserOptions: {
+ project: ['./tsconfig.node.json', './tsconfig.app.json'],
+ tsconfigRootDir: import.meta.dirname,
+ },
+ // other options...
+ },
+ },
+])
+```
diff --git a/retrocast-web/eslint.config.js b/retrocast-web/eslint.config.js
new file mode 100644
index 0000000..5e6b472
--- /dev/null
+++ b/retrocast-web/eslint.config.js
@@ -0,0 +1,23 @@
+import js from '@eslint/js'
+import globals from 'globals'
+import reactHooks from 'eslint-plugin-react-hooks'
+import reactRefresh from 'eslint-plugin-react-refresh'
+import tseslint from 'typescript-eslint'
+import { defineConfig, globalIgnores } from 'eslint/config'
+
+export default defineConfig([
+ globalIgnores(['dist']),
+ {
+ files: ['**/*.{ts,tsx}'],
+ extends: [
+ js.configs.recommended,
+ tseslint.configs.recommended,
+ reactHooks.configs.flat.recommended,
+ reactRefresh.configs.vite,
+ ],
+ languageOptions: {
+ ecmaVersion: 2020,
+ globals: globals.browser,
+ },
+ },
+])
diff --git a/retrocast-web/index.html b/retrocast-web/index.html
new file mode 100644
index 0000000..2d36625
--- /dev/null
+++ b/retrocast-web/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Retrocast
+
+
+
+
+
+
diff --git a/retrocast-web/nginx.conf b/retrocast-web/nginx.conf
new file mode 100644
index 0000000..6d75877
--- /dev/null
+++ b/retrocast-web/nginx.conf
@@ -0,0 +1,29 @@
+server {
+ listen 80;
+ root /usr/share/nginx/html;
+ index index.html;
+
+ # Cache static assets (Vite hashed filenames)
+ location /assets/ {
+ expires 1y;
+ add_header Cache-Control "public, immutable";
+ }
+
+ # SPA fallback: serve index.html for all non-file routes
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+
+ # API proxy (for standalone deployment without Caddy)
+ location /api/ {
+ proxy_pass http://api:8080;
+ }
+
+ # WebSocket gateway proxy
+ location /gateway {
+ proxy_pass http://api:8080;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ }
+}
diff --git a/retrocast-web/package.json b/retrocast-web/package.json
new file mode 100644
index 0000000..f5d714e
--- /dev/null
+++ b/retrocast-web/package.json
@@ -0,0 +1,41 @@
+{
+ "name": "retrocast-web",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "lint": "eslint .",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@tailwindcss/vite": "^4.1.18",
+ "react": "^19.2.0",
+ "react-dom": "^19.2.0",
+ "react-markdown": "^10.1.0",
+ "react-router": "^7.13.0",
+ "remark-gfm": "^4.0.1",
+ "tailwindcss": "^4.1.18",
+ "zustand": "^5.0.11"
+ },
+ "pnpm": {
+ "onlyBuiltDependencies": [
+ "esbuild"
+ ]
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.39.1",
+ "@types/node": "^24.10.1",
+ "@types/react": "^19.2.7",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^5.1.1",
+ "eslint": "^9.39.1",
+ "eslint-plugin-react-hooks": "^7.0.1",
+ "eslint-plugin-react-refresh": "^0.4.24",
+ "globals": "^16.5.0",
+ "typescript": "~5.9.3",
+ "typescript-eslint": "^8.48.0",
+ "vite": "^7.3.1"
+ }
+}
diff --git a/retrocast-web/pnpm-lock.yaml b/retrocast-web/pnpm-lock.yaml
new file mode 100644
index 0000000..e6f5338
--- /dev/null
+++ b/retrocast-web/pnpm-lock.yaml
@@ -0,0 +1,3333 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ dependencies:
+ '@tailwindcss/vite':
+ specifier: ^4.1.18
+ version: 4.1.18(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2))
+ react:
+ specifier: ^19.2.0
+ version: 19.2.4
+ react-dom:
+ specifier: ^19.2.0
+ version: 19.2.4(react@19.2.4)
+ react-markdown:
+ specifier: ^10.1.0
+ version: 10.1.0(@types/react@19.2.14)(react@19.2.4)
+ react-router:
+ specifier: ^7.13.0
+ version: 7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ remark-gfm:
+ specifier: ^4.0.1
+ version: 4.0.1
+ tailwindcss:
+ specifier: ^4.1.18
+ version: 4.1.18
+ zustand:
+ specifier: ^5.0.11
+ version: 5.0.11(@types/react@19.2.14)(react@19.2.4)
+ devDependencies:
+ '@eslint/js':
+ specifier: ^9.39.1
+ version: 9.39.2
+ '@types/node':
+ specifier: ^24.10.1
+ version: 24.10.13
+ '@types/react':
+ specifier: ^19.2.7
+ version: 19.2.14
+ '@types/react-dom':
+ specifier: ^19.2.3
+ version: 19.2.3(@types/react@19.2.14)
+ '@vitejs/plugin-react':
+ specifier: ^5.1.1
+ version: 5.1.4(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2))
+ eslint:
+ specifier: ^9.39.1
+ version: 9.39.2(jiti@2.6.1)
+ eslint-plugin-react-hooks:
+ specifier: ^7.0.1
+ version: 7.0.1(eslint@9.39.2(jiti@2.6.1))
+ eslint-plugin-react-refresh:
+ specifier: ^0.4.24
+ version: 0.4.26(eslint@9.39.2(jiti@2.6.1))
+ globals:
+ specifier: ^16.5.0
+ version: 16.5.0
+ typescript:
+ specifier: ~5.9.3
+ version: 5.9.3
+ typescript-eslint:
+ specifier: ^8.48.0
+ version: 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
+ vite:
+ specifier: ^7.3.1
+ version: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)
+
+packages:
+
+ '@babel/code-frame@7.29.0':
+ resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/compat-data@7.29.0':
+ resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/core@7.29.0':
+ resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/generator@7.29.1':
+ resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-compilation-targets@7.28.6':
+ resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-globals@7.28.0':
+ resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-module-imports@7.28.6':
+ resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-module-transforms@7.28.6':
+ resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0
+
+ '@babel/helper-plugin-utils@7.28.6':
+ resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-string-parser@7.27.1':
+ resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-validator-identifier@7.28.5':
+ resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-validator-option@7.27.1':
+ resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helpers@7.28.6':
+ resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/parser@7.29.0':
+ resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
+ '@babel/plugin-transform-react-jsx-self@7.27.1':
+ resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
+ '@babel/plugin-transform-react-jsx-source@7.27.1':
+ resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
+ '@babel/template@7.28.6':
+ resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/traverse@7.29.0':
+ resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/types@7.29.0':
+ resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
+ engines: {node: '>=6.9.0'}
+
+ '@esbuild/aix-ppc64@0.27.3':
+ resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [aix]
+
+ '@esbuild/android-arm64@0.27.3':
+ resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [android]
+
+ '@esbuild/android-arm@0.27.3':
+ resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [android]
+
+ '@esbuild/android-x64@0.27.3':
+ resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [android]
+
+ '@esbuild/darwin-arm64@0.27.3':
+ resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@esbuild/darwin-x64@0.27.3':
+ resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@esbuild/freebsd-arm64@0.27.3':
+ resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-x64@0.27.3':
+ resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@esbuild/linux-arm64@0.27.3':
+ resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@esbuild/linux-arm@0.27.3':
+ resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [linux]
+
+ '@esbuild/linux-ia32@0.27.3':
+ resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [linux]
+
+ '@esbuild/linux-loong64@0.27.3':
+ resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==}
+ engines: {node: '>=18'}
+ cpu: [loong64]
+ os: [linux]
+
+ '@esbuild/linux-mips64el@0.27.3':
+ resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==}
+ engines: {node: '>=18'}
+ cpu: [mips64el]
+ os: [linux]
+
+ '@esbuild/linux-ppc64@0.27.3':
+ resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@esbuild/linux-riscv64@0.27.3':
+ resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==}
+ engines: {node: '>=18'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@esbuild/linux-s390x@0.27.3':
+ resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==}
+ engines: {node: '>=18'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@esbuild/linux-x64@0.27.3':
+ resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [linux]
+
+ '@esbuild/netbsd-arm64@0.27.3':
+ resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [netbsd]
+
+ '@esbuild/netbsd-x64@0.27.3':
+ resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [netbsd]
+
+ '@esbuild/openbsd-arm64@0.27.3':
+ resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openbsd]
+
+ '@esbuild/openbsd-x64@0.27.3':
+ resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@esbuild/openharmony-arm64@0.27.3':
+ resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@esbuild/sunos-x64@0.27.3':
+ resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [sunos]
+
+ '@esbuild/win32-arm64@0.27.3':
+ resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@esbuild/win32-ia32@0.27.3':
+ resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@esbuild/win32-x64@0.27.3':
+ resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [win32]
+
+ '@eslint-community/eslint-utils@4.9.1':
+ resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+ peerDependencies:
+ eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
+
+ '@eslint-community/regexpp@4.12.2':
+ resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==}
+ engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
+
+ '@eslint/config-array@0.21.1':
+ resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/config-helpers@0.4.2':
+ resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/core@0.17.0':
+ resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/eslintrc@3.3.3':
+ resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/js@9.39.2':
+ resolution: {integrity: sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/object-schema@2.1.7':
+ resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/plugin-kit@0.4.1':
+ resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@humanfs/core@0.19.1':
+ resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
+ engines: {node: '>=18.18.0'}
+
+ '@humanfs/node@0.16.7':
+ resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==}
+ engines: {node: '>=18.18.0'}
+
+ '@humanwhocodes/module-importer@1.0.1':
+ resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==}
+ engines: {node: '>=12.22'}
+
+ '@humanwhocodes/retry@0.4.3':
+ resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
+ engines: {node: '>=18.18'}
+
+ '@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==}
+
+ '@rolldown/pluginutils@1.0.0-rc.3':
+ resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==}
+
+ '@rollup/rollup-android-arm-eabi@4.57.1':
+ resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==}
+ cpu: [arm]
+ os: [android]
+
+ '@rollup/rollup-android-arm64@4.57.1':
+ resolution: {integrity: sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==}
+ cpu: [arm64]
+ os: [android]
+
+ '@rollup/rollup-darwin-arm64@4.57.1':
+ resolution: {integrity: sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@rollup/rollup-darwin-x64@4.57.1':
+ resolution: {integrity: sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@rollup/rollup-freebsd-arm64@4.57.1':
+ resolution: {integrity: sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@rollup/rollup-freebsd-x64@4.57.1':
+ resolution: {integrity: sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.57.1':
+ resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==}
+ cpu: [arm]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-arm-musleabihf@4.57.1':
+ resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==}
+ cpu: [arm]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-arm64-gnu@4.57.1':
+ resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-arm64-musl@4.57.1':
+ resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-loong64-gnu@4.57.1':
+ resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==}
+ cpu: [loong64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-loong64-musl@4.57.1':
+ resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==}
+ cpu: [loong64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-ppc64-gnu@4.57.1':
+ resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-ppc64-musl@4.57.1':
+ resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-riscv64-gnu@4.57.1':
+ resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-riscv64-musl@4.57.1':
+ resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-s390x-gnu@4.57.1':
+ resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==}
+ cpu: [s390x]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-x64-gnu@4.57.1':
+ resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-x64-musl@4.57.1':
+ resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-openbsd-x64@4.57.1':
+ resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@rollup/rollup-openharmony-arm64@4.57.1':
+ resolution: {integrity: sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@rollup/rollup-win32-arm64-msvc@4.57.1':
+ resolution: {integrity: sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==}
+ cpu: [arm64]
+ os: [win32]
+
+ '@rollup/rollup-win32-ia32-msvc@4.57.1':
+ resolution: {integrity: sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==}
+ cpu: [ia32]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-gnu@4.57.1':
+ resolution: {integrity: sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==}
+ cpu: [x64]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-msvc@4.57.1':
+ resolution: {integrity: sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==}
+ cpu: [x64]
+ os: [win32]
+
+ '@tailwindcss/node@4.1.18':
+ resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==}
+
+ '@tailwindcss/oxide-android-arm64@4.1.18':
+ resolution: {integrity: sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [android]
+
+ '@tailwindcss/oxide-darwin-arm64@4.1.18':
+ resolution: {integrity: sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@tailwindcss/oxide-darwin-x64@4.1.18':
+ resolution: {integrity: sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@tailwindcss/oxide-freebsd-x64@4.1.18':
+ resolution: {integrity: sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18':
+ resolution: {integrity: sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==}
+ engines: {node: '>= 10'}
+ cpu: [arm]
+ os: [linux]
+
+ '@tailwindcss/oxide-linux-arm64-gnu@4.1.18':
+ resolution: {integrity: sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@tailwindcss/oxide-linux-arm64-musl@4.1.18':
+ resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@tailwindcss/oxide-linux-x64-gnu@4.1.18':
+ resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@tailwindcss/oxide-linux-x64-musl@4.1.18':
+ resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@tailwindcss/oxide-wasm32-wasi@4.1.18':
+ resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==}
+ 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.18':
+ resolution: {integrity: sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@tailwindcss/oxide-win32-x64-msvc@4.1.18':
+ resolution: {integrity: sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [win32]
+
+ '@tailwindcss/oxide@4.1.18':
+ resolution: {integrity: sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==}
+ engines: {node: '>= 10'}
+
+ '@tailwindcss/vite@4.1.18':
+ resolution: {integrity: sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==}
+ peerDependencies:
+ vite: ^5.2.0 || ^6 || ^7
+
+ '@types/babel__core@7.20.5':
+ resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
+
+ '@types/babel__generator@7.27.0':
+ resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==}
+
+ '@types/babel__template@7.4.4':
+ resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==}
+
+ '@types/babel__traverse@7.28.0':
+ resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
+
+ '@types/debug@4.1.12':
+ resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
+
+ '@types/estree-jsx@1.0.5':
+ resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
+
+ '@types/estree@1.0.8':
+ resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
+
+ '@types/hast@3.0.4':
+ resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
+
+ '@types/json-schema@7.0.15':
+ resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
+
+ '@types/mdast@4.0.4':
+ resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
+
+ '@types/ms@2.1.0':
+ resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
+
+ '@types/node@24.10.13':
+ resolution: {integrity: sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==}
+
+ '@types/react-dom@19.2.3':
+ resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
+ peerDependencies:
+ '@types/react': ^19.2.0
+
+ '@types/react@19.2.14':
+ resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
+
+ '@types/unist@2.0.11':
+ resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
+
+ '@types/unist@3.0.3':
+ resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
+
+ '@typescript-eslint/eslint-plugin@8.55.0':
+ resolution: {integrity: sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ '@typescript-eslint/parser': ^8.55.0
+ eslint: ^8.57.0 || ^9.0.0
+ typescript: '>=4.8.4 <6.0.0'
+
+ '@typescript-eslint/parser@8.55.0':
+ resolution: {integrity: sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0
+ typescript: '>=4.8.4 <6.0.0'
+
+ '@typescript-eslint/project-service@8.55.0':
+ resolution: {integrity: sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.0.0'
+
+ '@typescript-eslint/scope-manager@8.55.0':
+ resolution: {integrity: sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@typescript-eslint/tsconfig-utils@8.55.0':
+ resolution: {integrity: sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.0.0'
+
+ '@typescript-eslint/type-utils@8.55.0':
+ resolution: {integrity: sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0
+ typescript: '>=4.8.4 <6.0.0'
+
+ '@typescript-eslint/types@8.55.0':
+ resolution: {integrity: sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@typescript-eslint/typescript-estree@8.55.0':
+ resolution: {integrity: sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.0.0'
+
+ '@typescript-eslint/utils@8.55.0':
+ resolution: {integrity: sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0
+ typescript: '>=4.8.4 <6.0.0'
+
+ '@typescript-eslint/visitor-keys@8.55.0':
+ resolution: {integrity: sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@ungap/structured-clone@1.3.0':
+ resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
+
+ '@vitejs/plugin-react@5.1.4':
+ resolution: {integrity: sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ peerDependencies:
+ vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
+
+ acorn-jsx@5.3.2:
+ resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
+ peerDependencies:
+ acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
+
+ acorn@8.15.0:
+ resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==}
+ engines: {node: '>=0.4.0'}
+ hasBin: true
+
+ ajv@6.12.6:
+ resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
+
+ ansi-styles@4.3.0:
+ resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
+ engines: {node: '>=8'}
+
+ argparse@2.0.1:
+ resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
+
+ bail@2.0.2:
+ resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==}
+
+ balanced-match@1.0.2:
+ resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
+
+ baseline-browser-mapping@2.9.19:
+ resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==}
+ hasBin: true
+
+ brace-expansion@1.1.12:
+ resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
+
+ brace-expansion@2.0.2:
+ resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
+
+ browserslist@4.28.1:
+ resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==}
+ engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
+ hasBin: true
+
+ callsites@3.1.0:
+ resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
+ engines: {node: '>=6'}
+
+ caniuse-lite@1.0.30001770:
+ resolution: {integrity: sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==}
+
+ ccount@2.0.1:
+ resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
+
+ chalk@4.1.2:
+ resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
+ engines: {node: '>=10'}
+
+ character-entities-html4@2.1.0:
+ resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==}
+
+ character-entities-legacy@3.0.0:
+ resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==}
+
+ character-entities@2.0.2:
+ resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==}
+
+ character-reference-invalid@2.0.1:
+ resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==}
+
+ color-convert@2.0.1:
+ resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
+ engines: {node: '>=7.0.0'}
+
+ color-name@1.1.4:
+ resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
+
+ comma-separated-tokens@2.0.3:
+ resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
+
+ concat-map@0.0.1:
+ resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
+
+ convert-source-map@2.0.0:
+ resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
+
+ cookie@1.1.1:
+ resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
+ engines: {node: '>=18'}
+
+ cross-spawn@7.0.6:
+ resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
+ engines: {node: '>= 8'}
+
+ csstype@3.2.3:
+ resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+
+ debug@4.4.3:
+ resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
+ engines: {node: '>=6.0'}
+ peerDependencies:
+ supports-color: '*'
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+
+ decode-named-character-reference@1.3.0:
+ resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==}
+
+ deep-is@0.1.4:
+ resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
+
+ dequal@2.0.3:
+ resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
+ engines: {node: '>=6'}
+
+ detect-libc@2.1.2:
+ resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
+ engines: {node: '>=8'}
+
+ devlop@1.1.0:
+ resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
+
+ electron-to-chromium@1.5.286:
+ resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==}
+
+ enhanced-resolve@5.19.0:
+ resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==}
+ engines: {node: '>=10.13.0'}
+
+ esbuild@0.27.3:
+ resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ escalade@3.2.0:
+ resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
+ engines: {node: '>=6'}
+
+ escape-string-regexp@4.0.0:
+ resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
+ engines: {node: '>=10'}
+
+ escape-string-regexp@5.0.0:
+ resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
+ engines: {node: '>=12'}
+
+ eslint-plugin-react-hooks@7.0.1:
+ resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0
+
+ eslint-plugin-react-refresh@0.4.26:
+ resolution: {integrity: sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==}
+ peerDependencies:
+ eslint: '>=8.40'
+
+ eslint-scope@8.4.0:
+ resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ eslint-visitor-keys@3.4.3:
+ resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+
+ eslint-visitor-keys@4.2.1:
+ resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ eslint@9.39.2:
+ resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ hasBin: true
+ peerDependencies:
+ jiti: '*'
+ peerDependenciesMeta:
+ jiti:
+ optional: true
+
+ espree@10.4.0:
+ resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ esquery@1.7.0:
+ resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==}
+ engines: {node: '>=0.10'}
+
+ esrecurse@4.3.0:
+ resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==}
+ engines: {node: '>=4.0'}
+
+ estraverse@5.3.0:
+ resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
+ engines: {node: '>=4.0'}
+
+ estree-util-is-identifier-name@3.0.0:
+ resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==}
+
+ esutils@2.0.3:
+ resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
+ engines: {node: '>=0.10.0'}
+
+ extend@3.0.2:
+ resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
+
+ fast-deep-equal@3.1.3:
+ resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
+
+ fast-json-stable-stringify@2.1.0:
+ resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
+
+ fast-levenshtein@2.0.6:
+ resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
+
+ fdir@6.5.0:
+ resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
+ engines: {node: '>=12.0.0'}
+ peerDependencies:
+ picomatch: ^3 || ^4
+ peerDependenciesMeta:
+ picomatch:
+ optional: true
+
+ file-entry-cache@8.0.0:
+ resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
+ engines: {node: '>=16.0.0'}
+
+ find-up@5.0.0:
+ resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
+ engines: {node: '>=10'}
+
+ flat-cache@4.0.1:
+ resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
+ engines: {node: '>=16'}
+
+ flatted@3.3.3:
+ resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
+
+ fsevents@2.3.3:
+ resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
+ gensync@1.0.0-beta.2:
+ resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
+ engines: {node: '>=6.9.0'}
+
+ glob-parent@6.0.2:
+ resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
+ engines: {node: '>=10.13.0'}
+
+ globals@14.0.0:
+ resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
+ engines: {node: '>=18'}
+
+ globals@16.5.0:
+ resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==}
+ engines: {node: '>=18'}
+
+ graceful-fs@4.2.11:
+ resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
+
+ has-flag@4.0.0:
+ resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
+ engines: {node: '>=8'}
+
+ hast-util-to-jsx-runtime@2.3.6:
+ resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
+
+ hast-util-whitespace@3.0.0:
+ resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
+
+ hermes-estree@0.25.1:
+ resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
+
+ hermes-parser@0.25.1:
+ resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
+
+ html-url-attributes@3.0.1:
+ resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
+
+ ignore@5.3.2:
+ resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
+ engines: {node: '>= 4'}
+
+ ignore@7.0.5:
+ resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
+ engines: {node: '>= 4'}
+
+ import-fresh@3.3.1:
+ resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
+ engines: {node: '>=6'}
+
+ imurmurhash@0.1.4:
+ resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
+ engines: {node: '>=0.8.19'}
+
+ inline-style-parser@0.2.7:
+ resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
+
+ is-alphabetical@2.0.1:
+ resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==}
+
+ is-alphanumerical@2.0.1:
+ resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==}
+
+ is-decimal@2.0.1:
+ resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==}
+
+ is-extglob@2.1.1:
+ resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
+ engines: {node: '>=0.10.0'}
+
+ is-glob@4.0.3:
+ resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
+ engines: {node: '>=0.10.0'}
+
+ is-hexadecimal@2.0.1:
+ resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==}
+
+ is-plain-obj@4.1.0:
+ resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
+ engines: {node: '>=12'}
+
+ isexe@2.0.0:
+ resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
+
+ jiti@2.6.1:
+ resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
+ hasBin: true
+
+ js-tokens@4.0.0:
+ resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
+
+ js-yaml@4.1.1:
+ resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
+ hasBin: true
+
+ jsesc@3.1.0:
+ resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
+ engines: {node: '>=6'}
+ hasBin: true
+
+ json-buffer@3.0.1:
+ resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
+
+ json-schema-traverse@0.4.1:
+ resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
+
+ json-stable-stringify-without-jsonify@1.0.1:
+ resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
+
+ json5@2.2.3:
+ resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
+ engines: {node: '>=6'}
+ hasBin: true
+
+ keyv@4.5.4:
+ resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
+
+ levn@0.4.1:
+ resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
+ engines: {node: '>= 0.8.0'}
+
+ 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]
+ libc: [glibc]
+
+ lightningcss-linux-arm64-musl@1.30.2:
+ resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ lightningcss-linux-x64-gnu@1.30.2:
+ resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ lightningcss-linux-x64-musl@1.30.2:
+ resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ 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'}
+
+ locate-path@6.0.0:
+ resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
+ engines: {node: '>=10'}
+
+ lodash.merge@4.6.2:
+ resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
+
+ longest-streak@3.1.0:
+ resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
+
+ lru-cache@5.1.1:
+ resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
+
+ magic-string@0.30.21:
+ resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+
+ markdown-table@3.0.4:
+ resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
+
+ mdast-util-find-and-replace@3.0.2:
+ resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==}
+
+ mdast-util-from-markdown@2.0.2:
+ resolution: {integrity: sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==}
+
+ mdast-util-gfm-autolink-literal@2.0.1:
+ resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==}
+
+ mdast-util-gfm-footnote@2.1.0:
+ resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==}
+
+ mdast-util-gfm-strikethrough@2.0.0:
+ resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==}
+
+ mdast-util-gfm-table@2.0.0:
+ resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==}
+
+ mdast-util-gfm-task-list-item@2.0.0:
+ resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==}
+
+ mdast-util-gfm@3.1.0:
+ resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==}
+
+ mdast-util-mdx-expression@2.0.1:
+ resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==}
+
+ mdast-util-mdx-jsx@3.2.0:
+ resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==}
+
+ mdast-util-mdxjs-esm@2.0.1:
+ resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==}
+
+ mdast-util-phrasing@4.1.0:
+ resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==}
+
+ mdast-util-to-hast@13.2.1:
+ resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==}
+
+ mdast-util-to-markdown@2.1.2:
+ resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==}
+
+ mdast-util-to-string@4.0.0:
+ resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==}
+
+ micromark-core-commonmark@2.0.3:
+ resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
+
+ micromark-extension-gfm-autolink-literal@2.1.0:
+ resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==}
+
+ micromark-extension-gfm-footnote@2.1.0:
+ resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==}
+
+ micromark-extension-gfm-strikethrough@2.1.0:
+ resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==}
+
+ micromark-extension-gfm-table@2.1.1:
+ resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==}
+
+ micromark-extension-gfm-tagfilter@2.0.0:
+ resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==}
+
+ micromark-extension-gfm-task-list-item@2.1.0:
+ resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==}
+
+ micromark-extension-gfm@3.0.0:
+ resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==}
+
+ micromark-factory-destination@2.0.1:
+ resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==}
+
+ micromark-factory-label@2.0.1:
+ resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==}
+
+ micromark-factory-space@2.0.1:
+ resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==}
+
+ micromark-factory-title@2.0.1:
+ resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==}
+
+ micromark-factory-whitespace@2.0.1:
+ resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==}
+
+ micromark-util-character@2.1.1:
+ resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==}
+
+ micromark-util-chunked@2.0.1:
+ resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==}
+
+ micromark-util-classify-character@2.0.1:
+ resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==}
+
+ micromark-util-combine-extensions@2.0.1:
+ resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==}
+
+ micromark-util-decode-numeric-character-reference@2.0.2:
+ resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==}
+
+ micromark-util-decode-string@2.0.1:
+ resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==}
+
+ micromark-util-encode@2.0.1:
+ resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==}
+
+ micromark-util-html-tag-name@2.0.1:
+ resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==}
+
+ micromark-util-normalize-identifier@2.0.1:
+ resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==}
+
+ micromark-util-resolve-all@2.0.1:
+ resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==}
+
+ micromark-util-sanitize-uri@2.0.1:
+ resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==}
+
+ micromark-util-subtokenize@2.1.0:
+ resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==}
+
+ micromark-util-symbol@2.0.1:
+ resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==}
+
+ micromark-util-types@2.0.2:
+ resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==}
+
+ micromark@4.0.2:
+ resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==}
+
+ minimatch@3.1.2:
+ resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
+
+ minimatch@9.0.5:
+ resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
+ engines: {node: '>=16 || 14 >=14.17'}
+
+ 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
+
+ natural-compare@1.4.0:
+ resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
+
+ node-releases@2.0.27:
+ resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
+
+ optionator@0.9.4:
+ resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
+ engines: {node: '>= 0.8.0'}
+
+ p-limit@3.1.0:
+ resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
+ engines: {node: '>=10'}
+
+ p-locate@5.0.0:
+ resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
+ engines: {node: '>=10'}
+
+ parent-module@1.0.1:
+ resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
+ engines: {node: '>=6'}
+
+ parse-entities@4.0.2:
+ resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
+
+ path-exists@4.0.0:
+ resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
+ engines: {node: '>=8'}
+
+ path-key@3.1.1:
+ resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
+ engines: {node: '>=8'}
+
+ 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}
+
+ prelude-ls@1.2.1:
+ resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
+ engines: {node: '>= 0.8.0'}
+
+ property-information@7.1.0:
+ resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
+
+ punycode@2.3.1:
+ resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
+ engines: {node: '>=6'}
+
+ react-dom@19.2.4:
+ resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==}
+ peerDependencies:
+ react: ^19.2.4
+
+ react-markdown@10.1.0:
+ resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==}
+ peerDependencies:
+ '@types/react': '>=18'
+ react: '>=18'
+
+ react-refresh@0.18.0:
+ resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==}
+ engines: {node: '>=0.10.0'}
+
+ react-router@7.13.0:
+ resolution: {integrity: sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==}
+ engines: {node: '>=20.0.0'}
+ peerDependencies:
+ react: '>=18'
+ react-dom: '>=18'
+ peerDependenciesMeta:
+ react-dom:
+ optional: true
+
+ react@19.2.4:
+ resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
+ engines: {node: '>=0.10.0'}
+
+ remark-gfm@4.0.1:
+ resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
+
+ remark-parse@11.0.0:
+ resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==}
+
+ remark-rehype@11.1.2:
+ resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==}
+
+ remark-stringify@11.0.0:
+ resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
+
+ resolve-from@4.0.0:
+ resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
+ engines: {node: '>=4'}
+
+ rollup@4.57.1:
+ resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==}
+ engines: {node: '>=18.0.0', npm: '>=8.0.0'}
+ hasBin: true
+
+ scheduler@0.27.0:
+ resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
+
+ semver@6.3.1:
+ resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
+ hasBin: true
+
+ semver@7.7.4:
+ resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
+ engines: {node: '>=10'}
+ hasBin: true
+
+ set-cookie-parser@2.7.2:
+ resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
+
+ shebang-command@2.0.0:
+ resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
+ engines: {node: '>=8'}
+
+ shebang-regex@3.0.0:
+ resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
+ engines: {node: '>=8'}
+
+ source-map-js@1.2.1:
+ resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
+ engines: {node: '>=0.10.0'}
+
+ space-separated-tokens@2.0.2:
+ resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
+
+ stringify-entities@4.0.4:
+ resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
+
+ strip-json-comments@3.1.1:
+ resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
+ engines: {node: '>=8'}
+
+ style-to-js@1.1.21:
+ resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==}
+
+ style-to-object@1.0.14:
+ resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==}
+
+ supports-color@7.2.0:
+ resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
+ engines: {node: '>=8'}
+
+ tailwindcss@4.1.18:
+ resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==}
+
+ 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'}
+
+ trim-lines@3.0.1:
+ resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
+
+ trough@2.2.0:
+ resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==}
+
+ ts-api-utils@2.4.0:
+ resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==}
+ engines: {node: '>=18.12'}
+ peerDependencies:
+ typescript: '>=4.8.4'
+
+ type-check@0.4.0:
+ resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
+ engines: {node: '>= 0.8.0'}
+
+ typescript-eslint@8.55.0:
+ resolution: {integrity: sha512-HE4wj+r5lmDVS9gdaN0/+iqNvPZwGfnJ5lZuz7s5vLlg9ODw0bIiiETaios9LvFI1U94/VBXGm3CB2Y5cNFMpw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0
+ typescript: '>=4.8.4 <6.0.0'
+
+ typescript@5.9.3:
+ resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
+ engines: {node: '>=14.17'}
+ hasBin: true
+
+ undici-types@7.16.0:
+ resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
+
+ unified@11.0.5:
+ resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
+
+ unist-util-is@6.0.1:
+ resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==}
+
+ unist-util-position@5.0.0:
+ resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==}
+
+ unist-util-stringify-position@4.0.0:
+ resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==}
+
+ unist-util-visit-parents@6.0.2:
+ resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==}
+
+ unist-util-visit@5.1.0:
+ resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==}
+
+ update-browserslist-db@1.2.3:
+ resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
+ hasBin: true
+ peerDependencies:
+ browserslist: '>= 4.21.0'
+
+ uri-js@4.4.1:
+ resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
+
+ vfile-message@4.0.3:
+ resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
+
+ vfile@6.0.3:
+ resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
+
+ vite@7.3.1:
+ resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==}
+ 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
+
+ which@2.0.2:
+ resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
+ engines: {node: '>= 8'}
+ hasBin: true
+
+ word-wrap@1.2.5:
+ resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
+ engines: {node: '>=0.10.0'}
+
+ yallist@3.1.1:
+ resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
+
+ yocto-queue@0.1.0:
+ resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
+ engines: {node: '>=10'}
+
+ zod-validation-error@4.0.2:
+ resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==}
+ engines: {node: '>=18.0.0'}
+ peerDependencies:
+ zod: ^3.25.0 || ^4.0.0
+
+ zod@4.3.6:
+ resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
+
+ zustand@5.0.11:
+ resolution: {integrity: sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==}
+ engines: {node: '>=12.20.0'}
+ peerDependencies:
+ '@types/react': '>=18.0.0'
+ immer: '>=9.0.6'
+ react: '>=18.0.0'
+ use-sync-external-store: '>=1.2.0'
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ immer:
+ optional: true
+ react:
+ optional: true
+ use-sync-external-store:
+ optional: true
+
+ zwitch@2.0.4:
+ resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
+
+snapshots:
+
+ '@babel/code-frame@7.29.0':
+ dependencies:
+ '@babel/helper-validator-identifier': 7.28.5
+ js-tokens: 4.0.0
+ picocolors: 1.1.1
+
+ '@babel/compat-data@7.29.0': {}
+
+ '@babel/core@7.29.0':
+ dependencies:
+ '@babel/code-frame': 7.29.0
+ '@babel/generator': 7.29.1
+ '@babel/helper-compilation-targets': 7.28.6
+ '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0)
+ '@babel/helpers': 7.28.6
+ '@babel/parser': 7.29.0
+ '@babel/template': 7.28.6
+ '@babel/traverse': 7.29.0
+ '@babel/types': 7.29.0
+ '@jridgewell/remapping': 2.3.5
+ convert-source-map: 2.0.0
+ debug: 4.4.3
+ gensync: 1.0.0-beta.2
+ json5: 2.2.3
+ semver: 6.3.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/generator@7.29.1':
+ dependencies:
+ '@babel/parser': 7.29.0
+ '@babel/types': 7.29.0
+ '@jridgewell/gen-mapping': 0.3.13
+ '@jridgewell/trace-mapping': 0.3.31
+ jsesc: 3.1.0
+
+ '@babel/helper-compilation-targets@7.28.6':
+ dependencies:
+ '@babel/compat-data': 7.29.0
+ '@babel/helper-validator-option': 7.27.1
+ browserslist: 4.28.1
+ lru-cache: 5.1.1
+ semver: 6.3.1
+
+ '@babel/helper-globals@7.28.0': {}
+
+ '@babel/helper-module-imports@7.28.6':
+ dependencies:
+ '@babel/traverse': 7.29.0
+ '@babel/types': 7.29.0
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)':
+ dependencies:
+ '@babel/core': 7.29.0
+ '@babel/helper-module-imports': 7.28.6
+ '@babel/helper-validator-identifier': 7.28.5
+ '@babel/traverse': 7.29.0
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-plugin-utils@7.28.6': {}
+
+ '@babel/helper-string-parser@7.27.1': {}
+
+ '@babel/helper-validator-identifier@7.28.5': {}
+
+ '@babel/helper-validator-option@7.27.1': {}
+
+ '@babel/helpers@7.28.6':
+ dependencies:
+ '@babel/template': 7.28.6
+ '@babel/types': 7.29.0
+
+ '@babel/parser@7.29.0':
+ dependencies:
+ '@babel/types': 7.29.0
+
+ '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)':
+ dependencies:
+ '@babel/core': 7.29.0
+ '@babel/helper-plugin-utils': 7.28.6
+
+ '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)':
+ dependencies:
+ '@babel/core': 7.29.0
+ '@babel/helper-plugin-utils': 7.28.6
+
+ '@babel/template@7.28.6':
+ dependencies:
+ '@babel/code-frame': 7.29.0
+ '@babel/parser': 7.29.0
+ '@babel/types': 7.29.0
+
+ '@babel/traverse@7.29.0':
+ dependencies:
+ '@babel/code-frame': 7.29.0
+ '@babel/generator': 7.29.1
+ '@babel/helper-globals': 7.28.0
+ '@babel/parser': 7.29.0
+ '@babel/template': 7.28.6
+ '@babel/types': 7.29.0
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/types@7.29.0':
+ dependencies:
+ '@babel/helper-string-parser': 7.27.1
+ '@babel/helper-validator-identifier': 7.28.5
+
+ '@esbuild/aix-ppc64@0.27.3':
+ optional: true
+
+ '@esbuild/android-arm64@0.27.3':
+ optional: true
+
+ '@esbuild/android-arm@0.27.3':
+ optional: true
+
+ '@esbuild/android-x64@0.27.3':
+ optional: true
+
+ '@esbuild/darwin-arm64@0.27.3':
+ optional: true
+
+ '@esbuild/darwin-x64@0.27.3':
+ optional: true
+
+ '@esbuild/freebsd-arm64@0.27.3':
+ optional: true
+
+ '@esbuild/freebsd-x64@0.27.3':
+ optional: true
+
+ '@esbuild/linux-arm64@0.27.3':
+ optional: true
+
+ '@esbuild/linux-arm@0.27.3':
+ optional: true
+
+ '@esbuild/linux-ia32@0.27.3':
+ optional: true
+
+ '@esbuild/linux-loong64@0.27.3':
+ optional: true
+
+ '@esbuild/linux-mips64el@0.27.3':
+ optional: true
+
+ '@esbuild/linux-ppc64@0.27.3':
+ optional: true
+
+ '@esbuild/linux-riscv64@0.27.3':
+ optional: true
+
+ '@esbuild/linux-s390x@0.27.3':
+ optional: true
+
+ '@esbuild/linux-x64@0.27.3':
+ optional: true
+
+ '@esbuild/netbsd-arm64@0.27.3':
+ optional: true
+
+ '@esbuild/netbsd-x64@0.27.3':
+ optional: true
+
+ '@esbuild/openbsd-arm64@0.27.3':
+ optional: true
+
+ '@esbuild/openbsd-x64@0.27.3':
+ optional: true
+
+ '@esbuild/openharmony-arm64@0.27.3':
+ optional: true
+
+ '@esbuild/sunos-x64@0.27.3':
+ optional: true
+
+ '@esbuild/win32-arm64@0.27.3':
+ optional: true
+
+ '@esbuild/win32-ia32@0.27.3':
+ optional: true
+
+ '@esbuild/win32-x64@0.27.3':
+ optional: true
+
+ '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.6.1))':
+ dependencies:
+ eslint: 9.39.2(jiti@2.6.1)
+ eslint-visitor-keys: 3.4.3
+
+ '@eslint-community/regexpp@4.12.2': {}
+
+ '@eslint/config-array@0.21.1':
+ dependencies:
+ '@eslint/object-schema': 2.1.7
+ debug: 4.4.3
+ minimatch: 3.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ '@eslint/config-helpers@0.4.2':
+ dependencies:
+ '@eslint/core': 0.17.0
+
+ '@eslint/core@0.17.0':
+ dependencies:
+ '@types/json-schema': 7.0.15
+
+ '@eslint/eslintrc@3.3.3':
+ dependencies:
+ ajv: 6.12.6
+ debug: 4.4.3
+ espree: 10.4.0
+ globals: 14.0.0
+ ignore: 5.3.2
+ import-fresh: 3.3.1
+ js-yaml: 4.1.1
+ minimatch: 3.1.2
+ strip-json-comments: 3.1.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@eslint/js@9.39.2': {}
+
+ '@eslint/object-schema@2.1.7': {}
+
+ '@eslint/plugin-kit@0.4.1':
+ dependencies:
+ '@eslint/core': 0.17.0
+ levn: 0.4.1
+
+ '@humanfs/core@0.19.1': {}
+
+ '@humanfs/node@0.16.7':
+ dependencies:
+ '@humanfs/core': 0.19.1
+ '@humanwhocodes/retry': 0.4.3
+
+ '@humanwhocodes/module-importer@1.0.1': {}
+
+ '@humanwhocodes/retry@0.4.3': {}
+
+ '@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
+
+ '@rolldown/pluginutils@1.0.0-rc.3': {}
+
+ '@rollup/rollup-android-arm-eabi@4.57.1':
+ optional: true
+
+ '@rollup/rollup-android-arm64@4.57.1':
+ optional: true
+
+ '@rollup/rollup-darwin-arm64@4.57.1':
+ optional: true
+
+ '@rollup/rollup-darwin-x64@4.57.1':
+ optional: true
+
+ '@rollup/rollup-freebsd-arm64@4.57.1':
+ optional: true
+
+ '@rollup/rollup-freebsd-x64@4.57.1':
+ optional: true
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.57.1':
+ optional: true
+
+ '@rollup/rollup-linux-arm-musleabihf@4.57.1':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-gnu@4.57.1':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-musl@4.57.1':
+ optional: true
+
+ '@rollup/rollup-linux-loong64-gnu@4.57.1':
+ optional: true
+
+ '@rollup/rollup-linux-loong64-musl@4.57.1':
+ optional: true
+
+ '@rollup/rollup-linux-ppc64-gnu@4.57.1':
+ optional: true
+
+ '@rollup/rollup-linux-ppc64-musl@4.57.1':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-gnu@4.57.1':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-musl@4.57.1':
+ optional: true
+
+ '@rollup/rollup-linux-s390x-gnu@4.57.1':
+ optional: true
+
+ '@rollup/rollup-linux-x64-gnu@4.57.1':
+ optional: true
+
+ '@rollup/rollup-linux-x64-musl@4.57.1':
+ optional: true
+
+ '@rollup/rollup-openbsd-x64@4.57.1':
+ optional: true
+
+ '@rollup/rollup-openharmony-arm64@4.57.1':
+ optional: true
+
+ '@rollup/rollup-win32-arm64-msvc@4.57.1':
+ optional: true
+
+ '@rollup/rollup-win32-ia32-msvc@4.57.1':
+ optional: true
+
+ '@rollup/rollup-win32-x64-gnu@4.57.1':
+ optional: true
+
+ '@rollup/rollup-win32-x64-msvc@4.57.1':
+ optional: true
+
+ '@tailwindcss/node@4.1.18':
+ dependencies:
+ '@jridgewell/remapping': 2.3.5
+ enhanced-resolve: 5.19.0
+ jiti: 2.6.1
+ lightningcss: 1.30.2
+ magic-string: 0.30.21
+ source-map-js: 1.2.1
+ tailwindcss: 4.1.18
+
+ '@tailwindcss/oxide-android-arm64@4.1.18':
+ optional: true
+
+ '@tailwindcss/oxide-darwin-arm64@4.1.18':
+ optional: true
+
+ '@tailwindcss/oxide-darwin-x64@4.1.18':
+ optional: true
+
+ '@tailwindcss/oxide-freebsd-x64@4.1.18':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm64-gnu@4.1.18':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm64-musl@4.1.18':
+ optional: true
+
+ '@tailwindcss/oxide-linux-x64-gnu@4.1.18':
+ optional: true
+
+ '@tailwindcss/oxide-linux-x64-musl@4.1.18':
+ optional: true
+
+ '@tailwindcss/oxide-wasm32-wasi@4.1.18':
+ optional: true
+
+ '@tailwindcss/oxide-win32-arm64-msvc@4.1.18':
+ optional: true
+
+ '@tailwindcss/oxide-win32-x64-msvc@4.1.18':
+ optional: true
+
+ '@tailwindcss/oxide@4.1.18':
+ optionalDependencies:
+ '@tailwindcss/oxide-android-arm64': 4.1.18
+ '@tailwindcss/oxide-darwin-arm64': 4.1.18
+ '@tailwindcss/oxide-darwin-x64': 4.1.18
+ '@tailwindcss/oxide-freebsd-x64': 4.1.18
+ '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.18
+ '@tailwindcss/oxide-linux-arm64-gnu': 4.1.18
+ '@tailwindcss/oxide-linux-arm64-musl': 4.1.18
+ '@tailwindcss/oxide-linux-x64-gnu': 4.1.18
+ '@tailwindcss/oxide-linux-x64-musl': 4.1.18
+ '@tailwindcss/oxide-wasm32-wasi': 4.1.18
+ '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18
+ '@tailwindcss/oxide-win32-x64-msvc': 4.1.18
+
+ '@tailwindcss/vite@4.1.18(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2))':
+ dependencies:
+ '@tailwindcss/node': 4.1.18
+ '@tailwindcss/oxide': 4.1.18
+ tailwindcss: 4.1.18
+ vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)
+
+ '@types/babel__core@7.20.5':
+ dependencies:
+ '@babel/parser': 7.29.0
+ '@babel/types': 7.29.0
+ '@types/babel__generator': 7.27.0
+ '@types/babel__template': 7.4.4
+ '@types/babel__traverse': 7.28.0
+
+ '@types/babel__generator@7.27.0':
+ dependencies:
+ '@babel/types': 7.29.0
+
+ '@types/babel__template@7.4.4':
+ dependencies:
+ '@babel/parser': 7.29.0
+ '@babel/types': 7.29.0
+
+ '@types/babel__traverse@7.28.0':
+ dependencies:
+ '@babel/types': 7.29.0
+
+ '@types/debug@4.1.12':
+ dependencies:
+ '@types/ms': 2.1.0
+
+ '@types/estree-jsx@1.0.5':
+ dependencies:
+ '@types/estree': 1.0.8
+
+ '@types/estree@1.0.8': {}
+
+ '@types/hast@3.0.4':
+ dependencies:
+ '@types/unist': 3.0.3
+
+ '@types/json-schema@7.0.15': {}
+
+ '@types/mdast@4.0.4':
+ dependencies:
+ '@types/unist': 3.0.3
+
+ '@types/ms@2.1.0': {}
+
+ '@types/node@24.10.13':
+ dependencies:
+ undici-types: 7.16.0
+
+ '@types/react-dom@19.2.3(@types/react@19.2.14)':
+ dependencies:
+ '@types/react': 19.2.14
+
+ '@types/react@19.2.14':
+ dependencies:
+ csstype: 3.2.3
+
+ '@types/unist@2.0.11': {}
+
+ '@types/unist@3.0.3': {}
+
+ '@typescript-eslint/eslint-plugin@8.55.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
+ dependencies:
+ '@eslint-community/regexpp': 4.12.2
+ '@typescript-eslint/parser': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/scope-manager': 8.55.0
+ '@typescript-eslint/type-utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/visitor-keys': 8.55.0
+ eslint: 9.39.2(jiti@2.6.1)
+ ignore: 7.0.5
+ natural-compare: 1.4.0
+ ts-api-utils: 2.4.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/scope-manager': 8.55.0
+ '@typescript-eslint/types': 8.55.0
+ '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3)
+ '@typescript-eslint/visitor-keys': 8.55.0
+ debug: 4.4.3
+ eslint: 9.39.2(jiti@2.6.1)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/project-service@8.55.0(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/tsconfig-utils': 8.55.0(typescript@5.9.3)
+ '@typescript-eslint/types': 8.55.0
+ debug: 4.4.3
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/scope-manager@8.55.0':
+ dependencies:
+ '@typescript-eslint/types': 8.55.0
+ '@typescript-eslint/visitor-keys': 8.55.0
+
+ '@typescript-eslint/tsconfig-utils@8.55.0(typescript@5.9.3)':
+ dependencies:
+ typescript: 5.9.3
+
+ '@typescript-eslint/type-utils@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/types': 8.55.0
+ '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
+ debug: 4.4.3
+ eslint: 9.39.2(jiti@2.6.1)
+ ts-api-utils: 2.4.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/types@8.55.0': {}
+
+ '@typescript-eslint/typescript-estree@8.55.0(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/project-service': 8.55.0(typescript@5.9.3)
+ '@typescript-eslint/tsconfig-utils': 8.55.0(typescript@5.9.3)
+ '@typescript-eslint/types': 8.55.0
+ '@typescript-eslint/visitor-keys': 8.55.0
+ debug: 4.4.3
+ minimatch: 9.0.5
+ semver: 7.7.4
+ tinyglobby: 0.2.15
+ ts-api-utils: 2.4.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/utils@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
+ dependencies:
+ '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1))
+ '@typescript-eslint/scope-manager': 8.55.0
+ '@typescript-eslint/types': 8.55.0
+ '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3)
+ eslint: 9.39.2(jiti@2.6.1)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/visitor-keys@8.55.0':
+ dependencies:
+ '@typescript-eslint/types': 8.55.0
+ eslint-visitor-keys: 4.2.1
+
+ '@ungap/structured-clone@1.3.0': {}
+
+ '@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2))':
+ dependencies:
+ '@babel/core': 7.29.0
+ '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0)
+ '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0)
+ '@rolldown/pluginutils': 1.0.0-rc.3
+ '@types/babel__core': 7.20.5
+ react-refresh: 0.18.0
+ vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)
+ transitivePeerDependencies:
+ - supports-color
+
+ acorn-jsx@5.3.2(acorn@8.15.0):
+ dependencies:
+ acorn: 8.15.0
+
+ acorn@8.15.0: {}
+
+ ajv@6.12.6:
+ dependencies:
+ fast-deep-equal: 3.1.3
+ fast-json-stable-stringify: 2.1.0
+ json-schema-traverse: 0.4.1
+ uri-js: 4.4.1
+
+ ansi-styles@4.3.0:
+ dependencies:
+ color-convert: 2.0.1
+
+ argparse@2.0.1: {}
+
+ bail@2.0.2: {}
+
+ balanced-match@1.0.2: {}
+
+ baseline-browser-mapping@2.9.19: {}
+
+ brace-expansion@1.1.12:
+ dependencies:
+ balanced-match: 1.0.2
+ concat-map: 0.0.1
+
+ brace-expansion@2.0.2:
+ dependencies:
+ balanced-match: 1.0.2
+
+ browserslist@4.28.1:
+ dependencies:
+ baseline-browser-mapping: 2.9.19
+ caniuse-lite: 1.0.30001770
+ electron-to-chromium: 1.5.286
+ node-releases: 2.0.27
+ update-browserslist-db: 1.2.3(browserslist@4.28.1)
+
+ callsites@3.1.0: {}
+
+ caniuse-lite@1.0.30001770: {}
+
+ ccount@2.0.1: {}
+
+ chalk@4.1.2:
+ dependencies:
+ ansi-styles: 4.3.0
+ supports-color: 7.2.0
+
+ character-entities-html4@2.1.0: {}
+
+ character-entities-legacy@3.0.0: {}
+
+ character-entities@2.0.2: {}
+
+ character-reference-invalid@2.0.1: {}
+
+ color-convert@2.0.1:
+ dependencies:
+ color-name: 1.1.4
+
+ color-name@1.1.4: {}
+
+ comma-separated-tokens@2.0.3: {}
+
+ concat-map@0.0.1: {}
+
+ convert-source-map@2.0.0: {}
+
+ cookie@1.1.1: {}
+
+ cross-spawn@7.0.6:
+ dependencies:
+ path-key: 3.1.1
+ shebang-command: 2.0.0
+ which: 2.0.2
+
+ csstype@3.2.3: {}
+
+ debug@4.4.3:
+ dependencies:
+ ms: 2.1.3
+
+ decode-named-character-reference@1.3.0:
+ dependencies:
+ character-entities: 2.0.2
+
+ deep-is@0.1.4: {}
+
+ dequal@2.0.3: {}
+
+ detect-libc@2.1.2: {}
+
+ devlop@1.1.0:
+ dependencies:
+ dequal: 2.0.3
+
+ electron-to-chromium@1.5.286: {}
+
+ enhanced-resolve@5.19.0:
+ dependencies:
+ graceful-fs: 4.2.11
+ tapable: 2.3.0
+
+ esbuild@0.27.3:
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.27.3
+ '@esbuild/android-arm': 0.27.3
+ '@esbuild/android-arm64': 0.27.3
+ '@esbuild/android-x64': 0.27.3
+ '@esbuild/darwin-arm64': 0.27.3
+ '@esbuild/darwin-x64': 0.27.3
+ '@esbuild/freebsd-arm64': 0.27.3
+ '@esbuild/freebsd-x64': 0.27.3
+ '@esbuild/linux-arm': 0.27.3
+ '@esbuild/linux-arm64': 0.27.3
+ '@esbuild/linux-ia32': 0.27.3
+ '@esbuild/linux-loong64': 0.27.3
+ '@esbuild/linux-mips64el': 0.27.3
+ '@esbuild/linux-ppc64': 0.27.3
+ '@esbuild/linux-riscv64': 0.27.3
+ '@esbuild/linux-s390x': 0.27.3
+ '@esbuild/linux-x64': 0.27.3
+ '@esbuild/netbsd-arm64': 0.27.3
+ '@esbuild/netbsd-x64': 0.27.3
+ '@esbuild/openbsd-arm64': 0.27.3
+ '@esbuild/openbsd-x64': 0.27.3
+ '@esbuild/openharmony-arm64': 0.27.3
+ '@esbuild/sunos-x64': 0.27.3
+ '@esbuild/win32-arm64': 0.27.3
+ '@esbuild/win32-ia32': 0.27.3
+ '@esbuild/win32-x64': 0.27.3
+
+ escalade@3.2.0: {}
+
+ escape-string-regexp@4.0.0: {}
+
+ escape-string-regexp@5.0.0: {}
+
+ eslint-plugin-react-hooks@7.0.1(eslint@9.39.2(jiti@2.6.1)):
+ dependencies:
+ '@babel/core': 7.29.0
+ '@babel/parser': 7.29.0
+ eslint: 9.39.2(jiti@2.6.1)
+ hermes-parser: 0.25.1
+ zod: 4.3.6
+ zod-validation-error: 4.0.2(zod@4.3.6)
+ transitivePeerDependencies:
+ - supports-color
+
+ eslint-plugin-react-refresh@0.4.26(eslint@9.39.2(jiti@2.6.1)):
+ dependencies:
+ eslint: 9.39.2(jiti@2.6.1)
+
+ eslint-scope@8.4.0:
+ dependencies:
+ esrecurse: 4.3.0
+ estraverse: 5.3.0
+
+ eslint-visitor-keys@3.4.3: {}
+
+ eslint-visitor-keys@4.2.1: {}
+
+ eslint@9.39.2(jiti@2.6.1):
+ dependencies:
+ '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1))
+ '@eslint-community/regexpp': 4.12.2
+ '@eslint/config-array': 0.21.1
+ '@eslint/config-helpers': 0.4.2
+ '@eslint/core': 0.17.0
+ '@eslint/eslintrc': 3.3.3
+ '@eslint/js': 9.39.2
+ '@eslint/plugin-kit': 0.4.1
+ '@humanfs/node': 0.16.7
+ '@humanwhocodes/module-importer': 1.0.1
+ '@humanwhocodes/retry': 0.4.3
+ '@types/estree': 1.0.8
+ ajv: 6.12.6
+ chalk: 4.1.2
+ cross-spawn: 7.0.6
+ debug: 4.4.3
+ escape-string-regexp: 4.0.0
+ eslint-scope: 8.4.0
+ eslint-visitor-keys: 4.2.1
+ espree: 10.4.0
+ esquery: 1.7.0
+ esutils: 2.0.3
+ fast-deep-equal: 3.1.3
+ file-entry-cache: 8.0.0
+ find-up: 5.0.0
+ glob-parent: 6.0.2
+ ignore: 5.3.2
+ imurmurhash: 0.1.4
+ is-glob: 4.0.3
+ json-stable-stringify-without-jsonify: 1.0.1
+ lodash.merge: 4.6.2
+ minimatch: 3.1.2
+ natural-compare: 1.4.0
+ optionator: 0.9.4
+ optionalDependencies:
+ jiti: 2.6.1
+ transitivePeerDependencies:
+ - supports-color
+
+ espree@10.4.0:
+ dependencies:
+ acorn: 8.15.0
+ acorn-jsx: 5.3.2(acorn@8.15.0)
+ eslint-visitor-keys: 4.2.1
+
+ esquery@1.7.0:
+ dependencies:
+ estraverse: 5.3.0
+
+ esrecurse@4.3.0:
+ dependencies:
+ estraverse: 5.3.0
+
+ estraverse@5.3.0: {}
+
+ estree-util-is-identifier-name@3.0.0: {}
+
+ esutils@2.0.3: {}
+
+ extend@3.0.2: {}
+
+ fast-deep-equal@3.1.3: {}
+
+ fast-json-stable-stringify@2.1.0: {}
+
+ fast-levenshtein@2.0.6: {}
+
+ fdir@6.5.0(picomatch@4.0.3):
+ optionalDependencies:
+ picomatch: 4.0.3
+
+ file-entry-cache@8.0.0:
+ dependencies:
+ flat-cache: 4.0.1
+
+ find-up@5.0.0:
+ dependencies:
+ locate-path: 6.0.0
+ path-exists: 4.0.0
+
+ flat-cache@4.0.1:
+ dependencies:
+ flatted: 3.3.3
+ keyv: 4.5.4
+
+ flatted@3.3.3: {}
+
+ fsevents@2.3.3:
+ optional: true
+
+ gensync@1.0.0-beta.2: {}
+
+ glob-parent@6.0.2:
+ dependencies:
+ is-glob: 4.0.3
+
+ globals@14.0.0: {}
+
+ globals@16.5.0: {}
+
+ graceful-fs@4.2.11: {}
+
+ has-flag@4.0.0: {}
+
+ hast-util-to-jsx-runtime@2.3.6:
+ dependencies:
+ '@types/estree': 1.0.8
+ '@types/hast': 3.0.4
+ '@types/unist': 3.0.3
+ comma-separated-tokens: 2.0.3
+ devlop: 1.1.0
+ estree-util-is-identifier-name: 3.0.0
+ hast-util-whitespace: 3.0.0
+ mdast-util-mdx-expression: 2.0.1
+ mdast-util-mdx-jsx: 3.2.0
+ mdast-util-mdxjs-esm: 2.0.1
+ property-information: 7.1.0
+ space-separated-tokens: 2.0.2
+ style-to-js: 1.1.21
+ unist-util-position: 5.0.0
+ vfile-message: 4.0.3
+ transitivePeerDependencies:
+ - supports-color
+
+ hast-util-whitespace@3.0.0:
+ dependencies:
+ '@types/hast': 3.0.4
+
+ hermes-estree@0.25.1: {}
+
+ hermes-parser@0.25.1:
+ dependencies:
+ hermes-estree: 0.25.1
+
+ html-url-attributes@3.0.1: {}
+
+ ignore@5.3.2: {}
+
+ ignore@7.0.5: {}
+
+ import-fresh@3.3.1:
+ dependencies:
+ parent-module: 1.0.1
+ resolve-from: 4.0.0
+
+ imurmurhash@0.1.4: {}
+
+ inline-style-parser@0.2.7: {}
+
+ is-alphabetical@2.0.1: {}
+
+ is-alphanumerical@2.0.1:
+ dependencies:
+ is-alphabetical: 2.0.1
+ is-decimal: 2.0.1
+
+ is-decimal@2.0.1: {}
+
+ is-extglob@2.1.1: {}
+
+ is-glob@4.0.3:
+ dependencies:
+ is-extglob: 2.1.1
+
+ is-hexadecimal@2.0.1: {}
+
+ is-plain-obj@4.1.0: {}
+
+ isexe@2.0.0: {}
+
+ jiti@2.6.1: {}
+
+ js-tokens@4.0.0: {}
+
+ js-yaml@4.1.1:
+ dependencies:
+ argparse: 2.0.1
+
+ jsesc@3.1.0: {}
+
+ json-buffer@3.0.1: {}
+
+ json-schema-traverse@0.4.1: {}
+
+ json-stable-stringify-without-jsonify@1.0.1: {}
+
+ json5@2.2.3: {}
+
+ keyv@4.5.4:
+ dependencies:
+ json-buffer: 3.0.1
+
+ levn@0.4.1:
+ dependencies:
+ prelude-ls: 1.2.1
+ type-check: 0.4.0
+
+ 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
+
+ locate-path@6.0.0:
+ dependencies:
+ p-locate: 5.0.0
+
+ lodash.merge@4.6.2: {}
+
+ longest-streak@3.1.0: {}
+
+ lru-cache@5.1.1:
+ dependencies:
+ yallist: 3.1.1
+
+ magic-string@0.30.21:
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.5
+
+ markdown-table@3.0.4: {}
+
+ mdast-util-find-and-replace@3.0.2:
+ dependencies:
+ '@types/mdast': 4.0.4
+ escape-string-regexp: 5.0.0
+ unist-util-is: 6.0.1
+ unist-util-visit-parents: 6.0.2
+
+ mdast-util-from-markdown@2.0.2:
+ dependencies:
+ '@types/mdast': 4.0.4
+ '@types/unist': 3.0.3
+ decode-named-character-reference: 1.3.0
+ devlop: 1.1.0
+ mdast-util-to-string: 4.0.0
+ micromark: 4.0.2
+ micromark-util-decode-numeric-character-reference: 2.0.2
+ micromark-util-decode-string: 2.0.1
+ micromark-util-normalize-identifier: 2.0.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+ unist-util-stringify-position: 4.0.0
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-gfm-autolink-literal@2.0.1:
+ dependencies:
+ '@types/mdast': 4.0.4
+ ccount: 2.0.1
+ devlop: 1.1.0
+ mdast-util-find-and-replace: 3.0.2
+ micromark-util-character: 2.1.1
+
+ mdast-util-gfm-footnote@2.1.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ devlop: 1.1.0
+ mdast-util-from-markdown: 2.0.2
+ mdast-util-to-markdown: 2.1.2
+ micromark-util-normalize-identifier: 2.0.1
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-gfm-strikethrough@2.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ mdast-util-from-markdown: 2.0.2
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-gfm-table@2.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ devlop: 1.1.0
+ markdown-table: 3.0.4
+ mdast-util-from-markdown: 2.0.2
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-gfm-task-list-item@2.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ devlop: 1.1.0
+ mdast-util-from-markdown: 2.0.2
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-gfm@3.1.0:
+ dependencies:
+ mdast-util-from-markdown: 2.0.2
+ mdast-util-gfm-autolink-literal: 2.0.1
+ mdast-util-gfm-footnote: 2.1.0
+ mdast-util-gfm-strikethrough: 2.0.0
+ mdast-util-gfm-table: 2.0.0
+ mdast-util-gfm-task-list-item: 2.0.0
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-mdx-expression@2.0.1:
+ dependencies:
+ '@types/estree-jsx': 1.0.5
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ devlop: 1.1.0
+ mdast-util-from-markdown: 2.0.2
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-mdx-jsx@3.2.0:
+ dependencies:
+ '@types/estree-jsx': 1.0.5
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ '@types/unist': 3.0.3
+ ccount: 2.0.1
+ devlop: 1.1.0
+ mdast-util-from-markdown: 2.0.2
+ mdast-util-to-markdown: 2.1.2
+ parse-entities: 4.0.2
+ stringify-entities: 4.0.4
+ unist-util-stringify-position: 4.0.0
+ vfile-message: 4.0.3
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-mdxjs-esm@2.0.1:
+ dependencies:
+ '@types/estree-jsx': 1.0.5
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ devlop: 1.1.0
+ mdast-util-from-markdown: 2.0.2
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-phrasing@4.1.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ unist-util-is: 6.0.1
+
+ mdast-util-to-hast@13.2.1:
+ dependencies:
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ '@ungap/structured-clone': 1.3.0
+ devlop: 1.1.0
+ micromark-util-sanitize-uri: 2.0.1
+ trim-lines: 3.0.1
+ unist-util-position: 5.0.0
+ unist-util-visit: 5.1.0
+ vfile: 6.0.3
+
+ mdast-util-to-markdown@2.1.2:
+ dependencies:
+ '@types/mdast': 4.0.4
+ '@types/unist': 3.0.3
+ longest-streak: 3.1.0
+ mdast-util-phrasing: 4.1.0
+ mdast-util-to-string: 4.0.0
+ micromark-util-classify-character: 2.0.1
+ micromark-util-decode-string: 2.0.1
+ unist-util-visit: 5.1.0
+ zwitch: 2.0.4
+
+ mdast-util-to-string@4.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+
+ micromark-core-commonmark@2.0.3:
+ dependencies:
+ decode-named-character-reference: 1.3.0
+ devlop: 1.1.0
+ micromark-factory-destination: 2.0.1
+ micromark-factory-label: 2.0.1
+ micromark-factory-space: 2.0.1
+ micromark-factory-title: 2.0.1
+ micromark-factory-whitespace: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-chunked: 2.0.1
+ micromark-util-classify-character: 2.0.1
+ micromark-util-html-tag-name: 2.0.1
+ micromark-util-normalize-identifier: 2.0.1
+ micromark-util-resolve-all: 2.0.1
+ micromark-util-subtokenize: 2.1.0
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm-autolink-literal@2.1.0:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-sanitize-uri: 2.0.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm-footnote@2.1.0:
+ dependencies:
+ devlop: 1.1.0
+ micromark-core-commonmark: 2.0.3
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-normalize-identifier: 2.0.1
+ micromark-util-sanitize-uri: 2.0.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm-strikethrough@2.1.0:
+ dependencies:
+ devlop: 1.1.0
+ micromark-util-chunked: 2.0.1
+ micromark-util-classify-character: 2.0.1
+ micromark-util-resolve-all: 2.0.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm-table@2.1.1:
+ dependencies:
+ devlop: 1.1.0
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm-tagfilter@2.0.0:
+ dependencies:
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm-task-list-item@2.1.0:
+ dependencies:
+ devlop: 1.1.0
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm@3.0.0:
+ dependencies:
+ micromark-extension-gfm-autolink-literal: 2.1.0
+ micromark-extension-gfm-footnote: 2.1.0
+ micromark-extension-gfm-strikethrough: 2.1.0
+ micromark-extension-gfm-table: 2.1.1
+ micromark-extension-gfm-tagfilter: 2.0.0
+ micromark-extension-gfm-task-list-item: 2.1.0
+ micromark-util-combine-extensions: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-destination@2.0.1:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-label@2.0.1:
+ dependencies:
+ devlop: 1.1.0
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-space@2.0.1:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-title@2.0.1:
+ dependencies:
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-whitespace@2.0.1:
+ dependencies:
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-character@2.1.1:
+ dependencies:
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-chunked@2.0.1:
+ dependencies:
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-classify-character@2.0.1:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-combine-extensions@2.0.1:
+ dependencies:
+ micromark-util-chunked: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-decode-numeric-character-reference@2.0.2:
+ dependencies:
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-decode-string@2.0.1:
+ dependencies:
+ decode-named-character-reference: 1.3.0
+ micromark-util-character: 2.1.1
+ micromark-util-decode-numeric-character-reference: 2.0.2
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-encode@2.0.1: {}
+
+ micromark-util-html-tag-name@2.0.1: {}
+
+ micromark-util-normalize-identifier@2.0.1:
+ dependencies:
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-resolve-all@2.0.1:
+ dependencies:
+ micromark-util-types: 2.0.2
+
+ micromark-util-sanitize-uri@2.0.1:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-encode: 2.0.1
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-subtokenize@2.1.0:
+ dependencies:
+ devlop: 1.1.0
+ micromark-util-chunked: 2.0.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-symbol@2.0.1: {}
+
+ micromark-util-types@2.0.2: {}
+
+ micromark@4.0.2:
+ dependencies:
+ '@types/debug': 4.1.12
+ debug: 4.4.3
+ decode-named-character-reference: 1.3.0
+ devlop: 1.1.0
+ micromark-core-commonmark: 2.0.3
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-chunked: 2.0.1
+ micromark-util-combine-extensions: 2.0.1
+ micromark-util-decode-numeric-character-reference: 2.0.2
+ micromark-util-encode: 2.0.1
+ micromark-util-normalize-identifier: 2.0.1
+ micromark-util-resolve-all: 2.0.1
+ micromark-util-sanitize-uri: 2.0.1
+ micromark-util-subtokenize: 2.1.0
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+ transitivePeerDependencies:
+ - supports-color
+
+ minimatch@3.1.2:
+ dependencies:
+ brace-expansion: 1.1.12
+
+ minimatch@9.0.5:
+ dependencies:
+ brace-expansion: 2.0.2
+
+ ms@2.1.3: {}
+
+ nanoid@3.3.11: {}
+
+ natural-compare@1.4.0: {}
+
+ node-releases@2.0.27: {}
+
+ optionator@0.9.4:
+ dependencies:
+ deep-is: 0.1.4
+ fast-levenshtein: 2.0.6
+ levn: 0.4.1
+ prelude-ls: 1.2.1
+ type-check: 0.4.0
+ word-wrap: 1.2.5
+
+ p-limit@3.1.0:
+ dependencies:
+ yocto-queue: 0.1.0
+
+ p-locate@5.0.0:
+ dependencies:
+ p-limit: 3.1.0
+
+ parent-module@1.0.1:
+ dependencies:
+ callsites: 3.1.0
+
+ parse-entities@4.0.2:
+ dependencies:
+ '@types/unist': 2.0.11
+ character-entities-legacy: 3.0.0
+ character-reference-invalid: 2.0.1
+ decode-named-character-reference: 1.3.0
+ is-alphanumerical: 2.0.1
+ is-decimal: 2.0.1
+ is-hexadecimal: 2.0.1
+
+ path-exists@4.0.0: {}
+
+ path-key@3.1.1: {}
+
+ 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
+
+ prelude-ls@1.2.1: {}
+
+ property-information@7.1.0: {}
+
+ punycode@2.3.1: {}
+
+ react-dom@19.2.4(react@19.2.4):
+ dependencies:
+ react: 19.2.4
+ scheduler: 0.27.0
+
+ react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.4):
+ dependencies:
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ '@types/react': 19.2.14
+ devlop: 1.1.0
+ hast-util-to-jsx-runtime: 2.3.6
+ html-url-attributes: 3.0.1
+ mdast-util-to-hast: 13.2.1
+ react: 19.2.4
+ remark-parse: 11.0.0
+ remark-rehype: 11.1.2
+ unified: 11.0.5
+ unist-util-visit: 5.1.0
+ vfile: 6.0.3
+ transitivePeerDependencies:
+ - supports-color
+
+ react-refresh@0.18.0: {}
+
+ react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
+ dependencies:
+ cookie: 1.1.1
+ react: 19.2.4
+ set-cookie-parser: 2.7.2
+ optionalDependencies:
+ react-dom: 19.2.4(react@19.2.4)
+
+ react@19.2.4: {}
+
+ remark-gfm@4.0.1:
+ dependencies:
+ '@types/mdast': 4.0.4
+ mdast-util-gfm: 3.1.0
+ micromark-extension-gfm: 3.0.0
+ remark-parse: 11.0.0
+ remark-stringify: 11.0.0
+ unified: 11.0.5
+ transitivePeerDependencies:
+ - supports-color
+
+ remark-parse@11.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ mdast-util-from-markdown: 2.0.2
+ micromark-util-types: 2.0.2
+ unified: 11.0.5
+ transitivePeerDependencies:
+ - supports-color
+
+ remark-rehype@11.1.2:
+ dependencies:
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ mdast-util-to-hast: 13.2.1
+ unified: 11.0.5
+ vfile: 6.0.3
+
+ remark-stringify@11.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ mdast-util-to-markdown: 2.1.2
+ unified: 11.0.5
+
+ resolve-from@4.0.0: {}
+
+ rollup@4.57.1:
+ dependencies:
+ '@types/estree': 1.0.8
+ optionalDependencies:
+ '@rollup/rollup-android-arm-eabi': 4.57.1
+ '@rollup/rollup-android-arm64': 4.57.1
+ '@rollup/rollup-darwin-arm64': 4.57.1
+ '@rollup/rollup-darwin-x64': 4.57.1
+ '@rollup/rollup-freebsd-arm64': 4.57.1
+ '@rollup/rollup-freebsd-x64': 4.57.1
+ '@rollup/rollup-linux-arm-gnueabihf': 4.57.1
+ '@rollup/rollup-linux-arm-musleabihf': 4.57.1
+ '@rollup/rollup-linux-arm64-gnu': 4.57.1
+ '@rollup/rollup-linux-arm64-musl': 4.57.1
+ '@rollup/rollup-linux-loong64-gnu': 4.57.1
+ '@rollup/rollup-linux-loong64-musl': 4.57.1
+ '@rollup/rollup-linux-ppc64-gnu': 4.57.1
+ '@rollup/rollup-linux-ppc64-musl': 4.57.1
+ '@rollup/rollup-linux-riscv64-gnu': 4.57.1
+ '@rollup/rollup-linux-riscv64-musl': 4.57.1
+ '@rollup/rollup-linux-s390x-gnu': 4.57.1
+ '@rollup/rollup-linux-x64-gnu': 4.57.1
+ '@rollup/rollup-linux-x64-musl': 4.57.1
+ '@rollup/rollup-openbsd-x64': 4.57.1
+ '@rollup/rollup-openharmony-arm64': 4.57.1
+ '@rollup/rollup-win32-arm64-msvc': 4.57.1
+ '@rollup/rollup-win32-ia32-msvc': 4.57.1
+ '@rollup/rollup-win32-x64-gnu': 4.57.1
+ '@rollup/rollup-win32-x64-msvc': 4.57.1
+ fsevents: 2.3.3
+
+ scheduler@0.27.0: {}
+
+ semver@6.3.1: {}
+
+ semver@7.7.4: {}
+
+ set-cookie-parser@2.7.2: {}
+
+ shebang-command@2.0.0:
+ dependencies:
+ shebang-regex: 3.0.0
+
+ shebang-regex@3.0.0: {}
+
+ source-map-js@1.2.1: {}
+
+ space-separated-tokens@2.0.2: {}
+
+ stringify-entities@4.0.4:
+ dependencies:
+ character-entities-html4: 2.1.0
+ character-entities-legacy: 3.0.0
+
+ strip-json-comments@3.1.1: {}
+
+ style-to-js@1.1.21:
+ dependencies:
+ style-to-object: 1.0.14
+
+ style-to-object@1.0.14:
+ dependencies:
+ inline-style-parser: 0.2.7
+
+ supports-color@7.2.0:
+ dependencies:
+ has-flag: 4.0.0
+
+ tailwindcss@4.1.18: {}
+
+ tapable@2.3.0: {}
+
+ tinyglobby@0.2.15:
+ dependencies:
+ fdir: 6.5.0(picomatch@4.0.3)
+ picomatch: 4.0.3
+
+ trim-lines@3.0.1: {}
+
+ trough@2.2.0: {}
+
+ ts-api-utils@2.4.0(typescript@5.9.3):
+ dependencies:
+ typescript: 5.9.3
+
+ type-check@0.4.0:
+ dependencies:
+ prelude-ls: 1.2.1
+
+ typescript-eslint@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3):
+ dependencies:
+ '@typescript-eslint/eslint-plugin': 8.55.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/parser': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
+ eslint: 9.39.2(jiti@2.6.1)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ typescript@5.9.3: {}
+
+ undici-types@7.16.0: {}
+
+ unified@11.0.5:
+ dependencies:
+ '@types/unist': 3.0.3
+ bail: 2.0.2
+ devlop: 1.1.0
+ extend: 3.0.2
+ is-plain-obj: 4.1.0
+ trough: 2.2.0
+ vfile: 6.0.3
+
+ unist-util-is@6.0.1:
+ dependencies:
+ '@types/unist': 3.0.3
+
+ unist-util-position@5.0.0:
+ dependencies:
+ '@types/unist': 3.0.3
+
+ unist-util-stringify-position@4.0.0:
+ dependencies:
+ '@types/unist': 3.0.3
+
+ unist-util-visit-parents@6.0.2:
+ dependencies:
+ '@types/unist': 3.0.3
+ unist-util-is: 6.0.1
+
+ unist-util-visit@5.1.0:
+ dependencies:
+ '@types/unist': 3.0.3
+ unist-util-is: 6.0.1
+ unist-util-visit-parents: 6.0.2
+
+ update-browserslist-db@1.2.3(browserslist@4.28.1):
+ dependencies:
+ browserslist: 4.28.1
+ escalade: 3.2.0
+ picocolors: 1.1.1
+
+ uri-js@4.4.1:
+ dependencies:
+ punycode: 2.3.1
+
+ vfile-message@4.0.3:
+ dependencies:
+ '@types/unist': 3.0.3
+ unist-util-stringify-position: 4.0.0
+
+ vfile@6.0.3:
+ dependencies:
+ '@types/unist': 3.0.3
+ vfile-message: 4.0.3
+
+ vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2):
+ dependencies:
+ esbuild: 0.27.3
+ fdir: 6.5.0(picomatch@4.0.3)
+ picomatch: 4.0.3
+ postcss: 8.5.6
+ rollup: 4.57.1
+ tinyglobby: 0.2.15
+ optionalDependencies:
+ '@types/node': 24.10.13
+ fsevents: 2.3.3
+ jiti: 2.6.1
+ lightningcss: 1.30.2
+
+ which@2.0.2:
+ dependencies:
+ isexe: 2.0.0
+
+ word-wrap@1.2.5: {}
+
+ yallist@3.1.1: {}
+
+ yocto-queue@0.1.0: {}
+
+ zod-validation-error@4.0.2(zod@4.3.6):
+ dependencies:
+ zod: 4.3.6
+
+ zod@4.3.6: {}
+
+ zustand@5.0.11(@types/react@19.2.14)(react@19.2.4):
+ optionalDependencies:
+ '@types/react': 19.2.14
+ react: 19.2.4
+
+ zwitch@2.0.4: {}
diff --git a/retrocast-web/src/App.tsx b/retrocast-web/src/App.tsx
new file mode 100644
index 0000000..45a9d98
--- /dev/null
+++ b/retrocast-web/src/App.tsx
@@ -0,0 +1,25 @@
+import { BrowserRouter, Routes, Route, Navigate } from "react-router";
+import AuthLayout from "@/layouts/AuthLayout";
+import AppLayout from "@/layouts/AppLayout";
+import ServerAddressPage from "@/pages/ServerAddressPage";
+import LoginPage from "@/pages/LoginPage";
+import RegisterPage from "@/pages/RegisterPage";
+import ChatArea from "@/components/ChatArea";
+
+export default function App() {
+ return (
+
+
+ }>
+ } />
+ } />
+ } />
+
+ }>
+ } />
+
+ } />
+
+
+ );
+}
diff --git a/retrocast-web/src/components/AvatarView.tsx b/retrocast-web/src/components/AvatarView.tsx
new file mode 100644
index 0000000..c72e353
--- /dev/null
+++ b/retrocast-web/src/components/AvatarView.tsx
@@ -0,0 +1,75 @@
+import PresenceDot from "@/components/PresenceDot";
+
+interface AvatarViewProps {
+ userId: string;
+ displayName: string;
+ avatarHash: string | null;
+ size?: "sm" | "md" | "lg" | "xl";
+ showPresence?: boolean;
+ className?: string;
+}
+
+const SIZE_MAP = {
+ sm: { container: "h-6 w-6", text: "text-[10px]", presence: "sm" as const, px: 24 },
+ md: { container: "h-8 w-8", text: "text-xs", presence: "sm" as const, px: 32 },
+ lg: { container: "h-10 w-10", text: "text-sm", presence: "md" as const, px: 40 },
+ xl: { container: "h-20 w-20", text: "text-2xl", presence: "lg" as const, px: 80 },
+};
+
+// 8 preset avatar background colors derived from user ID
+const AVATAR_COLORS = [
+ "bg-red-500",
+ "bg-orange-500",
+ "bg-amber-500",
+ "bg-emerald-500",
+ "bg-cyan-500",
+ "bg-blue-500",
+ "bg-violet-500",
+ "bg-pink-500",
+];
+
+function avatarColor(userId: string): string {
+ let hash = 0;
+ for (let i = 0; i < userId.length; i++) {
+ hash = (hash * 31 + userId.charCodeAt(i)) | 0;
+ }
+ return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length];
+}
+
+export default function AvatarView({
+ userId,
+ displayName,
+ avatarHash,
+ size = "md",
+ showPresence = false,
+ className = "",
+}: AvatarViewProps) {
+ const serverUrl = localStorage.getItem("serverUrl") || "";
+ const initial = displayName[0]?.toUpperCase() || "?";
+ const s = SIZE_MAP[size];
+ const bgColor = avatarColor(userId);
+
+ return (
+
+
+ {avatarHash ? (
+

+ ) : (
+
{initial}
+ )}
+
+ {showPresence && (
+
+ )}
+
+ );
+}
diff --git a/retrocast-web/src/components/ChannelModal.tsx b/retrocast-web/src/components/ChannelModal.tsx
new file mode 100644
index 0000000..ddcd226
--- /dev/null
+++ b/retrocast-web/src/components/ChannelModal.tsx
@@ -0,0 +1,178 @@
+import { useState } from "react";
+import { api } from "@/lib/api";
+import { useChannelsStore } from "@/stores/channels";
+import type { Channel } from "@/types";
+
+interface ChannelModalProps {
+ guildId: string;
+ channel?: Channel; // If provided, editing; otherwise creating
+ onClose: () => void;
+}
+
+export default function ChannelModal({
+ guildId,
+ channel,
+ onClose,
+}: ChannelModalProps) {
+ const [name, setName] = useState(channel?.name || "");
+ const [topic, setTopic] = useState(channel?.topic || "");
+ const [type, setType] = useState(channel?.type ?? 0);
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState("");
+ const addChannel = useChannelsStore((s) => s.addChannel);
+ const updateChannel = useChannelsStore((s) => s.updateChannel);
+
+ const isEditing = !!channel;
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ const trimmed = name.trim();
+ if (!trimmed) return;
+
+ setSaving(true);
+ setError("");
+ try {
+ if (isEditing) {
+ const updated = await api.patch(
+ `/api/v1/channels/${channel.id}`,
+ { name: trimmed, topic: topic.trim() || null },
+ );
+ updateChannel(updated);
+ } else {
+ const created = await api.post(
+ `/api/v1/guilds/${guildId}/channels`,
+ { name: trimmed, type, topic: topic.trim() || null },
+ );
+ addChannel(created);
+ }
+ onClose();
+ } catch {
+ setError(isEditing ? "Failed to update channel" : "Failed to create channel");
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ async function handleDelete() {
+ if (!channel) return;
+ if (!confirm("Delete this channel? This cannot be undone.")) return;
+ try {
+ await api.delete(`/api/v1/channels/${channel.id}`);
+ useChannelsStore.getState().removeChannel(channel.id, guildId);
+ onClose();
+ } catch {
+ setError("Failed to delete channel");
+ }
+ }
+
+ return (
+
+
e.stopPropagation()}
+ >
+
+ {isEditing ? "Edit Channel" : "Create Channel"}
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+ );
+}
diff --git a/retrocast-web/src/components/ChannelSidebar.tsx b/retrocast-web/src/components/ChannelSidebar.tsx
new file mode 100644
index 0000000..029e237
--- /dev/null
+++ b/retrocast-web/src/components/ChannelSidebar.tsx
@@ -0,0 +1,376 @@
+import { useState } from "react";
+import { useGuildsStore } from "@/stores/guilds";
+import { useChannelsStore } from "@/stores/channels";
+import { useDMsStore } from "@/stores/dms";
+import { useAuthStore } from "@/stores/auth";
+import AvatarView from "@/components/AvatarView";
+import InviteModal from "@/components/InviteModal";
+import GuildSettingsModal from "@/components/GuildSettingsModal";
+import ChannelModal from "@/components/ChannelModal";
+import UserSettingsModal from "@/components/UserSettingsModal";
+import type { Channel } from "@/types";
+
+const CHANNEL_TYPE_VOICE = 2;
+const CHANNEL_TYPE_CATEGORY = 4;
+
+function HashIcon() {
+ return (
+
+ );
+}
+
+function VoiceIcon() {
+ return (
+
+ );
+}
+
+function ChannelItem({
+ channel,
+ isSelected,
+ onClick,
+ onEdit,
+}: {
+ channel: Channel;
+ isSelected: boolean;
+ onClick: () => void;
+ onEdit?: () => void;
+}) {
+ return (
+
+
+ {onEdit && (
+
+ )}
+
+ );
+}
+
+function CategoryGroup({
+ name,
+ channels,
+ selectedChannelId,
+ onSelectChannel,
+ onEditChannel,
+}: {
+ name: string;
+ channels: Channel[];
+ selectedChannelId: string | null;
+ onSelectChannel: (id: string) => void;
+ onEditChannel: (channel: Channel) => void;
+}) {
+ return (
+
+
+
+ {channels.map((ch) => (
+ onSelectChannel(ch.id)}
+ onEdit={() => onEditChannel(ch)}
+ />
+ ))}
+
+
+ );
+}
+
+function UserPanel() {
+ const user = useAuthStore((s) => s.user);
+ const [showUserSettings, setShowUserSettings] = useState(false);
+
+ if (!user) return null;
+
+ const displayName = user.display_name || user.username;
+
+ return (
+ <>
+
+
+
+
+ {displayName}
+
+
+ {user.username}
+
+
+
+
+ {showUserSettings && (
+ setShowUserSettings(false)} />
+ )}
+ >
+ );
+}
+
+function DMList() {
+ const dms = useDMsStore((s) => s.dms);
+ const selectedDMId = useDMsStore((s) => s.selectedDMId);
+ const selectDM = useDMsStore((s) => s.selectDM);
+ const currentUser = useAuthStore((s) => s.user);
+ const dmList = Array.from(dms.values());
+
+ return (
+
+
+
+ Direct Messages
+
+
+
+ {dmList.length === 0 && (
+
+ No conversations yet
+
+ )}
+ {dmList.map((dm) => {
+ const recipient = dm.recipients.find((r) => r.id !== currentUser?.id) || dm.recipients[0];
+ const name = recipient
+ ? recipient.display_name || recipient.username
+ : "Unknown";
+ return (
+
+ );
+ })}
+
+
+
+ );
+}
+
+export default function ChannelSidebar() {
+ const showDMList = useDMsStore((s) => s.showDMList);
+ const selectedGuildId = useGuildsStore((s) => s.selectedGuildId);
+ const guild = useGuildsStore(
+ (s) => (selectedGuildId ? s.guilds.get(selectedGuildId) : null),
+ );
+ const channelsByGuild = useChannelsStore((s) => s.channelsByGuild);
+ const selectedChannelId = useChannelsStore((s) => s.selectedChannelId);
+ const selectChannel = useChannelsStore((s) => s.selectChannel);
+
+ const [showInvite, setShowInvite] = useState(false);
+ const [showSettings, setShowSettings] = useState(false);
+ const [showCreateChannel, setShowCreateChannel] = useState(false);
+ const [editingChannel, setEditingChannel] = useState(null);
+
+ if (showDMList) {
+ return ;
+ }
+
+ if (!selectedGuildId || !guild) {
+ return (
+
+
+ Retrocast
+
+
+ Select a server
+
+
+
+ );
+ }
+
+ const channels = channelsByGuild.get(selectedGuildId) || [];
+
+ // Group channels: categories contain their children, ungrouped at top
+ const categories = channels.filter((c) => c.type === CHANNEL_TYPE_CATEGORY);
+ const ungrouped = channels.filter(
+ (c) => c.type !== CHANNEL_TYPE_CATEGORY && c.parent_id === null,
+ );
+
+ return (
+
+ {/* Guild header with actions */}
+
+
+ {guild.name}
+
+
+
+
+
+
+
+ {/* Channel list */}
+
+ {/* Ungrouped channels */}
+ {ungrouped.length > 0 && (
+
+ {ungrouped.map((ch) => (
+ selectChannel(ch.id)}
+ onEdit={() => setEditingChannel(ch)}
+ />
+ ))}
+
+ )}
+
+ {/* Category groups */}
+ {categories.map((cat) => {
+ const children = channels.filter(
+ (c) => c.parent_id === cat.id && c.type !== CHANNEL_TYPE_CATEGORY,
+ );
+ return (
+
+ );
+ })}
+
+
+ {/* Create channel button */}
+
+
+
+
+
+
+ {/* Modals */}
+ {showInvite && (
+
setShowInvite(false)}
+ />
+ )}
+ {showSettings && guild && (
+ setShowSettings(false)}
+ />
+ )}
+ {showCreateChannel && (
+ setShowCreateChannel(false)}
+ />
+ )}
+ {editingChannel && (
+ setEditingChannel(null)}
+ />
+ )}
+
+ );
+}
diff --git a/retrocast-web/src/components/ChatArea.tsx b/retrocast-web/src/components/ChatArea.tsx
new file mode 100644
index 0000000..7c1fe61
--- /dev/null
+++ b/retrocast-web/src/components/ChatArea.tsx
@@ -0,0 +1,153 @@
+import { useState } from "react";
+import { useChannelsStore } from "@/stores/channels";
+import { useGuildsStore } from "@/stores/guilds";
+import { useDMsStore } from "@/stores/dms";
+import { useAuthStore } from "@/stores/auth";
+import MessageList from "@/components/MessageList";
+import MessageInput from "@/components/MessageInput";
+import TypingIndicator from "@/components/TypingIndicator";
+import FileUpload from "@/components/FileUpload";
+import MemberList from "@/components/MemberList";
+import AvatarView from "@/components/AvatarView";
+
+export default function ChatArea() {
+ const selectedChannelId = useChannelsStore((s) => s.selectedChannelId);
+ const selectedGuildId = useGuildsStore((s) => s.selectedGuildId);
+ const channelsByGuild = useChannelsStore((s) => s.channelsByGuild);
+ const showDMList = useDMsStore((s) => s.showDMList);
+ const selectedDMId = useDMsStore((s) => s.selectedDMId);
+ const dms = useDMsStore((s) => s.dms);
+ const currentUser = useAuthStore((s) => s.user);
+ const [showMembers, setShowMembers] = useState(true);
+ const [pendingDropFiles, setPendingDropFiles] = useState([]);
+
+ // Determine which channel to show
+ const isDM = showDMList && selectedDMId;
+ const activeChannelId = isDM ? selectedDMId : selectedChannelId;
+
+ if (!activeChannelId) {
+ return (
+
+
+
+ Welcome to Retrocast
+
+
+ Select or create a server to get started
+
+
+
+ );
+ }
+
+ // Header info depends on whether this is a DM or guild channel
+ let headerContent: React.ReactNode;
+
+ if (isDM) {
+ const dm = dms.get(selectedDMId);
+ const recipient = dm?.recipients.find((r) => r.id !== currentUser?.id) || dm?.recipients[0];
+ const recipientName = recipient
+ ? recipient.display_name || recipient.username
+ : "Direct Message";
+
+ headerContent = (
+ <>
+ {recipient && (
+
+ )}
+ {recipientName}
+ >
+ );
+ } else {
+ const channels = selectedGuildId
+ ? channelsByGuild.get(selectedGuildId)
+ : null;
+ const channel = channels?.find((c) => c.id === selectedChannelId);
+
+ headerContent = (
+ <>
+ #
+
+ {channel?.name || "channel"}
+
+ {channel?.topic && (
+ <>
+
+
+ {channel.topic}
+
+ >
+ )}
+ >
+ );
+ }
+
+ // Channel name for MessageInput placeholder
+ const channelName = isDM
+ ? undefined
+ : (() => {
+ const channels = selectedGuildId
+ ? channelsByGuild.get(selectedGuildId)
+ : null;
+ return channels?.find((c) => c.id === selectedChannelId)?.name;
+ })();
+
+ return (
+
+
+ {/* Header */}
+
+ {headerContent}
+
+ {/* Spacer */}
+
+
+ {/* Member list toggle (guild channels only) */}
+ {selectedGuildId && !isDM && (
+
+ )}
+
+
+
setPendingDropFiles(files)}>
+
+
+ setPendingDropFiles([])}
+ />
+
+
+
+ {/* Member list panel (guild channels only) */}
+ {showMembers && selectedGuildId && !isDM && (
+
+ )}
+
+ );
+}
diff --git a/retrocast-web/src/components/FileUpload.tsx b/retrocast-web/src/components/FileUpload.tsx
new file mode 100644
index 0000000..886da69
--- /dev/null
+++ b/retrocast-web/src/components/FileUpload.tsx
@@ -0,0 +1,79 @@
+import { useState, useRef, type DragEvent } from "react";
+
+interface FileUploadProps {
+ onFilesSelected: (files: File[]) => void;
+ children: React.ReactNode;
+}
+
+export default function FileUpload({
+ onFilesSelected,
+ children,
+}: FileUploadProps) {
+ const [dragging, setDragging] = useState(false);
+ const dragCounterRef = useRef(0);
+
+ function handleDragEnter(e: DragEvent) {
+ e.preventDefault();
+ e.stopPropagation();
+ dragCounterRef.current++;
+ if (e.dataTransfer.types.includes("Files")) {
+ setDragging(true);
+ }
+ }
+
+ function handleDragLeave(e: DragEvent) {
+ e.preventDefault();
+ e.stopPropagation();
+ dragCounterRef.current--;
+ if (dragCounterRef.current === 0) {
+ setDragging(false);
+ }
+ }
+
+ function handleDragOver(e: DragEvent) {
+ e.preventDefault();
+ e.stopPropagation();
+ }
+
+ function handleDrop(e: DragEvent) {
+ e.preventDefault();
+ e.stopPropagation();
+ setDragging(false);
+ dragCounterRef.current = 0;
+
+ const files = Array.from(e.dataTransfer.files);
+ if (files.length > 0) {
+ onFilesSelected(files);
+ }
+ }
+
+ return (
+
+ {children}
+ {dragging && (
+
+
+
+
+ Drop files to upload
+
+
+
+ )}
+
+ );
+}
diff --git a/retrocast-web/src/components/GuildSettingsModal.tsx b/retrocast-web/src/components/GuildSettingsModal.tsx
new file mode 100644
index 0000000..34dc10e
--- /dev/null
+++ b/retrocast-web/src/components/GuildSettingsModal.tsx
@@ -0,0 +1,204 @@
+import { useState } from "react";
+import { api } from "@/lib/api";
+import { useGuildsStore } from "@/stores/guilds";
+import { useAuthStore } from "@/stores/auth";
+import RoleEditor from "@/components/RoleEditor";
+import type { Guild } from "@/types";
+
+interface GuildSettingsModalProps {
+ guild: Guild;
+ onClose: () => void;
+}
+
+type Tab = "general" | "roles";
+
+export default function GuildSettingsModal({
+ guild,
+ onClose,
+}: GuildSettingsModalProps) {
+ const [tab, setTab] = useState("general");
+ const [name, setName] = useState(guild.name);
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState("");
+ const [confirmDelete, setConfirmDelete] = useState(false);
+ const [deleting, setDeleting] = useState(false);
+ const currentUser = useAuthStore((s) => s.user);
+ const isOwner = currentUser?.id === guild.owner_id;
+ const setGuild = useGuildsStore((s) => s.setGuild);
+ const removeGuild = useGuildsStore((s) => s.removeGuild);
+ const selectGuild = useGuildsStore((s) => s.selectGuild);
+
+ async function handleSave(e: React.FormEvent) {
+ e.preventDefault();
+ const trimmed = name.trim();
+ if (!trimmed || trimmed === guild.name) {
+ onClose();
+ return;
+ }
+ setSaving(true);
+ setError("");
+ try {
+ const updated = await api.patch(`/api/v1/guilds/${guild.id}`, {
+ name: trimmed,
+ });
+ setGuild(updated);
+ onClose();
+ } catch {
+ setError("Failed to update server");
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ async function handleDelete() {
+ setDeleting(true);
+ try {
+ await api.delete(`/api/v1/guilds/${guild.id}`);
+ removeGuild(guild.id);
+ selectGuild(null);
+ onClose();
+ } catch {
+ setError("Failed to delete server");
+ } finally {
+ setDeleting(false);
+ }
+ }
+
+ async function handleLeave() {
+ try {
+ await api.delete(`/api/v1/guilds/${guild.id}/members/@me`);
+ removeGuild(guild.id);
+ selectGuild(null);
+ onClose();
+ } catch {
+ setError("Failed to leave server");
+ }
+ }
+
+ const tabs: { id: Tab; label: string }[] = [
+ { id: "general", label: "General" },
+ { id: "roles", label: "Roles" },
+ ];
+
+ return (
+
+
e.stopPropagation()}
+ >
+
+ Server Settings
+
+
+ {/* Tabs */}
+
+ {tabs.map((t) => (
+
+ ))}
+
+
+ {tab === "general" && (
+ <>
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+ {isOwner ? (
+ <>
+ {!confirmDelete ? (
+
+ ) : (
+
+
+ Are you sure? This cannot be undone.
+
+
+
+
+
+
+ )}
+ >
+ ) : (
+
+ )}
+
+ >
+ )}
+
+ {tab === "roles" &&
}
+
+
+
+
+
+
+ );
+}
diff --git a/retrocast-web/src/components/InviteModal.tsx b/retrocast-web/src/components/InviteModal.tsx
new file mode 100644
index 0000000..5adab42
--- /dev/null
+++ b/retrocast-web/src/components/InviteModal.tsx
@@ -0,0 +1,135 @@
+import { useState, useEffect } from "react";
+import { api } from "@/lib/api";
+import type { Invite } from "@/types";
+
+interface InviteModalProps {
+ guildId: string;
+ onClose: () => void;
+}
+
+export default function InviteModal({ guildId, onClose }: InviteModalProps) {
+ const [invites, setInvites] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [creating, setCreating] = useState(false);
+ const [copied, setCopied] = useState(null);
+
+ useEffect(() => {
+ setLoading(true);
+ api
+ .get(`/api/v1/guilds/${guildId}/invites`)
+ .then(setInvites)
+ .catch(() => {})
+ .finally(() => setLoading(false));
+ }, [guildId]);
+
+ async function handleCreate() {
+ setCreating(true);
+ try {
+ const invite = await api.post(
+ `/api/v1/guilds/${guildId}/invites`,
+ { max_uses: 0, max_age_seconds: 86400 * 7 },
+ );
+ setInvites((prev) => [invite, ...prev]);
+ } catch {
+ // ignore
+ } finally {
+ setCreating(false);
+ }
+ }
+
+ async function handleRevoke(code: string) {
+ try {
+ await api.delete(`/invites/${code}`);
+ setInvites((prev) => prev.filter((i) => i.code !== code));
+ } catch {
+ // ignore
+ }
+ }
+
+ function handleCopy(code: string) {
+ const url = `${window.location.origin}/invite/${code}`;
+ navigator.clipboard.writeText(url).catch(() => {});
+ setCopied(code);
+ setTimeout(() => setCopied(null), 2000);
+ }
+
+ return (
+
+
e.stopPropagation()}
+ >
+
+ Invite People
+
+
+
+
+ {loading && (
+
+ Loading invites...
+
+ )}
+
+ {!loading && invites.length === 0 && (
+
+ No active invites. Create one above.
+
+ )}
+
+
+ {invites.map((invite) => (
+
+
+
+ {invite.code}
+
+
+ {invite.uses}
+ {invite.max_uses > 0 ? `/${invite.max_uses}` : ""} uses
+ {invite.expires_at &&
+ ` · Expires ${new Date(invite.expires_at).toLocaleDateString()}`}
+
+
+
+
+
+
+
+ ))}
+
+
+
+
+
+
+
+ );
+}
diff --git a/retrocast-web/src/components/MarkdownContent.tsx b/retrocast-web/src/components/MarkdownContent.tsx
new file mode 100644
index 0000000..0aa3ef6
--- /dev/null
+++ b/retrocast-web/src/components/MarkdownContent.tsx
@@ -0,0 +1,95 @@
+import Markdown from "react-markdown";
+import remarkGfm from "remark-gfm";
+import type { Components } from "react-markdown";
+import { useState } from "react";
+
+function CopyButton({ text }: { text: string }) {
+ const [copied, setCopied] = useState(false);
+
+ function handleCopy() {
+ navigator.clipboard.writeText(text).catch(() => {});
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ }
+
+ return (
+
+ );
+}
+
+const components: Components = {
+ a({ children, href, ...props }) {
+ return (
+
+ {children}
+
+ );
+ },
+ code({ children, className, ...props }) {
+ const isBlock = className?.startsWith("language-");
+ const text = String(children).replace(/\n$/, "");
+
+ if (isBlock) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {children}
+
+ );
+ },
+ pre({ children }) {
+ // pre is handled by the code component for blocks
+ return <>{children}>;
+ },
+ blockquote({ children }) {
+ return (
+
+ {children}
+
+ );
+ },
+ ul({ children }) {
+ return ;
+ },
+ ol({ children }) {
+ return {children}
;
+ },
+ p({ children }) {
+ return {children}
;
+ },
+};
+
+export default function MarkdownContent({ content }: { content: string }) {
+ return (
+
+
+ {content}
+
+
+ );
+}
diff --git a/retrocast-web/src/components/MemberList.tsx b/retrocast-web/src/components/MemberList.tsx
new file mode 100644
index 0000000..1026774
--- /dev/null
+++ b/retrocast-web/src/components/MemberList.tsx
@@ -0,0 +1,232 @@
+import { useEffect, useState } from "react";
+import { useMembersStore } from "@/stores/members";
+import { useRolesStore } from "@/stores/roles";
+import { usePresenceStore } from "@/stores/presence";
+import { useUsersStore } from "@/stores/users";
+import AvatarView from "@/components/AvatarView";
+import UserProfilePopover from "@/components/UserProfilePopover";
+import type { Member, Role } from "@/types";
+
+interface MemberListProps {
+ guildId: string;
+}
+
+interface MemberDisplayInfo {
+ member: Member;
+ displayName: string;
+ username: string;
+ avatarHash: string | null;
+}
+
+function intToHex(color: number): string {
+ if (color === 0) return "";
+ return `#${color.toString(16).padStart(6, "0")}`;
+}
+
+function MemberRow({
+ info,
+ guildId,
+ highestRole,
+}: {
+ info: MemberDisplayInfo;
+ guildId: string;
+ highestRole: Role | null;
+}) {
+ const [popover, setPopover] = useState<{ x: number; y: number } | null>(
+ null,
+ );
+ const status = usePresenceStore(
+ (s) => s.presences.get(info.member.user_id) || "offline",
+ );
+ const isOffline = status === "offline" || status === "invisible";
+
+ function handleClick(e: React.MouseEvent) {
+ setPopover({ x: e.clientX - 310, y: e.clientY - 20 });
+ }
+
+ const nameColor = highestRole ? intToHex(highestRole.color) : "";
+
+ return (
+ <>
+
+ {popover && (
+ setPopover(null)}
+ />
+ )}
+ >
+ );
+}
+
+export default function MemberList({ guildId }: MemberListProps) {
+ const membersMap = useMembersStore(
+ (s) => s.membersByGuild.get(guildId) || new Map(),
+ );
+ const fetchMembers = useMembersStore((s) => s.fetchMembers);
+ const roles = useRolesStore((s) => s.rolesByGuild.get(guildId) || []);
+ const fetchRoles = useRolesStore((s) => s.fetchRoles);
+ const presences = usePresenceStore((s) => s.presences);
+ const usersCache = useUsersStore((s) => s.users);
+
+ useEffect(() => {
+ fetchMembers(guildId).catch(() => {});
+ fetchRoles(guildId).catch(() => {});
+ }, [guildId, fetchMembers, fetchRoles]);
+
+ const members = Array.from(membersMap.values());
+
+ // Build display info for each member, using the users cache for names/avatars
+ const memberInfos: MemberDisplayInfo[] = members.map((m) => {
+ const cached = usersCache.get(m.user_id);
+ return {
+ member: m,
+ displayName: m.nickname || cached?.display_name || cached?.username || `User ${m.user_id.slice(-4)}`,
+ username: cached?.username || m.user_id.slice(-8),
+ avatarHash: cached?.avatar_hash ?? null,
+ };
+ });
+
+ // Find highest role for each member (for coloring and grouping)
+ function getHighestRole(member: Member): Role | null {
+ const memberRoles = roles.filter(
+ (r) => !r.is_default && member.roles.includes(r.id),
+ );
+ if (memberRoles.length === 0) return null;
+ return memberRoles.reduce((highest, r) =>
+ r.position > highest.position ? r : highest,
+ );
+ }
+
+ // Group members by their highest role
+ const roleGroups = new Map();
+ const noRoleMembers: MemberDisplayInfo[] = [];
+
+ for (const info of memberInfos) {
+ const highest = getHighestRole(info.member);
+ if (highest) {
+ const group = roleGroups.get(highest.id) || [];
+ group.push(info);
+ roleGroups.set(highest.id, group);
+ } else {
+ noRoleMembers.push(info);
+ }
+ }
+
+ // Sort members within groups: online first, then alphabetically
+ function sortMembers(list: MemberDisplayInfo[]): MemberDisplayInfo[] {
+ return [...list].sort((a, b) => {
+ const aOnline = isOnline(a.member.user_id);
+ const bOnline = isOnline(b.member.user_id);
+ if (aOnline !== bOnline) return aOnline ? -1 : 1;
+ return a.displayName.localeCompare(b.displayName);
+ });
+ }
+
+ function isOnline(userId: string): boolean {
+ const s = presences.get(userId);
+ return s === "online" || s === "idle" || s === "dnd";
+ }
+
+ // Sorted role groups by position (highest first)
+ const sortedRoles = roles
+ .filter((r) => !r.is_default && roleGroups.has(r.id))
+ .sort((a, b) => b.position - a.position);
+
+ const onlineCount = members.filter((m) => isOnline(m.user_id)).length;
+
+ return (
+
+
+
+ Members — {onlineCount} Online
+
+
+ {/* Role groups */}
+ {sortedRoles.map((role) => {
+ const group = sortMembers(roleGroups.get(role.id) || []);
+ return (
+
+
+ {role.name} — {group.length}
+
+ {group.map((info) => (
+
+ ))}
+
+ );
+ })}
+
+ {/* Members with no special role — split into Online / Offline */}
+ {(() => {
+ const online = noRoleMembers.filter((i) => isOnline(i.member.user_id));
+ const offline = noRoleMembers.filter((i) => !isOnline(i.member.user_id));
+ return (
+ <>
+ {online.length > 0 && (
+
+
+ Online — {online.length}
+
+ {sortMembers(online).map((info) => (
+
+ ))}
+
+ )}
+ {offline.length > 0 && (
+
+
+ Offline — {offline.length}
+
+ {sortMembers(offline).map((info) => (
+
+ ))}
+
+ )}
+ >
+ );
+ })()}
+
+
+ );
+}
diff --git a/retrocast-web/src/components/MessageContextMenu.tsx b/retrocast-web/src/components/MessageContextMenu.tsx
new file mode 100644
index 0000000..d464c4d
--- /dev/null
+++ b/retrocast-web/src/components/MessageContextMenu.tsx
@@ -0,0 +1,104 @@
+import { useEffect } from "react";
+import { useAuthStore } from "@/stores/auth";
+import { api } from "@/lib/api";
+import type { Message } from "@/types";
+
+interface MessageContextMenuProps {
+ message: Message;
+ x: number;
+ y: number;
+ onClose: () => void;
+ onEdit: () => void;
+}
+
+export default function MessageContextMenu({
+ message,
+ x,
+ y,
+ onClose,
+ onEdit,
+}: MessageContextMenuProps) {
+ const currentUser = useAuthStore((s) => s.user);
+ const isOwn = currentUser?.id === message.author_id;
+
+ useEffect(() => {
+ function handler() {
+ onClose();
+ }
+ document.addEventListener("click", handler);
+ return () => document.removeEventListener("click", handler);
+ }, [onClose]);
+
+ async function handleDelete() {
+ if (!confirm("Delete this message?")) {
+ onClose();
+ return;
+ }
+ try {
+ await api.delete(
+ `/api/v1/channels/${message.channel_id}/messages/${message.id}`,
+ );
+ } catch {
+ // ignore
+ }
+ onClose();
+ }
+
+ function handleCopy() {
+ navigator.clipboard.writeText(message.content).catch(() => {});
+ onClose();
+ }
+
+ function handleCopyId() {
+ navigator.clipboard.writeText(message.id).catch(() => {});
+ onClose();
+ }
+
+ function handleEdit() {
+ onEdit();
+ onClose();
+ }
+
+ // Ensure menu stays within viewport
+ const menuStyle: React.CSSProperties = {
+ left: Math.min(x, window.innerWidth - 200),
+ top: Math.min(y, window.innerHeight - 200),
+ };
+
+ return (
+
+
+
+ {isOwn && (
+ <>
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/retrocast-web/src/components/MessageInput.tsx b/retrocast-web/src/components/MessageInput.tsx
new file mode 100644
index 0000000..10aba29
--- /dev/null
+++ b/retrocast-web/src/components/MessageInput.tsx
@@ -0,0 +1,186 @@
+import { useState, useRef, useEffect, useCallback, type KeyboardEvent } from "react";
+import { api } from "@/lib/api";
+import type { Message, Attachment } from "@/types";
+
+const TYPING_THROTTLE = 8000;
+
+interface MessageInputProps {
+ channelId: string;
+ channelName?: string;
+ droppedFiles?: File[];
+ onDropConsumed?: () => void;
+}
+
+export default function MessageInput({
+ channelId,
+ channelName,
+ droppedFiles,
+ onDropConsumed,
+}: MessageInputProps) {
+ const [content, setContent] = useState("");
+ const [sending, setSending] = useState(false);
+ const [pendingFiles, setPendingFiles] = useState([]);
+ const fileInputRef = useRef(null);
+ const lastTypingRef = useRef(0);
+
+ const sendTyping = useCallback(() => {
+ const now = Date.now();
+ if (now - lastTypingRef.current < TYPING_THROTTLE) return;
+ lastTypingRef.current = now;
+ api.post(`/api/v1/channels/${channelId}/typing`).catch(() => {});
+ }, [channelId]);
+
+ function addFiles(files: File[]) {
+ setPendingFiles((prev) => [...prev, ...files]);
+ }
+
+ // Consume files dropped via FileUpload wrapper
+ useEffect(() => {
+ if (droppedFiles && droppedFiles.length > 0) {
+ addFiles(droppedFiles);
+ onDropConsumed?.();
+ }
+ }, [droppedFiles]);
+
+ function handleInput(value: string) {
+ setContent(value);
+ if (value.length > 0) {
+ sendTyping();
+ }
+ }
+
+ async function handleSend() {
+ const text = content.trim();
+ if (!text && pendingFiles.length === 0) return;
+
+ setSending(true);
+ try {
+ // Upload pending files first
+ for (const file of pendingFiles) {
+ await api.upload(
+ `/api/v1/channels/${channelId}/attachments`,
+ file,
+ );
+ }
+
+ // Send message
+ if (text) {
+ await api.post(`/api/v1/channels/${channelId}/messages`, {
+ content: text,
+ });
+ }
+
+ setContent("");
+ setPendingFiles([]);
+ lastTypingRef.current = 0;
+ } catch {
+ // silent
+ } finally {
+ setSending(false);
+ }
+ }
+
+ function handleKeyDown(e: KeyboardEvent) {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ handleSend();
+ }
+ }
+
+ function handleFileSelect(e: React.ChangeEvent) {
+ const files = e.target.files;
+ if (files) {
+ addFiles(Array.from(files));
+ }
+ e.target.value = "";
+ }
+
+ function removePendingFile(index: number) {
+ setPendingFiles((prev) => prev.filter((_, i) => i !== index));
+ }
+
+ const placeholder = channelName
+ ? `Message #${channelName}`
+ : "Message this channel";
+
+ return (
+
+ {/* Pending files with image previews */}
+ {pendingFiles.length > 0 && (
+
+ {pendingFiles.map((file, i) => {
+ const isImage = file.type.startsWith("image/");
+ return (
+
+ {isImage && (
+
})
+ )}
+ {!isImage && (
+
{file.name}
+ )}
+
+
+ );
+ })}
+
+ )}
+
+
+
+
+
+
+
+ );
+}
diff --git a/retrocast-web/src/components/MessageList.tsx b/retrocast-web/src/components/MessageList.tsx
new file mode 100644
index 0000000..82bacdf
--- /dev/null
+++ b/retrocast-web/src/components/MessageList.tsx
@@ -0,0 +1,488 @@
+import { useEffect, useRef, useCallback, useState } from "react";
+import { useMessagesStore } from "@/stores/messages";
+import { useGuildsStore } from "@/stores/guilds";
+import { useMembersStore } from "@/stores/members";
+import { api } from "@/lib/api";
+import type { Message as MessageType, Attachment } from "@/types";
+import MarkdownContent from "@/components/MarkdownContent";
+import MessageContextMenu from "@/components/MessageContextMenu";
+import AvatarView from "@/components/AvatarView";
+import UserProfilePopover from "@/components/UserProfilePopover";
+
+function formatTimestamp(iso: string): string {
+ const date = new Date(iso);
+ const now = new Date();
+ const isToday = date.toDateString() === now.toDateString();
+ const yesterday = new Date(now);
+ yesterday.setDate(yesterday.getDate() - 1);
+ const isYesterday = date.toDateString() === yesterday.toDateString();
+
+ const time = date.toLocaleTimeString([], {
+ hour: "numeric",
+ minute: "2-digit",
+ });
+
+ if (isToday) return `Today at ${time}`;
+ if (isYesterday) return `Yesterday at ${time}`;
+ return `${date.toLocaleDateString()} ${time}`;
+}
+
+function formatDateSeparator(iso: string): string {
+ const date = new Date(iso);
+ const now = new Date();
+ const isToday = date.toDateString() === now.toDateString();
+ const yesterday = new Date(now);
+ yesterday.setDate(yesterday.getDate() - 1);
+ const isYesterday = date.toDateString() === yesterday.toDateString();
+
+ if (isToday) return "Today";
+ if (isYesterday) return "Yesterday";
+ return date.toLocaleDateString(undefined, {
+ weekday: "long",
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ });
+}
+
+function shouldGroup(prev: MessageType, curr: MessageType): boolean {
+ if (prev.author_id !== curr.author_id) return false;
+ const diff =
+ new Date(curr.created_at).getTime() -
+ new Date(prev.created_at).getTime();
+ return diff < 5 * 60 * 1000;
+}
+
+function isDifferentDay(a: string, b: string): boolean {
+ return new Date(a).toDateString() !== new Date(b).toDateString();
+}
+
+function AttachmentView({ attachment }: { attachment: Attachment }) {
+ const serverUrl = localStorage.getItem("serverUrl") || "";
+ const isImage = attachment.content_type.startsWith("image/");
+ const url = attachment.url.startsWith("http")
+ ? attachment.url
+ : `${serverUrl}${attachment.url}`;
+
+ if (isImage) {
+ return (
+
+
+
+ );
+ }
+
+ const sizeStr =
+ attachment.size < 1024
+ ? `${attachment.size} B`
+ : attachment.size < 1024 * 1024
+ ? `${(attachment.size / 1024).toFixed(1)} KB`
+ : `${(attachment.size / (1024 * 1024)).toFixed(1)} MB`;
+
+ return (
+
+
+
+
{attachment.filename}
+
{sizeStr}
+
+
+ );
+}
+
+function DateSeparator({ date }: { date: string }) {
+ return (
+
+
+
+ {formatDateSeparator(date)}
+
+
+
+ );
+}
+
+function MessageItem({
+ message,
+ grouped,
+}: {
+ message: MessageType;
+ grouped: boolean;
+}) {
+ const [contextMenu, setContextMenu] = useState<{
+ x: number;
+ y: number;
+ } | null>(null);
+ const [editing, setEditing] = useState(false);
+ const [editContent, setEditContent] = useState(message.content);
+ const [saving, setSaving] = useState(false);
+ const [profilePopover, setProfilePopover] = useState<{
+ x: number;
+ y: number;
+ } | null>(null);
+
+ const displayName =
+ message.author_display_name || message.author_username;
+ const selectedGuildId = useGuildsStore((s) => s.selectedGuildId);
+ const member = useMembersStore((s) => {
+ if (!selectedGuildId) return null;
+ const guildMembers = s.membersByGuild.get(selectedGuildId);
+ return guildMembers?.get(message.author_id) ?? null;
+ });
+
+ function handleNameClick(e: React.MouseEvent) {
+ if (!selectedGuildId || !member) return;
+ setProfilePopover({ x: e.clientX, y: e.clientY });
+ }
+
+ function handleContextMenu(e: React.MouseEvent) {
+ e.preventDefault();
+ setContextMenu({ x: e.clientX, y: e.clientY });
+ }
+
+ async function handleSaveEdit() {
+ const trimmed = editContent.trim();
+ if (!trimmed || trimmed === message.content) {
+ setEditing(false);
+ return;
+ }
+ setSaving(true);
+ try {
+ await api.patch(
+ `/api/v1/channels/${message.channel_id}/messages/${message.id}`,
+ { content: trimmed },
+ );
+ setEditing(false);
+ } catch {
+ // ignore
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ function handleEditKeyDown(e: React.KeyboardEvent) {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ handleSaveEdit();
+ }
+ if (e.key === "Escape") {
+ setEditing(false);
+ setEditContent(message.content);
+ }
+ }
+
+ const editArea = (
+
+ );
+
+ const contentArea = (
+ <>
+
+ {message.attachments?.map((att) => (
+
+ ))}
+ >
+ );
+
+ const ctxMenu = contextMenu && (
+ setContextMenu(null)}
+ onEdit={() => {
+ setEditing(true);
+ setEditContent(message.content);
+ }}
+ />
+ );
+
+ if (grouped) {
+ return (
+
+
+
+ {new Date(message.created_at).toLocaleTimeString([], {
+ hour: "numeric",
+ minute: "2-digit",
+ })}
+
+
+
+ {editing ? editArea : contentArea}
+
+ {ctxMenu}
+
+ );
+ }
+
+ const profileCard = profilePopover && member && selectedGuildId && (
+ setProfilePopover(null)}
+ />
+ );
+
+ return (
+
+
+
+
+
+
+ {formatTimestamp(message.created_at)}
+
+ {message.edited_at && (
+ (edited)
+ )}
+
+ {editing ? editArea : contentArea}
+
+ {ctxMenu}
+ {profileCard}
+
+ );
+}
+
+export default function MessageList({
+ channelId,
+}: {
+ channelId: string;
+}) {
+ const messages = useMessagesStore(
+ (s) => s.messagesByChannel.get(channelId) || [],
+ );
+ const hasMore = useMessagesStore(
+ (s) => s.hasMore.get(channelId) ?? true,
+ );
+ const setMessages = useMessagesStore((s) => s.setMessages);
+ const prependMessages = useMessagesStore((s) => s.prependMessages);
+ const fetchMessages = useMessagesStore((s) => s.fetchMessages);
+
+ const [loading, setLoading] = useState(false);
+ const [loadingMore, setLoadingMore] = useState(false);
+ const [showNewIndicator, setShowNewIndicator] = useState(false);
+ const bottomRef = useRef(null);
+ const listRef = useRef(null);
+ const prevScrollHeightRef = useRef(0);
+ const isAtBottomRef = useRef(true);
+ const prevMessageCountRef = useRef(0);
+
+ const updateIsAtBottom = useCallback(() => {
+ if (!listRef.current) return;
+ const el = listRef.current;
+ isAtBottomRef.current =
+ el.scrollHeight - el.scrollTop - el.clientHeight < 50;
+ }, []);
+
+ // Initial load
+ useEffect(() => {
+ setLoading(true);
+ prevMessageCountRef.current = 0;
+ setShowNewIndicator(false);
+ fetchMessages(channelId)
+ .then((msgs) => {
+ setMessages(channelId, [...msgs].reverse());
+ requestAnimationFrame(() => {
+ bottomRef.current?.scrollIntoView();
+ isAtBottomRef.current = true;
+ });
+ })
+ .catch(() => {})
+ .finally(() => setLoading(false));
+ }, [channelId, fetchMessages, setMessages]);
+
+ // Handle new messages
+ useEffect(() => {
+ if (messages.length <= prevMessageCountRef.current) {
+ prevMessageCountRef.current = messages.length;
+ return;
+ }
+ prevMessageCountRef.current = messages.length;
+
+ if (isAtBottomRef.current) {
+ requestAnimationFrame(() => {
+ bottomRef.current?.scrollIntoView({ behavior: "smooth" });
+ });
+ } else {
+ setShowNewIndicator(true);
+ }
+ }, [messages.length]);
+
+ function scrollToBottom() {
+ bottomRef.current?.scrollIntoView({ behavior: "smooth" });
+ setShowNewIndicator(false);
+ }
+
+ const loadOlder = useCallback(async () => {
+ if (loadingMore || !hasMore || messages.length === 0) return;
+ setLoadingMore(true);
+ const oldestId = messages[0]?.id;
+ if (!oldestId) {
+ setLoadingMore(false);
+ return;
+ }
+ prevScrollHeightRef.current = listRef.current?.scrollHeight || 0;
+ try {
+ const older = await fetchMessages(channelId, oldestId);
+ if (older.length > 0) {
+ prependMessages(channelId, [...older].reverse());
+ requestAnimationFrame(() => {
+ if (listRef.current) {
+ const newHeight = listRef.current.scrollHeight;
+ listRef.current.scrollTop =
+ newHeight - prevScrollHeightRef.current;
+ }
+ });
+ }
+ } catch {
+ // ignore
+ } finally {
+ setLoadingMore(false);
+ }
+ }, [
+ channelId,
+ loadingMore,
+ hasMore,
+ messages,
+ fetchMessages,
+ prependMessages,
+ ]);
+
+ const handleScroll = useCallback(() => {
+ updateIsAtBottom();
+ if (isAtBottomRef.current) {
+ setShowNewIndicator(false);
+ }
+ if (!listRef.current) return;
+ if (listRef.current.scrollTop < 100) {
+ loadOlder();
+ }
+ }, [loadOlder, updateIsAtBottom]);
+
+ if (loading) {
+ return (
+
+ Loading messages...
+
+ );
+ }
+
+ if (messages.length === 0) {
+ return (
+
+ No messages yet. Say something!
+
+ );
+ }
+
+ // Build elements with date separators and author grouping
+ const elements: React.ReactNode[] = [];
+ for (let i = 0; i < messages.length; i++) {
+ const msg = messages[i];
+ const prev = i > 0 ? messages[i - 1] : null;
+
+ if (!prev || isDifferentDay(prev.created_at, msg.created_at)) {
+ elements.push(
+ ,
+ );
+ }
+
+ const grouped = prev !== null && shouldGroup(prev, msg);
+ elements.push(
+ ,
+ );
+ }
+
+ return (
+
+
+ {loadingMore && (
+
+ Loading older messages...
+
+ )}
+ {!hasMore && messages.length > 0 && (
+
+ Beginning of conversation
+
+ )}
+ {elements}
+
+
+
+ {showNewIndicator && (
+
+ )}
+
+ );
+}
diff --git a/retrocast-web/src/components/PresenceDot.tsx b/retrocast-web/src/components/PresenceDot.tsx
new file mode 100644
index 0000000..de7d17a
--- /dev/null
+++ b/retrocast-web/src/components/PresenceDot.tsx
@@ -0,0 +1,36 @@
+import { usePresenceStore } from "@/stores/presence";
+
+const STATUS_COLORS: Record = {
+ online: "bg-[#23a559]",
+ idle: "bg-[#f0b232]",
+ dnd: "bg-[#f23f43]",
+ invisible: "bg-[#80848e]",
+ offline: "bg-[#80848e]",
+};
+
+interface PresenceDotProps {
+ userId: string;
+ size?: "sm" | "md" | "lg";
+ className?: string;
+}
+
+export default function PresenceDot({
+ userId,
+ size = "md",
+ className = "",
+}: PresenceDotProps) {
+ const status = usePresenceStore((s) => s.presences.get(userId) || "offline");
+
+ const sizeClasses = {
+ sm: "h-[10px] w-[10px]",
+ md: "h-3 w-3",
+ lg: "h-4 w-4",
+ };
+
+ return (
+
+ );
+}
diff --git a/retrocast-web/src/components/RoleEditor.tsx b/retrocast-web/src/components/RoleEditor.tsx
new file mode 100644
index 0000000..c8319a3
--- /dev/null
+++ b/retrocast-web/src/components/RoleEditor.tsx
@@ -0,0 +1,312 @@
+import { useState, useEffect } from "react";
+import { api } from "@/lib/api";
+import { useRolesStore } from "@/stores/roles";
+import type { Role } from "@/types";
+
+interface RoleEditorProps {
+ guildId: string;
+}
+
+const PERMISSION_BITS: { name: string; bit: number; category: string }[] = [
+ { name: "Administrator", bit: 1 << 31, category: "General" },
+ { name: "Manage Guild", bit: 1 << 7, category: "General" },
+ { name: "Manage Channels", bit: 1 << 3, category: "General" },
+ { name: "Manage Roles", bit: 1 << 4, category: "General" },
+ { name: "Create Invite", bit: 1 << 16, category: "General" },
+ { name: "Change Nickname", bit: 1 << 17, category: "General" },
+ { name: "Manage Nicknames", bit: 1 << 18, category: "General" },
+ { name: "Kick Members", bit: 1 << 5, category: "Moderation" },
+ { name: "Ban Members", bit: 1 << 6, category: "Moderation" },
+ { name: "View Channel", bit: 1 << 0, category: "Text" },
+ { name: "Send Messages", bit: 1 << 1, category: "Text" },
+ { name: "Manage Messages", bit: 1 << 2, category: "Text" },
+ { name: "Mention Everyone", bit: 1 << 13, category: "Text" },
+ { name: "Attach Files", bit: 1 << 14, category: "Text" },
+ { name: "Read Message History", bit: 1 << 15, category: "Text" },
+ { name: "Connect", bit: 1 << 8, category: "Voice" },
+ { name: "Speak", bit: 1 << 9, category: "Voice" },
+ { name: "Mute Members", bit: 1 << 10, category: "Voice" },
+ { name: "Deafen Members", bit: 1 << 11, category: "Voice" },
+ { name: "Move Members", bit: 1 << 12, category: "Voice" },
+];
+
+function intToHexColor(color: number): string {
+ return "#" + (color & 0xffffff).toString(16).padStart(6, "0");
+}
+
+function hexColorToInt(hex: string): number {
+ return parseInt(hex.replace("#", ""), 16);
+}
+
+function PermissionToggle({
+ name,
+ enabled,
+ onChange,
+}: {
+ name: string;
+ enabled: boolean;
+ onChange: (v: boolean) => void;
+}) {
+ return (
+
+ );
+}
+
+function RoleForm({
+ role,
+ guildId,
+ onCancel,
+}: {
+ role: Role | null;
+ guildId: string;
+ onCancel: () => void;
+}) {
+ const [name, setName] = useState(role?.name || "");
+ const [color, setColor] = useState(
+ role?.color ? intToHexColor(role.color) : "#99aab5",
+ );
+ const [permissions, setPermissions] = useState(
+ role ? Number(role.permissions) : 0,
+ );
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState("");
+ const setRole = useRolesStore((s) => s.setRole);
+
+ useEffect(() => {
+ setName(role?.name || "");
+ setColor(role?.color ? intToHexColor(role.color) : "#99aab5");
+ setPermissions(role ? Number(role.permissions) : 0);
+ setError("");
+ }, [role]);
+
+ function togglePermission(bit: number) {
+ setPermissions((prev) =>
+ (prev & bit) !== 0 ? prev & ~bit : prev | bit,
+ );
+ }
+
+ async function handleSave(e: React.FormEvent) {
+ e.preventDefault();
+ const trimmed = name.trim();
+ if (!trimmed) return;
+
+ setSaving(true);
+ setError("");
+ try {
+ const payload = {
+ name: trimmed,
+ color: hexColorToInt(color),
+ permissions: String(permissions),
+ };
+
+ if (role) {
+ const updated = await api.patch(
+ `/api/v1/guilds/${guildId}/roles/${role.id}`,
+ payload,
+ );
+ setRole(updated);
+ } else {
+ const created = await api.post(
+ `/api/v1/guilds/${guildId}/roles`,
+ payload,
+ );
+ setRole(created);
+ }
+ onCancel();
+ } catch {
+ setError(role ? "Failed to update role" : "Failed to create role");
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ async function handleDelete() {
+ if (!role || role.is_default) return;
+ if (!confirm("Delete this role? This cannot be undone.")) return;
+ try {
+ await api.delete(`/api/v1/guilds/${guildId}/roles/${role.id}`);
+ useRolesStore.getState().removeRole(guildId, role.id);
+ onCancel();
+ } catch {
+ setError("Failed to delete role");
+ }
+ }
+
+ const categories = [...new Set(PERMISSION_BITS.map((p) => p.category))];
+
+ return (
+
+ );
+}
+
+export default function RoleEditor({ guildId }: RoleEditorProps) {
+ const roles = useRolesStore((s) => s.rolesByGuild.get(guildId) || []);
+ const fetchRoles = useRolesStore((s) => s.fetchRoles);
+ const [selectedRole, setSelectedRole] = useState(null);
+ const [creating, setCreating] = useState(false);
+
+ useEffect(() => {
+ fetchRoles(guildId);
+ }, [guildId, fetchRoles]);
+
+ if (creating || selectedRole) {
+ return (
+
+
+
{
+ setSelectedRole(null);
+ setCreating(false);
+ }}
+ />
+
+ );
+ }
+
+ return (
+
+
+
+ Roles ({roles.length})
+
+
+
+
+ {roles.map((role) => (
+
+ ))}
+ {roles.length === 0 && (
+
+ No roles yet
+
+ )}
+
+
+ );
+}
diff --git a/retrocast-web/src/components/ServerList.tsx b/retrocast-web/src/components/ServerList.tsx
new file mode 100644
index 0000000..5e345ce
--- /dev/null
+++ b/retrocast-web/src/components/ServerList.tsx
@@ -0,0 +1,316 @@
+import { useState } from "react";
+import { useGuildsStore } from "@/stores/guilds";
+import { useChannelsStore } from "@/stores/channels";
+import { useDMsStore } from "@/stores/dms";
+import { api } from "@/lib/api";
+
+function GuildIcon({
+ guild,
+ isSelected,
+ onClick,
+}: {
+ guild: { id: string; name: string; icon_hash: string | null };
+ isSelected: boolean;
+ onClick: () => void;
+}) {
+ const initials = guild.name
+ .split(" ")
+ .map((w) => w[0])
+ .join("")
+ .slice(0, 2)
+ .toUpperCase();
+
+ const serverUrl = localStorage.getItem("serverUrl") || "";
+
+ return (
+
+ {/* Selection indicator */}
+
+
+
+ );
+}
+
+function CreateGuildModal({ onClose }: { onClose: () => void }) {
+ const [name, setName] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+ const createGuild = useGuildsStore((s) => s.createGuild);
+ const selectGuild = useGuildsStore((s) => s.selectGuild);
+
+ async function handleCreate(e: React.FormEvent) {
+ e.preventDefault();
+ if (!name.trim()) return;
+ setLoading(true);
+ setError("");
+ try {
+ const guild = await createGuild(name.trim());
+ selectGuild(guild.id);
+ onClose();
+ } catch {
+ setError("Failed to create server");
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+
+
e.stopPropagation()}
+ >
+
+ Create a Server
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+ );
+}
+
+function JoinGuildModal({ onClose }: { onClose: () => void }) {
+ const [code, setCode] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+ const selectGuild = useGuildsStore((s) => s.selectGuild);
+
+ async function handleJoin(e: React.FormEvent) {
+ e.preventDefault();
+ if (!code.trim()) return;
+ setLoading(true);
+ setError("");
+ try {
+ const result = await api.post<{ guild_id: string }>(
+ `/api/v1/invites/${code.trim()}`,
+ );
+ // Refresh guild list
+ await useGuildsStore.getState().fetchGuilds();
+ selectGuild(result.guild_id);
+ onClose();
+ } catch {
+ setError("Invalid or expired invite code");
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+
+
e.stopPropagation()}
+ >
+
+ Join a Server
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+ );
+}
+
+export default function ServerList() {
+ const guilds = useGuildsStore((s) => s.guilds);
+ const selectedGuildId = useGuildsStore((s) => s.selectedGuildId);
+ const selectGuild = useGuildsStore((s) => s.selectGuild);
+ const fetchChannels = useChannelsStore((s) => s.fetchChannels);
+ const selectChannel = useChannelsStore((s) => s.selectChannel);
+ const channelsByGuild = useChannelsStore((s) => s.channelsByGuild);
+ const showDMList = useDMsStore((s) => s.showDMList);
+ const setShowDMList = useDMsStore((s) => s.setShowDMList);
+ const [showCreate, setShowCreate] = useState(false);
+ const [showJoin, setShowJoin] = useState(false);
+
+ function handleDMClick() {
+ setShowDMList(true);
+ selectGuild(null);
+ selectChannel(null);
+ }
+
+ function handleSelectGuild(guildId: string) {
+ setShowDMList(false);
+ selectGuild(guildId);
+ // Fetch channels if not already loaded
+ if (!channelsByGuild.has(guildId)) {
+ fetchChannels(guildId).then(() => {
+ // Auto-select first text channel
+ const channels = useChannelsStore.getState().channelsByGuild.get(guildId);
+ const firstText = channels?.find((c) => c.type === 0);
+ if (firstText) {
+ selectChannel(firstText.id);
+ }
+ });
+ } else {
+ // Auto-select first text channel if none selected
+ const channels = channelsByGuild.get(guildId);
+ const firstText = channels?.find((c) => c.type === 0);
+ if (firstText) {
+ selectChannel(firstText.id);
+ }
+ }
+ }
+
+ const guildList = Array.from(guilds.values());
+
+ return (
+
+ {/* DM button */}
+
+
+ {/* Separator */}
+
+
+ {guildList.map((guild) => (
+
handleSelectGuild(guild.id)}
+ />
+ ))}
+
+ {/* Separator */}
+
+
+ {/* Create guild button */}
+
+
+ {/* Join guild button */}
+
+
+ {showCreate && setShowCreate(false)} />}
+ {showJoin && setShowJoin(false)} />}
+
+ );
+}
diff --git a/retrocast-web/src/components/TypingIndicator.tsx b/retrocast-web/src/components/TypingIndicator.tsx
new file mode 100644
index 0000000..bb0b7eb
--- /dev/null
+++ b/retrocast-web/src/components/TypingIndicator.tsx
@@ -0,0 +1,62 @@
+import { useTypingStore } from "@/stores/typing";
+import { useAuthStore } from "@/stores/auth";
+import { useMembersStore } from "@/stores/members";
+import { useGuildsStore } from "@/stores/guilds";
+
+export default function TypingIndicator({
+ channelId,
+}: {
+ channelId: string;
+}) {
+ const typingMap = useTypingStore(
+ (s) => s.typingByChannel.get(channelId),
+ );
+ const currentUserId = useAuthStore((s) => s.user?.id);
+ const selectedGuildId = useGuildsStore((s) => s.selectedGuildId);
+ const membersByGuild = useMembersStore((s) => s.membersByGuild);
+
+ if (!typingMap || typingMap.size === 0) {
+ return ;
+ }
+
+ // Filter out current user
+ const typingUserIds = Array.from(typingMap.keys()).filter(
+ (id) => id !== currentUserId,
+ );
+
+ if (typingUserIds.length === 0) {
+ return ;
+ }
+
+ // Resolve display names
+ const guildMembers = selectedGuildId
+ ? membersByGuild.get(selectedGuildId)
+ : null;
+
+ const names = typingUserIds.map((userId) => {
+ const member = guildMembers?.get(userId);
+ return member?.nickname || userId;
+ });
+
+ let text: string;
+ if (names.length === 1) {
+ text = `${names[0]} is typing...`;
+ } else if (names.length === 2) {
+ text = `${names[0]} and ${names[1]} are typing...`;
+ } else if (names.length === 3) {
+ text = `${names[0]}, ${names[1]}, and ${names[2]} are typing...`;
+ } else {
+ text = "Several people are typing...";
+ }
+
+ return (
+
+
+
+
+
+
+ {text}
+
+ );
+}
diff --git a/retrocast-web/src/components/UserProfilePopover.tsx b/retrocast-web/src/components/UserProfilePopover.tsx
new file mode 100644
index 0000000..72aaa0e
--- /dev/null
+++ b/retrocast-web/src/components/UserProfilePopover.tsx
@@ -0,0 +1,158 @@
+import { useEffect, useRef } from "react";
+import { usePresenceStore } from "@/stores/presence";
+import { useRolesStore } from "@/stores/roles";
+import AvatarView from "@/components/AvatarView";
+import type { Member } from "@/types";
+
+interface UserProfilePopoverProps {
+ member: Member;
+ displayName: string;
+ username: string;
+ avatarHash: string | null;
+ guildId: string;
+ x: number;
+ y: number;
+ onClose: () => void;
+}
+
+function intToHex(color: number): string {
+ if (color === 0) return "";
+ return `#${color.toString(16).padStart(6, "0")}`;
+}
+
+export default function UserProfilePopover({
+ member,
+ displayName,
+ username,
+ avatarHash,
+ guildId,
+ x,
+ y,
+ onClose,
+}: UserProfilePopoverProps) {
+ const ref = useRef(null);
+ const status = usePresenceStore(
+ (s) => s.presences.get(member.user_id) || "offline",
+ );
+ const roles = useRolesStore((s) => s.rolesByGuild.get(guildId) || []);
+
+ const memberRoles = roles.filter(
+ (r) => !r.is_default && member.roles.includes(r.id),
+ );
+
+ const joinedAt = new Date(member.joined_at).toLocaleDateString(undefined, {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ });
+
+ // Close on click outside or Escape
+ useEffect(() => {
+ function handleClick(e: MouseEvent) {
+ if (ref.current && !ref.current.contains(e.target as Node)) {
+ onClose();
+ }
+ }
+ function handleKey(e: KeyboardEvent) {
+ if (e.key === "Escape") onClose();
+ }
+ document.addEventListener("mousedown", handleClick);
+ document.addEventListener("keydown", handleKey);
+ return () => {
+ document.removeEventListener("mousedown", handleClick);
+ document.removeEventListener("keydown", handleKey);
+ };
+ }, [onClose]);
+
+ // Position: try to keep within viewport
+ const style: React.CSSProperties = {
+ left: Math.min(x, window.innerWidth - 320),
+ top: Math.min(y, window.innerHeight - 350),
+ };
+
+ return (
+
+ {/* Banner area */}
+
+
+ {/* Avatar overlapping banner */}
+
+
+
+ {/* Name */}
+
+
+ {displayName}
+
+
{username}
+
+
+ {/* Status */}
+
+
+ {status === "dnd" ? "Do Not Disturb" : status.charAt(0).toUpperCase() + status.slice(1)}
+
+
+
+
+ {/* Roles */}
+ {memberRoles.length > 0 && (
+
+
+ Roles
+
+
+ {memberRoles.map((role) => {
+ const hex = intToHex(role.color);
+ return (
+
+ {hex && (
+
+ )}
+ {role.name}
+
+ );
+ })}
+
+
+ )}
+
+ {/* Member since */}
+
+
+ Member Since
+
+
{joinedAt}
+
+
+
+ );
+}
diff --git a/retrocast-web/src/components/UserSettingsModal.tsx b/retrocast-web/src/components/UserSettingsModal.tsx
new file mode 100644
index 0000000..751033e
--- /dev/null
+++ b/retrocast-web/src/components/UserSettingsModal.tsx
@@ -0,0 +1,126 @@
+import { useState } from "react";
+import { api } from "@/lib/api";
+import { useAuthStore } from "@/stores/auth";
+import { gateway } from "@/lib/gateway";
+import type { User } from "@/types";
+
+interface UserSettingsModalProps {
+ onClose: () => void;
+}
+
+export default function UserSettingsModal({ onClose }: UserSettingsModalProps) {
+ const user = useAuthStore((s) => s.user);
+ const [displayName, setDisplayName] = useState(user?.display_name || "");
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState("");
+ const [success, setSuccess] = useState(false);
+
+ if (!user) return null;
+
+ async function handleSave(e: React.FormEvent) {
+ e.preventDefault();
+ const trimmed = displayName.trim();
+ if (!trimmed) return;
+
+ setSaving(true);
+ setError("");
+ setSuccess(false);
+ try {
+ const updated = await api.patch("/api/v1/users/@me", {
+ display_name: trimmed,
+ });
+ useAuthStore.setState({ user: updated });
+ setSuccess(true);
+ setTimeout(() => setSuccess(false), 2000);
+ } catch {
+ setError("Failed to update profile");
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ async function handleLogout() {
+ gateway.disconnect();
+ await useAuthStore.getState().logout();
+ window.location.href = "/server";
+ }
+
+ return (
+
+
e.stopPropagation()}
+ >
+
+ User Settings
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {success && (
+
+ Profile updated successfully
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/retrocast-web/src/index.css b/retrocast-web/src/index.css
new file mode 100644
index 0000000..47a4012
--- /dev/null
+++ b/retrocast-web/src/index.css
@@ -0,0 +1,30 @@
+@import "tailwindcss";
+
+@theme {
+ --color-bg-primary: #313338;
+ --color-bg-secondary: #2b2d31;
+ --color-bg-tertiary: #1e1f22;
+ --color-bg-input: #383a40;
+ --color-accent: #5865f2;
+ --color-accent-hover: #4752c4;
+ --color-text-primary: #dbdee1;
+ --color-text-secondary: #949ba4;
+ --color-text-muted: #6d6f78;
+ --color-text-interactive-hover: #dcddde;
+ --color-border: #3f4147;
+ --color-success: #23a559;
+ --color-danger: #f23f43;
+ --color-link: #00a8fc;
+}
+
+body {
+ margin: 0;
+ background-color: var(--color-bg-primary);
+ color: var(--color-text-primary);
+ font-family: "gg sans", "Noto Sans", "Helvetica Neue", Helvetica, Arial,
+ sans-serif;
+}
+
+#root {
+ min-height: 100vh;
+}
diff --git a/retrocast-web/src/layouts/AppLayout.tsx b/retrocast-web/src/layouts/AppLayout.tsx
new file mode 100644
index 0000000..ac8eb8e
--- /dev/null
+++ b/retrocast-web/src/layouts/AppLayout.tsx
@@ -0,0 +1,141 @@
+import { useEffect, useState, useSyncExternalStore } from "react";
+import { Navigate, Outlet } from "react-router";
+import { useAuthStore } from "@/stores/auth";
+import { useGuildsStore } from "@/stores/guilds";
+import { useChannelsStore } from "@/stores/channels";
+import { useDMsStore } from "@/stores/dms";
+import { gateway } from "@/lib/gateway";
+import { initGatewayDispatcher } from "@/lib/gateway-dispatcher";
+import ServerList from "@/components/ServerList";
+import ChannelSidebar from "@/components/ChannelSidebar";
+
+function useMediaQuery(query: string): boolean {
+ return useSyncExternalStore(
+ (cb) => {
+ const mql = window.matchMedia(query);
+ mql.addEventListener("change", cb);
+ return () => mql.removeEventListener("change", cb);
+ },
+ () => window.matchMedia(query).matches,
+ () => false,
+ );
+}
+
+export default function AppLayout() {
+ const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
+ const accessToken = useAuthStore((s) => s.accessToken);
+ const serverUrl = useAuthStore((s) => s.serverUrl);
+ const isMobile = !useMediaQuery("(min-width: 768px)");
+ const [sidebarOpen, setSidebarOpen] = useState(false);
+
+ useEffect(() => {
+ if (!isAuthenticated || !accessToken || !serverUrl) return;
+
+ initGatewayDispatcher();
+
+ // Connect gateway
+ const wsUrl = serverUrl.replace(/^http/, "ws") + "/gateway";
+ gateway.connect(wsUrl, accessToken);
+
+ // Fetch initial data
+ useGuildsStore
+ .getState()
+ .fetchGuilds()
+ .then(() => {
+ const { guilds, selectedGuildId, selectGuild } =
+ useGuildsStore.getState();
+ // Auto-select first guild if none selected
+ if (!selectedGuildId && guilds.size > 0) {
+ const firstGuild = guilds.values().next().value;
+ if (firstGuild) {
+ selectGuild(firstGuild.id);
+ // Fetch channels for the first guild
+ useChannelsStore
+ .getState()
+ .fetchChannels(firstGuild.id)
+ .then(() => {
+ const channels = useChannelsStore
+ .getState()
+ .channelsByGuild.get(firstGuild.id);
+ const firstText = channels?.find((c) => c.type === 0);
+ if (firstText) {
+ useChannelsStore.getState().selectChannel(firstText.id);
+ }
+ })
+ .catch(() => {});
+ }
+ }
+ })
+ .catch(() => {});
+
+ useDMsStore.getState().fetchDMs().catch(() => {});
+
+ return () => {
+ gateway.disconnect();
+ };
+ }, [isAuthenticated, accessToken, serverUrl]);
+
+ // Close sidebar when a channel/DM is selected (mobile)
+ useEffect(() => {
+ if (!isMobile) return;
+ let prevChannel = useChannelsStore.getState().selectedChannelId;
+ let prevDM = useDMsStore.getState().selectedDMId;
+ const unsubChannel = useChannelsStore.subscribe((s) => {
+ if (s.selectedChannelId !== prevChannel) {
+ prevChannel = s.selectedChannelId;
+ setSidebarOpen(false);
+ }
+ });
+ const unsubDM = useDMsStore.subscribe((s) => {
+ if (s.selectedDMId !== prevDM) {
+ prevDM = s.selectedDMId;
+ setSidebarOpen(false);
+ }
+ });
+ return () => {
+ unsubChannel();
+ unsubDM();
+ };
+ }, [isMobile]);
+
+ if (!isAuthenticated) {
+ return ;
+ }
+
+ return (
+
+ {/* Mobile hamburger button */}
+
+
+ {/* Sidebar overlay for mobile */}
+ {sidebarOpen && (
+
setSidebarOpen(false)}
+ />
+ )}
+
+ {/* Sidebars: always visible on md+, toggled on mobile */}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/retrocast-web/src/layouts/AuthLayout.tsx b/retrocast-web/src/layouts/AuthLayout.tsx
new file mode 100644
index 0000000..1acb3cc
--- /dev/null
+++ b/retrocast-web/src/layouts/AuthLayout.tsx
@@ -0,0 +1,11 @@
+import { Outlet } from "react-router";
+
+export default function AuthLayout() {
+ return (
+
+ );
+}
diff --git a/retrocast-web/src/lib/api.ts b/retrocast-web/src/lib/api.ts
new file mode 100644
index 0000000..f1e2b8c
--- /dev/null
+++ b/retrocast-web/src/lib/api.ts
@@ -0,0 +1,167 @@
+interface ApiError {
+ code: string;
+ message: string;
+}
+
+interface ApiErrorResponse {
+ error: ApiError;
+}
+
+export class ApiClientError extends Error {
+ code: string;
+ status: number;
+
+ constructor(code: string, message: string, status: number) {
+ super(message);
+ this.name = "ApiClientError";
+ this.code = code;
+ this.status = status;
+ }
+}
+
+type TokenProvider = () => string | null;
+type TokenRefresher = () => Promise
;
+
+class ApiClient {
+ private tokenProvider: TokenProvider = () => null;
+ private tokenRefresher: TokenRefresher | null = null;
+ private refreshPromise: Promise | null = null;
+
+ get baseUrl(): string {
+ return localStorage.getItem("serverUrl") || "";
+ }
+
+ setTokenProvider(provider: TokenProvider) {
+ this.tokenProvider = provider;
+ }
+
+ setTokenRefresher(refresher: TokenRefresher) {
+ this.tokenRefresher = refresher;
+ }
+
+ private async request(
+ method: string,
+ path: string,
+ body?: unknown,
+ retry = true,
+ ): Promise {
+ const url = `${this.baseUrl}${path}`;
+ const headers: Record = {
+ "Content-Type": "application/json",
+ };
+
+ const token = this.tokenProvider();
+ if (token) {
+ headers["Authorization"] = `Bearer ${token}`;
+ }
+
+ const res = await fetch(url, {
+ method,
+ headers,
+ body: body !== undefined ? JSON.stringify(body) : undefined,
+ });
+
+ if (res.status === 401 && retry && this.tokenRefresher) {
+ const newToken = await this.refreshToken();
+ if (newToken) {
+ headers["Authorization"] = `Bearer ${newToken}`;
+ const retryRes = await fetch(url, {
+ method,
+ headers,
+ body: body !== undefined ? JSON.stringify(body) : undefined,
+ });
+ return this.handleResponse(retryRes);
+ }
+ }
+
+ return this.handleResponse(res);
+ }
+
+ private async refreshToken(): Promise {
+ if (!this.tokenRefresher) return null;
+
+ if (this.refreshPromise) {
+ return this.refreshPromise;
+ }
+
+ this.refreshPromise = this.tokenRefresher();
+ try {
+ return await this.refreshPromise;
+ } catch {
+ return null;
+ } finally {
+ this.refreshPromise = null;
+ }
+ }
+
+ private async handleResponse(res: Response): Promise {
+ if (!res.ok) {
+ let errorData: ApiErrorResponse | undefined;
+ try {
+ errorData = await res.json();
+ } catch {
+ // Response body is not JSON
+ }
+ throw new ApiClientError(
+ errorData?.error?.code || "UNKNOWN_ERROR",
+ errorData?.error?.message || `Request failed with status ${res.status}`,
+ res.status,
+ );
+ }
+
+ const json = await res.json();
+ return json.data as T;
+ }
+
+ get(path: string): Promise {
+ return this.request("GET", path);
+ }
+
+ post(path: string, body?: unknown): Promise {
+ return this.request("POST", path, body);
+ }
+
+ put(path: string, body?: unknown): Promise {
+ return this.request("PUT", path, body);
+ }
+
+ patch(path: string, body?: unknown): Promise {
+ return this.request("PATCH", path, body);
+ }
+
+ delete(path: string): Promise {
+ return this.request("DELETE", path);
+ }
+
+ async upload(path: string, file: File): Promise {
+ const url = `${this.baseUrl}${path}`;
+ const headers: Record = {};
+ const token = this.tokenProvider();
+ if (token) {
+ headers["Authorization"] = `Bearer ${token}`;
+ }
+ const formData = new FormData();
+ formData.append("file", file);
+ const res = await fetch(url, {
+ method: "POST",
+ headers,
+ body: formData,
+ });
+ if (!res.ok) {
+ let errorData: ApiErrorResponse | undefined;
+ try {
+ errorData = await res.json();
+ } catch {
+ // not JSON
+ }
+ throw new ApiClientError(
+ errorData?.error?.code || "UNKNOWN_ERROR",
+ errorData?.error?.message || `Upload failed with status ${res.status}`,
+ res.status,
+ );
+ }
+ return (await res.json()) as T;
+ }
+}
+
+export const api = new ApiClient();
diff --git a/retrocast-web/src/lib/gateway-dispatcher.ts b/retrocast-web/src/lib/gateway-dispatcher.ts
new file mode 100644
index 0000000..462db7b
--- /dev/null
+++ b/retrocast-web/src/lib/gateway-dispatcher.ts
@@ -0,0 +1,173 @@
+import { gateway } from "@/lib/gateway";
+import { useGuildsStore } from "@/stores/guilds";
+import { useChannelsStore } from "@/stores/channels";
+import { useMembersStore } from "@/stores/members";
+import { useRolesStore } from "@/stores/roles";
+import { useMessagesStore } from "@/stores/messages";
+import { usePresenceStore } from "@/stores/presence";
+import { useTypingStore } from "@/stores/typing";
+import { useUsersStore } from "@/stores/users";
+import type { Guild, Channel, Member, Message, Role } from "@/types";
+
+interface ReadyPayload {
+ session_id: string;
+ user_id: string;
+ guilds: string[];
+}
+
+interface MessageDeletePayload {
+ id: string;
+ channel_id: string;
+}
+
+interface ChannelDeletePayload {
+ id: string;
+ guild_id: string;
+}
+
+interface GuildDeletePayload {
+ id: string;
+}
+
+interface MemberRemovePayload {
+ guild_id: string;
+ user_id: string;
+}
+
+interface RoleEventPayload {
+ guild_id: string;
+ role: Role;
+}
+
+interface RoleDeletePayload {
+ guild_id: string;
+ role_id: string;
+}
+
+interface TypingPayload {
+ channel_id: string;
+ guild_id: string;
+ user_id: string;
+}
+
+interface PresencePayload {
+ user_id: string;
+ status: "online" | "idle" | "dnd" | "invisible" | "offline";
+}
+
+let initialized = false;
+
+export function initGatewayDispatcher() {
+ if (initialized) return;
+ initialized = true;
+
+ gateway.on("READY", (_data) => {
+ void (_data as ReadyPayload);
+ // READY provides guild IDs — fetch full guild data
+ useGuildsStore.getState().fetchGuilds().catch(() => {});
+ });
+
+ // Guild events
+ gateway.on("GUILD_CREATE", (data) => {
+ const guild = data as Guild;
+ useGuildsStore.getState().setGuild(guild);
+ });
+
+ gateway.on("GUILD_UPDATE", (data) => {
+ const guild = data as Guild;
+ useGuildsStore.getState().setGuild(guild);
+ });
+
+ gateway.on("GUILD_DELETE", (data) => {
+ const payload = data as GuildDeletePayload;
+ useGuildsStore.getState().removeGuild(payload.id);
+ });
+
+ // Channel events
+ gateway.on("CHANNEL_CREATE", (data) => {
+ const channel = data as Channel;
+ useChannelsStore.getState().addChannel(channel);
+ });
+
+ gateway.on("CHANNEL_UPDATE", (data) => {
+ const channel = data as Channel;
+ useChannelsStore.getState().updateChannel(channel);
+ });
+
+ gateway.on("CHANNEL_DELETE", (data) => {
+ const payload = data as ChannelDeletePayload;
+ useChannelsStore.getState().removeChannel(payload.id, payload.guild_id);
+ });
+
+ // Message events
+ gateway.on("MESSAGE_CREATE", (data) => {
+ const message = data as Message;
+ useMessagesStore.getState().addMessage(message);
+ useUsersStore.getState().cacheFromMessage(message);
+ // Clear typing for the message author
+ useTypingStore.getState().clearTyping(message.channel_id, message.author_id);
+ });
+
+ gateway.on("MESSAGE_UPDATE", (data) => {
+ const message = data as Message;
+ useMessagesStore.getState().updateMessage(message);
+ useUsersStore.getState().cacheFromMessage(message);
+ });
+
+ gateway.on("MESSAGE_DELETE", (data) => {
+ const payload = data as MessageDeletePayload;
+ useMessagesStore.getState().removeMessage(payload.channel_id, payload.id);
+ });
+
+ // Member events
+ gateway.on("GUILD_MEMBER_ADD", (data) => {
+ const member = data as Member;
+ useMembersStore.getState().setMember(member);
+ });
+
+ gateway.on("GUILD_MEMBER_UPDATE", (data) => {
+ const member = data as Member;
+ useMembersStore.getState().setMember(member);
+ });
+
+ gateway.on("GUILD_MEMBER_REMOVE", (data) => {
+ const payload = data as MemberRemovePayload;
+ useMembersStore.getState().removeMember(payload.guild_id, payload.user_id);
+ });
+
+ // Role events
+ gateway.on("GUILD_ROLE_CREATE", (data) => {
+ const payload = data as RoleEventPayload;
+ useRolesStore.getState().setRole(payload.role);
+ });
+
+ gateway.on("GUILD_ROLE_UPDATE", (data) => {
+ const payload = data as RoleEventPayload;
+ useRolesStore.getState().setRole(payload.role);
+ });
+
+ gateway.on("GUILD_ROLE_DELETE", (data) => {
+ const payload = data as RoleDeletePayload;
+ useRolesStore.getState().removeRole(payload.guild_id, payload.role_id);
+ });
+
+ // Typing
+ gateway.on("TYPING_START", (data) => {
+ const payload = data as TypingPayload;
+ useTypingStore.getState().setTyping(payload.channel_id, payload.user_id);
+ });
+
+ // Presence
+ gateway.on("PRESENCE_UPDATE", (data) => {
+ const payload = data as PresencePayload;
+ usePresenceStore.getState().setPresence(payload.user_id, payload.status);
+ });
+
+ // Ban events — no client-side store needed yet, but could show toasts
+ gateway.on("GUILD_BAN_ADD", () => {});
+ gateway.on("GUILD_BAN_REMOVE", () => {});
+
+ // Reaction events — placeholder for future store
+ gateway.on("MESSAGE_REACTION_ADD", () => {});
+ gateway.on("MESSAGE_REACTION_REMOVE", () => {});
+}
diff --git a/retrocast-web/src/lib/gateway.ts b/retrocast-web/src/lib/gateway.ts
new file mode 100644
index 0000000..b3623a1
--- /dev/null
+++ b/retrocast-web/src/lib/gateway.ts
@@ -0,0 +1,212 @@
+import type { GatewayPayload } from "@/types";
+
+const OP_DISPATCH = 0;
+const OP_HEARTBEAT = 1;
+const OP_IDENTIFY = 2;
+const OP_PRESENCE_UPDATE = 3;
+const OP_RESUME = 6;
+const OP_RECONNECT = 7;
+const OP_HELLO = 10;
+const OP_HEARTBEAT_ACK = 11;
+
+const BACKOFF_BASE = 1000;
+const BACKOFF_MAX = 60000;
+const MAX_RETRIES = 10;
+const JITTER_FACTOR = 0.1;
+
+type EventCallback = (data: unknown) => void;
+
+interface HelloData {
+ heartbeat_interval: number;
+}
+
+interface ReadyData {
+ session_id: string;
+ user_id: string;
+ guilds: string[];
+}
+
+export class GatewayClient {
+ private ws: WebSocket | null = null;
+ private sessionId: string | null = null;
+ private sequence = 0;
+ private heartbeatInterval: number | null = null;
+ private heartbeatTimer: ReturnType | null = null;
+ private heartbeatAcked = true;
+ private listeners = new Map>();
+ private retryCount = 0;
+ private url: string | null = null;
+ private token: string | null = null;
+ private intentionalClose = false;
+
+ connect(url: string, token: string) {
+ this.url = url;
+ this.token = token;
+ this.intentionalClose = false;
+ this.retryCount = 0;
+ this.openSocket();
+ }
+
+ disconnect() {
+ this.intentionalClose = true;
+ this.cleanup();
+ }
+
+ updatePresence(status: "online" | "idle" | "dnd" | "invisible") {
+ this.send({ op: OP_PRESENCE_UPDATE, d: { status } });
+ }
+
+ on(event: string, callback: EventCallback) {
+ if (!this.listeners.has(event)) {
+ this.listeners.set(event, new Set());
+ }
+ this.listeners.get(event)!.add(callback);
+ }
+
+ off(event: string, callback: EventCallback) {
+ this.listeners.get(event)?.delete(callback);
+ }
+
+ private emit(event: string, data: unknown) {
+ this.listeners.get(event)?.forEach((cb) => cb(data));
+ }
+
+ private openSocket() {
+ if (!this.url) return;
+
+ this.ws = new WebSocket(this.url);
+
+ this.ws.onopen = () => {
+ this.retryCount = 0;
+ };
+
+ this.ws.onmessage = (event) => {
+ const payload: GatewayPayload = JSON.parse(event.data);
+ this.handlePayload(payload);
+ };
+
+ this.ws.onclose = () => {
+ this.stopHeartbeat();
+ if (!this.intentionalClose) {
+ this.attemptReconnect();
+ }
+ };
+
+ this.ws.onerror = () => {
+ // onclose will fire after onerror
+ };
+ }
+
+ private handlePayload(payload: GatewayPayload) {
+ switch (payload.op) {
+ case OP_HELLO: {
+ const data = payload.d as HelloData;
+ this.heartbeatInterval = data.heartbeat_interval;
+ this.startHeartbeat();
+
+ if (this.sessionId) {
+ this.send({
+ op: OP_RESUME,
+ d: {
+ token: this.token,
+ session_id: this.sessionId,
+ seq: this.sequence,
+ },
+ });
+ } else {
+ this.send({
+ op: OP_IDENTIFY,
+ d: { token: this.token },
+ });
+ }
+ break;
+ }
+
+ case OP_DISPATCH: {
+ if (payload.s !== undefined) {
+ this.sequence = payload.s;
+ }
+ if (payload.t === "READY") {
+ const data = payload.d as ReadyData;
+ this.sessionId = data.session_id;
+ }
+ if (payload.t) {
+ this.emit(payload.t, payload.d);
+ }
+ break;
+ }
+
+ case OP_HEARTBEAT_ACK:
+ this.heartbeatAcked = true;
+ break;
+
+ case OP_RECONNECT:
+ this.ws?.close();
+ break;
+
+ default:
+ break;
+ }
+ }
+
+ private send(payload: { op: number; d: unknown }) {
+ if (this.ws?.readyState === WebSocket.OPEN) {
+ this.ws.send(JSON.stringify(payload));
+ }
+ }
+
+ private startHeartbeat() {
+ this.stopHeartbeat();
+ if (!this.heartbeatInterval) return;
+
+ this.heartbeatAcked = true;
+ this.heartbeatTimer = setInterval(() => {
+ if (!this.heartbeatAcked) {
+ this.ws?.close();
+ return;
+ }
+ this.heartbeatAcked = false;
+ this.send({ op: OP_HEARTBEAT, d: this.sequence });
+ }, this.heartbeatInterval);
+ }
+
+ private stopHeartbeat() {
+ if (this.heartbeatTimer) {
+ clearInterval(this.heartbeatTimer);
+ this.heartbeatTimer = null;
+ }
+ }
+
+ private attemptReconnect() {
+ if (this.retryCount >= MAX_RETRIES) {
+ this.sessionId = null;
+ this.sequence = 0;
+ this.emit("GATEWAY_CLOSED", null);
+ return;
+ }
+
+ const delay =
+ Math.min(BACKOFF_BASE * 2 ** this.retryCount, BACKOFF_MAX) *
+ (1 + Math.random() * JITTER_FACTOR);
+ this.retryCount++;
+
+ setTimeout(() => {
+ if (!this.intentionalClose) {
+ this.openSocket();
+ }
+ }, delay);
+ }
+
+ private cleanup() {
+ this.stopHeartbeat();
+ if (this.ws) {
+ this.ws.onclose = null;
+ this.ws.close();
+ this.ws = null;
+ }
+ this.sessionId = null;
+ this.sequence = 0;
+ }
+}
+
+export const gateway = new GatewayClient();
diff --git a/retrocast-web/src/main.tsx b/retrocast-web/src/main.tsx
new file mode 100644
index 0000000..eff7ccc
--- /dev/null
+++ b/retrocast-web/src/main.tsx
@@ -0,0 +1,10 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import "./index.css";
+import App from "./App.tsx";
+
+createRoot(document.getElementById("root")!).render(
+
+
+ ,
+);
diff --git a/retrocast-web/src/pages/LoginPage.tsx b/retrocast-web/src/pages/LoginPage.tsx
new file mode 100644
index 0000000..d3ff9c2
--- /dev/null
+++ b/retrocast-web/src/pages/LoginPage.tsx
@@ -0,0 +1,85 @@
+import { useState, type FormEvent } from "react";
+import { Link, useNavigate } from "react-router";
+import { useAuthStore } from "@/stores/auth";
+import { ApiClientError } from "@/lib/api";
+
+export default function LoginPage() {
+ const navigate = useNavigate();
+ const login = useAuthStore((s) => s.login);
+ const [username, setUsername] = useState("");
+ const [password, setPassword] = useState("");
+ const [error, setError] = useState("");
+ const [loading, setLoading] = useState(false);
+
+ async function handleSubmit(e: FormEvent) {
+ e.preventDefault();
+ setError("");
+ setLoading(true);
+ try {
+ await login(username, password);
+ navigate("/app");
+ } catch (err) {
+ if (err instanceof ApiClientError) {
+ setError(err.message);
+ } else {
+ setError("Failed to connect to server");
+ }
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+
+ );
+}
diff --git a/retrocast-web/src/pages/RegisterPage.tsx b/retrocast-web/src/pages/RegisterPage.tsx
new file mode 100644
index 0000000..3420356
--- /dev/null
+++ b/retrocast-web/src/pages/RegisterPage.tsx
@@ -0,0 +1,85 @@
+import { useState, type FormEvent } from "react";
+import { Link, useNavigate } from "react-router";
+import { useAuthStore } from "@/stores/auth";
+import { ApiClientError } from "@/lib/api";
+
+export default function RegisterPage() {
+ const navigate = useNavigate();
+ const register = useAuthStore((s) => s.register);
+ const [username, setUsername] = useState("");
+ const [password, setPassword] = useState("");
+ const [error, setError] = useState("");
+ const [loading, setLoading] = useState(false);
+
+ async function handleSubmit(e: FormEvent) {
+ e.preventDefault();
+ setError("");
+ setLoading(true);
+ try {
+ await register(username, password);
+ navigate("/app");
+ } catch (err) {
+ if (err instanceof ApiClientError) {
+ setError(err.message);
+ } else {
+ setError("Failed to connect to server");
+ }
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+
+ );
+}
diff --git a/retrocast-web/src/pages/ServerAddressPage.tsx b/retrocast-web/src/pages/ServerAddressPage.tsx
new file mode 100644
index 0000000..e2f97c1
--- /dev/null
+++ b/retrocast-web/src/pages/ServerAddressPage.tsx
@@ -0,0 +1,46 @@
+import { useState, type FormEvent } from "react";
+import { useNavigate } from "react-router";
+import { useAuthStore } from "@/stores/auth";
+
+export default function ServerAddressPage() {
+ const navigate = useNavigate();
+ const setServerUrl = useAuthStore((s) => s.setServerUrl);
+ const savedUrl = useAuthStore((s) => s.serverUrl);
+ const [url, setUrl] = useState(savedUrl || "");
+
+ function handleSubmit(e: FormEvent) {
+ e.preventDefault();
+ const trimmed = url.trim().replace(/\/+$/, "");
+ if (!trimmed) return;
+ setServerUrl(trimmed);
+ navigate("/login");
+ }
+
+ return (
+
+ );
+}
diff --git a/retrocast-web/src/stores/auth.ts b/retrocast-web/src/stores/auth.ts
new file mode 100644
index 0000000..57b3858
--- /dev/null
+++ b/retrocast-web/src/stores/auth.ts
@@ -0,0 +1,95 @@
+import { create } from "zustand";
+import { api } from "@/lib/api";
+import type { User } from "@/types";
+
+interface AuthResponse {
+ access_token: string;
+ refresh_token: string;
+ user: User;
+}
+
+interface RefreshResponse {
+ access_token: string;
+}
+
+interface AuthState {
+ user: User | null;
+ accessToken: string | null;
+ refreshToken: string | null;
+ serverUrl: string | null;
+ isAuthenticated: boolean;
+ login: (username: string, password: string) => Promise;
+ register: (username: string, password: string) => Promise;
+ logout: () => Promise;
+ setServerUrl: (url: string) => void;
+ refreshAccessToken: () => Promise;
+}
+
+export const useAuthStore = create()((set, get) => {
+ // Wire up the API client token provider
+ api.setTokenProvider(() => get().accessToken);
+ api.setTokenRefresher(() => get().refreshAccessToken());
+
+ return {
+ user: null,
+ accessToken: null,
+ refreshToken: localStorage.getItem("refreshToken"),
+ serverUrl: localStorage.getItem("serverUrl"),
+ isAuthenticated: false,
+
+ login: async (username, password) => {
+ const data = await api.post("/api/v1/auth/login", {
+ username,
+ password,
+ });
+ localStorage.setItem("refreshToken", data.refresh_token);
+ set({
+ user: data.user,
+ accessToken: data.access_token,
+ refreshToken: data.refresh_token,
+ isAuthenticated: true,
+ });
+ },
+
+ register: async (username, password) => {
+ const data = await api.post("/api/v1/auth/register", {
+ username,
+ password,
+ });
+ localStorage.setItem("refreshToken", data.refresh_token);
+ set({
+ user: data.user,
+ accessToken: data.access_token,
+ refreshToken: data.refresh_token,
+ isAuthenticated: true,
+ });
+ },
+
+ logout: async () => {
+ localStorage.removeItem("refreshToken");
+ set({
+ user: null,
+ accessToken: null,
+ refreshToken: null,
+ isAuthenticated: false,
+ });
+ },
+
+ setServerUrl: (url) => {
+ localStorage.setItem("serverUrl", url);
+ set({ serverUrl: url });
+ },
+
+ refreshAccessToken: async () => {
+ const { refreshToken } = get();
+ if (!refreshToken) {
+ throw new Error("No refresh token");
+ }
+ const data = await api.post("/api/v1/auth/refresh", {
+ refresh_token: refreshToken,
+ });
+ set({ accessToken: data.access_token });
+ return data.access_token;
+ },
+ };
+});
diff --git a/retrocast-web/src/stores/channels.ts b/retrocast-web/src/stores/channels.ts
new file mode 100644
index 0000000..af0516b
--- /dev/null
+++ b/retrocast-web/src/stores/channels.ts
@@ -0,0 +1,88 @@
+import { create } from "zustand";
+import { api } from "@/lib/api";
+import type { Channel } from "@/types";
+
+interface ChannelsState {
+ channelsByGuild: Map;
+ selectedChannelId: string | null;
+ setChannels: (guildId: string, channels: Channel[]) => void;
+ addChannel: (channel: Channel) => void;
+ updateChannel: (channel: Channel) => void;
+ removeChannel: (channelId: string, guildId: string) => void;
+ selectChannel: (channelId: string | null) => void;
+ fetchChannels: (guildId: string) => Promise;
+}
+
+export const useChannelsStore = create()((set) => ({
+ channelsByGuild: new Map(),
+ selectedChannelId: null,
+
+ setChannels: (guildId, channels) => {
+ set((state) => {
+ const channelsByGuild = new Map(state.channelsByGuild);
+ channelsByGuild.set(
+ guildId,
+ [...channels].sort((a, b) => a.position - b.position),
+ );
+ return { channelsByGuild };
+ });
+ },
+
+ addChannel: (channel) => {
+ set((state) => {
+ const channelsByGuild = new Map(state.channelsByGuild);
+ const existing = channelsByGuild.get(channel.guild_id) || [];
+ channelsByGuild.set(
+ channel.guild_id,
+ [...existing, channel].sort((a, b) => a.position - b.position),
+ );
+ return { channelsByGuild };
+ });
+ },
+
+ updateChannel: (channel) => {
+ set((state) => {
+ const channelsByGuild = new Map(state.channelsByGuild);
+ const existing = channelsByGuild.get(channel.guild_id) || [];
+ channelsByGuild.set(
+ channel.guild_id,
+ existing
+ .map((c) => (c.id === channel.id ? channel : c))
+ .sort((a, b) => a.position - b.position),
+ );
+ return { channelsByGuild };
+ });
+ },
+
+ removeChannel: (channelId, guildId) => {
+ set((state) => {
+ const channelsByGuild = new Map(state.channelsByGuild);
+ const existing = channelsByGuild.get(guildId) || [];
+ channelsByGuild.set(
+ guildId,
+ existing.filter((c) => c.id !== channelId),
+ );
+ const selectedChannelId =
+ state.selectedChannelId === channelId ? null : state.selectedChannelId;
+ return { channelsByGuild, selectedChannelId };
+ });
+ },
+
+ selectChannel: (channelId) => {
+ set({ selectedChannelId: channelId });
+ },
+
+ fetchChannels: async (guildId) => {
+ const channels = await api.get(
+ `/api/v1/guilds/${guildId}/channels`,
+ );
+ set((state) => {
+ const channelsByGuild = new Map(state.channelsByGuild);
+ channelsByGuild.set(
+ guildId,
+ [...channels].sort((a, b) => a.position - b.position),
+ );
+ return { channelsByGuild };
+ });
+ },
+}));
diff --git a/retrocast-web/src/stores/dms.ts b/retrocast-web/src/stores/dms.ts
new file mode 100644
index 0000000..d468002
--- /dev/null
+++ b/retrocast-web/src/stores/dms.ts
@@ -0,0 +1,55 @@
+import { create } from "zustand";
+import { api } from "@/lib/api";
+import { useUsersStore } from "@/stores/users";
+import type { DMChannel } from "@/types";
+
+interface DMsState {
+ dms: Map;
+ showDMList: boolean;
+ selectedDMId: string | null;
+ fetchDMs: () => Promise;
+ addDM: (dm: DMChannel) => void;
+ selectDM: (dmId: string | null) => void;
+ toggleDMList: () => void;
+ setShowDMList: (show: boolean) => void;
+}
+
+export const useDMsStore = create()((set) => ({
+ dms: new Map(),
+ showDMList: false,
+ selectedDMId: null,
+
+ fetchDMs: async () => {
+ const dms = await api.get("/api/v1/users/@me/channels");
+ const map = new Map();
+ const usersStore = useUsersStore.getState();
+ for (const dm of dms) {
+ map.set(dm.id, dm);
+ // Cache DM recipients in user store
+ for (const recipient of dm.recipients) {
+ usersStore.cacheFromUser(recipient);
+ }
+ }
+ set({ dms: map });
+ },
+
+ addDM: (dm) => {
+ set((state) => {
+ const dms = new Map(state.dms);
+ dms.set(dm.id, dm);
+ return { dms };
+ });
+ },
+
+ selectDM: (dmId) => {
+ set({ selectedDMId: dmId });
+ },
+
+ toggleDMList: () => {
+ set((state) => ({ showDMList: !state.showDMList }));
+ },
+
+ setShowDMList: (show) => {
+ set({ showDMList: show });
+ },
+}));
diff --git a/retrocast-web/src/stores/guilds.ts b/retrocast-web/src/stores/guilds.ts
new file mode 100644
index 0000000..516880d
--- /dev/null
+++ b/retrocast-web/src/stores/guilds.ts
@@ -0,0 +1,55 @@
+import { create } from "zustand";
+import { api } from "@/lib/api";
+import type { Guild } from "@/types";
+
+interface GuildsState {
+ guilds: Map;
+ selectedGuildId: string | null;
+ setGuild: (guild: Guild) => void;
+ removeGuild: (guildId: string) => void;
+ selectGuild: (guildId: string | null) => void;
+ fetchGuilds: () => Promise;
+ createGuild: (name: string) => Promise;
+}
+
+export const useGuildsStore = create()((set, get) => ({
+ guilds: new Map(),
+ selectedGuildId: null,
+
+ setGuild: (guild) => {
+ set((state) => {
+ const guilds = new Map(state.guilds);
+ guilds.set(guild.id, guild);
+ return { guilds };
+ });
+ },
+
+ removeGuild: (guildId) => {
+ set((state) => {
+ const guilds = new Map(state.guilds);
+ guilds.delete(guildId);
+ const selectedGuildId =
+ state.selectedGuildId === guildId ? null : state.selectedGuildId;
+ return { guilds, selectedGuildId };
+ });
+ },
+
+ selectGuild: (guildId) => {
+ set({ selectedGuildId: guildId });
+ },
+
+ fetchGuilds: async () => {
+ const guilds = await api.get("/api/v1/users/@me/guilds");
+ const map = new Map();
+ for (const guild of guilds) {
+ map.set(guild.id, guild);
+ }
+ set({ guilds: map });
+ },
+
+ createGuild: async (name) => {
+ const guild = await api.post("/api/v1/guilds", { name });
+ get().setGuild(guild);
+ return guild;
+ },
+}));
diff --git a/retrocast-web/src/stores/members.ts b/retrocast-web/src/stores/members.ts
new file mode 100644
index 0000000..6575c21
--- /dev/null
+++ b/retrocast-web/src/stores/members.ts
@@ -0,0 +1,53 @@
+import { create } from "zustand";
+import { api } from "@/lib/api";
+import type { Member } from "@/types";
+
+interface MembersState {
+ membersByGuild: Map>;
+ setMember: (member: Member) => void;
+ removeMember: (guildId: string, userId: string) => void;
+ fetchMembers: (guildId: string) => Promise;
+}
+
+export const useMembersStore = create()((set) => ({
+ membersByGuild: new Map(),
+
+ setMember: (member) => {
+ set((state) => {
+ const membersByGuild = new Map(state.membersByGuild);
+ const guildMembers = new Map(
+ membersByGuild.get(member.guild_id) || new Map(),
+ );
+ guildMembers.set(member.user_id, member);
+ membersByGuild.set(member.guild_id, guildMembers);
+ return { membersByGuild };
+ });
+ },
+
+ removeMember: (guildId, userId) => {
+ set((state) => {
+ const membersByGuild = new Map(state.membersByGuild);
+ const guildMembers = new Map(
+ membersByGuild.get(guildId) || new Map(),
+ );
+ guildMembers.delete(userId);
+ membersByGuild.set(guildId, guildMembers);
+ return { membersByGuild };
+ });
+ },
+
+ fetchMembers: async (guildId) => {
+ const members = await api.get(
+ `/api/v1/guilds/${guildId}/members`,
+ );
+ set((state) => {
+ const membersByGuild = new Map(state.membersByGuild);
+ const guildMembers = new Map();
+ for (const member of members) {
+ guildMembers.set(member.user_id, member);
+ }
+ membersByGuild.set(guildId, guildMembers);
+ return { membersByGuild };
+ });
+ },
+}));
diff --git a/retrocast-web/src/stores/messages.ts b/retrocast-web/src/stores/messages.ts
new file mode 100644
index 0000000..b7d766f
--- /dev/null
+++ b/retrocast-web/src/stores/messages.ts
@@ -0,0 +1,93 @@
+import { create } from "zustand";
+import { api } from "@/lib/api";
+import { useUsersStore } from "@/stores/users";
+import type { Message } from "@/types";
+
+function cacheUsersFromMessages(messages: Message[]) {
+ const store = useUsersStore.getState();
+ for (const msg of messages) {
+ store.cacheFromMessage(msg);
+ }
+}
+
+const PAGE_SIZE = 50;
+
+interface MessagesState {
+ messagesByChannel: Map;
+ hasMore: Map;
+ addMessage: (message: Message) => void;
+ updateMessage: (message: Message) => void;
+ removeMessage: (channelId: string, messageId: string) => void;
+ setMessages: (channelId: string, messages: Message[]) => void;
+ prependMessages: (channelId: string, messages: Message[]) => void;
+ fetchMessages: (channelId: string, before?: string) => Promise;
+}
+
+export const useMessagesStore = create()((set) => ({
+ messagesByChannel: new Map(),
+ hasMore: new Map(),
+
+ addMessage: (message) => {
+ set((state) => {
+ const messagesByChannel = new Map(state.messagesByChannel);
+ const existing = messagesByChannel.get(message.channel_id) || [];
+ messagesByChannel.set(message.channel_id, [...existing, message]);
+ return { messagesByChannel };
+ });
+ },
+
+ updateMessage: (message) => {
+ set((state) => {
+ const messagesByChannel = new Map(state.messagesByChannel);
+ const existing = messagesByChannel.get(message.channel_id) || [];
+ messagesByChannel.set(
+ message.channel_id,
+ existing.map((m) => (m.id === message.id ? message : m)),
+ );
+ return { messagesByChannel };
+ });
+ },
+
+ removeMessage: (channelId, messageId) => {
+ set((state) => {
+ const messagesByChannel = new Map(state.messagesByChannel);
+ const existing = messagesByChannel.get(channelId) || [];
+ messagesByChannel.set(
+ channelId,
+ existing.filter((m) => m.id !== messageId),
+ );
+ return { messagesByChannel };
+ });
+ },
+
+ setMessages: (channelId, messages) => {
+ cacheUsersFromMessages(messages);
+ set((state) => {
+ const messagesByChannel = new Map(state.messagesByChannel);
+ messagesByChannel.set(channelId, messages);
+ const hasMore = new Map(state.hasMore);
+ hasMore.set(channelId, messages.length >= PAGE_SIZE);
+ return { messagesByChannel, hasMore };
+ });
+ },
+
+ prependMessages: (channelId, messages) => {
+ cacheUsersFromMessages(messages);
+ set((state) => {
+ const messagesByChannel = new Map(state.messagesByChannel);
+ const existing = messagesByChannel.get(channelId) || [];
+ messagesByChannel.set(channelId, [...messages, ...existing]);
+ const hasMore = new Map(state.hasMore);
+ hasMore.set(channelId, messages.length >= PAGE_SIZE);
+ return { messagesByChannel, hasMore };
+ });
+ },
+
+ fetchMessages: async (channelId, before?) => {
+ const params = before ? `?before=${before}` : "";
+ const messages = await api.get(
+ `/api/v1/channels/${channelId}/messages${params}`,
+ );
+ return messages;
+ },
+}));
diff --git a/retrocast-web/src/stores/presence.ts b/retrocast-web/src/stores/presence.ts
new file mode 100644
index 0000000..945e9d8
--- /dev/null
+++ b/retrocast-web/src/stores/presence.ts
@@ -0,0 +1,25 @@
+import { create } from "zustand";
+
+type PresenceStatus = "online" | "idle" | "dnd" | "invisible" | "offline";
+
+interface PresenceState {
+ presences: Map;
+ setPresence: (userId: string, status: PresenceStatus) => void;
+ clearPresences: () => void;
+}
+
+export const usePresenceStore = create()((set) => ({
+ presences: new Map(),
+
+ setPresence: (userId, status) => {
+ set((state) => {
+ const presences = new Map(state.presences);
+ presences.set(userId, status);
+ return { presences };
+ });
+ },
+
+ clearPresences: () => {
+ set({ presences: new Map() });
+ },
+}));
diff --git a/retrocast-web/src/stores/roles.ts b/retrocast-web/src/stores/roles.ts
new file mode 100644
index 0000000..e2bdcd6
--- /dev/null
+++ b/retrocast-web/src/stores/roles.ts
@@ -0,0 +1,67 @@
+import { create } from "zustand";
+import { api } from "@/lib/api";
+import type { Role } from "@/types";
+
+interface RolesState {
+ rolesByGuild: Map;
+ setRole: (role: Role) => void;
+ removeRole: (guildId: string, roleId: string) => void;
+ setRoles: (guildId: string, roles: Role[]) => void;
+ fetchRoles: (guildId: string) => Promise;
+}
+
+export const useRolesStore = create()((set) => ({
+ rolesByGuild: new Map(),
+
+ setRole: (role) => {
+ set((state) => {
+ const rolesByGuild = new Map(state.rolesByGuild);
+ const existing = rolesByGuild.get(role.guild_id) || [];
+ const idx = existing.findIndex((r) => r.id === role.id);
+ const updated =
+ idx >= 0
+ ? existing.map((r) => (r.id === role.id ? role : r))
+ : [...existing, role];
+ rolesByGuild.set(
+ role.guild_id,
+ updated.sort((a, b) => a.position - b.position),
+ );
+ return { rolesByGuild };
+ });
+ },
+
+ removeRole: (guildId, roleId) => {
+ set((state) => {
+ const rolesByGuild = new Map(state.rolesByGuild);
+ const existing = rolesByGuild.get(guildId) || [];
+ rolesByGuild.set(
+ guildId,
+ existing.filter((r) => r.id !== roleId),
+ );
+ return { rolesByGuild };
+ });
+ },
+
+ setRoles: (guildId, roles) => {
+ set((state) => {
+ const rolesByGuild = new Map(state.rolesByGuild);
+ rolesByGuild.set(
+ guildId,
+ [...roles].sort((a, b) => a.position - b.position),
+ );
+ return { rolesByGuild };
+ });
+ },
+
+ fetchRoles: async (guildId) => {
+ const roles = await api.get(`/api/v1/guilds/${guildId}/roles`);
+ set((state) => {
+ const rolesByGuild = new Map(state.rolesByGuild);
+ rolesByGuild.set(
+ guildId,
+ [...roles].sort((a, b) => a.position - b.position),
+ );
+ return { rolesByGuild };
+ });
+ },
+}));
diff --git a/retrocast-web/src/stores/typing.ts b/retrocast-web/src/stores/typing.ts
new file mode 100644
index 0000000..6613857
--- /dev/null
+++ b/retrocast-web/src/stores/typing.ts
@@ -0,0 +1,56 @@
+import { create } from "zustand";
+
+interface TypingEntry {
+ userId: string;
+ timestamp: number;
+}
+
+interface TypingState {
+ typingByChannel: Map>;
+ setTyping: (channelId: string, userId: string) => void;
+ clearTyping: (channelId: string, userId: string) => void;
+}
+
+const TYPING_TIMEOUT = 8000;
+
+export const useTypingStore = create()((set) => ({
+ typingByChannel: new Map(),
+
+ setTyping: (channelId, userId) => {
+ set((state) => {
+ const typingByChannel = new Map(state.typingByChannel);
+ const channelTyping = new Map(typingByChannel.get(channelId) || new Map());
+ channelTyping.set(userId, { userId, timestamp: Date.now() });
+ typingByChannel.set(channelId, channelTyping);
+ return { typingByChannel };
+ });
+
+ // Auto-clear after timeout
+ setTimeout(() => {
+ set((state) => {
+ const typingByChannel = new Map(state.typingByChannel);
+ const channelTyping = new Map(
+ typingByChannel.get(channelId) || new Map(),
+ );
+ const entry = channelTyping.get(userId);
+ if (entry && Date.now() - entry.timestamp >= TYPING_TIMEOUT) {
+ channelTyping.delete(userId);
+ typingByChannel.set(channelId, channelTyping);
+ }
+ return { typingByChannel };
+ });
+ }, TYPING_TIMEOUT);
+ },
+
+ clearTyping: (channelId, userId) => {
+ set((state) => {
+ const typingByChannel = new Map(state.typingByChannel);
+ const channelTyping = new Map(
+ typingByChannel.get(channelId) || new Map(),
+ );
+ channelTyping.delete(userId);
+ typingByChannel.set(channelId, channelTyping);
+ return { typingByChannel };
+ });
+ },
+}));
diff --git a/retrocast-web/src/stores/users.ts b/retrocast-web/src/stores/users.ts
new file mode 100644
index 0000000..e009b5e
--- /dev/null
+++ b/retrocast-web/src/stores/users.ts
@@ -0,0 +1,61 @@
+import { create } from "zustand";
+import type { User } from "@/types";
+
+interface UserCache {
+ username: string;
+ display_name: string;
+ avatar_hash: string | null;
+}
+
+interface UsersState {
+ users: Map;
+ cacheUser: (userId: string, data: UserCache) => void;
+ cacheFromMessage: (msg: {
+ author_id: string;
+ author_username: string;
+ author_display_name: string;
+ author_avatar_hash: string | null;
+ }) => void;
+ cacheFromUser: (user: User) => void;
+ getUser: (userId: string) => UserCache | undefined;
+}
+
+export const useUsersStore = create()((set, get) => ({
+ users: new Map(),
+
+ cacheUser: (userId, data) => {
+ set((state) => {
+ const users = new Map(state.users);
+ users.set(userId, data);
+ return { users };
+ });
+ },
+
+ cacheFromMessage: (msg) => {
+ set((state) => {
+ const users = new Map(state.users);
+ users.set(msg.author_id, {
+ username: msg.author_username,
+ display_name: msg.author_display_name,
+ avatar_hash: msg.author_avatar_hash,
+ });
+ return { users };
+ });
+ },
+
+ cacheFromUser: (user) => {
+ set((state) => {
+ const users = new Map(state.users);
+ users.set(user.id, {
+ username: user.username,
+ display_name: user.display_name,
+ avatar_hash: user.avatar_hash,
+ });
+ return { users };
+ });
+ },
+
+ getUser: (userId) => {
+ return get().users.get(userId);
+ },
+}));
diff --git a/retrocast-web/src/types/index.ts b/retrocast-web/src/types/index.ts
new file mode 100644
index 0000000..d92bfd2
--- /dev/null
+++ b/retrocast-web/src/types/index.ts
@@ -0,0 +1,90 @@
+export interface User {
+ id: string;
+ username: string;
+ display_name: string;
+ avatar_hash: string | null;
+ created_at: string;
+}
+
+export interface Guild {
+ id: string;
+ name: string;
+ icon_hash: string | null;
+ owner_id: string;
+ created_at: string;
+}
+
+export interface Channel {
+ id: string;
+ guild_id: string;
+ name: string;
+ type: number;
+ position: number;
+ topic: string | null;
+ parent_id: string | null;
+}
+
+export interface Message {
+ id: string;
+ channel_id: string;
+ author_id: string;
+ content: string;
+ created_at: string;
+ edited_at: string | null;
+ author_username: string;
+ author_display_name: string;
+ author_avatar_hash: string | null;
+ attachments: Attachment[];
+}
+
+export interface Attachment {
+ id: string;
+ message_id: string;
+ filename: string;
+ content_type: string;
+ size: number;
+ url: string;
+}
+
+export interface Role {
+ id: string;
+ guild_id: string;
+ name: string;
+ color: number;
+ permissions: string;
+ position: number;
+ is_default: boolean;
+}
+
+export interface Member {
+ guild_id: string;
+ user_id: string;
+ nickname: string | null;
+ joined_at: string;
+ roles: string[];
+}
+
+export interface DMChannel {
+ id: string;
+ type: number;
+ recipients: User[];
+ created_at: string;
+}
+
+export interface Invite {
+ code: string;
+ guild_id: string;
+ channel_id: string | null;
+ creator_id: string;
+ max_uses: number;
+ uses: number;
+ expires_at: string | null;
+ created_at: string;
+}
+
+export interface GatewayPayload {
+ op: number;
+ d: unknown;
+ s?: number;
+ t?: string;
+}
diff --git a/retrocast-web/tsconfig.app.json b/retrocast-web/tsconfig.app.json
new file mode 100644
index 0000000..0c521d5
--- /dev/null
+++ b/retrocast-web/tsconfig.app.json
@@ -0,0 +1,34 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "types": ["vite/client"],
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Path aliases */
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["src/*"]
+ },
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": ["src"]
+}
diff --git a/retrocast-web/tsconfig.json b/retrocast-web/tsconfig.json
new file mode 100644
index 0000000..1ffef60
--- /dev/null
+++ b/retrocast-web/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ]
+}
diff --git a/retrocast-web/tsconfig.node.json b/retrocast-web/tsconfig.node.json
new file mode 100644
index 0000000..8a67f62
--- /dev/null
+++ b/retrocast-web/tsconfig.node.json
@@ -0,0 +1,26 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "ES2023",
+ "lib": ["ES2023"],
+ "module": "ESNext",
+ "types": ["node"],
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/retrocast-web/vite.config.ts b/retrocast-web/vite.config.ts
new file mode 100644
index 0000000..56d93ce
--- /dev/null
+++ b/retrocast-web/vite.config.ts
@@ -0,0 +1,25 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+import tailwindcss from "@tailwindcss/vite";
+import path from "path";
+
+export default defineConfig({
+ plugins: [react(), tailwindcss()],
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "./src"),
+ },
+ },
+ server: {
+ proxy: {
+ "/api": {
+ target: "http://localhost:8080",
+ changeOrigin: true,
+ },
+ "/gateway": {
+ target: "ws://localhost:8080",
+ ws: true,
+ },
+ },
+ },
+});