Files
buzz/app/(ui)/admin/log/client.tsx
T
dmgnr a31c8fbb54
Build and Push Image / build-backend (push) Successful in 52s
Build and Push Image / build-frontend (push) Successful in 2m8s
Build and Push Image / push (push) Successful in 1m24s
chore: oxc
2026-06-10 01:44:11 +07:00

218 lines
6.9 KiB
TypeScript
Executable File

"use client";
import { ChevronDown, ChevronRight, FormIcon } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { stringify } from "yaml";
import SearchBox, { type Filters, type ParsedQuery } from "@/components/search";
import { SimpleTooltip } from "@/components/tooltip";
import { Button } from "@/components/ui/button";
import type { auditLog } from "@/lib/db/schema";
import { sse } from "@/lib/db/sse-endpoints";
type AuditLog = typeof auditLog.$inferSelect;
export default function AuditLogViewer({
logs: initialLogs = [],
users = [],
}: {
logs?: AuditLog[];
users?: { name: string; email: string }[];
}) {
const [logs, updateLogs] = useState(initialLogs);
const containerRef = useRef<HTMLDivElement>(null);
const entryRefs = useRef(new Map<string, HTMLDivElement>());
useEffect(() => {
return sse.log.sub("update", (res) =>
updateLogs((x) => [...x.slice(-999), res]),
).clean;
}, []);
const filters: Filters = {
user: () => users.map((u) => u.email),
has: () => ["artifact", "tierlist", "rubgram", "character", "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 focusedIds = useMemo(() => Array.from(expandedIds), [expandedIds]);
// biome-ignore lint/correctness/useExhaustiveDependencies: need to update
useEffect(() => {
const container = containerRef.current;
if (!container) return;
// exactly one focused → scroll to that entry
if (focusedIds.length === 1) {
const el = entryRefs.current.get(focusedIds[0]);
if (el) {
el.scrollIntoView({
behavior: "smooth",
block: "center",
});
}
return;
}
// more than one focused → do nothing
if (focusedIds.length > 1) {
return;
}
// none focused → always follow bottom
container.scrollTo({
top: container.scrollHeight,
behavior: "smooth",
});
}, [logs, focusedIds, query]);
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 = Math.abs(
new Date(currentLog.time).getTime() -
new Date(previousLog.time).getTime(),
);
return timeDiff < 5 * 60 * 1000; // Group if within 5 minutes
};
return (
<div className="flex h-svh w-full flex-col items-center gap-2 overflow-hidden bg-[#2225] p-2">
<SearchBox
filters={filters}
className="bg-input"
onQueryChange={setQuery}
/>
<div className="w-full grow-0 overflow-auto" ref={containerRef}>
{filteredLogs.map((log, idx) => {
const isGrouped = shouldGroup(log, logs[idx - 1]);
const isExpanded = expandedIds.has(log.id);
return (
<div
key={log.id}
ref={(el) => {
if (el) entryRefs.current.set(log.id, el);
else entryRefs.current.delete(log.id);
}}
className="group transition-colors hover:bg-[#2225]"
>
<div
className={`flex gap-4 px-4 ${isGrouped ? "py-0.5" : "py-3.5"} pb-0.5`}
>
<div className="w-min shrink-0 pt-0.5 text-right">
<span
className={`invisible text-xs whitespace-nowrap ${isGrouped ? "group-hover:visible" : ""}`}
>
{formatTime(log.time)}
</span>
</div>
<div className="min-w-0 flex-1">
{!isGrouped && (
<div className="mb-0.5 flex items-baseline gap-2">
<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="flex items-center gap-2 leading-relaxed text-gray-300">
{log.text}{" "}
{!isExpanded && log.details ? (
<SimpleTooltip text="Click to see details">
<FormIcon className="size-4 opacity-80" />
</SimpleTooltip>
) : (
""
)}
</span>
{log.details ? (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 text-gray-400 hover:bg-[#404249] hover:text-gray-200"
>
{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="overflow-x-auto rounded border border-[#27282c] bg-[#1e1f2255] p-3 font-mono text-sm text-gray-300">
{stringify(log.details)}
</pre>
</div>
) : undefined}
</div>
</div>
</div>
);
})}
</div>
</div>
);
}