diff --git a/web/src/community/community.css b/web/src/community/community.css index 34e1655..80b2d9a 100644 --- a/web/src/community/community.css +++ b/web/src/community/community.css @@ -475,9 +475,319 @@ background: #b42318; } +/* Detailseite /community/ */ + +.detail-page { + --card-radius: 3.711% / 5.882%; +} + +.detail-back { + margin: 0 0 16px; +} + +.detail-back a { + display: inline-flex; + align-items: center; + min-height: 36px; + font-weight: 600; + text-decoration: none; +} + +.detail-back a:hover { + text-decoration: underline; +} + +.detail-layout { + display: grid; + grid-template-columns: minmax(0, 1.35fr) minmax(300px, 1fr); + align-items: start; + gap: 24px 36px; +} + +.detail-card { + position: relative; + aspect-ratio: 1536 / 969; + overflow: hidden; + border-radius: var(--card-radius); + background: var(--panel-2); + box-shadow: var(--shadow-card), 0 0 0 1px rgb(255 255 255 / 0.06); +} + +.detail-card img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.detail-card-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + height: 100%; + padding: 16px; + color: var(--muted); + text-align: center; +} + +.detail-card-empty p { + margin: 0; +} + +.detail-info { + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; +} + +#app-root .detail-title { + margin: 0; + font-size: clamp(1.5rem, 3vw, 2rem); + font-weight: 800; + line-height: 1.15; + overflow-wrap: anywhere; +} + +.detail-issuer { + margin: 0; + font-size: 1.1rem; + font-weight: 600; + overflow-wrap: anywhere; +} + +.detail-meta { + margin: 0; + color: var(--muted); + overflow-wrap: anywhere; +} + +.detail-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.detail-tag { + display: inline-flex; + align-items: center; + min-height: 32px; + padding: 2px 13px; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--panel-2); + color: var(--muted); + font-size: 0.9rem; + font-weight: 600; + text-decoration: none; + overflow-wrap: anywhere; +} + +.detail-tag:hover { + border-color: var(--sel-line); + color: var(--text); +} + +.detail-notice-slot:empty, +.detail-admin-slot:empty { + display: none; +} + +.detail-hidden { + margin: 0; + padding: 10px 14px; + border: 1px solid var(--danger); + border-radius: 12px; + background: rgb(229 72 77 / 0.12); +} + +.detail-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 6px; +} + +.detail-btn-danger { + border-color: var(--danger); + color: var(--danger-text); +} + +.detail-btn-danger:hover:not(:disabled) { + border-color: var(--danger); + background: rgb(229 72 77 / 0.16); +} + +.detail-msg { + margin: 0; + color: var(--ok-text); +} + +.detail-msg.is-error { + color: var(--danger-text); +} + +.detail-msg:empty { + display: none; +} + +.detail-admin { + margin-top: 8px; + padding: 16px; + border: 1px solid var(--line); + border-radius: 16px; + background: var(--panel); +} + +.detail-admin-title { + margin: 0 0 8px; + font-size: 1.05rem; +} + +.detail-reports-count { + margin: 0 0 8px; + font-weight: 700; +} + +.detail-reasons { + margin: 0 0 12px; + padding-left: 20px; + color: var(--muted); +} + +.detail-admin-note { + margin: 0 0 12px; + color: var(--warn-text); +} + +.detail-admin-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.detail-unavailable { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 16px; + max-width: 560px; + padding: 24px 0; +} + +.detail-load-error, +.detail-status { + margin: 24px 0; + color: var(--muted); +} + +.detail-load-error { + margin: 0; + color: var(--danger-text); +} + +/* Melden-Dialog (Knöpfe und Meldungen kommen aus account.css) */ + +.report-dialog { + width: min(420px, calc(100vw - 32px)); + max-height: calc(100dvh - 32px); + padding: 0; + overflow: auto; + border: 1px solid var(--line-strong); + border-radius: 20px; + background: var(--panel); + color: var(--text); + box-shadow: var(--shadow-lg); +} + +.report-dialog::backdrop { + background: var(--backdrop); +} + +.report-card { + padding: 24px; +} + +.report-title { + margin: 0 0 8px; + font-size: 1.25rem; + line-height: 1.25; +} + +.report-form { + display: flex; + flex-direction: column; + gap: 14px; +} + +.report-text { + margin: 0; + color: var(--muted); + overflow-wrap: anywhere; +} + +.report-reasons { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + margin: 0; + padding: 0; + border: 0; +} + +.report-legend { + margin-bottom: 4px; + padding: 0; + color: var(--muted); + font-size: 0.9rem; + font-weight: 600; +} + +.report-reason { + display: flex; + align-items: center; + gap: 12px; + min-height: 44px; + padding: 0 12px; + border: 1px solid var(--line); + border-radius: 12px; + cursor: pointer; +} + +.report-reason:hover { + border-color: var(--sel-line); +} + +.report-reason:has(input:checked) { + border-color: var(--sel-line); + background: var(--sel-bg); +} + +.report-reason input { + flex: none; + width: 18px; + height: 18px; + margin: 0; + accent-color: var(--sel-line); +} + +.report-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 10px; +} + /* Tablet und Handy: eine Spalte, der Hero bleibt niedrig */ @media (max-width: 899px) { + .detail-layout { + grid-template-columns: minmax(0, 1fr); + } + .home-hero { --hero-card-max: 400px; grid-template-columns: minmax(0, 1fr); @@ -491,6 +801,10 @@ } @media (max-width: 599px) { + .detail-actions .home-btn { + flex: 1 1 100%; + } + .home-hero { --hero-card-max: 250px; gap: 8px; diff --git a/web/src/community/report-dialog.test.ts b/web/src/community/report-dialog.test.ts new file mode 100644 index 0000000..0e45067 --- /dev/null +++ b/web/src/community/report-dialog.test.ts @@ -0,0 +1,241 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// api.ts hängt am Appwrite-Client; der Dialog bekommt `send` von außen, ApiError wird nur als Klasse gebraucht. +vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' })); +vi.mock('../account/auth', () => ({ currentUserId: () => null, onAuthChange: () => () => {}, onLogout: () => () => {} })); + +import { ApiError, type ReportReason } from './api'; +import { REPORT_REASONS, reportDialog, type ReportOutcome } from './report-dialog'; + +type Send = (reason: ReportReason) => Promise<{ schonGemeldet: boolean }>; + +const send = vi.fn(); + +const dialogEl = () => document.querySelector('dialog.report-dialog'); +const buttonByText = (text: string) => + [...(dialogEl()?.querySelectorAll('button') ?? [])].find((b) => b.textContent === text) as HTMLButtonElement | undefined; +const radios = () => [...(dialogEl()?.querySelectorAll('input[type="radio"]') ?? [])]; +const choose = (value: string) => { + const radio = radios().find((r) => r.value === value) as HTMLInputElement; + radio.checked = true; + radio.dispatchEvent(new Event('change', { bubbles: true })); +}; +const submit = () => dialogEl()!.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); +const errorText = () => dialogEl()!.querySelector('[role="alert"]')!.textContent; +const statusText = () => dialogEl()!.querySelector('[role="status"]')!.textContent; +const settle = async () => { + for (let i = 0; i < 30; i++) await Promise.resolve(); +}; + +function open(opts: { cardTitle?: string; signal?: AbortSignal } = {}): Promise { + return reportDialog({ send, ...opts }); +} + +beforeEach(() => { + send.mockReset(); + document.body.innerHTML = ''; +}); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('Melden-Dialog', () => { + it('zeigt die fünf Gründe in der Reihenfolge der Spec, keiner ist vorgewählt', () => { + void open(); + expect(dialogEl()?.open).toBe(true); + expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Karte melden'); + expect(REPORT_REASONS.map((r) => r.label)).toEqual([ + 'Unangemessen', + 'Echte Kartendaten', + 'Urheberrecht/Marke', + 'Spam', + 'Sonstiges', + ]); + expect([...dialogEl()!.querySelectorAll('.report-reason')].map((l) => l.textContent)).toEqual([ + 'Unangemessen', + 'Echte Kartendaten', + 'Urheberrecht/Marke', + 'Spam', + 'Sonstiges', + ]); + expect(radios().map((r) => r.value)).toEqual(['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges']); + expect(radios().some((r) => r.checked)).toBe(false); + expect(buttonByText('Abbrechen')).toBeTruthy(); + expect(buttonByText('Melden')).toBeTruthy(); + }); + + it('nennt den Kartentitel als Text, nie als HTML', () => { + void open({ cardTitle: '' }); + expect(dialogEl()!.querySelector('img')).toBeNull(); + expect(dialogEl()!.textContent).toContain(''); + }); + + it('ohne Grund: Hinweis, nichts wird gesendet', async () => { + void open(); + submit(); + await settle(); + expect(errorText()).toBe('Bitte wähle einen Grund.'); + expect(send).not.toHaveBeenCalled(); + expect(dialogEl()?.open).toBe(true); + }); + + it.each([ + ['unangemessen', 'unangemessen'], + ['kartendaten', 'kartendaten'], + ['urheberrecht', 'urheberrecht'], + ['spam', 'spam'], + ['sonstiges', 'sonstiges'], + ])('Grund %s geht als %s an die API', async (value, expected) => { + send.mockResolvedValue({ schonGemeldet: false }); + void open(); + choose(value); + submit(); + await settle(); + expect(send).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledWith(expected); + }); + + it('neue Meldung: Dank im Dialog, „Schließen“ beendet mit „reported“', async () => { + send.mockResolvedValue({ schonGemeldet: false }); + const result = open(); + choose('spam'); + submit(); + await settle(); + expect(statusText()).toBe('Danke für deine Meldung.'); + expect(errorText()).toBe(''); + expect(radios()).toHaveLength(0); + expect(buttonByText('Melden')).toBeUndefined(); + expect(dialogEl()?.open).toBe(true); + buttonByText('Schließen')!.click(); + await expect(result).resolves.toBe('reported'); + expect(dialogEl()).toBeNull(); + }); + + it('doppelte Meldung: „Danke, du hast diese Karte schon gemeldet.“ und „duplicate“', async () => { + send.mockResolvedValue({ schonGemeldet: true }); + const result = open(); + choose('spam'); + submit(); + await settle(); + expect(statusText()).toBe('Danke, du hast diese Karte schon gemeldet.'); + buttonByText('Schließen')!.click(); + await expect(result).resolves.toBe('duplicate'); + }); + + it('Drosselung (429): der Servertext erscheint, der Dialog bleibt und lässt einen neuen Versuch zu', async () => { + send + .mockRejectedValueOnce(new ApiError(429, 'Zu viele Meldungen – bitte später nochmal.')) + .mockResolvedValueOnce({ schonGemeldet: false }); + const result = open(); + choose('spam'); + submit(); + await settle(); + expect(errorText()).toBe('Zu viele Meldungen – bitte später nochmal.'); + expect(dialogEl()?.open).toBe(true); + expect(buttonByText('Melden')!.disabled).toBe(false); + expect(radios().every((r) => !r.disabled)).toBe(true); + // Die Auswahl bleibt stehen. + expect(radios().find((r) => r.checked)?.value).toBe('spam'); + + submit(); + await settle(); + expect(send).toHaveBeenCalledTimes(2); + expect(errorText()).toBe(''); + expect(statusText()).toBe('Danke für deine Meldung.'); + buttonByText('Schließen')!.click(); + await expect(result).resolves.toBe('reported'); + }); + + it('andere Fehler des Dienstes zeigen ihren Servertext (z. B. 404, 503)', async () => { + send.mockRejectedValueOnce(new ApiError(404, 'Diese Karte gibt es nicht (mehr).')); + void open(); + choose('spam'); + submit(); + await settle(); + expect(errorText()).toBe('Diese Karte gibt es nicht (mehr).'); + + send.mockRejectedValueOnce(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.')); + submit(); + await settle(); + expect(errorText()).toBe('Der Dienst ist gerade nicht erreichbar.'); + }); + + it('ein unerwarteter Fehler (kein ApiError) bekommt einen allgemeinen deutschen Text', async () => { + send.mockRejectedValueOnce(new TypeError('offline')); + void open(); + choose('spam'); + submit(); + await settle(); + expect(errorText()).toBe('Keine Verbindung zum Server.'); + }); + + it('während des Sendens ist alles gesperrt, ein zweites Absenden sendet nichts', async () => { + let finish!: (v: { schonGemeldet: boolean }) => void; + send.mockReturnValue(new Promise((r) => (finish = r))); + void open(); + choose('spam'); + submit(); + await settle(); + expect(buttonByText('Melden')!.disabled).toBe(true); + expect(radios().every((r) => r.disabled)).toBe(true); + expect(dialogEl()!.querySelector('form')!.getAttribute('aria-busy')).toBe('true'); + submit(); + await settle(); + expect(send).toHaveBeenCalledTimes(1); + finish({ schonGemeldet: false }); + await settle(); + expect(statusText()).toBe('Danke für deine Meldung.'); + }); + + it('Abbrechen schließt ohne zu senden: „cancelled“', async () => { + const result = open(); + buttonByText('Abbrechen')!.click(); + await expect(result).resolves.toBe('cancelled'); + expect(dialogEl()).toBeNull(); + expect(send).not.toHaveBeenCalled(); + }); + + it('Esc (close-Ereignis) und Klick auf den Hintergrund schließen ebenfalls', async () => { + const first = open(); + dialogEl()!.dispatchEvent(new Event('close')); + await expect(first).resolves.toBe('cancelled'); + expect(dialogEl()).toBeNull(); + + const second = open(); + const dialog = dialogEl()!; + dialog.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + dialog.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await expect(second).resolves.toBe('cancelled'); + expect(dialogEl()).toBeNull(); + }); + + it('ein Klick im Inneren schließt nicht', () => { + void open(); + dialogEl()!.querySelector('.report-card')!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + expect(dialogEl()?.open).toBe(true); + }); + + it('das Signal (Seite verlassen) schließt den Dialog; eine spät eintreffende Antwort ändert nichts', async () => { + let finish!: (v: { schonGemeldet: boolean }) => void; + send.mockReturnValue(new Promise((r) => (finish = r))); + const controller = new AbortController(); + const result = open({ signal: controller.signal }); + choose('spam'); + submit(); + await settle(); + controller.abort(); + await expect(result).resolves.toBe('cancelled'); + expect(dialogEl()).toBeNull(); + finish({ schonGemeldet: false }); + await settle(); + expect(dialogEl()).toBeNull(); + }); + + it('ein schon abgebrochenes Signal öffnet gar nicht erst', async () => { + const controller = new AbortController(); + controller.abort(); + await expect(open({ signal: controller.signal })).resolves.toBe('cancelled'); + expect(dialogEl()).toBeNull(); + }); +}); diff --git a/web/src/community/report-dialog.ts b/web/src/community/report-dialog.ts new file mode 100644 index 0000000..7dacfb2 --- /dev/null +++ b/web/src/community/report-dialog.ts @@ -0,0 +1,156 @@ +// Melden-Dialog der Detailseite: Grund wählen, an den API-Dienst senden, das Ergebnis im Dialog zeigen. +// Eigenes wie confirm-dialog.ts; alle Texte gehen als Text in den DOM, nie als HTML. +import '../account/account.css'; +import './community.css'; +import { errorMessage } from '../account/errors'; +import { el } from '../editor/dom'; +import { ApiError, type ReportReason } from './api'; + +/** Die Gründe in der Reihenfolge und mit dem Wortlaut der Spec (§ 5), dazu der Wert für die API. */ +export const REPORT_REASONS: ReadonlyArray<{ value: ReportReason; label: string }> = [ + { value: 'unangemessen', label: 'Unangemessen' }, + { value: 'kartendaten', label: 'Echte Kartendaten' }, + { value: 'urheberrecht', label: 'Urheberrecht/Marke' }, + { value: 'spam', label: 'Spam' }, + { value: 'sonstiges', label: 'Sonstiges' }, +]; + +export const REPORT_THANKS = 'Danke für deine Meldung.'; +/** Wortlaut aus Spec § 11. */ +export const REPORT_DUPLICATE = 'Danke, du hast diese Karte schon gemeldet.'; +const NO_REASON = 'Bitte wähle einen Grund.'; + +/** `reported`: neue Meldung angenommen; `duplicate`: dieser Melder hatte die Karte schon gemeldet; `cancelled`: nichts gesendet oder Ausgang unbekannt. */ +export type ReportOutcome = 'reported' | 'duplicate' | 'cancelled'; + +export interface ReportDialogOptions { + /** Sendet die Meldung (`reportCard` der API). */ + send: (reason: ReportReason) => Promise<{ schonGemeldet: boolean }>; + /** Titel der Karte für die Überschrift des Textes (als Text). */ + cardTitle?: string; + /** Schließt den Dialog, z. B. beim Verlassen der Seite; eine danach eintreffende Antwort wird verworfen. */ + signal?: AbortSignal; +} + +/** + * Öffnet den Dialog. Löst auf, sobald er geschlossen ist: mit dem Ergebnis der letzten Meldung, sonst mit `cancelled`. + * Fehler des Dienstes erscheinen mit ihrem Servertext im Dialog; der Dialog bleibt dann offen für einen neuen Versuch. + */ +export function reportDialog(opts: ReportDialogOptions): Promise { + return new Promise((resolve, reject) => { + if (opts.signal?.aborted) { + resolve('cancelled'); + return; + } + + const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null; + let outcome: ReportOutcome = 'cancelled'; + let closed = false; + let busy = false; + + const radios = REPORT_REASONS.map(({ value }) => el('input', { type: 'radio', name: 'reason', value })); + const reasons = el( + 'fieldset', + { class: 'report-reasons' }, + el('legend', { class: 'report-legend' }, 'Grund der Meldung'), + ...REPORT_REASONS.map(({ label }, i) => el('label', { class: 'report-reason' }, radios[i], label)), + ); + const intro = el( + 'p', + { class: 'report-text' }, + opts.cardTitle ? `Warum möchtest du „${opts.cardTitle}“ melden?` : 'Warum möchtest du diese Karte melden?', + ); + const errorEl = el('p', { class: 'auth-msg auth-msg-error', role: 'alert' }); + const statusEl = el('p', { class: 'auth-msg auth-msg-ok', role: 'status' }); + const cancel = el('button', { type: 'button', class: 'auth-btn' }, 'Abbrechen'); + const send = el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, 'Melden'); + const actions = el('div', { class: 'report-actions' }, cancel, send); + const form = el('form', { class: 'report-form' }, intro, reasons, errorEl, statusEl, actions); + const dialog = el( + 'dialog', + { class: 'report-dialog', 'aria-labelledby': 'report-title' }, + el('div', { class: 'report-card' }, el('h2', { id: 'report-title', class: 'report-title' }, 'Karte melden'), form), + ); + + const finish = () => { + if (closed) return; + closed = true; + opts.signal?.removeEventListener('abort', finish); + if (dialog.open) dialog.close(); + dialog.remove(); + if (opener?.isConnected) opener.focus(); + resolve(outcome); + }; + + function setBusy(value: boolean): void { + busy = value; + form.setAttribute('aria-busy', String(value)); + for (const radio of radios) radio.disabled = value; + send.disabled = value; + } + + function showResult(duplicate: boolean): void { + outcome = duplicate ? 'duplicate' : 'reported'; + errorEl.textContent = ''; + statusEl.textContent = duplicate ? REPORT_DUPLICATE : REPORT_THANKS; + intro.remove(); + reasons.remove(); + const close = el('button', { type: 'button', class: 'auth-btn auth-btn-primary' }, 'Schließen'); + close.addEventListener('click', finish); + actions.replaceChildren(close); + close.focus(); + } + + async function submit(): Promise { + const chosen = REPORT_REASONS.find((_, i) => radios[i].checked); + if (!chosen) { + statusEl.textContent = ''; + errorEl.textContent = NO_REASON; + return; + } + setBusy(true); + errorEl.textContent = ''; + statusEl.textContent = ''; + try { + const res = await opts.send(chosen.value); + if (closed) return; + showResult(res.schonGemeldet); + } catch (err) { + if (closed) return; + // Der Dienst liefert deutsche Texte (auch für 429); alles andere (Netz, JWT) übersetzt errorMessage. + errorEl.textContent = err instanceof ApiError ? err.message : errorMessage(err, 'load'); + setBusy(false); + } + } + + form.addEventListener('submit', (event) => { + event.preventDefault(); + if (busy || outcome !== 'cancelled') return; + void submit(); + }); + cancel.addEventListener('click', finish); + // Esc schließt nativ und löst `close` aus. + dialog.addEventListener('close', finish); + // Hintergrund = Klick auf das selbst (die Karte füllt es ganz aus); Drücken und Loslassen müssen beide dort sein, + // sonst schließt ein Textmarkieren, das außerhalb endet, den Dialog. + let pressedOnBackdrop = false; + dialog.addEventListener('mousedown', (event) => { + pressedOnBackdrop = event.target === dialog; + }); + dialog.addEventListener('click', (event) => { + if (event.target === dialog && pressedOnBackdrop) finish(); + pressedOnBackdrop = false; + }); + opts.signal?.addEventListener('abort', finish); + + document.body.append(dialog); + try { + dialog.showModal(); + } catch (err) { + closed = true; + opts.signal?.removeEventListener('abort', finish); + dialog.remove(); + reject(err); + } + }); +} diff --git a/web/src/editor/card-url.ts b/web/src/editor/card-url.ts index 12c4b2a..3175c36 100644 --- a/web/src/editor/card-url.ts +++ b/web/src/editor/card-url.ts @@ -1,9 +1,20 @@ -// Die Adresse des Editors kann eine Karte nennen (`/gestalten?karte=`; alte Links `/?karte=` leitet main.ts um). +// Die Adresse des Editors kann eine Karte (`/gestalten?karte=`; alte Links `/?karte=` leitet main.ts um) +// oder eine Community-Vorlage (`/gestalten?vorlage=`) nennen. -/** Entfernt `karte` aus der Adresse (ersetzt den Verlaufseintrag), damit ein Neuladen nicht dieselbe Karte öffnet. */ -export function dropCardParam(): void { +/** Entfernt den Parameter aus der Adresse (ersetzt den Verlaufseintrag), damit ein Neuladen oder „Zurück“ nicht erneut lädt. */ +function dropParam(name: string): void { const url = new URL(location.href); - if (!url.searchParams.has('karte')) return; - url.searchParams.delete('karte'); + if (!url.searchParams.has(name)) return; + url.searchParams.delete(name); history.replaceState(null, '', url.pathname + url.search + url.hash); } + +/** Entfernt `karte` aus der Adresse. */ +export function dropCardParam(): void { + dropParam('karte'); +} + +/** Entfernt `vorlage` aus der Adresse. */ +export function dropTemplateParam(): void { + dropParam('vorlage'); +} diff --git a/web/src/editor/editor-page.test.ts b/web/src/editor/editor-page.test.ts index 5c1e2ab..bee4ea7 100644 --- a/web/src/editor/editor-page.test.ts +++ b/web/src/editor/editor-page.test.ts @@ -15,6 +15,8 @@ const mocks = vi.hoisted(() => ({ decodeImage: vi.fn(), loadCardAssets: vi.fn(), exportCard: vi.fn(), + getGalleryCard: vi.fn(), + loadGalleryImage: vi.fn(), logoutListeners: new Set<() => void>(), })); @@ -37,6 +39,10 @@ vi.mock('../card/render', () => ({ loadCardAssets: mocks.loadCardAssets, exportCard: mocks.exportCard, })); +vi.mock('../community/gallery', () => ({ + getGalleryCard: mocks.getGalleryCard, + loadGalleryImage: mocks.loadGalleryImage, +})); vi.mock('./upload', async (importOriginal) => ({ ...(await importOriginal()), decodeImage: mocks.decodeImage, @@ -975,3 +981,225 @@ describe('Speichern: Schnappschuss beim Klick', () => { }); }); + +describe('Vorlage übernehmen (/gestalten?vorlage=)', () => { + const openTemplate = (id: string) => history.replaceState(null, '', `/gestalten?vorlage=${id}`); + const galleryCard = (patch: Record = {}) => ({ + id: 'g1', + title: 'Mitternachtsblau', + imageFileId: 'gimg1', + previewFileId: 'gpre1', + ownerId: 'u9', + hidden: false, + ...patch, + }); + const dialogButton = (label: string) => + [...discardDialogEl()!.querySelectorAll('button')].find((b) => b.textContent === label) as HTMLButtonElement; + const available = (img: CardImage = image(1536, 969), blob = png()) => { + mocks.getGalleryCard.mockResolvedValue(galleryCard()); + mocks.loadGalleryImage.mockResolvedValue(blob); + mocks.decodeImage.mockResolvedValue(img); + return { img, blob }; + }; + + it('lädt das Galerie-Vollbild und setzt Bild, Vorlage „Nur Bild“ und einen leeren Speicherstand', async () => { + openTemplate('g1'); + const { img, blob } = available(); + mount(); + expect(noticeText()).toBe('Vorlage wird geladen …'); + await vi.waitFor(() => expect(store.get().image).toBe(img)); + + expect(mocks.getGalleryCard).toHaveBeenCalledWith('g1'); + expect(mocks.loadGalleryImage).toHaveBeenCalledWith(expect.objectContaining({ id: 'g1' }), 'full'); + expect(mocks.decodeImage).toHaveBeenCalledWith(blob); + const s = store.get(); + expect(s.imageBlob).toBe(blob); + expect(s.imageChanged).toBe(true); + expect(s.config.template).toBe('image'); + expect(s.config.crop).toEqual({ x: 0, y: 0, zoom: 1 }); + expect(s.saved).toBeNull(); + expect(s.savedConfig).toBeNull(); + expect(s.cardName).toBe('Meine Karte'); + expect(noticeText()).toBe(''); + expect(errorText()).toBe(''); + // kein erneuter Import bei Zurück oder Neuladen + expect(location.pathname + location.search).toBe('/gestalten'); + }); + + it('eine zuvor geöffnete gespeicherte Karte wird entkoppelt: Speichern legt eine neue Karte an', async () => { + openTemplate('g1'); + const { blob } = available(); + const config = { ...store.get().config, template: 'classic' as const, network: 'jcb' as const }; + store.set({ + image: image(), + imageBlob: png(), + imageChanged: false, + config, + savedConfig: config, + saved: { id: 'k7', name: 'Sieben', imageFileId: 'i', previewFileId: 'p', updatedAt: 'x' }, + cardName: 'Sieben', + }); + mount(); + await vi.waitFor(() => expect(store.get().imageBlob).toBe(blob)); + // gespeicherte Karte ohne Änderungen: keine Rückfrage + expect(discardDialogEl()).toBeNull(); + const s = store.get(); + expect(s.saved).toBeNull(); + expect(s.savedConfig).toBeNull(); + expect(s.cardName).toBe('Meine Karte'); + expect(s.config.template).toBe('image'); + + mocks.save.mockResolvedValue(savedCard({ id: 'neu', name: 'Meine Karte' })); + saveButton().click(); + await vi.waitFor(() => expect(mocks.save).toHaveBeenCalled()); + expect(mocks.save.mock.calls[0][0]).toMatchObject({ previous: null, name: 'Meine Karte', image: blob, imageChanged: true }); + }); + + it('ungespeicherte Arbeit: Rückfrage; „Verwerfen“ übernimmt die Vorlage', async () => { + openTemplate('g1'); + const { img } = available(); + withImage(); + mount(); + await vi.waitFor(() => expect(discardDialogEl()?.open).toBe(true)); + expect(discardDialogEl()!.querySelector('h2')!.textContent).toBe('Ungespeicherte Änderungen verwerfen?'); + // Solange gefragt wird, bleibt der Entwurf. + expect(store.get().image).not.toBe(img); + dialogButton('Verwerfen').click(); + await vi.waitFor(() => expect(store.get().image).toBe(img)); + expect(store.get().config.template).toBe('image'); + expect(discardDialogEl()).toBeNull(); + expect(noticeText()).toBe(''); + expect(location.pathname + location.search).toBe('/gestalten'); + }); + + it('„Abbrechen“: Entwurf bleibt, ?vorlage= verschwindet aus der Adresse', async () => { + openTemplate('g1'); + available(); + const { blob, img } = withImage(); + const before = store.get(); + mount(); + await vi.waitFor(() => expect(discardDialogEl()?.open).toBe(true)); + dialogButton('Abbrechen').click(); + await vi.waitFor(() => expect(discardDialogEl()).toBeNull()); + await vi.waitFor(() => expect(noticeText()).toBe('')); + expect(store.get()).toBe(before); + expect(store.get().imageBlob).toBe(blob); + expect(store.get().image).toBe(img); + expect(location.pathname + location.search).toBe('/gestalten'); + }); + + it('verlässt der Nutzer die Seite, während gefragt wird, schließt der Dialog und nichts wird übernommen', async () => { + openTemplate('g1'); + available(); + withImage(); + const before = store.get(); + mount(); + await vi.waitFor(() => expect(discardDialogEl()?.open).toBe(true)); + unmount?.(); + unmount = null; + await vi.waitFor(() => expect(discardDialogEl()).toBeNull()); + await new Promise((r) => setTimeout(r, 0)); + expect(store.get()).toBe(before); + }); + + it('nach dem Import lädt ein erneutes Öffnen der Seite die Vorlage nicht noch einmal', async () => { + openTemplate('g1'); + const { img } = available(); + mount(); + await vi.waitFor(() => expect(store.get().image).toBe(img)); + unmount?.(); + unmount = null; + mount(); + await new Promise((r) => setTimeout(r, 0)); + expect(mocks.getGalleryCard).toHaveBeenCalledTimes(1); + }); + + it('Vorlage nicht (mehr) vorhanden: Hinweis, der Editor bleibt', async () => { + openTemplate('weg'); + mocks.getGalleryCard.mockResolvedValue(null); + const { blob } = withImage(); + const before = store.get(); + mount(); + await vi.waitFor(() => expect(errorText()).toBe('Diese Vorlage ist nicht (mehr) verfügbar.')); + expect(mocks.loadGalleryImage).not.toHaveBeenCalled(); + expect(store.get()).toBe(before); + expect(store.get().imageBlob).toBe(blob); + expect(noticeText()).toBe(''); + expect(discardDialogEl()).toBeNull(); + }); + + it('Bild lässt sich nicht laden oder nicht dekodieren: derselbe Hinweis, nichts wird übernommen', async () => { + const remount = () => { + unmount?.(); + unmount = null; + mount(); + }; + openTemplate('g1'); + mocks.getGalleryCard.mockResolvedValue(galleryCard()); + mocks.loadGalleryImage.mockRejectedValueOnce(Object.assign(new Error('weg'), { code: 404, type: 'storage_file_not_found' })); + mount(); + await vi.waitFor(() => expect(errorText()).toBe('Diese Vorlage ist nicht (mehr) verfügbar.')); + expect(store.get().image).toBeNull(); + expect(noticeText()).toBe(''); + + mocks.loadGalleryImage.mockResolvedValue(png()); + mocks.decodeImage.mockRejectedValue(new Error('Das Bild konnte nicht gelesen werden.')); + remount(); + await vi.waitFor(() => expect(mocks.decodeImage).toHaveBeenCalled()); + await vi.waitFor(() => expect(errorText()).toBe('Diese Vorlage ist nicht (mehr) verfügbar.')); + expect(store.get().image).toBeNull(); + + mocks.getGalleryCard.mockRejectedValue(new TypeError('offline')); + remount(); + await vi.waitFor(() => expect(errorText()).toBe('Diese Vorlage ist nicht (mehr) verfügbar.')); + expect(store.get().image).toBeNull(); + }); + + it('verlässt der Nutzer die Seite während des Ladens, wird nichts mehr übernommen', async () => { + openTemplate('g1'); + let finish!: (c: unknown) => void; + mocks.getGalleryCard.mockReturnValue(new Promise((r) => (finish = r))); + mount(); + unmount?.(); + unmount = null; + finish(galleryCard()); + await new Promise((r) => setTimeout(r, 0)); + expect(mocks.loadGalleryImage).not.toHaveBeenCalled(); + expect(store.get().image).toBeNull(); + }); + + it('wählt der Nutzer während des Ladens selbst ein Bild, gewinnt dieses', async () => { + openTemplate('g1'); + let finish!: (c: unknown) => void; + mocks.getGalleryCard.mockReturnValue(new Promise((r) => (finish = r))); + mocks.decodeImage.mockResolvedValue(image(500, 500)); + mount(); + const file = new File(['x'], 'neu.png', { type: 'image/png' }); + const drop = new Event('drop', { bubbles: true, cancelable: true }) as Event & { dataTransfer: unknown }; + drop.dataTransfer = { types: ['Files'], files: [file] }; + root.querySelector('.editor')!.dispatchEvent(drop); + await vi.waitFor(() => expect(store.get().image?.w).toBe(500)); + finish(galleryCard()); + await new Promise((r) => setTimeout(r, 0)); + expect(mocks.loadGalleryImage).not.toHaveBeenCalled(); + expect(store.get().image?.w).toBe(500); + expect(store.get().config.template).not.toBe('image'); + expect(noticeText()).toBe(''); + }); + + it('?karte= hat Vorrang: dann wird keine Vorlage geladen', async () => { + history.replaceState(null, '', '/gestalten?karte=k1&vorlage=g1'); + mocks.get.mockResolvedValue(savedCard()); + mocks.loadImage.mockResolvedValue(png()); + mocks.decodeImage.mockResolvedValue(image()); + mount(); + await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1')); + expect(mocks.getGalleryCard).not.toHaveBeenCalled(); + }); + + it('ohne ?vorlage wird nichts aus der Galerie geladen', async () => { + mount(); + await new Promise((r) => setTimeout(r, 0)); + expect(mocks.getGalleryCard).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/editor/editor-page.ts b/web/src/editor/editor-page.ts index 3301488..3ba84d8 100644 --- a/web/src/editor/editor-page.ts +++ b/web/src/editor/editor-page.ts @@ -9,7 +9,8 @@ import { serializeConfig, type BrandId, type CardConfig, type DetailsTone, type import { clampCrop, panBy, zoomTo } from '../card/crop'; import { CARD_W, PREVIEW_QUALITY, PREVIEW_WIDTH } from '../card/geometry'; import { drawCard, exportCard, loadCardAssets, type CardAssets, type CardImage } from '../card/render'; -import { dropCardParam } from './card-url'; +import { getGalleryCard, loadGalleryImage } from '../community/gallery'; +import { dropCardParam, dropTemplateParam } from './card-url'; import { confirmDialog } from './confirm-dialog'; import { downloadFileName, pngSizeWarning, saveBlobAs } from './download'; import { el } from './dom'; @@ -24,6 +25,8 @@ const LOGO_ERROR = 'Die Logos konnten nicht geladen werden.'; const EXPORT_ERROR = 'Das PNG konnte nicht erzeugt werden.'; const NO_IMAGE = 'Bitte zuerst ein Bild wählen.'; const LOADING_CARD = 'Karte wird geladen …'; +const LOADING_TEMPLATE = 'Vorlage wird geladen …'; +const TEMPLATE_UNAVAILABLE = 'Diese Vorlage ist nicht (mehr) verfügbar.'; const LOGIN_TO_OPEN = 'Melde dich an, um diese Karte zu öffnen.'; const SAVED_FLASH_MS = 2000; @@ -278,6 +281,52 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void { } } + // Community-Vorlage (/gestalten?vorlage=): das Galerie-Vollbild wird als „Nur Bild“ übernommen, als neue Karte. + // Teilt sich das Token mit openSavedCard: nur der zuletzt angeforderte Ladevorgang zählt. + async function openTemplate(galleryId: string): Promise { + const token = ++loadToken; + const stale = () => disposed || token !== loadToken; + preview.setError(null); + preview.setWarning(null); + preview.setNotice(LOADING_TEMPLATE); + try { + // Eine ausgeblendete oder zurückgezogene Karte liefert null (Appwrite antwortet Fremden mit 404). + const card = await getGalleryCard(galleryId); + if (stale()) return; + if (!card) throw new Error(TEMPLATE_UNAVAILABLE); + const blob = await loadGalleryImage(card, 'full'); + if (stale()) return; + const image = await decodeImage(blob); + if (stale()) return; + + // Die Vorlage würde einen ungespeicherten Entwurf ersetzen: erst fragen (wie beim Öffnen einer Karte). + if (hasUnsavedWork(store.get())) { + const discard = await askToDiscard(); + if (stale()) return; + if (!discard) { + dropTemplateParam(); + return; + } + } + + setImage(store, image, blob); + // Die Vorlage ist eine neue Karte: sonst überschriebe Speichern eine zuvor geöffnete gespeicherte Karte. + store.set({ + config: { ...store.get().config, template: 'image' }, + saved: null, + savedConfig: null, + cardName: initialEditorState().cardName, + }); + // Kein erneuter Import bei „Zurück“ oder Neuladen. + dropTemplateParam(); + } catch { + if (stale()) return; + preview.setError(TEMPLATE_UNAVAILABLE); + } finally { + if (!stale()) preview.setNotice(null); + } + } + // Rückfrage vor dem Verwerfen von Änderungen („Neue Karte“, Öffnen einer anderen Karte). let askingToDiscard = false; async function askToDiscard(): Promise { @@ -396,8 +445,14 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void { }); // Die Adresse wird nur beim Öffnen der Seite gelesen; hält der Editor die Karte schon, bleibt sein Stand. - const cardId = new URLSearchParams(location.search).get('karte'); - if (cardId && store.get().saved?.id !== cardId) void openSavedCard(cardId); + const params = new URLSearchParams(location.search); + const cardId = params.get('karte'); + const templateId = params.get('vorlage'); + if (cardId) { + if (store.get().saved?.id !== cardId) void openSavedCard(cardId); + } else if (templateId) { + void openTemplate(templateId); + } return () => { disposed = true; diff --git a/web/src/pages/community-page.test.ts b/web/src/pages/community-page.test.ts new file mode 100644 index 0000000..76aa381 --- /dev/null +++ b/web/src/pages/community-page.test.ts @@ -0,0 +1,708 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { User } from '../account/auth'; +import { ApiError, type ReportReason } from '../community/api'; +import type { GalleryCard, GalleryReport } from '../community/gallery'; + +// gallery.ts, api.ts und auth.ts hängen am Appwrite-Client; die Seite bekommt alles über ihre Abhängigkeiten. +vi.mock('../account/appwrite', () => ({ + account: {}, + client: {}, + tables: {}, + storage: {}, + IDS: {}, + APPWRITE_ENDPOINT: '', +})); + +import { LoginCancelled } from '../account/with-login'; +import { mountCommunityPage, type CommunityDeps } from './community-page'; + +const card = (patch: Partial = {}): GalleryCard => ({ + id: 'g1', + title: 'Mitternachtsblau', + issuer: 'Beispielbank', + tags: ['dunkel', 'gold'], + displayName: 'Kenso', + imageFileId: 'img1', + previewFileId: 'pre1', + sourceCardId: 'src1', + ownerId: 'u1', + hidden: false, + reportCount: 0, + createdAt: '2026-10-04T12:00:00.000Z', + ...patch, +}); + +const report = (n: number, reason: string): GalleryReport => ({ + id: `r${n}`, + galleryId: 'g1', + reason, + createdAt: '2026-10-05T12:00:00.000Z', +}); + +const settle = async () => { + for (let i = 0; i < 40; i++) await Promise.resolve(); +}; +const blob = () => new Blob(['png'], { type: 'image/png' }); + +const admin: User = { $id: 'a1', email: 'kenso@example.com', labels: ['admin'] }; +const owner: User = { $id: 'u1', email: 'u@example.com', labels: [] }; +const stranger: User = { $id: 'u2', email: 'x@example.com', labels: [] }; + +const UNAVAILABLE = 'Diese Karte ist nicht (mehr) verfügbar.'; + +let root: HTMLElement; +let unmount: (() => void) | null; +let user: User | null; +let authListeners: Set<(u: User | null) => void>; +let urlCounter: number; +const revoked: string[] = []; +const getCard = vi.fn<(id: string) => Promise>(); +const listReports = vi.fn<(id: string) => Promise>(); +const loadImage = vi.fn<(c: GalleryCard, size: 'full' | 'preview') => Promise>(); +const reportCard = vi.fn<(id: string, reason: ReportReason) => Promise<{ schonGemeldet: boolean }>>(); +const withdrawCard = vi.fn<(id: string) => Promise>(); +const setVisibility = vi.fn<(id: string, visible: boolean) => Promise>(); +const navigate = vi.fn<(path: string) => void>(); +const saveBlobAs = vi.fn<(b: Blob, name: string) => void>(); +const withLogin = vi.fn((fn: () => Promise): Promise => fn()); +const getUser = vi.fn<() => Promise>(); + +function deps(): CommunityDeps { + return { + getCard, + listReports, + loadImage, + reportCard, + withdrawCard, + setVisibility, + currentUser: () => user, + getUser, + onAuthChange: (fn) => { + authListeners.add(fn); + return () => authListeners.delete(fn); + }, + navigate, + saveBlobAs, + withLogin: (fn: () => Promise) => withLogin(fn) as Promise, + }; +} + +async function mount(id = 'g1'): Promise { + unmount = mountCommunityPage(root, id, deps()); + await settle(); +} + +function login(next: User | null): void { + user = next; + for (const fn of [...authListeners]) fn(next); +} + +const q = (sel: string) => root.querySelector(sel); +const qa = (sel: string) => [...root.querySelectorAll(sel)]; +const buttonByText = (text: string) => + qa('button').find((b) => b.textContent === text) as HTMLButtonElement | undefined; +const buttonTexts = () => qa('button').map((b) => b.textContent); +const confirmEl = () => document.querySelector('dialog.confirm-dialog'); +const confirmButton = (label: string) => + [...confirmEl()!.querySelectorAll('button')].find((b) => b.textContent === label) as HTMLButtonElement; +const reportEl = () => document.querySelector('dialog.report-dialog'); +const errorText = () => q('[role="alert"]')?.textContent ?? ''; +const statusText = () => q('.detail-info [role="status"]')?.textContent ?? ''; + +beforeEach(() => { + for (const fn of [getCard, listReports, loadImage, reportCard, withdrawCard, setVisibility, navigate, saveBlobAs, withLogin, getUser]) { + fn.mockReset(); + } + getCard.mockResolvedValue(card()); + listReports.mockResolvedValue([]); + loadImage.mockImplementation(async () => blob()); + withdrawCard.mockResolvedValue(undefined); + setVisibility.mockResolvedValue(undefined); + getUser.mockResolvedValue(null); + user = null; + authListeners = new Set(); + urlCounter = 0; + revoked.length = 0; + URL.createObjectURL = vi.fn(() => `blob:detail-${++urlCounter}`); + URL.revokeObjectURL = vi.fn((url: string) => { + revoked.push(url); + }); + document.body.innerHTML = '
'; + root = document.getElementById('app-root') as HTMLElement; + unmount = null; + document.title = 'AirCard-IOS'; +}); + +afterEach(() => { + unmount?.(); + document.body.innerHTML = ''; +}); + +describe('Detailseite: nicht (mehr) verfügbar', () => { + it('Gast, Karte nicht vorhanden oder ausgeblendet (404): Text, Link zur Galerie, kein Bild, keine Knöpfe', async () => { + getCard.mockResolvedValue(null); + await mount('g1'); + expect(getCard).toHaveBeenCalledWith('g1'); + expect(q('h1')?.textContent).toBe(UNAVAILABLE); + const link = q('a[href="/#community"]'); + expect(link?.textContent).toBe('Zur Galerie'); + expect(link?.hasAttribute('data-link')).toBe(true); + expect(q('img')).toBeNull(); + expect(buttonTexts()).toEqual([]); + expect(loadImage).not.toHaveBeenCalled(); + expect(listReports).not.toHaveBeenCalled(); + expect(document.title).toBe('Community – AirCard-IOS'); + }); + + it('eine ausgeblendete Karte für jemanden, der weder Autor noch Admin ist (Rechte-Fehler): ebenso, ohne Bild', async () => { + user = stranger; + getCard.mockResolvedValue(card({ hidden: true, reportCount: 3 })); + await mount(); + expect(q('h1')?.textContent).toBe(UNAVAILABLE); + expect(q('img')).toBeNull(); + expect(loadImage).not.toHaveBeenCalled(); + expect(root.textContent).not.toContain('Mitternachtsblau'); + }); + + it('Direktaufruf des Autors: der Anmeldestatus ist noch unbekannt, der Server hat die ausgeblendete Karte aber geliefert', async () => { + getCard.mockResolvedValue(card({ hidden: true })); + getUser.mockImplementation(async () => { + user = owner; + return owner; + }); + await mount(); + expect(getUser).toHaveBeenCalledTimes(1); + expect(q('h1')?.textContent).toBe('Mitternachtsblau'); + expect(q('.detail-hidden')?.textContent).toContain('Ausgeblendet'); + expect(buttonTexts()).toContain('Zurückziehen'); + }); + + it('eine sichtbare Karte braucht keine Anmeldeabfrage', async () => { + await mount(); + expect(getUser).not.toHaveBeenCalled(); + }); + + it('Ladefehler (Netz): Hinweis und „Nochmal versuchen“, danach erscheint die Karte', async () => { + getCard.mockRejectedValueOnce(new TypeError('offline')); + await mount(); + expect(errorText()).toBe('Die Karte konnte gerade nicht geladen werden.'); + expect(q('img')).toBeNull(); + buttonByText('Nochmal versuchen')!.click(); + await settle(); + expect(getCard).toHaveBeenCalledTimes(2); + expect(q('h1')?.textContent).toBe('Mitternachtsblau'); + expect(errorText()).toBe(''); + }); + + it('während des Ladens steht ein Hinweis', async () => { + getCard.mockReturnValue(new Promise(() => {})); + await mount(); + expect(q('[role="status"]')?.textContent).toBe('Karte wird geladen …'); + }); + + it('meldet sich der Autor einer ausgeblendeten Karte an, wird sie neu geladen und gezeigt', async () => { + getCard.mockResolvedValueOnce(null); + await mount(); + expect(q('h1')?.textContent).toBe(UNAVAILABLE); + getCard.mockResolvedValueOnce(card({ hidden: true })); + login(owner); + await settle(); + expect(getCard).toHaveBeenCalledTimes(2); + expect(q('h1')?.textContent).toBe('Mitternachtsblau'); + expect(q('.detail-hidden')?.textContent).toContain('Ausgeblendet'); + }); +}); + +describe('Detailseite: Karte', () => { + it('zeigt Titel, Herausgeber, „von …“, Datum, Tags und das große Bild; setzt den Seitentitel', async () => { + await mount(); + expect(q('h1')?.textContent).toBe('Mitternachtsblau'); + expect(q('.detail-issuer')?.textContent).toBe('Beispielbank'); + expect(q('.detail-meta')?.textContent).toBe('von Kenso · 04.10.2026'); + const tags = qa('.detail-tag'); + expect(tags.map((t) => t.textContent)).toEqual(['dunkel', 'gold']); + expect(tags[1].getAttribute('href')).toBe('/?tag=gold#community'); + expect(tags[1].hasAttribute('data-link')).toBe(true); + expect(loadImage).toHaveBeenCalledWith(expect.objectContaining({ id: 'g1' }), 'full'); + const img = q('.detail-card img'); + expect(img?.getAttribute('src')).toBe('blob:detail-1'); + expect(img?.getAttribute('alt')).toBe('Community-Karte „Mitternachtsblau“'); + expect(document.title).toBe('Mitternachtsblau – AirCard-IOS'); + expect(q('a[href="/#community"]')?.textContent).toContain('Zur Galerie'); + }); + + it('ohne Anzeigenamen „von Anonym“; ohne Herausgeber und Tags fehlen die Zeilen', async () => { + getCard.mockResolvedValue(card({ displayName: '', issuer: '', tags: [] })); + await mount(); + expect(q('.detail-meta')?.textContent).toBe('von Anonym · 04.10.2026'); + expect(q('.detail-issuer')).toBeNull(); + expect(q('.detail-tags')).toBeNull(); + }); + + it('alles Nutzerseitige bleibt Text (kein HTML)', async () => { + getCard.mockResolvedValue( + card({ + title: 'Fett', + issuer: 'Bank', + displayName: '', + tags: [''], + }), + ); + await mount(); + expect(q('h1')?.textContent).toBe('Fett'); + expect(q('.detail-issuer')?.textContent).toBe('Bank'); + expect(q('.detail-meta')?.textContent).toContain(''); + expect(q('.detail-tag')?.textContent).toBe(''); + expect(root.querySelector('b, i, script')).toBeNull(); + expect(qa('img')).toHaveLength(1); // nur das Kartenbild + }); + + it('der Gast sieht Herunterladen, Als Vorlage gestalten, Melden – sonst nichts', async () => { + await mount(); + expect(buttonTexts()).toEqual(['Herunterladen', 'Als Vorlage gestalten', 'Melden']); + expect(listReports).not.toHaveBeenCalled(); + expect(q('.detail-admin')).toBeNull(); + }); + + it('Bild lädt nicht: Hinweis mit „Nochmal versuchen“; Herunterladen holt das Bild selbst nach', async () => { + loadImage.mockRejectedValueOnce(new TypeError('offline')); + await mount(); + expect(q('.detail-card')?.textContent).toContain('Das Bild konnte nicht geladen werden.'); + expect(q('.detail-card img')).toBeNull(); + // Herunterladen versucht es erneut und gelingt + buttonByText('Herunterladen')!.click(); + await settle(); + expect(loadImage).toHaveBeenCalledTimes(2); + expect(saveBlobAs).toHaveBeenCalledTimes(1); + }); + + it('„Nochmal versuchen“ beim Bild lädt es erneut', async () => { + loadImage.mockRejectedValueOnce(new TypeError('offline')); + await mount(); + buttonByText('Nochmal versuchen')!.click(); + await settle(); + expect(loadImage).toHaveBeenCalledTimes(2); + expect(q('.detail-card img')?.getAttribute('src')).toBe('blob:detail-1'); + }); + + it('ein Bild, das der Browser nicht darstellen kann, zeigt denselben Hinweis und gibt die URL frei', async () => { + await mount(); + q('.detail-card img')!.dispatchEvent(new Event('error')); + expect(q('.detail-card')?.textContent).toContain('Das Bild konnte nicht geladen werden.'); + expect(revoked).toEqual(['blob:detail-1']); + }); + + it('Object-URL wird beim Verlassen freigegeben; späte Antworten nach dem Verlassen bewirken nichts', async () => { + await mount(); + unmount?.(); + unmount = null; + expect(revoked).toEqual(['blob:detail-1']); + + // Karte kommt erst nach dem Verlassen + let finishCard!: (c: GalleryCard) => void; + getCard.mockReturnValue(new Promise((r) => (finishCard = r))); + root.replaceChildren(); + unmount = mountCommunityPage(root, 'g1', deps()); + unmount(); + unmount = null; + finishCard(card()); + await settle(); + expect(loadImage).toHaveBeenCalledTimes(1); // nur vom ersten Aufruf + expect(q('h1')).toBeNull(); // die Karte wird nicht mehr aufgebaut + + // Bild kommt erst nach dem Verlassen + let finishImage!: (b: Blob) => void; + getCard.mockResolvedValue(card()); + loadImage.mockReturnValue(new Promise((r) => (finishImage = r))); + unmount = mountCommunityPage(root, 'g1', deps()); + await settle(); + unmount(); + unmount = null; + const created = urlCounter; + finishImage(blob()); + await settle(); + expect(urlCounter).toBe(created); + }); +}); + +describe('Detailseite: Aufräumen', () => { + it('beim Verlassen wird der Auth-Abonnent abgemeldet, danach ändert sich nichts mehr', async () => { + await mount(); + expect(authListeners.size).toBe(1); + unmount?.(); + unmount = null; + expect(authListeners.size).toBe(0); + }); + + it('Meldungen, die erst nach dem Verlassen eintreffen, werden verworfen', async () => { + user = admin; + let finish!: (r: GalleryReport[]) => void; + listReports.mockReturnValue(new Promise((r) => (finish = r))); + await mount(); + unmount?.(); + unmount = null; + const html = root.innerHTML; + finish([report(1, 'spam')]); + await settle(); + expect(root.innerHTML).toBe(html); + }); +}); + +describe('Detailseite: Herunterladen und Vorlage', () => { + it('Herunterladen speichert das volle PNG als aircard-.png', async () => { + const full = blob(); + loadImage.mockResolvedValue(full); + getCard.mockResolvedValue(card({ title: 'Grüner Wald!' })); + await mount(); + buttonByText('Herunterladen')!.click(); + await settle(); + expect(saveBlobAs).toHaveBeenCalledTimes(1); + expect(saveBlobAs).toHaveBeenCalledWith(full, 'aircard-gruener-wald.png'); + // das schon geladene Bild wird wiederverwendet + expect(loadImage).toHaveBeenCalledTimes(1); + }); + + it('Herunterladen scheitert: Meldung, der Knopf geht wieder', async () => { + await mount(); + q('.detail-card img')!.dispatchEvent(new Event('error')); // kein Bild im Speicher + loadImage.mockRejectedValueOnce(new TypeError('offline')); + buttonByText('Herunterladen')!.click(); + await settle(); + expect(saveBlobAs).not.toHaveBeenCalled(); + expect(errorText()).toBe('Das Bild konnte nicht geladen werden.'); + expect(buttonByText('Herunterladen')!.disabled).toBe(false); + }); + + it('„Als Vorlage gestalten“ führt zu /gestalten?vorlage=', async () => { + await mount(); + buttonByText('Als Vorlage gestalten')!.click(); + expect(navigate).toHaveBeenCalledWith('/gestalten?vorlage=g1'); + }); +}); + +describe('Detailseite: Melden', () => { + const open = async () => { + buttonByText('Melden')!.click(); + await settle(); + }; + const submitReason = async (value: string) => { + const radio = reportEl()!.querySelector(`input[value="${value}"]`)!; + radio.checked = true; + reportEl()!.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + await settle(); + }; + + it('Dialog mit den fünf Gründen; die Wahl geht als API-Wert an reportCard', async () => { + reportCard.mockResolvedValue({ schonGemeldet: false }); + await mount(); + await open(); + expect([...reportEl()!.querySelectorAll('.report-reason')].map((l) => l.textContent)).toEqual([ + 'Unangemessen', + 'Echte Kartendaten', + 'Urheberrecht/Marke', + 'Spam', + 'Sonstiges', + ]); + await submitReason('urheberrecht'); + expect(reportCard).toHaveBeenCalledWith('g1', 'urheberrecht'); + expect(reportEl()!.textContent).toContain('Danke für deine Meldung.'); + }); + + it('doppelt gemeldet: „Danke, du hast diese Karte schon gemeldet.“', async () => { + reportCard.mockResolvedValue({ schonGemeldet: true }); + await mount(); + await open(); + await submitReason('spam'); + expect(reportEl()!.textContent).toContain('Danke, du hast diese Karte schon gemeldet.'); + }); + + it('zu viele Meldungen (429): der Servertext erscheint im Dialog', async () => { + reportCard.mockRejectedValue(new ApiError(429, 'Zu viele Meldungen – bitte später nochmal.')); + await mount(); + await open(); + await submitReason('spam'); + expect(reportEl()!.querySelector('[role="alert"]')!.textContent).toBe('Zu viele Meldungen – bitte später nochmal.'); + expect(reportEl()?.open).toBe(true); + }); + + it('Verlassen der Seite schließt den offenen Dialog', async () => { + await mount(); + await open(); + expect(reportEl()).not.toBeNull(); + unmount?.(); + unmount = null; + await settle(); + expect(reportEl()).toBeNull(); + }); + + it('eine ausgeblendete Karte (nur Autor/Admin sehen sie) hat keinen Melden-Knopf', async () => { + user = owner; + getCard.mockResolvedValue(card({ hidden: true })); + await mount(); + expect(buttonTexts()).not.toContain('Melden'); + }); +}); + +describe('Detailseite: Autor', () => { + it('der Autor sieht Zurückziehen, ein Fremder nicht', async () => { + user = owner; + await mount(); + expect(buttonTexts()).toEqual(['Herunterladen', 'Als Vorlage gestalten', 'Melden', 'Zurückziehen']); + unmount?.(); + unmount = null; + + user = stranger; + await mount(); + expect(buttonTexts()).not.toContain('Zurückziehen'); + expect(buttonTexts()).not.toContain('Löschen'); + expect(q('.detail-admin')).toBeNull(); + }); + + it('die Knöpfe erscheinen auch, wenn der Nutzer erst nach dem Laden bekannt wird, und verschwinden beim Abmelden', async () => { + await mount(); + expect(buttonTexts()).not.toContain('Zurückziehen'); + login(owner); + expect(buttonTexts()).toContain('Zurückziehen'); + login(null); + expect(buttonTexts()).not.toContain('Zurückziehen'); + }); + + it('Zurückziehen fragt nach; „Abbrechen“ lässt alles, „Zurückziehen“ ruft die API und geht zur Startseite', async () => { + user = owner; + await mount(); + buttonByText('Zurückziehen')!.click(); + await settle(); + expect(confirmEl()?.open).toBe(true); + expect(confirmEl()!.querySelector('h2')!.textContent).toBe('Karte „Mitternachtsblau“ zurückziehen?'); + confirmButton('Abbrechen').click(); + await settle(); + expect(confirmEl()).toBeNull(); + expect(withdrawCard).not.toHaveBeenCalled(); + expect(navigate).not.toHaveBeenCalled(); + + buttonByText('Zurückziehen')!.click(); + await settle(); + confirmButton('Zurückziehen').click(); + await settle(); + expect(withdrawCard).toHaveBeenCalledWith('g1'); + expect(withLogin).toHaveBeenCalledTimes(1); + expect(navigate).toHaveBeenCalledWith('/'); + }); + + it('Zurückziehen scheitert: Servertext, nicht navigiert, Knöpfe wieder bedienbar', async () => { + user = owner; + withdrawCard.mockRejectedValue(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.')); + await mount(); + buttonByText('Zurückziehen')!.click(); + await settle(); + confirmButton('Zurückziehen').click(); + await settle(); + expect(errorText()).toBe('Der Dienst ist gerade nicht erreichbar.'); + expect(navigate).not.toHaveBeenCalled(); + expect(qa('.detail-actions button').every((b) => !b.disabled)).toBe(true); + }); + + it('war die Karte schon weg (404), ist das Ziel erreicht: zur Startseite', async () => { + user = owner; + withdrawCard.mockRejectedValue(new ApiError(404, 'Diese Karte gibt es nicht (mehr).')); + await mount(); + buttonByText('Zurückziehen')!.click(); + await settle(); + confirmButton('Zurückziehen').click(); + await settle(); + expect(navigate).toHaveBeenCalledWith('/'); + expect(errorText()).toBe(''); + }); + + it('Anmeldung abgebrochen: Hinweis, nicht navigiert', async () => { + user = owner; + withLogin.mockRejectedValue(new LoginCancelled()); + await mount(); + buttonByText('Zurückziehen')!.click(); + await settle(); + confirmButton('Zurückziehen').click(); + await settle(); + expect(errorText()).toBe('Bitte melde dich an, um fortzufahren.'); + expect(navigate).not.toHaveBeenCalled(); + }); + + it('während die Anfrage läuft, ist alles gesperrt und es geht nur eine raus', async () => { + user = owner; + let finish!: () => void; + withdrawCard.mockReturnValue(new Promise((r) => (finish = r))); + await mount(); + buttonByText('Zurückziehen')!.click(); + await settle(); + confirmButton('Zurückziehen').click(); + await settle(); + expect(qa('.detail-actions button').every((b) => b.disabled)).toBe(true); + buttonByText('Zurückziehen')!.click(); + await settle(); + expect(confirmEl()).toBeNull(); + expect(withdrawCard).toHaveBeenCalledTimes(1); + finish(); + await settle(); + expect(navigate).toHaveBeenCalledWith('/'); + }); + + it('verlässt der Nutzer die Seite, während gefragt wird, schließt der Dialog und nichts passiert', async () => { + user = owner; + await mount(); + buttonByText('Zurückziehen')!.click(); + await settle(); + expect(confirmEl()).not.toBeNull(); + unmount?.(); + unmount = null; + await settle(); + expect(confirmEl()).toBeNull(); + expect(withdrawCard).not.toHaveBeenCalled(); + }); + + it('der Autor einer ausgeblendeten Karte sieht sie mit dem Hinweis „Ausgeblendet“', async () => { + user = owner; + getCard.mockResolvedValue(card({ hidden: true, reportCount: 3 })); + await mount(); + expect(q('h1')?.textContent).toBe('Mitternachtsblau'); + expect(q('.detail-hidden')?.textContent).toContain('Ausgeblendet'); + expect(q('.detail-hidden')?.textContent).toContain('für andere nicht sichtbar'); + expect(q('.detail-card img')).not.toBeNull(); + // Meldungszahl und Gründe sind Sache der Admins + expect(root.textContent).not.toContain('Meldungen'); + expect(listReports).not.toHaveBeenCalled(); + }); +}); + +describe('Detailseite: Admin', () => { + it('Admin sieht Ausblenden, Löschen, die Meldungszahl und die Gründe', async () => { + user = admin; + getCard.mockResolvedValue(card({ reportCount: 3 })); + listReports.mockResolvedValue([report(1, 'spam'), report(2, 'kartendaten'), report(3, 'unbekannt')]); + await mount(); + expect(listReports).toHaveBeenCalledWith('g1'); + expect(buttonTexts()).toEqual(['Herunterladen', 'Als Vorlage gestalten', 'Melden', 'Ausblenden', 'Löschen']); + expect(q('.detail-reports-count')?.textContent).toBe('3 Meldungen'); + expect(qa('.detail-reasons li').map((li) => li.textContent)).toEqual([ + 'Spam · 05.10.2026', + 'Echte Kartendaten · 05.10.2026', + 'unbekannt · 05.10.2026', + ]); + expect(buttonTexts()).not.toContain('Zurückziehen'); + }); + + it('eine Meldung heißt „1 Meldung“, keine „0 Meldungen“ ohne Liste', async () => { + user = admin; + getCard.mockResolvedValue(card({ reportCount: 1 })); + listReports.mockResolvedValue([report(1, 'spam')]); + await mount(); + expect(q('.detail-reports-count')?.textContent).toBe('1 Meldung'); + unmount?.(); + unmount = null; + + getCard.mockResolvedValue(card({ reportCount: 0 })); + listReports.mockResolvedValue([]); + await mount(); + expect(q('.detail-reports-count')?.textContent).toBe('0 Meldungen'); + expect(q('.detail-reasons')).toBeNull(); + }); + + it('Gründe lassen sich nicht laden: Hinweis, Zahl und Knöpfe bleiben', async () => { + user = admin; + getCard.mockResolvedValue(card({ reportCount: 2 })); + listReports.mockRejectedValue(new TypeError('offline')); + await mount(); + expect(q('.detail-reports-count')?.textContent).toBe('2 Meldungen'); + expect(q('.detail-admin')?.textContent).toContain('Die Gründe konnten nicht geladen werden.'); + expect(buttonTexts()).toContain('Ausblenden'); + }); + + it('Ausblenden ruft die API, zeigt „Ausgeblendet“ und wird zu „Einblenden“', async () => { + user = admin; + getCard.mockResolvedValue(card({ reportCount: 1 })); + listReports.mockResolvedValue([report(1, 'spam')]); + await mount(); + buttonByText('Ausblenden')!.click(); + await settle(); + expect(setVisibility).toHaveBeenCalledWith('g1', false); + expect(withLogin).toHaveBeenCalledTimes(1); + expect(buttonTexts()).toContain('Einblenden'); + expect(buttonTexts()).not.toContain('Ausblenden'); + expect(buttonTexts()).not.toContain('Melden'); + expect(q('.detail-hidden')?.textContent).toContain('Ausgeblendet'); + expect(statusText()).toBe('Karte ausgeblendet.'); + // Die Meldungen bleiben beim Ausblenden + expect(q('.detail-reports-count')?.textContent).toBe('1 Meldung'); + }); + + it('Einblenden macht die Karte wieder sichtbar; der Server löscht die Meldungen, die Anzeige folgt', async () => { + user = admin; + getCard.mockResolvedValue(card({ hidden: true, reportCount: 3 })); + listReports.mockResolvedValue([report(1, 'spam'), report(2, 'spam'), report(3, 'sonstiges')]); + await mount(); + expect(q('.detail-hidden')).not.toBeNull(); + buttonByText('Einblenden')!.click(); + await settle(); + expect(setVisibility).toHaveBeenCalledWith('g1', true); + expect(buttonTexts()).toContain('Ausblenden'); + expect(buttonTexts()).toContain('Melden'); + expect(q('.detail-hidden')).toBeNull(); + expect(q('.detail-reports-count')?.textContent).toBe('0 Meldungen'); + expect(q('.detail-reasons')).toBeNull(); + expect(statusText()).toBe('Karte eingeblendet.'); + }); + + it('Ausblenden scheitert: Servertext, Zustand unverändert', async () => { + user = admin; + setVisibility.mockRejectedValue(new ApiError(403, 'Nur für Administratoren.')); + await mount(); + buttonByText('Ausblenden')!.click(); + await settle(); + expect(errorText()).toBe('Nur für Administratoren.'); + expect(buttonTexts()).toContain('Ausblenden'); + expect(q('.detail-hidden')).toBeNull(); + expect(buttonByText('Ausblenden')!.disabled).toBe(false); + }); + + it('Löschen fragt nach und löscht über withdrawCard, danach zur Startseite', async () => { + user = admin; + await mount(); + buttonByText('Löschen')!.click(); + await settle(); + expect(confirmEl()!.querySelector('h2')!.textContent).toBe('Karte „Mitternachtsblau“ löschen?'); + confirmButton('Abbrechen').click(); + await settle(); + expect(withdrawCard).not.toHaveBeenCalled(); + + buttonByText('Löschen')!.click(); + await settle(); + confirmButton('Löschen').click(); + await settle(); + expect(withdrawCard).toHaveBeenCalledWith('g1'); + expect(navigate).toHaveBeenCalledWith('/'); + }); + + it('wird jemand erst später als Admin erkannt, erscheinen die Admin-Knöpfe und die Gründe; beim Verlust verschwinden sie', async () => { + getCard.mockResolvedValue(card({ reportCount: 1 })); + listReports.mockResolvedValue([report(1, 'spam')]); + await mount(); + expect(q('.detail-admin')).toBeNull(); + expect(listReports).not.toHaveBeenCalled(); + login(admin); + await settle(); + expect(listReports).toHaveBeenCalledTimes(1); + expect(buttonTexts()).toContain('Ausblenden'); + expect(q('.detail-reasons li')?.textContent).toBe('Spam · 05.10.2026'); + login(stranger); + expect(q('.detail-admin')).toBeNull(); + expect(buttonTexts()).not.toContain('Löschen'); + }); + + it('verliert ein Admin sein Recht, während er eine ausgeblendete Karte ansieht, wird sie unzugänglich', async () => { + user = admin; + getCard.mockResolvedValue(card({ hidden: true })); + await mount(); + expect(q('h1')?.textContent).toBe('Mitternachtsblau'); + login(stranger); + expect(q('h1')?.textContent).toBe(UNAVAILABLE); + expect(q('img')).toBeNull(); + }); +}); diff --git a/web/src/pages/community-page.ts b/web/src/pages/community-page.ts index b242baf..dd2da6a 100644 --- a/web/src/pages/community-page.ts +++ b/web/src/pages/community-page.ts @@ -1,10 +1,503 @@ -// Detailseite „/community/“. Platzhalter – Task 6 füllt sie. +// Detailseite „/community/“: große Karte, Angaben, Herunterladen, „Als Vorlage gestalten“, Melden. +// Autor: Zurückziehen. Admin (Label `admin`): Ausblenden/Einblenden, Löschen, Meldungszahl und Gründe. +// Ausgeblendete oder fehlende Karten sieht ein Besucher nur als „nicht (mehr) verfügbar“: Appwrite antwortet ihm mit 404, +// Autor und Admin dürfen die Zeile lesen und bekommen die Seite mit dem Hinweis „Ausgeblendet“. +import '../community/community.css'; +import { currentUser, getCurrentUser, isAdmin, onAuthChange, type User } from '../account/auth'; +import { errorMessage } from '../account/errors'; +import { LoginCancelled, withLogin } from '../account/with-login'; +import { ApiError, reportCard, setVisibility, withdrawCard, type ReportReason } from '../community/api'; +import { + getGalleryCard, + listReports, + loadGalleryImage, + type GalleryCard, + type GalleryReport, +} from '../community/gallery'; +import { REPORT_REASONS, reportDialog } from '../community/report-dialog'; +import { confirmDialog, type ConfirmOptions } from '../editor/confirm-dialog'; import { el } from '../editor/dom'; +import { downloadFileName, saveBlobAs } from '../editor/download'; +import { navigate, titleFor } from './router'; -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 () => {}; +/** Alles, was die Seite von außen braucht (für Tests austauschbar). */ +export interface CommunityDeps { + getCard(id: string): Promise; + listReports(galleryId: string): Promise; + loadImage(card: GalleryCard, size: 'full' | 'preview'): Promise; + reportCard(id: string, reason: ReportReason): Promise<{ schonGemeldet: boolean }>; + withdrawCard(id: string): Promise; + setVisibility(id: string, visible: boolean): Promise; + currentUser(): User | null; + /** Fragt den Anmeldestatus beim Server (bei einem Direktaufruf ist er noch nicht bekannt). */ + getUser(): Promise; + onAuthChange(fn: (user: User | null) => void): () => void; + navigate(path: string): void; + saveBlobAs(blob: Blob, filename: string): void; + /** Führt eine Anfrage mit Nutzer-JWT aus und meldet bei abgelaufener Sitzung einmal neu an. */ + withLogin(fn: () => Promise): Promise; +} + +export const defaultDeps: CommunityDeps = { + getCard: getGalleryCard, + listReports, + loadImage: loadGalleryImage, + reportCard, + withdrawCard, + setVisibility, + currentUser, + getUser: getCurrentUser, + onAuthChange, + navigate, + saveBlobAs, + withLogin: (fn) => withLogin(fn), +}; + +const CARD_W = 1536; +const CARD_H = 969; + +const UNAVAILABLE = 'Diese Karte ist nicht (mehr) verfügbar.'; +const LOADING = 'Karte wird geladen …'; +const LOAD_ERROR = 'Die Karte konnte gerade nicht geladen werden.'; +const IMAGE_LOADING = 'Bild wird geladen …'; +const IMAGE_ERROR = 'Das Bild konnte nicht geladen werden.'; +const REASONS_ERROR = 'Die Gründe konnten nicht geladen werden.'; +const LOGIN_NEEDED = 'Bitte melde dich an, um fortzufahren.'; +const HIDDEN_NOTICE = 'Diese Karte ist für andere nicht sichtbar.'; + +const REASON_LABELS: ReadonlyMap = new Map(REPORT_REASONS.map((r) => [r.value, r.label])); + +type Busy = 'download' | 'remove' | 'visibility' | null; +interface ReportsState { + status: 'idle' | 'loading' | 'ready' | 'error'; + list: GalleryReport[]; +} + +/** `dd.mm.yyyy` in der Zeitzone des Besuchers; leer bei einem unlesbaren Datum. */ +function formatDate(iso: string): string { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return ''; + const pad = (n: number) => String(n).padStart(2, '0'); + return `${pad(date.getDate())}.${pad(date.getMonth() + 1)}.${date.getFullYear()}`; +} + +/** „1 Meldung“, „3 Meldungen“. */ +function reportsLabel(count: number): string { + return count === 1 ? '1 Meldung' : `${count} Meldungen`; +} + +/** Der Text für den Nutzer: Servertexte (deutsch) unverändert, alles andere über errorMessage. */ +function failureText(err: unknown): string { + if (err instanceof LoginCancelled) return LOGIN_NEEDED; + if (err instanceof ApiError) return err.message; + return errorMessage(err, 'load'); +} + +const isOwner = (c: GalleryCard, user: User | null): boolean => !!user && c.ownerId !== '' && c.ownerId === user.$id; + +/** Ausgeblendete Karten dürfen nur Autor und Admin sehen (der Server liefert sie anderen gar nicht erst). */ +const mayView = (c: GalleryCard, user: User | null): boolean => !c.hidden || isOwner(c, user) || isAdmin(user); + +export function mountCommunityPage(root: HTMLElement, id: string, deps: CommunityDeps = defaultDeps): () => void { + let disposed = false; + const disposal = new AbortController(); // schließt offene Dialoge beim Verlassen der Seite + + let loadToken = 0; // nur die zuletzt angeforderte Karte zählt + let imageToken = 0; // … und nur das zuletzt angeforderte Bild + let generation = 0; // zählt jede neue Ansicht: Antworten für eine frühere Ansicht werden verworfen + let view: 'loading' | 'unavailable' | 'error' | 'card' = 'loading'; + let card: GalleryCard | null = null; + let reports: ReportsState = { status: 'idle', list: [] }; + let busy: Busy = null; + let imageBlob: Blob | null = null; + let imageUrl: string | null = null; + + const page = el('div', { class: 'detail-page' }); + root.replaceChildren(page); + const route = { id: 'community', params: { id } } as const; + + // ---- Bild ---- + + const imageSlot = el('div', { class: 'detail-card' }); + + function revokeImage(): void { + if (imageUrl) URL.revokeObjectURL(imageUrl); + imageUrl = null; + imageBlob = null; + } + + function showImageError(forCard: GalleryCard): void { + const retry = el('button', { type: 'button', class: 'home-btn' }, 'Nochmal versuchen'); + retry.addEventListener('click', () => startImage(forCard)); + imageSlot.setAttribute('aria-busy', 'false'); + imageSlot.replaceChildren(el('div', { class: 'detail-card-empty' }, el('p', {}, IMAGE_ERROR), retry)); + } + + function startImage(forCard: GalleryCard): void { + const token = ++imageToken; + revokeImage(); + imageSlot.setAttribute('aria-busy', 'true'); + imageSlot.replaceChildren(el('div', { class: 'detail-card-empty' }, el('p', {}, IMAGE_LOADING))); + deps.loadImage(forCard, 'full').then( + (blob) => { + if (disposed || token !== imageToken) return; // zu spät: keine URL anlegen, die niemand mehr freigibt + imageBlob = blob; + imageUrl = URL.createObjectURL(blob); + const img = el('img', { + src: imageUrl, + alt: `Community-Karte „${forCard.title}“`, + width: CARD_W, + height: CARD_H, + }); + // Die Datei kam an, lässt sich aber nicht darstellen. + img.addEventListener('error', () => { + if (disposed || token !== imageToken) return; + revokeImage(); + showImageError(forCard); + }); + imageSlot.setAttribute('aria-busy', 'false'); + imageSlot.replaceChildren(img); + }, + () => { + if (disposed || token !== imageToken) return; + showImageError(forCard); + }, + ); + } + + // ---- Ansichten ohne Karte ---- + + /** Verlässt die Kartenansicht: Bild und Meldungen gehören nicht mehr dazu. */ + function leaveCard(): void { + generation += 1; + imageToken += 1; + revokeImage(); + card = null; + busy = null; + reports = { status: 'idle', list: [] }; + } + + function showUnavailable(): void { + leaveCard(); + view = 'unavailable'; + document.title = titleFor(route); + page.replaceChildren( + el( + 'div', + { class: 'detail-unavailable' }, + el('h1', { class: 'detail-title' }, UNAVAILABLE), + el('a', { class: 'home-btn home-btn-primary', href: '/#community', 'data-link': true }, 'Zur Galerie'), + ), + ); + } + + function showLoadError(): void { + leaveCard(); + view = 'error'; + const retry = el('button', { type: 'button', class: 'home-btn home-btn-primary' }, 'Nochmal versuchen'); + retry.addEventListener('click', () => void load()); + page.replaceChildren( + el('div', { class: 'detail-unavailable' }, el('p', { class: 'detail-load-error', role: 'alert' }, LOAD_ERROR), retry), + ); + } + + // ---- Karte ---- + + const infoSlot = el('div', { class: 'detail-info' }); + const noticeSlot = el('div', { class: 'detail-notice-slot' }); + const actionsSlot = el('div', { class: 'detail-actions' }); + const adminSlot = el('div', { class: 'detail-admin-slot' }); + // Beide Meldungszeilen bleiben im DOM (leer ausgeblendet), damit Screenreader Änderungen melden. + const statusEl = el('p', { class: 'detail-msg', role: 'status' }); + const errorEl = el('p', { class: 'detail-msg is-error', role: 'alert' }); + + function showMessage(text: string, kind: 'info' | 'error'): void { + statusEl.textContent = kind === 'info' ? text : ''; + errorEl.textContent = kind === 'error' ? text : ''; + } + + function button(label: string, action: string, onClick: () => void, className = 'home-btn'): HTMLButtonElement { + const b = el('button', { type: 'button', class: className, 'data-action': action, disabled: busy !== null }, label); + b.addEventListener('click', onClick); + return b; + } + + /** Baut Hinweis, Knöpfe und Admin-Bereich aus dem Zustand neu auf; der Fokus bleibt auf dem gleichen Knopf. */ + function renderControls(): void { + if (!card) return; + const c = card; + const user = deps.currentUser(); + const focused = document.activeElement instanceof HTMLElement ? document.activeElement : null; + const focusedAction = focused && infoSlot.contains(focused) ? focused.closest('[data-action]')?.getAttribute('data-action') : null; + + noticeSlot.replaceChildren( + ...(c.hidden ? [el('p', { class: 'detail-hidden' }, el('strong', {}, 'Ausgeblendet'), ` – ${HIDDEN_NOTICE}`)] : []), + ); + + const buttons = [ + button(busy === 'download' ? 'Wird geladen …' : 'Herunterladen', 'download', () => void download(), 'home-btn home-btn-primary'), + button('Als Vorlage gestalten', 'template', () => deps.navigate(`/gestalten?vorlage=${encodeURIComponent(c.id)}`)), + ]; + // Eine ausgeblendete Karte lässt sich nicht melden (der Dienst kennt sie für Besucher nicht). + if (!c.hidden) buttons.push(button('Melden', 'report', () => void reportThis())); + if (isOwner(c, user)) { + buttons.push(button('Zurückziehen', 'withdraw', () => void removeCard('withdraw'), 'home-btn detail-btn-danger')); + } + actionsSlot.replaceChildren(...buttons); + + adminSlot.replaceChildren(...(isAdmin(user) ? [adminSection(c)] : [])); + + if (focusedAction) infoSlot.querySelector(`[data-action="${focusedAction}"]`)?.focus(); + } + + function adminSection(c: GalleryCard): HTMLElement { + const section = el( + 'section', + { class: 'detail-admin', 'aria-labelledby': 'detail-admin-title' }, + el('h2', { id: 'detail-admin-title', class: 'detail-admin-title' }, 'Moderation'), + el('p', { class: 'detail-reports-count' }, reportsLabel(c.reportCount)), + ); + if (reports.status === 'error') { + section.append(el('p', { class: 'detail-admin-note' }, REASONS_ERROR)); + } else if (reports.list.length > 0) { + section.append( + el( + 'ul', + { class: 'detail-reasons' }, + ...reports.list.map((r) => { + const date = formatDate(r.createdAt); + return el('li', {}, `${REASON_LABELS.get(r.reason) ?? r.reason}${date ? ` · ${date}` : ''}`); + }), + ), + ); + } + section.append( + el( + 'div', + { class: 'detail-admin-actions' }, + button(c.hidden ? 'Einblenden' : 'Ausblenden', 'visibility', () => void toggleVisibility()), + button('Löschen', 'delete', () => void removeCard('delete'), 'home-btn detail-btn-danger'), + ), + ); + return section; + } + + /** Lädt die Meldungen, sobald ein Admin die Karte sieht (einmal je Ansicht). */ + async function ensureReports(): Promise { + if (!card || reports.status !== 'idle' || !isAdmin(deps.currentUser())) return; + const mine = generation; + const galleryId = card.id; + reports = { status: 'loading', list: [] }; + try { + const list = await deps.listReports(galleryId); + if (disposed || mine !== generation) return; + reports = { status: 'ready', list }; + } catch { + if (disposed || mine !== generation) return; + reports = { status: 'error', list: [] }; + } + renderControls(); + } + + function showCard(c: GalleryCard): void { + leaveCard(); + view = 'card'; + card = c; + showMessage('', 'info'); + document.title = titleFor(route, c.title); + + const date = formatDate(c.createdAt); + const author = `von ${c.displayName !== '' ? c.displayName : 'Anonym'}`; + infoSlot.replaceChildren( + el('h1', { class: 'detail-title' }, c.title), + ...(c.issuer !== '' ? [el('p', { class: 'detail-issuer' }, c.issuer)] : []), + el('p', { class: 'detail-meta' }, date ? `${author} · ${date}` : author), + ...(c.tags.length > 0 + ? [ + el( + 'div', + { class: 'detail-tags' }, + ...c.tags.map((tag) => + el( + 'a', + { + class: 'detail-tag', + href: `/?tag=${encodeURIComponent(tag)}#community`, + 'data-link': true, + title: 'Karten mit diesem Tag zeigen', + }, + tag, + ), + ), + ), + ] + : []), + noticeSlot, + actionsSlot, + statusEl, + errorEl, + adminSlot, + ); + page.replaceChildren( + el('p', { class: 'detail-back' }, el('a', { href: '/#community', 'data-link': true }, '← Zur Galerie')), + el('div', { class: 'detail-layout' }, imageSlot, infoSlot), + ); + renderControls(); + startImage(c); + void ensureReports(); + } + + // ---- Aktionen ---- + + function setBusy(next: Busy): void { + busy = next; + renderControls(); + } + + async function download(): Promise { + const c = card; + if (!c || busy) return; + const mine = generation; + showMessage('', 'info'); + setBusy('download'); + try { + // Normalerweise liegt das Bild schon vor (es ist die große Karte); sonst wird es jetzt geholt. + const blob = imageBlob ?? (await deps.loadImage(c, 'full')); + if (disposed || mine !== generation) return; + const created = new Date(c.createdAt); + deps.saveBlobAs(blob, downloadFileName(c.title, Number.isNaN(created.getTime()) ? new Date() : created)); + } catch { + if (disposed || mine !== generation) return; + showMessage(IMAGE_ERROR, 'error'); + } + setBusy(null); + } + + async function reportThis(): Promise { + const c = card; + if (!c || busy) return; + await reportDialog({ + cardTitle: c.title, + signal: disposal.signal, + send: (reason) => deps.reportCard(c.id, reason), + }); + } + + async function ask(options: ConfirmOptions): Promise { + try { + return await confirmDialog(options, disposal.signal); + } catch { + return false; // Dialog nicht darstellbar: dann lieber nichts löschen + } + } + + /** Zurückziehen (Autor) und Löschen (Admin) sind für den Dienst dasselbe: Zeile, Dateien und Meldungen verschwinden. */ + async function removeCard(kind: 'withdraw' | 'delete'): Promise { + const c = card; + if (!c || busy) return; + const mine = generation; + const confirmed = await ask( + kind === 'withdraw' + ? { + title: `Karte „${c.title}“ zurückziehen?`, + text: 'Die Karte verschwindet aus der Galerie, ihr Bild und die Meldungen werden gelöscht. Deine private Karte bleibt erhalten.', + confirmLabel: 'Zurückziehen', + } + : { + title: `Karte „${c.title}“ löschen?`, + text: 'Die Karte, ihr Bild und alle Meldungen werden endgültig gelöscht.', + confirmLabel: 'Löschen', + }, + ); + if (!confirmed || disposed || mine !== generation || busy) return; + showMessage('', 'info'); + setBusy('remove'); + try { + await deps.withLogin(() => deps.withdrawCard(c.id)); + } catch (err) { + // Die Karte gibt es schon nicht mehr (anderer Tab, früherer Versuch mit verlorener Antwort): das Ziel ist erreicht. + if (!(err instanceof ApiError && err.code === 404)) { + if (disposed || mine !== generation) return; + showMessage(failureText(err), 'error'); + setBusy(null); + return; + } + } + if (!disposed) deps.navigate('/'); + } + + async function toggleVisibility(): Promise { + const c = card; + if (!c || busy) return; + const mine = generation; + const show = c.hidden; + showMessage('', 'info'); + setBusy('visibility'); + try { + await deps.withLogin(() => deps.setVisibility(c.id, show)); + if (disposed || mine !== generation) return; + // Einblenden setzt beim Dienst die Meldungen zurück (Zähler 0, Meldungen gelöscht); Ausblenden lässt sie stehen. + card = { ...c, hidden: !show, reportCount: show ? 0 : c.reportCount }; + if (show) reports = { status: 'ready', list: [] }; + showMessage(show ? 'Karte eingeblendet.' : 'Karte ausgeblendet.', 'info'); + } catch (err) { + if (disposed || mine !== generation) return; + showMessage(failureText(err), 'error'); + } + setBusy(null); + } + + // ---- Laden ---- + + /** `quiet`: die bisherige Ansicht bleibt stehen, bis die Antwort da ist (Wiederholen nach einer Anmeldung). */ + async function load(quiet = false): Promise { + const token = ++loadToken; + if (!quiet) { + leaveCard(); + view = 'loading'; + page.replaceChildren(el('p', { class: 'detail-status', role: 'status' }, LOADING)); + } + try { + const found = await deps.getCard(id); + if (disposed || token !== loadToken) return; + let user = deps.currentUser(); + if (found && !mayView(found, user)) { + // Der Server liefert eine ausgeblendete Karte nur Autor und Admin. Bei einem Direktaufruf ist die Anmeldung aber + // vielleicht noch nicht geklärt: dann erst den Anmeldestatus holen, statt dem Autor „nicht verfügbar“ zu zeigen. + user = await deps.getUser().catch(() => null); + if (disposed || token !== loadToken) return; + } + if (found && mayView(found, user)) showCard(found); + else showUnavailable(); + } catch { + if (disposed || token !== loadToken) return; + if (!quiet || view !== 'card') showLoadError(); + } + } + + const stopAuth = deps.onAuthChange((user) => { + if (disposed) return; + // Wer sich jetzt anmeldet, darf die Karte vielleicht sehen, die ihm vorher fehlte. + if (view === 'unavailable' || view === 'error') { + void load(true); + return; + } + if (view !== 'card' || !card) return; + if (!mayView(card, user)) { + showUnavailable(); + return; + } + if (!isAdmin(user)) reports = { status: 'idle', list: [] }; + renderControls(); + void ensureReports(); + }); + + void load(); + + return () => { + disposed = true; + disposal.abort(); + stopAuth(); + leaveCard(); + }; }