diff --git a/package-lock.json b/package-lock.json index f9bf2b81..a2610be3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,8 +13,8 @@ "@uiw/react-codemirror": "^4.25.3", "axios": "^1.13.2", "chart.js": "^4.5.1", - "gsap": "^3.14.2", "i18next": "^25.7.1", + "motion": "^12.34.3", "react": "^19.2.1", "react-chartjs-2": "^5.3.1", "react-dom": "^19.2.1", @@ -3113,6 +3113,33 @@ "node": ">= 6" } }, + "node_modules/framer-motion": { + "version": "12.34.3", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.34.3.tgz", + "integrity": "sha512-v81ecyZKYO/DfpTwHivqkxSUBzvceOpoI+wLfgCgoUIKxlFKEXdg0oR9imxwXumT4SFy8vRk9xzJ5l3/Du/55Q==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.34.3", + "motion-utils": "^12.29.2", + "tslib": "^2.4.0" + }, + "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 + } + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -3210,12 +3237,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gsap": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.14.2.tgz", - "integrity": "sha512-P8/mMxVLU7o4+55+1TCnQrPmgjPKnwkzkXOK1asnR9Jg2lna4tEY5qBJjMmAaOBDDZWtlRjBXjLa0w53G/uBLA==", - "license": "Standard 'no charge' license: https://gsap.com/standard-license." - }, "node_modules/has-flag": { "version": "4.0.0", "dev": true, @@ -3524,6 +3545,47 @@ "node": "*" } }, + "node_modules/motion": { + "version": "12.34.3", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.34.3.tgz", + "integrity": "sha512-xZIkBGO7v/Uvm+EyaqYd+9IpXu0sZqLywVlGdCFrrMiaO9JI4Kx51mO9KlHSWwll+gZUVY5OJsWgYI5FywJ/tw==", + "license": "MIT", + "dependencies": { + "framer-motion": "^12.34.3", + "tslib": "^2.4.0" + }, + "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 + } + } + }, + "node_modules/motion-dom": { + "version": "12.34.3", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.34.3.tgz", + "integrity": "sha512-sYgFe+pR9aIM7o4fhs2aXtOI+oqlUd33N9Yoxcgo1Fv7M20sRkHtCmzE/VRNIcq7uNJ+qio+Xubt1FXH3pQ+eQ==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.29.2" + } + }, + "node_modules/motion-utils": { + "version": "12.29.2", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.29.2.tgz", + "integrity": "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "dev": true, @@ -4025,6 +4087,12 @@ "node": ">=8.0" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "dev": true, diff --git a/package.json b/package.json index d03590da..e6bdc06f 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,8 @@ "@uiw/react-codemirror": "^4.25.3", "axios": "^1.13.2", "chart.js": "^4.5.1", - "gsap": "^3.14.2", "i18next": "^25.7.1", + "motion": "^12.34.3", "react": "^19.2.1", "react-chartjs-2": "^5.3.1", "react-dom": "^19.2.1", diff --git a/src/components/common/PageTransition.tsx b/src/components/common/PageTransition.tsx index 22311200..8f625411 100644 --- a/src/components/common/PageTransition.tsx +++ b/src/components/common/PageTransition.tsx @@ -1,12 +1,7 @@ -import { - ReactNode, - useCallback, - useLayoutEffect, - useRef, - useState, -} from 'react'; +import { ReactNode, useCallback, useLayoutEffect, useRef, useState } from 'react'; import { useLocation, type Location } from 'react-router-dom'; -import gsap from 'gsap'; +import { animate } from 'motion/mini'; +import type { AnimationPlaybackControlsWithThen } from 'motion-dom'; import { PageTransitionLayerContext, type LayerStatus } from './PageTransitionLayer'; import './PageTransition.scss'; @@ -25,6 +20,20 @@ const IOS_EXIT_TO_X_PERCENT_FORWARD = -30; const IOS_EXIT_TO_X_PERCENT_BACKWARD = 100; const IOS_ENTER_FROM_X_PERCENT_BACKWARD = -30; const IOS_EXIT_DIM_OPACITY = 0.72; +const IOS_SHADOW_VALUE = '-14px 0 24px rgba(0, 0, 0, 0.16)'; + +const easePower2Out = (progress: number) => 1 - (1 - progress) ** 3; +const easeCircOut = (progress: number) => Math.sqrt(1 - (progress - 1) ** 2); + +const buildVerticalTransform = (y: number) => `translate3d(0px, ${y}px, 0px)`; +const buildIosTransform = (xPercent: number, y: number) => `translate3d(${xPercent}%, ${y}px, 0px)`; + +const clearLayerStyles = (element: HTMLElement | null) => { + if (!element) return; + element.style.removeProperty('transform'); + element.style.removeProperty('opacity'); + element.style.removeProperty('box-shadow'); +}; type Layer = { key: string; @@ -150,16 +159,14 @@ export function PageTransition({ const targetIndex = prev.findIndex((layer) => layer.key === location.key); if (targetIndex !== -1) { - const targetStack: Layer[] = prev - .slice(0, targetIndex + 1) - .map((layer, idx): Layer => { - const isTarget = idx === targetIndex; - return { - ...layer, - location: isTarget ? location : layer.location, - status: isTarget ? 'current' : 'stacked', - }; - }); + const targetStack: Layer[] = prev.slice(0, targetIndex + 1).map((layer, idx): Layer => { + const isTarget = idx === targetIndex; + return { + ...layer, + location: isTarget ? location : layer.location, + status: isTarget ? 'current' : 'stacked', + }; + }); if (shouldSkipExitLayer) { nextLayersRef.current = targetStack; @@ -194,7 +201,7 @@ export function PageTransition({ layers, ]); - // Run GSAP animation when animating starts + // Run Motion animation when animating starts useLayoutEffect(() => { if (!isAnimating) return; @@ -204,10 +211,8 @@ export function PageTransition({ const exitingLayerEl = exitingLayerRef.current; const transitionVariant = transitionVariantRef.current; - gsap.set(currentLayerEl, { clearProps: 'transform,opacity,boxShadow' }); - if (exitingLayerEl) { - gsap.set(exitingLayerEl, { clearProps: 'transform,opacity,boxShadow' }); - } + clearLayerStyles(currentLayerEl); + clearLayerStyles(exitingLayerEl); const scrollContainer = resolveScrollContainer(); const exitScrollOffset = exitScrollOffsetRef.current; @@ -221,22 +226,21 @@ export function PageTransition({ const enterFromY = isForward ? VERTICAL_TRAVEL_DISTANCE : -VERTICAL_TRAVEL_DISTANCE; const exitToY = isForward ? -VERTICAL_TRAVEL_DISTANCE : VERTICAL_TRAVEL_DISTANCE; const exitBaseY = enterScrollOffset - exitScrollOffset; + const activeAnimations: AnimationPlaybackControlsWithThen[] = []; + let cancelled = false; + let completed = false; + const completeTransition = () => { + if (completed) return; + completed = true; - const tl = gsap.timeline({ - onComplete: () => { - const nextLayers = nextLayersRef.current; - nextLayersRef.current = null; - setLayers((prev) => nextLayers ?? prev.filter((layer) => layer.status !== 'exiting')); - setIsAnimating(false); + const nextLayers = nextLayersRef.current; + nextLayersRef.current = null; + setLayers((prev) => nextLayers ?? prev.filter((layer) => layer.status !== 'exiting')); + setIsAnimating(false); - if (currentLayerEl) { - gsap.set(currentLayerEl, { clearProps: 'transform,opacity,boxShadow' }); - } - if (exitingLayerEl) { - gsap.set(exitingLayerEl, { clearProps: 'transform,opacity,boxShadow' }); - } - }, - }); + clearLayerStyles(currentLayerEl); + clearLayerStyles(exitingLayerEl); + }; if (transitionVariant === 'ios') { const exitToXPercent = isForward @@ -247,90 +251,104 @@ export function PageTransition({ : IOS_ENTER_FROM_X_PERCENT_BACKWARD; if (exitingLayerEl) { - gsap.set(exitingLayerEl, { - y: exitBaseY, - xPercent: 0, - opacity: 1, - }); + exitingLayerEl.style.transform = buildIosTransform(0, exitBaseY); + exitingLayerEl.style.opacity = '1'; } - gsap.set(currentLayerEl, { - xPercent: enterFromXPercent, - opacity: 1, - }); - - const shadowValue = '-14px 0 24px rgba(0, 0, 0, 0.16)'; + currentLayerEl.style.transform = buildIosTransform(enterFromXPercent, 0); + currentLayerEl.style.opacity = '1'; const topLayerEl = isForward ? currentLayerEl : exitingLayerEl; if (topLayerEl) { - gsap.set(topLayerEl, { boxShadow: shadowValue }); + topLayerEl.style.boxShadow = IOS_SHADOW_VALUE; } if (exitingLayerEl) { - tl.to( - exitingLayerEl, - { - xPercent: exitToXPercent, - opacity: isForward ? IOS_EXIT_DIM_OPACITY : 1, - duration: IOS_TRANSITION_DURATION, - ease: 'power2.out', - force3D: true, - }, - 0 + activeAnimations.push( + animate( + exitingLayerEl, + { + transform: [ + buildIosTransform(0, exitBaseY), + buildIosTransform(exitToXPercent, exitBaseY), + ], + opacity: [1, isForward ? IOS_EXIT_DIM_OPACITY : 1], + }, + { + duration: IOS_TRANSITION_DURATION, + ease: easePower2Out, + } + ) ); } - tl.to( - currentLayerEl, - { - xPercent: 0, - opacity: 1, - duration: IOS_TRANSITION_DURATION, - ease: 'power2.out', - force3D: true, - }, - 0 + activeAnimations.push( + animate( + currentLayerEl, + { + transform: [buildIosTransform(enterFromXPercent, 0), buildIosTransform(0, 0)], + opacity: [1, 1], + }, + { + duration: IOS_TRANSITION_DURATION, + ease: easePower2Out, + } + ) ); } else { // Exit animation: fade out with slight movement (runs simultaneously) if (exitingLayerEl) { - gsap.set(exitingLayerEl, { y: exitBaseY }); - tl.to( - exitingLayerEl, - { - y: exitBaseY + exitToY, - opacity: 0, - duration: VERTICAL_TRANSITION_DURATION, - ease: 'circ.out', - force3D: true, - }, - 0 + exitingLayerEl.style.transform = buildVerticalTransform(exitBaseY); + activeAnimations.push( + animate( + exitingLayerEl, + { + transform: [ + buildVerticalTransform(exitBaseY), + buildVerticalTransform(exitBaseY + exitToY), + ], + opacity: [1, 0], + }, + { + duration: VERTICAL_TRANSITION_DURATION, + ease: easeCircOut, + } + ) ); } // Enter animation: fade in with slight movement (runs simultaneously) - tl.fromTo( - currentLayerEl, - { y: enterFromY, opacity: 0 }, - { - y: 0, - opacity: 1, - duration: VERTICAL_TRANSITION_DURATION, - ease: 'circ.out', - force3D: true, - onComplete: () => { - if (currentLayerEl) { - gsap.set(currentLayerEl, { clearProps: 'transform,opacity' }); - } + currentLayerEl.style.transform = buildVerticalTransform(enterFromY); + currentLayerEl.style.opacity = '0'; + activeAnimations.push( + animate( + currentLayerEl, + { + transform: [buildVerticalTransform(enterFromY), buildVerticalTransform(0)], + opacity: [0, 1], }, - }, - 0 + { + duration: VERTICAL_TRANSITION_DURATION, + ease: easeCircOut, + } + ) ); } + if (!activeAnimations.length) { + completeTransition(); + } else { + void Promise.all( + activeAnimations.map((animation) => animation.finished.catch(() => undefined)) + ).then(() => { + if (cancelled) return; + completeTransition(); + }); + } + return () => { - tl.kill(); - gsap.killTweensOf([currentLayerEl, exitingLayerEl]); + cancelled = true; + activeAnimations.forEach((animation) => animation.stop()); }; }, [isAnimating, resolveScrollContainer]); @@ -348,30 +366,30 @@ export function PageTransition({ return layers.map((layer, index) => { const shouldKeepStacked = layer.status === 'stacked' && index === keepStackedIndex; return ( -
- - {render(layer.location)} - -
+
+ + {render(layer.location)} + +
); }); })()} diff --git a/src/pages/AuthFilesPage.tsx b/src/pages/AuthFilesPage.tsx index 764e2f93..47aa141e 100644 --- a/src/pages/AuthFilesPage.tsx +++ b/src/pages/AuthFilesPage.tsx @@ -1,8 +1,17 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ChangeEvent } from 'react'; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type ChangeEvent, +} from 'react'; import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; -import gsap from 'gsap'; +import { animate } from 'motion/mini'; +import type { AnimationPlaybackControlsWithThen } from 'motion-dom'; import { useInterval } from '@/hooks/useInterval'; import { useHeaderRefresh } from '@/hooks/useHeaderRefresh'; import { usePageTransitionLayer } from '@/components/common/PageTransitionLayer'; @@ -21,7 +30,7 @@ import { isRuntimeOnlyAuthFile, normalizeProviderKey, type QuotaProviderType, - type ResolvedTheme + type ResolvedTheme, } from '@/features/authFiles/constants'; import { AuthFileCard } from '@/features/authFiles/components/AuthFileCard'; import { AuthFileDetailModal } from '@/features/authFiles/components/AuthFileDetailModal'; @@ -40,6 +49,11 @@ import { useAuthStore, useNotificationStore, useThemeStore } from '@/stores'; import type { AuthFileItem } from '@/types'; import styles from './AuthFilesPage.module.scss'; +const easePower3Out = (progress: number) => 1 - (1 - progress) ** 4; +const easePower2In = (progress: number) => progress ** 3; +const BATCH_BAR_BASE_TRANSFORM = 'translateX(-50%)'; +const BATCH_BAR_HIDDEN_TRANSFORM = 'translateX(-50%) translateY(56px)'; + export function AuthFilesPage() { const { t } = useTranslation(); const showNotification = useNotificationStore((state) => state.showNotification); @@ -59,6 +73,7 @@ export function AuthFilesPage() { const [viewMode, setViewMode] = useState<'diagram' | 'list'>('list'); const [batchActionBarVisible, setBatchActionBarVisible] = useState(false); const floatingBatchActionsRef = useRef(null); + const batchActionAnimationRef = useRef(null); const previousSelectionCountRef = useRef(0); const selectionCountRef = useRef(0); @@ -85,7 +100,7 @@ export function AuthFilesPage() { selectAllVisible, deselectAll, batchSetStatus, - batchDelete + batchDelete, } = useAuthFilesData({ refreshKeyStats }); const statusBarCache = useAuthFilesStatusBarCache(files, usageDetails); @@ -104,7 +119,7 @@ export function AuthFilesPage() { handleDeleteLink, handleToggleFork, handleRenameAlias, - handleDeleteAlias + handleDeleteAlias, } = useAuthFilesOauth({ viewMode, files }); const { @@ -115,7 +130,7 @@ export function AuthFilesPage() { modelsFileType, modelsError, showModels, - closeModelsModal + closeModelsModal, } = useAuthFilesModels(); const { @@ -125,11 +140,11 @@ export function AuthFilesPage() { openPrefixProxyEditor, closePrefixProxyEditor, handlePrefixProxyChange, - handlePrefixProxySave + handlePrefixProxySave, } = useAuthFilesPrefixProxyEditor({ disableControls: connectionStatus !== 'connected', loadFiles, - loadKeyStats: refreshKeyStats + loadKeyStats: refreshKeyStats, }); const disableControls = connectionStatus !== 'connected'; @@ -292,7 +307,7 @@ export function AuthFilesPage() { } const nextSearch = params.toString(); navigate(`/auth-files/oauth-excluded${nextSearch ? `?${nextSearch}` : ''}`, { - state: { fromAuthFiles: true } + state: { fromAuthFiles: true }, }); }, [filter, navigate] @@ -307,7 +322,7 @@ export function AuthFilesPage() { } const nextSearch = params.toString(); navigate(`/auth-files/oauth-model-alias${nextSearch ? `?${nextSearch}` : ''}`, { - state: { fromAuthFiles: true } + state: { fromAuthFiles: true }, }); }, [filter, navigate] @@ -354,31 +369,55 @@ export function AuthFilesPage() { const actionsEl = floatingBatchActionsRef.current; if (!actionsEl) return; - gsap.killTweensOf(actionsEl); + batchActionAnimationRef.current?.stop(); + batchActionAnimationRef.current = null; if (currentCount > 0 && previousCount === 0) { - gsap.fromTo( + batchActionAnimationRef.current = animate( actionsEl, - { y: 56, autoAlpha: 0 }, - { y: 0, autoAlpha: 1, duration: 0.28, ease: 'power3.out' } + { + transform: [BATCH_BAR_HIDDEN_TRANSFORM, BATCH_BAR_BASE_TRANSFORM], + opacity: [0, 1], + }, + { + duration: 0.28, + ease: easePower3Out, + onComplete: () => { + actionsEl.style.transform = BATCH_BAR_BASE_TRANSFORM; + actionsEl.style.opacity = '1'; + }, + } ); } else if (currentCount === 0 && previousCount > 0) { - gsap.to(actionsEl, { - y: 56, - autoAlpha: 0, - duration: 0.22, - ease: 'power2.in', - onComplete: () => { - if (selectionCountRef.current === 0) { - setBatchActionBarVisible(false); - } + batchActionAnimationRef.current = animate( + actionsEl, + { + transform: [BATCH_BAR_BASE_TRANSFORM, BATCH_BAR_HIDDEN_TRANSFORM], + opacity: [1, 0], + }, + { + duration: 0.22, + ease: easePower2In, + onComplete: () => { + if (selectionCountRef.current === 0) { + setBatchActionBarVisible(false); + } + }, } - }); + ); } previousSelectionCountRef.current = currentCount; }, [batchActionBarVisible, selectionCount]); + useEffect( + () => () => { + batchActionAnimationRef.current?.stop(); + batchActionAnimationRef.current = null; + }, + [] + ); + const renderFilterTags = () => (
{existingTypes.map((type) => { @@ -395,7 +434,7 @@ export function AuthFilesPage() { style={{ backgroundColor: isActive ? color.text : color.bg, color: isActive ? activeTextColor : color.text, - borderColor: color.text + borderColor: color.text, }} onClick={() => { setFilter(type); @@ -442,7 +481,9 @@ export function AuthFilesPage() {