1
0
Fork 0

feat(map): /map world map with authoring, placement, and feature APIs

Add the Leaflet map page and client (markers, labels, layer toggles, shipment interpolation), the placement and authoring server actions, the map-features and map-import API routes, and the map styles including leaflet pane isolation so portals stack correctly.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jason Fraley 2026-09-16 23:14:12 -04:00
parent 5be390012a
commit fcf220b961
10 changed files with 3988 additions and 0 deletions

View file

@ -0,0 +1,498 @@
"use server";
import config from "@payload-config";
import { getPayload } from "payload";
import type { GameStructure } from "@/payload-types";
import { hasPermission } from "@/utils/access-control/hasPermission";
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes";
import {
validatePlacement,
type BlueprintTerrain,
type NodeInput,
type StructureInput,
type ZoneInput,
} from "@/lib/map/placement";
import { maybeStartConstruction } from "@/lib/map/construction";
import { isPointArray, validatePosition, type MapPoint } from "@/lib/map/points";
import { validateHexColor } from "@/lib/map/style";
interface ActionResult {
success: boolean;
error?: string;
data?: unknown;
}
async function authenticate() {
const payloadConfig = await config;
const payload = await getPayload({ config: payloadConfig });
const { headers } = await import("next/headers");
const hdrs = await headers();
const { user } = await payload.auth({
headers: hdrs,
canSetHeaders: false,
});
if (!user) {
throw new Error("Unauthorized");
}
return { payload, user };
}
export async function placeStructure(params: {
mapId: number;
blueprintId: number;
name: string;
factionId?: number | null;
x: number;
y: number;
}): Promise<ActionResult> {
try {
const { payload, user } = await authenticate();
if (!(await hasLogisticsQualification(payload, user))) {
return {
success: false,
error: "Logistics qualification required to place structures.",
};
}
const { mapId, blueprintId, name, x, y } = params;
if (!Number.isFinite(x) || !Number.isFinite(y)) {
return { success: false, error: "Valid coordinates are required." };
}
if (!name || name.trim().length === 0) {
return { success: false, error: "A display name is required." };
}
const map = await payload
.findByID({ collection: "maps", id: mapId, depth: 0 })
.catch(() => null);
if (!map) {
return { success: false, error: "Map not found." };
}
if (!map.isActive) {
return { success: false, error: "This map is not active." };
}
const worldW = map.worldSizeWidth ?? 8192;
const worldH = map.worldSizeHeight ?? 8192;
if (x < 0 || x > worldW || y < 0 || y > worldH) {
return { success: false, error: "Site is outside the map bounds." };
}
const blueprint = await payload
.findByID({ collection: "structures", id: blueprintId, depth: 1 })
.catch(() => null);
if (!blueprint) {
return { success: false, error: "Structure blueprint not found." };
}
if (params.factionId != null) {
const faction = await payload
.findByID({ collection: "factions", id: params.factionId, depth: 0 })
.catch(() => null);
if (!faction) {
return { success: false, error: "Faction not found." };
}
}
const [zonesRes, nodesRes, structuresRes] = await Promise.all([
payload.find({
collection: "map-zones",
where: { map: { equals: mapId } },
depth: 0,
limit: 5000,
}),
payload.find({
collection: "resource-nodes",
where: { map: { equals: mapId } },
depth: 0,
limit: 5000,
}),
payload.find({
collection: "game-structures",
where: { and: [{ map: { equals: mapId } }, { coordinates: { exists: true } }] },
depth: 0,
limit: 1000,
}),
]);
const zones: ZoneInput[] = zonesRes.docs.map((zone) => ({
id: zone.id,
name: zone.name ?? null,
type: zone.type,
resources: ((zone.resources ?? []) as number[]) ?? [],
points: (zone.points ?? []) as MapPoint[],
}));
const nodes: NodeInput[] = nodesRes.docs.map((node) => ({
id: node.id,
resources: ((node.resources ?? []) as number[]) ?? [],
position: (node.position ?? [0, 0]) as MapPoint,
}));
const structures: StructureInput[] = structuresRes.docs
.map((doc) => {
const structure = doc as unknown as GameStructure;
return {
id: structure.id,
type: typeof structure.type === "object" ? structure.type.id : structure.type,
coordinates: structure.coordinates as MapPoint | null,
};
})
.filter((structure): structure is StructureInput & { coordinates: MapPoint } =>
isPointArray(structure.coordinates, 1),
);
const point: MapPoint = [x, y];
const terrain = (blueprint.terrainType ?? "land") as BlueprintTerrain;
const validation = validatePlacement({
zones,
nodes,
structures,
blueprintTerrain: terrain,
resourcesInRange: (blueprint.resourcesInRange ?? []).map((requirement) => ({
resource:
typeof requirement.resource === "object"
? requirement.resource?.id
: requirement.resource,
range: requirement.range ?? null,
})),
structuresInRange: (blueprint.structuresInRange ?? []).map((requirement) => ({
structure:
typeof requirement.structure === "object"
? requirement.structure?.id
: requirement.structure,
range: requirement.range ?? null,
})),
point,
});
if (!validation.allowed) {
return { success: false, error: validation.error ?? "Site is not valid for placement." };
}
const structure = await payload.create({
collection: "game-structures",
data: {
name: name.trim(),
type: blueprintId,
faction: params.factionId ?? null,
map: mapId,
coordinates: point,
constructionStatus: "awaiting_materials",
},
});
// Blueprints without materials start building immediately (the helper
// no-ops while any material is still missing).
try {
await maybeStartConstruction(payload, structure.id);
} catch {
// The site starts on the next delivery instead.
}
await emitGameEvent(payload, {
type: EventTypes.StructurePlaced,
message: `Construction site "${name.trim()}" (${blueprint.name}) placed at ${Math.round(x)}, ${Math.round(y)} on ${map.name}`,
actor: user.id,
structure: structure.id,
targetCollection: "game-structures",
targetId: structure.id,
data: {
mapId,
blueprintId,
blueprintName: blueprint.name,
point,
constructionDurationMinutes: blueprint.constructionDurationMinutes,
},
});
return { success: true, data: structure };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
if (message === "Unauthorized") {
return { success: false, error: "You must be logged in." };
}
return { success: false, error: message };
}
}
export interface MapRoadInput {
id?: number | null;
mapId: number;
name?: string | null;
description?: string | null;
surface: "paved" | "dirt" | "trail";
speedMultiplier: number;
strokeColor?: string | null;
strokeWeight?: number | null;
strokeStyle?: "solid" | "dashed" | "dotted" | null;
labelVisible?: boolean | null;
points: MapPoint[];
}
export interface MapZoneInput {
id?: number | null;
mapId: number;
name?: string | null;
description?: string | null;
type: "water" | "land";
resourceIds?: number[];
fillColor?: string | null;
strokeColor?: string | null;
fillOpacity?: number | null;
strokeWeight?: number | null;
labelVisible?: boolean | null;
points: MapPoint[];
}
export interface MapNodeInput {
id?: number | null;
mapId: number;
name: string;
description?: string | null;
resourceIds: number[];
position: MapPoint;
richness?: number | null;
}
async function requireMapPermission(
payload: Awaited<ReturnType<typeof getPayload>>,
user: { id: number | string },
permission: "maps:create" | "maps:update" | "maps:delete",
): Promise<boolean> {
return hasPermission(payload, user, permission);
}
function relationshipMapId(value: unknown): number | null {
if (typeof value === "number") return value;
if (value && typeof value === "object" && "id" in value) {
return (value as { id: number }).id;
}
return null;
}
type MapFeatureCollection = "map-roads" | "map-zones" | "resource-nodes";
/** When editing, the feature must exist and belong to the map being edited. */
async function existingFeatureOnMap(
payload: Awaited<ReturnType<typeof getPayload>>,
collection: MapFeatureCollection,
id: number,
mapId: number,
label: string,
): Promise<{ ok: true } | { ok: false; error: string }> {
const existing = await payload.findByID({ collection, id, depth: 0 }).catch(() => null);
if (!existing) return { ok: false, error: `${label} not found.` };
if (relationshipMapId(existing.map) !== mapId) {
return { ok: false, error: `This ${label.toLowerCase()} belongs to a different map.` };
}
return { ok: true };
}
export async function saveMapRoad(input: MapRoadInput): Promise<ActionResult> {
try {
const { payload, user } = await authenticate();
const editing = input.id != null;
if (!(await requireMapPermission(payload, user, editing ? "maps:update" : "maps:create"))) {
return { success: false, error: "Map configuration permission required." };
}
if (!isPointArray(input.points, 2)) {
return { success: false, error: "A road requires at least two points." };
}
if (input.speedMultiplier <= 0) {
return { success: false, error: "Speed multiplier must be greater than zero." };
}
const colorCheck = validateHexColor(input.strokeColor);
if (colorCheck !== true) return { success: false, error: colorCheck };
const map = await payload
.findByID({ collection: "maps", id: input.mapId, depth: 0 })
.catch(() => null);
if (!map) return { success: false, error: "Map not found." };
const roadData = {
name: input.name?.trim() || null,
description: input.description?.trim() || null,
surface: input.surface,
speedMultiplier: input.speedMultiplier,
strokeColor: input.strokeColor?.trim() || null,
strokeWeight: input.strokeWeight ?? null,
strokeStyle: input.strokeStyle ?? null,
labelVisible: input.labelVisible ?? null,
points: input.points,
};
if (editing) {
const existing = await existingFeatureOnMap(
payload,
"map-roads",
input.id!,
input.mapId,
"Road",
);
if (!existing.ok) return { success: false, error: existing.error };
const road = await payload.update({
collection: "map-roads",
id: input.id!,
data: roadData,
});
return { success: true, data: road };
}
const road = await payload.create({
collection: "map-roads",
data: { ...roadData, map: input.mapId },
});
return { success: true, data: road };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
if (message === "Unauthorized") return { success: false, error: "You must be logged in." };
return { success: false, error: message };
}
}
export async function saveMapZone(input: MapZoneInput): Promise<ActionResult> {
try {
const { payload, user } = await authenticate();
const editing = input.id != null;
if (!(await requireMapPermission(payload, user, editing ? "maps:update" : "maps:create"))) {
return { success: false, error: "Map configuration permission required." };
}
if (!isPointArray(input.points, 3)) {
return { success: false, error: "A zone requires at least three points." };
}
for (const color of [input.fillColor, input.strokeColor]) {
const colorCheck = validateHexColor(color);
if (colorCheck !== true) return { success: false, error: colorCheck };
}
const map = await payload
.findByID({ collection: "maps", id: input.mapId, depth: 0 })
.catch(() => null);
if (!map) return { success: false, error: "Map not found." };
const zoneData = {
name: input.name?.trim() || null,
description: input.description?.trim() || null,
type: input.type,
resources: input.resourceIds ?? [],
fillColor: input.fillColor?.trim() || null,
strokeColor: input.strokeColor?.trim() || null,
fillOpacity: input.fillOpacity ?? null,
strokeWeight: input.strokeWeight ?? null,
labelVisible: input.labelVisible ?? null,
points: input.points,
};
if (editing) {
const existing = await existingFeatureOnMap(
payload,
"map-zones",
input.id!,
input.mapId,
"Zone",
);
if (!existing.ok) return { success: false, error: existing.error };
const zone = await payload.update({
collection: "map-zones",
id: input.id!,
data: zoneData,
});
return { success: true, data: zone };
}
const zone = await payload.create({
collection: "map-zones",
data: { ...zoneData, map: input.mapId },
});
return { success: true, data: zone };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
if (message === "Unauthorized") return { success: false, error: "You must be logged in." };
return { success: false, error: message };
}
}
export async function saveMapNode(input: MapNodeInput): Promise<ActionResult> {
try {
const { payload, user } = await authenticate();
const editing = input.id != null;
if (!(await requireMapPermission(payload, user, editing ? "maps:update" : "maps:create"))) {
return { success: false, error: "Map configuration permission required." };
}
if (validatePosition(input.position) !== true) {
return { success: false, error: "A node requires a single [x, y] position." };
}
const resourceIds = [...new Set(input.resourceIds)];
if (resourceIds.length === 0) {
return { success: false, error: "A node requires at least one resource." };
}
const resources = await payload.find({
collection: "resources",
where: { id: { in: resourceIds } },
depth: 0,
limit: resourceIds.length,
});
if (resources.docs.length !== resourceIds.length) {
return { success: false, error: "One or more resources not found." };
}
const nodeData = {
name: input.name.trim(),
description: input.description?.trim() || null,
resources: resourceIds,
position: input.position,
richness: input.richness ?? 100,
};
if (editing) {
const existing = await existingFeatureOnMap(
payload,
"resource-nodes",
input.id!,
input.mapId,
"Node",
);
if (!existing.ok) return { success: false, error: existing.error };
const node = await payload.update({
collection: "resource-nodes",
id: input.id!,
data: nodeData,
});
return { success: true, data: node };
}
const node = await payload.create({
collection: "resource-nodes",
data: { ...nodeData, map: input.mapId },
});
return { success: true, data: node };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
if (message === "Unauthorized") return { success: false, error: "You must be logged in." };
return { success: false, error: message };
}
}
export async function deleteMapFeature(
kind: "road" | "zone" | "node",
id: number,
): Promise<ActionResult> {
try {
const { payload, user } = await authenticate();
if (!(await requireMapPermission(payload, user, "maps:delete"))) {
return { success: false, error: "Map configuration permission required." };
}
const collection =
kind === "road" ? "map-roads" : kind === "zone" ? "map-zones" : "resource-nodes";
await payload.delete({ collection, id, overrideAccess: false });
return { success: true };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
if (message === "Unauthorized") return { success: false, error: "You must be logged in." };
return { success: false, error: message };
}
}

