Merge branch 'mantine' of https://github.com/Dispatcharr/Dispatcharr into Proxy-Redis

This commit is contained in:
SergeantPanda 2025-03-12 16:49:33 -05:00
commit b300ee5bcf
113 changed files with 7430 additions and 21597 deletions

View file

@ -1,151 +0,0 @@
// frontend/src/App.js
import React, { useEffect, useState } from 'react';
import axios from 'axios';
import {
BrowserRouter as Router,
Route,
Routes,
Navigate,
} from 'react-router-dom';
import Sidebar from './components/Sidebar';
import Login from './pages/Login';
import Channels from './pages/Channels';
import M3U from './pages/M3U';
import { ThemeProvider } from '@mui/material/styles';
import { Box, CssBaseline, GlobalStyles } from '@mui/material';
import theme from './theme';
import EPG from './pages/EPG';
import Guide from './pages/Guide';
import Settings from './pages/Settings';
import StreamProfiles from './pages/StreamProfiles';
import useAuthStore from './store/auth';
import Alert from './components/Alert';
import FloatingVideo from './components/FloatingVideo';
import SuperuserForm from './components/forms/SuperuserForm';
import { WebsocketProvider } from './WebSocket';
const drawerWidth = 240;
const miniDrawerWidth = 60;
const defaultRoute = '/channels';
const App = () => {
const [open, setOpen] = useState(true);
const [needsSuperuser, setNeedsSuperuser] = useState(false);
const {
isAuthenticated,
setIsAuthenticated,
logout,
initData,
initializeAuth,
} = useAuthStore();
const toggleDrawer = () => {
setOpen(!open);
};
// Check if a superuser exists on first load.
useEffect(() => {
async function checkSuperuser() {
try {
const res = await axios.get('/api/accounts/initialize-superuser/');
if (!res.data.superuser_exists) {
setNeedsSuperuser(true);
}
} catch (error) {
console.error('Error checking superuser status:', error);
}
}
checkSuperuser();
}, []);
// Authentication check
useEffect(() => {
const checkAuth = async () => {
const loggedIn = await initializeAuth();
if (loggedIn) {
await initData();
setIsAuthenticated(true);
} else {
await logout();
}
};
checkAuth();
}, [initializeAuth, initData, setIsAuthenticated, logout]);
// If no superuser exists, show the initialization form
if (needsSuperuser) {
return <SuperuserForm onSuccess={() => setNeedsSuperuser(false)} />;
}
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<GlobalStyles
styles={{
'.Mui-TableHeadCell-Content': {
height: '100%',
alignItems: 'flex-end !important',
},
}}
/>
<WebsocketProvider>
<Router>
{/* Sidebar on the left */}
<Sidebar
open={open}
miniDrawerWidth={miniDrawerWidth}
drawerWidth={drawerWidth}
toggleDrawer={toggleDrawer}
/>
{/* Main content area */}
<Box
sx={{
display: 'flex',
flexDirection: 'column',
ml: `${open ? drawerWidth : miniDrawerWidth}px`,
transition: 'margin-left 0.3s',
backgroundColor: 'background.default',
minHeight: '100vh',
color: 'text.primary',
}}
>
<Box sx={{ p: 2, flex: 1, overflow: 'auto' }}>
<Routes>
{isAuthenticated ? (
<>
<Route path="/channels" element={<Channels />} />
<Route path="/m3u" element={<M3U />} />
<Route path="/epg" element={<EPG />} />
<Route
path="/stream-profiles"
element={<StreamProfiles />}
/>
<Route path="/guide" element={<Guide />} />
<Route path="/settings" element={<Settings />} />
</>
) : (
<Route path="/login" element={<Login />} />
)}
<Route
path="*"
element={
<Navigate
to={isAuthenticated ? defaultRoute : '/login'}
replace
/>
}
/>
</Routes>
</Box>
</Box>
</Router>
<Alert />
<FloatingVideo />
</WebsocketProvider>
</ThemeProvider>
);
};
export default App;

152
frontend/src/App.jsx Normal file
View file

@ -0,0 +1,152 @@
// frontend/src/App.js
import React, { useEffect, useState } from 'react';
import {
BrowserRouter as Router,
Route,
Routes,
Navigate,
} from 'react-router-dom';
import Sidebar from './components/Sidebar';
import Login from './pages/Login';
import Channels from './pages/Channels';
import M3U from './pages/M3U';
import EPG from './pages/EPG';
import Guide from './pages/Guide';
import Settings from './pages/Settings';
import StreamProfiles from './pages/StreamProfiles';
import useAuthStore from './store/auth';
import FloatingVideo from './components/FloatingVideo';
import { WebsocketProvider } from './WebSocket';
import { Box, AppShell, MantineProvider } from '@mantine/core';
import '@mantine/core/styles.css'; // Ensure Mantine global styles load
import '@mantine/notifications/styles.css';
import 'mantine-react-table/styles.css';
import './index.css';
import mantineTheme from './mantineTheme';
import API from './api';
import { Notifications } from '@mantine/notifications';
const drawerWidth = 240;
const miniDrawerWidth = 60;
const defaultRoute = '/channels';
const App = () => {
const [open, setOpen] = useState(true);
const {
isAuthenticated,
setIsAuthenticated,
logout,
initData,
initializeAuth,
setSuperuserExists,
} = useAuthStore();
const toggleDrawer = () => {
setOpen(!open);
};
// Check if a superuser exists on first load.
useEffect(() => {
async function checkSuperuser() {
try {
const response = await API.fetchSuperUser();
if (!response.superuser_exists) {
setSuperuserExists(false);
}
} catch (error) {
console.error('Error checking superuser status:', error);
}
}
checkSuperuser();
}, []);
// Authentication check
useEffect(() => {
const checkAuth = async () => {
const loggedIn = await initializeAuth();
if (loggedIn) {
await initData();
setIsAuthenticated(true);
} else {
await logout();
}
};
checkAuth();
}, [initializeAuth, initData, setIsAuthenticated, logout]);
return (
<MantineProvider
defaultColorScheme="dark"
theme={mantineTheme}
withGlobalStyles
withNormalizeCSS
>
<WebsocketProvider>
<Notifications containerWidth={250} />
<Router>
<AppShell
header={{
height: 0,
}}
navbar={{
width: open ? drawerWidth : miniDrawerWidth,
}}
>
<Sidebar
drawerWidth
miniDrawerWidth
collapsed={!open}
toggleDrawer={toggleDrawer}
/>
<AppShell.Main>
<Box
style={{
display: 'flex',
flexDirection: 'column',
// transition: 'margin-left 0.3s',
backgroundColor: '#18181b',
height: '100vh',
color: 'white',
}}
>
<Box sx={{ p: 2, flex: 1, overflow: 'auto' }}>
<Routes>
{isAuthenticated ? (
<>
<Route path="/channels" element={<Channels />} />
<Route path="/m3u" element={<M3U />} />
<Route path="/epg" element={<EPG />} />
<Route
path="/stream-profiles"
element={<StreamProfiles />}
/>
<Route path="/guide" element={<Guide />} />
<Route path="/settings" element={<Settings />} />
</>
) : (
<Route path="/login" element={<Login needsSuperuser />} />
)}
<Route
path="*"
element={
<Navigate
to={isAuthenticated ? defaultRoute : '/login'}
replace
/>
}
/>
</Routes>
</Box>
</Box>
</AppShell.Main>
</AppShell>
</Router>
<FloatingVideo />
</WebsocketProvider>
</MantineProvider>
);
};
export default App;

View file

@ -1,8 +0,0 @@
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

View file

