Files
AirCardIOS/web/src/pages/community-page.ts
T
ClaudeandClaude Sonnet 5.5 3cec7151a6 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>
2026-10-04 15:28:00 +00:00

504 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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();
};
}