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>
504 lines
19 KiB
TypeScript
504 lines
19 KiB
TypeScript
// 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';
|
||
|
||
/** 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();
|
||
};
|
||
}
|