"use client"; import { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { deleteMapFeature, saveMapNode, saveMapRoad, saveMapZone, } from "@/app/(frontend)/logistics/map/actions"; import type { MapPoint } from "@/lib/map/points"; import type { NodeFeature, RoadFeature, ZoneFeature } from "@/lib/map/types"; export type AuthoringKind = "road" | "zone" | "node" | null; export interface EditingFeature { kind: Exclude; id: number; label: string; } export interface MapAuthoring { kind: AuthoringKind; draftPoints: MapPoint[]; canSave: boolean; saving: boolean; editing: EditingFeature | null; fields: { roadName: string; setRoadName: (value: string) => void; roadDescription: string; setRoadDescription: (value: string) => void; surface: "paved" | "dirt" | "trail"; setSurface: (value: "paved" | "dirt" | "trail") => void; speedMultiplier: string; setSpeedMultiplier: (value: string) => void; strokeColor: string; setStrokeColor: (value: string) => void; strokeWeight: string; setStrokeWeight: (value: string) => void; strokeStyle: "solid" | "dashed" | "dotted"; setStrokeStyle: (value: "solid" | "dashed" | "dotted") => void; labelVisible: boolean; setLabelVisible: (value: boolean) => void; zoneName: string; setZoneName: (value: string) => void; zoneDescription: string; setZoneDescription: (value: string) => void; zoneType: "water" | "land"; setZoneType: (value: "water" | "land") => void; fillColor: string; setFillColor: (value: string) => void; zoneStrokeColor: string; setZoneStrokeColor: (value: string) => void; fillOpacity: string; setFillOpacity: (value: string) => void; zoneStrokeWeight: string; setZoneStrokeWeight: (value: string) => void; nodeName: string; setNodeName: (value: string) => void; nodeDescription: string; setNodeDescription: (value: string) => void; resourceIds: number[]; toggleResourceId: (id: number) => void; zoneResourceIds: number[]; toggleZoneResourceId: (id: number) => void; richness: string; setRichness: (value: string) => void; }; setKind: (kind: AuthoringKind) => void; pushPoint: (point: MapPoint) => void; clearDraft: () => void; save: () => Promise; removeFeature: (target: "road" | "zone" | "node", id: number) => Promise; startEditingRoad: (road: RoadFeature) => void; startEditingZone: (zone: ZoneFeature) => void; startEditingNode: (node: NodeFeature) => void; } export function useMapAuthoring(options: { mapId: number; onSaved: () => void }): MapAuthoring { const { mapId, onSaved } = options; const [kind, setKindState] = useState(null); const [draftPoints, setDraftPoints] = useState([]); const [roadName, setRoadName] = useState(""); const [roadDescription, setRoadDescription] = useState(""); const [surface, setSurface] = useState<"paved" | "dirt" | "trail">("paved"); const [speedMultiplier, setSpeedMultiplier] = useState("1"); const [strokeColor, setStrokeColor] = useState(""); const [strokeWeight, setStrokeWeight] = useState(""); const [strokeStyle, setStrokeStyle] = useState<"solid" | "dashed" | "dotted">("solid"); const [labelVisible, setLabelVisible] = useState(true); const [zoneName, setZoneName] = useState(""); const [zoneDescription, setZoneDescription] = useState(""); const [zoneType, setZoneType] = useState<"water" | "land">("water"); const [fillColor, setFillColor] = useState(""); const [zoneStrokeColor, setZoneStrokeColor] = useState(""); const [fillOpacity, setFillOpacity] = useState(""); const [zoneStrokeWeight, setZoneStrokeWeight] = useState(""); const [nodeName, setNodeName] = useState(""); const [nodeDescription, setNodeDescription] = useState(""); const [resourceIds, setResourceIds] = useState([]); const [zoneResourceIds, setZoneResourceIds] = useState([]); const [richness, setRichness] = useState("100"); const toggleResourceId = useCallback((id: number) => { setResourceIds((current) => current.includes(id) ? current.filter((entry) => entry !== id) : [...current, id], ); }, []); const toggleZoneResourceId = useCallback((id: number) => { setZoneResourceIds((current) => current.includes(id) ? current.filter((entry) => entry !== id) : [...current, id], ); }, []); const [saving, setSaving] = useState(false); const [editing, setEditing] = useState(null); const pushPoint = useCallback((point: MapPoint) => { setDraftPoints((current) => [...current, point]); }, []); const clearDraft = useCallback(() => setDraftPoints([]), []); const setKind = useCallback((next: AuthoringKind) => { setKindState(next); setDraftPoints([]); setEditing(null); }, []); const startEditingRoad = useCallback((road: RoadFeature) => { setKindState("road"); setDraftPoints(road.points.map((point) => [point[0], point[1]])); setRoadName(road.name ?? ""); setRoadDescription(road.description ?? ""); setSurface(road.surface); setSpeedMultiplier(String(road.speedMultiplier)); setStrokeColor(road.strokeColor ?? ""); setStrokeWeight(road.strokeWeight != null ? String(road.strokeWeight) : ""); setStrokeStyle(road.strokeStyle ?? "solid"); setLabelVisible(road.labelVisible !== false); setEditing({ kind: "road", id: road.id, label: road.name ?? `Road #${road.id}` }); }, []); const startEditingZone = useCallback((zone: ZoneFeature) => { setKindState("zone"); setDraftPoints(zone.points.map((point) => [point[0], point[1]])); setZoneName(zone.name ?? ""); setZoneDescription(zone.description ?? ""); setZoneType(zone.type); setZoneResourceIds(zone.resources.map((entry) => entry.id)); setFillColor(zone.fillColor ?? ""); setZoneStrokeColor(zone.strokeColor ?? ""); setFillOpacity(zone.fillOpacity != null ? String(zone.fillOpacity) : ""); setZoneStrokeWeight(zone.strokeWeight != null ? String(zone.strokeWeight) : ""); setLabelVisible(zone.labelVisible !== false); setEditing({ kind: "zone", id: zone.id, label: zone.name ?? `Zone #${zone.id}` }); }, []); const startEditingNode = useCallback((node: NodeFeature) => { setKindState("node"); setDraftPoints([[node.point[0], node.point[1]]]); setNodeName(node.name); setNodeDescription(node.description ?? ""); setResourceIds(node.resources.map((entry) => entry.id)); setRichness(String(node.richness ?? 100)); setEditing({ kind: "node", id: node.id, label: node.name }); }, []); const removeFeature = useCallback( async (target: "road" | "zone" | "node", id: number) => { const result = await deleteMapFeature(target, id); if (result.success) { if (editing && editing.id === id && editing.kind === target) setEditing(null); toast.success("Feature deleted."); onSaved(); } else { toast.error(result.error ?? "Delete failed."); } }, [editing, onSaved], ); const save = useCallback(async () => { if (kind == null) return; setSaving(true); try { if (kind === "road") { const result = await saveMapRoad({ id: editing?.kind === "road" ? editing.id : undefined, mapId, name: roadName.trim() || null, description: roadDescription.trim() || null, surface, speedMultiplier: Number(speedMultiplier) || 1, strokeColor: strokeColor.trim() || null, strokeWeight: strokeWeight.trim() ? Number(strokeWeight) : null, strokeStyle, labelVisible, points: draftPoints, }); if (!result.success) throw new Error(result.error ?? "Save failed."); toast.success(editing?.kind === "road" ? "Road updated." : "Road saved."); } else if (kind === "zone") { const result = await saveMapZone({ id: editing?.kind === "zone" ? editing.id : undefined, mapId, name: zoneName.trim() || null, description: zoneDescription.trim() || null, type: zoneType, resourceIds: zoneResourceIds, fillColor: fillColor.trim() || null, strokeColor: zoneStrokeColor.trim() || null, fillOpacity: fillOpacity.trim() ? Number(fillOpacity) : null, strokeWeight: zoneStrokeWeight.trim() ? Number(zoneStrokeWeight) : null, labelVisible, points: draftPoints, }); if (!result.success) throw new Error(result.error ?? "Save failed."); toast.success(editing?.kind === "zone" ? "Zone updated." : "Zone saved."); } else { const result = await saveMapNode({ id: editing?.kind === "node" ? editing.id : undefined, mapId, name: nodeName.trim(), description: nodeDescription.trim() || null, resourceIds, position: draftPoints[0], richness: Number(richness) || 100, }); if (!result.success) throw new Error(result.error ?? "Save failed."); toast.success(editing?.kind === "node" ? "Resource node updated." : "Resource node saved."); } setKindState(null); setDraftPoints([]); setEditing(null); setRoadName(""); setRoadDescription(""); setZoneName(""); setZoneDescription(""); setNodeName(""); setNodeDescription(""); setResourceIds([]); setZoneResourceIds([]); setStrokeColor(""); setStrokeWeight(""); setStrokeStyle("solid"); setFillColor(""); setZoneStrokeColor(""); setFillOpacity(""); setZoneStrokeWeight(""); setLabelVisible(true); onSaved(); } catch (error) { toast.error(error instanceof Error ? error.message : "Save failed."); } finally { setSaving(false); } }, [ kind, editing, mapId, roadName, roadDescription, surface, speedMultiplier, strokeColor, strokeWeight, strokeStyle, labelVisible, draftPoints, zoneName, zoneDescription, zoneType, fillColor, zoneStrokeColor, fillOpacity, zoneStrokeWeight, nodeName, nodeDescription, resourceIds, zoneResourceIds, richness, onSaved, ]); const roadReady = kind !== "road" || draftPoints.length >= 2; const zoneReady = kind !== "zone" || draftPoints.length >= 3; const nodeReady = kind !== "node" || (draftPoints.length === 1 && resourceIds.length > 0 && nodeName.trim().length > 0); const canSave = kind != null && roadReady && zoneReady && nodeReady && !saving; return { kind, draftPoints, canSave, saving, editing, fields: { roadName, setRoadName, roadDescription, setRoadDescription, surface, setSurface, speedMultiplier, setSpeedMultiplier, strokeColor, setStrokeColor, strokeWeight, setStrokeWeight, strokeStyle, setStrokeStyle, labelVisible, setLabelVisible, zoneName, setZoneName, zoneDescription, setZoneDescription, zoneType, setZoneType, fillColor, setFillColor, zoneStrokeColor, setZoneStrokeColor, fillOpacity, setFillOpacity, zoneStrokeWeight, setZoneStrokeWeight, nodeName, setNodeName, nodeDescription, setNodeDescription, resourceIds, toggleResourceId, zoneResourceIds, toggleZoneResourceId, richness, setRichness, }, setKind, pushPoint, clearDraft, save, removeFeature, startEditingRoad, startEditingZone, startEditingNode, }; } function useResourceOptions(): { id: number; name: string }[] { const [options, setOptions] = useState<{ id: number; name: string }[]>([]); useEffect(() => { void (async () => { const response = await fetch("/api/resources?limit=500&depth=0&sort=name", { cache: "no-store", }); if (response.ok) { const body = (await response.json()) as { docs: { id: number; name: string }[] }; setOptions(body.docs); } })(); }, []); return options; } function ResourcePicker({ label, selected, onToggle, resources, }: { label: string; selected: number[]; onToggle: (id: number) => void; resources: { id: number; name: string }[]; }) { const [query, setQuery] = useState(""); const [open, setOpen] = useState(false); const needle = query.trim().toLowerCase(); const filtered = needle ? resources.filter((resource) => resource.name.toLowerCase().includes(needle)) : resources; const summary = selected.length === 0 ? label : selected.length <= 2 ? resources .filter((resource) => selected.includes(resource.id)) .map((resource) => resource.name) .join(", ") || label : `${selected.length} selected`; return (
setQuery(event.target.value)} placeholder="Search resources..." className="h-7 rounded-none text-xs" />
{filtered.map((resource) => ( onToggle(resource.id)} onSelect={(event) => event.preventDefault()} className="text-xs" > {resource.name} ))} {filtered.length === 0 && (
No matches
)}
); } export function AuthoringToolbar({ authoring, mapId }: { authoring: MapAuthoring; mapId: number }) { const resources = useResourceOptions(); const { kind, fields } = authoring; return (
GM Authoring // Feature Editor REF {String(mapId).padStart(3, "0")}
{(["road", "zone", "node"] as const).map((target) => ( ))} {authoring.draftPoints.length} point{authoring.draftPoints.length === 1 ? "" : "s"} {kind === "road" && authoring.draftPoints.length < 2 && " (need 2+)"} {kind === "zone" && authoring.draftPoints.length < 3 && " (need 3+)"} {kind === "node" && authoring.draftPoints.length === 0 && " (click to set)"}
{authoring.editing && (
Editing {authoring.editing.kind}:{" "} {authoring.editing.label} {kind === "road" && " (click the map to add points to the route)"} {kind === "zone" && " (click the map to add vertices to the polygon)"} {kind === "node" && " (click the map to move the node)"}
)} {kind === "road" && ( <>
fields.setRoadName(event.target.value)} placeholder="Road name" className="h-8 w-40 text-xs" />
fields.setSpeedMultiplier(event.target.value)} className="h-8 w-16 text-xs" />
fields.setStrokeColor(event.target.value)} placeholder="#cbd5e1" className="h-8 w-24 text-xs" />
fields.setStrokeWeight(event.target.value)} placeholder="2" className="h-8 w-14 text-xs" />