1
0
Fork 0
polaris-task-force/src/components/frontend/storage/EventLedger.tsx
Z8MB1E 947a5c1bbf feat(ui): searchable combobox across data-driven dropdowns
- 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
2026-09-17 16:28:17 -04:00

248 lines
8.6 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Item, ItemContent, ItemDescription, ItemHeader, ItemTitle } from "@/components/ui/item";
import { BookOpenTextIcon, ScrollTextIcon } from "lucide-react";
import { useGameTick } from "@/hooks/useGameTick";
import { formatType } from "@/utils/event-log/formatType";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
interface LogEntry {
id: number;
timestamp: string;
type: string;
message: string;
system?: boolean;
actor?: { id: number; username: string } | number | null;
data?: Record<string, unknown>;
}
export function EventLedger({
structureId,
targetCollection,
targetId,
refreshKey,
}: {
structureId?: number;
targetCollection?: string;
targetId?: number;
refreshKey?: number;
}) {
const [entries, setEntries] = useState<LogEntry[]>([]);
const [loading, setLoading] = useState(true);
const [selectedTypes, setSelectedTypes] = useState<Set<string>>(new Set());
const [currentUserId, setCurrentUserId] = useState<number | null>(null);
const [typeQuery, setTypeQuery] = useState("");
const [filterMenuOpen, setFilterMenuOpen] = useState(false);
const initialDone = useRef(false);
const eventTypes = useMemo(() => {
const set = new Set(entries.map((e) => e.type));
return Array.from(set).sort();
}, [entries]);
const typeNeedle = typeQuery.trim().toLowerCase();
const visibleTypes = typeNeedle
? eventTypes.filter((t) => formatType(t).toLowerCase().includes(typeNeedle))
: eventTypes;
const filtered = useMemo(
() => (selectedTypes.size === 0 ? entries : entries.filter((e) => selectedTypes.has(e.type))),
[entries, selectedTypes],
);
const toggleType = (type: string) => {
setSelectedTypes((prev) => {
const next = new Set(prev);
if (next.has(type)) next.delete(type);
else next.add(type);
return next;
});
};
const dropdownLabel = selectedTypes.size === 0 ? "All" : `${selectedTypes.size} selected`;
const fetchEntries = useCallback(async () => {
if (!initialDone.current) setLoading(true);
const params = new URLSearchParams({
sort: "-timestamp",
limit: "50",
depth: "1",
});
if (targetCollection && targetId != null) {
params.set("where[targetCollection][equals]", targetCollection);
params.set("where[targetId][equals]", String(targetId));
} else if (structureId != null) {
params.set("where[structure.id][equals]", String(structureId));
}
try {
const r = await fetch(`/api/game-event-logs?${params}`);
const body = await r.json();
setEntries(body.docs ?? []);
} catch {
setEntries([]);
} finally {
initialDone.current = true;
setLoading(false);
}
}, [structureId, targetCollection, targetId]);
useEffect(() => {
fetch("/api/users/me")
.then((r) => r.json())
.then((body) => setCurrentUserId(body?.user?.id ?? null))
.catch(() => setCurrentUserId(null));
}, []);
useEffect(() => {
fetchEntries();
}, [fetchEntries]);
useEffect(() => {
if (initialDone.current) fetchEntries();
}, [refreshKey, fetchEntries]);
useEffect(() => {
const interval = setInterval(() => {
if (initialDone.current) fetchEntries();
}, 30_000);
return () => clearInterval(interval);
}, [fetchEntries]);
useGameTick(() => {
if (initialDone.current) fetchEntries();
});
if (loading) {
return (
<Item variant="outline">
<ItemHeader>
<ItemTitle className="flex items-center gap-2 text-muted-foreground">
<ScrollTextIcon className="size-4" />
Event Log
</ItemTitle>
</ItemHeader>
<ItemContent>
<p className="text-sm text-muted-foreground px-1 py-2">Loading...</p>
</ItemContent>
</Item>
);
}
return (
<Item variant="outline">
<ItemHeader>
<ItemTitle className="flex items-center gap-2 text-muted-foreground">
<ScrollTextIcon className="size-4" />
Event Log
</ItemTitle>
<ItemDescription>Recent activity.</ItemDescription>
</ItemHeader>
<ItemContent>
{eventTypes.length > 1 && (
<DropdownMenu
open={filterMenuOpen}
onOpenChange={(next) => {
setFilterMenuOpen(next);
if (!next) setTypeQuery("");
}}
>
<span className="text-xs text-muted-foreground">Filter event logs...</span>
<DropdownMenuTrigger asChild>
<button
type="button"
className="text-xs px-2.5 py-1 rounded border border-border text-muted-foreground hover:text-foreground transition-colors mb-3 min-w-1/3 w-fit"
>
{dropdownLabel}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-60">
<div className="p-1.5">
<Input
value={typeQuery}
onChange={(event) => setTypeQuery(event.target.value)}
placeholder="Search event types..."
className="h-7 rounded-none text-xs"
/>
</div>
<DropdownMenuCheckboxItem
checked={selectedTypes.size === 0}
onCheckedChange={() => setSelectedTypes(new Set())}
onSelect={(event) => event.preventDefault()}
>
All
</DropdownMenuCheckboxItem>
<div className="max-h-64 overflow-y-auto">
{visibleTypes.map((t) => (
<DropdownMenuCheckboxItem
key={t}
checked={selectedTypes.has(t)}
onCheckedChange={() => toggleType(t)}
onSelect={(event) => event.preventDefault()}
>
{formatType(t)}
</DropdownMenuCheckboxItem>
))}
{visibleTypes.length === 0 && (
<div className="px-2 py-3 text-center text-xs text-muted-foreground">
No matches
</div>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
)}
{filtered.length === 0 ? (
<p className="text-sm text-muted-foreground px-1 py-2">No events recorded yet.</p>
) : (
<div className="flex flex-col max-h-80 overflow-y-auto">
{filtered.map((entry, index) => {
const time = new Date(entry.timestamp);
const actorId =
typeof entry.actor === "object" && entry.actor ? entry.actor.id : entry.actor;
const actorName =
typeof entry.actor === "object" && entry.actor ? entry.actor.username : null;
const isYou = actorId != null && actorId === currentUserId;
const actorLabel = isYou ? "You" : actorName;
const isNarrative = entry.system === false;
return (
<div key={entry.id}>
<div
className={`flex items-start gap-3 py-2 text-sm ${isNarrative ? "pl-3 border-l-2 border-amber-500/40" : ""}`}
>
{isNarrative && (
<BookOpenTextIcon className="size-4 text-amber-400 shrink-0 mt-0.5" />
)}
<div className="flex-1 min-w-0 flex flex-col gap-0.5">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="tabular-nums shrink-0">
{time.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
</span>
{actorLabel && (
<span className={`font-medium ${isYou ? "text-foreground" : ""}`}>
{actorLabel}
</span>
)}
<span className="capitalize">{formatType(entry.type)}</span>
</div>
<span className={`min-w-0 ${isNarrative ? "text-amber-100/90" : ""}`}>
{entry.message}
</span>
</div>
</div>
{index < filtered.length - 1 && <div className="border-t border-border" />}
</div>
);
})}
</div>
)}
</ItemContent>
</Item>
);
}