From dfbe0cb6c5d7a46e2f894cc5262a59171a94599d Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Tue, 28 Jul 2026 16:08:13 -0400 Subject: [PATCH] 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 --- .../frontend/storage/EventLedger.tsx | 216 ++++++++++++++++++ .../frontend/storage/StructureStorageView.tsx | 5 + 2 files changed, 221 insertions(+) create mode 100644 src/components/frontend/storage/EventLedger.tsx diff --git a/src/components/frontend/storage/EventLedger.tsx b/src/components/frontend/storage/EventLedger.tsx new file mode 100644 index 0000000..427ba49 --- /dev/null +++ b/src/components/frontend/storage/EventLedger.tsx @@ -0,0 +1,216 @@ +"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; +} + +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([]); + const [loading, setLoading] = useState(true); + const [selectedTypes, setSelectedTypes] = useState>(new Set()); + const [currentUserId, setCurrentUserId] = useState(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 ( + + + + + Event Log + + + +

Loading...

+
+
+ ); + } + + return ( + + + + + Event Log + + Recent activity for this structure. + + + {eventTypes.length > 1 && ( + + + Filter event logs... + + + + + + setSelectedTypes(new Set())} + > + All + + {eventTypes.map((t) => ( + toggleType(t)} + > + {formatType(t)} + + ))} + + + )} + {filtered.length === 0 ? ( +

No events recorded yet.

+ ) : ( +
+ {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 ( +
+
+ {isNarrative && ( + + )} + + {time.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} + + + {actorLabel && ( + + {actorLabel} + + )}{" "} + {displayMessage} + + + {formatType(entry.type)} + +
+ {index < filtered.length - 1 &&
} +
+ ); + })} +
+ )} + + + ); +} diff --git a/src/components/frontend/storage/StructureStorageView.tsx b/src/components/frontend/storage/StructureStorageView.tsx index 527d4f2..22104b6 100644 --- a/src/components/frontend/storage/StructureStorageView.tsx +++ b/src/components/frontend/storage/StructureStorageView.tsx @@ -24,6 +24,7 @@ import { ZapIcon, BrainIcon, } from "lucide-react"; +import { EventLedger } from "./EventLedger"; import { moveGridItem, mergeGridStacks, @@ -56,6 +57,7 @@ export function StructureStorageView({ structure }: StructureStorageViewProps) { const router = useRouter(); const [viewMode, setViewMode] = useState<"grid" | "list">("grid"); const [selectedItem, setSelectedItem] = useState(null); + const [eventLogKey, setEventLogKey] = useState(0); const storedRaw = structure.storedResources ?? []; const gridWidth = structure.type.xSize ?? 0; @@ -79,6 +81,7 @@ export function StructureStorageView({ structure }: StructureStorageViewProps) { const refresh = useCallback(() => { router.refresh(); + setEventLogKey((k) => k + 1); }, [router]); const handleMoveItem = useCallback( @@ -202,6 +205,8 @@ export function StructureStorageView({ structure }: StructureStorageViewProps) { /> )} + + {hasVoidStorage && (