diff --git a/web/index.html b/web/index.html index f72b352..792ce73 100644 --- a/web/index.html +++ b/web/index.html @@ -3,19 +3,30 @@ - AirCard-IOS – Kartenmotiv gestalten + AirCard-IOS – Kartendesigns für Apple Wallet + +
AirCard-IOS -
diff --git a/web/src/account/account-menu.test.ts b/web/src/account/account-menu.test.ts new file mode 100644 index 0000000..c14e0a5 --- /dev/null +++ b/web/src/account/account-menu.test.ts @@ -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('button.account-avatar'); +const panel = () => slot.querySelector('.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 = '
'; + 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('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); + }); +}); diff --git a/web/src/account/account-menu.ts b/web/src/account/account-menu.ts index 08a05e5..a409825 100644 --- a/web/src/account/account-menu.ts +++ b/web/src/account/account-menu.ts @@ -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 { el } from '../editor/dom'; import { currentUser, logout, onAuthChange } from './auth'; @@ -13,8 +14,12 @@ export interface AccountMenu { export function mountAccountMenu(slot: HTMLElement): AccountMenu { 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 { + detach?.(); + detach = null; if (!isReady) { slot.replaceChildren(); return; @@ -26,9 +31,52 @@ export function mountAccountMenu(slot: HTMLElement): AccountMenu { slot.replaceChildren(el('div', { class: 'account-menu' }, signIn)); 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 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 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.disabled = true; error.textContent = ''; @@ -38,14 +86,7 @@ export function mountAccountMenu(slot: HTMLElement): AccountMenu { signOut.disabled = false; }); }); - slot.replaceChildren( - el( - 'div', - { class: 'account-menu', role: 'group', 'aria-label': 'Konto' }, - el('div', { class: 'account-row' }, email, signOut), - error, - ), - ); + slot.replaceChildren(wrap); } const off = onAuthChange(render); @@ -58,6 +99,8 @@ export function mountAccountMenu(slot: HTMLElement): AccountMenu { }, dispose() { off(); + detach?.(); + detach = null; slot.replaceChildren(); }, }; diff --git a/web/src/account/account.css b/web/src/account/account.css index 3a16bc0..9316271 100644 --- a/web/src/account/account.css +++ b/web/src/account/account.css @@ -25,6 +25,7 @@ .auth-btn-primary { border-color: var(--primary); background: var(--primary); + color: var(--on-primary); font-weight: 700; } @@ -35,7 +36,7 @@ .auth-btn-danger { border-color: var(--danger); - color: #ff9a9d; + color: var(--danger-text); } .auth-btn-danger:hover:not(:disabled) { @@ -70,43 +71,104 @@ cursor: not-allowed; } -/* Konto-Menü im Seitenkopf */ - -#account-slot { - min-width: 0; -} +/* Konto-Menü im Seitenkopf: „Anmelden“ bzw. Avatar-Kreis mit aufklappbarem Menü */ .account-menu { + position: relative; display: flex; - flex-direction: column; - align-items: flex-end; - gap: 4px; + align-items: center; min-width: 0; 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; - align-items: center; - gap: 12px; - min-width: 0; - max-width: 100%; + flex-direction: column; + gap: 2px; + min-width: 220px; + 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 { min-width: 0; - max-width: 28ch; + margin-bottom: 4px; + padding: 6px 12px 10px; overflow: hidden; + border-bottom: 1px solid var(--line); color: var(--muted); - font-size: 0.95rem; + font-size: 0.9rem; text-overflow: ellipsis; 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 { - color: #ff9a9d; + padding: 4px 12px 0; + color: var(--danger-text); font-size: 0.85rem; - text-align: right; } .account-error:empty { @@ -124,11 +186,11 @@ border-radius: 24px; background: var(--panel); color: var(--text); - box-shadow: 0 24px 80px rgb(0 0 0 / 0.55); + box-shadow: var(--shadow-lg); } .auth-dialog::backdrop { - background: rgb(6 11 20 / 0.72); + background: var(--backdrop); } .auth-card { @@ -261,11 +323,11 @@ } .auth-msg-error { - color: #ff9a9d; + color: var(--danger-text); } .auth-msg-ok { - color: #8fd9a8; + color: var(--ok-text); } .auth-aside { @@ -289,29 +351,15 @@ margin: 0; } -/* Schmale Bildschirme: Konto-Menü rutscht bei Bedarf in eine zweite Kopfzeile */ - @media (max-width: 599px) { - #app-header { - flex-wrap: wrap; - gap: 8px 16px; - } - - #account-slot { - max-width: 100%; - } - - .account-email { - max-width: none; - } - .auth-card { padding: 24px 20px 20px; } } @media (prefers-reduced-motion: reduce) { - .auth-btn { + .auth-btn, + .account-avatar { transition: none; } } diff --git a/web/src/account/auth.test.ts b/web/src/account/auth.test.ts index 6afd155..ce0f135 100644 --- a/web/src/account/auth.test.ts +++ b/web/src/account/auth.test.ts @@ -29,7 +29,7 @@ describe('getCurrentUser', () => { it('liefert den Nutzer und merkt sich die ID', async () => { const auth = await freshAuth(); 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'); }); @@ -71,7 +71,7 @@ describe('getCurrentUser', () => { await auth.getCurrentUser(); await auth.getCurrentUser(); 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')); 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', () => { it('legt die Sitzung an, holt den Nutzer und benachrichtigt', async () => { const auth = await freshAuth(); @@ -87,10 +133,10 @@ describe('login', () => { auth.onAuthChange(listener); mocks.createEmailPasswordSession.mockResolvedValue({}); 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(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 () => { @@ -114,7 +160,7 @@ describe('login bei bereits aktiver Sitzung (user_session_already_exists)', () = mocks.createEmailPasswordSession.mockRejectedValueOnce(existing()).mockResolvedValueOnce({}); mocks.deleteSession.mockResolvedValue({}); 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).toHaveBeenCalledWith({ sessionId: 'current' }); 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.deleteSession.mockRejectedValue(fail(401, 'general_unauthorized_scope')); 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); }); @@ -177,7 +223,7 @@ describe('login bei bereits aktiver Sitzung (user_session_already_exists)', () = mocks.createEmailPasswordSession.mockRejectedValueOnce(existing()).mockResolvedValueOnce({}); mocks.deleteSession.mockResolvedValue({}); 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); }); }); @@ -188,7 +234,7 @@ describe('register', () => { mocks.create.mockResolvedValue({}); mocks.createEmailPasswordSession.mockResolvedValue({}); 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]; expect(params).toMatchObject({ email: 'neu@b.de', password: 'geheim123', name: 'neu' }); expect(typeof params.userId).toBe('string'); diff --git a/web/src/account/auth.ts b/web/src/account/auth.ts index 0392598..4af7672 100644 --- a/web/src/account/auth.ts +++ b/web/src/account/auth.ts @@ -4,7 +4,7 @@ import { ID } from 'appwrite'; import { account } from './appwrite'; 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_MAX = 256; @@ -27,10 +27,16 @@ function setUser(user: User | null): void { function toUser(res: unknown): User { const data = (typeof res === 'object' && res !== null ? res : {}) as Record; 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 { let next: User | null; try { @@ -39,7 +45,11 @@ export async function getCurrentUser(): Promise { if (!isUnauthorized(err)) throw err; 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; } diff --git a/web/src/branding.test.ts b/web/src/branding.test.ts index 52c48d6..76e594a 100644 --- a/web/src/branding.test.ts +++ b/web/src/branding.test.ts @@ -14,7 +14,7 @@ function pngInfo(buf: Buffer) { describe('Name und Kopfbereich', () => { it('Titel und Meta-Angaben nennen AirCard-IOS', () => { - expect(indexHtml).toContain('AirCard-IOS – Kartenmotiv gestalten'); + expect(indexHtml).toContain('AirCard-IOS – Kartendesigns für Apple Wallet'); expect(indexHtml).toContain(''); expect(indexHtml).toContain(''); }); diff --git a/web/src/config.ts b/web/src/config.ts new file mode 100644 index 0000000..a720caa --- /dev/null +++ b/web/src/config.ts @@ -0,0 +1,7 @@ +// Konfiguration der Website. + +/** + * Öffentlicher Spenden-Link (Buy Me a Coffee, `https://buymeacoffee.com/`). + * Solange er leer ist, werden Spenden-Banner und -Knopf nicht angezeigt. + */ +export const DONATE_URL: string = ''; diff --git a/web/src/editor/auth-reset.test.ts b/web/src/editor/auth-reset.test.ts index 3225304..3ff99c9 100644 --- a/web/src/editor/auth-reset.test.ts +++ b/web/src/editor/auth-reset.test.ts @@ -67,7 +67,7 @@ beforeEach(() => { describe('bindEditorToAuth', () => { 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'); bindEditorToAuth(store, fake.auth); design(); @@ -76,7 +76,7 @@ describe('bindEditorToAuth', () => { expect(store.get().image).toBeNull(); expect(store.get().saved).toBeNull(); 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)', () => { @@ -90,7 +90,7 @@ describe('bindEditorToAuth', () => { }); 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'); bindEditorToAuth(store, fake.auth); const { blob, config } = design(); @@ -172,10 +172,11 @@ describe('bindEditorToAuth', () => { }); 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'); bindEditorToAuth(store, fake.auth); fake.logout(); + expect(location.pathname).toBe('/gestalten'); expect(location.search).toBe('?x=1'); expect(location.hash).toBe('#h'); }); diff --git a/web/src/editor/card-url.ts b/web/src/editor/card-url.ts index 4958c79..12c4b2a 100644 --- a/web/src/editor/card-url.ts +++ b/web/src/editor/card-url.ts @@ -1,4 +1,4 @@ -// Die Adresse des Editors kann eine Karte nennen (`/?karte=`). +// Die Adresse des Editors kann eine Karte nennen (`/gestalten?karte=`; alte Links `/?karte=` leitet main.ts um). /** Entfernt `karte` aus der Adresse (ersetzt den Verlaufseintrag), damit ein Neuladen nicht dieselbe Karte öffnet. */ export function dropCardParam(): void { diff --git a/web/src/editor/confirm-dialog.css b/web/src/editor/confirm-dialog.css index 1939b10..7db9194 100644 --- a/web/src/editor/confirm-dialog.css +++ b/web/src/editor/confirm-dialog.css @@ -7,11 +7,11 @@ border-radius: 20px; background: var(--panel); color: var(--text); - box-shadow: 0 24px 80px rgb(0 0 0 / 0.55); + box-shadow: var(--shadow-lg); } .confirm-dialog::backdrop { - background: rgb(6 11 20 / 0.72); + background: var(--backdrop); } .confirm-card { diff --git a/web/src/editor/dom.ts b/web/src/editor/dom.ts index 2e6b255..df1d6da 100644 --- a/web/src/editor/dom.ts +++ b/web/src/editor/dom.ts @@ -25,6 +25,13 @@ const ICONS = { lock: '', check: '', ban: '', + sun: + '' + + '' + + '', + moon: '', + menu: '', + search: '', } as const; export type IconName = keyof typeof ICONS; diff --git a/web/src/editor/editor-page.test.ts b/web/src/editor/editor-page.test.ts index 0abde94..5c1e2ab 100644 --- a/web/src/editor/editor-page.test.ts +++ b/web/src/editor/editor-page.test.ts @@ -297,8 +297,8 @@ describe('Speichern', () => { }); }); -describe('Karte öffnen (/?karte=)', () => { - const open = (id: string) => history.replaceState(null, '', `/?karte=${id}`); +describe('Karte öffnen (/gestalten?karte=)', () => { + const open = (id: string) => history.replaceState(null, '', `/gestalten?karte=${id}`); it('lädt Karte und Bild, begrenzt den Ausschnitt und setzt den Zustand', async () => { open('k1'); @@ -531,7 +531,7 @@ describe('Karte öffnen (/?karte=)', () => { expect(store.get()).toBe(before); expect(store.get().imageBlob).toBe(blob); 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 () => { @@ -733,7 +733,7 @@ describe('Bindung an eine gespeicherte Karte', () => { }); 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.loadImage.mockResolvedValue(png()); mocks.decodeImage.mockResolvedValue(image()); @@ -758,7 +758,7 @@ describe('Bindung an eine gespeicherte Karte', () => { describe('„Neue Karte“', () => { 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(); mount(); newCardButton().click(); @@ -774,7 +774,7 @@ describe('Bindung an eine gespeicherte Karte', () => { expect(s.config).not.toBe(DEFAULT_CONFIG); expect(s.config.crop).not.toBe(DEFAULT_CONFIG.crop); expect(s.config).not.toBe(config); - expect(location.pathname + location.search).toBe('/'); + expect(location.pathname + location.search).toBe('/gestalten'); expect(boundRow().hidden).toBe(true); }); @@ -787,7 +787,7 @@ describe('Bindung an eine gespeicherte Karte', () => { }); it('bricht eine noch ladende Karte ab', async () => { - history.replaceState(null, '', '/?karte=k2'); + history.replaceState(null, '', '/gestalten?karte=k2'); boundEditor(); let finish!: (c: SavedCard) => void; mocks.get.mockReturnValue(new Promise((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 () => { - history.replaceState(null, '', '/?karte=k1'); + history.replaceState(null, '', '/gestalten?karte=k1'); mocks.get.mockResolvedValue(savedCard()); mocks.loadImage.mockResolvedValue(png()); mocks.decodeImage.mockResolvedValue(image(3000, 2000)); @@ -928,7 +928,7 @@ describe('Bindung an eine gespeicherte Karte', () => { describe('Abmelden während des Ladens', () => { 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; mocks.get.mockReturnValue(new Promise((r) => (finish = r))); mount(); diff --git a/web/src/editor/editor.css b/web/src/editor/editor.css index 6af003a..b52a288 100644 --- a/web/src/editor/editor.css +++ b/web/src/editor/editor.css @@ -183,6 +183,13 @@ -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 { width: 46px; height: 46px; @@ -356,7 +363,7 @@ height: auto; border-radius: var(--card-radius); background: #1b2738; - box-shadow: 0 10px 34px rgba(0, 0, 0, 0.45); + box-shadow: var(--shadow-card); cursor: grab; touch-action: none; user-select: none; @@ -402,11 +409,11 @@ } .msg-error { - color: #ff9a9d; + color: var(--danger-text); } .msg-warning { - color: #f2c66d; + color: var(--warn-text); } .msg-notice { @@ -456,6 +463,7 @@ .btn-primary { border-color: var(--primary); background: var(--primary); + color: var(--on-primary); font-weight: 700; } diff --git a/web/src/main.ts b/web/src/main.ts index af97b63..e3cbccf 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -2,18 +2,37 @@ import '@fontsource-variable/nunito-sans'; import './styles.css'; import { mountAccountMenu } from './account/account-menu'; 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 { mountCommunityPage } from './pages/community-page'; +import { mountHomePage } from './pages/home-page'; import { mountResetPage } from './pages/reset-page'; import { mountEditor } from './editor/editor-page'; import { bindEditorToAuth } from './editor/auth-reset'; 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=`) zeigen jetzt auf `/gestalten?karte=`; 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. const editorStore = createStore(initialEditorState()); // … und gehört zum angemeldeten Konto: beim Abmelden oder Kontowechsel wird er geleert bzw. entkoppelt. 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 // (außer 401, das getCurrentUser selbst als „abgemeldet“ meldet), gilt der Nutzer als abgemeldet. const accountSlot = document.getElementById('account-slot'); @@ -26,12 +45,18 @@ if (accountSlot) { .finally(() => menu.ready()); } +// Der Router setzt `document.title` je Seite (titleFor) und ruft für jede Route die passende Mount-Funktion. startRouter((route, root) => { - if (route === 'editor') { - return mountEditor(root, editorStore); + switch (route.id) { + case 'gestalten': + return mountEditor(root, editorStore); + case 'cards': + return mountCardsPage(root, editorStore); + case 'community': + return mountCommunityPage(root, route.params.id ?? ''); + case 'reset': + return mountResetPage(root); + case 'home': + return mountHomePage(root); } - if (route === 'reset') { - return mountResetPage(root); - } - return mountCardsPage(root, editorStore); }); diff --git a/web/src/pages/cards-page.test.ts b/web/src/pages/cards-page.test.ts index a3ec15d..9914f7c 100644 --- a/web/src/pages/cards-page.test.ts +++ b/web/src/pages/cards-page.test.ts @@ -141,7 +141,7 @@ describe('Meine Karten: Zustände', () => { mount(); await vi.waitFor(() => expect(text()).toContain('Noch keine Karten gespeichert.')); const link = root.querySelector('a[data-link]')!; - expect(link.getAttribute('href')).toBe('/'); + expect(link.getAttribute('href')).toBe('/gestalten'); }); it('Fehler beim Laden: Meldung und „Nochmal versuchen“', async () => { @@ -247,7 +247,7 @@ describe('Meine Karten: Öffnen und Herunterladen', () => { mount(); await vi.waitFor(() => expect(tiles()).toHaveLength(1)); 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-.png speichern', async () => { diff --git a/web/src/pages/cards-page.ts b/web/src/pages/cards-page.ts index f56a29d..2b33ad4 100644 --- a/web/src/pages/cards-page.ts +++ b/web/src/pages/cards-page.ts @@ -156,7 +156,7 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi state = 'list'; thumbSlots.clear(); 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)); 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', () => { void run(async () => { diff --git a/web/src/pages/cards.css b/web/src/pages/cards.css index 1656fea..d2a35ad 100644 --- a/web/src/pages/cards.css +++ b/web/src/pages/cards.css @@ -10,7 +10,7 @@ } .cards-msg-error { - color: #ff9a9d; + color: var(--danger-text); } .cards-actions { @@ -85,7 +85,7 @@ .card-msg { margin: 0; - color: #ff9a9d; + color: var(--danger-text); font-size: 0.9rem; } @@ -94,7 +94,7 @@ } .card-msg.is-warning { - color: #f2c66d; + color: var(--warn-text); } .card-rename { diff --git a/web/src/pages/community-page.ts b/web/src/pages/community-page.ts new file mode 100644 index 0000000..b242baf --- /dev/null +++ b/web/src/pages/community-page.ts @@ -0,0 +1,10 @@ +// Detailseite „/community/“. 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 () => {}; +} diff --git a/web/src/pages/home-page.ts b/web/src/pages/home-page.ts new file mode 100644 index 0000000..2818080 --- /dev/null +++ b/web/src/pages/home-page.ts @@ -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 () => {}; +} diff --git a/web/src/pages/router.test.ts b/web/src/pages/router.test.ts index c90a21c..e428948 100644 --- a/web/src/pages/router.test.ts +++ b/web/src/pages/router.test.ts @@ -1,22 +1,84 @@ 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', () => { - it('ordnet / dem Editor zu', () => { - expect(matchRoute('/')).toBe('editor'); + it('ordnet / der Startseite zu', () => { + 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', () => { - expect(matchRoute('/karten')).toBe('cards'); - expect(matchRoute('/karten/')).toBe('cards'); + expect(matchRoute('/karten')).toEqual({ id: 'cards', params: {} }); + expect(matchRoute('/karten/')).toEqual({ id: 'cards', params: {} }); + }); + + it('ordnet /community/ 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', () => { - 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', () => { - expect(matchRoute('/irgendwas')).toBe('editor'); + it('fällt bei unbekannten Pfaden auf die Startseite zurück', () => { + expect(matchRoute('/irgendwas')).toEqual({ id: 'home', params: {} }); + }); +}); + +describe('legacyRedirect', () => { + it('leitet /?karte= auf /gestalten?karte= 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: „ – 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(); startRouter(render); 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', () => { @@ -49,23 +111,23 @@ describe('startRouter', () => { navigate('/passwort-neu'); expect(location.pathname).toBe('/passwort-neu'); 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', () => { const order: string[] = []; startRouter((route) => { - order.push(`render:${route}`); - return () => order.push(`cleanup:${route}`); + order.push(`render:${route.id}`); + return () => order.push(`cleanup:${route.id}`); }); navigate('/karten'); navigate('/'); expect(order).toEqual([ - 'render:editor', - 'cleanup:editor', + 'render:home', + 'cleanup:home', 'render:cards', 'cleanup:cards', - 'render:editor', + 'render:home', ]); }); @@ -91,7 +153,7 @@ describe('startRouter', () => { link.dispatchEvent(event); expect(event.defaultPrevented).toBe(true); 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', () => { @@ -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)', () => { const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {}); try { @@ -159,13 +275,17 @@ describe('startRouter', () => { vi.spyOn(window, 'scrollTo').mockImplementation(() => {}); try { 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'); expect(document.title).toBe('Meine Karten – AirCard-IOS'); + navigate('/community/abc123'); + expect(document.title).toBe('Community – AirCard-IOS'); navigate('/passwort-neu'); expect(document.title).toBe('Passwort neu setzen – AirCard-IOS'); navigate('/'); - expect(document.title).toBe('AirCard-IOS – Kartenmotiv gestalten'); + expect(document.title).toBe('AirCard-IOS – Kartendesigns für Apple Wallet'); } finally { vi.restoreAllMocks(); } @@ -177,6 +297,13 @@ describe('startRouter', () => { startRouter(render); history.pushState(null, '', '/passwort-neu'); 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); }); }); diff --git a/web/src/pages/router.ts b/web/src/pages/router.ts index ebe0d07..2e116b1 100644 --- a/web/src/pages/router.ts +++ b/web/src/pages/router.ts @@ -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 function matchRoute(pathname: string): RouteId { - const path = pathname.replace(/\/+$/, '') || '/'; - if (path === '/karten') return 'cards'; - if (path === '/passwort-neu') return 'reset'; - return 'editor'; +export interface Route { + id: RouteId; + params: { id?: string }; } -const TITLES: Record = { - editor: 'AirCard-IOS – Kartenmotiv gestalten', +type Render = (route: Route, root: HTMLElement) => void | (() => void); + +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=` heißt jetzt `/gestalten?karte=`; 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 = { + home: 'AirCard-IOS – Kartendesigns für Apple Wallet', + gestalten: 'Gestalten – AirCard-IOS', cards: 'Meine Karten – AirCard-IOS', + community: 'Community – 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. let stopCurrent: (() => void) | null = null; let renderCurrent: (() => void) | null = null; +/** Springt zum Anker `#`; 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 { const url = new URL(path, location.href); 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) { history.pushState(null, '', target); } - renderCurrent?.(); - // Eine neue Seite beginnt oben (pushState setzt die Scrollposition nicht zurück); bei popstate bleibt es dem Browser überlassen. - if (renderCurrent) window.scrollTo(0, 0); + if (!renderCurrent) return; + if (!hashOnly) renderCurrent(); + // 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 { @@ -42,7 +90,7 @@ export function startRouter(render: Render): void { cleanup?.(); cleanup = undefined; const route = matchRoute(location.pathname); - document.title = TITLES[route]; + document.title = titleFor(route); cleanup = render(route, root); }; diff --git a/web/src/shell/donate-banner.test.ts b/web/src/shell/donate-banner.test.ts new file mode 100644 index 0000000..af06533 --- /dev/null +++ b/web/src/shell/donate-banner.test.ts @@ -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 = ''; + 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('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); + }); +}); diff --git a/web/src/shell/donate-banner.ts b/web/src/shell/donate-banner.ts new file mode 100644 index 0000000..2134268 --- /dev/null +++ b/web/src/shell/donate-banner.ts @@ -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, + ), + ); +} diff --git a/web/src/shell/header.test.ts b/web/src/shell/header.test.ts new file mode 100644 index 0000000..4801f40 --- /dev/null +++ b/web/src/shell/header.test.ts @@ -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('form.header-search')!; +const input = () => search().querySelector('input')!; +const submit = () => search().dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); +const menuButton = () => header.querySelector('.menu-btn')!; +const nav = () => header.querySelector('nav.app-nav')!; + +beforeEach(() => { + mocks.navigate.mockReset(); + localStorage.clear(); + html.setAttribute('data-theme', 'dark'); + document.body.innerHTML = + '
' + + 'AirCard-IOS' + + '
' + + '
'; + 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('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('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('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(); + }); +}); diff --git a/web/src/shell/header.ts b/web/src/shell/header.ts new file mode 100644 index 0000000..f29316f --- /dev/null +++ b/web/src/shell/header.ts @@ -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(); + }; +} diff --git a/web/src/shell/shell.css b/web/src/shell/shell.css new file mode 100644 index 0000000..7dbb0be --- /dev/null +++ b/web/src/shell/shell.css @@ -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; + } +} diff --git a/web/src/shell/theme.test.ts b/web/src/shell/theme.test.ts new file mode 100644 index 0000000..6c4afac --- /dev/null +++ b/web/src/shell/theme.test.ts @@ -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 = ''; + 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 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 = {}; + 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'); + }); +}); diff --git a/web/src/shell/theme.ts b/web/src/shell/theme.ts new file mode 100644 index 0000000..92518b7 --- /dev/null +++ b/web/src/shell/theme.ts @@ -0,0 +1,63 @@ +// Hell/Dunkel: `data-theme` auf , Auswahl in localStorage `aircard.theme`, Standard = System. +// Das Skript im 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 = { 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; +} diff --git a/web/src/styles.css b/web/src/styles.css index 5c1dbf2..1e90424 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1,4 +1,5 @@ -:root { +:root, +:root[data-theme='dark'] { --bg: #0F1828; --panel: #162133; --panel-2: #1B2738; @@ -10,12 +11,43 @@ --muted: #9DB0C8; --primary: #2A4566; --primary-hover: #34557C; + --on-primary: #E8EEF7; --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; 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, *::after { @@ -47,63 +79,6 @@ a { 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 { flex: 1; width: 100%;