mc-dashboard/components/ModList.tsx
hurkicorgi 6c91f7fef0 UX/UI/perf pass: admin tabs, theme toggle, log polish, mod search, JAR cache
- Admin page split into tabs (Server/Players/Chat/Mods/Backups/Logs) with
  hash + localStorage persistence; inactive tabs no longer mount.
- Log viewer: level color-coding, search, level filter chips, auto-scroll
  toggle, copy button, visible-line count.
- Installed mods list: search field + side filter (all/both/server/client)
  with live count; public ModList gets skeleton + empty states and search.
- Theme toggle with no-flash inline init, localStorage + system preference.
- Layout: full OG / Twitter metadata, title template, keywords,
  dual-theme themeColor, metadataBase.
- lib/mods.ts: per-jar mtime+size parse cache (cold 6s -> warm ~45ms on
  /api/mods for the full mod list); cache eviction on mod removal.
- ChatBridge polling eased 3s -> 5s with 2s stale window.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 04:58:25 -06:00

103 lines
3.3 KiB
TypeScript

"use client";
import { useQuery } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
type Mod = {
modId: string;
displayName: string;
version: string;
filename: string;
size: string;
};
export function ModList() {
const [query, setQuery] = useState("");
const { data: mods, isLoading, isError } = useQuery<Mod[]>({
queryKey: ["mods"],
queryFn: () => fetch("/api/mods").then((r) => r.json()),
staleTime: 5 * 60 * 1000,
});
const filtered = useMemo(() => {
if (!mods) return [];
const q = query.trim().toLowerCase();
if (!q) return mods;
return mods.filter(
(m) =>
m.displayName.toLowerCase().includes(q) ||
m.modId.toLowerCase().includes(q) ||
m.filename.toLowerCase().includes(q)
);
}, [mods, query]);
return (
<Card>
<CardHeader className="pb-0">
<div className="flex items-center justify-between gap-3 flex-wrap">
<CardTitle className="text-base">
Installed Mods {mods ? `(${mods.length})` : ""}
</CardTitle>
{mods && mods.length > 6 && (
<Input
placeholder="Search mods..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="h-8 text-sm max-w-[220px]"
/>
)}
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<ul className="-mx-1 space-y-1">
{Array.from({ length: 6 }).map((_, i) => (
<li key={i} className="flex justify-between items-center px-3 py-2.5">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-12" />
</li>
))}
</ul>
) : isError ? (
<p className="text-sm text-red-300 py-4 text-center">
Failed to load mods.
</p>
) : filtered.length === 0 ? (
<p className="text-sm text-muted-foreground py-8 text-center">
{query ? `No mods match "${query}"` : "No mods installed."}
</p>
) : (
<ul className="max-h-[350px] sm:max-h-[400px] overflow-y-auto -mx-1">
{filtered.map((mod, i) => (
<li
key={mod.filename}
className={`flex justify-between items-center px-3 py-2.5 rounded-md ${
i % 2 === 1 ? "bg-muted/50" : ""
}`}
>
<div className="flex items-center gap-2 min-w-0">
<span className="text-sm font-medium truncate">
{mod.displayName}
</span>
{mod.version && (
<Badge variant="secondary" className="text-xs px-1.5 py-0 shrink-0 hidden sm:inline-flex">
{mod.version}
</Badge>
)}
</div>
<span className="text-xs text-muted-foreground whitespace-nowrap ml-3 tabular-nums">
{mod.size}
</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
);
}