feat: Add AnimatedRoutes component for page transitions
This commit is contained in:
49
frontend/package-lock.json
generated
49
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
160
frontend/src/components/AnimatedRoutes.tsx
Normal file
160
frontend/src/components/AnimatedRoutes.tsx
Normal 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;
|
||||
135
frontend/src/components/CollectionCard.tsx
Normal file
135
frontend/src/components/CollectionCard.tsx
Normal 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;
|
||||
44
frontend/src/components/PageTransition.tsx
Normal file
44
frontend/src/components/PageTransition.tsx
Normal 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;
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user