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:
1 parent
ba93d8f8c5
commit
05a735993b
13 files changed
+1664
No files matched your search
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env*
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# AirCard
|
||||||
|
|
||||||
|
Inoffizielles Hobbyprojekt: ein Editor, mit dem man eigene Kartenmotive für https://aircard-ios.org gestalten kann. Keine Verbindung zu Apple, Visa, Mastercard, UnionPay, American Express, JCB oder Discover.
|
||||||
|
|
||||||
|
Die Website liegt in `web/` (statisches Vite + TypeScript, ohne UI-Framework). Die Design-Spec steht in `docs/superpowers/specs/2026-10-04-aircard-website-design.md`.
|
||||||
|
|
||||||
|
## Entwicklung
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd web
|
||||||
|
npm install
|
||||||
|
npm run dev # Entwicklungsserver
|
||||||
|
npm test # Tests (Vitest)
|
||||||
|
npm run build # Typprüfung + Produktions-Build nach web/dist
|
||||||
|
```
|
||||||
@@ -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>
|
||||||
Generated
+1253
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'happy-dom',
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in new issue
Block a user