diff --git a/package.json b/package.json index fb05a417..68d99e63 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "d3-shape": "^3.2.0", "dayjs": "1.11.13", "foxact": "^0.2.45", + "framer-motion": "^12.23.12", "glob": "^11.0.2", "i18next": "^25.2.1", "js-base64": "^3.7.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f2b8096d..58c79d91 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -155,6 +155,9 @@ importers: foxact: specifier: ^0.2.45 version: 0.2.49(react@19.1.0) + framer-motion: + specifier: ^12.23.12 + version: 12.23.12(@emotion/is-prop-valid@1.3.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) glob: specifier: ^11.0.2 version: 11.0.3 @@ -2754,6 +2757,20 @@ packages: fraction.js@4.3.7: resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} + framer-motion@12.23.12: + resolution: {integrity: sha512-6e78rdVtnBvlEVgu6eFEAgG9v3wLnYEboM8I5O5EXvfKC8gxGQB8wXJdhkMy10iVcn05jl6CNw7/HTsTCfwcWg==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3201,6 +3218,12 @@ packages: peerDependencies: monaco-editor: '>=0.36' + motion-dom@12.23.12: + resolution: {integrity: sha512-RcR4fvMCTESQBD/uKQe49D5RUeDOokkGRmz4ceaJKDBgHYtZtntC/s2vLvY38gqGaytinij/yi3hMcWVcEF5Kw==} + + motion-utils@12.23.6: + resolution: {integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==} + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -6371,6 +6394,16 @@ snapshots: fraction.js@4.3.7: {} + framer-motion@12.23.12(@emotion/is-prop-valid@1.3.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + motion-dom: 12.23.12 + motion-utils: 12.23.6 + tslib: 2.8.1 + optionalDependencies: + '@emotion/is-prop-valid': 1.3.1 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + fsevents@2.3.3: optional: true @@ -6924,6 +6957,12 @@ snapshots: vscode-uri: 3.1.0 yaml: 2.7.1 + motion-dom@12.23.12: + dependencies: + motion-utils: 12.23.6 + + motion-utils@12.23.6: {} + mri@1.2.0: {} ms@2.1.3: {} diff --git a/src/components/home/power-button.tsx b/src/components/home/power-button.tsx index 456f7f90..1b879a98 100644 --- a/src/components/home/power-button.tsx +++ b/src/components/home/power-button.tsx @@ -1,72 +1,123 @@ import React from "react"; +import { motion, HTMLMotionProps, Transition, AnimatePresence } from "framer-motion"; import { cn } from "@root/lib/utils"; import { Power } from "lucide-react"; -export interface PowerButtonProps - extends React.ButtonHTMLAttributes { - checked?: boolean; - loading?: boolean; +export interface PowerButtonProps extends HTMLMotionProps<"button"> { + checked?: boolean; + loading?: boolean; } -export const PowerButton = React.forwardRef< - HTMLButtonElement, - PowerButtonProps ->(({ className, checked = false, loading = false, ...props }, ref) => { - const state = checked ? "on" : "off"; +export const PowerButton = React.forwardRef( + ({ className, checked = false, loading = false, ...props }, ref) => { + const state = checked ? "on" : "off"; - return ( -
-
+ // Единые, мягкие настройки для всех пружинных анимаций + const sharedSpring: Transition = { + type: "spring", + stiffness: 100, + damping: 30, + mass: 1, + }; - + const shadows = { + on: "0px 0px 50px rgba(34, 197, 94, 1)", + off: "0px 0px 30px rgba(239, 68, 68, 0.6)", + disabled: "none", + }; - {loading && ( -
-
-
- )} -
- ); -}); + const textColors = { + on: "rgb(255, 255, 255)", + off: "rgb(239, 68, 68)", + disabled: "rgb(100, 116, 139)", + }; + + const isDisabled = props.disabled && !loading; + const currentShadow = isDisabled ? shadows.disabled : checked ? shadows.on : shadows.off; + const currentColor = isDisabled ? textColors.disabled : checked ? textColors.on : textColors.off; + + return ( +
+ + + + + + + + + + + + {loading && ( + +
+ + )} + +
+ ); + } +); diff --git a/src/pages/home.tsx b/src/pages/home.tsx index fe0baad3..55b0589d 100644 --- a/src/pages/home.tsx +++ b/src/pages/home.tsx @@ -57,6 +57,36 @@ import { useAppData } from "@/providers/app-data-provider"; import { PowerButton } from "@/components/home/power-button"; import { cn } from "@root/lib/utils"; import map from "../assets/image/map.svg"; +import { AnimatePresence, motion } from "framer-motion"; + + +function useSmoothBoolean( + source: boolean, + delayOffMs: number = 600, + delayOnMs: number = 0 +): boolean { + const [value, setValue] = useState(source); + + useEffect(() => { + let timer: number | undefined; + + if (source) { + if (delayOnMs > 0) { + timer = window.setTimeout(() => setValue(true), delayOnMs); + } else { + setValue(true); + } + } else { + timer = window.setTimeout(() => setValue(false), delayOffMs); + } + + return () => { + if (timer) window.clearTimeout(timer); + }; + }, [source, delayOffMs, delayOnMs]); + + return value; +} const MinimalHomePage: React.FC = () => { const { t } = useTranslation(); @@ -91,7 +121,7 @@ const MinimalHomePage: React.FC = () => { } } catch (err: any) { toast.error(err.message || err.toString()); - mutateProfiles(); + await mutateProfiles(); } }, [patchProfiles, activateSelected, mutateProfiles, t], @@ -114,7 +144,11 @@ const MinimalHomePage: React.FC = () => { const { isAdminMode, isServiceMode } = useSystemState(); const { installServiceAndRestartCore } = useServiceInstaller(); const isTunAvailable = isServiceMode || isAdminMode; - const isProxyEnabled = verge?.enable_system_proxy || verge?.enable_tun_mode; + const isProxyEnabled = + (!!verge?.enable_system_proxy) || (!!verge?.enable_tun_mode); + + const uiProxyEnabled = useSmoothBoolean(isProxyEnabled, 600, 0); + const showTunAlert = (verge?.primary_action ?? "tun-mode") === "tun-mode" && !isTunAvailable; @@ -193,22 +227,75 @@ const MinimalHomePage: React.FC = () => { }; }, [isToggling, isProxyEnabled, t]); + const statsContainerVariants = { + initial: { opacity: 0, y: 25, filter: "blur(8px)", scale: 0.98 }, + animate: { + opacity: 1, + y: 0, + filter: "blur(0px)", + scale: 1, + transition: { + duration: 0.5, + ease: [0.25, 0.1, 0.25, 1], + when: "beforeChildren", + staggerChildren: 0.08, + }, + }, + exit: { + opacity: 0, + y: 10, + filter: "blur(10px)", + scale: 0.98, + transition: { + duration: 0.45, + ease: [0.22, 0.08, 0.05, 1], + when: "afterChildren", + staggerChildren: 0.06, + staggerDirection: -1, + }, + }, + } as const; + + const statItemVariants = { + initial: { opacity: 0, y: 10, filter: "blur(6px)" }, + animate: { + opacity: 1, + y: 0, + filter: "blur(0px)", + transition: { duration: 0.35, ease: "easeOut" }, + }, + exit: { + opacity: 0, + y: -8, + filter: "blur(6px)", + transition: { duration: 0.3, ease: "easeIn" }, + }, + } as const; + return (
World map
- {isProxyEnabled && ( -
- )}
@@ -356,142 +443,158 @@ const MinimalHomePage: React.FC = () => {
-
-
- {currentProfile?.announce && ( -
- {currentProfile.announce_url ? ( - - {currentProfile.announce.replace(/\\n/g, "\n")} - - - ) : ( -

- {currentProfile.announce} -

- )} -
- )} -
-

- {statusInfo.text} -

- {isProxyEnabled && ( -
-
- - {parseTraffic(connections.downloadTotal)} -
-
- - {parseTraffic(connections.uploadTotal)} -
-
- )} -
- -
- -
- - {showTunAlert && ( -
- - - {t("Attention Required")} - - {t("TUN requires Service Mode or Admin Mode")} - - {!isServiceMode && !isAdminMode && ( - - )} - -
- )} - -
- {profileItems.length > 0 ? ( - - ) : ( - - - {t("Get Started")} - - {t( - "You don't have any profiles yet. Add your first one to begin.", +
+
+ {currentProfile?.announce && ( +
+ {currentProfile.announce_url ? ( + + {currentProfile.announce.replace(/\\n/g, "\n")} + + + ) : ( +

+ {currentProfile.announce} +

)} - - - +
)} -
-
-
- - mutateProfiles()} /> -
+ + + + + mutateProfiles()} /> +
); };