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:
parent
516b86aa3d
commit
a9f8ee61f0
16 changed files with 394 additions and 175 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue