// 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'; /** 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(); }; }