View file

@ -0,0 +1,20 @@
"use client";
import dynamic from "next/dynamic";
import type { MapClientProps } from "@/components/frontend/map/MapClient";
const MapClient = dynamic(
() => import("@/components/frontend/map/MapClient").then((mod) => mod.MapClient),
{
ssr: false,
loading: () => (
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
Loading map...
</div>
),
},
);
export function MapLoader(props: MapClientProps) {
return <MapClient {...props} />;
}

View file

@ -0,0 +1,50 @@
import config from "@payload-config";
import { getPayload } from "payload";
import { headers } from "next/headers";
import type { Metadata } from "next";
import { Map as MapIcon } from "lucide-react";
import { hasPermission } from "@/utils/access-control/hasPermission";
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
import { MapLoader } from "./MapLoader";
export const metadata: Metadata = {
title: "World Map: Polaris Task Force",
};
export default async function MapPage() {
const payload = await getPayload({ config: await config });
const { user } = await payload.auth({ headers: await headers(), canSetHeaders: false });
const [mapsRes, canEdit, canPlace] = await Promise.all([
payload.find({
collection: "maps",
where: { isActive: { equals: true } },
sort: "name",
limit: 100,
depth: 0,
overrideAccess: true,
}),
user ? hasPermission(payload, user, "maps:update") : Promise.resolve(false),
user ? hasLogisticsQualification(payload, user) : Promise.resolve(false),
]);
const maps = mapsRes.docs.map((map) => ({ id: map.id, name: map.name }));
return (
<div className="relative flex min-h-[240px] flex-1 flex-col">
<div className="flex items-center justify-between gap-3 border-b border-white/15 bg-black/50 px-5 py-2 backdrop-blur-sm">
<div className="flex items-center gap-2">
<MapIcon className="size-4 text-muted-foreground" />
<h1 className="text-sm font-semibold uppercase tracking-[0.2em]">World Map</h1>
</div>
<div className="flex items-center gap-4 font-mono text-[9px] uppercase tracking-[0.2em] text-white/60">
<span>PTF Unit Command // Tactical Grid</span>
<span>
{maps.length} Active Grid{maps.length === 1 ? "" : "s"}
</span>
</div>
</div>
<MapLoader maps={maps} canEdit={canEdit} canPlace={canPlace} />
</div>
);
}

