- 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
332 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|