refactor with MUI

This commit is contained in:
Peifan Li
2025-11-22 13:47:27 -05:00
parent 8e65f40277
commit eb53d29228
19 changed files with 2323 additions and 3868 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,10 @@
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.5",
"@mui/material": "^7.3.5",
"axios": "^1.8.1",
"dotenv": "^16.4.7",
"react": "^19.0.0",

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
import { CssBaseline, ThemeProvider } from '@mui/material';
import axios from 'axios';
import { useEffect, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Route, BrowserRouter as Router, Routes } from 'react-router-dom';
import './App.css';
import BilibiliPartsModal from './components/BilibiliPartsModal';
@@ -10,6 +11,7 @@ import Home from './pages/Home';
import ManagePage from './pages/ManagePage';
import SearchResults from './pages/SearchResults';
import VideoPlayer from './pages/VideoPlayer';
import getTheme from './theme';
import { Collection, DownloadInfo, Video } from './types';
const API_URL = import.meta.env.VITE_API_URL;
@@ -69,18 +71,20 @@ function App() {
const [isCheckingParts, setIsCheckingParts] = useState<boolean>(false);
// Theme state
const [theme, setTheme] = useState<string>(() => {
return localStorage.getItem('theme') || 'dark';
const [themeMode, setThemeMode] = useState<'light' | 'dark'>(() => {
return (localStorage.getItem('theme') as 'light' | 'dark') || 'dark';
});
const theme = useMemo(() => getTheme(themeMode), [themeMode]);
// Apply theme to body
useEffect(() => {
document.body.className = theme === 'light' ? 'light-mode' : '';
localStorage.setItem('theme', theme);
}, [theme]);
document.body.className = themeMode === 'light' ? 'light-mode' : '';
localStorage.setItem('theme', themeMode);
}, [themeMode]);
const toggleTheme = () => {
setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light');
setThemeMode(prevTheme => prevTheme === 'light' ? 'dark' : 'light');
};
// Reference to the current search request's abort controller
@@ -642,117 +646,120 @@ function App() {
};
return (
<Router>
<div className="app">
<Header
onSearch={handleSearch}
onSubmit={handleVideoSubmit}
activeDownloads={activeDownloads}
isSearchMode={isSearchMode}
searchTerm={searchTerm}
onResetSearch={resetSearch}
theme={theme}
toggleTheme={toggleTheme}
/>
<ThemeProvider theme={theme}>
<CssBaseline />
<Router>
<div className="app">
<Header
onSearch={handleSearch}
onSubmit={handleVideoSubmit}
activeDownloads={activeDownloads}
isSearchMode={isSearchMode}
searchTerm={searchTerm}
onResetSearch={resetSearch}
theme={themeMode}
toggleTheme={toggleTheme}
/>
{/* Bilibili Parts Modal */}
<BilibiliPartsModal
isOpen={showBilibiliPartsModal}
onClose={() => setShowBilibiliPartsModal(false)}
videosNumber={bilibiliPartsInfo.videosNumber}
videoTitle={bilibiliPartsInfo.title}
onDownloadAll={handleDownloadAllBilibiliParts}
onDownloadCurrent={handleDownloadCurrentBilibiliPart}
isLoading={loading || isCheckingParts}
type={bilibiliPartsInfo.type}
/>
{/* Bilibili Parts Modal */}
<BilibiliPartsModal
isOpen={showBilibiliPartsModal}
onClose={() => setShowBilibiliPartsModal(false)}
videosNumber={bilibiliPartsInfo.videosNumber}
videoTitle={bilibiliPartsInfo.title}
onDownloadAll={handleDownloadAllBilibiliParts}
onDownloadCurrent={handleDownloadCurrentBilibiliPart}
isLoading={loading || isCheckingParts}
type={bilibiliPartsInfo.type}
/>
<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}
/>
}
/>
</Routes>
</main>
</div>
</Router>
<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}
/>
}
/>
</Routes>
</main>
</div>
</Router>
</ThemeProvider>
);
}

View File

@@ -1,3 +1,14 @@
import { ExpandLess, ExpandMore, Person } from '@mui/icons-material';
import {
Collapse,
List,
ListItemButton,
ListItemText,
Paper,
Typography,
useMediaQuery,
useTheme
} from '@mui/material';
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { Video } from '../types';
@@ -7,8 +18,10 @@ interface AuthorsListProps {
}
const AuthorsList: React.FC<AuthorsListProps> = ({ videos }) => {
const [isOpen, setIsOpen] = useState<boolean>(false);
const [isOpen, setIsOpen] = useState<boolean>(true);
const [authors, setAuthors] = useState<string[]>([]);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
useEffect(() => {
// Extract unique authors from videos
@@ -23,41 +36,49 @@ const AuthorsList: React.FC<AuthorsListProps> = ({ videos }) => {
}
}, [videos]);
const toggleDropdown = () => {
setIsOpen(!isOpen);
};
// Auto-collapse on mobile by default
useEffect(() => {
if (isMobile) {
setIsOpen(false);
} else {
setIsOpen(true);
}
}, [isMobile]);
if (!authors.length) {
return null;
}
return (
<div className="authors-container">
{/* Mobile dropdown toggle */}
<div className="authors-dropdown-toggle" onClick={toggleDropdown}>
<h3>Authors</h3>
<span className={`dropdown-arrow ${isOpen ? 'open' : ''}`}></span>
</div>
{/* Authors list - visible on desktop or when dropdown is open on mobile */}
<div className={`authors-list ${isOpen ? 'open' : ''}`}>
<h3 className="authors-title">Authors</h3>
<ul>
<Paper elevation={0} sx={{ bgcolor: 'transparent' }}>
<ListItemButton onClick={() => setIsOpen(!isOpen)} sx={{ borderRadius: 1 }}>
<Typography variant="h6" component="div" sx={{ flexGrow: 1, fontWeight: 600 }}>
Authors
</Typography>
{isOpen ? <ExpandLess /> : <ExpandMore />}
</ListItemButton>
<Collapse in={isOpen} timeout="auto" unmountOnExit>
<List component="div" disablePadding>
{authors.map(author => (
<li key={author} className="author-item">
<Link
to={`/author/${encodeURIComponent(author)}`}
className="author-link"
onClick={() => setIsOpen(false)} // Close dropdown when an author is selected
>
{author}
</Link>
</li>
<ListItemButton
key={author}
component={Link}
to={`/author/${encodeURIComponent(author)}`}
sx={{ pl: 2, borderRadius: 1 }}
>
<Person fontSize="small" sx={{ mr: 1, color: 'text.secondary' }} />
<ListItemText
primary={author}
primaryTypographyProps={{
variant: 'body2',
noWrap: true
}}
/>
</ListItemButton>
))}
</ul>
</div>
</div>
</List>
</Collapse>
</Paper>
);
};

View File

