Add transactional emails (SMTP) and planner features (no-go areas, notes, crash recovery)
Transactional emails: - Add nodemailer SMTP email module with dev-mode console logging - Magic link template and welcome template with HTML + plain text - Wire sendMagicLink into login flow, sendWelcome into registration - Update privacy page and deploy docs for SMTP configuration Planner features: - No-go areas: draw polygons on map (leaflet-geoman), synced via Yjs, passed to BRouter as nogos parameter, route recomputes on change - Session notes: collaborative Y.Text textarea in sidebar tab - Crash recovery: periodic localStorage save of Yjs state, restore on reconnect - Rate limit session creation (10/IP/hour) in /new route Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
05b5f6febf
commit
0a8dd0b766
27 changed files with 1030 additions and 69 deletions
119
apps/planner/app/components/NoGoAreaLayer.tsx
Normal file
119
apps/planner/app/components/NoGoAreaLayer.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { useEffect, useRef, useCallback } from "react";
|
||||
import { useMap } from "react-leaflet";
|
||||
import L from "leaflet";
|
||||
import * as Y from "yjs";
|
||||
import "@geoman-io/leaflet-geoman-free";
|
||||
import "@geoman-io/leaflet-geoman-free/dist/leaflet-geoman.css";
|
||||
|
||||
interface NoGoAreaLayerProps {
|
||||
noGoAreas: Y.Array<Y.Map<unknown>>;
|
||||
doc: Y.Doc;
|
||||
enabled: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
const NO_GO_STYLE: L.PathOptions = {
|
||||
color: "#ef4444",
|
||||
fillColor: "#ef4444",
|
||||
fillOpacity: 0.2,
|
||||
weight: 2,
|
||||
};
|
||||
|
||||
function yMapToLatLngs(yMap: Y.Map<unknown>): L.LatLng[] {
|
||||
const points = yMap.get("points") as Array<{ lat: number; lon: number }> | undefined;
|
||||
if (!points) return [];
|
||||
return points.map((p) => L.latLng(p.lat, p.lon));
|
||||
}
|
||||
|
||||
export function NoGoAreaLayer({ noGoAreas, doc, enabled, onToggle }: NoGoAreaLayerProps) {
|
||||
const map = useMap();
|
||||
const layerGroupRef = useRef<L.LayerGroup>(L.layerGroup());
|
||||
const polygonMapRef = useRef<Map<number, L.Polygon>>(new Map());
|
||||
const suppressObserverRef = useRef(false);
|
||||
|
||||
// Sync Yjs → Leaflet layers
|
||||
const syncLayers = useCallback(() => {
|
||||
const lg = layerGroupRef.current;
|
||||
// Clear all existing polygons
|
||||
lg.clearLayers();
|
||||
polygonMapRef.current.clear();
|
||||
|
||||
for (let i = 0; i < noGoAreas.length; i++) {
|
||||
const yMap = noGoAreas.get(i);
|
||||
const latLngs = yMapToLatLngs(yMap);
|
||||
if (latLngs.length < 3) continue;
|
||||
|
||||
const polygon = L.polygon(latLngs, NO_GO_STYLE);
|
||||
polygon.on("contextmenu", (e) => {
|
||||
L.DomEvent.preventDefault(e as unknown as Event);
|
||||
suppressObserverRef.current = true;
|
||||
noGoAreas.delete(i, 1);
|
||||
suppressObserverRef.current = false;
|
||||
syncLayers();
|
||||
});
|
||||
lg.addLayer(polygon);
|
||||
polygonMapRef.current.set(i, polygon);
|
||||
}
|
||||
}, [noGoAreas]);
|
||||
|
||||
// Add/remove layer group from map
|
||||
useEffect(() => {
|
||||
const lg = layerGroupRef.current;
|
||||
lg.addTo(map);
|
||||
return () => {
|
||||
lg.removeFrom(map);
|
||||
};
|
||||
}, [map]);
|
||||
|
||||
// Observe Yjs changes
|
||||
useEffect(() => {
|
||||
const observer = () => {
|
||||
if (!suppressObserverRef.current) {
|
||||
syncLayers();
|
||||
}
|
||||
};
|
||||
noGoAreas.observeDeep(observer);
|
||||
syncLayers();
|
||||
return () => noGoAreas.unobserveDeep(observer);
|
||||
}, [noGoAreas, syncLayers]);
|
||||
|
||||
// Geoman drawing controls
|
||||
useEffect(() => {
|
||||
if (enabled) {
|
||||
map.pm.enableDraw("Polygon", {
|
||||
pathOptions: NO_GO_STYLE,
|
||||
snappable: false,
|
||||
});
|
||||
} else {
|
||||
map.pm.disableDraw();
|
||||
}
|
||||
|
||||
const handleCreate = (e: { layer: L.Layer }) => {
|
||||
const layer = e.layer as L.Polygon;
|
||||
const latLngs = (layer.getLatLngs()[0] as L.LatLng[]);
|
||||
const points = latLngs.map((ll) => ({ lat: ll.lat, lon: ll.lng }));
|
||||
|
||||
// Remove the geoman-created layer (we'll render from Yjs)
|
||||
map.removeLayer(layer);
|
||||
|
||||
// Add to Yjs
|
||||
const yMap = new Y.Map();
|
||||
yMap.set("points", points);
|
||||
doc.transact(() => {
|
||||
noGoAreas.push([yMap]);
|
||||
});
|
||||
|
||||
// Exit draw mode
|
||||
onToggle();
|
||||
};
|
||||
|
||||
map.on("pm:create", handleCreate);
|
||||
|
||||
return () => {
|
||||
map.pm.disableDraw();
|
||||
map.off("pm:create", handleCreate);
|
||||
};
|
||||
}, [map, enabled, noGoAreas, doc, onToggle]);
|
||||
|
||||
return null;
|
||||
}
|
||||
66
apps/planner/app/components/NotesPanel.tsx
Normal file
66
apps/planner/app/components/NotesPanel.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { useEffect, useRef, useCallback } from "react";
|
||||
import type { YjsState } from "~/lib/use-yjs";
|
||||
|
||||
interface NotesPanelProps {
|
||||
yjs: YjsState;
|
||||
}
|
||||
|
||||
export function NotesPanel({ yjs }: NotesPanelProps) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const isLocalChange = useRef(false);
|
||||
|
||||
// Sync Y.Text → textarea
|
||||
useEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
|
||||
// Set initial value
|
||||
textarea.value = yjs.notes.toString();
|
||||
|
||||
const observer = () => {
|
||||
if (isLocalChange.current) return;
|
||||
const pos = textarea.selectionStart;
|
||||
textarea.value = yjs.notes.toString();
|
||||
textarea.selectionStart = pos;
|
||||
textarea.selectionEnd = pos;
|
||||
};
|
||||
|
||||
yjs.notes.observe(observer);
|
||||
return () => yjs.notes.unobserve(observer);
|
||||
}, [yjs.notes]);
|
||||
|
||||
// textarea → Y.Text
|
||||
const handleInput = useCallback(
|
||||
(e: React.FormEvent<HTMLTextAreaElement>) => {
|
||||
const textarea = e.currentTarget;
|
||||
const newValue = textarea.value;
|
||||
const currentValue = yjs.notes.toString();
|
||||
|
||||
if (newValue === currentValue) return;
|
||||
|
||||
isLocalChange.current = true;
|
||||
yjs.doc.transact(() => {
|
||||
yjs.notes.delete(0, yjs.notes.length);
|
||||
yjs.notes.insert(0, newValue);
|
||||
});
|
||||
isLocalChange.current = false;
|
||||
},
|
||||
[yjs.notes, yjs.doc],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="border-b border-gray-200 px-4 py-3">
|
||||
<h2 className="text-sm font-medium text-gray-900">Notes</h2>
|
||||
</div>
|
||||
<div className="flex-1 p-2">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
onInput={handleInput}
|
||||
className="h-full w-full resize-none rounded border border-gray-200 p-3 text-sm text-gray-700 placeholder-gray-400 focus:border-blue-300 focus:outline-none"
|
||||
placeholder="Add notes for this session..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import L from "leaflet";
|
|||
import * as Y from "yjs";
|
||||
import type { YjsState } from "~/lib/use-yjs";
|
||||
import { baseLayers } from "@trails-cool/map";
|
||||
import { NoGoAreaLayer } from "./NoGoAreaLayer";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
|
||||
function waypointIcon(index: number): L.DivIcon {
|
||||
|
|
@ -126,9 +127,38 @@ function CursorTracker({ awareness }: { awareness: YjsState["awareness"] }) {
|
|||
);
|
||||
}
|
||||
|
||||
function NoGoAreaButton({ active, onClick }: { active: boolean; onClick: () => void }) {
|
||||
return (
|
||||
<div className="leaflet-top leaflet-left" style={{ marginTop: 80 }}>
|
||||
<div className="leaflet-control leaflet-bar">
|
||||
<a
|
||||
href="#"
|
||||
role="button"
|
||||
title={active ? "Cancel no-go area" : "Draw no-go area"}
|
||||
onClick={(e) => { e.preventDefault(); onClick(); }}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 30,
|
||||
height: 30,
|
||||
fontSize: 16,
|
||||
background: active ? "#fecaca" : "white",
|
||||
color: active ? "#dc2626" : "#333",
|
||||
}}
|
||||
>
|
||||
⊘
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlannerMap({ yjs, onRouteRequest, highlightPosition }: PlannerMapProps) {
|
||||
const [waypoints, setWaypoints] = useState<WaypointData[]>([]);
|
||||
const [routeGeoJson, setRouteGeoJson] = useState<L.LatLngExpression[] | null>(null);
|
||||
const [noGoDrawing, setNoGoDrawing] = useState(false);
|
||||
const toggleNoGoDraw = useCallback(() => setNoGoDrawing((v) => !v), []);
|
||||
|
||||
// Sync waypoints from Yjs
|
||||
useEffect(() => {
|
||||
|
|
@ -215,8 +245,10 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition }: PlannerMa
|
|||
))}
|
||||
</LayersControl>
|
||||
|
||||
<MapClickHandler onAdd={addWaypoint} />
|
||||
<MapClickHandler onAdd={noGoDrawing ? () => {} : addWaypoint} />
|
||||
<CursorTracker awareness={yjs.awareness} />
|
||||
<NoGoAreaLayer noGoAreas={yjs.noGoAreas} doc={yjs.doc} enabled={noGoDrawing} onToggle={toggleNoGoDraw} />
|
||||
<NoGoAreaButton active={noGoDrawing} onClick={toggleNoGoDraw} />
|
||||
|
||||
{waypoints.map((wp, i) => (
|
||||
<Marker
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { ExportButton } from "~/components/ExportButton";
|
|||
import { SaveToJournalButton } from "~/components/SaveToJournalButton";
|
||||
import { YjsDebugPanel } from "~/components/YjsDebugPanel";
|
||||
import { ParticipantList } from "~/components/ParticipantList";
|
||||
import { NotesPanel } from "~/components/NotesPanel";
|
||||
|
||||
const PlannerMap = lazy(() =>
|
||||
import("~/components/PlannerMap").then((m) => ({ default: m.PlannerMap })),
|
||||
|
|
@ -104,6 +105,39 @@ function useAwarenessToasts(yjs: YjsState | null, t: TFunction) {
|
|||
return toasts;
|
||||
}
|
||||
|
||||
function SidebarTabs({ yjs, routeStats }: { yjs: YjsState; routeStats: ReturnType<typeof useRouting>["routeStats"] }) {
|
||||
const { t } = useTranslation("planner");
|
||||
const [tab, setTab] = useState<"waypoints" | "notes">("waypoints");
|
||||
|
||||
return (
|
||||
<aside className="hidden w-72 border-l border-gray-200 bg-white md:flex md:flex-col">
|
||||
<div className="flex border-b border-gray-200">
|
||||
<button
|
||||
onClick={() => setTab("waypoints")}
|
||||
className={`flex-1 px-3 py-2 text-xs font-medium ${tab === "waypoints" ? "border-b-2 border-blue-500 text-blue-600" : "text-gray-500 hover:text-gray-700"}`}
|
||||
>
|
||||
{t("sidebar.waypoints", "Waypoints")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab("notes")}
|
||||
className={`flex-1 px-3 py-2 text-xs font-medium ${tab === "notes" ? "border-b-2 border-blue-500 text-blue-600" : "text-gray-500 hover:text-gray-700"}`}
|
||||
>
|
||||
{t("sidebar.notes", "Notes")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{tab === "waypoints" ? (
|
||||
<Suspense fallback={null}>
|
||||
<WaypointSidebar yjs={yjs} routeStats={routeStats} />
|
||||
</Suspense>
|
||||
) : (
|
||||
<NotesPanel yjs={yjs} />
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
interface SessionViewProps {
|
||||
sessionId: string;
|
||||
callbackUrl?: string;
|
||||
|
|
@ -184,11 +218,7 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl,
|
|||
<ElevationChart yjs={yjs} onHover={handleElevationHover} />
|
||||
</Suspense>
|
||||
</main>
|
||||
<aside className="hidden w-72 border-l border-gray-200 bg-white md:block">
|
||||
<Suspense fallback={null}>
|
||||
<WaypointSidebar yjs={yjs} routeStats={routeStats} />
|
||||
</Suspense>
|
||||
</aside>
|
||||
<SidebarTabs yjs={yjs} routeStats={routeStats} />
|
||||
</div>
|
||||
<YjsDebugPanel yjs={yjs} />
|
||||
{toasts.length > 0 && (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue