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;
|
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