full mantine refactor

This commit is contained in:
dekzter 2025-03-11 17:43:16 -04:00
parent feda20079e
commit e991211c9b
142 changed files with 3875 additions and 30486 deletions

2
.gitignore vendored
View file

@ -8,3 +8,5 @@ node_modules/
staticfiles/
static/
data/
.next
next-env.d.ts

View file

@ -55,7 +55,7 @@ TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [
os.path.join(BASE_DIR, 'frontend/build'),
os.path.join(BASE_DIR, 'frontend/dist'),
BASE_DIR / "templates"
],
'APP_DIRS': True,
@ -139,7 +139,7 @@ STATIC_ROOT = BASE_DIR / 'static' # Directory where static files will be collec
# Adjust STATICFILES_DIRS to include the paths to the directories that contain your static files.
STATICFILES_DIRS = [
os.path.join(BASE_DIR, 'frontend/build/static'), # React build static files
os.path.join(BASE_DIR, 'frontend/build/assets'), # React build static files
]

View file

@ -15,3 +15,5 @@ fi
# Install frontend dependencies
cd /app/frontend && npm install
cd /app && pip install -r requirements.txt

41
frontend/.gitignore vendored
View file

@ -1,23 +1,24 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View file

@ -1,70 +1,12 @@
# Getting Started with Create React App
# React + Vite
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
## Available Scripts
Currently, two official plugins are available:
In the project directory, you can run:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
### `npm start`
## Expanding the ESLint configuration
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
The page will reload when you make changes.\
You may also see any lint errors in the console.
### `npm test`
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `npm run build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `npm run eject`
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).
### Code Splitting
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
### Analyzing the Bundle Size
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
### Making a Progressive Web App
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
### Advanced Configuration
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
### Deployment
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
### `npm run build` fails to minify
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
If you are developing a production application, we recommend using TypeScript and enable type-aware lint rules. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.

View file

@ -1,6 +0,0 @@
#!/bin/sh
apk add nodejs npm
cd /app/
npm i
PORT=9191 npm run start

View file

@ -1,12 +0,0 @@
import globals from "globals";
import pluginJs from "@eslint/js";
import pluginReact from "eslint-plugin-react";
/** @type {import('eslint').Linter.Config[]} */
export default [
{files: ["**/*.{js,mjs,cjs,jsx}"]},
{languageOptions: { globals: globals.browser }},
pluginJs.configs.recommended,
pluginReact.configs.flat.recommended,
];

18123
frontend/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,60 +1,54 @@
{
"name": "frontend",
"version": "0.1.0",
"name": "vite",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --host",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@fontsource/roboto": "^5.1.1",
"@mui/icons-material": "^6.4.5",
"@mui/material": "^6.4.5",
"@videojs/http-streaming": "^3.17.0",
"axios": "^1.7.9",
"@mantine/core": "^7.17.0",
"@mantine/dates": "^7.17.0",
"@mantine/hooks": "^7.17.0",
"@mantine/notifications": "^7.17.1",
"@mui/icons-material": "^6.4.7",
"@mui/material": "^6.4.7",
"@mui/x-date-pickers": "^7.27.3",
"@tabler/icons-react": "^3.31.0",
"allotment": "^1.20.3",
"axios": "^1.8.2",
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"eslint": "^8.57.1",
"formik": "^2.4.6",
"hls.js": "^1.5.20",
"lucide-react": "^0.479.0",
"material-react-table": "^3.2.0",
"mpegts.js": "^1.4.2",
"planby": "^1.1.7",
"pm2": "^5.4.3",
"prettier": "^3.5.2",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-draggable": "4.4.6",
"react-router-dom": "^7.2.0",
"react-scripts": "5.0.1",
"react-window": "^1.8.11",
"mantine-react-table": "^2.0.0-beta.9",
"material-react-table": "^3.2.1",
"mpegts.js": "^1.8.0",
"prettier": "^3.5.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-draggable": "^4.4.6",
"react-pro-sidebar": "^1.1.0",
"react-router-dom": "^7.3.0",
"video.js": "^8.21.0",
"web-vitals": "^2.1.4",
"yup": "^1.6.1",
"zustand": "^5.0.3"
},
"scripts": {
"start": "PORT=9191 react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"format": "prettier --write ."
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"proxy": "http://localhost:5656"
"devDependencies": {
"@eslint/js": "^9.21.0",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react-swc": "^3.8.0",
"eslint": "^9.21.0",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^15.15.0",
"vite": "^6.2.0"
}
}

View file

@ -1,5 +1,5 @@
// prettier.config.js or .prettierrc.js
module.exports = {
export default {
semi: true, // Add semicolons at the end of statements
singleQuote: true, // Use single quotes instead of double
tabWidth: 2, // Set the indentation width

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 778 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View file

@ -1,61 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="%PUBLIC_URL%/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="%PUBLIC_URL%/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest">
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="IPTV Master Control"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<link
rel="stylesheet"
href="https://fonts.googleapis.com/icon?family=Material+Icons"
/>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap"
/>
<title>Dispatcharr</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

View file

@ -1,25 +0,0 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View file

@ -1,3 +0,0 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

View file

@ -1 +0,0 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

View file

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before After
Before After

View file

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

View file

@ -6,27 +6,22 @@ import {
Routes,
Navigate,
} from 'react-router-dom';
// import Sidebar from './components/Sidebar';
import Sidebar from './components/Sidebar-new';
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';
import { AppShell, MantineProvider } from '@mantine/core';
import { Box, AppShell, MantineProvider } from '@mantine/core';
import '@mantine/core/styles.css'; // Ensure Mantine global styles load
import 'mantine-react-table/styles.css';
import mantineTheme from './mantineTheme';
import API from './api';
const drawerWidth = 240;
const miniDrawerWidth = 60;
@ -34,13 +29,13 @@ const defaultRoute = '/channels';
const App = () => {
const [open, setOpen] = useState(true);
const [needsSuperuser, setNeedsSuperuser] = useState(false);
const {
isAuthenticated,
setIsAuthenticated,
logout,
initData,
initializeAuth,
setSuperuserExists,
} = useAuthStore();
const toggleDrawer = () => {
@ -51,10 +46,9 @@ const App = () => {
useEffect(() => {
async function checkSuperuser() {
try {
const response = await fetch('/api/accounts/initialize-superuser/');
const res = await response.json();
if (!res.data.superuser_exists) {
setNeedsSuperuser(true);
const response = await API.fetchSuperUser();
if (!response.superuser_exists) {
setSuperuserExists(false);
}
} catch (error) {
console.error('Error checking superuser status:', error);
@ -77,11 +71,6 @@ const App = () => {
checkAuth();
}, [initializeAuth, initData, setIsAuthenticated, logout]);
// If no superuser exists, show the initialization form
if (needsSuperuser) {
return <SuperuserForm onSuccess={() => setNeedsSuperuser(false)} />;
}
return (
<MantineProvider
defaultColorScheme="dark"
@ -89,41 +78,32 @@ const App = () => {
withGlobalStyles
withNormalizeCSS
>
<ThemeProvider theme={theme}>
<GlobalStyles
styles={{
'.Mui-TableHeadCell-Content': {
height: '100%',
alignItems: 'flex-end !important',
},
}}
/>
<WebsocketProvider>
<Router>
<AppShell
header={{
height: 0,
}}
navbar={{
width: open ? drawerWidth : miniDrawerWidth,
}}
>
<Sidebar
drawerWidth
miniDrawerWidth
collapsed={!open}
toggleDrawer={toggleDrawer}
/>
<WebsocketProvider>
<Router>
<AppShell
header={{
height: 0,
}}
navbar={{
width: open ? drawerWidth : miniDrawerWidth,
}}
>
<Sidebar
drawerWidth
miniDrawerWidth
collapsed={!open}
toggleDrawer={toggleDrawer}
/>
<AppShell.Main>
<Box
sx={{
style={{
display: 'flex',
flexDirection: 'column',
ml: `${open ? drawerWidth : miniDrawerWidth}px`,
// transition: 'margin-left 0.3s',
backgroundColor: 'background.default',
minHeight: '100vh',
color: 'text.primary',
backgroundColor: '#18181b',
height: '100vh',
color: 'white',
}}
>
<Box sx={{ p: 2, flex: 1, overflow: 'auto' }}>
@ -141,7 +121,7 @@ const App = () => {
<Route path="/settings" element={<Settings />} />
</>
) : (
<Route path="/login" element={<Login />} />
<Route path="/login" element={<Login needsSuperuser />} />
)}
<Route
path="*"
@ -155,13 +135,12 @@ const App = () => {
</Routes>
</Box>
</Box>
</AppShell>
</Router>
</AppShell.Main>
</AppShell>
</Router>
<Alert />
<FloatingVideo />
</WebsocketProvider>
</ThemeProvider>
<FloatingVideo />
</WebsocketProvider>
</MantineProvider>
);
};

View file

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

View file

@ -1,85 +0,0 @@
import React, {
useState,
useEffect,
useRef,
createContext,
useContext,
} from 'react';
import useStreamsStore from './store/streams';
import useAlertStore from './store/alerts';
export const WebsocketContext = createContext(false, null, () => {});
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') {
wsUrl = `${window.location.hostname}:8001/ws/`;
}
if (window.location.protocol.match(/https/)) {
wsUrl = `wss://${wsUrl}`;
} else {
wsUrl = `ws://${wsUrl}`;
}
const socket = new WebSocket(wsUrl);
socket.onopen = () => {
console.log('websocket connected');
setIsReady(true);
};
// Reconnection logic
socket.onclose = () => {
setIsReady(false);
setTimeout(() => {
const reconnectWs = new WebSocket(wsUrl);
reconnectWs.onopen = () => setIsReady(true);
}, 3000); // Attempt to reconnect every 3 seconds
};
socket.onmessage = async (event) => {
event = JSON.parse(event.data);
switch (event.type) {
case 'm3u_refresh':
if (event.message?.success) {
fetchStreams();
showAlert(event.message.message, 'success');
}
break;
default:
console.error(`Unknown websocket event type: ${event.type}`);
break;
}
};
ws.current = socket;
return () => {
socket.close();
};
}, []);
const ret = [isReady, val, ws.current?.send.bind(ws.current)];
return (
<WebsocketContext.Provider value={ret}>
{children}
</WebsocketContext.Provider>
);
};
export const useWebSocket = () => {
const socket = useContext(WebsocketContext);
return socket;
};

