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:
parent
5be390012a
commit
fcf220b961
10 changed files with 3988 additions and 0 deletions
498
src/app/(frontend)/logistics/map/actions.ts
Normal file
498
src/app/(frontend)/logistics/map/actions.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
20
src/app/(frontend)/map/MapLoader.tsx
Normal file
20
src/app/(frontend)/map/MapLoader.tsx
Normal 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} />;
|
||||
}
|
||||
50
src/app/(frontend)/map/page.tsx
Normal file
50
src/app/(frontend)/map/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -3208,3 +3208,224 @@
|
|||
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;
|
||||
}
|
||||
|
|
|
|||
260
src/app/api/map-features/route.ts
Normal file
260
src/app/api/map-features/route.ts
Normal 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);
|
||||
}
|
||||
243
src/app/api/map-import/route.ts
Normal file
243
src/app/api/map-import/route.ts
Normal 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 });
|
||||
}
|
||||
778
src/components/frontend/map/AuthoringToolbar.tsx
Normal file
778
src/components/frontend/map/AuthoringToolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1352
src/components/frontend/map/MapClient.tsx
Normal file
1352
src/components/frontend/map/MapClient.tsx
Normal file
File diff suppressed because it is too large
Load diff
321
src/components/frontend/map/PlacementDialog.tsx
Normal file
321
src/components/frontend/map/PlacementDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
245
src/components/frontend/map/StructureDetailDrawer.tsx
Normal file
245
src/components/frontend/map/StructureDetailDrawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue