diff --git a/TODO.adoc b/TODO.adoc index 5824c1d..61e6ae1 100644 --- a/TODO.adoc +++ b/TODO.adoc @@ -45,6 +45,7 @@ independence from connectivity. ** [x] Edit/delete individual notes from their popup (logged-in only) — targets a feature by a client-generated `properties.id` (`GpxStore#update_feature`/`#delete_feature`), not array position ** [x] Measure's vertices are draggable (mousedown + move, `event.preventDefault()` to stop the map's own drag-pan) and deletable (click to select — shown in yellow — then Delete/Backspace) ** [x] Fixed: a note saved before `properties.id` existed (the earliest test notes) had no Edit/Delete-able target — the popup no longer offers them without an id, and `rake gpx:backfill_note_ids` assigns one to any pre-existing Note feature missing it +** [x] Title + body split (name in the black header, body in a separate field) with light self-authored markdown (`**bold**`, `*italic*`, auto-linked URLs) in the body — a *new*, separate `formatNoteBody` (not `formatDescription`, which stays plain/untrusted-safe for uploaded GPX/park4night content per docs/nps-resources.adoc's earlier markdown-rendering call) ** [x] Gated behind login: NotesControl isn't added to the map at all when logged out, Measure's "Save as note" button is hidden (Measure itself stays usable either way) ** [ ] The Unigrid Matrix category filter only re-slices the *points* source (`_applyCategoryFilter`) — toggling the "Note" category off hides point notes but not polygon/line ones sharing that category * [ ] Offline satellite tile caching (e.g. ESRI) for a selected route area — key for judging surface quality and obstacles when wild camping diff --git a/public/css/style.css b/public/css/style.css index 0f385bd..f5a4154 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -869,7 +869,8 @@ html, body { display: none; } -.notes-panel__text { +.notes-panel__title, +.notes-panel__body { width: 100%; box-sizing: border-box; font: inherit; @@ -877,6 +878,13 @@ html, body { padding: 4px 6px; border: 1px solid #ddd; border-radius: 4px; +} + +.notes-panel__title { + font-weight: 600; +} + +.notes-panel__body { resize: vertical; } @@ -1053,7 +1061,8 @@ html, body { color: #fff; } -.gpx-popup__edit-text { +.gpx-popup__edit-title, +.gpx-popup__edit-body { width: 100%; box-sizing: border-box; font: inherit; @@ -1061,6 +1070,14 @@ html, body { padding: 4px 6px; border: 1px solid #ddd; border-radius: 4px; + margin-bottom: 6px; +} + +.gpx-popup__edit-title { + font-weight: 600; +} + +.gpx-popup__edit-body { resize: vertical; } diff --git a/public/js/gpx.js b/public/js/gpx.js index 2b19e8d..9c288b1 100644 --- a/public/js/gpx.js +++ b/public/js/gpx.js @@ -742,23 +742,29 @@ export class GpxPanelControl { const content = popup.getElement().querySelector(".maplibregl-popup-content"); content.innerHTML = `
- + +
`; - const textarea = content.querySelector(".gpx-popup__edit-text"); - textarea.focus(); + const titleInput = content.querySelector(".gpx-popup__edit-title"); + const bodyInput = content.querySelector(".gpx-popup__edit-body"); + titleInput.focus(); content.querySelector(".gpx-popup__edit-cancel").addEventListener("click", () => { content.innerHTML = popupHtml(feature.properties, { editable: true }); this._wireNoteActions(popup, path, feature); }); content.querySelector(".gpx-popup__edit-save").addEventListener("click", async () => { - const text = textarea.value.trim(); - if (!text) return; - const updated = { ...feature, properties: { ...feature.properties, name: text } }; + const title = titleInput.value.trim(); + if (!title) return; + const details = bodyInput.value.trim(); + const properties = { ...feature.properties, name: title }; + if (details) properties.description = details; + else delete properties.description; + const updated = { ...feature, properties }; try { const body = new URLSearchParams({ path, id, feature: JSON.stringify(updated) }); const response = await fetch("/api/gpx/notes", { method: "PUT", body }); @@ -1085,7 +1091,13 @@ function popupHtml({ name, description, time, category, photos, icon }, { editab } if (category) parts.push(`

${escapeHtml(category)}

`); if (photos?.length > 0) parts.push(formatPhotos(photos)); - if (description) parts.push(formatDescription(description)); + // A Note's description is self-authored by the (logged-in) viewer, not + // uploaded/untrusted like every other description here — formatDescription + // is deliberately left alone for that untrusted case (see + // docs/nps-resources.adoc's note on why markdown rendering wasn't adopted + // for it); formatNoteBody is the separate, narrower path that's actually + // safe to add light markdown to. + if (description) parts.push(category === "Note" ? formatNoteBody(description) : formatDescription(description)); if (time) { const parsed = new Date(time); if (!Number.isNaN(parsed.getTime())) { @@ -1233,3 +1245,17 @@ function urlToLink(url) { const href = escapeHtml(core); return `${href}${escapeHtml(trailing)}`; } + +// A Note's body only — never an uploaded file's description (see the +// comment where this is called). linkify() runs first and does its own +// escaping, so what it returns is already safe HTML; **bold**/*italic* are +// then matched against that (already-escaped) text and wrapped in real +// tags — order matters: running it before linkify would have that same +// escaping re-encode the tags this just added. +function formatNoteBody(text) { + let html = linkify(text) + .replace(/\*\*(.+?)\*\*/g, "$1") + .replace(/\*(.+?)\*/g, "$1") + .replace(/\n/g, "
"); + return `

${html}

`; +} diff --git a/public/js/notes.js b/public/js/notes.js index 93eb141..a4ddc1a 100644 --- a/public/js/notes.js +++ b/public/js/notes.js @@ -28,7 +28,8 @@ export class NoteSaveForm { this._el.className = "notes-panel__form"; this._el.hidden = true; this._el.innerHTML = ` - + +
@@ -38,7 +39,8 @@ export class NoteSaveForm { `; container.appendChild(this._el); - this._text = this._el.querySelector(".notes-panel__text"); + this._title = this._el.querySelector(".notes-panel__title"); + this._body = this._el.querySelector(".notes-panel__body"); this._target = this._el.querySelector(".notes-panel__target"); this._newName = this._el.querySelector(".notes-panel__new-name"); this._saveButton = this._el.querySelector(".notes-panel__save"); @@ -58,8 +60,9 @@ export class NoteSaveForm { async open(geometry) { this._geometry = geometry; this._el.hidden = false; - this._text.value = ""; - this._text.focus(); + this._title.value = ""; + this._body.value = ""; + this._title.focus(); await this._populateTargets(); } @@ -90,8 +93,9 @@ export class NoteSaveForm { } async _save() { - const text = this._text.value.trim(); - if (!text || !this._geometry) return; + const title = this._title.value.trim(); + const details = this._body.value.trim(); + if (!title || !this._geometry) return; const isNewLayer = this._target.value === NEW_LAYER_VALUE; const name = isNewLayer ? this._newName.value.trim() : ""; @@ -102,17 +106,23 @@ export class NoteSaveForm { const feature = { type: "Feature", - properties: { id: generateNoteId(), name: text, category: "Note", time: new Date().toISOString() }, + properties: { + id: generateNoteId(), + name: title, + category: "Note", + time: new Date().toISOString(), + ...(details ? { description: details } : {}), + }, geometry: this._geometry, }; - const body = new URLSearchParams({ feature: JSON.stringify(feature) }); - if (isNewLayer) body.set("name", name); - else body.set("path", this._target.value); + const requestBody = new URLSearchParams({ feature: JSON.stringify(feature) }); + if (isNewLayer) requestBody.set("name", name); + else requestBody.set("path", this._target.value); this._saveButton.disabled = true; try { - const response = await fetch("/api/gpx/notes", { method: "POST", body }); + const response = await fetch("/api/gpx/notes", { method: "POST", body: requestBody }); if (!response.ok) throw new Error(`save failed: ${response.status}`); notifyGpxStoreChanged(); this.close();