Initial commit
This commit is contained in:
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);
|
||||
}
|
||||
Reference in New Issue
Block a user