- 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>
103 lines
3.3 KiB
TypeScript
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>
|
|
);
|
|
}
|