@ -6,7 +6,7 @@ import React, {
useContext,
} from 'react';
import useStreamsStore from './store/streams';
import useAlertStore from './store/alerts';
import { notifications } from '@mantine/notifications';
export const WebsocketContext = createContext(false, null, () => {});
@ -14,14 +14,13 @@ export const WebsocketProvider = ({ children }) => {
const [isReady, setIsReady] = useState(false);
const [val, setVal] = useState(null);
const { showAlert } = useAlertStore();
const { fetchStreams } = useStreamsStore();
const ws = useRef(null);
useEffect(() => {
let wsUrl = `${window.location.host}/ws/`;
if (process.env.REACT_APP_ENV_MODE == 'dev') {
if (import.meta.env.DEV) {
wsUrl = `${window.location.hostname}:8001/ws/`;
}
@ -53,7 +52,10 @@ export const WebsocketProvider = ({ children }) => {
case 'm3u_refresh':
if (event.message?.success) {
fetchStreams();
showAlert(event.message.message, 'success');
notifications.show({
message: event.message.message,
color: 'green.5',
});
}
break;

View file

@ -9,7 +9,9 @@ import useStreamProfilesStore from './store/streamProfiles';
import useSettingsStore from './store/settings';
// If needed, you can set a base host or keep it empty if relative requests
const host = '';
const host = import.meta.env.DEV
? `http://${window.location.hostname}:5656`
: '';
export default class API {
/**
@ -19,6 +21,27 @@ export default class API {
return await useAuthStore.getState().getToken();
}
static async fetchSuperUser() {
const response = await fetch(`${host}/api/accounts/initialize-superuser/`);
return await response.json();
}
static async createSuperUser({ username, email, password }) {
const response = await fetch(`${host}/api/accounts/initialize-superuser/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username,
password,
email,
}),
});
return await response.json();
}
static async login(username, password) {
const response = await fetch(`${host}/api/accounts/token/`, {
method: 'POST',
@ -456,13 +479,29 @@ export default class API {
}
static async addPlaylist(values) {
let body = null;
if (values.uploaded_file) {
body = new FormData();
for (const prop in values) {
body.append(prop, values[prop]);
}
} else {
body = { ...values };
delete body.uploaded_file;
body = JSON.stringify(body);
}
const response = await fetch(`${host}/api/m3u/accounts/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${await API.getAuthToken()}`,
'Content-Type': 'application/json',
...(values.uploaded_file
? {}
: {
'Content-Type': 'application/json',
}),
},
body: JSON.stringify(values),
body,
});
const retval = await response.json();

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4 KiB

View file

@ -1,26 +0,0 @@
import React, { useState } from 'react';
import { Snackbar, Alert, Button } from '@mui/material';
import useAlertStore from '../store/alerts';
const AlertPopup = () => {
const { open, message, severity, hideAlert } = useAlertStore();
const handleClose = () => {
hideAlert();
};
return (
<Snackbar
open={open}
autoHideDuration={5000}
onClose={handleClose}
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
>
<Alert onClose={handleClose} severity={severity} sx={{ width: '100%' }}>
{message}
</Alert>
</Snackbar>
);
};
export default AlertPopup;

View file

@ -8,6 +8,7 @@ export default function FloatingVideo() {
const { isVisible, streamUrl, hideVideo } = useVideoStore();
const videoRef = useRef(null);
const playerRef = useRef(null);
const videoContainerRef = useRef(null);
useEffect(() => {
if (!isVisible || !streamUrl) {
@ -48,8 +49,9 @@ export default function FloatingVideo() {
}
return (
<Draggable>
<Draggable nodeRef={videoContainerRef}>
<div
ref={videoContainerRef}
style={{
position: 'fixed',
bottom: '20px',

View file

@ -1,182 +0,0 @@
import React from 'react';
import { Link, useLocation } from 'react-router-dom';
import {
Drawer,
Toolbar,
Box,
Typography,
Avatar,
List,
ListItemButton,
ListItemIcon,
ListItemText,
} from '@mui/material';
import { useTheme } from '@mui/material/styles';
import {
ListOrdered,
Play,
Database,
SlidersHorizontal,
LayoutGrid,
Settings as LucideSettings,
} from 'lucide-react';
import logo from '../images/logo.png';
const navItems = [
{ label: 'Channels', icon: <ListOrdered size={20} />, path: '/channels' },
{ label: 'M3U', icon: <Play size={20} />, path: '/m3u' },
{ label: 'EPG', icon: <Database size={20} />, path: '/epg' },
{ label: 'Stream Profiles', icon: <SlidersHorizontal size={20} />, path: '/stream-profiles' },
{ label: 'TV Guide', icon: <LayoutGrid size={20} />, path: '/guide' },
{ label: 'Settings', icon: <LucideSettings size={20} />, path: '/settings' },
];
const Sidebar = ({ open, drawerWidth, miniDrawerWidth, toggleDrawer }) => {
const location = useLocation();
const theme = useTheme();
return (
<Drawer
variant="permanent"
PaperProps={{
sx: {
width: open ? drawerWidth : miniDrawerWidth,
overflowX: 'hidden',
transition: 'width 0.3s',
backgroundColor: theme.palette.background.default,
color: 'text.primary',
display: 'flex',
flexDirection: 'column',
boxShadow: 'none',
border: 'none',
},
}}
>
<Toolbar
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: open ? 'space-between' : 'center',
minHeight: '64px !important',
px: 2,
}}
>
{open ? (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
cursor: 'pointer',
}}
onClick={toggleDrawer}
>
<img
src={logo}
alt="Dispatcharr Logo"
style={{ width: 28, height: 'auto' }}
/>
<Typography variant="h6" noWrap sx={{ color: 'text.primary' }}>
Dispatcharr
</Typography>
</Box>
) : (
<img
src={logo}
alt="Dispatcharr Logo"
style={{ width: 28, height: 'auto', cursor: 'pointer' }}
onClick={toggleDrawer}
/>
)}
</Toolbar>
<List disablePadding sx={{ pt: 0 }}>
{navItems.map((item) => {
const isActive = location.pathname.startsWith(item.path);
return (
<ListItemButton
key={item.path}
component={Link}
to={item.path}
sx={{
px: 2,
py: 0.5,
mx: 'auto',
display: 'flex',
justifyContent: 'center',
color: 'inherit',
width: '100%',
'&:hover': { backgroundColor: 'unset !important' },
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
borderRadius: 1,
width: open ? '208px' : 'auto',
transition: 'all 0.2s ease',
bgcolor: isActive
? theme.custom.sidebar.activeBackground
: 'transparent',
border: isActive
? `1px solid ${theme.custom.sidebar.activeBorder}`
: '1px solid transparent',
color: 'text.primary',
px: 1,
py: 0.25,
'&:hover': {
bgcolor: theme.custom.sidebar.hoverBackground,
border: `1px solid ${theme.custom.sidebar.hoverBorder}`,
},
}}
>
<ListItemIcon
sx={{
color: 'text.primary',
minWidth: 0,
mr: open ? 1 : 'auto',
justifyContent: 'center',
}}
>
{item.icon}
</ListItemIcon>
{open && (
<ListItemText
primary={item.label}
primaryTypographyProps={{
sx: {
fontSize: '14px',
fontWeight: 400,
fontFamily: theme.custom.sidebar.fontFamily,
letterSpacing: '-0.3px',
// Keeping the text color as it is in your original
color: isActive ? '#d4d4d8' : '#d4d4d8',
},
}}
/>
)}
</Box>
</ListItemButton>
);
})}
</List>
<Box sx={{ flexGrow: 1 }} />
<Box sx={{ p: 2, display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Avatar
alt="John Doe"
src="/static/images/avatar.png"
sx={{ width: 32, height: 32 }}
/>
{open && (
<Typography variant="body2" noWrap sx={{ color: 'text.primary' }}>
John Doe
</Typography>
)}
</Box>
</Drawer>
);
};
export default Sidebar;

View file

@ -0,0 +1,227 @@
import React, { useRef } from 'react';
import { Link, useLocation } from 'react-router-dom';
import {
ListOrdered,
Play,
Database,
SlidersHorizontal,
LayoutGrid,
Settings as LucideSettings,
} from 'lucide-react';
import {
Avatar,
AppShell,
Group,
Stack,
Box,
Text,
UnstyledButton,
TextInput,
ActionIcon,
} from '@mantine/core';
import logo from '../images/logo.png';
import useChannelsStore from '../store/channels';
import './sidebar.css';
import useSettingsStore from '../store/settings';
import { ContentCopy } from '@mui/icons-material';
const NavLink = ({ item, isActive, collapsed }) => {
return (
<UnstyledButton
key={item.path}
component={Link}
to={item.path}
className={`navlink ${isActive ? 'navlink-active' : ''} ${collapsed ? 'navlink-collapsed' : ''}`}
>
{item.icon}
{!collapsed && (
<Text
sx={{
opacity: collapsed ? 0 : 1,
transition: 'opacity 0.2s ease-in-out',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
minWidth: collapsed ? 0 : 150,
}}
>
{item.label}
</Text>
)}
{!collapsed && item.badge && (
<Text size="sm" style={{ color: '#D4D4D8', whiteSpace: 'nowrap' }}>
{item.badge}
</Text>
)}
</UnstyledButton>
);
};
const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
const location = useLocation();
const { channels } = useChannelsStore();
const { environment } = useSettingsStore();
const publicIPRef = useRef(null);
// Navigation Items
const navItems = [
{
label: 'Channels',
icon: <ListOrdered size={20} />,
path: '/channels',
badge: `(${Object.keys(channels).length})`,
},
{ label: 'M3U', icon: <Play size={20} />, path: '/m3u' },
{ label: 'EPG', icon: <Database size={20} />, path: '/epg' },
{
label: 'Stream Profiles',
icon: <SlidersHorizontal size={20} />,
path: '/stream-profiles',
},
{ label: 'TV Guide', icon: <LayoutGrid size={20} />, path: '/guide' },
{
label: 'Settings',
icon: <LucideSettings size={20} />,
path: '/settings',
},
];
const copyPublicIP = async () => {
try {
await navigator.clipboard.writeText(environment.public_ip);
} catch (err) {
const inputElement = publicIPRef.current; // Get the actual input
console.log(inputElement);
if (inputElement) {
inputElement.focus();
inputElement.select();
// For older browsers
document.execCommand('copy');
}
}
};
return (
<AppShell.Navbar
width={{ base: collapsed ? miniDrawerWidth : drawerWidth }}
p="xs"
style={{
backgroundColor: '#1A1A1E',
// transition: 'width 0.3s ease',
borderRight: '1px solid #2A2A2E',
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
}}
>
{/* Brand - Click to Toggle */}
<Group
onClick={toggleDrawer}
spacing="sm"
style={{
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '16px 12px',
fontSize: 18,
fontWeight: 600,
color: '#FFFFFF',
justifyContent: collapsed ? 'center' : 'flex-start',
whiteSpace: 'nowrap',
}}
>
{/* <ListOrdered size={24} /> */}
<img width={30} src={logo} />
{!collapsed && (
<Text
sx={{
opacity: collapsed ? 0 : 1,
transition: 'opacity 0.2s ease-in-out',
whiteSpace: 'nowrap', // Ensures text never wraps
overflow: 'hidden',
textOverflow: 'ellipsis',
minWidth: collapsed ? 0 : 150, // Prevents reflow
}}
>
Dispatcharr
</Text>
)}
</Group>
{/* Navigation Links */}
<Stack gap="xs" mt="lg">
{navItems.map((item) => {
const isActive = location.pathname === item.path;
return (
<NavLink item={item} collapsed={collapsed} isActive={isActive} />
);
})}
</Stack>
{/* Profile Section */}
<Box
style={{
marginTop: 'auto',
padding: '16px',
display: 'flex',
alignItems: 'center',
gap: 10,
borderTop: '1px solid #2A2A2E',
justifyContent: collapsed ? 'center' : 'flex-start',
}}
>
<Group>
{!collapsed && (
<TextInput
label="Public IP"
ref={publicIPRef}
value={environment.public_ip}
leftSection={
environment.country_code && (
<img
src={`https://flagcdn.com/16x12/${environment.country_code.toLowerCase()}.png`}
alt={environment.country_name || environment.country_code}
title={environment.country_name || environment.country_code}
/>
)
}
rightSection={
<ActionIcon
variant="transparent"
color="gray.9"
onClick={copyPublicIP}
>
<ContentCopy />
</ActionIcon>
}
/>
)}
<Avatar src="https://via.placeholder.com/40" radius="xl" />
{!collapsed && (
<Group
style={{
flex: 1,
justifyContent: 'space-between',
whiteSpace: 'nowrap',
}}
>
<Text size="sm" color="white">
John Doe
</Text>
<Text size="sm" color="white">
</Text>
</Group>
)}
</Group>
</Box>
</AppShell.Navbar>
);
};
export default Sidebar;

View file

@ -1,491 +0,0 @@
import React, { useState, useEffect, useMemo } from 'react';
import {
Box,
Typography,
Stack,
TextField,
Button,
Select,
MenuItem,
Grid2,
InputLabel,
FormControl,
CircularProgress,
IconButton,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
FormHelperText,
} from '@mui/material';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import useChannelsStore from '../../store/channels';
import API from '../../api';
import useStreamProfilesStore from '../../store/streamProfiles';
import { Add as AddIcon, Remove as RemoveIcon } from '@mui/icons-material';
import useStreamsStore from '../../store/streams';
import {
MaterialReactTable,
useMaterialReactTable,
} from 'material-react-table';
import ChannelGroupForm from './ChannelGroup';
import usePlaylistsStore from '../../store/playlists';
import logo from '../../images/logo.png';
const Channel = ({ channel = null, isOpen, onClose }) => {
const channelGroups = useChannelsStore((state) => state.channelGroups);
const streams = useStreamsStore((state) => state.streams);
const { profiles: streamProfiles } = useStreamProfilesStore();
const { playlists } = usePlaylistsStore();
const [logoFile, setLogoFile] = useState(null);
const [logoPreview, setLogoPreview] = useState(logo);
const [channelStreams, setChannelStreams] = useState([]);
const [channelGroupModelOpen, setChannelGroupModalOpen] = useState(false);
const addStream = (stream) => {
const streamSet = new Set(channelStreams);
streamSet.add(stream);
setChannelStreams(Array.from(streamSet));
};
const removeStream = (stream) => {
const streamSet = new Set(channelStreams);
streamSet.delete(stream);
setChannelStreams(Array.from(streamSet));
};
const handleLogoChange = (e) => {
const file = e.target.files[0];
if (file) {
setLogoFile(file);
setLogoPreview(URL.createObjectURL(file));
}
};
const formik = useFormik({
initialValues: {
channel_name: '',
channel_number: '',
channel_group_id: '',
stream_profile_id: '0',
tvg_id: '',
tvg_name: '',
},
validationSchema: Yup.object({
channel_name: Yup.string().required('Name is required'),
channel_number: Yup.string().required('Invalid channel number').min(0),
channel_group_id: Yup.string().required('Channel group is required'),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
if (values.stream_profile_id == '0') {
values.stream_profile_id = null;
}
console.log(values);
if (channel?.id) {
await API.updateChannel({
id: channel.id,
...values,
logo_file: logoFile,
streams: channelStreams.map((stream) => stream.id),
});
} else {
await API.addChannel({
...values,
logo_file: logoFile,
streams: channelStreams.map((stream) => stream.id),
});
}
resetForm();
setLogoFile(null);
setLogoPreview(logo);
setSubmitting(false);
onClose();
},
});
useEffect(() => {
if (channel) {
formik.setValues({
channel_name: channel.channel_name,
channel_number: channel.channel_number,
channel_group_id: channel.channel_group?.id,
stream_profile_id: channel.stream_profile_id || '0',
tvg_id: channel.tvg_id,
tvg_name: channel.tvg_name,
});
console.log(channel);
const filteredStreams = streams
.filter((stream) => channel.stream_ids.includes(stream.id))
.sort(
(a, b) =>
channel.stream_ids.indexOf(a.id) - channel.stream_ids.indexOf(b.id)
);
setChannelStreams(filteredStreams);
} else {
formik.resetForm();
}
}, [channel]);
const activeStreamsTable = useMaterialReactTable({
data: channelStreams,
columns: useMemo(
() => [
{
header: 'Name',
accessorKey: 'name',
},
{
header: 'M3U',
accessorKey: 'group_name',
},
],
[]
),
enableSorting: false,
enableBottomToolbar: false,
enableTopToolbar: false,
columnFilterDisplayMode: 'popover',
enablePagination: false,
enableRowVirtualization: true,
enableRowOrdering: true,
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
initialState: {
density: 'compact',
},
enableRowActions: true,
positionActionsColumn: 'last',
renderRowActions: ({ row }) => (
<>
<IconButton
size="small" // Makes the button smaller
color="error" // Red color for delete actions
onClick={() => removeStream(row.original)}
>
<RemoveIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
</>
),
muiTableContainerProps: {
sx: {
height: '200px',
},
},
muiRowDragHandleProps: ({ table }) => ({
onDragEnd: () => {
const { draggingRow, hoveredRow } = table.getState();
if (hoveredRow && draggingRow) {
channelStreams.splice(
hoveredRow.index,
0,
channelStreams.splice(draggingRow.index, 1)[0]
);
setChannelStreams([...channelStreams]);
}
},
}),
});
const availableStreamsTable = useMaterialReactTable({
data: streams,
columns: useMemo(
() => [
{
header: 'Name',
accessorKey: 'name',
},
{
header: 'M3U',
accessorFn: (row) =>
playlists.find((playlist) => playlist.id === row.m3u_account)?.name,
},
],
[]
),
enableBottomToolbar: false,
enableTopToolbar: false,
columnFilterDisplayMode: 'popover',
enablePagination: false,
enableRowVirtualization: true,
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
initialState: {
density: 'compact',
},
enableRowActions: true,
renderRowActions: ({ row }) => (
<>
<IconButton
size="small" // Makes the button smaller
color="success" // Red color for delete actions
onClick={() => addStream(row.original)}
>
<AddIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
</>
),
positionActionsColumn: 'last',
muiTableContainerProps: {
sx: {
height: '200px',
},
},
});
if (!isOpen) {
return <></>;
}
return (
<>
<Dialog open={isOpen} onClose={onClose} fullWidth maxWidth="lg">
<DialogTitle
sx={{
backgroundColor: 'primary.main',
color: 'primary.contrastText',
}}
>
Channel
</DialogTitle>
<form onSubmit={formik.handleSubmit}>
<DialogContent>
<Grid2 container spacing={2}>
<Grid2 size={6}>
<TextField
fullWidth
id="channel_name"
name="channel_name"
label="Channel Name"
value={formik.values.channel_name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.channel_name &&
Boolean(formik.errors.channel_name)
}
helperText={
formik.touched.channel_name && formik.errors.channel_name
}
variant="standard"
/>
<Grid2
container
spacing={1}
sx={{
alignItems: 'center',
}}
>
<Grid2 size={11}>
<FormControl variant="standard" fullWidth>
<InputLabel id="channel-group-label">
Channel Group
</InputLabel>
<Select
labelId="channel-group-label"
id="channel_group_id"
name="channel_group_id"
label="Channel Group"
value={formik.values.channel_group_id}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.channel_group_id &&
Boolean(formik.errors.channel_group_id)
}
// helperText={formik.touched.channel_group_id && formik.errors.channel_group_id}
variant="standard"
>
{channelGroups.map((option, index) => (
<MenuItem key={index} value={option.id}>
{option.name}
</MenuItem>
))}
</Select>
<FormHelperText sx={{ color: 'error.main' }}>
{formik.touched.channel_group_id &&
formik.errors.channel_group_id}
</FormHelperText>
</FormControl>
</Grid2>
<Grid2 size={1}>
<IconButton
color="success"
onClick={() => setChannelGroupModalOpen(true)}
title="Create new group"
size="small"
variant="filled"
>
<AddIcon fontSize="small" />
</IconButton>
</Grid2>
</Grid2>
<FormControl variant="standard" fullWidth>
<InputLabel id="stream-profile-label">
Stream Profile
</InputLabel>
<Select
labelId="stream-profile-label"
id="stream_profile_id"
name="stream_profile_id"
value={formik.values.stream_profile_id}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.stream_profile_id &&
Boolean(formik.errors.stream_profile_id)
}
// helperText={formik.touched.channel_group_id && formik.errors.stream_profile_id}
variant="standard"
>
<MenuItem value="0" selected>
<em>Use Default</em>
</MenuItem>
{streamProfiles.map((option, index) => (
<MenuItem key={index} value={option.id}>
{option.profile_name}
</MenuItem>
))}
</Select>
</FormControl>
<TextField
fullWidth
id="channel_number"
name="channel_number"
label="Channel #"
value={formik.values.channel_number}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.channel_number &&
Boolean(formik.errors.channel_number)
}
helperText={
formik.touched.channel_number &&
formik.errors.channel_number
}
variant="standard"
/>
</Grid2>
<Grid2 size={6}>
<TextField
fullWidth
id="tvg_name"
name="tvg_name"
label="TVG Name"
value={formik.values.tvg_name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.tvg_name && Boolean(formik.errors.tvg_name)
}
helperText={formik.touched.tvg_name && formik.errors.tvg_name}
variant="standard"
/>
<TextField
fullWidth
id="tvg_id"
name="tvg_id"
label="TVG ID"
value={formik.values.tvg_id}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={formik.touched.tvg_id && Boolean(formik.errors.tvg_id)}
helperText={formik.touched.tvg_id && formik.errors.tvg_id}
variant="standard"
/>
<TextField
fullWidth
id="logo_url"
name="logo_url"
label="Logo URL (Optional)"
variant="standard"
sx={{ marginBottom: 2 }}
value={formik.values.logo_url}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
helperText="If you have a direct image URL, set it here."
/>
<Box mt={2} mb={2}>
{/* File upload input */}
<Stack
direction="row"
spacing={2}
sx={{
alignItems: 'center',
}}
>
<Typography>Logo</Typography>
{/* Display selected image */}
<Box mb={2}>
<img
src={logoPreview}
alt="Selected"
style={{ maxWidth: 50, height: 'auto' }}
/>
</Box>
<input
type="file"
id="logo"
name="logo"
accept="image/*"
onChange={(event) => handleLogoChange(event)}
style={{ display: 'none' }}
/>
<label htmlFor="logo">
<Button variant="contained" component="span" size="small">
Browse...
</Button>
</label>
</Stack>
</Box>
</Grid2>
</Grid2>
<Grid2 container spacing={2}>
<Grid2 size={6}>
<Typography>Active Streams</Typography>
<MaterialReactTable table={activeStreamsTable} />
</Grid2>
<Grid2 size={6}>
<Typography>Available Streams</Typography>
<MaterialReactTable table={availableStreamsTable} />
</Grid2>
</Grid2>
</DialogContent>
<DialogActions>
{/* Submit button */}
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
>
{formik.isSubmitting ? <CircularProgress size={24} /> : 'Submit'}
</Button>
</DialogActions>
</form>
</Dialog>
<ChannelGroupForm
isOpen={channelGroupModelOpen}
onClose={() => setChannelGroupModalOpen(false)}
/>
</>
);
};
export default Channel;

View file

@ -0,0 +1,420 @@
import React, { useState, useEffect, useMemo } from 'react';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import useChannelsStore from '../../store/channels';
import API from '../../api';
import useStreamProfilesStore from '../../store/streamProfiles';
import { Add as AddIcon, Remove as RemoveIcon } from '@mui/icons-material';
import useStreamsStore from '../../store/streams';
import { MantineReactTable, useMantineReactTable } from 'mantine-react-table';
import ChannelGroupForm from './ChannelGroup';
import usePlaylistsStore from '../../store/playlists';
import logo from '../../images/logo.png';
import {
Box,
Button,
Modal,
TextInput,
NativeSelect,
Text,
Group,
ActionIcon,
Center,
Grid,
Flex,
} from '@mantine/core';
import { SquarePlus } from 'lucide-react';
const Channel = ({ channel = null, isOpen, onClose }) => {
const channelGroups = useChannelsStore((state) => state.channelGroups);
const streams = useStreamsStore((state) => state.streams);
const { profiles: streamProfiles } = useStreamProfilesStore();
const { playlists } = usePlaylistsStore();
const [logoFile, setLogoFile] = useState(null);
const [logoPreview, setLogoPreview] = useState(logo);
const [channelStreams, setChannelStreams] = useState([]);
const [channelGroupModelOpen, setChannelGroupModalOpen] = useState(false);
const addStream = (stream) => {
const streamSet = new Set(channelStreams);
streamSet.add(stream);
setChannelStreams(Array.from(streamSet));
};
const removeStream = (stream) => {
const streamSet = new Set(channelStreams);
streamSet.delete(stream);
setChannelStreams(Array.from(streamSet));
};
const handleLogoChange = (e) => {
const file = e.target.files[0];
if (file) {
setLogoFile(file);
setLogoPreview(URL.createObjectURL(file));
}
};
const formik = useFormik({
initialValues: {
channel_name: '',
channel_number: '',
channel_group_id: '',
stream_profile_id: '0',
tvg_id: '',
tvg_name: '',
},
validationSchema: Yup.object({
channel_name: Yup.string().required('Name is required'),
channel_number: Yup.string().required('Invalid channel number').min(0),
channel_group_id: Yup.string().required('Channel group is required'),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
if (values.stream_profile_id == '0') {
values.stream_profile_id = null;
}
console.log(values);
if (channel?.id) {
await API.updateChannel({
id: channel.id,
...values,
logo_file: logoFile,
streams: channelStreams.map((stream) => stream.id),
});
} else {
await API.addChannel({
...values,
logo_file: logoFile,
streams: channelStreams.map((stream) => stream.id),
});
}
resetForm();
setLogoFile(null);
setLogoPreview(logo);
setSubmitting(false);
onClose();
},
});
useEffect(() => {
if (channel) {
formik.setValues({
channel_name: channel.channel_name,
channel_number: channel.channel_number,
channel_group_id: channel.channel_group?.id,
stream_profile_id: channel.stream_profile_id || '0',
tvg_id: channel.tvg_id,
tvg_name: channel.tvg_name,
});
console.log(channel);
setChannelStreams(channel.streams);
} else {
formik.resetForm();
}
}, [channel]);
// const activeStreamsTable = useMantineReactTable({
// data: channelStreams,
// columns: useMemo(
// () => [
// {
// header: 'Name',
// accessorKey: 'name',
// Cell: ({ cell }) => (
// <div
// style={{
// whiteSpace: 'nowrap',
// overflow: 'hidden',
// textOverflow: 'ellipsis',
// }}
// >
// {cell.getValue()}
// </div>
// ),
// },
// {
// header: 'M3U',
// accessorKey: 'group_name',
// Cell: ({ cell }) => (
// <div
// style={{
// whiteSpace: 'nowrap',
// overflow: 'hidden',
// textOverflow: 'ellipsis',
// }}
// >
// {cell.getValue()}
// </div>
// ),
// },
// ],
// []
// ),
// enableSorting: false,
// enableBottomToolbar: false,
// enableTopToolbar: false,
// columnFilterDisplayMode: 'popover',
// enablePagination: false,
// enableRowVirtualization: true,
// enableRowOrdering: true,
// rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
// initialState: {
// density: 'compact',
// },
// enableRowActions: true,
// positionActionsColumn: 'last',
// renderRowActions: ({ row }) => (
// <>
// <IconButton
// size="small" // Makes the button smaller
// color="error" // Red color for delete actions
// onClick={() => removeStream(row.original)}
// >
// <RemoveIcon fontSize="small" /> {/* Small icon size */}
// </IconButton>
// </>
// ),
// mantineTableContainerProps: {
// style: {
// height: '200px',
// },
// },
// mantineRowDragHandleProps: ({ table }) => ({
// onDragEnd: () => {
// const { draggingRow, hoveredRow } = table.getState();
// if (hoveredRow && draggingRow) {
// channelStreams.splice(
// hoveredRow.index,
// 0,
// channelStreams.splice(draggingRow.index, 1)[0]
// );
// setChannelStreams([...channelStreams]);
// }
// },
// }),
// });
// const availableStreamsTable = useMantineReactTable({
// data: streams,
// columns: useMemo(
// () => [
// {
// header: 'Name',
// accessorKey: 'name',
// },
// {
// header: 'M3U',
// accessorFn: (row) =>
// playlists.find((playlist) => playlist.id === row.m3u_account)?.name,
// },
// ],
// []
// ),
// enableBottomToolbar: false,
// enableTopToolbar: false,
// columnFilterDisplayMode: 'popover',
// enablePagination: false,
// enableRowVirtualization: true,
// rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
// initialState: {
// density: 'compact',
// },
// enableRowActions: true,
// renderRowActions: ({ row }) => (
// <>
// <IconButton
// size="small" // Makes the button smaller
// color="success" // Red color for delete actions
// onClick={() => addStream(row.original)}
// >
// <AddIcon fontSize="small" /> {/* Small icon size */}
// </IconButton>
// </>
// ),
// positionActionsColumn: 'last',
// mantineTableContainerProps: {
// style: {
// height: '200px',
// },
// },
// });
if (!isOpen) {
return <></>;
}
return (
<>
<Modal opened={isOpen} onClose={onClose} size={800} title="Channel">
<form onSubmit={formik.handleSubmit}>
<Grid gap={2}>
<Grid.Col span={6}>
<TextInput
id="channel_name"
name="channel_name"
label="Channel Name"
value={formik.values.channel_name}
onChange={formik.handleChange}
error={
formik.errors.channel_name ? formik.touched.channel_name : ''
}
/>
<Grid>
<Grid.Col span={11}>
<NativeSelect
id="channel_group_id"
name="channel_group_id"
label="Channel Group"
value={formik.values.channel_group_id}
onChange={formik.handleChange}
error={
formik.errors.channel_group_id
? formik.touched.channel_group_id
: ''
}
data={channelGroups.map((option, index) => ({
value: `${option.id}`,
label: option.name,
}))}
/>
</Grid.Col>
<Grid.Col span={1}>
<ActionIcon
color="green.5"
onClick={() => setChannelGroupModalOpen(true)}
title="Create new group"
size="small"
variant="light"
style={{ marginTop: '175%' }} // @TODO: I don't like this, figure out better placement
>
<SquarePlus />
</ActionIcon>
</Grid.Col>
</Grid>
<NativeSelect
id="stream_profile_id"
label="Stream Profile"
name="stream_profile_id"
value={formik.values.stream_profile_id}
onChange={formik.handleChange}
error={
formik.errors.stream_profile_id
? formik.touched.stream_profile_id
: ''
}
data={streamProfiles.map((option) => ({
value: `${option.id}`,
label: option.profile_name,
}))}
/>
<TextInput
id="channel_number"
name="channel_number"
label="Channel #"
value={formik.values.channel_number}
onChange={formik.handleChange}
error={
formik.errors.channel_number
? formik.touched.channel_number
: ''
}
/>
</Grid.Col>
<Grid.Col span={6}>
<TextInput
id="tvg_name"
name="tvg_name"
label="TVG Name"
value={formik.values.tvg_name}
onChange={formik.handleChange}
error={formik.errors.tvg_name ? formik.touched.tvg_name : ''}
/>
<TextInput
id="tvg_id"
name="tvg_id"
label="TVG ID"
value={formik.values.tvg_id}
onChange={formik.handleChange}
error={formik.errors.tvg_id ? formik.touched.tvg_id : ''}
/>
<TextInput
id="logo_url"
name="logo_url"
label="Logo URL (Optional)"
style={{ marginBottom: 2 }}
value={formik.values.logo_url}
onChange={formik.handleChange}
/>
<Group style={{ paddingTop: 10 }}>
<Text>Logo</Text>
{/* Display selected image */}
<Box>
<img
src={logoPreview}
alt="Selected"
style={{ maxWidth: 50, height: 'auto' }}
/>
</Box>
<input
type="file"
id="logo"
name="logo"
accept="image/*"
onChange={(event) => handleLogoChange(event)}
style={{ display: 'none' }}
/>
<label htmlFor="logo">
<Button variant="contained" component="span" size="small">
Browse...
</Button>
</label>
</Group>
</Grid.Col>
</Grid>
{/* <Grid gap={2}>
<Grid.Col span={6}>
<Typography>Active Streams</Typography>
<MantineReactTable table={activeStreamsTable} />
</Grid.Col>
<Grid.Col span={6}>
<Typography>Available Streams</Typography>
<MantineReactTable table={availableStreamsTable} />
</Grid.Col>
</Grid> */}
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
>
Submit
</Button>
</Flex>
</form>
</Modal>
<ChannelGroupForm
isOpen={channelGroupModelOpen}
onClose={() => setChannelGroupModalOpen(false)}
/>
</>
);
};
export default Channel;

View file

@ -1,94 +0,0 @@
// Modal.js
import React, { useEffect } from "react";
import {
TextField,
Button,
CircularProgress,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from "@mui/material";
import { useFormik } from "formik";
import * as Yup from "yup";
import API from "../../api";
const ChannelGroup = ({ channelGroup = null, isOpen, onClose }) => {
const formik = useFormik({
initialValues: {
name: "",
},
validationSchema: Yup.object({
name: Yup.string().required("Name is required"),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
if (channelGroup?.id) {
await API.updateChannelGroup({ id: channelGroup.id, ...values });
} else {
await API.addChannelGroup(values);
}
resetForm();
setSubmitting(false);
onClose();
},
});
useEffect(() => {
if (channelGroup) {
formik.setValues({
name: channelGroup.name,
});
} else {
formik.resetForm();
}
}, [channelGroup]);
if (!isOpen) {
return <></>;
}
return (
<Dialog open={isOpen} onClose={onClose}>
<DialogTitle
sx={{
backgroundColor: "primary.main",
color: "primary.contrastText",
}}
>
Channel Group
</DialogTitle>
<form onSubmit={formik.handleSubmit}>
<DialogContent>
<TextField
fullWidth
id="name"
name="name"
label="Name"
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={formik.touched.name && Boolean(formik.errors.name)}
helperText={formik.touched.name && formik.errors.name}
variant="standard"
/>
</DialogContent>
<DialogActions>
{/* Submit button */}
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
size="small"
>
{formik.isSubmitting ? <CircularProgress size={24} /> : "Submit"}
</Button>
</DialogActions>
</form>
</Dialog>
);
};
export default ChannelGroup;

View file

@ -0,0 +1,71 @@
// Modal.js
import React, { useEffect } from 'react';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import API from '../../api';
import { Flex, TextInput, Button, Modal } from '@mantine/core';
const ChannelGroup = ({ channelGroup = null, isOpen, onClose }) => {
const formik = useFormik({
initialValues: {
name: '',
},
validationSchema: Yup.object({
name: Yup.string().required('Name is required'),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
if (channelGroup?.id) {
await API.updateChannelGroup({ id: channelGroup.id, ...values });
} else {
await API.addChannelGroup(values);
}
resetForm();
setSubmitting(false);
onClose();
},
});
useEffect(() => {
if (channelGroup) {
formik.setValues({
name: channelGroup.name,
});
} else {
formik.resetForm();
}
}, [channelGroup]);
if (!isOpen) {
return <></>;
}
return (
<Modal opened={isOpen} onClose={onClose} title="Channel Group">
<form onSubmit={formik.handleSubmit}>
<TextInput
id="name"
name="name"
label="Name"
value={formik.values.name}
onChange={formik.handleChange}
error={formik.touched.name}
/>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
size="small"
>
Submit
</Button>
</Flex>
</form>
</Modal>
);
};
export default ChannelGroup;

View file

@ -1,175 +0,0 @@
// Modal.js
import React, { useState, useEffect } from 'react';
import {
TextField,
Button,
Select,
MenuItem,
InputLabel,
FormControl,
CircularProgress,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from '@mui/material';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import API from '../../api';
import useEPGsStore from '../../store/epgs';
const EPG = ({ epg = null, isOpen, onClose }) => {
const epgs = useEPGsStore((state) => state.epgs);
const [file, setFile] = useState(null);
const handleFileChange = (e) => {
const file = e.target.files[0];
if (file) {
setFile(file);
}
};
const formik = useFormik({
initialValues: {
name: '',
source_type: '',
url: '',
api_key: '',
is_active: true,
},
validationSchema: Yup.object({
name: Yup.string().required('Name is required'),
source_type: Yup.string().required('Source type is required'),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
if (epg?.id) {
await API.updateEPG({ id: epg.id, ...values, epg_file: file });
} else {
await API.addEPG({
...values,
epg_file: file,
});
}
resetForm();
setFile(null);
setSubmitting(false);
onClose();
},
});
useEffect(() => {
if (epg) {
formik.setValues({
name: epg.name,
source_type: epg.source_type,
url: epg.url,
api_key: epg.api_key,
is_active: epg.is_active,
});
} else {
formik.resetForm();
}
}, [epg]);
if (!isOpen) {
return <></>;
}
return (
<Dialog open={isOpen} onClose={onClose}>
<DialogTitle
sx={{
backgroundColor: 'primary.main',
color: 'primary.contrastText',
}}
>
EPG Source
</DialogTitle>
<form onSubmit={formik.handleSubmit}>
<DialogContent>
<TextField
fullWidth
id="name"
name="name"
label="Name"
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={formik.touched.name && Boolean(formik.errors.name)}
helperText={formik.touched.name && formik.errors.name}
variant="standard"
/>
<TextField
fullWidth
id="url"
name="url"
label="URL"
value={formik.values.url}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={formik.touched.url && Boolean(formik.errors.url)}
helperText={formik.touched.url && formik.errors.url}
variant="standard"
/>
<TextField
fullWidth
id="api_key"
name="api_key"
label="API Key"
value={formik.values.api_key}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={formik.touched.api_key && Boolean(formik.errors.api_key)}
helperText={formik.touched.api_key && formik.errors.api_key}
variant="standard"
/>
<FormControl variant="standard" fullWidth>
<InputLabel id="source-type-label">Source Type</InputLabel>
<Select
labelId="source-type-label"
id="source_type"
name="source_type"
label="Source Type"
value={formik.values.source_type}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.source_type && Boolean(formik.errors.source_type)
}
helperText={
formik.touched.source_type && formik.errors.source_type
}
variant="standard"
>
<MenuItem key="0" value="xmltv">
XMLTV
</MenuItem>
<MenuItem key="1" value="schedules_direct">
Schedules Direct
</MenuItem>
</Select>
</FormControl>
</DialogContent>
<DialogActions>
{/* Submit button */}
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
size="small"
>
{formik.isSubmitting ? <CircularProgress size={24} /> : 'Submit'}
</Button>
</DialogActions>
</form>
</Dialog>
);
};
export default EPG;

View file

@ -0,0 +1,143 @@
// Modal.js
import React, { useState, useEffect } from 'react';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import API from '../../api';
import useEPGsStore from '../../store/epgs';
import {
LoadingOverlay,
TextInput,
Button,
Checkbox,
Modal,
Flex,
NativeSelect,
FileInput,
Space,
} from '@mantine/core';
const EPG = ({ epg = null, isOpen, onClose }) => {
const epgs = useEPGsStore((state) => state.epgs);
const [file, setFile] = useState(null);
const handleFileChange = (e) => {
const file = e.target.files[0];
if (file) {
setFile(file);
}
};
const formik = useFormik({
initialValues: {
name: '',
source_type: 'xmltv',
url: '',
api_key: '',
is_active: true,
},
validationSchema: Yup.object({
name: Yup.string().required('Name is required'),
source_type: Yup.string().required('Source type is required'),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
if (epg?.id) {
await API.updateEPG({ id: epg.id, ...values, epg_file: file });
} else {
await API.addEPG({
...values,
epg_file: file,
});
}
resetForm();
setFile(null);
setSubmitting(false);
onClose();
},
});
useEffect(() => {
if (epg) {
formik.setValues({
name: epg.name,
source_type: epg.source_type,
url: epg.url,
api_key: epg.api_key,
is_active: epg.is_active,
});
} else {
formik.resetForm();
}
}, [epg]);
if (!isOpen) {
return <></>;
}
return (
<Modal opened={isOpen} onClose={onClose} title="EPG Source">
<form onSubmit={formik.handleSubmit}>
<TextInput
id="name"
name="name"
label="Name"
value={formik.values.name}
onChange={formik.handleChange}
error={formik.touched.name && Boolean(formik.errors.name)}
/>
<TextInput
id="url"
name="url"
label="URL"
value={formik.values.url}
onChange={formik.handleChange}
error={formik.touched.url && Boolean(formik.errors.url)}
/>
<TextInput
id="api_key"
name="api_key"
label="API Key"
value={formik.values.api_key}
onChange={formik.handleChange}
error={formik.touched.api_key && Boolean(formik.errors.api_key)}
/>
<NativeSelect
id="source_type"
name="source_type"
label="Source Type"
value={formik.values.source_type}
onChange={formik.handleChange}
error={
formik.touched.source_type && Boolean(formik.errors.source_type)
}
data={[
{
label: 'XMLTV',
value: 'xmltv',
},
{
label: 'Schedules Direct',
value: 'schedules_direct',
},
]}
/>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button
type="submit"
variant="contained"
disabled={formik.isSubmitting}
size="small"
>
Submit
</Button>
</Flex>
</form>
</Modal>
);
};
export default EPG;

View file

@ -1,111 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import useAuthStore from '../../store/auth';
import {
Box,
TextField,
Button,
Typography,
Grid2,
Paper,
} from '@mui/material';
const LoginForm = () => {
const { login, isAuthenticated, initData } = useAuthStore(); // Get login function from AuthContext
const navigate = useNavigate(); // Hook to navigate to other routes
const [formData, setFormData] = useState({ username: '', password: '' });
useEffect(() => {
if (isAuthenticated) {
navigate('/channels');
}
}, [isAuthenticated, navigate]);
const handleInputChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value,
});
};
const handleSubmit = async (e) => {
e.preventDefault();
await login(formData);
initData();
navigate('/channels'); // Or any other route you'd like
};
// // Handle form submission
// const handleSubmit = async (e) => {
// e.preventDefault();
// setLoading(true);
// setError(''); // Reset error on each new submission
// await login(username, password)
// navigate('/channels'); // Or any other route you'd like
// };
return (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
backgroundColor: '#f5f5f5',
}}
>
<Paper elevation={3} sx={{ padding: 3, width: '100%', maxWidth: 400 }}>
<Typography variant="h5" align="center" gutterBottom>
Login
</Typography>
<form onSubmit={handleSubmit}>
<Grid2
container
spacing={2}
justifyContent="center"
direction="column"
>
<Grid2 xs={12}>
<TextField
label="Username"
variant="standard"
fullWidth
name="username"
value={formData.username}
onChange={handleInputChange}
required
size="small"
/>
</Grid2>
<Grid2 xs={12}>
<TextField
label="Password"
variant="standard"
type="password"
fullWidth
name="password"
value={formData.password}
onChange={handleInputChange}
required
size="small"
/>
</Grid2>
<Grid2 xs={12}>
<Button
type="submit"
variant="contained"
color="primary"
fullWidth
>
Submit
</Button>
</Grid2>
</Grid2>
</form>
</Paper>
</Box>
);
};
export default LoginForm;

View file

@ -0,0 +1,93 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import useAuthStore from '../../store/auth';
import {
Paper,
Title,
TextInput,
Button,
Checkbox,
Modal,
Box,
Center,
Stack,
} from '@mantine/core';
const LoginForm = () => {
const { login, isAuthenticated, initData } = useAuthStore(); // Get login function from AuthContext
const navigate = useNavigate(); // Hook to navigate to other routes
const [formData, setFormData] = useState({ username: '', password: '' });
useEffect(() => {
if (isAuthenticated) {
navigate('/channels');
}
}, [isAuthenticated, navigate]);
const handleInputChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value,
});
};
const handleSubmit = async (e) => {
e.preventDefault();
await login(formData);
initData();
navigate('/channels'); // Or any other route you'd like
};
// // Handle form submission
// const handleSubmit = async (e) => {
// e.preventDefault();
// setLoading(true);
// setError(''); // Reset error on each new submission
// await login(username, password)
// navigate('/channels'); // Or any other route you'd like
// };
return (
<Center
style={{
height: '100vh',
}}
>
<Paper
elevation={3}
style={{ padding: 30, width: '100%', maxWidth: 400 }}
>
<Title order={4} align="center">
Login
</Title>
<form onSubmit={handleSubmit}>
<Stack>
<TextInput
label="Username"
name="username"
value={formData.username}
onChange={handleInputChange}
required
/>
<TextInput
label="Password"
type="password"
name="password"
value={formData.password}
onChange={handleInputChange}
required
/>
<Button type="submit" size="sm" sx={{ pt: 1 }}>
Submit
</Button>
</Stack>
</form>
</Paper>
</Center>
);
};
export default LoginForm;

View file

@ -1,246 +0,0 @@
// Modal.js
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
Stack,
TextField,
Button,
Select,
MenuItem,
InputLabel,
FormControl,
CircularProgress,
FormControlLabel,
Checkbox,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from '@mui/material';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import API from '../../api';
import useUserAgentsStore from '../../store/userAgents';
import M3UProfiles from './M3UProfiles';
const M3U = ({ playlist = null, isOpen, onClose }) => {
const userAgents = useUserAgentsStore((state) => state.userAgents);
const [file, setFile] = useState(null);
const [profileModalOpen, setProfileModalOpen] = useState(false);
const handleFileChange = (e) => {
const file = e.target.files[0];
if (file) {
setFile(file);
}
};
const formik = useFormik({
initialValues: {
name: '',
server_url: '',
user_agent: '',
is_active: true,
max_streams: 0,
},
validationSchema: Yup.object({
name: Yup.string().required('Name is required'),
server_url: Yup.string().required('Server URL is required'),
user_agent: Yup.string().required('User-Agent is required'),
max_streams: Yup.string().required('Max streams is required'),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
if (playlist?.id) {
await API.updatePlaylist({
id: playlist.id,
...values,
uploaded_file: file,
});
} else {
await API.addPlaylist({
...values,
uploaded_file: file,
});
}
resetForm();
setFile(null);
setSubmitting(false);
onClose();
},
});
useEffect(() => {
if (playlist) {
formik.setValues({
name: playlist.name,
server_url: playlist.server_url,
max_streams: playlist.max_streams,
user_agent: playlist.user_agent,
is_active: playlist.is_active,
});
} else {
formik.resetForm();
}
}, [playlist]);
if (!isOpen) {
return <></>;
}
return (
<Dialog open={isOpen} onClose={onClose}>
<DialogTitle
sx={{
backgroundColor: 'primary.main',
color: 'primary.contrastText',
}}
>
M3U Account
</DialogTitle>
<form onSubmit={formik.handleSubmit}>
<DialogContent>
<TextField
fullWidth
id="name"
name="name"
label="Name"
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={formik.touched.name && Boolean(formik.errors.name)}
helperText={formik.touched.name && formik.errors.name}
variant="standard"
/>
<TextField
fullWidth
id="server_url"
name="server_url"
label="URL"
value={formik.values.server_url}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.server_url && Boolean(formik.errors.server_url)
}
helperText={formik.touched.server_url && formik.errors.server_url}
variant="standard"
/>
<Box mb={2}>
<Stack
direction="row"
spacing={2}
sx={{
alignItems: 'center',
pt: 2,
}}
>
<Typography>File</Typography>
<input
type="file"
id="uploaded_file"
name="uploaded_file"
accept="image/*"
onChange={(event) => handleFileChange(event)}
style={{ display: 'none' }}
/>
<label htmlFor="uploaded_file">
<Button variant="contained" component="span">
Browse...
</Button>
</label>
</Stack>
</Box>
<TextField
fullWidth
id="max_streams"
name="max_streams"
label="Max Streams (0 = unlimited)"
value={formik.values.max_streams}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.max_streams && Boolean(formik.errors.max_streams)
}
helperText={formik.touched.max_streams && formik.errors.max_streams}
variant="standard"
/>
<FormControl variant="standard" fullWidth>
<InputLabel id="user-agent-label">User-Agent</InputLabel>
<Select
labelId="user-agent-label"
id="user_agent"
name="user_agent"
label="User-Agent"
value={formik.values.user_agent}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.user_agent && Boolean(formik.errors.user_agent)
}
// helperText={formik.touched.user_agent && formik.errors.user_agent}
variant="standard"
>
{userAgents.map((option, index) => (
<MenuItem key={index} value={option.id}>
{option.user_agent_name}
</MenuItem>
))}
</Select>
</FormControl>
<FormControlLabel
control={
<Checkbox
name="is_active"
checked={formik.values.is_active}
onChange={(e) =>
formik.setFieldValue('is_active', e.target.checked)
}
/>
}
label="Is Active"
/>
</DialogContent>
<DialogActions>
<Button
variant="contained"
color="primary"
size="small"
onClick={() => setProfileModalOpen(true)}
>
Profiles
</Button>
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
size="small"
>
{formik.isSubmitting ? <CircularProgress size={24} /> : 'Submit'}
</Button>
</DialogActions>
{playlist && (
<M3UProfiles
playlist={playlist}
isOpen={profileModalOpen}
onClose={() => setProfileModalOpen(false)}
/>
)}
</form>
</Dialog>
);
};
export default M3U;

View file

