mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-06-26 18:26:38 +00:00
refactor: replace framer-motion with css transitions
remove animation utils and optimize tab tile interactions add glowing border effect to tab tiles add glowing effect component and refactor animations
This commit is contained in:
parent
0d970d6c28
commit
c9703b132a
@ -1,5 +1,4 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
|
||||||
import { useStore } from '@nanostores/react';
|
import { useStore } from '@nanostores/react';
|
||||||
import * as RadixDialog from '@radix-ui/react-dialog';
|
import * as RadixDialog from '@radix-ui/react-dialog';
|
||||||
import { classNames } from '~/shared/utils/classNames';
|
import { classNames } from '~/shared/utils/classNames';
|
||||||
@ -122,32 +121,6 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => {
|
|||||||
.sort((a, b) => a.order - b.order);
|
.sort((a, b) => a.order - b.order);
|
||||||
}, [tabConfiguration, profile?.preferences?.notifications, baseTabConfig]);
|
}, [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
|
// Reset to default view when modal opens/closes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
@ -226,23 +199,15 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear loading state after a delay
|
// Clear loading state immediately for better responsiveness
|
||||||
setTimeout(() => setLoadingTab(null), 500);
|
setTimeout(() => setLoadingTab(null), 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RadixDialog.Root open={open}>
|
<RadixDialog.Root open={open}>
|
||||||
<RadixDialog.Portal>
|
<RadixDialog.Portal>
|
||||||
<div className="fixed inset-0 flex items-center justify-center z-[100] modern-scrollbar">
|
<div className="fixed inset-0 flex items-center justify-center z-[100] modern-scrollbar">
|
||||||
<RadixDialog.Overlay asChild>
|
<RadixDialog.Overlay className="absolute inset-0 bg-black/70 dark:bg-black/50 backdrop-blur-sm transition-opacity duration-200" />
|
||||||
<motion.div
|
|
||||||
className="absolute inset-0 bg-black/70 dark:bg-black/80 backdrop-blur-sm"
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
/>
|
|
||||||
</RadixDialog.Overlay>
|
|
||||||
|
|
||||||
<RadixDialog.Content
|
<RadixDialog.Content
|
||||||
aria-describedby={undefined}
|
aria-describedby={undefined}
|
||||||
@ -250,19 +215,17 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => {
|
|||||||
onPointerDownOutside={handleClose}
|
onPointerDownOutside={handleClose}
|
||||||
className="relative z-[101]"
|
className="relative z-[101]"
|
||||||
>
|
>
|
||||||
<motion.div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'w-[1200px] h-[90vh]',
|
'w-[1200px] h-[90vh]',
|
||||||
'bg-[#FAFAFA] dark:bg-[#0A0A0A]',
|
'bg-bolt-elements-background-depth-1',
|
||||||
'rounded-2xl shadow-2xl',
|
'rounded-2xl shadow-2xl',
|
||||||
'border border-[#E5E5E5] dark:border-[#1A1A1A]',
|
'border border-bolt-elements-borderColor',
|
||||||
'flex flex-col overflow-hidden',
|
'flex flex-col overflow-hidden',
|
||||||
'relative',
|
'relative',
|
||||||
|
'transform transition-all duration-200 ease-out',
|
||||||
|
open ? 'opacity-100 scale-100 translate-y-0' : 'opacity-0 scale-95 translate-y-4',
|
||||||
)}
|
)}
|
||||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
>
|
>
|
||||||
<div className="absolute inset-0 overflow-hidden rounded-2xl">
|
<div className="absolute inset-0 overflow-hidden rounded-2xl">
|
||||||
<BackgroundRays />
|
<BackgroundRays />
|
||||||
@ -274,7 +237,7 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => {
|
|||||||
{(activeTab || showTabManagement) && (
|
{(activeTab || showTabManagement) && (
|
||||||
<button
|
<button
|
||||||
onClick={handleBack}
|
onClick={handleBack}
|
||||||
className="flex items-center justify-center w-8 h-8 rounded-full bg-transparent hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-all duration-200"
|
className="flex items-center justify-center w-8 h-8 rounded-full bg-transparent hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-colors duration-150"
|
||||||
>
|
>
|
||||||
<div className="i-ph:arrow-left w-4 h-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
|
<div className="i-ph:arrow-left w-4 h-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
|
||||||
</button>
|
</button>
|
||||||
@ -293,7 +256,7 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => {
|
|||||||
{/* Close Button */}
|
{/* Close Button */}
|
||||||
<button
|
<button
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
className="flex items-center justify-center w-8 h-8 rounded-full bg-transparent hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-all duration-200"
|
className="flex items-center justify-center w-8 h-8 rounded-full bg-transparent hover:bg-purple-500/10 dark:hover:bg-purple-500/20 group transition-colors duration-150"
|
||||||
>
|
>
|
||||||
<div className="i-ph:x w-4 h-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
|
<div className="i-ph:x w-4 h-4 text-gray-500 dark:text-gray-400 group-hover:text-purple-500 transition-colors" />
|
||||||
</button>
|
</button>
|
||||||
@ -314,49 +277,50 @@ export const ControlPanel = ({ open, onClose }: ControlPanelProps) => {
|
|||||||
'touch-auto',
|
'touch-auto',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<motion.div
|
<div
|
||||||
key={activeTab || 'home'}
|
className={classNames(
|
||||||
initial={{ opacity: 0 }}
|
'p-6 transition-opacity duration-150',
|
||||||
animate={{ opacity: 1 }}
|
activeTab || showTabManagement ? 'opacity-100' : 'opacity-100',
|
||||||
exit={{ opacity: 0 }}
|
)}
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
className="p-6"
|
|
||||||
>
|
>
|
||||||
{showTabManagement ? (
|
{showTabManagement ? (
|
||||||
<TabManagement />
|
<TabManagement />
|
||||||
) : activeTab ? (
|
) : activeTab ? (
|
||||||
renderTabContent(activeTab)
|
renderTabContent(activeTab)
|
||||||
) : (
|
) : (
|
||||||
<motion.div
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 relative">
|
||||||
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 relative"
|
{visibleTabs.map((tab, index) => (
|
||||||
variants={gridLayoutVariants}
|
<div
|
||||||
initial="hidden"
|
key={tab.id}
|
||||||
animate="visible"
|
className={classNames(
|
||||||
>
|
'aspect-[1.5/1] transition-transform duration-100 ease-out',
|
||||||
<AnimatePresence mode="popLayout">
|
'hover:scale-[1.01]',
|
||||||
{visibleTabs.map((tab) => (
|
)}
|
||||||
<motion.div key={tab.id} layout variants={itemVariants} className="aspect-[1.5/1]">
|
style={{
|
||||||
<TabTile
|
animationDelay: `${index * 30}ms`,
|
||||||
tab={tab}
|
animation: open ? 'fadeInUp 200ms ease-out forwards' : 'none',
|
||||||
onClick={() => handleTabClick(tab.id as TabType)}
|
}}
|
||||||
isActive={activeTab === tab.id}
|
>
|
||||||
hasUpdate={getTabUpdateStatus(tab.id)}
|
<TabTile
|
||||||
statusMessage={getStatusMessage(tab.id)}
|
tab={tab}
|
||||||
description={TAB_DESCRIPTIONS[tab.id]}
|
onClick={() => handleTabClick(tab.id as TabType)}
|
||||||
isLoading={loadingTab === tab.id}
|
isActive={activeTab === tab.id}
|
||||||
className="h-full relative"
|
hasUpdate={getTabUpdateStatus(tab.id)}
|
||||||
>
|
statusMessage={getStatusMessage(tab.id)}
|
||||||
{BETA_TABS.has(tab.id) && <BetaLabel />}
|
description={TAB_DESCRIPTIONS[tab.id]}
|
||||||
</TabTile>
|
isLoading={loadingTab === tab.id}
|
||||||
</motion.div>
|
className="h-full relative"
|
||||||
))}
|
>
|
||||||
</AnimatePresence>
|
{BETA_TABS.has(tab.id) && <BetaLabel />}
|
||||||
</motion.div>
|
</TabTile>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
</RadixDialog.Content>
|
</RadixDialog.Content>
|
||||||
</div>
|
</div>
|
||||||
</RadixDialog.Portal>
|
</RadixDialog.Portal>
|
||||||
|
@ -8,6 +8,3 @@ export { TAB_LABELS, TAB_DESCRIPTIONS, DEFAULT_TAB_CONFIG } from './core/constan
|
|||||||
// Shared components
|
// Shared components
|
||||||
export { TabTile } from './shared/components/TabTile';
|
export { TabTile } from './shared/components/TabTile';
|
||||||
export { TabManagement } from './shared/components/TabManagement';
|
export { TabManagement } from './shared/components/TabManagement';
|
||||||
|
|
||||||
// Utils
|
|
||||||
export * from './utils/animations';
|
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { motion } from 'framer-motion';
|
|
||||||
import * as Tooltip from '@radix-ui/react-tooltip';
|
import * as Tooltip from '@radix-ui/react-tooltip';
|
||||||
import { classNames } from '~/shared/utils/classNames';
|
import { classNames } from '~/shared/utils/classNames';
|
||||||
import type { TabVisibilityConfig } from '~/settings/core/types';
|
import type { TabVisibilityConfig } from '~/settings/core/types';
|
||||||
import { TAB_LABELS, TAB_ICONS } from '~/settings/core/constants';
|
import { TAB_LABELS, TAB_ICONS } from '~/settings/core/constants';
|
||||||
|
import { GlowingEffect } from '~/shared/components/ui/GlowingEffect';
|
||||||
|
|
||||||
interface TabTileProps {
|
interface TabTileProps {
|
||||||
tab: TabVisibilityConfig;
|
tab: TabVisibilityConfig;
|
||||||
@ -28,106 +28,118 @@ export const TabTile: React.FC<TabTileProps> = ({
|
|||||||
children,
|
children,
|
||||||
}: TabTileProps) => {
|
}: TabTileProps) => {
|
||||||
return (
|
return (
|
||||||
<Tooltip.Provider delayDuration={200}>
|
<Tooltip.Provider delayDuration={0}>
|
||||||
<Tooltip.Root>
|
<Tooltip.Root>
|
||||||
<Tooltip.Trigger asChild>
|
<Tooltip.Trigger asChild>
|
||||||
<motion.div
|
<div className={classNames('min-h-[160px] list-none', className || '')}>
|
||||||
onClick={onClick}
|
<div className="relative h-full rounded-xl border border-[#E5E5E5] dark:border-[#333333] p-0.5">
|
||||||
className={classNames(
|
<GlowingEffect
|
||||||
'relative flex flex-col items-center p-6 rounded-xl',
|
blur={0}
|
||||||
'w-full h-full min-h-[160px]',
|
borderWidth={1}
|
||||||
'bg-white dark:bg-[#141414]',
|
spread={20}
|
||||||
'border border-[#E5E5E5] dark:border-[#333333]',
|
glow={true}
|
||||||
'group',
|
disabled={false}
|
||||||
'hover:bg-purple-50 dark:hover:bg-[#1a1a1a]',
|
proximity={40}
|
||||||
'hover:border-purple-200 dark:hover:border-purple-900/30',
|
inactiveZone={0.3}
|
||||||
isActive ? 'border-purple-500 dark:border-purple-500/50 bg-purple-500/5 dark:bg-purple-500/10' : '',
|
movementDuration={0.4}
|
||||||
isLoading ? 'cursor-wait opacity-70' : '',
|
/>
|
||||||
className || '',
|
<div
|
||||||
)}
|
onClick={onClick}
|
||||||
>
|
|
||||||
{/* Main Content */}
|
|
||||||
<div className="flex flex-col items-center justify-center flex-1 w-full">
|
|
||||||
{/* Icon */}
|
|
||||||
<motion.div
|
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'relative',
|
'relative flex flex-col items-center justify-center h-full p-4 rounded-lg',
|
||||||
'w-14 h-14',
|
'bg-white dark:bg-[#141414]',
|
||||||
'flex items-center justify-center',
|
'group cursor-pointer',
|
||||||
'rounded-xl',
|
'hover:bg-purple-50 dark:hover:bg-[#1a1a1a]',
|
||||||
'bg-gray-100 dark:bg-gray-800',
|
'transition-colors duration-100 ease-out',
|
||||||
'ring-1 ring-gray-200 dark:ring-gray-700',
|
isActive ? 'bg-purple-500/5 dark:bg-purple-500/10' : '',
|
||||||
'group-hover:bg-purple-100 dark:group-hover:bg-gray-700/80',
|
isLoading ? 'cursor-wait opacity-70 pointer-events-none' : '',
|
||||||
'group-hover:ring-purple-200 dark:group-hover:ring-purple-800/30',
|
|
||||||
isActive ? 'bg-purple-500/10 dark:bg-purple-500/10 ring-purple-500/30 dark:ring-purple-500/20' : '',
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<motion.div
|
{/* Icon */}
|
||||||
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
TAB_ICONS[tab.id],
|
'relative',
|
||||||
'w-8 h-8',
|
'w-14 h-14',
|
||||||
'text-gray-600 dark:text-gray-300',
|
'flex items-center justify-center',
|
||||||
'group-hover:text-purple-500 dark:group-hover:text-purple-400/80',
|
'rounded-xl',
|
||||||
isActive ? 'text-purple-500 dark:text-purple-400/90' : '',
|
'bg-gray-100 dark:bg-gray-800',
|
||||||
)}
|
'ring-1 ring-gray-200 dark:ring-gray-700',
|
||||||
/>
|
'group-hover:bg-purple-100 dark:group-hover:bg-gray-700/80',
|
||||||
</motion.div>
|
'group-hover:ring-purple-200 dark:group-hover:ring-purple-800/30',
|
||||||
|
'transition-all duration-100 ease-out',
|
||||||
{/* Label and Description */}
|
isActive ? 'bg-purple-500/10 dark:bg-purple-500/10 ring-purple-500/30 dark:ring-purple-500/20' : '',
|
||||||
<div className="flex flex-col items-center mt-5 w-full">
|
|
||||||
<h3
|
|
||||||
className={classNames(
|
|
||||||
'text-[15px] font-medium leading-snug mb-2',
|
|
||||||
'text-gray-700 dark:text-gray-200',
|
|
||||||
'group-hover:text-purple-600 dark:group-hover:text-purple-300/90',
|
|
||||||
isActive ? 'text-purple-500 dark:text-purple-400/90' : '',
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{TAB_LABELS[tab.id]}
|
<div
|
||||||
</h3>
|
|
||||||
{description && (
|
|
||||||
<p
|
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'text-[13px] leading-relaxed',
|
TAB_ICONS[tab.id],
|
||||||
'text-gray-500 dark:text-gray-400',
|
'w-8 h-8',
|
||||||
'max-w-[85%]',
|
'text-gray-600 dark:text-gray-300',
|
||||||
'text-center',
|
'group-hover:text-purple-500 dark:group-hover:text-purple-400/80',
|
||||||
'group-hover:text-purple-500 dark:group-hover:text-purple-400/70',
|
'transition-colors duration-100 ease-out',
|
||||||
isActive ? 'text-purple-400 dark:text-purple-400/80' : '',
|
isActive ? 'text-purple-500 dark:text-purple-400/90' : '',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Label and Description */}
|
||||||
|
<div className="flex flex-col items-center mt-4 w-full">
|
||||||
|
<h3
|
||||||
|
className={classNames(
|
||||||
|
'text-[15px] font-medium leading-snug mb-2',
|
||||||
|
'text-gray-700 dark:text-gray-200',
|
||||||
|
'group-hover:text-purple-600 dark:group-hover:text-purple-300/90',
|
||||||
|
'transition-colors duration-100 ease-out',
|
||||||
|
isActive ? 'text-purple-500 dark:text-purple-400/90' : '',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{description}
|
{TAB_LABELS[tab.id]}
|
||||||
</p>
|
</h3>
|
||||||
|
{description && (
|
||||||
|
<p
|
||||||
|
className={classNames(
|
||||||
|
'text-[13px] leading-relaxed',
|
||||||
|
'text-gray-500 dark:text-gray-400',
|
||||||
|
'max-w-[85%]',
|
||||||
|
'text-center',
|
||||||
|
'group-hover:text-purple-500 dark:group-hover:text-purple-400/70',
|
||||||
|
'transition-colors duration-100 ease-out',
|
||||||
|
isActive ? 'text-purple-400 dark:text-purple-400/80' : '',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Update Indicator with Tooltip */}
|
||||||
|
{hasUpdate && (
|
||||||
|
<>
|
||||||
|
<div className="absolute top-4 right-4 w-2 h-2 rounded-full bg-purple-500 dark:bg-purple-400 animate-pulse" />
|
||||||
|
<Tooltip.Portal>
|
||||||
|
<Tooltip.Content
|
||||||
|
className={classNames(
|
||||||
|
'px-3 py-1.5 rounded-lg',
|
||||||
|
'bg-[#18181B] text-white',
|
||||||
|
'text-sm font-medium',
|
||||||
|
'select-none',
|
||||||
|
'z-[100]',
|
||||||
|
)}
|
||||||
|
side="top"
|
||||||
|
sideOffset={5}
|
||||||
|
>
|
||||||
|
{statusMessage}
|
||||||
|
<Tooltip.Arrow className="fill-[#18181B]" />
|
||||||
|
</Tooltip.Content>
|
||||||
|
</Tooltip.Portal>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Children (e.g. Beta Label) */}
|
||||||
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{/* Update Indicator with Tooltip */}
|
|
||||||
{hasUpdate && (
|
|
||||||
<>
|
|
||||||
<div className="absolute top-4 right-4 w-2 h-2 rounded-full bg-purple-500 dark:bg-purple-400 animate-pulse" />
|
|
||||||
<Tooltip.Portal>
|
|
||||||
<Tooltip.Content
|
|
||||||
className={classNames(
|
|
||||||
'px-3 py-1.5 rounded-lg',
|
|
||||||
'bg-[#18181B] text-white',
|
|
||||||
'text-sm font-medium',
|
|
||||||
'select-none',
|
|
||||||
'z-[100]',
|
|
||||||
)}
|
|
||||||
side="top"
|
|
||||||
sideOffset={5}
|
|
||||||
>
|
|
||||||
{statusMessage}
|
|
||||||
<Tooltip.Arrow className="fill-[#18181B]" />
|
|
||||||
</Tooltip.Content>
|
|
||||||
</Tooltip.Portal>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Children (e.g. Beta Label) */}
|
|
||||||
{children}
|
|
||||||
</motion.div>
|
|
||||||
</Tooltip.Trigger>
|
</Tooltip.Trigger>
|
||||||
</Tooltip.Root>
|
</Tooltip.Root>
|
||||||
</Tooltip.Provider>
|
</Tooltip.Provider>
|
||||||
|
@ -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,
|
|
||||||
};
|
|
194
app/shared/components/ui/GlowingEffect.tsx
Normal file
194
app/shared/components/ui/GlowingEffect.tsx
Normal file
@ -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<HTMLDivElement>(null);
|
||||||
|
const lastPosition = useRef({ x: 0, y: 0 });
|
||||||
|
const animationFrameRef = useRef<number>(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 (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none absolute -inset-px hidden rounded-[inherit] border opacity-0 transition-opacity',
|
||||||
|
glow && 'opacity-100',
|
||||||
|
variant === 'white' && 'border-white',
|
||||||
|
disabled && '!block',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
'--blur': `${blur}px`,
|
||||||
|
'--spread': spread,
|
||||||
|
'--start': '0',
|
||||||
|
'--active': '0',
|
||||||
|
'--glowingeffect-border-width': `${borderWidth}px`,
|
||||||
|
'--repeating-conic-gradient-times': '5',
|
||||||
|
'--gradient':
|
||||||
|
variant === 'white'
|
||||||
|
? `repeating-conic-gradient(
|
||||||
|
from 236.84deg at 50% 50%,
|
||||||
|
var(--black),
|
||||||
|
var(--black) calc(25% / var(--repeating-conic-gradient-times))
|
||||||
|
)`
|
||||||
|
: `radial-gradient(circle, #9333ea 10%, #9333ea00 20%),
|
||||||
|
radial-gradient(circle at 40% 40%, #a855f7 5%, #a855f700 15%),
|
||||||
|
radial-gradient(circle at 60% 60%, #8b5cf6 10%, #8b5cf600 20%),
|
||||||
|
radial-gradient(circle at 40% 60%, #f63bdd 10%, #3b82f600 20%),
|
||||||
|
repeating-conic-gradient(
|
||||||
|
from 236.84deg at 50% 50%,
|
||||||
|
#9333ea 0%,
|
||||||
|
#a855f7 calc(25% / var(--repeating-conic-gradient-times)),
|
||||||
|
#8b5cf6 calc(50% / var(--repeating-conic-gradient-times)),
|
||||||
|
#f63bdd calc(75% / var(--repeating-conic-gradient-times)),
|
||||||
|
#9333ea calc(100% / var(--repeating-conic-gradient-times))
|
||||||
|
)`,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none absolute inset-0 rounded-[inherit] opacity-100 transition-opacity',
|
||||||
|
glow && 'opacity-100',
|
||||||
|
blur > 0 && 'blur-[var(--blur)] ',
|
||||||
|
className,
|
||||||
|
disabled && '!hidden',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'glow',
|
||||||
|
'rounded-[inherit]',
|
||||||
|
'after:content-[""] after:rounded-[inherit] after:absolute after:inset-[calc(-1*var(--glowingeffect-border-width))]',
|
||||||
|
'after:[border:var(--glowingeffect-border-width)_solid_transparent]',
|
||||||
|
'after:[background:var(--gradient)] after:[background-attachment:fixed]',
|
||||||
|
'after:opacity-[var(--active)] after:transition-opacity after:duration-300',
|
||||||
|
'after:[mask-clip:padding-box,border-box]',
|
||||||
|
'after:[mask-composite:intersect]',
|
||||||
|
'after:[mask-image:linear-gradient(#0000,#0000),conic-gradient(from_calc((var(--start)-var(--spread))*1deg),#00000000_0deg,#fff,#00000000_calc(var(--spread)*2deg))]',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
GlowingEffect.displayName = 'GlowingEffect';
|
||||||
|
|
||||||
|
export { GlowingEffect };
|
6
app/shared/utils/cn.ts
Normal file
6
app/shared/utils/cn.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { type ClassValue, clsx } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user