import { Suspense, lazy, useState, useCallback, useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; import L from "leaflet"; import type { TFunction } from "i18next"; import * as Sentry from "@sentry/react"; import { useYjs, type YjsState } from "~/lib/use-yjs"; import { useRouting, type RouteError } from "~/lib/use-routing"; import { getCoordinates } from "~/lib/route-data"; import { useDays } from "~/lib/use-days"; import { useUndo, useUndoShortcuts } from "~/lib/use-undo"; import { ProfileSelector } from "~/components/ProfileSelector"; import { ExportButton } from "~/components/ExportButton"; import { SaveToJournalButton } from "~/components/SaveToJournalButton"; import { YjsDebugPanel } from "~/components/YjsDebugPanel"; import { Topbar } from "~/components/Topbar"; import { useParticipants } from "~/lib/use-participants"; import { NotesPanel } from "~/components/NotesPanel"; const PlannerMap = lazy(() => import("~/components/PlannerMap").then((m) => ({ default: m.PlannerMap })), ); const WaypointSidebar = lazy(() => import("~/components/WaypointSidebar").then((m) => ({ default: m.WaypointSidebar })), ); const ElevationChart = lazy(() => import("~/components/ElevationChart").then((m) => ({ default: m.ElevationChart })), ); interface Toast { id: number; message: string; variant?: "error" | "info"; } let toastIdCounter = 0; function useToasts() { const [toasts, setToasts] = useState([]); const addToast = useCallback((message: string, variant: Toast["variant"] = "info") => { const id = ++toastIdCounter; setToasts((prev) => [...prev, { id, message, variant }]); setTimeout(() => { setToasts((prev) => prev.filter((toast) => toast.id !== id)); }, 4000); }, []); return { toasts, addToast }; } function useAwarenessToasts(yjs: YjsState | null, t: TFunction, addToast: (message: string) => void) { const namesCacheRef = useRef>(new Map()); const initializedRef = useRef(false); useEffect(() => { if (!yjs) return; const handleChange = ( changes: { added: number[]; updated: number[]; removed: number[] }, ) => { const states = yjs.awareness.getStates(); const localId = yjs.awareness.clientID; // On first change, seed the cache with current participants and skip toasts if (!initializedRef.current) { states.forEach((state, clientId) => { const user = (state as Record).user as { name: string } | undefined; if (user?.name) { namesCacheRef.current.set(clientId, user.name); } }); initializedRef.current = true; return; } // Update cache for any updated clients for (const clientId of changes.updated) { const state = states.get(clientId) as Record | undefined; const user = state?.user as { name: string } | undefined; if (user?.name) { namesCacheRef.current.set(clientId, user.name); } } for (const clientId of changes.added) { if (clientId === localId) continue; const state = states.get(clientId) as Record | undefined; const user = state?.user as { name: string } | undefined; const name = user?.name; if (name) { namesCacheRef.current.set(clientId, name); addToast(t("participants.joined", { name })); } } for (const clientId of changes.removed) { if (clientId === localId) continue; const name = namesCacheRef.current.get(clientId); namesCacheRef.current.delete(clientId); if (name) { addToast(t("participants.left", { name })); } } }; yjs.awareness.on("change", handleChange); return () => { yjs.awareness.off("change", handleChange); initializedRef.current = false; namesCacheRef.current.clear(); }; }, [yjs, t, addToast]); } function SidebarTabs({ yjs, routeStats, days, onWaypointHover, onWaypointSelect }: { yjs: YjsState; routeStats: ReturnType["routeStats"]; days: ReturnType; onWaypointHover: (index: number | null) => void; onWaypointSelect: (index: number | null) => void }) { const { t } = useTranslation("planner"); const [tab, setTab] = useState<"waypoints" | "notes">("waypoints"); return ( ); } interface SessionViewProps { sessionId: string; /** * True when the session was created with a journal callback URL + * token (i.e. the user came in from /journal/.../edit-in-planner). * The actual URL + token live server-side; the browser only needs * to know whether to render the Save-to-Journal button. */ hasJournalCallback?: boolean; returnUrl?: string; initialWaypoints?: Array<{ lat: number; lon: number; name?: string; isDayBreak?: boolean }>; initialNoGoAreas?: Array<{ points: Array<{ lat: number; lon: number }> }>; initialNotes?: string; } export function SessionView({ sessionId, hasJournalCallback, returnUrl, initialWaypoints, initialNoGoAreas, initialNotes }: SessionViewProps) { const { t } = useTranslation("planner"); useEffect(() => { Sentry.setTag("session_id", sessionId); }, [sessionId]); const yjs = useYjs(sessionId, initialWaypoints, initialNoGoAreas, initialNotes); const { computing, routeError, routeStats, requestRoute } = useRouting(yjs, sessionId); const { canUndo, canRedo, undo, redo } = useUndo(yjs?.undoManager ?? null); useUndoShortcuts(yjs?.undoManager ?? null); const { participants, renameLocal } = useParticipants(yjs); const days = useDays(yjs); const [highlightPosition, setHighlightPosition] = useState<[number, number] | null>(null); const [highlightedWaypoint, setHighlightedWaypoint] = useState(null); const [selectedWaypointIndex, setSelectedWaypointIndex] = useState(null); const [highlightChartDistance, setHighlightChartDistance] = useState(null); const [isZoomedByChart, setIsZoomedByChart] = useState(false); const { toasts, addToast } = useToasts(); useAwarenessToasts(yjs, t, addToast); // Show toast on route calculation error const prevErrorRef = useRef(null); useEffect(() => { if (routeError && routeError !== prevErrorRef.current) { const message = routeError === "rate_limit" ? t("rateLimitExceeded") : routeError === "no_route" ? t("noRouteFound") : t("routeError"); addToast(message, "error"); } prevErrorRef.current = routeError; }, [routeError, addToast, t]); const handleElevationHover = useCallback((pos: [number, number] | null) => { setHighlightPosition(pos); }, []); const handleChartClick = useCallback((position: [number, number]) => { const map = window.__leafletMap; map?.panTo(position); }, []); const handleChartDragSelect = useCallback((bounds: [[number, number], [number, number]]) => { const map = window.__leafletMap; map?.fitBounds(bounds, { padding: [30, 30] }); setIsZoomedByChart(true); }, []); const handleResetZoom = useCallback(() => { const map = window.__leafletMap; if (!map || !yjs) return; const coords = getCoordinates(yjs.routeData); if (!coords || coords.length < 2) return; const latLngs = coords.map((c) => [c[1], c[0]] as [number, number]); map.fitBounds(L.latLngBounds(latLngs), { padding: [50, 50] }); setIsZoomedByChart(false); }, [yjs]); if (!yjs) { return (

{t("connecting")}

); } return ( <> } actions={ <> {hasJournalCallback && ( )} } />
{computing && (
)} {t("loadingMap")}
} > addToast(msg, "error")} days={days} />
{isZoomedByChart && ( )}
{toasts.length > 0 && (
{toasts.map((toast) => (
{toast.message}
))}
)} ); }