Add save/load plans: backend server + frontend UI
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
fastapi>=0.111
|
||||
uvicorn[standard]>=0.29
|
||||
pydantic>=2.0
|
||||
@@ -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})
|
||||
+169
-6
@@ -5,14 +5,24 @@
|
||||
|
||||
let { activityUrl } = $props();
|
||||
|
||||
const API = `${import.meta.env.VITE_PLANNER_API_URL ?? ''}/api`;
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────────────
|
||||
let mapEl;
|
||||
let map;
|
||||
let waypoints = $state([]); // [{lng, lat, marker}]
|
||||
let profile = $state('gravel');
|
||||
let route = $state(null); // GeoJSON Feature from Brouter
|
||||
let loading = $state(false);
|
||||
let error = $state('');
|
||||
let waypoints = $state([]); // [{lng, lat, marker}]
|
||||
let profile = $state('gravel');
|
||||
let route = $state(null); // GeoJSON Feature from Brouter
|
||||
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' },
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user