- Add LockerSearch component with Search button and ⌘K shortcut - Add search.ts with buildSearchEntries, buildAssetStats, resolveAssetRef - Add command.tsx shadcn component (cmdk dependency) - Group search results by asset type with quantities and location chips - Show stats: slot, capacity, ammo, weight, NVG generation, rarity - Support navigation: weapon → customize, attachment → parent customize - Add locker-search-highlight animation for 3s amber pulse feedback - Add cmdk@^1.1.1 dependency
146 lines
5 KiB
TypeScript
146 lines
5 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import { Crosshair, Layers, Package, Palette, SearchIcon } from "lucide-react";
|
||
import type { Asset, Loadout } from "@/payload-types";
|
||
import { Button } from "@/components/ui/button";
|
||
import {
|
||
CommandDialog,
|
||
CommandEmpty,
|
||
CommandGroup,
|
||
CommandInput,
|
||
CommandItem,
|
||
CommandList,
|
||
} from "@/components/ui/command";
|
||
import { getAssetTypeLabel, type LockerGridItem } from "@/lib/locker";
|
||
import {
|
||
buildSearchEntries,
|
||
type LockerSearchEntry,
|
||
type LockerSearchLocationKind,
|
||
} from "@/lib/locker/search";
|
||
import { getAssetIcon } from "./LockerGrid";
|
||
|
||
function locationIcon(kind: LockerSearchLocationKind) {
|
||
switch (kind) {
|
||
case "locker":
|
||
return <Package className="size-3 text-muted-foreground" />;
|
||
case "attached":
|
||
return <Crosshair className="size-3 text-muted-foreground" />;
|
||
case "skin":
|
||
return <Palette className="size-3 text-muted-foreground" />;
|
||
case "loadout":
|
||
return <Layers className="size-3 text-muted-foreground" />;
|
||
}
|
||
}
|
||
|
||
function entrySearchText(entry: LockerSearchEntry): string {
|
||
return [
|
||
entry.displayName,
|
||
entry.asset.name,
|
||
getAssetTypeLabel(entry.assetType),
|
||
...entry.locations.map((l) => l.text),
|
||
]
|
||
.join(" ")
|
||
.toLowerCase();
|
||
}
|
||
|
||
interface LockerSearchProps {
|
||
items: LockerGridItem[];
|
||
loadouts: Loadout[];
|
||
onSelect: (entry: LockerSearchEntry) => void;
|
||
}
|
||
|
||
export function LockerSearch({ items, loadouts, onSelect }: LockerSearchProps) {
|
||
const [open, setOpen] = useState(false);
|
||
|
||
useEffect(() => {
|
||
const onKey = (e: KeyboardEvent) => {
|
||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
||
e.preventDefault();
|
||
setOpen((v) => !v);
|
||
}
|
||
};
|
||
window.addEventListener("keydown", onKey);
|
||
return () => window.removeEventListener("keydown", onKey);
|
||
}, []);
|
||
|
||
const entries = useMemo(() => buildSearchEntries(items, loadouts), [items, loadouts]);
|
||
|
||
const groups = useMemo(() => {
|
||
const byType = new Map<Asset["assetType"], LockerSearchEntry[]>();
|
||
for (const entry of entries) {
|
||
const list = byType.get(entry.assetType) ?? [];
|
||
list.push(entry);
|
||
byType.set(entry.assetType, list);
|
||
}
|
||
return [...byType.entries()].sort((a, b) =>
|
||
getAssetTypeLabel(a[0]).localeCompare(getAssetTypeLabel(b[0])),
|
||
);
|
||
}, [entries]);
|
||
|
||
return (
|
||
<>
|
||
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||
<SearchIcon className="size-3.5" />
|
||
Search
|
||
<kbd className="ml-1 inline-flex h-4 items-center gap-0.5 rounded border border-border bg-muted px-1 text-[10px] font-medium text-muted-foreground">
|
||
⌘K
|
||
</kbd>
|
||
</Button>
|
||
|
||
<CommandDialog open={open} onOpenChange={setOpen} title="Search Locker">
|
||
<CommandInput placeholder="Search your locker, attachments, skins, and loadouts..." />
|
||
<CommandList>
|
||
<CommandEmpty>No items found.</CommandEmpty>
|
||
{groups.map(([assetType, typeEntries]) => (
|
||
<CommandGroup key={assetType} heading={getAssetTypeLabel(assetType)}>
|
||
{typeEntries.map((entry) => (
|
||
<CommandItem
|
||
key={entry.key}
|
||
value={entrySearchText(entry)}
|
||
onSelect={() => {
|
||
setOpen(false);
|
||
onSelect(entry);
|
||
}}
|
||
className="items-start py-2.5"
|
||
>
|
||
<span className="mt-0.5 text-amber-400">
|
||
{getAssetIcon(entry.assetType, "size-4")}
|
||
</span>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2">
|
||
<span className="font-medium truncate">{entry.displayName}</span>
|
||
{entry.quantity > 0 && (
|
||
<span className="text-xs text-muted-foreground shrink-0">
|
||
{entry.quantity.toLocaleString()}×
|
||
</span>
|
||
)}
|
||
</div>
|
||
{entry.stats.length > 0 && (
|
||
<div className="text-xs text-muted-foreground truncate">
|
||
{entry.stats.map((s) => `${s.label}: ${s.value}`).join(" · ")}
|
||
</div>
|
||
)}
|
||
{entry.locations.length > 0 && (
|
||
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5">
|
||
{entry.locations.map((loc) => (
|
||
<span
|
||
key={loc.text}
|
||
className="inline-flex items-center gap-1 text-xs text-muted-foreground/80"
|
||
>
|
||
{locationIcon(loc.kind)}
|
||
{loc.text}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</CommandItem>
|
||
))}
|
||
</CommandGroup>
|
||
))}
|
||
</CommandList>
|
||
</CommandDialog>
|
||
</>
|
||
);
|
||
}
|