Files
AirCardIOS/web/src/account/account-menu.ts
T
ClaudeandClaude Opus 5.5 3e4bc21f00 feat(account): Benutzername beim Registrieren, im Konto-Menü und als einmalige Abfrage
- 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>
2026-10-05 07:22:15 +00:00

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();
},
};
}