Add save/load plans: backend server + frontend UI
This commit is contained in:
+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