Skip to content

Commit

Permalink
fix(ui): make darkMode persistent
Browse files Browse the repository at this point in the history
  • Loading branch information
nimdanitro committed Sep 30, 2024
1 parent 29a664e commit 40e4e77
Show file tree
Hide file tree
Showing 4 changed files with 237 additions and 30 deletions.
2 changes: 1 addition & 1 deletion ui/src/components/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
47 changes: 47 additions & 0 deletions ui/src/utils/useDarkMode.tsx
Original file line number Diff line number Diff line change
@@ -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<boolean>(localStorageKey, defaultValue ?? isDarkOS ?? false, {
initializeWithValue,
});

return {
isDarkMode,
toggle: () => {
setDarkMode((prev) => !prev);
},
enable: () => {
setDarkMode(true);
},
disable: () => {
setDarkMode(false);
},
set: (value) => {
setDarkMode(value);
},
};
}
180 changes: 163 additions & 17 deletions ui/src/utils/useLocalStorage.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,168 @@
import { Dispatch, SetStateAction, useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";

function getStorageValue<S>(key: string, defaultValue: S): S {
// getting stored value
const saved = localStorage.getItem(key);
const initial = saved && JSON.parse(saved);
return initial || defaultValue;
}
function useLocalStorage<S>(key: string, defaultValue: S): [S, Dispatch<SetStateAction<S>>] {
const [value, setValue] = useState<S>(() => {
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<T> = {
/** 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
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<T>} [options] - Options for customizing the behavior of serialization and deserialization (optional).
* @returns {[T, Dispatch<SetStateAction<T>>]} 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<T>(
key: string,
initialValue: T | (() => T),
options: UseLocalStorageOptions<T> = {},
): [T, Dispatch<SetStateAction<T>>] {
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<SetStateAction<T>> = 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];
}
38 changes: 26 additions & 12 deletions ui/src/utils/useMediaQuery.tsx
Original file line number Diff line number Diff line change
@@ -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<boolean>(getMatches(query));
const [matches, setMatches] = useState<boolean>(() => {
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 {
Expand All @@ -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;

0 comments on commit 40e4e77

Please sign in to comment.