refactor: dark mode and support system theme (#908)

This commit is contained in:
Innei 2023-08-16 20:18:52 +08:00 committed by GitHub
parent 90e51a5a26
commit 1da7bbc280
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 195 additions and 292 deletions

View File

@ -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",

View File

@ -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'}

View File

@ -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 (
<script
dangerouslySetInnerHTML={{
__html: `(() => {
return (
<script
dangerouslySetInnerHTML={{
__html: `(() => {
let DARK_MODE_STORAGE_KEY = "${DARK_MODE_STORAGE_KEY}";
let namespace = "xlog"
let getStorage = (key) => {
let data = {}
try {
data = JSON.parse(localStorage.getItem(namespace) || "{}")
} catch (error) {}
return data[key]
}
let COLOR_SCHEME_LIGHT = "${COLOR_SCHEME_LIGHT}";
let COLOR_SCHEME_DARK = "${COLOR_SCHEME_DARK}";
let data = {}
const isDark = getStorage(DARK_MODE_STORAGE_KEY)
const isDark = localStorage.getItem(DARK_MODE_STORAGE_KEY)
if (typeof isDark === "undefined") {
const currentColorScheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
@ -40,9 +23,7 @@ if (typeof isDark === "undefined") {
)
}
})();`,
}}
></script>
)
})
return null
}}
></script>
)
}

View File

@ -115,7 +115,9 @@ export default function RootLayout({
className={colorScheme}
suppressHydrationWarning
>
<ColorSchemeInjector />
<head>
<ColorSchemeInjector />
</head>
<body>
<Providers lang={lang}>
{modal}

View File

@ -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 (
<WagmiConfig config={wagmiConfig}>
<QueryClientProvider client={queryClient}>
<ConnectKitProvider
ipfsLinkToHttpLink={toGateway}
urlComposer={urlComposer}
signInStrategy="simple"
ignoreWalletDisconnectEvent={true}
>
<LangProvider lang={lang}>
<ModalStackProvider>{children}</ModalStackProvider>
</LangProvider>
<ThemeProvider
disableTransitionOnChange
storageKey={DARK_MODE_STORAGE_KEY}
attribute="class"
>
<WagmiConfig config={wagmiConfig}>
<QueryClientProvider client={queryClient}>
<ConnectKitProvider
ipfsLinkToHttpLink={toGateway}
urlComposer={urlComposer}
signInStrategy="simple"
ignoreWalletDisconnectEvent={true}
>
<LangProvider lang={lang}>
<ModalStackProvider>{children}</ModalStackProvider>
</LangProvider>
<NotificationModal
colorScheme={colorScheme}
filter={filterNotificationCharacter}
/>
</ConnectKitProvider>
</QueryClientProvider>
</WagmiConfig>
<NotificationModal
colorScheme={colorScheme}
filter={filterNotificationCharacter}
/>
</ConnectKitProvider>
</QueryClientProvider>
</WagmiConfig>
</ThemeProvider>
)
}

View File

@ -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 (
<Switch
checked={isDark || false}
onClick={(e) => 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`,
)}
>
<span className="sr-only">Switch Dark Mode</span>
<span
className={`${
isDark ? "translate-x-6" : "translate-x-1"
} inline-block h-4 w-4 transform rounded-full bg-white transition`}
/>
{isDark ? (
<i className="icon-[mingcute--moon-line] translate-x-2 scale-75" />
) : (
<i className="icon-[mingcute--sun-line] scale-75 -translate-x-3" />
)}
</Switch>
<div
className="absolute top-[3px] z-[-1] h-[32px] w-[32px] rounded-full bg-zinc-200/80 shadow-[0_1px_2px_0_rgba(127.5,127.5,127.5,.2),_0_1px_3px_0_rgba(127.5,127.5,127.5,.1)] duration-200 bg-blend-multiply transition-[left]"
style={{
left: { light: 4, system: 36, dark: 68 }[theme],
}}
/>
)
}
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 (
<div
role="radiogroup"
className="w-fit-content inline-flex rounded-full border border-slate-200 p-[3px]"
>
<button
aria-label="Switch to light theme"
type="button"
className={styles.base}
onClick={(e) => {
buildThemeTransition(e, "light")
}}
>
<i className="icon-[mingcute--sun-line] scale-75" />
</button>
<button
aria-label="Switch to system theme"
className={styles.base}
type="button"
onClick={(e) => {
buildThemeTransition(e, "system")
}}
>
<i className="icon-[mingcute--computer-line] scale-75" />
</button>
<button
aria-label="Switch to dark theme"
className={styles.base}
type="button"
onClick={(e) => {
buildThemeTransition(e, "dark")
}}
>
<i className="icon-[mingcute--moon-line] scale-75" />
</button>
</div>
)
}
export const DarkModeSwitch = () => {
return (
<div className="relative">
<ThemeSwitcher />
<ThemeIndicator />
</div>
)
}

View File

@ -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<IMediaStore>(() => {
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")
}