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:
1 parent
c4a66f674f
commit
3cec7151a6
8 files changed
+2221
-15
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,9 +1,20 @@
|
||||
// Die Adresse des Editors kann eine Karte nennen (`/gestalten?karte=<id>`; alte Links `/?karte=<id>` leitet main.ts um).
|
||||
// Die Adresse des Editors kann eine Karte (`/gestalten?karte=<id>`; alte Links `/?karte=<id>` leitet main.ts um)
|
||||
// oder eine Community-Vorlage (`/gestalten?vorlage=<galleryId>`) nennen.
|
||||
|
||||
/** Entfernt `karte` aus der Adresse (ersetzt den Verlaufseintrag), damit ein Neuladen nicht dieselbe Karte öffnet. */
|
||||
export function dropCardParam(): void {
|
||||
/** Entfernt den Parameter aus der Adresse (ersetzt den Verlaufseintrag), damit ein Neuladen oder „Zurück“ nicht erneut lädt. */
|
||||
function dropParam(name: string): void {
|
||||
const url = new URL(location.href);
|
||||
if (!url.searchParams.has('karte')) return;
|
||||
url.searchParams.delete('karte');
|
||||
if (!url.searchParams.has(name)) return;
|
||||
url.searchParams.delete(name);
|
||||
history.replaceState(null, '', url.pathname + url.search + url.hash);
|
||||
}
|
||||
|
||||
/** Entfernt `karte` aus der Adresse. */
|
||||
export function dropCardParam(): void {
|
||||
dropParam('karte');
|
||||
}
|
||||
|
||||
/** Entfernt `vorlage` aus der Adresse. */
|
||||
export function dropTemplateParam(): void {
|
||||
dropParam('vorlage');
|
||||
}
|
||||
@@ -15,6 +15,8 @@ const mocks = vi.hoisted(() => ({
|
||||
decodeImage: vi.fn(),
|
||||
loadCardAssets: vi.fn(),
|
||||
exportCard: vi.fn(),
|
||||
getGalleryCard: vi.fn(),
|
||||
loadGalleryImage: vi.fn(),
|
||||
logoutListeners: new Set<() => void>(),
|
||||
}));
|
||||
|
||||
@@ -37,6 +39,10 @@ vi.mock('../card/render', () => ({
|
||||
loadCardAssets: mocks.loadCardAssets,
|
||||
exportCard: mocks.exportCard,
|
||||
}));
|
||||
vi.mock('../community/gallery', () => ({
|
||||
getGalleryCard: mocks.getGalleryCard,
|
||||
loadGalleryImage: mocks.loadGalleryImage,
|
||||
}));
|
||||
vi.mock('./upload', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('./upload')>()),
|
||||
decodeImage: mocks.decodeImage,
|
||||
@@ -975,3 +981,225 @@ describe('Speichern: Schnappschuss beim Klick', () => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('Vorlage übernehmen (/gestalten?vorlage=<id>)', () => {
|
||||
const openTemplate = (id: string) => history.replaceState(null, '', `/gestalten?vorlage=${id}`);
|
||||
const galleryCard = (patch: Record<string, unknown> = {}) => ({
|
||||
id: 'g1',
|
||||
title: 'Mitternachtsblau',
|
||||
imageFileId: 'gimg1',
|
||||
previewFileId: 'gpre1',
|
||||
ownerId: 'u9',
|
||||
hidden: false,
|
||||
...patch,
|
||||
});
|
||||
const dialogButton = (label: string) =>
|
||||
[...discardDialogEl()!.querySelectorAll('button')].find((b) => b.textContent === label) as HTMLButtonElement;
|
||||
const available = (img: CardImage = image(1536, 969), blob = png()) => {
|
||||
mocks.getGalleryCard.mockResolvedValue(galleryCard());
|
||||
mocks.loadGalleryImage.mockResolvedValue(blob);
|
||||
mocks.decodeImage.mockResolvedValue(img);
|
||||
return { img, blob };
|
||||
};
|
||||
|
||||
it('lädt das Galerie-Vollbild und setzt Bild, Vorlage „Nur Bild“ und einen leeren Speicherstand', async () => {
|
||||
openTemplate('g1');
|
||||
const { img, blob } = available();
|
||||
mount();
|
||||
expect(noticeText()).toBe('Vorlage wird geladen …');
|
||||
await vi.waitFor(() => expect(store.get().image).toBe(img));
|
||||
|
||||
expect(mocks.getGalleryCard).toHaveBeenCalledWith('g1');
|
||||
expect(mocks.loadGalleryImage).toHaveBeenCalledWith(expect.objectContaining({ id: 'g1' }), 'full');
|
||||
expect(mocks.decodeImage).toHaveBeenCalledWith(blob);
|
||||
const s = store.get();
|
||||
expect(s.imageBlob).toBe(blob);
|
||||
expect(s.imageChanged).toBe(true);
|
||||
expect(s.config.template).toBe('image');
|
||||
expect(s.config.crop).toEqual({ x: 0, y: 0, zoom: 1 });
|
||||
expect(s.saved).toBeNull();
|
||||
expect(s.savedConfig).toBeNull();
|
||||
expect(s.cardName).toBe('Meine Karte');
|
||||
expect(noticeText()).toBe('');
|
||||
expect(errorText()).toBe('');
|
||||
// kein erneuter Import bei Zurück oder Neuladen
|
||||
expect(location.pathname + location.search).toBe('/gestalten');
|
||||
});
|
||||
|
||||
it('eine zuvor geöffnete gespeicherte Karte wird entkoppelt: Speichern legt eine neue Karte an', async () => {
|
||||
openTemplate('g1');
|
||||
const { blob } = available();
|
||||
const config = { ...store.get().config, template: 'classic' as const, network: 'jcb' as const };
|
||||
store.set({
|
||||
image: image(),
|
||||
imageBlob: png(),
|
||||
imageChanged: false,
|
||||
config,
|
||||
savedConfig: config,
|
||||
saved: { id: 'k7', name: 'Sieben', imageFileId: 'i', previewFileId: 'p', updatedAt: 'x' },
|
||||
cardName: 'Sieben',
|
||||
});
|
||||
mount();
|
||||
await vi.waitFor(() => expect(store.get().imageBlob).toBe(blob));
|
||||
// gespeicherte Karte ohne Änderungen: keine Rückfrage
|
||||
expect(discardDialogEl()).toBeNull();
|
||||
const s = store.get();
|
||||
expect(s.saved).toBeNull();
|
||||
expect(s.savedConfig).toBeNull();
|
||||
expect(s.cardName).toBe('Meine Karte');
|
||||
expect(s.config.template).toBe('image');
|
||||
|
||||
mocks.save.mockResolvedValue(savedCard({ id: 'neu', name: 'Meine Karte' }));
|
||||
saveButton().click();
|
||||
await vi.waitFor(() => expect(mocks.save).toHaveBeenCalled());
|
||||
expect(mocks.save.mock.calls[0][0]).toMatchObject({ previous: null, name: 'Meine Karte', image: blob, imageChanged: true });
|
||||
});
|
||||
|
||||
it('ungespeicherte Arbeit: Rückfrage; „Verwerfen“ übernimmt die Vorlage', async () => {
|
||||
openTemplate('g1');
|
||||
const { img } = available();
|
||||
withImage();
|
||||
mount();
|
||||
await vi.waitFor(() => expect(discardDialogEl()?.open).toBe(true));
|
||||
expect(discardDialogEl()!.querySelector('h2')!.textContent).toBe('Ungespeicherte Änderungen verwerfen?');
|
||||
// Solange gefragt wird, bleibt der Entwurf.
|
||||
expect(store.get().image).not.toBe(img);
|
||||
dialogButton('Verwerfen').click();
|
||||
await vi.waitFor(() => expect(store.get().image).toBe(img));
|
||||
expect(store.get().config.template).toBe('image');
|
||||
expect(discardDialogEl()).toBeNull();
|
||||
expect(noticeText()).toBe('');
|
||||
expect(location.pathname + location.search).toBe('/gestalten');
|
||||
});
|
||||
|
||||
it('„Abbrechen“: Entwurf bleibt, ?vorlage= verschwindet aus der Adresse', async () => {
|
||||
openTemplate('g1');
|
||||
available();
|
||||
const { blob, img } = withImage();
|
||||
const before = store.get();
|
||||
mount();
|
||||
await vi.waitFor(() => expect(discardDialogEl()?.open).toBe(true));
|
||||
dialogButton('Abbrechen').click();
|
||||
await vi.waitFor(() => expect(discardDialogEl()).toBeNull());
|
||||
await vi.waitFor(() => expect(noticeText()).toBe(''));
|
||||
expect(store.get()).toBe(before);
|
||||
expect(store.get().imageBlob).toBe(blob);
|
||||
expect(store.get().image).toBe(img);
|
||||
expect(location.pathname + location.search).toBe('/gestalten');
|
||||
});
|
||||
|
||||
it('verlässt der Nutzer die Seite, während gefragt wird, schließt der Dialog und nichts wird übernommen', async () => {
|
||||
openTemplate('g1');
|
||||
available();
|
||||
withImage();
|
||||
const before = store.get();
|
||||
mount();
|
||||
await vi.waitFor(() => expect(discardDialogEl()?.open).toBe(true));
|
||||
unmount?.();
|
||||
unmount = null;
|
||||
await vi.waitFor(() => expect(discardDialogEl()).toBeNull());
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(store.get()).toBe(before);
|
||||
});
|
||||
|
||||
it('nach dem Import lädt ein erneutes Öffnen der Seite die Vorlage nicht noch einmal', async () => {
|
||||
openTemplate('g1');
|
||||
const { img } = available();
|
||||
mount();
|
||||
await vi.waitFor(() => expect(store.get().image).toBe(img));
|
||||
unmount?.();
|
||||
unmount = null;
|
||||
mount();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(mocks.getGalleryCard).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Vorlage nicht (mehr) vorhanden: Hinweis, der Editor bleibt', async () => {
|
||||
openTemplate('weg');
|
||||
mocks.getGalleryCard.mockResolvedValue(null);
|
||||
const { blob } = withImage();
|
||||
const before = store.get();
|
||||
mount();
|
||||
await vi.waitFor(() => expect(errorText()).toBe('Diese Vorlage ist nicht (mehr) verfügbar.'));
|
||||
expect(mocks.loadGalleryImage).not.toHaveBeenCalled();
|
||||
expect(store.get()).toBe(before);
|
||||
expect(store.get().imageBlob).toBe(blob);
|
||||
expect(noticeText()).toBe('');
|
||||
expect(discardDialogEl()).toBeNull();
|
||||
});
|
||||
|
||||
it('Bild lässt sich nicht laden oder nicht dekodieren: derselbe Hinweis, nichts wird übernommen', async () => {
|
||||
const remount = () => {
|
||||
unmount?.();
|
||||
unmount = null;
|
||||
mount();
|
||||
};
|
||||
openTemplate('g1');
|
||||
mocks.getGalleryCard.mockResolvedValue(galleryCard());
|
||||
mocks.loadGalleryImage.mockRejectedValueOnce(Object.assign(new Error('weg'), { code: 404, type: 'storage_file_not_found' }));
|
||||
mount();
|
||||
await vi.waitFor(() => expect(errorText()).toBe('Diese Vorlage ist nicht (mehr) verfügbar.'));
|
||||
expect(store.get().image).toBeNull();
|
||||
expect(noticeText()).toBe('');
|
||||
|
||||
mocks.loadGalleryImage.mockResolvedValue(png());
|
||||
mocks.decodeImage.mockRejectedValue(new Error('Das Bild konnte nicht gelesen werden.'));
|
||||
remount();
|
||||
await vi.waitFor(() => expect(mocks.decodeImage).toHaveBeenCalled());
|
||||
await vi.waitFor(() => expect(errorText()).toBe('Diese Vorlage ist nicht (mehr) verfügbar.'));
|
||||
expect(store.get().image).toBeNull();
|
||||
|
||||
mocks.getGalleryCard.mockRejectedValue(new TypeError('offline'));
|
||||
remount();
|
||||
await vi.waitFor(() => expect(errorText()).toBe('Diese Vorlage ist nicht (mehr) verfügbar.'));
|
||||
expect(store.get().image).toBeNull();
|
||||
});
|
||||
|
||||
it('verlässt der Nutzer die Seite während des Ladens, wird nichts mehr übernommen', async () => {
|
||||
openTemplate('g1');
|
||||
let finish!: (c: unknown) => void;
|
||||
mocks.getGalleryCard.mockReturnValue(new Promise((r) => (finish = r)));
|
||||
mount();
|
||||
unmount?.();
|
||||
unmount = null;
|
||||
finish(galleryCard());
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(mocks.loadGalleryImage).not.toHaveBeenCalled();
|
||||
expect(store.get().image).toBeNull();
|
||||
});
|
||||
|
||||
it('wählt der Nutzer während des Ladens selbst ein Bild, gewinnt dieses', async () => {
|
||||
openTemplate('g1');
|
||||
let finish!: (c: unknown) => void;
|
||||
mocks.getGalleryCard.mockReturnValue(new Promise((r) => (finish = r)));
|
||||
mocks.decodeImage.mockResolvedValue(image(500, 500));
|
||||
mount();
|
||||
const file = new File(['x'], 'neu.png', { type: 'image/png' });
|
||||
const drop = new Event('drop', { bubbles: true, cancelable: true }) as Event & { dataTransfer: unknown };
|
||||
drop.dataTransfer = { types: ['Files'], files: [file] };
|
||||
root.querySelector('.editor')!.dispatchEvent(drop);
|
||||
await vi.waitFor(() => expect(store.get().image?.w).toBe(500));
|
||||
finish(galleryCard());
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(mocks.loadGalleryImage).not.toHaveBeenCalled();
|
||||
expect(store.get().image?.w).toBe(500);
|
||||
expect(store.get().config.template).not.toBe('image');
|
||||
expect(noticeText()).toBe('');
|
||||
});
|
||||
|
||||
it('?karte= hat Vorrang: dann wird keine Vorlage geladen', async () => {
|
||||
history.replaceState(null, '', '/gestalten?karte=k1&vorlage=g1');
|
||||
mocks.get.mockResolvedValue(savedCard());
|
||||
mocks.loadImage.mockResolvedValue(png());
|
||||
mocks.decodeImage.mockResolvedValue(image());
|
||||
mount();
|
||||
await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1'));
|
||||
expect(mocks.getGalleryCard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ohne ?vorlage wird nichts aus der Galerie geladen', async () => {
|
||||
mount();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(mocks.getGalleryCard).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,8 @@ import { serializeConfig, type BrandId, type CardConfig, type DetailsTone, type
|
||||
import { clampCrop, panBy, zoomTo } from '../card/crop';
|
||||
import { CARD_W, PREVIEW_QUALITY, PREVIEW_WIDTH } from '../card/geometry';
|
||||
import { drawCard, exportCard, loadCardAssets, type CardAssets, type CardImage } from '../card/render';
|
||||
import { dropCardParam } from './card-url';
|
||||
import { getGalleryCard, loadGalleryImage } from '../community/gallery';
|
||||
import { dropCardParam, dropTemplateParam } from './card-url';
|
||||
import { confirmDialog } from './confirm-dialog';
|
||||
import { downloadFileName, pngSizeWarning, saveBlobAs } from './download';
|
||||
import { el } from './dom';
|
||||
@@ -24,6 +25,8 @@ const LOGO_ERROR = 'Die Logos konnten nicht geladen werden.';
|
||||
const EXPORT_ERROR = 'Das PNG konnte nicht erzeugt werden.';
|
||||
const NO_IMAGE = 'Bitte zuerst ein Bild wählen.';
|
||||
const LOADING_CARD = 'Karte wird geladen …';
|
||||
const LOADING_TEMPLATE = 'Vorlage wird geladen …';
|
||||
const TEMPLATE_UNAVAILABLE = 'Diese Vorlage ist nicht (mehr) verfügbar.';
|
||||
const LOGIN_TO_OPEN = 'Melde dich an, um diese Karte zu öffnen.';
|
||||
const SAVED_FLASH_MS = 2000;
|
||||
|
||||
@@ -278,6 +281,52 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
|
||||
}
|
||||
}
|
||||
|
||||
// Community-Vorlage (/gestalten?vorlage=<galleryId>): das Galerie-Vollbild wird als „Nur Bild“ übernommen, als neue Karte.
|
||||
// Teilt sich das Token mit openSavedCard: nur der zuletzt angeforderte Ladevorgang zählt.
|
||||
async function openTemplate(galleryId: string): Promise<void> {
|
||||
const token = ++loadToken;
|
||||
const stale = () => disposed || token !== loadToken;
|
||||
preview.setError(null);
|
||||
preview.setWarning(null);
|
||||
preview.setNotice(LOADING_TEMPLATE);
|
||||
try {
|
||||
// Eine ausgeblendete oder zurückgezogene Karte liefert null (Appwrite antwortet Fremden mit 404).
|
||||
const card = await getGalleryCard(galleryId);
|
||||
if (stale()) return;
|
||||
if (!card) throw new Error(TEMPLATE_UNAVAILABLE);
|
||||
const blob = await loadGalleryImage(card, 'full');
|
||||
if (stale()) return;
|
||||
const image = await decodeImage(blob);
|
||||
if (stale()) return;
|
||||
|
||||
// Die Vorlage würde einen ungespeicherten Entwurf ersetzen: erst fragen (wie beim Öffnen einer Karte).
|
||||
if (hasUnsavedWork(store.get())) {
|
||||
const discard = await askToDiscard();
|
||||
if (stale()) return;
|
||||
if (!discard) {
|
||||
dropTemplateParam();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setImage(store, image, blob);
|
||||
// Die Vorlage ist eine neue Karte: sonst überschriebe Speichern eine zuvor geöffnete gespeicherte Karte.
|
||||
store.set({
|
||||
config: { ...store.get().config, template: 'image' },
|
||||
saved: null,
|
||||
savedConfig: null,
|
||||
cardName: initialEditorState().cardName,
|
||||
});
|
||||
// Kein erneuter Import bei „Zurück“ oder Neuladen.
|
||||
dropTemplateParam();
|
||||
} catch {
|
||||
if (stale()) return;
|
||||
preview.setError(TEMPLATE_UNAVAILABLE);
|
||||
} finally {
|
||||
if (!stale()) preview.setNotice(null);
|
||||
}
|
||||
}
|
||||
|
||||
// Rückfrage vor dem Verwerfen von Änderungen („Neue Karte“, Öffnen einer anderen Karte).
|
||||
let askingToDiscard = false;
|
||||
async function askToDiscard(): Promise<boolean> {
|
||||
@@ -396,8 +445,14 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
|
||||
});
|
||||
|
||||
// Die Adresse wird nur beim Öffnen der Seite gelesen; hält der Editor die Karte schon, bleibt sein Stand.
|
||||
const cardId = new URLSearchParams(location.search).get('karte');
|
||||
if (cardId && store.get().saved?.id !== cardId) void openSavedCard(cardId);
|
||||
const params = new URLSearchParams(location.search);
|
||||
const cardId = params.get('karte');
|
||||
const templateId = params.get('vorlage');
|
||||
if (cardId) {
|
||||
if (store.get().saved?.id !== cardId) void openSavedCard(cardId);
|
||||
} else if (templateId) {
|
||||
void openTemplate(templateId);
|
||||
}
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
|
||||
@@ -0,0 +1,708 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { User } from '../account/auth';
|
||||
import { ApiError, type ReportReason } from '../community/api';
|
||||
import type { GalleryCard, GalleryReport } from '../community/gallery';
|
||||
|
||||
// gallery.ts, api.ts und auth.ts hängen am Appwrite-Client; die Seite bekommt alles über ihre Abhängigkeiten.
|
||||
vi.mock('../account/appwrite', () => ({
|
||||
account: {},
|
||||
client: {},
|
||||
tables: {},
|
||||
storage: {},
|
||||
IDS: {},
|
||||
APPWRITE_ENDPOINT: '',
|
||||
}));
|
||||
|
||||
import { LoginCancelled } from '../account/with-login';
|
||||
import { mountCommunityPage, type CommunityDeps } from './community-page';
|
||||
|
||||
const card = (patch: Partial<GalleryCard> = {}): GalleryCard => ({
|
||||
id: 'g1',
|
||||
title: 'Mitternachtsblau',
|
||||
issuer: 'Beispielbank',
|
||||
tags: ['dunkel', 'gold'],
|
||||
displayName: 'Kenso',
|
||||
imageFileId: 'img1',
|
||||
previewFileId: 'pre1',
|
||||
sourceCardId: 'src1',
|
||||
ownerId: 'u1',
|
||||
hidden: false,
|
||||
reportCount: 0,
|
||||
createdAt: '2026-10-04T12:00:00.000Z',
|
||||
...patch,
|
||||
});
|
||||
|
||||
const report = (n: number, reason: string): GalleryReport => ({
|
||||
id: `r${n}`,
|
||||
galleryId: 'g1',
|
||||
reason,
|
||||
createdAt: '2026-10-05T12:00:00.000Z',
|
||||
});
|
||||
|
||||
const settle = async () => {
|
||||
for (let i = 0; i < 40; i++) await Promise.resolve();
|
||||
};
|
||||
const blob = () => new Blob(['png'], { type: 'image/png' });
|
||||
|
||||
const admin: User = { $id: 'a1', email: 'kenso@example.com', labels: ['admin'] };
|
||||
const owner: User = { $id: 'u1', email: 'u@example.com', labels: [] };
|
||||
const stranger: User = { $id: 'u2', email: 'x@example.com', labels: [] };
|
||||
|
||||
const UNAVAILABLE = 'Diese Karte ist nicht (mehr) verfügbar.';
|
||||
|
||||
let root: HTMLElement;
|
||||
let unmount: (() => void) | null;
|
||||
let user: User | null;
|
||||
let authListeners: Set<(u: User | null) => void>;
|
||||
let urlCounter: number;
|
||||
const revoked: string[] = [];
|
||||
const getCard = vi.fn<(id: string) => Promise<GalleryCard | null>>();
|
||||
const listReports = vi.fn<(id: string) => Promise<GalleryReport[]>>();
|
||||
const loadImage = vi.fn<(c: GalleryCard, size: 'full' | 'preview') => Promise<Blob>>();
|
||||
const reportCard = vi.fn<(id: string, reason: ReportReason) => Promise<{ schonGemeldet: boolean }>>();
|
||||
const withdrawCard = vi.fn<(id: string) => Promise<void>>();
|
||||
const setVisibility = vi.fn<(id: string, visible: boolean) => Promise<void>>();
|
||||
const navigate = vi.fn<(path: string) => void>();
|
||||
const saveBlobAs = vi.fn<(b: Blob, name: string) => void>();
|
||||
const withLogin = vi.fn(<T>(fn: () => Promise<T>): Promise<T> => fn());
|
||||
const getUser = vi.fn<() => Promise<User | null>>();
|
||||
|
||||
function deps(): CommunityDeps {
|
||||
return {
|
||||
getCard,
|
||||
listReports,
|
||||
loadImage,
|
||||
reportCard,
|
||||
withdrawCard,
|
||||
setVisibility,
|
||||
currentUser: () => user,
|
||||
getUser,
|
||||
onAuthChange: (fn) => {
|
||||
authListeners.add(fn);
|
||||
return () => authListeners.delete(fn);
|
||||
},
|
||||
navigate,
|
||||
saveBlobAs,
|
||||
withLogin: <T>(fn: () => Promise<T>) => withLogin(fn) as Promise<T>,
|
||||
};
|
||||
}
|
||||
|
||||
async function mount(id = 'g1'): Promise<void> {
|
||||
unmount = mountCommunityPage(root, id, deps());
|
||||
await settle();
|
||||
}
|
||||
|
||||
function login(next: User | null): void {
|
||||
user = next;
|
||||
for (const fn of [...authListeners]) fn(next);
|
||||
}
|
||||
|
||||
const q = <T extends Element = HTMLElement>(sel: string) => root.querySelector<T>(sel);
|
||||
const qa = <T extends Element = HTMLElement>(sel: string) => [...root.querySelectorAll<T>(sel)];
|
||||
const buttonByText = (text: string) =>
|
||||
qa<HTMLButtonElement>('button').find((b) => b.textContent === text) as HTMLButtonElement | undefined;
|
||||
const buttonTexts = () => qa<HTMLButtonElement>('button').map((b) => b.textContent);
|
||||
const confirmEl = () => document.querySelector<HTMLDialogElement>('dialog.confirm-dialog');
|
||||
const confirmButton = (label: string) =>
|
||||
[...confirmEl()!.querySelectorAll('button')].find((b) => b.textContent === label) as HTMLButtonElement;
|
||||
const reportEl = () => document.querySelector<HTMLDialogElement>('dialog.report-dialog');
|
||||
const errorText = () => q('[role="alert"]')?.textContent ?? '';
|
||||
const statusText = () => q('.detail-info [role="status"]')?.textContent ?? '';
|
||||
|
||||
beforeEach(() => {
|
||||
for (const fn of [getCard, listReports, loadImage, reportCard, withdrawCard, setVisibility, navigate, saveBlobAs, withLogin, getUser]) {
|
||||
fn.mockReset();
|
||||
}
|
||||
getCard.mockResolvedValue(card());
|
||||
listReports.mockResolvedValue([]);
|
||||
loadImage.mockImplementation(async () => blob());
|
||||
withdrawCard.mockResolvedValue(undefined);
|
||||
setVisibility.mockResolvedValue(undefined);
|
||||
getUser.mockResolvedValue(null);
|
||||
user = null;
|
||||
authListeners = new Set();
|
||||
urlCounter = 0;
|
||||
revoked.length = 0;
|
||||
URL.createObjectURL = vi.fn(() => `blob:detail-${++urlCounter}`);
|
||||
URL.revokeObjectURL = vi.fn((url: string) => {
|
||||
revoked.push(url);
|
||||
});
|
||||
document.body.innerHTML = '<main id="app-root"></main>';
|
||||
root = document.getElementById('app-root') as HTMLElement;
|
||||
unmount = null;
|
||||
document.title = 'AirCard-IOS';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
unmount?.();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('Detailseite: nicht (mehr) verfügbar', () => {
|
||||
it('Gast, Karte nicht vorhanden oder ausgeblendet (404): Text, Link zur Galerie, kein Bild, keine Knöpfe', async () => {
|
||||
getCard.mockResolvedValue(null);
|
||||
await mount('g1');
|
||||
expect(getCard).toHaveBeenCalledWith('g1');
|
||||
expect(q('h1')?.textContent).toBe(UNAVAILABLE);
|
||||
const link = q<HTMLAnchorElement>('a[href="/#community"]');
|
||||
expect(link?.textContent).toBe('Zur Galerie');
|
||||
expect(link?.hasAttribute('data-link')).toBe(true);
|
||||
expect(q('img')).toBeNull();
|
||||
expect(buttonTexts()).toEqual([]);
|
||||
expect(loadImage).not.toHaveBeenCalled();
|
||||
expect(listReports).not.toHaveBeenCalled();
|
||||
expect(document.title).toBe('Community – AirCard-IOS');
|
||||
});
|
||||
|
||||
it('eine ausgeblendete Karte für jemanden, der weder Autor noch Admin ist (Rechte-Fehler): ebenso, ohne Bild', async () => {
|
||||
user = stranger;
|
||||
getCard.mockResolvedValue(card({ hidden: true, reportCount: 3 }));
|
||||
await mount();
|
||||
expect(q('h1')?.textContent).toBe(UNAVAILABLE);
|
||||
expect(q('img')).toBeNull();
|
||||
expect(loadImage).not.toHaveBeenCalled();
|
||||
expect(root.textContent).not.toContain('Mitternachtsblau');
|
||||
});
|
||||
|
||||
it('Direktaufruf des Autors: der Anmeldestatus ist noch unbekannt, der Server hat die ausgeblendete Karte aber geliefert', async () => {
|
||||
getCard.mockResolvedValue(card({ hidden: true }));
|
||||
getUser.mockImplementation(async () => {
|
||||
user = owner;
|
||||
return owner;
|
||||
});
|
||||
await mount();
|
||||
expect(getUser).toHaveBeenCalledTimes(1);
|
||||
expect(q('h1')?.textContent).toBe('Mitternachtsblau');
|
||||
expect(q('.detail-hidden')?.textContent).toContain('Ausgeblendet');
|
||||
expect(buttonTexts()).toContain('Zurückziehen');
|
||||
});
|
||||
|
||||
it('eine sichtbare Karte braucht keine Anmeldeabfrage', async () => {
|
||||
await mount();
|
||||
expect(getUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Ladefehler (Netz): Hinweis und „Nochmal versuchen“, danach erscheint die Karte', async () => {
|
||||
getCard.mockRejectedValueOnce(new TypeError('offline'));
|
||||
await mount();
|
||||
expect(errorText()).toBe('Die Karte konnte gerade nicht geladen werden.');
|
||||
expect(q('img')).toBeNull();
|
||||
buttonByText('Nochmal versuchen')!.click();
|
||||
await settle();
|
||||
expect(getCard).toHaveBeenCalledTimes(2);
|
||||
expect(q('h1')?.textContent).toBe('Mitternachtsblau');
|
||||
expect(errorText()).toBe('');
|
||||
});
|
||||
|
||||
it('während des Ladens steht ein Hinweis', async () => {
|
||||
getCard.mockReturnValue(new Promise(() => {}));
|
||||
await mount();
|
||||
expect(q('[role="status"]')?.textContent).toBe('Karte wird geladen …');
|
||||
});
|
||||
|
||||
it('meldet sich der Autor einer ausgeblendeten Karte an, wird sie neu geladen und gezeigt', async () => {
|
||||
getCard.mockResolvedValueOnce(null);
|
||||
await mount();
|
||||
expect(q('h1')?.textContent).toBe(UNAVAILABLE);
|
||||
getCard.mockResolvedValueOnce(card({ hidden: true }));
|
||||
login(owner);
|
||||
await settle();
|
||||
expect(getCard).toHaveBeenCalledTimes(2);
|
||||
expect(q('h1')?.textContent).toBe('Mitternachtsblau');
|
||||
expect(q('.detail-hidden')?.textContent).toContain('Ausgeblendet');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Detailseite: Karte', () => {
|
||||
it('zeigt Titel, Herausgeber, „von …“, Datum, Tags und das große Bild; setzt den Seitentitel', async () => {
|
||||
await mount();
|
||||
expect(q('h1')?.textContent).toBe('Mitternachtsblau');
|
||||
expect(q('.detail-issuer')?.textContent).toBe('Beispielbank');
|
||||
expect(q('.detail-meta')?.textContent).toBe('von Kenso · 04.10.2026');
|
||||
const tags = qa<HTMLAnchorElement>('.detail-tag');
|
||||
expect(tags.map((t) => t.textContent)).toEqual(['dunkel', 'gold']);
|
||||
expect(tags[1].getAttribute('href')).toBe('/?tag=gold#community');
|
||||
expect(tags[1].hasAttribute('data-link')).toBe(true);
|
||||
expect(loadImage).toHaveBeenCalledWith(expect.objectContaining({ id: 'g1' }), 'full');
|
||||
const img = q<HTMLImageElement>('.detail-card img');
|
||||
expect(img?.getAttribute('src')).toBe('blob:detail-1');
|
||||
expect(img?.getAttribute('alt')).toBe('Community-Karte „Mitternachtsblau“');
|
||||
expect(document.title).toBe('Mitternachtsblau – AirCard-IOS');
|
||||
expect(q('a[href="/#community"]')?.textContent).toContain('Zur Galerie');
|
||||
});
|
||||
|
||||
it('ohne Anzeigenamen „von Anonym“; ohne Herausgeber und Tags fehlen die Zeilen', async () => {
|
||||
getCard.mockResolvedValue(card({ displayName: '', issuer: '', tags: [] }));
|
||||
await mount();
|
||||
expect(q('.detail-meta')?.textContent).toBe('von Anonym · 04.10.2026');
|
||||
expect(q('.detail-issuer')).toBeNull();
|
||||
expect(q('.detail-tags')).toBeNull();
|
||||
});
|
||||
|
||||
it('alles Nutzerseitige bleibt Text (kein HTML)', async () => {
|
||||
getCard.mockResolvedValue(
|
||||
card({
|
||||
title: '<b>Fett</b>',
|
||||
issuer: '<i>Bank</i>',
|
||||
displayName: '<script>alert(1)</script>',
|
||||
tags: ['<img src=x onerror=alert(1)>'],
|
||||
}),
|
||||
);
|
||||
await mount();
|
||||
expect(q('h1')?.textContent).toBe('<b>Fett</b>');
|
||||
expect(q('.detail-issuer')?.textContent).toBe('<i>Bank</i>');
|
||||
expect(q('.detail-meta')?.textContent).toContain('<script>alert(1)</script>');
|
||||
expect(q('.detail-tag')?.textContent).toBe('<img src=x onerror=alert(1)>');
|
||||
expect(root.querySelector('b, i, script')).toBeNull();
|
||||
expect(qa('img')).toHaveLength(1); // nur das Kartenbild
|
||||
});
|
||||
|
||||
it('der Gast sieht Herunterladen, Als Vorlage gestalten, Melden – sonst nichts', async () => {
|
||||
await mount();
|
||||
expect(buttonTexts()).toEqual(['Herunterladen', 'Als Vorlage gestalten', 'Melden']);
|
||||
expect(listReports).not.toHaveBeenCalled();
|
||||
expect(q('.detail-admin')).toBeNull();
|
||||
});
|
||||
|
||||
it('Bild lädt nicht: Hinweis mit „Nochmal versuchen“; Herunterladen holt das Bild selbst nach', async () => {
|
||||
loadImage.mockRejectedValueOnce(new TypeError('offline'));
|
||||
await mount();
|
||||
expect(q('.detail-card')?.textContent).toContain('Das Bild konnte nicht geladen werden.');
|
||||
expect(q('.detail-card img')).toBeNull();
|
||||
// Herunterladen versucht es erneut und gelingt
|
||||
buttonByText('Herunterladen')!.click();
|
||||
await settle();
|
||||
expect(loadImage).toHaveBeenCalledTimes(2);
|
||||
expect(saveBlobAs).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('„Nochmal versuchen“ beim Bild lädt es erneut', async () => {
|
||||
loadImage.mockRejectedValueOnce(new TypeError('offline'));
|
||||
await mount();
|
||||
buttonByText('Nochmal versuchen')!.click();
|
||||
await settle();
|
||||
expect(loadImage).toHaveBeenCalledTimes(2);
|
||||
expect(q('.detail-card img')?.getAttribute('src')).toBe('blob:detail-1');
|
||||
});
|
||||
|
||||
it('ein Bild, das der Browser nicht darstellen kann, zeigt denselben Hinweis und gibt die URL frei', async () => {
|
||||
await mount();
|
||||
q('.detail-card img')!.dispatchEvent(new Event('error'));
|
||||
expect(q('.detail-card')?.textContent).toContain('Das Bild konnte nicht geladen werden.');
|
||||
expect(revoked).toEqual(['blob:detail-1']);
|
||||
});
|
||||
|
||||
it('Object-URL wird beim Verlassen freigegeben; späte Antworten nach dem Verlassen bewirken nichts', async () => {
|
||||
await mount();
|
||||
unmount?.();
|
||||
unmount = null;
|
||||
expect(revoked).toEqual(['blob:detail-1']);
|
||||
|
||||
// Karte kommt erst nach dem Verlassen
|
||||
let finishCard!: (c: GalleryCard) => void;
|
||||
getCard.mockReturnValue(new Promise((r) => (finishCard = r)));
|
||||
root.replaceChildren();
|
||||
unmount = mountCommunityPage(root, 'g1', deps());
|
||||
unmount();
|
||||
unmount = null;
|
||||
finishCard(card());
|
||||
await settle();
|
||||
expect(loadImage).toHaveBeenCalledTimes(1); // nur vom ersten Aufruf
|
||||
expect(q('h1')).toBeNull(); // die Karte wird nicht mehr aufgebaut
|
||||
|
||||
// Bild kommt erst nach dem Verlassen
|
||||
let finishImage!: (b: Blob) => void;
|
||||
getCard.mockResolvedValue(card());
|
||||
loadImage.mockReturnValue(new Promise((r) => (finishImage = r)));
|
||||
unmount = mountCommunityPage(root, 'g1', deps());
|
||||
await settle();
|
||||
unmount();
|
||||
unmount = null;
|
||||
const created = urlCounter;
|
||||
finishImage(blob());
|
||||
await settle();
|
||||
expect(urlCounter).toBe(created);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Detailseite: Aufräumen', () => {
|
||||
it('beim Verlassen wird der Auth-Abonnent abgemeldet, danach ändert sich nichts mehr', async () => {
|
||||
await mount();
|
||||
expect(authListeners.size).toBe(1);
|
||||
unmount?.();
|
||||
unmount = null;
|
||||
expect(authListeners.size).toBe(0);
|
||||
});
|
||||
|
||||
it('Meldungen, die erst nach dem Verlassen eintreffen, werden verworfen', async () => {
|
||||
user = admin;
|
||||
let finish!: (r: GalleryReport[]) => void;
|
||||
listReports.mockReturnValue(new Promise((r) => (finish = r)));
|
||||
await mount();
|
||||
unmount?.();
|
||||
unmount = null;
|
||||
const html = root.innerHTML;
|
||||
finish([report(1, 'spam')]);
|
||||
await settle();
|
||||
expect(root.innerHTML).toBe(html);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Detailseite: Herunterladen und Vorlage', () => {
|
||||
it('Herunterladen speichert das volle PNG als aircard-<titel-slug>.png', async () => {
|
||||
const full = blob();
|
||||
loadImage.mockResolvedValue(full);
|
||||
getCard.mockResolvedValue(card({ title: 'Grüner Wald!' }));
|
||||
await mount();
|
||||
buttonByText('Herunterladen')!.click();
|
||||
await settle();
|
||||
expect(saveBlobAs).toHaveBeenCalledTimes(1);
|
||||
expect(saveBlobAs).toHaveBeenCalledWith(full, 'aircard-gruener-wald.png');
|
||||
// das schon geladene Bild wird wiederverwendet
|
||||
expect(loadImage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Herunterladen scheitert: Meldung, der Knopf geht wieder', async () => {
|
||||
await mount();
|
||||
q('.detail-card img')!.dispatchEvent(new Event('error')); // kein Bild im Speicher
|
||||
loadImage.mockRejectedValueOnce(new TypeError('offline'));
|
||||
buttonByText('Herunterladen')!.click();
|
||||
await settle();
|
||||
expect(saveBlobAs).not.toHaveBeenCalled();
|
||||
expect(errorText()).toBe('Das Bild konnte nicht geladen werden.');
|
||||
expect(buttonByText('Herunterladen')!.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('„Als Vorlage gestalten“ führt zu /gestalten?vorlage=<id>', async () => {
|
||||
await mount();
|
||||
buttonByText('Als Vorlage gestalten')!.click();
|
||||
expect(navigate).toHaveBeenCalledWith('/gestalten?vorlage=g1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Detailseite: Melden', () => {
|
||||
const open = async () => {
|
||||
buttonByText('Melden')!.click();
|
||||
await settle();
|
||||
};
|
||||
const submitReason = async (value: string) => {
|
||||
const radio = reportEl()!.querySelector<HTMLInputElement>(`input[value="${value}"]`)!;
|
||||
radio.checked = true;
|
||||
reportEl()!.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||
await settle();
|
||||
};
|
||||
|
||||
it('Dialog mit den fünf Gründen; die Wahl geht als API-Wert an reportCard', async () => {
|
||||
reportCard.mockResolvedValue({ schonGemeldet: false });
|
||||
await mount();
|
||||
await open();
|
||||
expect([...reportEl()!.querySelectorAll('.report-reason')].map((l) => l.textContent)).toEqual([
|
||||
'Unangemessen',
|
||||
'Echte Kartendaten',
|
||||
'Urheberrecht/Marke',
|
||||
'Spam',
|
||||
'Sonstiges',
|
||||
]);
|
||||
await submitReason('urheberrecht');
|
||||
expect(reportCard).toHaveBeenCalledWith('g1', 'urheberrecht');
|
||||
expect(reportEl()!.textContent).toContain('Danke für deine Meldung.');
|
||||
});
|
||||
|
||||
it('doppelt gemeldet: „Danke, du hast diese Karte schon gemeldet.“', async () => {
|
||||
reportCard.mockResolvedValue({ schonGemeldet: true });
|
||||
await mount();
|
||||
await open();
|
||||
await submitReason('spam');
|
||||
expect(reportEl()!.textContent).toContain('Danke, du hast diese Karte schon gemeldet.');
|
||||
});
|
||||
|
||||
it('zu viele Meldungen (429): der Servertext erscheint im Dialog', async () => {
|
||||
reportCard.mockRejectedValue(new ApiError(429, 'Zu viele Meldungen – bitte später nochmal.'));
|
||||
await mount();
|
||||
await open();
|
||||
await submitReason('spam');
|
||||
expect(reportEl()!.querySelector('[role="alert"]')!.textContent).toBe('Zu viele Meldungen – bitte später nochmal.');
|
||||
expect(reportEl()?.open).toBe(true);
|
||||
});
|
||||
|
||||
it('Verlassen der Seite schließt den offenen Dialog', async () => {
|
||||
await mount();
|
||||
await open();
|
||||
expect(reportEl()).not.toBeNull();
|
||||
unmount?.();
|
||||
unmount = null;
|
||||
await settle();
|
||||
expect(reportEl()).toBeNull();
|
||||
});
|
||||
|
||||
it('eine ausgeblendete Karte (nur Autor/Admin sehen sie) hat keinen Melden-Knopf', async () => {
|
||||
user = owner;
|
||||
getCard.mockResolvedValue(card({ hidden: true }));
|
||||
await mount();
|
||||
expect(buttonTexts()).not.toContain('Melden');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Detailseite: Autor', () => {
|
||||
it('der Autor sieht Zurückziehen, ein Fremder nicht', async () => {
|
||||
user = owner;
|
||||
await mount();
|
||||
expect(buttonTexts()).toEqual(['Herunterladen', 'Als Vorlage gestalten', 'Melden', 'Zurückziehen']);
|
||||
unmount?.();
|
||||
unmount = null;
|
||||
|
||||
user = stranger;
|
||||
await mount();
|
||||
expect(buttonTexts()).not.toContain('Zurückziehen');
|
||||
expect(buttonTexts()).not.toContain('Löschen');
|
||||
expect(q('.detail-admin')).toBeNull();
|
||||
});
|
||||
|
||||
it('die Knöpfe erscheinen auch, wenn der Nutzer erst nach dem Laden bekannt wird, und verschwinden beim Abmelden', async () => {
|
||||
await mount();
|
||||
expect(buttonTexts()).not.toContain('Zurückziehen');
|
||||
login(owner);
|
||||
expect(buttonTexts()).toContain('Zurückziehen');
|
||||
login(null);
|
||||
expect(buttonTexts()).not.toContain('Zurückziehen');
|
||||
});
|
||||
|
||||
it('Zurückziehen fragt nach; „Abbrechen“ lässt alles, „Zurückziehen“ ruft die API und geht zur Startseite', async () => {
|
||||
user = owner;
|
||||
await mount();
|
||||
buttonByText('Zurückziehen')!.click();
|
||||
await settle();
|
||||
expect(confirmEl()?.open).toBe(true);
|
||||
expect(confirmEl()!.querySelector('h2')!.textContent).toBe('Karte „Mitternachtsblau“ zurückziehen?');
|
||||
confirmButton('Abbrechen').click();
|
||||
await settle();
|
||||
expect(confirmEl()).toBeNull();
|
||||
expect(withdrawCard).not.toHaveBeenCalled();
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
|
||||
buttonByText('Zurückziehen')!.click();
|
||||
await settle();
|
||||
confirmButton('Zurückziehen').click();
|
||||
await settle();
|
||||
expect(withdrawCard).toHaveBeenCalledWith('g1');
|
||||
expect(withLogin).toHaveBeenCalledTimes(1);
|
||||
expect(navigate).toHaveBeenCalledWith('/');
|
||||
});
|
||||
|
||||
it('Zurückziehen scheitert: Servertext, nicht navigiert, Knöpfe wieder bedienbar', async () => {
|
||||
user = owner;
|
||||
withdrawCard.mockRejectedValue(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.'));
|
||||
await mount();
|
||||
buttonByText('Zurückziehen')!.click();
|
||||
await settle();
|
||||
confirmButton('Zurückziehen').click();
|
||||
await settle();
|
||||
expect(errorText()).toBe('Der Dienst ist gerade nicht erreichbar.');
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
expect(qa<HTMLButtonElement>('.detail-actions button').every((b) => !b.disabled)).toBe(true);
|
||||
});
|
||||
|
||||
it('war die Karte schon weg (404), ist das Ziel erreicht: zur Startseite', async () => {
|
||||
user = owner;
|
||||
withdrawCard.mockRejectedValue(new ApiError(404, 'Diese Karte gibt es nicht (mehr).'));
|
||||
await mount();
|
||||
buttonByText('Zurückziehen')!.click();
|
||||
await settle();
|
||||
confirmButton('Zurückziehen').click();
|
||||
await settle();
|
||||
expect(navigate).toHaveBeenCalledWith('/');
|
||||
expect(errorText()).toBe('');
|
||||
});
|
||||
|
||||
it('Anmeldung abgebrochen: Hinweis, nicht navigiert', async () => {
|
||||
user = owner;
|
||||
withLogin.mockRejectedValue(new LoginCancelled());
|
||||
await mount();
|
||||
buttonByText('Zurückziehen')!.click();
|
||||
await settle();
|
||||
confirmButton('Zurückziehen').click();
|
||||
await settle();
|
||||
expect(errorText()).toBe('Bitte melde dich an, um fortzufahren.');
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('während die Anfrage läuft, ist alles gesperrt und es geht nur eine raus', async () => {
|
||||
user = owner;
|
||||
let finish!: () => void;
|
||||
withdrawCard.mockReturnValue(new Promise<void>((r) => (finish = r)));
|
||||
await mount();
|
||||
buttonByText('Zurückziehen')!.click();
|
||||
await settle();
|
||||
confirmButton('Zurückziehen').click();
|
||||
await settle();
|
||||
expect(qa<HTMLButtonElement>('.detail-actions button').every((b) => b.disabled)).toBe(true);
|
||||
buttonByText('Zurückziehen')!.click();
|
||||
await settle();
|
||||
expect(confirmEl()).toBeNull();
|
||||
expect(withdrawCard).toHaveBeenCalledTimes(1);
|
||||
finish();
|
||||
await settle();
|
||||
expect(navigate).toHaveBeenCalledWith('/');
|
||||
});
|
||||
|
||||
it('verlässt der Nutzer die Seite, während gefragt wird, schließt der Dialog und nichts passiert', async () => {
|
||||
user = owner;
|
||||
await mount();
|
||||
buttonByText('Zurückziehen')!.click();
|
||||
await settle();
|
||||
expect(confirmEl()).not.toBeNull();
|
||||
unmount?.();
|
||||
unmount = null;
|
||||
await settle();
|
||||
expect(confirmEl()).toBeNull();
|
||||
expect(withdrawCard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('der Autor einer ausgeblendeten Karte sieht sie mit dem Hinweis „Ausgeblendet“', async () => {
|
||||
user = owner;
|
||||
getCard.mockResolvedValue(card({ hidden: true, reportCount: 3 }));
|
||||
await mount();
|
||||
expect(q('h1')?.textContent).toBe('Mitternachtsblau');
|
||||
expect(q('.detail-hidden')?.textContent).toContain('Ausgeblendet');
|
||||
expect(q('.detail-hidden')?.textContent).toContain('für andere nicht sichtbar');
|
||||
expect(q('.detail-card img')).not.toBeNull();
|
||||
// Meldungszahl und Gründe sind Sache der Admins
|
||||
expect(root.textContent).not.toContain('Meldungen');
|
||||
expect(listReports).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Detailseite: Admin', () => {
|
||||
it('Admin sieht Ausblenden, Löschen, die Meldungszahl und die Gründe', async () => {
|
||||
user = admin;
|
||||
getCard.mockResolvedValue(card({ reportCount: 3 }));
|
||||
listReports.mockResolvedValue([report(1, 'spam'), report(2, 'kartendaten'), report(3, 'unbekannt')]);
|
||||
await mount();
|
||||
expect(listReports).toHaveBeenCalledWith('g1');
|
||||
expect(buttonTexts()).toEqual(['Herunterladen', 'Als Vorlage gestalten', 'Melden', 'Ausblenden', 'Löschen']);
|
||||
expect(q('.detail-reports-count')?.textContent).toBe('3 Meldungen');
|
||||
expect(qa('.detail-reasons li').map((li) => li.textContent)).toEqual([
|
||||
'Spam · 05.10.2026',
|
||||
'Echte Kartendaten · 05.10.2026',
|
||||
'unbekannt · 05.10.2026',
|
||||
]);
|
||||
expect(buttonTexts()).not.toContain('Zurückziehen');
|
||||
});
|
||||
|
||||
it('eine Meldung heißt „1 Meldung“, keine „0 Meldungen“ ohne Liste', async () => {
|
||||
user = admin;
|
||||
getCard.mockResolvedValue(card({ reportCount: 1 }));
|
||||
listReports.mockResolvedValue([report(1, 'spam')]);
|
||||
await mount();
|
||||
expect(q('.detail-reports-count')?.textContent).toBe('1 Meldung');
|
||||
unmount?.();
|
||||
unmount = null;
|
||||
|
||||
getCard.mockResolvedValue(card({ reportCount: 0 }));
|
||||
listReports.mockResolvedValue([]);
|
||||
await mount();
|
||||
expect(q('.detail-reports-count')?.textContent).toBe('0 Meldungen');
|
||||
expect(q('.detail-reasons')).toBeNull();
|
||||
});
|
||||
|
||||
it('Gründe lassen sich nicht laden: Hinweis, Zahl und Knöpfe bleiben', async () => {
|
||||
user = admin;
|
||||
getCard.mockResolvedValue(card({ reportCount: 2 }));
|
||||
listReports.mockRejectedValue(new TypeError('offline'));
|
||||
await mount();
|
||||
expect(q('.detail-reports-count')?.textContent).toBe('2 Meldungen');
|
||||
expect(q('.detail-admin')?.textContent).toContain('Die Gründe konnten nicht geladen werden.');
|
||||
expect(buttonTexts()).toContain('Ausblenden');
|
||||
});
|
||||
|
||||
it('Ausblenden ruft die API, zeigt „Ausgeblendet“ und wird zu „Einblenden“', async () => {
|
||||
user = admin;
|
||||
getCard.mockResolvedValue(card({ reportCount: 1 }));
|
||||
listReports.mockResolvedValue([report(1, 'spam')]);
|
||||
await mount();
|
||||
buttonByText('Ausblenden')!.click();
|
||||
await settle();
|
||||
expect(setVisibility).toHaveBeenCalledWith('g1', false);
|
||||
expect(withLogin).toHaveBeenCalledTimes(1);
|
||||
expect(buttonTexts()).toContain('Einblenden');
|
||||
expect(buttonTexts()).not.toContain('Ausblenden');
|
||||
expect(buttonTexts()).not.toContain('Melden');
|
||||
expect(q('.detail-hidden')?.textContent).toContain('Ausgeblendet');
|
||||
expect(statusText()).toBe('Karte ausgeblendet.');
|
||||
// Die Meldungen bleiben beim Ausblenden
|
||||
expect(q('.detail-reports-count')?.textContent).toBe('1 Meldung');
|
||||
});
|
||||
|
||||
it('Einblenden macht die Karte wieder sichtbar; der Server löscht die Meldungen, die Anzeige folgt', async () => {
|
||||
user = admin;
|
||||
getCard.mockResolvedValue(card({ hidden: true, reportCount: 3 }));
|
||||
listReports.mockResolvedValue([report(1, 'spam'), report(2, 'spam'), report(3, 'sonstiges')]);
|
||||
await mount();
|
||||
expect(q('.detail-hidden')).not.toBeNull();
|
||||
buttonByText('Einblenden')!.click();
|
||||
await settle();
|
||||
expect(setVisibility).toHaveBeenCalledWith('g1', true);
|
||||
expect(buttonTexts()).toContain('Ausblenden');
|
||||
expect(buttonTexts()).toContain('Melden');
|
||||
expect(q('.detail-hidden')).toBeNull();
|
||||
expect(q('.detail-reports-count')?.textContent).toBe('0 Meldungen');
|
||||
expect(q('.detail-reasons')).toBeNull();
|
||||
expect(statusText()).toBe('Karte eingeblendet.');
|
||||
});
|
||||
|
||||
it('Ausblenden scheitert: Servertext, Zustand unverändert', async () => {
|
||||
user = admin;
|
||||
setVisibility.mockRejectedValue(new ApiError(403, 'Nur für Administratoren.'));
|
||||
await mount();
|
||||
buttonByText('Ausblenden')!.click();
|
||||
await settle();
|
||||
expect(errorText()).toBe('Nur für Administratoren.');
|
||||
expect(buttonTexts()).toContain('Ausblenden');
|
||||
expect(q('.detail-hidden')).toBeNull();
|
||||
expect(buttonByText('Ausblenden')!.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('Löschen fragt nach und löscht über withdrawCard, danach zur Startseite', async () => {
|
||||
user = admin;
|
||||
await mount();
|
||||
buttonByText('Löschen')!.click();
|
||||
await settle();
|
||||
expect(confirmEl()!.querySelector('h2')!.textContent).toBe('Karte „Mitternachtsblau“ löschen?');
|
||||
confirmButton('Abbrechen').click();
|
||||
await settle();
|
||||
expect(withdrawCard).not.toHaveBeenCalled();
|
||||
|
||||
buttonByText('Löschen')!.click();
|
||||
await settle();
|
||||
confirmButton('Löschen').click();
|
||||
await settle();
|
||||
expect(withdrawCard).toHaveBeenCalledWith('g1');
|
||||
expect(navigate).toHaveBeenCalledWith('/');
|
||||
});
|
||||
|
||||
it('wird jemand erst später als Admin erkannt, erscheinen die Admin-Knöpfe und die Gründe; beim Verlust verschwinden sie', async () => {
|
||||
getCard.mockResolvedValue(card({ reportCount: 1 }));
|
||||
listReports.mockResolvedValue([report(1, 'spam')]);
|
||||
await mount();
|
||||
expect(q('.detail-admin')).toBeNull();
|
||||
expect(listReports).not.toHaveBeenCalled();
|
||||
login(admin);
|
||||
await settle();
|
||||
expect(listReports).toHaveBeenCalledTimes(1);
|
||||
expect(buttonTexts()).toContain('Ausblenden');
|
||||
expect(q('.detail-reasons li')?.textContent).toBe('Spam · 05.10.2026');
|
||||
login(stranger);
|
||||
expect(q('.detail-admin')).toBeNull();
|
||||
expect(buttonTexts()).not.toContain('Löschen');
|
||||
});
|
||||
|
||||
it('verliert ein Admin sein Recht, während er eine ausgeblendete Karte ansieht, wird sie unzugänglich', async () => {
|
||||
user = admin;
|
||||
getCard.mockResolvedValue(card({ hidden: true }));
|
||||
await mount();
|
||||
expect(q('h1')?.textContent).toBe('Mitternachtsblau');
|
||||
login(stranger);
|
||||
expect(q('h1')?.textContent).toBe(UNAVAILABLE);
|
||||
expect(q('img')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user