1
0
Fork 0

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
This commit is contained in:
Jason Fraley 2026-08-02 18:50:19 -04:00
parent a466ed54ed
commit 4ae2b3137c
2 changed files with 145 additions and 12 deletions

View file

@ -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 <Wrench className={cls} />;
case "explosive":
return <Gem className={cls} />;
case "skin":
return <Palette className={cls} />;
case "miscellaneous":
return <Luggage className={cls} />;
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 (
<div
@ -112,6 +132,29 @@ export function LockerItemFace({
className="relative rounded-md overflow-hidden flex flex-col items-center justify-center gap-0.5 select-none"
>
<div className="absolute inset-0 opacity-20" style={{ backgroundColor: bgColor }} />
{(hasSkin || attachmentCount > 0 || hasCustomName) && (
<div className="absolute top-0.5 right-0.5 flex flex-col items-end gap-0.5 z-10">
{hasCustomName && (
<span className="rounded-sm bg-sky-400 p-0.5 text-white shadow" title="Custom name">
<Tag className="size-2.5" />
</span>
)}
{hasSkin && (
<span className="rounded-sm bg-amber-400 p-0.5 text-white shadow" title="Skin applied">
<Palette className="size-2.5" />
</span>
)}
{attachmentCount > 0 && (
<span
className="flex items-center gap-0.5 rounded-sm bg-primary px-0.5 py-0.5 text-white shadow"
title={`${attachmentCount} attachment${attachmentCount === 1 ? "" : "s"}`}
>
<Crosshair className="size-2.5" />
<span className="text-[8px] font-bold leading-none">{attachmentCount}</span>
</span>
)}
</div>
)}
<div className="relative flex flex-col items-center justify-center gap-0.5 p-1 w-full h-full">
{thumb ? (
<img src={thumb} alt={asset.name} className="size-6 object-contain opacity-80" />
@ -119,7 +162,7 @@ export function LockerItemFace({
<span className="text-muted-foreground/70">{getAssetIcon(asset.assetType, "size-5")}</span>
)}
<span className="text-[10px] leading-tight font-medium text-center text-foreground/90 line-clamp-2 px-0.5">
{asset.name}
{displayName}
</span>
{quantity > 1 && (
<span
@ -139,11 +182,13 @@ function DraggableLockerItem({
cellSize,
onSelect,
selected,
highlight,
}: {
item: LockerGridItem;
cellSize: number;
onSelect: (item: LockerGridItem) => 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
>
<LockerItemFace asset={item.asset} quantity={item.quantity} rotated={item.rotated} cellSize={cellSize} />
<LockerItemFace
asset={item.asset}
quantity={item.quantity}
rotated={item.rotated}
cellSize={cellSize}
equipment={item.equipment}
/>
</div>
);
}
@ -210,6 +262,8 @@ interface LockerGridProps {
onRemoveItem: (entryId: string) => Promise<void>;
onSelectItem: (item: LockerGridItem | null) => void;
selectedItem: LockerGridItem | null;
highlightId?: string | null;
onAttachItem?: (weaponEntryId: string, attachmentEntryId: string) => Promise<ActionResult>;
}
export function LockerGrid({
@ -222,10 +276,14 @@ export function LockerGrid({
onRemoveItem,
onSelectItem,
selectedItem,
highlightId,
onAttachItem,
}: LockerGridProps) {
const [activeItem, setActiveItem] = useState<LockerGridItem | null>(null);
const [hoverPosition, setHoverPosition] = useState<{ x: number; y: number } | null>(null);
const [pendingRotated, setPendingRotated] = useState(false);
const [dragError, setDragError] = useState<string | null>(null);
const dragErrorTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const [cellSize, setCellSize] = useState(48);
const gridRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(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<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 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<string>();
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}
/>
))}
</div>
@ -439,6 +568,7 @@ export function LockerGrid({
{gridWidth} x {gridHeight}
</span>
</div>
{dragError && <p className="text-xs text-red-400">{dragError}</p>}
</div>
<DragOverlay dropAnimation={null}>

View file

@ -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 }}