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} + )} + + {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} + )} + + {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" ? ( + + ) : ( + + )} + +
+ ); +}