View file

@ -3208,3 +3208,224 @@
animation: none; animation: none;
} }
} }
/* World map: dossier marker family (structure / node / shipment pins). */
/* World map: give the Leaflet container its own stacking context so its panes
(z-index 200-1000, applied in the root stacking context otherwise) stop
painting over Radix dropdown portals (body-level, z-50) on the map page. */
.leaflet-container {
isolation: isolate;
}
/* Map placement/authoring modes: crosshair cursor while a click mode is
active (MapContainer className is mount-only, so this lives on CSS). */
.map-host[data-mode="crosshair"] .leaflet-container {
cursor: crosshair;
}
/* World map: structure / construction-site markers (status-colored pin with an
inline SVG icon and a progress bar for sites under construction). The same
pin base is shared by the node and shipment badges. */
.map-structure-icon {
background: transparent;
border: none;
}
.map-structure-pin {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 2px;
background: rgb(8 10 14 / 90%);
border: 1.5px solid rgb(255 255 255 / 25%);
box-shadow:
0 0 0 1.5px rgb(0 0 0 / 65%),
0 1px 4px rgb(0 0 0 / 60%);
}
.map-structure-pin.map-pin-sm {
width: 20px;
height: 20px;
border-radius: 1.5px;
}
.map-structure-progress {
position: absolute;
left: 3px;
right: 3px;
bottom: -6px;
height: 3px;
overflow: hidden;
border-radius: 2px;
background: rgb(255 255 255 / 30%);
}
.map-structure-progress > div {
height: 100%;
background: #ffffff;
}
/* World map: dossier-style Leaflet chrome. Tooltips and popups are Leaflet DOM
(not shadcn), so they get the tactical card treatment here: dark glass,
hairline borders, squared corners, mono uppercase labels. */
.leaflet-container .map-tooltip-dossier.leaflet-tooltip {
background: rgb(0 0 0 / 82%);
backdrop-filter: blur(6px);
border: 1px solid rgb(255 255 255 / 25%);
border-radius: 0;
box-shadow: 0 2px 8px rgb(0 0 0 / 65%);
color: rgb(255 255 255 / 85%);
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New",
monospace;
font-size: 10px;
letter-spacing: 0.16em;
padding: 4px 8px;
text-transform: uppercase;
}
.leaflet-container .map-tooltip-dossier.leaflet-tooltip-top::before {
border-top-color: rgb(255 255 255 / 25%);
}
.leaflet-container .map-tooltip-dossier.leaflet-tooltip-bottom::before {
border-bottom-color: rgb(255 255 255 / 25%);
}
.leaflet-container .map-tooltip-dossier.leaflet-tooltip-left::before {
border-left-color: rgb(255 255 255 / 25%);
}
.leaflet-container .map-tooltip-dossier.leaflet-tooltip-right::before {
border-right-color: rgb(255 255 255 / 25%);
}
.leaflet-container .leaflet-popup-content-wrapper {
background: rgb(8 10 14 / 92%);
backdrop-filter: blur(8px);
border: 1px solid rgb(255 255 255 / 20%);
border-radius: 0;
box-shadow: 0 4px 16px rgb(0 0 0 / 65%);
color: rgb(255 255 255 / 90%);
}
.leaflet-container .leaflet-popup-content {
margin: 10px 12px;
line-height: 1.45;
}
.leaflet-container .leaflet-popup-tip {
background: rgb(8 10 14 / 92%);
box-shadow: none;
}
.leaflet-container a.leaflet-popup-close-button {
color: rgb(255 255 255 / 50%);
}
.leaflet-container a.leaflet-popup-close-button:hover {
color: #ffffff;
}
/* Dossier popups (structure/shipment cards): zero wrapper padding so the
classification strip and readout footer run edge to edge. */
.leaflet-container .map-popup-dossier .leaflet-popup-content {
margin: 0;
width: 100% !important;
}
/* World map: persistent map labels (structures, zones, roads) in the dossier
style: mono uppercase, tracked, dark halo instead of a box. */
.leaflet-container .map-label.leaflet-tooltip {
background: rgb(8 10 14 / 62%);
border: none;
box-shadow: none;
border-radius: 1px;
padding: 1px 5px;
color: rgb(255 255 255 / 92%);
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New",
monospace;
font-size: 10px;
letter-spacing: 0.16em;
text-transform: uppercase;
text-shadow:
0 0 3px rgb(0 0 0 / 95%),
0 0 6px rgb(0 0 0 / 85%);
white-space: nowrap;
}
.leaflet-container .map-label.leaflet-tooltip::before {
display: none;
}
.map-zone-label-icon,
.map-road-label-icon {
background: transparent;
border: none;
}
.map-zone-label {
color: rgb(255 255 255 / 90%);
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New",
monospace;
font-size: 10px;
letter-spacing: 0.2em;
text-transform: uppercase;
white-space: nowrap;
/* Size to the text itself: inside the 0x0 divIcon container a block div
resolves to 0 width, which makes the percentage centering transform a
no-op and leaves the label hanging off the road. */
width: max-content;
background: rgb(8 10 14 / 62%);
padding: 1px 5px;
border-radius: 1px;
text-shadow:
0 0 3px rgb(0 0 0 / 95%),
0 0 7px rgb(0 0 0 / 90%),
0 0 2px rgb(0 0 0 / 90%);
pointer-events: none;
transform: translate(-50%, -50%);
}
.map-road-label-svg {
overflow: visible;
}
.map-road-label-path {
fill: none;
stroke: none;
}
.map-road-label-text {
fill: rgb(255 255 255 / 94%);
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New",
monospace;
font-size: 11px;
letter-spacing: 0.2em;
/* Dark casing behind the glyphs (painted under the fill) for contrast on
any terrain. */
stroke: rgb(0 0 0 / 82%);
stroke-width: 4px;
paint-order: stroke;
stroke-linejoin: round;
}
.leaflet-container .map-zone-label-icon,
.leaflet-container .map-road-label-icon {
pointer-events: none;
}
/* Radix floating panels (Select/DropdownMenu/Popover/Tooltip) portal to <body>
and would otherwise paint below the map page's high-z overlays (authoring
toolbar z-[1000], structure drawer z-[1200]). Pin every popper wrapper above
them. */
[data-radix-popper-content-wrapper] {
z-index: 1300 !important;
}

