- Gemeinsamer Helfer loadGalleryImageChecked/checkGalleryImage (gallery.ts): lädt das Bild über new Image(), lehnt über 4 Megapixel oder nicht dekodierbare Dateien ab und liefert eine fertige Object-URL (der Aufrufer gibt sie frei). Raster -> „Keine Vorschau“ (auch bei <img>-Fehler), Hero -> Beispielmotiv, Detailseite -> „Das Bild konnte nicht geladen werden.“ Verspätet eintreffende URLs werden sofort freigegeben. - Tag-Filter sucht ",<tag>," (der Server speichert ",a,b,"): „gold“ trifft nicht mehr „marigold“. - Veröffentlichen-Dialog lehnt dieselben unsichtbaren Zeichen ab wie die API (C1, Nullbreite, Bidi, BOM). - Das Limit der API (429 „schon 20 Karten veröffentlicht“) gilt als eindeutige Ablehnung: die hochgeladenen Dateien werden aufgeräumt. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
290 lines
12 KiB
TypeScript
290 lines
12 KiB
TypeScript
// 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;
|
||
/**
|
||
* Steuerzeichen (C0, DEL, C1), Nullbreiten- und Bidi-Zeichen (U+200B-200F, U+202A-202E, U+2060-206F) und das BOM (U+FEFF):
|
||
* unsichtbar oder umordnend. Dasselbe Muster prüft der Server (deploy/api/app.js, CONTROL_CHARACTERS).
|
||
*/
|
||
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/;
|
||
|
||
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-heading' },
|
||
// Eigene ID für die Überschrift: `publish-title` gehört dem Titelfeld (`publish-<name>`), sonst zeigt sein Label auf die Überschrift.
|
||
el('div', { class: 'report-card' }, el('h2', { id: 'publish-heading', 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);
|
||
};
|
||
|
||
/** Öffnet den Dialog wieder, falls ihn der Browser geschlossen hat (Esc trotz verhindertem `cancel`). */
|
||
function reopen(): boolean {
|
||
if (dialog.open) return true;
|
||
try {
|
||
dialog.showModal();
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
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);
|
||
reopen();
|
||
showAlready();
|
||
return;
|
||
}
|
||
setBusy(false);
|
||
errorEl.textContent = publishFailureText(err);
|
||
reopen(); // war der Dialog zwischendurch zu, soll der Fehler zu sehen sein
|
||
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();
|
||
});
|
||
// Schließt der Browser den Dialog trotzdem mitten im Senden, wird er wieder geöffnet; aufgelöst wird erst mit dem echten Ergebnis.
|
||
dialog.addEventListener('close', () => {
|
||
if (busy && !closed) {
|
||
reopen();
|
||
return;
|
||
}
|
||
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();
|
||
});
|
||
}
|