From 3797cbcc03e68d1d568854d6266852a29c30bcbb Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Fri, 31 Jul 2026 20:02:25 -0400 Subject: [PATCH] feat: add logistics pages for resources and vehicles - Add resources list page with resource cards and detail pages - Add vehicles list page with vehicle cards, filters, and detail pages - Add ResourceCard, VehicleCard, and VehicleFilters components - Widen structure detail page max-width from 4xl to 7xl --- .../logistics/resources/[id]/page.tsx | 292 +++++++++++++ .../(frontend)/logistics/resources/page.tsx | 47 ++ .../logistics/structures/[id]/page.tsx | 2 +- .../logistics/vehicles/VehiclesList.tsx | 45 ++ .../logistics/vehicles/[id]/page.tsx | 401 ++++++++++++++++++ .../(frontend)/logistics/vehicles/page.tsx | 37 ++ .../frontend/logistics/ResourceCard.tsx | 178 ++++++++ .../frontend/logistics/VehicleCard.tsx | 170 ++++++++ .../frontend/logistics/VehicleFilters.tsx | 50 +++ 9 files changed, 1221 insertions(+), 1 deletion(-) create mode 100644 src/app/(frontend)/logistics/resources/[id]/page.tsx create mode 100644 src/app/(frontend)/logistics/resources/page.tsx create mode 100644 src/app/(frontend)/logistics/vehicles/VehiclesList.tsx create mode 100644 src/app/(frontend)/logistics/vehicles/[id]/page.tsx create mode 100644 src/app/(frontend)/logistics/vehicles/page.tsx create mode 100644 src/components/frontend/logistics/ResourceCard.tsx create mode 100644 src/components/frontend/logistics/VehicleCard.tsx create mode 100644 src/components/frontend/logistics/VehicleFilters.tsx diff --git a/src/app/(frontend)/logistics/resources/[id]/page.tsx b/src/app/(frontend)/logistics/resources/[id]/page.tsx new file mode 100644 index 0000000..da44c11 --- /dev/null +++ b/src/app/(frontend)/logistics/resources/[id]/page.tsx @@ -0,0 +1,292 @@ +import config from "@payload-config"; +import { getPayload } from "payload"; +import { notFound } from "next/navigation"; +import Link from "next/link"; +import type { Resource, Structure, Asset } from "@/payload-types"; +import { RichText } from "@payloadcms/richtext-lexical/react"; +import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext"; +import { + ArrowLeftIcon, + BoxIcon, + Building2Icon, + CoinsIcon, + Grid3x3Icon, + LayersIcon, + MoveIcon, + RotateCcwIcon, + ScaleIcon, + TagIcon, + WeightIcon, +} from "lucide-react"; +import { + Item, + ItemContent, + ItemDescription, + ItemHeader, + ItemMedia, + ItemTitle, +} from "@/components/ui/item"; + +interface ResourceDetailPageProps { + params: Promise<{ id: string }>; +} + +const rarityColors: Record = { + common: "bg-muted text-muted-foreground", + uncommon: "bg-green-500/20 text-green-400", + rare: "bg-blue-500/20 text-blue-400", + epic: "bg-purple-500/20 text-purple-400", + legendary: "bg-amber-500/20 text-amber-400", + exotic: "bg-pink-500/20 text-pink-400", + unique: "bg-red-500/20 text-red-400", +}; + +const typeColors: Record = { + physical: "bg-muted text-muted-foreground", + fluid: "bg-blue-500/20 text-blue-400", + energy: "bg-amber-500/20 text-amber-400", + abstract: "bg-purple-500/20 text-purple-400", +}; + +export default async function ResourceDetailPage({ + params, +}: ResourceDetailPageProps) { + const { id } = await params; + const payload = await getPayload({ config }); + + const resource = await payload + .findByID({ + collection: "resources", + id: Number(id), + depth: 2, + }) + .catch(() => null); + + if (!resource) { + notFound(); + } + + const [producersRes, autoGenRes, craftingRes] = await Promise.all([ + payload + .find({ + collection: "structures", + where: { + "canProduce.value": { equals: resource.id }, + }, + limit: 50, + depth: 1, + }) + .catch(() => ({ docs: [] })), + payload + .find({ + collection: "structures", + where: { + "autoGeneratedResources.resource": { equals: resource.id }, + }, + limit: 50, + depth: 1, + }) + .catch(() => ({ docs: [] })), + payload + .find({ + collection: "assets", + where: { + "crafting.craftingData.inputs.resource": { equals: resource.id }, + }, + limit: 50, + depth: 1, + }) + .catch(() => ({ docs: [] })), + ]); + + const producers = producersRes.docs as unknown as Structure[]; + const autoGenStructures = autoGenRes.docs as unknown as Structure[]; + const craftingAssets = craftingRes.docs as unknown as Asset[]; + + const allRelatedStructures = [ + ...producers, + ...autoGenStructures.filter( + (s) => !producers.some((p) => p.id === s.id), + ), + ]; + + return ( +
+ + + Back to Resources + + +
+
+
+ {resource.color ? ( +
+ ) : ( + + )} +

