refactor(frontend): decompose ConfigPage into dedicated config components

- Extract tab components: JailsTab, ActionsTab, FiltersTab, JailFilesTab,
  GlobalTab, ServerTab, ConfFilesTab, RegexTesterTab, MapTab, ExportTab
- Add form components: JailFileForm, ActionForm, FilterForm
- Add AutoSaveIndicator, RegexList, configStyles, and barrel index
- ConfigPage now composes these components; greatly reduces file size
- Add tests: ConfigPage.test.tsx, useAutoSave.test.ts
This commit is contained in:
2026-03-13 13:48:09 +01:00
parent a0e8566ff8
commit 9b73f6719d
23 changed files with 4275 additions and 1828 deletions

View File

@@ -0,0 +1,212 @@
/**
* MapTab — world map color threshold configuration editor.
*
* Allows the user to set the low / medium / high ban-count thresholds
* that drive country fill colors on the World Map page.
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Field,
Input,
MessageBar,
MessageBarBody,
Skeleton,
SkeletonItem,
Text,
tokens,
} from "@fluentui/react-components";
import { ApiError } from "../../api/client";
import {
fetchMapColorThresholds,
updateMapColorThresholds,
} from "../../api/config";
import type { MapColorThresholdsResponse, MapColorThresholdsUpdate } from "../../types/config";
import { useAutoSave } from "../../hooks/useAutoSave";
import { AutoSaveIndicator } from "./AutoSaveIndicator";
import { useConfigStyles } from "./configStyles";
// ---------------------------------------------------------------------------
// Inner form — only mounted after data is loaded.
// ---------------------------------------------------------------------------
interface MapFormProps {
initial: MapColorThresholdsResponse;
}
function MapForm({ initial }: MapFormProps): React.JSX.Element {
const styles = useConfigStyles();
const [thresholdHigh, setThresholdHigh] = useState(String(initial.threshold_high));
const [thresholdMedium, setThresholdMedium] = useState(String(initial.threshold_medium));
const [thresholdLow, setThresholdLow] = useState(String(initial.threshold_low));
const high = Number(thresholdHigh);
const medium = Number(thresholdMedium);
const low = Number(thresholdLow);
const validationError = useMemo<string | null>(() => {
if (isNaN(high) || isNaN(medium) || isNaN(low))
return "All thresholds must be valid numbers.";
if (high <= 0 || medium <= 0 || low <= 0)
return "All thresholds must be positive integers.";
if (!(high > medium && medium > low))
return "Thresholds must satisfy: high > medium > low.";
return null;
}, [high, medium, low]);
// Only pass a new payload to useAutoSave when all values are valid.
const [validPayload, setValidPayload] = useState<MapColorThresholdsUpdate>({
threshold_high: initial.threshold_high,
threshold_medium: initial.threshold_medium,
threshold_low: initial.threshold_low,
});
useEffect(() => {
if (validationError !== null) return;
setValidPayload({ threshold_high: high, threshold_medium: medium, threshold_low: low });
}, [high, medium, low, validationError]);
const saveThresholds = useCallback(
async (payload: MapColorThresholdsUpdate): Promise<void> => {
await updateMapColorThresholds(payload);
},
[],
);
const { status: saveStatus, errorText: saveErrorText, retry: retrySave } =
useAutoSave(validPayload, saveThresholds);
return (
<div>
<div className={styles.sectionCard}>
<Text as="h3" size={500} weight="semibold" block>
Map Color Thresholds
</Text>
<Text
as="p"
size={300}
className={styles.infoText}
block
style={{ marginBottom: tokens.spacingVerticalM }}
>
Configure the ban count thresholds that determine country fill colors on
the World Map. Countries with zero bans remain transparent. Colors
smoothly interpolate between thresholds.
</Text>
<div style={{ marginBottom: tokens.spacingVerticalS }}>
<AutoSaveIndicator
status={validationError ? "idle" : saveStatus}
errorText={saveErrorText}
onRetry={retrySave}
/>
</div>
{validationError && (
<MessageBar intent="error" style={{ marginBottom: tokens.spacingVerticalS }}>
<MessageBarBody>{validationError}</MessageBarBody>
</MessageBar>
)}
<div className={styles.fieldRowThree}>
<Field label="Low Threshold (Green)" required>
<Input
type="number"
value={thresholdLow}
onChange={(_, d) => {
setThresholdLow(d.value);
}}
min={1}
/>
</Field>
<Field label="Medium Threshold (Yellow)" required>
<Input
type="number"
value={thresholdMedium}
onChange={(_, d) => {
setThresholdMedium(d.value);
}}
min={1}
/>
</Field>
<Field label="High Threshold (Red)" required>
<Input
type="number"
value={thresholdHigh}
onChange={(_, d) => {
setThresholdHigh(d.value);
}}
min={1}
/>
</Field>
</div>
<Text
as="p"
size={200}
className={styles.infoText}
style={{ marginTop: tokens.spacingVerticalS }}
>
1 to {thresholdLow}: Light green Full green
<br /> {thresholdLow} to {thresholdMedium}: Green Yellow
<br /> {thresholdMedium} to {thresholdHigh}: Yellow Red
<br /> {thresholdHigh}+: Solid red
</Text>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Outer loader component.
// ---------------------------------------------------------------------------
/**
* Tab component for editing world-map ban-count color thresholds.
*
* @returns JSX element.
*/
export function MapTab(): React.JSX.Element {
const [thresholds, setThresholds] = useState<MapColorThresholdsResponse | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const load = useCallback(async (): Promise<void> => {
try {
const data = await fetchMapColorThresholds();
setThresholds(data);
} catch (err) {
setLoadError(
err instanceof ApiError ? err.message : "Failed to load map color thresholds",
);
}
}, []);
useEffect(() => {
void load();
}, [load]);
if (!thresholds && !loadError) {
return (
<Skeleton aria-label="Loading map settings…">
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 8 }}>
<SkeletonItem size={32} />
<SkeletonItem size={32} />
<SkeletonItem size={32} />
</div>
</Skeleton>
);
}
if (loadError)
return (
<MessageBar intent="error">
<MessageBarBody>{loadError}</MessageBarBody>
</MessageBar>
);
if (!thresholds) return <></>;
return <MapForm initial={thresholds} />;
}