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",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.8.1",
|
"axios": "^1.8.1",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"bilibili-save-nodejs": "^1.0.0",
|
"bilibili-save-nodejs": "^1.0.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
"youtube-dl-exec": "^2.4.17"
|
"youtube-dl-exec": "^2.4.17"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^5.0.5",
|
"@types/express": "^5.0.5",
|
||||||
"@types/fs-extra": "^11.0.4",
|
"@types/fs-extra": "^11.0.4",
|
||||||
@@ -99,6 +101,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/body-parser": {
|
||||||
"version": "1.19.6",
|
"version": "1.19.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||||
@@ -421,6 +430,15 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/bilibili-save-nodejs": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/bilibili-save-nodejs/-/bilibili-save-nodejs-1.0.0.tgz",
|
"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",
|
"description": "Backend for MyTube video streaming website",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.8.1",
|
"axios": "^1.8.1",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"bilibili-save-nodejs": "^1.0.0",
|
"bilibili-save-nodejs": "^1.0.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
@@ -24,6 +25,7 @@
|
|||||||
"youtube-dl-exec": "^2.4.17"
|
"youtube-dl-exec": "^2.4.17"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^5.0.5",
|
"@types/express": "^5.0.5",
|
||||||
"@types/fs-extra": "^11.0.4",
|
"@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 express from "express";
|
||||||
import { IMAGES_DIR, VIDEOS_DIR } from "./config/paths";
|
import { IMAGES_DIR, VIDEOS_DIR } from "./config/paths";
|
||||||
import apiRoutes from "./routes/api";
|
import apiRoutes from "./routes/api";
|
||||||
|
import settingsRoutes from './routes/settingsRoutes';
|
||||||
import * as storageService from "./services/storageService";
|
import * as storageService from "./services/storageService";
|
||||||
import { VERSION } from "./version";
|
import { VERSION } from "./version";
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ app.use("/images", express.static(IMAGES_DIR));
|
|||||||
|
|
||||||
// API Routes
|
// API Routes
|
||||||
app.use("/api", apiRoutes);
|
app.use("/api", apiRoutes);
|
||||||
|
app.use('/api/settings', settingsRoutes);
|
||||||
|
|
||||||
// Start the server
|
// Start the server
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
|
|||||||
@@ -19,6 +19,15 @@ class DownloadManager {
|
|||||||
this.maxConcurrentDownloads = 3;
|
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
|
* Add a download task to the manager
|
||||||
* @param downloadFn - Async function that performs the download
|
* @param downloadFn - Async function that performs the download
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ import { useSnackbar } from './contexts/SnackbarContext';
|
|||||||
import AuthorVideos from './pages/AuthorVideos';
|
import AuthorVideos from './pages/AuthorVideos';
|
||||||
import CollectionPage from './pages/CollectionPage';
|
import CollectionPage from './pages/CollectionPage';
|
||||||
import Home from './pages/Home';
|
import Home from './pages/Home';
|
||||||
|
import LoginPage from './pages/LoginPage';
|
||||||
import ManagePage from './pages/ManagePage';
|
import ManagePage from './pages/ManagePage';
|
||||||
import SearchResults from './pages/SearchResults';
|
import SearchResults from './pages/SearchResults';
|
||||||
|
import SettingsPage from './pages/SettingsPage';
|
||||||
import VideoPlayer from './pages/VideoPlayer';
|
import VideoPlayer from './pages/VideoPlayer';
|
||||||
import getTheme from './theme';
|
import getTheme from './theme';
|
||||||
import { Collection, DownloadInfo, Video } from './types';
|
import { Collection, DownloadInfo, Video } from './types';
|
||||||
@@ -78,6 +80,11 @@ function App() {
|
|||||||
return (localStorage.getItem('theme') as 'light' | 'dark') || 'dark';
|
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]);
|
const theme = useMemo(() => getTheme(themeMode), [themeMode]);
|
||||||
|
|
||||||
// Apply theme to body
|
// Apply theme to body
|
||||||
@@ -162,6 +169,44 @@ function App() {
|
|||||||
fetchVideos();
|
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
|
// Set up localStorage and event listeners
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('Setting up localStorage and event listeners');
|
console.log('Setting up localStorage and event listeners');
|
||||||
@@ -656,120 +701,132 @@ function App() {
|
|||||||
return (
|
return (
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
<Router>
|
{!isAuthenticated && loginRequired ? (
|
||||||
<div className="app">
|
checkingAuth ? (
|
||||||
<Header
|
<div className="loading">Loading...</div>
|
||||||
onSearch={handleSearch}
|
) : (
|
||||||
onSubmit={handleVideoSubmit}
|
<LoginPage onLoginSuccess={handleLoginSuccess} />
|
||||||
activeDownloads={activeDownloads}
|
)
|
||||||
isSearchMode={isSearchMode}
|
) : (
|
||||||
searchTerm={searchTerm}
|
<Router>
|
||||||
onResetSearch={resetSearch}
|
<div className="app">
|
||||||
theme={themeMode}
|
<Header
|
||||||
toggleTheme={toggleTheme}
|
onSearch={handleSearch}
|
||||||
collections={collections}
|
onSubmit={handleVideoSubmit}
|
||||||
videos={videos}
|
activeDownloads={activeDownloads}
|
||||||
/>
|
isSearchMode={isSearchMode}
|
||||||
|
searchTerm={searchTerm}
|
||||||
|
onResetSearch={resetSearch}
|
||||||
|
theme={themeMode}
|
||||||
|
toggleTheme={toggleTheme}
|
||||||
|
collections={collections}
|
||||||
|
videos={videos}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Bilibili Parts Modal */}
|
{/* Bilibili Parts Modal */}
|
||||||
<BilibiliPartsModal
|
<BilibiliPartsModal
|
||||||
isOpen={showBilibiliPartsModal}
|
isOpen={showBilibiliPartsModal}
|
||||||
onClose={() => setShowBilibiliPartsModal(false)}
|
onClose={() => setShowBilibiliPartsModal(false)}
|
||||||
videosNumber={bilibiliPartsInfo.videosNumber}
|
videosNumber={bilibiliPartsInfo.videosNumber}
|
||||||
videoTitle={bilibiliPartsInfo.title}
|
videoTitle={bilibiliPartsInfo.title}
|
||||||
onDownloadAll={handleDownloadAllBilibiliParts}
|
onDownloadAll={handleDownloadAllBilibiliParts}
|
||||||
onDownloadCurrent={handleDownloadCurrentBilibiliPart}
|
onDownloadCurrent={handleDownloadCurrentBilibiliPart}
|
||||||
isLoading={loading || isCheckingParts}
|
isLoading={loading || isCheckingParts}
|
||||||
type={bilibiliPartsInfo.type}
|
type={bilibiliPartsInfo.type}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main className="main-content">
|
<main className="main-content">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route
|
<Route
|
||||||
path="/"
|
path="/"
|
||||||
element={
|
element={
|
||||||
<Home
|
<Home
|
||||||
videos={videos}
|
videos={videos}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
error={error}
|
error={error}
|
||||||
onDeleteVideo={handleDeleteVideo}
|
onDeleteVideo={handleDeleteVideo}
|
||||||
collections={collections}
|
collections={collections}
|
||||||
isSearchMode={isSearchMode}
|
isSearchMode={isSearchMode}
|
||||||
searchTerm={searchTerm}
|
searchTerm={searchTerm}
|
||||||
localSearchResults={localSearchResults}
|
localSearchResults={localSearchResults}
|
||||||
youtubeLoading={youtubeLoading}
|
youtubeLoading={youtubeLoading}
|
||||||
searchResults={searchResults}
|
searchResults={searchResults}
|
||||||
onDownload={handleDownloadFromSearch}
|
onDownload={handleDownloadFromSearch}
|
||||||
onResetSearch={resetSearch}
|
onResetSearch={resetSearch}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/video/:id"
|
path="/video/:id"
|
||||||
element={
|
element={
|
||||||
<VideoPlayer
|
<VideoPlayer
|
||||||
videos={videos}
|
videos={videos}
|
||||||
onDeleteVideo={handleDeleteVideo}
|
onDeleteVideo={handleDeleteVideo}
|
||||||
collections={collections}
|
collections={collections}
|
||||||
onAddToCollection={handleAddToCollection}
|
onAddToCollection={handleAddToCollection}
|
||||||
onCreateCollection={handleCreateCollection}
|
onCreateCollection={handleCreateCollection}
|
||||||
onRemoveFromCollection={handleRemoveFromCollection}
|
onRemoveFromCollection={handleRemoveFromCollection}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/author/:author"
|
path="/author/:author"
|
||||||
element={
|
element={
|
||||||
<AuthorVideos
|
<AuthorVideos
|
||||||
videos={videos}
|
videos={videos}
|
||||||
onDeleteVideo={handleDeleteVideo}
|
onDeleteVideo={handleDeleteVideo}
|
||||||
collections={collections}
|
collections={collections}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/collection/:id"
|
path="/collection/:id"
|
||||||
element={
|
element={
|
||||||
<CollectionPage
|
<CollectionPage
|
||||||
collections={collections}
|
collections={collections}
|
||||||
videos={videos}
|
videos={videos}
|
||||||
onDeleteVideo={handleDeleteVideo}
|
onDeleteVideo={handleDeleteVideo}
|
||||||
onDeleteCollection={handleDeleteCollection}
|
onDeleteCollection={handleDeleteCollection}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/search"
|
path="/search"
|
||||||
element={
|
element={
|
||||||
<SearchResults
|
<SearchResults
|
||||||
results={searchResults}
|
results={searchResults}
|
||||||
localResults={localSearchResults}
|
localResults={localSearchResults}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
youtubeLoading={youtubeLoading}
|
youtubeLoading={youtubeLoading}
|
||||||
searchTerm={searchTerm}
|
searchTerm={searchTerm}
|
||||||
onDownload={handleDownloadFromSearch}
|
onDownload={handleDownloadFromSearch}
|
||||||
onDeleteVideo={handleDeleteVideo}
|
onDeleteVideo={handleDeleteVideo}
|
||||||
onResetSearch={resetSearch}
|
onResetSearch={resetSearch}
|
||||||
collections={collections}
|
collections={collections}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/manage"
|
path="/manage"
|
||||||
element={
|
element={
|
||||||
<ManagePage
|
<ManagePage
|
||||||
videos={videos}
|
videos={videos}
|
||||||
onDeleteVideo={handleDeleteVideo}
|
onDeleteVideo={handleDeleteVideo}
|
||||||
collections={collections}
|
collections={collections}
|
||||||
onDeleteCollection={handleDeleteCollection}
|
onDeleteCollection={handleDeleteCollection}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Routes>
|
<Route
|
||||||
</main>
|
path="/settings"
|
||||||
<Footer />
|
element={<SettingsPage />}
|
||||||
</div>
|
/>
|
||||||
</Router>
|
</Routes>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
</Router>
|
||||||
|
)}
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import {
|
|||||||
CloudUpload,
|
CloudUpload,
|
||||||
Download,
|
Download,
|
||||||
Menu as MenuIcon,
|
Menu as MenuIcon,
|
||||||
Search
|
Search,
|
||||||
|
Settings,
|
||||||
|
VideoLibrary
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import {
|
import {
|
||||||
AppBar,
|
AppBar,
|
||||||
@@ -69,6 +71,7 @@ const Header: React.FC<HeaderProps> = ({
|
|||||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||||
const [error, setError] = useState<string>('');
|
const [error, setError] = useState<string>('');
|
||||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||||
|
const [manageAnchorEl, setManageAnchorEl] = useState<null | HTMLElement>(null);
|
||||||
const [uploadModalOpen, setUploadModalOpen] = useState<boolean>(false);
|
const [uploadModalOpen, setUploadModalOpen] = useState<boolean>(false);
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState<boolean>(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState<boolean>(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -90,6 +93,14 @@ const Header: React.FC<HeaderProps> = ({
|
|||||||
setAnchorEl(null);
|
setAnchorEl(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleManageClick = (event: React.MouseEvent<HTMLElement>) => {
|
||||||
|
setManageAnchorEl(event.currentTarget);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleManageClose = () => {
|
||||||
|
setManageAnchorEl(null);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: FormEvent) => {
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
@@ -211,6 +222,50 @@ const Header: React.FC<HeaderProps> = ({
|
|||||||
<IconButton sx={{ ml: 1 }} onClick={toggleTheme} color="inherit">
|
<IconButton sx={{ ml: 1 }} onClick={toggleTheme} color="inherit">
|
||||||
{currentThemeMode === 'dark' ? <Brightness7 /> : <Brightness4 />}
|
{currentThemeMode === 'dark' ? <Brightness7 /> : <Brightness4 />}
|
||||||
</IconButton>
|
</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>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -307,16 +362,27 @@ const Header: React.FC<HeaderProps> = ({
|
|||||||
onItemClick={() => setMobileMenuOpen(false)}
|
onItemClick={() => setMobileMenuOpen(false)}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ mt: 3, textAlign: 'center', mb: 2 }}>
|
<Box sx={{ mt: 3, textAlign: 'center', mb: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
<Button
|
<Button
|
||||||
component={Link}
|
component={Link}
|
||||||
to="/manage"
|
to="/manage"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
fullWidth
|
fullWidth
|
||||||
onClick={() => setMobileMenuOpen(false)}
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
startIcon={<VideoLibrary />}
|
||||||
>
|
>
|
||||||
Manage Videos
|
Manage Videos
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
component={Link}
|
||||||
|
to="/settings"
|
||||||
|
variant="outlined"
|
||||||
|
fullWidth
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
startIcon={<Settings />}
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
Typography
|
Typography
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import AuthorsList from '../components/AuthorsList';
|
import AuthorsList from '../components/AuthorsList';
|
||||||
import Collections from '../components/Collections';
|
import Collections from '../components/Collections';
|
||||||
import VideoCard from '../components/VideoCard';
|
import VideoCard from '../components/VideoCard';
|
||||||
@@ -277,16 +276,6 @@ const Home: React.FC<HomeProps> = ({
|
|||||||
<Box sx={{ mt: 2 }}>
|
<Box sx={{ mt: 2 }}>
|
||||||
<AuthorsList videos={videoArray} />
|
<AuthorsList videos={videoArray} />
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ mt: 3, textAlign: 'center' }}>
|
|
||||||
<Button
|
|
||||||
component={Link}
|
|
||||||
to="/manage"
|
|
||||||
variant="outlined"
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
Manage Videos
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Grid>
|
</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 (
|
return (
|
||||||
<Container maxWidth="lg" sx={{ py: 4 }}>
|
<Container maxWidth="xl" sx={{ py: 4 }}>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4 }}>
|
||||||
<Typography variant="h4" component="h1" fontWeight="bold">
|
<Typography variant="h4" component="h1" fontWeight="bold">
|
||||||
Manage Content
|
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();
|
||||||
|
fetchVideo();
|
||||||
}, [id, videos, navigate, isDeleting]);
|
}, [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
|
// Fetch comments
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchComments = async () => {
|
const fetchComments = async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user