Бэкенд: регистрация со входом сразу и /api/config, профиль и смена пароля, свои папки, PATCH письма (прочитано, флажок, перенос; Спам ставит отметку), корзина и очистка, черновики, несколько адресатов и копия, вложения черновика уходят с письмом, вложения и заголовки To/Cc входящих сохраняются, письма заблокированных — в Спам. Исправлено: загрузка файла (415), колонка content_id (500 на любых вложениях), SPA-маршрут с точкой. Фронтенд переписан: все таблицы — грид, оформление — шаблон synthwave-design-showcase.html. α Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
293 lines
14 KiB
TypeScript
293 lines
14 KiB
TypeScript
import { ChangeDetectionStrategy, Component, DestroyRef, computed, effect, inject, input, signal } from '@angular/core';
|
|
import { DatePipe } from '@angular/common';
|
|
import { Router, RouterLink } from '@angular/router';
|
|
import { MatIconModule } from '@angular/material/icon';
|
|
import { MatTooltipModule } from '@angular/material/tooltip';
|
|
import {
|
|
JtGridBulkEvent, JtGridBulkService, JtGridCellDirective, JtGridColumn, JtGridComponent, JtGridRowEvent, RowAction
|
|
} from '@jambotron/aria-grid';
|
|
import { Observable, forkJoin, of } from 'rxjs';
|
|
import { ApiService } from '../../core/api.service';
|
|
import { MailStateService } from '../../core/mail-state.service';
|
|
import { Message, errorText, folderIcon, folderLabel, formatBytes } from '../../core/models';
|
|
import { DialogService } from '../../ui/dialog.service';
|
|
import { ToastService } from '../../ui/toast.service';
|
|
|
|
/** One folder: the message list (grid), search, bulk actions, empty Trash/Spam. */
|
|
@Component({
|
|
selector: 'app-folder',
|
|
imports: [DatePipe, RouterLink, MatIconModule, MatTooltipModule, JtGridComponent, JtGridCellDirective],
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
template: `
|
|
<section class="glass-panel static p-4 md:p-6" [attr.aria-labelledby]="'folder-title'">
|
|
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
|
|
<div class="flex items-center gap-3 min-w-0">
|
|
<mat-icon class="text-pink-400">{{ icon() }}</mat-icon>
|
|
<div class="min-w-0">
|
|
<h1 id="folder-title" class="text-xl font-orbitron font-bold neon-text-secondary truncate">{{ title() }}</h1>
|
|
<p class="text-xs text-slate-400 font-mono">
|
|
@if (q()) { Search “{{ q() }}”: {{ messages().length }} found }
|
|
@else { {{ messages().length }} messages · {{ unread() }} unread }
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
@if (q()) {
|
|
<a [routerLink]="['/folder', folderId()]" class="ghost-btn"><mat-icon>close</mat-icon> Clear search</a>
|
|
}
|
|
<button type="button" class="ghost-btn" (click)="load()" [disabled]="loading()" aria-label="Refresh" matTooltip="Refresh">
|
|
<mat-icon [class.animate-spin]="loading()">refresh</mat-icon>
|
|
</button>
|
|
@if (canEmpty()) {
|
|
<button type="button" class="ghost-btn danger" (click)="empty()" [disabled]="!messages().length">
|
|
<mat-icon>delete_forever</mat-icon> Empty {{ title() }}
|
|
</button>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
@if (error()) {
|
|
<p class="text-pink-400 font-rajdhani mb-3" role="alert">{{ error() }}</p>
|
|
}
|
|
|
|
<jt-grid [rows]="messages()" [columns]="columns()" [trackBy]="byId" [label]="title() + ' messages'"
|
|
[emptyMessage]="loading() ? 'Loading…' : q() ? 'Nothing found' : 'No messages here'"
|
|
[bulkActions]="bulkActions()" (bulkAction)="onBulk($event)"
|
|
[rowActions]="rowActions" (rowAction)="onRow($event)" [rowSubject]="subjectOf"
|
|
[highlight]="isUnread" [initialSort]="{ key: 'date', direction: 'desc' }" [pageSize]="25">
|
|
<ng-template jtGridCell="flag" let-row>
|
|
<button type="button" class="align-middle" [class]="row.flagged ? 'text-yellow-400' : 'text-slate-600 hover:text-yellow-300'"
|
|
[attr.aria-label]="row.flagged ? 'Remove flag' : 'Flag'" [attr.aria-pressed]="row.flagged" (click)="toggleFlag(row)">
|
|
<mat-icon class="!text-xl">{{ row.flagged ? 'star' : 'star_border' }}</mat-icon>
|
|
</button>
|
|
</ng-template>
|
|
<ng-template jtGridCell="who" let-row>
|
|
<div class="max-w-[220px] truncate" [class.font-bold]="!row.read" [class.text-white]="!row.read">
|
|
{{ who(row) }}
|
|
</div>
|
|
</ng-template>
|
|
<ng-template jtGridCell="subject" let-row>
|
|
<a [routerLink]="link(row)" class="block max-w-[560px] group">
|
|
<span class="truncate block group-hover:text-cyan-300" [class.font-bold]="!row.read" [class.text-white]="!row.read">
|
|
@if (!row.read) { <span class="inline-block w-2 h-2 rounded-full bg-pink-500 mr-1.5 align-middle shadow-[0_0_8px_#ff007f]" aria-label="Unread"></span> }
|
|
{{ row.subject || '(no subject)' }}
|
|
</span>
|
|
<span class="truncate block text-xs text-slate-500">{{ row.body }}</span>
|
|
</a>
|
|
</ng-template>
|
|
<ng-template jtGridCell="files" let-row>
|
|
@if (row.attachmentCount) {
|
|
<span class="text-cyan-300 text-xs font-mono whitespace-nowrap" [attr.aria-label]="row.attachmentCount + ' attachments'">
|
|
<mat-icon class="!text-base">attach_file</mat-icon>{{ row.attachmentCount }}
|
|
</span>
|
|
}
|
|
</ng-template>
|
|
<ng-template jtGridCell="date" let-row>
|
|
<span class="font-mono text-xs whitespace-nowrap text-slate-400" [title]="row.receivedAt | date: 'medium'">
|
|
{{ shortDate(row.receivedAt) }}
|
|
</span>
|
|
</ng-template>
|
|
<ng-template jtGridCell="size" let-row>
|
|
<span class="font-mono text-xs text-slate-500 whitespace-nowrap">{{ size(row) }}</span>
|
|
</ng-template>
|
|
</jt-grid>
|
|
</section>
|
|
`
|
|
})
|
|
export class FolderPage {
|
|
readonly id = input.required<string>();
|
|
readonly q = input<string | undefined>();
|
|
|
|
private api = inject(ApiService);
|
|
private state = inject(MailStateService);
|
|
private bulk = inject(JtGridBulkService);
|
|
private dialogs = inject(DialogService);
|
|
private toasts = inject(ToastService);
|
|
private router = inject(Router);
|
|
|
|
readonly messages = signal<Message[]>([]);
|
|
readonly loading = signal(false);
|
|
readonly error = signal('');
|
|
|
|
readonly folderId = computed(() => Number(this.id()));
|
|
readonly folder = computed(() => this.state.folder(this.folderId()));
|
|
readonly title = computed(() => this.folder() ? folderLabel(this.folder()!) : 'Folder');
|
|
readonly icon = computed(() => this.folder() ? folderIcon(this.folder()!) : 'folder');
|
|
readonly kind = computed(() => this.folder()?.system ? this.folder()!.name : 'own');
|
|
readonly outgoing = computed(() => this.kind() === 'Sent' || this.kind() === 'Drafts');
|
|
readonly canEmpty = computed(() => this.kind() === 'Trash' || this.kind() === 'Spam');
|
|
readonly unread = computed(() => this.messages().filter(m => !m.read).length);
|
|
|
|
readonly columns = computed<JtGridColumn<Message>[]>(() => [
|
|
{ key: 'flag', label: 'Flag', hiddenLabel: true, sort: m => m.flagged },
|
|
{ key: 'who', label: this.outgoing() ? 'To' : 'From', sort: m => this.who(m).toLowerCase() },
|
|
{ key: 'subject', label: 'Subject', sort: m => (m.subject || '').toLowerCase() },
|
|
{ key: 'files', label: 'Attachments', hiddenLabel: true, sort: m => m.attachmentCount },
|
|
{ key: 'date', label: 'Date', sort: m => m.receivedAt, align: 'end' },
|
|
{ key: 'size', label: 'Size', sort: m => this.bytes(m), align: 'end' }
|
|
]);
|
|
|
|
readonly bulkActions = computed<RowAction[]>(() => {
|
|
const here = this.folderId();
|
|
const moves = this.state.folders()
|
|
.filter(f => f.id !== here && f.name !== 'Drafts' && f.name !== 'Sent')
|
|
.map(f => ({ value: 'move:' + f.id, label: 'To ' + folderLabel(f), icon: f.system && f.name === 'Spam' ? 'report' : 'drive_file_move' }));
|
|
return [
|
|
{ value: 'read', label: 'Mark read', icon: 'drafts' },
|
|
{ value: 'unread', label: 'Mark unread', icon: 'mark_email_unread' },
|
|
{ value: 'flag', label: 'Flag', icon: 'star' },
|
|
...moves.filter(m => !m.label.endsWith('Trash')),
|
|
{ value: 'delete', label: this.kind() === 'Trash' ? 'Delete forever' : 'Delete', icon: 'delete', danger: true }
|
|
];
|
|
});
|
|
|
|
readonly byId = (m: Message) => m.id;
|
|
readonly isUnread = (m: Message) => !m.read;
|
|
readonly subjectOf = (m: Message) => m.subject || '(no subject)';
|
|
|
|
readonly rowActions = (m: Message): RowAction[] => {
|
|
const draft = this.kind() === 'Drafts';
|
|
const spam = this.kind() === 'Spam';
|
|
return [
|
|
draft ? { value: 'edit', label: 'Edit draft', icon: 'edit', inline: true }
|
|
: { value: 'reply', label: 'Reply', icon: 'reply', inline: true },
|
|
...(draft ? [] : [{ value: 'forward', label: 'Forward', icon: 'forward' }]),
|
|
{ value: m.read ? 'unread' : 'read', label: m.read ? 'Mark unread' : 'Mark read', icon: m.read ? 'mark_email_unread' : 'drafts' },
|
|
...(this.outgoing() ? [] : [spam ? { value: 'notspam', label: 'Not spam', icon: 'inbox' } : { value: 'spam', label: 'Spam', icon: 'report' }]),
|
|
{ value: 'delete', label: this.kind() === 'Trash' ? 'Delete forever' : 'Delete', icon: 'delete', danger: true }
|
|
];
|
|
};
|
|
|
|
constructor() {
|
|
effect(() => {
|
|
this.folderId();
|
|
this.q();
|
|
this.load();
|
|
});
|
|
const timer = setInterval(() => { if (!this.q() && !document.hidden) this.load(true); }, 30000);
|
|
inject(DestroyRef).onDestroy(() => clearInterval(timer));
|
|
}
|
|
|
|
load(quiet = false): void {
|
|
const id = this.folderId();
|
|
if (!id) return;
|
|
if (!quiet) this.loading.set(true);
|
|
const q = this.q()?.trim();
|
|
(q ? this.api.search(id, q) : this.api.messages(id)).subscribe({
|
|
next: list => { this.messages.set(list); this.loading.set(false); this.error.set(''); },
|
|
error: e => { this.loading.set(false); if (!quiet) this.error.set(errorText(e)); }
|
|
});
|
|
}
|
|
|
|
who(m: Message): string {
|
|
return this.outgoing() ? (m.recipientEmail || '(no recipient)') : (m.senderName || m.senderEmail);
|
|
}
|
|
|
|
link(m: Message): unknown[] {
|
|
return this.kind() === 'Drafts' ? ['/compose', { draft: m.id }] : ['/message', m.id];
|
|
}
|
|
|
|
bytes(m: Message): number { return m.messageSize ?? 0; }
|
|
size(m: Message): string { return formatBytes(m.messageSize); }
|
|
|
|
shortDate(value: string): string {
|
|
const date = new Date(value);
|
|
const now = new Date();
|
|
return date.toDateString() === now.toDateString()
|
|
? date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
: date.toLocaleDateString([], { day: 'numeric', month: 'short', ...(date.getFullYear() !== now.getFullYear() ? { year: 'numeric' } : {}) });
|
|
}
|
|
|
|
toggleFlag(m: Message): void {
|
|
this.patch(m, { flagged: !m.flagged });
|
|
}
|
|
|
|
onRow(e: JtGridRowEvent<Message>): void {
|
|
const m = e.row;
|
|
switch (e.action) {
|
|
case 'edit': this.router.navigate(['/compose', { draft: m.id }]); break;
|
|
case 'reply': this.router.navigate(['/compose', { reply: m.id }]); break;
|
|
case 'forward': this.router.navigate(['/compose', { forward: m.id }]); break;
|
|
case 'read': case 'unread':
|
|
this.patch(m, { read: e.action === 'read' });
|
|
break;
|
|
case 'spam': this.moveTo([m], this.state.system('Spam')?.id, 'Moved to Spam'); break;
|
|
case 'notspam': this.moveTo([m], this.state.inbox()?.id, 'Moved to Inbox'); break;
|
|
case 'delete':
|
|
if (this.kind() === 'Trash') {
|
|
this.dialogs.confirm('Delete forever?', `"${this.subjectOf(m)}" cannot be restored.`, 'Delete', true)
|
|
.subscribe(ok => ok !== null && this.remove([m]));
|
|
} else {
|
|
this.remove([m]);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
onBulk(e: JtGridBulkEvent<Message>): void {
|
|
const action = e.action;
|
|
if (action.startsWith('move:')) {
|
|
const target = this.state.folder(Number(action.slice(5)));
|
|
this.runBulk(e.rows, 'To ' + (target ? folderLabel(target) : 'folder'), m => this.api.updateMessage(m.id, { folderId: target!.id }));
|
|
return;
|
|
}
|
|
switch (action) {
|
|
case 'read': this.runBulk(e.rows, 'Mark read', m => this.api.updateMessage(m.id, { read: true })); break;
|
|
case 'unread': this.runBulk(e.rows, 'Mark unread', m => this.api.updateMessage(m.id, { read: false })); break;
|
|
case 'flag': this.runBulk(e.rows, 'Flag', m => this.api.updateMessage(m.id, { flagged: true })); break;
|
|
case 'delete':
|
|
this.runBulk(e.rows, this.kind() === 'Trash' ? 'Delete forever' : 'Delete', m => this.api.deleteMessage(m.id),
|
|
this.kind() === 'Trash' ? `Delete ${e.rows.length} messages forever? They cannot be restored.` : `Move ${e.rows.length} messages to the Trash?`);
|
|
break;
|
|
}
|
|
}
|
|
|
|
empty(): void {
|
|
const folder = this.folder();
|
|
if (!folder) return;
|
|
this.dialogs.confirm(`Empty ${this.title()}?`, `All ${this.messages().length} messages are deleted forever.`, 'Empty', true)
|
|
.subscribe(ok => {
|
|
if (ok === null) return;
|
|
this.api.emptyFolder(folder.id).subscribe({
|
|
next: r => { this.toasts.ok(`${r.deleted} messages deleted`); this.afterChange(); },
|
|
error: e => this.toasts.error(errorText(e))
|
|
});
|
|
});
|
|
}
|
|
|
|
private runBulk(rows: Message[], label: string, run: (m: Message) => Observable<unknown>, confirm?: string): void {
|
|
this.bulk.run({ rows, label, confirm, danger: !!confirm, run }).subscribe(() => this.afterChange());
|
|
}
|
|
|
|
private moveTo(rows: Message[], folderId: number | undefined, done: string): void {
|
|
if (!folderId) return;
|
|
forkJoin(rows.map(m => this.api.updateMessage(m.id, { folderId }))).subscribe({
|
|
next: () => { this.toasts.ok(done); this.afterChange(); },
|
|
error: e => this.toasts.error(errorText(e))
|
|
});
|
|
}
|
|
|
|
private remove(rows: Message[]): void {
|
|
forkJoin(rows.length ? rows.map(m => this.api.deleteMessage(m.id)) : [of(null)]).subscribe({
|
|
next: () => { this.toasts.ok(this.kind() === 'Trash' ? 'Deleted forever' : 'Moved to Trash'); this.afterChange(); },
|
|
error: e => this.toasts.error(errorText(e))
|
|
});
|
|
}
|
|
|
|
private patch(m: Message, change: { read?: boolean; flagged?: boolean }): void {
|
|
this.api.updateMessage(m.id, change).subscribe({
|
|
next: updated => {
|
|
this.messages.update(list => list.map(x => x.id === m.id ? { ...x, read: updated.read, flagged: updated.flagged } : x));
|
|
if (change.read !== undefined) this.state.refreshFolders();
|
|
},
|
|
error: e => this.toasts.error(errorText(e))
|
|
});
|
|
}
|
|
|
|
private afterChange(): void {
|
|
this.load(true);
|
|
this.state.refreshFolders();
|
|
this.state.refreshProfile();
|
|
}
|
|
}
|