Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 080931ac07 | |||
| c859ded3f7 | |||
| b8e9fb96c3 | |||
| f2bf70bc7d | |||
| cc14b4d1b7 | |||
| b27656bb43 | |||
| 2fb6bd3783 | |||
| 24bf3e38a7 | |||
| f13c04e809 | |||
| fd888c9ed1 | |||
| 17b606e1be | |||
| 84745eb104 | |||
| ae59d3deac | |||
| 687bd56e42 | |||
| 03d3b75fb4 | |||
| 32084a66d1 | |||
| 64e03964e9 | |||
| 11cc5350c8 | |||
| 0e4c79b735 | |||
| ffdea4fe62 | |||
| f0e2b60f43 | |||
| 0da98bfd96 | |||
| 189f989c0d | |||
| d2c4152ea7 | |||
| df309d088c | |||
| 506a824401 | |||
| 61612b52d3 | |||
| 640d92d231 |
0
.aidesigner/.gitkeep
Normal file
0
.aidesigner/.gitkeep
Normal file
11
.code-workspace.code-workspace
Normal file
11
.code-workspace.code-workspace
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"path": "../Speech2Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": {}
|
||||||
|
}
|
||||||
15
.gitignore
vendored
15
.gitignore
vendored
@@ -7,3 +7,18 @@ server/server/data/privatechat.sqlite-shm
|
|||||||
server/server/data/privatechat.sqlite-wal
|
server/server/data/privatechat.sqlite-wal
|
||||||
server/server/data/master.key
|
server/server/data/master.key
|
||||||
client/dist/*
|
client/dist/*
|
||||||
|
client/apple-client/WebApp/**
|
||||||
|
server/data/master.key
|
||||||
|
.vscode/extensions.json
|
||||||
|
.aidesigner/*
|
||||||
|
!.aidesigner/.gitkeep
|
||||||
|
CLAUDE.md
|
||||||
|
.mcp.json
|
||||||
|
.agents/skills/aidesigner-frontend/SKILL.md
|
||||||
|
.agents/skills/aidesigner-frontend/references/api.md
|
||||||
|
.agents/skills/aidesigner-frontend/references/frontend-rubric.md
|
||||||
|
.claude/agents/aidesigner-frontend.md
|
||||||
|
.claude/commands/aidesigner.md
|
||||||
|
.claude/skills/aidesigner-frontend/SKILL.md
|
||||||
|
.claude/skills/aidesigner-frontend/references/api.md
|
||||||
|
.claude/skills/aidesigner-frontend/references/frontend-rubric.md
|
||||||
|
|||||||
@@ -71,6 +71,13 @@ The repo also includes a multiplatform SwiftUI client in `apple-client/` for mac
|
|||||||
- Generate the Xcode project with `xcodegen generate --spec apple-client/project.yml --project-root apple-client`.
|
- Generate the Xcode project with `xcodegen generate --spec apple-client/project.yml --project-root apple-client`.
|
||||||
- A build of the Apple app automatically rebuilds the Angular client into `apple-client/WebApp/` before bundling it.
|
- A build of the Apple app automatically rebuilds the Angular client into `apple-client/WebApp/` before bundling it.
|
||||||
|
|
||||||
|
The backend also exposes the latest Angular browser build through an API that native clients can sync into their local `WKWebView` bundle cache:
|
||||||
|
|
||||||
|
- `GET /api/web-app/manifest`: Returns a bundle manifest with a stable `bundleId`, latest `generatedAt` timestamp, and a file list containing relative paths, SHA-256 hashes, MIME types, sizes, and download URLs.
|
||||||
|
- `GET /api/web-app/files/<relative-path>`: Streams an individual file from `client/dist/client/browser` with `ETag` and `Last-Modified` headers for native caching.
|
||||||
|
|
||||||
|
If the Angular build does not exist yet, those endpoints return `404`.
|
||||||
|
|
||||||
## Backend environment
|
## Backend environment
|
||||||
|
|
||||||
The backend accepts these environment variables:
|
The backend accepts these environment variables:
|
||||||
@@ -83,7 +90,7 @@ The backend accepts these environment variables:
|
|||||||
- `PRIVATECHAT_MASTER_KEY`: Optional master key for encrypting SQLite secret material and user credentials.
|
- `PRIVATECHAT_MASTER_KEY`: Optional master key for encrypting SQLite secret material and user credentials.
|
||||||
- `PRIVATECHAT_MASTER_KEY_PATH`: Optional file path for the generated master key.
|
- `PRIVATECHAT_MASTER_KEY_PATH`: Optional file path for the generated master key.
|
||||||
- `PRIVATECHAT_WEB_DIST_DIR`: Directory containing the prebuilt Angular browser bundle. Default `client/dist/client/browser`.
|
- `PRIVATECHAT_WEB_DIST_DIR`: Directory containing the prebuilt Angular browser bundle. Default `client/dist/client/browser`.
|
||||||
- `CORS_ORIGIN`: Optional allowed browser origin. If omitted, the server reflects request origins.
|
- `CORS_ORIGIN`: Optional comma-separated browser-origin allowlist. If omitted, the server accepts request origins. The special `null` origin from embedded `file://` webviews is accepted.
|
||||||
- `WEBAUTHN_ORIGIN`: Browser origin allowed to register access keys. Default `http://localhost:4200`.
|
- `WEBAUTHN_ORIGIN`: Browser origin allowed to register access keys. Default `http://localhost:4200`.
|
||||||
- `WEBAUTHN_RP_ID`: WebAuthn RP ID. Default hostname of `WEBAUTHN_ORIGIN`.
|
- `WEBAUTHN_RP_ID`: WebAuthn RP ID. Default hostname of `WEBAUTHN_ORIGIN`.
|
||||||
- `WEBAUTHN_RP_NAME`: Friendly RP name for browser access-key prompts. Default `PrivateChat`.
|
- `WEBAUTHN_RP_NAME`: Friendly RP name for browser access-key prompts. Default `PrivateChat`.
|
||||||
|
|||||||
@@ -49,10 +49,23 @@
|
|||||||
{
|
{
|
||||||
"glob": "**/*",
|
"glob": "**/*",
|
||||||
"input": "public"
|
"input": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"glob": "magick.wasm",
|
||||||
|
"input": "node_modules/@imagemagick/magick-wasm/dist",
|
||||||
|
"output": "/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"glob": "ort-wasm-simd-threaded.jsep.*",
|
||||||
|
"input": "node_modules/@huggingface/transformers/dist",
|
||||||
|
"output": "/transformers-wasm"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"styles": [
|
"styles": [
|
||||||
"src/styles.scss"
|
"src/styles.scss"
|
||||||
|
],
|
||||||
|
"scripts": [
|
||||||
|
"src/jsonview.js"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
@@ -60,13 +73,13 @@
|
|||||||
"budgets": [
|
"budgets": [
|
||||||
{
|
{
|
||||||
"type": "initial",
|
"type": "initial",
|
||||||
"maximumWarning": "700kB",
|
"maximumWarning": "1MB",
|
||||||
"maximumError": "1MB"
|
"maximumError": "2MB"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "anyComponentStyle",
|
"type": "anyComponentStyle",
|
||||||
"maximumWarning": "4kB",
|
"maximumWarning": "10kB",
|
||||||
"maximumError": "8kB"
|
"maximumError": "12kB"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"outputHashing": "all"
|
"outputHashing": "all"
|
||||||
|
|||||||
1777
client/package-lock.json
generated
1777
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,22 +12,25 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "npm@11.10.1",
|
"packageManager": "npm@11.10.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/common": "^21.2.0",
|
"@angular/common": "^21.2.8",
|
||||||
"@angular/compiler": "^21.2.0",
|
"@angular/compiler": "^21.2.8",
|
||||||
"@angular/core": "^21.2.0",
|
"@angular/core": "^21.2.8",
|
||||||
"@angular/forms": "^21.2.0",
|
"@angular/forms": "^21.2.8",
|
||||||
"@angular/platform-browser": "^21.2.0",
|
"@angular/platform-browser": "^21.2.8",
|
||||||
"@angular/router": "^21.2.0",
|
"@angular/router": "^21.2.8",
|
||||||
|
"@huggingface/transformers": "^3.8.1",
|
||||||
|
"@imagemagick/magick-wasm": "^0.0.39",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"rxjs": "~7.8.0",
|
"ngx-extended-pdf-viewer": "^25.6.4",
|
||||||
"tslib": "^2.3.0"
|
"rxjs": "~7.8.2",
|
||||||
|
"tslib": "^2.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular/build": "^21.2.1",
|
"@angular/build": "^21.2.7",
|
||||||
"@angular/cli": "^21.2.1",
|
"@angular/cli": "^21.2.7",
|
||||||
"@angular/compiler-cli": "^21.2.0",
|
"@angular/compiler-cli": "^21.2.8",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.4.2",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.2",
|
||||||
"typescript": "~5.9.2"
|
"typescript": "~5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
client/public/SymphonyDing.mp3
Normal file
BIN
client/public/SymphonyDing.mp3
Normal file
Binary file not shown.
BIN
client/public/apple-touch-icon.png
Normal file
BIN
client/public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@@ -1,3 +1,6 @@
|
|||||||
window.__PRIVATECHAT_ENV__ = {
|
window.__PRIVATECHAT_ENV__ = {
|
||||||
"PRIVATECHAT_CLIENT_SERVER_URL": "http://chatter.dubertrand.fr"
|
"PRIVATECHAT_CLIENT_SERVER_URL": "https://chatter.dubertrand.fr",
|
||||||
|
"PRIVATECHAT_CLIENT_WHISPER_MODEL": "Xenova/whisper-small",
|
||||||
|
"PRIVATECHAT_CLIENT_WHISPER_LANGUAGE": "auto",
|
||||||
|
"PRIVATECHAT_CLIENT_TRANSFORMERS_WASM_PATH": "/transformers-wasm/"
|
||||||
};
|
};
|
||||||
|
|||||||
21
client/public/icon-source.svg
Normal file
21
client/public/icon-source.svg
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="64" y1="40" x2="448" y2="472" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#10233B"/>
|
||||||
|
<stop offset="1" stop-color="#06111D"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="accent" x1="124" y1="124" x2="389" y2="389" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#84F4D6"/>
|
||||||
|
<stop offset="1" stop-color="#56ABFF"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="512" height="512" rx="120" fill="url(#bg)"/>
|
||||||
|
<circle cx="154" cy="148" r="96" fill="#8DF0DF" fill-opacity="0.16"/>
|
||||||
|
<circle cx="394" cy="118" r="78" fill="#58ABFF" fill-opacity="0.16"/>
|
||||||
|
<path d="M152 164C152 132.967 177.167 107.8 208.2 107.8H303.8C334.833 107.8 360 132.967 360 164V227.7C360 258.733 334.833 283.9 303.8 283.9H257.8L198.6 335.3C190.343 342.468 177.4 336.601 177.4 325.666V283.9H208.2C177.167 283.9 152 258.733 152 227.7V164Z" fill="url(#accent)"/>
|
||||||
|
<rect x="195" y="154" width="122" height="18" rx="9" fill="#062039" fill-opacity="0.9"/>
|
||||||
|
<rect x="195" y="196" width="86" height="18" rx="9" fill="#062039" fill-opacity="0.9"/>
|
||||||
|
<path d="M354.8 334.9C354.8 379.013 319.046 414.767 274.933 414.767C255.288 414.767 237.299 407.666 223.396 395.888L172.572 410.4C163.669 412.942 155.453 404.726 157.995 395.823L172.507 344.999C160.729 331.096 153.628 313.107 153.628 293.462C153.628 249.349 189.382 213.595 233.495 213.595C277.608 213.595 313.362 249.349 313.362 293.462C313.362 304.056 311.3 314.171 307.553 323.426L344.213 360.086C350.981 366.854 354.8 376.033 354.8 385.604V334.9Z" fill="#0F2540"/>
|
||||||
|
<circle cx="233.495" cy="293.462" r="52.895" fill="#E8F3FF"/>
|
||||||
|
<path d="M233.495 258.246C252.941 258.246 268.711 274.016 268.711 293.462C268.711 312.908 252.941 328.678 233.495 328.678C214.049 328.678 198.279 312.908 198.279 293.462C198.279 274.016 214.049 258.246 233.495 258.246Z" fill="#56ABFF"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
40
client/public/manifest.webmanifest
Normal file
40
client/public/manifest.webmanifest
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"id": "/",
|
||||||
|
"name": "PrivateChat",
|
||||||
|
"short_name": "PrivateChat",
|
||||||
|
"description": "Private peer-to-peer chat with Angular, Fastify, and WebRTC.",
|
||||||
|
"lang": "en",
|
||||||
|
"dir": "ltr",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"background_color": "#08111d",
|
||||||
|
"theme_color": "#08111d",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "pwa-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "pwa-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "maskable-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "maskable-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
client/public/maskable-192x192.png
Normal file
BIN
client/public/maskable-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
client/public/maskable-512x512.png
Normal file
BIN
client/public/maskable-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
BIN
client/public/notif.mp3
Normal file
BIN
client/public/notif.mp3
Normal file
Binary file not shown.
BIN
client/public/pwa-192x192.png
Normal file
BIN
client/public/pwa-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
client/public/pwa-512x512.png
Normal file
BIN
client/public/pwa-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
77
client/public/service-worker.js
Normal file
77
client/public/service-worker.js
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
const APP_SHELL_CACHE = 'privatechat-app-shell-v1';
|
||||||
|
const APP_SHELL_FILES = [
|
||||||
|
'/',
|
||||||
|
'/index.html',
|
||||||
|
'/manifest.webmanifest',
|
||||||
|
'/favicon.ico',
|
||||||
|
'/apple-touch-icon.png',
|
||||||
|
'/pwa-192x192.png',
|
||||||
|
'/pwa-512x512.png',
|
||||||
|
'/maskable-192x192.png',
|
||||||
|
'/maskable-512x512.png',
|
||||||
|
];
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(APP_SHELL_CACHE).then((cache) => cache.addAll(APP_SHELL_FILES)),
|
||||||
|
);
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((cacheNames) => Promise.all(
|
||||||
|
cacheNames
|
||||||
|
.filter((cacheName) => cacheName !== APP_SHELL_CACHE)
|
||||||
|
.map((cacheName) => caches.delete(cacheName)),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
self.clients.claim();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const { request } = event;
|
||||||
|
|
||||||
|
if (request.method !== 'GET') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
if (url.origin !== self.location.origin || url.pathname.startsWith('/api/') || url.pathname === '/ws') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.mode === 'navigate') {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(request)
|
||||||
|
.then((response) => {
|
||||||
|
const responseCopy = response.clone();
|
||||||
|
void caches.open(APP_SHELL_CACHE).then((cache) => cache.put('/index.html', responseCopy));
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.catch(async () => {
|
||||||
|
const cache = await caches.open(APP_SHELL_CACHE);
|
||||||
|
return cache.match('/index.html') || Response.error();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(request).then((cachedResponse) => {
|
||||||
|
const networkFetch = fetch(request)
|
||||||
|
.then((response) => {
|
||||||
|
if (response.ok) {
|
||||||
|
const responseCopy = response.clone();
|
||||||
|
void caches.open(APP_SHELL_CACHE).then((cache) => cache.put(request, responseCopy));
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.catch(() => cachedResponse || Response.error());
|
||||||
|
|
||||||
|
return cachedResponse || networkFetch;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -9,6 +9,10 @@ dotenv.config({ path: rootEnvPath });
|
|||||||
|
|
||||||
const runtimeEnv = {
|
const runtimeEnv = {
|
||||||
PRIVATECHAT_CLIENT_SERVER_URL: process.env.PRIVATECHAT_CLIENT_SERVER_URL ?? 'http://localhost:3000',
|
PRIVATECHAT_CLIENT_SERVER_URL: process.env.PRIVATECHAT_CLIENT_SERVER_URL ?? 'http://localhost:3000',
|
||||||
|
PRIVATECHAT_CLIENT_WHISPER_MODEL: process.env.PRIVATECHAT_CLIENT_WHISPER_MODEL ?? 'Xenova/whisper-small',
|
||||||
|
PRIVATECHAT_CLIENT_WHISPER_LANGUAGE: process.env.PRIVATECHAT_CLIENT_WHISPER_LANGUAGE ?? 'auto',
|
||||||
|
PRIVATECHAT_CLIENT_TRANSFORMERS_WASM_PATH:
|
||||||
|
process.env.PRIVATECHAT_CLIENT_TRANSFORMERS_WASM_PATH ?? '/transformers-wasm/',
|
||||||
};
|
};
|
||||||
|
|
||||||
const fileContents = `window.__PRIVATECHAT_ENV__ = ${JSON.stringify(runtimeEnv, null, 2)};\n`;
|
const fileContents = `window.__PRIVATECHAT_ENV__ = ${JSON.stringify(runtimeEnv, null, 2)};\n`;
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ export const routes: Routes = [
|
|||||||
path: '',
|
path: '',
|
||||||
component: HomePageComponent,
|
component: HomePageComponent,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'chat',
|
||||||
|
component: ChatPageComponent,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'chat/:peerId',
|
path: 'chat/:peerId',
|
||||||
component: ChatPageComponent,
|
component: ChatPageComponent,
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
.approval-card {
|
.approval-card {
|
||||||
border: 1px solid var(--surface-border-soft);
|
border: 1px solid var(--surface-border-soft);
|
||||||
border-radius: 1rem;
|
border-radius: 1.15rem;
|
||||||
background: var(--panel-soft-background);
|
background: var(--surface-background);
|
||||||
|
transition: border-color 160ms ease, transform 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-card:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--accent-color) 35%, transparent);
|
||||||
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|||||||
273
client/src/app/browser-speech-transcriber.service.ts
Normal file
273
client/src/app/browser-speech-transcriber.service.ts
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
import { inject, Injectable } from '@angular/core';
|
||||||
|
|
||||||
|
import { ChatSessionService } from './chat-session.service';
|
||||||
|
import type { DictationLanguage } from './models';
|
||||||
|
|
||||||
|
type PrivateChatRuntimeEnv = {
|
||||||
|
PRIVATECHAT_CLIENT_TRANSFORMERS_WASM_PATH?: string;
|
||||||
|
PRIVATECHAT_CLIENT_WHISPER_LANGUAGE?: string;
|
||||||
|
PRIVATECHAT_CLIENT_WHISPER_MODEL?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AutomaticSpeechRecognitionOutput = {
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AutomaticSpeechRecognitionPipeline = (
|
||||||
|
audio: Float32Array,
|
||||||
|
options?: {
|
||||||
|
chunk_length_s?: number;
|
||||||
|
stride_length_s?: number;
|
||||||
|
task?: 'transcribe';
|
||||||
|
language?: string;
|
||||||
|
},
|
||||||
|
) => Promise<AutomaticSpeechRecognitionOutput | AutomaticSpeechRecognitionOutput[]>;
|
||||||
|
|
||||||
|
type TransformersModule = {
|
||||||
|
env: {
|
||||||
|
backends: {
|
||||||
|
onnx: {
|
||||||
|
wasm?: {
|
||||||
|
wasmPaths?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
pipeline: (
|
||||||
|
task: string,
|
||||||
|
model: string,
|
||||||
|
options?: {
|
||||||
|
device?: 'wasm' | 'webgpu';
|
||||||
|
dtype?: 'fp32';
|
||||||
|
model_file_name?: string;
|
||||||
|
subfolder?: string;
|
||||||
|
},
|
||||||
|
) => Promise<unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const whisperTargetSampleRate = 16_000;
|
||||||
|
const defaultWhisperModel = 'Xenova/whisper-small';
|
||||||
|
const defaultTransformersWasmPath = '/transformers-wasm/';
|
||||||
|
const defaultChunkLengthSeconds = 30;
|
||||||
|
const defaultStrideLengthSeconds = 5;
|
||||||
|
const whisperLanguageNames: Record<DictationLanguage, string> = {
|
||||||
|
en: 'english',
|
||||||
|
fr: 'french',
|
||||||
|
es: 'spanish',
|
||||||
|
};
|
||||||
|
|
||||||
|
function readRuntimeEnv(): PrivateChatRuntimeEnv {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return (window as typeof window & { __PRIVATECHAT_ENV__?: PrivateChatRuntimeEnv }).__PRIVATECHAT_ENV__ ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAudioContextConstructor(): typeof AudioContext | null {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.AudioContext
|
||||||
|
?? (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
|
||||||
|
?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class BrowserSpeechTranscriberService {
|
||||||
|
private readonly session = inject(ChatSessionService);
|
||||||
|
private readonly runtimeEnv = readRuntimeEnv();
|
||||||
|
private readonly modelId = this.runtimeEnv.PRIVATECHAT_CLIENT_WHISPER_MODEL?.trim() || defaultWhisperModel;
|
||||||
|
private readonly fallbackLanguage = this.normalizeLanguage(
|
||||||
|
this.runtimeEnv.PRIVATECHAT_CLIENT_WHISPER_LANGUAGE,
|
||||||
|
);
|
||||||
|
private transformersModulePromise: Promise<TransformersModule> | null = null;
|
||||||
|
private pipelinePromise: Promise<AutomaticSpeechRecognitionPipeline> | null = null;
|
||||||
|
|
||||||
|
async preload(): Promise<void> {
|
||||||
|
await this.getPipeline();
|
||||||
|
}
|
||||||
|
|
||||||
|
async transcribe(audioBlob: Blob): Promise<string> {
|
||||||
|
if (audioBlob.size === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const waveform = await this.decodeToWhisperWaveform(audioBlob);
|
||||||
|
const transcriber = await this.getPipeline();
|
||||||
|
const inputLanguage = this.session.currentUser()
|
||||||
|
? this.session.dictationLanguage()
|
||||||
|
: this.resolveFallbackInputLanguage();
|
||||||
|
const output = await transcriber(waveform, {
|
||||||
|
chunk_length_s: defaultChunkLengthSeconds,
|
||||||
|
stride_length_s: defaultStrideLengthSeconds,
|
||||||
|
task: 'transcribe',
|
||||||
|
language: whisperLanguageNames[inputLanguage],
|
||||||
|
});
|
||||||
|
const transcription = Array.isArray(output) ? output[0] : output;
|
||||||
|
return transcription.text.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getPipeline(): Promise<AutomaticSpeechRecognitionPipeline> {
|
||||||
|
if (!this.pipelinePromise) {
|
||||||
|
this.pipelinePromise = this.createPreferredPipeline<AutomaticSpeechRecognitionPipeline>(
|
||||||
|
'automatic-speech-recognition',
|
||||||
|
this.modelId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.pipelinePromise!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getTransformersModule(): Promise<TransformersModule> {
|
||||||
|
if (!this.transformersModulePromise) {
|
||||||
|
this.transformersModulePromise = import('@huggingface/transformers') as Promise<TransformersModule>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transformersModule = await this.transformersModulePromise;
|
||||||
|
const onnxWasmEnv = transformersModule.env.backends.onnx.wasm;
|
||||||
|
|
||||||
|
if (onnxWasmEnv && !onnxWasmEnv.wasmPaths) {
|
||||||
|
onnxWasmEnv.wasmPaths =
|
||||||
|
this.runtimeEnv.PRIVATECHAT_CLIENT_TRANSFORMERS_WASM_PATH?.trim() || defaultTransformersWasmPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return transformersModule;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createPreferredPipeline<T>(
|
||||||
|
task: string,
|
||||||
|
model: string,
|
||||||
|
options?: {
|
||||||
|
dtype?: 'fp32';
|
||||||
|
model_file_name?: string;
|
||||||
|
subfolder?: string;
|
||||||
|
},
|
||||||
|
): Promise<T> {
|
||||||
|
const transformersModule = await this.getTransformersModule();
|
||||||
|
const candidateDevices: Array<'webgpu' | 'wasm'> = this.browserSupportsWebGpu()
|
||||||
|
? ['webgpu', 'wasm']
|
||||||
|
: ['wasm'];
|
||||||
|
let lastError: unknown = null;
|
||||||
|
|
||||||
|
for (const device of candidateDevices) {
|
||||||
|
try {
|
||||||
|
const pipeline = await transformersModule.pipeline(task, model, {
|
||||||
|
...options,
|
||||||
|
device,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.info(`[dictation] Loaded ${task} pipeline for ${model} on ${device}.`);
|
||||||
|
return pipeline as T;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
console.warn(`[dictation] Could not load ${task} pipeline for ${model} on ${device}.`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError instanceof Error ? lastError : new Error(`Could not load ${task} pipeline for ${model}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async decodeToWhisperWaveform(audioBlob: Blob): Promise<Float32Array> {
|
||||||
|
const audioContextConstructor = resolveAudioContextConstructor();
|
||||||
|
|
||||||
|
if (!audioContextConstructor) {
|
||||||
|
throw new Error('This browser cannot decode recorded audio for dictation.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrayBuffer = await audioBlob.arrayBuffer();
|
||||||
|
const audioContext = new audioContextConstructor();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer.slice(0));
|
||||||
|
const monoChannel = this.mixToMono(audioBuffer);
|
||||||
|
|
||||||
|
if (audioBuffer.sampleRate === whisperTargetSampleRate) {
|
||||||
|
return monoChannel;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.resampleMonoChannel(monoChannel, audioBuffer.sampleRate, whisperTargetSampleRate);
|
||||||
|
} catch (error) {
|
||||||
|
throw error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error('Could not decode the recorded dictation audio.');
|
||||||
|
} finally {
|
||||||
|
await audioContext.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mixToMono(audioBuffer: AudioBuffer): Float32Array {
|
||||||
|
const mixed = new Float32Array(audioBuffer.length);
|
||||||
|
|
||||||
|
for (let channelIndex = 0; channelIndex < audioBuffer.numberOfChannels; channelIndex += 1) {
|
||||||
|
const channel = audioBuffer.getChannelData(channelIndex);
|
||||||
|
|
||||||
|
for (let sampleIndex = 0; sampleIndex < channel.length; sampleIndex += 1) {
|
||||||
|
mixed[sampleIndex] += channel[sampleIndex];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audioBuffer.numberOfChannels > 1) {
|
||||||
|
for (let sampleIndex = 0; sampleIndex < mixed.length; sampleIndex += 1) {
|
||||||
|
mixed[sampleIndex] /= audioBuffer.numberOfChannels;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resampleMonoChannel(
|
||||||
|
monoChannel: Float32Array,
|
||||||
|
sourceSampleRate: number,
|
||||||
|
targetSampleRate: number,
|
||||||
|
): Float32Array {
|
||||||
|
if (sourceSampleRate === targetSampleRate) {
|
||||||
|
return monoChannel;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetLength = Math.max(1, Math.round(monoChannel.length * targetSampleRate / sourceSampleRate));
|
||||||
|
const resampled = new Float32Array(targetLength);
|
||||||
|
const positionRatio = sourceSampleRate / targetSampleRate;
|
||||||
|
|
||||||
|
for (let sampleIndex = 0; sampleIndex < targetLength; sampleIndex += 1) {
|
||||||
|
const sourcePosition = sampleIndex * positionRatio;
|
||||||
|
const sourceIndex = Math.floor(sourcePosition);
|
||||||
|
const nextSourceIndex = Math.min(sourceIndex + 1, monoChannel.length - 1);
|
||||||
|
const interpolationWeight = sourcePosition - sourceIndex;
|
||||||
|
const currentValue = monoChannel[sourceIndex] ?? 0;
|
||||||
|
const nextValue = monoChannel[nextSourceIndex] ?? currentValue;
|
||||||
|
|
||||||
|
resampled[sampleIndex] = currentValue + ((nextValue - currentValue) * interpolationWeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
return resampled;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeLanguage(language: string | undefined): string | null {
|
||||||
|
const trimmedLanguage = language?.trim();
|
||||||
|
|
||||||
|
if (!trimmedLanguage || trimmedLanguage.toLowerCase() === 'auto') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmedLanguage;
|
||||||
|
}
|
||||||
|
|
||||||
|
private browserSupportsWebGpu(): boolean {
|
||||||
|
return typeof navigator !== 'undefined' && 'gpu' in navigator;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveFallbackInputLanguage(): DictationLanguage {
|
||||||
|
switch (this.fallbackLanguage?.toLowerCase()) {
|
||||||
|
case 'french':
|
||||||
|
case 'fr':
|
||||||
|
return 'fr';
|
||||||
|
case 'spanish':
|
||||||
|
case 'es':
|
||||||
|
return 'es';
|
||||||
|
default:
|
||||||
|
return 'en';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,168 @@
|
|||||||
<main class="chat-shell py-4">
|
<main class="chat-shell py-4">
|
||||||
<div class="container-lg">
|
<div class="container-lg">
|
||||||
<section class="chat-page panel p-3 p-lg-4">
|
<section class="chat-page panel p-3 p-lg-4">
|
||||||
<div class="chat-header d-flex flex-column flex-lg-row justify-content-between align-items-start align-items-lg-center gap-3 mb-4">
|
<app-peer-call-modal
|
||||||
<div>
|
[visible]="callModalVisible()"
|
||||||
<a class="back-link" routerLink="/">← Back to dashboard</a>
|
[peerName]="callModalPeer()?.displayName ?? 'Peer'"
|
||||||
@if (currentUser(); as connectedUser) {
|
[callState]="callModalState()"
|
||||||
<h1 class="h3 mb-1 mt-2">{{ connectedUser.displayName }}</h1>
|
[callMode]="callModalMode()"
|
||||||
<div class="status-indicators mt-2">
|
[statusText]="callModalStatusText()"
|
||||||
|
[localStream]="localCallStream()"
|
||||||
|
[remoteStream]="remoteCallVideoStream()"
|
||||||
|
(acceptRequested)="callModalPeer() && acceptIncomingVoiceCall(callModalPeer()!.id)"
|
||||||
|
(rejectRequested)="callModalPeer() && rejectIncomingVoiceCall(callModalPeer()!.id)"
|
||||||
|
(hangupRequested)="callModalPeer() && endVoiceCall(callModalPeer()!.id)"
|
||||||
|
></app-peer-call-modal>
|
||||||
|
<audio #callAudioElement hidden autoplay playsinline></audio>
|
||||||
|
|
||||||
|
@if (callChoicePeer(); as selectedCallPeer) {
|
||||||
|
<div class="call-choice-backdrop" (click)="closeCallChoice()">
|
||||||
|
<section class="call-choice-card panel p-4" (click)="$event.stopPropagation()">
|
||||||
|
<p class="call-choice-eyebrow">Start a call</p>
|
||||||
|
<h2 class="h5 mb-2">{{ selectedCallPeer.displayName }}</h2>
|
||||||
|
<p class="small mb-3">Choose whether to place a full video call or audio only.</p>
|
||||||
|
<div class="call-choice-actions">
|
||||||
|
<button class="call-choice-button" type="button" (click)="startSelectedCall('video')">
|
||||||
|
<span class="call-choice-icon">📹</span>
|
||||||
|
<span>Video call</span>
|
||||||
|
</button>
|
||||||
|
<button class="call-choice-button" type="button" (click)="startSelectedCall('audio')">
|
||||||
|
<span class="call-choice-icon">🎙️</span>
|
||||||
|
<span>Audio only</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-end mt-3">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" (click)="closeCallChoice()">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (conversationModalOpen()) {
|
||||||
|
<div class="conversation-modal-backdrop" (click)="closeConversationModal()">
|
||||||
|
<section class="conversation-modal panel p-3 p-lg-4" (click)="$event.stopPropagation()">
|
||||||
|
<header class="conversation-modal-header">
|
||||||
|
<div>
|
||||||
|
<p class="conversation-modal-eyebrow mb-1">Fullscreen conversation</p>
|
||||||
|
<h2 class="h5 mb-0">{{ displayedPeer()?.displayName ?? 'Conversation' }}</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="conversation-modal-close"
|
||||||
|
type="button"
|
||||||
|
(click)="closeConversationModal()"
|
||||||
|
aria-label="Close fullscreen conversation"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div #fullscreenConversationContainer class="conversation conversation-modal-body">
|
||||||
|
<ng-container [ngTemplateOutlet]="conversationBubbles"></ng-container>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="chat-header mb-4">
|
||||||
|
@if (currentUser(); as connectedUser) {
|
||||||
|
<div class="chat-header-main">
|
||||||
|
<a class="back-link" routerLink="/" aria-label="Back to dashboard">←</a>
|
||||||
|
<h1 class="chat-header-title mb-0">{{ connectedUser.displayName }}</h1>
|
||||||
|
@if (displayedPeer(); as selectedPeer) {
|
||||||
|
<div class="peer-dropdown" (click)="$event.stopPropagation()">
|
||||||
|
<button
|
||||||
|
class="peer-dropdown-trigger peer-tile"
|
||||||
|
type="button"
|
||||||
|
[class.peer-tile-active]="true"
|
||||||
|
[class.peer-tile-unread]="isPeerUnread(selectedPeer.id)"
|
||||||
|
(click)="togglePeerDropdown()"
|
||||||
|
[attr.aria-expanded]="peerDropdownOpen()"
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-label="Choose peer"
|
||||||
|
>
|
||||||
|
<span class="peer-tile-main text-start">
|
||||||
|
<span class="peer-tile-row">
|
||||||
|
<span class="peer-tile-title">
|
||||||
|
<span class="fw-semibold">{{ selectedPeer.displayName }}</span>
|
||||||
|
@if (isPeerTyping(selectedPeer.id)) {
|
||||||
|
<span class="peer-typing-dots" aria-label="Typing">
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<span class="peer-tile-indicators">
|
||||||
|
<span
|
||||||
|
class="status-led peer-tile-status"
|
||||||
|
[class.status-led-ok]="selectedPeer.channelState === 'open' || selectedPeer.connectionState === 'connected'"
|
||||||
|
[class.status-led-offline]="selectedPeer.channelState !== 'open' && selectedPeer.connectionState !== 'connected'"
|
||||||
|
[attr.aria-label]="
|
||||||
|
selectedPeer.channelState === 'open' || selectedPeer.connectionState === 'connected'
|
||||||
|
? 'Connected'
|
||||||
|
: 'Disconnected'
|
||||||
|
"
|
||||||
|
></span>
|
||||||
|
<span class="peer-dropdown-caret" [class.peer-dropdown-caret-open]="peerDropdownOpen()">▾</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@if (peerDropdownOpen()) {
|
||||||
|
<div class="peer-dropdown-menu" role="listbox">
|
||||||
|
@for (dropdownPeer of dropdownPeers(); track dropdownPeer.id) {
|
||||||
|
<article
|
||||||
|
class="peer-tile"
|
||||||
|
[class.peer-tile-active]="dropdownPeer.id === peerId()"
|
||||||
|
[class.peer-tile-unread]="isPeerUnread(dropdownPeer.id)"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="peer-tile-main text-start"
|
||||||
|
type="button"
|
||||||
|
(click)="selectPeerFromDropdown(dropdownPeer.id)"
|
||||||
|
>
|
||||||
|
<div class="peer-tile-row">
|
||||||
|
<span class="peer-tile-title">
|
||||||
|
<span class="fw-semibold">{{ dropdownPeer.displayName }}</span>
|
||||||
|
@if (isPeerTyping(dropdownPeer.id)) {
|
||||||
|
<span class="peer-typing-dots" aria-label="Typing">
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="status-led peer-tile-status"
|
||||||
|
[class.status-led-ok]="dropdownPeer.channelState === 'open' || dropdownPeer.connectionState === 'connected'"
|
||||||
|
[class.status-led-offline]="dropdownPeer.channelState !== 'open' && dropdownPeer.connectionState !== 'connected'"
|
||||||
|
[attr.aria-label]="
|
||||||
|
dropdownPeer.channelState === 'open' || dropdownPeer.connectionState === 'connected'
|
||||||
|
? 'Connected'
|
||||||
|
: 'Disconnected'
|
||||||
|
"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="peer-tile-delete"
|
||||||
|
type="button"
|
||||||
|
title="Delete conversation"
|
||||||
|
aria-label="Delete conversation"
|
||||||
|
(click)="deleteConversation(dropdownPeer.id, $event)"
|
||||||
|
>
|
||||||
|
🗑️
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="status-indicators">
|
||||||
<div class="status-indicator">
|
<div class="status-indicator">
|
||||||
<span class="status-led" [class.status-led-ok]="indicatorTone(session.signalingState()) === 'ok'" [class.status-led-connecting]="indicatorTone(session.signalingState()) === 'connecting'" [class.status-led-offline]="indicatorTone(session.signalingState()) === 'offline'"></span>
|
<span class="status-led" [class.status-led-ok]="indicatorTone(session.signalingState()) === 'ok'" [class.status-led-connecting]="indicatorTone(session.signalingState()) === 'connecting'" [class.status-led-offline]="indicatorTone(session.signalingState()) === 'offline'"></span>
|
||||||
<span>Signaling</span>
|
<span>Signaling</span>
|
||||||
@@ -22,168 +178,344 @@
|
|||||||
<span class="status-led" [class.status-led-ok]="indicatorTone(webRtcState()) === 'ok'" [class.status-led-connecting]="indicatorTone(webRtcState()) === 'connecting'" [class.status-led-offline]="indicatorTone(webRtcState()) === 'offline'"></span>
|
<span class="status-led" [class.status-led-ok]="indicatorTone(webRtcState()) === 'ok'" [class.status-led-connecting]="indicatorTone(webRtcState()) === 'connecting'" [class.status-led-offline]="indicatorTone(webRtcState()) === 'offline'"></span>
|
||||||
<span>WebRTC</span>
|
<span>WebRTC</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="status-indicator status-indicator-action"
|
||||||
|
type="button"
|
||||||
|
[disabled]="conversation().length === 0"
|
||||||
|
aria-label="Open fullscreen conversation"
|
||||||
|
title="Open fullscreen conversation"
|
||||||
|
(click)="openConversationModal()"
|
||||||
|
>
|
||||||
|
<span class="expand-action-icon" aria-hidden="true">⤢</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
</div>
|
||||||
<h1 class="h3 mb-1 mt-2">Not signed in</h1>
|
} @else {
|
||||||
<p class="small text-secondary mb-0">Return to the dashboard and sign in again.</p>
|
<div class="chat-header-main">
|
||||||
}
|
<a class="back-link" routerLink="/" aria-label="Back to dashboard">←</a>
|
||||||
</div>
|
<h1 class="chat-header-title mb-0">Not signed in</h1>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="chat-layout">
|
<div class="chat-layout">
|
||||||
<aside class="peer-sidebar">
|
<div class="chat-main" (click)="closePeerDropdown()">
|
||||||
|
<div #conversationContainer class="conversation">
|
||||||
|
<ng-container [ngTemplateOutlet]="conversationBubbles"></ng-container>
|
||||||
<div class="peer-list">
|
|
||||||
@if (session.peers().length === 0) {
|
|
||||||
<div class="empty-chat empty-peers">
|
|
||||||
No peers are currently connected.
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@for (connectedPeer of session.peers(); track connectedPeer.id) {
|
|
||||||
<button
|
|
||||||
class="peer-tile text-start"
|
|
||||||
type="button"
|
|
||||||
[class.peer-tile-active]="connectedPeer.id === peerId()"
|
|
||||||
(click)="switchPeer(connectedPeer.id)"
|
|
||||||
>
|
|
||||||
<div class="peer-tile-row">
|
|
||||||
<span class="peer-tile-title">
|
|
||||||
<span class="fw-semibold">{{ connectedPeer.displayName }}</span>
|
|
||||||
@if (isPeerTyping(connectedPeer.id)) {
|
|
||||||
<span class="peer-typing-dots" aria-label="Typing">
|
|
||||||
<span></span>
|
|
||||||
<span></span>
|
|
||||||
<span></span>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="status-led peer-tile-status"
|
|
||||||
[class.status-led-ok]="connectedPeer.channelState === 'open' || connectedPeer.connectionState === 'connected'"
|
|
||||||
[class.status-led-offline]="connectedPeer.channelState !== 'open' && connectedPeer.connectionState !== 'connected'"
|
|
||||||
[attr.aria-label]="
|
|
||||||
connectedPeer.channelState === 'open' || connectedPeer.connectionState === 'connected'
|
|
||||||
? 'Connected'
|
|
||||||
: 'Disconnected'
|
|
||||||
"
|
|
||||||
></span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<div class="chat-main">
|
|
||||||
<div class="conversation">
|
|
||||||
@if (conversation().length === 0) {
|
|
||||||
<div class="empty-chat">
|
|
||||||
No text messages yet. The chat page is ready as soon as the peer channel opens.
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@for (entry of conversation(); track entry.id) {
|
|
||||||
<article
|
|
||||||
class="bubble"
|
|
||||||
[class.bubble-incoming]="entry.direction === 'incoming'"
|
|
||||||
[class.bubble-outgoing]="entry.direction === 'outgoing'"
|
|
||||||
[class.bubble-system]="entry.direction === 'system'"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="bubble-delete"
|
|
||||||
type="button"
|
|
||||||
(click)="deleteMessage(entry)"
|
|
||||||
title="Delete message"
|
|
||||||
aria-label="Delete message"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
<div class="bubble-meta">
|
|
||||||
<span>{{ entry.authorLabel }}</span>
|
|
||||||
<time>{{ entry.createdAt | date: 'shortTime' }}</time>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@switch (entry.kind) {
|
|
||||||
@case ('text') {
|
|
||||||
<p class="mb-0">{{ entry.text }}</p>
|
|
||||||
}
|
|
||||||
@case ('json') {
|
|
||||||
<pre class="bubble-json mb-0">{{ entry.payload | json }}</pre>
|
|
||||||
}
|
|
||||||
@case ('file') {
|
|
||||||
<div class="d-grid gap-3">
|
|
||||||
@if (isImageEntry(entry)) {
|
|
||||||
<img
|
|
||||||
class="bubble-image"
|
|
||||||
[src]="entry.downloadUrl"
|
|
||||||
[alt]="entry.fileName || 'Shared image'"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div class="fw-semibold">{{ entry.fileName }}</div>
|
|
||||||
@if (entry.fileSize) {
|
|
||||||
<div class="small text-secondary-emphasis">{{ entry.fileSize | number }} bytes</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (entry.downloadUrl) {
|
|
||||||
<a class="bubble-download" [href]="entry.downloadUrl" [download]="entry.fileName">Download</a>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@default {
|
|
||||||
<p class="mb-0">{{ entry.text }}</p>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</article>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="composer">
|
<div class="composer">
|
||||||
@if (peer(); as selectedPeer) {
|
|
||||||
<input
|
|
||||||
#fileInput
|
|
||||||
class="composer-file-input"
|
|
||||||
type="file"
|
|
||||||
[disabled]="selectedPeer.channelState !== 'open'"
|
|
||||||
(change)="sendFile(selectedPeer.id, fileInput)"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
class="composer-plus"
|
|
||||||
type="button"
|
|
||||||
[disabled]="selectedPeer.channelState !== 'open'"
|
|
||||||
(click)="fileInput.click()"
|
|
||||||
title="Send file"
|
|
||||||
aria-label="Send file"
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
|
#composerTextarea
|
||||||
class="form-control composer-textarea"
|
class="form-control composer-textarea"
|
||||||
rows="3"
|
rows="2"
|
||||||
[(ngModel)]="messageText"
|
[(ngModel)]="messageText"
|
||||||
(ngModelChange)="handleMessageTextChange($event)"
|
(ngModelChange)="handleMessageTextChange($event)"
|
||||||
(keydown.enter)="handleComposerEnter($event)"
|
(keydown.enter)="handleComposerEnter($event)"
|
||||||
[disabled]="!session.isSelectedPeerReady()"
|
(click)="trackComposerSelection(composerTextarea)"
|
||||||
placeholder="Write a text message to your peer"
|
(keyup)="trackComposerSelection(composerTextarea)"
|
||||||
|
(select)="trackComposerSelection(composerTextarea)"
|
||||||
|
[disabled]="!peerId()"
|
||||||
|
placeholder="Write a text message to your peer, even if they are offline"
|
||||||
></textarea>
|
></textarea>
|
||||||
<button
|
|
||||||
class="send-emoji"
|
<div class="composer-toolbar">
|
||||||
type="button"
|
<div class="composer-actions">
|
||||||
[disabled]="!session.isSelectedPeerReady()"
|
@if (peerId(); as selectedPeerId) {
|
||||||
(click)="sendMessage()"
|
@if (peer(); as livePeer) {
|
||||||
title="Send message"
|
<button
|
||||||
aria-label="Send message"
|
class="composer-call"
|
||||||
>
|
type="button"
|
||||||
✅
|
[disabled]="!canStartSelectedVoiceCall()"
|
||||||
</button>
|
(click)="openCallChoice(livePeer.id)"
|
||||||
|
title="Start call"
|
||||||
|
aria-label="Start call"
|
||||||
|
>
|
||||||
|
📞
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@if (canEndSelectedVoiceCall()) {
|
||||||
|
<button
|
||||||
|
class="composer-hangup"
|
||||||
|
type="button"
|
||||||
|
(click)="endVoiceCall(livePeer.id)"
|
||||||
|
title="End call"
|
||||||
|
aria-label="End call"
|
||||||
|
>
|
||||||
|
🛑
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="composer-voice"
|
||||||
|
type="button"
|
||||||
|
[disabled]="livePeer.channelState !== 'open' && !isRecordingVoice()"
|
||||||
|
(click)="toggleVoiceRecording()"
|
||||||
|
[title]="isRecordingVoice() ? 'Stop and send voice message' : 'Record voice message'"
|
||||||
|
[attr.aria-label]="isRecordingVoice() ? 'Stop and send voice message' : 'Record voice message'"
|
||||||
|
[class.composer-voice-recording]="isRecordingVoice()"
|
||||||
|
>
|
||||||
|
{{ isRecordingVoice() ? '⏹️' : '🎙️' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="composer-dictation"
|
||||||
|
type="button"
|
||||||
|
[disabled]="!selectedPeerId || isTranscribingDictation()"
|
||||||
|
(click)="toggleDictation(composerTextarea)"
|
||||||
|
[title]="
|
||||||
|
isDictating()
|
||||||
|
? 'Stop dictation and transcribe'
|
||||||
|
: isTranscribingDictation()
|
||||||
|
? 'Transcribing dictated audio'
|
||||||
|
: 'Start dictation'
|
||||||
|
"
|
||||||
|
[attr.aria-label]="
|
||||||
|
isDictating()
|
||||||
|
? 'Stop dictation and transcribe'
|
||||||
|
: isTranscribingDictation()
|
||||||
|
? 'Transcribing dictated audio'
|
||||||
|
: 'Start dictation'
|
||||||
|
"
|
||||||
|
[class.composer-dictation-active]="isDictating() || isTranscribingDictation()"
|
||||||
|
>
|
||||||
|
{{ isDictating() ? '🛑' : isTranscribingDictation() ? '⏳' : '🗣️' }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
<input
|
||||||
|
#fileInput
|
||||||
|
class="composer-file-input"
|
||||||
|
type="file"
|
||||||
|
[disabled]="!selectedPeerId"
|
||||||
|
(change)="sendFile(selectedPeerId, fileInput)"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="composer-plus"
|
||||||
|
type="button"
|
||||||
|
[disabled]="!selectedPeerId"
|
||||||
|
(click)="fileInput.click()"
|
||||||
|
title="Send file"
|
||||||
|
aria-label="Send file"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="composer-image-generate"
|
||||||
|
type="button"
|
||||||
|
[disabled]="!peer() || session.signalingState() !== 'connected' || !messageText.trim()"
|
||||||
|
(click)="requestGeneratedImage()"
|
||||||
|
title="Generate image from prompt"
|
||||||
|
aria-label="Generate image from prompt"
|
||||||
|
>
|
||||||
|
🖼️
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="composer-emoji-picker-shell">
|
||||||
|
@if (emojiPickerOpen()) {
|
||||||
|
<div class="composer-emoji-picker">
|
||||||
|
@for (emoji of emojiOptions; track emoji) {
|
||||||
|
<button
|
||||||
|
class="composer-emoji-option"
|
||||||
|
type="button"
|
||||||
|
(click)="insertEmoji(emoji, composerTextarea)"
|
||||||
|
[attr.aria-label]="'Insert ' + emoji"
|
||||||
|
[title]="'Insert ' + emoji"
|
||||||
|
>
|
||||||
|
{{ emoji }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<button
|
||||||
|
class="composer-emoji-trigger"
|
||||||
|
type="button"
|
||||||
|
[disabled]="!peerId()"
|
||||||
|
(click)="toggleEmojiPicker($event)"
|
||||||
|
title="Insert emoji"
|
||||||
|
aria-label="Insert emoji"
|
||||||
|
>
|
||||||
|
😀
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="send-emoji"
|
||||||
|
type="button"
|
||||||
|
[disabled]="!peerId()"
|
||||||
|
(click)="sendMessage()"
|
||||||
|
title="Send message"
|
||||||
|
aria-label="Send message"
|
||||||
|
>
|
||||||
|
✅
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (lastIncomingReceiveMetric(); as receiveMetric) {
|
||||||
|
<div class="composer-receive-speed" title="Receive speed of the last completed incoming WebRTC message">
|
||||||
|
<span class="composer-receive-speed-label">Rx</span>
|
||||||
|
<span class="composer-receive-speed-value">{{ receiveMetric.mbps | number: '1.2-2' }} Mbit/s</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<ng-template #conversationBubbles>
|
||||||
|
@if (conversation().length === 0) {
|
||||||
|
<div class="empty-chat">
|
||||||
|
No text messages yet. Messages and files can be queued here and will send when the peer reconnects.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@for (entry of conversation(); track entry.id) {
|
||||||
|
<article
|
||||||
|
class="bubble"
|
||||||
|
[class.bubble-incoming]="entry.direction === 'incoming'"
|
||||||
|
[class.bubble-outgoing]="entry.direction === 'outgoing'"
|
||||||
|
[class.bubble-pending]="isPendingOutgoingEntry(entry)"
|
||||||
|
[class.bubble-system]="entry.direction === 'system'"
|
||||||
|
[class.bubble-emoji-only]="isEmojiOnlyEntry(entry)"
|
||||||
|
>
|
||||||
|
@if (entry.direction !== 'system' && !isEmojiOnlyEntry(entry)) {
|
||||||
|
<div class="bubble-actions">
|
||||||
|
@if (isGeneratedImageEntry(entry)) {
|
||||||
|
<button
|
||||||
|
class="bubble-action"
|
||||||
|
type="button"
|
||||||
|
(click)="sendGeneratedImage(entry)"
|
||||||
|
title="Send image to peer"
|
||||||
|
aria-label="Send image to peer"
|
||||||
|
>
|
||||||
|
📤
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<button
|
||||||
|
class="bubble-action"
|
||||||
|
type="button"
|
||||||
|
(click)="toggleForwardMenu(entry, $event)"
|
||||||
|
title="Forward message"
|
||||||
|
aria-label="Forward message"
|
||||||
|
>
|
||||||
|
⏩
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="bubble-action bubble-delete"
|
||||||
|
type="button"
|
||||||
|
(click)="deleteMessage(entry)"
|
||||||
|
title="Delete message"
|
||||||
|
aria-label="Delete message"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
@if (isForwardMenuOpen(entry.id)) {
|
||||||
|
<div class="bubble-forward-menu">
|
||||||
|
<select #forwardSelect class="bubble-forward-select" (change)="forwardEntry(entry, forwardSelect.value, forwardSelect)">
|
||||||
|
<option value="">Forward to…</option>
|
||||||
|
@for (targetPeer of forwardTargets(entry); track targetPeer.id) {
|
||||||
|
<option [value]="targetPeer.id">{{ targetPeer.displayName }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (!isEmojiOnlyEntry(entry)) {
|
||||||
|
<div class="bubble-meta">
|
||||||
|
<span class="bubble-author">{{ entry.authorLabel }}</span>
|
||||||
|
<time class="bubble-time">{{ entry.createdAt | date: 'shortTime' }}</time>
|
||||||
|
@if (isPendingOutgoingEntry(entry)) {
|
||||||
|
<span class="bubble-delivery-state">Queued</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@switch (entry.kind) {
|
||||||
|
@case ('text') {
|
||||||
|
<p class="mb-0" [class.emoji-only-text]="isEmojiOnlyEntry(entry)">{{ entry.text }}</p>
|
||||||
|
}
|
||||||
|
@case ('json') {
|
||||||
|
<pre class="bubble-json mb-0">{{ entry.payload | json }}</pre>
|
||||||
|
}
|
||||||
|
@case ('file') {
|
||||||
|
<div class="d-grid gap-3">
|
||||||
|
@if (isImageEntry(entry)) {
|
||||||
|
<img
|
||||||
|
class="bubble-image"
|
||||||
|
[src]="imageDisplayUrl(entry)"
|
||||||
|
[alt]="entry.fileName || 'Shared image'"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (isVideoEntry(entry)) {
|
||||||
|
<video
|
||||||
|
class="bubble-video"
|
||||||
|
[src]="entry.downloadUrl"
|
||||||
|
controls
|
||||||
|
autoplay
|
||||||
|
muted
|
||||||
|
playsinline
|
||||||
|
preload="metadata"
|
||||||
|
></video>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (isIncomingJsonFileEntry(entry)) {
|
||||||
|
<app-json-file-viewer [entry]="entry"></app-json-file-viewer>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="fw-semibold">{{ entry.fileName }}</div>
|
||||||
|
@if (entry.fileSize) {
|
||||||
|
<div class="small text-secondary-emphasis">{{ entry.fileSize | number }} bytes</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (entry.downloadUrl) {
|
||||||
|
<a class="bubble-download" [href]="entry.downloadUrl" [download]="entry.fileName">Download</a>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (hasDocumentPreviewImage(entry)) {
|
||||||
|
<div class="bubble-preview">
|
||||||
|
<div class="bubble-preview-label">Preview</div>
|
||||||
|
<img
|
||||||
|
class="bubble-preview-image"
|
||||||
|
[src]="documentPreviewImageUrl(entry)"
|
||||||
|
[alt]="entry.fileName || 'Document preview'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@case ('voice') {
|
||||||
|
<div class="voice-bubble">
|
||||||
|
<div class="voice-bubble-label">Voice message</div>
|
||||||
|
@if (entry.downloadUrl) {
|
||||||
|
<audio
|
||||||
|
class="voice-player"
|
||||||
|
[src]="entry.downloadUrl"
|
||||||
|
controls
|
||||||
|
preload="metadata"
|
||||||
|
></audio>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@default {
|
||||||
|
@if (entry.showSpinner) {
|
||||||
|
<div class="bubble-system-status">
|
||||||
|
<span class="bubble-spinner" aria-hidden="true"></span>
|
||||||
|
<p class="mb-0">{{ entry.text }}</p>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<p class="mb-0">{{ entry.text }}</p>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</ng-template>
|
||||||
|
|||||||
@@ -8,249 +8,309 @@
|
|||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Panel ──────────────────────────────────────────────────────────────── */
|
||||||
.panel {
|
.panel {
|
||||||
border: 1px solid var(--surface-border);
|
border: 1px solid var(--surface-border);
|
||||||
border-radius: 1.75rem;
|
border-radius: 2rem;
|
||||||
background: var(--panel-background);
|
background: var(--panel-background);
|
||||||
backdrop-filter: blur(18px);
|
backdrop-filter: blur(22px);
|
||||||
box-shadow: 0 20px 60px var(--shadow-color);
|
-webkit-backdrop-filter: blur(22px);
|
||||||
|
box-shadow:
|
||||||
|
0 28px 72px var(--shadow-color),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.07);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Chat page shell ────────────────────────────────────────────────────── */
|
||||||
|
.chat-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: min(96vw, 98%);
|
||||||
|
height: min(calc(100dvh - 2rem), 1024px);
|
||||||
|
max-height: 1024px;
|
||||||
|
margin-inline: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header ─────────────────────────────────────────────────────────────── */
|
||||||
|
.chat-header { flex: 0 0 auto; }
|
||||||
|
|
||||||
|
.chat-header-main {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(1.1rem, 2vw, 1.45rem);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.1;
|
||||||
|
background: linear-gradient(135deg, var(--page-text) 40%, var(--accent-color) 100%);
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Back link ───────────────────────────────────────────────────────────── */
|
||||||
.back-link {
|
.back-link {
|
||||||
color: var(--link-color);
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--page-text-muted);
|
||||||
|
background: var(--badge-background);
|
||||||
|
border: 1px solid var(--surface-border-soft);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
line-height: 1;
|
||||||
|
transition: color 160ms ease, background 160ms ease, border-color 160ms ease, transform 160ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.back-link:hover,
|
||||||
|
.back-link:focus-visible {
|
||||||
|
color: var(--accent-color);
|
||||||
|
background: var(--accent-color-soft);
|
||||||
|
border-color: color-mix(in srgb, var(--accent-color) 35%, transparent);
|
||||||
|
transform: translateX(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Status indicators ───────────────────────────────────────────────────── */
|
||||||
.status-indicators {
|
.status-indicators {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.9rem;
|
gap: 0.5rem;
|
||||||
|
margin-left: auto;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-indicator {
|
.status-indicator {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.45rem;
|
gap: 0.42rem;
|
||||||
font-size: 0.9rem;
|
padding: 0.32rem 0.75rem;
|
||||||
color: var(--page-text-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-indicator-action {
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-indicator-action:not(:disabled) {
|
|
||||||
color: var(--page-text);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-indicator-action:not(:disabled):hover,
|
|
||||||
.status-indicator-action:not(:disabled):focus-visible {
|
|
||||||
color: var(--accent-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-indicator-action:disabled {
|
|
||||||
cursor: default;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-led {
|
|
||||||
width: 0.8rem;
|
|
||||||
height: 0.8rem;
|
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
box-shadow: 0 0 0 1px var(--input-border);
|
font-size: 0.72rem;
|
||||||
}
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 500;
|
||||||
.status-led-ok {
|
letter-spacing: 0.08em;
|
||||||
background: #59d66f;
|
text-transform: uppercase;
|
||||||
}
|
color: var(--page-text-soft);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
.status-led-connecting {
|
|
||||||
background: #f3ad3d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-led-offline {
|
|
||||||
background: #eb5d64;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-layout {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(15rem, 19rem) minmax(0, 1fr);
|
|
||||||
gap: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.peer-sidebar {
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 1.3rem;
|
|
||||||
border: 1px solid var(--surface-border-soft);
|
border: 1px solid var(--surface-border-soft);
|
||||||
background: var(--panel-soft-background);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.peer-count {
|
.expand-action-icon {
|
||||||
display: inline-flex;
|
font-size: 1.25rem;
|
||||||
min-width: 2rem;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0.35rem 0.65rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
background: var(--badge-background);
|
|
||||||
}
|
|
||||||
|
|
||||||
.peer-list {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.75rem;
|
|
||||||
max-height: calc(100dvh - 17rem);
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.peer-tile {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.95rem 1rem;
|
|
||||||
border: 1px solid var(--surface-border);
|
|
||||||
border-radius: 1rem;
|
|
||||||
color: inherit;
|
|
||||||
background: var(--surface-background);
|
|
||||||
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.peer-tile:hover,
|
|
||||||
.peer-tile:focus-visible,
|
|
||||||
.peer-tile-active {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
border-color: color-mix(in srgb, var(--accent-color) 35%, transparent);
|
|
||||||
background: var(--surface-hover-background);
|
|
||||||
}
|
|
||||||
|
|
||||||
.peer-tile-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.peer-tile-title {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.45rem;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.peer-typing-dots {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.2rem;
|
|
||||||
min-height: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.peer-typing-dots span {
|
|
||||||
width: 0.38rem;
|
|
||||||
height: 0.38rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--page-text);
|
|
||||||
opacity: 0.28;
|
|
||||||
animation: peer-typing-pulse 900ms infinite ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.peer-typing-dots span:nth-child(2) {
|
|
||||||
animation-delay: 120ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
.peer-typing-dots span:nth-child(3) {
|
|
||||||
animation-delay: 240ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
.peer-tile-status {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-main {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.conversation {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.85rem;
|
|
||||||
align-content: start;
|
|
||||||
min-height: 24rem;
|
|
||||||
max-height: calc(100dvh - 20rem);
|
|
||||||
overflow: auto;
|
|
||||||
padding: 0.5rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bubble {
|
|
||||||
position: relative;
|
|
||||||
align-self: start;
|
|
||||||
max-width: min(75%, 34rem);
|
|
||||||
padding: 0.9rem 1rem;
|
|
||||||
border-radius: 1.2rem;
|
|
||||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.14);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bubble-delete {
|
|
||||||
position: absolute;
|
|
||||||
top: 0.45rem;
|
|
||||||
right: 0.55rem;
|
|
||||||
width: 1.5rem;
|
|
||||||
height: 1.5rem;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 999px;
|
|
||||||
color: #fff;
|
|
||||||
background: var(--danger-background);
|
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bubble-incoming {
|
|
||||||
justify-self: start;
|
|
||||||
color: var(--incoming-bubble-text);
|
|
||||||
background: var(--incoming-bubble-background);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bubble-outgoing {
|
|
||||||
justify-self: end;
|
|
||||||
color: var(--outgoing-bubble-text);
|
|
||||||
background: var(--outgoing-bubble-background);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bubble-system {
|
|
||||||
justify-self: center;
|
|
||||||
max-width: 90%;
|
|
||||||
color: var(--page-text-soft);
|
|
||||||
background: var(--badge-background);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bubble-meta {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
margin-bottom: 0.35rem;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.composer {
|
/* ── Layout ──────────────────────────────────────────────────────────────── */
|
||||||
|
.chat-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
flex: 1 1 auto;
|
||||||
gap: 0.9rem;
|
grid-template-columns: minmax(0, 1fr);
|
||||||
align-items: end;
|
gap: 0;
|
||||||
padding-top: 1rem;
|
min-height: 0;
|
||||||
margin-top: 1rem;
|
|
||||||
border-top: 1px solid var(--surface-border-soft);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.composer-file-input {
|
.chat-main {
|
||||||
display: none;
|
display: grid;
|
||||||
|
grid-template-rows: minmax(0, 1fr) auto;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.composer-plus,
|
/* ── Conversation scroll area ────────────────────────────────────────────── */
|
||||||
.send-emoji {
|
.conversation {
|
||||||
width: 3.25rem;
|
display: grid;
|
||||||
height: 3.25rem;
|
gap: 0.75rem;
|
||||||
|
align-content: start;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 0.75rem 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Bubble ──────────────────────────────────────────────────────────────── */
|
||||||
|
.bubble {
|
||||||
|
position: relative;
|
||||||
|
align-self: start;
|
||||||
|
max-width: min(78%, 36rem);
|
||||||
|
padding: 0.85rem 3.25rem 0.85rem 1rem;
|
||||||
|
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-actions {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.45rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.28rem;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble:hover .bubble-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-action {
|
||||||
|
width: 1.45rem;
|
||||||
|
height: 1.45rem;
|
||||||
|
padding: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-size: 1.35rem;
|
color: var(--page-text-muted);
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
line-height: 1;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
transition: background 120ms ease, color 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-action:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.18);
|
||||||
|
color: var(--page-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-delete {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-delete:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.35);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-forward-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: 1.9rem;
|
||||||
|
right: 0;
|
||||||
|
z-index: 2;
|
||||||
|
min-width: 12rem;
|
||||||
|
padding: 0.45rem;
|
||||||
|
border: 1px solid var(--surface-border);
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
background: var(--panel-background);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-forward-select {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--input-border);
|
||||||
|
border-radius: 0.65rem;
|
||||||
|
color: var(--page-text);
|
||||||
|
background: var(--input-background);
|
||||||
|
padding: 0.35rem 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-only-text {
|
||||||
|
font-size: clamp(2.1rem, 5vw, 3.4rem);
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Bubble: image / video / download ───────────────────────────────────── */
|
||||||
|
.bubble-image,
|
||||||
|
.bubble-video {
|
||||||
|
width: 200px;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
border-radius: 1rem;
|
||||||
|
border: 1px solid var(--surface-border-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-video { background: #000; }
|
||||||
|
|
||||||
|
.bubble-download {
|
||||||
|
color: var(--accent-color);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-download:hover { color: var(--link-color); }
|
||||||
|
|
||||||
|
.bubble-preview { display: grid; gap: 0.45rem; }
|
||||||
|
|
||||||
|
.bubble-preview-label {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-preview-image {
|
||||||
|
display: block;
|
||||||
|
width: min(240px, 100%);
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border: 1px solid var(--surface-border);
|
||||||
|
border-radius: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Voice bubble ────────────────────────────────────────────────────────── */
|
||||||
|
.voice-bubble { display: grid; gap: 0.6rem; }
|
||||||
|
|
||||||
|
.voice-bubble-label {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
opacity: 0.7;
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-player {
|
||||||
|
display: block;
|
||||||
|
width: min(100%, 18rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-json {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Bubble metadata ─────────────────────────────────────────────────────── */
|
||||||
|
.bubble-author { font-weight: 600; }
|
||||||
|
|
||||||
|
/* ── Empty state ─────────────────────────────────────────────────────────── */
|
||||||
|
.empty-chat {
|
||||||
|
padding: 1.5rem;
|
||||||
|
border: 1px dashed var(--surface-border);
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
color: var(--page-text-muted);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-peers { min-height: 10rem; }
|
||||||
|
|
||||||
|
/* ── Composer ────────────────────────────────────────────────────────────── */
|
||||||
|
.composer {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 1rem 0 0;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
border-top: 1px solid var(--surface-border-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.composer-textarea,
|
.composer-textarea,
|
||||||
@@ -258,82 +318,255 @@
|
|||||||
color: var(--page-text);
|
color: var(--page-text);
|
||||||
background-color: var(--input-background);
|
background-color: var(--input-background);
|
||||||
border-color: var(--input-border);
|
border-color: var(--input-border);
|
||||||
|
border-radius: 1rem;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
|
transition: border-color 160ms ease, box-shadow 160ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.composer-textarea::placeholder {
|
.composer-textarea:focus {
|
||||||
color: var(--placeholder-color);
|
border-color: color-mix(in srgb, var(--accent-color) 50%, transparent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-color-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-textarea::placeholder { color: var(--placeholder-color); }
|
||||||
|
|
||||||
|
.composer-textarea {
|
||||||
|
min-height: calc(2 * 1.5rem + 1.25rem);
|
||||||
|
max-height: calc(6 * 1.5rem + 1.25rem);
|
||||||
|
overflow-y: auto;
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Composer toolbar ────────────────────────────────────────────────────── */
|
||||||
|
.composer-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.55rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-actions {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.45rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* All composer icon buttons share base sizing */
|
||||||
|
.composer-camera,
|
||||||
|
.composer-call,
|
||||||
|
.composer-dictation,
|
||||||
|
.composer-hangup,
|
||||||
|
.composer-voice,
|
||||||
|
.composer-image-generate,
|
||||||
|
.composer-emoji-trigger,
|
||||||
|
.composer-plus,
|
||||||
|
.send-emoji {
|
||||||
|
width: 2.85rem;
|
||||||
|
height: 2.85rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
transition: transform 160ms ease, opacity 160ms ease, box-shadow 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-camera:hover,
|
||||||
|
.composer-call:hover,
|
||||||
|
.composer-dictation:hover,
|
||||||
|
.composer-voice:hover,
|
||||||
|
.composer-image-generate:hover,
|
||||||
|
.composer-emoji-trigger:hover,
|
||||||
|
.composer-plus:hover,
|
||||||
|
.send-emoji:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(
|
||||||
|
.composer-camera,
|
||||||
|
.composer-call,
|
||||||
|
.composer-dictation,
|
||||||
|
.composer-hangup,
|
||||||
|
.composer-voice,
|
||||||
|
.composer-image-generate,
|
||||||
|
.composer-emoji-trigger,
|
||||||
|
.composer-plus,
|
||||||
|
.send-emoji
|
||||||
|
):disabled {
|
||||||
|
opacity: 0.38;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-file-input { display: none; }
|
||||||
|
|
||||||
|
/* Individual button colors */
|
||||||
|
.composer-call {
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, #059669, #10b981);
|
||||||
|
box-shadow: 0 4px 16px rgba(16, 185, 129, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-hangup {
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, #dc2626, #ef4444);
|
||||||
|
box-shadow: 0 4px 16px rgba(220, 38, 38, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-voice {
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, #d97706, #f59e0b);
|
||||||
|
box-shadow: 0 4px 16px rgba(245, 158, 11, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-voice-recording {
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, #db2777, #ec4899) !important;
|
||||||
|
box-shadow: 0 0 0 3px rgba(236, 72, 153, 0.2), 0 4px 16px rgba(219, 39, 119, 0.35) !important;
|
||||||
|
animation: voice-pulse 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes voice-pulse {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 3px rgba(236, 72, 153, 0.2); }
|
||||||
|
50% { box-shadow: 0 0 0 6px rgba(236, 72, 153, 0.08); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-dictation {
|
||||||
|
color: #1a1a1a;
|
||||||
|
background: linear-gradient(135deg, #fbbf24, #fde68a);
|
||||||
|
box-shadow: 0 4px 16px rgba(251, 191, 36, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-dictation-active {
|
||||||
|
color: #fff !important;
|
||||||
|
background: linear-gradient(135deg, #db2777, #ec4899) !important;
|
||||||
|
box-shadow: 0 4px 16px rgba(219, 39, 119, 0.3) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.composer-plus {
|
.composer-plus {
|
||||||
color: var(--page-text);
|
color: #fff;
|
||||||
background: var(--badge-background);
|
background: linear-gradient(135deg, #059669, #34d399);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: 0 4px 16px rgba(5, 150, 105, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-image-generate {
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, #7c3aed, #a78bfa);
|
||||||
|
box-shadow: 0 4px 16px rgba(124, 58, 237, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-emoji-trigger {
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, #4f46e5, #818cf8);
|
||||||
|
box-shadow: 0 4px 16px rgba(79, 70, 229, 0.28);
|
||||||
}
|
}
|
||||||
|
|
||||||
.send-emoji {
|
.send-emoji {
|
||||||
background: linear-gradient(135deg, #def7dd, #9bd5ff);
|
color: #fff;
|
||||||
|
background: var(--accent-gradient);
|
||||||
|
box-shadow: 0 4px 20px rgba(13, 148, 136, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble-image {
|
.send-emoji:not(:disabled):hover {
|
||||||
width: 200px;
|
background: var(--accent-gradient-hover);
|
||||||
max-width: 100%;
|
box-shadow: 0 6px 24px rgba(13, 148, 136, 0.5);
|
||||||
height: auto;
|
|
||||||
border-radius: 1rem;
|
|
||||||
display: block;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble-download {
|
/* ── Receive speed ───────────────────────────────────────────────────────── */
|
||||||
color: inherit;
|
.composer-receive-speed {
|
||||||
font-weight: 600;
|
display: inline-flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.45rem;
|
||||||
|
margin-left: auto;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--page-text-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble-json {
|
.composer-receive-speed-label {
|
||||||
white-space: pre-wrap;
|
font-size: 0.68rem;
|
||||||
word-break: break-word;
|
letter-spacing: 0.14em;
|
||||||
margin: 0;
|
text-transform: uppercase;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--accent-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-chat {
|
.composer-receive-speed-value {
|
||||||
padding: 1.25rem;
|
font-size: 0.88rem;
|
||||||
border: 1px dashed var(--input-border);
|
font-variant-numeric: tabular-nums;
|
||||||
border-radius: 1rem;
|
font-family: var(--font-mono);
|
||||||
color: var(--page-text-muted);
|
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-peers {
|
/* ── Emoji picker ────────────────────────────────────────────────────────── */
|
||||||
min-height: 10rem;
|
.composer-emoji-picker-shell { position: relative; }
|
||||||
|
|
||||||
|
.composer-emoji-picker {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: calc(100% + 0.65rem);
|
||||||
|
z-index: 3;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
gap: 0.35rem;
|
||||||
|
width: min(15rem, 72vw);
|
||||||
|
max-height: 10.5rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--surface-border);
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
background: var(--panel-background);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
box-shadow: 0 20px 48px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.h3,
|
.composer-emoji-option {
|
||||||
.small {
|
width: 2.15rem;
|
||||||
color: var(--page-text);
|
height: 2.15rem;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: var(--surface-background);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
line-height: 1;
|
||||||
|
transition: background 120ms ease, transform 120ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes peer-typing-pulse {
|
.composer-emoji-option:hover,
|
||||||
0%,
|
.composer-emoji-option:focus-visible {
|
||||||
80%,
|
background: var(--surface-hover-background);
|
||||||
100% {
|
transform: scale(1.12);
|
||||||
opacity: 0.28;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
40% {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Misc helpers ────────────────────────────────────────────────────────── */
|
||||||
|
.h3, .small { color: var(--page-text); }
|
||||||
|
|
||||||
|
/* ── Mobile ──────────────────────────────────────────────────────────────── */
|
||||||
@media (max-width: 767.98px) {
|
@media (max-width: 767.98px) {
|
||||||
.chat-layout {
|
.chat-layout { grid-template-columns: 1fr; }
|
||||||
grid-template-columns: 1fr;
|
|
||||||
|
.status-indicators {
|
||||||
|
width: 100%;
|
||||||
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.peer-list {
|
.bubble { max-width: 90%; }
|
||||||
max-height: 16rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bubble {
|
.composer-toolbar { justify-content: flex-start; }
|
||||||
max-width: 88%;
|
|
||||||
|
.composer-call,
|
||||||
|
.composer-hangup,
|
||||||
|
.composer-voice,
|
||||||
|
.composer-dictation,
|
||||||
|
.composer-image-generate,
|
||||||
|
.composer-emoji-trigger,
|
||||||
|
.composer-plus,
|
||||||
|
.send-emoji {
|
||||||
|
width: 2.6rem;
|
||||||
|
height: 2.6rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -25,7 +25,7 @@
|
|||||||
<div class="h4 mb-1">{{ user.displayName }}</div>
|
<div class="h4 mb-1">{{ user.displayName }}</div>
|
||||||
<div class="text-secondary mb-3">{{ user.username }}</div>
|
<div class="text-secondary mb-3">{{ user.username }}</div>
|
||||||
<div class="small status-pill mb-3">{{ session.status() }}</div>
|
<div class="small status-pill mb-3">{{ session.status() }}</div>
|
||||||
<button class="btn btn-accent w-100 mb-2" type="button" [disabled]="!canOpenChatUi()" (click)="openChatUi()">
|
<button class="btn btn-accent w-100 mb-2" type="button" (click)="openChatUi()">
|
||||||
Open chat UI
|
Open chat UI
|
||||||
</button>
|
</button>
|
||||||
@if (session.isApprovalAdmin()) {
|
@if (session.isApprovalAdmin()) {
|
||||||
@@ -53,22 +53,11 @@
|
|||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="h3 mb-1">Connect to the signaling backend</h2>
|
<h2 class="h3 mb-1">Connect to the signaling backend</h2>
|
||||||
<p class="text-secondary mb-0">Use the Fastify server for authentication and peer discovery.</p>
|
<p class="text-secondary mb-0">Use the current browser host for authentication and peer discovery.</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="badge rounded-pill text-bg-dark">Angular + Bootstrap</span>
|
<span class="badge rounded-pill text-bg-dark">Angular + Bootstrap</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label" for="serverUrl">Backend URL</label>
|
|
||||||
<input
|
|
||||||
id="serverUrl"
|
|
||||||
name="serverUrl"
|
|
||||||
class="form-control form-control-lg"
|
|
||||||
[(ngModel)]="serverUrl"
|
|
||||||
placeholder="http://localhost:3000"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="btn-group mb-4 w-100" role="group" aria-label="Authentication mode">
|
<div class="btn-group mb-4 w-100" role="group" aria-label="Authentication mode">
|
||||||
<button
|
<button
|
||||||
class="btn"
|
class="btn"
|
||||||
@@ -177,49 +166,123 @@
|
|||||||
}
|
}
|
||||||
} @else {
|
} @else {
|
||||||
<section class="row g-4 align-items-stretch">
|
<section class="row g-4 align-items-stretch">
|
||||||
<div class="col-lg-5">
|
<div class="col-12">
|
||||||
<div class="panel p-4 h-100">
|
<div class="panel p-4 h-100">
|
||||||
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
|
<div class="d-flex justify-content-between align-items-start gap-3 mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="h3 mb-1">Connection settings</h2>
|
<h2 class="h3 mb-1">Account tools</h2>
|
||||||
<p class="text-secondary mb-0">Manage the backend endpoint used for auth and signaling.</p>
|
<p class="text-secondary mb-0">This session uses the current browser host for auth and signaling.</p>
|
||||||
</div>
|
</div>
|
||||||
@if (session.isApprovalAdmin()) {
|
@if (session.isApprovalAdmin()) {
|
||||||
<a class="btn btn-sm btn-outline-light" routerLink="/approvals">Approvals</a>
|
<a class="btn btn-sm btn-outline-light" routerLink="/approvals">Approvals</a>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (!embeddedMode) {
|
<div class="small status-pill mb-3">{{ session.status() }}</div>
|
||||||
<label class="form-label" for="connectedServerUrl">Backend URL</label>
|
|
||||||
<div class="input-group mb-3">
|
|
||||||
<input
|
|
||||||
id="connectedServerUrl"
|
|
||||||
class="form-control"
|
|
||||||
[(ngModel)]="serverUrl"
|
|
||||||
(blur)="applyServerUrl()"
|
|
||||||
/>
|
|
||||||
<button class="btn btn-outline-secondary" type="button" (click)="applyServerUrl()">Apply</button>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<div class="empty-state p-4 text-center text-secondary">
|
|
||||||
Backend settings are managed by the native app in embedded mode.
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="small status-pill mt-3">{{ session.status() }}</div>
|
|
||||||
|
|
||||||
@if (session.error()) {
|
@if (session.error()) {
|
||||||
<div class="alert alert-danger mt-4 mb-0">{{ session.error() }}</div>
|
<div class="alert alert-danger mb-3">{{ session.error() }}</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (session.notice()) {
|
@if (session.notice()) {
|
||||||
<div class="alert alert-success mt-4 mb-0">{{ session.notice() }}</div>
|
<div class="alert alert-success mb-4">{{ session.notice() }}</div>
|
||||||
}
|
}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-lg-7">
|
<section class="access-key-panel mb-4">
|
||||||
<div class="panel p-4 h-100">
|
<div class="dictation-language-panel">
|
||||||
|
<div>
|
||||||
|
<h3 class="h5 mb-1">Dictation language</h3>
|
||||||
|
<p class="small text-secondary mb-0">
|
||||||
|
Speech input and text output use the same selected language.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dictation-language-select-shell mt-3">
|
||||||
|
<label class="form-label small mb-2" for="dictationLanguage">Language</label>
|
||||||
|
<select
|
||||||
|
id="dictationLanguage"
|
||||||
|
class="form-select"
|
||||||
|
[ngModel]="session.dictationLanguage()"
|
||||||
|
(ngModelChange)="setDictationLanguage($event)"
|
||||||
|
>
|
||||||
|
@for (option of dictationLanguageOptions; track option.value) {
|
||||||
|
<option [ngValue]="option.value">{{ option.label }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="access-key-panel mb-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-start gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 class="h5 mb-1">Notifications</h3>
|
||||||
|
<p class="small text-secondary mb-0">Play a sound when any incoming message or file arrives.</p>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-switch m-0">
|
||||||
|
<input
|
||||||
|
id="incomingMessageSoundEnabled"
|
||||||
|
class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
[ngModel]="session.incomingMessageSoundEnabled()"
|
||||||
|
(ngModelChange)="setIncomingMessageSound($event)"
|
||||||
|
/>
|
||||||
|
<label class="form-check-label small" for="incomingMessageSoundEnabled">
|
||||||
|
{{ session.incomingMessageSoundEnabled() ? 'On' : 'Off' }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="access-key-panel user-search-panel mb-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
|
||||||
|
<div>
|
||||||
|
<h3 class="h5 mb-1">Find people</h3>
|
||||||
|
<p class="small text-secondary mb-0">Search approved accounts and add them to the chat peer list.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-outline-light"
|
||||||
|
type="button"
|
||||||
|
[disabled]="loadingKnownUsers()"
|
||||||
|
(click)="reloadKnownUsers()"
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="user-search-shell">
|
||||||
|
<input
|
||||||
|
class="form-control"
|
||||||
|
[(ngModel)]="userSearch"
|
||||||
|
placeholder="Search by display name or username"
|
||||||
|
/>
|
||||||
|
|
||||||
|
@if (knownUsersError()) {
|
||||||
|
<div class="alert alert-danger mt-3 mb-0">{{ knownUsersError() }}</div>
|
||||||
|
} @else if (loadingKnownUsers()) {
|
||||||
|
<div class="empty-state p-3 text-center text-secondary mt-3">Loading users...</div>
|
||||||
|
} @else if (filteredKnownUsers().length === 0) {
|
||||||
|
<div class="empty-state p-3 text-center text-secondary mt-3">No matching users found.</div>
|
||||||
|
} @else {
|
||||||
|
<div class="user-search-results mt-3">
|
||||||
|
@for (user of filteredKnownUsers(); track user.id) {
|
||||||
|
<button class="user-search-result" type="button" (click)="addKnownPeer(user)">
|
||||||
|
<span class="user-search-result-copy">
|
||||||
|
<span class="fw-semibold">{{ user.displayName }}</span>
|
||||||
|
<span class="small text-secondary">@{{ user.username }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="user-search-result-action">Add</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (knownUsersNotice()) {
|
||||||
|
<div class="small text-secondary mt-3">{{ knownUsersNotice() }}</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="access-key-panel">
|
<section class="access-key-panel">
|
||||||
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
|
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -261,6 +324,63 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
@if (session.isApprovalAdmin()) {
|
||||||
|
<section class="access-key-panel mt-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
|
||||||
|
<div>
|
||||||
|
<h3 class="h5 mb-1">User administration</h3>
|
||||||
|
<p class="small text-secondary mb-0">Delete any user account directly from SQLite.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-outline-light"
|
||||||
|
type="button"
|
||||||
|
[disabled]="loadingAdminUsers()"
|
||||||
|
(click)="reloadAdminUsers()"
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (adminUsersError()) {
|
||||||
|
<div class="alert alert-danger mb-3">{{ adminUsersError() }}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
@if (loadingAdminUsers()) {
|
||||||
|
<div class="empty-state p-3 text-center text-secondary">Loading users...</div>
|
||||||
|
} @else if (adminUsers().length === 0) {
|
||||||
|
<div class="empty-state p-3 text-center text-secondary">No users found.</div>
|
||||||
|
} @else {
|
||||||
|
@for (user of adminUsers(); track user.id) {
|
||||||
|
<article class="access-key-card p-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-start gap-3">
|
||||||
|
<div>
|
||||||
|
<div class="fw-semibold">{{ user.displayName }}</div>
|
||||||
|
<div class="small text-secondary">@{{ user.username }}</div>
|
||||||
|
<div class="small text-secondary">
|
||||||
|
{{ user.isActive ? 'Approved' : 'Pending approval' }}
|
||||||
|
@if (user.approvedAt) {
|
||||||
|
· {{ user.approvedAt | date: 'short' }}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="small text-secondary">Created: {{ user.createdAt | date: 'medium' }}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-outline-danger"
|
||||||
|
type="button"
|
||||||
|
[disabled]="deletingUserId() === user.id"
|
||||||
|
(click)="deleteUser(user)"
|
||||||
|
>
|
||||||
|
{{ deletingUserId() === user.id ? 'Deleting...' : 'Delete user' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -8,142 +8,130 @@
|
|||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Panel primitives ───────────────────────────────────────────────────── */
|
||||||
.hero-panel,
|
.hero-panel,
|
||||||
.panel,
|
.panel,
|
||||||
.session-card,
|
.session-card,
|
||||||
.empty-state {
|
.empty-state {
|
||||||
border: 1px solid var(--surface-border);
|
border: 1px solid var(--surface-border);
|
||||||
background: var(--panel-background);
|
background: var(--panel-background);
|
||||||
backdrop-filter: blur(18px);
|
backdrop-filter: blur(20px);
|
||||||
box-shadow: 0 20px 60px var(--shadow-color);
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
box-shadow:
|
||||||
|
0 24px 64px var(--shadow-color),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-panel {
|
.hero-panel { border-radius: 2rem; }
|
||||||
border-radius: 2rem;
|
.panel { border-radius: 1.75rem; }
|
||||||
}
|
.session-card { border-radius: 1.5rem; }
|
||||||
|
|
||||||
.panel {
|
|
||||||
border-radius: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-muted {
|
.panel-muted {
|
||||||
background: var(--panel-alt-background);
|
background: var(--panel-alt-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-copy {
|
/* ── Hero header ────────────────────────────────────────────────────────── */
|
||||||
max-width: 52rem;
|
.hero-copy { max-width: 52rem; }
|
||||||
}
|
|
||||||
|
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.45rem 0.85rem;
|
padding: 0.4rem 0.9rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 0.75rem;
|
||||||
letter-spacing: 0.14em;
|
letter-spacing: 0.18em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
font-size: 0.72rem;
|
font-size: 0.68rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
font-family: var(--font-mono);
|
||||||
color: var(--accent-color);
|
color: var(--accent-color);
|
||||||
background: var(--accent-color-soft);
|
background: var(--accent-color-soft);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--accent-color) 25%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.eyebrow::before {
|
||||||
|
content: '';
|
||||||
|
width: 0.45rem;
|
||||||
|
height: 0.45rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-color);
|
||||||
|
animation: glow-pulse 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Theme toggle ───────────────────────────────────────────────────────── */
|
||||||
.theme-toggle {
|
.theme-toggle {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.55rem;
|
gap: 0.5rem;
|
||||||
min-width: 7.5rem;
|
min-width: 7.5rem;
|
||||||
height: 3rem;
|
height: 2.75rem;
|
||||||
padding: 0 0.95rem;
|
padding: 0 1rem;
|
||||||
border: 1px solid var(--surface-border);
|
border: 1px solid var(--surface-border);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
color: var(--page-text);
|
color: var(--page-text);
|
||||||
background: var(--panel-soft-background);
|
background: rgba(255, 255, 255, 0.04);
|
||||||
font-size: 0.95rem;
|
font-size: 0.88rem;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
text-transform: capitalize;
|
font-family: var(--font-mono);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
transition: transform 160ms ease, background 160ms ease, border-color 160ms ease;
|
transition: transform 160ms ease, background 160ms ease, border-color 160ms ease, box-shadow 160ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-toggle-icon {
|
.theme-toggle-icon { font-size: 1.15rem; }
|
||||||
font-size: 1.25rem;
|
.theme-toggle-label { letter-spacing: 0.06em; }
|
||||||
}
|
|
||||||
|
|
||||||
.theme-toggle-label {
|
|
||||||
letter-spacing: 0.03em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-toggle:hover,
|
.theme-toggle:hover,
|
||||||
.theme-toggle:focus-visible {
|
.theme-toggle:focus-visible {
|
||||||
transform: translateY(-1px);
|
border-color: color-mix(in srgb, var(--accent-color) 40%, transparent);
|
||||||
border-color: color-mix(in srgb, var(--accent-color) 35%, var(--surface-border));
|
|
||||||
background: var(--surface-hover-background);
|
background: var(--surface-hover-background);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-color-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.session-card {
|
/* ── Session card ───────────────────────────────────────────────────────── */
|
||||||
min-width: min(100%, 18rem);
|
.session-card { min-width: min(100%, 18rem); }
|
||||||
border-radius: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-pill {
|
.status-pill {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
padding: 0.45rem 0.8rem;
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.38rem 0.85rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--badge-background);
|
background: var(--badge-background);
|
||||||
}
|
|
||||||
|
|
||||||
.btn-accent {
|
|
||||||
color: #06111d;
|
|
||||||
border: 0;
|
|
||||||
background: var(--accent-gradient);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-accent:hover,
|
|
||||||
.btn-accent:focus-visible {
|
|
||||||
color: #06111d;
|
|
||||||
background: var(--accent-gradient-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.access-key-panel {
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 1rem;
|
|
||||||
background: var(--panel-soft-background);
|
|
||||||
}
|
|
||||||
|
|
||||||
.access-key-card {
|
|
||||||
border-radius: 0.9rem;
|
|
||||||
border: 1px solid var(--surface-border-soft);
|
border: 1px solid var(--surface-border-soft);
|
||||||
background: var(--surface-background);
|
font-family: var(--font-mono);
|
||||||
}
|
font-size: 0.72rem;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
.empty-state {
|
text-transform: uppercase;
|
||||||
border-radius: 1.25rem;
|
color: var(--page-text-soft);
|
||||||
}
|
|
||||||
|
|
||||||
.info-rail article {
|
|
||||||
padding: 1rem 1.1rem;
|
|
||||||
border-radius: 1rem;
|
|
||||||
background: var(--panel-soft-background);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Auth form card ─────────────────────────────────────────────────────── */
|
||||||
.form-control,
|
.form-control,
|
||||||
.form-control:focus {
|
.form-control:focus,
|
||||||
|
.form-select,
|
||||||
|
.form-select:focus {
|
||||||
color: var(--page-text);
|
color: var(--page-text);
|
||||||
background-color: var(--input-background);
|
background-color: var(--input-background);
|
||||||
border-color: var(--input-border);
|
border-color: var(--input-border);
|
||||||
|
border-radius: 0.85rem;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
|
transition: border-color 160ms ease, box-shadow 160ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-control::placeholder {
|
.form-control:focus,
|
||||||
color: var(--placeholder-color);
|
.form-select:focus {
|
||||||
|
border-color: color-mix(in srgb, var(--accent-color) 55%, transparent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-color-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-control::placeholder { color: var(--placeholder-color); }
|
||||||
|
|
||||||
.form-label,
|
.form-label,
|
||||||
.h3,
|
.h3, .h4, .h5,
|
||||||
.h4,
|
|
||||||
.display-5,
|
|
||||||
.fw-semibold,
|
.fw-semibold,
|
||||||
.fw-bold {
|
.fw-bold {
|
||||||
color: var(--page-text);
|
color: var(--page-text);
|
||||||
@@ -154,3 +142,134 @@
|
|||||||
.small {
|
.small {
|
||||||
color: var(--page-text-muted) !important;
|
color: var(--page-text-muted) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Auth tab toggle */
|
||||||
|
.btn-group .btn {
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-group .btn-primary {
|
||||||
|
background: var(--accent-gradient);
|
||||||
|
border-color: transparent;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 2px 12px rgba(13, 148, 136, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-group .btn-outline-primary {
|
||||||
|
color: var(--page-text-muted);
|
||||||
|
border-color: var(--surface-border);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-group .btn-outline-primary:hover {
|
||||||
|
background: var(--panel-soft-background);
|
||||||
|
color: var(--page-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Access key / settings panels ──────────────────────────────────────── */
|
||||||
|
.access-key-panel {
|
||||||
|
padding: 1.1rem 1.25rem;
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
background: var(--panel-soft-background);
|
||||||
|
border: 1px solid var(--surface-border-soft);
|
||||||
|
transition: border-color 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-key-panel:hover {
|
||||||
|
border-color: var(--surface-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-key-card {
|
||||||
|
border-radius: 1rem;
|
||||||
|
border: 1px solid var(--surface-border-soft);
|
||||||
|
background: var(--surface-background);
|
||||||
|
transition: border-color 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-key-card:hover {
|
||||||
|
border-color: var(--surface-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── User search ────────────────────────────────────────────────────────── */
|
||||||
|
.user-search-panel,
|
||||||
|
.user-search-shell,
|
||||||
|
.dictation-language-panel,
|
||||||
|
.dictation-language-select-shell {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-search-results {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-search-result {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
border: 1px solid var(--surface-border-soft);
|
||||||
|
border-radius: 1rem;
|
||||||
|
color: var(--page-text);
|
||||||
|
background: var(--surface-background);
|
||||||
|
text-align: left;
|
||||||
|
transition: border-color 160ms ease, background 160ms ease, transform 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-search-result:hover,
|
||||||
|
.user-search-result:focus-visible {
|
||||||
|
border-color: color-mix(in srgb, var(--accent-color) 38%, transparent);
|
||||||
|
background: var(--surface-hover-background);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-search-result-copy {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.08rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-search-result-action {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── How-it-works info rail ─────────────────────────────────────────────── */
|
||||||
|
.info-rail article {
|
||||||
|
padding: 1rem 1.15rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: var(--panel-soft-background);
|
||||||
|
border: 1px solid var(--surface-border-soft);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-rail article::before {
|
||||||
|
content: attr(data-step);
|
||||||
|
position: absolute;
|
||||||
|
top: 0.65rem;
|
||||||
|
right: 0.9rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent-color);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Empty state ────────────────────────────────────────────────────────── */
|
||||||
|
.empty-state {
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
border: 1px dashed var(--surface-border);
|
||||||
|
background: var(--panel-soft-background) !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Component, effect, inject } from '@angular/core';
|
import { Component, computed, effect, inject, signal } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Router, RouterLink } from '@angular/router';
|
import { Router, RouterLink } from '@angular/router';
|
||||||
|
|
||||||
import { ChatSessionService } from './chat-session.service';
|
import { ChatSessionService } from './chat-session.service';
|
||||||
|
import type { AdminUserSummary, DictationLanguage, UserProfile } from './models';
|
||||||
import { ThemeService } from './theme.service';
|
import { ThemeService } from './theme.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -18,15 +19,41 @@ export class HomePageComponent {
|
|||||||
authMode: 'login' | 'register' = 'login';
|
authMode: 'login' | 'register' = 'login';
|
||||||
readonly embeddedMode =
|
readonly embeddedMode =
|
||||||
typeof window !== 'undefined' && window.localStorage.getItem('privatechat.embeddedMode') === '1';
|
typeof window !== 'undefined' && window.localStorage.getItem('privatechat.embeddedMode') === '1';
|
||||||
serverUrl = '';
|
|
||||||
displayName = '';
|
displayName = '';
|
||||||
username = '';
|
username = '';
|
||||||
password = '';
|
password = '';
|
||||||
accessKeyLabel = '';
|
accessKeyLabel = '';
|
||||||
|
userSearch = '';
|
||||||
|
readonly adminUsers = signal<AdminUserSummary[]>([]);
|
||||||
|
readonly knownUsers = signal<UserProfile[]>([]);
|
||||||
|
readonly loadingKnownUsers = signal(false);
|
||||||
|
readonly knownUsersError = signal<string | null>(null);
|
||||||
|
readonly knownUsersNotice = signal<string | null>(null);
|
||||||
|
readonly loadingAdminUsers = signal(false);
|
||||||
|
readonly deletingUserId = signal<string | null>(null);
|
||||||
|
readonly adminUsersError = signal<string | null>(null);
|
||||||
|
readonly dictationLanguageOptions: Array<{ value: DictationLanguage; label: string }> = [
|
||||||
|
{ value: 'en', label: 'English' },
|
||||||
|
{ value: 'fr', label: 'French' },
|
||||||
|
{ value: 'es', label: 'Spanish' },
|
||||||
|
];
|
||||||
|
readonly filteredKnownUsers = computed(() => {
|
||||||
|
const query = this.userSearch.trim().toLowerCase();
|
||||||
|
const users = this.knownUsers();
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
return users.slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
return users
|
||||||
|
.filter((user) =>
|
||||||
|
user.displayName.toLowerCase().includes(query)
|
||||||
|
|| user.username.toLowerCase().includes(query),
|
||||||
|
)
|
||||||
|
.slice(0, 8);
|
||||||
|
});
|
||||||
|
|
||||||
constructor(readonly session: ChatSessionService) {
|
constructor(readonly session: ChatSessionService) {
|
||||||
this.serverUrl = session.serverUrl();
|
|
||||||
|
|
||||||
if (this.embeddedMode) {
|
if (this.embeddedMode) {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const currentUser = this.session.currentUser();
|
const currentUser = this.session.currentUser();
|
||||||
@@ -39,11 +66,37 @@ export class HomePageComponent {
|
|||||||
void this.router.navigate(['/chat', activePeerId], { replaceUrl: true });
|
void this.router.navigate(['/chat', activePeerId], { replaceUrl: true });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
effect(() => {
|
||||||
|
const currentUser = this.session.currentUser();
|
||||||
|
|
||||||
|
if (!currentUser) {
|
||||||
|
this.knownUsers.set([]);
|
||||||
|
this.loadingKnownUsers.set(false);
|
||||||
|
this.knownUsersError.set(null);
|
||||||
|
this.knownUsersNotice.set(null);
|
||||||
|
this.userSearch = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void this.reloadKnownUsers();
|
||||||
|
});
|
||||||
|
|
||||||
|
effect(() => {
|
||||||
|
const currentUser = this.session.currentUser();
|
||||||
|
|
||||||
|
if (!currentUser || !this.session.isApprovalAdmin()) {
|
||||||
|
this.adminUsers.set([]);
|
||||||
|
this.adminUsersError.set(null);
|
||||||
|
this.loadingAdminUsers.set(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void this.reloadAdminUsers();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async submitAuth(): Promise<void> {
|
async submitAuth(): Promise<void> {
|
||||||
this.applyServerUrl();
|
|
||||||
|
|
||||||
if (this.authMode === 'register') {
|
if (this.authMode === 'register') {
|
||||||
const authenticated = await this.session.register(this.username, this.password, this.displayName);
|
const authenticated = await this.session.register(this.username, this.password, this.displayName);
|
||||||
this.password = '';
|
this.password = '';
|
||||||
@@ -58,10 +111,6 @@ export class HomePageComponent {
|
|||||||
await this.session.login(this.username, this.password);
|
await this.session.login(this.username, this.password);
|
||||||
}
|
}
|
||||||
|
|
||||||
applyServerUrl(): void {
|
|
||||||
this.session.setServerUrl(this.serverUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
async logout(): Promise<void> {
|
async logout(): Promise<void> {
|
||||||
await this.session.logout();
|
await this.session.logout();
|
||||||
this.authMode = 'login';
|
this.authMode = 'login';
|
||||||
@@ -70,7 +119,6 @@ export class HomePageComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async loginWithAccessKey(): Promise<void> {
|
async loginWithAccessKey(): Promise<void> {
|
||||||
this.applyServerUrl();
|
|
||||||
await this.session.loginWithAccessKey(this.username);
|
await this.session.loginWithAccessKey(this.username);
|
||||||
this.password = '';
|
this.password = '';
|
||||||
}
|
}
|
||||||
@@ -80,23 +128,87 @@ export class HomePageComponent {
|
|||||||
this.accessKeyLabel = '';
|
this.accessKeyLabel = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
canOpenChatUi(): boolean {
|
async reloadKnownUsers(): Promise<void> {
|
||||||
return this.session.peers().length > 0;
|
this.loadingKnownUsers.set(true);
|
||||||
|
this.knownUsersError.set(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.knownUsers.set(await this.session.loadKnownUsers());
|
||||||
|
} catch (error) {
|
||||||
|
this.knownUsersError.set(
|
||||||
|
error instanceof Error ? error.message : 'Could not load users.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.loadingKnownUsers.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addKnownPeer(user: UserProfile): void {
|
||||||
|
this.session.rememberKnownPeer(user);
|
||||||
|
this.knownUsersNotice.set(`${user.displayName} was added to your chat peer list.`);
|
||||||
|
this.userSearch = user.displayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
async reloadAdminUsers(): Promise<void> {
|
||||||
|
this.loadingAdminUsers.set(true);
|
||||||
|
this.adminUsersError.set(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.adminUsers.set(await this.session.loadAdminUsers());
|
||||||
|
} catch (error) {
|
||||||
|
this.adminUsersError.set(
|
||||||
|
error instanceof Error ? error.message : 'Could not load users.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.loadingAdminUsers.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteUser(user: AdminUserSummary): Promise<void> {
|
||||||
|
if (
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
!window.confirm(`Delete user ${user.username}? This removes the account from SQLite.`)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.deletingUserId.set(user.id);
|
||||||
|
this.adminUsersError.set(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.session.deleteUserAccount(user.id);
|
||||||
|
this.adminUsers.update((users) => users.filter((candidate) => candidate.id !== user.id));
|
||||||
|
} catch (error) {
|
||||||
|
this.adminUsersError.set(
|
||||||
|
error instanceof Error ? error.message : 'Could not delete that user.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.deletingUserId.set(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async openChatUi(): Promise<void> {
|
async openChatUi(): Promise<void> {
|
||||||
const peerId = this.session.activePeerId() ?? this.session.peers()[0]?.id;
|
const peerId = this.session.activePeerId() ?? this.session.peers()[0]?.id;
|
||||||
|
|
||||||
if (!peerId) {
|
if (peerId) {
|
||||||
this.session.error.set('No connected peers are available yet.');
|
this.session.selectPeer(peerId);
|
||||||
|
await this.router.navigate(['/chat', peerId]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.session.selectPeer(peerId);
|
this.session.error.set(null);
|
||||||
await this.router.navigate(['/chat', peerId]);
|
await this.router.navigate(['/chat']);
|
||||||
}
|
}
|
||||||
|
|
||||||
cycleTheme(): void {
|
cycleTheme(): void {
|
||||||
this.theme.cycleMode();
|
this.theme.cycleMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setIncomingMessageSound(enabled: boolean): void {
|
||||||
|
this.session.setIncomingMessageSoundEnabled(enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
setDictationLanguage(language: string): void {
|
||||||
|
this.session.setDictationLanguage(language as DictationLanguage);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
29
client/src/app/json-file-viewer.component.scss
Normal file
29
client/src/app/json-file-viewer.component.scss
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
max-width: min(95%, 320px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-viewer-shell {
|
||||||
|
width: min(95%, 480px);
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-viewer-host {
|
||||||
|
max-height: 18rem;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-viewer-error {
|
||||||
|
padding: 0 0.75rem 0.75rem;
|
||||||
|
color: inherit;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .json-viewer-host .json-container {
|
||||||
|
min-width: max-content;
|
||||||
|
}
|
||||||
101
client/src/app/json-file-viewer.component.ts
Normal file
101
client/src/app/json-file-viewer.component.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { AfterViewInit, Component, ElementRef, Input, OnChanges, OnDestroy, ViewChild } from '@angular/core';
|
||||||
|
|
||||||
|
import type { ChatEntry } from './models';
|
||||||
|
|
||||||
|
type JsonViewTree = object;
|
||||||
|
|
||||||
|
type JsonViewApi = {
|
||||||
|
renderJSON(value: unknown, container: HTMLElement): JsonViewTree;
|
||||||
|
expand?(tree: JsonViewTree): void;
|
||||||
|
destroy?(tree: JsonViewTree): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
jsonview?: JsonViewApi;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-json-file-viewer',
|
||||||
|
imports: [CommonModule],
|
||||||
|
template: `
|
||||||
|
<div class="json-viewer-shell">
|
||||||
|
<div #host class="json-viewer-host"></div>
|
||||||
|
@if (errorMessage) {
|
||||||
|
<p class="json-viewer-error mb-0">{{ errorMessage }}</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styleUrl: './json-file-viewer.component.scss',
|
||||||
|
})
|
||||||
|
export class JsonFileViewerComponent implements AfterViewInit, OnChanges, OnDestroy {
|
||||||
|
@Input({ required: true }) entry!: ChatEntry;
|
||||||
|
@ViewChild('host') private readonly host?: ElementRef<HTMLDivElement>;
|
||||||
|
|
||||||
|
errorMessage: string | null = null;
|
||||||
|
private renderedTree: JsonViewTree | null = null;
|
||||||
|
private renderVersion = 0;
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
void this.renderJsonTree();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnChanges(): void {
|
||||||
|
void this.renderJsonTree();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.destroyRenderedTree();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async renderJsonTree(): Promise<void> {
|
||||||
|
const host = this.host?.nativeElement;
|
||||||
|
|
||||||
|
if (!host || !this.entry.downloadUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderVersion = ++this.renderVersion;
|
||||||
|
const jsonview = window.jsonview;
|
||||||
|
|
||||||
|
this.destroyRenderedTree();
|
||||||
|
host.replaceChildren();
|
||||||
|
this.errorMessage = null;
|
||||||
|
|
||||||
|
if (!jsonview) {
|
||||||
|
this.errorMessage = 'JSON viewer unavailable.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(this.entry.downloadUrl);
|
||||||
|
const content = await response.text();
|
||||||
|
const parsed = JSON.parse(content);
|
||||||
|
|
||||||
|
if (renderVersion !== this.renderVersion) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tree = jsonview.renderJSON(parsed, host);
|
||||||
|
jsonview.expand?.(tree);
|
||||||
|
this.renderedTree = tree;
|
||||||
|
} catch {
|
||||||
|
if (renderVersion !== this.renderVersion) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.errorMessage = 'Could not render JSON preview.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private destroyRenderedTree(): void {
|
||||||
|
if (!this.renderedTree) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.jsonview?.destroy?.(this.renderedTree);
|
||||||
|
this.renderedTree = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,15 @@ export interface PendingApprovalUser {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminUserSummary {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
displayName: string;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
approvedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AccessKeySummary {
|
export interface AccessKeySummary {
|
||||||
id: string;
|
id: string;
|
||||||
credentialId: string;
|
credentialId: string;
|
||||||
@@ -46,6 +55,8 @@ export interface AccessKeySummary {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DeliveryState = 'pending' | 'sent';
|
||||||
|
|
||||||
export interface RegistrationOptionsResponse {
|
export interface RegistrationOptionsResponse {
|
||||||
rp: PublicKeyCredentialRpEntity;
|
rp: PublicKeyCredentialRpEntity;
|
||||||
user: {
|
user: {
|
||||||
@@ -85,17 +96,25 @@ export interface ChatEntry {
|
|||||||
id: string;
|
id: string;
|
||||||
peerId: string;
|
peerId: string;
|
||||||
direction: 'incoming' | 'outgoing' | 'system';
|
direction: 'incoming' | 'outgoing' | 'system';
|
||||||
kind: 'text' | 'json' | 'file' | 'system';
|
kind: 'text' | 'json' | 'file' | 'voice' | 'system';
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
authorLabel: string;
|
authorLabel: string;
|
||||||
|
deliveryState?: DeliveryState;
|
||||||
|
generatedByAi?: boolean;
|
||||||
|
showSpinner?: boolean;
|
||||||
text?: string;
|
text?: string;
|
||||||
payload?: unknown;
|
payload?: unknown;
|
||||||
fileName?: string;
|
fileName?: string;
|
||||||
fileSize?: number;
|
fileSize?: number;
|
||||||
fileMimeType?: string;
|
fileMimeType?: string;
|
||||||
downloadUrl?: string;
|
downloadUrl?: string;
|
||||||
|
previewMimeType?: string;
|
||||||
|
previewDownloadUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CallMode = 'audio' | 'video';
|
||||||
|
export type DictationLanguage = 'en' | 'fr' | 'es';
|
||||||
|
|
||||||
export type SignalPayload =
|
export type SignalPayload =
|
||||||
| { type: 'sdp'; description: RTCSessionDescriptionInit }
|
| { type: 'sdp'; description: RTCSessionDescriptionInit }
|
||||||
| { type: 'ice-candidate'; candidate: RTCIceCandidateInit };
|
| { type: 'ice-candidate'; candidate: RTCIceCandidateInit };
|
||||||
@@ -105,6 +124,22 @@ export type ServerEvent =
|
|||||||
| { type: 'peer-joined'; peer: UserProfile }
|
| { type: 'peer-joined'; peer: UserProfile }
|
||||||
| { type: 'peer-left'; peerId: string }
|
| { type: 'peer-left'; peerId: string }
|
||||||
| { type: 'signal'; from: string; signal: SignalPayload }
|
| { type: 'signal'; from: string; signal: SignalPayload }
|
||||||
|
| {
|
||||||
|
type: 'image-generated';
|
||||||
|
requestId: string;
|
||||||
|
peerId: string;
|
||||||
|
prompt: string;
|
||||||
|
createdAt: number;
|
||||||
|
mimeType: string;
|
||||||
|
imageBase64: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'image-generation-error';
|
||||||
|
requestId: string;
|
||||||
|
peerId: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
| { type: 'pong' }
|
||||||
| { type: 'error'; message: string };
|
| { type: 'error'; message: string };
|
||||||
|
|
||||||
export type DataEnvelope =
|
export type DataEnvelope =
|
||||||
@@ -130,6 +165,7 @@ export type DataEnvelope =
|
|||||||
name: string;
|
name: string;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
size: number;
|
size: number;
|
||||||
|
attachmentKind?: 'file' | 'voice';
|
||||||
authorId: string;
|
authorId: string;
|
||||||
authorName: string;
|
authorName: string;
|
||||||
sentAt: number;
|
sentAt: number;
|
||||||
@@ -141,4 +177,15 @@ export type DataEnvelope =
|
|||||||
| {
|
| {
|
||||||
type: 'typing';
|
type: 'typing';
|
||||||
active: boolean;
|
active: boolean;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'voice-call-offer';
|
||||||
|
mode: CallMode;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'voice-call-response';
|
||||||
|
accepted: boolean;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'voice-call-ended';
|
||||||
};
|
};
|
||||||
|
|||||||
155
client/src/app/peer-call-modal.component.scss
Normal file
155
client/src/app/peer-call-modal.component.scss
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
:host {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1250;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top, rgba(78, 114, 255, 0.18), transparent 34%),
|
||||||
|
rgba(3, 8, 14, 0.82);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-modal-card {
|
||||||
|
width: min(100%, 72rem);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 1.75rem;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(9, 16, 28, 0.98), rgba(4, 8, 16, 0.96));
|
||||||
|
box-shadow: 0 28px 90px rgba(0, 0, 0, 0.48);
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-modal-header,
|
||||||
|
.call-modal-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.25rem 1.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-modal-footer {
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 1rem 1.25rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-modal-eyebrow {
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(255, 255, 255, 0.64);
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-modal-close {
|
||||||
|
width: 2.75rem;
|
||||||
|
height: 2.75rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-modal-stage {
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-video-panel {
|
||||||
|
position: relative;
|
||||||
|
min-height: min(72vh, 42rem);
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 1.35rem;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.03));
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-video-panel-local {
|
||||||
|
position: absolute;
|
||||||
|
right: 1rem;
|
||||||
|
bottom: 1rem;
|
||||||
|
width: min(22vw, 12rem);
|
||||||
|
min-height: auto;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 1rem;
|
||||||
|
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.34);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-video-label {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.85rem;
|
||||||
|
left: 0.85rem;
|
||||||
|
z-index: 1;
|
||||||
|
padding: 0.35rem 0.7rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: rgba(255, 255, 255, 0.88);
|
||||||
|
background: rgba(0, 0, 0, 0.34);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-video-player,
|
||||||
|
.call-video-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top, rgba(140, 191, 255, 0.18), transparent 36%),
|
||||||
|
#03070f;
|
||||||
|
aspect-ratio: 16 / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-video-player {
|
||||||
|
display: block;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-video-player-local {
|
||||||
|
transform: scaleX(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-video-placeholder {
|
||||||
|
padding: 1.25rem;
|
||||||
|
text-align: center;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
font-size: 0.98rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-video-placeholder-local {
|
||||||
|
min-height: 8rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.call-modal-backdrop {
|
||||||
|
padding: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-modal-card {
|
||||||
|
border-radius: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-video-panel {
|
||||||
|
min-height: 18rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-video-panel-local {
|
||||||
|
right: 0.75rem;
|
||||||
|
bottom: 0.75rem;
|
||||||
|
width: min(38vw, 8.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-modal-header,
|
||||||
|
.call-modal-footer {
|
||||||
|
padding-inline: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
168
client/src/app/peer-call-modal.component.ts
Normal file
168
client/src/app/peer-call-modal.component.ts
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import {
|
||||||
|
AfterViewInit,
|
||||||
|
Component,
|
||||||
|
ElementRef,
|
||||||
|
EventEmitter,
|
||||||
|
Input,
|
||||||
|
OnChanges,
|
||||||
|
OnDestroy,
|
||||||
|
Output,
|
||||||
|
ViewChild,
|
||||||
|
} from '@angular/core';
|
||||||
|
import type { CallMode } from './models';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-peer-call-modal',
|
||||||
|
imports: [CommonModule],
|
||||||
|
template: `
|
||||||
|
@if (visible) {
|
||||||
|
<div class="call-modal-backdrop">
|
||||||
|
<section class="call-modal-card" (click)="$event.stopPropagation()">
|
||||||
|
<header class="call-modal-header">
|
||||||
|
<div>
|
||||||
|
<p class="call-modal-eyebrow">Private {{ callMode === 'audio' ? 'audio' : 'video' }} call</p>
|
||||||
|
<h2 class="h4 mb-1">{{ peerName }}</h2>
|
||||||
|
<p class="small mb-0">{{ statusText }}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="call-modal-close"
|
||||||
|
type="button"
|
||||||
|
(click)="requestDismiss()"
|
||||||
|
[attr.aria-label]="callState === 'incoming' ? 'Decline call' : 'End call'"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="call-modal-stage">
|
||||||
|
<section class="call-video-panel call-video-panel-remote">
|
||||||
|
<div class="call-video-label">{{ callMode === 'audio' ? 'Peer audio' : 'Peer' }}</div>
|
||||||
|
@if (callMode === 'video' && remoteStream) {
|
||||||
|
<video #remoteVideoElement class="call-video-player" autoplay playsinline></video>
|
||||||
|
} @else {
|
||||||
|
<div class="call-video-placeholder">
|
||||||
|
{{
|
||||||
|
callMode === 'audio'
|
||||||
|
? 'Audio-only call in progress.'
|
||||||
|
: callState === 'incoming'
|
||||||
|
? 'Waiting for you to join.'
|
||||||
|
: 'Waiting for peer video…'
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<section class="call-video-panel call-video-panel-local">
|
||||||
|
<div class="call-video-label">You</div>
|
||||||
|
@if (callMode === 'video' && localStream) {
|
||||||
|
<video #localVideoElement class="call-video-player call-video-player-local" autoplay playsinline></video>
|
||||||
|
} @else {
|
||||||
|
<div class="call-video-placeholder call-video-placeholder-local">
|
||||||
|
{{ callMode === 'audio' ? 'Audio only' : callState === 'incoming' ? 'Camera starts when you accept.' : 'Starting your camera…' }}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="call-modal-footer">
|
||||||
|
@if (callState === 'incoming') {
|
||||||
|
<button class="btn btn-success" type="button" (click)="acceptRequested.emit()">
|
||||||
|
Accept
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-light" type="button" (click)="rejectRequested.emit()">
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
} @else {
|
||||||
|
<button class="btn btn-danger" type="button" (click)="hangupRequested.emit()">
|
||||||
|
{{ callState === 'outgoing' ? 'Cancel call' : 'End call' }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
styleUrl: './peer-call-modal.component.scss',
|
||||||
|
})
|
||||||
|
export class PeerCallModalComponent implements AfterViewInit, OnChanges, OnDestroy {
|
||||||
|
@Input() visible = false;
|
||||||
|
@Input() peerName = 'Peer';
|
||||||
|
@Input() callState: 'incoming' | 'outgoing' | 'active' = 'active';
|
||||||
|
@Input() callMode: CallMode = 'video';
|
||||||
|
@Input() statusText = '';
|
||||||
|
@Input() localStream: MediaStream | null = null;
|
||||||
|
@Input() remoteStream: MediaStream | null = null;
|
||||||
|
@Output() readonly acceptRequested = new EventEmitter<void>();
|
||||||
|
@Output() readonly rejectRequested = new EventEmitter<void>();
|
||||||
|
@Output() readonly hangupRequested = new EventEmitter<void>();
|
||||||
|
|
||||||
|
@ViewChild('localVideoElement')
|
||||||
|
set localVideoElementRef(value: ElementRef<HTMLVideoElement> | undefined) {
|
||||||
|
this.localVideoElement = value;
|
||||||
|
this.syncVideoSources();
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewChild('remoteVideoElement')
|
||||||
|
set remoteVideoElementRef(value: ElementRef<HTMLVideoElement> | undefined) {
|
||||||
|
this.remoteVideoElement = value;
|
||||||
|
this.syncVideoSources();
|
||||||
|
}
|
||||||
|
|
||||||
|
private localVideoElement?: ElementRef<HTMLVideoElement>;
|
||||||
|
private remoteVideoElement?: ElementRef<HTMLVideoElement>;
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
this.syncVideoSources();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnChanges(): void {
|
||||||
|
this.syncVideoSources();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.detachVideo(this.localVideoElement?.nativeElement);
|
||||||
|
this.detachVideo(this.remoteVideoElement?.nativeElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
requestDismiss(): void {
|
||||||
|
if (this.callState === 'incoming') {
|
||||||
|
this.rejectRequested.emit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.hangupRequested.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncVideoSources(): void {
|
||||||
|
this.syncVideo(this.localVideoElement?.nativeElement, this.visible ? this.localStream : null, true);
|
||||||
|
this.syncVideo(this.remoteVideoElement?.nativeElement, this.visible ? this.remoteStream : null, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncVideo(video: HTMLVideoElement | undefined, stream: MediaStream | null, muted: boolean): void {
|
||||||
|
if (!video) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
video.muted = muted;
|
||||||
|
video.srcObject = stream;
|
||||||
|
|
||||||
|
if (stream) {
|
||||||
|
void video.play().catch(() => {
|
||||||
|
// Autoplay can be delayed until the next user gesture on some platforms.
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
video.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
private detachVideo(video: HTMLVideoElement | undefined): void {
|
||||||
|
if (!video) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
video.pause();
|
||||||
|
video.srcObject = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,9 +5,16 @@
|
|||||||
<title>PrivateChat</title>
|
<title>PrivateChat</title>
|
||||||
<base href="/">
|
<base href="/">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="theme-color" content="#030712">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="PrivateChat">
|
||||||
|
<link rel="manifest" href="manifest.webmanifest">
|
||||||
|
<link rel="apple-touch-icon" href="apple-touch-icon.png">
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||||
<script src="env.js"></script>
|
<script src="env.js"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
1
client/src/jsonview.js
Normal file
1
client/src/jsonview.js
Normal file
File diff suppressed because one or more lines are too long
@@ -2,5 +2,13 @@ import { bootstrapApplication } from '@angular/platform-browser';
|
|||||||
import { appConfig } from './app/app.config';
|
import { appConfig } from './app/app.config';
|
||||||
import { App } from './app/app';
|
import { App } from './app/app';
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined' && 'serviceWorker' in navigator && window.isSecureContext) {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
void navigator.serviceWorker.register('/service-worker.js').catch((error) => {
|
||||||
|
console.error('Service worker registration failed.', error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
bootstrapApplication(App, appConfig)
|
bootstrapApplication(App, appConfig)
|
||||||
.catch((err) => console.error(err));
|
.catch((err) => console.error(err));
|
||||||
|
|||||||
@@ -1,112 +1,104 @@
|
|||||||
@use 'bootstrap/scss/bootstrap';
|
@use 'bootstrap/scss/bootstrap';
|
||||||
|
|
||||||
|
/* ── Design Tokens ───────────────────────────────────────────────────────── */
|
||||||
:root {
|
:root {
|
||||||
--page-text: #142236;
|
/* Text */
|
||||||
--page-text-muted: rgba(39, 63, 91, 0.72);
|
--page-text: #e2e8f4;
|
||||||
--page-text-soft: rgba(39, 63, 91, 0.82);
|
--page-text-muted: rgba(180, 196, 224, 0.68);
|
||||||
--page-background:
|
--page-text-soft: rgba(180, 196, 224, 0.82);
|
||||||
radial-gradient(circle at top left, rgba(81, 168, 255, 0.2), transparent 30%),
|
|
||||||
radial-gradient(circle at top right, rgba(129, 244, 215, 0.22), transparent 24%),
|
|
||||||
linear-gradient(180deg, #f6fbff 0%, #e8f1fb 100%);
|
|
||||||
--panel-background: rgba(255, 255, 255, 0.82);
|
|
||||||
--panel-alt-background: rgba(241, 247, 255, 0.9);
|
|
||||||
--panel-soft-background: rgba(20, 34, 54, 0.04);
|
|
||||||
--surface-background: rgba(255, 255, 255, 0.82);
|
|
||||||
--surface-hover-background: rgba(235, 244, 255, 0.98);
|
|
||||||
--surface-border: rgba(33, 62, 94, 0.12);
|
|
||||||
--surface-border-soft: rgba(33, 62, 94, 0.08);
|
|
||||||
--input-background: rgba(255, 255, 255, 0.92);
|
|
||||||
--input-border: rgba(77, 114, 154, 0.26);
|
|
||||||
--placeholder-color: rgba(55, 83, 118, 0.52);
|
|
||||||
--accent-color: #138a7b;
|
|
||||||
--accent-color-soft: rgba(19, 138, 123, 0.1);
|
|
||||||
--accent-gradient: linear-gradient(135deg, #8df0df, #6cb6ff);
|
|
||||||
--accent-gradient-hover: linear-gradient(135deg, #a6f5e8, #86c4ff);
|
|
||||||
--link-color: #2f7cd6;
|
|
||||||
--badge-background: rgba(20, 34, 54, 0.08);
|
|
||||||
--incoming-bubble-background: #d9ebff;
|
|
||||||
--incoming-bubble-text: #183759;
|
|
||||||
--outgoing-bubble-background: #d9f5df;
|
|
||||||
--outgoing-bubble-text: #1e4d2f;
|
|
||||||
--danger-background: #d94b53;
|
|
||||||
--shadow-color: rgba(41, 73, 110, 0.14);
|
|
||||||
color-scheme: light;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
/* Page background: deep obsidian with teal/blue aurora */
|
||||||
:root:not([data-theme]) {
|
|
||||||
--page-text: #eff3ff;
|
|
||||||
--page-text-muted: rgba(231, 238, 249, 0.72);
|
|
||||||
--page-text-soft: rgba(231, 238, 249, 0.84);
|
|
||||||
--page-background:
|
|
||||||
radial-gradient(circle at top left, rgba(129, 244, 215, 0.18), transparent 28%),
|
|
||||||
radial-gradient(circle at top right, rgba(85, 168, 255, 0.18), transparent 24%),
|
|
||||||
linear-gradient(180deg, #08111d 0%, #101d31 100%);
|
|
||||||
--panel-background: rgba(9, 16, 28, 0.78);
|
|
||||||
--panel-alt-background: rgba(15, 27, 44, 0.78);
|
|
||||||
--panel-soft-background: rgba(255, 255, 255, 0.04);
|
|
||||||
--surface-background: rgba(8, 14, 23, 0.7);
|
|
||||||
--surface-hover-background: rgba(16, 30, 49, 0.92);
|
|
||||||
--surface-border: rgba(255, 255, 255, 0.12);
|
|
||||||
--surface-border-soft: rgba(255, 255, 255, 0.08);
|
|
||||||
--input-background: rgba(255, 255, 255, 0.06);
|
|
||||||
--input-border: rgba(255, 255, 255, 0.16);
|
|
||||||
--placeholder-color: rgba(239, 243, 255, 0.5);
|
|
||||||
--accent-color: #81f4d7;
|
|
||||||
--accent-color-soft: rgba(129, 244, 215, 0.1);
|
|
||||||
--accent-gradient: linear-gradient(135deg, #81f4d7, #55a8ff);
|
|
||||||
--accent-gradient-hover: linear-gradient(135deg, #9bf7e0, #7abaff);
|
|
||||||
--link-color: #9bd5ff;
|
|
||||||
--badge-background: rgba(255, 255, 255, 0.08);
|
|
||||||
--incoming-bubble-background: #dcefff;
|
|
||||||
--incoming-bubble-text: #0f2540;
|
|
||||||
--outgoing-bubble-background: #def7dd;
|
|
||||||
--outgoing-bubble-text: #153420;
|
|
||||||
--danger-background: #d94b53;
|
|
||||||
--shadow-color: rgba(0, 0, 0, 0.28);
|
|
||||||
color-scheme: dark;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-theme='dark'] {
|
|
||||||
--page-text: #eff3ff;
|
|
||||||
--page-text-muted: rgba(231, 238, 249, 0.72);
|
|
||||||
--page-text-soft: rgba(231, 238, 249, 0.84);
|
|
||||||
--page-background:
|
--page-background:
|
||||||
radial-gradient(circle at top left, rgba(129, 244, 215, 0.18), transparent 28%),
|
radial-gradient(circle at 12% 8%, rgba(13, 148, 136, 0.22), transparent 28%),
|
||||||
radial-gradient(circle at top right, rgba(85, 168, 255, 0.18), transparent 24%),
|
radial-gradient(circle at 90% 5%, rgba(59, 130, 246, 0.18), transparent 24%),
|
||||||
linear-gradient(180deg, #08111d 0%, #101d31 100%);
|
radial-gradient(circle at 50% 95%, rgba(99, 102, 241, 0.12), transparent 30%),
|
||||||
--panel-background: rgba(9, 16, 28, 0.78);
|
linear-gradient(180deg, #030712 0%, #080f1e 100%);
|
||||||
--panel-alt-background: rgba(15, 27, 44, 0.78);
|
|
||||||
--panel-soft-background: rgba(255, 255, 255, 0.04);
|
/* Panels */
|
||||||
--surface-background: rgba(8, 14, 23, 0.7);
|
--panel-background: rgba(11, 17, 32, 0.72);
|
||||||
--surface-hover-background: rgba(16, 30, 49, 0.92);
|
--panel-alt-background: rgba(15, 23, 42, 0.78);
|
||||||
--surface-border: rgba(255, 255, 255, 0.12);
|
--panel-soft-background: rgba(255, 255, 255, 0.04);
|
||||||
--surface-border-soft: rgba(255, 255, 255, 0.08);
|
|
||||||
--input-background: rgba(255, 255, 255, 0.06);
|
/* Surfaces */
|
||||||
--input-border: rgba(255, 255, 255, 0.16);
|
--surface-background: rgba(8, 14, 24, 0.65);
|
||||||
--placeholder-color: rgba(239, 243, 255, 0.5);
|
--surface-hover-background: rgba(17, 28, 50, 0.92);
|
||||||
--accent-color: #81f4d7;
|
--surface-border: rgba(255, 255, 255, 0.08);
|
||||||
--accent-color-soft: rgba(129, 244, 215, 0.1);
|
--surface-border-soft: rgba(255, 255, 255, 0.05);
|
||||||
--accent-gradient: linear-gradient(135deg, #81f4d7, #55a8ff);
|
|
||||||
--accent-gradient-hover: linear-gradient(135deg, #9bf7e0, #7abaff);
|
/* Inputs */
|
||||||
--link-color: #9bd5ff;
|
--input-background: rgba(0, 0, 0, 0.35);
|
||||||
--badge-background: rgba(255, 255, 255, 0.08);
|
--input-border: rgba(255, 255, 255, 0.12);
|
||||||
--incoming-bubble-background: #dcefff;
|
--placeholder-color: rgba(148, 168, 210, 0.45);
|
||||||
--incoming-bubble-text: #0f2540;
|
|
||||||
--outgoing-bubble-background: #def7dd;
|
/* Accent — teal-to-blue gradient */
|
||||||
--outgoing-bubble-text: #153420;
|
--accent-color: #2dd4bf;
|
||||||
--danger-background: #d94b53;
|
--accent-color-soft: rgba(45, 212, 191, 0.12);
|
||||||
--shadow-color: rgba(0, 0, 0, 0.28);
|
--accent-gradient: linear-gradient(135deg, #0d9488, #3b82f6);
|
||||||
|
--accent-gradient-hover: linear-gradient(135deg, #14b8a6, #60a5fa);
|
||||||
|
|
||||||
|
/* Link */
|
||||||
|
--link-color: #60a5fa;
|
||||||
|
|
||||||
|
/* Badges / Pills */
|
||||||
|
--badge-background: rgba(255, 255, 255, 0.07);
|
||||||
|
|
||||||
|
/* Chat bubbles */
|
||||||
|
--incoming-bubble-background: rgba(30, 58, 138, 0.22);
|
||||||
|
--incoming-bubble-border: rgba(59, 130, 246, 0.28);
|
||||||
|
--incoming-bubble-text: #dbeafe;
|
||||||
|
--outgoing-bubble-background: rgba(6, 78, 59, 0.25);
|
||||||
|
--outgoing-bubble-border: rgba(45, 212, 191, 0.30);
|
||||||
|
--outgoing-bubble-text: #d1fae5;
|
||||||
|
|
||||||
|
/* Status LEDs */
|
||||||
|
--led-ok: #10b981;
|
||||||
|
--led-connecting: #f59e0b;
|
||||||
|
--led-offline: #ef4444;
|
||||||
|
|
||||||
|
/* Shadow */
|
||||||
|
--shadow-color: rgba(0, 0, 0, 0.55);
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
|
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Light theme override */
|
||||||
:root[data-theme='light'] {
|
:root[data-theme='light'] {
|
||||||
|
--page-text: #0f172a;
|
||||||
|
--page-text-muted: rgba(30, 50, 80, 0.66);
|
||||||
|
--page-text-soft: rgba(30, 50, 80, 0.82);
|
||||||
|
--page-background:
|
||||||
|
radial-gradient(circle at 12% 8%, rgba(13, 148, 136, 0.15), transparent 28%),
|
||||||
|
radial-gradient(circle at 90% 5%, rgba(59, 130, 246, 0.12), transparent 24%),
|
||||||
|
linear-gradient(180deg, #f0fafa 0%, #e8f1fb 100%);
|
||||||
|
--panel-background: rgba(255, 255, 255, 0.82);
|
||||||
|
--panel-alt-background: rgba(241, 248, 255, 0.88);
|
||||||
|
--panel-soft-background: rgba(15, 23, 42, 0.04);
|
||||||
|
--surface-background: rgba(255, 255, 255, 0.75);
|
||||||
|
--surface-hover-background: rgba(236, 248, 255, 0.98);
|
||||||
|
--surface-border: rgba(30, 60, 100, 0.12);
|
||||||
|
--surface-border-soft: rgba(30, 60, 100, 0.07);
|
||||||
|
--input-background: rgba(255, 255, 255, 0.9);
|
||||||
|
--input-border: rgba(100, 130, 180, 0.28);
|
||||||
|
--placeholder-color: rgba(60, 90, 130, 0.48);
|
||||||
|
--accent-color: #0d9488;
|
||||||
|
--accent-color-soft: rgba(13, 148, 136, 0.1);
|
||||||
|
--incoming-bubble-background: #dbeafe;
|
||||||
|
--incoming-bubble-border: rgba(59, 130, 246, 0.28);
|
||||||
|
--incoming-bubble-text: #1e3a5f;
|
||||||
|
--outgoing-bubble-background: #d1fae5;
|
||||||
|
--outgoing-bubble-border: rgba(13, 148, 136, 0.28);
|
||||||
|
--outgoing-bubble-text: #064e3b;
|
||||||
|
--shadow-color: rgba(30, 60, 100, 0.14);
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
/* ── Base ────────────────────────────────────────────────────────────────── */
|
||||||
body {
|
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
|
||||||
|
|
||||||
|
html, body {
|
||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,18 +109,28 @@ body {
|
|||||||
background: var(--page-background);
|
background: var(--page-background);
|
||||||
background-attachment: fixed;
|
background-attachment: fixed;
|
||||||
transition:
|
transition:
|
||||||
background 180ms ease,
|
background 220ms ease,
|
||||||
color 180ms ease,
|
color 220ms ease,
|
||||||
border-color 180ms ease,
|
border-color 220ms ease,
|
||||||
box-shadow 180ms ease;
|
box-shadow 220ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button, input, textarea, select {
|
||||||
input,
|
|
||||||
textarea {
|
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
|
::-webkit-scrollbar { width: 5px; height: 5px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(45, 212, 191, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Bootstrap overrides ────────────────────────────────────────────────── */
|
||||||
.text-secondary {
|
.text-secondary {
|
||||||
color: var(--page-text-muted) !important;
|
color: var(--page-text-muted) !important;
|
||||||
}
|
}
|
||||||
@@ -138,27 +140,30 @@ textarea {
|
|||||||
background: var(--badge-background) !important;
|
background: var(--badge-background) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-outline-light {
|
.btn-outline-light,
|
||||||
|
.btn-outline-light:hover,
|
||||||
|
.btn-outline-light:focus-visible {
|
||||||
color: var(--page-text);
|
color: var(--page-text);
|
||||||
border-color: var(--surface-border);
|
border-color: var(--surface-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-outline-light:hover,
|
.btn-outline-light:hover,
|
||||||
.btn-outline-light:focus-visible {
|
.btn-outline-light:focus-visible {
|
||||||
color: var(--page-text);
|
|
||||||
border-color: var(--surface-border);
|
|
||||||
background: var(--panel-soft-background);
|
background: var(--panel-soft-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-outline-light,
|
||||||
|
.btn-outline-secondary {
|
||||||
|
border-color: var(--surface-border);
|
||||||
|
}
|
||||||
|
|
||||||
.btn-outline-secondary {
|
.btn-outline-secondary {
|
||||||
color: var(--page-text-muted);
|
color: var(--page-text-muted);
|
||||||
border-color: var(--surface-border);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-outline-secondary:hover,
|
.btn-outline-secondary:hover,
|
||||||
.btn-outline-secondary:focus-visible {
|
.btn-outline-secondary:focus-visible {
|
||||||
color: var(--page-text);
|
color: var(--page-text);
|
||||||
border-color: var(--surface-border);
|
|
||||||
background: var(--panel-soft-background);
|
background: var(--panel-soft-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,3 +182,455 @@ textarea {
|
|||||||
.alert-warning {
|
.alert-warning {
|
||||||
border: 1px solid var(--surface-border);
|
border: 1px solid var(--surface-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Modals (shared across pages) ───────────────────────────────────────── */
|
||||||
|
.call-choice-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1240;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-choice-card {
|
||||||
|
width: min(100%, 25rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1230;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: rgba(0, 0, 0, 0.65);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-modal {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
width: min(100%, 96rem);
|
||||||
|
height: min(100dvh - 1.5rem, 100%);
|
||||||
|
max-height: 100dvh;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-modal-eyebrow,
|
||||||
|
.call-choice-eyebrow,
|
||||||
|
.bubble-delivery-state {
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-modal-eyebrow {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
color: var(--accent-color);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-modal-close {
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--page-text);
|
||||||
|
background: var(--badge-background);
|
||||||
|
font-size: 1.35rem;
|
||||||
|
line-height: 1;
|
||||||
|
transition: background 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-modal-close:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-modal-body {
|
||||||
|
min-height: 0;
|
||||||
|
max-height: none;
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-choice-eyebrow {
|
||||||
|
margin-bottom: 0.45rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
color: var(--accent-color);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-choice-actions {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-choice-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.85rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
border: 1px solid var(--surface-border);
|
||||||
|
border-radius: 1rem;
|
||||||
|
color: var(--page-text);
|
||||||
|
background: var(--surface-background);
|
||||||
|
text-align: left;
|
||||||
|
transition: border-color 160ms ease, background 160ms ease, transform 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-choice-button:hover,
|
||||||
|
.call-choice-button:focus-visible {
|
||||||
|
border-color: color-mix(in srgb, var(--accent-color) 40%, transparent);
|
||||||
|
background: var(--surface-hover-background);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-choice-icon {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--badge-background);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
border: 1px solid var(--surface-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator-action {
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator-action:not(:disabled) {
|
||||||
|
color: var(--page-text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator-action:not(:disabled):hover,
|
||||||
|
.status-indicator-action:not(:disabled):focus-visible {
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator-action:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Status LEDs ─────────────────────────────────────────────────────────── */
|
||||||
|
.status-led,
|
||||||
|
.peer-tile-delete,
|
||||||
|
.bubble-spinner {
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-led {
|
||||||
|
width: 0.55rem;
|
||||||
|
height: 0.55rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 0 6px currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-led-ok {
|
||||||
|
color: var(--led-ok);
|
||||||
|
background: var(--led-ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-led-connecting {
|
||||||
|
color: var(--led-connecting);
|
||||||
|
background: var(--led-connecting);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-led-offline {
|
||||||
|
color: var(--led-offline);
|
||||||
|
background: var(--led-offline);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Peer dropdown ───────────────────────────────────────────────────────── */
|
||||||
|
.peer-dropdown {
|
||||||
|
position: relative;
|
||||||
|
min-width: min(18rem, 42vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-dropdown-trigger {
|
||||||
|
width: 100%;
|
||||||
|
padding-top: 0.52rem;
|
||||||
|
padding-bottom: 0.52rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-dropdown-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 0.65rem);
|
||||||
|
left: 0;
|
||||||
|
z-index: 4;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.65rem;
|
||||||
|
width: 100%;
|
||||||
|
max-height: calc(3 * 4.55rem + 1.5rem);
|
||||||
|
overflow: auto;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--surface-border);
|
||||||
|
border-radius: 1.2rem;
|
||||||
|
background: var(--panel-background);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
box-shadow: 0 20px 48px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-tile {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 0.85rem 0.75rem 1rem;
|
||||||
|
border: 1px solid var(--surface-border);
|
||||||
|
border-radius: 1rem;
|
||||||
|
color: inherit;
|
||||||
|
background: var(--surface-background);
|
||||||
|
font-size: 1em;
|
||||||
|
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-tile-main {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
color: inherit;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-tile-indicators,
|
||||||
|
.bubble-system-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-tile-indicators {
|
||||||
|
gap: 0.38rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-dropdown-caret {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1;
|
||||||
|
transition: transform 160ms ease;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-dropdown-caret-open {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-tile-delete {
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0.5;
|
||||||
|
transition: opacity 160ms ease, background 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-tile-delete:hover,
|
||||||
|
.peer-tile-delete:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-tile:hover,
|
||||||
|
.peer-tile:focus-visible,
|
||||||
|
.peer-tile-active {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: color-mix(in srgb, var(--accent-color) 38%, transparent);
|
||||||
|
background: var(--surface-hover-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-tile-unread {
|
||||||
|
border-color: #ef4444;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(239, 68, 68, 0.4), 0 0 12px rgba(239, 68, 68, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-tile-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.53rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-tile-title {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.32rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Typing indicator */
|
||||||
|
.peer-typing-dots {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
min-height: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-typing-dots span {
|
||||||
|
width: 0.27rem;
|
||||||
|
height: 0.27rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--accent-color);
|
||||||
|
opacity: 0.35;
|
||||||
|
animation: peer-typing-pulse 900ms infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-typing-dots span:nth-child(2) { animation-delay: 120ms; }
|
||||||
|
.peer-typing-dots span:nth-child(3) { animation-delay: 240ms; }
|
||||||
|
|
||||||
|
.peer-tile-status {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Chat Bubbles ────────────────────────────────────────────────────────── */
|
||||||
|
.bubble-incoming {
|
||||||
|
justify-self: start;
|
||||||
|
color: var(--incoming-bubble-text);
|
||||||
|
background: var(--incoming-bubble-background);
|
||||||
|
border: 1px solid var(--incoming-bubble-border);
|
||||||
|
border-radius: 1.25rem 1.25rem 1.25rem 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-outgoing {
|
||||||
|
justify-self: end;
|
||||||
|
color: var(--outgoing-bubble-text);
|
||||||
|
background: var(--outgoing-bubble-background);
|
||||||
|
border: 1px solid var(--outgoing-bubble-border);
|
||||||
|
border-radius: 1.25rem 1.25rem 0.35rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-pending {
|
||||||
|
opacity: 0.52;
|
||||||
|
filter: grayscale(0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-system {
|
||||||
|
justify-self: center;
|
||||||
|
max-width: 90%;
|
||||||
|
color: var(--page-text-soft);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid var(--surface-border-soft);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-emoji-only {
|
||||||
|
max-width: none;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.45rem;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
opacity: 0.65;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-time { display: block; }
|
||||||
|
|
||||||
|
.bubble-delivery-state {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-system-status {
|
||||||
|
gap: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-spinner {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border: 0.15rem solid currentColor;
|
||||||
|
border-right-color: transparent;
|
||||||
|
opacity: 0.8;
|
||||||
|
animation: bubble-spin 700ms linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Keyframes ───────────────────────────────────────────────────────────── */
|
||||||
|
@keyframes peer-typing-pulse {
|
||||||
|
0%, 80%, 100% { opacity: 0.35; transform: translateY(0); }
|
||||||
|
40% { opacity: 1; transform: translateY(-2px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bubble-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes glow-pulse {
|
||||||
|
0%, 100% { opacity: 0.5; box-shadow: 0 0 6px currentColor; }
|
||||||
|
50% { opacity: 1; box-shadow: 0 0 14px currentColor; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-led-ok { animation: glow-pulse 3s ease-in-out infinite; }
|
||||||
|
|
||||||
|
/* ── Global accent button (used across pages) ───────────────────────────── */
|
||||||
|
.btn-accent,
|
||||||
|
.btn-accent:hover,
|
||||||
|
.btn-accent:focus-visible {
|
||||||
|
color: #fff;
|
||||||
|
border: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-accent {
|
||||||
|
background: var(--accent-gradient);
|
||||||
|
box-shadow: 0 4px 20px rgba(13, 148, 136, 0.3);
|
||||||
|
transition: opacity 160ms ease, transform 160ms ease, box-shadow 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-accent:hover,
|
||||||
|
.btn-accent:focus-visible {
|
||||||
|
background: var(--accent-gradient-hover);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 8px 28px rgba(13, 148, 136, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-accent:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mobile ──────────────────────────────────────────────────────────────── */
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.peer-dropdown { min-width: min(100%, 18rem); }
|
||||||
|
|
||||||
|
.conversation-modal-backdrop { padding: 0; }
|
||||||
|
|
||||||
|
.conversation-modal {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100dvh;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
1023
package-lock.json
generated
1023
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -7,9 +7,11 @@
|
|||||||
"dev:server": "npm run dev --prefix server",
|
"dev:server": "npm run dev --prefix server",
|
||||||
"dev:client": "npm run start --prefix client",
|
"dev:client": "npm run start --prefix client",
|
||||||
"build": "npm run build --prefix server && npm run build --prefix client",
|
"build": "npm run build --prefix server && npm run build --prefix client",
|
||||||
"start": "npm run build && npm run start --prefix server"
|
"start": "npm run build && npm run start --prefix server",
|
||||||
|
"restart": "npm run build && sudo systemctl restart privatechat.service"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^9.2.1"
|
"concurrently": "^9.2.1",
|
||||||
|
"puppeteer": "^24.41.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
560
server/dist/index.js
vendored
560
server/dist/index.js
vendored
@@ -1,14 +1,17 @@
|
|||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { TextEncoder } from 'node:util';
|
import { promisify, TextEncoder } from 'node:util';
|
||||||
import { DatabaseSync } from 'node:sqlite';
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
import cors from '@fastify/cors';
|
import cors from '@fastify/cors';
|
||||||
import jwt from '@fastify/jwt';
|
import jwt from '@fastify/jwt';
|
||||||
import fastifyStatic from '@fastify/static';
|
import fastifyStatic from '@fastify/static';
|
||||||
import websocket from '@fastify/websocket';
|
import websocket from '@fastify/websocket';
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
|
import libreOffice from 'libreoffice-convert';
|
||||||
import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse, } from '@simplewebauthn/server';
|
import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse, } from '@simplewebauthn/server';
|
||||||
import Fastify from 'fastify';
|
import Fastify from 'fastify';
|
||||||
import { Redis } from 'ioredis';
|
import { Redis } from 'ioredis';
|
||||||
@@ -40,6 +43,17 @@ const verifyAccessKeyAuthenticationSchema = z.object({
|
|||||||
const approvePendingUserParamsSchema = z.object({
|
const approvePendingUserParamsSchema = z.object({
|
||||||
userId: z.string().min(1),
|
userId: z.string().min(1),
|
||||||
});
|
});
|
||||||
|
const adminDeleteUserParamsSchema = z.object({
|
||||||
|
userId: z.string().min(1),
|
||||||
|
});
|
||||||
|
const webBundleFileParamsSchema = z.object({
|
||||||
|
'*': z.string().min(1),
|
||||||
|
});
|
||||||
|
const documentPreviewSchema = z.object({
|
||||||
|
fileName: z.string().trim().min(1).max(256),
|
||||||
|
mimeType: z.string().trim().min(1).max(256),
|
||||||
|
fileBase64: z.string().min(1).max(96_000_000),
|
||||||
|
});
|
||||||
const wsQuerySchema = z.object({
|
const wsQuerySchema = z.object({
|
||||||
token: z.string().min(1),
|
token: z.string().min(1),
|
||||||
});
|
});
|
||||||
@@ -66,20 +80,37 @@ const signalMessageSchema = z.discriminatedUnion('type', [
|
|||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
}),
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal('image-generation'),
|
||||||
|
requestId: z.string().uuid(),
|
||||||
|
peerId: z.string().min(1),
|
||||||
|
prompt: z.string().trim().min(1).max(4000),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal('ping'),
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
const app = Fastify({ logger: true });
|
const app = Fastify({ logger: true, trustProxy: true });
|
||||||
const approvalAdminUsername = 'ladparis';
|
const approvalAdminUsername = 'ladparis';
|
||||||
const dataDirectory = resolveStoragePath(process.env.PRIVATECHAT_DATA_DIR ?? 'server/data');
|
const dataDirectory = resolveStoragePath(process.env.PRIVATECHAT_DATA_DIR ?? 'server/data');
|
||||||
const sqlitePath = resolveStoragePath(process.env.SQLITE_PATH ?? path.join(dataDirectory, 'privatechat.sqlite'));
|
const sqlitePath = resolveStoragePath(process.env.SQLITE_PATH ?? path.join(dataDirectory, 'privatechat.sqlite'));
|
||||||
const masterKeyPath = resolveStoragePath(process.env.PRIVATECHAT_MASTER_KEY_PATH ?? path.join(dataDirectory, 'master.key'));
|
const masterKeyPath = resolveStoragePath(process.env.PRIVATECHAT_MASTER_KEY_PATH ?? path.join(dataDirectory, 'master.key'));
|
||||||
const frontendDistPath = resolveProjectPath(process.env.PRIVATECHAT_WEB_DIST_DIR ?? 'client/dist/client/browser');
|
const frontendDistPath = resolveProjectPath(process.env.PRIVATECHAT_WEB_DIST_DIR ?? 'client/dist/client/browser');
|
||||||
|
const ollamaServerUrl = (process.env.PRIVATECHAT_OLLAMA_URL ?? 'http://192.168.1.19:11434').replace(/\/+$/, '');
|
||||||
|
const ollamaImageModel = process.env.PRIVATECHAT_OLLAMA_IMAGE_MODEL ?? 'x/z-image-turbo:latest';
|
||||||
|
const ollamaImageSize = process.env.PRIVATECHAT_OLLAMA_IMAGE_SIZE ?? '1024x1024';
|
||||||
const sessionTtlSeconds = Number(process.env.SESSION_TTL_SECONDS ?? 60 * 60 * 12);
|
const sessionTtlSeconds = Number(process.env.SESSION_TTL_SECONDS ?? 60 * 60 * 12);
|
||||||
const webAuthnChallengeTtlSeconds = Number(process.env.WEBAUTHN_CHALLENGE_TTL_SECONDS ?? 5 * 60);
|
const webAuthnChallengeTtlSeconds = Number(process.env.WEBAUTHN_CHALLENGE_TTL_SECONDS ?? 5 * 60);
|
||||||
|
const allowedCorsOrigins = parseAllowedOrigins(process.env.CORS_ORIGIN);
|
||||||
|
const corsAllowedHeaders = ['Authorization', 'Content-Type'];
|
||||||
|
const corsMethods = ['GET', 'POST', 'OPTIONS'];
|
||||||
const webAuthnOrigin = process.env.WEBAUTHN_ORIGIN ?? 'http://localhost:4200';
|
const webAuthnOrigin = process.env.WEBAUTHN_ORIGIN ?? 'http://localhost:4200';
|
||||||
const webAuthnRpName = process.env.WEBAUTHN_RP_NAME ?? 'PrivateChat';
|
const webAuthnRpName = process.env.WEBAUTHN_RP_NAME ?? 'PrivateChat';
|
||||||
const webAuthnUserVerification = resolveWebAuthnUserVerification(process.env.WEBAUTHN_USER_VERIFICATION);
|
const webAuthnUserVerification = resolveWebAuthnUserVerification(process.env.WEBAUTHN_USER_VERIFICATION);
|
||||||
const frontendIndexPath = path.join(frontendDistPath, 'index.html');
|
const frontendIndexPath = path.join(frontendDistPath, 'index.html');
|
||||||
const hasFrontendBuild = fs.existsSync(frontendIndexPath);
|
const hasFrontendBuild = fs.existsSync(frontendIndexPath);
|
||||||
|
const convertOfficeDocument = promisify(libreOffice.convertWithOptions);
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
fs.mkdirSync(path.dirname(sqlitePath), { recursive: true });
|
fs.mkdirSync(path.dirname(sqlitePath), { recursive: true });
|
||||||
fs.mkdirSync(path.dirname(masterKeyPath), { recursive: true });
|
fs.mkdirSync(path.dirname(masterKeyPath), { recursive: true });
|
||||||
const encryptionKey = deriveEncryptionKey(loadOrCreateMasterKey(masterKeyPath));
|
const encryptionKey = deriveEncryptionKey(loadOrCreateMasterKey(masterKeyPath));
|
||||||
@@ -134,6 +165,11 @@ const selectPendingUsersStatement = database.prepare(`
|
|||||||
WHERE is_active = 0
|
WHERE is_active = 0
|
||||||
ORDER BY created_at ASC
|
ORDER BY created_at ASC
|
||||||
`);
|
`);
|
||||||
|
const selectAllUsersStatement = database.prepare(`
|
||||||
|
SELECT id, username, display_name, encrypted_credentials, is_active, created_at, approved_at
|
||||||
|
FROM users
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`);
|
||||||
const approveUserStatement = database.prepare(`
|
const approveUserStatement = database.prepare(`
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET is_active = 1, approved_at = ?
|
SET is_active = 1, approved_at = ?
|
||||||
@@ -168,18 +204,30 @@ const selectAccessKeyByCredentialIdStatement = database.prepare(`
|
|||||||
FROM webauthn_credentials
|
FROM webauthn_credentials
|
||||||
WHERE credential_id = ?
|
WHERE credential_id = ?
|
||||||
`);
|
`);
|
||||||
|
const deleteAccessKeysByUserStatement = database.prepare(`
|
||||||
|
DELETE FROM webauthn_credentials
|
||||||
|
WHERE user_id = ?
|
||||||
|
`);
|
||||||
const updateAccessKeyStatement = database.prepare(`
|
const updateAccessKeyStatement = database.prepare(`
|
||||||
UPDATE webauthn_credentials
|
UPDATE webauthn_credentials
|
||||||
SET encrypted_registration = ?
|
SET encrypted_registration = ?
|
||||||
WHERE credential_id = ?
|
WHERE credential_id = ?
|
||||||
`);
|
`);
|
||||||
|
const deleteUserStatement = database.prepare(`
|
||||||
|
DELETE FROM users
|
||||||
|
WHERE id = ?
|
||||||
|
`);
|
||||||
const jwtSecret = loadOrCreateSecret('jwt-secret', () => crypto.randomBytes(64).toString('hex'));
|
const jwtSecret = loadOrCreateSecret('jwt-secret', () => crypto.randomBytes(64).toString('hex'));
|
||||||
const redis = new Redis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379/0');
|
const redis = new Redis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379/0');
|
||||||
const socketsByUserId = new Map();
|
const socketsByUserId = new Map();
|
||||||
await redis.ping();
|
await redis.ping();
|
||||||
await app.register(cors, {
|
await app.register(cors, {
|
||||||
origin: process.env.CORS_ORIGIN ? [process.env.CORS_ORIGIN] : true,
|
origin(origin, callback) {
|
||||||
|
callback(null, isAllowedRequestOrigin(origin));
|
||||||
|
},
|
||||||
credentials: false,
|
credentials: false,
|
||||||
|
allowedHeaders: corsAllowedHeaders,
|
||||||
|
methods: corsMethods,
|
||||||
});
|
});
|
||||||
await app.register(jwt, {
|
await app.register(jwt, {
|
||||||
secret: jwtSecret,
|
secret: jwtSecret,
|
||||||
@@ -205,6 +253,45 @@ else {
|
|||||||
app.log.warn({ frontendDistPath }, 'Angular frontend build not found. Build the client before serving it from the backend.');
|
app.log.warn({ frontendDistPath }, 'Angular frontend build not found. Build the client before serving it from the backend.');
|
||||||
}
|
}
|
||||||
app.get('/api/health', async () => ({ ok: true }));
|
app.get('/api/health', async () => ({ ok: true }));
|
||||||
|
app.get('/api/web-app/manifest', async (request, reply) => {
|
||||||
|
const manifest = getFrontendBundleManifest();
|
||||||
|
if (!manifest) {
|
||||||
|
return reply.code(404).send({
|
||||||
|
message: 'Angular frontend build not found.',
|
||||||
|
frontendDistPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const etag = `"${manifest.bundleId}"`;
|
||||||
|
reply.header('Cache-Control', 'no-cache');
|
||||||
|
reply.header('ETag', etag);
|
||||||
|
if (requestMatchesEtag(request.headers['if-none-match'], etag)) {
|
||||||
|
return reply.code(304).send();
|
||||||
|
}
|
||||||
|
return manifest;
|
||||||
|
});
|
||||||
|
app.get('/api/web-app/files/*', async (request, reply) => {
|
||||||
|
const parsed = webBundleFileParamsSchema.safeParse(request.params);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
message: 'Invalid web bundle asset path.',
|
||||||
|
issues: parsed.error.flatten(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const asset = resolveFrontendBundleAsset(parsed.data['*']);
|
||||||
|
if (!asset) {
|
||||||
|
return reply.code(404).send({ message: 'Frontend asset not found.' });
|
||||||
|
}
|
||||||
|
const etag = `W/"${asset.etag}"`;
|
||||||
|
reply.header('Cache-Control', 'public, max-age=300');
|
||||||
|
reply.header('ETag', etag);
|
||||||
|
reply.header('Last-Modified', new Date(asset.lastModifiedMs).toUTCString());
|
||||||
|
if (requestMatchesEtag(request.headers['if-none-match'], etag)) {
|
||||||
|
return reply.code(304).send();
|
||||||
|
}
|
||||||
|
reply.header('Content-Length', String(asset.size));
|
||||||
|
reply.type(asset.contentType);
|
||||||
|
return reply.send(fs.createReadStream(asset.absolutePath));
|
||||||
|
});
|
||||||
app.post('/api/auth/register', async (request, reply) => {
|
app.post('/api/auth/register', async (request, reply) => {
|
||||||
const parsed = registerSchema.safeParse(request.body);
|
const parsed = registerSchema.safeParse(request.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@@ -370,6 +457,44 @@ app.get('/api/auth/session', async (request, reply) => {
|
|||||||
messageEncryptionKey: authContext.user.messageEncryptionKey,
|
messageEncryptionKey: authContext.user.messageEncryptionKey,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
app.get('/api/users', async (request, reply) => {
|
||||||
|
const authContext = await authenticateRequest(request, reply);
|
||||||
|
if (!authContext) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
users: listDiscoverableUsers(authContext.user.id),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
app.post('/api/files/document-preview-image', { bodyLimit: 64 * 1024 * 1024 }, async (request, reply) => {
|
||||||
|
const authContext = await authenticateRequest(request, reply);
|
||||||
|
if (!authContext) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = documentPreviewSchema.safeParse(request.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
message: 'Invalid document preview payload.',
|
||||||
|
issues: parsed.error.flatten(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!isSupportedPreviewDocument(parsed.data.fileName, parsed.data.mimeType)) {
|
||||||
|
return reply.code(400).send({ message: 'Only PDF, DOCX, XLSX, and PPTX files can be previewed.' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const previewImageBuffer = await createDocumentPreviewImage(parsed.data.fileName, parsed.data.mimeType, parsed.data.fileBase64);
|
||||||
|
return {
|
||||||
|
mimeType: 'image/png',
|
||||||
|
imageBase64: previewImageBuffer.toString('base64'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
app.log.warn({ err: error, userId: authContext.user.id }, 'Document preview generation failed');
|
||||||
|
return reply.code(422).send({
|
||||||
|
message: describeDocumentPreviewFailure(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
app.get('/api/admin/pending-users', async (request, reply) => {
|
app.get('/api/admin/pending-users', async (request, reply) => {
|
||||||
const authContext = await authenticateRequest(request, reply);
|
const authContext = await authenticateRequest(request, reply);
|
||||||
if (!authContext) {
|
if (!authContext) {
|
||||||
@@ -405,6 +530,41 @@ app.post('/api/admin/pending-users/:userId/approve', async (request, reply) => {
|
|||||||
user: toPublicUser(approvedUser),
|
user: toPublicUser(approvedUser),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
app.get('/api/admin/users', async (request, reply) => {
|
||||||
|
const authContext = await authenticateRequest(request, reply);
|
||||||
|
if (!authContext) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isApprovalAdmin(authContext.user)) {
|
||||||
|
return reply.code(403).send({ message: 'Only ladparis can delete users.' });
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
users: listAdminUsers(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
app.delete('/api/admin/users/:userId', async (request, reply) => {
|
||||||
|
const authContext = await authenticateRequest(request, reply);
|
||||||
|
if (!authContext) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isApprovalAdmin(authContext.user)) {
|
||||||
|
return reply.code(403).send({ message: 'Only ladparis can delete users.' });
|
||||||
|
}
|
||||||
|
const parsed = adminDeleteUserParamsSchema.safeParse(request.params);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
message: 'Invalid user deletion request.',
|
||||||
|
issues: parsed.error.flatten(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const deletedUser = await deleteUserAccount(parsed.data.userId);
|
||||||
|
if (!deletedUser) {
|
||||||
|
return reply.code(404).send({ message: 'User not found.' });
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
user: toPublicUser(deletedUser),
|
||||||
|
};
|
||||||
|
});
|
||||||
app.post('/api/auth/logout', async (request, reply) => {
|
app.post('/api/auth/logout', async (request, reply) => {
|
||||||
const authContext = await authenticateRequest(request, reply);
|
const authContext = await authenticateRequest(request, reply);
|
||||||
if (!authContext) {
|
if (!authContext) {
|
||||||
@@ -526,6 +686,11 @@ const port = Number(process.env.PORT ?? 16990);
|
|||||||
await app.listen({ port, host: '0.0.0.0' });
|
await app.listen({ port, host: '0.0.0.0' });
|
||||||
app.log.info(`PrivateChat signaling server listening on http://localhost:${port}`);
|
app.log.info(`PrivateChat signaling server listening on http://localhost:${port}`);
|
||||||
async function openSocket(socket, request) {
|
async function openSocket(socket, request) {
|
||||||
|
if (!isAllowedRequestOrigin(request.headers.origin)) {
|
||||||
|
send(socket, { type: 'error', message: 'Origin not allowed.' });
|
||||||
|
socket.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const query = wsQuerySchema.safeParse(request.query);
|
const query = wsQuerySchema.safeParse(request.query);
|
||||||
if (!query.success) {
|
if (!query.success) {
|
||||||
send(socket, { type: 'error', message: 'Missing token.' });
|
send(socket, { type: 'error', message: 'Missing token.' });
|
||||||
@@ -574,6 +739,34 @@ async function handleSocketMessage(userId, sessionId, socket, rawMessage) {
|
|||||||
send(socket, { type: 'error', message: 'Unsupported signaling message.' });
|
send(socket, { type: 'error', message: 'Unsupported signaling message.' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (parsed.type === 'ping') {
|
||||||
|
send(socket, { type: 'pong' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (parsed.type === 'image-generation') {
|
||||||
|
try {
|
||||||
|
const generatedImage = await generateImageFromPrompt(parsed.prompt);
|
||||||
|
send(socket, {
|
||||||
|
type: 'image-generated',
|
||||||
|
requestId: parsed.requestId,
|
||||||
|
peerId: parsed.peerId,
|
||||||
|
prompt: parsed.prompt,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
mimeType: generatedImage.mimeType,
|
||||||
|
imageBase64: generatedImage.imageBase64,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
app.log.warn({ err: error, userId, peerId: parsed.peerId }, 'Ollama image generation failed');
|
||||||
|
send(socket, {
|
||||||
|
type: 'image-generation-error',
|
||||||
|
requestId: parsed.requestId,
|
||||||
|
peerId: parsed.peerId,
|
||||||
|
message: error instanceof Error ? error.message : 'Image generation failed.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
let delivered = 0;
|
let delivered = 0;
|
||||||
const recipientSockets = socketsByUserId.get(parsed.to);
|
const recipientSockets = socketsByUserId.get(parsed.to);
|
||||||
if (recipientSockets) {
|
if (recipientSockets) {
|
||||||
@@ -656,6 +849,75 @@ async function authenticateTokenFromSession(userId, sessionId, decoded) {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
async function convertOfficeDocumentToPdf(fileName, fileBase64) {
|
||||||
|
const inputBuffer = Buffer.from(fileBase64, 'base64');
|
||||||
|
if (inputBuffer.byteLength === 0) {
|
||||||
|
throw new Error('The uploaded office document is empty.');
|
||||||
|
}
|
||||||
|
const normalizedFileName = normalizeOfficeDocumentFileName(fileName);
|
||||||
|
return convertOfficeDocument(inputBuffer, '.pdf', undefined, { fileName: normalizedFileName });
|
||||||
|
}
|
||||||
|
async function createDocumentPreviewImage(fileName, mimeType, fileBase64) {
|
||||||
|
const normalizedMimeType = mimeType.trim().toLowerCase();
|
||||||
|
const pdfBuffer = normalizedMimeType === 'application/pdf'
|
||||||
|
? decodeBase64File(fileBase64, 'The uploaded PDF is empty.')
|
||||||
|
: await convertOfficeDocumentToPdf(fileName, fileBase64);
|
||||||
|
return renderPdfFirstPageToPng(pdfBuffer);
|
||||||
|
}
|
||||||
|
async function renderPdfFirstPageToPng(pdfBuffer) {
|
||||||
|
const tempDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'privatechat-preview-'));
|
||||||
|
const pdfPath = path.join(tempDirectory, 'source.pdf');
|
||||||
|
const outputBasePath = path.join(tempDirectory, 'page-preview');
|
||||||
|
const imagePath = `${outputBasePath}.png`;
|
||||||
|
try {
|
||||||
|
await fs.promises.writeFile(pdfPath, pdfBuffer);
|
||||||
|
await execFileAsync('pdftoppm', ['-png', '-f', '1', '-singlefile', pdfPath, outputBasePath]);
|
||||||
|
return await fs.promises.readFile(imagePath);
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
await fs.promises.rm(tempDirectory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function decodeBase64File(fileBase64, emptyMessage) {
|
||||||
|
const inputBuffer = Buffer.from(fileBase64, 'base64');
|
||||||
|
if (inputBuffer.byteLength === 0) {
|
||||||
|
throw new Error(emptyMessage);
|
||||||
|
}
|
||||||
|
return inputBuffer;
|
||||||
|
}
|
||||||
|
function isSupportedPreviewDocument(fileName, mimeType) {
|
||||||
|
if (isPdfFile(fileName, mimeType)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return isSupportedOfficeDocument(fileName, mimeType);
|
||||||
|
}
|
||||||
|
function isSupportedOfficeDocument(fileName, mimeType) {
|
||||||
|
const normalizedFileName = fileName.trim().toLowerCase();
|
||||||
|
const normalizedMimeType = mimeType.trim().toLowerCase();
|
||||||
|
if (normalizedMimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
||||||
|
|| normalizedMimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
|| normalizedMimeType === 'application/vnd.openxmlformats-officedocument.presentationml.presentation') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return /\.(docx|xlsx|xslx|pptx)$/i.test(normalizedFileName);
|
||||||
|
}
|
||||||
|
function isPdfFile(fileName, mimeType) {
|
||||||
|
const normalizedFileName = fileName.trim().toLowerCase();
|
||||||
|
const normalizedMimeType = mimeType.trim().toLowerCase();
|
||||||
|
return normalizedMimeType === 'application/pdf' || normalizedFileName.endsWith('.pdf');
|
||||||
|
}
|
||||||
|
function normalizeOfficeDocumentFileName(fileName) {
|
||||||
|
return fileName.trim().replace(/\.xslx$/i, '.xlsx');
|
||||||
|
}
|
||||||
|
function describeDocumentPreviewFailure(error) {
|
||||||
|
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
||||||
|
return 'Document preview generation failed because a required conversion tool is missing on the server.';
|
||||||
|
}
|
||||||
|
if (error instanceof Error && error.message.trim()) {
|
||||||
|
return `Document preview generation failed: ${error.message}`;
|
||||||
|
}
|
||||||
|
return 'Document preview generation failed.';
|
||||||
|
}
|
||||||
function createUser(input) {
|
function createUser(input) {
|
||||||
const createdAt = new Date().toISOString();
|
const createdAt = new Date().toISOString();
|
||||||
const user = {
|
const user = {
|
||||||
@@ -683,6 +945,28 @@ function listPendingApprovalUsers() {
|
|||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
function listAdminUsers() {
|
||||||
|
const rows = selectAllUsersStatement.all();
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
username: row.username,
|
||||||
|
displayName: row.display_name,
|
||||||
|
isActive: row.is_active === 1,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
approvedAt: row.approved_at,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
function listDiscoverableUsers(currentUserId) {
|
||||||
|
const rows = selectAllUsersStatement.all();
|
||||||
|
return rows
|
||||||
|
.filter((row) => row.is_active === 1 && row.id !== currentUserId)
|
||||||
|
.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
username: row.username,
|
||||||
|
displayName: row.display_name,
|
||||||
|
}))
|
||||||
|
.sort((left, right) => left.displayName.localeCompare(right.displayName) || left.username.localeCompare(right.username));
|
||||||
|
}
|
||||||
function approveUser(userId) {
|
function approveUser(userId) {
|
||||||
const approvedAt = new Date().toISOString();
|
const approvedAt = new Date().toISOString();
|
||||||
const result = approveUserStatement.run(approvedAt, userId);
|
const result = approveUserStatement.run(approvedAt, userId);
|
||||||
@@ -691,6 +975,19 @@ function approveUser(userId) {
|
|||||||
}
|
}
|
||||||
return findUserById(userId);
|
return findUserById(userId);
|
||||||
}
|
}
|
||||||
|
async function deleteUserAccount(userId) {
|
||||||
|
const user = findUserById(userId);
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
deleteAccessKeysByUserStatement.run(userId);
|
||||||
|
const result = deleteUserStatement.run(userId);
|
||||||
|
if (result.changes === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
await destroyUserSessions(userId);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
function persistAccessKey(userId, input) {
|
function persistAccessKey(userId, input) {
|
||||||
createAccessKeyStatement.run(crypto.randomUUID(), userId, input.credentialId, input.label, encryptJson({
|
createAccessKeyStatement.run(crypto.randomUUID(), userId, input.credentialId, input.label, encryptJson({
|
||||||
credentialId: input.credentialId,
|
credentialId: input.credentialId,
|
||||||
@@ -802,6 +1099,32 @@ async function getSession(sessionId) {
|
|||||||
async function destroySession(sessionId) {
|
async function destroySession(sessionId) {
|
||||||
await redis.del(sessionKey(sessionId));
|
await redis.del(sessionKey(sessionId));
|
||||||
}
|
}
|
||||||
|
async function destroyUserSessions(userId) {
|
||||||
|
let cursor = '0';
|
||||||
|
do {
|
||||||
|
const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', `${sessionKey('*')}`, 'COUNT', 100);
|
||||||
|
cursor = nextCursor;
|
||||||
|
for (const key of keys) {
|
||||||
|
const payload = await redis.get(key);
|
||||||
|
if (!payload) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let session = null;
|
||||||
|
try {
|
||||||
|
session = JSON.parse(payload);
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
session = null;
|
||||||
|
}
|
||||||
|
if (!session || session.userId !== userId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await destroySession(session.sessionId);
|
||||||
|
await clearPendingRegistration(session.sessionId);
|
||||||
|
closeSocketSession(userId, session.sessionId);
|
||||||
|
}
|
||||||
|
} while (cursor !== '0');
|
||||||
|
}
|
||||||
function sessionKey(sessionId) {
|
function sessionKey(sessionId) {
|
||||||
return `privatechat:session:${sessionId}`;
|
return `privatechat:session:${sessionId}`;
|
||||||
}
|
}
|
||||||
@@ -889,12 +1212,214 @@ function parseClientMessage(rawMessage) {
|
|||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (parsed.data.type === 'ping') {
|
||||||
|
return { type: 'ping' };
|
||||||
|
}
|
||||||
|
if (parsed.data.type === 'image-generation') {
|
||||||
|
return {
|
||||||
|
type: 'image-generation',
|
||||||
|
requestId: parsed.data.requestId,
|
||||||
|
peerId: parsed.data.peerId,
|
||||||
|
prompt: parsed.data.prompt,
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
type: 'signal',
|
type: 'signal',
|
||||||
to: parsed.data.to,
|
to: parsed.data.to,
|
||||||
signal: normalizeSignal(parsed.data.signal),
|
signal: normalizeSignal(parsed.data.signal),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
async function generateImageFromPrompt(prompt) {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => abortController.abort(), 120_000);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${ollamaServerUrl}/v1/images/generations`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: ollamaImageModel,
|
||||||
|
prompt,
|
||||||
|
size: ollamaImageSize,
|
||||||
|
response_format: 'b64_json',
|
||||||
|
n: 1,
|
||||||
|
}),
|
||||||
|
signal: abortController.signal,
|
||||||
|
});
|
||||||
|
const payload = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorMessage = typeof payload.error === 'string'
|
||||||
|
? payload.error
|
||||||
|
: payload.error?.message;
|
||||||
|
throw new Error(errorMessage || 'Ollama image generation request failed.');
|
||||||
|
}
|
||||||
|
const imageBase64 = payload.data?.[0]?.b64_json?.trim();
|
||||||
|
if (!imageBase64) {
|
||||||
|
throw new Error('Ollama did not return image data.');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
imageBase64,
|
||||||
|
mimeType: inferImageMimeType(Buffer.from(imageBase64, 'base64')),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
throw new Error('Ollama image generation timed out.');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function inferImageMimeType(imageBuffer) {
|
||||||
|
if (imageBuffer.length >= 8 && imageBuffer.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) {
|
||||||
|
return 'image/png';
|
||||||
|
}
|
||||||
|
if (imageBuffer.length >= 3 && imageBuffer.subarray(0, 3).equals(Buffer.from([255, 216, 255]))) {
|
||||||
|
return 'image/jpeg';
|
||||||
|
}
|
||||||
|
if (imageBuffer.length >= 12 &&
|
||||||
|
imageBuffer.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||||||
|
imageBuffer.subarray(8, 12).toString('ascii') === 'WEBP') {
|
||||||
|
return 'image/webp';
|
||||||
|
}
|
||||||
|
if (imageBuffer.length >= 6) {
|
||||||
|
const header = imageBuffer.subarray(0, 6).toString('ascii');
|
||||||
|
if (header === 'GIF87a' || header === 'GIF89a') {
|
||||||
|
return 'image/gif';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'application/octet-stream';
|
||||||
|
}
|
||||||
|
function getFrontendBundleManifest() {
|
||||||
|
if (!fs.existsSync(frontendIndexPath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const files = listBundleFilePaths(frontendDistPath).map((absolutePath) => {
|
||||||
|
const relativePath = toBundleRelativePath(path.relative(frontendDistPath, absolutePath));
|
||||||
|
const stats = fs.statSync(absolutePath);
|
||||||
|
const sha256 = crypto.createHash('sha256').update(fs.readFileSync(absolutePath)).digest('hex');
|
||||||
|
return {
|
||||||
|
path: relativePath,
|
||||||
|
size: stats.size,
|
||||||
|
sha256,
|
||||||
|
lastModified: stats.mtime.toISOString(),
|
||||||
|
contentType: detectBundleContentType(relativePath),
|
||||||
|
href: bundleAssetHref(relativePath),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
files.sort((left, right) => left.path.localeCompare(right.path));
|
||||||
|
const generatedAt = files.reduce((latest, file) => (file.lastModified > latest ? file.lastModified : latest), new Date(0).toISOString());
|
||||||
|
const bundleId = files.reduce((hash, file) => {
|
||||||
|
hash.update(file.path);
|
||||||
|
hash.update(file.sha256);
|
||||||
|
hash.update(String(file.size));
|
||||||
|
return hash;
|
||||||
|
}, crypto.createHash('sha256')).digest('hex');
|
||||||
|
return {
|
||||||
|
bundleId,
|
||||||
|
generatedAt,
|
||||||
|
indexPath: 'index.html',
|
||||||
|
files,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function resolveFrontendBundleAsset(relativeAssetPath) {
|
||||||
|
if (!fs.existsSync(frontendIndexPath) || !fs.existsSync(frontendDistPath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const normalizedPath = toBundleRelativePath(relativeAssetPath);
|
||||||
|
if (normalizedPath.length === 0 ||
|
||||||
|
normalizedPath === '.' ||
|
||||||
|
normalizedPath.startsWith('../') ||
|
||||||
|
normalizedPath.startsWith('/')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const absolutePath = path.resolve(frontendDistPath, normalizedPath);
|
||||||
|
const relativeToRoot = path.relative(frontendDistPath, absolutePath);
|
||||||
|
if (relativeToRoot.startsWith('..') || path.isAbsolute(relativeToRoot) || !fs.existsSync(absolutePath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const stats = fs.statSync(absolutePath);
|
||||||
|
if (!stats.isFile()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
absolutePath,
|
||||||
|
contentType: detectBundleContentType(normalizedPath),
|
||||||
|
size: stats.size,
|
||||||
|
lastModifiedMs: stats.mtimeMs,
|
||||||
|
etag: `${stats.size}-${Math.floor(stats.mtimeMs)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function listBundleFilePaths(rootPath) {
|
||||||
|
return fs.readdirSync(rootPath, { withFileTypes: true }).flatMap((entry) => {
|
||||||
|
const entryPath = path.join(rootPath, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
return listBundleFilePaths(entryPath);
|
||||||
|
}
|
||||||
|
if (!entry.isFile()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [entryPath];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function bundleAssetHref(relativePath) {
|
||||||
|
return `/api/web-app/files/${relativePath.split('/').map((segment) => encodeURIComponent(segment)).join('/')}`;
|
||||||
|
}
|
||||||
|
function toBundleRelativePath(inputPath) {
|
||||||
|
return path.posix.normalize(inputPath.replaceAll('\\', '/'));
|
||||||
|
}
|
||||||
|
function detectBundleContentType(assetPath) {
|
||||||
|
const extension = path.extname(assetPath).toLowerCase();
|
||||||
|
switch (extension) {
|
||||||
|
case '.mp3':
|
||||||
|
return 'audio/mpeg';
|
||||||
|
case '.m4a':
|
||||||
|
return 'audio/mp4';
|
||||||
|
case '.css':
|
||||||
|
return 'text/css; charset=utf-8';
|
||||||
|
case '.html':
|
||||||
|
return 'text/html; charset=utf-8';
|
||||||
|
case '.ico':
|
||||||
|
return 'image/x-icon';
|
||||||
|
case '.jpeg':
|
||||||
|
case '.jpg':
|
||||||
|
return 'image/jpeg';
|
||||||
|
case '.js':
|
||||||
|
return 'text/javascript; charset=utf-8';
|
||||||
|
case '.json':
|
||||||
|
return 'application/json; charset=utf-8';
|
||||||
|
case '.map':
|
||||||
|
return 'application/json; charset=utf-8';
|
||||||
|
case '.png':
|
||||||
|
return 'image/png';
|
||||||
|
case '.svg':
|
||||||
|
return 'image/svg+xml; charset=utf-8';
|
||||||
|
case '.txt':
|
||||||
|
return 'text/plain; charset=utf-8';
|
||||||
|
case '.webp':
|
||||||
|
return 'image/webp';
|
||||||
|
case '.webmanifest':
|
||||||
|
return 'application/manifest+json; charset=utf-8';
|
||||||
|
case '.woff':
|
||||||
|
return 'font/woff';
|
||||||
|
case '.woff2':
|
||||||
|
return 'font/woff2';
|
||||||
|
default:
|
||||||
|
return 'application/octet-stream';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function requestMatchesEtag(headerValue, etag) {
|
||||||
|
if (!headerValue) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const incomingEtags = Array.isArray(headerValue)
|
||||||
|
? headerValue
|
||||||
|
: headerValue.split(',').map((value) => value.trim());
|
||||||
|
return incomingEtags.includes(etag) || incomingEtags.includes('*');
|
||||||
|
}
|
||||||
function normalizeSignal(signal) {
|
function normalizeSignal(signal) {
|
||||||
if (signal.type === 'sdp') {
|
if (signal.type === 'sdp') {
|
||||||
return {
|
return {
|
||||||
@@ -1001,6 +1526,35 @@ function resolveStoragePath(targetPath) {
|
|||||||
function resolveProjectPath(targetPath) {
|
function resolveProjectPath(targetPath) {
|
||||||
return path.isAbsolute(targetPath) ? targetPath : path.resolve(projectRootPath, targetPath);
|
return path.isAbsolute(targetPath) ? targetPath : path.resolve(projectRootPath, targetPath);
|
||||||
}
|
}
|
||||||
|
function parseAllowedOrigins(value) {
|
||||||
|
if (!value) {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
return new Set(value
|
||||||
|
.split(',')
|
||||||
|
.map((origin) => normalizeOrigin(origin))
|
||||||
|
.filter((origin) => origin.length > 0 && origin !== 'null'));
|
||||||
|
}
|
||||||
|
function normalizeOrigin(origin) {
|
||||||
|
const trimmed = origin.trim();
|
||||||
|
if (trimmed === 'null') {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
return trimmed.replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
function isAllowedRequestOrigin(originHeader) {
|
||||||
|
if (!originHeader) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const origin = normalizeOrigin(originHeader);
|
||||||
|
if (origin === 'null') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (allowedCorsOrigins.size === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return allowedCorsOrigins.has(origin);
|
||||||
|
}
|
||||||
function resolveWebAuthnOrigin(request) {
|
function resolveWebAuthnOrigin(request) {
|
||||||
const originHeader = request.headers.origin;
|
const originHeader = request.headers.origin;
|
||||||
if (typeof originHeader === 'string' && originHeader.length > 0) {
|
if (typeof originHeader === 'string' && originHeader.length > 0) {
|
||||||
|
|||||||
356
server/package-lock.json
generated
356
server/package-lock.json
generated
@@ -10,25 +10,27 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
"@fastify/jwt": "^10.0.0",
|
"@fastify/jwt": "^10.0.0",
|
||||||
"@fastify/static": "^9.0.0",
|
"@fastify/static": "^9.1.0",
|
||||||
"@fastify/websocket": "^11.2.0",
|
"@fastify/websocket": "^11.2.0",
|
||||||
"@simplewebauthn/server": "^13.2.3",
|
"@simplewebauthn/server": "^13.3.0",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.4.2",
|
||||||
"fastify": "^5.8.2",
|
"fastify": "^5.8.4",
|
||||||
"ioredis": "^5.10.0",
|
"ioredis": "^5.10.1",
|
||||||
|
"libreoffice-convert": "^1.8.1",
|
||||||
|
"ws": "^8.20.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^25.3.5",
|
"@types/node": "^25.6.0",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
|
||||||
"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
|
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -43,9 +45,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/android-arm": {
|
"node_modules/@esbuild/android-arm": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
|
||||||
"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
|
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -60,9 +62,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/android-arm64": {
|
"node_modules/@esbuild/android-arm64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
|
||||||
"integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
|
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -77,9 +79,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/android-x64": {
|
"node_modules/@esbuild/android-x64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
|
||||||
"integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
|
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -94,9 +96,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/darwin-arm64": {
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
|
||||||
"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
|
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -111,9 +113,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/darwin-x64": {
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
|
||||||
"integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
|
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -128,9 +130,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/freebsd-arm64": {
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
|
||||||
"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
|
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -145,9 +147,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/freebsd-x64": {
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
|
||||||
"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
|
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -162,9 +164,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-arm": {
|
"node_modules/@esbuild/linux-arm": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
|
||||||
"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
|
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -179,9 +181,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-arm64": {
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
|
||||||
"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
|
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -196,9 +198,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-ia32": {
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
|
||||||
"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
|
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -213,9 +215,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-loong64": {
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
|
||||||
"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
|
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
@@ -230,9 +232,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-mips64el": {
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
|
||||||
"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
|
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"mips64el"
|
"mips64el"
|
||||||
],
|
],
|
||||||
@@ -247,9 +249,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-ppc64": {
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
|
||||||
"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
|
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -264,9 +266,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-riscv64": {
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
|
||||||
"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
|
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -281,9 +283,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-s390x": {
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
|
||||||
"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
|
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -298,9 +300,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-x64": {
|
"node_modules/@esbuild/linux-x64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
|
||||||
"integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
|
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -315,9 +317,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/netbsd-arm64": {
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
|
||||||
"integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
|
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -332,9 +334,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/netbsd-x64": {
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
|
||||||
"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
|
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -349,9 +351,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openbsd-arm64": {
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
|
||||||
"integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
|
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -366,9 +368,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openbsd-x64": {
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
|
||||||
"integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
|
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -383,9 +385,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openharmony-arm64": {
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
|
||||||
"integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
|
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -400,9 +402,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/sunos-x64": {
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
|
||||||
"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
|
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -417,9 +419,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/win32-arm64": {
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
|
||||||
"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
|
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -434,9 +436,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/win32-ia32": {
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
|
||||||
"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
|
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -451,9 +453,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/win32-x64": {
|
"node_modules/@esbuild/win32-x64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
|
||||||
"integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
|
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -661,9 +663,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@fastify/static": {
|
"node_modules/@fastify/static": {
|
||||||
"version": "9.0.0",
|
"version": "9.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/static/-/static-9.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/static/-/static-9.1.0.tgz",
|
||||||
"integrity": "sha512-r64H8Woe/vfilg5RTy7lwWlE8ZZcTrc3kebYFMEUBrMqlydhQyoiExQXdYAy2REVpST/G35+stAM8WYp1WGmMA==",
|
"integrity": "sha512-EPRNQYqEYEYTK8yyGbcM0iHpyJaupb94bey5O6iCQfLTADr02kaZU+qeHSdd9H9TiMwTBVkrMa59V8CMbn3avQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -898,9 +900,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@simplewebauthn/server": {
|
"node_modules/@simplewebauthn/server": {
|
||||||
"version": "13.2.3",
|
"version": "13.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.0.tgz",
|
||||||
"integrity": "sha512-ZhcVBOw63birYx9jVfbhK6rTehckVes8PeWV324zpmdxr0BUfylospwMzcrxrdMcOi48MHWj2LCA+S528LnGvg==",
|
"integrity": "sha512-MLHYFrYG8/wK2i+86XMhiecK72nMaHKKt4bo+7Q1TbuG9iGjlSdfkPWKO5ZFE/BX+ygCJ7pr8H/AJeyAj1EaTQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hexagon/base64": "^1.1.27",
|
"@hexagon/base64": "^1.1.27",
|
||||||
@@ -917,13 +919,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "25.3.5",
|
"version": "25.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
|
||||||
"integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==",
|
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.18.0"
|
"undici-types": "~7.19.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/ws": {
|
"node_modules/@types/ws": {
|
||||||
@@ -1001,6 +1003,12 @@
|
|||||||
"node": ">=12.0.0"
|
"node": ">=12.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/async": {
|
||||||
|
"version": "3.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
||||||
|
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/atomic-sleep": {
|
"node_modules/atomic-sleep": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||||
@@ -1046,9 +1054,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "5.0.4",
|
"version": "5.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||||
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
|
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^4.0.2"
|
"balanced-match": "^4.0.2"
|
||||||
@@ -1067,9 +1075,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/content-disposition": {
|
"node_modules/content-disposition": {
|
||||||
"version": "1.0.1",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
|
||||||
"integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
|
"integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
@@ -1137,9 +1145,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dotenv": {
|
"node_modules/dotenv": {
|
||||||
"version": "17.3.1",
|
"version": "17.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
|
||||||
"integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==",
|
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@@ -1179,9 +1187,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
|
||||||
"integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
|
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -1192,32 +1200,32 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@esbuild/aix-ppc64": "0.27.3",
|
"@esbuild/aix-ppc64": "0.27.7",
|
||||||
"@esbuild/android-arm": "0.27.3",
|
"@esbuild/android-arm": "0.27.7",
|
||||||
"@esbuild/android-arm64": "0.27.3",
|
"@esbuild/android-arm64": "0.27.7",
|
||||||
"@esbuild/android-x64": "0.27.3",
|
"@esbuild/android-x64": "0.27.7",
|
||||||
"@esbuild/darwin-arm64": "0.27.3",
|
"@esbuild/darwin-arm64": "0.27.7",
|
||||||
"@esbuild/darwin-x64": "0.27.3",
|
"@esbuild/darwin-x64": "0.27.7",
|
||||||
"@esbuild/freebsd-arm64": "0.27.3",
|
"@esbuild/freebsd-arm64": "0.27.7",
|
||||||
"@esbuild/freebsd-x64": "0.27.3",
|
"@esbuild/freebsd-x64": "0.27.7",
|
||||||
"@esbuild/linux-arm": "0.27.3",
|
"@esbuild/linux-arm": "0.27.7",
|
||||||
"@esbuild/linux-arm64": "0.27.3",
|
"@esbuild/linux-arm64": "0.27.7",
|
||||||
"@esbuild/linux-ia32": "0.27.3",
|
"@esbuild/linux-ia32": "0.27.7",
|
||||||
"@esbuild/linux-loong64": "0.27.3",
|
"@esbuild/linux-loong64": "0.27.7",
|
||||||
"@esbuild/linux-mips64el": "0.27.3",
|
"@esbuild/linux-mips64el": "0.27.7",
|
||||||
"@esbuild/linux-ppc64": "0.27.3",
|
"@esbuild/linux-ppc64": "0.27.7",
|
||||||
"@esbuild/linux-riscv64": "0.27.3",
|
"@esbuild/linux-riscv64": "0.27.7",
|
||||||
"@esbuild/linux-s390x": "0.27.3",
|
"@esbuild/linux-s390x": "0.27.7",
|
||||||
"@esbuild/linux-x64": "0.27.3",
|
"@esbuild/linux-x64": "0.27.7",
|
||||||
"@esbuild/netbsd-arm64": "0.27.3",
|
"@esbuild/netbsd-arm64": "0.27.7",
|
||||||
"@esbuild/netbsd-x64": "0.27.3",
|
"@esbuild/netbsd-x64": "0.27.7",
|
||||||
"@esbuild/openbsd-arm64": "0.27.3",
|
"@esbuild/openbsd-arm64": "0.27.7",
|
||||||
"@esbuild/openbsd-x64": "0.27.3",
|
"@esbuild/openbsd-x64": "0.27.7",
|
||||||
"@esbuild/openharmony-arm64": "0.27.3",
|
"@esbuild/openharmony-arm64": "0.27.7",
|
||||||
"@esbuild/sunos-x64": "0.27.3",
|
"@esbuild/sunos-x64": "0.27.7",
|
||||||
"@esbuild/win32-arm64": "0.27.3",
|
"@esbuild/win32-arm64": "0.27.7",
|
||||||
"@esbuild/win32-ia32": "0.27.3",
|
"@esbuild/win32-ia32": "0.27.7",
|
||||||
"@esbuild/win32-x64": "0.27.3"
|
"@esbuild/win32-x64": "0.27.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/escape-html": {
|
"node_modules/escape-html": {
|
||||||
@@ -1263,15 +1271,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fast-jwt": {
|
"node_modules/fast-jwt": {
|
||||||
"version": "6.1.0",
|
"version": "6.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/fast-jwt/-/fast-jwt-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/fast-jwt/-/fast-jwt-6.2.2.tgz",
|
||||||
"integrity": "sha512-cGK/TXlud8INL49Iv7yRtZy0PHzNJId1shfqNCqdF0gOlWiy+1FPgjxX+ZHp/CYxFYDaoNnxeYEGzcXSkahUEQ==",
|
"integrity": "sha512-lzy+8JVyBOvwxjydFRBKLFVe1elRArL37pHRX1zHPt4T7FP7kNIpqauE1lOjZlD79DBzzRzQmp+28wbsY13weA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lukeed/ms": "^2.0.2",
|
"@lukeed/ms": "^2.0.2",
|
||||||
"asn1.js": "^5.4.1",
|
"asn1.js": "^5.4.1",
|
||||||
"ecdsa-sig-formatter": "^1.0.11",
|
"ecdsa-sig-formatter": "^1.0.11",
|
||||||
"mnemonist": "^0.40.0"
|
"mnemonist": "^0.40.0",
|
||||||
|
"safe-regex2": "^5.1.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
@@ -1315,9 +1324,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fastify": {
|
"node_modules/fastify": {
|
||||||
"version": "5.8.2",
|
"version": "5.8.4",
|
||||||
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.4.tgz",
|
||||||
"integrity": "sha512-lZmt3navvZG915IE+f7/TIVamxIwmBd+OMB+O9WBzcpIwOo6F0LTh0sluoMFk5VkrKTvvrwIaoJPkir4Z+jtAg==",
|
"integrity": "sha512-sa42J1xylbBAYUWALSBoyXKPDUvM3OoNOibIefA+Oha57FryXKKCZarA1iDntOCWp3O35voZLuDg2mdODXtPzQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -1422,9 +1431,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/get-tsconfig": {
|
"node_modules/get-tsconfig": {
|
||||||
"version": "4.13.6",
|
"version": "4.13.7",
|
||||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
|
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz",
|
||||||
"integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
|
"integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1478,9 +1487,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/ioredis": {
|
"node_modules/ioredis": {
|
||||||
"version": "5.10.0",
|
"version": "5.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz",
|
||||||
"integrity": "sha512-HVBe9OFuqs+Z6n64q09PQvP1/R4Bm+30PAyyD4wIEqssh3v9L21QjCVk4kRLucMBcDokJTcLjsGeVRlq/nH6DA==",
|
"integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ioredis/commands": "1.5.1",
|
"@ioredis/commands": "1.5.1",
|
||||||
@@ -1535,6 +1544,19 @@
|
|||||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/libreoffice-convert": {
|
||||||
|
"version": "1.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/libreoffice-convert/-/libreoffice-convert-1.8.1.tgz",
|
||||||
|
"integrity": "sha512-iZ1DD/EMTlPvol8G++QQ/0w4pVecSwRuhMLXRm7nRim/gcaSscSXuTO9Tgbkieyw5UdJg7UXD+lkFT8SCi51Dw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"async": "^3.2.3",
|
||||||
|
"tmp": "^0.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/light-my-request": {
|
"node_modules/light-my-request": {
|
||||||
"version": "6.6.0",
|
"version": "6.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
|
||||||
@@ -1585,9 +1607,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lru-cache": {
|
"node_modules/lru-cache": {
|
||||||
"version": "11.2.6",
|
"version": "11.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.3.tgz",
|
||||||
"integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
|
"integrity": "sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==",
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "20 || >=22"
|
"node": "20 || >=22"
|
||||||
@@ -1612,12 +1634,12 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "10.2.4",
|
"version": "10.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||||
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
|
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"brace-expansion": "^5.0.2"
|
"brace-expansion": "^5.0.5"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "18 || 20 || >=22"
|
"node": "18 || 20 || >=22"
|
||||||
@@ -1882,9 +1904,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/safe-regex2": {
|
"node_modules/safe-regex2": {
|
||||||
"version": "5.0.0",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.0.tgz",
|
||||||
"integrity": "sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==",
|
"integrity": "sha512-pNHAuBW7TrcleFHsxBr5QMi/Iyp0ENjUKz7GCcX1UO7cMh+NmVK6HxQckNL1tJp1XAJVjG6B8OKIPqodqj9rtw==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -1898,6 +1920,9 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ret": "~0.5.0"
|
"ret": "~0.5.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"safe-regex2": "bin/safe-regex2.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/safe-stable-stringify": {
|
"node_modules/safe-stable-stringify": {
|
||||||
@@ -2028,6 +2053,15 @@
|
|||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tmp": {
|
||||||
|
"version": "0.2.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
|
||||||
|
"integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/toad-cache": {
|
"node_modules/toad-cache": {
|
||||||
"version": "3.7.0",
|
"version": "3.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz",
|
||||||
@@ -2105,9 +2139,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "7.18.2",
|
"version": "7.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
|
||||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
@@ -2124,9 +2158,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/ws": {
|
"node_modules/ws": {
|
||||||
"version": "8.19.0",
|
"version": "8.20.0",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
|
|||||||
@@ -11,16 +11,18 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
"@fastify/jwt": "^10.0.0",
|
"@fastify/jwt": "^10.0.0",
|
||||||
"@fastify/static": "^9.0.0",
|
"@fastify/static": "^9.1.0",
|
||||||
"@fastify/websocket": "^11.2.0",
|
"@fastify/websocket": "^11.2.0",
|
||||||
"@simplewebauthn/server": "^13.2.3",
|
"@simplewebauthn/server": "^13.3.0",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.4.2",
|
||||||
"fastify": "^5.8.2",
|
"fastify": "^5.8.4",
|
||||||
"ioredis": "^5.10.0",
|
"ioredis": "^5.10.1",
|
||||||
|
"libreoffice-convert": "^1.8.1",
|
||||||
|
"ws": "^8.20.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^25.3.5",
|
"@types/node": "^25.6.0",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { TextEncoder } from 'node:util';
|
import { promisify, TextEncoder } from 'node:util';
|
||||||
import { DatabaseSync } from 'node:sqlite';
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
|
|
||||||
import cors from '@fastify/cors';
|
import cors from '@fastify/cors';
|
||||||
@@ -10,6 +12,7 @@ import jwt from '@fastify/jwt';
|
|||||||
import fastifyStatic from '@fastify/static';
|
import fastifyStatic from '@fastify/static';
|
||||||
import websocket from '@fastify/websocket';
|
import websocket from '@fastify/websocket';
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
|
import libreOffice from 'libreoffice-convert';
|
||||||
import {
|
import {
|
||||||
generateAuthenticationOptions,
|
generateAuthenticationOptions,
|
||||||
generateRegistrationOptions,
|
generateRegistrationOptions,
|
||||||
@@ -84,6 +87,15 @@ type PendingApprovalUser = {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type AdminUserSummary = {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
displayName: string;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
approvedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
type DatabaseAccessKeyRow = {
|
type DatabaseAccessKeyRow = {
|
||||||
id: string;
|
id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
@@ -97,17 +109,43 @@ type SignalPayload =
|
|||||||
| { type: 'sdp'; description: RTCSessionDescriptionInit }
|
| { type: 'sdp'; description: RTCSessionDescriptionInit }
|
||||||
| { type: 'ice-candidate'; candidate: RTCIceCandidateInit };
|
| { type: 'ice-candidate'; candidate: RTCIceCandidateInit };
|
||||||
|
|
||||||
type ClientMessage = {
|
type ClientMessage =
|
||||||
type: 'signal';
|
| {
|
||||||
to: string;
|
type: 'signal';
|
||||||
signal: SignalPayload;
|
to: string;
|
||||||
};
|
signal: SignalPayload;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'image-generation';
|
||||||
|
requestId: string;
|
||||||
|
peerId: string;
|
||||||
|
prompt: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'ping';
|
||||||
|
};
|
||||||
|
|
||||||
type ServerMessage =
|
type ServerMessage =
|
||||||
| { type: 'presence'; self: PublicUser; peers: PublicUser[] }
|
| { type: 'presence'; self: PublicUser; peers: PublicUser[] }
|
||||||
| { type: 'peer-joined'; peer: PublicUser }
|
| { type: 'peer-joined'; peer: PublicUser }
|
||||||
| { type: 'peer-left'; peerId: string }
|
| { type: 'peer-left'; peerId: string }
|
||||||
| { type: 'signal'; from: string; signal: SignalPayload }
|
| { type: 'signal'; from: string; signal: SignalPayload }
|
||||||
|
| {
|
||||||
|
type: 'image-generated';
|
||||||
|
requestId: string;
|
||||||
|
peerId: string;
|
||||||
|
prompt: string;
|
||||||
|
createdAt: number;
|
||||||
|
mimeType: string;
|
||||||
|
imageBase64: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'image-generation-error';
|
||||||
|
requestId: string;
|
||||||
|
peerId: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
| { type: 'pong' }
|
||||||
| { type: 'error'; message: string };
|
| { type: 'error'; message: string };
|
||||||
|
|
||||||
type StoredCredentials = {
|
type StoredCredentials = {
|
||||||
@@ -136,6 +174,22 @@ type AccessKeySummary = {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type WebBundleFileEntry = {
|
||||||
|
path: string;
|
||||||
|
size: number;
|
||||||
|
sha256: string;
|
||||||
|
lastModified: string;
|
||||||
|
contentType: string;
|
||||||
|
href: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WebBundleManifest = {
|
||||||
|
bundleId: string;
|
||||||
|
generatedAt: string;
|
||||||
|
indexPath: string;
|
||||||
|
files: WebBundleFileEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
type PendingRegistration = {
|
type PendingRegistration = {
|
||||||
challenge: string;
|
challenge: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -194,6 +248,20 @@ const approvePendingUserParamsSchema = z.object({
|
|||||||
userId: z.string().min(1),
|
userId: z.string().min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const adminDeleteUserParamsSchema = z.object({
|
||||||
|
userId: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
const webBundleFileParamsSchema = z.object({
|
||||||
|
'*': z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
const documentPreviewSchema = z.object({
|
||||||
|
fileName: z.string().trim().min(1).max(256),
|
||||||
|
mimeType: z.string().trim().min(1).max(256),
|
||||||
|
fileBase64: z.string().min(1).max(96_000_000),
|
||||||
|
});
|
||||||
|
|
||||||
const wsQuerySchema = z.object({
|
const wsQuerySchema = z.object({
|
||||||
token: z.string().min(1),
|
token: z.string().min(1),
|
||||||
});
|
});
|
||||||
@@ -221,9 +289,18 @@ const signalMessageSchema = z.discriminatedUnion('type', [
|
|||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
}),
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal('image-generation'),
|
||||||
|
requestId: z.string().uuid(),
|
||||||
|
peerId: z.string().min(1),
|
||||||
|
prompt: z.string().trim().min(1).max(4000),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal('ping'),
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const app = Fastify({ logger: true });
|
const app = Fastify({ logger: true, trustProxy: true });
|
||||||
const approvalAdminUsername = 'ladparis';
|
const approvalAdminUsername = 'ladparis';
|
||||||
|
|
||||||
const dataDirectory = resolveStoragePath(process.env.PRIVATECHAT_DATA_DIR ?? 'server/data');
|
const dataDirectory = resolveStoragePath(process.env.PRIVATECHAT_DATA_DIR ?? 'server/data');
|
||||||
@@ -236,8 +313,14 @@ const masterKeyPath = resolveStoragePath(
|
|||||||
const frontendDistPath = resolveProjectPath(
|
const frontendDistPath = resolveProjectPath(
|
||||||
process.env.PRIVATECHAT_WEB_DIST_DIR ?? 'client/dist/client/browser',
|
process.env.PRIVATECHAT_WEB_DIST_DIR ?? 'client/dist/client/browser',
|
||||||
);
|
);
|
||||||
|
const ollamaServerUrl = (process.env.PRIVATECHAT_OLLAMA_URL ?? 'http://192.168.1.19:11434').replace(/\/+$/, '');
|
||||||
|
const ollamaImageModel = process.env.PRIVATECHAT_OLLAMA_IMAGE_MODEL ?? 'x/z-image-turbo:latest';
|
||||||
|
const ollamaImageSize = process.env.PRIVATECHAT_OLLAMA_IMAGE_SIZE ?? '1024x1024';
|
||||||
const sessionTtlSeconds = Number(process.env.SESSION_TTL_SECONDS ?? 60 * 60 * 12);
|
const sessionTtlSeconds = Number(process.env.SESSION_TTL_SECONDS ?? 60 * 60 * 12);
|
||||||
const webAuthnChallengeTtlSeconds = Number(process.env.WEBAUTHN_CHALLENGE_TTL_SECONDS ?? 5 * 60);
|
const webAuthnChallengeTtlSeconds = Number(process.env.WEBAUTHN_CHALLENGE_TTL_SECONDS ?? 5 * 60);
|
||||||
|
const allowedCorsOrigins = parseAllowedOrigins(process.env.CORS_ORIGIN);
|
||||||
|
const corsAllowedHeaders = ['Authorization', 'Content-Type'];
|
||||||
|
const corsMethods = ['GET', 'POST', 'OPTIONS'];
|
||||||
const webAuthnOrigin = process.env.WEBAUTHN_ORIGIN ?? 'http://localhost:4200';
|
const webAuthnOrigin = process.env.WEBAUTHN_ORIGIN ?? 'http://localhost:4200';
|
||||||
const webAuthnRpName = process.env.WEBAUTHN_RP_NAME ?? 'PrivateChat';
|
const webAuthnRpName = process.env.WEBAUTHN_RP_NAME ?? 'PrivateChat';
|
||||||
const webAuthnUserVerification = resolveWebAuthnUserVerification(
|
const webAuthnUserVerification = resolveWebAuthnUserVerification(
|
||||||
@@ -245,6 +328,8 @@ const webAuthnUserVerification = resolveWebAuthnUserVerification(
|
|||||||
);
|
);
|
||||||
const frontendIndexPath = path.join(frontendDistPath, 'index.html');
|
const frontendIndexPath = path.join(frontendDistPath, 'index.html');
|
||||||
const hasFrontendBuild = fs.existsSync(frontendIndexPath);
|
const hasFrontendBuild = fs.existsSync(frontendIndexPath);
|
||||||
|
const convertOfficeDocument = promisify(libreOffice.convertWithOptions);
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
fs.mkdirSync(path.dirname(sqlitePath), { recursive: true });
|
fs.mkdirSync(path.dirname(sqlitePath), { recursive: true });
|
||||||
fs.mkdirSync(path.dirname(masterKeyPath), { recursive: true });
|
fs.mkdirSync(path.dirname(masterKeyPath), { recursive: true });
|
||||||
@@ -304,6 +389,11 @@ const selectPendingUsersStatement = database.prepare(`
|
|||||||
WHERE is_active = 0
|
WHERE is_active = 0
|
||||||
ORDER BY created_at ASC
|
ORDER BY created_at ASC
|
||||||
`);
|
`);
|
||||||
|
const selectAllUsersStatement = database.prepare(`
|
||||||
|
SELECT id, username, display_name, encrypted_credentials, is_active, created_at, approved_at
|
||||||
|
FROM users
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`);
|
||||||
const approveUserStatement = database.prepare(`
|
const approveUserStatement = database.prepare(`
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET is_active = 1, approved_at = ?
|
SET is_active = 1, approved_at = ?
|
||||||
@@ -338,11 +428,19 @@ const selectAccessKeyByCredentialIdStatement = database.prepare(`
|
|||||||
FROM webauthn_credentials
|
FROM webauthn_credentials
|
||||||
WHERE credential_id = ?
|
WHERE credential_id = ?
|
||||||
`);
|
`);
|
||||||
|
const deleteAccessKeysByUserStatement = database.prepare(`
|
||||||
|
DELETE FROM webauthn_credentials
|
||||||
|
WHERE user_id = ?
|
||||||
|
`);
|
||||||
const updateAccessKeyStatement = database.prepare(`
|
const updateAccessKeyStatement = database.prepare(`
|
||||||
UPDATE webauthn_credentials
|
UPDATE webauthn_credentials
|
||||||
SET encrypted_registration = ?
|
SET encrypted_registration = ?
|
||||||
WHERE credential_id = ?
|
WHERE credential_id = ?
|
||||||
`);
|
`);
|
||||||
|
const deleteUserStatement = database.prepare(`
|
||||||
|
DELETE FROM users
|
||||||
|
WHERE id = ?
|
||||||
|
`);
|
||||||
|
|
||||||
const jwtSecret = loadOrCreateSecret('jwt-secret', () => crypto.randomBytes(64).toString('hex'));
|
const jwtSecret = loadOrCreateSecret('jwt-secret', () => crypto.randomBytes(64).toString('hex'));
|
||||||
const redis = new Redis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379/0');
|
const redis = new Redis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379/0');
|
||||||
@@ -351,8 +449,12 @@ const socketsByUserId = new Map<string, Map<string, WebSocket>>();
|
|||||||
await redis.ping();
|
await redis.ping();
|
||||||
|
|
||||||
await app.register(cors, {
|
await app.register(cors, {
|
||||||
origin: process.env.CORS_ORIGIN ? [process.env.CORS_ORIGIN] : true,
|
origin(origin, callback) {
|
||||||
|
callback(null, isAllowedRequestOrigin(origin));
|
||||||
|
},
|
||||||
credentials: false,
|
credentials: false,
|
||||||
|
allowedHeaders: corsAllowedHeaders,
|
||||||
|
methods: corsMethods,
|
||||||
});
|
});
|
||||||
|
|
||||||
await app.register(jwt, {
|
await app.register(jwt, {
|
||||||
@@ -390,6 +492,57 @@ if (hasFrontendBuild) {
|
|||||||
|
|
||||||
app.get('/api/health', async () => ({ ok: true }));
|
app.get('/api/health', async () => ({ ok: true }));
|
||||||
|
|
||||||
|
app.get('/api/web-app/manifest', async (request, reply) => {
|
||||||
|
const manifest = getFrontendBundleManifest();
|
||||||
|
|
||||||
|
if (!manifest) {
|
||||||
|
return reply.code(404).send({
|
||||||
|
message: 'Angular frontend build not found.',
|
||||||
|
frontendDistPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const etag = `"${manifest.bundleId}"`;
|
||||||
|
reply.header('Cache-Control', 'no-cache');
|
||||||
|
reply.header('ETag', etag);
|
||||||
|
|
||||||
|
if (requestMatchesEtag(request.headers['if-none-match'], etag)) {
|
||||||
|
return reply.code(304).send();
|
||||||
|
}
|
||||||
|
|
||||||
|
return manifest;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/web-app/files/*', async (request, reply) => {
|
||||||
|
const parsed = webBundleFileParamsSchema.safeParse(request.params);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
message: 'Invalid web bundle asset path.',
|
||||||
|
issues: parsed.error.flatten(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const asset = resolveFrontendBundleAsset(parsed.data['*']);
|
||||||
|
|
||||||
|
if (!asset) {
|
||||||
|
return reply.code(404).send({ message: 'Frontend asset not found.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const etag = `W/"${asset.etag}"`;
|
||||||
|
reply.header('Cache-Control', 'public, max-age=300');
|
||||||
|
reply.header('ETag', etag);
|
||||||
|
reply.header('Last-Modified', new Date(asset.lastModifiedMs).toUTCString());
|
||||||
|
|
||||||
|
if (requestMatchesEtag(request.headers['if-none-match'], etag)) {
|
||||||
|
return reply.code(304).send();
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.header('Content-Length', String(asset.size));
|
||||||
|
reply.type(asset.contentType);
|
||||||
|
return reply.send(fs.createReadStream(asset.absolutePath));
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/api/auth/register', async (request, reply) => {
|
app.post('/api/auth/register', async (request, reply) => {
|
||||||
const parsed = registerSchema.safeParse(request.body);
|
const parsed = registerSchema.safeParse(request.body);
|
||||||
|
|
||||||
@@ -617,6 +770,57 @@ app.get('/api/auth/session', async (request, reply) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/users', async (request, reply) => {
|
||||||
|
const authContext = await authenticateRequest(request, reply);
|
||||||
|
|
||||||
|
if (!authContext) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
users: listDiscoverableUsers(authContext.user.id),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/files/document-preview-image', { bodyLimit: 64 * 1024 * 1024 }, async (request, reply) => {
|
||||||
|
const authContext = await authenticateRequest(request, reply);
|
||||||
|
|
||||||
|
if (!authContext) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = documentPreviewSchema.safeParse(request.body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
message: 'Invalid document preview payload.',
|
||||||
|
issues: parsed.error.flatten(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSupportedPreviewDocument(parsed.data.fileName, parsed.data.mimeType)) {
|
||||||
|
return reply.code(400).send({ message: 'Only PDF, DOCX, XLSX, and PPTX files can be previewed.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const previewImageBuffer = await createDocumentPreviewImage(
|
||||||
|
parsed.data.fileName,
|
||||||
|
parsed.data.mimeType,
|
||||||
|
parsed.data.fileBase64,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
mimeType: 'image/png',
|
||||||
|
imageBase64: previewImageBuffer.toString('base64'),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
app.log.warn({ err: error, userId: authContext.user.id }, 'Document preview generation failed');
|
||||||
|
return reply.code(422).send({
|
||||||
|
message: describeDocumentPreviewFailure(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/admin/pending-users', async (request, reply) => {
|
app.get('/api/admin/pending-users', async (request, reply) => {
|
||||||
const authContext = await authenticateRequest(request, reply);
|
const authContext = await authenticateRequest(request, reply);
|
||||||
|
|
||||||
@@ -664,6 +868,53 @@ app.post('/api/admin/pending-users/:userId/approve', async (request, reply) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/admin/users', async (request, reply) => {
|
||||||
|
const authContext = await authenticateRequest(request, reply);
|
||||||
|
|
||||||
|
if (!authContext) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isApprovalAdmin(authContext.user)) {
|
||||||
|
return reply.code(403).send({ message: 'Only ladparis can delete users.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
users: listAdminUsers(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/admin/users/:userId', async (request, reply) => {
|
||||||
|
const authContext = await authenticateRequest(request, reply);
|
||||||
|
|
||||||
|
if (!authContext) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isApprovalAdmin(authContext.user)) {
|
||||||
|
return reply.code(403).send({ message: 'Only ladparis can delete users.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = adminDeleteUserParamsSchema.safeParse(request.params);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
message: 'Invalid user deletion request.',
|
||||||
|
issues: parsed.error.flatten(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletedUser = await deleteUserAccount(parsed.data.userId);
|
||||||
|
|
||||||
|
if (!deletedUser) {
|
||||||
|
return reply.code(404).send({ message: 'User not found.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: toPublicUser(deletedUser),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/api/auth/logout', async (request, reply) => {
|
app.post('/api/auth/logout', async (request, reply) => {
|
||||||
const authContext = await authenticateRequest(request, reply);
|
const authContext = await authenticateRequest(request, reply);
|
||||||
|
|
||||||
@@ -829,6 +1080,12 @@ await app.listen({ port, host: '0.0.0.0' });
|
|||||||
app.log.info(`PrivateChat signaling server listening on http://localhost:${port}`);
|
app.log.info(`PrivateChat signaling server listening on http://localhost:${port}`);
|
||||||
|
|
||||||
async function openSocket(socket: WebSocket, request: FastifyRequest): Promise<void> {
|
async function openSocket(socket: WebSocket, request: FastifyRequest): Promise<void> {
|
||||||
|
if (!isAllowedRequestOrigin(request.headers.origin)) {
|
||||||
|
send(socket, { type: 'error', message: 'Origin not allowed.' });
|
||||||
|
socket.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const query = wsQuerySchema.safeParse(request.query);
|
const query = wsQuerySchema.safeParse(request.query);
|
||||||
|
|
||||||
if (!query.success) {
|
if (!query.success) {
|
||||||
@@ -901,6 +1158,37 @@ async function handleSocketMessage(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (parsed.type === 'ping') {
|
||||||
|
send(socket, { type: 'pong' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.type === 'image-generation') {
|
||||||
|
try {
|
||||||
|
const generatedImage = await generateImageFromPrompt(parsed.prompt);
|
||||||
|
|
||||||
|
send(socket, {
|
||||||
|
type: 'image-generated',
|
||||||
|
requestId: parsed.requestId,
|
||||||
|
peerId: parsed.peerId,
|
||||||
|
prompt: parsed.prompt,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
mimeType: generatedImage.mimeType,
|
||||||
|
imageBase64: generatedImage.imageBase64,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
app.log.warn({ err: error, userId, peerId: parsed.peerId }, 'Ollama image generation failed');
|
||||||
|
send(socket, {
|
||||||
|
type: 'image-generation-error',
|
||||||
|
requestId: parsed.requestId,
|
||||||
|
peerId: parsed.peerId,
|
||||||
|
message: error instanceof Error ? error.message : 'Image generation failed.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let delivered = 0;
|
let delivered = 0;
|
||||||
const recipientSockets = socketsByUserId.get(parsed.to);
|
const recipientSockets = socketsByUserId.get(parsed.to);
|
||||||
|
|
||||||
@@ -1011,6 +1299,101 @@ async function authenticateTokenFromSession(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function convertOfficeDocumentToPdf(fileName: string, fileBase64: string): Promise<Buffer> {
|
||||||
|
const inputBuffer = Buffer.from(fileBase64, 'base64');
|
||||||
|
|
||||||
|
if (inputBuffer.byteLength === 0) {
|
||||||
|
throw new Error('The uploaded office document is empty.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedFileName = normalizeOfficeDocumentFileName(fileName);
|
||||||
|
return convertOfficeDocument(inputBuffer, '.pdf', undefined, { fileName: normalizedFileName });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createDocumentPreviewImage(
|
||||||
|
fileName: string,
|
||||||
|
mimeType: string,
|
||||||
|
fileBase64: string,
|
||||||
|
): Promise<Buffer> {
|
||||||
|
const normalizedMimeType = mimeType.trim().toLowerCase();
|
||||||
|
const pdfBuffer = normalizedMimeType === 'application/pdf'
|
||||||
|
? decodeBase64File(fileBase64, 'The uploaded PDF is empty.')
|
||||||
|
: await convertOfficeDocumentToPdf(fileName, fileBase64);
|
||||||
|
|
||||||
|
return renderPdfFirstPageToPng(pdfBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderPdfFirstPageToPng(pdfBuffer: Buffer): Promise<Buffer> {
|
||||||
|
const tempDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'privatechat-preview-'));
|
||||||
|
const pdfPath = path.join(tempDirectory, 'source.pdf');
|
||||||
|
const outputBasePath = path.join(tempDirectory, 'page-preview');
|
||||||
|
const imagePath = `${outputBasePath}.png`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.promises.writeFile(pdfPath, pdfBuffer);
|
||||||
|
await execFileAsync('pdftoppm', ['-png', '-f', '1', '-singlefile', pdfPath, outputBasePath]);
|
||||||
|
return await fs.promises.readFile(imagePath);
|
||||||
|
} finally {
|
||||||
|
await fs.promises.rm(tempDirectory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeBase64File(fileBase64: string, emptyMessage: string): Buffer {
|
||||||
|
const inputBuffer = Buffer.from(fileBase64, 'base64');
|
||||||
|
|
||||||
|
if (inputBuffer.byteLength === 0) {
|
||||||
|
throw new Error(emptyMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return inputBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSupportedPreviewDocument(fileName: string, mimeType: string): boolean {
|
||||||
|
if (isPdfFile(fileName, mimeType)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isSupportedOfficeDocument(fileName, mimeType);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSupportedOfficeDocument(fileName: string, mimeType: string): boolean {
|
||||||
|
const normalizedFileName = fileName.trim().toLowerCase();
|
||||||
|
const normalizedMimeType = mimeType.trim().toLowerCase();
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalizedMimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
||||||
|
|| normalizedMimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
|| normalizedMimeType === 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return /\.(docx|xlsx|xslx|pptx)$/i.test(normalizedFileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPdfFile(fileName: string, mimeType: string): boolean {
|
||||||
|
const normalizedFileName = fileName.trim().toLowerCase();
|
||||||
|
const normalizedMimeType = mimeType.trim().toLowerCase();
|
||||||
|
|
||||||
|
return normalizedMimeType === 'application/pdf' || normalizedFileName.endsWith('.pdf');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOfficeDocumentFileName(fileName: string): string {
|
||||||
|
return fileName.trim().replace(/\.xslx$/i, '.xlsx');
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeDocumentPreviewFailure(error: unknown): string {
|
||||||
|
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
||||||
|
return 'Document preview generation failed because a required conversion tool is missing on the server.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error && error.message.trim()) {
|
||||||
|
return `Document preview generation failed: ${error.message}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Document preview generation failed.';
|
||||||
|
}
|
||||||
|
|
||||||
function createUser(input: {
|
function createUser(input: {
|
||||||
username: string;
|
username: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
@@ -1056,6 +1439,34 @@ function listPendingApprovalUsers(): PendingApprovalUser[] {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function listAdminUsers(): AdminUserSummary[] {
|
||||||
|
const rows = selectAllUsersStatement.all() as DatabaseUserRow[];
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
username: row.username,
|
||||||
|
displayName: row.display_name,
|
||||||
|
isActive: row.is_active === 1,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
approvedAt: row.approved_at,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function listDiscoverableUsers(currentUserId: string): PublicUser[] {
|
||||||
|
const rows = selectAllUsersStatement.all() as DatabaseUserRow[];
|
||||||
|
|
||||||
|
return rows
|
||||||
|
.filter((row) => row.is_active === 1 && row.id !== currentUserId)
|
||||||
|
.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
username: row.username,
|
||||||
|
displayName: row.display_name,
|
||||||
|
}))
|
||||||
|
.sort((left, right) =>
|
||||||
|
left.displayName.localeCompare(right.displayName) || left.username.localeCompare(right.username),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function approveUser(userId: string): UserRecord | null {
|
function approveUser(userId: string): UserRecord | null {
|
||||||
const approvedAt = new Date().toISOString();
|
const approvedAt = new Date().toISOString();
|
||||||
const result = approveUserStatement.run(approvedAt, userId);
|
const result = approveUserStatement.run(approvedAt, userId);
|
||||||
@@ -1067,6 +1478,25 @@ function approveUser(userId: string): UserRecord | null {
|
|||||||
return findUserById(userId);
|
return findUserById(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteUserAccount(userId: string): Promise<UserRecord | null> {
|
||||||
|
const user = findUserById(userId);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteAccessKeysByUserStatement.run(userId);
|
||||||
|
|
||||||
|
const result = deleteUserStatement.run(userId);
|
||||||
|
|
||||||
|
if (result.changes === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await destroyUserSessions(userId);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
function persistAccessKey(
|
function persistAccessKey(
|
||||||
userId: string,
|
userId: string,
|
||||||
input: {
|
input: {
|
||||||
@@ -1248,6 +1678,39 @@ async function destroySession(sessionId: string): Promise<void> {
|
|||||||
await redis.del(sessionKey(sessionId));
|
await redis.del(sessionKey(sessionId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function destroyUserSessions(userId: string): Promise<void> {
|
||||||
|
let cursor = '0';
|
||||||
|
|
||||||
|
do {
|
||||||
|
const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', `${sessionKey('*')}`, 'COUNT', 100);
|
||||||
|
cursor = nextCursor;
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
const payload = await redis.get(key);
|
||||||
|
|
||||||
|
if (!payload) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let session: SessionRecord | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
session = JSON.parse(payload) as SessionRecord;
|
||||||
|
} catch {
|
||||||
|
session = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session || session.userId !== userId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await destroySession(session.sessionId);
|
||||||
|
await clearPendingRegistration(session.sessionId);
|
||||||
|
closeSocketSession(userId, session.sessionId);
|
||||||
|
}
|
||||||
|
} while (cursor !== '0');
|
||||||
|
}
|
||||||
|
|
||||||
function sessionKey(sessionId: string): string {
|
function sessionKey(sessionId: string): string {
|
||||||
return `privatechat:session:${sessionId}`;
|
return `privatechat:session:${sessionId}`;
|
||||||
}
|
}
|
||||||
@@ -1364,6 +1827,19 @@ function parseClientMessage(rawMessage: string): ClientMessage | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (parsed.data.type === 'ping') {
|
||||||
|
return { type: 'ping' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.data.type === 'image-generation') {
|
||||||
|
return {
|
||||||
|
type: 'image-generation',
|
||||||
|
requestId: parsed.data.requestId,
|
||||||
|
peerId: parsed.data.peerId,
|
||||||
|
prompt: parsed.data.prompt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'signal',
|
type: 'signal',
|
||||||
to: parsed.data.to,
|
to: parsed.data.to,
|
||||||
@@ -1371,7 +1847,251 @@ function parseClientMessage(rawMessage: string): ClientMessage | null {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSignal(signal: ClientMessage['signal']): SignalPayload {
|
async function generateImageFromPrompt(prompt: string): Promise<{ imageBase64: string; mimeType: string }> {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => abortController.abort(), 120_000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${ollamaServerUrl}/v1/images/generations`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: ollamaImageModel,
|
||||||
|
prompt,
|
||||||
|
size: ollamaImageSize,
|
||||||
|
response_format: 'b64_json',
|
||||||
|
n: 1,
|
||||||
|
}),
|
||||||
|
signal: abortController.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = await response.json() as {
|
||||||
|
error?: { message?: string } | string;
|
||||||
|
data?: Array<{ b64_json?: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorMessage = typeof payload.error === 'string'
|
||||||
|
? payload.error
|
||||||
|
: payload.error?.message;
|
||||||
|
throw new Error(errorMessage || 'Ollama image generation request failed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageBase64 = payload.data?.[0]?.b64_json?.trim();
|
||||||
|
|
||||||
|
if (!imageBase64) {
|
||||||
|
throw new Error('Ollama did not return image data.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
imageBase64,
|
||||||
|
mimeType: inferImageMimeType(Buffer.from(imageBase64, 'base64')),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
throw new Error('Ollama image generation timed out.');
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferImageMimeType(imageBuffer: Buffer): string {
|
||||||
|
if (imageBuffer.length >= 8 && imageBuffer.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) {
|
||||||
|
return 'image/png';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageBuffer.length >= 3 && imageBuffer.subarray(0, 3).equals(Buffer.from([255, 216, 255]))) {
|
||||||
|
return 'image/jpeg';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
imageBuffer.length >= 12 &&
|
||||||
|
imageBuffer.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||||||
|
imageBuffer.subarray(8, 12).toString('ascii') === 'WEBP'
|
||||||
|
) {
|
||||||
|
return 'image/webp';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageBuffer.length >= 6) {
|
||||||
|
const header = imageBuffer.subarray(0, 6).toString('ascii');
|
||||||
|
|
||||||
|
if (header === 'GIF87a' || header === 'GIF89a') {
|
||||||
|
return 'image/gif';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFrontendBundleManifest(): WebBundleManifest | null {
|
||||||
|
if (!fs.existsSync(frontendIndexPath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = listBundleFilePaths(frontendDistPath).map((absolutePath) => {
|
||||||
|
const relativePath = toBundleRelativePath(path.relative(frontendDistPath, absolutePath));
|
||||||
|
const stats = fs.statSync(absolutePath);
|
||||||
|
const sha256 = crypto.createHash('sha256').update(fs.readFileSync(absolutePath)).digest('hex');
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: relativePath,
|
||||||
|
size: stats.size,
|
||||||
|
sha256,
|
||||||
|
lastModified: stats.mtime.toISOString(),
|
||||||
|
contentType: detectBundleContentType(relativePath),
|
||||||
|
href: bundleAssetHref(relativePath),
|
||||||
|
} satisfies WebBundleFileEntry;
|
||||||
|
});
|
||||||
|
|
||||||
|
files.sort((left, right) => left.path.localeCompare(right.path));
|
||||||
|
|
||||||
|
const generatedAt = files.reduce(
|
||||||
|
(latest, file) => (file.lastModified > latest ? file.lastModified : latest),
|
||||||
|
new Date(0).toISOString(),
|
||||||
|
);
|
||||||
|
const bundleId = files.reduce((hash, file) => {
|
||||||
|
hash.update(file.path);
|
||||||
|
hash.update(file.sha256);
|
||||||
|
hash.update(String(file.size));
|
||||||
|
return hash;
|
||||||
|
}, crypto.createHash('sha256')).digest('hex');
|
||||||
|
|
||||||
|
return {
|
||||||
|
bundleId,
|
||||||
|
generatedAt,
|
||||||
|
indexPath: 'index.html',
|
||||||
|
files,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFrontendBundleAsset(relativeAssetPath: string): {
|
||||||
|
absolutePath: string;
|
||||||
|
contentType: string;
|
||||||
|
size: number;
|
||||||
|
lastModifiedMs: number;
|
||||||
|
etag: string;
|
||||||
|
} | null {
|
||||||
|
if (!fs.existsSync(frontendIndexPath) || !fs.existsSync(frontendDistPath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedPath = toBundleRelativePath(relativeAssetPath);
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalizedPath.length === 0 ||
|
||||||
|
normalizedPath === '.' ||
|
||||||
|
normalizedPath.startsWith('../') ||
|
||||||
|
normalizedPath.startsWith('/')
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const absolutePath = path.resolve(frontendDistPath, normalizedPath);
|
||||||
|
const relativeToRoot = path.relative(frontendDistPath, absolutePath);
|
||||||
|
|
||||||
|
if (relativeToRoot.startsWith('..') || path.isAbsolute(relativeToRoot) || !fs.existsSync(absolutePath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = fs.statSync(absolutePath);
|
||||||
|
|
||||||
|
if (!stats.isFile()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
absolutePath,
|
||||||
|
contentType: detectBundleContentType(normalizedPath),
|
||||||
|
size: stats.size,
|
||||||
|
lastModifiedMs: stats.mtimeMs,
|
||||||
|
etag: `${stats.size}-${Math.floor(stats.mtimeMs)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function listBundleFilePaths(rootPath: string): string[] {
|
||||||
|
return fs.readdirSync(rootPath, { withFileTypes: true }).flatMap((entry) => {
|
||||||
|
const entryPath = path.join(rootPath, entry.name);
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
return listBundleFilePaths(entryPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!entry.isFile()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [entryPath];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function bundleAssetHref(relativePath: string): string {
|
||||||
|
return `/api/web-app/files/${relativePath.split('/').map((segment) => encodeURIComponent(segment)).join('/')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBundleRelativePath(inputPath: string): string {
|
||||||
|
return path.posix.normalize(inputPath.replaceAll('\\', '/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectBundleContentType(assetPath: string): string {
|
||||||
|
const extension = path.extname(assetPath).toLowerCase();
|
||||||
|
|
||||||
|
switch (extension) {
|
||||||
|
case '.mp3':
|
||||||
|
return 'audio/mpeg';
|
||||||
|
case '.m4a':
|
||||||
|
return 'audio/mp4';
|
||||||
|
case '.css':
|
||||||
|
return 'text/css; charset=utf-8';
|
||||||
|
case '.html':
|
||||||
|
return 'text/html; charset=utf-8';
|
||||||
|
case '.ico':
|
||||||
|
return 'image/x-icon';
|
||||||
|
case '.jpeg':
|
||||||
|
case '.jpg':
|
||||||
|
return 'image/jpeg';
|
||||||
|
case '.js':
|
||||||
|
return 'text/javascript; charset=utf-8';
|
||||||
|
case '.json':
|
||||||
|
return 'application/json; charset=utf-8';
|
||||||
|
case '.map':
|
||||||
|
return 'application/json; charset=utf-8';
|
||||||
|
case '.png':
|
||||||
|
return 'image/png';
|
||||||
|
case '.svg':
|
||||||
|
return 'image/svg+xml; charset=utf-8';
|
||||||
|
case '.txt':
|
||||||
|
return 'text/plain; charset=utf-8';
|
||||||
|
case '.webp':
|
||||||
|
return 'image/webp';
|
||||||
|
case '.webmanifest':
|
||||||
|
return 'application/manifest+json; charset=utf-8';
|
||||||
|
case '.woff':
|
||||||
|
return 'font/woff';
|
||||||
|
case '.woff2':
|
||||||
|
return 'font/woff2';
|
||||||
|
default:
|
||||||
|
return 'application/octet-stream';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestMatchesEtag(headerValue: string | string[] | undefined, etag: string): boolean {
|
||||||
|
if (!headerValue) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const incomingEtags = Array.isArray(headerValue)
|
||||||
|
? headerValue
|
||||||
|
: headerValue.split(',').map((value) => value.trim());
|
||||||
|
|
||||||
|
return incomingEtags.includes(etag) || incomingEtags.includes('*');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSignal(signal: Extract<ClientMessage, { type: 'signal' }>['signal']): SignalPayload {
|
||||||
if (signal.type === 'sdp') {
|
if (signal.type === 'sdp') {
|
||||||
return {
|
return {
|
||||||
type: 'sdp',
|
type: 'sdp',
|
||||||
@@ -1508,6 +2228,47 @@ function resolveProjectPath(targetPath: string): string {
|
|||||||
return path.isAbsolute(targetPath) ? targetPath : path.resolve(projectRootPath, targetPath);
|
return path.isAbsolute(targetPath) ? targetPath : path.resolve(projectRootPath, targetPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseAllowedOrigins(value: string | undefined): Set<string> {
|
||||||
|
if (!value) {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Set(
|
||||||
|
value
|
||||||
|
.split(',')
|
||||||
|
.map((origin) => normalizeOrigin(origin))
|
||||||
|
.filter((origin) => origin.length > 0 && origin !== 'null'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOrigin(origin: string): string {
|
||||||
|
const trimmed = origin.trim();
|
||||||
|
|
||||||
|
if (trimmed === 'null') {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed.replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAllowedRequestOrigin(originHeader: string | undefined): boolean {
|
||||||
|
if (!originHeader) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const origin = normalizeOrigin(originHeader);
|
||||||
|
|
||||||
|
if (origin === 'null') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowedCorsOrigins.size === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return allowedCorsOrigins.has(origin);
|
||||||
|
}
|
||||||
|
|
||||||
function resolveWebAuthnOrigin(request: FastifyRequest): string {
|
function resolveWebAuthnOrigin(request: FastifyRequest): string {
|
||||||
const originHeader = request.headers.origin;
|
const originHeader = request.headers.origin;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user