- Neu `account/profile.ts`: Muster, Sperrliste und Schlüssel (ein Test vergleicht sie mit `deploy/api/common.js`), Profile lesen (`getUsername`, `usernamesFor` gesammelt in einer Abfrage und zwischengespeichert, bei einem Fehler nur die bekannten Namen), `isUsernameTaken`, `saveUsername` (POST /api/profil, Zwischenspeicher + `onUsernameChange`), `myUsername`/`loadMyUsername` und der Merker `aircard.usernamePrompted`. `IDS.profiles`. - Neu `account/username-dialog.ts`: Dialog „Benutzername wählen“ in den Modi prompt („Später“), required („Abbrechen“) und change (vorbelegt), Fehler am Feld, Busy-Sperre, höchstens ein Dialog zur Zeit; dazu `requireUsername()`. - Registrieren: Pflichtfeld „Benutzername“ vor der E-Mail. Muster, Sperrliste und Verfügbarkeit werden vor dem Anlegen des Kontos geprüft (kein halbes Konto); danach wird der Name gespeichert, scheitert das, schließt der Login-Dialog und der Namensdialog fragt. - Konto-Menü: „@name“ oben, die E-Mail klein darunter, Eintrag „Benutzername ändern“ bzw. „Benutzername wählen“. - main.ts: Name nach Start und Anmeldung lesen; ohne Namen einmal pro Sitzung der Dialog „prompt“ (nicht, wenn der Name nicht lesbar ist). - Texte in `en.ts`, Stile in `account.css`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
132 lines
5.1 KiB
TypeScript
132 lines
5.1 KiB
TypeScript
// Konto-Menü im Seitenkopf: abgemeldet „Anmelden“, angemeldet ein Avatar-Kreis mit Initiale und einem Menü (Benutzername und E-Mail,
|
|
// „Meine Karten“, „Benutzername ändern“, „Abmelden“). Die Texte stehen in der Sprache der Seite; nach einem Sprachwechsel
|
|
// baut main.ts das Menü neu auf.
|
|
import './account.css';
|
|
import { el } from '../editor/dom';
|
|
import { t } from '../i18n/i18n';
|
|
import { currentUser, logout, onAuthChange } from './auth';
|
|
import { errorMessage } from './errors';
|
|
import { requireLogin } from './login-dialog';
|
|
import { myUsername, onUsernameChange } from './profile';
|
|
import { usernameDialog } from './username-dialog';
|
|
|
|
export interface AccountMenu {
|
|
/** Zeigt das Menü erst, wenn der Anmeldestatus bekannt ist (kein Aufblitzen von „Anmelden“ bei angemeldeten Nutzern). */
|
|
ready(): void;
|
|
dispose(): void;
|
|
}
|
|
|
|
export function mountAccountMenu(slot: HTMLElement): AccountMenu {
|
|
let isReady = false;
|
|
// Hebt die Dokument-Listener des gerade offenen Menüs wieder auf (null: kein Menü gezeichnet).
|
|
let detach: (() => void) | null = null;
|
|
|
|
function render(): void {
|
|
detach?.();
|
|
detach = null;
|
|
if (!isReady) {
|
|
slot.replaceChildren();
|
|
return;
|
|
}
|
|
const user = currentUser();
|
|
if (!user) {
|
|
const signIn = el('button', { type: 'button', class: 'auth-btn auth-btn-small' }, t('Anmelden'));
|
|
signIn.addEventListener('click', () => void requireLogin('login'));
|
|
slot.replaceChildren(el('div', { class: 'account-menu' }, signIn));
|
|
return;
|
|
}
|
|
|
|
const initial = Array.from(user.email)[0]?.toUpperCase() ?? '?';
|
|
const avatar = el(
|
|
'button',
|
|
{
|
|
type: 'button',
|
|
class: 'account-avatar',
|
|
'aria-label': t('Konto-Menü'),
|
|
'aria-expanded': 'false',
|
|
'aria-controls': 'account-dropdown',
|
|
},
|
|
initial,
|
|
);
|
|
// Mit Benutzernamen steht „@name“ oben und die E-Mail klein darunter; ohne Namen (noch nicht gelesen oder nicht gewählt) nur die E-Mail.
|
|
const name = myUsername();
|
|
const email = el('span', { class: 'account-email', title: user.email }, user.email);
|
|
const head = el(
|
|
'div',
|
|
{ class: 'account-head' },
|
|
...(name ? [el('span', { class: 'account-name', title: `@${name}` }, `@${name}`)] : []),
|
|
email,
|
|
);
|
|
const myCards = el('a', { class: 'account-item', href: '/karten', 'data-link': true }, t('Meine Karten'));
|
|
const changeName = el('button', { type: 'button', class: 'account-item' }, name ? t('Benutzername ändern') : t('Benutzername wählen'));
|
|
const signOut = el('button', { type: 'button', class: 'account-item' }, t('Abmelden'));
|
|
const error = el('span', { class: 'account-error', role: 'alert' });
|
|
const dropdown = el('div', { class: 'account-dropdown', id: 'account-dropdown', hidden: true }, head, myCards, changeName, signOut, error);
|
|
const wrap = el('div', { class: 'account-menu', role: 'group', 'aria-label': t('Konto') }, avatar, dropdown);
|
|
|
|
let isOpen = false;
|
|
function setOpen(open: boolean): void {
|
|
isOpen = open;
|
|
dropdown.hidden = !open;
|
|
avatar.setAttribute('aria-expanded', String(open));
|
|
}
|
|
avatar.addEventListener('click', () => setOpen(!isOpen));
|
|
|
|
const onDocumentClick = (event: MouseEvent) => {
|
|
if (!isOpen) return;
|
|
const target = event.target as Element | null;
|
|
if (target && wrap.contains(target) && !target.closest('a')) return; // nur Links schließen das Menü selbst
|
|
setOpen(false);
|
|
};
|
|
const onKeydown = (event: KeyboardEvent) => {
|
|
if (event.key !== 'Escape' || !isOpen) return;
|
|
setOpen(false);
|
|
avatar.focus();
|
|
};
|
|
document.addEventListener('click', onDocumentClick);
|
|
document.addEventListener('keydown', onKeydown);
|
|
detach = () => {
|
|
document.removeEventListener('click', onDocumentClick);
|
|
document.removeEventListener('keydown', onKeydown);
|
|
};
|
|
|
|
changeName.addEventListener('click', () => {
|
|
setOpen(false);
|
|
// Der Eintrag im geschlossenen (und nach dem Speichern neu gezeichneten) Menü kann den Fokus nicht halten: er geht an den Avatar.
|
|
usernameDialog({ mode: 'change' })
|
|
.catch((err: unknown) => console.error('Der Dialog „Benutzername“ konnte nicht geöffnet werden', err))
|
|
.finally(() => slot.querySelector<HTMLElement>('.account-avatar')?.focus());
|
|
});
|
|
|
|
signOut.addEventListener('click', () => {
|
|
signOut.disabled = true;
|
|
error.textContent = '';
|
|
// Bei Erfolg zeichnet onAuthChange das Menü neu; nur der Fehlerfall braucht Handarbeit.
|
|
logout().catch((err: unknown) => {
|
|
error.textContent = errorMessage(err, 'load');
|
|
signOut.disabled = false;
|
|
});
|
|
});
|
|
slot.replaceChildren(wrap);
|
|
}
|
|
|
|
const off = onAuthChange(render);
|
|
// Der Name steht fest oder ändert sich (gelesen nach dem Start, gespeichert im Dialog): das Menü zeigt gleich den neuen.
|
|
const offName = onUsernameChange(render);
|
|
render();
|
|
|
|
return {
|
|
ready() {
|
|
isReady = true;
|
|
render();
|
|
},
|
|
dispose() {
|
|
off();
|
|
offName();
|
|
detach?.();
|
|
detach = null;
|
|
slot.replaceChildren();
|
|
},
|
|
};
|
|
}
|