diff --git a/web/src/community/community.css b/web/src/community/community.css index 80b2d9a..3144d8c 100644 --- a/web/src/community/community.css +++ b/web/src/community/community.css @@ -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) { diff --git a/web/src/community/publish-dialog.test.ts b/web/src/community/publish-dialog.test.ts new file mode 100644 index 0000000..2740250 --- /dev/null +++ b/web/src/community/publish-dialog.test.ts @@ -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(); + +const dialogEl = () => document.querySelector('dialog.publish-dialog'); +const input = (name: string) => dialogEl()!.querySelector(`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 = {}): 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 { + 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: '' }); + expect(dialogEl()!.querySelector('img')).toBeNull(); + expect(dialogEl()!.textContent).toContain(''); + }); +}); + +describe('Veröffentlichen-Dialog: Prüfung vor dem Senden', () => { + const invalid = async (values: Partial, 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); + }); +}); diff --git a/web/src/community/publish-dialog.ts b/web/src/community/publish-dialog.ts new file mode 100644 index 0000000..881397a --- /dev/null +++ b/web/src/community/publish-dialog.ts @@ -0,0 +1,264 @@ +// Veröffentlichen-Dialog von „Meine Karten“ (Spec § 6): Titel, Herausgeber/Bank, Tags, Anzeigename und die Pflicht-Checkbox. +// Eigenes 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 { + return new Promise((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 = {}) => + 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 = { + 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 { + 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 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(); + }); +} diff --git a/web/src/community/publish.ts b/web/src/community/publish.ts index df76943..f8f22dd 100644 --- a/web/src/community/publish.ts +++ b/web/src/community/publish.ts @@ -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 { diff --git a/web/src/pages/cards-page.test.ts b/web/src/pages/cards-page.test.ts index f560394..b292971 100644 --- a/web/src/pages/cards-page.test.ts +++ b/web/src/pages/cards-page.test.ts @@ -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()), + publishSavedCard: mocks.publishSavedCard, +})); +vi.mock('../community/api', async (importOriginal) => ({ + ...(await importOriginal()), + 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 => ({ + 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('dialog.publish-dialog'); +const dlgInput = (name: string) => publishDialogEl()!.querySelector(`input[name="${name}"]`)!; +const sendForm = () => + publishDialogEl()!.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); +const fillDialog = (title = 'Mein Motiv', values: Record = {}) => { + 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('a[data-action="view"]'); + const mountWith = async (publications: Map = 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/) 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) => 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('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('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('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('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', '')]); + mount(); + await vi.waitFor(() => expect(tiles()).toHaveLength(1)); + await openPublish('a'); + expect(publishDialogEl()!.querySelector('img')).toBeNull(); + expect(publishDialogEl()!.textContent).toContain(''); + }); +}); + +describe('Meine Karten: Zurückziehen', () => { + const start = async (patch: Partial = {}) => { + 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(); + }); +}); diff --git a/web/src/pages/cards-page.ts b/web/src/pages/cards-page.ts index dfe6c06..a0264fb 100644 --- a/web/src/pages/cards-page.ts +++ b/web/src/pages/cards-page.ts @@ -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(); + let publicationsState: 'loading' | 'ready' | 'error' = 'loading'; + let publicationsToken = 0; + const tileUpdaters = new Map 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(); const previewFailed = new Set(); @@ -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 { + const token = ++publicationsToken; + try { + const found = await myPublications(userId); + if (disposed || token !== publicationsToken) return false; + const next = new Map(); + 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(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(); diff --git a/web/src/pages/cards.css b/web/src/pages/cards.css index d2a35ad..a9aad21 100644 --- a/web/src/pages/cards.css +++ b/web/src/pages/cards.css @@ -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);