1
0
Fork 0
polaris-task-force/src/app/(frontend)/logistics/vehicles/[id]/page.tsx
Z8MB1E 3797cbcc03 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
2026-07-31 20:02:25 -04:00

401 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, string> = {
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<string, string> = {
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 (
<div className="p-5 flex flex-col gap-6 max-w-7xl">
<Link
href="/logistics/vehicles"
className="text-sm text-muted-foreground hover:text-primary transition-colors flex items-center gap-1 w-fit"
>
<ArrowLeftIcon className="size-3" />
Back to Vehicles
</Link>
<div className="flex items-start justify-between gap-4">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<TruckIcon className="size-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">{vehicle.name}</h1>
</div>
<div className="flex items-center gap-2">
{(vehicle.category ?? []).map((cat) => (
<span
key={cat}
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${categoryColors[cat] ?? "bg-muted text-muted-foreground"}`}
>
{cat}
</span>
))}
</div>
</div>
</div>
{vehicle.description && (
<Item variant="outline">
<ItemHeader>
<ItemTitle>Description</ItemTitle>
</ItemHeader>
<ItemContent className="prose-invert prose-sm max-w-none [&_p]:mb-2 [&_ul]:list-disc [&_ul]:ml-4 [&_ol]:list-decimal [&_ol]:ml-4 [&_strong]:text-foreground [&_em]:italic">
<RichText data={vehicle.description} />
</ItemContent>
</Item>
)}
{materials.length > 0 && (
<Item variant="outline">
<ItemHeader>
<ItemTitle className="flex items-center gap-2">
<WrenchIcon className="size-4 text-muted-foreground" />
Construction Materials
</ItemTitle>
{vehicle.constructionTime && (
<span className="text-xs text-muted-foreground flex items-center gap-1">
<ClockIcon className="size-3" />
{vehicle.constructionTime} tick{vehicle.constructionTime !== 1 ? "s" : ""}
</span>
)}
</ItemHeader>
<ItemContent>
<div className="flex flex-wrap gap-3">
{materials.map((mat, index) => (
<span
key={index}
className="text-sm bg-muted px-2.5 py-1 rounded-md"
>
{mat.resource.name}: {mat.amount.toLocaleString()}
</span>
))}
</div>
</ItemContent>
</Item>
)}
{requiredTech.length > 0 && (
<section className="flex flex-col gap-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Required Technologies
</h2>
<div className="flex flex-wrap gap-2">
{requiredTech.map((tech) => (
<span
key={tech.id}
className="text-xs font-medium bg-muted border border-border px-2.5 py-1 rounded-md"
>
{tech.name}
</span>
))}
</div>
</section>
)}
{(vehicle.maxHealth ||
vehicle.armorRating ||
vehicle.maxSpeedOnRoad ||
vehicle.maxSpeedOffRoad ||
vehicle.maxAirspeed ||
vehicle.maxKnots) && (
<section className="flex flex-col gap-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Combat &amp; Performance
</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{vehicle.maxHealth && (
<Item variant="muted" size="sm">
<ItemMedia variant="icon">
<HeartIcon />
</ItemMedia>
<ItemContent>
<ItemTitle>Max Health</ItemTitle>
<ItemDescription>{vehicle.maxHealth.toLocaleString()}</ItemDescription>
</ItemContent>
</Item>
)}
{vehicle.armorRating && (
<Item variant="muted" size="sm">
<ItemMedia variant="icon">
<ShieldIcon />
</ItemMedia>
<ItemContent>
<ItemTitle>Armor Rating</ItemTitle>
<ItemDescription className="capitalize">
{vehicle.armorRating}
</ItemDescription>
</ItemContent>
</Item>
)}
{vehicle.maxSpeedOnRoad && (
<Item variant="muted" size="sm">
<ItemMedia variant="icon">
<GaugeIcon />
</ItemMedia>
<ItemContent>
<ItemTitle>Speed (Road)</ItemTitle>
<ItemDescription>{vehicle.maxSpeedOnRoad} km/h</ItemDescription>
</ItemContent>
</Item>
)}
{vehicle.maxSpeedOffRoad && (
<Item variant="muted" size="sm">
<ItemMedia variant="icon">
<GaugeIcon />
</ItemMedia>
<ItemContent>
<ItemTitle>Speed (Off-Road)</ItemTitle>
<ItemDescription>{vehicle.maxSpeedOffRoad} km/h</ItemDescription>
</ItemContent>
</Item>
)}
{vehicle.maxAirspeed && (
<Item variant="muted" size="sm">
<ItemMedia variant="icon">
<GaugeIcon />
</ItemMedia>
<ItemContent>
<ItemTitle>Airspeed</ItemTitle>
<ItemDescription>{vehicle.maxAirspeed} km/h</ItemDescription>
</ItemContent>
</Item>
)}
{vehicle.maxKnots && (
<Item variant="muted" size="sm">
<ItemMedia variant="icon">
<GaugeIcon />
</ItemMedia>
<ItemContent>
<ItemTitle>Max Knots</ItemTitle>
<ItemDescription>{vehicle.maxKnots} kn</ItemDescription>
</ItemContent>
</Item>
)}
</div>
</section>
)}
{(vehicle.width || vehicle.height || vehicle.maxMass || vehicle.aceCargoSlots) && (
<section className="flex flex-col gap-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Logistics
</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{(vehicle.width || vehicle.height) && (
<Item variant="muted" size="sm">
<ItemMedia variant="icon">
<BoxIcon />
</ItemMedia>
<ItemContent>
<ItemTitle>Grid Dimensions</ItemTitle>
<ItemDescription>
{vehicle.width ?? "—"}×{vehicle.height ?? "—"}
</ItemDescription>
</ItemContent>
</Item>
)}
{vehicle.maxMass && (
<Item variant="muted" size="sm">
<ItemMedia variant="icon">
<TruckIcon />
</ItemMedia>
<ItemContent>
<ItemTitle>Cargo Capacity</ItemTitle>
<ItemDescription>{vehicle.maxMass.toLocaleString()} kg</ItemDescription>
</ItemContent>
</Item>
)}
{vehicle.aceCargoSlots != null && (
<Item variant="muted" size="sm">
<ItemMedia variant="icon">
<BoxIcon />
</ItemMedia>
<ItemContent>
<ItemTitle>ACE Cargo Slots</ItemTitle>
<ItemDescription>{vehicle.aceCargoSlots}</ItemDescription>
</ItemContent>
</Item>
)}
</div>
</section>
)}
{(scrapReturns.length > 0 || upkeepCosts.length > 0) && (
<section className="flex flex-col gap-4">
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Maintenance
</h2>
{scrapReturns.length > 0 && (
<Item variant="outline">
<ItemHeader>
<ItemTitle className="flex items-center gap-2">
<WrenchIcon className="size-4 text-muted-foreground" />
Scrap Returns
</ItemTitle>
</ItemHeader>
<ItemContent>
<div className="flex flex-wrap gap-3">
{scrapReturns.map((scrap, index) => (
<span
key={index}
className="text-sm bg-muted px-2.5 py-1 rounded-md"
>
{scrap.resource.name}: {scrap.amount.toLocaleString()}
{scrap.chance < 100 && (
<span className="text-muted-foreground ml-1">
({scrap.chance}%)
</span>
)}
</span>
))}
</div>
</ItemContent>
</Item>
)}
{upkeepCosts.length > 0 && (
<Item variant="outline">
<ItemHeader>
<ItemTitle className="flex items-center gap-2">
<ClockIcon className="size-4 text-muted-foreground" />
Upkeep Costs
</ItemTitle>
<ItemDescription>Per game tick (every hour)</ItemDescription>
</ItemHeader>
<ItemContent>
<div className="flex flex-wrap gap-3">
{upkeepCosts.map((upkeep, index) => (
<span
key={index}
className="text-sm bg-muted px-2.5 py-1 rounded-md"
>
{upkeep.resource.name}: {upkeep.amount.toLocaleString()}/tick
</span>
))}
</div>
</ItemContent>
</Item>
)}
</section>
)}
{producingStructures.length > 0 && (
<section className="flex flex-col gap-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Produced By
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{producingStructures.map((structure) => (
<Link
key={structure.id}
href={`/logistics/structures/${structure.id}`}
>
<Item
variant="outline"
className="cursor-pointer group transition-all duration-200 hover:scale-[1.01]"
>
<ItemHeader>
<div className="flex items-center gap-2">
<ItemMedia variant="icon">
<Building2Icon />
</ItemMedia>
<ItemTitle>{structure.name}</ItemTitle>
</div>
</ItemHeader>
<ItemContent>
<ItemDescription className="line-clamp-1">
{convertLexicalToPlaintext({ data: structure.description }).slice(0, 120)}
</ItemDescription>
</ItemContent>
</Item>
</Link>
))}
</div>
</section>
)}
</div>
);
}