{resource.name}

+
+
+ + {resource.type} + + + {resource.rarity} + +
+
+
+ + {resource.description && ( + + + Description + + + + + + )} + +
+ + + + + + Base Value + {resource.baseValue.toLocaleString()} + + + + + + + + Unit of Measure + {resource.unitOfMeasure} + + + + + + + + Mass Per Unit + {resource.massPerUnit.toLocaleString()} kg + + + + + + + + Stack Size + {resource.stackSize ?? 1} + + + + + + + + Grid + + {resource.gridWidth}×{resource.gridHeight} + + + + + + + + + Stackable + {resource.stackable ? "Yes" : "No"} + + + + + + + + Rotatable + {resource.rotatable ? "Yes" : "No"} + + + + + + + + Tradeable + {resource.tradeable ? "Yes" : "No"} + + +
+ + {allRelatedStructures.length > 0 && ( +
+

+ Related Structures +

+
+ {allRelatedStructures.map((structure) => ( + + + +
+ + + + {structure.name} +
+
+ + + {convertLexicalToPlaintext({ data: structure.description }).slice(0, 120)} + + +
+ + ))} +
+
+ )} + + {craftingAssets.length > 0 && ( +
+

+ Used in Crafting +

+
+ {craftingAssets.map((asset) => ( + + + {asset.name} + + + ))} +
+
+ )} +
+ ); +} diff --git a/src/app/(frontend)/logistics/resources/page.tsx b/src/app/(frontend)/logistics/resources/page.tsx new file mode 100644 index 0000000..ebd3202 --- /dev/null +++ b/src/app/(frontend)/logistics/resources/page.tsx @@ -0,0 +1,47 @@ +import config from "@payload-config"; +import { getPayload } from "payload"; +import type { Resource } from "@/payload-types"; +import { PackageOpenIcon } from "lucide-react"; +import { ResourceCard } from "@/components/frontend/logistics/ResourceCard"; + +export const metadata = { + title: "Resources — Polaris Task Force", +}; + +export default async function ResourcesPage() { + const payload = await getPayload({ config }); + + const resourcesRes = await payload.find({ + collection: "resources", + limit: 500, + depth: 1, + }); + + const resources = resourcesRes.docs as Resource[]; + + return ( +
+
+
+ +

Resources

+
+

+ Resource catalog for the theater of operations. +

+
+ + {resources.length > 0 ? ( +
+ {resources.map((resource) => ( + + ))} +
+ ) : ( +

+ No resources available. +

+ )} +
+ ); +} diff --git a/src/app/(frontend)/logistics/structures/[id]/page.tsx b/src/app/(frontend)/logistics/structures/[id]/page.tsx index 0eef2a5..e6ae9f6 100644 --- a/src/app/(frontend)/logistics/structures/[id]/page.tsx +++ b/src/app/(frontend)/logistics/structures/[id]/page.tsx @@ -74,7 +74,7 @@ export default async function StructureDetailPage({ params }: StructureDetailPag }, 0); return ( -
+
(null); + + const filtered = useMemo(() => { + if (!selectedCategory) return vehicles; + return vehicles.filter( + (v) => v.category?.includes(selectedCategory as NonNullable[number]), + ); + }, [vehicles, selectedCategory]); + + return ( + <> + + {filtered.length > 0 ? ( +
+ {filtered.map((vehicle) => ( + + ))} +
+ ) : ( +

+ No vehicles match the selected filters. +

+ )} + + ); +} diff --git a/src/app/(frontend)/logistics/vehicles/[id]/page.tsx b/src/app/(frontend)/logistics/vehicles/[id]/page.tsx new file mode 100644 index 0000000..97d9518 --- /dev/null +++ b/src/app/(frontend)/logistics/vehicles/[id]/page.tsx @@ -0,0 +1,401 @@ +import config from "@payload-config"; +import { getPayload } from "payload"; +import { notFound } from "next/navigation"; +import Link from "next/link"; +import type { Vehicle, Resource, Technology, Structure } from "@/payload-types"; +import { RichText } from "@payloadcms/richtext-lexical/react"; +import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext"; +import { + ArrowLeftIcon, + BoxIcon, + Building2Icon, + ClockIcon, + CrosshairIcon, + GaugeIcon, + HeartIcon, + ShieldIcon, + TruckIcon, + WrenchIcon, +} from "lucide-react"; +import { + Item, + ItemContent, + ItemDescription, + ItemHeader, + ItemMedia, + ItemTitle, +} from "@/components/ui/item"; + +interface VehicleDetailPageProps { + params: Promise<{ id: string }>; +} + +const categoryColors: Record = { + civilian: "bg-muted text-muted-foreground", + utility: "bg-blue-500/20 text-blue-400", + transport: "bg-green-500/20 text-green-400", + weaponized: "bg-red-500/20 text-red-400", + military: "bg-amber-500/20 text-amber-400", +}; + +const armorColors: Record = { + none: "bg-muted text-muted-foreground", + light: "bg-green-500/20 text-green-400", + medium: "bg-amber-500/20 text-amber-400", + heavy: "bg-red-500/20 text-red-400", + composite: "bg-purple-500/20 text-purple-400", +}; + +export default async function VehicleDetailPage({ + params, +}: VehicleDetailPageProps) { + const { id } = await params; + const payload = await getPayload({ config }); + + const vehicle = await payload + .findByID({ + collection: "vehicles", + id: Number(id), + depth: 2, + }) + .catch(() => null); + + if (!vehicle) { + notFound(); + } + + const materials = (vehicle.materials ?? []).map((m) => ({ + resource: m.resource as Resource, + amount: m.amount ?? 0, + })); + + const requiredTech = (vehicle.requiredTech ?? []) as Technology[]; + + const scrapReturns = (vehicle.scrapReturns ?? []).map((s) => ({ + resource: s.resource as Resource, + amount: s.amount ?? 0, + chance: s.chance ?? 100, + })); + + const upkeepCosts = (vehicle.upkeepCosts ?? []).map((u) => ({ + resource: u.resource as Resource, + amount: u.amount ?? 0, + })); + + const producingStructures = + vehicle.requiredStructure?.docs?.filter( + (s): s is Structure => typeof s === "object" && s !== null, + ) ?? []; + + return ( +
+ + + Back to Vehicles + + +
+
+
+ +

{vehicle.name}

+
+
+ {(vehicle.category ?? []).map((cat) => ( + + {cat} + + ))} +
+
+
+ + {vehicle.description && ( + + + Description + + + + + + )} + + {materials.length > 0 && ( + + + + + Construction Materials + + {vehicle.constructionTime && ( + + + {vehicle.constructionTime} tick{vehicle.constructionTime !== 1 ? "s" : ""} + + )} + + +
+ {materials.map((mat, index) => ( + + {mat.resource.name}: {mat.amount.toLocaleString()} + + ))} +
+
+
+ )} + + {requiredTech.length > 0 && ( +
+

+ Required Technologies +

+
+ {requiredTech.map((tech) => ( + + {tech.name} + + ))} +
+
+ )} + + {(vehicle.maxHealth || + vehicle.armorRating || + vehicle.maxSpeedOnRoad || + vehicle.maxSpeedOffRoad || + vehicle.maxAirspeed || + vehicle.maxKnots) && ( +
+

+ Combat & Performance +

+
+ {vehicle.maxHealth && ( + + + + + + Max Health + {vehicle.maxHealth.toLocaleString()} + + + )} + {vehicle.armorRating && ( + + + + + + Armor Rating + + {vehicle.armorRating} + + + + )} + {vehicle.maxSpeedOnRoad && ( + + + + + + Speed (Road) + {vehicle.maxSpeedOnRoad} km/h + + + )} + {vehicle.maxSpeedOffRoad && ( + + + + + + Speed (Off-Road) + {vehicle.maxSpeedOffRoad} km/h + + + )} + {vehicle.maxAirspeed && ( + + + + + + Airspeed + {vehicle.maxAirspeed} km/h + + + )} + {vehicle.maxKnots && ( + + + + + + Max Knots + {vehicle.maxKnots} kn + + + )} +
+
+ )} + + {(vehicle.width || vehicle.height || vehicle.maxMass || vehicle.aceCargoSlots) && ( +
+

+ Logistics +

+
+ {(vehicle.width || vehicle.height) && ( + + + + + + Grid Dimensions + + {vehicle.width ?? "—"}×{vehicle.height ?? "—"} + + + + )} + {vehicle.maxMass && ( + + + + + + Cargo Capacity + {vehicle.maxMass.toLocaleString()} kg + + + )} + {vehicle.aceCargoSlots != null && ( + + + + + + ACE Cargo Slots + {vehicle.aceCargoSlots} + + + )} +
+
+ )} + + {(scrapReturns.length > 0 || upkeepCosts.length > 0) && ( +
+

+ Maintenance +

+ + {scrapReturns.length > 0 && ( + + + + + Scrap Returns + + + +
+ {scrapReturns.map((scrap, index) => ( + + {scrap.resource.name}: {scrap.amount.toLocaleString()} + {scrap.chance < 100 && ( + + ({scrap.chance}%) + + )} + + ))} +
+
+
+ )} + + {upkeepCosts.length > 0 && ( + + + + + Upkeep Costs + + Per game tick (every hour) + + +
+ {upkeepCosts.map((upkeep, index) => ( + + {upkeep.resource.name}: {upkeep.amount.toLocaleString()}/tick + + ))} +
+
+
+ )} +
+ )} + + {producingStructures.length > 0 && ( +
+

+ Produced By +

+
+ {producingStructures.map((structure) => ( + + + +
+ + + + {structure.name} +
+
+ + + {convertLexicalToPlaintext({ data: structure.description }).slice(0, 120)} + + +
+ + ))} +
+
+ )} +
+ ); +} diff --git a/src/app/(frontend)/logistics/vehicles/page.tsx b/src/app/(frontend)/logistics/vehicles/page.tsx new file mode 100644 index 0000000..6c43669 --- /dev/null +++ b/src/app/(frontend)/logistics/vehicles/page.tsx @@ -0,0 +1,37 @@ +import config from "@payload-config"; +import { getPayload } from "payload"; +import type { Vehicle } from "@/payload-types"; +import { TruckIcon } from "lucide-react"; +import { VehiclesList } from "./VehiclesList"; + +export const metadata = { + title: "Vehicles — Polaris Task Force", +}; + +export default async function VehiclesPage() { + const payload = await getPayload({ config }); + + const vehiclesRes = await payload.find({ + collection: "vehicles", + limit: 500, + depth: 1, + }); + + const vehicles = vehiclesRes.docs as Vehicle[]; + + return ( +
+
+
+ +

Vehicles

+
+

+ Vehicle catalog for deployment and operations. +

+
+ + +
+ ); +} diff --git a/src/components/frontend/logistics/ResourceCard.tsx b/src/components/frontend/logistics/ResourceCard.tsx new file mode 100644 index 0000000..ef50807 --- /dev/null +++ b/src/components/frontend/logistics/ResourceCard.tsx @@ -0,0 +1,178 @@ +"use client"; + +import Link from "next/link"; +import { + Item, + ItemContent, + ItemDescription, + ItemHeader, + ItemMedia, + ItemTitle, +} from "@/components/ui/item"; +import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext"; +import { + BrainIcon, + ChevronRightIcon, + DropletIcon, + HandshakeIcon, + PackageOpenIcon, + ScaleIcon, + ZapIcon, +} from "lucide-react"; + +interface ResourceCardProps { + resource: { + id: number; + name: string; + codeName: string; + type: "physical" | "fluid" | "energy" | "abstract"; + rarity: string; + baseValue: number; + unitOfMeasure: string; + massPerUnit: number; + icon?: { url: string } | number | null; + color?: string | null; + tradeable?: boolean | null; + description?: { + root: { + type: string; + children: { + type: any; + version: number; + [k: string]: unknown; + }[]; + direction: ("ltr" | "rtl") | null; + format: "left" | "start" | "center" | "right" | "end" | "justify" | ""; + indent: number; + version: number; + }; + [k: string]: unknown; + } | null; + }; +} + +const typeColors: Record = { + physical: "bg-stone-500/20 text-stone-400", + fluid: "bg-blue-500/20 text-blue-400", + energy: "bg-amber-500/20 text-amber-400", + abstract: "bg-purple-500/20 text-purple-400", +}; + +const typeIcons: Record = { + physical: , + fluid: , + energy: , + abstract: , +}; + +const rarityColors: Record = { + common: "bg-muted text-muted-foreground", + uncommon: "bg-green-500/20 text-green-400", + rare: "bg-blue-500/20 text-blue-400", + epic: "bg-purple-500/20 text-purple-400", + legendary: "bg-amber-500/20 text-amber-400", + exotic: "bg-pink-500/20 text-pink-400", + unique: "bg-red-500/20 text-red-400", +}; + +const rarityLabels: Record = { + common: "Common", + uncommon: "Uncommon", + rare: "Rare", + epic: "Epic", + legendary: "Legendary", + exotic: "Exotic", + unique: "Unique", +}; + +export function ResourceCard({ resource }: ResourceCardProps) { + const icon = + resource.icon && typeof resource.icon === "object" ? resource.icon : null; + const description = resource.description + ? convertLexicalToPlaintext({ data: resource.description }) + : null; + const truncatedDescription = + description && description.length > 150 + ? description.slice(0, 150) + "…" + : description; + + return ( + + + +
+ {icon ? ( + + {resource.name} + + ) : resource.color ? ( + + + + ) : ( + {typeIcons[resource.type]} + )} + + + {resource.codeName} + + {resource.name} + +
+
+ + {resource.type} + + + {rarityLabels[resource.rarity] ?? resource.rarity} + +
+
+ +
+ {truncatedDescription && ( +

+ {truncatedDescription} +

+ )} +
+ + {resource.baseValue.toLocaleString()} credits + + + + {resource.massPerUnit.toLocaleString()} kg/{resource.unitOfMeasure} + + {resource.tradeable && ( + + + Tradeable + + )} +
+
+
+ + {truncatedDescription ?? `${resource.type} resource — ${resource.unitOfMeasure}`} + + + View details + +
+ + ); +} diff --git a/src/components/frontend/logistics/VehicleCard.tsx b/src/components/frontend/logistics/VehicleCard.tsx new file mode 100644 index 0000000..950f287 --- /dev/null +++ b/src/components/frontend/logistics/VehicleCard.tsx @@ -0,0 +1,170 @@ +"use client"; + +import Link from "next/link"; +import { + Item, + ItemContent, + ItemDescription, + ItemHeader, + ItemMedia, + ItemTitle, +} from "@/components/ui/item"; +import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext"; +import { + ChevronRightIcon, + HeartIcon, + TimerIcon, + TruckIcon, +} from "lucide-react"; + +interface VehicleCardProps { + vehicle: { + id: number; + name: string; + classname?: string | null; + category?: string[] | null; + description?: { + root: { + type: string; + children: { + type: any; + version: number; + [k: string]: unknown; + }[]; + direction: ("ltr" | "rtl") | null; + format: "left" | "start" | "center" | "right" | "end" | "justify" | ""; + indent: number; + version: number; + }; + [k: string]: unknown; + } | null; + image?: { url: string } | number | null; + construction?: { + materials?: { resource: { name: string } | number; amount: number }[]; + constructionTime?: number; + }; + combat?: { + maxHealth?: number; + maxSpeedOnRoad?: number; + }; + }; +} + +const categoryColors: Record = { + civilian: "bg-stone-500/20 text-stone-400", + utility: "bg-emerald-500/20 text-emerald-400", + transport: "bg-blue-500/20 text-blue-400", + weaponized: "bg-red-500/20 text-red-400", + military: "bg-amber-500/20 text-amber-400", +}; + +const categoryLabels: Record = { + civilian: "Civilian", + utility: "Utility", + transport: "Transport", + weaponized: "Weaponized", + military: "Military", +}; + +export function VehicleCard({ vehicle }: VehicleCardProps) { + const image = + vehicle.image && typeof vehicle.image === "object" ? vehicle.image : null; + const description = vehicle.description + ? convertLexicalToPlaintext({ data: vehicle.description }) + : null; + const truncatedDescription = + description && description.length > 150 + ? description.slice(0, 150) + "…" + : description; + + const materialCount = vehicle.construction?.materials?.length ?? 0; + const constructionTime = vehicle.construction?.constructionTime; + const maxHealth = vehicle.combat?.maxHealth; + const maxSpeed = vehicle.combat?.maxSpeedOnRoad; + + return ( + + + +
+ {image ? ( + + {vehicle.name} + + ) : ( + + + + )} + + {vehicle.classname && ( + + {vehicle.classname} + + )} + {vehicle.name} + +
+
+ {vehicle.category?.map((cat) => ( + + {categoryLabels[cat] ?? cat} + + ))} +
+
+ +
+ {truncatedDescription && ( +

+ {truncatedDescription} +

+ )} +
+ {materialCount > 0 && ( + + {materialCount} material{materialCount !== 1 ? "s" : ""} + {constructionTime != null && ( + <> + ,{" "} + + {constructionTime} tick{constructionTime !== 1 ? "s" : ""} + + )} + + )} + {maxHealth != null && ( + + + {maxHealth.toLocaleString()} HP + + )} + {maxSpeed != null && ( + + + {maxSpeed} km/h + + )} +
+
+
+ + {truncatedDescription ?? vehicle.classname ?? "Vehicle"} + + + View details + +
+ + ); +} diff --git a/src/components/frontend/logistics/VehicleFilters.tsx b/src/components/frontend/logistics/VehicleFilters.tsx new file mode 100644 index 0000000..af9bb74 --- /dev/null +++ b/src/components/frontend/logistics/VehicleFilters.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +interface VehicleFiltersProps { + categories: string[]; + onFilterChange: (category: string | null) => void; +} + +const categoryLabels: Record = { + civilian: "Civilian", + utility: "Utility", + transport: "Transport", + weaponized: "Weaponized", + military: "Military", +}; + +export function VehicleFilters({ categories, onFilterChange }: VehicleFiltersProps) { + const [selected, setSelected] = useState(null); + + const handleChange = (category: string | null) => { + const next = selected === category ? null : category; + setSelected(next); + onFilterChange(next); + }; + + return ( +
+ + {categories.map((cat) => ( + + ))} +
+ ); +}