bookmarks: map + account UI for saved locations
This commit is contained in:
parent
41858f3e4b
commit
4e1c4a4197
1 changed files with 182 additions and 0 deletions
182
frontend/static/bookmarks.js
Normal file
182
frontend/static/bookmarks.js
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
// Bookmarked locations: the single source of truth for "places you've saved".
|
||||||
|
// Logged out, this is pure localStorage. Logged in, the server is authoritative
|
||||||
|
// and this module mirrors it into localStorage so the list still renders
|
||||||
|
// instantly (and offline) on the next load. Every consumer (the map page's
|
||||||
|
// star toggle + saved-locations dropdown, the alerts page's Saved locations
|
||||||
|
// section) reads through list()/subscribe() and never touches storage or the
|
||||||
|
// API directly.
|
||||||
|
import { apiFetch, onAuthChange, uv } from "./account.js";
|
||||||
|
|
||||||
|
const LS_KEY = "thermograph:bookmarks";
|
||||||
|
const DISMISS_KEY = "thermograph:bookmarks:import-dismissed";
|
||||||
|
const MAX_LOCAL = 200;
|
||||||
|
|
||||||
|
// A stable local key for a spot: rounded to ~4dp (~11m), so re-picking
|
||||||
|
// "the same place" a pixel off the first pin still matches.
|
||||||
|
export function cellId(lat, lon) {
|
||||||
|
return `${lat.toFixed(4)},${lon.toFixed(4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readLocal() {
|
||||||
|
try {
|
||||||
|
const arr = JSON.parse(localStorage.getItem(LS_KEY));
|
||||||
|
return Array.isArray(arr)
|
||||||
|
? arr.filter((b) => b && typeof b.lat === "number" && typeof b.lon === "number")
|
||||||
|
: [];
|
||||||
|
} catch (e) { return []; }
|
||||||
|
}
|
||||||
|
function writeLocal(rows) {
|
||||||
|
try { localStorage.setItem(LS_KEY, JSON.stringify(rows.slice(0, MAX_LOCAL))); } catch (e) { /* private mode, quota, etc. — degrade quietly */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
let cache = readLocal(); // what every consumer renders
|
||||||
|
let pending = []; // local-only bookmarks a signed-in user hasn't imported yet
|
||||||
|
let signedIn = false;
|
||||||
|
const subs = [];
|
||||||
|
|
||||||
|
function notify() { subs.forEach((cb) => { try { cb(cache.slice()); } catch (e) {} }); }
|
||||||
|
|
||||||
|
/** Subscribe to bookmark-list changes. Returns an unsubscribe function. */
|
||||||
|
export function subscribe(cb) {
|
||||||
|
subs.push(cb);
|
||||||
|
return () => { const i = subs.indexOf(cb); if (i > -1) subs.splice(i, 1); };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function list() { return cache.slice(); }
|
||||||
|
export function find(lat, lon) {
|
||||||
|
const id = cellId(lat, lon);
|
||||||
|
return cache.find((b) => cellId(b.lat, b.lon) === id) || null;
|
||||||
|
}
|
||||||
|
export function isBookmarked(lat, lon) { return !!find(lat, lon); }
|
||||||
|
export function pendingImportCount() { return pending.length; }
|
||||||
|
|
||||||
|
export function importDismissed() {
|
||||||
|
try { return localStorage.getItem(DISMISS_KEY) === "1"; } catch (e) { return false; }
|
||||||
|
}
|
||||||
|
export function dismissImportPrompt() {
|
||||||
|
try { localStorage.setItem(DISMISS_KEY, "1"); } catch (e) {}
|
||||||
|
pending = [];
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromServerRow(b) {
|
||||||
|
return {
|
||||||
|
id: b.id != null ? String(b.id) : (b.cell_id || cellId(b.lat, b.lon)),
|
||||||
|
cell_id: b.cell_id || null,
|
||||||
|
lat: b.lat, lon: b.lon,
|
||||||
|
label: b.label || null,
|
||||||
|
created_at: b.created_at || Date.now() / 1000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the server's list, or null on 401 (signed out) / offline / error —
|
||||||
|
// callers treat null as "can't say, fall back to local" rather than "empty".
|
||||||
|
async function fetchServerList() {
|
||||||
|
try {
|
||||||
|
const res = await apiFetch(uv("bookmarks"));
|
||||||
|
if (res.status === 401) { signedIn = false; return null; }
|
||||||
|
if (!res.ok) return null;
|
||||||
|
signedIn = true;
|
||||||
|
const data = await res.json();
|
||||||
|
return Array.isArray(data.bookmarks) ? data.bookmarks : [];
|
||||||
|
} catch (e) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconciles cache with the server, diffing against whatever was in
|
||||||
|
// localStorage right before this call so any local-only spots can be offered
|
||||||
|
// up for import instead of silently vanishing behind the server's list.
|
||||||
|
async function syncFromServer(preSyncLocal) {
|
||||||
|
const rows = await fetchServerList();
|
||||||
|
if (rows == null) return; // signed out or offline: leave cache as-is
|
||||||
|
const serverList = rows.map(fromServerRow);
|
||||||
|
const serverIds = new Set(serverList.map((b) => cellId(b.lat, b.lon)));
|
||||||
|
const localOnly = preSyncLocal.filter((b) => !serverIds.has(cellId(b.lat, b.lon)));
|
||||||
|
cache = serverList;
|
||||||
|
writeLocal(cache);
|
||||||
|
pending = importDismissed() ? [] : localOnly;
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Save (or re-save with a new label) the given spot. Upserts either way. */
|
||||||
|
export async function add(lat, lon, label) {
|
||||||
|
const id = cellId(lat, lon);
|
||||||
|
if (signedIn) {
|
||||||
|
try {
|
||||||
|
const res = await apiFetch(uv("bookmarks"), { method: "POST", json: { lat, lon, label: label || undefined } });
|
||||||
|
if (res.ok) {
|
||||||
|
const rows = await fetchServerList();
|
||||||
|
if (rows != null) { cache = rows.map(fromServerRow); writeLocal(cache); notify(); return find(lat, lon); }
|
||||||
|
}
|
||||||
|
} catch (e) { /* fall through so the star still visibly sticks, even offline */ }
|
||||||
|
}
|
||||||
|
let b = cache.find((x) => cellId(x.lat, x.lon) === id);
|
||||||
|
if (b) { if (label) b.label = label; }
|
||||||
|
else {
|
||||||
|
b = { id, lat, lon, label: label || null, created_at: Date.now() / 1000 };
|
||||||
|
cache = [b, ...cache].slice(0, MAX_LOCAL);
|
||||||
|
}
|
||||||
|
writeLocal(cache);
|
||||||
|
notify();
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function remove(id) {
|
||||||
|
if (signedIn) {
|
||||||
|
try { await apiFetch(uv(`bookmarks/${encodeURIComponent(id)}`), { method: "DELETE" }); } catch (e) {}
|
||||||
|
}
|
||||||
|
cache = cache.filter((b) => b.id !== id);
|
||||||
|
writeLocal(cache);
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rename(id, label) {
|
||||||
|
if (signedIn) {
|
||||||
|
try { await apiFetch(uv(`bookmarks/${encodeURIComponent(id)}`), { method: "PATCH", json: { label } }); }
|
||||||
|
catch (e) { /* still apply locally so the edit shows up */ }
|
||||||
|
}
|
||||||
|
const b = cache.find((x) => x.id === id);
|
||||||
|
if (b) { b.label = label; writeLocal(cache); notify(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runImport(items) {
|
||||||
|
if (!items.length) return { imported: 0, skipped: 0 };
|
||||||
|
const res = await apiFetch(uv("bookmarks/import"), { method: "POST", json: { items } });
|
||||||
|
if (!res.ok) throw new Error(`Import failed (${res.status}).`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (Array.isArray(data.bookmarks)) { cache = data.bookmarks.map(fromServerRow); writeLocal(cache); }
|
||||||
|
pending = [];
|
||||||
|
notify();
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Import the local-only bookmarks the login prompt flagged. */
|
||||||
|
export function importPending() {
|
||||||
|
return runImport(pending.map((b) => ({ lat: b.lat, lon: b.lon, label: b.label })));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Import everything currently in localStorage — the explicit "Import from
|
||||||
|
* this device" button on the alerts page, reachable even after the login
|
||||||
|
* prompt was dismissed. */
|
||||||
|
export function importAllLocal() {
|
||||||
|
return runImport(readLocal().map((b) => ({ lat: b.lat, lon: b.lon, label: b.label })));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- boot + auth transitions -------------------------------------------------
|
||||||
|
(async function boot() {
|
||||||
|
const local = readLocal();
|
||||||
|
cache = local;
|
||||||
|
notify(); // render local state immediately, don't wait on the network
|
||||||
|
await syncFromServer(local);
|
||||||
|
})();
|
||||||
|
|
||||||
|
onAuthChange(async (user) => {
|
||||||
|
if (user) {
|
||||||
|
await syncFromServer(readLocal());
|
||||||
|
} else {
|
||||||
|
// Logged out: fall back to local bookmarks without wiping them.
|
||||||
|
signedIn = false;
|
||||||
|
pending = [];
|
||||||
|
cache = readLocal();
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue