audit log, character list

This commit is contained in:
Dreamgineer
2025-10-23 04:31:57 -04:00 Unverified
parent ac69b256e3
commit fa25a97306
35 changed files with 1305 additions and 872 deletions
+101
View File
@@ -0,0 +1,101 @@
"use client";
import { UserPlus } from "lucide-react";
import { useMemo, useState } from "react";
import Avatar from "@/components/avatar";
import SearchBox, { type Filters, type ParsedQuery } from "@/components/search";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { type characters, element } from "@/lib/db/schema";
/** Filters and their value providers */
const filters: Filters = {
stars: () => ["4", "5"],
type: () => ["sword", "catalyst", "claymore", "bow", "polearm"],
element: () => element.enumValues,
};
export function CharManager({
chars,
}: {
chars: (typeof characters.$inferSelect)[];
}) {
const [query, setQuery] = useState<ParsedQuery>();
const filteredChars = useMemo(() => {
return chars.filter((char) =>
query
? (query.filters.stars
? char.stars.toString() === query.filters.stars
: true) &&
(query.filters.element
? char.vision === query.filters.element
: true) &&
(query.filters.weapon
? char.weapon.toLowerCase().includes(query.filters.type)
: true) &&
char.name.toLowerCase().includes(query.search.toLowerCase())
: true,
);
}, [query, chars]);
return (
<div className="flex flex-col w-full">
<div className="flex gap-2 justify-center p-2">
<SearchBox
filters={filters}
className="bg-input sticky top-2"
onQueryChange={setQuery}
/>
<Dialog>
<form>
<DialogTrigger asChild>
<Button className="h-10 w-10">
<UserPlus />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Character</DialogTitle>
</DialogHeader>
<div className="grid gap-4">
<div className="grid gap-3">
<Label htmlFor="name-1">Name</Label>
<Input id="name-1" name="name" />
</div>
<div className="grid gap-3">
<Label htmlFor="username-1">Username</Label>
<Input
id="username-1"
name="username"
defaultValue="@peduarte"
/>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button type="submit">Save changes</Button>
</DialogFooter>
</DialogContent>
</form>
</Dialog>
</div>
<div className="flex flex-wrap gap-4 justify-center">
{filteredChars.map((char) => (
<Avatar char={char} key={char.id} />
))}
</div>
</div>
);
}
+8
View File
@@ -0,0 +1,8 @@
import { db } from "@/lib/db";
import { characters } from "@/lib/db/schema";
import { CharManager } from "./client";
export default async function CharManagerPage() {
const chars = await db.select().from(characters);
return <CharManager chars={chars} />;
}
+9 -3
View File
@@ -1,8 +1,9 @@
"use client"; // Error boundaries must be Client Components
import { RefreshCw } from "lucide-react";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
export default function ErrorPage({
error,
@@ -11,6 +12,7 @@ export default function ErrorPage({
error: Error & { digest?: string };
reset: () => void;
}) {
const [loading, setLoading] = useState(false);
useEffect(() => {
// Log the error to an error reporting service
console.error(error);
@@ -22,10 +24,14 @@ export default function ErrorPage({
<Button
onClick={
// Attempt to recover by trying to re-render the segment
() => reset()
() => {
setLoading(true);
setTimeout(() => reset(), 500);
}
}
disabled={loading}
>
<RefreshCw />
{loading ? <Spinner /> : <RefreshCw />}
Try again
</Button>
</div>
+163
View File
@@ -0,0 +1,163 @@
"use client";
import { ChevronDown, ChevronRight } from "lucide-react";
import { useMemo, useState } from "react";
import { stringify } from "yaml";
import SearchBox, { type Filters, type ParsedQuery } from "@/components/search";
import { Button } from "@/components/ui/button";
import type { auditLog } from "@/lib/db/schema";
type AuditLog = typeof auditLog.$inferSelect;
export default function AuditLogViewer({
logs = [],
users = [],
}: {
logs?: AuditLog[];
users?: { name: string; email: string }[];
}) {
const filters: Filters = {
user: () => users.map((u) => u.email),
has: () => ["artifact", "tierlist", "file"],
};
const [query, setQuery] = useState<ParsedQuery>();
const filteredLogs = useMemo(() => {
return logs.filter((log) =>
query
? (query.filters.user
? log.author?.toString() ===
users.find((u) => u.email === query.filters.user)?.name
: true) &&
(query.filters.has
? log.text.toLowerCase().includes(query.filters.has)
: true) &&
stringify(log).toLowerCase().includes(query.search.toLowerCase())
: true,
);
}, [query, logs, users]);
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const toggleExpand = (id: string) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
const formatTime = (date: Date | string) => {
return new Date(date).toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
hour12: true,
timeZone: "Asia/Bangkok",
});
};
const formatDate = (date: Date | string) => {
return new Date(date).toLocaleDateString("en-US", {
month: "2-digit",
day: "2-digit",
year: "numeric",
timeZone: "Asia/Bangkok",
});
};
const shouldGroup = (
currentLog: AuditLog,
previousLog: AuditLog | undefined,
): boolean => {
if (!previousLog) return false;
if (currentLog.author !== previousLog.author) return false;
const timeDiff =
new Date(currentLog.time).getTime() -
new Date(previousLog.time).getTime();
return timeDiff < 5 * 60 * 1000; // Group if within 5 minutes
};
return (
<div className="bg-[#2225] h-svh w-full flex flex-col items-center gap-2 p-2 overflow-hidden">
<SearchBox
filters={filters}
className="bg-input"
onQueryChange={setQuery}
/>
<div className="w-full grow-0 overflow-auto">
{filteredLogs.map((log, idx) => {
const isGrouped = shouldGroup(log, logs[idx - 1]);
const isExpanded = expandedIds.has(log.id);
return (
<div
key={log.id}
className="group hover:bg-[#2225] transition-colors"
>
<div
className={`flex gap-4 px-4 ${isGrouped ? "py-0.5" : "py-3"}`}
>
<div className="w-min flex-shrink-0 text-right pt-0.5">
<span
className={`text-xs whitespace-nowrap invisible ${isGrouped ? "group-hover:visible" : ""}`}
>
{formatTime(log.time)}
</span>
</div>
<div className="flex-1 min-w-0">
{!isGrouped && (
<div className="flex items-baseline gap-2 mb-0.5">
<span className="font-semibold text-white">
{log.author || "System"}
</span>
<span className="text-xs text-gray-500">
{formatDate(log.time)} {formatTime(log.time)}
</span>
</div>
)}
<div
className="flex items-start justify-between gap-2"
onClick={() => toggleExpand(log.id)}
role="tree"
>
<span className="text-gray-300 leading-relaxed">
{log.text}
</span>
{log.details ? (
<Button
variant="ghost"
size="icon"
className="flex-shrink-0 h-6 w-6 text-gray-400 hover:text-gray-200 hover:bg-[#404249]"
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
) : undefined}
</div>
{isExpanded && log.details ? (
<div className="mt-2 mb-1">
<pre className="bg-[#1e1f2255] rounded p-3 text-sm text-gray-300 overflow-x-auto border border-[#27282c] font-mono">
{stringify(log.details)}
</pre>
</div>
) : undefined}
</div>
</div>
</div>
);
})}
</div>
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
import { db } from "@/lib/db";
import { auditLog, user } from "@/lib/db/schema";
import AuditLogViewer from "./client";
export default async function AuditLogViewerPage() {
const logs = await db.select().from(auditLog).orderBy(auditLog.time);
const users = await db
.select({ name: user.name, email: user.email })
.from(user);
return <AuditLogViewer logs={logs} users={users} />;
}