feat: Add settings functionality and settings page

This commit is contained in:
Peifan Li
2025-11-23 10:55:47 -05:00
parent 018e0b19b8
commit c9d683e903
14 changed files with 746 additions and 126 deletions

View File

@@ -0,0 +1,8 @@
{
"loginEnabled": false,
"defaultAutoPlay": false,
"defaultAutoLoop": false,
"maxConcurrentDownloads": 3,
"isPasswordSet": true,
"password": "$2b$10$1vONfSGZSusSlGf3Vng2UOX8lcmRxtHkTm6eWnP8FlJ19E.QHKNC."
}

View File

@@ -10,6 +10,7 @@
"license": "ISC",
"dependencies": {
"axios": "^1.8.1",
"bcryptjs": "^3.0.3",
"bilibili-save-nodejs": "^1.0.0",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
@@ -20,6 +21,7 @@
"youtube-dl-exec": "^2.4.17"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.5",
"@types/fs-extra": "^11.0.4",
@@ -99,6 +101,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/bcryptjs": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/body-parser": {
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@@ -421,6 +430,15 @@
],
"license": "MIT"
},
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/bilibili-save-nodejs": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/bilibili-save-nodejs/-/bilibili-save-nodejs-1.0.0.tgz",

View File

@@ -14,6 +14,7 @@
"description": "Backend for MyTube video streaming website",
"dependencies": {
"axios": "^1.8.1",
"bcryptjs": "^3.0.3",
"bilibili-save-nodejs": "^1.0.0",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
@@ -24,6 +25,7 @@
"youtube-dl-exec": "^2.4.17"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.5",
"@types/fs-extra": "^11.0.4",

View File

@@ -0,0 +1,107 @@
import bcrypt from 'bcryptjs';
import { Request, Response } from 'express';
import fs from 'fs-extra';
import path from 'path';
import downloadManager from '../services/downloadManager';
const SETTINGS_FILE = path.join(__dirname, '../../data/settings.json');
interface Settings {
loginEnabled: boolean;
password?: string;
defaultAutoPlay: boolean;
defaultAutoLoop: boolean;
maxConcurrentDownloads: number;
}
const defaultSettings: Settings = {
loginEnabled: false,
password: "",
defaultAutoPlay: false,
defaultAutoLoop: false,
maxConcurrentDownloads: 3
};
export const getSettings = async (req: Request, res: Response) => {
try {
if (!fs.existsSync(SETTINGS_FILE)) {
await fs.writeJson(SETTINGS_FILE, defaultSettings, { spaces: 2 });
return res.json(defaultSettings);
}
const settings = await fs.readJson(SETTINGS_FILE);
// Do not send the hashed password to the frontend
const { password, ...safeSettings } = settings;
res.json({ ...safeSettings, isPasswordSet: !!password });
} catch (error) {
console.error('Error reading settings:', error);
res.status(500).json({ error: 'Failed to read settings' });
}
};
export const updateSettings = async (req: Request, res: Response) => {
try {
const newSettings: Settings = req.body;
// Validate settings if needed
if (newSettings.maxConcurrentDownloads < 1) {
newSettings.maxConcurrentDownloads = 1;
}
// Handle password hashing
if (newSettings.password) {
// If password is provided, hash it
const salt = await bcrypt.genSalt(10);
newSettings.password = await bcrypt.hash(newSettings.password, salt);
} else {
// If password is empty/not provided, keep existing password if file exists
if (fs.existsSync(SETTINGS_FILE)) {
const existingSettings = await fs.readJson(SETTINGS_FILE);
newSettings.password = existingSettings.password;
}
}
await fs.writeJson(SETTINGS_FILE, newSettings, { spaces: 2 });
// Apply settings immediately where possible
downloadManager.setMaxConcurrentDownloads(newSettings.maxConcurrentDownloads);
res.json({ success: true, settings: { ...newSettings, password: undefined } });
} catch (error) {
console.error('Error updating settings:', error);
res.status(500).json({ error: 'Failed to update settings' });
}
};
export const verifyPassword = async (req: Request, res: Response) => {
try {
const { password } = req.body;
if (!fs.existsSync(SETTINGS_FILE)) {
return res.json({ success: true });
}
const settings = await fs.readJson(SETTINGS_FILE);
if (!settings.loginEnabled) {
return res.json({ success: true });
}
if (!settings.password) {
// If no password set but login enabled, allow access (or force set password?)
// For now, allow access
return res.json({ success: true });
}
const isMatch = await bcrypt.compare(password, settings.password);
if (isMatch) {
res.json({ success: true });
} else {
res.status(401).json({ success: false, error: 'Incorrect password' });
}
} catch (error) {
console.error('Error verifying password:', error);
res.status(500).json({ error: 'Failed to verify password' });
}
};

View File

@@ -0,0 +1,10 @@
import express from 'express';
import { getSettings, updateSettings, verifyPassword } from '../controllers/settingsController';
const router = express.Router();
router.get('/', getSettings);
router.post('/', updateSettings);
router.post('/verify-password', verifyPassword);
export default router;

View File

@@ -6,6 +6,7 @@ import cors from "cors";
import express from "express";
import { IMAGES_DIR, VIDEOS_DIR } from "./config/paths";
import apiRoutes from "./routes/api";
import settingsRoutes from './routes/settingsRoutes';
import * as storageService from "./services/storageService";
import { VERSION } from "./version";
@@ -29,6 +30,7 @@ app.use("/images", express.static(IMAGES_DIR));
// API Routes
app.use("/api", apiRoutes);
app.use('/api/settings', settingsRoutes);
// Start the server
app.listen(PORT, () => {

View File

@@ -19,6 +19,15 @@ class DownloadManager {
this.maxConcurrentDownloads = 3;
}
/**
* Set the maximum number of concurrent downloads
* @param limit - Maximum number of concurrent downloads
*/
setMaxConcurrentDownloads(limit: number): void {
this.maxConcurrentDownloads = limit;
this.processQueue();
}
/**
* Add a download task to the manager
* @param downloadFn - Async function that performs the download

View File

@@ -10,8 +10,10 @@ import { useSnackbar } from './contexts/SnackbarContext';
import AuthorVideos from './pages/AuthorVideos';
import CollectionPage from './pages/CollectionPage';
import Home from './pages/Home';
import LoginPage from './pages/LoginPage';
import ManagePage from './pages/ManagePage';
import SearchResults from './pages/SearchResults';
import SettingsPage from './pages/SettingsPage';
import VideoPlayer from './pages/VideoPlayer';
import getTheme from './theme';
import { Collection, DownloadInfo, Video } from './types';
@@ -78,6 +80,11 @@ function App() {
return (localStorage.getItem('theme') as 'light' | 'dark') || 'dark';
});
// Login state
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
const [loginRequired, setLoginRequired] = useState<boolean>(true); // Assume required until checked
const [checkingAuth, setCheckingAuth] = useState<boolean>(true);
const theme = useMemo(() => getTheme(themeMode), [themeMode]);
// Apply theme to body
@@ -162,6 +169,44 @@ function App() {
fetchVideos();
}, []);
// Check login settings and authentication status
useEffect(() => {
const checkAuth = async () => {
try {
// Check if login is enabled in settings
const response = await axios.get(`${API_URL}/settings`);
const { loginEnabled } = response.data;
if (!loginEnabled) {
setLoginRequired(false);
setIsAuthenticated(true);
} else {
setLoginRequired(true);
// Check if already authenticated in this session
const sessionAuth = sessionStorage.getItem('mytube_authenticated');
if (sessionAuth === 'true') {
setIsAuthenticated(true);
} else {
setIsAuthenticated(false);
}
}
} catch (error) {
console.error('Error checking auth settings:', error);
// If error, default to requiring login for security, but maybe allow if backend is down?
// Better to fail safe.
} finally {
setCheckingAuth(false);
}
};
checkAuth();
}, []);
const handleLoginSuccess = () => {
setIsAuthenticated(true);
sessionStorage.setItem('mytube_authenticated', 'true');
};
// Set up localStorage and event listeners
useEffect(() => {
console.log('Setting up localStorage and event listeners');
@@ -656,120 +701,132 @@ function App() {
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<Router>
<div className="app">
<Header
onSearch={handleSearch}
onSubmit={handleVideoSubmit}
activeDownloads={activeDownloads}
isSearchMode={isSearchMode}
searchTerm={searchTerm}
onResetSearch={resetSearch}
theme={themeMode}
toggleTheme={toggleTheme}
collections={collections}
videos={videos}
/>
{!isAuthenticated && loginRequired ? (
checkingAuth ? (
<div className="loading">Loading...</div>
) : (
<LoginPage onLoginSuccess={handleLoginSuccess} />
)
) : (
<Router>
<div className="app">
<Header
onSearch={handleSearch}
onSubmit={handleVideoSubmit}
activeDownloads={activeDownloads}
isSearchMode={isSearchMode}
searchTerm={searchTerm}
onResetSearch={resetSearch}
theme={themeMode}
toggleTheme={toggleTheme}
collections={collections}
videos={videos}
/>
{/* Bilibili Parts Modal */}
<BilibiliPartsModal
isOpen={showBilibiliPartsModal}
onClose={() => setShowBilibiliPartsModal(false)}
videosNumber={bilibiliPartsInfo.videosNumber}
videoTitle={bilibiliPartsInfo.title}
onDownloadAll={handleDownloadAllBilibiliParts}
onDownloadCurrent={handleDownloadCurrentBilibiliPart}
isLoading={loading || isCheckingParts}
type={bilibiliPartsInfo.type}
/>
{/* Bilibili Parts Modal */}
<BilibiliPartsModal
isOpen={showBilibiliPartsModal}
onClose={() => setShowBilibiliPartsModal(false)}
videosNumber={bilibiliPartsInfo.videosNumber}
videoTitle={bilibiliPartsInfo.title}
onDownloadAll={handleDownloadAllBilibiliParts}
onDownloadCurrent={handleDownloadCurrentBilibiliPart}
isLoading={loading || isCheckingParts}
type={bilibiliPartsInfo.type}
/>
<main className="main-content">
<Routes>
<Route
path="/"
element={
<Home
videos={videos}
loading={loading}
error={error}
onDeleteVideo={handleDeleteVideo}
collections={collections}
isSearchMode={isSearchMode}
searchTerm={searchTerm}
localSearchResults={localSearchResults}
youtubeLoading={youtubeLoading}
searchResults={searchResults}
onDownload={handleDownloadFromSearch}
onResetSearch={resetSearch}
/>
}
/>
<Route
path="/video/:id"
element={
<VideoPlayer
videos={videos}
onDeleteVideo={handleDeleteVideo}
collections={collections}
onAddToCollection={handleAddToCollection}
onCreateCollection={handleCreateCollection}
onRemoveFromCollection={handleRemoveFromCollection}
/>
}
/>
<Route
path="/author/:author"
element={
<AuthorVideos
videos={videos}
onDeleteVideo={handleDeleteVideo}
collections={collections}
/>
}
/>
<Route
path="/collection/:id"
element={
<CollectionPage
collections={collections}
videos={videos}
onDeleteVideo={handleDeleteVideo}
onDeleteCollection={handleDeleteCollection}
/>
}
/>
<Route
path="/search"
element={
<SearchResults
results={searchResults}
localResults={localSearchResults}
loading={loading}
youtubeLoading={youtubeLoading}
searchTerm={searchTerm}
onDownload={handleDownloadFromSearch}
onDeleteVideo={handleDeleteVideo}
onResetSearch={resetSearch}
collections={collections}
/>
}
/>
<Route
path="/manage"
element={
<ManagePage
videos={videos}
onDeleteVideo={handleDeleteVideo}
collections={collections}
onDeleteCollection={handleDeleteCollection}
/>
}
/>
</Routes>
</main>
<Footer />
</div>
</Router>
<main className="main-content">
<Routes>
<Route
path="/"
element={
<Home
videos={videos}
loading={loading}
error={error}
onDeleteVideo={handleDeleteVideo}
collections={collections}
isSearchMode={isSearchMode}
searchTerm={searchTerm}
localSearchResults={localSearchResults}
youtubeLoading={youtubeLoading}
searchResults={searchResults}
onDownload={handleDownloadFromSearch}
onResetSearch={resetSearch}
/>
}
/>
<Route
path="/video/:id"
element={
<VideoPlayer
videos={videos}
onDeleteVideo={handleDeleteVideo}
collections={collections}
onAddToCollection={handleAddToCollection}
onCreateCollection={handleCreateCollection}
onRemoveFromCollection={handleRemoveFromCollection}
/>
}
/>
<Route
path="/author/:author"
element={
<AuthorVideos
videos={videos}
onDeleteVideo={handleDeleteVideo}
collections={collections}
/>
}
/>
<Route
path="/collection/:id"
element={
<CollectionPage
collections={collections}
videos={videos}
onDeleteVideo={handleDeleteVideo}
onDeleteCollection={handleDeleteCollection}
/>
}
/>
<Route
path="/search"
element={
<SearchResults
results={searchResults}
localResults={localSearchResults}
loading={loading}
youtubeLoading={youtubeLoading}
searchTerm={searchTerm}
onDownload={handleDownloadFromSearch}
onDeleteVideo={handleDeleteVideo}
onResetSearch={resetSearch}
collections={collections}
/>
}
/>
<Route
path="/manage"
element={
<ManagePage
videos={videos}
onDeleteVideo={handleDeleteVideo}
collections={collections}
onDeleteCollection={handleDeleteCollection}
/>
}
/>
<Route
path="/settings"
element={<SettingsPage />}
/>
</Routes>
</main>
<Footer />
</div>
</Router>
)}
</ThemeProvider>
);
}

