From 90f89ab2fdeda0e81729d959b8114911b09e61ad Mon Sep 17 00:00:00 2001 From: Kirill Chernakov Date: Wed, 4 Dec 2024 12:41:28 +0400 Subject: [PATCH] feat(ui): dark mode detection (#2739) --- keep-ui/app/(keep)/layout.tsx | 5 ++ keep-ui/app/dark-mode-toggle.tsx | 59 ------------------ keep-ui/app/globals.css | 20 ++++++ keep-ui/components/LinkWithIcon.tsx | 14 +++-- keep-ui/components/navbar/UserInfo.tsx | 60 +++++++++--------- .../ui/DropdownMenu/DropdownMenu.tsx | 8 ++- keep-ui/shared/constants.ts | 1 + keep-ui/shared/ui/theme/ThemeControl.tsx | 61 +++++++++++++++++++ keep-ui/shared/ui/theme/ThemeScript.tsx | 30 +++++++++ keep-ui/shared/ui/theme/WatchUpdateTheme.ts | 47 ++++++++++++++ 10 files changed, 212 insertions(+), 93 deletions(-) delete mode 100644 keep-ui/app/dark-mode-toggle.tsx create mode 100644 keep-ui/shared/constants.ts create mode 100644 keep-ui/shared/ui/theme/ThemeControl.tsx create mode 100644 keep-ui/shared/ui/theme/ThemeScript.tsx create mode 100644 keep-ui/shared/ui/theme/WatchUpdateTheme.ts diff --git a/keep-ui/app/(keep)/layout.tsx b/keep-ui/app/(keep)/layout.tsx index a27212031..22b57b4c0 100644 --- a/keep-ui/app/(keep)/layout.tsx +++ b/keep-ui/app/(keep)/layout.tsx @@ -11,8 +11,10 @@ import { PHProvider } from "../posthog-provider"; import dynamic from "next/dynamic"; import ReadOnlyBanner from "../read-only-banner"; import { auth } from "@/auth"; +import { ThemeScript } from "@/shared/ui/theme/ThemeScript"; import "@/app/globals.css"; import "react-toastify/dist/ReactToastify.css"; +import { WatchUpdateTheme } from "@/shared/ui/theme/WatchUpdateTheme"; const PostHogPageView = dynamic(() => import("@/shared/ui/PostHogPageView"), { ssr: false, @@ -35,6 +37,8 @@ export default async function RootLayout({ children }: RootLayoutProps) { return ( + {/* ThemeScript must be the first thing to avoid flickering */} + @@ -55,6 +59,7 @@ export default async function RootLayout({ children }: RootLayoutProps) { + {/** footer */} {process.env.GIT_COMMIT_HASH && ( diff --git a/keep-ui/app/dark-mode-toggle.tsx b/keep-ui/app/dark-mode-toggle.tsx deleted file mode 100644 index cc3539bcd..000000000 --- a/keep-ui/app/dark-mode-toggle.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { Icon, Subtitle, Switch } from "@tremor/react"; -import { useEffect } from "react"; -import { MdDarkMode } from "react-icons/md"; -import { useLocalStorage } from "utils/hooks/useLocalStorage"; - -export default function DarkModeToggle() { - const [darkMode, setDarkMode] = useLocalStorage("darkMode", false); - - const applyDarkModeStyles = () => { - /** - * Taken from https://dev.to/jochemstoel/re-add-dark-mode-to-any-website-with-just-a-few-lines-of-code-phl - */ - var h = document.getElementsByTagName("head")[0], - s = document.createElement("style"); - s.setAttribute("type", "text/css"); - s.setAttribute("id", "nightify"); - s.appendChild( - document.createTextNode( - "html{-webkit-filter:invert(100%) hue-rotate(180deg) contrast(80%) !important; background: #fff;} .line-content {background-color: #fefefe;}" - ) - ); - h.appendChild(s); - }; - - const toggleDarkMode = () => { - /** - * Taken from https://dev.to/jochemstoel/re-add-dark-mode-to-any-website-with-just-a-few-lines-of-code-phl - */ - setDarkMode(!darkMode); - let q = document.querySelectorAll("#nightify"); - if (q.length) { - q.forEach((q) => q.parentNode?.removeChild(q)); - return false; - } - applyDarkModeStyles(); - }; - - useEffect(() => { - if (darkMode) { - applyDarkModeStyles(); - } - }, [darkMode]); - - return ( - - ); -} diff --git a/keep-ui/app/globals.css b/keep-ui/app/globals.css index 7751a9304..f1a3b1d0c 100644 --- a/keep-ui/app/globals.css +++ b/keep-ui/app/globals.css @@ -2,6 +2,26 @@ @tailwind components; @tailwind utilities; +/* TODO: use proper tailwind/css-variables solution */ +/** + * Taken from https://dev.to/jochemstoel/re-add-dark-mode-to-any-website-with-just-a-few-lines-of-code-phl + */ +html.workaround-dark { + filter: invert(100%) hue-rotate(180deg) contrast(80%) !important; + background: #fff; + + & .line-content { + background-color: #fefefe; + } + + & .workaround-dark-hidden { + display: none; + } + + & .workaround-dark-visible { + display: block !important; + } +} /* https://github.com/vercel/next.js/discussions/13387 nextjs-portal { display: none; diff --git a/keep-ui/components/LinkWithIcon.tsx b/keep-ui/components/LinkWithIcon.tsx index afbb21210..2b7a33f49 100644 --- a/keep-ui/components/LinkWithIcon.tsx +++ b/keep-ui/components/LinkWithIcon.tsx @@ -16,6 +16,7 @@ type LinkWithIconProps = { className?: string; testId?: string; isExact?: boolean; + iconClassName?: string; } & LinkProps & AnchorHTMLAttributes; @@ -30,6 +31,7 @@ export const LinkWithIcon = ({ className, testId, isExact = false, + iconClassName, ...restOfLinkProps }: LinkWithIconProps) => { const pathname = usePathname(); @@ -40,10 +42,14 @@ export const LinkWithIcon = ({ restOfLinkProps.href?.toString() || "" ); - const iconClasses = clsx("group-hover:text-orange-400", { - "text-orange-400": isActive, - "text-black": !isActive, - }); + const iconClasses = clsx( + "group-hover:text-orange-400", + { + "text-orange-400": isActive, + "text-black": !isActive, + }, + iconClassName + ); const textClasses = clsx("truncate", { "text-orange-400": isActive, diff --git a/keep-ui/components/navbar/UserInfo.tsx b/keep-ui/components/navbar/UserInfo.tsx index 09c920326..bc8ad7c9d 100644 --- a/keep-ui/components/navbar/UserInfo.tsx +++ b/keep-ui/components/navbar/UserInfo.tsx @@ -6,10 +6,8 @@ import { Session } from "next-auth"; import { useConfig } from "utils/hooks/useConfig"; import { AuthType } from "@/utils/authenticationType"; import Link from "next/link"; -import { LuSlack } from "react-icons/lu"; import { AiOutlineRight } from "react-icons/ai"; import { VscDebugDisconnect } from "react-icons/vsc"; -import DarkModeToggle from "app/dark-mode-toggle"; import { useFloating } from "@floating-ui/react"; import { Icon, Subtitle } from "@tremor/react"; import UserAvatar from "./UserAvatar"; @@ -17,6 +15,9 @@ import * as Frigade from "@frigade/react"; import { useState } from "react"; import Onboarding from "./Onboarding"; import { useSignOut } from "@/shared/lib/hooks/useSignOut"; +import { FaSlack } from "react-icons/fa"; +import { ThemeControl } from "@/shared/ui/theme/ThemeControl"; +import { HiOutlineDocumentText } from "react-icons/hi2"; const ONBOARDING_FLOW_ID = "flow_FHDz1hit"; @@ -37,18 +38,12 @@ const UserDropdown = ({ session }: UserDropdownProps) => { const isNoAuth = configData?.AUTH_TYPE === AuthType.NOAUTH; return ( - + {" "} {name ?? email} - - { return ( <>
    -
  • - - Providers - -
  • -
  • - {/* TODO: slows everything down. needs to be replaced */} - -
  • -
  • - - Join our Slack - -
  • {flow?.isCompleted === false && (
  • { />
  • )} - {session && } +
  • + + Providers + +
  • +
  • + + Join Slack + + + Docs + +
  • +
    + {session && } + +
); diff --git a/keep-ui/components/ui/DropdownMenu/DropdownMenu.tsx b/keep-ui/components/ui/DropdownMenu/DropdownMenu.tsx index 85fef8c4c..ce0ebfe5a 100644 --- a/keep-ui/components/ui/DropdownMenu/DropdownMenu.tsx +++ b/keep-ui/components/ui/DropdownMenu/DropdownMenu.tsx @@ -155,7 +155,10 @@ const MenuComponent = React.forwardRef< data-open={isOpen ? "" : undefined} data-nested={isNested ? "" : undefined} data-focus-inside={hasFocusInside ? "" : undefined} - className={isNested ? "DropdownMenuItem" : "DropdownMenuButton"} + className={clsx( + isNested ? "DropdownMenuItem" : "DropdownMenuButton", + props.className + )} {...getReferenceProps( parent.getItemProps({ ...props, @@ -237,7 +240,8 @@ const DropdownDropdownMenuItem = React.forwardRef< role="DropdownMenuItem" className={clsx( "DropdownMenuItem", - props.variant === "destructive" && "text-red-500" + props.variant === "destructive" && "text-red-500", + props.className )} tabIndex={isActive ? 0 : -1} disabled={disabled} diff --git a/keep-ui/shared/constants.ts b/keep-ui/shared/constants.ts new file mode 100644 index 000000000..9c81b8913 --- /dev/null +++ b/keep-ui/shared/constants.ts @@ -0,0 +1 @@ +export const LOCALSTORAGE_THEME_KEY = "theme"; diff --git a/keep-ui/shared/ui/theme/ThemeControl.tsx b/keep-ui/shared/ui/theme/ThemeControl.tsx new file mode 100644 index 000000000..2fdffc40b --- /dev/null +++ b/keep-ui/shared/ui/theme/ThemeControl.tsx @@ -0,0 +1,61 @@ +import { LOCALSTORAGE_THEME_KEY } from "@/shared/constants"; +import { + ComputerDesktopIcon, + MoonIcon, + SunIcon, +} from "@heroicons/react/20/solid"; +import { useLocalStorage } from "utils/hooks/useLocalStorage"; +import { DropdownMenu } from "@/components/ui/DropdownMenu/DropdownMenu"; +import clsx from "clsx"; + +const THEMES = { + light: { id: "light", icon: SunIcon, title: "Light" }, + dark: { id: "dark", icon: MoonIcon, title: "Dark" }, + system: { id: "system", icon: ComputerDesktopIcon, title: "System" }, +}; + +export function ThemeControl({ className }: { className?: string }) { + const [theme, setTheme] = useLocalStorage( + LOCALSTORAGE_THEME_KEY, + null + ); + + const updateTheme = (theme: string) => { + setTheme(theme === "system" ? null : theme); + if (theme !== "system") { + document.documentElement.classList[theme === "dark" ? "add" : "remove"]( + "workaround-dark" + ); + // If system theme is selected, will handle the rest + } + }; + + const value = theme === null ? "system" : theme; + + return ( + ( + <> + + + + + + + + )} + label="" + className={clsx(value !== "system" && "text-tremor-brand", className)} + > + {Object.values(THEMES).map(({ id, icon: Icon, title }) => ( + updateTheme(id)} + className={clsx(id === value && "text-tremor-brand")} + /> + ))} + + ); +} diff --git a/keep-ui/shared/ui/theme/ThemeScript.tsx b/keep-ui/shared/ui/theme/ThemeScript.tsx new file mode 100644 index 000000000..d7150c13a --- /dev/null +++ b/keep-ui/shared/ui/theme/ThemeScript.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { LOCALSTORAGE_THEME_KEY } from "../../constants"; + +export const ThemeScript = () => { + return ( +