View file

@ -6,7 +6,7 @@ import React, {
useContext,
} from 'react';
import useStreamsStore from './store/streams';
import useAlertStore from './store/alerts';
import { notifications } from '@mantine/notifications';
export const WebsocketContext = createContext(false, null, () => {});
@ -14,7 +14,6 @@ export const WebsocketProvider = ({ children }) => {
const [isReady, setIsReady] = useState(false);
const [val, setVal] = useState(null);
const { showAlert } = useAlertStore();
const { fetchStreams } = useStreamsStore();
const ws = useRef(null);
@ -53,7 +52,10 @@ export const WebsocketProvider = ({ children }) => {
case 'm3u_refresh':
if (event.message?.success) {
fetchStreams();
showAlert(event.message.message, 'success');
notifications.show({
title: 'event.message.message',
color: 'green.5',
});
}
break;

View file

@ -9,7 +9,9 @@ import useStreamProfilesStore from './store/streamProfiles';
import useSettingsStore from './store/settings';
// If needed, you can set a base host or keep it empty if relative requests
const host = '';
const host = import.meta.env.DEV
? `http://${window.location.hostname}:5656`
: '';
export default class API {
/**
@ -19,6 +21,27 @@ export default class API {
return await useAuthStore.getState().getToken();
}
static async fetchSuperUser() {
const response = await fetch(`${host}/api/accounts/initialize-superuser/`);
return await response.json();
}
static async createSuperUser({ username, email, password }) {
const response = await fetch(`${host}/api/accounts/initialize-superuser/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username,
password,
email,
}),
});
return await response.json();
}
static async login(username, password) {
const response = await fetch(`${host}/api/accounts/token/`, {
method: 'POST',

View file

Before

Width:  |  Height:  |  Size: 4 KiB

After

Width:  |  Height:  |  Size: 4 KiB

Before After
Before After

View file

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

View file

@ -1,98 +0,0 @@
// frontend/src/components/FloatingVideo.js
import React, { useEffect, useRef } from 'react';
import Draggable from 'react-draggable';
import useVideoStore from '../store/useVideoStore';
import mpegts from 'mpegts.js';
export default function FloatingVideo() {
const { isVisible, streamUrl, hideVideo } = useVideoStore();
const videoRef = useRef(null);
const playerRef = useRef(null);
useEffect(() => {
if (!isVisible || !streamUrl) {
return;
}
// If the browser supports MSE for live playback, initialize mpegts.js
if (mpegts.getFeatureList().mseLivePlayback) {
const player = mpegts.createPlayer({
type: 'mpegts',
url: streamUrl,
isLive: true,
// You can include other custom MPEGTS.js config fields here, e.g.:
// cors: true,
// withCredentials: false,
});
player.attachMediaElement(videoRef.current);
player.load();
player.play();
// Store player instance so we can clean up later
playerRef.current = player;
}
// Cleanup when component unmounts or streamUrl changes
return () => {
if (playerRef.current) {
playerRef.current.destroy();
playerRef.current = null;
}
};
}, [isVisible, streamUrl]);
// If the floating video is hidden or no URL is selected, do not render
if (!isVisible || !streamUrl) {
return null;
}
return (
<Draggable>
<div
style={{
position: 'fixed',
bottom: '20px',
right: '20px',
width: '320px',
zIndex: 9999,
backgroundColor: '#333',
borderRadius: '8px',
overflow: 'hidden',
boxShadow: '0 2px 10px rgba(0,0,0,0.7)',
}}
>
{/* Simple header row with a close button */}
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
padding: '4px',
}}
>
<button
onClick={hideVideo}
style={{
background: 'red',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '0.8rem',
padding: '2px 8px',
}}
>
X
</button>
</div>
{/* The <video> element used by mpegts.js */}
<video
ref={videoRef}
controls
style={{ width: '100%', height: '180px', backgroundColor: '#000' }}
/>
</div>
</Draggable>
);
}

View file

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

View file

@ -16,31 +16,68 @@ import {
Text,
UnstyledButton,
} from '@mantine/core';
import { useState } from 'react';
import headerLogo from '../images/dispatcharr.svg';
import logo from '../images/logo.png';
import useChannelsStore from '../store/channels';
import './sidebar.css';
// Navigation Items
const navItems = [
{
label: 'Channels',
icon: <ListOrdered size={20} />,
path: '/channels',
// badge: '(323)',
},
{ 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 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();
// 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',
},
];
return (
<AppShell.Navbar
@ -96,55 +133,7 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
const isActive = location.pathname === item.path;
return (
<UnstyledButton
key={item.path}
component={Link}
to={item.path}
style={{
display: 'flex',
flexDirection: 'row', // Ensures horizontal layout
flexWrap: 'nowrap',
alignItems: 'center',
gap: 12,
padding: collapsed ? '5px 8px' : '10px 16px',
borderRadius: 6,
color: isActive ? '#FFFFFF' : '#D4D4D8',
backgroundColor: isActive ? '#245043' : 'transparent',
border: isActive
? '1px solid #3BA882'
: '1px solid transparent',
transition: 'all 0.3s ease',
'&:hover': {
backgroundColor: isActive ? '#3A3A40' : '#2A2F34', // Gray hover effect when active
border: isActive ? '1px solid #3BA882' : '1px solid #3D3D42',
},
justifyContent: collapsed ? 'center' : 'flex-start',
}}
>
{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>
<NavLink item={item} collapsed={collapsed} isActive={isActive} />
);
})}
</Stack>

View file

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

View file

@ -0,0 +1,416 @@
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';
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,
stream_ids: channelStreams.map((stream) => stream.id),
});
} else {
await API.addChannel({
...values,
logo_file: logoFile,
stream_ids: 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="70%" 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 : ''
}
/>
<Group>
<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,
}))}
/>
<Center>
<ActionIcon
color="green.5"
onClick={() => setChannelGroupModalOpen(true)}
title="Create new group"
size="small"
variant="filled"
>
<AddIcon fontSize="small" />
</ActionIcon>
</Center>
</Group>
<NativeSelect
id="stream_profile_id"
label="Stream Profile"
name="stream_profile_id"
value={formik.values.stream_profile_id}
onChange={formik.handleChange}
error={
formik.errors.stream_profile_id
? formik.touched.stream_profile_id
: ''
}
data={streamProfiles.map((option) => ({
value: `${option.id}`,
label: option.profile_name,
}))}
/>
<TextInput
id="channel_number"
name="channel_number"
label="Channel #"
value={formik.values.channel_number}
onChange={formik.handleChange}
error={
formik.errors.channel_number
? formik.touched.channel_number
: ''
}
/>
</Grid.Col>
<Grid.Col span={6}>
<TextInput
id="tvg_name"
name="tvg_name"
label="TVG Name"
value={formik.values.tvg_name}
onChange={formik.handleChange}
error={formik.errors.tvg_name ? formik.touched.tvg_name : ''}
/>
<TextInput
id="tvg_id"
name="tvg_id"
label="TVG ID"
value={formik.values.tvg_id}
onChange={formik.handleChange}
error={formik.errors.tvg_id ? formik.touched.tvg_id : ''}
/>
<TextInput
id="logo_url"
name="logo_url"
label="Logo URL (Optional)"
style={{ marginBottom: 2 }}
value={formik.values.logo_url}
onChange={formik.handleChange}
/>
<Group style={{ paddingTop: 10 }}>
<Text>Logo</Text>
{/* Display selected image */}
<Box>
<img
src={logoPreview}
alt="Selected"
style={{ maxWidth: 50, height: 'auto' }}
/>
</Box>
<input
type="file"
id="logo"
name="logo"
accept="image/*"
onChange={(event) => handleLogoChange(event)}
style={{ display: 'none' }}
/>
<label htmlFor="logo">
<Button variant="contained" component="span" size="small">
Browse...
</Button>
</label>
</Group>
</Grid.Col>
</Grid>
{/* <Grid gap={2}>
<Grid.Col span={6}>
<Typography>Active Streams</Typography>
<MantineReactTable table={activeStreamsTable} />
</Grid.Col>
<Grid.Col span={6}>
<Typography>Available Streams</Typography>
<MantineReactTable table={availableStreamsTable} />
</Grid.Col>
</Grid> */}
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
>
Submit
</Button>
</Flex>
</form>
</Modal>
<ChannelGroupForm
isOpen={channelGroupModelOpen}
onClose={() => setChannelGroupModalOpen(false)}
/>
</>
);
};
export default Channel;

