Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d9fc77201 | ||
|
|
1076cd4f3f | ||
|
|
315402edc8 |
@@ -1,6 +1,6 @@
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import { ApiService } from './api.service';
|
||||
import { Folder, Profile } from './models';
|
||||
import { Folder, MailboxEntry, Profile } from './models';
|
||||
|
||||
/** Folders and the profile, shared by the sidebar, the lists and the header; refreshed after changes. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
@@ -9,6 +9,8 @@ export class MailStateService {
|
||||
|
||||
readonly folders = signal<Folder[]>([]);
|
||||
readonly profile = signal<Profile | null>(null);
|
||||
/** own, shared and attached mailboxes of the login — sidebar combobox, Settings → MY MAILBOXES */
|
||||
readonly boxes = signal<MailboxEntry[]>([]);
|
||||
readonly inbox = computed(() => this.folders().find(f => f.system && f.name === 'INBOX') ?? null);
|
||||
readonly unreadTotal = computed(() => this.inbox()?.unreadCount ?? 0);
|
||||
|
||||
@@ -28,8 +30,13 @@ export class MailStateService {
|
||||
this.api.me().subscribe({ next: profile => this.profile.set(profile), error: () => {} });
|
||||
}
|
||||
|
||||
refreshBoxes(): void {
|
||||
this.api.myMailboxes().subscribe({ next: list => this.boxes.set(list), error: () => {} });
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.folders.set([]);
|
||||
this.profile.set(null);
|
||||
this.boxes.set([]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { ApiService } from '../../core/api.service';
|
||||
import { AuthService } from '../../core/auth.service';
|
||||
import { MailStateService } from '../../core/mail-state.service';
|
||||
import { MailboxEntry, errorText } from '../../core/models';
|
||||
import { DialogService } from '../../ui/dialog.service';
|
||||
import { ToastService } from '../../ui/toast.service';
|
||||
|
||||
/** Settings → MY MAILBOXES (everyone): mailboxes in the sidebar combobox — add one by its password, remove an added one. */
|
||||
@Component({
|
||||
selector: 'app-my-mailboxes-section',
|
||||
imports: [FormsModule, MatIconModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: { class: 'block' },
|
||||
template: `
|
||||
<section class="glass-panel static p-5 md:p-6 h-full" aria-labelledby="my-boxes-title">
|
||||
<h2 id="my-boxes-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">alternate_email</mat-icon> MY MAILBOXES
|
||||
</h2>
|
||||
<ul class="space-y-1 mb-4" aria-label="Your mailboxes">
|
||||
@for (b of state.boxes(); track b.email) {
|
||||
<li class="flex items-center gap-2 px-2 py-1 rounded-lg border border-cyan-500/15">
|
||||
<mat-icon class="!text-lg text-slate-400 shrink-0">{{ b.shared ? 'groups' : b.kind === 'OWN' ? 'person' : 'link' }}</mat-icon>
|
||||
<span class="flex-grow truncate font-mono text-sm text-slate-200">{{ b.email }}</span>
|
||||
<span class="text-[11px] font-mono text-slate-500">{{ b.kind === 'OWN' ? 'own' : b.shared ? 'shared' : 'added' }}</span>
|
||||
@if (b.kind !== 'OWN') {
|
||||
<button type="button" class="ghost-btn !py-1 hover:text-rose-400" (click)="leave(b)"
|
||||
[attr.aria-label]="'Remove ' + b.email + ' from my mailboxes'" title="Remove from my mailboxes">
|
||||
<mat-icon>close</mat-icon></button>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
<form class="grid grid-cols-1 md:grid-cols-[1fr_1fr_auto] gap-3 items-end" (ngSubmit)="attach()" aria-label="Add a mailbox">
|
||||
<div><label class="synth-label" for="att-email">Mailbox</label>
|
||||
<input id="att-email" name="attEmail" class="synth-input" [(ngModel)]="email" autocomplete="off" placeholder="name@domain"></div>
|
||||
<div><label class="synth-label" for="att-pw">Its password</label>
|
||||
<input id="att-pw" name="attPw" type="password" class="synth-input" [(ngModel)]="password" autocomplete="off"></div>
|
||||
<button type="submit" class="cyber-btn px-4 py-2 rounded-lg text-xs font-bold"><mat-icon>link</mat-icon> ADD</button>
|
||||
</form>
|
||||
</section>
|
||||
`
|
||||
})
|
||||
export class MyMailboxesSection {
|
||||
readonly state = inject(MailStateService);
|
||||
private api = inject(ApiService);
|
||||
private auth = inject(AuthService);
|
||||
private dialogs = inject(DialogService);
|
||||
private toasts = inject(ToastService);
|
||||
|
||||
email = '';
|
||||
password = '';
|
||||
|
||||
constructor() {
|
||||
this.state.refreshBoxes();
|
||||
}
|
||||
|
||||
attach(): void {
|
||||
const email = this.email.trim().toLowerCase();
|
||||
if (!email || !this.password) { this.toasts.error('Mailbox and its password'); return; }
|
||||
this.api.attachMailbox(email, this.password).subscribe({
|
||||
next: b => {
|
||||
this.email = this.password = '';
|
||||
this.toasts.ok(b.email + ' added — pick it in the MAILBOX list on the left');
|
||||
this.state.refreshBoxes();
|
||||
},
|
||||
error: e => this.toasts.error(errorText(e))
|
||||
});
|
||||
}
|
||||
|
||||
leave(b: MailboxEntry): void {
|
||||
this.dialogs.confirm(`Remove ${b.email}?`, b.shared
|
||||
? 'It disappears from your list. An administrator can give it back.'
|
||||
: 'It disappears from your list. Add it again with its password.', 'Remove', true).subscribe(ok => {
|
||||
if (ok === null) return;
|
||||
this.api.leaveMailbox(b.email).subscribe({
|
||||
next: () => {
|
||||
this.toasts.ok(b.email + ' removed');
|
||||
if (b.email === this.auth.email()) {
|
||||
// removed the one we are in — back to the own mailbox
|
||||
this.api.mailboxSession(this.state.boxes().find(x => x.kind === 'OWN')?.email ?? '').subscribe({
|
||||
next: session => { this.state.reset(); this.auth.switchTo(session); location.assign('/'); },
|
||||
error: () => location.assign('/')
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.state.refreshBoxes();
|
||||
},
|
||||
error: e => this.toasts.error(errorText(e))
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,12 @@ import { THEMES, ThemeService } from '../../ui/theme.service';
|
||||
import { ToastService } from '../../ui/toast.service';
|
||||
import { MailboxesSection } from './mailboxes.section';
|
||||
import { ListsSection } from './lists.section';
|
||||
import { MyMailboxesSection } from './my-mailboxes.section';
|
||||
|
||||
/** Profile name, password, storage, look. */
|
||||
@Component({
|
||||
selector: 'app-settings',
|
||||
imports: [DatePipe, FormsModule, MatIconModule, MailboxesSection, ListsSection],
|
||||
imports: [DatePipe, FormsModule, MatIconModule, MailboxesSection, ListsSection, MyMailboxesSection],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-5">
|
||||
@@ -93,7 +94,7 @@ import { ListsSection } from './lists.section';
|
||||
<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"
|
||||
[class.nav-active]="theme.theme() === t.value"
|
||||
(click)="theme.theme.set(t.value)">{{ t.label }}</button>
|
||||
}
|
||||
</div>
|
||||
@@ -112,6 +113,8 @@ import { ListsSection } from './lists.section';
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<app-my-mailboxes-section />
|
||||
|
||||
@if (state.profile()?.admin) {
|
||||
<app-mailboxes-section />
|
||||
<app-lists-section />
|
||||
|
||||
@@ -81,43 +81,15 @@ import { ToastService } from '../../ui/toast.service';
|
||||
<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 md:pt-6 h-fit lg:sticky lg:top-20 lg:block" [class.hidden]="!menuOpen()"
|
||||
aria-label="Mail folders">
|
||||
<div class="flex items-center justify-between mb-2 px-1">
|
||||
<h2 class="text-[11px] font-orbitron font-bold text-cyan-300 tracking-widest">MAILBOXES</h2>
|
||||
<button type="button" class="text-slate-400 hover:text-cyan-300" matTooltip="Add a mailbox you have the password of"
|
||||
aria-label="Add mailbox" [attr.aria-expanded]="attachOpen()" (click)="attachOpen.set(!attachOpen())">
|
||||
<mat-icon class="!text-xl">add_circle_outline</mat-icon></button>
|
||||
</div>
|
||||
<ul class="space-y-1 mb-3" aria-label="Your mailboxes">
|
||||
@for (b of boxes(); track b.email) {
|
||||
<li class="flex items-center gap-1">
|
||||
<button type="button" (click)="switchTo(b)" [attr.aria-current]="b.email === auth.email() ? 'true' : null"
|
||||
class="flex-grow flex items-center gap-2 px-3 py-1.5 rounded-lg border text-left font-rajdhani font-semibold min-w-0"
|
||||
[class.border-pink-500/40]="b.email === auth.email()" [class.bg-pink-500/15]="b.email === auth.email()"
|
||||
[class.text-pink-200]="b.email === auth.email()" [class.border-transparent]="b.email !== auth.email()"
|
||||
[class.text-slate-300]="b.email !== auth.email()"
|
||||
[matTooltip]="b.email + (b.shared ? ' — shared' : b.kind === 'ATTACHED' ? ' — attached' : '')">
|
||||
<mat-icon class="!text-lg shrink-0">{{ b.shared ? 'groups' : b.kind === 'OWN' ? 'person' : 'link' }}</mat-icon>
|
||||
<span class="flex-grow truncate text-sm">{{ b.email }}</span>
|
||||
@if (b.unread) {
|
||||
<span class="text-[11px] font-mono px-2 rounded-full bg-pink-500/25 text-pink-300" [attr.aria-label]="b.unread + ' unread'">{{ b.unread }}</span>
|
||||
}
|
||||
</button>
|
||||
@if (b.kind !== 'OWN') {
|
||||
<button type="button" class="text-slate-500 hover:text-pink-400" [attr.aria-label]="'Remove ' + b.email + ' from my mailboxes'"
|
||||
matTooltip="Remove from my mailboxes" (click)="leave(b)"><mat-icon class="!text-base">close</mat-icon></button>
|
||||
}
|
||||
</li>
|
||||
<label for="mailbox-pick" class="block text-[11px] font-orbitron font-bold text-cyan-300 tracking-widest mb-2 px-1">MAILBOX</label>
|
||||
<select id="mailbox-pick" class="synth-input !py-1.5 !text-sm font-mono mb-4" [ngModel]="auth.email()"
|
||||
(ngModelChange)="pick($event)" [disabled]="state.boxes().length < 2">
|
||||
@for (b of state.boxes(); track b.email) {
|
||||
<option [value]="b.email">{{ b.email }}{{ b.shared ? ' · shared' : b.kind === 'ATTACHED' ? ' · attached' : '' }}{{ b.unread ? ' (' + b.unread + ')' : '' }}</option>
|
||||
} @empty {
|
||||
<option [value]="auth.email()">{{ auth.email() }}</option>
|
||||
}
|
||||
</ul>
|
||||
@if (attachOpen()) {
|
||||
<form class="space-y-2 mb-4 p-2 rounded-lg border border-cyan-500/20" (ngSubmit)="attach()" aria-label="Add a mailbox">
|
||||
<label class="synth-label" for="att-email">Mailbox</label>
|
||||
<input id="att-email" name="attEmail" class="synth-input !py-1.5" [(ngModel)]="attachEmail" autocomplete="off" placeholder="name@domain">
|
||||
<label class="synth-label" for="att-pw">Its password</label>
|
||||
<input id="att-pw" name="attPw" type="password" class="synth-input !py-1.5" [(ngModel)]="attachPassword" autocomplete="off">
|
||||
<button type="submit" class="cyber-btn w-full py-1.5 rounded-lg text-xs font-bold"><mat-icon>link</mat-icon> ADD</button>
|
||||
</form>
|
||||
}
|
||||
</select>
|
||||
|
||||
<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
|
||||
@@ -126,7 +98,7 @@ import { ToastService } from '../../ui/toast.service';
|
||||
<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"
|
||||
<a [routerLink]="['/folder', folder.id]" routerLinkActive="nav-active"
|
||||
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>
|
||||
@@ -148,7 +120,7 @@ import { ToastService } from '../../ui/toast.service';
|
||||
<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"
|
||||
<a [routerLink]="['/folder', folder.id]" routerLinkActive="nav-active"
|
||||
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>
|
||||
@@ -166,7 +138,7 @@ import { ToastService } from '../../ui/toast.service';
|
||||
|
||||
<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"
|
||||
<a [routerLink]="link.path" routerLinkActive="nav-active-text"
|
||||
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 }}
|
||||
@@ -203,11 +175,6 @@ export class ShellComponent {
|
||||
|
||||
readonly themes = THEMES;
|
||||
readonly menuOpen = signal(false);
|
||||
/** own, shared and attached mailboxes of the login (shown when there is more than the own one, or to add one) */
|
||||
readonly boxes = signal<MailboxEntry[]>([]);
|
||||
readonly attachOpen = signal(false);
|
||||
attachEmail = '';
|
||||
attachPassword = '';
|
||||
query = '';
|
||||
|
||||
readonly links = [
|
||||
@@ -238,8 +205,8 @@ export class ShellComponent {
|
||||
constructor() {
|
||||
this.state.refreshFolders();
|
||||
this.state.refreshProfile();
|
||||
this.loadBoxes();
|
||||
const timer = setInterval(() => { this.state.refreshFolders(); this.loadBoxes(); }, 30000);
|
||||
this.state.refreshBoxes();
|
||||
const timer = setInterval(() => { this.state.refreshFolders(); this.state.refreshBoxes(); }, 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'];
|
||||
@@ -300,10 +267,6 @@ export class ShellComponent {
|
||||
}
|
||||
}
|
||||
|
||||
loadBoxes(): void {
|
||||
this.api.myMailboxes().subscribe({ next: list => this.boxes.set(list), error: () => {} });
|
||||
}
|
||||
|
||||
/** Another mailbox of this login: its session, full reload — fresh folders and profile. */
|
||||
switchTo(b: MailboxEntry): void {
|
||||
if (b.email === this.auth.email()) { this.router.navigate(['/']); return; }
|
||||
@@ -313,37 +276,9 @@ export class ShellComponent {
|
||||
});
|
||||
}
|
||||
|
||||
attach(): void {
|
||||
const email = this.attachEmail.trim().toLowerCase();
|
||||
if (!email || !this.attachPassword) { this.toasts.error('Mailbox and its password'); return; }
|
||||
this.api.attachMailbox(email, this.attachPassword).subscribe({
|
||||
next: b => {
|
||||
this.attachEmail = this.attachPassword = '';
|
||||
this.attachOpen.set(false);
|
||||
this.toasts.ok(b.email + ' added to your mailboxes');
|
||||
this.loadBoxes();
|
||||
},
|
||||
error: e => this.toasts.error(errorText(e))
|
||||
});
|
||||
}
|
||||
|
||||
leave(b: MailboxEntry): void {
|
||||
this.dialogs.confirm(`Remove ${b.email}?`, b.shared
|
||||
? 'It disappears from your menu. An administrator can give it back.'
|
||||
: 'It disappears from your menu. Add it again with its password.', 'Remove', true).subscribe(ok => {
|
||||
if (ok === null) return;
|
||||
this.api.leaveMailbox(b.email).subscribe({
|
||||
next: () => {
|
||||
this.toasts.ok(b.email + ' removed');
|
||||
if (b.email === this.auth.email()) {
|
||||
const own = this.boxes().find(x => x.kind === 'OWN');
|
||||
if (own) { this.switchTo(own); return; }
|
||||
}
|
||||
this.loadBoxes();
|
||||
},
|
||||
error: e => this.toasts.error(errorText(e))
|
||||
});
|
||||
});
|
||||
pick(email: string): void {
|
||||
const b = this.state.boxes().find(x => x.email === email);
|
||||
if (b) this.switchTo(b);
|
||||
}
|
||||
|
||||
backToAdmin(): void {
|
||||
|
||||
@@ -300,6 +300,19 @@ body {
|
||||
}
|
||||
.synth-input:focus { outline: none; border-color: var(--primary-color); box-shadow: 0 0 0 2px rgba(255, 0, 127, 0.45); }
|
||||
.synth-input::placeholder { color: #64748b; }
|
||||
/* Selected item of the sidebar (folder, link) — in the colour of the theme, not a fixed pink */
|
||||
.nav-active {
|
||||
background-color: color-mix(in srgb, var(--primary-color) 18%, transparent) !important;
|
||||
border-color: color-mix(in srgb, var(--primary-color) 45%, transparent) !important;
|
||||
color: var(--primary-color) !important;
|
||||
}
|
||||
.nav-active-text { color: var(--primary-color) !important; }
|
||||
|
||||
/* Selects: the opened list is drawn by the browser — without this it is white (light OS theme) with our light text */
|
||||
select { color-scheme: dark; }
|
||||
select option, select optgroup { background-color: #0f172a; color: #e2e8f0; }
|
||||
select option:checked { background-color: var(--primary-color); color: #020617; }
|
||||
.high-contrast select option { background-color: #000000; color: #ffffff; }
|
||||
.synth-label { display: block; font-family: 'Fira Code', monospace; font-size: 0.72rem; color: var(--primary-color); margin-bottom: 0.3rem; letter-spacing: 0.04em; }
|
||||
|
||||
.ghost-btn {
|
||||
|
||||
Reference in New Issue
Block a user