@@ -1,3 +1,16 @@
import { Close } from '@mui/icons-material';
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
IconButton,
TextField,
Typography
} from '@mui/material';
import { useState } from 'react';
interface BilibiliPartsModalProps {
@@ -23,8 +36,6 @@ const BilibiliPartsModal: React.FC<BilibiliPartsModalProps> = ({
}) => {
const [collectionName, setCollectionName] = useState<string>('');
if (!isOpen) return null;
const handleDownloadAll = () => {
onDownloadAll(collectionName || videoTitle);
};
@@ -79,53 +90,71 @@ const BilibiliPartsModal: React.FC<BilibiliPartsModalProps> = ({
};
return (
<div className="modal-overlay">
<div className="modal-content">
<div className="modal-header">
<h2>{getHeaderText()}</h2>
<button className="close-btn" onClick={onClose}>×</button>
</div>
<div className="modal-body">
<p>
{getDescriptionText()}
</p>
<p>
<strong>Title:</strong> {videoTitle}
</p>
<p>Would you like to download all {type === 'parts' ? 'parts' : 'videos'}?</p>
<Dialog
open={isOpen}
onClose={onClose}
maxWidth="sm"
fullWidth
PaperProps={{
sx: { borderRadius: 2 }
}}
>
<DialogTitle sx={{ m: 0, p: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h6" component="div" sx={{ fontWeight: 600 }}>
{getHeaderText()}
</Typography>
<IconButton
aria-label="close"
onClick={onClose}
sx={{
color: (theme) => theme.palette.grey[500],
}}
>
<Close />
</IconButton>
</DialogTitle>
<DialogContent dividers>
<DialogContentText sx={{ mb: 2 }}>
{getDescriptionText()}
</DialogContentText>
<Typography variant="body2" gutterBottom>
<strong>Title:</strong> {videoTitle}
</Typography>
<Typography variant="body1" sx={{ mt: 2, mb: 1 }}>
Would you like to download all {type === 'parts' ? 'parts' : 'videos'}?
</Typography>
<div className="form-group">
<label htmlFor="collection-name">Collection Name:</label>
<input
type="text"
id="collection-name"
className="collection-input"
value={collectionName}
onChange={(e) => setCollectionName(e.target.value)}
placeholder={videoTitle}
disabled={isLoading}
/>
<small>All {type === 'parts' ? 'parts' : 'videos'} will be added to this collection</small>
</div>
</div>
<div className="modal-footer">
<button
className="btn secondary-btn"
onClick={onDownloadCurrent}
<Box sx={{ mt: 2 }}>
<TextField
fullWidth
label="Collection Name"
variant="outlined"
value={collectionName}
onChange={(e) => setCollectionName(e.target.value)}
placeholder={videoTitle}
disabled={isLoading}
>
{getCurrentButtonText()}
</button>
<button
className="btn primary-btn"
onClick={handleDownloadAll}
disabled={isLoading}
>
{getDownloadAllButtonText()}
</button>
</div>
</div>
</div>
helperText={`All ${type === 'parts' ? 'parts' : 'videos'} will be added to this collection`}
/>
</Box>
</DialogContent>
<DialogActions sx={{ p: 2 }}>
<Button
onClick={onDownloadCurrent}
disabled={isLoading}
color="inherit"
>
{getCurrentButtonText()}
</Button>
<Button
onClick={handleDownloadAll}
disabled={isLoading}
variant="contained"
color="primary"
>
{getDownloadAllButtonText()}
</Button>
</DialogActions>
</Dialog>
);
};

View File

@@ -1,4 +1,16 @@
import { useState } from 'react';
import { ExpandLess, ExpandMore, Folder } from '@mui/icons-material';
import {
Chip,
Collapse,
List,
ListItemButton,
ListItemText,
Paper,
Typography,
useMediaQuery,
useTheme
} from '@mui/material';
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { Collection } from '../types';
@@ -7,42 +19,59 @@ interface CollectionsProps {
}
const Collections: React.FC<CollectionsProps> = ({ collections }) => {
const [isOpen, setIsOpen] = useState<boolean>(false);
const [isOpen, setIsOpen] = useState<boolean>(true);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const toggleDropdown = () => {
setIsOpen(!isOpen);
};
// Auto-collapse on mobile by default
useEffect(() => {
if (isMobile) {
setIsOpen(false);
} else {
setIsOpen(true);
}
}, [isMobile]);
if (!collections || collections.length === 0) {
return null;
}
return (
<div className="collections-container">
{/* Mobile dropdown toggle */}
<div className="collections-dropdown-toggle" onClick={toggleDropdown}>
<h3>Collections</h3>
<span className={`dropdown-arrow ${isOpen ? 'open' : ''}`}></span>
</div>
{/* Collections list - visible on desktop or when dropdown is open on mobile */}
<div className={`collections-list ${isOpen ? 'open' : ''}`}>
<h3 className="collections-title">Collections</h3>
<ul>
<Paper elevation={0} sx={{ bgcolor: 'transparent' }}>
<ListItemButton onClick={() => setIsOpen(!isOpen)} sx={{ borderRadius: 1 }}>
<Typography variant="h6" component="div" sx={{ flexGrow: 1, fontWeight: 600 }}>
Collections
</Typography>
{isOpen ? <ExpandLess /> : <ExpandMore />}
</ListItemButton>
<Collapse in={isOpen} timeout="auto" unmountOnExit>
<List component="div" disablePadding>
{collections.map(collection => (
<li key={collection.id} className="collection-item">
<Link
to={`/collection/${collection.id}`}
className="collection-link"
onClick={() => setIsOpen(false)} // Close dropdown when a collection is selected
>
{collection.name} ({collection.videos.length})
</Link>
</li>
<ListItemButton
key={collection.id}
component={Link}
to={`/collection/${collection.id}`}
sx={{ pl: 2, borderRadius: 1 }}
>
<Folder fontSize="small" sx={{ mr: 1, color: 'secondary.main' }} />
<ListItemText
primary={collection.name}
primaryTypographyProps={{
variant: 'body2',
noWrap: true
}}
/>
<Chip
label={collection.videos.length}
size="small"
variant="outlined"
sx={{ height: 20, minWidth: 20, ml: 1 }}
/>
</ListItemButton>
))}
</ul>
</div>
</div>
</List>
</Collapse>
</Paper>
);
};

View File

@@ -1,3 +1,14 @@
import { Close } from '@mui/icons-material';
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
IconButton,
Typography
} from '@mui/material';
import React from 'react';
interface ConfirmationModalProps {
@@ -21,34 +32,57 @@ const ConfirmationModal: React.FC<ConfirmationModalProps> = ({
cancelText = 'Cancel',
isDanger = false
}) => {
if (!isOpen) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>{title}</h2>
<button className="close-btn" onClick={onClose}>×</button>
</div>
<div className="modal-body">
<p>{message}</p>
</div>
<div className="modal-footer">
<button className="btn secondary-btn" onClick={onClose}>
{cancelText}
</button>
<button
className={`btn ${isDanger ? 'danger-btn' : 'primary-btn'}`}
onClick={() => {
onConfirm();
onClose();
}}
>
{confirmText}
</button>
</div>
</div>
</div>
<Dialog
open={isOpen}
onClose={onClose}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
PaperProps={{
sx: {
borderRadius: 2,
minWidth: 300,
maxWidth: 500,
backgroundImage: 'none'
}
}}
>
<DialogTitle id="alert-dialog-title" sx={{ m: 0, p: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h6" component="div" sx={{ fontWeight: 600 }}>
{title}
</Typography>
<IconButton
aria-label="close"
onClick={onClose}
sx={{
color: (theme) => theme.palette.grey[500],
}}
>
<Close />
</IconButton>
</DialogTitle>
<DialogContent dividers>
<DialogContentText id="alert-dialog-description">
{message}
</DialogContentText>
</DialogContent>
<DialogActions sx={{ p: 2 }}>
<Button onClick={onClose} color="inherit" variant="text">
{cancelText}
</Button>
<Button
onClick={() => {
onConfirm();
onClose();
}}
color={isDanger ? 'error' : 'primary'}
variant="contained"
autoFocus
>
{confirmText}
</Button>
</DialogActions>
</Dialog>
);
};

View File

@@ -1,3 +1,17 @@
import { Close, Warning } from '@mui/icons-material';
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
IconButton,
Stack,
Typography
} from '@mui/material';
import React from 'react';
interface DeleteCollectionModalProps {
isOpen: boolean;
onClose: () => void;
@@ -15,85 +29,74 @@ const DeleteCollectionModal: React.FC<DeleteCollectionModalProps> = ({
collectionName,
videoCount
}) => {
if (!isOpen) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<div className="modal-header">
<h2>Delete Collection</h2>
<button className="close-btn" onClick={onClose}>×</button>
</div>
<Dialog
open={isOpen}
onClose={onClose}
maxWidth="sm"
fullWidth
PaperProps={{
sx: { borderRadius: 2 }
}}
>
<DialogTitle sx={{ m: 0, p: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h6" component="div" sx={{ fontWeight: 600 }}>
Delete Collection
</Typography>
<IconButton
aria-label="close"
onClick={onClose}
sx={{
color: (theme) => theme.palette.grey[500],
}}
>
<Close />
</IconButton>
</DialogTitle>
<DialogContent dividers>
<DialogContentText sx={{ mb: 2, color: 'text.primary' }}>
Are you sure you want to delete the collection <strong>"{collectionName}"</strong>?
</DialogContentText>
<DialogContentText sx={{ mb: 3 }}>
This collection contains <strong>{videoCount}</strong> video{videoCount !== 1 ? 's' : ''}.
</DialogContentText>
<div className="modal-body">
<p style={{ marginBottom: '12px', fontSize: '0.95rem' }}>
Are you sure you want to delete the collection <strong>"{collectionName}"</strong>?
</p>
<p style={{ marginBottom: '20px', fontSize: '0.95rem', color: 'var(--text-secondary)' }}>
This collection contains <strong>{videoCount}</strong> video{videoCount !== 1 ? 's' : ''}.
</p>
<Stack spacing={2}>
<Button
variant="outlined"
color="inherit"
onClick={onDeleteCollectionOnly}
fullWidth
sx={{ justifyContent: 'center', py: 1.5 }}
>
Delete Collection Only
</Button>
<div className="modal-actions" style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<button
className="btn secondary-btn glass-panel"
onClick={onDeleteCollectionOnly}
style={{
width: '100%',
padding: '12px',
borderRadius: '8px',
color: 'var(--text-color)',
cursor: 'pointer',
{videoCount > 0 && (
<Button
variant="contained"
color="error"
onClick={onDeleteCollectionAndVideos}
fullWidth
startIcon={<Warning />}
sx={{
justifyContent: 'center',
py: 1.5,
fontWeight: 600,
boxShadow: (theme) => `0 4px 12px ${theme.palette.error.main}40`
}}
>
Delete Collection Only
</button>
{videoCount > 0 && (
<button
className="btn danger-btn"
onClick={onDeleteCollectionAndVideos}
style={{
width: '100%',
padding: '12px',
borderRadius: '8px',
border: 'none',
background: 'linear-gradient(90deg, #ff4b4b 0%, #ff0000 100%)',
color: 'white',
fontWeight: '600',
cursor: 'pointer',
boxShadow: '0 4px 12px rgba(255, 0, 0, 0.3)',
transition: 'all 0.2s ease',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px'
}}
onMouseOver={(e) => {
e.currentTarget.style.transform = 'translateY(-2px)';
e.currentTarget.style.boxShadow = '0 6px 16px rgba(255, 0, 0, 0.4)';
}}
onMouseOut={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 4px 12px rgba(255, 0, 0, 0.3)';
}}
>
<span style={{ fontSize: '1.2em' }}></span>
<span>Delete Collection & All {videoCount} Videos</span>
</button>
)}
</div>
</div>
<div className="modal-footer">
<button
className="btn secondary-btn"
onClick={onClose}
>
Cancel
</button>
</div>
</div>
</div>
Delete Collection & All {videoCount} Videos
</Button>
)}
</Stack>
</DialogContent>
<DialogActions sx={{ p: 2 }}>
<Button onClick={onClose} color="inherit">
Cancel
</Button>
</DialogActions>
</Dialog>
);
};

View File

@@ -1,3 +1,24 @@
import {
Brightness4,
Brightness7,
Clear,
Download,
Search
} from '@mui/icons-material';
import {
AppBar,
Badge,
Box,
Button,
CircularProgress,
IconButton,
InputAdornment,
Menu,
MenuItem,
TextField,
Toolbar,
Typography
} from '@mui/material';
import { FormEvent, useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import logo from '../assets/logo.svg';
@@ -26,22 +47,30 @@ const Header: React.FC<HeaderProps> = ({
isSearchMode = false,
searchTerm = '',
onResetSearch,
theme,
theme: currentThemeMode,
toggleTheme
}) => {
const [videoUrl, setVideoUrl] = useState<string>('');
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const [error, setError] = useState<string>('');
const [showDownloads, setShowDownloads] = useState<boolean>(false);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const navigate = useNavigate();
const isDownloading = activeDownloads.length > 0;
// Log props for debugging
useEffect(() => {
console.log('Header props:', { activeDownloads });
}, [activeDownloads]);
const handleDownloadsClick = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleDownloadsClose = () => {
setAnchorEl(null);
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
@@ -50,11 +79,8 @@ const Header: React.FC<HeaderProps> = ({
return;
}
// Simple validation for YouTube or Bilibili URL
const youtubeRegex = /^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.?be)\/.+$/;
const bilibiliRegex = /^(https?:\/\/)?(www\.)?(bilibili\.com|b23\.tv)\/.+$/;
// Check if input is a URL
const isUrl = youtubeRegex.test(videoUrl) || bilibiliRegex.test(videoUrl);
setError('');
@@ -62,17 +88,14 @@ const Header: React.FC<HeaderProps> = ({
try {
if (isUrl) {
// Handle as URL for download
const result = await onSubmit(videoUrl);
if (result.success) {
setVideoUrl('');
} else if (result.isSearchTerm) {
// If backend determined it's a search term despite our check
const searchResult = await onSearch(videoUrl);
if (searchResult.success) {
setVideoUrl('');
navigate('/'); // Stay on homepage to show search results
navigate('/');
} else {
setError(searchResult.error);
}
@@ -80,12 +103,9 @@ const Header: React.FC<HeaderProps> = ({
setError(result.error);
}
} else {
// Handle as search term
const result = await onSearch(videoUrl);
if (result.success) {
setVideoUrl('');
// Stay on homepage to show search results
navigate('/');
} else {
setError(result.error);
@@ -100,115 +120,109 @@ const Header: React.FC<HeaderProps> = ({
};
return (
<header className="header">
<div className="header-content">
<div style={{ display: 'flex', alignItems: 'center', gap: '20px' }}>
<Link to="/" className="logo">
<img src={logo} alt="MyTube Logo" className="logo-icon" />
<span style={{ color: 'var(--text-color)' }}>MyTube</span>
<AppBar position="sticky" color="default" elevation={0} sx={{ bgcolor: 'background.paper', borderBottom: 1, borderColor: 'divider' }}>
<Toolbar>
<Box sx={{ display: 'flex', alignItems: 'center', flexGrow: 0, mr: 2 }}>
<Link to="/" onClick={onResetSearch} style={{ textDecoration: 'none', display: 'flex', alignItems: 'center', color: 'inherit' }}>
<img src={logo} alt="MyTube Logo" height={40} />
<Typography variant="h5" sx={{ ml: 1, fontWeight: 'bold' }}>
MyTube
</Typography>
</Link>
</Box>
<button
onClick={toggleTheme}
className="theme-toggle-btn"
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
style={{
background: 'none',
border: 'none',
fontSize: '1.2rem',
cursor: 'pointer',
padding: '8px',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--text-color)',
transition: 'background-color 0.2s'
<Box component="form" onSubmit={handleSubmit} sx={{ flexGrow: 1, display: 'flex', justifyContent: 'center', maxWidth: 800, mx: 'auto' }}>
<TextField
fullWidth
variant="outlined"
placeholder="Enter YouTube/Bilibili URL or search term"
value={videoUrl}
onChange={(e) => setVideoUrl(e.target.value)}
disabled={isSubmitting}
error={!!error}
helperText={error}
size="small"
InputProps={{
endAdornment: (
<InputAdornment position="end">
{isSearchMode && searchTerm && (
<IconButton onClick={onResetSearch} edge="end" size="small" sx={{ mr: 0.5 }}>
<Clear />
</IconButton>
)}
<Button
type="submit"
variant="contained"
disabled={isSubmitting}
sx={{ borderTopLeftRadius: 0, borderBottomLeftRadius: 0, height: '100%', minWidth: 'auto', px: 3 }}
>
{isSubmitting ? <CircularProgress size={24} color="inherit" /> : <Search />}
</Button>
</InputAdornment>
),
sx: { pr: 0, borderRadius: 2 }
}}
onMouseOver={(e) => e.currentTarget.style.backgroundColor = 'rgba(128, 128, 128, 0.1)'}
onMouseOut={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
{theme === 'dark' ? '☀️' : '🌙'}
</button>
</div>
/>
</Box>
<form className="url-form" onSubmit={handleSubmit}>
<div className="form-group">
<input
type="text"
className="url-input"
placeholder="Enter YouTube/Bilibili URL or search term"
value={videoUrl}
onChange={(e) => setVideoUrl(e.target.value)}
disabled={isSubmitting}
aria-label="Video URL or search term"
/>
{isSearchMode && searchTerm && (
<button
type="button"
className="clear-search-btn"
onClick={onResetSearch}
title="Clear search"
style={{
position: 'absolute',
right: '100px',
top: '50%',
transform: 'translateY(-50%)',
background: 'none',
border: 'none',
color: '#aaa',
fontSize: '1.2rem',
cursor: 'pointer'
}}
>
×
</button>
)}
<button
type="submit"
className="submit-btn"
disabled={isSubmitting}
>
{isSubmitting ? 'Processing...' : 'Submit'}
</button>
</div>
{error && (
<div className="form-error">
{error}
</div>
)}
{/* Active Downloads Indicator */}
<Box sx={{ display: 'flex', alignItems: 'center', ml: 2 }}>
{isDownloading && (
<div className="downloads-indicator-container">
<div
className="downloads-summary"
onClick={() => setShowDownloads(!showDownloads)}
<>
<IconButton color="inherit" onClick={handleDownloadsClick}>
<Badge badgeContent={activeDownloads.length} color="secondary">
<Download />
</Badge>
</IconButton>
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleDownloadsClose}
PaperProps={{
elevation: 0,
sx: {
overflow: 'visible',
filter: 'drop-shadow(0px 2px 8px rgba(0,0,0,0.32))',
mt: 1.5,
'& .MuiAvatar-root': {
width: 32,
height: 32,
ml: -0.5,
mr: 1,
},
'&:before': {
content: '""',
display: 'block',
position: 'absolute',
top: 0,
right: 14,
width: 10,
height: 10,
bgcolor: 'background.paper',
transform: 'translateY(-50%) rotate(45deg)',
zIndex: 0,
},
},
}}
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
>
<span className="download-icon"></span>
<span className="download-count">
{activeDownloads.length} Downloading
</span>
<span className="download-arrow">{showDownloads ? '▲' : '▼'}</span>
</div>
{showDownloads && (
<div className="downloads-dropdown">
{activeDownloads.map((download) => (
<div key={download.id} className="download-item">
<div className="download-spinner"></div>
<div className="download-title" title={download.title}>
{download.title}
</div>
</div>
))}
</div>
)}
</div>
{activeDownloads.map((download) => (
<MenuItem key={download.id}>
<CircularProgress size={20} sx={{ mr: 2 }} />
<Typography variant="body2" noWrap sx={{ maxWidth: 200 }}>
{download.title}
</Typography>
</MenuItem>
))}
</Menu>
</>
)}
</form>
</div>
</header>
<IconButton sx={{ ml: 1 }} onClick={toggleTheme} color="inherit">
{currentThemeMode === 'dark' ? <Brightness7 /> : <Brightness4 />}
</IconButton>
</Box>
</Toolbar>
</AppBar>
);
};

View File

@@ -1,3 +1,20 @@
import {
Delete,
Folder,
OndemandVideo,
YouTube
} from '@mui/icons-material';
import {
Box,
Card,
CardActionArea,
CardContent,
CardMedia,
Chip,
IconButton,
Typography,
useTheme
} from '@mui/material';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Collection, Video } from '../types';
@@ -19,6 +36,7 @@ const VideoCard: React.FC<VideoCardProps> = ({
showDeleteButton = false
}) => {
const navigate = useNavigate();
const theme = useTheme();
const [isDeleting, setIsDeleting] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
@@ -100,98 +118,130 @@ const VideoCard: React.FC<VideoCardProps> = ({
// Get source icon
const getSourceIcon = () => {
if (video.source === 'bilibili') {
return (
<div className="source-icon bilibili-icon" title="Bilibili">
B
</div>
);
return <OndemandVideo sx={{ color: '#23ade5' }} />; // Bilibili blue
}
return (
<div className="source-icon youtube-icon" title="YouTube">
YT
</div>
);
return <YouTube sx={{ color: '#ff0000' }} />; // YouTube red
};
return (
<>
<div className={`video-card ${isFirstInAnyCollection ? 'collection-first' : ''}`}>
{/* ... (rest of the video card JSX) ... */}
<div
className="thumbnail-container clickable"
onClick={handleVideoNavigation}
aria-label={isFirstInAnyCollection
? `View collection: ${firstInCollectionNames[0]}${firstInCollectionNames.length > 1 ? ' and others' : ''}`
: `Play ${video.title}`}
>
<img
src={thumbnailSrc || 'https://via.placeholder.com/480x360?text=No+Thumbnail'}
alt={`${video.title} thumbnail`}
className="thumbnail"
loading="lazy"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.onerror = null;
target.src = 'https://via.placeholder.com/480x360?text=No+Thumbnail';
}}
/>
{getSourceIcon()}
<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],
'& .delete-btn': {
opacity: 1
}
},
border: isFirstInAnyCollection ? `1px solid ${theme.palette.primary.main}` : 'none'
}}
>
<CardActionArea onClick={handleVideoNavigation} sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', alignItems: 'stretch' }}>
<Box sx={{ position: 'relative', paddingTop: '56.25%' /* 16:9 aspect ratio */ }}>
<CardMedia
component="img"
image={thumbnailSrc || 'https://via.placeholder.com/480x360?text=No+Thumbnail'}
alt={`${video.title} thumbnail`}
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';
}}
/>
{/* Show part number for multi-part videos */}
{video.partNumber && video.totalParts && video.totalParts > 1 && (
<div className="part-badge">
Part {video.partNumber}/{video.totalParts}
</div>
)}
<Box sx={{ position: 'absolute', top: 8, right: 8, bgcolor: 'rgba(0,0,0,0.7)', borderRadius: '50%', p: 0.5, display: 'flex' }}>
{getSourceIcon()}
</Box>
{/* Show collection badge if this is the first video in a collection */}
{isFirstInAnyCollection && (
<div className="collection-badge" title={`Collection${firstInCollectionNames.length > 1 ? 's' : ''}: ${firstInCollectionNames.join(', ')}`}>
<span className="collection-icon">📁</span>
</div>
)}
{/* Delete button overlay */}
{showDeleteButton && onDeleteVideo && (
<button
className="card-delete-btn"
onClick={handleDeleteClick}
disabled={isDeleting}
title="Delete video"
>
{isDeleting ? '...' : '×'}
</button>
)}
</div>
<div className="video-info">
<h3
className="video-title clickable"
onClick={handleVideoNavigation}
>
{isFirstInAnyCollection ? (
<>
{firstInCollectionNames[0]}
{firstInCollectionNames.length > 1 && <span className="more-collections"> +{firstInCollectionNames.length - 1}</span>}
</>
) : (
video.title
{video.partNumber && video.totalParts && video.totalParts > 1 && (
<Chip
label={`Part ${video.partNumber}/${video.totalParts}`}
size="small"
color="primary"
sx={{ position: 'absolute', bottom: 8, right: 8 }}
/>
)}
</h3>
<div className="video-meta">
<span
className="author-link"
onClick={handleAuthorClick}
role="button"
tabIndex={0}
aria-label={`View all videos by ${video.author}`}
>
{video.author}
</span>
<span className="video-date">{formatDate(video.date)}</span>
</div>
</div>
</div>
{isFirstInAnyCollection && (
<Chip
icon={<Folder />}
label={firstInCollectionNames.length > 1 ? `${firstInCollectionNames[0]} +${firstInCollectionNames.length - 1}` : firstInCollectionNames[0]}
color="secondary"
size="small"
sx={{ position: 'absolute', top: 8, left: 8 }}
/>
)}
</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' }}>
{isFirstInAnyCollection ? (
<>
{firstInCollectionNames[0]}
{firstInCollectionNames.length > 1 && <Typography component="span" color="text.secondary" sx={{ fontSize: 'inherit' }}> +{firstInCollectionNames.length - 1}</Typography>}
</>
) : (
video.title
)}
</Typography>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 'auto' }}>
<Typography
variant="body2"
color="text.secondary"
onClick={handleAuthorClick}
sx={{
cursor: 'pointer',
'&:hover': { color: 'primary.main' },
fontWeight: 500
}}
>
{video.author}
</Typography>
<Typography variant="caption" color="text.secondary">
{formatDate(video.date)}
</Typography>
</Box>
</CardContent>
</CardActionArea>
{showDeleteButton && onDeleteVideo && (
<IconButton
className="delete-btn"
onClick={handleDeleteClick}
disabled={isDeleting}
size="small"
sx={{
position: 'absolute',
top: 8,
right: 40, // Positioned to the left of the source icon
bgcolor: 'rgba(0,0,0,0.6)',
color: 'white',
opacity: 0, // Hidden by default, shown on hover
transition: 'opacity 0.2s',
'&:hover': {
bgcolor: 'error.main',
}
}}
>
<Delete fontSize="small" />
</IconButton>
)}
</Card>
<ConfirmationModal
isOpen={showDeleteModal}

View File

@@ -1,88 +1,12 @@
/* CSS Reset */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html,
body,
#root {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow-x: hidden;
background-color: var(--background-dark);
color: var(--text-color);
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
line-height: 1.6;
}
/* Remove default styles for common elements */
a {
text-decoration: none;
color: inherit;
}
button,
input,
select,
textarea {
font-family: inherit;
font-size: 100%;
line-height: 1.15;
margin: 0;
}
button {
cursor: pointer;
background: none;
border: none;
}
ul,
ol {
list-style: none;
}
img,
video {
max-width: 100%;
height: auto;
display: block;
}
/* Make the app container full width */
#root {
display: flex;
flex-direction: column;
min-height: 100%;
width: 100%;
}
/* Dark theme overrides */
/* index.css */
:root {
color-scheme: dark;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
color: #ff3e3e;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
body {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* Scrollbar styling for dark theme */

View File

@@ -1,3 +1,14 @@
import { ArrowBack } from '@mui/icons-material';
import {
Alert,
Avatar,
Box,
Button,
CircularProgress,
Container,
Grid,
Typography
} from '@mui/material';
import axios from 'axios';
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
@@ -58,11 +69,20 @@ const AuthorVideos: React.FC<AuthorVideosProps> = ({ videos: allVideos, onDelete
};
if (loading) {
return <div className="loading">Loading videos...</div>;
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '50vh' }}>
<CircularProgress />
<Typography sx={{ ml: 2 }}>Loading videos...</Typography>
</Box>
);
}
if (error) {
return <div className="error">{error}</div>;
return (
<Container sx={{ mt: 4 }}>
<Alert severity="error">{error}</Alert>
</Container>
);
}
// Filter videos to only show the first video from each collection
@@ -86,33 +106,47 @@ const AuthorVideos: React.FC<AuthorVideosProps> = ({ videos: allVideos, onDelete
});
return (
<div className="author-videos-container">
<div className="author-header">
<button className="back-button" onClick={handleBack}>
&larr; Back
</button>
<div className="author-info">
<h2>Author: {author ? decodeURIComponent(author) : 'Unknown'}</h2>
<span className="video-count">{authorVideos.length} video{authorVideos.length !== 1 ? 's' : ''}</span>
</div>
</div>
<Container maxWidth="xl" sx={{ py: 4 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4 }}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Avatar sx={{ width: 56, height: 56, bgcolor: 'primary.main', mr: 2, fontSize: '1.5rem' }}>
{author ? author.charAt(0).toUpperCase() : 'A'}
</Avatar>
<Box>
<Typography variant="h4" component="h1" fontWeight="bold">
{author ? decodeURIComponent(author) : 'Unknown'}
</Typography>
<Typography variant="subtitle1" color="text.secondary">
{authorVideos.length} video{authorVideos.length !== 1 ? 's' : ''}
</Typography>
</Box>
</Box>
<Button
variant="outlined"
startIcon={<ArrowBack />}
onClick={handleBack}
>
Back
</Button>
</Box>
{authorVideos.length === 0 ? (
<div className="no-videos">No videos found for this author.</div>
<Alert severity="info" variant="outlined">No videos found for this author.</Alert>
) : (
<div className="videos-grid">
<Grid container spacing={3}>
{filteredVideos.map(video => (
<VideoCard
key={video.id}
video={video}
collections={collections}
onDeleteVideo={onDeleteVideo}
showDeleteButton={true}
/>
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 3 }} key={video.id}>
<VideoCard
video={video}
collections={collections}
onDeleteVideo={onDeleteVideo}
showDeleteButton={true}
/>
</Grid>
))}
</div>
</Grid>
)}
</div>
</Container>
);
};

View File

@@ -1,3 +1,14 @@
import { ArrowBack, Folder } from '@mui/icons-material';
import {
Alert,
Avatar,
Box,
Button,
CircularProgress,
Container,
Grid,
Typography
} from '@mui/material';
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import DeleteCollectionModal from '../components/DeleteCollectionModal';
@@ -68,41 +79,62 @@ const CollectionPage: React.FC<CollectionPageProps> = ({ collections, videos, on
};
if (loading) {
return <div className="loading">Loading collection...</div>;
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '50vh' }}>
<CircularProgress />
<Typography sx={{ ml: 2 }}>Loading collection...</Typography>
</Box>
);
}
if (!collection) {
return <div className="error">Collection not found</div>;
return (
<Container sx={{ mt: 4 }}>
<Alert severity="error">Collection not found</Alert>
</Container>
);
}
return (
<div className="collection-page">
<div className="collection-header">
<button className="back-button" onClick={handleBack}>
&larr; Back
</button>
<div className="collection-info">
<h2 className="collection-title">Collection: {collection.name}</h2>
<span className="video-count">{collectionVideos.length} video{collectionVideos.length !== 1 ? 's' : ''}</span>
</div>
</div>
<Container maxWidth="xl" sx={{ py: 4 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4 }}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Avatar sx={{ width: 56, height: 56, bgcolor: 'secondary.main', mr: 2 }}>
<Folder fontSize="large" />
</Avatar>
<Box>
<Typography variant="h4" component="h1" fontWeight="bold">
{collection.name}
</Typography>
<Typography variant="subtitle1" color="text.secondary">
{collectionVideos.length} video{collectionVideos.length !== 1 ? 's' : ''}
</Typography>
</Box>
</Box>
<Button
variant="outlined"
startIcon={<ArrowBack />}
onClick={handleBack}
>
Back
</Button>
</Box>
{collectionVideos.length === 0 ? (
<div className="no-videos">
<p>No videos in this collection.</p>
</div>
<Alert severity="info" variant="outlined">No videos in this collection.</Alert>
) : (
<div className="videos-grid">
<Grid container spacing={3}>
{collectionVideos.map(video => (
<VideoCard
key={video.id}
video={video}
collections={collections}
onDeleteVideo={onDeleteVideo}
showDeleteButton={true}
/>
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 3 }} key={video.id}>
<VideoCard
video={video}
collections={collections}
onDeleteVideo={onDeleteVideo}
showDeleteButton={true}
/>
</Grid>
))}
</div>
</Grid>
)}
<DeleteCollectionModal
@@ -113,7 +145,7 @@ const CollectionPage: React.FC<CollectionPageProps> = ({ collections, videos, on
collectionName={collection?.name || ''}
videoCount={collectionVideos.length}
/>
</div>
</Container>
);
};

View File

@@ -1,3 +1,18 @@
import { ArrowBack, Download, OndemandVideo, YouTube } from '@mui/icons-material';
import {
Alert,
Box,
Button,
Card,
CardActions,
CardContent,
CardMedia,
Chip,
CircularProgress,
Container,
Grid,
Typography
} from '@mui/material';
import { Link } from 'react-router-dom';
import AuthorsList from '../components/AuthorsList';
import Collections from '../components/Collections';
@@ -44,15 +59,26 @@ const Home: React.FC<HomeProps> = ({
onDownload,
onResetSearch
}) => {
// Add default empty array to ensure videos is always an array
const videoArray = Array.isArray(videos) ? videos : [];
if (loading && videoArray.length === 0 && !isSearchMode) {
return <div className="loading">Loading videos...</div>;
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '50vh' }}>
<CircularProgress />
<Typography sx={{ ml: 2 }}>Loading videos...</Typography>
</Box>
);
}
if (error && videoArray.length === 0 && !isSearchMode) {
return <div className="error">{error}</div>;
return (
<Container sx={{ mt: 4 }}>
<Alert severity="error">{error}</Alert>
</Container>
);
}
// Filter videos to only show the first video from each collection
@@ -97,141 +123,166 @@ const Home: React.FC<HomeProps> = ({
const hasYouTubeResults = searchResults && searchResults.length > 0;
return (
<div className="search-results">
<div className="search-header">
<h2>Search Results for "{searchTerm}"</h2>
<Container maxWidth="xl" sx={{ py: 4 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4 }}>
<Typography variant="h4" component="h1" fontWeight="bold">
Search Results for "{searchTerm}"
</Typography>
{onResetSearch && (
<button className="back-to-home-btn" onClick={onResetSearch}>
Back to Home
</button>
<Button
variant="outlined"
startIcon={<ArrowBack />}
onClick={onResetSearch}
>
Back to Home
</Button>
)}
</div>
</Box>
{/* Local Video Results */}
<div className="search-results-section">
<h3 className="section-title">From Your Library</h3>
<Box sx={{ mb: 6 }}>
<Typography variant="h5" sx={{ mb: 3, fontWeight: 600, color: 'primary.main' }}>
From Your Library
</Typography>
{hasLocalResults ? (
<div className="search-results-grid">
<Grid container spacing={3}>
{localSearchResults.map((video) => (
<VideoCard
key={video.id}
video={video}
collections={collections}
onDeleteVideo={onDeleteVideo}
showDeleteButton={true}
/>
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 3 }} key={video.id}>
<VideoCard
video={video}
collections={collections}
onDeleteVideo={onDeleteVideo}
showDeleteButton={true}
/>
</Grid>
))}
</div>
</Grid>
) : (
<p className="no-results">No matching videos in your library.</p>
<Typography color="text.secondary">No matching videos in your library.</Typography>
)}
</div>
</Box>
{/* YouTube Search Results */}
<div className="search-results-section">
<h3 className="section-title">From YouTube</h3>
<Box>
<Typography variant="h5" sx={{ mb: 3, fontWeight: 600, color: '#ff0000' }}>
From YouTube
</Typography>
{youtubeLoading ? (
<div className="youtube-loading">
<div className="loading-spinner"></div>
<p>Loading YouTube results...</p>
</div>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 4 }}>
<CircularProgress color="error" />
<Typography sx={{ mt: 2 }}>Loading YouTube results...</Typography>
</Box>
) : hasYouTubeResults ? (
<div className="search-results-grid">
<Grid container spacing={3}>
{searchResults.map((result) => (
<div key={result.id} className="search-result-card">
<div className="search-result-thumbnail">
{result.thumbnailUrl ? (
<img
src={result.thumbnailUrl}
<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';
}}
/>
) : (
<div className="thumbnail-placeholder">No Thumbnail</div>
)}
</div>
<div className="search-result-info">
<h3 className="search-result-title">{result.title}</h3>
<p className="search-result-author">{result.author}</p>
<div className="search-result-meta">
{result.duration && (
<span className="search-result-duration">
{formatDuration(result.duration)}
</span>
<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 && (
<span className="search-result-views">
<Typography variant="caption" color="text.secondary">
{formatViewCount(result.viewCount)} views
</span>
</Typography>
)}
<span className={`source-badge ${result.source}`}>
{result.source}
</span>
</div>
<button
className="download-btn"
onClick={() => onDownload(result.sourceUrl, result.title)}
>
Download
</button>
</div>
</div>
</CardContent>
<CardActions sx={{ p: 2, pt: 0 }}>
<Button
fullWidth
variant="contained"
startIcon={<Download />}
onClick={() => onDownload(result.sourceUrl, result.title)}
>
Download
</Button>
</CardActions>
</Card>
</Grid>
))}
</div>
</Grid>
) : (
<p className="no-results">No YouTube results found.</p>
<Typography color="text.secondary">No YouTube results found.</Typography>
)}
</div>
</div>
</Box>
</Container>
);
}
// Regular home view (not in search mode)
return (
<div className="home-container">
<Container maxWidth="xl" sx={{ py: 4 }}>
{videoArray.length === 0 ? (
<div className="no-videos">
<p>No videos yet. Submit a YouTube URL to download your first video!</p>
</div>
<Box sx={{ textAlign: 'center', py: 8 }}>
<Typography variant="h5" color="text.secondary">
No videos yet. Submit a YouTube URL to download your first video!
</Typography>
</Box>
) : (
<div className="home-content">
<Grid container spacing={4}>
{/* Sidebar container for Collections and Authors */}
<div className="sidebar-container">
{/* Collections list */}
<Collections collections={collections} />
{/* Authors list */}
<AuthorsList videos={videoArray} />
<div className="manage-videos-link-container" style={{ marginTop: '1rem', paddingTop: '0.5rem' }}>
<Link
to="/manage"
className="author-link manage-link"
style={{ fontWeight: 'bold', color: 'var(--primary-color)', display: 'block', textAlign: 'center' }}
>
Manage Videos
</Link>
</div>
</div>
<Grid size={{ xs: 12, md: 3 }}>
<Box sx={{ position: 'sticky', top: 80 }}>
<Collections collections={collections} />
<Box sx={{ mt: 2 }}>
<AuthorsList videos={videoArray} />
</Box>
<Box sx={{ mt: 3, textAlign: 'center' }}>
<Button
component={Link}
to="/manage"
variant="outlined"
fullWidth
>
Manage Videos
</Button>
</Box>
</Box>
</Grid>
{/* Videos grid */}
<div className="videos-grid">
{filteredVideos.map(video => (
<VideoCard
key={video.id}
video={video}
collections={collections}
/>
))}
</div>
</div>
<Grid size={{ xs: 12, md: 9 }}>
<Grid container spacing={3}>
{filteredVideos.map(video => (
<Grid size={{ xs: 12, sm: 6, lg: 4, xl: 3 }} key={video.id}>
<VideoCard
video={video}
collections={collections}
/>
</Grid>
))}
</Grid>
</Grid>
</Grid>
)}
</div>
</Container>
);
};

