diff --git a/frontend/src/components/config/ConfigListDetail.tsx b/frontend/src/components/config/ConfigListDetail.tsx new file mode 100644 index 0000000..c041fc0 --- /dev/null +++ b/frontend/src/components/config/ConfigListDetail.tsx @@ -0,0 +1,326 @@ +/** + * ConfigListDetail — reusable two-pane master/detail layout component. + * + * Left pane: scrollable list of config items with active/inactive badges. + * Active items sort to the top. Selected item is highlighted. + * Right pane: renders {@link children} for the selected item, or an empty + * state message when nothing is selected. + * + * Keyboard navigation: ArrowUp / ArrowDown navigate items; Enter selects. + * On screens narrower than 900 px the list collapses to a Dropdown above + * the detail pane. + */ + +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { + Badge, + Dropdown, + MessageBar, + MessageBarBody, + Option, + Skeleton, + SkeletonItem, + Text, + Tooltip, + tokens, +} from "@fluentui/react-components"; +import type { OptionOnSelectData, SelectionEvents } from "@fluentui/react-components"; +import { DocumentSearch24Regular } from "@fluentui/react-icons"; +import { useConfigStyles } from "./configStyles"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface ConfigListDetailProps { + /** Complete list of config items to display. */ + items: T[]; + /** Predicate that determines whether an item is "active". */ + isActive: (item: T) => boolean; + /** Currently selected item name, or null when nothing is selected. */ + selectedName: string | null; + /** Called when the user selects an item by name. */ + onSelect: (name: string) => void; + /** Whether the items list is still being fetched. */ + loading: boolean; + /** Error string if the items list failed to load, or null. */ + error: string | null; + /** Detail pane content for the currently selected item. */ + children: React.ReactNode; +} + +/** Number of skeleton placeholder rows shown while loading. */ +const SKELETON_COUNT = 5; + +/** Maximum characters shown in a list-item name before ellipsis truncation. */ +const TOOLTIP_THRESHOLD = 22; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Sort items so active ones appear first, then alphabetically within each + * group. + */ +function sortItems( + items: T[], + isActive: (item: T) => boolean, +): T[] { + return [...items].sort((a, b) => { + const aActive = isActive(a) ? 0 : 1; + const bActive = isActive(b) ? 0 : 1; + if (aActive !== bActive) return aActive - bActive; + return a.name.localeCompare(b.name); + }); +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +/** + * Two-pane list/detail layout for config tabs. + * + * @param props - Component props. + * @returns JSX element. + */ +export function ConfigListDetail({ + items, + isActive, + selectedName, + onSelect, + loading, + error, + children, +}: ConfigListDetailProps): React.JSX.Element { + const styles = useConfigStyles(); + + const sorted = useMemo(() => sortItems(items, isActive), [items, isActive]); + + /** Ref to the currently focused list item for keyboard navigation. */ + const listRef = useRef(null); + + // -------------------------------------------------------------------------- + // Keyboard navigation for the list pane + // -------------------------------------------------------------------------- + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent): void => { + if (sorted.length === 0) return; + + const currentIndex = selectedName + ? sorted.findIndex((item) => item.name === selectedName) + : -1; + + if (event.key === "ArrowDown") { + event.preventDefault(); + const nextIdx = currentIndex < sorted.length - 1 ? currentIndex + 1 : 0; + const nextItem = sorted[nextIdx]; + if (nextItem) onSelect(nextItem.name); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + const prevIdx = currentIndex > 0 ? currentIndex - 1 : sorted.length - 1; + const prevItem = sorted[prevIdx]; + if (prevItem) onSelect(prevItem.name); + } + }, + [sorted, selectedName, onSelect], + ); + + // Scroll selected item into view when selection changes. + useEffect(() => { + if (!listRef.current || !selectedName) return; + const el = listRef.current.querySelector( + `[data-name="${CSS.escape(selectedName)}"]`, + ); + el?.scrollIntoView({ block: "nearest" }); + }, [selectedName]); + + // -------------------------------------------------------------------------- + // Dropdown handler (responsive collapse) + // -------------------------------------------------------------------------- + + const handleDropdownSelect = useCallback( + (_ev: SelectionEvents, data: OptionOnSelectData): void => { + if (data.optionValue) onSelect(data.optionValue); + }, + [onSelect], + ); + + // -------------------------------------------------------------------------- + // Loading / error states + // -------------------------------------------------------------------------- + + if (loading) { + return ( + + {Array.from({ length: SKELETON_COUNT }, (_, i) => ( + + ))} + + ); + } + + if (error) { + return ( + + {error} + + ); + } + + // -------------------------------------------------------------------------- + // Empty items list + // -------------------------------------------------------------------------- + + if (sorted.length === 0) { + return ( +
+ + No items found. +
+ ); + } + + // -------------------------------------------------------------------------- + // Full layout + // -------------------------------------------------------------------------- + + return ( +
+ {/* ── Left pane: full-width on narrow screens → Dropdown ── */} +
+ {/* Responsive dropdown (visible only on narrow screens via CSS) */} +
+ + {sorted.map((item) => ( + + ))} + +
+ + {/* Item list (hidden on narrow screens via CSS) */} +
+ {sorted.map((item) => { + const active = isActive(item); + const selected = item.name === selectedName; + const needsTooltip = item.name.length > TOOLTIP_THRESHOLD; + + const itemEl = ( +
{ + onSelect(item.name); + }} + onKeyDown={(e) => { + if (e.key === "Enter") onSelect(item.name); + }} + > + + {item.name} + + + {active ? "Active" : "Inactive"} + +
+ ); + + return needsTooltip ? ( + + {itemEl} + + ) : ( + itemEl + ); + })} +
+
+ + {/* ── Right pane: detail content ── */} +
+ {selectedName === null ? ( +
+ + Select an item from the list. +
+ ) : ( + children + )} +
+
+ ); +} diff --git a/frontend/src/components/config/RawConfigSection.tsx b/frontend/src/components/config/RawConfigSection.tsx new file mode 100644 index 0000000..774b982 --- /dev/null +++ b/frontend/src/components/config/RawConfigSection.tsx @@ -0,0 +1,197 @@ +/** + * RawConfigSection — collapsible raw-text editor for .conf files. + * + * Renders a single Accordion item. On first expand the raw content is + * fetched; the user can edit the textarea and save with a primary button. + * Feedback is shown via an {@link AutoSaveIndicator}-style message bar. + */ + +import { useCallback, useRef, useState } from "react"; +import { + Accordion, + AccordionHeader, + AccordionItem, + AccordionPanel, + Button, + MessageBar, + MessageBarBody, + Spinner, + Textarea, + tokens, +} from "@fluentui/react-components"; +import { AutoSaveIndicator } from "./AutoSaveIndicator"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface RawConfigSectionProps { + /** Async function that returns the raw file content string. */ + fetchContent: () => Promise; + /** Async function that saves updated raw content. */ + saveContent: (content: string) => Promise; + /** Label shown in the collapsible header, e.g. "Raw Jail Configuration". */ + label?: string; +} + +/** Minimum visible rows for the monospace text area. */ +const MIN_ROWS = 15; + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +/** + * Collapsible section for viewing and editing raw .conf file content. + * + * @param props - Component props. + * @returns JSX element. + */ +export function RawConfigSection({ + fetchContent, + saveContent, + label = "Raw Configuration", +}: RawConfigSectionProps): React.JSX.Element { + /** Raw text content; null means not yet loaded. */ + const [content, setContent] = useState(null); + const [localText, setLocalText] = useState(""); + const [fetchLoading, setFetchLoading] = useState(false); + const [fetchError, setFetchError] = useState(null); + const [saveStatus, setSaveStatus] = useState< + "idle" | "saving" | "saved" | "error" + >("idle"); + const [saveError, setSaveError] = useState(null); + + /** Whether the section has been expanded at least once. */ + const loadedRef = useRef(false); + + // -------------------------------------------------------------------------- + // Handlers + // -------------------------------------------------------------------------- + + /** Called when the accordion panel opens for the first time. */ + const handleExpand = useCallback( + (_: React.SyntheticEvent, data: { openItems: string[] }): void => { + const isOpen = data.openItems.includes("raw"); + if (!isOpen || loadedRef.current) return; + loadedRef.current = true; + + setFetchLoading(true); + setFetchError(null); + + fetchContent() + .then((text) => { + setContent(text); + setLocalText(text); + setFetchLoading(false); + }) + .catch((err: unknown) => { + setFetchError( + err instanceof Error ? err.message : "Failed to load raw content.", + ); + setFetchLoading(false); + }); + }, + [fetchContent], + ); + + const handleSave = useCallback(async (): Promise => { + setSaveStatus("saving"); + setSaveError(null); + try { + await saveContent(localText); + setContent(localText); + setSaveStatus("saved"); + } catch (err: unknown) { + const msg = + err instanceof Error ? err.message : "Failed to save raw content."; + setSaveError(msg); + setSaveStatus("error"); + } + }, [localText, saveContent]); + + const handleRetry = useCallback((): void => { + void handleSave(); + }, [handleSave]); + + // -------------------------------------------------------------------------- + // Render + // -------------------------------------------------------------------------- + + return ( + + + + {label} + + + {fetchLoading && ( +
+ + + Loading… + +
+ )} + + {fetchError && ( + + {fetchError} + + )} + + {!fetchLoading && content !== null && ( + <> +