diff --git a/package-lock.json b/package-lock.json index 3e2c6a1..a6e9fd3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@uiw/react-codemirror": "^4.25.3", "axios": "^1.13.2", "chart.js": "^4.5.1", + "gsap": "^3.14.2", "i18next": "^25.7.1", "react": "^19.2.1", "react-chartjs-2": "^5.3.1", @@ -3194,6 +3195,12 @@ "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, diff --git a/package.json b/package.json index fcdd7dd..2ce978f 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@uiw/react-codemirror": "^4.25.3", "axios": "^1.13.2", "chart.js": "^4.5.1", + "gsap": "^3.14.2", "i18next": "^25.7.1", "react": "^19.2.1", "react-chartjs-2": "^5.3.1", diff --git a/src/App.tsx b/src/App.tsx index 0ceefab..14afc87 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,17 +1,6 @@ import { useCallback, useEffect, useState } from 'react'; -import { HashRouter, Navigate, Route, Routes } from 'react-router-dom'; +import { HashRouter, Route, Routes } from 'react-router-dom'; import { LoginPage } from '@/pages/LoginPage'; -import { DashboardPage } from '@/pages/DashboardPage'; -import { SettingsPage } from '@/pages/SettingsPage'; -import { ApiKeysPage } from '@/pages/ApiKeysPage'; -import { AiProvidersPage } from '@/pages/AiProvidersPage'; -import { AuthFilesPage } from '@/pages/AuthFilesPage'; -import { OAuthPage } from '@/pages/OAuthPage'; -import { QuotaPage } from '@/pages/QuotaPage'; -import { UsagePage } from '@/pages/UsagePage'; -import { ConfigPage } from '@/pages/ConfigPage'; -import { LogsPage } from '@/pages/LogsPage'; -import { SystemPage } from '@/pages/SystemPage'; import { NotificationContainer } from '@/components/common/NotificationContainer'; import { SplashScreen } from '@/components/common/SplashScreen'; import { MainLayout } from '@/components/layout/MainLayout'; @@ -75,27 +64,13 @@ function App() { } /> } - > - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - + /> ); diff --git a/src/components/common/PageTransition.scss b/src/components/common/PageTransition.scss new file mode 100644 index 0000000..937f438 --- /dev/null +++ b/src/components/common/PageTransition.scss @@ -0,0 +1,34 @@ +@use '@/styles/variables.scss' as *; + +.page-transition { + position: relative; + flex: 1 1 auto; + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; + + &__layer { + display: flex; + flex-direction: column; + gap: $spacing-lg; + min-height: 0; + flex: 1; + will-change: transform, opacity; + + // During animation, exit layer uses absolute positioning + &--exit { + position: absolute; + inset: 0; + z-index: 1; + overflow: hidden; + pointer-events: none; + } + } + + // When both layers exist, current layer also needs positioning + &--animating &__layer:not(&__layer--exit) { + position: relative; + z-index: 0; + } +} diff --git a/src/components/common/PageTransition.tsx b/src/components/common/PageTransition.tsx new file mode 100644 index 0000000..aa614bf --- /dev/null +++ b/src/components/common/PageTransition.tsx @@ -0,0 +1,159 @@ +import { ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { useLocation, type Location } from 'react-router-dom'; +import gsap from 'gsap'; +import './PageTransition.scss'; + +interface PageTransitionProps { + render: (location: Location) => ReactNode; + getRouteOrder?: (pathname: string) => number | null; + scrollContainerRef?: React.RefObject; +} + +const TRANSITION_DURATION = 0.65; + +type LayerStatus = 'current' | 'exiting'; + +type Layer = { + key: string; + location: Location; + status: LayerStatus; +}; + +type TransitionDirection = 'forward' | 'backward'; + +export function PageTransition({ + render, + getRouteOrder, + scrollContainerRef, +}: PageTransitionProps) { + const location = useLocation(); + const currentLayerRef = useRef(null); + const exitingLayerRef = useRef(null); + const exitScrollOffsetRef = useRef(0); + + const [isAnimating, setIsAnimating] = useState(false); + const [transitionDirection, setTransitionDirection] = useState('forward'); + const [layers, setLayers] = useState(() => [ + { + key: location.key, + location, + status: 'current', + }, + ]); + const currentLayerKey = layers[layers.length - 1]?.key ?? location.key; + const currentLayerPathname = layers[layers.length - 1]?.location.pathname; + + const resolveScrollContainer = useCallback(() => { + if (scrollContainerRef?.current) return scrollContainerRef.current; + if (typeof document === 'undefined') return null; + return document.scrollingElement as HTMLElement | null; + }, [scrollContainerRef]); + + useEffect(() => { + if (isAnimating) return; + if (location.key === currentLayerKey) return; + const scrollContainer = resolveScrollContainer(); + exitScrollOffsetRef.current = scrollContainer?.scrollTop ?? 0; + const resolveOrderIndex = (pathname?: string) => { + if (!getRouteOrder || !pathname) return null; + const index = getRouteOrder(pathname); + return typeof index === 'number' && index >= 0 ? index : null; + }; + const fromIndex = resolveOrderIndex(currentLayerPathname); + const toIndex = resolveOrderIndex(location.pathname); + const nextDirection: TransitionDirection = + fromIndex === null || toIndex === null || fromIndex === toIndex + ? 'forward' + : toIndex > fromIndex + ? 'forward' + : 'backward'; + setTransitionDirection(nextDirection); + setLayers((prev) => { + const prevCurrent = prev[prev.length - 1]; + return [ + prevCurrent + ? { ...prevCurrent, status: 'exiting' } + : { key: location.key, location, status: 'exiting' }, + { key: location.key, location, status: 'current' }, + ]; + }); + setIsAnimating(true); + }, [ + isAnimating, + location, + currentLayerKey, + currentLayerPathname, + getRouteOrder, + resolveScrollContainer, + ]); + + // Run GSAP animation when animating starts + useLayoutEffect(() => { + if (!isAnimating) return; + + if (!currentLayerRef.current) return; + + const scrollContainer = resolveScrollContainer(); + const scrollOffset = exitScrollOffsetRef.current; + if (scrollContainer && scrollOffset > 0) { + scrollContainer.scrollTo({ top: 0, left: 0, behavior: 'auto' }); + } + + const tl = gsap.timeline({ + onComplete: () => { + setLayers((prev) => prev.filter((layer) => layer.status !== 'exiting')); + setIsAnimating(false); + }, + }); + + // Exit animation: fly out to top (slow-to-fast) + if (exitingLayerRef.current) { + gsap.set(exitingLayerRef.current, { y: scrollOffset ? -scrollOffset : 0 }); + tl.fromTo( + exitingLayerRef.current, + { yPercent: 0, opacity: 1 }, + { + yPercent: transitionDirection === 'forward' ? -100 : 100, + opacity: 0, + duration: TRANSITION_DURATION, + ease: 'power3.in', // slow start, fast end + }, + 0 + ); + } + + // Enter animation: slide in from bottom (slow-to-fast) + tl.fromTo( + currentLayerRef.current, + { yPercent: transitionDirection === 'forward' ? 100 : -100, opacity: 0 }, + { + yPercent: 0, + opacity: 1, + duration: TRANSITION_DURATION, + ease: 'power2.in', // slow start, fast end + }, + 0 + ); + + return () => { + tl.kill(); + gsap.killTweensOf([currentLayerRef.current, exitingLayerRef.current]); + }; + }, [isAnimating, transitionDirection, resolveScrollContainer]); + + return ( +
+ {layers.map((layer) => ( +
+ {render(layer.location)} +
+ ))} +
+ ); +} diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx index d1305a6..1a3b8bf 100644 --- a/src/components/layout/MainLayout.tsx +++ b/src/components/layout/MainLayout.tsx @@ -7,11 +7,13 @@ import { useRef, useState, } from 'react'; -import { NavLink, Outlet, useLocation } from 'react-router-dom'; +import { NavLink, useLocation } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/Button'; import { Modal } from '@/components/ui/Modal'; import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; +import { PageTransition } from '@/components/common/PageTransition'; +import { MainRoutes } from '@/router/MainRoutes'; import { IconBot, IconChartLine, @@ -200,6 +202,7 @@ export function MainLayout() { const [requestLogDraft, setRequestLogDraft] = useState(false); const [requestLogTouched, setRequestLogTouched] = useState(false); const [requestLogSaving, setRequestLogSaving] = useState(false); + const contentRef = useRef(null); const brandCollapseTimer = useRef | null>(null); const headerRef = useRef(null); const versionTapCount = useRef(0); @@ -341,6 +344,7 @@ export function MainLayout() { }); }, [fetchConfig]); + const statusClass = connectionStatus === 'connected' ? 'success' @@ -365,6 +369,18 @@ export function MainLayout() { : []), { path: '/system', label: t('nav.system_info'), icon: sidebarIcons.system }, ]; + const navOrder = navItems.map((item) => item.path); + const getRouteOrder = (pathname: string) => { + const trimmedPath = + pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname; + const normalizedPath = trimmedPath === '/dashboard' ? '/' : trimmedPath; + const exactIndex = navOrder.indexOf(normalizedPath); + if (exactIndex !== -1) return exactIndex; + const nestedIndex = navOrder.findIndex( + (path) => path !== '/' && normalizedPath.startsWith(`${path}/`) + ); + return nestedIndex === -1 ? null : nestedIndex; + }; const handleRefreshAll = async () => { clearCache(); @@ -508,9 +524,13 @@ export function MainLayout() { -
+
- + } + getRouteOrder={getRouteOrder} + scrollContainerRef={contentRef} + />