refactor with MUI
This commit is contained in:
733
frontend/package-lock.json
generated
733
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
2597
frontend/src/App.css
2597
frontend/src/App.css
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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}>
|
||||
← 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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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}>
|
||||
← 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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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
83
frontend/src/theme.ts
Normal 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;
|
||||
Reference in New Issue
Block a user