feat: refactor with Tanstack Query

This commit is contained in:
Peifan Li
2025-11-26 22:05:36 -05:00
parent 1fbec80917
commit 350cacb1f0
14 changed files with 724 additions and 726 deletions

View File

@@ -12,6 +12,8 @@
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.5", "@mui/icons-material": "^7.3.5",
"@mui/material": "^7.3.5", "@mui/material": "^7.3.5",
"@tanstack/react-query": "^5.90.11",
"@tanstack/react-query-devtools": "^5.91.1",
"axios": "^1.8.1", "axios": "^1.8.1",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"framer-motion": "^12.23.24", "framer-motion": "^12.23.24",
@@ -1669,6 +1671,59 @@
"win32" "win32"
] ]
}, },
"node_modules/@tanstack/query-core": {
"version": "5.90.11",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.11.tgz",
"integrity": "sha512-f9z/nXhCgWDF4lHqgIE30jxLe4sYv15QodfdPDKYAk7nAEjNcndy4dHz3ezhdUaR23BpWa4I2EH4/DZ0//Uf8A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/query-devtools": {
"version": "5.91.1",
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.91.1.tgz",
"integrity": "sha512-l8bxjk6BMsCaVQH6NzQEE/bEgFy1hAs5qbgXl0xhzezlaQbPk6Mgz9BqEg2vTLPOHD8N4k+w/gdgCbEzecGyNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.90.11",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.11.tgz",
"integrity": "sha512-3uyzz01D1fkTLXuxF3JfoJoHQMU2fxsfJwE+6N5hHy0dVNoZOvwKP8Z2k7k1KDeD54N20apcJnG75TBAStIrBA==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.90.11"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-query-devtools": {
"version": "5.91.1",
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.91.1.tgz",
"integrity": "sha512-tRnJYwEbH0kAOuToy8Ew7bJw1lX3AjkkgSlf/vzb+NpnqmHPdWM+lA2DSdGQSLi1SU0PDRrrCI1vnZnci96CsQ==",
"license": "MIT",
"dependencies": {
"@tanstack/query-devtools": "5.91.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"@tanstack/react-query": "^5.90.10",
"react": "^18 || ^19"
}
},
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",

View File

@@ -14,6 +14,8 @@
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.5", "@mui/icons-material": "^7.3.5",
"@mui/material": "^7.3.5", "@mui/material": "^7.3.5",
"@tanstack/react-query": "^5.90.11",
"@tanstack/react-query-devtools": "^5.91.1",
"axios": "^1.8.1", "axios": "^1.8.1",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"framer-motion": "^12.23.24", "framer-motion": "^12.23.24",

View File

@@ -194,19 +194,25 @@ function AppContent() {
); );
} }
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient();
function App() { function App() {
return ( return (
<LanguageProvider> <QueryClientProvider client={queryClient}>
<SnackbarProvider> <LanguageProvider>
<VideoProvider> <SnackbarProvider>
<CollectionProvider> <VideoProvider>
<DownloadProvider> <CollectionProvider>
<AppContent /> <DownloadProvider>
</DownloadProvider> <AppContent />
</CollectionProvider> </DownloadProvider>
</VideoProvider> </CollectionProvider>
</SnackbarProvider> </VideoProvider>
</LanguageProvider> </SnackbarProvider>
</LanguageProvider>
</QueryClientProvider>
); );
} }

View File

