1
0
Fork 0
polaris-task-force/src/components/frontend/personnel/NpcDirectory.tsx
Z8MB1E 0f7d3a57e3 feat(personnel): add npc contacts directory and statistics pages
Adds a Personnel nav group with Contacts (searchable NPC directory with profile pages showing listings and employment) and Statistics pages; moves Wiki under Intelligence and checks off the corresponding TODO item.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-06 01:24:14 -04:00

128 lines
4.5 KiB
TypeScript

"use client";
import { useMemo, useState } from "react";
import { SearchIcon } from "lucide-react";
import type { GameNpc } from "@/payload-types";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { NpcCard } from "@/components/frontend/personnel/NpcCard";
interface NpcDirectoryProps {
readonly npcs: GameNpc[];
}
export function NpcDirectory({ npcs }: NpcDirectoryProps) {
const [search, setSearch] = useState("");
const [inGameOnly, setInGameOnly] = useState(false);
const [vendorsOnly, setVendorsOnly] = useState(false);
const filteredNpcs = useMemo(() => {
const query = search.trim().toLowerCase();
return [...npcs]
.filter((npc) => {
if (inGameOnly && npc.isInGame !== true) return false;
if (vendorsOnly && npc.vendor?.enabled !== true) return false;
if (
query &&
!`${npc.name} ${npc.occupation ?? ""}`.toLowerCase().includes(query)
) {
return false;
}
return true;
})
.sort((left, right) => left.name.localeCompare(right.name));
}, [inGameOnly, npcs, search, vendorsOnly]);
const hasFilters = Boolean(search.trim()) || inGameOnly || vendorsOnly;
return (
<div className="flex flex-col gap-5">
<div className="flex flex-wrap items-end justify-between gap-3">
<div className="flex flex-wrap items-end gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="npc-search">Search contacts</Label>
<div className="relative">
<SearchIcon className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="npc-search"
aria-label="Search contacts by name or occupation"
className="w-full pl-8 sm:w-72"
placeholder="Search name or occupation..."
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
</div>
</div>
<fieldset className="flex flex-wrap items-center gap-2">
<legend className="sr-only">Contact filters</legend>
<div
className={cn(
"flex items-center gap-2 rounded-md border px-3 py-2",
inGameOnly && "border-primary/50 bg-primary/5",
)}
>
<Checkbox
id="npc-in-game-only"
checked={inGameOnly}
onCheckedChange={(checked) => setInGameOnly(checked === true)}
/>
<Label htmlFor="npc-in-game-only" className="cursor-pointer text-sm font-normal">
In-game only
</Label>
</div>
<div
className={cn(
"flex items-center gap-2 rounded-md border px-3 py-2",
vendorsOnly && "border-primary/50 bg-primary/5",
)}
>
<Checkbox
id="npc-vendors-only"
checked={vendorsOnly}
onCheckedChange={(checked) => setVendorsOnly(checked === true)}
/>
<Label htmlFor="npc-vendors-only" className="cursor-pointer text-sm font-normal">
Vendors only
</Label>
</div>
</fieldset>
</div>
<Badge variant="outline" className="w-fit">
{filteredNpcs.length} of {npcs.length}
</Badge>
</div>
{filteredNpcs.length === 0 ? (
<Card>
<CardContent className="flex flex-col gap-1 p-6">
<p className="font-semibold">
{npcs.length === 0
? "No contacts have been added."
: hasFilters
? "No contacts match those filters."
: "No contacts found."}
</p>
<p className="text-sm text-muted-foreground">
{npcs.length === 0
? "NPC records will appear here as the theater develops."
: "Try a different search or clear one of the filters."}
</p>
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
{filteredNpcs.map((npc) => (
<NpcCard key={npc.id} npc={npc} />
))}
</div>
)}
</div>
);
}