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:
2026-07-04 16:42:58 +02:00
parent bf5b5c2f3c
commit 7f021ab114
5 changed files with 435 additions and 179 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+173
View File
@@ -65,3 +65,176 @@
}
.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 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); }
+192
View File
@@ -212,12 +212,204 @@ function initPhotoPicker() {
}, 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 + '&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.
*/
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 ────────────────────────────────────────────────── */
function boot() {
// Exposed for later units (U6 draft restore) and the Playwright specs;
// null when this bundle loads on a page without the content field.
window.postFormEditor = initEditor();
initPhotoPicker();
initDisclosure();
initGeo();
initValidation();
}
if (document.readyState === 'loading') {
@@ -12,120 +12,11 @@
<div class="form-action-row">
<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>
<p id="location-status" class="form-status"></p>
<p id="weather-status" class="form-status"></p>
<p id="location-status" class="form-status" role="status"></p>
<p id="weather-status" class="form-status" role="status"></p>
</div>
<script>
// 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 +
'&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 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>
{# Get Location / Get Weather, disclosure, and validation all live in the
page-scoped bundle (js/src/post-form.js), loaded via head_assets above. #}
{% endblock %}