feat: Add AnimatedRoutes component for page transitions

This commit is contained in:
Peifan Li
2025-11-23 12:02:23 -05:00
parent e010a749e1
commit 05a929ee9e
7 changed files with 437 additions and 103 deletions

View File

@@ -14,6 +14,7 @@
"@mui/material": "^7.3.5",
"axios": "^1.8.1",
"dotenv": "^16.4.7",
"framer-motion": "^12.23.24",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.2.0"
@@ -2602,6 +2603,33 @@
"node": ">= 6"
}
},
"node_modules/framer-motion": {
"version": "12.23.24",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.24.tgz",
"integrity": "sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.23.23",
"motion-utils": "^12.23.6",
"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",
@@ -3051,6 +3079,21 @@
"node": "*"
}
},
"node_modules/motion-dom": {
"version": "12.23.23",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz",
"integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.23.6"
}
},
"node_modules/motion-utils": {
"version": "12.23.6",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz",
"integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -3561,6 +3604,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"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/turbo-stream": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/turbo-stream/-/turbo-stream-2.4.0.tgz",

View File

@@ -16,6 +16,7 @@
"@mui/material": "^7.3.5",
"axios": "^1.8.1",
"dotenv": "^16.4.7",
"framer-motion": "^12.23.24",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.2.0"

View File

@@ -1,21 +1,15 @@
import { Box, CssBaseline, ThemeProvider } from '@mui/material';
import axios from 'axios';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Route, BrowserRouter as Router, Routes } from 'react-router-dom';
import { BrowserRouter as Router } from 'react-router-dom';
import './App.css';
import AnimatedRoutes from './components/AnimatedRoutes';
import BilibiliPartsModal from './components/BilibiliPartsModal';
import Footer from './components/Footer';
import Header from './components/Header';
import { LanguageProvider } from './contexts/LanguageContext';
import { useSnackbar } from './contexts/SnackbarContext';
import AuthorVideos from './pages/AuthorVideos';
import CollectionPage from './pages/CollectionPage';
import Home from './pages/Home';
import LoginPage from './pages/LoginPage';
import ManagePage from './pages/ManagePage';
import SearchResults from './pages/SearchResults';
import SettingsPage from './pages/SettingsPage';
import VideoPlayer from './pages/VideoPlayer';
import getTheme from './theme';
import { Collection, DownloadInfo, Video } from './types';
@@ -738,93 +732,26 @@ function App() {
/>
<main className="main-content">
<Routes>
<Route
path="/"
element={
<Home
videos={videos}
loading={loading}
error={error}
onDeleteVideo={handleDeleteVideo}
collections={collections}
isSearchMode={isSearchMode}
searchTerm={searchTerm}
localSearchResults={localSearchResults}
youtubeLoading={youtubeLoading}
searchResults={searchResults}
onDownload={handleDownloadFromSearch}
onResetSearch={resetSearch}
/>
}
/>
<Route
path="/video/:id"
element={
<VideoPlayer
videos={videos}
onDeleteVideo={handleDeleteVideo}
collections={collections}
onAddToCollection={handleAddToCollection}
onCreateCollection={handleCreateCollection}
onRemoveFromCollection={handleRemoveFromCollection}
/>
}
/>
<Route
path="/author/:author"
element={
<AuthorVideos
videos={videos}
onDeleteVideo={handleDeleteVideo}
collections={collections}
/>
}
/>
<Route
path="/collection/:id"
element={
<CollectionPage
collections={collections}
videos={videos}
onDeleteVideo={handleDeleteVideo}
onDeleteCollection={handleDeleteCollection}
/>
}
/>
<Route
path="/search"
element={
<SearchResults
results={searchResults}
localResults={localSearchResults}
loading={loading}
youtubeLoading={youtubeLoading}
searchTerm={searchTerm}
onDownload={handleDownloadFromSearch}
onDeleteVideo={handleDeleteVideo}
onResetSearch={resetSearch}
collections={collections}
/>
}
/>
<Route
path="/manage"
element={
<ManagePage
videos={videos}
onDeleteVideo={handleDeleteVideo}
collections={collections}
onDeleteCollection={handleDeleteCollection}
/>
}
/>
<Route
path="/settings"
element={<SettingsPage />}
/>
</Routes>
<AnimatedRoutes
videos={videos}
loading={loading}
error={error}
onDeleteVideo={handleDeleteVideo}
collections={collections}
isSearchMode={isSearchMode}
searchTerm={searchTerm}
localSearchResults={localSearchResults}
youtubeLoading={youtubeLoading}
searchResults={searchResults}
onDownload={handleDownloadFromSearch}
onResetSearch={resetSearch}
onAddToCollection={handleAddToCollection}
onCreateCollection={handleCreateCollection}
onRemoveFromCollection={handleRemoveFromCollection}
onDeleteCollection={handleDeleteCollection}
/>
</main>
<Footer />
</Box>
</Router>
@@ -835,4 +762,3 @@ function App() {
}
export default App;

View File

@@ -0,0 +1,160 @@
import { AnimatePresence } from 'framer-motion';
import { Route, Routes, useLocation } from 'react-router-dom';
import AuthorVideos from '../pages/AuthorVideos';
import CollectionPage from '../pages/CollectionPage';
import Home from '../pages/Home';
import ManagePage from '../pages/ManagePage';
import SearchResults from '../pages/SearchResults';
import SettingsPage from '../pages/SettingsPage';
import VideoPlayer from '../pages/VideoPlayer';
import { Collection, Video } from '../types';
import PageTransition from './PageTransition';
interface AnimatedRoutesProps {
videos: Video[];
loading: boolean;
error: string | null;
onDeleteVideo: (id: string) => Promise<{ success: boolean; error?: string }>;
collections: Collection[];
isSearchMode: boolean;
searchTerm: string;
localSearchResults: Video[];
youtubeLoading: boolean;
searchResults: any[];
onDownload: (videoUrl: string) => Promise<any>;
onResetSearch: () => void;
onAddToCollection: (collectionId: string, videoId: string) => Promise<any>;
onCreateCollection: (name: string, videoId: string) => Promise<any>;
onRemoveFromCollection: (videoId: string) => Promise<boolean>;
onDeleteCollection: (collectionId: string, deleteVideos?: boolean) => Promise<{ success: boolean; error?: string }>;
}
const AnimatedRoutes = ({
videos,
loading,
error,
onDeleteVideo,
collections,
isSearchMode,
searchTerm,
localSearchResults,
youtubeLoading,
searchResults,
onDownload,
onResetSearch,
onAddToCollection,
onCreateCollection,
onRemoveFromCollection,
onDeleteCollection
}: AnimatedRoutesProps) => {
const location = useLocation();
return (
<AnimatePresence mode="wait">
<Routes location={location} key={location.pathname}>
<Route
path="/"
element={
<PageTransition>
<Home
videos={videos}
loading={loading}
error={error}
onDeleteVideo={onDeleteVideo}
collections={collections}
isSearchMode={isSearchMode}
searchTerm={searchTerm}
localSearchResults={localSearchResults}
youtubeLoading={youtubeLoading}
searchResults={searchResults}
onDownload={onDownload}
onResetSearch={onResetSearch}
/>
</PageTransition>
}
/>
<Route
path="/video/:id"
element={
<PageTransition>
<VideoPlayer
videos={videos}
onDeleteVideo={onDeleteVideo}
collections={collections}
onAddToCollection={onAddToCollection}
onCreateCollection={onCreateCollection}
onRemoveFromCollection={onRemoveFromCollection}
/>
</PageTransition>
}
/>
<Route
path="/author/:author"
element={
<PageTransition>
<AuthorVideos
videos={videos}
onDeleteVideo={onDeleteVideo}
collections={collections}
/>
</PageTransition>
}
/>
<Route
path="/collection/:id"
element={
<PageTransition>
<CollectionPage
collections={collections}
videos={videos}
onDeleteVideo={onDeleteVideo}
onDeleteCollection={onDeleteCollection}
/>
</PageTransition>
}
/>
<Route
path="/search"
element={
<PageTransition>
<SearchResults
results={searchResults}
localResults={localSearchResults}
youtubeLoading={youtubeLoading}
loading={loading}
onDownload={onDownload}
onResetSearch={onResetSearch}
onDeleteVideo={onDeleteVideo}
collections={collections}
searchTerm={searchTerm}
/>
</PageTransition>
}
/>
<Route
path="/manage"
element={
<PageTransition>
<ManagePage
videos={videos}
onDeleteVideo={onDeleteVideo}
collections={collections}
onDeleteCollection={onDeleteCollection}
/>
</PageTransition>
}
/>
<Route
path="/settings"
element={
<PageTransition>
<SettingsPage />
</PageTransition>
}
/>
</Routes>
</AnimatePresence>
);
};
export default AnimatedRoutes;

View File

@@ -0,0 +1,135 @@
import { Folder } from '@mui/icons-material';
import {
Box,
Card,
CardActionArea,
CardContent,
CardMedia,
Chip,
Typography,
useTheme
} from '@mui/material';
import { useNavigate } from 'react-router-dom';
import { useLanguage } from '../contexts/LanguageContext';
import { Collection, Video } from '../types';
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL;
interface CollectionCardProps {
collection: Collection;
videos: Video[];
}
const CollectionCard: React.FC<CollectionCardProps> = ({ collection, videos }) => {
const { t } = useLanguage();
const navigate = useNavigate();
const theme = useTheme();
// Get the first 4 videos in the collection
const collectionVideos = collection.videos
.map(id => videos.find(v => v.id === id))
.filter((v): v is Video => v !== undefined)
.slice(0, 4);
const handleClick = () => {
navigate(`/collection/${collection.id}`);
};
const getThumbnailSrc = (video: Video) => {
return video.thumbnailPath
? `${BACKEND_URL}${video.thumbnailPath}`
: video.thumbnailUrl;
};
return (
<Card
sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
position: 'relative',
transition: 'transform 0.2s, box-shadow 0.2s',
'&:hover': {
transform: 'translateY(-4px)',
boxShadow: theme.shadows[8],
},
border: `1px solid ${theme.palette.secondary.main}`
}}
>
<CardActionArea onClick={handleClick} sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', alignItems: 'stretch' }}>
<Box sx={{ position: 'relative', paddingTop: '56.25%' /* 16:9 aspect ratio */, bgcolor: 'action.hover' }}>
{/* 2x2 Grid for Thumbnails */}
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
display: 'flex',
flexWrap: 'wrap'
}}
>
{collectionVideos.length > 0 ? (
collectionVideos.map((video, index) => (
<Box
key={video.id}
sx={{
width: '50%',
height: '50%',
position: 'relative',
borderRight: index % 2 === 0 ? '1px solid rgba(255,255,255,0.1)' : 'none',
borderBottom: index < 2 ? '1px solid rgba(255,255,255,0.1)' : 'none',
overflow: 'hidden'
}}
>
<CardMedia
component="img"
image={getThumbnailSrc(video) || 'https://via.placeholder.com/240x180?text=No+Thumbnail'}
alt={video.title}
sx={{
width: '100%',
height: '100%',
objectFit: 'cover'
}}
onError={(e) => {
const target = e.target as HTMLImageElement;
target.onerror = null;
target.src = 'https://via.placeholder.com/240x180?text=No+Thumbnail';
}}
/>
</Box>
))
) : (
<Box sx={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Folder sx={{ fontSize: 60, color: 'text.disabled' }} />
</Box>
)}
</Box>
<Chip
icon={<Folder />}
label={`${collection.videos.length} videos`}
color="secondary"
size="small"
sx={{ position: 'absolute', bottom: 8, right: 8 }}
/>
</Box>
<CardContent sx={{ flexGrow: 1, p: 2 }}>
<Typography gutterBottom variant="subtitle1" component="div" sx={{ fontWeight: 600, lineHeight: 1.2, mb: 1 }}>
{collection.name} Collection
</Typography>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 'auto' }}>
<Typography variant="caption" color="text.secondary">
{new Date(collection.createdAt).toLocaleDateString()}
</Typography>
</Box>
</CardContent>
</CardActionArea>
</Card>
);
};
export default CollectionCard;

