promote: main → release #132

Merged
admin_emi merged 56 commits from main into release 2026-08-01 14:48:26 +00:00
Showing only changes of commit d6ead56971 - Show all commits

View file

@ -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 = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>`;
const STAR_FILLED = `<svg viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>`;
const CHEV_IC = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>`;
const CLOSE_X = `<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
const PENCIL_IC = `<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>`;
function esc(s) {
return String(s == null ? "" : s).replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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) + `<span>${saved ? "Saved" : "Save"}</span>`;
}
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 = `
<button type="button" class="bkm-pill" id="bkm-pill" hidden aria-haspopup="true" aria-expanded="false">
<span>Saved</span>${CHEV_IC}
</button>
<ul class="bkm-drop" id="bkm-drop" hidden aria-label="Saved locations"></ul>`;
findBar.appendChild(wrap);
pillEl = wrap.querySelector("#bkm-pill");
dropEl = wrap.querySelector("#bkm-drop");
pillEl.addEventListener("click", () => {
const open = dropEl.hidden;
dropEl.hidden = !open;
pillEl.setAttribute("aria-expanded", open ? "true" : "false");
});
document.addEventListener("click", (e) => {
if (!wrap.contains(e.target) && !dropEl.hidden) {
dropEl.hidden = true;
pillEl.setAttribute("aria-expanded", "false");
}
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && !dropEl.hidden) {
dropEl.hidden = true;
pillEl.setAttribute("aria-expanded", "false");
pillEl.focus();
}
});
}
function renderDropdown(list) {
if (!pillEl || !dropEl) return;
pillEl.hidden = list.length === 0; // new visitors see nothing extra at all
if (!list.length) { dropEl.hidden = true; return; }
dropEl.innerHTML = list.map((b) => {
const label = b.label || `${b.lat.toFixed(3)}, ${b.lon.toFixed(3)}`;
return `
<li class="bkm-item">
<button type="button" class="bkm-item-go" data-id="${esc(b.id)}" title="${esc(label)}">${esc(label)}</button>
<button type="button" class="bkm-item-btn bkm-item-rename" data-id="${esc(b.id)}" title="Rename" aria-label="Rename ${esc(label)}">${PENCIL_IC}</button>
<button type="button" class="bkm-item-btn bkm-item-remove" data-id="${esc(b.id)}" title="Remove" aria-label="Remove ${esc(label)}">${CLOSE_X}</button>
</li>`;
}).join("");
dropEl.querySelectorAll(".bkm-item-go").forEach((el) => {
el.addEventListener("click", () => {
const b = list.find((x) => x.id === el.dataset.id);
if (b) { dropEl.hidden = true; jumpTo(b.lat, b.lon); }
});
});
dropEl.querySelectorAll(".bkm-item-rename").forEach((el) => {
el.addEventListener("click", (e) => {
e.stopPropagation();
const b = list.find((x) => x.id === el.dataset.id);
if (!b) return;
const next = window.prompt("Rename this saved location:", b.label || "");
if (next && next.trim()) bookmarks.rename(b.id, next.trim());
});
});
dropEl.querySelectorAll(".bkm-item-remove").forEach((el) => {
el.addEventListener("click", (e) => {
e.stopPropagation();
const b = list.find((x) => x.id === el.dataset.id);
if (b) bookmarks.remove(b.id);
});
});
}
// ---- import prompt banner (non-blocking, shown once until dismissed) ----
let bannerEl = null;
function renderBanner() {
const n = bookmarks.pendingImportCount();
if (!n) { if (bannerEl) bannerEl.hidden = true; return; }
const controls = document.querySelector(".controls");
if (!bannerEl && controls) {
bannerEl = document.createElement("div");
bannerEl.className = "bkm-import-banner";
controls.insertAdjacentElement("afterend", bannerEl);
}
if (!bannerEl) return;
bannerEl.hidden = false;
bannerEl.innerHTML = `
<span>Import ${n} saved location${n === 1 ? "" : "s"} into your account?</span>
<span class="bkm-import-actions">
<button type="button" class="bkm-import-yes">Import</button>
<button type="button" class="bkm-import-no">Not now</button>
</span>`;
bannerEl.querySelector(".bkm-import-yes").onclick = async () => {
const btn = bannerEl.querySelector(".bkm-import-yes");
btn.disabled = true;
try { await bookmarks.importPending(); } catch (e) { btn.disabled = false; }
};
bannerEl.querySelector(".bkm-import-no").onclick = () => bookmarks.dismissImportPrompt();
}
// ---- boot ----
injectStyle();
buildSavedControl();
bookmarks.subscribe((list) => {
renderDropdown(list);
paintStar();
renderBanner();
});
renderDropdown(bookmarks.list());
renderBanner();