diff --git a/frontend/static/bookmarks-ui.js b/frontend/static/bookmarks-ui.js new file mode 100644 index 0000000..82877ed --- /dev/null +++ b/frontend/static/bookmarks-ui.js @@ -0,0 +1,252 @@ +// Map-page bookmark UI: a star toggle in the results "share" row, and a +// compact "Saved" pill + dropdown near the Find button for jumping between +// saved spots without retyping. Pure DOM glue over bookmarks.js, which is the +// only place bookmark data actually lives — this module holds no state of its +// own beyond references to the elements it built. +// +// Loaded as an extra module alongside app.js rather than merged into it: app.js +// owns #results' markup (it rewrites results.innerHTML wholesale on every grade +// via its own render()), so a MutationObserver on #results is what lets this +// module re-attach the star after every re-render without needing a hook +// app.js doesn't expose. selectLocation() is module-private to app.js, so +// jumping to a saved spot goes through the same hash the app already treats as +// authoritative on load (see nav.js: "a hash always wins") via a reload — +// the same path opening a shared link takes, so the normal grade flow runs. +import * as bookmarks from "./bookmarks.js"; + +const STAR_OUTLINE = ``; +const STAR_FILLED = ``; +const CHEV_IC = ``; +const CLOSE_X = ``; +const PENCIL_IC = ``; + +function esc(s) { + return String(s == null ? "" : s).replace(/[&<>"']/g, (c) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); +} + +function injectStyle() { + if (document.getElementById("tg-bookmarks-style")) return; + const style = document.createElement("style"); + style.id = "tg-bookmarks-style"; + style.textContent = ` +.bkm-star[aria-pressed="true"] { color: var(--accent); border-color: var(--accent); } +.bkm-pill-wrap { position: relative; display: inline-flex; align-self: center; } +.bkm-pill { + border-radius: 10px; border: 1px solid var(--border); cursor: pointer; + background: var(--surface-2); color: var(--text); font-weight: 600; font-size: 13px; + padding: 9px 12px; min-height: 40px; display: inline-flex; align-items: center; gap: 6px; +} +.bkm-pill:hover { border-color: var(--accent); } +.bkm-pill[hidden] { display: none; } +.bkm-drop { + position: absolute; left: 0; top: calc(100% + 6px); z-index: 1100; min-width: 260px; + max-width: min(340px, 90vw); max-height: 60vh; overflow-y: auto; + display: flex; flex-direction: column; padding: 6px; margin: 0; list-style: none; + background: var(--surface); border: 1px solid var(--border); border-radius: 12px; + box-shadow: 0 16px 40px rgba(0, 0, 0, .45); +} +.bkm-drop[hidden] { display: none; } +.bkm-item { display: flex; align-items: center; gap: 2px; border-radius: 8px; } +.bkm-item:hover { background: var(--surface-2); } +.bkm-item-go { + flex: 1 1 auto; min-width: 0; text-align: left; background: none; border: 0; cursor: pointer; + color: var(--text); font: inherit; font-size: 13.5px; padding: 9px 8px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.bkm-item-btn { + flex-shrink: 0; background: none; border: 0; color: var(--muted); cursor: pointer; + padding: 7px; border-radius: 6px; display: inline-flex; +} +.bkm-item-btn:hover { color: var(--text); background: var(--border); } +.bkm-empty { padding: 10px 8px; font-size: 12.5px; color: var(--muted); } +.bkm-import-banner { + display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; + background: var(--surface-2); border: 1px solid var(--border); border-left: 3px solid var(--accent); + border-radius: 12px; padding: 10px 14px; margin: 0 0 16px; font-size: 13.5px; +} +.bkm-import-banner .bkm-import-actions { display: flex; gap: 8px; flex-shrink: 0; } +.bkm-import-banner button { + border-radius: 8px; border: 1px solid var(--border); cursor: pointer; + padding: 7px 12px; font-size: 12.5px; font-weight: 600; background: var(--surface); color: var(--text); +} +.bkm-import-banner button.bkm-import-yes { background: var(--accent); color: #1a1206; border-color: var(--accent); } +@media (max-width: 480px) { .bkm-drop { left: auto; right: 0; } } +`; + document.head.appendChild(style); +} + +function parseHash() { + const p = new URLSearchParams(location.hash.slice(1)); + const lat = parseFloat(p.get("lat")), lon = parseFloat(p.get("lon")); + if (isNaN(lat) || isNaN(lon)) return null; + return { lat, lon }; +} + +function jumpTo(lat, lon) { + const dateInput = document.getElementById("date-input"); + const date = dateInput && dateInput.value ? dateInput.value : ""; + let h = `#lat=${lat.toFixed(5)}&lon=${lon.toFixed(5)}`; + if (date) h += `&date=${date}`; + location.hash = h; + location.reload(); +} + +function currentLabel() { + const h2 = document.querySelector("#results .loc-title h2"); + return (h2 && h2.textContent.trim()) || null; +} + +// ---- star toggle in the .share row ---- +function buildStar() { + const btn = document.createElement("button"); + btn.type = "button"; + btn.id = "btn-bookmark"; + btn.className = "bkm-star"; + btn.addEventListener("click", async () => { + const loc = parseHash(); + if (!loc) return; + const existing = bookmarks.find(loc.lat, loc.lon); + btn.disabled = true; + try { + if (existing) await bookmarks.remove(existing.id); + else await bookmarks.add(loc.lat, loc.lon, currentLabel()); + } finally { + btn.disabled = false; + } + }); + return btn; +} + +function paintStar() { + const share = document.querySelector("#results .share"); + if (!share) return; + let btn = share.querySelector("#btn-bookmark"); + if (!btn) { + btn = buildStar(); + share.insertBefore(btn, share.firstChild); + } + const loc = parseHash(); + const saved = loc ? !!bookmarks.find(loc.lat, loc.lon) : false; + btn.setAttribute("aria-pressed", saved ? "true" : "false"); + btn.title = saved ? "Remove this saved location" : "Save this location"; + btn.setAttribute("aria-label", btn.title); + btn.innerHTML = (saved ? STAR_FILLED : STAR_OUTLINE) + `${saved ? "Saved" : "Save"}`; +} + +const resultsEl = document.getElementById("results"); +if (resultsEl) { + new MutationObserver(() => paintStar()).observe(resultsEl, { childList: true }); +} + +// ---- "Saved" dropdown near the Find button ---- +let dropEl = null, pillEl = null; + +function buildSavedControl() { + const findBar = document.querySelector(".find-bar"); + if (!findBar) return; + const wrap = document.createElement("div"); + wrap.className = "bkm-pill-wrap"; + wrap.innerHTML = ` + +