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 { emitGameEvent } from "@/utils/event-log/emit";
|
||||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||||
import {
|
import {
|
||||||
|
ATTACHMENT_CATEGORIES,
|
||||||
autoArrangeLockerItems,
|
autoArrangeLockerItems,
|
||||||
ensureLockerStorage,
|
ensureLockerStorage,
|
||||||
findLockerEmptySpot,
|
findLockerEmptySpot,
|
||||||
getLockerGridDimensions,
|
getLockerGridDimensions,
|
||||||
getLockerItemFootprint,
|
getLockerItemFootprint,
|
||||||
getLoadoutSlot,
|
getLoadoutSlot,
|
||||||
|
isAttachmentCompatible,
|
||||||
isLockerPlacementValid,
|
isLockerPlacementValid,
|
||||||
|
isSkinAsset,
|
||||||
lockerIsRotatable,
|
lockerIsRotatable,
|
||||||
lockerIsStackable,
|
lockerIsStackable,
|
||||||
lockerMaxStack,
|
lockerMaxStack,
|
||||||
|
resolveAssetId,
|
||||||
|
skinAppliesTo,
|
||||||
|
toLockerGridItems,
|
||||||
type LockerGridItem,
|
type LockerGridItem,
|
||||||
} from "@/lib/locker";
|
} from "@/lib/locker";
|
||||||
|
|
||||||
interface ActionResult<T = undefined> {
|
export interface ActionResult<T = undefined> {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
data?: T;
|
data?: T;
|
||||||
|
|
@ -44,29 +50,6 @@ function isLockerManager(user: User): boolean {
|
||||||
return hasRoles(["admin", "developer"], user);
|
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(
|
async function getLockerWithItems(
|
||||||
payload: Awaited<ReturnType<typeof getPayload>>,
|
payload: Awaited<ReturnType<typeof getPayload>>,
|
||||||
userId: number,
|
userId: number,
|
||||||
|
|
@ -147,7 +130,7 @@ export async function addItemToLocker(
|
||||||
const grid = await getLockerGridDimensions(payload);
|
const grid = await getLockerGridDimensions(payload);
|
||||||
|
|
||||||
const existing = [...(locker.items ?? [])];
|
const existing = [...(locker.items ?? [])];
|
||||||
const gridItems = toGridItems(existing);
|
const gridItems = toLockerGridItems(existing);
|
||||||
const stackable = lockerIsStackable(asset);
|
const stackable = lockerIsStackable(asset);
|
||||||
const maxStack = lockerMaxStack(asset);
|
const maxStack = lockerMaxStack(asset);
|
||||||
const fp = getLockerItemFootprint(asset, false);
|
const fp = getLockerItemFootprint(asset, false);
|
||||||
|
|
@ -290,7 +273,7 @@ export async function moveLockerItem(
|
||||||
return { success: false, error: "Placement is outside grid bounds." };
|
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)) {
|
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." };
|
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." };
|
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)) {
|
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." };
|
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 (!asset || typeof asset === "number") return { success: false, error: "Invalid asset." };
|
||||||
if (!lockerIsStackable(asset)) return { success: false, error: "This asset cannot be stacked." };
|
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 fp = getLockerItemFootprint(asset, entry.rotated ?? false);
|
||||||
const spot = findLockerEmptySpot(gridItems, fp.width, fp.height, grid.width, grid.height);
|
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." };
|
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 grid = await getLockerGridDimensions(payload);
|
||||||
|
|
||||||
const items = [...(locker.items ?? [])];
|
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 updatedItems = items.map((entry) => {
|
||||||
const match = rearranged.find((r) => r.id === entry.id);
|
const match = rearranged.find((r) => r.id === entry.id);
|
||||||
|
|
@ -733,3 +716,571 @@ async function getOwnedLoadout(
|
||||||
if (ownerId !== user.id && !isLockerManager(user)) return null;
|
if (ownerId !== user.id && !isLockerManager(user)) return null;
|
||||||
return loadout;
|
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}
|
loadouts={loadouts}
|
||||||
assetsCatalog={assetsCatalog}
|
assetsCatalog={assetsCatalog}
|
||||||
isManager={isManager}
|
isManager={isManager}
|
||||||
|
userName={user?.username ?? "Member"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,15 @@ export const Loadouts: CollectionConfig = {
|
||||||
description: "The operator this loadout belongs to.",
|
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",
|
name: "slots",
|
||||||
type: "group",
|
type: "group",
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,55 @@ export const LockerStorages: CollectionConfig = {
|
||||||
description: "Whether this item is rotated 90 degrees (swaps width and height).",
|
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",
|
label: "Explosive",
|
||||||
value: "explosive",
|
value: "explosive",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Skin",
|
||||||
|
value: "skin",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Miscellaneous",
|
label: "Miscellaneous",
|
||||||
value: "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",
|
name: "Magazine Configuration",
|
||||||
type: "group",
|
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";
|
"use client";
|
||||||
|
|
||||||
import { PencilIcon, TrashIcon } from "lucide-react";
|
import { CheckCircle2Icon, PencilIcon, TrashIcon } from "lucide-react";
|
||||||
import type { Asset, Loadout } from "@/payload-types";
|
import type { Asset, Loadout } from "@/payload-types";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
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 {
|
function resolveSlotValue(value: LoadoutSlots[keyof LoadoutSlots]): Asset | null {
|
||||||
if (value == null) return null;
|
if (value == null) return null;
|
||||||
|
|
@ -12,13 +12,20 @@ function resolveSlotValue(value: LoadoutSlots[keyof LoadoutSlots]): Asset | null
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LoadoutCardProps {
|
function slotDisplayName(asset: Asset, ownedItems: LockerGridItem[]): string {
|
||||||
loadout: Loadout;
|
const custom = ownedItems.find((i) => i.assetId === asset.id)?.equipment?.customName?.trim();
|
||||||
onEdit: () => void;
|
return custom || asset.name;
|
||||||
onDelete: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 slots = (loadout.slots ?? {}) as LoadoutSlots;
|
||||||
const filled = LOADOUT_SLOTS.filter((slot) => {
|
const filled = LOADOUT_SLOTS.filter((slot) => {
|
||||||
const v = slots[slot.name];
|
const v = slots[slot.name];
|
||||||
|
|
@ -28,11 +35,26 @@ export function LoadoutCard({ loadout, onEdit, onDelete }: LoadoutCardProps) {
|
||||||
return (
|
return (
|
||||||
<Item variant="outline">
|
<Item variant="outline">
|
||||||
<ItemHeader>
|
<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 && (
|
{loadout.description && (
|
||||||
<span className="text-xs text-muted-foreground">{loadout.description}</span>
|
<span className="text-xs text-muted-foreground">{loadout.description}</span>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-1">
|
<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}>
|
<Button variant="outline" size="sm" onClick={onEdit}>
|
||||||
<PencilIcon className="size-3.5" />
|
<PencilIcon className="size-3.5" />
|
||||||
Edit
|
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"
|
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="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 className="text-muted-foreground">({getAssetTypeLabel(asset.assetType)})</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { Button } from "@/components/ui/button";
|
||||||
import { LoadoutCard } from "./LoadoutCard";
|
import { LoadoutCard } from "./LoadoutCard";
|
||||||
import { LoadoutEditor } from "./LoadoutEditor";
|
import { LoadoutEditor } from "./LoadoutEditor";
|
||||||
import type { LockerGridItem } from "@/lib/locker";
|
import type { LockerGridItem } from "@/lib/locker";
|
||||||
import { deleteLoadout } from "@/app/(frontend)/locker/actions";
|
import { deleteLoadout, equipLoadout } from "@/app/(frontend)/locker/actions";
|
||||||
|
|
||||||
interface LoadoutsPanelProps {
|
interface LoadoutsPanelProps {
|
||||||
loadouts: Loadout[];
|
loadouts: Loadout[];
|
||||||
|
|
@ -26,6 +26,11 @@ export function LoadoutsPanel({ loadouts, ownedItems }: LoadoutsPanelProps) {
|
||||||
if (res.success) router.refresh();
|
if (res.success) router.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleEquip(loadout: Loadout) {
|
||||||
|
const res = await equipLoadout(loadout.id, !loadout.equipped);
|
||||||
|
if (res.success) router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|
@ -48,8 +53,10 @@ export function LoadoutsPanel({ loadouts, ownedItems }: LoadoutsPanelProps) {
|
||||||
<LoadoutCard
|
<LoadoutCard
|
||||||
key={loadout.id}
|
key={loadout.id}
|
||||||
loadout={loadout}
|
loadout={loadout}
|
||||||
|
ownedItems={ownedItems}
|
||||||
onEdit={() => setEditing(loadout)}
|
onEdit={() => setEditing(loadout)}
|
||||||
onDelete={() => handleDelete(loadout)}
|
onDelete={() => handleDelete(loadout)}
|
||||||
|
onEquip={() => handleEquip(loadout)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
ScissorsIcon,
|
ScissorsIcon,
|
||||||
LayoutGridIcon,
|
LayoutGridIcon,
|
||||||
ListIcon,
|
ListIcon,
|
||||||
|
SlidersHorizontalIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
findLockerEmptySpot,
|
findLockerEmptySpot,
|
||||||
|
|
@ -30,6 +31,7 @@ interface LockerGridToolbarProps {
|
||||||
onRotate: (entryId: string) => Promise<void>;
|
onRotate: (entryId: string) => Promise<void>;
|
||||||
onRemove: (entryId: string) => Promise<void>;
|
onRemove: (entryId: string) => Promise<void>;
|
||||||
onSplit: (entryId: string, amount: number) => Promise<void>;
|
onSplit: (entryId: string, amount: number) => Promise<void>;
|
||||||
|
onCustomize: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LockerGridToolbar({
|
export function LockerGridToolbar({
|
||||||
|
|
@ -43,10 +45,15 @@ export function LockerGridToolbar({
|
||||||
onRotate,
|
onRotate,
|
||||||
onRemove,
|
onRemove,
|
||||||
onSplit,
|
onSplit,
|
||||||
|
onCustomize,
|
||||||
}: LockerGridToolbarProps) {
|
}: LockerGridToolbarProps) {
|
||||||
const occupancy = getLockerGridOccupancy(items, gridWidth, gridHeight);
|
const occupancy = getLockerGridOccupancy(items, gridWidth, gridHeight);
|
||||||
const asset = selectedItem?.asset ?? null;
|
const asset = selectedItem?.asset ?? null;
|
||||||
const fp = selectedItem && asset ? getLockerItemFootprint(asset, selectedItem.rotated) : 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 =
|
const canSplit =
|
||||||
selectedItem && asset && fp
|
selectedItem && asset && fp
|
||||||
? findLockerEmptySpot(items, fp.width, fp.height, gridWidth, gridHeight) !== null
|
? findLockerEmptySpot(items, fp.width, fp.height, gridWidth, gridHeight) !== null
|
||||||
|
|
@ -83,14 +90,21 @@ export function LockerGridToolbar({
|
||||||
{selectedItem && asset && (
|
{selectedItem && asset && (
|
||||||
<div className="flex items-center gap-3 p-2 rounded-md border bg-muted/30 text-xs">
|
<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="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">
|
<div className="text-muted-foreground">
|
||||||
{selectedItem.quantity.toLocaleString()}× · {getAssetTypeLabel(asset.assetType)}
|
{selectedItem.quantity.toLocaleString()}× · {getAssetTypeLabel(asset.assetType)}
|
||||||
|
{hasCustomName && asset && displayName !== asset.name && ` · as "${asset.name}"`}
|
||||||
{fp && ` · ${fp.width}x${fp.height}`}
|
{fp && ` · ${fp.width}x${fp.height}`}
|
||||||
{selectedItem.rotated && " · rotated"}
|
{selectedItem.rotated && " · rotated"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<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) && (
|
{lockerIsRotatable(asset) && (
|
||||||
<Button variant="outline" size="sm" onClick={() => onRotate(selectedItem.id)}>
|
<Button variant="outline" size="sm" onClick={() => onRotate(selectedItem.id)}>
|
||||||
<RotateCwIcon className="size-3.5" />
|
<RotateCwIcon className="size-3.5" />
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import type { Asset, Loadout, LockerStorage } from "@/payload-types";
|
import type { Asset, Loadout, LockerStorage } from "@/payload-types";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
|
@ -10,9 +10,14 @@ import { LockerGrid } from "./LockerGrid";
|
||||||
import { LockerGridToolbar } from "./LockerGridToolbar";
|
import { LockerGridToolbar } from "./LockerGridToolbar";
|
||||||
import { AddLockerItemDialog } from "./AddLockerItemDialog";
|
import { AddLockerItemDialog } from "./AddLockerItemDialog";
|
||||||
import { LoadoutsPanel } from "./LoadoutsPanel";
|
import { LoadoutsPanel } from "./LoadoutsPanel";
|
||||||
|
import { WardrobePanel } from "./WardrobePanel";
|
||||||
|
import { EquipmentEditorDialog } from "./EquipmentEditorDialog";
|
||||||
|
import { LockerSearch } from "./LockerSearch";
|
||||||
import { getAssetTypeLabel, type LockerGridItem } from "@/lib/locker";
|
import { getAssetTypeLabel, type LockerGridItem } from "@/lib/locker";
|
||||||
|
import type { LockerSearchEntry } from "@/lib/locker/search";
|
||||||
import {
|
import {
|
||||||
autoArrangeLocker,
|
autoArrangeLocker,
|
||||||
|
attachItemFromGrid,
|
||||||
mergeLockerStacks,
|
mergeLockerStacks,
|
||||||
moveLockerItem,
|
moveLockerItem,
|
||||||
removeLockerItem,
|
removeLockerItem,
|
||||||
|
|
@ -27,6 +32,7 @@ interface LockerViewProps {
|
||||||
loadouts: Loadout[];
|
loadouts: Loadout[];
|
||||||
assetsCatalog: Asset[];
|
assetsCatalog: Asset[];
|
||||||
isManager: boolean;
|
isManager: boolean;
|
||||||
|
userName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LockerView({
|
export function LockerView({
|
||||||
|
|
@ -36,10 +42,14 @@ export function LockerView({
|
||||||
loadouts,
|
loadouts,
|
||||||
assetsCatalog,
|
assetsCatalog,
|
||||||
isManager,
|
isManager,
|
||||||
|
userName,
|
||||||
}: LockerViewProps) {
|
}: LockerViewProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [tab, setTab] = useState("wardrobe");
|
||||||
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
|
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 [eventLogKey, setEventLogKey] = useState(0);
|
||||||
|
|
||||||
const items: LockerGridItem[] = useMemo(
|
const items: LockerGridItem[] = useMemo(
|
||||||
|
|
@ -56,11 +66,28 @@ export function LockerView({
|
||||||
gridY: entry.gridY ?? 0,
|
gridY: entry.gridY ?? 0,
|
||||||
rotated: entry.rotated ?? false,
|
rotated: entry.rotated ?? false,
|
||||||
asset,
|
asset,
|
||||||
|
equipment: entry.equipment ?? undefined,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
[locker.items],
|
[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(() => {
|
const refresh = useCallback(() => {
|
||||||
router.refresh();
|
router.refresh();
|
||||||
setEventLogKey((k) => k + 1);
|
setEventLogKey((k) => k + 1);
|
||||||
|
|
@ -94,7 +121,7 @@ export function LockerView({
|
||||||
async (entryId: string) => {
|
async (entryId: string) => {
|
||||||
const result = await removeLockerItem(entryId);
|
const result = await removeLockerItem(entryId);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setSelectedItem(null);
|
setSelectedId(null);
|
||||||
refresh();
|
refresh();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -114,12 +141,65 @@ export function LockerView({
|
||||||
if (result.success) refresh();
|
if (result.success) refresh();
|
||||||
}, [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 (
|
return (
|
||||||
<Tabs defaultValue="inventory">
|
<Tabs value={tab} onValueChange={setTab}>
|
||||||
<TabsList>
|
<div className="flex items-center justify-between gap-3">
|
||||||
<TabsTrigger value="inventory">Inventory</TabsTrigger>
|
<TabsList>
|
||||||
<TabsTrigger value="loadouts">Loadouts</TabsTrigger>
|
<TabsTrigger value="wardrobe">Wardrobe</TabsTrigger>
|
||||||
</TabsList>
|
<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">
|
<TabsContent value="inventory" className="flex flex-col gap-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|
@ -134,6 +214,7 @@ export function LockerView({
|
||||||
onRotate={handleRotateItem}
|
onRotate={handleRotateItem}
|
||||||
onRemove={handleRemoveItem}
|
onRemove={handleRemoveItem}
|
||||||
onSplit={handleSplitStack}
|
onSplit={handleSplitStack}
|
||||||
|
onCustomize={() => setCustomizeId(selectedItem?.id ?? null)}
|
||||||
/>
|
/>
|
||||||
{isManager && <AddLockerItemDialog assets={assetsCatalog} />}
|
{isManager && <AddLockerItemDialog assets={assetsCatalog} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -160,8 +241,10 @@ export function LockerView({
|
||||||
onMergeStacks={handleMergeStacks}
|
onMergeStacks={handleMergeStacks}
|
||||||
onRotateItem={handleRotateItem}
|
onRotateItem={handleRotateItem}
|
||||||
onRemoveItem={handleRemoveItem}
|
onRemoveItem={handleRemoveItem}
|
||||||
onSelectItem={setSelectedItem}
|
onSelectItem={(item) => setSelectedId(item?.id ?? null)}
|
||||||
selectedItem={selectedItem}
|
selectedItem={selectedItem}
|
||||||
|
highlightId={highlightId}
|
||||||
|
onAttachItem={handleAttachItem}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Item variant="outline">
|
<Item variant="outline">
|
||||||
|
|
@ -172,10 +255,10 @@ export function LockerView({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center gap-3 py-2.5 w-full text-left"
|
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">
|
<span className="text-sm font-medium flex-1 min-w-0 truncate">
|
||||||
{entry.asset.name}
|
{entry.equipment?.customName?.trim() || entry.asset.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{getAssetTypeLabel(entry.asset.assetType)}
|
{getAssetTypeLabel(entry.asset.assetType)}
|
||||||
|
|
@ -202,6 +285,15 @@ export function LockerView({
|
||||||
<TabsContent value="loadouts">
|
<TabsContent value="loadouts">
|
||||||
<LoadoutsPanel loadouts={loadouts} ownedItems={items} />
|
<LoadoutsPanel loadouts={loadouts} ownedItems={items} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<EquipmentEditorDialog
|
||||||
|
item={customizeItem}
|
||||||
|
items={items}
|
||||||
|
open={!!customizeItem}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setCustomizeId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Tabs>
|
</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 { Asset, LockerStorage } from "@/payload-types";
|
||||||
import type { GridDimensions } from "@/lib/logistics";
|
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
|
* 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
|
* `items` array on the locker-storages collection, but with the asset
|
||||||
|
|
@ -15,8 +25,35 @@ export type LockerGridItem = {
|
||||||
gridY: number;
|
gridY: number;
|
||||||
rotated: boolean;
|
rotated: boolean;
|
||||||
asset: Asset;
|
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. */
|
/** Equipment slot definitions for user loadouts. */
|
||||||
export type LoadoutSlotConfig = {
|
export type LoadoutSlotConfig = {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -82,6 +119,8 @@ export function getAssetTypeLabel(assetType: Asset["assetType"]): string {
|
||||||
return "Tool";
|
return "Tool";
|
||||||
case "explosive":
|
case "explosive":
|
||||||
return "Explosive";
|
return "Explosive";
|
||||||
|
case "skin":
|
||||||
|
return "Skin";
|
||||||
case "miscellaneous":
|
case "miscellaneous":
|
||||||
return "Miscellaneous";
|
return "Miscellaneous";
|
||||||
default:
|
default:
|
||||||
|
|
@ -105,6 +144,7 @@ const ASSET_TYPE_COLORS: Record<Asset["assetType"], string> = {
|
||||||
medical: "oklch(0.45 0.14 20)",
|
medical: "oklch(0.45 0.14 20)",
|
||||||
tool: "oklch(0.45 0.1 80)",
|
tool: "oklch(0.45 0.1 80)",
|
||||||
explosive: "oklch(0.45 0.16 55)",
|
explosive: "oklch(0.45 0.16 55)",
|
||||||
|
skin: "oklch(0.55 0.12 320)",
|
||||||
miscellaneous: "oklch(0.42 0 0)",
|
miscellaneous: "oklch(0.42 0 0)",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -318,3 +358,121 @@ export function findLockerMergeTarget(
|
||||||
}
|
}
|
||||||
return null;
|
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'
|
| 'medical'
|
||||||
| 'tool'
|
| 'tool'
|
||||||
| 'explosive'
|
| 'explosive'
|
||||||
|
| 'skin'
|
||||||
| 'miscellaneous';
|
| 'miscellaneous';
|
||||||
'Weapon Configuration'?: {
|
'Weapon Configuration'?: {
|
||||||
slotType?: ('primary' | 'secondary' | 'launcher') | null;
|
slotType?: ('primary' | 'secondary' | 'launcher') | null;
|
||||||
|
|
@ -694,6 +695,12 @@ export interface Asset {
|
||||||
capacityKg?: number | null;
|
capacityKg?: number | null;
|
||||||
weightKg?: 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'?: {
|
'Magazine Configuration'?: {
|
||||||
roundCapacity?: number | null;
|
roundCapacity?: number | null;
|
||||||
ammoType?: string | null;
|
ammoType?: string | null;
|
||||||
|
|
@ -1796,6 +1803,28 @@ export interface LockerStorage {
|
||||||
* Whether this item is rotated 90 degrees (swaps width and height).
|
* Whether this item is rotated 90 degrees (swaps width and height).
|
||||||
*/
|
*/
|
||||||
rotated?: boolean | null;
|
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;
|
id?: string | null;
|
||||||
}[]
|
}[]
|
||||||
| null;
|
| null;
|
||||||
|
|
@ -1824,6 +1853,10 @@ export interface Loadout {
|
||||||
* The operator this loadout belongs to.
|
* The operator this loadout belongs to.
|
||||||
*/
|
*/
|
||||||
ownerUser: number | User;
|
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.
|
* Assign assets from your locker to each equipment slot.
|
||||||
*/
|
*/
|
||||||
|
|
@ -2577,6 +2610,11 @@ export interface AssetsSelect<T extends boolean = true> {
|
||||||
capacityKg?: T;
|
capacityKg?: T;
|
||||||
weightKg?: T;
|
weightKg?: T;
|
||||||
};
|
};
|
||||||
|
'Skin Configuration'?:
|
||||||
|
| T
|
||||||
|
| {
|
||||||
|
appliesToAssetTypes?: T;
|
||||||
|
};
|
||||||
'Magazine Configuration'?:
|
'Magazine Configuration'?:
|
||||||
| T
|
| T
|
||||||
| {
|
| {
|
||||||
|
|
@ -2921,6 +2959,18 @@ export interface LockerStoragesSelect<T extends boolean = true> {
|
||||||
gridX?: T;
|
gridX?: T;
|
||||||
gridY?: T;
|
gridY?: T;
|
||||||
rotated?: T;
|
rotated?: T;
|
||||||
|
equipment?:
|
||||||
|
| T
|
||||||
|
| {
|
||||||
|
attachments?:
|
||||||
|
| T
|
||||||
|
| {
|
||||||
|
asset?: T;
|
||||||
|
id?: T;
|
||||||
|
};
|
||||||
|
skin?: T;
|
||||||
|
customName?: T;
|
||||||
|
};
|
||||||
id?: T;
|
id?: T;
|
||||||
};
|
};
|
||||||
itemCount?: T;
|
itemCount?: T;
|
||||||
|
|
@ -2935,6 +2985,7 @@ export interface LoadoutsSelect<T extends boolean = true> {
|
||||||
name?: T;
|
name?: T;
|
||||||
description?: T;
|
description?: T;
|
||||||
ownerUser?: T;
|
ownerUser?: T;
|
||||||
|
equipped?: T;
|
||||||
slots?:
|
slots?:
|
||||||
| T
|
| T
|
||||||
| {
|
| {
|
||||||
|
|
|
||||||
|
|
@ -32,12 +32,18 @@ export const EventTypes = {
|
||||||
LockerGridMerge: "locker:grid-merge",
|
LockerGridMerge: "locker:grid-merge",
|
||||||
LockerGridSplit: "locker:grid-split",
|
LockerGridSplit: "locker:grid-split",
|
||||||
LockerGridArrange: "locker:grid-arrange",
|
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
|
// Loadouts
|
||||||
LoadoutCreate: "loadout:create",
|
LoadoutCreate: "loadout:create",
|
||||||
LoadoutUpdate: "loadout:update",
|
LoadoutUpdate: "loadout:update",
|
||||||
LoadoutDelete: "loadout:delete",
|
LoadoutDelete: "loadout:delete",
|
||||||
LoadoutEquip: "loadout:equip",
|
LoadoutEquip: "loadout:equip",
|
||||||
|
LoadoutSetActive: "loadout:set-active",
|
||||||
|
|
||||||
// Shipping
|
// Shipping
|
||||||
ShipmentCreate: "shipment:create",
|
ShipmentCreate: "shipment:create",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue