diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..1a965bf --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,7 @@ +# Copy to .env.local and adjust to match deploy/.env (backend). Not versioned. +VITE_API_BASE_URL=http://localhost:5211 +# Must match the backend's PUBLIC_AUTH_URL exactly — it's baked into every token's issuer claim. +VITE_AUTH_AUTHORITY=http://host.docker.internal:8080 +VITE_AUTH_CLIENT_ID= +VITE_AUTH_REDIRECT_URI=http://localhost:3100/auth/callback +VITE_LIVEKIT_URL=ws://localhost:7880 diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.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/frontend/.oxlintrc.json b/frontend/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/frontend/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..37b1ec6 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,28 @@ +# Build context: frontend/ (docker compose -f deploy/compose.yml --profile apps build) +FROM node:24-alpine AS build +WORKDIR /src + +COPY package.json package-lock.json ./ +RUN npm ci + +COPY . . + +# Vite inlines VITE_* variables at build time, so they have to be build args, not +# runtime environment variables — see deploy/compose.yml's web service. +ARG VITE_API_BASE_URL +ARG VITE_AUTH_AUTHORITY +ARG VITE_AUTH_CLIENT_ID +ARG VITE_AUTH_REDIRECT_URI +ARG VITE_LIVEKIT_URL +ENV VITE_API_BASE_URL=$VITE_API_BASE_URL \ + VITE_AUTH_AUTHORITY=$VITE_AUTH_AUTHORITY \ + VITE_AUTH_CLIENT_ID=$VITE_AUTH_CLIENT_ID \ + VITE_AUTH_REDIRECT_URI=$VITE_AUTH_REDIRECT_URI \ + VITE_LIVEKIT_URL=$VITE_LIVEKIT_URL + +RUN npm run build + +FROM nginx:alpine AS runtime +COPY --from=build /src/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 3100 diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..d6af7e3 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,32 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## 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 Oxlint configuration + +If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`: + +```json +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "options": { + "typeAware": true + }, + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} +``` + +See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories. diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..c2051e3 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Ping + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..bf44364 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,10 @@ +server { + listen 3100; + root /usr/share/nginx/html; + index index.html; + + # SPA fallback: client-side routing (react-router) owns everything under /. + location / { + try_files $uri /index.html; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..98b8a5a --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1879 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "@livekit/components-react": "^2.9.24", + "@livekit/components-styles": "^1.2.0", + "@microsoft/signalr": "^10.0.11", + "livekit-client": "^2.22.3", + "oidc-client-ts": "^3.5.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-oidc-context": "^3.3.1", + "react-router-dom": "^7.18.4", + "zustand": "^5.0.15" + }, + "devDependencies": { + "@types/node": "^24.13.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.7", + "@vitejs/plugin-react": "^6.1.1", + "oxlint": "^1.81.0", + "typescript": "~6.0.2", + "vite": "^8.3.0" + } + }, + "node_modules/@bufbuild/protobuf": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.1.tgz", + "integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, + "node_modules/@livekit/components-core": { + "version": "0.12.15", + "resolved": "https://registry.npmjs.org/@livekit/components-core/-/components-core-0.12.15.tgz", + "integrity": "sha512-bDkK+jkPMgyT4a5lZmdcuc/hZ8fZUrXW+qQPNHVeSpXCBMzk5q9QWzx+HPtTsPJ8hnncWGfoT81RnXHBsy+Z8g==", + "license": "Apache-2.0", + "dependencies": { + "@floating-ui/dom": "1.7.6", + "loglevel": "1.9.1", + "rxjs": "7.8.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "livekit-client": "^2.20.1", + "tslib": "^2.6.2" + } + }, + "node_modules/@livekit/components-core/node_modules/loglevel": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.1.tgz", + "integrity": "sha512-hP3I3kCrDIMuRwAwHltphhDM1r8i55H33GgqjXbrisuJhF4kRhW1dNuxsRklp4bXl8DSdLaNLuiL4A/LWRfxvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/loglevel" + } + }, + "node_modules/@livekit/components-react": { + "version": "2.9.24", + "resolved": "https://registry.npmjs.org/@livekit/components-react/-/components-react-2.9.24.tgz", + "integrity": "sha512-qw5Oy1EfPg1f/xrKYrFielSrmtxY8BZ9mDt6299G5aikotQIztmldBfTNosrVS7VSNit7qeYfOFSyKaML20W6Q==", + "license": "Apache-2.0", + "dependencies": { + "@livekit/components-core": "0.12.15", + "clsx": "2.1.1", + "events": "^3.3.0", + "jose": "^6.0.12", + "usehooks-ts": "3.1.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@livekit/krisp-noise-filter": "^0.2.12 || ^0.3.0 || ^0.4.0", + "livekit-client": "^2.20.1", + "react": ">=18", + "react-dom": ">=18", + "tslib": "^2.6.2" + }, + "peerDependenciesMeta": { + "@livekit/krisp-noise-filter": { + "optional": true + } + } + }, + "node_modules/@livekit/components-styles": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@livekit/components-styles/-/components-styles-1.2.0.tgz", + "integrity": "sha512-74/rt0lDh6aHmOPmWAeDE9C4OrNW9RIdmhX/YRbovQBVNGNVWojRjl3FgQZ5LPFXO6l1maKB4JhXcBFENVxVvw==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@livekit/mutex": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@livekit/mutex/-/mutex-1.1.1.tgz", + "integrity": "sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==", + "license": "Apache-2.0" + }, + "node_modules/@livekit/protocol": { + "version": "1.50.4", + "resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.50.4.tgz", + "integrity": "sha512-L1uggNQAqyY21smQY8AllyOYbcv9Me9TaxwuLytL1R8ck9nbYPmQLNwEDi3pOFGAMa5F8I2nUi2Jc59W5awxlA==", + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "^1.10.0" + } + }, + "node_modules/@microsoft/signalr": { + "version": "10.0.11", + "resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-10.0.11.tgz", + "integrity": "sha512-FulOJ2EEtKvLQcswe/U7v8pzyXyk7Jua5xgWJPPwyQU/2Z9ORvKcVjyO75VvLsem+CJ1ORRexJ+7Bz1FCei2aw==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "eventsource": "^2.0.2", + "fetch-cookie": "^2.0.3", + "node-fetch": "^2.6.7", + "ws": "^7.5.10" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.149.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz", + "integrity": "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.83.0.tgz", + "integrity": "sha512-0yGY24EwsLk5YDe6F+VkmZyRHSwJDALa3nIrPpq7FXmp2lV2d0TzvBCGeZk+wgiULRGr5blhyr4QMp5KCXJUqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.83.0.tgz", + "integrity": "sha512-hHfJ0vc17A4iUjH5p9BsTUPYbYRNxGpvD2lbu1aBRk54bzNIx9o5TtYF39QPZcV95DagZd+4DEAw2RH3G2ZsMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.83.0.tgz", + "integrity": "sha512-hsOjYjszLb/3zym/TkzUMPAoQlTJcuzSyEPOAyA+skXJIX9M0o+4JfOtqopX/Vf4hSLrJ98j0nvFo23gzk8auQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.83.0.tgz", + "integrity": "sha512-mjh5oH2EA+wl5yRJYT9K9G61O2zFlpuv+yf2JwZOi0+dq2FnTUtm1h8i+5Ik0fXPWIu/k84I1psZR9aQsLAnyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.83.0.tgz", + "integrity": "sha512-fNHr64/YaO8YssuoDVC8+F4Uk5enR86q5uxfHkQrjAPs1dbAILOrD2uaud+J7MO8Fx774g44ERLD0IGIvZE48w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.83.0.tgz", + "integrity": "sha512-Qpwy3zzAwMj+8/lyYItHmkSMwbkprFNWTK7jPYDOxSyxEhaSLOWYUTCMkjF334J8/WD0nznCCsoBbIH6hpsuIw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.83.0.tgz", + "integrity": "sha512-s+BirYLFq7JL2k9sP0XI3ZXJ9dYvJ8sX3jLCLoag7tt+zrSHpZxP0jqznfL+Gdgwu7ay0dYgGYJXrQvq3iWloA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.83.0.tgz", + "integrity": "sha512-7lihXt3vKr+GIyapNbHrnFHm/biiW30le6Zv/DExbAFPF6YwCQXVFlONPFehxs0CpGO4CBfYPM9rdDT+XMoIlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.83.0.tgz", + "integrity": "sha512-q63JalLYVkZiZvls1z3PPUnpmQluOMXp0khqQMznCeAPLGydfNY8JhvuA4WlK57JfrvikU8wB5lPVveqpIXvew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.83.0.tgz", + "integrity": "sha512-krQmDF+dRbxvdqVPV88ZuOoPPu8X5BuqDA8Hd+qcS4YMRQCb+nexA57DazgGsc/rGdKBe3QmV0mnv0bdpW/p5g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.83.0.tgz", + "integrity": "sha512-MmOl8Y6txEAXZU1RG8Rr264jQ6D7VPmqFsU/45x/FeWsGe32hklTqGrLE6UxHzp5Rjt0wP+20tY8YXKgSFB3mw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.83.0.tgz", + "integrity": "sha512-u1rMymh0W3JZkq370kzQsYPULGWqhE09pZRqnZvUSoYaI9pVO5yVX+iYIslmWuEgwuzH9YAaOsScJiobWCHoOw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.83.0.tgz", + "integrity": "sha512-y0zK3HNwGysu7rqtE+BQG/d0bx5gh/KwlOtghN8oWeK1KcWzeaLqtZrbm8owqdma1lFyrce/hTO5ismuNu+INQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.83.0.tgz", + "integrity": "sha512-rS5gM0NgD7ngmuJmbIehsidtrOwKkLFwCQbKEeb9KuyQrrWNq5Zkn0uV6AYdXOMJ0grrWEiLwBuvMxt8w5vsNw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.83.0.tgz", + "integrity": "sha512-W2IH4EtpcPaWcvNGCA95YoDg4vxqE/ZiPCi3arrxEEpsK7+JQN9WYwrlYFx9pcdP6KPXqRqkv3zdQPHcx7b6YQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.83.0.tgz", + "integrity": "sha512-6LyKkUyoajssTPLlZmDbZIbu4IZ5B4bGuRUnBgCGpEvHP3FQMaYITncHA/unPUo7q+Z+pIu2HhdkQ+8d1SG7iA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.83.0.tgz", + "integrity": "sha512-Uz/fObEtF0jmNJQJ8CGRBKfefYstS0/wjD3s6IGzP8nUwsJykHQJBiN3npHwKiGRGn/vvBEgNr4B3cCzmmatvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.83.0.tgz", + "integrity": "sha512-u7XcvPW6Bk58tY5iWs2ESb0vJjoE/kuSpHxopbwp/p3ZtWVQXZ6wor5w3ssVTHOqd/v8b+QdhSFWQ4grEUNWpA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.83.0.tgz", + "integrity": "sha512-LZRubd7ph13QmAg4fFecTYVZkiYbROR2Htaxh/ufWRkDhPOm2wrwaEYR89e0YpPFD3dqBrPoxS7myBw5hmYA7Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz", + "integrity": "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.8.tgz", + "integrity": "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.8.tgz", + "integrity": "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.8.tgz", + "integrity": "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.8.tgz", + "integrity": "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.8.tgz", + "integrity": "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.8.tgz", + "integrity": "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.8.tgz", + "integrity": "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.8.tgz", + "integrity": "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.8.tgz", + "integrity": "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.8.tgz", + "integrity": "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.8.tgz", + "integrity": "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.8.tgz", + "integrity": "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.8.tgz", + "integrity": "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.8.tgz", + "integrity": "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/dom-mediacapture-record": { + "version": "1.0.22", + "resolved": "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.22.tgz", + "integrity": "sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/node": { + "version": "24.13.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.5.tgz", + "integrity": "sha512-TXyindR+lBr22aJIdMQzCFHPHR6cR4js838mRDCSz5hOKWZvZwsXSSiXDmjRj4iJmgl+sR9O+1mkoVBSMadNug==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fetch-cookie": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz", + "integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==", + "license": "Unlicense", + "dependencies": { + "set-cookie-parser": "^2.4.8", + "tough-cookie": "^4.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/livekit-client": { + "version": "2.22.3", + "resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.22.3.tgz", + "integrity": "sha512-jw9zBKXY5Gtr5MZ7vEON3QhMNccuDvYHck1PFSyG1aaateQPqgKZFBMgZkFZaXHIf9RV4MDW5xpTK2b/+qbwOg==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@livekit/mutex": "1.1.1", + "@livekit/protocol": "1.50.4", + "events": "^3.3.0", + "jose": "^6.1.0", + "loglevel": "^1.9.2", + "machina": "^7.0.0", + "sdp-transform": "^2.15.0", + "tslib": "2.8.1", + "typed-emitter": "^2.1.0", + "webrtc-adapter": "9.0.6" + }, + "peerDependencies": { + "@types/dom-mediacapture-record": "^1" + } + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "license": "MIT" + }, + "node_modules/loglevel": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", + "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/loglevel" + } + }, + "node_modules/machina": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/machina/-/machina-7.0.1.tgz", + "integrity": "sha512-0Xkgs8NeBvO2hW6bjJT8ewLWrMfh1haMPstaghpASWjXkLjRhtDiaJG1Kb7mslaOHxZYxpH7PIjpmirRVYbMMA==", + "license": "MIT", + "engines": { + "node": ">=22.22" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/oidc-client-ts": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.5.0.tgz", + "integrity": "sha512-l2q8l9CTCTOlbX+AnK4p3M+4CEpKpyQhle6blQkdFhm0IsBqsxm15bYaSa11G7pWdsYr6epdsRZxJpCyCRbT8A==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "jwt-decode": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/oxlint": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.83.0.tgz", + "integrity": "sha512-cyDzSzaw3uzP0TeCeq3lLRPPoaUxkbB4ZOXj+kn+5r+BX9V+4bNVGk9lxer+WrgcpebH4JxLlJ3KQjveVztOLQ==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.83.0", + "@oxlint/binding-android-arm64": "1.83.0", + "@oxlint/binding-darwin-arm64": "1.83.0", + "@oxlint/binding-darwin-x64": "1.83.0", + "@oxlint/binding-freebsd-x64": "1.83.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.83.0", + "@oxlint/binding-linux-arm-musleabihf": "1.83.0", + "@oxlint/binding-linux-arm64-gnu": "1.83.0", + "@oxlint/binding-linux-arm64-musl": "1.83.0", + "@oxlint/binding-linux-ppc64-gnu": "1.83.0", + "@oxlint/binding-linux-riscv64-gnu": "1.83.0", + "@oxlint/binding-linux-riscv64-musl": "1.83.0", + "@oxlint/binding-linux-s390x-gnu": "1.83.0", + "@oxlint/binding-linux-x64-gnu": "1.83.0", + "@oxlint/binding-linux-x64-musl": "1.83.0", + "@oxlint/binding-openharmony-arm64": "1.83.0", + "@oxlint/binding-win32-arm64-msvc": "1.83.0", + "@oxlint/binding-win32-ia32-msvc": "1.83.0", + "@oxlint/binding-win32-x64-msvc": "1.83.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2001", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/react-oidc-context": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/react-oidc-context/-/react-oidc-context-3.3.1.tgz", + "integrity": "sha512-/Azvm9W4DhhOtSDBE73kFInh1b6zZRRfILKbgmk2syExMF0PCYJOn/dGdOOi2BFX8x0rCeUe45NXHU+/+xDcrQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "oidc-client-ts": "^3.1.0", + "react": ">=16.14.0" + } + }, + "node_modules/react-router": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz", + "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz", + "integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.4" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, + "node_modules/rolldown": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz", + "integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.149.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.8", + "@rolldown/binding-android-arm64": "1.2.8", + "@rolldown/binding-darwin-arm64": "1.2.8", + "@rolldown/binding-darwin-x64": "1.2.8", + "@rolldown/binding-freebsd-x64": "1.2.8", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.8", + "@rolldown/binding-linux-arm64-gnu": "1.2.8", + "@rolldown/binding-linux-arm64-musl": "1.2.8", + "@rolldown/binding-linux-ppc64-gnu": "1.2.8", + "@rolldown/binding-linux-s390x-gnu": "1.2.8", + "@rolldown/binding-linux-x64-gnu": "1.2.8", + "@rolldown/binding-linux-x64-musl": "1.2.8", + "@rolldown/binding-openharmony-arm64": "1.2.8", + "@rolldown/binding-win32-arm64-msvc": "1.2.8", + "@rolldown/binding-win32-x64-msvc": "1.2.8" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/sdp": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.2.tgz", + "integrity": "sha512-xZocWwfyp4hkbN4hLWxMjmv2Q8aNa9MhmOZ7L9aCZPT+dZsgRr6wZRrSYE3HTdyk/2pZKPSgqI7ns7Een1xMSA==", + "license": "MIT" + }, + "node_modules/sdp-transform": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz", + "integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==", + "license": "MIT", + "bin": { + "sdp-verify": "checker.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "peer": true + }, + "node_modules/typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz", + "integrity": "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==", + "license": "MIT", + "optionalDependencies": { + "rxjs": "*" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/usehooks-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-3.1.1.tgz", + "integrity": "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA==", + "license": "MIT", + "dependencies": { + "lodash.debounce": "^4.0.8" + }, + "engines": { + "node": ">=16.15.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, + "node_modules/vite": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", + "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.6", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.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 + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/webrtc-adapter": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.6.tgz", + "integrity": "sha512-CHbl2ZQbxx164IgWRgzJno4hWtM4tFbRam1QfI3Yxhs3w/DvqluVxVWeXs3oL5/fbGkSNLKo0Ty5MgUWceNhog==", + "license": "BSD-3-Clause", + "dependencies": { + "sdp": "^3.2.0" + }, + "engines": { + "node": ">=6.0.0", + "npm": ">=3.10.0" + } + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/ws": { + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", + "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/zustand": { + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz", + "integrity": "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==", + "license": "MIT", + "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 + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..801ed77 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,33 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "oxlint", + "preview": "vite preview" + }, + "dependencies": { + "@livekit/components-react": "^2.9.24", + "@livekit/components-styles": "^1.2.0", + "@microsoft/signalr": "^10.0.11", + "livekit-client": "^2.22.3", + "oidc-client-ts": "^3.5.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-oidc-context": "^3.3.1", + "react-router-dom": "^7.18.4", + "zustand": "^5.0.15" + }, + "devDependencies": { + "@types/node": "^24.13.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.7", + "@vitejs/plugin-react": "^6.1.1", + "oxlint": "^1.81.0", + "typescript": "~6.0.2", + "vite": "^8.3.0" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..e113beb --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,127 @@ +import { Navigate, Route, Routes } from 'react-router-dom' +import { useAuth } from 'react-oidc-context' +import { ChatHubProvider, useChatHub } from './hub/ChatHubProvider' +import { ServerRail } from './components/ServerRail' +import { ChannelSidebar } from './components/ChannelSidebar' +import { ChatPanel } from './components/ChatPanel' +import { FriendsPanel } from './components/FriendsPanel' +import { CallDock } from './components/CallDock' +import { CallSignaling } from './components/CallSignaling' +import { PresenceListener } from './components/PresenceListener' +import { PresenceDot } from './components/PresenceDot' +import { Avatar } from './components/Avatar' +import { ToastHost } from './components/ToastHost' +import { Login } from './pages/Login' +import { AuthCallback } from './pages/AuthCallback' +import { useUiStore } from './store/uiStore' +import { useCallStore } from './store/callStore' +import { useCallInviteStore } from './store/callInviteStore' +import { useToastStore } from './store/toastStore' + +function AppShell() { + const view = useUiStore((s) => s.view) + const activeChannelId = useCallStore((s) => s.activeChannelId) + const join = useCallStore((s) => s.join) + const connection = useChatHub() + const setOutgoingCall = useCallInviteStore((s) => s.setOutgoingCall) + const pushToast = useToastStore((s) => s.push) + + async function startCall(channelId: string, calleeId: string, label: string) { + join(channelId, label) + if (!connection) return + try { + await connection.invoke('RingUser', channelId, calleeId) + setOutgoingCall({ channelId, calleeId, calleeName: label }) + } catch (error) { + console.error('Failed to ring user', error) + pushToast('Could not ring the other user', 'error') + } + } + + return ( +
+
+ + + {view.kind === 'friends' && ( +
+ +
+ )} + + {view.kind === 'channel' && ( + <> + +
+ +
+ + )} + + {view.kind === 'dm' && ( +
+
+ + + {view.otherUser.displayName ?? view.otherUser.username} + + {activeChannelId !== view.channelId && ( + + )} +
+ +
+ )} +
+ + + + + +
+ ) +} + +function MainLayout() { + return ( + + + + ) +} + +export default function App() { + const auth = useAuth() + + return ( + + } /> + +

Loading…

+ + ) : auth.isAuthenticated ? ( + + ) : ( + + ) + } + /> + } /> +
+ ) +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..cda04c1 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,114 @@ +import type { + AttachmentKind, + AttachmentUploadResponse, + CallTokenResponse, + ChannelResponse, + ChannelType, + FriendshipResponse, + MessageResponse, + ServerResponse, + UserResponse, +} from '../types/api' + +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL as string + +export class ApiError extends Error { + status: number + + constructor(status: number, message: string) { + super(message) + this.status = status + } +} + +export interface MessageAttachmentInput { + key: string + fileName: string + contentType: string + kind: AttachmentKind +} + +export function createApiClient(getAccessToken: () => string | undefined) { + async function request(path: string, init?: RequestInit): Promise { + const token = getAccessToken() + const response = await fetch(`${API_BASE_URL}${path}`, { + ...init, + headers: { + ...(init?.body ? { 'Content-Type': 'application/json' } : {}), + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...init?.headers, + }, + }) + + if (!response.ok) { + const body = await response.text().catch(() => '') + throw new ApiError(response.status, body || response.statusText) + } + + if (response.status === 204) { + return undefined as T + } + + return (await response.json()) as T + } + + return { + getCurrentUser: () => request('/users/me'), + getUserById: (id: string) => request(`/users/${id}`), + searchUsers: (query: string) => request(`/users/search?query=${encodeURIComponent(query)}`), + + getFriends: () => request('/friends'), + getPendingFriendRequests: () => request('/friends/requests'), + sendFriendRequest: (addresseeId: string) => + request('/friends/requests', { method: 'POST', body: JSON.stringify({ addresseeId }) }), + acceptFriendRequest: (id: string) => request(`/friends/requests/${id}/accept`, { method: 'POST' }), + rejectFriendRequest: (id: string) => request(`/friends/requests/${id}/reject`, { method: 'POST' }), + removeFriend: (id: string) => request(`/friends/${id}`, { method: 'DELETE' }), + blockUser: (userId: string) => request(`/friends/block/${userId}`, { method: 'POST' }), + + getServers: () => request('/servers'), + createServer: (name: string) => request('/servers', { method: 'POST', body: JSON.stringify({ name }) }), + + getServerChannels: (serverId: string) => request(`/servers/${serverId}/channels`), + createChannel: (serverId: string, name: string, type: ChannelType) => + request(`/servers/${serverId}/channels`, { method: 'POST', body: JSON.stringify({ name, type }) }), + getOrCreateDirectMessageChannel: (otherUserId: string) => + request(`/channels/dm/${otherUserId}`, { method: 'POST' }), + + getChannelMessages: (channelId: string, before?: string, take = 50) => + request( + `/channels/${channelId}/messages?take=${take}${before ? `&before=${before}` : ''}`, + ), + sendMessage: (channelId: string, content: string, attachments: MessageAttachmentInput[] = [], replyToMessageId?: string) => + request(`/channels/${channelId}/messages`, { + method: 'POST', + body: JSON.stringify({ content, replyToMessageId, attachments }), + }), + editMessage: (id: string, content: string) => + request(`/messages/${id}`, { method: 'PATCH', body: JSON.stringify({ content }) }), + deleteMessage: (id: string) => request(`/messages/${id}`, { method: 'DELETE' }), + + requestAttachmentUploadUrl: (fileName: string, contentType: string) => + request('/attachments/upload-url', { + method: 'POST', + body: JSON.stringify({ fileName, contentType }), + }), + + requestCallToken: (channelId: string) => + request(`/channels/${channelId}/call-token`, { method: 'POST' }), + } +} + +export async function uploadFileToPresignedUrl(uploadUrl: string, file: File | Blob, contentType: string): Promise { + const response = await fetch(uploadUrl, { + method: 'PUT', + headers: { 'Content-Type': contentType }, + body: file, + }) + + if (!response.ok) { + throw new ApiError(response.status, `Failed to upload file: ${response.statusText}`) + } +} + +export type ApiClient = ReturnType diff --git a/frontend/src/api/useApi.ts b/frontend/src/api/useApi.ts new file mode 100644 index 0000000..2135a40 --- /dev/null +++ b/frontend/src/api/useApi.ts @@ -0,0 +1,9 @@ +import { useMemo } from 'react' +import { useAuth } from 'react-oidc-context' +import { createApiClient } from './client' + +export function useApi() { + const auth = useAuth() + + return useMemo(() => createApiClient(() => auth.user?.access_token), [auth.user?.access_token]) +} diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/frontend/src/auth/oidcConfig.ts b/frontend/src/auth/oidcConfig.ts new file mode 100644 index 0000000..07ce6d0 --- /dev/null +++ b/frontend/src/auth/oidcConfig.ts @@ -0,0 +1,14 @@ +import type { AuthProviderProps } from 'react-oidc-context' +import { WebStorageStateStore } from 'oidc-client-ts' + +export const oidcConfig: AuthProviderProps = { + authority: import.meta.env.VITE_AUTH_AUTHORITY as string, + client_id: import.meta.env.VITE_AUTH_CLIENT_ID as string, + redirect_uri: import.meta.env.VITE_AUTH_REDIRECT_URI as string, + post_logout_redirect_uri: window.location.origin, + scope: 'openid profile email offline_access', + userStore: new WebStorageStateStore({ store: window.localStorage }), + onSigninCallback: () => { + window.history.replaceState({}, document.title, window.location.pathname.replace(/\/auth\/callback\/?$/, '/')) + }, +} diff --git a/frontend/src/components/Avatar.tsx b/frontend/src/components/Avatar.tsx new file mode 100644 index 0000000..0050856 --- /dev/null +++ b/frontend/src/components/Avatar.tsx @@ -0,0 +1,22 @@ +import { useState } from 'react' + +interface AvatarProps { + src?: string | null + fallbackText: string + alt: string + className?: string +} + +export function Avatar({ src, fallbackText, alt, className = '' }: AvatarProps) { + const [failed, setFailed] = useState(false) + + if (src && !failed) { + return {alt} setFailed(true)} /> + } + + return ( +
+ {fallbackText} +
+ ) +} diff --git a/frontend/src/components/CallDock.tsx b/frontend/src/components/CallDock.tsx new file mode 100644 index 0000000..b9b6b12 --- /dev/null +++ b/frontend/src/components/CallDock.tsx @@ -0,0 +1,123 @@ +import { LiveKitRoom, VideoConference, useRemoteParticipants } from '@livekit/components-react' +import { AudioPresets, ScreenSharePresets, type RoomOptions } from 'livekit-client' +import { useEffect, useState } from 'react' +import { useApi } from '../api/useApi' +import { ApiError } from '../api/client' +import { useChatHub } from '../hub/ChatHubProvider' +import { useCallStore } from '../store/callStore' +import { useCallInviteStore } from '../store/callInviteStore' +import { useToastStore } from '../store/toastStore' + +const LIVEKIT_URL = import.meta.env.VITE_LIVEKIT_URL + +const roomOptions: RoomOptions = { + adaptiveStream: true, + dynacast: true, + publishDefaults: { + videoCodec: 'vp9', + screenShareEncoding: ScreenSharePresets.h1080fps30.encoding, + audioPreset: AudioPresets.musicHighQualityStereo, + dtx: false, + }, +} + +function RingingWatcher() { + const remoteParticipants = useRemoteParticipants() + const outgoingCall = useCallInviteStore((s) => s.outgoingCall) + const setOutgoingCall = useCallInviteStore((s) => s.setOutgoingCall) + + useEffect(() => { + if (outgoingCall && remoteParticipants.length > 0) { + setOutgoingCall(null) + } + }, [remoteParticipants.length, outgoingCall, setOutgoingCall]) + + return null +} + +export function CallDock() { + const api = useApi() + const connection = useChatHub() + const activeChannelId = useCallStore((s) => s.activeChannelId) + const activeChannelLabel = useCallStore((s) => s.activeChannelLabel) + const leave = useCallStore((s) => s.leave) + const outgoingCall = useCallInviteStore((s) => s.outgoingCall) + const setOutgoingCall = useCallInviteStore((s) => s.setOutgoingCall) + const pushToast = useToastStore((s) => s.push) + const [token, setToken] = useState(null) + + useEffect(() => { + if (!activeChannelId) { + setToken(null) + return + } + + let cancelled = false + setToken(null) + void api + .requestCallToken(activeChannelId) + .then((response) => { + if (!cancelled) setToken(response.token) + }) + .catch((error) => { + if (cancelled) return + pushToast(error instanceof ApiError ? error.message : 'Failed to join the call', 'error') + leave() + }) + return () => { + cancelled = true + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [api, activeChannelId]) + + useEffect(() => { + if (!activeChannelId || !connection) return + void connection.invoke('SetBusy', true).catch(() => undefined) + return () => { + void connection.invoke('SetBusy', false).catch(() => undefined) + } + }, [activeChannelId, connection]) + + if (!activeChannelId) { + return null + } + + const isRinging = outgoingCall?.channelId === activeChannelId + + function handleLeave() { + if (outgoingCall?.channelId === activeChannelId) { + void connection?.invoke('CancelCall', outgoingCall.channelId, outgoingCall.calleeId).catch(() => undefined) + setOutgoingCall(null) + } + leave() + } + + return ( +
+
+ + 🔊 {activeChannelLabel} + {isRinging && Ringing…} + + +
+ {token ? ( + + + + + ) : ( +

Connecting…

+ )} +
+ ) +} diff --git a/frontend/src/components/CallSignaling.tsx b/frontend/src/components/CallSignaling.tsx new file mode 100644 index 0000000..284500a --- /dev/null +++ b/frontend/src/components/CallSignaling.tsx @@ -0,0 +1,96 @@ +import { useCallback } from 'react' +import { useApi } from '../api/useApi' +import { useChatHub } from '../hub/ChatHubProvider' +import { useHubEvent } from '../hub/useHubEvent' +import { useCallStore } from '../store/callStore' +import { useCallInviteStore } from '../store/callInviteStore' +import { useToastStore } from '../store/toastStore' +import { Modal } from './Modal' +import { Avatar } from './Avatar' + +export function CallSignaling() { + const api = useApi() + const connection = useChatHub() + const activeChannelId = useCallStore((s) => s.activeChannelId) + const join = useCallStore((s) => s.join) + const leave = useCallStore((s) => s.leave) + const incomingCall = useCallInviteStore((s) => s.incomingCall) + const outgoingCall = useCallInviteStore((s) => s.outgoingCall) + const setIncomingCall = useCallInviteStore((s) => s.setIncomingCall) + const setOutgoingCall = useCallInviteStore((s) => s.setOutgoingCall) + const push = useToastStore((s) => s.push) + + const onIncomingCall = useCallback( + (channelId: string, callerId: string) => { + void api.getUserById(callerId).then((caller) => { + setIncomingCall({ + channelId, + callerId, + callerName: caller.displayName ?? caller.username, + callerAvatarUrl: caller.avatarUrl, + }) + }) + }, + [api, setIncomingCall], + ) + + const onCallDeclined = useCallback( + (channelId: string) => { + if (outgoingCall?.channelId !== channelId) return + push(`${outgoingCall.calleeName} declined the call`, 'info') + setOutgoingCall(null) + if (activeChannelId === channelId) leave() + }, + [outgoingCall, push, setOutgoingCall, activeChannelId, leave], + ) + + const onCallCancelled = useCallback( + (channelId: string) => { + if (incomingCall?.channelId === channelId) setIncomingCall(null) + }, + [incomingCall, setIncomingCall], + ) + + useHubEvent('IncomingCall', onIncomingCall) + useHubEvent('CallDeclined', onCallDeclined) + useHubEvent('CallCancelled', onCallCancelled) + + function acceptCall() { + if (!incomingCall) return + join(incomingCall.channelId, incomingCall.callerName) + setIncomingCall(null) + } + + async function declineCall() { + if (!incomingCall) return + const { channelId, callerId } = incomingCall + setIncomingCall(null) + try { + await connection?.invoke('DeclineCall', channelId, callerId) + } catch (error) { + console.error('Failed to send DeclineCall', error) + } + } + + if (!incomingCall) return null + + return ( + void declineCall()}> +
+ +

{incomingCall.callerName} is calling you…

+
+
+ + +
+
+ ) +} diff --git a/frontend/src/components/ChannelSidebar.tsx b/frontend/src/components/ChannelSidebar.tsx new file mode 100644 index 0000000..66acc1b --- /dev/null +++ b/frontend/src/components/ChannelSidebar.tsx @@ -0,0 +1,99 @@ +import { useEffect, useState } from 'react' +import { useAuth } from 'react-oidc-context' +import { useApi } from '../api/useApi' +import type { ChannelResponse, ChannelType } from '../types/api' +import { useUiStore } from '../store/uiStore' +import { useCallStore } from '../store/callStore' +import { Modal } from './Modal' +import { Avatar } from './Avatar' + +export function ChannelSidebar({ serverId }: { serverId: string }) { + const api = useApi() + const auth = useAuth() + const { view, setView } = useUiStore() + const { activeChannelId, join, leave } = useCallStore() + const [channels, setChannels] = useState([]) + const [isCreating, setIsCreating] = useState(false) + const [draftName, setDraftName] = useState('') + const [draftType, setDraftType] = useState('Text') + + useEffect(() => { + void api.getServerChannels(serverId).then(setChannels) + }, [api, serverId]) + + async function createChannel() { + if (!draftName.trim()) return + const channel = await api.createChannel(serverId, draftName.trim(), draftType) + setChannels((prev) => [...prev, channel]) + setDraftName('') + setDraftType('Text') + setIsCreating(false) + } + + return ( + + ) +} diff --git a/frontend/src/components/ChatPanel.tsx b/frontend/src/components/ChatPanel.tsx new file mode 100644 index 0000000..712f1f9 --- /dev/null +++ b/frontend/src/components/ChatPanel.tsx @@ -0,0 +1,100 @@ +import { useCallback, useEffect, useState } from 'react' +import { useAuth } from 'react-oidc-context' +import { useApi } from '../api/useApi' +import type { MessageAttachmentInput } from '../api/client' +import type { MessageResponse } from '../types/api' +import { useChatHub } from '../hub/ChatHubProvider' +import { useHubEvent } from '../hub/useHubEvent' +import { MessageItem } from './MessageItem' +import { MessageComposer } from './MessageComposer' + +const GROUP_WINDOW_MS = 5 * 60 * 1000 + +export function ChatPanel({ channelId }: { channelId: string }) { + const api = useApi() + const auth = useAuth() + const connection = useChatHub() + const [messages, setMessages] = useState([]) + const currentUserId = auth.user?.profile.sub + + useEffect(() => { + let cancelled = false + void api.getChannelMessages(channelId).then((page) => { + if (!cancelled) setMessages([...page].reverse()) + }) + return () => { + cancelled = true + } + }, [api, channelId]) + + useEffect(() => { + if (!connection) return + void connection.invoke('JoinChannel', channelId).catch((error) => console.error('JoinChannel failed', error)) + return () => { + void connection.invoke('LeaveChannel', channelId).catch(() => undefined) + } + }, [connection, channelId]) + + const onMessageCreated = useCallback( + (message: MessageResponse) => { + if (message.channelId !== channelId) return + setMessages((prev) => [...prev, message]) + }, + [channelId], + ) + + const onMessageUpdated = useCallback( + (message: MessageResponse) => { + if (message.channelId !== channelId) return + setMessages((prev) => prev.map((m) => (m.id === message.id ? message : m))) + }, + [channelId], + ) + + const onMessageDeleted = useCallback((messageId: string) => { + setMessages((prev) => prev.filter((m) => m.id !== messageId)) + }, []) + + useHubEvent('MessageCreated', onMessageCreated) + useHubEvent('MessageUpdated', onMessageUpdated) + useHubEvent('MessageDeleted', onMessageDeleted) + + async function handleSend(content: string, attachments: MessageAttachmentInput[]) { + await api.sendMessage(channelId, content, attachments) + } + + async function handleEdit(id: string, newContent: string) { + await api.editMessage(id, newContent) + } + + async function handleDelete(id: string) { + await api.deleteMessage(id) + } + + return ( +
+
+ {messages.length === 0 &&

No messages yet. Say hi!

} + {messages.map((message, index) => { + const previous = messages[index - 1] + const showHeader = + !previous || + previous.author.id !== message.author.id || + new Date(message.createdAt).getTime() - new Date(previous.createdAt).getTime() > GROUP_WINDOW_MS + + return ( + + ) + })} +
+ +
+ ) +} diff --git a/frontend/src/components/ConfirmModal.tsx b/frontend/src/components/ConfirmModal.tsx new file mode 100644 index 0000000..627ff7f --- /dev/null +++ b/frontend/src/components/ConfirmModal.tsx @@ -0,0 +1,23 @@ +import { Modal } from './Modal' + +interface Props { + title: string + message: string + confirmLabel?: string + onConfirm: () => void + onCancel: () => void +} + +export function ConfirmModal({ title, message, confirmLabel = 'Confirm', onConfirm, onCancel }: Props) { + return ( + +

{message}

+
+ + +
+
+ ) +} diff --git a/frontend/src/components/FriendsPanel.tsx b/frontend/src/components/FriendsPanel.tsx new file mode 100644 index 0000000..d018100 --- /dev/null +++ b/frontend/src/components/FriendsPanel.tsx @@ -0,0 +1,202 @@ +import { useCallback, useEffect, useState } from 'react' +import { useApi } from '../api/useApi' +import { ApiError } from '../api/client' +import type { FriendshipResponse, UserResponse } from '../types/api' +import { useHubEvent } from '../hub/useHubEvent' +import { useUiStore } from '../store/uiStore' +import { usePresenceStore } from '../store/presenceStore' +import { ConfirmModal } from './ConfirmModal' +import { PresenceDot } from './PresenceDot' +import { Avatar } from './Avatar' + +function userLabel(user: UserResponse) { + return user.displayName ?? user.username +} + +interface PendingConfirm { + kind: 'remove' | 'block' + friendshipId: string + userId: string + label: string +} + +export function FriendsPanel() { + const api = useApi() + const setView = useUiStore((s) => s.setView) + const [friends, setFriends] = useState([]) + const [requests, setRequests] = useState([]) + const [query, setQuery] = useState('') + const [results, setResults] = useState([]) + const [errorMessage, setErrorMessage] = useState(null) + const [pendingConfirm, setPendingConfirm] = useState(null) + const seedPresence = usePresenceStore((s) => s.seed) + + const refresh = useCallback(() => { + void api.getFriends().then((friendships) => { + setFriends(friendships) + seedPresence(friendships.map((f) => [f.otherUser.id, f.presence])) + }) + void api.getPendingFriendRequests().then(setRequests) + }, [api, seedPresence]) + + useEffect(refresh, [refresh]) + useHubEvent('FriendRequestReceived', refresh) + useHubEvent('FriendshipUpdated', refresh) + + useEffect(() => { + if (query.trim().length < 2) { + setResults([]) + return + } + const handle = setTimeout(() => void api.searchUsers(query.trim()).then(setResults), 300) + return () => clearTimeout(handle) + }, [api, query]) + + async function runAction(action: Promise) { + setErrorMessage(null) + try { + await action + refresh() + } catch (error) { + setErrorMessage(error instanceof ApiError ? error.message : 'Something went wrong') + } + } + + async function openDirectMessage(user: UserResponse) { + try { + const channel = await api.getOrCreateDirectMessageChannel(user.id) + setView({ kind: 'dm', channelId: channel.id, otherUser: user }) + } catch (error) { + setErrorMessage(error instanceof ApiError ? error.message : 'Something went wrong') + } + } + + const incoming = requests.filter((r) => r.isIncoming) + const outgoing = requests.filter((r) => !r.isIncoming) + + return ( +
+ {errorMessage &&

{errorMessage}

} + +
+

Add friend

+ setQuery(e.target.value)} /> +
    + {results.map((user) => ( +
  • + + {userLabel(user)} + +
  • + ))} +
+
+ + {incoming.length > 0 && ( +
+

Incoming requests

+
    + {incoming.map((r) => ( +
  • + + {userLabel(r.otherUser)} + + +
  • + ))} +
+
+ )} + + {outgoing.length > 0 && ( +
+

Outgoing requests

+
    + {outgoing.map((r) => ( +
  • + + {userLabel(r.otherUser)} + Pending +
  • + ))} +
+
+ )} + +
+

Friends

+ {friends.length === 0 ? ( +

No friends yet — search a username above to add one.

+ ) : ( +
    + {friends.map((f) => ( +
  • + + + {userLabel(f.otherUser)} + + + +
  • + ))} +
+ )} +
+ + {pendingConfirm && ( + setPendingConfirm(null)} + onConfirm={() => { + const action = pendingConfirm + setPendingConfirm(null) + void runAction(action.kind === 'remove' ? api.removeFriend(action.friendshipId) : api.blockUser(action.userId)) + }} + /> + )} +
+ ) +} diff --git a/frontend/src/components/MessageComposer.tsx b/frontend/src/components/MessageComposer.tsx new file mode 100644 index 0000000..a2e805f --- /dev/null +++ b/frontend/src/components/MessageComposer.tsx @@ -0,0 +1,132 @@ +import { useRef, useState } from 'react' +import { useApi } from '../api/useApi' +import { ApiError, uploadFileToPresignedUrl } from '../api/client' +import type { MessageAttachmentInput } from '../api/client' +import type { AttachmentKind } from '../types/api' +import { useToastStore } from '../store/toastStore' + +interface PendingAttachment { + file: File | Blob + fileName: string + contentType: string + kind: AttachmentKind +} + +function guessKind(contentType: string): AttachmentKind { + if (contentType.startsWith('image/')) return 'Image' + if (contentType.startsWith('audio/')) return 'Audio' + return 'File' +} + +export function MessageComposer({ onSend }: { onSend: (content: string, attachments: MessageAttachmentInput[]) => Promise }) { + const api = useApi() + const pushToast = useToastStore((s) => s.push) + const [content, setContent] = useState('') + const [pending, setPending] = useState([]) + const [isRecording, setIsRecording] = useState(false) + const [isSending, setIsSending] = useState(false) + const fileInputRef = useRef(null) + const mediaRecorderRef = useRef(null) + const recordedChunksRef = useRef([]) + + function onFilesPicked(files: FileList | null) { + if (!files) return + const additions = Array.from(files).map((file) => ({ + file, + fileName: file.name, + contentType: file.type || 'application/octet-stream', + kind: guessKind(file.type), + })) + setPending((prev) => [...prev, ...additions]) + } + + async function toggleRecording() { + if (isRecording) { + mediaRecorderRef.current?.stop() + setIsRecording(false) + return + } + + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) + const recorder = new MediaRecorder(stream) + recordedChunksRef.current = [] + + recorder.ondataavailable = (e) => { + if (e.data.size > 0) recordedChunksRef.current.push(e.data) + } + recorder.onstop = () => { + stream.getTracks().forEach((track) => track.stop()) + const blob = new Blob(recordedChunksRef.current, { type: 'audio/webm' }) + setPending((prev) => [...prev, { file: blob, fileName: `voice-message-${Date.now()}.webm`, contentType: 'audio/webm', kind: 'Audio' }]) + } + + mediaRecorderRef.current = recorder + recorder.start() + setIsRecording(true) + } + + function removePending(index: number) { + setPending((prev) => prev.filter((_, i) => i !== index)) + } + + async function handleSend() { + if (!content.trim() && pending.length === 0) return + + setIsSending(true) + try { + const uploaded: MessageAttachmentInput[] = [] + for (const attachment of pending) { + const { uploadUrl, key } = await api.requestAttachmentUploadUrl(attachment.fileName, attachment.contentType) + await uploadFileToPresignedUrl(uploadUrl, attachment.file, attachment.contentType) + uploaded.push({ key, fileName: attachment.fileName, contentType: attachment.contentType, kind: attachment.kind }) + } + + await onSend(content.trim(), uploaded) + setContent('') + setPending([]) + } catch (error) { + pushToast(error instanceof ApiError ? error.message : 'Failed to send message', 'error') + } finally { + setIsSending(false) + } + } + + return ( +
+ {pending.length > 0 && ( +
    + {pending.map((attachment, index) => ( +
  • + {attachment.fileName} + +
  • + ))} +
+ )} +
+ onFilesPicked(e.target.files)} /> + + + setContent(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + void handleSend() + } + }} + /> + +
+
+ ) +} diff --git a/frontend/src/components/MessageItem.tsx b/frontend/src/components/MessageItem.tsx new file mode 100644 index 0000000..e3ba092 --- /dev/null +++ b/frontend/src/components/MessageItem.tsx @@ -0,0 +1,97 @@ +import { useState } from 'react' +import type { MessageResponse } from '../types/api' +import { ConfirmModal } from './ConfirmModal' +import { Avatar } from './Avatar' +import { formatMessageTimestamp, formatShortTime } from '../utils/formatTimestamp' + +interface Props { + message: MessageResponse + isOwn: boolean + showHeader: boolean + onEdit: (id: string, content: string) => Promise + onDelete: (id: string) => Promise +} + +export function MessageItem({ message, isOwn, showHeader, onEdit, onDelete }: Props) { + const [isEditing, setIsEditing] = useState(false) + const [draft, setDraft] = useState(message.content) + const [isConfirmingDelete, setIsConfirmingDelete] = useState(false) + const authorName = message.author.displayName ?? message.author.username + + async function saveEdit() { + if (draft.trim() && draft !== message.content) { + await onEdit(message.id, draft.trim()) + } + setIsEditing(false) + } + + return ( +
+ {showHeader ? ( + + ) : ( + {formatShortTime(message.createdAt)} + )} +
+ {showHeader && ( +
+ {authorName} + {formatMessageTimestamp(message.createdAt)} + {message.editedAt && (edited)} +
+ )} + + {isEditing ? ( +
+ setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') void saveEdit() + if (e.key === 'Escape') setIsEditing(false) + }} + autoFocus + /> + + +
+ ) : ( +

+ {message.content} + {!showHeader && message.editedAt && (edited)} +

+ )} + + {message.attachments.length > 0 && ( +
    + {message.attachments.map((attachment) => ( +
  • + {attachment.kind === 'Audio' ? '🎤' : attachment.kind === 'Image' ? '🖼' : '📎'} {attachment.fileName} +
  • + ))} +
+ )} + + {isOwn && !isEditing && ( +
+ + +
+ )} +
+ + {isConfirmingDelete && ( + setIsConfirmingDelete(false)} + onConfirm={() => { + setIsConfirmingDelete(false) + void onDelete(message.id) + }} + /> + )} +
+ ) +} diff --git a/frontend/src/components/Modal.tsx b/frontend/src/components/Modal.tsx new file mode 100644 index 0000000..ce2b996 --- /dev/null +++ b/frontend/src/components/Modal.tsx @@ -0,0 +1,12 @@ +import type { ReactNode } from 'react' + +export function Modal({ title, onClose, children }: { title: string; onClose: () => void; children: ReactNode }) { + return ( +
+
e.stopPropagation()}> +

{title}

+ {children} +
+
+ ) +} diff --git a/frontend/src/components/PresenceDot.tsx b/frontend/src/components/PresenceDot.tsx new file mode 100644 index 0000000..76a5492 --- /dev/null +++ b/frontend/src/components/PresenceDot.tsx @@ -0,0 +1,19 @@ +import { usePresenceStore } from '../store/presenceStore' +import type { PresenceStatus } from '../types/api' + +const LABELS: Record = { + Online: 'Online', + Busy: 'In a call', + Offline: 'Offline', +} + +export function PresenceDot({ userId, showLabel = false }: { userId: string; showLabel?: boolean }) { + const status = usePresenceStore((s) => s.statuses[userId] ?? 'Offline') + + return ( + + + {showLabel && {LABELS[status]}} + + ) +} diff --git a/frontend/src/components/PresenceListener.tsx b/frontend/src/components/PresenceListener.tsx new file mode 100644 index 0000000..13aeb35 --- /dev/null +++ b/frontend/src/components/PresenceListener.tsx @@ -0,0 +1,17 @@ +import { useCallback } from 'react' +import { useHubEvent } from '../hub/useHubEvent' +import { usePresenceStore } from '../store/presenceStore' +import type { PresenceStatus } from '../types/api' + +export function PresenceListener() { + const setStatus = usePresenceStore((s) => s.setStatus) + + const onPresenceChanged = useCallback( + (userId: string, status: PresenceStatus) => setStatus(userId, status), + [setStatus], + ) + + useHubEvent('PresenceChanged', onPresenceChanged) + + return null +} diff --git a/frontend/src/components/ServerRail.tsx b/frontend/src/components/ServerRail.tsx new file mode 100644 index 0000000..eea9aa6 --- /dev/null +++ b/frontend/src/components/ServerRail.tsx @@ -0,0 +1,79 @@ +import { useEffect, useState } from 'react' +import { useApi } from '../api/useApi' +import type { ServerResponse } from '../types/api' +import { useUiStore } from '../store/uiStore' +import { Modal } from './Modal' +import { Avatar } from './Avatar' + +export function ServerRail() { + const api = useApi() + const { view, setView } = useUiStore() + const [servers, setServers] = useState([]) + const [isCreating, setIsCreating] = useState(false) + const [draftName, setDraftName] = useState('') + + useEffect(() => { + void api.getServers().then(setServers) + }, [api]) + + async function selectServer(server: ServerResponse) { + const channels = await api.getServerChannels(server.id) + const firstTextChannel = channels.find((c) => c.type === 'Text') ?? channels[0] + if (firstTextChannel) { + setView({ kind: 'channel', serverId: server.id, channelId: firstTextChannel.id }) + } + } + + async function createServer() { + if (!draftName.trim()) return + const server = await api.createServer(draftName.trim()) + setServers((prev) => [...prev, server]) + setDraftName('') + setIsCreating(false) + void selectServer(server) + } + + const activeServerId = view.kind === 'channel' ? view.serverId : null + + return ( + + ) +} diff --git a/frontend/src/components/ToastHost.tsx b/frontend/src/components/ToastHost.tsx new file mode 100644 index 0000000..d232d57 --- /dev/null +++ b/frontend/src/components/ToastHost.tsx @@ -0,0 +1,18 @@ +import { useToastStore } from '../store/toastStore' + +export function ToastHost() { + const toasts = useToastStore((s) => s.toasts) + const dismiss = useToastStore((s) => s.dismiss) + + if (toasts.length === 0) return null + + return ( +
+ {toasts.map((toast) => ( +
dismiss(toast.id)}> + {toast.message} +
+ ))} +
+ ) +} diff --git a/frontend/src/hub/ChatHubProvider.tsx b/frontend/src/hub/ChatHubProvider.tsx new file mode 100644 index 0000000..5aff123 --- /dev/null +++ b/frontend/src/hub/ChatHubProvider.tsx @@ -0,0 +1,50 @@ +import { HubConnection, HubConnectionBuilder, LogLevel } from '@microsoft/signalr' +import { createContext, useContext, useEffect, useState, type ReactNode } from 'react' +import { useAuth } from 'react-oidc-context' + +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL as string + +const ChatHubContext = createContext(null) + +export function ChatHubProvider({ children }: { children: ReactNode }) { + const auth = useAuth() + const [connection, setConnection] = useState(null) + + useEffect(() => { + if (!auth.isAuthenticated) { + return + } + + const hubConnection = new HubConnectionBuilder() + .withUrl(`${API_BASE_URL}/hubs/chat`, { + accessTokenFactory: () => auth.user?.access_token ?? '', + }) + .withAutomaticReconnect() + .configureLogging(LogLevel.Warning) + .build() + + let cleanedUp = false + + hubConnection + .start() + .then(() => { + if (!cleanedUp) setConnection(hubConnection) + }) + .catch((error) => { + if (!cleanedUp) console.error('Failed to connect to chat hub', error) + }) + + return () => { + cleanedUp = true + void hubConnection.stop() + setConnection(null) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [auth.isAuthenticated, auth.user?.profile.sub]) + + return {children} +} + +export function useChatHub(): HubConnection | null { + return useContext(ChatHubContext) +} diff --git a/frontend/src/hub/useHubEvent.ts b/frontend/src/hub/useHubEvent.ts new file mode 100644 index 0000000..c8fd7a1 --- /dev/null +++ b/frontend/src/hub/useHubEvent.ts @@ -0,0 +1,17 @@ +import { useEffect } from 'react' +import { useChatHub } from './ChatHubProvider' + +export function useHubEvent(eventName: string, handler: (...args: TArgs) => void) { + const connection = useChatHub() + + useEffect(() => { + if (!connection) { + return + } + + connection.on(eventName, handler as (...args: unknown[]) => void) + return () => { + connection.off(eventName, handler as (...args: unknown[]) => void) + } + }, [connection, eventName, handler]) +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..4382a66 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,603 @@ +:root { + color-scheme: dark; + font-family: system-ui, -apple-system, 'Segoe UI', sans-serif; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: #1e1f22; + color: #dbdee1; +} + +button { + cursor: pointer; + border: none; + border-radius: 6px; + background: #4e5058; + color: #fff; + padding: 6px 10px; + font-size: 0.85rem; +} + +button:hover { + background: #5865f2; +} + +input { + background: #1e1f22; + border: 1px solid #3f4147; + border-radius: 6px; + color: #dbdee1; + padding: 8px 10px; +} + +.centered-screen { + height: 100vh; + display: flex; + align-items: center; + justify-content: center; +} + +.login-card { + text-align: center; +} + +.error-text { + color: #f23f42; +} + +.app-root { + display: flex; + flex-direction: column; + height: 100vh; +} + +.app-shell { + display: flex; + flex: 1; + min-height: 0; +} + +.server-rail { + width: 72px; + background: #1e1f22; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 12px 0; +} + +.server-rail-divider { + width: 32px; + height: 2px; + background: #35363c; + border-radius: 1px; +} + +.server-icon { + width: 48px; + height: 48px; + border-radius: 24px; + background: #313338; + font-size: 0.75rem; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.server-icon.active, +.server-icon:hover { + border-radius: 16px; + background: #5865f2; +} + +.server-icon-avatar { + width: 100%; + height: 100%; + border-radius: inherit; +} + +.channel-sidebar { + width: 240px; + background: #2b2d31; + display: flex; + flex-direction: column; +} + +.channel-sidebar-header { + padding: 16px; + font-weight: 600; + border-bottom: 1px solid #1e1f22; +} + +.channel-list { + flex: 1; + overflow-y: auto; + padding: 8px; + display: flex; + flex-direction: column; + gap: 2px; +} + +.channel-item { + background: transparent; + text-align: left; + color: #96989d; + padding: 6px 8px; +} + +.channel-item.active, +.channel-item:hover { + background: #3f4147; + color: #fff; +} + +.channel-row { + display: flex; + align-items: center; + gap: 4px; +} + +.channel-row .channel-item { + flex: 1; +} + +.channel-call-toggle { + font-size: 0.7rem; + padding: 4px 8px; + flex-shrink: 0; +} + +.current-user-footer { + padding: 10px; + background: #232428; + display: flex; + align-items: center; + justify-content: space-between; +} + +.current-user-info { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.current-user-avatar { + width: 28px; + height: 28px; + border-radius: 14px; + background: #5865f2; + font-size: 0.7rem; + flex-shrink: 0; +} + +.main-column { + flex: 1; + display: flex; + flex-direction: column; + background: #313338; + min-width: 0; +} + +.dm-header { + padding: 16px; + font-weight: 600; + border-bottom: 1px solid #1e1f22; + display: flex; + align-items: center; + justify-content: space-between; +} + +.dm-header-user { + display: flex; + align-items: center; + gap: 10px; +} + +.dm-header-avatar { + width: 28px; + height: 28px; + border-radius: 14px; + background: #5865f2; + font-size: 0.7rem; + flex-shrink: 0; +} + +.call-dock { + flex-shrink: 0; + background: #111214; + border-top: 1px solid #000; + display: flex; + flex-direction: column; + max-height: 45vh; +} + +.call-dock-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 16px; + font-size: 0.85rem; + color: #96989d; +} + +.call-dock-room { + height: 320px; +} + +.call-dock-connecting { + padding: 0 16px 12px; + color: #96989d; +} + +.chat-panel { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} + +.message-list { + flex: 1; + overflow-y: auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.message-item { + display: flex; + gap: 12px; +} + +.message-item.compact { + margin-top: -6px; +} + +.avatar-img { + object-fit: cover; + display: block; +} + +.avatar-fallback { + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + color: #fff; +} + +.message-avatar { + width: 36px; + height: 36px; + border-radius: 18px; + background: #5865f2; + flex-shrink: 0; +} + +.message-timestamp-gutter { + width: 36px; + flex-shrink: 0; + text-align: center; + font-size: 0.65rem; + color: #72767d; + opacity: 0; + padding-top: 3px; +} + +.message-item.compact:hover .message-timestamp-gutter { + opacity: 1; +} + +.message-header { + display: flex; + gap: 8px; + align-items: baseline; +} + +.message-author { + font-weight: 600; + color: #fff; +} + +.message-timestamp, +.message-edited { + font-size: 0.7rem; + color: #80838a; +} + +.message-content { + margin: 2px 0; + white-space: pre-wrap; + word-break: break-word; +} + +.message-attachments { + list-style: none; + padding: 0; + margin: 4px 0; + font-size: 0.85rem; + color: #96989d; +} + +.message-actions { + display: flex; + gap: 6px; +} + +.message-actions button { + background: transparent; + color: #80838a; + padding: 2px 6px; +} + +.message-actions button:hover { + color: #fff; + background: #3f4147; +} + +.message-edit-row { + display: flex; + gap: 6px; +} + +.message-edit-row input { + flex: 1; +} + +.message-composer { + padding: 16px; +} + +.pending-attachments { + list-style: none; + padding: 0; + margin: 0 0 8px; + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.pending-attachments li { + background: #2b2d31; + border-radius: 6px; + padding: 4px 8px; + display: flex; + align-items: center; + gap: 6px; + font-size: 0.8rem; +} + +.composer-row { + display: flex; + gap: 8px; + align-items: center; + background: #383a40; + border-radius: 8px; + padding: 6px 10px; +} + +.composer-input { + flex: 1; + background: transparent; + border: none; +} + +.friends-panel { + padding: 24px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 24px; + max-width: 640px; +} + +.user-list { + list-style: none; + padding: 0; + margin: 8px 0 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.user-list li { + display: flex; + align-items: center; + gap: 10px; + background: #2b2d31; + padding: 8px 12px; + border-radius: 8px; +} + +.user-list li span { + flex: 1; +} + +.user-avatar-sm { + width: 32px; + height: 32px; + border-radius: 16px; + background: #5865f2; + font-size: 0.8rem; + flex-shrink: 0; +} + +.empty-state { + color: #80838a; + font-size: 0.85rem; + padding: 8px 0; +} + +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} + +.modal-card { + background: #313338; + border-radius: 8px; + padding: 20px; + width: 320px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.modal-card h3 { + margin: 0; +} + +.modal-card input[type='text'], +.modal-card input:not([type]) { + width: 100%; +} + +.modal-radio-row { + display: flex; + gap: 16px; + font-size: 0.85rem; +} + +.modal-radio-row label { + display: flex; + align-items: center; + gap: 4px; +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.danger-button { + background: #f23f42; +} + +.danger-button:hover { + background: #d53437; +} + +.accept-button { + background: #23a55a; +} + +.accept-button:hover { + background: #1e8f4d; +} + +.incoming-call-caller { + display: flex; + align-items: center; + gap: 12px; +} + +.incoming-call-caller p { + margin: 0; +} + +.incoming-call-avatar { + width: 48px; + height: 48px; + border-radius: 24px; + background: #5865f2; + font-size: 1rem; + flex-shrink: 0; +} + +.call-ringing-badge { + margin-left: 8px; + font-size: 0.7rem; + color: #f0b132; + animation: pulse 1.4s ease-in-out infinite; +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.4; + } +} + +.toast-host { + position: fixed; + bottom: 16px; + right: 16px; + display: flex; + flex-direction: column; + gap: 8px; + z-index: 200; + max-width: 320px; +} + +.toast { + background: #2b2d31; + border-left: 4px solid #5865f2; + border-radius: 6px; + padding: 10px 14px; + font-size: 0.85rem; + cursor: pointer; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); +} + +.toast-error { + border-left-color: #f23f42; +} + +.user-list li .presence, +.dm-header .presence { + flex: 0 0 auto; +} + +.presence { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.presence-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + background: #80848e; +} + +.presence-dot-online { + background: #23a55a; +} + +.presence-dot-busy { + background: #f0b132; +} + +.presence-dot-offline { + background: #80848e; +} + +.presence-label { + font-size: 0.7rem; + color: #96989d; + font-weight: 400; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..2797e8d --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,18 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' +import { AuthProvider } from 'react-oidc-context' +import '@livekit/components-styles' +import './index.css' +import App from './App.tsx' +import { oidcConfig } from './auth/oidcConfig' + +createRoot(document.getElementById('root')!).render( + + + + + + + , +) diff --git a/frontend/src/pages/AuthCallback.tsx b/frontend/src/pages/AuthCallback.tsx new file mode 100644 index 0000000..568ffd5 --- /dev/null +++ b/frontend/src/pages/AuthCallback.tsx @@ -0,0 +1,24 @@ +import { Navigate } from 'react-router-dom' +import { useAuth } from 'react-oidc-context' + +export function AuthCallback() { + const auth = useAuth() + + if (auth.isLoading) { + return ( +
+

Signing in…

+
+ ) + } + + if (auth.error) { + return ( +
+

Sign-in failed: {auth.error.message}

+
+ ) + } + + return +} diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx new file mode 100644 index 0000000..7ec6f2b --- /dev/null +++ b/frontend/src/pages/Login.tsx @@ -0,0 +1,16 @@ +import { useAuth } from 'react-oidc-context' + +export function Login() { + const auth = useAuth() + + return ( +
+
+

Ping

+

Chat, voice and video, self-hosted.

+ + {auth.error &&

{auth.error.message}

} +
+
+ ) +} diff --git a/frontend/src/store/callInviteStore.ts b/frontend/src/store/callInviteStore.ts new file mode 100644 index 0000000..e0765b5 --- /dev/null +++ b/frontend/src/store/callInviteStore.ts @@ -0,0 +1,28 @@ +import { create } from 'zustand' + +export interface IncomingCall { + channelId: string + callerId: string + callerName: string + callerAvatarUrl: string | null +} + +export interface OutgoingCall { + channelId: string + calleeId: string + calleeName: string +} + +interface CallInviteState { + incomingCall: IncomingCall | null + outgoingCall: OutgoingCall | null + setIncomingCall: (call: IncomingCall | null) => void + setOutgoingCall: (call: OutgoingCall | null) => void +} + +export const useCallInviteStore = create((set) => ({ + incomingCall: null, + outgoingCall: null, + setIncomingCall: (call) => set({ incomingCall: call }), + setOutgoingCall: (call) => set({ outgoingCall: call }), +})) diff --git a/frontend/src/store/callStore.ts b/frontend/src/store/callStore.ts new file mode 100644 index 0000000..59542ab --- /dev/null +++ b/frontend/src/store/callStore.ts @@ -0,0 +1,15 @@ +import { create } from 'zustand' + +interface CallState { + activeChannelId: string | null + activeChannelLabel: string | null + join: (channelId: string, label: string) => void + leave: () => void +} + +export const useCallStore = create((set) => ({ + activeChannelId: null, + activeChannelLabel: null, + join: (channelId, label) => set({ activeChannelId: channelId, activeChannelLabel: label }), + leave: () => set({ activeChannelId: null, activeChannelLabel: null }), +})) diff --git a/frontend/src/store/presenceStore.ts b/frontend/src/store/presenceStore.ts new file mode 100644 index 0000000..ec5efe4 --- /dev/null +++ b/frontend/src/store/presenceStore.ts @@ -0,0 +1,16 @@ +import { create } from 'zustand' +import type { PresenceStatus } from '../types/api' + +interface PresenceState { + statuses: Record + setStatus: (userId: string, status: PresenceStatus) => void + seed: (entries: Array<[string, PresenceStatus]>) => void +} + +export const usePresenceStore = create((set) => ({ + statuses: {}, + setStatus: (userId, status) => + set((s) => ({ statuses: { ...s.statuses, [userId]: status } })), + seed: (entries) => + set((s) => ({ statuses: { ...s.statuses, ...Object.fromEntries(entries) } })), +})) diff --git a/frontend/src/store/toastStore.ts b/frontend/src/store/toastStore.ts new file mode 100644 index 0000000..69fa00f --- /dev/null +++ b/frontend/src/store/toastStore.ts @@ -0,0 +1,27 @@ +import { create } from 'zustand' + +export type ToastTone = 'error' | 'info' + +export interface Toast { + id: number + message: string + tone: ToastTone +} + +let nextId = 1 + +interface ToastState { + toasts: Toast[] + push: (message: string, tone?: ToastTone) => void + dismiss: (id: number) => void +} + +export const useToastStore = create((set) => ({ + toasts: [], + push: (message, tone = 'info') => { + const id = nextId++ + set((s) => ({ toasts: [...s.toasts, { id, message, tone }] })) + setTimeout(() => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })), 5000) + }, + dismiss: (id) => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })), +})) diff --git a/frontend/src/store/uiStore.ts b/frontend/src/store/uiStore.ts new file mode 100644 index 0000000..e70c690 --- /dev/null +++ b/frontend/src/store/uiStore.ts @@ -0,0 +1,17 @@ +import { create } from 'zustand' +import type { UserResponse } from '../types/api' + +export type View = + | { kind: 'friends' } + | { kind: 'channel'; serverId: string; channelId: string } + | { kind: 'dm'; channelId: string; otherUser: UserResponse } + +interface UiState { + view: View + setView: (view: View) => void +} + +export const useUiStore = create((set) => ({ + view: { kind: 'friends' }, + setView: (view) => set({ view }), +})) diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts new file mode 100644 index 0000000..a3c1576 --- /dev/null +++ b/frontend/src/types/api.ts @@ -0,0 +1,69 @@ +export interface UserResponse { + id: string + username: string + displayName: string | null + avatarUrl: string | null +} + +export type FriendshipStatus = 'Pending' | 'Accepted' | 'Blocked' + +export type PresenceStatus = 'Offline' | 'Online' | 'Busy' + +export interface FriendshipResponse { + id: string + otherUser: UserResponse + status: FriendshipStatus + isIncoming: boolean + createdAt: string + presence: PresenceStatus +} + +export interface ServerResponse { + id: string + name: string + iconUrl: string | null + ownerId: string +} + +export type ChannelType = 'Text' | 'Voice' | 'DirectMessage' + +export interface ChannelResponse { + id: string + serverId: string | null + name: string | null + type: ChannelType + position: number +} + +export type AttachmentKind = 'File' | 'Image' | 'Audio' + +export interface AttachmentResponse { + id: string + fileName: string + contentType: string + sizeBytes: number + kind: AttachmentKind +} + +export interface MessageResponse { + id: string + channelId: string + author: UserResponse + content: string + replyToMessageId: string | null + createdAt: string + editedAt: string | null + attachments: AttachmentResponse[] +} + +export interface AttachmentUploadResponse { + uploadUrl: string + bucket: string + key: string + expiresAt: string +} + +export interface CallTokenResponse { + token: string + roomName: string +} diff --git a/frontend/src/utils/formatTimestamp.ts b/frontend/src/utils/formatTimestamp.ts new file mode 100644 index 0000000..09cb5c6 --- /dev/null +++ b/frontend/src/utils/formatTimestamp.ts @@ -0,0 +1,15 @@ +export function formatMessageTimestamp(iso: string): string { + const date = new Date(iso) + const now = new Date() + const yesterday = new Date(now) + yesterday.setDate(now.getDate() - 1) + const time = formatShortTime(iso) + + if (date.toDateString() === now.toDateString()) return `Today at ${time}` + if (date.toDateString() === yesterday.toDateString()) return `Yesterday at ${time}` + return `${date.toLocaleDateString()} ${time}` +} + +export function formatShortTime(iso: string): string { + return new Date(iso).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }) +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..a583505 --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,13 @@ +/// + +interface ImportMetaEnv { + readonly VITE_API_BASE_URL: string + readonly VITE_AUTH_AUTHORITY: string + readonly VITE_AUTH_CLIENT_ID: string + readonly VITE_AUTH_REDIRECT_URI: string + readonly VITE_LIVEKIT_URL: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..6830b6f --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "allowArbitraryExtensions": true, + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..8455dcb --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "module": "nodenext", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..afd9b64 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,9 @@ +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite' + +export default defineConfig({ + plugins: [react()], + server: { + port: 3100, + }, +})