From 90c61eeff66cdd50123d73eeeddd7a06ba6be169 Mon Sep 17 00:00:00 2001 From: dhoeben Date: Sat, 20 Dec 2025 13:08:24 +0100 Subject: [PATCH] Add custom theme support --- frigate/api/app.py | 21 ++++++++ frigate/const.py | 1 + web/src/components/menu/GeneralSettings.tsx | 4 +- web/src/context/theme-provider.tsx | 58 +++++++++++++++++++-- 4 files changed, 79 insertions(+), 5 deletions(-) diff --git a/frigate/api/app.py b/frigate/api/app.py index fb32529ea..d2e4e5be6 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -32,7 +32,11 @@ from frigate.config.camera.updater import ( CameraConfigUpdateEnum, CameraConfigUpdateTopic, ) +<<<<<<< HEAD from frigate.ffmpeg_presets import FFMPEG_HWACCEL_VAAPI, _gpu_selector +======= +from frigate.const import THEMES_DIR +>>>>>>> a3cdd1b1 (Add custom theme support) from frigate.models import Event, Timeline from frigate.stats.prometheus import get_metrics, update_metrics from frigate.util.builtin import ( @@ -183,6 +187,23 @@ 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 + + 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): diff --git a/frigate/const.py b/frigate/const.py index 11e89886f..3fb077e5a 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..b2cf342d5 100644 --- a/web/src/context/theme-provider.tsx +++ b/web/src/context/theme-provider.tsx @@ -21,11 +21,25 @@ 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 = { children: React.ReactNode; defaultTheme?: Theme; @@ -97,6 +111,44 @@ export function ThemeProvider({ //console.log(localStorage.getItem(storageKey)); const root = window.document.documentElement; + if (!(window as any).__frigateThemesLoaded) { + (window as any).__frigateThemesLoaded = true; + + fetch("/api/config/themes") + .then((res) => (res.ok ? res.json() : [])) + .then((files: string[]) => { + files.forEach((file) => { + if (!file.endsWith(".css")) { + return; + } + + const baseName = file.replace(/\.css$/, ""); + const className = baseName.startsWith("theme-") + ? baseName + : `theme-${baseName}`; + + if (!colorSchemes.includes(className as ColorScheme)) { + // runtime extension is intentional + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + colorSchemes.push(className); + } + + if ( + !document.querySelector(`link[data-theme="${className}"]`) + ) { + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = `/config/themes/${file}`; + link.dataset.theme = className; + document.head.appendChild(link); + } + }); + }) + .catch(() => { + }); + } + root.classList.remove("light", "dark", "system", ...colorSchemes); root.classList.add(theme, colorScheme);