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:
parent
c0f8d7080d
commit
6e717684fb
16 changed files with 1785 additions and 51 deletions
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ export default async function LockerPage() {
|
|||
loadouts={loadouts}
|
||||
assetsCatalog={assetsCatalog}
|
||||
isManager={isManager}
|
||||
userName={user?.username ?? "Member"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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\").",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
293
src/components/frontend/locker/EquipmentEditorDialog.tsx
Normal file
293
src/components/frontend/locker/EquipmentEditorDialog.tsx
Normal 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 "{customName.trim()}" 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
116
src/components/frontend/locker/Wardrobe.tsx
Normal file
116
src/components/frontend/locker/Wardrobe.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
218
src/components/frontend/locker/WardrobePanel.tsx
Normal file
218
src/components/frontend/locker/WardrobePanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
117
src/components/frontend/locker/WardrobeSlotPicker.tsx
Normal file
117
src/components/frontend/locker/WardrobeSlotPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
| {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue