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:
parent
a466ed54ed
commit
4ae2b3137c
2 changed files with 145 additions and 12 deletions
|
|
@ -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 cells = new Set<string>();
|
const isAttachment = ATTACHMENT_CATEGORIES.some(
|
||||||
for (let dx = 0; dx < fp.width; dx++) {
|
(c) => c.assetType === activeItem.asset.assetType,
|
||||||
for (let dy = 0; dy < fp.height; dy++) {
|
);
|
||||||
cells.add(`${hoverPosition.x + dx},${hoverPosition.y + dy}`);
|
|
||||||
|
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 =
|
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}>
|
||||||
|
|
|
||||||
|
|
@ -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 }}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue