Бэкенд: регистрация со входом сразу и /api/config, профиль и смена пароля, свои папки, PATCH письма (прочитано, флажок, перенос; Спам ставит отметку), корзина и очистка, черновики, несколько адресатов и копия, вложения черновика уходят с письмом, вложения и заголовки To/Cc входящих сохраняются, письма заблокированных — в Спам. Исправлено: загрузка файла (415), колонка content_id (500 на любых вложениях), SPA-маршрут с точкой. Фронтенд переписан: все таблицы — грид, оформление — шаблон synthwave-design-showcase.html. α Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
153 lines
8.3 KiB
TypeScript
153 lines
8.3 KiB
TypeScript
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))
|
|
});
|
|
}
|
|
}
|