feat(post-form): text-only draft persistence (U6)

Mirror text field values (incl. EasyMDE content) to localStorage on every
edit and restore them on load. Clear the draft only when the server confirms
a successful post (.notices.success) — the invariant that guarantees text
survives validation failures, save errors, and session expiry (login form
shown → form absent → draft left intact for post-reauth restore). Photos are
not persisted (File/Blob can't serialize); on restore an inline hint says
they need re-selecting.

Refs R19 (preserve-on-failure), R20, KTD6.
This commit is contained in:
2026-07-04 16:45:28 +02:00
parent 7f021ab114
commit 9a96d0f19e
2 changed files with 147 additions and 61 deletions
File diff suppressed because one or more lines are too long
+88 -2
View File
@@ -401,11 +401,97 @@ function initValidation() {
}); });
} }
/* ── Draft persistence (U6, R19 preserve-on-failure + R20, KTD6) ──
* Text field values are mirrored to localStorage so a failed submit or an
* expired session mid-compose never loses the writing. Photos can't be
* serialized (File/Blob) so they are NOT persisted — an inline hint says so
* on restore. The draft is cleared ONLY when the server confirms a successful
* post (<div class="notices success">), which is exactly what guarantees the
* text survives every non-success outcome, independent of failure-type sniffing.
*/
var DRAFT_KEY = 'intotheeast:new-entry-draft';
function draftEls(form) {
return Array.prototype.slice.call(form.querySelectorAll('[name^="data["]'))
.filter(function (el) {
if (el.type === 'file') return false;
var n = el.name;
return n.indexOf('data[_json') !== 0 && n.indexOf('data[photos') !== 0;
});
}
function showReauthHint() {
var picker = document.querySelector('.photo-picker');
if (!picker || picker.querySelector('.photo-picker__reauth-hint')) return;
var p = document.createElement('p');
p.className = 'photo-picker__reauth-hint is-shown';
p.textContent = 'Your text was restored — photos need re-selecting (they cant be saved in a draft).';
picker.appendChild(p);
}
function initDraft() {
var form = document.querySelector('form[name="new-entry"]');
if (!form) return; // e.g. session expired → login form shown; draft is left intact
// A confirmed successful post: clear the draft and do NOT restore onto the
// freshly reset form. This runs before any save, so process.reset can't
// round-trip blank fields back into storage.
if (document.querySelector('.notices.success')) {
try { localStorage.removeItem(DRAFT_KEY); } catch (e) { /* ignore */ }
return;
}
function save() {
var data = {};
draftEls(form).forEach(function (el) {
if (el.type === 'radio') { if (el.checked) { data[el.name] = el.value; } }
else { data[el.name] = el.value; }
});
try { localStorage.setItem(DRAFT_KEY, JSON.stringify(data)); } catch (e) { /* quota / private mode */ }
}
var raw = null;
try { raw = localStorage.getItem(DRAFT_KEY); } catch (e) { raw = null; }
if (raw) {
var data = null;
try { data = JSON.parse(raw); } catch (e) { data = null; }
if (data) {
var restoredSomething = false;
Object.keys(data).forEach(function (name) {
var val = data[name];
if (val != null && String(val).trim()) { restoredSomething = true; }
if (name === 'data[content]') { return; } // restored via the editor below
var radios = form.querySelectorAll('input[type="radio"][name="' + name + '"]');
if (radios.length) {
radios.forEach(function (r) { r.checked = (r.value === val); });
return;
}
var el = form.querySelector('[name="' + name + '"]');
if (el && el.type !== 'file') { el.value = val; }
});
if (window.postFormEditor && data['data[content]'] != null) {
window.postFormEditor.value(data['data[content]']);
}
if (restoredSomething) { showReauthHint(); }
}
}
// Save text on every edit (change covers selects/toggles; input covers text).
form.addEventListener('input', save);
form.addEventListener('change', save);
if (window.postFormEditor) {
window.postFormEditor.codemirror.on('change', save);
}
}
/* ── Boot ────────────────────────────────────────────────── */ /* ── Boot ────────────────────────────────────────────────── */
function boot() { function boot() {
// Exposed for later units (U6 draft restore) and the Playwright specs; // Exposed for U6 draft restore and the Playwright specs; null when this
// null when this bundle loads on a page without the content field. // bundle loads on a page without the content field.
window.postFormEditor = initEditor(); window.postFormEditor = initEditor();
// Restore before disclosure/geo so their on-load checks (auto-open,
// weather-button enable) see the restored values.
initDraft();
initPhotoPicker(); initPhotoPicker();
initDisclosure(); initDisclosure();
initGeo(); initGeo();