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

+500 -7
View File
@@ -1,10 +1,503 @@
// Detailseite „/community/<id>“. Platzhalter – Task 6 füllt sie.
// Detailseite „/community/<id>“: 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<GalleryCard | null>;
listReports(galleryId: string): Promise<GalleryReport[]>;
loadImage(card: GalleryCard, size: 'full' | 'preview'): Promise<Blob>;
reportCard(id: string, reason: ReportReason): Promise<{ schonGemeldet: boolean }>;
withdrawCard(id: string): Promise<void>;
setVisibility(id: string, visible: boolean): Promise<void>;
currentUser(): User | null;
/** Fragt den Anmeldestatus beim Server (bei einem Direktaufruf ist er noch nicht bekannt). */
getUser(): Promise<User | null>;
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<T>(fn: () => Promise<T>): Promise<T>;
}
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<string, string> = 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<HTMLElement>(`[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<void> {
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<void> {
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<void> {
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<boolean> {
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<void> {
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<void> {
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<void> {
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();
};
}