bookmarks: map + account UI for saved locations
All checks were successful
secrets-guard / encrypted (pull_request) Successful in 20s
PR build (required check) / changes (pull_request) Successful in 22s
shell-lint / shellcheck (pull_request) Successful in 19s
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / build-frontend (pull_request) Successful in 2m27s
PR build (required check) / build-backend (pull_request) Successful in 2m51s
PR build (required check) / gate (pull_request) Successful in 3s
All checks were successful
secrets-guard / encrypted (pull_request) Successful in 20s
PR build (required check) / changes (pull_request) Successful in 22s
shell-lint / shellcheck (pull_request) Successful in 19s
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / build-frontend (pull_request) Successful in 2m27s
PR build (required check) / build-backend (pull_request) Successful in 2m51s
PR build (required check) / gate (pull_request) Successful in 3s
This commit is contained in:
parent
d6ead56971
commit
17cc14b48b
1 changed files with 96 additions and 3 deletions
|
|
@ -2,10 +2,16 @@
|
||||||
// them add a city (via the shared map picker), tune the percentile threshold and
|
// them add a city (via the shared map picker), tune the percentile threshold and
|
||||||
// watched metrics, toggle active, and remove. Auth-gated: signed-out visitors get
|
// watched metrics, toggle active, and remove. Auth-gated: signed-out visitors get
|
||||||
// a sign-in prompt. All calls go through account.js's cookie-aware apiFetch.
|
// a sign-in prompt. All calls go through account.js's cookie-aware apiFetch.
|
||||||
|
//
|
||||||
|
// Also the closest thing to an account page, so it doubles as the Saved
|
||||||
|
// locations surface: bookmarks.js is the single source of truth for those (see
|
||||||
|
// its header comment), this just lists/renders/edits them the same way it does
|
||||||
|
// alert subscriptions, reusing .sub-list/.sub-card.
|
||||||
import "./nav.js";
|
import "./nav.js";
|
||||||
import { apiFetch, openAuth, onAuthChange, uv } from "./account.js";
|
import { apiFetch, openAuth, onAuthChange, uv } from "./account.js";
|
||||||
import { open as openPicker } from "./mappicker.js";
|
import { open as openPicker } from "./mappicker.js";
|
||||||
import * as pushClient from "./push-client.js";
|
import * as pushClient from "./push-client.js";
|
||||||
|
import * as bookmarks from "./bookmarks.js";
|
||||||
|
|
||||||
// Metric keys a user can watch, with friendly labels. (fmax/fmin exist server-side
|
// Metric keys a user can watch, with friendly labels. (fmax/fmin exist server-side
|
||||||
// but are omitted from the picker to keep it focused; labelled here if present.)
|
// but are omitted from the picker to keep it focused; labelled here if present.)
|
||||||
|
|
@ -24,6 +30,7 @@ METRIC_LABEL.fmin = "Feels-like low";
|
||||||
const DEFAULT_METRICS = ["tmax", "feels", "precip"];
|
const DEFAULT_METRICS = ["tmax", "feels", "precip"];
|
||||||
|
|
||||||
const TRASH_IC = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m2 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>`;
|
const TRASH_IC = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m2 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>`;
|
||||||
|
const PENCIL_IC = `<svg viewBox="0 0 24 24" 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>`;
|
||||||
|
|
||||||
const body = document.getElementById("alerts-body");
|
const body = document.getElementById("alerts-body");
|
||||||
let subs = [];
|
let subs = [];
|
||||||
|
|
@ -33,6 +40,25 @@ function esc(s) {
|
||||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Small stylesheet for the bits of the Saved locations section that don't
|
||||||
|
// already have a home in .sub-list/.sub-card — injected once, not appended to
|
||||||
|
// the shared style.css, so this stays self-contained.
|
||||||
|
(function injectBkmStyle() {
|
||||||
|
if (document.getElementById("tg-bkm-alerts-style")) return;
|
||||||
|
const s = document.createElement("style");
|
||||||
|
s.id = "tg-bkm-alerts-style";
|
||||||
|
s.textContent = `
|
||||||
|
.bkm-heading { font-size: 15px; margin: 26px 0 4px; }
|
||||||
|
.bkm-import-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; margin: 0 0 12px; }
|
||||||
|
.bkm-import-status { margin: -4px 0 10px; }
|
||||||
|
.bkm-card-actions { display: flex; align-items: center; gap: 2px; flex-shrink: 0; }
|
||||||
|
.bkm-rename { background: none; border: 0; color: var(--muted); cursor: pointer; padding: 7px; border-radius: 6px; display: inline-flex; }
|
||||||
|
.bkm-rename:hover { color: var(--text); background: var(--border); }
|
||||||
|
.bkm-card-loc { color: var(--muted); font-size: 12.5px; }
|
||||||
|
`;
|
||||||
|
document.head.appendChild(s);
|
||||||
|
})();
|
||||||
|
|
||||||
async function readErr(res) {
|
async function readErr(res) {
|
||||||
try {
|
try {
|
||||||
const d = await res.json();
|
const d = await res.json();
|
||||||
|
|
@ -80,17 +106,84 @@ function render() {
|
||||||
<button type="button" class="find-btn" id="add-alert">+ Add a city</button>
|
<button type="button" class="find-btn" id="add-alert">+ Add a city</button>
|
||||||
<p class="alerts-note muted">Each alert sends at most one notification per week.</p>
|
<p class="alerts-note muted">Each alert sends at most one notification per week.</p>
|
||||||
</div>
|
</div>
|
||||||
<ul class="sub-list" id="sub-list"></ul>`;
|
<ul class="sub-list" id="sub-list"></ul>
|
||||||
|
<section class="bkm-section" id="bkm-alerts-section" aria-labelledby="bkm-heading"></section>`;
|
||||||
body.querySelector("#add-alert").onclick = startAdd;
|
body.querySelector("#add-alert").onclick = startAdd;
|
||||||
renderPushBar();
|
renderPushBar();
|
||||||
const list = body.querySelector("#sub-list");
|
const list = body.querySelector("#sub-list");
|
||||||
if (!subs.length) {
|
if (!subs.length) {
|
||||||
list.innerHTML = `<li class="sub-empty muted">No alerts yet. Add a city to get started.</li>`;
|
list.innerHTML = `<li class="sub-empty muted">No alerts yet. Add a city to get started.</li>`;
|
||||||
return;
|
} else {
|
||||||
|
subs.forEach((s) => list.appendChild(subCard(s)));
|
||||||
}
|
}
|
||||||
subs.forEach((s) => list.appendChild(subCard(s)));
|
renderBookmarksSection();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Saved locations (bookmarks) ---------------------------------------------
|
||||||
|
function bkmCard(b) {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
li.className = "sub-card";
|
||||||
|
const label = b.label || `${b.lat.toFixed(3)}, ${b.lon.toFixed(3)}`;
|
||||||
|
li.innerHTML = `
|
||||||
|
<div class="sub-top">
|
||||||
|
<div class="sub-title">
|
||||||
|
<span class="sub-name">${esc(label)}</span>
|
||||||
|
${b.label ? `<span class="bkm-card-loc">${esc(b.lat.toFixed(3))}, ${esc(b.lon.toFixed(3))}</span>` : ""}
|
||||||
|
</div>
|
||||||
|
<span class="bkm-card-actions">
|
||||||
|
<button type="button" class="bkm-rename" title="Rename" aria-label="Rename ${esc(label)}">${PENCIL_IC}</button>
|
||||||
|
<button type="button" class="sub-remove" aria-label="Remove ${esc(label)}">${TRASH_IC}</button>
|
||||||
|
</span>
|
||||||
|
</div>`;
|
||||||
|
li.querySelector(".bkm-rename").onclick = () => {
|
||||||
|
const next = window.prompt("Rename this saved location:", b.label || "");
|
||||||
|
if (next && next.trim()) bookmarks.rename(b.id, next.trim());
|
||||||
|
};
|
||||||
|
li.querySelector(".sub-remove").onclick = () => bookmarks.remove(b.id);
|
||||||
|
return li;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBookmarksSection() {
|
||||||
|
const el = document.getElementById("bkm-alerts-section");
|
||||||
|
if (!el) return;
|
||||||
|
const list = bookmarks.list();
|
||||||
|
el.innerHTML = `
|
||||||
|
<h2 class="bkm-heading" id="bkm-heading">Saved locations</h2>
|
||||||
|
<div class="bkm-import-row">
|
||||||
|
<p class="alerts-note muted">Places you've starred on the map, kept in sync with your account.</p>
|
||||||
|
<button type="button" class="find-btn" id="bkm-import-btn">Import saved locations from this device</button>
|
||||||
|
</div>
|
||||||
|
<p class="bkm-import-status muted" id="bkm-import-status" hidden></p>
|
||||||
|
<ul class="sub-list" id="bkm-list"></ul>`;
|
||||||
|
const ul = el.querySelector("#bkm-list");
|
||||||
|
if (!list.length) {
|
||||||
|
ul.innerHTML = `<li class="sub-empty muted">No saved locations yet. Star a spot on the map to see it here.</li>`;
|
||||||
|
} else {
|
||||||
|
list.forEach((b) => ul.appendChild(bkmCard(b)));
|
||||||
|
}
|
||||||
|
el.querySelector("#bkm-import-btn").onclick = async () => {
|
||||||
|
const btn = el.querySelector("#bkm-import-btn");
|
||||||
|
const status = el.querySelector("#bkm-import-status");
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
const res = await bookmarks.importAllLocal();
|
||||||
|
status.hidden = false;
|
||||||
|
status.textContent = res.imported
|
||||||
|
? `Imported ${res.imported} location${res.imported === 1 ? "" : "s"}${res.skipped ? ` (${res.skipped} already saved)` : ""}.`
|
||||||
|
: "Nothing new to import from this device.";
|
||||||
|
} catch (e) {
|
||||||
|
status.hidden = false;
|
||||||
|
status.textContent = e.message || "Couldn't import right now.";
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bookmarks can change from outside this render cycle (rename/remove above,
|
||||||
|
// or an import banner) — keep the section live rather than only painting once.
|
||||||
|
bookmarks.subscribe(() => renderBookmarksSection());
|
||||||
|
|
||||||
// --- push notifications toggle (this device) ---------------------------------
|
// --- push notifications toggle (this device) ---------------------------------
|
||||||
// Delivery over OS push is per-device: a subscription lives in each browser, so
|
// Delivery over OS push is per-device: a subscription lives in each browser, so
|
||||||
// this control reflects/toggles *this* device, separate from the account-wide
|
// this control reflects/toggles *this* device, separate from the account-wide
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue