Files
reactive-mail-server/frontend/src/app/pages/contacts/contacts.page.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

152 lines
7.7 KiB
TypeScript

import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { A11yModule } from '@angular/cdk/a11y';
import { MatIconModule } from '@angular/material/icon';
import {
JtGridBulkEvent, JtGridBulkService, JtGridCellDirective, JtGridColumn, JtGridComponent, JtGridRowEvent, RowAction
} from '@jambotron/aria-grid';
import { ApiService } from '../../core/api.service';
import { Contact, errorText } from '../../core/models';
import { DialogService } from '../../ui/dialog.service';
import { ToastService } from '../../ui/toast.service';
/** Address book: grid, add/edit form, write to a contact. */
@Component({
selector: 'app-contacts',
imports: [FormsModule, A11yModule, MatIconModule, JtGridComponent, JtGridCellDirective],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="glass-panel static p-4 md:p-6" aria-labelledby="contacts-title">
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
<div class="flex items-center gap-3">
<mat-icon class="text-pink-400">contacts</mat-icon>
<div>
<h1 id="contacts-title" class="text-xl font-orbitron font-bold neon-text-secondary">CONTACTS</h1>
<p class="text-xs text-slate-400 font-mono">{{ contacts().length }} contacts · suggested while you type an address</p>
</div>
</div>
<button type="button" class="cyber-btn px-4 py-2 rounded-lg text-xs font-bold" (click)="edit({ name: '', email: '' })">
<mat-icon>person_add</mat-icon> ADD CONTACT
</button>
</div>
<jt-grid [rows]="contacts()" [columns]="columns" [trackBy]="byEmail" label="Contacts"
[emptyMessage]="loading() ? 'Loading…' : 'No contacts yet'" [bulkActions]="bulkActions" (bulkAction)="onBulk($event)"
[rowActions]="rowActions" (rowAction)="onRow($event)" [rowSubject]="nameOf" [initialSort]="{ key: 'name', direction: 'asc' }">
<ng-template jtGridCell="name" let-row><span class="font-semibold text-slate-100">{{ row.name || '—' }}</span></ng-template>
<ng-template jtGridCell="email" let-row><span class="font-mono text-xs text-cyan-300">{{ row.email }}</span></ng-template>
<ng-template jtGridCell="phone" let-row><span class="font-mono text-xs">{{ row.phone || '' }}</span></ng-template>
<ng-template jtGridCell="company" let-row>{{ row.company || '' }}</ng-template>
</jt-grid>
</section>
@if (editing(); as c) {
<div class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md" (click)="editing.set(null)"
(keydown.escape)="editing.set(null)">
<form class="glass-panel static max-w-lg w-full p-6 border-2 !border-cyan-400 shadow-[0_0_50px_rgba(0,240,255,0.4)] space-y-4"
role="dialog" aria-modal="true" aria-labelledby="contact-form-title" cdkTrapFocus [cdkTrapFocusAutoCapture]="true"
(click)="$event.stopPropagation()" (ngSubmit)="save()">
<h2 id="contact-form-title" class="text-lg font-orbitron font-bold text-slate-100">{{ c.id ? 'EDIT CONTACT' : 'NEW CONTACT' }}</h2>
<div><label class="synth-label" for="c-name">Name</label>
<input id="c-name" name="name" class="synth-input" [(ngModel)]="c.name" maxlength="255"></div>
<div><label class="synth-label" for="c-email">Email *</label>
<input id="c-email" name="email" type="email" class="synth-input" [(ngModel)]="c.email" required maxlength="255"></div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div><label class="synth-label" for="c-phone">Phone</label>
<input id="c-phone" name="phone" class="synth-input" [(ngModel)]="c.phone" maxlength="50"></div>
<div><label class="synth-label" for="c-company">Company</label>
<input id="c-company" name="company" class="synth-input" [(ngModel)]="c.company" maxlength="255"></div>
</div>
@if (formError()) { <p class="text-sm text-pink-400" role="alert">{{ formError() }}</p> }
<div class="flex justify-end gap-3">
<button type="button" class="px-4 py-2 rounded-lg text-xs font-mono text-slate-400 hover:text-slate-200" (click)="editing.set(null)">CANCEL [ESC]</button>
<button type="submit" class="cyber-btn px-5 py-2 rounded-lg text-xs font-bold">SAVE</button>
</div>
</form>
</div>
}
`
})
export class ContactsPage {
private api = inject(ApiService);
private bulk = inject(JtGridBulkService);
private dialogs = inject(DialogService);
private toasts = inject(ToastService);
private router = inject(Router);
readonly contacts = signal<Contact[]>([]);
readonly loading = signal(true);
readonly editing = signal<Contact | null>(null);
readonly formError = signal('');
readonly columns: JtGridColumn<Contact>[] = [
{ key: 'name', label: 'Name', sort: c => (c.name || '').toLowerCase() },
{ key: 'email', label: 'Email', sort: c => c.email.toLowerCase() },
{ key: 'phone', label: 'Phone', sort: c => c.phone || '' },
{ key: 'company', label: 'Company', sort: c => (c.company || '').toLowerCase() }
];
readonly bulkActions: RowAction[] = [
{ value: 'write', label: 'Write to them', icon: 'edit' },
{ value: 'delete', label: 'Delete', icon: 'delete', danger: true }
];
readonly rowActions = (): RowAction[] => [
{ value: 'write', label: 'Write', icon: 'edit', inline: true },
{ value: 'edit', label: 'Edit', icon: 'manage_accounts', inline: true },
{ value: 'delete', label: 'Delete', icon: 'delete', danger: true }
];
readonly byEmail = (c: Contact) => c.id ?? c.email;
readonly nameOf = (c: Contact) => c.name || c.email;
constructor() { this.load(); }
load(): void {
this.api.contacts().subscribe({
next: list => { this.contacts.set(list); this.loading.set(false); },
error: e => { this.loading.set(false); this.toasts.error(errorText(e)); }
});
}
edit(c: Contact): void {
this.formError.set('');
this.editing.set({ ...c });
}
save(): void {
const c = this.editing();
if (!c) return;
if (!/^[^@\s]+@[^@\s]+$/.test((c.email || '').trim())) {
this.formError.set('Enter a valid email');
return;
}
const body = { name: (c.name || '').trim(), email: c.email.trim(), phone: (c.phone || '').trim(), company: (c.company || '').trim() };
(c.id ? this.api.updateContact(c.id, body) : this.api.addContact(body)).subscribe({
next: () => { this.editing.set(null); this.toasts.ok(c.id ? 'Contact saved' : 'Contact added'); this.load(); },
error: e => this.formError.set(errorText(e).includes('duplicate') ? 'This email is already in contacts' : errorText(e))
});
}
onRow(e: JtGridRowEvent<Contact>): void {
if (e.action === 'write') this.router.navigate(['/compose', { to: e.row.email }]);
if (e.action === 'edit') this.edit(e.row);
if (e.action === 'delete') {
this.dialogs.confirm(`Delete ${this.nameOf(e.row)}?`, 'The contact is removed from the address book.', 'Delete', true)
.subscribe(ok => ok !== null && this.api.deleteContact(e.row.id!).subscribe({
next: () => { this.toasts.ok('Contact deleted'); this.load(); },
error: err => this.toasts.error(errorText(err))
}));
}
}
onBulk(e: JtGridBulkEvent<Contact>): void {
if (e.action === 'write') {
this.router.navigate(['/compose', { to: e.rows.map(c => c.email).join(', ') }]);
return;
}
this.bulk.run({
rows: e.rows, label: 'Delete', danger: true, confirm: `Delete ${e.rows.length} contacts?`,
run: c => this.api.deleteContact(c.id!)
}).subscribe(() => this.load());
}
}