Почтовый кабинет: регистрация, папки, черновики, вложения, грид @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:
@@ -0,0 +1,254 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user