Initial commit
This commit is contained in:
17
client/.editorconfig
Normal file
17
client/.editorconfig
Normal 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
12
client/.prettierrc
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.html",
|
||||
"options": {
|
||||
"parser": "angular"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
4
client/.vscode/extensions.json
vendored
Normal file
4
client/.vscode/extensions.json
vendored
Normal 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
20
client/.vscode/launch.json
vendored
Normal 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
9
client/.vscode/mcp.json
vendored
Normal 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
42
client/.vscode/tasks.json
vendored
Normal 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
59
client/README.md
Normal 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
97
client/angular.json
Normal 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
7784
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
client/package.json
Normal file
33
client/package.json
Normal 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
3
client/public/env.js
Normal file
@@ -0,0 +1,3 @@
|
||||
window.__PRIVATECHAT_ENV__ = {
|
||||
"PRIVATECHAT_CLIENT_SERVER_URL": "http://chatter.dubertrand.fr"
|
||||
};
|
||||
BIN
client/public/favicon.ico
Normal file
BIN
client/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
17
client/scripts/write-env.js
Normal file
17
client/scripts/write-env.js
Normal 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');
|
||||
13
client/src/app/app.config.ts
Normal file
13
client/src/app/app.config.ts
Normal 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
1
client/src/app/app.html
Normal file
@@ -0,0 +1 @@
|
||||
<router-outlet />
|
||||
24
client/src/app/app.routes.ts
Normal file
24
client/src/app/app.routes.ts
Normal 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
4
client/src/app/app.scss
Normal file
@@ -0,0 +1,4 @@
|
||||
:host {
|
||||
display: block;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
14
client/src/app/app.ts
Normal file
14
client/src/app/app.ts
Normal 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) {}
|
||||
}
|
||||
45
client/src/app/approval-page.component.html
Normal file
45
client/src/app/approval-page.component.html
Normal 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>
|
||||
5
client/src/app/approval-page.component.scss
Normal file
5
client/src/app/approval-page.component.scss
Normal file
@@ -0,0 +1,5 @@
|
||||
.approval-card {
|
||||
border: 1px solid var(--surface-border-soft);
|
||||
border-radius: 1rem;
|
||||
background: var(--panel-soft-background);
|
||||
}
|
||||
61
client/src/app/approval-page.component.ts
Normal file
61
client/src/app/approval-page.component.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
199
client/src/app/chat-page.component.html
Normal file
199
client/src/app/chat-page.component.html
Normal 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>
|
||||
316
client/src/app/chat-page.component.scss
Normal file
316
client/src/app/chat-page.component.scss
Normal 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%;
|
||||
}
|
||||
}
|
||||
151
client/src/app/chat-page.component.ts
Normal file
151
client/src/app/chat-page.component.ts
Normal 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]);
|
||||
}
|
||||
}
|
||||
1799
client/src/app/chat-session.service.ts
Normal file
1799
client/src/app/chat-session.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
269
client/src/app/home-page.component.html
Normal file
269
client/src/app/home-page.component.html
Normal 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>
|
||||
156
client/src/app/home-page.component.scss
Normal file
156
client/src/app/home-page.component.scss
Normal 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;
|
||||
}
|
||||
102
client/src/app/home-page.component.ts
Normal file
102
client/src/app/home-page.component.ts
Normal 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
144
client/src/app/models.ts
Normal 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;
|
||||
};
|
||||
110
client/src/app/theme.service.ts
Normal file
110
client/src/app/theme.service.ts
Normal 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
17
client/src/index.html
Normal 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
6
client/src/main.ts
Normal 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
179
client/src/styles.scss
Normal 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
15
client/tsconfig.app.json
Normal 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
30
client/tsconfig.json
Normal 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
15
client/tsconfig.spec.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user