Files
intotheeast-com-content/themes/intotheeast/js/src/post-form.js
T
m038andClaude Fable 5 e17a5dc972 fix(theme): block submit on unfinished photo uploads; un-squeeze EXIF portraits in lightbox
Two prod bugs from the 2026-07-09 owner test:

- post-form.js: complete upload gate on create submit. The form plugin's
  guard only blocks PROCESSING/QUEUED, so a failed upload (processing-error)
  or a just-picked file (loading) submitted silently and the entry saved
  without its photo. Submit is now blocked unless every FilePond item is
  processing-complete, with a visible status message for the failed vs
  still-uploading cases. (Bundle rebuilt via make build-assets.)

- entry-journal partial: PhotoSwipe slides now link to a 2000px fit-within
  derivative and measure THAT file for data-pswp-width/height. The old
  img.width/height came from raw getimagesize() of the original, which
  ignores EXIF orientation, so stored-rotated portrait JPEGs got landscape
  slide boxes and rendered squeezed. Derivatives are re-encoded (EXIF
  stripped, orientation baked in server-side), so declared dims always match
  rendering. Also fixes the wrapper aspect-ratio pick for portrait-first
  entries. Note: Medium 'path' must be called as path() in Twig — the
  ArrayAccess 'path' item (page folder) shadows the method.

Covered by tests/ui/post/upload-gate.spec.js and lightbox-dims.spec.js in
the dev repo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0195b3cDdMeize2Mm1FgC2aU
2026-07-09 17:56:01 +02:00

