diff --git a/app/settings/core/ControlPanel.tsx b/app/settings/core/ControlPanel.tsx index 46201395..f1413222 100644 --- a/app/settings/core/ControlPanel.tsx +++ b/app/settings/core/ControlPanel.tsx @@ -1,5 +1,4 @@ import { useState, useEffect, useMemo } from 'react'; -import { motion, AnimatePresence } from 'framer-motion'; import { useStore } from '@nanostores/react'; import * as RadixDialog from '@radix-ui/react-dialog'; import { classNames } from '~/shared/utils/classNames'; @@ -122,32 +121,6 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => { .sort((a, b) => a.order - b.order); }, [tabConfiguration, profile?.preferences?.notifications, baseTabConfig]); - // Optimize animation performance with layout animations - const gridLayoutVariants = { - hidden: { opacity: 0 }, - visible: { - opacity: 1, - transition: { - staggerChildren: 0.05, - delayChildren: 0.1, - }, - }, - }; - - const itemVariants = { - hidden: { opacity: 0, scale: 0.8 }, - visible: { - opacity: 1, - scale: 1, - transition: { - type: 'spring', - stiffness: 200, - damping: 20, - mass: 0.6, - }, - }, - }; - // Reset to default view when modal opens/closes useEffect(() => { if (!open) { @@ -226,23 +199,15 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => { break; } - // Clear loading state after a delay - setTimeout(() => setLoadingTab(null), 500); + // Clear loading state immediately for better responsiveness + setTimeout(() => setLoadingTab(null), 100); }; return (
- - - + { onPointerDownOutside={handleClose} className="relative z-[101]" > -
@@ -274,7 +237,7 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => { {(activeTab || showTabManagement) && ( @@ -293,7 +256,7 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => { {/* Close Button */} @@ -314,49 +277,50 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => { 'touch-auto', )} > - {showTabManagement ? ( ) : activeTab ? ( renderTabContent(activeTab) ) : ( - - - {visibleTabs.map((tab) => ( - - handleTabClick(tab.id as TabType)} - isActive={activeTab === tab.id} - hasUpdate={getTabUpdateStatus(tab.id)} - statusMessage={getStatusMessage(tab.id)} - description={TAB_DESCRIPTIONS[tab.id]} - isLoading={loadingTab === tab.id} - className="h-full relative" - > - {BETA_TABS.has(tab.id) && } - - - ))} - - +
+ {visibleTabs.map((tab, index) => ( +
+ handleTabClick(tab.id as TabType)} + isActive={activeTab === tab.id} + hasUpdate={getTabUpdateStatus(tab.id)} + statusMessage={getStatusMessage(tab.id)} + description={TAB_DESCRIPTIONS[tab.id]} + isLoading={loadingTab === tab.id} + className="h-full relative" + > + {BETA_TABS.has(tab.id) && } + +
+ ))} +
)} -
+
- +
diff --git a/app/settings/index.ts b/app/settings/index.ts index a9700dc6..77147b7c 100644 --- a/app/settings/index.ts +++ b/app/settings/index.ts @@ -8,6 +8,3 @@ export { TAB_LABELS, TAB_DESCRIPTIONS, DEFAULT_TAB_CONFIG } from './core/constan // Shared components export { TabTile } from './shared/components/TabTile'; export { TabManagement } from './shared/components/TabManagement'; - -// Utils -export * from './utils/animations'; diff --git a/app/settings/shared/components/TabTile.tsx b/app/settings/shared/components/TabTile.tsx index 618fa5dd..d4a63498 100644 --- a/app/settings/shared/components/TabTile.tsx +++ b/app/settings/shared/components/TabTile.tsx @@ -1,8 +1,8 @@ -import { motion } from 'framer-motion'; import * as Tooltip from '@radix-ui/react-tooltip'; import { classNames } from '~/shared/utils/classNames'; import type { TabVisibilityConfig } from '~/settings/core/types'; import { TAB_LABELS, TAB_ICONS } from '~/settings/core/constants'; +import { GlowingEffect } from '~/shared/components/ui/GlowingEffect'; interface TabTileProps { tab: TabVisibilityConfig; @@ -28,106 +28,118 @@ export const TabTile: React.FC = ({ children, }: TabTileProps) => { return ( - + - - {/* Main Content */} -
- {/* Icon */} - +
+ +
- - - - {/* Label and Description */} -
-

- {TAB_LABELS[tab.id]} -

- {description && ( -

+

+ + {/* Label and Description */} +
+

- {description} -

+ {TAB_LABELS[tab.id]} +

+ {description && ( +

+ {description} +

+ )} +
+ + {/* Update Indicator with Tooltip */} + {hasUpdate && ( + <> +
+ + + {statusMessage} + + + + )} + + {/* Children (e.g. Beta Label) */} + {children}
- - {/* Update Indicator with Tooltip */} - {hasUpdate && ( - <> -
- - - {statusMessage} - - - - - )} - - {/* Children (e.g. Beta Label) */} - {children} - +
diff --git a/app/settings/utils/animations.ts b/app/settings/utils/animations.ts deleted file mode 100644 index 48d27e8b..00000000 --- a/app/settings/utils/animations.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { Variants } from 'framer-motion'; - -export const fadeIn: Variants = { - initial: { opacity: 0 }, - animate: { opacity: 1 }, - exit: { opacity: 0 }, -}; - -export const slideIn: Variants = { - initial: { opacity: 0, y: 20 }, - animate: { opacity: 1, y: 0 }, - exit: { opacity: 0, y: -20 }, -}; - -export const scaleIn: Variants = { - initial: { opacity: 0, scale: 0.8 }, - animate: { opacity: 1, scale: 1 }, - exit: { opacity: 0, scale: 0.8 }, -}; - -export const tabAnimation: Variants = { - initial: { opacity: 0, scale: 0.8, y: 20 }, - animate: { opacity: 1, scale: 1, y: 0 }, - exit: { opacity: 0, scale: 0.8, y: -20 }, -}; - -export const overlayAnimation: Variants = { - initial: { opacity: 0 }, - animate: { opacity: 1 }, - exit: { opacity: 0 }, -}; - -export const modalAnimation: Variants = { - initial: { opacity: 0, scale: 0.95, y: 20 }, - animate: { opacity: 1, scale: 1, y: 0 }, - exit: { opacity: 0, scale: 0.95, y: 20 }, -}; - -export const transition = { - duration: 0.2, -}; diff --git a/app/shared/components/ui/GlowingEffect.tsx b/app/shared/components/ui/GlowingEffect.tsx new file mode 100644 index 00000000..1d486f68 --- /dev/null +++ b/app/shared/components/ui/GlowingEffect.tsx @@ -0,0 +1,194 @@ +'use client'; + +import { memo, useCallback, useEffect, useRef } from 'react'; +import { cn } from '~/shared/utils/cn'; +import { animate } from 'framer-motion'; + +interface GlowingEffectProps { + blur?: number; + inactiveZone?: number; + proximity?: number; + spread?: number; + variant?: 'default' | 'white'; + glow?: boolean; + className?: string; + disabled?: boolean; + movementDuration?: number; + borderWidth?: number; +} + +const GlowingEffect = memo( + ({ + blur = 0, + inactiveZone = 0.7, + proximity = 0, + spread = 20, + variant = 'default', + glow = false, + className, + movementDuration = 2, + borderWidth = 1, + disabled = true, + }: GlowingEffectProps) => { + const containerRef = useRef(null); + const lastPosition = useRef({ x: 0, y: 0 }); + const animationFrameRef = useRef(0); + + const handleMove = useCallback( + (e?: MouseEvent | { x: number; y: number }) => { + if (!containerRef.current) { + return; + } + + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + + animationFrameRef.current = requestAnimationFrame(() => { + const element = containerRef.current; + + if (!element) { + return; + } + + const { left, top, width, height } = element.getBoundingClientRect(); + const mouseX = e?.x ?? lastPosition.current.x; + const mouseY = e?.y ?? lastPosition.current.y; + + if (e) { + lastPosition.current = { x: mouseX, y: mouseY }; + } + + const center = [left + width * 0.5, top + height * 0.5]; + const distanceFromCenter = Math.hypot(mouseX - center[0], mouseY - center[1]); + const inactiveRadius = 0.5 * Math.min(width, height) * inactiveZone; + + if (distanceFromCenter < inactiveRadius) { + element.style.setProperty('--active', '0'); + return; + } + + const isActive = + mouseX > left - proximity && + mouseX < left + width + proximity && + mouseY > top - proximity && + mouseY < top + height + proximity; + + element.style.setProperty('--active', isActive ? '1' : '0'); + + if (!isActive) { + return; + } + + const currentAngle = parseFloat(element.style.getPropertyValue('--start')) || 0; + const targetAngle = (180 * Math.atan2(mouseY - center[1], mouseX - center[0])) / Math.PI + 90; + + const angleDiff = ((targetAngle - currentAngle + 180) % 360) - 180; + const newAngle = currentAngle + angleDiff; + + animate(currentAngle, newAngle, { + duration: movementDuration, + ease: [0.16, 1, 0.3, 1], + onUpdate: (value) => { + element.style.setProperty('--start', String(value)); + }, + }); + }); + }, + [inactiveZone, proximity, movementDuration], + ); + + useEffect(() => { + if (disabled) { + return undefined; + } + + const handleScroll = () => handleMove(); + const handlePointerMove = (e: PointerEvent) => handleMove(e); + + window.addEventListener('scroll', handleScroll, { passive: true }); + document.body.addEventListener('pointermove', handlePointerMove, { + passive: true, + }); + + return () => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + + window.removeEventListener('scroll', handleScroll); + document.body.removeEventListener('pointermove', handlePointerMove); + }; + }, [handleMove, disabled]); + + return ( + <> +