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", "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
View File

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

View File

@@ -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>
);
}
);

View File

@@ -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>
); );
}; };