Compare commits
4
Commits
| 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);
|
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 */
|
/* Grav form field inputs */
|
||||||
.post-form-wrap .form-field { margin-bottom: var(--space-5); }
|
.post-form-wrap .form-field { margin-bottom: var(--space-5); }
|
||||||
.post-form-wrap .form-label label {
|
.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 */
|
/* 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) {
|
(function (global) {
|
||||||
var ACCENT = '#2A8C73';
|
var ACCENT = '#2A8C73';
|
||||||
var ACCENT_DIM = '#155244';
|
var ACCENT_DIM = '#155244';
|
||||||
var MAP_STYLE = 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json';
|
|
||||||
|
|
||||||
/* Build a GeoJSON LineString feature */
|
/* Build a GeoJSON LineString feature */
|
||||||
function lineFeature(coords) {
|
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. */
|
/* SortableJS drag feedback. */
|
||||||
.photo-editor__cell.sortable-ghost { opacity: 0.4; }
|
.photo-editor__cell.sortable-ghost { opacity: 0.4; }
|
||||||
.photo-editor__cell.sortable-chosen { outline: 2px solid var(--color-accent); }
|
.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 EasyMDE from 'easymde';
|
||||||
import Sortable from 'sortablejs';
|
import Sortable from 'sortablejs';
|
||||||
import 'easymde/dist/easymde.min.css';
|
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 './post-form.css';
|
||||||
import { apiSend, apiErrorMsg } from './api-utils.js';
|
import { apiSend, apiErrorMsg } from './api-utils.js';
|
||||||
|
import { getOrCreateLocationMap } from './location-map.js';
|
||||||
|
|
||||||
/* ── Markdown editor (EasyMDE) ───────────────────────────── */
|
/* ── Markdown editor (EasyMDE) ───────────────────────────── */
|
||||||
function initEditor() {
|
function initEditor() {
|
||||||
@@ -441,6 +445,10 @@ function initGeo() {
|
|||||||
setStatus(locStatus, '✓ Location captured · ' + lat + ', ' + lng, 'ok');
|
setStatus(locStatus, '✓ Location captured · ' + lat + ', ' + lng, 'ok');
|
||||||
syncWeatherEnabled();
|
syncWeatherEnabled();
|
||||||
reverseGeocode(lat, lng); // fill City/Country in the background
|
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) {
|
}, function (err) {
|
||||||
locBtn.classList.remove('is-loading');
|
locBtn.classList.remove('is-loading');
|
||||||
locBtn.disabled = false;
|
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) ─────────────
|
/* ── Blocking required-field validation (U5, R19) ─────────────
|
||||||
* Replaces the template's inline validator. Reads the EasyMDE-synced content
|
* Replaces the template's inline validator. Reads the EasyMDE-synced content
|
||||||
* value (initEditor keeps the textarea current) and shows per-field messages.
|
* value (initEditor keeps the textarea current) and shows per-field messages.
|
||||||
@@ -564,6 +817,13 @@ function initValidation() {
|
|||||||
if (!firstInvalid) firstInvalid = el;
|
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) {
|
if (firstInvalid) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (typeof firstInvalid.focus === 'function') firstInvalid.focus();
|
if (typeof firstInvalid.focus === 'function') firstInvalid.focus();
|
||||||
@@ -1206,6 +1466,7 @@ function boot() {
|
|||||||
initPhotoConversion();
|
initPhotoConversion();
|
||||||
initDisclosure();
|
initDisclosure();
|
||||||
initGeo();
|
initGeo();
|
||||||
|
initLocationDetails();
|
||||||
initValidation();
|
initValidation();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user