Add save/load plans: backend server + frontend UI

This commit is contained in:
Davide Scaini
2026-05-14 09:46:52 +02:00
parent 626d145861
commit 6d095ba3ea
4 changed files with 370 additions and 7 deletions
+1 -1
View File
@@ -2,6 +2,6 @@
set -e
VPS=root@95.216.55.151
VITE_ACTIVITY_URL=https://activity.bincio.org npm run build
VITE_ACTIVITY_URL=https://activity.bincio.org VITE_PLANNER_API_URL= npm run build
rsync -az --delete dist/ "$VPS:/var/www/planner/"
echo "Deployed to planner.bincio.org"
+3
View File
@@ -0,0 +1,3 @@
fastapi>=0.111
uvicorn[standard]>=0.29
pydantic>=2.0
+197
View File
@@ -0,0 +1,197 @@
"""BincioPlanner API — save/load route plans.
Auth: reads bincio_session cookie, validates against shared instance.db (same strategy as bincio_wiki).
Storage: JSON files at $PLANS_DIR/{handle}/{plan_id}.json
Run: uvicorn server:app --host 127.0.0.1 --port 8002
Env vars:
SHARED_DB_PATH path to bincio_activity instance.db (default: /var/bincio/data/instance.db)
PLANS_DIR root dir for plan JSON files (default: /var/bincio_planner)
"""
from __future__ import annotations
import json
import os
import re
import secrets
import sqlite3
import time
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
from fastapi import Cookie, Depends, FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import JSONResponse
from pydantic import BaseModel
_SHARED_DB_PATH = Path(os.environ.get("SHARED_DB_PATH", "/var/bincio/data/instance.db"))
_PLANS_DIR = Path(os.environ.get("PLANS_DIR", "/var/bincio_planner"))
_SESSION_COOKIE = "bincio_session"
_SAFE_ID = re.compile(r"^[A-Za-z0-9_-]{1,32}$")
# ── Auth ───────────────────────────────────────────────────────────────────────
@dataclass
class User:
handle: str
display_name: str
@contextmanager
def _db():
if not _SHARED_DB_PATH.exists():
raise HTTPException(503, f"Shared DB not found at {_SHARED_DB_PATH}. "
"Set SHARED_DB_PATH or run bincio_activity first.")
con = sqlite3.connect(_SHARED_DB_PATH, check_same_thread=False)
con.row_factory = sqlite3.Row
con.execute("PRAGMA journal_mode=WAL")
try:
yield con
finally:
con.close()
def _get_session_user(token: str) -> Optional[User]:
try:
with _db() as con:
row = con.execute(
"SELECT s.handle, s.expires_at, u.display_name, u.activity_access, u.suspended "
"FROM sessions s JOIN users u ON s.handle = u.handle "
"WHERE s.token = ?",
(token,),
).fetchone()
except HTTPException:
raise
except Exception:
return None
if not row:
return None
if row["expires_at"] < int(time.time()):
return None
if not row["activity_access"]:
return None
if row["suspended"]:
return None
return User(handle=row["handle"], display_name=row["display_name"])
async def require_auth(bincio_session: Optional[str] = Cookie(default=None)) -> User:
if not bincio_session:
raise HTTPException(401, "Authentication required")
user = _get_session_user(bincio_session)
if not user:
raise HTTPException(401, "Authentication required")
return user
# ── App ────────────────────────────────────────────────────────────────────────
app = FastAPI(title="BincioPlanner API", docs_url=None, redoc_url=None)
app.add_middleware(GZipMiddleware, minimum_size=1024)
app.add_middleware(
CORSMiddleware,
allow_origin_regex=r"https?://localhost(:\d+)?",
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=["Content-Type"],
)
# ── Storage helpers ────────────────────────────────────────────────────────────
def _user_dir(handle: str) -> Path:
d = _PLANS_DIR / handle
d.mkdir(parents=True, exist_ok=True)
return d
def _plan_path(handle: str, plan_id: str) -> Path:
if not _SAFE_ID.match(plan_id):
raise HTTPException(400, "Invalid plan id")
return _PLANS_DIR / handle / f"{plan_id}.json"
# ── Endpoints ──────────────────────────────────────────────────────────────────
class PlanBody(BaseModel):
name: str
waypoints: list[dict]
profile: str
geojson: dict | None = None
@app.get("/api/plans")
async def list_plans(user: User = Depends(require_auth)) -> JSONResponse:
d = _PLANS_DIR / user.handle
if not d.exists():
return JSONResponse({"plans": []})
plans = []
for p in sorted(d.glob("*.json"), key=lambda f: f.stat().st_mtime, reverse=True):
try:
data = json.loads(p.read_text(encoding="utf-8"))
plans.append({k: v for k, v in data.items() if k != "geojson"})
except Exception:
pass
return JSONResponse({"plans": plans})
@app.post("/api/plans")
async def create_plan(body: PlanBody, user: User = Depends(require_auth)) -> JSONResponse:
plan_id = secrets.token_urlsafe(12)
now = int(time.time())
data: dict = {
"id": plan_id,
"name": body.name.strip() or "Unnamed route",
"waypoints": body.waypoints,
"profile": body.profile,
"created_at": now,
"updated_at": now,
}
if body.geojson is not None:
data["geojson"] = body.geojson
_user_dir(user.handle).joinpath(f"{plan_id}.json").write_text(
json.dumps(data), encoding="utf-8"
)
return JSONResponse({"id": plan_id, "created_at": now})
@app.get("/api/plans/{plan_id}")
async def get_plan(plan_id: str, user: User = Depends(require_auth)) -> JSONResponse:
p = _plan_path(user.handle, plan_id)
if not p.exists():
raise HTTPException(404, "Plan not found")
return JSONResponse(json.loads(p.read_text(encoding="utf-8")))
@app.put("/api/plans/{plan_id}")
async def update_plan(
plan_id: str, body: PlanBody, user: User = Depends(require_auth)
) -> JSONResponse:
p = _plan_path(user.handle, plan_id)
if not p.exists():
raise HTTPException(404, "Plan not found")
data = json.loads(p.read_text(encoding="utf-8"))
data.update({
"name": body.name.strip() or data.get("name", "Unnamed route"),
"waypoints": body.waypoints,
"profile": body.profile,
"updated_at": int(time.time()),
})
if body.geojson is not None:
data["geojson"] = body.geojson
p.write_text(json.dumps(data), encoding="utf-8")
return JSONResponse({"id": plan_id, "updated": True})
@app.delete("/api/plans/{plan_id}")
async def delete_plan(plan_id: str, user: User = Depends(require_auth)) -> JSONResponse:
p = _plan_path(user.handle, plan_id)
if not p.exists():
raise HTTPException(404, "Plan not found")
p.unlink()
return JSONResponse({"id": plan_id, "deleted": True})
+164 -1
View File
@@ -5,6 +5,8 @@
let { activityUrl } = $props();
const API = `${import.meta.env.VITE_PLANNER_API_URL ?? ''}/api`;
// ── State ──────────────────────────────────────────────────────────────────
let mapEl;
let map;
@@ -14,6 +16,14 @@
let loading = $state(false);
let error = $state('');
// Plans
let plans = $state([]);
let plansLoading = $state(false);
let activePlanId = $state(null); // id of currently loaded plan (null = unsaved)
let savePanel = $state(false); // show save form
let saveName = $state('');
let saving = $state(false);
const PROFILES = [
{ id: 'fastbike', label: 'Road' },
{ id: 'gravel', label: 'Gravel' },
@@ -96,12 +106,14 @@
});
map.on('click', onMapClick);
loadPlans();
});
onDestroy(() => { if (map) map.remove(); });
// ── Waypoint management ────────────────────────────────────────────────────
function onMapClick(e) {
activePlanId = null;
addWaypoint(e.lngLat.lng, e.lngLat.lat);
}
@@ -122,6 +134,7 @@
waypoints[i].lng = newLng;
waypoints[i].lat = newLat;
}
activePlanId = null;
fetchRoute();
});
@@ -132,8 +145,8 @@
function removeWaypoint(i) {
waypoints[i].marker.remove();
waypoints = waypoints.filter((_, idx) => idx !== i);
// renumber remaining markers
waypoints.forEach((wp, idx) => { wp.marker.getElement().textContent = idx + 1; });
activePlanId = null;
fetchRoute();
}
@@ -205,6 +218,69 @@ ${trkpts}
a.click();
}
// ── Plans API ─────────────────────────────────────────────────────────────
async function loadPlans() {
plansLoading = true;
try {
const r = await fetch(`${API}/plans`, { credentials: 'include' });
if (r.ok) plans = (await r.json()).plans ?? [];
} catch {}
plansLoading = false;
}
async function savePlan() {
if (!saveName.trim() || waypoints.length < 2) return;
saving = true;
const body = {
name: saveName.trim(),
waypoints: waypoints.map(({ lng, lat }) => ({ lng, lat })),
profile,
geojson: route ?? undefined,
};
try {
if (activePlanId) {
await fetch(`${API}/plans/${activePlanId}`, {
method: 'PUT', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
} else {
const r = await fetch(`${API}/plans`, {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (r.ok) activePlanId = (await r.json()).id;
}
savePanel = false;
await loadPlans();
} catch {}
saving = false;
}
async function loadPlan(plan) {
clearAll();
profile = plan.profile ?? 'gravel';
activePlanId = plan.id;
saveName = plan.name;
// If the full plan (with geojson) is needed, fetch it; list endpoint omits geojson.
const r = await fetch(`${API}/plans/${plan.id}`, { credentials: 'include' });
if (!r.ok) return;
const full = await r.json();
for (const { lng, lat } of (full.waypoints ?? [])) addWaypoint(lng, lat);
if (full.geojson) {
route = full.geojson;
if (map.getSource('route')) map.getSource('route').setData(route);
}
}
async function deletePlan(plan, e) {
e.stopPropagation();
await fetch(`${API}/plans/${plan.id}`, { method: 'DELETE', credentials: 'include' });
if (activePlanId === plan.id) activePlanId = null;
await loadPlans();
}
// ── Helpers ────────────────────────────────────────────────────────────────
function emptyGeoJSON() {
return { type: 'FeatureCollection', features: [] };
@@ -294,6 +370,54 @@ ${trkpts}
{#if loading}<p class="status">Routing…</p>{/if}
{#if error}<p class="status error">{error}</p>{/if}
<!-- Save panel -->
{#if route && waypoints.length >= 2}
<section class="section">
<p class="label">Save route</p>
{#if savePanel}
<div class="save-form">
<input
class="save-input"
type="text"
placeholder="Route name…"
bind:value={saveName}
onkeydown={(e) => e.key === 'Enter' && savePlan()}
/>
<div class="wp-actions">
<button class="btn-primary" onclick={savePlan} disabled={saving || !saveName.trim()}>
{saving ? 'Saving…' : (activePlanId ? 'Update' : 'Save')}
</button>
<button class="btn-secondary" onclick={() => savePanel = false}>Cancel</button>
</div>
</div>
{:else}
<button class="btn-secondary full-width" onclick={() => { savePanel = true; }}>
{activePlanId ? '✎ Update plan' : '+ Save plan'}
</button>
{/if}
</section>
{/if}
<!-- Plans list -->
<section class="section">
<p class="label">My plans {#if plansLoading}<span class="muted">(loading…)</span>{/if}</p>
{#if plans.length === 0 && !plansLoading}
<p class="muted small">No saved plans yet.</p>
{:else}
<ul class="plan-list">
{#each plans as plan}
<li class="plan-item" class:active={activePlanId === plan.id}>
<button class="plan-load-btn" onclick={() => loadPlan(plan)}>
<span class="plan-name">{plan.name}</span>
<span class="plan-meta">{plan.waypoints?.length ?? '?'} pts · {plan.profile}</span>
</button>
<button class="remove-btn" onclick={(e) => deletePlan(plan, e)} title="Delete">×</button>
</li>
{/each}
</ul>
{/if}
</section>
</aside>
<!-- Map + elevation -->
@@ -419,6 +543,45 @@ ${trkpts}
.map-wrap { flex: 1; }
.elevation-wrap { height: 140px; flex-shrink: 0; background: var(--bg-card); border-top: 1px solid var(--border); }
.save-form { display: flex; flex-direction: column; gap: 0.5rem; }
.save-input {
width: 100%;
padding: 0.375rem 0.5rem;
border-radius: 0.375rem;
border: 1px solid var(--border);
background: var(--bg-elevated);
color: var(--text-primary);
font-size: 0.8rem;
box-sizing: border-box;
}
.save-input:focus { outline: none; border-color: var(--accent); }
.full-width { width: 100%; }
.plan-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.25rem; }
.plan-item {
display: flex;
align-items: center;
border-radius: 0.375rem;
border: 1px solid transparent;
position: relative;
}
.plan-item:hover { background: var(--bg-elevated); border-color: var(--border); }
.plan-item.active { background: var(--accent-dim); border-color: var(--accent); }
.plan-load-btn {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.1rem;
padding: 0.4rem 0.5rem;
background: none;
border: none;
text-align: left;
cursor: pointer;
min-width: 0;
}
.plan-name { font-size: 0.8rem; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.plan-meta { font-size: 0.7rem; color: var(--text-5); }
:global(.wp-marker) {
width: 1.5rem;
height: 1.5rem;