Initial commit

This commit is contained in:
2026-03-09 19:35:08 +01:00
commit f6b790a515
64 changed files with 18778 additions and 0 deletions

17
client/.editorconfig Normal file
View File

@@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false

12
client/.prettierrc Normal file
View File

@@ -0,0 +1,12 @@
{
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
}

4
client/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}

20
client/.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,20 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}

9
client/.vscode/mcp.json vendored Normal file
View File

@@ -0,0 +1,9 @@
{
// For more information, visit: https://angular.dev/ai/mcp
"servers": {
"angular-cli": {
"command": "npx",
"args": ["-y", "@angular/cli", "mcp"]
}
}
}

42
client/.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,42 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
}
]
}

59
client/README.md Normal file
View File

@@ -0,0 +1,59 @@
# Client
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.2.1.
## Development server
To start a local development server, run:
```bash
ng serve
```
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
## Code scaffolding
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
```bash
ng generate component component-name
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
```bash
ng generate --help
```
## Building
To build the project run:
```bash
ng build
```
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
## Running unit tests
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.

97
client/angular.json Normal file
View File

@@ -0,0 +1,97 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm"
},
"newProjectRoot": "projects",
"projects": {
"client": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss",
"skipTests": true
},
"@schematics/angular:class": {
"skipTests": true
},
"@schematics/angular:directive": {
"skipTests": true
},
"@schematics/angular:guard": {
"skipTests": true
},
"@schematics/angular:interceptor": {
"skipTests": true
},
"@schematics/angular:pipe": {
"skipTests": true
},
"@schematics/angular:resolver": {
"skipTests": true
},
"@schematics/angular:service": {
"skipTests": true
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.scss"
]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "700kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "client:build:production"
},
"development": {
"buildTarget": "client:build:development"
}
},
"defaultConfiguration": "development"
}
}
}
}
}

7784
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
client/package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "client",
"version": "0.0.0",
"scripts": {
"prepare-env": "node scripts/write-env.js",
"ng": "node node_modules/@angular/cli/bin/ng.js",
"start": "npm run prepare-env && node node_modules/@angular/cli/bin/ng.js serve",
"build": "npm run prepare-env && node node_modules/@angular/cli/bin/ng.js build",
"watch": "npm run prepare-env && node node_modules/@angular/cli/bin/ng.js build --watch --configuration development",
"test": "node node_modules/@angular/cli/bin/ng.js test"
},
"private": true,
"packageManager": "npm@11.10.1",
"dependencies": {
"@angular/common": "^21.2.0",
"@angular/compiler": "^21.2.0",
"@angular/core": "^21.2.0",
"@angular/forms": "^21.2.0",
"@angular/platform-browser": "^21.2.0",
"@angular/router": "^21.2.0",
"bootstrap": "^5.3.8",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
},
"devDependencies": {
"@angular/build": "^21.2.1",
"@angular/cli": "^21.2.1",
"@angular/compiler-cli": "^21.2.0",
"dotenv": "^17.3.1",
"prettier": "^3.8.1",
"typescript": "~5.9.2"
}
}

3
client/public/env.js Normal file
View File

@@ -0,0 +1,3 @@
window.__PRIVATECHAT_ENV__ = {
"PRIVATECHAT_CLIENT_SERVER_URL": "http://chatter.dubertrand.fr"
};

BIN
client/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,17 @@
const fs = require('node:fs');
const path = require('node:path');
const dotenv = require('dotenv');
const rootEnvPath = path.resolve(__dirname, '../../.env');
const outputPath = path.resolve(__dirname, '../public/env.js');
dotenv.config({ path: rootEnvPath });
const runtimeEnv = {
PRIVATECHAT_CLIENT_SERVER_URL: process.env.PRIVATECHAT_CLIENT_SERVER_URL ?? 'http://localhost:3000',
};
const fileContents = `window.__PRIVATECHAT_ENV__ = ${JSON.stringify(runtimeEnv, null, 2)};\n`;
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, fileContents, 'utf8');

View File

@@ -0,0 +1,13 @@
import { provideHttpClient, withFetch } from '@angular/common/http';
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideHttpClient(withFetch()),
provideRouter(routes)
]
};

1
client/src/app/app.html Normal file
View File

@@ -0,0 +1 @@
<router-outlet />

View File

@@ -0,0 +1,24 @@
import { Routes } from '@angular/router';
import { ApprovalPageComponent } from './approval-page.component';
import { ChatPageComponent } from './chat-page.component';
import { HomePageComponent } from './home-page.component';
export const routes: Routes = [
{
path: '',
component: HomePageComponent,
},
{
path: 'chat/:peerId',
component: ChatPageComponent,
},
{
path: 'approvals',
component: ApprovalPageComponent,
},
{
path: '**',
redirectTo: '',
},
];

4
client/src/app/app.scss Normal file
View File

@@ -0,0 +1,4 @@
:host {
display: block;
min-height: 100dvh;
}

14
client/src/app/app.ts Normal file
View File

@@ -0,0 +1,14 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { ThemeService } from './theme.service';
@Component({
selector: 'app-root',
imports: [RouterOutlet],
templateUrl: './app.html',
styleUrl: './app.scss',
})
export class App {
constructor(_: ThemeService) {}
}

View File

@@ -0,0 +1,45 @@
<main class="approval-shell py-4">
<div class="container-lg">
<section class="panel p-4 p-lg-5">
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-start gap-3 mb-4">
<div>
<a class="back-link" routerLink="/">← Back to dashboard</a>
<h1 class="h3 mt-2 mb-1">Account approvals</h1>
<p class="text-secondary mb-0">Only <code>ladparis</code> can activate newly registered accounts.</p>
</div>
<span class="badge rounded-pill text-bg-dark">{{ pendingUsers().length }} pending</span>
</div>
@if (errorMessage()) {
<div class="alert alert-danger mb-4">{{ errorMessage() }}</div>
}
@if (loading()) {
<div class="text-secondary">Loading pending accounts...</div>
} @else if (pendingUsers().length === 0) {
<div class="empty-state p-4 text-center text-secondary">No accounts are waiting for approval.</div>
} @else {
<div class="d-grid gap-3">
@for (user of pendingUsers(); track user.id) {
<article class="approval-card d-flex flex-column flex-lg-row justify-content-between align-items-start align-items-lg-center gap-3 p-3">
<div>
<div class="fw-semibold">{{ user.displayName }}</div>
<div class="text-secondary">{{ user.username }}</div>
<div class="small text-secondary">Registered {{ user.createdAt | date: 'medium' }}</div>
</div>
<button
class="btn btn-accent"
type="button"
[disabled]="approvingUserId() === user.id"
(click)="approve(user.id)"
>
{{ approvingUserId() === user.id ? 'Approving...' : 'Approve account' }}
</button>
</article>
}
</div>
}
</section>
</div>
</main>

View File

@@ -0,0 +1,5 @@
.approval-card {
border: 1px solid var(--surface-border-soft);
border-radius: 1rem;
background: var(--panel-soft-background);
}

View File

@@ -0,0 +1,61 @@
import { CommonModule } from '@angular/common';
import { Component, inject, signal } from '@angular/core';
import { Router, RouterLink } from '@angular/router';
import { ChatSessionService } from './chat-session.service';
import type { PendingApprovalUser } from './models';
@Component({
selector: 'app-approval-page',
imports: [CommonModule, RouterLink],
templateUrl: './approval-page.component.html',
styleUrl: './approval-page.component.scss',
})
export class ApprovalPageComponent {
private readonly router = inject(Router);
readonly pendingUsers = signal<PendingApprovalUser[]>([]);
readonly loading = signal(true);
readonly errorMessage = signal<string | null>(null);
readonly approvingUserId = signal<string | null>(null);
constructor(readonly session: ChatSessionService) {
if (!this.session.isApprovalAdmin()) {
void this.router.navigateByUrl('/');
return;
}
void this.loadPendingUsers();
}
async approve(userId: string): Promise<void> {
this.errorMessage.set(null);
this.approvingUserId.set(userId);
try {
await this.session.approvePendingUser(userId);
this.pendingUsers.update((users) => users.filter((user) => user.id !== userId));
} catch (error) {
this.errorMessage.set(
error instanceof Error ? error.message : 'Could not approve that account.',
);
} finally {
this.approvingUserId.set(null);
}
}
private async loadPendingUsers(): Promise<void> {
this.loading.set(true);
this.errorMessage.set(null);
try {
this.pendingUsers.set(await this.session.loadPendingApprovalUsers());
} catch (error) {
this.errorMessage.set(
error instanceof Error ? error.message : 'Could not load pending account approvals.',
);
} finally {
this.loading.set(false);
}
}
}

View File

@@ -0,0 +1,199 @@
<main class="chat-shell py-4">
<div class="container-lg">
<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">
<div>
<a class="back-link" routerLink="/">← Back to dashboard</a>
@if (currentUser(); as connectedUser) {
<h1 class="h3 mb-1 mt-2">{{ connectedUser.displayName }}</h1>
<div class="status-indicators mt-2">
<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>Signaling</span>
</div>
<div class="status-indicator">
<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>
</div>
</div>
} @else {
<h1 class="h3 mb-1 mt-2">Not signed in</h1>
<p class="small text-secondary mb-0">Return to the dashboard and sign in again.</p>
}
</div>
@if (peer(); as selectedPeer) {
<button
class="btn btn-outline-light"
type="button"
[disabled]="selectedPeer.channelState === 'open'"
(click)="ensureConnection()"
>
{{ selectedPeer.channelState === 'open' ? 'Connected' : 'Open channel' }}
</button>
}
</div>
<div class="chat-layout">
<aside class="peer-sidebar">
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
<div>
<h2 class="h5 mb-1">Connected peers</h2>
<p class="small text-secondary mb-0">Switch between active direct chats.</p>
</div>
<span class="peer-count">{{ session.peers().length }}</span>
</div>
<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 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
class="form-control composer-textarea"
rows="3"
[(ngModel)]="messageText"
(ngModelChange)="handleMessageTextChange($event)"
(keydown.enter)="handleComposerEnter($event)"
[disabled]="!session.isSelectedPeerReady()"
placeholder="Write a text message to your peer"
></textarea>
<button
class="send-emoji"
type="button"
[disabled]="!session.isSelectedPeerReady()"
(click)="sendMessage()"
title="Send message"
aria-label="Send message"
>
</button>
</div>
</div>
</div>
</section>
</div>
</main>

View File

@@ -0,0 +1,316 @@
:host {
display: block;
min-height: 100dvh;
color: var(--page-text);
}
.chat-shell {
min-height: 100dvh;
}
.panel {
border: 1px solid var(--surface-border);
border-radius: 1.75rem;
background: var(--panel-background);
backdrop-filter: blur(18px);
box-shadow: 0 20px 60px var(--shadow-color);
}
.back-link {
color: var(--link-color);
text-decoration: none;
}
.status-indicators {
display: flex;
flex-wrap: wrap;
gap: 0.9rem;
}
.status-indicator {
display: inline-flex;
align-items: center;
gap: 0.45rem;
font-size: 0.9rem;
color: var(--page-text-soft);
}
.status-led {
width: 0.8rem;
height: 0.8rem;
border-radius: 999px;
box-shadow: 0 0 0 1px var(--input-border);
}
.status-led-ok {
background: #59d66f;
}
.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);
background: var(--panel-soft-background);
}
.peer-count {
display: inline-flex;
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;
min-height: 24rem;
max-height: calc(100dvh - 20rem);
overflow: auto;
padding: 0.5rem 0;
}
.bubble {
position: relative;
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;
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;
}
.composer {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 0.9rem;
align-items: end;
padding-top: 1rem;
margin-top: 1rem;
border-top: 1px solid var(--surface-border-soft);
}
.composer-file-input {
display: none;
}
.composer-plus,
.send-emoji {
width: 3.25rem;
height: 3.25rem;
border: 0;
border-radius: 999px;
font-size: 1.35rem;
}
.composer-textarea,
.composer-textarea:focus {
color: var(--page-text);
background-color: var(--input-background);
border-color: var(--input-border);
box-shadow: none;
}
.composer-textarea::placeholder {
color: var(--placeholder-color);
}
.composer-plus {
color: var(--page-text);
background: var(--badge-background);
}
.send-emoji {
background: linear-gradient(135deg, #def7dd, #9bd5ff);
}
.bubble-image {
width: 200px;
max-width: 100%;
height: auto;
border-radius: 1rem;
display: block;
}
.bubble-download {
color: inherit;
font-weight: 600;
}
.bubble-json {
white-space: pre-wrap;
word-break: break-word;
margin: 0;
}
.empty-chat {
padding: 1.25rem;
border: 1px dashed var(--input-border);
border-radius: 1rem;
color: var(--page-text-muted);
text-align: center;
}
.empty-peers {
min-height: 10rem;
}
.h3,
.small {
color: var(--page-text);
}
@keyframes peer-typing-pulse {
0%,
80%,
100% {
opacity: 0.28;
transform: translateY(0);
}
40% {
opacity: 1;
transform: translateY(-1px);
}
}
@media (max-width: 767.98px) {
.chat-layout {
grid-template-columns: 1fr;
}
.peer-list {
max-height: 16rem;
}
.bubble {
max-width: 88%;
}
}

View File

@@ -0,0 +1,151 @@
import { CommonModule } from '@angular/common';
import { Component, computed, effect, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { ChatSessionService } from './chat-session.service';
import type { ChatEntry, ConnectionState } from './models';
@Component({
selector: 'app-chat-page',
imports: [CommonModule, FormsModule, RouterLink],
templateUrl: './chat-page.component.html',
styleUrl: './chat-page.component.scss',
})
export class ChatPageComponent {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly routeParamMap = toSignal(this.route.paramMap, {
initialValue: this.route.snapshot.paramMap,
});
messageText = '';
readonly peerId = computed(() => this.routeParamMap().get('peerId') ?? '');
readonly peer = computed(() => this.session.peers().find((item) => item.id === this.peerId()) ?? null);
readonly currentUser = computed(() => this.session.currentUser());
readonly conversation = computed(() =>
this.session
.messages()
.filter((entry) => entry.peerId === this.peerId()),
);
readonly webRtcState = computed<ConnectionState>(() => {
const selectedPeer = this.peer();
if (!selectedPeer) {
return 'disconnected';
}
if (selectedPeer.channelState === 'open' || selectedPeer.connectionState === 'connected') {
return 'connected';
}
if (selectedPeer.channelState === 'connecting' || selectedPeer.connectionState === 'connecting') {
return 'connecting';
}
return 'disconnected';
});
constructor(readonly session: ChatSessionService) {
if (!this.session.currentUser()) {
void this.router.navigateByUrl('/');
}
effect(() => {
const peerId = this.peerId();
if (!peerId) {
return;
}
this.session.selectPeer(peerId);
void this.session.connectToPeer(peerId);
});
}
async ensureConnection(): Promise<void> {
const peerId = this.peerId();
if (!peerId) {
return;
}
this.session.selectPeer(peerId);
await this.session.connectToPeer(peerId);
}
async sendMessage(): Promise<void> {
const peerId = this.peerId();
if (!peerId) {
return;
}
await this.session.sendText(peerId, this.messageText);
this.messageText = '';
}
handleComposerEnter(event: Event): void {
if (!(event instanceof KeyboardEvent) || event.shiftKey) {
return;
}
event.preventDefault();
void this.sendMessage();
}
handleMessageTextChange(text: string): void {
const peerId = this.peerId();
if (!peerId) {
return;
}
this.session.notifyTypingActivity(peerId, text);
}
async sendFile(peerId: string, input: HTMLInputElement): Promise<void> {
const file = input.files?.item(0);
if (!file) {
return;
}
await this.session.sendFile(peerId, file);
input.value = '';
}
async deleteMessage(entry: ChatEntry): Promise<void> {
await this.session.deleteMessage(entry);
}
isImageEntry(entry: ChatEntry): boolean {
return entry.kind === 'file' && !!entry.downloadUrl && (entry.fileMimeType?.startsWith('image/') ?? false);
}
isPeerTyping(peerId: string): boolean {
return this.session.typingPeerIds().includes(peerId);
}
indicatorTone(state: ConnectionState): 'ok' | 'connecting' | 'offline' {
if (state === 'connected') {
return 'ok';
}
if (state === 'connecting') {
return 'connecting';
}
return 'offline';
}
async switchPeer(peerId: string): Promise<void> {
if (!peerId || peerId === this.peerId()) {
return;
}
this.session.selectPeer(peerId);
await this.router.navigate(['/chat', peerId]);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,269 @@
<main class="shell py-4 py-lg-5">
<div class="container-xl">
@if (!embeddedMode) {
<section class="hero-panel mb-4 mb-lg-5 p-4 p-lg-5">
<div class="d-flex flex-column flex-lg-row align-items-start align-items-lg-center justify-content-between gap-4">
<div class="hero-copy">
<div class="d-flex align-items-center justify-content-between gap-3">
<span class="eyebrow mb-0">WebRTC Private Chat</span>
<button
class="theme-toggle"
type="button"
(click)="cycleTheme()"
[attr.aria-label]="'Theme mode: ' + theme.mode() + '. Switch to ' + theme.nextMode()"
[title]="'Theme mode: ' + theme.mode() + '. Click for ' + theme.nextMode()"
>
<span class="theme-toggle-icon" aria-hidden="true">{{ theme.emoji() }}</span>
<span class="theme-toggle-label">{{ theme.mode() }}</span>
</button>
</div>
</div>
@if (session.currentUser(); as user) {
<div class="session-card p-3 p-lg-4">
<div class="text-uppercase small text-secondary mb-2">Signed in</div>
<div class="h4 mb-1">{{ user.displayName }}</div>
<div class="text-secondary mb-3">{{ user.username }}</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()">
Open chat UI
</button>
@if (session.isApprovalAdmin()) {
<a class="btn btn-outline-light w-100 mb-2" routerLink="/approvals">Approve accounts</a>
}
<button class="btn btn-outline-light w-100" type="button" (click)="logout()">Log out</button>
</div>
}
</div>
</section>
}
@if (!session.currentUser()) {
@if (embeddedMode) {
<section class="panel p-4 p-lg-5 text-center">
<h2 class="h3 mb-3">Open Settings to sign in</h2>
<p class="text-secondary mb-0">
This embedded client expects authentication and backend configuration from the native app settings.
</p>
</section>
} @else {
<section class="row g-4 align-items-stretch">
<div class="col-lg-6">
<div class="panel p-4 h-100">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<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>
</div>
<span class="badge rounded-pill text-bg-dark">Angular + Bootstrap</span>
</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">
<button
class="btn"
[class.btn-primary]="authMode === 'login'"
[class.btn-outline-primary]="authMode !== 'login'"
type="button"
(click)="authMode = 'login'"
>
Log in
</button>
<button
class="btn"
[class.btn-primary]="authMode === 'register'"
[class.btn-outline-primary]="authMode !== 'register'"
type="button"
(click)="authMode = 'register'"
>
Register
</button>
</div>
<form class="d-grid gap-3" (ngSubmit)="submitAuth()">
@if (authMode === 'register') {
<div>
<label class="form-label" for="displayName">Display name</label>
<input
id="displayName"
name="displayName"
class="form-control form-control-lg"
[(ngModel)]="displayName"
placeholder="Operator One"
/>
</div>
}
<div>
<label class="form-label" for="username">Username</label>
<input
id="username"
name="username"
class="form-control form-control-lg"
[(ngModel)]="username"
placeholder="alice"
[attr.autocomplete]="authMode === 'login' ? 'username webauthn' : 'username'"
/>
</div>
<div>
<label class="form-label" for="password">Password</label>
<input
id="password"
name="password"
type="password"
class="form-control form-control-lg"
[(ngModel)]="password"
placeholder="At least 8 characters"
autocomplete="current-password"
/>
</div>
<button class="btn btn-accent btn-lg mt-2" type="submit">
{{ authMode === 'login' ? 'Enter chat' : 'Create account' }}
</button>
@if (authMode === 'login' && session.webAuthnSupported()) {
<div class="text-center small text-secondary mt-1">or</div>
<button class="btn btn-outline-light btn-lg" type="button" (click)="loginWithAccessKey()">
🔑 Use access key
</button>
<div class="small text-secondary">
Leave the username blank to choose from discoverable passkeys, or enter it to target one account.
</div>
}
</form>
@if (session.error()) {
<div class="alert alert-danger mt-4 mb-0">{{ session.error() }}</div>
}
@if (session.notice()) {
<div class="alert alert-success mt-4 mb-0">{{ session.notice() }}</div>
}
</div>
</div>
<div class="col-lg-6">
<div class="panel panel-muted p-4 h-100">
<h2 class="h3 mb-3">Transport model</h2>
<div class="info-rail d-grid gap-3">
<article>
<div class="small text-uppercase text-secondary mb-2">1. Authenticate</div>
<p class="mb-0">Register or log in against the Fastify API to receive a JWT.</p>
</article>
<article>
<div class="small text-uppercase text-secondary mb-2">2. Discover peers</div>
<p class="mb-0">Open the WebSocket signaling session and receive the online peer list.</p>
</article>
<article>
<div class="small text-uppercase text-secondary mb-2">3. Exchange data directly</div>
<p class="mb-0">Create a WebRTC data channel and send text, JSON, or files peer-to-peer.</p>
</article>
</div>
</div>
</div>
</section>
}
} @else {
<section class="row g-4 align-items-stretch">
<div class="col-lg-5">
<div class="panel p-4 h-100">
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
<div>
<h2 class="h3 mb-1">Connection settings</h2>
<p class="text-secondary mb-0">Manage the backend endpoint used for auth and signaling.</p>
</div>
@if (session.isApprovalAdmin()) {
<a class="btn btn-sm btn-outline-light" routerLink="/approvals">Approvals</a>
}
</div>
@if (!embeddedMode) {
<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()) {
<div class="alert alert-danger mt-4 mb-0">{{ session.error() }}</div>
}
@if (session.notice()) {
<div class="alert alert-success mt-4 mb-0">{{ session.notice() }}</div>
}
</div>
</div>
<div class="col-lg-7">
<div class="panel p-4 h-100">
<section class="access-key-panel">
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
<div>
<h3 class="h5 mb-1">Access keys</h3>
<p class="small text-secondary mb-0">Register one or more WebAuthn credentials for this account.</p>
</div>
<span class="badge rounded-pill text-bg-dark">{{ session.accessKeys().length }}</span>
</div>
@if (!embeddedMode && session.webAuthnSupported()) {
<div class="input-group mb-3">
<input
class="form-control"
[(ngModel)]="accessKeyLabel"
placeholder="Laptop passkey"
/>
<button class="btn btn-outline-light" type="button" (click)="registerAccessKey()">Add key</button>
</div>
} @else if (!embeddedMode) {
<div class="alert alert-warning mb-3">This browser does not expose WebAuthn registration APIs.</div>
} @else {
<div class="empty-state p-3 text-center text-secondary mb-3">
Access keys are managed through the native app in embedded mode.
</div>
}
<div class="d-grid gap-2">
@if (session.accessKeys().length === 0) {
<div class="empty-state p-3 text-center text-secondary">No access keys registered yet.</div>
}
@for (key of session.accessKeys(); track key.id) {
<article class="access-key-card p-3">
<div class="fw-semibold">{{ key.label }}</div>
<div class="small text-secondary">Device: {{ key.deviceType }}{{ key.backedUp ? ' / backed up' : '' }}</div>
<div class="small text-secondary">Transports: {{ key.transports.length > 0 ? key.transports.join(', ') : 'unspecified' }}</div>
<div class="small text-secondary">Added: {{ key.createdAt | date: 'medium' }}</div>
</article>
}
</div>
</section>
</div>
</div>
</section>
}
</div>
</main>

View File

@@ -0,0 +1,156 @@
:host {
display: block;
min-height: 100dvh;
color: var(--page-text);
}
.shell {
min-height: 100dvh;
}
.hero-panel,
.panel,
.session-card,
.empty-state {
border: 1px solid var(--surface-border);
background: var(--panel-background);
backdrop-filter: blur(18px);
box-shadow: 0 20px 60px var(--shadow-color);
}
.hero-panel {
border-radius: 2rem;
}
.panel {
border-radius: 1.5rem;
}
.panel-muted {
background: var(--panel-alt-background);
}
.hero-copy {
max-width: 52rem;
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.85rem;
border-radius: 999px;
margin-bottom: 1rem;
letter-spacing: 0.14em;
text-transform: uppercase;
font-size: 0.72rem;
font-weight: 700;
color: var(--accent-color);
background: var(--accent-color-soft);
}
.theme-toggle {
display: inline-flex;
align-items: center;
gap: 0.55rem;
min-width: 7.5rem;
height: 3rem;
padding: 0 0.95rem;
border: 1px solid var(--surface-border);
border-radius: 999px;
color: var(--page-text);
background: var(--panel-soft-background);
font-size: 0.95rem;
font-weight: 700;
text-transform: capitalize;
line-height: 1;
transition: transform 160ms ease, background 160ms ease, border-color 160ms ease;
}
.theme-toggle-icon {
font-size: 1.25rem;
}
.theme-toggle-label {
letter-spacing: 0.03em;
}
.theme-toggle:hover,
.theme-toggle:focus-visible {
transform: translateY(-1px);
border-color: color-mix(in srgb, var(--accent-color) 35%, var(--surface-border));
background: var(--surface-hover-background);
}
.session-card {
min-width: min(100%, 18rem);
border-radius: 1.5rem;
}
.status-pill {
display: inline-flex;
padding: 0.45rem 0.8rem;
border-radius: 999px;
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);
background: var(--surface-background);
}
.empty-state {
border-radius: 1.25rem;
}
.info-rail article {
padding: 1rem 1.1rem;
border-radius: 1rem;
background: var(--panel-soft-background);
}
.form-control,
.form-control:focus {
color: var(--page-text);
background-color: var(--input-background);
border-color: var(--input-border);
box-shadow: none;
}
.form-control::placeholder {
color: var(--placeholder-color);
}
.form-label,
.h3,
.h4,
.display-5,
.fw-semibold,
.fw-bold {
color: var(--page-text);
}
.text-secondary,
.lead,
.small {
color: var(--page-text-muted) !important;
}

View File

@@ -0,0 +1,102 @@
import { CommonModule } from '@angular/common';
import { Component, effect, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import { ChatSessionService } from './chat-session.service';
import { ThemeService } from './theme.service';
@Component({
selector: 'app-home-page',
imports: [CommonModule, FormsModule, RouterLink],
templateUrl: './home-page.component.html',
styleUrl: './home-page.component.scss',
})
export class HomePageComponent {
private readonly router = inject(Router);
readonly theme = inject(ThemeService);
authMode: 'login' | 'register' = 'login';
readonly embeddedMode =
typeof window !== 'undefined' && window.localStorage.getItem('privatechat.embeddedMode') === '1';
serverUrl = '';
displayName = '';
username = '';
password = '';
accessKeyLabel = '';
constructor(readonly session: ChatSessionService) {
this.serverUrl = session.serverUrl();
if (this.embeddedMode) {
effect(() => {
const currentUser = this.session.currentUser();
const activePeerId = this.session.activePeerId();
if (!currentUser || !activePeerId) {
return;
}
void this.router.navigate(['/chat', activePeerId], { replaceUrl: true });
});
}
}
async submitAuth(): Promise<void> {
this.applyServerUrl();
if (this.authMode === 'register') {
const authenticated = await this.session.register(this.username, this.password, this.displayName);
this.password = '';
if (!authenticated) {
this.authMode = 'login';
}
return;
}
await this.session.login(this.username, this.password);
}
applyServerUrl(): void {
this.session.setServerUrl(this.serverUrl);
}
async logout(): Promise<void> {
await this.session.logout();
this.authMode = 'login';
this.displayName = '';
this.password = '';
}
async loginWithAccessKey(): Promise<void> {
this.applyServerUrl();
await this.session.loginWithAccessKey(this.username);
this.password = '';
}
async registerAccessKey(): Promise<void> {
await this.session.registerAccessKey(this.accessKeyLabel);
this.accessKeyLabel = '';
}
canOpenChatUi(): boolean {
return this.session.peers().length > 0;
}
async openChatUi(): Promise<void> {
const peerId = this.session.activePeerId() ?? this.session.peers()[0]?.id;
if (!peerId) {
this.session.error.set('No connected peers are available yet.');
return;
}
this.session.selectPeer(peerId);
await this.router.navigate(['/chat', peerId]);
}
cycleTheme(): void {
this.theme.cycleMode();
}
}

144
client/src/app/models.ts Normal file
View File

@@ -0,0 +1,144 @@
export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'failed';
export type ChannelState = 'closed' | 'connecting' | 'open';
export interface UserProfile {
id: string;
username: string;
displayName: string;
}
export interface PeerSummary extends UserProfile {
connectionState: ConnectionState;
channelState: ChannelState;
}
export interface AuthResponse {
token: string;
user: UserProfile;
messageEncryptionKey: string;
}
export interface SessionResponse {
user: UserProfile;
messageEncryptionKey: string;
}
export interface PendingApprovalResponse {
pendingApproval: true;
message: string;
}
export interface PendingApprovalUser {
id: string;
username: string;
displayName: string;
createdAt: string;
}
export interface AccessKeySummary {
id: string;
credentialId: string;
label: string;
transports: string[];
deviceType: string;
backedUp: boolean;
aaguid: string;
createdAt: string;
}
export interface RegistrationOptionsResponse {
rp: PublicKeyCredentialRpEntity;
user: {
id: string;
name: string;
displayName: string;
};
challenge: string;
pubKeyCredParams: PublicKeyCredentialParameters[];
timeout?: number;
excludeCredentials?: Array<{
id: string;
type: PublicKeyCredentialType;
transports?: string[];
}>;
authenticatorSelection?: AuthenticatorSelectionCriteria;
attestation?: AttestationConveyancePreference;
extensions?: AuthenticationExtensionsClientInputs;
}
export interface AuthenticationOptionsResponse {
attemptId: string;
challenge: string;
timeout?: number;
rpId?: string;
allowCredentials?: Array<{
id: string;
type: PublicKeyCredentialType;
transports?: string[];
}>;
userVerification?: UserVerificationRequirement;
hints?: string[];
extensions?: AuthenticationExtensionsClientInputs;
}
export interface ChatEntry {
id: string;
peerId: string;
direction: 'incoming' | 'outgoing' | 'system';
kind: 'text' | 'json' | 'file' | 'system';
createdAt: number;
authorLabel: string;
text?: string;
payload?: unknown;
fileName?: string;
fileSize?: number;
fileMimeType?: string;
downloadUrl?: string;
}
export type SignalPayload =
| { type: 'sdp'; description: RTCSessionDescriptionInit }
| { type: 'ice-candidate'; candidate: RTCIceCandidateInit };
export type ServerEvent =
| { type: 'presence'; self: UserProfile; peers: UserProfile[] }
| { type: 'peer-joined'; peer: UserProfile }
| { type: 'peer-left'; peerId: string }
| { type: 'signal'; from: string; signal: SignalPayload }
| { type: 'error'; message: string };
export type DataEnvelope =
| {
type: 'text';
id: string;
body: string;
authorId: string;
authorName: string;
sentAt: number;
}
| {
type: 'json';
id: string;
body: unknown;
authorId: string;
authorName: string;
sentAt: number;
}
| {
type: 'file-meta';
id: string;
name: string;
mimeType: string;
size: number;
authorId: string;
authorName: string;
sentAt: number;
}
| {
type: 'file-complete';
id: string;
}
| {
type: 'typing';
active: boolean;
};

View File

@@ -0,0 +1,110 @@
import { Injectable, computed, signal } from '@angular/core';
export type ThemeMode = 'light' | 'dark' | 'system';
type ResolvedTheme = 'light' | 'dark';
@Injectable({ providedIn: 'root' })
export class ThemeService {
private readonly storageKey = 'privatechat.themeMode';
private readonly mediaQuery =
typeof window !== 'undefined' ? window.matchMedia('(prefers-color-scheme: dark)') : null;
readonly mode = signal<ThemeMode>(this.readStoredMode());
readonly resolvedTheme = computed<ResolvedTheme>(() => {
const mode = this.mode();
if (mode === 'system') {
return this.mediaQuery?.matches ? 'dark' : 'light';
}
return mode === 'dark' ? 'dark' : 'light';
});
readonly emoji = computed(() => {
switch (this.mode()) {
case 'light':
return '🌞';
case 'dark':
return '🌙';
default:
return '🖥️';
}
});
readonly nextMode = computed<ThemeMode>(() => {
switch (this.mode()) {
case 'light':
return 'dark';
case 'dark':
return 'system';
default:
return 'light';
}
});
constructor() {
this.applyTheme();
const mediaQuery = this.mediaQuery as
| (MediaQueryList & {
addListener?: (listener: () => void) => void;
})
| null;
if (mediaQuery) {
try {
mediaQuery.addEventListener('change', this.handleSystemThemeChange);
} catch {
mediaQuery.addListener?.(this.handleSystemThemeChange);
}
}
}
cycleMode(): void {
this.setMode(this.nextMode());
}
setMode(mode: ThemeMode): void {
this.mode.set(mode);
this.writeStoredMode(mode);
this.applyTheme();
}
private readonly handleSystemThemeChange = (): void => {
if (this.mode() === 'system') {
this.applyTheme();
}
};
private applyTheme(): void {
if (typeof document === 'undefined') {
return;
}
document.documentElement.dataset['themeMode'] = this.mode();
document.documentElement.dataset['theme'] = this.resolvedTheme();
document.documentElement.dataset['bsTheme'] = this.resolvedTheme();
document.documentElement.style.colorScheme = this.resolvedTheme();
}
private readStoredMode(): ThemeMode {
try {
const value = localStorage.getItem(this.storageKey);
if (value === 'light' || value === 'dark' || value === 'system') {
return value;
}
} catch {
// Ignore storage access issues.
}
return 'system';
}
private writeStoredMode(mode: ThemeMode): void {
try {
localStorage.setItem(this.storageKey, mode);
} catch {
// Ignore storage access issues.
}
}
}

17
client/src/index.html Normal file
View File

@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>PrivateChat</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preconnect" href="https://fonts.googleapis.com">
<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 rel="icon" type="image/x-icon" href="favicon.ico">
<script src="env.js"></script>
</head>
<body>
<app-root></app-root>
</body>
</html>

6
client/src/main.ts Normal file
View File

@@ -0,0 +1,6 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
bootstrapApplication(App, appConfig)
.catch((err) => console.error(err));

179
client/src/styles.scss Normal file
View File

@@ -0,0 +1,179 @@
@use 'bootstrap/scss/bootstrap';
:root {
--page-text: #142236;
--page-text-muted: rgba(39, 63, 91, 0.72);
--page-text-soft: rgba(39, 63, 91, 0.82);
--page-background:
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) {
: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:
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='light'] {
color-scheme: light;
}
html,
body {
min-height: 100dvh;
}
body {
margin: 0;
color: var(--page-text);
font-family: 'Space Grotesk', system-ui, sans-serif;
background: var(--page-background);
background-attachment: fixed;
transition:
background 180ms ease,
color 180ms ease,
border-color 180ms ease,
box-shadow 180ms ease;
}
button,
input,
textarea {
font: inherit;
}
.text-secondary {
color: var(--page-text-muted) !important;
}
.text-bg-dark {
color: var(--page-text) !important;
background: var(--badge-background) !important;
}
.btn-outline-light {
color: var(--page-text);
border-color: var(--surface-border);
}
.btn-outline-light:hover,
.btn-outline-light:focus-visible {
color: var(--page-text);
border-color: var(--surface-border);
background: var(--panel-soft-background);
}
.btn-outline-secondary {
color: var(--page-text-muted);
border-color: var(--surface-border);
}
.btn-outline-secondary:hover,
.btn-outline-secondary:focus-visible {
color: var(--page-text);
border-color: var(--surface-border);
background: var(--panel-soft-background);
}
.btn-outline-primary {
color: var(--link-color);
border-color: color-mix(in srgb, var(--link-color) 32%, transparent);
}
.btn-primary {
border-color: transparent;
background: var(--accent-gradient);
}
.alert-danger,
.alert-success,
.alert-warning {
border: 1px solid var(--surface-border);
}

15
client/tsconfig.app.json Normal file
View File

@@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"include": [
"src/**/*.ts"
],
"exclude": [
"src/**/*.spec.ts"
]
}

30
client/tsconfig.json Normal file
View File

@@ -0,0 +1,30 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"compileOnSave": false,
"compilerOptions": {
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"experimentalDecorators": true,
"importHelpers": true,
"target": "ES2022",
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
}
]
}

15
client/tsconfig.spec.json Normal file
View File

@@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"vitest/globals"
]
},
"include": [
"src/**/*.d.ts",
"src/**/*.spec.ts"
]
}