diff --git a/static/account.js b/static/account.js new file mode 100644 index 0000000..fadc900 --- /dev/null +++ b/static/account.js @@ -0,0 +1,342 @@ +// Accounts: the header "Sign in / account" entry, the auth modal, and the shared +// helpers other modules (subscriptions.js) use to talk to authed endpoints. +// +// Auth is a same-origin HttpOnly cookie, so the frontend stores no token — it just +// sends `credentials: same-origin` and asks `GET users/me` who it is. Loaded on +// every page the way units.js is (imported by each page's entry module). + +let currentUser = null; // {id, email, display_name} or null +const authCbs = []; // notified on login/logout so pages can re-gate + +// --- shared fetch helper ----------------------------------------------------- +// Base-relative URL (resolves under /thermograph automatically), cookie sent, +// and a constant X-TG-Auth header the server can require as cheap CSRF defense +// (trivial same-origin, impossible cross-site without a preflight we never grant). +export async function apiFetch(path, { method = "GET", json, form } = {}) { + const opts = { method, credentials: "same-origin", headers: { "X-TG-Auth": "1" } }; + if (json !== undefined) { + opts.headers["Content-Type"] = "application/json"; + opts.body = JSON.stringify(json); + } else if (form !== undefined) { + opts.headers["Content-Type"] = "application/x-www-form-urlencoded"; + opts.body = new URLSearchParams(form).toString(); + } + return fetch(path, opts); +} + +// Parse a JSON response, throwing a friendly Error on failure. fastapi-users +// returns machine codes ("LOGIN_BAD_CREDENTIALS") or {detail:{reason}} for +// password rejects; map the common ones to human text. +async function readJson(res) { + let data = null; + try { data = await res.json(); } catch (e) { /* empty body (e.g. 204) */ } + if (!res.ok) { + const detail = data && data.detail; + let msg; + if (detail && typeof detail === "object" && detail.reason) msg = detail.reason; + else if (detail === "LOGIN_BAD_CREDENTIALS") msg = "Wrong email or password."; + else if (detail === "REGISTER_USER_ALREADY_EXISTS") msg = "That email is already registered."; + else if (typeof detail === "string") msg = detail.replace(/_/g, " ").toLowerCase(); + else msg = `Request failed (${res.status}).`; + throw new Error(msg); + } + return data; +} + +export function getUser() { return currentUser; } +export function onAuthChange(cb) { authCbs.push(cb); } +function emitAuth() { authCbs.forEach((cb) => { try { cb(currentUser); } catch (e) {} }); } + +async function refreshUser() { + try { + const res = await apiFetch("api/v2/users/me"); + currentUser = res.ok ? await res.json() : null; + } catch (e) { currentUser = null; } + return currentUser; +} + +// --- auth calls -------------------------------------------------------------- +async function login(email, password) { + // fastapi-users login is an OAuth2 form: username=email, password. + const res = await apiFetch("api/v2/auth/login", { method: "POST", form: { username: email, password } }); + await readJson(res); // throws on bad creds; 204 body is empty +} +async function register(email, password) { + const res = await apiFetch("api/v2/auth/register", { method: "POST", json: { email, password } }); + await readJson(res); +} +export async function logout() { + try { await apiFetch("api/v2/auth/logout", { method: "POST" }); } catch (e) {} + currentUser = null; + renderHeader(); + emitAuth(); +} + +// --- auth modal -------------------------------------------------------------- +let modal = null, mode = "login"; + +function buildModal() { + modal = document.createElement("div"); + modal.className = "mp-overlay acct-overlay"; + modal.hidden = true; + modal.innerHTML = ` + `; + document.body.appendChild(modal); + + modal.querySelector(".mp-close").onclick = closeAuth; + modal.addEventListener("pointerdown", (e) => { if (e.target === modal) closeAuth(); }); + document.addEventListener("keydown", (e) => { if (!modal.hidden && e.key === "Escape") closeAuth(); }); + + const form = modal.querySelector(".acct-form"); + form.addEventListener("submit", onSubmit); + modal.querySelector(".acct-switch").addEventListener("click", (e) => { + const b = e.target.closest("button"); + if (b) setMode(mode === "login" ? "register" : "login"); + }); + setMode("login"); +} + +function setMode(m) { + mode = m; + const isLogin = m === "login"; + modal.querySelector(".acct-title").textContent = isLogin ? "Sign in" : "Create account"; + modal.querySelector(".acct-submit").textContent = isLogin ? "Sign in" : "Create account"; + modal.querySelector('input[name="password"]').setAttribute( + "autocomplete", isLogin ? "current-password" : "new-password"); + modal.querySelector(".acct-switch").innerHTML = isLogin + ? 'Need an account? ' + : 'Already have an account? '; + showError(""); +} + +function showError(msg) { + const el = modal.querySelector(".acct-error"); + el.textContent = msg || ""; + el.hidden = !msg; +} + +async function onSubmit(e) { + e.preventDefault(); + const email = modal.querySelector('input[name="email"]').value.trim(); + const password = modal.querySelector('input[name="password"]').value; + const btn = modal.querySelector(".acct-submit"); + btn.disabled = true; + showError(""); + try { + if (mode === "register") { await register(email, password); } + await login(email, password); // register flow signs in right after + await refreshUser(); + closeAuth(); + renderHeader(); + emitAuth(); + } catch (err) { + showError(err.message || "Something went wrong."); + } finally { + btn.disabled = false; + } +} + +export function openAuth(startMode = "login") { + if (!modal) buildModal(); + setMode(startMode); + modal.querySelector('input[name="email"]').value = ""; + modal.querySelector('input[name="password"]').value = ""; + modal.hidden = false; + modal.querySelector('input[name="email"]').focus(); +} +function closeAuth() { if (modal) modal.hidden = true; } + +// --- notifications (header bell) --------------------------------------------- +const BELL_IC = ``; + +let notifications = []; +let unreadCount = 0; +let notifTimer = null; + +function timeAgo(sec) { + const d = Date.now() / 1000 - sec; + if (d < 60) return "just now"; + if (d < 3600) return `${Math.floor(d / 60)}m ago`; + if (d < 86400) return `${Math.floor(d / 3600)}h ago`; + return `${Math.floor(d / 86400)}d ago`; +} + +async function loadNotifications() { + try { + const res = await apiFetch("api/v2/notifications?limit=50"); + if (!res.ok) return; + const data = await res.json(); + notifications = data.notifications || []; + unreadCount = data.unread_count || 0; + paintBadge(); + paintNotifList(); + } catch (e) { /* offline — leave prior state */ } +} + +function paintBadge() { + if (!acctEl) return; + const badge = acctEl.querySelector(".notif-badge"); + if (!badge) return; + badge.textContent = unreadCount > 99 ? "99+" : String(unreadCount); + badge.hidden = unreadCount === 0; +} + +function paintNotifList() { + if (!acctEl) return; + const list = acctEl.querySelector(".notif-list"); + if (!list) return; + if (!notifications.length) { + list.innerHTML = `
  • No alerts yet. Weather notifications will show up here.
  • `; + return; + } + list.innerHTML = notifications.map((n) => ` +
  • +
    ${escapeHtml(n.title)}
    +
    ${escapeHtml(n.body || "")}
    +
    ${timeAgo(n.created_at)}
    +
  • `).join(""); + list.querySelectorAll(".notif-item.is-unread").forEach((li) => { + li.addEventListener("click", () => markRead(Number(li.dataset.id), li)); + }); +} + +async function markRead(id, li) { + try { + const res = await apiFetch(`api/v2/notifications/${id}/read`, { method: "POST" }); + if (!res.ok) return; + const n = notifications.find((x) => x.id === id); + if (n && !n.read_at) { n.read_at = Date.now() / 1000; unreadCount = Math.max(0, unreadCount - 1); } + li.classList.remove("is-unread"); + paintBadge(); + } catch (e) {} +} + +async function markAllRead() { + try { + const res = await apiFetch("api/v2/notifications/read-all", { method: "POST" }); + if (!res.ok) return; + notifications.forEach((n) => { n.read_at = n.read_at || Date.now() / 1000; }); + unreadCount = 0; + paintBadge(); + paintNotifList(); + } catch (e) {} +} + +function startNotifPolling() { + loadNotifications(); + if (notifTimer) return; + notifTimer = setInterval(loadNotifications, 120000); // refresh unread every 2 min +} +function stopNotifPolling() { + if (notifTimer) { clearInterval(notifTimer); notifTimer = null; } + notifications = []; unreadCount = 0; +} + +// --- header entry ------------------------------------------------------------ +const USER_IC = ``; + +let acctEl = null; + +function ensureAcctEl() { + const brand = document.querySelector(".brand"); + if (!brand) return null; + if (!acctEl) { + acctEl = document.createElement("div"); + acctEl.className = "acct"; + const nav = brand.querySelector(".view-nav"); + brand.insertBefore(acctEl, nav); // top-right, just left of the view tabs + } + return acctEl; +} + +function renderHeader() { + const el = ensureAcctEl(); + if (!el) return; + if (currentUser) { + const name = currentUser.display_name || currentUser.email; + el.innerHTML = ` +
    + + +
    +
    + + +
    `; + const btn = el.querySelector(".acct-btn:not(.notif-btn)"); + const pop = el.querySelector(".acct-pop"); + btn.addEventListener("click", () => { + const open = pop.hidden; + pop.hidden = !open; + btn.setAttribute("aria-expanded", open ? "true" : "false"); + }); + // Notification bell dropdown. + const nBtn = el.querySelector(".notif-btn"); + const nPop = el.querySelector(".notif-pop"); + nBtn.addEventListener("click", () => { + const open = nPop.hidden; + nPop.hidden = !open; + nBtn.setAttribute("aria-expanded", open ? "true" : "false"); + if (open) loadNotifications(); // refresh on open + }); + el.querySelector(".notif-readall").addEventListener("click", markAllRead); + document.addEventListener("click", (e) => { + if (!el.contains(e.target)) { + pop.hidden = true; btn.setAttribute("aria-expanded", "false"); + nPop.hidden = true; nBtn.setAttribute("aria-expanded", "false"); + } + }); + el.querySelector(".acct-signout").addEventListener("click", logout); + paintBadge(); + paintNotifList(); + startNotifPolling(); + } else { + stopNotifPolling(); + el.innerHTML = ``; + el.querySelector(".acct-signin").addEventListener("click", () => openAuth("login")); + } +} + +function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, (c) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); +} + +// --- boot -------------------------------------------------------------------- +(async function initAccount() { + ensureAcctEl(); + renderHeader(); // paint the signed-out state immediately + await refreshUser(); // then confirm session from the cookie + renderHeader(); + emitAuth(); +})(); diff --git a/static/app.js b/static/app.js index d91de51..b45b532 100644 --- a/static/app.js +++ b/static/app.js @@ -1,6 +1,7 @@ // Weekly (map) page. Imports replace the old script-tag ordering contract. import { loadLastLocation, saveLastLocation, locHash } from "./nav.js"; import { fmtTemp, onUnitChange } from "./units.js"; +import "./account.js"; // header sign-in entry + notification bell import { getJSON, TTL, prefetchViews } from "./cache.js"; import { initFindButton, setFindLabel } from "./mappicker.js"; import { W, PW, H, PL, PR, plotTop, plotBot, xLabY, setChartWidth, chartPalette, diff --git a/static/calendar.js b/static/calendar.js index e104be0..614ca1f 100644 --- a/static/calendar.js +++ b/static/calendar.js @@ -3,6 +3,7 @@ import { loadLastLocation, saveLastLocation, locHash } from "./nav.js"; // The tooltip reads fmtTemp live, so a unit switch shows on the next hover. import { fmtTemp, onUnitChange } from "./units.js"; +import "./account.js"; // header sign-in entry + notification bell import { TTL, chunkedFetch, prefetchViews } from "./cache.js"; import { initFindButton, setFindLabel } from "./mappicker.js"; import { TIER_COLORS, PRECIP_COLORS, SCALE_TEMP, SCALE_RAIN, drynessColor, ord, diff --git a/static/compare.js b/static/compare.js index 8f65cc5..99dacac 100644 --- a/static/compare.js +++ b/static/compare.js @@ -25,6 +25,7 @@ import { MONTHS, pad, monthStart, monthEnd, buildChunks, allMonths, monthsToMask, maskToMonths, namePrimary, nameParts } from "./shared.js"; import { fmtTemp, fmtDelta, onUnitChange } from "./units.js"; +import "./account.js"; // header sign-in entry + notification bell import { initFilterSheet } from "./filtersheet.js"; const CMP = { diff --git a/static/day.js b/static/day.js index 41f0931..b1d3fc5 100644 --- a/static/day.js +++ b/static/day.js @@ -3,6 +3,7 @@ // and where the observed values land. Talks to /api/v2/day + /api/v2/geocode. import { loadLastLocation, saveLastLocation, locHash } from "./nav.js"; import { fmtTemp, onUnitChange } from "./units.js"; +import "./account.js"; // header sign-in entry + notification bell import { getJSON, TTL, prefetchViews } from "./cache.js"; import { initFindButton, setFindLabel } from "./mappicker.js"; import { TIER_COLORS, PRECIP_COLORS, DRY_COLOR, ord, fmtPrecip, fmtWind, fmtHumid, diff --git a/static/legend.html b/static/legend.html index 6ccb109..5ffd7b2 100644 --- a/static/legend.html +++ b/static/legend.html @@ -87,6 +87,7 @@ + + + diff --git a/static/subscriptions.js b/static/subscriptions.js new file mode 100644 index 0000000..c235147 --- /dev/null +++ b/static/subscriptions.js @@ -0,0 +1,294 @@ +// Weather-alerts management page (/alerts). Lists a user's subscriptions and lets +// 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. +import "./nav.js"; +import { apiFetch, openAuth, onAuthChange } from "./account.js"; +import { open as openPicker } from "./mappicker.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.) +const METRICS = [ + ["tmax", "High temp"], + ["tmin", "Low temp"], + ["feels", "Feels-like"], + ["humid", "Humidity"], + ["wind", "Wind"], + ["gust", "Gust"], + ["precip", "Precipitation"], +]; +const METRIC_LABEL = Object.fromEntries(METRICS); +METRIC_LABEL.fmax = "Feels-like high"; +METRIC_LABEL.fmin = "Feels-like low"; +const DEFAULT_METRICS = ["tmax", "feels", "precip"]; + +const TRASH_IC = ``; + +const body = document.getElementById("alerts-body"); +let subs = []; + +function esc(s) { + return String(s == null ? "" : s).replace(/[&<>"']/g, (c) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); +} + +async function readErr(res) { + try { + const d = await res.json(); + if (typeof d.detail === "string") return d.detail; + } catch (e) {} + return `Request failed (${res.status}).`; +} + +// --- load + top-level render ------------------------------------------------- +async function load() { + let res; + try { res = await apiFetch("api/v2/subscriptions"); } + catch (e) { body.innerHTML = `

    Offline — can't load alerts.

    `; return; } + if (res.status === 401) { renderGate(); return; } + if (!res.ok) { body.innerHTML = `

    ${esc(await readErr(res))}

    `; return; } + subs = await res.json(); + render(); +} + +function renderGate() { + body.innerHTML = ` +
    +

    Sign in to manage alerts

    +

    Create an account to subscribe to cities and get notified when their + weather is unusually extreme — above the percentile you choose.

    + +
    `; + body.querySelector("#gate-signin").onclick = () => openAuth("login"); +} + +function render() { + body.innerHTML = ` +
    + +

    Each alert sends at most one notification per week.

    +
    + `; + body.querySelector("#add-alert").onclick = startAdd; + const list = body.querySelector("#sub-list"); + if (!subs.length) { + list.innerHTML = `
  • No alerts yet. Add a city to get started.
  • `; + return; + } + subs.forEach((s) => list.appendChild(subCard(s))); +} + +// --- a single subscription card ---------------------------------------------- +function subCard(s) { + const li = document.createElement("li"); + li.className = "sub-card" + (s.active ? "" : " is-paused"); + li.dataset.id = s.id; + const kindLabel = s.kind === "forecast" ? "Forecast" : "Observed"; + const metrics = s.metrics.map((m) => `${esc(METRIC_LABEL[m] || m)}`).join(""); + li.innerHTML = ` +
    +
    + ${esc(s.label || `${s.lat.toFixed(2)}, ${s.lon.toFixed(2)}`)} + ${kindLabel} +
    + +
    +
    ${metrics}
    +
    +
    + Alert above the + + percentile +
    + +
    `; + + const seg = li.querySelector(".thr-seg"); + [95, 96, 97, 98, 99].forEach((p) => { + const b = document.createElement("button"); + b.type = "button"; + b.textContent = p; + b.className = p === s.threshold ? "active" : ""; + b.onclick = () => patch(s.id, { threshold: p }, li); + seg.appendChild(b); + }); + + li.querySelector(".sub-active").onchange = (e) => patch(s.id, { active: e.target.checked }, li); + li.querySelector(".sub-remove").onclick = () => removeSub(s.id, li); + return li; +} + +async function patch(id, data, li) { + li.classList.add("is-busy"); + try { + const res = await apiFetch(`api/v2/subscriptions/${id}`, { method: "PATCH", json: data }); + if (!res.ok) throw new Error(await readErr(res)); + const updated = await res.json(); + subs = subs.map((s) => (s.id === id ? updated : s)); + li.replaceWith(subCard(updated)); + } catch (e) { + li.classList.remove("is-busy"); + alert(e.message || "Couldn't update the alert."); + } +} + +async function removeSub(id, li) { + li.classList.add("is-busy"); + try { + const res = await apiFetch(`api/v2/subscriptions/${id}`, { method: "DELETE" }); + if (!res.ok && res.status !== 404) throw new Error(await readErr(res)); + subs = subs.filter((s) => s.id !== id); + li.remove(); + if (!subs.length) render(); + } catch (e) { + li.classList.remove("is-busy"); + alert(e.message || "Couldn't remove the alert."); + } +} + +// --- add flow: pick a location, then configure ------------------------------- +function startAdd() { + openPicker(null, async (lat, lon) => { + let label = null; + try { + const r = await fetch(`api/v2/place?lat=${lat}&lon=${lon}`); + const d = await r.json(); + label = d.place || null; + } catch (e) { /* label optional; server falls back to cached/coords */ } + openEditor({ lat, lon, label }); + }); +} + +let editor = null; + +function buildEditor() { + editor = document.createElement("div"); + editor.className = "mp-overlay acct-overlay sub-editor-overlay"; + editor.hidden = true; + editor.innerHTML = ` + `; + document.body.appendChild(editor); + + editor.querySelector(".mp-close").onclick = closeEditor; + editor.addEventListener("pointerdown", (e) => { if (e.target === editor) closeEditor(); }); + document.addEventListener("keydown", (e) => { if (!editor.hidden && e.key === "Escape") closeEditor(); }); + + // metric checkboxes + const checks = editor.querySelector(".metric-checks"); + METRICS.forEach(([key, label]) => { + const id = `mc-${key}`; + const wrap = document.createElement("label"); + wrap.className = "metric-check"; + wrap.innerHTML = ` ${esc(label)}`; + checks.appendChild(wrap); + }); + + // kind toggle + editor.querySelector(".kind-seg").addEventListener("click", (e) => { + const b = e.target.closest("button[data-kind]"); + if (!b) return; + editor.querySelectorAll(".kind-seg button").forEach((x) => x.classList.toggle("active", x === b)); + updateKindHelp(); + }); + + // threshold range + const range = editor.querySelector(".thr-range"); + range.addEventListener("input", () => { editor.querySelector(".thr-val").textContent = range.value; }); + + editor.querySelector(".sub-form").addEventListener("submit", onCreate); +} + +function updateKindHelp() { + const kind = editor.querySelector(".kind-seg button.active").dataset.kind; + editor.querySelector(".sub-kind-help").textContent = kind === "forecast" + ? "Heads-up in advance when the upcoming forecast is projected to pass your threshold." + : "Notify me once a recorded day has actually passed the threshold."; +} + +let editorLoc = null; + +function openEditor(loc) { + if (!editor) buildEditor(); + editorLoc = loc; + editor.querySelector(".sub-form-loc").textContent = loc.label || `${loc.lat.toFixed(3)}, ${loc.lon.toFixed(3)}`; + editor.querySelector(".kind-seg button[data-kind='observed']").click(); + editor.querySelectorAll(".metric-checks input").forEach((c) => { c.checked = DEFAULT_METRICS.includes(c.value); }); + const range = editor.querySelector(".thr-range"); + range.value = 97; editor.querySelector(".thr-val").textContent = "97"; + editor.querySelector(".two-sided").checked = true; + showFormError(""); + editor.hidden = false; +} +function closeEditor() { if (editor) editor.hidden = true; } + +function showFormError(msg) { + const el = editor.querySelector(".sub-form-error"); + el.textContent = msg || ""; + el.hidden = !msg; +} + +async function onCreate(e) { + e.preventDefault(); + const metrics = [...editor.querySelectorAll(".metric-checks input:checked")].map((c) => c.value); + if (!metrics.length) { showFormError("Pick at least one metric to watch."); return; } + const payload = { + lat: editorLoc.lat, + lon: editorLoc.lon, + label: editorLoc.label || undefined, + threshold: Number(editor.querySelector(".thr-range").value), + metrics, + kind: editor.querySelector(".kind-seg button.active").dataset.kind, + two_sided: editor.querySelector(".two-sided").checked, + }; + const btn = editor.querySelector(".sub-form .acct-submit"); + btn.disabled = true; + showFormError(""); + try { + const res = await apiFetch("api/v2/subscriptions", { method: "POST", json: payload }); + if (!res.ok) throw new Error(await readErr(res)); + const created = await res.json(); + subs.push(created); + render(); + closeEditor(); + } catch (err) { + showFormError(err.message || "Couldn't create the alert."); + } finally { + btn.disabled = false; + } +} + +// --- boot: react to auth resolving (account.js emits after checking the cookie) - +onAuthChange(() => load()); +load();