feat: Add Cloudflare Tunnel integration
This commit is contained in:
@@ -41,6 +41,7 @@
|
||||
- **yt-dlp 配置**: 通过用户界面自定义全局 `yt-dlp` 参数、网络代理及其他高级设置。
|
||||
- **访客模式**:启用只读模式,允许查看视频但无法进行修改。非常适合与他人分享您的视频库。
|
||||
- **云存储集成**:下载后自动将视频和缩略图上传到云存储(OpenList/Alist)。
|
||||
- **Cloudflare Tunnel 集成**: 内置 Cloudflare Tunnel 支持,无需端口转发即可轻松将本地 MyTube 实例暴露到互联网。
|
||||
|
||||
## 目录结构
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ A YouTube/Bilibili/MissAV video downloader and player that supports channel subs
|
||||
- **yt-dlp Configuration**: Customize global `yt-dlp` arguments, network proxy, and other advanced settings via settings page.
|
||||
- **Visitor Mode**: Enable read-only mode to allow viewing videos without modification capabilities. Perfect for sharing your library with others.
|
||||
- **Cloud Storage Integration**: Automatically upload videos and thumbnails to cloud storage (OpenList/Alist) after download.
|
||||
- **Cloudflare Tunnel Integration**: Built-in Cloudflare Tunnel support to easily expose your local MyTube instance to the internet without port forwarding.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Box, Typography } from '@mui/material';
|
||||
import { Link } from 'react-router-dom';
|
||||
import logo from '../../assets/logo.svg';
|
||||
import { useCloudflareStatus } from '../../hooks/useCloudflareStatus';
|
||||
|
||||
interface LogoProps {
|
||||
websiteName: string;
|
||||
@@ -8,9 +9,32 @@ interface LogoProps {
|
||||
}
|
||||
|
||||
const Logo: React.FC<LogoProps> = ({ websiteName, onResetSearch }) => {
|
||||
// Only check status if we think it might be enabled, or just check always (it handles enabled=false internally somewhat, but better to query only if needed)
|
||||
// Since we don't have easy access to settings here without adding another context, we'll check status always for now or ideally use a context.
|
||||
// However, the hook defaults to enabled=true. Let's rely on the hook handling null if not running.
|
||||
// Actually, checking status constantly might be overkill if disabled. But without global settings context readily available in Logo without refactor, we'll assume we want to check.
|
||||
// Better strategy: The user explicitly asked for "When cloudflare Status is Running".
|
||||
const { data: cloudflaredStatus } = useCloudflareStatus(true);
|
||||
|
||||
return (
|
||||
<Link to="/" onClick={onResetSearch} style={{ textDecoration: 'none', display: 'flex', alignItems: 'center', color: 'inherit' }}>
|
||||
<img src={logo} alt="MyTube Logo" height={40} />
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
<img src={logo} alt="MyTube Logo" height={40} />
|
||||
{cloudflaredStatus?.isRunning && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 2,
|
||||
right: -2,
|
||||
width: 8,
|
||||
height: 8,
|
||||
bgcolor: '#4caf50', // Green
|
||||
borderRadius: '50%',
|
||||
boxShadow: '0 0 4px #4caf50'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ ml: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 'bold', lineHeight: 1 }}>
|
||||
{websiteName}
|
||||
|
||||
127
frontend/src/components/Settings/CloudflareSettings.tsx
Normal file
127
frontend/src/components/Settings/CloudflareSettings.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { Alert, Box, CircularProgress, FormControlLabel, Switch, TextField, Tooltip, Typography } from '@mui/material';
|
||||
import React, { useState } from 'react';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
|
||||
|
||||
|
||||
import { useCloudflareStatus } from '../../hooks/useCloudflareStatus';
|
||||
|
||||
interface CloudflareSettingsProps {
|
||||
enabled?: boolean;
|
||||
token?: string;
|
||||
onChange: (field: string, value: string | number | boolean) => void;
|
||||
}
|
||||
|
||||
const CloudflareSettings: React.FC<CloudflareSettingsProps> = ({ enabled, token, onChange }) => {
|
||||
const { t } = useLanguage();
|
||||
const [showCopied, setShowCopied] = useState(false);
|
||||
|
||||
const handleCopyUrl = (url: string) => {
|
||||
navigator.clipboard.writeText(url);
|
||||
setShowCopied(true);
|
||||
setTimeout(() => setShowCopied(false), 2000);
|
||||
};
|
||||
|
||||
// Poll for Cloudflare Tunnel status
|
||||
const { data: cloudflaredStatus, isLoading } = useCloudflareStatus(enabled ?? false);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h6" >
|
||||
{t('cloudflaredTunnel')}
|
||||
</Typography>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={enabled ?? false}
|
||||
onChange={(e) => onChange('cloudflaredTunnelEnabled', e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label={t('enableCloudflaredTunnel')}
|
||||
/>
|
||||
|
||||
{(enabled) && (
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('cloudflaredToken')}
|
||||
type="password"
|
||||
value={token || ''}
|
||||
onChange={(e) => onChange('cloudflaredToken', e.target.value)}
|
||||
margin="normal"
|
||||
helperText={t('cloudflaredTokenHelper') || "Paste your tunnel token here, or leave empty to use a random Quick Tunnel."}
|
||||
/>
|
||||
)}
|
||||
|
||||
{enabled && (isLoading || (!cloudflaredStatus && enabled) || (cloudflaredStatus?.isRunning && !token && !cloudflaredStatus.publicUrl)) ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}>
|
||||
<CircularProgress />
|
||||
{cloudflaredStatus?.isRunning && !token && !cloudflaredStatus.publicUrl && (
|
||||
<Typography variant="body2" sx={{ ml: 2, mt: 0.5 }}>
|
||||
{t('waitingForUrl')}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
) : (enabled && cloudflaredStatus && (
|
||||
<Box sx={{ mt: 2, p: 2, bgcolor: 'background.paper', borderRadius: 1, border: 1, borderColor: 'divider' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1, gap: 1 }}>
|
||||
<Typography variant="subtitle2">{t('status') || 'Status'}:</Typography>
|
||||
<Typography variant="body2" color={cloudflaredStatus.isRunning ? 'success.main' : 'error.main'} fontWeight="bold">
|
||||
{cloudflaredStatus.isRunning ? t('running') : t('stopped')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{cloudflaredStatus.tunnelId && (
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<Typography variant="subtitle2">{t('tunnelId')}:</Typography>
|
||||
<Typography variant="body2" fontFamily="monospace">
|
||||
{cloudflaredStatus.tunnelId}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{cloudflaredStatus.accountTag && (
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<Typography variant="subtitle2">{t('accountTag')}:</Typography>
|
||||
<Typography variant="body2" fontFamily="monospace">
|
||||
{cloudflaredStatus.accountTag}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{cloudflaredStatus.publicUrl && (
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<Typography variant="subtitle2">{t('publicUrl')}:</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Tooltip title={showCopied ? t('copied') : t('clickToCopy')} arrow>
|
||||
<Typography
|
||||
variant="body2"
|
||||
fontFamily="monospace"
|
||||
sx={{
|
||||
wordBreak: 'break-all',
|
||||
cursor: 'pointer',
|
||||
'&:hover': { textDecoration: 'underline', color: 'primary.main' }
|
||||
}}
|
||||
onClick={() => handleCopyUrl(cloudflaredStatus.publicUrl!)}
|
||||
>
|
||||
{cloudflaredStatus.publicUrl}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Alert severity="warning" sx={{ mt: 1, py: 0 }}>
|
||||
{t('quickTunnelWarning')}
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!cloudflaredStatus.publicUrl && (
|
||||
<Alert severity="info" sx={{ mt: 1 }}>
|
||||
{t('managedInDashboard')}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CloudflareSettings;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Alert, Box, FormControl, FormControlLabel, InputLabel, MenuItem, Select, Switch, TextField, Typography } from '@mui/material';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
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';
|
||||
@@ -16,13 +16,11 @@ interface GeneralSettingsProps {
|
||||
savedVisitorMode?: boolean;
|
||||
infiniteScroll?: boolean;
|
||||
videoColumns?: number;
|
||||
cloudflaredTunnelEnabled?: boolean;
|
||||
cloudflaredToken?: string;
|
||||
onChange: (field: string, value: string | number | boolean) => void;
|
||||
}
|
||||
|
||||
const GeneralSettings: React.FC<GeneralSettingsProps> = (props) => {
|
||||
const { language, websiteName, showYoutubeSearch, visitorMode, savedVisitorMode, infiniteScroll, videoColumns, cloudflaredTunnelEnabled, cloudflaredToken, onChange } = props;
|
||||
const { language, websiteName, showYoutubeSearch, visitorMode, savedVisitorMode, infiniteScroll, videoColumns, onChange } = props;
|
||||
const { t } = useLanguage();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -34,16 +32,7 @@ const GeneralSettings: React.FC<GeneralSettingsProps> = (props) => {
|
||||
const [baseError, setBaseError] = useState('');
|
||||
|
||||
// Poll for Cloudflare Tunnel status
|
||||
const { data: cloudflaredStatus } = useQuery({
|
||||
queryKey: ['cloudflaredStatus'],
|
||||
queryFn: async () => {
|
||||
if (!cloudflaredTunnelEnabled) return null;
|
||||
const res = await axios.get(`${API_URL}/settings/cloudflared/status`);
|
||||
return res.data;
|
||||
},
|
||||
enabled: !!cloudflaredTunnelEnabled,
|
||||
refetchInterval: 5000 // Poll every 5 seconds
|
||||
});
|
||||
|
||||
|
||||
// Use saved value for visibility, current value for toggle state
|
||||
const isVisitorMode = savedVisitorMode ?? visitorMode ?? false;
|
||||
@@ -245,81 +234,7 @@ const GeneralSettings: React.FC<GeneralSettingsProps> = (props) => {
|
||||
</>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle1" gutterBottom sx={{ mt: 2 }}>
|
||||
{t('cloudflaredTunnel')}
|
||||
</Typography>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={cloudflaredTunnelEnabled ?? false}
|
||||
onChange={(e) => onChange('cloudflaredTunnelEnabled', e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label={t('enableCloudflaredTunnel')}
|
||||
/>
|
||||
|
||||
{(cloudflaredTunnelEnabled) && (
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('cloudflaredToken')}
|
||||
type="password"
|
||||
value={cloudflaredToken || ''}
|
||||
onChange={(e) => onChange('cloudflaredToken', e.target.value)}
|
||||
margin="normal"
|
||||
helperText={t('cloudflaredTokenHelper') || "Paste your tunnel token here, or leave empty to use a random Quick Tunnel."}
|
||||
/>
|
||||
)}
|
||||
|
||||
{cloudflaredTunnelEnabled && cloudflaredStatus && (
|
||||
<Box sx={{ mt: 2, p: 2, bgcolor: 'background.paper', borderRadius: 1, border: 1, borderColor: 'divider' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1, gap: 1 }}>
|
||||
<Typography variant="subtitle2">Status:</Typography>
|
||||
<Typography variant="body2" color={cloudflaredStatus.isRunning ? 'success.main' : 'error.main'} fontWeight="bold">
|
||||
{cloudflaredStatus.isRunning ? 'Running' : 'Stopped'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{cloudflaredStatus.tunnelId && (
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<Typography variant="subtitle2">Tunnel ID:</Typography>
|
||||
<Typography variant="body2" fontFamily="monospace">
|
||||
{cloudflaredStatus.tunnelId}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{cloudflaredStatus.accountTag && (
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<Typography variant="subtitle2">Account Tag:</Typography>
|
||||
<Typography variant="body2" fontFamily="monospace">
|
||||
{cloudflaredStatus.accountTag}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{cloudflaredStatus.publicUrl && (
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<Typography variant="subtitle2">Public URL:</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="body2" fontFamily="monospace" sx={{ wordBreak: 'break-all' }}>
|
||||
{cloudflaredStatus.publicUrl}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Alert severity="warning" sx={{ mt: 1, py: 0 }}>
|
||||
Quick Tunnel URLs change every time the tunnel restarts.
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!cloudflaredStatus.publicUrl && (
|
||||
<Alert severity="info" sx={{ mt: 1 }}>
|
||||
Public hostname is managed in your Cloudflare Zero Trust Dashboard.
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
|
||||
<Box>
|
||||
|
||||
24
frontend/src/hooks/useCloudflareStatus.ts
Normal file
24
frontend/src/hooks/useCloudflareStatus.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
interface CloudflareStatus {
|
||||
isRunning: boolean;
|
||||
tunnelId: string | null;
|
||||
accountTag: string | null;
|
||||
publicUrl: string | null;
|
||||
}
|
||||
|
||||
export const useCloudflareStatus = (enabled: boolean = true) => {
|
||||
return useQuery<CloudflareStatus>({
|
||||
queryKey: ['cloudflaredStatus'],
|
||||
queryFn: async () => {
|
||||
if (!enabled) return { isRunning: false, tunnelId: null, accountTag: null, publicUrl: null };
|
||||
const res = await axios.get(`${API_URL}/settings/cloudflared/status`);
|
||||
return res.data;
|
||||
},
|
||||
enabled: !!enabled,
|
||||
refetchInterval: 5000 // Poll every 5 seconds
|
||||
});
|
||||
};
|
||||
@@ -17,6 +17,7 @@ import React, { useEffect, useRef, useState } from 'react';
|
||||
import ConfirmationModal from '../components/ConfirmationModal';
|
||||
import AdvancedSettings from '../components/Settings/AdvancedSettings';
|
||||
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';
|
||||
@@ -481,8 +482,18 @@ const SettingsPage: React.FC = () => {
|
||||
savedVisitorMode={settingsData?.visitorMode}
|
||||
infiniteScroll={settings.infiniteScroll}
|
||||
videoColumns={settings.videoColumns}
|
||||
cloudflaredTunnelEnabled={settings.cloudflaredTunnelEnabled}
|
||||
cloudflaredToken={settings.cloudflaredToken}
|
||||
|
||||
onChange={(field, value) => handleChange(field as keyof Settings, value)}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={12}><Divider /></Grid>
|
||||
|
||||
{/* Cloudflare Settings */}
|
||||
<Grid size={12}>
|
||||
<CloudflareSettings
|
||||
enabled={settings.cloudflaredTunnelEnabled}
|
||||
token={settings.cloudflaredToken}
|
||||
onChange={(field, value) => handleChange(field as keyof Settings, value)}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
@@ -571,4 +571,19 @@ export const ar = {
|
||||
deleteAuthorConfirmation: "هل أنت متأكد أنك تريد حذف المؤلف {author}؟ سيؤدي هذا إلى حذف جميع مقاطع الفيديو المرتبطة بهذا المؤلف.",
|
||||
authorDeletedSuccessfully: "تم حذف المؤلف بنجاح",
|
||||
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.",
|
||||
};
|
||||
|
||||
@@ -552,4 +552,19 @@ export const de = {
|
||||
restoreFromLastBackupFailed: "Fehler beim Wiederherstellen aus Backup",
|
||||
lastBackupDate: "Datum des letzten Backups",
|
||||
noBackupAvailable: "Kein Backup verfügbar",
|
||||
|
||||
// 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.",
|
||||
};
|
||||
|
||||
@@ -564,8 +564,17 @@ export const en = {
|
||||
// Cloudflare Tunnel
|
||||
cloudflaredTunnel: "Cloudflare Tunnel",
|
||||
enableCloudflaredTunnel: "Enable Cloudflare Tunnel",
|
||||
cloudflaredToken: "Tunnel Token",
|
||||
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.",
|
||||
|
||||
// Database Export/Import
|
||||
exportImportDatabase: "Export/Import Database",
|
||||
|
||||
@@ -558,4 +558,19 @@ export const es = {
|
||||
restoreFromLastBackupFailed: "Error al restaurar desde la copia de respaldo",
|
||||
lastBackupDate: "Fecha de última copia de respaldo",
|
||||
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.",
|
||||
};
|
||||
|
||||
@@ -605,4 +605,19 @@ export const fr = {
|
||||
restoreFromLastBackupFailed: "Échec de la restauration depuis la sauvegarde",
|
||||
lastBackupDate: "Date de la dernière sauvegarde",
|
||||
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.",
|
||||
};
|
||||
|
||||
@@ -580,4 +580,19 @@ export const ja = {
|
||||
deleteAuthorConfirmation: "著者 {author} を削除してもよろしいですか?これにより、この著者に関連するすべての動画が削除されます。",
|
||||
authorDeletedSuccessfully: "著者が正常に削除されました",
|
||||
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.",
|
||||
};
|
||||
|
||||
@@ -571,4 +571,19 @@ export const ko = {
|
||||
deleteAuthorConfirmation: "작성자 {author}님을 삭제하시겠습니까? 이 작성자와 관련된 모든 동영상이 삭제됩니다.",
|
||||
authorDeletedSuccessfully: "작성자가 성공적으로 삭제되었습니다",
|
||||
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.",
|
||||
};
|
||||
|
||||
@@ -583,4 +583,19 @@ export const pt = {
|
||||
restoreFromLastBackupFailed: "Falha ao restaurar do backup",
|
||||
lastBackupDate: "Data do último backup",
|
||||
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.",
|
||||
};
|
||||
|
||||
@@ -577,4 +577,19 @@ export const ru = {
|
||||
restoreFromLastBackupFailed: "Не удалось восстановить из резервной копии",
|
||||
lastBackupDate: "Дата последней резервной копии",
|
||||
noBackupAvailable: "Резервная копия недоступна",
|
||||
|
||||
// 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.",
|
||||
};
|
||||
|
||||
@@ -548,6 +548,15 @@ export const zh = {
|
||||
enableCloudflaredTunnel: "启用 Cloudflare 穿透",
|
||||
cloudflaredToken: "Token",
|
||||
cloudflaredTokenHelper: "在此粘贴您的 Token,或留空以使用随机 Quick Tunnel。",
|
||||
waitingForUrl: "等待 Quick Tunnel URL...",
|
||||
running: "运行中",
|
||||
stopped: "已停止",
|
||||
tunnelId: "Tunnel ID",
|
||||
accountTag: "Account Tag",
|
||||
copied: "已复制!",
|
||||
clickToCopy: "点击复制",
|
||||
quickTunnelWarning: "每次重启隧道时,Quick Tunnel URL 都会更改。",
|
||||
managedInDashboard: "公开主机名在您的 Cloudflare Zero Trust 仪表板中管理。",
|
||||
|
||||
// Database Export/Import
|
||||
exportImportDatabase: "导出/导入数据库",
|
||||
|
||||
Reference in New Issue
Block a user