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:
parent
b6cf8c7cdc
commit
6c91f7fef0
10 changed files with 490 additions and 89 deletions
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue