feat: per-mission params, default config values, and mods bug docs
- Add per-mission params to rotation (MissionRotationItem.params); falls back to default_mission_params, then omits entirely if both empty - Add key-value widget to ConfigEditor for default_mission_params field - Add MissionParamsEditor component for editing param key/value/type rows - Bump config schema to 1.1.0 with migration from 1.0.0 - Add normalize_section() to Protocol and ArmaConfigGenerator for read-time backfill of old stored rows - Set Arma3 BasicConfig and ProfileConfig defaults from basic.cfg / Administrator.Arma3Profile - Document 3 known Mods tab bugs in CLAUDE.md for next-session fix
This commit is contained in:
@@ -4,6 +4,7 @@ import clsx from "clsx";
|
||||
import { useServerConfig, useServerConfigSection, useUpdateConfigSection, useServerConfigSchema } from "@/hooks/useServerDetail";
|
||||
import type { FieldSchema } from "@/hooks/useServerDetail";
|
||||
import { TagListEditor } from "@/components/ui/TagListEditor";
|
||||
import { MissionParamsEditor } from "@/components/servers/MissionParamsEditor";
|
||||
import { useAuthStore } from "@/store/auth.store";
|
||||
import { useUIStore } from "@/store/ui.store";
|
||||
import { logger } from "@/lib/logger";
|
||||
@@ -183,7 +184,7 @@ function ConfigSectionForm({
|
||||
const rawValue = displayValues[key];
|
||||
|
||||
return (
|
||||
<div key={key} className={clsx("flex gap-3", widget === "textarea" || widget === "tag-list" ? "flex-col" : "items-center")}>
|
||||
<div key={key} className={clsx("flex gap-3", widget === "textarea" || widget === "tag-list" || widget === "key-value" ? "flex-col" : "items-center")}>
|
||||
<label className="text-text-secondary text-sm w-40 shrink-0">{label}</label>
|
||||
{isEditing ? (
|
||||
<FieldWidget
|
||||
@@ -203,6 +204,14 @@ function ConfigSectionForm({
|
||||
<span className="text-text-primary text-sm flex-1 bg-surface-recessed rounded-lg px-3 py-2">
|
||||
{formatSelectDisplay(rawValue, fieldSchema)}
|
||||
</span>
|
||||
) : widget === "key-value" ? (
|
||||
<div className="bg-surface-recessed rounded-lg px-3 py-2">
|
||||
<MissionParamsEditor
|
||||
value={rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? (rawValue as Record<string, number | string | boolean>) : {}}
|
||||
onChange={() => {}}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-text-primary font-mono text-sm flex-1 bg-surface-recessed rounded-lg px-3 py-2">
|
||||
{widget === "password" ? "••••••••" : formatDisplayValue(rawValue)}
|
||||
@@ -301,6 +310,14 @@ function FieldWidget({
|
||||
/>
|
||||
);
|
||||
|
||||
case "key-value":
|
||||
return (
|
||||
<MissionParamsEditor
|
||||
value={value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, number | string | boolean>) : {}}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
|
||||
case "number":
|
||||
return (
|
||||
<input
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Upload, Trash2, Plus, X, Save } from "lucide-react";
|
||||
import { Fragment, useState, useRef, useEffect } from "react";
|
||||
import { Upload, Trash2, Plus, X, Save, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { MissionParamsEditor } from "./MissionParamsEditor";
|
||||
|
||||
import {
|
||||
useServerMissions,
|
||||
@@ -9,7 +10,7 @@ import {
|
||||
useDeleteMission,
|
||||
useServerConfigSection,
|
||||
} from "@/hooks/useServerDetail";
|
||||
import type { MissionRotationEntry } from "@/hooks/useServerDetail";
|
||||
import type { MissionRotationEntry, MissionParamValue } from "@/hooks/useServerDetail";
|
||||
import { useAuthStore } from "@/store/auth.store";
|
||||
import { useUIStore } from "@/store/ui.store";
|
||||
import { logger } from "@/lib/logger";
|
||||
@@ -39,6 +40,7 @@ export function MissionList({ serverId }: MissionListProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [rotation, setRotation] = useState<MissionRotationEntry[]>([]);
|
||||
const [uploadProgress, setUploadProgress] = useState<UploadProgress[]>([]);
|
||||
const [expandedParamsIdx, setExpandedParamsIdx] = useState<number | null>(null);
|
||||
|
||||
// Sync rotation from query on load
|
||||
useEffect(() => {
|
||||
@@ -81,7 +83,7 @@ export function MissionList({ serverId }: MissionListProps) {
|
||||
|
||||
const addToRotation = (missionName: string) => {
|
||||
if (rotation.some((r) => r.name === missionName)) return;
|
||||
setRotation([...rotation, { name: missionName, difficulty: "" }]);
|
||||
setRotation([...rotation, { name: missionName, difficulty: "", params: {} }]);
|
||||
};
|
||||
|
||||
const removeFromRotation = (idx: number) => {
|
||||
@@ -92,6 +94,10 @@ export function MissionList({ serverId }: MissionListProps) {
|
||||
setRotation(rotation.map((r, i) => (i === idx ? { ...r, difficulty } : r)));
|
||||
};
|
||||
|
||||
const updateParams = (idx: number, params: Record<string, MissionParamValue>) => {
|
||||
setRotation(rotation.map((r, i) => (i === idx ? { ...r, params } : r)));
|
||||
};
|
||||
|
||||
const handleSaveRotation = async () => {
|
||||
try {
|
||||
await updateRotation.mutateAsync({ missions: rotation, config_version: configVersion });
|
||||
@@ -249,7 +255,7 @@ export function MissionList({ serverId }: MissionListProps) {
|
||||
)}
|
||||
</div>
|
||||
<p className="text-text-muted text-xs mb-3">
|
||||
The server cycles through these missions in order. Set per-mission difficulty, then click <strong className="text-text-secondary">Save Rotation</strong> to apply.
|
||||
The server cycles through these missions in order. Set per-mission difficulty and optional params, then click <strong className="text-text-secondary">Save Rotation</strong> to apply.
|
||||
</p>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
@@ -260,6 +266,7 @@ export function MissionList({ serverId }: MissionListProps) {
|
||||
<th className="text-left text-text-muted font-medium px-3 py-2">Mission Name</th>
|
||||
<th className="text-left text-text-muted font-medium px-3 py-2">Terrain</th>
|
||||
<th className="text-left text-text-muted font-medium px-3 py-2">Difficulty</th>
|
||||
<th className="text-left text-text-muted font-medium px-3 py-2">Params</th>
|
||||
{isAdmin && (
|
||||
<th className="text-right text-text-muted font-medium px-3 py-2">Remove</th>
|
||||
)}
|
||||
@@ -268,58 +275,93 @@ export function MissionList({ serverId }: MissionListProps) {
|
||||
<tbody>
|
||||
{rotation.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={isAdmin ? 5 : 4} className="text-text-muted text-center py-6">
|
||||
<td colSpan={isAdmin ? 6 : 5} className="text-text-muted text-center py-6">
|
||||
No missions in rotation. Add from Available above.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rotation.map((entry, idx) => {
|
||||
const missionFile = missions.find((m) => m.name === entry.name);
|
||||
const paramCount = Object.keys(entry.params ?? {}).length;
|
||||
const isExpanded = expandedParamsIdx === idx;
|
||||
return (
|
||||
<tr
|
||||
key={`${entry.name}-${idx}`}
|
||||
className="border-b border-surface-overlay/50 hover:bg-surface-overlay/30"
|
||||
>
|
||||
<td className="text-text-muted font-mono text-xs px-3 py-2">{idx + 1}</td>
|
||||
<td className="text-text-primary px-3 py-2">{entry.name}</td>
|
||||
<td className="px-3 py-2">
|
||||
{missionFile?.terrain ? (
|
||||
<span className="bg-accent/20 text-accent text-xs px-2 py-0.5 rounded">
|
||||
{missionFile.terrain}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-text-muted text-xs">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{isAdmin ? (
|
||||
<select
|
||||
className="neu-input text-sm py-1"
|
||||
value={entry.difficulty}
|
||||
onChange={(e) => updateDifficulty(idx, e.target.value)}
|
||||
>
|
||||
{DIFFICULTY_OPTIONS.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt || "Default"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<span className="text-text-secondary">{entry.difficulty || "Default"}</span>
|
||||
)}
|
||||
</td>
|
||||
{isAdmin && (
|
||||
<td className="text-right px-3 py-2">
|
||||
<Fragment key={`${entry.name}-${idx}`}>
|
||||
<tr
|
||||
className="border-b border-surface-overlay/50 hover:bg-surface-overlay/30"
|
||||
>
|
||||
<td className="text-text-muted font-mono text-xs px-3 py-2">{idx + 1}</td>
|
||||
<td className="text-text-primary px-3 py-2">{entry.name}</td>
|
||||
<td className="px-3 py-2">
|
||||
{missionFile?.terrain ? (
|
||||
<span className="bg-accent/20 text-accent text-xs px-2 py-0.5 rounded">
|
||||
{missionFile.terrain}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-text-muted text-xs">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{isAdmin ? (
|
||||
<select
|
||||
className="neu-input text-sm py-1"
|
||||
value={entry.difficulty}
|
||||
onChange={(e) => updateDifficulty(idx, e.target.value)}
|
||||
>
|
||||
{DIFFICULTY_OPTIONS.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt || "Default"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<span className="text-text-secondary">{entry.difficulty || "Default"}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<button
|
||||
onClick={() => removeFromRotation(idx)}
|
||||
className="btn-ghost text-status-crashed"
|
||||
aria-label={`Remove ${entry.name} from rotation`}
|
||||
onClick={() => setExpandedParamsIdx(isExpanded ? null : idx)}
|
||||
className="btn-ghost text-xs flex items-center gap-1"
|
||||
title={isExpanded ? "Hide parameters" : "Edit parameters"}
|
||||
>
|
||||
<X size={14} />
|
||||
{isExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
{paramCount > 0 ? (
|
||||
<span className="bg-accent/20 text-accent px-1.5 py-0.5 rounded text-xs">
|
||||
{paramCount} param{paramCount !== 1 ? "s" : ""}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-text-muted">Default</span>
|
||||
)}
|
||||
</button>
|
||||
</td>
|
||||
{isAdmin && (
|
||||
<td className="text-right px-3 py-2">
|
||||
<button
|
||||
onClick={() => removeFromRotation(idx)}
|
||||
className="btn-ghost text-status-crashed"
|
||||
aria-label={`Remove ${entry.name} from rotation`}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
{isExpanded && (
|
||||
<tr className="bg-surface-overlay/10 border-b border-surface-overlay/50">
|
||||
<td colSpan={isAdmin ? 6 : 5} className="px-6 py-3">
|
||||
<div className="space-y-2">
|
||||
<p className="text-text-muted text-xs">
|
||||
Per-mission parameters override the server default. Leave empty to use defaults from the Config tab.
|
||||
</p>
|
||||
<MissionParamsEditor
|
||||
value={entry.params ?? {}}
|
||||
onChange={(next) => updateParams(idx, next)}
|
||||
readOnly={!isAdmin}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tr>
|
||||
</Fragment>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
144
frontend/src/components/servers/MissionParamsEditor.tsx
Normal file
144
frontend/src/components/servers/MissionParamsEditor.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { Plus, X } from "lucide-react";
|
||||
import type { MissionParamValue } from "@/hooks/useServerDetail";
|
||||
|
||||
type ParamsRecord = Record<string, MissionParamValue>;
|
||||
type ParamType = "number" | "boolean" | "string";
|
||||
|
||||
interface MissionParamsEditorProps {
|
||||
value: ParamsRecord;
|
||||
onChange: (next: ParamsRecord) => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export function MissionParamsEditor({ value, onChange, readOnly = false }: MissionParamsEditorProps) {
|
||||
const entries = Object.entries(value);
|
||||
|
||||
const getType = (val: MissionParamValue): ParamType => {
|
||||
if (typeof val === "boolean") return "boolean";
|
||||
if (typeof val === "number") return "number";
|
||||
return "string";
|
||||
};
|
||||
|
||||
const updateKey = (oldKey: string, newKey: string) => {
|
||||
if (oldKey === newKey || !newKey.trim()) return;
|
||||
const next: ParamsRecord = {};
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
next[k === oldKey ? newKey.trim() : k] = v;
|
||||
}
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const updateValue = (key: string, val: MissionParamValue) => {
|
||||
onChange({ ...value, [key]: val });
|
||||
};
|
||||
|
||||
const changeType = (key: string, type: ParamType) => {
|
||||
const defaultByType: Record<ParamType, MissionParamValue> = {
|
||||
number: 0,
|
||||
boolean: false,
|
||||
string: "",
|
||||
};
|
||||
updateValue(key, defaultByType[type]);
|
||||
};
|
||||
|
||||
const removeEntry = (key: string) => {
|
||||
const next = { ...value };
|
||||
delete next[key];
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const addEntry = () => {
|
||||
let base = "param";
|
||||
let i = 1;
|
||||
while (value[base] !== undefined) base = `param${i++}`;
|
||||
onChange({ ...value, [base]: 0 });
|
||||
};
|
||||
|
||||
if (entries.length === 0 && readOnly) {
|
||||
return <span className="text-text-muted text-sm italic">No parameters set</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{entries.map(([key, val]) => {
|
||||
const t = getType(val);
|
||||
return (
|
||||
<div key={key} className="flex gap-2 items-center">
|
||||
{readOnly ? (
|
||||
<span className="font-mono text-sm text-text-primary w-40 shrink-0">{key}</span>
|
||||
) : (
|
||||
<input
|
||||
className="neu-input text-sm font-mono"
|
||||
style={{ width: "10rem" }}
|
||||
value={key}
|
||||
onChange={(e) => updateKey(key, e.target.value)}
|
||||
onBlur={(e) => updateKey(key, e.target.value)}
|
||||
placeholder="param name"
|
||||
/>
|
||||
)}
|
||||
|
||||
{!readOnly && (
|
||||
<select
|
||||
className="neu-input text-sm"
|
||||
style={{ width: "6.5rem" }}
|
||||
value={t}
|
||||
onChange={(e) => changeType(key, e.target.value as ParamType)}
|
||||
>
|
||||
<option value="number">Number</option>
|
||||
<option value="string">String</option>
|
||||
<option value="boolean">Bool</option>
|
||||
</select>
|
||||
)}
|
||||
|
||||
{readOnly ? (
|
||||
<span className="font-mono text-sm text-text-secondary flex-1">{String(val)}</span>
|
||||
) : t === "boolean" ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-5 h-5 accent-accent"
|
||||
checked={Boolean(val)}
|
||||
onChange={(e) => updateValue(key, e.target.checked)}
|
||||
/>
|
||||
) : t === "number" ? (
|
||||
<input
|
||||
type="number"
|
||||
className="neu-input text-sm flex-1"
|
||||
value={String(val)}
|
||||
onChange={(e) => updateValue(key, Number(e.target.value))}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
className="neu-input text-sm flex-1"
|
||||
value={String(val)}
|
||||
onChange={(e) => updateValue(key, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeEntry(key)}
|
||||
className="btn-ghost text-status-crashed p-1"
|
||||
aria-label={`Remove ${key} parameter`}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={addEntry}
|
||||
className="btn-ghost text-sm flex items-center gap-1 mt-1"
|
||||
>
|
||||
<Plus size={12} />
|
||||
Add Parameter
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -105,9 +105,12 @@ export interface Mission {
|
||||
terrain: string;
|
||||
}
|
||||
|
||||
export type MissionParamValue = number | string | boolean;
|
||||
|
||||
export interface MissionRotationEntry {
|
||||
name: string;
|
||||
difficulty: string;
|
||||
params: Record<string, MissionParamValue>;
|
||||
}
|
||||
|
||||
export interface MissionsResponse {
|
||||
@@ -126,7 +129,7 @@ export interface Mod {
|
||||
}
|
||||
|
||||
export interface FieldSchema {
|
||||
widget: "text" | "number" | "password" | "textarea" | "select" | "toggle" | "tag-list" | "hidden";
|
||||
widget: "text" | "number" | "password" | "textarea" | "select" | "toggle" | "tag-list" | "hidden" | "key-value";
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
min?: number;
|
||||
|
||||
Reference in New Issue
Block a user