feat(community): Veröffentlichen aus Meine Karten

Neuer Dialog (publish-dialog.ts) laut Spec § 6: Titel (Pflicht, höchstens 60), Herausgeber/Bank (40),
Tags (bis 5, je 20, Komma-getrennt), Anzeigename (30, leer = Anonym, nie vorbelegt) und die
Pflicht-Checkbox im Wortlaut der Spec. Prüfung vor dem Senden mit deutschen Texten, während des Sendens
lässt sich der Dialog nicht schließen; Fehler stehen im Dialog („Veröffentlichen fehlgeschlagen – …“).

Meine Karten zeigt je Kachel den Status aus myPublications (einmal je Seitenaufbau, danach lokal
nachgeführt): Veröffentlicht + Ansehen + Zurückziehen, Ausgeblendet (gemeldet) + Zurückziehen, sonst
Veröffentlichen. Bei 409 wird der Status neu geholt; bleibt der Ausgang offen (503/5xx/429/408), wird vor
der Fehlermeldung nachgesehen und bei Erfolg der Erfolgszustand gezeigt. Zurückziehen fragt nach, 404 gilt
als erledigt. Nicht angemeldet läuft über withLogin. Das Löschen einer veröffentlichten Karte weist darauf
hin, dass die Veröffentlichung bleibt.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 15:47:56 +00:00
1 parent be440bc8e9
commit f6b9ee61f4
7 files changed
+1403 -9

No files matched your search