View file

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

View file

@ -3,7 +3,7 @@ import React, { useEffect } from 'react';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import API from '../../api';
import { Flex, TextInput, Button } from '@mantine/core';
import { Flex, TextInput, Button, Modal } from '@mantine/core';
const ChannelGroup = ({ channelGroup = null, isOpen, onClose }) => {
const formik = useFormik({

View file

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

View file

@ -116,11 +116,11 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
data={[
{
label: 'XMLTV',
valeu: 'xmltv',
value: 'xmltv',
},
{
label: 'Schedules Direct',
valeu: 'schedules_direct',
value: 'schedules_direct',
},
]}
/>
@ -132,7 +132,7 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
disabled={formik.isSubmitting}
size="small"
>
{formik.isSubmitting ? <CircularProgress size={24} /> : 'Submit'}
Submit
</Button>
</Flex>
</form>

View file

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

View file

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

View file

@ -23,6 +23,8 @@ const M3U = ({ playlist = null, isOpen, onClose }) => {
const [file, setFile] = useState(null);
const [profileModalOpen, setProfileModalOpen] = useState(false);
console.log(playlist);
const handleFileChange = (file) => {
console.log(file);
if (file) {
@ -34,7 +36,7 @@ const M3U = ({ playlist = null, isOpen, onClose }) => {
initialValues: {
name: '',
server_url: '',
user_agent: '',
user_agent: `${userAgents[0].id}`,
is_active: true,
max_streams: 0,
},
@ -82,8 +84,6 @@ const M3U = ({ playlist = null, isOpen, onClose }) => {
return <></>;
}
console.log(formik.values);
return (
<Modal opened={isOpen} onClose={onClose} title="M3U Account">
<div style={{ width: 400, position: 'relative' }}>
@ -126,7 +126,8 @@ const M3U = ({ playlist = null, isOpen, onClose }) => {
fullWidth
id="max_streams"
name="max_streams"
label="Max Streams (0 = unlimited)"
label="Max Streams"
placeholder="0 = Unlimited"
value={formik.values.max_streams}
onChange={formik.handleChange}
error={formik.errors.max_streams ? formik.touched.max_streams : ''}
@ -136,7 +137,7 @@ const M3U = ({ playlist = null, isOpen, onClose }) => {
id="user_agent"
name="user_agent"
label="User-Agent"
value={formik.values.user_agent.value}
value={formik.values.user_agent}
onChange={formik.handleChange}
error={formik.errors.user_agent ? formik.touched.user_agent : ''}
data={userAgents.map((ua) => ({
@ -157,15 +158,16 @@ const M3U = ({ playlist = null, isOpen, onClose }) => {
/>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button
variant="contained"
color="primary"
size="small"
onClick={() => setProfileModalOpen(true)}
>
Profiles
</Button>
{playlist && (
<Button
variant="contained"
color="primary"
size="small"
onClick={() => setProfileModalOpen(true)}
>
Profiles
</Button>
)}
<Button
type="submit"
variant="contained"

View file

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

View file

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

View file

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

View file

@ -1,18 +1,21 @@
// Modal.js
import React, { useEffect } from 'react';
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 { TextInput, Select, Button } from '@mantine/core';
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('');
console.log(stream);
const formik = useFormik({
initialValues: {
name: '',
url: '',
group_name: '',
stream_profile_id: '',
},
validationSchema: Yup.object({
@ -38,6 +41,7 @@ const Stream = ({ stream = null, isOpen, onClose }) => {
formik.setValues({
name: stream.name,
url: stream.url,
group_name: stream.group_name,
stream_profile_id: stream.stream_profile_id,
});
} else {
@ -50,7 +54,7 @@ const Stream = ({ stream = null, isOpen, onClose }) => {
}
return (
<Modal opened={isOpen} onClose={onClose} title="Stream">
<Modal opened={isOpen} onClose={onClose} title="Stream" zIndex={10}>
<form onSubmit={formik.handleSubmit}>
<TextInput
id="name"
@ -58,7 +62,7 @@ const Stream = ({ stream = null, isOpen, onClose }) => {
label="Stream Name"
value={formik.values.name}
onChange={formik.handleChange}
error={formik.touched.name && Boolean(formik.errors.name)}
error={formik.errors.name}
/>
<TextInput
@ -67,34 +71,43 @@ const Stream = ({ stream = null, isOpen, onClose }) => {
label="Stream URL"
value={formik.values.url}
onChange={formik.handleChange}
error={formik.touched.url && Boolean(formik.errors.url)}
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 (optional)"
value={formik.values.stream_profile_id}
onChange={formik.handleChange}
error={
formik.errors.stream_profile_id
? formik.touched.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 }}
/>
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
>
{formik.isSubmitting ? <CircularProgress size={24} /> : 'Submit'}
</Button>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
>
{formik.isSubmitting ? <CircularProgress size={24} /> : 'Submit'}
</Button>
</Flex>
</form>
</Modal>
);

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,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;
}

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import { MantineReactTable, useMantineReactTable } from 'mantine-react-table';
import useChannelsStore from '../../store/channels';
import useAlertStore from '../../store/alerts';
import { notifications } from '@mantine/notifications';
import {
Add as AddIcon,
LiveTv as LiveTvIcon,
@ -47,6 +47,8 @@ import {
Group,
useMantineTheme,
UnstyledButton,
Container,
Space,
} from '@mantine/core';
import {
IconArrowDown,
@ -232,7 +234,6 @@ const ChannelsTable = ({}) => {
fetchChannels,
setChannelsPageSelection,
} = useChannelsStore();
const { showAlert } = useAlertStore();
useEffect(() => {
setChannelGroupOptions([
@ -279,9 +280,17 @@ const ChannelsTable = ({}) => {
size="xs"
/>
),
meta: {
filterVariant: null,
},
Cell: ({ cell }) => (
<div
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{cell.getValue()}
</div>
),
},
{
header: 'Group',
@ -292,10 +301,10 @@ const ChannelsTable = ({}) => {
searchable
size="xs"
nothingFound="No options"
// onChange={(e, value) => {
// e.stopPropagation();
// handleGroupChange(value);
// }}
onChange={(e, value) => {
e.stopPropagation();
handleGroupChange(value);
}}
data={channelGroupOptions}
/>
),
@ -377,13 +386,19 @@ const ChannelsTable = ({}) => {
setIsLoading(false);
// We might get { message: "Channels have been auto-assigned!" }
showAlert(result.message || 'Channels assigned');
notifications.show({
title: result.message || 'Channels assigned',
color: 'green.5',
});
// Refresh the channel list
await fetchChannels();
} catch (err) {
console.error(err);
showAlert('Failed to assign channels');
notifications.show({
title: 'Failed to assign channels',
color: 'red.5',
});
}
};
@ -584,7 +599,7 @@ const ChannelsTable = ({}) => {
),
mantineTableContainerProps: {
style: {
height: 'calc(100vh - 125px)',
height: 'calc(100vh - 127px)',
overflowY: 'auto',
},
},
@ -596,7 +611,10 @@ const ChannelsTable = ({}) => {
return (
<Box>
{/* Header Row: outside the Paper */}
<Flex style={{ display: 'flex', alignItems: 'center', pb: 1 }} gap={15}>
<Flex
style={{ display: 'flex', alignItems: 'center', paddingBottom: 10 }}
gap={15}
>
<Text
w={88}
h={24}
@ -612,13 +630,11 @@ const ChannelsTable = ({}) => {
>
Channels
</Text>
<Box
<Flex
style={{
width: 43,
height: 25,
display: 'flex',
alignItems: 'center',
ml: 3,
marginLeft: 10,
}}
>
<Text
@ -635,191 +651,116 @@ const ChannelsTable = ({}) => {
>
Links:
</Text>
</Box>
<Box
style={{
display: 'flex',
gap: '6px',
ml: 0.75,
alignItems: 'center',
}}
>
<Popover withArrow shadow="md">
<Popover.Target>
<Button
onClick={copyHDHRUrl}
style={{
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
style={{
width: 14,
height: 14,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Tv2 size={14} color={theme.palette.custom.greenMain} />
</Box>
HDHR
</Button>
</Popover.Target>
<Popover.Dropdown>
<Group>
<TextInput value={textToCopy} size="small" sx={{ mr: 1 }} />
<ActionIcon
onClick={handleCopy}
size="sm"
variant="transparent"
>
<ContentCopy size="18" fontSize="small" />
</ActionIcon>
</Group>
</Popover.Dropdown>
</Popover>
<Popover withArrow shadow="md">
<Popover.Target>
<Button
onClick={copyM3UUrl}
style={{
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
<Group gap={5} style={{ paddingLeft: 10 }}>
<Popover withArrow shadow="md">
<Popover.Target>
<Button
leftSection={<Tv2 size={18} />}
size="compact-sm"
onClick={copyHDHRUrl}
p={5}
color="green"
variant="subtle"
style={{
width: 14,
height: 14,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: theme.palette.custom.greenMain,
color: theme.palette.custom.greenMain,
}}
>
<ScreenShare
size={14}
color={theme.palette.custom.indigoMain}
/>
</Box>
M3U
</Button>
</Popover.Target>
<Popover.Dropdown>
<Group>
<TextInput value={textToCopy} size="small" sx={{ mr: 1 }} />
<ActionIcon
onClick={handleCopy}
size="sm"
variant="transparent"
>
<ContentCopy size="18" fontSize="small" />
</ActionIcon>
</Group>
</Popover.Dropdown>
</Popover>
HDHR
</Button>
</Popover.Target>
<Popover.Dropdown>
<Group>
<TextInput value={textToCopy} size="small" sx={{ mr: 1 }} />
<ActionIcon
onClick={handleCopy}
size="sm"
variant="transparent"
>
<ContentCopy size="18" fontSize="small" />
</ActionIcon>
</Group>
</Popover.Dropdown>
</Popover>
<Popover withArrow shadow="md">
<Popover.Target>
<Button
onClick={copyEPGUrl}
style={{
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
<Popover withArrow shadow="md">
<Popover.Target>
<Button
leftSection={<ScreenShare size={18} />}
size="compact-sm"
onClick={copyM3UUrl}
p={5}
color="green"
variant="subtle"
style={{
width: 14,
height: 14,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: theme.palette.custom.indigoMain,
color: theme.palette.custom.indigoMain,
}}
>
<Scroll size={14} color={theme.palette.custom.greyText} />
</Box>
EPG
</Button>
</Popover.Target>
<Popover.Dropdown>
<Group>
<TextInput value={textToCopy} size="small" sx={{ mr: 1 }} />
<ActionIcon
onClick={handleCopy}
size="sm"
variant="transparent"
M3U
</Button>
</Popover.Target>
<Popover.Dropdown>
<Group>
<TextInput value={textToCopy} size="small" sx={{ mr: 1 }} />
<ActionIcon
onClick={handleCopy}
size="sm"
variant="transparent"
>
<ContentCopy size="18" fontSize="small" />
</ActionIcon>
</Group>
</Popover.Dropdown>
</Popover>
<Popover withArrow shadow="md">
<Popover.Target>
<Button
leftSection={<Scroll size={18} />}
size="compact-sm"
onClick={copyEPGUrl}
p={5}
color="green"
variant="subtle"
style={{
borderWidth: 1,
borderColor: theme.palette.custom.greyBorder,
color: theme.palette.custom.greyBorder,
}}
>
<ContentCopy size="18" fontSize="small" />
</ActionIcon>
</Group>
</Popover.Dropdown>
</Popover>
</Box>
EPG
</Button>
</Popover.Target>
<Popover.Dropdown>
<Group>
<TextInput value={textToCopy} size="small" sx={{ mr: 1 }} />
<ActionIcon
onClick={handleCopy}
size="sm"
variant="transparent"
>
<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',
// bgcolor: theme.palette.background.paper,
// borderRadius: 2,
// overflow: 'hidden',
// display: 'flex',
// flexDirection: 'column',
height: 'calc(100vh - 75px)',
display: 'flex',
flexDirection: 'column',
}}
>
{/* Top toolbar with Remove, Assign, Auto-match, and Add buttons */}
@ -836,7 +777,7 @@ const ChannelsTable = ({}) => {
<Flex gap={6}>
<Tooltip label="Remove Channels">
<Button
leftSection={<SquareMinus size={14} />}
leftSection={<SquareMinus size={18} />}
variant="default"
size="xs"
onClick={deleteChannels}
@ -847,7 +788,7 @@ const ChannelsTable = ({}) => {
<Tooltip label="Assign Channel #s">
<Button
leftSection={<IconSortAscendingNumbers size={14} />}
leftSection={<IconSortAscendingNumbers size={18} />}
variant="default"
size="xs"
onClick={assignChannels}
@ -859,7 +800,7 @@ const ChannelsTable = ({}) => {
<Tooltip label="Auto-Match EPG">
<Button
leftSection={<IconDeviceDesktopSearch size={14} />}
leftSection={<IconDeviceDesktopSearch size={18} />}
variant="default"
size="xs"
onClick={matchEpg}
@ -869,12 +810,12 @@ const ChannelsTable = ({}) => {
</Button>
</Tooltip>
<Tooltip label="Assign">
<Tooltip label="Create New Channel">
<Button
leftSection={<IconSquarePlus size={14} />}
leftSection={<IconSquarePlus size={18} />}
variant="light"
size="xs"
onClick={matchEpg}
onClick={() => editChannel()}
p={5}
color="green"
style={{
@ -890,14 +831,13 @@ const ChannelsTable = ({}) => {
</Box>
{/* Table or ghost empty state inside Paper */}
<Box sx={{ flex: 1, position: 'relative' }}>
{filteredData.length === 0 ? (
<Box
<Box style={{ flex: 1, position: 'relative' }}>
{filteredData.length === 0 && (
<Flex
style={{
position: 'relative',
width: '100%',
height: '100%',
bgcolor: theme.palette.background.paper,
height: 'calc(50vh - 124px)',
}}
>
<Box
@ -906,7 +846,7 @@ const ChannelsTable = ({}) => {
alt="Ghost"
style={{
position: 'absolute',
top: '50%',
top: '80%',
left: '50%',
width: '120px',
height: 'auto',
@ -956,41 +896,29 @@ const ChannelsTable = ({}) => {
and map them later.
</Text>
<Button
variant="contained"
leftSection={<IconSquarePlus size={18} />}
variant="light"
size="xs"
onClick={() => editChannel()}
startIcon={<AddIcon sx={{ fontSize: 16 }} />}
color="gray"
style={{
minWidth: '127px',
height: '25px',
borderRadius: '4px',
marginTop: 20,
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',
},
borderColor: 'gray',
color: 'white',
}}
>
Create channel
Create Channel
</Button>
</Box>
</Box>
) : (
<Box style={{ flex: 1, overflow: 'auto' }}>
<MantineReactTable table={table} />
</Box>
</Flex>
)}
</Box>
{filteredData.length > 0 && (
<Box style={{ flex: 1, overflow: 'auto' }}>
<MantineReactTable table={table} />
</Box>
)}
</Paper>
<ChannelForm

View file

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

View file

@ -10,18 +10,28 @@ import {
import useEPGsStore from '../../store/epgs';
import EPGForm from '../forms/EPG';
import { TableHelper } from '../../helpers';
import { ActionIcon, Text, Tooltip, Box } from '@mantine/core';
import useAlertStore from '../../store/alerts';
import {
ActionIcon,
Text,
Tooltip,
Box,
Paper,
Button,
Flex,
useMantineTheme,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { IconSquarePlus } from '@tabler/icons-react';
const EPGsTable = () => {
const [epg, setEPG] = useState(null);
const [epgModalOpen, setEPGModalOpen] = useState(false);
const [rowSelection, setRowSelection] = useState([]);
const { showAlert } = useAlertStore();
const epgs = useEPGsStore((state) => state.epgs);
const theme = useMantineTheme();
const columns = useMemo(
//column definitions...
() => [
@ -60,7 +70,9 @@ const EPGsTable = () => {
const refreshEPG = async (id) => {
await API.refreshEPG(id);
showAlert('EPG refresh initiated');
notifications.show({
title: 'EPG refresh initiated',
});
};
const closeEPGForm = () => {
@ -89,7 +101,8 @@ const EPGsTable = () => {
data: epgs,
enablePagination: false,
enableRowVirtualization: true,
enableRowSelection: true,
enableRowSelection: false,
renderTopToolbar: false,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
state: {
@ -136,29 +149,73 @@ const EPGsTable = () => {
height: 'calc(40vh - 0px)',
},
},
renderTopToolbarCustomActions: ({ table }) => (
<>
<Text>EPGs</Text>
<Tooltip label="Add New EPG">
<ActionIcon
size="small" // Makes the button smaller
color="success" // Red color for delete actions
variant="contained"
onClick={() => editEPG()}
>
<AddIcon fontSize="small" /> {/* Small icon size */}
</ActionIcon>
</Tooltip>
</>
),
});
return (
<Box
sx={{
padding: 1,
}}
>
<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>

View file

@ -1,242 +0,0 @@
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 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 usePlaylistsStore from '../../store/playlists';
import M3UForm from '../forms/M3U';
import { TableHelper } from '../../helpers';
const Example = () => {
const [playlist, setPlaylist] = useState(null);
const [playlistModalOpen, setPlaylistModalOpen] = useState(false);
const [rowSelection, setRowSelection] = useState([]);
const [activeFilterValue, setActiveFilterValue] = useState('all');
const playlists = usePlaylistsStore((state) => state.playlists);
const columns = useMemo(
//column definitions...
() => [
{
header: 'Name',
accessorKey: 'name',
},
{
header: 'URL / File',
accessorKey: 'server_url',
},
{
header: 'Max Streams',
accessorKey: 'max_streams',
size: 200,
},
{
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) 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 editPlaylist = async (playlist = null) => {
if (playlist) {
setPlaylist(playlist);
}
setPlaylistModalOpen(true);
};
const refreshPlaylist = async (id) => {
await API.refreshPlaylist(id);
};
const deletePlaylist = async (id) => {
await API.deletePlaylist(id);
};
const closeModal = () => {
setPlaylistModalOpen(false);
setPlaylist(null);
};
const deletePlaylists = async (ids) => {
const selected = table
.getRowModel()
.rows.filter((row) => row.getIsSelected());
// await API.deleteStreams(selected.map(stream => stream.original.id))
};
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: playlists,
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={() => {
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
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"
onClick={() => refreshPlaylist(row.original.id)}
sx={{ pt: 0, pb: 0 }}
>
<RefreshIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
</>
),
muiTableContainerProps: {
sx: {
height: 'calc(43vh - 0px)',
pr: 1,
pl: 1,
},
},
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} />
<M3UForm
playlist={playlist}
isOpen={playlistModalOpen}
onClose={closeModal}
/>
</Box>
);
};
export default Example;

View file

@ -1,17 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
MaterialReactTable,
useMaterialReactTable,
} from 'material-react-table';
import {
Box,
Stack,
Typography,
IconButton,
Tooltip,
Select,
MenuItem,
} from '@mui/material';
import { MantineReactTable, useMantineReactTable } from 'mantine-react-table';
import API from '../../api';
import {
Delete as DeleteIcon,
@ -22,9 +10,50 @@ import {
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,
} 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 +63,8 @@ const Example = () => {
const playlists = usePlaylistsStore((state) => state.playlists);
const theme = useMantineTheme();
const columns = useMemo(
//column definitions...
() => [
@ -44,6 +75,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 +97,7 @@ const Example = () => {
accessorKey: 'is_active',
size: 100,
sortingFn: 'basic',
muiTableBodyCellProps: {
mantineTableBodyCellProps: {
align: 'left',
},
Cell: ({ cell }) => (
@ -67,29 +109,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 +162,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 +185,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
<EditIcon fontSize="sm" />
</ActionIcon>
<ActionIcon
variant="transparent"
size="sm"
color="red.5"
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"
<DeleteIcon fontSize="sm" />
</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>
<RefreshIcon fontSize="sm" />
</ActionIcon>
</>
),
muiTableContainerProps: {
sx: {
height: 'calc(43vh - 0px)',
pr: 1,
pl: 1,
mantineTableContainerProps: {
style: {
height: 'calc(40vh - 0px)',
},
},
renderTopToolbarCustomActions: ({ table }) => (
<Stack
direction="row"
sx={{
alignItems: 'center',
}}
>
<Typography>M3U Accounts</Typography>
<Tooltip title="Add New M3U Account">
<IconButton
size="small" // Makes the button smaller
color="success" // Red color for delete actions
variant="contained"
onClick={() => editPlaylist()}
>
<AddIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
</Tooltip>
</Stack>
),
});
return (
<Box
sx={{
padding: 1,
}}
>
<MaterialReactTable table={table} />
<Box>
<Flex
style={{ display: 'flex', alignItems: 'center', paddingBottom: 10 }}
gap={15}
>
<Text
h={24}
style={{
fontFamily: 'Inter, sans-serif',
fontWeight: 500,
fontSize: '20px',
lineHeight: 1,
letterSpacing: '-0.3px',
color: 'gray.6', // Adjust this to match MUI's theme.palette.text.secondary
marginBottom: 0,
}}
>
M3U Accounts
</Text>
</Flex>
<Paper
style={{
bgcolor: theme.palette.background.paper,
borderRadius: 2,
}}
>
{/* Top toolbar with Remove, Assign, Auto-match, and Add buttons */}
<Box
style={{
display: 'flex',
// alignItems: 'center',
// backgroundColor: theme.palette.background.paper,
justifyContent: 'flex-end',
padding: 10,
// gap: 1,
}}
>
<Flex gap={6}>
<Tooltip label="Assign">
<Button
leftSection={<IconSquarePlus size={14} />}
variant="light"
size="xs"
onClick={() => editPlaylist()}
p={5}
color="green"
style={{
borderWidth: '1px',
borderColor: 'green',
color: 'white',
}}
>
Add
</Button>
</Tooltip>
</Flex>
</Box>
</Paper>
<MantineReactTable table={table} />
<M3UForm
playlist={playlist}
isOpen={playlistModalOpen}

View file

@ -1,233 +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,
} 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 StreamProfileForm from '../forms/StreamProfile';
import useStreamProfilesStore from '../../store/streamProfiles';
import { TableHelper } from '../../helpers';
import useSettingsStore from '../../store/settings';
import useAlertStore from '../../store/alerts';
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 columns = useMemo(
//column definitions...
() => [
{
header: 'Name',
accessorKey: 'profile_name',
},
{
header: 'Command',
accessorKey: 'command',
},
{
header: 'Parameters',
accessorKey: 'parameters',
},
{
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, filterValue) => {
if (filterValue == 'all') return true; // Show all if no filter
return String(row.getValue('is_active')) === filterValue;
},
},
],
[]
);
//optionally access the underlying virtualizer instance
const rowVirtualizerInstanceRef = useRef(null);
const [isLoading, setIsLoading] = useState(true);
const [sorting, setSorting] = useState([]);
const editStreamProfile = async (profile = null) => {
setProfile(profile);
setProfileModalOpen(true);
};
const deleteStreamProfile = async (id) => {
if (id == settings['default-stream-profile'].value) {
showAlert('Cannot delete default stream-profile', 'error');
return;
}
await API.deleteStreamProfile(id);
};
const closeStreamProfileForm = () => {
setProfile(null);
setProfileModalOpen(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: streamProfiles,
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={() => 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
onClick={() => deleteStreamProfile(row.original.id)}
sx={{ pt: 0, pb: 0 }}
>
<DeleteIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
</>
),
muiTableContainerProps: {
sx: {
height: 'calc(100vh - 73px)', // Subtract padding to avoid cutoff
overflowY: 'auto', // Internal scrolling for the table
},
},
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} />
<StreamProfileForm
profile={profile}
isOpen={profileModalOpen}
onClose={closeStreamProfileForm}
/>
</Box>
);
};
export default StreamProfiles;

View file

@ -12,8 +12,18 @@ 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 { Box, ActionIcon, Tooltip, Text } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import {
Box,
ActionIcon,
Tooltip,
Text,
Paper,
Flex,
Button,
useMantineTheme,
} from '@mantine/core';
import { IconSquarePlus } from '@tabler/icons-react';
const StreamProfiles = () => {
const [profile, setProfile] = useState(null);
@ -23,7 +33,8 @@ const StreamProfiles = () => {
const streamProfiles = useStreamProfilesStore((state) => state.profiles);
const { settings } = useSettingsStore();
const { showAlert } = useAlertStore();
const theme = useMantineTheme();
const columns = useMemo(
//column definitions...
@ -93,7 +104,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;
}
@ -127,6 +141,7 @@ const StreamProfiles = () => {
enablePagination: false,
enableRowVirtualization: true,
enableRowSelection: true,
renderTopToolbar: false,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
state: {
@ -161,33 +176,77 @@ const StreamProfiles = () => {
),
mantineTableContainerProps: {
style: {
height: 'calc(100vh - 90px)',
height: 'calc(100vh - 120px)',
overflowY: 'auto',
},
},
renderTopToolbarCustomActions: ({ table }) => (
<>
<Text>Stream Profiles</Text>
<Tooltip label="Add New Stream Profile">
<ActionIcon
variant="transparent"
size="sm"
color="green.5"
onClick={() => editStreamProfile()}
>
<AddIcon fontSize="small" /> {/* Small icon size */}
</ActionIcon>
</Tooltip>
</>
),
});
return (
<Box
sx={{
padding: 1,
}}
>
<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

View file

@ -1,598 +0,0 @@
import { useEffect, useMemo, useCallback, useState, useRef } from 'react';
import {
MaterialReactTable,
useMaterialReactTable,
} from 'material-react-table';
import {
Box,
Stack,
Typography,
IconButton,
Tooltip,
Button,
Menu,
MenuItem,
TextField,
Autocomplete,
InputAdornment,
} from '@mui/material';
import API from '../../api';
import {
Delete as DeleteIcon,
Edit as EditIcon,
Add as AddIcon,
MoreVert as MoreVertIcon,
PlaylistAdd as PlaylistAddIcon,
} 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 = ({}) => {
/**
* 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);
/**
* 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);
/**
* 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
sx={
{
// '& .MuiInputBase-root': { fontSize: '0.875rem' }, // Text size
// '& .MuiInputLabel-root': { fontSize: '0.75rem' }, // Label size
// width: '200px', // Optional: Adjust width
}
}
// slotProps={{
// input: {
// endAdornment: (
// <InputAdornment position="end">
// <IconButton
// onClick={() => handleFilterChange(column.id, '')} // Clear text on click
// edge="end"
// size="small"
// sx={{ p: 0 }}
// >
// <ClearIcon sx={{ fontSize: '1rem' }} />
// </IconButton>
// </InputAdornment>
// ),
// },
// }}
/>
),
},
{
header: 'Group',
accessorKey: 'group_name',
Header: ({ column }) => (
<Autocomplete
disablePortal
options={groupOptions}
size="small"
// sx={{ width: 300 }}
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' }, // Text size
'& .MuiInputLabel-root': { fontSize: '0.75rem' }, // Label size
width: '200px', // Optional: Adjust width
}}
/>
)}
/>
),
},
{
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"
// sx={{ width: 300 }}
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' }, // Text size
'& .MuiInputLabel-root': { fontSize: '0.75rem' }, // Label size
width: '200px', // Optional: Adjust width
}}
/>
)}
/>
),
},
],
[playlists, groupOptions, filters]
);
/**
* Functions
*/
const handleFilterChange = (e) => {
const { name, value } = e.target;
setFilters((prev) => ({
...prev,
[name]: value,
}));
};
const handleGroupChange = (value) => {
console.log(value);
setFilters((prev) => ({
...prev,
group_name: value ? value.value : '',
}));
};
const handleM3UChange = (value) => {
console.log(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.map((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();
// Apply debounced filters
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,
enableRowVirtualization: true,
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,
indeterminate:
selectedStreamIds.length > 0 && selectedStreamIds.length != rowCount,
onChange: onSelectAllChange,
},
onRowSelectionChange: onRowSelectionChange,
initialState: {
density: 'compact',
},
state: {
isLoading: isLoading,
sorting,
pagination,
rowSelection,
},
enableRowActions: true,
positionActionsColumn: 'first',
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,
},
},
renderTopToolbarCustomActions: ({ table }) => {
const selectedRowCount = table.getSelectedRowModel().rows.length;
return (
<Stack direction="row" sx={{ alignItems: 'center' }}>
<Typography>Streams</Typography>
<Tooltip title="Add New Stream">
<IconButton
size="small"
color="success"
variant="contained"
onClick={() => editStream()}
>
<SquarePlus size="18" fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Delete Streams">
<IconButton
size="small"
color="error"
variant="contained"
onClick={deleteStreams}
disabled={setSelectedStreamIds == 0 || unselectedStreamIds == 0}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
<Button
variant="contained"
onClick={createChannelsFromStreams}
size="small"
sx={{ marginLeft: 1 }}
disabled={selectedRowCount == 0}
>
CREATE CHANNELS
</Button>
<Button
variant="contained"
onClick={addStreamsToChannel}
size="small"
sx={{ marginLeft: 1 }}
disabled={
channelsPageSelection.length != 1 || selectedRowCount == 0
}
>
ADD TO CHANNEL
</Button>
</Stack>
);
},
});
/**
* 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>
<MaterialReactTable table={table} />
<StreamForm
stream={stream}
isOpen={modalOpen}
onClose={closeStreamForm}
/>
</Box>
);
};
export default StreamsTable;

View file

@ -13,7 +13,7 @@ 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';
import { SquarePlus, ListPlus, SquareMinus } from 'lucide-react';
import {
TextInput,
ActionIcon,
@ -25,6 +25,11 @@ import {
Text,
Paper,
Button,
Card,
Stack,
Title,
Divider,
Center,
} from '@mantine/core';
import {
IconArrowDown,
@ -32,9 +37,9 @@ import {
IconDeviceDesktopSearch,
IconSelector,
IconSortAscendingNumbers,
IconSquareMinus,
IconSquarePlus,
} from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
const StreamsTable = ({}) => {
/**
@ -47,6 +52,7 @@ const StreamsTable = ({}) => {
const [groupOptions, setGroupOptions] = useState([]);
const [m3uOptions, setM3uOptions] = useState([]);
const [actionsOpenRow, setActionsOpenRow] = useState(null);
const [dataFetched, setDataFetched] = useState(false);
const [data, setData] = useState([]); // Holds fetched data
const [rowCount, setRowCount] = useState(0);
@ -66,6 +72,8 @@ const StreamsTable = ({}) => {
});
const debouncedFilters = useDebounce(filters, 500);
const navigate = useNavigate();
/**
* Stores
*/
@ -80,6 +88,11 @@ const StreamsTable = ({}) => {
// Access the row virtualizer instance (optional)
const rowVirtualizerInstanceRef = useRef(null);
const handleSelectClick = (e) => {
e.stopPropagation();
e.preventDefault();
};
/**
* useMemo
*/
@ -119,15 +132,17 @@ const StreamsTable = ({}) => {
accessorKey: 'group_name',
size: 100,
Header: ({ column }) => (
<Select
placeholder="Group"
searchable
size="xs"
nothingFound="No options"
onClick={(e) => e.stopPropagation()}
onChange={handleGroupChange}
data={groupOptions}
/>
<Box onClick={handleSelectClick}>
<Select
placeholder="Group"
searchable
size="xs"
nothingFound="No options"
onClick={handleSelectClick}
onChange={handleGroupChange}
data={groupOptions}
/>
</Box>
),
Cell: ({ cell }) => (
<div
@ -147,18 +162,20 @@ const StreamsTable = ({}) => {
accessorFn: (row) =>
playlists.find((playlist) => playlist.id === row.m3u_account)?.name,
Header: ({ column }) => (
<Select
placeholder="M3U"
searchable
size="xs"
nothingFound="No options"
onClick={(e) => e.stopPropagation()}
onChange={handleM3UChange}
data={playlists.map((playlist) => ({
label: playlist.name,
value: `${playlist.id}`,
}))}
/>
<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>
),
},
],
@ -179,14 +196,14 @@ const StreamsTable = ({}) => {
const handleGroupChange = (value) => {
setFilters((prev) => ({
...prev,
group_name: value ? value.value : '',
group_name: value ? value : '',
}));
};
const handleM3UChange = (value) => {
setFilters((prev) => ({
...prev,
m3u_account: value ? value.value : '',
m3u_account: value ? value : '',
}));
};
@ -233,6 +250,9 @@ const StreamsTable = ({}) => {
setGroupOptions(groups);
setIsLoading(false);
if (dataFetched === false) {
setDataFetched(true);
}
}, [pagination, sorting, debouncedFilters]);
// Fallback: Individual creation (optional)
@ -393,6 +413,13 @@ const StreamsTable = ({}) => {
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],
@ -457,7 +484,7 @@ const StreamsTable = ({}) => {
<Menu.Dropdown>
<Menu.Item
onClick={() => editStream(row.original.id)}
onClick={() => editStream(row.original)}
disabled={row.original.m3u_account ? true : false}
>
Edit
@ -471,16 +498,16 @@ const StreamsTable = ({}) => {
),
mantineTableContainerProps: {
style: {
height: 'calc(100vh - 165px)',
height: 'calc(100vh - 180px)',
overflowY: 'auto',
},
},
displayColumnDefOptions: {
'mrt-row-actions': {
size: 68,
size: 30,
},
'mrt-row-select': {
size: 50,
size: 20,
},
},
});
@ -509,7 +536,10 @@ const StreamsTable = ({}) => {
return (
<>
<Flex style={{ display: 'flex', alignItems: 'center', pb: 1 }} gap={15}>
<Flex
style={{ display: 'flex', alignItems: 'center', paddingBottom: 10 }}
gap={15}
>
<Text
w={88}
h={24}
@ -519,7 +549,7 @@ const StreamsTable = ({}) => {
fontSize: '20px',
lineHeight: 1,
letterSpacing: '-0.3px',
color: 'gray.6', // Adjust this to match MUI's theme.palette.text.secondary
// color: 'gray.6', // Adjust this to match MUI's theme.palette.text.secondary
marginBottom: 0,
}}
>
@ -528,16 +558,14 @@ const StreamsTable = ({}) => {
</Flex>
<Paper
style={
{
// bgcolor: theme.palette.background.paper,
// borderRadius: 2,
// overflow: 'hidden',
// height: 'calc(100vh - 75px)',
// display: 'flex',
// flexDirection: 'column',
}
}
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
@ -551,51 +579,98 @@ const StreamsTable = ({}) => {
}}
>
<Flex gap={6}>
<Tooltip label="Remove Channels">
<Button
leftSection={<IconSquareMinus size={14} />}
variant="default"
size="xs"
onClick={deleteStreams}
>
Remove
</Button>
</Tooltip>
<Button
leftSection={<SquareMinus size={18} />}
variant="default"
size="xs"
onClick={deleteStreams}
>
Remove
</Button>
<Tooltip label="Auto-Match EPG">
<Button
leftSection={<IconDeviceDesktopSearch size={14} />}
variant="default"
size="xs"
onClick={createChannelsFromStreams}
p={5}
>
Create Channels
</Button>
</Tooltip>
<Button
leftSection={<IconSquarePlus size={18} />}
variant="default"
size="xs"
onClick={createChannelsFromStreams}
p={5}
>
Create Channels
</Button>
<Tooltip label="Assign">
<Button
leftSection={<IconSquarePlus size={14} />}
variant="light"
size="xs"
onClick={editStream}
p={5}
color="green"
style={{
borderWidth: '1px',
borderColor: 'green',
color: 'white',
}}
>
Add Stream
</Button>
</Tooltip>
<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>
</Paper>
<MantineReactTable table={table} />
{!dataFetched && (
<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>
)}
{dataFetched && <MantineReactTable table={table} />}
</Paper>
<StreamForm
stream={stream}
isOpen={modalOpen}

View file

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

View file

@ -12,8 +12,26 @@ import useUserAgentsStore from '../../store/userAgents';
import UserAgentForm from '../forms/UserAgent';
import { TableHelper } from '../../helpers';
import useSettingsStore from '../../store/settings';
import useAlertStore from '../../store/alerts';
import { ActionIcon, Center, Flex, Select, Tooltip, Text } from '@mantine/core';
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';
const UserAgentsTable = () => {
const [userAgent, setUserAgent] = useState(null);
@ -23,7 +41,6 @@ const UserAgentsTable = () => {
const userAgents = useUserAgentsStore((state) => state.userAgents);
const { settings } = useSettingsStore();
const { showAlert } = useAlertStore();
const columns = useMemo(
//column definitions...
@ -35,17 +52,39 @@ const UserAgentsTable = () => {
{
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',
muiTableBodyCellProps: {
mantineTableBodyCellProps: {
align: 'left',
},
Cell: ({ cell }) => (
@ -105,14 +144,20 @@ const UserAgentsTable = () => {
const deleteUserAgent = async (ids) => {
if (Array.isArray(ids)) {
if (ids.includes(settings['default-user-agent'].value)) {
showAlert('Cannot delete default user-agent', 'error');
notifications.show({
title: 'Cannot delete default user-agent',
color: 'red.5',
});
return;
}
await API.deleteUserAgents(ids);
} else {
if (ids == settings['default-user-agent'].value) {
showAlert('Cannot delete default user-agent', 'error');
notifications.show({
title: 'Cannot delete default user-agent',
color: 'red.5',
});
return;
}
@ -147,6 +192,7 @@ const UserAgentsTable = () => {
enablePagination: false,
enableRowVirtualization: true,
enableRowSelection: true,
renderTopToolbar: false,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
state: {
@ -182,30 +228,85 @@ const UserAgentsTable = () => {
</ActionIcon>
</>
),
muiTableContainerProps: {
sx: {
height: 'calc(42vh + 5px)',
mantineTableContainerProps: {
style: {
height: 'calc(43vh - 55px)',
},
},
renderTopToolbarCustomActions: ({ table }) => (
<Flex directino="row">
<Text>User-Agents</Text>
<Tooltip label="Add New User Agent">
<ActionIcon
variant="transparent"
size="small" // Makes the button smaller
color="green.5" // Red color for delete actions
onClick={() => editUserAgent()}
>
<AddIcon fontSize="small" /> {/* Small icon size */}
</ActionIcon>
</Tooltip>
</Flex>
),
});
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}

View file

@ -1,3 +0,0 @@
import table from "./table";
export const TableHelper = table;

View file

@ -1,53 +0,0 @@
// frontend/src/helpers/table.js
export default {
defaultProperties: {
enableGlobalFilter: false,
enableBottomToolbar: false,
enableDensityToggle: false,
enableFullScreenToggle: false,
positionToolbarAlertBanner: 'none',
// columnFilterDisplayMode: 'popover',
enableRowNumbers: false,
positionActionsColumn: 'last',
enableColumnActions: false,
enableColumnFilters: false,
enableGlobalFilter: false,
initialState: {
density: 'compact',
},
muiTableBodyCellProps: {
sx: {
py: 0,
borderColor: '#444',
color: '#E0E0E0',
fontSize: '0.85rem',
},
},
muiTableHeadCellProps: {
sx: {
py: 0,
color: '#CFCFCF',
backgroundColor: '#383A3F',
borderColor: '#444',
fontWeight: 600,
fontSize: '0.8rem',
},
},
muiTableBodyProps: {
sx: {
// Subtle row striping
'& tr:nth-of-type(odd)': {
backgroundColor: '#2F3034',
},
'& tr:nth-of-type(even)': {
backgroundColor: '#333539',
},
// Row hover effect
'& tr:hover td': {
backgroundColor: '#3B3D41',
},
},
},
},
};

View file

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

View file

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

View file

@ -1,43 +0,0 @@
import React, { useState } from 'react';
import ChannelsTable from '../components/tables/ChannelsTable';
import StreamsTable from '../components/tables/StreamsTable';
import { Grid2, Box } from '@mui/material';
const ChannelsPage = () => {
return (
<Grid2 container>
<Grid2 size={6}>
<Box
sx={{
height: '100vh', // Full viewport height
paddingTop: 0, // Top padding
paddingBottom: 1, // Bottom padding
paddingRight: 0.5,
paddingLeft: 0,
boxSizing: 'border-box', // Include padding in height calculation
overflow: 'hidden', // Prevent parent scrolling
}}
>
<ChannelsTable />
</Box>
</Grid2>
<Grid2 size={6}>
<Box
sx={{
height: '100vh', // Full viewport height
paddingTop: 0, // Top padding
paddingBottom: 1, // Bottom padding
paddingRight: 0,
paddingLeft: 0.5,
boxSizing: 'border-box', // Include padding in height calculation
overflow: 'hidden', // Prevent parent scrolling
}}
>
<StreamsTable />
</Box>
</Grid2>
</Grid2>
);
};
export default ChannelsPage;

View file

@ -5,11 +5,11 @@ import { Box, Grid } from '@mantine/core';
const ChannelsPage = () => {
return (
<Grid>
<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,
@ -23,8 +23,8 @@ const ChannelsPage = () => {
</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,

View file

@ -1,27 +0,0 @@
// src/components/Dashboard.js
import React, { useState } from "react";
const Dashboard = () => {
const [newStream, setNewStream] = useState("");
return (
<div>
<h1>Dashboard Page</h1>
<input
type="text"
value={newStream}
onChange={(e) => setNewStream(e.target.value)}
placeholder="Enter Stream"
/>
<h3>Streams:</h3>
<ul>
{state.streams.map((stream, index) => (
<li key={index}>{stream}</li>
))}
</ul>
</div>
);
};
export default Dashboard;

View file

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

View file

@ -9,8 +9,9 @@ const EPGPage = () => {
style={{
display: 'flex',
flexDirection: 'column',
height: '95vh',
height: '100vh',
overflow: 'hidden',
padding: 16,
}}
>
<Box style={{ flex: '1 1 50%', overflow: 'hidden' }}>

View file

@ -1,532 +0,0 @@
// 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 useSettingsStore from '../store/settings';
/** Layout constants */
const CHANNEL_WIDTH = 120; // Width of the channel/logo column
const PROGRAM_HEIGHT = 90; // Height of each channel row
const HOUR_WIDTH = 300; // The width for a 1-hour block
const MINUTE_INCREMENT = 15; // For positioning programs every 15 min
const MINUTE_BLOCK_WIDTH = HOUR_WIDTH / (60 / MINUTE_INCREMENT);
// Modal size constants
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();
const [programs, setPrograms] = useState([]);
const [guideChannels, setGuideChannels] = useState([]);
const [now, setNow] = useState(dayjs());
const [selectedProgram, setSelectedProgram] = useState(null);
const [loading, setLoading] = useState(true);
const { showAlert } = useAlertStore();
const {
environment: { env_mode },
} = useSettingsStore();
const guideRef = useRef(null);
// Load program data once
useEffect(() => {
if (!channels || channels.length === 0) {
console.warn('No channels provided or empty channels array');
showAlert('No channels available', 'error');
setLoading(false);
return;
}
const fetchPrograms = async () => {
console.log('Fetching program grid...');
const fetched = await API.getGrid(); // GETs your EPG grid
console.log(`Received ${fetched.length} programs`);
// Unique tvg_ids from returned programs
const programIds = [...new Set(fetched.map((p) => p.tvg_id))];
// Filter your Redux/Zustand channels by matching tvg_id
const filteredChannels = channels.filter((ch) =>
programIds.includes(ch.tvg_id)
);
console.log(
`found ${filteredChannels.length} channels with matching tvg_ids`
);
setGuideChannels(filteredChannels);
setPrograms(fetched);
setLoading(false);
};
fetchPrograms();
}, [channels]);
// Use start/end from props or default to "today at midnight" +24h
const defaultStart = dayjs(startDate || dayjs().startOf('day'));
const defaultEnd = endDate ? dayjs(endDate) : defaultStart.add(24, 'hour');
// Expand timeline if needed based on actual earliest/ latest program
const earliestProgramStart = useMemo(() => {
if (!programs.length) return defaultStart;
return programs.reduce((acc, p) => {
const s = dayjs(p.start_time);
return s.isBefore(acc) ? s : acc;
}, defaultStart);
}, [programs, defaultStart]);
const latestProgramEnd = useMemo(() => {
if (!programs.length) return defaultEnd;
return programs.reduce((acc, p) => {
const e = dayjs(p.end_time);
return e.isAfter(acc) ? e : acc;
}, defaultEnd);
}, [programs, defaultEnd]);
const start = earliestProgramStart.isBefore(defaultStart)
? earliestProgramStart
: defaultStart;
const end = latestProgramEnd.isAfter(defaultEnd)
? latestProgramEnd
: defaultEnd;
// Time increments in 15-min steps (for placing programs)
const programTimeline = useMemo(() => {
const times = [];
let current = start;
while (current.isBefore(end)) {
times.push(current);
current = current.add(MINUTE_INCREMENT, 'minute');
}
return times;
}, [start, end]);
// Hourly marks
const hourTimeline = useMemo(() => {
const hours = [];
let current = start;
while (current.isBefore(end)) {
hours.push(current);
current = current.add(1, 'hour');
}
return hours;
}, [start, end]);
// Scroll to "now" on load
useEffect(() => {
if (guideRef.current) {
const nowOffset = dayjs().diff(start, 'minute');
const scrollPosition =
(nowOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH -
MINUTE_BLOCK_WIDTH;
guideRef.current.scrollLeft = Math.max(scrollPosition, 0);
}
}, [programs, start]);
// Update “now” every 60s
useEffect(() => {
const interval = setInterval(() => {
setNow(dayjs());
}, 60000);
return () => clearInterval(interval);
}, []);
// Pixel offset for the “now” vertical line
const nowPosition = useMemo(() => {
if (now.isBefore(start) || now.isAfter(end)) return -1;
const minutesSinceStart = now.diff(start, 'minute');
return (minutesSinceStart / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
}, [now, start, end]);
// Helper: find channel by tvg_id
function findChannelByTvgId(tvgId) {
return guideChannels.find((ch) => ch.tvg_id === tvgId);
}
// The “Watch Now” click => show floating video
const { showVideo } = useVideoStore(); // or useVideoStore()
function handleWatchStream(program) {
const matched = findChannelByTvgId(program.tvg_id);
if (!matched) {
console.warn(`No channel found for tvg_id=${program.tvg_id}`);
return;
}
// Build a playable stream URL for that channel
let vidUrl = `/output/stream/${matched.channel_number}/`;
if (env_mode == 'dev') {
vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`;
}
showVideo(vidUrl);
// Optionally close the modal
setSelectedProgram(null);
}
// On program click, open the details modal
function handleProgramClick(program, event) {
// Optionally scroll that element into view or do something else
event.currentTarget.scrollIntoView({
behavior: 'smooth',
inline: 'center',
});
setSelectedProgram(program);
}
// Close the modal
function handleCloseModal() {
setSelectedProgram(null);
}
// Renders each program block
function renderProgram(program, channelStart) {
const programKey = `${program.tvg_id}-${program.start_time}`;
const programStart = dayjs(program.start_time);
const programEnd = dayjs(program.end_time);
const startOffsetMinutes = programStart.diff(channelStart, 'minute');
const durationMinutes = programEnd.diff(programStart, 'minute');
const leftPx = (startOffsetMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
const widthPx = (durationMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
// Highlight if currently live
const isLive = now.isAfter(programStart) && now.isBefore(programEnd);
return (
<Box
key={programKey}
sx={{
position: 'absolute',
left: leftPx,
top: 0,
width: widthPx,
cursor: 'pointer',
}}
onClick={(e) => handleProgramClick(program, e)}
>
<Paper
elevation={2}
sx={{
position: 'relative',
left: 2,
width: widthPx - 4,
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)',
},
}}
>
<Typography variant="body2" noWrap sx={{ fontWeight: 'bold' }}>
{program.title}
</Typography>
<Typography variant="overline" noWrap>
{programStart.format('h:mma')} - {programEnd.format('h:mma')}
</Typography>
</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={{
overflow: 'hidden',
width: '100%',
height: '100%',
backgroundColor: '#1a202c',
color: '#fff',
fontFamily: 'Roboto, sans-serif',
}}
>
{/* Sticky top bar */}
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: '#2d3748',
color: '#fff',
p: 2,
position: 'sticky',
top: 0,
zIndex: 999,
}}
>
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
TV Guide
</Typography>
<Typography variant="body2">
{now.format('dddd, MMMM D, YYYY • h:mm A')}
</Typography>
</Box>
{/* Main layout */}
<Stack direction="row">
{/* Channel Logos Column */}
<Box sx={{ backgroundColor: '#2d3748', color: '#fff' }}>
<Box
sx={{
width: CHANNEL_WIDTH,
height: '40px',
borderBottom: '1px solid #4a5568',
}}
/>
{guideChannels.map((channel) => (
<Box
key={channel.channel_name}
sx={{
display: 'flex',
height: PROGRAM_HEIGHT,
alignItems: 'center',
justifyContent: 'center',
borderBottom: '1px solid #4a5568',
}}
>
<Box
sx={{
width: CHANNEL_WIDTH,
display: 'flex',
p: 1,
justifyContent: 'center',
maxWidth: CHANNEL_WIDTH * 0.8,
maxHeight: PROGRAM_HEIGHT * 0.8,
}}
>
<img
src={channel.logo_url || logo}
alt={channel.channel_name}
style={{
width: '100%',
height: 'auto',
objectFit: 'contain',
}}
/>
</Box>
</Box>
))}
</Box>
{/* Timeline & Program Blocks */}
<Box
ref={guideRef}
sx={{
flex: 1,
overflowX: 'auto',
overflowY: 'auto',
}}
>
{/* Sticky timeline header */}
<Box
sx={{
display: 'flex',
position: 'sticky',
top: 0,
zIndex: 10,
backgroundColor: '#171923',
borderBottom: '1px solid #4a5568',
}}
>
<Box sx={{ flex: 1, display: 'flex' }}>
{hourTimeline.map((time, hourIndex) => (
<Box
key={time.format()}
sx={{
width: HOUR_WIDTH,
height: '40px',
position: 'relative',
color: '#a0aec0',
borderRight: '1px solid #4a5568',
}}
>
<Typography
variant="body2"
sx={{
position: 'absolute',
top: '50%',
left: hourIndex === 0 ? 4 : 'calc(50% - 16px)',
transform: 'translateY(-50%)',
}}
>
{time.format('h:mma')}
</Typography>
<Box
sx={{
position: 'absolute',
bottom: 0,
top: 0,
width: '100%',
display: 'grid',
gridTemplateColumns: 'repeat(4, 1fr)',
alignItems: 'end',
}}
>
{[0, 1, 2, 3].map((i) => (
<Box
key={i}
sx={{
width: '1px',
height: '10px',
backgroundColor: '#718096',
marginRight: i < 3 ? HOUR_WIDTH / 4 - 1 + 'px' : 0,
}}
/>
))}
</Box>
</Box>
))}
</Box>
</Box>
{/* Now line */}
<Box sx={{ position: 'relative' }}>
{nowPosition >= 0 && (
<Box
sx={{
position: 'absolute',
left: nowPosition,
top: 0,
bottom: 0,
width: '2px',
backgroundColor: '#38b2ac',
zIndex: 15,
}}
/>
)}
{/* Channel rows */}
{guideChannels.map((channel) => {
const channelPrograms = programs.filter(
(p) => p.tvg_id === channel.tvg_id
);
return (
<Box
key={channel.channel_name}
sx={{
display: 'flex',
position: 'relative',
minHeight: PROGRAM_HEIGHT,
borderBottom: '1px solid #4a5568',
}}
>
<Box sx={{ flex: 1, position: 'relative' }}>
{channelPrograms.map((prog) => renderProgram(prog, start))}
</Box>
</Box>
);
})}
</Box>
</Box>
</Stack>
{/* Modal for program details */}
<Dialog
open={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',
},
}}
>
{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' }}
>
Watch Now
</Button>
)}
<Button onClick={handleCloseModal} sx={{ color: '#38b2ac' }}>
Close
</Button>
</DialogActions>
</>
)}
</Dialog>
</Box>
);
}

View file

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

View file

@ -1,14 +0,0 @@
// src/components/Home.js
import React, { useState } from "react";
const Home = () => {
const [newChannel, setNewChannel] = useState("");
return (
<div>
<h1>Home Page</h1>
</div>
);
};
export default Home;

View file

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

View file

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

View file

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

View file

@ -13,11 +13,12 @@ const M3UPage = () => {
return (
<Box
sx={{
style={{
display: 'flex',
flexDirection: 'column',
height: '100vh',
overflow: 'hidden',
padding: 16,
}}
>
<Box sx={{ flex: '1 1 50%', overflow: 'hidden' }}>

View file

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

View file

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

View file

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

View file

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

View file

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

Some files were not shown because too many files have changed in this diff Show more