Files
reactive-mail-server/frontend/src/app/pages/shell/shell.component.ts
T
Liosha84 29b1beabe0 Почтовый кабинет: регистрация, папки, черновики, вложения, грид @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>
2026-11-05 16:41:29 +01:00

255 lines
13 KiB
TypeScript

import { ChangeDetectionStrategy, Component, DestroyRef, computed, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { NavigationEnd, Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { JtRowActionsComponent, RowAction } from '@jambotron/aria-grid';
import { filter } from 'rxjs';
import { ApiService } from '../../core/api.service';
import { AuthService } from '../../core/auth.service';
import { MailStateService } from '../../core/mail-state.service';
import { Folder, errorText, folderIcon, folderLabel, formatBytes } from '../../core/models';
import { BackgroundComponent } from '../../ui/background.component';
import { DialogService } from '../../ui/dialog.service';
import { THEMES, ThemeService } from '../../ui/theme.service';
import { ToastService } from '../../ui/toast.service';
/** Header, folders sidebar and the page — everything after login. */
@Component({
selector: 'app-shell',
imports: [FormsModule, RouterOutlet, RouterLink, RouterLinkActive, MatIconModule, MatTooltipModule,
JtRowActionsComponent, BackgroundComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<app-background />
<div class="relative z-10 flex flex-col min-h-screen">
<header class="px-4 py-3 lg:px-6 border-b border-cyan-500/20 glass-panel static rounded-none border-t-0 border-x-0 sticky top-0 z-40 backdrop-blur-xl">
<div class="max-w-[1500px] mx-auto flex items-center gap-3">
<button type="button" class="lg:hidden ghost-btn !px-2" aria-label="Folders" [attr.aria-expanded]="menuOpen()"
(click)="menuOpen.set(!menuOpen())"><mat-icon>menu</mat-icon></button>
<a routerLink="/" class="flex items-center gap-3 shrink-0" aria-label="Synth Mail — inbox">
<div class="w-10 h-10 rounded-lg bg-gradient-to-br from-pink-500 to-cyan-400 flex items-center justify-center
text-slate-950 font-black text-xl shadow-[0_0_15px_rgba(255,0,127,0.8)] font-orbitron">M</div>
<div class="hidden sm:block">
<div class="text-xl font-extrabold font-orbitron tracking-wider neon-text-primary leading-tight">
SYNTH <span class="text-cyan-400 font-normal">MAIL</span>
</div>
<div class="text-[10px] text-cyan-300/70 font-mono tracking-widest uppercase">Reactive mail server</div>
</div>
</a>
<form class="flex-grow max-w-xl mx-auto" role="search" (ngSubmit)="search()">
<label for="global-search" class="sr-only">Search in the current folder</label>
<div class="relative">
<mat-icon class="absolute left-3 top-1/2 -translate-y-1/2 text-cyan-400/70 !text-xl">search</mat-icon>
<input id="global-search" name="q" [(ngModel)]="query" placeholder="Search mail…"
class="synth-input !pl-10 !rounded-full !py-2">
</div>
</form>
<div class="hidden md:flex items-center gap-3 bg-slate-950/60 px-4 py-1.5 rounded-full border border-pink-500/30 text-xs font-mono">
<span class="w-2 h-2 rounded-full bg-emerald-400 animate-ping" aria-hidden="true"></span>
<span class="text-slate-300">Unread: <span class="text-pink-400 font-bold">{{ state.unreadTotal() }}</span></span>
<span class="text-slate-600" aria-hidden="true">|</span>
<span class="text-cyan-300 max-w-[220px] truncate" [matTooltip]="auth.email() ?? ''">{{ state.profile()?.displayName || auth.email() }}</span>
</div>
<label for="palette" class="sr-only">Color theme</label>
<select id="palette" class="hidden xl:block bg-slate-900/90 text-cyan-300 border border-cyan-500/40 rounded-lg px-2 py-1.5 text-xs font-mono"
[ngModel]="theme.theme()" (ngModelChange)="theme.theme.set($event)">
@for (t of themes; track t.value) { <option [value]="t.value">{{ t.label }}</option> }
</select>
<button type="button" class="ghost-btn !px-2" matTooltip="High contrast" aria-label="High contrast"
[attr.aria-pressed]="theme.highContrast()" (click)="theme.highContrast.set(!theme.highContrast())">
<mat-icon class="text-yellow-400">contrast</mat-icon>
</button>
<button type="button" class="ghost-btn !px-2" matTooltip="Log out" aria-label="Log out" (click)="logout()">
<mat-icon>logout</mat-icon>
</button>
</div>
</header>
<div class="flex-grow max-w-[1500px] w-full mx-auto p-3 md:p-5 grid grid-cols-1 lg:grid-cols-[260px_1fr] gap-5">
<nav class="glass-panel static p-4 h-fit lg:sticky lg:top-24 lg:block" [class.hidden]="!menuOpen()"
aria-label="Mail folders">
<a routerLink="/compose" class="cyber-btn w-full py-2.5 rounded-xl font-bold text-xs mb-4" (click)="menuOpen.set(false)">
<mat-icon>edit</mat-icon> COMPOSE
</a>
<ul class="space-y-1">
@for (folder of systemFolders(); track folder.id) {
<li>
<a [routerLink]="['/folder', folder.id]" routerLinkActive="!bg-pink-500/20 !text-pink-300 !border-pink-500/40"
class="flex items-center gap-3 px-3 py-2 rounded-lg border border-transparent text-slate-300 hover:bg-cyan-500/10 hover:text-cyan-300 font-rajdhani font-semibold"
(click)="menuOpen.set(false)">
<mat-icon class="!text-xl">{{ icon(folder) }}</mat-icon>
<span class="flex-grow">{{ label(folder) }}</span>
@if (badge(folder)) {
<span class="text-[11px] font-mono px-2 rounded-full bg-pink-500/25 text-pink-300 border border-pink-500/40"
[attr.aria-label]="badge(folder) + ' unread'">{{ badge(folder) }}</span>
}
</a>
</li>
}
</ul>
<div class="flex items-center justify-between mt-5 mb-2 px-1">
<h2 class="text-[11px] font-orbitron font-bold text-cyan-300 tracking-widest">MY FOLDERS</h2>
<button type="button" class="text-slate-400 hover:text-cyan-300" matTooltip="New folder" aria-label="New folder"
(click)="createFolder()"><mat-icon class="!text-xl">create_new_folder</mat-icon></button>
</div>
<ul class="space-y-1">
@for (folder of ownFolders(); track folder.id) {
<li class="flex items-center gap-1">
<a [routerLink]="['/folder', folder.id]" routerLinkActive="!bg-pink-500/20 !text-pink-300 !border-pink-500/40"
class="flex-grow flex items-center gap-3 px-3 py-1.5 rounded-lg border border-transparent text-slate-300 hover:bg-cyan-500/10 hover:text-cyan-300 font-rajdhani font-semibold min-w-0"
(click)="menuOpen.set(false)">
<mat-icon class="!text-xl">folder</mat-icon>
<span class="flex-grow truncate">{{ folder.name }}</span>
@if (folder.unreadCount) {
<span class="text-[11px] font-mono px-2 rounded-full bg-pink-500/25 text-pink-300">{{ folder.unreadCount }}</span>
}
</a>
<jt-row-actions [actions]="folderActions" [subject]="folder.name" (action)="onFolderAction($event, folder)" />
</li>
} @empty {
<li class="px-3 text-xs text-slate-500 font-mono">No folders yet</li>
}
</ul>
<div class="border-t border-cyan-500/20 mt-5 pt-3 space-y-1">
@for (link of links; track link.path) {
<a [routerLink]="link.path" routerLinkActive="!text-pink-300"
class="flex items-center gap-3 px-3 py-1.5 rounded-lg text-slate-400 hover:text-cyan-300 font-rajdhani font-semibold"
(click)="menuOpen.set(false)">
<mat-icon class="!text-xl">{{ link.icon }}</mat-icon>{{ link.label }}
</a>
}
</div>
@if (state.profile(); as p) {
<div class="mt-4 px-1 text-[11px] font-mono text-slate-400">
<div class="flex justify-between mb-1"><span>Storage</span><span class="text-cyan-300">{{ used() }}</span></div>
<div class="h-1.5 rounded-full bg-slate-800 overflow-hidden" role="progressbar" aria-label="Storage used"
[attr.aria-valuenow]="usedPercent()" aria-valuemin="0" aria-valuemax="100">
<div class="h-full bg-gradient-to-r from-cyan-400 to-pink-500" [style.width.%]="usedPercent()"></div>
</div>
</div>
}
</nav>
<main class="min-w-0" id="main">
<router-outlet />
</main>
</div>
</div>
`
})
export class ShellComponent {
readonly auth = inject(AuthService);
readonly state = inject(MailStateService);
readonly theme = inject(ThemeService);
private api = inject(ApiService);
private dialogs = inject(DialogService);
private toasts = inject(ToastService);
private router = inject(Router);
readonly themes = THEMES;
readonly menuOpen = signal(false);
query = '';
readonly links = [
{ path: '/contacts', label: 'Contacts', icon: 'contacts' },
{ path: '/blocked', label: 'Blocked senders', icon: 'block' },
{ path: '/settings', label: 'Settings', icon: 'settings' }
];
readonly folderActions: RowAction[] = [
{ value: 'rename', label: 'Rename', icon: 'drive_file_rename_outline' },
{ value: 'delete', label: 'Delete folder', icon: 'delete', danger: true }
];
readonly systemFolders = computed(() => this.state.folders().filter(f => f.system));
readonly ownFolders = computed(() => this.state.folders().filter(f => !f.system));
readonly used = computed(() => {
const p = this.state.profile();
return p ? `${formatBytes(p.usedBytes)} / ${formatBytes(p.quotaBytes)}` : '';
});
readonly usedPercent = computed(() => {
const p = this.state.profile();
return p && p.quotaBytes ? Math.min(100, Math.round(p.usedBytes / p.quotaBytes * 100)) : 0;
});
label = folderLabel;
icon = folderIcon;
constructor() {
this.state.refreshFolders();
this.state.refreshProfile();
const timer = setInterval(() => this.state.refreshFolders(), 30000);
inject(DestroyRef).onDestroy(() => clearInterval(timer));
this.router.events.pipe(filter(e => e instanceof NavigationEnd)).subscribe(() => {
const q = this.router.parseUrl(this.router.url).queryParams['q'];
this.query = typeof q === 'string' ? q : '';
});
}
/** Drafts: how many; Sent, Trash: nothing; others: unread. */
badge(folder: Folder): number {
if (folder.name === 'Drafts') return folder.messageCount;
if (folder.name === 'Sent' || folder.name === 'Trash') return 0;
return folder.unreadCount;
}
search(): void {
const match = this.router.url.match(/^\/folder\/(\d+)/);
const folderId = match ? Number(match[1]) : this.state.inbox()?.id;
if (!folderId) return;
const q = this.query.trim();
this.router.navigate(['/folder', folderId], { queryParams: q ? { q } : {} });
}
createFolder(): void {
this.dialogs.prompt('New folder', 'Folder name', '', 'Create').subscribe(name => {
if (!name) return;
this.api.createFolder(name).subscribe({
next: folder => {
this.state.refreshFolders();
this.toasts.ok(`Folder "${folder.name}" created`);
this.router.navigate(['/folder', folder.id]);
},
error: e => this.toasts.error(errorText(e))
});
});
}
onFolderAction(action: string, folder: Folder): void {
if (action === 'rename') {
this.dialogs.prompt('Rename folder', 'Folder name', folder.name, 'Rename').subscribe(name => {
if (!name || name === folder.name) return;
this.api.renameFolder(folder.id, name).subscribe({
next: () => { this.state.refreshFolders(); this.toasts.ok('Folder renamed'); },
error: e => this.toasts.error(errorText(e))
});
});
} else if (action === 'delete') {
this.dialogs.confirm(`Delete "${folder.name}"?`, 'Its messages go to the Trash.', 'Delete', true).subscribe(ok => {
if (ok === null) return;
this.api.deleteFolder(folder.id).subscribe({
next: () => {
this.state.refreshFolders();
this.toasts.ok('Folder deleted');
if (this.router.url.startsWith(`/folder/${folder.id}`)) this.router.navigate(['/']);
},
error: e => this.toasts.error(errorText(e))
});
});
}
}
logout(): void {
this.state.reset();
this.auth.logout();
}
}