feat: add visitor mode (read only)

This commit is contained in:
Peifan Li
2025-12-18 21:26:26 -05:00
parent 716cb8da7c
commit 60a02e8e7e
33 changed files with 638 additions and 324 deletions

View File

@@ -36,6 +36,7 @@ interface Settings {
proxyOnlyYoutube?: boolean;
moveSubtitlesToVideoFolder?: boolean;
moveThumbnailsToVideoFolder?: boolean;
visitorMode?: boolean;
}
const defaultSettings: Settings = {
@@ -54,6 +55,7 @@ const defaultSettings: Settings = {
websiteName: "MyTube",
itemsPerPage: 12,
showYoutubeSearch: true,
visitorMode: false,
};
/**
@@ -160,6 +162,45 @@ export const updateSettings = async (
res: Response
): Promise<void> => {
const newSettings: Settings = req.body;
const existingSettings = storageService.getSettings();
const mergedSettings = { ...defaultSettings, ...existingSettings };
// Check if visitor mode is enabled
// If visitor mode is enabled, only allow disabling it (setting visitorMode to false)
if (mergedSettings.visitorMode === true) {
// If visitorMode is being explicitly set to false, allow the update
if (newSettings.visitorMode === false) {
// Allow disabling visitor mode - merge with existing settings
const updatedSettings = { ...mergedSettings, ...newSettings };
storageService.saveSettings(updatedSettings);
res.json({
success: true,
settings: { ...updatedSettings, password: undefined },
});
return;
}
// If visitorMode is explicitly set to true (already enabled), allow but ignore other changes
if (newSettings.visitorMode === true) {
// Allow enabling visitor mode (though it's already enabled)
// But block all other changes - only update visitorMode
const allowedSettings: Settings = {
...mergedSettings,
visitorMode: true,
};
storageService.saveSettings(allowedSettings);
res.json({
success: true,
settings: { ...allowedSettings, password: undefined },
});
return;
}
// If visitor mode is enabled and trying to change other settings (without explicitly disabling visitor mode), block it
res.status(403).json({
success: false,
error: "Visitor mode is enabled. Only disabling visitor mode is allowed.",
});
return;
}
// Validate settings if needed
if (newSettings.maxConcurrentDownloads < 1) {
@@ -181,12 +222,10 @@ export const updateSettings = async (
newSettings.password = await bcrypt.hash(newSettings.password, salt);
} else {
// If password is empty/not provided, keep existing password
const existingSettings = storageService.getSettings();
newSettings.password = existingSettings.password;
}
// Preserve existing tags if tags are not explicitly provided or are empty when tags existed
const existingSettings = storageService.getSettings();
const oldTags: string[] = existingSettings.tags || [];
// If tags is undefined, preserve existing tags

View File

@@ -0,0 +1,53 @@
import { Request, Response, NextFunction } from "express";
import * as storageService from "../services/storageService";
/**
* Middleware to block write operations when visitor mode is enabled
* Only allows disabling visitor mode (POST /settings with visitorMode: false)
*/
export const visitorModeMiddleware = (
req: Request,
res: Response,
next: NextFunction
): void => {
const settings = storageService.getSettings();
const visitorMode = settings.visitorMode === true;
if (!visitorMode) {
// Visitor mode is not enabled, allow all requests
next();
return;
}
// Visitor mode is enabled
// Allow GET requests (read-only)
if (req.method === "GET") {
next();
return;
}
// This middleware is applied to settings routes, so any POST here is a settings update
// Check if the request is trying to disable visitor mode
if (req.method === "POST") {
const body = req.body || {};
// Check if the request is trying to disable visitor mode
if (body.visitorMode === false) {
// Allow disabling visitor mode
next();
return;
}
// Block all other settings updates
res.status(403).json({
success: false,
error: "Visitor mode is enabled. Only disabling visitor mode is allowed.",
});
return;
}
// Block all other write operations (PUT, DELETE, PATCH)
res.status(403).json({
success: false,
error: "Visitor mode is enabled. Write operations are not allowed.",
});
};

View File

@@ -0,0 +1,51 @@
import { NextFunction, Request, Response } from "express";
import * as storageService from "../services/storageService";
/**
* Middleware specifically for settings routes
* Allows disabling visitor mode even when visitor mode is enabled
*/
export const visitorModeSettingsMiddleware = (
req: Request,
res: Response,
next: NextFunction
): void => {
const settings = storageService.getSettings();
const visitorMode = settings.visitorMode === true;
if (!visitorMode) {
// Visitor mode is not enabled, allow all requests
next();
return;
}
// Visitor mode is enabled
// Allow GET requests (read-only)
if (req.method === "GET") {
next();
return;
}
// For POST requests, check if it's trying to disable visitor mode
if (req.method === "POST") {
const body = req.body || {};
// Check if the request is trying to disable visitor mode
if (body.visitorMode === false) {
// Allow disabling visitor mode
next();
return;
}
// Block all other settings updates
res.status(403).json({
success: false,
error: "Visitor mode is enabled. Only disabling visitor mode is allowed.",
});
return;
}
// Block all other write operations (PUT, DELETE, PATCH)
res.status(403).json({
success: false,
error: "Visitor mode is enabled. Write operations are not allowed.",
});
};

View File

@@ -11,6 +11,8 @@ import apiRoutes from "./routes/api";
import settingsRoutes from "./routes/settingsRoutes";
import downloadManager from "./services/downloadManager";
import * as storageService from "./services/storageService";
import { visitorModeMiddleware } from "./middleware/visitorModeMiddleware";
import { visitorModeSettingsMiddleware } from "./middleware/visitorModeSettingsMiddleware";
import { logger } from "./utils/logger";
import { VERSION } from "./version";
@@ -186,8 +188,10 @@ const startServer = async () => {
);
// API Routes
app.use("/api", apiRoutes);
app.use("/api/settings", settingsRoutes);
// Apply visitor mode middleware to all API routes
app.use("/api", visitorModeMiddleware, apiRoutes);
// Use separate middleware for settings that allows disabling visitor mode
app.use("/api/settings", visitorModeSettingsMiddleware, settingsRoutes);
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);

View File

