Back up unsaved editor work locally, structure the settings, fix archive accessibility

- The editor keeps unsaved changes in the browser and offers them
  after a crash, a closed tab or an expired session. No timed save on
  the server: every save is a full revision.
- The save status stays visible in narrow windows.
- Settings get a sticky section bar, two folded groups and a save bar
  that stays in view.
- The admin post list fetches terms and images once, not per post.
- Archive post titles are second-level headings, the archive head
  keeps full text contrast, the skip link sits above a sticky header,
  a linked image without alternative text names its link.
- The editor check starts through the dev router like the others.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Fable 5.2 committed 2026-11-10 13:01:23 +01:00
1 parent 5e46c98825
commit b08862b139
17 files changed
+182 -26

No files matched your search

+54
View File
@@ -88,6 +88,51 @@ function playPreview() {
$('#preview-motion').addEventListener('click', () => { previewPlayback = !previewPlayback; playPreview(); });
// ---- Lokale Sicherung --------------------------------------------------------------------------
// Ungespeicherte Änderungen liegen zusätzlich im Browser dieses Geräts. Stürzt der Browser ab,
// wird der Tab geschlossen oder läuft die Sitzung ab, bietet der Editor sie beim nächsten Öffnen
// zum Wiederherstellen an. Bewusst kein Speichern auf dem Server im Takt: Jedes Speichern legt
// dort eine vollständige Fassung an, und die Liste der Fassungen soll zeigen, was jemand
// gespeichert hat - nicht jeden Tastendruck.
const backupKey = `kant-editor:${root.dataset.api}`;
let backupTimer = null, backupOffer = null;
function readBackup() {
try {
const entry = JSON.parse(localStorage.getItem(backupKey) || 'null');
return entry && Array.isArray(entry.document?.blocks) ? entry : null;
} catch { return null; }
}
function writeBackup() {
// Solange ein Angebot offen ist, bleibt die Sicherung unangetastet: Der eben geladene Stand
// ist unverändert und würde sie sonst löschen, bevor jemand entschieden hat.
if (!history || backupOffer) return;
try {
if (history.dirty) localStorage.setItem(backupKey, JSON.stringify({ at: Date.now(), revisionId, document: history.document }));
else localStorage.removeItem(backupKey);
} catch { /* Speicher voll oder gesperrt: dann ohne Sicherung, der Editor arbeitet weiter. */ }
}
function scheduleBackup() { clearTimeout(backupTimer); backupTimer = setTimeout(writeBackup, 600); }
function closeBackupOffer(remove) {
backupOffer = null; $('#local-backup').hidden = true;
if (remove) { try { localStorage.removeItem(backupKey); } catch { /* nichts zu tun */ } }
}
function offerBackup() {
const entry = readBackup();
if (!entry || JSON.stringify(entry.document) === JSON.stringify(history.document)) { closeBackupOffer(Boolean(entry)); return; }
backupOffer = entry;
const when = new Date(entry.at).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
$('#local-backup-text').textContent = `Auf diesem Gerät liegen ungespeicherte Änderungen vom ${when} Uhr.`
+ (entry.revisionId && entry.revisionId !== revisionId ? ' Seitdem wurde die Seite auf dem Server gespeichert – beim Wiederherstellen ersetzt dein Stand den neueren, sobald du speicherst.' : '');
$('#local-backup').hidden = false;
}
function restoreBackup() {
const entry = backupOffer; if (!entry || !history || busy) return;
const wanted = repairSchema(clone(entry.document));
// Als ein Schritt im Verlauf: „Rückgängig“ führt zum geladenen Stand zurück.
history.change(doc => { for (const key of Object.keys(doc)) delete doc[key]; Object.assign(doc, wanted); });
closeBackupOffer(false); selected = null; changed();
}
function notify(message = '', recover = false) {
$('#message').textContent = message;
$('#message').hidden = !message;
@@ -118,6 +163,10 @@ async function request(path, body, signal) {
function controls() {
const ready = Boolean(history);
$('#save-status').textContent = busy ? 'Wird gespeichert …' : conflict ? 'Speicherkonflikt' : !ready ? 'Wird geladen …' : history.dirty ? 'Ungespeicherte Änderungen' : editingArea ? 'Bereich gespeichert' : revisionId === publishedRevisionId ? 'Veröffentlicht' : 'Entwurf gespeichert';
// Der Zustand als Kennung: Auf schmalen Fenstern zeigt die Kopfzeile statt des ganzen Satzes
// nur ein kurzes Wort dazu (editor.css) - vorher verschwand die Anzeige dort ganz.
$('#save-status').dataset.state = busy ? 'saving' : conflict ? 'conflict' : !ready ? 'loading' : history.dirty ? 'dirty' : 'saved';
if (ready && !busy) scheduleBackup();
$('#save').disabled = !ready || busy || conflict || !history.dirty;
if ($('#publish')) $('#publish').disabled = !ready || busy || conflict || history.dirty || revisionId === publishedRevisionId;
$('#undo').disabled = !ready || busy || !history.past.length;
@@ -1451,6 +1500,7 @@ async function load() {
registerPlugins(data.plugins || [], data.presets || []); buildLibrary(); buildPluginPresets();
selected = null; propertyPanel = 'content'; collapsed.clear(); conflict = false; notify();
if (language) setView('translate');
offerBackup();
changed(); resizePreview();
} catch (error) { notify(error.message, true); }
finally { busy = false; controls(); }
@@ -1952,6 +2002,10 @@ function onKey(event) {
}
}
window.addEventListener('keydown', onKey);
$('#local-backup-restore').addEventListener('click', restoreBackup);
$('#local-backup-discard').addEventListener('click', () => closeBackupOffer(true));
// Beim Verlassen sofort schreiben, nicht erst nach der Wartezeit.
window.addEventListener('pagehide', () => { clearTimeout(backupTimer); writeBackup(); });
window.addEventListener('beforeunload', event => { if (history?.dirty || busy) { event.preventDefault(); event.returnValue = ''; } });
buildLibrary();
// The workspace is a fixed frame; a scroll position restored from an earlier visit would push