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 {