refactor: reorgnize settings apge
This commit is contained in:
@@ -10,7 +10,7 @@ interface CollapsibleSectionProps {
|
||||
|
||||
const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({ title, children, defaultExpanded = true }) => {
|
||||
return (
|
||||
<Accordion defaultExpanded={defaultExpanded} sx={{ width: '100%', mb: 2 }}>
|
||||
<Accordion defaultExpanded={defaultExpanded} sx={{ width: '100%', mb: 0.25 }}>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMoreIcon />}
|
||||
aria-controls={`panel-${title.replace(/\s+/g, '-').toLowerCase()}-content`}
|
||||
|
||||
@@ -3,6 +3,25 @@ import { Box, Chip, Container, Link, Tooltip, Typography, useTheme } from '@mui/
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../utils/apiClient';
|
||||
|
||||
// Helper to compare semantic versions (v1 > v2)
|
||||
const isNewerVersion = (latest: string, current: string): boolean => {
|
||||
try {
|
||||
const v1 = latest.split('.').map(Number);
|
||||
const v2 = current.split('.').map(Number);
|
||||
|
||||
for (let i = 0; i < Math.max(v1.length, v2.length); i++) {
|
||||
const num1 = v1[i] || 0;
|
||||
const num2 = v2[i] || 0;
|
||||
if (num1 > num2) return true;
|
||||
if (num1 < num2) return false;
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
// Fallback to string comparison if parsing fails
|
||||
return latest !== current;
|
||||
}
|
||||
};
|
||||
|
||||
const Footer = () => {
|
||||
const theme = useTheme();
|
||||
const [updateInfo, setUpdateInfo] = useState<{
|
||||
@@ -15,8 +34,21 @@ const Footer = () => {
|
||||
const checkVersion = async () => {
|
||||
try {
|
||||
const response = await api.get('/system/version');
|
||||
if (response.data && response.data.hasUpdate) {
|
||||
setUpdateInfo(response.data);
|
||||
if (response.data && response.data.latestVersion) {
|
||||
const currentVersion = import.meta.env.VITE_APP_VERSION;
|
||||
const latestVersion = response.data.latestVersion;
|
||||
// Compare frontend version with latest version
|
||||
const hasUpdate = isNewerVersion(latestVersion, currentVersion);
|
||||
|
||||
if (hasUpdate) {
|
||||
setUpdateInfo({
|
||||
hasUpdate: true,
|
||||
latestVersion,
|
||||
releaseUrl: response.data.releaseUrl || ''
|
||||
});
|
||||
} else {
|
||||
setUpdateInfo(null);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently fail for version check
|
||||
|
||||
57
frontend/src/components/Settings/BasicSettings.tsx
Normal file
57
frontend/src/components/Settings/BasicSettings.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Box, FormControl, InputLabel, MenuItem, Select, TextField } from '@mui/material';
|
||||
import React from 'react';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
|
||||
interface BasicSettingsProps {
|
||||
language: string;
|
||||
websiteName?: string;
|
||||
onChange: (field: string, value: string | number | boolean) => void;
|
||||
}
|
||||
|
||||
const BasicSettings: React.FC<BasicSettingsProps> = ({ language, websiteName, onChange }) => {
|
||||
const { t } = useLanguage();
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<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>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('websiteName')}
|
||||
value={websiteName || ''}
|
||||
onChange={(e) => onChange('websiteName', e.target.value)}
|
||||
placeholder="MyTube"
|
||||
helperText={t('websiteNameHelper', {
|
||||
current: (websiteName || '').length,
|
||||
max: 15,
|
||||
default: 'MyTube'
|
||||
})}
|
||||
slotProps={{ htmlInput: { maxLength: 15 } }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default BasicSettings;
|
||||
@@ -1,268 +0,0 @@
|
||||
import { Box, FormControl, FormControlLabel, InputLabel, MenuItem, Select, Switch, TextField, Typography } from '@mui/material';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
import PasswordModal from '../PasswordModal';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
interface GeneralSettingsProps {
|
||||
language: string;
|
||||
websiteName?: string;
|
||||
itemsPerPage?: number;
|
||||
showYoutubeSearch?: boolean;
|
||||
visitorMode?: boolean;
|
||||
savedVisitorMode?: boolean;
|
||||
infiniteScroll?: boolean;
|
||||
videoColumns?: number;
|
||||
onChange: (field: string, value: string | number | boolean) => void;
|
||||
}
|
||||
|
||||
const GeneralSettings: React.FC<GeneralSettingsProps> = (props) => {
|
||||
const { language, websiteName, showYoutubeSearch, visitorMode, savedVisitorMode, infiniteScroll, videoColumns, onChange } = props;
|
||||
const { t } = useLanguage();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
||||
const [passwordError, setPasswordError] = useState('');
|
||||
const [isVerifyingPassword, setIsVerifyingPassword] = useState(false);
|
||||
const [pendingVisitorMode, setPendingVisitorMode] = useState<boolean | null>(null);
|
||||
const [remainingWaitTime, setRemainingWaitTime] = useState(0);
|
||||
const [baseError, setBaseError] = useState('');
|
||||
|
||||
// Poll for Cloudflare Tunnel status
|
||||
|
||||
|
||||
// Use saved value for visibility, current value for toggle state
|
||||
const isVisitorMode = savedVisitorMode ?? visitorMode ?? false;
|
||||
|
||||
const handleVisitorModeChange = (checked: boolean) => {
|
||||
setPendingVisitorMode(checked);
|
||||
setPasswordError('');
|
||||
setBaseError('');
|
||||
setRemainingWaitTime(0);
|
||||
setShowPasswordModal(true);
|
||||
};
|
||||
|
||||
const handlePasswordConfirm = async (password: string) => {
|
||||
setIsVerifyingPassword(true);
|
||||
setPasswordError('');
|
||||
setBaseError('');
|
||||
|
||||
try {
|
||||
await axios.post(`${API_URL}/settings/verify-password`, { password });
|
||||
|
||||
// If successful, save the setting immediately
|
||||
if (pendingVisitorMode !== null) {
|
||||
// Save to backend
|
||||
await axios.post(`${API_URL}/settings`, { visitorMode: pendingVisitorMode });
|
||||
|
||||
// Invalidate settings query to ensure global state (VisitorModeContext) updates immediately
|
||||
await queryClient.invalidateQueries({ queryKey: ['settings'] });
|
||||
|
||||
// Update parent state
|
||||
onChange('visitorMode', pendingVisitorMode);
|
||||
}
|
||||
setShowPasswordModal(false);
|
||||
setPendingVisitorMode(null);
|
||||
} catch (error: any) {
|
||||
console.error('Password verification failed:', error);
|
||||
if (error.response) {
|
||||
const { status, data } = error.response;
|
||||
if (status === 429) {
|
||||
const waitTimeMs = data.waitTime || 0;
|
||||
const seconds = Math.ceil(waitTimeMs / 1000);
|
||||
setRemainingWaitTime(seconds);
|
||||
setBaseError(t('tooManyAttempts') || 'Too many attempts.');
|
||||
} else if (status === 401) {
|
||||
const waitTimeMs = data.waitTime || 0;
|
||||
if (waitTimeMs > 0) {
|
||||
const seconds = Math.ceil(waitTimeMs / 1000);
|
||||
setRemainingWaitTime(seconds);
|
||||
setBaseError(t('incorrectPassword') || 'Incorrect password.');
|
||||
} else {
|
||||
setPasswordError(t('incorrectPassword') || 'Incorrect password');
|
||||
}
|
||||
} else {
|
||||
setPasswordError(t('loginFailed') || 'Verification failed');
|
||||
}
|
||||
} else {
|
||||
setPasswordError(t('networkError' as any) || 'Network error');
|
||||
}
|
||||
} finally {
|
||||
setIsVerifyingPassword(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClosePasswordModal = () => {
|
||||
setShowPasswordModal(false);
|
||||
setPendingVisitorMode(null);
|
||||
setPasswordError('');
|
||||
setBaseError('');
|
||||
setRemainingWaitTime(0);
|
||||
};
|
||||
|
||||
// Effect to handle countdown
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout;
|
||||
|
||||
if (remainingWaitTime > 0) {
|
||||
// Update error message immediately
|
||||
const waitMsg = t('waitTimeMessage')?.replace('{time}', `${remainingWaitTime}s`) || `Please wait ${remainingWaitTime}s.`;
|
||||
setPasswordError(`${baseError} ${waitMsg}`);
|
||||
|
||||
interval = setInterval(() => {
|
||||
setRemainingWaitTime((prev) => {
|
||||
if (prev <= 1) {
|
||||
// Countdown finished
|
||||
setPasswordError(baseError);
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
} else if (baseError && !passwordError) {
|
||||
// Restore base error if countdown finished but no explicit error set (though logic above handles it)
|
||||
// simplified: if remainingTime hits 0, the effect re-runs.
|
||||
// We handled the 0 case in the setRemainingWaitTime callback or we can handle it here if it transitions to 0.
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (interval) clearInterval(interval);
|
||||
};
|
||||
}, [remainingWaitTime, baseError, t]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ maxWidth: 400, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{!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={t('websiteName')}
|
||||
value={websiteName || ''}
|
||||
onChange={(e) => onChange('websiteName', e.target.value)}
|
||||
placeholder="MyTube"
|
||||
helperText={t('websiteNameHelper', {
|
||||
current: (websiteName || '').length,
|
||||
max: 15,
|
||||
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);
|
||||
}
|
||||
}}
|
||||
disabled={infiniteScroll ?? false}
|
||||
helperText={
|
||||
infiniteScroll
|
||||
? t('infiniteScrollDisabled') || "Disabled when Infinite Scroll is enabled"
|
||||
: (t('itemsPerPageHelper') || "Number of videos to show per page (Default: 12)")
|
||||
}
|
||||
slotProps={{ htmlInput: { min: 1 } }}
|
||||
/>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="video-columns-select-label">{t('maxVideoColumns') || 'Maximum Video Columns (Homepage)'}</InputLabel>
|
||||
<Select
|
||||
labelId="video-columns-select-label"
|
||||
id="video-columns-select"
|
||||
value={videoColumns || 4}
|
||||
label={t('videoColumns') || 'Video Columns (Homepage)'}
|
||||
onChange={(e) => onChange('videoColumns', Number(e.target.value))}
|
||||
>
|
||||
<MenuItem value={2}>{t('columnsCount', { count: 2 }) || '2 Columns'}</MenuItem>
|
||||
<MenuItem value={3}>{t('columnsCount', { count: 3 }) || '3 Columns'}</MenuItem>
|
||||
<MenuItem value={4}>{t('columnsCount', { count: 4 }) || '4 Columns'}</MenuItem>
|
||||
<MenuItem value={5}>{t('columnsCount', { count: 5 }) || '5 Columns'}</MenuItem>
|
||||
<MenuItem value={6}>{t('columnsCount', { count: 6 }) || '6 Columns'}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={infiniteScroll ?? false}
|
||||
onChange={(e) => onChange('infiniteScroll', e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label={t('infiniteScroll') || "Infinite Scroll"}
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={showYoutubeSearch ?? true}
|
||||
onChange={(e) => onChange('showYoutubeSearch', e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label={t('showYoutubeSearch') || "Show YouTube Search Results"}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
|
||||
<Box>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={visitorMode ?? false}
|
||||
onChange={(e) => handleVisitorModeChange(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label={t('visitorMode') || "Visitor Mode (Read-only)"}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5, ml: 4.5 }}>
|
||||
{t('visitorModeDescription') || "Read-only mode. Hidden videos will not be visible to visitors."}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<PasswordModal
|
||||
isOpen={showPasswordModal}
|
||||
onClose={handleClosePasswordModal}
|
||||
onConfirm={handlePasswordConfirm}
|
||||
title={t('password' as any) || "Enter Website Password"}
|
||||
message={t('visitorModePasswordPrompt' as any) || "Please enter the website password to change Visitor Mode settings."}
|
||||
error={passwordError}
|
||||
isLoading={isVerifyingPassword}
|
||||
/>
|
||||
</Box >
|
||||
);
|
||||
};
|
||||
|
||||
export default GeneralSettings;
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Box, FormControl, FormControlLabel, InputLabel, MenuItem, Select, Switch, TextField } from '@mui/material';
|
||||
import React from 'react';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
|
||||
interface InterfaceDisplaySettingsProps {
|
||||
itemsPerPage?: number;
|
||||
showYoutubeSearch?: boolean;
|
||||
infiniteScroll?: boolean;
|
||||
videoColumns?: number;
|
||||
onChange: (field: string, value: string | number | boolean) => void;
|
||||
}
|
||||
|
||||
const InterfaceDisplaySettings: React.FC<InterfaceDisplaySettingsProps> = (props) => {
|
||||
const { itemsPerPage, showYoutubeSearch, infiniteScroll, videoColumns, onChange } = props;
|
||||
const { t } = useLanguage();
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ maxWidth: 400, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('itemsPerPage') || "Items Per Page"}
|
||||
type="number"
|
||||
value={itemsPerPage || 12}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
if (!isNaN(val) && val > 0) {
|
||||
onChange('itemsPerPage', val);
|
||||
}
|
||||
}}
|
||||
disabled={infiniteScroll ?? false}
|
||||
helperText={
|
||||
infiniteScroll
|
||||
? t('infiniteScrollDisabled') || "Disabled when Infinite Scroll is enabled"
|
||||
: (t('itemsPerPageHelper') || "Number of videos to show per page (Default: 12)")
|
||||
}
|
||||
slotProps={{ htmlInput: { min: 1 } }}
|
||||
/>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="video-columns-select-label">{t('maxVideoColumns') || 'Maximum Video Columns (Homepage)'}</InputLabel>
|
||||
<Select
|
||||
labelId="video-columns-select-label"
|
||||
id="video-columns-select"
|
||||
value={videoColumns || 4}
|
||||
label={t('videoColumns') || 'Video Columns (Homepage)'}
|
||||
onChange={(e) => onChange('videoColumns', Number(e.target.value))}
|
||||
>
|
||||
<MenuItem value={2}>{t('columnsCount', { count: 2 }) || '2 Columns'}</MenuItem>
|
||||
<MenuItem value={3}>{t('columnsCount', { count: 3 }) || '3 Columns'}</MenuItem>
|
||||
<MenuItem value={4}>{t('columnsCount', { count: 4 }) || '4 Columns'}</MenuItem>
|
||||
<MenuItem value={5}>{t('columnsCount', { count: 5 }) || '5 Columns'}</MenuItem>
|
||||
<MenuItem value={6}>{t('columnsCount', { count: 6 }) || '6 Columns'}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={infiniteScroll ?? false}
|
||||
onChange={(e) => onChange('infiniteScroll', e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label={t('infiniteScroll') || "Infinite Scroll"}
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={showYoutubeSearch ?? true}
|
||||
onChange={(e) => onChange('showYoutubeSearch', e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label={t('showYoutubeSearch') || "Show YouTube Search Results"}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default InterfaceDisplaySettings;
|
||||
149
frontend/src/components/Settings/VisitorModeSettings.tsx
Normal file
149
frontend/src/components/Settings/VisitorModeSettings.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import { Box, FormControlLabel, Switch, Typography } from '@mui/material';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
import PasswordModal from '../PasswordModal';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
interface VisitorModeSettingsProps {
|
||||
visitorMode?: boolean;
|
||||
savedVisitorMode?: boolean;
|
||||
onChange: (field: string, value: string | number | boolean) => void;
|
||||
}
|
||||
|
||||
const VisitorModeSettings: React.FC<VisitorModeSettingsProps> = ({ visitorMode, savedVisitorMode: _savedVisitorMode, onChange }) => {
|
||||
const { t } = useLanguage();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
||||
const [passwordError, setPasswordError] = useState('');
|
||||
const [isVerifyingPassword, setIsVerifyingPassword] = useState(false);
|
||||
const [pendingVisitorMode, setPendingVisitorMode] = useState<boolean | null>(null);
|
||||
const [remainingWaitTime, setRemainingWaitTime] = useState(0);
|
||||
const [baseError, setBaseError] = useState('');
|
||||
|
||||
const handleVisitorModeChange = (checked: boolean) => {
|
||||
setPendingVisitorMode(checked);
|
||||
setPasswordError('');
|
||||
setBaseError('');
|
||||
setRemainingWaitTime(0);
|
||||
setShowPasswordModal(true);
|
||||
};
|
||||
|
||||
const handlePasswordConfirm = async (password: string) => {
|
||||
setIsVerifyingPassword(true);
|
||||
setPasswordError('');
|
||||
setBaseError('');
|
||||
|
||||
try {
|
||||
await axios.post(`${API_URL}/settings/verify-password`, { password });
|
||||
|
||||
// If successful, save the setting immediately
|
||||
if (pendingVisitorMode !== null) {
|
||||
// Save to backend
|
||||
await axios.post(`${API_URL}/settings`, { visitorMode: pendingVisitorMode });
|
||||
|
||||
// Invalidate settings query to ensure global state (VisitorModeContext) updates immediately
|
||||
await queryClient.invalidateQueries({ queryKey: ['settings'] });
|
||||
|
||||
// Update parent state
|
||||
onChange('visitorMode', pendingVisitorMode);
|
||||
}
|
||||
setShowPasswordModal(false);
|
||||
setPendingVisitorMode(null);
|
||||
} catch (error: any) {
|
||||
console.error('Password verification failed:', error);
|
||||
if (error.response) {
|
||||
const { status, data } = error.response;
|
||||
if (status === 429) {
|
||||
const waitTimeMs = data.waitTime || 0;
|
||||
const seconds = Math.ceil(waitTimeMs / 1000);
|
||||
setRemainingWaitTime(seconds);
|
||||
setBaseError(t('tooManyAttempts') || 'Too many attempts.');
|
||||
} else if (status === 401) {
|
||||
const waitTimeMs = data.waitTime || 0;
|
||||
if (waitTimeMs > 0) {
|
||||
const seconds = Math.ceil(waitTimeMs / 1000);
|
||||
setRemainingWaitTime(seconds);
|
||||
setBaseError(t('incorrectPassword') || 'Incorrect password.');
|
||||
} else {
|
||||
setPasswordError(t('incorrectPassword') || 'Incorrect password');
|
||||
}
|
||||
} else {
|
||||
setPasswordError(t('loginFailed') || 'Verification failed');
|
||||
}
|
||||
} else {
|
||||
setPasswordError(t('networkError' as any) || 'Network error');
|
||||
}
|
||||
} finally {
|
||||
setIsVerifyingPassword(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClosePasswordModal = () => {
|
||||
setShowPasswordModal(false);
|
||||
setPendingVisitorMode(null);
|
||||
setPasswordError('');
|
||||
setBaseError('');
|
||||
setRemainingWaitTime(0);
|
||||
};
|
||||
|
||||
// Effect to handle countdown
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout;
|
||||
|
||||
if (remainingWaitTime > 0) {
|
||||
// Update error message immediately
|
||||
const waitMsg = t('waitTimeMessage')?.replace('{time}', `${remainingWaitTime}s`) || `Please wait ${remainingWaitTime}s.`;
|
||||
setPasswordError(`${baseError} ${waitMsg}`);
|
||||
|
||||
interval = setInterval(() => {
|
||||
setRemainingWaitTime((prev) => {
|
||||
if (prev <= 1) {
|
||||
// Countdown finished
|
||||
setPasswordError(baseError);
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (interval) clearInterval(interval);
|
||||
};
|
||||
}, [remainingWaitTime, baseError, t]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={visitorMode ?? false}
|
||||
onChange={(e) => handleVisitorModeChange(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label={t('visitorMode') || "Visitor Mode (Read-only)"}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5, ml: 4.5 }}>
|
||||
{t('visitorModeDescription') || "Read-only mode. Hidden videos will not be visible to visitors."}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<PasswordModal
|
||||
isOpen={showPasswordModal}
|
||||
onClose={handleClosePasswordModal}
|
||||
onConfirm={handlePasswordConfirm}
|
||||
title={t('password' as any) || "Enter Website Password"}
|
||||
message={t('visitorModePasswordPrompt' as any) || "Please enter the website password to change Visitor Mode settings."}
|
||||
error={passwordError}
|
||||
isLoading={isVerifyingPassword}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default VisitorModeSettings;
|
||||
@@ -1,120 +0,0 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import axios from 'axios';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import GeneralSettings from '../GeneralSettings';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('axios');
|
||||
vi.mock('../../../contexts/LanguageContext', () => ({
|
||||
useLanguage: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
vi.mock('../../PasswordModal', () => ({
|
||||
default: ({ isOpen, onConfirm, onClose, error }: any) => isOpen ? (
|
||||
<div role="dialog">
|
||||
Password Modal
|
||||
{error && <div>{error}</div>}
|
||||
<button onClick={() => onConfirm('password')}>Confirm</button>
|
||||
<button onClick={onClose}>Close</button>
|
||||
</div>
|
||||
) : null
|
||||
}));
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false }
|
||||
}
|
||||
});
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
|
||||
describe('GeneralSettings', () => {
|
||||
const defaultProps = {
|
||||
language: 'en',
|
||||
websiteName: 'MyTube',
|
||||
itemsPerPage: 12,
|
||||
showYoutubeSearch: true,
|
||||
visitorMode: false,
|
||||
savedVisitorMode: false,
|
||||
infiniteScroll: false,
|
||||
videoColumns: 4,
|
||||
onChange: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
it('should render all settings controls', () => {
|
||||
render(<GeneralSettings {...defaultProps} />, { wrapper });
|
||||
|
||||
// Use getAllByText because label and helper text might match
|
||||
expect(screen.getAllByText('websiteName')[0]).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('MyTube')).toBeInTheDocument();
|
||||
expect(screen.getByText('infiniteScroll')).toBeInTheDocument();
|
||||
expect(screen.getByText('visitorMode')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onChange when inputs change', () => {
|
||||
render(<GeneralSettings {...defaultProps} />, { wrapper });
|
||||
|
||||
const nameInput = screen.getByDisplayValue('MyTube');
|
||||
fireEvent.change(nameInput, { target: { value: 'NewName' } });
|
||||
expect(defaultProps.onChange).toHaveBeenCalledWith('websiteName', 'NewName');
|
||||
|
||||
const itemsInput = screen.getByDisplayValue('12');
|
||||
fireEvent.change(itemsInput, { target: { value: '24' } });
|
||||
expect(defaultProps.onChange).toHaveBeenCalledWith('itemsPerPage', 24);
|
||||
});
|
||||
|
||||
it('should open password modal when changing visitor mode', () => {
|
||||
render(<GeneralSettings {...defaultProps} />, { wrapper });
|
||||
|
||||
const visitorSwitch = screen.getByLabelText('visitorMode');
|
||||
fireEvent.click(visitorSwitch);
|
||||
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should verify password and update visitor mode on success', async () => {
|
||||
(axios.post as any).mockResolvedValueOnce({ data: { success: true } }); // Verify
|
||||
(axios.post as any).mockResolvedValueOnce({ data: { success: true } }); // Save setting
|
||||
|
||||
render(<GeneralSettings {...defaultProps} />, { wrapper });
|
||||
|
||||
// Open modal
|
||||
fireEvent.click(screen.getByLabelText('visitorMode'));
|
||||
|
||||
// Confirm password
|
||||
fireEvent.click(screen.getByText('Confirm'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(axios.post).toHaveBeenCalledWith(expect.stringContaining('/verify-password'), { password: 'password' });
|
||||
expect(axios.post).toHaveBeenCalledWith(expect.stringContaining('/settings'), { visitorMode: true });
|
||||
expect(defaultProps.onChange).toHaveBeenCalledWith('visitorMode', true);
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show error on password verification failure', async () => {
|
||||
const errorResponse = {
|
||||
response: {
|
||||
status: 401,
|
||||
data: {}
|
||||
}
|
||||
};
|
||||
(axios.post as any).mockRejectedValue(errorResponse);
|
||||
|
||||
render(<GeneralSettings {...defaultProps} />, { wrapper });
|
||||
|
||||
fireEvent.click(screen.getByLabelText('visitorMode'));
|
||||
fireEvent.click(screen.getByText('Confirm'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('incorrectPassword')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -16,15 +16,17 @@ import React, { useEffect, useRef, useState } from 'react';
|
||||
import CollapsibleSection from '../components/CollapsibleSection';
|
||||
import ConfirmationModal from '../components/ConfirmationModal';
|
||||
import AdvancedSettings from '../components/Settings/AdvancedSettings';
|
||||
import BasicSettings from '../components/Settings/BasicSettings';
|
||||
import CloudDriveSettings from '../components/Settings/CloudDriveSettings';
|
||||
import CloudflareSettings from '../components/Settings/CloudflareSettings';
|
||||
import CookieSettings from '../components/Settings/CookieSettings';
|
||||
import DatabaseSettings from '../components/Settings/DatabaseSettings';
|
||||
import DownloadSettings from '../components/Settings/DownloadSettings';
|
||||
import GeneralSettings from '../components/Settings/GeneralSettings';
|
||||
import InterfaceDisplaySettings from '../components/Settings/InterfaceDisplaySettings';
|
||||
import SecuritySettings from '../components/Settings/SecuritySettings';
|
||||
import TagsSettings from '../components/Settings/TagsSettings';
|
||||
import VideoDefaultSettings from '../components/Settings/VideoDefaultSettings';
|
||||
import VisitorModeSettings from '../components/Settings/VisitorModeSettings';
|
||||
import YtDlpSettings from '../components/Settings/YtDlpSettings';
|
||||
import { useDownload } from '../contexts/DownloadContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
@@ -166,62 +168,77 @@ const SettingsPage: React.FC = () => {
|
||||
{/* Settings Card */}
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Grid container spacing={4}>
|
||||
{/* General Settings - Only show visitor mode toggle when visitor mode is enabled */}
|
||||
<Grid container spacing={2}>
|
||||
{/* 1. Basic Settings */}
|
||||
<Grid size={12}>
|
||||
<CollapsibleSection title={t('general')} defaultExpanded={true}>
|
||||
<GeneralSettings
|
||||
<CollapsibleSection title={t('basicSettings')} defaultExpanded={true}>
|
||||
<BasicSettings
|
||||
language={settings.language}
|
||||
websiteName={settings.websiteName}
|
||||
itemsPerPage={settings.itemsPerPage}
|
||||
showYoutubeSearch={settings.showYoutubeSearch}
|
||||
visitorMode={settings.visitorMode}
|
||||
savedVisitorMode={settingsData?.visitorMode}
|
||||
infiniteScroll={settings.infiniteScroll}
|
||||
videoColumns={settings.videoColumns}
|
||||
|
||||
onChange={(field, value) => handleChange(field as keyof Settings, value)}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
</Grid>
|
||||
|
||||
{/* Cloudflare Settings */}
|
||||
{/* 2. Interface & Display */}
|
||||
{!visitorMode && (
|
||||
<Grid size={12}>
|
||||
<CollapsibleSection title={t('interfaceDisplay')} defaultExpanded={false}>
|
||||
<InterfaceDisplaySettings
|
||||
itemsPerPage={settings.itemsPerPage}
|
||||
showYoutubeSearch={settings.showYoutubeSearch}
|
||||
infiniteScroll={settings.infiniteScroll}
|
||||
videoColumns={settings.videoColumns}
|
||||
onChange={(field, value) => handleChange(field as keyof Settings, value)}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* 3. Security & Access */}
|
||||
<Grid size={12}>
|
||||
<CollapsibleSection title={t('cloudflaredTunnel')} defaultExpanded={false}>
|
||||
<CloudflareSettings
|
||||
enabled={settings.cloudflaredTunnelEnabled}
|
||||
token={settings.cloudflaredToken}
|
||||
visitorMode={visitorMode}
|
||||
onChange={(field, value) => handleChange(field as keyof Settings, value)}
|
||||
/>
|
||||
<CollapsibleSection title={t('securityAccess')} defaultExpanded={false}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<Box>
|
||||
<VisitorModeSettings
|
||||
visitorMode={settings.visitorMode}
|
||||
savedVisitorMode={settingsData?.visitorMode}
|
||||
onChange={(field, value) => handleChange(field as keyof Settings, value)}
|
||||
/>
|
||||
</Box>
|
||||
{!visitorMode && (
|
||||
<>
|
||||
<Box>
|
||||
<SecuritySettings
|
||||
settings={settings}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<CookieSettings
|
||||
onSuccess={(msg) => setMessage({ text: msg, type: 'success' })}
|
||||
onError={(msg) => setMessage({ text: msg, type: 'error' })}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
<Box>
|
||||
<CloudflareSettings
|
||||
enabled={settings.cloudflaredTunnelEnabled}
|
||||
token={settings.cloudflaredToken}
|
||||
visitorMode={visitorMode}
|
||||
onChange={(field, value) => handleChange(field as keyof Settings, value)}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</CollapsibleSection>
|
||||
</Grid>
|
||||
|
||||
{!visitorMode && (
|
||||
<>
|
||||
{/* Cookie Upload Settings */}
|
||||
{/* 4. Video Playback */}
|
||||
<Grid size={12}>
|
||||
<CollapsibleSection title={t('cookieSettings') || 'Cookie Settings'} defaultExpanded={false}>
|
||||
<CookieSettings
|
||||
onSuccess={(msg) => setMessage({ text: msg, type: 'success' })}
|
||||
onError={(msg) => setMessage({ text: msg, type: 'error' })}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
</Grid>
|
||||
|
||||
{/* Security Settings */}
|
||||
<Grid size={12}>
|
||||
<CollapsibleSection title={t('security')} defaultExpanded={false}>
|
||||
<SecuritySettings
|
||||
settings={settings}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
</Grid>
|
||||
|
||||
{/* Video Defaults */}
|
||||
<Grid size={12}>
|
||||
<CollapsibleSection title={t('videoDefaults')} defaultExpanded={false}>
|
||||
<CollapsibleSection title={t('videoPlayback')} defaultExpanded={false}>
|
||||
<VideoDefaultSettings
|
||||
settings={settings}
|
||||
onChange={handleChange}
|
||||
@@ -229,9 +246,43 @@ const SettingsPage: React.FC = () => {
|
||||
</CollapsibleSection>
|
||||
</Grid>
|
||||
|
||||
{/* Tags Management */}
|
||||
{/* 5. Download & Storage */}
|
||||
<Grid size={12}>
|
||||
<CollapsibleSection title={t('tagsManagement') || 'Tags Management'} defaultExpanded={false}>
|
||||
<CollapsibleSection title={t('downloadStorage')} defaultExpanded={false}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>{t('downloadSettings')}</Typography>
|
||||
<DownloadSettings
|
||||
settings={settings}
|
||||
onChange={handleChange}
|
||||
activeDownloadsCount={activeDownloads.length}
|
||||
onCleanup={() => setShowCleanupTempFilesModal(true)}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>{t('cloudDriveSettings')}</Typography>
|
||||
<CloudDriveSettings
|
||||
settings={settings}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>{t('ytDlpConfiguration') || 'yt-dlp Configuration'}</Typography>
|
||||
<YtDlpSettings
|
||||
config={settings.ytDlpConfig || ''}
|
||||
proxyOnlyYoutube={settings.proxyOnlyYoutube || false}
|
||||
onChange={(config) => handleChange('ytDlpConfig', config)}
|
||||
onProxyOnlyYoutubeChange={(checked) => handleChange('proxyOnlyYoutube', checked)}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</CollapsibleSection>
|
||||
</Grid>
|
||||
|
||||
{/* 6. Content Management */}
|
||||
<Grid size={12}>
|
||||
<CollapsibleSection title={t('contentManagement')} defaultExpanded={false}>
|
||||
<TagsSettings
|
||||
tags={Array.isArray(settings.tags) ? settings.tags : []}
|
||||
onTagsChange={handleTagsChange}
|
||||
@@ -239,32 +290,9 @@ const SettingsPage: React.FC = () => {
|
||||
</CollapsibleSection>
|
||||
</Grid>
|
||||
|
||||
{/* Download Settings */}
|
||||
{/* 7. Data Management */}
|
||||
<Grid size={12}>
|
||||
<CollapsibleSection title={t('downloadSettings')} defaultExpanded={false}>
|
||||
<DownloadSettings
|
||||
settings={settings}
|
||||
onChange={handleChange}
|
||||
activeDownloadsCount={activeDownloads.length}
|
||||
onCleanup={() => setShowCleanupTempFilesModal(true)}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
</Grid>
|
||||
|
||||
{/* Cloud Drive Settings */}
|
||||
<Grid size={12}>
|
||||
<CollapsibleSection title={t('cloudDriveSettings')} defaultExpanded={false}>
|
||||
<CloudDriveSettings
|
||||
settings={settings}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
</Grid>
|
||||
|
||||
{/* Database Settings */}
|
||||
<Grid size={12}>
|
||||
<CollapsibleSection title={t('database')} defaultExpanded={false}>
|
||||
<CollapsibleSection title={t('dataManagement')} defaultExpanded={false}>
|
||||
<DatabaseSettings
|
||||
onMigrate={() => setShowMigrateConfirmModal(true)}
|
||||
onDeleteLegacy={() => setShowDeleteLegacyModal(true)}
|
||||
@@ -283,21 +311,9 @@ const SettingsPage: React.FC = () => {
|
||||
</CollapsibleSection>
|
||||
</Grid>
|
||||
|
||||
{/* yt-dlp Configuration */}
|
||||
{/* 8. Advanced */}
|
||||
<Grid size={12}>
|
||||
<CollapsibleSection title={t('ytDlpConfiguration') || 'yt-dlp Configuration'} defaultExpanded={false}>
|
||||
<YtDlpSettings
|
||||
config={settings.ytDlpConfig || ''}
|
||||
proxyOnlyYoutube={settings.proxyOnlyYoutube || false}
|
||||
onChange={(config) => handleChange('ytDlpConfig', config)}
|
||||
onProxyOnlyYoutubeChange={(checked) => handleChange('proxyOnlyYoutube', checked)}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
</Grid>
|
||||
|
||||
{/* Advanced Settings */}
|
||||
<Grid size={12}>
|
||||
<CollapsibleSection title={t('debugMode') || 'Advanced Settings'} defaultExpanded={false}>
|
||||
<CollapsibleSection title={t('advanced')} defaultExpanded={false}>
|
||||
<AdvancedSettings
|
||||
debugMode={debugMode}
|
||||
onDebugModeChange={setDebugMode}
|
||||
|
||||
@@ -7,6 +7,9 @@ const mockSettingsData = { data: {} };
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useQuery: vi.fn(() => mockSettingsData),
|
||||
useMutation: vi.fn(),
|
||||
useQueryClient: vi.fn(() => ({
|
||||
invalidateQueries: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../../contexts/LanguageContext', () => {
|
||||
@@ -67,8 +70,12 @@ vi.mock('../../hooks/useStickyButton', () => ({
|
||||
}));
|
||||
|
||||
// Mock Child Components to simplify testing
|
||||
vi.mock('../../components/Settings/GeneralSettings', () => ({
|
||||
default: () => <div data-testid="general-settings">GeneralSettings</div>,
|
||||
vi.mock('../../components/Settings/BasicSettings', () => ({
|
||||
default: () => <div data-testid="basic-settings">BasicSettings</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../../components/Settings/InterfaceDisplaySettings', () => ({
|
||||
default: () => <div data-testid="interface-display-settings">InterfaceDisplaySettings</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../../components/Settings/CloudflareSettings', () => ({
|
||||
@@ -134,7 +141,8 @@ describe('SettingsPage', () => {
|
||||
it('renders all settings sections', async () => {
|
||||
render(<SettingsPage />);
|
||||
|
||||
expect(screen.getByTestId('general-settings')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('basic-settings')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('interface-display-settings')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('cloudflare-settings')).toBeInTheDocument();
|
||||
// Since visitorMode is mocked to false, these should be visible
|
||||
expect(screen.getByTestId('cookie-settings')).toBeInTheDocument();
|
||||
|
||||
@@ -34,6 +34,15 @@ export const ar = {
|
||||
security: "الأمان",
|
||||
videoDefaults: "إعدادات مشغل الفيديو الافتراضية",
|
||||
downloadSettings: "إعدادات التحميل",
|
||||
// Settings Categories
|
||||
basicSettings: "الإعدادات الأساسية",
|
||||
interfaceDisplay: "الواجهة والعرض",
|
||||
securityAccess: "الأمان والوصول",
|
||||
videoPlayback: "تشغيل الفيديو",
|
||||
downloadStorage: "التنزيل والتخزين",
|
||||
contentManagement: "إدارة المحتوى",
|
||||
dataManagement: "إدارة البيانات",
|
||||
advanced: "متقدم",
|
||||
language: "اللغة",
|
||||
websiteName: "اسم الموقع",
|
||||
websiteNameHelper: "{current}/{max} أحرف (الافتراضي: {default})",
|
||||
@@ -97,9 +106,9 @@ export const ar = {
|
||||
removeLegacyDataConfirmMessage:
|
||||
"هل أنت متأكد أنك تريد حذف ملفات بيانات JSON القديمة؟ لا يمكن التراجع عن هذا الإجراء.",
|
||||
legacyDataDeleted: "تم حذف البيانات القديمة بنجاح.",
|
||||
formatLegacyFilenames: "Format Legacy Filenames",
|
||||
formatLegacyFilenames: "تنسيق أسماء الملفات القديمة",
|
||||
formatLegacyFilenamesDescription:
|
||||
"Batch rename all video files, thumbnails, and subtitles to the new standard format: Title-Author-YYYY. This operation will modify filenames on the disk and update the database logic.",
|
||||
"إعادة تسمية جميع ملفات الفيديو والصور والترجمات دفعة واحدة إلى التنسيق القياسي الجديد: العنوان-المؤلف-السنة. ستؤدي هذه العملية إلى تعديل أسماء الملفات على القرص وتحديث منطق قاعدة البيانات.",
|
||||
formatLegacyFilenamesButton: "تنسيق أسماء الملفات",
|
||||
formatFilenamesSuccess:
|
||||
"تمت المعالجة: {processed}\nتمت إعادة التسمية: {renamed}\nالأخطاء: {errors}",
|
||||
@@ -177,11 +186,11 @@ export const ar = {
|
||||
syncFailedMessage: "فشلت المزامنة. يرجى المحاولة مرة أخرى.",
|
||||
foundVideosToSync: "تم العثور على {count} مقاطع فيديو بملفات محلية للمزامنة",
|
||||
uploadingVideo: "جاري الرفع: {title}",
|
||||
clearThumbnailCache: "Clear Thumbnail Local Cache",
|
||||
clearing: "Clearing...",
|
||||
clearThumbnailCacheSuccess: "Thumbnail cache cleared successfully. Thumbnails will be regenerated when accessed next time.",
|
||||
clearThumbnailCacheError: "Failed to clear thumbnail cache",
|
||||
clearThumbnailCacheConfirmMessage: "This will clear all locally cached thumbnails for cloud videos. Thumbnails will be regenerated from cloud storage when accessed next time. Continue?",
|
||||
clearThumbnailCache: "مسح ذاكرة التخزين المؤقت للصور المصغرة",
|
||||
clearing: "جاري المسح...",
|
||||
clearThumbnailCacheSuccess: "تم مسح ذاكرة التخزين المؤقت للصور المصغرة بنجاح. سيتم إعادة إنشاء الصور المصغرة عند الوصول إليها في المرة القادمة.",
|
||||
clearThumbnailCacheError: "فشل مسح ذاكرة التخزين المؤقت للصور المصغرة",
|
||||
clearThumbnailCacheConfirmMessage: "سيؤدي هذا إلى مسح جميع الصور المصغرة المخزنة مؤقتًا محليًا لمقاطع الفيديو السحابية. سيتم إعادة إنشاء الصور المصغرة من التخزين السحابي عند الوصول إليها في المرة القادمة. هل تريد المتابعة؟",
|
||||
|
||||
// Manage
|
||||
manageContent: "إدارة المحتوى",
|
||||
@@ -582,19 +591,19 @@ export const ar = {
|
||||
failedToDeleteAuthor: "فشل حذف المؤلف",
|
||||
|
||||
// Cloudflare Tunnel
|
||||
cloudflaredTunnel: "Cloudflare Tunnel",
|
||||
enableCloudflaredTunnel: "Enable Cloudflare Tunnel",
|
||||
cloudflaredToken: "Tunnel Token (Optional)",
|
||||
cloudflaredTokenHelper: "Paste your tunnel token here, or leave empty to use a random Quick Tunnel.",
|
||||
waitingForUrl: "Waiting for Quick Tunnel URL...",
|
||||
running: "Running",
|
||||
stopped: "Stopped",
|
||||
tunnelId: "Tunnel ID",
|
||||
accountTag: "Account Tag",
|
||||
copied: "Copied!",
|
||||
clickToCopy: "Click to copy",
|
||||
quickTunnelWarning: "Quick Tunnel URLs change every time the tunnel restarts.",
|
||||
managedInDashboard: "Public hostname is managed in your Cloudflare Zero Trust Dashboard.",
|
||||
cloudflaredTunnel: "نفق Cloudflare",
|
||||
enableCloudflaredTunnel: "تمكين نفق Cloudflare",
|
||||
cloudflaredToken: "رمز النفق (اختياري)",
|
||||
cloudflaredTokenHelper: "الصق رمز النفق الخاص بك هنا، أو اتركه فارغًا لاستخدام نفق سريع عشوائي.",
|
||||
waitingForUrl: "في انتظار عنوان النفق السريع URL...",
|
||||
running: "يعمل",
|
||||
stopped: "متوقف",
|
||||
tunnelId: "معرف النفق",
|
||||
accountTag: "علامة الحساب",
|
||||
copied: "تم النسخ!",
|
||||
clickToCopy: "انقر للنسخ",
|
||||
quickTunnelWarning: "تتغير عناوين URL للنفق السريع في كل مرة يتم فيها إعادة تشغيل النفق.",
|
||||
managedInDashboard: "تتم إدارة اسم المضيف العام في لوحة تحكم Cloudflare Zero Trust الخاصة بك.",
|
||||
failedToDownloadVideo: "فشل تنزيل الفيديو. يرجى المحاولة مرة أخرى.",
|
||||
failedToDownload: "فشل التنزيل. يرجى المحاولة مرة أخرى.",
|
||||
playlistDownloadStarted: "بدأ تنزيل قائمة التشغيل",
|
||||
|
||||
@@ -32,6 +32,15 @@ export const de = {
|
||||
security: "Sicherheit",
|
||||
videoDefaults: "Player-Standardeinstellungen",
|
||||
downloadSettings: "Download-Einstellungen",
|
||||
// Settings Categories
|
||||
basicSettings: "Grundeinstellungen",
|
||||
interfaceDisplay: "Oberfläche & Anzeige",
|
||||
securityAccess: "Sicherheit & Zugriff",
|
||||
videoPlayback: "Videowiedergabe",
|
||||
downloadStorage: "Herunterladen & Speicher",
|
||||
contentManagement: "Inhaltsverwaltung",
|
||||
dataManagement: "Datenverwaltung",
|
||||
advanced: "Erweitert",
|
||||
language: "Sprache",
|
||||
websiteName: "Website-Name",
|
||||
websiteNameHelper: "{current}/{max} Zeichen (Standard: {default})",
|
||||
@@ -97,9 +106,9 @@ export const de = {
|
||||
removeLegacyDataConfirmMessage:
|
||||
"Sind Sie sicher, dass Sie die Legacy-JSON-Datendateien löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
legacyDataDeleted: "Legacy-Daten erfolgreich gelöscht.",
|
||||
formatLegacyFilenames: "Format Legacy Filenames",
|
||||
formatLegacyFilenames: "Veraltete Dateinamen formatieren",
|
||||
formatLegacyFilenamesDescription:
|
||||
"Batch rename all video files, thumbnails, and subtitles to the new standard format: Title-Author-YYYY. This operation will modify filenames on the disk and update the database logic.",
|
||||
"Benennen Sie alle Videodateien, Thumbnails und Untertitel im Stapel in das neue Standardformat um: Titel-Autor-JJJJ. Dieser Vorgang ändert Dateinamen auf dem Datenträger und aktualisiert die Datenbanklogik.",
|
||||
formatLegacyFilenamesButton: "Dateinamen formatieren",
|
||||
formatFilenamesSuccess:
|
||||
"Bearbeitet: {processed}\nUmbenannt: {renamed}\nFehler: {errors}",
|
||||
@@ -174,11 +183,11 @@ export const de = {
|
||||
syncFailedMessage: "Synchronisierung fehlgeschlagen. Bitte versuchen Sie es erneut.",
|
||||
foundVideosToSync: "{count} Videos mit lokalen Dateien zum Synchronisieren gefunden",
|
||||
uploadingVideo: "Lade hoch: {title}",
|
||||
clearThumbnailCache: "Clear Thumbnail Local Cache",
|
||||
clearing: "Clearing...",
|
||||
clearThumbnailCacheSuccess: "Thumbnail cache cleared successfully. Thumbnails will be regenerated when accessed next time.",
|
||||
clearThumbnailCacheError: "Failed to clear thumbnail cache",
|
||||
clearThumbnailCacheConfirmMessage: "This will clear all locally cached thumbnails for cloud videos. Thumbnails will be regenerated from cloud storage when accessed next time. Continue?",
|
||||
clearThumbnailCache: "Lokalen Thumbnail-Cache leeren",
|
||||
clearing: "Leeren...",
|
||||
clearThumbnailCacheSuccess: "Thumbnail-Cache erfolgreich geleert. Thumbnails werden beim nächsten Zugriff neu generiert.",
|
||||
clearThumbnailCacheError: "Fehler beim Leeren des Thumbnail-Caches",
|
||||
clearThumbnailCacheConfirmMessage: "Dies löscht alle lokal zwischengespeicherten Thumbnails für Cloud-Videos. Thumbnails werden beim nächsten Zugriff aus dem Cloud-Speicher neu generiert. Fortfahren?",
|
||||
|
||||
manageContent: "Inhalte Verwalten",
|
||||
videos: "Videos",
|
||||
@@ -564,18 +573,18 @@ export const de = {
|
||||
|
||||
// Cloudflare Tunnel
|
||||
cloudflaredTunnel: "Cloudflare Tunnel",
|
||||
enableCloudflaredTunnel: "Enable Cloudflare Tunnel",
|
||||
cloudflaredToken: "Tunnel Token (Optional)",
|
||||
cloudflaredTokenHelper: "Paste your tunnel token here, or leave empty to use a random Quick Tunnel.",
|
||||
waitingForUrl: "Waiting for Quick Tunnel URL...",
|
||||
running: "Running",
|
||||
stopped: "Stopped",
|
||||
tunnelId: "Tunnel ID",
|
||||
accountTag: "Account Tag",
|
||||
copied: "Copied!",
|
||||
clickToCopy: "Click to copy",
|
||||
quickTunnelWarning: "Quick Tunnel URLs change every time the tunnel restarts.",
|
||||
managedInDashboard: "Public hostname is managed in your Cloudflare Zero Trust Dashboard.",
|
||||
enableCloudflaredTunnel: "Cloudflare Tunnel aktivieren",
|
||||
cloudflaredToken: "Tunnel-Token (Optional)",
|
||||
cloudflaredTokenHelper: "Fügen Sie hier Ihr Tunnel-Token ein oder lassen Sie es leer, um einen zufälligen Quick Tunnel zu verwenden.",
|
||||
waitingForUrl: "Warte auf Quick Tunnel URL...",
|
||||
running: "Läuft",
|
||||
stopped: "Gestoppt",
|
||||
tunnelId: "Tunnel-ID",
|
||||
accountTag: "Konto-Tag",
|
||||
copied: "Kopiert!",
|
||||
clickToCopy: "Zum Kopieren klicken",
|
||||
quickTunnelWarning: "Quick Tunnel URLs ändern sich bei jedem Neustart des Tunnels.",
|
||||
managedInDashboard: "Öffentlicher Hostname wird in Ihrem Cloudflare Zero Trust Dashboard verwaltet.",
|
||||
failedToDownloadVideo: "Fehler beim Herunterladen des Videos. Bitte versuchen Sie es erneut.",
|
||||
failedToDownload: "Fehler beim Herunterladen. Bitte versuchen Sie es erneut.",
|
||||
playlistDownloadStarted: "Playlist-Download gestartet",
|
||||
|
||||
@@ -34,6 +34,15 @@ export const en = {
|
||||
security: "Security",
|
||||
videoDefaults: "Video Player Defaults",
|
||||
downloadSettings: "Download Settings",
|
||||
// Settings Categories
|
||||
basicSettings: "Basic Settings",
|
||||
interfaceDisplay: "Interface & Display",
|
||||
securityAccess: "Security & Access",
|
||||
videoPlayback: "Video Playback",
|
||||
downloadStorage: "Download & Storage",
|
||||
contentManagement: "Content Management",
|
||||
dataManagement: "Data Management",
|
||||
advanced: "Advanced",
|
||||
language: "Language",
|
||||
websiteName: "Website Name",
|
||||
websiteNameHelper: "{current}/{max} characters (Default: {default})",
|
||||
@@ -616,4 +625,5 @@ export const en = {
|
||||
noBackupAvailable: "No backup available",
|
||||
failedToDownloadVideo: "Failed to download video. Please try again.",
|
||||
failedToDownload: "Failed to download. Please try again.",
|
||||
|
||||
};
|
||||
|
||||
@@ -43,6 +43,15 @@ export const es = {
|
||||
security: "Seguridad",
|
||||
videoDefaults: "Predeterminados del Reproductor",
|
||||
downloadSettings: "Configuración de Descarga",
|
||||
// Settings Categories
|
||||
basicSettings: "Configuración Básica",
|
||||
interfaceDisplay: "Interfaz y Visualización",
|
||||
securityAccess: "Seguridad y Acceso",
|
||||
videoPlayback: "Reproducción de Video",
|
||||
downloadStorage: "Descarga y Almacenamiento",
|
||||
contentManagement: "Gestión de Contenido",
|
||||
dataManagement: "Gestión de Datos",
|
||||
advanced: "Avanzado",
|
||||
language: "Idioma",
|
||||
websiteName: "Nombre del sitio web",
|
||||
websiteNameHelper: "{current}/{max} caracteres (Predeterminado: {default})",
|
||||
@@ -188,11 +197,11 @@ export const es = {
|
||||
syncFailedMessage: "Sincronización fallida. Por favor intente de nuevo.",
|
||||
foundVideosToSync: "Se encontraron {count} videos con archivos locales para sincronizar",
|
||||
uploadingVideo: "Subiendo: {title}",
|
||||
clearThumbnailCache: "Clear Thumbnail Local Cache",
|
||||
clearing: "Clearing...",
|
||||
clearThumbnailCacheSuccess: "Thumbnail cache cleared successfully. Thumbnails will be regenerated when accessed next time.",
|
||||
clearThumbnailCacheError: "Failed to clear thumbnail cache",
|
||||
clearThumbnailCacheConfirmMessage: "This will clear all locally cached thumbnails for cloud videos. Thumbnails will be regenerated from cloud storage when accessed next time. Continue?",
|
||||
clearThumbnailCache: "Borrar caché local de miniaturas",
|
||||
clearing: "Borrando...",
|
||||
clearThumbnailCacheSuccess: "Caché de miniaturas borrado con éxito. Las miniaturas se regenerarán la próxima vez que se acceda a ellas.",
|
||||
clearThumbnailCacheError: "Error al borrar el caché de miniaturas",
|
||||
clearThumbnailCacheConfirmMessage: "Esto borrará todas las miniaturas almacenadas en caché localmente para videos en la nube. Las miniaturas se regenerarán desde el almacenamiento en la nube la próxima vez que se acceda a ellas. ¿Continuar?",
|
||||
|
||||
manageContent: "Gestionar Contenido",
|
||||
videos: "Videos",
|
||||
@@ -569,19 +578,19 @@ export const es = {
|
||||
noBackupAvailable: "No hay copia de respaldo disponible",
|
||||
|
||||
// Cloudflare Tunnel
|
||||
cloudflaredTunnel: "Cloudflare Tunnel",
|
||||
enableCloudflaredTunnel: "Enable Cloudflare Tunnel",
|
||||
cloudflaredToken: "Tunnel Token (Optional)",
|
||||
cloudflaredTokenHelper: "Paste your tunnel token here, or leave empty to use a random Quick Tunnel.",
|
||||
waitingForUrl: "Waiting for Quick Tunnel URL...",
|
||||
running: "Running",
|
||||
stopped: "Stopped",
|
||||
tunnelId: "Tunnel ID",
|
||||
accountTag: "Account Tag",
|
||||
copied: "Copied!",
|
||||
clickToCopy: "Click to copy",
|
||||
quickTunnelWarning: "Quick Tunnel URLs change every time the tunnel restarts.",
|
||||
managedInDashboard: "Public hostname is managed in your Cloudflare Zero Trust Dashboard.",
|
||||
cloudflaredTunnel: "Túnel Cloudflare",
|
||||
enableCloudflaredTunnel: "Habilitar túnel Cloudflare",
|
||||
cloudflaredToken: "Token del túnel (Opcional)",
|
||||
cloudflaredTokenHelper: "Pegue su token de túnel aquí, o déjelo vacío para usar un Quick Tunnel aleatorio.",
|
||||
waitingForUrl: "Esperando URL de Quick Tunnel...",
|
||||
running: "Ejecutando",
|
||||
stopped: "Detenido",
|
||||
tunnelId: "ID del Túnel",
|
||||
accountTag: "Etiqueta de cuenta",
|
||||
copied: "¡Copiado!",
|
||||
clickToCopy: "Clic para copiar",
|
||||
quickTunnelWarning: "Las URL de Quick Tunnel cambian cada vez que se reinicia el túnel.",
|
||||
managedInDashboard: "El nombre de host público se gestiona en su panel de control de Cloudflare Zero Trust.",
|
||||
failedToDownloadVideo: "Error al descargar el video. Inténtalo de nuevo.",
|
||||
failedToDownload: "Error al descargar. Inténtalo de nuevo.",
|
||||
playlistDownloadStarted: "Descarga de lista de reproducción iniciada",
|
||||
|
||||
@@ -37,6 +37,15 @@ export const fr = {
|
||||
security: "Sécurité",
|
||||
videoDefaults: "Paramètres par défaut du lecteur",
|
||||
downloadSettings: "Paramètres de téléchargement",
|
||||
// Settings Categories
|
||||
basicSettings: "Paramètres de base",
|
||||
interfaceDisplay: "Interface et Affichage",
|
||||
securityAccess: "Sécurité et Accès",
|
||||
videoPlayback: "Lecture Vidéo",
|
||||
downloadStorage: "Téléchargement et Stockage",
|
||||
contentManagement: "Gestion de Contenu",
|
||||
dataManagement: "Gestion de Données",
|
||||
advanced: "Avancé",
|
||||
language: "Langue",
|
||||
websiteName: "Nom du site web",
|
||||
websiteNameHelper: "{current}/{max} caractères (Défaut: {default})",
|
||||
@@ -197,11 +206,11 @@ export const fr = {
|
||||
foundVideosToSync:
|
||||
"{count} vidéos avec des fichiers locaux à synchroniser trouvées",
|
||||
uploadingVideo: "Téléversement : {title}",
|
||||
clearThumbnailCache: "Clear Thumbnail Local Cache",
|
||||
clearing: "Clearing...",
|
||||
clearThumbnailCacheSuccess: "Thumbnail cache cleared successfully. Thumbnails will be regenerated when accessed next time.",
|
||||
clearThumbnailCacheError: "Failed to clear thumbnail cache",
|
||||
clearThumbnailCacheConfirmMessage: "This will clear all locally cached thumbnails for cloud videos. Thumbnails will be regenerated from cloud storage when accessed next time. Continue?",
|
||||
clearThumbnailCache: "Vider le cache des miniatures locales",
|
||||
clearing: "Nettoyage...",
|
||||
clearThumbnailCacheSuccess: "Cache des miniatures vidé avec succès. Les miniatures seront régénérées lors du prochain accès.",
|
||||
clearThumbnailCacheError: "Échec du vidage du cache des miniatures",
|
||||
clearThumbnailCacheConfirmMessage: "Cela effacera toutes les miniatures mises en cache localement pour les vidéos cloud. Les miniatures seront régénérées à partir du stockage cloud lors du prochain accès. Continuer ?",
|
||||
|
||||
// Manage
|
||||
manageContent: "Gérer le contenu",
|
||||
@@ -616,19 +625,19 @@ export const fr = {
|
||||
noBackupAvailable: "Aucune sauvegarde disponible",
|
||||
|
||||
// Cloudflare Tunnel
|
||||
cloudflaredTunnel: "Cloudflare Tunnel",
|
||||
enableCloudflaredTunnel: "Enable Cloudflare Tunnel",
|
||||
cloudflaredToken: "Tunnel Token (Optional)",
|
||||
cloudflaredTokenHelper: "Paste your tunnel token here, or leave empty to use a random Quick Tunnel.",
|
||||
waitingForUrl: "Waiting for Quick Tunnel URL...",
|
||||
running: "Running",
|
||||
stopped: "Stopped",
|
||||
tunnelId: "Tunnel ID",
|
||||
accountTag: "Account Tag",
|
||||
copied: "Copied!",
|
||||
clickToCopy: "Click to copy",
|
||||
quickTunnelWarning: "Quick Tunnel URLs change every time the tunnel restarts.",
|
||||
managedInDashboard: "Public hostname is managed in your Cloudflare Zero Trust Dashboard.",
|
||||
cloudflaredTunnel: "Tunnel Cloudflare",
|
||||
enableCloudflaredTunnel: "Activer le tunnel Cloudflare",
|
||||
cloudflaredToken: "Jeton de tunnel (Optionnel)",
|
||||
cloudflaredTokenHelper: "Collez votre jeton de tunnel ici, ou laissez vide pour utiliser un Quick Tunnel aléatoire.",
|
||||
waitingForUrl: "En attente de l'URL Quick Tunnel...",
|
||||
running: "En cours",
|
||||
stopped: "Arrêté",
|
||||
tunnelId: "ID du Tunnel",
|
||||
accountTag: "Tag de compte",
|
||||
copied: "Copié !",
|
||||
clickToCopy: "Cliquer pour copier",
|
||||
quickTunnelWarning: "Les URL Quick Tunnel changent à chaque redémarrage du tunnel.",
|
||||
managedInDashboard: "Le nom d'hôte public est géré dans votre tableau de bord Cloudflare Zero Trust.",
|
||||
failedToDownloadVideo: "Échec du téléchargement de la vidéo. Veuillez réessayer.",
|
||||
failedToDownload: "Échec du téléchargement. Veuillez réessayer.",
|
||||
playlistDownloadStarted: "Téléchargement de la playlist commencé",
|
||||
|
||||
@@ -36,6 +36,15 @@ export const ja = {
|
||||
security: "セキュリティ",
|
||||
videoDefaults: "動画プレーヤーのデフォルト",
|
||||
downloadSettings: "ダウンロード設定",
|
||||
// Settings Categories
|
||||
basicSettings: "基本設定",
|
||||
interfaceDisplay: "インターフェースと表示",
|
||||
securityAccess: "セキュリティとアクセス",
|
||||
videoPlayback: "動画再生",
|
||||
downloadStorage: "ダウンロードとストレージ",
|
||||
contentManagement: "コンテンツ管理",
|
||||
dataManagement: "データ管理",
|
||||
advanced: "詳細設定",
|
||||
language: "言語",
|
||||
websiteName: "ウェブサイト名",
|
||||
websiteNameHelper: "{current}/{max} 文字 (デフォルト: {default})",
|
||||
@@ -102,9 +111,9 @@ export const ja = {
|
||||
removeLegacyDataConfirmMessage:
|
||||
"レガシーJSONデータファイルを削除してもよろしいですか?この操作は元に戻せません。",
|
||||
legacyDataDeleted: "レガシーデータが正常に削除されました。",
|
||||
formatLegacyFilenames: "Format Legacy Filenames",
|
||||
formatLegacyFilenames: "レガシーファイル名のフォーマット",
|
||||
formatLegacyFilenamesDescription:
|
||||
"Batch rename all video files, thumbnails, and subtitles to the new standard format: Title-Author-YYYY. This operation will modify filenames on the disk and update the database logic.",
|
||||
"すべての動画ファイル、サムネイル、字幕を新しい標準フォーマット(タイトル-作成者-YYYY)に一括リネームします。この操作はディスク上のファイル名を変更し、データベースのロジックを更新します。",
|
||||
formatLegacyFilenamesButton: "ファイル名をフォーマット",
|
||||
formatFilenamesSuccess:
|
||||
"処理済み: {processed}\n名前変更: {renamed}\nエラー: {errors}",
|
||||
@@ -183,11 +192,11 @@ export const ja = {
|
||||
syncFailedMessage: "同期に失敗しました。もう一度お試しください。",
|
||||
foundVideosToSync: "同期するローカルファイルを持つ動画が {count} 件見つかりました",
|
||||
uploadingVideo: "アップロード中: {title}",
|
||||
clearThumbnailCache: "Clear Thumbnail Local Cache",
|
||||
clearing: "Clearing...",
|
||||
clearThumbnailCacheSuccess: "Thumbnail cache cleared successfully. Thumbnails will be regenerated when accessed next time.",
|
||||
clearThumbnailCacheError: "Failed to clear thumbnail cache",
|
||||
clearThumbnailCacheConfirmMessage: "This will clear all locally cached thumbnails for cloud videos. Thumbnails will be regenerated from cloud storage when accessed next time. Continue?",
|
||||
clearThumbnailCache: "サムネイルのローカルキャッシュをクリア",
|
||||
clearing: "クリア中...",
|
||||
clearThumbnailCacheSuccess: "サムネイルキャッシュが正常にクリアされました。サムネイルは次回のアクセス時に再生成されます。",
|
||||
clearThumbnailCacheError: "サムネイルキャッシュのクリアに失敗しました",
|
||||
clearThumbnailCacheConfirmMessage: "クラウド動画用にローカルにキャッシュされたすべてのサムネイルをクリアします。サムネイルは次回のアクセス時にクラウドストレージから再生成されます。続行しますか?",
|
||||
|
||||
// Manage
|
||||
manageContent: "コンテンツの管理",
|
||||
@@ -592,18 +601,18 @@ export const ja = {
|
||||
|
||||
// Cloudflare Tunnel
|
||||
cloudflaredTunnel: "Cloudflare Tunnel",
|
||||
enableCloudflaredTunnel: "Enable Cloudflare Tunnel",
|
||||
cloudflaredToken: "Tunnel Token (Optional)",
|
||||
cloudflaredTokenHelper: "Paste your tunnel token here, or leave empty to use a random Quick Tunnel.",
|
||||
waitingForUrl: "Waiting for Quick Tunnel URL...",
|
||||
running: "Running",
|
||||
stopped: "Stopped",
|
||||
tunnelId: "Tunnel ID",
|
||||
accountTag: "Account Tag",
|
||||
copied: "Copied!",
|
||||
clickToCopy: "Click to copy",
|
||||
quickTunnelWarning: "Quick Tunnel URLs change every time the tunnel restarts.",
|
||||
managedInDashboard: "Public hostname is managed in your Cloudflare Zero Trust Dashboard.",
|
||||
enableCloudflaredTunnel: "Cloudflare Tunnelを有効にする",
|
||||
cloudflaredToken: "トンネルトークン (オプション)",
|
||||
cloudflaredTokenHelper: "ここにトンネルトークンを貼り付けるか、空のままにしてランダムなQuick Tunnelを使用します。",
|
||||
waitingForUrl: "Quick Tunnel URLを待機中...",
|
||||
running: "実行中",
|
||||
stopped: "停止",
|
||||
tunnelId: "トンネルID",
|
||||
accountTag: "アカウントタグ",
|
||||
copied: "コピーしました!",
|
||||
clickToCopy: "クリックしてコピー",
|
||||
quickTunnelWarning: "Quick TunnelのURLは、トンネルが再起動するたびに変更されます。",
|
||||
managedInDashboard: "パブリックホスト名はCloudflare Zero Trustダッシュボードで管理されています。",
|
||||
failedToDownloadVideo: "動画のダウンロードに失敗しました。もう一度お試しください。",
|
||||
failedToDownload: "ダウンロードに失敗しました。もう一度お試しください。",
|
||||
playlistDownloadStarted: "プレイリストのダウンロードが開始されました",
|
||||
|
||||
@@ -36,6 +36,15 @@ export const ko = {
|
||||
security: "보안",
|
||||
videoDefaults: "동영상 플레이어 기본값",
|
||||
downloadSettings: "다운로드 설정",
|
||||
// Settings Categories
|
||||
basicSettings: "기본 설정",
|
||||
interfaceDisplay: "인터페이스 및 디스플레이",
|
||||
securityAccess: "보안 및 액세스",
|
||||
videoPlayback: "동영상 재생",
|
||||
downloadStorage: "다운로드 및 저장소",
|
||||
contentManagement: "콘텐츠 관리",
|
||||
dataManagement: "데이터 관리",
|
||||
advanced: "고급",
|
||||
language: "언어",
|
||||
websiteName: "웹사이트 이름",
|
||||
websiteNameHelper: "{current}/{max} 자 (기본값: {default})",
|
||||
@@ -99,9 +108,9 @@ export const ko = {
|
||||
removeLegacyDataConfirmMessage:
|
||||
"레거시 JSON 데이터 파일을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
|
||||
legacyDataDeleted: "레거시 데이터가 성공적으로 삭제되었습니다.",
|
||||
formatLegacyFilenames: "Format Legacy Filenames",
|
||||
formatLegacyFilenames: "레거시 파일 이름 형식 지정",
|
||||
formatLegacyFilenamesDescription:
|
||||
"Batch rename all video files, thumbnails, and subtitles to the new standard format: Title-Author-YYYY. This operation will modify filenames on the disk and update the database logic.",
|
||||
"모든 동영상 파일, 썸네일, 자막을 새로운 표준 형식인 제목-작성자-YYYY로 일괄 이름을 변경합니다. 이 작업은 디스크의 파일 이름을 수정하고 데이터베이스 로직을 업데이트합니다.",
|
||||
formatLegacyFilenamesButton: "파일 이름 형식 지정",
|
||||
formatFilenamesSuccess:
|
||||
"처리됨: {processed}\n이름 변경됨: {renamed}\n오류: {errors}",
|
||||
@@ -180,11 +189,11 @@ export const ko = {
|
||||
syncFailedMessage: "동기화 실패. 다시 시도해주세요.",
|
||||
foundVideosToSync: "동기화할 로컬 파일이 있는 동영상 {count}개를 찾았습니다",
|
||||
uploadingVideo: "업로드 중: {title}",
|
||||
clearThumbnailCache: "Clear Thumbnail Local Cache",
|
||||
clearing: "Clearing...",
|
||||
clearThumbnailCacheSuccess: "Thumbnail cache cleared successfully. Thumbnails will be regenerated when accessed next time.",
|
||||
clearThumbnailCacheError: "Failed to clear thumbnail cache",
|
||||
clearThumbnailCacheConfirmMessage: "This will clear all locally cached thumbnails for cloud videos. Thumbnails will be regenerated from cloud storage when accessed next time. Continue?",
|
||||
clearThumbnailCache: "썸네일 로컬 캐시 지우기",
|
||||
clearing: "지우는 중...",
|
||||
clearThumbnailCacheSuccess: "썸네일 캐시가 성공적으로 지워졌습니다. 썸네일은 다음에 액세스할 때 재생성됩니다.",
|
||||
clearThumbnailCacheError: "썸네일 캐시 지우기 실패",
|
||||
clearThumbnailCacheConfirmMessage: "이 작업은 클라우드 비디오에 대해 로컬로 캐시된 모든 썸네일을 지웁니다. 썸네일은 다음에 액세스할 때 클라우드 저장소에서 재생성됩니다. 계속하시겠습니까?",
|
||||
|
||||
// Manage
|
||||
manageContent: "콘텐츠 관리",
|
||||
@@ -582,19 +591,19 @@ export const ko = {
|
||||
failedToDeleteAuthor: "작성자 삭제 실패",
|
||||
|
||||
// Cloudflare Tunnel
|
||||
cloudflaredTunnel: "Cloudflare Tunnel",
|
||||
enableCloudflaredTunnel: "Enable Cloudflare Tunnel",
|
||||
cloudflaredToken: "Tunnel Token (Optional)",
|
||||
cloudflaredTokenHelper: "Paste your tunnel token here, or leave empty to use a random Quick Tunnel.",
|
||||
waitingForUrl: "Waiting for Quick Tunnel URL...",
|
||||
running: "Running",
|
||||
stopped: "Stopped",
|
||||
tunnelId: "Tunnel ID",
|
||||
accountTag: "Account Tag",
|
||||
copied: "Copied!",
|
||||
clickToCopy: "Click to copy",
|
||||
quickTunnelWarning: "Quick Tunnel URLs change every time the tunnel restarts.",
|
||||
managedInDashboard: "Public hostname is managed in your Cloudflare Zero Trust Dashboard.",
|
||||
cloudflaredTunnel: "Cloudflare 터널",
|
||||
enableCloudflaredTunnel: "Cloudflare 터널 활성화",
|
||||
cloudflaredToken: "터널 토큰 (선택 사항)",
|
||||
cloudflaredTokenHelper: "여기에 터널 토큰을 붙여넣거나, 임의의 Quick Tunnel을 사용하려면 비워 두세요.",
|
||||
waitingForUrl: "Quick Tunnel URL 대기 중...",
|
||||
running: "실행 중",
|
||||
stopped: "중지됨",
|
||||
tunnelId: "터널 ID",
|
||||
accountTag: "계정 태그",
|
||||
copied: "복사됨!",
|
||||
clickToCopy: "클릭하여 복사",
|
||||
quickTunnelWarning: "Quick Tunnel URL은 터널이 다시 시작될 때마다 변경됩니다.",
|
||||
managedInDashboard: "공개 호스트 이름은 Cloudflare Zero Trust 대시보드에서 관리됩니다.",
|
||||
failedToDownloadVideo: "동영상 다운로드에 실패했습니다. 다시 시도해 주세요.",
|
||||
failedToDownload: "다운로드에 실패했습니다. 다시 시도해 주세요.",
|
||||
playlistDownloadStarted: "재생 목록 다운로드가 시작되었습니다",
|
||||
|
||||
@@ -37,6 +37,15 @@ export const pt = {
|
||||
security: "Segurança",
|
||||
videoDefaults: "Padrões do Reprodutor de Vídeo",
|
||||
downloadSettings: "Configurações de Download",
|
||||
// Settings Categories
|
||||
basicSettings: "Configurações Básicas",
|
||||
interfaceDisplay: "Interface e Exibição",
|
||||
securityAccess: "Segurança e Acesso",
|
||||
videoPlayback: "Reprodução de Vídeo",
|
||||
downloadStorage: "Download e Armazenamento",
|
||||
contentManagement: "Gerenciamento de Conteúdo",
|
||||
dataManagement: "Gerenciamento de Dados",
|
||||
advanced: "Avançado",
|
||||
language: "Idioma",
|
||||
websiteName: "Nome do site",
|
||||
websiteNameHelper: "{current}/{max} caracteres (Padrão: {default})",
|
||||
@@ -102,9 +111,9 @@ export const pt = {
|
||||
removeLegacyDataConfirmMessage:
|
||||
"Tem certeza de que deseja excluir os arquivos de dados JSON legados? Esta ação não pode ser desfeita.",
|
||||
legacyDataDeleted: "Dados legados excluídos com sucesso.",
|
||||
formatLegacyFilenames: "Format Legacy Filenames",
|
||||
formatLegacyFilenames: "Formatar Nomes de Arquivos Legados",
|
||||
formatLegacyFilenamesDescription:
|
||||
"Batch rename all video files, thumbnails, and subtitles to the new standard format: Title-Author-YYYY. This operation will modify filenames on the disk and update the database logic.",
|
||||
"Renomear em lote todos os arquivos de vídeo, miniaturas e legendas para o novo formato padrão: Título-Autor-AAAA. Esta operação modificará os nomes dos arquivos no disco e atualizará a lógica do banco de dados.",
|
||||
formatLegacyFilenamesButton: "Formatar Nomes de Arquivos",
|
||||
formatFilenamesSuccess:
|
||||
"Processado: {processed}\nRenomeado: {renamed}\nErros: {errors}",
|
||||
@@ -183,11 +192,11 @@ export const pt = {
|
||||
syncFailedMessage: "Falha na sincronização. Tente novamente.",
|
||||
foundVideosToSync: "Encontrados {count} vídeos com arquivos locais para sincronizar",
|
||||
uploadingVideo: "Enviando: {title}",
|
||||
clearThumbnailCache: "Clear Thumbnail Local Cache",
|
||||
clearing: "Clearing...",
|
||||
clearThumbnailCacheSuccess: "Thumbnail cache cleared successfully. Thumbnails will be regenerated when accessed next time.",
|
||||
clearThumbnailCacheError: "Failed to clear thumbnail cache",
|
||||
clearThumbnailCacheConfirmMessage: "This will clear all locally cached thumbnails for cloud videos. Thumbnails will be regenerated from cloud storage when accessed next time. Continue?",
|
||||
clearThumbnailCache: "Limpar Cache Local de Miniaturas",
|
||||
clearing: "Limpando...",
|
||||
clearThumbnailCacheSuccess: "Cache de miniaturas limpo com sucesso. As miniaturas serão regeneradas na próxima vez que forem acessadas.",
|
||||
clearThumbnailCacheError: "Falha ao limpar cache de miniaturas",
|
||||
clearThumbnailCacheConfirmMessage: "Isso limpará todas as miniaturas armazenadas localmente para vídeos na nuvem. As miniaturas serão regeneradas do armazenamento em nuvem na próxima vez que forem acessadas. Continuar?",
|
||||
|
||||
// Manage
|
||||
manageContent: "Gerenciar Conteúdo",
|
||||
@@ -594,19 +603,19 @@ export const pt = {
|
||||
noBackupAvailable: "Nenhum backup disponível",
|
||||
|
||||
// Cloudflare Tunnel
|
||||
cloudflaredTunnel: "Cloudflare Tunnel",
|
||||
enableCloudflaredTunnel: "Enable Cloudflare Tunnel",
|
||||
cloudflaredToken: "Tunnel Token (Optional)",
|
||||
cloudflaredTokenHelper: "Paste your tunnel token here, or leave empty to use a random Quick Tunnel.",
|
||||
waitingForUrl: "Waiting for Quick Tunnel URL...",
|
||||
running: "Running",
|
||||
stopped: "Stopped",
|
||||
tunnelId: "Tunnel ID",
|
||||
accountTag: "Account Tag",
|
||||
copied: "Copied!",
|
||||
clickToCopy: "Click to copy",
|
||||
quickTunnelWarning: "Quick Tunnel URLs change every time the tunnel restarts.",
|
||||
managedInDashboard: "Public hostname is managed in your Cloudflare Zero Trust Dashboard.",
|
||||
cloudflaredTunnel: "Túnel Cloudflare",
|
||||
enableCloudflaredTunnel: "Habilitar Túnel Cloudflare",
|
||||
cloudflaredToken: "Token do Túnel (Opcional)",
|
||||
cloudflaredTokenHelper: "Cole o token do túnel aqui, ou deixe em branco para usar um Túnel Rápido aleatório.",
|
||||
waitingForUrl: "Aguardando URL do Túnel Rápido...",
|
||||
running: "Executando",
|
||||
stopped: "Parado",
|
||||
tunnelId: "ID do Túnel",
|
||||
accountTag: "Tag da Conta",
|
||||
copied: "Copiado!",
|
||||
clickToCopy: "Clique para copiar",
|
||||
quickTunnelWarning: "URLs de Túnel Rápido mudam toda vez que o túnel é reiniciado.",
|
||||
managedInDashboard: "O nome do host público é gerenciado no painel Cloudflare Zero Trust.",
|
||||
failedToDownloadVideo: "Falha ao baixar o vídeo. Por favor, tente novamente.",
|
||||
failedToDownload: "Falha ao baixar. Por favor, tente novamente.",
|
||||
playlistDownloadStarted: "Download da playlist iniciado",
|
||||
|
||||
@@ -46,6 +46,15 @@ export const ru = {
|
||||
security: "Безопасность",
|
||||
videoDefaults: "Настройки плеера по умолчанию",
|
||||
downloadSettings: "Настройки загрузки",
|
||||
// Settings Categories
|
||||
basicSettings: "Основные настройки",
|
||||
interfaceDisplay: "Интерфейс и отображение",
|
||||
securityAccess: "Безопасность и доступ",
|
||||
videoPlayback: "Воспроизведение видео",
|
||||
downloadStorage: "Загрузка и хранение",
|
||||
contentManagement: "Управление контентом",
|
||||
dataManagement: "Управление данными",
|
||||
advanced: "Дополнительно",
|
||||
language: "Язык",
|
||||
websiteName: "Название веб-сайта",
|
||||
websiteNameHelper: "{current}/{max} символов (По умолчанию: {default})",
|
||||
@@ -111,9 +120,9 @@ export const ru = {
|
||||
removeLegacyDataConfirmMessage:
|
||||
"Вы уверены, что хотите удалить устаревшие файлы данных JSON? Это действие нельзя отменить.",
|
||||
legacyDataDeleted: "Устаревшие данные успешно удалены.",
|
||||
formatLegacyFilenames: "Format Legacy Filenames",
|
||||
formatLegacyFilenames: "Форматировать старые имена файлов",
|
||||
formatLegacyFilenamesDescription:
|
||||
"Batch rename all video files, thumbnails, and subtitles to the new standard format: Title-Author-YYYY. This operation will modify filenames on the disk and update the database logic.",
|
||||
"Пакетное переименование всех видеофайлов, миниатюр и субтитров в новый стандартный формат: Название-Автор-ГГГГ. Эта операция изменит имена файлов на диске и обновит логику базы данных.",
|
||||
formatLegacyFilenamesButton: "Форматировать имена файлов",
|
||||
formatFilenamesSuccess:
|
||||
"Обработано: {processed}\nПереименовано: {renamed}\nОшибки: {errors}",
|
||||
@@ -191,11 +200,11 @@ export const ru = {
|
||||
syncFailedMessage: "Ошибка синхронизации. Пожалуйста, попробуйте снова.",
|
||||
foundVideosToSync: "Найдено {count} видео с локальными файлами для синхронизации",
|
||||
uploadingVideo: "Загрузка: {title}",
|
||||
clearThumbnailCache: "Clear Thumbnail Local Cache",
|
||||
clearing: "Clearing...",
|
||||
clearThumbnailCacheSuccess: "Thumbnail cache cleared successfully. Thumbnails will be regenerated when accessed next time.",
|
||||
clearThumbnailCacheError: "Failed to clear thumbnail cache",
|
||||
clearThumbnailCacheConfirmMessage: "This will clear all locally cached thumbnails for cloud videos. Thumbnails will be regenerated from cloud storage when accessed next time. Continue?",
|
||||
clearThumbnailCache: "Очистить локальный кэш миниатюр",
|
||||
clearing: "Очистка...",
|
||||
clearThumbnailCacheSuccess: "Кэш миниатюр успешно очищен. Миниатюры будут сгенерированы заново при следующем доступе.",
|
||||
clearThumbnailCacheError: "Не удалось очистить кэш миниатюр",
|
||||
clearThumbnailCacheConfirmMessage: "Это удалит все локально кэшированные миниатюры для облачных видео. Миниатюры будут сгенерированы заново из облачного хранилища при следующем доступе. Продолжить?",
|
||||
|
||||
// Manage
|
||||
manageContent: "Управление контентом",
|
||||
@@ -589,18 +598,18 @@ export const ru = {
|
||||
|
||||
// Cloudflare Tunnel
|
||||
cloudflaredTunnel: "Cloudflare Tunnel",
|
||||
enableCloudflaredTunnel: "Enable Cloudflare Tunnel",
|
||||
cloudflaredToken: "Tunnel Token (Optional)",
|
||||
cloudflaredTokenHelper: "Paste your tunnel token here, or leave empty to use a random Quick Tunnel.",
|
||||
waitingForUrl: "Waiting for Quick Tunnel URL...",
|
||||
running: "Running",
|
||||
stopped: "Stopped",
|
||||
tunnelId: "Tunnel ID",
|
||||
accountTag: "Account Tag",
|
||||
copied: "Copied!",
|
||||
clickToCopy: "Click to copy",
|
||||
quickTunnelWarning: "Quick Tunnel URLs change every time the tunnel restarts.",
|
||||
managedInDashboard: "Public hostname is managed in your Cloudflare Zero Trust Dashboard.",
|
||||
enableCloudflaredTunnel: "Включить Cloudflare Tunnel",
|
||||
cloudflaredToken: "Токен туннеля (Необязательно)",
|
||||
cloudflaredTokenHelper: "Вставьте сюда токен туннеля или оставьте пустым, чтобы использовать случайный быстрый туннель.",
|
||||
waitingForUrl: "Ожидание URL быстрого туннеля...",
|
||||
running: "Запущен",
|
||||
stopped: "Остановлен",
|
||||
tunnelId: "ID туннеля",
|
||||
accountTag: "Тег учетной записи",
|
||||
copied: "Скопировано!",
|
||||
clickToCopy: "Нажмите, чтобы скопировать",
|
||||
quickTunnelWarning: "URL быстрых туннелей меняются при каждом перезапуске туннеля.",
|
||||
managedInDashboard: "Публичное имя хоста управляется в панели управления Cloudflare Zero Trust.",
|
||||
failedToDownloadVideo: "Не удалось скачать видео. Пожалуйста, попробуйте снова.",
|
||||
failedToDownload: "Не удалось скачать. Пожалуйста, попробуйте снова.",
|
||||
playlistDownloadStarted: "Скачивание плейлиста началось",
|
||||
|
||||
@@ -34,6 +34,15 @@ export const zh = {
|
||||
security: "安全",
|
||||
videoDefaults: "播放器默认设置",
|
||||
downloadSettings: "下载设置",
|
||||
// Settings Categories
|
||||
basicSettings: "基础设置",
|
||||
interfaceDisplay: "界面与显示",
|
||||
securityAccess: "安全与访问",
|
||||
videoPlayback: "视频播放",
|
||||
downloadStorage: "下载与存储",
|
||||
contentManagement: "内容管理",
|
||||
dataManagement: "数据管理",
|
||||
advanced: "高级设置",
|
||||
language: "语言",
|
||||
websiteName: "网站名称",
|
||||
websiteNameHelper: "{current}/{max} 字符 (默认: {default})",
|
||||
|
||||
Reference in New Issue
Block a user