1
0
Fork 0
polaris-task-force/src/components/frontend/storage/EventLedger.tsx

217 lines
7.3 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";
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 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({
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>
<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 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>
);
}