Add save/load plans: backend server + frontend UI
This commit is contained in:
@@ -2,6 +2,6 @@
|
|||||||
set -e
|
set -e
|
||||||
VPS=root@95.216.55.151
|
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/"
|
rsync -az --delete dist/ "$VPS:/var/www/planner/"
|
||||||
echo "Deployed to planner.bincio.org"
|
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();
|
let { activityUrl } = $props();
|
||||||
|
|
||||||
|
const API = `${import.meta.env.VITE_PLANNER_API_URL ?? ''}/api`;
|
||||||
|
|
||||||
// ── State ──────────────────────────────────────────────────────────────────
|
// ── State ──────────────────────────────────────────────────────────────────
|
||||||
let mapEl;
|
let mapEl;
|
||||||
let map;
|
let map;
|
||||||
let waypoints = $state([]); // [{lng, lat, marker}]
|
let waypoints = $state([]); // [{lng, lat, marker}]
|
||||||
let profile = $state('gravel');
|
let profile = $state('gravel');
|
||||||
let route = $state(null); // GeoJSON Feature from Brouter
|
let route = $state(null); // GeoJSON Feature from Brouter
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let error = $state('');
|
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 = [
|
const PROFILES = [
|
||||||
{ id: 'fastbike', label: 'Road' },
|
{ id: 'fastbike', label: 'Road' },
|
||||||
@@ -96,12 +106,14 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
map.on('click', onMapClick);
|
map.on('click', onMapClick);
|
||||||
|
loadPlans();
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => { if (map) map.remove(); });
|
onDestroy(() => { if (map) map.remove(); });
|
||||||
|
|
||||||
// ── Waypoint management ────────────────────────────────────────────────────
|
// ── Waypoint management ────────────────────────────────────────────────────
|
||||||
function onMapClick(e) {
|
function onMapClick(e) {
|
||||||
|
activePlanId = null;
|
||||||
addWaypoint(e.lngLat.lng, e.lngLat.lat);
|
addWaypoint(e.lngLat.lng, e.lngLat.lat);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +134,7 @@
|
|||||||
waypoints[i].lng = newLng;
|
waypoints[i].lng = newLng;
|
||||||
waypoints[i].lat = newLat;
|
waypoints[i].lat = newLat;
|
||||||
}
|
}
|
||||||
|
activePlanId = null;
|
||||||
fetchRoute();
|
fetchRoute();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -132,8 +145,8 @@
|
|||||||
function removeWaypoint(i) {
|
function removeWaypoint(i) {
|
||||||
waypoints[i].marker.remove();
|
waypoints[i].marker.remove();
|
||||||
waypoints = waypoints.filter((_, idx) => idx !== i);
|
waypoints = waypoints.filter((_, idx) => idx !== i);
|
||||||
// renumber remaining markers
|
|
||||||
waypoints.forEach((wp, idx) => { wp.marker.getElement().textContent = idx + 1; });
|
waypoints.forEach((wp, idx) => { wp.marker.getElement().textContent = idx + 1; });
|
||||||
|
activePlanId = null;
|
||||||
fetchRoute();
|
fetchRoute();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,6 +218,69 @@ ${trkpts}
|
|||||||
a.click();
|
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 ────────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
function emptyGeoJSON() {
|
function emptyGeoJSON() {
|
||||||
return { type: 'FeatureCollection', features: [] };
|
return { type: 'FeatureCollection', features: [] };
|
||||||
@@ -294,6 +370,54 @@ ${trkpts}
|
|||||||
|
|
||||||
{#if loading}<p class="status">Routing…</p>{/if}
|
{#if loading}<p class="status">Routing…</p>{/if}
|
||||||
{#if error}<p class="status error">{error}</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>
|
</aside>
|
||||||
|
|
||||||
<!-- Map + elevation -->
|
<!-- Map + elevation -->
|
||||||
@@ -419,6 +543,45 @@ ${trkpts}
|
|||||||
.map-wrap { flex: 1; }
|
.map-wrap { flex: 1; }
|
||||||
.elevation-wrap { height: 140px; flex-shrink: 0; background: var(--bg-card); border-top: 1px solid var(--border); }
|
.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) {
|
:global(.wp-marker) {
|
||||||
width: 1.5rem;
|
width: 1.5rem;
|
||||||
height: 1.5rem;
|
height: 1.5rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user