Почтовый кабинет: регистрация, папки, черновики, вложения, грид @jambotron/aria-grid, интерфейс по synthwave-шаблону
Бэкенд: регистрация со входом сразу и /api/config, профиль и смена пароля, свои папки, PATCH письма (прочитано, флажок, перенос; Спам ставит отметку), корзина и очистка, черновики, несколько адресатов и копия, вложения черновика уходят с письмом, вложения и заголовки To/Cc входящих сохраняются, письма заблокированных — в Спам. Исправлено: загрузка файла (415), колонка content_id (500 на любых вложениях), SPA-маршрут с точкой. Фронтенд переписан: все таблицы — грид, оформление — шаблон synthwave-design-showcase.html. α Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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)); }
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user