1
0
Fork 0

feat: implement storage system with capacity tracking and resource management

- Rebuild /logistics/storage overview with capacity bars, filters, and structure cards
- Add /logistics/structures/[id] detail page with resource list, storage rules, upkeep
- Add server actions for deposit, withdraw, and transfer of resources
- Add ManageStorageDialog with deposit/withdraw/transfer tabs
- Add capacity bar component with color-coded utilization
- Add StorageGrid, StructureCard, StorageFilters, ResourceList, StorageRules components
- Add logistics utility functions for mass calculations
- Install shadcn dialog, select, tabs, label components
This commit is contained in:
Jason Fraley 2026-07-27 19:19:48 -04:00
parent 0280d564f9
commit 12d276d44d
17 changed files with 2049 additions and 96 deletions

View file

@ -51,6 +51,7 @@
"nodemailer": "^9.0.3",
"payload": "3.68.5",
"postcss": "^8.5.6",
"radix-ui": "^1.6.7",
"react": "19.2.3",
"react-dom": "19.2.3",
"sharp": "0.34.5",

View file

@ -1,113 +1,50 @@
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemGroup,
ItemHeader,
ItemMedia,
ItemSeparator,
ItemTitle,
} from "@/components/ui/item";
import config from "@payload-config";
import { getPayload } from "payload";
import { GameStructure, Resource } from "@/payload-types";
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
import { BoxIcon, PackageOpenIcon } from "lucide-react";
import { GameStructure, Faction, Structure } from "@/payload-types";
import { StorageGrid } from "@/components/frontend/storage/StorageGrid";
import { WarehouseIcon } from "lucide-react";
export default async function LogisticsStoragePage() {
// const headers = await getHeaders();
const payloadConfig = await config;
const payload = await getPayload({ config: payloadConfig });
// const { user } = await payload.auth({ headers });
// const fileURL = `webstorm://file/${fileURLToPath(import.meta.url)}`;
const structures = await payload.find({
const structuresRes = await payload.find({
collection: "game-structures",
depth: 2,
limit: 500,
});
const ItemResource = ({
resource,
amount,
includeSeparator,
}: {
resource: Resource;
amount: number;
includeSeparator: boolean;
}) => (
<>
<Item>
<ItemHeader>
<ItemTitle>
<span>{resource.name}</span>
<span className="text-xs">
<span className="text-muted-foreground">{amount.toLocaleString()}</span>
{/*<span className="text-muted-foreground font-normal">x</span>*/}
</span>
</ItemTitle>
<ItemDescription>
{resource.description ? convertLexicalToPlaintext({ data: resource.description }) : ""}
</ItemDescription>
</ItemHeader>
{/*<ItemContent>
<Item size="sm" className="w-fit">
<ItemMedia variant="icon">
<PackageOpenIcon />
</ItemMedia>
<ItemContent>
<ItemTitle>In Storage</ItemTitle>
<ItemDescription>{amount}</ItemDescription>
</ItemContent>
</Item>
</ItemContent>*/}
</Item>
{includeSeparator && <ItemSeparator />}
</>
);
const factionsRes = await payload.find({
collection: "factions",
depth: 0,
limit: 500,
});
const ItemStructure = (structure: GameStructure) => (
<Item variant="outline">
<ItemHeader>
<p>{structure.name}</p>
<ItemDescription>A building.</ItemDescription>
</ItemHeader>
{/*<ItemMedia />*/}
<ItemContent>
<Item variant="muted" size="sm">
<ItemHeader>Stored Resources</ItemHeader>
<ItemMedia variant="icon">
<BoxIcon />
</ItemMedia>
<ItemContent>
<ItemGroup className="flex flex-col">
{structure.storedResources?.map((res, index) => {
const resource = res.resource as Resource;
return (
<ItemResource
key={resource.id}
resource={resource}
amount={res.amount}
includeSeparator={index !== structure.storedResources!.length - 1}
/>
);
})}
</ItemGroup>
</ItemContent>
</Item>
</ItemContent>
<ItemActions />
{/*<ItemFooter>{new Date(structure.updatedAt).toLocaleString()}</ItemFooter>*/}
</Item>
);
const structures = structuresRes.docs as unknown as (GameStructure & {
type: Structure;
faction?: Faction | null;
})[];
const factions = factionsRes.docs as unknown as Faction[];
const categories: Structure["category"][] = ["logistics", "military", "civilian"];
return (
<div className="p-5">
<div className="flex flex-col gap-2">
{structures.docs?.map((structure) => (
<ItemStructure key={structure.id} {...structure} />
))}
<div className="p-5 flex flex-col gap-6">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<WarehouseIcon className="size-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Storage</h1>
</div>
<p className="text-sm text-muted-foreground">
Overview of all structures and their stored resources across the theater of operations.
</p>
</div>
<StorageGrid
structures={structures}
categories={categories}
factions={factions}
/>
</div>
);
}

View file

@ -0,0 +1,239 @@
import config from "@payload-config";
import { getPayload } from "payload";
import { notFound } from "next/navigation";
import Link from "next/link";
import { GameStructure, Structure, Faction, Resource } from "@/payload-types";
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
import { CapacityBar } from "@/components/frontend/storage/CapacityBar";
import { ResourceList } from "@/components/frontend/storage/ResourceList";
import { StorageRules } from "@/components/frontend/storage/StorageRules";
import {
calculateStoredMass,
calculateUtilization,
getCategoryLabel,
getFunctionTypeLabel,
getGridSlots,
} from "@/lib/logistics";
import {
ArrowLeftIcon,
BoxIcon,
Building2Icon,
CoinsIcon,
MapPinIcon,
WrenchIcon,
} from "lucide-react";
import {
Item,
ItemContent,
ItemDescription,
ItemHeader,
ItemMedia,
ItemTitle,
} from "@/components/ui/item";
import { ManageStorageButton } from "@/components/frontend/storage/ManageStorageButton";
interface StructureDetailPageProps {
params: Promise<{ id: string }>;
}
export default async function StructureDetailPage({ params }: StructureDetailPageProps) {
const { id } = await params;
const payloadConfig = await config;
const payload = await getPayload({ config: payloadConfig });
const structureRes = await payload.findByID({
collection: "game-structures",
id: Number(id),
depth: 2,
}).catch(() => null);
if (!structureRes) {
notFound();
}
const structure = structureRes as unknown as GameStructure & {
type: Structure;
faction?: Faction | null;
};
const structureType = structure.type;
const storedResources = structure.storedResources ?? [];
const currentMass = calculateStoredMass(storedResources);
const utilization = calculateUtilization(structureType, storedResources);
const gridSlots = getGridSlots(structureType);
const functionLabel = getFunctionTypeLabel(structureType.functionType);
const description = structureType.description
? convertLexicalToPlaintext({ data: structureType.description })
: null;
const totalValue = storedResources.reduce((sum, entry) => {
const resource = entry.resource as Resource;
if (!resource || typeof resource === "number") return sum;
return sum + (resource.baseValue ?? 0) * entry.amount;
}, 0);
return (
<div className="p-5 flex flex-col gap-6 max-w-4xl">
<div className="flex flex-col gap-3">
<Link
href="/logistics/storage"
className="text-sm text-muted-foreground hover:text-primary transition-colors flex items-center gap-1 w-fit"
>
<ArrowLeftIcon className="size-3" />
Back to Storage
</Link>
<div className="flex items-start justify-between gap-4">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<Building2Icon className="size-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">{structure.name}</h1>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>{structureType.name}</span>
<span>·</span>
<span>{getCategoryLabel(structureType.category)}</span>
{functionLabel && (
<>
<span>·</span>
<span>{functionLabel}</span>
</>
)}
</div>
</div>
<div className="flex items-center gap-2">
{structure.faction && typeof structure.faction === "object" && (
<span className="text-xs bg-muted px-2.5 py-1 rounded-md">
{structure.faction.name}
</span>
)}
<ManageStorageButton structureId={structure.id} />
</div>
</div>
</div>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Item variant="outline">
<ItemHeader>
<ItemTitle className="flex items-center gap-2">
<BoxIcon className="size-4 text-muted-foreground" />
Storage Capacity
</ItemTitle>
</ItemHeader>
<ItemContent>
<CapacityBar
utilization={utilization}
currentMass={currentMass}
maxMass={structureType.maxCapacityMass ?? null}
/>
</ItemContent>
</Item>
<Item variant="outline">
<ItemHeader>
<ItemTitle className="flex items-center gap-2">
<CoinsIcon className="size-4 text-muted-foreground" />
Total Value
</ItemTitle>
</ItemHeader>
<ItemContent>
<div className="flex items-center gap-2">
<span className="text-2xl font-bold">{totalValue.toLocaleString()}</span>
<span className="text-sm text-muted-foreground">base value</span>
</div>
</ItemContent>
</Item>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{structure.coordinates && (
<Item variant="muted" size="sm">
<ItemMedia variant="icon">
<MapPinIcon />
</ItemMedia>
<ItemContent>
<ItemTitle>Coordinates</ItemTitle>
<ItemDescription>
{structure.coordinates[0].toFixed(1)}, {structure.coordinates[1].toFixed(1)}
</ItemDescription>
</ItemContent>
</Item>
)}
{gridSlots !== null && (
<Item variant="muted" size="sm">
<ItemMedia variant="icon">
<BoxIcon />
</ItemMedia>
<ItemContent>
<ItemTitle>Grid</ItemTitle>
<ItemDescription>
{structureType.xSize}×{structureType.ySize} ({gridSlots} slots)
</ItemDescription>
</ItemContent>
</Item>
)}
{structureType.maxCapacityMass && (
<Item variant="muted" size="sm">
<ItemMedia variant="icon">
<BoxIcon />
</ItemMedia>
<ItemContent>
<ItemTitle>Max Mass</ItemTitle>
<ItemDescription>{structureType.maxCapacityMass.toLocaleString()} kg</ItemDescription>
</ItemContent>
</Item>
)}
{structureType.maxHealth && (
<Item variant="muted" size="sm">
<ItemMedia variant="icon">
<WrenchIcon />
</ItemMedia>
<ItemContent>
<ItemTitle>Max Health</ItemTitle>
<ItemDescription>{structureType.maxHealth.toLocaleString()}</ItemDescription>
</ItemContent>
</Item>
)}
</div>
<ResourceList
storedResources={storedResources}
maxCapacityMass={structureType.maxCapacityMass ?? null}
/>
<StorageRules
allowedStorage={structureType.allowedStorage}
prohibitedStorage={structureType.prohibitedStorage}
/>
{structureType.upkeep && structureType.upkeep.length > 0 && (
<Item variant="outline">
<ItemHeader>
<ItemTitle className="flex items-center gap-2">
<WrenchIcon 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">
{structureType.upkeep.map((upkeep, index) => {
const resource = upkeep.resource as Resource;
if (!resource || typeof resource === "number") return null;
return (
<span key={upkeep.id ?? index} className="text-sm bg-muted px-2.5 py-1 rounded-md">
{resource.name}: {upkeep.amount?.toLocaleString()} {resource.unitOfMeasure}/tick
</span>
);
})}
</div>
</ItemContent>
</Item>
)}
</div>
);
}

View file

@ -0,0 +1,289 @@
"use server";
import config from "@payload-config";
import { getPayload } from "payload";
import { GameStructure, Resource, Structure } from "@/payload-types";
interface ActionResult {
success: boolean;
error?: string;
data?: GameStructure;
}
async function authenticate() {
const payloadConfig = await config;
const payload = await getPayload({ config: payloadConfig });
const { headers } = await import("next/headers");
const hdrs = await headers();
const { user } = await payload.auth({ headers: hdrs, canSetHeaders: false });
if (!user) {
throw new Error("Unauthorized");
}
return { payload, user };
}
async function getStructure(payload: Awaited<ReturnType<typeof getPayload>>, id: number) {
const structure = await payload.findByID({
collection: "game-structures",
id,
depth: 2,
}).catch(() => null);
if (!structure) {
throw new Error("Structure not found");
}
return structure as unknown as GameStructure & { type: Structure };
}
function getStoredAmount(storedResources: GameStructure["storedResources"], resourceId: number): number {
if (!storedResources) return 0;
const entry = storedResources.find((e) => {
const res = e.resource;
return typeof res === "object" && res !== null && "id" in res
? res.id === resourceId
: res === resourceId;
});
return entry?.amount ?? 0;
}
function calculateMass(storedResources: GameStructure["storedResources"]): number {
if (!storedResources) return 0;
return storedResources.reduce((total, entry) => {
const resource = entry.resource as Resource;
if (!resource || typeof resource === "number") return total;
return total + (resource.massPerUnit ?? 0) * entry.amount;
}, 0);
}
export async function addResource(
structureId: number,
resourceId: number,
amount: number,
): Promise<ActionResult> {
try {
const { payload } = await authenticate();
if (amount <= 0) {
return { success: false, error: "Amount must be greater than zero." };
}
const structure = await getStructure(payload, structureId);
const structureType = structure.type;
// Check prohibited storage
const isProhibited = structureType.prohibitedStorage?.some((rule) => {
if (!rule.item) return false;
const item = rule.item;
if (typeof item === "object" && "id" in item) {
return item.relationTo === "resources" && item.value === resourceId;
}
return false;
});
if (isProhibited) {
return { success: false, error: "This resource is prohibited from storage at this structure." };
}
// Fetch the resource to check mass
const resource = await payload.findByID({
collection: "resources",
id: resourceId,
depth: 0,
}).catch(() => null);
if (!resource) {
return { success: false, error: "Resource not found." };
}
// Check capacity
const currentMass = calculateMass(structure.storedResources);
const addedMass = (resource.massPerUnit ?? 0) * amount;
const maxMass = structureType.maxCapacityMass;
if (maxMass && currentMass + addedMass > maxMass) {
return {
success: false,
error: `Exceeds capacity. Available: ${(maxMass - currentMass).toLocaleString()} kg, needed: ${addedMass.toLocaleString()} kg.`,
};
}
// Update stored resources
const existing = [...(structure.storedResources ?? [])];
const existingIndex = existing.findIndex((e) => {
const res = e.resource;
return typeof res === "object" && res !== null && "id" in res
? res.id === resourceId
: res === resourceId;
});
if (existingIndex >= 0) {
existing[existingIndex] = {
...existing[existingIndex],
amount: existing[existingIndex].amount + amount,
};
} else {
existing.push({ resource: resourceId, amount });
}
const updated = await payload.update({
collection: "game-structures",
id: structureId,
data: { storedResources: existing },
});
return { success: true, data: updated as unknown as GameStructure };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
if (message === "Unauthorized") {
return { success: false, error: "You must be logged in." };
}
return { success: false, error: message };
}
}
export async function removeResource(
structureId: number,
resourceId: number,
amount: number,
): Promise<ActionResult> {
try {
const { payload } = await authenticate();
if (amount <= 0) {
return { success: false, error: "Amount must be greater than zero." };
}
const structure = await getStructure(payload, structureId);
const currentAmount = getStoredAmount(structure.storedResources, resourceId);
if (currentAmount <= 0) {
return { success: false, error: "This resource is not stored here." };
}
if (amount > currentAmount) {
return {
success: false,
error: `Cannot remove more than stored. Available: ${currentAmount.toLocaleString()}.`,
};
}
const existing = [...(structure.storedResources ?? [])];
const newAmount = currentAmount - amount;
if (newAmount <= 0) {
const filtered = existing.filter((e) => {
const res = e.resource;
return !(typeof res === "object" && res !== null && "id" in res
? res.id === resourceId
: res === resourceId);
});
const updated = await payload.update({
collection: "game-structures",
id: structureId,
data: { storedResources: filtered },
});
return { success: true, data: updated as unknown as GameStructure };
}
const updatedIndex = existing.findIndex((e) => {
const res = e.resource;
return typeof res === "object" && res !== null && "id" in res
? res.id === resourceId
: res === resourceId;
});
existing[updatedIndex] = { ...existing[updatedIndex], amount: newAmount };
const updated = await payload.update({
collection: "game-structures",
id: structureId,
data: { storedResources: existing },
});
return { success: true, data: updated as unknown as GameStructure };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
if (message === "Unauthorized") {
return { success: false, error: "You must be logged in." };
}
return { success: false, error: message };
}
}
export async function transferResource(
fromStructureId: number,
toStructureId: number,
resourceId: number,
amount: number,
): Promise<ActionResult> {
try {
const { payload } = await authenticate();
if (amount <= 0) {
return { success: false, error: "Amount must be greater than zero." };
}
if (fromStructureId === toStructureId) {
return { success: false, error: "Source and destination cannot be the same." };
}
const fromStructure = await getStructure(payload, fromStructureId);
const toStructure = await getStructure(payload, toStructureId);
const sourceAmount = getStoredAmount(fromStructure.storedResources, resourceId);
if (sourceAmount < amount) {
return {
success: false,
error: `Insufficient resources. Available: ${sourceAmount.toLocaleString()}.`,
};
}
// Check destination capacity
const resource = await payload.findByID({
collection: "resources",
id: resourceId,
depth: 0,
}).catch(() => null);
if (!resource) {
return { success: false, error: "Resource not found." };
}
const destMass = calculateMass(toStructure.storedResources);
const addedMass = (resource.massPerUnit ?? 0) * amount;
const destMaxMass = toStructure.type.maxCapacityMass;
if (destMaxMass && destMass + addedMass > destMaxMass) {
return {
success: false,
error: `Destination exceeds capacity. Available: ${(destMaxMass - destMass).toLocaleString()} kg.`,
};
}
// Remove from source
const removeResult = await removeResource(fromStructureId, resourceId, amount);
if (!removeResult.success) {
return removeResult;
}
// Add to destination
const addResult = await addResource(toStructureId, resourceId, amount);
if (!addResult.success) {
// Rollback: re-add to source
await addResource(fromStructureId, resourceId, amount);
return { success: false, error: `Transfer failed at destination: ${addResult.error}` };
}
return { success: true, data: addResult.data };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
if (message === "Unauthorized") {
return { success: false, error: "You must be logged in." };
}
return { success: false, error: message };
}
}

View file

@ -0,0 +1,56 @@
"use client";
import { Progress } from "@/components/ui/progress";
import { cn } from "@/lib/utils";
interface CapacityBarProps {
utilization: number | null;
currentMass: number;
maxMass: number | null;
className?: string;
}
function getUtilizationColor(utilization: number): string {
if (utilization > 85) return "text-red-400";
if (utilization > 60) return "text-yellow-400";
return "text-green-400";
}
function getProgressColor(utilization: number): string {
if (utilization > 85) return "[&>div]:bg-red-500";
if (utilization > 60) return "[&>div]:bg-yellow-500";
return "[&>div]:bg-green-500";
}
export function CapacityBar({ utilization, currentMass, maxMass, className }: CapacityBarProps) {
if (utilization === null || maxMass === null) {
return (
<div className={cn("flex flex-col gap-1", className)}>
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Mass stored</span>
<span className="text-muted-foreground">{currentMass.toLocaleString()} kg</span>
</div>
<Progress value={0} className="h-1.5" />
</div>
);
}
const colorClass = getUtilizationColor(utilization);
const progressClass = getProgressColor(utilization);
return (
<div className={cn("flex flex-col gap-1", className)}>
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Capacity</span>
<span className={cn("font-medium", colorClass)}>
{Math.round(utilization)}%
</span>
</div>
<Progress value={utilization} className={cn("h-1.5", progressClass)} />
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{currentMass.toLocaleString()} kg</span>
<span>{maxMass.toLocaleString()} kg</span>
</div>
</div>
);
}

View file

@ -0,0 +1,28 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { SettingsIcon } from "lucide-react";
import { ManageStorageDialog } from "@/components/frontend/storage/ManageStorageDialog";
interface ManageStorageButtonProps {
structureId: number;
}
export function ManageStorageButton({ structureId }: ManageStorageButtonProps) {
const [open, setOpen] = useState(false);
return (
<>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
<SettingsIcon className="size-3.5 mr-1.5" />
Manage
</Button>
<ManageStorageDialog
structureId={structureId}
open={open}
onOpenChange={setOpen}
/>
</>
);
}

View file

@ -0,0 +1,372 @@
"use client";
import { useState, useEffect, useTransition, useCallback } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
addResource,
removeResource,
transferResource,
} from "@/app/(frontend)/logistics/structures/actions";
import { GameStructure, Structure, Resource } from "@/payload-types";
import {
ArrowDownToLineIcon,
ArrowUpFromLineIcon,
ArrowRightLeftIcon,
Loader2Icon,
} from "lucide-react";
interface ManageStorageDialogProps {
structureId: number;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ManageStorageDialog({
structureId,
open,
onOpenChange,
}: ManageStorageDialogProps) {
const [structure, setStructure] = useState<
(GameStructure & { type: Structure }) | null
>(null);
const [allStructures, setAllStructures] = useState<
(GameStructure & { type: Structure })[]
>([]);
const [allResources, setAllResources] = useState<Resource[]>([]);
const [loading, setLoading] = useState(false);
const [isPending, startTransition] = useTransition();
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
// Form state
const [depositResourceId, setDepositResourceId] = useState<string>("");
const [depositAmount, setDepositAmount] = useState<string>("1");
const [withdrawResourceId, setWithdrawResourceId] = useState<string>("");
const [withdrawAmount, setWithdrawAmount] = useState<string>("1");
const [transferToId, setTransferToId] = useState<string>("");
const [transferResourceId, setTransferResourceId] = useState<string>("");
const [transferAmount, setTransferAmount] = useState<string>("1");
const fetchData = useCallback(async () => {
setLoading(true);
try {
const [structRes, allRes, resourcesRes] = await Promise.all([
fetch(`/api/game-structures/${structureId}?depth=2`),
fetch(`/api/game-structures?depth=2&limit=500`),
fetch(`/api/resources?depth=0&limit=500`),
]);
const structData = await structRes.json();
const allData = await allRes.json();
const resourcesData = await resourcesRes.json();
setStructure(structData);
setAllStructures(
allData.docs.filter(
(s: GameStructure & { type: Structure }) => s.id !== structureId,
),
);
setAllResources(resourcesData.docs ?? []);
} catch {
setMessage({ type: "error", text: "Failed to load structure data." });
} finally {
setLoading(false);
}
}, [structureId]);
useEffect(() => {
if (open) {
fetchData();
setMessage(null);
setDepositResourceId("");
setDepositAmount("1");
setWithdrawResourceId("");
setWithdrawAmount("1");
setTransferToId("");
setTransferResourceId("");
setTransferAmount("1");
}
}, [open, fetchData]);
const storedResources = structure?.storedResources ?? [];
const structureType = structure?.type;
const isResourceAllowed = (resourceId: number): boolean => {
if (!structureType) return true;
const prohibited = structureType.prohibitedStorage?.some((rule) => {
if (!rule.item) return false;
const item = rule.item;
if (typeof item === "object" && "id" in item) {
return item.relationTo === "resources" && item.value === resourceId;
}
return false;
});
return !prohibited;
};
const handleDeposit = () => {
if (!depositResourceId) return;
const resId = Number(depositResourceId);
const amt = Number(depositAmount);
if (isNaN(amt) || amt <= 0) {
setMessage({ type: "error", text: "Amount must be a positive number." });
return;
}
startTransition(async () => {
const result = await addResource(structureId, resId, amt);
if (result.success) {
setMessage({ type: "success", text: "Resource deposited successfully." });
setDepositAmount("1");
fetchData();
} else {
setMessage({ type: "error", text: result.error ?? "Unknown error." });
}
});
};
const handleWithdraw = () => {
if (!withdrawResourceId) return;
const resId = Number(withdrawResourceId);
const amt = Number(withdrawAmount);
if (isNaN(amt) || amt <= 0) {
setMessage({ type: "error", text: "Amount must be a positive number." });
return;
}
startTransition(async () => {
const result = await removeResource(structureId, resId, amt);
if (result.success) {
setMessage({ type: "success", text: "Resource withdrawn successfully." });
setWithdrawAmount("1");
fetchData();
} else {
setMessage({ type: "error", text: result.error ?? "Unknown error." });
}
});
};
const handleTransfer = () => {
if (!transferToId || !transferResourceId) return;
const destId = Number(transferToId);
const resId = Number(transferResourceId);
const amt = Number(transferAmount);
if (isNaN(amt) || amt <= 0) {
setMessage({ type: "error", text: "Amount must be a positive number." });
return;
}
startTransition(async () => {
const result = await transferResource(structureId, destId, resId, amt);
if (result.success) {
setMessage({ type: "success", text: "Transfer completed successfully." });
setTransferAmount("1");
fetchData();
} else {
setMessage({ type: "error", text: result.error ?? "Unknown error." });
}
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Manage Storage</DialogTitle>
<DialogDescription>
{structure?.name ?? "Loading..."}
</DialogDescription>
</DialogHeader>
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2Icon className="size-6 animate-spin text-muted-foreground" />
</div>
) : (
<Tabs defaultValue="deposit" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="deposit" className="flex items-center gap-1.5">
<ArrowDownToLineIcon className="size-3.5" />
Deposit
</TabsTrigger>
<TabsTrigger value="withdraw" className="flex items-center gap-1.5">
<ArrowUpFromLineIcon className="size-3.5" />
Withdraw
</TabsTrigger>
<TabsTrigger value="transfer" className="flex items-center gap-1.5">
<ArrowRightLeftIcon className="size-3.5" />
Transfer
</TabsTrigger>
</TabsList>
<TabsContent value="deposit" className="flex flex-col gap-4 pt-4">
<div className="flex flex-col gap-2">
<Label>Resource</Label>
<Select value={depositResourceId} onValueChange={setDepositResourceId}>
<SelectTrigger>
<SelectValue placeholder="Select a resource..." />
</SelectTrigger>
<SelectContent>
{allResources
.filter((r) => isResourceAllowed(r.id))
.map((resource) => (
<SelectItem key={resource.id} value={String(resource.id)}>
{resource.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label>Amount</Label>
<Input
type="number"
min="1"
value={depositAmount}
onChange={(e) => setDepositAmount(e.target.value)}
/>
</div>
<Button
onClick={handleDeposit}
disabled={isPending || !depositResourceId}
className="w-full"
>
{isPending ? (
<Loader2Icon className="size-4 animate-spin mr-2" />
) : (
<ArrowDownToLineIcon className="size-4 mr-2" />
)}
Deposit
</Button>
</TabsContent>
<TabsContent value="withdraw" className="flex flex-col gap-4 pt-4">
<div className="flex flex-col gap-2">
<Label>Resource</Label>
<Select value={withdrawResourceId} onValueChange={setWithdrawResourceId}>
<SelectTrigger>
<SelectValue placeholder="Select a resource..." />
</SelectTrigger>
<SelectContent>
{storedResources.map((entry) => {
const resource = entry.resource as Resource;
if (!resource || typeof resource === "number") return null;
return (
<SelectItem key={entry.id ?? resource.id} value={String(resource.id)}>
{resource.name} ({entry.amount.toLocaleString()} {resource.unitOfMeasure})
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label>Amount</Label>
<Input
type="number"
min="1"
value={withdrawAmount}
onChange={(e) => setWithdrawAmount(e.target.value)}
/>
</div>
<Button
onClick={handleWithdraw}
disabled={isPending || !withdrawResourceId}
className="w-full"
variant="destructive"
>
{isPending ? (
<Loader2Icon className="size-4 animate-spin mr-2" />
) : (
<ArrowUpFromLineIcon className="size-4 mr-2" />
)}
Withdraw
</Button>
</TabsContent>
<TabsContent value="transfer" className="flex flex-col gap-4 pt-4">
<div className="flex flex-col gap-2">
<Label>Destination</Label>
<Select value={transferToId} onValueChange={setTransferToId}>
<SelectTrigger>
<SelectValue placeholder="Select a structure..." />
</SelectTrigger>
<SelectContent>
{allStructures.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label>Resource</Label>
<Select value={transferResourceId} onValueChange={setTransferResourceId}>
<SelectTrigger>
<SelectValue placeholder="Select a resource..." />
</SelectTrigger>
<SelectContent>
{storedResources.map((entry) => {
const resource = entry.resource as Resource;
if (!resource || typeof resource === "number") return null;
return (
<SelectItem key={entry.id ?? resource.id} value={String(resource.id)}>
{resource.name} ({entry.amount.toLocaleString()} {resource.unitOfMeasure})
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label>Amount</Label>
<Input
type="number"
min="1"
value={transferAmount}
onChange={(e) => setTransferAmount(e.target.value)}
/>
</div>
<Button
onClick={handleTransfer}
disabled={isPending || !transferToId || !transferResourceId}
className="w-full"
>
{isPending ? (
<Loader2Icon className="size-4 animate-spin mr-2" />
) : (
<ArrowRightLeftIcon className="size-4 mr-2" />
)}
Transfer
</Button>
</TabsContent>
</Tabs>
)}
{message && (
<div
className={`text-sm px-3 py-2 rounded-md ${
message.type === "success"
? "bg-green-500/10 text-green-400 border border-green-500/20"
: "bg-red-500/10 text-red-400 border border-red-500/20"
}`}
>
{message.text}
</div>
)}
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,123 @@
import { Resource, GameStructure } from "@/payload-types";
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
import { Progress } from "@/components/ui/progress";
import {
Item,
ItemContent,
ItemDescription,
ItemHeader,
ItemMedia,
ItemSeparator,
ItemTitle,
} from "@/components/ui/item";
import { PackageOpenIcon, DropletIcon, ZapIcon, BrainIcon } from "lucide-react";
interface ResourceListProps {
storedResources: GameStructure["storedResources"];
maxCapacityMass: number | null;
}
function getResourceIcon(type: Resource["type"]) {
switch (type) {
case "fluid":
return <DropletIcon className="size-4" />;
case "energy":
return <ZapIcon className="size-4" />;
case "abstract":
return <BrainIcon className="size-4" />;
default:
return <PackageOpenIcon className="size-4" />;
}
}
export function ResourceList({ storedResources, maxCapacityMass }: ResourceListProps) {
const resources = storedResources ?? [];
if (resources.length === 0) {
return (
<Item variant="outline">
<ItemHeader>
<ItemTitle className="flex items-center gap-2">
<PackageOpenIcon className="size-4 text-muted-foreground" />
Stored Resources
</ItemTitle>
</ItemHeader>
<ItemContent>
<p className="text-sm text-muted-foreground py-2">No resources currently stored.</p>
</ItemContent>
</Item>
);
}
return (
<Item variant="outline">
<ItemHeader>
<ItemTitle className="flex items-center gap-2">
<PackageOpenIcon className="size-4 text-muted-foreground" />
Stored Resources
</ItemTitle>
<ItemDescription>
{resources.length} resource type{resources.length !== 1 ? "s" : ""}
</ItemDescription>
</ItemHeader>
<ItemContent>
<div className="flex flex-col">
{resources.map((entry, index) => {
const resource = entry.resource as Resource;
if (!resource || typeof resource === "number") return null;
const mass = (resource.massPerUnit ?? 0) * entry.amount;
const massPercent = maxCapacityMass
? Math.min((mass / maxCapacityMass) * 100, 100)
: null;
const description = resource.description
? convertLexicalToPlaintext({ data: resource.description })
: null;
return (
<div key={entry.id ?? index}>
<div className="flex items-center gap-3 py-2.5">
<ItemMedia variant="icon">
{resource.icon ? (
<img
src={typeof resource.icon === "object" ? resource.icon.url ?? "" : ""}
alt={resource.name}
className="size-4 object-contain"
/>
) : (
getResourceIcon(resource.type)
)}
</ItemMedia>
<ItemContent className="flex-1 min-w-0">
<ItemTitle>
<span
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: resource.color ?? "#888" }}
/>
<span>{resource.name}</span>
<span className="text-xs font-normal text-muted-foreground">
{entry.amount.toLocaleString()} {resource.unitOfMeasure}
</span>
</ItemTitle>
{description && (
<ItemDescription className="line-clamp-1">
{description}
</ItemDescription>
)}
</ItemContent>
<div className="text-right shrink-0">
<div className="text-sm font-medium">{mass.toLocaleString()} kg</div>
<div className="text-xs text-muted-foreground">
{(resource.baseValue * entry.amount).toLocaleString()} value
</div>
</div>
</div>
{index < resources.length - 1 && <ItemSeparator />}
</div>
);
})}
</div>
</ItemContent>
</Item>
);
}

View file

@ -0,0 +1,71 @@
"use client";
import { useState } from "react";
import { Faction, Structure } from "@/payload-types";
import { cn } from "@/lib/utils";
import { getCategoryLabel } from "@/lib/logistics";
interface StorageFiltersProps {
categories: Structure["category"][];
factions: Faction[];
onFilterChange: (filters: { category: Structure["category"] | null; factionId: number | null }) => void;
className?: string;
}
export function StorageFilters({ categories, factions, onFilterChange, className }: StorageFiltersProps) {
const [selectedCategory, setSelectedCategory] = useState<Structure["category"] | null>(null);
const [selectedFaction, setSelectedFaction] = useState<number | null>(null);
const handleCategoryChange = (category: Structure["category"] | null) => {
const next = selectedCategory === category ? null : category;
setSelectedCategory(next);
onFilterChange({ category: next, factionId: selectedFaction });
};
const handleFactionChange = (factionId: number | null) => {
const next = selectedFaction === factionId ? null : factionId;
setSelectedFaction(next);
onFilterChange({ category: selectedCategory, factionId: next });
};
return (
<div className={cn("flex flex-wrap gap-2", className)}>
<div className="flex items-center gap-1.5">
<span className="text-xs text-muted-foreground">Category:</span>
{categories.map((cat) => (
<button
key={cat}
onClick={() => handleCategoryChange(cat)}
className={cn(
"text-xs px-2.5 py-1 rounded-md border transition-colors",
selectedCategory === cat
? "bg-primary text-primary-foreground border-primary"
: "bg-muted text-muted-foreground border-border hover:bg-accent hover:text-accent-foreground",
)}
>
{getCategoryLabel(cat)}
</button>
))}
</div>
{factions.length > 0 && (
<div className="flex items-center gap-1.5">
<span className="text-xs text-muted-foreground">Faction:</span>
{factions.map((faction) => (
<button
key={faction.id}
onClick={() => handleFactionChange(faction.id)}
className={cn(
"text-xs px-2.5 py-1 rounded-md border transition-colors",
selectedFaction === faction.id
? "bg-primary text-primary-foreground border-primary"
: "bg-muted text-muted-foreground border-border hover:bg-accent hover:text-accent-foreground",
)}
>
{faction.name}
</button>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,58 @@
"use client";
import { useState } from "react";
import { GameStructure, Faction, Structure, Resource } from "@/payload-types";
import { StructureCard } from "@/components/frontend/storage/StructureCard";
import { StorageFilters } from "@/components/frontend/storage/StorageFilters";
interface StorageGridProps {
structures: (GameStructure & { type: Structure; faction?: Faction | null })[];
categories: Structure["category"][];
factions: Faction[];
}
export function StorageGrid({ structures, categories, factions }: StorageGridProps) {
const [filters, setFilters] = useState<{
category: Structure["category"] | null;
factionId: number | null;
}>({ category: null, factionId: null });
const filtered = structures.filter((s) => {
if (filters.category && s.type.category !== filters.category) return false;
if (filters.factionId) {
const factionId =
typeof s.faction === "object" && s.faction ? s.faction.id : typeof s.faction === "number" ? s.faction : null;
if (factionId !== filters.factionId) return false;
}
return true;
});
return (
<div className="flex flex-col gap-4">
<StorageFilters
categories={categories}
factions={factions}
onFilterChange={setFilters}
/>
{filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<p className="text-sm">No structures found.</p>
{filters.category || filters.factionId ? (
<button
onClick={() => setFilters({ category: null, factionId: null })}
className="text-sm text-primary hover:underline mt-1"
>
Clear filters
</button>
) : null}
</div>
) : (
<div className="flex flex-col gap-2">
{filtered.map((structure) => (
<StructureCard key={structure.id} structure={structure} />
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,93 @@
import { Structure } from "@/payload-types";
import {
Item,
ItemContent,
ItemDescription,
ItemHeader,
ItemTitle,
} from "@/components/ui/item";
import { CheckCircleIcon, XCircleIcon, ShieldCheckIcon } from "lucide-react";
interface StorageRulesProps {
allowedStorage: Structure["allowedStorage"];
prohibitedStorage: Structure["prohibitedStorage"];
}
export function StorageRules({ allowedStorage, prohibitedStorage }: StorageRulesProps) {
const hasAllowed = allowedStorage && allowedStorage.length > 0;
const hasProhibited = prohibitedStorage && prohibitedStorage.length > 0;
if (!hasAllowed && !hasProhibited) return null;
return (
<Item variant="outline">
<ItemHeader>
<ItemTitle className="flex items-center gap-2">
<ShieldCheckIcon className="size-4 text-muted-foreground" />
Storage Rules
</ItemTitle>
</ItemHeader>
<ItemContent>
<div className="flex flex-col gap-3">
{hasAllowed && (
<div className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Allowed
</span>
<div className="flex flex-wrap gap-1.5">
{allowedStorage!.map((rule, index) => {
if (!rule.item) return null;
const item = rule.item;
const value = item.value;
const name =
typeof value === "object" && value !== null && "name" in value
? String(value.name)
: `Item #${value}`;
return (
<span
key={rule.id ?? index}
className="text-xs bg-green-500/10 text-green-400 border border-green-500/20 px-2 py-0.5 rounded-md flex items-center gap-1"
>
<CheckCircleIcon className="size-3" />
{name}
{rule.amount && (
<span className="text-green-400/60">×{rule.amount}</span>
)}
</span>
);
})}
</div>
</div>
)}
{hasProhibited && (
<div className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Prohibited
</span>
<div className="flex flex-wrap gap-1.5">
{prohibitedStorage!.map((rule, index) => {
if (!rule.item) return null;
const item = rule.item;
const value = item.value;
const name =
typeof value === "object" && value !== null && "name" in value
? String(value.name)
: `Item #${value}`;
return (
<span
key={rule.id ?? index}
className="text-xs bg-red-500/10 text-red-400 border border-red-500/20 px-2 py-0.5 rounded-md flex items-center gap-1"
>
<XCircleIcon className="size-3" />
{name}
</span>
);
})}
</div>
</div>
)}
</div>
</ItemContent>
</Item>
);
}

View file

@ -0,0 +1,118 @@
"use client";
import Link from "next/link";
import {
Item,
ItemContent,
ItemDescription,
ItemHeader,
ItemMedia,
ItemTitle,
} from "@/components/ui/item";
import { CapacityBar } from "@/components/frontend/storage/CapacityBar";
import {
getCategoryLabel,
getFunctionTypeLabel,
getGridSlots,
getResourceCount,
calculateStoredMass,
calculateUtilization,
} from "@/lib/logistics";
import { GameStructure, Faction, Structure, Resource } from "@/payload-types";
import {
BoxIcon,
Building2Icon,
ChevronRightIcon,
Grid3x3Icon,
MapPinIcon,
} from "lucide-react";
interface StructureCardProps {
structure: GameStructure & {
type: Structure;
faction?: Faction | null;
};
}
export function StructureCard({ structure }: StructureCardProps) {
const structureType = structure.type;
const storedResources = structure.storedResources ?? [];
const currentMass = calculateStoredMass(storedResources);
const utilization = calculateUtilization(structureType, storedResources);
const resourceCount = getResourceCount(storedResources);
const gridSlots = getGridSlots(structureType);
const functionLabel = getFunctionTypeLabel(structureType.functionType);
return (
<Link href={`/logistics/structures/${structure.id}`}>
<Item variant="outline" className="cursor-pointer group">
<ItemHeader>
<div className="flex items-center gap-2">
<ItemMedia variant="icon">
<Building2Icon />
</ItemMedia>
<ItemTitle className="flex flex-col">
<span>{structure.name}</span>
<span className="text-xs font-normal text-muted-foreground">
{structureType.name}
</span>
</ItemTitle>
</div>
<div className="flex items-center gap-2">
{structure.faction && typeof structure.faction === "object" && (
<span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-md">
{structure.faction.name}
</span>
)}
<span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-md">
{getCategoryLabel(structureType.category)}
</span>
</div>
</ItemHeader>
<ItemContent>
<div className="flex flex-col gap-3 w-full">
<CapacityBar
utilization={utilization}
currentMass={currentMass}
maxMass={structureType.maxCapacityMass ?? null}
/>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-3">
<span className="flex items-center gap-1">
<BoxIcon className="size-3" />
{resourceCount} resource{resourceCount !== 1 ? "s" : ""}
</span>
{gridSlots !== null && (
<span className="flex items-center gap-1">
<Grid3x3Icon className="size-3" />
{structureType.xSize}×{structureType.ySize} grid
</span>
)}
{functionLabel && (
<span className="flex items-center gap-1">
{functionLabel}
</span>
)}
</div>
{structure.coordinates && (
<span className="flex items-center gap-1">
<MapPinIcon className="size-3" />
{structure.coordinates[0].toFixed(1)}, {structure.coordinates[1].toFixed(1)}
</span>
)}
</div>
</div>
</ItemContent>
<ItemDescription>
{resourceCount === 0
? "Empty — no resources stored."
: `${resourceCount} resource type${resourceCount !== 1 ? "s" : ""} stored`}
</ItemDescription>
<span className="text-xs text-muted-foreground group-hover:text-primary transition-colors flex items-center gap-1">
View details <ChevronRightIcon className="size-3" />
</span>
</Item>
</Link>
);
}

View file

@ -0,0 +1,158 @@
"use client"
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View file

@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View file

@ -0,0 +1,190 @@
"use client"
import * as React from "react"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View file

@ -0,0 +1,91 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Tabs as TabsPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

105
src/lib/logistics.ts Normal file
View file

@ -0,0 +1,105 @@
import { GameStructure, Resource, Structure } from "@/payload-types";
/**
* Calculate the total mass of all resources stored in a game structure.
* Requires resources to be populated (depth >= 1 on storedResources.resource).
*/
export function calculateStoredMass(storedResources: GameStructure["storedResources"]): number {
if (!storedResources) return 0;
return storedResources.reduce((total, entry) => {
const resource = entry.resource as Resource;
if (!resource || typeof resource === "number") return total;
return total + (resource.massPerUnit ?? 0) * entry.amount;
}, 0);
}
/**
* Calculate the storage utilization percentage for a game structure.
* Returns a value between 0 and 100 (or null if maxCapacityMass is not set).
*/
export function calculateUtilization(
structure: Structure,
storedResources: GameStructure["storedResources"],
): number | null {
const maxMass = structure.maxCapacityMass;
if (!maxMass || maxMass <= 0) return null;
const currentMass = calculateStoredMass(storedResources);
return Math.min((currentMass / maxMass) * 100, 100);
}
/**
* Get the total number of distinct resources stored.
*/
export function getResourceCount(storedResources: GameStructure["storedResources"]): number {
if (!storedResources) return 0;
return storedResources.length;
}
/**
* Get the total grid slots available (xSize * ySize), or null if not defined.
*/
export function getGridSlots(structure: Structure): number | null {
const x = structure.xSize;
const y = structure.ySize;
if (!x || !y) return null;
return x * y;
}
/**
* Map a resource type to a fallback icon name (used when no icon upload is set).
*/
export function getResourceTypeLabel(type: Resource["type"]): string {
switch (type) {
case "physical":
return "Physical";
case "fluid":
return "Fluid";
case "energy":
return "Energy";
case "abstract":
return "Abstract";
default:
return "Resource";
}
}
/**
* Map a structure category to a display-friendly label.
*/
export function getCategoryLabel(category: Structure["category"]): string {
switch (category) {
case "logistics":
return "Logistics";
case "military":
return "Military";
case "civilian":
return "Civilian";
default:
return category;
}
}
/**
* Map a structure functionType to a display-friendly label.
*/
export function getFunctionTypeLabel(functionType: Structure["functionType"]): string | null {
if (!functionType) return null;
switch (functionType) {
case "producer":
return "Producer";
case "storage":
return "Storage";
case "sensor":
return "Sensor";
case "converter":
return "Converter";
case "defense":
return "Defense";
case "miscellaneous":
return "Miscellaneous";
default:
return functionType;
}
}