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:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 13:39:09 +00:00
1 parent 1a64e12bc5
commit 25ff496548
30 files changed
+1783 -189

No files matched your search

+75
View File
@@ -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);
});
});