audit log, character list
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
Reference in New Issue
Block a user