feat(grid): add drag-and-drop grid UI components
GridCell — droppable cell with hover/valid/invalid state GridItem — draggable item with grip icon, visual feedback GridToolbar — view toggle, auto-arrange, selected-item actions InventoryGrid — DndContext wiring, R-key rotation, ResizeObserver cell sizing, drag overlay StructureStorageView — client wrapper with grid/list mode toggle, action handlers, resource-to-GridItem mapping
This commit is contained in:
parent
d2222e8701
commit
9175b103c2
5 changed files with 741 additions and 0 deletions
34
src/components/frontend/storage/GridCell.tsx
Normal file
34
src/components/frontend/storage/GridCell.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
className={cn(
|
||||||
|
"border border-border/30 transition-colors duration-75",
|
||||||
|
isHovered && !isInvalidDrop && "bg-green-500/25 border-green-500/50",
|
||||||
|
isHovered && isInvalidDrop && "bg-red-500/25 border-red-500/50",
|
||||||
|
!isHovered && isDropTarget && "bg-primary/5 border-primary/20",
|
||||||
|
)}
|
||||||
|
style={{ width: cellSize, height: cellSize }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
src/components/frontend/storage/GridItem.tsx
Normal file
105
src/components/frontend/storage/GridItem.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={cn(
|
||||||
|
"group rounded-md border cursor-grab active:cursor-grabbing select-none overflow-hidden",
|
||||||
|
"flex flex-col items-center justify-center gap-0.5",
|
||||||
|
"transition-shadow duration-150",
|
||||||
|
selected && "ring-2 ring-primary",
|
||||||
|
!selected && "hover:ring-1 hover:ring-primary/50",
|
||||||
|
)}
|
||||||
|
{...listeners}
|
||||||
|
{...attributes}
|
||||||
|
onClick={handleClick}
|
||||||
|
suppressHydrationWarning
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 opacity-20"
|
||||||
|
style={{ backgroundColor: bgColor }}
|
||||||
|
/>
|
||||||
|
<div className="relative flex flex-col items-center justify-center gap-0.5 p-1 w-full h-full">
|
||||||
|
{resource.icon && typeof resource.icon === "object" && "url" in resource.icon && (
|
||||||
|
<img
|
||||||
|
src={resource.icon.url ?? ""}
|
||||||
|
alt={resource.name}
|
||||||
|
className="size-6 object-contain opacity-80"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="text-[10px] leading-tight font-medium text-center text-foreground/90 line-clamp-2 px-0.5">
|
||||||
|
{resource.name}
|
||||||
|
</span>
|
||||||
|
{item.amount > 1 && (
|
||||||
|
<span
|
||||||
|
className="text-[9px] font-bold leading-none px-1 py-0.5 rounded-sm"
|
||||||
|
style={{ backgroundColor: bgColor, color: "white" }}
|
||||||
|
>
|
||||||
|
{item.amount.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="absolute top-0.5 right-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<GripVerticalIcon className="size-3 text-foreground/50" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
src/components/frontend/storage/GridToolbar.tsx
Normal file
114
src/components/frontend/storage/GridToolbar.tsx
Normal file
|
|
@ -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<void>;
|
||||||
|
onRotate: (entryId: string) => Promise<void>;
|
||||||
|
onRemove: (entryId: string) => Promise<void>;
|
||||||
|
onSplit: (entryId: string, amount: number) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={onAutoArrange}>
|
||||||
|
<ShuffleIcon className="size-3.5 mr-1" />
|
||||||
|
Auto-Arrange
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant={viewMode === "grid" ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onViewModeChange("grid")}
|
||||||
|
>
|
||||||
|
<LayoutGridIcon className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={viewMode === "list" ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onViewModeChange("list")}
|
||||||
|
>
|
||||||
|
<ListIcon className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedItem && res && (
|
||||||
|
<div className="flex items-center gap-3 p-2 rounded-md border bg-muted/30 text-xs">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium truncate">{res.name}</div>
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
{selectedItem.amount.toLocaleString()} {pluralize(selectedItem.amount, res.unitOfMeasure)}
|
||||||
|
{fp && ` · ${fp.width}x${fp.height}`}
|
||||||
|
{selectedItem.rotated && " · rotated"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{res.rotatable !== false && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onRotate(selectedItem.id)}
|
||||||
|
>
|
||||||
|
<RotateCwIcon className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{res.stackable && selectedItem.amount > 1 && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onSplit(selectedItem.id, Math.floor(selectedItem.amount / 2))}
|
||||||
|
>
|
||||||
|
<ScissorsIcon className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onRemove(selectedItem.id)}
|
||||||
|
>
|
||||||
|
<TrashIcon className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
344
src/components/frontend/storage/InventoryGrid.tsx
Normal file
344
src/components/frontend/storage/InventoryGrid.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: width * cellSize - 2,
|
||||||
|
height: height * cellSize - 2,
|
||||||
|
}}
|
||||||
|
className="relative rounded-md border border-primary/50 shadow-xl ring-2 ring-primary/50 select-none overflow-hidden flex flex-col items-center justify-center gap-0.5"
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 opacity-20" style={{ backgroundColor: bgColor }} />
|
||||||
|
<div className="relative flex flex-col items-center justify-center gap-0.5 p-1 w-full h-full">
|
||||||
|
{resource.icon && typeof resource.icon === "object" && "url" in resource.icon && (
|
||||||
|
<img
|
||||||
|
src={resource.icon.url ?? ""}
|
||||||
|
alt={resource.name}
|
||||||
|
className="size-6 object-contain opacity-80"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="text-[10px] leading-tight font-medium text-center text-foreground/90 line-clamp-2 px-0.5">
|
||||||
|
{resource.name}
|
||||||
|
</span>
|
||||||
|
{item.amount > 1 && (
|
||||||
|
<span
|
||||||
|
className="text-[9px] font-bold leading-none px-1 py-0.5 rounded-sm"
|
||||||
|
style={{ backgroundColor: bgColor, color: "white" }}
|
||||||
|
>
|
||||||
|
{item.amount.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string>();
|
||||||
|
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<void>;
|
||||||
|
onMergeStacks: (sourceId: string, targetId: string) => Promise<void>;
|
||||||
|
onRotateItem: (entryId: string) => Promise<void>;
|
||||||
|
onRemoveItem: (entryId: string) => Promise<void>;
|
||||||
|
onSelectItem: (item: GridItem | null) => void;
|
||||||
|
selectedItem: GridItem | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InventoryGrid({
|
||||||
|
structure,
|
||||||
|
storedResources,
|
||||||
|
onMoveItem,
|
||||||
|
onMergeStacks,
|
||||||
|
onRotateItem,
|
||||||
|
onRemoveItem,
|
||||||
|
onSelectItem,
|
||||||
|
selectedItem,
|
||||||
|
}: InventoryGridProps) {
|
||||||
|
const [activeItem, setActiveItem] = useState<GridItem | null>(null);
|
||||||
|
const [hoverPosition, setHoverPosition] = useState<{ x: number; y: number } | null>(null);
|
||||||
|
const [pendingRotated, setPendingRotated] = useState(false);
|
||||||
|
const [cellSize, setCellSize] = useState(48);
|
||||||
|
const gridRef = useRef<HTMLDivElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(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<string>();
|
||||||
|
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<string>();
|
||||||
|
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 (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragMove={handleDragMove}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div ref={containerRef} className="rounded-lg border bg-muted/20 p-2">
|
||||||
|
<div
|
||||||
|
ref={gridRef}
|
||||||
|
className="relative grid"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: `repeat(${gridWidth}, ${cellSize}px)`,
|
||||||
|
gridTemplateRows: `repeat(${gridHeight}, ${cellSize}px)`,
|
||||||
|
}}
|
||||||
|
onClick={handleBackgroundClick}
|
||||||
|
>
|
||||||
|
{Array.from({ length: gridHeight }, (_, y) =>
|
||||||
|
Array.from({ length: gridWidth }, (_, x) => {
|
||||||
|
const key = `${x},${y}`;
|
||||||
|
return (
|
||||||
|
<GridCell
|
||||||
|
key={key}
|
||||||
|
x={x}
|
||||||
|
y={y}
|
||||||
|
cellSize={cellSize}
|
||||||
|
isOccupied={occupiedCells.has(key)}
|
||||||
|
isDropTarget={!!activeItem}
|
||||||
|
isHovered={hoverInfo !== null && hoverInfo.cells.has(key)}
|
||||||
|
isInvalidDrop={hoverInfo !== null && !hoverInfo.valid}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
)}
|
||||||
|
|
||||||
|
{storedResources.map((item) => (
|
||||||
|
<GridItemComponent
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
cellSize={cellSize}
|
||||||
|
onSelect={onSelectItem}
|
||||||
|
selected={selectedItem?.id === item.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
{occupancy.used} / {occupancy.total} cells used
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{gridWidth} x {gridHeight}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DragOverlay dropAnimation={null}>
|
||||||
|
{activeItem && (
|
||||||
|
<DragOverlayContent item={activeItem} cellSize={cellSize} rotated={pendingRotated} />
|
||||||
|
)}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
|
);
|
||||||
|
}
|
||||||
144
src/components/frontend/storage/StructureStorageView.tsx
Normal file
144
src/components/frontend/storage/StructureStorageView.tsx
Normal file
|
|
@ -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<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]);
|
||||||
|
|
||||||
|
if (!hasGrid) {
|
||||||
|
return (
|
||||||
|
<ResourceList
|
||||||
|
storedResources={structure.storedResources ?? []}
|
||||||
|
maxCapacityMass={structure.type.maxCapacityMass ?? null}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue