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 ? (

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