From d99d6bd119919ec62481361824a0e05a2856a75d Mon Sep 17 00:00:00 2001 From: Lukas Date: Sun, 19 Apr 2026 12:04:24 +0200 Subject: [PATCH] Replace inline frontend styles with makeStyles and design tokens --- Docs/Tasks.md | 2 + frontend/src/components/ChartTooltip.tsx | 23 +++ .../src/components/JailDistributionChart.tsx | 31 ++-- frontend/src/components/SetupGuard.tsx | 21 ++- .../src/components/TopCountriesBarChart.tsx | 31 ++-- .../src/components/TopCountriesPieChart.tsx | 14 +- frontend/src/components/WorldMap.tsx | 27 ++- .../blocklist/BlocklistScheduleSection.tsx | 2 +- .../blocklist/BlocklistSourcesSection.tsx | 2 +- .../components/blocklist/PreviewDialog.tsx | 26 ++- .../components/blocklist/SourceFormDialog.tsx | 13 +- .../components/blocklist/blocklistStyles.ts | 6 +- .../components/config/AutoSaveIndicator.tsx | 60 ++++--- .../components/config/RawConfigSection.tsx | 65 ++++--- .../src/components/jail/BannedIpsSection.tsx | 126 +++++++------- .../src/pages/jails/JailOverviewSection.tsx | 160 ++++++++++-------- 16 files changed, 344 insertions(+), 265 deletions(-) create mode 100644 frontend/src/components/ChartTooltip.tsx diff --git a/Docs/Tasks.md b/Docs/Tasks.md index 8fab49b..c5ec60b 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -349,6 +349,8 @@ Reference: `Docs/Refactoring.md` for full analysis of each issue. **Docs changes needed:** None. +**Status:** Completed. + **Why this is needed:** Inline styles bypass Griffel's atomic CSS engine, produce non-deduplicatable style nodes, and cannot respond to theme changes. Hardcoded pixel values break when the user switches to a different Fluent UI theme with different spacing scales. The project rule states that `makeStyles` with design tokens is the only permitted styling mechanism. --- diff --git a/frontend/src/components/ChartTooltip.tsx b/frontend/src/components/ChartTooltip.tsx new file mode 100644 index 0000000..7f93c7d --- /dev/null +++ b/frontend/src/components/ChartTooltip.tsx @@ -0,0 +1,23 @@ +import { makeStyles, tokens } from "@fluentui/react-components"; + +const useStyles = makeStyles({ + tooltip: { + backgroundColor: tokens.colorNeutralBackground1, + border: `1px solid ${tokens.colorNeutralStroke2}`, + borderRadius: tokens.borderRadiusSmall, + padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalS}`, + color: tokens.colorNeutralForeground1, + fontSize: tokens.fontSizeBase200, + }, +}); + +interface ChartTooltipProps { + active: boolean; + children: React.ReactNode; +} + +export function ChartTooltip({ active, children }: ChartTooltipProps): React.JSX.Element | null { + const styles = useStyles(); + if (!active) return null; + return
{children}
; +} diff --git a/frontend/src/components/JailDistributionChart.tsx b/frontend/src/components/JailDistributionChart.tsx index ad78a83..9d5e2cb 100644 --- a/frontend/src/components/JailDistributionChart.tsx +++ b/frontend/src/components/JailDistributionChart.tsx @@ -16,10 +16,7 @@ import { YAxis, } from "recharts"; import type { TooltipContentProps } from "recharts/types/component/Tooltip"; -import { - tokens, - makeStyles, -} from "@fluentui/react-components"; +import { makeStyles } from "@fluentui/react-components"; import { CHART_AXIS_TEXT_TOKEN, CHART_GRID_LINE_TOKEN, @@ -27,6 +24,7 @@ import { resolveFluentToken, } from "../utils/chartTheme"; import { ChartStateWrapper } from "./ChartStateWrapper"; +import { ChartTooltip } from "./ChartTooltip"; import { useJailDistribution } from "../hooks/useJailDistribution"; import type { BanOriginFilter, TimeRange } from "../types/ban"; @@ -69,6 +67,10 @@ interface BarEntry { // Styles // --------------------------------------------------------------------------- +const CHART_TICK_FONT_SIZE = 12; +const CHART_MARGIN = { top: 4, right: 16, bottom: 4, left: 8 }; +const Y_AXIS_WIDTH = 160; + const useStyles = makeStyles({ wrapper: { width: "100%", @@ -106,20 +108,11 @@ function JailTooltip(props: TooltipContentProps): React.JSX.Element | null { const { fullName, value } = entry.payload as BarEntry; return ( -
+ {fullName}
{String(value)} ban{value === 1 ? "" : "s"} -
+ ); } @@ -162,20 +155,20 @@ export function JailDistributionChart({ diff --git a/frontend/src/components/SetupGuard.tsx b/frontend/src/components/SetupGuard.tsx index f7b341d..6a589dd 100644 --- a/frontend/src/components/SetupGuard.tsx +++ b/frontend/src/components/SetupGuard.tsx @@ -7,7 +7,7 @@ */ import { Navigate } from "react-router-dom"; -import { Spinner } from "@fluentui/react-components"; +import { Spinner, makeStyles } from "@fluentui/react-components"; import { useSetup } from "../hooks/useSetup"; /** @@ -24,19 +24,22 @@ interface SetupGuardProps { * * Redirects to `/setup` if setup is still pending. */ +const useStyles = makeStyles({ + loadingWrapper: { + display: "flex", + justifyContent: "center", + alignItems: "center", + minHeight: "100vh", + }, +}); + export function SetupGuard({ children }: SetupGuardProps): React.JSX.Element { + const styles = useStyles(); const { status, loading } = useSetup(); if (loading) { return ( -
+
); diff --git a/frontend/src/components/TopCountriesBarChart.tsx b/frontend/src/components/TopCountriesBarChart.tsx index d7fb8b0..02d4cec 100644 --- a/frontend/src/components/TopCountriesBarChart.tsx +++ b/frontend/src/components/TopCountriesBarChart.tsx @@ -20,6 +20,7 @@ import { CHART_GRID_LINE_TOKEN, resolveFluentToken, } from "../utils/chartTheme"; +import { ChartTooltip } from "./ChartTooltip"; // --------------------------------------------------------------------------- // Constants @@ -60,6 +61,10 @@ interface BarEntry { // Styles // --------------------------------------------------------------------------- +const CHART_TICK_FONT_SIZE = 12; +const CHART_MARGIN = { top: 4, right: 16, bottom: 4, left: 8 }; +const Y_AXIS_WIDTH = 140; + const useStyles = makeStyles({ wrapper: { width: "100%", @@ -106,30 +111,18 @@ function buildEntries( // Custom tooltip // --------------------------------------------------------------------------- -function BarTooltip( - props: TooltipContentProps, -): React.JSX.Element | null { +function BarTooltip(props: TooltipContentProps): React.JSX.Element | null { const { active, payload } = props; if (!active || payload.length === 0) return null; const entry = payload[0]; if (entry == null) return null; - // `fullName` is stored as an extra field on the payload item. const fullName = (entry.payload as BarEntry).fullName; return ( -
+ {fullName}
{String(entry.value)} ban{entry.value === 1 ? "" : "s"} -
+ ); } @@ -171,20 +164,20 @@ export function TopCountriesBarChart({ diff --git a/frontend/src/components/TopCountriesPieChart.tsx b/frontend/src/components/TopCountriesPieChart.tsx index 2820033..f5ca244 100644 --- a/frontend/src/components/TopCountriesPieChart.tsx +++ b/frontend/src/components/TopCountriesPieChart.tsx @@ -16,6 +16,7 @@ import type { LegendPayload } from "recharts/types/component/DefaultLegendConten import type { TooltipContentProps } from "recharts/types/component/Tooltip"; import { tokens, makeStyles, Text } from "@fluentui/react-components"; import { CHART_PALETTE, resolveFluentToken } from "../utils/chartTheme"; +import { ChartTooltip } from "./ChartTooltip"; // --------------------------------------------------------------------------- // Constants @@ -107,22 +108,13 @@ function PieTooltip(props: TooltipContentProps): React.JSX.Element | null { const banCount = entry.value; const displayName: string = entry.name?.toString() ?? ""; return ( -
+ {displayName}
{banCount != null ? `${String(banCount)} ban${banCount === 1 ? "" : "s"}` : ""} -
+ ); } diff --git a/frontend/src/components/WorldMap.tsx b/frontend/src/components/WorldMap.tsx index b5ad730..70d2e34 100644 --- a/frontend/src/components/WorldMap.tsx +++ b/frontend/src/components/WorldMap.tsx @@ -14,14 +14,9 @@ import { useState, } from "react"; import { Button, makeStyles, tokens } from "@fluentui/react-components"; -import { geoMercator, geoPath, type GeoPath } from "d3-geo"; +import { geoMercator, geoPath } from "d3-geo"; import { feature } from "topojson-client"; -import type { - Feature, - FeatureCollection, - GeoJsonProperties, - Geometry, -} from "geojson"; +import type { FeatureCollection } from "geojson"; import type { GeometryCollection as TopoGeometryCollection, Topology, @@ -37,6 +32,7 @@ const MIN_ZOOM = 1; const MAX_ZOOM = 8; const ZOOM_STEP = 0.5; const PAN_THRESHOLD = 3; +const MAP_COUNTRY_STROKE_WIDTH = 0.75; const useStyles = makeStyles({ mapWrapper: { @@ -52,7 +48,7 @@ const useStyles = makeStyles({ country: { transition: "fill 150ms ease, stroke 150ms ease", stroke: tokens.colorNeutralStroke2, - strokeWidth: 0.75, + strokeWidth: MAP_COUNTRY_STROKE_WIDTH, fill: "var(--country-fill)", outline: "none", cursor: "pointer", @@ -64,7 +60,7 @@ const useStyles = makeStyles({ fill: "var(--country-selected-fill)", }, countLabel: { - fontSize: "9px", + fontSize: tokens.fontSizeBase200, fontWeight: "600", fill: tokens.colorNeutralForeground1, pointerEvents: "none", @@ -173,10 +169,7 @@ export function WorldMap({ const geoJson = useMemo( () => - feature(topology, topology.objects.countries) as FeatureCollection< - Geometry, - GeoJsonProperties - >, + feature(topology, topology.objects.countries) as FeatureCollection, [topology], ); @@ -185,13 +178,13 @@ export function WorldMap({ [geoJson], ); - const pathGenerator = useMemo>>( + const pathGenerator = useMemo( () => geoPath().projection(projection), [projection], ); const countryFeatures = useMemo( - () => geoJson.features.filter((feature) => feature.id != null && feature.geometry != null), + () => geoJson.features, [geoJson.features], ); @@ -331,7 +324,7 @@ export function WorldMap({ - + {countryFeatures.map((featureItem) => { const rawId = featureItem.id; const numericId = String(Number(rawId)); diff --git a/frontend/src/components/blocklist/BlocklistScheduleSection.tsx b/frontend/src/components/blocklist/BlocklistScheduleSection.tsx index d5bc1ae..5ce98f6 100644 --- a/frontend/src/components/blocklist/BlocklistScheduleSection.tsx +++ b/frontend/src/components/blocklist/BlocklistScheduleSection.tsx @@ -166,7 +166,7 @@ export function BlocklistScheduleSection({ onRunImport, runImportRunning }: Sche )} -
diff --git a/frontend/src/components/blocklist/BlocklistSourcesSection.tsx b/frontend/src/components/blocklist/BlocklistSourcesSection.tsx index 16ee6d5..9345137 100644 --- a/frontend/src/components/blocklist/BlocklistSourcesSection.tsx +++ b/frontend/src/components/blocklist/BlocklistSourcesSection.tsx @@ -118,7 +118,7 @@ export function BlocklistSourcesSection({ onRunImport, runImportRunning }: Sourc Blocklist Sources -
+
diff --git a/frontend/src/components/blocklist/PreviewDialog.tsx b/frontend/src/components/blocklist/PreviewDialog.tsx index ff9f653..acfa0b0 100644 --- a/frontend/src/components/blocklist/PreviewDialog.tsx +++ b/frontend/src/components/blocklist/PreviewDialog.tsx @@ -11,6 +11,8 @@ import { MessageBarBody, Spinner, Text, + makeStyles, + tokens, } from "@fluentui/react-components"; import type { BlocklistSource, PreviewResponse } from "../../types/blocklist"; @@ -21,7 +23,25 @@ interface PreviewDialogProps { fetchPreview: (id: number) => Promise; } +const useStyles = makeStyles({ + centeredLoader: { + textAlign: "center", + padding: tokens.spacingVerticalL, + }, + previewStack: { + display: "flex", + flexDirection: "column", + gap: tokens.spacingVerticalXS, + }, + previewEntries: { + display: "flex", + flexDirection: "column", + gap: tokens.spacingVerticalXS, + }, +}); + export function PreviewDialog({ open, source, onClose, fetchPreview }: PreviewDialogProps): React.JSX.Element { + const styles = useStyles(); const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -49,7 +69,7 @@ export function PreviewDialog({ open, source, onClose, fetchPreview }: PreviewDi Preview — {source?.name ?? ""} {loading && ( -
+
)} @@ -59,11 +79,11 @@ export function PreviewDialog({ open, source, onClose, fetchPreview }: PreviewDi )} {data && ( -
+
{data.valid_count} valid IPs / {data.skipped_count} skipped of {data.total_lines} total lines. Showing first {data.entries.length}: -
+
{data.entries.map((entry) => (
{entry}
))} diff --git a/frontend/src/components/blocklist/SourceFormDialog.tsx b/frontend/src/components/blocklist/SourceFormDialog.tsx index 7c5e1f3..f78cbd1 100644 --- a/frontend/src/components/blocklist/SourceFormDialog.tsx +++ b/frontend/src/components/blocklist/SourceFormDialog.tsx @@ -12,6 +12,8 @@ import { MessageBar, MessageBarBody, Switch, + makeStyles, + tokens, } from "@fluentui/react-components"; interface SourceFormValues { @@ -30,6 +32,14 @@ interface SourceFormDialogProps { onSubmit: (values: SourceFormValues) => void; } +const useStyles = makeStyles({ + contentStack: { + display: "flex", + flexDirection: "column", + gap: tokens.spacingVerticalL, + }, +}); + export function SourceFormDialog({ open, mode, @@ -39,6 +49,7 @@ export function SourceFormDialog({ onClose, onSubmit, }: SourceFormDialogProps): React.JSX.Element { + const styles = useStyles(); const [values, setValues] = useState(initial); const handleOpen = useCallback((): void => { @@ -56,7 +67,7 @@ export function SourceFormDialog({ {mode === "add" ? "Add Blocklist Source" : "Edit Blocklist Source"} -
+
{error && ( {error} diff --git a/frontend/src/components/blocklist/blocklistStyles.ts b/frontend/src/components/blocklist/blocklistStyles.ts index bb11768..d259477 100644 --- a/frontend/src/components/blocklist/blocklistStyles.ts +++ b/frontend/src/components/blocklist/blocklistStyles.ts @@ -9,7 +9,8 @@ export const useBlocklistStyles = makeStyles({ tableWrapper: { overflowX: "auto" }, actionsCell: { display: "flex", gap: tokens.spacingHorizontalS, flexWrap: "wrap" }, - mono: { fontFamily: "Consolas, 'Courier New', monospace", fontSize: "12px" }, + headerActions: { display: "flex", gap: tokens.spacingHorizontalS }, + mono: { fontFamily: tokens.fontFamilyMonospace, fontSize: tokens.fontSizeBase200 }, centred: { display: "flex", justifyContent: "center", @@ -22,13 +23,14 @@ export const useBlocklistStyles = makeStyles({ alignItems: "flex-end", }, scheduleField: { minWidth: "140px" }, + saveButton: { alignSelf: "flex-end" }, metaRow: { display: "flex", gap: tokens.spacingHorizontalL, flexWrap: "wrap", paddingTop: tokens.spacingVerticalS, }, - metaItem: { display: "flex", flexDirection: "column", gap: "2px" }, + metaItem: { display: "flex", flexDirection: "column", gap: tokens.spacingHorizontalXXS }, runResult: { display: "flex", flexDirection: "column", diff --git a/frontend/src/components/config/AutoSaveIndicator.tsx b/frontend/src/components/config/AutoSaveIndicator.tsx index 78ce013..f7bed84 100644 --- a/frontend/src/components/config/AutoSaveIndicator.tsx +++ b/frontend/src/components/config/AutoSaveIndicator.tsx @@ -14,6 +14,8 @@ import { Button, Spinner, Text, + makeStyles, + mergeClasses, tokens, } from "@fluentui/react-components"; import { Checkmark16Regular } from "@fluentui/react-icons"; @@ -33,6 +35,32 @@ export interface AutoSaveIndicatorProps { /** Fade-out delay after "saved" status in milliseconds. */ const SAVED_FADE_DELAY_MS = 2000; +const useStyles = makeStyles({ + root: { + display: "inline-flex", + alignItems: "center", + gap: tokens.spacingHorizontalXS, + minWidth: "80px", + }, + savingText: { + color: tokens.colorNeutralForeground2, + }, + errorText: { + color: tokens.colorPaletteRedForeground3, + }, + savedMotion: { + transition: `opacity ${tokens.durationNormal} ${tokens.curveDecelerateMid}, transform ${tokens.durationNormal} ${tokens.curveDecelerateMid}`, + animationName: "fadeInScale", + animationDuration: tokens.durationFast, + animationTimingFunction: tokens.curveDecelerateMid, + animationFillMode: "both", + }, + savedHidden: { + opacity: 0, + transform: "scale(0.95)", + }, +}); + /** * Compact inline indicator for auto-save state. * @@ -44,6 +72,7 @@ export function AutoSaveIndicator({ errorText, onRetry, }: AutoSaveIndicatorProps): React.JSX.Element { + const styles = useStyles(); const [fadingOut, setFadingOut] = useState(false); // Trigger the fade-out transition 2 s after the saved state is reached. @@ -62,20 +91,11 @@ export function AutoSaveIndicator({ // Always render the aria-live region so screen readers track changes. return ( - + {status === "saving" && ( <> - + Saving… @@ -83,15 +103,10 @@ export function AutoSaveIndicator({ {status === "saved" && ( - + {errorText ?? "Save failed."} {onRetry && ( diff --git a/frontend/src/components/config/RawConfigSection.tsx b/frontend/src/components/config/RawConfigSection.tsx index 774b982..a11b014 100644 --- a/frontend/src/components/config/RawConfigSection.tsx +++ b/frontend/src/components/config/RawConfigSection.tsx @@ -17,6 +17,7 @@ import { MessageBarBody, Spinner, Textarea, + makeStyles, tokens, } from "@fluentui/react-components"; import { AutoSaveIndicator } from "./AutoSaveIndicator"; @@ -37,6 +38,37 @@ export interface RawConfigSectionProps { /** Minimum visible rows for the monospace text area. */ const MIN_ROWS = 15; +const useStyles = makeStyles({ + headerText: { + fontWeight: tokens.fontWeightSemibold, + }, + loadingRow: { + display: "flex", + alignItems: "center", + gap: tokens.spacingHorizontalS, + padding: tokens.spacingVerticalM, + }, + loadingText: { + color: tokens.colorNeutralForeground3, + }, + textArea: { + fontFamily: tokens.fontFamilyMonospace, + fontSize: tokens.fontSizeBase200, + width: "100%", + borderLeft: `3px solid ${tokens.colorBrandStroke1}`, + marginBottom: tokens.spacingVerticalS, + }, + controlsRow: { + display: "flex", + alignItems: "center", + gap: tokens.spacingHorizontalS, + marginTop: tokens.spacingVerticalXS, + }, + errorBar: { + marginBottom: tokens.spacingVerticalS, + }, +}); + // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- @@ -52,6 +84,7 @@ export function RawConfigSection({ saveContent, label = "Raw Configuration", }: RawConfigSectionProps): React.JSX.Element { + const styles = useStyles(); /** Raw text content; null means not yet loaded. */ const [content, setContent] = useState(null); const [localText, setLocalText] = useState(""); @@ -122,27 +155,20 @@ export function RawConfigSection({ - {label} + {label} {fetchLoading && ( -
+
- + Loading…
)} {fetchError && ( - + {fetchError} )} @@ -156,23 +182,10 @@ export function RawConfigSection({ }} resize="vertical" rows={MIN_ROWS} - style={{ - fontFamily: "monospace", - fontSize: "0.85rem", - width: "100%", - borderLeft: `3px solid ${tokens.colorBrandStroke1}`, - marginBottom: tokens.spacingVerticalS, - }} + className={styles.textArea} aria-label={label} /> -
+
) : items.length === 0 ? (
- + No IPs currently banned in this jail.
@@ -334,7 +338,7 @@ export function BannedIpsSection({ onPageChange(1); } }} - style={{ minWidth: "80px" }} + className={styles.rowsPerPageDropdown} > {PAGE_SIZE_OPTIONS.map((n) => (