feat(post-form): Field Notes styling, disclosure, feedback states (U5)
Migrate Get Location/Weather + required-field validation out of the template's inline scripts into the bundle: generalize the field lookup so weather_desc (now a <select>) is found, gate Get Weather until coords exist, and add idle/loading/success/error states. Build the 'More options' <details> (native, KTD5) around the advanced trio, auto-open when any has a value. Add Field Notes CSS: select/number styling, EasyMDE dark theme, disclosure, button spinner, and server form-message states. Refs R5, R11, R12, R18, R19, AE3, KTD5.
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -65,3 +65,176 @@
|
|||||||
}
|
}
|
||||||
.photo-card.is-error .photo-card__state { background: rgba(176, 0, 32, 0.85); }
|
.photo-card.is-error .photo-card__state { background: rgba(176, 0, 32, 0.85); }
|
||||||
.photo-card.is-converting .photo-card__thumb { opacity: 0.4; }
|
.photo-card.is-converting .photo-card__thumb { opacity: 0.4; }
|
||||||
|
|
||||||
|
/* Photo picker add button + hint (U5 polish) */
|
||||||
|
.photo-picker__add {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.photo-picker.is-busy .photo-picker__add {
|
||||||
|
opacity: 0.6;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.photo-picker__hint:empty { display: none; }
|
||||||
|
.photo-picker__reauth-hint {
|
||||||
|
display: none;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-ink-muted);
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
}
|
||||||
|
.photo-picker__reauth-hint.is-shown { display: block; }
|
||||||
|
|
||||||
|
/* ── Field Notes styling for the new controls (U5, R11/R12) ── */
|
||||||
|
|
||||||
|
/* Select fields (weather condition, transport mode) match the text inputs. */
|
||||||
|
.post-form-wrap select {
|
||||||
|
width: 100%;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
min-height: 44px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-canvas);
|
||||||
|
color: var(--color-ink);
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath fill='%2390887E' d='M1 1l5 5 5-5'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 1rem center;
|
||||||
|
padding-right: 2.5rem;
|
||||||
|
}
|
||||||
|
.post-form-wrap select:focus {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
.post-form-wrap input[type="number"] {
|
||||||
|
width: 100%;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
min-height: 44px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-canvas);
|
||||||
|
color: var(--color-ink);
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
.post-form-wrap input[type="number"]:focus {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "More options" disclosure */
|
||||||
|
.more-options {
|
||||||
|
margin-bottom: var(--space-5);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-canvas);
|
||||||
|
}
|
||||||
|
.more-options__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;
|
||||||
|
}
|
||||||
|
.more-options__summary::-webkit-details-marker { display: none; }
|
||||||
|
.more-options__summary::before {
|
||||||
|
content: '▸';
|
||||||
|
margin-right: var(--space-2);
|
||||||
|
color: var(--color-ink-muted);
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
.more-options[open] .more-options__summary::before { transform: rotate(90deg); }
|
||||||
|
.more-options[open] .more-options__summary { border-bottom: 1px solid var(--color-border); }
|
||||||
|
.more-options > .form-field { padding: 0 1rem; }
|
||||||
|
.more-options > .form-field:first-of-type { padding-top: var(--space-4); }
|
||||||
|
.more-options > .form-field:last-child { padding-bottom: var(--space-4); margin-bottom: 0; }
|
||||||
|
|
||||||
|
/* Button loading spinner (Get Location / Get Weather) */
|
||||||
|
.btn-action.is-loading { position: relative; color: transparent; pointer-events: none; }
|
||||||
|
.btn-action.is-loading::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin: -8px 0 0 -8px;
|
||||||
|
border: 2px solid var(--color-ink-muted);
|
||||||
|
border-top-color: var(--color-accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: post-spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
.btn-action[disabled] { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
@keyframes post-spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* Grav form save/validation messages (success + error from the server) */
|
||||||
|
.post-form-wrap .form-messages { margin-bottom: var(--space-5); }
|
||||||
|
.post-form-wrap .form-message {
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
.post-form-wrap .form-message.notice,
|
||||||
|
.post-form-wrap .form-message.error {
|
||||||
|
background: var(--color-accent-light);
|
||||||
|
color: var(--color-ink);
|
||||||
|
border: 1px solid var(--color-error);
|
||||||
|
}
|
||||||
|
.post-form-wrap .form-message.success {
|
||||||
|
background: var(--color-accent-light);
|
||||||
|
color: var(--color-ink);
|
||||||
|
border: 1px solid var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── EasyMDE — Field Notes dark theme (U5) ─────────────────── */
|
||||||
|
.EasyMDEContainer .CodeMirror {
|
||||||
|
background: var(--color-canvas);
|
||||||
|
color: var(--color-ink);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0 0 var(--radius-md) var(--radius-md);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
line-height: var(--leading-normal);
|
||||||
|
padding: var(--space-1);
|
||||||
|
}
|
||||||
|
.EasyMDEContainer .CodeMirror-cursor { border-color: var(--color-ink); }
|
||||||
|
.EasyMDEContainer .CodeMirror-selected { background: var(--color-accent-light) !important; }
|
||||||
|
.EasyMDEContainer .editor-toolbar {
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-bottom: none;
|
||||||
|
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.EasyMDEContainer .editor-toolbar button {
|
||||||
|
color: var(--color-ink) !important;
|
||||||
|
min-width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
}
|
||||||
|
.EasyMDEContainer .editor-toolbar button:hover,
|
||||||
|
.EasyMDEContainer .editor-toolbar button.active {
|
||||||
|
background: var(--color-accent-light);
|
||||||
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
.EasyMDEContainer .editor-toolbar i.separator { border-color: var(--color-border); }
|
||||||
|
.EasyMDEContainer .editor-preview,
|
||||||
|
.EasyMDEContainer .editor-preview-side {
|
||||||
|
background: var(--color-canvas);
|
||||||
|
color: var(--color-ink);
|
||||||
|
}
|
||||||
|
.EasyMDEContainer .editor-preview a { color: var(--color-accent); }
|
||||||
|
|||||||
@@ -212,12 +212,204 @@ function initPhotoPicker() {
|
|||||||
}, true);
|
}, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 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.
|
||||||
|
*/
|
||||||
|
function initValidation() {
|
||||||
|
var REQUIRED = { title: 'Title', content: 'Content' };
|
||||||
|
var form = document.querySelector('form[name="new-entry"]');
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
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' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Boot ────────────────────────────────────────────────── */
|
/* ── Boot ────────────────────────────────────────────────── */
|
||||||
function boot() {
|
function boot() {
|
||||||
// Exposed for later units (U6 draft restore) and the Playwright specs;
|
// Exposed for later units (U6 draft restore) and the Playwright specs;
|
||||||
// null when this bundle loads on a page without the content field.
|
// null when this bundle loads on a page without the content field.
|
||||||
window.postFormEditor = initEditor();
|
window.postFormEditor = initEditor();
|
||||||
initPhotoPicker();
|
initPhotoPicker();
|
||||||
|
initDisclosure();
|
||||||
|
initGeo();
|
||||||
|
initValidation();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
|
|||||||
@@ -12,120 +12,11 @@
|
|||||||
|
|
||||||
<div class="form-action-row">
|
<div class="form-action-row">
|
||||||
<button type="button" id="get-location" class="btn-action">📍 Get Location</button>
|
<button type="button" id="get-location" class="btn-action">📍 Get Location</button>
|
||||||
<button type="button" id="get-weather" class="btn-action">🌤 Get Weather</button>
|
<button type="button" id="get-weather" class="btn-action" disabled>🌤 Get Weather</button>
|
||||||
</div>
|
</div>
|
||||||
<p id="location-status" class="form-status"></p>
|
<p id="location-status" class="form-status" role="status"></p>
|
||||||
<p id="weather-status" class="form-status"></p>
|
<p id="weather-status" class="form-status" role="status"></p>
|
||||||
</div>
|
</div>
|
||||||
|
{# Get Location / Get Weather, disclosure, and validation all live in the
|
||||||
<script>
|
page-scoped bundle (js/src/post-form.js), loaded via head_assets above. #}
|
||||||
// Custom validation — form uses novalidate so we handle it here
|
|
||||||
(function() {
|
|
||||||
var REQUIRED = ['title', 'content'];
|
|
||||||
var form = document.querySelector('form[name="new-entry"]');
|
|
||||||
if (!form) return;
|
|
||||||
|
|
||||||
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(field, msg) {
|
|
||||||
field.classList.add('field-invalid');
|
|
||||||
var err = document.createElement('span');
|
|
||||||
err.className = 'field-error';
|
|
||||||
err.textContent = msg;
|
|
||||||
field.parentNode.insertBefore(err, field.nextSibling);
|
|
||||||
}
|
|
||||||
|
|
||||||
form.addEventListener('submit', function(e) {
|
|
||||||
clearErrors();
|
|
||||||
var firstInvalid = null;
|
|
||||||
REQUIRED.forEach(function(name) {
|
|
||||||
var field = form.querySelector('[name="data[' + name + ']"]');
|
|
||||||
if (field && !field.value.trim()) {
|
|
||||||
showError(field, name.charAt(0).toUpperCase() + name.slice(1) + ' is required.');
|
|
||||||
if (!firstInvalid) firstInvalid = field;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (firstInvalid) {
|
|
||||||
e.preventDefault();
|
|
||||||
firstInvalid.focus();
|
|
||||||
firstInvalid.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}());
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
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 getField(name) {
|
|
||||||
return document.querySelector('input[name="data[' + name + ']"]');
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('get-location').addEventListener('click', function() {
|
|
||||||
var status = document.getElementById('location-status');
|
|
||||||
status.className = 'form-status';
|
|
||||||
status.textContent = 'Getting location…';
|
|
||||||
if (!navigator.geolocation) {
|
|
||||||
status.textContent = 'Geolocation not supported.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
navigator.geolocation.getCurrentPosition(function(pos) {
|
|
||||||
var lat = pos.coords.latitude.toFixed(6);
|
|
||||||
var lng = pos.coords.longitude.toFixed(6);
|
|
||||||
var latField = getField('lat');
|
|
||||||
var lngField = getField('lng');
|
|
||||||
if (latField) latField.value = lat;
|
|
||||||
if (lngField) lngField.value = lng;
|
|
||||||
status.textContent = '✓ Location captured · ' + lat + ', ' + lng;
|
|
||||||
status.classList.add('form-status--ok');
|
|
||||||
}, function(err) {
|
|
||||||
status.textContent = '✗ Could not get location: ' + err.message;
|
|
||||||
status.classList.add('form-status--err');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('get-weather').addEventListener('click', function() {
|
|
||||||
var status = document.getElementById('weather-status');
|
|
||||||
status.className = 'form-status';
|
|
||||||
var latField = getField('lat');
|
|
||||||
var lngField = getField('lng');
|
|
||||||
var lat = latField ? latField.value.trim() : '';
|
|
||||||
var lng = lngField ? lngField.value.trim() : '';
|
|
||||||
if (!lat || !lng) {
|
|
||||||
status.textContent = 'Get location first, then fetch weather.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
status.textContent = 'Fetching weather…';
|
|
||||||
var url = 'https://api.open-meteo.com/v1/forecast?latitude=' + lat +
|
|
||||||
'&longitude=' + 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 code = data.current.weather_code;
|
|
||||||
var desc = WMO_MAP[code] || 'Cloudy';
|
|
||||||
var tempField = getField('weather_temp_c');
|
|
||||||
var descField = getField('weather_desc');
|
|
||||||
if (tempField) tempField.value = temp;
|
|
||||||
if (descField) descField.value = desc;
|
|
||||||
status.textContent = '✓ Weather set · ' + desc + ' · ' + temp + '°C';
|
|
||||||
status.classList.add('form-status--ok');
|
|
||||||
})
|
|
||||||
.catch(function() {
|
|
||||||
status.textContent = '✗ Could not fetch weather — enter manually if needed.';
|
|
||||||
status.classList.add('form-status--err');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user