diff --git a/src/app/(frontend)/logistics/map/actions.ts b/src/app/(frontend)/logistics/map/actions.ts index 1676446..837e8e2 100644 --- a/src/app/(frontend)/logistics/map/actions.ts +++ b/src/app/(frontend)/logistics/map/actions.ts @@ -17,6 +17,8 @@ import { import { maybeStartConstruction } from "@/lib/map/construction"; import { isPointArray, validatePosition, type MapPoint } from "@/lib/map/points"; import { validateHexColor } from "@/lib/map/style"; +import { NODE_ICON_OPTIONS } from "@/lib/map/nodeIcons"; +import type { ResourceNode } from "@/payload-types"; interface ActionResult { success: boolean; @@ -281,6 +283,7 @@ export interface MapNodeInput { resourceIds: number[]; position: MapPoint; richness?: number | null; + icon?: string | null; } async function requireMapPermission( @@ -450,17 +453,23 @@ export async function saveMapNode(input: MapNodeInput): Promise { 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." }; + if (resourceIds.length > 0) { + 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 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." }; + // An empty resource list makes the node a plain marker: no resource + // validation, and placement's resourcesInRange never matches it. + + const icon = input.icon ?? "marker"; + if (!NODE_ICON_OPTIONS.some((option) => option.value === icon)) { + return { success: false, error: "Unknown node icon." }; } const nodeData = { @@ -469,6 +478,7 @@ export async function saveMapNode(input: MapNodeInput): Promise { resources: resourceIds, position: input.position, richness: input.richness ?? 100, + icon: icon as ResourceNode["icon"], }; if (editing) { diff --git a/src/app/(frontend)/map/[id]/page.tsx b/src/app/(frontend)/map/[id]/page.tsx new file mode 100644 index 0000000..82dc767 --- /dev/null +++ b/src/app/(frontend)/map/[id]/page.tsx @@ -0,0 +1,60 @@ +import config from "@payload-config"; +import { getPayload } from "payload"; +import { headers } from "next/headers"; +import { notFound } from "next/navigation"; +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"; + +interface MapViewPageProps { + params: Promise<{ id: string }>; +} + +export async function generateMetadata({ params }: MapViewPageProps): Promise { + const { id } = await params; + return { title: `World Map #${id}: Polaris Task Force` }; +} + +export default async function MapViewPage({ params }: MapViewPageProps) { + const { id } = await params; + const mapId = Number(id); + const payload = await getPayload({ config: await config }); + const { user } = await payload.auth({ headers: await headers(), canSetHeaders: false }); + + const map = Number.isInteger(mapId) + ? await payload + .findByID({ collection: "maps", id: mapId, depth: 0, overrideAccess: true }) + .catch(() => null) + : null; + + if (!map) notFound(); + + const [canEdit, canPlace] = await Promise.all([ + user ? hasPermission(payload, user, "maps:update") : Promise.resolve(false), + user ? hasLogisticsQualification(payload, user) : Promise.resolve(false), + ]); + + // Inactive maps stay reachable for editors previewing them, but are hidden + // from everyone else. + if (!map.isActive && !canEdit) notFound(); + + return ( +
+
+
+ +

{map.name}

+
+
+ PTF Unit Command // Tactical Grid + + {map.worldSizeWidth}×{map.worldSizeHeight} m + +
+
+ +
+ ); +} diff --git a/src/app/(frontend)/map/page.tsx b/src/app/(frontend)/map/page.tsx index 49a77ed..30176f1 100644 --- a/src/app/(frontend)/map/page.tsx +++ b/src/app/(frontend)/map/page.tsx @@ -1,37 +1,44 @@ import config from "@payload-config"; import { getPayload } from "payload"; -import { headers } from "next/headers"; +import { Fragment } from "react"; +import Link from "next/link"; 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"; +import { Map as MapIcon, MapPinIcon } from "lucide-react"; +import type { Campaign, Faction, Map as MapDoc, Media } from "@/payload-types"; +import { + easeStatClass, + isWorldMapEntry, + weatherStatClass, + weatherStatLabel, + worldMapScreenQuery, +} from "@/lib/map/screen"; export const metadata: Metadata = { title: "World Map: Polaris Task Force", }; -export default async function MapPage() { +function basemapUrl(map: MapDoc): string | null { + const image = map.basemapImage as Media | number | null | undefined; + if (image && typeof image === "object" && image.url) return image.url; + return null; +} + +export default async function MapPickerPage() { 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 mapsRes = await payload.find({ + collection: "maps", + where: worldMapScreenQuery(), + sort: "name", + limit: 100, + depth: 1, + overrideAccess: true, + }); - const maps = mapsRes.docs.map((map) => ({ id: map.id, name: map.name })); + const maps = mapsRes.docs.filter((map) => isWorldMapEntry(map)); return ( -
+
@@ -44,7 +51,104 @@ export default async function MapPage() {
- + + {maps.length === 0 ? ( +
+ +

+ No maps published to the world map yet. Maps are configured in the admin panel (World: + Maps) with the "World Map Entry" flag. +

+
+ ) : ( +
+ {maps.map((map) => { + const campaign = + map.campaign && typeof map.campaign === "object" ? (map.campaign as Campaign) : null; + const faction = + map.factionControl && typeof map.factionControl === "object" + ? (map.factionControl as Faction) + : null; + const url = basemapUrl(map); + const stats = [ + map.weather + ? { + key: `w-${map.id}`, + label: weatherStatLabel(map.weather), + cls: weatherStatClass(map.weather), + } + : null, + map.easeOfAccess + ? { + key: `e-${map.id}`, + label: `${map.easeOfAccess} access`, + cls: easeStatClass(map.easeOfAccess), + } + : null, + faction ? { key: `f-${map.id}`, label: faction.name, cls: "text-violet-400" } : null, + ].filter((stat) => stat !== null); + return ( + +
+ {url ? ( + // eslint-disable-next-line @next/next/no-img-element + {map.name} + ) : ( +
+ +
+ )} +
+
+
+

{map.name}

+ + {map.worldSizeWidth}×{map.worldSizeHeight} m + +
+

+ {campaign ? campaign.name : "Unit grid"} +

+ {map.description && ( +

+ {map.description} +

+ )} + {stats.length > 0 && ( +
+ {stats.map((stat, index) => ( + + {index > 0 && ( + + )} + + {stat.label} + + + ))} +
+ )} +
+ + ); + })} +
+ )}
); } diff --git a/src/app/api/map-features/route.ts b/src/app/api/map-features/route.ts index fb0418d..1d32731 100644 --- a/src/app/api/map-features/route.ts +++ b/src/app/api/map-features/route.ts @@ -202,6 +202,7 @@ export async function GET(req: NextRequest) { description: node.description ?? null, resources: relEntries(node.resources), richness: node.richness ?? null, + icon: node.icon ?? null, })); const roads = roadsRes.docs.map((road) => ({ diff --git a/src/collections/world/Maps.ts b/src/collections/world/Maps.ts index bceeb54..a2830fd 100644 --- a/src/collections/world/Maps.ts +++ b/src/collections/world/Maps.ts @@ -29,6 +29,81 @@ export const Maps: CollectionConfig = { description: "Inactive maps are hidden from the map page and cannot receive placements.", }, }, + { + name: "worldMap", + type: "checkbox", + label: "World Map Entry", + defaultValue: false, + admin: { + description: + "Show on the World Map screen. Leave off for mission-only maps (e.g. side op maps without imagery).", + }, + }, + { + name: "campaign", + type: "relationship", + relationTo: "campaigns", + label: "Campaign", + admin: { + description: + "Campaign this map belongs to. Maps tied to custom (side) campaigns are excluded from the World Map screen even when flagged as world map entries.", + }, + }, + { + name: "description", + type: "textarea", + label: "Description", + admin: { + description: "Optional map blurb shown on the World Map picker cards.", + }, + }, + { + name: "weather", + type: "text", + label: "General Weather", + admin: { + description: + "Optional freeform weather condition for intel display (e.g. Clear, Monsoon, Ashfall).", + }, + }, + { + name: "latitude", + type: "number", + label: "Real-World Latitude", + admin: { + description: "Optional real-world reference coordinate (degrees).", + }, + }, + { + name: "longitude", + type: "number", + label: "Real-World Longitude", + admin: { + description: "Optional real-world reference coordinate (degrees).", + }, + }, + { + name: "easeOfAccess", + type: "select", + label: "Ease of Access", + options: [ + { label: "Easy", value: "easy" }, + { label: "Moderate", value: "moderate" }, + { label: "Difficult", value: "difficult" }, + ], + admin: { + description: "Optional: how hard the area is to reach for intel display.", + }, + }, + { + name: "factionControl", + type: "relationship", + relationTo: "factions", + label: "Faction Control", + admin: { + description: "Optional faction associated with or controlling this map.", + }, + }, { label: "World Size (meters)", type: "group", @@ -71,7 +146,8 @@ export const Maps: CollectionConfig = { { label: "XYZ Tile Layer", value: "tiles" }, ], admin: { - description: "Image: a single uploaded map image stretched over the world bounds. Tiles: an external XYZ tile server.", + description: + "Image: a single uploaded map image stretched over the world bounds. Tiles: an external XYZ tile server.", }, }, { diff --git a/src/collections/world/ResourceNodes.ts b/src/collections/world/ResourceNodes.ts index 8ce5d0b..3c5e79a 100644 --- a/src/collections/world/ResourceNodes.ts +++ b/src/collections/world/ResourceNodes.ts @@ -1,6 +1,7 @@ import { CollectionConfig } from "payload"; import { requirePermission } from "@/utils/access-control/hasPermission"; import { validatePosition } from "@/lib/map/points"; +import { NODE_ICON_OPTIONS } from "@/lib/map/nodeIcons"; /** * Map resource nodes (deposits, forests, wells, ...). Display + placement @@ -46,9 +47,21 @@ export const ResourceNodes: CollectionConfig = { type: "relationship", relationTo: "resources", hasMany: true, - required: true, admin: { - description: "Every resource this node yields (e.g. copper AND oil).", + description: + "Every resource this node yields (e.g. copper AND oil). Leave empty for a plain marker node.", + }, + }, + { + name: "icon", + type: "select", + options: NODE_ICON_OPTIONS.map((option) => ({ + label: option.label, + value: option.value, + })), + defaultValue: "marker", + admin: { + description: "Marker icon shown on the map.", }, }, { diff --git a/src/components/frontend/blocks/AppSidebar.tsx b/src/components/frontend/blocks/AppSidebar.tsx index f9b70b9..88f6399 100644 --- a/src/components/frontend/blocks/AppSidebar.tsx +++ b/src/components/frontend/blocks/AppSidebar.tsx @@ -155,7 +155,7 @@ export function AppSidebar({ )} {hasLogistics && ( - + )} diff --git a/src/components/frontend/blocks/NavCore.tsx b/src/components/frontend/blocks/NavCore.tsx index 325a10f..70587be 100644 --- a/src/components/frontend/blocks/NavCore.tsx +++ b/src/components/frontend/blocks/NavCore.tsx @@ -18,6 +18,7 @@ import { export function NavCore({ label, items, + expandDefaultOpen = true, }: { label: string; items: { @@ -25,12 +26,14 @@ export function NavCore({ url: string; icon: LucideIcon; isActive?: boolean; + defaultOpen?: boolean; items?: { title: string; url: string; icon?: LucideIcon; }[]; }[]; + expandDefaultOpen?: boolean; }) { if (items.length === 0) return null; @@ -44,7 +47,7 @@ export function NavCore({ diff --git a/src/components/frontend/blocks/sidebarData.ts b/src/components/frontend/blocks/sidebarData.ts index 195e32d..9c98747 100644 --- a/src/components/frontend/blocks/sidebarData.ts +++ b/src/components/frontend/blocks/sidebarData.ts @@ -12,6 +12,7 @@ import { Flag, Kanban, LifeBuoy, + Map, Package, Radar, ScrollText, @@ -25,10 +26,13 @@ import { } from "lucide-react"; export const sidebarData = { - // World Map hidden until the feature ships; the /map route and all of its - // functionality stay live for direct-URL access. Restore with: - // [{ title: "World Map", url: "/map", icon: MapIcon }] (and the MapIcon import). - navMap: [], + navMap: [ + { + title: "World Map", + url: "/map", + icon: Map, + }, + ], navHelpdesk: [ { title: "Helpdesk", @@ -90,6 +94,7 @@ export const sidebarData = { title: "Assets", url: "", icon: Boxes, + defaultOpen: true, items: [ { title: "Structures", @@ -112,6 +117,7 @@ export const sidebarData = { title: "Operations", url: "", icon: Package, + defaultOpen: true, items: [ { title: "Shipments", @@ -134,6 +140,7 @@ export const sidebarData = { title: "Personnel", url: "", icon: Users, + defaultOpen: true, items: [ { title: "Contacts", diff --git a/src/components/frontend/map/AuthoringToolbar.tsx b/src/components/frontend/map/AuthoringToolbar.tsx index fc11f8b..28fc3c4 100644 --- a/src/components/frontend/map/AuthoringToolbar.tsx +++ b/src/components/frontend/map/AuthoringToolbar.tsx @@ -25,6 +25,7 @@ import { saveMapZone, } from "@/app/(frontend)/logistics/map/actions"; import type { MapPoint } from "@/lib/map/points"; +import { NODE_ICON_OPTIONS } from "@/lib/map/nodeIcons"; import type { NodeFeature, RoadFeature, ZoneFeature } from "@/lib/map/types"; export type AuthoringKind = "road" | "zone" | "node" | null; @@ -80,6 +81,8 @@ export interface MapAuthoring { setNodeDescription: (value: string) => void; resourceIds: number[]; toggleResourceId: (id: number) => void; + nodeIcon: string; + setNodeIcon: (value: string) => void; zoneResourceIds: number[]; toggleZoneResourceId: (id: number) => void; richness: string; @@ -123,6 +126,7 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void }) const [nodeName, setNodeName] = useState(""); const [nodeDescription, setNodeDescription] = useState(""); const [resourceIds, setResourceIds] = useState([]); + const [nodeIcon, setNodeIcon] = useState("marker"); const [zoneResourceIds, setZoneResourceIds] = useState([]); const [richness, setRichness] = useState("100"); @@ -212,6 +216,7 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void }) setNodeName(node.name); setNodeDescription(node.description ?? ""); setResourceIds(node.resources.map((entry) => entry.id)); + setNodeIcon(node.icon ?? "marker"); setRichness(String(node.richness ?? 100)); setEditing({ kind: "node", id: node.id, label: node.name }); }, []); @@ -272,6 +277,7 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void }) id: editing?.kind === "node" ? editing.id : undefined, mapId, name: nodeName.trim(), + icon: nodeIcon, description: nodeDescription.trim() || null, resourceIds, position: draftPoints[0], @@ -330,6 +336,7 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void }) nodeName, nodeDescription, resourceIds, + nodeIcon, zoneResourceIds, richness, onSaved, @@ -337,9 +344,7 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void }) 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 nodeReady = kind !== "node" || (draftPoints.length === 1 && nodeName.trim().length > 0); const canSave = kind != null && roadReady && zoneReady && nodeReady && !saving; return { @@ -387,6 +392,8 @@ export function useMapAuthoring(options: { mapId: number; onSaved: () => void }) setNodeDescription, resourceIds, toggleResourceId, + nodeIcon, + setNodeIcon, zoneResourceIds, toggleZoneResourceId, richness, @@ -822,6 +829,21 @@ export function AuthoringToolbar({ authoring, mapId }: { authoring: MapAuthoring className="w-full rounded-none border border-white/20 bg-black/40 px-2 py-1 text-xs text-white placeholder:text-white/30" />
+
+ + +
${paths}`; } -const nodeIcon = L.divIcon({ - className: "map-structure-icon", - html: `
${dossierSvg(NODE_SVG_PATH, "#22d3ee")}
`, - iconSize: [20, 20], - iconAnchor: [10, 10], -}); +const nodeIconCache = new Map(); + +function nodeIconFor(icon: string | null | undefined): L.DivIcon { + const key = icon ?? "marker"; + let cached = nodeIconCache.get(key); + if (!cached) { + const option = nodeIconOption(key); + cached = L.divIcon({ + className: "map-structure-icon", + html: `
${dossierSvg(option.path, "#22d3ee")}
`, + iconSize: [20, 20], + iconAnchor: [10, 10], + }); + nodeIconCache.set(key, cached); + } + return cached; +} const shipmentIconCache = new Map(); @@ -1145,8 +1150,8 @@ function ShipmentLayer({ shipments }: { shipments: ShipmentFeature[] }) { ); } -export function MapClient({ maps, canEdit, canPlace }: MapClientProps) { - const [selectedMapId, setSelectedMapId] = useState(maps[0]?.id ?? null); +export function MapClient({ map, canEdit, canPlace }: MapClientProps) { + const selectedMapId = map.id; const [features, setFeatures] = useState(null); const [error, setError] = useState(null); const [toggles, setToggles] = useState({ @@ -1304,16 +1309,6 @@ export function MapClient({ maps, canEdit, canPlace }: MapClientProps) { ]; }, [mode, authoring.kind, features]); - if (maps.length === 0) { - return ( -
-

- No active maps yet. Maps are configured in the admin panel (World: Maps). -

-
- ); - } - const toggle = (key: keyof LayerToggles) => setToggles((current) => ({ ...current, [key]: !current[key] })); @@ -1519,7 +1514,7 @@ export function MapClient({ maps, canEdit, canPlace }: MapClientProps) { {features && toggles.nodes && ( <> {nodeDots.map(({ node, position }) => ( - +
{node.name}
-
- {node.resources.map((entry) => entry.name).join(", ") || "No resources"} -
+ {node.resources.length > 0 ? ( +
+ Resources: {node.resources.map((entry) => entry.name).join(", ")} +
+ ) : null} {node.description && (
{node.description}
)} - {node.richness != null && ( + {node.richness != null && node.resources.length > 0 && (
Richness {node.richness}
)} {canEdit && mode === "edit" && ( @@ -1667,26 +1664,6 @@ export function MapClient({ maps, canEdit, canPlace }: MapClientProps) {
- - {layerToggleKeys.map(({ key, label }) => (