@ -0,0 +1,191 @@
// Modal.js
import React, { useState, useEffect } from 'react';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import API from '../../api';
import useUserAgentsStore from '../../store/userAgents';
import M3UProfiles from './M3UProfiles';
import {
LoadingOverlay,
TextInput,
Button,
Checkbox,
Modal,
Flex,
NativeSelect,
FileInput,
Select,
Space,
} from '@mantine/core';
const M3U = ({ playlist = null, isOpen, onClose }) => {
const userAgents = useUserAgentsStore((state) => state.userAgents);
const [file, setFile] = useState(null);
const [profileModalOpen, setProfileModalOpen] = useState(false);
const handleFileChange = (file) => {
if (file) {
setFile(file);
}
};
const formik = useFormik({
initialValues: {
name: '',
server_url: '',
user_agent: `${userAgents[0].id}`,
is_active: true,
max_streams: 0,
},
validationSchema: Yup.object({
name: Yup.string().required('Name is required'),
user_agent: Yup.string().required('User-Agent is required'),
max_streams: Yup.string().required('Max streams is required'),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
if (playlist?.id) {
await API.updatePlaylist({
id: playlist.id,
...values,
uploaded_file: file,
});
} else {
await API.addPlaylist({
...values,
uploaded_file: file,
});
}
resetForm();
setFile(null);
setSubmitting(false);
onClose();
},
});
useEffect(() => {
if (playlist) {
formik.setValues({
name: playlist.name,
server_url: playlist.server_url,
max_streams: playlist.max_streams,
user_agent: playlist.user_agent,
is_active: playlist.is_active,
});
} else {
formik.resetForm();
}
}, [playlist]);
if (!isOpen) {
return <></>;
}
return (
<Modal opened={isOpen} onClose={onClose} title="M3U Account">
<div style={{ width: 400, position: 'relative' }}>
<LoadingOverlay visible={formik.isSubmitting} overlayBlur={2} />
<form onSubmit={formik.handleSubmit}>
<TextInput
fullWidth
id="name"
name="name"
label="Name"
value={formik.values.name}
onChange={formik.handleChange}
error={formik.touched.name && Boolean(formik.errors.name)}
helperText={formik.touched.name && formik.errors.name}
/>
<TextInput
fullWidth
id="server_url"
name="server_url"
label="URL"
value={formik.values.server_url}
onChange={formik.handleChange}
error={
formik.touched.server_url && Boolean(formik.errors.server_url)
}
helperText={formik.touched.server_url && formik.errors.server_url}
/>
<FileInput
id="uploaded_file"
label="Upload files"
placeholder="Upload files"
value={formik.uploaded_file}
onChange={handleFileChange}
/>
<TextInput
fullWidth
id="max_streams"
name="max_streams"
label="Max Streams"
placeholder="0 = Unlimited"
value={formik.values.max_streams}
onChange={formik.handleChange}
error={formik.errors.max_streams ? formik.touched.max_streams : ''}
/>
<NativeSelect
id="user_agent"
name="user_agent"
label="User-Agent"
value={formik.values.user_agent}
onChange={formik.handleChange}
error={formik.errors.user_agent ? formik.touched.user_agent : ''}
data={userAgents.map((ua) => ({
label: ua.user_agent_name,
value: `${ua.id}`,
}))}
/>
<Space h="md" />
<Checkbox
label="Is Active"
name="is_active"
checked={formik.values.is_active}
onChange={(e) =>
formik.setFieldValue('is_active', e.target.checked)
}
/>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
{playlist && (
<Button
variant="contained"
color="primary"
size="small"
onClick={() => setProfileModalOpen(true)}
>
Profiles
</Button>
)}
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
size="small"
>
Save
</Button>
</Flex>
{playlist && (
<M3UProfiles
playlist={playlist}
isOpen={profileModalOpen}
onClose={() => setProfileModalOpen(false)}
/>
)}
</form>
</div>
</Modal>
);
};
export default M3U;

View file

@ -1,195 +0,0 @@
import React, { useState, useEffect } from 'react';
import {
TextField,
Typography,
Card,
CardContent,
Dialog,
DialogContent,
DialogTitle,
DialogActions,
Button,
CircularProgress,
} from '@mui/material';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import API from '../../api';
const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
const [searchPattern, setSearchPattern] = useState('');
const [replacePattern, setReplacePattern] = useState('');
let regex;
try {
regex = new RegExp(searchPattern, 'g');
} catch (e) {
regex = null;
}
const highlightedUrl = regex
? m3u.server_url.replace(regex, (match) => `<mark>${match}</mark>`)
: m3u.server_url;
const resultUrl = regex
? m3u.server_url.replace(regex, replacePattern)
: m3u.server_url;
const onSearchPatternUpdate = (e) => {
formik.handleChange(e);
setSearchPattern(e.target.value);
};
const onReplacePatternUpdate = (e) => {
formik.handleChange(e);
setReplacePattern(e.target.value);
};
const formik = useFormik({
initialValues: {
name: '',
max_streams: 0,
search_pattern: '',
replace_pattern: '',
},
validationSchema: Yup.object({
name: Yup.string().required('Name is required'),
search_pattern: Yup.string().required('Search pattern is required'),
replace_pattern: Yup.string().required('Replace pattern is required'),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
console.log('submiting');
if (profile?.id) {
await API.updateM3UProfile(m3u.id, {
id: profile.id,
...values,
});
} else {
await API.addM3UProfile(m3u.id, values);
}
resetForm();
setSubmitting(false);
onClose();
},
});
useEffect(() => {
if (profile) {
setSearchPattern(profile.search_pattern);
setReplacePattern(profile.replace_pattern);
formik.setValues({
name: profile.name,
max_streams: profile.max_streams,
search_pattern: profile.search_pattern,
replace_pattern: profile.replace_pattern,
});
} else {
formik.resetForm();
}
}, [profile]);
return (
<Dialog open={isOpen} onClose={onClose}>
<DialogTitle
sx={{ backgroundColor: 'primary.main', color: 'primary.contrastText' }}
>
M3U Profile
</DialogTitle>
<form onSubmit={formik.handleSubmit}>
<DialogContent>
<TextField
fullWidth
id="name"
name="name"
label="Name"
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={formik.touched.name && Boolean(formik.errors.name)}
helperText={formik.touched.name && formik.errors.name}
variant="standard"
/>
<TextField
fullWidth
id="max_streams"
name="max_streams"
label="Max Streams"
value={formik.values.max_streams}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.max_streams && Boolean(formik.errors.max_streams)
}
helperText={formik.touched.max_streams && formik.errors.max_streams}
variant="standard"
/>
<TextField
fullWidth
id="search_pattern"
name="search_pattern"
label="Search Pattern (Regex)"
value={searchPattern}
onChange={onSearchPatternUpdate}
onBlur={formik.handleBlur}
error={
formik.touched.search_pattern &&
Boolean(formik.errors.search_pattern)
}
helperText={
formik.touched.search_pattern && formik.errors.search_pattern
}
variant="standard"
/>
<TextField
fullWidth
id="replace_pattern"
name="replace_pattern"
label="Replace Pattern"
value={replacePattern}
onChange={onReplacePatternUpdate}
onBlur={formik.handleBlur}
error={
formik.touched.replace_pattern &&
Boolean(formik.errors.replace_pattern)
}
helperText={
formik.touched.replace_pattern && formik.errors.replace_pattern
}
variant="standard"
/>
</DialogContent>
<DialogActions>
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
size="small"
>
{formik.isSubmitting ? <CircularProgress size={24} /> : 'Submit'}
</Button>
</DialogActions>
</form>
<Card>
<CardContent>
<Typography variant="h6">Search</Typography>
<Typography
dangerouslySetInnerHTML={{ __html: highlightedUrl }}
sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
/>
</CardContent>
</Card>
<Card>
<CardContent>
<Typography variant="h6">Replace</Typography>
<Typography>{resultUrl}</Typography>
</CardContent>
</Card>
</Dialog>
);
};
export default RegexFormAndView;

View file

@ -0,0 +1,158 @@
import React, { useState, useEffect } from 'react';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import API from '../../api';
import {
Flex,
Modal,
TextInput,
Button,
Title,
Text,
Paper,
} from '@mantine/core';
const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
const [searchPattern, setSearchPattern] = useState('');
const [replacePattern, setReplacePattern] = useState('');
let regex;
try {
regex = new RegExp(searchPattern, 'g');
} catch (e) {
regex = null;
}
const highlightedUrl = regex
? m3u.server_url.replace(regex, (match) => `<mark>${match}</mark>`)
: m3u.server_url;
const resultUrl =
regex && replacePattern
? m3u.server_url.replace(regex, replacePattern)
: m3u.server_url;
const onSearchPatternUpdate = (e) => {
formik.handleChange(e);
setSearchPattern(e.target.value);
};
const onReplacePatternUpdate = (e) => {
formik.handleChange(e);
setReplacePattern(e.target.value);
};
const formik = useFormik({
initialValues: {
name: '',
max_streams: 0,
search_pattern: '',
replace_pattern: '',
},
validationSchema: Yup.object({
name: Yup.string().required('Name is required'),
search_pattern: Yup.string().required('Search pattern is required'),
replace_pattern: Yup.string().required('Replace pattern is required'),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
console.log('submiting');
if (profile?.id) {
await API.updateM3UProfile(m3u.id, {
id: profile.id,
...values,
});
} else {
await API.addM3UProfile(m3u.id, values);
}
resetForm();
setSubmitting(false);
onClose();
},
});
useEffect(() => {
if (profile) {
setSearchPattern(profile.search_pattern);
setReplacePattern(profile.replace_pattern);
formik.setValues({
name: profile.name,
max_streams: profile.max_streams,
search_pattern: profile.search_pattern,
replace_pattern: profile.replace_pattern,
});
} else {
formik.resetForm();
}
}, [profile]);
return (
<Modal opened={isOpen} onClose={onClose} title="M3U Profile">
<form onSubmit={formik.handleSubmit}>
<TextInput
id="name"
name="name"
label="Name"
value={formik.values.name}
onChange={formik.handleChange}
error={formik.errors.name ? formik.touched.name : ''}
/>
<TextInput
id="max_streams"
name="max_streams"
label="Max Streams"
value={formik.values.max_streams}
onChange={formik.handleChange}
error={formik.errors.max_streams ? formik.touched.max_streams : ''}
/>
<TextInput
id="search_pattern"
name="search_pattern"
label="Search Pattern (Regex)"
value={searchPattern}
onChange={onSearchPatternUpdate}
error={
formik.errors.search_pattern ? formik.touched.search_pattern : ''
}
/>
<TextInput
id="replace_pattern"
name="replace_pattern"
label="Replace Pattern"
value={replacePattern}
onChange={onReplacePatternUpdate}
error={
formik.errors.replace_pattern ? formik.touched.replace_pattern : ''
}
/>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
size="small"
>
Submit
</Button>
</Flex>
</form>
<Paper shadow="sm" p="md" radius="md" withBorder>
<Text>Search</Text>
<Text
dangerouslySetInnerHTML={{ __html: highlightedUrl }}
sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
/>
</Paper>
<Paper p="md" withBorder>
<Text>Replace</Text>
<Text>{resultUrl}</Text>
</Paper>
</Modal>
);
};
export default RegexFormAndView;

View file

@ -1,133 +0,0 @@
import React, { useState, useMemo } from 'react';
import {
Typography,
Dialog,
DialogContent,
DialogTitle,
DialogActions,
Button,
Box,
Switch,
IconButton,
List,
ListItem,
ListItemText,
} from '@mui/material';
import API from '../../api';
import M3UProfile from './M3UProfile';
import { Delete as DeleteIcon, Edit as EditIcon } from '@mui/icons-material';
import usePlaylistsStore from '../../store/playlists';
const M3UProfiles = ({ playlist = null, isOpen, onClose }) => {
const profiles = usePlaylistsStore((state) => state.profiles[playlist.id]);
const [profileEditorOpen, setProfileEditorOpen] = useState(false);
const [profile, setProfile] = useState(null);
const editProfile = (profile = null) => {
if (profile) {
setProfile(profile);
}
setProfileEditorOpen(true);
};
const deleteProfile = async (id) => {
await API.deleteM3UProfile(playlist.id, id);
};
const toggleActive = async (values) => {
await API.updateM3UProfile(playlist.id, {
...values,
is_active: !values.is_active,
});
};
const closeEditor = () => {
setProfile(null);
setProfileEditorOpen(false);
};
if (!isOpen || !profiles) {
return <></>;
}
return (
<>
<Dialog open={isOpen} onClose={onClose}>
<DialogTitle
sx={{
backgroundColor: 'primary.main',
color: 'primary.contrastText',
}}
>
Profiles
</DialogTitle>
<DialogContent>
<List>
{profiles
.filter((playlist) => playlist.is_default == false)
.map((item) => (
<ListItem
key={item.id}
sx={{
display: 'flex',
alignItems: 'center',
marginBottom: 2,
}}
>
<ListItemText
primary={item.name}
secondary={
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Typography variant="body2" sx={{ marginRight: 2 }}>
Max Streams: {item.max_streams}
</Typography>
<Switch
checked={item.is_active}
onChange={() => toggleActive(item)}
color="primary"
inputProps={{ 'aria-label': 'active switch' }}
/>
<IconButton
onClick={() => editProfile(item)}
color="warning"
>
<EditIcon />
</IconButton>
<IconButton
onClick={() => deleteProfile(item.id)}
color="error"
>
<DeleteIcon />
</IconButton>
</Box>
}
/>
</ListItem>
))}
</List>
</DialogContent>
<DialogActions>
<Button
variant="contained"
color="primary"
size="small"
onClick={editProfile}
>
New
</Button>
</DialogActions>
</Dialog>
<M3UProfile
m3u={playlist}
profile={profile}
isOpen={profileEditorOpen}
onClose={closeEditor}
/>
</>
);
};
export default M3UProfiles;

View file

@ -0,0 +1,107 @@
import React, { useState, useMemo } from 'react';
import API from '../../api';
import M3UProfile from './M3UProfile';
import { Delete as DeleteIcon, Edit as EditIcon } from '@mui/icons-material';
import usePlaylistsStore from '../../store/playlists';
import {
Card,
Checkbox,
Flex,
Modal,
Button,
Box,
ActionIcon,
Text,
} from '@mantine/core';
const M3UProfiles = ({ playlist = null, isOpen, onClose }) => {
const profiles = usePlaylistsStore((state) => state.profiles[playlist.id]);
const [profileEditorOpen, setProfileEditorOpen] = useState(false);
const [profile, setProfile] = useState(null);
const editProfile = (profile = null) => {
if (profile) {
setProfile(profile);
}
setProfileEditorOpen(true);
};
const deleteProfile = async (id) => {
await API.deleteM3UProfile(playlist.id, id);
};
const toggleActive = async (values) => {
await API.updateM3UProfile(playlist.id, {
...values,
is_active: !values.is_active,
});
};
const closeEditor = () => {
setProfile(null);
setProfileEditorOpen(false);
};
if (!isOpen || !profiles) {
return <></>;
}
return (
<>
<Modal opened={isOpen} onClose={onClose} title="Profiles">
{profiles
.filter((playlist) => playlist.is_default == false)
.map((item) => (
<Card
// key={item.id}
// sx={{
// display: 'flex',
// alignItems: 'center',
// marginBottom: 2,
// }}
>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Text>Max Streams: {item.max_streams}</Text>
<Checkbox
label="Is Active"
checked={item.is_active}
onChange={() => toggleActive(item)}
color="primary"
/>
<ActionIcon onClick={() => editProfile(item)} color="yellow.5">
<EditIcon />
</ActionIcon>
<ActionIcon
onClick={() => deleteProfile(item.id)}
color="error"
>
<DeleteIcon />
</ActionIcon>
</Box>
</Card>
))}
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button
variant="contained"
color="primary"
size="small"
onClick={editProfile}
>
New
</Button>
</Flex>
</Modal>
<M3UProfile
m3u={playlist}
profile={profile}
isOpen={profileEditorOpen}
onClose={closeEditor}
/>
</>
);
};
export default M3UProfiles;

View file

