trails/apps/planner/app/components/ExportButton.tsx
Ullrich Schäfer 3b9672e0ff
planner: give the Yjs document a typed schema seam
The routeData Y.Map's ~15 string keys (geojson, coordinates,
segmentBoundaries, road metadata, profile, colorMode, baseLayer,
overlays, poiCategories) were read and written raw at ~30 call sites,
each with its own JSON parsing and casts; parseJsonArray existed twice
and waypoint extraction four times. GPX assembly was duplicated between
SaveToJournalButton and ExportButton, so the saved plan and the
exported file could silently diverge.

- new lib/route-data.ts owns the routeData (+ noGoAreas) schema:
  typed read/write, JSON encoding internal, ColorMode moves here
  (re-exported from ColoredRoute for existing importers)
- new lib/gpx-export.ts owns GPX assembly: buildRouteGpx /
  buildPlanGpx / buildDayGpxFiles / hasDayBreaks; multi-day splitting
  becomes a pure, tested function
- waypoint-ymap.ts gains extractWaypoints / extractWaypointData; the
  four hand-rolled copies (use-routing, use-waypoint-manager,
  WaypointSidebar, use-days) now share it, and WaypointSidebar's
  moveWaypoint reuses the round-trip helpers instead of re-listing
  every waypoint field
- all hooks/components consume the seam; no raw routeData key strings
  remain outside route-data.ts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 01:35:04 +02:00

100 lines
3.3 KiB
TypeScript

import { useCallback, useState, useRef, useEffect } from "react";
import { useTranslation } from "react-i18next";
import type { YjsState } from "~/lib/use-yjs";
import {
buildDayGpxFiles,
buildPlanGpx,
buildRouteGpx,
hasDayBreaks,
} from "~/lib/gpx-export";
function download(gpx: string, filename: string) {
const blob = new Blob([gpx], { type: "application/gpx+xml" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
export function ExportButton({ yjs }: { yjs: YjsState }) {
const { t } = useTranslation("planner");
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
// Close dropdown on outside mousedown
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [open]);
const handleExportRoute = useCallback(() => {
download(buildRouteGpx(yjs), "route.gpx");
setOpen(false);
}, [yjs]);
const handleExportPlan = useCallback(() => {
download(buildPlanGpx(yjs), "route-plan.gpx");
setOpen(false);
}, [yjs]);
const handleExportDays = useCallback(() => {
for (const file of buildDayGpxFiles(yjs)) {
download(file.gpx, file.filename);
}
setOpen(false);
}, [yjs]);
const hasMultipleDays = hasDayBreaks(yjs);
return (
<div ref={ref} className="relative z-[1001]">
<div className="flex">
<button
onClick={handleExportRoute}
className="rounded-l bg-gray-100 px-3 py-1 text-sm text-gray-700 hover:bg-gray-200"
>
{t("exportGpx")}
</button>
<button
onClick={(e) => { e.stopPropagation(); setOpen((v) => !v); }}
className="rounded-r border-l border-gray-300 bg-gray-100 px-2.5 py-1 text-sm text-gray-700 hover:bg-gray-200"
>
</button>
</div>
{open && (
<div className="absolute left-0 top-full z-50 mt-1 w-56 rounded border border-gray-200 bg-white py-1 shadow-lg">
<button
onClick={handleExportRoute}
className="block w-full px-3 py-1.5 text-left hover:bg-gray-100"
>
<span className="text-sm text-gray-700">{t("exportRoute")}</span>
<span className="block text-xs text-gray-400">{t("exportRouteDesc")}</span>
</button>
<button
onClick={handleExportPlan}
className="block w-full px-3 py-1.5 text-left hover:bg-gray-100"
>
<span className="text-sm text-gray-700">{t("exportPlan")}</span>
<span className="block text-xs text-gray-400">{t("exportPlanDesc")}</span>
</button>
{hasMultipleDays && (
<button
onClick={handleExportDays}
className="block w-full px-3 py-1.5 text-left hover:bg-gray-100"
>
<span className="text-sm text-gray-700">{t("exportDays")}</span>
<span className="block text-xs text-gray-400">{t("exportDaysDesc")}</span>
</button>
)}
</div>
)}
</div>
);
}