From 1da7bbc28062c2b667bab62dc1bad4043b1ef39c Mon Sep 17 00:00:00 2001 From: Innei Date: Wed, 16 Aug 2023 20:18:52 +0800 Subject: [PATCH] refactor: dark mode and support system theme (#908) --- package.json | 1 + pnpm-lock.yaml | 15 ++ src/app/ColorSchemeInjector.tsx | 37 +--- src/app/layout.tsx | 4 +- src/app/providers.tsx | 48 +++-- src/components/common/DarkModeSwitch.tsx | 150 ++++++++++++--- src/hooks/useDarkMode.ts | 232 ++--------------------- 7 files changed, 195 insertions(+), 292 deletions(-) diff --git a/package.json b/package.json index 03c83386..894f4f1e 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,7 @@ "mermaid": "10.3.1", "nanoid": "4.0.2", "next": "13.4.12", + "next-themes": "0.2.1", "node-id3": "0.2.6", "open-graph-scraper": "^6.2.2", "pangu": "4.0.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 991ad330..8328dbed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -206,6 +206,9 @@ dependencies: next: specifier: 13.4.12 version: 13.4.12(@babel/core@7.22.10)(react-dom@18.2.0)(react@18.2.0) + next-themes: + specifier: 0.2.1 + version: 0.2.1(next@13.4.12)(react-dom@18.2.0)(react@18.2.0) node-id3: specifier: 0.2.6 version: 0.2.6 @@ -9214,6 +9217,18 @@ packages: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} dev: true + /next-themes@0.2.1(next@13.4.12)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-B+AKNfYNIzh0vqQQKqQItTS8evEouKD7H5Hj3kmuPERwddR2TxvDSFZuTj6T7Jfn1oyeUyJMydPl1Bkxkh0W7A==} + peerDependencies: + next: '*' + react: '*' + react-dom: '*' + dependencies: + next: 13.4.12(@babel/core@7.22.10)(react-dom@18.2.0)(react@18.2.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /next@13.4.12(@babel/core@7.22.10)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-eHfnru9x6NRmTMcjQp6Nz0J4XH9OubmzOa7CkWL+AUrUxpibub3vWwttjduu9No16dug1kq04hiUUpo7J3m3Xw==} engines: {node: '>=16.8.0'} diff --git a/src/app/ColorSchemeInjector.tsx b/src/app/ColorSchemeInjector.tsx index b6850548..d56fb426 100644 --- a/src/app/ColorSchemeInjector.tsx +++ b/src/app/ColorSchemeInjector.tsx @@ -1,33 +1,16 @@ "use client" -import { useServerInsertedHTML } from "next/navigation" - -import { - COLOR_SCHEME_DARK, - COLOR_SCHEME_LIGHT, - DARK_MODE_STORAGE_KEY, -} from "~/lib/constants" +import { DARK_MODE_STORAGE_KEY } from "~/lib/constants" export const ColorSchemeInjector = () => { - useServerInsertedHTML(() => { - return ( - - ) - }) - return null + }} + > + ) } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index de559f82..8c2f4e85 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -115,7 +115,9 @@ export default function RootLayout({ className={colorScheme} suppressHydrationWarning > - + + + {modal} diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 72ae562b..bd4381be 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -1,5 +1,6 @@ "use client" +import { ThemeProvider } from "next-themes" import { useState } from "react" import { WagmiConfig } from "wagmi" @@ -11,10 +12,11 @@ import { import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { ModalStackProvider } from "~/components/ui/ModalStack" +import { useDarkModeListener } from "~/hooks/useDarkMode" // eslint-disable-next-line import/no-unresolved -import { useDarkMode } from "~/hooks/useDarkMode" import { useMobileLayout } from "~/hooks/useMobileLayout" import { useNProgress } from "~/hooks/useNProgress" +import { DARK_MODE_STORAGE_KEY } from "~/lib/constants" import { APP_NAME, WALLET_CONNECT_V2_PROJECT_ID } from "~/lib/env" import { filterNotificationCharacter } from "~/lib/filter-character" import { toGateway } from "~/lib/ipfs-parser" @@ -42,31 +44,37 @@ export default function Providers({ children: React.ReactNode lang: string }) { - useDarkMode() useMobileLayout() useNProgress() + useDarkModeListener() const [queryClient] = useState(createQueryClient) return ( - - - - - {children} - + + + + + + {children} + - - - - + + + + + ) } diff --git a/src/components/common/DarkModeSwitch.tsx b/src/components/common/DarkModeSwitch.tsx index 5ec40c1e..00e31e7f 100644 --- a/src/components/common/DarkModeSwitch.tsx +++ b/src/components/common/DarkModeSwitch.tsx @@ -1,34 +1,130 @@ "use client" -import { Switch } from "@headlessui/react" +import { useTheme } from "next-themes" +import { MouseEvent } from "react" -import { useDarkModeSwitch, useIsDark } from "~/hooks/useDarkMode" -import { cn } from "~/lib/utils" +import { useIsClient } from "~/hooks/useClient" -export const DarkModeSwitch = () => { - const toggle = useDarkModeSwitch() - const isDark = useIsDark() +const styles = { + base: " rounded-inherit inline-flex h-[32px] w-[32px] items-center justify-center border-0 text-current", +} + +const ThemeIndicator = () => { + const { theme } = useTheme() + + const isClient = useIsClient() + + if (!isClient) return null + if (!theme) return null return ( - toggle(e)} - className={cn( - `${isDark ? "bg-accent" : "bg-gray-200"}`, - ` relative inline-flex h-6 w-11 items-center rounded-full text-base dark:text-always-gray-200 text-always-gray-700 align-middle`, - )} - > - Switch Dark Mode - - - {isDark ? ( - - ) : ( - - )} - +
+ ) +} + +const ThemeSwitcher = () => { + const { setTheme } = useTheme() + + const buildThemeTransition = ( + e: MouseEvent, + theme: "light" | "dark" | "system", + ) => { + return setTheme(theme) + // disable in https://github.com/Crossbell-Box/xLog/commit/06713c901e90b57de6f5f5a0252d2c9dd874335f + if ( + !("startViewTransition" in document) || + window.matchMedia(`(prefers-reduced-motion: reduce)`).matches + ) { + setTheme(theme) + return + } + + const $document = document.documentElement + + const { x, y } = e.currentTarget.getBoundingClientRect() + + const endRadius = Math.hypot( + Math.max(x, window.innerWidth - x), + Math.max(y, window.innerHeight - y), + ) + + document + .startViewTransition(() => { + return Promise.resolve() + }) + ?.ready.then(() => { + setTheme(theme) + + if (x === 0) return + const clipPath = [ + `circle(0px at ${x}px ${y}px)`, + `circle(${endRadius}px at ${x}px ${y}px)`, + ] + + $document.animate( + { + clipPath, + }, + { + duration: 300, + easing: "ease-in", + pseudoElement: "::view-transition-new(root)", + // pseudoElement: darkMode + // ? "::view-transition-old(root)" + // : "::view-transition-new(root)", + }, + ) + }) + } + + return ( +
+ + + +
+ ) +} + +export const DarkModeSwitch = () => { + return ( +
+ + +
) } diff --git a/src/hooks/useDarkMode.ts b/src/hooks/useDarkMode.ts index 2214cce8..28c428ce 100644 --- a/src/hooks/useDarkMode.ts +++ b/src/hooks/useDarkMode.ts @@ -1,231 +1,31 @@ -import { MouseEvent, useEffect, useRef, useState } from "react" -import { create } from "zustand" +import { useTheme } from "next-themes" +import { useEffect } from "react" -import { - COLOR_SCHEME_DARK, - COLOR_SCHEME_LIGHT, - DARK_MODE_STORAGE_KEY, - DEFAULT_COLOR_SCHEME, - IS_DEV, -} from "~/lib/constants" +import { COLOR_SCHEME_DARK, COLOR_SCHEME_LIGHT, IS_DEV } from "~/lib/constants" import { OUR_DOMAIN } from "~/lib/env" -import { noop } from "~/lib/noop" -import { delStorage, getStorage, setStorage } from "~/lib/storage" -import { isServerSide } from "~/lib/utils" - -import { useGetState } from "./useGetState" - -interface IMediaStore { - isDark: boolean - toggle: (e: MouseEvent) => void -} - -const useMediaStore = create(() => { - return { - isDark: DEFAULT_COLOR_SCHEME === COLOR_SCHEME_DARK, - toggle: () => void 0, - } -}) - -interface DarkModeConfig { - classNameDark?: string // A className to set "dark mode". Default = "dark". - classNameLight?: string // A className to set "light mode". Default = "light". - element?: HTMLElement | undefined | null // The element to apply the className. Default = `document.body`. - transition?: ViewTransition | undefined // Specify the `animate` when switching the mode. Only Chromium >= 111 etc. -} - -const useDarkModeInternal = ( - initialState: boolean | undefined, - options: DarkModeConfig, -) => { - const { - classNameDark = COLOR_SCHEME_DARK, - classNameLight = COLOR_SCHEME_LIGHT, - element, - transition, - } = options - - const [darkMode, setDarkMode] = useState(initialState) - const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 }) +export const useDarkModeListener = () => { + const { theme, systemTheme } = useTheme() useEffect(() => { - const presentedDarkMode = - !isServerSide() && getStorage(DARK_MODE_STORAGE_KEY) + if (theme === "system") { + // delete cookie + document.cookie = IS_DEV + ? `color_scheme=;` + : `color_scheme=; Domain=.${OUR_DOMAIN}; Path=/; Secure; expires=Thu, 01 Jan 1970 00:00:00 GMT` - if (presentedDarkMode !== undefined) { - setDarkMode(presentedDarkMode === "true") - } else if (typeof initialState === "undefined") { - setDarkMode(window.matchMedia("(prefers-color-scheme: dark)").matches) - } - }, []) - - useEffect(() => { - const handler = (e: MediaQueryListEvent) => { - const storageValue = getStorage(DARK_MODE_STORAGE_KEY) - const parseStorageValueAsBool = storageValue === "true" - setDarkMode(e.matches) - - // reset dark mode, follow system - if (parseStorageValueAsBool === e.matches) { - delStorage(DARK_MODE_STORAGE_KEY) - } - } - - const storageHandler = () => { - const storageValue = getStorage(DARK_MODE_STORAGE_KEY, true) - // if not storage color mode, switch to follow system - if (storageValue === undefined) { - setDarkMode(window.matchMedia("(prefers-color-scheme: dark)").matches) - } else { - // make multiple pages to switch to dark mode together. - setDarkMode(storageValue === "true") - } - } - - window.addEventListener("storage", storageHandler) - window - .matchMedia("(prefers-color-scheme: dark)") - .addEventListener("change", handler) - - return () => { - window.removeEventListener("storage", storageHandler) - window - .matchMedia("(prefers-color-scheme: dark)") - .removeEventListener("change", handler) - } - }, []) - - const getDarkMode = useGetState(darkMode) - useEffect(() => { - const handler = () => { - // if set color mode follow system, del storage - if ( - window.matchMedia("(prefers-color-scheme: dark)").matches === - getDarkMode() - ) { - delStorage(DARK_MODE_STORAGE_KEY) - } - } - window.addEventListener("beforeunload", handler) - - return () => { - window.removeEventListener("beforeunload", handler) - } - }, []) - useEffect(() => { - if (isServerSide() || typeof darkMode === "undefined") { return } - const $document = element || document.documentElement - const setDarkModeClass = () => { - if (darkMode) { - $document.classList.remove(classNameLight) - $document.classList.add(classNameDark) - } else { - $document.classList.remove(classNameDark) - $document.classList.add(classNameLight) - } - } - - const { x, y } = mousePosition - const endRadius = Math.hypot( - Math.max(x, innerWidth - x), - Math.max(y, innerHeight - y), - ) - transition?.ready.then(() => { - setDarkModeClass() - if (mousePosition.x === 0) return - const clipPath = [ - `circle(0px at ${x}px ${y}px)`, - `circle(${endRadius}px at ${x}px ${y}px)`, - ] - $document.animate( - { - clipPath: !darkMode ? clipPath : [...clipPath].reverse(), - }, - { - duration: 400, - easing: "ease-in-out", - pseudoElement: darkMode - ? "::view-transition-old(root)" - : "::view-transition-new(root)", - }, - ) - }) ?? setDarkModeClass() - }, [classNameDark, classNameLight, darkMode, element]) - - if (isServerSide()) { - return { - toggle: () => {}, - value: false, - } - } - - return { - value: darkMode, - toggle: (e: MouseEvent) => { - setDarkMode((d) => { - if (DARK_MODE_STORAGE_KEY && !isServerSide()) { - setStorage(DARK_MODE_STORAGE_KEY, String(!d)) - setMousePosition({ x: e.clientX, y: e.clientY }) - } - return !d - }) - }, - } -} - -const mockElement = { - classList: { - add: noop, - remove: noop, - }, -} - -export const useDarkMode = () => { - const { toggle, value } = useDarkModeInternal( - getStorage(DARK_MODE_STORAGE_KEY) === undefined - ? undefined - : getStorage(DARK_MODE_STORAGE_KEY) === "true", - { - classNameDark: COLOR_SCHEME_DARK, - classNameLight: COLOR_SCHEME_LIGHT, - element: (globalThis.document && document.documentElement) || mockElement, - // transition: - // !isServerSide() && - // !!document.startViewTransition && - // !window.matchMedia(`(prefers-reduced-motion: reduce)`).matches - // ? document.startViewTransition() - // : undefined, - }, - ) - - useEffect(() => { - useMediaStore.setState({ - isDark: value, - }) - const colorScheme = value ? COLOR_SCHEME_DARK : COLOR_SCHEME_LIGHT + const isDarkMode = theme === "dark" + const colorScheme = isDarkMode ? COLOR_SCHEME_DARK : COLOR_SCHEME_LIGHT const date = new Date() date.setMonth(date.getMonth() + 1) document.cookie = IS_DEV ? `color_scheme=${colorScheme};` : `color_scheme=${colorScheme}; Domain=.${OUR_DOMAIN}; Path=/; Secure; expires=${date.toUTCString()}` - }, [value]) - - const onceRef = useRef(false) - if (!onceRef.current) { - onceRef.current = true - useMediaStore.setState({ toggle }) - } - - return { - toggle, - value, - } + }, [theme, systemTheme]) } -export const useIsDark = () => useMediaStore((state) => state.isDark) - -export const useDarkModeSwitch = () => { - return useMediaStore((state) => state.toggle) +export const useIsDark = () => { + const { theme, systemTheme } = useTheme() + return theme === "dark" || (theme === "system" && systemTheme === "dark") }