- 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
401 lines
14 KiB
TypeScript
401 lines
14 KiB
TypeScript
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 & 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>
|
||
);
|
||
}
|