feat: Add SearchPage component and route
This commit is contained in:
@@ -18,6 +18,7 @@ import Home from './pages/Home';
|
||||
import InstructionPage from './pages/InstructionPage';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import ManagePage from './pages/ManagePage';
|
||||
import SearchPage from './pages/SearchPage';
|
||||
import SettingsPage from './pages/SettingsPage';
|
||||
import SubscriptionsPage from './pages/SubscriptionsPage';
|
||||
import VideoPlayer from './pages/VideoPlayer';
|
||||
@@ -107,6 +108,7 @@ function AppContent() {
|
||||
<Box component="main" sx={{ flexGrow: 1, p: 0, width: '100%', overflowX: 'hidden' }}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/search" element={<SearchPage />} />
|
||||
<Route path="/manage" element={<ManagePage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/downloads" element={<DownloadPage />} />
|
||||
|
||||
@@ -137,26 +137,16 @@ const Header: React.FC<HeaderProps> = ({
|
||||
setVideoUrl('');
|
||||
setMobileMenuOpen(false);
|
||||
} else if (result.isSearchTerm) {
|
||||
const searchResult = await onSearch(videoUrl);
|
||||
if (searchResult.success) {
|
||||
setVideoUrl('');
|
||||
setMobileMenuOpen(false);
|
||||
navigate('/');
|
||||
} else {
|
||||
setError(searchResult.error);
|
||||
}
|
||||
navigate(`/search?q=${encodeURIComponent(videoUrl)}`);
|
||||
} else {
|
||||
setError(result.error);
|
||||
}
|
||||
} else {
|
||||
const result = await onSearch(videoUrl);
|
||||
if (result.success) {
|
||||
setVideoUrl('');
|
||||
setMobileMenuOpen(false);
|
||||
navigate('/');
|
||||
} else {
|
||||
setError(result.error);
|
||||
}
|
||||
navigate(`/search?q=${encodeURIComponent(videoUrl)}`);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(t('unexpectedErrorOccurred'));
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import { ArrowBack, Collections as CollectionsIcon, Download, GridView, OndemandVideo, ViewSidebar, YouTube } from '@mui/icons-material';
|
||||
|
||||
import { Collections as CollectionsIcon, GridView, ViewSidebar } from '@mui/icons-material';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardActions,
|
||||
CardContent,
|
||||
CardMedia,
|
||||
Chip,
|
||||
|
||||
CircularProgress,
|
||||
Collapse,
|
||||
Container,
|
||||
@@ -26,7 +21,6 @@ import Collections from '../components/Collections';
|
||||
import TagsList from '../components/TagsList';
|
||||
import VideoCard from '../components/VideoCard';
|
||||
import { useCollection } from '../contexts/CollectionContext';
|
||||
import { useDownload } from '../contexts/DownloadContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { useVideo } from '../contexts/VideoContext';
|
||||
|
||||
@@ -38,20 +32,12 @@ const Home: React.FC = () => {
|
||||
videos,
|
||||
loading,
|
||||
error,
|
||||
deleteVideo,
|
||||
availableTags,
|
||||
selectedTags,
|
||||
handleTagToggle,
|
||||
isSearchMode,
|
||||
searchTerm,
|
||||
localSearchResults,
|
||||
searchResults,
|
||||
youtubeLoading,
|
||||
setIsSearchMode,
|
||||
resetSearch
|
||||
handleTagToggle
|
||||
} = useVideo();
|
||||
const { collections } = useCollection();
|
||||
const { handleVideoSubmit } = useDownload();
|
||||
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const ITEMS_PER_PAGE = 12;
|
||||
@@ -112,19 +98,12 @@ const Home: React.FC = () => {
|
||||
setPage(1);
|
||||
}, [videos, collections, selectedTags]);
|
||||
|
||||
const handleDownload = async (url: string) => {
|
||||
try {
|
||||
setIsSearchMode(false);
|
||||
await handleVideoSubmit(url);
|
||||
} catch (error) {
|
||||
console.error('Error downloading from search:', error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Add default empty array to ensure videos is always an array
|
||||
const videoArray = Array.isArray(videos) ? videos : [];
|
||||
|
||||
if (!settingsLoaded || (loading && videoArray.length === 0 && !isSearchMode)) {
|
||||
if (!settingsLoaded || (loading && videoArray.length === 0)) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '50vh' }}>
|
||||
<CircularProgress />
|
||||
@@ -133,7 +112,7 @@ const Home: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (error && videoArray.length === 0 && !isSearchMode) {
|
||||
if (error && videoArray.length === 0) {
|
||||
return (
|
||||
<Container sx={{ mt: 4 }}>
|
||||
<Alert severity="error">{error}</Alert>
|
||||
@@ -196,141 +175,6 @@ const Home: React.FC = () => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
// Helper function to format duration in seconds to MM:SS
|
||||
const formatDuration = (seconds?: number) => {
|
||||
if (!seconds) return '';
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = Math.floor(seconds % 60);
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// Helper function to format view count
|
||||
const formatViewCount = (count?: number) => {
|
||||
if (!count) return '0';
|
||||
if (count < 1000) return count.toString();
|
||||
if (count < 1000000) return `${(count / 1000).toFixed(1)}K`;
|
||||
return `${(count / 1000000).toFixed(1)}M`;
|
||||
};
|
||||
|
||||
// If in search mode, show search results
|
||||
if (isSearchMode) {
|
||||
const hasLocalResults = localSearchResults && localSearchResults.length > 0;
|
||||
const hasYouTubeResults = searchResults && searchResults.length > 0;
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: 4 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4 }}>
|
||||
<Typography variant="h4" component="h1" fontWeight="bold">
|
||||
{t('searchResultsFor')} "{searchTerm}"
|
||||
</Typography>
|
||||
{resetSearch && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ArrowBack />}
|
||||
onClick={resetSearch}
|
||||
>
|
||||
{t('backToHome')}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Local Video Results */}
|
||||
<Box sx={{ mb: 6 }}>
|
||||
<Typography variant="h5" sx={{ mb: 3, fontWeight: 600, color: 'primary.main' }}>
|
||||
{t('fromYourLibrary')}
|
||||
</Typography>
|
||||
{hasLocalResults ? (
|
||||
<Grid container spacing={3}>
|
||||
{localSearchResults.map((video) => (
|
||||
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 3 }} key={video.id}>
|
||||
<VideoCard
|
||||
video={video}
|
||||
collections={collections}
|
||||
onDeleteVideo={deleteVideo}
|
||||
showDeleteButton={true}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
) : (
|
||||
<Typography color="text.secondary">{t('noMatchingVideos')}</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* YouTube Search Results */}
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ mb: 3, fontWeight: 600, color: '#ff0000' }}>
|
||||
{t('fromYouTube')}
|
||||
</Typography>
|
||||
|
||||
{youtubeLoading ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 4 }}>
|
||||
<CircularProgress color="error" />
|
||||
<Typography sx={{ mt: 2 }}>{t('loadingYouTubeResults')}</Typography>
|
||||
</Box>
|
||||
) : hasYouTubeResults ? (
|
||||
<Grid container spacing={3}>
|
||||
{searchResults.map((result) => (
|
||||
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 3 }} key={result.id}>
|
||||
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<Box sx={{ position: 'relative', paddingTop: '56.25%' }}>
|
||||
<CardMedia
|
||||
component="img"
|
||||
image={result.thumbnailUrl || 'https://via.placeholder.com/480x360?text=No+Thumbnail'}
|
||||
alt={result.title}
|
||||
sx={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.onerror = null;
|
||||
target.src = 'https://via.placeholder.com/480x360?text=No+Thumbnail';
|
||||
}}
|
||||
/>
|
||||
{result.duration && (
|
||||
<Chip
|
||||
label={formatDuration(result.duration)}
|
||||
size="small"
|
||||
sx={{ position: 'absolute', bottom: 8, right: 8, bgcolor: 'rgba(0,0,0,0.8)', color: 'white' }}
|
||||
/>
|
||||
)}
|
||||
<Box sx={{ position: 'absolute', top: 8, right: 8, bgcolor: 'rgba(0,0,0,0.7)', borderRadius: '50%', p: 0.5, display: 'flex' }}>
|
||||
{result.source === 'bilibili' ? <OndemandVideo sx={{ color: '#23ade5' }} /> : <YouTube sx={{ color: '#ff0000' }} />}
|
||||
</Box>
|
||||
</Box>
|
||||
<CardContent sx={{ flexGrow: 1, p: 2 }}>
|
||||
<Typography gutterBottom variant="subtitle1" component="div" sx={{ fontWeight: 600, lineHeight: 1.2, mb: 1, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
{result.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
{result.author}
|
||||
</Typography>
|
||||
{result.viewCount && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{formatViewCount(result.viewCount)} {t('views')}
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardActions sx={{ p: 2, pt: 0 }}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
startIcon={<Download />}
|
||||
onClick={() => handleDownload(result.sourceUrl)}
|
||||
>
|
||||
{t('download')}
|
||||
</Button>
|
||||
</CardActions>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
) : (
|
||||
<Typography color="text.secondary">{t('noYouTubeResults')}</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
// Regular home view (not in search mode)
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: 4 }}>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
ArrowBack,
|
||||
Check,
|
||||
Close,
|
||||
|
||||
@@ -270,14 +269,6 @@ const ManagePage: React.FC = () => {
|
||||
>
|
||||
{scanMutation.isPending ? (t('scanning') || 'Scanning...') : (t('scanFiles') || 'Scan Files')}
|
||||
</Button>
|
||||
<Button
|
||||
component={Link}
|
||||
to="/"
|
||||
variant="outlined"
|
||||
startIcon={<ArrowBack />}
|
||||
>
|
||||
{t('backToHome')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
|
||||
184
frontend/src/pages/SearchPage.tsx
Normal file
184
frontend/src/pages/SearchPage.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { Download, OndemandVideo, YouTube } from '@mui/icons-material';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardActions,
|
||||
CardContent,
|
||||
CardMedia,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Container,
|
||||
Grid,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import VideoCard from '../components/VideoCard';
|
||||
import { useCollection } from '../contexts/CollectionContext';
|
||||
import { useDownload } from '../contexts/DownloadContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { useVideo } from '../contexts/VideoContext';
|
||||
|
||||
const SearchPage: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
const {
|
||||
deleteVideo,
|
||||
localSearchResults,
|
||||
searchResults,
|
||||
youtubeLoading,
|
||||
handleSearch,
|
||||
resetSearch,
|
||||
searchTerm: contextSearchTerm
|
||||
} = useVideo();
|
||||
const { collections } = useCollection();
|
||||
const { handleVideoSubmit } = useDownload();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const query = searchParams.get('q');
|
||||
|
||||
useEffect(() => {
|
||||
if (query && query !== contextSearchTerm) {
|
||||
handleSearch(query);
|
||||
}
|
||||
}, [query, contextSearchTerm, handleSearch]);
|
||||
|
||||
const handleDownload = async (url: string) => {
|
||||
try {
|
||||
await handleVideoSubmit(url);
|
||||
} catch (error) {
|
||||
console.error('Error downloading from search:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
resetSearch();
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
// Helper function to format duration in seconds to MM:SS
|
||||
const formatDuration = (seconds?: number) => {
|
||||
if (!seconds) return '';
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = Math.floor(seconds % 60);
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// Helper function to format view count
|
||||
const formatViewCount = (count?: number) => {
|
||||
if (!count) return '0';
|
||||
if (count < 1000) return count.toString();
|
||||
if (count < 1000000) return `${(count / 1000).toFixed(1)}K`;
|
||||
return `${(count / 1000000).toFixed(1)}M`;
|
||||
};
|
||||
|
||||
const hasLocalResults = localSearchResults && localSearchResults.length > 0;
|
||||
const hasYouTubeResults = searchResults && searchResults.length > 0;
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: 4 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4 }}>
|
||||
<Typography variant="h4" component="h1" fontWeight="bold">
|
||||
{t('searchResultsFor')} "{query}"
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Local Video Results */}
|
||||
<Box sx={{ mb: 6 }}>
|
||||
<Typography variant="h5" sx={{ mb: 3, fontWeight: 600, color: 'primary.main' }}>
|
||||
{t('fromYourLibrary')}
|
||||
</Typography>
|
||||
{hasLocalResults ? (
|
||||
<Grid container spacing={3}>
|
||||
{localSearchResults.map((video) => (
|
||||
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 3 }} key={video.id}>
|
||||
<VideoCard
|
||||
video={video}
|
||||
collections={collections}
|
||||
onDeleteVideo={deleteVideo}
|
||||
showDeleteButton={true}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
) : (
|
||||
<Typography color="text.secondary">{t('noMatchingVideos')}</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* YouTube Search Results */}
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ mb: 3, fontWeight: 600, color: '#ff0000' }}>
|
||||
{t('fromYouTube')}
|
||||
</Typography>
|
||||
|
||||
{youtubeLoading ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 4 }}>
|
||||
<CircularProgress color="error" />
|
||||
<Typography sx={{ mt: 2 }}>{t('loadingYouTubeResults')}</Typography>
|
||||
</Box>
|
||||
) : hasYouTubeResults ? (
|
||||
<Grid container spacing={3}>
|
||||
{searchResults.map((result) => (
|
||||
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 3 }} key={result.id}>
|
||||
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<Box sx={{ position: 'relative', paddingTop: '56.25%' }}>
|
||||
<CardMedia
|
||||
component="img"
|
||||
image={result.thumbnailUrl || 'https://via.placeholder.com/480x360?text=No+Thumbnail'}
|
||||
alt={result.title}
|
||||
sx={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.onerror = null;
|
||||
target.src = 'https://via.placeholder.com/480x360?text=No+Thumbnail';
|
||||
}}
|
||||
/>
|
||||
{result.duration && (
|
||||
<Chip
|
||||
label={formatDuration(result.duration)}
|
||||
size="small"
|
||||
sx={{ position: 'absolute', bottom: 8, right: 8, bgcolor: 'rgba(0,0,0,0.8)', color: 'white' }}
|
||||
/>
|
||||
)}
|
||||
<Box sx={{ position: 'absolute', top: 8, right: 8, bgcolor: 'rgba(0,0,0,0.7)', borderRadius: '50%', p: 0.5, display: 'flex' }}>
|
||||
{result.source === 'bilibili' ? <OndemandVideo sx={{ color: '#23ade5' }} /> : <YouTube sx={{ color: '#ff0000' }} />}
|
||||
</Box>
|
||||
</Box>
|
||||
<CardContent sx={{ flexGrow: 1, p: 2 }}>
|
||||
<Typography gutterBottom variant="subtitle1" component="div" sx={{ fontWeight: 600, lineHeight: 1.2, mb: 1, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
{result.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
{result.author}
|
||||
</Typography>
|
||||
{result.viewCount && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{formatViewCount(result.viewCount)} {t('views')}
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardActions sx={{ p: 2, pt: 0 }}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
startIcon={<Download />}
|
||||
onClick={() => handleDownload(result.sourceUrl)}
|
||||
>
|
||||
{t('download')}
|
||||
</Button>
|
||||
</CardActions>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
) : (
|
||||
<Typography color="text.secondary">{t('noYouTubeResults')}</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchPage;
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
ArrowBack,
|
||||
CloudUpload,
|
||||
Save
|
||||
} from '@mui/icons-material';
|
||||
@@ -27,7 +26,6 @@ import {
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import ConfirmationModal from '../components/ConfirmationModal';
|
||||
import { useDownload } from '../contexts/DownloadContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
@@ -277,14 +275,6 @@ const SettingsPage: React.FC = () => {
|
||||
<Typography variant="h4" component="h1" fontWeight="bold">
|
||||
{t('settings')}
|
||||
</Typography>
|
||||
<Button
|
||||
component={Link}
|
||||
to="/"
|
||||
variant="outlined"
|
||||
startIcon={<ArrowBack />}
|
||||
>
|
||||
{t('backToHome')}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Card variant="outlined">
|
||||
|
||||
@@ -147,6 +147,24 @@ const VideoPlayer: React.FC = () => {
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
// Format duration (seconds or MM:SS)
|
||||
const formatDuration = (duration: string | number | undefined) => {
|
||||
if (!duration) return '00:00';
|
||||
|
||||
// If it's already a string with colon, assume it's formatted
|
||||
if (typeof duration === 'string' && duration.includes(':')) {
|
||||
return duration;
|
||||
}
|
||||
|
||||
// Otherwise treat as seconds
|
||||
const seconds = typeof duration === 'string' ? parseInt(duration, 10) : duration;
|
||||
if (isNaN(seconds)) return '00:00';
|
||||
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = Math.floor(seconds % 60);
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// Handle navigation to author videos page
|
||||
const handleAuthorClick = () => {
|
||||
if (video) {
|
||||
@@ -499,7 +517,7 @@ const VideoPlayer: React.FC = () => {
|
||||
/>
|
||||
{relatedVideo.duration && (
|
||||
<Chip
|
||||
label={relatedVideo.duration || '00:00'}
|
||||
label={formatDuration(relatedVideo.duration)}
|
||||
size="small"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
|
||||
Reference in New Issue
Block a user