diff --git a/src/components/frontend/storage/GridCell.tsx b/src/components/frontend/storage/GridCell.tsx
new file mode 100644
index 0000000..33c7f79
--- /dev/null
+++ b/src/components/frontend/storage/GridCell.tsx
@@ -0,0 +1,34 @@
+"use client";
+
+import { useDroppable } from "@dnd-kit/core";
+import { cn } from "@/lib/utils";
+
+interface GridCellProps {
+ x: number;
+ y: number;
+ cellSize: number;
+ isOccupied: boolean;
+ isDropTarget: boolean;
+ isHovered: boolean;
+ isInvalidDrop: boolean;
+}
+
+export function GridCell({ x, y, cellSize, isOccupied, isDropTarget, isHovered, isInvalidDrop }: GridCellProps) {
+ const { setNodeRef } = useDroppable({
+ id: `cell-${x}-${y}`,
+ data: { x, y },
+ });
+
+ return (
+
+ );
+}
diff --git a/src/components/frontend/storage/GridItem.tsx b/src/components/frontend/storage/GridItem.tsx
new file mode 100644
index 0000000..d9d803d
--- /dev/null
+++ b/src/components/frontend/storage/GridItem.tsx
@@ -0,0 +1,105 @@
+"use client";
+
+import { useRef, useCallback, useEffect } from "react";
+import { useDraggable } from "@dnd-kit/core";
+import { CSS } from "@dnd-kit/utilities";
+import { Resource } from "@/payload-types";
+import { getItemFootprint, type GridItem as GridItemType } from "@/lib/logistics";
+import { cn } from "@/lib/utils";
+import { GripVerticalIcon } from "lucide-react";
+
+interface GridItemProps {
+ item: GridItemType;
+ cellSize: number;
+ onSelect: (item: GridItemType) => void;
+ selected: boolean;
+}
+
+export function GridItemComponent({ item, cellSize, onSelect, selected }: GridItemProps) {
+ const resource = item.resource as Resource;
+ const { width, height } = getItemFootprint(resource, item.rotated);
+
+ const didDrag = useRef(false);
+
+ const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
+ id: `grid-item-${item.id}`,
+ data: { item },
+ });
+
+ useEffect(() => {
+ if (isDragging) {
+ didDrag.current = true;
+ }
+ }, [isDragging]);
+
+ const handleClick = useCallback(
+ (e: React.MouseEvent) => {
+ if (!didDrag.current) {
+ e.stopPropagation();
+ onSelect(item);
+ }
+ didDrag.current = false;
+ },
+ [item, onSelect],
+ );
+
+ const style: React.CSSProperties = {
+ position: "absolute",
+ left: item.gridX * cellSize,
+ top: item.gridY * cellSize,
+ width: width * cellSize - 2,
+ height: height * cellSize - 2,
+ zIndex: 10,
+ transition: isDragging ? "none" : "left 0.15s ease, top 0.15s ease",
+ transform: CSS.Translate.toString(transform),
+ opacity: isDragging ? 0 : 1,
+ };
+
+ const bgColor = resource.color ?? "oklch(0.269 0 0)";
+
+ return (
+
+
+
+ {resource.icon && typeof resource.icon === "object" && "url" in resource.icon && (
+

+ )}
+
+ {resource.name}
+
+ {item.amount > 1 && (
+
+ {item.amount.toLocaleString()}
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/src/components/frontend/storage/GridToolbar.tsx b/src/components/frontend/storage/GridToolbar.tsx
new file mode 100644
index 0000000..3a875c2
--- /dev/null
+++ b/src/components/frontend/storage/GridToolbar.tsx
@@ -0,0 +1,114 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import {
+ ShuffleIcon,
+ RotateCwIcon,
+ TrashIcon,
+ ScissorsIcon,
+ LayoutGridIcon,
+ ListIcon,
+} from "lucide-react";
+import { GridItem, getItemFootprint, getGridOccupancy } from "@/lib/logistics";
+import { Resource } from "@/payload-types";
+import { cn, pluralize } from "@/lib/utils";
+
+interface GridToolbarProps {
+ gridWidth: number;
+ gridHeight: number;
+ storedResources: GridItem[];
+ selectedItem: GridItem | null;
+ viewMode: "grid" | "list";
+ onViewModeChange: (mode: "grid" | "list") => void;
+ onAutoArrange: () => Promise;
+ onRotate: (entryId: string) => Promise;
+ onRemove: (entryId: string) => Promise;
+ onSplit: (entryId: string, amount: number) => Promise;
+}
+
+export function GridToolbar({
+ gridWidth,
+ gridHeight,
+ storedResources,
+ selectedItem,
+ viewMode,
+ onViewModeChange,
+ onAutoArrange,
+ onRotate,
+ onRemove,
+ onSplit,
+}: GridToolbarProps) {
+ const occupancy = getGridOccupancy(storedResources, gridWidth, gridHeight);
+ const res = selectedItem ? (selectedItem.resource as Resource) : null;
+ const fp = res ? getItemFootprint(res, selectedItem!.rotated) : null;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ {selectedItem && res && (
+
+
+
{res.name}
+
+ {selectedItem.amount.toLocaleString()} {pluralize(selectedItem.amount, res.unitOfMeasure)}
+ {fp && ` · ${fp.width}x${fp.height}`}
+ {selectedItem.rotated && " · rotated"}
+
+
+
+ {res.rotatable !== false && (
+
+ )}
+ {res.stackable && selectedItem.amount > 1 && (
+
+ )}
+
+
+
+ )}
+
+ );
+}
diff --git a/src/components/frontend/storage/InventoryGrid.tsx b/src/components/frontend/storage/InventoryGrid.tsx
new file mode 100644
index 0000000..e232376
--- /dev/null
+++ b/src/components/frontend/storage/InventoryGrid.tsx
@@ -0,0 +1,344 @@
+"use client";
+
+import { useState, useCallback, useMemo, useRef, useEffect } from "react";
+import {
+ DndContext,
+ DragOverlay,
+ PointerSensor,
+ useSensor,
+ useSensors,
+ type DragStartEvent,
+ type DragMoveEvent,
+ type DragEndEvent,
+} from "@dnd-kit/core";
+import { GameStructure, Structure, Resource } from "@/payload-types";
+import {
+ getItemFootprint,
+ isValidPlacement,
+ getGridOccupancy,
+ type GridItem,
+} from "@/lib/logistics";
+import { GridItemComponent } from "./GridItem";
+import { GridCell } from "./GridCell";
+
+function DragOverlayContent({ item, cellSize, rotated }: { item: GridItem; cellSize: number; rotated?: boolean }) {
+ const resource = item.resource as Resource;
+ const effective = rotated ?? item.rotated;
+ const { width, height } = getItemFootprint(resource, effective);
+ const bgColor = resource.color ?? "oklch(0.269 0 0)";
+
+ return (
+
+
+
+ {resource.icon && typeof resource.icon === "object" && "url" in resource.icon && (
+

+ )}
+
+ {resource.name}
+
+ {item.amount > 1 && (
+
+ {item.amount.toLocaleString()}
+
+ )}
+
+
+ );
+}
+
+function findMergeTarget(items: GridItem[], dragged: GridItem, targetX: number, targetY: number, rotated?: boolean): GridItem | null {
+ const effective = rotated ?? dragged.rotated;
+ const fp = getItemFootprint(dragged.resource as Resource, effective);
+ const targetCells = new Set();
+ for (let dx = 0; dx < fp.width; dx++) {
+ for (let dy = 0; dy < fp.height; dy++) {
+ targetCells.add(`${targetX + dx},${targetY + dy}`);
+ }
+ }
+ for (const other of items) {
+ if (other.id === dragged.id) continue;
+ if (other.resourceId !== dragged.resourceId) continue;
+ const otherFp = getItemFootprint(other.resource as Resource, other.rotated);
+ for (let dx = 0; dx < otherFp.width; dx++) {
+ for (let dy = 0; dy < otherFp.height; dy++) {
+ if (targetCells.has(`${other.gridX + dx},${other.gridY + dy}`)) {
+ return other;
+ }
+ }
+ }
+ }
+ return null;
+}
+
+interface InventoryGridProps {
+ structure: GameStructure & { type: Structure };
+ storedResources: GridItem[];
+ onMoveItem: (entryId: string, gridX: number, gridY: number, rotated?: boolean) => Promise;
+ onMergeStacks: (sourceId: string, targetId: string) => Promise;
+ onRotateItem: (entryId: string) => Promise;
+ onRemoveItem: (entryId: string) => Promise;
+ onSelectItem: (item: GridItem | null) => void;
+ selectedItem: GridItem | null;
+}
+
+export function InventoryGrid({
+ structure,
+ storedResources,
+ onMoveItem,
+ onMergeStacks,
+ onRotateItem,
+ onRemoveItem,
+ onSelectItem,
+ selectedItem,
+}: InventoryGridProps) {
+ const [activeItem, setActiveItem] = useState(null);
+ const [hoverPosition, setHoverPosition] = useState<{ x: number; y: number } | null>(null);
+ const [pendingRotated, setPendingRotated] = useState(false);
+ const [cellSize, setCellSize] = useState(48);
+ const gridRef = useRef(null);
+ const containerRef = useRef(null);
+
+ const gridWidth = structure.type.xSize ?? 1;
+ const gridHeight = structure.type.ySize ?? 1;
+
+ useEffect(() => {
+ const el = containerRef.current;
+ if (!el) return;
+ const observer = new ResizeObserver((entries) => {
+ for (const entry of entries) {
+ const w = entry.contentRect.width;
+ const computed = Math.floor(w / gridWidth);
+ setCellSize(Math.max(32, Math.min(computed, 80)));
+ }
+ });
+ observer.observe(el);
+ return () => observer.disconnect();
+ }, [gridWidth]);
+
+ useEffect(() => {
+ if (!activeItem) return;
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "r" || e.key === "R") {
+ e.preventDefault();
+ setPendingRotated((v) => !v);
+ }
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [activeItem]);
+
+ const sensors = useSensors(
+ useSensor(PointerSensor, {
+ activationConstraint: { distance: 5 },
+ }),
+ );
+
+ const occupiedCells = useMemo(() => {
+ const cells = new Set();
+ for (const item of storedResources) {
+ const fp = getItemFootprint(item.resource as Resource, item.rotated);
+ for (let dx = 0; dx < fp.width; dx++) {
+ for (let dy = 0; dy < fp.height; dy++) {
+ cells.add(`${item.gridX + dx},${item.gridY + dy}`);
+ }
+ }
+ }
+ return cells;
+ }, [storedResources]);
+
+ const occupancy = getGridOccupancy(storedResources, gridWidth, gridHeight);
+
+ const hoverInfo = useMemo(() => {
+ if (!hoverPosition || !activeItem) return null;
+ const res = activeItem.resource as Resource;
+ const fp = getItemFootprint(res, pendingRotated);
+ const cells = new Set();
+ for (let dx = 0; dx < fp.width; dx++) {
+ for (let dy = 0; dy < fp.height; dy++) {
+ cells.add(`${hoverPosition.x + dx},${hoverPosition.y + dy}`);
+ }
+ }
+ const mergeTarget = findMergeTarget(storedResources, activeItem, hoverPosition.x, hoverPosition.y, pendingRotated);
+ const valid = mergeTarget !== null || isValidPlacement(
+ storedResources,
+ activeItem.id,
+ hoverPosition.x,
+ hoverPosition.y,
+ fp.width,
+ fp.height,
+ gridWidth,
+ gridHeight,
+ );
+ return { cells, valid };
+ }, [hoverPosition, activeItem, storedResources, gridWidth, gridHeight, pendingRotated]);
+
+ const handleDragStart = useCallback(
+ (event: DragStartEvent) => {
+ const data = event.active.data.current;
+ if (data?.item) {
+ const item = data.item as GridItem;
+ setActiveItem(item);
+ setPendingRotated(item.rotated ?? false);
+ }
+ setHoverPosition(null);
+ },
+ [],
+ );
+
+ const handleDragMove = useCallback(
+ (event: DragMoveEvent) => {
+ const { activatorEvent, delta } = event;
+ const gridEl = gridRef.current;
+ if (!gridEl) return;
+ const rect = gridEl.getBoundingClientRect();
+ if (!("clientX" in activatorEvent)) return;
+ const px = (activatorEvent as PointerEvent).clientX + delta.x;
+ const py = (activatorEvent as PointerEvent).clientY + delta.y;
+ const x = Math.floor((px - rect.left) / cellSize);
+ const y = Math.floor((py - rect.top) / cellSize);
+ if (x < 0 || y < 0 || x >= gridWidth || y >= gridHeight) {
+ setHoverPosition(null);
+ return;
+ }
+ setHoverPosition({ x, y });
+ },
+ [gridWidth, gridHeight, cellSize],
+ );
+
+ const handleDragEnd = useCallback(
+ async (event: DragEndEvent) => {
+ setActiveItem(null);
+ setHoverPosition(null);
+ const { active, activatorEvent, delta } = event;
+
+ const item = active.data.current?.item as GridItem | undefined;
+ if (!item) return;
+
+ const gridEl = gridRef.current;
+ if (!gridEl) return;
+
+ const rect = gridEl.getBoundingClientRect();
+ if (!("clientX" in activatorEvent)) return;
+ const px = (activatorEvent as PointerEvent).clientX + delta.x;
+ const py = (activatorEvent as PointerEvent).clientY + delta.y;
+
+ const targetX = Math.floor((px - rect.left) / cellSize);
+ const targetY = Math.floor((py - rect.top) / cellSize);
+
+ if (targetX < 0 || targetY < 0 || targetX >= gridWidth || targetY >= gridHeight) return;
+ if (targetX === item.gridX && targetY === item.gridY) {
+ if (pendingRotated === item.rotated) return;
+ await onMoveItem(item.id, targetX, targetY, pendingRotated);
+ return;
+ }
+
+ const mergeTarget = findMergeTarget(storedResources, item, targetX, targetY, pendingRotated);
+ if (mergeTarget) {
+ await onMergeStacks(item.id, mergeTarget.id);
+ return;
+ }
+
+ const res = item.resource as Resource;
+ const fp = getItemFootprint(res, pendingRotated);
+ const valid = isValidPlacement(
+ storedResources,
+ item.id,
+ targetX,
+ targetY,
+ fp.width,
+ fp.height,
+ gridWidth,
+ gridHeight,
+ );
+
+ if (valid) {
+ await onMoveItem(item.id, targetX, targetY, pendingRotated);
+ }
+ },
+ [storedResources, gridWidth, gridHeight, onMoveItem, onMergeStacks, cellSize, pendingRotated],
+ );
+
+ const handleBackgroundClick = useCallback(() => {
+ onSelectItem(null);
+ }, [onSelectItem]);
+
+ return (
+
+
+
+
+ {Array.from({ length: gridHeight }, (_, y) =>
+ Array.from({ length: gridWidth }, (_, x) => {
+ const key = `${x},${y}`;
+ return (
+
+ );
+ }),
+ )}
+
+ {storedResources.map((item) => (
+
+ ))}
+
+
+
+
+
+ {occupancy.used} / {occupancy.total} cells used
+
+
+ {gridWidth} x {gridHeight}
+
+
+
+
+
+ {activeItem && (
+
+ )}
+
+
+ );
+}
diff --git a/src/components/frontend/storage/StructureStorageView.tsx b/src/components/frontend/storage/StructureStorageView.tsx
new file mode 100644
index 0000000..c8db8e1
--- /dev/null
+++ b/src/components/frontend/storage/StructureStorageView.tsx
@@ -0,0 +1,144 @@
+"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 {
+ moveGridItem,
+ mergeGridStacks,
+ rotateGridItem,
+ removeGridItem,
+ splitGridStack,
+ autoArrangeGrid,
+} from "@/app/(frontend)/logistics/structures/actions";
+
+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(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]);
+
+ if (!hasGrid) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+ {viewMode === "grid" ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}