feat: add visitor mode (read only)
This commit is contained in:
@@ -36,6 +36,7 @@ interface Settings {
|
||||
proxyOnlyYoutube?: boolean;
|
||||
moveSubtitlesToVideoFolder?: boolean;
|
||||
moveThumbnailsToVideoFolder?: boolean;
|
||||
visitorMode?: boolean;
|
||||
}
|
||||
|
||||
const defaultSettings: Settings = {
|
||||
@@ -54,6 +55,7 @@ const defaultSettings: Settings = {
|
||||
websiteName: "MyTube",
|
||||
itemsPerPage: 12,
|
||||
showYoutubeSearch: true,
|
||||
visitorMode: false,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -160,6 +162,45 @@ export const updateSettings = async (
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
const newSettings: Settings = req.body;
|
||||
const existingSettings = storageService.getSettings();
|
||||
const mergedSettings = { ...defaultSettings, ...existingSettings };
|
||||
|
||||
// Check if visitor mode is enabled
|
||||
// If visitor mode is enabled, only allow disabling it (setting visitorMode to false)
|
||||
if (mergedSettings.visitorMode === true) {
|
||||
// If visitorMode is being explicitly set to false, allow the update
|
||||
if (newSettings.visitorMode === false) {
|
||||
// Allow disabling visitor mode - merge with existing settings
|
||||
const updatedSettings = { ...mergedSettings, ...newSettings };
|
||||
storageService.saveSettings(updatedSettings);
|
||||
res.json({
|
||||
success: true,
|
||||
settings: { ...updatedSettings, password: undefined },
|
||||
});
|
||||
return;
|
||||
}
|
||||
// If visitorMode is explicitly set to true (already enabled), allow but ignore other changes
|
||||
if (newSettings.visitorMode === true) {
|
||||
// Allow enabling visitor mode (though it's already enabled)
|
||||
// But block all other changes - only update visitorMode
|
||||
const allowedSettings: Settings = {
|
||||
...mergedSettings,
|
||||
visitorMode: true,
|
||||
};
|
||||
storageService.saveSettings(allowedSettings);
|
||||
res.json({
|
||||
success: true,
|
||||
settings: { ...allowedSettings, password: undefined },
|
||||
});
|
||||
return;
|
||||
}
|
||||
// If visitor mode is enabled and trying to change other settings (without explicitly disabling visitor mode), block it
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: "Visitor mode is enabled. Only disabling visitor mode is allowed.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate settings if needed
|
||||
if (newSettings.maxConcurrentDownloads < 1) {
|
||||
@@ -181,12 +222,10 @@ export const updateSettings = async (
|
||||
newSettings.password = await bcrypt.hash(newSettings.password, salt);
|
||||
} else {
|
||||
// If password is empty/not provided, keep existing password
|
||||
const existingSettings = storageService.getSettings();
|
||||
newSettings.password = existingSettings.password;
|
||||
}
|
||||
|
||||
// Preserve existing tags if tags are not explicitly provided or are empty when tags existed
|
||||
const existingSettings = storageService.getSettings();
|
||||
const oldTags: string[] = existingSettings.tags || [];
|
||||
|
||||
// If tags is undefined, preserve existing tags
|
||||
|
||||
53
backend/src/middleware/visitorModeMiddleware.ts
Normal file
53
backend/src/middleware/visitorModeMiddleware.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import * as storageService from "../services/storageService";
|
||||
|
||||
/**
|
||||
* Middleware to block write operations when visitor mode is enabled
|
||||
* Only allows disabling visitor mode (POST /settings with visitorMode: false)
|
||||
*/
|
||||
export const visitorModeMiddleware = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void => {
|
||||
const settings = storageService.getSettings();
|
||||
const visitorMode = settings.visitorMode === true;
|
||||
|
||||
if (!visitorMode) {
|
||||
// Visitor mode is not enabled, allow all requests
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Visitor mode is enabled
|
||||
// Allow GET requests (read-only)
|
||||
if (req.method === "GET") {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// This middleware is applied to settings routes, so any POST here is a settings update
|
||||
// Check if the request is trying to disable visitor mode
|
||||
if (req.method === "POST") {
|
||||
const body = req.body || {};
|
||||
// Check if the request is trying to disable visitor mode
|
||||
if (body.visitorMode === false) {
|
||||
// Allow disabling visitor mode
|
||||
next();
|
||||
return;
|
||||
}
|
||||
// Block all other settings updates
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: "Visitor mode is enabled. Only disabling visitor mode is allowed.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Block all other write operations (PUT, DELETE, PATCH)
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: "Visitor mode is enabled. Write operations are not allowed.",
|
||||
});
|
||||
};
|
||||
|
||||
51
backend/src/middleware/visitorModeSettingsMiddleware.ts
Normal file
51
backend/src/middleware/visitorModeSettingsMiddleware.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import * as storageService from "../services/storageService";
|
||||
|
||||
/**
|
||||
* Middleware specifically for settings routes
|
||||
* Allows disabling visitor mode even when visitor mode is enabled
|
||||
*/
|
||||
export const visitorModeSettingsMiddleware = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void => {
|
||||
const settings = storageService.getSettings();
|
||||
const visitorMode = settings.visitorMode === true;
|
||||
|
||||
if (!visitorMode) {
|
||||
// Visitor mode is not enabled, allow all requests
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Visitor mode is enabled
|
||||
// Allow GET requests (read-only)
|
||||
if (req.method === "GET") {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// For POST requests, check if it's trying to disable visitor mode
|
||||
if (req.method === "POST") {
|
||||
const body = req.body || {};
|
||||
// Check if the request is trying to disable visitor mode
|
||||
if (body.visitorMode === false) {
|
||||
// Allow disabling visitor mode
|
||||
next();
|
||||
return;
|
||||
}
|
||||
// Block all other settings updates
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: "Visitor mode is enabled. Only disabling visitor mode is allowed.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Block all other write operations (PUT, DELETE, PATCH)
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: "Visitor mode is enabled. Write operations are not allowed.",
|
||||
});
|
||||
};
|
||||
@@ -11,6 +11,8 @@ import apiRoutes from "./routes/api";
|
||||
import settingsRoutes from "./routes/settingsRoutes";
|
||||
import downloadManager from "./services/downloadManager";
|
||||
import * as storageService from "./services/storageService";
|
||||
import { visitorModeMiddleware } from "./middleware/visitorModeMiddleware";
|
||||
import { visitorModeSettingsMiddleware } from "./middleware/visitorModeSettingsMiddleware";
|
||||
import { logger } from "./utils/logger";
|
||||
import { VERSION } from "./version";
|
||||
|
||||
@@ -186,8 +188,10 @@ const startServer = async () => {
|
||||
);
|
||||
|
||||
// API Routes
|
||||
app.use("/api", apiRoutes);
|
||||
app.use("/api/settings", settingsRoutes);
|
||||
// Apply visitor mode middleware to all API routes
|
||||
app.use("/api", visitorModeMiddleware, apiRoutes);
|
||||
// Use separate middleware for settings that allows disabling visitor mode
|
||||
app.use("/api/settings", visitorModeSettingsMiddleware, settingsRoutes);
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server running on port ${PORT}`);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { LanguageProvider } from './contexts/LanguageContext';
|
||||
import { SnackbarProvider } from './contexts/SnackbarContext';
|
||||
import { ThemeContextProvider } from './contexts/ThemeContext';
|
||||
import { VideoProvider, useVideo } from './contexts/VideoContext';
|
||||
import { VisitorModeProvider } from './contexts/VisitorModeContext';
|
||||
import AuthorVideos from './pages/AuthorVideos';
|
||||
import CollectionPage from './pages/CollectionPage';
|
||||
import DownloadPage from './pages/DownloadPage';
|
||||
@@ -130,15 +131,17 @@ function App() {
|
||||
<ThemeContextProvider>
|
||||
<LanguageProvider>
|
||||
<SnackbarProvider>
|
||||
<AuthProvider>
|
||||
<VideoProvider>
|
||||
<CollectionProvider>
|
||||
<DownloadProvider>
|
||||
<AppContent />
|
||||
</DownloadProvider>
|
||||
</CollectionProvider>
|
||||
</VideoProvider>
|
||||
</AuthProvider>
|
||||
<VisitorModeProvider>
|
||||
<AuthProvider>
|
||||
<VideoProvider>
|
||||
<CollectionProvider>
|
||||
<DownloadProvider>
|
||||
<AppContent />
|
||||
</DownloadProvider>
|
||||
</CollectionProvider>
|
||||
</VideoProvider>
|
||||
</AuthProvider>
|
||||
</VisitorModeProvider>
|
||||
</SnackbarProvider>
|
||||
</LanguageProvider>
|
||||
</ThemeContextProvider>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Brightness4, Brightness7, Download, Settings } from '@mui/icons-materia
|
||||
import { Badge, Box, IconButton, Tooltip, useMediaQuery, useTheme } from '@mui/material';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
import { useThemeContext } from '../../contexts/ThemeContext';
|
||||
import { useVisitorMode } from '../../contexts/VisitorModeContext';
|
||||
import DownloadsMenu from './DownloadsMenu';
|
||||
import ManageMenu from './ManageMenu';
|
||||
import { DownloadInfo } from './types';
|
||||
@@ -29,22 +30,27 @@ const ActionButtons: React.FC<ActionButtonsProps> = ({
|
||||
}) => {
|
||||
const { mode: currentThemeMode, toggleTheme } = useThemeContext();
|
||||
const { t } = useLanguage();
|
||||
const { visitorMode } = useVisitorMode();
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<IconButton color="inherit" onClick={onDownloadsClick}>
|
||||
<Badge badgeContent={activeDownloads.length + queuedDownloads.length} color="secondary">
|
||||
<Download />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
<DownloadsMenu
|
||||
anchorEl={downloadsAnchorEl}
|
||||
onClose={onDownloadsClose}
|
||||
activeDownloads={activeDownloads}
|
||||
queuedDownloads={queuedDownloads}
|
||||
/>
|
||||
{!visitorMode && (
|
||||
<>
|
||||
<IconButton color="inherit" onClick={onDownloadsClick}>
|
||||
<Badge badgeContent={activeDownloads.length + queuedDownloads.length} color="secondary">
|
||||
<Download />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
<DownloadsMenu
|
||||
anchorEl={downloadsAnchorEl}
|
||||
onClose={onDownloadsClose}
|
||||
activeDownloads={activeDownloads}
|
||||
queuedDownloads={queuedDownloads}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<IconButton onClick={toggleTheme} color="inherit">
|
||||
{currentThemeMode === 'dark' ? <Brightness7 /> : <Brightness4 />}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Settings, VideoLibrary } from '@mui/icons-material';
|
||||
import { Box, Button, Collapse, Stack } from '@mui/material';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
import { useVisitorMode } from '../../contexts/VisitorModeContext';
|
||||
import { Collection, Video } from '../../types';
|
||||
import AuthorsList from '../AuthorsList';
|
||||
import Collections from '../Collections';
|
||||
@@ -44,24 +45,27 @@ const MobileMenu: React.FC<MobileMenuProps> = ({
|
||||
onTagToggle
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const { visitorMode } = useVisitorMode();
|
||||
|
||||
return (
|
||||
<Collapse in={open} sx={{ width: '100%' }}>
|
||||
<Box sx={{ maxHeight: '80vh', overflowY: 'auto' }}>
|
||||
<Stack spacing={2} sx={{ py: 2 }}>
|
||||
{/* Row 1: Search Input */}
|
||||
<Box>
|
||||
<SearchInput
|
||||
videoUrl={videoUrl}
|
||||
setVideoUrl={setVideoUrl}
|
||||
isSubmitting={isSubmitting}
|
||||
error={error}
|
||||
isSearchMode={isSearchMode}
|
||||
searchTerm={searchTerm}
|
||||
onResetSearch={onResetSearch}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</Box>
|
||||
{!visitorMode && (
|
||||
<Box>
|
||||
<SearchInput
|
||||
videoUrl={videoUrl}
|
||||
setVideoUrl={setVideoUrl}
|
||||
isSubmitting={isSubmitting}
|
||||
error={error}
|
||||
isSearchMode={isSearchMode}
|
||||
searchTerm={searchTerm}
|
||||
onResetSearch={onResetSearch}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Mobile Navigation Buttons */}
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '@mui/material';
|
||||
import { FormEvent } from 'react';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
import { useVisitorMode } from '../../contexts/VisitorModeContext';
|
||||
|
||||
interface SearchInputProps {
|
||||
videoUrl: string;
|
||||
@@ -32,16 +33,17 @@ const SearchInput: React.FC<SearchInputProps> = ({
|
||||
onSubmit
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const { visitorMode } = useVisitorMode();
|
||||
|
||||
return (
|
||||
<Box component="form" onSubmit={onSubmit} sx={{ flexGrow: 1, display: 'flex', justifyContent: 'center', width: '100%' }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
placeholder={t('enterUrlOrSearchTerm')}
|
||||
placeholder={visitorMode ? t('visitorModeReadOnly') || 'Visitor mode: Read-only' : t('enterUrlOrSearchTerm')}
|
||||
value={videoUrl}
|
||||
onChange={(e) => setVideoUrl(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
disabled={isSubmitting || visitorMode}
|
||||
error={!!error}
|
||||
helperText={error}
|
||||
size="small"
|
||||
@@ -57,7 +59,7 @@ const SearchInput: React.FC<SearchInputProps> = ({
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={isSubmitting}
|
||||
disabled={isSubmitting || visitorMode}
|
||||
sx={{ borderTopLeftRadius: 0, borderBottomLeftRadius: 0, height: '100%', minWidth: 'auto', px: 3 }}
|
||||
>
|
||||
{isSubmitting ? <CircularProgress size={24} color="inherit" /> : <Search />}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { FormEvent, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
import { useVideo } from '../../contexts/VideoContext';
|
||||
import { useVisitorMode } from '../../contexts/VisitorModeContext';
|
||||
import ActionButtons from './ActionButtons';
|
||||
import Logo from './Logo';
|
||||
import MobileMenu from './MobileMenu';
|
||||
@@ -39,6 +40,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const { t } = useLanguage();
|
||||
const { visitorMode } = useVisitorMode();
|
||||
const { availableTags, selectedTags, handleTagToggle } = useVideo();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -148,19 +150,21 @@ const Header: React.FC<HeaderProps> = ({
|
||||
{/* Desktop Layout */}
|
||||
{!isMobile && (
|
||||
<>
|
||||
<Box sx={{ flexGrow: 1, display: 'flex', justifyContent: 'center', maxWidth: 800, mx: 'auto' }}>
|
||||
<SearchInput
|
||||
videoUrl={videoUrl}
|
||||
setVideoUrl={setVideoUrl}
|
||||
isSubmitting={isSubmitting}
|
||||
error={error}
|
||||
isSearchMode={isSearchMode}
|
||||
searchTerm={searchTerm}
|
||||
onResetSearch={onResetSearch}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', ml: 2 }}>
|
||||
{!visitorMode && (
|
||||
<Box sx={{ flexGrow: 1, display: 'flex', justifyContent: 'center', maxWidth: 800, mx: 'auto' }}>
|
||||
<SearchInput
|
||||
videoUrl={videoUrl}
|
||||
setVideoUrl={setVideoUrl}
|
||||
isSubmitting={isSubmitting}
|
||||
error={error}
|
||||
isSearchMode={isSearchMode}
|
||||
searchTerm={searchTerm}
|
||||
onResetSearch={onResetSearch}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', ml: visitorMode ? 'auto' : 2 }}>
|
||||
<ActionButtons
|
||||
activeDownloads={activeDownloads}
|
||||
queuedDownloads={queuedDownloads}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from '@mui/material';
|
||||
import React from 'react';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
import { useVisitorMode } from '../../contexts/VisitorModeContext';
|
||||
import { Collection } from '../../types';
|
||||
|
||||
interface CollectionsTableProps {
|
||||
@@ -38,6 +39,7 @@ const CollectionsTable: React.FC<CollectionsTableProps> = ({
|
||||
getCollectionSize
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const { visitorMode } = useVisitorMode();
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 6 }}>
|
||||
@@ -55,7 +57,7 @@ const CollectionsTable: React.FC<CollectionsTableProps> = ({
|
||||
<TableCell>{t('videos')}</TableCell>
|
||||
<TableCell>{t('size')}</TableCell>
|
||||
<TableCell>{t('created')}</TableCell>
|
||||
<TableCell align="right">{t('actions')}</TableCell>
|
||||
{!visitorMode && <TableCell align="right">{t('actions')}</TableCell>}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
@@ -67,17 +69,19 @@ const CollectionsTable: React.FC<CollectionsTableProps> = ({
|
||||
<TableCell>{collection.videos.length} videos</TableCell>
|
||||
<TableCell>{getCollectionSize(collection.videos)}</TableCell>
|
||||
<TableCell>{new Date(collection.createdAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title={t('deleteCollection')}>
|
||||
<IconButton
|
||||
color="error"
|
||||
onClick={() => onDelete(collection)}
|
||||
size="small"
|
||||
>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
{!visitorMode && (
|
||||
<TableCell align="right">
|
||||
<Tooltip title={t('deleteCollection')}>
|
||||
<IconButton
|
||||
color="error"
|
||||
onClick={() => onDelete(collection)}
|
||||
size="small"
|
||||
>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import React, { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
import { useVisitorMode } from '../../contexts/VisitorModeContext';
|
||||
import { Video } from '../../types';
|
||||
import { formatDuration, formatSize } from '../../utils/formatUtils';
|
||||
|
||||
@@ -72,6 +73,7 @@ const VideosTable: React.FC<VideosTableProps> = ({
|
||||
onUpdateVideo
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const { visitorMode } = useVisitorMode();
|
||||
|
||||
// Local editing state
|
||||
const [editingVideoId, setEditingVideoId] = useState<string | null>(null);
|
||||
@@ -166,7 +168,7 @@ const VideosTable: React.FC<VideosTableProps> = ({
|
||||
{t('size')}
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
<TableCell align="right">{t('actions')}</TableCell>
|
||||
{!visitorMode && <TableCell align="right">{t('actions')}</TableCell>}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
@@ -182,26 +184,28 @@ const VideosTable: React.FC<VideosTableProps> = ({
|
||||
sx={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 1 }}
|
||||
/>
|
||||
</Link>
|
||||
<Tooltip title={t('refreshThumbnail') || "Refresh Thumbnail"}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onRefreshThumbnail(video.id)}
|
||||
disabled={refreshingId === video.id}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
bgcolor: 'rgba(0,0,0,0.5)',
|
||||
color: 'white',
|
||||
'&:hover': { bgcolor: 'rgba(0,0,0,0.7)' },
|
||||
p: 0.5,
|
||||
width: 24,
|
||||
height: 24
|
||||
}}
|
||||
>
|
||||
{refreshingId === video.id ? <CircularProgress size={14} color="inherit" /> : <Refresh sx={{ fontSize: 16 }} />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{!visitorMode && (
|
||||
<Tooltip title={t('refreshThumbnail') || "Refresh Thumbnail"}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onRefreshThumbnail(video.id)}
|
||||
disabled={refreshingId === video.id}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
bgcolor: 'rgba(0,0,0,0.5)',
|
||||
color: 'white',
|
||||
'&:hover': { bgcolor: 'rgba(0,0,0,0.7)' },
|
||||
p: 0.5,
|
||||
width: 24,
|
||||
height: 24
|
||||
}}
|
||||
>
|
||||
{refreshingId === video.id ? <CircularProgress size={14} color="inherit" /> : <Refresh sx={{ fontSize: 16 }} />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
<Typography variant="caption" display="block" sx={{ mt: 0.5, color: 'text.secondary', textAlign: 'center' }}>
|
||||
{formatDuration(video.duration)}
|
||||
@@ -240,13 +244,15 @@ const VideosTable: React.FC<VideosTableProps> = ({
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start' }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleEditClick(video)}
|
||||
sx={{ mr: 1, mt: -0.5, opacity: 0.6, '&:hover': { opacity: 1 } }}
|
||||
>
|
||||
<Edit fontSize="small" />
|
||||
</IconButton>
|
||||
{!visitorMode && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleEditClick(video)}
|
||||
sx={{ mr: 1, mt: -0.5, opacity: 0.6, '&:hover': { opacity: 1 } }}
|
||||
>
|
||||
<Edit fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
@@ -279,17 +285,19 @@ const VideosTable: React.FC<VideosTableProps> = ({
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>{formatSize(video.fileSize)}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title={t('deleteVideo')}>
|
||||
<IconButton
|
||||
color="error"
|
||||
onClick={() => onDeleteClick(video.id)}
|
||||
disabled={deletingId === video.id}
|
||||
>
|
||||
{deletingId === video.id ? <CircularProgress size={24} /> : <Delete />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
{!visitorMode && (
|
||||
<TableCell align="right">
|
||||
<Tooltip title={t('deleteVideo')}>
|
||||
<IconButton
|
||||
color="error"
|
||||
onClick={() => onDeleteClick(video.id)}
|
||||
disabled={deletingId === video.id}
|
||||
>
|
||||
{deletingId === video.id ? <CircularProgress size={24} /> : <Delete />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -7,72 +7,89 @@ interface GeneralSettingsProps {
|
||||
websiteName?: string;
|
||||
itemsPerPage?: number;
|
||||
showYoutubeSearch?: boolean;
|
||||
visitorMode?: boolean;
|
||||
onChange: (field: string, value: string | number | boolean) => void;
|
||||
}
|
||||
|
||||
const GeneralSettings: React.FC<GeneralSettingsProps> = (props) => {
|
||||
const { language, websiteName, showYoutubeSearch, onChange } = props;
|
||||
const { language, websiteName, showYoutubeSearch, visitorMode, onChange } = props;
|
||||
const { t } = useLanguage();
|
||||
|
||||
const isVisitorMode = visitorMode ?? false;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>{t('general')}</Typography>
|
||||
<Box sx={{ maxWidth: 400, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="language-select-label">{t('language')}</InputLabel>
|
||||
<Select
|
||||
labelId="language-select-label"
|
||||
id="language-select"
|
||||
value={language || 'en'}
|
||||
label={t('language')}
|
||||
onChange={(e) => onChange('language', e.target.value)}
|
||||
>
|
||||
<MenuItem value="en">English</MenuItem>
|
||||
<MenuItem value="zh">中文 (Chinese)</MenuItem>
|
||||
<MenuItem value="es">Español (Spanish)</MenuItem>
|
||||
<MenuItem value="de">Deutsch (German)</MenuItem>
|
||||
<MenuItem value="ja">日本語 (Japanese)</MenuItem>
|
||||
<MenuItem value="fr">Français (French)</MenuItem>
|
||||
<MenuItem value="ko">한국어 (Korean)</MenuItem>
|
||||
<MenuItem value="ar">العربية (Arabic)</MenuItem>
|
||||
<MenuItem value="pt">Português (Portuguese)</MenuItem>
|
||||
<MenuItem value="ru">Русский (Russian)</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
{!isVisitorMode && (
|
||||
<>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="language-select-label">{t('language')}</InputLabel>
|
||||
<Select
|
||||
labelId="language-select-label"
|
||||
id="language-select"
|
||||
value={language || 'en'}
|
||||
label={t('language')}
|
||||
onChange={(e) => onChange('language', e.target.value)}
|
||||
>
|
||||
<MenuItem value="en">English</MenuItem>
|
||||
<MenuItem value="zh">中文 (Chinese)</MenuItem>
|
||||
<MenuItem value="es">Español (Spanish)</MenuItem>
|
||||
<MenuItem value="de">Deutsch (German)</MenuItem>
|
||||
<MenuItem value="ja">日本語 (Japanese)</MenuItem>
|
||||
<MenuItem value="fr">Français (French)</MenuItem>
|
||||
<MenuItem value="ko">한국어 (Korean)</MenuItem>
|
||||
<MenuItem value="ar">العربية (Arabic)</MenuItem>
|
||||
<MenuItem value="pt">Português (Portuguese)</MenuItem>
|
||||
<MenuItem value="ru">Русский (Russian)</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Website Name"
|
||||
value={websiteName || ''}
|
||||
onChange={(e) => onChange('websiteName', e.target.value)}
|
||||
placeholder="MyTube"
|
||||
helperText={`${(websiteName || '').length}/15 characters (Default: MyTube)`}
|
||||
slotProps={{ htmlInput: { maxLength: 15 } }}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Website Name"
|
||||
value={websiteName || ''}
|
||||
onChange={(e) => onChange('websiteName', e.target.value)}
|
||||
placeholder="MyTube"
|
||||
helperText={`${(websiteName || '').length}/15 characters (Default: MyTube)`}
|
||||
slotProps={{ htmlInput: { maxLength: 15 } }}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('itemsPerPage') || "Items Per Page"}
|
||||
type="number"
|
||||
value={props.itemsPerPage || 12}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
if (!isNaN(val) && val > 0) {
|
||||
onChange('itemsPerPage', val);
|
||||
}
|
||||
}}
|
||||
helperText={t('itemsPerPageHelper') || "Number of videos to show per page (Default: 12)"}
|
||||
slotProps={{ htmlInput: { min: 1 } }}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('itemsPerPage') || "Items Per Page"}
|
||||
type="number"
|
||||
value={props.itemsPerPage || 12}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
if (!isNaN(val) && val > 0) {
|
||||
onChange('itemsPerPage', val);
|
||||
}
|
||||
}}
|
||||
helperText={t('itemsPerPageHelper') || "Number of videos to show per page (Default: 12)"}
|
||||
slotProps={{ htmlInput: { min: 1 } }}
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={showYoutubeSearch ?? true}
|
||||
onChange={(e) => onChange('showYoutubeSearch', e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label={t('showYoutubeSearch') || "Show YouTube Search Results"}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={showYoutubeSearch ?? true}
|
||||
onChange={(e) => onChange('showYoutubeSearch', e.target.checked)}
|
||||
checked={visitorMode ?? false}
|
||||
onChange={(e) => onChange('visitorMode', e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label={t('showYoutubeSearch') || "Show YouTube Search Results"}
|
||||
label={t('visitorMode') || "Visitor Mode (Read-only)"}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -265,6 +265,8 @@ const VideoCard: React.FC<VideoCardProps> = ({
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
sx={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
@@ -289,8 +291,6 @@ const VideoCard: React.FC<VideoCardProps> = ({
|
||||
>
|
||||
<CardActionArea
|
||||
onClick={handleVideoNavigation}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', alignItems: 'stretch' }}
|
||||
>
|
||||
<Box sx={{ position: 'relative', paddingTop: '56.25%' /* 16:9 aspect ratio */ }}>
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from '@mui/material';
|
||||
import React, { useState } from 'react';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
import { useVisitorMode } from '../../contexts/VisitorModeContext';
|
||||
import { Video } from '../../types';
|
||||
import { formatDate, formatDuration } from '../../utils/formatUtils';
|
||||
|
||||
@@ -100,6 +101,7 @@ const UpNextSidebar: React.FC<UpNextSidebarProps> = ({
|
||||
onAddToCollection
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const { visitorMode } = useVisitorMode();
|
||||
const [hoveredVideoId, setHoveredVideoId] = useState<string | null>(null);
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
@@ -154,7 +156,7 @@ const UpNextSidebar: React.FC<UpNextSidebarProps> = ({
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{hoveredVideoId === relatedVideo.id && !isMobile && !isTouch && (
|
||||
{hoveredVideoId === relatedVideo.id && !isMobile && !isTouch && !visitorMode && (
|
||||
<Tooltip title={t('addToCollection')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Check, Close, Edit, ExpandLess, ExpandMore } from '@mui/icons-material'
|
||||
import { Box, Button, TextField, Tooltip, Typography } from '@mui/material';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useLanguage } from '../../../contexts/LanguageContext';
|
||||
import { useVisitorMode } from '../../../contexts/VisitorModeContext';
|
||||
|
||||
interface EditableTitleProps {
|
||||
title: string;
|
||||
@@ -10,6 +11,7 @@ interface EditableTitleProps {
|
||||
|
||||
const EditableTitle: React.FC<EditableTitleProps> = ({ title, onSave }) => {
|
||||
const { t } = useLanguage();
|
||||
const { visitorMode } = useVisitorMode();
|
||||
const [isEditingTitle, setIsEditingTitle] = useState<boolean>(false);
|
||||
const [editedTitle, setEditedTitle] = useState<string>('');
|
||||
const [isTitleExpanded, setIsTitleExpanded] = useState(false);
|
||||
@@ -103,15 +105,17 @@ const EditableTitle: React.FC<EditableTitleProps> = ({ title, onSave }) => {
|
||||
{title}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
<Tooltip title={t('editTitle')}>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={handleStartEditingTitle}
|
||||
sx={{ minWidth: 'auto', p: 0.5, color: 'text.secondary' }}
|
||||
>
|
||||
<Edit fontSize="small" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{!visitorMode && (
|
||||
<Tooltip title={t('editTitle')}>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={handleStartEditingTitle}
|
||||
sx={{ minWidth: 'auto', p: 0.5, color: 'text.secondary' }}
|
||||
>
|
||||
<Edit fontSize="small" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{showExpandButton && (
|
||||
<Tooltip title={isTitleExpanded ? t('collapse') : t('expand')}>
|
||||
<Button
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Button, Menu, MenuItem, Stack, Tooltip, useMediaQuery, useTheme } from
|
||||
import React, { useState } from 'react';
|
||||
import { useLanguage } from '../../../contexts/LanguageContext';
|
||||
import { useSnackbar } from '../../../contexts/SnackbarContext';
|
||||
import { useVisitorMode } from '../../../contexts/VisitorModeContext';
|
||||
import { useShareVideo } from '../../../hooks/useShareVideo';
|
||||
import { Video } from '../../../types';
|
||||
import VideoKebabMenuButtons from './VideoKebabMenuButtons';
|
||||
@@ -23,6 +24,7 @@ const VideoActionButtons: React.FC<VideoActionButtonsProps> = ({
|
||||
const { t } = useLanguage();
|
||||
const { handleShare } = useShareVideo(video);
|
||||
const { showSnackbar } = useSnackbar();
|
||||
const { visitorMode } = useVisitorMode();
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const [playerMenuAnchor, setPlayerMenuAnchor] = useState<null | HTMLElement>(null);
|
||||
@@ -169,27 +171,31 @@ const VideoActionButtons: React.FC<VideoActionButtonsProps> = ({
|
||||
<Share />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('addToCollection')}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
onClick={() => onAddToCollection()}
|
||||
sx={{ minWidth: 'auto', p: 1, color: 'text.secondary', borderColor: 'text.secondary', '&:hover': { color: 'primary.main', borderColor: 'primary.main' } }}
|
||||
>
|
||||
<Add />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('delete')}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
onClick={onDelete}
|
||||
disabled={isDeleting}
|
||||
sx={{ minWidth: 'auto', p: 1, color: 'text.secondary', borderColor: 'text.secondary', '&:hover': { color: 'error.main', borderColor: 'error.main' } }}
|
||||
>
|
||||
<Delete />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{!visitorMode && (
|
||||
<>
|
||||
<Tooltip title={t('addToCollection')}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
onClick={() => onAddToCollection()}
|
||||
sx={{ minWidth: 'auto', p: 1, color: 'text.secondary', borderColor: 'text.secondary', '&:hover': { color: 'primary.main', borderColor: 'primary.main' } }}
|
||||
>
|
||||
<Add />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('delete')}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
onClick={onDelete}
|
||||
disabled={isDeleting}
|
||||
sx={{ minWidth: 'auto', p: 1, color: 'text.secondary', borderColor: 'text.secondary', '&:hover': { color: 'error.main', borderColor: 'error.main' } }}
|
||||
>
|
||||
<Delete />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Notifications, NotificationsActive } from '@mui/icons-material';
|
||||
import { Avatar, Box, IconButton, Tooltip, Typography } from '@mui/material';
|
||||
import React from 'react';
|
||||
import { useLanguage } from '../../../contexts/LanguageContext';
|
||||
import { useVisitorMode } from '../../../contexts/VisitorModeContext';
|
||||
|
||||
interface VideoAuthorInfoProps {
|
||||
author: string;
|
||||
@@ -36,7 +37,8 @@ const VideoAuthorInfo: React.FC<VideoAuthorInfoProps> = ({
|
||||
onUnsubscribe
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const showSubscribeButton = source === 'youtube' || source === 'bilibili';
|
||||
const { visitorMode } = useVisitorMode();
|
||||
const showSubscribeButton = (source === 'youtube' || source === 'bilibili') && !visitorMode;
|
||||
|
||||
const handleSubscribeClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Add, Cast, Delete, MoreVert, Share } from '@mui/icons-material';
|
||||
import { Button, IconButton, Menu, Stack, Tooltip } from '@mui/material';
|
||||
import React, { useState } from 'react';
|
||||
import { useLanguage } from '../../../contexts/LanguageContext';
|
||||
import { useVisitorMode } from '../../../contexts/VisitorModeContext';
|
||||
|
||||
interface VideoKebabMenuButtonsProps {
|
||||
onPlayWith: (anchor: HTMLElement) => void;
|
||||
@@ -21,6 +22,7 @@ const VideoKebabMenuButtons: React.FC<VideoKebabMenuButtonsProps> = ({
|
||||
sx
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const { visitorMode } = useVisitorMode();
|
||||
const [kebabMenuAnchor, setKebabMenuAnchor] = useState<null | HTMLElement>(null);
|
||||
|
||||
const handleKebabMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
|
||||
@@ -128,28 +130,32 @@ const VideoKebabMenuButtons: React.FC<VideoKebabMenuButtonsProps> = ({
|
||||
<Share />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('addToCollection')}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
onClick={handleAddToCollection}
|
||||
sx={{ minWidth: 'auto', p: 1, color: 'text.secondary', borderColor: 'text.secondary', '&:hover': { color: 'primary.main', borderColor: 'primary.main' } }}
|
||||
>
|
||||
<Add />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{onDelete && (
|
||||
<Tooltip title={t('delete')}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
sx={{ minWidth: 'auto', p: 1, color: 'text.secondary', borderColor: 'text.secondary', '&:hover': { color: 'error.main', borderColor: 'error.main' } }}
|
||||
>
|
||||
<Delete />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{!visitorMode && (
|
||||
<>
|
||||
<Tooltip title={t('addToCollection')}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
onClick={handleAddToCollection}
|
||||
sx={{ minWidth: 'auto', p: 1, color: 'text.secondary', borderColor: 'text.secondary', '&:hover': { color: 'primary.main', borderColor: 'primary.main' } }}
|
||||
>
|
||||
<Add />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{onDelete && (
|
||||
<Tooltip title={t('delete')}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
sx={{ minWidth: 'auto', p: 1, color: 'text.secondary', borderColor: 'text.secondary', '&:hover': { color: 'error.main', borderColor: 'error.main' } }}
|
||||
>
|
||||
<Delete />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Menu>
|
||||
|
||||
43
frontend/src/contexts/VisitorModeContext.tsx
Normal file
43
frontend/src/contexts/VisitorModeContext.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import axios from 'axios';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
interface VisitorModeContextType {
|
||||
visitorMode: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const VisitorModeContext = createContext<VisitorModeContextType>({
|
||||
visitorMode: false,
|
||||
isLoading: true,
|
||||
});
|
||||
|
||||
export const VisitorModeProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const { data: settingsData, isLoading } = useQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: async () => {
|
||||
const response = await axios.get(`${API_URL}/settings`);
|
||||
return response.data;
|
||||
},
|
||||
refetchInterval: 5000, // Refetch every 5 seconds to keep visitor mode state in sync
|
||||
});
|
||||
|
||||
const visitorMode = settingsData?.visitorMode === true;
|
||||
|
||||
return (
|
||||
<VisitorModeContext.Provider value={{ visitorMode, isLoading }}>
|
||||
{children}
|
||||
</VisitorModeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useVisitorMode = () => {
|
||||
const context = useContext(VisitorModeContext);
|
||||
if (!context) {
|
||||
throw new Error('useVisitorMode must be used within a VisitorModeProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ import VideosTable from '../components/ManagePage/VideosTable';
|
||||
import { useCollection } from '../contexts/CollectionContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { useSnackbar } from '../contexts/SnackbarContext';
|
||||
import { useVisitorMode } from '../contexts/VisitorModeContext';
|
||||
import { useVideo } from '../contexts/VideoContext';
|
||||
import { Collection, Video } from '../types';
|
||||
import { formatSize } from '../utils/formatUtils';
|
||||
@@ -28,6 +29,7 @@ const ManagePage: React.FC = () => {
|
||||
const [searchTerm, setSearchTerm] = useState<string>('');
|
||||
const { t } = useLanguage();
|
||||
const { showSnackbar } = useSnackbar();
|
||||
const { visitorMode } = useVisitorMode();
|
||||
const { videos, deleteVideo, refreshThumbnail, updateVideo } = useVideo();
|
||||
const { collections, deleteCollection } = useCollection();
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
@@ -176,16 +178,18 @@ const ManagePage: React.FC = () => {
|
||||
<Typography variant="h4" component="h1" fontWeight="bold">
|
||||
{t('manageContent')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<FindInPage />}
|
||||
onClick={() => setShowScanConfirmModal(true)}
|
||||
disabled={scanMutation.isPending}
|
||||
>
|
||||
{scanMutation.isPending ? (t('scanning') || 'Scanning...') : (t('scanFiles') || 'Scan Files')}
|
||||
</Button>
|
||||
</Box>
|
||||
{!visitorMode && (
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<FindInPage />}
|
||||
onClick={() => setShowScanConfirmModal(true)}
|
||||
disabled={scanMutation.isPending}
|
||||
>
|
||||
{scanMutation.isPending ? (t('scanning') || 'Scanning...') : (t('scanFiles') || 'Scan Files')}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<DeleteCollectionModal
|
||||
|
||||
@@ -27,6 +27,7 @@ import VideoDefaultSettings from '../components/Settings/VideoDefaultSettings';
|
||||
import YtDlpSettings from '../components/Settings/YtDlpSettings';
|
||||
import { useDownload } from '../contexts/DownloadContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { useVisitorMode } from '../contexts/VisitorModeContext';
|
||||
import { Settings } from '../types';
|
||||
import ConsoleManager from '../utils/consoleManager';
|
||||
import { SNACKBAR_AUTO_HIDE_DURATION } from '../utils/constants';
|
||||
@@ -38,6 +39,7 @@ const API_URL = import.meta.env.VITE_API_URL;
|
||||
const SettingsPage: React.FC = () => {
|
||||
const { t, setLanguage } = useLanguage();
|
||||
const { activeDownloads } = useDownload();
|
||||
const { visitorMode } = useVisitorMode();
|
||||
|
||||
const [settings, setSettings] = useState<Settings>({
|
||||
loginEnabled: false,
|
||||
@@ -467,122 +469,138 @@ const SettingsPage: React.FC = () => {
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Grid container spacing={4}>
|
||||
{/* General Settings */}
|
||||
{/* General Settings - Only show visitor mode toggle when visitor mode is enabled */}
|
||||
<Grid size={12}>
|
||||
<GeneralSettings
|
||||
language={settings.language}
|
||||
websiteName={settings.websiteName}
|
||||
itemsPerPage={settings.itemsPerPage}
|
||||
showYoutubeSearch={settings.showYoutubeSearch}
|
||||
onChange={(field, value) => handleChange(field as keyof Settings, value)}
|
||||
/>
|
||||
{visitorMode ? (
|
||||
<GeneralSettings
|
||||
language={settings.language}
|
||||
websiteName={settings.websiteName}
|
||||
itemsPerPage={settings.itemsPerPage}
|
||||
showYoutubeSearch={settings.showYoutubeSearch}
|
||||
visitorMode={settings.visitorMode}
|
||||
onChange={(field, value) => handleChange(field as keyof Settings, value)}
|
||||
/>
|
||||
) : (
|
||||
<GeneralSettings
|
||||
language={settings.language}
|
||||
websiteName={settings.websiteName}
|
||||
itemsPerPage={settings.itemsPerPage}
|
||||
showYoutubeSearch={settings.showYoutubeSearch}
|
||||
visitorMode={settings.visitorMode}
|
||||
onChange={(field, value) => handleChange(field as keyof Settings, value)}
|
||||
/>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
<Grid size={12}><Divider /></Grid>
|
||||
{!visitorMode && (
|
||||
<>
|
||||
<Grid size={12}><Divider /></Grid>
|
||||
|
||||
{/* Cookie Upload Settings */}
|
||||
<Grid size={12}>
|
||||
<CookieSettings
|
||||
onSuccess={(msg) => setMessage({ text: msg, type: 'success' })}
|
||||
onError={(msg) => setMessage({ text: msg, type: 'error' })}
|
||||
/>
|
||||
</Grid>
|
||||
{/* Cookie Upload Settings */}
|
||||
<Grid size={12}>
|
||||
<CookieSettings
|
||||
onSuccess={(msg) => setMessage({ text: msg, type: 'success' })}
|
||||
onError={(msg) => setMessage({ text: msg, type: 'error' })}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={12}><Divider /></Grid>
|
||||
<Grid size={12}><Divider /></Grid>
|
||||
|
||||
{/* Security Settings */}
|
||||
<Grid size={12}>
|
||||
<SecuritySettings
|
||||
settings={settings}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</Grid>
|
||||
{/* Security Settings */}
|
||||
<Grid size={12}>
|
||||
<SecuritySettings
|
||||
settings={settings}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={12}><Divider /></Grid>
|
||||
<Grid size={12}><Divider /></Grid>
|
||||
|
||||
{/* Video Defaults */}
|
||||
<Grid size={12}>
|
||||
<VideoDefaultSettings
|
||||
settings={settings}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</Grid>
|
||||
{/* Video Defaults */}
|
||||
<Grid size={12}>
|
||||
<VideoDefaultSettings
|
||||
settings={settings}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={12}><Divider /></Grid>
|
||||
<Grid size={12}><Divider /></Grid>
|
||||
|
||||
{/* Tags Management */}
|
||||
<Grid size={12}>
|
||||
<TagsSettings
|
||||
tags={Array.isArray(settings.tags) ? settings.tags : []}
|
||||
onTagsChange={handleTagsChange}
|
||||
/>
|
||||
</Grid>
|
||||
{/* Tags Management */}
|
||||
<Grid size={12}>
|
||||
<TagsSettings
|
||||
tags={Array.isArray(settings.tags) ? settings.tags : []}
|
||||
onTagsChange={handleTagsChange}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={12}><Divider /></Grid>
|
||||
<Grid size={12}><Divider /></Grid>
|
||||
|
||||
{/* Download Settings */}
|
||||
<Grid size={12}>
|
||||
<DownloadSettings
|
||||
settings={settings}
|
||||
onChange={handleChange}
|
||||
activeDownloadsCount={activeDownloads.length}
|
||||
onCleanup={() => setShowCleanupTempFilesModal(true)}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</Grid>
|
||||
{/* Download Settings */}
|
||||
<Grid size={12}>
|
||||
<DownloadSettings
|
||||
settings={settings}
|
||||
onChange={handleChange}
|
||||
activeDownloadsCount={activeDownloads.length}
|
||||
onCleanup={() => setShowCleanupTempFilesModal(true)}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={12}><Divider /></Grid>
|
||||
<Grid size={12}><Divider /></Grid>
|
||||
|
||||
{/* Cloud Drive Settings */}
|
||||
<Grid size={12}>
|
||||
<CloudDriveSettings
|
||||
settings={settings}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</Grid>
|
||||
{/* Cloud Drive Settings */}
|
||||
<Grid size={12}>
|
||||
<CloudDriveSettings
|
||||
settings={settings}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={12}><Divider /></Grid>
|
||||
<Grid size={12}><Divider /></Grid>
|
||||
|
||||
{/* Database Settings */}
|
||||
<Grid size={12}>
|
||||
<DatabaseSettings
|
||||
onMigrate={() => setShowMigrateConfirmModal(true)}
|
||||
onDeleteLegacy={() => setShowDeleteLegacyModal(true)}
|
||||
onFormatFilenames={() => setShowFormatConfirmModal(true)}
|
||||
onExportDatabase={handleExportDatabase}
|
||||
onImportDatabase={handleImportDatabase}
|
||||
onCleanupBackupDatabases={handleCleanupBackupDatabases}
|
||||
onRestoreFromLastBackup={handleRestoreFromLastBackup}
|
||||
isSaving={isSaving}
|
||||
lastBackupInfo={lastBackupInfo}
|
||||
moveSubtitlesToVideoFolder={settings.moveSubtitlesToVideoFolder || false}
|
||||
onMoveSubtitlesToVideoFolderChange={(checked) => handleChange('moveSubtitlesToVideoFolder', checked)}
|
||||
moveThumbnailsToVideoFolder={settings.moveThumbnailsToVideoFolder || false}
|
||||
onMoveThumbnailsToVideoFolderChange={(checked) => handleChange('moveThumbnailsToVideoFolder', checked)}
|
||||
/>
|
||||
</Grid>
|
||||
{/* Database Settings */}
|
||||
<Grid size={12}>
|
||||
<DatabaseSettings
|
||||
onMigrate={() => setShowMigrateConfirmModal(true)}
|
||||
onDeleteLegacy={() => setShowDeleteLegacyModal(true)}
|
||||
onFormatFilenames={() => setShowFormatConfirmModal(true)}
|
||||
onExportDatabase={handleExportDatabase}
|
||||
onImportDatabase={handleImportDatabase}
|
||||
onCleanupBackupDatabases={handleCleanupBackupDatabases}
|
||||
onRestoreFromLastBackup={handleRestoreFromLastBackup}
|
||||
isSaving={isSaving}
|
||||
lastBackupInfo={lastBackupInfo}
|
||||
moveSubtitlesToVideoFolder={settings.moveSubtitlesToVideoFolder || false}
|
||||
onMoveSubtitlesToVideoFolderChange={(checked) => handleChange('moveSubtitlesToVideoFolder', checked)}
|
||||
moveThumbnailsToVideoFolder={settings.moveThumbnailsToVideoFolder || false}
|
||||
onMoveThumbnailsToVideoFolderChange={(checked) => handleChange('moveThumbnailsToVideoFolder', checked)}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={12}><Divider /></Grid>
|
||||
<Grid size={12}><Divider /></Grid>
|
||||
|
||||
{/* yt-dlp Configuration */}
|
||||
<Grid size={12}>
|
||||
<YtDlpSettings
|
||||
config={settings.ytDlpConfig || ''}
|
||||
proxyOnlyYoutube={settings.proxyOnlyYoutube || false}
|
||||
onChange={(config) => handleChange('ytDlpConfig', config)}
|
||||
onProxyOnlyYoutubeChange={(checked) => handleChange('proxyOnlyYoutube', checked)}
|
||||
/>
|
||||
</Grid>
|
||||
{/* yt-dlp Configuration */}
|
||||
<Grid size={12}>
|
||||
<YtDlpSettings
|
||||
config={settings.ytDlpConfig || ''}
|
||||
proxyOnlyYoutube={settings.proxyOnlyYoutube || false}
|
||||
onChange={(config) => handleChange('ytDlpConfig', config)}
|
||||
onProxyOnlyYoutubeChange={(checked) => handleChange('proxyOnlyYoutube', checked)}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={12}><Divider /></Grid>
|
||||
<Grid size={12}><Divider /></Grid>
|
||||
|
||||
{/* Advanced Settings */}
|
||||
<Grid size={12}>
|
||||
<AdvancedSettings
|
||||
debugMode={debugMode}
|
||||
onDebugModeChange={setDebugMode}
|
||||
/>
|
||||
</Grid>
|
||||
{/* Advanced Settings */}
|
||||
<Grid size={12}>
|
||||
<AdvancedSettings
|
||||
debugMode={debugMode}
|
||||
onDebugModeChange={setDebugMode}
|
||||
/>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
|
||||
</Grid>
|
||||
</CardContent>
|
||||
|
||||
@@ -17,6 +17,7 @@ import React, { useEffect, useState } from 'react';
|
||||
import ConfirmationModal from '../components/ConfirmationModal';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { useSnackbar } from '../contexts/SnackbarContext';
|
||||
import { useVisitorMode } from '../contexts/VisitorModeContext';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
@@ -35,6 +36,7 @@ interface Subscription {
|
||||
const SubscriptionsPage: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
const { showSnackbar } = useSnackbar();
|
||||
const { visitorMode } = useVisitorMode();
|
||||
const [subscriptions, setSubscriptions] = useState<Subscription[]>([]);
|
||||
const [isUnsubscribeModalOpen, setIsUnsubscribeModalOpen] = useState(false);
|
||||
const [selectedSubscription, setSelectedSubscription] = useState<{ id: string; author: string } | null>(null);
|
||||
@@ -94,13 +96,13 @@ const SubscriptionsPage: React.FC = () => {
|
||||
<TableCell>{t('interval')}</TableCell>
|
||||
<TableCell>{t('lastCheck')}</TableCell>
|
||||
<TableCell>{t('downloads')}</TableCell>
|
||||
<TableCell align="right">{t('actions')}</TableCell>
|
||||
{!visitorMode && <TableCell align="right">{t('actions')}</TableCell>}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{subscriptions.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} align="center">
|
||||
<TableCell colSpan={visitorMode ? 5 : 6} align="center">
|
||||
<Typography color="text.secondary" sx={{ py: 4 }}>
|
||||
{t('noVideos')} {/* Reusing "No videos found" or similar if "No subscriptions" key missing */}
|
||||
</Typography>
|
||||
@@ -123,15 +125,17 @@ const SubscriptionsPage: React.FC = () => {
|
||||
<TableCell>{sub.interval} {t('minutes')}</TableCell>
|
||||
<TableCell>{formatDate(sub.lastCheck)}</TableCell>
|
||||
<TableCell>{sub.downloadCount}</TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton
|
||||
color="error"
|
||||
onClick={() => handleUnsubscribeClick(sub.id, sub.author)}
|
||||
title={t('unsubscribe')}
|
||||
>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
{!visitorMode && (
|
||||
<TableCell align="right">
|
||||
<IconButton
|
||||
color="error"
|
||||
onClick={() => handleUnsubscribeClick(sub.id, sub.author)}
|
||||
title={t('unsubscribe')}
|
||||
>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -76,4 +76,5 @@ export interface Settings {
|
||||
proxyOnlyYoutube?: boolean;
|
||||
moveSubtitlesToVideoFolder?: boolean;
|
||||
moveThumbnailsToVideoFolder?: boolean;
|
||||
visitorMode?: boolean;
|
||||
}
|
||||
|
||||
@@ -107,6 +107,9 @@ export const ar = {
|
||||
"لا يمكن التنظيف أثناء نشاط التنزيلات. يرجى الانتظار حتى تكتمل جميع التنزيلات أو إلغائها أولاً.",
|
||||
itemsPerPage: "عناصر لكل صفحة",
|
||||
itemsPerPageHelper: "عدد مقاطع الفيديو المعروضة في كل صفحة (الافتراضي: 12)",
|
||||
showYoutubeSearch: "عرض نتائج بحث YouTube",
|
||||
visitorMode: "وضع الزائر (للقراءة فقط)",
|
||||
visitorModeReadOnly: "وضع الزائر: للقراءة فقط",
|
||||
cleanupTempFilesSuccess: "تم حذف {count} ملف (ملفات) مؤقت بنجاح.",
|
||||
cleanupTempFilesFailed: "فشل تنظيف الملفات المؤقتة",
|
||||
|
||||
|
||||
@@ -101,7 +101,11 @@ export const de = {
|
||||
cleanupTempFilesDescription:
|
||||
"Alle temporären Download-Dateien (.ytdl, .part) aus dem Upload-Verzeichnis entfernen. Dies hilft, Speicherplatz von unvollständigen oder abgebrochenen Downloads freizugeben.",
|
||||
cleanupTempFilesConfirmTitle: "Temporäre Dateien bereinigen?",
|
||||
itemsPerPage: "Elemente pro Seite",
|
||||
itemsPerPageHelper: "Anzahl der Videos pro Seite (Standard: 12)",
|
||||
showYoutubeSearch: "YouTube-Suchergebnisse anzeigen",
|
||||
visitorMode: "Besuchermodus (Nur-Lesen)",
|
||||
visitorModeReadOnly: "Besuchermodus: Nur-Lesen",
|
||||
cleanupTempFilesSuccess: "Erfolgreich {count} temporäre Datei(en) gelöscht.",
|
||||
cleanupTempFilesFailed: "Fehler beim Bereinigen temporärer Dateien",
|
||||
|
||||
|
||||
@@ -105,6 +105,8 @@ export const en = {
|
||||
itemsPerPage: "Items Per Page",
|
||||
itemsPerPageHelper: "Number of videos to show per page (Default: 12)",
|
||||
showYoutubeSearch: "Show YouTube Search Results",
|
||||
visitorMode: "Visitor Mode (Read-only)",
|
||||
visitorModeReadOnly: "Visitor mode: Read-only",
|
||||
cleanupTempFilesSuccess: "Successfully deleted {count} temporary file(s).",
|
||||
cleanupTempFilesFailed: "Failed to clean up temporary files",
|
||||
|
||||
|
||||
@@ -117,6 +117,9 @@ export const es = {
|
||||
itemsPerPage: "Elementos por página",
|
||||
itemsPerPageHelper:
|
||||
"Número de videos para mostrar por página (Predeterminado: 12)",
|
||||
showYoutubeSearch: "Mostrar resultados de búsqueda de YouTube",
|
||||
visitorMode: "Modo Visitante (Solo lectura)",
|
||||
visitorModeReadOnly: "Modo visitante: Solo lectura",
|
||||
cleanupTempFilesSuccess:
|
||||
"Se eliminaron exitosamente {count} archivo(s) temporal(es).",
|
||||
cleanupTempFilesFailed: "Error al limpiar archivos temporales",
|
||||
|
||||
@@ -116,6 +116,9 @@ export const fr = {
|
||||
"Impossible de nettoyer pendant que des téléchargements sont actifs. Veuillez attendre la fin de tous les téléchargements ou les annuler d'abord.",
|
||||
itemsPerPage: "Éléments par page",
|
||||
itemsPerPageHelper: "Nombre de vidéos à afficher par page (Défaut : 12)",
|
||||
showYoutubeSearch: "Afficher les résultats de recherche YouTube",
|
||||
visitorMode: "Mode Visiteur (Lecture seule)",
|
||||
visitorModeReadOnly: "Mode visiteur : Lecture seule",
|
||||
cleanupTempFilesSuccess:
|
||||
"{count} fichier(s) temporaire(s) supprimé(s) avec succès.",
|
||||
cleanupTempFilesFailed: "Échec du nettoyage des fichiers temporaires",
|
||||
|
||||
@@ -112,6 +112,9 @@ export const ja = {
|
||||
"ダウンロードがアクティブな間はクリーンアップできません。すべてのダウンロードが完了するまで待つか、キャンセルしてください。",
|
||||
itemsPerPage: "1ページあたりの項目数",
|
||||
itemsPerPageHelper: "1ページに表示する動画の数 (デフォルト: 12)",
|
||||
showYoutubeSearch: "YouTube検索結果を表示",
|
||||
visitorMode: "ビジターモード(読み取り専用)",
|
||||
visitorModeReadOnly: "ビジターモード:読み取り専用",
|
||||
cleanupTempFilesSuccess: "{count}個の一時ファイルを正常に削除しました。",
|
||||
cleanupTempFilesFailed: "一時ファイルのクリーンアップに失敗しました",
|
||||
|
||||
|
||||
@@ -109,6 +109,9 @@ export const ko = {
|
||||
"다운로드가 진행되는 동안 정리할 수 없습니다. 모든 다운로드가 완료될 때까지 기다리거나 먼저 취소하십시오.",
|
||||
itemsPerPage: "페이지 당 항목 수",
|
||||
itemsPerPageHelper: "페이지 당 표시할 비디오 수 (기본값: 12)",
|
||||
showYoutubeSearch: "YouTube 검색 결과 표시",
|
||||
visitorMode: "방문자 모드 (읽기 전용)",
|
||||
visitorModeReadOnly: "방문자 모드: 읽기 전용",
|
||||
cleanupTempFilesSuccess: "{count}개의 임시 파일을 성공적으로 삭제했습니다.",
|
||||
cleanupTempFilesFailed: "임시 파일 정리 실패",
|
||||
|
||||
|
||||
@@ -112,6 +112,9 @@ export const pt = {
|
||||
"Não é possível limpar enquanto houver downloads ativos. Aguarde a conclusão de todos os downloads ou cancele-os primeiro.",
|
||||
itemsPerPage: "Itens por página",
|
||||
itemsPerPageHelper: "Número de vídeos a mostrar por página (Padrão: 12)",
|
||||
showYoutubeSearch: "Mostrar resultados de pesquisa do YouTube",
|
||||
visitorMode: "Modo Visitante (Somente leitura)",
|
||||
visitorModeReadOnly: "Modo visitante: Somente leitura",
|
||||
cleanupTempFilesSuccess:
|
||||
"{count} arquivo(s) temporário(s) excluído(s) com sucesso.",
|
||||
cleanupTempFilesFailed: "Falha ao limpar arquivos temporários",
|
||||
|
||||
@@ -121,6 +121,9 @@ export const ru = {
|
||||
"Невозможно очистить, пока активны загрузки. Пожалуйста, дождитесь завершения всех загрузок или сначала отмените их.",
|
||||
itemsPerPage: "Элементов на странице",
|
||||
itemsPerPageHelper: "Количество видео на странице (По умолчанию: 12)",
|
||||
showYoutubeSearch: "Показать результаты поиска YouTube",
|
||||
visitorMode: "Режим посетителя (Только чтение)",
|
||||
visitorModeReadOnly: "Режим посетителя: Только чтение",
|
||||
cleanupTempFilesSuccess: "Успешно удалено {count} временных файлов.",
|
||||
cleanupTempFilesFailed: "Не удалось очистить временные файлы",
|
||||
|
||||
|
||||
@@ -105,6 +105,8 @@ export const zh = {
|
||||
itemsPerPage: "每页显示数量",
|
||||
itemsPerPageHelper: "每页显示的视频数量 (默认: 12)",
|
||||
showYoutubeSearch: "显示 YouTube 搜索结果",
|
||||
visitorMode: "访客模式(只读)",
|
||||
visitorModeReadOnly: "访客模式:只读",
|
||||
cleanupTempFilesSuccess: "成功删除了 {count} 个临时文件。",
|
||||
cleanupTempFilesFailed: "清理临时文件失败",
|
||||
|
||||
|
||||
Reference in New Issue
Block a user