diff --git a/surfsense_web/components/theme/theme-toggle.tsx b/surfsense_web/components/theme/theme-toggle.tsx index 112cc7b88..382d11087 100644 --- a/surfsense_web/components/theme/theme-toggle.tsx +++ b/surfsense_web/components/theme/theme-toggle.tsx @@ -1,71 +1,802 @@ "use client"; -import { MoonIcon, SunIcon } from "lucide-react"; import { motion } from "motion/react"; import { useTheme } from "next-themes"; -import { useEffect, useState } from "react"; -import { Button } from "@/components/ui/button"; +import { useCallback, useEffect, useId, useState } from "react"; -export function ThemeTogglerComponent() { - const { theme, setTheme } = useTheme(); +import { cn } from "@/lib/utils"; - const [isClient, setIsClient] = useState(false); +// /////////////////////////////////////////////////////////////////////////// +// Types +export type AnimationVariant = + | "circle" + | "rectangle" + | "gif" + | "polygon" + | "circle-blur"; +export type AnimationStart = + | "top-left" + | "top-right" + | "bottom-left" + | "bottom-right" + | "center" + | "top-center" + | "bottom-center" + | "bottom-up" + | "top-down" + | "left-right" + | "right-left"; - useEffect(() => { - setIsClient(true); - }, []); - - return ( - isClient && ( - - ) - ); +interface Animation { + name: string; + css: string; } + +// /////////////////////////////////////////////////////////////////////////// +// Helper functions + +const getPositionCoords = (position: AnimationStart) => { + switch (position) { + case "top-left": + return { cx: "0", cy: "0" }; + case "top-right": + return { cx: "40", cy: "0" }; + case "bottom-left": + return { cx: "0", cy: "40" }; + case "bottom-right": + return { cx: "40", cy: "40" }; + case "top-center": + return { cx: "20", cy: "0" }; + case "bottom-center": + return { cx: "20", cy: "40" }; + case "bottom-up": + case "top-down": + case "left-right": + case "right-left": + return { cx: "20", cy: "20" }; + } +}; + +const generateSVG = (variant: AnimationVariant, start: AnimationStart) => { + if (variant === "circle-blur") { + if (start === "center") { + return `data:image/svg+xml,`; + } + const positionCoords = getPositionCoords(start); + if (!positionCoords) { + throw new Error(`Invalid start position: ${start}`); + } + const { cx, cy } = positionCoords; + return `data:image/svg+xml,`; + } + + if (start === "center") return; + + if (variant === "rectangle") return ""; + + const positionCoords = getPositionCoords(start); + if (!positionCoords) { + throw new Error(`Invalid start position: ${start}`); + } + const { cx, cy } = positionCoords; + + if (variant === "circle") { + return `data:image/svg+xml,`; + } + + return ""; +}; + +const getTransformOrigin = (start: AnimationStart) => { + switch (start) { + case "top-left": + return "top left"; + case "top-right": + return "top right"; + case "bottom-left": + return "bottom left"; + case "bottom-right": + return "bottom right"; + case "top-center": + return "top center"; + case "bottom-center": + return "bottom center"; + case "bottom-up": + case "top-down": + case "left-right": + case "right-left": + return "center"; + } +}; + +export const createAnimation = ( + variant: AnimationVariant, + start: AnimationStart = "center", + blur = false, + url?: string, +): Animation => { + const svg = generateSVG(variant, start); + const transformOrigin = getTransformOrigin(start); + + if (variant === "rectangle") { + const getClipPath = (direction: AnimationStart) => { + switch (direction) { + case "bottom-up": + return { + from: "polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)", + to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", + }; + case "top-down": + return { + from: "polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)", + to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", + }; + case "left-right": + return { + from: "polygon(0% 0%, 0% 0%, 0% 100%, 0% 100%)", + to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", + }; + case "right-left": + return { + from: "polygon(100% 0%, 100% 0%, 100% 100%, 100% 100%)", + to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", + }; + case "top-left": + return { + from: "polygon(0% 0%, 0% 0%, 0% 0%, 0% 0%)", + to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", + }; + case "top-right": + return { + from: "polygon(100% 0%, 100% 0%, 100% 0%, 100% 0%)", + to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", + }; + case "bottom-left": + return { + from: "polygon(0% 100%, 0% 100%, 0% 100%, 0% 100%)", + to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", + }; + case "bottom-right": + return { + from: "polygon(100% 100%, 100% 100%, 100% 100%, 100% 100%)", + to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", + }; + default: + return { + from: "polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)", + to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", + }; + } + }; + + const clipPath = getClipPath(start); + + return { + name: `${variant}-${start}${blur ? "-blur" : ""}`, + css: ` + ::view-transition-group(root) { + animation-duration: 0.7s; + animation-timing-function: var(--expo-out); + } + + ::view-transition-new(root) { + animation-name: reveal-light-${start}${blur ? "-blur" : ""}; + ${blur ? "filter: blur(2px);" : ""} + } + + ::view-transition-old(root), + .dark::view-transition-old(root) { + animation: none; + z-index: -1; + } + .dark::view-transition-new(root) { + animation-name: reveal-dark-${start}${blur ? "-blur" : ""}; + ${blur ? "filter: blur(2px);" : ""} + } + + @keyframes reveal-dark-${start}${blur ? "-blur" : ""} { + from { + clip-path: ${clipPath.from}; + ${blur ? "filter: blur(8px);" : ""} + } + ${blur ? "50% { filter: blur(4px); }" : ""} + to { + clip-path: ${clipPath.to}; + ${blur ? "filter: blur(0px);" : ""} + } + } + + @keyframes reveal-light-${start}${blur ? "-blur" : ""} { + from { + clip-path: ${clipPath.from}; + ${blur ? "filter: blur(8px);" : ""} + } + ${blur ? "50% { filter: blur(4px); }" : ""} + to { + clip-path: ${clipPath.to}; + ${blur ? "filter: blur(0px);" : ""} + } + } + `, + }; + } + if (variant === "circle" && start == "center") { + return { + name: `${variant}-${start}${blur ? "-blur" : ""}`, + css: ` + ::view-transition-group(root) { + animation-duration: 0.7s; + animation-timing-function: var(--expo-out); + } + + ::view-transition-new(root) { + animation-name: reveal-light${blur ? "-blur" : ""}; + ${blur ? "filter: blur(2px);" : ""} + } + + ::view-transition-old(root), + .dark::view-transition-old(root) { + animation: none; + z-index: -1; + } + .dark::view-transition-new(root) { + animation-name: reveal-dark${blur ? "-blur" : ""}; + ${blur ? "filter: blur(2px);" : ""} + } + + @keyframes reveal-dark${blur ? "-blur" : ""} { + from { + clip-path: circle(0% at 50% 50%); + ${blur ? "filter: blur(8px);" : ""} + } + ${blur ? "50% { filter: blur(4px); }" : ""} + to { + clip-path: circle(100.0% at 50% 50%); + ${blur ? "filter: blur(0px);" : ""} + } + } + + @keyframes reveal-light${blur ? "-blur" : ""} { + from { + clip-path: circle(0% at 50% 50%); + ${blur ? "filter: blur(8px);" : ""} + } + ${blur ? "50% { filter: blur(4px); }" : ""} + to { + clip-path: circle(100.0% at 50% 50%); + ${blur ? "filter: blur(0px);" : ""} + } + } + `, + }; + } + if (variant === "gif") { + return { + name: `${variant}-${start}`, + css: ` + ::view-transition-group(root) { + animation-timing-function: var(--expo-in); +} + +::view-transition-new(root) { + mask: url('${url}') center / 0 no-repeat; + animation: scale 3s; +} + +::view-transition-old(root), +.dark::view-transition-old(root) { + animation: scale 3s; +} + +@keyframes scale { + 0% { + mask-size: 0; + } + 10% { + mask-size: 50vmax; + } + 90% { + mask-size: 50vmax; + } + 100% { + mask-size: 2000vmax; + } +}`, + }; + } + + if (variant === "circle-blur") { + if (start === "center") { + return { + name: `${variant}-${start}`, + css: ` + ::view-transition-group(root) { + animation-timing-function: var(--expo-out); + } + + ::view-transition-new(root) { + mask: url('${svg}') center / 0 no-repeat; + mask-origin: content-box; + animation: scale 1s; + transform-origin: center; + } + + ::view-transition-old(root), + .dark::view-transition-old(root) { + animation: scale 1s; + transform-origin: center; + z-index: -1; + } + + @keyframes scale { + to { + mask-size: 350vmax; + } + } + `, + }; + } + + return { + name: `${variant}-${start}`, + css: ` + ::view-transition-group(root) { + animation-timing-function: var(--expo-out); + } + + ::view-transition-new(root) { + mask: url('${svg}') ${start.replace("-", " ")} / 0 no-repeat; + mask-origin: content-box; + animation: scale 1s; + transform-origin: ${transformOrigin}; + } + + ::view-transition-old(root), + .dark::view-transition-old(root) { + animation: scale 1s; + transform-origin: ${transformOrigin}; + z-index: -1; + } + + @keyframes scale { + to { + mask-size: 350vmax; + } + } + `, + }; + } + + if (variant === "polygon") { + const getPolygonClipPaths = (position: AnimationStart) => { + switch (position) { + case "top-left": + return { + darkFrom: "polygon(50% -71%, -50% 71%, -50% 71%, 50% -71%)", + darkTo: "polygon(50% -71%, -50% 71%, 50% 171%, 171% 50%)", + lightFrom: "polygon(171% 50%, 50% 171%, 50% 171%, 171% 50%)", + lightTo: "polygon(171% 50%, 50% 171%, -50% 71%, 50% -71%)", + }; + case "top-right": + return { + darkFrom: "polygon(150% -71%, 250% 71%, 250% 71%, 150% -71%)", + darkTo: "polygon(150% -71%, 250% 71%, 50% 171%, -71% 50%)", + lightFrom: "polygon(-71% 50%, 50% 171%, 50% 171%, -71% 50%)", + lightTo: "polygon(-71% 50%, 50% 171%, 250% 71%, 150% -71%)", + }; + default: + return { + darkFrom: "polygon(50% -71%, -50% 71%, -50% 71%, 50% -71%)", + darkTo: "polygon(50% -71%, -50% 71%, 50% 171%, 171% 50%)", + lightFrom: "polygon(171% 50%, 50% 171%, 50% 171%, 171% 50%)", + lightTo: "polygon(171% 50%, 50% 171%, -50% 71%, 50% -71%)", + }; + } + }; + + const clipPaths = getPolygonClipPaths(start); + + return { + name: `${variant}-${start}${blur ? "-blur" : ""}`, + css: ` + ::view-transition-group(root) { + animation-duration: 0.7s; + animation-timing-function: var(--expo-out); + } + + ::view-transition-new(root) { + animation-name: reveal-light-${start}${blur ? "-blur" : ""}; + ${blur ? "filter: blur(2px);" : ""} + } + + ::view-transition-old(root), + .dark::view-transition-old(root) { + animation: none; + z-index: -1; + } + .dark::view-transition-new(root) { + animation-name: reveal-dark-${start}${blur ? "-blur" : ""}; + ${blur ? "filter: blur(2px);" : ""} + } + + @keyframes reveal-dark-${start}${blur ? "-blur" : ""} { + from { + clip-path: ${clipPaths.darkFrom}; + ${blur ? "filter: blur(8px);" : ""} + } + ${blur ? "50% { filter: blur(4px); }" : ""} + to { + clip-path: ${clipPaths.darkTo}; + ${blur ? "filter: blur(0px);" : ""} + } + } + + @keyframes reveal-light-${start}${blur ? "-blur" : ""} { + from { + clip-path: ${clipPaths.lightFrom}; + ${blur ? "filter: blur(8px);" : ""} + } + ${blur ? "50% { filter: blur(4px); }" : ""} + to { + clip-path: ${clipPaths.lightTo}; + ${blur ? "filter: blur(0px);" : ""} + } + } + `, + }; + } + + // Handle circle variants with start positions using clip-path + if (variant === "circle" && start !== "center") { + const getClipPathPosition = (position: AnimationStart) => { + switch (position) { + case "top-left": + return "0% 0%"; + case "top-right": + return "100% 0%"; + case "bottom-left": + return "0% 100%"; + case "bottom-right": + return "100% 100%"; + case "top-center": + return "50% 0%"; + case "bottom-center": + return "50% 100%"; + default: + return "50% 50%"; + } + }; + + const clipPosition = getClipPathPosition(start); + + return { + name: `${variant}-${start}${blur ? "-blur" : ""}`, + css: ` + ::view-transition-group(root) { + animation-duration: 1s; + animation-timing-function: var(--expo-out); + } + + ::view-transition-new(root) { + animation-name: reveal-light-${start}${blur ? "-blur" : ""}; + ${blur ? "filter: blur(2px);" : ""} + } + + ::view-transition-old(root), + .dark::view-transition-old(root) { + animation: none; + z-index: -1; + } + .dark::view-transition-new(root) { + animation-name: reveal-dark-${start}${blur ? "-blur" : ""}; + ${blur ? "filter: blur(2px);" : ""} + } + + @keyframes reveal-dark-${start}${blur ? "-blur" : ""} { + from { + clip-path: circle(0% at ${clipPosition}); + ${blur ? "filter: blur(8px);" : ""} + } + ${blur ? "50% { filter: blur(4px); }" : ""} + to { + clip-path: circle(150.0% at ${clipPosition}); + ${blur ? "filter: blur(0px);" : ""} + } + } + + @keyframes reveal-light-${start}${blur ? "-blur" : ""} { + from { + clip-path: circle(0% at ${clipPosition}); + ${blur ? "filter: blur(8px);" : ""} + } + ${blur ? "50% { filter: blur(4px); }" : ""} + to { + clip-path: circle(150.0% at ${clipPosition}); + ${blur ? "filter: blur(0px);" : ""} + } + } + `, + }; + } + + return { + name: `${variant}-${start}${blur ? "-blur" : ""}`, + css: ` + ::view-transition-group(root) { + animation-timing-function: var(--expo-in); + } + ::view-transition-new(root) { + mask: url('${svg}') ${start.replace("-", " ")} / 0 no-repeat; + mask-origin: content-box; + animation: scale-${start}${blur ? "-blur" : ""} 1s; + transform-origin: ${transformOrigin}; + ${blur ? "filter: blur(2px);" : ""} + } + ::view-transition-old(root), + .dark::view-transition-old(root) { + animation: scale-${start}${blur ? "-blur" : ""} 1s; + transform-origin: ${transformOrigin}; + z-index: -1; + } + @keyframes scale-${start}${blur ? "-blur" : ""} { + from { + ${blur ? "filter: blur(8px);" : ""} + } + ${blur ? "50% { filter: blur(4px); }" : ""} + to { + mask-size: 2000vmax; + ${blur ? "filter: blur(0px);" : ""} + } + } + `, + }; +}; + +// /////////////////////////////////////////////////////////////////////////// +// Custom hook for theme toggle functionality +export const useThemeToggle = ({ + variant = "circle", + start = "center", + blur = false, + gifUrl = "", +}: { + variant?: AnimationVariant; + start?: AnimationStart; + blur?: boolean; + gifUrl?: string; +} = {}) => { + const { theme, setTheme, resolvedTheme } = useTheme(); + + const [isDark, setIsDark] = useState(false); + + // Sync isDark state with resolved theme after hydration + useEffect(() => { + setIsDark(resolvedTheme === "dark"); + }, [resolvedTheme]); + + const styleId = "theme-transition-styles"; + + const updateStyles = useCallback((css: string) => { + if (typeof window === "undefined") return; + + let styleElement = document.getElementById(styleId) as HTMLStyleElement; + + if (!styleElement) { + styleElement = document.createElement("style"); + styleElement.id = styleId; + document.head.appendChild(styleElement); + } + + styleElement.textContent = css; + }, []); + + const toggleTheme = useCallback(() => { + setIsDark(!isDark); + + const animation = createAnimation(variant, start, blur, gifUrl); + + updateStyles(animation.css); + + if (typeof window === "undefined") return; + + const switchTheme = () => { + setTheme(theme === "light" ? "dark" : "light"); + }; + + if (!document.startViewTransition) { + switchTheme(); + return; + } + + document.startViewTransition(switchTheme); + }, [theme, setTheme, variant, start, blur, gifUrl, updateStyles, isDark]); + + const setCrazyLightTheme = useCallback(() => { + setIsDark(false); + + const animation = createAnimation(variant, start, blur, gifUrl); + + updateStyles(animation.css); + + if (typeof window === "undefined") return; + + const switchTheme = () => { + setTheme("light"); + }; + + if (!document.startViewTransition) { + switchTheme(); + return; + } + + document.startViewTransition(switchTheme); + }, [setTheme, variant, start, blur, gifUrl, updateStyles]); + + const setCrazyDarkTheme = useCallback(() => { + setIsDark(true); + + const animation = createAnimation(variant, start, blur, gifUrl); + + updateStyles(animation.css); + + if (typeof window === "undefined") return; + + const switchTheme = () => { + setTheme("dark"); + }; + + if (!document.startViewTransition) { + switchTheme(); + return; + } + + document.startViewTransition(switchTheme); + }, [setTheme, variant, start, blur, gifUrl, updateStyles]); + + const setCrazySystemTheme = useCallback(() => { + if (typeof window === "undefined") return; + + const prefersDark = window.matchMedia( + "(prefers-color-scheme: dark)", + ).matches; + setIsDark(prefersDark); + + const animation = createAnimation(variant, start, blur, gifUrl); + + updateStyles(animation.css); + + const switchTheme = () => { + setTheme("system"); + }; + + if (!document.startViewTransition) { + switchTheme(); + return; + } + + document.startViewTransition(switchTheme); + }, [setTheme, variant, start, blur, gifUrl, updateStyles]); + + return { + isDark, + setIsDark, + toggleTheme, + setCrazyLightTheme, + setCrazyDarkTheme, + setCrazySystemTheme, + }; +}; + +// /////////////////////////////////////////////////////////////////////////// +// Theme Toggle Button Component (Sun/Moon Style) + +export const ThemeToggleButton = ({ + className = "", + variant = "circle", + start = "center", + blur = false, + gifUrl = "", +}: { + className?: string; + variant?: AnimationVariant; + start?: AnimationStart; + blur?: boolean; + gifUrl?: string; +}) => { + const { isDark, toggleTheme } = useThemeToggle({ + variant, + start, + blur, + gifUrl, + }); + const clipId = useId(); + const clipPathId = `theme-toggle-clip-${clipId}`; + + return ( + + ); +}; + +// /////////////////////////////////////////////////////////////////////////// +// Backwards compatible export (alias for ThemeToggleButton with default settings) +export function ThemeTogglerComponent() { + return ( + + ); +} + +/** + * Skiper 26 Theme_buttons_002 — React + CSS + transition view api https://developer.chrome.com/docs/web-platform/view-transitions/ + * Original concept from rudrodip + * Inspired by from https://github.com/rudrodip/theme-toggle-effect + * We respect the original creators. This is an inspired rebuild with our own taste and does not claim any ownership. + * These animations aren't associated with the rudrodip . They're independent recreations meant to study interaction design + * + * License & Usage: + * - Free to use and modify in both personal and commercial projects. + * - Attribution to Skiper UI is required when using the free version. + * - No attribution required with Skiper UI Pro. + * + * Feedback and contributions are welcome. + * + * Author: @gurvinder-singh02 + * Website: https://gxuri.in + * Twitter: https://x.com/Gur__vi + */