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
This commit is contained in:
parent
5679020b42
commit
dfbe0cb6c5
2 changed files with 221 additions and 0 deletions
216
src/components/frontend/storage/EventLedger.tsx
Normal file
216
src/components/frontend/storage/EventLedger.tsx
Normal file
|
|
@ -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<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,7 @@ import {
|
||||||
ZapIcon,
|
ZapIcon,
|
||||||
BrainIcon,
|
BrainIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { EventLedger } from "./EventLedger";
|
||||||
import {
|
import {
|
||||||
moveGridItem,
|
moveGridItem,
|
||||||
mergeGridStacks,
|
mergeGridStacks,
|
||||||
|
|
@ -56,6 +57,7 @@ export function StructureStorageView({ structure }: StructureStorageViewProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
|
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
|
||||||
const [selectedItem, setSelectedItem] = useState<GridItem | null>(null);
|
const [selectedItem, setSelectedItem] = useState<GridItem | null>(null);
|
||||||
|
const [eventLogKey, setEventLogKey] = useState(0);
|
||||||
|
|
||||||
const storedRaw = structure.storedResources ?? [];
|
const storedRaw = structure.storedResources ?? [];
|
||||||
const gridWidth = structure.type.xSize ?? 0;
|
const gridWidth = structure.type.xSize ?? 0;
|
||||||
|
|
@ -79,6 +81,7 @@ export function StructureStorageView({ structure }: StructureStorageViewProps) {
|
||||||
|
|
||||||
const refresh = useCallback(() => {
|
const refresh = useCallback(() => {
|
||||||
router.refresh();
|
router.refresh();
|
||||||
|
setEventLogKey((k) => k + 1);
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
const handleMoveItem = useCallback(
|
const handleMoveItem = useCallback(
|
||||||
|
|
@ -202,6 +205,8 @@ export function StructureStorageView({ structure }: StructureStorageViewProps) {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<EventLedger structureId={structure.id} refreshKey={eventLogKey} />
|
||||||
|
|
||||||
{hasVoidStorage && (
|
{hasVoidStorage && (
|
||||||
<Item variant="outline">
|
<Item variant="outline">
|
||||||
<ItemHeader>
|
<ItemHeader>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue