feat: Introduce AuthProvider for authentication

This commit is contained in:
Peifan Li
2025-11-26 22:28:44 -05:00
parent 59eb6bb2ab
commit 401ab1d04d
12 changed files with 339 additions and 447 deletions

View File

@@ -1,5 +1,4 @@
import { Box, CssBaseline, ThemeProvider } from '@mui/material';
import axios from 'axios';
import { useEffect, useMemo, useState } from 'react';
import { BrowserRouter as Router } from 'react-router-dom';
import './App.css';
@@ -7,6 +6,7 @@ import AnimatedRoutes from './components/AnimatedRoutes';
import BilibiliPartsModal from './components/BilibiliPartsModal';
import Footer from './components/Footer';
import Header from './components/Header';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import { CollectionProvider, useCollection } from './contexts/CollectionContext';
import { DownloadProvider, useDownload } from './contexts/DownloadContext';
import { LanguageProvider } from './contexts/LanguageContext';
@@ -15,31 +15,17 @@ import { VideoProvider, useVideo } from './contexts/VideoContext';
import LoginPage from './pages/LoginPage';
import getTheme from './theme';
const API_URL = import.meta.env.VITE_API_URL;
function AppContent() {
const {
videos,
loading,
error,
deleteVideo,
isSearchMode,
searchTerm,
searchResults,
localSearchResults,
youtubeLoading,
handleSearch,
resetSearch,
setIsSearchMode
resetSearch
} = useVideo();
const {
collections,
createCollection,
addToCollection,
removeFromCollection,
deleteCollection
} = useCollection();
const { collections } = useCollection();
const {
activeDownloads,
@@ -53,16 +39,13 @@ function AppContent() {
handleDownloadCurrentBilibiliPart
} = useDownload();
const { isAuthenticated, loginRequired, checkingAuth } = useAuth();
// Theme state
const [themeMode, setThemeMode] = useState<'light' | 'dark'>(() => {
return (localStorage.getItem('theme') as 'light' | 'dark') || 'dark';
});
// Login state
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
const [loginRequired, setLoginRequired] = useState<boolean>(true); // Assume required until checked
const [checkingAuth, setCheckingAuth] = useState<boolean>(true);
const theme = useMemo(() => getTheme(themeMode), [themeMode]);
// Apply theme to body
@@ -75,58 +58,6 @@ function AppContent() {
setThemeMode(prevTheme => prevTheme === 'light' ? 'dark' : 'light');
};
// Check login settings and authentication status
useEffect(() => {
const checkAuth = async () => {
try {
// Check if login is enabled in settings
const response = await axios.get(`${API_URL}/settings`);
const { loginEnabled, isPasswordSet } = response.data;
// Login is required only if enabled AND a password is set
if (!loginEnabled || !isPasswordSet) {
setLoginRequired(false);
setIsAuthenticated(true);
} else {
setLoginRequired(true);
// Check if already authenticated in this session
const sessionAuth = sessionStorage.getItem('mytube_authenticated');
if (sessionAuth === 'true') {
setIsAuthenticated(true);
} else {
setIsAuthenticated(false);
}
}
} catch (error) {
console.error('Error checking auth settings:', error);
// If error, default to requiring login for security, but maybe allow if backend is down?
// Better to fail safe.
} finally {
setCheckingAuth(false);
}
};
checkAuth();
}, []);
const handleLoginSuccess = () => {
setIsAuthenticated(true);
sessionStorage.setItem('mytube_authenticated', 'true');
};
const handleDownloadFromSearch = async (videoUrl: string) => {
try {
// We need to stop the search mode
setIsSearchMode(false);
const result = await handleVideoSubmit(videoUrl);
return result;
} catch (error) {
console.error('Error in handleDownloadFromSearch:', error);
return { success: false, error: 'Failed to download video' };
}
};
return (
<ThemeProvider theme={theme}>
<CssBaseline />
@@ -134,7 +65,7 @@ function AppContent() {
checkingAuth ? (
<div className="loading">Loading...</div>
) : (
<LoginPage onLoginSuccess={handleLoginSuccess} />
<LoginPage />
)
) : (
<Router>
@@ -165,25 +96,8 @@ function AppContent() {
type={bilibiliPartsInfo.type}
/>
<Box component="main" sx={{ flex: 1, display: 'flex', flexDirection: 'column', width: '100%', px: { xs: 1, md: 2, lg: 4 } }}>
<AnimatedRoutes
videos={videos}
loading={loading}
error={error}
onDeleteVideo={deleteVideo}
collections={collections}
isSearchMode={isSearchMode}
searchTerm={searchTerm}
localSearchResults={localSearchResults}
youtubeLoading={youtubeLoading}
searchResults={searchResults}
onDownload={handleDownloadFromSearch}
onResetSearch={resetSearch}
onAddToCollection={addToCollection}
onCreateCollection={createCollection}
onRemoveFromCollection={removeFromCollection}
onDeleteCollection={deleteCollection}
/>
<Box component="main" sx={{ flexGrow: 1, p: 0, width: '100%', overflowX: 'hidden' }}>
<AnimatedRoutes />
</Box>
<Footer />
@@ -203,13 +117,15 @@ function App() {
<QueryClientProvider client={queryClient}>
<LanguageProvider>
<SnackbarProvider>
<VideoProvider>
<CollectionProvider>
<DownloadProvider>
<AppContent />
</DownloadProvider>
</CollectionProvider>
</VideoProvider>
<AuthProvider>
<VideoProvider>
<CollectionProvider>
<DownloadProvider>
<AppContent />
</DownloadProvider>
</CollectionProvider>
</VideoProvider>
</AuthProvider>
</SnackbarProvider>
</LanguageProvider>
</QueryClientProvider>

View File

@@ -1,53 +1,17 @@
import { AnimatePresence } from 'framer-motion';
import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
import { AnimatePresence, motion } from 'framer-motion';
import React from 'react';
import { Route, Routes, useLocation } from 'react-router-dom';
import AuthorVideos from '../pages/AuthorVideos';
import CollectionPage from '../pages/CollectionPage';
import DownloadPage from '../pages/DownloadPage';
import Home from '../pages/Home';
import LoginPage from '../pages/LoginPage';
import ManagePage from '../pages/ManagePage';
import SearchResults from '../pages/SearchResults';
import SettingsPage from '../pages/SettingsPage';
import VideoPlayer from '../pages/VideoPlayer';
import { Collection, Video } from '../types';
import PageTransition from './PageTransition';
interface AnimatedRoutesProps {
videos: Video[];
loading: boolean;
error: string | null;
onDeleteVideo: (id: string) => Promise<{ success: boolean; error?: string }>;
collections: Collection[];
isSearchMode: boolean;
searchTerm: string;
localSearchResults: Video[];
youtubeLoading: boolean;
searchResults: any[];
onDownload: (videoUrl: string) => Promise<any>;
onResetSearch: () => void;
onAddToCollection: (collectionId: string, videoId: string) => Promise<any>;
onCreateCollection: (name: string, videoId: string) => Promise<any>;
onRemoveFromCollection: (videoId: string) => Promise<boolean>;
onDeleteCollection: (collectionId: string, deleteVideos?: boolean) => Promise<{ success: boolean; error?: string }>;
}
const AnimatedRoutes = ({
videos,
loading,
error,
onDeleteVideo,
collections,
isSearchMode,
searchTerm,
localSearchResults,
youtubeLoading,
searchResults,
onDownload,
onResetSearch,
onAddToCollection,
onCreateCollection,
onRemoveFromCollection,
onDeleteCollection
}: AnimatedRoutesProps) => {
const AnimatedRoutes: React.FC = () => {
const location = useLocation();
return (
@@ -56,115 +20,120 @@ const AnimatedRoutes = ({
<Route
path="/"
element={
<PageTransition>
<Home
videos={videos}
loading={loading}
error={error}
onDeleteVideo={onDeleteVideo}
collections={collections}
isSearchMode={isSearchMode}
searchTerm={searchTerm}
localSearchResults={localSearchResults}
youtubeLoading={youtubeLoading}
searchResults={searchResults}
onDownload={onDownload}
onResetSearch={onResetSearch}
/>
</PageTransition>
}
/>
<Route
path="/video/:id"
element={
<PageTransition>
<VideoPlayer
videos={videos}
onDeleteVideo={onDeleteVideo}
collections={collections}
onAddToCollection={onAddToCollection}
onCreateCollection={onCreateCollection}
onRemoveFromCollection={onRemoveFromCollection}
/>
</PageTransition>
}
/>
<Route
path="/author/:author"
element={
<PageTransition>
<AuthorVideos
videos={videos}
onDeleteVideo={onDeleteVideo}
collections={collections}
/>
</PageTransition>
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
transition={{ duration: 0.3 }}
>
<Home />
</motion.div>
}
/>
<Route
path="/collection/:id"
element={
<PageTransition>
<CollectionPage
collections={collections}
videos={videos}
onDeleteVideo={onDeleteVideo}
onDeleteCollection={onDeleteCollection}
/>
</PageTransition>
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.3 }}
>
<CollectionPage />
</motion.div>
}
/>
<Route
path="/search"
path="/video/:id"
element={
<PageTransition>
<SearchResults
results={searchResults}
localResults={localSearchResults}
youtubeLoading={youtubeLoading}
loading={loading}
onDownload={onDownload}
onResetSearch={onResetSearch}
onDeleteVideo={onDeleteVideo}
collections={collections}
searchTerm={searchTerm}
/>
</PageTransition>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.3 }}
>
<VideoPlayer />
</motion.div>
}
/>
<Route
path="/manage"
path="/author/:authorName"
element={
<PageTransition>
<ManagePage
videos={videos}
onDeleteVideo={onDeleteVideo}
collections={collections}
onDeleteCollection={onDeleteCollection}
/>
</PageTransition>
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.3 }}
>
<AuthorVideos />
</motion.div>
}
/>
<Route
path="/downloads"
element={
<PageTransition>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
>
<DownloadPage />
</PageTransition>
</motion.div>
}
/>
<Route
path="/settings"
element={
<PageTransition>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
>
<SettingsPage />
</PageTransition>
</motion.div>
}
/>
<Route
path="/manage"
element={
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
>
<ManagePage />
</motion.div>
}
/>
<Route
path="/search"
element={
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
>
<SearchResults />
</motion.div>
}
/>
<Route
path="/login"
element={
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
transition={{ duration: 0.3 }}
>
<LoginPage />
</motion.div>
}
/>
{/* Redirect /login to home if already authenticated (or login disabled) */}
<Route path="/login" element={<Navigate to="/" replace />} />
{/* Catch all - redirect to home */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</AnimatePresence>
);

View File

@@ -28,7 +28,7 @@ const BACKEND_URL = import.meta.env.VITE_BACKEND_URL;
interface VideoCardProps {
video: Video;
collections?: Collection[];
onDeleteVideo?: (id: string) => Promise<void>;
onDeleteVideo?: (id: string) => Promise<any>;
showDeleteButton?: boolean;
disableCollectionGrouping?: boolean;
}

View File

@@ -0,0 +1,77 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import React, { createContext, useContext, useState } from 'react';
const API_URL = import.meta.env.VITE_API_URL;
interface AuthContextType {
isAuthenticated: boolean;
loginRequired: boolean;
checkingAuth: boolean;
login: () => void;
logout: () => void;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
const [loginRequired, setLoginRequired] = useState<boolean>(true); // Assume required until checked
const queryClient = useQueryClient();
// Check login settings and authentication status
const { isLoading: checkingAuth } = useQuery({
queryKey: ['authSettings'],
queryFn: async () => {
try {
// Check if login is enabled in settings
const response = await axios.get(`${API_URL}/settings`);
const { loginEnabled, isPasswordSet } = response.data;
// Login is required only if enabled AND a password is set
if (!loginEnabled || !isPasswordSet) {
setLoginRequired(false);
setIsAuthenticated(true);
} else {
setLoginRequired(true);
// Check if already authenticated in this session
const sessionAuth = sessionStorage.getItem('mytube_authenticated');
if (sessionAuth === 'true') {
setIsAuthenticated(true);
} else {
setIsAuthenticated(false);
}
}
return response.data;
} catch (error) {
console.error('Error checking auth settings:', error);
return null;
}
}
});
const login = () => {
setIsAuthenticated(true);
sessionStorage.setItem('mytube_authenticated', 'true');
};
const logout = () => {
setIsAuthenticated(false);
sessionStorage.removeItem('mytube_authenticated');
queryClient.invalidateQueries({ queryKey: ['authSettings'] });
};
return (
<AuthContext.Provider value={{ isAuthenticated, loginRequired, checkingAuth, login, logout }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

View File

@@ -12,28 +12,20 @@ import {
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import VideoCard from '../components/VideoCard';
import { useCollection } from '../contexts/CollectionContext';
import { useLanguage } from '../contexts/LanguageContext';
import { useVideo } from '../contexts/VideoContext';
import { Collection, Video } from '../types';
import { Video } from '../types';
interface AuthorVideosProps {
videos?: Video[]; // Make optional since we can get from context
onDeleteVideo: (id: string) => Promise<any>;
collections: Collection[];
}
const AuthorVideos: React.FC<AuthorVideosProps> = ({ videos: propVideos, onDeleteVideo, collections = [] }) => {
const AuthorVideos: React.FC = () => {
const { t } = useLanguage();
const { author } = useParams<{ author: string }>();
const navigate = useNavigate();
const { videos: contextVideos, loading: contextLoading } = useVideo();
const { videos, loading, deleteVideo } = useVideo();
const { collections } = useCollection();
const [authorVideos, setAuthorVideos] = useState<Video[]>([]);
// Use prop videos if available, otherwise context videos
const videos = propVideos && propVideos.length > 0 ? propVideos : contextVideos;
const loading = (propVideos && propVideos.length > 0) ? false : contextLoading;
useEffect(() => {
if (!author) return;
@@ -112,7 +104,7 @@ const AuthorVideos: React.FC<AuthorVideosProps> = ({ videos: propVideos, onDelet
<VideoCard
video={video}
collections={collections}
onDeleteVideo={onDeleteVideo}
onDeleteVideo={deleteVideo}
showDeleteButton={true}
/>
</Grid>

View File

@@ -4,59 +4,34 @@ import {
Avatar,
Box,
Button,
CircularProgress,
Container,
Grid,
Pagination,
Typography
} from '@mui/material';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import DeleteCollectionModal from '../components/DeleteCollectionModal';
import VideoCard from '../components/VideoCard';
import { useCollection } from '../contexts/CollectionContext';
import { useLanguage } from '../contexts/LanguageContext';
import { Collection, Video } from '../types';
import { useVideo } from '../contexts/VideoContext';
interface CollectionPageProps {
collections: Collection[];
videos: Video[];
onDeleteVideo: (id: string) => Promise<any>;
onDeleteCollection: (id: string, deleteVideos: boolean) => Promise<any>;
}
const CollectionPage: React.FC<CollectionPageProps> = ({ collections, videos, onDeleteVideo, onDeleteCollection }) => {
const CollectionPage: React.FC = () => {
const { t } = useLanguage();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [collection, setCollection] = useState<Collection | null>(null);
const [collectionVideos, setCollectionVideos] = useState<Video[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const { collections, deleteCollection } = useCollection();
const { videos, deleteVideo } = useVideo();
const [showDeleteModal, setShowDeleteModal] = useState<boolean>(false);
const [page, setPage] = useState(1);
const ITEMS_PER_PAGE = 12;
useEffect(() => {
if (collections && collections.length > 0 && id) {
const foundCollection = collections.find(c => c.id === id);
if (foundCollection) {
setCollection(foundCollection);
// Find all videos that are in this collection
const videosInCollection = videos.filter(video =>
foundCollection.videos.includes(video.id)
);
setCollectionVideos(videosInCollection);
} else {
// Collection not found, redirect to home
navigate('/');
}
}
setLoading(false);
setLoading(false);
}, [id, collections, videos, navigate]);
const collection = collections.find(c => c.id === id);
const collectionVideos = collection
? videos.filter(video => collection.videos.includes(video.id))
: [];
// Pagination logic
const totalPages = Math.ceil(collectionVideos.length / ITEMS_PER_PAGE);
@@ -80,8 +55,8 @@ const CollectionPage: React.FC<CollectionPageProps> = ({ collections, videos, on
const handleDeleteCollectionOnly = async () => {
if (!id) return;
const success = await onDeleteCollection(id, false);
if (success) {
const result = await deleteCollection(id, false);
if (result.success) {
navigate('/');
}
setShowDeleteModal(false);
@@ -89,26 +64,25 @@ const CollectionPage: React.FC<CollectionPageProps> = ({ collections, videos, on
const handleDeleteCollectionAndVideos = async () => {
if (!id) return;
const success = await onDeleteCollection(id, true);
if (success) {
const result = await deleteCollection(id, true);
if (result.success) {
navigate('/');
}
setShowDeleteModal(false);
};
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '50vh' }}>
<CircularProgress />
<Typography sx={{ ml: 2 }}>{t('loadingCollection')}</Typography>
</Box>
);
}
if (!collection) {
return (
<Container sx={{ mt: 4 }}>
<Alert severity="error">{t('collectionNotFound')}</Alert>
<Button
variant="outlined"
startIcon={<ArrowBack />}
onClick={handleBack}
sx={{ mt: 2 }}
>
{t('back')}
</Button>
</Container>
);
}
@@ -148,7 +122,7 @@ const CollectionPage: React.FC<CollectionPageProps> = ({ collections, videos, on
<VideoCard
video={video}
collections={collections}
onDeleteVideo={onDeleteVideo}
onDeleteVideo={deleteVideo}
showDeleteButton={true}
disableCollectionGrouping={true}
/>

View File

@@ -13,7 +13,6 @@ import {
LinearProgress,
List,
ListItem,
ListItemSecondaryAction,
ListItemText,
Paper,
Tab,
@@ -206,7 +205,14 @@ const DownloadPage: React.FC = () => {
<List>
{activeDownloads.map((download) => (
<Paper key={download.id} sx={{ mb: 2, p: 2 }}>
<ListItem disableGutters>
<ListItem
disableGutters
secondaryAction={
<IconButton edge="end" aria-label="cancel" onClick={() => handleCancelDownload(download.id)}>
<CancelIcon />
</IconButton>
}
>
<ListItemText
primary={download.title}
secondaryTypographyProps={{ component: 'div' }}
@@ -219,11 +225,6 @@ const DownloadPage: React.FC = () => {
</Box>
}
/>
<ListItemSecondaryAction>
<IconButton edge="end" aria-label="cancel" onClick={() => handleCancelDownload(download.id)}>
<CancelIcon />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
</Paper>
))}
@@ -249,16 +250,18 @@ const DownloadPage: React.FC = () => {
<List>
{queuedDownloads.map((download) => (
<Paper key={download.id} sx={{ mb: 2, p: 2 }}>
<ListItem disableGutters>
<ListItem
disableGutters
secondaryAction={
<IconButton edge="end" aria-label="remove" onClick={() => handleRemoveFromQueue(download.id)}>
<DeleteIcon />
</IconButton>
}
>
<ListItemText
primary={download.title}
secondary={t('queued') || 'Queued'}
/>
<ListItemSecondaryAction>
<IconButton edge="end" aria-label="remove" onClick={() => handleRemoveFromQueue(download.id)}>
<DeleteIcon />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
</Paper>
))}
@@ -284,7 +287,14 @@ const DownloadPage: React.FC = () => {
<List>
{history.map((item: DownloadHistoryItem) => (
<Paper key={item.id} sx={{ mb: 2, p: 2 }}>
<ListItem disableGutters>
<ListItem
disableGutters
secondaryAction={
<IconButton edge="end" aria-label="remove" onClick={() => handleRemoveFromHistory(item.id)}>
<DeleteIcon />
</IconButton>
}
>
<ListItemText
primary={item.title}
secondaryTypographyProps={{ component: 'div' }}
@@ -301,18 +311,13 @@ const DownloadPage: React.FC = () => {
</Box>
}
/>
<Box sx={{ mr: 2 }}>
<Box sx={{ mr: 8 }}>
{item.status === 'success' ? (
<Chip icon={<CheckCircleIcon />} label={t('success') || 'Success'} color="success" size="small" />
) : (
<Chip icon={<ErrorIcon />} label={t('failed') || 'Failed'} color="error" size="small" />
)}
</Box>
<ListItemSecondaryAction>
<IconButton edge="end" aria-label="remove" onClick={() => handleRemoveFromHistory(item.id)}>
<DeleteIcon />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
</Paper>
))}

View File

@@ -22,55 +22,34 @@ import CollectionCard from '../components/CollectionCard';
import Collections from '../components/Collections';
import TagsList from '../components/TagsList';
import VideoCard from '../components/VideoCard';
import { useCollection } from '../contexts/CollectionContext';
import { useDownload } from '../contexts/DownloadContext';
import { useLanguage } from '../contexts/LanguageContext';
import { useVideo } from '../contexts/VideoContext';
import { Collection, Video } from '../types';
interface SearchResult {
id: string;
title: string;
author: string;
thumbnailUrl: string;
duration?: number;
viewCount?: number;
source: 'youtube' | 'bilibili';
sourceUrl: string;
}
interface HomeProps {
videos: Video[];
loading: boolean;
error: string | null;
onDeleteVideo: (id: string) => Promise<any>;
collections: Collection[];
isSearchMode: boolean;
searchTerm: string;
localSearchResults: Video[];
youtubeLoading: boolean;
searchResults: SearchResult[];
onDownload: (url: string, title?: string) => void;
onResetSearch: () => void;
}
const Home: React.FC<HomeProps> = ({
videos = [],
loading,
error,
onDeleteVideo,
collections = [],
isSearchMode = false,
searchTerm = '',
localSearchResults = [],
youtubeLoading = false,
searchResults = [],
onDownload,
onResetSearch
}) => {
const Home: React.FC = () => {
const { t } = useLanguage();
const {
videos,
loading,
error,
deleteVideo,
availableTags,
selectedTags,
handleTagToggle,
isSearchMode,
searchTerm,
localSearchResults,
searchResults,
youtubeLoading,
setIsSearchMode,
resetSearch
} = useVideo();
const { collections } = useCollection();
const { handleVideoSubmit } = useDownload();
const [page, setPage] = useState(1);
const ITEMS_PER_PAGE = 12;
const { t } = useLanguage();
const { availableTags, selectedTags, handleTagToggle } = useVideo();
const [viewMode, setViewMode] = useState<'collections' | 'all-videos'>(() => {
const saved = localStorage.getItem('homeViewMode');
return (saved as 'collections' | 'all-videos') || 'collections';
@@ -81,6 +60,14 @@ const Home: React.FC<HomeProps> = ({
setPage(1);
}, [videos, collections, selectedTags]);
const handleDownload = async (url: string) => {
try {
setIsSearchMode(false);
await handleVideoSubmit(url);
} catch (error) {
console.error('Error downloading from search:', error);
}
};
// Add default empty array to ensure videos is always an array
const videoArray = Array.isArray(videos) ? videos : [];
@@ -139,8 +126,6 @@ const Home: React.FC<HomeProps> = ({
});
});
const handleViewModeChange = (mode: 'collections' | 'all-videos') => {
setViewMode(mode);
localStorage.setItem('homeViewMode', mode);
@@ -159,8 +144,6 @@ const Home: React.FC<HomeProps> = ({
window.scrollTo({ top: 0, behavior: 'smooth' });
};
// Helper function to format duration in seconds to MM:SS
const formatDuration = (seconds?: number) => {
if (!seconds) return '';
@@ -188,11 +171,11 @@ const Home: React.FC<HomeProps> = ({
<Typography variant="h4" component="h1" fontWeight="bold">
{t('searchResultsFor')} "{searchTerm}"
</Typography>
{onResetSearch && (
{resetSearch && (
<Button
variant="outlined"
startIcon={<ArrowBack />}
onClick={onResetSearch}
onClick={resetSearch}
>
{t('backToHome')}
</Button>
@@ -211,7 +194,7 @@ const Home: React.FC<HomeProps> = ({
<VideoCard
video={video}
collections={collections}
onDeleteVideo={onDeleteVideo}
onDeleteVideo={deleteVideo}
showDeleteButton={true}
/>
</Grid>
@@ -279,7 +262,7 @@ const Home: React.FC<HomeProps> = ({
fullWidth
variant="contained"
startIcon={<Download />}
onClick={() => onDownload(result.sourceUrl, result.title)}
onClick={() => handleDownload(result.sourceUrl)}
>
{t('download')}
</Button>

View File

@@ -13,19 +13,17 @@ import {
import { useMutation } from '@tanstack/react-query';
import axios from 'axios';
import React, { useState } from 'react';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
import getTheme from '../theme';
const API_URL = import.meta.env.VITE_API_URL;
interface LoginPageProps {
onLoginSuccess: () => void;
}
const LoginPage: React.FC<LoginPageProps> = ({ onLoginSuccess }) => {
const LoginPage: React.FC = () => {
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const { t } = useLanguage();
const { login } = useAuth();
// Use dark theme for login page to match app style
const theme = getTheme('dark');
@@ -37,7 +35,7 @@ const LoginPage: React.FC<LoginPageProps> = ({ onLoginSuccess }) => {
},
onSuccess: (data) => {
if (data.success) {
onLoginSuccess();
login();
} else {
setError(t('incorrectPassword'));
}

View File

@@ -33,23 +33,18 @@ import { useState } from 'react';
import { Link } from 'react-router-dom';
import ConfirmationModal from '../components/ConfirmationModal';
import DeleteCollectionModal from '../components/DeleteCollectionModal';
import { useCollection } from '../contexts/CollectionContext';
import { useLanguage } from '../contexts/LanguageContext';
import { useVideo } from '../contexts/VideoContext';
import { Collection, Video } from '../types';
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL;
interface ManagePageProps {
videos: Video[];
onDeleteVideo: (id: string) => Promise<any>;
collections: Collection[];
onDeleteCollection: (id: string, deleteVideos: boolean) => Promise<any>;
}
const ManagePage: React.FC<ManagePageProps> = ({ videos, onDeleteVideo, collections = [], onDeleteCollection }) => {
const ManagePage: React.FC = () => {
const [searchTerm, setSearchTerm] = useState<string>('');
const { t } = useLanguage();
const { refreshThumbnail, updateVideo } = useVideo();
const { videos, deleteVideo, refreshThumbnail, updateVideo } = useVideo();
const { collections, deleteCollection } = useCollection();
const [deletingId, setDeletingId] = useState<string | null>(null);
const [refreshingId, setRefreshingId] = useState<string | null>(null);
const [collectionToDelete, setCollectionToDelete] = useState<Collection | null>(null);
@@ -98,7 +93,7 @@ const ManagePage: React.FC<ManagePageProps> = ({ videos, onDeleteVideo, collecti
if (!videoToDelete) return;
setDeletingId(videoToDelete);
await onDeleteVideo(videoToDelete);
await deleteVideo(videoToDelete);
setDeletingId(null);
setVideoToDelete(null);
setShowVideoDeleteModal(false); // Close the modal after deletion
@@ -116,7 +111,7 @@ const ManagePage: React.FC<ManagePageProps> = ({ videos, onDeleteVideo, collecti
const handleCollectionDeleteOnly = async () => {
if (!collectionToDelete) return;
setIsDeletingCollection(true);
await onDeleteCollection(collectionToDelete.id, false);
await deleteCollection(collectionToDelete.id, false);
setIsDeletingCollection(false);
setCollectionToDelete(null);
};
@@ -124,7 +119,7 @@ const ManagePage: React.FC<ManagePageProps> = ({ videos, onDeleteVideo, collecti
const handleCollectionDeleteAll = async () => {
if (!collectionToDelete) return;
setIsDeletingCollection(true);
await onDeleteCollection(collectionToDelete.id, true);
await deleteCollection(collectionToDelete.id, true);
setIsDeletingCollection(false);
setCollectionToDelete(null);
};

View File

@@ -16,56 +16,45 @@ import {
import React, { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import VideoCard from '../components/VideoCard';
import { Collection, Video } from '../types';
import { useCollection } from '../contexts/CollectionContext';
import { useDownload } from '../contexts/DownloadContext';
import { useVideo } from '../contexts/VideoContext';
interface SearchResult {
id: string;
title: string;
author: string;
thumbnailUrl: string;
duration?: number;
viewCount?: number;
source: 'youtube' | 'bilibili';
sourceUrl: string;
}
interface SearchResultsProps {
results: SearchResult[];
localResults: Video[];
searchTerm: string;
loading: boolean;
youtubeLoading: boolean;
onDownload: (url: string, title?: string) => void;
onDeleteVideo: (id: string) => Promise<any>;
onResetSearch: () => void;
collections: Collection[];
}
const SearchResults: React.FC<SearchResultsProps> = ({
results,
localResults,
searchTerm,
loading,
youtubeLoading,
onDownload,
onDeleteVideo,
onResetSearch,
collections = []
}) => {
const SearchResults: React.FC = () => {
const navigate = useNavigate();
const {
searchResults,
localSearchResults,
searchTerm,
loading,
youtubeLoading,
deleteVideo,
resetSearch,
setIsSearchMode
} = useVideo();
const { collections } = useCollection();
const { handleVideoSubmit } = useDownload();
// If search term is empty, reset search and go back to home
useEffect(() => {
if (!searchTerm || searchTerm.trim() === '') {
if (onResetSearch) {
onResetSearch();
if (resetSearch) {
resetSearch();
}
}
}, [searchTerm, onResetSearch]);
}, [searchTerm, resetSearch]);
const handleDownload = async (videoUrl: string, title: string) => {
const handleDownload = async (videoUrl: string) => {
try {
await onDownload(videoUrl, title);
// We need to stop the search mode before downloading?
// Actually App.tsx implementation was:
// setIsSearchMode(false);
// await handleVideoSubmit(videoUrl);
// Let's replicate that behavior if we want to exit search on download
// Or maybe just download and stay on search results?
// The original implementation in App.tsx exited search mode.
setIsSearchMode(false);
await handleVideoSubmit(videoUrl);
} catch (error) {
console.error('Error downloading from search results:', error);
}
@@ -73,8 +62,8 @@ const SearchResults: React.FC<SearchResultsProps> = ({
const handleBackClick = () => {
// Call the onResetSearch function to reset search mode
if (onResetSearch) {
onResetSearch();
if (resetSearch) {
resetSearch();
} else {
// Fallback to navigate if onResetSearch is not provided
navigate('/');
@@ -96,8 +85,8 @@ const SearchResults: React.FC<SearchResultsProps> = ({
);
}
const hasLocalResults = localResults && localResults.length > 0;
const hasYouTubeResults = results && results.length > 0;
const hasLocalResults = localSearchResults && localSearchResults.length > 0;
const hasYouTubeResults = searchResults && searchResults.length > 0;
const noResults = !hasLocalResults && !hasYouTubeResults && !youtubeLoading;
// Helper function to format duration in seconds to MM:SS
@@ -158,11 +147,11 @@ const SearchResults: React.FC<SearchResultsProps> = ({
</Typography>
{hasLocalResults ? (
<Grid container spacing={3}>
{localResults.map((video) => <Grid size={{ xs: 12, sm: 6, md: 4, lg: 3 }} key={video.id}>
{localSearchResults.map((video) => <Grid size={{ xs: 12, sm: 6, md: 4, lg: 3 }} key={video.id}>
<VideoCard
video={video}
collections={collections}
onDeleteVideo={onDeleteVideo}
onDeleteVideo={deleteVideo}
showDeleteButton={true}
/>
</Grid>
@@ -186,7 +175,7 @@ const SearchResults: React.FC<SearchResultsProps> = ({
</Box>
) : hasYouTubeResults ? (
<Grid container spacing={3}>
{results.map((result) => <Grid size={{ xs: 12, sm: 6, md: 4, lg: 3 }} key={result.id}>
{searchResults.map((result) => <Grid size={{ xs: 12, sm: 6, md: 4, lg: 3 }} key={result.id}>
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<Box sx={{ position: 'relative', paddingTop: '56.25%' }}>
<CardMedia
@@ -229,7 +218,7 @@ const SearchResults: React.FC<SearchResultsProps> = ({
fullWidth
variant="contained"
startIcon={<Download />}
onClick={() => handleDownload(result.sourceUrl, result.title)}
onClick={() => handleDownload(result.sourceUrl)}
>
Download
</Button>

View File

@@ -20,36 +20,30 @@ import CollectionModal from '../components/VideoPlayer/CollectionModal';
import CommentsSection from '../components/VideoPlayer/CommentsSection';
import VideoControls from '../components/VideoPlayer/VideoControls';
import VideoInfo from '../components/VideoPlayer/VideoInfo';
import { useCollection } from '../contexts/CollectionContext';
import { useLanguage } from '../contexts/LanguageContext';
import { useSnackbar } from '../contexts/SnackbarContext';
import { useVideo } from '../contexts/VideoContext';
import { Collection, Video } from '../types';
const API_URL = import.meta.env.VITE_API_URL;
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL;
interface VideoPlayerProps {
videos: Video[];
onDeleteVideo: (id: string) => Promise<{ success: boolean; error?: string }>;
collections: Collection[];
onAddToCollection: (collectionId: string, videoId: string) => Promise<void>;
onCreateCollection: (name: string, videoId: string) => Promise<void>;
onRemoveFromCollection: (videoId: string) => Promise<any>;
}
const VideoPlayer: React.FC<VideoPlayerProps> = ({
videos,
onDeleteVideo,
collections,
onAddToCollection,
onCreateCollection,
onRemoveFromCollection
}) => {
const VideoPlayer: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { t } = useLanguage();
const { showSnackbar } = useSnackbar();
const queryClient = useQueryClient();
const { videos, deleteVideo } = useVideo();
const {
collections,
addToCollection,
createCollection,
removeFromCollection
} = useCollection();
const [showCollectionModal, setShowCollectionModal] = useState<boolean>(false);
const [videoCollections, setVideoCollections] = useState<Collection[]>([]);
const [showComments, setShowComments] = useState<boolean>(false);
@@ -155,7 +149,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
// Delete mutation
const deleteMutation = useMutation({
mutationFn: async (videoId: string) => {
return await onDeleteVideo(videoId);
return await deleteVideo(videoId);
},
onSuccess: (result) => {
if (result.success) {
@@ -192,7 +186,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
const handleCreateCollection = async (name: string) => {
if (!id) return;
try {
await onCreateCollection(name, id);
await createCollection(name, id);
} catch (error) {
console.error('Error creating collection:', error);
}
@@ -201,7 +195,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
const handleAddToExistingCollection = async (collectionId: string) => {
if (!id) return;
try {
await onAddToCollection(collectionId, id);
await addToCollection(collectionId, id);
} catch (error) {
console.error('Error adding to collection:', error);
}
@@ -211,7 +205,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
if (!id) return;
try {
await onRemoveFromCollection(id);
await removeFromCollection(id);
} catch (error) {
console.error('Error removing from collection:', error);
}