1
0
Fork 0
polaris-task-force/src/components/frontend/map/PlacementDialog.tsx
Z8MB1E 947a5c1bbf feat(ui): searchable combobox across data-driven dropdowns
- new ui/combobox primitive (popover plus cmdk): search input, scrolling
  list, trigger-width popover, per-option disabled and hint lines
- converts every data-driven single-value select site-wide (blueprints,
  factions, accounts, assignments, NPCs, structures, locker items, seats,
  missions, ranks, resources, mod lists, event targets, sprints)
- long multi-select checkbox menus (event log types, kanban labels) gain
  search inputs following the resource picker pattern
- static short enum selects stay as plain selects
2026-09-17 16:28:17 -04:00

332 lines
11 KiB
TypeScript

"use client";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Combobox } from "@/components/ui/combobox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { placeStructure } from "@/app/(frontend)/logistics/map/actions";
import {
validatePlacement,
type NodeInput,
type StructureInput,
type ZoneInput,
} from "@/lib/map/placement";
import type { MapPoint } from "@/lib/map/points";
import type { MapConfig, NodeFeature, StructureFeature, ZoneFeature } from "@/lib/map/types";
interface BlueprintOption {
id: number;
name: string;
terrainType: "land" | "water" | null;
constructionDurationMinutes: number | null;
requiredTech: string[];
materials: { resource: number; resourceName: string; amount: number | null }[];
resourcesInRange: { resource: number | null; range: number | null }[];
structuresInRange: { structure: number | null; range: number | null }[];
}
interface FactionOption {
id: number;
name: string;
}
interface PlacementDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
mapConfig: MapConfig | null;
point: MapPoint;
zones: ZoneFeature[];
nodes: NodeFeature[];
structures: StructureFeature[];
onPlaced: () => void;
}
export function PlacementDialog({
open,
onOpenChange,
mapConfig,
point,
zones,
nodes,
structures,
onPlaced,
}: PlacementDialogProps) {
const [blueprints, setBlueprints] = useState<BlueprintOption[]>([]);
const [factions, setFactions] = useState<FactionOption[]>([]);
const [blueprintId, setBlueprintId] = useState<string>("");
const [name, setName] = useState("");
const [factionId, setFactionId] = useState<string>("none");
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (!open) return;
let cancelled = false;
void (async () => {
const [blueprintRes, factionRes] = await Promise.all([
fetch("/api/structures?limit=500&depth=1&sort=name", { cache: "no-store" }),
fetch("/api/factions?limit=200&depth=0&sort=name", { cache: "no-store" }),
]);
if (cancelled) return;
if (blueprintRes.ok) {
const body = (await blueprintRes.json()) as {
docs: {
id: number;
name: string;
terrainType?: string | null;
constructionDurationMinutes?: number | null;
requiredTech?: { name?: string }[] | number[];
materials?: {
resource?: { id: number; name?: string } | number;
amount?: number | null;
}[];
resourcesInRange?: {
resource?: { id: number } | number | null;
range?: number | null;
}[];
structuresInRange?: {
structure?: { id: number } | number | null;
range?: number | null;
}[];
}[];
};
setBlueprints(
body.docs.map((doc) => ({
id: doc.id,
name: doc.name,
terrainType: (doc.terrainType as "land" | "water" | null | undefined) ?? "land",
constructionDurationMinutes: doc.constructionDurationMinutes ?? null,
requiredTech: (doc.requiredTech ?? [])
.filter(
(tech): tech is { name?: string } => typeof tech === "object" && tech !== null,
)
.map((tech) => tech.name ?? "Unknown"),
materials: (doc.materials ?? []).map((material) => ({
resource:
typeof material.resource === "object" && material.resource !== null
? material.resource.id
: 0,
resourceName:
typeof material.resource === "object" && material.resource !== null
? (material.resource.name ?? "Resource")
: "Resource",
amount: material.amount ?? 0,
})),
resourcesInRange: (doc.resourcesInRange ?? []).map((requirement) => ({
resource:
typeof requirement.resource === "object" && requirement.resource !== null
? requirement.resource.id
: null,
range: requirement.range ?? null,
})),
structuresInRange: (doc.structuresInRange ?? []).map((requirement) => ({
structure:
typeof requirement.structure === "object" && requirement.structure !== null
? requirement.structure.id
: null,
range: requirement.range ?? null,
})),
})),
);
}
if (factionRes.ok) {
const body = (await factionRes.json()) as { docs: { id: number; name: string }[] };
setFactions(body.docs);
}
})();
return () => {
cancelled = true;
};
}, [open]);
const blueprint = useMemo(
() => blueprints.find((option) => option.id === Number(blueprintId)) ?? null,
[blueprints, blueprintId],
);
const validationError = useMemo(() => {
if (!mapConfig || !blueprint) return null;
if (
point[0] < 0 ||
point[0] > mapConfig.worldSizeWidth ||
point[1] < 0 ||
point[1] > mapConfig.worldSizeHeight
) {
return "Site is outside the map bounds.";
}
const zoneInputs: ZoneInput[] = zones.map((zone) => ({
id: zone.id,
name: zone.name,
type: zone.type,
resources: zone.resources.map((entry) => entry.id),
points: zone.points,
}));
const nodeInputs: NodeInput[] = nodes.map((node) => ({
id: node.id,
resources: node.resources.map((entry) => entry.id),
position: node.point,
}));
const structureInputs: StructureInput[] = structures.map((structure) => ({
id: structure.id,
type: structure.blueprintId ?? 0,
coordinates: structure.point,
}));
const validation = validatePlacement({
zones: zoneInputs,
nodes: nodeInputs,
structures: structureInputs,
blueprintTerrain: blueprint.terrainType ?? "land",
resourcesInRange: blueprint.resourcesInRange,
structuresInRange: blueprint.structuresInRange,
point,
});
return validation.allowed ? null : (validation.error ?? "Site is not valid.");
}, [mapConfig, blueprint, point, zones, nodes, structures]);
const reset = () => {
setBlueprintId("");
setName("");
setFactionId("none");
};
const submit = async () => {
if (!mapConfig || !blueprint) return;
setSubmitting(true);
try {
const result = await placeStructure({
mapId: mapConfig.id,
blueprintId: blueprint.id,
name,
factionId: factionId === "none" ? null : Number(factionId),
x: point[0],
y: point[1],
});
if (result.success) {
toast.success(`Placement of "${name}" submitted. Ship materials to start construction.`);
reset();
onOpenChange(false);
onPlaced();
} else {
toast.error(result.error ?? "Placement failed.");
}
} finally {
setSubmitting(false);
}
};
const canSubmit = Boolean(blueprint) && name.trim().length > 0 && !validationError && !submitting;
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) reset();
onOpenChange(nextOpen);
}}
>
<DialogContent className="sm:max-w-[440px]">
<DialogHeader>
<DialogTitle>Place structure</DialogTitle>
<DialogDescription>
Site at {Math.round(point[0])} m east, {Math.round(point[1])} m south
{mapConfig ? ` on ${mapConfig.name}` : ""}.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="place-blueprint">Blueprint</Label>
<Combobox
id="place-blueprint"
value={blueprintId}
onValueChange={setBlueprintId}
placeholder="Select blueprint"
searchPlaceholder="Search blueprints..."
emptyMessage="No blueprints available."
options={blueprints.map((option) => ({
value: String(option.id),
label: option.name,
}))}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="place-name">Display name</Label>
<Input
id="place-name"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="e.g. FOB Wolfpack"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="place-faction">Faction (optional)</Label>
<Combobox
id="place-faction"
value={factionId}
onValueChange={setFactionId}
searchPlaceholder="Search factions..."
emptyMessage="No factions available."
options={[
{ value: "none", label: "No faction" },
...factions.map((faction) => ({
value: String(faction.id),
label: faction.name,
})),
]}
/>
</div>
{blueprint && (
<div className="rounded-md border border-white/10 bg-black/30 p-3 text-xs text-white/70 flex flex-col gap-1">
<div>
Terrain: <span className="capitalize">{blueprint.terrainType ?? "land"}</span>
{blueprint.constructionDurationMinutes != null && (
<>
{" \u00b7 "}Build time: {blueprint.constructionDurationMinutes} min
</>
)}
</div>
{blueprint.materials.length > 0 && (
<div>
Materials:{" "}
{blueprint.materials
.map((material) => `${material.resourceName} x${material.amount ?? 0}`)
.join(", ")}
</div>
)}
{blueprint.requiredTech.length > 0 && (
<div>Requires tech (informational): {blueprint.requiredTech.join(", ")}</div>
)}
</div>
)}
{validationError && (
<p className="rounded-md border border-red-500/40 bg-red-950/60 p-2 text-xs text-red-200">
{validationError}
</p>
)}
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button size="sm" onClick={() => void submit()} disabled={!canSubmit}>
{submitting ? "Placing..." : "Place structure"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}