1
0
Fork 0
polaris-task-force/src/components/frontend/locker/LockerSearch.tsx
Z8MB1E a466ed54ed feat(locker): add command palette search
- 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
2026-08-02 18:50:07 -04:00

146 lines
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 { 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>
</>
);
}