- new ui/combobox primitive (popover plus cmdk): search input, scrolling list, trigger-width popover, per-option disabled and hint lines - converts every data-driven single-value select site-wide (blueprints, factions, accounts, assignments, NPCs, structures, locker items, seats, missions, ranks, resources, mod lists, event targets, sprints) - long multi-select checkbox menus (event log types, kanban labels) gain search inputs following the resource picker pattern - static short enum selects stay as plain selects
210 lines
7.5 KiB
TypeScript
210 lines
7.5 KiB
TypeScript
"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 { Combobox } from "@/components/ui/combobox";
|
|
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
|
import { Wardrobe } from "./Wardrobe";
|
|
import { WardrobeSlotPicker } from "./WardrobeSlotPicker";
|
|
import {
|
|
getAssetTypeLabel,
|
|
getLoadoutSlot,
|
|
getWardrobeSlot,
|
|
type LoadoutSlots,
|
|
type LockerGridItem,
|
|
WARDROBE_SLOT_LAYOUT,
|
|
} 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 { success: false, error: "No loadout equipped." };
|
|
setBusy(true);
|
|
const res = await setLoadoutSlot(equippedLoadout.id, slotName, assetId);
|
|
setBusy(false);
|
|
if (res.success) {
|
|
setPickerSlot(null);
|
|
router.refresh();
|
|
}
|
|
return res;
|
|
}
|
|
|
|
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 gap-6 lg:grid-cols-[minmax(0,40rem)_minmax(0,1fr)]">
|
|
<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="mt-3 text-center text-sm text-muted-foreground">
|
|
{equippedLoadout
|
|
? "Select a slot block 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">
|
|
<Combobox
|
|
className="flex-1"
|
|
value={equippedLoadout ? String(equippedLoadout.id) : ""}
|
|
onValueChange={(v) => handleEquip(Number(v))}
|
|
disabled={busy}
|
|
placeholder={
|
|
equippedLoadout ? equippedLoadout.name : "Select a loadout to equip"
|
|
}
|
|
searchPlaceholder="Search loadouts..."
|
|
options={loadouts.map((l) => ({
|
|
value: String(l.id),
|
|
label: l.name,
|
|
}))}
|
|
/>
|
|
{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>
|
|
);
|
|
}
|