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:
1 parent
1a64e12bc5
commit
25ff496548
30 files changed
+1783
-189
No files matched your search
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 () => {};
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user