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>
128 lines
4.5 KiB
TypeScript
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>
|
|
);
|
|
}
|