@ -1,151 +0,0 @@
// Modal.js
import React, { useEffect } from 'react';
import {
TextField,
Button,
Select,
MenuItem,
Grid2,
InputLabel,
FormControl,
CircularProgress,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from '@mui/material';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import API from '../../api';
import useStreamProfilesStore from '../../store/streamProfiles';
const Stream = ({ stream = null, isOpen, onClose }) => {
const streamProfiles = useStreamProfilesStore((state) => state.profiles);
const formik = useFormik({
initialValues: {
name: '',
url: '',
stream_profile_id: '',
},
validationSchema: Yup.object({
name: Yup.string().required('Name is required'),
url: Yup.string().required('URL is required').min(0),
// stream_profile_id: Yup.string().required('Stream profile is required'),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
if (stream?.id) {
await API.updateStream({ id: stream.id, ...values });
} else {
await API.addStream(values);
}
resetForm();
setSubmitting(false);
onClose();
},
});
useEffect(() => {
if (stream) {
formik.setValues({
name: stream.name,
url: stream.url,
stream_profile_id: stream.stream_profile_id,
});
} else {
formik.resetForm();
}
}, [stream]);
if (!isOpen) {
return <></>;
}
return (
<Dialog open={isOpen} onClose={onClose}>
<DialogTitle
sx={{
backgroundColor: 'primary.main',
color: 'primary.contrastText',
}}
>
Stream
</DialogTitle>
<form onSubmit={formik.handleSubmit}>
<DialogContent>
<Grid2 container spacing={2}>
<Grid2 size={12}>
<TextField
fullWidth
id="name"
name="name"
label="Stream Name"
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={formik.touched.name && Boolean(formik.errors.name)}
helperText={formik.touched.name && formik.errors.name}
variant="standard"
/>
<TextField
fullWidth
id="url"
name="url"
label="Stream URL"
value={formik.values.url}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={formik.touched.url && Boolean(formik.errors.url)}
helperText={formik.touched.url && formik.errors.url}
variant="standard"
/>
<FormControl variant="standard" fullWidth>
<InputLabel id="stream-profile-label">
Stream Profile
</InputLabel>
<Select
labelId="stream-profile-label"
id="stream_profile_id"
name="stream_profile_id"
label="Stream Profile (optional)"
value={formik.values.stream_profile_id}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.stream_profile_id &&
Boolean(formik.errors.stream_profile_id)
}
// helperText={formik.touched.channel_group_id && formik.errors.stream_profile_id}
variant="standard"
>
{streamProfiles.map((option, index) => (
<MenuItem key={index} value={option.id}>
{option.profile_name}
</MenuItem>
))}
</Select>
</FormControl>
</Grid2>
</Grid2>
</DialogContent>
<DialogActions>
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
>
{formik.isSubmitting ? <CircularProgress size={24} /> : 'Submit'}
</Button>
</DialogActions>
</form>
</Dialog>
);
};
export default Stream;

View file

@ -0,0 +1,115 @@
// Modal.js
import React, { useEffect, useState } from 'react';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import API from '../../api';
import useStreamProfilesStore from '../../store/streamProfiles';
import { Modal, TextInput, Select, Button, Flex } from '@mantine/core';
const Stream = ({ stream = null, isOpen, onClose }) => {
const streamProfiles = useStreamProfilesStore((state) => state.profiles);
const [selectedStreamProfile, setSelectedStreamProfile] = useState('');
const formik = useFormik({
initialValues: {
name: '',
url: '',
group_name: '',
stream_profile_id: '',
},
validationSchema: Yup.object({
name: Yup.string().required('Name is required'),
url: Yup.string().required('URL is required').min(0),
// stream_profile_id: Yup.string().required('Stream profile is required'),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
if (stream?.id) {
await API.updateStream({ id: stream.id, ...values });
} else {
await API.addStream(values);
}
resetForm();
setSubmitting(false);
onClose();
},
});
useEffect(() => {
if (stream) {
formik.setValues({
name: stream.name,
url: stream.url,
group_name: stream.group_name,
stream_profile_id: stream.stream_profile_id,
});
} else {
formik.resetForm();
}
}, [stream]);
if (!isOpen) {
return <></>;
}
return (
<Modal opened={isOpen} onClose={onClose} title="Stream" zIndex={10}>
<form onSubmit={formik.handleSubmit}>
<TextInput
id="name"
name="name"
label="Stream Name"
value={formik.values.name}
onChange={formik.handleChange}
error={formik.errors.name}
/>
<TextInput
id="url"
name="url"
label="Stream URL"
value={formik.values.url}
onChange={formik.handleChange}
error={formik.errors.url}
/>
<TextInput
id="group_name"
name="group_name"
label="Group"
value={formik.values.group_name}
onChange={formik.handleChange}
error={formik.errors.group_name}
/>
<Select
id="stream_profile_id"
name="stream_profile_id"
label="Stream Profile"
placeholder="Optional"
value={selectedStreamProfile}
onChange={setSelectedStreamProfile}
error={formik.errors.stream_profile_id}
data={streamProfiles.map((profile) => ({
label: profile.profile_name,
value: `${profile.id}`,
}))}
comboboxProps={{ withinPortal: false, zIndex: 1000 }}
/>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
>
Submit
</Button>
</Flex>
</form>
</Modal>
);
};
export default Stream;

View file

@ -1,165 +0,0 @@
// Modal.js
import React, { useEffect } from "react";
import {
TextField,
Button,
Select,
MenuItem,
InputLabel,
FormControl,
CircularProgress,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from "@mui/material";
import { useFormik } from "formik";
import * as Yup from "yup";
import API from "../../api";
import useUserAgentsStore from "../../store/userAgents";
const StreamProfile = ({ profile = null, isOpen, onClose }) => {
const userAgents = useUserAgentsStore((state) => state.userAgents);
const formik = useFormik({
initialValues: {
profile_name: "",
command: "",
parameters: "",
is_active: true,
user_agent: "",
},
validationSchema: Yup.object({
profile_name: Yup.string().required("Name is required"),
command: Yup.string().required("Command is required"),
parameters: Yup.string().required("Parameters are is required"),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
if (profile?.id) {
await API.updateStreamProfile({ id: profile.id, ...values });
} else {
await API.addStreamProfile(values);
}
resetForm();
setSubmitting(false);
onClose();
},
});
useEffect(() => {
if (profile) {
formik.setValues({
profile_name: profile.profile_name,
command: profile.command,
parameters: profile.parameters,
is_active: profile.is_active,
user_agent: profile.user_agent,
});
} else {
formik.resetForm();
}
}, [profile]);
if (!isOpen) {
return <></>;
}
return (
<Dialog open={isOpen} onClose={onClose}>
<DialogTitle
sx={{
backgroundColor: "primary.main",
color: "primary.contrastText",
}}
>
Stream Profile
</DialogTitle>
<form onSubmit={formik.handleSubmit}>
<DialogContent>
<TextField
fullWidth
id="profile_name"
name="profile_name"
label="Name"
value={formik.values.profile_name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.profile_name && Boolean(formik.errors.profile_name)
}
helperText={
formik.touched.profile_name && formik.errors.profile_name
}
variant="standard"
/>
<TextField
fullWidth
id="command"
name="command"
label="Command"
value={formik.values.command}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={formik.touched.command && Boolean(formik.errors.command)}
helperText={formik.touched.command && formik.errors.command}
variant="standard"
/>
<TextField
fullWidth
id="parameters"
name="parameters"
label="Parameters"
value={formik.values.parameters}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.parameters && Boolean(formik.errors.parameters)
}
helperText={formik.touched.parameters && formik.errors.parameters}
variant="standard"
/>
<FormControl variant="standard" fullWidth>
<InputLabel id="channel-group-label">User-Agent</InputLabel>
<Select
labelId="channel-group-label"
id="user_agent"
name="user_agent"
label="User-Agent"
value={formik.values.user_agent}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.user_agent && Boolean(formik.errors.user_agent)
}
// helperText={formik.touched.user_agent && formik.errors.user_agent}
variant="standard"
>
{userAgents.map((option, index) => (
<MenuItem key={index} value={option.id}>
{option.user_agent_name}
</MenuItem>
))}
</Select>
</FormControl>
</DialogContent>
<DialogActions>
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
size="small"
>
{formik.isSubmitting ? <CircularProgress size={24} /> : "Submit"}
</Button>
</DialogActions>
</form>
</Dialog>
);
};
export default StreamProfile;

View file

@ -0,0 +1,113 @@
// Modal.js
import React, { useEffect } from 'react';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import API from '../../api';
import useUserAgentsStore from '../../store/userAgents';
import { Modal, TextInput, Select, Button, Flex } from '@mantine/core';
const StreamProfile = ({ profile = null, isOpen, onClose }) => {
const userAgents = useUserAgentsStore((state) => state.userAgents);
const formik = useFormik({
initialValues: {
profile_name: '',
command: '',
parameters: '',
is_active: true,
user_agent: '',
},
validationSchema: Yup.object({
profile_name: Yup.string().required('Name is required'),
command: Yup.string().required('Command is required'),
parameters: Yup.string().required('Parameters are is required'),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
if (profile?.id) {
await API.updateStreamProfile({ id: profile.id, ...values });
} else {
await API.addStreamProfile(values);
}
resetForm();
setSubmitting(false);
onClose();
},
});
useEffect(() => {
if (profile) {
formik.setValues({
profile_name: profile.profile_name,
command: profile.command,
parameters: profile.parameters,
is_active: profile.is_active,
user_agent: profile.user_agent,
});
} else {
formik.resetForm();
}
}, [profile]);
if (!isOpen) {
return <></>;
}
return (
<Modal opened={isOpen} onClose={onClose} title="Stream Profile">
<form onSubmit={formik.handleSubmit}>
<TextInput
id="profile_name"
name="profile_name"
label="Name"
value={formik.values.profile_name}
onChange={formik.handleChange}
error={formik.errors.profile_name}
/>
<TextInput
id="command"
name="command"
label="Command"
value={formik.values.command}
onChange={formik.handleChange}
error={formik.errors.command}
/>
<TextInput
id="parameters"
name="parameters"
label="Parameters"
value={formik.values.parameters}
onChange={formik.handleChange}
error={formik.errors.parameters}
/>
<Select
id="user_agent"
name="user_agent"
label="User-Agent"
value={formik.values.user_agent}
onChange={formik.handleChange}
error={formik.errors.user_agent}
data={userAgents.map((ua) => ({
label: ua.user_agent_name,
value: `${ua.id}`,
}))}
/>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
size="small"
>
Submit
</Button>
</Flex>
</form>
</Modal>
);
};
export default StreamProfile;

View file

@ -1,128 +0,0 @@
// frontend/src/components/forms/SuperuserForm.js
import React, { useState } from 'react';
import axios from 'axios';
import {
Box,
Paper,
Typography,
Grid2,
TextField,
Button,
} from '@mui/material';
function SuperuserForm({ onSuccess }) {
const [formData, setFormData] = useState({
username: '',
password: '',
email: '',
});
const [error, setError] = useState('');
const handleChange = (e) => {
setFormData((prev) => ({
...prev,
[e.target.name]: e.target.value,
}));
};
const handleSubmit = async (e) => {
e.preventDefault();
try {
const res = await axios.post('/api/accounts/initialize-superuser/', {
username: formData.username,
password: formData.password,
email: formData.email,
});
if (res.data.superuser_exists) {
onSuccess();
}
} catch (err) {
let msg = 'Failed to create superuser.';
if (err.response && err.response.data && err.response.data.error) {
msg += ` ${err.response.data.error}`;
}
setError(msg);
}
};
return (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
backgroundColor: '#f5f5f5',
}}
>
<Paper elevation={3} sx={{ padding: 3, width: '100%', maxWidth: 400 }}>
<Typography variant="h5" align="center" gutterBottom>
Create your Super User Account
</Typography>
{error && (
<Typography variant="body2" color="error" sx={{ mb: 2 }}>
{error}
</Typography>
)}
<form onSubmit={handleSubmit}>
<Grid2
container
spacing={2}
justifyContent="center"
direction="column"
>
<Grid2 xs={12}>
<TextField
label="Username"
variant="standard"
fullWidth
name="username"
value={formData.username}
onChange={handleChange}
required
size="small"
/>
</Grid2>
<Grid2 xs={12}>
<TextField
label="Password"
variant="standard"
type="password"
fullWidth
name="password"
value={formData.password}
onChange={handleChange}
required
size="small"
/>
</Grid2>
<Grid2 xs={12}>
<TextField
label="Email (optional)"
variant="standard"
type="email"
fullWidth
name="email"
value={formData.email}
onChange={handleChange}
size="small"
/>
</Grid2>
<Grid2 xs={12}>
<Button
type="submit"
variant="contained"
color="primary"
fullWidth
>
Create Superuser
</Button>
</Grid2>
</Grid2>
</form>
</Paper>
</Box>
);
}
export default SuperuserForm;

View file

@ -0,0 +1,94 @@
// frontend/src/components/forms/SuperuserForm.js
import React, { useState } from 'react';
import { TextInput, Center, Button, Paper, Title, Stack } from '@mantine/core';
import API from '../../api';
import useAuthStore from '../../store/auth';
function SuperuserForm({}) {
const [formData, setFormData] = useState({
username: '',
password: '',
email: '',
});
const [error, setError] = useState('');
const { setSuperuserExists } = useAuthStore();
const handleChange = (e) => {
setFormData((prev) => ({
...prev,
[e.target.name]: e.target.value,
}));
};
const handleSubmit = async (e) => {
e.preventDefault();
try {
console.log(formData);
const response = await API.createSuperUser({
username: formData.username,
password: formData.password,
email: formData.email,
});
if (response.superuser_exists) {
setSuperuserExists(true);
}
} catch (err) {
console.log(err);
// let msg = 'Failed to create superuser.';
// if (err.response && err.response.data && err.response.data.error) {
// msg += ` ${err.response.data.error}`;
// }
// setError(msg);
}
};
return (
<Center
style={{
height: '100vh',
}}
>
<Paper
elevation={3}
style={{ padding: 30, width: '100%', maxWidth: 400 }}
>
<Title order={4} align="center">
Create your Super User Account
</Title>
<form onSubmit={handleSubmit}>
<Stack>
<TextInput
label="Username"
name="username"
value={formData.username}
onChange={handleChange}
required
/>
<TextInput
label="Password"
type="password"
name="password"
value={formData.password}
onChange={handleChange}
required
/>
<TextInput
label="Email (optional)"
type="email"
name="email"
value={formData.email}
onChange={handleChange}
/>
<Button type="submit" size="sm" sx={{ pt: 1 }}>
Submit
</Button>
</Stack>
</form>
</Paper>
</Center>
);
}
export default SuperuserForm;

View file

@ -1,144 +0,0 @@
// Modal.js
import React, { useEffect } from 'react';
import {
TextField,
Button,
CircularProgress,
Checkbox,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from '@mui/material';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import API from '../../api';
import useSettingsStore from '../../store/settings';
const UserAgent = ({ userAgent = null, isOpen, onClose }) => {
const formik = useFormik({
initialValues: {
user_agent_name: '',
user_agent: '',
description: '',
is_active: true,
},
validationSchema: Yup.object({
user_agent_name: Yup.string().required('Name is required'),
user_agent: Yup.string().required('User-Agent is required'),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
if (userAgent?.id) {
await API.updateUserAgent({ id: userAgent.id, ...values });
} else {
await API.addUserAgent(values);
}
resetForm();
setSubmitting(false);
onClose();
},
});
useEffect(() => {
if (userAgent) {
formik.setValues({
user_agent_name: userAgent.user_agent_name,
user_agent: userAgent.user_agent,
description: userAgent.description,
is_active: userAgent.is_active,
});
} else {
formik.resetForm();
}
}, [userAgent]);
if (!isOpen) {
return <></>;
}
return (
<Dialog open={isOpen} onClose={onClose}>
<DialogTitle
sx={{
backgroundColor: 'primary.main',
color: 'primary.contrastText',
}}
>
User-Agent
</DialogTitle>
<form onSubmit={formik.handleSubmit}>
<DialogContent>
<TextField
fullWidth
id="user_agent_name"
name="user_agent_name"
label="Name"
value={formik.values.user_agent_name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.user_agent_name &&
Boolean(formik.errors.user_agent_name)
}
helperText={
formik.touched.user_agent_name && formik.errors.user_agent_name
}
variant="standard"
/>
<TextField
fullWidth
id="user_agent"
name="user_agent"
label="User-Agent"
value={formik.values.user_agent}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.user_agent && Boolean(formik.errors.user_agent)
}
helperText={formik.touched.user_agent && formik.errors.user_agent}
variant="standard"
/>
<TextField
fullWidth
id="description"
name="description"
label="Description"
value={formik.values.description}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.description && Boolean(formik.errors.description)
}
helperText={formik.touched.description && formik.errors.description}
variant="standard"
/>
<Checkbox
name="is_active"
checked={formik.values.is_active}
onChange={formik.handleChange}
/>
</DialogContent>
<DialogActions>
<Button
size="small"
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
>
{formik.isSubmitting ? <CircularProgress size={24} /> : 'Submit'}
</Button>
</DialogActions>
</form>
</Dialog>
);
};
export default UserAgent;

View file

@ -0,0 +1,119 @@
// Modal.js
import React, { useEffect } from 'react';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import API from '../../api';
import {
LoadingOverlay,
TextInput,
Button,
Checkbox,
Modal,
Flex,
NativeSelect,
FileInput,
Space,
} from '@mantine/core';
const UserAgent = ({ userAgent = null, isOpen, onClose }) => {
const formik = useFormik({
initialValues: {
user_agent_name: '',
user_agent: '',
description: '',
is_active: true,
},
validationSchema: Yup.object({
user_agent_name: Yup.string().required('Name is required'),
user_agent: Yup.string().required('User-Agent is required'),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
if (userAgent?.id) {
await API.updateUserAgent({ id: userAgent.id, ...values });
} else {
await API.addUserAgent(values);
}
resetForm();
setSubmitting(false);
onClose();
},
});
useEffect(() => {
if (userAgent) {
formik.setValues({
user_agent_name: userAgent.user_agent_name,
user_agent: userAgent.user_agent,
description: userAgent.description,
is_active: userAgent.is_active,
});
} else {
formik.resetForm();
}
}, [userAgent]);
if (!isOpen) {
return <></>;
}
return (
<Modal opened={isOpen} onClose={onClose} title="User-Agent">
<form onSubmit={formik.handleSubmit}>
<TextInput
id="user_agent_name"
name="user_agent_name"
label="Name"
value={formik.values.user_agent_name}
onChange={formik.handleChange}
error={
formik.touched.user_agent_name &&
Boolean(formik.errors.user_agent_name)
}
/>
<TextInput
id="user_agent"
name="user_agent"
label="User-Agent"
value={formik.values.user_agent}
onChange={formik.handleChange}
error={formik.touched.user_agent && Boolean(formik.errors.user_agent)}
/>
<TextInput
id="description"
name="description"
label="Description"
value={formik.values.description}
onChange={formik.handleChange}
error={
formik.touched.description && Boolean(formik.errors.description)
}
/>
<Space h="md" />
<Checkbox
name="is_active"
label="Is Active"
checked={formik.values.is_active}
onChange={formik.handleChange}
/>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button
size="small"
type="submit"
variant="contained"
disabled={formik.isSubmitting}
>
Submit
</Button>
</Flex>
</form>
</Modal>
);
};
export default UserAgent;

View file

@ -0,0 +1,41 @@
.mantine-Stack-root .navlink {
display: flex;
flex-direction: row; /* Ensures horizontal layout */
flex-wrap: nowrap;
align-items: center;
gap: 12px;
padding: 5px 8px !important;
border-radius: 6px;
color: #D4D4D8; /* Default color when not active */
background-color: transparent; /* Default background when not active */
border: 1px solid transparent;
transition: all 0.3s ease;
}
/* Active state styles */
.navlink.navlink-active {
color: #FFFFFF;
background-color: #245043;
border: 1px solid #3BA882;
}
/* Hover effect */
.navlink:hover {
background-color: #2A2F34; /* Gray hover effect when not active */
border: 1px solid #3D3D42;
}
/* Hover effect for active state */
.navlink.navlink-active:hover {
background-color: #3A3A40;
border: 1px solid #3BA882;
}
/* Collapse condition for justifyContent */
.navlink.navlink-collapsed {
justify-content: center;
}
.navlink:not(.navlink-collapsed) {
justify-content: flex-start;
}

View file

@ -1,938 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
MaterialReactTable,
useMaterialReactTable,
MRT_ShowHideColumnsButton
} from 'material-react-table';
import {
Box,
Grid2,
Typography,
Tooltip,
IconButton,
Button,
Snackbar,
Popover,
TextField,
Autocomplete,
InputAdornment,
Paper,
} from '@mui/material';
import useChannelsStore from '../../store/channels';
import {
Delete as DeleteIcon,
Edit as EditIcon,
Add as AddIcon,
ContentCopy,
Clear as ClearIcon,
IndeterminateCheckBox,
CompareArrows,
Code,
AddBox,
LiveTv as LiveTvIcon,
} from '@mui/icons-material';
import API from '../../api';
import ChannelForm from '../forms/Channel';
import { TableHelper } from '../../helpers';
import utils from '../../utils';
import logo from '../../images/logo.png';
import useVideoStore from '../../store/useVideoStore';
import useSettingsStore from '../../store/settings';
import usePlaylistsStore from '../../store/playlists';
import { Tv2, ScreenShare, Scroll, SquareMinus, Pencil } from 'lucide-react';
import { styled, useTheme } from '@mui/material/styles';
import ghostImage from '../../images/ghost.svg';
/* -----------------------------------------------------------
Child table for streams
------------------------------------------------------------ */
const ChannelStreams = ({ channel, isExpanded }) => {
const channelStreams = useChannelsStore(
(state) => state.channels[channel.id]?.streams
);
const { playlists } = usePlaylistsStore();
const removeStream = async (stream) => {
const newStreamList = channelStreams.filter((s) => s.id !== stream.id);
await API.updateChannel({
...channel,
stream_ids: newStreamList.map((s) => s.id),
});
};
const channelStreamsTable = useMaterialReactTable({
...TableHelper.defaultProperties,
data: channelStreams,
columns: useMemo(
() => [
{
header: 'Name',
accessorKey: 'name',
},
{
header: 'M3U',
accessorFn: (row) =>
playlists.find((playlist) => playlist.id === row.m3u_account)?.name,
},
],
[playlists]
),
enableKeyboardShortcuts: false,
enableColumnFilters: false,
enableSorting: false,
enableBottomToolbar: false,
enableTopToolbar: false,
enablePagination: false,
enableRowVirtualization: true,
enableColumnHeaders: false,
initialState: { density: 'compact' },
columnFilterDisplayMode: 'popover',
enableRowActions: true,
enableRowOrdering: true,
muiRowDragHandleProps: ({ table }) => ({
onDragEnd: async () => {
const { draggingRow, hoveredRow } = table.getState();
if (hoveredRow && draggingRow) {
channelStreams.splice(
hoveredRow.index,
0,
channelStreams.splice(draggingRow.index, 1)[0]
);
const { streams: oldStreams, ...channelUpdate } = channel;
await API.updateChannel({
...channelUpdate,
stream_ids: channelStreams.map((s) => s.id),
});
}
},
}),
renderRowActions: ({ row }) => (
<IconButton
size="small"
color="error"
onClick={() => removeStream(row.original)}
>
<DeleteIcon fontSize="small" />
</IconButton>
),
});
if (!isExpanded) return null;
return (
<Box sx={{ backgroundColor: 'primary.main', pt: 1, pb: 1, width: '100%' }}>
<MaterialReactTable table={channelStreamsTable} />
</Box>
);
};
/* -----------------------------------------------------------
Custom-styled buttons (HDHR, M3U, EPG)
------------------------------------------------------------ */
const HDHRButton = styled(Button)(() => ({
border: '1px solid #a3d977',
color: '#a3d977',
backgroundColor: 'transparent',
textTransform: 'none',
fontSize: '0.85rem',
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '2px 8px',
minWidth: 'auto',
'&:hover': {
borderColor: '#c2e583',
color: '#c2e583',
backgroundColor: 'rgba(163,217,119,0.1)',
},
}));
const M3UButton = styled(Button)(() => ({
border: '1px solid #5f6dc6',
color: '#5f6dc6',
backgroundColor: 'transparent',
textTransform: 'none',
fontSize: '0.85rem',
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '2px 8px',
minWidth: 'auto',
'&:hover': {
borderColor: '#7f8de6',
color: '#7f8de6',
backgroundColor: 'rgba(95,109,198,0.1)',
},
}));
const EPGButton = styled(Button)(() => ({
border: '1px solid #707070',
color: '#a0a0a0',
backgroundColor: 'transparent',
textTransform: 'none',
fontSize: '0.85rem',
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '2px 8px',
minWidth: 'auto',
'&:hover': {
borderColor: '#a0a0a0',
color: '#c0c0c0',
backgroundColor: 'rgba(112,112,112,0.1)',
},
}));
/* -----------------------------------------------------------
Main ChannelsTable component
------------------------------------------------------------ */
const ChannelsTable = () => {
const [channel, setChannel] = useState(null);
const [channelModalOpen, setChannelModalOpen] = useState(false);
const [rowSelection, setRowSelection] = useState([]);
const [channelGroupOptions, setChannelGroupOptions] = useState([]);
const [anchorEl, setAnchorEl] = useState(null);
const [textToCopy, setTextToCopy] = useState('');
const [snackbarMessage, setSnackbarMessage] = useState('');
const [snackbarOpen, setSnackbarOpen] = useState(false);
const [filterValues, setFilterValues] = useState({});
const [isLoading, setIsLoading] = useState(true);
const [sorting, setSorting] = useState([]);
const theme = useTheme();
const outputUrlRef = useRef(null);
const {
channels,
isLoading: channelsLoading,
fetchChannels,
setChannelsPageSelection,
} = useChannelsStore();
const { showVideo } = useVideoStore();
const {
environment: { env_mode },
} = useSettingsStore();
// Gather unique group names
useEffect(() => {
setChannelGroupOptions([
...new Set(Object.values(channels).map((ch) => ch.channel_group?.name)),
]);
}, [channels]);
// Handle filters
const handleFilterChange = (columnId, value) => {
setFilterValues((prev) => ({
...prev,
[columnId]: value ? value.toLowerCase() : '',
}));
};
// Close the top-right snackbar
const closeSnackbar = () => setSnackbarOpen(false);
// Open the Channel form
const editChannel = (ch = null) => {
setChannel(ch);
setChannelModalOpen(true);
};
// Close the Channel form
const closeChannelForm = () => {
setChannel(null);
setChannelModalOpen(false);
};
// Single channel delete
const deleteChannel = async (id) => {
await API.deleteChannel(id);
};
// Bulk delete channels
const deleteChannels = async () => {
setIsLoading(true);
const selected = table.getRowModel().rows.filter((row) => row.getIsSelected());
await utils.Limiter(
4,
selected.map((chan) => () => deleteChannel(chan.original.id))
);
setIsLoading(false);
};
// Watch stream
const handleWatchStream = (channelNumber) => {
let vidUrl = `/output/stream/${channelNumber}/`;
if (env_mode === 'dev') {
vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`;
}
showVideo(vidUrl);
};
// Assign Channels
const assignChannels = async () => {
try {
const rowOrder = table.getRowModel().rows.map((row) => row.original.id);
setIsLoading(true);
const result = await API.assignChannelNumbers(rowOrder);
setIsLoading(false);
setSnackbarMessage(result.message || 'Channels assigned');
setSnackbarOpen(true);
await fetchChannels();
} catch (err) {
console.error(err);
setSnackbarMessage('Failed to assign channels');
setSnackbarOpen(true);
}
};
// Match EPG
const matchEpg = async () => {
try {
const resp = await fetch('/api/channels/channels/match-epg/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${await API.getAuthToken()}`,
},
});
if (resp.ok) {
setSnackbarMessage('EPG matching task started!');
} else {
const text = await resp.text();
setSnackbarMessage(`Failed to start EPG matching: ${text}`);
}
} catch (err) {
setSnackbarMessage(`Error: ${err.message}`);
}
setSnackbarOpen(true);
};
// Copy popover logic
const closePopover = () => {
setAnchorEl(null);
setSnackbarMessage('');
};
const openPopover = Boolean(anchorEl);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(textToCopy);
setSnackbarMessage('Copied!');
} catch (err) {
const inputElement = outputUrlRef.current?.querySelector('input');
if (inputElement) {
inputElement.focus();
inputElement.select();
document.execCommand('copy');
setSnackbarMessage('Copied!');
}
}
setSnackbarOpen(true);
};
// Copy HDHR/M3U/EPG URL
const copyM3UUrl = (event) => {
setAnchorEl(event.currentTarget);
setTextToCopy(`${window.location.protocol}//${window.location.host}/output/m3u`);
};
const copyEPGUrl = (event) => {
setAnchorEl(event.currentTarget);
setTextToCopy(`${window.location.protocol}//${window.location.host}/output/epg`);
};
const copyHDHRUrl = (event) => {
setAnchorEl(event.currentTarget);
setTextToCopy(`${window.location.protocol}//${window.location.host}/output/hdhr`);
};
// When component mounts
useEffect(() => {
if (typeof window !== 'undefined') {
setIsLoading(false);
}
}, []);
// Scroll to top on sorting
const rowVirtualizerInstanceRef = useRef(null);
useEffect(() => {
try {
rowVirtualizerInstanceRef.current?.scrollToIndex?.(0);
} catch (error) {
console.error(error);
}
}, [sorting]);
// Build the columns
const columns = useMemo(() => [
{
header: '#',
size: 50,
accessorKey: 'channel_number',
},
{
header: 'Name',
accessorKey: 'channel_name',
muiTableHeadCellProps: { sx: { textAlign: 'center' } },
Header: ({ column }) => (
<TextField
variant="standard"
label="Name"
value={filterValues[column.id] || ''}
onChange={(e) => handleFilterChange(column.id, e.target.value)}
size="small"
margin="none"
fullWidth
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => handleFilterChange(column.id, '')}
edge="end"
size="small"
>
<ClearIcon sx={{ fontSize: '1rem' }} />
</IconButton>
</InputAdornment>
),
},
}}
/>
),
},
{
header: 'Group',
accessorFn: (row) => row.channel_group?.name || '',
Header: ({ column }) => (
<Autocomplete
disablePortal
options={channelGroupOptions}
size="small"
sx={{ width: 300 }}
clearOnEscape
onChange={(event, newValue) => {
event.stopPropagation();
handleFilterChange(column.id, newValue);
}}
renderInput={(params) => (
<TextField
{...params}
label="Group"
size="small"
variant="standard"
onClick={(e) => e.stopPropagation()}
sx={{ pb: 0.8 }}
/>
)}
/>
),
},
{
header: 'Logo',
accessorKey: 'logo_url',
enableSorting: false,
size: 55,
Cell: ({ cell }) => (
<Grid2 container direction="row" sx={{ justifyContent: 'center', alignItems: 'center' }}>
<img src={cell.getValue() || logo} width="20" alt="channel logo" />
</Grid2>
),
},
], [channelGroupOptions, filterValues]);
// Filter the data
const filteredData = Object.values(channels).filter((row) =>
columns.every(({ accessorKey }) =>
filterValues[accessorKey]
? row[accessorKey]?.toLowerCase().includes(filterValues[accessorKey])
: true
)
);
// Build the MRT instance
const table = useMaterialReactTable({
...TableHelper.defaultProperties,
columns,
data: filteredData,
enablePagination: false,
enableColumnActions: false,
enableRowVirtualization: true,
enableRowSelection: true,
enableRowActions: true,
enableExpandAll: false,
// Fully disable MRT's built-in top toolbar
enableTopToolbar: false,
renderTopToolbar: () => null,
renderToolbarInternalActions: () => null,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
state: {
isLoading: isLoading || channelsLoading,
sorting,
rowSelection,
},
rowVirtualizerInstanceRef,
rowVirtualizerOptions: { overscan: 5 },
initialState: { density: 'compact' },
displayColumnDefOptions: {
'mrt-row-select': { size: 50 },
'mrt-row-expand': {
size: 10,
header: '',
muiTableHeadCellProps: { sx: { width: 38, minWidth: 38, maxWidth: 38 } },
muiTableBodyCellProps: { sx: { width: 38, minWidth: 38, maxWidth: 38 } },
},
'mrt-row-actions': { size: 68 },
},
muiExpandButtonProps: ({ row, table }) => ({
onClick: () => {
setRowSelection({ [row.index]: true });
table.setExpanded({ [row.id]: !row.getIsExpanded() });
},
sx: {
transform: row.getIsExpanded() ? 'rotate(180deg)' : 'rotate(-90deg)',
transition: 'transform 0.2s',
},
}),
// Expand child table
renderDetailPanel: ({ row }) => (
<ChannelStreams channel={row.original} isExpanded={row.getIsExpanded()} />
),
// Row actions
renderRowActions: ({ row }) => (
<Box sx={{ justifyContent: 'right' }}>
<Tooltip title="Edit Channel">
<IconButton
size="small"
color="warning"
onClick={() => editChannel(row.original)}
sx={{ py: 0, px: 0.5 }}
>
<Pencil size="18" />
</IconButton>
</Tooltip>
<Tooltip title="Delete Channel">
<IconButton
size="small"
color="error"
onClick={() => deleteChannel(row.original.id)}
sx={{ py: 0, px: 0.5 }}
>
<SquareMinus size="18" />
</IconButton>
</Tooltip>
<Tooltip title="Preview Channel">
<IconButton
size="small"
color="info"
onClick={() => handleWatchStream(row.original.channel_number)}
sx={{ py: 0, px: 0.5 }}
>
<LiveTvIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
),
muiTableContainerProps: {
sx: {
height: 'calc(100vh - 75px)',
overflowY: 'auto',
},
},
});
// Sync the selection with your store
useEffect(() => {
const selectedRows = table.getSelectedRowModel().rows.map((row) => row.original);
setChannelsPageSelection(selectedRows);
}, [rowSelection, table, setChannelsPageSelection]);
return (
<Box>
{/* Header row, outside the Paper */}
<Box sx={{ display: 'flex', alignItems: 'center', pb: 1 }}>
<Typography
sx={{
width: 88,
fontFamily: 'Inter, sans-serif',
fontWeight: 500,
fontSize: '20px',
lineHeight: 1,
letterSpacing: '-0.3px',
color: theme.palette.text.secondary,
mb: 0,
}}
>
Channels
</Typography>
{/* "Links" label and HDHR/M3U/EPG buttons */}
<Box sx={{ width: 43, height: 25, display: 'flex', alignItems: 'center', ml: 3 }}>
<Typography
sx={{
width: 37,
height: 17,
fontFamily: 'Inter, sans-serif',
fontWeight: 400,
fontSize: '14px',
lineHeight: 1,
letterSpacing: '-0.3px',
color: theme.palette.text.secondary,
}}
>
Links:
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: '6px', ml: 0.75 }}>
<Button
onClick={copyHDHRUrl}
sx={{
width: '71px',
height: '25px',
borderRadius: '4px',
border: `1px solid ${theme.palette.custom.greenMain}`,
backgroundColor: 'transparent',
color: theme.palette.custom.greenMain,
fontSize: '14px',
lineHeight: 1,
letterSpacing: '-0.3px',
textTransform: 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px',
padding: 0,
minWidth: 0,
'&:hover': { backgroundColor: theme.palette.custom.greenHoverBg },
}}
>
<Box sx={{ width: 14, height: 14, display: 'flex', alignItems: 'center' }}>
<Tv2 size={14} color={theme.palette.custom.greenMain} />
</Box>
HDHR
</Button>
<Button
onClick={copyM3UUrl}
sx={{
width: '64px',
height: '25px',
borderRadius: '4px',
border: `1px solid ${theme.palette.custom.indigoMain}`,
backgroundColor: 'transparent',
color: theme.palette.custom.indigoMain,
fontSize: '14px',
lineHeight: 1,
letterSpacing: '-0.3px',
textTransform: 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px',
padding: 0,
minWidth: 0,
'&:hover': { backgroundColor: theme.palette.custom.indigoHoverBg },
}}
>
<Box sx={{ width: 14, height: 14, display: 'flex', alignItems: 'center' }}>
<ScreenShare size={14} color={theme.palette.custom.indigoMain} />
</Box>
M3U
</Button>
<Button
onClick={copyEPGUrl}
sx={{
width: '60px',
height: '25px',
borderRadius: '4px',
border: `1px solid ${theme.palette.custom.greyBorder}`,
backgroundColor: 'transparent',
color: theme.palette.custom.greyText,
fontSize: '14px',
lineHeight: 1,
letterSpacing: '-0.3px',
textTransform: 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px',
padding: 0,
minWidth: 0,
'&:hover': { backgroundColor: theme.palette.custom.greyHoverBg },
}}
>
<Box sx={{ width: 14, height: 14, display: 'flex', alignItems: 'center' }}>
<Scroll size={14} color={theme.palette.custom.greyText} />
</Box>
EPG
</Button>
</Box>
</Box>
{/* Paper with your custom top bar + table */}
<Paper
sx={{
bgcolor: theme.palette.background.paper,
borderRadius: 2,
overflow: 'hidden',
height: 'calc(100vh - 75px)',
display: 'flex',
flexDirection: 'column',
}}
>
{/* Our own top toolbar to mimic StreamsTable's style */}
{/*
define selectedCount in JS, NOT inline:
*/}
{(() => {
const selectedCount = table.getSelectedRowModel().rows.length;
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
backgroundColor: theme.palette.background.paper,
justifyContent: 'flex-end',
p: 1,
gap: 1,
}}
>
<Tooltip title="Remove">
<Button
onClick={deleteChannels}
variant="outlined"
size="small"
disabled={selectedCount === 0}
startIcon={<IndeterminateCheckBox sx={{ fontSize: 16, color: theme.palette.text.secondary }} />}
sx={{
borderColor: theme.palette.custom.borderDefault,
borderRadius: '4px',
borderWidth: '1px',
borderStyle: 'solid',
height: '25px',
opacity: selectedCount ? 1 : 0.4,
color: theme.palette.text.secondary,
fontSize: '0.85rem',
px: 1,
py: 0.5,
'&:hover': { borderColor: theme.palette.custom.borderHover },
}}
>
Remove
</Button>
</Tooltip>
<Tooltip title="Assign">
<Button
onClick={assignChannels}
variant="outlined"
size="small"
disabled={selectedCount === 0}
startIcon={<CompareArrows sx={{ fontSize: 16, color: theme.palette.text.secondary }} />}
sx={{
borderColor: theme.palette.custom.borderDefault,
borderRadius: '4px',
borderWidth: '1px',
borderStyle: 'solid',
height: '25px',
opacity: selectedCount ? 1 : 0.4,
color: theme.palette.text.secondary,
fontSize: '0.85rem',
px: 1,
py: 0.5,
'&:hover': { borderColor: theme.palette.custom.borderHover },
}}
>
Assign
</Button>
</Tooltip>
<Tooltip title="Auto-match">
<Button
onClick={matchEpg}
variant="outlined"
size="small"
startIcon={<Code sx={{ fontSize: 16, color: theme.palette.text.secondary }} />}
sx={{
minWidth: '106px',
borderColor: theme.palette.custom.borderDefault,
borderRadius: '4px',
borderWidth: '1px',
borderStyle: 'solid',
height: '25px',
color: theme.palette.text.secondary,
fontSize: '0.85rem',
px: 1,
py: 0.5,
'&:hover': { borderColor: theme.palette.custom.borderHover },
}}
>
Auto-match
</Button>
</Tooltip>
<Tooltip title="Add Channel">
<Button
onClick={() => editChannel()}
variant="contained"
size="small"
startIcon={<AddBox sx={{ fontSize: 16, color: theme.palette.custom.successIcon }} />}
sx={{
minWidth: '57px',
height: '25px',
borderRadius: '4px',
borderColor: theme.palette.custom.successBorder,
borderWidth: '1px',
borderStyle: 'solid',
backgroundColor: theme.palette.custom.successBg,
color: '#fff',
fontSize: '0.85rem',
px: 1,
py: 0.5,
'&:hover': { backgroundColor: theme.palette.custom.successBgHover },
}}
>
Add
</Button>
</Tooltip>
<Tooltip title="Show/Hide Columns">
<MRT_ShowHideColumnsButton table={table} />
</Tooltip>
</Box>
);
})()}
{/* Table or ghost empty state */}
<Box sx={{ flex: 1, position: 'relative' }}>
{filteredData.length === 0 ? (
<Box sx={{ position: 'relative', width: '100%', height: '100%', bgcolor: theme.palette.background.paper }}>
<Box
component="img"
src={ghostImage}
alt="Ghost"
sx={{
position: 'absolute',
top: '50%',
left: '50%',
width: '120px',
height: 'auto',
transform: 'translate(-50%, -50%)',
opacity: 0.2,
pointerEvents: 'none',
}}
/>
<Box
sx={{
position: 'absolute',
top: '25%',
left: '50%',
transform: 'translate(-50%, -50%)',
textAlign: 'center',
zIndex: 2,
width: 467,
px: 2,
}}
>
<Typography
sx={{
fontFamily: 'Inter, sans-serif',
fontWeight: 400,
fontSize: '20px',
lineHeight: '28px',
letterSpacing: '-0.3px',
color: theme.palette.text.secondary,
mb: 1,
}}
>
Its recommended to create channels after adding your M3U or streams.
</Typography>
<Typography
sx={{
fontFamily: 'Inter, sans-serif',
fontWeight: 400,
fontSize: '16px',
lineHeight: '24px',
letterSpacing: '-0.2px',
color: theme.palette.text.secondary,
mb: 2,
}}
>
You can still create channels without streams if youd like, and map them later.
</Typography>
<Button
variant="contained"
onClick={() => editChannel()}
startIcon={<AddIcon sx={{ fontSize: 16 }} />}
sx={{
minWidth: '127px',
height: '25px',
borderRadius: '4px',
borderWidth: '1px',
borderStyle: 'solid',
color: theme.palette.text.secondary,
borderColor: theme.palette.custom.borderHover,
backgroundColor: '#1f1f23',
fontFamily: 'Inter, sans-serif',
fontWeight: 400,
fontSize: '0.85rem',
letterSpacing: '-0.2px',
textTransform: 'none',
px: 1,
py: 0.5,
'&:hover': {
borderColor: theme.palette.custom.borderDefault,
backgroundColor: '#17171B',
},
}}
>
Create channel
</Button>
</Box>
</Box>
) : (
<Box sx={{ flex: 1, overflow: 'auto' }}>
<MaterialReactTable table={table} />
</Box>
)}
</Box>
</Paper>
{/* Channel Form */}
<ChannelForm channel={channel} isOpen={channelModalOpen} onClose={closeChannelForm} />
{/* Copy popover */}
<Popover
open={openPopover}
anchorEl={anchorEl}
onClose={closePopover}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
>
<Box sx={{ p: 2, display: 'flex', alignItems: 'center' }}>
<TextField
id="output-url"
value={textToCopy}
variant="standard"
size="small"
sx={{ mr: 1 }}
inputRef={outputUrlRef}
/>
<IconButton onClick={handleCopy} color="primary">
<ContentCopy />
</IconButton>
</Box>
</Popover>
<Snackbar
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
open={snackbarOpen}
autoHideDuration={5000}
onClose={closeSnackbar}
message={snackbarMessage}
/>
</Box>
);
};
export default ChannelsTable;

View file

@ -0,0 +1,880 @@
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import { MantineReactTable, useMantineReactTable } from 'mantine-react-table';
import useChannelsStore from '../../store/channels';
import { notifications } from '@mantine/notifications';
import {
Add as AddIcon,
LiveTv as LiveTvIcon,
ContentCopy,
IndeterminateCheckBox,
CompareArrows,
Code,
AddBox,
} from '@mui/icons-material';
import API from '../../api';
import ChannelForm from '../forms/Channel';
import { TableHelper } from '../../helpers';
import utils from '../../utils';
import logo from '../../images/logo.png';
import useVideoStore from '../../store/useVideoStore';
import useSettingsStore from '../../store/settings';
import usePlaylistsStore from '../../store/playlists';
import {
Tv2,
ScreenShare,
Scroll,
SquareMinus,
CirclePlay,
SquarePen,
Binary,
ArrowDown01,
SquarePlus,
} from 'lucide-react';
import ghostImage from '../../images/ghost.svg';
import {
Box,
TextInput,
Popover,
ActionIcon,
Select,
Button,
Paper,
Flex,
Text,
Tooltip,
Grid,
Group,
useMantineTheme,
Center,
Container,
} from '@mantine/core';
const ChannelStreams = ({ channel, isExpanded }) => {
const channelStreams = useChannelsStore(
(state) => state.channels[channel.id]?.streams
);
const { playlists } = usePlaylistsStore();
const removeStream = async (stream) => {
const newStreamList = channelStreams.filter((s) => s.id !== stream.id);
await API.updateChannel({
...channel,
stream_ids: newStreamList.map((s) => s.id),
});
};
const channelStreamsTable = useMantineReactTable({
...TableHelper.defaultProperties,
data: channelStreams,
columns: useMemo(
() => [
{
size: 400,
header: 'Name',
accessorKey: 'name',
Cell: ({ cell }) => (
<div
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{cell.getValue()}
</div>
),
},
{
size: 100,
header: 'M3U',
accessorFn: (row) =>
playlists.find((playlist) => playlist.id === row.m3u_account)?.name,
Cell: ({ cell }) => (
<div
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{cell.getValue()}
</div>
),
},
],
[playlists]
),
displayColumnDefOptions: {
'mrt-row-actions': {
size: 10,
},
},
enableKeyboardShortcuts: false,
enableColumnFilters: false,
enableBottomToolbar: false,
enableTopToolbar: false,
columnFilterDisplayMode: 'popover',
enablePagination: false,
enableRowVirtualization: true,
enableColumnHeaders: false,
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
initialState: {
density: 'compact',
},
enableRowActions: true,
enableRowOrdering: true,
mantineTableHeadRowProps: {
style: { display: 'none' },
},
mantineTableBodyCellProps: {
style: {
// py: 0,
padding: 4,
borderColor: '#444',
color: '#E0E0E0',
fontSize: '0.85rem',
},
},
mantineRowDragHandleProps: ({ table }) => ({
onDragEnd: async () => {
const { draggingRow, hoveredRow } = table.getState();
if (hoveredRow && draggingRow) {
channelStreams.splice(
hoveredRow.index,
0,
channelStreams.splice(draggingRow.index, 1)[0]
);
const { streams: _, ...channelUpdate } = channel;
API.updateChannel({
...channelUpdate,
stream_ids: channelStreams.map((stream) => stream.id),
});
}
},
}),
renderRowActions: ({ row }) => (
<Tooltip label="Remove stream">
<ActionIcon
size="sm"
color="red.9"
variant="transparent"
onClick={() => removeStream(row.original)}
>
<SquareMinus size="18" fontSize="small" />
</ActionIcon>
</Tooltip>
),
});
if (!isExpanded) {
return <></>;
}
return (
<Box style={{ width: '100%' }}>
<MantineReactTable table={channelStreamsTable} />
</Box>
);
};
const m3uUrl = `${window.location.protocol}//${window.location.host}/output/m3u`;
const epgUrl = `${window.location.protocol}//${window.location.host}/output/epg`;
const hdhrUrl = `${window.location.protocol}//${window.location.host}/output/hdhr`;
const ChannelsTable = ({}) => {
const [channel, setChannel] = useState(null);
const [channelModalOpen, setChannelModalOpen] = useState(false);
const [rowSelection, setRowSelection] = useState([]);
const [channelGroupOptions, setChannelGroupOptions] = useState([]);
const [textToCopy, setTextToCopy] = useState('');
const [filterValues, setFilterValues] = useState({});
// const theme = useTheme();
const theme = useMantineTheme();
const { showVideo } = useVideoStore();
const {
channels,
isLoading: channelsLoading,
fetchChannels,
setChannelsPageSelection,
} = useChannelsStore();
useEffect(() => {
setChannelGroupOptions([
...new Set(
Object.values(channels).map((channel) => channel.channel_group?.name)
),
]);
}, [channels]);
const handleFilterChange = (columnId, value) => {
setFilterValues((prev) => ({
...prev,
[columnId]: value ? value.toLowerCase() : '',
}));
};
const hdhrUrlRef = useRef(null);
const m3uUrlRef = useRef(null);
const epgUrlRef = useRef(null);
const {
environment: { env_mode },
} = useSettingsStore();
// Configure columns
const columns = useMemo(
() => [
{
header: '#',
size: 50,
accessorKey: 'channel_number',
},
{
header: 'Name',
accessorKey: 'channel_name',
mantineTableHeadCellProps: {
sx: { textAlign: 'center' },
},
Header: ({ column }) => (
<TextInput
name="name"
placeholder="Name"
value={filterValues[column.id]}
onChange={(e) => {
e.stopPropagation();
handleFilterChange(column.id, e.target.value);
}}
size="xs"
/>
),
Cell: ({ cell }) => (
<div
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{cell.getValue()}
</div>
),
},
{
header: 'Group',
accessorFn: (row) => row.channel_group?.name || '',
Header: ({ column }) => (
<Select
placeholder="Group"
searchable
size="xs"
nothingFound="No options"
onChange={(e, value) => {
e.stopPropagation();
handleGroupChange(value);
}}
data={channelGroupOptions}
/>
),
},
{
header: 'Logo',
accessorKey: 'logo_url',
enableSorting: false,
size: 55,
Cell: ({ cell }) => (
<Grid
container
direction="row"
sx={{
justifyContent: 'center',
alignItems: 'center',
}}
>
<img src={cell.getValue() || logo} width="20" alt="channel logo" />
</Grid>
),
meta: {
filterVariant: null,
},
},
],
[channelGroupOptions, filterValues]
);
// Access the row virtualizer instance (optional)
const rowVirtualizerInstanceRef = useRef(null);
const [isLoading, setIsLoading] = useState(true);
const [sorting, setSorting] = useState([]);
const editChannel = async (ch = null) => {
setChannel(ch);
setChannelModalOpen(true);
};
const deleteChannel = async (id) => {
await API.deleteChannel(id);
};
function handleWatchStream(channelNumber) {
let vidUrl = `/output/stream/${channelNumber}/`;
if (env_mode == 'dev') {
vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`;
}
showVideo(vidUrl);
}
// (Optional) bulk delete, but your endpoint is @TODO
const deleteChannels = async () => {
setIsLoading(true);
const selected = table
.getRowModel()
.rows.filter((row) => row.getIsSelected());
await API.deleteChannels(selected.map((row) => row.original.id));
setIsLoading(false);
};
//
// The "Assign Channels" button logic
//
const assignChannels = async () => {
try {
// Get row order from the table
const rowOrder = table.getRowModel().rows.map((row) => row.original.id);
// Call our custom API endpoint
setIsLoading(true);
const result = await API.assignChannelNumbers(rowOrder);
setIsLoading(false);
// We might get { message: "Channels have been auto-assigned!" }
notifications.show({
title: result.message || 'Channels assigned',
color: 'green.5',
});
// Refresh the channel list
await fetchChannels();
} catch (err) {
console.error(err);
notifications.show({
title: 'Failed to assign channels',
color: 'red.5',
});
}
};
//
// The new "Match EPG" button logic
//
const matchEpg = async () => {
try {
// Hit our new endpoint that triggers the fuzzy matching Celery task
const resp = await fetch('/api/channels/channels/match-epg/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${await API.getAuthToken()}`,
},
});
if (resp.ok) {
showAlert('EPG matching task started!');
} else {
const text = await resp.text();
showAlert(`Failed to start EPG matching: ${text}`);
}
} catch (err) {
showAlert(`Error: ${err.message}`);
}
};
const closeChannelForm = () => {
setChannel(null);
setChannelModalOpen(false);
};
useEffect(() => {
if (typeof window !== 'undefined') {
setIsLoading(false);
}
}, []);
useEffect(() => {
// Scroll to the top of the table when sorting changes
try {
rowVirtualizerInstanceRef.current?.scrollToIndex?.(0);
} catch (error) {
console.error(error);
}
}, [sorting]);
const handleCopy = async (textToCopy, ref) => {
try {
await navigator.clipboard.writeText(textToCopy);
notifications.show({
title: 'Copied!',
// style: { width: '200px', left: '200px' },
});
} catch (err) {
const inputElement = ref.current; // Get the actual input
if (inputElement) {
inputElement.focus();
inputElement.select();
// For older browsers
document.execCommand('copy');
notifications.show({ title: 'Copied!' });
}
}
};
// Example copy URLs
const copyM3UUrl = () => {
handleCopy(
`${window.location.protocol}//${window.location.host}/output/m3u`,
m3uUrlRef
);
};
const copyEPGUrl = () => {
handleCopy(
`${window.location.protocol}//${window.location.host}/output/epg`,
epgUrlRef
);
};
const copyHDHRUrl = () => {
handleCopy(
`${window.location.protocol}//${window.location.host}/output/hdhr`,
hdhrUrlRef
);
};
useEffect(() => {
const selectedRows = table
.getSelectedRowModel()
.rows.map((row) => row.original);
setChannelsPageSelection(selectedRows);
}, [rowSelection]);
const filteredData = Object.values(channels).filter((row) =>
columns.every(({ accessorKey }) =>
filterValues[accessorKey]
? row[accessorKey]?.toLowerCase().includes(filterValues[accessorKey])
: true
)
);
const table = useMantineReactTable({
...TableHelper.defaultProperties,
columns,
data: filteredData,
enablePagination: false,
enableColumnActions: false,
enableRowVirtualization: true,
enableRowSelection: true,
renderTopToolbar: false,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
state: {
isLoading: isLoading || channelsLoading,
sorting,
rowSelection,
},
rowVirtualizerInstanceRef,
rowVirtualizerOptions: { overscan: 5 },
initialState: {
density: 'compact',
},
enableRowActions: true,
enableExpandAll: false,
displayColumnDefOptions: {
'mrt-row-select': {
size: 20,
},
'mrt-row-expand': {
size: 10,
header: '',
},
'mrt-row-actions': {
size: 74,
},
},
mantineExpandButtonProps: ({ row, table }) => ({
onClick: () => {
setRowSelection({ [row.index]: true });
table.setExpanded({ [row.id]: !row.getIsExpanded() });
},
size: 'xs',
style: {
transform: row.getIsExpanded() ? 'rotate(180deg)' : 'rotate(-90deg)',
transition: 'transform 0.2s',
},
}),
renderDetailPanel: ({ row }) => (
<ChannelStreams channel={row.original} isExpanded={row.getIsExpanded()} />
),
renderRowActions: ({ row }) => (
<Box sx={{ justifyContent: 'right' }}>
<Center>
<Tooltip label="Edit Channel">
<ActionIcon
size="sm"
variant="transparent"
color="yellow.5"
onClick={() => {
editChannel(row.original);
}}
>
<SquarePen size="18" />
</ActionIcon>
</Tooltip>
<Tooltip label="Delete Channel">
<ActionIcon
size="sm"
variant="transparent"
color="red.9"
onClick={() => deleteChannel(row.original.id)}
>
<SquareMinus size="18" />
</ActionIcon>
</Tooltip>
<Tooltip label="Preview Channel">
<ActionIcon
size="sm"
variant="transparent"
color="green.5"
onClick={() => handleWatchStream(row.original.channel_number)}
>
<CirclePlay size="18" />
</ActionIcon>
</Tooltip>
</Center>
</Box>
),
mantineTableContainerProps: {
style: {
height: 'calc(100vh - 127px)',
overflowY: 'auto',
},
},
});
return (
<Box>
{/* Header Row: outside the Paper */}
<Flex
style={{ display: 'flex', alignItems: 'center', paddingBottom: 10 }}
gap={15}
>
<Text
w={88}
h={24}
style={{
fontFamily: 'Inter, sans-serif',
fontWeight: 500,
fontSize: '20px',
lineHeight: 1,
letterSpacing: '-0.3px',
color: 'gray.6', // Adjust this to match MUI's theme.palette.text.secondary
marginBottom: 0,
}}
>
Channels
</Text>
<Flex
style={{
display: 'flex',
alignItems: 'center',
marginLeft: 10,
}}
>
<Text
w={37}
h={17}
style={{
fontFamily: 'Inter, sans-serif',
fontWeight: 400,
fontSize: '14px',
lineHeight: 1,
letterSpacing: '-0.3px',
color: 'gray.6', // Adjust this to match MUI's theme.palette.text.secondary
}}
>
Links:
</Text>
<Group gap={5} style={{ paddingLeft: 10 }}>
<Popover withArrow shadow="md">
<Popover.Target>
<Button
leftSection={<Tv2 size={18} />}
size="compact-sm"
p={5}
color="green"
variant="subtle"
style={{
borderColor: theme.palette.custom.greenMain,
color: theme.palette.custom.greenMain,
}}
>
HDHR
</Button>
</Popover.Target>
<Popover.Dropdown>
<Group>
<TextInput ref={hdhrUrlRef} value={hdhrUrl} size="small" />
<ActionIcon
onClick={copyHDHRUrl}
size="sm"
variant="transparent"
color="gray.5"
>
<ContentCopy size="18" fontSize="small" />
</ActionIcon>
</Group>
</Popover.Dropdown>
</Popover>
<Popover withArrow shadow="md">
<Popover.Target>
<Button
leftSection={<ScreenShare size={18} />}
size="compact-sm"
p={5}
variant="subtle"
style={{
borderColor: theme.palette.custom.indigoMain,
color: theme.palette.custom.indigoMain,
}}
>
M3U
</Button>
</Popover.Target>
<Popover.Dropdown>
<Group>
<TextInput ref={m3uUrlRef} value={m3uUrl} size="small" />
<ActionIcon
onClick={copyM3UUrl}
size="sm"
variant="transparent"
color="gray.5"
>
<ContentCopy size="18" fontSize="small" />
</ActionIcon>
</Group>
</Popover.Dropdown>
</Popover>
<Popover withArrow shadow="md">
<Popover.Target>
<Button
leftSection={<Scroll size={18} />}
size="compact-sm"
p={5}
variant="subtle"
color="gray.5"
style={{
borderColor: theme.palette.custom.greyBorder,
color: theme.palette.custom.greyBorder,
}}
>
EPG
</Button>
</Popover.Target>
<Popover.Dropdown>
<Group>
<TextInput ref={epgUrlRef} value={epgUrl} size="small" />
<ActionIcon
onClick={copyEPGUrl}
size="sm"
variant="transparent"
color="gray.5"
>
<ContentCopy size="18" fontSize="small" />
</ActionIcon>
</Group>
</Popover.Dropdown>
</Popover>
</Group>
</Flex>
</Flex>
{/* Paper container: contains top toolbar and table (or ghost state) */}
<Paper
style={{
// bgcolor: theme.palette.background.paper,
// borderRadius: 2,
// overflow: 'hidden',
// display: 'flex',
// flexDirection: 'column',
height: 'calc(100vh - 75px)',
}}
>
{/* Top toolbar with Remove, Assign, Auto-match, and Add buttons */}
<Box
style={{
display: 'flex',
// alignItems: 'center',
// backgroundColor: theme.palette.background.paper,
justifyContent: 'flex-end',
padding: 10,
// gap: 1,
}}
>
<Flex gap={6}>
<Button
leftSection={<SquareMinus size={18} />}
variant="default"
size="xs"
onClick={deleteChannels}
>
Remove
</Button>
<Tooltip label="Assign Channel #s">
<Button
leftSection={<ArrowDown01 size={18} />}
variant="default"
size="xs"
onClick={assignChannels}
p={5}
>
Assign
</Button>
</Tooltip>
<Tooltip label="Auto-Match EPG">
<Button
leftSection={<Binary size={18} />}
variant="default"
size="xs"
onClick={matchEpg}
p={5}
>
Auto-Match
</Button>
</Tooltip>
<Button
leftSection={<SquarePlus size={18} />}
variant="light"
size="xs"
onClick={() => editChannel()}
p={5}
color="green"
style={{
borderWidth: '1px',
borderColor: 'green',
color: 'white',
}}
>
Add
</Button>
</Flex>
</Box>
{/* Table or ghost empty state inside Paper */}
<Box>
{Object.keys(channels).length === 0 && (
<Box
style={{
paddingTop: 20,
bgcolor: theme.palette.background.paper,
}}
>
<Center>
<Box
style={{
textAlign: 'center',
width: '55%',
}}
>
<Text
style={{
fontFamily: 'Inter, sans-serif',
fontWeight: 400,
fontSize: '20px',
lineHeight: '28px',
letterSpacing: '-0.3px',
color: theme.palette.text.secondary,
mb: 1,
}}
>
Its recommended to create channels after adding your M3U or
streams.
</Text>
<Text
style={{
fontFamily: 'Inter, sans-serif',
fontWeight: 400,
fontSize: '16px',
lineHeight: '24px',
letterSpacing: '-0.2px',
color: theme.palette.text.secondary,
mb: 2,
}}
>
You can still create channels without streams if youd like,
and map them later.
</Text>
<Button
leftSection={<SquarePlus size={18} />}
variant="light"
size="xs"
onClick={() => editChannel()}
color="gray"
style={{
marginTop: 20,
borderWidth: '1px',
borderColor: 'gray',
color: 'white',
}}
>
Create Channel
</Button>
</Box>
</Center>
<Center>
<Box
component="img"
src={ghostImage}
alt="Ghost"
style={{
paddingTop: 30,
width: '120px',
height: 'auto',
opacity: 0.2,
pointerEvents: 'none',
}}
/>
</Center>
</Box>
)}
</Box>
{Object.keys(channels).length > 0 && (
<MantineReactTable table={table} />
)}
</Paper>
<ChannelForm
channel={channel}
isOpen={channelModalOpen}
onClose={closeChannelForm}
/>
</Box>
);
};
export default ChannelsTable;

View file

@ -1,205 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
MaterialReactTable,
MRT_ShowHideColumnsButton,
MRT_ToggleFullScreenButton,
useMaterialReactTable,
} from 'material-react-table';
import {
Box,
Grid2,
Stack,
Typography,
IconButton,
Tooltip,
Checkbox,
Select,
MenuItem,
Snackbar,
} from '@mui/material';
import API from '../../api';
import {
Delete as DeleteIcon,
Edit as EditIcon,
Add as AddIcon,
SwapVert as SwapVertIcon,
Check as CheckIcon,
Close as CloseIcon,
Refresh as RefreshIcon,
} from '@mui/icons-material';
import useEPGsStore from '../../store/epgs';
import EPGForm from '../forms/EPG';
import { TableHelper } from '../../helpers';
const EPGsTable = () => {
const [epg, setEPG] = useState(null);
const [epgModalOpen, setEPGModalOpen] = useState(false);
const [rowSelection, setRowSelection] = useState([]);
const [snackbarMessage, setSnackbarMessage] = useState('');
const [snackbarOpen, setSnackbarOpen] = useState(false);
const epgs = useEPGsStore((state) => state.epgs);
const columns = useMemo(
//column definitions...
() => [
{
header: 'Name',
accessorKey: 'name',
},
{
header: 'Source Type',
accessorKey: 'source_type',
},
{
header: 'URL / API Key',
accessorKey: 'max_streams',
},
],
[]
);
//optionally access the underlying virtualizer instance
const rowVirtualizerInstanceRef = useRef(null);
const [isLoading, setIsLoading] = useState(true);
const [sorting, setSorting] = useState([]);
const closeSnackbar = () => {
setSnackbarOpen(false);
};
const editEPG = async (epg = null) => {
setEPG(epg);
setEPGModalOpen(true);
};
const deleteEPG = async (id) => {
setIsLoading(true);
await API.deleteEPG(id);
setIsLoading(false);
};
const refreshEPG = async (id) => {
await API.refreshEPG(id);
setSnackbarMessage('EPG refresh initiated');
setSnackbarOpen(true);
};
const closeEPGForm = () => {
setEPG(null);
setEPGModalOpen(false);
};
useEffect(() => {
if (typeof window !== 'undefined') {
setIsLoading(false);
}
}, []);
useEffect(() => {
//scroll to the top of the table when the sorting changes
try {
rowVirtualizerInstanceRef.current?.scrollToIndex?.(0);
} catch (error) {
console.error(error);
}
}, [sorting]);
const table = useMaterialReactTable({
...TableHelper.defaultProperties,
columns,
data: epgs,
enablePagination: false,
enableRowVirtualization: true,
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
state: {
isLoading,
sorting,
rowSelection,
},
rowVirtualizerInstanceRef, //optional
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
initialState: {
density: 'compact',
},
enableRowActions: true,
renderRowActions: ({ row }) => (
<>
<IconButton
size="small" // Makes the button smaller
color="info" // Red color for delete actions
onClick={() => editEPG(row.original)}
sx={{ pt: 0, pb: 0 }}
>
<EditIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
<IconButton
size="small" // Makes the button smaller
color="error" // Red color for delete actions
onClick={() => deleteEPG(row.original.id)}
sx={{ pt: 0, pb: 0 }}
>
<DeleteIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
<IconButton
size="small" // Makes the button smaller
color="info" // Red color for delete actions
onClick={() => refreshEPG(row.original.id)}
sx={{ pt: 0, pb: 0 }}
>
<RefreshIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
</>
),
muiTableContainerProps: {
sx: {
height: 'calc(43vh - 0px)',
},
},
renderTopToolbarCustomActions: ({ table }) => (
<Stack
direction="row"
sx={{
alignItems: 'center',
}}
>
<Typography>EPGs</Typography>
<Tooltip title="Add New EPG">
<IconButton
size="small" // Makes the button smaller
color="success" // Red color for delete actions
variant="contained"
onClick={() => editEPG()}
>
<AddIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
</Tooltip>
</Stack>
),
});
return (
<Box
sx={{
padding: 1,
}}
>
<MaterialReactTable table={table} />
<EPGForm epg={epg} isOpen={epgModalOpen} onClose={closeEPGForm} />
<Snackbar
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
open={snackbarOpen}
autoHideDuration={5000}
onClose={closeSnackbar}
message={snackbarMessage}
/>
</Box>
);
};
export default EPGsTable;

View file

