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 @@
+
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
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}
}
+
+
+
+ {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 (
+
+ )
+}
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 (
+
+ )
+ }
+
+ 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,
+ },
+})