Files
AirCardIOS/web/src/community/publish-dialog.ts
T
ClaudeandClaude Opus 5.5 56cb67be4b feat(community): „von @name“ statt Anzeigename, Benutzername vor dem Hochladen und Veröffentlichen
Logo-Kacheln, Logo-Infozeile und die Detailseite einer Community-Karte zeigen den Benutzernamen des Kontos (Hilfsfunktion byline: Profil,
sonst alter Anzeigename, sonst „Anonym“). Die Namen kommen je geladener Seite in einer Abfrage nach, ein Fehler dabei bleibt ohne Fehlertext,
ein eigenes Umbenennen aktualisiert die eigenen Einträge sofort.

Die Felder „Anzeigename“ in den Dialogen „Logo hochladen“ und „Veröffentlichen“ fallen weg, ebenso account/display-name.ts und der
localStorage-Schlüssel; der Client schickt displayName: ''. Vor dem Hochladen und Veröffentlichen verlangt requireUsername() einen Namen
(„Abbrechen“: es passiert nichts); antwortet der Dienst mit 403 usernameRequired, öffnet sich der Namensdialog, danach ist ein neuer Versuch möglich.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:06:44 +00:00

307 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Veröffentlichen-Dialog von „Meine Karten“ (Spec § 6): Titel, Herausgeber/Bank, Tags und die Pflicht-Checkbox. Einen Anzeigenamen gibt
// es nicht mehr: die Detailseite zeigt den Benutzernamen des Kontos (Spec § 5). Hat das Konto keinen, antwortet der Dienst mit 403
// `usernameRequired`: dann öffnet sich der Namensdialog, und mit dem gespeicherten Namen lässt es sich noch einmal versuchen.
// Eigenes <dialog> wie report-dialog.ts; alle Texte gehen als Text in den DOM, nie als HTML. Übersetzt wird beim Aufbau mit t().
import '../account/account.css';
import './community.css';
import { errorMessage } from '../account/errors';
import { usernameDialog } from '../account/username-dialog';
import { LOGIN_NEEDED, LoginCancelled } from '../account/with-login';
import { el, setFieldInvalid } from '../editor/dom';
import { PIXELS_MESSAGE, READ_MESSAGE } from '../editor/upload';
import { msg, t } from '../i18n/i18n';
import { ApiError, apiErrorText, isAlreadyPublished, isUsernameRequired } from './api';
import { LOGO_MISSING_PUBLISH, NOT_RENDERED, TOO_LARGE, normalizeTags, type PublishForm } from './publish';
/** Wortlaut aus Spec § 6. */
export const CONSENT_TEXT = msg('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 = msg('Diese Karte ist schon veröffentlicht.');
const RUNNING = msg('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 TAGS_MAX = 5;
const TAG_MAX = 20;
/** Die Fehlerzeile des Dialogs: ein ungültiges Feld verweist mit `aria-describedby` auf sie. */
const ERROR_ID = 'publish-error';
/**
* 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).
* Auch für den Namen im Dialog „Logo hochladen“ (logos/logo-dialog.ts).
*/
export 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' | 'consent';
/** Das erste Problem in Formularreihenfolge (mit Text in der Sprache der Seite) 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: t('Bitte gib einen Titel ein.') };
if (CONTROL_CHARACTERS.test(title)) return { field: 'title', message: t('Der Titel enthält ungültige Zeichen.') };
if (title.length > TITLE_MAX) {
return { field: 'title', message: t('Der Titel ist zu lang (höchstens {max} Zeichen).', { max: TITLE_MAX }) };
}
const issuer = values.issuer.trim();
if (CONTROL_CHARACTERS.test(issuer)) return { field: 'issuer', message: t('Herausgeber / Bank enthält ungültige Zeichen.') };
if (issuer.length > ISSUER_MAX) {
return { field: 'issuer', message: t('Herausgeber / Bank ist zu lang (höchstens {max} Zeichen).', { max: ISSUER_MAX }) };
}
const tags = normalizeTags(values.tags);
if (tags.some((tag) => CONTROL_CHARACTERS.test(tag))) return { field: 'tags', message: t('Tags enthalten ungültige Zeichen.') };
if (tags.some((tag) => tag.length > TAG_MAX)) {
return { field: 'tags', message: t('Ein Tag darf höchstens {max} Zeichen lang sein.', { max: TAG_MAX }) };
}
if (tags.length > TAGS_MAX) return { field: 'tags', message: t('Bitte gib höchstens {max} Tags an.', { max: TAGS_MAX }) };
if (!values.consent) return { field: 'consent', message: t('Bitte bestätige die Erklärung zum Bild.') };
return null;
}
/** Meldungen aus dem Rendern und dem Bildlesen tragen den deutschen Text (msg); angezeigt werden sie mit t(). Alles andere übersetzt `errorMessage`. */
const GERMAN_MESSAGES = new Set<string>([TOO_LARGE, NOT_RENDERED, LOGO_MISSING_PUBLISH, READ_MESSAGE, PIXELS_MESSAGE]);
/**
* Der Text zu einem fehlgeschlagenen Veröffentlichen in der Sprache der Seite (Spec § 11: „Veröffentlichen fehlgeschlagen – …“).
* Fehler des Dienstes kommen über ihren Code (`apiErrorText`).
*/
export function publishFailureText(err: unknown): string {
if (err instanceof LoginCancelled) return t(LOGIN_NEEDED);
let reason: string;
if (err instanceof ApiError) reason = apiErrorText(err);
else if (err instanceof Error && GERMAN_MESSAGES.has(err.message)) reason = t(err.message);
else reason = errorMessage(err, 'save');
return t('Veröffentlichen fehlgeschlagen – {reason}', { reason });
}
/** `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 consentInput = el('input', { type: 'checkbox', name: 'consent', id: 'publish-consent' });
const inputs: Record<PublishField, HTMLInputElement> = {
title: titleInput,
issuer: issuerInput,
tags: tagsInput,
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
? t('„{name}“ wird mit Bild öffentlich in der Community-Galerie gezeigt.', { name: opts.cardName })
: t('Die Karte wird mit Bild öffentlich in der Community-Galerie gezeigt.'),
);
const fields = el(
'div',
{ class: 'publish-fields' },
field(t('Titel (Pflicht)'), titleInput),
field(t('Herausgeber / Bank (optional)'), issuerInput),
field(t('Tags (optional)'), tagsInput, t('Bis zu 5, mit Komma getrennt, z. B. „visa, gold“.')),
el('label', { class: 'publish-consent' }, consentInput, el('span', {}, t(CONSENT_TEXT))),
);
const errorEl = el('p', { class: 'auth-msg auth-msg-error', id: ERROR_ID, role: 'alert' });
const statusEl = el('p', { class: 'auth-msg', role: 'status' });
const cancel = el('button', { type: 'button', class: 'auth-btn' }, t('Abbrechen'));
const send = el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, t('Veröffentlichen'));
const actions = el('div', { class: 'report-actions' }, cancel, send);
// novalidate: die Prüfung läuft selbst, mit Texten der Seite 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' }, t('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 ? t(RUNNING) : '';
}
function clearInvalid(): void {
for (const control of Object.values(inputs)) setFieldInvalid(control, ERROR_ID, false);
}
function showAlready(): void {
outcome = 'already';
errorEl.textContent = '';
statusEl.textContent = t(ALREADY_PUBLISHED);
intro.remove();
fields.remove();
const close = el('button', { type: 'button', class: 'auth-btn auth-btn-primary' }, t('Schließen'));
close.addEventListener('click', finish);
actions.replaceChildren(close);
close.focus();
}
/**
* Der Dienst verlangt einen Benutzernamen: der Namensdialog öffnet sich über dem Dialog. Mit gespeichertem Namen ist der Hinweis erledigt
* und der Fokus liegt auf „Veröffentlichen“ (erneut versuchen); bei „Abbrechen“ oder wenn der Namensdialog nicht aufgeht, bleibt der Grund stehen.
*/
async function askForUsername(): Promise<void> {
let saved: string | null = null;
try {
saved = await usernameDialog({ mode: 'required', signal: opts.signal });
} catch {
// der Namensdialog lässt sich nicht öffnen: der Grund oben steht, der Nutzer kann den Namen im Konto-Menü wählen
}
if (closed || saved === null) return;
errorEl.textContent = '';
send.focus();
}
async function submit(): Promise<void> {
const values: PublishFormValues = {
title: titleInput.value.trim(),
issuer: issuerInput.value.trim(),
tags: tagsInput.value,
consent: consentInput.checked,
};
clearInvalid();
errorEl.textContent = '';
const problem = validatePublishForm(values);
if (problem) {
errorEl.textContent = problem.message;
setFieldInvalid(inputs[problem.field], ERROR_ID, true);
inputs[problem.field].focus();
return;
}
setBusy(true);
try {
await opts.submit({ title: values.title, issuer: values.issuer, tags: values.tags });
} 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
if (isUsernameRequired(err)) await askForUsername();
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();
});
}