@ -0,0 +1,226 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { MantineReactTable, useMantineReactTable } from 'mantine-react-table';
import API from '../../api';
import {
Delete as DeleteIcon,
Edit as EditIcon,
Add as AddIcon,
Refresh as RefreshIcon,
} from '@mui/icons-material';
import useEPGsStore from '../../store/epgs';
import EPGForm from '../forms/EPG';
import { TableHelper } from '../../helpers';
import {
ActionIcon,
Text,
Tooltip,
Box,
Paper,
Button,
Flex,
useMantineTheme,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { IconSquarePlus } from '@tabler/icons-react';
import { RefreshCcw, SquareMinus, SquarePen } from 'lucide-react';
const EPGsTable = () => {
const [epg, setEPG] = useState(null);
const [epgModalOpen, setEPGModalOpen] = useState(false);
const [rowSelection, setRowSelection] = useState([]);
const epgs = useEPGsStore((state) => state.epgs);
const theme = useMantineTheme();
const columns = useMemo(
//column definitions...
() => [
{
header: 'Name',
accessorKey: 'name',
},
{
header: 'Source Type',
accessorKey: 'source_type',
},
{
header: 'URL / API Key',
accessorKey: 'max_streams',
},
],
[]
);
//optionally access the underlying virtualizer instance
const rowVirtualizerInstanceRef = useRef(null);
const [isLoading, setIsLoading] = useState(true);
const [sorting, setSorting] = useState([]);
const editEPG = async (epg = null) => {
setEPG(epg);
setEPGModalOpen(true);
};
const deleteEPG = async (id) => {
setIsLoading(true);
await API.deleteEPG(id);
setIsLoading(false);
};
const refreshEPG = async (id) => {
await API.refreshEPG(id);
notifications.show({
title: 'EPG refresh initiated',
});
};
const closeEPGForm = () => {
setEPG(null);
setEPGModalOpen(false);
};
useEffect(() => {
if (typeof window !== 'undefined') {
setIsLoading(false);
}
}, []);
useEffect(() => {
//scroll to the top of the table when the sorting changes
try {
rowVirtualizerInstanceRef.current?.scrollToIndex?.(0);
} catch (error) {
console.error(error);
}
}, [sorting]);
const table = useMantineReactTable({
...TableHelper.defaultProperties,
columns,
data: epgs,
enablePagination: false,
enableRowVirtualization: true,
enableRowSelection: false,
renderTopToolbar: false,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
state: {
isLoading,
sorting,
rowSelection,
},
rowVirtualizerInstanceRef, //optional
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
initialState: {
density: 'compact',
},
enableRowActions: true,
renderRowActions: ({ row }) => (
<>
<ActionIcon
variant="transparent"
size="sm" // Makes the button smaller
color="yellow.5" // Red color for delete actions
onClick={() => editEPG(row.original)}
>
<SquarePen size="18" /> {/* Small icon size */}
</ActionIcon>
<ActionIcon
variant="transparent"
size="sm" // Makes the button smaller
color="red.9" // Red color for delete actions
onClick={() => deleteEPG(row.original.id)}
>
<SquareMinus size="18" /> {/* Small icon size */}
</ActionIcon>
<ActionIcon
variant="transparent"
size="sm" // Makes the button smaller
color="blue.5" // Red color for delete actions
onClick={() => refreshEPG(row.original.id)}
>
<RefreshCcw size="18" /> {/* Small icon size */}
</ActionIcon>
</>
),
mantineTableContainerProps: {
style: {
height: 'calc(40vh - 0px)',
},
},
});
return (
<Box>
<Flex
style={{
display: 'flex',
alignItems: 'center',
paddingBottom: 10,
}}
gap={15}
>
<Text
h={24}
style={{
fontFamily: 'Inter, sans-serif',
fontWeight: 500,
fontSize: '20px',
lineHeight: 1,
letterSpacing: '-0.3px',
color: 'gray.6', // Adjust this to match MUI's theme.palette.text.secondary
marginBottom: 0,
}}
>
EPGs
</Text>
</Flex>
<Paper
style={{
bgcolor: theme.palette.background.paper,
borderRadius: 2,
}}
>
{/* Top toolbar with Remove, Assign, Auto-match, and Add buttons */}
<Box
style={{
display: 'flex',
// alignItems: 'center',
// backgroundColor: theme.palette.background.paper,
justifyContent: 'flex-end',
padding: 10,
// gap: 1,
}}
>
<Flex gap={6}>
<Tooltip label="Assign">
<Button
leftSection={<IconSquarePlus size={18} />}
variant="light"
size="xs"
onClick={() => editEPG()}
p={5}
color="green"
style={{
borderWidth: '1px',
borderColor: 'green',
color: 'white',
}}
>
Add EPG
</Button>
</Tooltip>
</Flex>
</Box>
</Paper>
<MantineReactTable table={table} />
<EPGForm epg={epg} isOpen={epgModalOpen} onClose={closeEPGForm} />
</Box>
);
};
export default EPGsTable;

View file

@ -1,17 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
MaterialReactTable,
useMaterialReactTable,
} from 'material-react-table';
import {
Box,
Stack,
Typography,
IconButton,
Tooltip,
Select,
MenuItem,
} from '@mui/material';
import { MantineReactTable, useMantineReactTable } from 'mantine-react-table';
import API from '../../api';
import {
Delete as DeleteIcon,
@ -20,11 +8,53 @@ import {
SwapVert as SwapVertIcon,
Check as CheckIcon,
Close as CloseIcon,
Refresh as RefreshIcon,
} from '@mui/icons-material';
import {
LiveTv as LiveTvIcon,
ContentCopy,
Tv as TvIcon,
Clear as ClearIcon,
IndeterminateCheckBox,
CompareArrows,
Code,
AddBox,
Hd as HdIcon,
} from '@mui/icons-material';
import usePlaylistsStore from '../../store/playlists';
import M3UForm from '../forms/M3U';
import { TableHelper } from '../../helpers';
import {
useMantineTheme,
Paper,
Button,
Flex,
Text,
Box,
ActionIcon,
Tooltip,
Select,
} from '@mantine/core';
import {
Tv2,
ScreenShare,
Scroll,
SquareMinus,
Pencil,
ArrowUp,
ArrowDown,
ArrowUpDown,
TvMinimalPlay,
SquarePen,
RefreshCcw,
} from 'lucide-react';
import {
IconArrowDown,
IconArrowUp,
IconDeviceDesktopSearch,
IconSelector,
IconSortAscendingNumbers,
IconSquarePlus,
} from '@tabler/icons-react'; // Import custom icons
const Example = () => {
const [playlist, setPlaylist] = useState(null);
@ -34,6 +64,8 @@ const Example = () => {
const playlists = usePlaylistsStore((state) => state.playlists);
const theme = useMantineTheme();
const columns = useMemo(
//column definitions...
() => [
@ -44,6 +76,17 @@ const Example = () => {
{
header: 'URL / File',
accessorKey: 'server_url',
Cell: ({ cell }) => (
<div
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{cell.getValue()}
</div>
),
},
{
header: 'Max Streams',
@ -55,7 +98,7 @@ const Example = () => {
accessorKey: 'is_active',
size: 100,
sortingFn: 'basic',
muiTableBodyCellProps: {
mantineTableBodyCellProps: {
align: 'left',
},
Cell: ({ cell }) => (
@ -67,29 +110,6 @@ const Example = () => {
)}
</Box>
),
Filter: ({ column }) => (
<Box>
<Select
size="small"
variant="standard"
value={activeFilterValue}
onChange={(e) => {
setActiveFilterValue(e.target.value);
column.setFilterValue(e.target.value);
}}
displayEmpty
fullWidth
>
<MenuItem value="all">All</MenuItem>
<MenuItem value="true">Active</MenuItem>
<MenuItem value="false">Inactive</MenuItem>
</Select>
</Box>
),
filterFn: (row, _columnId, activeFilterValue) => {
if (!activeFilterValue) return true; // Show all if no filter
return String(row.getValue('is_active')) === activeFilterValue;
},
},
],
[]
@ -143,14 +163,15 @@ const Example = () => {
}
}, [sorting]);
const table = useMaterialReactTable({
const table = useMantineReactTable({
...TableHelper.defaultProperties,
columns,
data: playlists,
enablePagination: false,
enableRowVirtualization: true,
// enableRowSelection: true,
enableRowSelection: false,
onRowSelectionChange: setRowSelection,
renderTopToolbar: false,
onSortingChange: setSorting,
state: {
isLoading,
@ -165,71 +186,103 @@ const Example = () => {
enableRowActions: true,
renderRowActions: ({ row }) => (
<>
<IconButton
size="small" // Makes the button smaller
color="warning" // Red color for delete actions
<ActionIcon
variant="transparent"
size="sm"
color="yellow.5"
onClick={() => {
editPlaylist(row.original);
}}
sx={{ pt: 0, pb: 0 }}
>
<EditIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
<IconButton
size="small" // Makes the button smaller
color="error" // Red color for delete actions
<SquarePen size="18" />
</ActionIcon>
<ActionIcon
variant="transparent"
size="sm"
color="red.9"
onClick={() => deletePlaylist(row.original.id)}
sx={{ pt: 0, pb: 0 }}
>
<DeleteIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
<IconButton
size="small" // Makes the button smaller
color="info" // Red color for delete actions
variant="contained"
<SquareMinus size="18" />
</ActionIcon>
<ActionIcon
variant="transparent"
size="sm"
color="blue.5"
onClick={() => refreshPlaylist(row.original.id)}
sx={{ pt: 0, pb: 0 }}
>
<RefreshIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
<RefreshCcw size="18" />
</ActionIcon>
</>
),
muiTableContainerProps: {
sx: {
height: 'calc(43vh - 0px)',
pr: 1,
pl: 1,
mantineTableContainerProps: {
style: {
height: 'calc(40vh - 0px)',
},
},
renderTopToolbarCustomActions: ({ table }) => (
<Stack
direction="row"
sx={{
alignItems: 'center',
}}
>
<Typography>M3U Accounts</Typography>
<Tooltip title="Add New M3U Account">
<IconButton
size="small" // Makes the button smaller
color="success" // Red color for delete actions
variant="contained"
onClick={() => editPlaylist()}
>
<AddIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
</Tooltip>
</Stack>
),
});
return (
<Box
sx={{
padding: 1,
}}
>
<MaterialReactTable table={table} />
<Box>
<Flex
style={{ display: 'flex', alignItems: 'center', paddingBottom: 10 }}
gap={15}
>
<Text
h={24}
style={{
fontFamily: 'Inter, sans-serif',
fontWeight: 500,
fontSize: '20px',
lineHeight: 1,
letterSpacing: '-0.3px',
color: 'gray.6', // Adjust this to match MUI's theme.palette.text.secondary
marginBottom: 0,
}}
>
M3U Accounts
</Text>
</Flex>
<Paper
style={{
bgcolor: theme.palette.background.paper,
borderRadius: 2,
}}
>
{/* Top toolbar with Remove, Assign, Auto-match, and Add buttons */}
<Box
style={{
display: 'flex',
// alignItems: 'center',
// backgroundColor: theme.palette.background.paper,
justifyContent: 'flex-end',
padding: 10,
// gap: 1,
}}
>
<Flex gap={6}>
<Tooltip label="Assign">
<Button
leftSection={<IconSquarePlus size={14} />}
variant="light"
size="xs"
onClick={() => editPlaylist()}
p={5}
color="green"
style={{
borderWidth: '1px',
borderColor: 'green',
color: 'white',
}}
>
Add
</Button>
</Tooltip>
</Flex>
</Box>
</Paper>
<MantineReactTable table={table} />
<M3UForm
playlist={playlist}
isOpen={playlistModalOpen}

View file

@ -1,49 +1,41 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
MaterialReactTable,
MRT_ShowHideColumnsButton,
MRT_ToggleFullScreenButton,
useMaterialReactTable,
} from 'material-react-table';
import {
Box,
Grid2,
Stack,
Typography,
IconButton,
Tooltip,
Checkbox,
Select,
MenuItem,
} from '@mui/material';
import { MantineReactTable, useMantineReactTable } from 'mantine-react-table';
import API from '../../api';
import {
Delete as DeleteIcon,
Edit as EditIcon,
Add as AddIcon,
SwapVert as SwapVertIcon,
Check as CheckIcon,
Close as CloseIcon,
Refresh as RefreshIcon,
} from '@mui/icons-material';
import useEPGsStore from '../../store/epgs';
import StreamProfileForm from '../forms/StreamProfile';
import useStreamProfilesStore from '../../store/streamProfiles';
import { TableHelper } from '../../helpers';
import useSettingsStore from '../../store/settings';
import useAlertStore from '../../store/alerts';
import { notifications } from '@mantine/notifications';
import {
Box,
ActionIcon,
Tooltip,
Text,
Paper,
Flex,
Button,
useMantineTheme,
} from '@mantine/core';
import { IconSquarePlus } from '@tabler/icons-react';
import { SquareMinus, SquarePen } from 'lucide-react';
const StreamProfiles = () => {
const [profile, setProfile] = useState(null);
const [profileModalOpen, setProfileModalOpen] = useState(false);
const [rowSelection, setRowSelection] = useState([]);
const [snackbarMessage, setSnackbarMessage] = useState('');
const [snackbarOpen, setSnackbarOpen] = useState(false);
const [activeFilterValue, setActiveFilterValue] = useState('all');
const streamProfiles = useStreamProfilesStore((state) => state.profiles);
const { settings } = useSettingsStore();
const { showAlert } = useAlertStore();
const theme = useMantineTheme();
const columns = useMemo(
//column definitions...
@ -81,23 +73,18 @@ const StreamProfiles = () => {
<Box>
<Select
size="small"
variant="standard"
value={activeFilterValue}
onChange={(e) => {
setActiveFilterValue(e.target.value);
column.setFilterValue(e.target.value);
}}
displayEmpty
fullWidth
>
<MenuItem value="all">All</MenuItem>
<MenuItem value="true">Active</MenuItem>
<MenuItem value="false">Inactive</MenuItem>
</Select>
data={['All', 'Active', 'Inactive']}
/>
</Box>
),
filterFn: (row, _columnId, filterValue) => {
if (filterValue == 'all') return true; // Show all if no filter
if (filterValue == 'all') return true;
return String(row.getValue('is_active')) === filterValue;
},
},
@ -118,7 +105,10 @@ const StreamProfiles = () => {
const deleteStreamProfile = async (id) => {
if (id == settings['default-stream-profile'].value) {
showAlert('Cannot delete default stream-profile', 'error');
notifications.show({
title: 'Cannot delete default stream-profile',
color: 'red.5',
});
return;
}
@ -145,19 +135,20 @@ const StreamProfiles = () => {
}
}, [sorting]);
const table = useMaterialReactTable({
const table = useMantineReactTable({
...TableHelper.defaultProperties,
columns,
data: streamProfiles,
enablePagination: false,
enableRowVirtualization: true,
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
// enableRowSelection: true,
renderTopToolbar: false,
// onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
state: {
isLoading,
sorting,
rowSelection,
// rowSelection,
},
rowVirtualizerInstanceRef, //optional
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
@ -167,59 +158,98 @@ const StreamProfiles = () => {
enableRowActions: true,
renderRowActions: ({ row }) => (
<>
<IconButton
size="small" // Makes the button smaller
color="warning" // Red color for delete actions
<ActionIcon
variant="transparent"
color="yellow.5"
size="sm"
onClick={() => editStreamProfile(row.original)}
sx={{ pt: 0, pb: 0 }}
>
<EditIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
<IconButton
size="small" // Makes the button smaller
color="error" // Red color for delete actions
<SquarePen size="18" /> {/* Small icon size */}
</ActionIcon>
<ActionIcon
variant="transparent"
size="sm"
color="red.9"
onClick={() => deleteStreamProfile(row.original.id)}
sx={{ pt: 0, pb: 0 }}
>
<DeleteIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
<SquareMinus fontSize="small" /> {/* Small icon size */}
</ActionIcon>
</>
),
muiTableContainerProps: {
sx: {
height: 'calc(100vh - 73px)', // Subtract padding to avoid cutoff
overflowY: 'auto', // Internal scrolling for the table
mantineTableContainerProps: {
style: {
height: 'calc(100vh - 120px)',
overflowY: 'auto',
},
},
renderTopToolbarCustomActions: ({ table }) => (
<Stack
direction="row"
sx={{
alignItems: 'center',
}}
>
<Typography>Stream Profiles</Typography>
<Tooltip title="Add New Stream Profile">
<IconButton
size="small" // Makes the button smaller
color="success" // Red color for delete actions
variant="contained"
onClick={() => editStreamProfile()}
>
<AddIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
</Tooltip>
</Stack>
),
});
return (
<Box
sx={{
padding: 1,
}}
>
<MaterialReactTable table={table} />
<Box>
<Flex
style={{
display: 'flex',
alignItems: 'center',
paddingBottom: 10,
}}
gap={15}
>
<Text
h={24}
style={{
fontFamily: 'Inter, sans-serif',
fontWeight: 500,
fontSize: '20px',
lineHeight: 1,
letterSpacing: '-0.3px',
color: 'gray.6', // Adjust this to match MUI's theme.palette.text.secondary
marginBottom: 0,
}}
>
Stream Profiles
</Text>
</Flex>
<Paper
style={{
bgcolor: theme.palette.background.paper,
borderRadius: 2,
}}
>
{/* Top toolbar with Remove, Assign, Auto-match, and Add buttons */}
<Box
style={{
display: 'flex',
// alignItems: 'center',
// backgroundColor: theme.palette.background.paper,
justifyContent: 'flex-end',
padding: 10,
// gap: 1,
}}
>
<Flex gap={6}>
<Tooltip label="Assign">
<Button
leftSection={<IconSquarePlus size={18} />}
variant="light"
size="xs"
onClick={() => editStreamProfile()}
p={5}
color="green"
style={{
borderWidth: '1px',
borderColor: 'green',
color: 'white',
}}
>
Add Stream Profile
</Button>
</Tooltip>
</Flex>
</Box>
</Paper>
<MantineReactTable table={table} />
<StreamProfileForm
profile={profile}

View file

@ -1,844 +0,0 @@
import { useEffect, useMemo, useCallback, useState, useRef } from 'react';
import {
MaterialReactTable,
useMaterialReactTable,
MRT_ShowHideColumnsButton, // <-- import this
} from 'material-react-table';
import {
Box,
Stack,
Typography,
IconButton,
Tooltip,
Button,
Menu,
MenuItem,
TextField,
Autocomplete,
InputAdornment,
Paper,
} from '@mui/material';
import API from '../../api';
import { useTheme } from '@mui/material/styles';
import {
Delete as DeleteIcon,
Edit as EditIcon,
Add as AddIcon,
MoreVert as MoreVertIcon,
PlaylistAdd as PlaylistAddIcon,
IndeterminateCheckBox,
AddBox,
} from '@mui/icons-material';
import { TableHelper } from '../../helpers';
import StreamForm from '../forms/Stream';
import usePlaylistsStore from '../../store/playlists';
import useChannelsStore from '../../store/channels';
import { useDebounce } from '../../utils';
import { SquarePlus, ListPlus } from 'lucide-react';
const StreamsTable = ({}) => {
const theme = useTheme();
/**
* useState
*/
const [rowSelection, setRowSelection] = useState([]);
const [stream, setStream] = useState(null);
const [modalOpen, setModalOpen] = useState(false);
const [moreActionsAnchorEl, setMoreActionsAnchorEl] = useState(null);
const [groupOptions, setGroupOptions] = useState([]);
const [m3uOptions, setM3uOptions] = useState([]);
const [actionsOpenRow, setActionsOpenRow] = useState(null);
const [data, setData] = useState([]); // Holds fetched data
const [rowCount, setRowCount] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [sorting, setSorting] = useState([]);
const [selectedStreamIds, setSelectedStreamIds] = useState([]);
const [unselectedStreamIds, setUnselectedStreamIds] = useState([]);
// const [allRowsSelected, setAllRowsSelected] = useState(false);
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: 25,
});
const [filters, setFilters] = useState({
name: '',
group_name: '',
m3u_account: '',
});
const debouncedFilters = useDebounce(filters, 500);
const hasData = data.length > 0;
/**
* Stores
*/
const { playlists } = usePlaylistsStore();
const { channelsPageSelection } = useChannelsStore();
const channelSelectionStreams = useChannelsStore(
(state) => state.channels[state.channelsPageSelection[0]?.id]?.streams
);
const isMoreActionsOpen = Boolean(moreActionsAnchorEl);
// Access the row virtualizer instance (optional)
const rowVirtualizerInstanceRef = useRef(null);
const eligibleSelectedStreamId = selectedStreamIds.find(
(id) =>
channelsPageSelection.length === 1 &&
!(
channelSelectionStreams &&
channelSelectionStreams.map((stream) => stream.id).includes(id)
)
);
/**
* useMemo
*/
const columns = useMemo(
() => [
{
header: 'Name',
accessorKey: 'name',
muiTableHeadCellProps: {
sx: { textAlign: 'center' }, // Center-align the header
},
Header: ({ column }) => (
<TextField
variant="standard"
name="name"
label="Name"
value={filters.name || ''}
onClick={(e) => e.stopPropagation()}
onChange={handleFilterChange}
size="small"
margin="none"
fullWidth
/>
),
},
{
header: 'Group',
accessorKey: 'group_name',
Header: ({ column }) => (
<Autocomplete
disablePortal
options={groupOptions}
size="small"
clearOnEscape
onChange={(e, value) => {
e.stopPropagation();
handleGroupChange(value);
}}
renderInput={(params) => (
<TextField
{...params}
label="Group"
size="small"
variant="standard"
onClick={(e) => e.stopPropagation()}
sx={{
pb: 0.8,
'& .MuiInputBase-root': { fontSize: '0.875rem' },
'& .MuiInputLabel-root': { fontSize: '0.75rem' },
width: '200px',
}}
/>
)}
/>
),
},
{
header: 'M3U',
size: 100,
accessorFn: (row) =>
playlists.find((playlist) => playlist.id === row.m3u_account)?.name,
Header: ({ column }) => (
<Autocomplete
disablePortal
options={playlists.map((playlist) => ({
label: playlist.name,
value: playlist.id,
}))}
size="small"
clearOnEscape
onChange={(e, value) => {
e.stopPropagation();
handleM3UChange(value);
}}
renderInput={(params) => (
<TextField
{...params}
label="M3U"
size="small"
variant="standard"
onClick={(e) => e.stopPropagation()}
sx={{
pb: 0.8,
'& .MuiInputBase-root': { fontSize: '0.875rem' },
'& .MuiInputLabel-root': { fontSize: '0.75rem' },
width: '200px',
}}
/>
)}
/>
),
},
],
[playlists, groupOptions, filters]
);
/**
* Functions
*/
const handleFilterChange = (e) => {
const { name, value } = e.target;
setFilters((prev) => ({
...prev,
[name]: value,
}));
};
const handleGroupChange = (value) => {
setFilters((prev) => ({
...prev,
group_name: value ? value.value : '',
}));
};
const handleM3UChange = (value) => {
setFilters((prev) => ({
...prev,
m3u_account: value ? value.value : '',
}));
};
const fetchData = useCallback(async () => {
setIsLoading(true);
const params = new URLSearchParams();
params.append('page', pagination.pageIndex + 1);
params.append('page_size', pagination.pageSize);
// Apply sorting
if (sorting.length > 0) {
const sortField = sorting[0].id;
const sortDirection = sorting[0].desc ? '-' : '';
params.append('ordering', `${sortDirection}${sortField}`);
}
// Apply debounced filters
Object.entries(debouncedFilters).forEach(([key, value]) => {
if (value) params.append(key, value);
});
try {
const result = await API.queryStreams(params);
setData(result.results);
setRowCount(result.count);
const newSelection = {};
result.results.forEach((item, index) => {
if (selectedStreamIds.includes(item.id)) {
newSelection[index] = true;
}
});
// ✅ Only update rowSelection if it's different
if (JSON.stringify(newSelection) !== JSON.stringify(rowSelection)) {
setRowSelection(newSelection);
}
} catch (error) {
console.error('Error fetching data:', error);
}
const groups = await API.getStreamGroups();
setGroupOptions(groups);
setIsLoading(false);
}, [pagination, sorting, debouncedFilters]);
useEffect(() => {
console.log(pagination);
}, [pagination]);
// Fallback: Individual creation (optional)
const createChannelFromStream = async (stream) => {
await API.createChannelFromStream({
channel_name: stream.name,
channel_number: null,
stream_id: stream.id,
});
};
// Bulk creation: create channels from selected streams in one API call
const createChannelsFromStreams = async () => {
setIsLoading(true);
await API.createChannelsFromStreams(
selectedStreamIds.map((stream_id) => ({
stream_id,
}))
);
setIsLoading(false);
};
const editStream = async (stream = null) => {
setStream(stream);
setModalOpen(true);
};
const deleteStream = async (id) => {
await API.deleteStream(id);
};
const deleteStreams = async () => {
setIsLoading(true);
await API.deleteStreams(selectedStreamIds);
setIsLoading(false);
};
const closeStreamForm = () => {
setStream(null);
setModalOpen(false);
};
const addStreamsToChannel = async () => {
const { streams, ...channel } = { ...channelsPageSelection[0] };
await API.updateChannel({
...channel,
stream_ids: [
...new Set(
channelSelectionStreams
.map((stream) => stream.id)
.concat(selectedStreamIds)
),
],
});
};
const addStreamToChannel = async (streamId) => {
const { streams, ...channel } = { ...channelsPageSelection[0] };
await API.updateChannel({
...channel,
stream_ids: [
...new Set(
channelSelectionStreams.map((stream) => stream.id).concat([streamId])
),
],
});
};
const handleMoreActionsClick = (event, rowId) => {
setMoreActionsAnchorEl(event.currentTarget);
setActionsOpenRow(rowId);
};
const handleMoreActionsClose = () => {
setMoreActionsAnchorEl(null);
setActionsOpenRow(null);
};
const onRowSelectionChange = (updater) => {
setRowSelection((prevRowSelection) => {
const newRowSelection =
typeof updater === 'function' ? updater(prevRowSelection) : updater;
const updatedSelected = new Set([...selectedStreamIds]);
table.getRowModel().rows.forEach((row) => {
if (newRowSelection[row.id] === undefined || !newRowSelection[row.id]) {
updatedSelected.delete(row.original.id);
} else {
updatedSelected.add(row.original.id);
}
});
setSelectedStreamIds([...updatedSelected]);
return newRowSelection;
});
};
const onSelectAllChange = async (e) => {
const selectAll = e.target.checked;
if (selectAll) {
// Get all stream IDs for current view
const params = new URLSearchParams();
Object.entries(debouncedFilters).forEach(([key, value]) => {
if (value) params.append(key, value);
});
const ids = await API.getAllStreamIds(params);
setSelectedStreamIds(ids);
} else {
setSelectedStreamIds([]);
}
const newSelection = {};
table.getRowModel().rows.forEach((item, index) => {
newSelection[index] = selectAll;
});
setRowSelection(newSelection);
};
const onPaginationChange = (updater) => {
const newPagination = updater(pagination);
if (JSON.stringify(newPagination) === JSON.stringify(pagination)) {
// Prevent infinite re-render when there are no results
return;
}
setPagination(updater);
};
const table = useMaterialReactTable({
...TableHelper.defaultProperties,
columns,
data,
enablePagination: true,
manualPagination: true,
enableTopToolbar: false, // completely removes MRT's built-in top toolbar
enableRowVirtualization: true,
renderTopToolbar: () => null, // Removes the entire top toolbar
renderToolbarInternalActions: () => null,
rowVirtualizerInstanceRef,
rowVirtualizerOptions: { overscan: 5 }, // optionally customize the row virtualizer
manualSorting: true,
enableBottomToolbar: true,
enableStickyHeader: true,
onPaginationChange: onPaginationChange,
onSortingChange: setSorting,
rowCount: rowCount,
enableRowSelection: true,
muiSelectAllCheckboxProps: {
checked: selectedStreamIds.length === rowCount && rowCount > 0,
indeterminate:
selectedStreamIds.length > 0 && selectedStreamIds.length !== rowCount,
onChange: onSelectAllChange,
},
onRowSelectionChange: onRowSelectionChange,
initialState: {
density: 'compact',
},
state: {
isLoading,
sorting,
pagination,
rowSelection,
},
enableRowActions: true,
positionActionsColumn: 'first',
enableHiding: false,
// you can still use the custom toolbar callback if you like
renderTopToolbarCustomActions: ({ table }) => {
const selectedRowCount = table.getSelectedRowModel().rows.length;
// optionally do something with selectedRowCount
},
renderRowActions: ({ row }) => (
<>
<Tooltip title="Add to Channel">
<IconButton
size="small"
color="info"
onClick={() => addStreamToChannel(row.original.id)}
sx={{ py: 0, px: 0.5 }}
disabled={
channelsPageSelection.length !== 1 ||
(channelSelectionStreams &&
channelSelectionStreams
.map((stream) => stream.id)
.includes(row.original.id))
}
>
<ListPlus size="18" fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Create New Channel">
<IconButton
size="small"
color="success"
onClick={() => createChannelFromStream(row.original)}
sx={{ py: 0, px: 0.5 }}
>
<SquarePlus size="18" fontSize="small" />
</IconButton>
</Tooltip>
<IconButton
onClick={(event) => handleMoreActionsClick(event, row.original.id)}
size="small"
sx={{ py: 0, px: 0.5 }}
>
<MoreVertIcon />
</IconButton>
<Menu
anchorEl={moreActionsAnchorEl}
open={isMoreActionsOpen && actionsOpenRow === row.original.id}
onClose={handleMoreActionsClose}
>
<MenuItem
onClick={() => editStream(row.original.id)}
disabled={row.original.m3u_account ? true : false}
>
Edit
</MenuItem>
<MenuItem onClick={() => deleteStream(row.original.id)}>
Delete Stream
</MenuItem>
</Menu>
</>
),
muiPaginationProps: {
size: 'small',
rowsPerPageOptions: [25, 50, 100, 250, 500, 1000, 10000],
labelRowsPerPage: 'Rows per page',
},
muiTableContainerProps: {
sx: {
height: 'calc(100vh - 145px)',
overflowY: 'auto',
},
},
displayColumnDefOptions: {
'mrt-row-actions': {
size: 68,
},
'mrt-row-select': {
size: 50,
},
},
});
/**
* useEffects
*/
useEffect(() => {
fetchData();
}, [fetchData]);
useEffect(() => {
if (typeof window !== 'undefined') {
setIsLoading(false);
}
}, []);
useEffect(() => {
// Scroll to the top of the table when sorting changes
try {
rowVirtualizerInstanceRef.current?.scrollToIndex?.(0);
} catch (error) {
console.error(error);
}
}, [sorting]);
return (
<Box>
{/* Header Row */}
<Box sx={{ display: 'flex', alignItems: 'center', pb: 1 }}>
<Typography
sx={{
width: 88,
height: 24,
fontFamily: 'Inter, sans-serif',
fontWeight: 500,
fontSize: '20px',
lineHeight: 1,
letterSpacing: '-0.3px',
color: theme.palette.text.secondary,
mb: 0,
}}
>
Streams
</Typography>
</Box>
{/* Paper container with ghost state vs table */}
<Paper
sx={{
bgcolor: theme.palette.background.paper,
borderRadius: 2,
overflow: 'hidden',
height: 'calc(100vh - 75px)',
display: 'flex',
flexDirection: 'column',
}}
>
{/* Top toolbar: always visible */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
backgroundColor: theme.palette.background.paper,
justifyContent: 'flex-end',
p: 1,
gap: 1,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Tooltip title="Remove">
<Button
variant="outlined"
size="small"
startIcon={
<IndeterminateCheckBox
sx={{ fontSize: 16, color: theme.palette.text.secondary }}
/>
}
sx={{
borderColor: theme.palette.custom.borderDefault,
borderRadius: '4px',
borderWidth: '1px',
borderStyle: 'solid',
height: '25px',
opacity: 0.4,
color: theme.palette.text.secondary,
fontSize: '0.85rem',
px: 1,
py: 0.5,
'&:hover': {
borderColor: theme.palette.custom.borderHover,
},
}}
>
Remove
</Button>
</Tooltip>
<Tooltip title="Add to Channel">
<span>
<Button
variant="outlined"
size="small"
startIcon={
<AddBox
sx={{ fontSize: 16, color: theme.palette.text.secondary }}
/>
}
disabled={
channelsPageSelection.length !== 1 ||
!eligibleSelectedStreamId
}
onClick={() => {
if (eligibleSelectedStreamId) {
addStreamToChannel(eligibleSelectedStreamId);
}
}}
sx={{
minWidth: '57px',
height: '25px',
borderRadius: '4px',
borderColor: theme.palette.custom.successBorder,
borderWidth: '1px',
borderStyle: 'solid',
backgroundColor: theme.palette.custom.successBg,
color: '#fff',
fontSize: '0.85rem',
px: 1,
py: 0.5,
'&:hover': {
backgroundColor: theme.palette.custom.successBgHover,
},
}}
>
Add to Channel
</Button>
</span>
</Tooltip>
<Tooltip title="Create Channels">
<Button
variant="outlined"
size="small"
startIcon={
<AddBox
sx={{ fontSize: 16, color: theme.palette.text.secondary }}
/>
}
disabled={selectedStreamIds.length === 0}
sx={{
minWidth: '57px',
height: '25px',
borderRadius: '4px',
borderColor: theme.palette.custom.successBorder,
borderWidth: '1px',
borderStyle: 'solid',
backgroundColor: theme.palette.custom.successBg,
color: '#fff',
fontSize: '0.85rem',
px: 1,
py: 0.5,
'&:hover': {
backgroundColor: theme.palette.custom.successBgHover,
},
}}
onClick={() => createChannelsFromStreams()}
>
Create Channels
</Button>
</Tooltip>
<Tooltip title="Add Channel">
<Button
variant="contained"
size="small"
onClick={() => editStream()}
startIcon={
<AddBox
sx={{
fontSize: 16,
color: theme.palette.custom.successIcon,
}}
/>
}
sx={{
minWidth: '57px',
height: '25px',
borderRadius: '4px',
borderColor: theme.palette.custom.successBorder,
borderWidth: '1px',
borderStyle: 'solid',
backgroundColor: theme.palette.custom.successBg,
color: '#fff',
fontSize: '0.85rem',
px: 1,
py: 0.5,
'&:hover': {
backgroundColor: theme.palette.custom.successBgHover,
},
}}
>
Add
</Button>
</Tooltip>
{/* Show/Hide Columns Button added to top bar */}
<Tooltip title="Show/Hide Columns">
<MRT_ShowHideColumnsButton table={table} />
</Tooltip>
</Box>
</Box>
{/* Main content */}
<Box
sx={{
flex: 1,
position: 'relative',
bgcolor: theme.palette.background.paper,
}}
>
<StreamForm stream={stream} isOpen={modalOpen} onClose={closeStreamForm} />
{hasData ? (
<Box sx={{ width: "100%", height: "calc(102vh - 150px)", overflow: "auto" }}>
<MaterialReactTable
table={table}
sx={{ height: "100%" }}
/>
</Box>
) : (
// Ghost state placeholder, shown when there is no data
<Box
sx={{
position: 'absolute',
top: '25%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: '420px',
height: '247px',
border: '1px solid #52525C',
borderRadius: '8px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
p: 2,
textAlign: 'center',
}}
>
<Typography
sx={{
fontWeight: 400,
fontSize: '20px',
lineHeight: '28px',
letterSpacing: '-0.3px',
color: '#D4D4D8',
mb: 1,
}}
>
Getting started
</Typography>
<Typography
sx={{
fontWeight: 400,
fontSize: '16px',
lineHeight: '24px',
letterSpacing: '-0.2px',
color: '#9FA3A9',
width: '372px',
mb: 2,
}}
>
In order to get started, add your M3U or start adding custom streams.
</Typography>
<Button
variant="contained"
sx={{
minWidth: '127px',
height: '25px',
borderRadius: '4px',
borderWidth: '1px',
borderStyle: 'solid',
color: theme.palette.text.secondary,
borderColor: theme.palette.custom.borderHover,
backgroundColor: '#1f1f23',
fontFamily: 'Inter, sans-serif',
fontWeight: 400,
fontSize: '0.85rem',
letterSpacing: '-0.2px',
textTransform: 'none',
px: 1,
py: 0.5,
'&:hover': {
borderColor: theme.palette.custom.borderDefault,
backgroundColor: '#17171B',
},
}}
>
Add M3U
</Button>
<Typography sx={{ fontSize: '14px', color: '#71717B', mb: 1 }}>
or
</Typography>
<Button
variant="contained"
sx={{
minWidth: '127px',
height: '25px',
borderRadius: '4px',
borderWidth: '1px',
borderStyle: 'solid',
color: theme.palette.text.secondary,
borderColor: theme.palette.custom.borderHover,
backgroundColor: '#1f1f23',
fontFamily: 'Inter, sans-serif',
fontWeight: 400,
fontSize: '0.85rem',
letterSpacing: '-0.2px',
textTransform: 'none',
px: 1,
py: 0.5,
'&:hover': {
borderColor: theme.palette.custom.borderDefault,
backgroundColor: '#17171B',
},
}}
>
Add Individual Stream
</Button>
</Box>
)}
</Box>
</Paper>
</Box>
);
};
export default StreamsTable;

View file

@ -0,0 +1,746 @@
import { useEffect, useMemo, useCallback, useState, useRef } from 'react';
import { MantineReactTable, useMantineReactTable } from 'mantine-react-table';
import API from '../../api';
import { useTheme } from '@mui/material/styles';
import {
Delete as DeleteIcon,
Edit as EditIcon,
Add as AddIcon,
MoreVert as MoreVertIcon,
PlaylistAdd as PlaylistAddIcon,
IndeterminateCheckBox,
AddBox,
} from '@mui/icons-material';
import { TableHelper } from '../../helpers';
import StreamForm from '../forms/Stream';
import usePlaylistsStore from '../../store/playlists';
import useChannelsStore from '../../store/channels';
import { useDebounce } from '../../utils';
import { SquarePlus, ListPlus, SquareMinus } from 'lucide-react';
import {
TextInput,
ActionIcon,
Select,
Tooltip,
Menu,
Flex,
Box,
Text,
Paper,
Button,
Card,
Stack,
Title,
Divider,
Center,
Pagination,
Group,
NumberInput,
NativeSelect,
} from '@mantine/core';
import {
IconArrowDown,
IconArrowUp,
IconDeviceDesktopSearch,
IconSelector,
IconSortAscendingNumbers,
IconSquarePlus,
} from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
const StreamsTable = ({}) => {
const theme = useTheme();
/**
* useState
*/
const [rowSelection, setRowSelection] = useState([]);
const [stream, setStream] = useState(null);
const [modalOpen, setModalOpen] = useState(false);
const [moreActionsAnchorEl, setMoreActionsAnchorEl] = useState(null);
const [groupOptions, setGroupOptions] = useState([]);
const [m3uOptions, setM3uOptions] = useState([]);
const [actionsOpenRow, setActionsOpenRow] = useState(null);
const [initialDataCount, setInitialDataCount] = useState(null);
const [data, setData] = useState([]); // Holds fetched data
const [rowCount, setRowCount] = useState(0);
const [pageCount, setPageCount] = useState(0);
const [paginationString, setPaginationString] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [sorting, setSorting] = useState([]);
const [selectedStreamIds, setSelectedStreamIds] = useState([]);
const [unselectedStreamIds, setUnselectedStreamIds] = useState([]);
// const [allRowsSelected, setAllRowsSelected] = useState(false);
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: 250,
});
const [filters, setFilters] = useState({
name: '',
group_name: '',
m3u_account: '',
});
const debouncedFilters = useDebounce(filters, 500);
const hasData = data.length > 0;
const navigate = useNavigate();
/**
* Stores
*/
const { playlists } = usePlaylistsStore();
const { channelsPageSelection } = useChannelsStore();
const channelSelectionStreams = useChannelsStore(
(state) => state.channels[state.channelsPageSelection[0]?.id]?.streams
);
const isMoreActionsOpen = Boolean(moreActionsAnchorEl);
// Access the row virtualizer instance (optional)
const rowVirtualizerInstanceRef = useRef(null);
const handleSelectClick = (e) => {
e.stopPropagation();
e.preventDefault();
};
/**
* useMemo
*/
const columns = useMemo(
() => [
{
header: 'Name',
accessorKey: 'name',
mantineTableHeadCellProps: {
style: { textAlign: 'center' }, // Center-align the header
},
Header: ({ column }) => (
<TextInput
name="name"
placeholder="Name"
value={filters.name || ''}
onClick={(e) => e.stopPropagation()}
onChange={handleFilterChange}
size="xs"
/>
),
Cell: ({ cell }) => (
<div
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{cell.getValue()}
</div>
),
},
{
header: 'Group',
accessorKey: 'group_name',
size: 100,
Header: ({ column }) => (
<Box onClick={handleSelectClick}>
<Select
placeholder="Group"
searchable
size="xs"
nothingFound="No options"
onClick={handleSelectClick}
onChange={handleGroupChange}
data={groupOptions}
/>
</Box>
),
Cell: ({ cell }) => (
<div
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{cell.getValue()}
</div>
),
},
{
header: 'M3U',
size: 75,
accessorFn: (row) =>
playlists.find((playlist) => playlist.id === row.m3u_account)?.name,
Header: ({ column }) => (
<Box onClick={handleSelectClick}>
<Select
placeholder="M3U"
searchable
size="xs"
nothingFound="No options"
onClick={handleSelectClick}
onChange={handleM3UChange}
data={playlists.map((playlist) => ({
label: playlist.name,
value: `${playlist.id}`,
}))}
/>
</Box>
),
},
],
[playlists, groupOptions, filters]
);
/**
* Functions
*/
const handleFilterChange = (e) => {
const { name, value } = e.target;
setFilters((prev) => ({
...prev,
[name]: value,
}));
};
const handleGroupChange = (value) => {
setFilters((prev) => ({
...prev,
group_name: value ? value : '',
}));
};
const handleM3UChange = (value) => {
setFilters((prev) => ({
...prev,
m3u_account: value ? value : '',
}));
};
const fetchData = useCallback(async () => {
setIsLoading(true);
const params = new URLSearchParams();
params.append('page', pagination.pageIndex + 1);
params.append('page_size', pagination.pageSize);
// Apply sorting
if (sorting.length > 0) {
const sortField = sorting[0].id;
const sortDirection = sorting[0].desc ? '-' : '';
params.append('ordering', `${sortDirection}${sortField}`);
}
// Apply debounced filters
Object.entries(debouncedFilters).forEach(([key, value]) => {
if (value) params.append(key, value);
});
try {
const result = await API.queryStreams(params);
setData(result.results);
setRowCount(result.count);
setPageCount(Math.ceil(result.count / pagination.pageSize));
// Calculate the starting and ending item indexes
const startItem = pagination.pageIndex * pagination.pageSize + 1; // +1 to start from 1, not 0
const endItem = Math.min(
(pagination.pageIndex + 1) * pagination.pageSize,
result.count
);
if (initialDataCount === null) {
setInitialDataCount(result.count);
}
// Generate the string
setPaginationString(`${startItem} to ${endItem} of ${result.count}`);
const newSelection = {};
result.results.forEach((item, index) => {
if (selectedStreamIds.includes(item.id)) {
newSelection[index] = true;
}
});
// Only update rowSelection if it's different
if (JSON.stringify(newSelection) !== JSON.stringify(rowSelection)) {
setRowSelection(newSelection);
}
} catch (error) {
console.error('Error fetching data:', error);
}
const groups = await API.getStreamGroups();
setGroupOptions(groups);
setIsLoading(false);
}, [pagination, sorting, debouncedFilters]);
// Fallback: Individual creation (optional)
const createChannelFromStream = async (stream) => {
await API.createChannelFromStream({
channel_name: stream.name,
channel_number: null,
stream_id: stream.id,
});
};
// Bulk creation: create channels from selected streams in one API call
const createChannelsFromStreams = async () => {
setIsLoading(true);
await API.createChannelsFromStreams(
selectedStreamIds.map((stream_id) => ({
stream_id,
}))
);
setIsLoading(false);
};
const editStream = async (stream = null) => {
setStream(stream);
setModalOpen(true);
};
const deleteStream = async (id) => {
await API.deleteStream(id);
};
const deleteStreams = async () => {
setIsLoading(true);
await API.deleteStreams(selectedStreamIds);
setIsLoading(false);
};
const closeStreamForm = () => {
setStream(null);
setModalOpen(false);
};
const addStreamsToChannel = async () => {
const { streams, ...channel } = { ...channelsPageSelection[0] };
await API.updateChannel({
...channel,
stream_ids: [
...new Set(
channelSelectionStreams
.map((stream) => stream.id)
.concat(selectedStreamIds)
),
],
});
};
const addStreamToChannel = async (streamId) => {
const { streams, ...channel } = { ...channelsPageSelection[0] };
await API.updateChannel({
...channel,
stream_ids: [
...new Set(
channelSelectionStreams.map((stream) => stream.id).concat([streamId])
),
],
});
};
const handleMoreActionsClick = (event, rowId) => {
setMoreActionsAnchorEl(event.currentTarget);
setActionsOpenRow(rowId);
};
const handleMoreActionsClose = () => {
setMoreActionsAnchorEl(null);
setActionsOpenRow(null);
};
const onRowSelectionChange = (updater) => {
setRowSelection((prevRowSelection) => {
const newRowSelection =
typeof updater === 'function' ? updater(prevRowSelection) : updater;
const updatedSelected = new Set([...selectedStreamIds]);
table.getRowModel().rows.forEach((row) => {
if (newRowSelection[row.id] === undefined || !newRowSelection[row.id]) {
updatedSelected.delete(row.original.id);
} else {
updatedSelected.add(row.original.id);
}
});
setSelectedStreamIds([...updatedSelected]);
return newRowSelection;
});
};
const onSelectAllChange = async (e) => {
const selectAll = e.target.checked;
if (selectAll) {
// Get all stream IDs for current view
const params = new URLSearchParams();
Object.entries(debouncedFilters).forEach(([key, value]) => {
if (value) params.append(key, value);
});
const ids = await API.getAllStreamIds(params);
setSelectedStreamIds(ids);
} else {
setSelectedStreamIds([]);
}
const newSelection = {};
table.getRowModel().rows.forEach((item, index) => {
newSelection[index] = selectAll;
});
setRowSelection(newSelection);
};
const onPageSizeChange = (e) => {
setPagination({
...pagination,
pageSize: e.target.value,
});
};
const onPageIndexChange = (pageIndex) => {
if (!pageIndex || pageIndex > pageCount) {
return;
}
setPagination({
...pagination,
pageIndex: pageIndex - 1,
});
};
const onPaginationChange = (updater) => {
const newPagination = updater(pagination);
if (JSON.stringify(newPagination) === JSON.stringify(pagination)) {
// Prevent infinite re-render when there are no results
return;
}
setPagination(updater);
};
const table = useMantineReactTable({
...TableHelper.defaultProperties,
columns,
data,
enablePagination: true,
manualPagination: true,
enableTopToolbar: false,
enableRowVirtualization: true,
renderTopToolbar: () => null, // Removes the entire top toolbar
renderToolbarInternalActions: () => null,
rowVirtualizerInstanceRef,
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
enableBottomToolbar: true,
renderBottomToolbar: ({ table }) => (
<Group
gap={5}
justify="center"
style={{ padding: 8, borderTop: '1px solid #666' }}
>
<Text size="xs">Page Size</Text>
<NativeSelect
size="xxs"
value={pagination.pageSize}
data={['25', '50', '100', '250', '500', '1000']}
onChange={onPageSizeChange}
style={{ paddingRight: 20 }}
/>
<Pagination
total={pageCount}
value={pagination.pageIndex + 1}
onChange={onPageIndexChange}
size="xs"
withEdges
style={{ paddingRight: 20 }}
/>
<Text size="xs">{paginationString}</Text>
</Group>
),
enableStickyHeader: true,
// onPaginationChange: onPaginationChange,
rowCount: rowCount,
enableRowSelection: true,
mantineSelectAllCheckboxProps: {
checked: selectedStreamIds.length == rowCount,
indeterminate:
selectedStreamIds.length > 0 && selectedStreamIds.length !== rowCount,
onChange: onSelectAllChange,
size: 'xs',
},
muiPaginationProps: {
size: 'small',
rowsPerPageOptions: [25, 50, 100, 250, 500, 1000, 10000],
labelRowsPerPage: 'Rows per page',
},
onSortingChange: setSorting,
onRowSelectionChange: onRowSelectionChange,
initialState: {
density: 'compact',
},
state: {
isLoading,
sorting,
// pagination,
rowSelection,
},
enableRowActions: true,
positionActionsColumn: 'first',
enableHiding: false,
// you can still use the custom toolbar callback if you like
renderTopToolbarCustomActions: ({ table }) => {
const selectedRowCount = table.getSelectedRowModel().rows.length;
// optionally do something with selectedRowCount
},
renderRowActions: ({ row }) => (
<>
<Tooltip label="Add to Channel">
<ActionIcon
size="sm"
color="blue.5"
variant="transparent"
onClick={() => addStreamToChannel(row.original.id)}
disabled={
channelsPageSelection.length !== 1 ||
(channelSelectionStreams &&
channelSelectionStreams
.map((stream) => stream.id)
.includes(row.original.id))
}
>
<ListPlus size="18" fontSize="small" />
</ActionIcon>
</Tooltip>
<Tooltip label="Create New Channel">
<ActionIcon
size="sm"
color="green.5"
variant="transparent"
onClick={() => createChannelFromStream(row.original)}
>
<SquarePlus size="18" fontSize="small" />
</ActionIcon>
</Tooltip>
<Menu>
<Menu.Target>
<ActionIcon
onClick={(event) =>
handleMoreActionsClick(event, row.original.id)
}
variant="transparent"
size="sm"
>
<MoreVertIcon />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
onClick={() => editStream(row.original)}
disabled={row.original.m3u_account ? true : false}
>
Edit
</Menu.Item>
<Menu.Item
onClick={() => deleteStream(row.original.id)}
disabled={row.original.m3u_account ? true : false}
>
Delete Stream
</Menu.Item>
</Menu.Dropdown>
</Menu>
</>
),
mantineTableContainerProps: {
style: {
height: 'calc(100vh - 167px)',
overflowY: 'auto',
},
},
displayColumnDefOptions: {
'mrt-row-actions': {
size: 30,
},
'mrt-row-select': {
size: 20,
},
},
});
/**
* useEffects
*/
useEffect(() => {
fetchData();
}, [fetchData]);
useEffect(() => {
if (typeof window !== 'undefined') {
setIsLoading(false);
}
}, []);
useEffect(() => {
// Scroll to the top of the table when sorting changes
try {
rowVirtualizerInstanceRef.current?.scrollToIndex?.(0);
} catch (error) {
console.error(error);
}
}, [sorting]);
return (
<>
<Flex
style={{ display: 'flex', alignItems: 'center', paddingBottom: 12 }}
gap={15}
>
<Text
w={88}
h={24}
style={{
fontFamily: 'Inter, sans-serif',
fontWeight: 500,
fontSize: '20px',
lineHeight: 1,
letterSpacing: '-0.3px',
// color: 'gray.6', // Adjust this to match MUI's theme.palette.text.secondary
marginBottom: 0,
}}
>
Streams
</Text>
</Flex>
<Paper
style={{
// bgcolor: theme.palette.background.paper,
// borderRadius: 2,
// overflow: 'hidden',
height: 'calc(100vh - 75px)',
// display: 'flex',
// flexDirection: 'column',
}}
>
{/* Top toolbar with Remove, Assign, Auto-match, and Add buttons */}
<Box
style={{
display: 'flex',
// alignItems: 'center',
// backgroundColor: theme.palette.background.paper,
justifyContent: 'flex-end',
padding: 10,
// gap: 1,
}}
>
<Flex gap={6}>
<Button
leftSection={<SquareMinus size={18} />}
variant="default"
size="xs"
onClick={deleteStreams}
>
Remove
</Button>
<Button
leftSection={<IconSquarePlus size={18} />}
variant="default"
size="xs"
onClick={createChannelsFromStreams}
p={5}
>
Create Channels
</Button>
<Button
leftSection={<IconSquarePlus size={18} />}
variant="light"
size="xs"
onClick={() => editStream()}
p={5}
color="green"
style={{
borderWidth: '1px',
borderColor: 'green',
color: 'white',
}}
>
Add Stream
</Button>
</Flex>
</Box>
{initialDataCount === 0 && (
<Center style={{ paddingTop: 20 }}>
<Card
shadow="sm"
padding="lg"
radius="md"
withBorder
style={{
backgroundColor: '#222',
borderColor: '#444',
textAlign: 'center',
width: '400px',
}}
>
<Stack align="center">
<Title order={3} style={{ color: '#d4d4d8' }}>
Getting started
</Title>
<Text size="sm" color="dimmed">
In order to get started, add your M3U or start <br />
adding custom streams.
</Text>
<Button
variant="default"
radius="md"
size="md"
onClick={() => navigate('/m3u')}
style={{
backgroundColor: '#444',
color: '#d4d4d8',
border: '1px solid #666',
}}
>
Add M3U
</Button>
<Divider label="or" labelPosition="center" color="gray" />
<Button
variant="default"
radius="md"
size="md"
onClick={() => editStream()}
style={{
backgroundColor: '#333',
color: '#d4d4d8',
border: '1px solid #666',
}}
>
Add Individual Stream
</Button>
</Stack>
</Card>
</Center>
)}
{initialDataCount > 0 && <MantineReactTable table={table} />}
</Paper>
<StreamForm
stream={stream}
isOpen={modalOpen}
onClose={closeStreamForm}
/>
</>
);
};
export default StreamsTable;

View file

@ -1,238 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
MaterialReactTable,
MRT_ShowHideColumnsButton,
MRT_ToggleFullScreenButton,
useMaterialReactTable,
} from 'material-react-table';
import {
Box,
Grid2,
Stack,
Typography,
IconButton,
Tooltip,
Select,
MenuItem,
} from '@mui/material';
import API from '../../api';
import {
Delete as DeleteIcon,
Edit as EditIcon,
Add as AddIcon,
Check as CheckIcon,
Close as CloseIcon,
} from '@mui/icons-material';
import useUserAgentsStore from '../../store/userAgents';
import UserAgentForm from '../forms/UserAgent';
import { TableHelper } from '../../helpers';
import useSettingsStore from '../../store/settings';
import useAlertStore from '../../store/alerts';
const UserAgentsTable = () => {
const [userAgent, setUserAgent] = useState(null);
const [userAgentModalOpen, setUserAgentModalOpen] = useState(false);
const [rowSelection, setRowSelection] = useState([]);
const [activeFilterValue, setActiveFilterValue] = useState('all');
const userAgents = useUserAgentsStore((state) => state.userAgents);
const { settings } = useSettingsStore();
const { showAlert } = useAlertStore();
const columns = useMemo(
//column definitions...
() => [
{
header: 'Name',
accessorKey: 'user_agent_name',
},
{
header: 'User-Agent',
accessorKey: 'user_agent',
},
{
header: 'Desecription',
accessorKey: 'description',
},
{
header: 'Active',
accessorKey: 'is_active',
size: 100,
sortingFn: 'basic',
muiTableBodyCellProps: {
align: 'left',
},
Cell: ({ cell }) => (
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
{cell.getValue() ? (
<CheckIcon color="success" />
) : (
<CloseIcon color="error" />
)}
</Box>
),
Filter: ({ column }) => (
<Box>
<Select
size="small"
variant="standard"
value={activeFilterValue}
onChange={(e) => {
setActiveFilterValue(e.target.value);
column.setFilterValue(e.target.value);
}}
displayEmpty
fullWidth
>
<MenuItem value="all">All</MenuItem>
<MenuItem value="true">Active</MenuItem>
<MenuItem value="false">Inactive</MenuItem>
</Select>
</Box>
),
filterFn: (row, _columnId, activeFilterValue) => {
if (activeFilterValue == 'all') return true; // Show all if no filter
return String(row.getValue('is_active')) === activeFilterValue;
},
},
],
[]
);
//optionally access the underlying virtualizer instance
const rowVirtualizerInstanceRef = useRef(null);
const [isLoading, setIsLoading] = useState(true);
const [sorting, setSorting] = useState([]);
const editUserAgent = async (userAgent = null) => {
setUserAgent(userAgent);
setUserAgentModalOpen(true);
};
const deleteUserAgent = async (ids) => {
if (Array.isArray(ids)) {
if (ids.includes(settings['default-user-agent'].value)) {
showAlert('Cannot delete default user-agent', 'error');
return;
}
await API.deleteUserAgents(ids);
} else {
if (ids == settings['default-user-agent'].value) {
showAlert('Cannot delete default user-agent', 'error');
return;
}
await API.deleteUserAgent(ids);
}
};
const closeUserAgentForm = () => {
setUserAgent(null);
setUserAgentModalOpen(false);
};
useEffect(() => {
if (typeof window !== 'undefined') {
setIsLoading(false);
}
}, []);
useEffect(() => {
//scroll to the top of the table when the sorting changes
try {
rowVirtualizerInstanceRef.current?.scrollToIndex?.(0);
} catch (error) {
console.error(error);
}
}, [sorting]);
const table = useMaterialReactTable({
...TableHelper.defaultProperties,
columns,
data: userAgents,
enablePagination: false,
enableRowVirtualization: true,
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
state: {
isLoading,
sorting,
rowSelection,
},
rowVirtualizerInstanceRef, //optional
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
initialState: {
density: 'compact',
},
enableRowActions: true,
renderRowActions: ({ row }) => (
<>
<IconButton
size="small" // Makes the button smaller
color="warning" // Red color for delete actions
onClick={() => {
editUserAgent(row.original);
}}
sx={{ pt: 0, pb: 0 }}
>
<EditIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
<IconButton
size="small" // Makes the button smaller
color="error" // Red color for delete actions
onClick={() => deleteUserAgent(row.original.id)}
sx={{ pt: 0, pb: 0 }}
>
<DeleteIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
</>
),
muiTableContainerProps: {
sx: {
height: 'calc(42vh + 5px)',
},
},
renderTopToolbarCustomActions: ({ table }) => (
<Stack
direction="row"
sx={{
alignItems: 'center',
}}
>
<Typography>User-Agents</Typography>
<Tooltip title="Add New User Agent">
<IconButton
size="small" // Makes the button smaller
color="success" // Red color for delete actions
variant="contained"
onClick={() => editUserAgent()}
>
<AddIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
</Tooltip>
</Stack>
),
});
return (
<>
<Box
sx={{
padding: 1,
}}
>
<MaterialReactTable table={table} />
</Box>
<UserAgentForm
userAgent={userAgent}
isOpen={userAgentModalOpen}
onClose={closeUserAgentForm}
/>
</>
);
};
export default UserAgentsTable;

View file

@ -0,0 +1,321 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { MantineReactTable, useMantineReactTable } from 'mantine-react-table';
import API from '../../api';
import {
Delete as DeleteIcon,
Edit as EditIcon,
Add as AddIcon,
Check as CheckIcon,
Close as CloseIcon,
} from '@mui/icons-material';
import useUserAgentsStore from '../../store/userAgents';
import UserAgentForm from '../forms/UserAgent';
import { TableHelper } from '../../helpers';
import useSettingsStore from '../../store/settings';
import { notifications } from '@mantine/notifications';
import {
ActionIcon,
Center,
Flex,
Select,
Tooltip,
Text,
Paper,
Box,
Button,
} from '@mantine/core';
import {
IconArrowDown,
IconArrowUp,
IconDeviceDesktopSearch,
IconSelector,
IconSortAscendingNumbers,
IconSquarePlus,
} from '@tabler/icons-react';
import { SquareMinus, SquarePen } from 'lucide-react';
const UserAgentsTable = () => {
const [userAgent, setUserAgent] = useState(null);
const [userAgentModalOpen, setUserAgentModalOpen] = useState(false);
const [rowSelection, setRowSelection] = useState([]);
const [activeFilterValue, setActiveFilterValue] = useState('all');
const userAgents = useUserAgentsStore((state) => state.userAgents);
const { settings } = useSettingsStore();
const columns = useMemo(
//column definitions...
() => [
{
header: 'Name',
accessorKey: 'user_agent_name',
},
{
header: 'User-Agent',
accessorKey: 'user_agent',
Cell: ({ cell }) => (
<div
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{cell.getValue()}
</div>
),
},
{
header: 'Desecription',
accessorKey: 'description',
Cell: ({ cell }) => (
<div
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{cell.getValue()}
</div>
),
},
{
header: 'Active',
accessorKey: 'is_active',
size: 100,
sortingFn: 'basic',
mantineTableBodyCellProps: {
align: 'left',
},
Cell: ({ cell }) => (
<Center>
{cell.getValue() ? (
<CheckIcon color="success" />
) : (
<CloseIcon color="error" />
)}
</Center>
),
Filter: ({ column }) => (
<Select
size="small"
value={activeFilterValue}
onChange={(e) => {
setActiveFilterValue(e.target.value);
column.setFilterValue(e.target.value);
}}
displayEmpty
data={[
{
value: 'all',
label: 'All',
},
{
value: 'active',
label: 'Active',
},
{
value: 'inactive',
label: 'Inactive',
},
]}
/>
),
filterFn: (row, _columnId, activeFilterValue) => {
if (activeFilterValue == 'all') return true; // Show all if no filter
return String(row.getValue('is_active')) === activeFilterValue;
},
},
],
[]
);
//optionally access the underlying virtualizer instance
const rowVirtualizerInstanceRef = useRef(null);
const [isLoading, setIsLoading] = useState(true);
const [sorting, setSorting] = useState([]);
const editUserAgent = async (userAgent = null) => {
setUserAgent(userAgent);
setUserAgentModalOpen(true);
};
const deleteUserAgent = async (ids) => {
if (Array.isArray(ids)) {
if (ids.includes(settings['default-user-agent'].value)) {
notifications.show({
title: 'Cannot delete default user-agent',
color: 'red.5',
});
return;
}
await API.deleteUserAgents(ids);
} else {
if (ids == settings['default-user-agent'].value) {
notifications.show({
title: 'Cannot delete default user-agent',
color: 'red.5',
});
return;
}
await API.deleteUserAgent(ids);
}
};
const closeUserAgentForm = () => {
setUserAgent(null);
setUserAgentModalOpen(false);
};
useEffect(() => {
if (typeof window !== 'undefined') {
setIsLoading(false);
}
}, []);
useEffect(() => {
//scroll to the top of the table when the sorting changes
try {
rowVirtualizerInstanceRef.current?.scrollToIndex?.(0);
} catch (error) {
console.error(error);
}
}, [sorting]);
const table = useMantineReactTable({
...TableHelper.defaultProperties,
columns,
data: userAgents,
enablePagination: false,
enableRowVirtualization: true,
// enableRowSelection: true,
renderTopToolbar: false,
// onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
state: {
isLoading,
sorting,
// rowSelection,
},
rowVirtualizerInstanceRef, //optional
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
initialState: {
density: 'compact',
},
enableRowActions: true,
renderRowActions: ({ row }) => (
<>
<ActionIcon
variant="transparent"
size="sm" // Makes the button smaller
color="yellow.5" // Red color for delete actions
onClick={() => {
editUserAgent(row.original);
}}
>
<SquarePen size="18" /> {/* Small icon size */}
</ActionIcon>
<ActionIcon
variant="transparent"
size="sm"
color="red.9" // Red color for delete actions
onClick={() => deleteUserAgent(row.original.id)}
>
<SquareMinus size="18" /> {/* Small icon size */}
</ActionIcon>
</>
),
mantineTableContainerProps: {
style: {
height: 'calc(43vh - 55px)',
},
},
});
return (
<>
<Flex
style={{
display: 'flex',
alignItems: 'center',
paddingTop: 10,
paddingBottom: 10,
}}
gap={15}
>
<Text
h={24}
style={{
fontFamily: 'Inter, sans-serif',
fontWeight: 500,
fontSize: '20px',
lineHeight: 1,
letterSpacing: '-0.3px',
color: 'gray.6', // Adjust this to match MUI's theme.palette.text.secondary
marginBottom: 0,
}}
>
User-Agents
</Text>
</Flex>
<Paper
style={
{
// bgcolor: theme.palette.background.paper,
// borderRadius: 2,
// overflow: 'hidden',
// height: 'calc(100vh - 75px)',
// display: 'flex',
// flexDirection: 'column',
}
}
>
{/* Top toolbar with Remove, Assign, Auto-match, and Add buttons */}
<Box
style={{
display: 'flex',
// alignItems: 'center',
// backgroundColor: theme.palette.background.paper,
justifyContent: 'flex-end',
padding: 10,
// gap: 1,
}}
>
<Flex gap={6}>
<Tooltip label="Assign">
<Button
leftSection={<IconSquarePlus size={18} />}
variant="light"
size="xs"
onClick={() => editUserAgent()}
p={5}
color="green"
style={{
borderWidth: '1px',
borderColor: 'green',
color: 'white',
}}
>
Add User-Agent
</Button>
</Tooltip>
</Flex>
</Box>
</Paper>
<MantineReactTable table={table} />
<UserAgentForm
userAgent={userAgent}
isOpen={userAgentModalOpen}
onClose={closeUserAgentForm}
/>
</>
);
};
export default UserAgentsTable;

View file

@ -2,12 +2,10 @@
export default {
defaultProperties: {
enableGlobalFilter: false,
enableBottomToolbar: false,
enableDensityToggle: false,
enableFullScreenToggle: false,
positionToolbarAlertBanner: 'none',
// columnFilterDisplayMode: 'popover',
enableRowNumbers: false,
positionActionsColumn: 'last',
enableColumnActions: false,
@ -16,26 +14,49 @@ export default {
initialState: {
density: 'compact',
},
muiTableBodyCellProps: {
sx: {
py: 0,
mantinePaperProps: {
style: {
'--mrt-selected-row-background-color': '#163632',
},
},
mantineSelectAllCheckboxProps: {
size: 'xs',
},
mantineSelectCheckboxProps: {
size: 'xs',
},
mantineTableBodyRowProps: ({ isDetailPanel, row }) => {
if (isDetailPanel && row.getIsSelected()) {
return {
style: {
backgroundColor: '#163632',
},
};
}
},
mantineTableBodyCellProps: {
style: {
// py: 0,
paddingLeft: 10,
paddingRight: 10,
borderColor: '#444',
color: '#E0E0E0',
fontSize: '0.85rem',
},
},
muiTableHeadCellProps: {
sx: {
py: 0,
mantineTableHeadCellProps: {
style: {
paddingLeft: 10,
paddingRight: 10,
color: '#CFCFCF',
backgroundColor: '#383A3F',
borderColor: '#444',
fontWeight: 600,
fontSize: '0.8rem',
// fontWeight: 600,
// fontSize: '0.8rem',
},
},
muiTableBodyProps: {
sx: {
mantineTableBodyProps: {
style: {
// Subtle row striping
'& tr:nth-of-type(odd)': {
backgroundColor: '#2F3034',

View file

@ -29,3 +29,7 @@ code {
::-webkit-scrollbar-thumb:hover {
background: #777;
}
table.mrt-table tr.mantine-Table-tr.mantine-Table-tr-detail-panel td.mantine-Table-td-detail-panel {
width: 100% !important;
}

View file

@ -1,14 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom/client'; // Import the "react-dom/client" for React 18
import './index.css'; // Optional styles
import App from './App'; // Import your App component
// Create a root element
const root = ReactDOM.createRoot(document.getElementById('root'));
// Render your app using the "root.render" method
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

9
frontend/src/main.jsx Normal file
View file

@ -0,0 +1,9 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View file

@ -0,0 +1,58 @@
import { createTheme, MantineProvider, rem } from '@mantine/core';
const theme = createTheme({
palette: {
mode: 'dark',
background: {
default: '#18181b', // Global background color (Tailwind zinc-900)
paper: '#27272a', // Paper background (Tailwind zinc-800)
},
primary: {
main: '#4A90E2',
contrastText: '#FFFFFF',
},
secondary: {
main: '#F5A623',
contrastText: '#FFFFFF',
},
text: {
primary: '#FFFFFF',
secondary: '#d4d4d8', // Updated secondary text color (Tailwind zinc-300)
},
// Custom colors for components (chip buttons, borders, etc.)
custom: {
// For chip buttons:
greenMain: '#90C43E',
greenHoverBg: 'rgba(144,196,62,0.1)',
indigoMain: '#4F39F6',
indigoHoverBg: 'rgba(79,57,246,0.1)',
greyBorder: '#707070',
greyHoverBg: 'rgba(112,112,112,0.1)',
greyText: '#a0a0a0',
// Common border colors:
borderDefault: '#3f3f46', // Tailwind zinc-700
borderHover: '#5f5f66', // Approximate Tailwind zinc-600
// For the "Add" button:
successBorder: '#00a63e',
successBg: '#0d542b',
successBgHover: '#0a4020',
successIcon: '#05DF72',
},
},
custom: {
sidebar: {
activeBackground: 'rgba(21, 69, 62, 0.67)',
activeBorder: '#14917e',
hoverBackground: '#27272a',
hoverBorder: '#3f3f46',
fontFamily: 'Inter, sans-serif',
},
},
});
export default theme;

View file

@ -0,0 +1,15 @@
import React from 'react';
import { Allotment } from 'allotment';
import { Box, Container } from '@mantine/core';
import 'allotment/dist/style.css';
const ChannelsPage = () => {
return (
<Allotment>
<div>Pane 1</div>
<div>Pane 1</div>
</Allotment>
);
};
export default ChannelsPage;

View file

@ -1,15 +1,15 @@
import React, { useState } from 'react';
import ChannelsTable from '../components/tables/ChannelsTable';
import StreamsTable from '../components/tables/StreamsTable';
import { Grid2, Box } from '@mui/material';
import { Box, Grid } from '@mantine/core';
const ChannelsPage = () => {
return (
<Grid2 container>
<Grid2 size={6}>
<Grid style={{ padding: 18 }}>
<Grid.Col span={6}>
<Box
sx={{
height: '100vh', // Full viewport height
style={{
height: '100vh - 20px', // Full viewport height
paddingTop: 0, // Top padding
paddingBottom: 1, // Bottom padding
paddingRight: 0.5,
@ -20,11 +20,11 @@ const ChannelsPage = () => {
>
<ChannelsTable />
</Box>
</Grid2>
<Grid2 size={6}>
</Grid.Col>
<Grid.Col span={6}>
<Box
sx={{
height: '100vh', // Full viewport height
style={{
height: '100vh - 20px', // Full viewport height
paddingTop: 0, // Top padding
paddingBottom: 1, // Bottom padding
paddingRight: 0,
@ -35,8 +35,8 @@ const ChannelsPage = () => {
>
<StreamsTable />
</Box>
</Grid2>
</Grid2>
</Grid.Col>
</Grid>
);
};

View file

@ -1,27 +0,0 @@
import React from "react";
import { Box } from "@mui/material";
import UserAgentsTable from "../components/tables/UserAgentsTable";
import EPGsTable from "../components/tables/EPGsTable";
const EPGPage = () => {
return (
<Box
sx={{
display: "flex",
flexDirection: "column",
height: "100vh",
overflow: "hidden",
}}
>
<Box sx={{ flex: "1 1 50%", overflow: "hidden" }}>
<EPGsTable />
</Box>
<Box sx={{ flex: "1 1 50%", overflow: "hidden" }}>
<UserAgentsTable />
</Box>
</Box>
);
};
export default EPGPage;

View file

@ -0,0 +1,28 @@
import React from 'react';
import { Box } from '@mantine/core';
import UserAgentsTable from '../components/tables/UserAgentsTable';
import EPGsTable from '../components/tables/EPGsTable';
const EPGPage = () => {
return (
<Box
style={{
display: 'flex',
flexDirection: 'column',
height: '100vh',
overflow: 'hidden',
padding: 16,
}}
>
<Box style={{ flex: '1 1 50%', overflow: 'hidden' }}>
<EPGsTable />
</Box>
<Box style={{ flex: '1 1 50%', overflow: 'hidden' }}>
<UserAgentsTable />
</Box>
</Box>
);
};
export default EPGPage;

View file

@ -1,26 +1,23 @@
// frontend/src/pages/Guide.js
import React, { useMemo, useState, useEffect, useRef } from 'react';
import {
Box,
Typography,
Paper,
Stack,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Slide,
CircularProgress,
Backdrop,
} from '@mui/material';
import dayjs from 'dayjs';
import API from '../api';
import useChannelsStore from '../store/channels';
import logo from '../images/logo.png';
import useVideoStore from '../store/useVideoStore'; // NEW import
import useAlertStore from '../store/alerts';
import { notifications } from '@mantine/notifications';
import useSettingsStore from '../store/settings';
import {
Title,
Box,
Modal,
Flex,
Button,
Text,
Paper,
Grid,
} from '@mantine/core';
import './guide.css';
/** Layout constants */
const CHANNEL_WIDTH = 120; // Width of the channel/logo column
@ -33,11 +30,6 @@ const MINUTE_BLOCK_WIDTH = HOUR_WIDTH / (60 / MINUTE_INCREMENT);
const MODAL_WIDTH = 600;
const MODAL_HEIGHT = 400;
// Slide transition for Dialog
const Transition = React.forwardRef(function Transition(props, ref) {
return <Slide direction="up" ref={ref} {...props} />;
});
export default function TVChannelGuide({ startDate, endDate }) {
const { channels } = useChannelsStore();
@ -46,7 +38,6 @@ export default function TVChannelGuide({ startDate, endDate }) {
const [now, setNow] = useState(dayjs());
const [selectedProgram, setSelectedProgram] = useState(null);
const [loading, setLoading] = useState(true);
const { showAlert } = useAlertStore();
const {
environment: { env_mode },
} = useSettingsStore();
@ -55,9 +46,9 @@ export default function TVChannelGuide({ startDate, endDate }) {
// Load program data once
useEffect(() => {
if (!channels || channels.length === 0) {
if (!Object.keys(channels).length === 0) {
console.warn('No channels provided or empty channels array');
showAlert('No channels available', 'error');
notifications.show({ title: 'No channels available', color: 'red.5' });
setLoading(false);
return;
}
@ -71,7 +62,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
const programIds = [...new Set(fetched.map((p) => p.tvg_id))];
// Filter your Redux/Zustand channels by matching tvg_id
const filteredChannels = channels.filter((ch) =>
const filteredChannels = Object.values(channels).filter((ch) =>
programIds.includes(ch.tvg_id)
);
console.log(
@ -217,8 +208,9 @@ export default function TVChannelGuide({ startDate, endDate }) {
return (
<Box
className="guide-program-container"
key={programKey}
sx={{
style={{
position: 'absolute',
left: leftPx,
top: 0,
@ -229,62 +221,45 @@ export default function TVChannelGuide({ startDate, endDate }) {
>
<Paper
elevation={2}
sx={{
position: 'relative',
left: 2,
className={`guide-program ${isLive ? 'live' : 'not-live'}`}
style={{
// position: 'relative',
// left: 2,
width: widthPx - 4,
top: 2,
// top: 2,
height: PROGRAM_HEIGHT - 4,
p: 1,
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
borderRadius: '8px',
background: isLive
? 'linear-gradient(to right, #1e3a8a, #2c5282)'
: 'linear-gradient(to right, #2d3748, #2d3748)',
color: '#fff',
transition: 'background 0.3s ease',
'&:hover': {
background: isLive
? 'linear-gradient(to right, #1e3a8a, #2a4365)'
: 'linear-gradient(to right, #2d3748, #1a202c)',
},
// padding: 10,
// overflow: 'hidden',
// whiteSpace: 'nowrap',
// textOverflow: 'ellipsis',
// borderRadius: '8px',
// background: isLive
// ? 'linear-gradient(to right, #1e3a8a, #2c5282)'
// : 'linear-gradient(to right, #2d3748, #2d3748)',
// color: '#fff',
// transition: 'background 0.3s ease',
// '&:hover': {
// background: isLive
// ? 'linear-gradient(to right, #1e3a8a, #2a4365)'
// : 'linear-gradient(to right, #2d3748, #1a202c)',
// },
}}
>
<Typography variant="body2" noWrap sx={{ fontWeight: 'bold' }}>
<Text size="md" style={{ fontWeight: 'bold' }}>
{program.title}
</Typography>
<Typography variant="overline" noWrap>
</Text>
<Text size="sm" noWrap>
{programStart.format('h:mma')} - {programEnd.format('h:mma')}
</Typography>
</Text>
</Paper>
</Box>
);
}
if (loading) {
return (
<Backdrop
sx={{
// color: '#fff',
zIndex: (theme) => theme.zIndex.drawer + 1,
position: 'fixed', // Ensure it covers the entire page
top: 0,
left: 0,
right: 0,
bottom: 0,
}}
open={loading}
>
<CircularProgress color="inherit" />
</Backdrop>
);
}
return (
<Box
sx={{
className="tv-guide"
style={{
overflow: 'hidden',
width: '100%',
height: '100%',
@ -294,33 +269,27 @@ export default function TVChannelGuide({ startDate, endDate }) {
}}
>
{/* Sticky top bar */}
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
<Flex
justify="space-between"
style={{
backgroundColor: '#2d3748',
color: '#fff',
p: 2,
padding: 20,
position: 'sticky',
top: 0,
zIndex: 999,
}}
>
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
<Title order={3} style={{ fontWeight: 'bold' }}>
TV Guide
</Typography>
<Typography variant="body2">
{now.format('dddd, MMMM D, YYYY • h:mm A')}
</Typography>
</Box>
</Title>
<Text>{now.format('dddd, MMMM D, YYYY • h:mm A')}</Text>
</Flex>
{/* Main layout */}
<Stack direction="row">
<Grid direction="row" style={{ padding: 8 }}>
{/* Channel Logos Column */}
<Box sx={{ backgroundColor: '#2d3748', color: '#fff' }}>
<Box style={{ backgroundColor: '#2d3748', color: '#fff' }}>
<Box
sx={{
style={{
width: CHANNEL_WIDTH,
height: '40px',
borderBottom: '1px solid #4a5568',
@ -329,7 +298,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
{guideChannels.map((channel) => (
<Box
key={channel.channel_name}
sx={{
style={{
display: 'flex',
height: PROGRAM_HEIGHT,
alignItems: 'center',
@ -338,7 +307,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
}}
>
<Box
sx={{
style={{
width: CHANNEL_WIDTH,
display: 'flex',
p: 1,
@ -364,7 +333,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
{/* Timeline & Program Blocks */}
<Box
ref={guideRef}
sx={{
style={{
flex: 1,
overflowX: 'auto',
overflowY: 'auto',
@ -372,7 +341,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
>
{/* Sticky timeline header */}
<Box
sx={{
style={{
display: 'flex',
position: 'sticky',
top: 0,
@ -381,11 +350,11 @@ export default function TVChannelGuide({ startDate, endDate }) {
borderBottom: '1px solid #4a5568',
}}
>
<Box sx={{ flex: 1, display: 'flex' }}>
<Box style={{ flex: 1, display: 'flex' }}>
{hourTimeline.map((time, hourIndex) => (
<Box
key={time.format()}
sx={{
style={{
width: HOUR_WIDTH,
height: '40px',
position: 'relative',
@ -393,9 +362,9 @@ export default function TVChannelGuide({ startDate, endDate }) {
borderRight: '1px solid #4a5568',
}}
>
<Typography
variant="body2"
sx={{
<Text
size="sm"
style={{
position: 'absolute',
top: '50%',
left: hourIndex === 0 ? 4 : 'calc(50% - 16px)',
@ -403,9 +372,9 @@ export default function TVChannelGuide({ startDate, endDate }) {
}}
>
{time.format('h:mma')}
</Typography>
</Text>
<Box
sx={{
style={{
position: 'absolute',
bottom: 0,
top: 0,
@ -418,7 +387,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
{[0, 1, 2, 3].map((i) => (
<Box
key={i}
sx={{
style={{
width: '1px',
height: '10px',
backgroundColor: '#718096',
@ -433,10 +402,10 @@ export default function TVChannelGuide({ startDate, endDate }) {
</Box>
{/* Now line */}
<Box sx={{ position: 'relative' }}>
<Box style={{ position: 'relative' }}>
{nowPosition >= 0 && (
<Box
sx={{
style={{
position: 'absolute',
left: nowPosition,
top: 0,
@ -456,14 +425,14 @@ export default function TVChannelGuide({ startDate, endDate }) {
return (
<Box
key={channel.channel_name}
sx={{
style={{
display: 'flex',
position: 'relative',
minHeight: PROGRAM_HEIGHT,
borderBottom: '1px solid #4a5568',
}}
>
<Box sx={{ flex: 1, position: 'relative' }}>
<Box style={{ flex: 1, position: 'relative' }}>
{channelPrograms.map((prog) => renderProgram(prog, start))}
</Box>
</Box>
@ -471,62 +440,36 @@ export default function TVChannelGuide({ startDate, endDate }) {
})}
</Box>
</Box>
</Stack>
</Grid>
{/* Modal for program details */}
<Dialog
open={Boolean(selectedProgram)}
<Modal
title={selectedProgram ? selectedProgram.title : ''}
opened={Boolean(selectedProgram)}
onClose={handleCloseModal}
TransitionComponent={Transition}
keepMounted
PaperProps={{
sx: {
width: MODAL_WIDTH,
height: MODAL_HEIGHT,
m: 'auto',
backgroundColor: '#1a202c',
border: '2px solid #718096',
},
}}
sx={{
'& .MuiDialog-container': {
alignItems: 'center',
justifyContent: 'center',
},
}}
yOffset="25vh"
>
{selectedProgram && (
<>
<DialogTitle sx={{ color: '#fff' }}>
{selectedProgram.title}
</DialogTitle>
<DialogContent sx={{ color: '#a0aec0' }}>
<Typography variant="caption" display="block">
{dayjs(selectedProgram.start_time).format('h:mma')} -{' '}
{dayjs(selectedProgram.end_time).format('h:mma')}
</Typography>
<Typography variant="body1" sx={{ mt: 2, color: '#fff' }}>
{selectedProgram.description || 'No description available.'}
</Typography>
</DialogContent>
<DialogActions>
{/* Only show the Watch button if currently live */}
{now.isAfter(dayjs(selectedProgram.start_time)) &&
now.isBefore(dayjs(selectedProgram.end_time)) && (
<Button
onClick={() => handleWatchStream(selectedProgram)}
sx={{ color: '#38b2ac' }}
>
<Text size="sm">
{dayjs(selectedProgram.start_time).format('h:mma')} -{' '}
{dayjs(selectedProgram.end_time).format('h:mma')}
</Text>
<Text style={{ mt: 2, color: '#fff' }}>
{selectedProgram.description || 'No description available.'}
</Text>
{/* Only show the Watch button if currently live */}
{now.isAfter(dayjs(selectedProgram.start_time)) &&
now.isBefore(dayjs(selectedProgram.end_time)) && (
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button onClick={() => handleWatchStream(selectedProgram)}>
Watch Now
</Button>
)}
<Button onClick={handleCloseModal} sx={{ color: '#38b2ac' }}>
Close
</Button>
</DialogActions>
</Flex>
)}
</>
)}
</Dialog>
</Modal>
</Box>
);
}

View file

@ -1,8 +0,0 @@
import React from "react";
import LoginForm from "../components/forms/LoginForm";
const Login = () => {
return <LoginForm />;
};
export default Login;

View file

@ -0,0 +1,16 @@
import React from 'react';
import LoginForm from '../components/forms/LoginForm';
import SuperuserForm from '../components/forms/SuperuserForm';
import useAuthStore from '../store/auth';
const Login = ({}) => {
const { superuserExists } = useAuthStore();
if (!superuserExists) {
return <SuperuserForm />;
}
return <LoginForm />;
};
export default Login;

View file

@ -1,72 +0,0 @@
import React, { useState } from "react";
import useUserAgentsStore from "../store/userAgents";
import { Box } from "@mui/material";
import M3UsTable from "../components/tables/M3UsTable";
import UserAgentsTable from "../components/tables/UserAgentsTable";
import usePlaylistsStore from "../store/playlists";
import API from "../api";
import M3UForm from "../components/forms/M3U";
const M3UPage = () => {
const isLoading = useUserAgentsStore((state) => state.isLoading);
const error = useUserAgentsStore((state) => state.error);
const playlists = usePlaylistsStore((state) => state.playlists);
const [playlist, setPlaylist] = useState(null);
const [playlistModalOpen, setPlaylistModalOpen] = useState(false);
const [userAgent, setUserAgent] = useState(null);
const [userAgentModalOpen, setUserAgentModalOpen] = useState(false);
const editUserAgent = async (userAgent = null) => {
setUserAgent(userAgent);
setUserAgentModalOpen(true);
};
const editPlaylist = async (playlist = null) => {
setPlaylist(playlist);
setPlaylistModalOpen(true);
};
const deleteUserAgent = async (ids) => {
if (Array.isArray(ids)) {
await API.deleteUserAgents(ids);
} else {
await API.deleteUserAgent(ids);
}
};
const deletePlaylist = async (id) => {
await API.deletePlaylist(id);
};
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return (
<Box
sx={{
display: "flex",
flexDirection: "column",
height: "100vh",
overflow: "hidden",
}}
>
<Box sx={{ flex: "1 1 50%", overflow: "hidden" }}>
<M3UsTable />
</Box>
<Box sx={{ flex: "1 1 50%", overflow: "hidden" }}>
<UserAgentsTable />
</Box>
<M3UForm
playlist={playlist}
isOpen={playlistModalOpen}
onClose={() => setPlaylistModalOpen(false)}
/>
</Box>
);
};
export default M3UPage;

View file

@ -0,0 +1,35 @@
import React, { useState } from 'react';
import useUserAgentsStore from '../store/userAgents';
import M3UsTable from '../components/tables/M3UsTable';
import UserAgentsTable from '../components/tables/UserAgentsTable';
import { Box } from '@mantine/core';
const M3UPage = () => {
const isLoading = useUserAgentsStore((state) => state.isLoading);
const error = useUserAgentsStore((state) => state.error);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return (
<Box
style={{
display: 'flex',
flexDirection: 'column',
height: '100vh',
overflow: 'hidden',
padding: 16,
}}
>
<Box sx={{ flex: '1 1 50%', overflow: 'hidden' }}>
<M3UsTable />
</Box>
<Box sx={{ flex: '1 1 50%', overflow: 'hidden' }}>
<UserAgentsTable />
</Box>
</Box>
);
};
export default M3UPage;

View file

@ -1,197 +0,0 @@
import React, { useEffect } from 'react';
import {
Grid as Grid2,
Box,
Container,
Typography,
FormControl,
Select,
MenuItem,
CircularProgress,
InputLabel,
Button,
} from '@mui/material';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import API from '../api';
import useSettingsStore from '../store/settings';
import useUserAgentsStore from '../store/userAgents';
import useStreamProfilesStore from '../store/streamProfiles';
const SettingsPage = () => {
const { settings } = useSettingsStore();
const { userAgents } = useUserAgentsStore();
const { profiles: streamProfiles } = useStreamProfilesStore();
// Add your region choices here:
const regionChoices = [
{ value: 'us', label: 'US' },
{ value: 'uk', label: 'UK' },
{ value: 'nl', label: 'NL' },
{ value: 'de', label: 'DE' },
// Add more if needed
];
const formik = useFormik({
initialValues: {
'default-user-agent': '',
'default-stream-profile': '',
'preferred-region': '',
},
validationSchema: Yup.object({
'default-user-agent': Yup.string().required('User-Agent is required'),
'default-stream-profile': Yup.string().required(
'Stream Profile is required'
),
// The region is optional or required as you prefer
// 'preferred-region': Yup.string().required('Region is required'),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
const changedSettings = {};
for (const settingKey in values) {
// If the user changed the settings value from whats in the DB:
if (String(values[settingKey]) !== String(settings[settingKey].value)) {
changedSettings[settingKey] = values[settingKey];
}
}
// Update each changed setting in the backend
for (const updatedKey in changedSettings) {
await API.updateSetting({
...settings[updatedKey],
value: changedSettings[updatedKey],
});
}
setSubmitting(false);
// Dont necessarily resetForm, in case the user wants to see new values
},
});
// Initialize form values once settings / userAgents / profiles are loaded
useEffect(() => {
formik.setValues(
Object.values(settings).reduce((acc, setting) => {
// If the settings value is numeric, parse it
// Otherwise, just store as string
const possibleNumber = parseInt(setting.value, 10);
acc[setting.key] = isNaN(possibleNumber)
? setting.value
: possibleNumber;
return acc;
}, {})
);
// eslint-disable-next-line
}, [settings, userAgents, streamProfiles]);
return (
<Container maxWidth="md">
<Box mt={4}>
<Typography variant="h4" gutterBottom>
Settings
</Typography>
<form onSubmit={formik.handleSubmit}>
<Grid2 container spacing={3}>
{/* Default User-Agent */}
<Grid2 xs={12}>
<FormControl variant="standard" fullWidth>
<InputLabel id="user-agent-label">Default User-Agent</InputLabel>
<Select
labelId="user-agent-label"
id={settings['default-user-agent']?.id}
name={settings['default-user-agent']?.key}
label={settings['default-user-agent']?.name}
value={formik.values['default-user-agent'] || ''}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched['default-user-agent'] &&
Boolean(formik.errors['default-user-agent'])
}
variant="standard"
>
{userAgents.map((option) => (
<MenuItem key={option.id} value={option.id}>
{option.user_agent_name}
</MenuItem>
))}
</Select>
</FormControl>
</Grid2>
{/* Default Stream Profile */}
<Grid2 xs={12}>
<FormControl variant="standard" fullWidth>
<InputLabel id="stream-profile-label">
Default Stream Profile
</InputLabel>
<Select
labelId="stream-profile-label"
id={settings['default-stream-profile']?.id}
name={settings['default-stream-profile']?.key}
label={settings['default-stream-profile']?.name}
value={formik.values['default-stream-profile'] || ''}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched['default-stream-profile'] &&
Boolean(formik.errors['default-stream-profile'])
}
variant="standard"
>
{streamProfiles.map((profile) => (
<MenuItem key={profile.id} value={profile.id}>
{profile.profile_name}
</MenuItem>
))}
</Select>
</FormControl>
</Grid2>
{/* Preferred Region */}
<Grid2 xs={12}>
{/* Only render if you do indeed have "preferred-region" in the DB */}
{settings['preferred-region'] && (
<FormControl variant="standard" fullWidth>
<InputLabel id="region-label">Preferred Region</InputLabel>
<Select
labelId="region-label"
id={settings['preferred-region'].id}
name={settings['preferred-region'].key}
label={settings['preferred-region'].name}
value={formik.values['preferred-region'] || ''}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
variant="standard"
>
{regionChoices.map((r) => (
<MenuItem key={r.value} value={r.value}>
{r.label}
</MenuItem>
))}
</Select>
</FormControl>
)}
</Grid2>
</Grid2>
<Box mt={4} display="flex" justifyContent="flex-end">
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
size="small"
>
{formik.isSubmitting ? <CircularProgress size={24} /> : 'Submit'}
</Button>
</Box>
</form>
</Box>
</Container>
);
};
export default SettingsPage;

View file

@ -0,0 +1,154 @@
import React, { useEffect } from 'react';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import API from '../api';
import useSettingsStore from '../store/settings';
import useUserAgentsStore from '../store/userAgents';
import useStreamProfilesStore from '../store/streamProfiles';
import {
Button,
Center,
Flex,
Paper,
NativeSelect,
Title,
} from '@mantine/core';
const SettingsPage = () => {
const { settings } = useSettingsStore();
const { userAgents } = useUserAgentsStore();
const { profiles: streamProfiles } = useStreamProfilesStore();
// Add your region choices here:
const regionChoices = [
{ value: 'us', label: 'US' },
{ value: 'uk', label: 'UK' },
{ value: 'nl', label: 'NL' },
{ value: 'de', label: 'DE' },
// Add more if needed
];
const formik = useFormik({
initialValues: {
'default-user-agent': `${settings['default-user-agent'].id}`,
'default-stream-profile': `${settings['default-stream-profile'].id}`,
// 'preferred-region': '',
},
validationSchema: Yup.object({
'default-user-agent': Yup.string().required('User-Agent is required'),
'default-stream-profile': Yup.string().required(
'Stream Profile is required'
),
// The region is optional or required as you prefer
// 'preferred-region': Yup.string().required('Region is required'),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
console.log(values);
const changedSettings = {};
for (const settingKey in values) {
// If the user changed the settings value from whats in the DB:
if (String(values[settingKey]) !== String(settings[settingKey].value)) {
changedSettings[settingKey] = values[settingKey];
}
}
// Update each changed setting in the backend
for (const updatedKey in changedSettings) {
await API.updateSetting({
...settings[updatedKey],
value: changedSettings[updatedKey],
});
}
setSubmitting(false);
// Dont necessarily resetForm, in case the user wants to see new values
},
});
// Initialize form values once settings / userAgents / profiles are loaded
useEffect(() => {
formik.setValues(
Object.values(settings).reduce((acc, setting) => {
// If the settings value is numeric, parse it
// Otherwise, just store as string
const possibleNumber = parseInt(setting.value, 10);
acc[setting.key] = isNaN(possibleNumber)
? setting.value
: possibleNumber;
return acc;
}, {})
);
// eslint-disable-next-line
}, [settings, userAgents, streamProfiles]);
return (
<Center
style={{
height: '100vh',
}}
>
<Paper
elevation={3}
style={{ padding: 30, width: '100%', maxWidth: 400 }}
>
<Title order={4} align="center">
Settings
</Title>
<form onSubmit={formik.handleSubmit}>
<NativeSelect
id={settings['default-user-agent']?.id}
name={settings['default-user-agent']?.key}
label={settings['default-user-agent']?.name}
value={formik.values['default-user-agent'] || ''}
onChange={formik.handleChange}
error={formik.errors['default-user-agent']}
data={userAgents.map((option) => ({
value: `${option.id}`,
label: option.user_agent_name,
}))}
/>
<NativeSelect
id={settings['default-stream-profile']?.id}
name={settings['default-stream-profile']?.key}
label={settings['default-stream-profile']?.name}
value={formik.values['default-stream-profile'] || ''}
onChange={formik.handleChange}
error={formik.errors['default-stream-profile']}
data={streamProfiles.map((option) => ({
value: `${option.id}`,
label: option.profile_name,
}))}
/>
{/* <Select
labelId="region-label"
id={settings['preferred-region'].id}
name={settings['preferred-region'].key}
label={settings['preferred-region'].name}
value={formik.values['preferred-region'] || ''}
onChange={formik.handleChange}
data={regionChoices.map((r) => ({
label: r.label,
value: `${r.value}`,
}))}
/> */}
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
size="small"
>
Submit
</Button>
</Flex>
</form>
</Paper>
</Center>
);
};
export default SettingsPage;

View file

@ -1,8 +0,0 @@
import React from "react";
import StreamProfilesTable from "../components/tables/StreamProfilesTable";
const StreamProfilesPage = () => {
return <StreamProfilesTable />;
};
export default StreamProfilesPage;

View file

@ -0,0 +1,13 @@
import React from 'react';
import StreamProfilesTable from '../components/tables/StreamProfilesTable';
import { Box } from '@mantine/core';
const StreamProfilesPage = () => {
return (
<Box style={{ padding: 16 }}>
<StreamProfilesTable />
</Box>
);
};
export default StreamProfilesPage;

View file

@ -0,0 +1,25 @@
.tv-guide .guide-program-container .guide-program {
position: relative;
left: 2px;
top: 2px;
padding: 10px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
border-radius: 8px;
background: linear-gradient(to right, #2d3748, #2d3748); /* Default background */
color: #fff;
transition: background 0.3s ease;
}
.tv-guide .guide-program-container .guide-program.live {
background: linear-gradient(to right, #1e3a8a, #2c5282);
}
.tv-guide .guide-program-container .guide-program.live:hover {
background: linear-gradient(to right, #1e3a8a, #2a4365);
}
.tv-guide .guide-program-container .guide-program.not-live:hover {
background: linear-gradient(to right, #2d3748, #1a202c);
}

View file

@ -1,13 +0,0 @@
const reportWebVitals = onPerfEntry => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

View file

@ -1,5 +0,0 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';

View file

@ -1,24 +0,0 @@
// frontend/src/store/useAlertStore.js
import { create } from 'zustand';
/**
* Global store to track whether a floating video is visible and which URL is playing.
*/
const useAlertStore = create((set) => ({
open: false,
message: '',
severity: 'info',
showAlert: (message, severity = 'info') =>
set({
open: true,
message,
severity,
}),
hideAlert: () => {
set({ open: false });
},
}));
export default useAlertStore;

View file

@ -23,10 +23,13 @@ const useAuthStore = create((set, get) => ({
accessToken: localStorage.getItem('accessToken') || null,
refreshToken: localStorage.getItem('refreshToken') || null,
tokenExpiration: localStorage.getItem('tokenExpiration') || null,
superuserExists: true,
isAuthenticated: false,
setIsAuthenticated: (isAuthenticated) => set({ isAuthenticated }),
setSuperuserExists: (superuserExists) => set({ superuserExists }),
initData: async () => {
await Promise.all([
useChannelsStore.getState().fetchChannels(),

View file

@ -48,7 +48,10 @@ const useChannelsStore = create((set) => ({
set((state) => ({
channels: {
...state.channels,
...newChannels,
...newChannels.reduce((acc, channel) => {
acc[channel.id] = channel;
return acc;
}, {}),
},
})),