Added some animations

This commit is contained in:
coolcoala
2025-08-23 03:10:18 +03:00
parent ca7f6b86d7
commit 3ecd73f430
4 changed files with 399 additions and 205 deletions

View File

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

39
pnpm-lock.yaml generated
View File

@@ -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: {}

View File

@@ -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<HTMLButtonElement> {
export interface PowerButtonProps extends HTMLMotionProps<"button"> {
checked?: boolean;
loading?: boolean;
}
export const PowerButton = React.forwardRef<
HTMLButtonElement,
PowerButtonProps
>(({ className, checked = false, loading = false, ...props }, ref) => {
export const PowerButton = React.forwardRef<HTMLButtonElement, PowerButtonProps>(
({ className, checked = false, loading = false, ...props }, ref) => {
const state = checked ? "on" : "off";
// Единые, мягкие настройки для всех пружинных анимаций
const sharedSpring: Transition = {
type: "spring",
stiffness: 100,
damping: 30,
mass: 1,
};
const glowColors = {
on: "rgba(74, 222, 128, 0.6)",
off: "rgba(239, 68, 68, 0.4)",
};
const shadows = {
on: "0px 0px 50px rgba(34, 197, 94, 1)",
off: "0px 0px 30px rgba(239, 68, 68, 0.6)",
disabled: "none",
};
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 (
<div className="relative flex items-center justify-center h-44 w-44">
<div
className={cn(
"absolute h-28 w-28 rounded-full blur-3xl transition-all duration-500",
state === "on" ? "bg-green-400/60" : "bg-red-500/40",
props.disabled && "opacity-0",
)}
<motion.div
className="absolute h-28 w-28 rounded-full blur-3xl"
animate={{
backgroundColor: state === "on" ? glowColors.on : glowColors.off,
opacity: isDisabled ? 0 : checked ? 1 : 0.3,
scale: checked ? 1.2 : 0.8,
}}
transition={sharedSpring}
/>
<button
<motion.div
className="absolute h-40 w-40 rounded-full blur-[60px]"
animate={{
backgroundColor: checked ? "rgba(34, 197, 94, 0.2)" : "rgba(239, 68, 68, 0.1)",
opacity: isDisabled ? 0 : checked ? 0.8 : 0,
scale: checked ? 1.4 : 0.6,
}}
transition={sharedSpring}
/>
<motion.button
ref={ref}
type="button"
disabled={loading || props.disabled}
data-state={state}
animate={{
scale: checked ? 1.1 : 0.9,
boxShadow: currentShadow,
color: currentColor,
}}
whileHover={{ scale: checked ? 1.15 : 0.95 }}
whileTap={{ scale: checked ? 1.05 : 0.85 }}
transition={sharedSpring}
className={cn(
"group",
"relative z-10 flex items-center justify-center h-36 w-36 rounded-full border-2",
"backdrop-blur-sm bg-white/10 border-white/20",
"text-red-500 shadow-[0_0_30px_rgba(239,68,68,0.6)]",
"data-[state=on]:text-green-500 dark:data-[state=on]:text-white",
"data-[state=on]:shadow-[0_0_50px_rgba(34,197,94,1)]",
"transition-all duration-300 hover:scale-105 active:scale-95 focus:outline-none",
"disabled:cursor-not-allowed disabled:scale-100",
// Стили ТОЛЬКО для отключенного состояния (но не для загрузки)
props.disabled &&
!loading &&
"grayscale opacity-50 shadow-none bg-slate-100/70 border-slate-300/80",
className,
"focus:outline-none",
"disabled:cursor-not-allowed",
isDisabled && "grayscale opacity-50 bg-slate-100/70 border-slate-300/80",
className
)}
{...props}
>
<Power
className={cn(
"h-20 w-20",
!props.disabled &&
"active:scale-90 transition-transform duration-300",
)}
/>
</button>
<motion.span
className="flex items-center justify-center"
animate={{ scale: checked ? 1 / 1.1 : 1 }}
whileTap={{ scale: 0.95 }}
transition={sharedSpring}
>
<Power className="h-20 w-20" />
</motion.span>
</motion.button>
<AnimatePresence>
{loading && (
<div className="absolute inset-0 flex items-center justify-center z-20">
<motion.div
key="pb-loader"
className="absolute inset-0 z-20 flex items-center justify-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
>
<div
className={cn(
"h-full w-full animate-spin rounded-full border-4",
"border-transparent",
checked ? "border-t-green-500" : "border-t-red-500",
"blur-xs",
"blur-xs"
)}
/>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
});
}
);

View File

@@ -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<boolean>(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 (
<div className="h-full w-full flex flex-col">
<div className="absolute inset-0 opacity-20 pointer-events-none z-0 [transform:translateZ(0)]">
<img src={map} alt="World map" className="w-full h-full object-cover" />
</div>
{isProxyEnabled && (
<div
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 h-[500px] w-[500px] rounded-full pointer-events-none z-0 transition-opacity duration-500"
<motion.div
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 h-[500px] w-[500px] rounded-full pointer-events-none z-0"
style={{
background:
"radial-gradient(circle, rgba(34,197,94,0.3) 0%, transparent 70%)",
filter: "blur(100px)",
}}
animate={{
opacity: uiProxyEnabled ? 1 : 0,
scale: uiProxyEnabled ? 1 : 0.92,
}}
transition={{
type: "spring",
stiffness: 120,
damping: 25,
mass: 1,
}}
/>
)}
<header className="flex-shrink-0 p-5 grid grid-cols-3 items-center z-10">
<div className="flex justify-start">
@@ -378,34 +465,60 @@ const MinimalHomePage: React.FC = () => {
)}
</div>
)}
<div className="relative text-center">
<h1
<motion.h1
className={cn(
"text-4xl mb-2 font-semibold transition-colors duration-300",
statusInfo.isAnimating && "animate-pulse",
"text-4xl mb-2 font-semibold",
statusInfo.isAnimating && "animate-pulse"
)}
style={{ color: statusInfo.color }}
animate={{ color: statusInfo.color }}
transition={{ duration: 0.35, ease: "easeOut" }}
>
{statusInfo.text}
</h1>
{isProxyEnabled && (
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-52 flex justify-center items-center text-sm text-muted-foreground gap-6">
<div className="flex items-center gap-1">
</motion.h1>
<AnimatePresence mode="wait">
{uiProxyEnabled && (
<motion.div
key="traffic-stats"
className="absolute top-full left-1/2 -translate-x-1/2 mt-52 flex justify-center items-center text-sm text-muted-foreground gap-6"
variants={statsContainerVariants}
initial="initial"
animate="animate"
exit="exit"
style={{ willChange: "opacity, transform, filter" }}
>
<motion.div
className="flex items-center gap-1"
variants={statItemVariants}
style={{ willChange: "opacity, transform, filter" }}
>
<ArrowDown className="h-4 w-4 text-green-500" />
<motion.span layout>
{parseTraffic(connections.downloadTotal)}
</div>
<div className="flex items-center gap-1">
</motion.span>
</motion.div>
<motion.div
className="flex items-center gap-1"
variants={statItemVariants}
style={{ willChange: "opacity, transform, filter" }}
>
<ArrowUp className="h-4 w-4 text-sky-500" />
<motion.span layout>
{parseTraffic(connections.uploadTotal)}
</div>
</div>
</motion.span>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
<div className="relative -translate-y-6">
<PowerButton
loading={isToggling}
checked={!!isProxyEnabled}
checked={uiProxyEnabled}
onClick={handleToggleProxy}
disabled={showTunAlert || isToggling || profileItems.length === 0}
aria-label={t("Toggle Proxy")}
@@ -414,21 +527,14 @@ const MinimalHomePage: React.FC = () => {
{showTunAlert && (
<div className="w-full max-w-sm">
<Alert
variant="destructive"
className="flex flex-col items-center gap-2 text-center"
>
<Alert className="flex flex-col items-center gap-2 text-center" variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>{t("Attention Required")}</AlertTitle>
<AlertDescription className="text-xs">
{t("TUN requires Service Mode or Admin Mode")}
</AlertDescription>
{!isServiceMode && !isAdminMode && (
<Button
size="sm"
className="mt-2"
onClick={installServiceAndRestartCore}
>
<Button size="sm" className="mt-2" onClick={installServiceAndRestartCore}>
<Wrench className="mr-2 h-4 w-4" />
{t("Install Service")}
</Button>
@@ -445,14 +551,9 @@ const MinimalHomePage: React.FC = () => {
<PlusCircle className="h-4 w-4" />
<AlertTitle>{t("Get Started")}</AlertTitle>
<AlertDescription className="whitespace-pre-wrap">
{t(
"You don't have any profiles yet. Add your first one to begin.",
)}
{t("You don't have any profiles yet. Add your first one to begin.")}
</AlertDescription>
<Button
className="mt-2"
onClick={() => viewerRef.current?.create()}
>
<Button className="mt-2" onClick={() => viewerRef.current?.create()}>
{t("Add Profile")}
</Button>
</Alert>
@@ -460,6 +561,7 @@ const MinimalHomePage: React.FC = () => {
</div>
</div>
</main>
<footer className="flex justify-center p-4 flex-shrink-0">
{currentProfile?.support_url && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
@@ -490,6 +592,7 @@ const MinimalHomePage: React.FC = () => {
</div>
)}
</footer>
<ProfileViewer ref={viewerRef} onChange={() => mutateProfiles()} />
</div>
);