1
0
Fork 0

feat(events): add global event log page

New /events page rendering the full game event ledger (system + narrative events) with type/target/source/date filtering, backed by a shared shadcn table component.
This commit is contained in:
Jason Fraley 2026-09-07 05:07:51 -04:00
parent 227885daf7
commit 29f7e2b57a
3 changed files with 574 additions and 0 deletions

View file

@ -0,0 +1,22 @@
import type { Metadata } from "next";
import EventLogTable from "@/components/frontend/events/EventLogTable";
export const metadata: Metadata = {
title: "Event Log (Polaris Task Force)",
};
export default function EventLogPage() {
return (
<div className="flex flex-col gap-6 p-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Event Log</h1>
<p className="text-muted-foreground text-sm">
The full game event ledger — system actions and GM narrative events. Filter by type,
target, source, or date range.
</p>
</div>
<EventLogTable />
</div>
);
}

View file

@ -0,0 +1,485 @@
"use client";
import * as React from "react";
import { ChevronLeft, ChevronRight, RotateCcw, Search } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { EventTypes } from "@/utils/event-log/eventTypes";
import { formatType } from "@/utils/event-log/formatType";
import { cn } from "@/lib/utils";
// Local label map mirroring GameEventLogs TARGET_COLLECTIONS (the collection
// file imports Payload server-side, so it cannot be imported from a client
// component).
const TARGET_COLLECTION_LABELS: Record<string, string> = {
"game-structures": "Game Structures",
"game-vehicles": "Game Vehicles",
"game-npcs": "Game NPCs",
"npc-staffing": "NPC Staffing",
structures: "Structures",
resources: "Resources",
assets: "Assets",
vehicles: "Vehicles",
factions: "Factions",
missions: "Missions",
campaigns: "Campaigns",
users: "Users",
technologies: "Technologies",
maps: "Maps",
shipments: "Shipments",
"bank-accounts": "Bank Accounts",
"bank-transactions": "Bank Transactions",
"ledger-entries": "Ledger Entries",
"locker-storages": "Locker Storages",
loadouts: "Loadouts",
"market-listings": "Market Listings",
"market-negotiations": "Market Negotiations",
"market-state": "Market State",
evaluations: "Evaluations",
tickets: "Tickets",
"wiki-pages": "Wiki Pages",
"wiki-revisions": "Wiki Revisions",
"wiki-templates": "Wiki Templates",
"game-servers": "Game Servers",
"assignment-transfers": "Assignment Transfers",
"voice-scripts": "Voice Scripts",
"voice-submissions": "Voice Submissions",
};
const TARGET_COLLECTION_OPTIONS = Object.entries(TARGET_COLLECTION_LABELS).sort((a, b) =>
a[1].localeCompare(b[1]),
);
const PAGE_SIZES = [25, 50, 100];
type SourceFilter = "all" | "system" | "narrative";
type SortDir = "desc" | "asc";
interface EventRow {
id: number;
system?: boolean | null;
timestamp: string;
type: string;
message: string;
actor?: number | null | { displayName?: string | null; username?: string };
structure?: number | null | { name?: string | null };
targetCollection?: string | null;
targetId?: number | null;
}
interface EventLogResponse {
docs: EventRow[];
totalDocs: number;
page?: number;
totalPages?: number;
}
/** Group event type values by their prefix (e.g. "market" from "market:sale"). */
function eventTypeGroups(): [string, string[]][] {
const groups = new Map<string, string[]>();
for (const value of Object.values(EventTypes)) {
const prefix = value.includes(":") ? value.split(":")[0] : "other";
const list = groups.get(prefix);
if (list) {
list.push(value);
} else {
groups.set(prefix, [value]);
}
}
return [...groups.entries()].sort((a, b) => a[0].localeCompare(b[0]));
}
const TYPE_GROUPS = eventTypeGroups();
function actorLabel(actor: EventRow["actor"]): string {
if (actor == null || typeof actor === "number") return "System";
return actor.displayName || actor.username || "Unknown";
}
function structureLabel(structure: EventRow["structure"]): string {
if (structure == null) return "—";
if (typeof structure === "number") return `#${structure}`;
return structure.name || "Unknown structure";
}
function formatTimestamp(ts: string): string {
const date = new Date(ts);
return date.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
export default function EventLogTable() {
const [searchInput, setSearchInput] = React.useState("");
const [search, setSearch] = React.useState("");
const [selectedTypes, setSelectedTypes] = React.useState<string[]>([]);
const [targetCollection, setTargetCollection] = React.useState<string>("all");
const [source, setSource] = React.useState<SourceFilter>("all");
const [dateFrom, setDateFrom] = React.useState("");
const [dateTo, setDateTo] = React.useState("");
const [sortDir, setSortDir] = React.useState<SortDir>("desc");
const [page, setPage] = React.useState(1);
const [pageSize, setPageSize] = React.useState(50);
const [rows, setRows] = React.useState<EventRow[]>([]);
const [totalDocs, setTotalDocs] = React.useState(0);
const [totalPages, setTotalPages] = React.useState(1);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
// Debounce the search box.
React.useEffect(() => {
const t = setTimeout(() => setSearch(searchInput.trim()), 300);
return () => clearTimeout(t);
}, [searchInput]);
// Any filter change resets to page 1.
React.useEffect(() => {
setPage(1);
}, [search, selectedTypes, targetCollection, source, dateFrom, dateTo, sortDir, pageSize]);
const hasActiveFilters =
search !== "" ||
selectedTypes.length > 0 ||
targetCollection !== "all" ||
source !== "all" ||
dateFrom !== "" ||
dateTo !== "";
const resetFilters = React.useCallback(() => {
setSearchInput("");
setSelectedTypes([]);
setTargetCollection("all");
setSource("all");
setDateFrom("");
setDateTo("");
}, []);
React.useEffect(() => {
const controller = new AbortController();
const conditions: Record<string, unknown>[] = [];
if (search) {
conditions.push({ or: [{ message: { like: search } }, { type: { like: search } }] });
}
if (selectedTypes.length > 0) {
conditions.push({ type: { in: selectedTypes } });
}
if (targetCollection !== "all") {
conditions.push({ targetCollection: { equals: targetCollection } });
}
if (source !== "all") {
conditions.push({ system: { equals: source === "system" } });
}
if (dateFrom) {
conditions.push({ timestamp: { greater_than_equal: `${dateFrom}T00:00:00.000Z` } });
}
if (dateTo) {
conditions.push({ timestamp: { less_than_equal: `${dateTo}T23:59:59.999Z` } });
}
const params = new URLSearchParams({
depth: "1",
sort: `${sortDir === "desc" ? "-" : ""}timestamp`,
limit: String(pageSize),
page: String(page),
});
if (conditions.length > 0) {
const where = conditions.length === 1 ? conditions[0] : { and: conditions };
params.set("where", JSON.stringify(where));
}
async function load() {
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/game-event-logs?${params.toString()}`, {
signal: controller.signal,
credentials: "include",
});
if (!res.ok) {
throw new Error(`Request failed (${res.status})`);
}
const data = (await res.json()) as EventLogResponse;
setRows(data.docs ?? []);
setTotalDocs(data.totalDocs ?? 0);
setTotalPages(Math.max(1, data.totalPages ?? 1));
} catch (err) {
if (controller.signal.aborted) return;
setError(err instanceof Error ? err.message : "Failed to load events");
setRows([]);
setTotalDocs(0);
} finally {
if (!controller.signal.aborted) {
setLoading(false);
}
}
}
void load();
return () => controller.abort();
}, [search, selectedTypes, targetCollection, source, dateFrom, dateTo, sortDir, page, pageSize]);
const toggleType = (value: string) => {
setSelectedTypes((prev) =>
prev.includes(value) ? prev.filter((v) => v !== value) : [...prev, value],
);
};
const from = totalDocs === 0 ? 0 : (page - 1) * pageSize + 1;
const to = Math.min(page * pageSize, totalDocs);
return (
<div className="flex flex-col gap-4">
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-2">
<div className="relative w-full max-w-xs">
<Search className="text-muted-foreground absolute top-1/2 left-2 size-4 -translate-y-1/2" />
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search message or type…"
className="pl-8"
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
Type
{selectedTypes.length > 0 && (
<Badge variant="secondary" className="ml-1 px-1.5 py-0 text-[10px]">
{selectedTypes.length}
</Badge>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="max-h-96 w-64 overflow-y-auto" align="start">
{TYPE_GROUPS.map(([prefix, types], groupIdx) => (
<React.Fragment key={prefix}>
{groupIdx > 0 && <DropdownMenuSeparator />}
<DropdownMenuLabel className="text-xs uppercase text-muted-foreground">
{prefix}
</DropdownMenuLabel>
{types.map((value) => (
<DropdownMenuCheckboxItem
key={value}
checked={selectedTypes.includes(value)}
onCheckedChange={() => toggleType(value)}
onSelect={(e) => e.preventDefault()}
>
{formatType(value)}
</DropdownMenuCheckboxItem>
))}
</React.Fragment>
))}
</DropdownMenuContent>
</DropdownMenu>
<Select value={targetCollection} onValueChange={setTargetCollection}>
<SelectTrigger className="h-8 w-44">
<SelectValue placeholder="Target" />
</SelectTrigger>
<SelectContent className="max-h-72">
<SelectItem value="all">All targets</SelectItem>
{TARGET_COLLECTION_OPTIONS.map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={source} onValueChange={(v) => setSource(v as SourceFilter)}>
<SelectTrigger className="h-8 w-36">
<SelectValue placeholder="Source" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All sources</SelectItem>
<SelectItem value="system">System</SelectItem>
<SelectItem value="narrative">Narrative</SelectItem>
</SelectContent>
</Select>
<div className="flex items-center gap-1">
<Input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className="w-36"
aria-label="From date"
/>
<span className="text-muted-foreground text-xs">–</span>
<Input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="w-36"
aria-label="To date"
/>
</div>
<Select value={sortDir} onValueChange={(v) => setSortDir(v as SortDir)}>
<SelectTrigger className="h-8 w-28">
<SelectValue placeholder="Sort" />
</SelectTrigger>
<SelectContent>
<SelectItem value="desc">Newest first</SelectItem>
<SelectItem value="asc">Oldest first</SelectItem>
</SelectContent>
</Select>
{hasActiveFilters && (
<Button variant="ghost" size="sm" onClick={resetFilters}>
<RotateCcw className="size-4" />
Reset
</Button>
)}
<span className="text-muted-foreground ml-auto text-xs">
{totalDocs.toLocaleString()} event{totalDocs === 1 ? "" : "s"}
</span>
</div>
{/* Table */}
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-44">Time</TableHead>
<TableHead className="w-44">Type</TableHead>
<TableHead>Message</TableHead>
<TableHead className="w-32">Actor</TableHead>
<TableHead className="w-44">Structure</TableHead>
<TableHead className="w-40">Target</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading && rows.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-muted-foreground py-8 text-center">
Loading events…
</TableCell>
</TableRow>
) : error ? (
<TableRow>
<TableCell colSpan={6} className="text-destructive py-8 text-center">
{error}
</TableCell>
</TableRow>
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-muted-foreground py-8 text-center">
No events match the current filters.
</TableCell>
</TableRow>
) : (
rows.map((row) => {
const narrative = row.system === false;
return (
<TableRow
key={row.id}
className={cn(narrative && "border-l-2 border-l-amber-500/70")}
>
<TableCell className="text-muted-foreground whitespace-nowrap text-xs">
{formatTimestamp(row.timestamp)}
</TableCell>
<TableCell>
<Badge variant="outline" className="max-w-40 truncate font-normal">
{formatType(row.type)}
</Badge>
</TableCell>
<TableCell className="max-w-md whitespace-normal">{row.message}</TableCell>
<TableCell className="text-xs">{actorLabel(row.actor)}</TableCell>
<TableCell className="text-xs">{structureLabel(row.structure)}</TableCell>
<TableCell className="text-muted-foreground text-xs">
{row.targetCollection
? `${TARGET_COLLECTION_LABELS[row.targetCollection] ?? row.targetCollection}${
row.targetId != null ? ` #${row.targetId}` : ""
}`
: "—"}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-muted-foreground text-xs">
Showing {from.toLocaleString()}–{to.toLocaleString()} of {totalDocs.toLocaleString()}
</span>
<div className="flex items-center gap-2">
<Select
value={String(pageSize)}
onValueChange={(v) => setPageSize(Number(v))}
>
<SelectTrigger className="h-8 w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZES.map((size) => (
<SelectItem key={size} value={String(size)}>
{size} / page
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
disabled={page <= 1 || loading}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Prev
</Button>
<span className="text-muted-foreground text-xs">
Page {page} / {totalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages || loading}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,67 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div className="relative w-full overflow-auto">
<table className={cn("w-full caption-bottom text-sm", className)} {...props} />
</div>
);
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return <thead className={cn("[&_tr]:border-b", className)} {...props} />;
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return <tbody className={cn("[&_tr:last-child]:border-0", className)} {...props} />;
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
className={cn("bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", className)}
{...props}
/>
);
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className,
)}
{...props}
/>
);
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
className={cn(
"text-muted-foreground h-10 px-2 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0",
className,
)}
{...props}
/>
);
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
className={cn("p-2 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
);
}
function TableCaption({ className, ...props }: React.ComponentProps<"caption">) {
return <caption className={cn("text-muted-foreground mt-4 text-sm", className)} {...props} />;
}
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };