feat(main): main
This commit is contained in:
@@ -2,31 +2,82 @@ import React, { useState } from 'react';
|
||||
import ChannelTuner from './components/ChannelTuner';
|
||||
import Guide from './components/Guide';
|
||||
import Settings from './components/Settings';
|
||||
import LoginModal from './components/LoginModal';
|
||||
import AdminSetup from './components/AdminSetup';
|
||||
import { AuthProvider, useAuth } from './AuthContext';
|
||||
import axios from 'axios';
|
||||
|
||||
function App() {
|
||||
function MainApp() {
|
||||
const { user, hasUsers, loading } = useAuth();
|
||||
const [showGuide, setShowGuide] = useState(false);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [needsLogin, setNeedsLogin] = useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Intercept generic axios response to catch 401 globally if not handled locally
|
||||
const interceptor = axios.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
if (error.response?.status === 401) {
|
||||
setNeedsLogin(true);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
return () => axios.interceptors.response.eject(interceptor);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleKey = (e) => {
|
||||
// 'S' key opens settings (when nothing else is open)
|
||||
if (e.key === 's' && !showGuide && !showSettings) {
|
||||
setShowSettings(true);
|
||||
if (loading) return;
|
||||
const key = (e.key || '').toLowerCase();
|
||||
|
||||
// Never intercept keys while the user is typing in a form field.
|
||||
const tag = document.activeElement?.tagName;
|
||||
const isTyping = ['INPUT', 'TEXTAREA', 'SELECT'].includes(tag)
|
||||
|| document.activeElement?.isContentEditable;
|
||||
|
||||
// If Settings is open, Esc/Backspace should only close Settings —
|
||||
// but only when the user is NOT typing in a field inside the modal.
|
||||
if (showSettings && ['escape', 'backspace'].includes(key) && !isTyping) {
|
||||
e.preventDefault();
|
||||
setShowSettings(false);
|
||||
return;
|
||||
}
|
||||
// Escape/Backspace closes whatever is open
|
||||
if (['Escape', 'Backspace'].includes(e.key)) {
|
||||
|
||||
// 'S' key opens settings (when nothing else is open and not typing)
|
||||
if (key === 's' && !showGuide && !showSettings && !isTyping) {
|
||||
e.preventDefault();
|
||||
if (!user) {
|
||||
setNeedsLogin(true);
|
||||
} else {
|
||||
setShowSettings(true);
|
||||
}
|
||||
}
|
||||
// Escape/Backspace closes whatever is open (not while typing)
|
||||
if (['escape', 'backspace'].includes(key) && !isTyping) {
|
||||
e.preventDefault();
|
||||
setShowGuide(false);
|
||||
setShowSettings(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKey);
|
||||
return () => window.removeEventListener('keydown', handleKey);
|
||||
}, [showGuide, showSettings]);
|
||||
}, [showGuide, showSettings, user, loading]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ChannelTuner always stays mounted to preserve buffering */}
|
||||
<ChannelTuner onOpenGuide={() => setShowGuide(!showGuide)} />
|
||||
<ChannelTuner
|
||||
onOpenGuide={() => {
|
||||
// If Settings is open, Back/Escape should only close Settings.
|
||||
if (showSettings) {
|
||||
setShowSettings(false);
|
||||
return;
|
||||
}
|
||||
setShowGuide(prev => !prev);
|
||||
}}
|
||||
/>
|
||||
|
||||
{showGuide && (
|
||||
<Guide
|
||||
@@ -41,14 +92,40 @@ function App() {
|
||||
{!showGuide && !showSettings && (
|
||||
<button
|
||||
className="settings-gear-btn"
|
||||
onClick={() => setShowSettings(true)}
|
||||
title="Settings (S)"
|
||||
onClick={() => {
|
||||
if (loading) return;
|
||||
if (!user) setNeedsLogin(true);
|
||||
else setShowSettings(true);
|
||||
}}
|
||||
title={loading ? 'Checking session...' : 'Settings (S)'}
|
||||
disabled={loading}
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
{!loading && needsLogin && hasUsers && (
|
||||
<LoginModal
|
||||
onSuccess={() => {
|
||||
setNeedsLogin(false);
|
||||
setShowSettings(true); // auto-open settings after login
|
||||
}}
|
||||
onClose={() => setNeedsLogin(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!hasUsers && <AdminSetup />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<MainApp />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
56
frontend/src/AuthContext.jsx
Normal file
56
frontend/src/AuthContext.jsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { fetchCurrentUser, loginUser, logoutUser, checkHasUsers } from './api';
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export const AuthProvider = ({ children }) => {
|
||||
const [user, setUser] = useState(null);
|
||||
const [hasUsers, setHasUsers] = useState(true); // default true to avoid flashing setup screen
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Checks on initial load if we have an active session cookie and if the database is empty
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
fetchCurrentUser().catch(err => {
|
||||
if (err.response?.status !== 401) console.error("Auth check error:", err);
|
||||
return null;
|
||||
}),
|
||||
checkHasUsers().catch(err => {
|
||||
console.error("Failed to check has-users status", err);
|
||||
return { has_users: true };
|
||||
})
|
||||
]).then(([userData, hasUsersData]) => {
|
||||
setUser(userData);
|
||||
setHasUsers(hasUsersData.has_users);
|
||||
}).finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const login = async (username, password) => {
|
||||
const data = await loginUser(username, password);
|
||||
if (data.success && data.user) {
|
||||
setUser(data.user);
|
||||
return data.user;
|
||||
}
|
||||
throw new Error("Invalid response from server");
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await logoutUser();
|
||||
} catch (e) {
|
||||
console.error("Logout error", e);
|
||||
} finally {
|
||||
setUser(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, hasUsers, setHasUsers, login, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = () => useContext(AuthContext);
|
||||
@@ -3,6 +3,17 @@ import axios from 'axios';
|
||||
const apiClient = axios.create({
|
||||
baseURL: '/api',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
withCredentials: true // send session cookies securely
|
||||
});
|
||||
|
||||
// CSRF wrapper for POST/PUT/PATCH/DELETE
|
||||
apiClient.interceptors.request.use(config => {
|
||||
// Try to find the csrftoken cookie Django sets
|
||||
const csrfCookie = document.cookie.split('; ').find(row => row.startsWith('csrftoken='));
|
||||
if (csrfCookie) {
|
||||
config.headers['X-CSRFToken'] = csrfCookie.split('=')[1];
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// ── Channels ──────────────────────────────────────────────────────────────
|
||||
@@ -11,6 +22,13 @@ export const createChannel = async (payload) => (await apiClient.post('/channel/
|
||||
export const updateChannel = async (id, payload) => (await apiClient.patch(`/channel/${id}`, payload)).data;
|
||||
export const deleteChannel = async (id) => { await apiClient.delete(`/channel/${id}`); };
|
||||
|
||||
// ── Authentication ──────────────────────────────────────────────────────────
|
||||
export const checkHasUsers = async () => (await apiClient.get('/auth/has-users')).data;
|
||||
export const setupAdmin = async (payload) => (await apiClient.post('/auth/setup', payload)).data;
|
||||
export const loginUser = async (username, password) => (await apiClient.post('/auth/login', { username, password })).data;
|
||||
export const logoutUser = async () => (await apiClient.post('/auth/logout')).data;
|
||||
export const fetchCurrentUser = async () => (await apiClient.get('/auth/me')).data;
|
||||
|
||||
// Channel program data
|
||||
export const fetchChannelNow = async (channelId) =>
|
||||
(await apiClient.get(`/channel/${channelId}/now`)).data;
|
||||
@@ -77,3 +95,27 @@ export const fetchUsers = async () => (await apiClient.get('/user/')).data;
|
||||
export const createUser = async (payload) => (await apiClient.post('/user/', payload)).data;
|
||||
export const updateUser = async (id, payload) => (await apiClient.patch(`/user/${id}`, payload)).data;
|
||||
export const deleteUser = async (id) => { await apiClient.delete(`/user/${id}`); };
|
||||
|
||||
// -- Backups ----------------------------------------------------------------
|
||||
export const fetchBackupSchedule = async () => (await apiClient.get('/backups/settings')).data;
|
||||
export const updateBackupSchedule = async (payload) => (await apiClient.put('/backups/settings', payload)).data;
|
||||
export const runBackupNow = async () => (await apiClient.post('/backups/run')).data;
|
||||
export const fetchBackups = async () => (await apiClient.get('/backups/')).data;
|
||||
|
||||
export const downloadBackup = async (filename) => {
|
||||
const response = await apiClient.get(`/backups/${encodeURIComponent(filename)}/download`, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const importBackup = async (file, mode = 'append') => {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
return (
|
||||
await apiClient.post(`/backups/import?mode=${mode}`, form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
).data;
|
||||
};
|
||||
|
||||
|
||||
84
frontend/src/components/AdminSetup.jsx
Normal file
84
frontend/src/components/AdminSetup.jsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import React, { useState } from 'react';
|
||||
import { setupAdmin, loginUser } from '../api';
|
||||
import { useAuth } from '../AuthContext';
|
||||
import './LoginModal.css';
|
||||
|
||||
const AdminSetup = () => {
|
||||
const { setHasUsers, login } = useAuth();
|
||||
const [username, setUsername] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await setupAdmin({ username, email, password });
|
||||
|
||||
// Auto-login after setup success (the backend already logs the session in,
|
||||
// but fetchCurrentUser wasn't called. Setup router returns the user.)
|
||||
// Alternatively, we can force a manual login to populate the frontend context.
|
||||
await login(username, password);
|
||||
|
||||
setHasUsers(true);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || "Setup failed. The database might not be empty.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-modal-overlay">
|
||||
<div className="login-modal">
|
||||
<h2>Welcome to PYTV</h2>
|
||||
<p className="login-description">No users exist in the database. Please create an administrator account to begin.</p>
|
||||
|
||||
{error && <div className="login-error">{error}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit} className="login-form">
|
||||
<label>
|
||||
Admin Username
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Email
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="login-actions">
|
||||
<button type="submit" className="btn-primary" disabled={loading}>
|
||||
{loading ? 'Creating...' : 'Create Admin Account'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminSetup;
|
||||
117
frontend/src/components/LoginModal.css
Normal file
117
frontend/src/components/LoginModal.css
Normal file
@@ -0,0 +1,117 @@
|
||||
.login-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.login-modal {
|
||||
background: #1e1e24;
|
||||
padding: 2rem;
|
||||
border-radius: 12px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.5);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.login-modal h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-description {
|
||||
color: #a0a0b0;
|
||||
font-size: 0.95rem;
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.login-error {
|
||||
background: rgba(255, 60, 60, 0.1);
|
||||
color: #ff6b6b;
|
||||
border: 1px solid rgba(255, 60, 60, 0.3);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.login-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.login-form input {
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #333;
|
||||
background: #111;
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.login-form input:focus {
|
||||
outline: none;
|
||||
border-color: #4a90e2;
|
||||
}
|
||||
|
||||
.login-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.login-actions button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
background: transparent;
|
||||
color: #a0a0b0;
|
||||
}
|
||||
|
||||
.btn-cancel:hover {
|
||||
background: rgba(255,255,255,0.05);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #4a90e2;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #357abd;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
73
frontend/src/components/LoginModal.jsx
Normal file
73
frontend/src/components/LoginModal.jsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../AuthContext';
|
||||
import './LoginModal.css';
|
||||
|
||||
const LoginModal = ({ onClose, onSuccess }) => {
|
||||
const { login } = useAuth();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await login(username, password);
|
||||
onSuccess?.();
|
||||
onClose?.();
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || "Invalid credentials or network error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-modal-overlay">
|
||||
<div className="login-modal">
|
||||
<h2>Sign In Required</h2>
|
||||
<p className="login-description">This channel requires authentication to stream.</p>
|
||||
|
||||
{error && <div className="login-error">{error}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit} className="login-form">
|
||||
<label>
|
||||
Username
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="login-actions">
|
||||
{onClose && (
|
||||
<button type="button" onClick={onClose} className="btn-cancel">
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
<button type="submit" className="btn-primary" disabled={loading}>
|
||||
{loading ? 'Signing In...' : 'Sign In'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginModal;
|
||||
@@ -9,23 +9,27 @@ import {
|
||||
fetchLibraries, fetchCollections,
|
||||
fetchDownloadStatus, triggerCacheUpcoming, downloadItem, fetchDownloadProgress,
|
||||
fetchChannelStatus, triggerChannelDownload,
|
||||
fetchBackupSchedule, updateBackupSchedule, runBackupNow,
|
||||
fetchBackups, downloadBackup, importBackup,
|
||||
} from '../api';
|
||||
import { useAuth } from '../AuthContext';
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────
|
||||
|
||||
const TABS = [
|
||||
{ id: 'channels', label: '📺 Channels' },
|
||||
{ id: 'sources', label: '📡 Sources' },
|
||||
{ id: 'downloads', label: '⬇ Downloads' },
|
||||
{ id: 'schedule', label: '📅 Scheduling' },
|
||||
{ id: 'users', label: '👤 Users' },
|
||||
{ id: 'channels', label: 'Channels' },
|
||||
{ id: 'sources', label: 'Sources' },
|
||||
{ id: 'downloads', label: 'Downloads' },
|
||||
{ id: 'schedule', label: 'Schedule' },
|
||||
{ id: 'backups', label: 'Backups' },
|
||||
{ id: 'users', label: 'Users' },
|
||||
];
|
||||
|
||||
const SOURCE_TYPE_OPTIONS = [
|
||||
{ value: 'youtube_channel', label: '▶ YouTube Channel' },
|
||||
{ value: 'youtube_playlist', label: '▶ YouTube Playlist' },
|
||||
{ value: 'local_directory', label: '📁 Local Directory' },
|
||||
{ value: 'stream', label: '📡 Live Stream' },
|
||||
{ value: 'youtube_channel', label: 'YouTube Channel' },
|
||||
{ value: 'youtube_playlist', label: 'YouTube Playlist' },
|
||||
{ value: 'local_directory', label: 'Local Directory' },
|
||||
{ value: 'stream', label: 'Live Stream' },
|
||||
];
|
||||
|
||||
const RULE_MODE_OPTIONS = [
|
||||
@@ -167,7 +171,7 @@ function ChannelsTab() {
|
||||
const [channelSources, setChannelSources] = useState({}); // { channelId: [rules] }
|
||||
const [channelStatuses, setChannelStatuses] = useState({}); // { channelId: statusData }
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [form, setForm] = useState({ name: '', slug: '', channel_number: '', description: '', library_id: '', owner_user_id: '' });
|
||||
const [form, setForm] = useState({ name: '', slug: '', channel_number: '', description: '', library_id: '', owner_user_id: '', requires_auth: false });
|
||||
const [assignForm, setAssignForm] = useState({ source_id: '', rule_mode: 'allow', weight: 1.0, schedule_block_label: '' });
|
||||
const [syncingId, setSyncingId] = useState(null);
|
||||
const [downloadingId, setDownloadingId] = useState(null);
|
||||
@@ -217,10 +221,11 @@ function ChannelsTab() {
|
||||
channel_number: form.channel_number ? parseInt(form.channel_number) : undefined,
|
||||
library_id: parseInt(form.library_id),
|
||||
owner_user_id: parseInt(form.owner_user_id),
|
||||
requires_auth: form.requires_auth,
|
||||
});
|
||||
setChannels(c => [...c, ch]);
|
||||
setShowForm(false);
|
||||
setForm({ name: '', slug: '', channel_number: '', description: '', library_id: '', owner_user_id: '' });
|
||||
setForm({ name: '', slug: '', channel_number: '', description: '', library_id: '', owner_user_id: '', requires_auth: false });
|
||||
ok(`Channel "${ch.name}" created.`);
|
||||
} catch { err('Failed to create channel. Check slug is unique.'); }
|
||||
};
|
||||
@@ -286,6 +291,14 @@ function ChannelsTab() {
|
||||
} catch { err('Failed to update fallback collection.'); }
|
||||
};
|
||||
|
||||
const handleToggleAuth = async (ch) => {
|
||||
try {
|
||||
const updated = await updateChannel(ch.id, { requires_auth: !ch.requires_auth });
|
||||
setChannels(cs => cs.map(c => c.id === updated.id ? updated : c));
|
||||
ok(updated.requires_auth ? 'Authentication enabled.' : 'Authentication disabled.');
|
||||
} catch { err('Failed to update auth requirement.'); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tab-content">
|
||||
<Feedback fb={feedback} clear={() => setFeedback(null)} />
|
||||
@@ -317,7 +330,14 @@ function ChannelsTab() {
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label>Description<input value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} /></label>
|
||||
<div className="form-row">
|
||||
<label>Description<input value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} /></label>
|
||||
<label className="checkbox-label" style={{ alignSelf: 'flex-end', marginBottom: '0.75rem' }}>
|
||||
<input type="checkbox" checked={form.requires_auth} onChange={e => setForm(f => ({ ...f, requires_auth: e.target.checked }))} />
|
||||
Require Sign-In
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn-accent">Create Channel</button>
|
||||
</form>
|
||||
)}
|
||||
@@ -337,6 +357,7 @@ function ChannelsTab() {
|
||||
<strong>{ch.name}</strong>
|
||||
<span className="row-sub">{ch.slug} · {ch.scheduling_mode}</span>
|
||||
<span className="row-badges">
|
||||
{ch.requires_auth && <span className="badge badge-error">🔒 Private</span>}
|
||||
{!hasRules && isExpanded === false && channelSources[ch.id] !== undefined && (
|
||||
<span className="badge badge-warn" style={{ marginLeft: '0.2rem' }}>⚠️ Fallback Library Mode</span>
|
||||
)}
|
||||
@@ -370,29 +391,29 @@ function ChannelsTab() {
|
||||
|
||||
{/* ─── Channel Status ──────────────────────────────────── */}
|
||||
{channelStatuses[ch.id] && (
|
||||
<div style={{ marginBottom: '1.25rem', padding: '0.75rem', background: 'rgba(59, 130, 246, 0.1)', border: '1px solid rgba(59, 130, 246, 0.3)', borderRadius: '6px' }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: '0.4rem', color: '#60a5fa' }}>Schedule Status (Next 24 Hours)</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.5rem', fontSize: '0.9rem' }}>
|
||||
<div className="expand-card is-info">
|
||||
<div className="expand-card-title is-info">Schedule Status — Next 24 Hours</div>
|
||||
<div className="expand-card-grid">
|
||||
<span><strong>Total Upcoming:</strong> {channelStatuses[ch.id].total_upcoming_airings}</span>
|
||||
<span><strong>Cached:</strong> {channelStatuses[ch.id].total_cached_airings} ({Math.round(channelStatuses[ch.id].percent_cached)}%)</span>
|
||||
</div>
|
||||
{channelStatuses[ch.id].missing_items?.length > 0 && (
|
||||
<div style={{ marginTop: '0.75rem', fontSize: '0.8rem', opacity: 0.8 }}>
|
||||
<strong>Missing Downloads:</strong> {channelStatuses[ch.id].missing_items.slice(0, 3).map(i => `[${i.source_name}] ${i.title}`).join(', ')}
|
||||
<p>
|
||||
<strong>Missing:</strong>{' '}
|
||||
{channelStatuses[ch.id].missing_items.slice(0, 3).map(i => i.title).join(', ')}
|
||||
{channelStatuses[ch.id].missing_items.length > 3 ? ` +${channelStatuses[ch.id].missing_items.length - 3} more` : ''}
|
||||
</div>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Fallback block selector ───────────────────────── */}
|
||||
<div style={{ marginBottom: '1.25rem', padding: '0.75rem', background: 'rgba(239,68,68,0.1)', border: '1px solid rgba(239,68,68,0.3)', borderRadius: '6px' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', fontSize: '0.9rem' }}>
|
||||
<span style={{ whiteSpace: 'nowrap', fontWeight: 600 }}>⛔ Error Fallback Collection</span>
|
||||
<div className="expand-card is-warn">
|
||||
<div className="expand-card-title is-warn">Error Fallback Collection</div>
|
||||
<label>
|
||||
<select
|
||||
value={ch.fallback_collection_id ?? ''}
|
||||
onChange={e => handleSetFallback(ch, e.target.value)}
|
||||
style={{ flex: 1 }}
|
||||
title="When scheduled programming cannot play, items from this collection will air instead."
|
||||
>
|
||||
<option value="">— None (use block sources) —</option>
|
||||
@@ -402,9 +423,20 @@ function ChannelsTab() {
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<p style={{ margin: '0.4rem 0 0 0', fontSize: '0.78rem', opacity: 0.65 }}>
|
||||
Items in this collection will air when a scheduled video cannot be played (missing file). If none is set, the scheduler picks a safe video from the block's normal sources.
|
||||
</p>
|
||||
<p>Items from this collection air when a scheduled video cannot be played. If none is set, the scheduler picks a safe video from the block's normal sources.</p>
|
||||
</div>
|
||||
|
||||
{/* ─── Security Settings ────────────────────────────── */}
|
||||
<div className="expand-card">
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={ch.requires_auth}
|
||||
onChange={() => handleToggleAuth(ch)}
|
||||
/>
|
||||
Require Sign-In to Stream
|
||||
</label>
|
||||
<p>If enabled, viewers must be logged in to watch or see the schedule for this channel.</p>
|
||||
</div>
|
||||
|
||||
<h4 className="expand-section-title">Assigned Sources</h4>
|
||||
@@ -426,9 +458,8 @@ function ChannelsTab() {
|
||||
))}
|
||||
|
||||
{/* Assign form */}
|
||||
<div className="assign-form" style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginTop: '1rem' }}>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||
<div className="assign-form">
|
||||
<div className="assign-form-row">
|
||||
<select
|
||||
value={assignForm.source_id}
|
||||
onChange={e => setAssignForm(f => ({ ...f, source_id: e.target.value }))}
|
||||
@@ -437,23 +468,21 @@ function ChannelsTab() {
|
||||
<option value="">— Select source —</option>
|
||||
{sources.map(s => <option key={s.id} value={s.id}>{s.name} ({s.source_type})</option>)}
|
||||
</select>
|
||||
|
||||
<button
|
||||
className="btn-accent"
|
||||
<button
|
||||
className="btn-accent"
|
||||
onClick={() => {
|
||||
if (!assignForm.source_id) { err('Select a source first.'); return; }
|
||||
setAssignForm(f => ({ ...f, rule_mode: 'prefer', weight: 10.0 }));
|
||||
// We wait for re-render state update before submit
|
||||
setTimeout(() => handleAssign(ch.id), 0);
|
||||
}}
|
||||
title="Quick add as heavily preferred source to ensure it dominates schedule"
|
||||
title="Add as heavily preferred source to dominate scheduling"
|
||||
>
|
||||
★ Set as Primary Source
|
||||
Set as Primary
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', opacity: 0.8, fontSize: '0.9rem' }}>
|
||||
<span>Or custom rule:</span>
|
||||
<div className="assign-form-hint">
|
||||
<span>Custom rule:</span>
|
||||
<select
|
||||
value={assignForm.rule_mode}
|
||||
onChange={e => setAssignForm(f => ({ ...f, rule_mode: e.target.value }))}
|
||||
@@ -464,16 +493,16 @@ function ChannelsTab() {
|
||||
type="number" min="0.1" max="10" step="0.1"
|
||||
value={assignForm.weight}
|
||||
onChange={e => setAssignForm(f => ({ ...f, weight: e.target.value }))}
|
||||
style={{ width: 60 }}
|
||||
style={{ width: 56 }}
|
||||
title="Weight (higher = more airings)"
|
||||
/>
|
||||
<select
|
||||
value={assignForm.schedule_block_label}
|
||||
onChange={e => setAssignForm(f => ({ ...f, schedule_block_label: e.target.value }))}
|
||||
style={{ flex: 1 }}
|
||||
title="If set, this source will ONLY play during blocks with this exact name. Leaving it empty applies to all blocks."
|
||||
title="Restrict to a named block, or leave empty for all blocks"
|
||||
>
|
||||
<option value="">— Any Time (Default) —</option>
|
||||
<option value="">— Any block —</option>
|
||||
{Array.from(new Set(
|
||||
templates.filter(t => t.channel_id === ch.id)
|
||||
.flatMap(t => (templateBlocks[t.id] || []).map(b => b.name))
|
||||
@@ -481,7 +510,7 @@ function ChannelsTab() {
|
||||
<option key={name} value={name}>{name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="btn-sync sm" onClick={() => handleAssign(ch.id)}>+ Add Rule</button>
|
||||
<button className="btn-sync sm" onClick={() => handleAssign(ch.id)}>+ Add</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -582,6 +611,13 @@ function SourcesTab() {
|
||||
};
|
||||
|
||||
const isYT = (src) => src.source_type.startsWith('youtube');
|
||||
const sourceAvatar = (src) => {
|
||||
if (src.source_type === 'youtube_channel') return 'YTC';
|
||||
if (src.source_type === 'youtube_playlist') return 'YTP';
|
||||
if (src.source_type === 'local_directory') return 'DIR';
|
||||
if (src.source_type === 'stream') return 'STR';
|
||||
return 'SRC';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tab-content">
|
||||
@@ -663,7 +699,7 @@ function SourcesTab() {
|
||||
const synced = src.last_scanned_at;
|
||||
return (
|
||||
<div key={src.id} className="settings-row">
|
||||
<div className="row-avatar">{isYT(src) ? '▶' : '📁'}</div>
|
||||
<div className={`row-avatar${isYT(src) ? ' is-accent' : ''}`}>{sourceAvatar(src)}</div>
|
||||
<div className="row-info">
|
||||
<strong>{src.name}</strong>
|
||||
<span className="row-sub">{src.uri}</span>
|
||||
@@ -842,11 +878,11 @@ function DownloadsTab() {
|
||||
{!loading && visibleItems.length === 0 && (
|
||||
<EmptyState text="No YouTube videos found. Sync a source first." />
|
||||
)}
|
||||
{visibleItems.map(item => (
|
||||
<div key={item.id} className={`settings-row download-item-row ${item.cached ? 'is-cached' : ''}`}>
|
||||
<div className="row-avatar" style={{ fontSize: '0.8rem', background: item.cached ? 'rgba(34,197,94,0.12)' : 'rgba(255,255,255,0.05)', borderColor: item.cached ? 'rgba(34,197,94,0.3)' : 'var(--pytv-glass-border)', color: item.cached ? '#86efac' : 'var(--pytv-text-dim)' }}>
|
||||
{item.cached ? '✓' : '▶'}
|
||||
</div>
|
||||
{visibleItems.map(item => (
|
||||
<div key={item.id} className={`settings-row download-item-row ${item.cached ? 'is-cached' : ''}`}>
|
||||
<div className={`row-avatar${item.cached ? ' is-accent' : ''}`}>
|
||||
{item.cached ? '✓' : 'YT'}
|
||||
</div>
|
||||
<div className="row-info">
|
||||
<strong>{item.title}</strong>
|
||||
<span className="row-sub">{item.source_name} · {item.runtime_seconds}s</span>
|
||||
@@ -949,7 +985,7 @@ function SchedulingTab() {
|
||||
let total = 0;
|
||||
for (const id of chIds) {
|
||||
try { const r = await generateScheduleToday(id); total += r.airings_created; }
|
||||
catch {}
|
||||
catch (e) { console.warn('Failed to generate schedule for channel', id, e); }
|
||||
}
|
||||
ok(`Generated today's schedule: ${total} total airings created.`);
|
||||
};
|
||||
@@ -962,8 +998,8 @@ function SchedulingTab() {
|
||||
|
||||
<div className="settings-section-title">
|
||||
<h3>Schedule Templates</h3>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<button className="btn-sync" onClick={handleGenerateAll}>▶ Generate All Today</button>
|
||||
<div className="row-actions">
|
||||
<button className="btn-sync" onClick={handleGenerateAll}>Generate All Today</button>
|
||||
<button className="btn-accent" onClick={() => setShowForm(f => !f)}>
|
||||
{showForm ? '— Cancel' : '+ New Template'}
|
||||
</button>
|
||||
@@ -1009,7 +1045,7 @@ function SchedulingTab() {
|
||||
return (
|
||||
<div key={t.id} className={`settings-row-expandable ${isExpanded ? 'expanded' : ''}`}>
|
||||
<div className="settings-row" onClick={() => toggleExpand(t)}>
|
||||
<div className="row-avatar" style={{ fontSize: '1.2rem' }}>📄</div>
|
||||
<div className="row-avatar">TPL</div>
|
||||
<div className="row-info">
|
||||
<strong>{t.name}</strong>
|
||||
<span className="row-sub">{channelName(t.channel_id)} · {t.timezone_name}</span>
|
||||
@@ -1028,34 +1064,34 @@ function SchedulingTab() {
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="channel-expand-panel block-editor" style={{ background: 'rgba(0,0,0,0.1)', borderTop: 'none', padding: '1rem', borderBottomLeftRadius: '6px', borderBottomRightRadius: '6px' }}>
|
||||
<h4 style={{ margin: '0 0 1rem 0', opacity: 0.9 }}>Schedule Blocks</h4>
|
||||
|
||||
<div className="channel-expand-panel block-editor">
|
||||
<h4 className="expand-section-title">Schedule Blocks</h4>
|
||||
|
||||
{blocks.length === 0 && (
|
||||
<div style={{ fontSize: '0.9rem', opacity: 0.7, marginBottom: '1rem' }}>
|
||||
No blocks defined. By default, PYTV acts as if there is a single 24/7 block. If you define blocks here, you must completely cover the 24 hours of a day to avoid dead air.
|
||||
</div>
|
||||
<p style={{ fontSize: '0.82rem', color: 'var(--pytv-text-dim)', marginBottom: '0.5rem' }}>
|
||||
No blocks defined. By default PYTV uses a single 24/7 block. Define blocks here to create specific programming windows.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{blocks.length > 0 && (
|
||||
<div style={{ position: 'relative', width: '100%', height: '32px', background: '#2f3640', borderRadius: '4px', marginBottom: '1.5rem', overflow: 'hidden', border: '1px solid var(--pytv-glass-border)' }}>
|
||||
{/* Timeline tick marks */}
|
||||
<div className="block-timeline">
|
||||
{[0, 6, 12, 18].map(h => (
|
||||
<div key={h} style={{ position: 'absolute', left: `${(h/24)*100}%`, height: '100%', borderLeft: '1px dashed rgba(255,255,255,0.2)', pointerEvents: 'none', paddingLeft: '2px', fontSize: '0.65rem', color: 'rgba(255,255,255,0.4)', paddingTop: '2px' }}>
|
||||
<div key={h} className="block-timeline-tick" style={{ left: `${(h / 24) * 100}%` }}>
|
||||
{h}:00
|
||||
</div>
|
||||
))}
|
||||
{blocks.map(b => {
|
||||
const startPct = timeToPct(b.start_local_time);
|
||||
let endPct = timeToPct(b.end_local_time);
|
||||
if (endPct <= startPct) endPct = 100; // spills past midnight visually
|
||||
if (endPct <= startPct) endPct = 100;
|
||||
const width = endPct - startPct;
|
||||
const color = b.block_type === 'OFF_AIR' ? 'rgba(248, 113, 113, 0.8)' : 'rgba(96, 165, 250, 0.8)'; // red vs blue
|
||||
const cls = b.block_type === 'OFF_AIR' ? 'is-off-air' : 'is-programming';
|
||||
return (
|
||||
<div
|
||||
key={`vis-${b.id}`}
|
||||
style={{ position: 'absolute', left: `${startPct}%`, width: `${width}%`, height: '100%', background: color, borderRight: '1px solid rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '0.75rem', color: '#fff', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', padding: '0 4px' }}
|
||||
title={`${b.name} (${b.start_local_time.slice(0,5)} - ${b.end_local_time.slice(0,5)}) Rating: ${b.target_content_rating || 'Any'}`}
|
||||
<div
|
||||
key={`vis-${b.id}`}
|
||||
className={`block-timeline-segment ${cls}`}
|
||||
style={{ left: `${startPct}%`, width: `${width}%` }}
|
||||
title={`${b.name} (${b.start_local_time.slice(0,5)}–${b.end_local_time.slice(0,5)})`}
|
||||
>
|
||||
{width > 8 ? b.name : ''}
|
||||
</div>
|
||||
@@ -1064,13 +1100,13 @@ function SchedulingTab() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginBottom: '1.5rem' }}>
|
||||
<div className="block-list">
|
||||
{blocks.map(b => (
|
||||
<div key={b.id} style={{ display: 'flex', gap: '0.5rem', background: '#353b48', padding: '0.5rem', borderRadius: '4px', alignItems: 'center', fontSize: '0.9rem' }}>
|
||||
<strong style={{ minWidth: 100 }}>{b.name}</strong>
|
||||
<span style={{ fontFamily: 'monospace', opacity: 0.8 }}>{b.start_local_time.slice(0,5)} - {b.end_local_time.slice(0,5)}</span>
|
||||
<div key={b.id} className="block-row">
|
||||
<span className="block-row-name">{b.name}</span>
|
||||
<span className="block-row-time">{b.start_local_time.slice(0,5)} – {b.end_local_time.slice(0,5)}</span>
|
||||
<span className={`badge ${b.block_type === 'OFF_AIR' ? 'badge-warn' : 'badge-ok'}`}>{b.block_type}</span>
|
||||
{b.target_content_rating && <span className="badge badge-type">Rating Tier: {b.target_content_rating}</span>}
|
||||
{b.target_content_rating && <span className="badge badge-type">Rating {b.target_content_rating}</span>}
|
||||
<div style={{ flex: 1 }} />
|
||||
<IconBtn icon="✕" kind="danger" onClick={async () => {
|
||||
try {
|
||||
@@ -1083,9 +1119,9 @@ function SchedulingTab() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form className="assign-form" style={{ background: '#2f3640' }} onSubmit={async (e) => {
|
||||
<form className="block-add-form" onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
if (!blockForm.name || !blockForm.start_local_time || !blockForm.end_local_time) { err('Fill req fields'); return; }
|
||||
if (!blockForm.name || !blockForm.start_local_time || !blockForm.end_local_time) { err('Fill required fields'); return; }
|
||||
try {
|
||||
const nb = await createTemplateBlock({
|
||||
schedule_template_id: t.id,
|
||||
@@ -1100,19 +1136,19 @@ function SchedulingTab() {
|
||||
ok('Block created.');
|
||||
} catch { err('Failed to create block'); }
|
||||
}}>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||
<input placeholder="Block Name (e.g. Morning News)" required style={{ flex: 1 }} value={blockForm.name} onChange={e => setBlockForm(f => ({...f, name: e.target.value}))} />
|
||||
<div className="block-add-row">
|
||||
<input placeholder="Block name" required style={{ flex: 1 }} value={blockForm.name} onChange={e => setBlockForm(f => ({...f, name: e.target.value}))} />
|
||||
<select value={blockForm.block_type} onChange={e => setBlockForm(f => ({...f, block_type: e.target.value}))}>
|
||||
<option value="PROGRAM">Programming</option>
|
||||
<option value="OFF_AIR">Off Air / Dead Time</option>
|
||||
<option value="OFF_AIR">Off Air</option>
|
||||
</select>
|
||||
<input type="time" required value={blockForm.start_local_time} onChange={e => setBlockForm(f => ({...f, start_local_time: e.target.value}))} />
|
||||
<span style={{ opacity: 0.5 }}>to</span>
|
||||
<input type="time" required value={blockForm.end_local_time} onChange={e => setBlockForm(f => ({...f, end_local_time: e.target.value}))} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', marginTop: '0.5rem' }}>
|
||||
<div className="block-add-row">
|
||||
<input type="time" required value={blockForm.start_local_time} onChange={e => setBlockForm(f => ({...f, start_local_time: e.target.value}))} />
|
||||
<span className="block-time-sep">to</span>
|
||||
<input type="time" required value={blockForm.end_local_time} onChange={e => setBlockForm(f => ({...f, end_local_time: e.target.value}))} />
|
||||
<select value={blockForm.target_content_rating} onChange={e => setBlockForm(f => ({...f, target_content_rating: e.target.value}))}>
|
||||
<option value="">Any content rating</option>
|
||||
<option value="">Any rating</option>
|
||||
<option value="1">TV-Y</option>
|
||||
<option value="2">TV-Y7</option>
|
||||
<option value="3">TV-G</option>
|
||||
@@ -1134,10 +1170,217 @@ function SchedulingTab() {
|
||||
);
|
||||
}
|
||||
|
||||
function BackupsTab() {
|
||||
const [schedule, setSchedule] = useState(null);
|
||||
const [files, setFiles] = useState([]);
|
||||
const [importMode, setImportMode] = useState('append');
|
||||
const [importFile, setImportFile] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [feedback, setFeedback, ok, err] = useFeedback();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [sched, backups] = await Promise.all([fetchBackupSchedule(), fetchBackups()]);
|
||||
setSchedule(sched);
|
||||
setFiles(backups);
|
||||
} catch {
|
||||
err('Failed to load backup settings.');
|
||||
}
|
||||
}, [err]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const saveSchedule = async () => {
|
||||
if (!schedule) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const updated = await updateBackupSchedule(schedule);
|
||||
setSchedule(updated);
|
||||
ok('Backup schedule saved.');
|
||||
} catch {
|
||||
err('Failed to save backup schedule.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackupNow = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await runBackupNow();
|
||||
ok(`Backup created: ${result.filename}`);
|
||||
await load();
|
||||
} catch {
|
||||
err('Backup failed.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async (filename) => {
|
||||
try {
|
||||
const blob = await downloadBackup(filename);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
err('Failed to download backup.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!importFile) {
|
||||
err('Choose a backup JSON file first.');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await importBackup(importFile, importMode);
|
||||
ok(`Import complete (${result.mode}): ${result.created} created, ${result.updated} updated.`);
|
||||
await load();
|
||||
} catch {
|
||||
err('Import failed.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tab-content">
|
||||
<Feedback fb={feedback} clear={() => setFeedback(null)} />
|
||||
{!schedule && <EmptyState text="Loading backup settings..." />}
|
||||
{schedule && (
|
||||
<>
|
||||
<div className="settings-section-title"><h3>Backup Schedule</h3></div>
|
||||
<div className="settings-form">
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={schedule.enabled}
|
||||
onChange={e => setSchedule(s => ({ ...s, enabled: e.target.checked }))}
|
||||
/>
|
||||
Enable automatic backups
|
||||
</label>
|
||||
<div className="form-row">
|
||||
<label>Frequency
|
||||
<select value={schedule.frequency} onChange={e => setSchedule(s => ({ ...s, frequency: e.target.value }))}>
|
||||
<option value="hourly">Hourly</option>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Time (24h)
|
||||
<input
|
||||
type="time"
|
||||
value={`${String(schedule.hour).padStart(2, '0')}:${String(schedule.minute).padStart(2, '0')}`}
|
||||
onChange={e => {
|
||||
const [h, m] = e.target.value.split(':').map(Number);
|
||||
setSchedule(s => ({ ...s, hour: h, minute: m }));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{schedule.frequency === 'weekly' && (
|
||||
<label>Day of Week
|
||||
<select value={schedule.day_of_week} onChange={e => setSchedule(s => ({ ...s, day_of_week: parseInt(e.target.value) }))}>
|
||||
<option value={0}>Monday</option>
|
||||
<option value={1}>Tuesday</option>
|
||||
<option value={2}>Wednesday</option>
|
||||
<option value={3}>Thursday</option>
|
||||
<option value={4}>Friday</option>
|
||||
<option value={5}>Saturday</option>
|
||||
<option value={6}>Sunday</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{schedule.frequency === 'monthly' && (
|
||||
<label>Day of Month
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={28}
|
||||
value={schedule.day_of_month}
|
||||
onChange={e => setSchedule(s => ({ ...s, day_of_month: parseInt(e.target.value || '1') }))}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label>Retention (number of backup files to keep)
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={schedule.retention_count}
|
||||
onChange={e => setSchedule(s => ({ ...s, retention_count: parseInt(e.target.value || '1') }))}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="form-actions">
|
||||
<button className="btn-accent" onClick={saveSchedule} disabled={busy}>Save Schedule</button>
|
||||
<button className="btn-sync" onClick={handleBackupNow} disabled={busy}>Backup Now</button>
|
||||
</div>
|
||||
<p className="settings-empty" style={{ marginTop: '0.4rem' }}>
|
||||
Backups are always stored on server path <code>/Backups</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="settings-section-title"><h3>Import Backup</h3></div>
|
||||
<div className="settings-form">
|
||||
<label>Backup File (JSON)
|
||||
<input type="file" accept="application/json,.json" onChange={e => setImportFile(e.target.files?.[0] || null)} />
|
||||
</label>
|
||||
<label>Import Mode
|
||||
<select value={importMode} onChange={e => setImportMode(e.target.value)}>
|
||||
<option value="append">Append (merge into existing DB)</option>
|
||||
<option value="override">Override (replace existing DB)</option>
|
||||
</select>
|
||||
</label>
|
||||
<button className="btn-accent" onClick={handleImport} disabled={busy || !importFile}>Import Backup</button>
|
||||
</div>
|
||||
|
||||
<div className="settings-section-title"><h3>Available Backups</h3></div>
|
||||
<div className="settings-row-list">
|
||||
{files.length === 0 && <EmptyState text="No backups found." />}
|
||||
{files.map(file => (
|
||||
<div key={file.filename} className="settings-row">
|
||||
<div className="row-avatar">BK</div>
|
||||
<div className="row-info">
|
||||
<strong>{file.filename}</strong>
|
||||
<span className="row-sub">{Math.round(file.size_bytes / 1024)} KB · {new Date(file.created_at).toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="row-actions">
|
||||
<button className="btn-sync sm" onClick={() => handleDownload(file.filename)}>Download</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Root Settings Component ──────────────────────────────────────────────
|
||||
|
||||
export default function Settings({ onClose }) {
|
||||
const [activeTab, setActiveTab] = useState('channels');
|
||||
const { user, logout } = useAuth();
|
||||
const [loggingOut, setLoggingOut] = useState(false);
|
||||
|
||||
const handleLogout = async () => {
|
||||
setLoggingOut(true);
|
||||
try {
|
||||
await logout();
|
||||
onClose();
|
||||
} finally {
|
||||
setLoggingOut(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="settings-overlay">
|
||||
@@ -1146,6 +1389,11 @@ export default function Settings({ onClose }) {
|
||||
<div className="settings-header">
|
||||
<span className="settings-logo">PYTV</span>
|
||||
<h2>Settings</h2>
|
||||
{user && (
|
||||
<button className="btn-accent" onClick={handleLogout} disabled={loggingOut}>
|
||||
{loggingOut ? 'Logging out...' : 'Logout'}
|
||||
</button>
|
||||
)}
|
||||
<button className="settings-close-btn" onClick={onClose}>✕ Close</button>
|
||||
</div>
|
||||
|
||||
@@ -1168,6 +1416,7 @@ export default function Settings({ onClose }) {
|
||||
{activeTab === 'sources' && <SourcesTab />}
|
||||
{activeTab === 'downloads' && <DownloadsTab />}
|
||||
{activeTab === 'schedule' && <SchedulingTab />}
|
||||
{activeTab === 'backups' && <BackupsTab />}
|
||||
{activeTab === 'users' && <UsersTab />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user