View file

@ -0,0 +1,260 @@
import { NextRequest, NextResponse } from "next/server";
import config from "@payload-config";
import { getPayload } from "payload";
import type { GameStructure, Map as MapDoc, Shipment } from "@/payload-types";
import { isPointArray, type MapPoint } from "@/lib/map/points";
import type { MapFeaturesResponse, StructureFeature } from "@/lib/map/types";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
/**
* PostGIS point fields read back as a FLAT [x, y] tuple (not an array of
* tuples like the JSON feature geometry).
*/
function pointOf(coordinates: unknown): MapPoint | null {
if (!Array.isArray(coordinates) || coordinates.length !== 2) return null;
const x = coordinates[0];
const y = coordinates[1];
if (
typeof x !== "number" ||
typeof y !== "number" ||
!Number.isFinite(x) ||
!Number.isFinite(y)
) {
return null;
}
return [x, y];
}
function relationshipId(value: unknown): number | null {
if (typeof value === "number") return value;
if (value && typeof value === "object" && "id" in value) return (value as { id: number }).id;
return null;
}
/**
* View-surface feature feed for the map page (settled decision 8): any
* logged-in user may read map features here, while maps/road/zone/node
* configuration stays maps:*-gated in the Payload admin panel.
*/
export async function GET(req: NextRequest) {
const payload = await getPayload({ config: await config });
const { user } = await payload.auth({ headers: req.headers, canSetHeaders: false });
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const mapId = Number(req.nextUrl.searchParams.get("mapId"));
if (!Number.isInteger(mapId) || mapId <= 0) {
return NextResponse.json({ error: "mapId is required" }, { status: 400 });
}
const map = await payload
.findByID({ collection: "maps", id: mapId, depth: 1, overrideAccess: true })
.catch(() => null);
if (!map) {
return NextResponse.json({ error: "Map not found" }, { status: 404 });
}
const mapDoc = map as unknown as MapDoc & {
basemapImage: { url?: string | null } | number | null;
};
const worldW = Number(mapDoc.worldSizeWidth ?? 8192);
const worldH = Number(mapDoc.worldSizeHeight ?? 8192);
const [structuresRes, nodesRes, roadsRes, zonesRes, shipmentsRes] = await Promise.all([
payload.find({
collection: "game-structures",
where: { map: { equals: mapId } },
depth: 2,
limit: 1000,
overrideAccess: true,
}),
payload.find({
collection: "resource-nodes",
where: { map: { equals: mapId } },
depth: 1,
limit: 5000,
overrideAccess: true,
}),
payload.find({
collection: "map-roads",
where: { map: { equals: mapId } },
depth: 0,
limit: 5000,
overrideAccess: true,
}),
payload.find({
collection: "map-zones",
where: { map: { equals: mapId } },
depth: 1,
limit: 5000,
overrideAccess: true,
}),
payload.find({
collection: "shipments",
where: {
and: [{ map: { equals: mapId } }, { status: { in: ["dispatched", "in_transit"] } }],
},
depth: 2,
limit: 200,
sort: "-dispatchedAt",
overrideAccess: true,
}),
]);
const structures: StructureFeature[] = [];
for (const doc of structuresRes.docs) {
const structure = doc as unknown as GameStructure & { type: { id: number; name: string } };
const point = pointOf(structure.coordinates);
if (!point) continue;
const blueprint = typeof structure.type === "object" ? structure.type : null;
const faction =
structure.faction && typeof structure.faction === "object" ? structure.faction : null;
const storageEntries = [
...(structure.storedResources ?? []),
...((structure as GameStructure).voidStorage ?? []),
];
let storageUsedKg = 0;
for (const entry of storageEntries) {
const resource = typeof entry.resource === "object" ? entry.resource : null;
storageUsedKg += (resource?.massPerUnit ?? 0) * entry.amount;
}
structures.push({
id: structure.id,
name: structure.name,
point,
constructionStatus: structure.constructionStatus ?? "complete",
constructionStartedAt: structure.constructionStartedAt ?? null,
constructionCompletesAt: structure.constructionCompletesAt ?? null,
constructionDurationMinutes: blueprint?.constructionDurationMinutes ?? null,
storageUsedKg,
storageCapacityKg: blueprint?.maxCapacityMass ?? null,
labelVisible: structure.labelVisible ?? null,
labelPriority: structure.labelPriority ?? null,
blueprintId: blueprint?.id ?? null,
blueprintName: blueprint?.name ?? null,
blueprintTerrain: (blueprint?.terrainType as "land" | "water" | undefined) ?? null,
materials: (blueprint?.materials ?? []).map((material) => ({
resource: relationshipId(material.resource) ?? 0,
resourceName:
material.resource && typeof material.resource === "object"
? material.resource.name
: "Resource",
amount: material.amount ?? 0,
})),
resourcesInRange: (blueprint?.resourcesInRange ?? []).map((requirement) => ({
resource: relationshipId(requirement.resource),
structure: null,
range: requirement.range ?? null,
})),
structuresInRange: (blueprint?.structuresInRange ?? []).map((requirement) => ({
resource: null,
structure: relationshipId(requirement.structure),
range: requirement.range ?? null,
})),
factionName: faction && "name" in faction ? String(faction.name) : null,
});
}
const relEntries = (value: unknown): { id: number; name: string }[] =>
(Array.isArray(value) ? value : []).map((entry) =>
typeof entry === "object" && entry !== null && "id" in entry
? { id: entry.id as number, name: String((entry as { name?: string }).name ?? "Resource") }
: { id: entry as number, name: "Resource" },
);
const nodes = nodesRes.docs.map((node) => ({
id: node.id,
name: node.name,
point: pointOf(node.position) ?? [0, 0],
description: node.description ?? null,
resources: relEntries(node.resources),
richness: node.richness ?? null,
}));
const roads = roadsRes.docs.map((road) => ({
id: road.id,
name: road.name ?? null,
description: road.description ?? null,
surface: road.surface,
speedMultiplier: road.speedMultiplier,
strokeColor: road.strokeColor ?? null,
strokeWeight: road.strokeWeight ?? null,
strokeStyle: road.strokeStyle ?? null,
labelVisible: road.labelVisible ?? null,
points: isPointArray(road.points, 2) ? (road.points as MapPoint[]) : [],
}));
const zones = zonesRes.docs.map((zone) => ({
id: zone.id,
name: zone.name ?? null,
description: zone.description ?? null,
type: zone.type,
resources: relEntries(zone.resources),
fillColor: zone.fillColor ?? null,
strokeColor: zone.strokeColor ?? null,
fillOpacity: zone.fillOpacity ?? null,
strokeWeight: zone.strokeWeight ?? null,
labelVisible: zone.labelVisible ?? null,
points: isPointArray(zone.points, 3) ? (zone.points as MapPoint[]) : [],
}));
const shipments = shipmentsRes.docs.map((doc) => {
const shipment = doc as unknown as Shipment & {
origin: GameStructure;
destination: GameStructure;
transportVehicle: { name: string } | number | null;
};
const vehicle =
shipment.transportVehicle && typeof shipment.transportVehicle === "object"
? shipment.transportVehicle
: null;
const vehicleType =
vehicle && typeof (vehicle as { type?: unknown }).type === "object"
? ((vehicle as { type: { name?: string } }).type.name ?? null)
: null;
return {
id: shipment.id,
name: shipment.name ?? null,
status: shipment.status,
routePath: isPointArray(shipment.routePath, 2) ? (shipment.routePath as MapPoint[]) : null,
originName: shipment.origin?.name ?? null,
originPoint: pointOf(shipment.origin?.coordinates),
destinationName: shipment.destination?.name ?? null,
destinationPoint: pointOf(shipment.destination?.coordinates),
dispatchedAt: shipment.dispatchedAt ?? null,
estimatedArrival: shipment.estimatedArrival ?? null,
vehicleName: vehicleType,
};
});
const response: MapFeaturesResponse = {
map: {
id: mapDoc.id,
name: mapDoc.name,
worldSizeWidth: worldW,
worldSizeHeight: worldH,
basemapMode: mapDoc.basemapMode,
basemapImageUrl:
mapDoc.basemapImage && typeof mapDoc.basemapImage === "object"
? (mapDoc.basemapImage.url ?? null)
: null,
tileUrlTemplate: mapDoc.tileUrlTemplate ?? null,
minZoom: mapDoc.minZoom ?? 0,
maxZoom: mapDoc.maxZoom ?? 12,
defaultZoom: mapDoc.defaultZoom ?? 2,
defaultCenter: [mapDoc.defaultCenterX ?? worldW / 2, mapDoc.defaultCenterY ?? worldH / 2],
},
structures,
nodes,
roads,
zones,
shipments,
};
return NextResponse.json(response);
}

