1
0
Fork 0

feat(locker): add equipment editor and wardrobe tab

- Add EquipmentEditorDialog for per-category attachments, skins, and custom names
- Add Wardrobe component with soldier image and clickable equipment slots
- Add WardrobePanel with loadout selector and current equipment display
- Add WardrobeSlotPicker dialog for equipping items from locker
- Add equip/unequip loadout functionality with equipped badge
- Add equipment schema to LockerStorages (attachments, skin, customName)
- Add wardrobe slot layout with hotspot positions for each equipment type
- Add locker:item-attach, locker:item-detach, locker:item-skin event types
- Update LoadoutCard to show equipped status and equipment list
- Update LoadoutsPanel to pass ownedItems for equipment display
This commit is contained in:
Jason Fraley 2026-08-02 18:49:59 -04:00
parent c0f8d7080d
commit 6e717684fb
16 changed files with 1785 additions and 51 deletions

View file

@ -7,20 +7,26 @@ import hasRoles from "@/utils/access-control/hasRoles";
import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes";
import {
ATTACHMENT_CATEGORIES,
autoArrangeLockerItems,
ensureLockerStorage,
findLockerEmptySpot,
getLockerGridDimensions,
getLockerItemFootprint,
getLoadoutSlot,
isAttachmentCompatible,
isLockerPlacementValid,
isSkinAsset,
lockerIsRotatable,
lockerIsStackable,
lockerMaxStack,
resolveAssetId,
skinAppliesTo,
toLockerGridItems,
type LockerGridItem,
} from "@/lib/locker";
interface ActionResult<T = undefined> {
export interface ActionResult<T = undefined> {
success: boolean;
error?: string;
data?: T;
@ -44,29 +50,6 @@ function isLockerManager(user: User): boolean {
return hasRoles(["admin", "developer"], user);
}
function toGridItems(items: LockerStorage["items"]): LockerGridItem[] {
return (items ?? [])
.filter((e) => e.asset && typeof e.asset === "object" && "id" in e.asset)
.map((e) => {
const asset = e.asset as Asset;
return {
id: e.id ?? `${asset.id}-${e.gridX}-${e.gridY}`,
assetId: asset.id,
quantity: e.quantity,
gridX: e.gridX ?? 0,
gridY: e.gridY ?? 0,
rotated: e.rotated ?? false,
asset,
};
});
}
function resolveAssetId(asset: NonNullable<LockerStorage["items"]>[number]["asset"]): number | null {
if (asset == null) return null;
if (typeof asset === "object" && "id" in asset) return asset.id as number;
return asset as number;
}
async function getLockerWithItems(
payload: Awaited<ReturnType<typeof getPayload>>,
userId: number,
@ -147,7 +130,7 @@ export async function addItemToLocker(
const grid = await getLockerGridDimensions(payload);
const existing = [...(locker.items ?? [])];
const gridItems = toGridItems(existing);
const gridItems = toLockerGridItems(existing);
const stackable = lockerIsStackable(asset);
const maxStack = lockerMaxStack(asset);
const fp = getLockerItemFootprint(asset, false);
@ -290,7 +273,7 @@ export async function moveLockerItem(
return { success: false, error: "Placement is outside grid bounds." };
}
const gridItems = toGridItems(items);
const gridItems = toLockerGridItems(items);
if (!isLockerPlacementValid(gridItems, entryId, gridX, gridY, fp.width, fp.height, grid.width, grid.height)) {
return { success: false, error: "Cannot move here — overlaps with another item." };
}
@ -347,7 +330,7 @@ export async function rotateLockerItem(entryId: string): Promise<ActionResult> {
return { success: false, error: "Rotation would push the item out of bounds." };
}
const gridItems = toGridItems(items);
const gridItems = toLockerGridItems(items);
if (!isLockerPlacementValid(gridItems, entryId, entry.gridX, entry.gridY, fp.width, fp.height, grid.width, grid.height)) {
return { success: false, error: "Rotation would overlap with another item." };
}
@ -464,7 +447,7 @@ export async function splitLockerStack(entryId: string, splitAmount: number): Pr
if (!asset || typeof asset === "number") return { success: false, error: "Invalid asset." };
if (!lockerIsStackable(asset)) return { success: false, error: "This asset cannot be stacked." };
const gridItems = toGridItems(items);
const gridItems = toLockerGridItems(items);
const fp = getLockerItemFootprint(asset, entry.rotated ?? false);
const spot = findLockerEmptySpot(gridItems, fp.width, fp.height, grid.width, grid.height);
if (!spot) return { success: false, error: "No empty spot available for the split stack." };
@ -511,7 +494,7 @@ export async function autoArrangeLocker(): Promise<ActionResult> {
const grid = await getLockerGridDimensions(payload);
const items = [...(locker.items ?? [])];
const rearranged = autoArrangeLockerItems(toGridItems(items), grid.width, grid.height);
const rearranged = autoArrangeLockerItems(toLockerGridItems(items), grid.width, grid.height);
const updatedItems = items.map((entry) => {
const match = rearranged.find((r) => r.id === entry.id);
@ -733,3 +716,571 @@ async function getOwnedLoadout(
if (ownerId !== user.id && !isLockerManager(user)) return null;
return loadout;
}
// --- Wardrobe / equipped loadout ---
export async function equipLoadout(loadoutId: number, equipped = true): Promise<ActionResult> {
try {
const { payload, user } = await authenticate();
const loadout = await getOwnedLoadout(payload, user, loadoutId, 0);
if (!loadout) return { success: false, error: "Loadout not found." };
const ownedRes = (await payload.find({
collection: "loadouts",
where: { ownerUser: { equals: user.id } },
limit: 100,
depth: 0,
overrideAccess: true,
})) as unknown as { docs: Loadout[] };
for (const doc of ownedRes.docs) {
const isEquipped = doc.equipped === true;
if (doc.id === loadoutId) {
if (isEquipped !== equipped) {
await payload.update({
collection: "loadouts",
id: doc.id,
data: { equipped },
overrideAccess: true,
depth: 0,
});
}
} else if (isEquipped && equipped) {
await payload.update({
collection: "loadouts",
id: doc.id,
data: { equipped: false },
overrideAccess: true,
depth: 0,
});
}
}
await emitGameEvent(payload, {
type: EventTypes.LoadoutSetActive,
message: equipped
? `Set "${loadout.name}" as the equipped loadout`
: `Un-equipped "${loadout.name}"`,
actor: user.id,
targetCollection: "loadouts",
targetId: loadoutId,
data: { loadoutName: loadout.name, active: equipped },
});
return { success: true };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return message === "Unauthorized"
? { success: false, error: "You must be logged in." }
: { success: false, error: message };
}
}
// --- Equipment customization (attachments & skins) ---
export async function setItemAttachment(
entryId: string,
assetId: number,
): Promise<ActionResult> {
try {
const { payload, user } = await authenticate();
const locker = await getLockerWithItems(payload, user.id as number);
const items = [...(locker.items ?? [])];
const targetIdx = items.findIndex((e) => e.id === entryId);
if (targetIdx < 0) return { success: false, error: "Item not found in locker." };
const target = items[targetIdx];
const weapon = target.asset as Asset;
if (!weapon || typeof weapon === "number" || weapon.assetType !== "weapon") {
return { success: false, error: "Attachments can only be installed on weapons." };
}
const attachment = await getAssetOrThrow(payload, assetId);
if (!isAttachmentCompatible(weapon, attachment)) {
return {
success: false,
error: `"${attachment.name}" is not compatible with ${weapon.name}.`,
};
}
const already = (target.equipment?.attachments ?? []).some(
(a) => resolveAssetId(a.asset) === assetId,
);
if (already) return { success: false, error: "This attachment is already installed." };
const srcIdx = items.findIndex(
(e) => e.id !== entryId && resolveAssetId(e.asset) === assetId && e.quantity >= 1,
);
if (srcIdx < 0) {
return { success: false, error: `You do not have ${attachment.name} in your locker.` };
}
const src = items[srcIdx];
const updatedItems = [...items];
if (src.quantity > 1) {
updatedItems[srcIdx] = { ...src, quantity: src.quantity - 1 };
} else {
updatedItems.splice(srcIdx, 1);
}
const tIdx = updatedItems.findIndex((e) => e.id === entryId);
const currentAttachments = target.equipment?.attachments ?? [];
updatedItems[tIdx] = {
...updatedItems[tIdx],
equipment: {
...(updatedItems[tIdx].equipment ?? {}),
attachments: [...currentAttachments, { asset: assetId }],
},
};
await payload.update({
collection: "locker-storages",
id: locker.id,
data: { items: updatedItems },
overrideAccess: true,
depth: 0,
});
await emitGameEvent(payload, {
type: EventTypes.LockerItemAttach,
message: `Installed ${attachment.name} on ${weapon.name}`,
actor: user.id,
targetCollection: "locker-storages",
targetId: locker.id,
data: {
entryId,
weaponId: weapon.id,
weaponName: weapon.name,
attachmentId: assetId,
attachmentName: attachment.name,
},
});
return { success: true };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return message === "Unauthorized"
? { success: false, error: "You must be logged in." }
: { success: false, error: message };
}
}
export async function removeItemAttachment(
entryId: string,
attachmentId: number,
): Promise<ActionResult> {
try {
const { payload, user } = await authenticate();
const locker = await getLockerWithItems(payload, user.id as number);
const grid = await getLockerGridDimensions(payload);
const items = [...(locker.items ?? [])];
const targetIdx = items.findIndex((e) => e.id === entryId);
if (targetIdx < 0) return { success: false, error: "Item not found in locker." };
const target = items[targetIdx];
const weapon = target.asset as Asset;
if (!weapon || typeof weapon === "number") return { success: false, error: "Invalid asset." };
const attachments = target.equipment?.attachments ?? [];
const attIdx = attachments.findIndex((a) => resolveAssetId(a.asset) === attachmentId);
if (attIdx < 0) return { success: false, error: "Attachment not installed on this item." };
const attachment = await getAssetOrThrow(payload, attachmentId);
const remainingAttachments = attachments.filter((_, i) => i !== attIdx);
const gridItems = toLockerGridItems(items);
const fp = getLockerItemFootprint(attachment, false);
const spot = findLockerEmptySpot(gridItems, fp.width, fp.height, grid.width, grid.height);
if (!spot) {
return { success: false, error: `No space in locker to return ${attachment.name}.` };
}
const updatedItems = [...items];
updatedItems[targetIdx] = {
...target,
equipment: { ...(target.equipment ?? {}), attachments: remainingAttachments },
};
updatedItems.push({
asset: attachmentId,
quantity: 1,
gridX: spot.x,
gridY: spot.y,
rotated: false,
});
await payload.update({
collection: "locker-storages",
id: locker.id,
data: { items: updatedItems },
overrideAccess: true,
depth: 0,
});
await emitGameEvent(payload, {
type: EventTypes.LockerItemDetach,
message: `Removed ${attachment.name} from ${weapon.name}`,
actor: user.id,
targetCollection: "locker-storages",
targetId: locker.id,
data: {
entryId,
weaponId: weapon.id,
weaponName: weapon.name,
attachmentId,
attachmentName: attachment.name,
},
});
return { success: true };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return message === "Unauthorized"
? { success: false, error: "You must be logged in." }
: { success: false, error: message };
}
}
export async function setItemSkin(entryId: string, assetId: number): Promise<ActionResult> {
try {
const { payload, user } = await authenticate();
const locker = await getLockerWithItems(payload, user.id as number);
const grid = await getLockerGridDimensions(payload);
let items = [...(locker.items ?? [])];
const targetIdx = items.findIndex((e) => e.id === entryId);
if (targetIdx < 0) return { success: false, error: "Item not found in locker." };
const target = items[targetIdx];
const equipment = target.asset as Asset;
if (!equipment || typeof equipment === "number") return { success: false, error: "Invalid asset." };
const skin = await getAssetOrThrow(payload, assetId);
if (!isSkinAsset(skin)) return { success: false, error: `"${skin.name}" is not a skin.` };
if (!skinAppliesTo(skin, equipment)) {
return { success: false, error: `"${skin.name}" cannot be applied to ${equipment.name}.` };
}
const currentSkinId = resolveAssetId(target.equipment?.skin);
if (currentSkinId != null && currentSkinId !== assetId) {
const oldSkin = await getAssetOrThrow(payload, currentSkinId).catch(() => null);
if (oldSkin) {
const fp = getLockerItemFootprint(oldSkin, false);
const spot = findLockerEmptySpot(
toLockerGridItems(items),
fp.width,
fp.height,
grid.width,
grid.height,
);
if (!spot) {
return {
success: false,
error: "No space in locker to return the currently applied skin.",
};
}
items = [
...items,
{ asset: currentSkinId, quantity: 1, gridX: spot.x, gridY: spot.y, rotated: false },
];
}
}
const srcIdx = items.findIndex(
(e) => e.id !== entryId && resolveAssetId(e.asset) === assetId && e.quantity >= 1,
);
if (srcIdx < 0) {
return { success: false, error: `You do not have ${skin.name} in your locker.` };
}
const src = items[srcIdx];
const updatedItems = [...items];
if (src.quantity > 1) {
updatedItems[srcIdx] = { ...src, quantity: src.quantity - 1 };
} else {
updatedItems.splice(srcIdx, 1);
}
const tIdx = updatedItems.findIndex((e) => e.id === entryId);
updatedItems[tIdx] = {
...updatedItems[tIdx],
equipment: { ...(updatedItems[tIdx].equipment ?? {}), skin: assetId },
};
await payload.update({
collection: "locker-storages",
id: locker.id,
data: { items: updatedItems },
overrideAccess: true,
depth: 0,
});
await emitGameEvent(payload, {
type: EventTypes.LockerItemSkin,
message: `Applied ${skin.name} to ${equipment.name}`,
actor: user.id,
targetCollection: "locker-storages",
targetId: locker.id,
data: {
entryId,
equipmentId: equipment.id,
equipmentName: equipment.name,
skinId: assetId,
skinName: skin.name,
},
});
return { success: true };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return message === "Unauthorized"
? { success: false, error: "You must be logged in." }
: { success: false, error: message };
}
}
export async function removeItemSkin(entryId: string): Promise<ActionResult> {
try {
const { payload, user } = await authenticate();
const locker = await getLockerWithItems(payload, user.id as number);
const grid = await getLockerGridDimensions(payload);
const items = [...(locker.items ?? [])];
const targetIdx = items.findIndex((e) => e.id === entryId);
if (targetIdx < 0) return { success: false, error: "Item not found in locker." };
const target = items[targetIdx];
const skinId = resolveAssetId(target.equipment?.skin);
if (skinId == null) return { success: false, error: "No skin applied to this item." };
const skin = await getAssetOrThrow(payload, skinId);
const fp = getLockerItemFootprint(skin, false);
const spot = findLockerEmptySpot(toLockerGridItems(items), fp.width, fp.height, grid.width, grid.height);
if (!spot) return { success: false, error: `No space in locker to return ${skin.name}.` };
const updatedItems = [...items];
updatedItems[targetIdx] = {
...target,
equipment: { ...(target.equipment ?? {}), skin: null },
};
updatedItems.push({ asset: skinId, quantity: 1, gridX: spot.x, gridY: spot.y, rotated: false });
await payload.update({
collection: "locker-storages",
id: locker.id,
data: { items: updatedItems },
overrideAccess: true,
depth: 0,
});
await emitGameEvent(payload, {
type: EventTypes.LockerItemSkinClear,
message: `Removed ${skin.name} from ${(target.asset as Asset).name ?? "item"}`,
actor: user.id,
targetCollection: "locker-storages",
targetId: locker.id,
data: { entryId, skinId, skinName: skin.name },
});
return { success: true };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return message === "Unauthorized"
? { success: false, error: "You must be logged in." }
: { success: false, error: message };
}
}
export async function renameLockerItem(
entryId: string,
customName: string,
): Promise<ActionResult> {
try {
const { payload, user } = await authenticate();
const locker = await getLockerWithItems(payload, user.id as number);
const items = [...(locker.items ?? [])];
const idx = items.findIndex((e) => e.id === entryId);
if (idx < 0) return { success: false, error: "Item not found in locker." };
const entry = items[idx];
const asset = entry.asset as Asset;
if (!asset || typeof asset === "number") return { success: false, error: "Invalid asset." };
const name = customName.trim();
items[idx] = { ...entry, equipment: { ...(entry.equipment ?? {}), customName: name || null } };
await payload.update({
collection: "locker-storages",
id: locker.id,
data: { items },
overrideAccess: true,
depth: 0,
});
await emitGameEvent(payload, {
type: EventTypes.LockerItemRename,
message: name
? `Renamed ${asset.name} to "${name}"`
: `Cleared custom name on ${asset.name}`,
actor: user.id,
targetCollection: "locker-storages",
targetId: locker.id,
data: { entryId, assetId: asset.id, assetName: asset.name, customName: name || null },
});
return { success: true };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return message === "Unauthorized"
? { success: false, error: "You must be logged in." }
: { success: false, error: message };
}
}
export async function attachItemFromGrid(
weaponEntryId: string,
attachmentEntryId: string,
): Promise<ActionResult> {
try {
const { payload, user } = await authenticate();
const locker = await getLockerWithItems(payload, user.id as number);
const grid = await getLockerGridDimensions(payload);
const items = [...(locker.items ?? [])];
const weaponIdx = items.findIndex((e) => e.id === weaponEntryId);
const attIdx = items.findIndex((e) => e.id === attachmentEntryId);
if (weaponIdx < 0) return { success: false, error: "Weapon not found in locker." };
if (attIdx < 0) return { success: false, error: "Item not found in locker." };
const weapon = items[weaponIdx].asset as Asset;
if (!weapon || typeof weapon === "number" || weapon.assetType !== "weapon") {
return { success: false, error: "Attachments can only be installed on weapons." };
}
const attachment = items[attIdx].asset as Asset;
if (!attachment || typeof attachment === "number") {
return { success: false, error: "Invalid item." };
}
const category = ATTACHMENT_CATEGORIES.find((c) => c.assetType === attachment.assetType);
if (!category) {
return { success: false, error: "This item type cannot be attached to a weapon." };
}
if (!isAttachmentCompatible(weapon, attachment)) {
return {
success: false,
error: `"${attachment.name}" is not compatible with ${weapon.name}.`,
};
}
const already = (items[weaponIdx].equipment?.attachments ?? []).some(
(a) => resolveAssetId(a.asset) === attachment.id,
);
if (already) {
return { success: false, error: "This attachment is already installed on this weapon." };
}
let working = [...items];
const dragged = working[attIdx];
if (dragged.quantity > 1) {
working[attIdx] = { ...dragged, quantity: dragged.quantity - 1 };
} else {
working.splice(attIdx, 1);
}
let existingAsset: Asset | null = null;
for (const a of items[weaponIdx].equipment?.attachments ?? []) {
const id = resolveAssetId(a.asset);
if (id == null) continue;
const existing = await getAssetOrThrow(payload, id);
if (existing.assetType === category.assetType) {
existingAsset = existing;
break;
}
}
if (existingAsset) {
const fp = getLockerItemFootprint(existingAsset, false);
const spot = findLockerEmptySpot(
toLockerGridItems(working),
fp.width,
fp.height,
grid.width,
grid.height,
);
if (!spot) {
return { success: false, error: `No space in locker to return ${existingAsset.name}.` };
}
working.push({
asset: existingAsset.id,
quantity: 1,
gridX: spot.x,
gridY: spot.y,
rotated: false,
});
}
const wIdx = working.findIndex((e) => e.id === weaponEntryId);
if (wIdx < 0) return { success: false, error: "Weapon not found in locker." };
const kept = (working[wIdx].equipment?.attachments ?? []).filter(
(a) => resolveAssetId(a.asset) !== existingAsset?.id,
);
working[wIdx] = {
...working[wIdx],
equipment: {
...(working[wIdx].equipment ?? {}),
attachments: [...kept, { asset: attachment.id }],
},
};
await payload.update({
collection: "locker-storages",
id: locker.id,
data: { items: working },
overrideAccess: true,
depth: 0,
});
if (existingAsset) {
await emitGameEvent(payload, {
type: EventTypes.LockerItemDetach,
message: `Removed ${existingAsset.name} from ${weapon.name}`,
actor: user.id,
targetCollection: "locker-storages",
targetId: locker.id,
data: {
entryId: weaponEntryId,
weaponId: weapon.id,
weaponName: weapon.name,
attachmentId: existingAsset.id,
attachmentName: existingAsset.name,
},
});
}
await emitGameEvent(payload, {
type: EventTypes.LockerItemAttach,
message: `Installed ${attachment.name} on ${weapon.name}`,
actor: user.id,
targetCollection: "locker-storages",
targetId: locker.id,
data: {
entryId: weaponEntryId,
weaponId: weapon.id,
weaponName: weapon.name,
attachmentId: attachment.id,
attachmentName: attachment.name,
},
});
return { success: true };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return message === "Unauthorized"
? { success: false, error: "You must be logged in." }
: { success: false, error: message };
}
}

View file

@ -70,6 +70,7 @@ export default async function LockerPage() {
loadouts={loadouts}
assetsCatalog={assetsCatalog}
isManager={isManager}
userName={user?.username ?? "Member"}
/>
</div>
);

View file

@ -52,6 +52,15 @@ export const Loadouts: CollectionConfig = {
description: "The operator this loadout belongs to.",
},
},
{
name: "equipped",
type: "checkbox",
defaultValue: false,
admin: {
description:
"Whether this is the member's currently equipped loadout. Only one loadout per member can be equipped; the wardrobe reflects the equipped loadout.",
},
},
{
name: "slots",
type: "group",

View file

@ -89,6 +89,55 @@ export const LockerStorages: CollectionConfig = {
description: "Whether this item is rotated 90 degrees (swaps width and height).",
},
},
{
name: "equipment",
type: "group",
label: "Equipment Customization",
admin: {
description:
"Per-instance customization for this item: installed attachments, applied skin, and a custom name. Managed from the member locker UI.",
},
fields: [
{
name: "attachments",
type: "array",
label: "Installed Attachments",
admin: {
description:
"Attachments installed on this item (e.g. optics, muzzles, bipods on a weapon). Installing consumes the attachment from the locker grid.",
},
fields: [
{
name: "asset",
type: "relationship",
relationTo: "assets",
required: true,
},
],
},
{
name: "skin",
type: "relationship",
relationTo: "assets",
admin: {
description:
"Skin applied to this item. Applying consumes the skin from the locker grid.",
},
filterOptions: {
assetType: {
equals: "skin",
},
},
},
{
name: "customName",
type: "text",
admin: {
description: "Optional custom name for this item (e.g. \"Frank's Karma\").",
},
},
],
},
],
},
{

View file

@ -119,6 +119,10 @@ export const Assets: CollectionConfig = {
label: "Explosive",
value: "explosive",
},
{
label: "Skin",
value: "skin",
},
{
label: "Miscellaneous",
value: "miscellaneous",
@ -232,6 +236,32 @@ export const Assets: CollectionConfig = {
},
],
},
{
name: "Skin Configuration",
type: "group",
admin: {
condition: (data, siblingData) => siblingData.assetType === "skin",
},
fields: [
{
name: "appliesToAssetTypes",
type: "select",
hasMany: true,
label: "Applies To",
options: [
{ label: "Weapon", value: "weapon" },
{ label: "Uniform", value: "uniform" },
{ label: "Vest", value: "vest" },
{ label: "Backpack", value: "backpack" },
{ label: "Helmet", value: "helmet" },
],
admin: {
description:
"Which equipment types this skin can be applied to. Leave empty to allow on any equipment.",
},
},
],
},
{
name: "Magazine Configuration",
type: "group",

View file

@ -0,0 +1,293 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import type { Asset } from "@/payload-types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
getAssetTypeLabel,
getAttachmentOptionsForItem,
getSkinOptionsForItem,
resolveAssetId,
type LockerGridItem,
} from "@/lib/locker";
import { getAssetIcon } from "./LockerGrid";
import {
removeItemAttachment,
removeItemSkin,
renameLockerItem,
setItemAttachment,
setItemSkin,
} from "@/app/(frontend)/locker/actions";
interface EquipmentEditorDialogProps {
item: LockerGridItem | null;
items: LockerGridItem[];
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function EquipmentEditorDialog({
item,
items,
open,
onOpenChange,
}: EquipmentEditorDialogProps) {
const router = useRouter();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [customName, setCustomName] = useState("");
useEffect(() => {
setCustomName(item?.equipment?.customName ?? "");
setError(null);
}, [item, open]);
const resolvedItem = useMemo(
() => (item ? items.find((i) => i.id === item.id) ?? item : null),
[item, items],
);
const attachmentOptions = useMemo(
() => (resolvedItem ? getAttachmentOptionsForItem(resolvedItem.asset, items) : []),
[resolvedItem, items],
);
const installedAssets = useMemo(() => {
const list: Asset[] = [];
for (const a of resolvedItem?.equipment?.attachments ?? []) {
const resolved = resolveAssetId(a.asset);
if (resolved == null) continue;
const found = items.find((i) => i.assetId === resolved)?.asset;
if (found) list.push(found);
else if (typeof a.asset === "object" && "id" in a.asset) list.push(a.asset as Asset);
}
return list;
}, [resolvedItem, items]);
const installedSkinId = resolveAssetId(resolvedItem?.equipment?.skin);
const installedSkin = useMemo(() => {
if (installedSkinId == null) return null;
return items.find((i) => i.assetId === installedSkinId)?.asset ?? null;
}, [items, installedSkinId]);
const skinOptions = useMemo(
() => (resolvedItem ? getSkinOptionsForItem(resolvedItem.asset, items) : []),
[resolvedItem, items],
);
const mergedSkinOptions = useMemo(() => {
if (!installedSkin) return skinOptions;
return skinOptions.some((o) => o.assetId === installedSkin.id)
? skinOptions
: [{ assetId: installedSkin.id, quantity: 0, asset: installedSkin }, ...skinOptions];
}, [skinOptions, installedSkin]);
if (!resolvedItem) return null;
const activeItem = resolvedItem;
async function handleCategoryPick(
category: { name: string; label: string; assetType: Asset["assetType"] },
value: string,
) {
setBusy(true);
setError(null);
const installed = installedAssets.find((a) => a.assetType === category.assetType);
const assetId = value === "none" ? null : Number(value);
if (assetId == null) {
if (installed) {
const removed = await removeItemAttachment(activeItem.id, installed.id);
if (!removed.success) setError(removed.error ?? "Failed to remove attachment.");
else router.refresh();
}
setBusy(false);
return;
}
if (installed && installed.id !== assetId) {
const removed = await removeItemAttachment(activeItem.id, installed.id);
if (!removed.success) {
setError(removed.error ?? "Failed to replace current attachment.");
setBusy(false);
return;
}
}
const res = await setItemAttachment(activeItem.id, assetId);
if (!res.success) setError(res.error ?? "Failed to install attachment.");
else router.refresh();
setBusy(false);
}
async function handleSkinPick(value: string) {
setBusy(true);
setError(null);
const assetId = value === "none" ? null : Number(value);
const res =
assetId == null
? await removeItemSkin(activeItem.id)
: await setItemSkin(activeItem.id, assetId);
if (!res.success) setError(res.error ?? "Failed to update skin.");
else router.refresh();
setBusy(false);
}
async function handleRename() {
setBusy(true);
setError(null);
const res = await renameLockerItem(activeItem.id, customName);
if (!res.success) setError(res.error ?? "Failed to rename item.");
else router.refresh();
setBusy(false);
}
const displayName = customName.trim() || activeItem.asset.name;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Customize {displayName}</DialogTitle>
<DialogDescription>
Install attachments, apply a skin, and give your {getAssetTypeLabel(activeItem.asset.assetType).toLowerCase()} a name.
Installing consumes the item from your locker grid; removing returns it.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-5">
<div className="flex items-center gap-3 rounded-md border p-3">
<span className="text-amber-400">{getAssetIcon(activeItem.asset.assetType, "size-6")}</span>
<div className="min-w-0">
<div className="font-medium truncate">{activeItem.asset.name}</div>
<div className="text-xs text-muted-foreground">
{getAssetTypeLabel(activeItem.asset.assetType)} · {activeItem.quantity.toLocaleString()}×
</div>
</div>
</div>
{attachmentOptions.length > 0 && (
<div className="flex flex-col gap-3">
<Label className="text-sm font-semibold">Attachments</Label>
{attachmentOptions.map(({ category, options }) => {
const installed = installedAssets.find((a) => a.assetType === category.assetType);
const categoryOptions =
installed && !options.some((o) => o.assetId === installed.id)
? [{ assetId: installed.id, quantity: 0, asset: installed }, ...options]
: options;
return (
<div key={category.name} className="grid grid-cols-[100px_1fr] items-center gap-2">
<span className="text-xs text-muted-foreground">{category.label}</span>
<Select
value={installed ? String(installed.id) : "none"}
onValueChange={(v) => handleCategoryPick(category, v)}
disabled={busy}
>
<SelectTrigger>
<SelectValue
placeholder={categoryOptions.length === 0 ? "No compatible items" : "Choose an attachment"}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{categoryOptions.map((o) => (
<SelectItem key={o.assetId} value={String(o.assetId)}>
{o.asset.name} ·{" "}
{o.quantity > 0 ? `${o.quantity.toLocaleString()}×` : "installed"}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
})}
<p className="text-xs text-muted-foreground">
Compatible with this weapon based on its catalog entry. Replacing an attachment returns
the old one to your locker.
</p>
</div>
)}
<div className="flex flex-col gap-3">
<Label className="text-sm font-semibold">Skin</Label>
<div className="grid grid-cols-[100px_1fr] items-center gap-2">
<span className="text-xs text-muted-foreground">Applied</span>
<Select
value={installedSkin ? String(installedSkin.id) : "none"}
onValueChange={handleSkinPick}
disabled={busy}
>
<SelectTrigger>
<SelectValue
placeholder={mergedSkinOptions.length === 0 ? "No compatible skins" : "Choose a skin"}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{mergedSkinOptions.map((o) => (
<SelectItem key={o.assetId} value={String(o.assetId)}>
{o.asset.name} · {o.quantity > 0 ? `${o.quantity.toLocaleString()}×` : "applied"}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{installedSkin && (
<p className="text-xs text-muted-foreground">
Currently applied: {installedSkin.name}. Choosing another skin returns the old one to your locker.
</p>
)}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="custom-name" className="text-sm font-semibold">
Custom Name
</Label>
<div className="flex items-center gap-2">
<Input
id="custom-name"
value={customName}
onChange={(e) => setCustomName(e.target.value)}
placeholder="e.g., Frank's Karma"
maxLength={60}
/>
<Button variant="outline" size="sm" onClick={handleRename} disabled={busy}>
Save
</Button>
</div>
{customName.trim() && (
<p className="text-xs text-muted-foreground">
Will display as &quot;{customName.trim()}&quot; in your locker and wardrobe.
</p>
)}
</div>
{error && <p className="text-sm text-red-400">{error}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Done
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -1,10 +1,10 @@
"use client";
import { PencilIcon, TrashIcon } from "lucide-react";
import { CheckCircle2Icon, PencilIcon, TrashIcon } from "lucide-react";
import type { Asset, Loadout } from "@/payload-types";
import { Button } from "@/components/ui/button";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import { getAssetTypeLabel, LOADOUT_SLOTS, type LoadoutSlots } from "@/lib/locker";
import { getAssetTypeLabel, LOADOUT_SLOTS, type LockerGridItem, type LoadoutSlots } from "@/lib/locker";
function resolveSlotValue(value: LoadoutSlots[keyof LoadoutSlots]): Asset | null {
if (value == null) return null;
@ -12,13 +12,20 @@ function resolveSlotValue(value: LoadoutSlots[keyof LoadoutSlots]): Asset | null
return null;
}
interface LoadoutCardProps {
loadout: Loadout;
onEdit: () => void;
onDelete: () => void;
function slotDisplayName(asset: Asset, ownedItems: LockerGridItem[]): string {
const custom = ownedItems.find((i) => i.assetId === asset.id)?.equipment?.customName?.trim();
return custom || asset.name;
}
export function LoadoutCard({ loadout, onEdit, onDelete }: LoadoutCardProps) {
interface LoadoutCardProps {
loadout: Loadout;
ownedItems: LockerGridItem[];
onEdit: () => void;
onDelete: () => void;
onEquip: () => void;
}
export function LoadoutCard({ loadout, ownedItems, onEdit, onDelete, onEquip }: LoadoutCardProps) {
const slots = (loadout.slots ?? {}) as LoadoutSlots;
const filled = LOADOUT_SLOTS.filter((slot) => {
const v = slots[slot.name];
@ -28,11 +35,26 @@ export function LoadoutCard({ loadout, onEdit, onDelete }: LoadoutCardProps) {
return (
<Item variant="outline">
<ItemHeader>
<ItemTitle>{loadout.name}</ItemTitle>
<ItemTitle className="flex items-center gap-2">
{loadout.name}
{loadout.equipped && (
<span className="inline-flex items-center gap-1 text-xs font-medium text-amber-400">
<CheckCircle2Icon className="size-3.5" />
Equipped
</span>
)}
</ItemTitle>
{loadout.description && (
<span className="text-xs text-muted-foreground">{loadout.description}</span>
)}
<div className="flex items-center gap-1">
<Button
variant={loadout.equipped ? "default" : "outline"}
size="sm"
onClick={onEquip}
>
{loadout.equipped ? "Unequip" : "Equip"}
</Button>
<Button variant="outline" size="sm" onClick={onEdit}>
<PencilIcon className="size-3.5" />
Edit
@ -57,7 +79,7 @@ export function LoadoutCard({ loadout, onEdit, onDelete }: LoadoutCardProps) {
className="inline-flex items-center gap-1.5 text-xs bg-muted px-2.5 py-1 rounded-md"
>
<span className="text-muted-foreground">{slot.label}:</span>
<span className="font-medium">{asset.name}</span>
<span className="font-medium">{slotDisplayName(asset, ownedItems)}</span>
<span className="text-muted-foreground">({getAssetTypeLabel(asset.assetType)})</span>
</span>
);

View file

@ -8,7 +8,7 @@ import { Button } from "@/components/ui/button";
import { LoadoutCard } from "./LoadoutCard";
import { LoadoutEditor } from "./LoadoutEditor";
import type { LockerGridItem } from "@/lib/locker";
import { deleteLoadout } from "@/app/(frontend)/locker/actions";
import { deleteLoadout, equipLoadout } from "@/app/(frontend)/locker/actions";
interface LoadoutsPanelProps {
loadouts: Loadout[];
@ -26,6 +26,11 @@ export function LoadoutsPanel({ loadouts, ownedItems }: LoadoutsPanelProps) {
if (res.success) router.refresh();
}
async function handleEquip(loadout: Loadout) {
const res = await equipLoadout(loadout.id, !loadout.equipped);
if (res.success) router.refresh();
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
@ -48,8 +53,10 @@ export function LoadoutsPanel({ loadouts, ownedItems }: LoadoutsPanelProps) {
<LoadoutCard
key={loadout.id}
loadout={loadout}
ownedItems={ownedItems}
onEdit={() => setEditing(loadout)}
onDelete={() => handleDelete(loadout)}
onEquip={() => handleEquip(loadout)}
/>
))}
</div>

View file

@ -8,6 +8,7 @@ import {
ScissorsIcon,
LayoutGridIcon,
ListIcon,
SlidersHorizontalIcon,
} from "lucide-react";
import {
findLockerEmptySpot,
@ -30,6 +31,7 @@ interface LockerGridToolbarProps {
onRotate: (entryId: string) => Promise<void>;
onRemove: (entryId: string) => Promise<void>;
onSplit: (entryId: string, amount: number) => Promise<void>;
onCustomize: () => void;
}
export function LockerGridToolbar({
@ -43,10 +45,15 @@ export function LockerGridToolbar({
onRotate,
onRemove,
onSplit,
onCustomize,
}: LockerGridToolbarProps) {
const occupancy = getLockerGridOccupancy(items, gridWidth, gridHeight);
const asset = selectedItem?.asset ?? null;
const fp = selectedItem && asset ? getLockerItemFootprint(asset, selectedItem.rotated) : null;
const displayName = selectedItem?.equipment?.customName?.trim() || asset?.name || null;
const hasCustomName =
selectedItem?.equipment?.customName?.trim() != null &&
selectedItem.equipment.customName.trim() !== "";
const canSplit =
selectedItem && asset && fp
? findLockerEmptySpot(items, fp.width, fp.height, gridWidth, gridHeight) !== null
@ -83,14 +90,21 @@ export function LockerGridToolbar({
{selectedItem && asset && (
<div className="flex items-center gap-3 p-2 rounded-md border bg-muted/30 text-xs">
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{asset.name}</div>
<div className="font-medium truncate">{displayName}</div>
<div className="text-muted-foreground">
{selectedItem.quantity.toLocaleString()}× · {getAssetTypeLabel(asset.assetType)}
{hasCustomName && asset && displayName !== asset.name && ` · as "${asset.name}"`}
{fp && ` · ${fp.width}x${fp.height}`}
{selectedItem.rotated && " · rotated"}
</div>
</div>
<div className="flex items-center gap-1">
{asset?.assetType === "weapon" && (
<Button variant="outline" size="sm" onClick={onCustomize}>
<SlidersHorizontalIcon className="size-3.5" />
Customize
</Button>
)}
{lockerIsRotatable(asset) && (
<Button variant="outline" size="sm" onClick={() => onRotate(selectedItem.id)}>
<RotateCwIcon className="size-3.5" />

View file

@ -1,6 +1,6 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import type { Asset, Loadout, LockerStorage } from "@/payload-types";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@ -10,9 +10,14 @@ import { LockerGrid } from "./LockerGrid";
import { LockerGridToolbar } from "./LockerGridToolbar";
import { AddLockerItemDialog } from "./AddLockerItemDialog";
import { LoadoutsPanel } from "./LoadoutsPanel";
import { WardrobePanel } from "./WardrobePanel";
import { EquipmentEditorDialog } from "./EquipmentEditorDialog";
import { LockerSearch } from "./LockerSearch";
import { getAssetTypeLabel, type LockerGridItem } from "@/lib/locker";
import type { LockerSearchEntry } from "@/lib/locker/search";
import {
autoArrangeLocker,
attachItemFromGrid,
mergeLockerStacks,
moveLockerItem,
removeLockerItem,
@ -27,6 +32,7 @@ interface LockerViewProps {
loadouts: Loadout[];
assetsCatalog: Asset[];
isManager: boolean;
userName: string;
}
export function LockerView({
@ -36,10 +42,14 @@ export function LockerView({
loadouts,
assetsCatalog,
isManager,
userName,
}: LockerViewProps) {
const router = useRouter();
const [tab, setTab] = useState("wardrobe");
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
const [selectedItem, setSelectedItem] = useState<LockerGridItem | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [customizeId, setCustomizeId] = useState<string | null>(null);
const [highlightId, setHighlightId] = useState<string | null>(null);
const [eventLogKey, setEventLogKey] = useState(0);
const items: LockerGridItem[] = useMemo(
@ -56,11 +66,28 @@ export function LockerView({
gridY: entry.gridY ?? 0,
rotated: entry.rotated ?? false,
asset,
equipment: entry.equipment ?? undefined,
};
}),
[locker.items],
);
const selectedItem = useMemo(
() => items.find((i) => i.id === selectedId) ?? null,
[items, selectedId],
);
const customizeItem = useMemo(
() => items.find((i) => i.id === customizeId) ?? null,
[items, customizeId],
);
useEffect(() => {
if (!highlightId) return;
const timer = setTimeout(() => setHighlightId(null), 3000);
return () => clearTimeout(timer);
}, [highlightId]);
const refresh = useCallback(() => {
router.refresh();
setEventLogKey((k) => k + 1);
@ -94,7 +121,7 @@ export function LockerView({
async (entryId: string) => {
const result = await removeLockerItem(entryId);
if (result.success) {
setSelectedItem(null);
setSelectedId(null);
refresh();
}
},
@ -114,12 +141,65 @@ export function LockerView({
if (result.success) refresh();
}, [refresh]);
const handleAttachItem = useCallback(
async (weaponEntryId: string, attachmentEntryId: string) => {
const result = await attachItemFromGrid(weaponEntryId, attachmentEntryId);
if (result.success) refresh();
return result;
},
[refresh],
);
const handleSearchSelect = useCallback(
(entry: LockerSearchEntry) => {
setViewMode("grid");
setTab("inventory");
const gridItem = items.find((i) => i.assetId === entry.assetId) ?? null;
if (gridItem && entry.asset.assetType === "weapon") {
setSelectedId(gridItem.id);
setCustomizeId(gridItem.id);
return;
}
const parentId = entry.locations.find(
(l) => (l.kind === "attached" || l.kind === "skin") && l.parentEntryId,
)?.parentEntryId;
if (parentId) {
const parent = items.find((i) => i.id === parentId);
if (parent && parent.asset.assetType === "weapon") {
setSelectedId(parent.id);
setCustomizeId(parent.id);
return;
}
}
if (gridItem) setHighlightId(gridItem.id);
},
[items],
);
return (
<Tabs defaultValue="inventory">
<TabsList>
<TabsTrigger value="inventory">Inventory</TabsTrigger>
<TabsTrigger value="loadouts">Loadouts</TabsTrigger>
</TabsList>
<Tabs value={tab} onValueChange={setTab}>
<div className="flex items-center justify-between gap-3">
<TabsList>
<TabsTrigger value="wardrobe">Wardrobe</TabsTrigger>
<TabsTrigger value="inventory">Inventory</TabsTrigger>
<TabsTrigger value="loadouts">Loadouts</TabsTrigger>
</TabsList>
<LockerSearch items={items} loadouts={loadouts} onSelect={handleSearchSelect} />
</div>
<TabsContent value="wardrobe">
<WardrobePanel
userName={userName}
loadouts={loadouts}
equippedLoadout={loadouts.find((l) => l.equipped) ?? null}
items={items}
/>
</TabsContent>
<TabsContent value="inventory" className="flex flex-col gap-4">
<div className="flex items-center justify-between">
@ -134,6 +214,7 @@ export function LockerView({
onRotate={handleRotateItem}
onRemove={handleRemoveItem}
onSplit={handleSplitStack}
onCustomize={() => setCustomizeId(selectedItem?.id ?? null)}
/>
{isManager && <AddLockerItemDialog assets={assetsCatalog} />}
</div>
@ -160,8 +241,10 @@ export function LockerView({
onMergeStacks={handleMergeStacks}
onRotateItem={handleRotateItem}
onRemoveItem={handleRemoveItem}
onSelectItem={setSelectedItem}
onSelectItem={(item) => setSelectedId(item?.id ?? null)}
selectedItem={selectedItem}
highlightId={highlightId}
onAttachItem={handleAttachItem}
/>
) : (
<Item variant="outline">
@ -172,10 +255,10 @@ export function LockerView({
<button
type="button"
className="flex items-center gap-3 py-2.5 w-full text-left"
onClick={() => setSelectedItem(entry)}
onClick={() => setSelectedId(entry.id)}
>
<span className="text-sm font-medium flex-1 min-w-0 truncate">
{entry.asset.name}
{entry.equipment?.customName?.trim() || entry.asset.name}
</span>
<span className="text-xs text-muted-foreground">
{getAssetTypeLabel(entry.asset.assetType)}
@ -202,6 +285,15 @@ export function LockerView({
<TabsContent value="loadouts">
<LoadoutsPanel loadouts={loadouts} ownedItems={items} />
</TabsContent>
<EquipmentEditorDialog
item={customizeItem}
items={items}
open={!!customizeItem}
onOpenChange={(open) => {
if (!open) setCustomizeId(null);
}}
/>
</Tabs>
);
}

View file

@ -0,0 +1,116 @@
"use client";
import { useMemo } from "react";
import {
Backpack,
Crosshair,
Eye,
Glasses,
Rocket,
Shield,
Shirt,
Target,
User,
} from "lucide-react";
import type { Asset, Loadout } from "@/payload-types";
import {
WARDROBE_SLOT_LAYOUT,
getLoadoutSlot,
type WardrobeSlotIcon,
type LoadoutSlots,
type LockerGridItem,
} from "@/lib/locker";
import { cn } from "@/lib/utils";
import { getAssetIcon } from "./LockerGrid";
function slotIcon(icon: WardrobeSlotIcon, filled: boolean) {
const cls = filled ? "size-3.5" : "size-3";
switch (icon) {
case "head":
return <User className={cls} />;
case "eye":
return <Eye className={cls} />;
case "face":
return <Glasses className={cls} />;
case "chest":
return <Shield className={cls} />;
case "torso":
return <Shirt className={cls} />;
case "back":
return <Backpack className={cls} />;
case "weapon":
return <Crosshair className={cls} />;
case "sidearm":
return <Target className={cls} />;
case "heavy":
return <Rocket className={cls} />;
}
}
function resolveSlotAsset(
value: LoadoutSlots[keyof LoadoutSlots],
items: LockerGridItem[],
): Asset | null {
if (value == null) return null;
if (typeof value === "object" && "id" in value) return value as Asset;
const id = value as number;
return items.find((i) => i.assetId === id)?.asset ?? null;
}
export function Wardrobe({
loadout,
items,
onSlotClick,
}: {
loadout: Loadout;
items: LockerGridItem[];
onSlotClick: (slotName: string) => void;
}) {
const slots = (loadout.slots ?? {}) as LoadoutSlots;
const filled = useMemo(() => {
const set = new Set<string>();
for (const layout of WARDROBE_SLOT_LAYOUT) {
const value = slots[layout.name];
if (value != null) set.add(layout.zone);
}
return set;
}, [slots]);
return (
<div className="relative mx-auto w-full max-w-100">
<div className="relative" style={{ aspectRatio: "260 / 380" }}>
<img
src="/images/soldier-pose-alpha.png"
alt="Character wardrobe reflecting the equipped loadout"
className="h-full w-full object-contain pointer-events-none select-none"
draggable={false}
/>
{WARDROBE_SLOT_LAYOUT.map((layout) => {
const value = slots[layout.name];
const asset = resolveSlotAsset(value, items);
return (
<button
key={layout.name}
type="button"
title={asset ? `${layout.label}: ${asset.name}` : `${layout.label} (empty)`}
aria-label={`${layout.label} slot: ${asset ? asset.name : "empty"}`}
onClick={() => onSlotClick(layout.name)}
className={cn(
"absolute flex items-center justify-center rounded-full border transition-colors",
"-translate-x-1/2 -translate-y-1/2 cursor-pointer",
asset
? "size-7 border-amber-400/70 bg-amber-500/25 text-amber-200 hover:bg-amber-500/40"
: "size-6 border-border bg-background/80 text-muted-foreground hover:border-primary hover:text-foreground",
)}
style={{ left: `${layout.x}%`, top: `${layout.y}%` }}
>
{asset ? getAssetIcon(asset.assetType, "size-3.5") : slotIcon(layout.icon, false)}
</button>
);
})}
</div>
</div>
);
}

View file

@ -0,0 +1,218 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import type { Asset, Loadout } from "@/payload-types";
import { Button } from "@/components/ui/button";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Wardrobe } from "./Wardrobe";
import { WardrobeSlotPicker } from "./WardrobeSlotPicker";
import {
WARDROBE_SLOT_LAYOUT,
getAssetTypeLabel,
getWardrobeSlot,
getLoadoutSlot,
type LoadoutSlots,
type LockerGridItem,
} from "@/lib/locker";
import { equipLoadout, setLoadoutSlot } from "@/app/(frontend)/locker/actions";
import { getAssetIcon } from "./LockerGrid";
interface WardrobePanelProps {
userName: string;
loadouts: Loadout[];
equippedLoadout: Loadout | null;
items: LockerGridItem[];
}
function resolveSlotAsset(
value: LoadoutSlots[keyof LoadoutSlots],
items: LockerGridItem[],
): Asset | null {
if (value == null) return null;
if (typeof value === "object" && "id" in value) return value as Asset;
const id = value as number;
return items.find((i) => i.assetId === id)?.asset ?? null;
}
function resolveSlotDisplayName(asset: Asset, items: LockerGridItem[]): string {
const custom = items.find((i) => i.assetId === asset.id)?.equipment?.customName?.trim();
return custom || asset.name;
}
export function WardrobePanel({ userName, loadouts, equippedLoadout, items }: WardrobePanelProps) {
const router = useRouter();
const [pickerSlot, setPickerSlot] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const slots = (equippedLoadout?.slots ?? {}) as LoadoutSlots;
async function handleEquip(loadoutId: number) {
setBusy(true);
const res = await equipLoadout(loadoutId);
setBusy(false);
if (res.success) router.refresh();
}
async function handlePick(slotName: string, assetId: number | null) {
if (!equippedLoadout) return;
setBusy(true);
const res = await setLoadoutSlot(equippedLoadout.id, slotName, assetId);
setBusy(false);
if (res.success) {
setPickerSlot(null);
router.refresh();
}
}
const pickerLayout = pickerSlot ? getWardrobeSlot(pickerSlot) : null;
const pickerConfig = pickerSlot ? getLoadoutSlot(pickerSlot) : null;
const pickerValue = pickerSlot ? slots[pickerSlot as keyof LoadoutSlots] ?? null : null;
const filled = equippedLoadout
? WARDROBE_SLOT_LAYOUT.filter((layout) => slots[layout.name] != null)
: [];
return (
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,500px)_1fr] gap-6">
<Item variant="outline" className="self-start">
<ItemHeader>
<ItemTitle>Character</ItemTitle>
</ItemHeader>
<ItemContent>
<Wardrobe
loadout={
equippedLoadout ?? ({ name: "Empty", slots: {} } as unknown as Loadout)
}
items={items}
onSlotClick={(name) => setPickerSlot(name)}
/>
<p className="text-center text-sm text-muted-foreground mt-3">
{equippedLoadout
? "Click a highlighted point to equip gear from your locker."
: "Equip a loadout to start dressing your character."}
</p>
</ItemContent>
</Item>
<div className="flex flex-col gap-4">
<Item variant="outline">
<ItemHeader>
<ItemTitle>Equipped Loadout</ItemTitle>
</ItemHeader>
<ItemContent>
{loadouts.length === 0 ? (
<p className="text-sm text-muted-foreground">
You have no loadouts yet. Head to the Loadouts tab to create one, then come back
here to equip it.
</p>
) : (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<Select
value={equippedLoadout ? String(equippedLoadout.id) : ""}
onValueChange={(v) => handleEquip(Number(v))}
disabled={busy}
>
<SelectTrigger className="flex-1">
<SelectValue
placeholder={equippedLoadout ? equippedLoadout.name : "Select a loadout to equip"}
/>
</SelectTrigger>
<SelectContent>
{loadouts.map((l) => (
<SelectItem key={l.id} value={String(l.id)}>
{l.name}
</SelectItem>
))}
</SelectContent>
</Select>
{equippedLoadout && (
<Button
variant="outline"
size="sm"
onClick={() => setPickerSlot("helmet")}
disabled={busy}
>
Equip Gear
</Button>
)}
</div>
{!equippedLoadout && (
<p className="text-sm text-muted-foreground">
Choose a loadout above — it becomes the active loadout that the wardrobe reflects.
</p>
)}
</div>
)}
</ItemContent>
</Item>
<Item variant="outline">
<ItemHeader>
<ItemTitle>Current Equipment</ItemTitle>
</ItemHeader>
<ItemContent>
{filled.length === 0 ? (
<p className="text-sm text-muted-foreground">
Nothing equipped. Select a loadout above and click its equipment slots.
</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5">
{filled.map((layout) => {
const asset = resolveSlotAsset(slots[layout.name], items);
if (!asset) return null;
return (
<button
key={layout.name}
type="button"
onClick={() => setPickerSlot(layout.name)}
className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-left text-sm hover:bg-muted transition-colors"
>
<span className="text-amber-400">{getAssetIcon(asset.assetType, "size-4")}</span>
<span className="flex-1 min-w-0">
<span className="block text-xs text-muted-foreground">{layout.label}</span>
<span className="block font-medium truncate">
{resolveSlotDisplayName(asset, items)}
</span>
</span>
<span className="text-xs text-muted-foreground">
{getAssetTypeLabel(asset.assetType)}
</span>
</button>
);
})}
</div>
)}
</ItemContent>
</Item>
<p className="text-sm text-muted-foreground">
{userName}, you have {items.length.toLocaleString()} distinct items in your locker.
</p>
</div>
{pickerLayout && pickerConfig && (
<WardrobeSlotPicker
open={!!pickerSlot}
onOpenChange={(open) => {
if (!open) setPickerSlot(null);
}}
slot={pickerConfig}
items={items}
currentValue={pickerValue}
busy={busy}
onPick={(assetId) => handlePick(pickerLayout.name, assetId)}
/>
)}
</div>
);
}

View file

@ -0,0 +1,117 @@
"use client";
import { useMemo } from "react";
import type { Asset } from "@/payload-types";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { getAssetTypeLabel, type LoadoutSlotConfig, type LockerGridItem } from "@/lib/locker";
import { getAssetIcon } from "./LockerGrid";
import { cn } from "@/lib/utils";
interface WardrobeSlotPickerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
slot: LoadoutSlotConfig;
items: LockerGridItem[];
currentValue: number | Asset | null;
busy: boolean;
onPick: (assetId: number | null) => void;
}
function resolveCurrentId(value: number | Asset | null): number | null {
if (value == null) return null;
if (typeof value === "object" && "id" in value) return value.id;
return value;
}
export function WardrobeSlotPicker({
open,
onOpenChange,
slot,
items,
currentValue,
busy,
onPick,
}: WardrobeSlotPickerProps) {
const options = useMemo(() => {
const types = new Set(slot.assetTypes);
const seen = new Set<number>();
return items.filter((entry) => {
if (seen.has(entry.assetId)) return false;
if (!types.has(entry.asset.assetType)) return false;
seen.add(entry.assetId);
return true;
});
}, [items, slot.assetTypes]);
const currentId = resolveCurrentId(currentValue);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{slot.label} Slot</DialogTitle>
<DialogDescription>
Choose an item from your locker to equip in this slot, or clear it.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-1.5">
{options.length === 0 && (
<p className="text-sm text-muted-foreground py-2">
No matching items in your locker for this slot.
</p>
)}
{options.map((entry) => {
const selected = entry.assetId === currentId;
return (
<button
key={entry.assetId}
type="button"
disabled={busy}
onClick={() => onPick(entry.assetId)}
className={cn(
"flex items-center gap-3 rounded-md border px-3 py-2 text-left text-sm transition-colors",
selected
? "border-primary bg-primary/10"
: "border-border hover:bg-muted",
)}
>
<span className="text-muted-foreground">{getAssetIcon(entry.asset.assetType)}</span>
<span className="flex-1 min-w-0">
<span className="block font-medium truncate">{entry.asset.name}</span>
<span className="block text-xs text-muted-foreground">
{getAssetTypeLabel(entry.asset.assetType)} · {entry.quantity.toLocaleString()}×
</span>
</span>
{selected && <span className="text-xs font-semibold text-primary">Equipped</span>}
</button>
);
})}
</div>
<DialogFooter>
<Button
variant="outline"
disabled={busy}
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
{currentId != null && (
<Button variant="ghost" disabled={busy} onClick={() => onPick(null)}>
Clear Slot
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -2,6 +2,16 @@ import type { Payload } from "payload";
import type { Asset, LockerStorage } from "@/payload-types";
import type { GridDimensions } from "@/lib/logistics";
/**
* Per-instance customization stored on a locker grid entry: installed
* attachments, applied skin, and an optional custom name.
*/
export type LockerItemEquipment = {
attachments?: { asset: number | Asset; id?: string | null }[] | null;
skin?: number | Asset | null;
customName?: string | null;
} | null;
/**
* A single entry in a user's locker grid. Mirrors the shape of the
* `items` array on the locker-storages collection, but with the asset
@ -15,8 +25,35 @@ export type LockerGridItem = {
gridY: number;
rotated: boolean;
asset: Asset;
equipment?: LockerItemEquipment;
};
/** Normalize raw locker `items` into populated grid items. */
export function toLockerGridItems(items: LockerStorage["items"]): LockerGridItem[] {
return (items ?? [])
.filter((e) => e.asset && typeof e.asset === "object" && "id" in e.asset)
.map((e) => {
const asset = e.asset as Asset;
return {
id: e.id ?? `${asset.id}-${e.gridX}-${e.gridY}`,
assetId: asset.id,
quantity: e.quantity,
gridX: e.gridX ?? 0,
gridY: e.gridY ?? 0,
rotated: e.rotated ?? false,
asset,
equipment: e.equipment ?? null,
};
});
}
/** Resolve the numeric id from a relationship value (id or populated doc). */
export function resolveAssetId(value: number | Asset | null | undefined): number | null {
if (value == null) return null;
if (typeof value === "object" && "id" in value) return value.id;
return value;
}
/** Equipment slot definitions for user loadouts. */
export type LoadoutSlotConfig = {
name: string;
@ -82,6 +119,8 @@ export function getAssetTypeLabel(assetType: Asset["assetType"]): string {
return "Tool";
case "explosive":
return "Explosive";
case "skin":
return "Skin";
case "miscellaneous":
return "Miscellaneous";
default:
@ -105,6 +144,7 @@ const ASSET_TYPE_COLORS: Record<Asset["assetType"], string> = {
medical: "oklch(0.45 0.14 20)",
tool: "oklch(0.45 0.1 80)",
explosive: "oklch(0.45 0.16 55)",
skin: "oklch(0.55 0.12 320)",
miscellaneous: "oklch(0.42 0 0)",
};
@ -318,3 +358,121 @@ export function findLockerMergeTarget(
}
return null;
}
// --- Wardrobe ---
export type WardrobeSlotIcon =
| "head"
| "eye"
| "face"
| "chest"
| "torso"
| "back"
| "weapon"
| "sidearm"
| "heavy";
export type WardrobeSlotLayout = {
name: LoadoutSlotName;
label: string;
/** Hotspot position as percentages of the wardrobe container (0-100). */
x: number;
y: number;
/** Which body zone this slot highlights. */
zone: string;
icon: WardrobeSlotIcon;
};
/** Positioning of each equipment slot on the wardrobe figure. */
export const WARDROBE_SLOT_LAYOUT: WardrobeSlotLayout[] = [
{ name: "helmet", label: "Helmet", x: 57, y: 10, zone: "helmet", icon: "head" },
{ name: "nvg", label: "NVG", x: 46, y: 8, zone: "nvg", icon: "eye" },
{ name: "facewear", label: "Facewear", x: 52, y: 15, zone: "facewear", icon: "face" },
{ name: "radio", label: "Radio", x: 47, y: 25, zone: "radio", icon: "chest" },
{ name: "vest", label: "Vest", x: 53, y: 32, zone: "vest", icon: "chest" },
{ name: "uniform", label: "Uniform", x: 53, y: 46, zone: "uniform", icon: "torso" },
{ name: "backpack", label: "Backpack", x: 68, y: 36, zone: "backpack", icon: "back" },
{ name: "primary", label: "Primary", x: 30, y: 19, zone: "primary", icon: "weapon" },
{ name: "optic", label: "Optic", x: 37, y: 18, zone: "primary", icon: "weapon" },
{ name: "muzzle", label: "Muzzle", x: 25, y: 6, zone: "primary", icon: "weapon" },
{ name: "bipod", label: "Bipod", x: 24, y: 12, zone: "primary", icon: "weapon" },
{ name: "launcher", label: "Launcher", x: 68, y: 22, zone: "launcher", icon: "heavy" },
{ name: "secondary", label: "Secondary", x: 35, y: 56, zone: "secondary", icon: "sidearm" },
];
export function getWardrobeSlot(name: string): WardrobeSlotLayout | null {
return WARDROBE_SLOT_LAYOUT.find((s) => s.name === name) ?? null;
}
// --- Equipment customization (attachments & skins) ---
export const ATTACHMENT_CATEGORIES: {
name: string;
label: string;
assetType: Asset["assetType"];
}[] = [
{ name: "optic", label: "Optic", assetType: "optic" },
{ name: "muzzle", label: "Muzzle", assetType: "muzzle" },
{ name: "bipod", label: "Bipod", assetType: "bipod" },
];
export const ATTACHMENT_ASSET_TYPES = new Set(ATTACHMENT_CATEGORIES.map((c) => c.assetType));
/** Explicit compatible attachment ids from the weapon's catalog entry (empty = any). */
export function getCompatibleAttachmentIds(weapon: Asset): Set<number> {
const list = weapon["Weapon Configuration"]?.compatibleAttachments;
if (!list || list.length === 0) return new Set();
const ids = new Set<number>();
for (const ref of list) {
const id = resolveAssetId(ref);
if (id != null) ids.add(id);
}
return ids;
}
export function isAttachmentCompatible(weapon: Asset, attachment: Asset): boolean {
if (!ATTACHMENT_ASSET_TYPES.has(attachment.assetType)) return false;
const allowed = getCompatibleAttachmentIds(weapon);
if (allowed.size > 0 && !allowed.has(attachment.id)) return false;
return true;
}
/** Categorised attachment candidates from the locker for a given weapon. */
export function getAttachmentOptionsForItem(
weapon: Asset,
items: LockerGridItem[],
): { category: (typeof ATTACHMENT_CATEGORIES)[number]; options: LockerGridItem[] }[] {
const allowed = getCompatibleAttachmentIds(weapon);
return ATTACHMENT_CATEGORIES.map((category) => ({
category,
options: items.filter((entry) => {
if (entry.assetId === weapon.id) return false;
if (entry.asset.assetType !== category.assetType) return false;
if (allowed.size > 0 && !allowed.has(entry.assetId)) return false;
return true;
}),
}));
}
export function isSkinAsset(asset: Asset): boolean {
return asset.assetType === "skin";
}
/** True if a skin can be applied to the given equipment asset. */
export function skinAppliesTo(skin: Asset, equipment: Asset): boolean {
const applies = skin["Skin Configuration"]?.appliesToAssetTypes;
if (!applies || applies.length === 0) return true;
return (applies as readonly string[]).includes(equipment.assetType);
}
/** Skin candidates from the locker that fit the given equipment asset. */
export function getSkinOptionsForItem(
equipment: Asset,
items: LockerGridItem[],
): LockerGridItem[] {
return items.filter((entry) => {
if (entry.assetId === equipment.id) return false;
if (!isSkinAsset(entry.asset)) return false;
return skinAppliesTo(entry.asset, equipment);
});
}

View file

@ -677,6 +677,7 @@ export interface Asset {
| 'medical'
| 'tool'
| 'explosive'
| 'skin'
| 'miscellaneous';
'Weapon Configuration'?: {
slotType?: ('primary' | 'secondary' | 'launcher') | null;
@ -694,6 +695,12 @@ export interface Asset {
capacityKg?: number | null;
weightKg?: number | null;
};
'Skin Configuration'?: {
/**
* Which equipment types this skin can be applied to. Leave empty to allow on any equipment.
*/
appliesToAssetTypes?: ('weapon' | 'uniform' | 'vest' | 'backpack' | 'helmet')[] | null;
};
'Magazine Configuration'?: {
roundCapacity?: number | null;
ammoType?: string | null;
@ -1796,6 +1803,28 @@ export interface LockerStorage {
* Whether this item is rotated 90 degrees (swaps width and height).
*/
rotated?: boolean | null;
/**
* Per-instance customization for this item: installed attachments, applied skin, and a custom name. Managed from the member locker UI.
*/
equipment?: {
/**
* Attachments installed on this item (e.g. optics, muzzles, bipods on a weapon). Installing consumes the attachment from the locker grid.
*/
attachments?:
| {
asset: number | Asset;
id?: string | null;
}[]
| null;
/**
* Skin applied to this item. Applying consumes the skin from the locker grid.
*/
skin?: (number | null) | Asset;
/**
* Optional custom name for this item (e.g. "Frank's Karma").
*/
customName?: string | null;
};
id?: string | null;
}[]
| null;
@ -1824,6 +1853,10 @@ export interface Loadout {
* The operator this loadout belongs to.
*/
ownerUser: number | User;
/**
* Whether this is the member's currently equipped loadout. Only one loadout per member can be equipped; the wardrobe reflects the equipped loadout.
*/
equipped?: boolean | null;
/**
* Assign assets from your locker to each equipment slot.
*/
@ -2577,6 +2610,11 @@ export interface AssetsSelect<T extends boolean = true> {
capacityKg?: T;
weightKg?: T;
};
'Skin Configuration'?:
| T
| {
appliesToAssetTypes?: T;
};
'Magazine Configuration'?:
| T
| {
@ -2921,6 +2959,18 @@ export interface LockerStoragesSelect<T extends boolean = true> {
gridX?: T;
gridY?: T;
rotated?: T;
equipment?:
| T
| {
attachments?:
| T
| {
asset?: T;
id?: T;
};
skin?: T;
customName?: T;
};
id?: T;
};
itemCount?: T;
@ -2935,6 +2985,7 @@ export interface LoadoutsSelect<T extends boolean = true> {
name?: T;
description?: T;
ownerUser?: T;
equipped?: T;
slots?:
| T
| {

View file

@ -32,12 +32,18 @@ export const EventTypes = {
LockerGridMerge: "locker:grid-merge",
LockerGridSplit: "locker:grid-split",
LockerGridArrange: "locker:grid-arrange",
LockerItemAttach: "locker:item-attach",
LockerItemDetach: "locker:item-detach",
LockerItemSkin: "locker:item-skin",
LockerItemSkinClear: "locker:item-skin-clear",
LockerItemRename: "locker:item-rename",
// Loadouts
LoadoutCreate: "loadout:create",
LoadoutUpdate: "loadout:update",
LoadoutDelete: "loadout:delete",
LoadoutEquip: "loadout:equip",
LoadoutSetActive: "loadout:set-active",
// Shipping
ShipmentCreate: "shipment:create",