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>
This commit is contained in:
hurkicorgi 2026-04-13 04:58:25 -06:00
parent b6cf8c7cdc
commit 6c91f7fef0
10 changed files with 490 additions and 89 deletions

View file

@ -122,6 +122,8 @@ export function ModManager() {
const [confirmRemove, setConfirmRemove] = useState<string | null>(null);
const [confirmRestore, setConfirmRestore] = useState<string | null>(null);
const [confirmDeleteSnap, setConfirmDeleteSnap] = useState<string | null>(null);
const [installedQuery, setInstalledQuery] = useState("");
const [sideFilter, setSideFilter] = useState<"all" | ModSide>("all");
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
// Installed mods
@ -690,11 +692,57 @@ export function ModManager() {
<>
<Separator />
<div>
<h3 className="text-sm font-semibold mb-3">
Installed Mods ({mods.length})
</h3>
<div className="flex items-center justify-between gap-3 mb-3 flex-wrap">
<h3 className="text-sm font-semibold">
Installed Mods ({(() => {
const q = installedQuery.trim().toLowerCase();
const count = mods.filter((m) =>
(sideFilter === "all" || m.side === sideFilter) &&
(!q ||
m.displayName.toLowerCase().includes(q) ||
m.modId.toLowerCase().includes(q) ||
m.filename.toLowerCase().includes(q))
).length;
return count === mods.length ? mods.length : `${count} / ${mods.length}`;
})()})
</h3>
<div className="flex items-center gap-2 flex-wrap">
<div className="flex gap-1">
{(["all", "both", "server", "client"] as const).map((s) => (
<button
key={s}
onClick={() => setSideFilter(s)}
className={`text-[10px] font-semibold uppercase rounded px-2 py-1 border transition ${
sideFilter === s
? "border-primary/40 bg-primary/10 text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
{s}
</button>
))}
</div>
<Input
placeholder="Search installed..."
value={installedQuery}
onChange={(e) => setInstalledQuery(e.target.value)}
className="h-8 text-sm max-w-[200px]"
/>
</div>
</div>
<ul className="max-h-[400px] overflow-y-auto space-y-1">
{mods.map((mod) => (
{mods
.filter((m) => {
if (sideFilter !== "all" && m.side !== sideFilter) return false;
const q = installedQuery.trim().toLowerCase();
if (!q) return true;
return (
m.displayName.toLowerCase().includes(q) ||
m.modId.toLowerCase().includes(q) ||
m.filename.toLowerCase().includes(q)
);
})
.map((mod) => (
<li
key={mod.filename}
className="flex items-center justify-between gap-2 px-3 py-2 rounded-md bg-muted/50 group"