Merge pull request 'promote: dev → main (location bookmarks)' (#119) from dev into main
All checks were successful
secrets-guard / encrypted (push) Successful in 13s
shell-lint / shellcheck (push) Successful in 15s
Build + push images (Forgejo registry) / build-push (backend) (push) Successful in 1m7s
Build + push images (Forgejo registry) / build-push (frontend) (push) Successful in 1m5s
Deploy / deploy (backend) (push) Successful in 1m16s
Deploy / deploy (frontend) (push) Successful in 1m29s
All checks were successful
secrets-guard / encrypted (push) Successful in 13s
shell-lint / shellcheck (push) Successful in 15s
Build + push images (Forgejo registry) / build-push (backend) (push) Successful in 1m7s
Build + push images (Forgejo registry) / build-push (frontend) (push) Successful in 1m5s
Deploy / deploy (backend) (push) Successful in 1m16s
Deploy / deploy (frontend) (push) Successful in 1m29s
This commit is contained in:
commit
4f21e47750
9 changed files with 1016 additions and 6 deletions
|
|
@ -1,4 +1,4 @@
|
|||
"""Authenticated API for subscriptions and notifications.
|
||||
"""Authenticated API for subscriptions, notifications, and bookmarks.
|
||||
|
||||
All routes require a logged-in user (the fastapi-users cookie session) and are
|
||||
scoped to that user — a row that isn't theirs reads as 404, never 403, so the API
|
||||
|
|
@ -7,7 +7,7 @@ doesn't leak which ids exist. Mounted under {BASE}/api/v2 alongside the rest of
|
|||
import os
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
|
@ -17,8 +17,15 @@ from data import climate
|
|||
from data import grid
|
||||
from notifications import push
|
||||
from accounts.db import get_async_session, get_read_session
|
||||
from accounts.models import Notification, PushSubscription, Subscription
|
||||
from accounts.models import Bookmark, Notification, PushSubscription, Subscription
|
||||
from accounts.schemas import (
|
||||
BOOKMARK_MAX_PER_USER,
|
||||
BookmarkImportIn,
|
||||
BookmarkImportOut,
|
||||
BookmarkIn,
|
||||
BookmarkList,
|
||||
BookmarkOut,
|
||||
BookmarkPatch,
|
||||
NotificationList,
|
||||
NotificationOut,
|
||||
PushSubscriptionIn,
|
||||
|
|
@ -323,3 +330,171 @@ async def push_test(
|
|||
# `failed` (delivery rejected — e.g. VAPID key mismatch) is surfaced so the client
|
||||
# can tell "request accepted" apart from "actually delivered".
|
||||
return {"devices": len(rows), "sent": sent, "pruned": pruned, "failed": failed}
|
||||
|
||||
|
||||
# --- bookmarks -----------------------------------------------------------------
|
||||
async def _owned_bookmark(session: AsyncSession, bookmark_id: int, user) -> Bookmark:
|
||||
bookmark = (
|
||||
await session.execute(
|
||||
select(Bookmark).where(
|
||||
Bookmark.id == bookmark_id, Bookmark.user_id == user.id
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if bookmark is None:
|
||||
raise HTTPException(status_code=404, detail="Bookmark not found.")
|
||||
return bookmark
|
||||
|
||||
|
||||
@router.get("/bookmarks", response_model=BookmarkList)
|
||||
async def list_bookmarks(
|
||||
user=Depends(current_active_user),
|
||||
session: AsyncSession = Depends(get_read_session), # pure read → read-only engine
|
||||
):
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(Bookmark)
|
||||
.where(Bookmark.user_id == user.id)
|
||||
.order_by(Bookmark.created_at)
|
||||
)
|
||||
).scalars().all()
|
||||
return BookmarkList(bookmarks=[BookmarkOut.model_validate(r) for r in rows])
|
||||
|
||||
|
||||
@router.post("/bookmarks", response_model=BookmarkOut, status_code=201)
|
||||
async def create_bookmark(
|
||||
body: BookmarkIn,
|
||||
response: Response,
|
||||
user=Depends(current_active_user),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Create a bookmark, or — if one already exists for this (user, cell) —
|
||||
rename it in place and return 200. Idempotent upsert rather than a 409, so
|
||||
re-bookmarking a place you already saved is never an error."""
|
||||
cell = grid.snap(body.lat, body.lon)
|
||||
cell_id = cell["id"]
|
||||
|
||||
existing = (
|
||||
await session.execute(
|
||||
select(Bookmark).where(
|
||||
Bookmark.user_id == user.id, Bookmark.cell_id == cell_id
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
existing.label = body.label
|
||||
await session.commit()
|
||||
await session.refresh(existing)
|
||||
audit.log_activity("bookmark.update", {"user_id": str(user.id),
|
||||
"bookmark_id": existing.id})
|
||||
response.status_code = 200
|
||||
return existing
|
||||
|
||||
count = (
|
||||
await session.execute(
|
||||
select(func.count()).select_from(Bookmark).where(Bookmark.user_id == user.id)
|
||||
)
|
||||
).scalar_one()
|
||||
if count >= BOOKMARK_MAX_PER_USER:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"You can have at most {BOOKMARK_MAX_PER_USER} bookmarks.",
|
||||
)
|
||||
|
||||
bookmark = Bookmark(
|
||||
user_id=user.id,
|
||||
cell_id=cell_id,
|
||||
label=body.label,
|
||||
lat=body.lat,
|
||||
lon=body.lon,
|
||||
)
|
||||
session.add(bookmark)
|
||||
await session.commit()
|
||||
await session.refresh(bookmark)
|
||||
audit.log_activity("bookmark.create", {"user_id": str(user.id), "bookmark_id": bookmark.id,
|
||||
"cell_id": cell_id})
|
||||
return bookmark
|
||||
|
||||
|
||||
@router.patch("/bookmarks/{bookmark_id}", response_model=BookmarkOut)
|
||||
async def update_bookmark(
|
||||
bookmark_id: int,
|
||||
body: BookmarkPatch,
|
||||
user=Depends(current_active_user),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
bookmark = await _owned_bookmark(session, bookmark_id, user)
|
||||
bookmark.label = body.label
|
||||
await session.commit()
|
||||
await session.refresh(bookmark)
|
||||
audit.log_activity("bookmark.rename", {"user_id": str(user.id), "bookmark_id": bookmark.id})
|
||||
return bookmark
|
||||
|
||||
|
||||
@router.delete("/bookmarks/{bookmark_id}", status_code=204)
|
||||
async def delete_bookmark(
|
||||
bookmark_id: int,
|
||||
user=Depends(current_active_user),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
bookmark = await _owned_bookmark(session, bookmark_id, user)
|
||||
await session.delete(bookmark)
|
||||
await session.commit()
|
||||
audit.log_activity("bookmark.delete", {"user_id": str(user.id), "bookmark_id": bookmark_id})
|
||||
|
||||
|
||||
@router.post("/bookmarks/import", response_model=BookmarkImportOut)
|
||||
async def import_bookmarks(
|
||||
body: BookmarkImportIn,
|
||||
user=Depends(current_active_user),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""Bulk-import a visitor's localStorage bookmarks at login. Existing rows keep
|
||||
their label (never overwritten); duplicates within the payload itself are also
|
||||
skipped. Stops importing once the user's total hits BOOKMARK_MAX_PER_USER —
|
||||
any remaining items count as skipped rather than erroring."""
|
||||
existing_rows = (
|
||||
await session.execute(select(Bookmark).where(Bookmark.user_id == user.id))
|
||||
).scalars().all()
|
||||
existing_cells = {r.cell_id for r in existing_rows}
|
||||
count = len(existing_rows)
|
||||
|
||||
imported = 0
|
||||
skipped = 0
|
||||
seen_cells: set[str] = set()
|
||||
for item in body.items:
|
||||
cell_id = grid.snap(item.lat, item.lon)["id"]
|
||||
if cell_id in existing_cells or cell_id in seen_cells:
|
||||
skipped += 1
|
||||
continue
|
||||
if count >= BOOKMARK_MAX_PER_USER:
|
||||
skipped += 1
|
||||
continue
|
||||
session.add(Bookmark(
|
||||
user_id=user.id,
|
||||
cell_id=cell_id,
|
||||
label=item.label,
|
||||
lat=item.lat,
|
||||
lon=item.lon,
|
||||
))
|
||||
seen_cells.add(cell_id)
|
||||
count += 1
|
||||
imported += 1
|
||||
|
||||
if imported:
|
||||
await session.commit()
|
||||
audit.log_activity("bookmark.import", {"user_id": str(user.id),
|
||||
"imported": imported, "skipped": skipped})
|
||||
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(Bookmark)
|
||||
.where(Bookmark.user_id == user.id)
|
||||
.order_by(Bookmark.created_at)
|
||||
)
|
||||
).scalars().all()
|
||||
return BookmarkImportOut(
|
||||
imported=imported,
|
||||
skipped=skipped,
|
||||
bookmarks=[BookmarkOut.model_validate(r) for r in rows],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -195,3 +195,31 @@ class PendingDigest(Base):
|
|||
# duplicating, which is what makes the form idempotent.
|
||||
UniqueConstraint("email", name="uq_pending_digest_email"),
|
||||
)
|
||||
|
||||
|
||||
class Bookmark(Base):
|
||||
"""A saved location, owned by a user — the "bookmarked locations" feature.
|
||||
|
||||
Keyed like ``Subscription`` on ``grid.snap(lat, lon)["id"]``: one bookmark per
|
||||
(user, cell), so re-bookmarking the same cell is an upsert of the label rather
|
||||
than a duplicate row (see ``create_bookmark`` / ``import_bookmarks`` in
|
||||
api_accounts.py, which enforce this at the application layer too).
|
||||
"""
|
||||
__tablename__ = "bookmark"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
GUID, ForeignKey("user.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
# grid.snap(lat, lon)["id"] — the stable per-location key used across the app.
|
||||
cell_id: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
label: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||
lat: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
lon: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
created_at: Mapped[float] = mapped_column(Float, nullable=False, default=time.time)
|
||||
|
||||
__table_args__ = (
|
||||
# One bookmark per location per user — re-bookmarking upserts the label.
|
||||
UniqueConstraint("user_id", "cell_id", name="uq_bookmark_user_cell"),
|
||||
Index("idx_bookmark_user", "user_id"),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,11 @@ from data import grading
|
|||
ALLOWED_METRICS = tuple(grading.CLIMO_METRICS)
|
||||
DEFAULT_METRICS = ["tmax", "feels", "precip"]
|
||||
|
||||
# Bookmark limits, shared by the single-create and bulk-import paths.
|
||||
BOOKMARK_LABEL_MAX_LEN = 80
|
||||
BOOKMARK_MAX_PER_USER = 200
|
||||
BOOKMARK_IMPORT_MAX_ITEMS = 200
|
||||
|
||||
|
||||
# --- users (fastapi-users) ---------------------------------------------------
|
||||
class UserRead(schemas.BaseUser[uuid.UUID]):
|
||||
|
|
@ -135,3 +140,69 @@ class PushSubscriptionIn(BaseModel):
|
|||
|
||||
class PushUnsubscribeIn(BaseModel):
|
||||
endpoint: str
|
||||
|
||||
|
||||
# --- bookmarks ----------------------------------------------------------------
|
||||
def _check_label(v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("label is required")
|
||||
if len(v) > BOOKMARK_LABEL_MAX_LEN:
|
||||
raise ValueError(f"label must be at most {BOOKMARK_LABEL_MAX_LEN} characters")
|
||||
return v
|
||||
|
||||
|
||||
class BookmarkIn(BaseModel):
|
||||
lat: float = Field(ge=-90, le=90)
|
||||
lon: float = Field(ge=-180, le=180)
|
||||
label: str
|
||||
|
||||
@field_validator("label")
|
||||
@classmethod
|
||||
def _label(cls, v):
|
||||
return _check_label(v)
|
||||
|
||||
|
||||
class BookmarkPatch(BaseModel):
|
||||
label: str
|
||||
|
||||
@field_validator("label")
|
||||
@classmethod
|
||||
def _label(cls, v):
|
||||
return _check_label(v)
|
||||
|
||||
|
||||
class BookmarkOut(BaseModel):
|
||||
id: int
|
||||
cell_id: str
|
||||
label: str
|
||||
lat: float
|
||||
lon: float
|
||||
created_at: float
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class BookmarkList(BaseModel):
|
||||
bookmarks: list[BookmarkOut]
|
||||
|
||||
|
||||
class BookmarkImportItem(BaseModel):
|
||||
lat: float = Field(ge=-90, le=90)
|
||||
lon: float = Field(ge=-180, le=180)
|
||||
label: str
|
||||
|
||||
@field_validator("label")
|
||||
@classmethod
|
||||
def _label(cls, v):
|
||||
return _check_label(v)
|
||||
|
||||
|
||||
class BookmarkImportIn(BaseModel):
|
||||
items: list[BookmarkImportItem] = Field(max_length=BOOKMARK_IMPORT_MAX_ITEMS)
|
||||
|
||||
|
||||
class BookmarkImportOut(BaseModel):
|
||||
imported: int
|
||||
skipped: int
|
||||
bookmarks: list[BookmarkOut]
|
||||
|
|
|
|||
45
backend/alembic/versions/0004_bookmarks.py
Normal file
45
backend/alembic/versions/0004_bookmarks.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
"""bookmark: saved locations
|
||||
|
||||
Adds the ``bookmark`` table backing the "bookmarked locations" feature — one row
|
||||
per (user, grid cell), following the same shape as ``subscription``.
|
||||
|
||||
Conditional on purpose, like 0003_user_discord_only: revision 0001 builds the
|
||||
schema from the *current* models.py via ``Base.metadata.create_all``, so a
|
||||
database created after ``Bookmark`` was added to the ORM (fresh envs, most tests)
|
||||
already has the table, and an unconditional ``create_table`` would fail there
|
||||
with a duplicate-table error. Only a database stamped before this revision is
|
||||
missing it.
|
||||
|
||||
Revision ID: 0004_bookmarks
|
||||
Revises: 0003_user_discord_only
|
||||
Create Date: 2026-07-26
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0004_bookmarks"
|
||||
down_revision = "0003_user_discord_only"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _has_table() -> bool:
|
||||
return sa.inspect(op.get_bind()).has_table("bookmark")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if _has_table():
|
||||
return
|
||||
# Built from the live ORM table (accounts.models.Bookmark) rather than
|
||||
# hand-duplicated column definitions, so this can never drift from models.py.
|
||||
from accounts.models import Bookmark
|
||||
|
||||
Bookmark.__table__.create(bind=op.get_bind())
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if not _has_table():
|
||||
return
|
||||
from accounts.models import Bookmark
|
||||
|
||||
Bookmark.__table__.drop(bind=op.get_bind())
|
||||
160
backend/tests/accounts/test_bookmarks.py
Normal file
160
backend/tests/accounts/test_bookmarks.py
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
"""Route-level tests for the bookmarks feature: CRUD, upsert-on-duplicate,
|
||||
ownership, auth, bulk import (dedupe/counts), and the per-user cap. Uses a
|
||||
throwaway accounts DB (conftest points THERMOGRAPH_ACCOUNTS_DB at a temp file),
|
||||
same as test_api_accounts.py."""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from web import app as appmod
|
||||
from accounts import db
|
||||
|
||||
V2 = "/thermograph/api/v2"
|
||||
PW = "supersecret123"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def _tables():
|
||||
# TestClient(app) (no context manager) skips the lifespan, so create the
|
||||
# account tables directly against the temp DB.
|
||||
db.Base.metadata.create_all(db.sync_engine)
|
||||
|
||||
|
||||
def _client():
|
||||
return TestClient(appmod.app)
|
||||
|
||||
|
||||
def _login(client, email):
|
||||
r = client.post(f"{V2}/auth/register", json={"email": email, "password": PW})
|
||||
assert r.status_code in (201, 400) # 400 only if the email already exists
|
||||
r = client.post(f"{V2}/auth/login", data={"username": email, "password": PW})
|
||||
assert r.status_code == 204 # sets the session cookie on the client
|
||||
|
||||
|
||||
def test_bookmarks_require_auth():
|
||||
c = _client()
|
||||
assert c.get(f"{V2}/bookmarks").status_code == 401
|
||||
assert c.post(f"{V2}/bookmarks", json={"lat": 1, "lon": 1, "label": "x"}).status_code == 401
|
||||
assert c.post(f"{V2}/bookmarks/import", json={"items": []}).status_code == 401
|
||||
|
||||
|
||||
def test_bookmark_crud():
|
||||
c = _client()
|
||||
_login(c, "bm-crud@example.com")
|
||||
|
||||
r = c.post(f"{V2}/bookmarks", json={"lat": 47.6, "lon": -122.3, "label": "Seattle"})
|
||||
assert r.status_code == 201
|
||||
bm = r.json()
|
||||
assert bm["cell_id"] and bm["label"] == "Seattle"
|
||||
assert bm["lat"] == 47.6 and bm["lon"] == -122.3
|
||||
|
||||
listed = c.get(f"{V2}/bookmarks").json()["bookmarks"]
|
||||
assert len(listed) == 1 and listed[0]["id"] == bm["id"]
|
||||
|
||||
patched = c.patch(f"{V2}/bookmarks/{bm['id']}", json={"label": "Home"})
|
||||
assert patched.status_code == 200
|
||||
assert patched.json()["label"] == "Home"
|
||||
|
||||
assert c.delete(f"{V2}/bookmarks/{bm['id']}").status_code == 204
|
||||
assert c.get(f"{V2}/bookmarks").json()["bookmarks"] == []
|
||||
|
||||
|
||||
def test_bookmark_upsert_on_duplicate():
|
||||
c = _client()
|
||||
_login(c, "bm-upsert@example.com")
|
||||
body = {"lat": 33.4, "lon": -112.0, "label": "Phoenix"}
|
||||
|
||||
first = c.post(f"{V2}/bookmarks", json=body)
|
||||
assert first.status_code == 201
|
||||
bm_id = first.json()["id"]
|
||||
|
||||
# Re-bookmarking the same location (same cell) upserts the label — 200, not 409.
|
||||
second = c.post(f"{V2}/bookmarks", json={**body, "label": "Phoenix Home"})
|
||||
assert second.status_code == 200
|
||||
assert second.json()["id"] == bm_id
|
||||
assert second.json()["label"] == "Phoenix Home"
|
||||
|
||||
assert len(c.get(f"{V2}/bookmarks").json()["bookmarks"]) == 1
|
||||
|
||||
|
||||
def test_bookmark_validation():
|
||||
c = _client()
|
||||
_login(c, "bm-valid@example.com")
|
||||
assert c.post(f"{V2}/bookmarks", json={"lat": 1, "lon": 1, "label": " "}).status_code == 422
|
||||
assert c.post(f"{V2}/bookmarks", json={"lat": 1, "lon": 1, "label": "x" * 81}).status_code == 422
|
||||
assert c.post(f"{V2}/bookmarks", json={"lat": 91, "lon": 1, "label": "x"}).status_code == 422
|
||||
assert c.post(f"{V2}/bookmarks", json={"lat": 1, "lon": 181, "label": "x"}).status_code == 422
|
||||
# a label that's only over-length due to surrounding whitespace is fine once trimmed
|
||||
ok = c.post(f"{V2}/bookmarks", json={"lat": 2, "lon": 2, "label": " Trimmed "})
|
||||
assert ok.status_code == 201 and ok.json()["label"] == "Trimmed"
|
||||
|
||||
|
||||
def test_ownership_is_404():
|
||||
a, b = _client(), _client()
|
||||
_login(a, "bm-owner-a@example.com")
|
||||
_login(b, "bm-owner-b@example.com")
|
||||
bm_id = a.post(f"{V2}/bookmarks", json={"lat": 40.7, "lon": -74.0, "label": "NYC"}).json()["id"]
|
||||
|
||||
# B cannot see, patch, or delete A's bookmark — 404, not 403 (no existence leak)
|
||||
assert b.patch(f"{V2}/bookmarks/{bm_id}", json={"label": "Nope"}).status_code == 404
|
||||
assert b.delete(f"{V2}/bookmarks/{bm_id}").status_code == 404
|
||||
assert b.get(f"{V2}/bookmarks").json()["bookmarks"] == []
|
||||
|
||||
|
||||
def test_bookmark_import_dedupe_and_counts():
|
||||
c = _client()
|
||||
_login(c, "bm-import@example.com")
|
||||
# Pre-existing bookmark at the Seattle cell, with a label import must NOT overwrite.
|
||||
existing = c.post(
|
||||
f"{V2}/bookmarks", json={"lat": 47.6, "lon": -122.3, "label": "Original"}
|
||||
).json()
|
||||
assert existing["label"] == "Original"
|
||||
|
||||
r = c.post(f"{V2}/bookmarks/import", json={"items": [
|
||||
{"lat": 47.6, "lon": -122.3, "label": "Should Not Overwrite"}, # dup of existing cell
|
||||
{"lat": 34.0, "lon": -118.2, "label": "LA"}, # new
|
||||
{"lat": 34.0, "lon": -118.2, "label": "LA Dup"}, # dup within payload
|
||||
{"lat": 51.5, "lon": -0.1, "label": "London"}, # new
|
||||
]})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["imported"] == 2
|
||||
assert body["skipped"] == 2
|
||||
assert len(body["bookmarks"]) == 3
|
||||
|
||||
by_label = {b["label"] for b in body["bookmarks"]}
|
||||
assert by_label == {"Original", "LA", "London"} # existing label untouched
|
||||
|
||||
|
||||
def test_bookmark_cap():
|
||||
c = _client()
|
||||
_login(c, "bm-cap@example.com")
|
||||
for i in range(200):
|
||||
r = c.post(f"{V2}/bookmarks", json={"lat": i * 0.1, "lon": 0.0, "label": f"spot-{i}"})
|
||||
assert r.status_code == 201, r.text
|
||||
|
||||
over = c.post(f"{V2}/bookmarks", json={"lat": 89.0, "lon": 179.0, "label": "one too many"})
|
||||
assert over.status_code == 409
|
||||
|
||||
assert len(c.get(f"{V2}/bookmarks").json()["bookmarks"]) == 200
|
||||
|
||||
|
||||
def test_bookmark_import_stops_at_cap():
|
||||
c = _client()
|
||||
_login(c, "bm-import-cap@example.com")
|
||||
for i in range(198):
|
||||
r = c.post(f"{V2}/bookmarks", json={"lat": i * 0.1, "lon": 10.0, "label": f"spot-{i}"})
|
||||
assert r.status_code == 201, r.text
|
||||
|
||||
r = c.post(f"{V2}/bookmarks/import", json={"items": [
|
||||
{"lat": 60.0, "lon": 60.0, "label": "a"},
|
||||
{"lat": 61.0, "lon": 61.0, "label": "b"},
|
||||
{"lat": 62.0, "lon": 62.0, "label": "c"},
|
||||
{"lat": 63.0, "lon": 63.0, "label": "d"},
|
||||
{"lat": 64.0, "lon": 64.0, "label": "e"},
|
||||
]})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
# Only 2 slots remained before the 200 cap; the rest count as skipped.
|
||||
assert body["imported"] == 2
|
||||
assert body["skipped"] == 3
|
||||
assert len(body["bookmarks"]) == 200
|
||||
252
frontend/static/bookmarks-ui.js
Normal file
252
frontend/static/bookmarks-ui.js
Normal 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) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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();
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
|
@ -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) {
|
|||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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
|
||||
|
|
|
|||
|
|
@ -198,6 +198,10 @@
|
|||
{% block body_scripts %}
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script type="module" src="{{ asset_base }}/app.js"></script>
|
||||
{# Bookmarked locations: a star toggle in the results share row + a "Saved"
|
||||
dropdown by the Find button. Loaded as its own module alongside app.js
|
||||
(not merged into it) — see bookmarks-ui.js's header comment for why. #}
|
||||
<script type="module" src="{{ asset_base }}/bookmarks-ui.js"></script>
|
||||
{# The records strip renders real temperatures server-side as .temp spans, so
|
||||
it needs the same converter the SEO pages use or they'd stay in °F while
|
||||
the toggle says °C. Both import units.js, which the module loader dedupes. #}
|
||||
|
|
|
|||
Loading…
Reference in a new issue