feat: fix mods tab, add client/server split, and scaffold server dirs

Mods tab bug fixes:
- mod_manager: fix wrong kwargs in set_enabled_mods, fix scan dir to use
  mods/ subdir instead of server root, migrate old string-list format to
  dict format on read
- service: replace dead server_mods SQL JOIN with get_enabled_mods()
  call through the mod_manager capability; pass is_server_mod to
  build_mod_args
- mods_router: accept list[EnabledModEntry] objects (name + is_server_mod)
  instead of bare strings

Client/server mod split:
- Mods now stored as list[{"name": str, "is_server_mod": bool}]; old
  string-list format auto-migrated on read
- is_server_mod=true routes to -serverMod= arg; false to -mod= arg
- ModList UI: amber Client/Server badge in selected pane; toggle button
  in split-pane selector

Directory scaffold:
- process_config: adds "mods" to dir layout; provides get_dir_readme()
  with per-directory README.txt content
- file_utils: ensure_server_dirs() gains readme_provider kwarg; writes
  README.txt idempotently if absent
- service.create_server: passes readme_provider via hasattr probe
- main.py startup: backfills all existing servers with correct subdirs
  and README files (idempotent)

Docs: API.md and FRONTEND.md updated for new mod schema and types
Test __init__.py files added for pytest discovery
This commit is contained in:
Tran G. (Revernomad) Khoa
2026-04-20 10:54:56 +07:00
parent fa95587567
commit d45345a094
12 changed files with 209 additions and 67 deletions

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
import { Save } from "lucide-react";
import { Save, Server } from "lucide-react";
import { useServerMods, useSetEnabledMods } from "@/hooks/useServerDetail";
import type { Mod } from "@/hooks/useServerDetail";
@@ -38,14 +38,25 @@ export function ModList({ serverId }: ModListProps) {
setAvailable((prev) => [...prev, { ...mod, enabled: false }].sort((a, b) => a.name.localeCompare(b.name)));
};
const toggleServerMod = (modName: string) => {
setSelected((prev) =>
prev.map((m) => m.name === modName ? { ...m, is_server_mod: !m.is_server_mod } : m),
);
};
const _selectedKey = (mods: Mod[]) =>
mods.map((m) => `${m.name}:${m.is_server_mod}`).sort().join(",");
const hasChanges = modsData !== undefined && (
selected.map((m) => m.name).sort().join(",") !==
(modsData.mods.filter((m) => m.enabled).map((m) => m.name).sort().join(","))
_selectedKey(selected) !==
_selectedKey(modsData.mods.filter((m) => m.enabled))
);
const handleApply = async () => {
try {
await setEnabledMods.mutateAsync(selected.map((m) => m.name));
await setEnabledMods.mutateAsync(
selected.map((m) => ({ name: m.name, is_server_mod: m.is_server_mod })),
);
addNotification({ type: "success", message: `${selected.length} mod(s) enabled. Server restart required.` });
} catch (err) {
logger.error("ModList", "Failed to apply mods: %s", err);
@@ -144,6 +155,7 @@ export function ModList({ serverId }: ModListProps) {
mod={mod}
actionLabel="←"
onAction={isAdmin ? () => moveToAvailable(mod) : undefined}
onToggleServerMod={isAdmin ? () => toggleServerMod(mod.name) : undefined}
selected
/>
))
@@ -159,17 +171,17 @@ function ModRow({
mod,
actionLabel,
onAction,
onToggleServerMod,
selected = false,
}: {
mod: Mod;
actionLabel: string;
onAction?: () => void;
onToggleServerMod?: () => void;
selected?: boolean;
}) {
return (
<div
className="flex items-center gap-2 px-2 py-1.5 rounded-lg bg-surface-recessed shadow-neu-recessed"
>
<div className="flex items-center gap-2 px-2 py-1.5 rounded-lg bg-surface-recessed shadow-neu-recessed">
<div className="flex-1 min-w-0">
<p className="text-text-primary text-sm font-medium truncate">
{mod.display_name ?? mod.name}
@@ -186,6 +198,21 @@ function ModRow({
<span className="text-text-muted text-xs">{formatSize(mod.size_bytes)}</span>
</div>
</div>
{selected && onToggleServerMod && (
<button
type="button"
onClick={onToggleServerMod}
title={mod.is_server_mod ? "Server-only mod (-serverMod). Click to switch to client mod (-mod)" : "Client mod (-mod). Click to switch to server-only (-serverMod)"}
className={`flex items-center gap-1 text-xs px-1.5 py-0.5 rounded shrink-0 transition-colors ${
mod.is_server_mod
? "bg-amber-500/20 text-amber-400"
: "bg-surface-raised text-text-muted hover:text-text-secondary"
}`}
>
<Server size={10} />
{mod.is_server_mod ? "Server" : "Client"}
</button>
)}
{onAction && (
<button
type="button"