1
0
Fork 0

feat(logistics): add game-vehicles collection and deployed vehicle views

This commit is contained in:
Jason Fraley 2026-08-02 02:03:43 -04:00
parent 9a5952ec99
commit d6ba4ccc39
4 changed files with 649 additions and 0 deletions

View file

@ -0,0 +1,320 @@
import config from "@payload-config";
import { getPayload } from "payload";
import { notFound } from "next/navigation";
import Link from "next/link";
import type {
GameVehicle,
Vehicle,
GameStructure,
Structure,
Resource,
} from "@/payload-types";
import {
ArrowLeftIcon,
FuelIcon,
HeartIcon,
MapPinIcon,
ShieldIcon,
TruckIcon,
} from "lucide-react";
import {
Item,
ItemContent,
ItemDescription,
ItemHeader,
ItemMedia,
ItemTitle,
} from "@/components/ui/item";
interface GameVehicleDetailPageProps {
params: Promise<{ id: string }>;
}
const statusColors: Record<string, string> = {
idle: "bg-emerald-500/20 text-emerald-400",
assigned: "bg-blue-500/20 text-blue-400",
in_transit: "bg-amber-500/20 text-amber-400",
damaged: "bg-red-500/20 text-red-400",
destroyed: "bg-red-500/20 text-red-400",
};
export default async function GameVehicleDetailPage({
params,
}: GameVehicleDetailPageProps) {
const { id } = await params;
const payload = await getPayload({ config });
const vehicle = await payload
.findByID({
collection: "game-vehicles",
id: Number(id),
depth: 2,
})
.catch(() => null);
if (!vehicle) {
notFound();
}
const vehicleData = vehicle as unknown as GameVehicle & {
type: Vehicle;
};
const vehicleType =
typeof vehicleData.type === "object" ? vehicleData.type : null;
const deployedAt =
typeof vehicleData.deployedAt === "object" &&
vehicleData.deployedAt !== null
? (vehicleData.deployedAt as GameStructure & { type: Structure })
: null;
const faction =
typeof vehicleData.faction === "object" && vehicleData.faction !== null
? vehicleData.faction
: null;
const fuelPercent = vehicleType?.fuel?.fuelCapacity
? (vehicleData.currentFuel / vehicleType.fuel.fuelCapacity) * 100
: 0;
const storedResources = vehicleData.storedResources ?? [];
const resourceCount = storedResources.length;
return (
<div className="p-5 flex flex-col gap-6 max-w-7xl">
<Link
href="/logistics/game-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">{vehicleData.name}</h1>
</div>
<div className="flex items-center gap-2">
<span
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${statusColors[vehicleData.status] ?? "bg-muted text-muted-foreground"}`}
>
{vehicleData.status === "in_transit"
? "In Transit"
: vehicleData.status.charAt(0).toUpperCase() +
vehicleData.status.slice(1)}
</span>
<span className="text-xs text-muted-foreground">
{vehicleType?.name ?? "Unknown type"}
</span>
</div>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Item variant="muted" size="sm">
<ItemMedia variant="icon">
<FuelIcon />
</ItemMedia>
<ItemContent>
<ItemTitle>Fuel</ItemTitle>
<ItemDescription>
{vehicleData.currentFuel?.toFixed(1)} /{" "}
{vehicleType?.fuel?.fuelCapacity ?? "?"}
</ItemDescription>
</ItemContent>
</Item>
<Item variant="muted" size="sm">
<ItemMedia variant="icon">
<HeartIcon />
</ItemMedia>
<ItemContent>
<ItemTitle>Health</ItemTitle>
<ItemDescription>
{vehicleData.currentHealth}
</ItemDescription>
</ItemContent>
</Item>
<Item variant="muted" size="sm">
<ItemMedia variant="icon">
<MapPinIcon />
</ItemMedia>
<ItemContent>
<ItemTitle>Location</ItemTitle>
<ItemDescription>
{deployedAt?.name ?? "In transit"}
</ItemDescription>
</ItemContent>
</Item>
<Item variant="muted" size="sm">
<ItemMedia variant="icon">
<ShieldIcon />
</ItemMedia>
<ItemContent>
<ItemTitle>Faction</ItemTitle>
<ItemDescription>
{faction?.name ?? "Unassigned"}
</ItemDescription>
</ItemContent>
</Item>
</div>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
<div className="xl:col-span-2 flex flex-col gap-6">
{/* Fuel Gauge */}
<Item variant="outline">
<ItemHeader>
<div className="flex items-center gap-2">
<FuelIcon className="size-4 text-muted-foreground" />
<ItemTitle>Fuel Tank</ItemTitle>
</div>
</ItemHeader>
<ItemContent className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<div className="flex-1 h-3 bg-border rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
fuelPercent > 20 ? "bg-green-500" : "bg-red-500"
}`}
style={{
width: `${Math.min(100, fuelPercent)}%`,
}}
/>
</div>
<span className="text-sm font-mono font-semibold">
{fuelPercent.toFixed(0)}%
</span>
</div>
<div className="flex justify-between text-xs text-muted-foreground">
<span>
{vehicleData.currentFuel?.toFixed(1)} units remaining
</span>
<span>
Capacity: {vehicleType?.fuel?.fuelCapacity ?? "?"} units
</span>
</div>
{vehicleType?.fuel?.fuelType &&
typeof vehicleType.fuel.fuelType === "object" && (
<div className="text-xs text-muted-foreground">
Fuel type: {vehicleType.fuel.fuelType.name} · Consumption
rate: {vehicleType.fuel.fuelConsumptionRate}/unit
</div>
)}
</ItemContent>
</Item>
{/* Cargo */}
<Item variant="outline">
<ItemHeader>
<ItemTitle>Cargo</ItemTitle>
<span className="text-xs text-muted-foreground">
{resourceCount} item type{resourceCount !== 1 ? "s" : ""}
</span>
</ItemHeader>
<ItemContent>
{storedResources.length > 0 ? (
<div className="border border-border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 border-b border-border">
<th className="text-left px-4 py-2.5 font-semibold uppercase text-xs tracking-wider text-muted-foreground">
Resource
</th>
<th className="text-right px-4 py-2.5 font-semibold uppercase text-xs tracking-wider text-muted-foreground">
Amount
</th>
</tr>
</thead>
<tbody>
{storedResources.map((entry, index) => {
const resource =
typeof entry.resource === "object"
? (entry.resource as Resource)
: null;
return (
<tr
key={index}
className="border-b border-border/50"
>
<td className="px-4 py-2 font-medium">
{resource?.name ?? `Resource #${entry.resource}`}
</td>
<td className="px-4 py-2 text-right font-mono">
{entry.amount?.toLocaleString()}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : (
<p className="text-sm text-muted-foreground italic">
Vehicle cargo is empty.
</p>
)}
</ItemContent>
</Item>
</div>
{/* Sidebar */}
<div className="flex flex-col gap-4">
{/* Vehicle Type Info */}
<Item variant="outline">
<ItemHeader>
<ItemTitle>Vehicle Type</ItemTitle>
</ItemHeader>
<ItemContent>
<div className="flex flex-col gap-2 text-sm">
<div>
<p className="font-semibold">
{vehicleType?.name ?? "Unknown"}
</p>
{vehicleType?.classname && (
<p className="text-xs text-muted-foreground font-mono">
{vehicleType.classname}
</p>
)}
</div>
{vehicleType?.category &&
vehicleType.category.length > 0 && (
<div className="flex flex-wrap gap-1">
{vehicleType.category.map((cat) => (
<span
key={cat}
className="text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted"
>
{cat}
</span>
))}
</div>
)}
</div>
</ItemContent>
</Item>
{/* Location */}
{deployedAt && (
<Link href={`/logistics/structures/${deployedAt.id}`}>
<Item
variant="outline"
className="cursor-pointer group transition-all duration-200 hover:scale-[1.01]"
>
<ItemHeader>
<div className="flex items-center gap-2">
<MapPinIcon className="size-4 text-muted-foreground" />
<ItemTitle>Stationed At</ItemTitle>
</div>
</ItemHeader>
<ItemContent>
<p className="text-sm font-semibold group-hover:text-primary transition-colors">
{deployedAt.name}
</p>
</ItemContent>
</Item>
</Link>
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,137 @@
import config from "@payload-config";
import { getPayload } from "payload";
import type { GameVehicle, Vehicle } from "@/payload-types";
import { TruckIcon } from "lucide-react";
import Link from "next/link";
import {
Item,
ItemContent,
ItemDescription,
ItemHeader,
ItemMedia,
ItemTitle,
} from "@/components/ui/item";
export const metadata = {
title: "Vehicles — Polaris Task Force",
};
export default async function GameVehiclesPage() {
const payload = await getPayload({ config });
const vehiclesRes = await payload.find({
collection: "game-vehicles",
limit: 500,
depth: 2,
});
const vehicles = vehiclesRes.docs as unknown as (GameVehicle & {
type: Vehicle;
})[];
const statusColors: Record<string, string> = {
idle: "bg-emerald-500/20 text-emerald-400",
assigned: "bg-blue-500/20 text-blue-400",
in_transit: "bg-amber-500/20 text-amber-400",
damaged: "bg-red-500/20 text-red-400",
destroyed: "bg-red-500/20 text-red-400",
};
return (
<div className="p-5 flex flex-col gap-6">
<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">Vehicles</h1>
</div>
<p className="text-sm text-muted-foreground">
{vehicles.length} vehicle{vehicles.length !== 1 ? "s" : ""} deployed.
</p>
</div>
{vehicles.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{vehicles.map((vehicle) => {
const vehicleType =
typeof vehicle.type === "object" ? vehicle.type : null;
const fuelPercent = vehicleType?.fuel?.fuelCapacity
? (vehicle.currentFuel / vehicleType.fuel.fuelCapacity) * 100
: 0;
return (
<Link
key={vehicle.id}
href={`/logistics/game-vehicles/${vehicle.id}`}
>
<Item
variant="outline"
className="cursor-pointer group transition-all duration-200 hover:scale-[1.01] hover:[box-shadow:inset_0_2px_8px_rgba(255,255,255,0.33)]"
>
<ItemHeader>
<div className="flex items-center gap-2">
<ItemMedia variant="icon">
<TruckIcon />
</ItemMedia>
<ItemTitle className="flex flex-col">
<span>{vehicle.name}</span>
<span className="text-xs font-normal text-muted-foreground">
{vehicleType?.name ?? "Unknown type"}
</span>
</ItemTitle>
</div>
<span
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${statusColors[vehicle.status] ?? "bg-muted text-muted-foreground"}`}
>
{vehicle.status === "in_transit"
? "In Transit"
: vehicle.status.charAt(0).toUpperCase() +
vehicle.status.slice(1)}
</span>
</ItemHeader>
<ItemContent>
<div className="flex flex-col gap-2 w-full">
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 bg-border rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
fuelPercent > 20
? "bg-green-500"
: "bg-red-500"
}`}
style={{
width: `${Math.min(100, fuelPercent)}%`,
}}
/>
</div>
<span className="text-[10px] font-mono text-muted-foreground">
{vehicle.currentFuel?.toFixed(0)} /{" "}
{vehicleType?.fuel?.fuelCapacity ?? "?"} fuel
</span>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>HP: {vehicle.currentHealth}</span>
{vehicleType?.fuel?.fuelType &&
typeof vehicleType.fuel.fuelType === "object" && (
<span>
{vehicleType.fuel.fuelType.name}
</span>
)}
</div>
</div>
</ItemContent>
<ItemDescription>
{vehicleType?.name ?? "Unknown type"}
</ItemDescription>
</Item>
</Link>
);
})}
</div>
) : (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<p className="text-sm">No vehicles deployed.</p>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,154 @@
import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole";
export const GameVehicles: CollectionConfig = {
slug: "game-vehicles",
admin: {
group: "Game",
useAsTitle: "name",
},
access: {
read: ({ req }) => !!req.user,
create: isDeveloper,
update: isDeveloper,
delete: isDeveloper,
},
fields: [
{
name: "name",
type: "text",
required: true,
admin: {
description: "Display name or callsign for this vehicle instance.",
},
},
{
name: "type",
type: "relationship",
relationTo: "vehicles",
required: true,
admin: {
description: "The vehicle type/template this instance is based on.",
},
},
{
name: "deployedAt",
type: "relationship",
relationTo: "game-structures",
admin: {
description: "The structure where this vehicle is currently stationed. Null if in transit.",
},
},
{
name: "faction",
type: "relationship",
relationTo: "factions",
admin: {
description: "The faction that owns this vehicle.",
},
},
{
name: "status",
type: "select",
options: [
{ label: "Idle", value: "idle" },
{ label: "Assigned", value: "assigned" },
{ label: "In Transit", value: "in_transit" },
{ label: "Damaged", value: "damaged" },
{ label: "Destroyed", value: "destroyed" },
],
defaultValue: "idle",
required: true,
},
{
name: "currentFuel",
type: "number",
required: true,
defaultValue: 0,
admin: {
description: "Current fuel level. Must be between 0 and the vehicle type's fuel capacity.",
},
},
{
name: "currentHealth",
type: "number",
required: true,
admin: {
description: "Current health. Must be between 0 and the vehicle type's max health.",
},
},
{
name: "storedResources",
type: "array",
fields: [
{
name: "resource",
type: "relationship",
relationTo: "resources",
required: true,
},
{
name: "amount",
type: "number",
required: true,
min: 1,
},
{
name: "gridX",
type: "number",
required: true,
min: 0,
},
{
name: "gridY",
type: "number",
required: true,
min: 0,
},
{
name: "rotated",
type: "checkbox",
defaultValue: false,
},
],
},
{
name: "voidStorage",
type: "array",
admin: {
description: "Items displaced by grid resizing.",
},
fields: [
{
name: "resource",
type: "relationship",
relationTo: "resources",
required: true,
},
{
name: "amount",
type: "number",
required: true,
min: 1,
},
{
name: "gridX",
type: "number",
required: true,
min: 0,
},
{
name: "gridY",
type: "number",
required: true,
min: 0,
},
{
name: "rotated",
type: "checkbox",
defaultValue: false,
},
],
},
],
};

View file

@ -92,6 +92,44 @@ export const Vehicles: CollectionConfig = {
},
],
},
{
name: "fuel",
type: "group",
admin: {
description:
"Fuel configuration for this vehicle. Defines what fuel type it uses, how much it can hold, and how fast it consumes fuel.",
},
fields: [
{
name: "fuelType",
type: "relationship",
relationTo: "resources",
required: true,
admin: {
description:
"The type of fuel this vehicle consumes (e.g., gasoline, diesel, jet fuel).",
},
},
{
name: "fuelCapacity",
type: "number",
required: true,
admin: {
description: "Maximum fuel units this vehicle's tank can hold.",
},
},
{
name: "fuelConsumptionRate",
type: "number",
required: true,
defaultValue: 1,
admin: {
description:
"Fuel consumed per unit of distance traveled.",
},
},
],
},
],
},