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
1249 lines
58 KiB
JavaScript
1249 lines
58 KiB
JavaScript
/*
|
||
* /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 (1–6)';
|
||
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 (1–6)';
|
||
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 couldn’t 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 + '¤t=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 can’t 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, 'Couldn’t 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, 'Couldn’t 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('Couldn’t add photos — your login session expired. Sign in again, then retry.', true);
|
||
} else {
|
||
setStatus(failed + ' photo' + (failed > 1 ? 's' : '') + ' couldn’t be added.', true);
|
||
}
|
||
}).catch(function (err) {
|
||
var msg;
|
||
if (err && err.rolledBack) {
|
||
msg = err.rollbackIncomplete
|
||
? 'Couldn’t finish adding photos and cleanup was incomplete — reload the page and check your photos.'
|
||
: 'Couldn’t finish adding photos — changes were rolled back. Try again.';
|
||
} else {
|
||
msg = 'Couldn’t 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('Couldn’t 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 couldn’t 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();
|
||
}
|