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",
|
"@emotion/styled": "^11.14.1",
|
||||||
"@mui/icons-material": "^7.3.5",
|
"@mui/icons-material": "^7.3.5",
|
||||||
"@mui/material": "^7.3.5",
|
"@mui/material": "^7.3.5",
|
||||||
|
"@tanstack/react-query": "^5.90.11",
|
||||||
|
"@tanstack/react-query-devtools": "^5.91.1",
|
||||||
"axios": "^1.8.1",
|
"axios": "^1.8.1",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"framer-motion": "^12.23.24",
|
"framer-motion": "^12.23.24",
|
||||||
@@ -1669,6 +1671,59 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/query-core": {
|
||||||
|
"version": "5.90.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.11.tgz",
|
||||||
|
"integrity": "sha512-f9z/nXhCgWDF4lHqgIE30jxLe4sYv15QodfdPDKYAk7nAEjNcndy4dHz3ezhdUaR23BpWa4I2EH4/DZ0//Uf8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tanstack/query-devtools": {
|
||||||
|
"version": "5.91.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.91.1.tgz",
|
||||||
|
"integrity": "sha512-l8bxjk6BMsCaVQH6NzQEE/bEgFy1hAs5qbgXl0xhzezlaQbPk6Mgz9BqEg2vTLPOHD8N4k+w/gdgCbEzecGyNg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tanstack/react-query": {
|
||||||
|
"version": "5.90.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.11.tgz",
|
||||||
|
"integrity": "sha512-3uyzz01D1fkTLXuxF3JfoJoHQMU2fxsfJwE+6N5hHy0dVNoZOvwKP8Z2k7k1KDeD54N20apcJnG75TBAStIrBA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/query-core": "5.90.11"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18 || ^19"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tanstack/react-query-devtools": {
|
||||||
|
"version": "5.91.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.91.1.tgz",
|
||||||
|
"integrity": "sha512-tRnJYwEbH0kAOuToy8Ew7bJw1lX3AjkkgSlf/vzb+NpnqmHPdWM+lA2DSdGQSLi1SU0PDRrrCI1vnZnci96CsQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/query-devtools": "5.91.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tanstack/react-query": "^5.90.10",
|
||||||
|
"react": "^18 || ^19"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/babel__core": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
"@mui/icons-material": "^7.3.5",
|
"@mui/icons-material": "^7.3.5",
|
||||||
"@mui/material": "^7.3.5",
|
"@mui/material": "^7.3.5",
|
||||||
|
"@tanstack/react-query": "^5.90.11",
|
||||||
|
"@tanstack/react-query-devtools": "^5.91.1",
|
||||||
"axios": "^1.8.1",
|
"axios": "^1.8.1",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"framer-motion": "^12.23.24",
|
"framer-motion": "^12.23.24",
|
||||||
|
|||||||
@@ -194,19 +194,25 @@ function AppContent() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<LanguageProvider>
|
<QueryClientProvider client={queryClient}>
|
||||||
<SnackbarProvider>
|
<LanguageProvider>
|
||||||
<VideoProvider>
|
<SnackbarProvider>
|
||||||
<CollectionProvider>
|
<VideoProvider>
|
||||||
<DownloadProvider>
|
<CollectionProvider>
|
||||||
<AppContent />
|
<DownloadProvider>
|
||||||
</DownloadProvider>
|
<AppContent />
|
||||||
</CollectionProvider>
|
</DownloadProvider>
|
||||||
</VideoProvider>
|
</CollectionProvider>
|
||||||
</SnackbarProvider>
|
</VideoProvider>
|
||||||
</LanguageProvider>
|
</SnackbarProvider>
|
||||||
|
</LanguageProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ const Header: React.FC<HeaderProps> = ({
|
|||||||
const { availableTags, selectedTags, handleTagToggle } = useVideo();
|
const { availableTags, selectedTags, handleTagToggle } = useVideo();
|
||||||
|
|
||||||
|
|
||||||
const isDownloading = activeDownloads.length > 0 || queuedDownloads.length > 0;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('Header props:', { activeDownloads, queuedDownloads });
|
console.log('Header props:', { activeDownloads, queuedDownloads });
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
TextField,
|
TextField,
|
||||||
Typography
|
Typography
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
@@ -29,7 +30,6 @@ const UploadModal: React.FC<UploadModalProps> = ({ open, onClose, onUploadSucces
|
|||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const [title, setTitle] = useState<string>('');
|
const [title, setTitle] = useState<string>('');
|
||||||
const [author, setAuthor] = useState<string>('Admin');
|
const [author, setAuthor] = useState<string>('Admin');
|
||||||
const [uploading, setUploading] = useState<boolean>(false);
|
|
||||||
const [progress, setProgress] = useState<number>(0);
|
const [progress, setProgress] = useState<number>(0);
|
||||||
const [error, setError] = useState<string>('');
|
const [error, setError] = useState<string>('');
|
||||||
|
|
||||||
@@ -43,22 +43,8 @@ const UploadModal: React.FC<UploadModalProps> = ({ open, onClose, onUploadSucces
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpload = async () => {
|
const uploadMutation = useMutation({
|
||||||
if (!file) {
|
mutationFn: async (formData: FormData) => {
|
||||||
setError(t('pleaseSelectVideo'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setUploading(true);
|
|
||||||
setError('');
|
|
||||||
setProgress(0);
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('video', file);
|
|
||||||
formData.append('title', title);
|
|
||||||
formData.append('author', author);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await axios.post(`${API_URL}/upload`, formData, {
|
await axios.post(`${API_URL}/upload`, formData, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'multipart/form-data',
|
'Content-Type': 'multipart/form-data',
|
||||||
@@ -68,15 +54,32 @@ const UploadModal: React.FC<UploadModalProps> = ({ open, onClose, onUploadSucces
|
|||||||
setProgress(percentCompleted);
|
setProgress(percentCompleted);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
onUploadSuccess();
|
onUploadSuccess();
|
||||||
handleClose();
|
handleClose();
|
||||||
} catch (err: any) {
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
console.error('Upload failed:', err);
|
console.error('Upload failed:', err);
|
||||||
setError(err.response?.data?.error || t('failedToUpload'));
|
setError(err.response?.data?.error || t('failedToUpload'));
|
||||||
} finally {
|
|
||||||
setUploading(false);
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleUpload = () => {
|
||||||
|
if (!file) {
|
||||||
|
setError(t('pleaseSelectVideo'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError('');
|
||||||
|
setProgress(0);
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('video', file);
|
||||||
|
formData.append('title', title);
|
||||||
|
formData.append('author', author);
|
||||||
|
|
||||||
|
uploadMutation.mutate(formData);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
@@ -89,7 +92,7 @@ const UploadModal: React.FC<UploadModalProps> = ({ open, onClose, onUploadSucces
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={!uploading ? handleClose : undefined} maxWidth="sm" fullWidth>
|
<Dialog open={open} onClose={!uploadMutation.isPending ? handleClose : undefined} maxWidth="sm" fullWidth>
|
||||||
<DialogTitle>{t('uploadVideo')}</DialogTitle>
|
<DialogTitle>{t('uploadVideo')}</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<Stack spacing={3} sx={{ mt: 1 }}>
|
<Stack spacing={3} sx={{ mt: 1 }}>
|
||||||
@@ -114,7 +117,7 @@ const UploadModal: React.FC<UploadModalProps> = ({ open, onClose, onUploadSucces
|
|||||||
fullWidth
|
fullWidth
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
disabled={uploading}
|
disabled={uploadMutation.isPending}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
@@ -122,7 +125,7 @@ const UploadModal: React.FC<UploadModalProps> = ({ open, onClose, onUploadSucces
|
|||||||
fullWidth
|
fullWidth
|
||||||
value={author}
|
value={author}
|
||||||
onChange={(e) => setAuthor(e.target.value)}
|
onChange={(e) => setAuthor(e.target.value)}
|
||||||
disabled={uploading}
|
disabled={uploadMutation.isPending}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@@ -131,7 +134,7 @@ const UploadModal: React.FC<UploadModalProps> = ({ open, onClose, onUploadSucces
|
|||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{uploading && (
|
{uploadMutation.isPending && (
|
||||||
<Box sx={{ width: '100%' }}>
|
<Box sx={{ width: '100%' }}>
|
||||||
<LinearProgress variant="determinate" value={progress} />
|
<LinearProgress variant="determinate" value={progress} />
|
||||||
<Typography variant="caption" color="text.secondary" align="center" display="block" sx={{ mt: 1 }}>
|
<Typography variant="caption" color="text.secondary" align="center" display="block" sx={{ mt: 1 }}>
|
||||||
@@ -142,13 +145,13 @@ const UploadModal: React.FC<UploadModalProps> = ({ open, onClose, onUploadSucces
|
|||||||
</Stack>
|
</Stack>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={handleClose} disabled={uploading}>{t('cancel')}</Button>
|
<Button onClick={handleClose} disabled={uploadMutation.isPending}>{t('cancel')}</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleUpload}
|
onClick={handleUpload}
|
||||||
variant="contained"
|
variant="contained"
|
||||||
disabled={!file || uploading}
|
disabled={!file || uploadMutation.isPending}
|
||||||
>
|
>
|
||||||
{uploading ? <CircularProgress size={24} /> : t('upload')}
|
{uploadMutation.isPending ? <CircularProgress size={24} /> : t('upload')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
import React, { createContext, useContext } from 'react';
|
||||||
import { Collection } from '../types';
|
import { Collection } from '../types';
|
||||||
import { useSnackbar } from './SnackbarContext';
|
import { useSnackbar } from './SnackbarContext';
|
||||||
import { useVideo } from './VideoContext';
|
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL;
|
const API_URL = import.meta.env.VITE_API_URL;
|
||||||
|
|
||||||
@@ -27,46 +27,66 @@ export const useCollection = () => {
|
|||||||
|
|
||||||
export const CollectionProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
export const CollectionProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
const { showSnackbar } = useSnackbar();
|
const { showSnackbar } = useSnackbar();
|
||||||
const { fetchVideos } = useVideo();
|
const queryClient = useQueryClient();
|
||||||
const [collections, setCollections] = useState<Collection[]>([]);
|
|
||||||
|
const { data: collections = [], refetch: fetchCollectionsQuery } = useQuery({
|
||||||
|
queryKey: ['collections'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await axios.get(`${API_URL}/collections`);
|
||||||
|
return response.data as Collection[];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const fetchCollections = async () => {
|
const fetchCollections = async () => {
|
||||||
try {
|
await fetchCollectionsQuery();
|
||||||
const response = await axios.get(`${API_URL}/collections`);
|
|
||||||
setCollections(response.data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching collections:', error);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const createCollection = async (name: string, videoId: string) => {
|
const createCollectionMutation = useMutation({
|
||||||
try {
|
mutationFn: async ({ name, videoId }: { name: string, videoId: string }) => {
|
||||||
const response = await axios.post(`${API_URL}/collections`, {
|
const response = await axios.post(`${API_URL}/collections`, {
|
||||||
name,
|
name,
|
||||||
videoId
|
videoId
|
||||||
});
|
});
|
||||||
setCollections(prevCollections => [...prevCollections, response.data]);
|
|
||||||
showSnackbar('Collection created successfully');
|
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['collections'] });
|
||||||
|
showSnackbar('Collection created successfully');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
console.error('Error creating collection:', error);
|
console.error('Error creating collection:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const createCollection = async (name: string, videoId: string) => {
|
||||||
|
try {
|
||||||
|
return await createCollectionMutation.mutateAsync({ name, videoId });
|
||||||
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const addToCollection = async (collectionId: string, videoId: string) => {
|
const addToCollectionMutation = useMutation({
|
||||||
try {
|
mutationFn: async ({ collectionId, videoId }: { collectionId: string, videoId: string }) => {
|
||||||
const response = await axios.put(`${API_URL}/collections/${collectionId}`, {
|
const response = await axios.put(`${API_URL}/collections/${collectionId}`, {
|
||||||
videoId,
|
videoId,
|
||||||
action: "add"
|
action: "add"
|
||||||
});
|
});
|
||||||
setCollections(prevCollections => prevCollections.map(collection =>
|
|
||||||
collection.id === collectionId ? response.data : collection
|
|
||||||
));
|
|
||||||
showSnackbar('Video added to collection');
|
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['collections'] });
|
||||||
|
showSnackbar('Video added to collection');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
console.error('Error adding video to collection:', error);
|
console.error('Error adding video to collection:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const addToCollection = async (collectionId: string, videoId: string) => {
|
||||||
|
try {
|
||||||
|
return await addToCollectionMutation.mutateAsync({ collectionId, videoId });
|
||||||
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -77,18 +97,14 @@ export const CollectionProvider: React.FC<{ children: React.ReactNode }> = ({ ch
|
|||||||
collection.videos.includes(videoId)
|
collection.videos.includes(videoId)
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const collection of collectionsWithVideo) {
|
await Promise.all(collectionsWithVideo.map(collection =>
|
||||||
await axios.put(`${API_URL}/collections/${collection.id}`, {
|
axios.put(`${API_URL}/collections/${collection.id}`, {
|
||||||
videoId,
|
videoId,
|
||||||
action: "remove"
|
action: "remove"
|
||||||
});
|
})
|
||||||
}
|
));
|
||||||
|
|
||||||
setCollections(prevCollections => prevCollections.map(collection => ({
|
|
||||||
...collection,
|
|
||||||
videos: collection.videos.filter(v => v !== videoId)
|
|
||||||
})));
|
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['collections'] });
|
||||||
showSnackbar('Video removed from collection');
|
showSnackbar('Video removed from collection');
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -97,34 +113,35 @@ export const CollectionProvider: React.FC<{ children: React.ReactNode }> = ({ ch
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteCollection = async (collectionId: string, deleteVideos = false) => {
|
const deleteCollectionMutation = useMutation({
|
||||||
try {
|
mutationFn: async ({ collectionId, deleteVideos }: { collectionId: string, deleteVideos: boolean }) => {
|
||||||
await axios.delete(`${API_URL}/collections/${collectionId}`, {
|
await axios.delete(`${API_URL}/collections/${collectionId}`, {
|
||||||
params: { deleteVideos: deleteVideos ? 'true' : 'false' }
|
params: { deleteVideos: deleteVideos ? 'true' : 'false' }
|
||||||
});
|
});
|
||||||
|
return { collectionId, deleteVideos };
|
||||||
setCollections(prevCollections =>
|
},
|
||||||
prevCollections.filter(collection => collection.id !== collectionId)
|
onSuccess: ({ deleteVideos }) => {
|
||||||
);
|
queryClient.invalidateQueries({ queryKey: ['collections'] });
|
||||||
|
|
||||||
if (deleteVideos) {
|
if (deleteVideos) {
|
||||||
await fetchVideos();
|
queryClient.invalidateQueries({ queryKey: ['videos'] });
|
||||||
}
|
}
|
||||||
|
|
||||||
showSnackbar('Collection deleted successfully');
|
showSnackbar('Collection deleted successfully');
|
||||||
return { success: true };
|
},
|
||||||
} catch (error) {
|
onError: (error) => {
|
||||||
console.error('Error deleting collection:', error);
|
console.error('Error deleting collection:', error);
|
||||||
showSnackbar('Failed to delete collection', 'error');
|
showSnackbar('Failed to delete collection', 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteCollection = async (collectionId: string, deleteVideos = false) => {
|
||||||
|
try {
|
||||||
|
await deleteCollectionMutation.mutateAsync({ collectionId, deleteVideos });
|
||||||
|
return { success: true };
|
||||||
|
} catch {
|
||||||
return { success: false, error: 'Failed to delete collection' };
|
return { success: false, error: 'Failed to delete collection' };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fetch collections on mount
|
|
||||||
useEffect(() => {
|
|
||||||
fetchCollections();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CollectionContext.Provider value={{
|
<CollectionContext.Provider value={{
|
||||||
collections,
|
collections,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import React, { createContext, useContext, useEffect, useRef, useState } from 'react';
|
import React, { createContext, useContext, useEffect, useRef, useState } from 'react';
|
||||||
import { DownloadInfo } from '../types';
|
import { DownloadInfo } from '../types';
|
||||||
@@ -65,15 +66,23 @@ export const DownloadProvider: React.FC<{ children: React.ReactNode }> = ({ chil
|
|||||||
const { showSnackbar } = useSnackbar();
|
const { showSnackbar } = useSnackbar();
|
||||||
const { fetchVideos, handleSearch, setVideos } = useVideo();
|
const { fetchVideos, handleSearch, setVideos } = useVideo();
|
||||||
const { fetchCollections } = useCollection();
|
const { fetchCollections } = useCollection();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// Get initial download status from localStorage
|
// Get initial download status from localStorage
|
||||||
const initialStatus = getStoredDownloadStatus();
|
const initialStatus = getStoredDownloadStatus();
|
||||||
const [activeDownloads, setActiveDownloads] = useState<DownloadInfo[]>(
|
|
||||||
initialStatus ? initialStatus.activeDownloads || [] : []
|
const { data: downloadStatus } = useQuery({
|
||||||
);
|
queryKey: ['downloadStatus'],
|
||||||
const [queuedDownloads, setQueuedDownloads] = useState<DownloadInfo[]>(
|
queryFn: async () => {
|
||||||
initialStatus ? initialStatus.queuedDownloads || [] : []
|
const response = await axios.get(`${API_URL}/download-status`);
|
||||||
);
|
return response.data;
|
||||||
|
},
|
||||||
|
refetchInterval: 2000,
|
||||||
|
initialData: initialStatus || { activeDownloads: [], queuedDownloads: [] }
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeDownloads = downloadStatus.activeDownloads || [];
|
||||||
|
const queuedDownloads = downloadStatus.queuedDownloads || [];
|
||||||
|
|
||||||
// Bilibili multi-part video state
|
// Bilibili multi-part video state
|
||||||
const [showBilibiliPartsModal, setShowBilibiliPartsModal] = useState<boolean>(false);
|
const [showBilibiliPartsModal, setShowBilibiliPartsModal] = useState<boolean>(false);
|
||||||
@@ -89,67 +98,43 @@ export const DownloadProvider: React.FC<{ children: React.ReactNode }> = ({ chil
|
|||||||
// Reference to track current download IDs for detecting completion
|
// Reference to track current download IDs for detecting completion
|
||||||
const currentDownloadIdsRef = useRef<Set<string>>(new Set());
|
const currentDownloadIdsRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
// Add a function to check download status from the backend
|
useEffect(() => {
|
||||||
const checkBackendDownloadStatus = async () => {
|
const newIds = new Set<string>([
|
||||||
try {
|
...activeDownloads.map((d: DownloadInfo) => d.id),
|
||||||
const response = await axios.get(`${API_URL}/download-status`);
|
...queuedDownloads.map((d: DownloadInfo) => d.id)
|
||||||
const { activeDownloads: backendActive, queuedDownloads: backendQueued } = response.data;
|
]);
|
||||||
|
|
||||||
const newActive = backendActive || [];
|
let hasCompleted = false;
|
||||||
const newQueued = backendQueued || [];
|
if (currentDownloadIdsRef.current.size > 0) {
|
||||||
|
for (const id of currentDownloadIdsRef.current) {
|
||||||
// Create a set of all current download IDs from the backend
|
if (!newIds.has(id)) {
|
||||||
const newIds = new Set<string>([
|
hasCompleted = true;
|
||||||
...newActive.map((d: DownloadInfo) => d.id),
|
break;
|
||||||
...newQueued.map((d: DownloadInfo) => d.id)
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Check if any ID from the previous check is missing in the new check
|
|
||||||
// This implies it finished (or failed), so we should refresh the video list
|
|
||||||
let hasCompleted = false;
|
|
||||||
if (currentDownloadIdsRef.current.size > 0) {
|
|
||||||
for (const id of currentDownloadIdsRef.current) {
|
|
||||||
if (!newIds.has(id)) {
|
|
||||||
hasCompleted = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the ref for the next check
|
|
||||||
currentDownloadIdsRef.current = newIds;
|
|
||||||
|
|
||||||
if (hasCompleted) {
|
|
||||||
console.log('Download completed, refreshing videos');
|
|
||||||
fetchVideos();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newActive.length > 0 || newQueued.length > 0) {
|
|
||||||
// If backend has active or queued downloads, update the local status
|
|
||||||
setActiveDownloads(newActive);
|
|
||||||
setQueuedDownloads(newQueued);
|
|
||||||
|
|
||||||
// Save to localStorage for persistence
|
|
||||||
const statusData = {
|
|
||||||
activeDownloads: newActive,
|
|
||||||
queuedDownloads: newQueued,
|
|
||||||
timestamp: Date.now()
|
|
||||||
};
|
|
||||||
localStorage.setItem(DOWNLOAD_STATUS_KEY, JSON.stringify(statusData));
|
|
||||||
} else {
|
|
||||||
// If backend says no downloads are in progress, clear the status
|
|
||||||
if (activeDownloads.length > 0 || queuedDownloads.length > 0) {
|
|
||||||
console.log('Backend says downloads are complete, clearing status');
|
|
||||||
localStorage.removeItem(DOWNLOAD_STATUS_KEY);
|
|
||||||
setActiveDownloads([]);
|
|
||||||
setQueuedDownloads([]);
|
|
||||||
// Refresh videos list when downloads complete (fallback)
|
|
||||||
fetchVideos();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error checking backend download status:', error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
currentDownloadIdsRef.current = newIds;
|
||||||
|
|
||||||
|
if (hasCompleted) {
|
||||||
|
console.log('Download completed, refreshing videos');
|
||||||
|
fetchVideos();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeDownloads.length > 0 || queuedDownloads.length > 0) {
|
||||||
|
const statusData = {
|
||||||
|
activeDownloads,
|
||||||
|
queuedDownloads,
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
localStorage.setItem(DOWNLOAD_STATUS_KEY, JSON.stringify(statusData));
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(DOWNLOAD_STATUS_KEY);
|
||||||
|
}
|
||||||
|
}, [activeDownloads, queuedDownloads, fetchVideos]);
|
||||||
|
|
||||||
|
const checkBackendDownloadStatus = async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['downloadStatus'] });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleVideoSubmit = async (videoUrl: string, skipCollectionCheck = false): Promise<any> => {
|
const handleVideoSubmit = async (videoUrl: string, skipCollectionCheck = false): Promise<any> => {
|
||||||
@@ -211,9 +196,6 @@ export const DownloadProvider: React.FC<{ children: React.ReactNode }> = ({ chil
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Normal download flow
|
// Normal download flow
|
||||||
// We don't set activeDownloads here immediately because the backend will queue it
|
|
||||||
// and we'll pick it up via polling
|
|
||||||
|
|
||||||
const response = await axios.post(`${API_URL}/download`, { youtubeUrl: videoUrl });
|
const response = await axios.post(`${API_URL}/download`, { youtubeUrl: videoUrl });
|
||||||
|
|
||||||
// If the response contains a downloadId, it means it was queued/started
|
// If the response contains a downloadId, it means it was queued/started
|
||||||
@@ -225,16 +207,6 @@ export const DownloadProvider: React.FC<{ children: React.ReactNode }> = ({ chil
|
|||||||
setVideos(prevVideos => [response.data.video, ...prevVideos]);
|
setVideos(prevVideos => [response.data.video, ...prevVideos]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the setIsSearchMode from VideoContext but we need to expose it there first
|
|
||||||
// For now, we can assume the caller handles UI state or we add it to VideoContext
|
|
||||||
// Actually, let's just use the resetSearch from VideoContext which handles search mode
|
|
||||||
// But wait, resetSearch clears everything. We just want to exit search mode.
|
|
||||||
// Let's update VideoContext to expose setIsSearchMode or handle it better.
|
|
||||||
// For now, let's assume VideoContext handles it via resetSearch if needed, or we just ignore it here
|
|
||||||
// and let the component handle UI.
|
|
||||||
// Wait, the original code called setIsSearchMode(false).
|
|
||||||
// I should add setIsSearchMode to VideoContext interface.
|
|
||||||
|
|
||||||
showSnackbar('Video downloading');
|
showSnackbar('Video downloading');
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -293,69 +265,6 @@ export const DownloadProvider: React.FC<{ children: React.ReactNode }> = ({ chil
|
|||||||
return await handleVideoSubmit(bilibiliPartsInfo.url, true);
|
return await handleVideoSubmit(bilibiliPartsInfo.url, true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check backend download status periodically
|
|
||||||
useEffect(() => {
|
|
||||||
// Check immediately on mount
|
|
||||||
checkBackendDownloadStatus();
|
|
||||||
|
|
||||||
// Then check every 2 seconds (faster polling for better UX)
|
|
||||||
const statusCheckInterval = setInterval(checkBackendDownloadStatus, 2000);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearInterval(statusCheckInterval);
|
|
||||||
};
|
|
||||||
}, [activeDownloads.length, queuedDownloads.length]);
|
|
||||||
|
|
||||||
// Set up localStorage and event listeners
|
|
||||||
useEffect(() => {
|
|
||||||
// Set up event listener for storage changes (for multi-tab support)
|
|
||||||
const handleStorageChange = (e: StorageEvent) => {
|
|
||||||
if (e.key === DOWNLOAD_STATUS_KEY) {
|
|
||||||
try {
|
|
||||||
const newStatus = e.newValue ? JSON.parse(e.newValue) : { activeDownloads: [], queuedDownloads: [] };
|
|
||||||
setActiveDownloads(newStatus.activeDownloads || []);
|
|
||||||
setQueuedDownloads(newStatus.queuedDownloads || []);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error handling storage change:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('storage', handleStorageChange);
|
|
||||||
|
|
||||||
// Set up periodic check for stale download status
|
|
||||||
const checkDownloadStatus = () => {
|
|
||||||
const status = getStoredDownloadStatus();
|
|
||||||
if (!status && (activeDownloads.length > 0 || queuedDownloads.length > 0)) {
|
|
||||||
console.log('Clearing stale download status');
|
|
||||||
setActiveDownloads([]);
|
|
||||||
setQueuedDownloads([]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check every minute
|
|
||||||
const statusCheckInterval = setInterval(checkDownloadStatus, 60000);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('storage', handleStorageChange);
|
|
||||||
clearInterval(statusCheckInterval);
|
|
||||||
};
|
|
||||||
}, [activeDownloads, queuedDownloads]);
|
|
||||||
|
|
||||||
// Update localStorage whenever activeDownloads or queuedDownloads changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeDownloads.length > 0 || queuedDownloads.length > 0) {
|
|
||||||
const statusData = {
|
|
||||||
activeDownloads,
|
|
||||||
queuedDownloads,
|
|
||||||
timestamp: Date.now()
|
|
||||||
};
|
|
||||||
localStorage.setItem(DOWNLOAD_STATUS_KEY, JSON.stringify(statusData));
|
|
||||||
} else {
|
|
||||||
localStorage.removeItem(DOWNLOAD_STATUS_KEY);
|
|
||||||
}
|
|
||||||
}, [activeDownloads, queuedDownloads]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DownloadContext.Provider value={{
|
<DownloadContext.Provider value={{
|
||||||
activeDownloads,
|
activeDownloads,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import React, { createContext, useContext, useEffect, useRef, useState } from 'react';
|
import React, { createContext, useContext, useEffect, useRef, useState } from 'react';
|
||||||
import { Video } from '../types';
|
import { Video } from '../types';
|
||||||
@@ -42,9 +43,27 @@ export const useVideo = () => {
|
|||||||
export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
const { showSnackbar } = useSnackbar();
|
const { showSnackbar } = useSnackbar();
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const [videos, setVideos] = useState<Video[]>([]);
|
const queryClient = useQueryClient();
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
// Videos Query
|
||||||
|
const { data: videos = [], isLoading: videosLoading, error: videosError, refetch: refetchVideos } = useQuery({
|
||||||
|
queryKey: ['videos'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await axios.get(`${API_URL}/videos`);
|
||||||
|
return response.data as Video[];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tags Query
|
||||||
|
const { data: availableTags = [] } = useQuery({
|
||||||
|
queryKey: ['tags'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await axios.get(`${API_URL}/settings`);
|
||||||
|
return response.data.tags || [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||||
|
|
||||||
// Search state
|
// Search state
|
||||||
const [searchResults, setSearchResults] = useState<any[]>([]);
|
const [searchResults, setSearchResults] = useState<any[]>([]);
|
||||||
@@ -56,31 +75,43 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
|||||||
// Reference to the current search request's abort controller
|
// Reference to the current search request's abort controller
|
||||||
const searchAbortController = useRef<AbortController | null>(null);
|
const searchAbortController = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
// Wrapper for refetch to match interface
|
||||||
const fetchVideos = async () => {
|
const fetchVideos = async () => {
|
||||||
try {
|
await refetchVideos();
|
||||||
setLoading(true);
|
|
||||||
const response = await axios.get(`${API_URL}/videos`);
|
|
||||||
setVideos(response.data);
|
|
||||||
setError(null);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error fetching videos:', err);
|
|
||||||
setError(t('failedToLoadVideos'));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Emulate setVideos for compatibility
|
||||||
|
const setVideos: React.Dispatch<React.SetStateAction<Video[]>> = (updater) => {
|
||||||
|
queryClient.setQueryData(['videos'], (oldVideos: Video[] | undefined) => {
|
||||||
|
const currentVideos = oldVideos || [];
|
||||||
|
if (typeof updater === 'function') {
|
||||||
|
return updater(currentVideos);
|
||||||
|
}
|
||||||
|
return updater;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteVideoMutation = useMutation({
|
||||||
|
mutationFn: async (id: string) => {
|
||||||
|
await axios.delete(`${API_URL}/videos/${id}`);
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
onSuccess: (id) => {
|
||||||
|
queryClient.setQueryData(['videos'], (old: Video[] | undefined) =>
|
||||||
|
old ? old.filter(video => video.id !== id) : []
|
||||||
|
);
|
||||||
|
showSnackbar(t('videoRemovedSuccessfully'));
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('Error deleting video:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const deleteVideo = async (id: string) => {
|
const deleteVideo = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
await deleteVideoMutation.mutateAsync(id);
|
||||||
await axios.delete(`${API_URL}/videos/${id}`);
|
|
||||||
setVideos(prevVideos => prevVideos.filter(video => video.id !== id));
|
|
||||||
setLoading(false);
|
|
||||||
showSnackbar(t('videoRemovedSuccessfully'));
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting video:', error);
|
|
||||||
setLoading(false);
|
|
||||||
return { success: false, error: t('failedToDeleteVideo') };
|
return { success: false, error: t('failedToDeleteVideo') };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -161,25 +192,6 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
|||||||
return { success: false, error: t('failedToSearch') };
|
return { success: false, error: t('failedToSearch') };
|
||||||
}
|
}
|
||||||
return { success: false, error: t('searchCancelled') };
|
return { success: false, error: t('searchCancelled') };
|
||||||
} finally {
|
|
||||||
if (searchAbortController.current && !searchAbortController.current.signal.aborted) {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Tags state
|
|
||||||
const [availableTags, setAvailableTags] = useState<string[]>([]);
|
|
||||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
|
||||||
|
|
||||||
const fetchTags = async () => {
|
|
||||||
try {
|
|
||||||
const response = await axios.get(`${API_URL}/settings`);
|
|
||||||
if (response.data.tags) {
|
|
||||||
setAvailableTags(response.data.tags);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching tags:', error);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -191,12 +203,6 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fetch videos and tags on mount
|
|
||||||
useEffect(() => {
|
|
||||||
fetchVideos();
|
|
||||||
fetchTags();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Cleanup search on unmount
|
// Cleanup search on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -207,38 +213,68 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const refreshThumbnailMutation = useMutation({
|
||||||
|
mutationFn: async (id: string) => {
|
||||||
|
const response = await axios.post(`${API_URL}/videos/${id}/refresh-thumbnail`);
|
||||||
|
return { id, data: response.data };
|
||||||
|
},
|
||||||
|
onSuccess: ({ id, data }) => {
|
||||||
|
if (data.success) {
|
||||||
|
queryClient.setQueryData(['videos'], (old: Video[] | undefined) =>
|
||||||
|
old ? old.map(video =>
|
||||||
|
video.id === id
|
||||||
|
? { ...video, thumbnailUrl: data.thumbnailUrl, thumbnailPath: data.thumbnailUrl }
|
||||||
|
: video
|
||||||
|
) : []
|
||||||
|
);
|
||||||
|
showSnackbar(t('thumbnailRefreshed'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('Error refreshing thumbnail:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const refreshThumbnail = async (id: string) => {
|
const refreshThumbnail = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.post(`${API_URL}/videos/${id}/refresh-thumbnail`);
|
const result = await refreshThumbnailMutation.mutateAsync(id);
|
||||||
if (response.data.success) {
|
if (result.data.success) {
|
||||||
setVideos(prevVideos => prevVideos.map(video =>
|
|
||||||
video.id === id
|
|
||||||
? { ...video, thumbnailUrl: response.data.thumbnailUrl, thumbnailPath: response.data.thumbnailUrl }
|
|
||||||
: video
|
|
||||||
));
|
|
||||||
showSnackbar(t('thumbnailRefreshed'));
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
return { success: false, error: t('thumbnailRefreshFailed') };
|
return { success: false, error: t('thumbnailRefreshFailed') };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error refreshing thumbnail:', error);
|
|
||||||
return { success: false, error: t('thumbnailRefreshFailed') };
|
return { success: false, error: t('thumbnailRefreshFailed') };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateVideoMutation = useMutation({
|
||||||
|
mutationFn: async ({ id, updates }: { id: string; updates: Partial<Video> }) => {
|
||||||
|
const response = await axios.put(`${API_URL}/videos/${id}`, updates);
|
||||||
|
return { id, updates, data: response.data };
|
||||||
|
},
|
||||||
|
onSuccess: ({ id, updates, data }) => {
|
||||||
|
if (data.success) {
|
||||||
|
queryClient.setQueryData(['videos'], (old: Video[] | undefined) =>
|
||||||
|
old ? old.map(video =>
|
||||||
|
video.id === id ? { ...video, ...updates } : video
|
||||||
|
) : []
|
||||||
|
);
|
||||||
|
showSnackbar(t('videoUpdated'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('Error updating video:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const updateVideo = async (id: string, updates: Partial<Video>) => {
|
const updateVideo = async (id: string, updates: Partial<Video>) => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.put(`${API_URL}/videos/${id}`, updates);
|
const result = await updateVideoMutation.mutateAsync({ id, updates });
|
||||||
if (response.data.success) {
|
if (result.data.success) {
|
||||||
setVideos(prevVideos => prevVideos.map(video =>
|
|
||||||
video.id === id ? { ...video, ...updates } : video
|
|
||||||
));
|
|
||||||
showSnackbar(t('videoUpdated'));
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
return { success: false, error: t('videoUpdateFailed') };
|
return { success: false, error: t('videoUpdateFailed') };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating video:', error);
|
|
||||||
return { success: false, error: t('videoUpdateFailed') };
|
return { success: false, error: t('videoUpdateFailed') };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -246,8 +282,8 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
|||||||
return (
|
return (
|
||||||
<VideoContext.Provider value={{
|
<VideoContext.Provider value={{
|
||||||
videos,
|
videos,
|
||||||
loading,
|
loading: videosLoading,
|
||||||
error,
|
error: videosError ? (videosError as Error).message : null,
|
||||||
fetchVideos,
|
fetchVideos,
|
||||||
deleteVideo,
|
deleteVideo,
|
||||||
updateVideo,
|
updateVideo,
|
||||||
|
|||||||
@@ -9,62 +9,41 @@ import {
|
|||||||
Grid,
|
Grid,
|
||||||
Typography
|
Typography
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import axios from 'axios';
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import VideoCard from '../components/VideoCard';
|
import VideoCard from '../components/VideoCard';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
|
import { useVideo } from '../contexts/VideoContext';
|
||||||
import { Collection, Video } from '../types';
|
import { Collection, Video } from '../types';
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL;
|
|
||||||
|
|
||||||
interface AuthorVideosProps {
|
interface AuthorVideosProps {
|
||||||
videos: Video[];
|
videos?: Video[]; // Make optional since we can get from context
|
||||||
onDeleteVideo: (id: string) => Promise<any>;
|
onDeleteVideo: (id: string) => Promise<any>;
|
||||||
collections: Collection[];
|
collections: Collection[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthorVideos: React.FC<AuthorVideosProps> = ({ videos: allVideos, onDeleteVideo, collections = [] }) => {
|
const AuthorVideos: React.FC<AuthorVideosProps> = ({ videos: propVideos, onDeleteVideo, collections = [] }) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const { author } = useParams<{ author: string }>();
|
const { author } = useParams<{ author: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { videos: contextVideos, loading: contextLoading } = useVideo();
|
||||||
|
|
||||||
const [authorVideos, setAuthorVideos] = useState<Video[]>([]);
|
const [authorVideos, setAuthorVideos] = useState<Video[]>([]);
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
// Use prop videos if available, otherwise context videos
|
||||||
|
const videos = propVideos && propVideos.length > 0 ? propVideos : contextVideos;
|
||||||
|
const loading = (propVideos && propVideos.length > 0) ? false : contextLoading;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!author) return;
|
if (!author) return;
|
||||||
|
|
||||||
// If videos are passed as props, filter them
|
if (videos) {
|
||||||
if (allVideos && allVideos.length > 0) {
|
const filteredVideos = videos.filter(
|
||||||
const filteredVideos = allVideos.filter(
|
|
||||||
video => video.author === author
|
video => video.author === author
|
||||||
);
|
);
|
||||||
setAuthorVideos(filteredVideos);
|
setAuthorVideos(filteredVideos);
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
}, [author, videos]);
|
||||||
// Otherwise fetch from API
|
|
||||||
const fetchVideos = async () => {
|
|
||||||
try {
|
|
||||||
const response = await axios.get(`${API_URL}/videos`);
|
|
||||||
// Filter videos by author
|
|
||||||
const filteredVideos = response.data.filter(
|
|
||||||
(video: Video) => video.author === author
|
|
||||||
);
|
|
||||||
setAuthorVideos(filteredVideos);
|
|
||||||
setError(null);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error fetching videos:', err);
|
|
||||||
setError('Failed to load videos. Please try again later.');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchVideos();
|
|
||||||
}, [author, allVideos]);
|
|
||||||
|
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
navigate(-1);
|
navigate(-1);
|
||||||
@@ -79,14 +58,6 @@ const AuthorVideos: React.FC<AuthorVideosProps> = ({ videos: allVideos, onDelete
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<Container sx={{ mt: 4 }}>
|
|
||||||
<Alert severity="error">{t('loadVideosError')}</Alert>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter videos to only show the first video from each collection
|
// Filter videos to only show the first video from each collection
|
||||||
const filteredVideos = authorVideos.filter(video => {
|
const filteredVideos = authorVideos.filter(video => {
|
||||||
// If the video is not in any collection, show it
|
// If the video is not in any collection, show it
|
||||||
|
|||||||
@@ -20,24 +20,15 @@ import {
|
|||||||
Tabs,
|
Tabs,
|
||||||
Typography
|
Typography
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
import { useDownload } from '../contexts/DownloadContext';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
import { useSnackbar } from '../contexts/SnackbarContext';
|
import { useSnackbar } from '../contexts/SnackbarContext';
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL;
|
const API_URL = import.meta.env.VITE_API_URL;
|
||||||
|
|
||||||
interface DownloadInfo {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
timestamp: number;
|
|
||||||
filename?: string;
|
|
||||||
totalSize?: string;
|
|
||||||
downloadedSize?: string;
|
|
||||||
progress?: number;
|
|
||||||
speed?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DownloadHistoryItem {
|
interface DownloadHistoryItem {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -80,102 +71,114 @@ function CustomTabPanel(props: TabPanelProps) {
|
|||||||
const DownloadPage: React.FC = () => {
|
const DownloadPage: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const { showSnackbar } = useSnackbar();
|
const { showSnackbar } = useSnackbar();
|
||||||
|
const { activeDownloads, queuedDownloads } = useDownload();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [tabValue, setTabValue] = useState(0);
|
const [tabValue, setTabValue] = useState(0);
|
||||||
const [activeDownloads, setActiveDownloads] = useState<DownloadInfo[]>([]);
|
|
||||||
const [queuedDownloads, setQueuedDownloads] = useState<DownloadInfo[]>([]);
|
|
||||||
const [history, setHistory] = useState<DownloadHistoryItem[]>([]);
|
|
||||||
|
|
||||||
const fetchStatus = async () => {
|
// Fetch history with polling
|
||||||
try {
|
const { data: history = [] } = useQuery({
|
||||||
const response = await axios.get(`${API_URL}/download-status`);
|
queryKey: ['downloadHistory'],
|
||||||
setActiveDownloads(response.data.activeDownloads);
|
queryFn: async () => {
|
||||||
setQueuedDownloads(response.data.queuedDownloads);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching download status:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchHistory = async () => {
|
|
||||||
try {
|
|
||||||
const response = await axios.get(`${API_URL}/downloads/history`);
|
const response = await axios.get(`${API_URL}/downloads/history`);
|
||||||
setHistory(response.data);
|
return response.data;
|
||||||
} catch (error) {
|
},
|
||||||
console.error('Error fetching history:', error);
|
refetchInterval: 2000
|
||||||
}
|
});
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchStatus();
|
|
||||||
fetchHistory();
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
fetchStatus();
|
|
||||||
fetchHistory();
|
|
||||||
}, 1000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => {
|
const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => {
|
||||||
setTabValue(newValue);
|
setTabValue(newValue);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancelDownload = async (id: string) => {
|
// Cancel download mutation
|
||||||
try {
|
const cancelMutation = useMutation({
|
||||||
|
mutationFn: async (id: string) => {
|
||||||
await axios.post(`${API_URL}/downloads/cancel/${id}`);
|
await axios.post(`${API_URL}/downloads/cancel/${id}`);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
showSnackbar(t('downloadCancelled') || 'Download cancelled');
|
showSnackbar(t('downloadCancelled') || 'Download cancelled');
|
||||||
fetchStatus();
|
// DownloadContext handles active/queued updates via its own polling
|
||||||
} catch (error) {
|
// But we might want to invalidate to be sure
|
||||||
console.error('Error cancelling download:', error);
|
queryClient.invalidateQueries({ queryKey: ['downloadStatus'] });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
showSnackbar(t('error') || 'Error');
|
showSnackbar(t('error') || 'Error');
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCancelDownload = (id: string) => {
|
||||||
|
cancelMutation.mutate(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveFromQueue = async (id: string) => {
|
// Remove from queue mutation
|
||||||
try {
|
const removeFromQueueMutation = useMutation({
|
||||||
|
mutationFn: async (id: string) => {
|
||||||
await axios.delete(`${API_URL}/downloads/queue/${id}`);
|
await axios.delete(`${API_URL}/downloads/queue/${id}`);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
showSnackbar(t('removedFromQueue') || 'Removed from queue');
|
showSnackbar(t('removedFromQueue') || 'Removed from queue');
|
||||||
fetchStatus();
|
queryClient.invalidateQueries({ queryKey: ['downloadStatus'] });
|
||||||
} catch (error) {
|
},
|
||||||
console.error('Error removing from queue:', error);
|
onError: () => {
|
||||||
showSnackbar(t('error') || 'Error');
|
showSnackbar(t('error') || 'Error');
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleRemoveFromQueue = (id: string) => {
|
||||||
|
removeFromQueueMutation.mutate(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClearQueue = async () => {
|
// Clear queue mutation
|
||||||
try {
|
const clearQueueMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
await axios.delete(`${API_URL}/downloads/queue`);
|
await axios.delete(`${API_URL}/downloads/queue`);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
showSnackbar(t('queueCleared') || 'Queue cleared');
|
showSnackbar(t('queueCleared') || 'Queue cleared');
|
||||||
fetchStatus();
|
queryClient.invalidateQueries({ queryKey: ['downloadStatus'] });
|
||||||
} catch (error) {
|
},
|
||||||
console.error('Error clearing queue:', error);
|
onError: () => {
|
||||||
showSnackbar(t('error') || 'Error');
|
showSnackbar(t('error') || 'Error');
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleClearQueue = () => {
|
||||||
|
clearQueueMutation.mutate();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveFromHistory = async (id: string) => {
|
// Remove from history mutation
|
||||||
try {
|
const removeFromHistoryMutation = useMutation({
|
||||||
|
mutationFn: async (id: string) => {
|
||||||
await axios.delete(`${API_URL}/downloads/history/${id}`);
|
await axios.delete(`${API_URL}/downloads/history/${id}`);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
showSnackbar(t('removedFromHistory') || 'Removed from history');
|
showSnackbar(t('removedFromHistory') || 'Removed from history');
|
||||||
fetchHistory();
|
queryClient.invalidateQueries({ queryKey: ['downloadHistory'] });
|
||||||
} catch (error) {
|
},
|
||||||
console.error('Error removing from history:', error);
|
onError: () => {
|
||||||
showSnackbar(t('error') || 'Error');
|
showSnackbar(t('error') || 'Error');
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleRemoveFromHistory = (id: string) => {
|
||||||
|
removeFromHistoryMutation.mutate(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClearHistory = async () => {
|
// Clear history mutation
|
||||||
try {
|
const clearHistoryMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
await axios.delete(`${API_URL}/downloads/history`);
|
await axios.delete(`${API_URL}/downloads/history`);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
showSnackbar(t('historyCleared') || 'History cleared');
|
showSnackbar(t('historyCleared') || 'History cleared');
|
||||||
fetchHistory();
|
queryClient.invalidateQueries({ queryKey: ['downloadHistory'] });
|
||||||
} catch (error) {
|
},
|
||||||
console.error('Error clearing history:', error);
|
onError: () => {
|
||||||
showSnackbar(t('error') || 'Error');
|
showSnackbar(t('error') || 'Error');
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
|
|
||||||
const formatBytes = (bytes?: string | number) => {
|
const handleClearHistory = () => {
|
||||||
if (!bytes) return '-';
|
clearHistoryMutation.mutate();
|
||||||
return bytes.toString(); // Simplified, ideally use a helper
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (timestamp: number) => {
|
const formatDate = (timestamp: number) => {
|
||||||
@@ -279,7 +282,7 @@ const DownloadPage: React.FC = () => {
|
|||||||
<Typography color="textSecondary">{t('noDownloadHistory') || 'No download history'}</Typography>
|
<Typography color="textSecondary">{t('noDownloadHistory') || 'No download history'}</Typography>
|
||||||
) : (
|
) : (
|
||||||
<List>
|
<List>
|
||||||
{history.map((item) => (
|
{history.map((item: DownloadHistoryItem) => (
|
||||||
<Paper key={item.id} sx={{ mb: 2, p: 2 }}>
|
<Paper key={item.id} sx={{ mb: 2, p: 2 }}>
|
||||||
<ListItem disableGutters>
|
<ListItem disableGutters>
|
||||||
<ListItemText
|
<ListItemText
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
ThemeProvider,
|
ThemeProvider,
|
||||||
Typography
|
Typography
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
@@ -24,34 +25,37 @@ interface LoginPageProps {
|
|||||||
const LoginPage: React.FC<LoginPageProps> = ({ onLoginSuccess }) => {
|
const LoginPage: React.FC<LoginPageProps> = ({ onLoginSuccess }) => {
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
||||||
// Use dark theme for login page to match app style
|
// Use dark theme for login page to match app style
|
||||||
const theme = getTheme('dark');
|
const theme = getTheme('dark');
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const loginMutation = useMutation({
|
||||||
e.preventDefault();
|
mutationFn: async (password: string) => {
|
||||||
setLoading(true);
|
|
||||||
setError('');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await axios.post(`${API_URL}/settings/verify-password`, { password });
|
const response = await axios.post(`${API_URL}/settings/verify-password`, { password });
|
||||||
if (response.data.success) {
|
return response.data;
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
if (data.success) {
|
||||||
onLoginSuccess();
|
onLoginSuccess();
|
||||||
} else {
|
} else {
|
||||||
setError(t('incorrectPassword'));
|
setError(t('incorrectPassword'));
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
console.error('Login error:', err);
|
console.error('Login error:', err);
|
||||||
if (err.response && err.response.status === 401) {
|
if (err.response && err.response.status === 401) {
|
||||||
setError(t('incorrectPassword'));
|
setError(t('incorrectPassword'));
|
||||||
} else {
|
} else {
|
||||||
setError(t('loginFailed'));
|
setError(t('loginFailed'));
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
loginMutation.mutate(password);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -97,9 +101,9 @@ const LoginPage: React.FC<LoginPageProps> = ({ onLoginSuccess }) => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
variant="contained"
|
variant="contained"
|
||||||
sx={{ mt: 3, mb: 2 }}
|
sx={{ mt: 3, mb: 2 }}
|
||||||
disabled={loading}
|
disabled={loginMutation.isPending}
|
||||||
>
|
>
|
||||||
{loading ? t('verifying') : t('signIn')}
|
{loginMutation.isPending ? t('verifying') : t('signIn')}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
TextField,
|
TextField,
|
||||||
Typography
|
Typography
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
@@ -46,6 +47,10 @@ interface Settings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SettingsPage: React.FC = () => {
|
const SettingsPage: React.FC = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { t, setLanguage } = useLanguage();
|
||||||
|
const { activeDownloads } = useDownload();
|
||||||
|
|
||||||
const [settings, setSettings] = useState<Settings>({
|
const [settings, setSettings] = useState<Settings>({
|
||||||
loginEnabled: false,
|
loginEnabled: false,
|
||||||
password: '',
|
password: '',
|
||||||
@@ -56,7 +61,6 @@ const SettingsPage: React.FC = () => {
|
|||||||
tags: []
|
tags: []
|
||||||
});
|
});
|
||||||
const [newTag, setNewTag] = useState('');
|
const [newTag, setNewTag] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [message, setMessage] = useState<{ text: string; type: 'success' | 'error' | 'warning' | 'info' } | null>(null);
|
const [message, setMessage] = useState<{ text: string; type: 'success' | 'error' | 'warning' | 'info' } | null>(null);
|
||||||
|
|
||||||
// Modal states
|
// Modal states
|
||||||
@@ -72,51 +76,191 @@ const SettingsPage: React.FC = () => {
|
|||||||
|
|
||||||
const [debugMode, setDebugMode] = useState(ConsoleManager.getDebugMode());
|
const [debugMode, setDebugMode] = useState(ConsoleManager.getDebugMode());
|
||||||
|
|
||||||
const { t, setLanguage } = useLanguage();
|
// Fetch settings
|
||||||
const { activeDownloads } = useDownload();
|
const { data: settingsData } = useQuery({
|
||||||
|
queryKey: ['settings'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await axios.get(`${API_URL}/settings`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSettings();
|
if (settingsData) {
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchSettings = async () => {
|
|
||||||
try {
|
|
||||||
const response = await axios.get(`${API_URL}/settings`);
|
|
||||||
setSettings({
|
setSettings({
|
||||||
...response.data,
|
...settingsData,
|
||||||
tags: response.data.tags || []
|
tags: settingsData.tags || []
|
||||||
});
|
});
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching settings:', error);
|
|
||||||
setMessage({ text: t('settingsFailed'), type: 'error' });
|
|
||||||
} finally {
|
|
||||||
// Loading finished
|
|
||||||
}
|
}
|
||||||
};
|
}, [settingsData]);
|
||||||
|
|
||||||
const handleSave = async () => {
|
// Save settings mutation
|
||||||
setSaving(true);
|
const saveMutation = useMutation({
|
||||||
try {
|
mutationFn: async (newSettings: Settings) => {
|
||||||
// Only send password if it has been changed (is not empty)
|
// Only send password if it has been changed (is not empty)
|
||||||
const settingsToSend = { ...settings };
|
const settingsToSend = { ...newSettings };
|
||||||
if (!settingsToSend.password) {
|
if (!settingsToSend.password) {
|
||||||
delete settingsToSend.password;
|
delete settingsToSend.password;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Saving settings:', settingsToSend);
|
|
||||||
await axios.post(`${API_URL}/settings`, settingsToSend);
|
await axios.post(`${API_URL}/settings`, settingsToSend);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['settings'] });
|
||||||
setMessage({ text: t('settingsSaved'), type: 'success' });
|
setMessage({ text: t('settingsSaved'), type: 'success' });
|
||||||
|
|
||||||
// Clear password field after save
|
// Clear password field after save
|
||||||
setSettings(prev => ({ ...prev, password: '', isPasswordSet: true }));
|
setSettings(prev => ({ ...prev, password: '', isPasswordSet: true }));
|
||||||
} catch (error) {
|
},
|
||||||
console.error('Error saving settings:', error);
|
onError: () => {
|
||||||
setMessage({ text: t('settingsFailed'), type: 'error' });
|
setMessage({ text: t('settingsFailed'), type: 'error' });
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
saveMutation.mutate(settings);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Scan files mutation
|
||||||
|
const scanMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const res = await axios.post(`${API_URL}/scan-files`);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setInfoModal({
|
||||||
|
isOpen: true,
|
||||||
|
title: t('success'),
|
||||||
|
message: t('scanFilesSuccess').replace('{count}', data.addedCount.toString()),
|
||||||
|
type: 'success'
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
setInfoModal({
|
||||||
|
isOpen: true,
|
||||||
|
title: t('error'),
|
||||||
|
message: `${t('scanFilesFailed')}: ${error.response?.data?.details || error.message}`,
|
||||||
|
type: 'error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Migrate data mutation
|
||||||
|
const migrateMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const res = await axios.post(`${API_URL}/settings/migrate`);
|
||||||
|
return res.data.results;
|
||||||
|
},
|
||||||
|
onSuccess: (results) => {
|
||||||
|
let msg = `${t('migrationReport')}:\n`;
|
||||||
|
let hasData = false;
|
||||||
|
|
||||||
|
if (results.warnings && results.warnings.length > 0) {
|
||||||
|
msg += `\n⚠️ ${t('migrationWarnings')}:\n${results.warnings.join('\n')}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const categories = ['videos', 'collections', 'settings', 'downloads'];
|
||||||
|
categories.forEach(cat => {
|
||||||
|
const data = results[cat];
|
||||||
|
if (data) {
|
||||||
|
if (data.found) {
|
||||||
|
msg += `\n✅ ${cat}: ${data.count} ${t('itemsMigrated')}`;
|
||||||
|
hasData = true;
|
||||||
|
} else {
|
||||||
|
msg += `\n❌ ${cat}: ${t('fileNotFound')} ${data.path}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (results.errors && results.errors.length > 0) {
|
||||||
|
msg += `\n\n⛔ ${t('migrationErrors')}:\n${results.errors.join('\n')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasData && (!results.errors || results.errors.length === 0)) {
|
||||||
|
msg += `\n\n⚠️ ${t('noDataFilesFound')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
setInfoModal({
|
||||||
|
isOpen: true,
|
||||||
|
title: hasData ? t('migrationResults') : t('migrationNoData'),
|
||||||
|
message: msg,
|
||||||
|
type: hasData ? 'success' : 'warning'
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
setInfoModal({
|
||||||
|
isOpen: true,
|
||||||
|
title: t('error'),
|
||||||
|
message: `${t('migrationFailed')}: ${error.response?.data?.details || error.message}`,
|
||||||
|
type: 'error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup temp files mutation
|
||||||
|
const cleanupMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const res = await axios.post(`${API_URL}/cleanup-temp-files`);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
const { deletedCount, errors } = data;
|
||||||
|
let msg = t('cleanupTempFilesSuccess').replace('{count}', deletedCount.toString());
|
||||||
|
if (errors && errors.length > 0) {
|
||||||
|
msg += `\n\nErrors:\n${errors.join('\n')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
setInfoModal({
|
||||||
|
isOpen: true,
|
||||||
|
title: t('success'),
|
||||||
|
message: msg,
|
||||||
|
type: errors && errors.length > 0 ? 'warning' : 'success'
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
const errorMsg = error.response?.data?.error === "Cannot clean up while downloads are active"
|
||||||
|
? t('cleanupTempFilesActiveDownloads')
|
||||||
|
: `${t('cleanupTempFilesFailed')}: ${error.response?.data?.details || error.message}`;
|
||||||
|
|
||||||
|
setInfoModal({
|
||||||
|
isOpen: true,
|
||||||
|
title: t('error'),
|
||||||
|
message: errorMsg,
|
||||||
|
type: 'error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete legacy data mutation
|
||||||
|
const deleteLegacyMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const res = await axios.post(`${API_URL}/settings/delete-legacy`);
|
||||||
|
return res.data.results;
|
||||||
|
},
|
||||||
|
onSuccess: (results) => {
|
||||||
|
let msg = `${t('legacyDataDeleted')}\n`;
|
||||||
|
if (results.deleted.length > 0) {
|
||||||
|
msg += `\nDeleted: ${results.deleted.join(', ')}`;
|
||||||
|
}
|
||||||
|
if (results.failed.length > 0) {
|
||||||
|
msg += `\nFailed: ${results.failed.join(', ')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
setInfoModal({
|
||||||
|
isOpen: true,
|
||||||
|
title: t('success'),
|
||||||
|
message: msg,
|
||||||
|
type: 'success'
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
setInfoModal({
|
||||||
|
isOpen: true,
|
||||||
|
title: t('error'),
|
||||||
|
message: `Failed to delete legacy data: ${error.response?.data?.details || error.message}`,
|
||||||
|
type: 'error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const handleChange = (field: keyof Settings, value: string | boolean | number) => {
|
const handleChange = (field: keyof Settings, value: string | boolean | number) => {
|
||||||
setSettings(prev => ({ ...prev, [field]: value }));
|
setSettings(prev => ({ ...prev, [field]: value }));
|
||||||
if (field === 'language') {
|
if (field === 'language') {
|
||||||
@@ -137,6 +281,8 @@ const SettingsPage: React.FC = () => {
|
|||||||
setSettings(prev => ({ ...prev, tags: updatedTags }));
|
setSettings(prev => ({ ...prev, tags: updatedTags }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isSaving = saveMutation.isPending || scanMutation.isPending || migrateMutation.isPending || cleanupMutation.isPending || deleteLegacyMutation.isPending;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container maxWidth="xl" sx={{ py: 4 }}>
|
<Container maxWidth="xl" sx={{ py: 4 }}>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4 }}>
|
||||||
@@ -314,7 +460,7 @@ const SettingsPage: React.FC = () => {
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="warning"
|
color="warning"
|
||||||
onClick={() => setShowCleanupTempFilesModal(true)}
|
onClick={() => setShowCleanupTempFilesModal(true)}
|
||||||
disabled={saving || activeDownloads.length > 0}
|
disabled={isSaving || activeDownloads.length > 0}
|
||||||
>
|
>
|
||||||
{t('cleanupTempFiles')}
|
{t('cleanupTempFiles')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -333,7 +479,7 @@ const SettingsPage: React.FC = () => {
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="warning"
|
color="warning"
|
||||||
onClick={() => setShowMigrateConfirmModal(true)}
|
onClick={() => setShowMigrateConfirmModal(true)}
|
||||||
disabled={saving}
|
disabled={isSaving}
|
||||||
>
|
>
|
||||||
{t('migrateDataButton')}
|
{t('migrateDataButton')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -341,31 +487,8 @@ const SettingsPage: React.FC = () => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="primary"
|
color="primary"
|
||||||
onClick={async () => {
|
onClick={() => scanMutation.mutate()}
|
||||||
setSaving(true);
|
disabled={isSaving}
|
||||||
try {
|
|
||||||
const res = await axios.post(`${API_URL}/scan-files`);
|
|
||||||
const { addedCount } = res.data;
|
|
||||||
|
|
||||||
setInfoModal({
|
|
||||||
isOpen: true,
|
|
||||||
title: t('success'),
|
|
||||||
message: t('scanFilesSuccess').replace('{count}', addedCount.toString()),
|
|
||||||
type: 'success'
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Scan failed:', error);
|
|
||||||
setInfoModal({
|
|
||||||
isOpen: true,
|
|
||||||
title: t('error'),
|
|
||||||
message: `${t('scanFilesFailed')}: ${error.response?.data?.details || error.message}`,
|
|
||||||
type: 'error'
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={saving}
|
|
||||||
sx={{ ml: 2 }}
|
sx={{ ml: 2 }}
|
||||||
>
|
>
|
||||||
{t('scanFiles')}
|
{t('scanFiles')}
|
||||||
@@ -380,7 +503,7 @@ const SettingsPage: React.FC = () => {
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="error"
|
color="error"
|
||||||
onClick={() => setShowDeleteLegacyModal(true)}
|
onClick={() => setShowDeleteLegacyModal(true)}
|
||||||
disabled={saving}
|
disabled={isSaving}
|
||||||
>
|
>
|
||||||
{t('deleteLegacyDataButton')}
|
{t('deleteLegacyDataButton')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -416,9 +539,9 @@ const SettingsPage: React.FC = () => {
|
|||||||
size="large"
|
size="large"
|
||||||
startIcon={<Save />}
|
startIcon={<Save />}
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving}
|
disabled={isSaving}
|
||||||
>
|
>
|
||||||
{saving ? t('saving') : t('saveSettings')}
|
{isSaving ? t('saving') : t('saveSettings')}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Grid>
|
</Grid>
|
||||||
@@ -439,42 +562,9 @@ const SettingsPage: React.FC = () => {
|
|||||||
<ConfirmationModal
|
<ConfirmationModal
|
||||||
isOpen={showDeleteLegacyModal}
|
isOpen={showDeleteLegacyModal}
|
||||||
onClose={() => setShowDeleteLegacyModal(false)}
|
onClose={() => setShowDeleteLegacyModal(false)}
|
||||||
onConfirm={async () => {
|
onConfirm={() => {
|
||||||
setSaving(true);
|
setShowDeleteLegacyModal(false);
|
||||||
try {
|
deleteLegacyMutation.mutate();
|
||||||
const res = await axios.post(`${API_URL}/settings/delete-legacy`);
|
|
||||||
const results = res.data.results;
|
|
||||||
console.log('Delete legacy results:', results);
|
|
||||||
|
|
||||||
let msg = `${t('legacyDataDeleted')}\n`;
|
|
||||||
if (results.deleted.length > 0) {
|
|
||||||
msg += `\nDeleted: ${results.deleted.join(', ')}`;
|
|
||||||
}
|
|
||||||
if (results.failed.length > 0) {
|
|
||||||
msg += `\nFailed: ${results.failed.join(', ')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (results.failed.length > 0) {
|
|
||||||
msg += `\nFailed: ${results.failed.join(', ')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
setInfoModal({
|
|
||||||
isOpen: true,
|
|
||||||
title: t('success'),
|
|
||||||
message: msg,
|
|
||||||
type: 'success'
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Failed to delete legacy data:', error);
|
|
||||||
setInfoModal({
|
|
||||||
isOpen: true,
|
|
||||||
title: t('error'),
|
|
||||||
message: `Failed to delete legacy data: ${error.response?.data?.details || error.message}`,
|
|
||||||
type: 'error'
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
title={t('removeLegacyDataConfirmTitle')}
|
title={t('removeLegacyDataConfirmTitle')}
|
||||||
message={t('removeLegacyDataConfirmMessage')}
|
message={t('removeLegacyDataConfirmMessage')}
|
||||||
@@ -487,58 +577,9 @@ const SettingsPage: React.FC = () => {
|
|||||||
<ConfirmationModal
|
<ConfirmationModal
|
||||||
isOpen={showMigrateConfirmModal}
|
isOpen={showMigrateConfirmModal}
|
||||||
onClose={() => setShowMigrateConfirmModal(false)}
|
onClose={() => setShowMigrateConfirmModal(false)}
|
||||||
onConfirm={async () => {
|
onConfirm={() => {
|
||||||
setSaving(true);
|
setShowMigrateConfirmModal(false);
|
||||||
try {
|
migrateMutation.mutate();
|
||||||
const res = await axios.post(`${API_URL}/settings/migrate`);
|
|
||||||
const results = res.data.results;
|
|
||||||
console.log('Migration results:', results);
|
|
||||||
|
|
||||||
let msg = `${t('migrationReport')}:\n`;
|
|
||||||
let hasData = false;
|
|
||||||
|
|
||||||
if (results.warnings && results.warnings.length > 0) {
|
|
||||||
msg += `\n⚠️ ${t('migrationWarnings')}:\n${results.warnings.join('\n')}\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const categories = ['videos', 'collections', 'settings', 'downloads'];
|
|
||||||
categories.forEach(cat => {
|
|
||||||
const data = results[cat];
|
|
||||||
if (data) {
|
|
||||||
if (data.found) {
|
|
||||||
msg += `\n✅ ${cat}: ${data.count} ${t('itemsMigrated')}`;
|
|
||||||
hasData = true;
|
|
||||||
} else {
|
|
||||||
msg += `\n❌ ${cat}: ${t('fileNotFound')} ${data.path}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (results.errors && results.errors.length > 0) {
|
|
||||||
msg += `\n\n⛔ ${t('migrationErrors')}:\n${results.errors.join('\n')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasData && (!results.errors || results.errors.length === 0)) {
|
|
||||||
msg += `\n\n⚠️ ${t('noDataFilesFound')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
setInfoModal({
|
|
||||||
isOpen: true,
|
|
||||||
title: hasData ? t('migrationResults') : t('migrationNoData'),
|
|
||||||
message: msg,
|
|
||||||
type: hasData ? 'success' : 'warning'
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Migration failed:', error);
|
|
||||||
setInfoModal({
|
|
||||||
isOpen: true,
|
|
||||||
title: t('error'),
|
|
||||||
message: `${t('migrationFailed')}: ${error.response?.data?.details || error.message}`,
|
|
||||||
type: 'error'
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
title={t('migrateDataButton')}
|
title={t('migrateDataButton')}
|
||||||
message={t('migrateConfirmation')}
|
message={t('migrateConfirmation')}
|
||||||
@@ -550,38 +591,9 @@ const SettingsPage: React.FC = () => {
|
|||||||
<ConfirmationModal
|
<ConfirmationModal
|
||||||
isOpen={showCleanupTempFilesModal}
|
isOpen={showCleanupTempFilesModal}
|
||||||
onClose={() => setShowCleanupTempFilesModal(false)}
|
onClose={() => setShowCleanupTempFilesModal(false)}
|
||||||
onConfirm={async () => {
|
onConfirm={() => {
|
||||||
setSaving(true);
|
setShowCleanupTempFilesModal(false);
|
||||||
try {
|
cleanupMutation.mutate();
|
||||||
const res = await axios.post(`${API_URL}/cleanup-temp-files`);
|
|
||||||
const { deletedCount, errors } = res.data;
|
|
||||||
|
|
||||||
let msg = t('cleanupTempFilesSuccess').replace('{count}', deletedCount.toString());
|
|
||||||
if (errors && errors.length > 0) {
|
|
||||||
msg += `\n\nErrors:\n${errors.join('\n')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
setInfoModal({
|
|
||||||
isOpen: true,
|
|
||||||
title: t('success'),
|
|
||||||
message: msg,
|
|
||||||
type: errors && errors.length > 0 ? 'warning' : 'success'
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Cleanup failed:', error);
|
|
||||||
const errorMsg = error.response?.data?.error === "Cannot clean up while downloads are active"
|
|
||||||
? t('cleanupTempFilesActiveDownloads')
|
|
||||||
: `${t('cleanupTempFilesFailed')}: ${error.response?.data?.details || error.message}`;
|
|
||||||
|
|
||||||
setInfoModal({
|
|
||||||
isOpen: true,
|
|
||||||
title: t('error'),
|
|
||||||
message: errorMsg,
|
|
||||||
type: 'error'
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
title={t('cleanupTempFilesConfirmTitle')}
|
title={t('cleanupTempFilesConfirmTitle')}
|
||||||
message={t('cleanupTempFilesConfirmMessage')}
|
message={t('cleanupTempFilesConfirmMessage')}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Stack,
|
Stack,
|
||||||
Typography
|
Typography
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
@@ -21,7 +22,7 @@ import VideoControls from '../components/VideoPlayer/VideoControls';
|
|||||||
import VideoInfo from '../components/VideoPlayer/VideoInfo';
|
import VideoInfo from '../components/VideoPlayer/VideoInfo';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
import { useSnackbar } from '../contexts/SnackbarContext';
|
import { useSnackbar } from '../contexts/SnackbarContext';
|
||||||
import { Collection, Comment, Video } from '../types';
|
import { Collection, Video } from '../types';
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL;
|
const API_URL = import.meta.env.VITE_API_URL;
|
||||||
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL;
|
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL;
|
||||||
@@ -47,21 +48,11 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const { showSnackbar } = useSnackbar();
|
const { showSnackbar } = useSnackbar();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [video, setVideo] = useState<Video | null>(null);
|
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [isDeleting, setIsDeleting] = useState<boolean>(false);
|
|
||||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
|
||||||
const [showCollectionModal, setShowCollectionModal] = useState<boolean>(false);
|
const [showCollectionModal, setShowCollectionModal] = useState<boolean>(false);
|
||||||
const [videoCollections, setVideoCollections] = useState<Collection[]>([]);
|
const [videoCollections, setVideoCollections] = useState<Collection[]>([]);
|
||||||
const [comments, setComments] = useState<Comment[]>([]);
|
|
||||||
const [loadingComments, setLoadingComments] = useState<boolean>(false);
|
|
||||||
const [showComments, setShowComments] = useState<boolean>(false);
|
const [showComments, setShowComments] = useState<boolean>(false);
|
||||||
const [commentsLoaded, setCommentsLoaded] = useState<boolean>(false);
|
|
||||||
const [availableTags, setAvailableTags] = useState<string[]>([]);
|
|
||||||
const [autoPlay, setAutoPlay] = useState<boolean>(false);
|
|
||||||
const [autoLoop, setAutoLoop] = useState<boolean>(false);
|
|
||||||
|
|
||||||
// Confirmation Modal State
|
// Confirmation Modal State
|
||||||
const [confirmationModal, setConfirmationModal] = useState({
|
const [confirmationModal, setConfirmationModal] = useState({
|
||||||
@@ -74,84 +65,54 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|||||||
isDanger: false
|
isDanger: false
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fetch video details
|
||||||
|
const { data: video, isLoading: loading, error } = useQuery({
|
||||||
|
queryKey: ['video', id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await axios.get(`${API_URL}/videos/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
initialData: () => {
|
||||||
|
return videos.find(v => v.id === id);
|
||||||
|
},
|
||||||
|
enabled: !!id,
|
||||||
|
retry: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle error redirect
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Don't try to fetch the video if it's being deleted
|
if (error) {
|
||||||
if (isDeleting) {
|
const timer = setTimeout(() => {
|
||||||
return;
|
navigate('/');
|
||||||
|
}, 3000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
}
|
}
|
||||||
|
}, [error, navigate]);
|
||||||
|
|
||||||
const fetchVideo = async () => {
|
// Fetch settings
|
||||||
if (!id) return;
|
const { data: settings } = useQuery({
|
||||||
|
queryKey: ['settings'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await axios.get(`${API_URL}/settings`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// First check if the video is in the videos prop
|
const autoPlay = settings?.defaultAutoPlay || false;
|
||||||
const foundVideo = videos.find(v => v.id === id);
|
const autoLoop = settings?.defaultAutoLoop || false;
|
||||||
|
const availableTags = settings?.tags || [];
|
||||||
|
|
||||||
if (foundVideo) {
|
// Fetch comments
|
||||||
setVideo(foundVideo);
|
const { data: comments = [], isLoading: loadingComments } = useQuery({
|
||||||
setLoading(false);
|
queryKey: ['comments', id],
|
||||||
return;
|
queryFn: async () => {
|
||||||
}
|
|
||||||
|
|
||||||
// If not found in props, try to fetch from API
|
|
||||||
try {
|
|
||||||
const response = await axios.get(`${API_URL}/videos/${id}`);
|
|
||||||
setVideo(response.data);
|
|
||||||
setError(null);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error fetching video:', err);
|
|
||||||
setError(t('videoNotFoundOrLoaded'));
|
|
||||||
|
|
||||||
// Redirect to home after 3 seconds if video not found
|
|
||||||
setTimeout(() => {
|
|
||||||
navigate('/');
|
|
||||||
}, 3000);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchVideo();
|
|
||||||
}, [id, videos, navigate, isDeleting]);
|
|
||||||
|
|
||||||
// Fetch settings and apply defaults
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchSettings = async () => {
|
|
||||||
try {
|
|
||||||
const response = await axios.get(`${API_URL}/settings`);
|
|
||||||
const { defaultAutoPlay, defaultAutoLoop } = response.data;
|
|
||||||
|
|
||||||
setAutoPlay(!!defaultAutoPlay);
|
|
||||||
setAutoLoop(!!defaultAutoLoop);
|
|
||||||
|
|
||||||
setAvailableTags(response.data.tags || []);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching settings:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchSettings();
|
|
||||||
}, [id]); // Re-run when video changes
|
|
||||||
|
|
||||||
const fetchComments = async () => {
|
|
||||||
if (!id) return;
|
|
||||||
|
|
||||||
setLoadingComments(true);
|
|
||||||
try {
|
|
||||||
const response = await axios.get(`${API_URL}/videos/${id}/comments`);
|
const response = await axios.get(`${API_URL}/videos/${id}/comments`);
|
||||||
setComments(response.data);
|
return response.data;
|
||||||
setCommentsLoaded(true);
|
},
|
||||||
} catch (err) {
|
enabled: showComments && !!id
|
||||||
console.error('Error fetching comments:', err);
|
});
|
||||||
// We don't set a global error here as comments are secondary
|
|
||||||
} finally {
|
|
||||||
setLoadingComments(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggleComments = () => {
|
const handleToggleComments = () => {
|
||||||
if (!showComments && !commentsLoaded) {
|
|
||||||
fetchComments();
|
|
||||||
}
|
|
||||||
setShowComments(!showComments);
|
setShowComments(!showComments);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -191,27 +152,21 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|||||||
navigate(`/collection/${collectionId}`);
|
navigate(`/collection/${collectionId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Delete mutation
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: async (videoId: string) => {
|
||||||
|
return await onDeleteVideo(videoId);
|
||||||
|
},
|
||||||
|
onSuccess: (result) => {
|
||||||
|
if (result.success) {
|
||||||
|
navigate('/', { replace: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const executeDelete = async () => {
|
const executeDelete = async () => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
|
await deleteMutation.mutateAsync(id);
|
||||||
setIsDeleting(true);
|
|
||||||
setDeleteError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await onDeleteVideo(id);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
// Navigate to home immediately after successful deletion
|
|
||||||
navigate('/', { replace: true });
|
|
||||||
} else {
|
|
||||||
setDeleteError(result.error || t('deleteFailed'));
|
|
||||||
setIsDeleting(false);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setDeleteError(t('unexpectedErrorOccurred'));
|
|
||||||
console.error(err);
|
|
||||||
setIsDeleting(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
@@ -274,43 +229,63 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Rating mutation
|
||||||
|
const ratingMutation = useMutation({
|
||||||
|
mutationFn: async (newValue: number) => {
|
||||||
|
await axios.post(`${API_URL}/videos/${id}/rate`, { rating: newValue });
|
||||||
|
return newValue;
|
||||||
|
},
|
||||||
|
onSuccess: (newValue) => {
|
||||||
|
queryClient.setQueryData(['video', id], (old: Video | undefined) => old ? { ...old, rating: newValue } : old);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const handleRatingChange = async (newValue: number) => {
|
const handleRatingChange = async (newValue: number) => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
|
await ratingMutation.mutateAsync(newValue);
|
||||||
try {
|
|
||||||
await axios.post(`${API_URL}/videos/${id}/rate`, { rating: newValue });
|
|
||||||
setVideo(prev => prev ? { ...prev, rating: newValue } : null);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating rating:', error);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Title mutation
|
||||||
|
const titleMutation = useMutation({
|
||||||
|
mutationFn: async (newTitle: string) => {
|
||||||
|
const response = await axios.put(`${API_URL}/videos/${id}`, { title: newTitle });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
onSuccess: (data, newTitle) => {
|
||||||
|
if (data.success) {
|
||||||
|
queryClient.setQueryData(['video', id], (old: Video | undefined) => old ? { ...old, title: newTitle } : old);
|
||||||
|
showSnackbar(t('titleUpdated'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
showSnackbar(t('titleUpdateFailed'), 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const handleSaveTitle = async (newTitle: string) => {
|
const handleSaveTitle = async (newTitle: string) => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
|
await titleMutation.mutateAsync(newTitle);
|
||||||
try {
|
|
||||||
const response = await axios.put(`${API_URL}/videos/${id}`, { title: newTitle });
|
|
||||||
if (response.data.success) {
|
|
||||||
setVideo(prev => prev ? { ...prev, title: newTitle } : null);
|
|
||||||
showSnackbar(t('titleUpdated'));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating title:', error);
|
|
||||||
showSnackbar(t('titleUpdateFailed'), 'error');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Tags mutation
|
||||||
|
const tagsMutation = useMutation({
|
||||||
|
mutationFn: async (newTags: string[]) => {
|
||||||
|
const response = await axios.put(`${API_URL}/videos/${id}`, { tags: newTags });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
onSuccess: (data, newTags) => {
|
||||||
|
if (data.success) {
|
||||||
|
queryClient.setQueryData(['video', id], (old: Video | undefined) => old ? { ...old, tags: newTags } : old);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
showSnackbar(t('error'), 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const handleUpdateTags = async (newTags: string[]) => {
|
const handleUpdateTags = async (newTags: string[]) => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
try {
|
await tagsMutation.mutateAsync(newTags);
|
||||||
const response = await axios.put(`${API_URL}/videos/${id}`, { tags: newTags });
|
|
||||||
if (response.data.success) {
|
|
||||||
setVideo(prev => prev ? { ...prev, tags: newTags } : null);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating tags:', error);
|
|
||||||
showSnackbar(t('error'), 'error');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const [hasViewed, setHasViewed] = useState<boolean>(false);
|
const [hasViewed, setHasViewed] = useState<boolean>(false);
|
||||||
@@ -342,7 +317,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|||||||
axios.post(`${API_URL}/videos/${id}/view`)
|
axios.post(`${API_URL}/videos/${id}/view`)
|
||||||
.then(res => {
|
.then(res => {
|
||||||
if (res.data.success && video) {
|
if (res.data.success && video) {
|
||||||
setVideo({ ...video, viewCount: res.data.viewCount });
|
queryClient.setQueryData(['video', id], (old: Video | undefined) => old ? { ...old, viewCount: res.data.viewCount } : old);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(err => console.error('Error incrementing view count:', err));
|
.catch(err => console.error('Error incrementing view count:', err));
|
||||||
@@ -369,7 +344,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|||||||
if (error || !video) {
|
if (error || !video) {
|
||||||
return (
|
return (
|
||||||
<Container sx={{ mt: 4 }}>
|
<Container sx={{ mt: 4 }}>
|
||||||
<Alert severity="error">{error || t('videoNotFound')}</Alert>
|
<Alert severity="error">{t('videoNotFoundOrLoaded')}</Alert>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -397,8 +372,8 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|||||||
onAuthorClick={handleAuthorClick}
|
onAuthorClick={handleAuthorClick}
|
||||||
onAddToCollection={handleAddToCollection}
|
onAddToCollection={handleAddToCollection}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
isDeleting={isDeleting}
|
isDeleting={deleteMutation.isPending}
|
||||||
deleteError={deleteError}
|
deleteError={deleteMutation.error ? (deleteMutation.error as any).message || t('deleteFailed') : null}
|
||||||
videoCollections={videoCollections}
|
videoCollections={videoCollections}
|
||||||
onCollectionClick={handleCollectionClick}
|
onCollectionClick={handleCollectionClick}
|
||||||
availableTags={availableTags}
|
availableTags={availableTags}
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ export interface DownloadInfo {
|
|||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
timestamp?: number;
|
timestamp?: number;
|
||||||
|
progress?: number;
|
||||||
|
speed?: string;
|
||||||
|
totalSize?: string;
|
||||||
|
downloadedSize?: string;
|
||||||
|
filename?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Comment {
|
export interface Comment {
|
||||||
|
|||||||
Reference in New Issue
Block a user