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

+314
View File
@@ -475,9 +475,319 @@
background: #b42318;
}
/* Detailseite /community/<id> */
.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;
+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();
});
});
+156
View File
@@ -0,0 +1,156 @@
// Melden-Dialog der Detailseite: Grund wählen, an den API-Dienst senden, das Ergebnis im Dialog zeigen.
// Eigenes <dialog> 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<ReportOutcome> {
return new Promise<ReportOutcome>((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<void> {
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 <dialog> 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);
}
});
}