View File

@@ -5,7 +5,9 @@ import {
CloudUpload,
Download,
Menu as MenuIcon,
Search
Search,
Settings,
VideoLibrary
} from '@mui/icons-material';
import {
AppBar,
@@ -69,6 +71,7 @@ const Header: React.FC<HeaderProps> = ({
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const [error, setError] = useState<string>('');
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [manageAnchorEl, setManageAnchorEl] = useState<null | HTMLElement>(null);
const [uploadModalOpen, setUploadModalOpen] = useState<boolean>(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState<boolean>(false);
const navigate = useNavigate();
@@ -90,6 +93,14 @@ const Header: React.FC<HeaderProps> = ({
setAnchorEl(null);
};
const handleManageClick = (event: React.MouseEvent<HTMLElement>) => {
setManageAnchorEl(event.currentTarget);
};
const handleManageClose = () => {
setManageAnchorEl(null);
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
@@ -211,6 +222,50 @@ const Header: React.FC<HeaderProps> = ({
<IconButton sx={{ ml: 1 }} onClick={toggleTheme} color="inherit">
{currentThemeMode === 'dark' ? <Brightness7 /> : <Brightness4 />}
</IconButton>
<Tooltip title="Manage">
<IconButton
color="inherit"
onClick={handleManageClick}
sx={{ ml: 1 }}
>
<Settings />
</IconButton>
</Tooltip>
<Menu
anchorEl={manageAnchorEl}
open={Boolean(manageAnchorEl)}
onClose={handleManageClose}
PaperProps={{
elevation: 0,
sx: {
overflow: 'visible',
filter: 'drop-shadow(0px 2px 8px rgba(0,0,0,0.32))',
mt: 1.5,
'&:before': {
content: '""',
display: 'block',
position: 'absolute',
top: 0,
right: 14,
width: 10,
height: 10,
bgcolor: 'background.paper',
transform: 'translateY(-50%) rotate(45deg)',
zIndex: 0,
},
},
}}
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
>
<MenuItem onClick={() => { handleManageClose(); navigate('/manage'); }}>
<VideoLibrary sx={{ mr: 2 }} /> Manage Content
</MenuItem>
<MenuItem onClick={() => { handleManageClose(); navigate('/settings'); }}>
<Settings sx={{ mr: 2 }} /> Settings
</MenuItem>
</Menu>
</Box>
);
@@ -307,16 +362,27 @@ const Header: React.FC<HeaderProps> = ({
onItemClick={() => setMobileMenuOpen(false)}
/>
</Box>
<Box sx={{ mt: 3, textAlign: 'center', mb: 2 }}>
<Box sx={{ mt: 3, textAlign: 'center', mb: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
<Button
component={Link}
to="/manage"
variant="outlined"
fullWidth
onClick={() => setMobileMenuOpen(false)}
startIcon={<VideoLibrary />}
>
Manage Videos
</Button>
<Button
component={Link}
to="/settings"
variant="outlined"
fullWidth
onClick={() => setMobileMenuOpen(false)}
startIcon={<Settings />}
>
Settings
</Button>
</Box>
</Box>
</Stack>

View File

@@ -15,7 +15,6 @@ import {
Typography
} from '@mui/material';
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import AuthorsList from '../components/AuthorsList';
import Collections from '../components/Collections';
import VideoCard from '../components/VideoCard';
@@ -277,16 +276,6 @@ const Home: React.FC<HomeProps> = ({
<Box sx={{ mt: 2 }}>
<AuthorsList videos={videoArray} />
</Box>
<Box sx={{ mt: 3, textAlign: 'center' }}>
<Button
component={Link}
to="/manage"
variant="outlined"
fullWidth
>
Manage Videos
</Button>
</Box>
</Box>
</Grid>

View File

@@ -0,0 +1,108 @@
import { LockOutlined } from '@mui/icons-material';
import {
Alert,
Avatar,
Box,
Button,
Container,
CssBaseline,
TextField,
ThemeProvider,
Typography
} from '@mui/material';
import axios from 'axios';
import React, { useState } from 'react';
import getTheme from '../theme';
const API_URL = import.meta.env.VITE_API_URL;
interface LoginPageProps {
onLoginSuccess: () => void;
}
const LoginPage: React.FC<LoginPageProps> = ({ onLoginSuccess }) => {
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
// Use dark theme for login page to match app style
const theme = getTheme('dark');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
const response = await axios.post(`${API_URL}/settings/verify-password`, { password });
if (response.data.success) {
onLoginSuccess();
} else {
setError('Incorrect password');
}
} catch (err: any) {
console.error('Login error:', err);
if (err.response && err.response.status === 401) {
setError('Incorrect password');
} else {
setError('Failed to verify password. Please try again.');
}
} finally {
setLoading(false);
}
};
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<Container component="main" maxWidth="xs">
<Box
sx={{
marginTop: 8,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<Avatar sx={{ m: 1, bgcolor: 'secondary.main' }}>
<LockOutlined />
</Avatar>
<Typography component="h1" variant="h5">
Sign in
</Typography>
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 1 }}>
<TextField
margin="normal"
required
fullWidth
name="password"
label="Password"
type="password"
id="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoFocus
/>
{error && (
<Alert severity="error" sx={{ mt: 2 }}>
{error}
</Alert>
)}
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
disabled={loading}
>
{loading ? 'Verifying...' : 'Sign In'}
</Button>
</Box>
</Box>
</Container>
</ThemeProvider>
);
};
export default LoginPage;

View File

@@ -123,7 +123,7 @@ const ManagePage: React.FC<ManagePageProps> = ({ videos, onDeleteVideo, collecti
};
return (
<Container maxWidth="lg" sx={{ py: 4 }}>
<Container maxWidth="xl" sx={{ py: 4 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4 }}>
<Typography variant="h4" component="h1" fontWeight="bold">
Manage Content

View File

@@ -0,0 +1,218 @@
import {
ArrowBack,
Save
} from '@mui/icons-material';
import {
Alert,
Box,
Button,
Card,
CardContent,
Container,
Divider,
FormControlLabel,
Grid,
Slider,
Snackbar,
Switch,
TextField,
Typography
} from '@mui/material';
import axios from 'axios';
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
const API_URL = import.meta.env.VITE_API_URL;
interface Settings {
loginEnabled: boolean;
password?: string;
isPasswordSet?: boolean;
defaultAutoPlay: boolean;
defaultAutoLoop: boolean;
maxConcurrentDownloads: number;
}
const SettingsPage: React.FC = () => {
const [settings, setSettings] = useState<Settings>({
loginEnabled: false,
password: '',
defaultAutoPlay: false,
defaultAutoLoop: false,
maxConcurrentDownloads: 3
});
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ text: string; type: 'success' | 'error' } | null>(null);
useEffect(() => {
fetchSettings();
}, []);
const fetchSettings = async () => {
try {
const response = await axios.get(`${API_URL}/settings`);
setSettings(response.data);
} catch (error) {
console.error('Error fetching settings:', error);
setMessage({ text: 'Failed to load settings', type: 'error' });
} finally {
// Loading finished
}
};
const handleSave = async () => {
setSaving(true);
try {
// Only send password if it has been changed (is not empty)
const settingsToSend = { ...settings };
if (!settingsToSend.password) {
delete settingsToSend.password;
}
await axios.post(`${API_URL}/settings`, settingsToSend);
setMessage({ text: 'Settings saved successfully', type: 'success' });
// Clear password field after save
setSettings(prev => ({ ...prev, password: '', isPasswordSet: true }));
} catch (error) {
console.error('Error saving settings:', error);
setMessage({ text: 'Failed to save settings', type: 'error' });
} finally {
setSaving(false);
}
};
const handleChange = (field: keyof Settings, value: any) => {
setSettings(prev => ({ ...prev, [field]: value }));
};
return (
<Container maxWidth="xl" sx={{ py: 4 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4 }}>
<Typography variant="h4" component="h1" fontWeight="bold">
Settings
</Typography>
<Button
component={Link}
to="/manage"
variant="outlined"
startIcon={<ArrowBack />}
>
Back to Manage
</Button>
</Box>
<Card variant="outlined">
<CardContent>
<Grid container spacing={4}>
{/* Security Settings */}
<Grid size={12}>
<Typography variant="h6" gutterBottom>Security</Typography>
<FormControlLabel
control={
<Switch
checked={settings.loginEnabled}
onChange={(e) => handleChange('loginEnabled', e.target.checked)}
/>
}
label="Enable Login Protection"
/>
{settings.loginEnabled && (
<Box sx={{ mt: 2, maxWidth: 400 }}>
<TextField
fullWidth
label="Password"
type="password"
value={settings.password || ''}
onChange={(e) => handleChange('password', e.target.value)}
helperText={
settings.isPasswordSet
? "Leave empty to keep current password, or type to change"
: "Set a password for accessing the application"
}
/>
</Box>
)}
</Grid>
<Grid size={12}><Divider /></Grid>
{/* Video Defaults */}
<Grid size={12}>
<Typography variant="h6" gutterBottom>Video Player Defaults</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<FormControlLabel
control={
<Switch
checked={settings.defaultAutoPlay}
onChange={(e) => handleChange('defaultAutoPlay', e.target.checked)}
/>
}
label="Auto-play Videos"
/>
<FormControlLabel
control={
<Switch
checked={settings.defaultAutoLoop}
onChange={(e) => handleChange('defaultAutoLoop', e.target.checked)}
/>
}
label="Auto-loop Videos"
/>
</Box>
</Grid>
<Grid size={12}><Divider /></Grid>
{/* Download Settings */}
<Grid size={12}>
<Typography variant="h6" gutterBottom>Download Settings</Typography>
<Typography gutterBottom>
Max Concurrent Downloads: {settings.maxConcurrentDownloads}
</Typography>
<Box sx={{ maxWidth: 400, px: 2 }}>
<Slider
value={settings.maxConcurrentDownloads}
onChange={(_, value) => handleChange('maxConcurrentDownloads', value)}
min={1}
max={10}
step={1}
marks
valueLabelDisplay="auto"
/>
</Box>
</Grid>
<Grid size={12}>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 2 }}>
<Button
variant="contained"
size="large"
startIcon={<Save />}
onClick={handleSave}
disabled={saving}
>
{saving ? 'Saving...' : 'Save Settings'}
</Button>
</Box>
</Grid>
</Grid>
</CardContent>
</Card>
<Snackbar
open={!!message}
autoHideDuration={6000}
onClose={() => setMessage(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert severity={message?.type} onClose={() => setMessage(null)}>
{message?.text}
</Alert>
</Snackbar>
</Container>
);
};
export default SettingsPage;

View File

@@ -155,8 +155,34 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
};
fetchVideo();
fetchVideo();
}, [id, videos, navigate, isDeleting]);
// Fetch settings and apply defaults
useEffect(() => {
const fetchSettings = async () => {
try {
const response = await axios.get(`${API_URL}/settings`);
const { defaultAutoPlay, defaultAutoLoop } = response.data;
if (videoRef.current) {
if (defaultAutoPlay) {
videoRef.current.autoplay = true;
setIsPlaying(true);
}
if (defaultAutoLoop) {
videoRef.current.loop = true;
setIsLooping(true);
}
}
} catch (error) {
console.error('Error fetching settings:', error);
}
};
fetchSettings();
}, [id]); // Re-run when video changes
// Fetch comments
useEffect(() => {
const fetchComments = async () => {