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, Monitor,
MoonStar, MoonStar,
Package, Package,
Palette,
Shield, Shield,
Shirt, Shirt,
Target, Target,
Wrench, Wrench,
Crosshair, Crosshair,
Tag,
} from "lucide-react"; } from "lucide-react";
import type { Asset } from "@/payload-types"; import type { Asset } from "@/payload-types";
import { import {
ATTACHMENT_CATEGORIES,
findLockerMergeTarget, findLockerMergeTarget,
getAssetTypeColor, getAssetTypeColor,
getLockerItemFootprint, getLockerItemFootprint,
getLockerGridOccupancy, getLockerGridOccupancy,
isAttachmentCompatible,
isLockerPlacementValid, isLockerPlacementValid,
resolveAssetId,
type LockerGridItem, type LockerGridItem,
type LockerItemEquipment,
} from "@/lib/locker"; } from "@/lib/locker";
import { GridCell } from "@/components/frontend/storage/GridCell"; import { GridCell } from "@/components/frontend/storage/GridCell";
import type { ActionResult } from "@/app/(frontend)/locker/actions";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export function getAssetIcon(assetType: Asset["assetType"], className?: string) { export function getAssetIcon(assetType: Asset["assetType"], className?: string) {
@ -75,6 +82,8 @@ export function getAssetIcon(assetType: Asset["assetType"], className?: string)
return <Wrench className={cls} />; return <Wrench className={cls} />;
case "explosive": case "explosive":
return <Gem className={cls} />; return <Gem className={cls} />;
case "skin":
return <Palette className={cls} />;
case "miscellaneous": case "miscellaneous":
return <Luggage className={cls} />; return <Luggage className={cls} />;
default: default:
@ -88,20 +97,31 @@ function assetThumbnailUrl(asset: Asset): string | null {
return 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({ export function LockerItemFace({
asset, asset,
quantity, quantity,
rotated, rotated,
cellSize, cellSize,
equipment,
}: { }: {
asset: Asset; asset: Asset;
quantity: number; quantity: number;
rotated?: boolean; rotated?: boolean;
cellSize: number; cellSize: number;
equipment?: LockerItemEquipment;
}) { }) {
const fp = getLockerItemFootprint(asset, rotated ?? false); const fp = getLockerItemFootprint(asset, rotated ?? false);
const bgColor = getAssetTypeColor(asset.assetType); const bgColor = getAssetTypeColor(asset.assetType);
const thumb = assetThumbnailUrl(asset); 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 ( return (
<div <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" 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 }} /> <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"> <div className="relative flex flex-col items-center justify-center gap-0.5 p-1 w-full h-full">
{thumb ? ( {thumb ? (
<img src={thumb} alt={asset.name} className="size-6 object-contain opacity-80" /> <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-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"> <span className="text-[10px] leading-tight font-medium text-center text-foreground/90 line-clamp-2 px-0.5">
{asset.name} {displayName}
</span> </span>
{quantity > 1 && ( {quantity > 1 && (
<span <span
@ -139,11 +182,13 @@ function DraggableLockerItem({
cellSize, cellSize,
onSelect, onSelect,
selected, selected,
highlight,
}: { }: {
item: LockerGridItem; item: LockerGridItem;
cellSize: number; cellSize: number;
onSelect: (item: LockerGridItem) => void; onSelect: (item: LockerGridItem) => void;
selected: boolean; selected: boolean;
highlight?: boolean;
}) { }) {
const didDrag = useRef(false); const didDrag = useRef(false);
const fp = getLockerItemFootprint(item.asset, item.rotated); const fp = getLockerItemFootprint(item.asset, item.rotated);
@ -189,13 +234,20 @@ function DraggableLockerItem({
"transition-shadow duration-150", "transition-shadow duration-150",
selected && "ring-2 ring-primary", selected && "ring-2 ring-primary",
!selected && "hover:ring-1 hover:ring-primary/50", !selected && "hover:ring-1 hover:ring-primary/50",
highlight && "locker-search-highlight",
)} )}
{...listeners} {...listeners}
{...attributes} {...attributes}
onClick={handleClick} onClick={handleClick}
suppressHydrationWarning 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> </div>
); );
} }
@ -210,6 +262,8 @@ interface LockerGridProps {
onRemoveItem: (entryId: string) => Promise<void>; onRemoveItem: (entryId: string) => Promise<void>;
onSelectItem: (item: LockerGridItem | null) => void; onSelectItem: (item: LockerGridItem | null) => void;
selectedItem: LockerGridItem | null; selectedItem: LockerGridItem | null;
highlightId?: string | null;
onAttachItem?: (weaponEntryId: string, attachmentEntryId: string) => Promise<ActionResult>;
} }
export function LockerGrid({ export function LockerGrid({
@ -222,10 +276,14 @@ export function LockerGrid({
onRemoveItem, onRemoveItem,
onSelectItem, onSelectItem,
selectedItem, selectedItem,
highlightId,
onAttachItem,
}: LockerGridProps) { }: LockerGridProps) {
const [activeItem, setActiveItem] = useState<LockerGridItem | null>(null); const [activeItem, setActiveItem] = useState<LockerGridItem | null>(null);
const [hoverPosition, setHoverPosition] = useState<{ x: number; y: number } | null>(null); const [hoverPosition, setHoverPosition] = useState<{ x: number; y: number } | null>(null);
const [pendingRotated, setPendingRotated] = useState(false); 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 [cellSize, setCellSize] = useState(48);
const gridRef = useRef<HTMLDivElement>(null); const gridRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@ -280,15 +338,56 @@ export function LockerGrid({
const hoverInfo = useMemo(() => { const hoverInfo = useMemo(() => {
if (!hoverPosition || !activeItem) return null; if (!hoverPosition || !activeItem) return null;
const fp = getLockerItemFootprint(activeItem.asset, pendingRotated); const fp = getLockerItemFootprint(activeItem.asset, pendingRotated);
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 cells = new Set<string>(); 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 dx = 0; dx < fp.width; dx++) {
for (let dy = 0; dy < fp.height; dy++) { for (let dy = 0; dy < fp.height; dy++) {
cells.add(`${hoverPosition.x + dx},${hoverPosition.y + dy}`); cells.add(`${hoverPosition.x + dx},${hoverPosition.y + dy}`);
} }
} }
const mergeTarget = findLockerMergeTarget(items, activeItem, hoverPosition.x, hoverPosition.y, pendingRotated); }
const valid = const valid =
mergeTarget !== null || mode !== "move" ||
isLockerPlacementValid( isLockerPlacementValid(
items, items,
activeItem.id, activeItem.id,
@ -299,7 +398,8 @@ export function LockerGrid({
gridWidth, gridWidth,
gridHeight, gridHeight,
); );
return { cells, valid };
return { cells, valid, isAttachDrop: mode === "attach" };
}, [hoverPosition, activeItem, items, gridWidth, gridHeight, pendingRotated]); }, [hoverPosition, activeItem, items, gridWidth, gridHeight, pendingRotated]);
const handleDragStart = useCallback((event: DragStartEvent) => { const handleDragStart = useCallback((event: DragStartEvent) => {
@ -310,6 +410,7 @@ export function LockerGrid({
setPendingRotated(item.rotated ?? false); setPendingRotated(item.rotated ?? false);
} }
setHoverPosition(null); setHoverPosition(null);
setDragError(null);
}, []); }, []);
const handleDragMove = useCallback( const handleDragMove = useCallback(
@ -365,6 +466,32 @@ export function LockerGrid({
return; 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 fp = getLockerItemFootprint(item.asset, pendingRotated);
const valid = isLockerPlacementValid( const valid = isLockerPlacementValid(
items, items,
@ -381,7 +508,7 @@ export function LockerGrid({
await onMoveItem(item.id, targetX, targetY, pendingRotated); 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(() => { const handleBackgroundClick = useCallback(() => {
@ -414,6 +541,7 @@ export function LockerGrid({
isDropTarget={!!activeItem} isDropTarget={!!activeItem}
isHovered={hoverInfo !== null && hoverInfo.cells.has(key)} isHovered={hoverInfo !== null && hoverInfo.cells.has(key)}
isInvalidDrop={hoverInfo !== null && !hoverInfo.valid} isInvalidDrop={hoverInfo !== null && !hoverInfo.valid}
isAttachDrop={hoverInfo?.isAttachDrop ?? false}
/> />
); );
}), }),
@ -426,6 +554,7 @@ export function LockerGrid({
cellSize={cellSize} cellSize={cellSize}
onSelect={onSelectItem} onSelect={onSelectItem}
selected={selectedItem?.id === item.id} selected={selectedItem?.id === item.id}
highlight={highlightId === item.id}
/> />
))} ))}
</div> </div>
@ -439,6 +568,7 @@ export function LockerGrid({
{gridWidth} x {gridHeight} {gridWidth} x {gridHeight}
</span> </span>
</div> </div>
{dragError && <p className="text-xs text-red-400">{dragError}</p>}
</div> </div>
<DragOverlay dropAnimation={null}> <DragOverlay dropAnimation={null}>

View file

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