- {structures.docs?.map((structure) => (
-
- ))}
+
+
+
+
+
Storage
+
+
+ Overview of all structures and their stored resources across the theater of operations.
+
+
);
}
diff --git a/src/app/(frontend)/logistics/structures/[id]/page.tsx b/src/app/(frontend)/logistics/structures/[id]/page.tsx
new file mode 100644
index 0000000..a16c7c4
--- /dev/null
+++ b/src/app/(frontend)/logistics/structures/[id]/page.tsx
@@ -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 (
+
+
+
+
+ Back to Storage
+
+
+
+
+
+
+
{structure.name}
+
+
+ {structureType.name}
+ ·
+ {getCategoryLabel(structureType.category)}
+ {functionLabel && (
+ <>
+ ·
+ {functionLabel}
+ >
+ )}
+
+
+
+ {structure.faction && typeof structure.faction === "object" && (
+
+ {structure.faction.name}
+
+ )}
+
+
+
+
+
+ {description && (
+
{description}
+ )}
+
+
+
-
+
+
+
+ Storage Capacity
+
+
+
+
+
+
+
+
-
+
+
+
+ Total Value
+
+
+
+
+ {totalValue.toLocaleString()}
+ base value
+
+
+
+
+
+
+ {structure.coordinates && (
+ -
+
+
+
+
+ Coordinates
+
+ {structure.coordinates[0].toFixed(1)}, {structure.coordinates[1].toFixed(1)}
+
+
+
+ )}
+ {gridSlots !== null && (
+ -
+
+
+
+
+ Grid
+
+ {structureType.xSize}×{structureType.ySize} ({gridSlots} slots)
+
+
+
+ )}
+ {structureType.maxCapacityMass && (
+ -
+
+
+
+
+ Max Mass
+ {structureType.maxCapacityMass.toLocaleString()} kg
+
+
+ )}
+ {structureType.maxHealth && (
+ -
+
+
+
+
+ Max Health
+ {structureType.maxHealth.toLocaleString()}
+
+
+ )}
+
+
+
+
+
+
+ {structureType.upkeep && structureType.upkeep.length > 0 && (
+
-
+
+
+
+ Upkeep Costs
+
+ Per game tick (every hour)
+
+
+
+ {structureType.upkeep.map((upkeep, index) => {
+ const resource = upkeep.resource as Resource;
+ if (!resource || typeof resource === "number") return null;
+ return (
+
+ {resource.name}: {upkeep.amount?.toLocaleString()} {resource.unitOfMeasure}/tick
+
+ );
+ })}
+
+
+
+ )}
+
+ );
+}
diff --git a/src/app/(frontend)/logistics/structures/actions.ts b/src/app/(frontend)/logistics/structures/actions.ts
new file mode 100644
index 0000000..2b3c591
--- /dev/null
+++ b/src/app/(frontend)/logistics/structures/actions.ts
@@ -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
>, 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 {
+ 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 {
+ 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 {
+ 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 };
+ }
+}
diff --git a/src/components/frontend/storage/CapacityBar.tsx b/src/components/frontend/storage/CapacityBar.tsx
new file mode 100644
index 0000000..0c182a9
--- /dev/null
+++ b/src/components/frontend/storage/CapacityBar.tsx
@@ -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 (
+
+
+ Mass stored
+ {currentMass.toLocaleString()} kg
+
+
+
+ );
+ }
+
+ const colorClass = getUtilizationColor(utilization);
+ const progressClass = getProgressColor(utilization);
+
+ return (
+
+
+ Capacity
+
+ {Math.round(utilization)}%
+
+
+
+
+ {currentMass.toLocaleString()} kg
+ {maxMass.toLocaleString()} kg
+
+
+ );
+}
diff --git a/src/components/frontend/storage/ManageStorageButton.tsx b/src/components/frontend/storage/ManageStorageButton.tsx
new file mode 100644
index 0000000..c9df99c
--- /dev/null
+++ b/src/components/frontend/storage/ManageStorageButton.tsx
@@ -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 (
+ <>
+
+
+ >
+ );
+}
diff --git a/src/components/frontend/storage/ManageStorageDialog.tsx b/src/components/frontend/storage/ManageStorageDialog.tsx
new file mode 100644
index 0000000..8b49ef1
--- /dev/null
+++ b/src/components/frontend/storage/ManageStorageDialog.tsx
@@ -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([]);
+ 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("");
+ const [depositAmount, setDepositAmount] = useState("1");
+ const [withdrawResourceId, setWithdrawResourceId] = useState("");
+ const [withdrawAmount, setWithdrawAmount] = useState("1");
+ const [transferToId, setTransferToId] = useState("");
+ const [transferResourceId, setTransferResourceId] = useState("");
+ const [transferAmount, setTransferAmount] = useState("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 (
+
+ );
+}
diff --git a/src/components/frontend/storage/ResourceList.tsx b/src/components/frontend/storage/ResourceList.tsx
new file mode 100644
index 0000000..38cf711
--- /dev/null
+++ b/src/components/frontend/storage/ResourceList.tsx
@@ -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 ;
+ case "energy":
+ return ;
+ case "abstract":
+ return ;
+ default:
+ return ;
+ }
+}
+
+export function ResourceList({ storedResources, maxCapacityMass }: ResourceListProps) {
+ const resources = storedResources ?? [];
+
+ if (resources.length === 0) {
+ return (
+ -
+
+
+
+ Stored Resources
+
+
+
+
No resources currently stored.
+
+
+ );
+ }
+
+ return (
+ -
+
+
+
+ Stored Resources
+
+
+ {resources.length} resource type{resources.length !== 1 ? "s" : ""}
+
+
+
+
+ {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 (
+
+
+
+ {resource.icon ? (
+
+ ) : (
+ getResourceIcon(resource.type)
+ )}
+
+
+
+
+ {resource.name}
+
+ {entry.amount.toLocaleString()} {resource.unitOfMeasure}
+
+
+ {description && (
+
+ {description}
+
+ )}
+
+
+
{mass.toLocaleString()} kg
+
+ {(resource.baseValue * entry.amount).toLocaleString()} value
+
+
+
+ {index < resources.length - 1 &&
}
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/src/components/frontend/storage/StorageFilters.tsx b/src/components/frontend/storage/StorageFilters.tsx
new file mode 100644
index 0000000..13f2c42
--- /dev/null
+++ b/src/components/frontend/storage/StorageFilters.tsx
@@ -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(null);
+ const [selectedFaction, setSelectedFaction] = useState(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 (
+
+
+ Category:
+ {categories.map((cat) => (
+
+ ))}
+
+ {factions.length > 0 && (
+
+ Faction:
+ {factions.map((faction) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/components/frontend/storage/StorageGrid.tsx b/src/components/frontend/storage/StorageGrid.tsx
new file mode 100644
index 0000000..b8f0181
--- /dev/null
+++ b/src/components/frontend/storage/StorageGrid.tsx
@@ -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 (
+
+
+ {filtered.length === 0 ? (
+
+
No structures found.
+ {filters.category || filters.factionId ? (
+
+ ) : null}
+
+ ) : (
+
+ {filtered.map((structure) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/components/frontend/storage/StorageRules.tsx b/src/components/frontend/storage/StorageRules.tsx
new file mode 100644
index 0000000..1c91f7d
--- /dev/null
+++ b/src/components/frontend/storage/StorageRules.tsx
@@ -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 (
+ -
+
+
+
+ Storage Rules
+
+
+
+
+ {hasAllowed && (
+
+
+ Allowed
+
+
+ {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 (
+
+
+ {name}
+ {rule.amount && (
+ ×{rule.amount}
+ )}
+
+ );
+ })}
+
+
+ )}
+ {hasProhibited && (
+
+
+ Prohibited
+
+
+ {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 (
+
+
+ {name}
+
+ );
+ })}
+
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/frontend/storage/StructureCard.tsx b/src/components/frontend/storage/StructureCard.tsx
new file mode 100644
index 0000000..ea05563
--- /dev/null
+++ b/src/components/frontend/storage/StructureCard.tsx
@@ -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 (
+
+ -
+
+
+
+
+
+
+ {structure.name}
+
+ {structureType.name}
+
+
+
+
+ {structure.faction && typeof structure.faction === "object" && (
+
+ {structure.faction.name}
+
+ )}
+
+ {getCategoryLabel(structureType.category)}
+
+
+
+
+
+
+
+
+
+
+ {resourceCount} resource{resourceCount !== 1 ? "s" : ""}
+
+ {gridSlots !== null && (
+
+
+ {structureType.xSize}×{structureType.ySize} grid
+
+ )}
+ {functionLabel && (
+
+ {functionLabel}
+
+ )}
+
+ {structure.coordinates && (
+
+
+ {structure.coordinates[0].toFixed(1)}, {structure.coordinates[1].toFixed(1)}
+
+ )}
+
+
+
+
+ {resourceCount === 0
+ ? "Empty — no resources stored."
+ : `${resourceCount} resource type${resourceCount !== 1 ? "s" : ""} stored`}
+
+
+ View details
+
+
+
+ );
+}
diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx
new file mode 100644
index 0000000..84bdef4
--- /dev/null
+++ b/src/components/ui/dialog.tsx
@@ -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) {
+ return
+}
+
+function DialogTrigger({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogPortal({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogClose({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DialogContent({
+ className,
+ children,
+ showCloseButton = true,
+ ...props
+}: React.ComponentProps & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+
+ Close
+
+ )}
+
+
+ )
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function DialogFooter({
+ className,
+ showCloseButton = false,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+ {children}
+ {showCloseButton && (
+
+
+
+ )}
+
+ )
+}
+
+function DialogTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+}
diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx
new file mode 100644
index 0000000..1ac80f7
--- /dev/null
+++ b/src/components/ui/label.tsx
@@ -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) {
+ return (
+
+ )
+}
+
+export { Label }
diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx
new file mode 100644
index 0000000..c0dc712
--- /dev/null
+++ b/src/components/ui/select.tsx
@@ -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) {
+ return
+}
+
+function SelectGroup({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SelectValue({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SelectTrigger({
+ className,
+ size = "default",
+ children,
+ ...props
+}: React.ComponentProps & {
+ size?: "sm" | "default"
+}) {
+ return (
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectContent({
+ className,
+ children,
+ position = "item-aligned",
+ align = "center",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectLabel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SelectItem({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function SelectSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SelectScrollUpButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function SelectScrollDownButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+}
diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx
new file mode 100644
index 0000000..b463afd
--- /dev/null
+++ b/src/components/ui/tabs.tsx
@@ -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) {
+ return (
+
+ )
+}
+
+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 &
+ VariantProps) {
+ return (
+
+ )
+}
+
+function TabsTrigger({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function TabsContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
diff --git a/src/lib/logistics.ts b/src/lib/logistics.ts
new file mode 100644
index 0000000..e4bdfd6
--- /dev/null
+++ b/src/lib/logistics.ts
@@ -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;
+ }
+}