feat(main): main

This commit is contained in:
2026-03-20 15:00:24 -04:00
parent af3076342a
commit c9718c5483
30 changed files with 2513 additions and 559 deletions

View File

@@ -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;

View 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);

View File

@@ -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;
};

View 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;

View 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;
}

View 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;

View File

@@ -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