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:
parent
0280d564f9
commit
12d276d44d
17 changed files with 2049 additions and 96 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
239
src/app/(frontend)/logistics/structures/[id]/page.tsx
Normal file
239
src/app/(frontend)/logistics/structures/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
289
src/app/(frontend)/logistics/structures/actions.ts
Normal file
289
src/app/(frontend)/logistics/structures/actions.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
56
src/components/frontend/storage/CapacityBar.tsx
Normal file
56
src/components/frontend/storage/CapacityBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
src/components/frontend/storage/ManageStorageButton.tsx
Normal file
28
src/components/frontend/storage/ManageStorageButton.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
372
src/components/frontend/storage/ManageStorageDialog.tsx
Normal file
372
src/components/frontend/storage/ManageStorageDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
123
src/components/frontend/storage/ResourceList.tsx
Normal file
123
src/components/frontend/storage/ResourceList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
src/components/frontend/storage/StorageFilters.tsx
Normal file
71
src/components/frontend/storage/StorageFilters.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
src/components/frontend/storage/StorageGrid.tsx
Normal file
58
src/components/frontend/storage/StorageGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
93
src/components/frontend/storage/StorageRules.tsx
Normal file
93
src/components/frontend/storage/StorageRules.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
118
src/components/frontend/storage/StructureCard.tsx
Normal file
118
src/components/frontend/storage/StructureCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
158
src/components/ui/dialog.tsx
Normal file
158
src/components/ui/dialog.tsx
Normal 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,
|
||||
}
|
||||
24
src/components/ui/label.tsx
Normal file
24
src/components/ui/label.tsx
Normal 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 }
|
||||
190
src/components/ui/select.tsx
Normal file
190
src/components/ui/select.tsx
Normal 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,
|
||||
}
|
||||
91
src/components/ui/tabs.tsx
Normal file
91
src/components/ui/tabs.tsx
Normal 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
105
src/lib/logistics.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue