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