Compare commits

...

6 Commits

Author SHA1 Message Date
Douwe
736f30e62c
Merge 517c1b76f4 into bcccae7f9c 2026-01-19 13:18:43 +00:00
dhoeben
517c1b76f4 fix linting 2025-12-28 11:20:45 +01:00
dhoeben
067c26f271 Fix linting 2025-12-23 18:17:56 +01:00
dhoeben
8c508b53d0 Fixed security issues 2025-12-23 15:06:02 +01:00
dhoeben
3ca2231e10 Run Prettier 2025-12-23 15:06:02 +01:00
dhoeben
90c61eeff6 Add custom theme support 2025-12-23 15:05:53 +01:00
4 changed files with 101 additions and 8 deletions

View File

@ -5,6 +5,7 @@ import copy
import json import json
import logging import logging
import os import os
import re
import traceback import traceback
import urllib import urllib
from datetime import datetime, timedelta from datetime import datetime, timedelta
@ -32,6 +33,7 @@ from frigate.config.camera.updater import (
CameraConfigUpdateEnum, CameraConfigUpdateEnum,
CameraConfigUpdateTopic, CameraConfigUpdateTopic,
) )
from frigate.const import THEMES_DIR
from frigate.ffmpeg_presets import FFMPEG_HWACCEL_VAAPI, _gpu_selector from frigate.ffmpeg_presets import FFMPEG_HWACCEL_VAAPI, _gpu_selector
from frigate.jobs.media_sync import ( from frigate.jobs.media_sync import (
get_current_media_sync_job, get_current_media_sync_job,
@ -190,6 +192,28 @@ def config(request: Request):
return JSONResponse(content=config) 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"]))]) @router.get("/config/raw_paths", dependencies=[Depends(require_role(["admin"]))])
def config_raw_paths(request: Request): def config_raw_paths(request: Request):
"""Admin-only endpoint that returns camera paths and go2rtc streams without credential masking.""" """Admin-only endpoint that returns camera paths and go2rtc streams without credential masking."""

View File

@ -5,6 +5,7 @@ INSTALL_DIR = "/opt/frigate"
CONFIG_DIR = "/config" CONFIG_DIR = "/config"
DEFAULT_DB_PATH = f"{CONFIG_DIR}/frigate.db" DEFAULT_DB_PATH = f"{CONFIG_DIR}/frigate.db"
MODEL_CACHE_DIR = f"{CONFIG_DIR}/model_cache" MODEL_CACHE_DIR = f"{CONFIG_DIR}/model_cache"
THEMES_DIR = f"{CONFIG_DIR}/themes"
BASE_DIR = "/media/frigate" BASE_DIR = "/media/frigate"
CLIPS_DIR = f"{BASE_DIR}/clips" CLIPS_DIR = f"{BASE_DIR}/clips"
EXPORT_DIR = f"{BASE_DIR}/exports" EXPORT_DIR = f"{BASE_DIR}/exports"

View File

@ -487,11 +487,11 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
{scheme === colorScheme ? ( {scheme === colorScheme ? (
<> <>
<IoColorPalette className="mr-2 size-4 rotate-0 scale-100 transition-all" /> <IoColorPalette className="mr-2 size-4 rotate-0 scale-100 transition-all" />
{t(friendlyColorSchemeName(scheme))} {friendlyColorSchemeName(scheme, t)}
</> </>
) : ( ) : (
<span className="ml-6 mr-2"> <span className="ml-6 mr-2">
{t(friendlyColorSchemeName(scheme))} {friendlyColorSchemeName(scheme, t)}
</span> </span>
)} )}
</MenuItem> </MenuItem>

View File

@ -1,4 +1,5 @@
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 Theme = "dark" | "light" | "system";
type ColorScheme = type ColorScheme =
@ -21,9 +22,22 @@ export const colorSchemes: ColorScheme[] = [
// Helper function to generate friendly color scheme names // Helper function to generate friendly color scheme names
// eslint-disable-next-line react-refresh/only-export-components // eslint-disable-next-line react-refresh/only-export-components
export const friendlyColorSchemeName = (className: string): string => { export const friendlyColorSchemeName = (
const words = className.split("-").slice(1); // Exclude the first word (e.g., 'theme') className: string,
return "menu.theme." + words.join(""); 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 = { type ThemeProviderProps = {
@ -51,6 +65,9 @@ const initialState: ThemeProviderState = {
const ThemeProviderContext = createContext<ThemeProviderState>(initialState); const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
const fetcher = (url: string) =>
fetch(url).then((res) => (res.ok ? res.json() : []));
export function ThemeProvider({ export function ThemeProvider({
children, children,
defaultTheme = "system", defaultTheme = "system",
@ -92,13 +109,64 @@ export function ThemeProvider({
: "light"; : "light";
}, [theme]); }, [theme]);
const { data: customFiles } = useSWR<string[]>(
"/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<void>((resolve) => {
link.onload = () => resolve();
link.onerror = () => resolve();
});
});
Promise.all(links).then(() => setThemesReady(true));
}, [customFiles]);
useEffect(() => { useEffect(() => {
//localStorage.removeItem(storageKey); //localStorage.removeItem(storageKey);
//console.log(localStorage.getItem(storageKey)); //console.log(localStorage.getItem(storageKey));
if (!themesReady) {
return;
}
const root = window.document.documentElement; const root = window.document.documentElement;
root.classList.remove("light", "dark", "system", ...colorSchemes); root.classList.remove("light", "dark", "system", ...allColorSchemes);
root.classList.add(theme, colorScheme); root.classList.add(theme, colorScheme);
if (systemTheme) { if (systemTheme) {
@ -107,7 +175,7 @@ export function ThemeProvider({
} }
root.classList.add(theme); root.classList.add(theme);
}, [theme, colorScheme, systemTheme]); }, [theme, colorScheme, systemTheme, themesReady, allColorSchemes]);
const value = { const value = {
theme, theme,