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
+1782
-188
No files matched your search
+15
-4
@@ -3,19 +3,30 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>AirCard-IOS – Kartenmotiv gestalten</title>
|
<title>AirCard-IOS – Kartendesigns für Apple Wallet</title>
|
||||||
<meta name="theme-color" content="#0F1828" />
|
<meta name="theme-color" content="#0F1828" />
|
||||||
<meta name="apple-mobile-web-app-title" content="AirCard-IOS" />
|
<meta name="apple-mobile-web-app-title" content="AirCard-IOS" />
|
||||||
<link rel="icon" href="/favicon.svg?v=2" type="image/svg+xml" />
|
<link rel="icon" href="/favicon.svg?v=2" type="image/svg+xml" />
|
||||||
<link rel="icon" href="/favicon-32.png?v=2" sizes="32x32" type="image/png" />
|
<link rel="icon" href="/favicon-32.png?v=2" sizes="32x32" type="image/png" />
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png?v=2" />
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png?v=2" />
|
||||||
|
<script>
|
||||||
|
// Hell/Dunkel vor dem ersten Paint setzen (kein Aufblitzen); dieselbe Regel wie initialTheme() in src/shell/theme.ts.
|
||||||
|
try {
|
||||||
|
var theme = null;
|
||||||
|
try {
|
||||||
|
theme = localStorage.getItem('aircard.theme');
|
||||||
|
} catch (e) {}
|
||||||
|
if (theme !== 'light' && theme !== 'dark') {
|
||||||
|
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
} catch (e) {}
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div id="donate-banner"></div>
|
||||||
<header id="app-header">
|
<header id="app-header">
|
||||||
<a class="brand" href="/" data-link><img src="/favicon.svg?v=2" alt="" width="28" height="28" /><span>AirCard-IOS</span></a>
|
<a class="brand" href="/" data-link><img src="/favicon.svg?v=2" alt="" width="28" height="28" /><span>AirCard-IOS</span></a>
|
||||||
<nav class="app-nav" aria-label="Hauptnavigation">
|
|
||||||
<a href="/karten" data-link>Meine Karten</a>
|
|
||||||
</nav>
|
|
||||||
<div id="account-slot"></div>
|
<div id="account-slot"></div>
|
||||||
</header>
|
</header>
|
||||||
<main id="app-root"></main>
|
<main id="app-root"></main>
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
type TestUser = { $id: string; email: string; labels?: string[] };
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
user: null as TestUser | null,
|
||||||
|
listeners: new Set<(user: TestUser | null) => void>(),
|
||||||
|
logout: vi.fn(),
|
||||||
|
requireLogin: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./auth', () => ({
|
||||||
|
currentUser: () => mocks.user,
|
||||||
|
logout: mocks.logout,
|
||||||
|
onAuthChange: (fn: (user: TestUser | null) => void) => {
|
||||||
|
mocks.listeners.add(fn);
|
||||||
|
return () => mocks.listeners.delete(fn);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock('./login-dialog', () => ({ requireLogin: mocks.requireLogin }));
|
||||||
|
|
||||||
|
import { mountAccountMenu, type AccountMenu } from './account-menu';
|
||||||
|
|
||||||
|
let slot: HTMLElement;
|
||||||
|
let menu: AccountMenu;
|
||||||
|
|
||||||
|
const avatar = () => slot.querySelector<HTMLButtonElement>('button.account-avatar');
|
||||||
|
const panel = () => slot.querySelector<HTMLElement>('.account-dropdown');
|
||||||
|
const buttonByText = (text: string) => [...slot.querySelectorAll('button')].find((b) => b.textContent === text);
|
||||||
|
|
||||||
|
function setUser(user: TestUser | null) {
|
||||||
|
mocks.user = user;
|
||||||
|
for (const fn of [...mocks.listeners]) fn(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.user = null;
|
||||||
|
mocks.listeners.clear();
|
||||||
|
mocks.logout.mockReset();
|
||||||
|
mocks.requireLogin.mockReset();
|
||||||
|
document.body.innerHTML = '<div id="account-slot"></div>';
|
||||||
|
slot = document.getElementById('account-slot') as HTMLElement;
|
||||||
|
menu = mountAccountMenu(slot);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Konto-Menü', () => {
|
||||||
|
it('zeigt vor ready() nichts (kein Aufblitzen von „Anmelden“)', () => {
|
||||||
|
expect(slot.childElementCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('abgemeldet: „Anmelden“ öffnet den Login-Dialog', () => {
|
||||||
|
menu.ready();
|
||||||
|
buttonByText('Anmelden')!.click();
|
||||||
|
expect(mocks.requireLogin).toHaveBeenCalledWith('login');
|
||||||
|
expect(avatar()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('angemeldet: Avatar-Kreis mit Initiale, Menü zunächst zu', () => {
|
||||||
|
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
|
||||||
|
menu.ready();
|
||||||
|
expect(avatar()?.textContent).toBe('K');
|
||||||
|
expect(avatar()?.getAttribute('aria-expanded')).toBe('false');
|
||||||
|
expect(avatar()?.getAttribute('aria-label')).toBe('Konto-Menü');
|
||||||
|
expect(panel()?.hidden).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Klick öffnet das Menü: E-Mail, „Meine Karten“, „Abmelden“', () => {
|
||||||
|
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
|
||||||
|
menu.ready();
|
||||||
|
avatar()!.click();
|
||||||
|
expect(avatar()?.getAttribute('aria-expanded')).toBe('true');
|
||||||
|
expect(panel()?.hidden).toBe(false);
|
||||||
|
expect(panel()?.querySelector('.account-email')?.textContent).toBe('kenso@example.com');
|
||||||
|
const link = panel()!.querySelector<HTMLAnchorElement>('a[data-link]')!;
|
||||||
|
expect(link.textContent).toBe('Meine Karten');
|
||||||
|
expect(link.getAttribute('href')).toBe('/karten');
|
||||||
|
expect(buttonByText('Abmelden')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Escape schließt das Menü und gibt den Fokus an den Avatar', () => {
|
||||||
|
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
|
||||||
|
menu.ready();
|
||||||
|
avatar()!.click();
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||||
|
expect(panel()?.hidden).toBe(true);
|
||||||
|
expect(avatar()?.getAttribute('aria-expanded')).toBe('false');
|
||||||
|
expect(document.activeElement).toBe(avatar());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Klick außerhalb oder auf „Meine Karten“ schließt das Menü', () => {
|
||||||
|
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
|
||||||
|
menu.ready();
|
||||||
|
avatar()!.click();
|
||||||
|
document.body.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
expect(panel()?.hidden).toBe(true);
|
||||||
|
|
||||||
|
avatar()!.click();
|
||||||
|
panel()!.querySelector('a')!.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||||
|
expect(panel()?.hidden).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Klick im Menü selbst (auf die E-Mail) schließt es nicht', () => {
|
||||||
|
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
|
||||||
|
menu.ready();
|
||||||
|
avatar()!.click();
|
||||||
|
panel()!.querySelector('.account-email')!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
expect(panel()?.hidden).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Abmelden ruft logout; bei einem Fehler bleibt das Menü offen und zeigt die Meldung', async () => {
|
||||||
|
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
|
||||||
|
mocks.logout.mockRejectedValueOnce(new TypeError('offline'));
|
||||||
|
menu.ready();
|
||||||
|
avatar()!.click();
|
||||||
|
const signOut = buttonByText('Abmelden')!;
|
||||||
|
signOut.click();
|
||||||
|
expect(mocks.logout).toHaveBeenCalledOnce();
|
||||||
|
expect(signOut.disabled).toBe(true);
|
||||||
|
await vi.waitFor(() => expect(signOut.disabled).toBe(false));
|
||||||
|
expect(slot.querySelector('[role="alert"]')?.textContent).toBe('Keine Verbindung zum Server.');
|
||||||
|
expect(panel()?.hidden).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nach dem Abmelden (Auth-Änderung) steht wieder „Anmelden“ da', () => {
|
||||||
|
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
|
||||||
|
menu.ready();
|
||||||
|
avatar()!.click();
|
||||||
|
setUser(null);
|
||||||
|
expect(avatar()).toBeNull();
|
||||||
|
expect(buttonByText('Anmelden')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispose leert den Slot und meldet den Listener ab', () => {
|
||||||
|
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
|
||||||
|
menu.ready();
|
||||||
|
menu.dispose();
|
||||||
|
expect(slot.childElementCount).toBe(0);
|
||||||
|
expect(mocks.listeners.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
// Konto-Menü im Seitenkopf: abgemeldet „Anmelden“, angemeldet E-Mail und „Abmelden“.
|
// Konto-Menü im Seitenkopf: abgemeldet „Anmelden“, angemeldet ein Avatar-Kreis mit Initiale
|
||||||
|
// und einem Menü (E-Mail, „Meine Karten“, „Abmelden“).
|
||||||
import './account.css';
|
import './account.css';
|
||||||
import { el } from '../editor/dom';
|
import { el } from '../editor/dom';
|
||||||
import { currentUser, logout, onAuthChange } from './auth';
|
import { currentUser, logout, onAuthChange } from './auth';
|
||||||
@@ -13,8 +14,12 @@ export interface AccountMenu {
|
|||||||
|
|
||||||
export function mountAccountMenu(slot: HTMLElement): AccountMenu {
|
export function mountAccountMenu(slot: HTMLElement): AccountMenu {
|
||||||
let isReady = false;
|
let isReady = false;
|
||||||
|
// Hebt die Dokument-Listener des gerade offenen Menüs wieder auf (null: kein Menü gezeichnet).
|
||||||
|
let detach: (() => void) | null = null;
|
||||||
|
|
||||||
function render(): void {
|
function render(): void {
|
||||||
|
detach?.();
|
||||||
|
detach = null;
|
||||||
if (!isReady) {
|
if (!isReady) {
|
||||||
slot.replaceChildren();
|
slot.replaceChildren();
|
||||||
return;
|
return;
|
||||||
@@ -26,9 +31,52 @@ export function mountAccountMenu(slot: HTMLElement): AccountMenu {
|
|||||||
slot.replaceChildren(el('div', { class: 'account-menu' }, signIn));
|
slot.replaceChildren(el('div', { class: 'account-menu' }, signIn));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const initial = Array.from(user.email)[0]?.toUpperCase() ?? '?';
|
||||||
|
const avatar = el(
|
||||||
|
'button',
|
||||||
|
{
|
||||||
|
type: 'button',
|
||||||
|
class: 'account-avatar',
|
||||||
|
'aria-label': 'Konto-Menü',
|
||||||
|
'aria-expanded': 'false',
|
||||||
|
'aria-controls': 'account-dropdown',
|
||||||
|
},
|
||||||
|
initial,
|
||||||
|
);
|
||||||
const email = el('span', { class: 'account-email', title: user.email }, user.email);
|
const email = el('span', { class: 'account-email', title: user.email }, user.email);
|
||||||
const signOut = el('button', { type: 'button', class: 'auth-btn auth-btn-small' }, 'Abmelden');
|
const myCards = el('a', { class: 'account-item', href: '/karten', 'data-link': true }, 'Meine Karten');
|
||||||
|
const signOut = el('button', { type: 'button', class: 'account-item' }, 'Abmelden');
|
||||||
const error = el('span', { class: 'account-error', role: 'alert' });
|
const error = el('span', { class: 'account-error', role: 'alert' });
|
||||||
|
const dropdown = el('div', { class: 'account-dropdown', id: 'account-dropdown', hidden: true }, email, myCards, signOut, error);
|
||||||
|
const wrap = el('div', { class: 'account-menu', role: 'group', 'aria-label': 'Konto' }, avatar, dropdown);
|
||||||
|
|
||||||
|
let isOpen = false;
|
||||||
|
function setOpen(open: boolean): void {
|
||||||
|
isOpen = open;
|
||||||
|
dropdown.hidden = !open;
|
||||||
|
avatar.setAttribute('aria-expanded', String(open));
|
||||||
|
}
|
||||||
|
avatar.addEventListener('click', () => setOpen(!isOpen));
|
||||||
|
|
||||||
|
const onDocumentClick = (event: MouseEvent) => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
const target = event.target as Element | null;
|
||||||
|
if (target && wrap.contains(target) && !target.closest('a')) return; // nur Links schließen das Menü selbst
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
const onKeydown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key !== 'Escape' || !isOpen) return;
|
||||||
|
setOpen(false);
|
||||||
|
avatar.focus();
|
||||||
|
};
|
||||||
|
document.addEventListener('click', onDocumentClick);
|
||||||
|
document.addEventListener('keydown', onKeydown);
|
||||||
|
detach = () => {
|
||||||
|
document.removeEventListener('click', onDocumentClick);
|
||||||
|
document.removeEventListener('keydown', onKeydown);
|
||||||
|
};
|
||||||
|
|
||||||
signOut.addEventListener('click', () => {
|
signOut.addEventListener('click', () => {
|
||||||
signOut.disabled = true;
|
signOut.disabled = true;
|
||||||
error.textContent = '';
|
error.textContent = '';
|
||||||
@@ -38,14 +86,7 @@ export function mountAccountMenu(slot: HTMLElement): AccountMenu {
|
|||||||
signOut.disabled = false;
|
signOut.disabled = false;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
slot.replaceChildren(
|
slot.replaceChildren(wrap);
|
||||||
el(
|
|
||||||
'div',
|
|
||||||
{ class: 'account-menu', role: 'group', 'aria-label': 'Konto' },
|
|
||||||
el('div', { class: 'account-row' }, email, signOut),
|
|
||||||
error,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const off = onAuthChange(render);
|
const off = onAuthChange(render);
|
||||||
@@ -58,6 +99,8 @@ export function mountAccountMenu(slot: HTMLElement): AccountMenu {
|
|||||||
},
|
},
|
||||||
dispose() {
|
dispose() {
|
||||||
off();
|
off();
|
||||||
|
detach?.();
|
||||||
|
detach = null;
|
||||||
slot.replaceChildren();
|
slot.replaceChildren();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+86
-38
@@ -25,6 +25,7 @@
|
|||||||
.auth-btn-primary {
|
.auth-btn-primary {
|
||||||
border-color: var(--primary);
|
border-color: var(--primary);
|
||||||
background: var(--primary);
|
background: var(--primary);
|
||||||
|
color: var(--on-primary);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +36,7 @@
|
|||||||
|
|
||||||
.auth-btn-danger {
|
.auth-btn-danger {
|
||||||
border-color: var(--danger);
|
border-color: var(--danger);
|
||||||
color: #ff9a9d;
|
color: var(--danger-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-btn-danger:hover:not(:disabled) {
|
.auth-btn-danger:hover:not(:disabled) {
|
||||||
@@ -70,43 +71,104 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Konto-Menü im Seitenkopf */
|
/* Konto-Menü im Seitenkopf: „Anmelden“ bzw. Avatar-Kreis mit aufklappbarem Menü */
|
||||||
|
|
||||||
#account-slot {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-menu {
|
.account-menu {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
align-items: flex-end;
|
|
||||||
gap: 4px;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-row {
|
.account-avatar {
|
||||||
|
appearance: none;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--on-primary);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-avatar:hover,
|
||||||
|
.account-avatar[aria-expanded='true'] {
|
||||||
|
border-color: var(--sel-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
right: 0;
|
||||||
|
z-index: 40;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 2px;
|
||||||
min-width: 0;
|
min-width: 220px;
|
||||||
max-width: 100%;
|
max-width: min(320px, calc(100vw - 32px));
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--panel);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-dropdown[hidden] {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-email {
|
.account-email {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: 28ch;
|
margin-bottom: 4px;
|
||||||
|
padding: 6px 12px 10px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 0.95rem;
|
font-size: 0.9rem;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.account-item {
|
||||||
|
appearance: none;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: left;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-item:hover:not(:disabled) {
|
||||||
|
background: var(--panel-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-item:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.account-error {
|
.account-error {
|
||||||
color: #ff9a9d;
|
padding: 4px 12px 0;
|
||||||
|
color: var(--danger-text);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
text-align: right;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-error:empty {
|
.account-error:empty {
|
||||||
@@ -124,11 +186,11 @@
|
|||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
box-shadow: 0 24px 80px rgb(0 0 0 / 0.55);
|
box-shadow: var(--shadow-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-dialog::backdrop {
|
.auth-dialog::backdrop {
|
||||||
background: rgb(6 11 20 / 0.72);
|
background: var(--backdrop);
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-card {
|
.auth-card {
|
||||||
@@ -261,11 +323,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.auth-msg-error {
|
.auth-msg-error {
|
||||||
color: #ff9a9d;
|
color: var(--danger-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-msg-ok {
|
.auth-msg-ok {
|
||||||
color: #8fd9a8;
|
color: var(--ok-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-aside {
|
.auth-aside {
|
||||||
@@ -289,29 +351,15 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Schmale Bildschirme: Konto-Menü rutscht bei Bedarf in eine zweite Kopfzeile */
|
|
||||||
|
|
||||||
@media (max-width: 599px) {
|
@media (max-width: 599px) {
|
||||||
#app-header {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#account-slot {
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-email {
|
|
||||||
max-width: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-card {
|
.auth-card {
|
||||||
padding: 24px 20px 20px;
|
padding: 24px 20px 20px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.auth-btn {
|
.auth-btn,
|
||||||
|
.account-avatar {
|
||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -29,7 +29,7 @@ describe('getCurrentUser', () => {
|
|||||||
it('liefert den Nutzer und merkt sich die ID', async () => {
|
it('liefert den Nutzer und merkt sich die ID', async () => {
|
||||||
const auth = await freshAuth();
|
const auth = await freshAuth();
|
||||||
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de', name: 'a' });
|
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de', name: 'a' });
|
||||||
await expect(auth.getCurrentUser()).resolves.toEqual({ $id: 'u1', email: 'a@b.de' });
|
await expect(auth.getCurrentUser()).resolves.toEqual({ $id: 'u1', email: 'a@b.de', labels: [] });
|
||||||
expect(auth.currentUserId()).toBe('u1');
|
expect(auth.currentUserId()).toBe('u1');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ describe('getCurrentUser', () => {
|
|||||||
await auth.getCurrentUser();
|
await auth.getCurrentUser();
|
||||||
await auth.getCurrentUser();
|
await auth.getCurrentUser();
|
||||||
expect(listener).toHaveBeenCalledTimes(1);
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
expect(listener).toHaveBeenLastCalledWith({ $id: 'u1', email: 'a@b.de' });
|
expect(listener).toHaveBeenLastCalledWith({ $id: 'u1', email: 'a@b.de', labels: [] });
|
||||||
mocks.get.mockRejectedValue(fail(401, 'general_unauthorized_scope'));
|
mocks.get.mockRejectedValue(fail(401, 'general_unauthorized_scope'));
|
||||||
await auth.getCurrentUser();
|
await auth.getCurrentUser();
|
||||||
await auth.getCurrentUser();
|
await auth.getCurrentUser();
|
||||||
@@ -80,6 +80,52 @@ describe('getCurrentUser', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Labels und isAdmin', () => {
|
||||||
|
it('getCurrentUser übernimmt die Labels; fehlen sie (oder sind sie kein Array aus Texten), sind es []', async () => {
|
||||||
|
const auth = await freshAuth();
|
||||||
|
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de', labels: ['admin', 'x'] });
|
||||||
|
await expect(auth.getCurrentUser()).resolves.toEqual({ $id: 'u1', email: 'a@b.de', labels: ['admin', 'x'] });
|
||||||
|
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de' });
|
||||||
|
await expect(auth.getCurrentUser()).resolves.toMatchObject({ labels: [] });
|
||||||
|
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de', labels: 'admin' });
|
||||||
|
await expect(auth.getCurrentUser()).resolves.toMatchObject({ labels: [] });
|
||||||
|
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de', labels: ['admin', 7, null] });
|
||||||
|
await expect(auth.getCurrentUser()).resolves.toMatchObject({ labels: ['admin'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('login liefert die Labels ebenfalls', async () => {
|
||||||
|
const auth = await freshAuth();
|
||||||
|
mocks.createEmailPasswordSession.mockResolvedValue({});
|
||||||
|
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de', labels: ['admin'] });
|
||||||
|
await expect(auth.login('a@b.de', 'geheim123')).resolves.toMatchObject({ labels: ['admin'] });
|
||||||
|
expect(auth.currentUser()?.labels).toEqual(['admin']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eine Änderung nur der Labels benachrichtigt die Abonnenten', async () => {
|
||||||
|
const auth = await freshAuth();
|
||||||
|
const listener = vi.fn();
|
||||||
|
auth.onAuthChange(listener);
|
||||||
|
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de', labels: [] });
|
||||||
|
await auth.getCurrentUser();
|
||||||
|
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de', labels: [] });
|
||||||
|
await auth.getCurrentUser();
|
||||||
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
|
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de', labels: ['admin'] });
|
||||||
|
await auth.getCurrentUser();
|
||||||
|
expect(listener).toHaveBeenCalledTimes(2);
|
||||||
|
expect(listener).toHaveBeenLastCalledWith({ $id: 'u1', email: 'a@b.de', labels: ['admin'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isAdmin ist nur mit dem Label „admin“ wahr', async () => {
|
||||||
|
const auth = await freshAuth();
|
||||||
|
expect(auth.isAdmin(null)).toBe(false);
|
||||||
|
expect(auth.isAdmin({ $id: 'u1', email: 'a@b.de' })).toBe(false);
|
||||||
|
expect(auth.isAdmin({ $id: 'u1', email: 'a@b.de', labels: [] })).toBe(false);
|
||||||
|
expect(auth.isAdmin({ $id: 'u1', email: 'a@b.de', labels: ['administrator', 'Admin'] })).toBe(false);
|
||||||
|
expect(auth.isAdmin({ $id: 'u1', email: 'a@b.de', labels: ['x', 'admin'] })).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('login', () => {
|
describe('login', () => {
|
||||||
it('legt die Sitzung an, holt den Nutzer und benachrichtigt', async () => {
|
it('legt die Sitzung an, holt den Nutzer und benachrichtigt', async () => {
|
||||||
const auth = await freshAuth();
|
const auth = await freshAuth();
|
||||||
@@ -87,10 +133,10 @@ describe('login', () => {
|
|||||||
auth.onAuthChange(listener);
|
auth.onAuthChange(listener);
|
||||||
mocks.createEmailPasswordSession.mockResolvedValue({});
|
mocks.createEmailPasswordSession.mockResolvedValue({});
|
||||||
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' });
|
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' });
|
||||||
await expect(auth.login('a@b.de', 'geheim123')).resolves.toEqual({ $id: 'u1', email: 'a@b.de' });
|
await expect(auth.login('a@b.de', 'geheim123')).resolves.toEqual({ $id: 'u1', email: 'a@b.de', labels: [] });
|
||||||
expect(mocks.createEmailPasswordSession).toHaveBeenCalledWith({ email: 'a@b.de', password: 'geheim123' });
|
expect(mocks.createEmailPasswordSession).toHaveBeenCalledWith({ email: 'a@b.de', password: 'geheim123' });
|
||||||
expect(auth.currentUserId()).toBe('u1');
|
expect(auth.currentUserId()).toBe('u1');
|
||||||
expect(listener).toHaveBeenCalledWith({ $id: 'u1', email: 'a@b.de' });
|
expect(listener).toHaveBeenCalledWith({ $id: 'u1', email: 'a@b.de', labels: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('bei falschem Passwort fliegt der Fehler weiter und nichts ändert sich', async () => {
|
it('bei falschem Passwort fliegt der Fehler weiter und nichts ändert sich', async () => {
|
||||||
@@ -114,7 +160,7 @@ describe('login bei bereits aktiver Sitzung (user_session_already_exists)', () =
|
|||||||
mocks.createEmailPasswordSession.mockRejectedValueOnce(existing()).mockResolvedValueOnce({});
|
mocks.createEmailPasswordSession.mockRejectedValueOnce(existing()).mockResolvedValueOnce({});
|
||||||
mocks.deleteSession.mockResolvedValue({});
|
mocks.deleteSession.mockResolvedValue({});
|
||||||
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' });
|
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' });
|
||||||
await expect(auth.login('a@b.de', 'geheim123')).resolves.toEqual({ $id: 'u1', email: 'a@b.de' });
|
await expect(auth.login('a@b.de', 'geheim123')).resolves.toEqual({ $id: 'u1', email: 'a@b.de', labels: [] });
|
||||||
expect(mocks.deleteSession).toHaveBeenCalledTimes(1);
|
expect(mocks.deleteSession).toHaveBeenCalledTimes(1);
|
||||||
expect(mocks.deleteSession).toHaveBeenCalledWith({ sessionId: 'current' });
|
expect(mocks.deleteSession).toHaveBeenCalledWith({ sessionId: 'current' });
|
||||||
expect(mocks.createEmailPasswordSession).toHaveBeenCalledTimes(2);
|
expect(mocks.createEmailPasswordSession).toHaveBeenCalledTimes(2);
|
||||||
@@ -127,7 +173,7 @@ describe('login bei bereits aktiver Sitzung (user_session_already_exists)', () =
|
|||||||
mocks.createEmailPasswordSession.mockRejectedValueOnce(existing()).mockResolvedValueOnce({});
|
mocks.createEmailPasswordSession.mockRejectedValueOnce(existing()).mockResolvedValueOnce({});
|
||||||
mocks.deleteSession.mockRejectedValue(fail(401, 'general_unauthorized_scope'));
|
mocks.deleteSession.mockRejectedValue(fail(401, 'general_unauthorized_scope'));
|
||||||
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' });
|
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' });
|
||||||
await expect(auth.login('a@b.de', 'geheim123')).resolves.toEqual({ $id: 'u1', email: 'a@b.de' });
|
await expect(auth.login('a@b.de', 'geheim123')).resolves.toEqual({ $id: 'u1', email: 'a@b.de', labels: [] });
|
||||||
expect(mocks.createEmailPasswordSession).toHaveBeenCalledTimes(2);
|
expect(mocks.createEmailPasswordSession).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -177,7 +223,7 @@ describe('login bei bereits aktiver Sitzung (user_session_already_exists)', () =
|
|||||||
mocks.createEmailPasswordSession.mockRejectedValueOnce(existing()).mockResolvedValueOnce({});
|
mocks.createEmailPasswordSession.mockRejectedValueOnce(existing()).mockResolvedValueOnce({});
|
||||||
mocks.deleteSession.mockResolvedValue({});
|
mocks.deleteSession.mockResolvedValue({});
|
||||||
mocks.get.mockResolvedValue({ $id: 'u2', email: 'neu@b.de' });
|
mocks.get.mockResolvedValue({ $id: 'u2', email: 'neu@b.de' });
|
||||||
await expect(auth.register('neu@b.de', 'geheim123')).resolves.toEqual({ $id: 'u2', email: 'neu@b.de' });
|
await expect(auth.register('neu@b.de', 'geheim123')).resolves.toEqual({ $id: 'u2', email: 'neu@b.de', labels: [] });
|
||||||
expect(mocks.deleteSession).toHaveBeenCalledTimes(1);
|
expect(mocks.deleteSession).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -188,7 +234,7 @@ describe('register', () => {
|
|||||||
mocks.create.mockResolvedValue({});
|
mocks.create.mockResolvedValue({});
|
||||||
mocks.createEmailPasswordSession.mockResolvedValue({});
|
mocks.createEmailPasswordSession.mockResolvedValue({});
|
||||||
mocks.get.mockResolvedValue({ $id: 'u2', email: 'neu@b.de' });
|
mocks.get.mockResolvedValue({ $id: 'u2', email: 'neu@b.de' });
|
||||||
await expect(auth.register('neu@b.de', 'geheim123')).resolves.toEqual({ $id: 'u2', email: 'neu@b.de' });
|
await expect(auth.register('neu@b.de', 'geheim123')).resolves.toEqual({ $id: 'u2', email: 'neu@b.de', labels: [] });
|
||||||
const params = mocks.create.mock.calls[0][0];
|
const params = mocks.create.mock.calls[0][0];
|
||||||
expect(params).toMatchObject({ email: 'neu@b.de', password: 'geheim123', name: 'neu' });
|
expect(params).toMatchObject({ email: 'neu@b.de', password: 'geheim123', name: 'neu' });
|
||||||
expect(typeof params.userId).toBe('string');
|
expect(typeof params.userId).toBe('string');
|
||||||
|
|||||||
+14
-4
@@ -4,7 +4,7 @@ import { ID } from 'appwrite';
|
|||||||
import { account } from './appwrite';
|
import { account } from './appwrite';
|
||||||
import { errorType, isUnauthorized } from './errors';
|
import { errorType, isUnauthorized } from './errors';
|
||||||
|
|
||||||
export type User = { $id: string; email: string };
|
export type User = { $id: string; email: string; labels?: string[] };
|
||||||
|
|
||||||
const PASSWORD_MIN = 8;
|
const PASSWORD_MIN = 8;
|
||||||
const PASSWORD_MAX = 256;
|
const PASSWORD_MAX = 256;
|
||||||
@@ -27,10 +27,16 @@ function setUser(user: User | null): void {
|
|||||||
function toUser(res: unknown): User {
|
function toUser(res: unknown): User {
|
||||||
const data = (typeof res === 'object' && res !== null ? res : {}) as Record<string, unknown>;
|
const data = (typeof res === 'object' && res !== null ? res : {}) as Record<string, unknown>;
|
||||||
if (typeof data.$id !== 'string' || typeof data.email !== 'string') throw new Error('Unerwartete Antwort');
|
if (typeof data.$id !== 'string' || typeof data.email !== 'string') throw new Error('Unerwartete Antwort');
|
||||||
return { $id: data.$id, email: data.email };
|
const labels = Array.isArray(data.labels) ? data.labels.filter((l): l is string => typeof l === 'string') : [];
|
||||||
|
return { $id: data.$id, email: data.email, labels };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Der angemeldete Nutzer laut Server; 401 heißt „nicht angemeldet“. Abonnenten hören nur echte Änderungen. */
|
/** Wahr nur für Konten mit dem Appwrite-Label `admin` (die Rechte prüft der Server; das hier steuert nur die Oberfläche). */
|
||||||
|
export function isAdmin(user: User | null): boolean {
|
||||||
|
return (user?.labels ?? []).includes('admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der angemeldete Nutzer laut Server; 401 heißt „nicht angemeldet“. Abonnenten hören nur echte Änderungen (auch der Labels). */
|
||||||
export async function getCurrentUser(): Promise<User | null> {
|
export async function getCurrentUser(): Promise<User | null> {
|
||||||
let next: User | null;
|
let next: User | null;
|
||||||
try {
|
try {
|
||||||
@@ -39,7 +45,11 @@ export async function getCurrentUser(): Promise<User | null> {
|
|||||||
if (!isUnauthorized(err)) throw err;
|
if (!isUnauthorized(err)) throw err;
|
||||||
next = null;
|
next = null;
|
||||||
}
|
}
|
||||||
if (next?.$id !== cachedUser?.$id || next?.email !== cachedUser?.email) setUser(next);
|
const same =
|
||||||
|
next?.$id === cachedUser?.$id &&
|
||||||
|
next?.email === cachedUser?.email &&
|
||||||
|
(next?.labels ?? []).join('\n') === (cachedUser?.labels ?? []).join('\n');
|
||||||
|
if (!same) setUser(next);
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ function pngInfo(buf: Buffer) {
|
|||||||
|
|
||||||
describe('Name und Kopfbereich', () => {
|
describe('Name und Kopfbereich', () => {
|
||||||
it('Titel und Meta-Angaben nennen AirCard-IOS', () => {
|
it('Titel und Meta-Angaben nennen AirCard-IOS', () => {
|
||||||
expect(indexHtml).toContain('<title>AirCard-IOS – Kartenmotiv gestalten</title>');
|
expect(indexHtml).toContain('<title>AirCard-IOS – Kartendesigns für Apple Wallet</title>');
|
||||||
expect(indexHtml).toContain('<meta name="theme-color" content="#0F1828" />');
|
expect(indexHtml).toContain('<meta name="theme-color" content="#0F1828" />');
|
||||||
expect(indexHtml).toContain('<meta name="apple-mobile-web-app-title" content="AirCard-IOS" />');
|
expect(indexHtml).toContain('<meta name="apple-mobile-web-app-title" content="AirCard-IOS" />');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// Konfiguration der Website.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Öffentlicher Spenden-Link (Buy Me a Coffee, `https://buymeacoffee.com/<name>`).
|
||||||
|
* Solange er leer ist, werden Spenden-Banner und -Knopf nicht angezeigt.
|
||||||
|
*/
|
||||||
|
export const DONATE_URL: string = '';
|
||||||
@@ -67,7 +67,7 @@ beforeEach(() => {
|
|||||||
|
|
||||||
describe('bindEditorToAuth', () => {
|
describe('bindEditorToAuth', () => {
|
||||||
it('Abmelden: der Editor wird ganz geleert und ?karte= fällt aus der Adresse', () => {
|
it('Abmelden: der Editor wird ganz geleert und ?karte= fällt aus der Adresse', () => {
|
||||||
history.replaceState(null, '', '/?karte=k1');
|
history.replaceState(null, '', '/gestalten?karte=k1');
|
||||||
const fake = fakeAuth('a');
|
const fake = fakeAuth('a');
|
||||||
bindEditorToAuth(store, fake.auth);
|
bindEditorToAuth(store, fake.auth);
|
||||||
design();
|
design();
|
||||||
@@ -76,7 +76,7 @@ describe('bindEditorToAuth', () => {
|
|||||||
expect(store.get().image).toBeNull();
|
expect(store.get().image).toBeNull();
|
||||||
expect(store.get().saved).toBeNull();
|
expect(store.get().saved).toBeNull();
|
||||||
expect(location.search).toBe('');
|
expect(location.search).toBe('');
|
||||||
expect(location.pathname).toBe('/');
|
expect(location.pathname).toBe('/gestalten');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Abmelden: der Anfangszustand besteht aus frischen Objekten (nichts geteilt mit dem alten)', () => {
|
it('Abmelden: der Anfangszustand besteht aus frischen Objekten (nichts geteilt mit dem alten)', () => {
|
||||||
@@ -90,7 +90,7 @@ describe('bindEditorToAuth', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('Kontowechsel A → B: Entwurf bleibt, aber ohne Karte und als „Bild noch nicht gespeichert“', () => {
|
it('Kontowechsel A → B: Entwurf bleibt, aber ohne Karte und als „Bild noch nicht gespeichert“', () => {
|
||||||
history.replaceState(null, '', '/?karte=k1');
|
history.replaceState(null, '', '/gestalten?karte=k1');
|
||||||
const fake = fakeAuth('a');
|
const fake = fakeAuth('a');
|
||||||
bindEditorToAuth(store, fake.auth);
|
bindEditorToAuth(store, fake.auth);
|
||||||
const { blob, config } = design();
|
const { blob, config } = design();
|
||||||
@@ -172,10 +172,11 @@ describe('bindEditorToAuth', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('lässt andere Teile der Adresse stehen', () => {
|
it('lässt andere Teile der Adresse stehen', () => {
|
||||||
history.replaceState(null, '', '/?karte=k1&x=1#h');
|
history.replaceState(null, '', '/gestalten?karte=k1&x=1#h');
|
||||||
const fake = fakeAuth('a');
|
const fake = fakeAuth('a');
|
||||||
bindEditorToAuth(store, fake.auth);
|
bindEditorToAuth(store, fake.auth);
|
||||||
fake.logout();
|
fake.logout();
|
||||||
|
expect(location.pathname).toBe('/gestalten');
|
||||||
expect(location.search).toBe('?x=1');
|
expect(location.search).toBe('?x=1');
|
||||||
expect(location.hash).toBe('#h');
|
expect(location.hash).toBe('#h');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Die Adresse des Editors kann eine Karte nennen (`/?karte=<id>`).
|
// Die Adresse des Editors kann eine Karte nennen (`/gestalten?karte=<id>`; alte Links `/?karte=<id>` leitet main.ts um).
|
||||||
|
|
||||||
/** Entfernt `karte` aus der Adresse (ersetzt den Verlaufseintrag), damit ein Neuladen nicht dieselbe Karte öffnet. */
|
/** Entfernt `karte` aus der Adresse (ersetzt den Verlaufseintrag), damit ein Neuladen nicht dieselbe Karte öffnet. */
|
||||||
export function dropCardParam(): void {
|
export function dropCardParam(): void {
|
||||||
|
|||||||
@@ -7,11 +7,11 @@
|
|||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
box-shadow: 0 24px 80px rgb(0 0 0 / 0.55);
|
box-shadow: var(--shadow-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-dialog::backdrop {
|
.confirm-dialog::backdrop {
|
||||||
background: rgb(6 11 20 / 0.72);
|
background: var(--backdrop);
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-card {
|
.confirm-card {
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ const ICONS = {
|
|||||||
lock: '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
lock: '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
||||||
check: '<path d="M20 6 9 17l-5-5"/>',
|
check: '<path d="M20 6 9 17l-5-5"/>',
|
||||||
ban: '<circle cx="12" cy="12" r="9"/><path d="m5.6 5.6 12.8 12.8"/>',
|
ban: '<circle cx="12" cy="12" r="9"/><path d="m5.6 5.6 12.8 12.8"/>',
|
||||||
|
sun:
|
||||||
|
'<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/>' +
|
||||||
|
'<path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/>' +
|
||||||
|
'<path d="m19.07 4.93-1.41 1.41"/>',
|
||||||
|
moon: '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
|
||||||
|
menu: '<path d="M4 6h16"/><path d="M4 12h16"/><path d="M4 18h16"/>',
|
||||||
|
search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type IconName = keyof typeof ICONS;
|
export type IconName = keyof typeof ICONS;
|
||||||
|
|||||||
@@ -297,8 +297,8 @@ describe('Speichern', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Karte öffnen (/?karte=<id>)', () => {
|
describe('Karte öffnen (/gestalten?karte=<id>)', () => {
|
||||||
const open = (id: string) => history.replaceState(null, '', `/?karte=${id}`);
|
const open = (id: string) => history.replaceState(null, '', `/gestalten?karte=${id}`);
|
||||||
|
|
||||||
it('lädt Karte und Bild, begrenzt den Ausschnitt und setzt den Zustand', async () => {
|
it('lädt Karte und Bild, begrenzt den Ausschnitt und setzt den Zustand', async () => {
|
||||||
open('k1');
|
open('k1');
|
||||||
@@ -531,7 +531,7 @@ describe('Karte öffnen (/?karte=<id>)', () => {
|
|||||||
expect(store.get()).toBe(before);
|
expect(store.get()).toBe(before);
|
||||||
expect(store.get().imageBlob).toBe(blob);
|
expect(store.get().imageBlob).toBe(blob);
|
||||||
expect(store.get().image).toBe(img);
|
expect(store.get().image).toBe(img);
|
||||||
expect(location.pathname + location.search).toBe('/');
|
expect(location.pathname + location.search).toBe('/gestalten');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Esc im Dialog verwirft nichts', async () => {
|
it('Esc im Dialog verwirft nichts', async () => {
|
||||||
@@ -733,7 +733,7 @@ describe('Bindung an eine gespeicherte Karte', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('ist nach dem Öffnen einer Karte da', async () => {
|
it('ist nach dem Öffnen einer Karte da', async () => {
|
||||||
history.replaceState(null, '', '/?karte=k1');
|
history.replaceState(null, '', '/gestalten?karte=k1');
|
||||||
mocks.get.mockResolvedValue(savedCard());
|
mocks.get.mockResolvedValue(savedCard());
|
||||||
mocks.loadImage.mockResolvedValue(png());
|
mocks.loadImage.mockResolvedValue(png());
|
||||||
mocks.decodeImage.mockResolvedValue(image());
|
mocks.decodeImage.mockResolvedValue(image());
|
||||||
@@ -758,7 +758,7 @@ describe('Bindung an eine gespeicherte Karte', () => {
|
|||||||
|
|
||||||
describe('„Neue Karte“', () => {
|
describe('„Neue Karte“', () => {
|
||||||
it('setzt den Editor zurück (frische Objekte) und entfernt ?karte= aus der Adresse', () => {
|
it('setzt den Editor zurück (frische Objekte) und entfernt ?karte= aus der Adresse', () => {
|
||||||
history.replaceState(null, '', '/?karte=k1');
|
history.replaceState(null, '', '/gestalten?karte=k1');
|
||||||
const { config } = boundEditor();
|
const { config } = boundEditor();
|
||||||
mount();
|
mount();
|
||||||
newCardButton().click();
|
newCardButton().click();
|
||||||
@@ -774,7 +774,7 @@ describe('Bindung an eine gespeicherte Karte', () => {
|
|||||||
expect(s.config).not.toBe(DEFAULT_CONFIG);
|
expect(s.config).not.toBe(DEFAULT_CONFIG);
|
||||||
expect(s.config.crop).not.toBe(DEFAULT_CONFIG.crop);
|
expect(s.config.crop).not.toBe(DEFAULT_CONFIG.crop);
|
||||||
expect(s.config).not.toBe(config);
|
expect(s.config).not.toBe(config);
|
||||||
expect(location.pathname + location.search).toBe('/');
|
expect(location.pathname + location.search).toBe('/gestalten');
|
||||||
expect(boundRow().hidden).toBe(true);
|
expect(boundRow().hidden).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -787,7 +787,7 @@ describe('Bindung an eine gespeicherte Karte', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('bricht eine noch ladende Karte ab', async () => {
|
it('bricht eine noch ladende Karte ab', async () => {
|
||||||
history.replaceState(null, '', '/?karte=k2');
|
history.replaceState(null, '', '/gestalten?karte=k2');
|
||||||
boundEditor();
|
boundEditor();
|
||||||
let finish!: (c: SavedCard) => void;
|
let finish!: (c: SavedCard) => void;
|
||||||
mocks.get.mockReturnValue(new Promise<SavedCard>((r) => (finish = r)));
|
mocks.get.mockReturnValue(new Promise<SavedCard>((r) => (finish = r)));
|
||||||
@@ -869,7 +869,7 @@ describe('Bindung an eine gespeicherte Karte', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('fragt nach dem Öffnen einer Karte nicht, solange nichts geändert wurde', async () => {
|
it('fragt nach dem Öffnen einer Karte nicht, solange nichts geändert wurde', async () => {
|
||||||
history.replaceState(null, '', '/?karte=k1');
|
history.replaceState(null, '', '/gestalten?karte=k1');
|
||||||
mocks.get.mockResolvedValue(savedCard());
|
mocks.get.mockResolvedValue(savedCard());
|
||||||
mocks.loadImage.mockResolvedValue(png());
|
mocks.loadImage.mockResolvedValue(png());
|
||||||
mocks.decodeImage.mockResolvedValue(image(3000, 2000));
|
mocks.decodeImage.mockResolvedValue(image(3000, 2000));
|
||||||
@@ -928,7 +928,7 @@ describe('Bindung an eine gespeicherte Karte', () => {
|
|||||||
|
|
||||||
describe('Abmelden während des Ladens', () => {
|
describe('Abmelden während des Ladens', () => {
|
||||||
it('eine noch ladende Karte landet nicht mehr im Editor', async () => {
|
it('eine noch ladende Karte landet nicht mehr im Editor', async () => {
|
||||||
history.replaceState(null, '', '/?karte=k1');
|
history.replaceState(null, '', '/gestalten?karte=k1');
|
||||||
let finish!: (c: SavedCard) => void;
|
let finish!: (c: SavedCard) => void;
|
||||||
mocks.get.mockReturnValue(new Promise<SavedCard>((r) => (finish = r)));
|
mocks.get.mockReturnValue(new Promise<SavedCard>((r) => (finish = r)));
|
||||||
mount();
|
mount();
|
||||||
|
|||||||
@@ -183,6 +183,13 @@
|
|||||||
-webkit-user-drag: none;
|
-webkit-user-drag: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Die Logos sind für die dunkle Karte gemacht (weiß, silbern, Umriss): im hellen Theme stehen sie wie auf der Karte auf deren Grund. */
|
||||||
|
:root[data-theme='light'] .mark-img {
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #1b2738;
|
||||||
|
}
|
||||||
|
|
||||||
.mark-none {
|
.mark-none {
|
||||||
width: 46px;
|
width: 46px;
|
||||||
height: 46px;
|
height: 46px;
|
||||||
@@ -356,7 +363,7 @@
|
|||||||
height: auto;
|
height: auto;
|
||||||
border-radius: var(--card-radius);
|
border-radius: var(--card-radius);
|
||||||
background: #1b2738;
|
background: #1b2738;
|
||||||
box-shadow: 0 10px 34px rgba(0, 0, 0, 0.45);
|
box-shadow: var(--shadow-card);
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
@@ -402,11 +409,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.msg-error {
|
.msg-error {
|
||||||
color: #ff9a9d;
|
color: var(--danger-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.msg-warning {
|
.msg-warning {
|
||||||
color: #f2c66d;
|
color: var(--warn-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.msg-notice {
|
.msg-notice {
|
||||||
@@ -456,6 +463,7 @@
|
|||||||
.btn-primary {
|
.btn-primary {
|
||||||
border-color: var(--primary);
|
border-color: var(--primary);
|
||||||
background: var(--primary);
|
background: var(--primary);
|
||||||
|
color: var(--on-primary);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+31
-6
@@ -2,18 +2,37 @@ import '@fontsource-variable/nunito-sans';
|
|||||||
import './styles.css';
|
import './styles.css';
|
||||||
import { mountAccountMenu } from './account/account-menu';
|
import { mountAccountMenu } from './account/account-menu';
|
||||||
import { getCurrentUser } from './account/auth';
|
import { getCurrentUser } from './account/auth';
|
||||||
import { startRouter } from './pages/router';
|
import { DONATE_URL } from './config';
|
||||||
|
import { legacyRedirect, startRouter } from './pages/router';
|
||||||
import { mountCardsPage } from './pages/cards-page';
|
import { mountCardsPage } from './pages/cards-page';
|
||||||
|
import { mountCommunityPage } from './pages/community-page';
|
||||||
|
import { mountHomePage } from './pages/home-page';
|
||||||
import { mountResetPage } from './pages/reset-page';
|
import { mountResetPage } from './pages/reset-page';
|
||||||
import { mountEditor } from './editor/editor-page';
|
import { mountEditor } from './editor/editor-page';
|
||||||
import { bindEditorToAuth } from './editor/auth-reset';
|
import { bindEditorToAuth } from './editor/auth-reset';
|
||||||
import { createStore, initialEditorState } from './editor/state';
|
import { createStore, initialEditorState } from './editor/state';
|
||||||
|
import { mountDonateBanner } from './shell/donate-banner';
|
||||||
|
import { mountHeader } from './shell/header';
|
||||||
|
import { initTheme } from './shell/theme';
|
||||||
|
|
||||||
|
// Hell/Dunkel: das Skript in index.html hat es vor dem ersten Paint gesetzt; hier wird nur die Browserleiste angeglichen.
|
||||||
|
initTheme();
|
||||||
|
|
||||||
|
// Alte Editor-Links (`/?karte=<id>`) zeigen jetzt auf `/gestalten?karte=<id>`; der Verlauf bekommt keinen Eintrag dazu.
|
||||||
|
const legacyTarget = legacyRedirect(new URL(location.href));
|
||||||
|
if (legacyTarget) history.replaceState(null, '', legacyTarget);
|
||||||
|
|
||||||
// Der Editor-Zustand lebt außerhalb der Seiten und überlebt Seitenwechsel.
|
// Der Editor-Zustand lebt außerhalb der Seiten und überlebt Seitenwechsel.
|
||||||
const editorStore = createStore(initialEditorState());
|
const editorStore = createStore(initialEditorState());
|
||||||
// … und gehört zum angemeldeten Konto: beim Abmelden oder Kontowechsel wird er geleert bzw. entkoppelt.
|
// … und gehört zum angemeldeten Konto: beim Abmelden oder Kontowechsel wird er geleert bzw. entkoppelt.
|
||||||
bindEditorToAuth(editorStore);
|
bindEditorToAuth(editorStore);
|
||||||
|
|
||||||
|
// Spenden-Banner (ohne DONATE_URL bleibt der Platz leer) und Kopfzeile mit Navigation, Suche und Knöpfen.
|
||||||
|
const bannerSlot = document.getElementById('donate-banner');
|
||||||
|
if (bannerSlot) mountDonateBanner(bannerSlot, DONATE_URL);
|
||||||
|
const header = document.getElementById('app-header');
|
||||||
|
if (header) mountHeader(header, { donateUrl: DONATE_URL });
|
||||||
|
|
||||||
// Konto-Menü im Seitenkopf. Der Anmeldestatus wird einmal beim Start geholt; schlägt das fehl
|
// Konto-Menü im Seitenkopf. Der Anmeldestatus wird einmal beim Start geholt; schlägt das fehl
|
||||||
// (außer 401, das getCurrentUser selbst als „abgemeldet“ meldet), gilt der Nutzer als abgemeldet.
|
// (außer 401, das getCurrentUser selbst als „abgemeldet“ meldet), gilt der Nutzer als abgemeldet.
|
||||||
const accountSlot = document.getElementById('account-slot');
|
const accountSlot = document.getElementById('account-slot');
|
||||||
@@ -26,12 +45,18 @@ if (accountSlot) {
|
|||||||
.finally(() => menu.ready());
|
.finally(() => menu.ready());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Der Router setzt `document.title` je Seite (titleFor) und ruft für jede Route die passende Mount-Funktion.
|
||||||
startRouter((route, root) => {
|
startRouter((route, root) => {
|
||||||
if (route === 'editor') {
|
switch (route.id) {
|
||||||
|
case 'gestalten':
|
||||||
return mountEditor(root, editorStore);
|
return mountEditor(root, editorStore);
|
||||||
}
|
case 'cards':
|
||||||
if (route === 'reset') {
|
|
||||||
return mountResetPage(root);
|
|
||||||
}
|
|
||||||
return mountCardsPage(root, editorStore);
|
return mountCardsPage(root, editorStore);
|
||||||
|
case 'community':
|
||||||
|
return mountCommunityPage(root, route.params.id ?? '');
|
||||||
|
case 'reset':
|
||||||
|
return mountResetPage(root);
|
||||||
|
case 'home':
|
||||||
|
return mountHomePage(root);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
@@ -141,7 +141,7 @@ describe('Meine Karten: Zustände', () => {
|
|||||||
mount();
|
mount();
|
||||||
await vi.waitFor(() => expect(text()).toContain('Noch keine Karten gespeichert.'));
|
await vi.waitFor(() => expect(text()).toContain('Noch keine Karten gespeichert.'));
|
||||||
const link = root.querySelector<HTMLAnchorElement>('a[data-link]')!;
|
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 () => {
|
it('Fehler beim Laden: Meldung und „Nochmal versuchen“', async () => {
|
||||||
@@ -247,7 +247,7 @@ describe('Meine Karten: Öffnen und Herunterladen', () => {
|
|||||||
mount();
|
mount();
|
||||||
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
|
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
|
||||||
button(tileFor('a'), 'Öffnen').click();
|
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 () => {
|
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';
|
state = 'list';
|
||||||
thumbSlots.clear();
|
thumbSlots.clear();
|
||||||
if (cards.length === 0) {
|
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));
|
show(el('p', { class: 'cards-msg' }, EMPTY), el('p', { class: 'cards-actions' }, toEditor));
|
||||||
return;
|
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', () => {
|
downloadButton.addEventListener('click', () => {
|
||||||
void run(async () => {
|
void run(async () => {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.cards-msg-error {
|
.cards-msg-error {
|
||||||
color: #ff9a9d;
|
color: var(--danger-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cards-actions {
|
.cards-actions {
|
||||||
@@ -85,7 +85,7 @@
|
|||||||
|
|
||||||
.card-msg {
|
.card-msg {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #ff9a9d;
|
color: var(--danger-text);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card-msg.is-warning {
|
.card-msg.is-warning {
|
||||||
color: #f2c66d;
|
color: var(--warn-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-rename {
|
.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 { 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', () => {
|
describe('matchRoute', () => {
|
||||||
it('ordnet / dem Editor zu', () => {
|
it('ordnet / der Startseite zu', () => {
|
||||||
expect(matchRoute('/')).toBe('editor');
|
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', () => {
|
it('ordnet /karten und /karten/ den Karten zu', () => {
|
||||||
expect(matchRoute('/karten')).toBe('cards');
|
expect(matchRoute('/karten')).toEqual({ id: 'cards', params: {} });
|
||||||
expect(matchRoute('/karten/')).toBe('cards');
|
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', () => {
|
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', () => {
|
it('fällt bei unbekannten Pfaden auf die Startseite zurück', () => {
|
||||||
expect(matchRoute('/irgendwas')).toBe('editor');
|
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();
|
const render = vi.fn();
|
||||||
startRouter(render);
|
startRouter(render);
|
||||||
expect(render).toHaveBeenCalledTimes(1);
|
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', () => {
|
it('navigate setzt die URL per pushState und rendert neu', () => {
|
||||||
@@ -49,23 +111,23 @@ describe('startRouter', () => {
|
|||||||
navigate('/passwort-neu');
|
navigate('/passwort-neu');
|
||||||
expect(location.pathname).toBe('/passwort-neu');
|
expect(location.pathname).toBe('/passwort-neu');
|
||||||
expect(history.length).toBe(lengthBefore + 1);
|
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', () => {
|
it('ruft vor dem nächsten Rendern den Cleanup der vorherigen Seite', () => {
|
||||||
const order: string[] = [];
|
const order: string[] = [];
|
||||||
startRouter((route) => {
|
startRouter((route) => {
|
||||||
order.push(`render:${route}`);
|
order.push(`render:${route.id}`);
|
||||||
return () => order.push(`cleanup:${route}`);
|
return () => order.push(`cleanup:${route.id}`);
|
||||||
});
|
});
|
||||||
navigate('/karten');
|
navigate('/karten');
|
||||||
navigate('/');
|
navigate('/');
|
||||||
expect(order).toEqual([
|
expect(order).toEqual([
|
||||||
'render:editor',
|
'render:home',
|
||||||
'cleanup:editor',
|
'cleanup:home',
|
||||||
'render:cards',
|
'render:cards',
|
||||||
'cleanup:cards',
|
'cleanup:cards',
|
||||||
'render:editor',
|
'render:home',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -91,7 +153,7 @@ describe('startRouter', () => {
|
|||||||
link.dispatchEvent(event);
|
link.dispatchEvent(event);
|
||||||
expect(event.defaultPrevented).toBe(true);
|
expect(event.defaultPrevented).toBe(true);
|
||||||
expect(location.pathname).toBe('/karten');
|
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', () => {
|
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)', () => {
|
it('beim Start und bei popstate wird nicht gescrollt (das übernimmt der Browser)', () => {
|
||||||
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
|
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
|
||||||
try {
|
try {
|
||||||
@@ -159,13 +275,17 @@ describe('startRouter', () => {
|
|||||||
vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
|
vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
|
||||||
try {
|
try {
|
||||||
startRouter(vi.fn());
|
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');
|
navigate('/karten');
|
||||||
expect(document.title).toBe('Meine Karten – AirCard-IOS');
|
expect(document.title).toBe('Meine Karten – AirCard-IOS');
|
||||||
|
navigate('/community/abc123');
|
||||||
|
expect(document.title).toBe('Community – AirCard-IOS');
|
||||||
navigate('/passwort-neu');
|
navigate('/passwort-neu');
|
||||||
expect(document.title).toBe('Passwort neu setzen – AirCard-IOS');
|
expect(document.title).toBe('Passwort neu setzen – AirCard-IOS');
|
||||||
navigate('/');
|
navigate('/');
|
||||||
expect(document.title).toBe('AirCard-IOS – Kartenmotiv gestalten');
|
expect(document.title).toBe('AirCard-IOS – Kartendesigns für Apple Wallet');
|
||||||
} finally {
|
} finally {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
}
|
}
|
||||||
@@ -177,6 +297,13 @@ describe('startRouter', () => {
|
|||||||
startRouter(render);
|
startRouter(render);
|
||||||
history.pushState(null, '', '/passwort-neu');
|
history.pushState(null, '', '/passwort-neu');
|
||||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
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 interface Route {
|
||||||
|
id: RouteId;
|
||||||
export function matchRoute(pathname: string): RouteId {
|
params: { id?: string };
|
||||||
const path = pathname.replace(/\/+$/, '') || '/';
|
|
||||||
if (path === '/karten') return 'cards';
|
|
||||||
if (path === '/passwort-neu') return 'reset';
|
|
||||||
return 'editor';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const TITLES: Record<RouteId, string> = {
|
type Render = (route: Route, root: HTMLElement) => void | (() => void);
|
||||||
editor: 'AirCard-IOS – Kartenmotiv gestalten',
|
|
||||||
|
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',
|
cards: 'Meine Karten – AirCard-IOS',
|
||||||
|
community: 'Community – AirCard-IOS',
|
||||||
reset: 'Passwort neu setzen – 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.
|
// Beendet den laufenden Router (Seiten-Cleanup, Listener); null, solange keiner läuft.
|
||||||
let stopCurrent: (() => void) | null = null;
|
let stopCurrent: (() => void) | null = null;
|
||||||
let renderCurrent: (() => 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 {
|
export function navigate(path: string): void {
|
||||||
const url = new URL(path, location.href);
|
const url = new URL(path, location.href);
|
||||||
const target = url.pathname + url.search + url.hash;
|
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) {
|
if (target !== location.pathname + location.search + location.hash) {
|
||||||
history.pushState(null, '', target);
|
history.pushState(null, '', target);
|
||||||
}
|
}
|
||||||
renderCurrent?.();
|
if (!renderCurrent) return;
|
||||||
// Eine neue Seite beginnt oben (pushState setzt die Scrollposition nicht zurück); bei popstate bleibt es dem Browser überlassen.
|
if (!hashOnly) renderCurrent();
|
||||||
if (renderCurrent) window.scrollTo(0, 0);
|
// 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 {
|
export function startRouter(render: Render): void {
|
||||||
@@ -42,7 +90,7 @@ export function startRouter(render: Render): void {
|
|||||||
cleanup?.();
|
cleanup?.();
|
||||||
cleanup = undefined;
|
cleanup = undefined;
|
||||||
const route = matchRoute(location.pathname);
|
const route = matchRoute(location.pathname);
|
||||||
document.title = TITLES[route];
|
document.title = titleFor(route);
|
||||||
cleanup = render(route, root);
|
cleanup = render(route, root);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { mountDonateBanner } from './donate-banner';
|
||||||
|
|
||||||
|
const KEY = 'aircard.donateBannerClosed';
|
||||||
|
const URL_ = 'https://buymeacoffee.com/aircard';
|
||||||
|
const TEXT = '☕ Gefällt dir AirCard-IOS? Spendier uns einen Kaffee – damit das Projekt weiterläuft.';
|
||||||
|
|
||||||
|
let slot: HTMLElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
document.body.innerHTML = '<div id="donate-banner"></div>';
|
||||||
|
slot = document.getElementById('donate-banner') as HTMLElement;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mountDonateBanner', () => {
|
||||||
|
it('leere URL: nichts wird gerendert', () => {
|
||||||
|
mountDonateBanner(slot, '');
|
||||||
|
expect(slot.childElementCount).toBe(0);
|
||||||
|
mountDonateBanner(slot, ' ');
|
||||||
|
expect(slot.childElementCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zeigt Text, Link „Unterstützen →“ (neuer Tab) und Schließen-Knopf', () => {
|
||||||
|
mountDonateBanner(slot, URL_);
|
||||||
|
expect(slot.querySelector('.donate-banner-text')?.textContent).toBe(TEXT);
|
||||||
|
const link = slot.querySelector<HTMLAnchorElement>('a.donate-banner-link')!;
|
||||||
|
expect(link.textContent).toBe('Unterstützen →');
|
||||||
|
expect(link.getAttribute('href')).toBe(URL_);
|
||||||
|
expect(link.target).toBe('_blank');
|
||||||
|
expect(link.rel).toBe('noopener noreferrer');
|
||||||
|
expect(link.hasAttribute('data-link')).toBe(false);
|
||||||
|
expect(slot.querySelector('button')?.getAttribute('aria-label')).toBe('Banner schließen');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Schließen setzt das Flag und entfernt das Banner', () => {
|
||||||
|
mountDonateBanner(slot, URL_);
|
||||||
|
slot.querySelector('button')!.click();
|
||||||
|
expect(localStorage.getItem(KEY)).toBe('1');
|
||||||
|
expect(slot.childElementCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Flag gesetzt: nichts wird gerendert', () => {
|
||||||
|
localStorage.setItem(KEY, '1');
|
||||||
|
mountDonateBanner(slot, URL_);
|
||||||
|
expect(slot.childElementCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zweimal eingehängt gibt es nur ein Banner', () => {
|
||||||
|
mountDonateBanner(slot, URL_);
|
||||||
|
mountDonateBanner(slot, URL_);
|
||||||
|
expect(slot.querySelectorAll('.donate-banner')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('localStorage wirft beim Lesen: Banner wird gezeigt', () => {
|
||||||
|
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||||
|
throw new DOMException('blockiert', 'SecurityError');
|
||||||
|
});
|
||||||
|
expect(() => mountDonateBanner(slot, URL_)).not.toThrow();
|
||||||
|
expect(slot.querySelectorAll('.donate-banner')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('localStorage wirft beim Schreiben: Banner verschwindet trotzdem, kein Fehler', () => {
|
||||||
|
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||||
|
throw new DOMException('voll', 'QuotaExceededError');
|
||||||
|
});
|
||||||
|
mountDonateBanner(slot, URL_);
|
||||||
|
expect(() => slot.querySelector('button')!.click()).not.toThrow();
|
||||||
|
expect(slot.childElementCount).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// Schließbares Spenden-Banner ganz oben. Ohne Spenden-Link (config.ts) oder nach dem Schließen wird nichts angezeigt.
|
||||||
|
import './shell.css';
|
||||||
|
import { el } from '../editor/dom';
|
||||||
|
|
||||||
|
const CLOSED_KEY = 'aircard.donateBannerClosed';
|
||||||
|
|
||||||
|
function isClosed(): boolean {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(CLOSED_KEY) !== null;
|
||||||
|
} catch {
|
||||||
|
return false; // Speicher gesperrt: das Banner bleibt sichtbar, bis die Seite neu geladen wird
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberClosed(): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(CLOSED_KEY, '1');
|
||||||
|
} catch {
|
||||||
|
// gilt dann nur für diese Sitzung
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mountDonateBanner(slot: HTMLElement, url: string): void {
|
||||||
|
slot.replaceChildren();
|
||||||
|
if (!url.trim() || isClosed()) return;
|
||||||
|
|
||||||
|
const close = el('button', { type: 'button', class: 'donate-banner-close', 'aria-label': 'Banner schließen' }, '×');
|
||||||
|
close.addEventListener('click', () => {
|
||||||
|
rememberClosed();
|
||||||
|
slot.replaceChildren();
|
||||||
|
});
|
||||||
|
|
||||||
|
slot.append(
|
||||||
|
el(
|
||||||
|
'div',
|
||||||
|
{ class: 'donate-banner' },
|
||||||
|
el(
|
||||||
|
'p',
|
||||||
|
{ class: 'donate-banner-text' },
|
||||||
|
el('span', { 'aria-hidden': 'true' }, '☕'),
|
||||||
|
' Gefällt dir AirCard-IOS? Spendier uns einen Kaffee – damit das Projekt weiterläuft.',
|
||||||
|
),
|
||||||
|
el('a', { class: 'donate-banner-link', href: url, target: '_blank', rel: 'noopener noreferrer' }, 'Unterstützen →'),
|
||||||
|
close,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({ navigate: vi.fn() }));
|
||||||
|
vi.mock('../pages/router', () => ({ navigate: mocks.navigate }));
|
||||||
|
|
||||||
|
import { mountHeader } from './header';
|
||||||
|
|
||||||
|
const DONATE = 'https://buymeacoffee.com/aircard';
|
||||||
|
const html = document.documentElement;
|
||||||
|
|
||||||
|
let header: HTMLElement;
|
||||||
|
let dispose: (() => void) | null = null;
|
||||||
|
|
||||||
|
function mount(donateUrl = '') {
|
||||||
|
dispose = mountHeader(header, { donateUrl });
|
||||||
|
}
|
||||||
|
|
||||||
|
const search = () => header.querySelector<HTMLFormElement>('form.header-search')!;
|
||||||
|
const input = () => search().querySelector<HTMLInputElement>('input')!;
|
||||||
|
const submit = () => search().dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||||
|
const menuButton = () => header.querySelector<HTMLButtonElement>('.menu-btn')!;
|
||||||
|
const nav = () => header.querySelector<HTMLElement>('nav.app-nav')!;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.navigate.mockReset();
|
||||||
|
localStorage.clear();
|
||||||
|
html.setAttribute('data-theme', 'dark');
|
||||||
|
document.body.innerHTML =
|
||||||
|
'<header id="app-header">' +
|
||||||
|
'<a class="brand" href="/" data-link><img src="/favicon.svg?v=2" alt="" width="28" height="28" /><span>AirCard-IOS</span></a>' +
|
||||||
|
'<div id="account-slot"></div>' +
|
||||||
|
'</header>';
|
||||||
|
header = document.getElementById('app-header') as HTMLElement;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
dispose?.();
|
||||||
|
dispose = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mountHeader: Aufbau', () => {
|
||||||
|
it('lässt Marke und Konto-Slot stehen und ergänzt Navigation, Suche und Knöpfe', () => {
|
||||||
|
const brand = header.querySelector('.brand');
|
||||||
|
const slot = header.querySelector('#account-slot');
|
||||||
|
mount();
|
||||||
|
expect(header.querySelector('.brand')).toBe(brand);
|
||||||
|
expect(header.querySelector('#account-slot')).toBe(slot);
|
||||||
|
expect(header.firstElementChild).toBe(brand);
|
||||||
|
expect(header.lastElementChild).toBe(slot);
|
||||||
|
expect(nav()).not.toBeNull();
|
||||||
|
expect(search()).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Navigation: Community, Gestalten, Meine Karten als data-link', () => {
|
||||||
|
mount();
|
||||||
|
const links = [...nav().querySelectorAll<HTMLAnchorElement>('a')].filter((a) => !a.classList.contains('nav-donate'));
|
||||||
|
expect(links.map((a) => [a.textContent, a.getAttribute('href'), a.hasAttribute('data-link')])).toEqual([
|
||||||
|
['Community', '/#community', true],
|
||||||
|
['Gestalten', '/gestalten', true],
|
||||||
|
['Meine Karten', '/karten', true],
|
||||||
|
]);
|
||||||
|
expect(nav().getAttribute('aria-label')).toBe('Hauptnavigation');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Suchfeld mit Platzhalter und Beschriftung', () => {
|
||||||
|
mount();
|
||||||
|
expect(input().placeholder).toBe('Titel, Tags oder Herausgeber');
|
||||||
|
expect(input().type).toBe('search');
|
||||||
|
expect(input().getAttribute('aria-label')).toBe('Community-Karten durchsuchen');
|
||||||
|
expect(search().getAttribute('role')).toBe('search');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mountHeader: Suche', () => {
|
||||||
|
it('Enter mit „ visa “ navigiert zu /?q=visa#community', () => {
|
||||||
|
mount();
|
||||||
|
input().value = ' visa ';
|
||||||
|
submit();
|
||||||
|
expect(mocks.navigate).toHaveBeenCalledOnce();
|
||||||
|
expect(mocks.navigate).toHaveBeenCalledWith('/?q=visa#community');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('kodiert Sonderzeichen', () => {
|
||||||
|
mount();
|
||||||
|
input().value = 'a b&c=d#e';
|
||||||
|
submit();
|
||||||
|
expect(mocks.navigate).toHaveBeenCalledWith('/?q=a%20b%26c%3Dd%23e#community');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leere oder nur aus Leerzeichen bestehende Eingabe: nichts passiert', () => {
|
||||||
|
mount();
|
||||||
|
input().value = '';
|
||||||
|
submit();
|
||||||
|
input().value = ' ';
|
||||||
|
submit();
|
||||||
|
expect(mocks.navigate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('das Absenden lädt die Seite nicht neu (Standardaktion verhindert)', () => {
|
||||||
|
mount();
|
||||||
|
input().value = 'visa';
|
||||||
|
const event = new Event('submit', { bubbles: true, cancelable: true });
|
||||||
|
search().dispatchEvent(event);
|
||||||
|
expect(event.defaultPrevented).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mountHeader: Spenden-Knopf', () => {
|
||||||
|
it('ohne donateUrl gibt es keinen ☕-Knopf', () => {
|
||||||
|
mount('');
|
||||||
|
expect(header.querySelector('.header-donate')).toBeNull();
|
||||||
|
expect(header.querySelector('.nav-donate')).toBeNull();
|
||||||
|
expect(header.textContent).not.toContain('☕');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mit donateUrl: Link „☕ Unterstützen“ in neuem Tab', () => {
|
||||||
|
mount(DONATE);
|
||||||
|
const link = header.querySelector<HTMLAnchorElement>('a.header-donate')!;
|
||||||
|
expect(link.getAttribute('href')).toBe(DONATE);
|
||||||
|
expect(link.target).toBe('_blank');
|
||||||
|
expect(link.rel).toBe('noopener noreferrer');
|
||||||
|
expect(link.textContent).toContain('☕');
|
||||||
|
expect(link.textContent).toContain('Unterstützen');
|
||||||
|
expect(link.hasAttribute('data-link')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mountHeader: Hell/Dunkel', () => {
|
||||||
|
it('Knopf „Hell/Dunkel umschalten“ wechselt data-theme und speichert', () => {
|
||||||
|
mount();
|
||||||
|
const button = header.querySelector<HTMLButtonElement>('button.theme-btn')!;
|
||||||
|
expect(button.getAttribute('aria-label')).toBe('Hell/Dunkel umschalten');
|
||||||
|
button.click();
|
||||||
|
expect(html.getAttribute('data-theme')).toBe('light');
|
||||||
|
expect(localStorage.getItem('aircard.theme')).toBe('light');
|
||||||
|
button.click();
|
||||||
|
expect(html.getAttribute('data-theme')).toBe('dark');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mountHeader: Menü unter 900 px', () => {
|
||||||
|
it('Menü-Knopf öffnet und schließt die Navigation (aria-expanded)', () => {
|
||||||
|
mount();
|
||||||
|
const button = menuButton();
|
||||||
|
expect(button.getAttribute('aria-label')).toBe('Menü');
|
||||||
|
expect(button.getAttribute('aria-controls')).toBe(nav().id);
|
||||||
|
expect(button.getAttribute('aria-expanded')).toBe('false');
|
||||||
|
expect(nav().classList.contains('is-open')).toBe(false);
|
||||||
|
button.click();
|
||||||
|
expect(button.getAttribute('aria-expanded')).toBe('true');
|
||||||
|
expect(nav().classList.contains('is-open')).toBe(true);
|
||||||
|
button.click();
|
||||||
|
expect(button.getAttribute('aria-expanded')).toBe('false');
|
||||||
|
expect(nav().classList.contains('is-open')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Escape schließt das Menü und gibt den Fokus an den Knopf', () => {
|
||||||
|
mount();
|
||||||
|
menuButton().click();
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||||
|
expect(nav().classList.contains('is-open')).toBe(false);
|
||||||
|
expect(menuButton().getAttribute('aria-expanded')).toBe('false');
|
||||||
|
expect(document.activeElement).toBe(menuButton());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein Klick auf einen Navigationslink oder außerhalb schließt das Menü', () => {
|
||||||
|
mount();
|
||||||
|
menuButton().click();
|
||||||
|
nav().querySelector('a')!.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||||
|
expect(nav().classList.contains('is-open')).toBe(false);
|
||||||
|
|
||||||
|
menuButton().click();
|
||||||
|
document.body.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
expect(nav().classList.contains('is-open')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Suchen schließt das Menü ebenfalls', () => {
|
||||||
|
mount();
|
||||||
|
menuButton().click();
|
||||||
|
input().value = 'visa';
|
||||||
|
submit();
|
||||||
|
expect(nav().classList.contains('is-open')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mountHeader: Aufräumen', () => {
|
||||||
|
it('entfernt Ergänztes; Marke und Konto-Slot bleiben', () => {
|
||||||
|
mount(DONATE);
|
||||||
|
dispose?.();
|
||||||
|
dispose = null;
|
||||||
|
expect([...header.children].map((c) => c.className || c.id)).toEqual(['brand', 'account-slot']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('meldet alle Dokument-Listener wieder ab', () => {
|
||||||
|
const added = vi.spyOn(document, 'addEventListener');
|
||||||
|
const removed = vi.spyOn(document, 'removeEventListener');
|
||||||
|
mount(DONATE);
|
||||||
|
expect(added).toHaveBeenCalled();
|
||||||
|
dispose?.();
|
||||||
|
dispose = null;
|
||||||
|
for (const [type, listener] of added.mock.calls) {
|
||||||
|
expect(removed.mock.calls.some(([t, l]) => t === type && l === listener)).toBe(true);
|
||||||
|
}
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// Kopfzeile: Marke und Konto-Slot stehen in index.html, hier kommen Navigation, Suche und Knöpfe dazu.
|
||||||
|
// Ab 900 px Breite liegt alles in einer Zeile; darunter steckt die Navigation hinter dem Menü-Knopf
|
||||||
|
// und das Suchfeld bekommt eine eigene Zeile (Stile in shell.css).
|
||||||
|
import './shell.css';
|
||||||
|
import { el, icon } from '../editor/dom';
|
||||||
|
import { navigate } from '../pages/router';
|
||||||
|
import { toggleTheme } from './theme';
|
||||||
|
|
||||||
|
export interface HeaderOptions {
|
||||||
|
/** Spenden-Link; leer = kein Spenden-Knopf. */
|
||||||
|
donateUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mountHeader(header: HTMLElement, opts: HeaderOptions): () => void {
|
||||||
|
const brand = header.querySelector('.brand');
|
||||||
|
const accountSlot = header.querySelector('#account-slot');
|
||||||
|
const hasDonate = opts.donateUrl.trim() !== '';
|
||||||
|
|
||||||
|
const link = (label: string, href: string) => el('a', { href, 'data-link': true }, label);
|
||||||
|
const donateAttrs = { href: opts.donateUrl, target: '_blank', rel: 'noopener noreferrer' };
|
||||||
|
|
||||||
|
const nav = el(
|
||||||
|
'nav',
|
||||||
|
{ class: 'app-nav', id: 'app-nav', 'aria-label': 'Hauptnavigation' },
|
||||||
|
link('Community', '/#community'),
|
||||||
|
link('Gestalten', '/gestalten'),
|
||||||
|
link('Meine Karten', '/karten'),
|
||||||
|
);
|
||||||
|
// Unter 600 px ist in der Kopfzeile kein Platz mehr für den Knopf: er steht dann im aufgeklappten Menü.
|
||||||
|
if (hasDonate) nav.append(el('a', { class: 'nav-donate', ...donateAttrs }, '☕ Unterstützen'));
|
||||||
|
|
||||||
|
const input = el('input', {
|
||||||
|
class: 'header-search-input',
|
||||||
|
type: 'search',
|
||||||
|
name: 'q',
|
||||||
|
placeholder: 'Titel, Tags oder Herausgeber',
|
||||||
|
'aria-label': 'Community-Karten durchsuchen',
|
||||||
|
autocomplete: 'off',
|
||||||
|
enterkeyhint: 'search',
|
||||||
|
});
|
||||||
|
const searchButton = el('button', { class: 'header-search-btn', type: 'submit', 'aria-label': 'Suchen' }, icon('search'));
|
||||||
|
const search = el('form', { class: 'header-search', role: 'search' }, input, searchButton);
|
||||||
|
|
||||||
|
const themeButton = el(
|
||||||
|
'button',
|
||||||
|
{ class: 'header-btn theme-btn', type: 'button', 'aria-label': 'Hell/Dunkel umschalten', title: 'Hell/Dunkel umschalten' },
|
||||||
|
icon('sun', 'theme-icon-sun'),
|
||||||
|
icon('moon', 'theme-icon-moon'),
|
||||||
|
);
|
||||||
|
themeButton.addEventListener('click', () => void toggleTheme());
|
||||||
|
|
||||||
|
const menuButton = el(
|
||||||
|
'button',
|
||||||
|
{ class: 'header-btn menu-btn', type: 'button', 'aria-label': 'Menü', 'aria-expanded': 'false', 'aria-controls': nav.id },
|
||||||
|
icon('menu'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const tools = el('div', { class: 'header-tools' });
|
||||||
|
if (hasDonate) {
|
||||||
|
tools.append(
|
||||||
|
el(
|
||||||
|
'a',
|
||||||
|
{ class: 'header-donate', ...donateAttrs },
|
||||||
|
el('span', { class: 'header-donate-icon', 'aria-hidden': 'true' }, '☕'),
|
||||||
|
el('span', { class: 'header-donate-text' }, 'Unterstützen'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
tools.append(themeButton, menuButton);
|
||||||
|
|
||||||
|
// Menü (nur unter 900 px sichtbar)
|
||||||
|
let open = false;
|
||||||
|
function setOpen(next: boolean): void {
|
||||||
|
open = next;
|
||||||
|
nav.classList.toggle('is-open', next);
|
||||||
|
menuButton.setAttribute('aria-expanded', String(next));
|
||||||
|
}
|
||||||
|
menuButton.addEventListener('click', () => setOpen(!open));
|
||||||
|
|
||||||
|
const onDocumentClick = (event: MouseEvent) => {
|
||||||
|
if (!open) return;
|
||||||
|
const target = event.target as Element | null;
|
||||||
|
if (target && menuButton.contains(target)) return; // das erledigt der Knopf selbst
|
||||||
|
if (target && nav.contains(target) && !target.closest('a')) return;
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
const onKeydown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key !== 'Escape' || !open) return;
|
||||||
|
setOpen(false);
|
||||||
|
menuButton.focus();
|
||||||
|
};
|
||||||
|
document.addEventListener('click', onDocumentClick);
|
||||||
|
document.addEventListener('keydown', onKeydown);
|
||||||
|
|
||||||
|
search.addEventListener('submit', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const q = input.value.trim();
|
||||||
|
if (!q) return;
|
||||||
|
setOpen(false);
|
||||||
|
input.blur(); // schließt auf dem Handy die Bildschirmtastatur
|
||||||
|
navigate(`/?q=${encodeURIComponent(q)}#community`);
|
||||||
|
});
|
||||||
|
|
||||||
|
header.replaceChildren(...[brand, nav, search, tools, accountSlot].filter((node): node is Element => node !== null));
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('click', onDocumentClick);
|
||||||
|
document.removeEventListener('keydown', onKeydown);
|
||||||
|
nav.remove();
|
||||||
|
search.remove();
|
||||||
|
tools.remove();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,416 @@
|
|||||||
|
/* Rahmen der Seite: Spenden-Banner, Kopfzeile (Marke, Navigation, Suche, Knöpfe, Konto). */
|
||||||
|
|
||||||
|
/* Spenden-Banner */
|
||||||
|
|
||||||
|
.donate-banner {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2px 14px;
|
||||||
|
padding: 8px 48px 8px 16px;
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--on-primary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.donate-banner-text {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.donate-banner-link {
|
||||||
|
color: inherit;
|
||||||
|
font-weight: 800;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.donate-banner-link:hover {
|
||||||
|
text-decoration-thickness: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.donate-banner :focus-visible {
|
||||||
|
outline-color: var(--on-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.donate-banner-close {
|
||||||
|
appearance: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 6px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
margin: -20px 0 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.donate-banner-close:hover {
|
||||||
|
background: rgb(255 255 255 / 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Kopfzeile */
|
||||||
|
|
||||||
|
#app-header {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px 14px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: var(--panel);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-right: 6px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand img {
|
||||||
|
display: block;
|
||||||
|
flex: none;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav a {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav a:hover {
|
||||||
|
background: var(--panel-2);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-donate {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Suche */
|
||||||
|
|
||||||
|
.header-search {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 120px; /* kleine Basis: die Zeile bricht erst um, wenn auch das Suchfeld nicht mehr schrumpfen kann */
|
||||||
|
align-items: center;
|
||||||
|
max-width: 360px;
|
||||||
|
min-width: 0;
|
||||||
|
height: 42px;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search:focus-within {
|
||||||
|
border-color: var(--sel-line);
|
||||||
|
outline: 2px solid var(--sel-line);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0 4px 0 16px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 1rem; /* mindestens 16 px, sonst zoomt iOS beim Fokussieren */
|
||||||
|
outline: none;
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search-input::placeholder {
|
||||||
|
color: var(--muted);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search-input::-webkit-search-cancel-button {
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search-btn {
|
||||||
|
appearance: none;
|
||||||
|
display: grid;
|
||||||
|
flex: none;
|
||||||
|
place-items: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0 999px 999px 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search-btn:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Knöpfe rechts: Spenden, Hell/Dunkel, Menü */
|
||||||
|
|
||||||
|
.header-tools {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-btn {
|
||||||
|
appearance: none;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.12s, border-color 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-btn:hover {
|
||||||
|
border-color: var(--sel-line);
|
||||||
|
background: var(--panel-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-btn .icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.8;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search-btn .icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sonne im dunklen Theme (Klick = hell), Mond im hellen (Klick = dunkel); ohne Attribut gilt dunkel */
|
||||||
|
:root[data-theme='light'] .theme-icon-sun,
|
||||||
|
:root:not([data-theme='light']) .theme-icon-moon {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-btn {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-donate {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: 1px solid var(--sel-line);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--sel-bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background-color 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-donate:hover {
|
||||||
|
background: var(--panel-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Konto-Slot */
|
||||||
|
|
||||||
|
#account-slot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bis 1179 px zeigt der Spenden-Knopf nur die Tasse; der Text bleibt für Screenreader */
|
||||||
|
@media (max-width: 1179px) {
|
||||||
|
.header-donate {
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-donate-text {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
margin: -1px;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
clip-path: inset(50%);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Desktop mit wenig Platz (900–1099 px): alles etwas enger, damit die Zeile nicht umbricht */
|
||||||
|
@media (min-width: 900px) and (max-width: 1099px) {
|
||||||
|
#app-header {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
margin-right: 0;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav a {
|
||||||
|
padding: 8px 9px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Unter 900 px: Navigation hinter dem Menü-Knopf, Suchfeld als eigene Zeile darunter */
|
||||||
|
@media (max-width: 899px) {
|
||||||
|
#app-header {
|
||||||
|
padding: 10px 16px;
|
||||||
|
gap: 10px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-btn {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-tools {
|
||||||
|
order: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#account-slot {
|
||||||
|
order: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav {
|
||||||
|
display: none;
|
||||||
|
flex-basis: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
order: 3;
|
||||||
|
padding: 6px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav.is-open {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav a {
|
||||||
|
padding: 12px 14px;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
order: 4;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Unter 600 px: Marke und Knöpfe etwas kleiner, damit „Anmelden“ noch in die erste Zeile passt;
|
||||||
|
der Spenden-Knopf wandert ins aufgeklappte Menü */
|
||||||
|
@media (max-width: 599px) {
|
||||||
|
#app-header {
|
||||||
|
gap: 10px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
gap: 8px;
|
||||||
|
margin-right: 0;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand img {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-tools {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-btn {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#account-slot .auth-btn-small {
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-donate {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-donate {
|
||||||
|
display: block;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sehr schmale Telefone */
|
||||||
|
@media (max-width: 379px) {
|
||||||
|
#app-header {
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.header-btn,
|
||||||
|
.header-donate {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { DEFAULT_CONFIG } from '../card/config';
|
||||||
|
import { renderCard, type CardAssets, type Ctx } from '../card/render';
|
||||||
|
import { applyTheme, currentTheme, initialTheme, initTheme, toggleTheme } from './theme';
|
||||||
|
|
||||||
|
const KEY = 'aircard.theme';
|
||||||
|
const html = document.documentElement;
|
||||||
|
|
||||||
|
function mockMatchMedia(prefersDark: boolean) {
|
||||||
|
window.matchMedia = vi.fn((query: string) => ({
|
||||||
|
matches: query === '(prefers-color-scheme: dark)' ? prefersDark : false,
|
||||||
|
media: query,
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
})) as unknown as typeof window.matchMedia;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
html.removeAttribute('data-theme');
|
||||||
|
document.head.innerHTML = '<meta name="theme-color" content="#0F1828" />';
|
||||||
|
mockMatchMedia(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('initialTheme', () => {
|
||||||
|
it('ohne gespeicherten Wert gilt das System', () => {
|
||||||
|
expect(initialTheme(null, true)).toBe('dark');
|
||||||
|
expect(initialTheme(null, false)).toBe('light');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein gespeicherter Wert schlägt das System', () => {
|
||||||
|
expect(initialTheme('light', true)).toBe('light');
|
||||||
|
expect(initialTheme('dark', false)).toBe('dark');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein ungültiger Wert wird ignoriert (System)', () => {
|
||||||
|
expect(initialTheme('blau', true)).toBe('dark');
|
||||||
|
expect(initialTheme('blau', false)).toBe('light');
|
||||||
|
expect(initialTheme('', false)).toBe('light');
|
||||||
|
expect(initialTheme('LIGHT', true)).toBe('dark');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyTheme', () => {
|
||||||
|
it('setzt data-theme auf <html> und die Browserfarbe, speichert aber nichts', () => {
|
||||||
|
applyTheme('light');
|
||||||
|
expect(html.getAttribute('data-theme')).toBe('light');
|
||||||
|
expect(document.querySelector('meta[name="theme-color"]')?.getAttribute('content')).toBe('#F4F6FB');
|
||||||
|
applyTheme('dark');
|
||||||
|
expect(html.getAttribute('data-theme')).toBe('dark');
|
||||||
|
expect(document.querySelector('meta[name="theme-color"]')?.getAttribute('content')).toBe('#0F1828');
|
||||||
|
expect(localStorage.getItem(KEY)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('kommt auch ohne theme-color-Tag klar', () => {
|
||||||
|
document.head.innerHTML = '';
|
||||||
|
expect(() => applyTheme('light')).not.toThrow();
|
||||||
|
expect(html.getAttribute('data-theme')).toBe('light');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('initTheme', () => {
|
||||||
|
it('nimmt den gespeicherten Wert', () => {
|
||||||
|
localStorage.setItem(KEY, 'light');
|
||||||
|
expect(initTheme()).toBe('light');
|
||||||
|
expect(html.getAttribute('data-theme')).toBe('light');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne gespeicherten Wert das System (dunkel bzw. hell)', () => {
|
||||||
|
expect(initTheme()).toBe('dark');
|
||||||
|
mockMatchMedia(false);
|
||||||
|
expect(initTheme()).toBe('light');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wirft localStorage beim Lesen, gilt das System', () => {
|
||||||
|
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||||
|
throw new DOMException('blockiert', 'SecurityError');
|
||||||
|
});
|
||||||
|
mockMatchMedia(false);
|
||||||
|
expect(initTheme()).toBe('light');
|
||||||
|
expect(html.getAttribute('data-theme')).toBe('light');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fehlt matchMedia, bleibt es dunkel', () => {
|
||||||
|
window.matchMedia = undefined as unknown as typeof window.matchMedia;
|
||||||
|
expect(initTheme()).toBe('dark');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toggleTheme', () => {
|
||||||
|
it('wechselt zwischen dunkel und hell, setzt data-theme und speichert', () => {
|
||||||
|
applyTheme('dark');
|
||||||
|
expect(toggleTheme()).toBe('light');
|
||||||
|
expect(html.getAttribute('data-theme')).toBe('light');
|
||||||
|
expect(localStorage.getItem(KEY)).toBe('light');
|
||||||
|
expect(toggleTheme()).toBe('dark');
|
||||||
|
expect(html.getAttribute('data-theme')).toBe('dark');
|
||||||
|
expect(localStorage.getItem(KEY)).toBe('dark');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne data-theme geht es vom Systemwert aus', () => {
|
||||||
|
mockMatchMedia(false); // System hell → Wechsel auf dunkel
|
||||||
|
expect(toggleTheme()).toBe('dark');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wirft localStorage beim Schreiben, gibt es keinen Fehler und der Wechsel gilt trotzdem', () => {
|
||||||
|
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||||
|
throw new DOMException('voll', 'QuotaExceededError');
|
||||||
|
});
|
||||||
|
applyTheme('dark');
|
||||||
|
expect(() => toggleTheme()).not.toThrow();
|
||||||
|
expect(html.getAttribute('data-theme')).toBe('light');
|
||||||
|
expect(currentTheme()).toBe('light');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Theme und Kartenausgabe', () => {
|
||||||
|
// Umschalten verändert nur UI-Tokens: der Renderer liest weder data-theme noch CSS-Variablen.
|
||||||
|
function drawLog(): string[] {
|
||||||
|
const log: string[] = [];
|
||||||
|
const state: Record<string, unknown> = {};
|
||||||
|
const ctx = new Proxy(state, {
|
||||||
|
get(target, prop: string) {
|
||||||
|
if (prop in target) return target[prop];
|
||||||
|
if (prop === 'measureText') return (text: string) => ({ width: text.length * 20 });
|
||||||
|
if (prop === 'createLinearGradient') {
|
||||||
|
return (...args: number[]) => {
|
||||||
|
log.push(`gradient(${args.join(',')})`);
|
||||||
|
return { addColorStop: (offset: number, color: string) => log.push(`stop(${offset},${color})`) };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return (...args: unknown[]) => void log.push(`${prop}(${args.map((a) => (typeof a === 'object' ? 'obj' : String(a))).join(',')})`);
|
||||||
|
},
|
||||||
|
set(target, prop: string, value) {
|
||||||
|
target[prop] = value;
|
||||||
|
log.push(`${prop}=${String(value)}`);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const assets: CardAssets = { image: null, network: {} as CanvasImageSource, brand: {} as CanvasImageSource };
|
||||||
|
renderCard(ctx as unknown as Ctx, { ...DEFAULT_CONFIG, template: 'classic' }, assets, () => log.push('strokePath'));
|
||||||
|
return log;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('hell und dunkel zeichnen dieselbe Karte', () => {
|
||||||
|
applyTheme('dark');
|
||||||
|
const dark = drawLog();
|
||||||
|
applyTheme('light');
|
||||||
|
const light = drawLog();
|
||||||
|
expect(dark.length).toBeGreaterThan(10);
|
||||||
|
expect(light).toEqual(dark);
|
||||||
|
expect(dark.join('\n')).toContain('fillStyle=#1B2738');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// Hell/Dunkel: `data-theme` auf <html>, Auswahl in localStorage `aircard.theme`, Standard = System.
|
||||||
|
// Das Skript im <head> von index.html setzt das Attribut schon vor dem ersten Paint (kein Aufblitzen).
|
||||||
|
// Das Theme betrifft nur die Oberfläche (CSS-Tokens); die Karte selbst (Vorschau, PNG) bleibt davon unberührt.
|
||||||
|
|
||||||
|
export type Theme = 'light' | 'dark';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'aircard.theme';
|
||||||
|
// Farbe der Browserleiste (Handy), passend zu `--bg` des jeweiligen Themes.
|
||||||
|
const BROWSER_COLOR: Record<Theme, string> = { dark: '#0F1828', light: '#F4F6FB' };
|
||||||
|
|
||||||
|
/** Gespeicherter Wert, sonst System; ein ungültiger Wert zählt als „nichts gespeichert“. */
|
||||||
|
export function initialTheme(stored: string | null, prefersDark: boolean): Theme {
|
||||||
|
if (stored === 'light' || stored === 'dark') return stored;
|
||||||
|
return prefersDark ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStored(): string | null {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(STORAGE_KEY);
|
||||||
|
} catch {
|
||||||
|
return null; // Speicher gesperrt (z. B. privates Fenster): es gilt das System
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function systemPrefersDark(): boolean {
|
||||||
|
try {
|
||||||
|
// Ohne Angabe (alter Browser) bleibt es beim bisherigen dunklen Look.
|
||||||
|
return window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)').matches : true;
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Setzt das Theme (Attribut und Browserleiste), ohne etwas zu speichern. */
|
||||||
|
export function applyTheme(theme: Theme): void {
|
||||||
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', BROWSER_COLOR[theme]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Das gerade angezeigte Theme (Attribut, sonst gespeicherter Wert bzw. System). */
|
||||||
|
export function currentTheme(): Theme {
|
||||||
|
const attr = document.documentElement.getAttribute('data-theme');
|
||||||
|
return attr === 'light' || attr === 'dark' ? attr : initialTheme(readStored(), systemPrefersDark());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wendet beim Start das gespeicherte bzw. das System-Theme an. */
|
||||||
|
export function initTheme(): Theme {
|
||||||
|
const theme = initialTheme(readStored(), systemPrefersDark());
|
||||||
|
applyTheme(theme);
|
||||||
|
return theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wechselt Hell ↔ Dunkel und merkt es sich; ein gesperrter Speicher ist kein Fehler. */
|
||||||
|
export function toggleTheme(): Theme {
|
||||||
|
const next: Theme = currentTheme() === 'dark' ? 'light' : 'dark';
|
||||||
|
applyTheme(next);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, next);
|
||||||
|
} catch {
|
||||||
|
// gilt dann nur für diese Sitzung
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
+33
-58
@@ -1,4 +1,5 @@
|
|||||||
:root {
|
:root,
|
||||||
|
:root[data-theme='dark'] {
|
||||||
--bg: #0F1828;
|
--bg: #0F1828;
|
||||||
--panel: #162133;
|
--panel: #162133;
|
||||||
--panel-2: #1B2738;
|
--panel-2: #1B2738;
|
||||||
@@ -10,12 +11,43 @@
|
|||||||
--muted: #9DB0C8;
|
--muted: #9DB0C8;
|
||||||
--primary: #2A4566;
|
--primary: #2A4566;
|
||||||
--primary-hover: #34557C;
|
--primary-hover: #34557C;
|
||||||
|
--on-primary: #E8EEF7;
|
||||||
--danger: #E5484D;
|
--danger: #E5484D;
|
||||||
|
--danger-text: #ff9a9d;
|
||||||
|
--warn-text: #f2c66d;
|
||||||
|
--ok-text: #8fd9a8;
|
||||||
|
--backdrop: rgb(6 11 20 / 0.72);
|
||||||
|
--shadow-lg: 0 24px 80px rgb(0 0 0 / 0.55);
|
||||||
|
--shadow-card: 0 10px 34px rgb(0 0 0 / 0.45);
|
||||||
--radius: 14px;
|
--radius: 14px;
|
||||||
|
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hell: nur die Oberfläche; die Kartenfarben (Vorschau, PNG) hängen nicht vom Theme ab. */
|
||||||
|
:root[data-theme='light'] {
|
||||||
|
--bg: #F4F6FB;
|
||||||
|
--panel: #FFFFFF;
|
||||||
|
--panel-2: #EEF1F7;
|
||||||
|
--line: #D9DFEA;
|
||||||
|
--line-strong: #B8C2D3;
|
||||||
|
--sel-bg: #E3ECFB;
|
||||||
|
--sel-line: #3A6FD8;
|
||||||
|
--text: #0F1828;
|
||||||
|
--muted: #5B6B82;
|
||||||
|
--primary: #2A4566;
|
||||||
|
--primary-hover: #34557C;
|
||||||
|
--on-primary: #FFFFFF;
|
||||||
|
--danger-text: #B42318;
|
||||||
|
--warn-text: #8A5A00;
|
||||||
|
--ok-text: #1B6B3A;
|
||||||
|
--backdrop: rgb(15 24 40 / 0.5);
|
||||||
|
--shadow-lg: 0 24px 80px rgb(15 24 40 / 0.28);
|
||||||
|
--shadow-card: 0 10px 34px rgb(15 24 40 / 0.22);
|
||||||
|
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
*,
|
*,
|
||||||
*::before,
|
*::before,
|
||||||
*::after {
|
*::after {
|
||||||
@@ -47,63 +79,6 @@ a {
|
|||||||
outline-offset: 2px;
|
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 {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
color: var(--text);
|
|
||||||
font-size: 1.35rem;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 0.01em;
|
|
||||||
text-decoration: none;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand img {
|
|
||||||
display: block;
|
|
||||||
flex: none;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Schmale Bildschirme: Marke etwas kleiner, damit sie samt „Meine Karten“ in die erste Kopfzeile passt */
|
|
||||||
@media (max-width: 599px) {
|
|
||||||
.brand {
|
|
||||||
gap: 8px;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand img {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.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 {
|
#app-root {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
Reference in new issue
Block a user