feat: refactor with Tanstack Query
This commit is contained in:
55
frontend/package-lock.json
generated
55
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user