diff --git a/package.json b/package.json index 5d68f2c..2dfd541 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/(frontend)/logistics/storage/page.tsx b/src/app/(frontend)/logistics/storage/page.tsx index 394180a..4d05153 100644 --- a/src/app/(frontend)/logistics/storage/page.tsx +++ b/src/app/(frontend)/logistics/storage/page.tsx @@ -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; - }) => ( - <> - - - - {resource.name} - - {amount.toLocaleString()} - {/*x*/} - - - - {resource.description ? convertLexicalToPlaintext({ data: resource.description }) : ""} - - - {/* - - - - - - In Storage - {amount} - - - */} - - {includeSeparator && } - - ); + const factionsRes = await payload.find({ + collection: "factions", + depth: 0, + limit: 500, + }); - const ItemStructure = (structure: GameStructure) => ( - - -

{structure.name}

- A building. -
- {/**/} - - - Stored Resources - - - - - - {structure.storedResources?.map((res, index) => { - const resource = res.resource as Resource; - return ( - - ); - })} - - - - - - {/*{new Date(structure.updatedAt).toLocaleString()}*/} -
- ); + 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 ( -
-
- {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 ( + + + + Manage Storage + + {structure?.name ?? "Loading..."} + + + + {loading ? ( +
+ +
+ ) : ( + + + + + Deposit + + + + Withdraw + + + + Transfer + + + + +
+ + +
+
+ + setDepositAmount(e.target.value)} + /> +
+ +
+ + +
+ + +
+
+ + setWithdrawAmount(e.target.value)} + /> +
+ +
+ + +
+ + +
+
+ + +
+
+ + setTransferAmount(e.target.value)} + /> +
+ +
+
+ )} + + {message && ( +
+ {message.text} +
+ )} +
+
+ ); +} 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 ? ( + {resource.name} + ) : ( + 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; + } +}