Почтовый кабинет: регистрация, папки, черновики, вложения, грид @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:
@@ -1,13 +1,15 @@
|
||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||
import { provideRouter, withHashLocation } from '@angular/router';
|
||||
import { provideRouter, withComponentInputBinding } from '@angular/router';
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
import { routes } from './app.routes';
|
||||
import { authInterceptor } from './interceptors/auth.interceptor';
|
||||
import { authInterceptor } from './core/auth';
|
||||
import { provideMailGrid } from './ui/grid';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideRouter(routes),
|
||||
provideHttpClient(withInterceptors([authInterceptor]))
|
||||
provideRouter(routes, withComponentInputBinding()),
|
||||
provideHttpClient(withInterceptors([authInterceptor])),
|
||||
provideMailGrid()
|
||||
]
|
||||
};
|
||||
|
||||
@@ -1,43 +1,23 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { authGuard, guestGuard } from './core/auth';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: 'login',
|
||||
loadComponent: () => import('./components/login/login.component'),
|
||||
},
|
||||
{
|
||||
path: 'register',
|
||||
loadComponent: () => import('./components/register/register.component'),
|
||||
},
|
||||
{ path: 'login', canActivate: [guestGuard], loadComponent: () => import('./pages/auth/login.page').then(m => m.LoginPage) },
|
||||
{ path: 'register', canActivate: [guestGuard], loadComponent: () => import('./pages/auth/register.page').then(m => m.RegisterPage) },
|
||||
{
|
||||
path: '',
|
||||
loadComponent: () => import('./components/layout/layout.component'),
|
||||
canActivate: [authGuard],
|
||||
loadComponent: () => import('./pages/shell/shell.component').then(m => m.ShellComponent),
|
||||
children: [
|
||||
{
|
||||
path: 'inbox',
|
||||
loadComponent: () => import('./components/inbox/inbox.component'),
|
||||
},
|
||||
{
|
||||
path: 'compose',
|
||||
loadComponent: () => import('./components/compose/compose.component'),
|
||||
},
|
||||
{
|
||||
path: 'message/:id',
|
||||
loadComponent: () => import('./components/message-detail/message-detail.component'),
|
||||
},
|
||||
{
|
||||
path: 'contacts',
|
||||
loadComponent: () => import('./components/contacts/contacts.component'),
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'inbox',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '**',
|
||||
redirectTo: 'login',
|
||||
{ path: '', pathMatch: 'full', loadComponent: () => import('./pages/inbox-redirect').then(m => m.InboxRedirect) },
|
||||
{ path: 'folder/:id', loadComponent: () => import('./pages/folder/folder.page').then(m => m.FolderPage) },
|
||||
{ path: 'message/:id', loadComponent: () => import('./pages/message/message.page').then(m => m.MessagePage) },
|
||||
{ path: 'compose', loadComponent: () => import('./pages/compose/compose.page').then(m => m.ComposePage) },
|
||||
{ path: 'contacts', loadComponent: () => import('./pages/contacts/contacts.page').then(m => m.ContactsPage) },
|
||||
{ path: 'blocked', loadComponent: () => import('./pages/blocked/blocked.page').then(m => m.BlockedPage) },
|
||||
{ path: 'settings', loadComponent: () => import('./pages/settings/settings.page').then(m => m.SettingsPage) }
|
||||
]
|
||||
},
|
||||
{ path: '**', redirectTo: '' }
|
||||
];
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { OverlaysComponent } from './ui/overlays.component';
|
||||
import { ThemeService } from './ui/theme.service';
|
||||
|
||||
@Component({
|
||||
imports: [RouterOutlet],
|
||||
imports: [RouterOutlet, OverlaysComponent],
|
||||
selector: 'app-root',
|
||||
template: `<router-outlet />`,
|
||||
styles: ``
|
||||
template: `<router-outlet /><app-overlays />`
|
||||
})
|
||||
export class App {}
|
||||
export class App {
|
||||
// applies the remembered palette before the first page renders
|
||||
private theme = inject(ThemeService);
|
||||
}
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MailService } from '../../services/mail.service';
|
||||
import { Router, ActivatedRoute } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-compose',
|
||||
standalone: true,
|
||||
imports: [RouterLink, FormsModule],
|
||||
providers: [MailService],
|
||||
template: `
|
||||
<div class="glass-panel p-6">
|
||||
<h2 class="text-lg font-orbitron font-bold text-cyan-500 mb-6 flex items-center gap-2">
|
||||
<i class="fa-solid fa-pen-to-square text-pink-500"></i> COMPOSE MESSAGE
|
||||
</h2>
|
||||
|
||||
@if (success) {
|
||||
<div class="mb-4 p-3 rounded-lg bg-cyan-500/20 border border-cyan-500/40 text-cyan-500 text-sm font-mono">
|
||||
Message sent successfully!
|
||||
</div>
|
||||
}
|
||||
|
||||
<form (ngSubmit)="onSend()" class="space-y-4">
|
||||
<div>
|
||||
<label for="to" class="block text-xs font-mono text-cyan-500 mb-1">TO</label>
|
||||
<input id="to" type="email" [(ngModel)]="to" name="to" required
|
||||
class="w-full bg-slate-950/80 border border-cyan-500/40 rounded-xl px-4 py-3 text-sm text-slate-200 font-mono focus:outline-none focus:ring-2 focus:ring-pink-500 focus:border-cyan-500 transition-all"
|
||||
placeholder="recipient@example.com">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="subject" class="block text-xs font-mono text-cyan-500 mb-1">SUBJECT</label>
|
||||
<input id="subject" type="text" [(ngModel)]="subject" name="subject" required
|
||||
class="w-full bg-slate-950/80 border border-cyan-500/40 rounded-xl px-4 py-3 text-sm text-slate-200 font-mono focus:outline-none focus:ring-2 focus:ring-pink-500 focus:border-cyan-500 transition-all"
|
||||
placeholder="Enter subject...">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<label for="body" class="text-xs font-mono text-cyan-500">BODY</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<span class="text-xs font-mono text-slate-400">HTML</span>
|
||||
<input type="checkbox" [(ngModel)]="isHtml" name="isHtml" class="sr-only">
|
||||
<span class="w-10 h-5 rounded-full bg-slate-900/50 border border-cyan-500/30 relative transition-all"
|
||||
[class.bg-cyan-500/30]="isHtml" [class.border-cyan-500]="isHtml">
|
||||
<span class="absolute top-0.5 left-0.5 w-3.5 h-3.5 rounded-full bg-cyan-500 transition-all"
|
||||
[class.translate-x-5]="isHtml"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<textarea id="body" [(ngModel)]="body" name="body" rows="10" required
|
||||
class="w-full bg-slate-950/80 border border-cyan-500/40 rounded-xl px-4 py-3 text-sm text-slate-200 font-mono focus:outline-none focus:ring-2 focus:ring-pink-500 focus:border-cyan-500 transition-all resize-none"
|
||||
[placeholder]="isHtml ? 'Write HTML or Markdown...' : 'Write your message...'"></textarea>
|
||||
@if (isHtml) {
|
||||
<p class="text-xs text-slate-500 font-mono mt-1"><i class="fa-solid fa-lightbulb mr-1"></i>Supports Markdown: **bold**, *italic*, - lists, code </p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button type="submit" [disabled]="sending"
|
||||
class="cyber-btn px-8 py-3 rounded-xl text-sm font-bold">
|
||||
<i class="fa-solid fa-paper-plane mr-1"></i> {{ sending ? 'SENDING...' : 'SEND' }}
|
||||
</button>
|
||||
<a routerLink="/inbox" class="px-6 py-3 rounded-xl text-xs font-mono text-slate-400 border border-slate-600 hover:text-slate-200 hover:border-slate-400 transition-all text-center">
|
||||
CANCEL
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`,
|
||||
styles: ``
|
||||
})
|
||||
export default class ComposeComponent {
|
||||
private mailService = inject(MailService);
|
||||
private router = inject(Router);
|
||||
private route = inject(ActivatedRoute);
|
||||
|
||||
to = '';
|
||||
subject = '';
|
||||
body = '';
|
||||
isHtml = false;
|
||||
sending = false;
|
||||
success = false;
|
||||
|
||||
constructor() {
|
||||
// Поддержка Reply и Forward через queryParams
|
||||
this.route.queryParams.subscribe(params => {
|
||||
if (params['replyTo']) {
|
||||
this.to = params['replyTo'];
|
||||
}
|
||||
if (params['subject']) {
|
||||
this.subject = params['subject'];
|
||||
}
|
||||
if (params['forwardSubject']) {
|
||||
this.subject = params['forwardSubject'];
|
||||
}
|
||||
if (params['forwardBody']) {
|
||||
this.body = '\n\n--- Forwarded message ---\n' + params['forwardBody'];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onSend(): void {
|
||||
this.sending = true;
|
||||
this.mailService.send({ to: this.to, subject: this.subject, body: this.body, html: this.isHtml }).subscribe({
|
||||
next: () => {
|
||||
this.success = true;
|
||||
this.sending = false;
|
||||
this.to = '';
|
||||
this.subject = '';
|
||||
this.body = '';
|
||||
this.isHtml = false;
|
||||
setTimeout(() => this.router.navigate(['/inbox']), 1500);
|
||||
},
|
||||
error: () => {
|
||||
this.sending = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
<div class="space-y-4">
|
||||
<h2 class="text-lg font-orbitron font-bold text-cyan-300 flex items-center gap-2">
|
||||
<i class="fa-solid fa-users text-pink-500"></i> CONTACTS
|
||||
<span class="text-sm font-mono text-slate-400 ml-auto">{{ contacts().length }} contacts</span>
|
||||
</h2>
|
||||
|
||||
<!-- Форма добавления -->
|
||||
<div class="glass-panel p-5">
|
||||
<h3 class="text-sm font-orbitron font-bold text-yellow-400 mb-3 flex items-center gap-2">
|
||||
<i class="fa-solid fa-pen-to-square text-pink-500"></i> {{ editingContact() ? 'EDIT CONTACT' : 'ADD NEW CONTACT' }}
|
||||
</h3>
|
||||
<form (ngSubmit)="saveContact()" class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-mono text-pink-400 mb-1">NAME</label>
|
||||
<input type="text" [(ngModel)]="formName" name="name"
|
||||
class="w-full bg-slate-950/80 border border-cyan-500/40 rounded-xl px-4 py-2.5 text-sm font-mono text-slate-200 focus:outline-none focus:ring-2 focus:ring-pink-500 focus:border-cyan-400 transition-all"
|
||||
placeholder="John Doe">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-mono text-pink-400 mb-1">EMAIL *</label>
|
||||
<input type="email" [(ngModel)]="formEmail" name="email" required
|
||||
class="w-full bg-slate-950/80 border border-cyan-500/40 rounded-xl px-4 py-2.5 text-sm font-mono text-slate-200 focus:outline-none focus:ring-2 focus:ring-pink-500 focus:border-cyan-400 transition-all"
|
||||
placeholder="john@example.com">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-mono text-pink-400 mb-1">PHONE</label>
|
||||
<input type="text" [(ngModel)]="formPhone" name="phone"
|
||||
class="w-full bg-slate-950/80 border border-cyan-500/40 rounded-xl px-4 py-2.5 text-sm font-mono text-slate-200 focus:outline-none focus:ring-2 focus:ring-pink-500 focus:border-cyan-400 transition-all"
|
||||
placeholder="+1 234 567 8900">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-mono text-pink-400 mb-1">COMPANY</label>
|
||||
<input type="text" [(ngModel)]="formCompany" name="company"
|
||||
class="w-full bg-slate-950/80 border border-cyan-500/40 rounded-xl px-4 py-2.5 text-sm font-mono text-slate-200 focus:outline-none focus:ring-2 focus:ring-pink-500 focus:border-cyan-400 transition-all"
|
||||
placeholder="Acme Corp">
|
||||
</div>
|
||||
<div class="md:col-span-2 flex gap-2">
|
||||
<button type="submit"
|
||||
class="cyber-btn px-6 py-2 rounded-xl text-xs font-bold">
|
||||
@if (editingContact()) {
|
||||
<i class="fa-solid fa-save mr-1"></i> UPDATE
|
||||
} @else {
|
||||
<i class="fa-solid fa-plus mr-1"></i> ADD
|
||||
}
|
||||
</button>
|
||||
@if (editingContact()) {
|
||||
<button type="button" (click)="cancelEdit()"
|
||||
class="px-4 py-2 rounded-xl text-xs font-mono text-slate-400 border border-slate-600/40 hover:text-slate-200 hover:border-slate-400 transition-all">
|
||||
<i class="fa-solid fa-xmark mr-1"></i> CANCEL
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Список контактов -->
|
||||
<div class="glass-panel p-5">
|
||||
@if (contacts().length === 0) {
|
||||
<div class="text-center py-12 text-slate-400 font-mono">
|
||||
<i class="fa-solid fa-address-card text-4xl mb-4 text-slate-600"></i>
|
||||
<p>No contacts yet</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="space-y-2">
|
||||
@for (contact of contacts(); track contact.id) {
|
||||
<div class="flex items-center gap-3 p-3 rounded-xl border border-cyan-500/10 hover:border-cyan-500/40 hover:bg-cyan-500/5 transition-all">
|
||||
<div class="w-10 h-10 rounded-full bg-purple-500/30 flex items-center justify-center text-cyan-400 font-bold text-lg">
|
||||
{{ (contact.name || contact.email).charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-mono text-slate-200 truncate">{{ contact.name || '(No name)' }}</div>
|
||||
<div class="text-xs text-slate-400 font-mono truncate">{{ contact.email }}</div>
|
||||
@if (contact.phone || contact.company) {
|
||||
<div class="text-xs text-slate-500 font-mono">
|
||||
@if (contact.company) {
|
||||
{{ contact.company }}
|
||||
}
|
||||
@if (contact.company && contact.phone) {
|
||||
•
|
||||
}
|
||||
@if (contact.phone) {
|
||||
{{ contact.phone }}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<button (click)="editContact(contact)"
|
||||
class="px-2 py-1 text-xs font-mono text-cyan-400 hover:bg-cyan-500/10 rounded-lg transition-all">
|
||||
<i class="fa-solid fa-pen"></i>
|
||||
</button>
|
||||
<button (click)="deleteContact(contact.id!)"
|
||||
class="px-2 py-1 text-xs font-mono text-pink-400 hover:bg-pink-500/10 rounded-lg transition-all">
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,94 +0,0 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MailService } from '../../services/mail.service';
|
||||
import { Contact } from '../../models/mail.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-contacts',
|
||||
standalone: true,
|
||||
imports: [FormsModule],
|
||||
providers: [MailService],
|
||||
templateUrl: './contacts.component.html',
|
||||
styles: ``
|
||||
})
|
||||
export default class ContactsComponent {
|
||||
private mailService = inject(MailService);
|
||||
|
||||
contacts = signal<Contact[]>([]);
|
||||
editingContact = signal<Contact | null>(null);
|
||||
|
||||
formName = '';
|
||||
formEmail = '';
|
||||
formPhone = '';
|
||||
formCompany = '';
|
||||
|
||||
constructor() {
|
||||
this.loadContacts();
|
||||
}
|
||||
|
||||
loadContacts(): void {
|
||||
this.mailService.getContacts().subscribe({
|
||||
next: (contacts) => this.contacts.set(contacts),
|
||||
error: (e) => console.error('Failed to load contacts', e)
|
||||
});
|
||||
}
|
||||
|
||||
saveContact(): void {
|
||||
if (!this.formEmail.trim()) return;
|
||||
|
||||
const contact: Contact = {
|
||||
name: this.formName.trim(),
|
||||
email: this.formEmail.trim(),
|
||||
phone: this.formPhone.trim() || undefined,
|
||||
company: this.formCompany.trim() || undefined
|
||||
};
|
||||
|
||||
if (this.editingContact()?.id) {
|
||||
this.mailService.updateContact(this.editingContact()!.id!, contact).subscribe({
|
||||
next: () => {
|
||||
this.resetForm();
|
||||
this.loadContacts();
|
||||
},
|
||||
error: (e) => console.error('Failed to update contact', e)
|
||||
});
|
||||
} else {
|
||||
this.mailService.addContact(contact).subscribe({
|
||||
next: () => {
|
||||
this.resetForm();
|
||||
this.loadContacts();
|
||||
},
|
||||
error: (e) => console.error('Failed to add contact', e)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
editContact(contact: Contact): void {
|
||||
this.editingContact.set(contact);
|
||||
this.formName = contact.name || '';
|
||||
this.formEmail = contact.email || '';
|
||||
this.formPhone = contact.phone || '';
|
||||
this.formCompany = contact.company || '';
|
||||
}
|
||||
|
||||
cancelEdit(): void {
|
||||
this.editingContact.set(null);
|
||||
this.resetForm();
|
||||
}
|
||||
|
||||
deleteContact(id: number): void {
|
||||
if (confirm('Delete this contact?')) {
|
||||
this.mailService.deleteContact(id).subscribe({
|
||||
next: () => this.loadContacts(),
|
||||
error: (e) => console.error('Failed to delete contact', e)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
resetForm(): void {
|
||||
this.formName = '';
|
||||
this.formEmail = '';
|
||||
this.formPhone = '';
|
||||
this.formCompany = '';
|
||||
this.editingContact.set(null);
|
||||
}
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { RouterLink, ActivatedRoute } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MailService } from '../../services/mail.service';
|
||||
import { Message, Folder } from '../../models/mail.models';
|
||||
import { DatePipe, SlicePipe } from '@angular/common';
|
||||
import { map, switchMap } from 'rxjs/operators';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
selector: 'app-inbox',
|
||||
standalone: true,
|
||||
imports: [RouterLink, FormsModule, DatePipe, SlicePipe],
|
||||
providers: [MailService],
|
||||
template: `
|
||||
<div class="space-y-4">
|
||||
<!-- Поиск -->
|
||||
<div class="glass-panel p-4">
|
||||
<div class="flex gap-3">
|
||||
<input [(ngModel)]="searchQuery" (keyup.enter)="loadMessages()"
|
||||
class="flex-1 bg-slate-950/80 border border-cyan-500/40 rounded-xl px-4 py-2 text-sm text-slate-200 font-mono focus:outline-none focus:ring-2 focus:ring-pink-500 focus:border-cyan-400 transition-all"
|
||||
placeholder="Search messages...">
|
||||
<button (click)="loadMessages()" class="cyber-btn px-6 py-2 rounded-xl text-xs font-bold">
|
||||
<i class="fa-solid fa-magnifying-glass mr-1"></i> SEARCH
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Список писем -->
|
||||
<div class="glass-panel p-4">
|
||||
<h2 class="text-lg font-orbitron font-bold text-cyan-300 mb-4 flex items-center gap-2">
|
||||
<i class="fa-solid fa-inbox text-pink-500"></i> {{ folderName() }}
|
||||
<span class="text-sm font-mono text-slate-400 ml-auto">{{ messages()?.length || 0 }} messages</span>
|
||||
</h2>
|
||||
|
||||
@if (!messages() || messages().length === 0) {
|
||||
<div class="text-center py-12 text-slate-400 font-mono">
|
||||
<i class="fa-solid fa-envelope-open-text text-4xl mb-4 text-slate-600"></i>
|
||||
<p>No messages yet</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="space-y-2">
|
||||
@for (msg of messages() || []; track msg.id) {
|
||||
<a routerLink="/message/{{ msg.id }}"
|
||||
class="block p-4 rounded-xl border border-cyan-500/10 hover:border-cyan-500/40 hover:bg-cyan-500/5 transition-all cursor-pointer group">
|
||||
<div class="flex items-start gap-3">
|
||||
<!-- Индикатор прочитанности -->
|
||||
<div class="mt-1">
|
||||
@if (!msg.read) {
|
||||
<span class="w-3 h-3 rounded-full bg-pink-500 inline-block shadow-[0_0_8px_rgba(255,0,127,0.8)]"></span>
|
||||
} @else {
|
||||
<span class="w-3 h-3 rounded-full border border-slate-500 inline-block"></span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="font-bold text-sm text-slate-200 font-rajdhani truncate">
|
||||
{{ msg.senderName || msg.sender }}
|
||||
</span>
|
||||
@if (msg.spam) {
|
||||
<span class="px-2 py-0.5 text-xs rounded-full bg-yellow-500/20 text-yellow-400 font-mono">
|
||||
SPAM
|
||||
</span>
|
||||
}
|
||||
@if (msg.flagged) {
|
||||
<i class="fa-solid fa-star text-yellow-400 text-xs"></i>
|
||||
}
|
||||
</div>
|
||||
<div class="text-sm text-cyan-400 font-rajdhani truncate group-hover:text-pink-400 transition-colors">
|
||||
{{ msg.subject || '(No subject)' }}
|
||||
</div>
|
||||
<div class="text-xs text-slate-400 font-mono truncate">
|
||||
{{ msg.body || '' | slice:0:100 }}{{ (msg.body || '').length > 100 ? '...' : '' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-right flex-shrink-0">
|
||||
<div class="text-xs text-slate-400 font-mono">
|
||||
{{ msg.sentAt | date:'MMM d, HH:mm' }}
|
||||
</div>
|
||||
@if (msg.hasAttachments) {
|
||||
<span class="text-xs text-purple-400 font-mono"><i class="fa-solid fa-paperclip mr-1"></i>{{ msg.attachmentCount }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Пагинация -->
|
||||
@if (totalPages() > 1) {
|
||||
<div class="glass-panel p-4 flex items-center justify-center gap-2">
|
||||
<button (click)="goToPage(page() - 1)" [disabled]="page() === 0"
|
||||
class="px-3 py-1 rounded-lg text-xs font-mono text-cyan-400 border border-cyan-500/30 hover:bg-cyan-500/10 disabled:opacity-30 disabled:cursor-not-allowed transition-all">
|
||||
<i class="fa-solid fa-chevron-left mr-1"></i> Prev
|
||||
</button>
|
||||
@for (p of pageNumbers; track p) {
|
||||
<button (click)="goToPage(p)"
|
||||
[class.bg-pink-500/20]="p === page()" [class.text-pink-400]="p === page()"
|
||||
[class.border-pink-500]="p === page()"
|
||||
class="px-3 py-1 rounded-lg text-xs font-mono text-slate-400 border border-cyan-500/20 hover:bg-cyan-500/10 hover:text-cyan-300 transition-all">
|
||||
{{ p + 1 }}
|
||||
</button>
|
||||
}
|
||||
<button (click)="goToPage(page() + 1)" [disabled]="page() >= totalPages() - 1"
|
||||
class="px-3 py-1 rounded-lg text-xs font-mono text-cyan-400 border border-cyan-500/30 hover:bg-cyan-500/10 disabled:opacity-30 disabled:cursor-not-allowed transition-all">
|
||||
Next <i class="fa-solid fa-chevron-right ml-1"></i>
|
||||
</button>
|
||||
<span class="text-xs font-mono text-slate-500 ml-2">
|
||||
Page {{ page() + 1 }} of {{ totalPages() }}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: ``
|
||||
})
|
||||
export default class InboxComponent {
|
||||
private route = inject(ActivatedRoute);
|
||||
private mailService = inject(MailService);
|
||||
messages = signal<Message[]>([]);
|
||||
folderName = signal<string>('INBOX');
|
||||
searchQuery = '';
|
||||
folderId = signal<number>(1);
|
||||
page = signal<number>(0);
|
||||
totalPages = signal<number>(1);
|
||||
pageSize = 20;
|
||||
|
||||
constructor() {
|
||||
// Подписываемся на изменения folderId в queryParams
|
||||
this.route.queryParams.subscribe(params => {
|
||||
const newFolderId = params['folderId'] ? Number(params['folderId']) : 1;
|
||||
if (newFolderId !== this.folderId()) {
|
||||
this.folderId.set(newFolderId);
|
||||
this.loadFolderName(newFolderId);
|
||||
this.loadMessages();
|
||||
}
|
||||
});
|
||||
// Начальная загрузка
|
||||
const initialFolderId = this.route.snapshot.queryParams['folderId'] ? Number(this.route.snapshot.queryParams['folderId']) : 1;
|
||||
this.folderId.set(initialFolderId);
|
||||
this.loadFolderName(initialFolderId);
|
||||
this.loadMessages();
|
||||
}
|
||||
|
||||
loadFolderName(folderId: number): void {
|
||||
this.mailService.getFolders().subscribe({
|
||||
next: (folders) => {
|
||||
const folder = folders.find(f => f.id === folderId);
|
||||
this.folderName.set(folder ? folder.name : 'INBOX');
|
||||
},
|
||||
error: () => this.folderName.set('INBOX')
|
||||
});
|
||||
}
|
||||
|
||||
loadMessages(): void {
|
||||
const currentFolderId = this.folderId();
|
||||
const currentPage = this.page();
|
||||
if (this.searchQuery.trim()) {
|
||||
this.mailService.searchMessages(currentFolderId, this.searchQuery.trim()).subscribe({
|
||||
next: (msgs) => this.messages.set(msgs),
|
||||
error: () => console.error('Search failed')
|
||||
});
|
||||
} else {
|
||||
this.mailService.getMessages(currentFolderId, currentPage, this.pageSize).subscribe({
|
||||
next: (result) => {
|
||||
this.messages.set(result.content);
|
||||
this.totalPages.set(result.totalPages);
|
||||
},
|
||||
error: () => console.error('Failed to load messages')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
goToPage(page: number): void {
|
||||
if (page >= 0 && page < this.totalPages()) {
|
||||
this.page.set(page);
|
||||
this.loadMessages();
|
||||
}
|
||||
}
|
||||
|
||||
get pageNumbers(): number[] {
|
||||
const pages: number[] = [];
|
||||
const total = this.totalPages();
|
||||
const current = this.page();
|
||||
const start = Math.max(0, current - 2);
|
||||
const end = Math.min(total - 1, current + 2);
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
import { Component, inject, signal, effect } from '@angular/core';
|
||||
import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { MailService } from '../../services/mail.service';
|
||||
import { Folder } from '../../models/mail.models';
|
||||
|
||||
const THEMES = [
|
||||
{ value: 'theme-cyber-magenta', label: '💖 Cyber Magenta' },
|
||||
{ value: 'theme-neon-cyan', label: '🩵 Neon Cyan' },
|
||||
{ value: 'theme-sunset-orange', label: '🧡 Sunset Orange' },
|
||||
{ value: 'theme-matrix-green', label: '💚 Matrix Green' },
|
||||
];
|
||||
|
||||
@Component({
|
||||
selector: 'app-layout',
|
||||
standalone: true,
|
||||
imports: [RouterOutlet, RouterLink, RouterLinkActive],
|
||||
template: `
|
||||
<div #root class="min-h-screen relative theme-cyber-magenta" [class.light-theme]="isLightTheme()" [class.high-contrast]="isHighContrast()">
|
||||
<!-- Synthwave анимированный фон -->
|
||||
<div class="synth-stage" aria-hidden="true">
|
||||
<div class="synth-sun"></div>
|
||||
<div class="synth-mountains"></div>
|
||||
<div class="grid-container">
|
||||
<div class="grid-plane"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Контент -->
|
||||
<div class="relative z-10 flex flex-col min-h-screen">
|
||||
<!-- Шапка -->
|
||||
<header class="p-4 lg:p-6 border-b border-cyan-500/20 glass-panel rounded-none border-t-0 border-x-0 sticky top-0 z-40 backdrop-blur-xl">
|
||||
<div class="max-w-7xl mx-auto flex flex-col md:flex-row items-center justify-between gap-4">
|
||||
|
||||
<!-- Логотип -->
|
||||
<div class="flex items-center space-x-3">
|
||||
<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>
|
||||
<h1 class="text-2xl font-extrabold font-orbitron tracking-wider neon-text-primary">
|
||||
MAIL<span class="text-cyan-400 font-normal">SERVER</span>
|
||||
</h1>
|
||||
<p class="text-xs text-cyan-300/70 font-mono tracking-widest uppercase">Synthwave Edition</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Статистика -->
|
||||
<div class="flex items-center space-x-4 bg-slate-950/60 px-4 py-2 rounded-full border border-pink-500/30 text-xs font-mono">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="w-2 h-2 rounded-full bg-emerald-400 animate-ping"></span>
|
||||
<span class="text-slate-300">SMTP: <span class="text-emerald-400 font-bold">ONLINE</span></span>
|
||||
</div>
|
||||
<span class="text-slate-600">|</span>
|
||||
<div class="text-slate-300">User: <span class="text-cyan-400 font-bold">{{ email }}</span></div>
|
||||
<span class="text-slate-600">|</span>
|
||||
<div class="text-slate-300">Folders: <span class="text-pink-400 font-bold">{{ folders().length }}</span></div>
|
||||
</div>
|
||||
|
||||
<!-- Контроли -->
|
||||
<div class="flex items-center space-x-3">
|
||||
<!-- Выбор цветовой темы -->
|
||||
<div class="relative">
|
||||
<select #themeSelect (change)="onThemeChange(themeSelect.value)"
|
||||
class="bg-slate-900/90 text-cyan-300 border border-cyan-500/40 rounded-lg px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-pink-500 cursor-pointer">
|
||||
@for (theme of themes; track theme.value) {
|
||||
<option [value]="theme.value" [selected]="currentTheme() === theme.value">
|
||||
{{ theme.label }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- High Contrast -->
|
||||
<button (click)="toggleHighContrast()"
|
||||
[attr.aria-pressed]="isHighContrast()"
|
||||
title="Toggle High Contrast Mode"
|
||||
class="px-3 py-1.5 rounded-lg border border-cyan-500/40 bg-slate-900/80 text-xs font-mono hover:bg-cyan-500/20 focus:outline-none focus:ring-2 focus:ring-pink-500 transition-all flex items-center gap-1.5">
|
||||
<i class="fa-solid fa-circle-half-stroke text-yellow-400"></i>
|
||||
<span>Contrast</span>
|
||||
</button>
|
||||
|
||||
<!-- Light/Dark -->
|
||||
<button (click)="toggleTheme()" class="cyber-btn px-3 py-1.5 rounded-lg text-xs font-bold">
|
||||
{{ isLightTheme() ? '🌙 Dark' : '☀️ Light' }}
|
||||
</button>
|
||||
|
||||
<!-- Logout -->
|
||||
<button (click)="onLogout()" class="cyber-btn px-4 py-1.5 rounded-lg text-xs font-bold">
|
||||
<i class="fa-solid fa-power-off mr-1"></i> Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Основная область -->
|
||||
<div class="flex flex-1 max-w-7xl mx-auto w-full p-4 gap-4">
|
||||
<!-- Боковая панель -->
|
||||
<aside class="w-64 flex-shrink-0">
|
||||
<div class="glass-panel p-4">
|
||||
<h2 class="text-sm font-orbitron font-bold text-cyan-300 mb-3 flex items-center gap-2">
|
||||
<i class="fa-solid fa-folder-tree text-pink-500"></i> FOLDERS
|
||||
</h2>
|
||||
<nav class="space-y-1">
|
||||
@for (folder of folders(); track folder.id) {
|
||||
<a routerLink="/inbox" [queryParams]="{folderId: folder.id}"
|
||||
routerLinkActive="bg-pink-500/20 text-pink-400 border-pink-500"
|
||||
[queryParamsHandling]="'merge'"
|
||||
class="block px-3 py-2 rounded-lg text-sm font-rajdhani text-slate-300 hover:bg-cyan-500/10 hover:text-cyan-300 border border-transparent transition-all">
|
||||
{{ folder.name }}
|
||||
@if (folder.unreadCount > 0) {
|
||||
<span class="ml-2 px-2 py-0.5 text-xs rounded-full bg-pink-500/30 text-pink-400 font-mono">
|
||||
{{ folder.unreadCount }}
|
||||
</span>
|
||||
}
|
||||
</a>
|
||||
}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Контакты -->
|
||||
<div class="glass-panel p-4 mt-4">
|
||||
<a routerLink="/contacts"
|
||||
routerLinkActive="bg-cyan-500/20 text-cyan-300 border-cyan-500"
|
||||
class="block px-3 py-2 rounded-lg text-sm font-rajdhani text-slate-300 hover:bg-cyan-500/10 hover:text-cyan-300 border border-transparent transition-all">
|
||||
<i class="fa-solid fa-address-book mr-2"></i> Contacts
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Кнопка написать -->
|
||||
<div class="glass-panel p-4 mt-4">
|
||||
<a routerLink="/compose" class="cyber-btn w-full py-3 rounded-xl text-xs font-bold block text-center">
|
||||
<i class="fa-solid fa-pen-to-square mr-2"></i> NEW MESSAGE
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Контент маршрутов -->
|
||||
<main class="flex-1 min-w-0">
|
||||
<router-outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Футер -->
|
||||
<footer class="p-4 border-t border-cyan-500/20 glass-panel rounded-none border-b-0 border-x-0 mt-6 text-center text-xs font-mono text-slate-400">
|
||||
<div class="max-w-7xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-2">
|
||||
<div>MAILSERVER SYNTHWAVE © 2026</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="text-cyan-400"><i class="fa-solid fa-universal-access mr-1"></i> A11Y READY</span>
|
||||
<span class="text-pink-400"><i class="fa-solid fa-bolt mr-1"></i> HIGH PERF CSS3D</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: ``
|
||||
})
|
||||
export default class LayoutComponent {
|
||||
authService = inject(AuthService);
|
||||
mailService = inject(MailService);
|
||||
|
||||
email = this.authService.getEmail() || '';
|
||||
folders = signal<Folder[]>([]);
|
||||
isLightTheme = signal(false);
|
||||
isHighContrast = signal(false);
|
||||
currentTheme = signal('theme-cyber-magenta');
|
||||
themes = THEMES;
|
||||
|
||||
constructor() {
|
||||
this.loadFolders();
|
||||
// Загружаем сохранённые настройки
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
if (savedTheme === 'light') {
|
||||
this.isLightTheme.set(true);
|
||||
}
|
||||
const savedPalette = localStorage.getItem('palette');
|
||||
if (savedPalette) {
|
||||
this.currentTheme.set(savedPalette);
|
||||
}
|
||||
const savedContrast = localStorage.getItem('highContrast');
|
||||
if (savedContrast === 'true') {
|
||||
this.isHighContrast.set(true);
|
||||
}
|
||||
|
||||
// Применяем тему при изменении
|
||||
effect(() => {
|
||||
const root = document.documentElement;
|
||||
// Удаляем все theme классы
|
||||
THEMES.forEach(t => root.classList.remove(t.value));
|
||||
root.classList.add(this.currentTheme());
|
||||
});
|
||||
}
|
||||
|
||||
onThemeChange(theme: string): void {
|
||||
this.currentTheme.set(theme);
|
||||
localStorage.setItem('palette', theme);
|
||||
}
|
||||
|
||||
toggleTheme(): void {
|
||||
const isLight = !this.isLightTheme();
|
||||
this.isLightTheme.set(isLight);
|
||||
if (isLight) {
|
||||
document.documentElement.classList.add('light-theme');
|
||||
localStorage.setItem('theme', 'light');
|
||||
} else {
|
||||
document.documentElement.classList.remove('light-theme');
|
||||
localStorage.setItem('theme', 'dark');
|
||||
}
|
||||
}
|
||||
|
||||
toggleHighContrast(): void {
|
||||
const hc = !this.isHighContrast();
|
||||
this.isHighContrast.set(hc);
|
||||
if (hc) {
|
||||
document.documentElement.classList.add('high-contrast');
|
||||
localStorage.setItem('highContrast', 'true');
|
||||
} else {
|
||||
document.documentElement.classList.remove('high-contrast');
|
||||
localStorage.setItem('highContrast', 'false');
|
||||
}
|
||||
}
|
||||
|
||||
loadFolders(): void {
|
||||
this.mailService.getFolders().subscribe({
|
||||
next: (folders) => this.folders.set(folders),
|
||||
error: () => console.error('Failed to load folders')
|
||||
});
|
||||
}
|
||||
|
||||
onLogout(): void {
|
||||
this.authService.logout();
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
standalone: true,
|
||||
imports: [RouterLink, FormsModule],
|
||||
template: `
|
||||
<div class="min-h-screen relative flex items-center justify-center theme-cyber-magenta">
|
||||
<!-- Synthwave анимированный фон -->
|
||||
<div class="synth-stage" aria-hidden="true">
|
||||
<div class="synth-sun"></div>
|
||||
<div class="synth-mountains"></div>
|
||||
<div class="grid-container">
|
||||
<div class="grid-plane"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Форма логина -->
|
||||
<div class="relative z-10 w-full max-w-md px-4">
|
||||
<div class="glass-panel p-8 border-2 border-cyan-400 shadow-[0_0_50px_rgba(0,240,255,0.4)]">
|
||||
<div class="text-center mb-8">
|
||||
<div class="w-16 h-16 mx-auto rounded-xl bg-gradient-to-br from-pink-500 to-cyan-400 flex items-center justify-center text-slate-950 font-black text-2xl font-orbitron shadow-[0_0_20px_rgba(255,0,127,0.8)] mb-4">
|
||||
M
|
||||
</div>
|
||||
<h1 class="text-3xl font-extrabold font-orbitron tracking-wider neon-text-primary mb-2">
|
||||
MAIL<span class="text-cyan-400 font-normal">SERVER</span>
|
||||
</h1>
|
||||
<p class="text-sm text-cyan-300/70 font-mono tracking-widest uppercase">Synthwave Edition</p>
|
||||
</div>
|
||||
|
||||
@if (error) {
|
||||
<div class="mb-4 p-3 rounded-lg bg-pink-500/20 border border-pink-500/40 text-pink-400 text-sm font-mono">
|
||||
{{ error }}
|
||||
</div>
|
||||
}
|
||||
|
||||
<form (ngSubmit)="onLogin()" class="space-y-4">
|
||||
<div>
|
||||
<label for="email" class="block text-xs font-mono text-pink-400 mb-1">EMAIL</label>
|
||||
<input id="email" type="email" [(ngModel)]="email" name="email" required
|
||||
class="w-full bg-slate-950/80 border border-cyan-500/40 rounded-xl px-4 py-3 text-sm text-slate-200 font-mono focus:outline-none focus:ring-2 focus:ring-pink-500 focus:border-cyan-400 transition-all"
|
||||
placeholder="user@example.com">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-xs font-mono text-pink-400 mb-1">PASSWORD</label>
|
||||
<input id="password" type="password" [(ngModel)]="password" name="password" required
|
||||
class="w-full bg-slate-950/80 border border-cyan-500/40 rounded-xl px-4 py-3 text-sm text-slate-200 font-mono focus:outline-none focus:ring-2 focus:ring-pink-500 focus:border-cyan-400 transition-all"
|
||||
placeholder="••••••••">
|
||||
</div>
|
||||
|
||||
<button type="submit" [disabled]="loading"
|
||||
class="cyber-btn w-full py-3 rounded-xl text-sm font-bold mt-6">
|
||||
@if (loading) {
|
||||
<i class="fa-solid fa-spinner fa-spin mr-1"></i> CONNECTING...
|
||||
} @else {
|
||||
<i class="fa-solid fa-bolt mr-1"></i> LOGIN
|
||||
}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="text-center text-sm text-slate-400 mt-6 font-rajdhani">
|
||||
No account?
|
||||
<a routerLink="/register" class="text-cyan-400 hover:text-pink-400 font-mono transition-colors">
|
||||
Register here
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: ``
|
||||
})
|
||||
export default class LoginComponent {
|
||||
private authService = inject(AuthService);
|
||||
private router = inject(Router);
|
||||
|
||||
email = '';
|
||||
password = '';
|
||||
error = '' as string | null;
|
||||
loading = false as boolean;
|
||||
|
||||
onLogin(): void {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
this.authService.login({ email: this.email, password: this.password }).subscribe({
|
||||
next: () => this.router.navigate(['/inbox']),
|
||||
error: (err) => {
|
||||
this.error = 'Invalid email or password';
|
||||
this.loading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { MailService } from '../../services/mail.service';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { Message } from '../../models/mail.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-message-detail',
|
||||
standalone: true,
|
||||
imports: [RouterLink, DatePipe],
|
||||
template: `
|
||||
@if (message) {
|
||||
<div class="glass-panel p-6">
|
||||
<!-- Заголовок письма -->
|
||||
<div class="mb-6 pb-4 border-b border-cyan-500/20">
|
||||
<h1 class="text-xl font-orbitron font-bold neon-text-secondary mb-3">
|
||||
{{ message.subject || '(No subject)' }}
|
||||
</h1>
|
||||
|
||||
<div class="flex items-center gap-4 text-sm font-mono">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-cyan-500">FROM:</span>
|
||||
<span class="text-slate-200">{{ message.senderName || message.sender }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-cyan-500">DATE:</span>
|
||||
<span class="text-slate-200">{{ message.sentAt | date:'MMM d, y, HH:mm' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (message.spam) {
|
||||
<div class="mt-3 px-3 py-1.5 inline-block rounded-full bg-yellow-500/20 border border-yellow-500/40 text-yellow-500 text-xs font-mono">
|
||||
<i class="fa-solid fa-triangle-exclamation mr-1"></i> MARKED AS SPAM
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Тело письма -->
|
||||
<div class="prose prose-invert max-w-none mb-6">
|
||||
<p class="text-slate-300 font-rajdhani text-lg whitespace-pre-wrap leading-relaxed">
|
||||
{{ message.body }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Вложения -->
|
||||
@if (message.hasAttachments) {
|
||||
<div class="mb-6 p-4 rounded-xl bg-slate-950/60 border border-purple-500/30">
|
||||
<h3 class="text-sm font-orbitron font-bold text-purple-500 mb-3">
|
||||
<i class="fa-solid fa-paperclip mr-1"></i> ATTACHMENTS ({{ message.attachmentCount }})
|
||||
</h3>
|
||||
<p class="text-xs text-slate-400 font-mono">
|
||||
Attachments are available via the API
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Действия -->
|
||||
<div class="flex gap-3 pt-4 border-t border-cyan-500/20">
|
||||
<a routerLink="/inbox" class="cyber-btn px-6 py-2 rounded-xl text-xs font-bold">
|
||||
<i class="fa-solid fa-chevron-left mr-1"></i> BACK TO INBOX
|
||||
</a>
|
||||
<a [routerLink]="['/compose']" [queryParams]="{replyTo: message.sender, subject: 'Re: ' + (message.subject || '')}"
|
||||
class="px-6 py-2 rounded-xl text-xs font-mono text-cyan-500 border border-cyan-500/40 hover:bg-cyan-500/20 transition-all">
|
||||
<i class="fa-solid fa-reply mr-1"></i> REPLY
|
||||
</a>
|
||||
<a [routerLink]="['/compose']" [queryParams]="{forwardTo: '', forwardSubject: 'Fwd: ' + (message.subject || ''), forwardBody: message.body}"
|
||||
class="px-6 py-2 rounded-xl text-xs font-mono text-purple-500 border border-purple-500/40 hover:bg-purple-500/20 transition-all">
|
||||
<i class="fa-solid fa-share mr-1"></i> FORWARD
|
||||
</a>
|
||||
<button (click)="onDelete()" class="px-6 py-2 rounded-xl text-xs font-mono text-pink-500 border border-pink-500/40 hover:bg-pink-500/20 transition-all">
|
||||
<i class="fa-solid fa-trash mr-1"></i> DELETE
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="glass-panel p-6 text-center py-12">
|
||||
<i class="fa-solid fa-envelope-open-text text-4xl mb-4 text-slate-600"></i>
|
||||
<p class="text-slate-400 font-mono">Message not found</p>
|
||||
<a routerLink="/inbox" class="cyber-btn px-6 py-2 rounded-xl text-xs font-bold mt-4 inline-block">
|
||||
← BACK TO INBOX
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
styles: ``
|
||||
})
|
||||
export default class MessageDetailComponent {
|
||||
private mailService = inject(MailService);
|
||||
private route = inject(ActivatedRoute);
|
||||
|
||||
message: Message | null = null;
|
||||
messageId = 0;
|
||||
|
||||
constructor() {
|
||||
this.messageId = Number(this.route.snapshot.paramMap.get('id'));
|
||||
this.loadMessage();
|
||||
}
|
||||
|
||||
loadMessage(): void {
|
||||
this.mailService.getMessage(this.messageId).subscribe({
|
||||
next: (msg) => {
|
||||
this.message = msg;
|
||||
if (!msg.read) {
|
||||
this.mailService.markAsRead(this.messageId).subscribe();
|
||||
}
|
||||
},
|
||||
error: () => console.error('Failed to load message')
|
||||
});
|
||||
}
|
||||
|
||||
onDelete(): void {
|
||||
if (confirm('Delete this message?')) {
|
||||
this.mailService.deleteMessage(this.messageId).subscribe({
|
||||
next: () => (window.location.href = '/inbox'),
|
||||
error: () => console.error('Failed to delete message')
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-register',
|
||||
standalone: true,
|
||||
imports: [RouterLink, FormsModule],
|
||||
template: `
|
||||
<div class="min-h-screen relative flex items-center justify-center theme-cyber-magenta">
|
||||
<!-- Synthwave анимированный фон -->
|
||||
<div class="synth-stage" aria-hidden="true">
|
||||
<div class="synth-sun"></div>
|
||||
<div class="synth-mountains"></div>
|
||||
<div class="grid-container">
|
||||
<div class="grid-plane"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Форма регистрации -->
|
||||
<div class="relative z-10 w-full max-w-md px-4">
|
||||
<div class="glass-panel p-8 border-2 border-cyan-400 shadow-[0_0_50px_rgba(0,240,255,0.4)]">
|
||||
<div class="text-center mb-8">
|
||||
<div class="w-16 h-16 mx-auto rounded-xl bg-gradient-to-br from-pink-500 to-cyan-400 flex items-center justify-center text-slate-950 font-black text-2xl font-orbitron shadow-[0_0_20px_rgba(255,0,127,0.8)] mb-4">
|
||||
M
|
||||
</div>
|
||||
<h1 class="text-3xl font-extrabold font-orbitron tracking-wider neon-text-primary mb-2">
|
||||
MAIL<span class="text-cyan-400 font-normal">SERVER</span>
|
||||
</h1>
|
||||
<p class="text-sm text-cyan-300/70 font-mono tracking-widest uppercase">Create your mailbox</p>
|
||||
</div>
|
||||
|
||||
@if (error) {
|
||||
<div class="mb-4 p-3 rounded-lg bg-pink-500/20 border border-pink-500/40 text-pink-400 text-sm font-mono">
|
||||
{{ error }}
|
||||
</div>
|
||||
}
|
||||
@if (success) {
|
||||
<div class="mb-4 p-3 rounded-lg bg-cyan-500/20 border border-cyan-500/40 text-cyan-400 text-sm font-mono">
|
||||
Account created! Redirecting to login...
|
||||
</div>
|
||||
}
|
||||
|
||||
<form (ngSubmit)="onRegister()" class="space-y-4">
|
||||
<div>
|
||||
<label for="displayName" class="block text-xs font-mono text-pink-400 mb-1">DISPLAY NAME</label>
|
||||
<input id="displayName" type="text" [(ngModel)]="displayName" name="displayName" required
|
||||
class="w-full bg-slate-950/80 border border-cyan-500/40 rounded-xl px-4 py-3 text-sm text-slate-200 font-mono focus:outline-none focus:ring-2 focus:ring-pink-500 focus:border-cyan-400 transition-all"
|
||||
placeholder="John Doe">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="email" class="block text-xs font-mono text-pink-400 mb-1">EMAIL</label>
|
||||
<input id="email" type="email" [(ngModel)]="email" name="email" required
|
||||
class="w-full bg-slate-950/80 border border-cyan-500/40 rounded-xl px-4 py-3 text-sm text-slate-200 font-mono focus:outline-none focus:ring-2 focus:ring-pink-500 focus:border-cyan-400 transition-all"
|
||||
placeholder="user@example.com">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-xs font-mono text-pink-400 mb-1">PASSWORD</label>
|
||||
<input id="password" type="password" [(ngModel)]="password" name="password" required
|
||||
class="w-full bg-slate-950/80 border border-cyan-500/40 rounded-xl px-4 py-3 text-sm text-slate-200 font-mono focus:outline-none focus:ring-2 focus:ring-pink-500 focus:border-cyan-400 transition-all"
|
||||
placeholder="••••••••">
|
||||
</div>
|
||||
|
||||
<button type="submit" [disabled]="loading"
|
||||
class="cyber-btn w-full py-3 rounded-xl text-sm font-bold mt-6">
|
||||
@if (loading) {
|
||||
<i class="fa-solid fa-spinner fa-spin mr-1"></i> CREATING...
|
||||
} @else {
|
||||
<i class="fa-solid fa-bolt mr-1"></i> REGISTER
|
||||
}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="text-center text-sm text-slate-400 mt-6 font-rajdhani">
|
||||
Already have an account?
|
||||
<a routerLink="/login" class="text-cyan-400 hover:text-pink-400 font-mono transition-colors">
|
||||
Login here
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: ``
|
||||
})
|
||||
export default class RegisterComponent {
|
||||
private authService = inject(AuthService);
|
||||
private router = inject(Router);
|
||||
|
||||
displayName = '';
|
||||
email = '';
|
||||
password = '';
|
||||
error = '' as string | null;
|
||||
success = false as boolean;
|
||||
loading = false as boolean;
|
||||
|
||||
onRegister(): void {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
this.success = false;
|
||||
this.authService.register({
|
||||
email: this.email,
|
||||
password: this.password,
|
||||
displayName: this.displayName
|
||||
}).subscribe({
|
||||
next: () => {
|
||||
this.success = true;
|
||||
this.loading = false;
|
||||
setTimeout(() => this.router.navigate(['/login']), 1500);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error = 'Registration failed. Email may already be taken.';
|
||||
this.loading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Attachment, BlacklistEntry, Contact, Draft, Folder, Message, Profile, ServerConfig } from './models';
|
||||
|
||||
/** The mail server REST API (same origin, /api). */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ApiService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
config(): Observable<ServerConfig> { return this.http.get<ServerConfig>('/api/config'); }
|
||||
|
||||
// profile
|
||||
me(): Observable<Profile> { return this.http.get<Profile>('/api/me'); }
|
||||
updateMe(displayName: string): Observable<Profile> { return this.http.put<Profile>('/api/me', { displayName }); }
|
||||
changePassword(currentPassword: string, newPassword: string): Observable<unknown> {
|
||||
return this.http.put('/api/me/password', { currentPassword, newPassword });
|
||||
}
|
||||
|
||||
// folders
|
||||
folders(): Observable<Folder[]> { return this.http.get<Folder[]>('/api/folders'); }
|
||||
createFolder(name: string): Observable<Folder> { return this.http.post<Folder>('/api/folders', { name }); }
|
||||
renameFolder(id: number, name: string): Observable<unknown> { return this.http.put(`/api/folders/${id}`, { name }); }
|
||||
deleteFolder(id: number): Observable<unknown> { return this.http.delete(`/api/folders/${id}`); }
|
||||
emptyFolder(id: number): Observable<{ deleted: number }> {
|
||||
return this.http.delete<{ deleted: number }>(`/api/folders/${id}/messages`);
|
||||
}
|
||||
|
||||
// messages
|
||||
messages(folderId: number): Observable<Message[]> {
|
||||
return this.http.get<Message[]>('/api/messages', { params: { folderId } });
|
||||
}
|
||||
search(folderId: number, keyword: string): Observable<Message[]> {
|
||||
return this.http.get<Message[]>('/api/messages/search', { params: { folderId, keyword } });
|
||||
}
|
||||
message(id: number): Observable<Message> { return this.http.get<Message>(`/api/messages/${id}`); }
|
||||
updateMessage(id: number, change: { read?: boolean; flagged?: boolean; folderId?: number }): Observable<Message> {
|
||||
return this.http.patch<Message>(`/api/messages/${id}`, change);
|
||||
}
|
||||
deleteMessage(id: number): Observable<unknown> { return this.http.delete(`/api/messages/${id}`); }
|
||||
|
||||
// compose
|
||||
createDraft(draft: Draft): Observable<Message> { return this.http.post<Message>('/api/drafts', draft); }
|
||||
updateDraft(id: number, draft: Draft): Observable<Message> { return this.http.put<Message>(`/api/drafts/${id}`, draft); }
|
||||
send(draft: Draft, draftId: number | null): Observable<Message> {
|
||||
return this.http.post<Message>('/api/send', { ...draft, draftId });
|
||||
}
|
||||
|
||||
// attachments
|
||||
attachments(messageId: number): Observable<Attachment[]> {
|
||||
return this.http.get<Attachment[]>(`/api/messages/${messageId}/attachments`);
|
||||
}
|
||||
upload(messageId: number, file: File): Observable<Attachment> {
|
||||
const form = new FormData();
|
||||
form.append('file', file, file.name);
|
||||
return this.http.post<Attachment>(`/api/messages/${messageId}/attachments`, form);
|
||||
}
|
||||
deleteAttachment(id: number): Observable<unknown> { return this.http.delete(`/api/attachments/${id}`); }
|
||||
download(id: number): Observable<Blob> {
|
||||
return this.http.get(`/api/attachments/${id}/download`, { responseType: 'blob' });
|
||||
}
|
||||
|
||||
// contacts
|
||||
contacts(): Observable<Contact[]> { return this.http.get<Contact[]>('/api/contacts'); }
|
||||
addContact(contact: Contact): Observable<unknown> { return this.http.post('/api/contacts', contact); }
|
||||
updateContact(id: number, contact: Contact): Observable<unknown> { return this.http.put(`/api/contacts/${id}`, contact); }
|
||||
deleteContact(id: number): Observable<unknown> { return this.http.delete(`/api/contacts/${id}`); }
|
||||
|
||||
// blacklist
|
||||
blacklist(): Observable<BlacklistEntry[]> { return this.http.get<BlacklistEntry[]>('/api/spam/blacklist'); }
|
||||
block(senderEmail: string, reason: string): Observable<unknown> {
|
||||
return this.http.post('/api/spam/blacklist', { senderEmail, reason });
|
||||
}
|
||||
unblock(senderEmail: string): Observable<unknown> {
|
||||
return this.http.delete('/api/spam/blacklist', { params: { senderEmail } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Router } from '@angular/router';
|
||||
import { Observable, tap } from 'rxjs';
|
||||
|
||||
interface TokenResponse { token: string; email: string; }
|
||||
|
||||
/** JWT in localStorage; who is logged in. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuthService {
|
||||
private http = inject(HttpClient);
|
||||
private router = inject(Router);
|
||||
|
||||
readonly email = signal<string | null>(read('email'));
|
||||
readonly loggedIn = computed(() => this.email() !== null && read('token') !== null);
|
||||
|
||||
login(email: string, password: string): Observable<TokenResponse> {
|
||||
return this.http.post<TokenResponse>('/api/login', { email, password }).pipe(tap(r => this.store(r)));
|
||||
}
|
||||
|
||||
register(email: string, password: string, displayName: string): Observable<TokenResponse> {
|
||||
return this.http.post<TokenResponse>('/api/register', { email, password, displayName }).pipe(tap(r => this.store(r)));
|
||||
}
|
||||
|
||||
token(): string | null { return read('token'); }
|
||||
|
||||
logout(): void {
|
||||
this.clear();
|
||||
this.router.navigate(['/login']);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
try {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('email');
|
||||
} catch { /* storage blocked */ }
|
||||
this.email.set(null);
|
||||
}
|
||||
|
||||
private store(response: TokenResponse): void {
|
||||
try {
|
||||
localStorage.setItem('token', response.token);
|
||||
localStorage.setItem('email', response.email);
|
||||
} catch { /* storage blocked: logged in for this tab only */ }
|
||||
this.email.set(response.email);
|
||||
}
|
||||
}
|
||||
|
||||
function read(key: string): string | null {
|
||||
try { return localStorage.getItem(key); } catch { return null; }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
|
||||
import { inject } from '@angular/core';
|
||||
import { CanActivateFn, Router } from '@angular/router';
|
||||
import { catchError, throwError } from 'rxjs';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
const auth = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
const token = auth.token();
|
||||
const request = token ? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }) : req;
|
||||
return next(request).pipe(
|
||||
catchError((error: unknown) => {
|
||||
// not logged in or the token expired → to the login form
|
||||
if (error instanceof HttpErrorResponse && error.status === 401 && !req.url.endsWith('/login')) {
|
||||
auth.clear();
|
||||
router.navigate(['/login']);
|
||||
}
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
export const authGuard: CanActivateFn = () =>
|
||||
inject(AuthService).loggedIn() ? true : inject(Router).createUrlTree(['/login']);
|
||||
|
||||
export const guestGuard: CanActivateFn = () =>
|
||||
inject(AuthService).loggedIn() ? inject(Router).createUrlTree(['/']) : true;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import { ApiService } from './api.service';
|
||||
import { Folder, Profile } from './models';
|
||||
|
||||
/** Folders and the profile, shared by the sidebar, the lists and the header; refreshed after changes. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MailStateService {
|
||||
private api = inject(ApiService);
|
||||
|
||||
readonly folders = signal<Folder[]>([]);
|
||||
readonly profile = signal<Profile | null>(null);
|
||||
readonly inbox = computed(() => this.folders().find(f => f.system && f.name === 'INBOX') ?? null);
|
||||
readonly unreadTotal = computed(() => this.inbox()?.unreadCount ?? 0);
|
||||
|
||||
system(name: string): Folder | null {
|
||||
return this.folders().find(f => f.system && f.name === name) ?? null;
|
||||
}
|
||||
|
||||
folder(id: number): Folder | null {
|
||||
return this.folders().find(f => f.id === id) ?? null;
|
||||
}
|
||||
|
||||
refreshFolders(): void {
|
||||
this.api.folders().subscribe({ next: folders => this.folders.set(folders), error: () => {} });
|
||||
}
|
||||
|
||||
refreshProfile(): void {
|
||||
this.api.me().subscribe({ next: profile => this.profile.set(profile), error: () => {} });
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.folders.set([]);
|
||||
this.profile.set(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
export interface Folder {
|
||||
id: number;
|
||||
name: string;
|
||||
system: boolean;
|
||||
messageCount: number;
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: number;
|
||||
folderId: number;
|
||||
senderEmail: string;
|
||||
senderName?: string | null;
|
||||
recipientEmail: string;
|
||||
ccEmail?: string | null;
|
||||
subject: string;
|
||||
body: string;
|
||||
html: boolean;
|
||||
read: boolean;
|
||||
flagged: boolean;
|
||||
spam: boolean;
|
||||
messageSize?: number | null;
|
||||
receivedAt: string;
|
||||
hasAttachments: boolean;
|
||||
attachmentCount: number;
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
id: number;
|
||||
filename: string;
|
||||
contentType: string;
|
||||
sizeBytes: number;
|
||||
}
|
||||
|
||||
export interface Contact {
|
||||
id?: number;
|
||||
name: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
company?: string;
|
||||
}
|
||||
|
||||
export interface BlacklistEntry {
|
||||
id: number;
|
||||
senderEmail: string;
|
||||
reason?: string | null;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface Profile {
|
||||
email: string;
|
||||
displayName: string;
|
||||
usedBytes: number;
|
||||
quotaBytes: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ServerConfig {
|
||||
registrationEnabled: boolean;
|
||||
domains: string[];
|
||||
}
|
||||
|
||||
export interface Draft {
|
||||
to: string;
|
||||
cc: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
html: boolean;
|
||||
}
|
||||
|
||||
/** System folders: label and icon in the web client. */
|
||||
export const FOLDER_VIEW: Record<string, { label: string; icon: string }> = {
|
||||
INBOX: { label: 'Inbox', icon: 'inbox' },
|
||||
Drafts: { label: 'Drafts', icon: 'edit_note' },
|
||||
Sent: { label: 'Sent', icon: 'send' },
|
||||
Spam: { label: 'Spam', icon: 'report' },
|
||||
Trash: { label: 'Trash', icon: 'delete' }
|
||||
};
|
||||
|
||||
export function folderLabel(folder: Folder): string {
|
||||
return folder.system ? (FOLDER_VIEW[folder.name]?.label ?? folder.name) : folder.name;
|
||||
}
|
||||
|
||||
export function folderIcon(folder: Folder): string {
|
||||
return folder.system ? (FOLDER_VIEW[folder.name]?.icon ?? 'folder') : 'folder';
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number | null | undefined): string {
|
||||
const value = bytes ?? 0;
|
||||
if (value < 1024) return `${value} B`;
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
|
||||
if (value < 1024 * 1024 * 1024) return `${(value / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${(value / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
/** Text of an API error: our {error}, Spring's {message}, or the status. */
|
||||
export function errorText(error: unknown): string {
|
||||
const e = error as { error?: { error?: string; message?: string; status?: number } | string; status?: number; message?: string };
|
||||
if (e?.error && typeof e.error === 'object') {
|
||||
// Spring's error body: {status, error: "Bad Request", message: "No such mailbox: …"} — the reason is in message
|
||||
const body = e.error;
|
||||
const springBody = typeof body.status === 'number';
|
||||
return (springBody ? body.message || body.error : body.error || body.message) || `Error ${e.status ?? ''}`.trim();
|
||||
}
|
||||
if (typeof e?.error === 'string' && e.error) return e.error;
|
||||
if (e?.status === 0) return 'Server is not reachable';
|
||||
return e?.message || 'Something went wrong';
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { HttpErrorResponse, HttpInterceptorFn, HttpRequest, HttpHandlerFn } from '@angular/common/http';
|
||||
import { inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { catchError, throwError } from 'rxjs';
|
||||
|
||||
export const authInterceptor: HttpInterceptorFn = (req: HttpRequest<unknown>, next: HttpHandlerFn) => {
|
||||
const router = inject(Router);
|
||||
const token = localStorage.getItem('token');
|
||||
const request = token ? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }) : req;
|
||||
|
||||
return next(request).pipe(
|
||||
catchError((error: unknown) => {
|
||||
// not logged in or the token expired → to the login form
|
||||
if (error instanceof HttpErrorResponse && error.status === 401 && !req.url.endsWith('/login')) {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('email');
|
||||
router.navigate(['/login']);
|
||||
}
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
};
|
||||
@@ -1,73 +0,0 @@
|
||||
export interface Message {
|
||||
id: number;
|
||||
subject: string;
|
||||
body: string;
|
||||
sender: string;
|
||||
senderName?: string;
|
||||
recipient: string;
|
||||
sentAt: string;
|
||||
read: boolean;
|
||||
flagged: boolean;
|
||||
spam: boolean;
|
||||
folderId: number;
|
||||
folderName: string;
|
||||
hasAttachments: boolean;
|
||||
attachmentCount: number;
|
||||
}
|
||||
|
||||
export interface Folder {
|
||||
id: number;
|
||||
name: string;
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
id: number;
|
||||
filename: string;
|
||||
contentType: string;
|
||||
sizeBytes: number;
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export interface SendRequest {
|
||||
to: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
html?: boolean;
|
||||
}
|
||||
|
||||
export interface BlacklistEntry {
|
||||
senderEmail: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface Contact {
|
||||
id?: number;
|
||||
name: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
company?: string;
|
||||
}
|
||||
|
||||
export interface PageResponse<T> {
|
||||
content: T[];
|
||||
number: number;
|
||||
size: number;
|
||||
totalElements: number;
|
||||
totalPages: number;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
import { BackgroundComponent } from '../../ui/background.component';
|
||||
|
||||
/** Logo + a glass card over the synthwave background — the login and registration pages. */
|
||||
@Component({
|
||||
selector: 'app-auth-frame',
|
||||
imports: [BackgroundComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<app-background />
|
||||
<main class="relative z-10 min-h-screen flex items-center justify-center p-4">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="flex items-center justify-center gap-3 mb-6">
|
||||
<div class="w-12 h-12 rounded-lg bg-gradient-to-br from-pink-500 to-cyan-400 flex items-center justify-center
|
||||
text-slate-950 font-black text-2xl shadow-[0_0_15px_rgba(255,0,127,0.8)] font-orbitron">M</div>
|
||||
<div>
|
||||
<h1 class="text-3xl font-extrabold font-orbitron tracking-wider neon-text-primary">
|
||||
SYNTH <span class="text-cyan-400 font-normal">MAIL</span>
|
||||
</h1>
|
||||
<p class="text-xs text-cyan-300/70 font-mono tracking-widest uppercase">Reactive mail server</p>
|
||||
</div>
|
||||
</div>
|
||||
<section class="glass-panel static p-6 sm:p-8" [attr.aria-labelledby]="'auth-title'">
|
||||
<h2 id="auth-title" class="text-xl font-orbitron font-bold neon-text-secondary mb-1">{{ title() }}</h2>
|
||||
<p class="text-xs text-slate-400 font-mono mb-6">{{ subtitle() }}</p>
|
||||
<ng-content />
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
`
|
||||
})
|
||||
export class AuthFrameComponent {
|
||||
readonly title = input.required<string>();
|
||||
readonly subtitle = input('');
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { ApiService } from '../../core/api.service';
|
||||
import { AuthService } from '../../core/auth.service';
|
||||
import { errorText } from '../../core/models';
|
||||
import { AuthFrameComponent } from './auth-frame.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
imports: [FormsModule, RouterLink, MatIconModule, AuthFrameComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<app-auth-frame title="LOG IN" subtitle="Your mailbox address and password">
|
||||
<form (ngSubmit)="submit()" class="space-y-4">
|
||||
<div>
|
||||
<label class="synth-label" for="email">Email</label>
|
||||
<input id="email" name="email" type="email" class="synth-input" [(ngModel)]="email" required
|
||||
autocomplete="username" placeholder="you@domain">
|
||||
</div>
|
||||
<div>
|
||||
<label class="synth-label" for="password">Password</label>
|
||||
<div class="relative">
|
||||
<input id="password" name="password" [type]="show() ? 'text' : 'password'" class="synth-input pr-10"
|
||||
[(ngModel)]="password" required autocomplete="current-password">
|
||||
<button type="button" class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-cyan-300"
|
||||
[attr.aria-label]="show() ? 'Hide password' : 'Show password'" (click)="show.set(!show())">
|
||||
<mat-icon>{{ show() ? 'visibility_off' : 'visibility' }}</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@if (error()) {
|
||||
<p class="text-sm text-pink-400 font-rajdhani flex items-center gap-2" role="alert">
|
||||
<mat-icon class="!text-lg">error</mat-icon>{{ error() }}
|
||||
</p>
|
||||
}
|
||||
<button type="submit" class="cyber-btn w-full py-2.5 rounded-xl font-bold text-sm" [disabled]="busy()">
|
||||
<mat-icon>login</mat-icon> {{ busy() ? 'LOGGING IN…' : 'LOG IN' }}
|
||||
</button>
|
||||
</form>
|
||||
@if (registration()) {
|
||||
<p class="mt-6 text-center text-sm text-slate-400 font-rajdhani">
|
||||
No mailbox yet? <a routerLink="/register" class="text-cyan-300 hover:text-pink-400 font-semibold">Create one</a>
|
||||
</p>
|
||||
}
|
||||
</app-auth-frame>
|
||||
`
|
||||
})
|
||||
export class LoginPage {
|
||||
private auth = inject(AuthService);
|
||||
private router = inject(Router);
|
||||
|
||||
email = '';
|
||||
password = '';
|
||||
readonly show = signal(false);
|
||||
readonly busy = signal(false);
|
||||
readonly error = signal('');
|
||||
readonly registration = signal(false);
|
||||
|
||||
constructor() {
|
||||
inject(ApiService).config().subscribe({ next: c => this.registration.set(c.registrationEnabled), error: () => {} });
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (!this.email.trim() || !this.password) {
|
||||
this.error.set('Enter the email and the password');
|
||||
return;
|
||||
}
|
||||
this.busy.set(true);
|
||||
this.error.set('');
|
||||
this.auth.login(this.email.trim(), this.password).subscribe({
|
||||
next: () => this.router.navigate(['/']),
|
||||
error: e => {
|
||||
this.busy.set(false);
|
||||
this.error.set(errorText(e) === 'Invalid credentials' ? 'Wrong email or password' : errorText(e));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { ApiService } from '../../core/api.service';
|
||||
import { AuthService } from '../../core/auth.service';
|
||||
import { errorText } from '../../core/models';
|
||||
import { AuthFrameComponent } from './auth-frame.component';
|
||||
|
||||
const NAME = /^[a-z0-9]([a-z0-9._-]{0,62}[a-z0-9])?$/;
|
||||
|
||||
@Component({
|
||||
selector: 'app-register',
|
||||
imports: [FormsModule, RouterLink, MatIconModule, AuthFrameComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<app-auth-frame title="NEW MAILBOX" subtitle="Pick an address — you are logged in right away">
|
||||
@if (!loaded()) {
|
||||
<p class="text-slate-400 font-mono text-sm">Loading…</p>
|
||||
} @else if (!enabled()) {
|
||||
<p class="text-slate-300 font-rajdhani mb-4">Registration is closed on this server. Ask the administrator for a mailbox.</p>
|
||||
<a routerLink="/login" class="cyber-btn w-full py-2.5 rounded-xl font-bold text-sm"><mat-icon>arrow_back</mat-icon> BACK TO LOGIN</a>
|
||||
} @else {
|
||||
<form (ngSubmit)="submit()" class="space-y-4">
|
||||
<div>
|
||||
<label class="synth-label" for="local">Address</label>
|
||||
<div class="flex items-stretch gap-2">
|
||||
<input id="local" name="local" class="synth-input" [ngModel]="local()" (ngModelChange)="local.set($event.toLowerCase())"
|
||||
required autocomplete="username" placeholder="name" aria-describedby="local-hint">
|
||||
<span class="flex items-center text-cyan-300 font-mono">@</span>
|
||||
@if (domains().length > 1) {
|
||||
<select name="domain" class="synth-input !w-auto" [ngModel]="domain()" (ngModelChange)="domain.set($event)"
|
||||
aria-label="Domain">
|
||||
@for (d of domains(); track d) { <option [value]="d">{{ d }}</option> }
|
||||
</select>
|
||||
} @else {
|
||||
<span class="flex items-center px-3 rounded-[10px] bg-slate-900/70 border border-cyan-500/30 font-mono text-sm text-slate-200">
|
||||
{{ domain() }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<p id="local-hint" class="mt-1 text-[11px] font-mono" [class]="localValid() || !local() ? 'text-slate-500' : 'text-pink-400'">
|
||||
Letters, digits, dot, dash, underscore
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="synth-label" for="name">Your name (shown to recipients)</label>
|
||||
<input id="name" name="name" class="synth-input" [(ngModel)]="displayName" maxlength="100" autocomplete="name">
|
||||
</div>
|
||||
<div>
|
||||
<label class="synth-label" for="password">Password (8+ characters)</label>
|
||||
<input id="password" name="password" type="password" class="synth-input" [ngModel]="password()"
|
||||
(ngModelChange)="password.set($event)" required minlength="8" autocomplete="new-password">
|
||||
</div>
|
||||
<div>
|
||||
<label class="synth-label" for="password2">Password again</label>
|
||||
<input id="password2" name="password2" type="password" class="synth-input" [ngModel]="password2()"
|
||||
(ngModelChange)="password2.set($event)" required autocomplete="new-password">
|
||||
</div>
|
||||
@if (error()) {
|
||||
<p class="text-sm text-pink-400 font-rajdhani flex items-center gap-2" role="alert">
|
||||
<mat-icon class="!text-lg">error</mat-icon>{{ error() }}
|
||||
</p>
|
||||
}
|
||||
<button type="submit" class="cyber-btn w-full py-2.5 rounded-xl font-bold text-sm" [disabled]="busy()">
|
||||
<mat-icon>person_add</mat-icon> {{ busy() ? 'CREATING…' : 'CREATE MAILBOX' }}
|
||||
</button>
|
||||
</form>
|
||||
<p class="mt-6 text-center text-sm text-slate-400 font-rajdhani">
|
||||
Have a mailbox? <a routerLink="/login" class="text-cyan-300 hover:text-pink-400 font-semibold">Log in</a>
|
||||
</p>
|
||||
}
|
||||
</app-auth-frame>
|
||||
`
|
||||
})
|
||||
export class RegisterPage {
|
||||
private auth = inject(AuthService);
|
||||
private router = inject(Router);
|
||||
|
||||
readonly loaded = signal(false);
|
||||
readonly enabled = signal(false);
|
||||
readonly domains = signal<string[]>([]);
|
||||
readonly domain = signal('');
|
||||
readonly local = signal('');
|
||||
readonly password = signal('');
|
||||
readonly password2 = signal('');
|
||||
displayName = '';
|
||||
readonly busy = signal(false);
|
||||
readonly error = signal('');
|
||||
readonly localValid = computed(() => NAME.test(this.local()));
|
||||
|
||||
constructor() {
|
||||
inject(ApiService).config().subscribe({
|
||||
next: c => {
|
||||
this.enabled.set(c.registrationEnabled);
|
||||
this.domains.set(c.domains);
|
||||
this.domain.set(c.domains[0] ?? '');
|
||||
this.loaded.set(true);
|
||||
},
|
||||
error: e => { this.loaded.set(true); this.error.set(errorText(e)); }
|
||||
});
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
const problem = !this.localValid() ? 'The address: letters, digits, dot, dash, underscore'
|
||||
: this.password().length < 8 ? 'The password is shorter than 8 characters'
|
||||
: this.password() !== this.password2() ? 'The passwords do not match' : '';
|
||||
if (problem) {
|
||||
this.error.set(problem);
|
||||
return;
|
||||
}
|
||||
this.busy.set(true);
|
||||
this.error.set('');
|
||||
this.auth.register(`${this.local()}@${this.domain()}`, this.password(), this.displayName.trim()).subscribe({
|
||||
next: () => this.router.navigate(['/']),
|
||||
error: e => { this.busy.set(false); this.error.set(errorText(e)); }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
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 { BlacklistEntry, errorText } from '../../core/models';
|
||||
import { ToastService } from '../../ui/toast.service';
|
||||
|
||||
/** Blocked senders: their mail goes straight to Spam. */
|
||||
@Component({
|
||||
selector: 'app-blocked',
|
||||
imports: [DatePipe, FormsModule, MatIconModule, JtGridComponent, JtGridCellDirective],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="glass-panel static p-4 md:p-6" aria-labelledby="blocked-title">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<mat-icon class="text-pink-400">block</mat-icon>
|
||||
<div>
|
||||
<h1 id="blocked-title" class="text-xl font-orbitron font-bold neon-text-secondary">BLOCKED SENDERS</h1>
|
||||
<p class="text-xs text-slate-400 font-mono">Messages from these addresses go straight to Spam</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="flex flex-wrap items-end gap-3 mb-5 p-4 rounded-xl bg-slate-950/50 border border-pink-500/20" (ngSubmit)="add()">
|
||||
<div class="flex-grow min-w-[220px]">
|
||||
<label class="synth-label" for="b-email">Sender address</label>
|
||||
<input id="b-email" name="email" type="email" class="synth-input" [(ngModel)]="email" placeholder="spammer@example.com">
|
||||
</div>
|
||||
<div class="flex-grow min-w-[180px]">
|
||||
<label class="synth-label" for="b-reason">Reason (optional)</label>
|
||||
<input id="b-reason" name="reason" class="synth-input" [(ngModel)]="reason" maxlength="255">
|
||||
</div>
|
||||
<button type="submit" class="cyber-btn px-4 py-2.5 rounded-lg text-xs font-bold"><mat-icon>block</mat-icon> BLOCK</button>
|
||||
</form>
|
||||
|
||||
<jt-grid [rows]="entries()" [columns]="columns" [trackBy]="bySender" label="Blocked senders"
|
||||
[emptyMessage]="loading() ? 'Loading…' : 'Nobody is blocked'" [bulkActions]="bulkActions" (bulkAction)="onBulk($event)"
|
||||
[rowActions]="rowActions" (rowAction)="onRow($event)" [rowSubject]="senderOf" [initialSort]="{ key: 'date', direction: 'desc' }">
|
||||
<ng-template jtGridCell="sender" let-row><span class="font-mono text-sm text-cyan-300">{{ row.senderEmail }}</span></ng-template>
|
||||
<ng-template jtGridCell="reason" let-row><span class="text-slate-300">{{ row.reason || '' }}</span></ng-template>
|
||||
<ng-template jtGridCell="date" let-row><span class="font-mono text-xs text-slate-400">{{ row.createdAt | date: 'd MMM y, HH:mm' }}</span></ng-template>
|
||||
</jt-grid>
|
||||
</section>
|
||||
`
|
||||
})
|
||||
export class BlockedPage {
|
||||
private api = inject(ApiService);
|
||||
private bulk = inject(JtGridBulkService);
|
||||
private toasts = inject(ToastService);
|
||||
|
||||
readonly entries = signal<BlacklistEntry[]>([]);
|
||||
readonly loading = signal(true);
|
||||
email = '';
|
||||
reason = '';
|
||||
|
||||
readonly columns: JtGridColumn<BlacklistEntry>[] = [
|
||||
{ key: 'sender', label: 'Sender', sort: e => e.senderEmail },
|
||||
{ key: 'reason', label: 'Reason', sort: e => e.reason || '' },
|
||||
{ key: 'date', label: 'Blocked', sort: e => e.createdAt || '', align: 'end' }
|
||||
];
|
||||
readonly bulkActions: RowAction[] = [{ value: 'unblock', label: 'Unblock', icon: 'lock_open' }];
|
||||
readonly rowActions = (): RowAction[] => [{ value: 'unblock', label: 'Unblock', icon: 'lock_open', inline: true }];
|
||||
readonly bySender = (e: BlacklistEntry) => e.senderEmail;
|
||||
readonly senderOf = (e: BlacklistEntry) => e.senderEmail;
|
||||
|
||||
constructor() { this.load(); }
|
||||
|
||||
load(): void {
|
||||
this.api.blacklist().subscribe({
|
||||
next: list => { this.entries.set(list); this.loading.set(false); },
|
||||
error: e => { this.loading.set(false); this.toasts.error(errorText(e)); }
|
||||
});
|
||||
}
|
||||
|
||||
add(): void {
|
||||
const email = this.email.trim().toLowerCase();
|
||||
if (!/^[^@\s]+@[^@\s]+$/.test(email)) {
|
||||
this.toasts.error('Enter a valid sender address');
|
||||
return;
|
||||
}
|
||||
this.api.block(email, this.reason.trim()).subscribe({
|
||||
next: () => { this.toasts.ok(`${email} blocked`); this.email = ''; this.reason = ''; this.load(); },
|
||||
error: e => this.toasts.error(errorText(e))
|
||||
});
|
||||
}
|
||||
|
||||
onRow(e: JtGridRowEvent<BlacklistEntry>): void {
|
||||
this.api.unblock(e.row.senderEmail).subscribe({
|
||||
next: () => { this.toasts.ok(`${e.row.senderEmail} unblocked`); this.load(); },
|
||||
error: err => this.toasts.error(errorText(err))
|
||||
});
|
||||
}
|
||||
|
||||
onBulk(e: JtGridBulkEvent<BlacklistEntry>): void {
|
||||
this.bulk.run({ rows: e.rows, label: 'Unblock', run: x => this.api.unblock(x.senderEmail) }).subscribe(() => this.load());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import { ChangeDetectionStrategy, Component, DestroyRef, computed, effect, inject, input, signal, untracked } from '@angular/core';
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { Observable, of, switchMap, tap } from 'rxjs';
|
||||
import { ApiService } from '../../core/api.service';
|
||||
import { MailStateService } from '../../core/mail-state.service';
|
||||
import { Attachment, Contact, Draft, Message, errorText, formatBytes } from '../../core/models';
|
||||
import { DialogService } from '../../ui/dialog.service';
|
||||
import { ToastService } from '../../ui/toast.service';
|
||||
|
||||
const MAX_FILE = 10 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* New message, reply, reply all, forward, or a draft being edited. A draft is saved on the server
|
||||
* (automatically every 15 s while there are changes, and before attaching a file — files belong to the draft).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-compose',
|
||||
imports: [FormsModule, NgTemplateOutlet, MatIconModule, MatTooltipModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="glass-panel static p-4 md:p-6" aria-labelledby="compose-title">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 mb-5">
|
||||
<h1 id="compose-title" class="text-xl font-orbitron font-bold neon-text-secondary">{{ title() }}</h1>
|
||||
<span class="text-xs font-mono" [class]="dirty() ? 'text-yellow-300' : 'text-slate-500'" aria-live="polite">
|
||||
{{ saving() ? 'Saving…' : dirty() ? 'Unsaved changes' : draftId() ? 'Draft saved' : '' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<form (ngSubmit)="send()" class="space-y-4">
|
||||
<div class="relative">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="synth-label" for="to">To</label>
|
||||
@if (!showCc()) {
|
||||
<button type="button" class="text-[11px] font-mono text-cyan-300 hover:text-pink-400 mb-1" (click)="showCc.set(true)">+ Cc</button>
|
||||
}
|
||||
</div>
|
||||
<input id="to" name="to" class="synth-input" [ngModel]="to()" (ngModelChange)="to.set($event); touch()"
|
||||
(focus)="focused.set('to')" (blur)="blurLater()" autocomplete="off" placeholder="name@domain, another@domain"
|
||||
role="combobox" aria-autocomplete="list" [attr.aria-expanded]="focused() === 'to' && suggestions().length > 0"
|
||||
aria-controls="suggest-to">
|
||||
@if (focused() === 'to') { <ng-container *ngTemplateOutlet="suggest; context: { field: 'to' }" /> }
|
||||
</div>
|
||||
@if (showCc()) {
|
||||
<div class="relative">
|
||||
<label class="synth-label" for="cc">Cc</label>
|
||||
<input id="cc" name="cc" class="synth-input" [ngModel]="cc()" (ngModelChange)="cc.set($event); touch()"
|
||||
(focus)="focused.set('cc')" (blur)="blurLater()" autocomplete="off"
|
||||
role="combobox" aria-autocomplete="list" [attr.aria-expanded]="focused() === 'cc' && suggestions().length > 0"
|
||||
aria-controls="suggest-cc">
|
||||
@if (focused() === 'cc') { <ng-container *ngTemplateOutlet="suggest; context: { field: 'cc' }" /> }
|
||||
</div>
|
||||
}
|
||||
<div>
|
||||
<label class="synth-label" for="subject">Subject</label>
|
||||
<input id="subject" name="subject" class="synth-input" [ngModel]="subject()" (ngModelChange)="subject.set($event); touch()"
|
||||
maxlength="1000" autocomplete="off">
|
||||
</div>
|
||||
<div>
|
||||
<label class="synth-label" for="body">Message</label>
|
||||
<textarea id="body" name="body" rows="14" class="synth-input font-rajdhani leading-relaxed resize-y"
|
||||
[ngModel]="body()" (ngModelChange)="body.set($event); touch()"></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<label class="ghost-btn cursor-pointer" [class.opacity-50]="uploading()">
|
||||
<mat-icon>attach_file</mat-icon> Attach files
|
||||
<input type="file" multiple class="sr-only" (change)="attach($event)" [disabled]="uploading()">
|
||||
</label>
|
||||
<span class="text-[11px] font-mono text-slate-500">up to 10 MB each</span>
|
||||
@if (uploading()) { <span class="text-xs font-mono text-cyan-300">Uploading…</span> }
|
||||
</div>
|
||||
@if (attachments().length) {
|
||||
<ul class="flex flex-wrap gap-2 mt-3" aria-label="Attached files">
|
||||
@for (a of attachments(); track a.id) {
|
||||
<li class="flex items-center gap-2 pl-3 pr-1 py-1 rounded-lg bg-slate-900/70 border border-cyan-500/30 text-sm">
|
||||
<mat-icon class="!text-base text-cyan-300">attach_file</mat-icon>
|
||||
<span class="max-w-[220px] truncate text-slate-200">{{ a.filename }}</span>
|
||||
<span class="text-xs font-mono text-slate-500">{{ size(a.sizeBytes) }}</span>
|
||||
<button type="button" class="text-slate-400 hover:text-pink-400 p-1" [attr.aria-label]="'Remove ' + a.filename"
|
||||
(click)="detach(a)"><mat-icon class="!text-lg">close</mat-icon></button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (error()) {
|
||||
<p class="text-sm text-pink-400 font-rajdhani flex items-center gap-2" role="alert"><mat-icon class="!text-lg">error</mat-icon>{{ error() }}</p>
|
||||
}
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3 pt-2 border-t border-cyan-500/20">
|
||||
<button type="submit" class="cyber-btn px-6 py-2.5 rounded-xl font-bold text-sm" [disabled]="sending() || uploading()">
|
||||
<mat-icon>send</mat-icon> {{ sending() ? 'SENDING…' : 'SEND' }}
|
||||
</button>
|
||||
<button type="button" class="ghost-btn" (click)="saveNow()" [disabled]="saving() || !hasContent()">
|
||||
<mat-icon>save</mat-icon> Save draft
|
||||
</button>
|
||||
<span class="flex-grow"></span>
|
||||
<button type="button" class="ghost-btn danger" (click)="discard()"><mat-icon>delete</mat-icon> Discard</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<ng-template #suggest let-field="field">
|
||||
@if (suggestions().length) {
|
||||
<ul [id]="'suggest-' + field" role="listbox" aria-label="Contacts"
|
||||
class="absolute z-30 w-full mt-1 bg-slate-950/95 border border-pink-500/40 rounded-xl shadow-2xl py-1 backdrop-blur-2xl">
|
||||
@for (c of suggestions(); track c.email) {
|
||||
<li role="option" aria-selected="false" class="px-4 py-2 text-sm cursor-pointer hover:bg-cyan-500/20 flex gap-2"
|
||||
(mousedown)="$event.preventDefault(); pick(field, c)">
|
||||
<span class="text-slate-200 font-semibold">{{ c.name || c.email }}</span>
|
||||
@if (c.name) { <span class="text-cyan-300 font-mono text-xs self-center">{{ c.email }}</span> }
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</ng-template>
|
||||
`
|
||||
})
|
||||
export class ComposePage {
|
||||
readonly draft = input<string>();
|
||||
readonly reply = input<string>();
|
||||
readonly replyAll = input<string>();
|
||||
readonly forward = input<string>();
|
||||
readonly toParam = input<string>(undefined, { alias: 'to' });
|
||||
|
||||
private api = inject(ApiService);
|
||||
private state = inject(MailStateService);
|
||||
private dialogs = inject(DialogService);
|
||||
private toasts = inject(ToastService);
|
||||
private router = inject(Router);
|
||||
|
||||
readonly to = signal('');
|
||||
readonly cc = signal('');
|
||||
readonly subject = signal('');
|
||||
readonly body = signal('');
|
||||
readonly showCc = signal(false);
|
||||
readonly draftId = signal<number | null>(null);
|
||||
readonly attachments = signal<Attachment[]>([]);
|
||||
readonly dirty = signal(false);
|
||||
readonly saving = signal(false);
|
||||
readonly sending = signal(false);
|
||||
readonly uploading = signal(false);
|
||||
readonly error = signal('');
|
||||
readonly focused = signal<'to' | 'cc' | null>(null);
|
||||
private contacts = signal<Contact[]>([]);
|
||||
private done = false;
|
||||
|
||||
readonly title = computed(() => this.reply() ? 'REPLY' : this.replyAll() ? 'REPLY ALL' : this.forward() ? 'FORWARD'
|
||||
: this.draft() ? 'EDIT DRAFT' : 'NEW MESSAGE');
|
||||
readonly hasContent = computed(() => !!(this.to().trim() || this.cc().trim() || this.subject().trim() || this.body().trim()));
|
||||
|
||||
/** Contacts matching the address being typed (after the last comma). */
|
||||
readonly suggestions = computed(() => {
|
||||
const field = this.focused();
|
||||
if (!field) return [];
|
||||
const value = field === 'to' ? this.to() : this.cc();
|
||||
const typed = value.split(/[,;]/).pop()!.trim().toLowerCase();
|
||||
if (!typed) return [];
|
||||
const taken = value.toLowerCase();
|
||||
return this.contacts()
|
||||
.filter(c => !taken.includes(c.email.toLowerCase()) &&
|
||||
(c.email.toLowerCase().includes(typed) || (c.name || '').toLowerCase().includes(typed)))
|
||||
.slice(0, 6);
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.api.contacts().subscribe({ next: c => this.contacts.set(c), error: () => {} });
|
||||
effect(() => {
|
||||
const draft = this.draft(), reply = this.reply(), replyAll = this.replyAll(), forward = this.forward(), to = this.toParam();
|
||||
untracked(() => this.init(draft, reply, replyAll, forward, to));
|
||||
});
|
||||
const timer = setInterval(() => { if (this.dirty() && !this.saving() && !this.sending()) this.save().subscribe(); }, 15000);
|
||||
inject(DestroyRef).onDestroy(() => {
|
||||
clearInterval(timer);
|
||||
if (!this.done && this.dirty() && this.hasContent()) this.save().subscribe();
|
||||
});
|
||||
}
|
||||
|
||||
private init(draft?: string, reply?: string, replyAll?: string, forward?: string, to?: string): void {
|
||||
const source = draft ?? reply ?? replyAll ?? forward;
|
||||
if (to) this.to.set(to);
|
||||
if (!source) return;
|
||||
this.api.message(Number(source)).subscribe({
|
||||
next: m => {
|
||||
if (draft) this.fromDraft(m);
|
||||
else if (forward) this.fromForward(m);
|
||||
else this.fromReply(m, !!replyAll);
|
||||
},
|
||||
error: e => this.error.set(errorText(e))
|
||||
});
|
||||
}
|
||||
|
||||
private fromDraft(m: Message): void {
|
||||
this.draftId.set(m.id);
|
||||
this.to.set(m.recipientEmail || '');
|
||||
this.cc.set(m.ccEmail || '');
|
||||
this.showCc.set(!!m.ccEmail);
|
||||
this.subject.set(m.subject || '');
|
||||
this.body.set(m.body || '');
|
||||
if (m.attachmentCount) this.api.attachments(m.id).subscribe({ next: a => this.attachments.set(a), error: () => {} });
|
||||
}
|
||||
|
||||
private fromReply(m: Message, all: boolean): void {
|
||||
const me = (this.state.profile()?.email ?? '').toLowerCase();
|
||||
this.to.set(m.senderEmail);
|
||||
if (all) {
|
||||
const others = [...m.recipientEmail.split(','), ...(m.ccEmail ? m.ccEmail.split(',') : [])]
|
||||
.map(a => a.trim()).filter(a => a && a.toLowerCase() !== me && a.toLowerCase() !== m.senderEmail.toLowerCase());
|
||||
if (others.length) { this.cc.set(others.join(', ')); this.showCc.set(true); }
|
||||
}
|
||||
this.subject.set(prefixed('Re: ', m.subject));
|
||||
this.body.set(`\n\n${header(m)} wrote:\n${quote(m)}`);
|
||||
this.dirty.set(true);
|
||||
}
|
||||
|
||||
private fromForward(m: Message): void {
|
||||
this.subject.set(prefixed('Fwd: ', m.subject));
|
||||
this.body.set(`\n\n---------- Forwarded message ----------\nFrom: ${header(m)}\nDate: ${new Date(m.receivedAt).toLocaleString()}\n`
|
||||
+ `Subject: ${m.subject || ''}\nTo: ${m.recipientEmail}\n\n${plain(m)}`);
|
||||
this.dirty.set(true);
|
||||
if (m.attachmentCount) this.toasts.ok('Attachments of the original are not forwarded — attach them again if needed');
|
||||
}
|
||||
|
||||
touch(): void { this.dirty.set(true); this.error.set(''); }
|
||||
|
||||
blurLater(): void { setTimeout(() => this.focused.set(null), 150); }
|
||||
|
||||
pick(field: 'to' | 'cc', contact: Contact): void {
|
||||
const target = field === 'to' ? this.to : this.cc;
|
||||
const parts = target().split(/[,;]/).map(p => p.trim());
|
||||
parts[parts.length - 1] = contact.email;
|
||||
target.set(parts.filter(Boolean).join(', ') + ', ');
|
||||
this.touch();
|
||||
}
|
||||
|
||||
private current(): Draft {
|
||||
return { to: this.to().trim(), cc: this.showCc() ? this.cc().trim() : '', subject: this.subject(), body: this.body(), html: false };
|
||||
}
|
||||
|
||||
/** Creates the draft on the first save, updates it afterwards. */
|
||||
private save(): Observable<number> {
|
||||
const id = this.draftId();
|
||||
this.saving.set(true);
|
||||
const request = id ? this.api.updateDraft(id, this.current()) : this.api.createDraft(this.current());
|
||||
return request.pipe(
|
||||
tap({
|
||||
next: m => { this.draftId.set(m.id); this.dirty.set(false); this.saving.set(false); this.state.refreshFolders(); },
|
||||
error: e => { this.saving.set(false); this.error.set('Draft not saved: ' + errorText(e)); }
|
||||
}),
|
||||
switchMap(m => of(m.id))
|
||||
);
|
||||
}
|
||||
|
||||
saveNow(): void {
|
||||
this.save().subscribe({ next: () => this.toasts.ok('Draft saved'), error: () => {} });
|
||||
}
|
||||
|
||||
attach(event: Event): void {
|
||||
const field = event.target as HTMLInputElement;
|
||||
const files = Array.from(field.files ?? []);
|
||||
// the input is cleared only after the upload: Chrome cannot read a File whose input was reset
|
||||
const reset = () => { field.value = ''; };
|
||||
const tooBig = files.filter(f => f.size > MAX_FILE);
|
||||
if (tooBig.length) this.toasts.error(`Too big (over 10 MB): ${tooBig.map(f => f.name).join(', ')}`);
|
||||
const ok = files.filter(f => f.size <= MAX_FILE);
|
||||
if (!ok.length) { reset(); return; }
|
||||
this.uploading.set(true);
|
||||
const id = this.draftId();
|
||||
(id && !this.dirty() ? of(id) : this.save()).subscribe({
|
||||
next: draftId => this.uploadNext(draftId, ok, reset),
|
||||
error: () => { this.uploading.set(false); reset(); }
|
||||
});
|
||||
}
|
||||
|
||||
private uploadNext(draftId: number, files: File[], done: () => void): void {
|
||||
const [file, ...rest] = files;
|
||||
if (!file) {
|
||||
this.uploading.set(false);
|
||||
done();
|
||||
return;
|
||||
}
|
||||
this.api.upload(draftId, file).subscribe({
|
||||
next: a => { this.attachments.update(list => [...list, a]); this.uploadNext(draftId, rest, done); },
|
||||
error: e => { this.toasts.error(`${file.name}: ${errorText(e)}`); this.uploadNext(draftId, rest, done); }
|
||||
});
|
||||
}
|
||||
|
||||
detach(a: Attachment): void {
|
||||
this.api.deleteAttachment(a.id).subscribe({
|
||||
next: () => this.attachments.update(list => list.filter(x => x.id !== a.id)),
|
||||
error: e => this.toasts.error(errorText(e))
|
||||
});
|
||||
}
|
||||
|
||||
send(): void {
|
||||
if (!this.to().trim()) {
|
||||
this.error.set('Who is it for? Fill in "To"');
|
||||
return;
|
||||
}
|
||||
const go = () => {
|
||||
this.sending.set(true);
|
||||
this.error.set('');
|
||||
this.api.send(this.current(), this.draftId()).subscribe({
|
||||
next: () => {
|
||||
this.done = true;
|
||||
this.toasts.ok('Message sent');
|
||||
this.state.refreshFolders();
|
||||
this.state.refreshProfile();
|
||||
const sent = this.state.system('Sent');
|
||||
this.router.navigate(sent ? ['/folder', sent.id] : ['/']);
|
||||
},
|
||||
error: e => { this.sending.set(false); this.error.set(errorText(e)); }
|
||||
});
|
||||
};
|
||||
if (!this.subject().trim()) {
|
||||
this.dialogs.confirm('Send without a subject?', 'The message has no subject.', 'Send').subscribe(ok => ok !== null && go());
|
||||
} else {
|
||||
go();
|
||||
}
|
||||
}
|
||||
|
||||
discard(): void {
|
||||
const id = this.draftId();
|
||||
const leave = () => {
|
||||
this.done = true;
|
||||
this.router.navigate(['/']);
|
||||
};
|
||||
if (!id && !this.hasContent()) {
|
||||
leave();
|
||||
return;
|
||||
}
|
||||
this.dialogs.confirm('Discard this message?', id ? 'The draft and its attachments are deleted.' : 'What you typed is lost.', 'Discard', true)
|
||||
.subscribe(ok => {
|
||||
if (ok === null) return;
|
||||
if (!id) { leave(); return; }
|
||||
// a draft: to the Trash, then out of it
|
||||
this.api.deleteMessage(id).pipe(switchMap(() => this.api.deleteMessage(id))).subscribe({
|
||||
next: () => { this.state.refreshFolders(); leave(); },
|
||||
error: () => { this.state.refreshFolders(); leave(); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
size(bytes: number): string { return formatBytes(bytes); }
|
||||
}
|
||||
|
||||
function prefixed(prefix: string, subject: string): string {
|
||||
const s = subject || '';
|
||||
return s.toLowerCase().startsWith(prefix.toLowerCase()) ? s : prefix + s;
|
||||
}
|
||||
|
||||
function header(m: Message): string {
|
||||
return m.senderName ? `${m.senderName} <${m.senderEmail}>` : m.senderEmail;
|
||||
}
|
||||
|
||||
function plain(m: Message): string {
|
||||
if (!m.html) return m.body || '';
|
||||
const doc = new DOMParser().parseFromString(m.body || '', 'text/html');
|
||||
return (doc.body.textContent || '').replace(/\n{3,}/g, '\n\n').trim();
|
||||
}
|
||||
|
||||
function quote(m: Message): string {
|
||||
return plain(m).split('\n').map(line => '> ' + line).join('\n');
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { ApiService } from '../core/api.service';
|
||||
import { errorText } from '../core/models';
|
||||
|
||||
/** "/" → the Inbox (its id is per user). */
|
||||
@Component({
|
||||
selector: 'app-inbox-redirect',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `<section class="glass-panel static p-6 text-slate-400 font-mono text-sm">{{ message }}</section>`
|
||||
})
|
||||
export class InboxRedirect {
|
||||
message = 'Loading…';
|
||||
|
||||
constructor() {
|
||||
const router = inject(Router);
|
||||
inject(ApiService).folders().subscribe({
|
||||
next: folders => {
|
||||
const inbox = folders.find(f => f.system && f.name === 'INBOX') ?? folders[0];
|
||||
if (inbox) router.navigate(['/folder', inbox.id], { replaceUrl: true });
|
||||
},
|
||||
error: e => { this.message = errorText(e); }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, effect, inject, input, signal } from '@angular/core';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||
import { Location } from '@angular/common';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { ApiService } from '../../core/api.service';
|
||||
import { MailStateService } from '../../core/mail-state.service';
|
||||
import { Attachment, Message, errorText, folderLabel, formatBytes } from '../../core/models';
|
||||
import { DialogService } from '../../ui/dialog.service';
|
||||
import { ToastService } from '../../ui/toast.service';
|
||||
|
||||
/** One message: headers, body, attachments, reply/forward/move/spam/block/delete. */
|
||||
@Component({
|
||||
selector: 'app-message',
|
||||
imports: [DatePipe, FormsModule, RouterLink, MatIconModule, MatTooltipModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
@if (error()) {
|
||||
<section class="glass-panel static p-6">
|
||||
<p class="text-pink-400 font-rajdhani mb-4" role="alert">{{ error() }}</p>
|
||||
<a routerLink="/" class="ghost-btn"><mat-icon>arrow_back</mat-icon> Inbox</a>
|
||||
</section>
|
||||
} @else if (message(); as m) {
|
||||
<article class="glass-panel static p-4 md:p-6" aria-labelledby="message-subject">
|
||||
<div class="flex flex-wrap items-center gap-2 mb-5 pb-4 border-b border-cyan-500/20" role="toolbar" aria-label="Message actions">
|
||||
<button type="button" class="ghost-btn" (click)="back()"><mat-icon>arrow_back</mat-icon> Back</button>
|
||||
<span class="flex-grow"></span>
|
||||
<a [routerLink]="['/compose', { reply: m.id }]" class="ghost-btn"><mat-icon>reply</mat-icon> Reply</a>
|
||||
@if (hasOthers()) {
|
||||
<a [routerLink]="['/compose', { replyAll: m.id }]" class="ghost-btn"><mat-icon>reply_all</mat-icon> Reply all</a>
|
||||
}
|
||||
<a [routerLink]="['/compose', { forward: m.id }]" class="ghost-btn"><mat-icon>forward</mat-icon> Forward</a>
|
||||
<button type="button" class="ghost-btn" [attr.aria-pressed]="m.flagged" (click)="update({ flagged: !m.flagged })">
|
||||
<mat-icon [class.text-yellow-400]="m.flagged">{{ m.flagged ? 'star' : 'star_border' }}</mat-icon> {{ m.flagged ? 'Flagged' : 'Flag' }}
|
||||
</button>
|
||||
<button type="button" class="ghost-btn" (click)="markUnread()"><mat-icon>mark_email_unread</mat-icon> Unread</button>
|
||||
<label for="move-to" class="sr-only">Move to folder</label>
|
||||
<select id="move-to" class="bg-slate-900/80 text-slate-300 border border-cyan-500/35 rounded-[10px] px-2 py-1.5 text-xs font-mono"
|
||||
[ngModel]="''" (ngModelChange)="move($event)">
|
||||
<option value="" disabled>Move to…</option>
|
||||
@for (f of targets(); track f.id) { <option [value]="f.id">{{ label(f) }}</option> }
|
||||
</select>
|
||||
@if (inSpam()) {
|
||||
<button type="button" class="ghost-btn" (click)="notSpam()"><mat-icon>inbox</mat-icon> Not spam</button>
|
||||
} @else if (!outgoing()) {
|
||||
<button type="button" class="ghost-btn" (click)="spam()"><mat-icon>report</mat-icon> Spam</button>
|
||||
}
|
||||
<button type="button" class="ghost-btn danger" (click)="remove()"><mat-icon>delete</mat-icon> {{ inTrash() ? 'Delete forever' : 'Delete' }}</button>
|
||||
</div>
|
||||
|
||||
<h1 id="message-subject" class="text-2xl font-orbitron font-bold neon-text-secondary mb-4 break-words">{{ m.subject || '(no subject)' }}</h1>
|
||||
|
||||
<div class="flex items-start gap-3 mb-5">
|
||||
<div class="w-11 h-11 rounded-full bg-gradient-to-br from-pink-500/60 to-cyan-400/60 flex items-center justify-center font-orbitron font-bold text-white shrink-0"
|
||||
aria-hidden="true">{{ initial() }}</div>
|
||||
<dl class="text-sm font-rajdhani min-w-0 flex-grow grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5">
|
||||
<dt class="text-slate-500 font-mono text-xs pt-0.5">From</dt>
|
||||
<dd class="text-slate-100 break-all">
|
||||
@if (m.senderName) { <span class="font-bold">{{ m.senderName }}</span> }
|
||||
<span class="text-cyan-300 font-mono text-xs ml-1"><{{ m.senderEmail }}></span>
|
||||
@if (!outgoing()) {
|
||||
<button type="button" class="ml-2 text-[11px] font-mono text-slate-500 hover:text-pink-400" (click)="block()">block sender</button>
|
||||
}
|
||||
</dd>
|
||||
<dt class="text-slate-500 font-mono text-xs pt-0.5">To</dt>
|
||||
<dd class="text-slate-300 break-all">{{ m.recipientEmail }}</dd>
|
||||
@if (m.ccEmail) {
|
||||
<dt class="text-slate-500 font-mono text-xs pt-0.5">Cc</dt>
|
||||
<dd class="text-slate-300 break-all">{{ m.ccEmail }}</dd>
|
||||
}
|
||||
<dt class="text-slate-500 font-mono text-xs pt-0.5">Date</dt>
|
||||
<dd class="text-slate-400 font-mono text-xs pt-0.5">{{ m.receivedAt | date: 'EEEE, d MMMM y, HH:mm' }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
@if (m.spam) {
|
||||
<p class="mb-4 px-3 py-2 rounded-lg bg-pink-500/15 border border-pink-500/40 text-pink-200 text-sm font-rajdhani flex items-center gap-2">
|
||||
<mat-icon>report</mat-icon> Marked as spam: the sender is blocked or the message failed the SPF/DKIM/DMARC checks.
|
||||
</p>
|
||||
}
|
||||
|
||||
@if (m.html) {
|
||||
<div class="flex items-center gap-3 mb-2 text-xs font-mono text-slate-400">
|
||||
<span>HTML message</span>
|
||||
<button type="button" class="ghost-btn !py-1" (click)="remoteImages.set(!remoteImages())" [attr.aria-pressed]="remoteImages()">
|
||||
<mat-icon>image</mat-icon> {{ remoteImages() ? 'Hide remote images' : 'Show remote images' }}
|
||||
</button>
|
||||
</div>
|
||||
<iframe class="w-full h-[65vh] rounded-xl bg-white border border-cyan-500/30" sandbox="allow-popups allow-popups-to-escape-sandbox"
|
||||
[srcdoc]="htmlDoc()" title="Message body"></iframe>
|
||||
} @else {
|
||||
<div class="bg-slate-950/70 rounded-xl border border-cyan-500/20 p-4 md:p-5 font-rajdhani text-[1.02rem] text-slate-200 whitespace-pre-wrap break-words leading-relaxed">{{ m.body }}</div>
|
||||
}
|
||||
|
||||
@if (attachments().length) {
|
||||
<h2 class="mt-6 mb-2 text-xs font-orbitron font-bold text-cyan-300 tracking-widest">ATTACHMENTS ({{ attachments().length }})</h2>
|
||||
<ul class="flex flex-wrap gap-2">
|
||||
@for (a of attachments(); track a.id) {
|
||||
<li>
|
||||
<button type="button" class="ghost-btn !py-2" (click)="download(a)" [attr.aria-label]="'Download ' + a.filename">
|
||||
<mat-icon>{{ fileIcon(a) }}</mat-icon>
|
||||
<span class="max-w-[220px] truncate text-slate-200">{{ a.filename }}</span>
|
||||
<span class="text-slate-500">{{ size(a.sizeBytes) }}</span>
|
||||
<mat-icon class="text-cyan-300">download</mat-icon>
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</article>
|
||||
} @else {
|
||||
<section class="glass-panel static p-6 text-slate-400 font-mono text-sm">Loading…</section>
|
||||
}
|
||||
`
|
||||
})
|
||||
export class MessagePage {
|
||||
readonly id = input.required<string>();
|
||||
|
||||
private api = inject(ApiService);
|
||||
private state = inject(MailStateService);
|
||||
private dialogs = inject(DialogService);
|
||||
private toasts = inject(ToastService);
|
||||
private router = inject(Router);
|
||||
private location = inject(Location);
|
||||
private sanitizer = inject(DomSanitizer);
|
||||
|
||||
readonly message = signal<Message | null>(null);
|
||||
readonly attachments = signal<Attachment[]>([]);
|
||||
readonly error = signal('');
|
||||
readonly remoteImages = signal(false);
|
||||
|
||||
readonly folder = computed(() => { const m = this.message(); return m ? this.state.folder(m.folderId) : null; });
|
||||
readonly inTrash = computed(() => this.folder()?.system === true && this.folder()?.name === 'Trash');
|
||||
readonly inSpam = computed(() => this.folder()?.system === true && this.folder()?.name === 'Spam');
|
||||
readonly outgoing = computed(() => this.folder()?.system === true && ['Sent', 'Drafts'].includes(this.folder()!.name));
|
||||
readonly targets = computed(() => this.state.folders().filter(f => f.id !== this.message()?.folderId && f.name !== 'Drafts'));
|
||||
readonly initial = computed(() => (this.message()?.senderName || this.message()?.senderEmail || '?').trim().charAt(0).toUpperCase());
|
||||
readonly hasOthers = computed(() => {
|
||||
const m = this.message();
|
||||
if (!m) return false;
|
||||
return m.recipientEmail.split(',').length + (m.ccEmail ? m.ccEmail.split(',').length : 0) > 1;
|
||||
});
|
||||
|
||||
/** No scripts (sandbox), remote content only when asked — a sender cannot see that the message was opened. */
|
||||
readonly htmlDoc = computed<SafeHtml>(() => {
|
||||
const img = this.remoteImages() ? 'img-src data: https: http:;' : 'img-src data:;';
|
||||
const csp = `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; ${img} font-src data:">`;
|
||||
const base = '<base target="_blank"><style>body{font-family:system-ui,sans-serif;margin:16px;color:#111;word-wrap:break-word}</style>';
|
||||
return this.sanitizer.bypassSecurityTrustHtml(csp + base + (this.message()?.body ?? ''));
|
||||
});
|
||||
|
||||
label = folderLabel;
|
||||
|
||||
constructor() {
|
||||
effect(() => this.load(Number(this.id())));
|
||||
}
|
||||
|
||||
private load(id: number): void {
|
||||
this.message.set(null);
|
||||
this.attachments.set([]);
|
||||
this.error.set('');
|
||||
this.remoteImages.set(false);
|
||||
this.api.message(id).subscribe({
|
||||
next: m => {
|
||||
this.message.set(m);
|
||||
if (m.attachmentCount) this.api.attachments(m.id).subscribe({ next: a => this.attachments.set(a), error: () => {} });
|
||||
if (!m.read) {
|
||||
this.api.updateMessage(m.id, { read: true }).subscribe({
|
||||
next: () => { this.message.update(x => x && { ...x, read: true }); this.state.refreshFolders(); },
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
},
|
||||
error: e => this.error.set(e?.status === 404 ? 'This message does not exist (or was deleted).' : errorText(e))
|
||||
});
|
||||
}
|
||||
|
||||
back(): void {
|
||||
const m = this.message();
|
||||
if (history.length > 1) this.location.back();
|
||||
else this.router.navigate(m ? ['/folder', m.folderId] : ['/']);
|
||||
}
|
||||
|
||||
update(change: { read?: boolean; flagged?: boolean; folderId?: number }): void {
|
||||
const m = this.message();
|
||||
if (!m) return;
|
||||
this.api.updateMessage(m.id, change).subscribe({
|
||||
next: updated => { this.message.set({ ...m, ...updated, body: m.body }); this.state.refreshFolders(); },
|
||||
error: e => this.toasts.error(errorText(e))
|
||||
});
|
||||
}
|
||||
|
||||
markUnread(): void {
|
||||
const m = this.message();
|
||||
if (!m) return;
|
||||
this.api.updateMessage(m.id, { read: false }).subscribe({
|
||||
next: () => { this.state.refreshFolders(); this.router.navigate(['/folder', m.folderId]); },
|
||||
error: e => this.toasts.error(errorText(e))
|
||||
});
|
||||
}
|
||||
|
||||
move(value: string): void {
|
||||
const target = this.state.folder(Number(value));
|
||||
const m = this.message();
|
||||
if (!target || !m) return;
|
||||
this.api.updateMessage(m.id, { folderId: target.id }).subscribe({
|
||||
next: () => { this.toasts.ok(`Moved to ${folderLabel(target)}`); this.state.refreshFolders(); this.router.navigate(['/folder', m.folderId]); },
|
||||
error: e => this.toasts.error(errorText(e))
|
||||
});
|
||||
}
|
||||
|
||||
spam(): void { const s = this.state.system('Spam'); if (s) this.move(String(s.id)); }
|
||||
notSpam(): void { const i = this.state.inbox(); if (i) this.move(String(i.id)); }
|
||||
|
||||
block(): void {
|
||||
const m = this.message();
|
||||
if (!m) return;
|
||||
this.dialogs.confirm(`Block ${m.senderEmail}?`, 'New messages from this sender go straight to Spam. This message moves to Spam too.', 'Block', true)
|
||||
.subscribe(ok => {
|
||||
if (ok === null) return;
|
||||
this.api.block(m.senderEmail, 'Blocked from a message').subscribe({
|
||||
next: () => { this.toasts.ok(`${m.senderEmail} blocked`); this.spam(); },
|
||||
error: e => this.toasts.error(errorText(e))
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
remove(): void {
|
||||
const m = this.message();
|
||||
if (!m) return;
|
||||
const go = () => this.api.deleteMessage(m.id).subscribe({
|
||||
next: () => {
|
||||
this.toasts.ok(this.inTrash() ? 'Deleted forever' : 'Moved to Trash');
|
||||
this.state.refreshFolders();
|
||||
this.state.refreshProfile();
|
||||
this.router.navigate(['/folder', m.folderId]);
|
||||
},
|
||||
error: e => this.toasts.error(errorText(e))
|
||||
});
|
||||
if (this.inTrash()) {
|
||||
this.dialogs.confirm('Delete forever?', 'This message cannot be restored.', 'Delete', true).subscribe(ok => ok !== null && go());
|
||||
} else {
|
||||
go();
|
||||
}
|
||||
}
|
||||
|
||||
download(a: Attachment): void {
|
||||
this.api.download(a.id).subscribe({
|
||||
next: blob => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = a.filename;
|
||||
link.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
},
|
||||
error: e => this.toasts.error(errorText(e))
|
||||
});
|
||||
}
|
||||
|
||||
size(bytes: number): string { return formatBytes(bytes); }
|
||||
|
||||
fileIcon(a: Attachment): string {
|
||||
const type = a.contentType || '';
|
||||
if (type.startsWith('image/')) return 'image';
|
||||
if (type.includes('pdf')) return 'picture_as_pdf';
|
||||
if (type.includes('zip') || type.includes('compressed')) return 'folder_zip';
|
||||
if (type.startsWith('text/')) return 'description';
|
||||
return 'attach_file';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, effect, inject, signal } from '@angular/core';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { ApiService } from '../../core/api.service';
|
||||
import { MailStateService } from '../../core/mail-state.service';
|
||||
import { errorText, formatBytes } from '../../core/models';
|
||||
import { THEMES, ThemeService } from '../../ui/theme.service';
|
||||
import { ToastService } from '../../ui/toast.service';
|
||||
|
||||
/** Profile name, password, storage, look. */
|
||||
@Component({
|
||||
selector: 'app-settings',
|
||||
imports: [DatePipe, FormsModule, MatIconModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-5">
|
||||
<section class="glass-panel static p-5 md:p-6" aria-labelledby="profile-title">
|
||||
<h1 id="profile-title" class="text-lg font-orbitron font-bold text-cyan-300 flex items-center gap-2 mb-4 border-b border-cyan-500/20 pb-2">
|
||||
<mat-icon class="text-pink-500">badge</mat-icon> PROFILE
|
||||
</h1>
|
||||
@if (state.profile(); as p) {
|
||||
<dl class="text-sm font-rajdhani grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 mb-5">
|
||||
<dt class="text-slate-500 font-mono text-xs pt-0.5">Mailbox</dt><dd class="text-cyan-300 font-mono">{{ p.email }}</dd>
|
||||
<dt class="text-slate-500 font-mono text-xs pt-0.5">Since</dt><dd class="text-slate-300">{{ p.createdAt | date: 'd MMMM y' }}</dd>
|
||||
</dl>
|
||||
}
|
||||
<form (ngSubmit)="saveName()" class="space-y-3">
|
||||
<div>
|
||||
<label class="synth-label" for="display-name">Name shown to recipients</label>
|
||||
<input id="display-name" name="displayName" class="synth-input" [(ngModel)]="displayName" maxlength="100" required>
|
||||
</div>
|
||||
<button type="submit" class="cyber-btn px-4 py-2 rounded-lg text-xs font-bold"><mat-icon>save</mat-icon> SAVE NAME</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="glass-panel static p-5 md:p-6" aria-labelledby="password-title">
|
||||
<h2 id="password-title" class="text-lg font-orbitron font-bold text-cyan-300 flex items-center gap-2 mb-4 border-b border-cyan-500/20 pb-2">
|
||||
<mat-icon class="text-pink-500">key</mat-icon> PASSWORD
|
||||
</h2>
|
||||
<form (ngSubmit)="changePassword()" class="space-y-3">
|
||||
<input type="email" name="username" class="sr-only" [value]="state.profile()?.email ?? ''" autocomplete="username" tabindex="-1" aria-hidden="true">
|
||||
<div><label class="synth-label" for="pw-current">Current password</label>
|
||||
<input id="pw-current" name="current" type="password" class="synth-input" [(ngModel)]="current" autocomplete="current-password"></div>
|
||||
<div><label class="synth-label" for="pw-new">New password (8+ characters)</label>
|
||||
<input id="pw-new" name="next" type="password" class="synth-input" [(ngModel)]="next" autocomplete="new-password"></div>
|
||||
<div><label class="synth-label" for="pw-again">New password again</label>
|
||||
<input id="pw-again" name="again" type="password" class="synth-input" [(ngModel)]="again" autocomplete="new-password"></div>
|
||||
@if (passwordError()) { <p class="text-sm text-pink-400" role="alert">{{ passwordError() }}</p> }
|
||||
<button type="submit" class="cyber-btn px-4 py-2 rounded-lg text-xs font-bold"><mat-icon>lock_reset</mat-icon> CHANGE PASSWORD</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="glass-panel static p-5 md:p-6" aria-labelledby="storage-title">
|
||||
<h2 id="storage-title" class="text-lg font-orbitron font-bold text-cyan-300 flex items-center gap-2 mb-4 border-b border-cyan-500/20 pb-2">
|
||||
<mat-icon class="text-pink-500">storage</mat-icon> STORAGE
|
||||
</h2>
|
||||
<div class="flex justify-between text-xs font-mono text-slate-300 mb-1">
|
||||
<span>{{ used() }} of {{ quota() }}</span><span class="text-pink-400">{{ percent() }}%</span>
|
||||
</div>
|
||||
<div class="h-2 rounded-full bg-slate-800 overflow-hidden mb-4" role="progressbar" aria-label="Storage used"
|
||||
[attr.aria-valuenow]="percent()" aria-valuemin="0" aria-valuemax="100">
|
||||
<div class="h-full bg-gradient-to-r from-cyan-400 to-pink-500" [style.width.%]="percent()"></div>
|
||||
</div>
|
||||
<ul class="text-sm font-rajdhani text-slate-300 space-y-1">
|
||||
@for (f of state.folders(); track f.id) {
|
||||
<li class="flex justify-between"><span>{{ f.system ? label(f.name) : f.name }}</span>
|
||||
<span class="font-mono text-xs text-slate-400">{{ f.messageCount }} messages</span></li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="glass-panel static p-5 md:p-6" aria-labelledby="look-title">
|
||||
<h2 id="look-title" class="text-lg font-orbitron font-bold text-cyan-300 flex items-center gap-2 mb-4 border-b border-cyan-500/20 pb-2">
|
||||
<mat-icon class="text-pink-500">palette</mat-icon> LOOK
|
||||
</h2>
|
||||
<fieldset class="mb-4">
|
||||
<legend class="synth-label">Palette</legend>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
@for (t of themes; track t.value) {
|
||||
<button type="button" class="ghost-btn justify-center !py-2" [attr.aria-pressed]="theme.theme() === t.value"
|
||||
[class.!border-pink-500]="theme.theme() === t.value" [class.!text-pink-300]="theme.theme() === t.value"
|
||||
(click)="theme.theme.set(t.value)">{{ t.label }}</button>
|
||||
}
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between p-3 bg-slate-900/50 rounded-lg border border-pink-500/20">
|
||||
<div><div class="font-bold text-slate-200">High contrast</div><div class="text-xs text-slate-400">Black background, bright outlines</div></div>
|
||||
<button type="button" class="ghost-btn" [attr.aria-pressed]="theme.highContrast()" (click)="theme.highContrast.set(!theme.highContrast())">
|
||||
{{ theme.highContrast() ? 'ON' : 'OFF' }}</button>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-slate-900/50 rounded-lg border border-pink-500/20">
|
||||
<div><div class="font-bold text-slate-200">Grid motion</div><div class="text-xs text-slate-400">The moving retro floor in the background</div></div>
|
||||
<button type="button" class="ghost-btn" [attr.aria-pressed]="theme.animation()" (click)="theme.animation.set(!theme.animation())">
|
||||
{{ theme.animation() ? 'ON' : 'OFF' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
export class SettingsPage {
|
||||
readonly state = inject(MailStateService);
|
||||
readonly theme = inject(ThemeService);
|
||||
private api = inject(ApiService);
|
||||
private toasts = inject(ToastService);
|
||||
|
||||
readonly themes = THEMES;
|
||||
displayName = '';
|
||||
current = '';
|
||||
next = '';
|
||||
again = '';
|
||||
readonly passwordError = signal('');
|
||||
|
||||
readonly used = computed(() => formatBytes(this.state.profile()?.usedBytes));
|
||||
readonly quota = computed(() => formatBytes(this.state.profile()?.quotaBytes));
|
||||
readonly percent = computed(() => {
|
||||
const p = this.state.profile();
|
||||
return p && p.quotaBytes ? Math.min(100, Math.round(p.usedBytes / p.quotaBytes * 1000) / 10) : 0;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.state.refreshProfile();
|
||||
effect(() => { this.displayName = this.state.profile()?.displayName ?? ''; });
|
||||
}
|
||||
|
||||
label(name: string): string {
|
||||
return ({ INBOX: 'Inbox', Drafts: 'Drafts', Sent: 'Sent', Spam: 'Spam', Trash: 'Trash' } as Record<string, string>)[name] ?? name;
|
||||
}
|
||||
|
||||
saveName(): void {
|
||||
const name = this.displayName.trim();
|
||||
if (!name) { this.toasts.error('The name cannot be empty'); return; }
|
||||
this.api.updateMe(name).subscribe({
|
||||
next: p => { this.state.profile.set(p); this.toasts.ok('Name saved'); },
|
||||
error: e => this.toasts.error(errorText(e))
|
||||
});
|
||||
}
|
||||
|
||||
changePassword(): void {
|
||||
const problem = !this.current ? 'Enter the current password'
|
||||
: this.next.length < 8 ? 'The new password is shorter than 8 characters'
|
||||
: this.next !== this.again ? 'The new passwords do not match' : '';
|
||||
this.passwordError.set(problem);
|
||||
if (problem) return;
|
||||
this.api.changePassword(this.current, this.next).subscribe({
|
||||
next: () => { this.current = this.next = this.again = ''; this.toasts.ok('Password changed'); },
|
||||
error: e => this.passwordError.set(errorText(e))
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, BehaviorSubject } from 'rxjs';
|
||||
import { tap, shareReplay } from 'rxjs/operators';
|
||||
import { LoginRequest, LoginResponse, RegisterRequest } from '../models/mail.models';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class AuthService {
|
||||
private http = inject(HttpClient);
|
||||
private apiUrl = '/api';
|
||||
|
||||
private currentUserSubject = new BehaviorSubject<string | null>(
|
||||
localStorage.getItem('email')
|
||||
);
|
||||
currentUser$ = this.currentUserSubject.asObservable();
|
||||
|
||||
private tokenSubject = new BehaviorSubject<string | null>(
|
||||
localStorage.getItem('token')
|
||||
);
|
||||
token$ = this.tokenSubject.asObservable();
|
||||
|
||||
login({ email, password }: LoginRequest): Observable<LoginResponse> {
|
||||
return this.http.post<LoginResponse>(`${this.apiUrl}/login`, { email, password }).pipe(
|
||||
tap(({ token, email }) => {
|
||||
localStorage.setItem('token', token);
|
||||
localStorage.setItem('email', email);
|
||||
this.tokenSubject.next(token);
|
||||
this.currentUserSubject.next(email);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
register({ email, password, displayName }: RegisterRequest): Observable<void> {
|
||||
return this.http.post<void>(`${this.apiUrl}/register`, { email, password, displayName });
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('email');
|
||||
this.tokenSubject.next(null);
|
||||
this.currentUserSubject.next(null);
|
||||
}
|
||||
|
||||
getToken(): string | null {
|
||||
return localStorage.getItem('token');
|
||||
}
|
||||
|
||||
isLoggedIn(): boolean {
|
||||
return !!localStorage.getItem('token');
|
||||
}
|
||||
|
||||
getEmail(): string | null {
|
||||
return localStorage.getItem('email');
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, map } from 'rxjs';
|
||||
import { Message, Folder, Attachment, SendRequest, BlacklistEntry, PageResponse, Contact } from '../models/mail.models';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class MailService {
|
||||
private http = inject(HttpClient);
|
||||
private apiUrl = '/api';
|
||||
|
||||
// Папки
|
||||
getFolders(): Observable<Folder[]> {
|
||||
return this.http.get<Folder[]>(`${this.apiUrl}/folders`);
|
||||
}
|
||||
|
||||
// Письма
|
||||
getMessages(folderId: number, page: number = 0, size: number = 20): Observable<PageResponse<Message>> {
|
||||
return this.http.get<PageResponse<Message>>(`${this.apiUrl}/messages`, {
|
||||
params: { folderId, page, size }
|
||||
});
|
||||
}
|
||||
|
||||
getMessage(id: number): Observable<Message> {
|
||||
return this.http.get<Message>(`${this.apiUrl}/messages/${id}`);
|
||||
}
|
||||
|
||||
searchMessages(
|
||||
folderId: number,
|
||||
keyword?: string,
|
||||
subject?: string,
|
||||
body?: string,
|
||||
sender?: string,
|
||||
recipient?: string
|
||||
): Observable<Message[]> {
|
||||
const params: { [key: string]: string } = { folderId: folderId.toString() };
|
||||
if (keyword) params['keyword'] = keyword;
|
||||
if (subject) params['subject'] = subject;
|
||||
if (body) params['body'] = body;
|
||||
if (sender) params['sender'] = sender;
|
||||
if (recipient) params['recipient'] = recipient;
|
||||
return this.http.get<PageResponse<Message>>(`${this.apiUrl}/messages/search`, { params })
|
||||
.pipe(map(response => response.content));
|
||||
}
|
||||
|
||||
// Отправка
|
||||
send(sendRequest: SendRequest): Observable<void> {
|
||||
return this.http.post<void>(`${this.apiUrl}/send`, sendRequest);
|
||||
}
|
||||
|
||||
// Статус
|
||||
markAsRead(id: number): Observable<void> {
|
||||
return this.http.put<void>(`${this.apiUrl}/messages/${id}/read`, null);
|
||||
}
|
||||
|
||||
deleteMessage(id: number): Observable<void> {
|
||||
return this.http.delete<void>(`${this.apiUrl}/messages/${id}`);
|
||||
}
|
||||
|
||||
// Вложения
|
||||
getAttachments(messageId: number): Observable<Attachment[]> {
|
||||
return this.http.get<Attachment[]>(`${this.apiUrl}/messages/${messageId}/attachments`);
|
||||
}
|
||||
|
||||
uploadAttachment(messageId: number, file: File): Observable<void> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return this.http.post<void>(`${this.apiUrl}/messages/${messageId}/attachments`, formData);
|
||||
}
|
||||
|
||||
downloadAttachment(attachmentId: number): Observable<Blob> {
|
||||
return this.http.get<Blob>(`${this.apiUrl}/attachments/${attachmentId}/download`, {
|
||||
responseType: 'blob' as 'json'
|
||||
});
|
||||
}
|
||||
|
||||
deleteAttachment(attachmentId: number): Observable<void> {
|
||||
return this.http.delete<void>(`${this.apiUrl}/attachments/${attachmentId}`);
|
||||
}
|
||||
|
||||
// Чёрный список
|
||||
getBlacklist(): Observable<string[]> {
|
||||
return this.http.get<string[]>(`${this.apiUrl}/spam/blacklist`);
|
||||
}
|
||||
|
||||
addToBlacklist(entry: BlacklistEntry): Observable<void> {
|
||||
return this.http.post<void>(`${this.apiUrl}/spam/blacklist`, entry);
|
||||
}
|
||||
|
||||
removeFromBlacklist(senderEmail: string): Observable<void> {
|
||||
return this.http.delete<void>(`${this.apiUrl}/spam/blacklist`, {
|
||||
params: { senderEmail }
|
||||
});
|
||||
}
|
||||
|
||||
// Контакты
|
||||
getContacts(): Observable<Contact[]> {
|
||||
return this.http.get<Contact[]>(`${this.apiUrl}/contacts`);
|
||||
}
|
||||
|
||||
addContact(contact: Contact): Observable<Contact> {
|
||||
return this.http.post<Contact>(`${this.apiUrl}/contacts`, contact);
|
||||
}
|
||||
|
||||
updateContact(id: number, contact: Contact): Observable<Contact> {
|
||||
return this.http.put<Contact>(`${this.apiUrl}/contacts/${id}`, contact);
|
||||
}
|
||||
|
||||
deleteContact(id: number): Observable<void> {
|
||||
return this.http.delete<void>(`${this.apiUrl}/contacts/${id}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
|
||||
/** Synthwave sun, mountains and the moving grid floor (the showcase background). */
|
||||
@Component({
|
||||
selector: 'app-background',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="synth-stage" aria-hidden="true">
|
||||
<div class="synth-sun"></div>
|
||||
<div class="synth-mountains"></div>
|
||||
<div class="grid-container"><div class="grid-plane"></div></div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
export class BackgroundComponent {}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
export interface DialogRequest {
|
||||
title: string;
|
||||
message?: string;
|
||||
confirmLabel?: string;
|
||||
danger?: boolean;
|
||||
/** Set — the dialog asks for a text (prompt); the value is the initial text. */
|
||||
input?: string;
|
||||
inputLabel?: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
interface OpenDialog extends DialogRequest { resolve: (value: string | null) => void; }
|
||||
|
||||
/** One glass dialog at a time: confirm (resolves '' or null) and prompt (the text or null). */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DialogService {
|
||||
readonly current = signal<OpenDialog | null>(null);
|
||||
|
||||
ask(request: DialogRequest): Observable<string | null> {
|
||||
return new Observable<string | null>(subscriber => {
|
||||
this.current()?.resolve(null);
|
||||
this.current.set({
|
||||
...request,
|
||||
resolve: value => {
|
||||
this.current.set(null);
|
||||
subscriber.next(value);
|
||||
subscriber.complete();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
confirm(title: string, message: string, confirmLabel = 'Confirm', danger = false): Observable<string | null> {
|
||||
return this.ask({ title, message, confirmLabel, danger });
|
||||
}
|
||||
|
||||
prompt(title: string, inputLabel: string, value = '', confirmLabel = 'Save'): Observable<string | null> {
|
||||
return this.ask({ title, inputLabel, input: value, confirmLabel, required: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Provider, inject } from '@angular/core';
|
||||
import { JT_GRID_BULK_UI, JtGridBulkUi } from '@jambotron/aria-grid';
|
||||
import { DialogService } from './dialog.service';
|
||||
import { ToastService } from './toast.service';
|
||||
|
||||
/** The grid's bulk actions ask through our glass dialog and report through our toasts. */
|
||||
export function provideMailGrid(): Provider[] {
|
||||
return [{
|
||||
provide: JT_GRID_BULK_UI,
|
||||
useFactory: (): JtGridBulkUi => {
|
||||
const dialogs = inject(DialogService);
|
||||
const toasts = inject(ToastService);
|
||||
return {
|
||||
ask: request => dialogs.ask({
|
||||
title: request.title,
|
||||
message: request.message,
|
||||
confirmLabel: request.confirmLabel,
|
||||
danger: request.danger,
|
||||
...(request.reason ? { input: '', inputLabel: 'Reason', required: true } : {})
|
||||
}),
|
||||
success: message => toasts.ok(message),
|
||||
error: message => toasts.error(message)
|
||||
};
|
||||
}
|
||||
}];
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { ChangeDetectionStrategy, Component, ElementRef, effect, inject, signal, viewChild } from '@angular/core';
|
||||
import { A11yModule } from '@angular/cdk/a11y';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { DialogService } from './dialog.service';
|
||||
import { ToastService } from './toast.service';
|
||||
|
||||
/** Toasts and the dialog — placed once in the app root. */
|
||||
@Component({
|
||||
selector: 'app-overlays',
|
||||
imports: [A11yModule, FormsModule, MatIconModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="fixed bottom-4 right-4 z-[60] flex flex-col gap-2 w-[min(380px,calc(100vw-2rem))]">
|
||||
@for (toast of toasts.toasts(); track toast.id) {
|
||||
<div class="glass-panel static px-4 py-3 flex items-start gap-3 text-sm font-rajdhani"
|
||||
[class.border-pink-500]="toast.kind === 'error'" role="status">
|
||||
<mat-icon [class]="toast.kind === 'error' ? 'text-pink-400' : 'text-emerald-400'">
|
||||
{{ toast.kind === 'error' ? 'error' : 'check_circle' }}
|
||||
</mat-icon>
|
||||
<span class="flex-grow text-slate-200">{{ toast.text }}</span>
|
||||
<button type="button" class="text-slate-400 hover:text-pink-400" aria-label="Dismiss" (click)="toasts.dismiss(toast.id)">
|
||||
<mat-icon class="!text-lg">close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (dialogs.current(); as d) {
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md"
|
||||
(click)="cancel()" (keydown.escape)="cancel()">
|
||||
<div class="glass-panel static max-w-lg w-full p-6 relative border-2 !border-cyan-400 shadow-[0_0_50px_rgba(0,240,255,0.4)]"
|
||||
role="dialog" aria-modal="true" aria-labelledby="dialog-title" cdkTrapFocus [cdkTrapFocusAutoCapture]="true"
|
||||
(click)="$event.stopPropagation()">
|
||||
<button type="button" (click)="cancel()" aria-label="Close dialog"
|
||||
class="absolute top-4 right-4 text-slate-400 hover:text-pink-400 rounded-lg p-1">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-10 h-10 rounded-full flex items-center justify-center border"
|
||||
[class]="d.danger ? 'bg-pink-500/20 border-pink-500 text-pink-400' : 'bg-cyan-500/15 border-cyan-400 text-cyan-300'">
|
||||
<mat-icon>{{ d.danger ? 'warning' : d.input !== undefined ? 'edit' : 'help' }}</mat-icon>
|
||||
</div>
|
||||
<h3 id="dialog-title" class="text-lg font-orbitron font-bold text-slate-100 pr-8">{{ d.title }}</h3>
|
||||
</div>
|
||||
@if (d.message) {
|
||||
<p class="font-rajdhani text-slate-300 mb-4 whitespace-pre-line">{{ d.message }}</p>
|
||||
}
|
||||
<form (ngSubmit)="confirm()">
|
||||
@if (d.input !== undefined) {
|
||||
<label class="synth-label" for="dialog-input">{{ d.inputLabel }}</label>
|
||||
<input #input id="dialog-input" name="value" class="synth-input mb-5" [(ngModel)]="value" autocomplete="off">
|
||||
}
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" (click)="cancel()" class="px-4 py-2 rounded-lg text-xs font-mono text-slate-400 hover:text-slate-200">
|
||||
CANCEL [ESC]
|
||||
</button>
|
||||
<button type="submit" class="cyber-btn px-5 py-2 rounded-lg text-xs font-bold"
|
||||
[class.!border-pink-500]="d.danger" [disabled]="d.required && !value().trim()">
|
||||
{{ d.confirmLabel || 'Confirm' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
`
|
||||
})
|
||||
export class OverlaysComponent {
|
||||
readonly toasts = inject(ToastService);
|
||||
readonly dialogs = inject(DialogService);
|
||||
readonly value = signal('');
|
||||
private input = viewChild<ElementRef<HTMLInputElement>>('input');
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const d = this.dialogs.current();
|
||||
this.value.set(d?.input ?? '');
|
||||
});
|
||||
effect(() => {
|
||||
const el = this.input()?.nativeElement;
|
||||
if (el) setTimeout(() => el.select());
|
||||
});
|
||||
}
|
||||
|
||||
confirm(): void {
|
||||
const d = this.dialogs.current();
|
||||
if (!d || (d.required && !this.value().trim())) return;
|
||||
d.resolve(d.input !== undefined ? this.value().trim() : '');
|
||||
}
|
||||
|
||||
cancel(): void { this.dialogs.current()?.resolve(null); }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Injectable, effect, signal } from '@angular/core';
|
||||
|
||||
export const THEMES = [
|
||||
{ value: 'theme-cyber-magenta', label: 'Cyber Magenta' },
|
||||
{ value: 'theme-neon-cyan', label: 'Neon Cyan' },
|
||||
{ value: 'theme-sunset-orange', label: 'Sunset Orange' },
|
||||
{ value: 'theme-matrix-green', label: 'Matrix Green' }
|
||||
];
|
||||
|
||||
/** Palette, high contrast and background motion — on <html>, remembered in this browser. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ThemeService {
|
||||
readonly theme = signal(load('theme', THEMES[0].value));
|
||||
readonly highContrast = signal(load('contrast', 'off') === 'on');
|
||||
readonly animation = signal(load('animation', 'on') === 'on');
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const root = document.documentElement;
|
||||
THEMES.forEach(t => root.classList.toggle(t.value, t.value === this.theme()));
|
||||
root.classList.toggle('high-contrast', this.highContrast());
|
||||
root.classList.toggle('no-anim', !this.animation());
|
||||
save('theme', this.theme());
|
||||
save('contrast', this.highContrast() ? 'on' : 'off');
|
||||
save('animation', this.animation() ? 'on' : 'off');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function load(key: string, fallback: string): string {
|
||||
try { return localStorage.getItem('ui.' + key) ?? fallback; } catch { return fallback; }
|
||||
}
|
||||
|
||||
function save(key: string, value: string): void {
|
||||
try { localStorage.setItem('ui.' + key, value); } catch { /* storage blocked */ }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { LiveAnnouncer } from '@angular/cdk/a11y';
|
||||
|
||||
export interface Toast { id: number; text: string; kind: 'ok' | 'error'; }
|
||||
|
||||
/** Short messages in the corner; also read out by screen readers. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ToastService {
|
||||
private announcer = inject(LiveAnnouncer);
|
||||
private next = 1;
|
||||
readonly toasts = signal<Toast[]>([]);
|
||||
|
||||
ok(text: string): void { this.show(text, 'ok', 4000); }
|
||||
error(text: string): void { this.show(text, 'error', 8000); }
|
||||
|
||||
dismiss(id: number): void { this.toasts.update(list => list.filter(t => t.id !== id)); }
|
||||
|
||||
private show(text: string, kind: Toast['kind'], ms: number): void {
|
||||
const id = this.next++;
|
||||
this.toasts.update(list => [...list.slice(-3), { id, text, kind }]);
|
||||
this.announcer.announce(text, kind === 'error' ? 'assertive' : 'polite');
|
||||
setTimeout(() => this.dismiss(id), ms);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user