diff --git a/src/app/(frontend)/events/page.tsx b/src/app/(frontend)/events/page.tsx new file mode 100644 index 0000000..622f2c5 --- /dev/null +++ b/src/app/(frontend)/events/page.tsx @@ -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 ( +
+
+

Event Log

+

+ The full game event ledger — system actions and GM narrative events. Filter by type, + target, source, or date range. +

+
+ +
+ ); +} diff --git a/src/components/frontend/events/EventLogTable.tsx b/src/components/frontend/events/EventLogTable.tsx new file mode 100644 index 0000000..90f5c0d --- /dev/null +++ b/src/components/frontend/events/EventLogTable.tsx @@ -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 = { + "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(); + 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([]); + const [targetCollection, setTargetCollection] = React.useState("all"); + const [source, setSource] = React.useState("all"); + const [dateFrom, setDateFrom] = React.useState(""); + const [dateTo, setDateTo] = React.useState(""); + const [sortDir, setSortDir] = React.useState("desc"); + const [page, setPage] = React.useState(1); + const [pageSize, setPageSize] = React.useState(50); + + const [rows, setRows] = React.useState([]); + const [totalDocs, setTotalDocs] = React.useState(0); + const [totalPages, setTotalPages] = React.useState(1); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(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[] = []; + 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 ( +
+ {/* Toolbar */} +
+
+ + setSearchInput(e.target.value)} + placeholder="Search message or type…" + className="pl-8" + /> +
+ + + + + + + {TYPE_GROUPS.map(([prefix, types], groupIdx) => ( + + {groupIdx > 0 && } + + {prefix} + + {types.map((value) => ( + toggleType(value)} + onSelect={(e) => e.preventDefault()} + > + {formatType(value)} + + ))} + + ))} + + + + + + + +
+ setDateFrom(e.target.value)} + className="w-36" + aria-label="From date" + /> + – + setDateTo(e.target.value)} + className="w-36" + aria-label="To date" + /> +
+ + + + {hasActiveFilters && ( + + )} + + + {totalDocs.toLocaleString()} event{totalDocs === 1 ? "" : "s"} + +
+ + {/* Table */} +
+ + + + Time + Type + Message + Actor + Structure + Target + + + + {loading && rows.length === 0 ? ( + + + Loading events… + + + ) : error ? ( + + + {error} + + + ) : rows.length === 0 ? ( + + + No events match the current filters. + + + ) : ( + rows.map((row) => { + const narrative = row.system === false; + return ( + + + {formatTimestamp(row.timestamp)} + + + + {formatType(row.type)} + + + {row.message} + {actorLabel(row.actor)} + {structureLabel(row.structure)} + + {row.targetCollection + ? `${TARGET_COLLECTION_LABELS[row.targetCollection] ?? row.targetCollection}${ + row.targetId != null ? ` #${row.targetId}` : "" + }` + : "—"} + + + ); + }) + )} + +
+
+ + {/* Pagination */} +
+ + Showing {from.toLocaleString()}–{to.toLocaleString()} of {totalDocs.toLocaleString()} + +
+ + + + Page {page} / {totalPages} + + +
+
+
+ ); +} diff --git a/src/components/ui/table.tsx b/src/components/ui/table.tsx new file mode 100644 index 0000000..eec3907 --- /dev/null +++ b/src/components/ui/table.tsx @@ -0,0 +1,67 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Table({ className, ...props }: React.ComponentProps<"table">) { + return ( +
+ + + ); +} + +function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { + return ; +} + +function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { + return ; +} + +function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { + return ( + tr]:last:border-b-0", className)} + {...props} + /> + ); +} + +function TableRow({ className, ...props }: React.ComponentProps<"tr">) { + return ( + + ); +} + +function TableHead({ className, ...props }: React.ComponentProps<"th">) { + return ( +
+ ); +} + +function TableCell({ className, ...props }: React.ComponentProps<"td">) { + return ( + + ); +} + +function TableCaption({ className, ...props }: React.ComponentProps<"caption">) { + return
; +} + +export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };