diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7e80711..e2c8786 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,8 @@ "@emotion/styled": "^11.14.1", "@mui/icons-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", "dotenv": "^16.4.7", "framer-motion": "^12.23.24", @@ -1669,6 +1671,59 @@ "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": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index c6da8e1..e18a788 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,8 @@ "@emotion/styled": "^11.14.1", "@mui/icons-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", "dotenv": "^16.4.7", "framer-motion": "^12.23.24", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 414df9d..acf2829 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -194,19 +194,25 @@ function AppContent() { ); } +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +const queryClient = new QueryClient(); + function App() { return ( - - - - - - - - - - - + + + + + + + + + + + + + ); } diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 306987f..b8e24d3 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -93,7 +93,7 @@ const Header: React.FC = ({ const { availableTags, selectedTags, handleTagToggle } = useVideo(); - const isDownloading = activeDownloads.length > 0 || queuedDownloads.length > 0; + useEffect(() => { console.log('Header props:', { activeDownloads, queuedDownloads }); diff --git a/frontend/src/components/UploadModal.tsx b/frontend/src/components/UploadModal.tsx index a1aa76e..c014091 100644 --- a/frontend/src/components/UploadModal.tsx +++ b/frontend/src/components/UploadModal.tsx @@ -12,6 +12,7 @@ import { TextField, Typography } from '@mui/material'; +import { useMutation } from '@tanstack/react-query'; import axios from 'axios'; import { useState } from 'react'; import { useLanguage } from '../contexts/LanguageContext'; @@ -29,7 +30,6 @@ const UploadModal: React.FC = ({ open, onClose, onUploadSucces const [file, setFile] = useState(null); const [title, setTitle] = useState(''); const [author, setAuthor] = useState('Admin'); - const [uploading, setUploading] = useState(false); const [progress, setProgress] = useState(0); const [error, setError] = useState(''); @@ -43,22 +43,8 @@ const UploadModal: React.FC = ({ open, onClose, onUploadSucces } }; - const handleUpload = async () => { - if (!file) { - 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 { + const uploadMutation = useMutation({ + mutationFn: async (formData: FormData) => { await axios.post(`${API_URL}/upload`, formData, { headers: { 'Content-Type': 'multipart/form-data', @@ -68,15 +54,32 @@ const UploadModal: React.FC = ({ open, onClose, onUploadSucces setProgress(percentCompleted); }, }); - + }, + onSuccess: () => { onUploadSuccess(); handleClose(); - } catch (err: any) { + }, + onError: (err: any) => { console.error('Upload failed:', err); 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 = () => { @@ -89,7 +92,7 @@ const UploadModal: React.FC = ({ open, onClose, onUploadSucces }; return ( - + {t('uploadVideo')} @@ -114,7 +117,7 @@ const UploadModal: React.FC = ({ open, onClose, onUploadSucces fullWidth value={title} onChange={(e) => setTitle(e.target.value)} - disabled={uploading} + disabled={uploadMutation.isPending} /> = ({ open, onClose, onUploadSucces fullWidth value={author} onChange={(e) => setAuthor(e.target.value)} - disabled={uploading} + disabled={uploadMutation.isPending} /> {error && ( @@ -131,7 +134,7 @@ const UploadModal: React.FC = ({ open, onClose, onUploadSucces )} - {uploading && ( + {uploadMutation.isPending && ( @@ -142,13 +145,13 @@ const UploadModal: React.FC = ({ open, onClose, onUploadSucces - + diff --git a/frontend/src/contexts/CollectionContext.tsx b/frontend/src/contexts/CollectionContext.tsx index 55d11be..49e6618 100644 --- a/frontend/src/contexts/CollectionContext.tsx +++ b/frontend/src/contexts/CollectionContext.tsx @@ -1,8 +1,8 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import axios from 'axios'; -import React, { createContext, useContext, useEffect, useState } from 'react'; +import React, { createContext, useContext } from 'react'; import { Collection } from '../types'; import { useSnackbar } from './SnackbarContext'; -import { useVideo } from './VideoContext'; 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 }) => { const { showSnackbar } = useSnackbar(); - const { fetchVideos } = useVideo(); - const [collections, setCollections] = useState([]); + const queryClient = useQueryClient(); + + 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 () => { - try { - const response = await axios.get(`${API_URL}/collections`); - setCollections(response.data); - } catch (error) { - console.error('Error fetching collections:', error); - } + await fetchCollectionsQuery(); }; - const createCollection = async (name: string, videoId: string) => { - try { + const createCollectionMutation = useMutation({ + mutationFn: async ({ name, videoId }: { name: string, videoId: string }) => { const response = await axios.post(`${API_URL}/collections`, { name, videoId }); - setCollections(prevCollections => [...prevCollections, response.data]); - showSnackbar('Collection created successfully'); return response.data; - } catch (error) { + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['collections'] }); + showSnackbar('Collection created successfully'); + }, + onError: (error) => { console.error('Error creating collection:', error); + } + }); + + const createCollection = async (name: string, videoId: string) => { + try { + return await createCollectionMutation.mutateAsync({ name, videoId }); + } catch { return null; } }; - const addToCollection = async (collectionId: string, videoId: string) => { - try { + const addToCollectionMutation = useMutation({ + mutationFn: async ({ collectionId, videoId }: { collectionId: string, videoId: string }) => { const response = await axios.put(`${API_URL}/collections/${collectionId}`, { videoId, action: "add" }); - setCollections(prevCollections => prevCollections.map(collection => - collection.id === collectionId ? response.data : collection - )); - showSnackbar('Video added to collection'); 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); + } + }); + + const addToCollection = async (collectionId: string, videoId: string) => { + try { + return await addToCollectionMutation.mutateAsync({ collectionId, videoId }); + } catch { return null; } }; @@ -77,18 +97,14 @@ export const CollectionProvider: React.FC<{ children: React.ReactNode }> = ({ ch collection.videos.includes(videoId) ); - for (const collection of collectionsWithVideo) { - await axios.put(`${API_URL}/collections/${collection.id}`, { + await Promise.all(collectionsWithVideo.map(collection => + axios.put(`${API_URL}/collections/${collection.id}`, { videoId, action: "remove" - }); - } - - setCollections(prevCollections => prevCollections.map(collection => ({ - ...collection, - videos: collection.videos.filter(v => v !== videoId) - }))); + }) + )); + queryClient.invalidateQueries({ queryKey: ['collections'] }); showSnackbar('Video removed from collection'); return true; } catch (error) { @@ -97,34 +113,35 @@ export const CollectionProvider: React.FC<{ children: React.ReactNode }> = ({ ch } }; - const deleteCollection = async (collectionId: string, deleteVideos = false) => { - try { + const deleteCollectionMutation = useMutation({ + mutationFn: async ({ collectionId, deleteVideos }: { collectionId: string, deleteVideos: boolean }) => { await axios.delete(`${API_URL}/collections/${collectionId}`, { params: { deleteVideos: deleteVideos ? 'true' : 'false' } }); - - setCollections(prevCollections => - prevCollections.filter(collection => collection.id !== collectionId) - ); - + return { collectionId, deleteVideos }; + }, + onSuccess: ({ deleteVideos }) => { + queryClient.invalidateQueries({ queryKey: ['collections'] }); if (deleteVideos) { - await fetchVideos(); + queryClient.invalidateQueries({ queryKey: ['videos'] }); } - showSnackbar('Collection deleted successfully'); - return { success: true }; - } catch (error) { + }, + onError: (error) => { console.error('Error deleting 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' }; } }; - // Fetch collections on mount - useEffect(() => { - fetchCollections(); - }, []); - return ( = ({ chil const { showSnackbar } = useSnackbar(); const { fetchVideos, handleSearch, setVideos } = useVideo(); const { fetchCollections } = useCollection(); + const queryClient = useQueryClient(); // Get initial download status from localStorage const initialStatus = getStoredDownloadStatus(); - const [activeDownloads, setActiveDownloads] = useState( - initialStatus ? initialStatus.activeDownloads || [] : [] - ); - const [queuedDownloads, setQueuedDownloads] = useState( - initialStatus ? initialStatus.queuedDownloads || [] : [] - ); + + const { data: downloadStatus } = useQuery({ + queryKey: ['downloadStatus'], + queryFn: async () => { + 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 const [showBilibiliPartsModal, setShowBilibiliPartsModal] = useState(false); @@ -89,67 +98,43 @@ export const DownloadProvider: React.FC<{ children: React.ReactNode }> = ({ chil // Reference to track current download IDs for detecting completion const currentDownloadIdsRef = useRef>(new Set()); - // Add a function to check download status from the backend - const checkBackendDownloadStatus = async () => { - try { - const response = await axios.get(`${API_URL}/download-status`); - const { activeDownloads: backendActive, queuedDownloads: backendQueued } = response.data; + useEffect(() => { + const newIds = new Set([ + ...activeDownloads.map((d: DownloadInfo) => d.id), + ...queuedDownloads.map((d: DownloadInfo) => d.id) + ]); - const newActive = backendActive || []; - const newQueued = backendQueued || []; - - // Create a set of all current download IDs from the backend - const newIds = new Set([ - ...newActive.map((d: DownloadInfo) => d.id), - ...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; - } + 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 => { @@ -211,9 +196,6 @@ export const DownloadProvider: React.FC<{ children: React.ReactNode }> = ({ chil } // 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 }); // 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]); } - // 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'); return { success: true }; } catch (err: any) { @@ -293,69 +265,6 @@ export const DownloadProvider: React.FC<{ children: React.ReactNode }> = ({ chil 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 ( { export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const { showSnackbar } = useSnackbar(); const { t } = useLanguage(); - const [videos, setVideos] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const queryClient = useQueryClient(); + + // 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([]); // Search state const [searchResults, setSearchResults] = useState([]); @@ -56,31 +75,43 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre // Reference to the current search request's abort controller const searchAbortController = useRef(null); + // Wrapper for refetch to match interface const fetchVideos = async () => { - try { - 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); - } + await refetchVideos(); }; + // Emulate setVideos for compatibility + const setVideos: React.Dispatch> = (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) => { try { - setLoading(true); - await axios.delete(`${API_URL}/videos/${id}`); - setVideos(prevVideos => prevVideos.filter(video => video.id !== id)); - setLoading(false); - showSnackbar(t('videoRemovedSuccessfully')); + await deleteVideoMutation.mutateAsync(id); return { success: true }; } catch (error) { - console.error('Error deleting video:', error); - setLoading(false); 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('searchCancelled') }; - } finally { - if (searchAbortController.current && !searchAbortController.current.signal.aborted) { - setLoading(false); - } - } - }; - - // Tags state - const [availableTags, setAvailableTags] = useState([]); - const [selectedTags, setSelectedTags] = useState([]); - - 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 useEffect(() => { 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) => { try { - const response = await axios.post(`${API_URL}/videos/${id}/refresh-thumbnail`); - if (response.data.success) { - setVideos(prevVideos => prevVideos.map(video => - video.id === id - ? { ...video, thumbnailUrl: response.data.thumbnailUrl, thumbnailPath: response.data.thumbnailUrl } - : video - )); - showSnackbar(t('thumbnailRefreshed')); + const result = await refreshThumbnailMutation.mutateAsync(id); + if (result.data.success) { return { success: true }; } return { success: false, error: t('thumbnailRefreshFailed') }; } catch (error) { - console.error('Error refreshing thumbnail:', error); return { success: false, error: t('thumbnailRefreshFailed') }; } }; + const updateVideoMutation = useMutation({ + mutationFn: async ({ id, updates }: { id: string; updates: Partial