diff --git a/ui/src/components/Navbar.tsx b/ui/src/components/Navbar.tsx index cbbfe296..bade4dcd 100644 --- a/ui/src/components/Navbar.tsx +++ b/ui/src/components/Navbar.tsx @@ -25,7 +25,7 @@ import { faCalendar, faClock } from "@fortawesome/free-regular-svg-icons"; import logo from "assets/logo.svg"; import { useTranslation } from "react-i18next"; import { NavLink, useParams } from "react-router-dom"; -import { useDarkMode } from "usehooks-ts"; +import { useDarkMode } from "utils/useDarkMode"; import { UserContext } from "utils"; import { useDate } from "utils/useDate"; diff --git a/ui/src/utils/useDarkMode.tsx b/ui/src/utils/useDarkMode.tsx new file mode 100644 index 00000000..c6670276 --- /dev/null +++ b/ui/src/utils/useDarkMode.tsx @@ -0,0 +1,47 @@ +import { useLocalStorage } from "./useLocalStorage"; +import { useMediaQuery } from "./useMediaQuery"; + +const COLOR_SCHEME_QUERY = "(prefers-color-scheme: dark)"; +const LOCAL_STORAGE_KEY = "usehooks-ts-dark-mode"; + +type DarkModeOptions = { + defaultValue?: boolean; + localStorageKey?: string; + initializeWithValue?: boolean; +}; + +type DarkModeReturn = { + isDarkMode: boolean; + toggle: () => void; + enable: () => void; + disable: () => void; + set: (value: boolean) => void; +}; + +export function useDarkMode(options: DarkModeOptions = { initializeWithValue: true }): DarkModeReturn { + const { defaultValue, localStorageKey = LOCAL_STORAGE_KEY, initializeWithValue = true } = options; + + const isDarkOS = useMediaQuery(COLOR_SCHEME_QUERY, { + initializeWithValue, + defaultValue, + }); + const [isDarkMode, setDarkMode] = useLocalStorage(localStorageKey, defaultValue ?? isDarkOS ?? false, { + initializeWithValue, + }); + + return { + isDarkMode, + toggle: () => { + setDarkMode((prev) => !prev); + }, + enable: () => { + setDarkMode(true); + }, + disable: () => { + setDarkMode(false); + }, + set: (value) => { + setDarkMode(value); + }, + }; +} diff --git a/ui/src/utils/useLocalStorage.tsx b/ui/src/utils/useLocalStorage.tsx index 27750e09..e73ee232 100644 --- a/ui/src/utils/useLocalStorage.tsx +++ b/ui/src/utils/useLocalStorage.tsx @@ -1,22 +1,168 @@ -import { Dispatch, SetStateAction, useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; -function getStorageValue(key: string, defaultValue: S): S { - // getting stored value - const saved = localStorage.getItem(key); - const initial = saved && JSON.parse(saved); - return initial || defaultValue; -} -function useLocalStorage(key: string, defaultValue: S): [S, Dispatch>] { - const [value, setValue] = useState(() => { - return getStorageValue(key, defaultValue); - }); +import type { Dispatch, SetStateAction } from "react"; + +import { useEventCallback } from "usehooks-ts"; +import { useEventListener } from "usehooks-ts"; - useEffect(() => { - // storing input name - localStorage.setItem(key, JSON.stringify(value)); - }, [key, value]); +declare global { + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions + interface WindowEventMap { + "local-storage": CustomEvent; + } +} - return [value, setValue]; +/** + * Options for customizing the behavior of serialization and deserialization. + * @template T - The type of the state to be stored in local storage. + */ +type UseLocalStorageOptions = { + /** A function to serialize the value before storing it. */ + serializer?: (value: T) => string; + /** A function to deserialize the stored value. */ + deserializer?: (value: string) => T; + /** + * If `true` (default), the hook will initialize reading the local storage. In SSR, you should set it to `false`, returning the initial value initially. + * @default true + */ + initializeWithValue?: boolean; }; -export default useLocalStorage \ No newline at end of file +const IS_SERVER = typeof window === "undefined"; + +/** + * Custom hook that uses local storage to persist state across page reloads. + * @template T - The type of the state to be stored in local storage. + * @param {string} key - The key under which the value will be stored in local storage. + * @param {T | (() => T)} initialValue - The initial value of the state or a function that returns the initial value. + * @param {UseLocalStorageOptions} [options] - Options for customizing the behavior of serialization and deserialization (optional). + * @returns {[T, Dispatch>]} A tuple containing the stored value and a function to set the value. + * @public + * @see [Documentation](https://usehooks-ts.com/react-hook/use-local-storage) + * @see [MDN Local Storage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) + * @example + * ```tsx + * const [count, setCount] = useLocalStorage('count', 0); + * // Access the `count` value and the `setCount` function to update it. + * ``` + */ +export function useLocalStorage( + key: string, + initialValue: T | (() => T), + options: UseLocalStorageOptions = {}, +): [T, Dispatch>] { + const { initializeWithValue = true } = options; + + const serializer = useCallback<(value: T) => string>( + (value) => { + if (options.serializer) { + return options.serializer(value); + } + + return JSON.stringify(value); + }, + [options], + ); + + const deserializer = useCallback<(value: string) => T>( + (value) => { + if (options.deserializer) { + return options.deserializer(value); + } + // Support 'undefined' as a value + if (value === "undefined") { + return undefined as unknown as T; + } + + const defaultValue = initialValue instanceof Function ? initialValue() : initialValue; + + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch (error) { + console.error("Error parsing JSON:", error); + return defaultValue; // Return initialValue if parsing fails + } + + return parsed as T; + }, + [options, initialValue], + ); + + // Get from local storage then + // parse stored json or return initialValue + const readValue = useCallback((): T => { + const initialValueToUse = initialValue instanceof Function ? initialValue() : initialValue; + + // Prevent build error "window is undefined" but keeps working + if (IS_SERVER) { + return initialValueToUse; + } + + try { + const raw = window.localStorage.getItem(key); + return raw ? deserializer(raw) : initialValueToUse; + } catch (error) { + console.warn(`Error reading localStorage key “${key}”:`, error); + return initialValueToUse; + } + }, [initialValue, key, deserializer]); + + const [storedValue, setStoredValue] = useState(() => { + if (initializeWithValue) { + return readValue(); + } + return initialValue instanceof Function ? initialValue() : initialValue; + }); + + // Return a wrapped version of useState's setter function that ... + // ... persists the new value to localStorage. + const setValue: Dispatch> = useEventCallback((value) => { + // Prevent build error "window is undefined" but keeps working + if (IS_SERVER) { + console.warn(`Tried setting localStorage key “${key}” even though environment is not a client`); + } + + try { + // Allow value to be a function so we have the same API as useState + const newValue = value instanceof Function ? value(readValue()) : value; + + // Save to local storage + window.localStorage.setItem(key, serializer(newValue)); + + // Save state + setStoredValue(newValue); + + // We dispatch a custom event so every similar useLocalStorage hook is notified + window.dispatchEvent(new StorageEvent("local-storage", { key })); + } catch (error) { + console.warn(`Error setting localStorage key “${key}”:`, error); + } + }); + + useEffect(() => { + const value = readValue(); + setValue(value); + setStoredValue(value); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key]); + + const handleStorageChange = useCallback( + (event: StorageEvent | CustomEvent) => { + if ((event as StorageEvent).key && (event as StorageEvent).key !== key) { + return; + } + setStoredValue(readValue()); + }, + [key, readValue], + ); + + // this only works for other documents, not the current one + useEventListener("storage", handleStorageChange); + + // this is a custom event, triggered in writeValueToLocalStorage + // See: useLocalStorage() + useEventListener("local-storage", handleStorageChange); + + return [storedValue, setValue]; +} diff --git a/ui/src/utils/useMediaQuery.tsx b/ui/src/utils/useMediaQuery.tsx index 879ed3b5..268ae0f9 100644 --- a/ui/src/utils/useMediaQuery.tsx +++ b/ui/src/utils/useMediaQuery.tsx @@ -1,27 +1,44 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; -function useMediaQuery(query: string): boolean { +import { useIsomorphicLayoutEffect } from "usehooks-ts"; + +type UseMediaQueryOptions = { + defaultValue?: boolean; + initializeWithValue?: boolean; +}; + +const IS_SERVER = typeof window === "undefined"; + +export function useMediaQuery( + query: string, + { defaultValue = false, initializeWithValue = true }: UseMediaQueryOptions = {}, +): boolean { const getMatches = (query: string): boolean => { - // Prevents SSR issues - if (typeof window !== "undefined") { - return window.matchMedia(query).matches; + if (IS_SERVER) { + return defaultValue; } - return false; + return window.matchMedia(query).matches; }; - const [matches, setMatches] = useState(getMatches(query)); + const [matches, setMatches] = useState(() => { + if (initializeWithValue) { + return getMatches(query); + } + return defaultValue; + }); + // Handles the change event of the media query. function handleChange() { setMatches(getMatches(query)); } - useEffect(() => { + useIsomorphicLayoutEffect(() => { const matchMedia = window.matchMedia(query); // Triggered at the first client-side load and if query changes handleChange(); - // Listen matchMedia + // Use deprecated `addListener` and `removeListener` to support Safari < 14 (#135) if (matchMedia.addListener) { matchMedia.addListener(handleChange); } else { @@ -35,10 +52,7 @@ function useMediaQuery(query: string): boolean { matchMedia.removeEventListener("change", handleChange); } }; - // eslint-disable-next-line react-hooks/exhaustive-deps }, [query]); return matches; } - -export default useMediaQuery;