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

+2 -2
View File
@@ -141,7 +141,7 @@ describe('Meine Karten: Zustände', () => {
mount();
await vi.waitFor(() => expect(text()).toContain('Noch keine Karten gespeichert.'));
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 () => {
@@ -247,7 +247,7 @@ describe('Meine Karten: Öffnen und Herunterladen', () => {
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
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 () => {
+2 -2
View File
@@ -156,7 +156,7 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
state = 'list';
thumbSlots.clear();
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));
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', () => {
void run(async () => {
+3 -3
View File
@@ -10,7 +10,7 @@
}
.cards-msg-error {
color: #ff9a9d;
color: var(--danger-text);
}
.cards-actions {
@@ -85,7 +85,7 @@
.card-msg {
margin: 0;
color: #ff9a9d;
color: var(--danger-text);
font-size: 0.9rem;
}
@@ -94,7 +94,7 @@
}
.card-msg.is-warning {
color: #f2c66d;
color: var(--warn-text);
}
.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 { matchRoute, navigate, startRouter } from './router';
import { legacyRedirect, matchRoute, navigate, ROUTE_TITLES, startRouter, titleFor } from './router';
describe('matchRoute', () => {
it('ordnet / dem Editor zu', () => {
expect(matchRoute('/')).toBe('editor');
it('ordnet / der Startseite zu', () => {
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', () => {
expect(matchRoute('/karten')).toBe('cards');
expect(matchRoute('/karten/')).toBe('cards');
expect(matchRoute('/karten')).toEqual({ id: 'cards', params: {} });
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', () => {
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', () => {
expect(matchRoute('/irgendwas')).toBe('editor');
it('fällt bei unbekannten Pfaden auf die Startseite zurück', () => {
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();
startRouter(render);
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', () => {
@@ -49,23 +111,23 @@ describe('startRouter', () => {
navigate('/passwort-neu');
expect(location.pathname).toBe('/passwort-neu');
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', () => {
const order: string[] = [];
startRouter((route) => {
order.push(`render:${route}`);
return () => order.push(`cleanup:${route}`);
order.push(`render:${route.id}`);
return () => order.push(`cleanup:${route.id}`);
});
navigate('/karten');
navigate('/');
expect(order).toEqual([
'render:editor',
'cleanup:editor',
'render:home',
'cleanup:home',
'render:cards',
'cleanup:cards',
'render:editor',
'render:home',
]);
});
@@ -91,7 +153,7 @@ describe('startRouter', () => {
link.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
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', () => {
@@ -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)', () => {
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
try {
@@ -159,13 +275,17 @@ describe('startRouter', () => {
vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
try {
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');
expect(document.title).toBe('Meine Karten – AirCard-IOS');
navigate('/community/abc123');
expect(document.title).toBe('Community – AirCard-IOS');
navigate('/passwort-neu');
expect(document.title).toBe('Passwort neu setzen – AirCard-IOS');
navigate('/');
expect(document.title).toBe('AirCard-IOS – Kartenmotiv gestalten');
expect(document.title).toBe('AirCard-IOS – Kartendesigns für Apple Wallet');
} finally {
vi.restoreAllMocks();
}
@@ -177,6 +297,13 @@ describe('startRouter', () => {
startRouter(render);
history.pushState(null, '', '/passwort-neu');
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 function matchRoute(pathname: string): RouteId {
const path = pathname.replace(/\/+$/, '') || '/';
if (path === '/karten') return 'cards';
if (path === '/passwort-neu') return 'reset';
return 'editor';
export interface Route {
id: RouteId;
params: { id?: string };
}
const TITLES: Record<RouteId, string> = {
editor: 'AirCard-IOS – Kartenmotiv gestalten',
type Render = (route: Route, root: HTMLElement) => void | (() => void);
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',
community: 'Community – 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.
let stopCurrent: (() => 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 {
const url = new URL(path, location.href);
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) {
history.pushState(null, '', target);
}
renderCurrent?.();
// Eine neue Seite beginnt oben (pushState setzt die Scrollposition nicht zurück); bei popstate bleibt es dem Browser überlassen.
if (renderCurrent) window.scrollTo(0, 0);
if (!renderCurrent) return;
if (!hashOnly) renderCurrent();
// 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 {
@@ -42,7 +90,7 @@ export function startRouter(render: Render): void {
cleanup?.();
cleanup = undefined;
const route = matchRoute(location.pathname);
document.title = TITLES[route];
document.title = titleFor(route);
cleanup = render(route, root);
};