Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions frigate/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import json
import logging
import os
import re
import traceback
import urllib
from datetime import datetime, timedelta
Expand Down Expand Up @@ -32,6 +33,7 @@
CameraConfigUpdateEnum,
CameraConfigUpdateTopic,
)
from frigate.const import THEMES_DIR
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
Expand Down Expand Up @@ -184,6 +186,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."""
Expand Down
1 change: 1 addition & 0 deletions frigate/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
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"
Expand Down
4 changes: 2 additions & 2 deletions web/src/components/menu/GeneralSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -487,11 +487,11 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
{scheme === colorScheme ? (
<>
<IoColorPalette className="mr-2 size-4 rotate-0 scale-100 transition-all" />
{t(friendlyColorSchemeName(scheme))}
{friendlyColorSchemeName(scheme, t)}
</>
) : (
<span className="ml-6 mr-2">
{t(friendlyColorSchemeName(scheme))}
{friendlyColorSchemeName(scheme, t)}
</span>
)}
</MenuItem>
Expand Down
80 changes: 74 additions & 6 deletions web/src/context/theme-provider.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createContext, useContext, useEffect, useMemo, useState } from "react";
import useSWR from "swr";

type Theme = "dark" | "light" | "system";
type ColorScheme =
Expand All @@ -21,9 +22,22 @@

// 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,

Check failure on line 27 in web/src/context/theme-provider.tsx

View workflow job for this annotation

GitHub Actions / Web - Lint

Unexpected any. Specify a different type
): 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 = {
Expand Down Expand Up @@ -51,6 +65,9 @@

const ThemeProviderContext = createContext<ThemeProviderState>(initialState);

const fetcher = (url: string) =>
fetch(url).then((res) => (res.ok ? res.json() : []));

export function ThemeProvider({
children,
defaultTheme = "system",
Expand Down Expand Up @@ -92,13 +109,64 @@
: "light";
}, [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(() => {
//localStorage.removeItem(storageKey);
//console.log(localStorage.getItem(storageKey));
const root = window.document.documentElement;
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) {
Expand All @@ -107,7 +175,7 @@
}

root.classList.add(theme);
}, [theme, colorScheme, systemTheme]);
}, [theme, colorScheme, systemTheme, themesReady, allColorSchemes]);

const value = {
theme,
Expand Down
Loading