Keep old pages readable, detect stale settings writes, make backups honest

- Own CSS is parsed on write only. A page whose stored CSS no longer
  passes a stricter rule answers 200 and opens in the editor; that one
  block loses its CSS. A document read several times in one request is
  validated once.
- Settings and theme refuse a write from an outdated form.
- A backup fails when a media file does not arrive completely and
  leaves no half directory behind; rotation counts intact backups only.
- The archive update takes its backup behind the maintenance page.
- A package import puts all media in place before removing the old
  ones, reports honestly when that fails and keeps a maintenance state
  that was already on.
- Restoring replaces the SQLite file in one move and points at pending
  migrations.
- Six irreversible actions ask first, in a dialog of the admin's own.
- Ctrl+S in the editor takes the field being typed in along; Delete
  no longer removes a block while a dialog is open.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Fable 5.2 committed 2026-11-10 11:41:05 +01:00
1 parent 0d61aa0f22
commit ead47cc924
25 files changed
+437 -72

No files matched your search

+10 -4
View File
@@ -1155,7 +1155,13 @@ details.form-card > summary.form-card-summary::before, details.form-card[open] >
.update-lauf .secondary { background: transparent; color: #fff; border-color: #ffffff66 }
.update-letztes { margin: 0 0 12px }
.update-manual { margin-top: 6px }
.update-dialog { max-width: 460px; border: 0; border-radius: 12px; padding: 24px 26px; box-shadow: 0 18px 48px #0003 }
.update-dialog::backdrop { background: #1f2d2766 }
.update-dialog h2 { margin: 0 0 10px; font-size: 18px }
.update-dialog .dialog-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px }
.update-dialog, .confirm-dialog { max-width: 460px; border: 0; border-radius: 12px; padding: 24px 26px; box-shadow: 0 18px 48px #0003 }
.update-dialog::backdrop, .confirm-dialog::backdrop { background: #1f2d2766 }
.update-dialog h2, .confirm-dialog h2 { margin: 0 0 10px; font-size: 18px }
.update-dialog .dialog-actions, .confirm-dialog .dialog-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px }
/* Die Rückfrage vor etwas Endgültigem (confirm.js). */
.confirm-dialog { width: calc(100vw - 32px); color: var(--ink) }
.confirm-dialog p { margin: 0; color: var(--muted); line-height: 1.5 }
.confirm-dialog .dialog-actions .primary, .confirm-dialog .dialog-actions .secondary { margin: 0 }
.primary.danger { background: var(--danger); border-color: var(--danger) }
.primary.danger:hover:not(:disabled) { background: #6f251b; border-color: #6f251b }
+68
View File
@@ -0,0 +1,68 @@
/**
* Rückfrage im Stil der Verwaltung - statt confirm() des Browsers, und statt gar keiner.
*
* Ein Formular meldet sich mit data-confirm-title an (dazu optional data-confirm-text,
* data-confirm-label und data-confirm-variant="danger"). Sein Absenden wird angehalten, bis der
* Dialog bestätigt ist. Ohne JavaScript bleibt es ein gewöhnliches Formular.
*
* Skripte rufen kantConfirm({ title, message, confirmLabel, variant, onConfirm }) auf.
*/
(() => {
let dialog = null;
let pending = null;
let opener = null;
const build = () => {
dialog = document.createElement('dialog');
dialog.className = 'confirm-dialog';
dialog.setAttribute('aria-labelledby', 'confirm-dialog-title');
dialog.setAttribute('aria-describedby', 'confirm-dialog-text');
const title = document.createElement('h2'); title.id = 'confirm-dialog-title';
const text = document.createElement('p'); text.id = 'confirm-dialog-text';
const actions = document.createElement('div'); actions.className = 'dialog-actions';
const cancel = document.createElement('button'); cancel.type = 'button'; cancel.className = 'secondary'; cancel.textContent = 'Abbrechen'; cancel.dataset.confirmCancel = '';
const ok = document.createElement('button'); ok.type = 'button'; ok.dataset.confirmOk = '';
actions.append(cancel, ok);
dialog.append(title, text, actions);
cancel.addEventListener('click', () => dialog.close());
ok.addEventListener('click', () => { const run = pending; pending = null; dialog.close(); run?.(); });
// Escape und der Klick neben den Dialog schließen ohne Folgen.
dialog.addEventListener('click', event => { if (event.target === dialog) dialog.close(); });
dialog.addEventListener('close', () => { pending = null; opener?.focus?.({ preventScroll: true }); opener = null; });
document.body.append(dialog);
};
const ask = ({ title, message = '', confirmLabel = 'Bestätigen', variant = 'primary', onConfirm }) => {
if (!dialog) build();
if (dialog.open) dialog.close();
opener = document.activeElement;
dialog.querySelector('h2').textContent = title;
const text = dialog.querySelector('p'); text.textContent = message; text.hidden = '' === message;
const ok = dialog.querySelector('[data-confirm-ok]');
ok.textContent = confirmLabel;
ok.className = 'danger' === variant ? 'primary danger' : 'primary';
pending = onConfirm;
dialog.showModal();
// Der Fokus liegt auf „Abbrechen“: Die Eingabetaste darf nichts Endgültiges auslösen.
dialog.querySelector('[data-confirm-cancel]').focus();
};
window.kantConfirm = ask;
document.addEventListener('submit', event => {
const form = event.target;
if (!(form instanceof HTMLFormElement) || !form.dataset.confirmTitle || form.dataset.confirmed) return;
event.preventDefault();
const submitter = event.submitter;
ask({
title: form.dataset.confirmTitle,
message: form.dataset.confirmText || '',
confirmLabel: form.dataset.confirmLabel || submitter?.textContent.trim() || 'Bestätigen',
variant: form.dataset.confirmVariant || 'primary',
onConfirm: () => {
form.dataset.confirmed = '1';
// requestSubmit nimmt den Knopf mit, falls er einen Namen und Wert trägt.
submitter && submitter.form === form ? form.requestSubmit(submitter) : form.requestSubmit();
},
});
});
})();
+14 -2
View File
@@ -1920,7 +1920,15 @@ function onKey(event) {
if (!history || busy) return;
const key = event.key.toLowerCase();
if (event.ctrlKey || event.metaKey) {
if (key === 's') { event.preventDefault(); save(); }
if (key === 's') {
event.preventDefault();
// Viele Felder schreiben erst beim Verlassen ins Dokument (eigenes CSS, Zahlen im Design).
// Ohne diesen Schritt sicherte Strg+S den Stand ohne das, was gerade getippt wurde - und
// meldete trotzdem „gespeichert“.
const field = document.activeElement;
if (field && field.ownerDocument === document && inField(field) && !field.isContentEditable) field.blur();
save();
}
// Keep native text-field undo intact; the toolbar always undoes the complete document.
if (inField(event.target)) return;
if (key === 'z' || key === 'y') { event.preventDefault(); (event.shiftKey || key === 'y') ? history.redo() : history.undo(); changed(); }
@@ -1935,7 +1943,11 @@ function onKey(event) {
}
// Delete removes the selected element - but never while a field or the preview text is
// being typed in, and never something the sidebar buttons would refuse.
if ((event.key === 'Delete' || event.key === 'Backspace') && selected && !inField(event.target) && !inlineEdit && !$('#delete').disabled) {
// Auch nicht, während ein Dialog offen ist oder der Fokus auf einem Bedienelement der
// Seitenleiste liegt: Entf auf einer Kachel der Mediathek löschte sonst den gewählten Baustein.
const where = event.target;
const onStage = !where?.closest || where.ownerDocument !== document || where === document.body || Boolean(where.closest('#structure, .canvas-scroll'));
if ((event.key === 'Delete' || event.key === 'Backspace') && selected && onStage && !document.querySelector('dialog[open]') && !inField(event.target) && !inlineEdit && !$('#delete').disabled) {
event.preventDefault(); actions.delete(selected);
}
}
+6 -1
View File
@@ -316,7 +316,12 @@ if (root) {
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();
const card = button.closest('[data-field]');
const name = card.querySelector('[data-field-label]')?.value.trim();
const remove = () => { card.remove(); refresh(); add.focus(); };
// Ein eben angelegtes, leeres Feld verschwindet ohne Frage; alles andere fragt nach.
if (!name || !window.kantConfirm) { remove(); return; }
window.kantConfirm({ title: `Feld „${name}“ entfernen?`, message: 'Bereits eingegangene Anfragen behalten ihre Angaben. Wirksam wird es erst mit „Speichern“.', confirmLabel: 'Feld entfernen', variant: 'danger', onConfirm: remove });
});
list.addEventListener('input', event => {
const card = event.target.closest('[data-field]');