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:
42
API.md
42
API.md
@@ -1374,18 +1374,30 @@ List all available mods and which are currently enabled for this server.
|
||||
"mods": [
|
||||
{
|
||||
"name": "@CBA_A3",
|
||||
"folder_path": "C:/Arma3Server/@CBA_A3",
|
||||
"enabled": true
|
||||
"path": "D:/Arma3Server/1/mods/@CBA_A3",
|
||||
"size_bytes": 12345678,
|
||||
"enabled": true,
|
||||
"is_server_mod": false,
|
||||
"display_name": "Community Base Addons A3",
|
||||
"workshop_id": "450814997"
|
||||
},
|
||||
{
|
||||
"name": "@ACRE2",
|
||||
"folder_path": "C:/Arma3Server/@ACRE2",
|
||||
"enabled": true
|
||||
"path": "D:/Arma3Server/1/mods/@ACRE2",
|
||||
"size_bytes": 9876543,
|
||||
"enabled": true,
|
||||
"is_server_mod": true,
|
||||
"display_name": "ACRE2",
|
||||
"workshop_id": "751965892"
|
||||
},
|
||||
{
|
||||
"name": "@USAF",
|
||||
"folder_path": "C:/Arma3Server/@USAF",
|
||||
"enabled": false
|
||||
"path": "D:/Arma3Server/1/mods/@USAF",
|
||||
"size_bytes": 55000000,
|
||||
"enabled": false,
|
||||
"is_server_mod": false,
|
||||
"display_name": null,
|
||||
"workshop_id": null
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1393,6 +1405,8 @@ List all available mods and which are currently enabled for this server.
|
||||
}
|
||||
```
|
||||
|
||||
Mod folders are scanned from `{server_data_dir}/{server_id}/mods/@*`. `display_name` is parsed from `mod.cpp`; `workshop_id` from `meta.cpp`. `is_server_mod: true` means the mod is passed via `-serverMod=` instead of `-mod=`.
|
||||
|
||||
---
|
||||
|
||||
### PUT /servers/{server_id}/mods/enabled
|
||||
@@ -1405,13 +1419,18 @@ Set the list of enabled mods. This **replaces** the entire enabled list — send
|
||||
|
||||
```json
|
||||
{
|
||||
"mods": ["@CBA_A3", "@ACRE2"]
|
||||
"mods": [
|
||||
{ "name": "@CBA_A3", "is_server_mod": false },
|
||||
{ "name": "@ACRE2", "is_server_mod": true }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|--------|---------------|----------|------------------------------------|
|
||||
| `mods` | array[string] | Yes | Complete list of mod names to enable |
|
||||
|---------------------|---------|----------|-------------|
|
||||
| `mods` | array | Yes | Complete list of mod entries to enable |
|
||||
| `mods[].name` | string | Yes | Mod folder name (must start with `@`) |
|
||||
| `mods[].is_server_mod` | bool | No | `true` → `-serverMod=`, `false` (default) → `-mod=` |
|
||||
|
||||
**Response 200:**
|
||||
|
||||
@@ -1420,7 +1439,10 @@ Set the list of enabled mods. This **replaces** the entire enabled list — send
|
||||
"success": true,
|
||||
"data": {
|
||||
"message": "Enabled mods updated. Restart the server for changes to take effect.",
|
||||
"enabled_mods": ["@CBA_A3", "@ACRE2"]
|
||||
"enabled_mods": [
|
||||
{ "name": "@CBA_A3", "is_server_mod": false },
|
||||
{ "name": "@ACRE2", "is_server_mod": true }
|
||||
]
|
||||
},
|
||||
"error": null
|
||||
}
|
||||
|
||||
@@ -189,7 +189,7 @@ All server data flows through TanStack Query hooks:
|
||||
| `useUploadMission(id)` | Mutation | `POST /api/servers/:id/missions` (multipart, `File[]`) | Invalidates `["missions", id]` |
|
||||
| `useUpdateMissionRotation(id)` | Mutation | `PUT /api/servers/:id/missions/rotation` | Invalidates rotation + server config |
|
||||
| `useDeleteMission(id)` | Mutation | `DELETE /api/servers/:id/missions/:filename` | Invalidates `["missions", id]` |
|
||||
| `useSetEnabledMods(id)` | Mutation | `PUT /api/servers/:id/mods/enabled` | Invalidates `["mods", id]` |
|
||||
| `useSetEnabledMods(id)` | Mutation | `PUT /api/servers/:id/mods/enabled` body: `EnabledModEntry[]` | Invalidates `["mods", id]` |
|
||||
| `useSendCommand(id)` | Mutation | `POST /api/servers/:id/rcon/command` | No invalidation |
|
||||
| `useKickPlayer(id)` | Mutation | `POST /api/servers/:id/players/:slot_id/kick` | Invalidates `["players", id]` |
|
||||
| `useBanPlayer(id)` | Mutation | `POST /api/servers/:id/players/:slot_id/ban` | Invalidates players + bans |
|
||||
@@ -219,7 +219,8 @@ All server data flows through TanStack Query hooks:
|
||||
**Key type notes**:
|
||||
- `Server` type in `useServers.ts` uses `game_port`, `current_players`, `max_players` (matches enriched API response)
|
||||
- `Mission` type: `{ name, filename, size_bytes, terrain }` — terrain parsed from filename
|
||||
- `Mod` type: `{ name, path, size_bytes, enabled, display_name, workshop_id }` — `display_name`/`workshop_id` from mod.cpp/meta.cpp
|
||||
- `Mod` type: `{ name, path, size_bytes, enabled, is_server_mod, display_name, workshop_id }` — `display_name`/`workshop_id` from mod.cpp/meta.cpp; `is_server_mod` controls `-serverMod=` vs `-mod=`
|
||||
- `EnabledModEntry` type: `{ name: string, is_server_mod: boolean }` — used as `useSetEnabledMods` mutation input
|
||||
- `Ban` type: `{ id, server_id, guid, name, reason, banned_by, banned_at, expires_at, is_active, game_data }` (matches API)
|
||||
- There is no REST endpoint for logs — logs are only pushed via WebSocket events
|
||||
|
||||
|
||||
@@ -47,24 +47,27 @@ class Arma3ModManager:
|
||||
def _server_dir(self) -> Path:
|
||||
return get_server_dir(self._server_id)
|
||||
|
||||
def _mods_dir(self) -> Path:
|
||||
return get_server_dir(self._server_id) / "mods"
|
||||
|
||||
# ── File / DB operations ──
|
||||
|
||||
def list_available_mods(self) -> list[dict]:
|
||||
"""
|
||||
Scan the server directory for mod folders (directories starting with '@').
|
||||
Scan the server's mods/ subdirectory for mod folders (directories starting with '@').
|
||||
|
||||
Returns list of dicts:
|
||||
name: str — directory name (e.g. "@CBA_A3")
|
||||
path: str — absolute directory path
|
||||
size_bytes: int — total directory size (approximate, non-recursive)
|
||||
"""
|
||||
server_dir = self._server_dir()
|
||||
if not server_dir.exists():
|
||||
mods_dir = self._mods_dir()
|
||||
if not mods_dir.exists():
|
||||
return []
|
||||
|
||||
mods = []
|
||||
try:
|
||||
for entry in server_dir.iterdir():
|
||||
for entry in mods_dir.iterdir():
|
||||
if entry.is_dir() and _MOD_DIR_PATTERN.match(entry.name):
|
||||
try:
|
||||
size = sum(
|
||||
@@ -87,54 +90,59 @@ class Arma3ModManager:
|
||||
mods.sort(key=lambda m: m["name"].lower())
|
||||
return mods
|
||||
|
||||
def get_enabled_mods(self, config_repo) -> list[str]:
|
||||
def get_enabled_mods(self, config_repo) -> list[dict]:
|
||||
"""
|
||||
Get the list of enabled mod names from the database config.
|
||||
Get the list of enabled mods from the database config.
|
||||
|
||||
Args:
|
||||
config_repo: ConfigRepository instance.
|
||||
|
||||
Returns list of mod directory names (e.g. ["@CBA_A3", "@ace"]).
|
||||
Returns list of dicts: [{"name": "@CBA_A3", "is_server_mod": False}, ...]
|
||||
Handles migration from old string-list format automatically.
|
||||
"""
|
||||
mods_section = config_repo.get_section(self._server_id, "mods")
|
||||
if mods_section is None:
|
||||
return []
|
||||
enabled = mods_section.get("enabled_mods", [])
|
||||
if isinstance(enabled, str):
|
||||
enabled = [m.strip() for m in enabled.split(",") if m.strip()]
|
||||
return enabled
|
||||
raw = mods_section.get("enabled_mods", [])
|
||||
result = []
|
||||
for item in raw:
|
||||
if isinstance(item, str):
|
||||
result.append({"name": item, "is_server_mod": False})
|
||||
elif isinstance(item, dict):
|
||||
result.append({"name": item.get("name", ""), "is_server_mod": bool(item.get("is_server_mod", False))})
|
||||
return result
|
||||
|
||||
def set_enabled_mods(self, mod_names: list[str], config_repo) -> None:
|
||||
def set_enabled_mods(self, mod_entries: list[dict], config_repo) -> None:
|
||||
"""
|
||||
Update the enabled mods list in the database config.
|
||||
|
||||
Args:
|
||||
mod_names: List of mod directory names to enable.
|
||||
mod_entries: List of dicts with "name" (str) and "is_server_mod" (bool).
|
||||
config_repo: ConfigRepository instance.
|
||||
|
||||
Raises AdapterError if any mod name doesn't exist on disk.
|
||||
Raises AdapterError if any mod name is invalid or not found on disk.
|
||||
"""
|
||||
available = {m["name"] for m in self.list_available_mods()}
|
||||
for name in mod_names:
|
||||
for entry in mod_entries:
|
||||
name = entry.get("name", "")
|
||||
if not _MOD_DIR_PATTERN.match(name):
|
||||
raise AdapterError(f"Invalid mod name '{name}': must start with '@'")
|
||||
if name not in available:
|
||||
raise AdapterError(
|
||||
f"Mod '{name}' not found in server directory. "
|
||||
f"Mod '{name}' not found in mods directory. "
|
||||
f"Available: {sorted(available)}"
|
||||
)
|
||||
|
||||
mods_section = config_repo.get_section(self._server_id, "mods") or {}
|
||||
current_version = mods_section.get("config_version", 0)
|
||||
current_version = mods_section.get("_meta", {}).get("config_version")
|
||||
config_repo.upsert_section(
|
||||
server_id=self._server_id,
|
||||
game_type="arma3",
|
||||
section="mods",
|
||||
data={"enabled_mods": mod_names},
|
||||
expected_version=current_version,
|
||||
config_data={"enabled_mods": mod_entries},
|
||||
schema_version="1.0.0",
|
||||
expected_config_version=current_version,
|
||||
)
|
||||
logger.info(
|
||||
"Updated enabled mods for server %d: %s",
|
||||
self._server_id, mod_names,
|
||||
self._server_id, [e["name"] for e in mod_entries],
|
||||
)
|
||||
|
||||
# ── CLI argument building ──
|
||||
|
||||
@@ -27,4 +27,50 @@ class Arma3ProcessConfig:
|
||||
|
||||
def get_server_dir_layout(self) -> list[str]:
|
||||
"""Subdirectories to create inside servers/{id}/."""
|
||||
return ["server", "battleye", "mpmissions"]
|
||||
return ["server", "battleye", "mpmissions", "mods"]
|
||||
|
||||
_DIR_READMES: dict[str, str] = {
|
||||
"server": (
|
||||
"Arma 3 Server — Log Directory\n"
|
||||
"==============================\n\n"
|
||||
"Arma 3 writes RPT log files here (e.g. arma3server_2024-01-01_12-00-00.rpt).\n"
|
||||
"These are viewable in Languard's Logs tab.\n\n"
|
||||
"Do NOT place files here manually."
|
||||
),
|
||||
"battleye": (
|
||||
"BattlEye Anti-Cheat\n"
|
||||
"===================\n\n"
|
||||
"BattlEye configuration and GUID ban list files live here.\n"
|
||||
"Managed automatically by Arma 3 and Languard.\n\n"
|
||||
"Do NOT modify these files manually unless you know what you are doing."
|
||||
),
|
||||
"mpmissions": (
|
||||
"Mission Files\n"
|
||||
"=============\n\n"
|
||||
"Place Arma 3 mission files (.pbo) here to make them available for the server.\n"
|
||||
"Once placed here they will appear in Languard's Missions tab.\n\n"
|
||||
"Example: Wasteland_A3.Altis.pbo"
|
||||
),
|
||||
"mods": (
|
||||
"Mods\n"
|
||||
"====\n\n"
|
||||
"Place Arma 3 mod folders here. Each mod folder must start with '@'.\n\n"
|
||||
"Example layout:\n"
|
||||
" mods/\n"
|
||||
" @CBA_A3/\n"
|
||||
" addons/\n"
|
||||
" @ACE/\n"
|
||||
" addons/\n\n"
|
||||
"After placing mods here:\n"
|
||||
" 1. Go to the Mods tab in Languard.\n"
|
||||
" 2. Select the mods you want to enable.\n"
|
||||
" 3. Toggle 'Server-only' for mods that should use -serverMod= (e.g. task force radio server plugin).\n"
|
||||
" 4. Click 'Apply Selection'.\n"
|
||||
" 5. Restart the server for changes to take effect.\n\n"
|
||||
"Mods with a mod.cpp file will display their friendly name in the UI.\n"
|
||||
"Workshop mods with meta.cpp will show their Workshop ID."
|
||||
),
|
||||
}
|
||||
|
||||
def get_dir_readme(self, dir_name: str) -> str | None:
|
||||
return self._DIR_READMES.get(dir_name)
|
||||
@@ -24,8 +24,13 @@ def _ok(data):
|
||||
return {"success": True, "data": data, "error": None}
|
||||
|
||||
|
||||
class EnabledModEntry(BaseModel):
|
||||
name: str
|
||||
is_server_mod: bool = False
|
||||
|
||||
|
||||
class SetEnabledModsRequest(BaseModel):
|
||||
mods: list[str]
|
||||
mods: list[EnabledModEntry]
|
||||
|
||||
|
||||
def _get_mod_manager(server_id: int, game_type: str):
|
||||
@@ -52,12 +57,15 @@ def list_mods(
|
||||
config_repo = ConfigRepository(db)
|
||||
try:
|
||||
available = mgr.list_available_mods()
|
||||
enabled = set(mgr.get_enabled_mods(config_repo))
|
||||
enabled_mods = mgr.get_enabled_mods(config_repo)
|
||||
except AdapterError as exc:
|
||||
raise HTTPException(status_code=500, detail={"code": "ADAPTER_ERROR", "message": str(exc)})
|
||||
|
||||
enabled_map = {m["name"]: m for m in enabled_mods}
|
||||
for mod in available:
|
||||
mod["enabled"] = mod["name"] in enabled
|
||||
entry = enabled_map.get(mod["name"])
|
||||
mod["enabled"] = entry is not None
|
||||
mod["is_server_mod"] = entry["is_server_mod"] if entry else False
|
||||
|
||||
return _ok({
|
||||
"server_id": server_id,
|
||||
@@ -83,7 +91,7 @@ def set_enabled_mods(
|
||||
|
||||
config_repo = ConfigRepository(db)
|
||||
try:
|
||||
mgr.set_enabled_mods(body.mods, config_repo)
|
||||
mgr.set_enabled_mods([m.model_dump() for m in body.mods], config_repo)
|
||||
except AdapterError as exc:
|
||||
raise HTTPException(status_code=400, detail={"code": "ADAPTER_ERROR", "message": str(exc)})
|
||||
except ValueError as exc:
|
||||
@@ -97,5 +105,5 @@ def set_enabled_mods(
|
||||
|
||||
return _ok({
|
||||
"message": "Enabled mods updated. Restart the server for changes to take effect.",
|
||||
"enabled_mods": body.mods,
|
||||
"enabled_mods": [m.model_dump() for m in body.mods],
|
||||
})
|
||||
@@ -126,9 +126,10 @@ class ServerService:
|
||||
max_restarts=max_restarts,
|
||||
)
|
||||
|
||||
# Create directory layout
|
||||
# Create directory layout with per-directory README files
|
||||
layout = process_config.get_server_dir_layout()
|
||||
ensure_server_dirs(server_id, layout)
|
||||
readme_fn = getattr(process_config, "get_dir_readme", None)
|
||||
ensure_server_dirs(server_id, layout, readme_provider=readme_fn)
|
||||
|
||||
# Seed default config sections
|
||||
config_gen = adapter.get_config_generator()
|
||||
@@ -242,17 +243,17 @@ class ServerService:
|
||||
# Get mod args if adapter supports mods
|
||||
mod_args: list[str] = []
|
||||
if adapter.has_capability("mod_manager"):
|
||||
from sqlalchemy import text
|
||||
mods = self._db.execute(
|
||||
text("""
|
||||
SELECT m.folder_path, sm.is_server_mod, sm.sort_order
|
||||
FROM server_mods sm JOIN mods m ON m.id = sm.mod_id
|
||||
WHERE sm.server_id = :sid ORDER BY sm.sort_order
|
||||
"""),
|
||||
{"sid": server_id},
|
||||
).fetchall()
|
||||
mod_list = [dict(r._mapping) for r in mods]
|
||||
mod_args = adapter.get_mod_manager().build_mod_args(mod_list)
|
||||
mod_mgr = adapter.get_mod_manager(server_id)
|
||||
enabled_mods = mod_mgr.get_enabled_mods(self._config_repo)
|
||||
server_dir = get_server_dir(server_id)
|
||||
mod_list = [
|
||||
{
|
||||
"folder_path": str(server_dir / "mods" / m["name"]),
|
||||
"game_data": {"is_server_mod": m.get("is_server_mod", False)},
|
||||
}
|
||||
for m in enabled_mods
|
||||
]
|
||||
mod_args = mod_mgr.build_mod_args(mod_list)
|
||||
|
||||
# Write config files (atomic)
|
||||
server_dir = get_server_dir(server_id)
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
|
||||
def get_server_dir(server_id: int) -> Path:
|
||||
@@ -12,16 +13,27 @@ def get_server_dir(server_id: int) -> Path:
|
||||
return base / str(server_id)
|
||||
|
||||
|
||||
def ensure_server_dirs(server_id: int, layout: list[str] | None = None) -> None:
|
||||
def ensure_server_dirs(
|
||||
server_id: int,
|
||||
layout: list[str] | None = None,
|
||||
readme_provider: Callable[[str], str | None] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Create servers/{id}/ and any subdirectories from adapter layout.
|
||||
layout example: ["server", "battleye", "mpmissions"]
|
||||
If readme_provider is given, writes README.txt into each subdir (skips if file already exists).
|
||||
"""
|
||||
server_dir = get_server_dir(server_id)
|
||||
server_dir.mkdir(parents=True, exist_ok=True)
|
||||
if layout:
|
||||
for subdir in layout:
|
||||
(server_dir / subdir).mkdir(parents=True, exist_ok=True)
|
||||
subdir_path = server_dir / subdir
|
||||
subdir_path.mkdir(parents=True, exist_ok=True)
|
||||
if readme_provider:
|
||||
content = readme_provider(subdir)
|
||||
if content:
|
||||
readme_path = subdir_path / "README.txt"
|
||||
if not readme_path.exists():
|
||||
readme_path.write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
def safe_delete_file(path: Path) -> bool:
|
||||
|
||||
@@ -90,7 +90,24 @@ async def lifespan(app: FastAPI):
|
||||
except Exception as exc:
|
||||
logger.error("Failed to reattach threads for server %d: %s", server["id"], exc)
|
||||
|
||||
# 8. Seed default admin if no users exist
|
||||
# 8. Backfill server directory scaffold for existing servers (idempotent)
|
||||
from core.dal.server_repository import ServerRepository as _ServerRepo
|
||||
from core.utils.file_utils import ensure_server_dirs as _ensure_dirs
|
||||
from adapters.registry import GameAdapterRegistry as _Registry
|
||||
with engine.connect() as db:
|
||||
for server in _ServerRepo(db).get_all():
|
||||
try:
|
||||
_adapter = _Registry.get(server["game_type"])
|
||||
_pc = _adapter.get_process_config()
|
||||
_ensure_dirs(
|
||||
server["id"],
|
||||
_pc.get_server_dir_layout(),
|
||||
readme_provider=getattr(_pc, "get_dir_readme", None),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Dir scaffold failed for server %d: %s", server["id"], exc)
|
||||
|
||||
# 9. Seed default admin if no users exist
|
||||
from core.auth.service import AuthService
|
||||
with engine.connect() as db:
|
||||
svc = AuthService(db)
|
||||
@@ -104,7 +121,7 @@ async def lifespan(app: FastAPI):
|
||||
logger.warning(" Change this password immediately!")
|
||||
logger.warning("=" * 60)
|
||||
|
||||
# 9. Register and start APScheduler cleanup jobs
|
||||
# 10. Register and start APScheduler cleanup jobs
|
||||
from core.jobs.scheduler import start_scheduler, stop_scheduler
|
||||
from core.jobs.cleanup_jobs import register_cleanup_jobs
|
||||
register_cleanup_jobs()
|
||||
|
||||
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
0
backend/tests/adapters/__init__.py
Normal file
0
backend/tests/adapters/__init__.py
Normal file
0
backend/tests/adapters/arma3/__init__.py
Normal file
0
backend/tests/adapters/arma3/__init__.py
Normal 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"
|
||||
|
||||
Reference in New Issue
Block a user