feat(web): Projektgerüst, Router, Grundlayout

Vite + TypeScript ohne UI-Framework in web/, exakte Versionen, History-Router
(matchRoute, navigate, startRouter mit Cleanup, a[data-link], popstate),
Grundlayout mit Kopfzeile, Fußzeile und Platzhalterseiten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-04 07:23:01 +00:00
1 parent ba93d8f8c5
commit 05a735993b
13 files changed
+1664

No files matched your search

+23
View File
@@ -0,0 +1,23 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AirCard – Kartenmotiv gestalten</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
</head>
<body>
<header id="app-header">
<a class="brand" href="/" data-link>AirCard</a>
<nav class="app-nav" aria-label="Hauptnavigation">
<a href="/karten" data-link>Meine Karten</a>
</nav>
<div id="account-slot"></div>
</header>
<main id="app-root"></main>
<footer id="app-footer">
<p>Inoffizielles Hobbyprojekt. Keine Verbindung zu Apple, Visa, Mastercard, UnionPay, American Express, JCB oder Discover. Alle Marken gehören ihren Inhabern.</p>
</footer>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1253
View File
File diff suppressed because it is too large. Load diff
+21
View File
@@ -0,0 +1,21 @@
{
"name": "aircard-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"test": "vitest run",
"preview": "vite preview"
},
"dependencies": {
"@fontsource-variable/nunito-sans": "5.3.0"
},
"devDependencies": {
"happy-dom": "20.14.5",
"typescript": "6.0.3",
"vite": "8.3.2",
"vitest": "5.0.3"
}
}
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect x="4" y="12" width="56" height="40" rx="6" fill="#1B2738" stroke="#8FB4E8" stroke-width="3"/>
<rect x="10" y="22" width="14" height="10" rx="2" fill="#8FB4E8"/>
</svg>

After

Width:  |  Height:  |  Size: 239 B

