The stock Grav `datetime` field template is deprecated and falls back to a plain text box, so `type: datetime` + `default: now` rendered a raw input showing the literal word "now" — unusable. Add a theme override at templates/forms/fields/datetime/datetime.html.twig that renders a native <input type="datetime-local"> (real calendar+clock, great on mobile), drop the `default: now`, and prefill the current local time from post-form.js. Also add `date` to the existing client-side validator. Together with the picker (which can't hold an invalid value) this stops a bad/empty date from round-tripping to the server — which was the trigger that made Grav re-render the managed FilePond field from the session flash as filename-only inputs and resurrect a photo the user had removed. Grav still reformats the submitted value to the blueprint `format: 'Y-m-d H:i'` on save, so stored dates and folder slugs are unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
650 lines
29 KiB
JavaScript
650 lines
29 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 'easymde/dist/easymde.min.css';
|
||
import './post-form.css';
|
||
|
||
/* ── Markdown editor (EasyMDE) ───────────────────────────── */
|
||
function initEditor() {
|
||
var textarea = document.querySelector('textarea[name="data[content]"]');
|
||
if (!textarea) return null;
|
||
|
||
var editor = new EasyMDE({
|
||
element: textarea,
|
||
spellChecker: false,
|
||
autoDownloadFontAwesome: false, // toolbar glyphs come from post-form.css, no FA CDN
|
||
status: false,
|
||
placeholder: 'What happened today?',
|
||
minHeight: '180px',
|
||
// Minimal toolbar; custom classNames drive the CSS glyphs in post-form.css.
|
||
toolbar: [
|
||
{ name: 'bold', action: EasyMDE.toggleBold, className: 'mde-btn mde-bold', title: 'Bold' },
|
||
{ name: 'italic', action: EasyMDE.toggleItalic, className: 'mde-btn mde-italic', title: 'Italic' },
|
||
{ name: 'unordered-list', action: EasyMDE.toggleUnorderedList, className: 'mde-btn mde-ul', title: 'Bulleted list' },
|
||
{ name: 'link', action: EasyMDE.drawLink, className: 'mde-btn mde-link', title: 'Insert link' },
|
||
'|',
|
||
{ name: 'preview', action: EasyMDE.togglePreview, className: 'mde-btn mde-preview', title: 'Toggle preview' },
|
||
],
|
||
});
|
||
|
||
// KTD3 / R15: keep the underlying <textarea> in sync with the editor so the
|
||
// form's custom required-field validator (which reads [name="data[content]"])
|
||
// and the submitted payload both see the live value.
|
||
editor.codemirror.on('change', function () { editor.codemirror.save(); });
|
||
|
||
var form = textarea.closest('form');
|
||
if (form) {
|
||
// Capture phase fires before the inline bubble-phase validator, so the
|
||
// textarea already holds the current value when validation reads it.
|
||
form.addEventListener('submit', function () { editor.codemirror.save(); }, true);
|
||
}
|
||
|
||
return editor;
|
||
}
|
||
|
||
/* ── Photo picker + client-side HEIC→JPEG (U4) ───────────────
|
||
* We replaced Grav's managed filepond field with a plain, controlled picker
|
||
* (see the `photos` field template) because filepond instant-uploads each file
|
||
* the moment it is added and its FilePond.create config is not ours to hook —
|
||
* so HEIC could never be converted before upload. Here we own the flow: sniff,
|
||
* lazy-convert HEIC, then POST each web-safe JPEG to Grav's AJAX file-upload
|
||
* route (into the form flash, exactly as filepond's server.process did) so
|
||
* add-page-by-form's copyFiles() picks them up on submit.
|
||
*/
|
||
|
||
// ISO-BMFF 'ftyp' brands that indicate HEIC/HEIF. Sniffed from bytes, never
|
||
// from the filename/MIME (R16) — iOS sometimes hands us .heic with an image/*
|
||
// MIME, and a renamed .jpg can still be HEIC underneath.
|
||
var HEIC_BRANDS = ['heic', 'heix', 'heif', 'hevc', 'hevx', 'mif1', 'msf1', 'heim', 'heis', 'hevm', 'hevs'];
|
||
|
||
function looksLikeHeic(file) {
|
||
return file.slice(0, 64).arrayBuffer().then(function (buf) {
|
||
var b = new Uint8Array(buf);
|
||
if (b.length < 12) return false;
|
||
// bytes 4..8 must be the 'ftyp' box type
|
||
if (String.fromCharCode(b[4], b[5], b[6], b[7]) !== 'ftyp') return false;
|
||
// scan major brand + compatible brands (4-byte codes from offset 8)
|
||
var brands = '';
|
||
for (var i = 8; i + 4 <= b.length; i += 4) {
|
||
brands += String.fromCharCode(b[i], b[i + 1], b[i + 2], b[i + 3]).toLowerCase();
|
||
}
|
||
return HEIC_BRANDS.some(function (brand) { return brands.indexOf(brand) !== -1; });
|
||
}).catch(function () { return false; });
|
||
}
|
||
|
||
function slugifyJpegName(name) {
|
||
var base = String(name || 'photo').replace(/\.[^.]+$/, '');
|
||
base = base.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||
return (base || 'photo') + '.jpg';
|
||
}
|
||
|
||
// 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 (max 4)';
|
||
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;
|
||
|
||
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-1..N 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 (max 4)';
|
||
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);
|
||
|
||
// 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-1..N 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 trio (hero_image/force_connect/featured)
|
||
* flat; here we relocate their .form-field wrappers into a native <details> so
|
||
* they collapse by default with full keyboard/AT support. Auto-opens if any
|
||
* advanced field already carries a value (edit / draft restore).
|
||
*/
|
||
function initDisclosure() {
|
||
var flagged = Array.prototype.slice.call(document.querySelectorAll('.advanced-field'));
|
||
if (!flagged.length) return;
|
||
|
||
var wrappers = [];
|
||
flagged.forEach(function (el) {
|
||
var w = el.closest('.form-field');
|
||
if (w && wrappers.indexOf(w) === -1) wrappers.push(w);
|
||
});
|
||
if (!wrappers.length) return;
|
||
|
||
var details = document.createElement('details');
|
||
details.className = 'more-options';
|
||
var summary = document.createElement('summary');
|
||
summary.className = 'more-options__summary';
|
||
summary.textContent = 'More options';
|
||
details.appendChild(summary);
|
||
|
||
wrappers[0].parentNode.insertBefore(details, wrappers[0]);
|
||
wrappers.forEach(function (w) { details.appendChild(w); });
|
||
|
||
var hasValue = wrappers.some(function (w) {
|
||
var text = w.querySelector('input[type="text"]');
|
||
if (text && text.value.trim()) return true;
|
||
var toggle = w.querySelector('input[type="radio"]:checked');
|
||
if (toggle && toggle.value && toggle.value !== '0') return true;
|
||
return false;
|
||
});
|
||
if (hasValue) details.open = true;
|
||
}
|
||
|
||
/* ── Get Location / Get Weather (U5, R18) ─────────────────────
|
||
* Migrated out of the template's inline scripts so it can read the now-<select>
|
||
* weather_desc field and expose idle/loading/success/error states.
|
||
*/
|
||
var WMO_MAP = {
|
||
0: 'Sunny', 1: 'Partly cloudy', 2: 'Partly cloudy', 3: 'Cloudy',
|
||
45: 'Foggy', 48: 'Foggy',
|
||
51: 'Drizzle', 53: 'Drizzle', 55: 'Drizzle', 56: 'Drizzle', 57: 'Drizzle',
|
||
61: 'Rain', 63: 'Rain', 65: 'Rain', 66: 'Rain', 67: 'Rain', 80: 'Rain', 81: 'Rain', 82: 'Rain',
|
||
71: 'Snow', 73: 'Snow', 75: 'Snow', 77: 'Snow', 85: 'Snow', 86: 'Snow',
|
||
95: 'Thunderstorm', 96: 'Thunderstorm', 99: 'Thunderstorm'
|
||
};
|
||
|
||
function setStatus(el, msg, kind) {
|
||
if (!el) return;
|
||
el.className = 'form-status' + (kind ? ' form-status--' + kind : '');
|
||
el.textContent = msg || '';
|
||
}
|
||
|
||
function initGeo() {
|
||
var locBtn = document.getElementById('get-location');
|
||
var wxBtn = document.getElementById('get-weather');
|
||
if (!locBtn && !wxBtn) return;
|
||
|
||
var locStatus = document.getElementById('location-status');
|
||
var wxStatus = document.getElementById('weather-status');
|
||
|
||
function coords() {
|
||
var latEl = field('lat');
|
||
var lngEl = field('lng');
|
||
var lat = latEl ? latEl.value.trim() : '';
|
||
var lng = lngEl ? lngEl.value.trim() : '';
|
||
return (lat && lng) ? { lat: lat, lng: lng } : null;
|
||
}
|
||
|
||
// Get Weather is meaningless without coordinates — gate it (R18).
|
||
function syncWeatherEnabled() {
|
||
if (!wxBtn) return;
|
||
var ok = !!coords();
|
||
wxBtn.disabled = !ok;
|
||
wxBtn.title = ok ? '' : 'Get location first';
|
||
}
|
||
syncWeatherEnabled();
|
||
|
||
if (locBtn) {
|
||
locBtn.addEventListener('click', function () {
|
||
if (!navigator.geolocation) {
|
||
setStatus(locStatus, 'Geolocation is not supported on this device.', 'err');
|
||
return;
|
||
}
|
||
locBtn.classList.add('is-loading');
|
||
locBtn.disabled = true;
|
||
setStatus(locStatus, 'Getting location…');
|
||
navigator.geolocation.getCurrentPosition(function (pos) {
|
||
var lat = pos.coords.latitude.toFixed(6);
|
||
var lng = pos.coords.longitude.toFixed(6);
|
||
var latEl = field('lat');
|
||
var lngEl = field('lng');
|
||
if (latEl) latEl.value = lat;
|
||
if (lngEl) lngEl.value = lng;
|
||
locBtn.classList.remove('is-loading');
|
||
locBtn.disabled = false;
|
||
setStatus(locStatus, '✓ Location captured · ' + lat + ', ' + lng, 'ok');
|
||
syncWeatherEnabled();
|
||
}, function (err) {
|
||
locBtn.classList.remove('is-loading');
|
||
locBtn.disabled = false;
|
||
setStatus(locStatus, '✗ ' + (err && err.message ? err.message : 'Could not get location') + ' — enter coordinates manually if needed.', 'err');
|
||
}, { enableHighAccuracy: true, timeout: 15000 });
|
||
});
|
||
}
|
||
|
||
if (wxBtn) {
|
||
wxBtn.addEventListener('click', function () {
|
||
var c = coords();
|
||
if (!c) {
|
||
setStatus(wxStatus, 'Get location first, then fetch weather.', 'err');
|
||
return;
|
||
}
|
||
wxBtn.classList.add('is-loading');
|
||
wxBtn.disabled = true;
|
||
setStatus(wxStatus, 'Fetching weather…');
|
||
var url = 'https://api.open-meteo.com/v1/forecast?latitude=' + c.lat +
|
||
'&longitude=' + c.lng + '¤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);
|
||
}
|
||
|
||
// Bubble phase: runs after initEditor's capture-phase codemirror.save(), so
|
||
// the content textarea already holds the live value.
|
||
form.addEventListener('submit', function (e) {
|
||
clearErrors();
|
||
var firstInvalid = null;
|
||
Object.keys(REQUIRED).forEach(function (name) {
|
||
var el = field(name);
|
||
if (el && !String(el.value).trim()) {
|
||
showError(el, REQUIRED[name] + ' is required.');
|
||
if (!firstInvalid) firstInvalid = el;
|
||
}
|
||
});
|
||
if (firstInvalid) {
|
||
e.preventDefault();
|
||
firstInvalid.focus();
|
||
firstInvalid.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
}
|
||
});
|
||
}
|
||
|
||
/* ── Draft persistence (U6, R19 preserve-on-failure + R20, KTD6) ──
|
||
* Text field values are mirrored to localStorage so a failed submit or an
|
||
* expired session mid-compose never loses the writing. Photos can't be
|
||
* serialized (File/Blob) so they are NOT persisted — an inline hint says so
|
||
* on restore. The draft is cleared ONLY when the server confirms a successful
|
||
* post (<div class="notices success">), which is exactly what guarantees the
|
||
* text survives every non-success outcome, independent of failure-type sniffing.
|
||
*/
|
||
var DRAFT_KEY = 'intotheeast:new-entry-draft';
|
||
|
||
function draftEls(form) {
|
||
return Array.prototype.slice.call(form.querySelectorAll('[name^="data["]'))
|
||
.filter(function (el) {
|
||
if (el.type === 'file') return false;
|
||
var n = el.name;
|
||
return n.indexOf('data[_json') !== 0 && n.indexOf('data[photos') !== 0;
|
||
});
|
||
}
|
||
|
||
function showReauthHint() {
|
||
// 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
|
||
|
||
// 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' });
|
||
}
|
||
|
||
/* ── 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();
|
||
// Restore before disclosure/geo so their on-load checks (auto-open,
|
||
// weather-button enable) see the restored values.
|
||
initDraft();
|
||
initPhotoConversion();
|
||
initDisclosure();
|
||
initGeo();
|
||
initValidation();
|
||
}
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', boot);
|
||
} else {
|
||
boot();
|
||
}
|