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
+169 -6
View File
@@ -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;