+16
View File
@@ -0,0 +1,16 @@
import '@fontsource-variable/nunito-sans';
import './styles.css';
import { startRouter, type RouteId } from './pages/router';
// Platzhalter, bis die Seiten aus den Folgeaufgaben (Editor, Karten, Passwort) angebunden sind.
const PLACEHOLDER_TITLES: Record<RouteId, string> = {
editor: 'Editor',
cards: 'Meine Karten',
reset: 'Passwort neu setzen',
};
startRouter((route, root) => {
const heading = document.createElement('h1');
heading.textContent = PLACEHOLDER_TITLES[route];
root.replaceChildren(heading);
});
+135
View File
@@ -0,0 +1,135 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { matchRoute, navigate, startRouter } from './router';
describe('matchRoute', () => {
it('ordnet / dem Editor zu', () => {
expect(matchRoute('/')).toBe('editor');
});
it('ordnet /karten und /karten/ den Karten zu', () => {
expect(matchRoute('/karten')).toBe('cards');
expect(matchRoute('/karten/')).toBe('cards');
});
it('ordnet /passwort-neu dem Zurücksetzen zu', () => {
expect(matchRoute('/passwort-neu')).toBe('reset');
});
it('fällt bei unbekannten Pfaden auf den Editor zurück', () => {
expect(matchRoute('/irgendwas')).toBe('editor');
});
});
// Jeder startRouter-Aufruf baut den vorherigen Router (Listener + Seite) ab,
// die Tests brauchen daher keinen eigenen Aufräum-Schritt.
describe('startRouter', () => {
let root: HTMLElement;
beforeEach(() => {
document.body.innerHTML =
'<header id="app-header"><a id="to-cards" href="/karten" data-link>Meine Karten</a>' +
'<a id="plain" href="/karten">ohne data-link</a></header>' +
'<main id="app-root"></main>';
root = document.getElementById('app-root') as HTMLElement;
history.replaceState(null, '', '/');
});
it('rendert die Route der aktuellen URL in #app-root', () => {
history.replaceState(null, '', '/karten');
const render = vi.fn();
startRouter(render);
expect(render).toHaveBeenCalledTimes(1);
expect(render).toHaveBeenCalledWith('cards', root);
});
it('navigate setzt die URL per pushState und rendert neu', () => {
const render = vi.fn();
startRouter(render);
const lengthBefore = history.length;
navigate('/passwort-neu');
expect(location.pathname).toBe('/passwort-neu');
expect(history.length).toBe(lengthBefore + 1);
expect(render).toHaveBeenLastCalledWith('reset', 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}`);
});
navigate('/karten');
navigate('/');
expect(order).toEqual([
'render:editor',
'cleanup:editor',
'render:cards',
'cleanup:cards',
'render:editor',
]);
});
it('baut beim erneuten Start den alten Router ab', () => {
const cleanup = vi.fn();
const first = vi.fn(() => cleanup);
startRouter(first);
const second = vi.fn();
startRouter(second);
expect(cleanup).toHaveBeenCalledTimes(1);
first.mockClear();
navigate('/karten');
expect(first).not.toHaveBeenCalled();
expect(second).toHaveBeenCalledTimes(2);
});
it('fängt Klicks auf a[data-link] ab und navigiert ohne Neuladen', () => {
const render = vi.fn();
startRouter(render);
const link = document.getElementById('to-cards') as HTMLAnchorElement;
const event = new MouseEvent('click', { bubbles: true, cancelable: true, button: 0 });
link.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
expect(location.pathname).toBe('/karten');
expect(render).toHaveBeenLastCalledWith('cards', root);
});
it('lässt Klicks auf Links ohne data-link und Klicks mit Modifier-Taste unberührt', () => {
const render = vi.fn();
startRouter(render);
render.mockClear();
// Der window-Listener läuft nach dem Router (document) und hält nur die echte
// Browser-Navigation im Test an; zuvor merkt er sich, ob der Router eingegriffen hat.
const prevented: boolean[] = [];
const stopBrowser = (e: Event) => {
prevented.push(e.defaultPrevented);
e.preventDefault();
};
window.addEventListener('click', stopBrowser);
const click = (id: string, init: MouseEventInit = {}) =>
document
.getElementById(id)
?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, button: 0, ...init }));
click('plain');
click('to-cards', { ctrlKey: true });
click('to-cards', { metaKey: true });
click('to-cards', { shiftKey: true });
click('to-cards', { button: 1 });
window.removeEventListener('click', stopBrowser);
expect(prevented).toEqual([false, false, false, false, false]);
expect(render).not.toHaveBeenCalled();
expect(location.pathname).toBe('/');
});
it('rendert bei popstate die dann aktuelle Route', () => {
const render = vi.fn();
startRouter(render);
history.pushState(null, '', '/passwort-neu');
window.dispatchEvent(new PopStateEvent('popstate'));
expect(render).toHaveBeenLastCalledWith('reset', root);
});
});
+64
View File
@@ -0,0 +1,64 @@
export type RouteId = 'editor' | 'cards' | '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';
}
// Beendet den laufenden Router (Seiten-Cleanup, Listener); null, solange keiner läuft.
let stopCurrent: (() => void) | null = null;
let renderCurrent: (() => void) | null = null;
export function navigate(path: string): void {
const url = new URL(path, location.href);
const target = url.pathname + url.search + url.hash;
if (target !== location.pathname + location.search + location.hash) {
history.pushState(null, '', target);
}
renderCurrent?.();
}
export function startRouter(render: Render): void {
stopCurrent?.();
const root = document.getElementById('app-root');
if (!root) throw new Error('#app-root fehlt');
let cleanup: void | (() => void);
const renderPage = () => {
cleanup?.();
cleanup = undefined;
cleanup = render(matchRoute(location.pathname), root);
};
const onClick = (event: MouseEvent) => {
if (event.defaultPrevented || event.button !== 0) return;
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
const anchor = (event.target as Element | null)?.closest<HTMLAnchorElement>('a[data-link]');
if (!anchor || (anchor.target && anchor.target !== '_self')) return;
const url = new URL(anchor.href, location.href);
if (url.origin !== location.origin) return;
event.preventDefault();
navigate(url.pathname + url.search + url.hash);
};
document.addEventListener('click', onClick);
window.addEventListener('popstate', renderPage);
renderCurrent = renderPage;
stopCurrent = () => {
document.removeEventListener('click', onClick);
window.removeEventListener('popstate', renderPage);
cleanup?.();
cleanup = undefined;
renderCurrent = null;
stopCurrent = null;
};
renderPage();
}
+108
View File
@@ -0,0 +1,108 @@
:root {
--bg: #0F1828;
--panel: #162133;
--panel-2: #1B2738;
--line: #2A3A52;
--line-strong: #3D5576;
--sel-bg: #1F3150;
--sel-line: #8FB4E8;
--text: #E8EEF7;
--muted: #9DB0C8;
--primary: #2A4566;
--primary-hover: #34557C;
--danger: #E5484D;
--radius: 14px;
color-scheme: dark;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
margin: 0;
}
body {
display: flex;
flex-direction: column;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font-family: 'Nunito Sans Variable', system-ui, sans-serif;
font-size: 16px;
line-height: 1.5;
}
a {
color: var(--sel-line);
}
:focus-visible {
outline: 2px solid var(--sel-line);
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 {
color: var(--text);
font-size: 1.35rem;
font-weight: 800;
letter-spacing: 0.01em;
text-decoration: none;
}
.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 {
flex: 1;
width: 100%;
max-width: 1280px;
margin: 0 auto;
padding: 24px 20px;
}
#app-root h1 {
margin: 0 0 16px;
font-size: 1.6rem;
line-height: 1.2;
}
#app-footer {
padding: 16px 20px;
border-top: 1px solid var(--line);
color: var(--muted);
font-size: 0.85rem;
text-align: center;
}
#app-footer p {
max-width: 900px;
margin: 0 auto;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true,
"isolatedModules": true,
"skipLibCheck": true,
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'happy-dom',
},
});