+39
View File
@@ -781,6 +781,45 @@
gap: 10px;
}
/* Veröffentlichen-Dialog: Hülle, Meldungen und Knöpfe wie beim Melden-Dialog, dazu Felder und die Pflicht-Checkbox */
.publish-dialog {
width: min(480px, calc(100vw - 32px));
}
.publish-fields {
display: flex;
flex-direction: column;
gap: 14px;
}
.publish-consent {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 12px;
cursor: pointer;
}
.publish-consent:has(input:checked) {
border-color: var(--sel-line);
background: var(--sel-bg);
}
.publish-consent input {
flex: none;
width: 18px;
height: 18px;
margin: 2px 0 0;
accent-color: var(--sel-line);
}
.publish-consent span {
overflow-wrap: anywhere;
}
/* Tablet und Handy: eine Spalte, der Hero bleibt niedrig */
@media (max-width: 899px) {
+376
View File
@@ -0,0 +1,376 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// publish.ts (normalizeTags) und api.ts hängen am Appwrite-Client; der Dialog bekommt `submit` von außen.
vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' }));
vi.mock('../account/auth', () => ({ currentUserId: () => null, onAuthChange: () => () => {}, onLogout: () => () => {} }));
import { LOGIN_NEEDED, LoginCancelled } from '../account/with-login';
import { ApiError } from './api';
import { NOT_RENDERED, TOO_LARGE, type PublishForm } from './publish';
import {
CONSENT_TEXT,
publishDialog,
validatePublishForm,
type PublishFormValues,
type PublishOutcome,
} from './publish-dialog';
type Submit = (form: PublishForm) => Promise<{ id: string }>;
const submit = vi.fn<Submit>();
const dialogEl = () => document.querySelector<HTMLDialogElement>('dialog.publish-dialog');
const input = (name: string) => dialogEl()!.querySelector<HTMLInputElement>(`input[name="${name}"]`)!;
const consent = () => input('consent');
const buttonByText = (text: string) =>
[...(dialogEl()?.querySelectorAll('button') ?? [])].find((b) => b.textContent === text) as HTMLButtonElement | undefined;
const form = () => dialogEl()!.querySelector('form')!;
const send = () => 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 fill(values: Partial<PublishFormValues> = {}): void {
const v = { title: 'Meine Karte', issuer: '', tags: '', displayName: '', consent: true, ...values };
input('title').value = v.title;
input('issuer').value = v.issuer;
input('tags').value = v.tags;
input('displayName').value = v.displayName;
consent().checked = v.consent;
}
function open(opts: { cardName?: string; signal?: AbortSignal } = {}): Promise<PublishOutcome> {
return publishDialog({ submit, ...opts });
}
beforeEach(() => {
submit.mockReset();
document.body.innerHTML = '';
});
afterEach(() => {
document.body.innerHTML = '';
});
describe('Veröffentlichen-Dialog: Felder', () => {
it('zeigt die Felder der Spec in Reihenfolge, alle leer, die Checkbox ungesetzt', () => {
void open({ cardName: 'Urlaub' });
expect(dialogEl()?.open).toBe(true);
expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Karte veröffentlichen');
expect([...form().querySelectorAll('input')].map((i) => i.name)).toEqual(['title', 'issuer', 'tags', 'displayName', 'consent']);
expect([...form().querySelectorAll('.auth-label')].map((l) => l.textContent)).toEqual([
'Titel (Pflicht)',
'Herausgeber / Bank (optional)',
'Tags (optional)',
'Anzeigename (optional)',
]);
for (const name of ['title', 'issuer', 'tags', 'displayName']) expect(input(name).value).toBe('');
expect(consent().type).toBe('checkbox');
expect(consent().checked).toBe(false);
expect(buttonByText('Abbrechen')).toBeTruthy();
expect(buttonByText('Veröffentlichen')).toBeTruthy();
});
it('der Anzeigename ist leer und nennt „Anonym“ als Platzhalter (nie eine E-Mail)', () => {
void open();
expect(input('displayName').value).toBe('');
expect(input('displayName').placeholder).toBe('Anonym');
expect(dialogEl()!.textContent).not.toMatch(/@/);
});
it('die Pflicht-Checkbox hat genau den Wortlaut der Spec', () => {
void open();
expect(CONSENT_TEXT).toBe('Das Bild gehört mir bzw. ich darf es verwenden, und es enthält keine echten Kartendaten.');
expect(consent().closest('label')!.textContent).toBe(CONSENT_TEXT);
});
it('nennt den Kartennamen als Text, nie als HTML', () => {
void open({ cardName: '<img src=x onerror=alert(1)>' });
expect(dialogEl()!.querySelector('img')).toBeNull();
expect(dialogEl()!.textContent).toContain('<img src=x onerror=alert(1)>');
});
});
describe('Veröffentlichen-Dialog: Prüfung vor dem Senden', () => {
const invalid = async (values: Partial<PublishFormValues>, message: string, field: string) => {
void open();
fill(values);
send();
await settle();
expect(errorText()).toBe(message);
expect(submit).not.toHaveBeenCalled();
expect(dialogEl()?.open).toBe(true);
expect(input(field).getAttribute('aria-invalid')).toBe('true');
expect(document.activeElement).toBe(input(field));
};
it('Titel leer (auch nur Leerzeichen)', async () => {
await invalid({ title: ' ' }, 'Bitte gib einen Titel ein.', 'title');
});
it('Titel länger als 60 Zeichen', async () => {
await invalid({ title: 'x'.repeat(61) }, 'Der Titel ist zu lang (höchstens 60 Zeichen).', 'title');
});
it('Herausgeber länger als 40 Zeichen', async () => {
await invalid({ issuer: 'x'.repeat(41) }, 'Herausgeber / Bank ist zu lang (höchstens 40 Zeichen).', 'issuer');
});
it('sechs Tags', async () => {
await invalid({ tags: 'a, b, c, d, e, f' }, 'Bitte gib höchstens 5 Tags an.', 'tags');
});
it('ein Tag länger als 20 Zeichen', async () => {
await invalid({ tags: `visa, ${'x'.repeat(21)}` }, 'Ein Tag darf höchstens 20 Zeichen lang sein.', 'tags');
});
it('Anzeigename länger als 30 Zeichen', async () => {
await invalid({ displayName: 'x'.repeat(31) }, 'Der Anzeigename ist zu lang (höchstens 30 Zeichen).', 'displayName');
});
it('Checkbox fehlt', async () => {
await invalid({ consent: false }, 'Bitte bestätige die Erklärung zum Bild.', 'consent');
});
it('Steuerzeichen werden abgelehnt (der Server täte es auch)', async () => {
await invalid({ title: 'a\tb' }, 'Der Titel enthält ungültige Zeichen.', 'title');
});
it('Grenzen sind inklusive: 60/40/30 Zeichen und fünf Tags gehen durch; doppelte und leere Tags zählen nicht', async () => {
submit.mockResolvedValue({ id: 'g1' });
const outcome = open();
fill({ title: 'x'.repeat(60), issuer: 'y'.repeat(40), displayName: 'z'.repeat(30), tags: 'A, a, , b, c, d, e,' });
send();
await expect(outcome).resolves.toBe('published');
expect(submit).toHaveBeenCalledOnce();
});
it('eine behobene Eingabe verliert die Markierung beim nächsten Senden', async () => {
submit.mockResolvedValue({ id: 'g1' });
void open();
fill({ title: '' });
send();
expect(input('title').getAttribute('aria-invalid')).toBe('true');
fill({ title: 'Ok' });
send();
expect(input('title').getAttribute('aria-invalid')).toBeNull();
expect(errorText()).toBe('');
});
it('validatePublishForm meldet das erste Problem in Formularreihenfolge, sonst null', () => {
const ok: PublishFormValues = { title: 'T', issuer: '', tags: '', displayName: '', consent: true };
expect(validatePublishForm(ok)).toBeNull();
expect(validatePublishForm({ ...ok, title: '', consent: false })?.field).toBe('title');
expect(validatePublishForm({ ...ok, tags: 'a,b,c,d,e,f', consent: false })?.field).toBe('tags');
expect(validatePublishForm({ ...ok, consent: false })?.field).toBe('consent');
});
});
describe('Veröffentlichen-Dialog: Senden', () => {
it('sendet die getrimmten Eingaben (Tags roh, Normalisierung macht publishSavedCard) und schließt bei Erfolg', async () => {
submit.mockResolvedValue({ id: 'g1' });
const outcome = open();
fill({ title: ' Mein Motiv ', issuer: ' Beispielbank ', tags: 'Visa, Gold', displayName: ' Kenso ' });
send();
await expect(outcome).resolves.toBe('published');
expect(submit).toHaveBeenCalledWith({ title: 'Mein Motiv', issuer: 'Beispielbank', tags: 'Visa, Gold', displayName: 'Kenso' });
expect(dialogEl()).toBeNull();
});
it('leere optionale Felder werden leer gesendet (Anonym macht der Server)', async () => {
submit.mockResolvedValue({ id: 'g1' });
const outcome = open();
fill({ title: 'Nur Titel' });
send();
await outcome;
expect(submit).toHaveBeenCalledWith({ title: 'Nur Titel', issuer: '', tags: '', displayName: '' });
});
it('während des Sendens: „Veröffentlichen“ und „Abbrechen“ gesperrt, Felder gesperrt, zweites Senden wird ignoriert', async () => {
let finish!: (r: { id: string }) => void;
submit.mockReturnValue(new Promise((r) => (finish = r)));
const outcome = open();
fill();
send();
expect(buttonByText('Veröffentlichen')!.disabled).toBe(true);
expect(buttonByText('Abbrechen')!.disabled).toBe(true);
expect(input('title').disabled).toBe(true);
expect(consent().disabled).toBe(true);
expect(form().getAttribute('aria-busy')).toBe('true');
expect(statusText()).toBe('Wird veröffentlicht …');
send();
expect(submit).toHaveBeenCalledOnce();
finish({ id: 'g1' });
await expect(outcome).resolves.toBe('published');
});
it('während des Sendens schließen weder Esc noch ein Klick auf den Hintergrund den Dialog', async () => {
let finish!: (r: { id: string }) => void;
submit.mockReturnValue(new Promise((r) => (finish = r)));
const outcome = open();
fill();
send();
const esc = new Event('cancel', { cancelable: true });
dialogEl()!.dispatchEvent(esc);
expect(esc.defaultPrevented).toBe(true);
dialogEl()!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
dialogEl()!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(dialogEl()?.open).toBe(true);
finish({ id: 'g1' });
await outcome;
});
it('Fehler des Dienstes: Servertext im Dialog, der Dialog bleibt offen, ein neuer Versuch ist möglich', async () => {
submit.mockRejectedValueOnce(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.'));
const outcome = open();
fill();
send();
await settle();
expect(errorText()).toBe('Veröffentlichen fehlgeschlagen – Der Dienst ist gerade nicht erreichbar.');
expect(dialogEl()?.open).toBe(true);
expect(buttonByText('Veröffentlichen')!.disabled).toBe(false);
expect(buttonByText('Abbrechen')!.disabled).toBe(false);
expect(input('title').disabled).toBe(false);
expect(input('title').value).toBe('Meine Karte');
expect(consent().checked).toBe(true);
expect(statusText()).toBe('');
submit.mockResolvedValueOnce({ id: 'g1' });
send();
await expect(outcome).resolves.toBe('published');
expect(submit).toHaveBeenCalledTimes(2);
});
it('Validierungsfehler des Dienstes (400) erscheinen mit ihrem Text', async () => {
submit.mockRejectedValue(new ApiError(400, 'Der Titel enthält ungültige Zeichen.'));
void open();
fill();
send();
await settle();
expect(errorText()).toBe('Veröffentlichen fehlgeschlagen – Der Titel enthält ungültige Zeichen.');
});
it('409: „Diese Karte ist schon veröffentlicht.“, kein neuer Versuch, Ergebnis „already“ nach „Schließen“', async () => {
submit.mockRejectedValue(new ApiError(409, 'Diese Karte ist schon veröffentlicht.'));
const outcome = open();
fill();
send();
await settle();
expect(statusText()).toBe('Diese Karte ist schon veröffentlicht.');
expect(errorText()).toBe('');
expect(form().querySelector('input')).toBeNull();
expect(buttonByText('Veröffentlichen')).toBeUndefined();
send();
expect(submit).toHaveBeenCalledOnce();
buttonByText('Schließen')!.click();
await expect(outcome).resolves.toBe('already');
expect(dialogEl()).toBeNull();
});
it('abgebrochene Anmeldung: Hinweis „Bitte melde dich an“', async () => {
submit.mockRejectedValue(new LoginCancelled());
void open();
fill();
send();
await settle();
expect(errorText()).toBe(LOGIN_NEEDED);
});
it('deutsche Meldungen aus dem Rendern und andere Fehler werden übersetzt', async () => {
submit.mockRejectedValueOnce(new Error(TOO_LARGE));
void open();
fill();
send();
await settle();
expect(errorText()).toBe(`Veröffentlichen fehlgeschlagen – ${TOO_LARGE}`);
submit.mockRejectedValueOnce(new Error(NOT_RENDERED));
send();
await settle();
expect(errorText()).toBe(`Veröffentlichen fehlgeschlagen – ${NOT_RENDERED}`);
submit.mockRejectedValueOnce(new TypeError('Failed to fetch'));
send();
await settle();
expect(errorText()).toBe('Veröffentlichen fehlgeschlagen – Keine Verbindung zum Server.');
submit.mockRejectedValueOnce(new Error('boom: interne Details'));
send();
await settle();
expect(errorText()).toBe('Veröffentlichen fehlgeschlagen – Etwas ist schiefgelaufen. Bitte nochmal versuchen.');
});
});
describe('Veröffentlichen-Dialog: Schließen', () => {
it('„Abbrechen“ schließt ohne zu senden', async () => {
const outcome = open();
fill();
buttonByText('Abbrechen')!.click();
await expect(outcome).resolves.toBe('cancelled');
expect(submit).not.toHaveBeenCalled();
expect(dialogEl()).toBeNull();
});
it('Esc (close-Ereignis) und Klick auf den Hintergrund schließen', async () => {
const first = open();
dialogEl()!.dispatchEvent(new Event('close'));
await expect(first).resolves.toBe('cancelled');
expect(dialogEl()).toBeNull();
const second = open();
dialogEl()!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
dialogEl()!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await expect(second).resolves.toBe('cancelled');
});
it('ein Markieren, das außerhalb endet, schließt nicht (Drücken im Formular, Loslassen auf dem Hintergrund)', () => {
void open();
input('title').dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
dialogEl()!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(dialogEl()?.open).toBe(true);
});
it('das Abbrechen-Signal (Seite verlassen) schließt den Dialog, auch während des Sendens', async () => {
const controller = new AbortController();
let finish!: (r: { id: string }) => void;
submit.mockReturnValue(new Promise((r) => (finish = r)));
const outcome = open({ signal: controller.signal });
fill();
send();
controller.abort();
await expect(outcome).resolves.toBe('cancelled');
expect(dialogEl()).toBeNull();
// Eine danach eintreffende Antwort bleibt folgenlos.
finish({ id: 'g1' });
await settle();
});
it('ein schon abgebrochenes Signal öffnet gar nichts', async () => {
const controller = new AbortController();
controller.abort();
await expect(open({ signal: controller.signal })).resolves.toBe('cancelled');
expect(dialogEl()).toBeNull();
});
it('ein Fehler nach dem Schließen durch das Signal wird verschluckt', async () => {
const controller = new AbortController();
let fail!: (e: Error) => void;
submit.mockReturnValue(new Promise((_, r) => (fail = r)));
const outcome = open({ signal: controller.signal });
fill();
send();
controller.abort();
await outcome;
fail(new ApiError(503, 'weg'));
await settle();
expect(dialogEl()).toBeNull();
});
it('der Fokus kehrt zum auslösenden Knopf zurück', async () => {
const opener = document.createElement('button');
document.body.append(opener);
opener.focus();
const outcome = open();
buttonByText('Abbrechen')!.click();
await outcome;
expect(document.activeElement).toBe(opener);
});
});
+264
View File
@@ -0,0 +1,264 @@
// Veröffentlichen-Dialog von „Meine Karten“ (Spec § 6): Titel, Herausgeber/Bank, Tags, Anzeigename und die Pflicht-Checkbox.
// Eigenes <dialog> wie report-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 { LOGIN_NEEDED, LoginCancelled } from '../account/with-login';
import { el } from '../editor/dom';
import { PIXELS_MESSAGE, READ_MESSAGE } from '../editor/upload';
import { ApiError, isAlreadyPublished } from './api';
import { NOT_RENDERED, TOO_LARGE, normalizeTags, type PublishForm } from './publish';
/** Wortlaut aus Spec § 6. */
export const CONSENT_TEXT = 'Das Bild gehört mir bzw. ich darf es verwenden, und es enthält keine echten Kartendaten.';
/** Wortlaut aus Spec § 11. */
export const ALREADY_PUBLISHED = 'Diese Karte ist schon veröffentlicht.';
const FAILED_PREFIX = 'Veröffentlichen fehlgeschlagen – ';
const RUNNING = 'Wird veröffentlicht …';
/** Grenzen wie der Server (deploy/api/app.js): Länge in UTF-16-Einheiten, nach dem Trimmen. */
const TITLE_MAX = 60;
const ISSUER_MAX = 40;
const DISPLAY_NAME_MAX = 30;
const TAGS_MAX = 5;
const TAG_MAX = 20;
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
export interface PublishFormValues extends PublishForm {
consent: boolean;
}
export type PublishField = 'title' | 'issuer' | 'tags' | 'displayName' | 'consent';
/** Das erste Problem in Formularreihenfolge (mit deutschem Text) oder null, wenn alles passt. Die Grenzen prüft der Server noch einmal. */
export function validatePublishForm(values: PublishFormValues): { field: PublishField; message: string } | null {
const title = values.title.trim();
if (title === '') return { field: 'title', message: 'Bitte gib einen Titel ein.' };
if (CONTROL_CHARACTERS.test(title)) return { field: 'title', message: 'Der Titel enthält ungültige Zeichen.' };
if (title.length > TITLE_MAX) return { field: 'title', message: `Der Titel ist zu lang (höchstens ${TITLE_MAX} Zeichen).` };
const issuer = values.issuer.trim();
if (CONTROL_CHARACTERS.test(issuer)) return { field: 'issuer', message: 'Herausgeber / Bank enthält ungültige Zeichen.' };
if (issuer.length > ISSUER_MAX) {
return { field: 'issuer', message: `Herausgeber / Bank ist zu lang (höchstens ${ISSUER_MAX} Zeichen).` };
}
const tags = normalizeTags(values.tags);
if (tags.some((tag) => CONTROL_CHARACTERS.test(tag))) return { field: 'tags', message: 'Tags enthalten ungültige Zeichen.' };
if (tags.some((tag) => tag.length > TAG_MAX)) {
return { field: 'tags', message: `Ein Tag darf höchstens ${TAG_MAX} Zeichen lang sein.` };
}
if (tags.length > TAGS_MAX) return { field: 'tags', message: `Bitte gib höchstens ${TAGS_MAX} Tags an.` };
const displayName = values.displayName.trim();
if (CONTROL_CHARACTERS.test(displayName)) {
return { field: 'displayName', message: 'Der Anzeigename enthält ungültige Zeichen.' };
}
if (displayName.length > DISPLAY_NAME_MAX) {
return { field: 'displayName', message: `Der Anzeigename ist zu lang (höchstens ${DISPLAY_NAME_MAX} Zeichen).` };
}
if (!values.consent) return { field: 'consent', message: 'Bitte bestätige die Erklärung zum Bild.' };
return null;
}
/** Meldungen aus dem Rendern und dem Bildlesen sind schon deutsch; alles andere übersetzt `errorMessage`. */
const GERMAN_MESSAGES = new Set([TOO_LARGE, NOT_RENDERED, READ_MESSAGE, PIXELS_MESSAGE]);
/** Der Text zu einem fehlgeschlagenen Veröffentlichen (Spec § 11: „Veröffentlichen fehlgeschlagen – …“). */
export function publishFailureText(err: unknown): string {
if (err instanceof LoginCancelled) return LOGIN_NEEDED;
if (err instanceof ApiError) return FAILED_PREFIX + err.message;
if (err instanceof Error && GERMAN_MESSAGES.has(err.message)) return FAILED_PREFIX + err.message;
return FAILED_PREFIX + errorMessage(err, 'save');
}
/** `published`: veröffentlicht; `already`: die Karte war schon veröffentlicht (409); `cancelled`: nichts veröffentlicht oder Dialog verlassen. */
export type PublishOutcome = 'published' | 'already' | 'cancelled';
export interface PublishDialogOptions {
/** Veröffentlicht die Karte (rendern, hochladen, API); wirft bei Fehler. */
submit: (form: PublishForm) => Promise<{ id: string }>;
/** Name der privaten Karte für den Einleitungstext (als Text). */
cardName?: 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. Fehler erscheinen im Dialog; er bleibt dann für einen neuen
* Versuch offen. Während des Sendens lässt er sich nicht schließen (Abbrechen gesperrt, Esc und Hintergrund wirkungslos),
* damit niemand glaubt, etwas abgebrochen zu haben, das dann doch veröffentlicht wird; nur `signal` schließt immer.
*/
export function publishDialog(opts: PublishDialogOptions): Promise<PublishOutcome> {
return new Promise<PublishOutcome>((resolve, reject) => {
if (opts.signal?.aborted) {
resolve('cancelled');
return;
}
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
let outcome: PublishOutcome = 'cancelled';
let closed = false;
let busy = false;
const text = (name: string, extra: Record<string, string> = {}) =>
el('input', { class: 'auth-input', type: 'text', name, id: `publish-${name}`, autocomplete: 'off', ...extra });
const titleInput = text('title', { 'aria-required': 'true' });
const issuerInput = text('issuer');
const tagsInput = text('tags', { 'aria-describedby': 'publish-tags-hint' });
const nameInput = text('displayName', { placeholder: 'Anonym', 'aria-describedby': 'publish-displayName-hint' });
const consentInput = el('input', { type: 'checkbox', name: 'consent', id: 'publish-consent' });
const inputs: Record<PublishField, HTMLInputElement> = {
title: titleInput,
issuer: issuerInput,
tags: tagsInput,
displayName: nameInput,
consent: consentInput,
};
const field = (label: string, control: HTMLInputElement, hint?: string) =>
el(
'div',
{ class: 'auth-field' },
el('label', { class: 'auth-label', for: control.id }, label),
control,
hint ? el('p', { class: 'auth-hint', id: `${control.id}-hint` }, hint) : '',
);
const intro = el(
'p',
{ class: 'report-text' },
opts.cardName
? `„${opts.cardName}“ wird mit Bild öffentlich in der Community-Galerie gezeigt.`
: 'Die Karte wird mit Bild öffentlich in der Community-Galerie gezeigt.',
);
const fields = el(
'div',
{ class: 'publish-fields' },
field('Titel (Pflicht)', titleInput),
field('Herausgeber / Bank (optional)', issuerInput),
field('Tags (optional)', tagsInput, 'Bis zu 5, mit Komma getrennt, z. B. „visa, gold“.'),
field('Anzeigename (optional)', nameInput, 'Leer = „Anonym“. Deine E-Mail-Adresse wird nie angezeigt.'),
el('label', { class: 'publish-consent' }, consentInput, el('span', {}, CONSENT_TEXT)),
);
const errorEl = el('p', { class: 'auth-msg auth-msg-error', role: 'alert' });
const statusEl = el('p', { class: 'auth-msg', role: 'status' });
const cancel = el('button', { type: 'button', class: 'auth-btn' }, 'Abbrechen');
const send = el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, 'Veröffentlichen');
const actions = el('div', { class: 'report-actions' }, cancel, send);
// novalidate: die Prüfung läuft selbst, mit deutschen Texten statt der des Browsers.
const form = el('form', { class: 'report-form', novalidate: true }, intro, fields, errorEl, statusEl, actions);
const dialog = el(
'dialog',
{ class: 'report-dialog publish-dialog', 'aria-labelledby': 'publish-title' },
el('div', { class: 'report-card' }, el('h2', { id: 'publish-title', class: 'report-title' }, 'Karte veröffentlichen'), 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 control of Object.values(inputs)) control.disabled = value;
send.disabled = value;
cancel.disabled = value;
statusEl.textContent = value ? RUNNING : '';
}
function clearInvalid(): void {
for (const control of Object.values(inputs)) control.removeAttribute('aria-invalid');
}
function showAlready(): void {
outcome = 'already';
errorEl.textContent = '';
statusEl.textContent = ALREADY_PUBLISHED;
intro.remove();
fields.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 values: PublishFormValues = {
title: titleInput.value.trim(),
issuer: issuerInput.value.trim(),
tags: tagsInput.value,
displayName: nameInput.value.trim(),
consent: consentInput.checked,
};
clearInvalid();
errorEl.textContent = '';
const problem = validatePublishForm(values);
if (problem) {
errorEl.textContent = problem.message;
inputs[problem.field].setAttribute('aria-invalid', 'true');
inputs[problem.field].focus();
return;
}
setBusy(true);
try {
await opts.submit({ title: values.title, issuer: values.issuer, tags: values.tags, displayName: values.displayName });
} catch (err) {
if (closed) return;
if (isAlreadyPublished(err)) {
setBusy(false);
showAlready();
return;
}
setBusy(false);
errorEl.textContent = publishFailureText(err);
return;
}
if (closed) return;
outcome = 'published';
finish();
}
form.addEventListener('submit', (event) => {
event.preventDefault();
if (busy || outcome !== 'cancelled') return;
void submit();
});
cancel.addEventListener('click', finish);
// Esc löst `cancel` aus (abbrechbar) und danach `close`; mitten im Senden darf der Dialog nicht zugehen.
dialog.addEventListener('cancel', (event) => {
if (busy) event.preventDefault();
});
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 && !busy) 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);
return;
}
titleInput.focus();
});
}
+2 -2
View File
@@ -11,8 +11,8 @@ import { isDefinitiveFailure, publishCard, type PublishInput } from './api';
/** Der Galerie-Bucket nimmt Dateien bis 5 MiB an (wie der Server prüft). */
export const PNG_MAX_BYTES = 5_242_880;
const TOO_LARGE = 'Das Kartenbild ist zu groß für die Galerie (max. 5 MB).';
const NOT_RENDERED = 'Das Kartenbild konnte nicht erzeugt werden.';
export const TOO_LARGE = 'Das Kartenbild ist zu groß für die Galerie (max. 5 MB).';
export const NOT_RENDERED = 'Das Kartenbild konnte nicht erzeugt werden.';
const GALLERY_TYPES = ['image/png', 'image/webp'];
export interface PublishForm {
+487
View File
@@ -3,6 +3,7 @@ import type { User } from '../account/auth';
import type { SavedCard } from '../account/cards';
import { DEFAULT_CONFIG } from '../card/config';
import { createStore, initialEditorState } from '../editor/state';
import type { GalleryCard } from '../community/gallery';
const mocks = vi.hoisted(() => ({
list: vi.fn(),
@@ -17,6 +18,9 @@ const mocks = vi.hoisted(() => ({
loadCardAssets: vi.fn(),
exportCard: vi.fn(),
saveBlobAs: vi.fn(),
publishSavedCard: vi.fn(),
withdrawCard: vi.fn(),
myPublications: vi.fn(),
authListeners: new Set<(user: User | null) => void>(),
}));
@@ -52,6 +56,17 @@ vi.mock('../account/cards', async (importOriginal) => ({
}),
}));
vi.mock('../community/publish', async (importOriginal) => ({
...(await importOriginal<typeof import('../community/publish')>()),
publishSavedCard: mocks.publishSavedCard,
}));
vi.mock('../community/api', async (importOriginal) => ({
...(await importOriginal<typeof import('../community/api')>()),
withdrawCard: mocks.withdrawCard,
}));
vi.mock('../community/gallery', () => ({ myPublications: mocks.myPublications }));
import { ApiError } from '../community/api';
import { mountCardsPage } from './cards-page';
const user: User = { $id: 'u1', email: 'a@b.de' };
@@ -86,6 +101,7 @@ beforeEach(() => {
store = createStore(initialEditorState());
mocks.getCurrentUser.mockResolvedValue(user);
mocks.requireLogin.mockResolvedValue(true);
mocks.myPublications.mockResolvedValue(new Map());
mocks.loadPreview.mockImplementation(async () => new Blob(['p'], { type: 'image/webp' }));
unmount = null;
});
@@ -621,3 +637,474 @@ describe('Meine Karten: Seite wird verlassen', () => {
});
});
const published = (id: string, sourceCardId: string, patch: Partial<GalleryCard> = {}): GalleryCard => ({
id,
title: 'Mein Motiv',
issuer: '',
tags: [],
displayName: '',
imageFileId: `gi-${id}`,
previewFileId: `gp-${id}`,
sourceCardId,
ownerId: 'u1',
hidden: false,
reportCount: 0,
createdAt: '2026-10-04T12:00:00.000Z',
...patch,
});
const labels = (scope: ParentNode) => [...scope.querySelectorAll('.card-buttons button, .card-buttons a')].map((n) => n.textContent);
const badge = (id: string) => tileFor(id).querySelector('.card-pub')?.textContent ?? '';
const publishDialogEl = () => document.querySelector<HTMLDialogElement>('dialog.publish-dialog');
const dlgInput = (name: string) => publishDialogEl()!.querySelector<HTMLInputElement>(`input[name="${name}"]`)!;
const sendForm = () =>
publishDialogEl()!.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
const fillDialog = (title = 'Mein Motiv', values: Record<string, string> = {}) => {
dlgInput('title').value = title;
for (const [name, value] of Object.entries(values)) dlgInput(name).value = value;
dlgInput('consent').checked = true;
};
const dlgError = () => publishDialogEl()!.querySelector('[role="alert"]')!.textContent;
const openPublish = async (id: string) => {
await vi.waitFor(() => expect(button(tileFor(id), 'Veröffentlichen').disabled).toBe(false));
button(tileFor(id), 'Veröffentlichen').click();
await vi.waitFor(() => expect(publishDialogEl()?.open).toBe(true));
};
describe('Meine Karten: Veröffentlichung (Status an der Kachel)', () => {
const pubMap = (...cards: GalleryCard[]) => new Map(cards.map((c) => [c.sourceCardId, c]));
const view = (id: string) => tileFor(id).querySelector<HTMLAnchorElement>('a[data-action="view"]');
const mountWith = async (publications: Map<string, GalleryCard> = new Map()) => {
mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]);
mocks.myPublications.mockResolvedValue(publications);
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(2));
await vi.waitFor(() => expect(button(tileFor('a'), 'Löschen')).toBeTruthy());
await vi.waitFor(() => expect(tileFor('a').querySelector('[data-action="publish"], [data-action="withdraw"]')).not.toBeNull());
};
it('lädt den Status einmal je Seitenaufbau (für die angemeldete Nutzer-ID), nicht abgemeldet', async () => {
await mountWith();
expect(mocks.myPublications).toHaveBeenCalledTimes(1);
expect(mocks.myPublications).toHaveBeenCalledWith('u1');
unmount?.();
unmount = null;
mocks.myPublications.mockClear();
mocks.getCurrentUser.mockResolvedValue(null);
mount();
await vi.waitFor(() => expect(text()).toContain('Melde dich an'));
expect(mocks.myPublications).not.toHaveBeenCalled();
});
it('nicht veröffentlicht: „Veröffentlichen“, kein Badge', async () => {
await mountWith();
expect(labels(tileFor('a'))).toEqual(['Öffnen', 'Herunterladen', 'Umbenennen', 'Veröffentlichen', 'Löschen']);
expect(badge('a')).toBe('');
expect(view('a')).toBeNull();
});
it('veröffentlicht: Badge „Veröffentlicht“, „Ansehen“ (/community/<id>) und „Zurückziehen“ statt „Veröffentlichen“', async () => {
await mountWith(pubMap(published('g1', 'a')));
expect(badge('a')).toBe('Veröffentlicht');
expect(labels(tileFor('a'))).toEqual(['Öffnen', 'Herunterladen', 'Umbenennen', 'Ansehen', 'Zurückziehen', 'Löschen']);
expect(view('a')!.getAttribute('href')).toBe('/community/g1');
expect(view('a')!.hasAttribute('data-link')).toBe(true);
// Die andere Kachel bleibt unberührt.
expect(badge('b')).toBe('');
expect(labels(tileFor('b'))).toContain('Veröffentlichen');
});
it('ausgeblendet (gemeldet): Badge „Ausgeblendet (gemeldet)“ und „Zurückziehen“, aber kein „Ansehen“ und kein „Veröffentlichen“', async () => {
await mountWith(pubMap(published('g1', 'a', { hidden: true, reportCount: 3 })));
expect(badge('a')).toBe('Ausgeblendet (gemeldet)');
expect(labels(tileFor('a'))).toEqual(['Öffnen', 'Herunterladen', 'Umbenennen', 'Zurückziehen', 'Löschen']);
expect(view('a')).toBeNull();
});
it('solange der Status lädt, gibt es weder „Veröffentlichen“ noch „Zurückziehen“; danach erscheint der Stand', async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
let finish!: (m: Map<string, GalleryCard>) => void;
mocks.myPublications.mockReturnValue(new Promise((r) => (finish = r)));
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
expect(labels(tileFor('a'))).toEqual(['Öffnen', 'Herunterladen', 'Umbenennen', 'Löschen']);
finish(pubMap(published('g1', 'a')));
await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht'));
});
it('Status lässt sich nicht laden: Hinweis mit „Nochmal versuchen“, „Veröffentlichen“ bleibt möglich (409 fängt Doppeltes ab)', async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
mocks.myPublications.mockRejectedValueOnce(new TypeError('offline'));
mount();
await vi.waitFor(() => expect(text()).toContain('Der Veröffentlichungsstatus konnte nicht geladen werden.'));
expect(labels(tileFor('a'))).toContain('Veröffentlichen');
mocks.myPublications.mockResolvedValue(pubMap(published('g1', 'a')));
button(root, 'Nochmal versuchen').click();
await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht'));
expect(text()).not.toContain('konnte nicht geladen werden');
});
it('Abmelden räumt den Status weg, nach dem Anmelden wird er neu geholt', async () => {
await mountWith(pubMap(published('g1', 'a')));
for (const fn of mocks.authListeners) fn(null);
expect(tiles()).toHaveLength(0);
mocks.myPublications.mockResolvedValue(new Map());
for (const fn of mocks.authListeners) fn(user);
await vi.waitFor(() => expect(tiles()).toHaveLength(2));
await vi.waitFor(() => expect(labels(tileFor('a'))).toContain('Veröffentlichen'));
expect(badge('a')).toBe('');
expect(mocks.myPublications).toHaveBeenCalledTimes(2);
});
it('der Status überlebt Umbenennen (Liste wird neu aufgebaut) ohne neue Anfrage', async () => {
mocks.rename.mockImplementation(async (id: string, name: string) => card(id, name, { updatedAt: '2026-10-06T08:00:00.000Z' }));
await mountWith(pubMap(published('g1', 'a')));
button(tileFor('a'), 'Umbenennen').click();
const input = tileFor('a').querySelector<HTMLInputElement>('input')!;
input.value = 'Anders';
submit(input.closest('form')!);
await vi.waitFor(() => expect(tileFor('a').querySelector('.card-name')!.textContent).toBe('Anders'));
expect(badge('a')).toBe('Veröffentlicht');
expect(mocks.myPublications).toHaveBeenCalledTimes(1);
});
it('Löschen einer veröffentlichten Karte weist darauf hin, dass die Veröffentlichung bleibt', async () => {
await mountWith(pubMap(published('g1', 'a')));
button(tileFor('a'), 'Löschen').click();
await vi.waitFor(() => expect(dialog()?.open).toBe(true));
expect(dialog()!.textContent).toContain('bleibt in der Community-Galerie');
button(dialog()!, 'Abbrechen').click();
await vi.waitFor(() => expect(dialog()).toBeNull());
button(tileFor('b'), 'Löschen').click();
await vi.waitFor(() => expect(dialog()?.open).toBe(true));
expect(dialog()!.textContent).not.toContain('Community-Galerie');
});
});
describe('Meine Karten: Veröffentlichen', () => {
const start = async () => {
mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]);
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(2));
await vi.waitFor(() => expect(labels(tileFor('a'))).toContain('Veröffentlichen'));
};
it('„Veröffentlichen“ öffnet den Dialog: leere Felder, nie die E-Mail im Anzeigenamen', async () => {
await start();
await openPublish('a');
expect(dlgInput('title').value).toBe('');
expect(dlgInput('displayName').value).toBe('');
expect(dlgInput('displayName').placeholder).toBe('Anonym');
expect(publishDialogEl()!.textContent).not.toContain('a@b.de');
expect(publishDialogEl()!.textContent).toContain('„Alpha“');
expect(mocks.publishSavedCard).not.toHaveBeenCalled();
});
it('Abbrechen veröffentlicht nichts, Fokus zurück auf „Veröffentlichen“', async () => {
await start();
await openPublish('a');
button(publishDialogEl()!, 'Abbrechen').click();
await vi.waitFor(() => expect(publishDialogEl()).toBeNull());
expect(mocks.publishSavedCard).not.toHaveBeenCalled();
await vi.waitFor(() => expect(document.activeElement).toBe(button(tileFor('a'), 'Veröffentlichen')));
});
it('ohne Pflicht-Checkbox wird nichts veröffentlicht', async () => {
await start();
await openPublish('a');
dlgInput('title').value = 'Mein Motiv';
sendForm();
expect(dlgError()).toBe('Bitte bestätige die Erklärung zum Bild.');
expect(mocks.publishSavedCard).not.toHaveBeenCalled();
});
it('Erfolg: Badge „Veröffentlicht“ und „Ansehen“ erscheinen sofort, ohne die Liste neu zu laden', async () => {
mocks.publishSavedCard.mockResolvedValue({ id: 'g9' });
await start();
await openPublish('a');
fillDialog('Mein Motiv', { issuer: 'Beispielbank', tags: 'Visa, Gold', displayName: 'Kenso' });
sendForm();
await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht'));
expect(mocks.publishSavedCard).toHaveBeenCalledWith(
expect.objectContaining({ id: 'a', name: 'Alpha' }),
{ title: 'Mein Motiv', issuer: 'Beispielbank', tags: 'Visa, Gold', displayName: 'Kenso' },
);
expect(publishDialogEl()).toBeNull();
const link = tileFor('a').querySelector<HTMLAnchorElement>('a[data-action="view"]')!;
expect(link.textContent).toBe('Ansehen');
expect(link.getAttribute('href')).toBe('/community/g9');
expect(labels(tileFor('a'))).toEqual(['Öffnen', 'Herunterladen', 'Umbenennen', 'Ansehen', 'Zurückziehen', 'Löschen']);
// Alle Knöpfe der Kachel sind wieder bedienbar, die andere Kachel ist unberührt.
expect(button(tileFor('a'), 'Zurückziehen').disabled).toBe(false);
expect(labels(tileFor('b'))).toContain('Veröffentlichen');
expect(mocks.list).toHaveBeenCalledTimes(1);
expect(mocks.myPublications).toHaveBeenCalledTimes(1);
// Der Fokus geht auf „Ansehen“ (der auslösende Knopf ist weg).
expect(document.activeElement).toBe(link);
});
it('während des Veröffentlichens sind die Kachel-Knöpfe gesperrt und der Dialog lässt sich nicht schließen', async () => {
let finish!: (r: { id: string }) => void;
mocks.publishSavedCard.mockReturnValue(new Promise((r) => (finish = r)));
await start();
await openPublish('a');
fillDialog();
sendForm();
expect(button(publishDialogEl()!, 'Veröffentlichen').disabled).toBe(true);
expect(button(tileFor('a'), 'Löschen').disabled).toBe(true);
expect(tileFor('a').getAttribute('aria-busy')).toBe('true');
finish({ id: 'g9' });
await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht'));
expect(tileFor('a').hasAttribute('aria-busy')).toBe(false);
});
it('Fehler (400): Text im Dialog, kein Status geändert, keine Nachprüfung, neuer Versuch möglich', async () => {
mocks.publishSavedCard.mockRejectedValueOnce(new ApiError(400, 'Der Titel enthält ungültige Zeichen.'));
await start();
await openPublish('a');
fillDialog();
sendForm();
await vi.waitFor(() => expect(dlgError()).toBe('Veröffentlichen fehlgeschlagen – Der Titel enthält ungültige Zeichen.'));
expect(publishDialogEl()?.open).toBe(true);
expect(badge('a')).toBe('');
expect(mocks.myPublications).toHaveBeenCalledTimes(1);
mocks.publishSavedCard.mockResolvedValueOnce({ id: 'g9' });
sendForm();
await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht'));
});
it('409: „Diese Karte ist schon veröffentlicht.“, der Status wird neu geholt und zeigt „Veröffentlicht“', async () => {
mocks.publishSavedCard.mockRejectedValue(new ApiError(409, 'Diese Karte ist schon veröffentlicht.'));
await start();
await openPublish('a');
fillDialog();
mocks.myPublications.mockResolvedValue(new Map([['a', published('g1', 'a')]]));
sendForm();
await vi.waitFor(() => expect(publishDialogEl()!.textContent).toContain('Diese Karte ist schon veröffentlicht.'));
await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht'));
expect(mocks.myPublications).toHaveBeenCalledTimes(2);
button(publishDialogEl()!, 'Schließen').click();
await vi.waitFor(() => expect(publishDialogEl()).toBeNull());
expect(tileFor('a').querySelector<HTMLAnchorElement>('a[data-action="view"]')!.getAttribute('href')).toBe('/community/g1');
expect(mocks.publishSavedCard).toHaveBeenCalledOnce();
});
describe('Ausgang offen (503, 429, 408, 5xx): Nachprüfung vor der Fehlermeldung', () => {
it('war die Karte doch veröffentlicht: Erfolgszustand statt Fehler', async () => {
mocks.publishSavedCard.mockRejectedValue(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.'));
await start();
await openPublish('a');
fillDialog();
mocks.myPublications.mockResolvedValue(new Map([['a', published('g1', 'a')]]));
sendForm();
await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht'));
expect(publishDialogEl()).toBeNull();
expect(tileFor('a').querySelector<HTMLAnchorElement>('a[data-action="view"]')!.getAttribute('href')).toBe('/community/g1');
expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe('');
expect(mocks.myPublications).toHaveBeenCalledTimes(2);
});
it.each([408, 429, 500, 504])('auch bei %i wird nachgeprüft', async (code) => {
mocks.publishSavedCard.mockRejectedValue(new ApiError(code, 'Offen.'));
await start();
await openPublish('a');
fillDialog();
mocks.myPublications.mockResolvedValue(new Map([['a', published('g1', 'a')]]));
sendForm();
await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht'));
});
it('war sie es nicht: Fehlermeldung im Dialog, Status unverändert', async () => {
mocks.publishSavedCard.mockRejectedValue(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.'));
await start();
await openPublish('a');
fillDialog();
sendForm();
await vi.waitFor(() =>
expect(dlgError()).toBe('Veröffentlichen fehlgeschlagen – Der Dienst ist gerade nicht erreichbar.'),
);
expect(mocks.myPublications).toHaveBeenCalledTimes(2);
expect(badge('a')).toBe('');
expect(publishDialogEl()?.open).toBe(true);
});
it('scheitert auch die Nachprüfung: die ursprüngliche Meldung bleibt', async () => {
mocks.publishSavedCard.mockRejectedValue(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.'));
await start();
await openPublish('a');
fillDialog();
mocks.myPublications.mockRejectedValue(new TypeError('offline'));
sendForm();
await vi.waitFor(() =>
expect(dlgError()).toBe('Veröffentlichen fehlgeschlagen – Der Dienst ist gerade nicht erreichbar.'),
);
expect(badge('a')).toBe('');
});
it('die Nachprüfung findet nur die Veröffentlichung dieser Karte (eine andere zählt nicht)', async () => {
mocks.publishSavedCard.mockRejectedValue(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.'));
await start();
await openPublish('a');
fillDialog();
mocks.myPublications.mockResolvedValue(new Map([['b', published('g2', 'b')]]));
sendForm();
await vi.waitFor(() => expect(dlgError()).toContain('Der Dienst ist gerade nicht erreichbar.'));
expect(badge('a')).toBe('');
expect(badge('b')).toBe('Veröffentlicht');
});
});
it('abgelaufene Sitzung (401): Anmelden, dann wird veröffentlicht', async () => {
mocks.publishSavedCard
.mockRejectedValueOnce(Object.assign(new Error('Unauthorized'), { code: 401 }))
.mockResolvedValueOnce({ id: 'g9' });
await start();
await openPublish('a');
fillDialog();
sendForm();
await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht'));
expect(mocks.requireLogin).toHaveBeenCalledOnce();
expect(mocks.publishSavedCard).toHaveBeenCalledTimes(2);
});
it('Anmeldung abgebrochen: Hinweis im Dialog, nichts veröffentlicht', async () => {
mocks.requireLogin.mockResolvedValue(false);
mocks.publishSavedCard.mockRejectedValue(Object.assign(new Error('Unauthorized'), { code: 401 }));
await start();
await openPublish('a');
fillDialog();
sendForm();
await vi.waitFor(() => expect(dlgError()).toBe('Bitte melde dich an, um fortzufahren.'));
expect(badge('a')).toBe('');
});
it('die Seite wird verlassen: der Dialog geht zu, eine spätere Antwort ändert nichts mehr', async () => {
let finish!: (r: { id: string }) => void;
mocks.publishSavedCard.mockReturnValue(new Promise((r) => (finish = r)));
await start();
await openPublish('a');
fillDialog();
sendForm();
unmount?.();
unmount = null;
expect(publishDialogEl()).toBeNull();
finish({ id: 'g9' });
await new Promise((r) => setTimeout(r, 0));
expect(root.querySelector('.card-badge')).toBeNull();
});
it('die Seite wird verlassen, solange der Dialog offen ist: er geht zu, nichts wird veröffentlicht', async () => {
await start();
await openPublish('a');
unmount?.();
unmount = null;
expect(publishDialogEl()).toBeNull();
expect(mocks.publishSavedCard).not.toHaveBeenCalled();
});
it('Titel und Namen erscheinen als Text, nie als HTML', async () => {
mocks.list.mockResolvedValue([card('a', '<img src=x onerror=alert(1)>')]);
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
await openPublish('a');
expect(publishDialogEl()!.querySelector('img')).toBeNull();
expect(publishDialogEl()!.textContent).toContain('<img src=x onerror=alert(1)>');
});
});
describe('Meine Karten: Zurückziehen', () => {
const start = async (patch: Partial<GalleryCard> = {}) => {
mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]);
mocks.myPublications.mockResolvedValue(new Map([['a', published('g1', 'a', patch)]]));
mount();
await vi.waitFor(() => expect(tiles()).toHaveLength(2));
await vi.waitFor(() => expect(labels(tileFor('a'))).toContain('Zurückziehen'));
};
const openConfirm = async () => {
button(tileFor('a'), 'Zurückziehen').click();
await vi.waitFor(() => expect(dialog()?.open).toBe(true));
};
it('fragt nach, zieht dann die Veröffentlichung (Galerie-ID) zurück; die Kachel zeigt wieder „Veröffentlichen“', async () => {
mocks.withdrawCard.mockResolvedValue(undefined);
await start();
await openConfirm();
expect(dialog()!.querySelector('h2')!.textContent).toBe('Karte „Mein Motiv“ zurückziehen?');
expect(dialog()!.textContent).toContain('Deine private Karte bleibt erhalten.');
expect(mocks.withdrawCard).not.toHaveBeenCalled();
button(dialog()!, 'Zurückziehen').click();
await vi.waitFor(() => expect(labels(tileFor('a'))).toContain('Veröffentlichen'));
expect(mocks.withdrawCard).toHaveBeenCalledWith('g1');
expect(badge('a')).toBe('');
expect(labels(tileFor('a'))).toEqual(['Öffnen', 'Herunterladen', 'Umbenennen', 'Veröffentlichen', 'Löschen']);
expect(dialog()).toBeNull();
expect(mocks.list).toHaveBeenCalledTimes(1);
expect(mocks.myPublications).toHaveBeenCalledTimes(1);
// Fokus auf dem neuen „Veröffentlichen“-Knopf.
expect(document.activeElement).toBe(button(tileFor('a'), 'Veröffentlichen'));
});
it('ausgeblendete Kacheln behalten „Zurückziehen“: danach lässt sich neu veröffentlichen', async () => {
mocks.withdrawCard.mockResolvedValue(undefined);
await start({ hidden: true, reportCount: 3 });
expect(badge('a')).toBe('Ausgeblendet (gemeldet)');
await openConfirm();
button(dialog()!, 'Zurückziehen').click();
await vi.waitFor(() => expect(labels(tileFor('a'))).toContain('Veröffentlichen'));
expect(mocks.withdrawCard).toHaveBeenCalledWith('g1');
expect(badge('a')).toBe('');
});
it('„Abbrechen“ zieht nichts zurück', async () => {
await start();
await openConfirm();
button(dialog()!, 'Abbrechen').click();
await vi.waitFor(() => expect(dialog()).toBeNull());
expect(mocks.withdrawCard).not.toHaveBeenCalled();
expect(badge('a')).toBe('Veröffentlicht');
await vi.waitFor(() => expect(button(tileFor('a'), 'Zurückziehen').disabled).toBe(false));
});
it('Fehler: Text des Dienstes an der Kachel, Status bleibt', async () => {
mocks.withdrawCard.mockRejectedValue(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.'));
await start();
await openConfirm();
button(dialog()!, 'Zurückziehen').click();
await vi.waitFor(() =>
expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe('Der Dienst ist gerade nicht erreichbar.'),
);
expect(badge('a')).toBe('Veröffentlicht');
expect(button(tileFor('a'), 'Zurückziehen').disabled).toBe(false);
});
it('die Veröffentlichung gibt es schon nicht mehr (404): Ziel erreicht, die Kachel zeigt „Veröffentlichen“', async () => {
mocks.withdrawCard.mockRejectedValue(new ApiError(404, 'Diese Karte gibt es nicht.'));
await start();
await openConfirm();
button(dialog()!, 'Zurückziehen').click();
await vi.waitFor(() => expect(labels(tileFor('a'))).toContain('Veröffentlichen'));
expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe('');
});
it('abgelaufene Sitzung (401): Anmelden, dann wird zurückgezogen', async () => {
mocks.withdrawCard
.mockRejectedValueOnce(new ApiError(401, 'Bitte melde dich an.'))
.mockResolvedValueOnce(undefined);
await start();
await openConfirm();
button(dialog()!, 'Zurückziehen').click();
await vi.waitFor(() => expect(labels(tileFor('a'))).toContain('Veröffentlichen'));
expect(mocks.requireLogin).toHaveBeenCalledOnce();
expect(mocks.withdrawCard).toHaveBeenCalledTimes(2);
});
it('Seite verlassen mit offener Rückfrage: nichts wird zurückgezogen', async () => {
await start();
await openConfirm();
unmount?.();
unmount = null;
expect(dialog()).toBeNull();
await new Promise((r) => setTimeout(r, 0));
expect(mocks.withdrawCard).not.toHaveBeenCalled();
});
});
+195 -7
View File
@@ -1,4 +1,5 @@
// Seite /karten („Meine Karten“): Kacheln mit Vorschau; Öffnen, Herunterladen, Umbenennen, Löschen.
// Seite /karten („Meine Karten“): Kacheln mit Vorschau; Öffnen, Herunterladen, Umbenennen, Löschen,
// Veröffentlichen in der Community-Galerie und Zurückziehen (Status je Kachel aus den eigenen Veröffentlichungen).
import '../account/account.css';
import './cards.css';
import { getCurrentUser, onAuthChange } from '../account/auth';
@@ -9,6 +10,10 @@ import { LOGIN_NEEDED, LoginCancelled, withLogin } from '../account/with-login';
import { clampCrop } from '../card/crop';
import { CARD_W } from '../card/geometry';
import { exportCard, loadCardAssets } from '../card/render';
import { ApiError, isAlreadyPublished, isDefinitiveFailure, withdrawCard } from '../community/api';
import { myPublications } from '../community/gallery';
import { publishSavedCard, type PublishForm } from '../community/publish';
import { publishDialog } from '../community/publish-dialog';
import { confirmDialog } from '../editor/confirm-dialog';
import { el } from '../editor/dom';
import { downloadFileName, pngSizeWarning, saveBlobAs } from '../editor/download';
@@ -21,6 +26,15 @@ const LOADING = 'Karten werden geladen …';
const LOGGED_OUT = 'Melde dich an, um deine Karten zu sehen.';
const EMPTY = 'Noch keine Karten gespeichert.';
const PREVIEW_PARALLEL = 4;
const STATUS_FAILED = 'Der Veröffentlichungsstatus konnte nicht geladen werden.';
const PUBLISHED_NOTE = 'Die veröffentlichte Karte bleibt in der Community-Galerie, bis du sie zurückziehst.';
/** Was die Kachel über die Veröffentlichung wissen muss. */
interface Publication {
id: string;
title: string;
hidden: boolean;
}
export function mountCardsPage(root: HTMLElement, store: EditorStore): () => void {
const service = cardsService();
@@ -30,6 +44,16 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
let loadToken = 0;
let cards: SavedCard[] = [];
// Veröffentlichungen: einmal je Seitenaufbau geladen (`myPublications`), danach lokal nachgeführt.
// `loading`: noch unbekannt (keine Veröffentlichen-/Zurückziehen-Knöpfe); `error`: Laden gescheitert (Veröffentlichen bleibt
// möglich, ein Doppeltes fängt der Dienst mit 409 ab).
let userId = '';
let publications = new Map<string, Publication>();
let publicationsState: 'loading' | 'ready' | 'error' = 'loading';
let publicationsToken = 0;
const tileUpdaters = new Map<string, () => void>();
let statusNotice: HTMLElement | null = null;
// Vorschaubilder: Object-URLs je Vorschau-Datei, beim Verlassen der Seite (und wenn sie nicht mehr gebraucht werden) freigegeben.
const previewUrls = new Map<string, string>();
const previewFailed = new Set<string>();
@@ -114,6 +138,10 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
function showLoggedOut(): void {
state = 'loggedOut';
cards = [];
publications = new Map();
publicationsToken += 1; // eine noch laufende Abfrage gehört dem alten Nutzer
tileUpdaters.clear();
statusNotice = null;
queue.length = 0;
previewQueued.clear();
thumbSlots.clear();
@@ -147,6 +175,8 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
function renderList(focusId?: string, focusAction?: string): void {
state = 'list';
thumbSlots.clear();
tileUpdaters.clear();
statusNotice = null;
if (cards.length === 0) {
const toEditor = el('a', { class: 'auth-btn auth-btn-primary cards-link-btn', href: '/gestalten', 'data-link': true }, 'Zum Editor');
show(el('p', { class: 'cards-msg' }, EMPTY), el('p', { class: 'cards-actions' }, toEditor));
@@ -154,7 +184,9 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
}
const grid = el('ul', { class: 'cards-grid' });
for (const card of cards) grid.append(buildTile(card));
show(grid);
statusNotice = el('p', { class: 'cards-msg cards-status', role: 'status' });
show(statusNotice, grid);
updateStatusNotice();
for (const card of cards) {
fillThumb(card);
queuePreview(card);
@@ -165,6 +197,78 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
}
}
// ---- Veröffentlichungen ----
function updateStatusNotice(): void {
if (!statusNotice) return;
if (publicationsState !== 'error') {
statusNotice.replaceChildren();
return;
}
const retry = el('button', { type: 'button', class: 'auth-btn auth-btn-small' }, 'Nochmal versuchen');
retry.addEventListener('click', () => {
publicationsState = 'loading';
updateAllTiles();
void fetchPublications();
});
statusNotice.replaceChildren(`${STATUS_FAILED} `, retry);
}
function updateAllTiles(): void {
for (const update of tileUpdaters.values()) update();
updateStatusNotice();
}
/** Holt die eigenen Veröffentlichungen neu. Wahr, wenn der Stand jetzt stimmt; bei Fehler bleibt der bisherige Stand. */
async function fetchPublications(): Promise<boolean> {
const token = ++publicationsToken;
try {
const found = await myPublications(userId);
if (disposed || token !== publicationsToken) return false;
const next = new Map<string, Publication>();
for (const [cardId, entry] of found) next.set(cardId, { id: entry.id, title: entry.title, hidden: entry.hidden });
publications = next;
publicationsState = 'ready';
} catch {
if (disposed || token !== publicationsToken) return false;
if (publicationsState === 'loading') publicationsState = 'error';
updateAllTiles();
return false;
}
updateAllTiles();
return true;
}
/** Trägt eine Veröffentlichung (oder ihr Ende) ein und aktualisiert nur diese Kachel. */
function setPublication(cardId: string, entry: Publication | null): void {
publicationsToken += 1; // eine noch laufende Abfrage kann älter sein als diese Änderung
if (entry) publications.set(cardId, entry);
else publications.delete(cardId);
tileUpdaters.get(cardId)?.();
}
/**
* Veröffentlicht die Karte. Ist sie schon veröffentlicht (409), wird der Status neu geholt (der Dialog zeigt den Text).
* Bleibt der Ausgang offen (503, 429, 408, 5xx: die Veröffentlichung kann trotzdem angelegt worden sein), wird vor
* der Fehlermeldung nachgesehen und bei Erfolg der Erfolgszustand gezeigt.
*/
async function publishCard(card: SavedCard, form: PublishForm): Promise<{ id: string }> {
try {
const { id } = await withLogin(() => publishSavedCard(card, form));
if (!disposed) setPublication(card.id, { id, title: form.title.trim(), hidden: false });
return { id };
} catch (err) {
if (isAlreadyPublished(err)) {
await fetchPublications();
throw err;
}
if (isDefinitiveFailure(err)) throw err;
const current = (await fetchPublications()) ? publications.get(card.id) : undefined;
if (!current) throw err;
return { id: current.id };
}
}
// ---- Kacheln und Aktionen ----
function buildTile(card: SavedCard): HTMLElement {
@@ -179,7 +283,10 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
const downloadButton = action('download', 'Herunterladen');
const renameButton = action('rename', 'Umbenennen');
const deleteButton = action('delete', 'Löschen', 'auth-btn auth-btn-small auth-btn-danger');
const buttons = [openButton, downloadButton, renameButton, deleteButton];
// Die Knöpfe zur Veröffentlichung wechseln mit dem Status; die übrigen bleiben als dieselben Elemente stehen (Fokus).
const publishSlot = el('span', { class: 'card-pub-controls' });
const badge = el('p', { class: 'card-pub' });
const buttonRow = el('div', { class: 'card-buttons' }, openButton, downloadButton, renameButton, publishSlot, deleteButton);
const tile = el(
'li',
@@ -187,7 +294,8 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
thumb,
nameSlot,
date ? el('p', { class: 'card-date' }, el('time', { datetime: card.updatedAt }, date)) : '',
el('div', { class: 'card-buttons' }, ...buttons),
badge,
buttonRow,
message,
);
@@ -196,7 +304,8 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
const label = busyButton?.textContent ?? '';
message.textContent = '';
message.classList.remove('is-warning');
for (const b of buttons) b.disabled = true;
const buttons = () => buttonRow.querySelectorAll('button');
for (const b of buttons()) b.disabled = true;
tile.setAttribute('aria-busy', 'true');
if (busyButton && busyText) busyButton.textContent = busyText;
try {
@@ -205,11 +314,82 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
if (!disposed) message.textContent = failureText(err);
} finally {
if (busyButton && busyText) busyButton.textContent = label;
for (const b of buttons) b.disabled = false;
// Auch Knöpfe, die erst während der Aktion entstanden sind (neuer Status), sind danach wieder bedienbar.
for (const b of buttons()) b.disabled = false;
tile.removeAttribute('aria-busy');
}
}
/** Nach einer Aktion mit Dialog: Fokus auf den ersten passenden Knopf (der auslösende kann ersetzt worden sein). */
function focusControl(selector: string): void {
if (disposed || !tile.isConnected) return;
tile.querySelector<HTMLElement>(selector)?.focus();
}
function renderPublication(): void {
const entry = publications.get(card.id);
badge.replaceChildren();
const controls: HTMLElement[] = [];
if (entry) {
badge.append(
el('span', { class: entry.hidden ? 'card-badge is-hidden' : 'card-badge' }, entry.hidden ? 'Ausgeblendet (gemeldet)' : 'Veröffentlicht'),
);
// Ausgeblendet sehen nur Autor und Admin; „Ansehen“ gibt es dann nicht, „Zurückziehen“ bleibt (aufräumen, neu veröffentlichen).
if (!entry.hidden) {
controls.push(
el(
'a',
{
class: 'auth-btn auth-btn-small cards-link-btn',
href: `/community/${encodeURIComponent(entry.id)}`,
'data-link': true,
'data-action': 'view',
},
'Ansehen',
),
);
}
const withdrawButton = action('withdraw', 'Zurückziehen');
withdrawButton.addEventListener('click', () => startWithdraw(entry));
controls.push(withdrawButton);
} else if (publicationsState !== 'loading') {
const publishButton = action('publish', 'Veröffentlichen');
publishButton.addEventListener('click', () => startPublish());
controls.push(publishButton);
}
publishSlot.replaceChildren(...controls);
}
function startPublish(): void {
void run(async () => {
await publishDialog({ cardName: card.name, signal: disposal.signal, submit: (form) => publishCard(card, form) });
}).then(() => focusControl('[data-action="view"], [data-action="withdraw"], [data-action="publish"]'));
}
function startWithdraw(entry: Publication): void {
void run(async () => {
const confirmed = await confirmDialog(
{
title: `Karte „${entry.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',
},
disposal.signal,
);
if (!confirmed) return;
try {
await withLogin(() => withdrawCard(entry.id));
} catch (err) {
// Die Veröffentlichung gibt es schon nicht mehr (anderer Tab, früherer Versuch mit verlorener Antwort): Ziel erreicht.
if (!(err instanceof ApiError && err.code === 404)) throw err;
}
if (!disposed) setPublication(card.id, null);
}).then(() => focusControl('[data-action="publish"], [data-action="withdraw"]'));
}
tileUpdaters.set(card.id, renderPublication);
renderPublication();
openButton.addEventListener('click', () => navigate(`/gestalten?karte=${encodeURIComponent(card.id)}`));
downloadButton.addEventListener('click', () => {
@@ -235,7 +415,9 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
const confirmed = await confirmDialog(
{
title: `Karte „${card.name}“ löschen?`,
text: 'Die Karte und ihr Bild werden endgültig gelöscht.',
text: publications.has(card.id)
? `Die Karte und ihr Bild werden endgültig gelöscht. ${PUBLISHED_NOTE}`
: 'Die Karte und ihr Bild werden endgültig gelöscht.',
confirmLabel: 'Löschen',
},
disposal.signal,
@@ -320,6 +502,7 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
function failureText(err: unknown): string {
if (err instanceof LoginCancelled) return LOGIN_NEEDED;
if (err instanceof ApiError) return err.message; // der Dienst antwortet auf Deutsch
if (err instanceof Error && (err.message === READ_MESSAGE || err.message === PIXELS_MESSAGE)) return err.message;
return errorMessage(err, 'load');
}
@@ -340,9 +523,14 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
const list = await withLogin(() => service.list());
if (disposed || token !== loadToken) return;
cards = list;
userId = user.$id;
publications = new Map();
publicationsState = 'loading';
revokePreviews(new Set(list.map((c) => c.previewFileId)));
previewFailed.clear();
renderList();
// Der Status kommt nach: die Kacheln stehen sofort, die Knöpfe zur Veröffentlichung folgen.
void fetchPublications();
} catch (err) {
if (disposed || token !== loadToken) return;
if (err instanceof LoginCancelled) showLoggedOut();
+40
View File
@@ -17,6 +17,18 @@
margin: 0;
}
/* Hinweis, wenn der Veröffentlichungsstatus nicht geladen werden konnte (leer = unsichtbar) */
.cards-status {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
}
.cards-status:empty {
display: none;
}
.cards-link-btn {
display: inline-flex;
align-items: center;
@@ -83,6 +95,34 @@
gap: 8px;
}
/* Die Knöpfe zur Veröffentlichung wechseln als Gruppe; im Layout verhalten sie sich wie direkte Kinder von .card-buttons. */
.card-pub-controls {
display: contents;
}
.card-pub {
margin: 0;
}
.card-pub:empty {
display: none;
}
.card-badge {
display: inline-block;
padding: 3px 10px;
border: 1px solid var(--ok-text);
border-radius: 999px;
color: var(--ok-text);
font-size: 0.85rem;
font-weight: 600;
}
.card-badge.is-hidden {
border-color: var(--warn-text);
color: var(--warn-text);
}
.card-msg {
margin: 0;
color: var(--danger-text);