feat: add rating; UI adjustment

This commit is contained in:
Peifan Li
2025-11-23 11:42:09 -05:00
parent dc7b0a4478
commit e010a749e1
23 changed files with 934 additions and 357 deletions

View File

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

View File

@@ -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) => {

View File

@@ -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" });
}
};

View File

@@ -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);

View File

@@ -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();

View File

@@ -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;

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>
);
};

View File

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

View File

@@ -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}
/>
</>

View 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;
};

View File

@@ -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 => (

View File

@@ -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}>

View File

@@ -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>
) : (

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,6 +14,7 @@ export interface Video {
partNumber?: number;
totalParts?: number;
seriesTitle?: string;
rating?: number;
[key: string]: any;
}

View 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;