@@ -11,6 +11,7 @@ import { LanguageProvider } from './contexts/LanguageContext';
import { SnackbarProvider } from './contexts/SnackbarContext';
import { ThemeContextProvider } from './contexts/ThemeContext';
import { VideoProvider, useVideo } from './contexts/VideoContext';
import { VisitorModeProvider } from './contexts/VisitorModeContext';
import AuthorVideos from './pages/AuthorVideos';
import CollectionPage from './pages/CollectionPage';
import DownloadPage from './pages/DownloadPage';
@@ -130,15 +131,17 @@ function App() {
<ThemeContextProvider>
<LanguageProvider>
<SnackbarProvider>
<AuthProvider>
<VideoProvider>
<CollectionProvider>
<DownloadProvider>
<AppContent />
</DownloadProvider>
</CollectionProvider>
</VideoProvider>
</AuthProvider>
<VisitorModeProvider>
<AuthProvider>
<VideoProvider>
<CollectionProvider>
<DownloadProvider>
<AppContent />
</DownloadProvider>
</CollectionProvider>
</VideoProvider>
</AuthProvider>
</VisitorModeProvider>
</SnackbarProvider>
</LanguageProvider>
</ThemeContextProvider>

View File

@@ -2,6 +2,7 @@ import { Brightness4, Brightness7, Download, Settings } from '@mui/icons-materia
import { Badge, Box, IconButton, Tooltip, useMediaQuery, useTheme } from '@mui/material';
import { useLanguage } from '../../contexts/LanguageContext';
import { useThemeContext } from '../../contexts/ThemeContext';
import { useVisitorMode } from '../../contexts/VisitorModeContext';
import DownloadsMenu from './DownloadsMenu';
import ManageMenu from './ManageMenu';
import { DownloadInfo } from './types';
@@ -29,22 +30,27 @@ const ActionButtons: React.FC<ActionButtonsProps> = ({
}) => {
const { mode: currentThemeMode, toggleTheme } = useThemeContext();
const { t } = useLanguage();
const { visitorMode } = useVisitorMode();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<IconButton color="inherit" onClick={onDownloadsClick}>
<Badge badgeContent={activeDownloads.length + queuedDownloads.length} color="secondary">
<Download />
</Badge>
</IconButton>
<DownloadsMenu
anchorEl={downloadsAnchorEl}
onClose={onDownloadsClose}
activeDownloads={activeDownloads}
queuedDownloads={queuedDownloads}
/>
{!visitorMode && (
<>
<IconButton color="inherit" onClick={onDownloadsClick}>
<Badge badgeContent={activeDownloads.length + queuedDownloads.length} color="secondary">
<Download />
</Badge>
</IconButton>
<DownloadsMenu
anchorEl={downloadsAnchorEl}
onClose={onDownloadsClose}
activeDownloads={activeDownloads}
queuedDownloads={queuedDownloads}
/>
</>
)}
<IconButton onClick={toggleTheme} color="inherit">
{currentThemeMode === 'dark' ? <Brightness7 /> : <Brightness4 />}

View File

@@ -2,6 +2,7 @@ import { Settings, VideoLibrary } from '@mui/icons-material';
import { Box, Button, Collapse, Stack } from '@mui/material';
import { Link } from 'react-router-dom';
import { useLanguage } from '../../contexts/LanguageContext';
import { useVisitorMode } from '../../contexts/VisitorModeContext';
import { Collection, Video } from '../../types';
import AuthorsList from '../AuthorsList';
import Collections from '../Collections';
@@ -44,24 +45,27 @@ const MobileMenu: React.FC<MobileMenuProps> = ({
onTagToggle
}) => {
const { t } = useLanguage();
const { visitorMode } = useVisitorMode();
return (
<Collapse in={open} sx={{ width: '100%' }}>
<Box sx={{ maxHeight: '80vh', overflowY: 'auto' }}>
<Stack spacing={2} sx={{ py: 2 }}>
{/* Row 1: Search Input */}
<Box>
<SearchInput
videoUrl={videoUrl}
setVideoUrl={setVideoUrl}
isSubmitting={isSubmitting}
error={error}
isSearchMode={isSearchMode}
searchTerm={searchTerm}
onResetSearch={onResetSearch}
onSubmit={onSubmit}
/>
</Box>
{!visitorMode && (
<Box>
<SearchInput
videoUrl={videoUrl}
setVideoUrl={setVideoUrl}
isSubmitting={isSubmitting}
error={error}
isSearchMode={isSearchMode}
searchTerm={searchTerm}
onResetSearch={onResetSearch}
onSubmit={onSubmit}
/>
</Box>
)}
{/* Mobile Navigation Buttons */}
<Box sx={{ display: 'flex', gap: 2 }}>

View File

@@ -9,6 +9,7 @@ import {
} from '@mui/material';
import { FormEvent } from 'react';
import { useLanguage } from '../../contexts/LanguageContext';
import { useVisitorMode } from '../../contexts/VisitorModeContext';
interface SearchInputProps {
videoUrl: string;
@@ -32,16 +33,17 @@ const SearchInput: React.FC<SearchInputProps> = ({
onSubmit
}) => {
const { t } = useLanguage();
const { visitorMode } = useVisitorMode();
return (
<Box component="form" onSubmit={onSubmit} sx={{ flexGrow: 1, display: 'flex', justifyContent: 'center', width: '100%' }}>
<TextField
fullWidth
variant="outlined"
placeholder={t('enterUrlOrSearchTerm')}
placeholder={visitorMode ? t('visitorModeReadOnly') || 'Visitor mode: Read-only' : t('enterUrlOrSearchTerm')}
value={videoUrl}
onChange={(e) => setVideoUrl(e.target.value)}
disabled={isSubmitting}
disabled={isSubmitting || visitorMode}
error={!!error}
helperText={error}
size="small"
@@ -57,7 +59,7 @@ const SearchInput: React.FC<SearchInputProps> = ({
<Button
type="submit"
variant="contained"
disabled={isSubmitting}
disabled={isSubmitting || visitorMode}
sx={{ borderTopLeftRadius: 0, borderBottomLeftRadius: 0, height: '100%', minWidth: 'auto', px: 3 }}
>
{isSubmitting ? <CircularProgress size={24} color="inherit" /> : <Search />}

View File

@@ -12,6 +12,7 @@ import { FormEvent, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useLanguage } from '../../contexts/LanguageContext';
import { useVideo } from '../../contexts/VideoContext';
import { useVisitorMode } from '../../contexts/VisitorModeContext';
import ActionButtons from './ActionButtons';
import Logo from './Logo';
import MobileMenu from './MobileMenu';
@@ -39,6 +40,7 @@ const Header: React.FC<HeaderProps> = ({
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const { t } = useLanguage();
const { visitorMode } = useVisitorMode();
const { availableTags, selectedTags, handleTagToggle } = useVideo();
useEffect(() => {
@@ -148,19 +150,21 @@ const Header: React.FC<HeaderProps> = ({
{/* Desktop Layout */}
{!isMobile && (
<>
<Box sx={{ flexGrow: 1, display: 'flex', justifyContent: 'center', maxWidth: 800, mx: 'auto' }}>
<SearchInput
videoUrl={videoUrl}
setVideoUrl={setVideoUrl}
isSubmitting={isSubmitting}
error={error}
isSearchMode={isSearchMode}
searchTerm={searchTerm}
onResetSearch={onResetSearch}
onSubmit={handleSubmit}
/>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', ml: 2 }}>
{!visitorMode && (
<Box sx={{ flexGrow: 1, display: 'flex', justifyContent: 'center', maxWidth: 800, mx: 'auto' }}>
<SearchInput
videoUrl={videoUrl}
setVideoUrl={setVideoUrl}
isSubmitting={isSubmitting}
error={error}
isSearchMode={isSearchMode}
searchTerm={searchTerm}
onResetSearch={onResetSearch}
onSubmit={handleSubmit}
/>
</Box>
)}
<Box sx={{ display: 'flex', alignItems: 'center', ml: visitorMode ? 'auto' : 2 }}>
<ActionButtons
activeDownloads={activeDownloads}
queuedDownloads={queuedDownloads}

View File

@@ -16,6 +16,7 @@ import {
} from '@mui/material';
import React from 'react';
import { useLanguage } from '../../contexts/LanguageContext';
import { useVisitorMode } from '../../contexts/VisitorModeContext';
import { Collection } from '../../types';
interface CollectionsTableProps {
@@ -38,6 +39,7 @@ const CollectionsTable: React.FC<CollectionsTableProps> = ({
getCollectionSize
}) => {
const { t } = useLanguage();
const { visitorMode } = useVisitorMode();
return (
<Box sx={{ mb: 6 }}>
@@ -55,7 +57,7 @@ const CollectionsTable: React.FC<CollectionsTableProps> = ({
<TableCell>{t('videos')}</TableCell>
<TableCell>{t('size')}</TableCell>
<TableCell>{t('created')}</TableCell>
<TableCell align="right">{t('actions')}</TableCell>
{!visitorMode && <TableCell align="right">{t('actions')}</TableCell>}
</TableRow>
</TableHead>
<TableBody>
@@ -67,17 +69,19 @@ const CollectionsTable: React.FC<CollectionsTableProps> = ({
<TableCell>{collection.videos.length} videos</TableCell>
<TableCell>{getCollectionSize(collection.videos)}</TableCell>
<TableCell>{new Date(collection.createdAt).toLocaleDateString()}</TableCell>
<TableCell align="right">
<Tooltip title={t('deleteCollection')}>
<IconButton
color="error"
onClick={() => onDelete(collection)}
size="small"
>
<Delete />
</IconButton>
</Tooltip>
</TableCell>
{!visitorMode && (
<TableCell align="right">
<Tooltip title={t('deleteCollection')}>
<IconButton
color="error"
onClick={() => onDelete(collection)}
size="small"
>
<Delete />
</IconButton>
</Tooltip>
</TableCell>
)}
</TableRow>
))}
</TableBody>

View File

@@ -29,6 +29,7 @@ import {
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { useLanguage } from '../../contexts/LanguageContext';
import { useVisitorMode } from '../../contexts/VisitorModeContext';
import { Video } from '../../types';
import { formatDuration, formatSize } from '../../utils/formatUtils';
@@ -72,6 +73,7 @@ const VideosTable: React.FC<VideosTableProps> = ({
onUpdateVideo
}) => {
const { t } = useLanguage();
const { visitorMode } = useVisitorMode();
// Local editing state
const [editingVideoId, setEditingVideoId] = useState<string | null>(null);
@@ -166,7 +168,7 @@ const VideosTable: React.FC<VideosTableProps> = ({
{t('size')}
</TableSortLabel>
</TableCell>
<TableCell align="right">{t('actions')}</TableCell>
{!visitorMode && <TableCell align="right">{t('actions')}</TableCell>}
</TableRow>
</TableHead>
<TableBody>
@@ -182,26 +184,28 @@ const VideosTable: React.FC<VideosTableProps> = ({
sx={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 1 }}
/>
</Link>
<Tooltip title={t('refreshThumbnail') || "Refresh Thumbnail"}>
<IconButton
size="small"
onClick={() => onRefreshThumbnail(video.id)}
disabled={refreshingId === video.id}
sx={{
position: 'absolute',
top: 0,
right: 0,
bgcolor: 'rgba(0,0,0,0.5)',
color: 'white',
'&:hover': { bgcolor: 'rgba(0,0,0,0.7)' },
p: 0.5,
width: 24,
height: 24
}}
>
{refreshingId === video.id ? <CircularProgress size={14} color="inherit" /> : <Refresh sx={{ fontSize: 16 }} />}
</IconButton>
</Tooltip>
{!visitorMode && (
<Tooltip title={t('refreshThumbnail') || "Refresh Thumbnail"}>
<IconButton
size="small"
onClick={() => onRefreshThumbnail(video.id)}
disabled={refreshingId === video.id}
sx={{
position: 'absolute',
top: 0,
right: 0,
bgcolor: 'rgba(0,0,0,0.5)',
color: 'white',
'&:hover': { bgcolor: 'rgba(0,0,0,0.7)' },
p: 0.5,
width: 24,
height: 24
}}
>
{refreshingId === video.id ? <CircularProgress size={14} color="inherit" /> : <Refresh sx={{ fontSize: 16 }} />}
</IconButton>
</Tooltip>
)}
</Box>
<Typography variant="caption" display="block" sx={{ mt: 0.5, color: 'text.secondary', textAlign: 'center' }}>
{formatDuration(video.duration)}
@@ -240,13 +244,15 @@ const VideosTable: React.FC<VideosTableProps> = ({
</Box>
) : (
<Box sx={{ display: 'flex', alignItems: 'flex-start' }}>
<IconButton
size="small"
onClick={() => handleEditClick(video)}
sx={{ mr: 1, mt: -0.5, opacity: 0.6, '&:hover': { opacity: 1 } }}
>
<Edit fontSize="small" />
</IconButton>
{!visitorMode && (
<IconButton
size="small"
onClick={() => handleEditClick(video)}
sx={{ mr: 1, mt: -0.5, opacity: 0.6, '&:hover': { opacity: 1 } }}
>
<Edit fontSize="small" />
</IconButton>
)}
<Typography
variant="body2"
sx={{
@@ -279,17 +285,19 @@ const VideosTable: React.FC<VideosTableProps> = ({
</Link>
</TableCell>
<TableCell>{formatSize(video.fileSize)}</TableCell>
<TableCell align="right">
<Tooltip title={t('deleteVideo')}>
<IconButton
color="error"
onClick={() => onDeleteClick(video.id)}
disabled={deletingId === video.id}
>
{deletingId === video.id ? <CircularProgress size={24} /> : <Delete />}
</IconButton>
</Tooltip>
</TableCell>
{!visitorMode && (
<TableCell align="right">
<Tooltip title={t('deleteVideo')}>
<IconButton
color="error"
onClick={() => onDeleteClick(video.id)}
disabled={deletingId === video.id}
>
{deletingId === video.id ? <CircularProgress size={24} /> : <Delete />}
</IconButton>
</Tooltip>
</TableCell>
)}
</TableRow>
))}
</TableBody>

View File

@@ -7,72 +7,89 @@ interface GeneralSettingsProps {
websiteName?: string;
itemsPerPage?: number;
showYoutubeSearch?: boolean;
visitorMode?: boolean;
onChange: (field: string, value: string | number | boolean) => void;
}
const GeneralSettings: React.FC<GeneralSettingsProps> = (props) => {
const { language, websiteName, showYoutubeSearch, onChange } = props;
const { language, websiteName, showYoutubeSearch, visitorMode, onChange } = props;
const { t } = useLanguage();
const isVisitorMode = visitorMode ?? false;
return (
<Box>
<Typography variant="h6" gutterBottom>{t('general')}</Typography>
<Box sx={{ maxWidth: 400, display: 'flex', flexDirection: 'column', gap: 3 }}>
<FormControl fullWidth>
<InputLabel id="language-select-label">{t('language')}</InputLabel>
<Select
labelId="language-select-label"
id="language-select"
value={language || 'en'}
label={t('language')}
onChange={(e) => onChange('language', e.target.value)}
>
<MenuItem value="en">English</MenuItem>
<MenuItem value="zh"> (Chinese)</MenuItem>
<MenuItem value="es">Español (Spanish)</MenuItem>
<MenuItem value="de">Deutsch (German)</MenuItem>
<MenuItem value="ja"> (Japanese)</MenuItem>
<MenuItem value="fr">Français (French)</MenuItem>
<MenuItem value="ko"> (Korean)</MenuItem>
<MenuItem value="ar">العربية (Arabic)</MenuItem>
<MenuItem value="pt">Português (Portuguese)</MenuItem>
<MenuItem value="ru">Русский (Russian)</MenuItem>
</Select>
</FormControl>
{!isVisitorMode && (
<>
<FormControl fullWidth>
<InputLabel id="language-select-label">{t('language')}</InputLabel>
<Select
labelId="language-select-label"
id="language-select"
value={language || 'en'}
label={t('language')}
onChange={(e) => onChange('language', e.target.value)}
>
<MenuItem value="en">English</MenuItem>
<MenuItem value="zh"> (Chinese)</MenuItem>
<MenuItem value="es">Español (Spanish)</MenuItem>
<MenuItem value="de">Deutsch (German)</MenuItem>
<MenuItem value="ja"> (Japanese)</MenuItem>
<MenuItem value="fr">Français (French)</MenuItem>
<MenuItem value="ko"> (Korean)</MenuItem>
<MenuItem value="ar">العربية (Arabic)</MenuItem>
<MenuItem value="pt">Português (Portuguese)</MenuItem>
<MenuItem value="ru">Русский (Russian)</MenuItem>
</Select>
</FormControl>
<TextField
fullWidth
label="Website Name"
value={websiteName || ''}
onChange={(e) => onChange('websiteName', e.target.value)}
placeholder="MyTube"
helperText={`${(websiteName || '').length}/15 characters (Default: MyTube)`}
slotProps={{ htmlInput: { maxLength: 15 } }}
/>
<TextField
fullWidth
label="Website Name"
value={websiteName || ''}
onChange={(e) => onChange('websiteName', e.target.value)}
placeholder="MyTube"
helperText={`${(websiteName || '').length}/15 characters (Default: MyTube)`}
slotProps={{ htmlInput: { maxLength: 15 } }}
/>
<TextField
fullWidth
label={t('itemsPerPage') || "Items Per Page"}
type="number"
value={props.itemsPerPage || 12}
onChange={(e) => {
const val = parseInt(e.target.value);
if (!isNaN(val) && val > 0) {
onChange('itemsPerPage', val);
}
}}
helperText={t('itemsPerPageHelper') || "Number of videos to show per page (Default: 12)"}
slotProps={{ htmlInput: { min: 1 } }}
/>
<TextField
fullWidth
label={t('itemsPerPage') || "Items Per Page"}
type="number"
value={props.itemsPerPage || 12}
onChange={(e) => {
const val = parseInt(e.target.value);
if (!isNaN(val) && val > 0) {
onChange('itemsPerPage', val);
}
}}
helperText={t('itemsPerPageHelper') || "Number of videos to show per page (Default: 12)"}
slotProps={{ htmlInput: { min: 1 } }}
/>
<FormControlLabel
control={
<Switch
checked={showYoutubeSearch ?? true}
onChange={(e) => onChange('showYoutubeSearch', e.target.checked)}
/>
}
label={t('showYoutubeSearch') || "Show YouTube Search Results"}
/>
</>
)}
<FormControlLabel
control={
<Switch
checked={showYoutubeSearch ?? true}
onChange={(e) => onChange('showYoutubeSearch', e.target.checked)}
checked={visitorMode ?? false}
onChange={(e) => onChange('visitorMode', e.target.checked)}
/>
}
label={t('showYoutubeSearch') || "Show YouTube Search Results"}
label={t('visitorMode') || "Visitor Mode (Read-only)"}
/>
</Box>
</Box>

View File

@@ -265,6 +265,8 @@ const VideoCard: React.FC<VideoCardProps> = ({
return (
<>
<Card
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
sx={{
height: '100%',
display: 'flex',
@@ -289,8 +291,6 @@ const VideoCard: React.FC<VideoCardProps> = ({
>
<CardActionArea
onClick={handleVideoNavigation}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', alignItems: 'stretch' }}
>
<Box sx={{ position: 'relative', paddingTop: '56.25%' /* 16:9 aspect ratio */ }}>

View File

@@ -19,6 +19,7 @@ import {
} from '@mui/material';
import React, { useState } from 'react';
import { useLanguage } from '../../contexts/LanguageContext';
import { useVisitorMode } from '../../contexts/VisitorModeContext';
import { Video } from '../../types';
import { formatDate, formatDuration } from '../../utils/formatUtils';
@@ -100,6 +101,7 @@ const UpNextSidebar: React.FC<UpNextSidebarProps> = ({
onAddToCollection
}) => {
const { t } = useLanguage();
const { visitorMode } = useVisitorMode();
const [hoveredVideoId, setHoveredVideoId] = useState<string | null>(null);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
@@ -154,7 +156,7 @@ const UpNextSidebar: React.FC<UpNextSidebarProps> = ({
</Typography>
</Box>
{hoveredVideoId === relatedVideo.id && !isMobile && !isTouch && (
{hoveredVideoId === relatedVideo.id && !isMobile && !isTouch && !visitorMode && (
<Tooltip title={t('addToCollection')}>
<IconButton
size="small"

View File

@@ -2,6 +2,7 @@ import { Check, Close, Edit, ExpandLess, ExpandMore } from '@mui/icons-material'
import { Box, Button, TextField, Tooltip, Typography } from '@mui/material';
import React, { useEffect, useRef, useState } from 'react';
import { useLanguage } from '../../../contexts/LanguageContext';
import { useVisitorMode } from '../../../contexts/VisitorModeContext';
interface EditableTitleProps {
title: string;
@@ -10,6 +11,7 @@ interface EditableTitleProps {
const EditableTitle: React.FC<EditableTitleProps> = ({ title, onSave }) => {
const { t } = useLanguage();
const { visitorMode } = useVisitorMode();
const [isEditingTitle, setIsEditingTitle] = useState<boolean>(false);
const [editedTitle, setEditedTitle] = useState<string>('');
const [isTitleExpanded, setIsTitleExpanded] = useState(false);
@@ -103,15 +105,17 @@ const EditableTitle: React.FC<EditableTitleProps> = ({ title, onSave }) => {
{title}
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
<Tooltip title={t('editTitle')}>
<Button
size="small"
onClick={handleStartEditingTitle}
sx={{ minWidth: 'auto', p: 0.5, color: 'text.secondary' }}
>
<Edit fontSize="small" />
</Button>
</Tooltip>
{!visitorMode && (
<Tooltip title={t('editTitle')}>
<Button
size="small"
onClick={handleStartEditingTitle}
sx={{ minWidth: 'auto', p: 0.5, color: 'text.secondary' }}
>
<Edit fontSize="small" />
</Button>
</Tooltip>
)}
{showExpandButton && (
<Tooltip title={isTitleExpanded ? t('collapse') : t('expand')}>
<Button

View File

@@ -3,6 +3,7 @@ import { Button, Menu, MenuItem, Stack, Tooltip, useMediaQuery, useTheme } from
import React, { useState } from 'react';
import { useLanguage } from '../../../contexts/LanguageContext';
import { useSnackbar } from '../../../contexts/SnackbarContext';
import { useVisitorMode } from '../../../contexts/VisitorModeContext';
import { useShareVideo } from '../../../hooks/useShareVideo';
import { Video } from '../../../types';
import VideoKebabMenuButtons from './VideoKebabMenuButtons';
@@ -23,6 +24,7 @@ const VideoActionButtons: React.FC<VideoActionButtonsProps> = ({
const { t } = useLanguage();
const { handleShare } = useShareVideo(video);
const { showSnackbar } = useSnackbar();
const { visitorMode } = useVisitorMode();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const [playerMenuAnchor, setPlayerMenuAnchor] = useState<null | HTMLElement>(null);
@@ -169,27 +171,31 @@ const VideoActionButtons: React.FC<VideoActionButtonsProps> = ({
<Share />
</Button>
</Tooltip>
<Tooltip title={t('addToCollection')}>
<Button
variant="outlined"
color="inherit"
onClick={() => onAddToCollection()}
sx={{ minWidth: 'auto', p: 1, color: 'text.secondary', borderColor: 'text.secondary', '&:hover': { color: 'primary.main', borderColor: 'primary.main' } }}
>
<Add />
</Button>
</Tooltip>
<Tooltip title={t('delete')}>
<Button
variant="outlined"
color="inherit"
onClick={onDelete}
disabled={isDeleting}
sx={{ minWidth: 'auto', p: 1, color: 'text.secondary', borderColor: 'text.secondary', '&:hover': { color: 'error.main', borderColor: 'error.main' } }}
>
<Delete />
</Button>
</Tooltip>
{!visitorMode && (
<>
<Tooltip title={t('addToCollection')}>
<Button
variant="outlined"
color="inherit"
onClick={() => onAddToCollection()}
sx={{ minWidth: 'auto', p: 1, color: 'text.secondary', borderColor: 'text.secondary', '&:hover': { color: 'primary.main', borderColor: 'primary.main' } }}
>
<Add />
</Button>
</Tooltip>
<Tooltip title={t('delete')}>
<Button
variant="outlined"
color="inherit"
onClick={onDelete}
disabled={isDeleting}
sx={{ minWidth: 'auto', p: 1, color: 'text.secondary', borderColor: 'text.secondary', '&:hover': { color: 'error.main', borderColor: 'error.main' } }}
>
<Delete />
</Button>
</Tooltip>
</>
)}
</Stack>
);

View File

@@ -2,6 +2,7 @@ import { Notifications, NotificationsActive } from '@mui/icons-material';
import { Avatar, Box, IconButton, Tooltip, Typography } from '@mui/material';
import React from 'react';
import { useLanguage } from '../../../contexts/LanguageContext';
import { useVisitorMode } from '../../../contexts/VisitorModeContext';
interface VideoAuthorInfoProps {
author: string;
@@ -36,7 +37,8 @@ const VideoAuthorInfo: React.FC<VideoAuthorInfoProps> = ({
onUnsubscribe
}) => {
const { t } = useLanguage();
const showSubscribeButton = source === 'youtube' || source === 'bilibili';
const { visitorMode } = useVisitorMode();
const showSubscribeButton = (source === 'youtube' || source === 'bilibili') && !visitorMode;
const handleSubscribeClick = (e: React.MouseEvent) => {
e.stopPropagation();

View File

@@ -2,6 +2,7 @@ import { Add, Cast, Delete, MoreVert, Share } from '@mui/icons-material';
import { Button, IconButton, Menu, Stack, Tooltip } from '@mui/material';
import React, { useState } from 'react';
import { useLanguage } from '../../../contexts/LanguageContext';
import { useVisitorMode } from '../../../contexts/VisitorModeContext';
interface VideoKebabMenuButtonsProps {
onPlayWith: (anchor: HTMLElement) => void;
@@ -21,6 +22,7 @@ const VideoKebabMenuButtons: React.FC<VideoKebabMenuButtonsProps> = ({
sx
}) => {
const { t } = useLanguage();
const { visitorMode } = useVisitorMode();
const [kebabMenuAnchor, setKebabMenuAnchor] = useState<null | HTMLElement>(null);
const handleKebabMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
@@ -128,28 +130,32 @@ const VideoKebabMenuButtons: React.FC<VideoKebabMenuButtonsProps> = ({
<Share />
</Button>
</Tooltip>
<Tooltip title={t('addToCollection')}>
<Button
variant="outlined"
color="inherit"
onClick={handleAddToCollection}
sx={{ minWidth: 'auto', p: 1, color: 'text.secondary', borderColor: 'text.secondary', '&:hover': { color: 'primary.main', borderColor: 'primary.main' } }}
>
<Add />
</Button>
</Tooltip>
{onDelete && (
<Tooltip title={t('delete')}>
<Button
variant="outlined"
color="inherit"
onClick={handleDelete}
disabled={isDeleting}
sx={{ minWidth: 'auto', p: 1, color: 'text.secondary', borderColor: 'text.secondary', '&:hover': { color: 'error.main', borderColor: 'error.main' } }}
>
<Delete />
</Button>
</Tooltip>
{!visitorMode && (
<>
<Tooltip title={t('addToCollection')}>
<Button
variant="outlined"
color="inherit"
onClick={handleAddToCollection}
sx={{ minWidth: 'auto', p: 1, color: 'text.secondary', borderColor: 'text.secondary', '&:hover': { color: 'primary.main', borderColor: 'primary.main' } }}
>
<Add />
</Button>
</Tooltip>
{onDelete && (
<Tooltip title={t('delete')}>
<Button
variant="outlined"
color="inherit"
onClick={handleDelete}
disabled={isDeleting}
sx={{ minWidth: 'auto', p: 1, color: 'text.secondary', borderColor: 'text.secondary', '&:hover': { color: 'error.main', borderColor: 'error.main' } }}
>
<Delete />
</Button>
</Tooltip>
)}
</>
)}
</Stack>
</Menu>

View File

@@ -0,0 +1,43 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import axios from 'axios';
import { useQuery } from '@tanstack/react-query';
const API_URL = import.meta.env.VITE_API_URL;
interface VisitorModeContextType {
visitorMode: boolean;
isLoading: boolean;
}
const VisitorModeContext = createContext<VisitorModeContextType>({
visitorMode: false,
isLoading: true,
});
export const VisitorModeProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const { data: settingsData, isLoading } = useQuery({
queryKey: ['settings'],
queryFn: async () => {
const response = await axios.get(`${API_URL}/settings`);
return response.data;
},
refetchInterval: 5000, // Refetch every 5 seconds to keep visitor mode state in sync
});
const visitorMode = settingsData?.visitorMode === true;
return (
<VisitorModeContext.Provider value={{ visitorMode, isLoading }}>
{children}
</VisitorModeContext.Provider>
);
};
export const useVisitorMode = () => {
const context = useContext(VisitorModeContext);
if (!context) {
throw new Error('useVisitorMode must be used within a VisitorModeProvider');
}
return context;
};

View File

@@ -18,6 +18,7 @@ import VideosTable from '../components/ManagePage/VideosTable';
import { useCollection } from '../contexts/CollectionContext';
import { useLanguage } from '../contexts/LanguageContext';
import { useSnackbar } from '../contexts/SnackbarContext';
import { useVisitorMode } from '../contexts/VisitorModeContext';
import { useVideo } from '../contexts/VideoContext';
import { Collection, Video } from '../types';
import { formatSize } from '../utils/formatUtils';
@@ -28,6 +29,7 @@ const ManagePage: React.FC = () => {
const [searchTerm, setSearchTerm] = useState<string>('');
const { t } = useLanguage();
const { showSnackbar } = useSnackbar();
const { visitorMode } = useVisitorMode();
const { videos, deleteVideo, refreshThumbnail, updateVideo } = useVideo();
const { collections, deleteCollection } = useCollection();
const [deletingId, setDeletingId] = useState<string | null>(null);
@@ -176,16 +178,18 @@ const ManagePage: React.FC = () => {
<Typography variant="h4" component="h1" fontWeight="bold">
{t('manageContent')}
</Typography>
<Box sx={{ display: 'flex', gap: 2 }}>
<Button
variant="outlined"
startIcon={<FindInPage />}
onClick={() => setShowScanConfirmModal(true)}
disabled={scanMutation.isPending}
>
{scanMutation.isPending ? (t('scanning') || 'Scanning...') : (t('scanFiles') || 'Scan Files')}
</Button>
</Box>
{!visitorMode && (
<Box sx={{ display: 'flex', gap: 2 }}>
<Button
variant="outlined"
startIcon={<FindInPage />}
onClick={() => setShowScanConfirmModal(true)}
disabled={scanMutation.isPending}
>
{scanMutation.isPending ? (t('scanning') || 'Scanning...') : (t('scanFiles') || 'Scan Files')}
</Button>
</Box>
)}
</Box>
<DeleteCollectionModal

View File

@@ -27,6 +27,7 @@ import VideoDefaultSettings from '../components/Settings/VideoDefaultSettings';
import YtDlpSettings from '../components/Settings/YtDlpSettings';
import { useDownload } from '../contexts/DownloadContext';
import { useLanguage } from '../contexts/LanguageContext';
import { useVisitorMode } from '../contexts/VisitorModeContext';
import { Settings } from '../types';
import ConsoleManager from '../utils/consoleManager';
import { SNACKBAR_AUTO_HIDE_DURATION } from '../utils/constants';
@@ -38,6 +39,7 @@ const API_URL = import.meta.env.VITE_API_URL;
const SettingsPage: React.FC = () => {
const { t, setLanguage } = useLanguage();
const { activeDownloads } = useDownload();
const { visitorMode } = useVisitorMode();
const [settings, setSettings] = useState<Settings>({
loginEnabled: false,
@@ -467,122 +469,138 @@ const SettingsPage: React.FC = () => {
<Card variant="outlined">
<CardContent>
<Grid container spacing={4}>
{/* General Settings */}
{/* General Settings - Only show visitor mode toggle when visitor mode is enabled */}
<Grid size={12}>
<GeneralSettings
language={settings.language}
websiteName={settings.websiteName}
itemsPerPage={settings.itemsPerPage}
showYoutubeSearch={settings.showYoutubeSearch}
onChange={(field, value) => handleChange(field as keyof Settings, value)}
/>
{visitorMode ? (
<GeneralSettings
language={settings.language}
websiteName={settings.websiteName}
itemsPerPage={settings.itemsPerPage}
showYoutubeSearch={settings.showYoutubeSearch}
visitorMode={settings.visitorMode}
onChange={(field, value) => handleChange(field as keyof Settings, value)}
/>
) : (
<GeneralSettings
language={settings.language}
websiteName={settings.websiteName}
itemsPerPage={settings.itemsPerPage}
showYoutubeSearch={settings.showYoutubeSearch}
visitorMode={settings.visitorMode}
onChange={(field, value) => handleChange(field as keyof Settings, value)}
/>
)}
</Grid>
<Grid size={12}><Divider /></Grid>
{!visitorMode && (
<>
<Grid size={12}><Divider /></Grid>
{/* Cookie Upload Settings */}
<Grid size={12}>
<CookieSettings
onSuccess={(msg) => setMessage({ text: msg, type: 'success' })}
onError={(msg) => setMessage({ text: msg, type: 'error' })}
/>
</Grid>
{/* Cookie Upload Settings */}
<Grid size={12}>
<CookieSettings
onSuccess={(msg) => setMessage({ text: msg, type: 'success' })}
onError={(msg) => setMessage({ text: msg, type: 'error' })}
/>
</Grid>
<Grid size={12}><Divider /></Grid>
<Grid size={12}><Divider /></Grid>
{/* Security Settings */}
<Grid size={12}>
<SecuritySettings
settings={settings}
onChange={handleChange}
/>
</Grid>
{/* Security Settings */}
<Grid size={12}>
<SecuritySettings
settings={settings}
onChange={handleChange}
/>
</Grid>
<Grid size={12}><Divider /></Grid>
<Grid size={12}><Divider /></Grid>
{/* Video Defaults */}
<Grid size={12}>
<VideoDefaultSettings
settings={settings}
onChange={handleChange}
/>
</Grid>
{/* Video Defaults */}
<Grid size={12}>
<VideoDefaultSettings
settings={settings}
onChange={handleChange}
/>
</Grid>
<Grid size={12}><Divider /></Grid>
<Grid size={12}><Divider /></Grid>
{/* Tags Management */}
<Grid size={12}>
<TagsSettings
tags={Array.isArray(settings.tags) ? settings.tags : []}
onTagsChange={handleTagsChange}
/>
</Grid>
{/* Tags Management */}
<Grid size={12}>
<TagsSettings
tags={Array.isArray(settings.tags) ? settings.tags : []}
onTagsChange={handleTagsChange}
/>
</Grid>
<Grid size={12}><Divider /></Grid>
<Grid size={12}><Divider /></Grid>
{/* Download Settings */}
<Grid size={12}>
<DownloadSettings
settings={settings}
onChange={handleChange}
activeDownloadsCount={activeDownloads.length}
onCleanup={() => setShowCleanupTempFilesModal(true)}
isSaving={isSaving}
/>
</Grid>
{/* Download Settings */}
<Grid size={12}>
<DownloadSettings
settings={settings}
onChange={handleChange}
activeDownloadsCount={activeDownloads.length}
onCleanup={() => setShowCleanupTempFilesModal(true)}
isSaving={isSaving}
/>
</Grid>
<Grid size={12}><Divider /></Grid>
<Grid size={12}><Divider /></Grid>
{/* Cloud Drive Settings */}
<Grid size={12}>
<CloudDriveSettings
settings={settings}
onChange={handleChange}
/>
</Grid>
{/* Cloud Drive Settings */}
<Grid size={12}>
<CloudDriveSettings
settings={settings}
onChange={handleChange}
/>
</Grid>
<Grid size={12}><Divider /></Grid>
<Grid size={12}><Divider /></Grid>
{/* Database Settings */}
<Grid size={12}>
<DatabaseSettings
onMigrate={() => setShowMigrateConfirmModal(true)}
onDeleteLegacy={() => setShowDeleteLegacyModal(true)}
onFormatFilenames={() => setShowFormatConfirmModal(true)}
onExportDatabase={handleExportDatabase}
onImportDatabase={handleImportDatabase}
onCleanupBackupDatabases={handleCleanupBackupDatabases}
onRestoreFromLastBackup={handleRestoreFromLastBackup}
isSaving={isSaving}
lastBackupInfo={lastBackupInfo}
moveSubtitlesToVideoFolder={settings.moveSubtitlesToVideoFolder || false}
onMoveSubtitlesToVideoFolderChange={(checked) => handleChange('moveSubtitlesToVideoFolder', checked)}
moveThumbnailsToVideoFolder={settings.moveThumbnailsToVideoFolder || false}
onMoveThumbnailsToVideoFolderChange={(checked) => handleChange('moveThumbnailsToVideoFolder', checked)}
/>
</Grid>
{/* Database Settings */}
<Grid size={12}>
<DatabaseSettings
onMigrate={() => setShowMigrateConfirmModal(true)}
onDeleteLegacy={() => setShowDeleteLegacyModal(true)}
onFormatFilenames={() => setShowFormatConfirmModal(true)}
onExportDatabase={handleExportDatabase}
onImportDatabase={handleImportDatabase}
onCleanupBackupDatabases={handleCleanupBackupDatabases}
onRestoreFromLastBackup={handleRestoreFromLastBackup}
isSaving={isSaving}
lastBackupInfo={lastBackupInfo}
moveSubtitlesToVideoFolder={settings.moveSubtitlesToVideoFolder || false}
onMoveSubtitlesToVideoFolderChange={(checked) => handleChange('moveSubtitlesToVideoFolder', checked)}
moveThumbnailsToVideoFolder={settings.moveThumbnailsToVideoFolder || false}
onMoveThumbnailsToVideoFolderChange={(checked) => handleChange('moveThumbnailsToVideoFolder', checked)}
/>
</Grid>
<Grid size={12}><Divider /></Grid>
<Grid size={12}><Divider /></Grid>
{/* yt-dlp Configuration */}
<Grid size={12}>
<YtDlpSettings
config={settings.ytDlpConfig || ''}
proxyOnlyYoutube={settings.proxyOnlyYoutube || false}
onChange={(config) => handleChange('ytDlpConfig', config)}
onProxyOnlyYoutubeChange={(checked) => handleChange('proxyOnlyYoutube', checked)}
/>
</Grid>
{/* yt-dlp Configuration */}
<Grid size={12}>
<YtDlpSettings
config={settings.ytDlpConfig || ''}
proxyOnlyYoutube={settings.proxyOnlyYoutube || false}
onChange={(config) => handleChange('ytDlpConfig', config)}
onProxyOnlyYoutubeChange={(checked) => handleChange('proxyOnlyYoutube', checked)}
/>
</Grid>
<Grid size={12}><Divider /></Grid>
<Grid size={12}><Divider /></Grid>
{/* Advanced Settings */}
<Grid size={12}>
<AdvancedSettings
debugMode={debugMode}
onDebugModeChange={setDebugMode}
/>
</Grid>
{/* Advanced Settings */}
<Grid size={12}>
<AdvancedSettings
debugMode={debugMode}
onDebugModeChange={setDebugMode}
/>
</Grid>
</>
)}
</Grid>
</CardContent>

View File

@@ -17,6 +17,7 @@ import React, { useEffect, useState } from 'react';
import ConfirmationModal from '../components/ConfirmationModal';
import { useLanguage } from '../contexts/LanguageContext';
import { useSnackbar } from '../contexts/SnackbarContext';
import { useVisitorMode } from '../contexts/VisitorModeContext';
const API_URL = import.meta.env.VITE_API_URL;
@@ -35,6 +36,7 @@ interface Subscription {
const SubscriptionsPage: React.FC = () => {
const { t } = useLanguage();
const { showSnackbar } = useSnackbar();
const { visitorMode } = useVisitorMode();
const [subscriptions, setSubscriptions] = useState<Subscription[]>([]);
const [isUnsubscribeModalOpen, setIsUnsubscribeModalOpen] = useState(false);
const [selectedSubscription, setSelectedSubscription] = useState<{ id: string; author: string } | null>(null);
@@ -94,13 +96,13 @@ const SubscriptionsPage: React.FC = () => {
<TableCell>{t('interval')}</TableCell>
<TableCell>{t('lastCheck')}</TableCell>
<TableCell>{t('downloads')}</TableCell>
<TableCell align="right">{t('actions')}</TableCell>
{!visitorMode && <TableCell align="right">{t('actions')}</TableCell>}
</TableRow>
</TableHead>
<TableBody>
{subscriptions.length === 0 ? (
<TableRow>
<TableCell colSpan={6} align="center">
<TableCell colSpan={visitorMode ? 5 : 6} align="center">
<Typography color="text.secondary" sx={{ py: 4 }}>
{t('noVideos')} {/* Reusing "No videos found" or similar if "No subscriptions" key missing */}
</Typography>
@@ -123,15 +125,17 @@ const SubscriptionsPage: React.FC = () => {
<TableCell>{sub.interval} {t('minutes')}</TableCell>
<TableCell>{formatDate(sub.lastCheck)}</TableCell>
<TableCell>{sub.downloadCount}</TableCell>
<TableCell align="right">
<IconButton
color="error"
onClick={() => handleUnsubscribeClick(sub.id, sub.author)}
title={t('unsubscribe')}
>
<Delete />
</IconButton>
</TableCell>
{!visitorMode && (
<TableCell align="right">
<IconButton
color="error"
onClick={() => handleUnsubscribeClick(sub.id, sub.author)}
title={t('unsubscribe')}
>
<Delete />
</IconButton>
</TableCell>
)}
</TableRow>
))
)}

View File

@@ -76,4 +76,5 @@ export interface Settings {
proxyOnlyYoutube?: boolean;
moveSubtitlesToVideoFolder?: boolean;
moveThumbnailsToVideoFolder?: boolean;
visitorMode?: boolean;
}

View File

@@ -107,6 +107,9 @@ export const ar = {
"لا يمكن التنظيف أثناء نشاط التنزيلات. يرجى الانتظار حتى تكتمل جميع التنزيلات أو إلغائها أولاً.",
itemsPerPage: "عناصر لكل صفحة",
itemsPerPageHelper: "عدد مقاطع الفيديو المعروضة في كل صفحة (الافتراضي: 12)",
showYoutubeSearch: "عرض نتائج بحث YouTube",
visitorMode: "وضع الزائر (للقراءة فقط)",
visitorModeReadOnly: "وضع الزائر: للقراءة فقط",
cleanupTempFilesSuccess: "تم حذف {count} ملف (ملفات) مؤقت بنجاح.",
cleanupTempFilesFailed: "فشل تنظيف الملفات المؤقتة",

View File

@@ -101,7 +101,11 @@ export const de = {
cleanupTempFilesDescription:
"Alle temporären Download-Dateien (.ytdl, .part) aus dem Upload-Verzeichnis entfernen. Dies hilft, Speicherplatz von unvollständigen oder abgebrochenen Downloads freizugeben.",
cleanupTempFilesConfirmTitle: "Temporäre Dateien bereinigen?",
itemsPerPage: "Elemente pro Seite",
itemsPerPageHelper: "Anzahl der Videos pro Seite (Standard: 12)",
showYoutubeSearch: "YouTube-Suchergebnisse anzeigen",
visitorMode: "Besuchermodus (Nur-Lesen)",
visitorModeReadOnly: "Besuchermodus: Nur-Lesen",
cleanupTempFilesSuccess: "Erfolgreich {count} temporäre Datei(en) gelöscht.",
cleanupTempFilesFailed: "Fehler beim Bereinigen temporärer Dateien",

View File

@@ -105,6 +105,8 @@ export const en = {
itemsPerPage: "Items Per Page",
itemsPerPageHelper: "Number of videos to show per page (Default: 12)",
showYoutubeSearch: "Show YouTube Search Results",
visitorMode: "Visitor Mode (Read-only)",
visitorModeReadOnly: "Visitor mode: Read-only",
cleanupTempFilesSuccess: "Successfully deleted {count} temporary file(s).",
cleanupTempFilesFailed: "Failed to clean up temporary files",

View File

@@ -117,6 +117,9 @@ export const es = {
itemsPerPage: "Elementos por página",
itemsPerPageHelper:
"Número de videos para mostrar por página (Predeterminado: 12)",
showYoutubeSearch: "Mostrar resultados de búsqueda de YouTube",
visitorMode: "Modo Visitante (Solo lectura)",
visitorModeReadOnly: "Modo visitante: Solo lectura",
cleanupTempFilesSuccess:
"Se eliminaron exitosamente {count} archivo(s) temporal(es).",
cleanupTempFilesFailed: "Error al limpiar archivos temporales",

View File

@@ -116,6 +116,9 @@ export const fr = {
"Impossible de nettoyer pendant que des téléchargements sont actifs. Veuillez attendre la fin de tous les téléchargements ou les annuler d'abord.",
itemsPerPage: "Éléments par page",
itemsPerPageHelper: "Nombre de vidéos à afficher par page (Défaut : 12)",
showYoutubeSearch: "Afficher les résultats de recherche YouTube",
visitorMode: "Mode Visiteur (Lecture seule)",
visitorModeReadOnly: "Mode visiteur : Lecture seule",
cleanupTempFilesSuccess:
"{count} fichier(s) temporaire(s) supprimé(s) avec succès.",
cleanupTempFilesFailed: "Échec du nettoyage des fichiers temporaires",

View File

@@ -112,6 +112,9 @@ export const ja = {
"ダウンロードがアクティブな間はクリーンアップできません。すべてのダウンロードが完了するまで待つか、キャンセルしてください。",
itemsPerPage: "1ページあたりの項目数",
itemsPerPageHelper: "1ページに表示する動画の数 (デフォルト: 12)",
showYoutubeSearch: "YouTube検索結果を表示",
visitorMode: "ビジターモード(読み取り専用)",
visitorModeReadOnly: "ビジターモード:読み取り専用",
cleanupTempFilesSuccess: "{count}個の一時ファイルを正常に削除しました。",
cleanupTempFilesFailed: "一時ファイルのクリーンアップに失敗しました",

View File

@@ -109,6 +109,9 @@ export const ko = {
"다운로드가 진행되는 동안 정리할 수 없습니다. 모든 다운로드가 완료될 때까지 기다리거나 먼저 취소하십시오.",
itemsPerPage: "페이지 당 항목 수",
itemsPerPageHelper: "페이지 당 표시할 비디오 수 (기본값: 12)",
showYoutubeSearch: "YouTube 검색 결과 표시",
visitorMode: "방문자 모드 (읽기 전용)",
visitorModeReadOnly: "방문자 모드: 읽기 전용",
cleanupTempFilesSuccess: "{count}개의 임시 파일을 성공적으로 삭제했습니다.",
cleanupTempFilesFailed: "임시 파일 정리 실패",

View File

@@ -112,6 +112,9 @@ export const pt = {
"Não é possível limpar enquanto houver downloads ativos. Aguarde a conclusão de todos os downloads ou cancele-os primeiro.",
itemsPerPage: "Itens por página",
itemsPerPageHelper: "Número de vídeos a mostrar por página (Padrão: 12)",
showYoutubeSearch: "Mostrar resultados de pesquisa do YouTube",
visitorMode: "Modo Visitante (Somente leitura)",
visitorModeReadOnly: "Modo visitante: Somente leitura",
cleanupTempFilesSuccess:
"{count} arquivo(s) temporário(s) excluído(s) com sucesso.",
cleanupTempFilesFailed: "Falha ao limpar arquivos temporários",

View File

@@ -121,6 +121,9 @@ export const ru = {
"Невозможно очистить, пока активны загрузки. Пожалуйста, дождитесь завершения всех загрузок или сначала отмените их.",
itemsPerPage: "Элементов на странице",
itemsPerPageHelper: "Количество видео на странице (По умолчанию: 12)",
showYoutubeSearch: "Показать результаты поиска YouTube",
visitorMode: "Режим посетителя (Только чтение)",
visitorModeReadOnly: "Режим посетителя: Только чтение",
cleanupTempFilesSuccess: "Успешно удалено {count} временных файлов.",
cleanupTempFilesFailed: "Не удалось очистить временные файлы",

View File

@@ -105,6 +105,8 @@ export const zh = {
itemsPerPage: "每页显示数量",
itemsPerPageHelper: "每页显示的视频数量 (默认: 12)",
showYoutubeSearch: "显示 YouTube 搜索结果",
visitorMode: "访客模式(只读)",
visitorModeReadOnly: "访客模式:只读",
cleanupTempFilesSuccess: "成功删除了 {count} 个临时文件。",
cleanupTempFilesFailed: "清理临时文件失败",