feat(shell): Routen, Kopfzeile, Hell/Dunkel, Spenden-Banner
- Router: Routen home, gestalten, cards, community/<id>, reset; Umleitung /?karte=<id> nach /gestalten?karte=<id>; Titel je Route (titleFor); Hash-Ziele (/#community) scrollen den Anker ins Bild, nur-Hash-Wechsel bauen die Seite nicht neu auf. - Kopfzeile: Navigation, Suche (Enter -> /?q=...#community), Spenden-Knopf, Hell/Dunkel-Schalter, Menue unter 900 px, Konto als Avatar-Kreis mit Menue. - Theme: data-theme auf <html> (Skript im <head> gegen Aufblitzen), helle Tokens, danger-/warn-/ok-text-Tokens statt fester Farben. - Spenden-Banner (schliessbar); ohne DONATE_URL bleiben Banner und Knopf weg. - User.labels und isAdmin; Platzhalter fuer Startseite und Detailseite. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1a64e12bc5
commit
25ff496548
30 files changed
+1783
-189
No files matched your search
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user