1
0
Fork 0
polaris-task-force/src/components/frontend/storage/StructureStorageView.tsx
Z8MB1E 59769076ff feat: add GameEventLogs collection and event emission infrastructure
- New game-event-logs collection with timestamp, type, message, actor,
  structure, targetCollection/targetId, and data (JSON) fields
- EventTypes constants registry for modular event category management
- emitGameEvent() utility for emitting events from server actions and hooks
- Register collection in Payload config
- Document event log system architecture in AGENTS.md
2026-07-28 16:08:04 -04:00

283 lines
9.3 KiB
TypeScript

"use client";
import { useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import { GameStructure, Structure, Resource } from "@/payload-types";
import { InventoryGrid } from "./InventoryGrid";
import { GridToolbar } from "./GridToolbar";
import { ResourceList } from "./ResourceList";
import { type GridItem } from "@/lib/logistics";
import { pluralize } from "@/lib/utils";
import {
Item,
ItemContent,
ItemDescription,
ItemHeader,
ItemSeparator,
ItemMedia,
ItemTitle,
} from "@/components/ui/item";
import {
PackageOpenIcon,
AlertTriangleIcon,
DropletIcon,
ZapIcon,
BrainIcon,
} from "lucide-react";
import {
moveGridItem,
mergeGridStacks,
rotateGridItem,
removeGridItem,
splitGridStack,
autoArrangeGrid,
removeResource,
retrieveFromVoid,
} from "@/app/(frontend)/logistics/structures/actions";
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" />;
}
}
interface StructureStorageViewProps {
structure: GameStructure & { type: Structure };
}
export function StructureStorageView({ structure }: StructureStorageViewProps) {
const router = useRouter();
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
const [selectedItem, setSelectedItem] = useState<GridItem | null>(null);
const storedRaw = structure.storedResources ?? [];
const gridWidth = structure.type.xSize ?? 0;
const gridHeight = structure.type.ySize ?? 0;
const hasGrid = gridWidth > 0 && gridHeight > 0;
const storedResources: GridItem[] = storedRaw
.filter((entry) => {
const res = entry.resource as Resource;
return res && typeof res === "object" && "id" in res;
})
.map((entry) => ({
id: entry.id ?? `${(entry.resource as Resource).id}-${entry.gridX}-${entry.gridY}`,
resourceId: (entry.resource as Resource).id,
amount: entry.amount,
gridX: entry.gridX ?? 0,
gridY: entry.gridY ?? 0,
rotated: entry.rotated ?? false,
resource: entry.resource as Resource,
}));
const refresh = useCallback(() => {
router.refresh();
}, [router]);
const handleMoveItem = useCallback(
async (entryId: string, gridX: number, gridY: number, rotated?: boolean) => {
const result = await moveGridItem(structure.id, entryId, gridX, gridY, rotated);
if (result.success) refresh();
},
[structure.id, refresh],
);
const handleMergeStacks = useCallback(
async (sourceId: string, targetId: string) => {
const result = await mergeGridStacks(structure.id, sourceId, targetId);
if (result.success) refresh();
},
[structure.id, refresh],
);
const handleRotateItem = useCallback(
async (entryId: string) => {
const result = await rotateGridItem(structure.id, entryId);
if (result.success) refresh();
},
[structure.id, refresh],
);
const handleRemoveItem = useCallback(
async (entryId: string) => {
const result = await removeGridItem(structure.id, entryId);
if (result.success) {
setSelectedItem(null);
refresh();
}
},
[structure.id, refresh],
);
const handleSplitStack = useCallback(
async (entryId: string, amount: number) => {
const result = await splitGridStack(structure.id, entryId, amount);
if (result.success) refresh();
},
[structure.id, refresh],
);
const handleAutoArrange = useCallback(async () => {
const result = await autoArrangeGrid(structure.id);
if (result.success) refresh();
}, [structure.id, refresh]);
const handleWithdrawFromVoid = useCallback(
async (resourceId: number, amount: number) => {
const result = await removeResource(structure.id, resourceId, amount);
if (result.success) refresh();
},
[structure.id, refresh],
);
const handleRetrieveFromVoid = useCallback(async () => {
const result = await retrieveFromVoid(structure.id);
if (result.success) refresh();
}, [structure.id, refresh]);
if (!hasGrid) {
return (
<ResourceList
storedResources={structure.storedResources ?? []}
maxCapacityMass={structure.type.maxCapacityMass ?? null}
/>
);
}
const voidStorageRaw = (structure as any).voidStorage ?? [];
const voidStorageItems = voidStorageRaw
.filter((entry: any) => {
const res = entry.resource as Resource;
return res && typeof res === "object" && "id" in res;
})
.map((entry: any) => ({
id: entry.id ?? `${(entry.resource as Resource).id}-void`,
resourceId: (entry.resource as Resource).id,
amount: entry.amount,
gridX: entry.gridX ?? 0,
gridY: entry.gridY ?? 0,
rotated: entry.rotated ?? false,
resource: entry.resource as Resource,
}));
const hasVoidStorage = voidStorageItems.length > 0;
return (
<div className="flex flex-col gap-4">
<GridToolbar
gridWidth={gridWidth}
gridHeight={gridHeight}
storedResources={storedResources}
selectedItem={selectedItem}
viewMode={viewMode}
onViewModeChange={setViewMode}
onAutoArrange={handleAutoArrange}
onRotate={handleRotateItem}
onRemove={handleRemoveItem}
onSplit={handleSplitStack}
/>
{viewMode === "grid" ? (
<InventoryGrid
structure={structure}
storedResources={storedResources}
onMoveItem={handleMoveItem}
onMergeStacks={handleMergeStacks}
onRotateItem={handleRotateItem}
onRemoveItem={handleRemoveItem}
onSelectItem={setSelectedItem}
selectedItem={selectedItem}
/>
) : (
<ResourceList
storedResources={structure.storedResources ?? []}
maxCapacityMass={structure.type.maxCapacityMass ?? null}
/>
)}
{hasVoidStorage && (
<Item variant="outline">
<ItemHeader>
<ItemTitle className="flex items-center gap-2 text-amber-400">
<AlertTriangleIcon className="size-4" />
Void Storage
</ItemTitle>
<ItemDescription className="flex items-center gap-2">
<span>
{voidStorageItems.length} item{voidStorageItems.length !== 1 ? "s" : ""} — these no longer fit in the grid.
</span>
<button
type="button"
onClick={handleRetrieveFromVoid}
className="text-xs bg-amber-500/10 text-amber-400 hover:bg-amber-500/20 px-2 py-0.5 rounded transition-colors"
>
Retrieve All to Grid
</button>
</ItemDescription>
</ItemHeader>
<ItemContent>
<div className="flex flex-col">
{voidStorageItems.map((entry: any, index: number) => {
const resource = entry.resource as Resource;
if (!resource || typeof resource === "number") return null;
const mass = (resource.massPerUnit ?? 0) * entry.amount;
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>
</ItemTitle>
</ItemContent>
<div className="text-right shrink-0">
<div className="text-sm font-medium">
{entry.amount.toLocaleString()} {pluralize(entry.amount, resource.unitOfMeasure)}
</div>
<div className="text-xs text-muted-foreground">
{mass > 0 && `${mass.toLocaleString()} kg`}
</div>
</div>
<button
type="button"
onClick={() => handleWithdrawFromVoid(entry.resourceId, entry.amount)}
className="text-xs text-muted-foreground hover:text-foreground px-2 py-1 rounded transition-colors shrink-0"
title="Withdraw from structure"
>
Withdraw
</button>
</div>
{index < voidStorageItems.length - 1 && <ItemSeparator />}
</div>
);
})}
</div>
</ItemContent>
</Item>
)}
</div>
);
}