feat: add rating; UI adjustment
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"loginEnabled": false,
|
||||
"loginEnabled": true,
|
||||
"defaultAutoPlay": false,
|
||||
"defaultAutoLoop": false,
|
||||
"maxConcurrentDownloads": 3,
|
||||
"isPasswordSet": true,
|
||||
"password": "$2b$10$1vONfSGZSusSlGf3Vng2UOX8lcmRxtHkTm6eWnP8FlJ19E.QHKNC."
|
||||
"language": "en",
|
||||
"password": "$2b$10$4g06vnvfzqN8Pnm.1JEqkO8D9lNE.QGg4/AA1rQm9ipjmtJQN7VDO"
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ interface Settings {
|
||||
defaultAutoPlay: boolean;
|
||||
defaultAutoLoop: boolean;
|
||||
maxConcurrentDownloads: number;
|
||||
language: string;
|
||||
}
|
||||
|
||||
const defaultSettings: Settings = {
|
||||
@@ -19,7 +20,8 @@ const defaultSettings: Settings = {
|
||||
password: "",
|
||||
defaultAutoPlay: false,
|
||||
defaultAutoLoop: false,
|
||||
maxConcurrentDownloads: 3
|
||||
maxConcurrentDownloads: 3,
|
||||
language: 'en'
|
||||
};
|
||||
|
||||
export const getSettings = async (req: Request, res: Response) => {
|
||||
|
||||
@@ -462,3 +462,31 @@ export const uploadVideo = async (req: Request, res: Response): Promise<any> =>
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Rate video
|
||||
export const rateVideo = (req: Request, res: Response): any => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { rating } = req.body;
|
||||
|
||||
if (typeof rating !== 'number' || rating < 1 || rating > 5) {
|
||||
return res.status(400).json({ error: "Rating must be a number between 1 and 5" });
|
||||
}
|
||||
|
||||
const updatedVideo = storageService.updateVideo(id, { rating });
|
||||
|
||||
if (!updatedVideo) {
|
||||
return res.status(404).json({ error: "Video not found" });
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "Video rated successfully",
|
||||
video: updatedVideo
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error rating video:", error);
|
||||
res.status(500).json({ error: "Failed to rate video" });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ router.get("/videos", videoController.getVideos);
|
||||
router.get("/videos/:id", videoController.getVideoById);
|
||||
router.delete("/videos/:id", videoController.deleteVideo);
|
||||
router.get("/videos/:id/comments", videoController.getVideoComments);
|
||||
router.post("/videos/:id/rate", videoController.rateVideo);
|
||||
|
||||
router.get("/download-status", videoController.getDownloadStatus);
|
||||
router.get("/check-bilibili-parts", videoController.checkBilibiliParts);
|
||||
|
||||
@@ -154,6 +154,21 @@ export function saveVideo(videoData: Video): Video {
|
||||
return videoData;
|
||||
}
|
||||
|
||||
// Update a video
|
||||
export function updateVideo(id: string, updates: Partial<Video>): Video | null {
|
||||
let videos = getVideos();
|
||||
const index = videos.findIndex((v) => v.id === id);
|
||||
|
||||
if (index === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const updatedVideo = { ...videos[index], ...updates };
|
||||
videos[index] = updatedVideo;
|
||||
fs.writeFileSync(VIDEOS_DATA_PATH, JSON.stringify(videos, null, 2));
|
||||
return updatedVideo;
|
||||
}
|
||||
|
||||
// Delete a video
|
||||
export function deleteVideo(id: string): boolean {
|
||||
let videos = getVideos();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CssBaseline, ThemeProvider } from '@mui/material';
|
||||
import { Box, CssBaseline, ThemeProvider } from '@mui/material';
|
||||
import axios from 'axios';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Route, BrowserRouter as Router, Routes } from 'react-router-dom';
|
||||
@@ -6,6 +6,7 @@ import './App.css';
|
||||
import BilibiliPartsModal from './components/BilibiliPartsModal';
|
||||
import Footer from './components/Footer';
|
||||
import Header from './components/Header';
|
||||
import { LanguageProvider } from './contexts/LanguageContext';
|
||||
import { useSnackbar } from './contexts/SnackbarContext';
|
||||
import AuthorVideos from './pages/AuthorVideos';
|
||||
import CollectionPage from './pages/CollectionPage';
|
||||
@@ -699,136 +700,139 @@ function App() {
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
{!isAuthenticated && loginRequired ? (
|
||||
checkingAuth ? (
|
||||
<div className="loading">Loading...</div>
|
||||
<LanguageProvider>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
{!isAuthenticated && loginRequired ? (
|
||||
checkingAuth ? (
|
||||
<div className="loading">Loading...</div>
|
||||
) : (
|
||||
<LoginPage onLoginSuccess={handleLoginSuccess} />
|
||||
)
|
||||
) : (
|
||||
<LoginPage onLoginSuccess={handleLoginSuccess} />
|
||||
)
|
||||
) : (
|
||||
<Router>
|
||||
<div className="app">
|
||||
<Header
|
||||
onSearch={handleSearch}
|
||||
onSubmit={handleVideoSubmit}
|
||||
activeDownloads={activeDownloads}
|
||||
isSearchMode={isSearchMode}
|
||||
searchTerm={searchTerm}
|
||||
onResetSearch={resetSearch}
|
||||
theme={themeMode}
|
||||
toggleTheme={toggleTheme}
|
||||
collections={collections}
|
||||
videos={videos}
|
||||
/>
|
||||
<Router>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
|
||||
<Header
|
||||
onSearch={handleSearch}
|
||||
onSubmit={handleVideoSubmit}
|
||||
activeDownloads={activeDownloads}
|
||||
isSearchMode={isSearchMode}
|
||||
searchTerm={searchTerm}
|
||||
onResetSearch={resetSearch}
|
||||
theme={themeMode}
|
||||
toggleTheme={toggleTheme}
|
||||
collections={collections}
|
||||
videos={videos}
|
||||
/>
|
||||
|
||||
{/* Bilibili Parts Modal */}
|
||||
<BilibiliPartsModal
|
||||
isOpen={showBilibiliPartsModal}
|
||||
onClose={() => setShowBilibiliPartsModal(false)}
|
||||
videosNumber={bilibiliPartsInfo.videosNumber}
|
||||
videoTitle={bilibiliPartsInfo.title}
|
||||
onDownloadAll={handleDownloadAllBilibiliParts}
|
||||
onDownloadCurrent={handleDownloadCurrentBilibiliPart}
|
||||
isLoading={loading || isCheckingParts}
|
||||
type={bilibiliPartsInfo.type}
|
||||
/>
|
||||
{/* Bilibili Parts Modal */}
|
||||
<BilibiliPartsModal
|
||||
isOpen={showBilibiliPartsModal}
|
||||
onClose={() => setShowBilibiliPartsModal(false)}
|
||||
videosNumber={bilibiliPartsInfo.videosNumber}
|
||||
videoTitle={bilibiliPartsInfo.title}
|
||||
onDownloadAll={handleDownloadAllBilibiliParts}
|
||||
onDownloadCurrent={handleDownloadCurrentBilibiliPart}
|
||||
isLoading={loading || isCheckingParts}
|
||||
type={bilibiliPartsInfo.type}
|
||||
/>
|
||||
|
||||
<main className="main-content">
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<Home
|
||||
videos={videos}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onDeleteVideo={handleDeleteVideo}
|
||||
collections={collections}
|
||||
isSearchMode={isSearchMode}
|
||||
searchTerm={searchTerm}
|
||||
localSearchResults={localSearchResults}
|
||||
youtubeLoading={youtubeLoading}
|
||||
searchResults={searchResults}
|
||||
onDownload={handleDownloadFromSearch}
|
||||
onResetSearch={resetSearch}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/video/:id"
|
||||
element={
|
||||
<VideoPlayer
|
||||
videos={videos}
|
||||
onDeleteVideo={handleDeleteVideo}
|
||||
collections={collections}
|
||||
onAddToCollection={handleAddToCollection}
|
||||
onCreateCollection={handleCreateCollection}
|
||||
onRemoveFromCollection={handleRemoveFromCollection}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/author/:author"
|
||||
element={
|
||||
<AuthorVideos
|
||||
videos={videos}
|
||||
onDeleteVideo={handleDeleteVideo}
|
||||
collections={collections}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/collection/:id"
|
||||
element={
|
||||
<CollectionPage
|
||||
collections={collections}
|
||||
videos={videos}
|
||||
onDeleteVideo={handleDeleteVideo}
|
||||
onDeleteCollection={handleDeleteCollection}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/search"
|
||||
element={
|
||||
<SearchResults
|
||||
results={searchResults}
|
||||
localResults={localSearchResults}
|
||||
loading={loading}
|
||||
youtubeLoading={youtubeLoading}
|
||||
searchTerm={searchTerm}
|
||||
onDownload={handleDownloadFromSearch}
|
||||
onDeleteVideo={handleDeleteVideo}
|
||||
onResetSearch={resetSearch}
|
||||
collections={collections}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/manage"
|
||||
element={
|
||||
<ManagePage
|
||||
videos={videos}
|
||||
onDeleteVideo={handleDeleteVideo}
|
||||
collections={collections}
|
||||
onDeleteCollection={handleDeleteCollection}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={<SettingsPage />}
|
||||
/>
|
||||
</Routes>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</Router>
|
||||
)}
|
||||
</ThemeProvider>
|
||||
<main className="main-content">
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<Home
|
||||
videos={videos}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onDeleteVideo={handleDeleteVideo}
|
||||
collections={collections}
|
||||
isSearchMode={isSearchMode}
|
||||
searchTerm={searchTerm}
|
||||
localSearchResults={localSearchResults}
|
||||
youtubeLoading={youtubeLoading}
|
||||
searchResults={searchResults}
|
||||
onDownload={handleDownloadFromSearch}
|
||||
onResetSearch={resetSearch}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/video/:id"
|
||||
element={
|
||||
<VideoPlayer
|
||||
videos={videos}
|
||||
onDeleteVideo={handleDeleteVideo}
|
||||
collections={collections}
|
||||
onAddToCollection={handleAddToCollection}
|
||||
onCreateCollection={handleCreateCollection}
|
||||
onRemoveFromCollection={handleRemoveFromCollection}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/author/:author"
|
||||
element={
|
||||
<AuthorVideos
|
||||
videos={videos}
|
||||
onDeleteVideo={handleDeleteVideo}
|
||||
collections={collections}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/collection/:id"
|
||||
element={
|
||||
<CollectionPage
|
||||
collections={collections}
|
||||
videos={videos}
|
||||
onDeleteVideo={handleDeleteVideo}
|
||||
onDeleteCollection={handleDeleteCollection}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/search"
|
||||
element={
|
||||
<SearchResults
|
||||
results={searchResults}
|
||||
localResults={localSearchResults}
|
||||
loading={loading}
|
||||
youtubeLoading={youtubeLoading}
|
||||
searchTerm={searchTerm}
|
||||
onDownload={handleDownloadFromSearch}
|
||||
onDeleteVideo={handleDeleteVideo}
|
||||
onResetSearch={resetSearch}
|
||||
collections={collections}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/manage"
|
||||
element={
|
||||
<ManagePage
|
||||
videos={videos}
|
||||
onDeleteVideo={handleDeleteVideo}
|
||||
collections={collections}
|
||||
onDeleteCollection={handleDeleteCollection}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={<SettingsPage />}
|
||||
/>
|
||||
</Routes>
|
||||
</main>
|
||||
<Footer />
|
||||
</Box>
|
||||
</Router>
|
||||
)}
|
||||
</ThemeProvider>
|
||||
</LanguageProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '@mui/material';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { Video } from '../types';
|
||||
|
||||
interface AuthorsListProps {
|
||||
@@ -19,6 +20,7 @@ interface AuthorsListProps {
|
||||
}
|
||||
|
||||
const AuthorsList: React.FC<AuthorsListProps> = ({ videos, onItemClick }) => {
|
||||
const { t } = useLanguage();
|
||||
const [isOpen, setIsOpen] = useState<boolean>(true);
|
||||
const [authors, setAuthors] = useState<string[]>([]);
|
||||
const theme = useTheme();
|
||||
@@ -54,7 +56,7 @@ const AuthorsList: React.FC<AuthorsListProps> = ({ videos, onItemClick }) => {
|
||||
<Paper elevation={0} sx={{ bgcolor: 'transparent' }}>
|
||||
<ListItemButton onClick={() => setIsOpen(!isOpen)} sx={{ borderRadius: 1 }}>
|
||||
<Typography variant="h6" component="div" sx={{ flexGrow: 1, fontWeight: 600 }}>
|
||||
Authors
|
||||
{t('authors')}
|
||||
</Typography>
|
||||
{isOpen ? <ExpandLess /> : <ExpandMore />}
|
||||
</ListItemButton>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import { useState } from 'react';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
|
||||
interface BilibiliPartsModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -34,6 +35,7 @@ const BilibiliPartsModal: React.FC<BilibiliPartsModalProps> = ({
|
||||
isLoading,
|
||||
type = 'parts'
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const [collectionName, setCollectionName] = useState<string>('');
|
||||
|
||||
const handleDownloadAll = () => {
|
||||
@@ -44,48 +46,48 @@ const BilibiliPartsModal: React.FC<BilibiliPartsModalProps> = ({
|
||||
const getHeaderText = () => {
|
||||
switch (type) {
|
||||
case 'collection':
|
||||
return 'Bilibili Collection Detected';
|
||||
return t('bilibiliCollectionDetected');
|
||||
case 'series':
|
||||
return 'Bilibili Series Detected';
|
||||
return t('bilibiliSeriesDetected');
|
||||
default:
|
||||
return 'Multi-part Video Detected';
|
||||
return t('multiPartVideoDetected');
|
||||
}
|
||||
};
|
||||
|
||||
const getDescriptionText = () => {
|
||||
switch (type) {
|
||||
case 'collection':
|
||||
return `This Bilibili collection has ${videosNumber} videos.`;
|
||||
return t('collectionHasVideos', { count: videosNumber });
|
||||
case 'series':
|
||||
return `This Bilibili series has ${videosNumber} videos.`;
|
||||
return t('seriesHasVideos', { count: videosNumber });
|
||||
default:
|
||||
return `This Bilibili video has ${videosNumber} parts.`;
|
||||
return t('videoHasParts', { count: videosNumber });
|
||||
}
|
||||
};
|
||||
|
||||
const getDownloadAllButtonText = () => {
|
||||
if (isLoading) return 'Processing...';
|
||||
if (isLoading) return t('processing');
|
||||
|
||||
switch (type) {
|
||||
case 'collection':
|
||||
return `Download All ${videosNumber} Videos`;
|
||||
return t('downloadAllVideos', { count: videosNumber });
|
||||
case 'series':
|
||||
return `Download All ${videosNumber} Videos`;
|
||||
return t('downloadAllVideos', { count: videosNumber });
|
||||
default:
|
||||
return `Download All ${videosNumber} Parts`;
|
||||
return t('downloadAllParts', { count: videosNumber });
|
||||
}
|
||||
};
|
||||
|
||||
const getCurrentButtonText = () => {
|
||||
if (isLoading) return 'Processing...';
|
||||
if (isLoading) return t('processing');
|
||||
|
||||
switch (type) {
|
||||
case 'collection':
|
||||
return 'Download This Video Only';
|
||||
return t('downloadThisVideoOnly');
|
||||
case 'series':
|
||||
return 'Download This Video Only';
|
||||
return t('downloadThisVideoOnly');
|
||||
default:
|
||||
return 'Download Current Part Only';
|
||||
return t('downloadCurrentPartOnly');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -118,22 +120,22 @@ const BilibiliPartsModal: React.FC<BilibiliPartsModalProps> = ({
|
||||
{getDescriptionText()}
|
||||
</DialogContentText>
|
||||
<Typography variant="body2" gutterBottom>
|
||||
<strong>Title:</strong> {videoTitle}
|
||||
<strong>{t('title')}:</strong> {videoTitle}
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ mt: 2, mb: 1 }}>
|
||||
Would you like to download all {type === 'parts' ? 'parts' : 'videos'}?
|
||||
{type === 'parts' ? t('wouldYouLikeToDownloadAllParts') : t('wouldYouLikeToDownloadAllVideos')}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Collection Name"
|
||||
label={t('collectionName')}
|
||||
variant="outlined"
|
||||
value={collectionName}
|
||||
onChange={(e) => setCollectionName(e.target.value)}
|
||||
placeholder={videoTitle}
|
||||
disabled={isLoading}
|
||||
helperText={`All ${type === 'parts' ? 'parts' : 'videos'} will be added to this collection`}
|
||||
helperText={type === 'parts' ? t('allPartsAddedToCollection') : t('allVideosAddedToCollection')}
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '@mui/material';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { Collection } from '../types';
|
||||
|
||||
interface CollectionsProps {
|
||||
@@ -20,6 +21,7 @@ interface CollectionsProps {
|
||||
}
|
||||
|
||||
const Collections: React.FC<CollectionsProps> = ({ collections, onItemClick }) => {
|
||||
const { t } = useLanguage();
|
||||
const [isOpen, setIsOpen] = useState<boolean>(true);
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
@@ -41,7 +43,7 @@ const Collections: React.FC<CollectionsProps> = ({ collections, onItemClick }) =
|
||||
<Paper elevation={0} sx={{ bgcolor: 'transparent' }}>
|
||||
<ListItemButton onClick={() => setIsOpen(!isOpen)} sx={{ borderRadius: 1 }}>
|
||||
<Typography variant="h6" component="div" sx={{ flexGrow: 1, fontWeight: 600 }}>
|
||||
Collections
|
||||
{t('collections')}
|
||||
</Typography>
|
||||
{isOpen ? <ExpandLess /> : <ExpandMore />}
|
||||
</ListItemButton>
|
||||
|
||||
@@ -21,6 +21,8 @@ interface DeleteCollectionModalProps {
|
||||
videoCount: number;
|
||||
}
|
||||
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
|
||||
const DeleteCollectionModal: React.FC<DeleteCollectionModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
@@ -29,6 +31,7 @@ const DeleteCollectionModal: React.FC<DeleteCollectionModalProps> = ({
|
||||
collectionName,
|
||||
videoCount
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
@@ -41,7 +44,7 @@ const DeleteCollectionModal: React.FC<DeleteCollectionModalProps> = ({
|
||||
>
|
||||
<DialogTitle sx={{ m: 0, p: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="h6" component="div" sx={{ fontWeight: 600 }}>
|
||||
Delete Collection
|
||||
{t('deleteCollectionTitle')}
|
||||
</Typography>
|
||||
<IconButton
|
||||
aria-label="close"
|
||||
@@ -55,10 +58,10 @@ const DeleteCollectionModal: React.FC<DeleteCollectionModalProps> = ({
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<DialogContentText sx={{ mb: 2, color: 'text.primary' }}>
|
||||
Are you sure you want to delete the collection <strong>"{collectionName}"</strong>?
|
||||
{t('deleteCollectionConfirmation')} <strong>"{collectionName}"</strong>?
|
||||
</DialogContentText>
|
||||
<DialogContentText sx={{ mb: 3 }}>
|
||||
This collection contains <strong>{videoCount}</strong> video{videoCount !== 1 ? 's' : ''}.
|
||||
{t('collectionContains')} <strong>{videoCount}</strong> {t('videos')}.
|
||||
</DialogContentText>
|
||||
|
||||
<Stack spacing={2}>
|
||||
@@ -69,7 +72,7 @@ const DeleteCollectionModal: React.FC<DeleteCollectionModalProps> = ({
|
||||
fullWidth
|
||||
sx={{ justifyContent: 'center', py: 1.5 }}
|
||||
>
|
||||
Delete Collection Only
|
||||
{t('deleteCollectionOnly')}
|
||||
</Button>
|
||||
|
||||
{videoCount > 0 && (
|
||||
@@ -86,14 +89,14 @@ const DeleteCollectionModal: React.FC<DeleteCollectionModalProps> = ({
|
||||
boxShadow: (theme) => `0 4px 12px ${theme.palette.error.main}40`
|
||||
}}
|
||||
>
|
||||
Delete Collection & All {videoCount} Videos
|
||||
{t('deleteCollectionAndVideos')}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ p: 2 }}>
|
||||
<Button onClick={onClose} color="inherit">
|
||||
Cancel
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
ClickAwayListener,
|
||||
Collapse,
|
||||
IconButton,
|
||||
InputAdornment,
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
import { FormEvent, useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import logo from '../assets/logo.svg';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { Collection, Video } from '../types';
|
||||
import AuthorsList from './AuthorsList';
|
||||
import Collections from './Collections';
|
||||
@@ -77,6 +79,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
const navigate = useNavigate();
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const { t } = useLanguage();
|
||||
|
||||
|
||||
const isDownloading = activeDownloads.length > 0;
|
||||
@@ -105,7 +108,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
e.preventDefault();
|
||||
|
||||
if (!videoUrl.trim()) {
|
||||
setError('Please enter a video URL or search term');
|
||||
setError(t('pleaseEnterUrlOrSearchTerm'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -145,7 +148,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError('An unexpected error occurred. Please try again.');
|
||||
setError(t('unexpectedErrorOccurred'));
|
||||
console.error(err);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
@@ -162,7 +165,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
|
||||
const renderActionButtons = () => (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Tooltip title="Upload Video">
|
||||
<Tooltip title={t('uploadVideo')}>
|
||||
<IconButton color="inherit" onClick={() => setUploadModalOpen(true)} sx={{ mr: 1 }}>
|
||||
<CloudUpload />
|
||||
</IconButton>
|
||||
@@ -223,15 +226,17 @@ const Header: React.FC<HeaderProps> = ({
|
||||
{currentThemeMode === 'dark' ? <Brightness7 /> : <Brightness4 />}
|
||||
</IconButton>
|
||||
|
||||
<Tooltip title="Manage">
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={handleManageClick}
|
||||
sx={{ ml: 1 }}
|
||||
>
|
||||
<Settings />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{!isMobile && (
|
||||
<Tooltip title={t('manage')}>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={handleManageClick}
|
||||
sx={{ ml: 1 }}
|
||||
>
|
||||
<Settings />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Menu
|
||||
anchorEl={manageAnchorEl}
|
||||
open={Boolean(manageAnchorEl)}
|
||||
@@ -260,10 +265,10 @@ const Header: React.FC<HeaderProps> = ({
|
||||
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
|
||||
>
|
||||
<MenuItem onClick={() => { handleManageClose(); navigate('/manage'); }}>
|
||||
<VideoLibrary sx={{ mr: 2 }} /> Manage Content
|
||||
<VideoLibrary sx={{ mr: 2 }} /> {t('manageContent')}
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => { handleManageClose(); navigate('/settings'); }}>
|
||||
<Settings sx={{ mr: 2 }} /> Settings
|
||||
<Settings sx={{ mr: 2 }} /> {t('settings')}
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Box>
|
||||
@@ -274,7 +279,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
<TextField
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
placeholder="Enter YouTube/Bilibili URL or search term"
|
||||
placeholder={t('enterUrlOrSearchTerm')}
|
||||
value={videoUrl}
|
||||
onChange={(e) => setVideoUrl(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
@@ -306,63 +311,52 @@ const Header: React.FC<HeaderProps> = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<AppBar position="sticky" color="default" elevation={0} sx={{ bgcolor: 'background.paper', borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Toolbar sx={{ flexDirection: isMobile ? 'column' : 'row', alignItems: isMobile ? 'stretch' : 'center', py: isMobile ? 1 : 0 }}>
|
||||
{/* Top Bar for Mobile / Main Bar for Desktop */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: isMobile ? '100%' : 'auto', flexGrow: isMobile ? 0 : 0, mr: isMobile ? 0 : 2 }}>
|
||||
<Link to="/" onClick={onResetSearch} style={{ textDecoration: 'none', display: 'flex', alignItems: 'center', color: 'inherit' }}>
|
||||
<img src={logo} alt="MyTube Logo" height={40} />
|
||||
<Typography variant="h5" sx={{ ml: 1, fontWeight: 'bold' }}>
|
||||
MyTube
|
||||
</Typography>
|
||||
</Link>
|
||||
<ClickAwayListener onClickAway={() => setMobileMenuOpen(false)}>
|
||||
<AppBar position="sticky" color="default" elevation={0} sx={{ bgcolor: 'background.paper', borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Toolbar sx={{ flexDirection: isMobile ? 'column' : 'row', alignItems: isMobile ? 'stretch' : 'center', py: isMobile ? 1 : 0 }}>
|
||||
{/* Top Bar for Mobile / Main Bar for Desktop */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: isMobile ? '100%' : 'auto', flexGrow: isMobile ? 0 : 0, mr: isMobile ? 0 : 2 }}>
|
||||
<Link to="/" onClick={onResetSearch} style={{ textDecoration: 'none', display: 'flex', alignItems: 'center', color: 'inherit' }}>
|
||||
<img src={logo} alt="MyTube Logo" height={40} />
|
||||
<Typography variant="h5" sx={{ ml: 1, fontWeight: 'bold' }}>
|
||||
{t('myTube')}
|
||||
</Typography>
|
||||
</Link>
|
||||
|
||||
{isMobile && (
|
||||
<IconButton onClick={() => setMobileMenuOpen(!mobileMenuOpen)}>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
{isMobile && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
{renderActionButtons()}
|
||||
<IconButton onClick={() => setMobileMenuOpen(!mobileMenuOpen)}>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Desktop Layout */}
|
||||
{!isMobile && (
|
||||
<>
|
||||
<Box sx={{ flexGrow: 1, display: 'flex', justifyContent: 'center', maxWidth: 800, mx: 'auto' }}>
|
||||
{renderSearchInput()}
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', ml: 2 }}>
|
||||
{renderActionButtons()}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Desktop Layout */}
|
||||
{!isMobile && (
|
||||
<>
|
||||
<Box sx={{ flexGrow: 1, display: 'flex', justifyContent: 'center', maxWidth: 800, mx: 'auto' }}>
|
||||
{renderSearchInput()}
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', ml: 2 }}>
|
||||
{renderActionButtons()}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Mobile Dropdown Layout */}
|
||||
{isMobile && (
|
||||
<Collapse in={mobileMenuOpen} sx={{ width: '100%' }}>
|
||||
<Box sx={{ maxHeight: '80vh', overflowY: 'auto' }}>
|
||||
<Stack spacing={2} sx={{ py: 2 }}>
|
||||
{/* Row 1: Search Input */}
|
||||
<Box>
|
||||
{renderSearchInput()}
|
||||
</Box>
|
||||
{/* Row 2: Action Buttons */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||
{renderActionButtons()}
|
||||
</Box>
|
||||
|
||||
{/* Mobile Navigation Items */}
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Collections
|
||||
collections={collections}
|
||||
onItemClick={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<AuthorsList
|
||||
videos={videos}
|
||||
onItemClick={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
{/* Mobile Dropdown Layout */}
|
||||
{isMobile && (
|
||||
<Collapse in={mobileMenuOpen} sx={{ width: '100%' }}>
|
||||
<Box sx={{ maxHeight: '80vh', overflowY: 'auto' }}>
|
||||
<Stack spacing={2} sx={{ py: 2 }}>
|
||||
{/* Row 1: Search Input */}
|
||||
<Box>
|
||||
{renderSearchInput()}
|
||||
</Box>
|
||||
<Box sx={{ mt: 3, textAlign: 'center', mb: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
|
||||
{/* Mobile Navigation Buttons - Moved under search */}
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Button
|
||||
component={Link}
|
||||
to="/manage"
|
||||
@@ -371,7 +365,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
startIcon={<VideoLibrary />}
|
||||
>
|
||||
Manage Videos
|
||||
{t('manageVideos')}
|
||||
</Button>
|
||||
<Button
|
||||
component={Link}
|
||||
@@ -381,22 +375,46 @@ const Header: React.FC<HeaderProps> = ({
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
startIcon={<Settings />}
|
||||
>
|
||||
Settings
|
||||
{t('settings')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Collapse>
|
||||
)}
|
||||
</Toolbar>
|
||||
|
||||
<UploadModal
|
||||
open={uploadModalOpen}
|
||||
onClose={() => setUploadModalOpen(false)}
|
||||
onUploadSuccess={handleUploadSuccess}
|
||||
/>
|
||||
</AppBar>
|
||||
{/* Row 2: Action Buttons - Removed from here for mobile */}
|
||||
{/* <Box sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||
{renderActionButtons()}
|
||||
</Box> */}
|
||||
|
||||
{/* Mobile Navigation Items */}
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Collections
|
||||
collections={collections}
|
||||
onItemClick={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<AuthorsList
|
||||
videos={videos}
|
||||
onItemClick={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Collapse>
|
||||
)}
|
||||
</Toolbar>
|
||||
|
||||
<UploadModal
|
||||
open={uploadModalOpen}
|
||||
onClose={() => setUploadModalOpen(false)}
|
||||
onUploadSuccess={handleUploadSuccess}
|
||||
/>
|
||||
<UploadModal
|
||||
open={uploadModalOpen}
|
||||
onClose={() => setUploadModalOpen(false)}
|
||||
onUploadSuccess={handleUploadSuccess}
|
||||
/>
|
||||
</AppBar>
|
||||
</ClickAwayListener>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '@mui/material';
|
||||
import axios from 'axios';
|
||||
import { useState } from 'react';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
@@ -24,6 +25,7 @@ interface UploadModalProps {
|
||||
}
|
||||
|
||||
const UploadModal: React.FC<UploadModalProps> = ({ open, onClose, onUploadSuccess }) => {
|
||||
const { t } = useLanguage();
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [title, setTitle] = useState<string>('');
|
||||
const [author, setAuthor] = useState<string>('Admin');
|
||||
@@ -43,7 +45,7 @@ const UploadModal: React.FC<UploadModalProps> = ({ open, onClose, onUploadSucces
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!file) {
|
||||
setError('Please select a video file');
|
||||
setError(t('pleaseSelectVideo'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -71,7 +73,7 @@ const UploadModal: React.FC<UploadModalProps> = ({ open, onClose, onUploadSucces
|
||||
handleClose();
|
||||
} catch (err: any) {
|
||||
console.error('Upload failed:', err);
|
||||
setError(err.response?.data?.error || 'Failed to upload video');
|
||||
setError(err.response?.data?.error || t('failedToUpload'));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
@@ -88,7 +90,7 @@ const UploadModal: React.FC<UploadModalProps> = ({ open, onClose, onUploadSucces
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={!uploading ? handleClose : undefined} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Upload Video</DialogTitle>
|
||||
<DialogTitle>{t('uploadVideo')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={3} sx={{ mt: 1 }}>
|
||||
<Button
|
||||
@@ -98,7 +100,7 @@ const UploadModal: React.FC<UploadModalProps> = ({ open, onClose, onUploadSucces
|
||||
fullWidth
|
||||
sx={{ height: 100, borderStyle: 'dashed' }}
|
||||
>
|
||||
{file ? file.name : 'Select Video File'}
|
||||
{file ? file.name : t('selectVideoFile')}
|
||||
<input
|
||||
type="file"
|
||||
hidden
|
||||
@@ -108,7 +110,7 @@ const UploadModal: React.FC<UploadModalProps> = ({ open, onClose, onUploadSucces
|
||||
</Button>
|
||||
|
||||
<TextField
|
||||
label="Title"
|
||||
label={t('title')}
|
||||
fullWidth
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
@@ -116,7 +118,7 @@ const UploadModal: React.FC<UploadModalProps> = ({ open, onClose, onUploadSucces
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Author"
|
||||
label={t('author')}
|
||||
fullWidth
|
||||
value={author}
|
||||
onChange={(e) => setAuthor(e.target.value)}
|
||||
@@ -133,20 +135,20 @@ const UploadModal: React.FC<UploadModalProps> = ({ open, onClose, onUploadSucces
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<LinearProgress variant="determinate" value={progress} />
|
||||
<Typography variant="caption" color="text.secondary" align="center" display="block" sx={{ mt: 1 }}>
|
||||
Uploading... {progress}%
|
||||
{t('uploading')} {progress}%
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose} disabled={uploading}>Cancel</Button>
|
||||
<Button onClick={handleClose} disabled={uploading}>{t('cancel')}</Button>
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
variant="contained"
|
||||
disabled={!file || uploading}
|
||||
>
|
||||
{uploading ? <CircularProgress size={24} /> : 'Upload'}
|
||||
{uploading ? <CircularProgress size={24} /> : t('upload')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from '@mui/material';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { Collection, Video } from '../types';
|
||||
import ConfirmationModal from './ConfirmationModal';
|
||||
|
||||
@@ -38,6 +39,7 @@ const VideoCard: React.FC<VideoCardProps> = ({
|
||||
showDeleteButton = false,
|
||||
disableCollectionGrouping = false
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const navigate = useNavigate();
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
@@ -47,7 +49,7 @@ const VideoCard: React.FC<VideoCardProps> = ({
|
||||
// Format the date (assuming format YYYYMMDD from youtube-dl)
|
||||
const formatDate = (dateString: string) => {
|
||||
if (!dateString || dateString.length !== 8) {
|
||||
return 'Unknown date';
|
||||
return t('unknownDate');
|
||||
}
|
||||
|
||||
const year = dateString.substring(0, 4);
|
||||
@@ -175,7 +177,7 @@ const VideoCard: React.FC<VideoCardProps> = ({
|
||||
|
||||
{video.partNumber && video.totalParts && video.totalParts > 1 && (
|
||||
<Chip
|
||||
label={`Part ${video.partNumber}/${video.totalParts}`}
|
||||
label={`${t('part')} ${video.partNumber}/${video.totalParts}`}
|
||||
size="small"
|
||||
color="primary"
|
||||
sx={{ position: 'absolute', bottom: 8, right: 8 }}
|
||||
@@ -253,9 +255,9 @@ const VideoCard: React.FC<VideoCardProps> = ({
|
||||
isOpen={showDeleteModal}
|
||||
onClose={() => setShowDeleteModal(false)}
|
||||
onConfirm={confirmDelete}
|
||||
title="Delete Video"
|
||||
message={`Are you sure you want to delete "${video.title}"?`}
|
||||
confirmText="Delete"
|
||||
title={t('deleteVideo')}
|
||||
message={`${t('confirmDelete')} "${video.title}"?`}
|
||||
confirmText={t('delete')}
|
||||
isDanger={true}
|
||||
/>
|
||||
</>
|
||||
|
||||
74
frontend/src/contexts/LanguageContext.tsx
Normal file
74
frontend/src/contexts/LanguageContext.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import axios from 'axios';
|
||||
import React, { createContext, ReactNode, useContext, useEffect, useState } from 'react';
|
||||
import { Language, TranslationKey, translations } from '../utils/translations';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
interface LanguageContextType {
|
||||
language: Language;
|
||||
setLanguage: (lang: Language) => Promise<void>;
|
||||
t: (key: TranslationKey, replacements?: Record<string, string | number>) => string;
|
||||
}
|
||||
|
||||
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
|
||||
|
||||
export const LanguageProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [language, setLanguageState] = useState<Language>('en');
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/settings`);
|
||||
if (response.data.language) {
|
||||
setLanguageState(response.data.language);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching settings for language:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const setLanguage = async (lang: Language) => {
|
||||
setLanguageState(lang);
|
||||
try {
|
||||
// We need to fetch current settings first to not overwrite other settings
|
||||
// Or ideally the backend supports partial updates, but our controller expects full object usually
|
||||
// Let's fetch first to be safe
|
||||
const response = await axios.get(`${API_URL}/settings`);
|
||||
const currentSettings = response.data;
|
||||
|
||||
await axios.post(`${API_URL}/settings`, {
|
||||
...currentSettings,
|
||||
language: lang
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error saving language setting:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const t = (key: TranslationKey, replacements?: Record<string, string | number>): string => {
|
||||
let text = translations[language][key] || key;
|
||||
if (replacements) {
|
||||
Object.entries(replacements).forEach(([placeholder, value]) => {
|
||||
text = text.replace(`{${placeholder}}`, String(value));
|
||||
});
|
||||
}
|
||||
return text;
|
||||
};
|
||||
|
||||
return (
|
||||
<LanguageContext.Provider value={{ language, setLanguage, t }}>
|
||||
{children}
|
||||
</LanguageContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useLanguage = () => {
|
||||
const context = useContext(LanguageContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useLanguage must be used within a LanguageProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -13,6 +13,7 @@ import axios from 'axios';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import VideoCard from '../components/VideoCard';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { Collection, Video } from '../types';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
@@ -24,6 +25,7 @@ interface AuthorVideosProps {
|
||||
}
|
||||
|
||||
const AuthorVideos: React.FC<AuthorVideosProps> = ({ videos: allVideos, onDeleteVideo, collections = [] }) => {
|
||||
const { t } = useLanguage();
|
||||
const { author } = useParams<{ author: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [authorVideos, setAuthorVideos] = useState<Video[]>([]);
|
||||
@@ -72,7 +74,7 @@ const AuthorVideos: React.FC<AuthorVideosProps> = ({ videos: allVideos, onDelete
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '50vh' }}>
|
||||
<CircularProgress />
|
||||
<Typography sx={{ ml: 2 }}>Loading videos...</Typography>
|
||||
<Typography sx={{ ml: 2 }}>{t('loadingVideos')}</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -80,7 +82,7 @@ const AuthorVideos: React.FC<AuthorVideosProps> = ({ videos: allVideos, onDelete
|
||||
if (error) {
|
||||
return (
|
||||
<Container sx={{ mt: 4 }}>
|
||||
<Alert severity="error">{error}</Alert>
|
||||
<Alert severity="error">{t('loadVideosError')}</Alert>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -114,10 +116,10 @@ const AuthorVideos: React.FC<AuthorVideosProps> = ({ videos: allVideos, onDelete
|
||||
</Avatar>
|
||||
<Box>
|
||||
<Typography variant="h4" component="h1" fontWeight="bold">
|
||||
{author ? decodeURIComponent(author) : 'Unknown'}
|
||||
{author ? decodeURIComponent(author) : t('unknownAuthor')}
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" color="text.secondary">
|
||||
{authorVideos.length} video{authorVideos.length !== 1 ? 's' : ''}
|
||||
{authorVideos.length} {t('videos')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -126,12 +128,12 @@ const AuthorVideos: React.FC<AuthorVideosProps> = ({ videos: allVideos, onDelete
|
||||
startIcon={<ArrowBack />}
|
||||
onClick={handleBack}
|
||||
>
|
||||
Back
|
||||
{t('back')}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{authorVideos.length === 0 ? (
|
||||
<Alert severity="info" variant="outlined">No videos found for this author.</Alert>
|
||||
<Alert severity="info" variant="outlined">{t('noVideosForAuthor')}</Alert>
|
||||
) : (
|
||||
<Grid container spacing={3}>
|
||||
{filteredVideos.map(video => (
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import DeleteCollectionModal from '../components/DeleteCollectionModal';
|
||||
import VideoCard from '../components/VideoCard';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { Collection, Video } from '../types';
|
||||
|
||||
interface CollectionPageProps {
|
||||
@@ -24,6 +25,7 @@ interface CollectionPageProps {
|
||||
}
|
||||
|
||||
const CollectionPage: React.FC<CollectionPageProps> = ({ collections, videos, onDeleteVideo, onDeleteCollection }) => {
|
||||
const { t } = useLanguage();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [collection, setCollection] = useState<Collection | null>(null);
|
||||
@@ -63,7 +65,7 @@ const CollectionPage: React.FC<CollectionPageProps> = ({ collections, videos, on
|
||||
page * ITEMS_PER_PAGE
|
||||
);
|
||||
|
||||
const handlePageChange = (event: React.ChangeEvent<unknown>, value: number) => {
|
||||
const handlePageChange = (_: React.ChangeEvent<unknown>, value: number) => {
|
||||
setPage(value);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
@@ -98,7 +100,7 @@ const CollectionPage: React.FC<CollectionPageProps> = ({ collections, videos, on
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '50vh' }}>
|
||||
<CircularProgress />
|
||||
<Typography sx={{ ml: 2 }}>Loading collection...</Typography>
|
||||
<Typography sx={{ ml: 2 }}>{t('loadingCollection')}</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -106,7 +108,7 @@ const CollectionPage: React.FC<CollectionPageProps> = ({ collections, videos, on
|
||||
if (!collection) {
|
||||
return (
|
||||
<Container sx={{ mt: 4 }}>
|
||||
<Alert severity="error">Collection not found</Alert>
|
||||
<Alert severity="error">{t('collectionNotFound')}</Alert>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -123,7 +125,7 @@ const CollectionPage: React.FC<CollectionPageProps> = ({ collections, videos, on
|
||||
{collection.name}
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" color="text.secondary">
|
||||
{collectionVideos.length} video{collectionVideos.length !== 1 ? 's' : ''}
|
||||
{collectionVideos.length} {t('videos')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -132,12 +134,12 @@ const CollectionPage: React.FC<CollectionPageProps> = ({ collections, videos, on
|
||||
startIcon={<ArrowBack />}
|
||||
onClick={handleBack}
|
||||
>
|
||||
Back
|
||||
{t('back')}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{collectionVideos.length === 0 ? (
|
||||
<Alert severity="info" variant="outlined">No videos in this collection.</Alert>
|
||||
<Alert severity="info" variant="outlined">{t('noVideosInCollection')}</Alert>
|
||||
) : (
|
||||
<Box>
|
||||
<Grid container spacing={3}>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useEffect, useState } from 'react';
|
||||
import AuthorsList from '../components/AuthorsList';
|
||||
import Collections from '../components/Collections';
|
||||
import VideoCard from '../components/VideoCard';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { Collection, Video } from '../types';
|
||||
|
||||
interface SearchResult {
|
||||
@@ -62,6 +63,7 @@ const Home: React.FC<HomeProps> = ({
|
||||
}) => {
|
||||
const [page, setPage] = useState(1);
|
||||
const ITEMS_PER_PAGE = 12;
|
||||
const { t } = useLanguage();
|
||||
|
||||
// Reset page when filters change (though currently no filters other than search which is separate)
|
||||
useEffect(() => {
|
||||
@@ -76,7 +78,7 @@ const Home: React.FC<HomeProps> = ({
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '50vh' }}>
|
||||
<CircularProgress />
|
||||
<Typography sx={{ ml: 2 }}>Loading videos...</Typography>
|
||||
<Typography sx={{ ml: 2 }}>{t('loadingVideos')}</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -148,7 +150,7 @@ const Home: React.FC<HomeProps> = ({
|
||||
<Container maxWidth="xl" sx={{ py: 4 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4 }}>
|
||||
<Typography variant="h4" component="h1" fontWeight="bold">
|
||||
Search Results for "{searchTerm}"
|
||||
{t('searchResultsFor')} "{searchTerm}"
|
||||
</Typography>
|
||||
{onResetSearch && (
|
||||
<Button
|
||||
@@ -156,7 +158,7 @@ const Home: React.FC<HomeProps> = ({
|
||||
startIcon={<ArrowBack />}
|
||||
onClick={onResetSearch}
|
||||
>
|
||||
Back to Home
|
||||
{t('backToHome')}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
@@ -164,7 +166,7 @@ const Home: React.FC<HomeProps> = ({
|
||||
{/* Local Video Results */}
|
||||
<Box sx={{ mb: 6 }}>
|
||||
<Typography variant="h5" sx={{ mb: 3, fontWeight: 600, color: 'primary.main' }}>
|
||||
From Your Library
|
||||
{t('fromYourLibrary')}
|
||||
</Typography>
|
||||
{hasLocalResults ? (
|
||||
<Grid container spacing={3}>
|
||||
@@ -180,20 +182,20 @@ const Home: React.FC<HomeProps> = ({
|
||||
))}
|
||||
</Grid>
|
||||
) : (
|
||||
<Typography color="text.secondary">No matching videos in your library.</Typography>
|
||||
<Typography color="text.secondary">{t('noMatchingVideos')}</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* YouTube Search Results */}
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ mb: 3, fontWeight: 600, color: '#ff0000' }}>
|
||||
From YouTube
|
||||
{t('fromYouTube')}
|
||||
</Typography>
|
||||
|
||||
{youtubeLoading ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 4 }}>
|
||||
<CircularProgress color="error" />
|
||||
<Typography sx={{ mt: 2 }}>Loading YouTube results...</Typography>
|
||||
<Typography sx={{ mt: 2 }}>{t('loadingYouTubeResults')}</Typography>
|
||||
</Box>
|
||||
) : hasYouTubeResults ? (
|
||||
<Grid container spacing={3}>
|
||||
@@ -232,7 +234,7 @@ const Home: React.FC<HomeProps> = ({
|
||||
</Typography>
|
||||
{result.viewCount && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{formatViewCount(result.viewCount)} views
|
||||
{formatViewCount(result.viewCount)} {t('views')}
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -243,7 +245,7 @@ const Home: React.FC<HomeProps> = ({
|
||||
startIcon={<Download />}
|
||||
onClick={() => onDownload(result.sourceUrl, result.title)}
|
||||
>
|
||||
Download
|
||||
{t('download')}
|
||||
</Button>
|
||||
</CardActions>
|
||||
</Card>
|
||||
@@ -251,7 +253,7 @@ const Home: React.FC<HomeProps> = ({
|
||||
))}
|
||||
</Grid>
|
||||
) : (
|
||||
<Typography color="text.secondary">No YouTube results found.</Typography>
|
||||
<Typography color="text.secondary">{t('noYouTubeResults')}</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Container>
|
||||
@@ -264,7 +266,7 @@ const Home: React.FC<HomeProps> = ({
|
||||
{videoArray.length === 0 ? (
|
||||
<Box sx={{ textAlign: 'center', py: 8 }}>
|
||||
<Typography variant="h5" color="text.secondary">
|
||||
No videos yet. Submit a YouTube URL to download your first video!
|
||||
{t('noVideosYet')}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '@mui/material';
|
||||
import axios from 'axios';
|
||||
import React, { useState } from 'react';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import getTheme from '../theme';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
@@ -24,6 +25,7 @@ const LoginPage: React.FC<LoginPageProps> = ({ onLoginSuccess }) => {
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { t } = useLanguage();
|
||||
|
||||
// Use dark theme for login page to match app style
|
||||
const theme = getTheme('dark');
|
||||
@@ -38,14 +40,14 @@ const LoginPage: React.FC<LoginPageProps> = ({ onLoginSuccess }) => {
|
||||
if (response.data.success) {
|
||||
onLoginSuccess();
|
||||
} else {
|
||||
setError('Incorrect password');
|
||||
setError(t('incorrectPassword'));
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Login error:', err);
|
||||
if (err.response && err.response.status === 401) {
|
||||
setError('Incorrect password');
|
||||
setError(t('incorrectPassword'));
|
||||
} else {
|
||||
setError('Failed to verify password. Please try again.');
|
||||
setError(t('loginFailed'));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -68,7 +70,7 @@ const LoginPage: React.FC<LoginPageProps> = ({ onLoginSuccess }) => {
|
||||
<LockOutlined />
|
||||
</Avatar>
|
||||
<Typography component="h1" variant="h5">
|
||||
Sign in
|
||||
{t('signIn')}
|
||||
</Typography>
|
||||
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 1 }}>
|
||||
<TextField
|
||||
@@ -76,13 +78,14 @@ const LoginPage: React.FC<LoginPageProps> = ({ onLoginSuccess }) => {
|
||||
required
|
||||
fullWidth
|
||||
name="password"
|
||||
label="Password"
|
||||
label={t('password')}
|
||||
type="password"
|
||||
id="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoFocus
|
||||
helperText={t('defaultPasswordHint') || "Default password: 123"}
|
||||
/>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
@@ -96,7 +99,7 @@ const LoginPage: React.FC<LoginPageProps> = ({ onLoginSuccess }) => {
|
||||
sx={{ mt: 3, mb: 2 }}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Verifying...' : 'Sign In'}
|
||||
{loading ? t('verifying') : t('signIn')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -29,6 +29,7 @@ import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import ConfirmationModal from '../components/ConfirmationModal';
|
||||
import DeleteCollectionModal from '../components/DeleteCollectionModal';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { Collection, Video } from '../types';
|
||||
|
||||
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL;
|
||||
@@ -42,6 +43,7 @@ interface ManagePageProps {
|
||||
|
||||
const ManagePage: React.FC<ManagePageProps> = ({ videos, onDeleteVideo, collections = [], onDeleteCollection }) => {
|
||||
const [searchTerm, setSearchTerm] = useState<string>('');
|
||||
const { t } = useLanguage();
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [collectionToDelete, setCollectionToDelete] = useState<Collection | null>(null);
|
||||
const [isDeletingCollection, setIsDeletingCollection] = useState<boolean>(false);
|
||||
@@ -71,11 +73,11 @@ const ManagePage: React.FC<ManagePageProps> = ({ videos, onDeleteVideo, collecti
|
||||
videoPage * ITEMS_PER_PAGE
|
||||
);
|
||||
|
||||
const handleCollectionPageChange = (event: React.ChangeEvent<unknown>, value: number) => {
|
||||
const handleCollectionPageChange = (_: React.ChangeEvent<unknown>, value: number) => {
|
||||
setCollectionPage(value);
|
||||
};
|
||||
|
||||
const handleVideoPageChange = (event: React.ChangeEvent<unknown>, value: number) => {
|
||||
const handleVideoPageChange = (_: React.ChangeEvent<unknown>, value: number) => {
|
||||
setVideoPage(value);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
@@ -126,7 +128,7 @@ const ManagePage: React.FC<ManagePageProps> = ({ videos, onDeleteVideo, collecti
|
||||
<Container maxWidth="xl" sx={{ py: 4 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4 }}>
|
||||
<Typography variant="h4" component="h1" fontWeight="bold">
|
||||
Manage Content
|
||||
{t('manageContent')}
|
||||
</Typography>
|
||||
<Button
|
||||
component={Link}
|
||||
@@ -134,7 +136,7 @@ const ManagePage: React.FC<ManagePageProps> = ({ videos, onDeleteVideo, collecti
|
||||
variant="outlined"
|
||||
startIcon={<ArrowBack />}
|
||||
>
|
||||
Back to Home
|
||||
{t('backToHome')}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
@@ -154,16 +156,16 @@ const ManagePage: React.FC<ManagePageProps> = ({ videos, onDeleteVideo, collecti
|
||||
setVideoToDelete(null);
|
||||
}}
|
||||
onConfirm={confirmDeleteVideo}
|
||||
title="Delete Video"
|
||||
message="Are you sure you want to delete this video?"
|
||||
confirmText="Delete"
|
||||
title={t('deleteVideo')}
|
||||
message={t('confirmDelete')}
|
||||
confirmText={t('delete')}
|
||||
isDanger={true}
|
||||
/>
|
||||
|
||||
<Box sx={{ mb: 6 }}>
|
||||
<Typography variant="h5" sx={{ mb: 2, display: 'flex', alignItems: 'center' }}>
|
||||
<Folder sx={{ mr: 1, color: 'secondary.main' }} />
|
||||
Collections ({collections.length})
|
||||
{t('collections')} ({collections.length})
|
||||
</Typography>
|
||||
|
||||
{collections.length > 0 ? (
|
||||
@@ -171,10 +173,10 @@ const ManagePage: React.FC<ManagePageProps> = ({ videos, onDeleteVideo, collecti
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Videos</TableCell>
|
||||
<TableCell>Created</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
<TableCell>{t('name')}</TableCell>
|
||||
<TableCell>{t('videos')}</TableCell>
|
||||
<TableCell>{t('created')}</TableCell>
|
||||
<TableCell align="right">{t('actions')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
@@ -186,7 +188,7 @@ const ManagePage: React.FC<ManagePageProps> = ({ videos, onDeleteVideo, collecti
|
||||
<TableCell>{collection.videos.length} videos</TableCell>
|
||||
<TableCell>{new Date(collection.createdAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title="Delete Collection">
|
||||
<Tooltip title={t('deleteCollection')}>
|
||||
<IconButton
|
||||
color="error"
|
||||
onClick={() => confirmDeleteCollection(collection)}
|
||||
@@ -202,7 +204,7 @@ const ManagePage: React.FC<ManagePageProps> = ({ videos, onDeleteVideo, collecti
|
||||
</Table>
|
||||
</TableContainer>
|
||||
) : (
|
||||
<Alert severity="info" variant="outlined">No collections found.</Alert>
|
||||
<Alert severity="info" variant="outlined">{t('noCollections')}</Alert>
|
||||
)}
|
||||
|
||||
{totalCollectionPages > 1 && (
|
||||
@@ -223,7 +225,7 @@ const ManagePage: React.FC<ManagePageProps> = ({ videos, onDeleteVideo, collecti
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h5" sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<VideoLibrary sx={{ mr: 1, color: 'primary.main' }} />
|
||||
Videos ({filteredVideos.length})
|
||||
{t('videos')} ({filteredVideos.length})
|
||||
</Typography>
|
||||
<TextField
|
||||
placeholder="Search videos..."
|
||||
@@ -246,10 +248,10 @@ const ManagePage: React.FC<ManagePageProps> = ({ videos, onDeleteVideo, collecti
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Thumbnail</TableCell>
|
||||
<TableCell>Title</TableCell>
|
||||
<TableCell>Author</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
<TableCell>{t('thumbnail')}</TableCell>
|
||||
<TableCell>{t('title')}</TableCell>
|
||||
<TableCell>{t('author')}</TableCell>
|
||||
<TableCell align="right">{t('actions')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
@@ -268,7 +270,7 @@ const ManagePage: React.FC<ManagePageProps> = ({ videos, onDeleteVideo, collecti
|
||||
</TableCell>
|
||||
<TableCell>{video.author}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title="Delete Video">
|
||||
<Tooltip title={t('deleteVideo')}>
|
||||
<IconButton
|
||||
color="error"
|
||||
onClick={() => handleDelete(video.id)}
|
||||
@@ -284,7 +286,7 @@ const ManagePage: React.FC<ManagePageProps> = ({ videos, onDeleteVideo, collecti
|
||||
</Table>
|
||||
</TableContainer>
|
||||
) : (
|
||||
<Alert severity="info" variant="outlined">No videos found matching your search.</Alert>
|
||||
<Alert severity="info" variant="outlined">{t('noVideosFoundMatching')}</Alert>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -10,8 +10,12 @@ import {
|
||||
CardContent,
|
||||
Container,
|
||||
Divider,
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
Grid,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
Slider,
|
||||
Snackbar,
|
||||
Switch,
|
||||
@@ -21,6 +25,7 @@ import {
|
||||
import axios from 'axios';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
@@ -31,6 +36,7 @@ interface Settings {
|
||||
defaultAutoPlay: boolean;
|
||||
defaultAutoLoop: boolean;
|
||||
maxConcurrentDownloads: number;
|
||||
language: string;
|
||||
}
|
||||
|
||||
const SettingsPage: React.FC = () => {
|
||||
@@ -39,10 +45,12 @@ const SettingsPage: React.FC = () => {
|
||||
password: '',
|
||||
defaultAutoPlay: false,
|
||||
defaultAutoLoop: false,
|
||||
maxConcurrentDownloads: 3
|
||||
maxConcurrentDownloads: 3,
|
||||
language: 'en'
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<{ text: string; type: 'success' | 'error' } | null>(null);
|
||||
const { t, setLanguage } = useLanguage();
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
@@ -54,7 +62,7 @@ const SettingsPage: React.FC = () => {
|
||||
setSettings(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching settings:', error);
|
||||
setMessage({ text: 'Failed to load settings', type: 'error' });
|
||||
setMessage({ text: t('settingsFailed'), type: 'error' });
|
||||
} finally {
|
||||
// Loading finished
|
||||
}
|
||||
@@ -70,13 +78,13 @@ const SettingsPage: React.FC = () => {
|
||||
}
|
||||
|
||||
await axios.post(`${API_URL}/settings`, settingsToSend);
|
||||
setMessage({ text: 'Settings saved successfully', type: 'success' });
|
||||
setMessage({ text: t('settingsSaved'), type: 'success' });
|
||||
|
||||
// Clear password field after save
|
||||
setSettings(prev => ({ ...prev, password: '', isPasswordSet: true }));
|
||||
} catch (error) {
|
||||
console.error('Error saving settings:', error);
|
||||
setMessage({ text: 'Failed to save settings', type: 'error' });
|
||||
setMessage({ text: t('settingsFailed'), type: 'error' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -84,30 +92,55 @@ const SettingsPage: React.FC = () => {
|
||||
|
||||
const handleChange = (field: keyof Settings, value: any) => {
|
||||
setSettings(prev => ({ ...prev, [field]: value }));
|
||||
if (field === 'language') {
|
||||
setLanguage(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: 4 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4 }}>
|
||||
<Typography variant="h4" component="h1" fontWeight="bold">
|
||||
Settings
|
||||
{t('settings')}
|
||||
</Typography>
|
||||
<Button
|
||||
component={Link}
|
||||
to="/manage"
|
||||
to="/"
|
||||
variant="outlined"
|
||||
startIcon={<ArrowBack />}
|
||||
>
|
||||
Back to Manage
|
||||
{t('backToHome')}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Grid container spacing={4}>
|
||||
{/* General Settings */}
|
||||
<Grid size={12}>
|
||||
<Typography variant="h6" gutterBottom>{t('general')}</Typography>
|
||||
<Box sx={{ maxWidth: 400 }}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="language-select-label">{t('language')}</InputLabel>
|
||||
<Select
|
||||
labelId="language-select-label"
|
||||
id="language-select"
|
||||
value={settings.language || 'en'}
|
||||
label={t('language')}
|
||||
onChange={(e) => handleChange('language', e.target.value)}
|
||||
>
|
||||
<MenuItem value="en">English</MenuItem>
|
||||
<MenuItem value="zh">Chinese</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
<Grid size={12}><Divider /></Grid>
|
||||
|
||||
{/* Security Settings */}
|
||||
<Grid size={12}>
|
||||
<Typography variant="h6" gutterBottom>Security</Typography>
|
||||
<Typography variant="h6" gutterBottom>{t('security')}</Typography>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
@@ -115,21 +148,21 @@ const SettingsPage: React.FC = () => {
|
||||
onChange={(e) => handleChange('loginEnabled', e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Enable Login Protection"
|
||||
label={t('enableLogin')}
|
||||
/>
|
||||
|
||||
{settings.loginEnabled && (
|
||||
<Box sx={{ mt: 2, maxWidth: 400 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Password"
|
||||
label={t('password')}
|
||||
type="password"
|
||||
value={settings.password || ''}
|
||||
onChange={(e) => handleChange('password', e.target.value)}
|
||||
helperText={
|
||||
settings.isPasswordSet
|
||||
? "Leave empty to keep current password, or type to change"
|
||||
: "Set a password for accessing the application"
|
||||
? t('passwordHelper')
|
||||
: t('passwordSetHelper')
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
@@ -140,7 +173,7 @@ const SettingsPage: React.FC = () => {
|
||||
|
||||
{/* Video Defaults */}
|
||||
<Grid size={12}>
|
||||
<Typography variant="h6" gutterBottom>Video Player Defaults</Typography>
|
||||
<Typography variant="h6" gutterBottom>{t('videoDefaults')}</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
@@ -149,7 +182,7 @@ const SettingsPage: React.FC = () => {
|
||||
onChange={(e) => handleChange('defaultAutoPlay', e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Auto-play Videos"
|
||||
label={t('autoPlay')}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
@@ -158,7 +191,7 @@ const SettingsPage: React.FC = () => {
|
||||
onChange={(e) => handleChange('defaultAutoLoop', e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Auto-loop Videos"
|
||||
label={t('autoLoop')}
|
||||
/>
|
||||
</Box>
|
||||
</Grid>
|
||||
@@ -167,9 +200,9 @@ const SettingsPage: React.FC = () => {
|
||||
|
||||
{/* Download Settings */}
|
||||
<Grid size={12}>
|
||||
<Typography variant="h6" gutterBottom>Download Settings</Typography>
|
||||
<Typography variant="h6" gutterBottom>{t('downloadSettings')}</Typography>
|
||||
<Typography gutterBottom>
|
||||
Max Concurrent Downloads: {settings.maxConcurrentDownloads}
|
||||
{t('maxConcurrent')}: {settings.maxConcurrentDownloads}
|
||||
</Typography>
|
||||
<Box sx={{ maxWidth: 400, px: 2 }}>
|
||||
<Slider
|
||||
@@ -193,7 +226,7 @@ const SettingsPage: React.FC = () => {
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Settings'}
|
||||
{saving ? t('saving') : t('saveSettings')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Add,
|
||||
Delete,
|
||||
Download,
|
||||
FastForward,
|
||||
FastRewind,
|
||||
Folder,
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
Grid,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Rating,
|
||||
Select,
|
||||
Stack,
|
||||
TextField,
|
||||
@@ -41,6 +43,7 @@ import axios from 'axios';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import ConfirmationModal from '../components/ConfirmationModal';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { Collection, Comment, Video } from '../types';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
@@ -67,6 +70,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
const navigate = useNavigate();
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
const { t } = useLanguage();
|
||||
|
||||
const [video, setVideo] = useState<Video | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
@@ -90,7 +94,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
title: '',
|
||||
message: '',
|
||||
onConfirm: () => { },
|
||||
confirmText: 'Confirm',
|
||||
confirmText: t('confirm'),
|
||||
isDanger: false
|
||||
});
|
||||
|
||||
@@ -143,7 +147,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Error fetching video:', err);
|
||||
setError('Video not found or could not be loaded.');
|
||||
setError(t('videoNotFoundOrLoaded'));
|
||||
|
||||
// Redirect to home after 3 seconds if video not found
|
||||
setTimeout(() => {
|
||||
@@ -252,11 +256,11 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
// Navigate to home immediately after successful deletion
|
||||
navigate('/', { replace: true });
|
||||
} else {
|
||||
setDeleteError(result.error || 'Failed to delete video');
|
||||
setDeleteError(result.error || t('deleteFailed'));
|
||||
setIsDeleting(false);
|
||||
}
|
||||
} catch (err) {
|
||||
setDeleteError('An unexpected error occurred while deleting the video.');
|
||||
setDeleteError(t('unexpectedErrorOccurred'));
|
||||
console.error(err);
|
||||
setIsDeleting(false);
|
||||
}
|
||||
@@ -265,10 +269,10 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
const handleDelete = () => {
|
||||
setConfirmationModal({
|
||||
isOpen: true,
|
||||
title: 'Delete Video',
|
||||
message: 'Are you sure you want to delete this video?',
|
||||
title: t('deleteVideo'),
|
||||
message: t('confirmDelete'),
|
||||
onConfirm: executeDelete,
|
||||
confirmText: 'Delete',
|
||||
confirmText: t('delete'),
|
||||
isDanger: true
|
||||
});
|
||||
};
|
||||
@@ -323,19 +327,30 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
const handleRemoveFromCollection = () => {
|
||||
setConfirmationModal({
|
||||
isOpen: true,
|
||||
title: 'Remove from Collection',
|
||||
message: 'Are you sure you want to remove this video from the collection?',
|
||||
title: t('removeFromCollection'),
|
||||
message: t('confirmRemoveFromCollection'),
|
||||
onConfirm: executeRemoveFromCollection,
|
||||
confirmText: 'Remove',
|
||||
confirmText: t('remove'),
|
||||
isDanger: true
|
||||
});
|
||||
};
|
||||
|
||||
const handleRatingChange = async (event: React.SyntheticEvent, newValue: number | null) => {
|
||||
if (!newValue || !id) return;
|
||||
|
||||
try {
|
||||
await axios.post(`${API_URL}/videos/${id}/rate`, { rating: newValue });
|
||||
setVideo(prev => prev ? { ...prev, rating: newValue } : null);
|
||||
} catch (error) {
|
||||
console.error('Error updating rating:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '50vh' }}>
|
||||
<CircularProgress />
|
||||
<Typography sx={{ ml: 2 }}>Loading video...</Typography>
|
||||
<Typography sx={{ ml: 2 }}>{t('loadingVideo')}</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -343,7 +358,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
if (error || !video) {
|
||||
return (
|
||||
<Container sx={{ mt: 4 }}>
|
||||
<Alert severity="error">{error || 'Video not found'}</Alert>
|
||||
<Alert severity="error">{error || t('videoNotFound')}</Alert>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -386,7 +401,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
startIcon={isPlaying ? <Pause /> : <PlayArrow />}
|
||||
fullWidth={isMobile}
|
||||
>
|
||||
{isPlaying ? "Pause" : "Play"}
|
||||
{isPlaying ? t('paused') : t('playing')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@@ -396,7 +411,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
startIcon={<Loop />}
|
||||
fullWidth={isMobile}
|
||||
>
|
||||
Loop {isLooping ? "On" : "Off"}
|
||||
{t('loop')} {isLooping ? t('on') : t('off')}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
@@ -424,6 +439,16 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
{video.title}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<Rating
|
||||
value={video.rating || 0}
|
||||
onChange={handleRatingChange}
|
||||
/>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ ml: 1 }}>
|
||||
{video.rating ? `(${video.rating})` : t('rateThisVideo')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Stack
|
||||
direction={{ xs: 'column', sm: 'row' }}
|
||||
justifyContent="space-between"
|
||||
@@ -456,7 +481,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
startIcon={<Add />}
|
||||
onClick={handleAddToCollection}
|
||||
>
|
||||
Add to Collection
|
||||
{t('addToCollection')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
@@ -465,7 +490,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
{isDeleting ? t('deleting') : t('delete')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
@@ -483,23 +508,31 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
{video.sourceUrl && (
|
||||
<Typography variant="body2">
|
||||
<a href={video.sourceUrl} target="_blank" rel="noopener noreferrer" style={{ color: theme.palette.primary.main, textDecoration: 'none' }}>
|
||||
<strong>Original Link</strong>
|
||||
<strong>{t('originalLink')}</strong>
|
||||
</a>
|
||||
</Typography>
|
||||
)}
|
||||
{video.videoPath && (
|
||||
<Typography variant="body2" sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<a href={`${BACKEND_URL}${video.videoPath}`} download style={{ color: theme.palette.primary.main, textDecoration: 'none', display: 'flex', alignItems: 'center' }}>
|
||||
<Download fontSize="small" sx={{ mr: 0.5 }} />
|
||||
<strong>{t('download')}</strong>
|
||||
</a>
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="body2">
|
||||
<strong>Source:</strong> {video.source === 'bilibili' ? 'Bilibili' : (video.source === 'local' ? 'Local Upload' : 'YouTube')}
|
||||
<strong>{t('source')}</strong> {video.source === 'bilibili' ? 'Bilibili' : (video.source === 'local' ? 'Local Upload' : 'YouTube')}
|
||||
</Typography>
|
||||
{video.addedAt && (
|
||||
<Typography variant="body2">
|
||||
<strong>Added Date:</strong> {new Date(video.addedAt).toLocaleDateString()}
|
||||
<strong>{t('addedDate')}</strong> {new Date(video.addedAt).toLocaleDateString()}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{videoCollections.length > 0 && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>Collections:</Typography>
|
||||
<Typography variant="subtitle2" gutterBottom>{t('collections')}:</Typography>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap">
|
||||
{videoCollections.map(c => (
|
||||
<Chip
|
||||
@@ -521,7 +554,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
{/* Comments Section */}
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<Typography variant="h6" gutterBottom fontWeight="bold">
|
||||
Latest Comments
|
||||
{t('latestComments')}
|
||||
</Typography>
|
||||
|
||||
{loadingComments ? (
|
||||
@@ -553,7 +586,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
</Stack>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No comments available.
|
||||
{t('noComments')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
@@ -562,7 +595,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
|
||||
{/* Sidebar Column - Up Next */}
|
||||
<Grid size={{ xs: 12, lg: 3 }}>
|
||||
<Typography variant="h6" gutterBottom fontWeight="bold">Up Next</Typography>
|
||||
<Typography variant="h6" gutterBottom fontWeight="bold">{t('upNext')}</Typography>
|
||||
<Stack spacing={2}>
|
||||
{relatedVideos.map(relatedVideo => (
|
||||
<Card
|
||||
@@ -612,7 +645,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
</Card>
|
||||
))}
|
||||
{relatedVideos.length === 0 && (
|
||||
<Typography variant="body2" color="text.secondary">No other videos available</Typography>
|
||||
<Typography variant="body2" color="text.secondary">{t('noOtherVideos')}</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
</Grid>
|
||||
@@ -620,30 +653,30 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
|
||||
{/* Collection Modal */}
|
||||
<Dialog open={showCollectionModal} onClose={handleCloseModal} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Add to Collection</DialogTitle>
|
||||
<DialogTitle>{t('addToCollection')}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
{videoCollections.length > 0 && (
|
||||
<Alert severity="info" sx={{ mb: 3 }} action={
|
||||
<Button color="error" size="small" onClick={handleRemoveFromCollection}>
|
||||
Remove
|
||||
{t('remove')}
|
||||
</Button>
|
||||
}>
|
||||
Currently in: <strong>{videoCollections[0].name}</strong>
|
||||
{t('currentlyIn')} <strong>{videoCollections[0].name}</strong>
|
||||
<Typography variant="caption" display="block">
|
||||
Adding to a different collection will remove it from the current one.
|
||||
{t('collectionWarning')}
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{collections && collections.length > 0 && (
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>Add to existing collection:</Typography>
|
||||
<Typography variant="subtitle2" gutterBottom>{t('addToExistingCollection')}</Typography>
|
||||
<Stack direction="row" spacing={2}>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>Select a collection</InputLabel>
|
||||
<InputLabel>{t('selectCollection')}</InputLabel>
|
||||
<Select
|
||||
value={selectedCollection}
|
||||
label="Select a collection"
|
||||
label={t('selectCollection')}
|
||||
onChange={(e) => setSelectedCollection(e.target.value)}
|
||||
>
|
||||
{collections.map(collection => (
|
||||
@@ -652,7 +685,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
value={collection.id}
|
||||
disabled={videoCollections.length > 0 && videoCollections[0].id === collection.id}
|
||||
>
|
||||
{collection.name} {videoCollections.length > 0 && videoCollections[0].id === collection.id ? '(Current)' : ''}
|
||||
{collection.name} {videoCollections.length > 0 && videoCollections[0].id === collection.id ? t('current') : ''}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
@@ -662,19 +695,19 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
onClick={handleAddToExistingCollection}
|
||||
disabled={!selectedCollection}
|
||||
>
|
||||
Add
|
||||
{t('add')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>Create new collection:</Typography>
|
||||
<Typography variant="subtitle2" gutterBottom>{t('createNewCollection')}</Typography>
|
||||
<Stack direction="row" spacing={2}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label="Collection name"
|
||||
label={t('collectionName')}
|
||||
value={newCollectionName}
|
||||
onChange={(e) => setNewCollectionName(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && newCollectionName.trim() && handleCreateCollection()}
|
||||
@@ -684,13 +717,13 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
onClick={handleCreateCollection}
|
||||
disabled={!newCollectionName.trim()}
|
||||
>
|
||||
Create
|
||||
{t('create')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCloseModal} color="inherit">Cancel</Button>
|
||||
<Button onClick={handleCloseModal} color="inherit">{t('cancel')}</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface Video {
|
||||
partNumber?: number;
|
||||
totalParts?: number;
|
||||
seriesTitle?: string;
|
||||
rating?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
|
||||
343
frontend/src/utils/translations.ts
Normal file
343
frontend/src/utils/translations.ts
Normal file
@@ -0,0 +1,343 @@
|
||||
export const translations = {
|
||||
en: {
|
||||
// Header
|
||||
myTube: "MyTube",
|
||||
manage: "Manage",
|
||||
settings: "Settings",
|
||||
logout: "Logout",
|
||||
pleaseEnterUrlOrSearchTerm: "Please enter a video URL or search term",
|
||||
unexpectedErrorOccurred: "An unexpected error occurred. Please try again.",
|
||||
uploadVideo: "Upload Video",
|
||||
enterUrlOrSearchTerm: "Enter YouTube/Bilibili URL or search term",
|
||||
manageVideos: "Manage Videos",
|
||||
|
||||
// Home
|
||||
pasteUrl: "Paste video or collection URL",
|
||||
download: "Download",
|
||||
search: "Search",
|
||||
recentDownloads: "Recent Downloads",
|
||||
noDownloads: "No downloads yet",
|
||||
downloadStarted: "Download started",
|
||||
downloadFailed: "Download failed",
|
||||
loadingVideos: "Loading videos...",
|
||||
searchResultsFor: "Search Results for",
|
||||
fromYourLibrary: "From Your Library",
|
||||
noMatchingVideos: "No matching videos in your library.",
|
||||
fromYouTube: "From YouTube",
|
||||
loadingYouTubeResults: "Loading YouTube results...",
|
||||
noYouTubeResults: "No YouTube results found",
|
||||
noVideosYet: "No videos yet. Submit a YouTube URL to download your first video!",
|
||||
views: "views",
|
||||
|
||||
// Settings
|
||||
general: "General",
|
||||
security: "Security",
|
||||
videoDefaults: "Video Player Defaults",
|
||||
downloadSettings: "Download Settings",
|
||||
language: "Language",
|
||||
enableLogin: "Enable Login Protection",
|
||||
password: "Password",
|
||||
passwordHelper: "Leave empty to keep current password, or type to change",
|
||||
passwordSetHelper: "Set a password for accessing the application",
|
||||
autoPlay: "Auto-play Videos",
|
||||
autoLoop: "Auto-loop Videos",
|
||||
maxConcurrent: "Max Concurrent Downloads",
|
||||
saveSettings: "Save Settings",
|
||||
saving: "Saving...",
|
||||
backToManage: "Back to Manage",
|
||||
settingsSaved: "Settings saved successfully",
|
||||
settingsFailed: "Failed to save settings",
|
||||
|
||||
// Manage
|
||||
manageContent: "Manage Content",
|
||||
videos: "Videos",
|
||||
collections: "Collections",
|
||||
delete: "Delete",
|
||||
backToHome: "Back to Home",
|
||||
confirmDelete: "Are you sure you want to delete this?",
|
||||
deleteSuccess: "Deleted successfully",
|
||||
deleteFailed: "Failed to delete",
|
||||
noVideos: "No videos found",
|
||||
noCollections: "No collections found",
|
||||
searchVideos: "Search videos...",
|
||||
thumbnail: "Thumbnail",
|
||||
title: "Title",
|
||||
author: "Author",
|
||||
authors: "Authors",
|
||||
created: "Created",
|
||||
name: "Name",
|
||||
actions: "Actions",
|
||||
deleteCollection: "Delete Collection",
|
||||
deleteVideo: "Delete Video",
|
||||
noVideosFoundMatching: "No videos found matching your search.",
|
||||
|
||||
// Video Player
|
||||
playing: "Playing",
|
||||
paused: "Paused",
|
||||
next: "Next",
|
||||
previous: "Previous",
|
||||
loop: "Loop",
|
||||
autoPlayOn: "Auto-play On",
|
||||
autoPlayOff: "Auto-play Off",
|
||||
videoNotFound: "Video not found",
|
||||
videoNotFoundOrLoaded: "Video not found or could not be loaded.",
|
||||
deleting: "Deleting...",
|
||||
addToCollection: "Add to Collection",
|
||||
originalLink: "Original Link",
|
||||
source: "Source:",
|
||||
addedDate: "Added Date:",
|
||||
latestComments: "Latest Comments",
|
||||
noComments: "No comments available.",
|
||||
upNext: "Up Next",
|
||||
noOtherVideos: "No other videos available",
|
||||
currentlyIn: "Currently in:",
|
||||
collectionWarning: "Adding to a different collection will remove it from the current one.",
|
||||
addToExistingCollection: "Add to existing collection:",
|
||||
selectCollection: "Select a collection",
|
||||
add: "Add",
|
||||
createNewCollection: "Create new collection:",
|
||||
collectionName: "Collection name",
|
||||
create: "Create",
|
||||
removeFromCollection: "Remove from Collection",
|
||||
confirmRemoveFromCollection: "Are you sure you want to remove this video from the collection?",
|
||||
remove: "Remove",
|
||||
loadingVideo: "Loading video...",
|
||||
current: "(Current)",
|
||||
rateThisVideo: "Rate this video",
|
||||
|
||||
// Login
|
||||
signIn: "Sign in",
|
||||
verifying: "Verifying...",
|
||||
incorrectPassword: "Incorrect password",
|
||||
loginFailed: "Failed to verify password",
|
||||
defaultPasswordHint: "Default password: 123",
|
||||
|
||||
// Collection Page
|
||||
loadingCollection: "Loading collection...",
|
||||
collectionNotFound: "Collection not found",
|
||||
noVideosInCollection: "No videos in this collection.",
|
||||
back: "Back",
|
||||
|
||||
// Author Videos
|
||||
loadVideosError: "Failed to load videos. Please try again later.",
|
||||
unknownAuthor: "Unknown",
|
||||
noVideosForAuthor: "No videos found for this author.",
|
||||
|
||||
// Delete Collection Modal
|
||||
deleteCollectionTitle: "Delete Collection",
|
||||
deleteCollectionConfirmation: "Are you sure you want to delete the collection",
|
||||
collectionContains: "This collection contains",
|
||||
deleteCollectionOnly: "Delete Collection Only",
|
||||
deleteCollectionAndVideos: "Delete Collection & All Videos",
|
||||
|
||||
// Common
|
||||
loading: "Loading...",
|
||||
error: "Error",
|
||||
success: "Success",
|
||||
cancel: "Cancel",
|
||||
confirm: "Confirm",
|
||||
on: "On",
|
||||
off: "Off",
|
||||
|
||||
// Video Card
|
||||
unknownDate: "Unknown date",
|
||||
part: "Part",
|
||||
|
||||
// Upload Modal
|
||||
selectVideoFile: "Select Video File",
|
||||
pleaseSelectVideo: "Please select a video file",
|
||||
uploadFailed: "Upload failed",
|
||||
failedToUpload: "Failed to upload video",
|
||||
uploading: "Uploading...",
|
||||
upload: "Upload",
|
||||
|
||||
// Bilibili Modal
|
||||
bilibiliCollectionDetected: "Bilibili Collection Detected",
|
||||
bilibiliSeriesDetected: "Bilibili Series Detected",
|
||||
multiPartVideoDetected: "Multi-part Video Detected",
|
||||
collectionHasVideos: "This Bilibili collection has {count} videos.",
|
||||
seriesHasVideos: "This Bilibili series has {count} videos.",
|
||||
videoHasParts: "This Bilibili video has {count} parts.",
|
||||
downloadAllVideos: "Download All {count} Videos",
|
||||
downloadAllParts: "Download All {count} Parts",
|
||||
downloadThisVideoOnly: "Download This Video Only",
|
||||
downloadCurrentPartOnly: "Download Current Part Only",
|
||||
processing: "Processing...",
|
||||
wouldYouLikeToDownloadAllParts: "Would you like to download all parts?",
|
||||
wouldYouLikeToDownloadAllVideos: "Would you like to download all videos?",
|
||||
allPartsAddedToCollection: "All parts will be added to this collection",
|
||||
allVideosAddedToCollection: "All videos will be added to this collection"
|
||||
},
|
||||
zh: {
|
||||
// Header
|
||||
myTube: "MyTube",
|
||||
manage: "管理",
|
||||
settings: "设置",
|
||||
logout: "退出",
|
||||
pleaseEnterUrlOrSearchTerm: "请输入视频链接或搜索关键词",
|
||||
unexpectedErrorOccurred: "发生意外错误,请重试",
|
||||
uploadVideo: "上传视频",
|
||||
enterUrlOrSearchTerm: "输入 YouTube/Bilibili 链接或搜索关键词",
|
||||
manageVideos: "管理视频",
|
||||
|
||||
// Home
|
||||
pasteUrl: "粘贴视频或合集链接",
|
||||
download: "下载",
|
||||
search: "搜索",
|
||||
recentDownloads: "最近下载",
|
||||
noDownloads: "暂无下载",
|
||||
downloadStarted: "开始下载",
|
||||
downloadFailed: "下载失败",
|
||||
loadingVideos: "加载视频中...",
|
||||
searchResultsFor: "搜索结果:",
|
||||
fromYourLibrary: "来自您的媒体库",
|
||||
noMatchingVideos: "媒体库中未找到匹配视频",
|
||||
fromYouTube: "来自 YouTube",
|
||||
loadingYouTubeResults: "加载 YouTube 结果中...",
|
||||
noYouTubeResults: "未找到 YouTube 结果",
|
||||
noVideosYet: "暂无视频。提交 YouTube 链接以下载您的第一个视频!",
|
||||
views: "次观看",
|
||||
|
||||
// Settings
|
||||
general: "常规",
|
||||
security: "安全",
|
||||
videoDefaults: "播放器默认设置",
|
||||
downloadSettings: "下载设置",
|
||||
language: "语言",
|
||||
enableLogin: "启用登录保护",
|
||||
password: "密码",
|
||||
passwordHelper: "留空以保持当前密码,或输入新密码以更改",
|
||||
passwordSetHelper: "设置访问应用程序的密码",
|
||||
autoPlay: "自动播放视频",
|
||||
autoLoop: "自动循环播放",
|
||||
maxConcurrent: "最大同时下载数",
|
||||
saveSettings: "保存设置",
|
||||
saving: "保存中...",
|
||||
backToManage: "返回管理",
|
||||
settingsSaved: "设置保存成功",
|
||||
settingsFailed: "保存设置失败",
|
||||
|
||||
// Manage
|
||||
manageContent: "内容管理",
|
||||
videos: "视频",
|
||||
collections: "合集",
|
||||
delete: "删除",
|
||||
backToHome: "返回首页",
|
||||
confirmDelete: "确定要删除吗?",
|
||||
deleteSuccess: "删除成功",
|
||||
deleteFailed: "删除失败",
|
||||
noVideos: "未找到视频",
|
||||
noCollections: "未找到合集",
|
||||
searchVideos: "搜索视频...",
|
||||
thumbnail: "缩略图",
|
||||
title: "标题",
|
||||
author: "作者",
|
||||
authors: "作者列表",
|
||||
created: "创建时间",
|
||||
name: "名称",
|
||||
actions: "操作",
|
||||
deleteCollection: "删除合集",
|
||||
deleteVideo: "删除视频",
|
||||
noVideosFoundMatching: "未找到匹配的视频。",
|
||||
|
||||
// Video Player
|
||||
playing: "播放中",
|
||||
paused: "已暂停",
|
||||
next: "下一个",
|
||||
previous: "上一个",
|
||||
loop: "循环",
|
||||
autoPlayOn: "自动播放已开启",
|
||||
autoPlayOff: "自动播放已关闭",
|
||||
videoNotFound: "未找到视频",
|
||||
videoNotFoundOrLoaded: "未找到视频或无法加载。",
|
||||
deleting: "删除中...",
|
||||
addToCollection: "添加到合集",
|
||||
originalLink: "原始链接",
|
||||
source: "来源:",
|
||||
addedDate: "添加日期:",
|
||||
latestComments: "最新评论",
|
||||
noComments: "暂无评论。",
|
||||
upNext: "接下来播放",
|
||||
noOtherVideos: "没有其他视频",
|
||||
currentlyIn: "当前所在:",
|
||||
collectionWarning: "添加到其他合集将从当前合集中移除。",
|
||||
addToExistingCollection: "添加到现有合集:",
|
||||
selectCollection: "选择合集",
|
||||
add: "添加",
|
||||
createNewCollection: "创建新合集:",
|
||||
collectionName: "合集名称",
|
||||
create: "创建",
|
||||
removeFromCollection: "从合集中移除",
|
||||
confirmRemoveFromCollection: "确定要从合集中移除此视频吗?",
|
||||
remove: "移除",
|
||||
loadingVideo: "加载视频中...",
|
||||
current: "(当前)",
|
||||
rateThisVideo: "给视频评分",
|
||||
|
||||
// Login
|
||||
signIn: "登录",
|
||||
verifying: "验证中...",
|
||||
incorrectPassword: "密码错误",
|
||||
loginFailed: "验证密码失败",
|
||||
defaultPasswordHint: "默认密码:123",
|
||||
|
||||
// Collection Page
|
||||
loadingCollection: "加载合集中...",
|
||||
collectionNotFound: "未找到合集",
|
||||
noVideosInCollection: "此合集中没有视频。",
|
||||
back: "返回",
|
||||
|
||||
// Author Videos
|
||||
loadVideosError: "加载视频失败,请稍后再试。",
|
||||
unknownAuthor: "未知",
|
||||
noVideosForAuthor: "未找到该作者的视频。",
|
||||
|
||||
// Delete Collection Modal
|
||||
deleteCollectionTitle: "删除合集",
|
||||
deleteCollectionConfirmation: "确定要删除合集",
|
||||
collectionContains: "此合集包含",
|
||||
deleteCollectionOnly: "仅删除合集",
|
||||
deleteCollectionAndVideos: "删除合集及所有视频",
|
||||
|
||||
// Common
|
||||
loading: "加载中...",
|
||||
error: "错误",
|
||||
success: "成功",
|
||||
cancel: "取消",
|
||||
confirm: "确认",
|
||||
on: "开启",
|
||||
off: "关闭",
|
||||
|
||||
// Video Card
|
||||
unknownDate: "未知日期",
|
||||
part: "分P",
|
||||
|
||||
// Upload Modal
|
||||
selectVideoFile: "选择视频文件",
|
||||
pleaseSelectVideo: "请选择视频文件",
|
||||
uploadFailed: "上传失败",
|
||||
failedToUpload: "视频上传失败",
|
||||
uploading: "上传中...",
|
||||
upload: "上传",
|
||||
|
||||
// Bilibili Modal
|
||||
bilibiliCollectionDetected: "检测到 Bilibili 合集",
|
||||
bilibiliSeriesDetected: "检测到 Bilibili 系列",
|
||||
multiPartVideoDetected: "检测到多P视频",
|
||||
collectionHasVideos: "此合集包含 {count} 个视频。",
|
||||
seriesHasVideos: "此系列包含 {count} 个视频。",
|
||||
videoHasParts: "此视频包含 {count} 个分P。",
|
||||
downloadAllVideos: "下载所有 {count} 个视频",
|
||||
downloadAllParts: "下载所有 {count} 个分P",
|
||||
downloadThisVideoOnly: "仅下载此视频",
|
||||
downloadCurrentPartOnly: "仅下载当前分P",
|
||||
processing: "处理中...",
|
||||
wouldYouLikeToDownloadAllParts: "您想要下载所有分P吗?",
|
||||
wouldYouLikeToDownloadAllVideos: "您想要下载所有视频吗?",
|
||||
allPartsAddedToCollection: "所有分P将被添加到此合集",
|
||||
allVideosAddedToCollection: "所有视频将被添加到此合集"
|
||||
}
|
||||
};
|
||||
|
||||
export type Language = 'en' | 'zh';
|
||||
export type TranslationKey = keyof typeof translations.en;
|
||||
Reference in New Issue
Block a user