mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-20 16:51:10 +00:00
Merge branch 'mantine' of https://github.com/Dispatcharr/Dispatcharr into Proxy-Redis
This commit is contained in:
commit
b300ee5bcf
113 changed files with 7430 additions and 21597 deletions
|
|
@ -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
152
frontend/src/App.jsx
Normal 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;
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal 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 |
|
|
@ -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;
|
||||
|
|
@ -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',
|
||||
|
|
@ -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;
|
||||
227
frontend/src/components/Sidebar.jsx
Normal file
227
frontend/src/components/Sidebar.jsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
420
frontend/src/components/forms/Channel.jsx
Normal file
420
frontend/src/components/forms/Channel.jsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
71
frontend/src/components/forms/ChannelGroup.jsx
Normal file
71
frontend/src/components/forms/ChannelGroup.jsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
143
frontend/src/components/forms/EPG.jsx
Normal file
143
frontend/src/components/forms/EPG.jsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
93
frontend/src/components/forms/LoginForm.jsx
Normal file
93
frontend/src/components/forms/LoginForm.jsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
191
frontend/src/components/forms/M3U.jsx
Normal file
191
frontend/src/components/forms/M3U.jsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
158
frontend/src/components/forms/M3UProfile.jsx
Normal file
158
frontend/src/components/forms/M3UProfile.jsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
107
frontend/src/components/forms/M3UProfiles.jsx
Normal file
107
frontend/src/components/forms/M3UProfiles.jsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
115
frontend/src/components/forms/Stream.jsx
Normal file
115
frontend/src/components/forms/Stream.jsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
113
frontend/src/components/forms/StreamProfile.jsx
Normal file
113
frontend/src/components/forms/StreamProfile.jsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
94
frontend/src/components/forms/SuperuserForm.jsx
Normal file
94
frontend/src/components/forms/SuperuserForm.jsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
119
frontend/src/components/forms/UserAgent.jsx
Normal file
119
frontend/src/components/forms/UserAgent.jsx
Normal 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;
|
||||
41
frontend/src/components/sidebar.css
Normal file
41
frontend/src/components/sidebar.css
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
}}
|
||||
>
|
||||
It’s 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 you’d 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;
|
||||
880
frontend/src/components/tables/ChannelsTable.jsx
Normal file
880
frontend/src/components/tables/ChannelsTable.jsx
Normal 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,
|
||||
}}
|
||||
>
|
||||
It’s 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 you’d 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;
|
||||
|
|
@ -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;
|
||||
226
frontend/src/components/tables/EPGsTable.jsx
Normal file
226
frontend/src/components/tables/EPGsTable.jsx
Normal 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;
|
||||
|
|
@ -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}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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;
|
||||
746
frontend/src/components/tables/StreamsTable.jsx
Normal file
746
frontend/src/components/tables/StreamsTable.jsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
321
frontend/src/components/tables/UserAgentsTable.jsx
Normal file
321
frontend/src/components/tables/UserAgentsTable.jsx
Normal 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;
|
||||
|
|
@ -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',
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
9
frontend/src/main.jsx
Normal 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>
|
||||
);
|
||||
58
frontend/src/mantineTheme.jsx
Normal file
58
frontend/src/mantineTheme.jsx
Normal 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;
|
||||
15
frontend/src/pages/Channels-test.jsx
Normal file
15
frontend/src/pages/Channels-test.jsx
Normal 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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -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;
|
||||
28
frontend/src/pages/EPG.jsx
Normal file
28
frontend/src/pages/EPG.jsx
Normal 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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
import React from "react";
|
||||
import LoginForm from "../components/forms/LoginForm";
|
||||
|
||||
const Login = () => {
|
||||
return <LoginForm />;
|
||||
};
|
||||
|
||||
export default Login;
|
||||
16
frontend/src/pages/Login.jsx
Normal file
16
frontend/src/pages/Login.jsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
35
frontend/src/pages/M3U.jsx
Normal file
35
frontend/src/pages/M3U.jsx
Normal 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;
|
||||
|
|
@ -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 setting’s value from what’s 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);
|
||||
// Don’t 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 setting’s 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;
|
||||
154
frontend/src/pages/Settings.jsx
Normal file
154
frontend/src/pages/Settings.jsx
Normal 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 setting’s value from what’s 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);
|
||||
// Don’t 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 setting’s 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;
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
import React from "react";
|
||||
import StreamProfilesTable from "../components/tables/StreamProfilesTable";
|
||||
|
||||
const StreamProfilesPage = () => {
|
||||
return <StreamProfilesTable />;
|
||||
};
|
||||
|
||||
export default StreamProfilesPage;
|
||||
13
frontend/src/pages/StreamProfiles.jsx
Normal file
13
frontend/src/pages/StreamProfiles.jsx
Normal 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;
|
||||
25
frontend/src/pages/guide.css
Normal file
25
frontend/src/pages/guide.css
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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';
|
||||
|
|
@ -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;
|
||||
|
|
@ -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(),
|
||||
|
|
@ -48,7 +48,10 @@ const useChannelsStore = create((set) => ({
|
|||
set((state) => ({
|
||||
channels: {
|
||||
...state.channels,
|
||||
...newChannels,
|
||||
...newChannels.reduce((acc, channel) => {
|
||||
acc[channel.id] = channel;
|
||||
return acc;
|
||||
}, {}),
|
||||
},
|
||||
})),
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue