forked from Github/frigate
@@ -1,196 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import { set as setData } from 'idb-keyval';
|
||||
import { DarkModeProvider, useDarkMode, usePersistence } from '..';
|
||||
import { fireEvent, render, screen } from 'testing-library';
|
||||
import { useCallback } from 'preact/hooks';
|
||||
import * as WS from '../../api/ws';
|
||||
|
||||
function DarkModeChecker() {
|
||||
const { currentMode } = useDarkMode();
|
||||
return <div data-testid={currentMode}>{currentMode}</div>;
|
||||
}
|
||||
|
||||
describe('DarkMode', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(WS, 'WsProvider').mockImplementation(({ children }) => children);
|
||||
});
|
||||
|
||||
test('uses media by default', async () => {
|
||||
render(
|
||||
<DarkModeProvider>
|
||||
<DarkModeChecker />
|
||||
</DarkModeProvider>
|
||||
);
|
||||
const el = await screen.findByTestId('media');
|
||||
expect(el).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('uses the mode stored in idb - dark', async () => {
|
||||
setData('darkmode', 'dark');
|
||||
render(
|
||||
<DarkModeProvider>
|
||||
<DarkModeChecker />
|
||||
</DarkModeProvider>
|
||||
);
|
||||
const el = await screen.findByTestId('dark');
|
||||
expect(el).toBeInTheDocument();
|
||||
expect(document.body.classList.contains('dark')).toBe(true);
|
||||
});
|
||||
|
||||
test('uses the mode stored in idb - light', async () => {
|
||||
setData('darkmode', 'light');
|
||||
render(
|
||||
<DarkModeProvider>
|
||||
<DarkModeChecker />
|
||||
</DarkModeProvider>
|
||||
);
|
||||
const el = await screen.findByTestId('light');
|
||||
expect(el).toBeInTheDocument();
|
||||
expect(document.body.classList.contains('dark')).toBe(false);
|
||||
});
|
||||
|
||||
test('allows updating the mode', async () => {
|
||||
setData('darkmode', 'dark');
|
||||
|
||||
function Updater() {
|
||||
const { setDarkMode } = useDarkMode();
|
||||
const handleClick = useCallback(() => {
|
||||
setDarkMode('light');
|
||||
}, [setDarkMode]);
|
||||
return <div onClick={handleClick}>click me</div>;
|
||||
}
|
||||
|
||||
render(
|
||||
<DarkModeProvider>
|
||||
<DarkModeChecker />
|
||||
<Updater />
|
||||
</DarkModeProvider>
|
||||
);
|
||||
|
||||
const dark = await screen.findByTestId('dark');
|
||||
expect(dark).toBeInTheDocument();
|
||||
expect(document.body.classList.contains('dark')).toBe(true);
|
||||
|
||||
const button = await screen.findByText('click me');
|
||||
fireEvent.click(button);
|
||||
|
||||
const light = await screen.findByTestId('light');
|
||||
expect(light).toBeInTheDocument();
|
||||
expect(document.body.classList.contains('dark')).toBe(false);
|
||||
});
|
||||
|
||||
test('when using media, matches on preference', async () => {
|
||||
setData('darkmode', 'media');
|
||||
vi.spyOn(window, 'matchMedia').mockImplementation((query) => {
|
||||
if (query === '(prefers-color-scheme: dark)') {
|
||||
return { matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() };
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected query to matchMedia: ${query}`);
|
||||
});
|
||||
render(
|
||||
<DarkModeProvider>
|
||||
<DarkModeChecker />
|
||||
</DarkModeProvider>
|
||||
);
|
||||
|
||||
const el = await screen.findByTestId('dark');
|
||||
expect(el).toBeInTheDocument();
|
||||
expect(document.body.classList.contains('dark')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('usePersistence', () => {
|
||||
test('returns a defaultValue initially', async () => {
|
||||
function Component() {
|
||||
const [value, , loaded] = usePersistence('tacos', 'my-default');
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="loaded">{loaded ? 'loaded' : 'not loaded'}</div>
|
||||
<div data-testid="value">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render(<Component />);
|
||||
|
||||
expect(screen.getByTestId('loaded')).toMatchInlineSnapshot(`
|
||||
<div
|
||||
data-testid="loaded"
|
||||
>
|
||||
not loaded
|
||||
</div>
|
||||
`);
|
||||
expect(screen.getByTestId('value')).toMatchInlineSnapshot(`
|
||||
<div
|
||||
data-testid="value"
|
||||
>
|
||||
my-default
|
||||
</div>
|
||||
`);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/no-disabled-tests
|
||||
test.skip('updates with the previously-persisted value', async () => {
|
||||
setData('tacos', 'are delicious');
|
||||
|
||||
function Component() {
|
||||
const [value, , loaded] = usePersistence('tacos', 'my-default');
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="loaded">{loaded ? 'loaded' : 'not loaded'}</div>
|
||||
<div data-testid="value">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render(<Component />);
|
||||
|
||||
await screen.findByText('loaded');
|
||||
|
||||
expect(screen.getByTestId('loaded')).toMatchInlineSnapshot(`
|
||||
<div
|
||||
data-testid="loaded"
|
||||
>
|
||||
loaded
|
||||
</div>
|
||||
`);
|
||||
expect(screen.getByTestId('value')).toMatchInlineSnapshot(`
|
||||
<div
|
||||
data-testid="value"
|
||||
>
|
||||
are delicious
|
||||
</div>
|
||||
`);
|
||||
});
|
||||
|
||||
test('can be updated manually', async () => {
|
||||
setData('darkmode', 'are delicious');
|
||||
|
||||
function Component() {
|
||||
const [value, setValue] = usePersistence('tacos', 'my-default');
|
||||
const handleClick = useCallback(() => {
|
||||
setValue('super delicious');
|
||||
}, [setValue]);
|
||||
return (
|
||||
<div>
|
||||
<div onClick={handleClick}>click me</div>
|
||||
<div data-testid="value">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render(<Component />);
|
||||
|
||||
const button = await screen.findByText('click me');
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(screen.getByTestId('value')).toMatchInlineSnapshot(`
|
||||
<div
|
||||
data-testid="value"
|
||||
>
|
||||
super delicious
|
||||
</div>
|
||||
`);
|
||||
});
|
||||
});
|
||||
@@ -1,111 +0,0 @@
|
||||
import { h, createContext } from 'preact';
|
||||
import { get as getData, set as setData } from 'idb-keyval';
|
||||
import { useCallback, useContext, useEffect, useLayoutEffect, useState } from 'preact/hooks';
|
||||
|
||||
const DarkMode = createContext(null);
|
||||
|
||||
export function DarkModeProvider({ children }) {
|
||||
const [persistedMode, setPersistedMode] = useState(null);
|
||||
const [currentMode, setCurrentMode] = useState(persistedMode !== 'media' ? persistedMode : null);
|
||||
|
||||
const setDarkMode = useCallback(
|
||||
(value) => {
|
||||
setPersistedMode(value);
|
||||
setData('darkmode', value);
|
||||
setCurrentMode(value);
|
||||
},
|
||||
[setPersistedMode]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const darkmode = await getData('darkmode');
|
||||
setDarkMode(darkmode || 'media');
|
||||
}
|
||||
|
||||
load();
|
||||
}, [setDarkMode]);
|
||||
|
||||
const handleMediaMatch = useCallback(
|
||||
({ matches }) => {
|
||||
if (matches) {
|
||||
setCurrentMode('dark');
|
||||
} else {
|
||||
setCurrentMode('light');
|
||||
}
|
||||
},
|
||||
[setCurrentMode]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (persistedMode !== 'media') {
|
||||
return;
|
||||
}
|
||||
|
||||
const query = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
query.addEventListener('change', handleMediaMatch);
|
||||
handleMediaMatch(query);
|
||||
}, [persistedMode, handleMediaMatch]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (currentMode === 'dark') {
|
||||
document.body.classList.add('dark');
|
||||
} else {
|
||||
document.body.classList.remove('dark');
|
||||
}
|
||||
}, [currentMode]);
|
||||
|
||||
return !persistedMode ? null : (
|
||||
<DarkMode.Provider value={{ currentMode, persistedMode, setDarkMode }}>{children}</DarkMode.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDarkMode() {
|
||||
return useContext(DarkMode);
|
||||
}
|
||||
|
||||
const Drawer = createContext(null);
|
||||
|
||||
export function DrawerProvider({ children }) {
|
||||
const [showDrawer, setShowDrawer] = useState(false);
|
||||
|
||||
return <Drawer.Provider value={{ showDrawer, setShowDrawer }}>{children}</Drawer.Provider>;
|
||||
}
|
||||
|
||||
export function useDrawer() {
|
||||
return useContext(Drawer);
|
||||
}
|
||||
|
||||
export function usePersistence(key, defaultValue = undefined) {
|
||||
const [value, setInternalValue] = useState(defaultValue);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
const setValue = useCallback(
|
||||
(value) => {
|
||||
setInternalValue(value);
|
||||
async function update() {
|
||||
await setData(key, value);
|
||||
}
|
||||
|
||||
update();
|
||||
},
|
||||
[key]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setLoaded(false);
|
||||
setInternalValue(defaultValue);
|
||||
|
||||
async function load() {
|
||||
const value = await getData(key);
|
||||
if (typeof value !== 'undefined') {
|
||||
setValue(value);
|
||||
}
|
||||
setLoaded(true);
|
||||
}
|
||||
|
||||
load();
|
||||
}, [key, defaultValue, setValue]);
|
||||
|
||||
return [value, setValue, loaded];
|
||||
}
|
||||
25
web/src/context/providers.tsx
Normal file
25
web/src/context/providers.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { ReactNode } from "react";
|
||||
import { ThemeProvider } from "@/context/theme-provider";
|
||||
import { RecoilRoot } from "recoil";
|
||||
import { ApiProvider } from "@/api";
|
||||
import { IconContext } from "react-icons";
|
||||
|
||||
type TProvidersProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function providers({ children }: TProvidersProps) {
|
||||
return (
|
||||
<RecoilRoot>
|
||||
<ApiProvider>
|
||||
<ThemeProvider defaultTheme="light" storageKey="frigate-ui-theme">
|
||||
<IconContext.Provider value={{ size: "20" }}>
|
||||
{children}
|
||||
</IconContext.Provider>
|
||||
</ThemeProvider>
|
||||
</ApiProvider>
|
||||
</RecoilRoot>
|
||||
);
|
||||
}
|
||||
|
||||
export default providers;
|
||||
136
web/src/context/theme-provider.tsx
Normal file
136
web/src/context/theme-provider.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
type Theme = "dark" | "light" | "system";
|
||||
type ColorScheme =
|
||||
| "theme-blue"
|
||||
| "theme-gold"
|
||||
| "theme-green"
|
||||
| "theme-nature"
|
||||
| "theme-netflix"
|
||||
| "theme-nord"
|
||||
| "theme-orange"
|
||||
| "theme-red"
|
||||
| "theme-default";
|
||||
|
||||
export const colorSchemes: ColorScheme[] = [
|
||||
"theme-blue",
|
||||
"theme-gold",
|
||||
"theme-green",
|
||||
"theme-nature",
|
||||
"theme-netflix",
|
||||
"theme-nord",
|
||||
"theme-orange",
|
||||
"theme-red",
|
||||
"theme-default",
|
||||
];
|
||||
|
||||
// Helper function to generate friendly color scheme names
|
||||
export const friendlyColorSchemeName = (className: string): string => {
|
||||
const words = className.split("-").slice(1); // Exclude the first word (e.g., 'theme')
|
||||
return words
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
};
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode;
|
||||
defaultTheme?: Theme;
|
||||
defaultColorScheme?: ColorScheme;
|
||||
storageKey?: string;
|
||||
};
|
||||
|
||||
type ThemeProviderState = {
|
||||
theme: Theme;
|
||||
colorScheme: ColorScheme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
setColorScheme: (colorScheme: ColorScheme) => void;
|
||||
};
|
||||
|
||||
const initialState: ThemeProviderState = {
|
||||
theme: "system",
|
||||
colorScheme: "theme-default",
|
||||
setTheme: () => null,
|
||||
setColorScheme: () => null,
|
||||
};
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = "system",
|
||||
defaultColorScheme = "theme-default",
|
||||
storageKey = "frigate-ui-theme",
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
try {
|
||||
const storedData = JSON.parse(localStorage.getItem(storageKey) || "{}");
|
||||
return storedData.theme || defaultTheme;
|
||||
} catch (error) {
|
||||
console.error("Error parsing theme data from storage:", error);
|
||||
return defaultTheme;
|
||||
}
|
||||
});
|
||||
|
||||
const [colorScheme, setColorScheme] = useState<ColorScheme>(() => {
|
||||
try {
|
||||
const storedData = JSON.parse(localStorage.getItem(storageKey) || "{}");
|
||||
return storedData.colorScheme === "default"
|
||||
? defaultColorScheme
|
||||
: storedData.colorScheme || defaultColorScheme;
|
||||
} catch (error) {
|
||||
console.error("Error parsing color scheme data from storage:", error);
|
||||
return defaultColorScheme;
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
//localStorage.removeItem(storageKey);
|
||||
//console.log(localStorage.getItem(storageKey));
|
||||
const root = window.document.documentElement;
|
||||
|
||||
root.classList.remove("light", "dark", "system", ...colorSchemes);
|
||||
|
||||
root.classList.add(theme, colorScheme);
|
||||
|
||||
if (theme === "system") {
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
.matches
|
||||
? "dark"
|
||||
: "light";
|
||||
|
||||
root.classList.add(systemTheme);
|
||||
return;
|
||||
}
|
||||
|
||||
root.classList.add(theme);
|
||||
}, [theme, colorScheme]);
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
colorScheme,
|
||||
setTheme: (theme: Theme) => {
|
||||
localStorage.setItem(storageKey, JSON.stringify({ theme, colorScheme }));
|
||||
setTheme(theme);
|
||||
},
|
||||
setColorScheme: (colorScheme: ColorScheme) => {
|
||||
localStorage.setItem(storageKey, JSON.stringify({ theme, colorScheme }));
|
||||
setColorScheme(colorScheme);
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeProviderContext);
|
||||
|
||||
if (context === undefined)
|
||||
throw new Error("useTheme must be used within a ThemeProvider");
|
||||
|
||||
return context;
|
||||
};
|
||||
Reference in New Issue
Block a user