feat(shell): Routen, Kopfzeile, Hell/Dunkel, Spenden-Banner

- Router: Routen home, gestalten, cards, community/<id>, reset; Umleitung
  /?karte=<id> nach /gestalten?karte=<id>; Titel je Route (titleFor);
  Hash-Ziele (/#community) scrollen den Anker ins Bild, nur-Hash-Wechsel
  bauen die Seite nicht neu auf.
- Kopfzeile: Navigation, Suche (Enter -> /?q=...#community), Spenden-Knopf,
  Hell/Dunkel-Schalter, Menue unter 900 px, Konto als Avatar-Kreis mit Menue.
- Theme: data-theme auf <html> (Skript im <head> gegen Aufblitzen), helle
  Tokens, danger-/warn-/ok-text-Tokens statt fester Farben.
- Spenden-Banner (schliessbar); ohne DONATE_URL bleiben Banner und Knopf weg.
- User.labels und isAdmin; Platzhalter fuer Startseite und Detailseite.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 13:39:09 +00:00
1 parent 1a64e12bc5
commit 25ff496548
30 files changed
+1783 -189

No files matched your search

+15 -4
View File
@@ -3,19 +3,30 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AirCard-IOS – Kartenmotiv gestalten</title> <title>AirCard-IOS – Kartendesigns für Apple Wallet</title>
<meta name="theme-color" content="#0F1828" /> <meta name="theme-color" content="#0F1828" />
<meta name="apple-mobile-web-app-title" content="AirCard-IOS" /> <meta name="apple-mobile-web-app-title" content="AirCard-IOS" />
<link rel="icon" href="/favicon.svg?v=2" type="image/svg+xml" /> <link rel="icon" href="/favicon.svg?v=2" type="image/svg+xml" />
<link rel="icon" href="/favicon-32.png?v=2" sizes="32x32" type="image/png" /> <link rel="icon" href="/favicon-32.png?v=2" sizes="32x32" type="image/png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png?v=2" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png?v=2" />
<script>
// Hell/Dunkel vor dem ersten Paint setzen (kein Aufblitzen); dieselbe Regel wie initialTheme() in src/shell/theme.ts.
try {
var theme = null;
try {
theme = localStorage.getItem('aircard.theme');
} catch (e) {}
if (theme !== 'light' && theme !== 'dark') {
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
document.documentElement.setAttribute('data-theme', theme);
} catch (e) {}
</script>
</head> </head>
<body> <body>
<div id="donate-banner"></div>
<header id="app-header"> <header id="app-header">
<a class="brand" href="/" data-link><img src="/favicon.svg?v=2" alt="" width="28" height="28" /><span>AirCard-IOS</span></a> <a class="brand" href="/" data-link><img src="/favicon.svg?v=2" alt="" width="28" height="28" /><span>AirCard-IOS</span></a>
<nav class="app-nav" aria-label="Hauptnavigation">
<a href="/karten" data-link>Meine Karten</a>
</nav>
<div id="account-slot"></div> <div id="account-slot"></div>
</header> </header>
<main id="app-root"></main> <main id="app-root"></main>
+140
View File
@@ -0,0 +1,140 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
type TestUser = { $id: string; email: string; labels?: string[] };
const mocks = vi.hoisted(() => ({
user: null as TestUser | null,
listeners: new Set<(user: TestUser | null) => void>(),
logout: vi.fn(),
requireLogin: vi.fn(),
}));
vi.mock('./auth', () => ({
currentUser: () => mocks.user,
logout: mocks.logout,
onAuthChange: (fn: (user: TestUser | null) => void) => {
mocks.listeners.add(fn);
return () => mocks.listeners.delete(fn);
},
}));
vi.mock('./login-dialog', () => ({ requireLogin: mocks.requireLogin }));
import { mountAccountMenu, type AccountMenu } from './account-menu';
let slot: HTMLElement;
let menu: AccountMenu;
const avatar = () => slot.querySelector<HTMLButtonElement>('button.account-avatar');
const panel = () => slot.querySelector<HTMLElement>('.account-dropdown');
const buttonByText = (text: string) => [...slot.querySelectorAll('button')].find((b) => b.textContent === text);
function setUser(user: TestUser | null) {
mocks.user = user;
for (const fn of [...mocks.listeners]) fn(user);
}
beforeEach(() => {
mocks.user = null;
mocks.listeners.clear();
mocks.logout.mockReset();
mocks.requireLogin.mockReset();
document.body.innerHTML = '<div id="account-slot"></div>';
slot = document.getElementById('account-slot') as HTMLElement;
menu = mountAccountMenu(slot);
});
describe('Konto-Menü', () => {
it('zeigt vor ready() nichts (kein Aufblitzen von „Anmelden“)', () => {
expect(slot.childElementCount).toBe(0);
});
it('abgemeldet: „Anmelden“ öffnet den Login-Dialog', () => {
menu.ready();
buttonByText('Anmelden')!.click();
expect(mocks.requireLogin).toHaveBeenCalledWith('login');
expect(avatar()).toBeNull();
});
it('angemeldet: Avatar-Kreis mit Initiale, Menü zunächst zu', () => {
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
menu.ready();
expect(avatar()?.textContent).toBe('K');
expect(avatar()?.getAttribute('aria-expanded')).toBe('false');
expect(avatar()?.getAttribute('aria-label')).toBe('Konto-Menü');
expect(panel()?.hidden).toBe(true);
});
it('Klick öffnet das Menü: E-Mail, „Meine Karten“, „Abmelden“', () => {
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
menu.ready();
avatar()!.click();
expect(avatar()?.getAttribute('aria-expanded')).toBe('true');
expect(panel()?.hidden).toBe(false);
expect(panel()?.querySelector('.account-email')?.textContent).toBe('kenso@example.com');
const link = panel()!.querySelector<HTMLAnchorElement>('a[data-link]')!;
expect(link.textContent).toBe('Meine Karten');
expect(link.getAttribute('href')).toBe('/karten');
expect(buttonByText('Abmelden')).toBeDefined();
});
it('Escape schließt das Menü und gibt den Fokus an den Avatar', () => {
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
menu.ready();
avatar()!.click();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(panel()?.hidden).toBe(true);
expect(avatar()?.getAttribute('aria-expanded')).toBe('false');
expect(document.activeElement).toBe(avatar());
});
it('Klick außerhalb oder auf „Meine Karten“ schließt das Menü', () => {
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
menu.ready();
avatar()!.click();
document.body.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(panel()?.hidden).toBe(true);
avatar()!.click();
panel()!.querySelector('a')!.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
expect(panel()?.hidden).toBe(true);
});
it('Klick im Menü selbst (auf die E-Mail) schließt es nicht', () => {
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
menu.ready();
avatar()!.click();
panel()!.querySelector('.account-email')!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(panel()?.hidden).toBe(false);
});
it('Abmelden ruft logout; bei einem Fehler bleibt das Menü offen und zeigt die Meldung', async () => {
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
mocks.logout.mockRejectedValueOnce(new TypeError('offline'));
menu.ready();
avatar()!.click();
const signOut = buttonByText('Abmelden')!;
signOut.click();
expect(mocks.logout).toHaveBeenCalledOnce();
expect(signOut.disabled).toBe(true);
await vi.waitFor(() => expect(signOut.disabled).toBe(false));
expect(slot.querySelector('[role="alert"]')?.textContent).toBe('Keine Verbindung zum Server.');
expect(panel()?.hidden).toBe(false);
});
it('nach dem Abmelden (Auth-Änderung) steht wieder „Anmelden“ da', () => {
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
menu.ready();
avatar()!.click();
setUser(null);
expect(avatar()).toBeNull();
expect(buttonByText('Anmelden')).toBeDefined();
});
it('dispose leert den Slot und meldet den Listener ab', () => {
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
menu.ready();
menu.dispose();
expect(slot.childElementCount).toBe(0);
expect(mocks.listeners.size).toBe(0);
});
});
+53 -10
View File
@@ -1,4 +1,5 @@
// Konto-Menü im Seitenkopf: abgemeldet „Anmelden“, angemeldet E-Mail und „Abmelden“. // Konto-Menü im Seitenkopf: abgemeldet „Anmelden“, angemeldet ein Avatar-Kreis mit Initiale
// und einem Menü (E-Mail, „Meine Karten“, „Abmelden“).
import './account.css'; import './account.css';
import { el } from '../editor/dom'; import { el } from '../editor/dom';
import { currentUser, logout, onAuthChange } from './auth'; import { currentUser, logout, onAuthChange } from './auth';
@@ -13,8 +14,12 @@ export interface AccountMenu {
export function mountAccountMenu(slot: HTMLElement): AccountMenu { export function mountAccountMenu(slot: HTMLElement): AccountMenu {
let isReady = false; 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 { function render(): void {
detach?.();
detach = null;
if (!isReady) { if (!isReady) {
slot.replaceChildren(); slot.replaceChildren();
return; return;
@@ -26,9 +31,52 @@ export function mountAccountMenu(slot: HTMLElement): AccountMenu {
slot.replaceChildren(el('div', { class: 'account-menu' }, signIn)); slot.replaceChildren(el('div', { class: 'account-menu' }, signIn));
return; return;
} }
const initial = Array.from(user.email)[0]?.toUpperCase() ?? '?';
const avatar = el(
'button',
{
type: 'button',
class: 'account-avatar',
'aria-label': 'Konto-Menü',
'aria-expanded': 'false',
'aria-controls': 'account-dropdown',
},
initial,
);
const email = el('span', { class: 'account-email', title: user.email }, user.email); const email = el('span', { class: 'account-email', title: user.email }, user.email);
const signOut = el('button', { type: 'button', class: 'auth-btn auth-btn-small' }, 'Abmelden'); const myCards = el('a', { class: 'account-item', href: '/karten', 'data-link': true }, 'Meine Karten');
const signOut = el('button', { type: 'button', class: 'account-item' }, 'Abmelden');
const error = el('span', { class: 'account-error', role: 'alert' }); const error = el('span', { class: 'account-error', role: 'alert' });
const dropdown = el('div', { class: 'account-dropdown', id: 'account-dropdown', hidden: true }, email, myCards, signOut, error);
const wrap = el('div', { class: 'account-menu', role: 'group', 'aria-label': '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);
};
signOut.addEventListener('click', () => { signOut.addEventListener('click', () => {
signOut.disabled = true; signOut.disabled = true;
error.textContent = ''; error.textContent = '';
@@ -38,14 +86,7 @@ export function mountAccountMenu(slot: HTMLElement): AccountMenu {
signOut.disabled = false; signOut.disabled = false;
}); });
}); });
slot.replaceChildren( slot.replaceChildren(wrap);
el(
'div',
{ class: 'account-menu', role: 'group', 'aria-label': 'Konto' },
el('div', { class: 'account-row' }, email, signOut),
error,
),
);
} }
const off = onAuthChange(render); const off = onAuthChange(render);
@@ -58,6 +99,8 @@ export function mountAccountMenu(slot: HTMLElement): AccountMenu {
}, },
dispose() { dispose() {
off(); off();
detach?.();
detach = null;
slot.replaceChildren(); slot.replaceChildren();
}, },
}; };
+86 -38
View File
@@ -25,6 +25,7 @@
.auth-btn-primary { .auth-btn-primary {
border-color: var(--primary); border-color: var(--primary);
background: var(--primary); background: var(--primary);
color: var(--on-primary);
font-weight: 700; font-weight: 700;
} }
@@ -35,7 +36,7 @@
.auth-btn-danger { .auth-btn-danger {
border-color: var(--danger); border-color: var(--danger);
color: #ff9a9d; color: var(--danger-text);
} }
.auth-btn-danger:hover:not(:disabled) { .auth-btn-danger:hover:not(:disabled) {
@@ -70,43 +71,104 @@
cursor: not-allowed; cursor: not-allowed;
} }
/* Konto-Menü im Seitenkopf */ /* Konto-Menü im Seitenkopf: „Anmelden“ bzw. Avatar-Kreis mit aufklappbarem Menü */
#account-slot {
min-width: 0;
}
.account-menu { .account-menu {
position: relative;
display: flex; display: flex;
flex-direction: column; align-items: center;
align-items: flex-end;
gap: 4px;
min-width: 0; min-width: 0;
max-width: 100%; max-width: 100%;
} }
.account-row { .account-avatar {
appearance: none;
display: grid;
place-items: center;
width: 40px;
height: 40px;
margin: 0;
padding: 0;
border: 2px solid transparent;
border-radius: 50%;
background: var(--primary);
color: var(--on-primary);
font: inherit;
font-size: 1.05rem;
font-weight: 800;
line-height: 1;
cursor: pointer;
transition: border-color 0.12s;
}
.account-avatar:hover,
.account-avatar[aria-expanded='true'] {
border-color: var(--sel-line);
}
.account-dropdown {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 40;
display: flex; display: flex;
align-items: center; flex-direction: column;
gap: 12px; gap: 2px;
min-width: 0; min-width: 220px;
max-width: 100%; max-width: min(320px, calc(100vw - 32px));
padding: 8px;
border: 1px solid var(--line-strong);
border-radius: 14px;
background: var(--panel);
box-shadow: var(--shadow-lg);
}
.account-dropdown[hidden] {
display: none;
} }
.account-email { .account-email {
min-width: 0; min-width: 0;
max-width: 28ch; margin-bottom: 4px;
padding: 6px 12px 10px;
overflow: hidden; overflow: hidden;
border-bottom: 1px solid var(--line);
color: var(--muted); color: var(--muted);
font-size: 0.95rem; font-size: 0.9rem;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.account-item {
appearance: none;
display: block;
width: 100%;
margin: 0;
padding: 10px 12px;
border: 0;
border-radius: 10px;
background: transparent;
color: var(--text);
font: inherit;
font-weight: 600;
text-align: left;
text-decoration: none;
cursor: pointer;
}
.account-item:hover:not(:disabled) {
background: var(--panel-2);
}
.account-item:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.account-error { .account-error {
color: #ff9a9d; padding: 4px 12px 0;
color: var(--danger-text);
font-size: 0.85rem; font-size: 0.85rem;
text-align: right;
} }
.account-error:empty { .account-error:empty {
@@ -124,11 +186,11 @@
border-radius: 24px; border-radius: 24px;
background: var(--panel); background: var(--panel);
color: var(--text); color: var(--text);
box-shadow: 0 24px 80px rgb(0 0 0 / 0.55); box-shadow: var(--shadow-lg);
} }
.auth-dialog::backdrop { .auth-dialog::backdrop {
background: rgb(6 11 20 / 0.72); background: var(--backdrop);
} }
.auth-card { .auth-card {
@@ -261,11 +323,11 @@
} }
.auth-msg-error { .auth-msg-error {
color: #ff9a9d; color: var(--danger-text);
} }
.auth-msg-ok { .auth-msg-ok {
color: #8fd9a8; color: var(--ok-text);
} }
.auth-aside { .auth-aside {
@@ -289,29 +351,15 @@
margin: 0; margin: 0;
} }
/* Schmale Bildschirme: Konto-Menü rutscht bei Bedarf in eine zweite Kopfzeile */
@media (max-width: 599px) { @media (max-width: 599px) {
#app-header {
flex-wrap: wrap;
gap: 8px 16px;
}
#account-slot {
max-width: 100%;
}
.account-email {
max-width: none;
}
.auth-card { .auth-card {
padding: 24px 20px 20px; padding: 24px 20px 20px;
} }
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.auth-btn { .auth-btn,
.account-avatar {
transition: none; transition: none;
} }
} }
+54 -8
View File
@@ -29,7 +29,7 @@ describe('getCurrentUser', () => {
it('liefert den Nutzer und merkt sich die ID', async () => { it('liefert den Nutzer und merkt sich die ID', async () => {
const auth = await freshAuth(); const auth = await freshAuth();
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de', name: 'a' }); mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de', name: 'a' });
await expect(auth.getCurrentUser()).resolves.toEqual({ $id: 'u1', email: 'a@b.de' }); await expect(auth.getCurrentUser()).resolves.toEqual({ $id: 'u1', email: 'a@b.de', labels: [] });
expect(auth.currentUserId()).toBe('u1'); expect(auth.currentUserId()).toBe('u1');
}); });
@@ -71,7 +71,7 @@ describe('getCurrentUser', () => {
await auth.getCurrentUser(); await auth.getCurrentUser();
await auth.getCurrentUser(); await auth.getCurrentUser();
expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenLastCalledWith({ $id: 'u1', email: 'a@b.de' }); expect(listener).toHaveBeenLastCalledWith({ $id: 'u1', email: 'a@b.de', labels: [] });
mocks.get.mockRejectedValue(fail(401, 'general_unauthorized_scope')); mocks.get.mockRejectedValue(fail(401, 'general_unauthorized_scope'));
await auth.getCurrentUser(); await auth.getCurrentUser();
await auth.getCurrentUser(); await auth.getCurrentUser();
@@ -80,6 +80,52 @@ describe('getCurrentUser', () => {
}); });
}); });
describe('Labels und isAdmin', () => {
it('getCurrentUser übernimmt die Labels; fehlen sie (oder sind sie kein Array aus Texten), sind es []', async () => {
const auth = await freshAuth();
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de', labels: ['admin', 'x'] });
await expect(auth.getCurrentUser()).resolves.toEqual({ $id: 'u1', email: 'a@b.de', labels: ['admin', 'x'] });
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de' });
await expect(auth.getCurrentUser()).resolves.toMatchObject({ labels: [] });
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de', labels: 'admin' });
await expect(auth.getCurrentUser()).resolves.toMatchObject({ labels: [] });
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de', labels: ['admin', 7, null] });
await expect(auth.getCurrentUser()).resolves.toMatchObject({ labels: ['admin'] });
});
it('login liefert die Labels ebenfalls', async () => {
const auth = await freshAuth();
mocks.createEmailPasswordSession.mockResolvedValue({});
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de', labels: ['admin'] });
await expect(auth.login('a@b.de', 'geheim123')).resolves.toMatchObject({ labels: ['admin'] });
expect(auth.currentUser()?.labels).toEqual(['admin']);
});
it('eine Änderung nur der Labels benachrichtigt die Abonnenten', async () => {
const auth = await freshAuth();
const listener = vi.fn();
auth.onAuthChange(listener);
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de', labels: [] });
await auth.getCurrentUser();
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de', labels: [] });
await auth.getCurrentUser();
expect(listener).toHaveBeenCalledTimes(1);
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de', labels: ['admin'] });
await auth.getCurrentUser();
expect(listener).toHaveBeenCalledTimes(2);
expect(listener).toHaveBeenLastCalledWith({ $id: 'u1', email: 'a@b.de', labels: ['admin'] });
});
it('isAdmin ist nur mit dem Label „admin“ wahr', async () => {
const auth = await freshAuth();
expect(auth.isAdmin(null)).toBe(false);
expect(auth.isAdmin({ $id: 'u1', email: 'a@b.de' })).toBe(false);
expect(auth.isAdmin({ $id: 'u1', email: 'a@b.de', labels: [] })).toBe(false);
expect(auth.isAdmin({ $id: 'u1', email: 'a@b.de', labels: ['administrator', 'Admin'] })).toBe(false);
expect(auth.isAdmin({ $id: 'u1', email: 'a@b.de', labels: ['x', 'admin'] })).toBe(true);
});
});
describe('login', () => { describe('login', () => {
it('legt die Sitzung an, holt den Nutzer und benachrichtigt', async () => { it('legt die Sitzung an, holt den Nutzer und benachrichtigt', async () => {
const auth = await freshAuth(); const auth = await freshAuth();
@@ -87,10 +133,10 @@ describe('login', () => {
auth.onAuthChange(listener); auth.onAuthChange(listener);
mocks.createEmailPasswordSession.mockResolvedValue({}); mocks.createEmailPasswordSession.mockResolvedValue({});
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' }); mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' });
await expect(auth.login('a@b.de', 'geheim123')).resolves.toEqual({ $id: 'u1', email: 'a@b.de' }); await expect(auth.login('a@b.de', 'geheim123')).resolves.toEqual({ $id: 'u1', email: 'a@b.de', labels: [] });
expect(mocks.createEmailPasswordSession).toHaveBeenCalledWith({ email: 'a@b.de', password: 'geheim123' }); expect(mocks.createEmailPasswordSession).toHaveBeenCalledWith({ email: 'a@b.de', password: 'geheim123' });
expect(auth.currentUserId()).toBe('u1'); expect(auth.currentUserId()).toBe('u1');
expect(listener).toHaveBeenCalledWith({ $id: 'u1', email: 'a@b.de' }); expect(listener).toHaveBeenCalledWith({ $id: 'u1', email: 'a@b.de', labels: [] });
}); });
it('bei falschem Passwort fliegt der Fehler weiter und nichts ändert sich', async () => { it('bei falschem Passwort fliegt der Fehler weiter und nichts ändert sich', async () => {
@@ -114,7 +160,7 @@ describe('login bei bereits aktiver Sitzung (user_session_already_exists)', () =
mocks.createEmailPasswordSession.mockRejectedValueOnce(existing()).mockResolvedValueOnce({}); mocks.createEmailPasswordSession.mockRejectedValueOnce(existing()).mockResolvedValueOnce({});
mocks.deleteSession.mockResolvedValue({}); mocks.deleteSession.mockResolvedValue({});
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' }); mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' });
await expect(auth.login('a@b.de', 'geheim123')).resolves.toEqual({ $id: 'u1', email: 'a@b.de' }); await expect(auth.login('a@b.de', 'geheim123')).resolves.toEqual({ $id: 'u1', email: 'a@b.de', labels: [] });
expect(mocks.deleteSession).toHaveBeenCalledTimes(1); expect(mocks.deleteSession).toHaveBeenCalledTimes(1);
expect(mocks.deleteSession).toHaveBeenCalledWith({ sessionId: 'current' }); expect(mocks.deleteSession).toHaveBeenCalledWith({ sessionId: 'current' });
expect(mocks.createEmailPasswordSession).toHaveBeenCalledTimes(2); expect(mocks.createEmailPasswordSession).toHaveBeenCalledTimes(2);
@@ -127,7 +173,7 @@ describe('login bei bereits aktiver Sitzung (user_session_already_exists)', () =
mocks.createEmailPasswordSession.mockRejectedValueOnce(existing()).mockResolvedValueOnce({}); mocks.createEmailPasswordSession.mockRejectedValueOnce(existing()).mockResolvedValueOnce({});
mocks.deleteSession.mockRejectedValue(fail(401, 'general_unauthorized_scope')); mocks.deleteSession.mockRejectedValue(fail(401, 'general_unauthorized_scope'));
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' }); mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' });
await expect(auth.login('a@b.de', 'geheim123')).resolves.toEqual({ $id: 'u1', email: 'a@b.de' }); await expect(auth.login('a@b.de', 'geheim123')).resolves.toEqual({ $id: 'u1', email: 'a@b.de', labels: [] });
expect(mocks.createEmailPasswordSession).toHaveBeenCalledTimes(2); expect(mocks.createEmailPasswordSession).toHaveBeenCalledTimes(2);
}); });
@@ -177,7 +223,7 @@ describe('login bei bereits aktiver Sitzung (user_session_already_exists)', () =
mocks.createEmailPasswordSession.mockRejectedValueOnce(existing()).mockResolvedValueOnce({}); mocks.createEmailPasswordSession.mockRejectedValueOnce(existing()).mockResolvedValueOnce({});
mocks.deleteSession.mockResolvedValue({}); mocks.deleteSession.mockResolvedValue({});
mocks.get.mockResolvedValue({ $id: 'u2', email: 'neu@b.de' }); mocks.get.mockResolvedValue({ $id: 'u2', email: 'neu@b.de' });
await expect(auth.register('neu@b.de', 'geheim123')).resolves.toEqual({ $id: 'u2', email: 'neu@b.de' }); await expect(auth.register('neu@b.de', 'geheim123')).resolves.toEqual({ $id: 'u2', email: 'neu@b.de', labels: [] });
expect(mocks.deleteSession).toHaveBeenCalledTimes(1); expect(mocks.deleteSession).toHaveBeenCalledTimes(1);
}); });
}); });
@@ -188,7 +234,7 @@ describe('register', () => {
mocks.create.mockResolvedValue({}); mocks.create.mockResolvedValue({});
mocks.createEmailPasswordSession.mockResolvedValue({}); mocks.createEmailPasswordSession.mockResolvedValue({});
mocks.get.mockResolvedValue({ $id: 'u2', email: 'neu@b.de' }); mocks.get.mockResolvedValue({ $id: 'u2', email: 'neu@b.de' });
await expect(auth.register('neu@b.de', 'geheim123')).resolves.toEqual({ $id: 'u2', email: 'neu@b.de' }); await expect(auth.register('neu@b.de', 'geheim123')).resolves.toEqual({ $id: 'u2', email: 'neu@b.de', labels: [] });
const params = mocks.create.mock.calls[0][0]; const params = mocks.create.mock.calls[0][0];
expect(params).toMatchObject({ email: 'neu@b.de', password: 'geheim123', name: 'neu' }); expect(params).toMatchObject({ email: 'neu@b.de', password: 'geheim123', name: 'neu' });
expect(typeof params.userId).toBe('string'); expect(typeof params.userId).toBe('string');
+14 -4
View File
@@ -4,7 +4,7 @@ import { ID } from 'appwrite';
import { account } from './appwrite'; import { account } from './appwrite';
import { errorType, isUnauthorized } from './errors'; import { errorType, isUnauthorized } from './errors';
export type User = { $id: string; email: string }; export type User = { $id: string; email: string; labels?: string[] };
const PASSWORD_MIN = 8; const PASSWORD_MIN = 8;
const PASSWORD_MAX = 256; const PASSWORD_MAX = 256;
@@ -27,10 +27,16 @@ function setUser(user: User | null): void {
function toUser(res: unknown): User { function toUser(res: unknown): User {
const data = (typeof res === 'object' && res !== null ? res : {}) as Record<string, unknown>; const data = (typeof res === 'object' && res !== null ? res : {}) as Record<string, unknown>;
if (typeof data.$id !== 'string' || typeof data.email !== 'string') throw new Error('Unerwartete Antwort'); if (typeof data.$id !== 'string' || typeof data.email !== 'string') throw new Error('Unerwartete Antwort');
return { $id: data.$id, email: data.email }; const labels = Array.isArray(data.labels) ? data.labels.filter((l): l is string => typeof l === 'string') : [];
return { $id: data.$id, email: data.email, labels };
} }
/** Der angemeldete Nutzer laut Server; 401 heißt „nicht angemeldet“. Abonnenten hören nur echte Änderungen. */ /** Wahr nur für Konten mit dem Appwrite-Label `admin` (die Rechte prüft der Server; das hier steuert nur die Oberfläche). */
export function isAdmin(user: User | null): boolean {
return (user?.labels ?? []).includes('admin');
}
/** Der angemeldete Nutzer laut Server; 401 heißt „nicht angemeldet“. Abonnenten hören nur echte Änderungen (auch der Labels). */
export async function getCurrentUser(): Promise<User | null> { export async function getCurrentUser(): Promise<User | null> {
let next: User | null; let next: User | null;
try { try {
@@ -39,7 +45,11 @@ export async function getCurrentUser(): Promise<User | null> {
if (!isUnauthorized(err)) throw err; if (!isUnauthorized(err)) throw err;
next = null; next = null;
} }
if (next?.$id !== cachedUser?.$id || next?.email !== cachedUser?.email) setUser(next); const same =
next?.$id === cachedUser?.$id &&
next?.email === cachedUser?.email &&
(next?.labels ?? []).join('\n') === (cachedUser?.labels ?? []).join('\n');
if (!same) setUser(next);
return next; return next;
} }
+1 -1
View File
@@ -14,7 +14,7 @@ function pngInfo(buf: Buffer) {
describe('Name und Kopfbereich', () => { describe('Name und Kopfbereich', () => {
it('Titel und Meta-Angaben nennen AirCard-IOS', () => { it('Titel und Meta-Angaben nennen AirCard-IOS', () => {
expect(indexHtml).toContain('<title>AirCard-IOS – Kartenmotiv gestalten</title>'); expect(indexHtml).toContain('<title>AirCard-IOS – Kartendesigns für Apple Wallet</title>');
expect(indexHtml).toContain('<meta name="theme-color" content="#0F1828" />'); expect(indexHtml).toContain('<meta name="theme-color" content="#0F1828" />');
expect(indexHtml).toContain('<meta name="apple-mobile-web-app-title" content="AirCard-IOS" />'); expect(indexHtml).toContain('<meta name="apple-mobile-web-app-title" content="AirCard-IOS" />');
}); });
+7
View File
@@ -0,0 +1,7 @@
// Konfiguration der Website.
/**
* Öffentlicher Spenden-Link (Buy Me a Coffee, `https://buymeacoffee.com/<name>`).
* Solange er leer ist, werden Spenden-Banner und -Knopf nicht angezeigt.
*/
export const DONATE_URL: string = '';
+5 -4
View File
@@ -67,7 +67,7 @@ beforeEach(() => {
describe('bindEditorToAuth', () => { describe('bindEditorToAuth', () => {
it('Abmelden: der Editor wird ganz geleert und ?karte= fällt aus der Adresse', () => { it('Abmelden: der Editor wird ganz geleert und ?karte= fällt aus der Adresse', () => {
history.replaceState(null, '', '/?karte=k1'); history.replaceState(null, '', '/gestalten?karte=k1');
const fake = fakeAuth('a'); const fake = fakeAuth('a');
bindEditorToAuth(store, fake.auth); bindEditorToAuth(store, fake.auth);
design(); design();
@@ -76,7 +76,7 @@ describe('bindEditorToAuth', () => {
expect(store.get().image).toBeNull(); expect(store.get().image).toBeNull();
expect(store.get().saved).toBeNull(); expect(store.get().saved).toBeNull();
expect(location.search).toBe(''); expect(location.search).toBe('');
expect(location.pathname).toBe('/'); expect(location.pathname).toBe('/gestalten');
}); });
it('Abmelden: der Anfangszustand besteht aus frischen Objekten (nichts geteilt mit dem alten)', () => { it('Abmelden: der Anfangszustand besteht aus frischen Objekten (nichts geteilt mit dem alten)', () => {
@@ -90,7 +90,7 @@ describe('bindEditorToAuth', () => {
}); });
it('Kontowechsel A → B: Entwurf bleibt, aber ohne Karte und als „Bild noch nicht gespeichert“', () => { it('Kontowechsel A → B: Entwurf bleibt, aber ohne Karte und als „Bild noch nicht gespeichert“', () => {
history.replaceState(null, '', '/?karte=k1'); history.replaceState(null, '', '/gestalten?karte=k1');
const fake = fakeAuth('a'); const fake = fakeAuth('a');
bindEditorToAuth(store, fake.auth); bindEditorToAuth(store, fake.auth);
const { blob, config } = design(); const { blob, config } = design();
@@ -172,10 +172,11 @@ describe('bindEditorToAuth', () => {
}); });
it('lässt andere Teile der Adresse stehen', () => { it('lässt andere Teile der Adresse stehen', () => {
history.replaceState(null, '', '/?karte=k1&x=1#h'); history.replaceState(null, '', '/gestalten?karte=k1&x=1#h');
const fake = fakeAuth('a'); const fake = fakeAuth('a');
bindEditorToAuth(store, fake.auth); bindEditorToAuth(store, fake.auth);
fake.logout(); fake.logout();
expect(location.pathname).toBe('/gestalten');
expect(location.search).toBe('?x=1'); expect(location.search).toBe('?x=1');
expect(location.hash).toBe('#h'); expect(location.hash).toBe('#h');
}); });
+1 -1
View File
@@ -1,4 +1,4 @@
// Die Adresse des Editors kann eine Karte nennen (`/?karte=<id>`). // Die Adresse des Editors kann eine Karte nennen (`/gestalten?karte=<id>`; alte Links `/?karte=<id>` leitet main.ts um).
/** Entfernt `karte` aus der Adresse (ersetzt den Verlaufseintrag), damit ein Neuladen nicht dieselbe Karte öffnet. */ /** Entfernt `karte` aus der Adresse (ersetzt den Verlaufseintrag), damit ein Neuladen nicht dieselbe Karte öffnet. */
export function dropCardParam(): void { export function dropCardParam(): void {
+2 -2
View File
@@ -7,11 +7,11 @@
border-radius: 20px; border-radius: 20px;
background: var(--panel); background: var(--panel);
color: var(--text); color: var(--text);
box-shadow: 0 24px 80px rgb(0 0 0 / 0.55); box-shadow: var(--shadow-lg);
} }
.confirm-dialog::backdrop { .confirm-dialog::backdrop {
background: rgb(6 11 20 / 0.72); background: var(--backdrop);
} }
.confirm-card { .confirm-card {
+7
View File
@@ -25,6 +25,13 @@ const ICONS = {
lock: '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>', lock: '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
check: '<path d="M20 6 9 17l-5-5"/>', check: '<path d="M20 6 9 17l-5-5"/>',
ban: '<circle cx="12" cy="12" r="9"/><path d="m5.6 5.6 12.8 12.8"/>', ban: '<circle cx="12" cy="12" r="9"/><path d="m5.6 5.6 12.8 12.8"/>',
sun:
'<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/>' +
'<path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/>' +
'<path d="m19.07 4.93-1.41 1.41"/>',
moon: '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
menu: '<path d="M4 6h16"/><path d="M4 12h16"/><path d="M4 18h16"/>',
search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
} as const; } as const;
export type IconName = keyof typeof ICONS; export type IconName = keyof typeof ICONS;
+9 -9
View File
@@ -297,8 +297,8 @@ describe('Speichern', () => {
}); });
}); });
describe('Karte öffnen (/?karte=<id>)', () => { describe('Karte öffnen (/gestalten?karte=<id>)', () => {
const open = (id: string) => history.replaceState(null, '', `/?karte=${id}`); const open = (id: string) => history.replaceState(null, '', `/gestalten?karte=${id}`);
it('lädt Karte und Bild, begrenzt den Ausschnitt und setzt den Zustand', async () => { it('lädt Karte und Bild, begrenzt den Ausschnitt und setzt den Zustand', async () => {
open('k1'); open('k1');
@@ -531,7 +531,7 @@ describe('Karte öffnen (/?karte=<id>)', () => {
expect(store.get()).toBe(before); expect(store.get()).toBe(before);
expect(store.get().imageBlob).toBe(blob); expect(store.get().imageBlob).toBe(blob);
expect(store.get().image).toBe(img); expect(store.get().image).toBe(img);
expect(location.pathname + location.search).toBe('/'); expect(location.pathname + location.search).toBe('/gestalten');
}); });
it('Esc im Dialog verwirft nichts', async () => { it('Esc im Dialog verwirft nichts', async () => {
@@ -733,7 +733,7 @@ describe('Bindung an eine gespeicherte Karte', () => {
}); });
it('ist nach dem Öffnen einer Karte da', async () => { it('ist nach dem Öffnen einer Karte da', async () => {
history.replaceState(null, '', '/?karte=k1'); history.replaceState(null, '', '/gestalten?karte=k1');
mocks.get.mockResolvedValue(savedCard()); mocks.get.mockResolvedValue(savedCard());
mocks.loadImage.mockResolvedValue(png()); mocks.loadImage.mockResolvedValue(png());
mocks.decodeImage.mockResolvedValue(image()); mocks.decodeImage.mockResolvedValue(image());
@@ -758,7 +758,7 @@ describe('Bindung an eine gespeicherte Karte', () => {
describe('„Neue Karte“', () => { describe('„Neue Karte“', () => {
it('setzt den Editor zurück (frische Objekte) und entfernt ?karte= aus der Adresse', () => { it('setzt den Editor zurück (frische Objekte) und entfernt ?karte= aus der Adresse', () => {
history.replaceState(null, '', '/?karte=k1'); history.replaceState(null, '', '/gestalten?karte=k1');
const { config } = boundEditor(); const { config } = boundEditor();
mount(); mount();
newCardButton().click(); newCardButton().click();
@@ -774,7 +774,7 @@ describe('Bindung an eine gespeicherte Karte', () => {
expect(s.config).not.toBe(DEFAULT_CONFIG); expect(s.config).not.toBe(DEFAULT_CONFIG);
expect(s.config.crop).not.toBe(DEFAULT_CONFIG.crop); expect(s.config.crop).not.toBe(DEFAULT_CONFIG.crop);
expect(s.config).not.toBe(config); expect(s.config).not.toBe(config);
expect(location.pathname + location.search).toBe('/'); expect(location.pathname + location.search).toBe('/gestalten');
expect(boundRow().hidden).toBe(true); expect(boundRow().hidden).toBe(true);
}); });
@@ -787,7 +787,7 @@ describe('Bindung an eine gespeicherte Karte', () => {
}); });
it('bricht eine noch ladende Karte ab', async () => { it('bricht eine noch ladende Karte ab', async () => {
history.replaceState(null, '', '/?karte=k2'); history.replaceState(null, '', '/gestalten?karte=k2');
boundEditor(); boundEditor();
let finish!: (c: SavedCard) => void; let finish!: (c: SavedCard) => void;
mocks.get.mockReturnValue(new Promise<SavedCard>((r) => (finish = r))); mocks.get.mockReturnValue(new Promise<SavedCard>((r) => (finish = r)));
@@ -869,7 +869,7 @@ describe('Bindung an eine gespeicherte Karte', () => {
}); });
it('fragt nach dem Öffnen einer Karte nicht, solange nichts geändert wurde', async () => { it('fragt nach dem Öffnen einer Karte nicht, solange nichts geändert wurde', async () => {
history.replaceState(null, '', '/?karte=k1'); history.replaceState(null, '', '/gestalten?karte=k1');
mocks.get.mockResolvedValue(savedCard()); mocks.get.mockResolvedValue(savedCard());
mocks.loadImage.mockResolvedValue(png()); mocks.loadImage.mockResolvedValue(png());
mocks.decodeImage.mockResolvedValue(image(3000, 2000)); mocks.decodeImage.mockResolvedValue(image(3000, 2000));
@@ -928,7 +928,7 @@ describe('Bindung an eine gespeicherte Karte', () => {
describe('Abmelden während des Ladens', () => { describe('Abmelden während des Ladens', () => {
it('eine noch ladende Karte landet nicht mehr im Editor', async () => { it('eine noch ladende Karte landet nicht mehr im Editor', async () => {
history.replaceState(null, '', '/?karte=k1'); history.replaceState(null, '', '/gestalten?karte=k1');
let finish!: (c: SavedCard) => void; let finish!: (c: SavedCard) => void;
mocks.get.mockReturnValue(new Promise<SavedCard>((r) => (finish = r))); mocks.get.mockReturnValue(new Promise<SavedCard>((r) => (finish = r)));
mount(); mount();
+11 -3
View File
@@ -183,6 +183,13 @@
-webkit-user-drag: none; -webkit-user-drag: none;
} }
/* Die Logos sind für die dunkle Karte gemacht (weiß, silbern, Umriss): im hellen Theme stehen sie wie auf der Karte auf deren Grund. */
:root[data-theme='light'] .mark-img {
padding: 5px 8px;
border-radius: 8px;
background: #1b2738;
}
.mark-none { .mark-none {
width: 46px; width: 46px;
height: 46px; height: 46px;
@@ -356,7 +363,7 @@
height: auto; height: auto;
border-radius: var(--card-radius); border-radius: var(--card-radius);
background: #1b2738; background: #1b2738;
box-shadow: 0 10px 34px rgba(0, 0, 0, 0.45); box-shadow: var(--shadow-card);
cursor: grab; cursor: grab;
touch-action: none; touch-action: none;
user-select: none; user-select: none;
@@ -402,11 +409,11 @@
} }
.msg-error { .msg-error {
color: #ff9a9d; color: var(--danger-text);
} }
.msg-warning { .msg-warning {
color: #f2c66d; color: var(--warn-text);
} }
.msg-notice { .msg-notice {
@@ -456,6 +463,7 @@
.btn-primary { .btn-primary {
border-color: var(--primary); border-color: var(--primary);
background: var(--primary); background: var(--primary);
color: var(--on-primary);
font-weight: 700; font-weight: 700;
} }
+32 -7
View File
@@ -2,18 +2,37 @@ import '@fontsource-variable/nunito-sans';
import './styles.css'; import './styles.css';
import { mountAccountMenu } from './account/account-menu'; import { mountAccountMenu } from './account/account-menu';
import { getCurrentUser } from './account/auth'; import { getCurrentUser } from './account/auth';
import { startRouter } from './pages/router'; import { DONATE_URL } from './config';
import { legacyRedirect, startRouter } from './pages/router';
import { mountCardsPage } from './pages/cards-page'; import { mountCardsPage } from './pages/cards-page';
import { mountCommunityPage } from './pages/community-page';
import { mountHomePage } from './pages/home-page';
import { mountResetPage } from './pages/reset-page'; import { mountResetPage } from './pages/reset-page';
import { mountEditor } from './editor/editor-page'; import { mountEditor } from './editor/editor-page';
import { bindEditorToAuth } from './editor/auth-reset'; import { bindEditorToAuth } from './editor/auth-reset';
import { createStore, initialEditorState } from './editor/state'; import { createStore, initialEditorState } from './editor/state';
import { mountDonateBanner } from './shell/donate-banner';
import { mountHeader } from './shell/header';
import { initTheme } from './shell/theme';
// Hell/Dunkel: das Skript in index.html hat es vor dem ersten Paint gesetzt; hier wird nur die Browserleiste angeglichen.
initTheme();
// Alte Editor-Links (`/?karte=<id>`) zeigen jetzt auf `/gestalten?karte=<id>`; der Verlauf bekommt keinen Eintrag dazu.
const legacyTarget = legacyRedirect(new URL(location.href));
if (legacyTarget) history.replaceState(null, '', legacyTarget);
// Der Editor-Zustand lebt außerhalb der Seiten und überlebt Seitenwechsel. // Der Editor-Zustand lebt außerhalb der Seiten und überlebt Seitenwechsel.
const editorStore = createStore(initialEditorState()); const editorStore = createStore(initialEditorState());
// … und gehört zum angemeldeten Konto: beim Abmelden oder Kontowechsel wird er geleert bzw. entkoppelt. // … und gehört zum angemeldeten Konto: beim Abmelden oder Kontowechsel wird er geleert bzw. entkoppelt.
bindEditorToAuth(editorStore); bindEditorToAuth(editorStore);
// Spenden-Banner (ohne DONATE_URL bleibt der Platz leer) und Kopfzeile mit Navigation, Suche und Knöpfen.
const bannerSlot = document.getElementById('donate-banner');
if (bannerSlot) mountDonateBanner(bannerSlot, DONATE_URL);
const header = document.getElementById('app-header');
if (header) mountHeader(header, { donateUrl: DONATE_URL });
// Konto-Menü im Seitenkopf. Der Anmeldestatus wird einmal beim Start geholt; schlägt das fehl // Konto-Menü im Seitenkopf. Der Anmeldestatus wird einmal beim Start geholt; schlägt das fehl
// (außer 401, das getCurrentUser selbst als „abgemeldet“ meldet), gilt der Nutzer als abgemeldet. // (außer 401, das getCurrentUser selbst als „abgemeldet“ meldet), gilt der Nutzer als abgemeldet.
const accountSlot = document.getElementById('account-slot'); const accountSlot = document.getElementById('account-slot');
@@ -26,12 +45,18 @@ if (accountSlot) {
.finally(() => menu.ready()); .finally(() => menu.ready());
} }
// Der Router setzt `document.title` je Seite (titleFor) und ruft für jede Route die passende Mount-Funktion.
startRouter((route, root) => { startRouter((route, root) => {
if (route === 'editor') { switch (route.id) {
return mountEditor(root, editorStore); case 'gestalten':
return mountEditor(root, editorStore);
case 'cards':
return mountCardsPage(root, editorStore);
case 'community':
return mountCommunityPage(root, route.params.id ?? '');
case 'reset':
return mountResetPage(root);
case 'home':
return mountHomePage(root);
} }
if (route === 'reset') {
return mountResetPage(root);
}
return mountCardsPage(root, editorStore);
}); });
+2 -2
View File
@@ -141,7 +141,7 @@ describe('Meine Karten: Zustände', () => {
mount(); mount();
await vi.waitFor(() => expect(text()).toContain('Noch keine Karten gespeichert.')); await vi.waitFor(() => expect(text()).toContain('Noch keine Karten gespeichert.'));
const link = root.querySelector<HTMLAnchorElement>('a[data-link]')!; const link = root.querySelector<HTMLAnchorElement>('a[data-link]')!;
expect(link.getAttribute('href')).toBe('/'); expect(link.getAttribute('href')).toBe('/gestalten');
}); });
it('Fehler beim Laden: Meldung und „Nochmal versuchen“', async () => { it('Fehler beim Laden: Meldung und „Nochmal versuchen“', async () => {
@@ -247,7 +247,7 @@ describe('Meine Karten: Öffnen und Herunterladen', () => {
mount(); mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(1)); await vi.waitFor(() => expect(tiles()).toHaveLength(1));
button(tileFor('a'), 'Öffnen').click(); button(tileFor('a'), 'Öffnen').click();
expect(mocks.navigate).toHaveBeenCalledWith('/?karte=a'); expect(mocks.navigate).toHaveBeenCalledWith('/gestalten?karte=a');
}); });
it('Herunterladen: Bild laden, als PNG rendern und unter aircard-<name>.png speichern', async () => { it('Herunterladen: Bild laden, als PNG rendern und unter aircard-<name>.png speichern', async () => {
+2 -2
View File
@@ -156,7 +156,7 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
state = 'list'; state = 'list';
thumbSlots.clear(); thumbSlots.clear();
if (cards.length === 0) { if (cards.length === 0) {
const toEditor = el('a', { class: 'auth-btn auth-btn-primary cards-link-btn', href: '/', 'data-link': true }, 'Zum Editor'); const toEditor = el('a', { class: 'auth-btn auth-btn-primary cards-link-btn', href: '/gestalten', 'data-link': true }, 'Zum Editor');
show(el('p', { class: 'cards-msg' }, EMPTY), el('p', { class: 'cards-actions' }, toEditor)); show(el('p', { class: 'cards-msg' }, EMPTY), el('p', { class: 'cards-actions' }, toEditor));
return; return;
} }
@@ -218,7 +218,7 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
} }
} }
openButton.addEventListener('click', () => navigate(`/?karte=${encodeURIComponent(card.id)}`)); openButton.addEventListener('click', () => navigate(`/gestalten?karte=${encodeURIComponent(card.id)}`));
downloadButton.addEventListener('click', () => { downloadButton.addEventListener('click', () => {
void run(async () => { void run(async () => {
+3 -3
View File
@@ -10,7 +10,7 @@
} }
.cards-msg-error { .cards-msg-error {
color: #ff9a9d; color: var(--danger-text);
} }
.cards-actions { .cards-actions {
@@ -85,7 +85,7 @@
.card-msg { .card-msg {
margin: 0; margin: 0;
color: #ff9a9d; color: var(--danger-text);
font-size: 0.9rem; font-size: 0.9rem;
} }
@@ -94,7 +94,7 @@
} }
.card-msg.is-warning { .card-msg.is-warning {
color: #f2c66d; color: var(--warn-text);
} }
.card-rename { .card-rename {
+10
View File
@@ -0,0 +1,10 @@
// Detailseite „/community/<id>“. Platzhalter – Task 6 füllt sie.
import { el } from '../editor/dom';
export function mountCommunityPage(root: HTMLElement, id: string): () => void {
root.replaceChildren(
el('h1', {}, 'Community-Karte'),
el('p', { class: 'community-placeholder', 'data-card-id': id }, el('a', { href: '/#community', 'data-link': true }, 'Zur Galerie')),
);
return () => {};
}
+10
View File
@@ -0,0 +1,10 @@
// Startseite „/“: Hero + Community-Galerie. Platzhalter – Task 5 füllt sie.
import { el } from '../editor/dom';
export function mountHomePage(root: HTMLElement): () => void {
root.replaceChildren(
el('h1', {}, 'Kartendesigns für Apple Wallet'),
el('section', { id: 'community', class: 'home-community' }, el('h2', {}, 'Community-Karten')),
);
return () => {};
}
+146 -19
View File
@@ -1,22 +1,84 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { matchRoute, navigate, startRouter } from './router'; import { legacyRedirect, matchRoute, navigate, ROUTE_TITLES, startRouter, titleFor } from './router';
describe('matchRoute', () => { describe('matchRoute', () => {
it('ordnet / dem Editor zu', () => { it('ordnet / der Startseite zu', () => {
expect(matchRoute('/')).toBe('editor'); expect(matchRoute('/')).toEqual({ id: 'home', params: {} });
});
it('ordnet /gestalten und /gestalten/ dem Editor zu', () => {
expect(matchRoute('/gestalten')).toEqual({ id: 'gestalten', params: {} });
expect(matchRoute('/gestalten/')).toEqual({ id: 'gestalten', params: {} });
}); });
it('ordnet /karten und /karten/ den Karten zu', () => { it('ordnet /karten und /karten/ den Karten zu', () => {
expect(matchRoute('/karten')).toBe('cards'); expect(matchRoute('/karten')).toEqual({ id: 'cards', params: {} });
expect(matchRoute('/karten/')).toBe('cards'); expect(matchRoute('/karten/')).toEqual({ id: 'cards', params: {} });
});
it('ordnet /community/<id> der Detailseite zu und liefert die ID', () => {
expect(matchRoute('/community/abc123')).toEqual({ id: 'community', params: { id: 'abc123' } });
expect(matchRoute('/community/abc123/')).toEqual({ id: 'community', params: { id: 'abc123' } });
});
it('decodiert die ID; eine kaputte Kodierung fällt auf die Startseite zurück', () => {
expect(matchRoute('/community/a%20b')).toEqual({ id: 'community', params: { id: 'a b' } });
expect(matchRoute('/community/%E0%A4%A')).toEqual({ id: 'home', params: {} });
});
it('/community ohne ID und mit weiteren Segmenten ist unbekannt und führt auf die Startseite', () => {
expect(matchRoute('/community')).toEqual({ id: 'home', params: {} });
expect(matchRoute('/community/')).toEqual({ id: 'home', params: {} });
expect(matchRoute('/community/abc/def')).toEqual({ id: 'home', params: {} });
}); });
it('ordnet /passwort-neu dem Zurücksetzen zu', () => { it('ordnet /passwort-neu dem Zurücksetzen zu', () => {
expect(matchRoute('/passwort-neu')).toBe('reset'); expect(matchRoute('/passwort-neu')).toEqual({ id: 'reset', params: {} });
}); });
it('fällt bei unbekannten Pfaden auf den Editor zurück', () => { it('fällt bei unbekannten Pfaden auf die Startseite zurück', () => {
expect(matchRoute('/irgendwas')).toBe('editor'); expect(matchRoute('/irgendwas')).toEqual({ id: 'home', params: {} });
});
});
describe('legacyRedirect', () => {
it('leitet /?karte=<id> auf /gestalten?karte=<id> um', () => {
expect(legacyRedirect(new URL('https://x/?karte=K1'))).toBe('/gestalten?karte=K1');
});
it('behält weitere Parameter und den Hash bei', () => {
expect(legacyRedirect(new URL('https://x/?karte=K1&x=1#h'))).toBe('/gestalten?karte=K1&x=1#h');
});
it('ohne (oder mit leerem) karte-Parameter gibt es keine Umleitung', () => {
expect(legacyRedirect(new URL('https://x/'))).toBeNull();
expect(legacyRedirect(new URL('https://x/?q=visa'))).toBeNull();
expect(legacyRedirect(new URL('https://x/?karte='))).toBeNull();
});
it('nur die Startseite wird umgeleitet, andere Pfade bleiben unberührt', () => {
expect(legacyRedirect(new URL('https://x/gestalten?karte=K1'))).toBeNull();
expect(legacyRedirect(new URL('https://x/karten?karte=K1'))).toBeNull();
});
});
describe('Seitentitel', () => {
it('nennt je Route den Titel aus der Spec', () => {
expect(titleFor({ id: 'home', params: {} })).toBe('AirCard-IOS – Kartendesigns für Apple Wallet');
expect(titleFor({ id: 'gestalten', params: {} })).toBe('Gestalten – AirCard-IOS');
expect(titleFor({ id: 'cards', params: {} })).toBe('Meine Karten – AirCard-IOS');
expect(titleFor({ id: 'reset', params: {} })).toBe('Passwort neu setzen – AirCard-IOS');
});
it('Detailseite: „<Titel> – AirCard-IOS“, ohne Titel ein allgemeiner Name', () => {
const route = { id: 'community', params: { id: 'abc' } } as const;
expect(titleFor(route, 'Mein Motiv')).toBe('Mein Motiv – AirCard-IOS');
expect(titleFor(route)).toBe(ROUTE_TITLES.community);
expect(ROUTE_TITLES.community).toBe('Community – AirCard-IOS');
});
it('ein Titel wird nur auf der Detailseite eingesetzt', () => {
expect(titleFor({ id: 'cards', params: {} }, 'Fremd')).toBe('Meine Karten – AirCard-IOS');
}); });
}); });
@@ -39,7 +101,7 @@ describe('startRouter', () => {
const render = vi.fn(); const render = vi.fn();
startRouter(render); startRouter(render);
expect(render).toHaveBeenCalledTimes(1); expect(render).toHaveBeenCalledTimes(1);
expect(render).toHaveBeenCalledWith('cards', root); expect(render).toHaveBeenCalledWith({ id: 'cards', params: {} }, root);
}); });
it('navigate setzt die URL per pushState und rendert neu', () => { it('navigate setzt die URL per pushState und rendert neu', () => {
@@ -49,23 +111,23 @@ describe('startRouter', () => {
navigate('/passwort-neu'); navigate('/passwort-neu');
expect(location.pathname).toBe('/passwort-neu'); expect(location.pathname).toBe('/passwort-neu');
expect(history.length).toBe(lengthBefore + 1); expect(history.length).toBe(lengthBefore + 1);
expect(render).toHaveBeenLastCalledWith('reset', root); expect(render).toHaveBeenLastCalledWith({ id: 'reset', params: {} }, root);
}); });
it('ruft vor dem nächsten Rendern den Cleanup der vorherigen Seite', () => { it('ruft vor dem nächsten Rendern den Cleanup der vorherigen Seite', () => {
const order: string[] = []; const order: string[] = [];
startRouter((route) => { startRouter((route) => {
order.push(`render:${route}`); order.push(`render:${route.id}`);
return () => order.push(`cleanup:${route}`); return () => order.push(`cleanup:${route.id}`);
}); });
navigate('/karten'); navigate('/karten');
navigate('/'); navigate('/');
expect(order).toEqual([ expect(order).toEqual([
'render:editor', 'render:home',
'cleanup:editor', 'cleanup:home',
'render:cards', 'render:cards',
'cleanup:cards', 'cleanup:cards',
'render:editor', 'render:home',
]); ]);
}); });
@@ -91,7 +153,7 @@ describe('startRouter', () => {
link.dispatchEvent(event); link.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true); expect(event.defaultPrevented).toBe(true);
expect(location.pathname).toBe('/karten'); expect(location.pathname).toBe('/karten');
expect(render).toHaveBeenLastCalledWith('cards', root); expect(render).toHaveBeenLastCalledWith({ id: 'cards', params: {} }, root);
}); });
it('lässt Klicks auf Links ohne data-link und Klicks mit Modifier-Taste unberührt', () => { it('lässt Klicks auf Links ohne data-link und Klicks mit Modifier-Taste unberührt', () => {
@@ -143,6 +205,60 @@ describe('startRouter', () => {
} }
}); });
it('navigate zu einem Hash-Ziel scrollt den Anker ins Bild statt an den Seitenanfang', () => {
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
try {
const anchor = document.createElement('section');
anchor.id = 'community';
const scrollIntoView = vi.fn();
anchor.scrollIntoView = scrollIntoView;
startRouter(() => {
root.append(anchor); // der Anker entsteht erst beim Rendern
});
navigate('/karten');
scrollTo.mockClear();
navigate('/?q=visa#community');
expect(location.pathname + location.search + location.hash).toBe('/?q=visa#community');
expect(scrollIntoView).toHaveBeenCalledTimes(1);
expect(scrollTo).not.toHaveBeenCalled();
} finally {
scrollTo.mockRestore();
}
});
it('ein Hash ohne passenden Anker springt an den Seitenanfang', () => {
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
try {
startRouter(vi.fn());
navigate('/#gibt-es-nicht');
expect(scrollTo).toHaveBeenCalledWith(0, 0);
} finally {
scrollTo.mockRestore();
}
});
it('ändert sich nur der Hash, wird nicht neu gerendert, nur gescrollt', () => {
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
try {
const anchor = document.createElement('section');
anchor.id = 'community';
const scrollIntoView = vi.fn();
anchor.scrollIntoView = scrollIntoView;
root.append(anchor);
const render = vi.fn();
startRouter(render);
render.mockClear();
const lengthBefore = history.length;
navigate('/#community');
expect(location.hash).toBe('#community');
expect(history.length).toBe(lengthBefore + 1);
expect(render).not.toHaveBeenCalled();
expect(scrollIntoView).toHaveBeenCalledTimes(1);
} finally {
scrollTo.mockRestore();
}
});
it('beim Start und bei popstate wird nicht gescrollt (das übernimmt der Browser)', () => { it('beim Start und bei popstate wird nicht gescrollt (das übernimmt der Browser)', () => {
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {}); const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
try { try {
@@ -159,13 +275,17 @@ describe('startRouter', () => {
vi.spyOn(window, 'scrollTo').mockImplementation(() => {}); vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
try { try {
startRouter(vi.fn()); startRouter(vi.fn());
expect(document.title).toBe('AirCard-IOS – Kartenmotiv gestalten'); expect(document.title).toBe('AirCard-IOS – Kartendesigns für Apple Wallet');
navigate('/gestalten');
expect(document.title).toBe('Gestalten – AirCard-IOS');
navigate('/karten'); navigate('/karten');
expect(document.title).toBe('Meine Karten – AirCard-IOS'); expect(document.title).toBe('Meine Karten – AirCard-IOS');
navigate('/community/abc123');
expect(document.title).toBe('Community – AirCard-IOS');
navigate('/passwort-neu'); navigate('/passwort-neu');
expect(document.title).toBe('Passwort neu setzen – AirCard-IOS'); expect(document.title).toBe('Passwort neu setzen – AirCard-IOS');
navigate('/'); navigate('/');
expect(document.title).toBe('AirCard-IOS – Kartenmotiv gestalten'); expect(document.title).toBe('AirCard-IOS – Kartendesigns für Apple Wallet');
} finally { } finally {
vi.restoreAllMocks(); vi.restoreAllMocks();
} }
@@ -177,6 +297,13 @@ describe('startRouter', () => {
startRouter(render); startRouter(render);
history.pushState(null, '', '/passwort-neu'); history.pushState(null, '', '/passwort-neu');
window.dispatchEvent(new PopStateEvent('popstate')); window.dispatchEvent(new PopStateEvent('popstate'));
expect(render).toHaveBeenLastCalledWith('reset', root); expect(render).toHaveBeenLastCalledWith({ id: 'reset', params: {} }, root);
});
it('übergibt die ID der Detailseite als Parameter', () => {
history.replaceState(null, '', '/community/abc123');
const render = vi.fn();
startRouter(render);
expect(render).toHaveBeenCalledWith({ id: 'community', params: { id: 'abc123' } }, root);
}); });
}); });
+62 -14
View File
@@ -1,33 +1,81 @@
export type RouteId = 'editor' | 'cards' | 'reset'; export type RouteId = 'home' | 'gestalten' | 'cards' | 'community' | 'reset';
type Render = (route: RouteId, root: HTMLElement) => void | (() => void); export interface Route {
id: RouteId;
export function matchRoute(pathname: string): RouteId { params: { id?: string };
const path = pathname.replace(/\/+$/, '') || '/';
if (path === '/karten') return 'cards';
if (path === '/passwort-neu') return 'reset';
return 'editor';
} }
const TITLES: Record<RouteId, string> = { type Render = (route: Route, root: HTMLElement) => void | (() => void);
editor: 'AirCard-IOS – Kartenmotiv gestalten',
const normalize = (pathname: string): string => pathname.replace(/\/+$/, '') || '/';
export function matchRoute(pathname: string): Route {
const path = normalize(pathname);
if (path === '/gestalten') return { id: 'gestalten', params: {} };
if (path === '/karten') return { id: 'cards', params: {} };
if (path === '/passwort-neu') return { id: 'reset', params: {} };
const detail = /^\/community\/([^/]+)$/.exec(path);
if (detail) {
try {
return { id: 'community', params: { id: decodeURIComponent(detail[1]) } };
} catch {
// kaputte Prozent-Kodierung: wie jede unbekannte Adresse
}
}
return { id: 'home', params: {} };
}
/** Alte Adresse des Editors: `/?karte=<id>` heißt jetzt `/gestalten?karte=<id>`; sonst null (keine Umleitung). */
export function legacyRedirect(url: URL): string | null {
if (normalize(url.pathname) !== '/' || !url.searchParams.get('karte')) return null;
return `/gestalten${url.search}${url.hash}`;
}
export const ROUTE_TITLES: Record<RouteId, string> = {
home: 'AirCard-IOS – Kartendesigns für Apple Wallet',
gestalten: 'Gestalten – AirCard-IOS',
cards: 'Meine Karten – AirCard-IOS', cards: 'Meine Karten – AirCard-IOS',
community: 'Community – AirCard-IOS',
reset: 'Passwort neu setzen – AirCard-IOS', reset: 'Passwort neu setzen – AirCard-IOS',
}; };
/** Titel der Seite; `detailTitle` (Titel der Community-Karte) gilt nur für die Detailseite. */
export function titleFor(route: Route, detailTitle?: string): string {
if (route.id === 'community' && detailTitle) return `${detailTitle} – AirCard-IOS`;
return ROUTE_TITLES[route.id];
}
// Beendet den laufenden Router (Seiten-Cleanup, Listener); null, solange keiner läuft. // Beendet den laufenden Router (Seiten-Cleanup, Listener); null, solange keiner läuft.
let stopCurrent: (() => void) | null = null; let stopCurrent: (() => void) | null = null;
let renderCurrent: (() => void) | null = null; let renderCurrent: (() => void) | null = null;
/** Springt zum Anker `#<id>`; gibt es ihn nicht, an den Seitenanfang. */
function scrollToHash(hash: string): void {
let id = hash.slice(1);
try {
id = decodeURIComponent(id);
} catch {
// unverändert verwenden
}
const target = id ? document.getElementById(id) : null;
if (target) target.scrollIntoView();
else window.scrollTo(0, 0);
}
export function navigate(path: string): void { export function navigate(path: string): void {
const url = new URL(path, location.href); const url = new URL(path, location.href);
const target = url.pathname + url.search + url.hash; const target = url.pathname + url.search + url.hash;
// Ändert sich nur der Hash (z. B. „Community“ auf der Startseite), bleibt die Seite stehen: nur zum Anker scrollen.
const hashOnly = !!url.hash && url.pathname + url.search === location.pathname + location.search;
if (target !== location.pathname + location.search + location.hash) { if (target !== location.pathname + location.search + location.hash) {
history.pushState(null, '', target); history.pushState(null, '', target);
} }
renderCurrent?.(); if (!renderCurrent) return;
// Eine neue Seite beginnt oben (pushState setzt die Scrollposition nicht zurück); bei popstate bleibt es dem Browser überlassen. if (!hashOnly) renderCurrent();
if (renderCurrent) window.scrollTo(0, 0); // Eine neue Seite beginnt oben (pushState setzt die Scrollposition nicht zurück), ein Hash-Ziel kommt ins Bild;
// bei popstate bleibt es dem Browser überlassen.
if (url.hash) scrollToHash(url.hash);
else window.scrollTo(0, 0);
} }
export function startRouter(render: Render): void { export function startRouter(render: Render): void {
@@ -42,7 +90,7 @@ export function startRouter(render: Render): void {
cleanup?.(); cleanup?.();
cleanup = undefined; cleanup = undefined;
const route = matchRoute(location.pathname); const route = matchRoute(location.pathname);
document.title = TITLES[route]; document.title = titleFor(route);
cleanup = render(route, root); cleanup = render(route, root);
}; };
+75
View File
@@ -0,0 +1,75 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mountDonateBanner } from './donate-banner';
const KEY = 'aircard.donateBannerClosed';
const URL_ = 'https://buymeacoffee.com/aircard';
const TEXT = '☕ Gefällt dir AirCard-IOS? Spendier uns einen Kaffee – damit das Projekt weiterläuft.';
let slot: HTMLElement;
beforeEach(() => {
localStorage.clear();
document.body.innerHTML = '<div id="donate-banner"></div>';
slot = document.getElementById('donate-banner') as HTMLElement;
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('mountDonateBanner', () => {
it('leere URL: nichts wird gerendert', () => {
mountDonateBanner(slot, '');
expect(slot.childElementCount).toBe(0);
mountDonateBanner(slot, ' ');
expect(slot.childElementCount).toBe(0);
});
it('zeigt Text, Link „Unterstützen →“ (neuer Tab) und Schließen-Knopf', () => {
mountDonateBanner(slot, URL_);
expect(slot.querySelector('.donate-banner-text')?.textContent).toBe(TEXT);
const link = slot.querySelector<HTMLAnchorElement>('a.donate-banner-link')!;
expect(link.textContent).toBe('Unterstützen →');
expect(link.getAttribute('href')).toBe(URL_);
expect(link.target).toBe('_blank');
expect(link.rel).toBe('noopener noreferrer');
expect(link.hasAttribute('data-link')).toBe(false);
expect(slot.querySelector('button')?.getAttribute('aria-label')).toBe('Banner schließen');
});
it('Schließen setzt das Flag und entfernt das Banner', () => {
mountDonateBanner(slot, URL_);
slot.querySelector('button')!.click();
expect(localStorage.getItem(KEY)).toBe('1');
expect(slot.childElementCount).toBe(0);
});
it('Flag gesetzt: nichts wird gerendert', () => {
localStorage.setItem(KEY, '1');
mountDonateBanner(slot, URL_);
expect(slot.childElementCount).toBe(0);
});
it('zweimal eingehängt gibt es nur ein Banner', () => {
mountDonateBanner(slot, URL_);
mountDonateBanner(slot, URL_);
expect(slot.querySelectorAll('.donate-banner')).toHaveLength(1);
});
it('localStorage wirft beim Lesen: Banner wird gezeigt', () => {
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new DOMException('blockiert', 'SecurityError');
});
expect(() => mountDonateBanner(slot, URL_)).not.toThrow();
expect(slot.querySelectorAll('.donate-banner')).toHaveLength(1);
});
it('localStorage wirft beim Schreiben: Banner verschwindet trotzdem, kein Fehler', () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new DOMException('voll', 'QuotaExceededError');
});
mountDonateBanner(slot, URL_);
expect(() => slot.querySelector('button')!.click()).not.toThrow();
expect(slot.childElementCount).toBe(0);
});
});
+47
View File
@@ -0,0 +1,47 @@
// Schließbares Spenden-Banner ganz oben. Ohne Spenden-Link (config.ts) oder nach dem Schließen wird nichts angezeigt.
import './shell.css';
import { el } from '../editor/dom';
const CLOSED_KEY = 'aircard.donateBannerClosed';
function isClosed(): boolean {
try {
return localStorage.getItem(CLOSED_KEY) !== null;
} catch {
return false; // Speicher gesperrt: das Banner bleibt sichtbar, bis die Seite neu geladen wird
}
}
function rememberClosed(): void {
try {
localStorage.setItem(CLOSED_KEY, '1');
} catch {
// gilt dann nur für diese Sitzung
}
}
export function mountDonateBanner(slot: HTMLElement, url: string): void {
slot.replaceChildren();
if (!url.trim() || isClosed()) return;
const close = el('button', { type: 'button', class: 'donate-banner-close', 'aria-label': 'Banner schließen' }, '×');
close.addEventListener('click', () => {
rememberClosed();
slot.replaceChildren();
});
slot.append(
el(
'div',
{ class: 'donate-banner' },
el(
'p',
{ class: 'donate-banner-text' },
el('span', { 'aria-hidden': 'true' }, '☕'),
' Gefällt dir AirCard-IOS? Spendier uns einen Kaffee – damit das Projekt weiterläuft.',
),
el('a', { class: 'donate-banner-link', href: url, target: '_blank', rel: 'noopener noreferrer' }, 'Unterstützen →'),
close,
),
);
}
+206
View File
@@ -0,0 +1,206 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({ navigate: vi.fn() }));
vi.mock('../pages/router', () => ({ navigate: mocks.navigate }));
import { mountHeader } from './header';
const DONATE = 'https://buymeacoffee.com/aircard';
const html = document.documentElement;
let header: HTMLElement;
let dispose: (() => void) | null = null;
function mount(donateUrl = '') {
dispose = mountHeader(header, { donateUrl });
}
const search = () => header.querySelector<HTMLFormElement>('form.header-search')!;
const input = () => search().querySelector<HTMLInputElement>('input')!;
const submit = () => search().dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
const menuButton = () => header.querySelector<HTMLButtonElement>('.menu-btn')!;
const nav = () => header.querySelector<HTMLElement>('nav.app-nav')!;
beforeEach(() => {
mocks.navigate.mockReset();
localStorage.clear();
html.setAttribute('data-theme', 'dark');
document.body.innerHTML =
'<header id="app-header">' +
'<a class="brand" href="/" data-link><img src="/favicon.svg?v=2" alt="" width="28" height="28" /><span>AirCard-IOS</span></a>' +
'<div id="account-slot"></div>' +
'</header>';
header = document.getElementById('app-header') as HTMLElement;
});
afterEach(() => {
dispose?.();
dispose = null;
});
describe('mountHeader: Aufbau', () => {
it('lässt Marke und Konto-Slot stehen und ergänzt Navigation, Suche und Knöpfe', () => {
const brand = header.querySelector('.brand');
const slot = header.querySelector('#account-slot');
mount();
expect(header.querySelector('.brand')).toBe(brand);
expect(header.querySelector('#account-slot')).toBe(slot);
expect(header.firstElementChild).toBe(brand);
expect(header.lastElementChild).toBe(slot);
expect(nav()).not.toBeNull();
expect(search()).not.toBeNull();
});
it('Navigation: Community, Gestalten, Meine Karten als data-link', () => {
mount();
const links = [...nav().querySelectorAll<HTMLAnchorElement>('a')].filter((a) => !a.classList.contains('nav-donate'));
expect(links.map((a) => [a.textContent, a.getAttribute('href'), a.hasAttribute('data-link')])).toEqual([
['Community', '/#community', true],
['Gestalten', '/gestalten', true],
['Meine Karten', '/karten', true],
]);
expect(nav().getAttribute('aria-label')).toBe('Hauptnavigation');
});
it('Suchfeld mit Platzhalter und Beschriftung', () => {
mount();
expect(input().placeholder).toBe('Titel, Tags oder Herausgeber');
expect(input().type).toBe('search');
expect(input().getAttribute('aria-label')).toBe('Community-Karten durchsuchen');
expect(search().getAttribute('role')).toBe('search');
});
});
describe('mountHeader: Suche', () => {
it('Enter mit „ visa “ navigiert zu /?q=visa#community', () => {
mount();
input().value = ' visa ';
submit();
expect(mocks.navigate).toHaveBeenCalledOnce();
expect(mocks.navigate).toHaveBeenCalledWith('/?q=visa#community');
});
it('kodiert Sonderzeichen', () => {
mount();
input().value = 'a b&c=d#e';
submit();
expect(mocks.navigate).toHaveBeenCalledWith('/?q=a%20b%26c%3Dd%23e#community');
});
it('leere oder nur aus Leerzeichen bestehende Eingabe: nichts passiert', () => {
mount();
input().value = '';
submit();
input().value = ' ';
submit();
expect(mocks.navigate).not.toHaveBeenCalled();
});
it('das Absenden lädt die Seite nicht neu (Standardaktion verhindert)', () => {
mount();
input().value = 'visa';
const event = new Event('submit', { bubbles: true, cancelable: true });
search().dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
});
});
describe('mountHeader: Spenden-Knopf', () => {
it('ohne donateUrl gibt es keinen ☕-Knopf', () => {
mount('');
expect(header.querySelector('.header-donate')).toBeNull();
expect(header.querySelector('.nav-donate')).toBeNull();
expect(header.textContent).not.toContain('☕');
});
it('mit donateUrl: Link „☕ Unterstützen“ in neuem Tab', () => {
mount(DONATE);
const link = header.querySelector<HTMLAnchorElement>('a.header-donate')!;
expect(link.getAttribute('href')).toBe(DONATE);
expect(link.target).toBe('_blank');
expect(link.rel).toBe('noopener noreferrer');
expect(link.textContent).toContain('☕');
expect(link.textContent).toContain('Unterstützen');
expect(link.hasAttribute('data-link')).toBe(false);
});
});
describe('mountHeader: Hell/Dunkel', () => {
it('Knopf „Hell/Dunkel umschalten“ wechselt data-theme und speichert', () => {
mount();
const button = header.querySelector<HTMLButtonElement>('button.theme-btn')!;
expect(button.getAttribute('aria-label')).toBe('Hell/Dunkel umschalten');
button.click();
expect(html.getAttribute('data-theme')).toBe('light');
expect(localStorage.getItem('aircard.theme')).toBe('light');
button.click();
expect(html.getAttribute('data-theme')).toBe('dark');
});
});
describe('mountHeader: Menü unter 900 px', () => {
it('Menü-Knopf öffnet und schließt die Navigation (aria-expanded)', () => {
mount();
const button = menuButton();
expect(button.getAttribute('aria-label')).toBe('Menü');
expect(button.getAttribute('aria-controls')).toBe(nav().id);
expect(button.getAttribute('aria-expanded')).toBe('false');
expect(nav().classList.contains('is-open')).toBe(false);
button.click();
expect(button.getAttribute('aria-expanded')).toBe('true');
expect(nav().classList.contains('is-open')).toBe(true);
button.click();
expect(button.getAttribute('aria-expanded')).toBe('false');
expect(nav().classList.contains('is-open')).toBe(false);
});
it('Escape schließt das Menü und gibt den Fokus an den Knopf', () => {
mount();
menuButton().click();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(nav().classList.contains('is-open')).toBe(false);
expect(menuButton().getAttribute('aria-expanded')).toBe('false');
expect(document.activeElement).toBe(menuButton());
});
it('ein Klick auf einen Navigationslink oder außerhalb schließt das Menü', () => {
mount();
menuButton().click();
nav().querySelector('a')!.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
expect(nav().classList.contains('is-open')).toBe(false);
menuButton().click();
document.body.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(nav().classList.contains('is-open')).toBe(false);
});
it('Suchen schließt das Menü ebenfalls', () => {
mount();
menuButton().click();
input().value = 'visa';
submit();
expect(nav().classList.contains('is-open')).toBe(false);
});
});
describe('mountHeader: Aufräumen', () => {
it('entfernt Ergänztes; Marke und Konto-Slot bleiben', () => {
mount(DONATE);
dispose?.();
dispose = null;
expect([...header.children].map((c) => c.className || c.id)).toEqual(['brand', 'account-slot']);
});
it('meldet alle Dokument-Listener wieder ab', () => {
const added = vi.spyOn(document, 'addEventListener');
const removed = vi.spyOn(document, 'removeEventListener');
mount(DONATE);
expect(added).toHaveBeenCalled();
dispose?.();
dispose = null;
for (const [type, listener] of added.mock.calls) {
expect(removed.mock.calls.some(([t, l]) => t === type && l === listener)).toBe(true);
}
vi.restoreAllMocks();
});
});
+113
View File
@@ -0,0 +1,113 @@
// Kopfzeile: Marke und Konto-Slot stehen in index.html, hier kommen Navigation, Suche und Knöpfe dazu.
// Ab 900 px Breite liegt alles in einer Zeile; darunter steckt die Navigation hinter dem Menü-Knopf
// und das Suchfeld bekommt eine eigene Zeile (Stile in shell.css).
import './shell.css';
import { el, icon } from '../editor/dom';
import { navigate } from '../pages/router';
import { toggleTheme } from './theme';
export interface HeaderOptions {
/** Spenden-Link; leer = kein Spenden-Knopf. */
donateUrl: string;
}
export function mountHeader(header: HTMLElement, opts: HeaderOptions): () => void {
const brand = header.querySelector('.brand');
const accountSlot = header.querySelector('#account-slot');
const hasDonate = opts.donateUrl.trim() !== '';
const link = (label: string, href: string) => el('a', { href, 'data-link': true }, label);
const donateAttrs = { href: opts.donateUrl, target: '_blank', rel: 'noopener noreferrer' };
const nav = el(
'nav',
{ class: 'app-nav', id: 'app-nav', 'aria-label': 'Hauptnavigation' },
link('Community', '/#community'),
link('Gestalten', '/gestalten'),
link('Meine Karten', '/karten'),
);
// Unter 600 px ist in der Kopfzeile kein Platz mehr für den Knopf: er steht dann im aufgeklappten Menü.
if (hasDonate) nav.append(el('a', { class: 'nav-donate', ...donateAttrs }, '☕ Unterstützen'));
const input = el('input', {
class: 'header-search-input',
type: 'search',
name: 'q',
placeholder: 'Titel, Tags oder Herausgeber',
'aria-label': 'Community-Karten durchsuchen',
autocomplete: 'off',
enterkeyhint: 'search',
});
const searchButton = el('button', { class: 'header-search-btn', type: 'submit', 'aria-label': 'Suchen' }, icon('search'));
const search = el('form', { class: 'header-search', role: 'search' }, input, searchButton);
const themeButton = el(
'button',
{ class: 'header-btn theme-btn', type: 'button', 'aria-label': 'Hell/Dunkel umschalten', title: 'Hell/Dunkel umschalten' },
icon('sun', 'theme-icon-sun'),
icon('moon', 'theme-icon-moon'),
);
themeButton.addEventListener('click', () => void toggleTheme());
const menuButton = el(
'button',
{ class: 'header-btn menu-btn', type: 'button', 'aria-label': 'Menü', 'aria-expanded': 'false', 'aria-controls': nav.id },
icon('menu'),
);
const tools = el('div', { class: 'header-tools' });
if (hasDonate) {
tools.append(
el(
'a',
{ class: 'header-donate', ...donateAttrs },
el('span', { class: 'header-donate-icon', 'aria-hidden': 'true' }, '☕'),
el('span', { class: 'header-donate-text' }, 'Unterstützen'),
),
);
}
tools.append(themeButton, menuButton);
// Menü (nur unter 900 px sichtbar)
let open = false;
function setOpen(next: boolean): void {
open = next;
nav.classList.toggle('is-open', next);
menuButton.setAttribute('aria-expanded', String(next));
}
menuButton.addEventListener('click', () => setOpen(!open));
const onDocumentClick = (event: MouseEvent) => {
if (!open) return;
const target = event.target as Element | null;
if (target && menuButton.contains(target)) return; // das erledigt der Knopf selbst
if (target && nav.contains(target) && !target.closest('a')) return;
setOpen(false);
};
const onKeydown = (event: KeyboardEvent) => {
if (event.key !== 'Escape' || !open) return;
setOpen(false);
menuButton.focus();
};
document.addEventListener('click', onDocumentClick);
document.addEventListener('keydown', onKeydown);
search.addEventListener('submit', (event) => {
event.preventDefault();
const q = input.value.trim();
if (!q) return;
setOpen(false);
input.blur(); // schließt auf dem Handy die Bildschirmtastatur
navigate(`/?q=${encodeURIComponent(q)}#community`);
});
header.replaceChildren(...[brand, nav, search, tools, accountSlot].filter((node): node is Element => node !== null));
return () => {
document.removeEventListener('click', onDocumentClick);
document.removeEventListener('keydown', onKeydown);
nav.remove();
search.remove();
tools.remove();
};
}
+416
View File
@@ -0,0 +1,416 @@
/* Rahmen der Seite: Spenden-Banner, Kopfzeile (Marke, Navigation, Suche, Knöpfe, Konto). */
/* Spenden-Banner */
.donate-banner {
position: relative;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 2px 14px;
padding: 8px 48px 8px 16px;
background: var(--primary);
color: var(--on-primary);
font-size: 0.95rem;
line-height: 1.35;
text-align: center;
}
.donate-banner-text {
margin: 0;
}
.donate-banner-link {
color: inherit;
font-weight: 800;
text-decoration: underline;
text-underline-offset: 3px;
white-space: nowrap;
}
.donate-banner-link:hover {
text-decoration-thickness: 2px;
}
.donate-banner :focus-visible {
outline-color: var(--on-primary);
}
.donate-banner-close {
appearance: none;
position: absolute;
top: 50%;
right: 6px;
display: grid;
place-items: center;
width: 40px;
height: 40px;
margin: -20px 0 0;
padding: 0;
border: 0;
border-radius: 12px;
background: transparent;
color: inherit;
font: inherit;
font-size: 1.6rem;
line-height: 1;
cursor: pointer;
}
.donate-banner-close:hover {
background: rgb(255 255 255 / 0.16);
}
/* Kopfzeile */
#app-header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 14px;
padding: 10px 20px;
background: var(--panel);
border-bottom: 1px solid var(--line);
}
.brand {
display: inline-flex;
align-items: center;
gap: 10px;
margin-right: 6px;
color: var(--text);
font-size: 1.35rem;
font-weight: 800;
letter-spacing: 0.01em;
text-decoration: none;
white-space: nowrap;
}
.brand img {
display: block;
flex: none;
width: 28px;
height: 28px;
}
.app-nav {
display: flex;
align-items: center;
gap: 2px;
}
.app-nav a {
padding: 8px 12px;
border-radius: 10px;
color: var(--muted);
font-weight: 600;
text-decoration: none;
white-space: nowrap;
}
.app-nav a:hover {
background: var(--panel-2);
color: var(--text);
}
.nav-donate {
display: none;
}
/* Suche */
.header-search {
display: flex;
flex: 1 1 120px; /* kleine Basis: die Zeile bricht erst um, wenn auch das Suchfeld nicht mehr schrumpfen kann */
align-items: center;
max-width: 360px;
min-width: 0;
height: 42px;
border: 1px solid var(--line-strong);
border-radius: 999px;
background: var(--bg);
}
.header-search:focus-within {
border-color: var(--sel-line);
outline: 2px solid var(--sel-line);
outline-offset: 1px;
}
.header-search-input {
flex: 1;
min-width: 0;
height: 100%;
padding: 0 4px 0 16px;
border: 0;
background: transparent;
color: var(--text);
font: inherit;
font-size: 1rem; /* mindestens 16 px, sonst zoomt iOS beim Fokussieren */
outline: none;
appearance: none;
}
.header-search-input::placeholder {
color: var(--muted);
opacity: 1;
}
.header-search-input::-webkit-search-cancel-button {
appearance: none;
}
.header-search-btn {
appearance: none;
display: grid;
flex: none;
place-items: center;
width: 40px;
height: 100%;
margin: 0;
padding: 0;
border: 0;
border-radius: 0 999px 999px 0;
background: transparent;
color: var(--muted);
cursor: pointer;
}
.header-search-btn:hover {
color: var(--text);
}
/* Knöpfe rechts: Spenden, Hell/Dunkel, Menü */
.header-tools {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.header-btn {
appearance: none;
display: grid;
place-items: center;
width: 40px;
height: 40px;
margin: 0;
padding: 0;
border: 1px solid var(--line-strong);
border-radius: 12px;
background: transparent;
color: var(--text);
cursor: pointer;
transition: background-color 0.12s, border-color 0.12s;
}
.header-btn:hover {
border-color: var(--sel-line);
background: var(--panel-2);
}
.header-btn .icon {
width: 20px;
height: 20px;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.header-search-btn .icon {
width: 18px;
height: 18px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
/* Sonne im dunklen Theme (Klick = hell), Mond im hellen (Klick = dunkel); ohne Attribut gilt dunkel */
:root[data-theme='light'] .theme-icon-sun,
:root:not([data-theme='light']) .theme-icon-moon {
display: none;
}
.menu-btn {
display: none;
}
.header-donate {
position: relative;
display: inline-flex;
align-items: center;
gap: 8px;
height: 40px;
padding: 0 14px;
border: 1px solid var(--sel-line);
border-radius: 12px;
background: var(--sel-bg);
color: var(--text);
font-weight: 700;
text-decoration: none;
white-space: nowrap;
transition: background-color 0.12s;
}
.header-donate:hover {
background: var(--panel-2);
}
/* Konto-Slot */
#account-slot {
display: flex;
align-items: center;
min-width: 0;
}
/* Bis 1179 px zeigt der Spenden-Knopf nur die Tasse; der Text bleibt für Screenreader */
@media (max-width: 1179px) {
.header-donate {
justify-content: center;
width: 40px;
padding: 0;
}
.header-donate-text {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
}
/* Desktop mit wenig Platz (900–1099 px): alles etwas enger, damit die Zeile nicht umbricht */
@media (min-width: 900px) and (max-width: 1099px) {
#app-header {
gap: 10px;
}
.brand {
margin-right: 0;
font-size: 1.2rem;
}
.app-nav a {
padding: 8px 9px;
font-size: 0.95rem;
}
}
/* Unter 900 px: Navigation hinter dem Menü-Knopf, Suchfeld als eigene Zeile darunter */
@media (max-width: 899px) {
#app-header {
padding: 10px 16px;
gap: 10px 8px;
}
.menu-btn {
display: grid;
}
.header-tools {
order: 1;
}
#account-slot {
order: 2;
}
.app-nav {
display: none;
flex-basis: 100%;
flex-direction: column;
align-items: stretch;
order: 3;
padding: 6px;
border: 1px solid var(--line);
border-radius: 14px;
background: var(--bg);
}
.app-nav.is-open {
display: flex;
}
.app-nav a {
padding: 12px 14px;
font-size: 1.05rem;
}
.header-search {
flex: 1 1 100%;
order: 4;
max-width: none;
}
}
/* Unter 600 px: Marke und Knöpfe etwas kleiner, damit „Anmelden“ noch in die erste Zeile passt;
der Spenden-Knopf wandert ins aufgeklappte Menü */
@media (max-width: 599px) {
#app-header {
gap: 10px 6px;
}
.brand {
gap: 8px;
margin-right: 0;
font-size: 1.2rem;
}
.brand img {
width: 24px;
height: 24px;
}
.header-tools {
gap: 6px;
}
.header-btn {
width: 38px;
height: 38px;
}
#account-slot .auth-btn-small {
min-height: 38px;
padding: 0 12px;
}
.header-donate {
display: none;
}
.nav-donate {
display: block;
color: var(--text);
}
}
/* Sehr schmale Telefone */
@media (max-width: 379px) {
#app-header {
padding: 10px 12px;
}
.brand {
font-size: 1.1rem;
}
}
@media (prefers-reduced-motion: reduce) {
.header-btn,
.header-donate {
transition: none;
}
}
+158
View File
@@ -0,0 +1,158 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DEFAULT_CONFIG } from '../card/config';
import { renderCard, type CardAssets, type Ctx } from '../card/render';
import { applyTheme, currentTheme, initialTheme, initTheme, toggleTheme } from './theme';
const KEY = 'aircard.theme';
const html = document.documentElement;
function mockMatchMedia(prefersDark: boolean) {
window.matchMedia = vi.fn((query: string) => ({
matches: query === '(prefers-color-scheme: dark)' ? prefersDark : false,
media: query,
addEventListener: () => {},
removeEventListener: () => {},
})) as unknown as typeof window.matchMedia;
}
beforeEach(() => {
localStorage.clear();
html.removeAttribute('data-theme');
document.head.innerHTML = '<meta name="theme-color" content="#0F1828" />';
mockMatchMedia(true);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('initialTheme', () => {
it('ohne gespeicherten Wert gilt das System', () => {
expect(initialTheme(null, true)).toBe('dark');
expect(initialTheme(null, false)).toBe('light');
});
it('ein gespeicherter Wert schlägt das System', () => {
expect(initialTheme('light', true)).toBe('light');
expect(initialTheme('dark', false)).toBe('dark');
});
it('ein ungültiger Wert wird ignoriert (System)', () => {
expect(initialTheme('blau', true)).toBe('dark');
expect(initialTheme('blau', false)).toBe('light');
expect(initialTheme('', false)).toBe('light');
expect(initialTheme('LIGHT', true)).toBe('dark');
});
});
describe('applyTheme', () => {
it('setzt data-theme auf <html> und die Browserfarbe, speichert aber nichts', () => {
applyTheme('light');
expect(html.getAttribute('data-theme')).toBe('light');
expect(document.querySelector('meta[name="theme-color"]')?.getAttribute('content')).toBe('#F4F6FB');
applyTheme('dark');
expect(html.getAttribute('data-theme')).toBe('dark');
expect(document.querySelector('meta[name="theme-color"]')?.getAttribute('content')).toBe('#0F1828');
expect(localStorage.getItem(KEY)).toBeNull();
});
it('kommt auch ohne theme-color-Tag klar', () => {
document.head.innerHTML = '';
expect(() => applyTheme('light')).not.toThrow();
expect(html.getAttribute('data-theme')).toBe('light');
});
});
describe('initTheme', () => {
it('nimmt den gespeicherten Wert', () => {
localStorage.setItem(KEY, 'light');
expect(initTheme()).toBe('light');
expect(html.getAttribute('data-theme')).toBe('light');
});
it('ohne gespeicherten Wert das System (dunkel bzw. hell)', () => {
expect(initTheme()).toBe('dark');
mockMatchMedia(false);
expect(initTheme()).toBe('light');
});
it('wirft localStorage beim Lesen, gilt das System', () => {
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new DOMException('blockiert', 'SecurityError');
});
mockMatchMedia(false);
expect(initTheme()).toBe('light');
expect(html.getAttribute('data-theme')).toBe('light');
});
it('fehlt matchMedia, bleibt es dunkel', () => {
window.matchMedia = undefined as unknown as typeof window.matchMedia;
expect(initTheme()).toBe('dark');
});
});
describe('toggleTheme', () => {
it('wechselt zwischen dunkel und hell, setzt data-theme und speichert', () => {
applyTheme('dark');
expect(toggleTheme()).toBe('light');
expect(html.getAttribute('data-theme')).toBe('light');
expect(localStorage.getItem(KEY)).toBe('light');
expect(toggleTheme()).toBe('dark');
expect(html.getAttribute('data-theme')).toBe('dark');
expect(localStorage.getItem(KEY)).toBe('dark');
});
it('ohne data-theme geht es vom Systemwert aus', () => {
mockMatchMedia(false); // System hell → Wechsel auf dunkel
expect(toggleTheme()).toBe('dark');
});
it('wirft localStorage beim Schreiben, gibt es keinen Fehler und der Wechsel gilt trotzdem', () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new DOMException('voll', 'QuotaExceededError');
});
applyTheme('dark');
expect(() => toggleTheme()).not.toThrow();
expect(html.getAttribute('data-theme')).toBe('light');
expect(currentTheme()).toBe('light');
});
});
describe('Theme und Kartenausgabe', () => {
// Umschalten verändert nur UI-Tokens: der Renderer liest weder data-theme noch CSS-Variablen.
function drawLog(): string[] {
const log: string[] = [];
const state: Record<string, unknown> = {};
const ctx = new Proxy(state, {
get(target, prop: string) {
if (prop in target) return target[prop];
if (prop === 'measureText') return (text: string) => ({ width: text.length * 20 });
if (prop === 'createLinearGradient') {
return (...args: number[]) => {
log.push(`gradient(${args.join(',')})`);
return { addColorStop: (offset: number, color: string) => log.push(`stop(${offset},${color})`) };
};
}
return (...args: unknown[]) => void log.push(`${prop}(${args.map((a) => (typeof a === 'object' ? 'obj' : String(a))).join(',')})`);
},
set(target, prop: string, value) {
target[prop] = value;
log.push(`${prop}=${String(value)}`);
return true;
},
});
const assets: CardAssets = { image: null, network: {} as CanvasImageSource, brand: {} as CanvasImageSource };
renderCard(ctx as unknown as Ctx, { ...DEFAULT_CONFIG, template: 'classic' }, assets, () => log.push('strokePath'));
return log;
}
it('hell und dunkel zeichnen dieselbe Karte', () => {
applyTheme('dark');
const dark = drawLog();
applyTheme('light');
const light = drawLog();
expect(dark.length).toBeGreaterThan(10);
expect(light).toEqual(dark);
expect(dark.join('\n')).toContain('fillStyle=#1B2738');
});
});
+63
View File
@@ -0,0 +1,63 @@
// Hell/Dunkel: `data-theme` auf <html>, Auswahl in localStorage `aircard.theme`, Standard = System.
// Das Skript im <head> von index.html setzt das Attribut schon vor dem ersten Paint (kein Aufblitzen).
// Das Theme betrifft nur die Oberfläche (CSS-Tokens); die Karte selbst (Vorschau, PNG) bleibt davon unberührt.
export type Theme = 'light' | 'dark';
const STORAGE_KEY = 'aircard.theme';
// Farbe der Browserleiste (Handy), passend zu `--bg` des jeweiligen Themes.
const BROWSER_COLOR: Record<Theme, string> = { dark: '#0F1828', light: '#F4F6FB' };
/** Gespeicherter Wert, sonst System; ein ungültiger Wert zählt als „nichts gespeichert“. */
export function initialTheme(stored: string | null, prefersDark: boolean): Theme {
if (stored === 'light' || stored === 'dark') return stored;
return prefersDark ? 'dark' : 'light';
}
function readStored(): string | null {
try {
return localStorage.getItem(STORAGE_KEY);
} catch {
return null; // Speicher gesperrt (z. B. privates Fenster): es gilt das System
}
}
function systemPrefersDark(): boolean {
try {
// Ohne Angabe (alter Browser) bleibt es beim bisherigen dunklen Look.
return window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)').matches : true;
} catch {
return true;
}
}
/** Setzt das Theme (Attribut und Browserleiste), ohne etwas zu speichern. */
export function applyTheme(theme: Theme): void {
document.documentElement.setAttribute('data-theme', theme);
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', BROWSER_COLOR[theme]);
}
/** Das gerade angezeigte Theme (Attribut, sonst gespeicherter Wert bzw. System). */
export function currentTheme(): Theme {
const attr = document.documentElement.getAttribute('data-theme');
return attr === 'light' || attr === 'dark' ? attr : initialTheme(readStored(), systemPrefersDark());
}
/** Wendet beim Start das gespeicherte bzw. das System-Theme an. */
export function initTheme(): Theme {
const theme = initialTheme(readStored(), systemPrefersDark());
applyTheme(theme);
return theme;
}
/** Wechselt Hell ↔ Dunkel und merkt es sich; ein gesperrter Speicher ist kein Fehler. */
export function toggleTheme(): Theme {
const next: Theme = currentTheme() === 'dark' ? 'light' : 'dark';
applyTheme(next);
try {
localStorage.setItem(STORAGE_KEY, next);
} catch {
// gilt dann nur für diese Sitzung
}
return next;
}
+33 -58
View File
@@ -1,4 +1,5 @@
:root { :root,
:root[data-theme='dark'] {
--bg: #0F1828; --bg: #0F1828;
--panel: #162133; --panel: #162133;
--panel-2: #1B2738; --panel-2: #1B2738;
@@ -10,12 +11,43 @@
--muted: #9DB0C8; --muted: #9DB0C8;
--primary: #2A4566; --primary: #2A4566;
--primary-hover: #34557C; --primary-hover: #34557C;
--on-primary: #E8EEF7;
--danger: #E5484D; --danger: #E5484D;
--danger-text: #ff9a9d;
--warn-text: #f2c66d;
--ok-text: #8fd9a8;
--backdrop: rgb(6 11 20 / 0.72);
--shadow-lg: 0 24px 80px rgb(0 0 0 / 0.55);
--shadow-card: 0 10px 34px rgb(0 0 0 / 0.45);
--radius: 14px; --radius: 14px;
color-scheme: dark; color-scheme: dark;
} }
/* Hell: nur die Oberfläche; die Kartenfarben (Vorschau, PNG) hängen nicht vom Theme ab. */
:root[data-theme='light'] {
--bg: #F4F6FB;
--panel: #FFFFFF;
--panel-2: #EEF1F7;
--line: #D9DFEA;
--line-strong: #B8C2D3;
--sel-bg: #E3ECFB;
--sel-line: #3A6FD8;
--text: #0F1828;
--muted: #5B6B82;
--primary: #2A4566;
--primary-hover: #34557C;
--on-primary: #FFFFFF;
--danger-text: #B42318;
--warn-text: #8A5A00;
--ok-text: #1B6B3A;
--backdrop: rgb(15 24 40 / 0.5);
--shadow-lg: 0 24px 80px rgb(15 24 40 / 0.28);
--shadow-card: 0 10px 34px rgb(15 24 40 / 0.22);
color-scheme: light;
}
*, *,
*::before, *::before,
*::after { *::after {
@@ -47,63 +79,6 @@ a {
outline-offset: 2px; outline-offset: 2px;
} }
#app-header {
display: flex;
align-items: center;
gap: 24px;
padding: 14px 20px;
background: var(--panel);
border-bottom: 1px solid var(--line);
}
.brand {
display: inline-flex;
align-items: center;
gap: 10px;
color: var(--text);
font-size: 1.35rem;
font-weight: 800;
letter-spacing: 0.01em;
text-decoration: none;
white-space: nowrap;
}
.brand img {
display: block;
flex: none;
width: 28px;
height: 28px;
}
/* Schmale Bildschirme: Marke etwas kleiner, damit sie samt „Meine Karten“ in die erste Kopfzeile passt */
@media (max-width: 599px) {
.brand {
gap: 8px;
font-size: 1.2rem;
}
.brand img {
width: 24px;
height: 24px;
}
}
.app-nav a {
color: var(--muted);
font-weight: 600;
text-decoration: none;
}
.app-nav a:hover {
color: var(--text);
}
#account-slot {
display: flex;
align-items: center;
margin-left: auto;
}
#app-root { #app-root {
flex: 1; flex: 1;
width: 100%; width: 100%;