feat: Add SearchPage component and route

This commit is contained in:
Peifan Li
2025-12-05 09:17:06 -05:00
parent 3fe6983e00
commit 421b418bd6
7 changed files with 218 additions and 199 deletions

View File

@@ -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 />} />

View File

@@ -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'));

View File

@@ -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 }}>

View File

@@ -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>

View 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;

View File

@@ -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">

View File

@@ -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',