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