Files
intotheeast-com-content/themes/intotheeast/js/src/post-form.js
T
m038andClaude Opus 4.8 1cde137daa feat(post-form): native datetime-local picker + require date client-side
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>
2026-07-04 20:41:49 +02:00

650 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* /post journal form bundle — page-scoped (loaded only by post-form.html.twig).
* Kept out of the global main.js so EasyMDE (+ the U4 HEIC converter, lazy-loaded)
* never ship on any other page. Built as ESM with code-splitting so the heic-to
* dynamic import becomes a separately-fetched chunk (see package.json / KTD2).
*
* Every init function presence-guards its own DOM, mirroring main.js's
* initTripStats pattern, so this is a no-op if an element is absent.
*/
import EasyMDE from 'easymde';
import 'easymde/dist/easymde.min.css';
import './post-form.css';
/* ── Markdown editor (EasyMDE) ───────────────────────────── */
function initEditor() {
var textarea = document.querySelector('textarea[name="data[content]"]');
if (!textarea) return null;
var editor = new EasyMDE({
element: textarea,
spellChecker: false,
autoDownloadFontAwesome: false, // toolbar glyphs come from post-form.css, no FA CDN
status: false,
placeholder: 'What happened today?',
minHeight: '180px',
// Minimal toolbar; custom classNames drive the CSS glyphs in post-form.css.
toolbar: [
{ name: 'bold', action: EasyMDE.toggleBold, className: 'mde-btn mde-bold', title: 'Bold' },
{ name: 'italic', action: EasyMDE.toggleItalic, className: 'mde-btn mde-italic', title: 'Italic' },
{ name: 'unordered-list', action: EasyMDE.toggleUnorderedList, className: 'mde-btn mde-ul', title: 'Bulleted list' },
{ name: 'link', action: EasyMDE.drawLink, className: 'mde-btn mde-link', title: 'Insert link' },
'|',
{ name: 'preview', action: EasyMDE.togglePreview, className: 'mde-btn mde-preview', title: 'Toggle preview' },
],
});
// KTD3 / R15: keep the underlying <textarea> in sync with the editor so the
// form's custom required-field validator (which reads [name="data[content]"])
// and the submitted payload both see the live value.
editor.codemirror.on('change', function () { editor.codemirror.save(); });
var form = textarea.closest('form');
if (form) {
// Capture phase fires before the inline bubble-phase validator, so the
// textarea already holds the current value when validation reads it.
form.addEventListener('submit', function () { editor.codemirror.save(); }, true);
}
return editor;
}
/* ── Photo picker + client-side HEIC→JPEG (U4) ───────────────
* We replaced Grav's managed filepond field with a plain, controlled picker
* (see the `photos` field template) because filepond instant-uploads each file
* the moment it is added and its FilePond.create config is not ours to hook —
* so HEIC could never be converted before upload. Here we own the flow: sniff,
* lazy-convert HEIC, then POST each web-safe JPEG to Grav's AJAX file-upload
* route (into the form flash, exactly as filepond's server.process did) so
* add-page-by-form's copyFiles() picks them up on submit.
*/
// ISO-BMFF 'ftyp' brands that indicate HEIC/HEIF. Sniffed from bytes, never
// from the filename/MIME (R16) — iOS sometimes hands us .heic with an image/*
// MIME, and a renamed .jpg can still be HEIC underneath.
var HEIC_BRANDS = ['heic', 'heix', 'heif', 'hevc', 'hevx', 'mif1', 'msf1', 'heim', 'heis', 'hevm', 'hevs'];
function looksLikeHeic(file) {
return file.slice(0, 64).arrayBuffer().then(function (buf) {
var b = new Uint8Array(buf);
if (b.length < 12) return false;
// bytes 4..8 must be the 'ftyp' box type
if (String.fromCharCode(b[4], b[5], b[6], b[7]) !== 'ftyp') return false;
// scan major brand + compatible brands (4-byte codes from offset 8)
var brands = '';
for (var i = 8; i + 4 <= b.length; i += 4) {
brands += String.fromCharCode(b[i], b[i + 1], b[i + 2], b[i + 3]).toLowerCase();
}
return HEIC_BRANDS.some(function (brand) { return brands.indexOf(brand) !== -1; });
}).catch(function () { return false; });
}
function slugifyJpegName(name) {
var base = String(name || 'photo').replace(/\.[^.]+$/, '');
base = base.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
return (base || 'photo') + '.jpg';
}
// 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 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-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 + '&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);
}
// 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 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
// 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();
}