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:
parent
227885daf7
commit
29f7e2b57a
3 changed files with 574 additions and 0 deletions
22
src/app/(frontend)/events/page.tsx
Normal file
22
src/app/(frontend)/events/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
485
src/components/frontend/events/EventLogTable.tsx
Normal file
485
src/components/frontend/events/EventLogTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
src/components/ui/table.tsx
Normal file
67
src/components/ui/table.tsx
Normal 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 };
|
||||
Loading…
Reference in a new issue