Add GPX file import to planner + archive change

Two import entry points:
- Home page: "Import GPX" button next to "Start Planning"
- In-session: drag-and-drop GPX onto the map (with confirmation)

Parses GPX client-side, extracts waypoints (Douglas-Peucker) and
no-go areas from extensions. Non-GPX files show error toast.

Also:
- Fix spec drift: non-GPX drop now shows error toast (was silent)
- Add E2E tests for import button, invalid GPX, and session creation
- Archive gpx-import-planner change, sync specs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-03 17:31:44 +01:00
parent 516b86aa3d
commit a9f8ee61f0
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
16 changed files with 394 additions and 175 deletions

View file

@ -2,8 +2,10 @@ import { useEffect, useState, useCallback, useRef } from "react";
import { MapContainer, TileLayer, LayersControl, Marker, CircleMarker, useMapEvents, useMap } from "react-leaflet";
import L from "leaflet";
import * as Y from "yjs";
import { useTranslation } from "react-i18next";
import type { YjsState } from "~/lib/use-yjs";
import { baseLayers } from "@trails-cool/map";
import { parseGpxAsync, extractWaypoints } from "@trails-cool/gpx";
import { NoGoAreaLayer } from "./NoGoAreaLayer";
import { ColoredRoute, findSegmentForPoint, type ColorMode } from "./ColoredRoute";
import { RouteInteraction } from "./RouteInteraction";
@ -41,6 +43,7 @@ function getWaypointsFromYjs(waypoints: Y.Array<Y.Map<unknown>>): WaypointData[]
interface PlannerMapProps {
yjs: YjsState;
onRouteRequest?: (waypoints: WaypointData[]) => void;
onImportError?: (message: string) => void;
highlightPosition?: [number, number] | null;
}
@ -203,8 +206,11 @@ function NoGoAreaButton({ active, onClick }: { active: boolean; onClick: () => v
);
}
export function PlannerMap({ yjs, onRouteRequest, highlightPosition }: PlannerMapProps) {
export function PlannerMap({ yjs, onRouteRequest, highlightPosition, onImportError }: PlannerMapProps) {
const { t } = useTranslation("planner");
const [waypoints, setWaypoints] = useState<WaypointData[]>([]);
const [draggingOver, setDraggingOver] = useState(false);
const dragCounterRef = useRef(0);
const [routeCoordinates, setRouteCoordinates] = useState<[number, number, number][] | null>(null);
const [segmentBoundaries, setSegmentBoundaries] = useState<number[]>([]);
const [surfaces, setSurfaces] = useState<string[]>([]);
@ -336,7 +342,80 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition }: PlannerMa
[yjs.waypoints],
);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
dragCounterRef.current++;
if (dragCounterRef.current === 1) setDraggingOver(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
dragCounterRef.current--;
if (dragCounterRef.current === 0) setDraggingOver(false);
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
}, []);
const handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
dragCounterRef.current = 0;
setDraggingOver(false);
const file = e.dataTransfer.files[0];
if (!file) return;
if (!file.name.toLowerCase().endsWith(".gpx")) {
onImportError?.(t("importGpxError"));
return;
}
try {
const text = await file.text();
const gpxData = await parseGpxAsync(text);
const newWaypoints = extractWaypoints(gpxData);
if (newWaypoints.length < 2) return;
if (!window.confirm(t("replaceRouteConfirm"))) return;
yjs.doc.transact(() => {
// Replace waypoints
yjs.waypoints.delete(0, yjs.waypoints.length);
for (const wp of newWaypoints) {
const yMap = new Y.Map();
yMap.set("lat", wp.lat);
yMap.set("lon", wp.lon);
yjs.waypoints.push([yMap]);
}
// Replace no-go areas
yjs.noGoAreas.delete(0, yjs.noGoAreas.length);
for (const area of gpxData.noGoAreas) {
const yMap = new Y.Map();
yMap.set("points", area.points);
yjs.noGoAreas.push([yMap]);
}
});
} catch {
onImportError?.(t("importGpxError"));
}
}, [yjs, t, onImportError]);
return (
<div
className="relative h-full w-full"
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{draggingOver && (
<div className="absolute inset-0 z-[2000] flex items-center justify-center bg-blue-500/20 backdrop-blur-sm">
<div className="rounded-xl bg-white px-8 py-6 text-lg font-medium text-blue-600 shadow-lg">
{t("dropGpxHere")}
</div>
</div>
)}
<MapContainer center={[50.1, 10.0]} zoom={6} className="h-full w-full">
<LayersControl position="topright">
{baseLayers.map((layer, i) => (
@ -411,5 +490,6 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition }: PlannerMa
/>
)}
</MapContainer>
</div>
);
}

View file

@ -262,7 +262,7 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl,
</div>
}
>
<PlannerMap yjs={yjs} onRouteRequest={requestRoute} highlightPosition={highlightPosition} />
<PlannerMap yjs={yjs} onRouteRequest={requestRoute} highlightPosition={highlightPosition} onImportError={(msg) => addToast(msg, "error")} />
</Suspense>
</div>
<Suspense fallback={null}>

View file

@ -1,5 +1,8 @@
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router";
import type { Route } from "./+types/home";
import { parseGpxAsync, extractWaypoints } from "@trails-cool/gpx";
export function meta(_args: Route.MetaArgs) {
return [
@ -18,6 +21,41 @@ const features = [
export default function Home() {
const { t } = useTranslation("planner");
const navigate = useNavigate();
const fileInputRef = useRef<HTMLInputElement>(null);
const [importError, setImportError] = useState<string | null>(null);
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Reset input so the same file can be re-selected
e.target.value = "";
try {
const text = await file.text();
const gpxData = await parseGpxAsync(text);
const waypoints = extractWaypoints(gpxData);
const noGoAreas = gpxData.noGoAreas;
// Create session via API
const resp = await fetch("/api/sessions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
if (!resp.ok) return;
const session = await resp.json() as { url: string };
// Build session URL with imported data
const params = new URLSearchParams();
if (waypoints.length > 0) params.set("waypoints", JSON.stringify(waypoints));
if (noGoAreas.length > 0) params.set("noGoAreas", JSON.stringify(noGoAreas));
navigate(`${session.url}?${params}`);
} catch {
setImportError(t("importGpxError"));
setTimeout(() => setImportError(null), 4000);
}
};
return (
<div className="flex h-full flex-col overflow-y-auto">
@ -29,12 +67,30 @@ export default function Home() {
<p className="mt-4 max-w-lg text-center text-lg text-gray-600">
{t("landing.heroDescription")}
</p>
<a
href="/new"
className="mt-8 rounded-lg bg-blue-600 px-8 py-3 text-lg font-medium text-white shadow-sm hover:bg-blue-700"
>
{t("landing.startPlanning")}
</a>
<div className="mt-8 flex gap-4">
<a
href="/new"
className="rounded-lg bg-blue-600 px-8 py-3 text-lg font-medium text-white shadow-sm hover:bg-blue-700"
>
{t("landing.startPlanning")}
</a>
<button
onClick={() => fileInputRef.current?.click()}
className="rounded-lg border border-gray-300 bg-white px-8 py-3 text-lg font-medium text-gray-700 shadow-sm hover:bg-gray-50"
>
{t("importGpx")}
</button>
<input
ref={fileInputRef}
type="file"
accept=".gpx"
className="hidden"
onChange={handleFileSelect}
/>
</div>
{importError && (
<p className="mt-4 text-sm text-red-600">{importError}</p>
)}
</section>
{/* Features */}