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:
1 parent
be440bc8e9
commit
f6b9ee61f4
7 files changed
+1403
-9
No files matched your search
@@ -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();
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user