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:
kantarciandClaude Opus 5.5 committed 2026-10-09 16:56:52 +02:00
1 parent c7705b7083
commit e15b2517b1
51 files changed
+3045 -219

No files matched your search

+32
View File
@@ -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();