feat: refactor with Tanstack Query

This commit is contained in:
Peifan Li
2025-11-26 22:05:36 -05:00
parent 4a86b367b1
commit 59eb6bb2ab
14 changed files with 724 additions and 726 deletions

View File

@@ -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",

View File

@@ -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",

View File

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

View File

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

View File

@@ -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<UploadModalProps> = ({ open, onClose, onUploadSucces
const [file, setFile] = useState<File | null>(null);
const [title, setTitle] = useState<string>('');
const [author, setAuthor] = useState<string>('Admin');
const [uploading, setUploading] = useState<boolean>(false);
const [progress, setProgress] = useState<number>(0);
const [error, setError] = useState<string>('');
@@ -43,22 +43,8 @@ const UploadModal: React.FC<UploadModalProps> = ({ 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<UploadModalProps> = ({ 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<UploadModalProps> = ({ open, onClose, onUploadSucces
};
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>
<DialogContent>
<Stack spacing={3} sx={{ mt: 1 }}>
@@ -114,7 +117,7 @@ const UploadModal: React.FC<UploadModalProps> = ({ open, onClose, onUploadSucces
fullWidth
value={title}
onChange={(e) => setTitle(e.target.value)}
disabled={uploading}
disabled={uploadMutation.isPending}
/>
<TextField
@@ -122,7 +125,7 @@ const UploadModal: React.FC<UploadModalProps> = ({ 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<UploadModalProps> = ({ open, onClose, onUploadSucces
</Typography>
)}
{uploading && (
{uploadMutation.isPending && (
<Box sx={{ width: '100%' }}>
<LinearProgress variant="determinate" value={progress} />
<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>
</DialogContent>
<DialogActions>
<Button onClick={handleClose} disabled={uploading}>{t('cancel')}</Button>
<Button onClick={handleClose} disabled={uploadMutation.isPending}>{t('cancel')}</Button>
<Button
onClick={handleUpload}
variant="contained"
disabled={!file || uploading}
disabled={!file || uploadMutation.isPending}
>
{uploading ? <CircularProgress size={24} /> : t('upload')}
{uploadMutation.isPending ? <CircularProgress size={24} /> : t('upload')}
</Button>
</DialogActions>
</Dialog>

View File

@@ -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<Collection[]>([]);
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 (
<CollectionContext.Provider value={{
collections,

View File

@@ -1,3 +1,4 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import React, { createContext, useContext, useEffect, useRef, useState } from 'react';
import { DownloadInfo } from '../types';
@@ -65,15 +66,23 @@ export const DownloadProvider: React.FC<{ children: React.ReactNode }> = ({ 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<DownloadInfo[]>(
initialStatus ? initialStatus.activeDownloads || [] : []
);
const [queuedDownloads, setQueuedDownloads] = useState<DownloadInfo[]>(
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<boolean>(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<Set<string>>(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<string>([
...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<string>([
...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<any> => {
@@ -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 (
<DownloadContext.Provider value={{
activeDownloads,

View File

@@ -1,3 +1,4 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import React, { createContext, useContext, useEffect, useRef, useState } from 'react';
import { Video } from '../types';
@@ -42,9 +43,27 @@ export const useVideo = () => {
export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { showSnackbar } = useSnackbar();
const { t } = useLanguage();
const [videos, setVideos] = useState<Video[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(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<string[]>([]);
// Search state
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
const searchAbortController = useRef<AbortController | null>(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<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) => {
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<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
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<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>) => {
try {
const response = await axios.put(`${API_URL}/videos/${id}`, updates);
if (response.data.success) {
setVideos(prevVideos => prevVideos.map(video =>
video.id === id ? { ...video, ...updates } : video
));
showSnackbar(t('videoUpdated'));
const result = await updateVideoMutation.mutateAsync({ id, updates });
if (result.data.success) {
return { success: true };
}
return { success: false, error: t('videoUpdateFailed') };
} catch (error) {
console.error('Error updating video:', error);
return { success: false, error: t('videoUpdateFailed') };
}
};
@@ -246,8 +282,8 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
return (
<VideoContext.Provider value={{
videos,
loading,
error,
loading: videosLoading,
error: videosError ? (videosError as Error).message : null,
fetchVideos,
deleteVideo,
updateVideo,

View File

@@ -9,62 +9,41 @@ import {
Grid,
Typography
} from '@mui/material';
import axios from 'axios';
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import VideoCard from '../components/VideoCard';
import { useLanguage } from '../contexts/LanguageContext';
import { useVideo } from '../contexts/VideoContext';
import { Collection, Video } from '../types';
const API_URL = import.meta.env.VITE_API_URL;
interface AuthorVideosProps {
videos: Video[];
videos?: Video[]; // Make optional since we can get from context
onDeleteVideo: (id: string) => Promise<any>;
collections: Collection[];
}
const AuthorVideos: React.FC<AuthorVideosProps> = ({ videos: allVideos, onDeleteVideo, collections = [] }) => {
const AuthorVideos: React.FC<AuthorVideosProps> = ({ videos: propVideos, onDeleteVideo, collections = [] }) => {
const { t } = useLanguage();
const { author } = useParams<{ author: string }>();
const navigate = useNavigate();
const { videos: contextVideos, loading: contextLoading } = useVideo();
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(() => {
if (!author) return;
// If videos are passed as props, filter them
if (allVideos && allVideos.length > 0) {
const filteredVideos = allVideos.filter(
if (videos) {
const filteredVideos = videos.filter(
video => video.author === author
);
setAuthorVideos(filteredVideos);
setLoading(false);
return;
}
// 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]);
}, [author, videos]);
const handleBack = () => {
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
const filteredVideos = authorVideos.filter(video => {
// If the video is not in any collection, show it

View File

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

View File

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

View File

@@ -23,6 +23,7 @@ import {
TextField,
Typography
} from '@mui/material';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
@@ -46,6 +47,10 @@ interface Settings {
}
const SettingsPage: React.FC = () => {
const queryClient = useQueryClient();
const { t, setLanguage } = useLanguage();
const { activeDownloads } = useDownload();
const [settings, setSettings] = useState<Settings>({
loginEnabled: false,
password: '',
@@ -56,7 +61,6 @@ const SettingsPage: React.FC = () => {
tags: []
});
const [newTag, setNewTag] = useState('');
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ text: string; type: 'success' | 'error' | 'warning' | 'info' } | null>(null);
// Modal states
@@ -72,51 +76,191 @@ const SettingsPage: React.FC = () => {
const [debugMode, setDebugMode] = useState(ConsoleManager.getDebugMode());
const { t, setLanguage } = useLanguage();
const { activeDownloads } = useDownload();
// Fetch settings
const { data: settingsData } = useQuery({
queryKey: ['settings'],
queryFn: async () => {
const response = await axios.get(`${API_URL}/settings`);
return response.data;
}
});
useEffect(() => {
fetchSettings();
}, []);
const fetchSettings = async () => {
try {
const response = await axios.get(`${API_URL}/settings`);
if (settingsData) {
setSettings({
...response.data,
tags: response.data.tags || []
...settingsData,
tags: settingsData.tags || []
});
} catch (error) {
console.error('Error fetching settings:', error);
setMessage({ text: t('settingsFailed'), type: 'error' });
} finally {
// Loading finished
}
};
}, [settingsData]);
const handleSave = async () => {
setSaving(true);
try {
// Save settings mutation
const saveMutation = useMutation({
mutationFn: async (newSettings: Settings) => {
// Only send password if it has been changed (is not empty)
const settingsToSend = { ...settings };
const settingsToSend = { ...newSettings };
if (!settingsToSend.password) {
delete settingsToSend.password;
}
console.log('Saving settings:', settingsToSend);
await axios.post(`${API_URL}/settings`, settingsToSend);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['settings'] });
setMessage({ text: t('settingsSaved'), type: 'success' });
// Clear password field after save
setSettings(prev => ({ ...prev, password: '', isPasswordSet: true }));
} catch (error) {
console.error('Error saving settings:', error);
},
onError: () => {
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) => {
setSettings(prev => ({ ...prev, [field]: value }));
if (field === 'language') {
@@ -137,6 +281,8 @@ const SettingsPage: React.FC = () => {
setSettings(prev => ({ ...prev, tags: updatedTags }));
};
const isSaving = saveMutation.isPending || scanMutation.isPending || migrateMutation.isPending || cleanupMutation.isPending || deleteLegacyMutation.isPending;
return (
<Container maxWidth="xl" sx={{ py: 4 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4 }}>
@@ -314,7 +460,7 @@ const SettingsPage: React.FC = () => {
variant="outlined"
color="warning"
onClick={() => setShowCleanupTempFilesModal(true)}
disabled={saving || activeDownloads.length > 0}
disabled={isSaving || activeDownloads.length > 0}
>
{t('cleanupTempFiles')}
</Button>
@@ -333,7 +479,7 @@ const SettingsPage: React.FC = () => {
variant="outlined"
color="warning"
onClick={() => setShowMigrateConfirmModal(true)}
disabled={saving}
disabled={isSaving}
>
{t('migrateDataButton')}
</Button>
@@ -341,31 +487,8 @@ const SettingsPage: React.FC = () => {
<Button
variant="outlined"
color="primary"
onClick={async () => {
setSaving(true);
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}
onClick={() => scanMutation.mutate()}
disabled={isSaving}
sx={{ ml: 2 }}
>
{t('scanFiles')}
@@ -380,7 +503,7 @@ const SettingsPage: React.FC = () => {
variant="outlined"
color="error"
onClick={() => setShowDeleteLegacyModal(true)}
disabled={saving}
disabled={isSaving}
>
{t('deleteLegacyDataButton')}
</Button>
@@ -416,9 +539,9 @@ const SettingsPage: React.FC = () => {
size="large"
startIcon={<Save />}
onClick={handleSave}
disabled={saving}
disabled={isSaving}
>
{saving ? t('saving') : t('saveSettings')}
{isSaving ? t('saving') : t('saveSettings')}
</Button>
</Box>
</Grid>
@@ -439,42 +562,9 @@ const SettingsPage: React.FC = () => {
<ConfirmationModal
isOpen={showDeleteLegacyModal}
onClose={() => setShowDeleteLegacyModal(false)}
onConfirm={async () => {
setSaving(true);
try {
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);
}
onConfirm={() => {
setShowDeleteLegacyModal(false);
deleteLegacyMutation.mutate();
}}
title={t('removeLegacyDataConfirmTitle')}
message={t('removeLegacyDataConfirmMessage')}
@@ -487,58 +577,9 @@ const SettingsPage: React.FC = () => {
<ConfirmationModal
isOpen={showMigrateConfirmModal}
onClose={() => setShowMigrateConfirmModal(false)}
onConfirm={async () => {
setSaving(true);
try {
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);
}
onConfirm={() => {
setShowMigrateConfirmModal(false);
migrateMutation.mutate();
}}
title={t('migrateDataButton')}
message={t('migrateConfirmation')}
@@ -550,38 +591,9 @@ const SettingsPage: React.FC = () => {
<ConfirmationModal
isOpen={showCleanupTempFilesModal}
onClose={() => setShowCleanupTempFilesModal(false)}
onConfirm={async () => {
setSaving(true);
try {
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);
}
onConfirm={() => {
setShowCleanupTempFilesModal(false);
cleanupMutation.mutate();
}}
title={t('cleanupTempFilesConfirmTitle')}
message={t('cleanupTempFilesConfirmMessage')}

View File

@@ -11,6 +11,7 @@ import {
Stack,
Typography
} from '@mui/material';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
@@ -21,7 +22,7 @@ import VideoControls from '../components/VideoPlayer/VideoControls';
import VideoInfo from '../components/VideoPlayer/VideoInfo';
import { useLanguage } from '../contexts/LanguageContext';
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 BACKEND_URL = import.meta.env.VITE_BACKEND_URL;
@@ -47,21 +48,11 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
const navigate = useNavigate();
const { t } = useLanguage();
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 [videoCollections, setVideoCollections] = useState<Collection[]>([]);
const [comments, setComments] = useState<Comment[]>([]);
const [loadingComments, setLoadingComments] = 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
const [confirmationModal, setConfirmationModal] = useState({
@@ -74,84 +65,54 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
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(() => {
// Don't try to fetch the video if it's being deleted
if (isDeleting) {
return;
if (error) {
const timer = setTimeout(() => {
navigate('/');
}, 3000);
return () => clearTimeout(timer);
}
}, [error, navigate]);
const fetchVideo = async () => {
if (!id) return;
// Fetch settings
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 foundVideo = videos.find(v => v.id === id);
const autoPlay = settings?.defaultAutoPlay || false;
const autoLoop = settings?.defaultAutoLoop || false;
const availableTags = settings?.tags || [];
if (foundVideo) {
setVideo(foundVideo);
setLoading(false);
return;
}
// 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 {
// Fetch comments
const { data: comments = [], isLoading: loadingComments } = useQuery({
queryKey: ['comments', id],
queryFn: async () => {
const response = await axios.get(`${API_URL}/videos/${id}/comments`);
setComments(response.data);
setCommentsLoaded(true);
} catch (err) {
console.error('Error fetching comments:', err);
// We don't set a global error here as comments are secondary
} finally {
setLoadingComments(false);
}
};
return response.data;
},
enabled: showComments && !!id
});
const handleToggleComments = () => {
if (!showComments && !commentsLoaded) {
fetchComments();
}
setShowComments(!showComments);
};
@@ -191,27 +152,21 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
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 () => {
if (!id) return;
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);
}
await deleteMutation.mutateAsync(id);
};
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) => {
if (!id) return;
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);
}
await ratingMutation.mutateAsync(newValue);
};
// 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) => {
if (!id) return;
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');
}
await titleMutation.mutateAsync(newTitle);
};
// 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[]) => {
if (!id) return;
try {
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');
}
await tagsMutation.mutateAsync(newTags);
};
const [hasViewed, setHasViewed] = useState<boolean>(false);
@@ -342,7 +317,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
axios.post(`${API_URL}/videos/${id}/view`)
.then(res => {
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));
@@ -369,7 +344,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
if (error || !video) {
return (
<Container sx={{ mt: 4 }}>
<Alert severity="error">{error || t('videoNotFound')}</Alert>
<Alert severity="error">{t('videoNotFoundOrLoaded')}</Alert>
</Container>
);
}
@@ -397,8 +372,8 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
onAuthorClick={handleAuthorClick}
onAddToCollection={handleAddToCollection}
onDelete={handleDelete}
isDeleting={isDeleting}
deleteError={deleteError}
isDeleting={deleteMutation.isPending}
deleteError={deleteMutation.error ? (deleteMutation.error as any).message || t('deleteFailed') : null}
videoCollections={videoCollections}
onCollectionClick={handleCollectionClick}
availableTags={availableTags}

View File

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