Translate pages, areas, menus, forms and posts field by field instead of rebuilding them
A site can have further languages (Settings → Languages). A page is built once; each language adds only its texts, keyed by the stable block IDs, plus its own address under /en/… (ADR 111, document schema v27, migration 20261009000100 for page_translation). - Editor: DE | EN switch, a list of all texts with original, state (missing, translated, original changed) and filter; structure stays with the main language. Undo, drafts, publishing and revisions cover all languages at once. - Website: /en and translated slugs, lang, canonical, hreflang with x-default, og:locale, sitemap; links to translated pages follow the language; a language switch block. - Header, footer and linked sections translate the same way; menu entries carry labels per language; fixed template texts come from a small dictionary. - Forms: labels, help, options, button and success message per language; messages and the step script speak the language, the submission and the email stay in the main language. - Posts, archives, category names, consent text and the 404 page follow the language. - Single-language sites see none of it and never touch the new table. The forms administration becomes an overview with one row per form and its own edit page; fields are one line each with details collapsed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c7705b7083
commit
e15b2517b1
51 files changed
+3045
-219
No files matched your search
+50
-7
@@ -74,6 +74,9 @@ label { display: block; margin: 18px 0 7px; font-size: 13px; font-weight: 600 }
|
||||
der Zeile das Häkchen um - in der Menüverwaltung klappten so die Button-Einstellungen zu. */
|
||||
.check-label { display: flex; align-items: center; gap: 8px; font-weight: 500; width: fit-content; max-width: 100% }
|
||||
.check-label input { width: 16px; min-height: 16px }
|
||||
/* Eine Gruppe von Häkchen nebeneinander, die auf schmalen Bildschirmen umbricht (Sprachen). */
|
||||
.check-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px 20px; border: 0; padding: 0; margin: 0 0 12px; min-width: 0 }
|
||||
.check-grid legend { font-weight: 600; margin-bottom: 8px; padding: 0 }
|
||||
/* A grid of fields that becomes one column when there is no room. */
|
||||
.fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 0 24px }
|
||||
.fields > div > label:first-child, .field > label:first-child { margin-top: 14px }
|
||||
@@ -864,12 +867,42 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.card-body .form-fields-head .secondary, .card-body .list-actions .secondary { margin: 0; min-height: 36px; padding: 6px 12px; font-size: 12px }
|
||||
.form-fields-head .secondary .nav-icon, .list-actions .secondary .nav-icon { width: 15px; height: 15px; color: var(--accent) }
|
||||
.field-list { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 10px }
|
||||
.field-card { position: relative; padding: 2px 16px 16px; border: 1px solid var(--line); border-radius: 11px; background: #fafbf8 }
|
||||
.field-card { position: relative; padding: 8px 10px; border: 1px solid var(--line); border-radius: 10px; background: #fafbf8 }
|
||||
.field-card.is-open { padding-bottom: 14px; background: #fff; box-shadow: 0 1px 6px #203b3414 }
|
||||
.field-card-new { border-style: dashed; background: var(--paper) }
|
||||
.field-card-head { display: grid; grid-template-columns: 26px minmax(0, 1fr) 190px 38px; gap: 0 12px; align-items: end }
|
||||
.field-card-index { display: grid; place-items: center; width: 26px; height: 26px; margin-bottom: 8px; border-radius: 50%; background: #e4ebe2; color: var(--accent); font-size: 12px; font-weight: 700 }
|
||||
.field-card-head { display: grid; grid-template-columns: 24px minmax(0, 1fr) 170px auto auto 34px; gap: 0 10px; align-items: center }
|
||||
.field-card-head input, .field-card-head select { min-height: 36px }
|
||||
.field-card-index { display: grid; place-items: center; width: 24px; height: 24px; border-radius: 50%; background: #e4ebe2; color: var(--accent); font-size: 12px; font-weight: 700 }
|
||||
.field-card label { margin-top: 12px }
|
||||
.field-card-body { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 0 16px; padding-left: 38px }
|
||||
.field-card-head .field-card-required { margin: 0; font-size: 13px; white-space: nowrap }
|
||||
.field-toggle { display: inline-flex; align-items: center; gap: 4px; min-height: 34px; padding: 0 8px; border: 1px solid transparent; border-radius: 8px; background: transparent; color: var(--accent); font-size: 12px; font-weight: 650; white-space: nowrap }
|
||||
.field-toggle:hover { background: #eef3ec }
|
||||
.field-toggle .form-chevron { transition: transform .15s }
|
||||
.field-card.is-open .field-toggle .form-chevron { transform: rotate(180deg) }
|
||||
.field-card-meta { margin: 4px 0 0 34px; font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
|
||||
.field-card-body { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 0 16px; padding-left: 34px }
|
||||
.field-card-body[hidden] { display: none }
|
||||
.field-translation { grid-column: 1 / -1; margin-top: 12px; padding-top: 8px; border-top: 1px dashed var(--line) }
|
||||
.field-translation summary { cursor: pointer; font-size: 13px; font-weight: 650 }
|
||||
/* Ein Formular je zuklappbarer Karte, Abschnitte darin ebenfalls zuklappbar. */
|
||||
.form-card > .form-card-summary { display: flex; align-items: center; gap: 12px; padding: 16px 20px; cursor: pointer; list-style: none }
|
||||
details.form-card > summary.form-card-summary::before, details.form-card[open] > summary.form-card-summary::before { content: none }
|
||||
.form-card > .form-card-summary::-webkit-details-marker, .form-section > summary::-webkit-details-marker { display: none }
|
||||
.form-card-name { font-size: 16px; font-weight: 700 }
|
||||
.form-card > .form-card-summary { cursor: default; border-bottom: 1px solid var(--line) }
|
||||
.form-list-name { margin: 0; font-size: 15px; font-weight: 650 }
|
||||
.form-list-name a { text-decoration: none }
|
||||
.form-list-name a:hover { text-decoration: underline }
|
||||
.form-list .row { align-items: center }
|
||||
.back-to-list { display: inline-block; margin-top: 4px }
|
||||
.form-card-facts { flex: 1; min-width: 0; font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
|
||||
.form-chevron { flex: none; color: #52665a; transition: transform .15s }
|
||||
.form-card[open] > .form-card-summary { border-bottom: 1px solid var(--line) }
|
||||
.form-card[open] > .form-card-summary > .form-chevron, .form-section[open] > summary > .form-chevron { transform: rotate(180deg) }
|
||||
.form-section { border-bottom: 1px solid var(--line); padding-bottom: 12px }
|
||||
.form-section > summary { display: flex; align-items: center; gap: 10px; padding: 14px 0 2px; cursor: pointer; list-style: none }
|
||||
.form-section > summary .form-part { margin: 0 }
|
||||
.form-section-hint { flex: 1; min-width: 0; font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
|
||||
.field-card-body .field-help { margin-top: 5px }
|
||||
.field-card-flags { align-self: end; padding-bottom: 11px }
|
||||
.field-card-flags .check-label { margin: 12px 0 0 }
|
||||
@@ -916,11 +949,16 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.form-look .color-control { margin-top: 0 }
|
||||
@media (max-width: 1100px) { .form-editor { grid-template-columns: 1fr } }
|
||||
@media (max-width: 700px) {
|
||||
.field-card { padding-top: 8px }
|
||||
.field-card-head { grid-template-columns: 26px minmax(0, 1fr) }
|
||||
.field-card-title, .field-card-type { grid-column: 2 }
|
||||
.field-card-head { grid-template-columns: 24px minmax(0, 1fr) auto; gap: 8px }
|
||||
.field-card-title { grid-column: 2 / 4; padding-right: 36px }
|
||||
.field-card-type { grid-column: 2 }
|
||||
.field-card-head .field-card-required { grid-column: 3 }
|
||||
.field-toggle { grid-column: 2 }
|
||||
.field-card-head .field-remove { position: absolute; top: 8px; right: 8px }
|
||||
.field-card-meta { margin-left: 0 }
|
||||
.field-card-body { padding-left: 0 }
|
||||
.form-card-facts { white-space: normal }
|
||||
.form-card > .form-card-summary { flex-wrap: wrap; padding: 14px 16px }
|
||||
}
|
||||
|
||||
/* ---- design: palette cards, ready-made palettes, font cards, typography preview -------- */
|
||||
@@ -1006,3 +1044,8 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.menu-entry > .menu-drag-handle { position: absolute; left: 14px; top: 10px; z-index: 1 }
|
||||
.menu-entry summary { padding-left: 58px; min-height: 60px }
|
||||
@media (max-width: 560px) { .menu-entry > .menu-drag-handle { left: 8px; top: 8px } .menu-entry summary { padding-left: 50px; min-height: 56px } }
|
||||
/* Texte der Website in weiteren Sprachen (Einstellungen → Sprachen, ADR 111). */
|
||||
.language-texts { margin-top: 24px; padding-top: 16px; border-top: 1px solid #dfe5e1 }
|
||||
.language-texts h3 { margin: 0 0 6px; font-size: 15px }
|
||||
.language-text-group { display: grid; gap: 6px; border: 1px solid #dfe5e1; border-radius: 8px; padding: 12px 16px; margin: 12px 0; min-width: 0 }
|
||||
.language-text-group legend { font-weight: 600; padding: 0 4px }
|
||||
@@ -1,5 +1,5 @@
|
||||
export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', postnav: 'Beitragsnavigation', iconcard: 'Symbolkarte', stat: 'Kennzahl', quote: 'Kundenstimme', logos: 'Logoleiste', cta: 'Aufruf-Band', contact: 'Kontaktdaten', divider: 'Trennlinie', spacer: 'Abstand', accordion: 'Akkordeon', accordionitem: 'Akkordeon-Eintrag', tabs: 'Tabs', tabitem: 'Tab', form: 'Formular', breadcrumb: 'Brotkrumen', menu: 'Navigation', partialref: 'Verknüpfte Sektion', embed: 'Externer Inhalt', icon: 'Symbol', progress: 'Fortschritt', slider: 'Slider', legal: 'Rechtszeile', social: 'Soziale Profile', html: 'Eigenes HTML' };
|
||||
export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦', postlist: '☷', postnav: '⇄', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·', tabs: '▭', tabitem: '▫', form: '✉', breadcrumb: '⟩', menu: '☰', partialref: '⧉', legal: '§', embed: '⬓', icon: '◆', progress: '▰', slider: '⇄', social: '⚭', html: '</>' };
|
||||
export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', postnav: 'Beitragsnavigation', iconcard: 'Symbolkarte', stat: 'Kennzahl', quote: 'Kundenstimme', logos: 'Logoleiste', cta: 'Aufruf-Band', contact: 'Kontaktdaten', divider: 'Trennlinie', spacer: 'Abstand', accordion: 'Akkordeon', accordionitem: 'Akkordeon-Eintrag', tabs: 'Tabs', tabitem: 'Tab', form: 'Formular', breadcrumb: 'Brotkrumen', menu: 'Navigation', partialref: 'Verknüpfte Sektion', embed: 'Externer Inhalt', icon: 'Symbol', progress: 'Fortschritt', slider: 'Slider', legal: 'Rechtszeile', social: 'Soziale Profile', html: 'Eigenes HTML', language: 'Sprachwahl' };
|
||||
export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦', postlist: '☷', postnav: '⇄', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·', tabs: '▭', tabitem: '▫', form: '✉', breadcrumb: '⟩', menu: '☰', partialref: '⧉', legal: '§', embed: '⬓', icon: '◆', progress: '▰', slider: '⇄', social: '⚭', html: '</>', language: '文' };
|
||||
// A namespaced type belongs to a plugin this installation may not have. Such blocks stay in the
|
||||
// document, stay selectable and movable, but the editor never edits or fills them.
|
||||
/**
|
||||
@@ -12,7 +12,7 @@ export const CATEGORIES = [
|
||||
{ name: 'Inhalt', types: ['heading', 'text', 'image', 'button', 'icon', 'divider', 'spacer'] },
|
||||
{ name: 'Bausteine', types: ['iconcard', 'stat', 'quote', 'cta', 'contact', 'social', 'logos', 'progress'] },
|
||||
{ name: 'Gruppen', types: ['gallery', 'accordion', 'accordionitem', 'tabs', 'tabitem', 'slider'] },
|
||||
{ name: 'Seite und Daten', types: ['postlist', 'postnav', 'breadcrumb', 'menu', 'form', 'legal', 'partialref', 'embed', 'html'] },
|
||||
{ name: 'Seite und Daten', types: ['postlist', 'postnav', 'breadcrumb', 'menu', 'language', 'form', 'legal', 'partialref', 'embed', 'html'] },
|
||||
];
|
||||
|
||||
/** How many columns a row can be added with. */
|
||||
@@ -128,6 +128,8 @@ export function createBlock(type) {
|
||||
if (type === 'postlist') block.props = { count: 3, category: '', tag: '', layout: 'karten' };
|
||||
if (type === 'postnav') block.props = { previousLabel: 'Vorheriger Beitrag', nextLabel: 'Nächster Beitrag' };
|
||||
if (type === 'legal') block.props = { label: 'Rechtliches' };
|
||||
// Welche Sprachen es gibt, weiß die Website; der Block sagt nur, ob er Kürzel oder Namen zeigt.
|
||||
if (type === 'language') block.props = { display: 'code' };
|
||||
// Leer eingefuegt: nichts steht auf der Seite, bis jemand Code eintraegt.
|
||||
if (type === 'html') block.props = { html: '' };
|
||||
if (type === 'iconcard') block.props = { icon: 'check', title: 'Deine Leistung', text: 'Beschreibe hier kurz, worum es geht.', href: '' };
|
||||
@@ -180,6 +182,7 @@ export function repairSchema(document) {
|
||||
}
|
||||
};
|
||||
walk(document.blocks);
|
||||
if (document.translations) upgradeDocument(document, 27);
|
||||
return document;
|
||||
}
|
||||
|
||||
@@ -194,6 +197,7 @@ export function upgradeForType(document, type) {
|
||||
if (type === 'postnav') upgradeDocument(document, 18);
|
||||
if (type === 'legal') upgradeDocument(document, 19);
|
||||
if (type === 'html') upgradeDocument(document, 24);
|
||||
if (type === 'language') upgradeDocument(document, 27);
|
||||
if (['iconcard', 'stat', 'quote', 'logos', 'cta', 'contact', 'divider', 'spacer', 'accordion', 'accordionitem'].includes(type)) upgradeDocument(document, 6);
|
||||
if (type === 'tabs' || type === 'tabitem') upgradeDocument(document, 7);
|
||||
if (type === 'form') upgradeDocument(document, 8);
|
||||
@@ -344,7 +348,7 @@ export const devices = { desktop: 'Desktop', tablet: 'Tablet', mobile: 'Mobil' }
|
||||
// blieb bei 12 stehen, während Bausteine schon v13 bis v15 verlangten: in einem aktuellen Dokument
|
||||
// liess sich dann gar nichts mehr einfügen, und ein Symbol, ein Slider oder ein externer Inhalt
|
||||
// nirgends. Eine Zahl, an einer Stelle.
|
||||
export const SCHEMA_VERSION = 26;
|
||||
export const SCHEMA_VERSION = 27;
|
||||
|
||||
export const VISIBILITY = { all: 'Auf allen Geräten', desktop: 'Nur Desktop', tablet: 'Nur Tablet', mobile: 'Nur Mobil', 'hide-desktop': 'Auf Desktop ausblenden', 'hide-tablet': 'Auf Tablet ausblenden', 'hide-mobile': 'Auf Mobil ausblenden', hidden: 'Überall ausblenden' };
|
||||
|
||||
|
||||
@@ -285,3 +285,35 @@ html:has(>.editor-body){overflow:hidden;height:100%}
|
||||
.canvas-panel{height:62vh;flex:none}.sidebar{height:62vh;flex:none;border-right:0;border-top:1px solid var(--line)}
|
||||
.library-buttons{grid-template-columns:repeat(4,minmax(0,1fr))}
|
||||
}
|
||||
/* Übersetzen (ADR 111). Der Umschalter sitzt neben der Vorschaugröße; im Übersetzungsmodus
|
||||
verschwindet alles, was den Aufbau ändert - Reiter, Blockaktionen, Werkzeuge der Vorschau. */
|
||||
.language-switch{display:flex;gap:2px;padding:3px;background:#ffffff14;border-radius:9px}
|
||||
.language-switch[hidden]{display:none}
|
||||
.language-switch button{border:0;background:transparent;color:#c9d4cd;padding:0 10px;min-height:30px;min-width:36px;border-radius:6px;font-size:12px;font-weight:700;letter-spacing:.04em}
|
||||
.language-switch button:hover:not(:disabled){color:#fff}
|
||||
.editor-header .language-switch button[aria-pressed=true]{background:#fff;color:var(--ink)}
|
||||
#editor[data-language] .language-switch button[aria-pressed=true]{background:#f5c451;color:#1d1d1b}
|
||||
#editor[data-language] .sidebar-nav,#editor[data-language] .sidebar-foot,#editor[data-language] .ov-toolbar,#editor[data-language] .ov-insert,#editor[data-language] .ov-resize,#editor[data-language] .ov-drop{display:none!important}
|
||||
.translate-head{display:grid;gap:8px;margin-bottom:16px}
|
||||
.translate-head h2{margin:0;font-size:16px}
|
||||
.translate-head h2:focus{outline:none}
|
||||
.translate-head progress{width:100%;height:8px;accent-color:var(--ink)}
|
||||
.translate-summary{margin:0;font-size:13px;font-weight:600}
|
||||
.translate-group{border-top:1px solid var(--line,#dfe5e1);padding:12px 0 4px;scroll-margin-top:12px}
|
||||
.translate-group.is-current{box-shadow:inset 3px 0 0 #f5c451;padding-left:10px}
|
||||
.translate-group h3{margin:0 0 8px;font-size:13px}
|
||||
.translate-jump{border:0;background:none;padding:0;font:inherit;font-weight:700;color:inherit;text-align:left;cursor:pointer}
|
||||
.translate-jump:hover{text-decoration:underline}
|
||||
.translate-field{display:grid;gap:5px;margin-bottom:14px}
|
||||
.translate-field-head{display:flex;justify-content:space-between;align-items:baseline;gap:8px}
|
||||
.translate-field-head label{margin:0}
|
||||
.translate-state{font-size:11px;font-weight:700;padding:2px 7px;border-radius:999px;background:#e8ece9;color:#384640;white-space:nowrap}
|
||||
.translate-field[data-state=missing] .translate-state{background:#fde9c8;color:#6b3f00}
|
||||
.translate-field[data-state=stale] .translate-state{background:#fbd5d0;color:#7a1d12}
|
||||
.translate-field[data-state=done] .translate-state{background:#d7eedf;color:#1c5233}
|
||||
.translate-original{margin:0;padding:6px 8px;border-radius:6px;background:#f2f4f3;color:#46514c;font-size:12.5px;line-height:1.45;white-space:pre-line;max-height:7.5em;overflow:auto}
|
||||
.translate-actions{display:flex;gap:12px;flex-wrap:wrap}
|
||||
.translate-slug{display:flex;align-items:center;gap:4px}
|
||||
.translate-slug input{flex:1;min-width:0}
|
||||
.translate-prefix{font-size:12px;color:#5b6862;white-space:nowrap}
|
||||
.translate-filter{margin-top:4px}
|
||||
+257
-12
@@ -13,6 +13,7 @@ import { VISIBILITY, setVisibility, registerPlugins, pluginDefinition, PLUGIN_PR
|
||||
|
||||
// Symbole, ihre deutschen Namen und die Kachelauswahl. Die Liste spiegelt DocumentValidator::ICONS.
|
||||
import { renderIconField } from './icons.js';
|
||||
import { entries as translationEntries, setText, confirmText, setPageValue, slugify, plainText } from './translate.js';
|
||||
const ICON_SIZES = [12, 16, 20, 24, 32, 40, 48, 56, 64, 80, 96, 120, 160, 200, 240];
|
||||
// Mirrors SocialProfiles::NETWORKS; the addresses themselves live in the settings.
|
||||
const SOCIAL_NETWORKS = { facebook: 'Facebook', instagram: 'Instagram', linkedin: 'LinkedIn', youtube: 'YouTube', tiktok: 'TikTok', x: 'X', threads: 'Threads', pinterest: 'Pinterest', xing: 'Xing', whatsapp: 'WhatsApp', google: 'Google-Unternehmensprofil' };
|
||||
@@ -32,6 +33,9 @@ let device = 'desktop', propertyPanel = 'content', designTokens, designSchema, d
|
||||
// same factor, so it is kept here instead of being recomputed from the transform string.
|
||||
let previewScale = 1, overlay = null, pendingInsert = null;
|
||||
const collapsed = new Set();
|
||||
// Übersetzen (ADR 111): welche Sprachen es gibt, und welche gerade bearbeitet wird. null ist die
|
||||
// Hauptsprache - dann ist der Editor genau der von vorher.
|
||||
let languageInfo = null, language = null, translateFilter = 'all';
|
||||
let renderedProperties = null;
|
||||
let revealAfterPreview = null;
|
||||
let previewPlayback = false, stopPlayback = () => {};
|
||||
@@ -119,7 +123,7 @@ function controls() {
|
||||
$('#undo').disabled = !ready || busy || !history.past.length;
|
||||
$('#redo').disabled = !ready || busy || !history.future.length;
|
||||
const current = ready ? locate(history.document.blocks, selected) : null;
|
||||
for (const id of ['duplicate', 'delete', 'move-up', 'move-down', 'nest', 'outdent', 'go-parent', 'add-here', 'close-element']) $(`#${id}`).disabled = !current || busy;
|
||||
for (const id of ['duplicate', 'delete', 'move-up', 'move-down', 'nest', 'outdent', 'go-parent', 'add-here', 'close-element']) $(`#${id}`).disabled = !current || busy || Boolean(language);
|
||||
if (current) {
|
||||
$('#move-up').disabled ||= current.index === 0;
|
||||
$('#move-down').disabled ||= current.index === current.siblings.length - 1;
|
||||
@@ -142,6 +146,8 @@ function controls() {
|
||||
$('[data-panel="design"]').disabled ||= !current;
|
||||
$('#open-media-library').disabled = !ready || busy;
|
||||
$('#preview-motion').disabled = !ready || busy;
|
||||
$('#language-switch').querySelectorAll('button').forEach(button => button.disabled = !ready || busy);
|
||||
$('#translate-panel').querySelectorAll('input,textarea,button').forEach(input => { input.disabled = busy; });
|
||||
}
|
||||
|
||||
function renderTree() {
|
||||
@@ -203,6 +209,7 @@ function choose(id, scroll = true) {
|
||||
history.mergeKey = null;
|
||||
syncView();
|
||||
renderTree(); renderProperties(); controls(); highlight(scroll);
|
||||
if (language && selected) focusTranslation(selected);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -210,6 +217,7 @@ function choose(id, scroll = true) {
|
||||
* the exception: there the selection only moves the highlight, editing is one more step.
|
||||
*/
|
||||
function syncView() {
|
||||
if (language) return;
|
||||
if (selected && view !== 'structure') setView('element');
|
||||
else if (!selected && view === 'element') setView('elements');
|
||||
}
|
||||
@@ -594,6 +602,12 @@ function renderContent(block, panel) {
|
||||
if (block.type === 'legal') {
|
||||
field('Beschriftung für Vorlesesoftware', 'label', block.props?.label || 'Rechtliches', { maxLength: 60, help: 'Der Baustein zeigt Impressum, Datenschutz und – sobald jemand gefragt wurde – den Weg zurück zur Einwilligung. Die Adressen stehen in den Einstellungen. Platziere ihn dort, wo die Links erscheinen sollen, zum Beispiel im Fußbereich.' });
|
||||
}
|
||||
if (block.type === 'language') {
|
||||
segmented('Anzeige', 'display', block.props.display === 'name' ? 'name' : 'code', [['code', 'Kürzel (DE · EN)'], ['name', 'Namen (Deutsch · English)']], 27);
|
||||
const help = document.createElement('p'); help.className = 'field-help';
|
||||
help.textContent = 'Zeigt dieselbe Seite in jeder Sprache, in der sie veröffentlicht ist. Die Sprachen stehen unter Einstellungen → Sprachen. Auf einer Seite, die es nur in einer Sprache gibt, bleibt die Sprachwahl leer. Am besten in den Kopfbereich setzen.';
|
||||
panel.append(help);
|
||||
}
|
||||
if (block.type === 'postnav') {
|
||||
field('Beschriftung zurück', 'previousLabel', block.props.previousLabel, { maxLength: 60 });
|
||||
field('Beschriftung weiter', 'nextLabel', block.props.nextLabel, { maxLength: 60, help: 'Die Beiträge selbst sucht die Website beim Ausliefern - innerhalb derselben Kategorie.' });
|
||||
@@ -978,7 +992,7 @@ async function updatePreview() {
|
||||
const sequence = previewSequence;
|
||||
const controller = new AbortController(); previewController = controller;
|
||||
try {
|
||||
const html = await request('/preview', { document: history.document }, controller.signal);
|
||||
const html = await request('/preview', { document: history.document, language }, controller.signal);
|
||||
if (sequence !== previewSequence) return;
|
||||
const frame = $('#preview');
|
||||
const top = frame.contentWindow?.scrollY || 0;
|
||||
@@ -1019,7 +1033,7 @@ async function updatePreview() {
|
||||
menuSummary.parentElement.open = !menuSummary.parentElement.open;
|
||||
}
|
||||
const insert = event.target.closest('[data-editor-target]');
|
||||
if (insert && !busy && history) {
|
||||
if (insert && !busy && history && !language) {
|
||||
stopInlineEdit();
|
||||
const id = insert.dataset.editorTarget;
|
||||
if (!locate(history.document.blocks, id)) return;
|
||||
@@ -1044,12 +1058,13 @@ async function updatePreview() {
|
||||
const element = event.target.closest('[id]');
|
||||
if (!element) return;
|
||||
const block = locate(history.document.blocks, element.id)?.block;
|
||||
if (language) return;
|
||||
if (block && INLINE_TEXT[block.type]) { event.preventDefault(); choose(element.id, false); startInlineEdit(element.id); }
|
||||
});
|
||||
// Right click: the element's actions where the pointer is, as in Flatsome.
|
||||
doc.addEventListener('contextmenu', event => {
|
||||
const element = event.target.closest('[id]');
|
||||
if (!element || !locate(history.document.blocks, element.id) || busy || inlineEdit) return;
|
||||
if (!element || !locate(history.document.blocks, element.id) || busy || inlineEdit || language) return;
|
||||
event.preventDefault();
|
||||
choose(element.id, false);
|
||||
openContextMenu(element.id, event.clientX, event.clientY);
|
||||
@@ -1065,7 +1080,8 @@ async function updatePreview() {
|
||||
// The head may arrive before the blocks. Decorate only once the complete DOM exists.
|
||||
const finish = () => {
|
||||
if (sequence !== previewSequence || doc !== frame.contentDocument) return;
|
||||
decorateContainers(doc, history.document);
|
||||
// Beim Übersetzen gibt es nichts einzufügen: der Aufbau gehört der Hauptsprache.
|
||||
if (!language) decorateContainers(doc, history.document);
|
||||
overlay?.attach();
|
||||
styleReady.then(loaded => {
|
||||
if (sequence !== previewSequence || doc !== frame.contentDocument) return;
|
||||
@@ -1075,7 +1091,9 @@ async function updatePreview() {
|
||||
frame.style.visibility = '';
|
||||
showMotionStatus();
|
||||
playPreview();
|
||||
$('#preview-status').textContent = loaded
|
||||
$('#preview-status').textContent = loaded && language
|
||||
? `Entwurf auf ${languageName(language)} · Klicken springt zum Text in der Übersetzungsliste. Leere Felder zeigen den Text der Hauptsprache.`
|
||||
: loaded
|
||||
? 'Entwurf · Klicken wählt aus, Doppelklick bearbeitet Text, Elemente lassen sich hierher ziehen.'
|
||||
: 'Editor-Hilfsflächen konnten nicht geladen werden. Bitte den Editor neu laden.';
|
||||
});
|
||||
@@ -1114,7 +1132,226 @@ function changed() {
|
||||
if (!locate(history.document.blocks, selected)) selected = null;
|
||||
if (selected && !$('#preview').contentDocument?.getElementById(selected)) revealAfterPreview = selected;
|
||||
syncView();
|
||||
renderTree(); renderProperties(); controls(); schedulePreview();
|
||||
renderTree(); renderProperties(); renderTranslations(); controls(); schedulePreview();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Übersetzen (ADR 111).
|
||||
//
|
||||
// Dieselbe Seite, dieselbe Vorschau - nur die Seitenleiste zeigt statt Werkzeugkasten und
|
||||
// Eigenschaften die Liste der Texte: Original links darüber, Übersetzung darunter. Aufbau und
|
||||
// Gestaltung bleiben in der Hauptsprache; hier lässt sich nichts verschieben oder löschen.
|
||||
// Jede Eingabe ist ein gewöhnlicher Schritt im Verlauf, also gelten Undo, Speichern und
|
||||
// Veröffentlichen wie immer und für alle Sprachen zugleich.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
const languageName = code => languageInfo?.extra.find(item => item.code === code)?.name ?? languageInfo?.main.name ?? code;
|
||||
|
||||
function renderLanguageSwitch() {
|
||||
const holder = $('#language-switch');
|
||||
const extra = languageInfo?.extra ?? [];
|
||||
holder.hidden = !extra.length;
|
||||
$('#publish-languages').hidden = !extra.length;
|
||||
holder.replaceChildren();
|
||||
if (!extra.length) return;
|
||||
for (const item of [languageInfo.main, ...extra]) {
|
||||
const button = document.createElement('button'); button.type = 'button';
|
||||
button.textContent = item.code.toUpperCase();
|
||||
button.title = item === languageInfo.main ? `${item.name} – Hauptsprache: Aufbau, Gestaltung und Texte` : `${item.name} – nur die Texte übersetzen`;
|
||||
// Der Name beginnt mit dem sichtbaren Kürzel, damit auch Sprachsteuerung den Knopf findet.
|
||||
button.setAttribute('aria-label', item === languageInfo.main ? `${item.code.toUpperCase()} – ${item.name} (Hauptsprache) bearbeiten` : `${item.code.toUpperCase()} – ${item.name} übersetzen`);
|
||||
const active = (language ?? languageInfo.main.code) === item.code;
|
||||
button.setAttribute('aria-pressed', String(active));
|
||||
button.addEventListener('click', () => setLanguage(item === languageInfo.main ? null : item.code));
|
||||
holder.append(button);
|
||||
}
|
||||
}
|
||||
|
||||
function setLanguage(code) {
|
||||
if (busy || !history || code === language) return;
|
||||
stopInlineEdit(); closeContextMenu(); setPendingInsert(null);
|
||||
language = code;
|
||||
if (language) root.dataset.language = language; else delete root.dataset.language;
|
||||
selected = null; history.mergeKey = null; translateFilter = 'all';
|
||||
setView(language ? 'translate' : 'elements');
|
||||
renderLanguageSwitch(); renderTree(); renderProperties(); renderTranslations(); controls(); schedulePreview();
|
||||
if (language) $('#translate-panel').querySelector('h2')?.focus();
|
||||
}
|
||||
|
||||
/** Springt in der Liste zu den Texten des gewählten Blocks oder des ersten Blocks darin. */
|
||||
function focusTranslation(id) {
|
||||
const panel = $('#translate-panel');
|
||||
const ids = [];
|
||||
const collect = block => { ids.push(block.id); (block.children ?? []).forEach(collect); };
|
||||
const block = locate(history.document.blocks, id)?.block;
|
||||
if (!block) return;
|
||||
collect(block);
|
||||
const group = ids.map(item => panel.querySelector(`.translate-group[data-block-id="${CSS.escape(item)}"]`)).find(Boolean);
|
||||
if (!group) return;
|
||||
if (group.hidden) { translateFilter = 'all'; renderTranslations(); return focusTranslation(id); }
|
||||
panel.querySelectorAll('.translate-group.is-current').forEach(item => item.classList.remove('is-current'));
|
||||
group.classList.add('is-current');
|
||||
const body = $('.sidebar-body');
|
||||
body.scrollTop += group.getBoundingClientRect().top - body.getBoundingClientRect().top - 12;
|
||||
group.querySelector('textarea,input,[contenteditable="true"]')?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
const STATE_TEXT = { missing: 'Fehlt', stale: 'Original geändert', done: 'Übersetzt' };
|
||||
|
||||
function renderTranslations() {
|
||||
const panel = $('#translate-panel');
|
||||
if (!language || !history) { panel.replaceChildren(); return; }
|
||||
const scroll = $('.sidebar-body').scrollTop;
|
||||
const info = languageInfo.extra.find(item => item.code === language);
|
||||
const doc = history.document;
|
||||
const translation = doc.translations?.[language] ?? {};
|
||||
// Ein Kopf- oder Fußbereich hat weder Adresse noch Seitentitel - nur Texte in Blöcken.
|
||||
const list = translationEntries(doc, language).filter(entry => !languageInfo.area || entry.blockId);
|
||||
const count = { total: list.length, done: list.filter(entry => entry.state !== 'missing').length, stale: list.filter(entry => entry.state === 'stale').length };
|
||||
const el = (tag, className = '', text = '') => { const node = document.createElement(tag); if (className) node.className = className; if (text) node.textContent = text; return node; };
|
||||
panel.replaceChildren();
|
||||
|
||||
const head = el('div', 'translate-head');
|
||||
const title = el('h2', '', `Übersetzung: ${info.name}`); title.tabIndex = -1;
|
||||
const meter = el('progress'); meter.max = Math.max(1, count.total); meter.value = count.done; meter.setAttribute('aria-label', 'Übersetzt');
|
||||
const summary = el('p', 'translate-summary', `${count.done} von ${count.total} Texten übersetzt${count.stale ? ` · ${count.stale} mit geändertem Original` : ''}`);
|
||||
summary.setAttribute('role', 'status');
|
||||
const hint = el('p', 'field-help', `Aufbau, Bilder und Gestaltung bearbeitest du in ${languageInfo.main.name}. Hier stehen nur die Texte. Ein leeres Feld zeigt auf der Website den Text in ${languageInfo.main.name}.`);
|
||||
const filter = el('div', 'segmented translate-filter'); filter.setAttribute('role', 'group'); filter.setAttribute('aria-label', 'Texte filtern');
|
||||
filter.style.gridTemplateColumns = 'repeat(3, 1fr)';
|
||||
for (const [value, text] of [['all', 'Alle'], ['missing', 'Fehlend'], ['stale', 'Geändert']]) {
|
||||
const option = el('button', '', text); option.type = 'button'; option.setAttribute('aria-pressed', String(translateFilter === value));
|
||||
option.addEventListener('click', () => { translateFilter = value; renderTranslations(); });
|
||||
filter.append(option);
|
||||
}
|
||||
head.append(title, meter, summary, hint, filter);
|
||||
panel.append(head);
|
||||
|
||||
// Adresse und Suchtitel: gehören nur zur Übersetzung, ein Original gibt es dafür nicht.
|
||||
const page = el('section', 'translate-group translate-page'); page.dataset.blockId = '';
|
||||
if (languageInfo.area) page.hidden = true;
|
||||
page.append(el('h3', '', 'Seite'));
|
||||
const published = languageInfo.published?.[language];
|
||||
if (languageInfo.home) {
|
||||
page.append(el('p', 'field-help', `Startseite – erscheint unter /${language}.`));
|
||||
} else {
|
||||
const label = el('label', '', 'Adresse'); label.htmlFor = 'translate-slug';
|
||||
const wrap = el('div', 'translate-slug');
|
||||
const prefix = el('span', 'translate-prefix', `/${language}/…/`); prefix.setAttribute('aria-hidden', 'true');
|
||||
const input = el('input'); input.id = 'translate-slug'; input.maxLength = 80; input.value = translation.slug ?? '';
|
||||
input.placeholder = languageInfo.slug; input.spellcheck = false; input.autocomplete = 'off';
|
||||
input.pattern = '[a-z0-9]+(-[a-z0-9]+)*';
|
||||
const help = el('p', 'field-help', `Kleinbuchstaben, Ziffern, Bindestriche. Leer: wie in ${languageInfo.main.name} (${languageInfo.slug}).`); help.id = 'translate-slug-help';
|
||||
input.setAttribute('aria-describedby', help.id);
|
||||
input.addEventListener('input', () => { history.change(content => setPageValue(content, language, 'slug', input.value), 'tr-slug'); controls(); });
|
||||
const suggest = el('button', 'text-button', 'Aus dem übersetzten Titel'); suggest.type = 'button';
|
||||
suggest.addEventListener('click', () => {
|
||||
const proposal = slugify(history.document.translations?.[language]?.title ?? '');
|
||||
if (!proposal) { help.textContent = 'Erst den Seitentitel übersetzen, dann lässt sich die Adresse daraus bilden.'; return; }
|
||||
input.value = proposal; history.change(content => setPageValue(content, language, 'slug', proposal)); controls();
|
||||
});
|
||||
wrap.append(prefix, input);
|
||||
page.append(label, wrap, help, suggest);
|
||||
}
|
||||
const seoLabel = el('label', '', 'Titel für Suchmaschinen'); seoLabel.htmlFor = 'translate-seo';
|
||||
const seo = el('input'); seo.id = 'translate-seo'; seo.maxLength = 160; seo.value = translation.seoTitle ?? ''; seo.lang = language;
|
||||
seo.placeholder = 'Leer: der übersetzte Seitentitel';
|
||||
seo.addEventListener('input', () => { history.change(content => setPageValue(content, language, 'seoTitle', seo.value), 'tr-seo'); controls(); });
|
||||
page.append(seoLabel, seo);
|
||||
page.append(el('p', 'field-help', published
|
||||
? `Veröffentlicht unter /${language}${languageInfo.home ? '' : `/${published}`}. Änderungen gehen mit „Veröffentlichen“ online.`
|
||||
: `Noch nicht auf der Website. Die Seite erscheint auf ${info.name}, sobald ein Text übersetzt und die Seite veröffentlicht ist.`));
|
||||
panel.append(page);
|
||||
|
||||
// Die Texte, gruppiert nach Block in Seitenreihenfolge; Seitentitel und Beschreibung vorn.
|
||||
let group = null, groupId;
|
||||
for (const entry of list) {
|
||||
const id = entry.blockId ?? '__page';
|
||||
if (id !== groupId) {
|
||||
groupId = id;
|
||||
group = el('section', 'translate-group'); group.dataset.blockId = entry.blockId ?? '';
|
||||
const heading = el('h3');
|
||||
if (entry.blockId) {
|
||||
const jump = el('button', 'translate-jump', describe(entry.block)); jump.type = 'button';
|
||||
jump.title = 'In der Vorschau zeigen';
|
||||
const current = group;
|
||||
jump.addEventListener('click', () => { selected = entry.blockId; highlight(true); panel.querySelectorAll('.translate-group.is-current').forEach(item => item.classList.remove('is-current')); current.classList.add('is-current'); });
|
||||
heading.append(jump);
|
||||
} else heading.textContent = 'Seitentitel und Beschreibung';
|
||||
group.append(heading);
|
||||
panel.append(group);
|
||||
}
|
||||
group.append(translationField(entry, info));
|
||||
}
|
||||
for (const section of panel.querySelectorAll('.translate-group:not(.translate-page)')) {
|
||||
const fields = [...section.querySelectorAll('.translate-field')];
|
||||
for (const field of fields) field.hidden = translateFilter !== 'all' && field.dataset.state !== translateFilter;
|
||||
section.hidden = fields.every(field => field.hidden);
|
||||
}
|
||||
if (!list.length) panel.append(el('p', 'panel-hint', languageInfo.area ? 'In diesem Bereich gibt es keine Texte zum Übersetzen. Menübeschriftungen übersetzt du unter Menüs.' : 'Auf dieser Seite gibt es noch keine Texte zum Übersetzen.'));
|
||||
else if (languageInfo.area) head.append(el('p', 'field-help', 'Gespeicherte Übersetzungen eines Bereichs gelten sofort auf allen Seiten dieser Sprache. Menübeschriftungen übersetzt du unter Menüs.'));
|
||||
else if (translateFilter !== 'all' && !panel.querySelector('.translate-field:not([hidden])')) panel.append(el('p', 'panel-hint', translateFilter === 'missing' ? 'Alles übersetzt.' : 'Kein Original wurde seit der Übersetzung geändert.'));
|
||||
if (selected) panel.querySelector(`.translate-group[data-block-id="${CSS.escape(selected)}"]`)?.classList.add('is-current');
|
||||
$('.sidebar-body').scrollTop = scroll;
|
||||
controls();
|
||||
}
|
||||
|
||||
/** Kurzname eines Blocks für die Liste: Art und der Anfang des Originals. */
|
||||
function describe(block) {
|
||||
const text = plainText(block.props?.text ?? block.props?.title ?? block.props?.alt ?? block.props?.label ?? '');
|
||||
return text ? `${blockLabel(block)} · ${text.length > 40 ? `${text.slice(0, 40)} …` : text}` : blockLabel(block);
|
||||
}
|
||||
|
||||
function translationField(entry, info) {
|
||||
const el = (tag, className = '', text = '') => { const node = document.createElement(tag); if (className) node.className = className; if (text) node.textContent = text; return node; };
|
||||
const row = el('div', 'translate-field'); row.dataset.state = entry.state; row.dataset.key = entry.key;
|
||||
const id = `tr-${entry.key.replace(/[^a-zA-Z0-9_-]/g, '-')}`;
|
||||
const label = el('label', '', entry.label); label.htmlFor = id;
|
||||
const badge = el('span', 'translate-state', STATE_TEXT[entry.state]);
|
||||
const top = el('div', 'translate-field-head'); top.append(label, badge);
|
||||
const original = el('p', 'translate-original'); original.lang = languageInfo.main.code;
|
||||
original.textContent = entry.field.rich ? plainText(entry.original) : entry.original;
|
||||
const originalId = `${id}-original`; original.id = originalId;
|
||||
const update = value => {
|
||||
history.change(content => setText(content, language, entry.key, value), `tr-${entry.key}`);
|
||||
const state = value.trim() === '' ? 'missing' : 'done';
|
||||
row.dataset.state = state; badge.textContent = STATE_TEXT[state];
|
||||
controls(); schedulePreview();
|
||||
};
|
||||
row.append(top, original);
|
||||
let input;
|
||||
const rich = entry.field.rich === 'text' ? history.document.schemaVersion >= 12 : entry.field.rich === 'heading' ? history.document.schemaVersion >= 17 : false;
|
||||
if (rich) {
|
||||
const editor = createRichText(entry.value, markup => update(plainText(markup) === '' ? '' : markup), { label: `${entry.label} auf ${info.name}` });
|
||||
editor.element.lang = language;
|
||||
if (info.rtl) editor.element.dir = 'rtl';
|
||||
editor.element.querySelector('[contenteditable]')?.setAttribute('aria-describedby', originalId);
|
||||
// Das Label zeigt auf die Fläche, die man beschreibt.
|
||||
const surface = editor.element.querySelector('[contenteditable]'); if (surface) { surface.id = id; label.addEventListener('click', () => surface.focus()); }
|
||||
row.append(editor.element);
|
||||
} else {
|
||||
input = el(entry.field.multiline ? 'textarea' : 'input');
|
||||
input.id = id; input.value = entry.value; input.maxLength = entry.field.max; input.lang = language;
|
||||
if (info.rtl) input.dir = 'rtl';
|
||||
if (entry.field.multiline) input.rows = 3;
|
||||
input.setAttribute('aria-describedby', originalId);
|
||||
input.addEventListener('input', () => update(input.value));
|
||||
row.append(input);
|
||||
}
|
||||
const actions = el('div', 'translate-actions');
|
||||
if (entry.state === 'missing') {
|
||||
const copy = el('button', 'text-button', 'Original übernehmen'); copy.type = 'button';
|
||||
copy.title = 'Den Text der Hauptsprache als Ausgangspunkt einsetzen';
|
||||
copy.addEventListener('click', () => { history.change(content => setText(content, language, entry.key, entry.original)); changed(); });
|
||||
actions.append(copy);
|
||||
}
|
||||
if (entry.state === 'stale') {
|
||||
const ok = el('button', 'text-button', 'Übersetzung passt'); ok.type = 'button';
|
||||
ok.title = 'Das Original wurde geändert, die Übersetzung stimmt trotzdem';
|
||||
ok.addEventListener('click', () => { history.change(content => confirmText(content, language, entry.key)); changed(); });
|
||||
actions.append(ok);
|
||||
}
|
||||
if (actions.childElementCount) row.append(actions);
|
||||
return row;
|
||||
}
|
||||
|
||||
async function loadDesignSchema() {
|
||||
@@ -1152,10 +1389,15 @@ async function load() {
|
||||
// der Server schon die Vorschau ab (siehe repairSchema).
|
||||
history = new EditorHistory(repairSchema(data.document));
|
||||
({ version, revisionId, publishedRevisionId, csrfToken } = data);
|
||||
languageInfo = data.languages ?? null;
|
||||
if (language && !languageInfo?.extra.some(item => item.code === language)) { language = null; delete root.dataset.language; }
|
||||
renderLanguageSwitch();
|
||||
// Die Bausteine der eingeschalteten Plugins kommen mit dem Dokument; der Werkzeugkasten
|
||||
// bekommt dafür eine eigene Gruppe.
|
||||
registerPlugins(data.plugins || [], data.presets || []); buildLibrary(); buildPluginPresets();
|
||||
selected = null; propertyPanel = 'content'; collapsed.clear(); conflict = false; notify(); changed(); resizePreview();
|
||||
selected = null; propertyPanel = 'content'; collapsed.clear(); conflict = false; notify();
|
||||
if (language) setView('translate');
|
||||
changed(); resizePreview();
|
||||
} catch (error) { notify(error.message, true); }
|
||||
finally { busy = false; controls(); }
|
||||
}
|
||||
@@ -1167,13 +1409,15 @@ async function save(publish = false) {
|
||||
try {
|
||||
const data = await request(publish ? '/publish' : '/draft', publish ? { expectedVersion: version, revisionId } : { expectedVersion: version, document: snapshot });
|
||||
({ version, revisionId, publishedRevisionId, csrfToken } = data);
|
||||
if (data.languages) languageInfo = data.languages;
|
||||
// Der Server bereinigt Rich Text beim Speichern. Weicht seine Fassung ab, uebernimmt der
|
||||
// Editor sie - als gespeicherter Stand, ohne den Verlauf zu verlieren.
|
||||
if (!publish && data.document && JSON.stringify(data.document) !== JSON.stringify(snapshot) && JSON.stringify(history.document) === JSON.stringify(snapshot)) {
|
||||
history.adopt(data.document);
|
||||
history.markSaved(data.document);
|
||||
renderTree(); renderProperties(); schedulePreview();
|
||||
renderTree(); renderProperties(); renderTranslations(); schedulePreview();
|
||||
} else history.markSaved(snapshot);
|
||||
if (publish && language) renderTranslations();
|
||||
notify();
|
||||
} catch (error) { notify(error.message, true); }
|
||||
finally { busy = false; controls(); }
|
||||
@@ -1514,12 +1758,13 @@ overlay = createCanvasOverlay({
|
||||
getDocument: () => history.document,
|
||||
getSelected: () => selected,
|
||||
onAction: (action, id) => {
|
||||
if (!id || busy || !history) return;
|
||||
if (!id || busy || !history || language) return;
|
||||
if (action === 'up') actions.move(id, -1);
|
||||
else if (action === 'down') actions.move(id, 1);
|
||||
else actions[action]?.(id);
|
||||
},
|
||||
onInsert: (id, where) => {
|
||||
if (language) return;
|
||||
const item = locate(history.document.blocks, id);
|
||||
if (!item) return;
|
||||
setPendingInsert({ parentId: item.parent?.id ?? null, index: item.index + (where === 'after' ? 1 : 0) });
|
||||
@@ -1527,12 +1772,12 @@ overlay = createCanvasOverlay({
|
||||
// The border between two columns was dragged (or nudged with the arrow keys): the shares of
|
||||
// the whole row for the current device, one undo step per drag.
|
||||
onResize: (rowId, value) => {
|
||||
if (busy || !history) return;
|
||||
if (busy || !history || language) return;
|
||||
history.change(doc => setStyle(doc, rowId, device, 'columnWidths', value));
|
||||
changed();
|
||||
},
|
||||
onDrop: (payload, plan) => {
|
||||
if (busy || !history) return;
|
||||
if (busy || !history || language) return;
|
||||
if (payload.kind === 'add') {
|
||||
const id = history.change(doc => payload.columns
|
||||
? insertRowAt(doc, payload.columns, plan.parentId, plan.index)
|
||||
|
||||
@@ -250,10 +250,35 @@ if (root) {
|
||||
if (help) help.textContent = 'Ein E-Mail-Feld des Formulars. Dann lässt sich auf die Nachricht direkt antworten.';
|
||||
};
|
||||
|
||||
// Eine Feldkarte ist eine Zeile; die Details öffnet man bei Bedarf. Darunter steht kurz, was
|
||||
// in ihnen eingestellt ist, damit man nicht jede Karte aufklappen muss.
|
||||
const setOpen = (card, open) => {
|
||||
card.querySelector('[data-field-body]').hidden = !open;
|
||||
const toggle = card.querySelector('[data-field-toggle]');
|
||||
toggle.setAttribute('aria-expanded', String(open));
|
||||
card.classList.toggle('is-open', open);
|
||||
};
|
||||
const meta = card => {
|
||||
const type = card.querySelector('[data-field-type]').value;
|
||||
const parts = [card.querySelector('[data-field-name]').value.trim()];
|
||||
const options = card.querySelector('[data-field-options-input]').value.split(',').map(o => o.trim()).filter(Boolean);
|
||||
if (['select', 'radio', 'checkboxes'].includes(type)) parts.push(options.length ? options.join(', ') : 'noch keine Optionen');
|
||||
if (card.querySelector('[data-field-help]').value.trim()) parts.push('Hilfetext');
|
||||
if (card.querySelector('[data-field-showif]').value.trim()) parts.push('mit Bedingung');
|
||||
for (const translation of card.querySelectorAll('[data-field-translation]')) {
|
||||
if ([...translation.querySelectorAll('input')].some(input => input.value.trim())) parts.push(`${translation.dataset.fieldTranslation.toUpperCase()} ✓`);
|
||||
}
|
||||
return parts.filter(Boolean).join(' · ');
|
||||
};
|
||||
|
||||
const refresh = () => {
|
||||
cards().forEach((card, index) => {
|
||||
card.querySelector('[data-field-index]').textContent = String(index + 1);
|
||||
const type = card.querySelector('[data-field-type]').value;
|
||||
const line = card.querySelector('[data-field-meta]');
|
||||
line.textContent = meta(card); line.hidden = !line.textContent || card.classList.contains('is-open');
|
||||
const label = card.querySelector('[data-field-label]').value.trim() || `Feld ${index + 1}`;
|
||||
card.querySelector('[data-field-toggle]').setAttribute('aria-label', `Details zu „${label}“`);
|
||||
card.querySelector('[data-field-options]').hidden = !['select', 'radio', 'checkboxes'].includes(type);
|
||||
card.querySelector('[data-field-images]').hidden = !['radio', 'checkboxes'].includes(type);
|
||||
if (['radio', 'checkboxes'].includes(type)) renderImagePickers(card);
|
||||
@@ -276,12 +301,19 @@ if (root) {
|
||||
const empty = cards().filter(card => card.classList.contains('field-card-new'));
|
||||
if (cards().length > empty.length) empty.forEach(card => card.remove());
|
||||
add.hidden = false;
|
||||
for (const card of cards()) {
|
||||
card.querySelector('[data-field-toggle]').hidden = false;
|
||||
setOpen(card, card.classList.contains('field-card-new'));
|
||||
}
|
||||
add.addEventListener('click', () => {
|
||||
list.insertAdjacentHTML('beforeend', template.innerHTML.replaceAll('__n__', String(next++)));
|
||||
const card = list.lastElementChild; card.classList.remove('field-card-new');
|
||||
card.querySelector('[data-field-toggle]').hidden = false; setOpen(card, true);
|
||||
refresh(); card.querySelector('[data-field-label]').focus();
|
||||
});
|
||||
list.addEventListener('click', event => {
|
||||
const toggle = event.target.closest('[data-field-toggle]');
|
||||
if (toggle) { const card = toggle.closest('[data-field]'); setOpen(card, !card.classList.contains('is-open')); refresh(); return; }
|
||||
const button = event.target.closest('[data-remove-field]');
|
||||
if (!button) return;
|
||||
button.closest('[data-field]').remove(); refresh(); add.focus();
|
||||
|
||||
+16
-10
@@ -16,6 +16,10 @@
|
||||
};
|
||||
|
||||
const setup = form => {
|
||||
// Die Texte des Skripts. Auf einer übersetzten Seite bringt das Formular sie mit (ADR 111).
|
||||
let texts = {};
|
||||
try { texts = JSON.parse(form.dataset.texts || '{}'); } catch { texts = {}; }
|
||||
const t = (key, fallback) => texts[key] || fallback;
|
||||
const rules = [...form.querySelectorAll('[data-show-if]')];
|
||||
const steps = [...form.querySelectorAll('[data-step]')];
|
||||
const submit = form.querySelector('.cms-button[type="submit"]');
|
||||
@@ -54,27 +58,27 @@
|
||||
if (wantsSummary) {
|
||||
summary = el('div', 'form-step form-summary');
|
||||
summary.setAttribute('data-step', '');
|
||||
const title = el('p', 'form-step-title', 'Zusammenfassung');
|
||||
const title = el('p', 'form-step-title', t('summary', 'Zusammenfassung'));
|
||||
title.tabIndex = -1;
|
||||
summary.append(title, el('p', 'form-step-intro', 'Bitte prüfen Sie Ihre Angaben. Über „Ändern“ kommen Sie zum jeweiligen Schritt zurück.'));
|
||||
summary.append(title, el('p', 'form-step-intro', t('summaryIntro', 'Bitte prüfen Sie Ihre Angaben. Über „Ändern“ kommen Sie zum jeweiligen Schritt zurück.')));
|
||||
summary.append(el('div', 'form-summary-body'));
|
||||
steps[steps.length - 1].after(summary);
|
||||
steps.push(summary);
|
||||
}
|
||||
|
||||
const progress = el('ol', 'form-progress');
|
||||
progress.setAttribute('aria-label', 'Schritte des Formulars');
|
||||
progress.setAttribute('aria-label', t('steps', 'Schritte des Formulars'));
|
||||
steps.forEach((step, index) => {
|
||||
const item = el('li', 'form-progress-step');
|
||||
item.append(el('span', 'form-progress-number', String(index + 1)), el('span', 'form-progress-title', step.querySelector('.form-step-title')?.textContent.trim() || `Schritt ${index + 1}`));
|
||||
item.append(el('span', 'form-progress-number', String(index + 1)), el('span', 'form-progress-title', step.querySelector('.form-step-title')?.textContent.trim() || `${t('step', 'Schritt')} ${index + 1}`));
|
||||
progress.append(item);
|
||||
});
|
||||
form.insertBefore(progress, steps[0]);
|
||||
|
||||
const nav = el('div', 'form-step-nav');
|
||||
const back = el('button', 'button cms-button form-step-back', 'Zurück');
|
||||
const back = el('button', 'button cms-button form-step-back', t('back', 'Zurück'));
|
||||
back.type = 'button';
|
||||
const next = el('button', 'button cms-button form-step-next', 'Weiter');
|
||||
const next = el('button', 'button cms-button form-step-next', t('next', 'Weiter'));
|
||||
next.type = 'button';
|
||||
nav.append(back, next);
|
||||
(submit || form).before(nav);
|
||||
@@ -90,8 +94,10 @@
|
||||
if (!picked.length) return '';
|
||||
const unit = field.querySelector('.form-unit')?.textContent || '';
|
||||
return picked.map(input => {
|
||||
if (input.type === 'checkbox' && input.value === 'ja' && field.querySelector('.check-row label')) return 'Ja';
|
||||
if (input.type === 'checkbox' && input.value === 'ja' && field.querySelector('.check-row label')) return t('yes', 'Ja');
|
||||
if (input.tagName === 'SELECT') return input.selectedOptions[0]?.textContent || input.value;
|
||||
// Angezeigt wird die Beschriftung der Option - auf einer übersetzten Seite die übersetzte.
|
||||
if (input.type === 'checkbox' || input.type === 'radio') return (input.labels?.[0]?.querySelector('.form-choice-text') ?? input.labels?.[0])?.textContent.trim() || input.value;
|
||||
return input.value + (unit ? ` ${unit}` : '');
|
||||
}).join(', ');
|
||||
};
|
||||
@@ -103,8 +109,8 @@
|
||||
if (step === summary || step.hasAttribute('data-inactive')) return;
|
||||
const block = el('section', 'form-summary-step');
|
||||
const head = el('div', 'form-summary-head');
|
||||
head.append(el('h4', 'form-summary-title', step.querySelector('.form-step-title')?.textContent.trim() || `Schritt ${index + 1}`));
|
||||
const change = el('button', 'form-summary-change', 'Ändern');
|
||||
head.append(el('h4', 'form-summary-title', step.querySelector('.form-step-title')?.textContent.trim() || `${t('step', 'Schritt')} ${index + 1}`));
|
||||
const change = el('button', 'form-summary-change', t('change', 'Ändern'));
|
||||
change.type = 'button';
|
||||
change.setAttribute('aria-label', `${head.firstChild.textContent} ändern`);
|
||||
change.addEventListener('click', () => show(index));
|
||||
@@ -153,7 +159,7 @@
|
||||
if (group.closest('[data-inactive]')) continue;
|
||||
const note = group.querySelector('.field-error[data-live]');
|
||||
if (!group.querySelector('input:checked')) {
|
||||
if (!note) group.append(Object.assign(el('p', 'field-error', 'Bitte wähle mindestens eine Option.'), { dataset: { live: '' } }));
|
||||
if (!note) group.append(Object.assign(el('p', 'field-error', t('chooseOne', 'Bitte wähle mindestens eine Option.')), { dataset: { live: '' } }));
|
||||
else note.hidden = false;
|
||||
group.classList.add('has-error');
|
||||
group.querySelector('input')?.focus();
|
||||
|
||||
@@ -54,6 +54,7 @@ export const PREVIEWS = {
|
||||
postnav: '<path d="M9.4 4.6L6 8l3.4 3.4M14.6 4.6L18 8l-3.4 3.4"/><path d="M6.4 8h3.6M14 8h3.6"/>',
|
||||
breadcrumb: `${bar(2, 7.2, 5, 1.7)}<path d="M8.6 6.3l1.6 1.7-1.6 1.7"/>${bar(11.6, 7.2, 4, 1.7)}<path d="M17.2 6.3l1.6 1.7-1.6 1.7"/>${bar(20.2, 7.2, 2, 1.7)}`,
|
||||
menu: `${bar(2, 7.2, 5, 1.8)}${bar(9, 7.2, 6, 1.8)}${bar(17, 7.2, 5, 1.8)}`,
|
||||
language: '<circle cx="12" cy="8" r="5.6"/><path d="M6.4 8h11.2M12 2.4c-2.4 2.8-2.4 8.4 0 11.2M12 2.4c2.4 2.8 2.4 8.4 0 11.2"/>',
|
||||
form: '<rect x="2.5" y="2" width="19" height="3.6" rx="1.2"/><rect x="2.5" y="6.8" width="19" height="3.6" rx="1.2"/><rect x="2.5" y="11.6" width="8" height="3.4" rx="1.2" fill="currentColor" stroke="none"/>',
|
||||
legal: `${bar(2, 4, 20, 1.6)}${bar(2, 7.4, 16, 1.6)}${bar(2, 10.8, 11, 1.6)}`,
|
||||
partialref: '<rect x="2.5" y="4.5" width="13" height="9" rx="1.5"/><path d="M6.5 4.5V2h13v9h-2.5"/>',
|
||||
|
||||
@@ -85,6 +85,12 @@ function createRow(data) {
|
||||
summary.append(el('strong', 'menu-entry-title', data.label), el('span', 'menu-entry-kind', data.type === 'page' ? 'Seitenlink' : 'Eigener Link'), chevron);
|
||||
const body = el('div', 'menu-entry-fields'); details.append(summary, body); row.append(handle, details);
|
||||
const label = field('label', data.label, 'Beschriftung'); label.required = true; label.maxLength = 80;
|
||||
// Je weitere Sprache eine eigene Beschriftung (ADR 111); leer zeigt die der Hauptsprache.
|
||||
for (const language of JSON.parse(form.dataset.languages || '[]')) {
|
||||
const translated = field(`label_${language.code}`, data[`label_${language.code}`] ?? data.labels?.[language.code] ?? '', `Beschriftung ${language.name}`);
|
||||
translated.maxLength = 80; translated.lang = language.code; translated.placeholder = `Leer: wie „${data.label}“`;
|
||||
if (language.rtl) translated.dir = 'rtl';
|
||||
}
|
||||
if (data.type === 'page') {
|
||||
const picker = form.querySelector('.menu-add .content-picker').cloneNode(true); delete picker.dataset.ready;
|
||||
const input = picker.querySelector('[role="combobox"]'); input.id = `menu-target-${id}`; input.value = data.pageDisplay || '';
|
||||
|
||||
@@ -570,6 +570,12 @@ html.kant-top-armed.kant-top-shown .scroll-top{opacity:1;visibility:visible;tran
|
||||
.block-legal{display:flex;flex-wrap:wrap;gap:6px;align-items:center;font-size:13px}
|
||||
.block-legal a{color:inherit;text-decoration:none}
|
||||
.block-legal a:hover{text-decoration:underline}
|
||||
/* Die Sprachwahl (ADR 111): eine kurze Reihe, die gewählte Sprache fett. Jedes Ziel mindestens
|
||||
24 px, damit es mit dem Finger zu treffen ist. */
|
||||
.block-language ul{display:flex;flex-wrap:wrap;gap:2px;align-items:center;list-style:none;margin:0;padding:0}
|
||||
.block-language a{display:inline-flex;align-items:center;justify-content:center;min-width:24px;min-height:24px;padding:4px 8px;color:inherit;text-decoration:none;font-size:14px;line-height:1.2}
|
||||
.block-language a:hover{text-decoration:underline}
|
||||
.block-language a[aria-current]{font-weight:700}
|
||||
.image-zoom{cursor:zoom-in}
|
||||
.zoom-dialog{border:0;padding:0;max-width:min(96vw,1400px);max-height:94vh;background:transparent;color:#fff;overflow:visible}
|
||||
.zoom-dialog::backdrop{background:rgba(8,12,20,.88)}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Übersetzen im Editor (ADR 111).
|
||||
*
|
||||
* Eine Seite wird einmal gebaut; je weitere Sprache trägt ihr Dokument unter `translations` nur
|
||||
* die Texte, adressiert über die ID des Blocks. Dieses Modul kennt die übersetzbaren Felder,
|
||||
* schreibt eine Übersetzung in das Dokument und sagt, was fehlt oder veraltet ist. Es ändert
|
||||
* nie Aufbau, Gestaltung oder die Texte der Hauptsprache.
|
||||
*/
|
||||
import { locate, upgradeDocument, blockLabel } from './editor-model.js';
|
||||
|
||||
/** Dieselbe Liste wie Translations::FIELDS, mit Beschriftung, Länge und Art des Feldes. */
|
||||
export const FIELDS = {
|
||||
heading: [{ prop: 'text', label: 'Überschrift', max: 2000, rich: 'heading' }],
|
||||
text: [{ prop: 'text', label: 'Text', max: 20000, rich: 'text', multiline: true }],
|
||||
button: [{ prop: 'text', label: 'Beschriftung', max: 300 }],
|
||||
image: [{ prop: 'alt', label: 'Alternativtext', max: 300, multiline: true }, { prop: 'caption', label: 'Bildunterschrift', max: 500, multiline: true }],
|
||||
iconcard: [{ prop: 'title', label: 'Titel', max: 120 }, { prop: 'text', label: 'Text', max: 600, multiline: true }],
|
||||
stat: [{ prop: 'value', label: 'Wert', max: 16 }, { prop: 'label', label: 'Beschriftung', max: 80 }],
|
||||
quote: [{ prop: 'text', label: 'Zitat', max: 1000, multiline: true }, { prop: 'author', label: 'Name', max: 120 }, { prop: 'role', label: 'Rolle', max: 120 }],
|
||||
cta: [{ prop: 'title', label: 'Titel', max: 160 }, { prop: 'text', label: 'Text', max: 600, multiline: true }, { prop: 'buttonText', label: 'Button', max: 80 }],
|
||||
contact: [{ prop: 'address', label: 'Anschrift', max: 200, multiline: true }],
|
||||
accordionitem: [{ prop: 'title', label: 'Titel', max: 160 }],
|
||||
tabitem: [{ prop: 'title', label: 'Titel', max: 160 }],
|
||||
legal: [{ prop: 'label', label: 'Beschriftung für Vorlesesoftware', max: 60 }],
|
||||
postnav: [{ prop: 'previousLabel', label: 'Beschriftung zurück', max: 60 }, { prop: 'nextLabel', label: 'Beschriftung weiter', max: 60 }],
|
||||
icon: [{ prop: 'label', label: 'Beschriftung', max: 120 }],
|
||||
progress: [{ prop: 'label', label: 'Beschriftung', max: 120 }],
|
||||
embed: [{ prop: 'title', label: 'Titel', max: 160 }, { prop: 'hint', label: 'Hinweis', max: 300, multiline: true }],
|
||||
};
|
||||
|
||||
/** Felder der Seite selbst. `seoTitle` hat kein Gegenstück im Dokument der Hauptsprache. */
|
||||
export const PAGE_FIELDS = [
|
||||
{ key: 'title', label: 'Seitentitel', max: 160 },
|
||||
{ key: 'description', label: 'Beschreibung für Suchmaschinen', max: 320, multiline: true },
|
||||
];
|
||||
|
||||
/**
|
||||
* Der Vermerk, wie ein Text der Hauptsprache beim Übersetzen aussah: FNV-1a über UTF-8, 32 Bit,
|
||||
* als acht Hexziffern - dasselbe wie hash('fnv1a32') in PHP (Translations::fingerprint).
|
||||
*/
|
||||
export function fingerprint(text) {
|
||||
let hash = 0x811c9dc5;
|
||||
for (const byte of new TextEncoder().encode(text)) {
|
||||
hash ^= byte;
|
||||
hash = Math.imul(hash, 0x01000193) >>> 0;
|
||||
}
|
||||
return hash.toString(16).padStart(8, '0');
|
||||
}
|
||||
|
||||
const empty = value => typeof value !== 'string' || value.trim() === '';
|
||||
|
||||
/** Das sichtbare Wort eines formatierten Texts, für Überschriften in der Liste. */
|
||||
export function plainText(markup) {
|
||||
const node = document.createElement('template');
|
||||
node.innerHTML = markup ?? '';
|
||||
return node.content.textContent.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle übersetzbaren Texte des Dokuments in Seitenreihenfolge, je mit Original, Übersetzung und
|
||||
* Zustand: "missing" (fehlt), "stale" (Original seitdem geändert) oder "done".
|
||||
*
|
||||
* @return {Array<{key: string, blockId: string|null, prop: string, label: string, block: object|null, original: string, value: string, state: string, field: object}>}
|
||||
*/
|
||||
export function entries(document, locale) {
|
||||
const translation = document.translations?.[locale] ?? {};
|
||||
const list = [];
|
||||
const push = (key, blockId, prop, field, original, value, block = null) => {
|
||||
if (empty(original)) return;
|
||||
const hash = translation.source?.[key];
|
||||
const state = empty(value) ? 'missing' : hash && hash !== fingerprint(original) ? 'stale' : 'done';
|
||||
list.push({ key, blockId, prop, label: field.label, block, original, value: value ?? '', state, field });
|
||||
};
|
||||
for (const field of PAGE_FIELDS) push(field.key, null, field.key, field, document[field.key], translation[field.key]);
|
||||
const walk = blocks => {
|
||||
for (const block of blocks ?? []) {
|
||||
for (const field of FIELDS[block.type] ?? []) {
|
||||
// Ein dekoratives Bild oder Symbol hat keinen Text, also auch keinen zu übersetzen.
|
||||
if (['alt', 'label'].includes(field.prop) && block.props?.decorative === true) continue;
|
||||
push(`${block.id}.${field.prop}`, block.id, field.prop, field, block.props?.[field.prop], translation.blocks?.[block.id]?.[field.prop], block);
|
||||
}
|
||||
walk(block.children);
|
||||
}
|
||||
};
|
||||
walk(document.blocks);
|
||||
return list;
|
||||
}
|
||||
|
||||
/** Zählt, was übersetzt ist: { total, done, stale }. */
|
||||
export function progress(document, locale) {
|
||||
const list = entries(document, locale);
|
||||
return { total: list.length, done: list.filter(entry => entry.state !== 'missing').length, stale: list.filter(entry => entry.state === 'stale').length };
|
||||
}
|
||||
|
||||
/** Die Übersetzung einer Sprache, angelegt, falls es sie noch nicht gibt. */
|
||||
function ensure(document, locale) {
|
||||
upgradeDocument(document, 27);
|
||||
document.translations ??= {};
|
||||
return (document.translations[locale] ??= {});
|
||||
}
|
||||
|
||||
/** Räumt eine Sprache auf, die nichts mehr enthält, damit das Dokument nicht wächst. */
|
||||
function tidy(document, locale) {
|
||||
const translation = document.translations?.[locale];
|
||||
if (!translation) return;
|
||||
for (const [id, fields] of Object.entries(translation.blocks ?? {})) if (!Object.keys(fields).length) delete translation.blocks[id];
|
||||
if (translation.blocks && !Object.keys(translation.blocks).length) delete translation.blocks;
|
||||
if (translation.source && !Object.keys(translation.source).length) delete translation.source;
|
||||
if (!Object.keys(translation).length) delete document.translations[locale];
|
||||
if (document.translations && !Object.keys(document.translations).length) delete document.translations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schreibt einen übersetzten Text. Mit dem Text wird vermerkt, wie das Original gerade aussieht -
|
||||
* ändert es sich später, gilt die Übersetzung als veraltet. Leer heißt: nicht übersetzt.
|
||||
* Läuft innerhalb einer Transaktion der History.
|
||||
*/
|
||||
export function setText(document, locale, key, value) {
|
||||
const translation = ensure(document, locale);
|
||||
const [blockId, prop] = key.includes('.') ? key.split('.') : [null, key];
|
||||
const original = blockId ? locate(document.blocks, blockId)?.block?.props?.[prop] : document[prop];
|
||||
if (blockId) {
|
||||
translation.blocks ??= {};
|
||||
const fields = (translation.blocks[blockId] ??= {});
|
||||
if (empty(value)) delete fields[prop]; else fields[prop] = value;
|
||||
} else if (empty(value)) delete translation[prop]; else translation[prop] = value;
|
||||
translation.source ??= {};
|
||||
if (empty(value) || typeof original !== 'string') delete translation.source[key];
|
||||
else translation.source[key] = fingerprint(original);
|
||||
tidy(document, locale);
|
||||
}
|
||||
|
||||
/** Bestätigt eine veraltete Übersetzung, ohne sie zu ändern: das Original ist so gemeint. */
|
||||
export function confirmText(document, locale, key) {
|
||||
const translation = document.translations?.[locale];
|
||||
const [blockId, prop] = key.includes('.') ? key.split('.') : [null, key];
|
||||
const original = blockId ? locate(document.blocks, blockId)?.block?.props?.[prop] : document[prop];
|
||||
if (!translation || typeof original !== 'string') return;
|
||||
(translation.source ??= {})[key] = fingerprint(original);
|
||||
}
|
||||
|
||||
/** Adresse und Suchtitel einer Sprache. Leer heißt: wie in der Hauptsprache beziehungsweise wie der Titel. */
|
||||
export function setPageValue(document, locale, key, value) {
|
||||
const translation = ensure(document, locale);
|
||||
if (empty(value)) delete translation[key]; else translation[key] = key === 'slug' ? value.trim().toLowerCase() : value;
|
||||
tidy(document, locale);
|
||||
}
|
||||
|
||||
/** Aus einem Titel eine Adresse: Kleinbuchstaben, Ziffern, einzelne Bindestriche. */
|
||||
export function slugify(text) {
|
||||
return plainText(text).toLowerCase()
|
||||
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss')
|
||||
.normalize('NFKD').replace(/[̀-ͯ]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80).replace(/-+$/g, '');
|
||||
}
|
||||
|
||||
/** Kurzer Name eines Blocks für die Liste: Art und der Anfang seines Originaltexts. */
|
||||
export function describeBlock(block) {
|
||||
return blockLabel(block);
|
||||
}
|
||||
Reference in new issue
Block a user