refactor: reorgnize settings apge

This commit is contained in:
Peifan Li
2025-12-29 22:17:31 -05:00
parent a1af750c0e
commit f812fe492e
19 changed files with 676 additions and 630 deletions

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -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: "بدأ تنزيل قائمة التشغيل",

View File

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

View File

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

View File

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

View File

@@ -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é",

View File

@@ -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 TunnelURLは、トンネルが再起動するたびに変更されます。",
managedInDashboard: "パブリックホスト名はCloudflare Zero Trustダッシュボードで管理されています。",
failedToDownloadVideo: "動画のダウンロードに失敗しました。もう一度お試しください。",
failedToDownload: "ダウンロードに失敗しました。もう一度お試しください。",
playlistDownloadStarted: "プレイリストのダウンロードが開始されました",

View File

@@ -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: "재생 목록 다운로드가 시작되었습니다",

View File

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

View File

@@ -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: "Скачивание плейлиста началось",

View File

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