feat: Add settings functionality and settings page
This commit is contained in:
8
backend/data/settings.json
Normal file
8
backend/data/settings.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"loginEnabled": false,
|
||||
"defaultAutoPlay": false,
|
||||
"defaultAutoLoop": false,
|
||||
"maxConcurrentDownloads": 3,
|
||||
"isPasswordSet": true,
|
||||
"password": "$2b$10$1vONfSGZSusSlGf3Vng2UOX8lcmRxtHkTm6eWnP8FlJ19E.QHKNC."
|
||||
}
|
||||
18
backend/package-lock.json
generated
18
backend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
107
backend/src/controllers/settingsController.ts
Normal file
107
backend/src/controllers/settingsController.ts
Normal 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' });
|
||||
}
|
||||
};
|
||||
10
backend/src/routes/settingsRoutes.ts
Normal file
10
backend/src/routes/settingsRoutes.ts
Normal 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;
|
||||
@@ -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, () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,6 +701,13 @@ function App() {
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
{!isAuthenticated && loginRequired ? (
|
||||
checkingAuth ? (
|
||||
<div className="loading">Loading...</div>
|
||||
) : (
|
||||
<LoginPage onLoginSuccess={handleLoginSuccess} />
|
||||
)
|
||||
) : (
|
||||
<Router>
|
||||
<div className="app">
|
||||
<Header
|
||||
@@ -765,11 +817,16 @@ function App() {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={<SettingsPage />}
|
||||
/>
|
||||
</Routes>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</Router>
|
||||
)}
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
108
frontend/src/pages/LoginPage.tsx
Normal file
108
frontend/src/pages/LoginPage.tsx
Normal 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;
|
||||
@@ -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
|
||||
|
||||
218
frontend/src/pages/SettingsPage.tsx
Normal file
218
frontend/src/pages/SettingsPage.tsx
Normal 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;
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user