1
0
Fork 0
polaris-task-force/src/components/frontend/storage/EventLedger.tsx
Z8MB1E dfbe0cb6c5 feat: add EventLedger UI component with live updates and filtering
- Client-side EventLedger component that queries game-event-logs REST API
  for a given structureId and renders a scrollable feed
- Actor name prefix with 'You' substitution for the current user, messages
  lowercased to fit sentence syntax
- Multi-select dropdown filter (shadcn DropdownMenuCheckboxItem) for event
  types with PascalCase formatting
- Narrative/GM events (system: false) styled with amber accent + book icon
- Smart refresh: smooth re-fetch without clearing existing entries, 30s
  polling interval, and instant refreshKey trigger from parent actions
2026-07-28 16:08:13 -04:00

216 lines
7.1 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Item, ItemContent, ItemHeader, ItemTitle, ItemDescription } from "@/components/ui/item";
import { BookOpenTextIcon, ScrollTextIcon } from "lucide-react";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
interface LogEntry {
id: number;
timestamp: string;
type: string;
message: string;
system?: boolean;
actor?: { id: number; username: string } | number | null;
data?: Record<string, unknown>;
}
function formatType(type: string) {
return type
.replace(":", " ")
.split(" ")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
}
export function EventLedger({
structureId,
refreshKey,
}: {
structureId: 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 initialDone = useRef(false);
const eventTypes = useMemo(() => {
const set = new Set(entries.map((e) => e.type));
return Array.from(set).sort();
}, [entries]);
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({
"where[structure.id][equals]": String(structureId),
sort: "-timestamp",
limit: "50",
depth: "1",
});
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]);
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]);
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 for this structure.</ItemDescription>
</ItemHeader>
<ItemContent>
{eventTypes.length > 1 && (
<DropdownMenu>
<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">
<DropdownMenuCheckboxItem
checked={selectedTypes.size === 0}
onCheckedChange={() => setSelectedTypes(new Set())}
>
All
</DropdownMenuCheckboxItem>
{eventTypes.map((t) => (
<DropdownMenuCheckboxItem
key={t}
checked={selectedTypes.has(t)}
onCheckedChange={() => toggleType(t)}
>
{formatType(t)}
</DropdownMenuCheckboxItem>
))}
</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 displayMessage = actorLabel
? entry.message.charAt(0).toLowerCase() + entry.message.slice(1)
: entry.message;
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" />
)}
<span className="text-xs text-muted-foreground shrink-0 w-16 text-right tabular-nums pt-0.5">
{time.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
</span>
<span className={`flex-1 min-w-0 ${isNarrative ? "text-amber-100/90" : ""}`}>
{actorLabel && (
<span
className={`font-medium ${isYou ? "text-foreground" : "text-muted-foreground"}`}
>
{actorLabel}
</span>
)}{" "}
{displayMessage}
</span>
<span className="text-xs text-muted-foreground capitalize shrink-0 pt-0.5">
{formatType(entry.type)}
</span>
</div>
{index < filtered.length - 1 && <div className="border-t border-border" />}
</div>
);
})}
</div>
)}
</ItemContent>
</Item>
);
}