diff --git a/frigate/api/app.py b/frigate/api/app.py index a50e52afc..172b4a409 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -5,6 +5,7 @@ import copy import json import logging import os +import re import traceback import urllib from datetime import datetime, timedelta @@ -32,6 +33,7 @@ from frigate.config.camera.updater import ( CameraConfigUpdateEnum, CameraConfigUpdateTopic, ) +from frigate.const import THEMES_DIR from frigate.ffmpeg_presets import FFMPEG_HWACCEL_VAAPI, _gpu_selector from frigate.jobs.media_sync import ( get_current_media_sync_job, @@ -190,6 +192,28 @@ def config(request: Request): return JSONResponse(content=config) +@router.get("/config/themes") +def config_themes(): + themes_dir = THEMES_DIR + + if not os.path.isdir(themes_dir): + return JSONResponse(content=[]) + + themes: list[str] = [] + for name in sorted(os.listdir(themes_dir)): + if not name.lower().endswith(".css"): + continue + + if not re.fullmatch(r"[a-zA-Z0-9._-]+\.css", name): + continue + + full_path = os.path.join(themes_dir, name) + if os.path.isfile(full_path): + themes.append(name) + + return JSONResponse(content=themes) + + @router.get("/config/raw_paths", dependencies=[Depends(require_role(["admin"]))]) def config_raw_paths(request: Request): """Admin-only endpoint that returns camera paths and go2rtc streams without credential masking.""" diff --git a/frigate/const.py b/frigate/const.py index ac8144c2c..5483fe458 100644 --- a/frigate/const.py +++ b/frigate/const.py @@ -5,6 +5,7 @@ INSTALL_DIR = "/opt/frigate" CONFIG_DIR = "/config" DEFAULT_DB_PATH = f"{CONFIG_DIR}/frigate.db" MODEL_CACHE_DIR = f"{CONFIG_DIR}/model_cache" +THEMES_DIR = f"{CONFIG_DIR}/themes" BASE_DIR = "/media/frigate" CLIPS_DIR = f"{BASE_DIR}/clips" EXPORT_DIR = f"{BASE_DIR}/exports" diff --git a/web/src/components/menu/GeneralSettings.tsx b/web/src/components/menu/GeneralSettings.tsx index 1788bce84..a3a2efa0e 100644 --- a/web/src/components/menu/GeneralSettings.tsx +++ b/web/src/components/menu/GeneralSettings.tsx @@ -487,11 +487,11 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) { {scheme === colorScheme ? ( <> - {t(friendlyColorSchemeName(scheme))} + {friendlyColorSchemeName(scheme, t)} ) : ( - {t(friendlyColorSchemeName(scheme))} + {friendlyColorSchemeName(scheme, t)} )} diff --git a/web/src/context/theme-provider.tsx b/web/src/context/theme-provider.tsx index d2be5e7ee..66660bee6 100644 --- a/web/src/context/theme-provider.tsx +++ b/web/src/context/theme-provider.tsx @@ -1,4 +1,5 @@ import { createContext, useContext, useEffect, useMemo, useState } from "react"; +import useSWR from "swr"; type Theme = "dark" | "light" | "system"; type ColorScheme = @@ -21,9 +22,22 @@ export const colorSchemes: ColorScheme[] = [ // Helper function to generate friendly color scheme names // eslint-disable-next-line react-refresh/only-export-components -export const friendlyColorSchemeName = (className: string): string => { - const words = className.split("-").slice(1); // Exclude the first word (e.g., 'theme') - return "menu.theme." + words.join(""); +export const friendlyColorSchemeName = ( + className: string, + t?: (key: string, options?: any) => string, +): string => { + const words = className.split("-").slice(1); + const key = "menu.theme." + words.join(""); + + if (!t) { + return key; + } + + const fallback = words + .join(" ") + .replace(/\b\w/g, (char) => char.toUpperCase()); + + return t(key, { defaultValue: fallback }); }; type ThemeProviderProps = { @@ -51,6 +65,9 @@ const initialState: ThemeProviderState = { const ThemeProviderContext = createContext(initialState); +const fetcher = (url: string) => + fetch(url).then((res) => (res.ok ? res.json() : [])); + export function ThemeProvider({ children, defaultTheme = "system", @@ -92,13 +109,64 @@ export function ThemeProvider({ : "light"; }, [theme]); + const { data: customFiles } = useSWR( + "/api/config/themes", + fetcher, + { + revalidateOnFocus: false, + revalidateOnReconnect: false, + }, + ); + + const allColorSchemes = useMemo(() => { + const customSchemes = + customFiles + ?.filter((f) => /^[a-zA-Z0-9._-]+\.css$/.test(f)) + .map((f) => { + const base = f.replace(/\.css$/, ""); + return ( + base.startsWith("theme-") ? base : `theme-${base}` + ) as ColorScheme; + }) ?? []; + + return [...colorSchemes, ...customSchemes]; + }, [customFiles]); + + const [themesReady, setThemesReady] = useState(false); + + useEffect(() => { + if (!customFiles) { + setThemesReady(true); + return; + } + + const links = customFiles + .filter((f) => /^[a-zA-Z0-9._-]+\.css$/.test(f)) + .map((file) => { + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = `/config/themes/${file}`; + document.head.appendChild(link); + + return new Promise((resolve) => { + link.onload = () => resolve(); + link.onerror = () => resolve(); + }); + }); + + Promise.all(links).then(() => setThemesReady(true)); + }, [customFiles]); + useEffect(() => { //localStorage.removeItem(storageKey); //console.log(localStorage.getItem(storageKey)); + if (!themesReady) { + return; + } + const root = window.document.documentElement; - root.classList.remove("light", "dark", "system", ...colorSchemes); - + root.classList.remove("light", "dark", "system", ...allColorSchemes); root.classList.add(theme, colorScheme); if (systemTheme) { @@ -107,7 +175,7 @@ export function ThemeProvider({ } root.classList.add(theme); - }, [theme, colorScheme, systemTheme]); + }, [theme, colorScheme, systemTheme, themesReady, allColorSchemes]); const value = { theme,