View File

@@ -1,6 +1,33 @@
import {
ArrowBack,
Delete,
Folder,
Search,
VideoLibrary
} from '@mui/icons-material';
import {
Alert,
Box,
Button,
CircularProgress,
Container,
IconButton,
InputAdornment,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Tooltip,
Typography
} from '@mui/material';
import { useState } from 'react';
import { Link } from 'react-router-dom';
import ConfirmationModal from '../components/ConfirmationModal';
import DeleteCollectionModal from '../components/DeleteCollectionModal';
import { Collection, Video } from '../types';
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL;
@@ -44,11 +71,18 @@ const ManagePage: React.FC<ManagePageProps> = ({ videos, onDeleteVideo, collecti
setCollectionToDelete(collection);
};
const handleCollectionDelete = async (deleteVideos: boolean) => {
const handleCollectionDeleteOnly = async () => {
if (!collectionToDelete) return;
setIsDeletingCollection(true);
await onDeleteCollection(collectionToDelete.id, deleteVideos);
await onDeleteCollection(collectionToDelete.id, false);
setIsDeletingCollection(false);
setCollectionToDelete(null);
};
const handleCollectionDeleteAll = async () => {
if (!collectionToDelete) return;
setIsDeletingCollection(true);
await onDeleteCollection(collectionToDelete.id, true);
setIsDeletingCollection(false);
setCollectionToDelete(null);
};
@@ -61,73 +95,29 @@ const ManagePage: React.FC<ManagePageProps> = ({ videos, onDeleteVideo, collecti
};
return (
<div className="manage-page">
<div className="manage-header">
<h1>Manage Content</h1>
<Link to="/" className="back-link"> Back to Home</Link>
</div>
<Container maxWidth="lg" sx={{ py: 4 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4 }}>
<Typography variant="h4" component="h1" fontWeight="bold">
Manage Content
</Typography>
<Button
component={Link}
to="/"
variant="outlined"
startIcon={<ArrowBack />}
>
Back to Home
</Button>
</Box>
{/* Delete Collection Modal */}
{collectionToDelete && (
<div className="modal-overlay" onClick={() => !isDeletingCollection && setCollectionToDelete(null)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>Delete Collection</h2>
<button
className="close-btn"
onClick={() => setCollectionToDelete(null)}
disabled={isDeletingCollection}
>
×
</button>
</div>
<div className="modal-body">
<p style={{ marginBottom: '12px', fontSize: '0.95rem' }}>
You are about to delete the collection <strong>"{collectionToDelete.name}"</strong>.
</p>
<p style={{ color: 'var(--text-secondary)', fontSize: '0.9rem', marginBottom: '20px' }}>
This collection contains <strong>{collectionToDelete.videos.length}</strong> video{collectionToDelete.videos.length !== 1 ? 's' : ''}.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
<button
className="btn secondary-btn"
onClick={() => handleCollectionDelete(false)}
disabled={isDeletingCollection}
style={{ width: '100%' }}
>
{isDeletingCollection ? 'Deleting...' : 'Delete Collection Only'}
</button>
{collectionToDelete.videos.length > 0 && (
<button
className="btn primary-btn"
onClick={() => handleCollectionDelete(true)}
disabled={isDeletingCollection}
style={{
width: '100%',
background: 'linear-gradient(135deg, #ff3e3e 0%, #ff6b6b 100%)',
color: 'white'
}}
>
{isDeletingCollection ? 'Deleting...' : '⚠️ Delete Collection & Videos'}
</button>
)}
</div>
</div>
<div className="modal-footer">
<button
className="btn secondary-btn"
onClick={() => setCollectionToDelete(null)}
disabled={isDeletingCollection}
>
Cancel
</button>
</div>
</div>
</div>
)}
<DeleteCollectionModal
isOpen={!!collectionToDelete}
onClose={() => !isDeletingCollection && setCollectionToDelete(null)}
onDeleteCollectionOnly={handleCollectionDeleteOnly}
onDeleteCollectionAndVideos={handleCollectionDeleteAll}
collectionName={collectionToDelete?.name || ''}
videoCount={collectionToDelete?.videos.length || 0}
/>
<ConfirmationModal
isOpen={showVideoDeleteModal}
@@ -142,101 +132,121 @@ const ManagePage: React.FC<ManagePageProps> = ({ videos, onDeleteVideo, collecti
isDanger={true}
/>
<div className="manage-section">
<h2>Collections ({collections.length})</h2>
<div className="manage-list">
{collections.length > 0 ? (
<table className="manage-table">
<thead>
<tr>
<th>Name</th>
<th>Videos</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{collections.map(collection => (
<tr key={collection.id}>
<td className="col-title">{collection.name}</td>
<td>{collection.videos.length} videos</td>
<td>{new Date(collection.createdAt).toLocaleDateString()}</td>
<td className="col-actions">
<button
className="delete-btn-small"
onClick={() => confirmDeleteCollection(collection)}
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
) : (
<div className="no-videos-found">
No collections found.
</div>
)}
</div>
</div>
<Box sx={{ mb: 6 }}>
<Typography variant="h5" sx={{ mb: 2, display: 'flex', alignItems: 'center' }}>
<Folder sx={{ mr: 1, color: 'secondary.main' }} />
Collections ({collections.length})
</Typography>
<div className="manage-section">
<h2>Videos ({filteredVideos.length})</h2>
<div className="manage-controls">
<input
type="text"
{collections.length > 0 ? (
<TableContainer component={Paper} variant="outlined">
<Table>
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Videos</TableCell>
<TableCell>Created</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{collections.map(collection => (
<TableRow key={collection.id} hover>
<TableCell component="th" scope="row" sx={{ fontWeight: 500 }}>
{collection.name}
</TableCell>
<TableCell>{collection.videos.length} videos</TableCell>
<TableCell>{new Date(collection.createdAt).toLocaleDateString()}</TableCell>
<TableCell align="right">
<Tooltip title="Delete Collection">
<IconButton
color="error"
onClick={() => confirmDeleteCollection(collection)}
size="small"
>
<Delete />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
) : (
<Alert severity="info" variant="outlined">No collections found.</Alert>
)}
</Box>
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h5" sx={{ display: 'flex', alignItems: 'center' }}>
<VideoLibrary sx={{ mr: 1, color: 'primary.main' }} />
Videos ({filteredVideos.length})
</Typography>
<TextField
placeholder="Search videos..."
size="small"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="manage-search"
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Search />
</InputAdornment>
),
}}
sx={{ width: 300 }}
/>
</div>
</Box>
<div className="manage-list">
{filteredVideos.length > 0 ? (
<table className="manage-table">
<thead>
<tr>
<th>Thumbnail</th>
<th>Title</th>
<th>Author</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{filteredVideos.length > 0 ? (
<TableContainer component={Paper} variant="outlined">
<Table>
<TableHead>
<TableRow>
<TableCell>Thumbnail</TableCell>
<TableCell>Title</TableCell>
<TableCell>Author</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{filteredVideos.map(video => (
<tr key={video.id}>
<td className="col-thumbnail">
<img
<TableRow key={video.id} hover>
<TableCell sx={{ width: 140 }}>
<Box
component="img"
src={getThumbnailSrc(video)}
alt={video.title}
className="manage-thumbnail"
sx={{ width: 120, height: 68, objectFit: 'cover', borderRadius: 1 }}
/>
</td>
<td className="col-title">{video.title}</td>
<td className="col-author">{video.author}</td>
<td className="col-actions">
<button
className="delete-btn-small"
onClick={() => handleDelete(video.id)}
disabled={deletingId === video.id}
>
{deletingId === video.id ? 'Deleting...' : 'Delete'}
</button>
</td>
</tr>
</TableCell>
<TableCell sx={{ fontWeight: 500 }}>
{video.title}
</TableCell>
<TableCell>{video.author}</TableCell>
<TableCell align="right">
<Tooltip title="Delete Video">
<IconButton
color="error"
onClick={() => handleDelete(video.id)}
disabled={deletingId === video.id}
>
{deletingId === video.id ? <CircularProgress size={24} /> : <Delete />}
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))}
</tbody>
</table>
) : (
<div className="no-videos-found">
No videos found matching your search.
</div>
)}
</div>
</div>
</div>
</TableBody>
</Table>
</TableContainer>
) : (
<Alert severity="info" variant="outlined">No videos found matching your search.</Alert>
)}
</Box>
</Container>
);
};

View File

@@ -1,3 +1,18 @@
import { ArrowBack, Download, OndemandVideo, YouTube } from '@mui/icons-material';
import {
Alert,
Box,
Button,
Card,
CardActions,
CardContent,
CardMedia,
Chip,
CircularProgress,
Container,
Grid,
Typography
} from '@mui/material';
import React, { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import VideoCard from '../components/VideoCard';
@@ -74,10 +89,10 @@ const SearchResults: React.FC<SearchResultsProps> = ({
// If the entire page is loading
if (loading) {
return (
<div className="search-results">
<h2>Searching for "{searchTerm}"...</h2>
<div className="loading-spinner"></div>
</div>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minHeight: '50vh' }}>
<Typography variant="h5" gutterBottom>Searching for "{searchTerm}"...</Typography>
<CircularProgress />
</Box>
);
}
@@ -103,111 +118,131 @@ const SearchResults: React.FC<SearchResultsProps> = ({
if (noResults) {
return (
<div className="search-results">
<div className="search-header">
<button className="back-button" onClick={handleBackClick}>
<span></span> Back to Home
</button>
<h2>Search Results for "{searchTerm}"</h2>
</div>
<p className="no-results">No results found. Try a different search term.</p>
</div>
<Container maxWidth="xl" sx={{ py: 4 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4 }}>
<Typography variant="h4" component="h1" fontWeight="bold">
Search Results for "{searchTerm}"
</Typography>
<Button
variant="outlined"
startIcon={<ArrowBack />}
onClick={handleBackClick}
>
Back to Home
</Button>
</Box>
<Alert severity="info" variant="outlined">No results found. Try a different search term.</Alert>
</Container>
);
}
return (
<div className="search-results">
<div className="search-header">
<button className="back-button" onClick={handleBackClick}>
<span></span> Back to Home
</button>
<h2>Search Results for "{searchTerm}"</h2>
</div>
<Container maxWidth="xl" sx={{ py: 4 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4 }}>
<Typography variant="h4" component="h1" fontWeight="bold">
Search Results for "{searchTerm}"
</Typography>
<Button
variant="outlined"
startIcon={<ArrowBack />}
onClick={handleBackClick}
>
Back to Home
</Button>
</Box>
{/* Local Video Results */}
{hasLocalResults ? (
<div className="search-results-section">
<h3 className="section-title">From Your Library</h3>
<div className="search-results-grid">
{localResults.map((video) => (
<Box sx={{ mb: 6 }}>
<Typography variant="h5" sx={{ mb: 3, fontWeight: 600, color: 'primary.main' }}>
From Your Library
</Typography>
{hasLocalResults ? (
<Grid container spacing={3}>
{localResults.map((video) => <Grid size={{ xs: 12, sm: 6, md: 4, lg: 3 }} key={video.id}>
<VideoCard
key={video.id}
video={video}
collections={collections}
onDeleteVideo={onDeleteVideo}
showDeleteButton={true}
/>
))}
</div>
</div>
) : (
<div className="search-results-section">
<h3 className="section-title">From Your Library</h3>
<p className="no-results">No matching videos in your library.</p>
</div>
)}
</Grid>
)}
</Grid>
) : (
<Typography color="text.secondary">No matching videos in your library.</Typography>
)}
</Box>
{/* YouTube Search Results */}
<div className="search-results-section">
<h3 className="section-title">From YouTube</h3>
<Box>
<Typography variant="h5" sx={{ mb: 3, fontWeight: 600, color: '#ff0000' }}>
From YouTube
</Typography>
{youtubeLoading ? (
<div className="youtube-loading">
<div className="loading-spinner"></div>
<p>Loading YouTube results...</p>
</div>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 4 }}>
<CircularProgress color="error" />
<Typography sx={{ mt: 2 }}>Loading YouTube results...</Typography>
</Box>
) : hasYouTubeResults ? (
<div className="search-results-grid">
{results.map((result) => (
<div key={result.id} className="search-result-card">
<div className="search-result-thumbnail">
{result.thumbnailUrl ? (
<img
src={result.thumbnailUrl}
alt={result.title}
onError={(e) => {
const target = e.target as HTMLImageElement;
target.onerror = null;
target.src = 'https://via.placeholder.com/480x360?text=No+Thumbnail';
}}
<Grid container spacing={3}>
{results.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' }}
/>
) : (
<div className="thumbnail-placeholder">No Thumbnail</div>
)}
</div>
<div className="search-result-info">
<h3 className="search-result-title">{result.title}</h3>
<p className="search-result-author">{result.author}</p>
<div className="search-result-meta">
{result.duration && (
<span className="search-result-duration">
{formatDuration(result.duration)}
</span>
)}
{result.viewCount && (
<span className="search-result-views">
{formatViewCount(result.viewCount)} views
</span>
)}
<span className={`source-badge ${result.source}`}>
{result.source}
</span>
</div>
<button
className="download-btn"
<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)} views
</Typography>
)}
</CardContent>
<CardActions sx={{ p: 2, pt: 0 }}>
<Button
fullWidth
variant="contained"
startIcon={<Download />}
onClick={() => handleDownload(result.sourceUrl, result.title)}
>
Download
</button>
</div>
</div>
))}
</div>
</Button>
</CardActions>
</Card>
</Grid>
)}
</Grid>
) : (
<p className="no-results">No YouTube results found.</p>
<Typography color="text.secondary">No YouTube results found.</Typography>
)}
</div>
</div>
</Box>
</Container>
);
};

View File

@@ -1,8 +1,40 @@
import {
Add,
Delete,
Folder
} from '@mui/icons-material';
import {
Alert,
Avatar,
Box,
Button,
Card,
CardContent,
CardMedia,
Chip,
CircularProgress,
Container,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Divider,
FormControl,
Grid,
InputLabel,
MenuItem,
Select,
Stack,
TextField,
Typography,
useTheme
} from '@mui/material';
import axios from 'axios';
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import ConfirmationModal from '../components/ConfirmationModal';
import { Collection, Video } from '../types';
const API_URL = import.meta.env.VITE_API_URL;
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL;
@@ -25,6 +57,8 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
}) => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const theme = useTheme();
const [video, setVideo] = useState<Video | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
@@ -213,296 +247,269 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
};
if (loading) {
return <div className="loading">Loading video...</div>;
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '50vh' }}>
<CircularProgress />
<Typography sx={{ ml: 2 }}>Loading video...</Typography>
</Box>
);
}
if (error || !video) {
return <div className="error">{error || 'Video not found'}</div>;
return (
<Container sx={{ mt: 4 }}>
<Alert severity="error">{error || 'Video not found'}</Alert>
</Container>
);
}
// Get related videos (exclude current video)
const relatedVideos = videos.filter(v => v.id !== id).slice(0, 10);
return (
<div className="video-player-page">
{/* Main Content Column */}
<div className="video-main-content">
<div className="video-wrapper">
<video
className="video-player"
controls
autoPlay
src={`${BACKEND_URL}${video.videoPath || video.sourceUrl}`}
>
Your browser does not support the video tag.
</video>
</div>
<Container maxWidth="xl" sx={{ py: 4 }}>
<Grid container spacing={4}>
{/* Main Content Column */}
<Grid size={{ xs: 12, lg: 9 }}>
<Box sx={{ width: '100%', bgcolor: 'black', borderRadius: 2, overflow: 'hidden', boxShadow: 4 }}>
<video
style={{ width: '100%', aspectRatio: '16/9', display: 'block' }}
controls
autoPlay
src={`${BACKEND_URL}${video.videoPath || video.sourceUrl}`}
>
Your browser does not support the video tag.
</video>
</Box>
<div className="video-info-section">
<h1 className="video-title-h1">{video.title}</h1>
<Box sx={{ mt: 2 }}>
<Typography variant="h5" component="h1" fontWeight="bold" gutterBottom>
{video.title}
</Typography>
<div className="video-actions-row">
<div className="video-primary-actions">
<div className="channel-row" style={{ marginBottom: 0 }}>
<div className="channel-avatar">
<Stack
direction={{ xs: 'column', sm: 'row' }}
justifyContent="space-between"
alignItems={{ xs: 'flex-start', sm: 'center' }}
spacing={2}
sx={{ mb: 2 }}
>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Avatar sx={{ bgcolor: 'primary.main', mr: 2 }}>
{video.author ? video.author.charAt(0).toUpperCase() : 'A'}
</div>
<div className="channel-info">
<div
className="channel-name clickable"
</Avatar>
<Box>
<Typography
variant="subtitle1"
fontWeight="bold"
onClick={handleAuthorClick}
sx={{ cursor: 'pointer', '&:hover': { color: 'primary.main' } }}
>
{video.author}
</div>
<div className="video-stats">
{/* Placeholder for subscribers if we had that data */}
</div>
</div>
</div>
</div>
</Typography>
<Typography variant="caption" color="text.secondary">
{formatDate(video.date)}
</Typography>
</Box>
</Box>
<div className="video-primary-actions">
<button
className="action-btn btn-secondary"
onClick={handleAddToCollection}
>
<span>+ Add to Collection</span>
</button>
<button
className="action-btn btn-danger"
onClick={handleDelete}
disabled={isDeleting}
>
{isDeleting ? 'Deleting...' : 'Delete'}
</button>
</div>
</div>
{deleteError && (
<div className="error-message" style={{ color: 'var(--primary-color)', marginTop: '10px' }}>
{deleteError}
</div>
)}
</div>
<div className="channel-desc-container">
<div className="video-stats" style={{ marginBottom: '8px', color: 'var(--text-secondary)', fontWeight: 'bold' }}>
{/* Views would go here */}
{formatDate(video.date)}
</div>
<div className="description-text" style={{ color: 'var(--text-color)' }}>
{/* We don't have a real description, so we'll show some metadata */}
<p>Source: {video.source === 'bilibili' ? 'Bilibili' : 'YouTube'}</p>
{video.sourceUrl && (
<p>
Original Link: <a href={video.sourceUrl} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--primary-color)' }}>{video.sourceUrl}</a>
</p>
)}
</div>
{videoCollections.length > 0 && (
<div className="collection-tags">
{videoCollections.map(c => (
<span
key={c.id}
className="collection-pill"
onClick={() => handleCollectionClick(c.id)}
<Stack direction="row" spacing={1}>
<Button
variant="outlined"
startIcon={<Add />}
onClick={handleAddToCollection}
>
{c.name}
</span>
))}
</div>
)}
</div>
</div>
Add to Collection
</Button>
<Button
variant="contained"
color="error"
startIcon={<Delete />}
onClick={handleDelete}
disabled={isDeleting}
>
{isDeleting ? 'Deleting...' : 'Delete'}
</Button>
</Stack>
</Stack>
{deleteError && (
<Alert severity="error" sx={{ mb: 2 }}>
{deleteError}
</Alert>
)}
{/* Sidebar Column - Up Next */}
<div className="video-sidebar">
<h3 className="sidebar-title">Up Next</h3>
<div className="related-videos-list">
{relatedVideos.map(relatedVideo => (
<div
key={relatedVideo.id}
className="related-video-card"
onClick={() => navigate(`/video/${relatedVideo.id}`)}
>
<div className="related-video-thumbnail">
<img
src={`${BACKEND_URL}${relatedVideo.thumbnailPath}`}
alt={relatedVideo.title}
onError={(e) => {
const target = e.target as HTMLImageElement;
target.onerror = null;
target.src = 'https://via.placeholder.com/168x94?text=No+Thumbnail';
}}
/>
<span className="duration-badge">{relatedVideo.duration || '00:00'}</span>
</div>
<div className="related-video-info">
<div className="related-video-title">{relatedVideo.title}</div>
<div className="related-video-author">{relatedVideo.author}</div>
<div className="related-video-meta">
{formatDate(relatedVideo.date)}
</div>
</div>
</div>
))}
{relatedVideos.length === 0 && (
<div className="no-videos">No other videos available</div>
)}
</div>
</div>
<Divider sx={{ my: 2 }} />
{/* Collection Modal */}
{
showCollectionModal && (
<div className="modal-overlay" onClick={handleCloseModal}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>Add to Collection</h2>
<button className="close-btn" onClick={handleCloseModal}>×</button>
</div>
<Box sx={{ bgcolor: 'background.paper', p: 2, borderRadius: 2 }}>
<Typography variant="body2" paragraph>
<strong>Source:</strong> {video.source === 'bilibili' ? 'Bilibili' : 'YouTube'}
</Typography>
{video.sourceUrl && (
<Typography variant="body2" paragraph>
<strong>Original Link:</strong>{' '}
<a href={video.sourceUrl} target="_blank" rel="noopener noreferrer" style={{ color: theme.palette.primary.main }}>
{video.sourceUrl}
</a>
</Typography>
)}
<div className="modal-body">
{videoCollections.length > 0 && (
<div className="current-collection" style={{
marginBottom: '1.5rem',
padding: '1rem',
background: 'linear-gradient(135deg, rgba(62, 166, 255, 0.1) 0%, rgba(62, 166, 255, 0.05) 100%)',
borderRadius: '8px',
border: '1px solid rgba(62, 166, 255, 0.3)'
}}>
<p style={{ margin: '0 0 0.5rem 0', color: 'var(--text-color)', fontWeight: '500' }}>
📁 Currently in: <strong>{videoCollections[0].name}</strong>
</p>
<p style={{ margin: '0 0 1rem 0', fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
Adding to a different collection will remove it from the current one.
</p>
<button
className="modal-btn danger-btn"
style={{ width: '100%' }}
onClick={handleRemoveFromCollection}
>
Remove from Collection
</button>
</div>
)}
{videoCollections.length > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2" gutterBottom>Collections:</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap">
{videoCollections.map(c => (
<Chip
key={c.id}
icon={<Folder />}
label={c.name}
onClick={() => handleCollectionClick(c.id)}
color="secondary"
variant="outlined"
clickable
sx={{ mb: 1 }}
/>
))}
</Stack>
</Box>
)}
</Box>
</Box>
</Grid>
{collections && collections.length > 0 && (
<div className="existing-collections" style={{ marginBottom: '1.5rem' }}>
<h3 style={{ margin: '0 0 10px 0', fontSize: '1rem', fontWeight: '600', color: 'var(--text-color)' }}>
Add to existing collection:
</h3>
<div style={{ position: 'relative' }}>
<select
value={selectedCollection}
onChange={(e) => setSelectedCollection(e.target.value)}
className="glass-panel"
style={{
width: '100%',
padding: '12px 16px',
paddingRight: '40px',
borderRadius: '8px',
color: 'var(--text-color)',
fontSize: '1rem',
marginBottom: '0.8rem',
cursor: 'pointer',
appearance: 'none',
backgroundImage: `url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='var(--text-color)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e")`,
backgroundRepeat: 'no-repeat',
backgroundPosition: 'right 12px center',
backgroundSize: '16px',
}}
>
<option value="" style={{ color: 'var(--text-color)', backgroundColor: 'var(--background-card)' }}>Select a collection</option>
{collections.map(collection => (
<option
key={collection.id}
value={collection.id}
disabled={videoCollections.length > 0 && videoCollections[0].id === collection.id}
style={{ color: 'var(--text-color)', backgroundColor: 'var(--background-card)' }}
>
{collection.name} {videoCollections.length > 0 && videoCollections[0].id === collection.id ? '(Current)' : ''}
</option>
))}
</select>
</div>
<button
className="modal-btn primary-btn"
style={{
width: '100%',
padding: '12px',
borderRadius: '8px',
background: 'linear-gradient(135deg, #00C6FF 0%, #0072FF 100%)',
border: 'none',
color: 'white',
fontWeight: '600',
cursor: selectedCollection ? 'pointer' : 'not-allowed',
opacity: selectedCollection ? 1 : 0.6,
transition: 'all 0.2s ease'
}}
onClick={handleAddToExistingCollection}
disabled={!selectedCollection}
>
Add to Collection
</button>
</div>
)}
<div className="new-collection">
<h3 style={{ margin: '0 0 10px 0', fontSize: '1rem', fontWeight: '600', color: 'var(--text-color)' }}>
Create new collection:
</h3>
<input
type="text"
className="collection-input glass-panel"
placeholder="Collection name"
value={newCollectionName}
onChange={(e) => setNewCollectionName(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && newCollectionName.trim() && handleCreateCollection()}
style={{
width: '100%',
padding: '12px 16px',
borderRadius: '8px',
color: 'var(--text-color)',
fontSize: '1rem',
marginBottom: '0.8rem',
{/* Sidebar Column - Up Next */}
<Grid size={{ xs: 12, lg: 3 }}>
<Typography variant="h6" gutterBottom fontWeight="bold">Up Next</Typography>
<Stack spacing={2}>
{relatedVideos.map(relatedVideo => (
<Card
key={relatedVideo.id}
sx={{ display: 'flex', cursor: 'pointer', '&:hover': { bgcolor: 'action.hover' } }}
onClick={() => navigate(`/video/${relatedVideo.id}`)}
>
<Box sx={{ width: 168, minWidth: 168, position: 'relative' }}>
<CardMedia
component="img"
sx={{ width: '100%', height: 94, objectFit: 'cover' }}
image={`${BACKEND_URL}${relatedVideo.thumbnailPath}`}
alt={relatedVideo.title}
onError={(e) => {
const target = e.target as HTMLImageElement;
target.onerror = null;
target.src = 'https://via.placeholder.com/168x94?text=No+Thumbnail';
}}
/>
<button
className="modal-btn primary-btn"
style={{
width: '100%',
padding: '12px',
borderRadius: '8px',
background: 'linear-gradient(135deg, #00C6FF 0%, #0072FF 100%)',
border: 'none',
color: 'white',
fontWeight: '600',
cursor: newCollectionName.trim() ? 'pointer' : 'not-allowed',
opacity: newCollectionName.trim() ? 1 : 0.6,
transition: 'all 0.2s ease'
}}
onClick={handleCreateCollection}
disabled={!newCollectionName.trim()}
>
Create Collection
</button>
</div>
</div>
{relatedVideo.duration && (
<Chip
label={relatedVideo.duration || '00:00'}
size="small"
sx={{
position: 'absolute',
bottom: 4,
right: 4,
height: 20,
fontSize: '0.75rem',
bgcolor: 'rgba(0,0,0,0.8)',
color: 'white'
}}
/>
)}
</Box>
<CardContent sx={{ flex: '1 0 auto', p: 1, '&:last-child': { pb: 1 } }}>
<Typography variant="body2" fontWeight="bold" sx={{ lineHeight: 1.2, mb: 0.5, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{relatedVideo.title}
</Typography>
<Typography variant="caption" display="block" color="text.secondary">
{relatedVideo.author}
</Typography>
<Typography variant="caption" display="block" color="text.secondary">
{formatDate(relatedVideo.date)}
</Typography>
</CardContent>
</Card>
))}
{relatedVideos.length === 0 && (
<Typography variant="body2" color="text.secondary">No other videos available</Typography>
)}
</Stack>
</Grid>
</Grid>
<div className="modal-footer">
<button className="btn secondary-btn" onClick={handleCloseModal}>
Cancel
</button>
</div>
</div>
</div>
)
}
{/* Collection Modal */}
<Dialog open={showCollectionModal} onClose={handleCloseModal} maxWidth="sm" fullWidth>
<DialogTitle>Add to Collection</DialogTitle>
<DialogContent dividers>
{videoCollections.length > 0 && (
<Alert severity="info" sx={{ mb: 3 }} action={
<Button color="error" size="small" onClick={handleRemoveFromCollection}>
Remove
</Button>
}>
Currently in: <strong>{videoCollections[0].name}</strong>
<Typography variant="caption" display="block">
Adding to a different collection will remove it from the current one.
</Typography>
</Alert>
)}
{collections && collections.length > 0 && (
<Box sx={{ mb: 4 }}>
<Typography variant="subtitle2" gutterBottom>Add to existing collection:</Typography>
<Stack direction="row" spacing={2}>
<FormControl fullWidth size="small">
<InputLabel>Select a collection</InputLabel>
<Select
value={selectedCollection}
label="Select a collection"
onChange={(e) => setSelectedCollection(e.target.value)}
>
{collections.map(collection => (
<MenuItem
key={collection.id}
value={collection.id}
disabled={videoCollections.length > 0 && videoCollections[0].id === collection.id}
>
{collection.name} {videoCollections.length > 0 && videoCollections[0].id === collection.id ? '(Current)' : ''}
</MenuItem>
))}
</Select>
</FormControl>
<Button
variant="contained"
onClick={handleAddToExistingCollection}
disabled={!selectedCollection}
>
Add
</Button>
</Stack>
</Box>
)}
<Box>
<Typography variant="subtitle2" gutterBottom>Create new collection:</Typography>
<Stack direction="row" spacing={2}>
<TextField
fullWidth
size="small"
label="Collection name"
value={newCollectionName}
onChange={(e) => setNewCollectionName(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && newCollectionName.trim() && handleCreateCollection()}
/>
<Button
variant="contained"
onClick={handleCreateCollection}
disabled={!newCollectionName.trim()}
>
Create
</Button>
</Stack>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseModal} color="inherit">Cancel</Button>
</DialogActions>
</Dialog>
<ConfirmationModal
isOpen={confirmationModal.isOpen}
@@ -513,7 +520,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
confirmText={confirmationModal.confirmText}
isDanger={confirmationModal.isDanger}
/>
</div >
</Container>
);
};

83
frontend/src/theme.ts Normal file
View File

@@ -0,0 +1,83 @@
import { createTheme } from '@mui/material/styles';
const getTheme = (mode: 'light' | 'dark') => createTheme({
palette: {
mode,
primary: {
main: '#00e5ff', // Neon Cyan
},
secondary: {
main: '#651fff', // Deep Purple
},
background: {
default: mode === 'dark' ? '#0a0a0a' : '#f5f5f5',
paper: mode === 'dark' ? '#1e1e1e' : '#ffffff',
},
text: {
primary: mode === 'dark' ? '#ffffff' : '#000000',
secondary: mode === 'dark' ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.7)',
},
},
typography: {
fontFamily: '"Inter", "Roboto", "Helvetica", "Arial", sans-serif',
h1: {
fontWeight: 700,
},
h2: {
fontWeight: 600,
},
h3: {
fontWeight: 600,
},
},
components: {
MuiButton: {
styleOverrides: {
root: {
borderRadius: 8,
textTransform: 'none',
fontWeight: 600,
},
containedPrimary: {
boxShadow: '0 0 10px rgba(0, 229, 255, 0.5)', // Neon glow
'&:hover': {
boxShadow: '0 0 20px rgba(0, 229, 255, 0.7)',
},
},
},
},
MuiCard: {
styleOverrides: {
root: {
borderRadius: 16,
backgroundImage: 'none', // Remove default gradient in dark mode if unwanted
backgroundColor: mode === 'dark' ? 'rgba(30, 30, 30, 0.6)' : '#ffffff', // Glassmorphism base
backdropFilter: 'blur(10px)',
border: mode === 'dark' ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid rgba(0, 0, 0, 0.1)',
},
},
},
MuiAppBar: {
styleOverrides: {
root: {
backgroundColor: mode === 'dark' ? 'rgba(10, 10, 10, 0.8)' : 'rgba(255, 255, 255, 0.8)',
backdropFilter: 'blur(10px)',
borderBottom: mode === 'dark' ? '1px solid rgba(255, 255, 255, 0.05)' : '1px solid rgba(0, 0, 0, 0.05)',
backgroundImage: 'none',
color: mode === 'dark' ? '#fff' : '#000',
},
},
},
MuiDialog: {
styleOverrides: {
paper: {
borderRadius: 16,
backgroundColor: mode === 'dark' ? '#1e1e1e' : '#ffffff',
border: mode === 'dark' ? '1px solid rgba(255, 255, 255, 0.1)' : 'none',
},
},
},
},
});
export default getTheme;