1
0
Fork 0
polaris-task-force/src/components/frontend/locker/LockerView.tsx
Z8MB1E 6e717684fb 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
2026-08-02 18:49:59 -04:00

299 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}