Added some animations
This commit is contained in:
@@ -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
39
pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
@@ -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> {
|
||||
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<HTMLButtonElement, PowerButtonProps>(
|
||||
({ 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">
|
||||
<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",
|
||||
)}
|
||||
/>
|
||||
// Единые, мягкие настройки для всех пружинных анимаций
|
||||
const sharedSpring: Transition = {
|
||||
type: "spring",
|
||||
stiffness: 100,
|
||||
damping: 30,
|
||||
mass: 1,
|
||||
};
|
||||
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
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 glowColors = {
|
||||
on: "rgba(74, 222, 128, 0.6)",
|
||||
off: "rgba(239, 68, 68, 0.4)",
|
||||
};
|
||||
|
||||
// Стили ТОЛЬКО для отключенного состояния (но не для загрузки)
|
||||
props.disabled &&
|
||||
!loading &&
|
||||
"grayscale opacity-50 shadow-none 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>
|
||||
const shadows = {
|
||||
on: "0px 0px 50px rgba(34, 197, 94, 1)",
|
||||
off: "0px 0px 30px rgba(239, 68, 68, 0.6)",
|
||||
disabled: "none",
|
||||
};
|
||||
|
||||
{loading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center z-20">
|
||||
<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",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
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">
|
||||
<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 { 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"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(circle, rgba(34,197,94,0.3) 0%, transparent 70%)",
|
||||
filter: "blur(100px)",
|
||||
}}
|
||||
<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">
|
||||
@@ -356,142 +443,158 @@ const MinimalHomePage: React.FC = () => {
|
||||
<div className="flex justify-end"></div>
|
||||
</header>
|
||||
|
||||
<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">
|
||||
{currentProfile?.announce && (
|
||||
<div className="absolute -top-15 w-full flex justify-center text-center max-w-lg">
|
||||
{currentProfile.announce_url ? (
|
||||
<a
|
||||
href={currentProfile.announce_url}
|
||||
target="_blank"
|
||||
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"
|
||||
title={currentProfile.announce_url.replace(/\\n/g, "\n")}
|
||||
>
|
||||
<span>{currentProfile.announce.replace(/\\n/g, "\n")}</span>
|
||||
<ExternalLink className="h-4 w-4 flex-shrink-0" />
|
||||
</a>
|
||||
) : (
|
||||
<p className="text-base font-semibold text-foreground whitespace-pre-wrap">
|
||||
{currentProfile.announce}
|
||||
</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.",
|
||||
<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">
|
||||
{currentProfile?.announce && (
|
||||
<div className="absolute -top-15 w-full flex justify-center text-center max-w-lg">
|
||||
{currentProfile.announce_url ? (
|
||||
<a
|
||||
href={currentProfile.announce_url}
|
||||
target="_blank"
|
||||
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"
|
||||
title={currentProfile.announce_url.replace(/\\n/g, "\n")}
|
||||
>
|
||||
<span>{currentProfile.announce.replace(/\\n/g, "\n")}</span>
|
||||
<ExternalLink className="h-4 w-4 flex-shrink-0" />
|
||||
</a>
|
||||
) : (
|
||||
<p className="text-base font-semibold text-foreground whitespace-pre-wrap">
|
||||
{currentProfile.announce}
|
||||
</p>
|
||||
)}
|
||||
</AlertDescription>
|
||||
<Button
|
||||
className="mt-2"
|
||||
onClick={() => viewerRef.current?.create()}
|
||||
>
|
||||
{t("Add Profile")}
|
||||
</Button>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
</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">
|
||||
<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" />
|
||||
|
||||
<div className="relative text-center">
|
||||
<motion.h1
|
||||
className={cn(
|
||||
"text-4xl mb-2 font-semibold",
|
||||
statusInfo.isAnimating && "animate-pulse"
|
||||
)}
|
||||
animate={{ color: statusInfo.color }}
|
||||
transition={{ duration: 0.35, ease: "easeOut" }}
|
||||
>
|
||||
{statusInfo.text}
|
||||
</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)}
|
||||
</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>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{currentProfile.support_url}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</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>
|
||||
<Button className="mt-2" onClick={() => viewerRef.current?.create()}>
|
||||
{t("Add Profile")}
|
||||
</Button>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</footer>
|
||||
<ProfileViewer ref={viewerRef} onChange={() => mutateProfiles()} />
|
||||
</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">
|
||||
<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