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,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