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 17cc14b48b - Show all commits

View file

@ -2,10 +2,16 @@
// 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
// 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 { apiFetch, openAuth, onAuthChange, uv } from "./account.js";
import { open as openPicker } from "./mappicker.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
// 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 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");
let subs = [];
@ -33,6 +40,25 @@ function esc(s) {
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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) {
try {
const d = await res.json();
@ -80,17 +106,84 @@ function render() {
<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>
</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;
renderPushBar();
const list = body.querySelector("#sub-list");
if (!subs.length) {
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) ---------------------------------
// 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