feat(community): Detailseite, Melden, Admin-Aktionen, Vorlage

Detailseite /community/<id>: große Karte, Titel, Herausgeber, „von …“, Datum,
Tags, Herunterladen (aircard-<slug>.png), „Als Vorlage gestalten“ und Melden-Dialog
(fünf Gründe, Dank-, Doppelt- und Drosselungs-Text). Autor: Zurückziehen; Admin:
Ausblenden/Einblenden, Löschen, Meldungszahl und Gründe. Fehlende oder
ausgeblendete Karten zeigen Besuchern „nicht (mehr) verfügbar“, Autor und Admin
sehen sie mit dem Hinweis „Ausgeblendet“.

Editor: /gestalten?vorlage=<id> übernimmt das Galerie-Vollbild als „Nur Bild“,
setzt saved, savedConfig und cardName zurück, ersetzt die Adresse durch /gestalten
und fragt vorher bei ungespeicherter Arbeit.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 15:28:00 +00:00
1 parent c4a66f674f
commit 3cec7151a6
8 files changed
+2221 -15

No files matched your search

+241
View File
@@ -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<Send>();
const dialogEl = () => document.querySelector<HTMLDialogElement>('dialog.report-dialog');
const buttonByText = (text: string) =>
[...(dialogEl()?.querySelectorAll('button') ?? [])].find((b) => b.textContent === text) as HTMLButtonElement | undefined;
const radios = () => [...(dialogEl()?.querySelectorAll<HTMLInputElement>('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<ReportOutcome> {
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: '<img src=x onerror=alert(1)>' });
expect(dialogEl()!.querySelector('img')).toBeNull();
expect(dialogEl()!.textContent).toContain('<img src=x onerror=alert(1)>');
});
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();
});
});