From 90c61eeff66cdd50123d73eeeddd7a06ba6be169 Mon Sep 17 00:00:00 2001 From: dhoeben Date: Sat, 20 Dec 2025 13:08:24 +0100 Subject: [PATCH 1/5] 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); From 3ca2231e1027c654953b30462f4e9b81762e2ab7 Mon Sep 17 00:00:00 2001 From: dhoeben Date: Sun, 21 Dec 2025 09:04:03 +0100 Subject: [PATCH 2/5] Run Prettier --- web/src/context/theme-provider.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/web/src/context/theme-provider.tsx b/web/src/context/theme-provider.tsx index b2cf342d5..0c654708b 100644 --- a/web/src/context/theme-provider.tsx +++ b/web/src/context/theme-provider.tsx @@ -23,7 +23,7 @@ export const colorSchemes: ColorScheme[] = [ // eslint-disable-next-line react-refresh/only-export-components export const friendlyColorSchemeName = ( className: string, - t?: (key: string, options?: any) => string + t?: (key: string, options?: any) => string, ): string => { const words = className.split("-").slice(1); const key = "menu.theme." + words.join(""); @@ -39,7 +39,6 @@ export const friendlyColorSchemeName = ( return t(key, { defaultValue: fallback }); }; - type ThemeProviderProps = { children: React.ReactNode; defaultTheme?: Theme; @@ -134,9 +133,7 @@ export function ThemeProvider({ colorSchemes.push(className); } - if ( - !document.querySelector(`link[data-theme="${className}"]`) - ) { + if (!document.querySelector(`link[data-theme="${className}"]`)) { const link = document.createElement("link"); link.rel = "stylesheet"; link.href = `/config/themes/${file}`; @@ -150,7 +147,6 @@ export function ThemeProvider({ } root.classList.remove("light", "dark", "system", ...colorSchemes); - root.classList.add(theme, colorScheme); if (systemTheme) { From 8c508b53d02f5424b02bc04ba8ef24141972a4ed Mon Sep 17 00:00:00 2001 From: dhoeben Date: Tue, 23 Dec 2025 11:27:34 +0100 Subject: [PATCH 3/5] Fixed security issues --- frigate/api/app.py | 4 ++ web/src/context/theme-provider.tsx | 104 ++++++++++++++++++----------- 2 files changed, 69 insertions(+), 39 deletions(-) diff --git a/frigate/api/app.py b/frigate/api/app.py index d2e4e5be6..84e01a465 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 @@ -199,6 +200,9 @@ def config_themes(): 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) diff --git a/web/src/context/theme-provider.tsx b/web/src/context/theme-provider.tsx index 0c654708b..b7f786e0e 100644 --- a/web/src/context/theme-provider.tsx +++ b/web/src/context/theme-provider.tsx @@ -1,4 +1,11 @@ -import { createContext, useContext, useEffect, useMemo, useState } from "react"; +import { + createContext, + useContext, + useEffect, + useMemo, + useState, +} from "react"; +import useSWR from "swr"; type Theme = "dark" | "light" | "system"; type ColorScheme = @@ -64,6 +71,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", @@ -105,48 +115,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)); - 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(() => { - }); + if (!themesReady) { + return; } - root.classList.remove("light", "dark", "system", ...colorSchemes); + const root = window.document.documentElement; + + root.classList.remove("light", "dark", "system", ...allColorSchemes); root.classList.add(theme, colorScheme); if (systemTheme) { @@ -155,7 +181,7 @@ export function ThemeProvider({ } root.classList.add(theme); - }, [theme, colorScheme, systemTheme]); + }, [theme, colorScheme, systemTheme, themesReady, allColorSchemes]); const value = { theme, From 067c26f2717aa3640172fdbadcb3118ad2d9ee17 Mon Sep 17 00:00:00 2001 From: dhoeben Date: Tue, 23 Dec 2025 18:17:56 +0100 Subject: [PATCH 4/5] Fix linting --- frigate/api/app.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/frigate/api/app.py b/frigate/api/app.py index 84e01a465..50012ae88 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -33,11 +33,8 @@ 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.ffmpeg_presets import FFMPEG_HWACCEL_VAAPI, _gpu_selector from frigate.models import Event, Timeline from frigate.stats.prometheus import get_metrics, update_metrics from frigate.util.builtin import ( @@ -188,6 +185,7 @@ def config(request: Request): return JSONResponse(content=config) + @router.get("/config/themes") def config_themes(): themes_dir = THEMES_DIR @@ -209,6 +207,7 @@ def config_themes(): 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.""" From 517c1b76f4f7c84638b502d0cc3c2e5733e5e6ed Mon Sep 17 00:00:00 2001 From: dhoeben Date: Sun, 28 Dec 2025 11:20:45 +0100 Subject: [PATCH 5/5] fix linting --- web/src/context/theme-provider.tsx | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/web/src/context/theme-provider.tsx b/web/src/context/theme-provider.tsx index b7f786e0e..66660bee6 100644 --- a/web/src/context/theme-provider.tsx +++ b/web/src/context/theme-provider.tsx @@ -1,10 +1,4 @@ -import { - createContext, - useContext, - useEffect, - useMemo, - useState, -} from "react"; +import { createContext, useContext, useEffect, useMemo, useState } from "react"; import useSWR from "swr"; type Theme = "dark" | "light" | "system"; @@ -130,9 +124,9 @@ export function ThemeProvider({ ?.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 ( + base.startsWith("theme-") ? base : `theme-${base}` + ) as ColorScheme; }) ?? []; return [...colorSchemes, ...customSchemes];