feat: Phase 1 — Config UI Schema system with per-field widget routing
- Backend: add Arma3ConfigGenerator.get_ui_schema() with widget hints per field - Backend: add ServerService.get_config_schema() and GET /config/schema endpoint - Frontend: add FieldSchema/ConfigSchema types + useServerConfigSchema hook - Frontend: new TagListEditor component for dynamic string-list editing - Frontend: ConfigEditor now routes each field to correct widget (text/number/password/textarea/select/toggle/tag-list) - Frontend: password fields have show/hide toggle; toggles render as checkbox; tag-list uses TagListEditor - Tests: 8 new tests covering hook and TagListEditor; all 136 tests green
This commit is contained in:
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
| Phase | Status | Last session note |
|
| Phase | Status | Last session note |
|
||||||
|-------|--------|------------------|
|
|-------|--------|------------------|
|
||||||
| 1 — Config UI Schema | `[ ] not started` | |
|
| 1 — Config UI Schema | `[x] done` | TagListEditor, useServerConfigSchema, ConfigEditor widget routing, backend get_ui_schema + endpoint |
|
||||||
| 2 — Mission Rotation | `[ ] not started` | |
|
| 2 — Mission Rotation | `[ ] not started` | |
|
||||||
| 3 — Mod Display Names + Split Pane | `[ ] not started` | |
|
| 3 — Mod Display Names + Split Pane | `[ ] not started` | |
|
||||||
| 4 — Player Kick/Ban | `[ ] not started` | |
|
| 4 — Player Kick/Ban | `[ ] not started` | |
|
||||||
|
|||||||
@@ -382,6 +382,37 @@ class Arma3ConfigGenerator:
|
|||||||
args.extend(mod_args)
|
args.extend(mod_args)
|
||||||
return args
|
return args
|
||||||
|
|
||||||
|
def get_ui_schema(self) -> dict:
|
||||||
|
return {
|
||||||
|
"server": {
|
||||||
|
"hostname": {"widget": "text", "label": "Server Hostname"},
|
||||||
|
"max_players": {"widget": "number", "label": "Max Players", "min": 1, "max": 1000},
|
||||||
|
"password": {"widget": "password", "label": "Player Password"},
|
||||||
|
"password_admin": {"widget": "password", "label": "Admin Password"},
|
||||||
|
"motd_lines": {"widget": "textarea", "label": "Message of the Day (one line per row)"},
|
||||||
|
"forced_difficulty": {"widget": "select", "label": "Difficulty Preset",
|
||||||
|
"options": ["Recruit", "Regular", "Veteran", "Custom"]},
|
||||||
|
"battleye": {"widget": "toggle", "label": "BattleEye Anti-Cheat"},
|
||||||
|
"disable_von": {"widget": "toggle", "label": "Disable Voice over Net (VoN)"},
|
||||||
|
"verify_signatures": {"widget": "number", "label": "Verify Signatures (0=off, 1=on, 2=strict)",
|
||||||
|
"min": 0, "max": 2},
|
||||||
|
"persistent": {"widget": "toggle", "label": "Persistent (keep running when empty)"},
|
||||||
|
"admin_uids": {"widget": "tag-list", "label": "Admin Steam UIDs",
|
||||||
|
"placeholder": "76561198000000000"},
|
||||||
|
},
|
||||||
|
"basic": {
|
||||||
|
"max_custom_file_size": {"widget": "number", "label": "Max Custom File Size (bytes)"},
|
||||||
|
},
|
||||||
|
"launch": {
|
||||||
|
"extra_params": {"widget": "tag-list", "label": "Additional Startup Parameters",
|
||||||
|
"placeholder": "-limitFPS=100"},
|
||||||
|
},
|
||||||
|
"rcon": {
|
||||||
|
"rcon_password": {"widget": "password", "label": "RCon Password"},
|
||||||
|
"max_ping": {"widget": "number", "label": "RCon Port"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
def preview_config(
|
def preview_config(
|
||||||
self,
|
self,
|
||||||
server_id: int,
|
server_id: int,
|
||||||
|
|||||||
@@ -134,6 +134,15 @@ def get_config(
|
|||||||
return _ok(ServerService(db).get_config(server_id))
|
return _ok(ServerService(db).get_config(server_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{server_id}/config/schema")
|
||||||
|
def get_config_schema(
|
||||||
|
server_id: int,
|
||||||
|
db: Annotated[Connection, Depends(get_db)] = None,
|
||||||
|
_user: Annotated[dict, Depends(get_current_user)] = None,
|
||||||
|
):
|
||||||
|
return _ok(ServerService(db).get_config_schema(server_id))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{server_id}/config/preview")
|
@router.get("/{server_id}/config/preview")
|
||||||
def get_config_preview(
|
def get_config_preview(
|
||||||
server_id: int,
|
server_id: int,
|
||||||
|
|||||||
@@ -396,6 +396,14 @@ class ServerService:
|
|||||||
data[field] = "***"
|
data[field] = "***"
|
||||||
return sections
|
return sections
|
||||||
|
|
||||||
|
def get_config_schema(self, server_id: int) -> dict:
|
||||||
|
server = self.get_server(server_id)
|
||||||
|
adapter = GameAdapterRegistry.get(server["game_type"])
|
||||||
|
config_gen = adapter.get_config_generator()
|
||||||
|
if hasattr(config_gen, "get_ui_schema"):
|
||||||
|
return config_gen.get_ui_schema()
|
||||||
|
return {}
|
||||||
|
|
||||||
def get_config_section(self, server_id: int, section: str) -> dict:
|
def get_config_section(self, server_id: int, section: str) -> dict:
|
||||||
server = self.get_server(server_id)
|
server = self.get_server(server_id)
|
||||||
adapter = GameAdapterRegistry.get(server["game_type"])
|
adapter = GameAdapterRegistry.get(server["game_type"])
|
||||||
|
|||||||
103
frontend/src/__tests__/ConfigSchemaPhase1.test.tsx
Normal file
103
frontend/src/__tests__/ConfigSchemaPhase1.test.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { useServerConfigSchema } from "@/hooks/useServerDetail";
|
||||||
|
import { TagListEditor } from "@/components/ui/TagListEditor";
|
||||||
|
|
||||||
|
vi.mock("@/lib/api", () => ({
|
||||||
|
apiClient: {
|
||||||
|
get: vi.fn(),
|
||||||
|
post: vi.fn(),
|
||||||
|
put: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { apiClient } from "@/lib/api";
|
||||||
|
|
||||||
|
function createWrapper() {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false } },
|
||||||
|
});
|
||||||
|
return function Wrapper({ children }: { children: ReactNode }) {
|
||||||
|
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOCK_SCHEMA = {
|
||||||
|
server: {
|
||||||
|
hostname: { widget: "text", label: "Server Hostname" },
|
||||||
|
max_players: { widget: "number", label: "Max Players", min: 1, max: 1000 },
|
||||||
|
password: { widget: "password", label: "Player Password" },
|
||||||
|
forced_difficulty: {
|
||||||
|
widget: "select",
|
||||||
|
label: "Difficulty Preset",
|
||||||
|
options: ["Recruit", "Regular", "Veteran", "Custom"],
|
||||||
|
},
|
||||||
|
battleye: { widget: "toggle", label: "BattleEye Anti-Cheat" },
|
||||||
|
motd_lines: { widget: "textarea", label: "Message of the Day (one line per row)" },
|
||||||
|
admin_uids: { widget: "tag-list", label: "Admin Steam UIDs", placeholder: "76561198000000000" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("useServerConfigSchema", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(apiClient.get).mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fetches schema from /api/servers/:id/config/schema", async () => {
|
||||||
|
vi.mocked(apiClient.get).mockResolvedValue({ data: { success: true, data: MOCK_SCHEMA } });
|
||||||
|
const { result } = renderHook(() => useServerConfigSchema(1), { wrapper: createWrapper() });
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
expect(result.current.data).toEqual(MOCK_SCHEMA);
|
||||||
|
expect(apiClient.get).toHaveBeenCalledWith("/api/servers/1/config/schema");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is disabled when serverId is 0", () => {
|
||||||
|
const { result } = renderHook(() => useServerConfigSchema(0), { wrapper: createWrapper() });
|
||||||
|
expect(result.current.fetchStatus).toBe("idle");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TagListEditor", () => {
|
||||||
|
it("renders existing items", () => {
|
||||||
|
render(<TagListEditor value={["uid1", "uid2"]} onChange={() => {}} />);
|
||||||
|
expect(screen.getByDisplayValue("uid1")).toBeInTheDocument();
|
||||||
|
expect(screen.getByDisplayValue("uid2")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onChange with new item when Add is clicked", () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(<TagListEditor value={["uid1"]} onChange={onChange} />);
|
||||||
|
fireEvent.click(screen.getByText("+ Add"));
|
||||||
|
expect(onChange).toHaveBeenCalledWith(["uid1", ""]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onChange without item when remove is clicked", () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(<TagListEditor value={["uid1", "uid2"]} onChange={onChange} />);
|
||||||
|
const removeButtons = screen.getAllByText("✕");
|
||||||
|
fireEvent.click(removeButtons[0]);
|
||||||
|
expect(onChange).toHaveBeenCalledWith(["uid2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onChange with updated value on input change", () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(<TagListEditor value={["uid1"]} onChange={onChange} />);
|
||||||
|
fireEvent.change(screen.getByDisplayValue("uid1"), { target: { value: "uid_new" } });
|
||||||
|
expect(onChange).toHaveBeenCalledWith(["uid_new"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders placeholder text", () => {
|
||||||
|
render(<TagListEditor value={[""]} onChange={() => {}} placeholder="Enter UID" />);
|
||||||
|
expect(screen.getByPlaceholderText("Enter UID")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables inputs when disabled prop is true", () => {
|
||||||
|
render(<TagListEditor value={["uid1"]} onChange={() => {}} disabled />);
|
||||||
|
expect(screen.getByDisplayValue("uid1")).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
import { useServerConfig, useServerConfigSection, useUpdateConfigSection } from "@/hooks/useServerDetail";
|
import { useServerConfig, useServerConfigSection, useUpdateConfigSection, useServerConfigSchema } from "@/hooks/useServerDetail";
|
||||||
|
import type { FieldSchema } from "@/hooks/useServerDetail";
|
||||||
|
import { TagListEditor } from "@/components/ui/TagListEditor";
|
||||||
import { useAuthStore } from "@/store/auth.store";
|
import { useAuthStore } from "@/store/auth.store";
|
||||||
import { useUIStore } from "@/store/ui.store";
|
import { useUIStore } from "@/store/ui.store";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
@@ -74,8 +76,10 @@ function ConfigSectionForm({
|
|||||||
addNotification: (n: { type: "success" | "error" | "info" | "warning"; message: string }) => void;
|
addNotification: (n: { type: "success" | "error" | "info" | "warning"; message: string }) => void;
|
||||||
}) {
|
}) {
|
||||||
const { data: sectionData, isLoading } = useServerConfigSection(serverId, section);
|
const { data: sectionData, isLoading } = useServerConfigSection(serverId, section);
|
||||||
|
const { data: schema } = useServerConfigSchema(serverId);
|
||||||
const updateSection = useUpdateConfigSection(serverId, section);
|
const updateSection = useUpdateConfigSection(serverId, section);
|
||||||
const [editValues, setEditValues] = useState<Record<string, unknown> | null>(null);
|
const [editValues, setEditValues] = useState<Record<string, unknown> | null>(null);
|
||||||
|
const [showPassword, setShowPassword] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <div className="text-text-muted text-sm">Loading section...</div>;
|
return <div className="text-text-muted text-sm">Loading section...</div>;
|
||||||
@@ -89,13 +93,16 @@ function ConfigSectionForm({
|
|||||||
const meta = sectionData._meta;
|
const meta = sectionData._meta;
|
||||||
const displayValues = editValues ?? Object.fromEntries(fields);
|
const displayValues = editValues ?? Object.fromEntries(fields);
|
||||||
const isEditing = editValues !== null;
|
const isEditing = editValues !== null;
|
||||||
|
const sectionSchema = schema?.[section] ?? {};
|
||||||
|
|
||||||
const handleEdit = () => {
|
const handleEdit = () => {
|
||||||
setEditValues(Object.fromEntries(fields));
|
setEditValues(Object.fromEntries(fields));
|
||||||
|
setShowPassword({});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
setEditValues(null);
|
setEditValues(null);
|
||||||
|
setShowPassword({});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
@@ -107,6 +114,7 @@ function ConfigSectionForm({
|
|||||||
});
|
});
|
||||||
addNotification({ type: "success", message: `${section} config updated` });
|
addNotification({ type: "success", message: `${section} config updated` });
|
||||||
setEditValues(null);
|
setEditValues(null);
|
||||||
|
setShowPassword({});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error("ConfigEditor", "Failed to update config section %s: %s", section, err);
|
logger.error("ConfigEditor", "Failed to update config section %s: %s", section, err);
|
||||||
if (err instanceof Error && "response" in err) {
|
if (err instanceof Error && "response" in err) {
|
||||||
@@ -127,6 +135,10 @@ function ConfigSectionForm({
|
|||||||
setEditValues({ ...editValues, [key]: value });
|
setEditValues({ ...editValues, [key]: value });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleShowPassword = (key: string) => {
|
||||||
|
setShowPassword((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
@@ -140,23 +152,33 @@ function ConfigSectionForm({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{fields.map(([key, value]) => (
|
{fields.map(([key]) => {
|
||||||
<div key={key} className="flex items-center gap-3">
|
const fieldSchema: FieldSchema | undefined = sectionSchema[key];
|
||||||
<label className="text-text-secondary text-sm w-40 shrink-0">{formatLabel(key)}</label>
|
const widget = fieldSchema?.widget ?? (SENSITIVE_KEYS.has(key) ? "password" : "text");
|
||||||
{isEditing && !SENSITIVE_KEYS.has(key) ? (
|
const label = fieldSchema?.label ?? formatLabel(key);
|
||||||
<input
|
const rawValue = displayValues[key];
|
||||||
className="neu-input flex-1 text-sm"
|
|
||||||
value={String(editValues?.[key] ?? "")}
|
return (
|
||||||
onChange={(e) => handleChange(key, e.target.value)}
|
<div key={key} className={clsx("flex gap-3", widget === "textarea" || widget === "tag-list" ? "flex-col" : "items-center")}>
|
||||||
type={typeof value === "number" ? "number" : "text"}
|
<label className="text-text-secondary text-sm w-40 shrink-0">{label}</label>
|
||||||
|
{isEditing ? (
|
||||||
|
<FieldWidget
|
||||||
|
fieldKey={key}
|
||||||
|
widget={widget}
|
||||||
|
fieldSchema={fieldSchema}
|
||||||
|
value={rawValue}
|
||||||
|
showPassword={showPassword[key] ?? false}
|
||||||
|
onTogglePassword={() => toggleShowPassword(key)}
|
||||||
|
onChange={(v) => handleChange(key, v)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-text-primary font-mono text-sm flex-1 bg-surface-recessed rounded-lg px-3 py-2">
|
<span className="text-text-primary font-mono text-sm flex-1 bg-surface-recessed rounded-lg px-3 py-2">
|
||||||
{SENSITIVE_KEYS.has(key) ? "••••••••" : String(value ?? "--")}
|
{widget === "password" ? "••••••••" : formatDisplayValue(rawValue)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{isEditing && (
|
{isEditing && (
|
||||||
<div className="flex gap-2 mt-4">
|
<div className="flex gap-2 mt-4">
|
||||||
@@ -172,6 +194,115 @@ function ConfigSectionForm({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function FieldWidget({
|
||||||
|
fieldKey,
|
||||||
|
widget,
|
||||||
|
fieldSchema,
|
||||||
|
value,
|
||||||
|
showPassword,
|
||||||
|
onTogglePassword,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
fieldKey: string;
|
||||||
|
widget: string;
|
||||||
|
fieldSchema: FieldSchema | undefined;
|
||||||
|
value: unknown;
|
||||||
|
showPassword: boolean;
|
||||||
|
onTogglePassword: () => void;
|
||||||
|
onChange: (v: unknown) => void;
|
||||||
|
}) {
|
||||||
|
switch (widget) {
|
||||||
|
case "textarea":
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className="neu-input flex-1 text-sm"
|
||||||
|
rows={4}
|
||||||
|
value={Array.isArray(value) ? (value as string[]).join("\n") : String(value ?? "")}
|
||||||
|
onChange={(e) => onChange(e.target.value.split("\n"))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "select":
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
className="neu-input flex-1 text-sm"
|
||||||
|
value={String(value ?? "")}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
>
|
||||||
|
{(fieldSchema?.options ?? []).map((opt) => (
|
||||||
|
<option key={opt} value={opt}>{opt}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "toggle":
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="w-5 h-5 accent-accent"
|
||||||
|
checked={value === true || value === 1 || value === "true"}
|
||||||
|
onChange={(e) => onChange(e.target.checked ? "true" : "false")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "tag-list":
|
||||||
|
return (
|
||||||
|
<TagListEditor
|
||||||
|
value={Array.isArray(value) ? (value as string[]) : []}
|
||||||
|
onChange={onChange}
|
||||||
|
placeholder={fieldSchema?.placeholder}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "number":
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="neu-input flex-1 text-sm"
|
||||||
|
value={String(value ?? "")}
|
||||||
|
min={fieldSchema?.min}
|
||||||
|
max={fieldSchema?.max}
|
||||||
|
onChange={(e) => onChange(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "password":
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2 flex-1">
|
||||||
|
<input
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
className="neu-input flex-1 text-sm"
|
||||||
|
value={String(value ?? "")}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onTogglePassword}
|
||||||
|
className="btn-ghost text-sm px-2"
|
||||||
|
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||||
|
>
|
||||||
|
{showPassword ? "Hide" : "Show"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="neu-input flex-1 text-sm"
|
||||||
|
value={String(value ?? "")}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDisplayValue(value: unknown): string {
|
||||||
|
if (Array.isArray(value)) return value.join(", ") || "--";
|
||||||
|
return String(value ?? "--");
|
||||||
|
}
|
||||||
|
|
||||||
function formatLabel(key: string): string {
|
function formatLabel(key: string): string {
|
||||||
return key
|
return key
|
||||||
.replace(/_/g, " ")
|
.replace(/_/g, " ")
|
||||||
|
|||||||
40
frontend/src/components/ui/TagListEditor.tsx
Normal file
40
frontend/src/components/ui/TagListEditor.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
interface TagListEditorProps {
|
||||||
|
value: string[];
|
||||||
|
onChange: (v: string[]) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TagListEditor({ value, onChange, placeholder, disabled }: TagListEditorProps) {
|
||||||
|
const update = (idx: number, val: string) =>
|
||||||
|
onChange(value.map((v, i) => (i === idx ? val : v)));
|
||||||
|
const remove = (idx: number) => onChange(value.filter((_, i) => i !== idx));
|
||||||
|
const add = () => onChange([...value, ""]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{value.map((item, idx) => (
|
||||||
|
<div key={idx} className="flex gap-2">
|
||||||
|
<input
|
||||||
|
className="flex-1 neu-input"
|
||||||
|
value={item}
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => update(idx, e.target.value)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => remove(idx)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="btn-ghost text-status-crashed px-2"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button type="button" onClick={add} disabled={disabled} className="btn-ghost text-sm">
|
||||||
|
+ Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -115,6 +115,21 @@ export interface Mod {
|
|||||||
path: string;
|
path: string;
|
||||||
size_bytes: number;
|
size_bytes: number;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
display_name: string | null;
|
||||||
|
workshop_id: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FieldSchema {
|
||||||
|
widget: "text" | "number" | "password" | "textarea" | "select" | "toggle" | "tag-list";
|
||||||
|
label?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
options?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigSchema {
|
||||||
|
[section: string]: { [field: string]: FieldSchema };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModsResponse {
|
export interface ModsResponse {
|
||||||
@@ -125,6 +140,19 @@ export interface ModsResponse {
|
|||||||
|
|
||||||
// ── Query Hooks ────────────────────────────────────────────────────────
|
// ── Query Hooks ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useServerConfigSchema(serverId: number) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["servers", serverId, "config", "schema"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiClient.get<{ success: boolean; data: ConfigSchema }>(
|
||||||
|
`/api/servers/${serverId}/config/schema`,
|
||||||
|
);
|
||||||
|
return res.data.data;
|
||||||
|
},
|
||||||
|
enabled: serverId > 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useServerConfig(serverId: number) {
|
export function useServerConfig(serverId: number) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["servers", serverId, "config"],
|
queryKey: ["servers", serverId, "config"],
|
||||||
|
|||||||
Reference in New Issue
Block a user