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