import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { ChangeEvent, ReactNode, useCallback, useContext, useEffect, useRef, useState, } from "react"; import { toast } from "sonner"; import { Button, buttonVariants } from "../../components/ui/button"; import useSWR from "swr"; import { FrigateConfig } from "@/types/frigateConfig"; import { useUserPersistence, deleteUserNamespacedKey, } from "@/hooks/use-user-persistence"; import { isMobileOnly } from "react-device-detect"; import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, } from "../../components/ui/select"; import { useTranslation } from "react-i18next"; import { AuthContext } from "@/context/auth-context"; import { SettingsGroupCard, SPLIT_ROW_CLASS_NAME, DESCRIPTION_CLASS_NAME, CONTROL_COLUMN_CLASS_NAME, } from "@/components/card/SettingsGroupCard"; import Heading from "@/components/ui/heading"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { cn } from "@/lib/utils"; import ImportUiSettingsDialog from "@/components/overlay/dialog/ImportUiSettingsDialog"; import { applyImportPayload, buildExportPayload, downloadJson, exportFileName, hasImportableContent, ImportSummary, ParseError, parseUiSettingsFile, summarizeImport, TransferSection, UiSettingsFile, } from "@/utils/uiSettingsTransfer"; const WEEK_STARTS_ON = ["Sunday", "Monday"]; const IMPORT_FAILED_FLAG = "frigate-ui-settings-import-failed"; type ConfirmTarget = "layouts" | "streaming" | "naturalAspect"; type SwitchSettingRowProps = { id: string; label: string; description: string; checked: boolean | undefined; onCheckedChange: (checked: boolean | undefined) => void; }; function SwitchSettingRow({ id, label, description, checked, onCheckedChange, }: SwitchSettingRowProps) { return (

{description}

); } type ValueSettingRowProps = { id: string; label: string; description: string; control: ReactNode; }; function ValueSettingRow({ id, label, description, control, }: ValueSettingRowProps) { return (

{description}

{control}

{description}

); } export default function UiSettingsView() { const { data: config } = useSWR("config"); const { t } = useTranslation("views/settings"); const { auth } = useContext(AuthContext); const username = auth?.user?.username; const PLAYBACK_RATE_DEFAULT = [0.5, 1, 2, 4, 8, 16]; const clearStoredLayouts = useCallback(() => { if (!config) { return []; } Object.entries(config.camera_groups).forEach(async ([cameraName]) => { await deleteUserNamespacedKey(`${cameraName}-draggable-layout`, username) .then(() => { toast.success( t("general.toast.success.clearStoredLayout", { cameraName }), { position: "top-center", }, ); }) .catch((error) => { const errorMessage = error.response?.data?.message || error.response?.data?.detail || "Unknown error"; toast.error( t("general.toast.error.clearStoredLayoutFailed", { errorMessage }), { position: "top-center", }, ); }); }); }, [config, t, username]); const clearStreamingSettings = useCallback(async () => { if (!config) { return []; } await deleteUserNamespacedKey("streaming-settings", username) .then(() => { toast.success(t("general.toast.success.clearStreamingSettings"), { position: "top-center", }); }) .catch((error) => { const errorMessage = error.response?.data?.message || error.response?.data?.detail || "Unknown error"; toast.error( t("general.toast.error.clearStreamingSettingsFailed", { errorMessage, }), { position: "top-center", }, ); }); }, [config, t, username]); const fileInputRef = useRef(null); const [pendingImport, setPendingImport] = useState<{ name: string; file: UiSettingsFile; summary: ImportSummary; } | null>(null); const importErrorMessage = useCallback( (error: ParseError) => { // literal keys per branch: a template key would be invisible to // npm run i18n:extract, which CI verifies switch (error) { case "invalid_json": return t("general.toast.error.importInvalidJson"); case "wrong_type": return t("general.toast.error.importWrongType"); case "unsupported_version": return t("general.toast.error.importUnsupportedVersion"); case "invalid_schema": return t("general.toast.error.importInvalidSchema"); } }, [t], ); const handleExport = useCallback(async () => { if (!config || auth.isLoading) { return; } let payload: UiSettingsFile; try { payload = await buildExportPayload( Object.keys(config.camera_groups), config.version, username, ); } catch { toast.error(t("general.toast.error.exportUiSettingsFailed"), { position: "top-center", }); return; } downloadJson(payload, exportFileName(new Date())); toast.success(t("general.toast.success.exportUiSettings"), { position: "top-center", }); }, [config, auth.isLoading, username, t]); const handleFileSelected = useCallback( async (event: ChangeEvent) => { const selected = event.target.files?.[0]; // reset so choosing the same file again still fires a change event event.target.value = ""; if (!selected || !config || auth.isLoading) { return; } const result = parseUiSettingsFile(await selected.text()); if (!result.ok) { toast.error(importErrorMessage(result.error), { position: "top-center", }); return; } const summary = summarizeImport( result.file, Object.keys(config.camera_groups), Object.keys(config.cameras), ); // a file exported from a browser with nothing stored is structurally // valid, and would open a dialog with every switch disabled if (!hasImportableContent(summary)) { toast.error(t("general.toast.error.importNothingToApply"), { position: "top-center", }); return; } setPendingImport({ name: selected.name, file: result.file, summary }); }, [config, auth.isLoading, importErrorMessage, t], ); const handleImportConfirm = useCallback( async (sections: Record) => { if (!pendingImport || auth.isLoading) { return; } try { await applyImportPayload(pendingImport.file, sections, username); } catch { // writes are already in flight when one rejects, so reload anyway: // staying mounted lets the persistence providers write their stale // state back over whatever did land sessionStorage.setItem(IMPORT_FAILED_FLAG, "1"); } window.location.reload(); }, [pendingImport, auth.isLoading, username], ); useEffect(() => { document.title = t("documentTitle.general"); }, [t]); useEffect(() => { if (!sessionStorage.getItem(IMPORT_FAILED_FLAG)) { return; } sessionStorage.removeItem(IMPORT_FAILED_FLAG); toast.error(t("general.toast.error.importUiSettingsFailed"), { position: "top-center", }); }, [t]); const [autoLive, setAutoLive] = useUserPersistence("autoLiveView", true); const [cameraNames, setCameraName] = useUserPersistence( "displayCameraNames", false, ); const [naturalAspect, setNaturalAspect] = useUserPersistence( "naturalAspectLayout", false, ); const [playbackRate, setPlaybackRate] = useUserPersistence("playbackRate", 1); const [weekStartsOn, setWeekStartsOn] = useUserPersistence("weekStartsOn", 0); const [alertVideos, setAlertVideos] = useUserPersistence("alertVideos", true); const [fallbackTimeout, setFallbackTimeout] = useUserPersistence( "liveFallbackTimeout", 3, ); const [pendingConfirm, setPendingConfirm] = useState( null, ); const confirmCopy = useCallback( (target: ConfirmTarget) => { // literal keys per branch: a template key would be invisible to // npm run i18n:extract, which CI verifies switch (target) { case "layouts": return { title: t("general.storedLayouts.clearAll"), description: t("general.storedLayouts.clearConfirm"), action: t("button.clear", { ns: "common" }), }; case "streaming": return { title: t("general.cameraGroupStreaming.clearAll"), description: t("general.cameraGroupStreaming.clearConfirm"), action: t("button.clear", { ns: "common" }), }; case "naturalAspect": return { title: t("general.liveDashboard.naturalAspectLayout.label"), description: t( "general.liveDashboard.naturalAspectLayout.descNote", ), action: naturalAspect ? t("button.disable", { ns: "common" }) : t("button.enable", { ns: "common" }), }; } }, [naturalAspect, t], ); const handleConfirm = useCallback(() => { switch (pendingConfirm) { case "layouts": clearStoredLayouts(); break; case "streaming": clearStreamingSettings(); break; case "naturalAspect": // a layout only renders in the mode that built it setNaturalAspect(!naturalAspect); clearStoredLayouts(); break; } setPendingConfirm(null); }, [ pendingConfirm, clearStoredLayouts, clearStreamingSettings, naturalAspect, setNaturalAspect, ]); const liveDashboardSwitchRows = [ { id: "auto-live", label: t("general.liveDashboard.automaticLiveView.label"), description: t("general.liveDashboard.automaticLiveView.desc"), checked: autoLive, onCheckedChange: setAutoLive, }, { id: "images-only", label: t("general.liveDashboard.playAlertVideos.label"), description: t("general.liveDashboard.playAlertVideos.desc"), checked: alertVideos, onCheckedChange: setAlertVideos, }, { id: "camera-names", label: t("general.liveDashboard.displayCameraNames.label"), description: t("general.liveDashboard.displayCameraNames.desc"), checked: cameraNames, onCheckedChange: setCameraName, }, // phones use the static grid, so tile sizing has nothing to affect there ...(isMobileOnly ? [] : [ { id: "natural-aspect", label: t("general.liveDashboard.naturalAspectLayout.label"), description: t("general.liveDashboard.naturalAspectLayout.desc"), checked: naturalAspect, onCheckedChange: () => setPendingConfirm("naturalAspect"), }, ]), ]; return (
{t("general.title")}
{liveDashboardSwitchRows.map((row) => ( ))} setFallbackTimeout(parseInt(value, 10)) } > {t("time.second", { ns: "common", time: fallbackTimeout, count: fallbackTimeout, })} {[1, 2, 3, 5, 8, 10, 12, 15].map((timeout) => ( {t("time.second", { ns: "common", time: timeout, count: timeout, })} ))} } /> setPendingConfirm("layouts")} > {t("general.storedLayouts.clearAll")} } /> setPendingConfirm("streaming")} > {t("general.cameraGroupStreaming.clearAll")} } />
} /> setPlaybackRate(parseFloat(value))} > {`${playbackRate}x`} {PLAYBACK_RATE_DEFAULT.map((rate) => ( {rate}x ))} } /> setWeekStartsOn(parseInt(value, 10)) } > {t( "general.calendar.firstWeekday." + WEEK_STARTS_ON[weekStartsOn ?? 0].toLowerCase(), )} {WEEK_STARTS_ON.map((day, index) => ( {t( "general.calendar.firstWeekday." + day.toLowerCase(), )} ))} } />
{pendingImport && ( { if (!open) { setPendingImport(null); } }} fileName={pendingImport.name} file={pendingImport.file} summary={pendingImport.summary} currentNaturalAspect={naturalAspect ?? false} onConfirm={handleImportConfirm} /> )} { if (!open) { setPendingConfirm(null); } }} > {pendingConfirm && confirmCopy(pendingConfirm).title} {pendingConfirm && confirmCopy(pendingConfirm).description} {t("button.cancel", { ns: "common" })} {pendingConfirm && confirmCopy(pendingConfirm).action} ); }