Compare commits
4
Commits
02fa4e94a7
...
13c76b29a8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13c76b29a8 | ||
|
|
797b472a60 | ||
|
|
52e9fbadf2 | ||
|
|
a00690fbde |
File diff suppressed because one or more lines are too long
@@ -833,10 +833,6 @@ body::after {
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
/* Hide GPS coordinate fields — filled by JS, not user-facing */
|
||||
.post-form-wrap .form-field:has(input[name="data[lat]"]),
|
||||
.post-form-wrap .form-field:has(input[name="data[lng]"]) { display: none !important; }
|
||||
|
||||
/* Grav form field inputs */
|
||||
.post-form-wrap .form-field { margin-bottom: var(--space-5); }
|
||||
.post-form-wrap .form-label label {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,8 +1,9 @@
|
||||
/* Shared MapLibre GL utilities — loaded by map.html.twig, dailies.html.twig, home.html.twig */
|
||||
import { MAP_STYLE } from './src/map-style.js';
|
||||
|
||||
(function (global) {
|
||||
var ACCENT = '#2A8C73';
|
||||
var ACCENT_DIM = '#155244';
|
||||
var MAP_STYLE = 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json';
|
||||
|
||||
/* Build a GeoJSON LineString feature */
|
||||
function lineFeature(coords) {
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Single-marker, draggable preview map for the post form's "More location
|
||||
* details" panel. A dedicated sibling to maplibre-utils.js's initEntryMap
|
||||
* rather than an extension of it — that engine is built for multi-marker,
|
||||
* GPX-drawing, popup-bearing read-only maps, none of which this preview
|
||||
* needs (KTD1).
|
||||
*
|
||||
* maplibre-gl itself is lazy-imported on first call (KTD3) so an ordinary
|
||||
* GPS-only submit, where the panel is never opened, never fetches it. The
|
||||
* created map/marker are cached in module scope and reused on every
|
||||
* subsequent call — see the panel's toggle-open handler in post-form.js,
|
||||
* which calls this on every open and then always calls the returned
|
||||
* handle's resize() (the container sits under display:none while the panel
|
||||
* is closed, so the first paint would otherwise get a zero-size canvas).
|
||||
*
|
||||
* The cache stores the in-flight PROMISE, not just the resolved handle —
|
||||
* written synchronously before import() settles. A close/reopen of the panel
|
||||
* while the maplibre-gl chunk is still loading would otherwise re-enter this
|
||||
* function and race a second import().then() into building a second Map
|
||||
* against the same container (caught in code review — confirmed independently
|
||||
* by four reviewers).
|
||||
*/
|
||||
import { MAP_STYLE } from './map-style.js';
|
||||
|
||||
var cached = null; // { container, promise }
|
||||
|
||||
function buildPinElement() {
|
||||
var el = document.createElement('div');
|
||||
el.className = 'location-pin'; // visual styling lives in style.css
|
||||
return el;
|
||||
}
|
||||
|
||||
export function getOrCreateLocationMap(container, onDragEnd) {
|
||||
if (cached && cached.container === container) {
|
||||
return cached.promise;
|
||||
}
|
||||
|
||||
var promise = import('maplibre-gl').then(function (mod) {
|
||||
var maplibregl = mod.default || mod;
|
||||
var map = new maplibregl.Map({
|
||||
container: container,
|
||||
style: MAP_STYLE,
|
||||
center: [0, 20],
|
||||
zoom: 2,
|
||||
attributionControl: false
|
||||
});
|
||||
map.addControl(new maplibregl.AttributionControl({ compact: true }), 'bottom-left');
|
||||
|
||||
var marker = new maplibregl.Marker({ draggable: true, element: buildPinElement() });
|
||||
var pinSet = false; // R12: no pin shown until a coordinate is first set
|
||||
marker.on('dragend', function () {
|
||||
if (onDragEnd) onDragEnd(marker.getLngLat());
|
||||
});
|
||||
|
||||
return {
|
||||
setPin: function (lat, lng) {
|
||||
marker.setLngLat([lng, lat]);
|
||||
if (!pinSet) { marker.addTo(map); pinSet = true; }
|
||||
map.panTo([lng, lat]);
|
||||
},
|
||||
hasPin: function () { return pinSet; },
|
||||
resize: function () { map.resize(); }
|
||||
};
|
||||
}).catch(function (err) {
|
||||
// Leave the cache clear so a later retry (e.g. after transient network
|
||||
// failure) re-attempts the import instead of permanently returning a
|
||||
// rejected promise for this container.
|
||||
if (cached && cached.container === container) cached = null;
|
||||
throw err;
|
||||
});
|
||||
|
||||
cached = { container: container, promise: promise };
|
||||
return promise;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Shared MapLibre style URL — single source of truth for maplibre-utils.js
|
||||
// (multi-marker/GPX maps) and location-map.js (post-form pin preview), so the
|
||||
// two never drift apart (KTD1).
|
||||
export const MAP_STYLE = 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json';
|
||||
@@ -519,3 +519,107 @@
|
||||
/* SortableJS drag feedback. */
|
||||
.photo-editor__cell.sortable-ghost { opacity: 0.4; }
|
||||
.photo-editor__cell.sortable-chosen { outline: 2px solid var(--color-accent); }
|
||||
|
||||
/* "More location details" disclosure — search + map preview for setting an
|
||||
entry's coordinates without live GPS. Mirrors .more-options's disclosure
|
||||
look (above); the lat/lng fields (relocated here by JS) and the lookup
|
||||
button reuse style.css's existing .btn-action/.form-status/.field-invalid
|
||||
conventions unmodified. */
|
||||
.location-details {
|
||||
margin-bottom: var(--space-5);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-canvas);
|
||||
}
|
||||
.location-details__summary {
|
||||
cursor: pointer;
|
||||
padding: 0.875rem 1rem;
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-ink);
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
}
|
||||
.location-details__summary::-webkit-details-marker { display: none; }
|
||||
.location-details__summary::before {
|
||||
content: '▸';
|
||||
margin-right: var(--space-2);
|
||||
color: var(--color-ink-muted);
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.location-details[open] .location-details__summary::before { transform: rotate(90deg); }
|
||||
.location-details[open] .location-details__summary { border-bottom: 1px solid var(--color-border); }
|
||||
.location-details > .form-field { padding: 0 1rem; }
|
||||
.location-details > .form-field:first-of-type { padding-top: var(--space-4); }
|
||||
.location-details > .form-field:last-of-type { padding-bottom: var(--space-2); }
|
||||
.location-details__body { padding: 1rem; }
|
||||
|
||||
.location-search-row { display: flex; gap: var(--space-3); align-items: center; flex-wrap: wrap; }
|
||||
|
||||
.location-search-hint {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-ink-muted);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
.location-search-hint:empty { display: none; }
|
||||
|
||||
.location-search-results {
|
||||
list-style: none;
|
||||
margin: var(--space-3) 0 0;
|
||||
padding: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
.location-search-results:empty { display: none; margin: 0; border: none; }
|
||||
.location-search-results li + li { border-top: 1px solid var(--color-border); }
|
||||
.location-search-results button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 0.75rem 1rem;
|
||||
min-height: 44px;
|
||||
background: var(--color-canvas);
|
||||
border: none;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-ink);
|
||||
cursor: pointer;
|
||||
}
|
||||
.location-search-results button:hover,
|
||||
.location-search-results button:focus-visible { background: var(--color-paper); }
|
||||
|
||||
.location-map {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 240px;
|
||||
margin-top: var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
background: var(--color-paper);
|
||||
}
|
||||
.location-map .maplibregl-canvas { border-radius: var(--radius-md); }
|
||||
|
||||
.location-pin {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent);
|
||||
border: 3px solid #fff;
|
||||
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.5);
|
||||
cursor: grab;
|
||||
}
|
||||
.location-pin:active { cursor: grabbing; }
|
||||
|
||||
/* Mismatch flag: a typed lat/lng that doesn't (yet) parse to a valid pin. */
|
||||
.location-field--mismatch { border-color: var(--color-error) !important; outline-color: var(--color-error) !important; }
|
||||
.location-field-note {
|
||||
display: block;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-error);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
@@ -10,8 +10,12 @@
|
||||
import EasyMDE from 'easymde';
|
||||
import Sortable from 'sortablejs';
|
||||
import 'easymde/dist/easymde.min.css';
|
||||
// maplibre-gl's CSS is a static import — a dynamically-imported chunk's CSS is
|
||||
// never auto-linked (R10). The JS itself stays a lazy import (see location-map.js).
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import './post-form.css';
|
||||
import { apiSend, apiErrorMsg } from './api-utils.js';
|
||||
import { getOrCreateLocationMap } from './location-map.js';
|
||||
|
||||
/* ── Markdown editor (EasyMDE) ───────────────────────────── */
|
||||
function initEditor() {
|
||||
@@ -441,6 +445,10 @@ function initGeo() {
|
||||
setStatus(locStatus, '✓ Location captured · ' + lat + ', ' + lng, 'ok');
|
||||
syncWeatherEnabled();
|
||||
reverseGeocode(lat, lng); // fill City/Country in the background
|
||||
// R11/KTD4: only push a live pin update if the panel is already
|
||||
// open — otherwise the toggle-open handler syncs it on next open.
|
||||
var openLocationDetails = document.querySelector('.location-details');
|
||||
if (openLocationDetails && openLocationDetails.open) syncPinFromFields();
|
||||
}, function (err) {
|
||||
locBtn.classList.remove('is-loading');
|
||||
locBtn.disabled = false;
|
||||
@@ -480,6 +488,251 @@ function initGeo() {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── "More location details" panel (U2-U5) ─────────────────────
|
||||
* Closed-by-default disclosure holding a city/country search-by-lookup, a
|
||||
* single-marker MapLibre preview (lazy-loaded, see location-map.js — KTD1/
|
||||
* KTD3), and the relocated lat/lng fields. Built entirely in JS (KTD6),
|
||||
* mirroring initDisclosure()'s create-via-JS + relocate-wrapper approach, so
|
||||
* the template needs no structural change.
|
||||
*
|
||||
* Exposed at module scope (reassigned once the panel exists) so initGeo()'s
|
||||
* GPS success handler — defined earlier in this file, but only invoked later
|
||||
* on user click, after boot() has run initLocationDetails() — can push a live
|
||||
* pin update when the panel is already open (KTD4).
|
||||
*/
|
||||
var syncPinFromFields = function () {}; // no-op until the panel initializes
|
||||
|
||||
function debounce(fn, ms) {
|
||||
var t = null;
|
||||
return function () {
|
||||
var args = arguments;
|
||||
clearTimeout(t);
|
||||
t = setTimeout(function () { fn.apply(null, args); }, ms);
|
||||
};
|
||||
}
|
||||
|
||||
function geocodeResultLabel(r) {
|
||||
return [r.name, r.admin1, r.country].filter(Boolean).join(', ');
|
||||
}
|
||||
|
||||
function initLocationDetails() {
|
||||
var latEl = field('lat');
|
||||
var lngEl = field('lng');
|
||||
if (!latEl || !lngEl) return; // no-op guard: fields absent from the DOM
|
||||
var latWrap = latEl.closest('.form-field');
|
||||
var lngWrap = lngEl.closest('.form-field');
|
||||
if (!latWrap || !lngWrap) return;
|
||||
|
||||
var cityEl = field('location_city');
|
||||
var countryEl = field('location_country');
|
||||
var countryWrap = countryEl ? countryEl.closest('.form-field') : null;
|
||||
var anchor = countryWrap || latWrap;
|
||||
|
||||
// ── Shell (U2, R1-R3, KTD6) ──
|
||||
var details = document.createElement('details');
|
||||
details.className = 'location-details';
|
||||
var summary = document.createElement('summary');
|
||||
summary.className = 'location-details__summary';
|
||||
summary.textContent = 'More location details';
|
||||
details.appendChild(summary);
|
||||
|
||||
var body = document.createElement('div');
|
||||
body.className = 'location-details__body';
|
||||
|
||||
var searchRow = document.createElement('div');
|
||||
searchRow.className = 'location-search-row';
|
||||
var lookupBtn = document.createElement('button');
|
||||
lookupBtn.type = 'button';
|
||||
lookupBtn.id = 'lookup-coords';
|
||||
lookupBtn.className = 'btn-action';
|
||||
var LOOKUP_LABEL = '🔍 Look up coordinates';
|
||||
lookupBtn.textContent = LOOKUP_LABEL;
|
||||
searchRow.appendChild(lookupBtn);
|
||||
body.appendChild(searchRow);
|
||||
|
||||
var hint = document.createElement('p');
|
||||
hint.id = 'location-search-hint';
|
||||
hint.className = 'location-search-hint';
|
||||
hint.setAttribute('role', 'status');
|
||||
body.appendChild(hint);
|
||||
|
||||
var results = document.createElement('ul');
|
||||
results.id = 'location-search-results';
|
||||
results.className = 'location-search-results';
|
||||
body.appendChild(results);
|
||||
|
||||
var mapContainer = document.createElement('div');
|
||||
mapContainer.id = 'location-map';
|
||||
mapContainer.className = 'location-map';
|
||||
body.appendChild(mapContainer);
|
||||
|
||||
details.appendChild(body);
|
||||
|
||||
anchor.parentNode.insertBefore(details, anchor.nextSibling);
|
||||
details.appendChild(latWrap);
|
||||
details.appendChild(lngWrap);
|
||||
|
||||
// ── Mismatch flag (U5, R11/R13) ──
|
||||
function fieldNoteId(el) { return el === latEl ? 'location-lat-note' : 'location-lng-note'; }
|
||||
|
||||
function setMismatch() {
|
||||
[latEl, lngEl].forEach(function (el) {
|
||||
el.classList.add('location-field--mismatch');
|
||||
el.setAttribute('aria-invalid', 'true');
|
||||
var id = fieldNoteId(el);
|
||||
var note = document.getElementById(id);
|
||||
if (!note) {
|
||||
note = document.createElement('span');
|
||||
note.id = id;
|
||||
note.className = 'location-field-note';
|
||||
note.setAttribute('role', 'status');
|
||||
note.setAttribute('aria-live', 'polite');
|
||||
note.textContent = 'Not reflected on the map yet.';
|
||||
el.parentNode.insertBefore(note, el.nextSibling);
|
||||
}
|
||||
el.setAttribute('aria-describedby', id);
|
||||
});
|
||||
}
|
||||
|
||||
function clearMismatch() {
|
||||
[latEl, lngEl].forEach(function (el) {
|
||||
el.classList.remove('location-field--mismatch');
|
||||
el.removeAttribute('aria-invalid');
|
||||
el.removeAttribute('aria-describedby');
|
||||
var note = document.getElementById(fieldNoteId(el));
|
||||
if (note && note.parentNode) note.parentNode.removeChild(note);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Map + sync (U4, U5, R9-R14, KTD1/KTD3/KTD4) ──
|
||||
var mapHandle = null;
|
||||
|
||||
function syncFields() {
|
||||
var lat = parseFloat(latEl.value);
|
||||
var lng = parseFloat(lngEl.value);
|
||||
var valid = isFinite(lat) && isFinite(lng) &&
|
||||
lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180;
|
||||
if (valid) {
|
||||
clearMismatch();
|
||||
if (mapHandle) mapHandle.setPin(lat, lng);
|
||||
} else if (latEl.value.trim() || lngEl.value.trim()) {
|
||||
// Only flag once the traveller has actually typed something —
|
||||
// fresh blank fields are "no pin yet" (R12), not a mismatch.
|
||||
setMismatch();
|
||||
} else {
|
||||
// Both blanked back out after being flagged — nothing left to submit,
|
||||
// so the flag no longer applies.
|
||||
clearMismatch();
|
||||
}
|
||||
}
|
||||
syncPinFromFields = syncFields; // expose for initGeo()'s GPS handler
|
||||
|
||||
function onDragEnd(lngLat) {
|
||||
// Drag writes straight into the fields; it does not call syncFields()
|
||||
// back, avoiding a feedback loop (KTD4).
|
||||
latEl.value = lngLat.lat.toFixed(6);
|
||||
lngEl.value = lngLat.lng.toFixed(6);
|
||||
clearMismatch();
|
||||
}
|
||||
|
||||
details.addEventListener('toggle', function () {
|
||||
if (!details.open) return;
|
||||
getOrCreateLocationMap(mapContainer, onDragEnd).then(function (handle) {
|
||||
mapHandle = handle;
|
||||
handle.resize(); // fixes the zero-size canvas from painting while display:none
|
||||
syncFields();
|
||||
}).catch(function () {
|
||||
setHint('Map preview unavailable — you can still enter coordinates directly.');
|
||||
});
|
||||
});
|
||||
|
||||
latEl.addEventListener('blur', syncFields);
|
||||
lngEl.addEventListener('blur', syncFields);
|
||||
var debouncedSync = debounce(syncFields, 400);
|
||||
latEl.addEventListener('input', debouncedSync);
|
||||
lngEl.addEventListener('input', debouncedSync);
|
||||
|
||||
// ── Search (U3, R4-R8, KTD2) ──
|
||||
function setHint(msg) { hint.textContent = msg || ''; }
|
||||
|
||||
function hideResults() { results.innerHTML = ''; }
|
||||
|
||||
function showResults(list) {
|
||||
list.forEach(function (r) {
|
||||
var li = document.createElement('li');
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.textContent = geocodeResultLabel(r); // createElement + textContent — no innerHTML for API data (R7)
|
||||
btn.addEventListener('click', function () {
|
||||
latEl.value = Number(r.latitude).toFixed(6);
|
||||
lngEl.value = Number(r.longitude).toFixed(6);
|
||||
hideResults();
|
||||
syncFields(); // R7: sets lat/lng + pin only — never writes City/Country
|
||||
});
|
||||
li.appendChild(btn);
|
||||
results.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
lookupBtn.addEventListener('click', function () {
|
||||
hideResults();
|
||||
var city = cityEl ? cityEl.value.trim() : '';
|
||||
var country = countryEl ? countryEl.value.trim() : '';
|
||||
if (!city && !country) {
|
||||
setHint('Enter a city or country first.');
|
||||
return;
|
||||
}
|
||||
// R4: never concatenate Country into the query string — verified live,
|
||||
// it silently breaks disambiguation (e.g. "Paris, Texas"). Query by
|
||||
// City; Country only ranks results client-side, and is the query term
|
||||
// only as a fallback when City itself is blank.
|
||||
var query = city || country;
|
||||
setHint('');
|
||||
lookupBtn.disabled = true;
|
||||
lookupBtn.textContent = 'Searching…';
|
||||
var url = 'https://geocoding-api.open-meteo.com/v1/search?name=' +
|
||||
encodeURIComponent(query) + '&count=10&language=en&format=json';
|
||||
// Bound the request so a hung/never-resolving response can't leave the
|
||||
// button stuck disabled on "Searching…" forever.
|
||||
var controller = new AbortController();
|
||||
var timeoutId = setTimeout(function () { controller.abort(); }, 10000);
|
||||
fetch(url, { signal: controller.signal }).then(function (r) { return r.json(); }).then(function (data) {
|
||||
var list = (data && data.results) || [];
|
||||
if (!list.length) {
|
||||
setHint('No matches — try adding a country, or drag the pin on the map.');
|
||||
return;
|
||||
}
|
||||
if (country) {
|
||||
var needle = country.toLowerCase();
|
||||
// Match against admin1 (state/region — e.g. "Texas") as well as
|
||||
// country ("United States"): Open-Meteo's `country` field alone
|
||||
// never reflects a state/province, so a Country field typed as
|
||||
// "Texas" would never rank the Texas Paris above the other
|
||||
// US-state Paris matches without also checking admin1 (verified
|
||||
// live — see the design doc's Paris/Texas disambiguation case).
|
||||
var matches = function (r) {
|
||||
return (r.admin1 || '').toLowerCase().indexOf(needle) !== -1 ||
|
||||
(r.country || '').toLowerCase().indexOf(needle) !== -1;
|
||||
};
|
||||
list = list.slice().sort(function (a, b) {
|
||||
var am = matches(a);
|
||||
var bm = matches(b);
|
||||
if (am === bm) return 0;
|
||||
return am ? -1 : 1;
|
||||
});
|
||||
}
|
||||
showResults(list);
|
||||
}).catch(function () {
|
||||
// R8: network failure (including our own timeout abort) degrades
|
||||
// silently — fields untouched.
|
||||
}).then(function () {
|
||||
clearTimeout(timeoutId);
|
||||
lookupBtn.disabled = false;
|
||||
lookupBtn.textContent = LOOKUP_LABEL;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ── 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.
|
||||
@@ -564,6 +817,13 @@ function initValidation() {
|
||||
if (!firstInvalid) firstInvalid = el;
|
||||
}
|
||||
});
|
||||
|
||||
// A flagged-but-unresolved lat/lng (typed garbage, never fixed or
|
||||
// cleared) must not reach the server — the mismatch styling alone
|
||||
// doesn't block submission (caught in code review).
|
||||
var mismatchEl = form.querySelector('.location-field--mismatch');
|
||||
if (mismatchEl && !firstInvalid) firstInvalid = mismatchEl;
|
||||
|
||||
if (firstInvalid) {
|
||||
e.preventDefault();
|
||||
if (typeof firstInvalid.focus === 'function') firstInvalid.focus();
|
||||
@@ -1206,6 +1466,7 @@ function boot() {
|
||||
initPhotoConversion();
|
||||
initDisclosure();
|
||||
initGeo();
|
||||
initLocationDetails();
|
||||
initValidation();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user