1249 lines
58 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 Sortable from 'sortablejs';
import 'easymde/dist/easymde.min.css';
import './post-form.css';
import { apiSend, apiErrorMsg } from './api-utils.js';
/* ── 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';
}
// Insert (once) a status line just after the FilePond field for conversion
// feedback, returning it. FilePond owns the per-thumbnail chrome; this line
// carries the pre-FilePond "converting…" / error state.
function photoStatusEl() {
var root = document.querySelector('.filepond-root, .form-input-file');
if (!root || !root.parentNode) return null;
var el = root.parentNode.querySelector('.photo-convert-status');
if (!el) {
el = document.createElement('p');
el.className = 'photo-convert-status form-status';
el.setAttribute('role', 'status');
root.parentNode.insertBefore(el, root.nextSibling);
}
return el;
}
// Wrap the photo picker in a native <details> so it can auto-collapse to a
// live summary bar once uploads settle (issue #3: thumbnails otherwise crowd
// the writing area). The field's own <label> is hidden — the <summary> becomes
// the section header. Returns { details, summary } or null when no picker.
function buildPhotoCollapse() {
var root = document.querySelector('.filepond-root, .form-input-file');
if (!root) return null;
var wrapper = root.closest('.form-field');
if (!wrapper) return null;
// Grav wraps the field label in a .form-label div (not a bare <label>);
// hide it so the <summary> is the section's only header.
var fieldLabel = wrapper.querySelector('.form-label');
var details = document.createElement('details');
details.className = 'photos-collapse';
details.open = true;
var summary = document.createElement('summary');
summary.className = 'photos-collapse__summary';
summary.textContent = 'Photos (16)';
details.appendChild(summary);
// Relocate the existing control (+ label/status) into the details body.
while (wrapper.firstChild) details.appendChild(wrapper.firstChild);
wrapper.appendChild(details);
if (fieldLabel) fieldLabel.style.display = 'none';
return { details: details, summary: summary };
}
function initPhotoConversion() {
var form = document.querySelector('form[name="new-entry"]');
if (!form) return;
// Edit mode runs its OWN live photo editor (initPhotoEditor) against the media
// API and decommissions FilePond entirely — no collapse, no HEIC-into-FilePond
// hooks, and crucially no `photo_order` submit handler. A stale photo_order
// posted on text Save would drive cache-on-save.reconcilePhotos() →
// deleteUnlistedImages() and silently delete any photo added live after the
// page opened. Skipping the wiring here (FilePond left unpopulated) means no
// manifest is posted, so the live-managed folder is left untouched on Save.
if (EDIT_MODE) return;
var converting = 0; // HEIC conversions in flight (before FilePond) — gates Submit
var collapse = buildPhotoCollapse();
var orderPond = null; // set when the managed FilePond instance is found
// FilePond does NOT re-sequence its submitted data[photos][] inputs when the
// list is reordered — those stay in upload order — so the drag order never
// reaches the server on its own. Send it explicitly: on submit, write the
// current visual order into a hidden input. Its name is a TOP-LEVEL POST key
// ("photo_order", not "data[...]") so Grav's form never captures it into the
// page data — the server reads it straight from $_POST and renames the
// copied files photo-01..NN to match, with nothing leaking into frontmatter.
form.addEventListener('submit', function () {
if (!orderPond) return;
var files = orderPond.getFiles();
if (!files.length) return; // text-only post — send nothing
var hidden = form.querySelector('input[name="photo_order"]');
if (!hidden) {
hidden = document.createElement('input');
hidden.type = 'hidden';
hidden.name = 'photo_order';
form.appendChild(hidden);
}
hidden.value = JSON.stringify(files.map(function (f) { return f.filename; }));
}, true);
function setStatus(msg, kind) {
var el = photoStatusEl();
if (!el) return;
el.textContent = msg || '';
el.className = 'photo-convert-status form-status' + (kind ? ' form-status--' + kind : '');
}
// Live summary + auto-collapse for the photo section. Reads FilePond's DOM
// item states (robust across FilePond versions) plus the pre-FilePond HEIC
// `converting` counter, so the section stays open while anything is in
// flight and collapses to "✓ N ready" only once everything has settled.
function refreshCollapse() {
if (!collapse) return;
var items = document.querySelectorAll('.filepond--item[data-filepond-item-state]');
var total = items.length;
var done = 0;
Array.prototype.forEach.call(items, function (el) {
if (el.getAttribute('data-filepond-item-state') === 'processing-complete') done++;
});
if (converting > 0 || (total > 0 && done < total)) {
collapse.summary.textContent = converting > 0
? 'Converting ' + converting + ' photo' + (converting > 1 ? 's' : '') + '…'
: 'Uploading ' + total + ' photo' + (total > 1 ? 's' : '') + '…';
collapse.details.open = true; // keep visible while working
} else if (total > 0) {
collapse.summary.textContent = '✓ ' + total + ' photo' + (total > 1 ? 's' : '') + ' ready — tap to review';
collapse.details.open = false; // auto-collapse when settled
} else {
collapse.summary.textContent = 'Photos (16)';
collapse.details.open = true;
}
}
// FilePond fires processfile/processfiles a tick before it flips the item's
// data-filepond-item-state to "processing-complete", so a refresh bound
// straight to the event reads a stale state. Re-check on trailing timeouts
// so the summary settles on the final DOM state.
function scheduleRefresh() {
refreshCollapse();
setTimeout(refreshCollapse, 150);
setTimeout(refreshCollapse, 500);
}
function updateGate() {
var btn = form.querySelector('button[type="submit"], input[type="submit"]');
if (btn) btn.disabled = converting > 0;
if (converting > 0) {
setStatus('Converting ' + converting + ' photo' + (converting > 1 ? 's' : '') + '…');
} else {
var el = photoStatusEl();
if (el && /Converting/.test(el.textContent)) setStatus('');
}
refreshCollapse();
}
// Guard the submit while a HEIC is still converting (it hasn't entered
// FilePond yet, so FilePond's own processing gate wouldn't cover it). R17.
form.addEventListener('submit', function (e) {
if (converting > 0) {
e.preventDefault();
setStatus('Hang on — a photo is still converting.', 'err');
}
}, true);
// Complete upload gate (BUG 2026-07-09: fast create submit lost the photo).
// The form plugin's own submit guard (filepond-handler.js) only blocks the
// PROCESSING / PROCESSING_QUEUED states. A file still being read (LOADING —
// the "too quick" click) or one whose upload FAILED (PROCESSING_ERROR)
// slips through it and the entry is saved without the photo — silently,
// because an errored thumbnail still satisfies the ≥1-photo count. Block
// submit unless every FilePond item reached PROCESSING_COMPLETE, and say
// which case blocked it.
form.addEventListener('submit', function (e) {
if (!orderPond) return;
var files = orderPond.getFiles();
if (!files.length) return; // the ≥1-photo rule is initValidation's job
var statuses = (window.FilePond && window.FilePond.FileStatus) || {};
var pending = 0;
var failed = 0;
files.forEach(function (f) {
if (f.status === statuses.PROCESSING_COMPLETE) return;
if (f.status === statuses.PROCESSING_ERROR || f.status === statuses.LOAD_ERROR) failed++;
else pending++; // LOADING, INIT, IDLE, QUEUED, PROCESSING, …
});
if (!pending && !failed) return;
e.preventDefault();
if (collapse) collapse.details.open = true; // reveal the item states
setStatus(failed
? 'A photo failed to upload — remove it (tap its ✕) and re-add it before posting.'
: 'Photos are still uploading — hang on a moment.', 'err');
var el = photoStatusEl();
if (el && typeof el.scrollIntoView === 'function') {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, true);
// FilePond hook: reject a HEIC item, convert it to JPEG via the lazy heic-to
// chunk (KTD4), then re-add the JPEG through pond.addFile() so FilePond
// uploads and page-attaches it via its own (correct) contract.
function makeBeforeAddFile(pond) {
return function (item) {
var file = item && item.file;
if (!file) return true;
return looksLikeHeic(file).then(function (isHeic) {
if (!isHeic) return true; // web format (R9) — FilePond handles it
converting++;
updateGate();
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 (jpeg) {
var jpegFile = new File([jpeg], slugifyJpegName(file.name), { type: 'image/jpeg' });
pond.addFile(jpegFile);
}).catch(function () {
// Fail closed (R16): the original HEIC is never added/uploaded.
setStatus('A photo couldnt be converted and was skipped — the others are fine.', 'err');
}).then(function () {
converting--;
updateGate();
});
return false; // reject the original HEIC
});
};
}
// The managed FilePond instance is created by the form plugin's
// filepond-handler (DOMContentLoaded). Poll briefly, then attach the hook.
var tries = 0;
(function attach() {
var ponds = (window.GravFilePond && window.GravFilePond.getInstances)
? window.GravFilePond.getInstances() : [];
if (!ponds.length) {
if (tries++ < 120) setTimeout(attach, 50);
return;
}
ponds.forEach(function (pond) {
if (pond && !pond._heicHooked) {
pond._heicHooked = true;
// allowReorder: drag thumbnails to set the order. The server
// (cache-on-save) renames the copied files photo-01..NN in the
// submitted order so the published entry honours it (entry media
// is filename-ordered; hero = first).
pond.setOptions({ beforeAddFile: makeBeforeAddFile(pond), allowReorder: true, itemInsertLocation: 'after' });
orderPond = pond;
// Update the collapse summary as files are added/uploaded/removed.
['addfile', 'processfile', 'processfiles', 'removefile', 'error'].forEach(function (ev) {
try { pond.on(ev, scheduleRefresh); } catch (e) { /* older FilePond API */ }
});
refreshCollapse();
}
});
})();
}
/* ── 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 fields (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); });
// A toggle only counts as an "advanced value" worth auto-expanding for when
// it DEVIATES from its blueprint default — otherwise `published` (default ON)
// would trip this on every plain create and render More options expanded.
// Read each toggle's default straight from the markup rather than hardcoding
// field names: Grav's toggle template stamps the HTML `checked` attribute on
// the default option (value ?? default ?? highlight), and prefill/edit only
// ever set the live `.checked` property — never the attribute — so `[checked]`
// still points at the blueprint default while `:checked` is the current state.
// This stays correct if a future advanced toggle defaults ON. (Edit mode
// force-opens the panel elsewhere — see initEditMode — so this only governs
// the create / draft-restore case.)
var hasValue = wrappers.some(function (w) {
var text = w.querySelector('input[type="text"]');
if (text && text.value.trim()) return true;
var current = w.querySelector('input[type="radio"]:checked');
if (!current || !current.value) return false;
var def = w.querySelector('input[type="radio"][checked]');
return def ? current.value !== def.value : current.value !== '0';
});
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();
// Reverse-geocode the captured coordinates into City + Country via
// BigDataCloud's free client endpoint (no API key, CORS-enabled). Only
// fills fields the traveller left blank — never clobbers a manual entry —
// and appends the resolved place to the location status. Best-effort: a
// failure leaves the coordinates (and the manual city/country fields) intact.
function reverseGeocode(lat, lng) {
var cityEl = field('location_city');
var countryEl = field('location_country');
// Nothing to fill if the traveller already typed both.
if ((!cityEl || cityEl.value.trim()) && (!countryEl || countryEl.value.trim())) return;
var url = 'https://api.bigdatacloud.net/data/reverse-geocode-client?latitude=' +
encodeURIComponent(lat) + '&longitude=' + encodeURIComponent(lng) + '&localityLanguage=en';
fetch(url).then(function (r) { return r.json(); }).then(function (data) {
// BigDataCloud's `city` is often empty in rural areas; `locality`
// is the more reliably-populated place name, so fall back to it.
var city = (data.city || data.locality || '').trim();
var country = (data.countryName || '').trim();
if (cityEl && !cityEl.value.trim() && city) cityEl.value = city;
if (countryEl && !countryEl.value.trim() && country) countryEl.value = country;
var place = [city, country].filter(Boolean).join(', ');
if (place) {
setStatus(locStatus, '✓ Location captured · ' + place, 'ok');
}
}).catch(function () { /* keep the coordinates-only status */ });
}
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();
reverseGeocode(lat, lng); // fill City/Country in the background
}, 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.
*/
// Current local wall-clock time as the value a datetime-local input expects
// ("YYYY-MM-DDTHH:MM"). Uses the browser's timezone — the server has no idea
// where the traveller is — and strips seconds so the value round-trips cleanly.
function localNowDatetime() {
var d = new Date();
d.setSeconds(0, 0);
d.setMinutes(d.getMinutes() - d.getTimezoneOffset());
return d.toISOString().slice(0, 16);
}
function initValidation() {
var REQUIRED = { title: 'Title', date: 'Date & time', content: 'Content' };
var form = document.querySelector('form[name="new-entry"]');
if (!form) return;
// Prefill the date/time picker with "now" (local) unless draft restore
// already put a value there. initDraft runs before initValidation in boot(),
// so an empty field here means there was no saved draft date to keep.
var dateEl = field('date');
if (dateEl && !String(dateEl.value).trim()) {
dateEl.value = localNowDatetime();
}
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);
}
// At least one photo is required. The picker is first in the form, so if it
// is empty this is the first invalid field — reveal the (possibly collapsed)
// section, show the message under its header, and return an element to
// scroll to. Uses the same .field-error class so clearErrors() cleans it up.
function showPhotoError(msg) {
var collapse = form.querySelector('.photos-collapse');
var host, anchor;
if (collapse) {
collapse.open = true; // reveal the drop zone + message
anchor = collapse.querySelector('.photos-collapse__summary');
host = collapse;
} else {
host = document.querySelector('.filepond-root, .form-input-file');
anchor = host;
}
if (!anchor) return host || null;
var err = document.createElement('span');
err.className = 'field-error';
err.textContent = msg;
anchor.insertAdjacentElement('afterend', err);
return host || anchor;
}
// 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;
// ≥1 photo (photos are the first field). Count any present FilePond item.
// Skipped in edit mode (U7): the edit form loads the entry's existing
// photos, and the owner may deliberately remove all of them to leave a
// text-only entry — so an empty FilePond on an edit submit is allowed.
if (!EDIT_MODE && document.querySelectorAll('.filepond--item').length < 1) {
firstInvalid = showPhotoError('Add at least one photo.');
}
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();
if (typeof firstInvalid.focus === 'function') 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() {
// The filepond-root div is server-rendered, so it exists even before
// FilePond enhances it. Place the hint right after the photo field.
var root = document.querySelector('.filepond-root, .form-input-file');
if (!root || !root.parentNode) return;
if (root.parentNode.querySelector('.photo-reauth-hint')) return;
var p = document.createElement('p');
p.className = 'photo-reauth-hint is-shown';
p.textContent = 'Your text was restored — photos need re-selecting (they cant be saved in a draft).';
root.parentNode.insertBefore(p, root.nextSibling);
}
function initDraft() {
var form = document.querySelector('form[name="new-entry"]');
if (!form) return; // e.g. session expired → login form shown; draft is left intact
if (EDIT_MODE) return; // editing an existing entry: don't restore/overwrite with a create draft
// 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);
}
}
/* ── Post-success confirmation (issue: message was off-screen) ─────
* After a successful submit the page re-renders with Grav's success notice at
* the top of a long, reset form — easy to miss on mobile. Scroll it into view
* and add a "View your journal" / "Post another" CTA the owner can act on.
*/
function initSuccessState() {
var wrap = document.querySelector('.post-form-wrap');
var notice = document.querySelector('.post-form-wrap .notices.success, .post-form-wrap .notices.green');
if (!wrap || !notice) return;
var panel = document.createElement('div');
panel.className = 'post-success';
var title = document.createElement('p');
title.className = 'post-success__title';
title.textContent = '✓ Saved to your journal.';
panel.appendChild(title);
var actions = document.createElement('div');
actions.className = 'post-success__actions';
var tripUrl = wrap.getAttribute('data-trip-url');
if (tripUrl) {
var view = document.createElement('a');
view.className = 'post-success__view';
view.href = tripUrl;
view.textContent = 'View your journal →';
actions.appendChild(view);
}
var again = document.createElement('a');
again.className = 'post-success__again';
again.href = window.location.pathname; // reload /post fresh
again.textContent = 'Post another';
actions.appendChild(again);
panel.appendChild(actions);
notice.parentNode.insertBefore(panel, notice.nextSibling);
// Issue #1: after a successful post, show the confirmation *only* — hide the
// (now-reset) form and the location/weather controls so a stray empty form
// doesn't render below the success panel. The .notices message and the
// injected panel live in .form-wrapper *before* the <form>, so hiding the
// form leaves them visible.
['form', '.form-action-row', '#location-status', '#weather-status'].forEach(function (sel) {
var el = wrap.querySelector(sel);
if (el) el.style.display = 'none';
});
notice.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
/* ── Edit mode (U5, KTD4; U7 photos): prefill from the API, adapt the form ─────
* The Edit control on a journal card links to /post?edit=<entry-route>. We detect
* that param, disable the form (D1), fetch the entry via the session-auth Grav
* API (credentials:include — the gpx-manager pattern), populate every field, set
* the hidden edit_path so the save writes back in place (cache-on-save toggles
* overwrite_mode:edit server-side), and switch the chrome to "Edit entry" /
* "Save changes" (D6).
*
* Photos: FilePond is decommissioned in edit mode (see initPhotoConversion's
* EDIT_MODE guard). Instead initPhotoEditor renders our own thumbnail grid from
* the media API and persists add/delete/reorder LIVE — decoupled from this form's
* text-field Save. The >=1-photo rule stays relaxed in edit mode so an owner may
* leave a text-only entry after removing every photo.
*/
var EDIT_MODE = false;
function qparam(name) {
return new URLSearchParams(window.location.search).get(name);
}
function editSetText(name, value) {
var el = field(name);
if (el) el.value = (value === undefined || value === null) ? '' : String(value);
}
// Toggles (published/featured/force_connect) render as a 1/0 radio pair.
function editSetToggle(name, truthy) {
var want = truthy ? '1' : '0';
var radios = document.querySelectorAll('[name="data[' + name + ']"]');
Array.prototype.forEach.call(radios, function (r) { r.checked = (String(r.value) === want); });
}
function editSetContent(value) {
var text = (value === undefined || value === null) ? '' : String(value);
if (window.postFormEditor && typeof window.postFormEditor.value === 'function') {
window.postFormEditor.value(text);
} else {
var ta = field('content');
if (ta) ta.value = text;
}
}
// Disable/enable the form's own fields + submit (get-location/weather live
// OUTSIDE the form, so they're untouched). Also gates the EasyMDE editor.
//
// The photos/FilePond control is deliberately EXCLUDED: FilePond reads the
// disabled state of its underlying input when the form plugin creates it and
// never re-enables. In edit mode FilePond is decommissioned anyway — the live
// photo editor (initPhotoEditor) hides it and manages add/delete/reorder against
// the media API independently of this form's Save — so its disabled state during
// the D1 prefill window is irrelevant.
function editFormDisabled(form, disabled) {
var els = form.querySelectorAll('input, textarea, select, button');
Array.prototype.forEach.call(els, function (el) {
if (el.type === 'file' || (el.name && el.name.indexOf('data[photos') === 0)) return;
el.disabled = disabled;
});
if (window.postFormEditor && window.postFormEditor.codemirror) {
window.postFormEditor.codemirror.setOption('readOnly', disabled ? 'nocursor' : false);
}
}
function editSubmitLabel(btn, text) {
if (!btn) return;
if (btn.tagName === 'INPUT') btn.value = text; else btn.textContent = text;
}
function editShowError(wrap, msg) {
var existing = wrap.querySelector('.post-edit-error');
if (existing) { existing.textContent = msg; return; }
var banner = document.createElement('div');
banner.className = 'post-edit-error';
banner.setAttribute('role', 'alert');
banner.textContent = msg;
var h1 = wrap.querySelector('h1');
if (h1) h1.insertAdjacentElement('afterend', banner); else wrap.insertBefore(banner, wrap.firstChild);
}
/* ── Photo editor (edit mode): live add / delete / reorder via the media API ───
* Replaces the FilePond photo path in edit mode with our own thumbnail grid (the
* gpx-manager pattern): add + delete hit the STOCK media API and reorder hits the
* custom /entry/{slug}/photos/order route; each op persists IMMEDIATELY, decoupled
* from the form's text-field Save. SortableJS provides the drag FilePond couldn't
* do reliably here. After every mutation the entry is renumbered photo-01..NN
* server-side so the feed cover (media.images|first) follows the arranged order.
*
* Every op's failure path shows an inline error and never lets the shown grid
* disagree with disk without saying so: a failed reorder reverts the SortableJS
* move to the last-known-good order; a failed add rolls its uploads back.
*/
var PHOTO_EXT_RE = /\.(jpe?g|png|webp|heic|heif|gif)$/i;
// Numeric index behind a photo-NN filename (zero-padding tolerant); non-photo
// names (freshly stock-uploaded, not yet renumbered) sort last, then by name.
function photoIndexOf(name) {
var m = /(?:^|\/)photo-0*(\d+)\./i.exec(name);
return m ? parseInt(m[1], 10) : Number.MAX_SAFE_INTEGER;
}
function byPhotoOrder(a, b) {
var ia = photoIndexOf(a), ib = photoIndexOf(b);
if (ia !== ib) return ia - ib;
return a < b ? -1 : (a > b ? 1 : 0);
}
// A throwaway, collision-proof upload name. The file is renumbered to photo-NN
// immediately after the batch, so this name only has to be unique enough not to
// clobber an existing photo-NN or a same-named sibling in the same add batch.
function uploadName(origName, forceJpeg) {
var s = String(origName || 'photo');
var dot = s.lastIndexOf('.');
var base = (dot > 0 ? s.slice(0, dot) : s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'photo';
var ext = forceJpeg ? '.jpg' : (dot > 0 ? s.slice(dot).toLowerCase() : '.jpg');
var uniq = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
return base + '-' + uniq + ext;
}
function initPhotoEditor(route) {
var form = document.querySelector('form[name="new-entry"]');
if (!form || !route) return;
// The reorder route keys on the entry's folder segment (last path segment).
var slug = route.split('/').filter(Boolean).pop();
if (!slug) return;
// Decommission the (hidden, unpopulated) FilePond section entirely.
var fpRoot = document.querySelector('.filepond-root, .form-input-file');
var fpField = fpRoot ? fpRoot.closest('.form-field') : null;
if (fpField) fpField.style.display = 'none';
var legacyCollapse = form.querySelector('.photos-collapse');
if (legacyCollapse) legacyCollapse.style.display = 'none';
// Build the editor and place it at the top of the form (photos come first).
var host = document.createElement('div');
host.className = 'photo-editor';
host.innerHTML =
'<div class="photo-editor__head">' +
'<span class="photo-editor__label">Photos</span>' +
'<button type="button" class="photo-editor__add">Add photos</button>' +
'</div>' +
'<p class="photo-editor__status gpx-status" role="status"></p>' +
'<div class="photo-editor__grid" aria-live="polite"></div>' +
// SVG intentionally excluded (server-side block is a documented fast-follow).
'<input type="file" class="photo-editor__input" multiple hidden ' +
'accept="image/jpeg,image/png,image/webp,.jpg,.jpeg,.png,.webp,.heic,.heif">';
if (fpField && fpField.parentNode) fpField.parentNode.insertBefore(host, fpField);
else form.insertBefore(host, form.firstElementChild);
var grid = host.querySelector('.photo-editor__grid');
var statusEl = host.querySelector('.photo-editor__status');
var addBtn = host.querySelector('.photo-editor__add');
var fileInput = host.querySelector('.photo-editor__input');
var photos = []; // current filenames, in display order
var sortable = null;
var busy = false;
var renderNonce = 0; // cache-buster: photo-NN URLs are reused across reorders
function setStatus(msg, isError) {
statusEl.textContent = msg || '';
statusEl.className = 'photo-editor__status gpx-status' + (isError ? ' error' : '');
}
function setBusy(b) {
busy = b;
addBtn.disabled = b;
Array.prototype.forEach.call(
grid.querySelectorAll('.photo-editor__del, .photo-editor__confirm-yes, .photo-editor__confirm-no'),
function (el) { el.disabled = b; }
);
if (sortable) sortable.option('disabled', b);
}
// apiSend / apiErrorMsg now live in ./api-utils.js (shared with trip-publish.js).
function mediaList() {
return fetch('/api/v1/pages' + route + '/media', { credentials: 'include', headers: { Accept: 'application/json' } })
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(function (j) {
return ((j && j.data) || [])
.filter(function (m) { return m && typeof m.filename === 'string' && PHOTO_EXT_RE.test(m.filename); })
.map(function (m) { return m.filename; })
.sort(byPhotoOrder);
});
}
function reorder(order) {
return apiSend('/api/v1/entry/' + encodeURIComponent(slug) + '/photos/order', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ order: order })
});
}
function render(list) {
photos = (list || []).slice();
if (sortable) { sortable.destroy(); sortable = null; }
grid.innerHTML = '';
if (!photos.length) {
grid.innerHTML = '<p class="photo-editor__empty">No photos yet — add some.</p>';
return;
}
var v = ++renderNonce;
photos.forEach(function (name) {
var cell = document.createElement('div');
cell.className = 'photo-editor__cell';
cell.setAttribute('data-filename', name);
var img = document.createElement('img');
img.className = 'photo-editor__img';
img.loading = 'lazy';
img.alt = name;
// Reorder reuses photo-NN URLs for different bytes; bust the cache.
img.src = route + '/' + encodeURIComponent(name) + '?v=' + v;
var del = document.createElement('button');
del.type = 'button';
del.className = 'photo-editor__del';
del.setAttribute('aria-label', 'Delete ' + name);
del.textContent = '✕';
del.addEventListener('click', function () { confirmDelete(cell, name); });
cell.appendChild(img);
cell.appendChild(del);
grid.appendChild(cell);
});
if (photos.length > 1) {
sortable = new Sortable(grid, {
animation: 150,
draggable: '.photo-editor__cell',
filter: '.photo-editor__confirm',
onEnd: applyReorder
});
}
}
function currentDomOrder() {
return Array.prototype.map.call(grid.querySelectorAll('.photo-editor__cell'),
function (c) { return c.getAttribute('data-filename'); });
}
function sameOrder(a, b) {
if (a.length !== b.length) return false;
for (var i = 0; i < a.length; i++) { if (a[i] !== b[i]) return false; }
return true;
}
function applyReorder() {
var next = currentDomOrder();
if (sameOrder(next, photos)) return; // dropped in place
var lastGood = photos.slice();
setBusy(true);
setStatus('Saving order…');
// Split the two failure stages: a failed SAVE reverts the drag; a failed
// REFRESH after a successful save must NOT revert (the order did persist)
// — show the saved order and reconcile on the next op.
reorder(next).then(function () {
return mediaList().then(
function (list) { setStatus(''); render(list); },
function () { setStatus(''); render(next); } // saved; DOM already shows it
);
}, function (err) {
setStatus(apiErrorMsg(err, 'Couldnt save the new order — reverted. Try again.'), true);
render(lastGood); // revert the SortableJS move to last-known-good
}).then(function () { setBusy(false); });
}
// ✕ swaps the cell to an inline "Delete? [Confirm] [Cancel]" (option a).
function confirmDelete(cell, name) {
if (busy || cell.querySelector('.photo-editor__confirm')) return;
var overlay = document.createElement('div');
overlay.className = 'photo-editor__confirm';
overlay.innerHTML =
'<span class="photo-editor__confirm-q">Delete?</span>' +
'<button type="button" class="photo-editor__confirm-yes">Confirm</button>' +
'<button type="button" class="photo-editor__confirm-no">Cancel</button>';
cell.appendChild(overlay);
cell.classList.add('is-confirming');
overlay.querySelector('.photo-editor__confirm-no').addEventListener('click', function () {
cell.classList.remove('is-confirming');
overlay.remove();
});
overlay.querySelector('.photo-editor__confirm-yes').addEventListener('click', function () {
overlay.querySelector('.photo-editor__confirm-yes').disabled = true;
overlay.querySelector('.photo-editor__confirm-no').disabled = true;
doDelete(name);
});
}
function doDelete(name) {
var lastGood = photos.slice();
var remaining = photos.filter(function (p) { return p !== name; });
setBusy(true);
setStatus('Deleting…');
// A 404 means the file is already gone — treat it as success so retrying
// a ghost cell converges instead of looping on "couldn't delete".
apiSend('/api/v1/pages' + route + '/media/' + encodeURIComponent(name), { method: 'DELETE' }, [204, 404])
.then(function () {
// Deleted. Renumber survivors (cover=first), then refresh. A failure
// AFTER this point must NOT resurrect the deleted photo — show the
// survivor set, never lastGood.
return (remaining.length ? reorder(remaining) : Promise.resolve()).then(mediaList).then(
function (list) { setStatus(''); render(list); },
function () {
setStatus('Photo deleted, but refreshing the list failed — reload if it looks off.', true);
render(remaining);
}
);
}, function (err) {
// The DELETE request itself failed — nothing changed on disk.
setStatus(apiErrorMsg(err, 'Couldnt delete that photo. Try again.'), true);
render(lastGood);
})
.then(function () { setBusy(false); });
}
// HEIC→JPEG (reusing the sniff + lazy heic-to converter) else pass through.
function toWebSafe(file) {
return looksLikeHeic(file).then(function (isHeic) {
if (!isHeic) return { blob: file, name: uploadName(file.name, false) };
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 (jpeg) { return { blob: jpeg, name: uploadName(file.name, true) }; });
});
}
addBtn.addEventListener('click', function () { if (!busy) fileInput.click(); });
fileInput.addEventListener('change', function () {
var files = Array.prototype.slice.call(fileInput.files || []);
fileInput.value = '';
if (files.length) addFiles(files);
});
function addFiles(files) {
setBusy(true);
var total = files.length, done = 0, failed = 0, authFailed = false;
var before = photos.slice();
var seq = Promise.resolve();
files.forEach(function (file) {
seq = seq.then(function () {
done++;
setStatus('Uploading ' + done + ' of ' + total + '…');
return toWebSafe(file).then(function (prep) {
var fd = new FormData();
fd.append('file', prep.blob, prep.name);
return apiSend('/api/v1/pages' + route + '/media', { method: 'POST', body: fd });
}).then(null, function (err) {
failed++;
if (err && (err.status === 401 || err.status === 403)) authFailed = true;
});
});
});
seq.then(function () {
setStatus('Finishing…');
return mediaList(); // authoritative set (includes new stock-named files)
}).then(function (current) {
var kept = before.filter(function (n) { return current.indexOf(n) !== -1; });
var added = current.filter(function (n) { return before.indexOf(n) === -1; });
var order = kept.concat(added);
if (!order.length) { render([]); return null; }
// One renumber pass after the whole batch. If it fails, auto-retry
// (idempotent — renumber skips files not on disk); if it still fails,
// roll the just-added files back so no orphan stock-named image breaks
// cover=first, then surface a single error. Track whether every
// rollback DELETE actually landed (204/404 = gone): a swallowed
// rollback failure would leave a stray stock-named file that can steal
// the lexicographic cover slot, so we warn the owner to reload.
return reorder(order)
.catch(function () { return reorder(order); })
.catch(function () {
return Promise.all(added.map(function (n) {
return apiSend('/api/v1/pages' + route + '/media/' + encodeURIComponent(n), { method: 'DELETE' }, [204, 404])
.then(function () { return true; }, function () { return false; });
})).then(function (results) {
var e = new Error('reorder failed');
e.rolledBack = true;
e.rollbackIncomplete = results.indexOf(false) !== -1;
throw e;
});
})
.then(mediaList);
}).then(function (list) {
if (list) render(list);
if (!failed) {
setStatus('');
} else if (authFailed) {
setStatus('Couldnt add photos — your login session expired. Sign in again, then retry.', true);
} else {
setStatus(failed + ' photo' + (failed > 1 ? 's' : '') + ' couldnt be added.', true);
}
}).catch(function (err) {
var msg;
if (err && err.rolledBack) {
msg = err.rollbackIncomplete
? 'Couldnt finish adding photos and cleanup was incomplete — reload the page and check your photos.'
: 'Couldnt finish adding photos — changes were rolled back. Try again.';
} else {
msg = 'Couldnt add photos. Please try again.';
}
setStatus(msg, true);
return mediaList().then(render, function () { render(before); });
}).then(function () { setBusy(false); });
}
// Initial load.
grid.innerHTML = '<p class="photo-editor__loading">Loading photos…</p>';
mediaList().then(render).catch(function () {
setStatus('Couldnt load photos.', true);
render([]);
});
}
function initEditMode() {
var form = document.querySelector('form[name="new-entry"]');
var wrap = document.querySelector('.post-form-wrap');
if (!form || !wrap) return;
var route = qparam('edit');
if (!route) return; // create mode — nothing to do
EDIT_MODE = true;
var h1 = wrap.querySelector('h1');
if (h1) h1.textContent = 'Edit entry'; // D6
var submitBtn = form.querySelector('button[type="submit"], input[type="submit"]');
var origLabel = submitBtn ? (submitBtn.tagName === 'INPUT' ? submitBtn.value : submitBtn.textContent) : 'Save changes';
// U7: the photos section stays visible in edit mode — existing photos are
// loaded into FilePond below once the prefill resolves. The >=1-photo rule is
// skipped while EDIT_MODE (see initValidation) so removing every photo is OK.
// D1: no typing before prefill lands — disable + loading label.
editFormDisabled(form, true);
editSubmitLabel(submitBtn, 'Loading entry…');
// D3/D5: carry the edit context on the form action so a server re-render
// (once the Form 9.1.10 filepond regression is fixed) re-enters edit mode and
// the return target survives the round-trip.
var ret = qparam('return') || wrap.getAttribute('data-trip-url') || '';
form.setAttribute('action', '/post?edit=' + encodeURIComponent(route) + (ret ? '&return=' + encodeURIComponent(ret) : ''));
fetch('/api/v1/pages' + route, { credentials: 'include', headers: { Accept: 'application/json' } })
.then(function (r) { if (!r.ok) { var e = new Error('HTTP ' + r.status); e.status = r.status; throw e; } return r.json(); })
.then(function (json) {
var d = (json && json.data) || {};
var h = d.header || {};
editSetText('title', h.title != null ? h.title : d.title);
editSetText('date', h.date ? String(h.date).replace(' ', 'T') : ''); // datetime-local wants a T separator
editSetContent(d.content);
editSetText('lat', h.lat);
editSetText('lng', h.lng);
editSetText('location_city', h.location_city);
editSetText('location_country', h.location_country);
editSetText('weather_desc', h.weather_desc);
editSetText('weather_temp_c', h.weather_temp_c);
editSetText('transport_mode', h.transport_mode);
editSetToggle('featured', h.featured);
editSetToggle('force_connect', h.force_connect);
editSetToggle('published', h.published !== undefined ? h.published : d.published);
var editPathEl = field('edit_path');
if (editPathEl) editPathEl.value = route + '/entry.md';
editFormDisabled(form, false);
editSubmitLabel(submitBtn, 'Save changes'); // D6
var more = form.querySelector('.more-options');
if (more) more.open = true; // reveal Published/Featured/Connector
initPhotoEditor(route); // live add/delete/reorder via the media API
})
.catch(function (err) {
// D7: inline error between heading and first field; keep the form
// disabled and empty rather than leaving a half-filled state.
// Distinguish a deleted/missing entry (404) from a transient load
// failure so the owner knows whether retrying is worthwhile.
var msg = (err && err.status === 404)
? 'This entry no longer exists — it may have been deleted. Head back to the journal.'
: 'Sorry — this entry couldnt be loaded for editing. Check your connection and try again.';
editShowError(wrap, msg);
editSubmitLabel(submitBtn, origLabel);
});
}
/* ── 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();
initSuccessState();
// Edit mode first: sets EDIT_MODE (so initDraft skips and initValidation
// relaxes the photo rule), disables the form and starts the async prefill.
initEditMode();
// Restore before disclosure/geo so their on-load checks (auto-open,
// weather-button enable) see the restored values. No-op in edit mode.
initDraft();
initPhotoConversion();
initDisclosure();
initGeo();
initValidation();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}