@@ -93,7 +93,7 @@ const Header: React.FC<HeaderProps> = ({
const { availableTags, selectedTags, handleTagToggle } = useVideo(); const { availableTags, selectedTags, handleTagToggle } = useVideo();
const isDownloading = activeDownloads.length > 0 || queuedDownloads.length > 0;
useEffect(() => { useEffect(() => {
console.log('Header props:', { activeDownloads, queuedDownloads }); console.log('Header props:', { activeDownloads, queuedDownloads });

View File

@@ -12,6 +12,7 @@ import {
TextField, TextField,
Typography Typography
} from '@mui/material'; } from '@mui/material';
import { useMutation } from '@tanstack/react-query';
import axios from 'axios'; import axios from 'axios';
import { useState } from 'react'; import { useState } from 'react';
import { useLanguage } from '../contexts/LanguageContext'; import { useLanguage } from '../contexts/LanguageContext';
@@ -29,7 +30,6 @@ const UploadModal: React.FC<UploadModalProps> = ({ open, onClose, onUploadSucces
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [title, setTitle] = useState<string>(''); const [title, setTitle] = useState<string>('');
const [author, setAuthor] = useState<string>('Admin'); const [author, setAuthor] = useState<string>('Admin');
const [uploading, setUploading] = useState<boolean>(false);
const [progress, setProgress] = useState<number>(0); const [progress, setProgress] = useState<number>(0);
const [error, setError] = useState<string>(''); const [error, setError] = useState<string>('');
@@ -43,22 +43,8 @@ const UploadModal: React.FC<UploadModalProps> = ({ open, onClose, onUploadSucces
} }
}; };
const handleUpload = async () => { const uploadMutation = useMutation({
if (!file) { mutationFn: async (formData: FormData) => {
setError(t('pleaseSelectVideo'));
return;
}
setUploading(true);
setError('');
setProgress(0);
const formData = new FormData();
formData.append('video', file);
formData.append('title', title);
formData.append('author', author);
try {
await axios.post(`${API_URL}/upload`, formData, { await axios.post(`${API_URL}/upload`, formData, {
headers: { headers: {
'Content-Type': 'multipart/form-data', 'Content-Type': 'multipart/form-data',
@@ -68,15 +54,32 @@ const UploadModal: React.FC<UploadModalProps> = ({ open, onClose, onUploadSucces
setProgress(percentCompleted); setProgress(percentCompleted);
}, },
}); });
},
onSuccess: () => {
onUploadSuccess(); onUploadSuccess();
handleClose(); handleClose();
} catch (err: any) { },
onError: (err: any) => {
console.error('Upload failed:', err); console.error('Upload failed:', err);
setError(err.response?.data?.error || t('failedToUpload')); setError(err.response?.data?.error || t('failedToUpload'));
} finally {
setUploading(false);
} }
});
const handleUpload = () => {
if (!file) {
setError(t('pleaseSelectVideo'));
return;
}
setError('');
setProgress(0);
const formData = new FormData();
formData.append('video', file);
formData.append('title', title);
formData.append('author', author);
uploadMutation.mutate(formData);
}; };
const handleClose = () => { const handleClose = () => {
@@ -89,7 +92,7 @@ const UploadModal: React.FC<UploadModalProps> = ({ open, onClose, onUploadSucces
}; };
return ( return (
<Dialog open={open} onClose={!uploading ? handleClose : undefined} maxWidth="sm" fullWidth> <Dialog open={open} onClose={!uploadMutation.isPending ? handleClose : undefined} maxWidth="sm" fullWidth>
<DialogTitle>{t('uploadVideo')}</DialogTitle> <DialogTitle>{t('uploadVideo')}</DialogTitle>
<DialogContent> <DialogContent>
<Stack spacing={3} sx={{ mt: 1 }}> <Stack spacing={3} sx={{ mt: 1 }}>
@@ -114,7 +117,7 @@ const UploadModal: React.FC<UploadModalProps> = ({ open, onClose, onUploadSucces
fullWidth fullWidth
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
disabled={uploading} disabled={uploadMutation.isPending}
/> />
<TextField <TextField
@@ -122,7 +125,7 @@ const UploadModal: React.FC<UploadModalProps> = ({ open, onClose, onUploadSucces
fullWidth fullWidth
value={author} value={author}
onChange={(e) => setAuthor(e.target.value)} onChange={(e) => setAuthor(e.target.value)}
disabled={uploading} disabled={uploadMutation.isPending}
/> />
{error && ( {error && (
@@ -131,7 +134,7 @@ const UploadModal: React.FC<UploadModalProps> = ({ open, onClose, onUploadSucces
</Typography> </Typography>
)} )}
{uploading && ( {uploadMutation.isPending && (
<Box sx={{ width: '100%' }}> <Box sx={{ width: '100%' }}>
<LinearProgress variant="determinate" value={progress} /> <LinearProgress variant="determinate" value={progress} />
<Typography variant="caption" color="text.secondary" align="center" display="block" sx={{ mt: 1 }}> <Typography variant="caption" color="text.secondary" align="center" display="block" sx={{ mt: 1 }}>
@@ -142,13 +145,13 @@ const UploadModal: React.FC<UploadModalProps> = ({ open, onClose, onUploadSucces
</Stack> </Stack>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={handleClose} disabled={uploading}>{t('cancel')}</Button> <Button onClick={handleClose} disabled={uploadMutation.isPending}>{t('cancel')}</Button>
<Button <Button
onClick={handleUpload} onClick={handleUpload}
variant="contained" variant="contained"
disabled={!file || uploading} disabled={!file || uploadMutation.isPending}
> >
{uploading ? <CircularProgress size={24} /> : t('upload')} {uploadMutation.isPending ? <CircularProgress size={24} /> : t('upload')}
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>

View File

@@ -1,8 +1,8 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import axios from 'axios'; import axios from 'axios';
import React, { createContext, useContext, useEffect, useState } from 'react'; import React, { createContext, useContext } from 'react';
import { Collection } from '../types'; import { Collection } from '../types';
import { useSnackbar } from './SnackbarContext'; import { useSnackbar } from './SnackbarContext';
import { useVideo } from './VideoContext';
const API_URL = import.meta.env.VITE_API_URL; const API_URL = import.meta.env.VITE_API_URL;
@@ -27,46 +27,66 @@ export const useCollection = () => {
export const CollectionProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { export const CollectionProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { showSnackbar } = useSnackbar(); const { showSnackbar } = useSnackbar();
const { fetchVideos } = useVideo(); const queryClient = useQueryClient();
const [collections, setCollections] = useState<Collection[]>([]);
const { data: collections = [], refetch: fetchCollectionsQuery } = useQuery({
queryKey: ['collections'],
queryFn: async () => {
const response = await axios.get(`${API_URL}/collections`);
return response.data as Collection[];
}
});
const fetchCollections = async () => { const fetchCollections = async () => {
try { await fetchCollectionsQuery();
const response = await axios.get(`${API_URL}/collections`);
setCollections(response.data);
} catch (error) {
console.error('Error fetching collections:', error);
}
}; };
const createCollection = async (name: string, videoId: string) => { const createCollectionMutation = useMutation({
try { mutationFn: async ({ name, videoId }: { name: string, videoId: string }) => {
const response = await axios.post(`${API_URL}/collections`, { const response = await axios.post(`${API_URL}/collections`, {
name, name,
videoId videoId
}); });
setCollections(prevCollections => [...prevCollections, response.data]);
showSnackbar('Collection created successfully');
return response.data; return response.data;
} catch (error) { },
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['collections'] });
showSnackbar('Collection created successfully');
},
onError: (error) => {
console.error('Error creating collection:', error); console.error('Error creating collection:', error);
}
});
const createCollection = async (name: string, videoId: string) => {
try {
return await createCollectionMutation.mutateAsync({ name, videoId });
} catch {
return null; return null;
} }
}; };
const addToCollection = async (collectionId: string, videoId: string) => { const addToCollectionMutation = useMutation({
try { mutationFn: async ({ collectionId, videoId }: { collectionId: string, videoId: string }) => {
const response = await axios.put(`${API_URL}/collections/${collectionId}`, { const response = await axios.put(`${API_URL}/collections/${collectionId}`, {
videoId, videoId,
action: "add" action: "add"
}); });
setCollections(prevCollections => prevCollections.map(collection =>
collection.id === collectionId ? response.data : collection
));
showSnackbar('Video added to collection');
return response.data; return response.data;
} catch (error) { },
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['collections'] });
showSnackbar('Video added to collection');
},
onError: (error) => {
console.error('Error adding video to collection:', error); console.error('Error adding video to collection:', error);
}
});
const addToCollection = async (collectionId: string, videoId: string) => {
try {
return await addToCollectionMutation.mutateAsync({ collectionId, videoId });
} catch {
return null; return null;
} }
}; };
@@ -77,18 +97,14 @@ export const CollectionProvider: React.FC<{ children: React.ReactNode }> = ({ ch
collection.videos.includes(videoId) collection.videos.includes(videoId)
); );
for (const collection of collectionsWithVideo) { await Promise.all(collectionsWithVideo.map(collection =>
await axios.put(`${API_URL}/collections/${collection.id}`, { axios.put(`${API_URL}/collections/${collection.id}`, {
videoId, videoId,
action: "remove" action: "remove"
}); })
} ));
setCollections(prevCollections => prevCollections.map(collection => ({
...collection,
videos: collection.videos.filter(v => v !== videoId)
})));
queryClient.invalidateQueries({ queryKey: ['collections'] });
showSnackbar('Video removed from collection'); showSnackbar('Video removed from collection');
return true; return true;
} catch (error) { } catch (error) {
@@ -97,34 +113,35 @@ export const CollectionProvider: React.FC<{ children: React.ReactNode }> = ({ ch
} }
}; };
const deleteCollection = async (collectionId: string, deleteVideos = false) => { const deleteCollectionMutation = useMutation({
try { mutationFn: async ({ collectionId, deleteVideos }: { collectionId: string, deleteVideos: boolean }) => {
await axios.delete(`${API_URL}/collections/${collectionId}`, { await axios.delete(`${API_URL}/collections/${collectionId}`, {
params: { deleteVideos: deleteVideos ? 'true' : 'false' } params: { deleteVideos: deleteVideos ? 'true' : 'false' }
}); });
return { collectionId, deleteVideos };
setCollections(prevCollections => },
prevCollections.filter(collection => collection.id !== collectionId) onSuccess: ({ deleteVideos }) => {
); queryClient.invalidateQueries({ queryKey: ['collections'] });
if (deleteVideos) { if (deleteVideos) {
await fetchVideos(); queryClient.invalidateQueries({ queryKey: ['videos'] });
} }
showSnackbar('Collection deleted successfully'); showSnackbar('Collection deleted successfully');
return { success: true }; },
} catch (error) { onError: (error) => {
console.error('Error deleting collection:', error); console.error('Error deleting collection:', error);
showSnackbar('Failed to delete collection', 'error'); showSnackbar('Failed to delete collection', 'error');
}
});
const deleteCollection = async (collectionId: string, deleteVideos = false) => {
try {
await deleteCollectionMutation.mutateAsync({ collectionId, deleteVideos });
return { success: true };
} catch {
return { success: false, error: 'Failed to delete collection' }; return { success: false, error: 'Failed to delete collection' };
} }
}; };
// Fetch collections on mount
useEffect(() => {
fetchCollections();
}, []);
return ( return (
<CollectionContext.Provider value={{ <CollectionContext.Provider value={{
collections, collections,

View File

@@ -1,3 +1,4 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import axios from 'axios'; import axios from 'axios';
import React, { createContext, useContext, useEffect, useRef, useState } from 'react'; import React, { createContext, useContext, useEffect, useRef, useState } from 'react';
import { DownloadInfo } from '../types'; import { DownloadInfo } from '../types';
@@ -65,15 +66,23 @@ export const DownloadProvider: React.FC<{ children: React.ReactNode }> = ({ chil
const { showSnackbar } = useSnackbar(); const { showSnackbar } = useSnackbar();
const { fetchVideos, handleSearch, setVideos } = useVideo(); const { fetchVideos, handleSearch, setVideos } = useVideo();
const { fetchCollections } = useCollection(); const { fetchCollections } = useCollection();
const queryClient = useQueryClient();
// Get initial download status from localStorage // Get initial download status from localStorage
const initialStatus = getStoredDownloadStatus(); const initialStatus = getStoredDownloadStatus();
const [activeDownloads, setActiveDownloads] = useState<DownloadInfo[]>(
initialStatus ? initialStatus.activeDownloads || [] : [] const { data: downloadStatus } = useQuery({
); queryKey: ['downloadStatus'],
const [queuedDownloads, setQueuedDownloads] = useState<DownloadInfo[]>( queryFn: async () => {
initialStatus ? initialStatus.queuedDownloads || [] : [] const response = await axios.get(`${API_URL}/download-status`);
); return response.data;
},
refetchInterval: 2000,
initialData: initialStatus || { activeDownloads: [], queuedDownloads: [] }
});
const activeDownloads = downloadStatus.activeDownloads || [];
const queuedDownloads = downloadStatus.queuedDownloads || [];
// Bilibili multi-part video state // Bilibili multi-part video state
const [showBilibiliPartsModal, setShowBilibiliPartsModal] = useState<boolean>(false); const [showBilibiliPartsModal, setShowBilibiliPartsModal] = useState<boolean>(false);
@@ -89,67 +98,43 @@ export const DownloadProvider: React.FC<{ children: React.ReactNode }> = ({ chil
// Reference to track current download IDs for detecting completion // Reference to track current download IDs for detecting completion
const currentDownloadIdsRef = useRef<Set<string>>(new Set()); const currentDownloadIdsRef = useRef<Set<string>>(new Set());
// Add a function to check download status from the backend useEffect(() => {
const checkBackendDownloadStatus = async () => { const newIds = new Set<string>([
try { ...activeDownloads.map((d: DownloadInfo) => d.id),
const response = await axios.get(`${API_URL}/download-status`); ...queuedDownloads.map((d: DownloadInfo) => d.id)
const { activeDownloads: backendActive, queuedDownloads: backendQueued } = response.data; ]);
const newActive = backendActive || []; let hasCompleted = false;
const newQueued = backendQueued || []; if (currentDownloadIdsRef.current.size > 0) {
for (const id of currentDownloadIdsRef.current) {
// Create a set of all current download IDs from the backend if (!newIds.has(id)) {
const newIds = new Set<string>([ hasCompleted = true;
...newActive.map((d: DownloadInfo) => d.id), break;
...newQueued.map((d: DownloadInfo) => d.id)
]);
// Check if any ID from the previous check is missing in the new check
// This implies it finished (or failed), so we should refresh the video list
let hasCompleted = false;
if (currentDownloadIdsRef.current.size > 0) {
for (const id of currentDownloadIdsRef.current) {
if (!newIds.has(id)) {
hasCompleted = true;
break;
}
} }
} }
// Update the ref for the next check
currentDownloadIdsRef.current = newIds;
if (hasCompleted) {
console.log('Download completed, refreshing videos');
fetchVideos();
}
if (newActive.length > 0 || newQueued.length > 0) {
// If backend has active or queued downloads, update the local status
setActiveDownloads(newActive);
setQueuedDownloads(newQueued);
// Save to localStorage for persistence
const statusData = {
activeDownloads: newActive,
queuedDownloads: newQueued,
timestamp: Date.now()
};
localStorage.setItem(DOWNLOAD_STATUS_KEY, JSON.stringify(statusData));
} else {
// If backend says no downloads are in progress, clear the status
if (activeDownloads.length > 0 || queuedDownloads.length > 0) {
console.log('Backend says downloads are complete, clearing status');
localStorage.removeItem(DOWNLOAD_STATUS_KEY);
setActiveDownloads([]);
setQueuedDownloads([]);
// Refresh videos list when downloads complete (fallback)
fetchVideos();
}
}
} catch (error) {
console.error('Error checking backend download status:', error);
} }
currentDownloadIdsRef.current = newIds;
if (hasCompleted) {
console.log('Download completed, refreshing videos');
fetchVideos();
}
if (activeDownloads.length > 0 || queuedDownloads.length > 0) {
const statusData = {
activeDownloads,
queuedDownloads,
timestamp: Date.now()
};
localStorage.setItem(DOWNLOAD_STATUS_KEY, JSON.stringify(statusData));
} else {
localStorage.removeItem(DOWNLOAD_STATUS_KEY);
}
}, [activeDownloads, queuedDownloads, fetchVideos]);
const checkBackendDownloadStatus = async () => {
await queryClient.invalidateQueries({ queryKey: ['downloadStatus'] });
}; };
const handleVideoSubmit = async (videoUrl: string, skipCollectionCheck = false): Promise<any> => { const handleVideoSubmit = async (videoUrl: string, skipCollectionCheck = false): Promise<any> => {
@@ -211,9 +196,6 @@ export const DownloadProvider: React.FC<{ children: React.ReactNode }> = ({ chil
} }
// Normal download flow // Normal download flow
// We don't set activeDownloads here immediately because the backend will queue it
// and we'll pick it up via polling
const response = await axios.post(`${API_URL}/download`, { youtubeUrl: videoUrl }); const response = await axios.post(`${API_URL}/download`, { youtubeUrl: videoUrl });
// If the response contains a downloadId, it means it was queued/started // If the response contains a downloadId, it means it was queued/started
@@ -225,16 +207,6 @@ export const DownloadProvider: React.FC<{ children: React.ReactNode }> = ({ chil
setVideos(prevVideos => [response.data.video, ...prevVideos]); setVideos(prevVideos => [response.data.video, ...prevVideos]);
} }
// Use the setIsSearchMode from VideoContext but we need to expose it there first
// For now, we can assume the caller handles UI state or we add it to VideoContext
// Actually, let's just use the resetSearch from VideoContext which handles search mode
// But wait, resetSearch clears everything. We just want to exit search mode.
// Let's update VideoContext to expose setIsSearchMode or handle it better.
// For now, let's assume VideoContext handles it via resetSearch if needed, or we just ignore it here
// and let the component handle UI.
// Wait, the original code called setIsSearchMode(false).
// I should add setIsSearchMode to VideoContext interface.
showSnackbar('Video downloading'); showSnackbar('Video downloading');
return { success: true }; return { success: true };
} catch (err: any) { } catch (err: any) {
@@ -293,69 +265,6 @@ export const DownloadProvider: React.FC<{ children: React.ReactNode }> = ({ chil
return await handleVideoSubmit(bilibiliPartsInfo.url, true); return await handleVideoSubmit(bilibiliPartsInfo.url, true);
}; };
// Check backend download status periodically
useEffect(() => {
// Check immediately on mount
checkBackendDownloadStatus();
// Then check every 2 seconds (faster polling for better UX)
const statusCheckInterval = setInterval(checkBackendDownloadStatus, 2000);
return () => {
clearInterval(statusCheckInterval);
};
}, [activeDownloads.length, queuedDownloads.length]);
// Set up localStorage and event listeners
useEffect(() => {
// Set up event listener for storage changes (for multi-tab support)
const handleStorageChange = (e: StorageEvent) => {
if (e.key === DOWNLOAD_STATUS_KEY) {
try {
const newStatus = e.newValue ? JSON.parse(e.newValue) : { activeDownloads: [], queuedDownloads: [] };
setActiveDownloads(newStatus.activeDownloads || []);
setQueuedDownloads(newStatus.queuedDownloads || []);
} catch (error) {
console.error('Error handling storage change:', error);
}
}
};
window.addEventListener('storage', handleStorageChange);
// Set up periodic check for stale download status
const checkDownloadStatus = () => {
const status = getStoredDownloadStatus();
if (!status && (activeDownloads.length > 0 || queuedDownloads.length > 0)) {
console.log('Clearing stale download status');
setActiveDownloads([]);
setQueuedDownloads([]);
}
};
// Check every minute
const statusCheckInterval = setInterval(checkDownloadStatus, 60000);
return () => {
window.removeEventListener('storage', handleStorageChange);
clearInterval(statusCheckInterval);
};
}, [activeDownloads, queuedDownloads]);
// Update localStorage whenever activeDownloads or queuedDownloads changes
useEffect(() => {
if (activeDownloads.length > 0 || queuedDownloads.length > 0) {
const statusData = {
activeDownloads,
queuedDownloads,
timestamp: Date.now()
};
localStorage.setItem(DOWNLOAD_STATUS_KEY, JSON.stringify(statusData));
} else {
localStorage.removeItem(DOWNLOAD_STATUS_KEY);
}
}, [activeDownloads, queuedDownloads]);
return ( return (
<DownloadContext.Provider value={{ <DownloadContext.Provider value={{
activeDownloads, activeDownloads,

View File

@@ -1,3 +1,4 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import axios from 'axios'; import axios from 'axios';
import React, { createContext, useContext, useEffect, useRef, useState } from 'react'; import React, { createContext, useContext, useEffect, useRef, useState } from 'react';
import { Video } from '../types'; import { Video } from '../types';
@@ -42,9 +43,27 @@ export const useVideo = () => {
export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { showSnackbar } = useSnackbar(); const { showSnackbar } = useSnackbar();
const { t } = useLanguage(); const { t } = useLanguage();
const [videos, setVideos] = useState<Video[]>([]); const queryClient = useQueryClient();
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); // Videos Query
const { data: videos = [], isLoading: videosLoading, error: videosError, refetch: refetchVideos } = useQuery({
queryKey: ['videos'],
queryFn: async () => {
const response = await axios.get(`${API_URL}/videos`);
return response.data as Video[];
},
});
// Tags Query
const { data: availableTags = [] } = useQuery({
queryKey: ['tags'],
queryFn: async () => {
const response = await axios.get(`${API_URL}/settings`);
return response.data.tags || [];
},
});
const [selectedTags, setSelectedTags] = useState<string[]>([]);
// Search state // Search state
const [searchResults, setSearchResults] = useState<any[]>([]); const [searchResults, setSearchResults] = useState<any[]>([]);
@@ -56,31 +75,43 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
// Reference to the current search request's abort controller // Reference to the current search request's abort controller
const searchAbortController = useRef<AbortController | null>(null); const searchAbortController = useRef<AbortController | null>(null);
// Wrapper for refetch to match interface
const fetchVideos = async () => { const fetchVideos = async () => {
try { await refetchVideos();
setLoading(true);
const response = await axios.get(`${API_URL}/videos`);
setVideos(response.data);
setError(null);
} catch (err) {
console.error('Error fetching videos:', err);
setError(t('failedToLoadVideos'));
} finally {
setLoading(false);
}
}; };
// Emulate setVideos for compatibility
const setVideos: React.Dispatch<React.SetStateAction<Video[]>> = (updater) => {
queryClient.setQueryData(['videos'], (oldVideos: Video[] | undefined) => {
const currentVideos = oldVideos || [];
if (typeof updater === 'function') {
return updater(currentVideos);
}
return updater;
});
};
const deleteVideoMutation = useMutation({
mutationFn: async (id: string) => {
await axios.delete(`${API_URL}/videos/${id}`);
return id;
},
onSuccess: (id) => {
queryClient.setQueryData(['videos'], (old: Video[] | undefined) =>
old ? old.filter(video => video.id !== id) : []
);
showSnackbar(t('videoRemovedSuccessfully'));
},
onError: (error) => {
console.error('Error deleting video:', error);
}
});
const deleteVideo = async (id: string) => { const deleteVideo = async (id: string) => {
try { try {
setLoading(true); await deleteVideoMutation.mutateAsync(id);
await axios.delete(`${API_URL}/videos/${id}`);
setVideos(prevVideos => prevVideos.filter(video => video.id !== id));
setLoading(false);
showSnackbar(t('videoRemovedSuccessfully'));
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error('Error deleting video:', error);
setLoading(false);
return { success: false, error: t('failedToDeleteVideo') }; return { success: false, error: t('failedToDeleteVideo') };
} }
}; };
@@ -161,25 +192,6 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
return { success: false, error: t('failedToSearch') }; return { success: false, error: t('failedToSearch') };
} }
return { success: false, error: t('searchCancelled') }; return { success: false, error: t('searchCancelled') };
} finally {
if (searchAbortController.current && !searchAbortController.current.signal.aborted) {
setLoading(false);
}
}
};
// Tags state
const [availableTags, setAvailableTags] = useState<string[]>([]);
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const fetchTags = async () => {
try {
const response = await axios.get(`${API_URL}/settings`);
if (response.data.tags) {
setAvailableTags(response.data.tags);
}
} catch (error) {
console.error('Error fetching tags:', error);
} }
}; };
@@ -191,12 +203,6 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
); );
}; };
// Fetch videos and tags on mount
useEffect(() => {
fetchVideos();
fetchTags();
}, []);
// Cleanup search on unmount // Cleanup search on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -207,38 +213,68 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
}; };
}, []); }, []);
const refreshThumbnailMutation = useMutation({
mutationFn: async (id: string) => {
const response = await axios.post(`${API_URL}/videos/${id}/refresh-thumbnail`);
return { id, data: response.data };
},
onSuccess: ({ id, data }) => {
if (data.success) {
queryClient.setQueryData(['videos'], (old: Video[] | undefined) =>
old ? old.map(video =>
video.id === id
? { ...video, thumbnailUrl: data.thumbnailUrl, thumbnailPath: data.thumbnailUrl }
: video
) : []
);
showSnackbar(t('thumbnailRefreshed'));
}
},
onError: (error) => {
console.error('Error refreshing thumbnail:', error);
}
});
const refreshThumbnail = async (id: string) => { const refreshThumbnail = async (id: string) => {
try { try {
const response = await axios.post(`${API_URL}/videos/${id}/refresh-thumbnail`); const result = await refreshThumbnailMutation.mutateAsync(id);
if (response.data.success) { if (result.data.success) {
setVideos(prevVideos => prevVideos.map(video =>
video.id === id
? { ...video, thumbnailUrl: response.data.thumbnailUrl, thumbnailPath: response.data.thumbnailUrl }
: video
));
showSnackbar(t('thumbnailRefreshed'));
return { success: true }; return { success: true };
} }
return { success: false, error: t('thumbnailRefreshFailed') }; return { success: false, error: t('thumbnailRefreshFailed') };
} catch (error) { } catch (error) {
console.error('Error refreshing thumbnail:', error);
return { success: false, error: t('thumbnailRefreshFailed') }; return { success: false, error: t('thumbnailRefreshFailed') };
} }
}; };
const updateVideoMutation = useMutation({
mutationFn: async ({ id, updates }: { id: string; updates: Partial<Video> }) => {
const response = await axios.put(`${API_URL}/videos/${id}`, updates);
return { id, updates, data: response.data };
},
onSuccess: ({ id, updates, data }) => {
if (data.success) {
queryClient.setQueryData(['videos'], (old: Video[] | undefined) =>
old ? old.map(video =>
video.id === id ? { ...video, ...updates } : video
) : []
);
showSnackbar(t('videoUpdated'));
}
},
onError: (error) => {
console.error('Error updating video:', error);
}
});
const updateVideo = async (id: string, updates: Partial<Video>) => { const updateVideo = async (id: string, updates: Partial<Video>) => {
try { try {
const response = await axios.put(`${API_URL}/videos/${id}`, updates); const result = await updateVideoMutation.mutateAsync({ id, updates });
if (response.data.success) { if (result.data.success) {
setVideos(prevVideos => prevVideos.map(video =>
video.id === id ? { ...video, ...updates } : video
));
showSnackbar(t('videoUpdated'));
return { success: true }; return { success: true };
} }
return { success: false, error: t('videoUpdateFailed') }; return { success: false, error: t('videoUpdateFailed') };
} catch (error) { } catch (error) {
console.error('Error updating video:', error);
return { success: false, error: t('videoUpdateFailed') }; return { success: false, error: t('videoUpdateFailed') };
} }
}; };
@@ -246,8 +282,8 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
return ( return (
<VideoContext.Provider value={{ <VideoContext.Provider value={{
videos, videos,
loading, loading: videosLoading,
error, error: videosError ? (videosError as Error).message : null,
fetchVideos, fetchVideos,
deleteVideo, deleteVideo,
updateVideo, updateVideo,

View File

@@ -9,62 +9,41 @@ import {
Grid, Grid,
Typography Typography
} from '@mui/material'; } from '@mui/material';
import axios from 'axios';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import VideoCard from '../components/VideoCard'; import VideoCard from '../components/VideoCard';
import { useLanguage } from '../contexts/LanguageContext'; import { useLanguage } from '../contexts/LanguageContext';
import { useVideo } from '../contexts/VideoContext';
import { Collection, Video } from '../types'; import { Collection, Video } from '../types';
const API_URL = import.meta.env.VITE_API_URL;
interface AuthorVideosProps { interface AuthorVideosProps {
videos: Video[]; videos?: Video[]; // Make optional since we can get from context
onDeleteVideo: (id: string) => Promise<any>; onDeleteVideo: (id: string) => Promise<any>;
collections: Collection[]; collections: Collection[];
} }
const AuthorVideos: React.FC<AuthorVideosProps> = ({ videos: allVideos, onDeleteVideo, collections = [] }) => { const AuthorVideos: React.FC<AuthorVideosProps> = ({ videos: propVideos, onDeleteVideo, collections = [] }) => {
const { t } = useLanguage(); const { t } = useLanguage();
const { author } = useParams<{ author: string }>(); const { author } = useParams<{ author: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const { videos: contextVideos, loading: contextLoading } = useVideo();
const [authorVideos, setAuthorVideos] = useState<Video[]>([]); const [authorVideos, setAuthorVideos] = useState<Video[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); // 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(() => { useEffect(() => {
if (!author) return; if (!author) return;
// If videos are passed as props, filter them if (videos) {
if (allVideos && allVideos.length > 0) { const filteredVideos = videos.filter(
const filteredVideos = allVideos.filter(
video => video.author === author video => video.author === author
); );
setAuthorVideos(filteredVideos); setAuthorVideos(filteredVideos);
setLoading(false);
return;
} }
}, [author, videos]);
// Otherwise fetch from API
const fetchVideos = async () => {
try {
const response = await axios.get(`${API_URL}/videos`);
// Filter videos by author
const filteredVideos = response.data.filter(
(video: Video) => video.author === author
);
setAuthorVideos(filteredVideos);
setError(null);
} catch (err) {
console.error('Error fetching videos:', err);
setError('Failed to load videos. Please try again later.');
} finally {
setLoading(false);
}
};
fetchVideos();
}, [author, allVideos]);
const handleBack = () => { const handleBack = () => {
navigate(-1); navigate(-1);
@@ -79,14 +58,6 @@ const AuthorVideos: React.FC<AuthorVideosProps> = ({ videos: allVideos, onDelete
); );
} }
if (error) {
return (
<Container sx={{ mt: 4 }}>
<Alert severity="error">{t('loadVideosError')}</Alert>
</Container>
);
}
// Filter videos to only show the first video from each collection // Filter videos to only show the first video from each collection
const filteredVideos = authorVideos.filter(video => { const filteredVideos = authorVideos.filter(video => {
// If the video is not in any collection, show it // If the video is not in any collection, show it

View File

@@ -20,24 +20,15 @@ import {
Tabs, Tabs,
Typography Typography
} from '@mui/material'; } from '@mui/material';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import axios from 'axios'; import axios from 'axios';
import React, { useEffect, useState } from 'react'; import React, { useState } from 'react';
import { useDownload } from '../contexts/DownloadContext';
import { useLanguage } from '../contexts/LanguageContext'; import { useLanguage } from '../contexts/LanguageContext';
import { useSnackbar } from '../contexts/SnackbarContext'; import { useSnackbar } from '../contexts/SnackbarContext';
const API_URL = import.meta.env.VITE_API_URL; const API_URL = import.meta.env.VITE_API_URL;
interface DownloadInfo {
id: string;
title: string;
timestamp: number;
filename?: string;
totalSize?: string;
downloadedSize?: string;
progress?: number;
speed?: string;
}
interface DownloadHistoryItem { interface DownloadHistoryItem {
id: string; id: string;
title: string; title: string;
@@ -80,102 +71,114 @@ function CustomTabPanel(props: TabPanelProps) {
const DownloadPage: React.FC = () => { const DownloadPage: React.FC = () => {
const { t } = useLanguage(); const { t } = useLanguage();
const { showSnackbar } = useSnackbar(); const { showSnackbar } = useSnackbar();
const { activeDownloads, queuedDownloads } = useDownload();
const queryClient = useQueryClient();
const [tabValue, setTabValue] = useState(0); const [tabValue, setTabValue] = useState(0);
const [activeDownloads, setActiveDownloads] = useState<DownloadInfo[]>([]);
const [queuedDownloads, setQueuedDownloads] = useState<DownloadInfo[]>([]);
const [history, setHistory] = useState<DownloadHistoryItem[]>([]);
const fetchStatus = async () => { // Fetch history with polling
try { const { data: history = [] } = useQuery({
const response = await axios.get(`${API_URL}/download-status`); queryKey: ['downloadHistory'],
setActiveDownloads(response.data.activeDownloads); queryFn: async () => {
setQueuedDownloads(response.data.queuedDownloads);
} catch (error) {
console.error('Error fetching download status:', error);
}
};
const fetchHistory = async () => {
try {
const response = await axios.get(`${API_URL}/downloads/history`); const response = await axios.get(`${API_URL}/downloads/history`);
setHistory(response.data); return response.data;
} catch (error) { },
console.error('Error fetching history:', error); refetchInterval: 2000
} });
};
useEffect(() => {
fetchStatus();
fetchHistory();
const interval = setInterval(() => {
fetchStatus();
fetchHistory();
}, 1000);
return () => clearInterval(interval);
}, []);
const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => { const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => {
setTabValue(newValue); setTabValue(newValue);
}; };
const handleCancelDownload = async (id: string) => { // Cancel download mutation
try { const cancelMutation = useMutation({
mutationFn: async (id: string) => {
await axios.post(`${API_URL}/downloads/cancel/${id}`); await axios.post(`${API_URL}/downloads/cancel/${id}`);
},
onSuccess: () => {
showSnackbar(t('downloadCancelled') || 'Download cancelled'); showSnackbar(t('downloadCancelled') || 'Download cancelled');
fetchStatus(); // DownloadContext handles active/queued updates via its own polling
} catch (error) { // But we might want to invalidate to be sure
console.error('Error cancelling download:', error); queryClient.invalidateQueries({ queryKey: ['downloadStatus'] });
},
onError: () => {
showSnackbar(t('error') || 'Error'); showSnackbar(t('error') || 'Error');
} }
});
const handleCancelDownload = (id: string) => {
cancelMutation.mutate(id);
}; };
const handleRemoveFromQueue = async (id: string) => { // Remove from queue mutation
try { const removeFromQueueMutation = useMutation({
mutationFn: async (id: string) => {
await axios.delete(`${API_URL}/downloads/queue/${id}`); await axios.delete(`${API_URL}/downloads/queue/${id}`);
},
onSuccess: () => {
showSnackbar(t('removedFromQueue') || 'Removed from queue'); showSnackbar(t('removedFromQueue') || 'Removed from queue');
fetchStatus(); queryClient.invalidateQueries({ queryKey: ['downloadStatus'] });
} catch (error) { },
console.error('Error removing from queue:', error); onError: () => {
showSnackbar(t('error') || 'Error'); showSnackbar(t('error') || 'Error');
} }
});
const handleRemoveFromQueue = (id: string) => {
removeFromQueueMutation.mutate(id);
}; };
const handleClearQueue = async () => { // Clear queue mutation
try { const clearQueueMutation = useMutation({
mutationFn: async () => {
await axios.delete(`${API_URL}/downloads/queue`); await axios.delete(`${API_URL}/downloads/queue`);
},
onSuccess: () => {
showSnackbar(t('queueCleared') || 'Queue cleared'); showSnackbar(t('queueCleared') || 'Queue cleared');
fetchStatus(); queryClient.invalidateQueries({ queryKey: ['downloadStatus'] });
} catch (error) { },
console.error('Error clearing queue:', error); onError: () => {
showSnackbar(t('error') || 'Error'); showSnackbar(t('error') || 'Error');
} }
});
const handleClearQueue = () => {
clearQueueMutation.mutate();
}; };
const handleRemoveFromHistory = async (id: string) => { // Remove from history mutation
try { const removeFromHistoryMutation = useMutation({
mutationFn: async (id: string) => {
await axios.delete(`${API_URL}/downloads/history/${id}`); await axios.delete(`${API_URL}/downloads/history/${id}`);
},
onSuccess: () => {
showSnackbar(t('removedFromHistory') || 'Removed from history'); showSnackbar(t('removedFromHistory') || 'Removed from history');
fetchHistory(); queryClient.invalidateQueries({ queryKey: ['downloadHistory'] });
} catch (error) { },
console.error('Error removing from history:', error); onError: () => {
showSnackbar(t('error') || 'Error'); showSnackbar(t('error') || 'Error');
} }
});
const handleRemoveFromHistory = (id: string) => {
removeFromHistoryMutation.mutate(id);
}; };
const handleClearHistory = async () => { // Clear history mutation
try { const clearHistoryMutation = useMutation({
mutationFn: async () => {
await axios.delete(`${API_URL}/downloads/history`); await axios.delete(`${API_URL}/downloads/history`);
},
onSuccess: () => {
showSnackbar(t('historyCleared') || 'History cleared'); showSnackbar(t('historyCleared') || 'History cleared');
fetchHistory(); queryClient.invalidateQueries({ queryKey: ['downloadHistory'] });
} catch (error) { },
console.error('Error clearing history:', error); onError: () => {
showSnackbar(t('error') || 'Error'); showSnackbar(t('error') || 'Error');
} }
}; });
const formatBytes = (bytes?: string | number) => { const handleClearHistory = () => {
if (!bytes) return '-'; clearHistoryMutation.mutate();
return bytes.toString(); // Simplified, ideally use a helper
}; };
const formatDate = (timestamp: number) => { const formatDate = (timestamp: number) => {
@@ -279,7 +282,7 @@ const DownloadPage: React.FC = () => {
<Typography color="textSecondary">{t('noDownloadHistory') || 'No download history'}</Typography> <Typography color="textSecondary">{t('noDownloadHistory') || 'No download history'}</Typography>
) : ( ) : (
<List> <List>
{history.map((item) => ( {history.map((item: DownloadHistoryItem) => (
<Paper key={item.id} sx={{ mb: 2, p: 2 }}> <Paper key={item.id} sx={{ mb: 2, p: 2 }}>
<ListItem disableGutters> <ListItem disableGutters>
<ListItemText <ListItemText

View File

@@ -10,6 +10,7 @@ import {
ThemeProvider, ThemeProvider,
Typography Typography
} from '@mui/material'; } from '@mui/material';
import { useMutation } from '@tanstack/react-query';
import axios from 'axios'; import axios from 'axios';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useLanguage } from '../contexts/LanguageContext'; import { useLanguage } from '../contexts/LanguageContext';
@@ -24,34 +25,37 @@ interface LoginPageProps {
const LoginPage: React.FC<LoginPageProps> = ({ onLoginSuccess }) => { const LoginPage: React.FC<LoginPageProps> = ({ onLoginSuccess }) => {
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { t } = useLanguage(); const { t } = useLanguage();
// Use dark theme for login page to match app style // Use dark theme for login page to match app style
const theme = getTheme('dark'); const theme = getTheme('dark');
const handleSubmit = async (e: React.FormEvent) => { const loginMutation = useMutation({
e.preventDefault(); mutationFn: async (password: string) => {
setLoading(true);
setError('');
try {
const response = await axios.post(`${API_URL}/settings/verify-password`, { password }); const response = await axios.post(`${API_URL}/settings/verify-password`, { password });
if (response.data.success) { return response.data;
},
onSuccess: (data) => {
if (data.success) {
onLoginSuccess(); onLoginSuccess();
} else { } else {
setError(t('incorrectPassword')); setError(t('incorrectPassword'));
} }
} catch (err: any) { },
onError: (err: any) => {
console.error('Login error:', err); console.error('Login error:', err);
if (err.response && err.response.status === 401) { if (err.response && err.response.status === 401) {
setError(t('incorrectPassword')); setError(t('incorrectPassword'));
} else { } else {
setError(t('loginFailed')); setError(t('loginFailed'));
} }
} finally {
setLoading(false);
} }
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setError('');
loginMutation.mutate(password);
}; };
return ( return (
@@ -97,9 +101,9 @@ const LoginPage: React.FC<LoginPageProps> = ({ onLoginSuccess }) => {
fullWidth fullWidth
variant="contained" variant="contained"
sx={{ mt: 3, mb: 2 }} sx={{ mt: 3, mb: 2 }}
disabled={loading} disabled={loginMutation.isPending}
> >
{loading ? t('verifying') : t('signIn')} {loginMutation.isPending ? t('verifying') : t('signIn')}
</Button> </Button>
</Box> </Box>
</Box> </Box>

View File

@@ -23,6 +23,7 @@ import {
TextField, TextField,
Typography Typography
} from '@mui/material'; } from '@mui/material';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import axios from 'axios'; import axios from 'axios';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
@@ -46,6 +47,10 @@ interface Settings {
} }
const SettingsPage: React.FC = () => { const SettingsPage: React.FC = () => {
const queryClient = useQueryClient();
const { t, setLanguage } = useLanguage();
const { activeDownloads } = useDownload();
const [settings, setSettings] = useState<Settings>({ const [settings, setSettings] = useState<Settings>({
loginEnabled: false, loginEnabled: false,
password: '', password: '',
@@ -56,7 +61,6 @@ const SettingsPage: React.FC = () => {
tags: [] tags: []
}); });
const [newTag, setNewTag] = useState(''); const [newTag, setNewTag] = useState('');
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ text: string; type: 'success' | 'error' | 'warning' | 'info' } | null>(null); const [message, setMessage] = useState<{ text: string; type: 'success' | 'error' | 'warning' | 'info' } | null>(null);
// Modal states // Modal states
@@ -72,51 +76,191 @@ const SettingsPage: React.FC = () => {
const [debugMode, setDebugMode] = useState(ConsoleManager.getDebugMode()); const [debugMode, setDebugMode] = useState(ConsoleManager.getDebugMode());
const { t, setLanguage } = useLanguage(); // Fetch settings
const { activeDownloads } = useDownload(); const { data: settingsData } = useQuery({
queryKey: ['settings'],
queryFn: async () => {
const response = await axios.get(`${API_URL}/settings`);
return response.data;
}
});
useEffect(() => { useEffect(() => {
fetchSettings(); if (settingsData) {
}, []);
const fetchSettings = async () => {
try {
const response = await axios.get(`${API_URL}/settings`);
setSettings({ setSettings({
...response.data, ...settingsData,
tags: response.data.tags || [] tags: settingsData.tags || []
}); });
} catch (error) {
console.error('Error fetching settings:', error);
setMessage({ text: t('settingsFailed'), type: 'error' });
} finally {
// Loading finished
} }
}; }, [settingsData]);
const handleSave = async () => { // Save settings mutation
setSaving(true); const saveMutation = useMutation({
try { mutationFn: async (newSettings: Settings) => {
// Only send password if it has been changed (is not empty) // Only send password if it has been changed (is not empty)
const settingsToSend = { ...settings }; const settingsToSend = { ...newSettings };
if (!settingsToSend.password) { if (!settingsToSend.password) {
delete settingsToSend.password; delete settingsToSend.password;
} }
console.log('Saving settings:', settingsToSend);
await axios.post(`${API_URL}/settings`, settingsToSend); await axios.post(`${API_URL}/settings`, settingsToSend);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['settings'] });
setMessage({ text: t('settingsSaved'), type: 'success' }); setMessage({ text: t('settingsSaved'), type: 'success' });
// Clear password field after save // Clear password field after save
setSettings(prev => ({ ...prev, password: '', isPasswordSet: true })); setSettings(prev => ({ ...prev, password: '', isPasswordSet: true }));
} catch (error) { },
console.error('Error saving settings:', error); onError: () => {
setMessage({ text: t('settingsFailed'), type: 'error' }); setMessage({ text: t('settingsFailed'), type: 'error' });
} finally {
setSaving(false);
} }
});
const handleSave = () => {
saveMutation.mutate(settings);
}; };
// Scan files mutation
const scanMutation = useMutation({
mutationFn: async () => {
const res = await axios.post(`${API_URL}/scan-files`);
return res.data;
},
onSuccess: (data) => {
setInfoModal({
isOpen: true,
title: t('success'),
message: t('scanFilesSuccess').replace('{count}', data.addedCount.toString()),
type: 'success'
});
},
onError: (error: any) => {
setInfoModal({
isOpen: true,
title: t('error'),
message: `${t('scanFilesFailed')}: ${error.response?.data?.details || error.message}`,
type: 'error'
});
}
});
// Migrate data mutation
const migrateMutation = useMutation({
mutationFn: async () => {
const res = await axios.post(`${API_URL}/settings/migrate`);
return res.data.results;
},
onSuccess: (results) => {
let msg = `${t('migrationReport')}:\n`;
let hasData = false;
if (results.warnings && results.warnings.length > 0) {
msg += `\n⚠ ${t('migrationWarnings')}:\n${results.warnings.join('\n')}\n`;
}
const categories = ['videos', 'collections', 'settings', 'downloads'];
categories.forEach(cat => {
const data = results[cat];
if (data) {
if (data.found) {
msg += `\n✅ ${cat}: ${data.count} ${t('itemsMigrated')}`;
hasData = true;
} else {
msg += `\n❌ ${cat}: ${t('fileNotFound')} ${data.path}`;
}
}
});
if (results.errors && results.errors.length > 0) {
msg += `\n\n⛔ ${t('migrationErrors')}:\n${results.errors.join('\n')}`;
}
if (!hasData && (!results.errors || results.errors.length === 0)) {
msg += `\n\n⚠ ${t('noDataFilesFound')}`;
}
setInfoModal({
isOpen: true,
title: hasData ? t('migrationResults') : t('migrationNoData'),
message: msg,
type: hasData ? 'success' : 'warning'
});
},
onError: (error: any) => {
setInfoModal({
isOpen: true,
title: t('error'),
message: `${t('migrationFailed')}: ${error.response?.data?.details || error.message}`,
type: 'error'
});
}
});
// Cleanup temp files mutation
const cleanupMutation = useMutation({
mutationFn: async () => {
const res = await axios.post(`${API_URL}/cleanup-temp-files`);
return res.data;
},
onSuccess: (data) => {
const { deletedCount, errors } = data;
let msg = t('cleanupTempFilesSuccess').replace('{count}', deletedCount.toString());
if (errors && errors.length > 0) {
msg += `\n\nErrors:\n${errors.join('\n')}`;
}
setInfoModal({
isOpen: true,
title: t('success'),
message: msg,
type: errors && errors.length > 0 ? 'warning' : 'success'
});
},
onError: (error: any) => {
const errorMsg = error.response?.data?.error === "Cannot clean up while downloads are active"
? t('cleanupTempFilesActiveDownloads')
: `${t('cleanupTempFilesFailed')}: ${error.response?.data?.details || error.message}`;
setInfoModal({
isOpen: true,
title: t('error'),
message: errorMsg,
type: 'error'
});
}
});
// Delete legacy data mutation
const deleteLegacyMutation = useMutation({
mutationFn: async () => {
const res = await axios.post(`${API_URL}/settings/delete-legacy`);
return res.data.results;
},
onSuccess: (results) => {
let msg = `${t('legacyDataDeleted')}\n`;
if (results.deleted.length > 0) {
msg += `\nDeleted: ${results.deleted.join(', ')}`;
}
if (results.failed.length > 0) {
msg += `\nFailed: ${results.failed.join(', ')}`;
}
setInfoModal({
isOpen: true,
title: t('success'),
message: msg,
type: 'success'
});
},
onError: (error: any) => {
setInfoModal({
isOpen: true,
title: t('error'),
message: `Failed to delete legacy data: ${error.response?.data?.details || error.message}`,
type: 'error'
});
}
});
const handleChange = (field: keyof Settings, value: string | boolean | number) => { const handleChange = (field: keyof Settings, value: string | boolean | number) => {
setSettings(prev => ({ ...prev, [field]: value })); setSettings(prev => ({ ...prev, [field]: value }));
if (field === 'language') { if (field === 'language') {
@@ -137,6 +281,8 @@ const SettingsPage: React.FC = () => {
setSettings(prev => ({ ...prev, tags: updatedTags })); setSettings(prev => ({ ...prev, tags: updatedTags }));
}; };
const isSaving = saveMutation.isPending || scanMutation.isPending || migrateMutation.isPending || cleanupMutation.isPending || deleteLegacyMutation.isPending;
return ( return (
<Container maxWidth="xl" sx={{ py: 4 }}> <Container maxWidth="xl" sx={{ py: 4 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4 }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4 }}>
@@ -314,7 +460,7 @@ const SettingsPage: React.FC = () => {
variant="outlined" variant="outlined"
color="warning" color="warning"
onClick={() => setShowCleanupTempFilesModal(true)} onClick={() => setShowCleanupTempFilesModal(true)}
disabled={saving || activeDownloads.length > 0} disabled={isSaving || activeDownloads.length > 0}
> >
{t('cleanupTempFiles')} {t('cleanupTempFiles')}
</Button> </Button>
@@ -333,7 +479,7 @@ const SettingsPage: React.FC = () => {
variant="outlined" variant="outlined"
color="warning" color="warning"
onClick={() => setShowMigrateConfirmModal(true)} onClick={() => setShowMigrateConfirmModal(true)}
disabled={saving} disabled={isSaving}
> >
{t('migrateDataButton')} {t('migrateDataButton')}
</Button> </Button>
@@ -341,31 +487,8 @@ const SettingsPage: React.FC = () => {
<Button <Button
variant="outlined" variant="outlined"
color="primary" color="primary"
onClick={async () => { onClick={() => scanMutation.mutate()}
setSaving(true); disabled={isSaving}
try {
const res = await axios.post(`${API_URL}/scan-files`);
const { addedCount } = res.data;
setInfoModal({
isOpen: true,
title: t('success'),
message: t('scanFilesSuccess').replace('{count}', addedCount.toString()),
type: 'success'
});
} catch (error: any) {
console.error('Scan failed:', error);
setInfoModal({
isOpen: true,
title: t('error'),
message: `${t('scanFilesFailed')}: ${error.response?.data?.details || error.message}`,
type: 'error'
});
} finally {
setSaving(false);
}
}}
disabled={saving}
sx={{ ml: 2 }} sx={{ ml: 2 }}
> >
{t('scanFiles')} {t('scanFiles')}
@@ -380,7 +503,7 @@ const SettingsPage: React.FC = () => {
variant="outlined" variant="outlined"
color="error" color="error"
onClick={() => setShowDeleteLegacyModal(true)} onClick={() => setShowDeleteLegacyModal(true)}
disabled={saving} disabled={isSaving}
> >
{t('deleteLegacyDataButton')} {t('deleteLegacyDataButton')}
</Button> </Button>
@@ -416,9 +539,9 @@ const SettingsPage: React.FC = () => {
size="large" size="large"
startIcon={<Save />} startIcon={<Save />}
onClick={handleSave} onClick={handleSave}
disabled={saving} disabled={isSaving}
> >
{saving ? t('saving') : t('saveSettings')} {isSaving ? t('saving') : t('saveSettings')}
</Button> </Button>
</Box> </Box>
</Grid> </Grid>
@@ -439,42 +562,9 @@ const SettingsPage: React.FC = () => {
<ConfirmationModal <ConfirmationModal
isOpen={showDeleteLegacyModal} isOpen={showDeleteLegacyModal}
onClose={() => setShowDeleteLegacyModal(false)} onClose={() => setShowDeleteLegacyModal(false)}
onConfirm={async () => { onConfirm={() => {
setSaving(true); setShowDeleteLegacyModal(false);
try { deleteLegacyMutation.mutate();
const res = await axios.post(`${API_URL}/settings/delete-legacy`);
const results = res.data.results;
console.log('Delete legacy results:', results);
let msg = `${t('legacyDataDeleted')}\n`;
if (results.deleted.length > 0) {
msg += `\nDeleted: ${results.deleted.join(', ')}`;
}
if (results.failed.length > 0) {
msg += `\nFailed: ${results.failed.join(', ')}`;
}
if (results.failed.length > 0) {
msg += `\nFailed: ${results.failed.join(', ')}`;
}
setInfoModal({
isOpen: true,
title: t('success'),
message: msg,
type: 'success'
});
} catch (error: any) {
console.error('Failed to delete legacy data:', error);
setInfoModal({
isOpen: true,
title: t('error'),
message: `Failed to delete legacy data: ${error.response?.data?.details || error.message}`,
type: 'error'
});
} finally {
setSaving(false);
}
}} }}
title={t('removeLegacyDataConfirmTitle')} title={t('removeLegacyDataConfirmTitle')}
message={t('removeLegacyDataConfirmMessage')} message={t('removeLegacyDataConfirmMessage')}
@@ -487,58 +577,9 @@ const SettingsPage: React.FC = () => {
<ConfirmationModal <ConfirmationModal
isOpen={showMigrateConfirmModal} isOpen={showMigrateConfirmModal}
onClose={() => setShowMigrateConfirmModal(false)} onClose={() => setShowMigrateConfirmModal(false)}
onConfirm={async () => { onConfirm={() => {
setSaving(true); setShowMigrateConfirmModal(false);
try { migrateMutation.mutate();
const res = await axios.post(`${API_URL}/settings/migrate`);
const results = res.data.results;
console.log('Migration results:', results);
let msg = `${t('migrationReport')}:\n`;
let hasData = false;
if (results.warnings && results.warnings.length > 0) {
msg += `\n⚠ ${t('migrationWarnings')}:\n${results.warnings.join('\n')}\n`;
}
const categories = ['videos', 'collections', 'settings', 'downloads'];
categories.forEach(cat => {
const data = results[cat];
if (data) {
if (data.found) {
msg += `\n✅ ${cat}: ${data.count} ${t('itemsMigrated')}`;
hasData = true;
} else {
msg += `\n❌ ${cat}: ${t('fileNotFound')} ${data.path}`;
}
}
});
if (results.errors && results.errors.length > 0) {
msg += `\n\n⛔ ${t('migrationErrors')}:\n${results.errors.join('\n')}`;
}
if (!hasData && (!results.errors || results.errors.length === 0)) {
msg += `\n\n⚠ ${t('noDataFilesFound')}`;
}
setInfoModal({
isOpen: true,
title: hasData ? t('migrationResults') : t('migrationNoData'),
message: msg,
type: hasData ? 'success' : 'warning'
});
} catch (error: any) {
console.error('Migration failed:', error);
setInfoModal({
isOpen: true,
title: t('error'),
message: `${t('migrationFailed')}: ${error.response?.data?.details || error.message}`,
type: 'error'
});
} finally {
setSaving(false);
}
}} }}
title={t('migrateDataButton')} title={t('migrateDataButton')}
message={t('migrateConfirmation')} message={t('migrateConfirmation')}
@@ -550,38 +591,9 @@ const SettingsPage: React.FC = () => {
<ConfirmationModal <ConfirmationModal
isOpen={showCleanupTempFilesModal} isOpen={showCleanupTempFilesModal}
onClose={() => setShowCleanupTempFilesModal(false)} onClose={() => setShowCleanupTempFilesModal(false)}
onConfirm={async () => { onConfirm={() => {
setSaving(true); setShowCleanupTempFilesModal(false);
try { cleanupMutation.mutate();
const res = await axios.post(`${API_URL}/cleanup-temp-files`);
const { deletedCount, errors } = res.data;
let msg = t('cleanupTempFilesSuccess').replace('{count}', deletedCount.toString());
if (errors && errors.length > 0) {
msg += `\n\nErrors:\n${errors.join('\n')}`;
}
setInfoModal({
isOpen: true,
title: t('success'),
message: msg,
type: errors && errors.length > 0 ? 'warning' : 'success'
});
} catch (error: any) {
console.error('Cleanup failed:', error);
const errorMsg = error.response?.data?.error === "Cannot clean up while downloads are active"
? t('cleanupTempFilesActiveDownloads')
: `${t('cleanupTempFilesFailed')}: ${error.response?.data?.details || error.message}`;
setInfoModal({
isOpen: true,
title: t('error'),
message: errorMsg,
type: 'error'
});
} finally {
setSaving(false);
}
}} }}
title={t('cleanupTempFilesConfirmTitle')} title={t('cleanupTempFilesConfirmTitle')}
message={t('cleanupTempFilesConfirmMessage')} message={t('cleanupTempFilesConfirmMessage')}

View File

@@ -11,6 +11,7 @@ import {
Stack, Stack,
Typography Typography
} from '@mui/material'; } from '@mui/material';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import axios from 'axios'; import axios from 'axios';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
@@ -21,7 +22,7 @@ import VideoControls from '../components/VideoPlayer/VideoControls';
import VideoInfo from '../components/VideoPlayer/VideoInfo'; import VideoInfo from '../components/VideoPlayer/VideoInfo';
import { useLanguage } from '../contexts/LanguageContext'; import { useLanguage } from '../contexts/LanguageContext';
import { useSnackbar } from '../contexts/SnackbarContext'; import { useSnackbar } from '../contexts/SnackbarContext';
import { Collection, Comment, Video } from '../types'; import { Collection, Video } from '../types';
const API_URL = import.meta.env.VITE_API_URL; const API_URL = import.meta.env.VITE_API_URL;
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL; const BACKEND_URL = import.meta.env.VITE_BACKEND_URL;
@@ -47,21 +48,11 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useLanguage(); const { t } = useLanguage();
const { showSnackbar } = useSnackbar(); const { showSnackbar } = useSnackbar();
const queryClient = useQueryClient();
const [video, setVideo] = useState<Video | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState<boolean>(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [showCollectionModal, setShowCollectionModal] = useState<boolean>(false); const [showCollectionModal, setShowCollectionModal] = useState<boolean>(false);
const [videoCollections, setVideoCollections] = useState<Collection[]>([]); const [videoCollections, setVideoCollections] = useState<Collection[]>([]);
const [comments, setComments] = useState<Comment[]>([]);
const [loadingComments, setLoadingComments] = useState<boolean>(false);
const [showComments, setShowComments] = useState<boolean>(false); const [showComments, setShowComments] = useState<boolean>(false);
const [commentsLoaded, setCommentsLoaded] = useState<boolean>(false);
const [availableTags, setAvailableTags] = useState<string[]>([]);
const [autoPlay, setAutoPlay] = useState<boolean>(false);
const [autoLoop, setAutoLoop] = useState<boolean>(false);
// Confirmation Modal State // Confirmation Modal State
const [confirmationModal, setConfirmationModal] = useState({ const [confirmationModal, setConfirmationModal] = useState({
@@ -74,84 +65,54 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
isDanger: false isDanger: false
}); });
// Fetch video details
const { data: video, isLoading: loading, error } = useQuery({
queryKey: ['video', id],
queryFn: async () => {
const response = await axios.get(`${API_URL}/videos/${id}`);
return response.data;
},
initialData: () => {
return videos.find(v => v.id === id);
},
enabled: !!id,
retry: false
});
// Handle error redirect
useEffect(() => { useEffect(() => {
// Don't try to fetch the video if it's being deleted if (error) {
if (isDeleting) { const timer = setTimeout(() => {
return; navigate('/');
}, 3000);
return () => clearTimeout(timer);
} }
}, [error, navigate]);
const fetchVideo = async () => { // Fetch settings
if (!id) return; const { data: settings } = useQuery({
queryKey: ['settings'],
queryFn: async () => {
const response = await axios.get(`${API_URL}/settings`);
return response.data;
}
});
// First check if the video is in the videos prop const autoPlay = settings?.defaultAutoPlay || false;
const foundVideo = videos.find(v => v.id === id); const autoLoop = settings?.defaultAutoLoop || false;
const availableTags = settings?.tags || [];
if (foundVideo) { // Fetch comments
setVideo(foundVideo); const { data: comments = [], isLoading: loadingComments } = useQuery({
setLoading(false); queryKey: ['comments', id],
return; queryFn: async () => {
}
// If not found in props, try to fetch from API
try {
const response = await axios.get(`${API_URL}/videos/${id}`);
setVideo(response.data);
setError(null);
} catch (err) {
console.error('Error fetching video:', err);
setError(t('videoNotFoundOrLoaded'));
// Redirect to home after 3 seconds if video not found
setTimeout(() => {
navigate('/');
}, 3000);
} finally {
setLoading(false);
}
};
fetchVideo();
}, [id, videos, navigate, isDeleting]);
// Fetch settings and apply defaults
useEffect(() => {
const fetchSettings = async () => {
try {
const response = await axios.get(`${API_URL}/settings`);
const { defaultAutoPlay, defaultAutoLoop } = response.data;
setAutoPlay(!!defaultAutoPlay);
setAutoLoop(!!defaultAutoLoop);
setAvailableTags(response.data.tags || []);
} catch (error) {
console.error('Error fetching settings:', error);
}
};
fetchSettings();
}, [id]); // Re-run when video changes
const fetchComments = async () => {
if (!id) return;
setLoadingComments(true);
try {
const response = await axios.get(`${API_URL}/videos/${id}/comments`); const response = await axios.get(`${API_URL}/videos/${id}/comments`);
setComments(response.data); return response.data;
setCommentsLoaded(true); },
} catch (err) { enabled: showComments && !!id
console.error('Error fetching comments:', err); });
// We don't set a global error here as comments are secondary
} finally {
setLoadingComments(false);
}
};
const handleToggleComments = () => { const handleToggleComments = () => {
if (!showComments && !commentsLoaded) {
fetchComments();
}
setShowComments(!showComments); setShowComments(!showComments);
}; };
@@ -191,27 +152,21 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
navigate(`/collection/${collectionId}`); navigate(`/collection/${collectionId}`);
}; };
// Delete mutation
const deleteMutation = useMutation({
mutationFn: async (videoId: string) => {
return await onDeleteVideo(videoId);
},
onSuccess: (result) => {
if (result.success) {
navigate('/', { replace: true });
}
}
});
const executeDelete = async () => { const executeDelete = async () => {
if (!id) return; if (!id) return;
await deleteMutation.mutateAsync(id);
setIsDeleting(true);
setDeleteError(null);
try {
const result = await onDeleteVideo(id);
if (result.success) {
// Navigate to home immediately after successful deletion
navigate('/', { replace: true });
} else {
setDeleteError(result.error || t('deleteFailed'));
setIsDeleting(false);
}
} catch (err) {
setDeleteError(t('unexpectedErrorOccurred'));
console.error(err);
setIsDeleting(false);
}
}; };
const handleDelete = () => { const handleDelete = () => {
@@ -274,43 +229,63 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
}); });
}; };
// Rating mutation
const ratingMutation = useMutation({
mutationFn: async (newValue: number) => {
await axios.post(`${API_URL}/videos/${id}/rate`, { rating: newValue });
return newValue;
},
onSuccess: (newValue) => {
queryClient.setQueryData(['video', id], (old: Video | undefined) => old ? { ...old, rating: newValue } : old);
}
});
const handleRatingChange = async (newValue: number) => { const handleRatingChange = async (newValue: number) => {
if (!id) return; if (!id) return;
await ratingMutation.mutateAsync(newValue);
try {
await axios.post(`${API_URL}/videos/${id}/rate`, { rating: newValue });
setVideo(prev => prev ? { ...prev, rating: newValue } : null);
} catch (error) {
console.error('Error updating rating:', error);
}
}; };
// Title mutation
const titleMutation = useMutation({
mutationFn: async (newTitle: string) => {
const response = await axios.put(`${API_URL}/videos/${id}`, { title: newTitle });
return response.data;
},
onSuccess: (data, newTitle) => {
if (data.success) {
queryClient.setQueryData(['video', id], (old: Video | undefined) => old ? { ...old, title: newTitle } : old);
showSnackbar(t('titleUpdated'));
}
},
onError: () => {
showSnackbar(t('titleUpdateFailed'), 'error');
}
});
const handleSaveTitle = async (newTitle: string) => { const handleSaveTitle = async (newTitle: string) => {
if (!id) return; if (!id) return;
await titleMutation.mutateAsync(newTitle);
try {
const response = await axios.put(`${API_URL}/videos/${id}`, { title: newTitle });
if (response.data.success) {
setVideo(prev => prev ? { ...prev, title: newTitle } : null);
showSnackbar(t('titleUpdated'));
}
} catch (error) {
console.error('Error updating title:', error);
showSnackbar(t('titleUpdateFailed'), 'error');
}
}; };
// Tags mutation
const tagsMutation = useMutation({
mutationFn: async (newTags: string[]) => {
const response = await axios.put(`${API_URL}/videos/${id}`, { tags: newTags });
return response.data;
},
onSuccess: (data, newTags) => {
if (data.success) {
queryClient.setQueryData(['video', id], (old: Video | undefined) => old ? { ...old, tags: newTags } : old);
}
},
onError: () => {
showSnackbar(t('error'), 'error');
}
});
const handleUpdateTags = async (newTags: string[]) => { const handleUpdateTags = async (newTags: string[]) => {
if (!id) return; if (!id) return;
try { await tagsMutation.mutateAsync(newTags);
const response = await axios.put(`${API_URL}/videos/${id}`, { tags: newTags });
if (response.data.success) {
setVideo(prev => prev ? { ...prev, tags: newTags } : null);
}
} catch (error) {
console.error('Error updating tags:', error);
showSnackbar(t('error'), 'error');
}
}; };
const [hasViewed, setHasViewed] = useState<boolean>(false); const [hasViewed, setHasViewed] = useState<boolean>(false);
@@ -342,7 +317,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
axios.post(`${API_URL}/videos/${id}/view`) axios.post(`${API_URL}/videos/${id}/view`)
.then(res => { .then(res => {
if (res.data.success && video) { if (res.data.success && video) {
setVideo({ ...video, viewCount: res.data.viewCount }); queryClient.setQueryData(['video', id], (old: Video | undefined) => old ? { ...old, viewCount: res.data.viewCount } : old);
} }
}) })
.catch(err => console.error('Error incrementing view count:', err)); .catch(err => console.error('Error incrementing view count:', err));
@@ -369,7 +344,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
if (error || !video) { if (error || !video) {
return ( return (
<Container sx={{ mt: 4 }}> <Container sx={{ mt: 4 }}>
<Alert severity="error">{error || t('videoNotFound')}</Alert> <Alert severity="error">{t('videoNotFoundOrLoaded')}</Alert>
</Container> </Container>
); );
} }
@@ -397,8 +372,8 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
onAuthorClick={handleAuthorClick} onAuthorClick={handleAuthorClick}
onAddToCollection={handleAddToCollection} onAddToCollection={handleAddToCollection}
onDelete={handleDelete} onDelete={handleDelete}
isDeleting={isDeleting} isDeleting={deleteMutation.isPending}
deleteError={deleteError} deleteError={deleteMutation.error ? (deleteMutation.error as any).message || t('deleteFailed') : null}
videoCollections={videoCollections} videoCollections={videoCollections}
onCollectionClick={handleCollectionClick} onCollectionClick={handleCollectionClick}
availableTags={availableTags} availableTags={availableTags}

View File

@@ -35,6 +35,11 @@ export interface DownloadInfo {
id: string; id: string;
title: string; title: string;
timestamp?: number; timestamp?: number;
progress?: number;
speed?: string;
totalSize?: string;
downloadedSize?: string;
filename?: string;
} }
export interface Comment { export interface Comment {