Added some animations
This commit is contained in:
@@ -74,6 +74,7 @@
|
|||||||
"d3-shape": "^3.2.0",
|
"d3-shape": "^3.2.0",
|
||||||
"dayjs": "1.11.13",
|
"dayjs": "1.11.13",
|
||||||
"foxact": "^0.2.45",
|
"foxact": "^0.2.45",
|
||||||
|
"framer-motion": "^12.23.12",
|
||||||
"glob": "^11.0.2",
|
"glob": "^11.0.2",
|
||||||
"i18next": "^25.2.1",
|
"i18next": "^25.2.1",
|
||||||
"js-base64": "^3.7.7",
|
"js-base64": "^3.7.7",
|
||||||
|
|||||||
39
pnpm-lock.yaml
generated
39
pnpm-lock.yaml
generated
@@ -155,6 +155,9 @@ importers:
|
|||||||
foxact:
|
foxact:
|
||||||
specifier: ^0.2.45
|
specifier: ^0.2.45
|
||||||
version: 0.2.49(react@19.1.0)
|
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:
|
glob:
|
||||||
specifier: ^11.0.2
|
specifier: ^11.0.2
|
||||||
version: 11.0.3
|
version: 11.0.3
|
||||||
@@ -2754,6 +2757,20 @@ packages:
|
|||||||
fraction.js@4.3.7:
|
fraction.js@4.3.7:
|
||||||
resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
|
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:
|
fsevents@2.3.3:
|
||||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
@@ -3201,6 +3218,12 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
monaco-editor: '>=0.36'
|
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:
|
mri@1.2.0:
|
||||||
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
|
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@@ -6371,6 +6394,16 @@ snapshots:
|
|||||||
|
|
||||||
fraction.js@4.3.7: {}
|
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:
|
fsevents@2.3.3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -6924,6 +6957,12 @@ snapshots:
|
|||||||
vscode-uri: 3.1.0
|
vscode-uri: 3.1.0
|
||||||
yaml: 2.7.1
|
yaml: 2.7.1
|
||||||
|
|
||||||
|
motion-dom@12.23.12:
|
||||||
|
dependencies:
|
||||||
|
motion-utils: 12.23.6
|
||||||
|
|
||||||
|
motion-utils@12.23.6: {}
|
||||||
|
|
||||||
mri@1.2.0: {}
|
mri@1.2.0: {}
|
||||||
|
|
||||||
ms@2.1.3: {}
|
ms@2.1.3: {}
|
||||||
|
|||||||
@@ -1,72 +1,123 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { motion, HTMLMotionProps, Transition, AnimatePresence } from "framer-motion";
|
||||||
import { cn } from "@root/lib/utils";
|
import { cn } from "@root/lib/utils";
|
||||||
import { Power } from "lucide-react";
|
import { Power } from "lucide-react";
|
||||||
|
|
||||||
export interface PowerButtonProps
|
export interface PowerButtonProps extends HTMLMotionProps<"button"> {
|
||||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
checked?: boolean;
|
||||||
checked?: boolean;
|
loading?: boolean;
|
||||||
loading?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PowerButton = React.forwardRef<
|
export const PowerButton = React.forwardRef<HTMLButtonElement, PowerButtonProps>(
|
||||||
HTMLButtonElement,
|
({ className, checked = false, loading = false, ...props }, ref) => {
|
||||||
PowerButtonProps
|
const state = checked ? "on" : "off";
|
||||||
>(({ className, checked = false, loading = false, ...props }, ref) => {
|
|
||||||
const state = checked ? "on" : "off";
|
|
||||||
|
|
||||||
return (
|
// Единые, мягкие настройки для всех пружинных анимаций
|
||||||
<div className="relative flex items-center justify-center h-44 w-44">
|
const sharedSpring: Transition = {
|
||||||
<div
|
type: "spring",
|
||||||
className={cn(
|
stiffness: 100,
|
||||||
"absolute h-28 w-28 rounded-full blur-3xl transition-all duration-500",
|
damping: 30,
|
||||||
state === "on" ? "bg-green-400/60" : "bg-red-500/40",
|
mass: 1,
|
||||||
props.disabled && "opacity-0",
|
};
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<button
|
const glowColors = {
|
||||||
ref={ref}
|
on: "rgba(74, 222, 128, 0.6)",
|
||||||
type="button"
|
off: "rgba(239, 68, 68, 0.4)",
|
||||||
disabled={loading || props.disabled}
|
};
|
||||||
data-state={state}
|
|
||||||
className={cn(
|
|
||||||
"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",
|
|
||||||
|
|
||||||
// Стили ТОЛЬКО для отключенного состояния (но не для загрузки)
|
const shadows = {
|
||||||
props.disabled &&
|
on: "0px 0px 50px rgba(34, 197, 94, 1)",
|
||||||
!loading &&
|
off: "0px 0px 30px rgba(239, 68, 68, 0.6)",
|
||||||
"grayscale opacity-50 shadow-none bg-slate-100/70 border-slate-300/80",
|
disabled: "none",
|
||||||
className,
|
};
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<Power
|
|
||||||
className={cn(
|
|
||||||
"h-20 w-20",
|
|
||||||
!props.disabled &&
|
|
||||||
"active:scale-90 transition-transform duration-300",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{loading && (
|
const textColors = {
|
||||||
<div className="absolute inset-0 flex items-center justify-center z-20">
|
on: "rgb(255, 255, 255)",
|
||||||
<div
|
off: "rgb(239, 68, 68)",
|
||||||
className={cn(
|
disabled: "rgb(100, 116, 139)",
|
||||||
"h-full w-full animate-spin rounded-full border-4",
|
};
|
||||||
"border-transparent",
|
|
||||||
checked ? "border-t-green-500" : "border-t-red-500",
|
const isDisabled = props.disabled && !loading;
|
||||||
"blur-xs",
|
const currentShadow = isDisabled ? shadows.disabled : checked ? shadows.on : shadows.off;
|
||||||
)}
|
const currentColor = isDisabled ? textColors.disabled : checked ? textColors.on : textColors.off;
|
||||||
/>
|
|
||||||
</div>
|
return (
|
||||||
)}
|
<div className="relative flex items-center justify-center h-44 w-44">
|
||||||
</div>
|
<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}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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}
|
||||||
|
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",
|
||||||
|
"focus:outline-none",
|
||||||
|
"disabled:cursor-not-allowed",
|
||||||
|
isDisabled && "grayscale opacity-50 bg-slate-100/70 border-slate-300/80",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<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 && (
|
||||||
|
<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"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|||||||
@@ -57,6 +57,36 @@ import { useAppData } from "@/providers/app-data-provider";
|
|||||||
import { PowerButton } from "@/components/home/power-button";
|
import { PowerButton } from "@/components/home/power-button";
|
||||||
import { cn } from "@root/lib/utils";
|
import { cn } from "@root/lib/utils";
|
||||||
import map from "../assets/image/map.svg";
|
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 MinimalHomePage: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -91,7 +121,7 @@ const MinimalHomePage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
toast.error(err.message || err.toString());
|
toast.error(err.message || err.toString());
|
||||||
mutateProfiles();
|
await mutateProfiles();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[patchProfiles, activateSelected, mutateProfiles, t],
|
[patchProfiles, activateSelected, mutateProfiles, t],
|
||||||
@@ -114,7 +144,11 @@ const MinimalHomePage: React.FC = () => {
|
|||||||
const { isAdminMode, isServiceMode } = useSystemState();
|
const { isAdminMode, isServiceMode } = useSystemState();
|
||||||
const { installServiceAndRestartCore } = useServiceInstaller();
|
const { installServiceAndRestartCore } = useServiceInstaller();
|
||||||
const isTunAvailable = isServiceMode || isAdminMode;
|
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 =
|
const showTunAlert =
|
||||||
(verge?.primary_action ?? "tun-mode") === "tun-mode" && !isTunAvailable;
|
(verge?.primary_action ?? "tun-mode") === "tun-mode" && !isTunAvailable;
|
||||||
|
|
||||||
@@ -193,22 +227,75 @@ const MinimalHomePage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
}, [isToggling, isProxyEnabled, t]);
|
}, [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 (
|
return (
|
||||||
<div className="h-full w-full flex flex-col">
|
<div className="h-full w-full flex flex-col">
|
||||||
<div className="absolute inset-0 opacity-20 pointer-events-none z-0 [transform:translateZ(0)]">
|
<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" />
|
<img src={map} alt="World map" className="w-full h-full object-cover" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isProxyEnabled && (
|
<motion.div
|
||||||
<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"
|
||||||
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"
|
style={{
|
||||||
style={{
|
background:
|
||||||
background:
|
"radial-gradient(circle, rgba(34,197,94,0.3) 0%, transparent 70%)",
|
||||||
"radial-gradient(circle, rgba(34,197,94,0.3) 0%, transparent 70%)",
|
filter: "blur(100px)",
|
||||||
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">
|
<header className="flex-shrink-0 p-5 grid grid-cols-3 items-center z-10">
|
||||||
<div className="flex justify-start">
|
<div className="flex justify-start">
|
||||||
@@ -356,142 +443,158 @@ const MinimalHomePage: React.FC = () => {
|
|||||||
<div className="flex justify-end"></div>
|
<div className="flex justify-end"></div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="flex-1 overflow-y-auto flex items-center justify-center">
|
<main className="flex-1 overflow-y-auto flex items-center justify-center">
|
||||||
<div className="relative flex flex-col items-center gap-8 py-10 w-full max-w-4xl px-4">
|
<div className="relative flex flex-col items-center gap-8 py-10 w-full max-w-4xl px-4">
|
||||||
{currentProfile?.announce && (
|
{currentProfile?.announce && (
|
||||||
<div className="absolute -top-15 w-full flex justify-center text-center max-w-lg">
|
<div className="absolute -top-15 w-full flex justify-center text-center max-w-lg">
|
||||||
{currentProfile.announce_url ? (
|
{currentProfile.announce_url ? (
|
||||||
<a
|
<a
|
||||||
href={currentProfile.announce_url}
|
href={currentProfile.announce_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center gap-2 text-base font-semibold text-foreground hover:underline hover:opacity-80 transition-all whitespace-pre-wrap"
|
className="inline-flex items-center gap-2 text-base font-semibold text-foreground hover:underline hover:opacity-80 transition-all whitespace-pre-wrap"
|
||||||
title={currentProfile.announce_url.replace(/\\n/g, "\n")}
|
title={currentProfile.announce_url.replace(/\\n/g, "\n")}
|
||||||
>
|
>
|
||||||
<span>{currentProfile.announce.replace(/\\n/g, "\n")}</span>
|
<span>{currentProfile.announce.replace(/\\n/g, "\n")}</span>
|
||||||
<ExternalLink className="h-4 w-4 flex-shrink-0" />
|
<ExternalLink className="h-4 w-4 flex-shrink-0" />
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-base font-semibold text-foreground whitespace-pre-wrap">
|
<p className="text-base font-semibold text-foreground whitespace-pre-wrap">
|
||||||
{currentProfile.announce}
|
{currentProfile.announce}
|
||||||
</p>
|
</p>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="relative text-center">
|
|
||||||
<h1
|
|
||||||
className={cn(
|
|
||||||
"text-4xl mb-2 font-semibold transition-colors duration-300",
|
|
||||||
statusInfo.isAnimating && "animate-pulse",
|
|
||||||
)}
|
|
||||||
style={{ color: statusInfo.color }}
|
|
||||||
>
|
|
||||||
{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">
|
|
||||||
<ArrowDown className="h-4 w-4 text-green-500" />
|
|
||||||
{parseTraffic(connections.downloadTotal)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<ArrowUp className="h-4 w-4 text-sky-500" />
|
|
||||||
{parseTraffic(connections.uploadTotal)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative -translate-y-6">
|
|
||||||
<PowerButton
|
|
||||||
loading={isToggling}
|
|
||||||
checked={!!isProxyEnabled}
|
|
||||||
onClick={handleToggleProxy}
|
|
||||||
disabled={showTunAlert || isToggling || profileItems.length === 0}
|
|
||||||
aria-label={t("Toggle Proxy")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showTunAlert && (
|
|
||||||
<div className="w-full max-w-sm">
|
|
||||||
<Alert
|
|
||||||
variant="destructive"
|
|
||||||
className="flex flex-col items-center gap-2 text-center"
|
|
||||||
>
|
|
||||||
<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}
|
|
||||||
>
|
|
||||||
<Wrench className="mr-2 h-4 w-4" />
|
|
||||||
{t("Install Service")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Alert>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="w-full max-w-sm mt-4 flex justify-center">
|
|
||||||
{profileItems.length > 0 ? (
|
|
||||||
<ProxySelectors />
|
|
||||||
) : (
|
|
||||||
<Alert className="flex flex-col items-center gap-2 text-center">
|
|
||||||
<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.",
|
|
||||||
)}
|
)}
|
||||||
</AlertDescription>
|
</div>
|
||||||
<Button
|
|
||||||
className="mt-2"
|
|
||||||
onClick={() => viewerRef.current?.create()}
|
|
||||||
>
|
|
||||||
{t("Add Profile")}
|
|
||||||
</Button>
|
|
||||||
</Alert>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
<div className="relative text-center">
|
||||||
</main>
|
<motion.h1
|
||||||
<footer className="flex justify-center p-4 flex-shrink-0">
|
className={cn(
|
||||||
{currentProfile?.support_url && (
|
"text-4xl mb-2 font-semibold",
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
statusInfo.isAnimating && "animate-pulse"
|
||||||
<span>{t("Support")}:</span>
|
)}
|
||||||
<TooltipProvider>
|
animate={{ color: statusInfo.color }}
|
||||||
<Tooltip>
|
transition={{ duration: 0.35, ease: "easeOut" }}
|
||||||
<TooltipTrigger asChild>
|
>
|
||||||
<a
|
{statusInfo.text}
|
||||||
href={currentProfile.support_url}
|
</motion.h1>
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
<AnimatePresence mode="wait">
|
||||||
className="transition-colors hover:text-primary"
|
{uiProxyEnabled && (
|
||||||
>
|
<motion.div
|
||||||
{currentProfile.support_url.includes("t.me") ||
|
key="traffic-stats"
|
||||||
currentProfile.support_url.includes("telegram") ||
|
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"
|
||||||
currentProfile.support_url.startsWith("tg://") ? (
|
variants={statsContainerVariants}
|
||||||
<Send className="h-5 w-5" />
|
initial="initial"
|
||||||
) : (
|
animate="animate"
|
||||||
<Globe className="h-5 w-5" />
|
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)}
|
||||||
|
</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)}
|
||||||
|
</motion.span>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative -translate-y-6">
|
||||||
|
<PowerButton
|
||||||
|
loading={isToggling}
|
||||||
|
checked={uiProxyEnabled}
|
||||||
|
onClick={handleToggleProxy}
|
||||||
|
disabled={showTunAlert || isToggling || profileItems.length === 0}
|
||||||
|
aria-label={t("Toggle Proxy")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showTunAlert && (
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
<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}>
|
||||||
|
<Wrench className="mr-2 h-4 w-4" />
|
||||||
|
{t("Install Service")}
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
</a>
|
</Alert>
|
||||||
</TooltipTrigger>
|
</div>
|
||||||
<TooltipContent>
|
)}
|
||||||
<p>{currentProfile.support_url}</p>
|
|
||||||
</TooltipContent>
|
<div className="w-full max-w-sm mt-4 flex justify-center">
|
||||||
</Tooltip>
|
{profileItems.length > 0 ? (
|
||||||
</TooltipProvider>
|
<ProxySelectors />
|
||||||
|
) : (
|
||||||
|
<Alert className="flex flex-col items-center gap-2 text-center">
|
||||||
|
<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.")}
|
||||||
|
</AlertDescription>
|
||||||
|
<Button className="mt-2" onClick={() => viewerRef.current?.create()}>
|
||||||
|
{t("Add Profile")}
|
||||||
|
</Button>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</main>
|
||||||
</footer>
|
|
||||||
<ProfileViewer ref={viewerRef} onChange={() => mutateProfiles()} />
|
<footer className="flex justify-center p-4 flex-shrink-0">
|
||||||
</div>
|
{currentProfile?.support_url && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<span>{t("Support")}:</span>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<a
|
||||||
|
href={currentProfile.support_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="transition-colors hover:text-primary"
|
||||||
|
>
|
||||||
|
{currentProfile.support_url.includes("t.me") ||
|
||||||
|
currentProfile.support_url.includes("telegram") ||
|
||||||
|
currentProfile.support_url.startsWith("tg://") ? (
|
||||||
|
<Send className="h-5 w-5" />
|
||||||
|
) : (
|
||||||
|
<Globe className="h-5 w-5" />
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{currentProfile.support_url}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<ProfileViewer ref={viewerRef} onChange={() => mutateProfiles()} />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user