View File

@@ -0,0 +1,44 @@
import { motion } from 'framer-motion';
import { ReactNode } from 'react';
interface PageTransitionProps {
children: ReactNode;
}
const pageVariants = {
initial: {
opacity: 0,
y: 20,
},
in: {
opacity: 1,
y: 0,
},
out: {
opacity: 0,
y: -20,
},
};
const pageTransition = {
type: 'tween',
ease: 'anticipate',
duration: 0.3,
} as const;
const PageTransition = ({ children }: PageTransitionProps) => {
return (
<motion.div
initial="initial"
animate="in"
exit="out"
variants={pageVariants}
transition={pageTransition}
style={{ width: '100%', height: '100%' }}
>
{children}
</motion.div>
);
};
export default PageTransition;

View File

@@ -16,6 +16,7 @@ import {
} from '@mui/material';
import { useEffect, useState } from 'react';
import AuthorsList from '../components/AuthorsList';
import CollectionCard from '../components/CollectionCard';
import Collections from '../components/Collections';
import VideoCard from '../components/VideoCard';
import { useLanguage } from '../contexts/LanguageContext';
@@ -284,14 +285,32 @@ const Home: React.FC<HomeProps> = ({
{/* Videos grid */}
<Grid size={{ xs: 12, md: 9 }}>
<Grid container spacing={3}>
{displayedVideos.map(video => (
<Grid size={{ xs: 12, sm: 6, lg: 4, xl: 3 }} key={video.id}>
<VideoCard
video={video}
collections={collections}
/>
</Grid>
))}
{displayedVideos.map(video => {
// Check if this video is the first in a collection
const collection = collections.find(c => c.videos[0] === video.id);
// If it is, render CollectionCard
if (collection) {
return (
<Grid size={{ xs: 12, sm: 6, lg: 4, xl: 3 }} key={`collection-${collection.id}`}>
<CollectionCard
collection={collection}
videos={videoArray}
/>
</Grid>
);
}
// Otherwise render VideoCard
return (
<Grid size={{ xs: 12, sm: 6, lg: 4, xl: 3 }} key={video.id}>
<VideoCard
video={video}
collections={collections}
/>
</Grid>
);
})}
</Grid>
{totalPages > 1 && (