- 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
213 lines
6.5 KiB
TypeScript
213 lines
6.5 KiB
TypeScript
/**
|
|
* 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} />;
|
|
}
|
|
|
|
|