Почтовый кабинет: регистрация, папки, черновики, вложения, грид @jambotron/aria-grid, интерфейс по synthwave-шаблону

Бэкенд: регистрация со входом сразу и /api/config, профиль и смена пароля, свои папки, PATCH письма
(прочитано, флажок, перенос; Спам ставит отметку), корзина и очистка, черновики, несколько адресатов и копия,
вложения черновика уходят с письмом, вложения и заголовки To/Cc входящих сохраняются, письма заблокированных —
в Спам. Исправлено: загрузка файла (415), колонка content_id (500 на любых вложениях), SPA-маршрут с точкой.
Фронтенд переписан: все таблицы — грид, оформление — шаблон synthwave-design-showcase.html.

α
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 16:41:29 +01:00
parent f605fbfd85
commit 29b1beabe0
70 changed files with 3468 additions and 1737 deletions
@@ -0,0 +1,152 @@
import { ChangeDetectionStrategy, Component, computed, effect, inject, signal } from '@angular/core';
import { DatePipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { ApiService } from '../../core/api.service';
import { MailStateService } from '../../core/mail-state.service';
import { errorText, formatBytes } from '../../core/models';
import { THEMES, ThemeService } from '../../ui/theme.service';
import { ToastService } from '../../ui/toast.service';
/** Profile name, password, storage, look. */
@Component({
selector: 'app-settings',
imports: [DatePipe, FormsModule, MatIconModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="grid grid-cols-1 xl:grid-cols-2 gap-5">
<section class="glass-panel static p-5 md:p-6" aria-labelledby="profile-title">
<h1 id="profile-title" class="text-lg font-orbitron font-bold text-cyan-300 flex items-center gap-2 mb-4 border-b border-cyan-500/20 pb-2">
<mat-icon class="text-pink-500">badge</mat-icon> PROFILE
</h1>
@if (state.profile(); as p) {
<dl class="text-sm font-rajdhani grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 mb-5">
<dt class="text-slate-500 font-mono text-xs pt-0.5">Mailbox</dt><dd class="text-cyan-300 font-mono">{{ p.email }}</dd>
<dt class="text-slate-500 font-mono text-xs pt-0.5">Since</dt><dd class="text-slate-300">{{ p.createdAt | date: 'd MMMM y' }}</dd>
</dl>
}
<form (ngSubmit)="saveName()" class="space-y-3">
<div>
<label class="synth-label" for="display-name">Name shown to recipients</label>
<input id="display-name" name="displayName" class="synth-input" [(ngModel)]="displayName" maxlength="100" required>
</div>
<button type="submit" class="cyber-btn px-4 py-2 rounded-lg text-xs font-bold"><mat-icon>save</mat-icon> SAVE NAME</button>
</form>
</section>
<section class="glass-panel static p-5 md:p-6" aria-labelledby="password-title">
<h2 id="password-title" class="text-lg font-orbitron font-bold text-cyan-300 flex items-center gap-2 mb-4 border-b border-cyan-500/20 pb-2">
<mat-icon class="text-pink-500">key</mat-icon> PASSWORD
</h2>
<form (ngSubmit)="changePassword()" class="space-y-3">
<input type="email" name="username" class="sr-only" [value]="state.profile()?.email ?? ''" autocomplete="username" tabindex="-1" aria-hidden="true">
<div><label class="synth-label" for="pw-current">Current password</label>
<input id="pw-current" name="current" type="password" class="synth-input" [(ngModel)]="current" autocomplete="current-password"></div>
<div><label class="synth-label" for="pw-new">New password (8+ characters)</label>
<input id="pw-new" name="next" type="password" class="synth-input" [(ngModel)]="next" autocomplete="new-password"></div>
<div><label class="synth-label" for="pw-again">New password again</label>
<input id="pw-again" name="again" type="password" class="synth-input" [(ngModel)]="again" autocomplete="new-password"></div>
@if (passwordError()) { <p class="text-sm text-pink-400" role="alert">{{ passwordError() }}</p> }
<button type="submit" class="cyber-btn px-4 py-2 rounded-lg text-xs font-bold"><mat-icon>lock_reset</mat-icon> CHANGE PASSWORD</button>
</form>
</section>
<section class="glass-panel static p-5 md:p-6" aria-labelledby="storage-title">
<h2 id="storage-title" class="text-lg font-orbitron font-bold text-cyan-300 flex items-center gap-2 mb-4 border-b border-cyan-500/20 pb-2">
<mat-icon class="text-pink-500">storage</mat-icon> STORAGE
</h2>
<div class="flex justify-between text-xs font-mono text-slate-300 mb-1">
<span>{{ used() }} of {{ quota() }}</span><span class="text-pink-400">{{ percent() }}%</span>
</div>
<div class="h-2 rounded-full bg-slate-800 overflow-hidden mb-4" role="progressbar" aria-label="Storage used"
[attr.aria-valuenow]="percent()" aria-valuemin="0" aria-valuemax="100">
<div class="h-full bg-gradient-to-r from-cyan-400 to-pink-500" [style.width.%]="percent()"></div>
</div>
<ul class="text-sm font-rajdhani text-slate-300 space-y-1">
@for (f of state.folders(); track f.id) {
<li class="flex justify-between"><span>{{ f.system ? label(f.name) : f.name }}</span>
<span class="font-mono text-xs text-slate-400">{{ f.messageCount }} messages</span></li>
}
</ul>
</section>
<section class="glass-panel static p-5 md:p-6" aria-labelledby="look-title">
<h2 id="look-title" class="text-lg font-orbitron font-bold text-cyan-300 flex items-center gap-2 mb-4 border-b border-cyan-500/20 pb-2">
<mat-icon class="text-pink-500">palette</mat-icon> LOOK
</h2>
<fieldset class="mb-4">
<legend class="synth-label">Palette</legend>
<div class="grid grid-cols-2 gap-2">
@for (t of themes; track t.value) {
<button type="button" class="ghost-btn justify-center !py-2" [attr.aria-pressed]="theme.theme() === t.value"
[class.!border-pink-500]="theme.theme() === t.value" [class.!text-pink-300]="theme.theme() === t.value"
(click)="theme.theme.set(t.value)">{{ t.label }}</button>
}
</div>
</fieldset>
<div class="space-y-3">
<div class="flex items-center justify-between p-3 bg-slate-900/50 rounded-lg border border-pink-500/20">
<div><div class="font-bold text-slate-200">High contrast</div><div class="text-xs text-slate-400">Black background, bright outlines</div></div>
<button type="button" class="ghost-btn" [attr.aria-pressed]="theme.highContrast()" (click)="theme.highContrast.set(!theme.highContrast())">
{{ theme.highContrast() ? 'ON' : 'OFF' }}</button>
</div>
<div class="flex items-center justify-between p-3 bg-slate-900/50 rounded-lg border border-pink-500/20">
<div><div class="font-bold text-slate-200">Grid motion</div><div class="text-xs text-slate-400">The moving retro floor in the background</div></div>
<button type="button" class="ghost-btn" [attr.aria-pressed]="theme.animation()" (click)="theme.animation.set(!theme.animation())">
{{ theme.animation() ? 'ON' : 'OFF' }}</button>
</div>
</div>
</section>
</div>
`
})
export class SettingsPage {
readonly state = inject(MailStateService);
readonly theme = inject(ThemeService);
private api = inject(ApiService);
private toasts = inject(ToastService);
readonly themes = THEMES;
displayName = '';
current = '';
next = '';
again = '';
readonly passwordError = signal('');
readonly used = computed(() => formatBytes(this.state.profile()?.usedBytes));
readonly quota = computed(() => formatBytes(this.state.profile()?.quotaBytes));
readonly percent = computed(() => {
const p = this.state.profile();
return p && p.quotaBytes ? Math.min(100, Math.round(p.usedBytes / p.quotaBytes * 1000) / 10) : 0;
});
constructor() {
this.state.refreshProfile();
effect(() => { this.displayName = this.state.profile()?.displayName ?? ''; });
}
label(name: string): string {
return ({ INBOX: 'Inbox', Drafts: 'Drafts', Sent: 'Sent', Spam: 'Spam', Trash: 'Trash' } as Record<string, string>)[name] ?? name;
}
saveName(): void {
const name = this.displayName.trim();
if (!name) { this.toasts.error('The name cannot be empty'); return; }
this.api.updateMe(name).subscribe({
next: p => { this.state.profile.set(p); this.toasts.ok('Name saved'); },
error: e => this.toasts.error(errorText(e))
});
}
changePassword(): void {
const problem = !this.current ? 'Enter the current password'
: this.next.length < 8 ? 'The new password is shorter than 8 characters'
: this.next !== this.again ? 'The new passwords do not match' : '';
this.passwordError.set(problem);
if (problem) return;
this.api.changePassword(this.current, this.next).subscribe({
next: () => { this.current = this.next = this.again = ''; this.toasts.ok('Password changed'); },
error: e => this.passwordError.set(errorText(e))
});
}
}