View file

@ -0,0 +1,243 @@
import { NextRequest, NextResponse } from "next/server";
import config from "@payload-config";
import { getPayload } from "payload";
import { hasPermission } from "@/utils/access-control/hasPermission";
import { isPointArray, type MapPoint } from "@/lib/map/points";
import { validateHexColor } from "@/lib/map/style";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
interface GeoJsonPosition {
0: number;
1: number;
length: number;
}
interface GeoJsonFeature {
type?: string;
geometry?: {
type?: string;
coordinates?: unknown;
} | null;
properties?: Record<string, unknown> | null;
}
interface GeoJsonCollection {
type?: string;
features?: GeoJsonFeature[];
}
const ROAD_SURFACES = new Set(["paved", "dirt", "trail"]);
function toMapPoint(position: unknown): MapPoint | null {
const p = position as GeoJsonPosition | undefined;
if (!p || !Array.isArray(p) || p.length < 2) return null;
const x = Number(p[0]);
const y = Number(p[1]);
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
return [x, y];
}
function propString(props: Record<string, unknown>, key: string): string | null {
const value = props[key];
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function propNumber(props: Record<string, unknown>, key: string): number | null {
const value = props[key];
const num = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
return Number.isFinite(num) ? num : null;
}
function propHexColor(props: Record<string, unknown>, key: string): string | null {
const value = propString(props, key);
return validateHexColor(value) === true ? value : null;
}
function propStrokeStyle(
props: Record<string, unknown>,
key: string,
): "solid" | "dashed" | "dotted" | null {
const value = propString(props, key);
return value === "dashed" || value === "dotted" || value === "solid" ? value : null;
}
/**
* GeoJSON FeatureCollection import for road/zone/node authoring (JOSM-style
* workflows). See docs/map-system.md for the full mapping table.
*
* Mapping:
* - LineString -> map-roads (props: name, description, surface, speedMultiplier,
* strokeColor #rrggbb, strokeWeight, strokeStyle solid|dashed|dotted)
* - Polygon -> map-zones (props: name, description, type "water"|"land"; outer ring only;
* fillColor, strokeColor #rrggbb, fillOpacity, strokeWeight)
* - Point -> resource-nodes (props: name, resource [resource ID] or
* resources [array of IDs], resourceName/resourceNames, richness)
*
* GeoJSON positions are interpreted directly as map meters: [x, y] =
* [east, south] on the map grid (same convention as the authoring UI).
*/
export async function POST(req: NextRequest) {
const payload = await getPayload({ config: await config });
const { user } = await payload.auth({ headers: req.headers, canSetHeaders: false });
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!(await hasPermission(payload, user, "maps:create"))) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
let body: (GeoJsonCollection & { mapId?: number }) | null = null;
try {
body = (await req.json()) as GeoJsonCollection & { mapId?: number };
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const mapId = Number(body?.mapId);
if (!Number.isInteger(mapId) || mapId <= 0) {
return NextResponse.json({ error: "mapId is required" }, { status: 400 });
}
if (body?.type !== "FeatureCollection" || !Array.isArray(body.features)) {
return NextResponse.json(
{ error: "Body must be a GeoJSON FeatureCollection with a mapId field" },
{ status: 400 },
);
}
const map = await payload
.findByID({ collection: "maps", id: mapId, depth: 0, overrideAccess: true })
.catch(() => null);
if (!map) {
return NextResponse.json({ error: "Map not found" }, { status: 404 });
}
const created = { roads: 0, zones: 0, nodes: 0 };
const errors: string[] = [];
for (let index = 0; index < body.features.length; index++) {
const feature = body.features[index];
const label = feature.properties?.name ?? `feature ${index}`;
const props = feature.properties ?? {};
try {
const geometryType = feature.geometry?.type;
const rawCoordinates = feature.geometry?.coordinates;
if (geometryType === "LineString" && isPointArray(rawCoordinates, 2)) {
const surface = propString(props, "surface");
await payload.create({
collection: "map-roads",
data: {
map: mapId,
name: propString(props, "name"),
description: propString(props, "description"),
surface:
surface && ROAD_SURFACES.has(surface)
? (surface as "paved" | "dirt" | "trail")
: "paved",
speedMultiplier: propNumber(props, "speedMultiplier") ?? 1,
strokeColor: propHexColor(props, "strokeColor"),
strokeWeight: propNumber(props, "strokeWeight"),
strokeStyle: propStrokeStyle(props, "strokeStyle"),
points: (rawCoordinates as GeoJsonPosition[]).map(toMapPoint) as MapPoint[],
},
overrideAccess: true,
});
created.roads++;
continue;
}
if (geometryType === "Polygon" && Array.isArray(rawCoordinates)) {
const ring = (rawCoordinates as unknown[][])[0];
if (isPointArray(ring, 3)) {
const zoneType = propString(props, "type");
const zoneResourceNames = (propString(props, "resources") ?? "")
.split(",")
.map((entry) => entry.trim())
.filter(Boolean);
let zoneResourceIds: number[] = [];
if (zoneResourceNames.length > 0) {
const found = await payload.find({
collection: "resources",
where: { name: { in: zoneResourceNames } },
depth: 0,
limit: 100,
overrideAccess: true,
});
zoneResourceIds = found.docs.map((doc) => doc.id);
}
await payload.create({
collection: "map-zones",
data: {
map: mapId,
name: propString(props, "name"),
description: propString(props, "description"),
type: zoneType === "land" ? "land" : "water",
resources: zoneResourceIds,
fillColor: propHexColor(props, "fillColor"),
strokeColor: propHexColor(props, "strokeColor"),
fillOpacity: propNumber(props, "fillOpacity"),
strokeWeight: propNumber(props, "strokeWeight"),
points: (ring as GeoJsonPosition[]).map(toMapPoint) as MapPoint[],
},
overrideAccess: true,
});
created.zones++;
continue;
}
}
if (geometryType === "Point" && Array.isArray(rawCoordinates)) {
const position = toMapPoint(rawCoordinates);
if (!position) throw new Error("invalid Point coordinates");
const resourceIds: number[] = [];
const resourceNames: string[] = [];
if (Array.isArray(props["resources"])) {
for (const entry of props["resources"]) {
if (typeof entry === "number") resourceIds.push(entry);
else if (typeof entry === "string") resourceNames.push(entry);
}
}
const singleId = propNumber(props, "resource");
if (singleId != null) resourceIds.push(singleId);
const singleName = propString(props, "resourceName");
if (singleName) resourceNames.push(singleName);
if (resourceNames.length > 0) {
const found = await payload.find({
collection: "resources",
where: { name: { in: resourceNames } },
depth: 0,
limit: 100,
overrideAccess: true,
});
resourceIds.push(...found.docs.map((doc) => doc.id));
}
const uniqueResourceIds = [...new Set(resourceIds)];
if (uniqueResourceIds.length === 0)
throw new Error("missing resource (ID) or resourceName property");
const name = propString(props, "name") ?? `Node ${position[0]}, ${position[1]}`;
await payload.create({
collection: "resource-nodes",
data: {
map: mapId,
name,
resources: uniqueResourceIds,
position,
richness: propNumber(props, "richness") ?? 100,
},
overrideAccess: true,
});
created.nodes++;
continue;
}
errors.push(`${label}: unsupported or malformed geometry "${String(geometryType)}"`);
} catch (error) {
errors.push(`${label}: ${error instanceof Error ? error.message : "import failed"}`);
}
}
return NextResponse.json({ ok: errors.length === 0, created, errors });
}

View file

@ -0,0 +1,778 @@
"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<AuthoringKind, null>;
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<void>;
removeFeature: (target: "road" | "zone" | "node", id: number) => Promise<void>;
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<AuthoringKind>(null);
const [draftPoints, setDraftPoints] = useState<MapPoint[]>([]);
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<number[]>([]);
const [zoneResourceIds, setZoneResourceIds] = useState<number[]>([]);
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<EditingFeature | null>(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 (
<div className="flex flex-col gap-1">
<Label className="text-[10px] uppercase text-white/60">{label}</Label>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="h-8 w-52 justify-between rounded-none px-2 text-xs font-normal normal-case"
>
<span className="truncate">{summary}</span>
<span className="text-white/40">▾</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="z-[1100] w-60 rounded-none border-white/20 bg-black/95"
>
<div className="p-1.5">
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search resources..."
className="h-7 rounded-none text-xs"
/>
</div>
<div className="max-h-64 overflow-y-auto">
{filtered.map((resource) => (
<DropdownMenuCheckboxItem
key={resource.id}
checked={selected.includes(resource.id)}
onCheckedChange={() => onToggle(resource.id)}
onSelect={(event) => event.preventDefault()}
className="text-xs"
>
{resource.name}
</DropdownMenuCheckboxItem>
))}
{filtered.length === 0 && (
<div className="px-2 py-3 text-center text-xs text-white/40">No matches</div>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
export function AuthoringToolbar({ authoring, mapId }: { authoring: MapAuthoring; mapId: number }) {
const resources = useResourceOptions();
const { kind, fields } = authoring;
return (
<div className="pointer-events-auto w-[640px] max-w-[calc(100vw-10rem)] overflow-hidden rounded-none border border-white/15 bg-black/80 backdrop-blur-sm">
<div className="flex items-center justify-between gap-6 border-b border-white/15 bg-black/50 px-3 py-1">
<span className="font-mono text-[9px] uppercase tracking-[0.2em] text-white/60">
GM Authoring // Feature Editor
</span>
<span className="font-mono text-[9px] uppercase tracking-[0.2em] text-white/60">
REF {String(mapId).padStart(3, "0")}
</span>
</div>
<div className="flex flex-col gap-2 p-2">
<div className="flex items-center gap-1.5">
{(["road", "zone", "node"] as const).map((target) => (
<Button
key={target}
size="sm"
variant={kind === target && !authoring.editing ? "secondary" : "outline"}
className="h-8 px-2.5 text-xs capitalize"
onClick={() =>
authoring.setKind(kind === target && !authoring.editing ? null : target)
}
>
{target === "node" ? "New node" : `New ${target}`}
</Button>
))}
<span className="ml-auto text-xs text-white/60">
{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)"}
</span>
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-xs"
onClick={authoring.clearDraft}
disabled={authoring.draftPoints.length === 0}
>
Clear
</Button>
<Button
size="sm"
className="h-7 px-2 text-xs"
onClick={() => void authoring.save()}
disabled={!authoring.canSave}
>
{authoring.saving ? "Saving..." : authoring.editing ? "Save changes" : "Save"}
</Button>
</div>
{authoring.editing && (
<div className="flex items-center gap-2 rounded-md border border-sky-500/40 bg-sky-950/50 px-2 py-1.5 text-xs text-sky-200">
<span>
Editing {authoring.editing.kind}:{" "}
<span className="font-semibold">{authoring.editing.label}</span>
{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)"}
</span>
<Button
size="sm"
variant="ghost"
className="ml-auto h-6 px-2 text-xs"
onClick={() => authoring.setKind(null)}
>
Cancel edit
</Button>
</div>
)}
{kind === "road" && (
<>
<div className="flex flex-wrap items-end gap-2">
<div className="flex flex-col gap-1">
<Label className="text-[10px] uppercase text-white/60">Name</Label>
<Input
value={fields.roadName}
onChange={(event) => fields.setRoadName(event.target.value)}
placeholder="Road name"
className="h-8 w-40 text-xs"
/>
</div>
<div className="flex flex-col gap-1">
<Label className="text-[10px] uppercase text-white/60">Surface</Label>
<Select
value={fields.surface}
onValueChange={(value) => fields.setSurface(value as "paved" | "dirt" | "trail")}
>
<SelectTrigger className="h-8 w-24 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="paved">Paved</SelectItem>
<SelectItem value="dirt">Dirt</SelectItem>
<SelectItem value="trail">Trail</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1">
<Label className="text-[10px] uppercase text-white/60">Speed x</Label>
<Input
type="number"
step="0.1"
min="0.1"
value={fields.speedMultiplier}
onChange={(event) => fields.setSpeedMultiplier(event.target.value)}
className="h-8 w-16 text-xs"
/>
</div>
<div className="flex flex-col gap-1">
<Label className="text-[10px] uppercase text-white/60">Color</Label>
<Input
value={fields.strokeColor}
onChange={(event) => fields.setStrokeColor(event.target.value)}
placeholder="#cbd5e1"
className="h-8 w-24 text-xs"
/>
</div>
<div className="flex flex-col gap-1">
<Label className="text-[10px] uppercase text-white/60">Width</Label>
<Input
type="number"
min="1"
max="10"
value={fields.strokeWeight}
onChange={(event) => fields.setStrokeWeight(event.target.value)}
placeholder="2"
className="h-8 w-14 text-xs"
/>
</div>
<div className="flex flex-col gap-1">
<Label className="text-[10px] uppercase text-white/60">Style</Label>
<Select
value={fields.strokeStyle}
onValueChange={(value) =>
fields.setStrokeStyle(value as "solid" | "dashed" | "dotted")
}
>
<SelectTrigger className="h-8 w-24 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="solid">Solid</SelectItem>
<SelectItem value="dashed">Dashed</SelectItem>
<SelectItem value="dotted">Dotted</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-end gap-3">
<div className="flex flex-1 flex-col gap-1">
<Label className="text-[10px] uppercase text-white/60">Description</Label>
<textarea
value={fields.roadDescription}
onChange={(event) => fields.setRoadDescription(event.target.value)}
placeholder="Shown in the tooltip + popup"
rows={2}
className="w-full rounded-none border border-white/20 bg-black/40 px-2 py-1 text-xs text-white placeholder:text-white/30"
/>
</div>
<label className="flex items-center gap-1.5 pb-1 text-[10px] uppercase tracking-wide text-white/60">
<input
type="checkbox"
checked={fields.labelVisible}
onChange={(event) => fields.setLabelVisible(event.target.checked)}
/>
Label
</label>
</div>
</>
)}
{kind === "zone" && (
<>
<div className="flex flex-wrap items-end gap-2">
<div className="flex flex-col gap-1">
<Label className="text-[10px] uppercase text-white/60">Name</Label>
<Input
value={fields.zoneName}
onChange={(event) => fields.setZoneName(event.target.value)}
placeholder="Zone name"
className="h-8 w-40 text-xs"
/>
</div>
<div className="flex flex-col gap-1">
<Label className="text-[10px] uppercase text-white/60">Type</Label>
<Select
value={fields.zoneType}
onValueChange={(value) => fields.setZoneType(value as "water" | "land")}
>
<SelectTrigger className="h-8 w-24 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="water">Water</SelectItem>
<SelectItem value="land">Land</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1">
<Label className="text-[10px] uppercase text-white/60">Fill</Label>
<Input
value={fields.fillColor}
onChange={(event) => fields.setFillColor(event.target.value)}
placeholder="#38bdf8"
className="h-8 w-24 text-xs"
/>
</div>
<div className="flex flex-col gap-1">
<Label className="text-[10px] uppercase text-white/60">Edge</Label>
<Input
value={fields.zoneStrokeColor}
onChange={(event) => fields.setZoneStrokeColor(event.target.value)}
placeholder="#38bdf8"
className="h-8 w-24 text-xs"
/>
</div>
<div className="flex flex-col gap-1">
<Label className="text-[10px] uppercase text-white/60">Opacity</Label>
<Input
type="number"
step="0.05"
min="0"
max="1"
value={fields.fillOpacity}
onChange={(event) => fields.setFillOpacity(event.target.value)}
placeholder="0.25"
className="h-8 w-16 text-xs"
/>
</div>
<div className="flex flex-col gap-1">
<Label className="text-[10px] uppercase text-white/60">Width</Label>
<Input
type="number"
min="0"
max="10"
value={fields.zoneStrokeWeight}
onChange={(event) => fields.setZoneStrokeWeight(event.target.value)}
placeholder="1"
className="h-8 w-14 text-xs"
/>
</div>
<label className="flex items-center gap-1.5 pb-1 text-[10px] uppercase tracking-wide text-white/60">
<input
type="checkbox"
checked={fields.labelVisible}
onChange={(event) => fields.setLabelVisible(event.target.checked)}
/>
Label
</label>
</div>
<ResourcePicker
label="Resources in zone"
selected={fields.zoneResourceIds}
onToggle={fields.toggleZoneResourceId}
resources={resources}
/>
<div className="flex flex-col gap-1">
<Label className="text-[10px] uppercase text-white/60">Description</Label>
<textarea
value={fields.zoneDescription}
onChange={(event) => fields.setZoneDescription(event.target.value)}
placeholder="Shown in the tooltip + popup"
rows={2}
className="w-full rounded-none border border-white/20 bg-black/40 px-2 py-1 text-xs text-white placeholder:text-white/30"
/>
</div>
</>
)}
{kind === "node" && (
<div className="flex flex-wrap items-end gap-2">
<div className="flex flex-col gap-1">
<Label className="text-[10px] uppercase text-white/60">Name</Label>
<Input
value={fields.nodeName}
onChange={(event) => fields.setNodeName(event.target.value)}
placeholder="Node name"
className="h-8 w-40 text-xs"
/>
</div>
<div className="flex flex-col gap-1">
<Label className="text-[10px] uppercase text-white/60">Description</Label>
<textarea
value={fields.nodeDescription}
onChange={(event) => fields.setNodeDescription(event.target.value)}
placeholder="Shown in the tooltip + popup"
rows={2}
className="w-full rounded-none border border-white/20 bg-black/40 px-2 py-1 text-xs text-white placeholder:text-white/30"
/>
</div>
<ResourcePicker
label="Resources"
selected={fields.resourceIds}
onToggle={fields.toggleResourceId}
resources={resources}
/>
<div className="flex flex-col gap-1">
<Label className="text-[10px] uppercase text-white/60">Richness</Label>
<Input
type="number"
min="0"
value={fields.richness}
onChange={(event) => fields.setRichness(event.target.value)}
className="h-8 w-16 text-xs"
/>
</div>
</div>
)}
</div>
</div>
);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,321 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
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>
<Select value={blueprintId} onValueChange={setBlueprintId}>
<SelectTrigger id="place-blueprint" className="w-full">
<SelectValue placeholder="Select blueprint" />
</SelectTrigger>
<SelectContent>
{blueprints.map((option) => (
<SelectItem key={option.id} value={String(option.id)}>
{option.name}
</SelectItem>
))}
</SelectContent>
</Select>
</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>
<Select value={factionId} onValueChange={setFactionId}>
<SelectTrigger id="place-faction" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No faction</SelectItem>
{factions.map((faction) => (
<SelectItem key={faction.id} value={String(faction.id)}>
{faction.name}
</SelectItem>
))}
</SelectContent>
</Select>
</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>
);
}

View file

@ -0,0 +1,245 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { X, ExternalLinkIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { CapacityBar } from "@/components/frontend/storage/CapacityBar";
import { StorageRules } from "@/components/frontend/storage/StorageRules";
import { StructureStorageView } from "@/components/frontend/storage/StructureStorageView";
import { StaffRoster } from "@/components/frontend/baseManagement/StaffRoster";
import { UpgradeCard } from "@/components/frontend/baseManagement/UpgradeCard";
import { ConstructionPanel } from "@/components/frontend/baseManagement/ConstructionPanel";
import { getStructureDetail } from "@/app/(frontend)/logistics/structures/[id]/detail";
import type { StructureDetailData } from "@/app/(frontend)/logistics/structures/[id]/detail-data";
import { useGameTick } from "@/hooks/useGameTick";
import {
BoxIcon,
CoinsIcon,
MapPinIcon,
WrenchIcon,
} from "lucide-react";
import {
Item,
ItemContent,
ItemDescription,
ItemHeader,
ItemMedia,
ItemTitle,
} from "@/components/ui/item";
export function StructureDetailDrawer({
structureId,
onClose,
}: {
structureId: number;
onClose: () => void;
}) {
const [data, setData] = useState<StructureDetailData | null>(null);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
const result = await getStructureDetail(structureId);
if (result.success && result.data) {
setData(result.data);
setError(null);
} else {
setError(result.error ?? "Failed to load structure.");
}
}, [structureId]);
useEffect(() => {
void load();
}, [load]);
useGameTick(load);
const structure = data?.structure ?? null;
const blueprint = data?.blueprint ?? null;
return (
<div className="absolute inset-y-0 right-0 z-[1200] flex w-[480px] max-w-[94vw] flex-col border-l border-white/15 bg-[#080a0e]/95 backdrop-blur-md">
<div className="flex items-center justify-between gap-4 border-b border-white/15 bg-black/60 px-3 py-1.5">
<span className="font-mono text-[9px] uppercase tracking-[0.2em] text-white/60">
Structure Dossier // Preview
</span>
<span className="font-mono text-[9px] uppercase tracking-[0.2em] text-white/60">
REF {String(structureId).padStart(3, "0")}
</span>
<button
onClick={onClose}
aria-label="Close preview"
className="rounded-none p-1 text-white/50 transition-colors hover:text-white"
>
<X className="size-4" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
{!data && !error && (
<p className="p-4 font-mono text-[10px] uppercase tracking-[0.2em] text-white/50">
Loading dossier...
</p>
)}
{error && (
<div className="rounded-none border border-red-500/40 bg-red-950/60 p-3 text-xs text-red-200">
{error}
</div>
)}
{data && structure && blueprint && (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
{data.imageUrl && (
<div className="relative overflow-hidden rounded-none border border-white/15">
<img src={data.imageUrl} alt={structure.name} className="h-36 w-full object-cover" />
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent" />
</div>
)}
<div className="font-mono text-[10px] uppercase tracking-[0.14em] text-white/60">
{blueprint.name}
{data.gates.canManage ? " // managed by you" : ""}
</div>
<div className="text-base font-semibold text-white/95">{structure.name}</div>
{data.description && (
<p className="text-xs text-muted-foreground">{data.description}</p>
)}
<div className="flex gap-2">
<Button
asChild
size="sm"
variant="outline"
className="h-7 flex-1 rounded-none px-2 font-mono text-[10px] uppercase tracking-[0.2em]"
>
<Link href={`/logistics/structures/${structure.id}`} target="_blank">
<ExternalLinkIcon className="size-3" />
Full Page
</Link>
</Button>
</div>
</div>
<div className="grid grid-cols-1 gap-3">
<Item variant="outline">
<ItemHeader>
<ItemTitle className="flex items-center gap-2">
<BoxIcon className="size-4 text-muted-foreground" />
Storage Capacity
</ItemTitle>
</ItemHeader>
<ItemContent>
<CapacityBar
utilization={data.derived.utilization}
currentMass={data.derived.currentMass}
maxMass={blueprint.maxCapacityMass ?? null}
/>
</ItemContent>
</Item>
<Item variant="outline">
<ItemHeader>
<ItemTitle className="flex items-center gap-2">
<CoinsIcon className="size-4 text-muted-foreground" />
Total Value
</ItemTitle>
</ItemHeader>
<ItemContent>
<div className="flex items-center gap-2">
<span className="text-xl font-bold">{data.derived.totalValue.toLocaleString()}</span>
<span className="text-xs text-muted-foreground">base value</span>
</div>
</ItemContent>
</Item>
</div>
<div className="grid grid-cols-2 gap-2">
{structure.coordinates && (
<Item variant="muted" size="sm">
<ItemMedia variant="icon">
<MapPinIcon />
</ItemMedia>
<ItemContent>
<ItemTitle>Coordinates</ItemTitle>
<ItemDescription>
{structure.coordinates[0].toFixed(0)}, {structure.coordinates[1].toFixed(0)}
</ItemDescription>
</ItemContent>
</Item>
)}
{data.derived.gridSlots !== null && (
<Item variant="muted" size="sm">
<ItemMedia variant="icon">
<BoxIcon />
</ItemMedia>
<ItemContent>
<ItemTitle>Grid</ItemTitle>
<ItemDescription>
{blueprint.xSize}×{blueprint.ySize} ({data.derived.gridSlots} slots)
</ItemDescription>
</ItemContent>
</Item>
)}
{blueprint.maxCapacityMass && (
<Item variant="muted" size="sm">
<ItemMedia variant="icon">
<BoxIcon />
</ItemMedia>
<ItemContent>
<ItemTitle>Max Capacity</ItemTitle>
<ItemDescription>
{blueprint.maxCapacityMass.toLocaleString()} kg (mass)
</ItemDescription>
</ItemContent>
</Item>
)}
{blueprint.maxHealth && (
<Item variant="muted" size="sm">
<ItemMedia variant="icon">
<WrenchIcon />
</ItemMedia>
<ItemContent>
<ItemTitle>Max Health</ItemTitle>
<ItemDescription>{blueprint.maxHealth.toLocaleString()}</ItemDescription>
</ItemContent>
</Item>
)}
</div>
<UpgradeCard gameStructureId={structure.id} upgrade={data.upgrade} />
<ConstructionPanel
status={structure.constructionStatus ?? "complete"}
startedAt={structure.constructionStartedAt ?? null}
completesAt={structure.constructionCompletesAt ?? null}
durationMinutes={blueprint.constructionDurationMinutes ?? null}
materials={data.derived.constructionMaterials}
/>
<StaffRoster
structure={data.staffing.structureStaffing}
staff={data.staffing.staff}
classifications={data.staffing.classifications}
npcs={data.staffing.npcs}
canManage={data.gates.canManage}
hiringEnabled={data.hiring.enabled}
hiringDisabledMessage={data.hiring.disabledMessage}
currencyConfig={data.currencyConfig}
/>
<StructureStorageView
structure={structure}
canMutate={data.gates.canManageStorage}
canArrange={data.gates.canTransferStorage}
/>
<StorageRules
allowedStorage={blueprint.allowedStorage}
prohibitedStorage={blueprint.prohibitedStorage}
restrictToAllowed={blueprint.restrictToAllowed}
/>
</div>
)}
</div>
</div>
);
}