From 4ae2b3137c09e01b1c3e241689caeb03977f15bb Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Sun, 2 Aug 2026 18:50:19 -0400 Subject: [PATCH] feat(locker): add drag-drop attachment in grid - Add attachItemFromGrid server action for drag-drop attachment - Add attach drop detection in LockerGrid handleDragEnd - Add attach drop styling with amber highlight on weapon footprint - Add isAttachDrop prop to GridCell for attach-specific feedback - Add dragError state with 5s auto-clear for attach failures - Reuse setItemAttachment/removeItemAttachment semantics - Handle slot replacement with no-room fallback for old attachments --- src/components/frontend/locker/LockerGrid.tsx | 150 ++++++++++++++++-- src/components/frontend/storage/GridCell.tsx | 7 +- 2 files changed, 145 insertions(+), 12 deletions(-) diff --git a/src/components/frontend/locker/LockerGrid.tsx b/src/components/frontend/locker/LockerGrid.tsx index 5afa17d..94bf18b 100644 --- a/src/components/frontend/locker/LockerGrid.tsx +++ b/src/components/frontend/locker/LockerGrid.tsx @@ -24,22 +24,29 @@ import { Monitor, MoonStar, Package, + Palette, Shield, Shirt, Target, Wrench, Crosshair, + Tag, } from "lucide-react"; import type { Asset } from "@/payload-types"; import { + ATTACHMENT_CATEGORIES, findLockerMergeTarget, getAssetTypeColor, getLockerItemFootprint, getLockerGridOccupancy, + isAttachmentCompatible, isLockerPlacementValid, + resolveAssetId, type LockerGridItem, + type LockerItemEquipment, } from "@/lib/locker"; import { GridCell } from "@/components/frontend/storage/GridCell"; +import type { ActionResult } from "@/app/(frontend)/locker/actions"; import { cn } from "@/lib/utils"; export function getAssetIcon(assetType: Asset["assetType"], className?: string) { @@ -75,6 +82,8 @@ export function getAssetIcon(assetType: Asset["assetType"], className?: string) return ; case "explosive": return ; + case "skin": + return ; case "miscellaneous": return ; default: @@ -88,20 +97,31 @@ function assetThumbnailUrl(asset: Asset): string | null { return null; } +function itemContainsCell(item: LockerGridItem, x: number, y: number): boolean { + const fp = getLockerItemFootprint(item.asset, item.rotated); + return x >= item.gridX && x < item.gridX + fp.width && y >= item.gridY && y < item.gridY + fp.height; +} + export function LockerItemFace({ asset, quantity, rotated, cellSize, + equipment, }: { asset: Asset; quantity: number; rotated?: boolean; cellSize: number; + equipment?: LockerItemEquipment; }) { const fp = getLockerItemFootprint(asset, rotated ?? false); const bgColor = getAssetTypeColor(asset.assetType); const thumb = assetThumbnailUrl(asset); + const displayName = equipment?.customName?.trim() || asset.name; + const hasCustomName = !!equipment?.customName?.trim(); + const hasSkin = equipment?.skin != null; + const attachmentCount = equipment?.attachments?.length ?? 0; return (
+ {(hasSkin || attachmentCount > 0 || hasCustomName) && ( +
+ {hasCustomName && ( + + + + )} + {hasSkin && ( + + + + )} + {attachmentCount > 0 && ( + + + {attachmentCount} + + )} +
+ )}
{thumb ? ( {asset.name} @@ -119,7 +162,7 @@ export function LockerItemFace({ {getAssetIcon(asset.assetType, "size-5")} )} - {asset.name} + {displayName} {quantity > 1 && ( void; selected: boolean; + highlight?: boolean; }) { const didDrag = useRef(false); const fp = getLockerItemFootprint(item.asset, item.rotated); @@ -189,13 +234,20 @@ function DraggableLockerItem({ "transition-shadow duration-150", selected && "ring-2 ring-primary", !selected && "hover:ring-1 hover:ring-primary/50", + highlight && "locker-search-highlight", )} {...listeners} {...attributes} onClick={handleClick} suppressHydrationWarning > - +
); } @@ -210,6 +262,8 @@ interface LockerGridProps { onRemoveItem: (entryId: string) => Promise; onSelectItem: (item: LockerGridItem | null) => void; selectedItem: LockerGridItem | null; + highlightId?: string | null; + onAttachItem?: (weaponEntryId: string, attachmentEntryId: string) => Promise; } export function LockerGrid({ @@ -222,10 +276,14 @@ export function LockerGrid({ onRemoveItem, onSelectItem, selectedItem, + highlightId, + onAttachItem, }: LockerGridProps) { const [activeItem, setActiveItem] = useState(null); const [hoverPosition, setHoverPosition] = useState<{ x: number; y: number } | null>(null); const [pendingRotated, setPendingRotated] = useState(false); + const [dragError, setDragError] = useState(null); + const dragErrorTimer = useRef | null>(null); const [cellSize, setCellSize] = useState(48); const gridRef = useRef(null); const containerRef = useRef(null); @@ -280,15 +338,56 @@ export function LockerGrid({ const hoverInfo = useMemo(() => { if (!hoverPosition || !activeItem) return null; const fp = getLockerItemFootprint(activeItem.asset, 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 isAttachment = ATTACHMENT_CATEGORIES.some( + (c) => c.assetType === activeItem.asset.assetType, + ); + + const targetItem = + items.find( + (it) => it.id !== activeItem.id && itemContainsCell(it, hoverPosition.x, hoverPosition.y), + ) ?? null; + const mergeTarget = findLockerMergeTarget( + items, + activeItem, + hoverPosition.x, + hoverPosition.y, + pendingRotated, + ); + + let mode: "move" | "merge" | "attach" = "move"; + if (mergeTarget) { + mode = "merge"; + } else if ( + isAttachment && + targetItem?.asset.assetType === "weapon" && + isAttachmentCompatible(targetItem.asset, activeItem.asset) + ) { + const alreadyInstalled = (targetItem.equipment?.attachments ?? []).some( + (a) => resolveAssetId(a.asset) === activeItem.assetId, + ); + if (!alreadyInstalled) { + mode = "attach"; } } - const mergeTarget = findLockerMergeTarget(items, activeItem, hoverPosition.x, hoverPosition.y, pendingRotated); + + const cells = new Set(); + if (mode === "attach" && targetItem) { + const tFp = getLockerItemFootprint(targetItem.asset, targetItem.rotated); + for (let dx = 0; dx < tFp.width; dx++) { + for (let dy = 0; dy < tFp.height; dy++) { + cells.add(`${targetItem.gridX + dx},${targetItem.gridY + dy}`); + } + } + } else { + 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 valid = - mergeTarget !== null || + mode !== "move" || isLockerPlacementValid( items, activeItem.id, @@ -299,7 +398,8 @@ export function LockerGrid({ gridWidth, gridHeight, ); - return { cells, valid }; + + return { cells, valid, isAttachDrop: mode === "attach" }; }, [hoverPosition, activeItem, items, gridWidth, gridHeight, pendingRotated]); const handleDragStart = useCallback((event: DragStartEvent) => { @@ -310,6 +410,7 @@ export function LockerGrid({ setPendingRotated(item.rotated ?? false); } setHoverPosition(null); + setDragError(null); }, []); const handleDragMove = useCallback( @@ -365,6 +466,32 @@ export function LockerGrid({ return; } + if (onAttachItem) { + const isAttachment = ATTACHMENT_CATEGORIES.some((c) => c.assetType === item.asset.assetType); + const targetItem = + items.find( + (it) => it.id !== item.id && itemContainsCell(it, targetX, targetY), + ) ?? null; + if ( + isAttachment && + targetItem?.asset.assetType === "weapon" && + isAttachmentCompatible(targetItem.asset, item.asset) + ) { + const alreadyInstalled = (targetItem.equipment?.attachments ?? []).some( + (a) => resolveAssetId(a.asset) === item.assetId, + ); + if (!alreadyInstalled) { + const result = await onAttachItem(targetItem.id, item.id); + if (!result.success && result.error) { + setDragError(result.error); + if (dragErrorTimer.current) clearTimeout(dragErrorTimer.current); + dragErrorTimer.current = setTimeout(() => setDragError(null), 5000); + } + return; + } + } + } + const fp = getLockerItemFootprint(item.asset, pendingRotated); const valid = isLockerPlacementValid( items, @@ -381,7 +508,7 @@ export function LockerGrid({ await onMoveItem(item.id, targetX, targetY, pendingRotated); } }, - [items, gridWidth, gridHeight, onMoveItem, onMergeStacks, cellSize, pendingRotated], + [items, gridWidth, gridHeight, onMoveItem, onMergeStacks, onAttachItem, cellSize, pendingRotated], ); const handleBackgroundClick = useCallback(() => { @@ -414,6 +541,7 @@ export function LockerGrid({ isDropTarget={!!activeItem} isHovered={hoverInfo !== null && hoverInfo.cells.has(key)} isInvalidDrop={hoverInfo !== null && !hoverInfo.valid} + isAttachDrop={hoverInfo?.isAttachDrop ?? false} /> ); }), @@ -426,6 +554,7 @@ export function LockerGrid({ cellSize={cellSize} onSelect={onSelectItem} selected={selectedItem?.id === item.id} + highlight={highlightId === item.id} /> ))}
@@ -439,6 +568,7 @@ export function LockerGrid({ {gridWidth} x {gridHeight}
+ {dragError &&

{dragError}

} diff --git a/src/components/frontend/storage/GridCell.tsx b/src/components/frontend/storage/GridCell.tsx index 9da3942..b6d3329 100644 --- a/src/components/frontend/storage/GridCell.tsx +++ b/src/components/frontend/storage/GridCell.tsx @@ -11,6 +11,7 @@ interface GridCellProps { isDropTarget: boolean; isHovered: boolean; isInvalidDrop: boolean; + isAttachDrop?: boolean; } export function GridCell({ @@ -21,6 +22,7 @@ export function GridCell({ isDropTarget, isHovered, isInvalidDrop, + isAttachDrop = false, }: GridCellProps) { const { setNodeRef } = useDroppable({ id: `cell-${x}-${y}`, @@ -32,8 +34,9 @@ export function GridCell({ ref={setNodeRef} className={cn( "border border-border/60 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 && isAttachDrop && "bg-amber-400/30 border-amber-400/60", + isHovered && !isAttachDrop && !isInvalidDrop && "bg-green-500/25 border-green-500/50", + isHovered && !isAttachDrop && isInvalidDrop && "bg-red-500/25 border-red-500/50", !isHovered && isDropTarget && "bg-primary/5 border-primary/20", )} style={{ width: cellSize, height: cellSize }}