Files
intotheeast-com-content/themes/intotheeast/js/src/post-form.js
T
m038 9a96d0f19e 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.
2026-07-04 16:45:28 +02:00

506 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* /post journal form bundle — page-scoped (loaded only by post-form.html.twig).
* Kept out of the global main.js so EasyMDE (+ the U4 HEIC converter, lazy-loaded)
* never ship on any other page. Built as ESM with code-splitting so the heic-to
* dynamic import becomes a separately-fetched chunk (see package.json / KTD2).
*
* Every init function presence-guards its own DOM, mirroring main.js's
* initTripStats pattern, so this is a no-op if an element is absent.
*/
import EasyMDE from 'easymde';
import 'easymde/dist/easymde.min.css';
import './post-form.css';
/* ── Markdown editor (EasyMDE) ───────────────────────────── */
function initEditor() {
var textarea = document.querySelector('textarea[name="data[content]"]');
if (!textarea) return null;
var editor = new EasyMDE({
element: textarea,
spellChecker: false,
autoDownloadFontAwesome: false, // toolbar glyphs come from post-form.css, no FA CDN
status: false,
placeholder: 'What happened today?',
minHeight: '180px',
// Minimal toolbar; custom classNames drive the CSS glyphs in post-form.css.
toolbar: [
{ name: 'bold', action: EasyMDE.toggleBold, className: 'mde-btn mde-bold', title: 'Bold' },
{ name: 'italic', action: EasyMDE.toggleItalic, className: 'mde-btn mde-italic', title: 'Italic' },
{ name: 'unordered-list', action: EasyMDE.toggleUnorderedList, className: 'mde-btn mde-ul', title: 'Bulleted list' },
{ name: 'link', action: EasyMDE.drawLink, className: 'mde-btn mde-link', title: 'Insert link' },
'|',
{ name: 'preview', action: EasyMDE.togglePreview, className: 'mde-btn mde-preview', title: 'Toggle preview' },
],
});
// KTD3 / R15: keep the underlying <textarea> in sync with the editor so the
// form's custom required-field validator (which reads [name="data[content]"])
// and the submitted payload both see the live value.
editor.codemirror.on('change', function () { editor.codemirror.save(); });
var form = textarea.closest('form');
if (form) {
// Capture phase fires before the inline bubble-phase validator, so the
// textarea already holds the current value when validation reads it.
form.addEventListener('submit', function () { editor.codemirror.save(); }, true);
}
return editor;
}
/* ── Photo picker + client-side HEIC→JPEG (U4) ───────────────
* We replaced Grav's managed filepond field with a plain, controlled picker
* (see the `photos` field template) because filepond instant-uploads each file
* the moment it is added and its FilePond.create config is not ours to hook —
* so HEIC could never be converted before upload. Here we own the flow: sniff,
* lazy-convert HEIC, then POST each web-safe JPEG to Grav's AJAX file-upload
* route (into the form flash, exactly as filepond's server.process did) so
* add-page-by-form's copyFiles() picks them up on submit.
*/
// ISO-BMFF 'ftyp' brands that indicate HEIC/HEIF. Sniffed from bytes, never
// from the filename/MIME (R16) — iOS sometimes hands us .heic with an image/*
// MIME, and a renamed .jpg can still be HEIC underneath.
var HEIC_BRANDS = ['heic', 'heix', 'heif', 'hevc', 'hevx', 'mif1', 'msf1', 'heim', 'heis', 'hevm', 'hevs'];
function looksLikeHeic(file) {
return file.slice(0, 64).arrayBuffer().then(function (buf) {
var b = new Uint8Array(buf);
if (b.length < 12) return false;
// bytes 4..8 must be the 'ftyp' box type
if (String.fromCharCode(b[4], b[5], b[6], b[7]) !== 'ftyp') return false;
// scan major brand + compatible brands (4-byte codes from offset 8)
var brands = '';
for (var i = 8; i + 4 <= b.length; i += 4) {
brands += String.fromCharCode(b[i], b[i + 1], b[i + 2], b[i + 3]).toLowerCase();
}
return HEIC_BRANDS.some(function (brand) { return brands.indexOf(brand) !== -1; });
}).catch(function () { return false; });
}
function slugifyJpegName(name) {
var base = String(name || 'photo').replace(/\.[^.]+$/, '');
base = base.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
return (base || 'photo') + '.jpg';
}
function initPhotoPicker() {
var root = document.querySelector('.photo-picker');
if (!root) return;
var input = root.querySelector('.photo-picker__input');
var list = root.querySelector('.photo-picker__list');
var hint = root.querySelector('.photo-picker__hint');
var form = root.closest('form');
if (!input || !form) return;
var fieldName = root.getAttribute('data-file-field-name') || 'photos';
var uploadUrl = root.getAttribute('data-file-url-add');
var limit = parseInt(root.getAttribute('data-limit'), 10) || 4;
var attached = 0; // photos that converted + uploaded (or are reserved) OK
var inFlight = 0; // conversions/uploads still running — gates Submit (R17)
function hiddenVal(name) {
var el = form.querySelector('[name="' + name + '"]');
return el ? el.value : '';
}
function setHint(msg, kind) {
if (!hint) return;
hint.textContent = msg || '';
hint.className = 'photo-picker__hint form-status' + (kind ? ' form-status--' + kind : '');
}
function updateSubmitState() {
var btn = form.querySelector('button[type="submit"], input[type="submit"]');
if (btn) btn.disabled = inFlight > 0;
root.classList.toggle('is-busy', inFlight > 0);
}
function addCard(file) {
var li = document.createElement('li');
li.className = 'photo-card is-converting';
var img = document.createElement('img');
img.className = 'photo-card__thumb';
img.alt = file.name || 'photo';
var state = document.createElement('span');
state.className = 'photo-card__state';
state.textContent = 'converting…';
li.appendChild(img);
li.appendChild(state);
if (list) list.appendChild(li);
return { li: li, img: img, state: state };
}
function uploadToFlash(blob, filename) {
var fd = new FormData();
fd.append('__form-name__', hiddenVal('__form-name__'));
fd.append('__unique_form_id__', hiddenVal('__unique_form_id__'));
fd.append('__form-file-uploader__', '1');
fd.append('form-nonce', hiddenVal('form-nonce'));
fd.append('name', fieldName);
fd.append('data[' + fieldName + '][]', blob, filename);
return fetch(uploadUrl, {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
body: fd,
credentials: 'same-origin'
}).then(function (r) { return r.json(); }).then(function (j) {
if (!j || j.status === 'error') {
throw new Error((j && j.message) || 'Upload failed');
}
return j;
});
}
function processFile(file) {
if (attached >= limit) {
setHint('You can attach up to ' + limit + ' photos.', 'err');
return;
}
attached++; // reserve a slot; released if this photo fails
inFlight++;
updateSubmitState();
var card = addCard(file);
looksLikeHeic(file).then(function (isHeic) {
if (!isHeic) return file; // already web-renderable (R9) — pass through
// Lazy-load the converter only when a HEIC is actually picked (KTD4);
// this is the separately-fetched code-split chunk.
return import('heic-to').then(function (mod) {
var heicTo = mod.heicTo || (mod.default && mod.default.heicTo);
return heicTo({ blob: file, type: 'image/jpeg', quality: 0.85 });
});
}).then(function (out) {
var passedThrough = out === file;
var filename = (passedThrough && /\.(jpe?g|png)$/i.test(file.name)) ? file.name : slugifyJpegName(file.name);
try { card.img.src = URL.createObjectURL(out); } catch (e) { /* no preview */ }
card.state.textContent = 'uploading…';
return uploadToFlash(out, filename);
}).then(function () {
card.li.classList.remove('is-converting');
card.li.classList.add('is-done');
card.state.textContent = '✓';
}).catch(function () {
// Fail closed (R16): drop this photo, keep the others + Submit usable,
// never upload the original HEIC.
attached--;
card.li.classList.remove('is-converting');
card.li.classList.add('is-error');
card.state.textContent = '✗ skipped';
setHint('A photo could not be processed and was skipped — the others are fine.', 'err');
}).then(function () {
inFlight--;
updateSubmitState();
});
}
input.addEventListener('change', function () {
setHint('');
Array.prototype.slice.call(input.files || []).forEach(processFile);
input.value = ''; // let the same file be re-picked after a removal/error
});
// Backstop for the Submit gate (button is also disabled while in flight).
form.addEventListener('submit', function (e) {
if (inFlight > 0) {
e.preventDefault();
setHint('Hang on — photos are still uploading.', 'err');
}
}, true);
}
/* ── Field helper ────────────────────────────────────────── */
// Attribute selector matches input, select AND textarea — unlike the old
// input[...] lookup, which broke once weather_desc became a <select> (U2).
function field(name) {
return document.querySelector('[name="data[' + name + ']"]');
}
/* ── "More options" disclosure (U5, KTD5) ─────────────────────
* The blueprint renders the advanced trio (hero_image/force_connect/featured)
* flat; here we relocate their .form-field wrappers into a native <details> so
* they collapse by default with full keyboard/AT support. Auto-opens if any
* advanced field already carries a value (edit / draft restore).
*/
function initDisclosure() {
var flagged = Array.prototype.slice.call(document.querySelectorAll('.advanced-field'));
if (!flagged.length) return;
var wrappers = [];
flagged.forEach(function (el) {
var w = el.closest('.form-field');
if (w && wrappers.indexOf(w) === -1) wrappers.push(w);
});
if (!wrappers.length) return;
var details = document.createElement('details');
details.className = 'more-options';
var summary = document.createElement('summary');
summary.className = 'more-options__summary';
summary.textContent = 'More options';
details.appendChild(summary);
wrappers[0].parentNode.insertBefore(details, wrappers[0]);
wrappers.forEach(function (w) { details.appendChild(w); });
var hasValue = wrappers.some(function (w) {
var text = w.querySelector('input[type="text"]');
if (text && text.value.trim()) return true;
var toggle = w.querySelector('input[type="radio"]:checked');
if (toggle && toggle.value && toggle.value !== '0') return true;
return false;
});
if (hasValue) details.open = true;
}
/* ── Get Location / Get Weather (U5, R18) ─────────────────────
* Migrated out of the template's inline scripts so it can read the now-<select>
* weather_desc field and expose idle/loading/success/error states.
*/
var WMO_MAP = {
0: 'Sunny', 1: 'Partly cloudy', 2: 'Partly cloudy', 3: 'Cloudy',
45: 'Foggy', 48: 'Foggy',
51: 'Drizzle', 53: 'Drizzle', 55: 'Drizzle', 56: 'Drizzle', 57: 'Drizzle',
61: 'Rain', 63: 'Rain', 65: 'Rain', 66: 'Rain', 67: 'Rain', 80: 'Rain', 81: 'Rain', 82: 'Rain',
71: 'Snow', 73: 'Snow', 75: 'Snow', 77: 'Snow', 85: 'Snow', 86: 'Snow',
95: 'Thunderstorm', 96: 'Thunderstorm', 99: 'Thunderstorm'
};
function setStatus(el, msg, kind) {
if (!el) return;
el.className = 'form-status' + (kind ? ' form-status--' + kind : '');
el.textContent = msg || '';
}
function initGeo() {
var locBtn = document.getElementById('get-location');
var wxBtn = document.getElementById('get-weather');
if (!locBtn && !wxBtn) return;
var locStatus = document.getElementById('location-status');
var wxStatus = document.getElementById('weather-status');
function coords() {
var latEl = field('lat');
var lngEl = field('lng');
var lat = latEl ? latEl.value.trim() : '';
var lng = lngEl ? lngEl.value.trim() : '';
return (lat && lng) ? { lat: lat, lng: lng } : null;
}
// Get Weather is meaningless without coordinates — gate it (R18).
function syncWeatherEnabled() {
if (!wxBtn) return;
var ok = !!coords();
wxBtn.disabled = !ok;
wxBtn.title = ok ? '' : 'Get location first';
}
syncWeatherEnabled();
if (locBtn) {
locBtn.addEventListener('click', function () {
if (!navigator.geolocation) {
setStatus(locStatus, 'Geolocation is not supported on this device.', 'err');
return;
}
locBtn.classList.add('is-loading');
locBtn.disabled = true;
setStatus(locStatus, 'Getting location…');
navigator.geolocation.getCurrentPosition(function (pos) {
var lat = pos.coords.latitude.toFixed(6);
var lng = pos.coords.longitude.toFixed(6);
var latEl = field('lat');
var lngEl = field('lng');
if (latEl) latEl.value = lat;
if (lngEl) lngEl.value = lng;
locBtn.classList.remove('is-loading');
locBtn.disabled = false;
setStatus(locStatus, '✓ Location captured · ' + lat + ', ' + lng, 'ok');
syncWeatherEnabled();
}, function (err) {
locBtn.classList.remove('is-loading');
locBtn.disabled = false;
setStatus(locStatus, '✗ ' + (err && err.message ? err.message : 'Could not get location') + ' — enter coordinates manually if needed.', 'err');
}, { enableHighAccuracy: true, timeout: 15000 });
});
}
if (wxBtn) {
wxBtn.addEventListener('click', function () {
var c = coords();
if (!c) {
setStatus(wxStatus, 'Get location first, then fetch weather.', 'err');
return;
}
wxBtn.classList.add('is-loading');
wxBtn.disabled = true;
setStatus(wxStatus, 'Fetching weather…');
var url = 'https://api.open-meteo.com/v1/forecast?latitude=' + c.lat +
'&longitude=' + c.lng + '&current=temperature_2m,weather_code&temperature_unit=celsius';
fetch(url).then(function (r) { return r.json(); }).then(function (data) {
var temp = Math.round(data.current.temperature_2m);
var desc = WMO_MAP[data.current.weather_code] || 'Cloudy';
var tempEl = field('weather_temp_c');
var descEl = field('weather_desc');
if (tempEl) tempEl.value = temp;
if (descEl) descEl.value = desc; // works for <select> too (R6)
wxBtn.classList.remove('is-loading');
syncWeatherEnabled();
setStatus(wxStatus, '✓ Weather set · ' + desc + ' · ' + temp + '°C (edit above if needed)', 'ok');
}).catch(function () {
wxBtn.classList.remove('is-loading');
syncWeatherEnabled();
setStatus(wxStatus, '✗ Could not fetch weather — set it manually above.', 'err');
});
});
}
}
/* ── Blocking required-field validation (U5, R19) ─────────────
* Replaces the template's inline validator. Reads the EasyMDE-synced content
* value (initEditor keeps the textarea current) and shows per-field messages.
*/
function initValidation() {
var REQUIRED = { title: 'Title', content: 'Content' };
var form = document.querySelector('form[name="new-entry"]');
if (!form) return;
function clearErrors() {
form.querySelectorAll('.field-error').forEach(function (el) { el.remove(); });
form.querySelectorAll('.field-invalid').forEach(function (el) { el.classList.remove('field-invalid'); });
}
function showError(el, msg) {
el.classList.add('field-invalid');
var err = document.createElement('span');
err.className = 'field-error';
err.textContent = msg;
el.parentNode.insertBefore(err, el.nextSibling);
}
// Bubble phase: runs after initEditor's capture-phase codemirror.save(), so
// the content textarea already holds the live value.
form.addEventListener('submit', function (e) {
clearErrors();
var firstInvalid = null;
Object.keys(REQUIRED).forEach(function (name) {
var el = field(name);
if (el && !String(el.value).trim()) {
showError(el, REQUIRED[name] + ' is required.');
if (!firstInvalid) firstInvalid = el;
}
});
if (firstInvalid) {
e.preventDefault();
firstInvalid.focus();
firstInvalid.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
});
}
/* ── 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 ────────────────────────────────────────────────── */
function boot() {
// Exposed for U6 draft restore and the Playwright specs; null when this
// bundle loads on a page without the content field.
window.postFormEditor = initEditor();
// Restore before disclosure/geo so their on-load checks (auto-open,
// weather-button enable) see the restored values.
initDraft();
initPhotoPicker();
initDisclosure();
initGeo();
initValidation();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}