- 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
299 lines
9.5 KiB
TypeScript
299 lines
9.5 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import type { Asset, Loadout, LockerStorage } from "@/payload-types";
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||
import { EventLedger } from "@/components/frontend/storage/EventLedger";
|
||
import { LockerGrid } from "./LockerGrid";
|
||
import { LockerGridToolbar } from "./LockerGridToolbar";
|
||
import { AddLockerItemDialog } from "./AddLockerItemDialog";
|
||
import { LoadoutsPanel } from "./LoadoutsPanel";
|
||
import { WardrobePanel } from "./WardrobePanel";
|
||
import { EquipmentEditorDialog } from "./EquipmentEditorDialog";
|
||
import { LockerSearch } from "./LockerSearch";
|
||
import { getAssetTypeLabel, type LockerGridItem } from "@/lib/locker";
|
||
import type { LockerSearchEntry } from "@/lib/locker/search";
|
||
import {
|
||
autoArrangeLocker,
|
||
attachItemFromGrid,
|
||
mergeLockerStacks,
|
||
moveLockerItem,
|
||
removeLockerItem,
|
||
rotateLockerItem,
|
||
splitLockerStack,
|
||
} from "@/app/(frontend)/locker/actions";
|
||
|
||
interface LockerViewProps {
|
||
locker: LockerStorage;
|
||
gridWidth: number;
|
||
gridHeight: number;
|
||
loadouts: Loadout[];
|
||
assetsCatalog: Asset[];
|
||
isManager: boolean;
|
||
userName: string;
|
||
}
|
||
|
||
export function LockerView({
|
||
locker,
|
||
gridWidth,
|
||
gridHeight,
|
||
loadouts,
|
||
assetsCatalog,
|
||
isManager,
|
||
userName,
|
||
}: LockerViewProps) {
|
||
const router = useRouter();
|
||
const [tab, setTab] = useState("wardrobe");
|
||
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
|
||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||
const [customizeId, setCustomizeId] = useState<string | null>(null);
|
||
const [highlightId, setHighlightId] = useState<string | null>(null);
|
||
const [eventLogKey, setEventLogKey] = useState(0);
|
||
|
||
const items: LockerGridItem[] = useMemo(
|
||
() =>
|
||
(locker.items ?? [])
|
||
.filter((entry) => entry.asset && typeof entry.asset === "object" && "id" in entry.asset)
|
||
.map((entry) => {
|
||
const asset = entry.asset as Asset;
|
||
return {
|
||
id: entry.id ?? `${asset.id}-${entry.gridX}-${entry.gridY}`,
|
||
assetId: asset.id,
|
||
quantity: entry.quantity,
|
||
gridX: entry.gridX ?? 0,
|
||
gridY: entry.gridY ?? 0,
|
||
rotated: entry.rotated ?? false,
|
||
asset,
|
||
equipment: entry.equipment ?? undefined,
|
||
};
|
||
}),
|
||
[locker.items],
|
||
);
|
||
|
||
const selectedItem = useMemo(
|
||
() => items.find((i) => i.id === selectedId) ?? null,
|
||
[items, selectedId],
|
||
);
|
||
|
||
const customizeItem = useMemo(
|
||
() => items.find((i) => i.id === customizeId) ?? null,
|
||
[items, customizeId],
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (!highlightId) return;
|
||
const timer = setTimeout(() => setHighlightId(null), 3000);
|
||
return () => clearTimeout(timer);
|
||
}, [highlightId]);
|
||
|
||
const refresh = useCallback(() => {
|
||
router.refresh();
|
||
setEventLogKey((k) => k + 1);
|
||
}, [router]);
|
||
|
||
const handleMoveItem = useCallback(
|
||
async (entryId: string, gridX: number, gridY: number, rotated?: boolean) => {
|
||
const result = await moveLockerItem(entryId, gridX, gridY, rotated);
|
||
if (result.success) refresh();
|
||
},
|
||
[refresh],
|
||
);
|
||
|
||
const handleMergeStacks = useCallback(
|
||
async (sourceId: string, targetId: string) => {
|
||
const result = await mergeLockerStacks(sourceId, targetId);
|
||
if (result.success) refresh();
|
||
},
|
||
[refresh],
|
||
);
|
||
|
||
const handleRotateItem = useCallback(
|
||
async (entryId: string) => {
|
||
const result = await rotateLockerItem(entryId);
|
||
if (result.success) refresh();
|
||
},
|
||
[refresh],
|
||
);
|
||
|
||
const handleRemoveItem = useCallback(
|
||
async (entryId: string) => {
|
||
const result = await removeLockerItem(entryId);
|
||
if (result.success) {
|
||
setSelectedId(null);
|
||
refresh();
|
||
}
|
||
},
|
||
[refresh],
|
||
);
|
||
|
||
const handleSplitStack = useCallback(
|
||
async (entryId: string, amount: number) => {
|
||
const result = await splitLockerStack(entryId, amount);
|
||
if (result.success) refresh();
|
||
},
|
||
[refresh],
|
||
);
|
||
|
||
const handleAutoArrange = useCallback(async () => {
|
||
const result = await autoArrangeLocker();
|
||
if (result.success) refresh();
|
||
}, [refresh]);
|
||
|
||
const handleAttachItem = useCallback(
|
||
async (weaponEntryId: string, attachmentEntryId: string) => {
|
||
const result = await attachItemFromGrid(weaponEntryId, attachmentEntryId);
|
||
if (result.success) refresh();
|
||
return result;
|
||
},
|
||
[refresh],
|
||
);
|
||
|
||
const handleSearchSelect = useCallback(
|
||
(entry: LockerSearchEntry) => {
|
||
setViewMode("grid");
|
||
setTab("inventory");
|
||
|
||
const gridItem = items.find((i) => i.assetId === entry.assetId) ?? null;
|
||
|
||
if (gridItem && entry.asset.assetType === "weapon") {
|
||
setSelectedId(gridItem.id);
|
||
setCustomizeId(gridItem.id);
|
||
return;
|
||
}
|
||
|
||
const parentId = entry.locations.find(
|
||
(l) => (l.kind === "attached" || l.kind === "skin") && l.parentEntryId,
|
||
)?.parentEntryId;
|
||
|
||
if (parentId) {
|
||
const parent = items.find((i) => i.id === parentId);
|
||
if (parent && parent.asset.assetType === "weapon") {
|
||
setSelectedId(parent.id);
|
||
setCustomizeId(parent.id);
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (gridItem) setHighlightId(gridItem.id);
|
||
},
|
||
[items],
|
||
);
|
||
|
||
return (
|
||
<Tabs value={tab} onValueChange={setTab}>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<TabsList>
|
||
<TabsTrigger value="wardrobe">Wardrobe</TabsTrigger>
|
||
<TabsTrigger value="inventory">Inventory</TabsTrigger>
|
||
<TabsTrigger value="loadouts">Loadouts</TabsTrigger>
|
||
</TabsList>
|
||
<LockerSearch items={items} loadouts={loadouts} onSelect={handleSearchSelect} />
|
||
</div>
|
||
|
||
<TabsContent value="wardrobe">
|
||
<WardrobePanel
|
||
userName={userName}
|
||
loadouts={loadouts}
|
||
equippedLoadout={loadouts.find((l) => l.equipped) ?? null}
|
||
items={items}
|
||
/>
|
||
</TabsContent>
|
||
|
||
<TabsContent value="inventory" className="flex flex-col gap-4">
|
||
<div className="flex items-center justify-between">
|
||
<LockerGridToolbar
|
||
gridWidth={gridWidth}
|
||
gridHeight={gridHeight}
|
||
items={items}
|
||
selectedItem={selectedItem}
|
||
viewMode={viewMode}
|
||
onViewModeChange={setViewMode}
|
||
onAutoArrange={handleAutoArrange}
|
||
onRotate={handleRotateItem}
|
||
onRemove={handleRemoveItem}
|
||
onSplit={handleSplitStack}
|
||
onCustomize={() => setCustomizeId(selectedItem?.id ?? null)}
|
||
/>
|
||
{isManager && <AddLockerItemDialog assets={assetsCatalog} />}
|
||
</div>
|
||
|
||
{items.length === 0 ? (
|
||
<Item variant="outline">
|
||
<ItemHeader>
|
||
<ItemTitle className="text-muted-foreground">Empty Locker</ItemTitle>
|
||
</ItemHeader>
|
||
<ItemContent>
|
||
<p className="text-sm text-muted-foreground">
|
||
{isManager
|
||
? "Your locker is empty. Add items to get started."
|
||
: "Your locker is empty. Reach out to logistics to have equipment issued."}
|
||
</p>
|
||
</ItemContent>
|
||
</Item>
|
||
) : viewMode === "grid" ? (
|
||
<LockerGrid
|
||
gridWidth={gridWidth}
|
||
gridHeight={gridHeight}
|
||
items={items}
|
||
onMoveItem={handleMoveItem}
|
||
onMergeStacks={handleMergeStacks}
|
||
onRotateItem={handleRotateItem}
|
||
onRemoveItem={handleRemoveItem}
|
||
onSelectItem={(item) => setSelectedId(item?.id ?? null)}
|
||
selectedItem={selectedItem}
|
||
highlightId={highlightId}
|
||
onAttachItem={handleAttachItem}
|
||
/>
|
||
) : (
|
||
<Item variant="outline">
|
||
<ItemContent>
|
||
<div className="flex flex-col">
|
||
{items.map((entry, index) => (
|
||
<div key={entry.id}>
|
||
<button
|
||
type="button"
|
||
className="flex items-center gap-3 py-2.5 w-full text-left"
|
||
onClick={() => setSelectedId(entry.id)}
|
||
>
|
||
<span className="text-sm font-medium flex-1 min-w-0 truncate">
|
||
{entry.equipment?.customName?.trim() || entry.asset.name}
|
||
</span>
|
||
<span className="text-xs text-muted-foreground">
|
||
{getAssetTypeLabel(entry.asset.assetType)}
|
||
</span>
|
||
<span className="text-xs text-muted-foreground shrink-0">
|
||
{entry.quantity.toLocaleString()}×
|
||
</span>
|
||
</button>
|
||
{index < items.length - 1 && <div className="border-t border-border" />}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</ItemContent>
|
||
</Item>
|
||
)}
|
||
|
||
<EventLedger
|
||
targetCollection="locker-storages"
|
||
targetId={locker.id}
|
||
refreshKey={eventLogKey}
|
||
/>
|
||
</TabsContent>
|
||
|
||
<TabsContent value="loadouts">
|
||
<LoadoutsPanel loadouts={loadouts} ownedItems={items} />
|
||
</TabsContent>
|
||
|
||
<EquipmentEditorDialog
|
||
item={customizeItem}
|
||
items={items}
|
||
open={!!customizeItem}
|
||
onOpenChange={(open) => {
|
||
if (!open) setCustomizeId(null);
|
||
}}
|
||
/>
|
||
</Tabs>
|
||
);
|
||
}
|