full mantine refactor
2
.gitignore
vendored
|
|
@ -8,3 +8,5 @@ node_modules/
|
|||
staticfiles/
|
||||
static/
|
||||
data/
|
||||
.next
|
||||
next-env.d.ts
|
||||
|
|
|
|||
|
|
@ -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
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -15,3 +15,5 @@ fi
|
|||
|
||||
# Install frontend dependencies
|
||||
cd /app/frontend && npm install
|
||||
|
||||
cd /app && pip install -r requirements.txt
|
||||
|
|
|
|||
41
frontend/.gitignore
vendored
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
#!/bin/sh
|
||||
|
||||
apk add nodejs npm
|
||||
cd /app/
|
||||
npm i
|
||||
PORT=9191 npm run start
|
||||
|
|
@ -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
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 778 B |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
|
@ -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>
|
||||
|
Before Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 9.4 KiB |
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
|
|
@ -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"}
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 4 KiB After Width: | Height: | Size: 4 KiB |
|
|
@ -1,26 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Snackbar, Alert, Button } from '@mui/material';
|
||||
import useAlertStore from '../store/alerts';
|
||||
|
||||
const AlertPopup = () => {
|
||||
const { open, message, severity, hideAlert } = useAlertStore();
|
||||
|
||||
const handleClose = () => {
|
||||
hideAlert();
|
||||
};
|
||||
|
||||
return (
|
||||
<Snackbar
|
||||
open={open}
|
||||
autoHideDuration={5000}
|
||||
onClose={handleClose}
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
>
|
||||
<Alert onClose={handleClose} severity={severity} sx={{ width: '100%' }}>
|
||||
{message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertPopup;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
416
frontend/src/components/forms/Channel.jsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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({
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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"
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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;
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
// frontend/src/components/forms/SuperuserForm.js
|
||||
import React, { useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
Grid2,
|
||||
TextField,
|
||||
Button,
|
||||
} from '@mui/material';
|
||||
|
||||
function SuperuserForm({ onSuccess }) {
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
password: '',
|
||||
email: '',
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleChange = (e) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[e.target.name]: e.target.value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const res = await axios.post('/api/accounts/initialize-superuser/', {
|
||||
username: formData.username,
|
||||
password: formData.password,
|
||||
email: formData.email,
|
||||
});
|
||||
if (res.data.superuser_exists) {
|
||||
onSuccess();
|
||||
}
|
||||
} catch (err) {
|
||||
let msg = 'Failed to create superuser.';
|
||||
if (err.response && err.response.data && err.response.data.error) {
|
||||
msg += ` ${err.response.data.error}`;
|
||||
}
|
||||
setError(msg);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
backgroundColor: '#f5f5f5',
|
||||
}}
|
||||
>
|
||||
<Paper elevation={3} sx={{ padding: 3, width: '100%', maxWidth: 400 }}>
|
||||
<Typography variant="h5" align="center" gutterBottom>
|
||||
Create your Super User Account
|
||||
</Typography>
|
||||
{error && (
|
||||
<Typography variant="body2" color="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Grid2
|
||||
container
|
||||
spacing={2}
|
||||
justifyContent="center"
|
||||
direction="column"
|
||||
>
|
||||
<Grid2 xs={12}>
|
||||
<TextField
|
||||
label="Username"
|
||||
variant="standard"
|
||||
fullWidth
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleChange}
|
||||
required
|
||||
size="small"
|
||||
/>
|
||||
</Grid2>
|
||||
<Grid2 xs={12}>
|
||||
<TextField
|
||||
label="Password"
|
||||
variant="standard"
|
||||
type="password"
|
||||
fullWidth
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
required
|
||||
size="small"
|
||||
/>
|
||||
</Grid2>
|
||||
<Grid2 xs={12}>
|
||||
<TextField
|
||||
label="Email (optional)"
|
||||
variant="standard"
|
||||
type="email"
|
||||
fullWidth
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
size="small"
|
||||
/>
|
||||
</Grid2>
|
||||
<Grid2 xs={12}>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
fullWidth
|
||||
>
|
||||
Create Superuser
|
||||
</Button>
|
||||
</Grid2>
|
||||
</Grid2>
|
||||
</form>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default SuperuserForm;
|
||||
94
frontend/src/components/forms/SuperuserForm.jsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
// frontend/src/components/forms/SuperuserForm.js
|
||||
import React, { useState } from 'react';
|
||||
import { TextInput, Center, Button, Paper, Title, Stack } from '@mantine/core';
|
||||
import API from '../../api';
|
||||
import useAuthStore from '../../store/auth';
|
||||
|
||||
function SuperuserForm({}) {
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
password: '',
|
||||
email: '',
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
const { setSuperuserExists } = useAuthStore();
|
||||
|
||||
const handleChange = (e) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[e.target.name]: e.target.value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
console.log(formData);
|
||||
const response = await API.createSuperUser({
|
||||
username: formData.username,
|
||||
password: formData.password,
|
||||
email: formData.email,
|
||||
});
|
||||
if (response.superuser_exists) {
|
||||
setSuperuserExists(true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
// let msg = 'Failed to create superuser.';
|
||||
// if (err.response && err.response.data && err.response.data.error) {
|
||||
// msg += ` ${err.response.data.error}`;
|
||||
// }
|
||||
// setError(msg);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Center
|
||||
style={{
|
||||
height: '100vh',
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
elevation={3}
|
||||
style={{ padding: 30, width: '100%', maxWidth: 400 }}
|
||||
>
|
||||
<Title order={4} align="center">
|
||||
Create your Super User Account
|
||||
</Title>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack>
|
||||
<TextInput
|
||||
label="Username"
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
label="Password"
|
||||
type="password"
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Email (optional)"
|
||||
type="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
|
||||
<Button type="submit" size="sm" sx={{ pt: 1 }}>
|
||||
Submit
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
export default SuperuserForm;
|
||||
|
|
@ -1,144 +0,0 @@
|
|||
// Modal.js
|
||||
import React, { useEffect } from 'react';
|
||||
import {
|
||||
TextField,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
} from '@mui/material';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import API from '../../api';
|
||||
import useSettingsStore from '../../store/settings';
|
||||
|
||||
const UserAgent = ({ userAgent = null, isOpen, onClose }) => {
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
user_agent_name: '',
|
||||
user_agent: '',
|
||||
description: '',
|
||||
is_active: true,
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
user_agent_name: Yup.string().required('Name is required'),
|
||||
user_agent: Yup.string().required('User-Agent is required'),
|
||||
}),
|
||||
onSubmit: async (values, { setSubmitting, resetForm }) => {
|
||||
if (userAgent?.id) {
|
||||
await API.updateUserAgent({ id: userAgent.id, ...values });
|
||||
} else {
|
||||
await API.addUserAgent(values);
|
||||
}
|
||||
|
||||
resetForm();
|
||||
setSubmitting(false);
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (userAgent) {
|
||||
formik.setValues({
|
||||
user_agent_name: userAgent.user_agent_name,
|
||||
user_agent: userAgent.user_agent,
|
||||
description: userAgent.description,
|
||||
is_active: userAgent.is_active,
|
||||
});
|
||||
} else {
|
||||
formik.resetForm();
|
||||
}
|
||||
}, [userAgent]);
|
||||
|
||||
if (!isOpen) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onClose={onClose}>
|
||||
<DialogTitle
|
||||
sx={{
|
||||
backgroundColor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
}}
|
||||
>
|
||||
User-Agent
|
||||
</DialogTitle>
|
||||
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
fullWidth
|
||||
id="user_agent_name"
|
||||
name="user_agent_name"
|
||||
label="Name"
|
||||
value={formik.values.user_agent_name}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
error={
|
||||
formik.touched.user_agent_name &&
|
||||
Boolean(formik.errors.user_agent_name)
|
||||
}
|
||||
helperText={
|
||||
formik.touched.user_agent_name && formik.errors.user_agent_name
|
||||
}
|
||||
variant="standard"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
id="user_agent"
|
||||
name="user_agent"
|
||||
label="User-Agent"
|
||||
value={formik.values.user_agent}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
error={
|
||||
formik.touched.user_agent && Boolean(formik.errors.user_agent)
|
||||
}
|
||||
helperText={formik.touched.user_agent && formik.errors.user_agent}
|
||||
variant="standard"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
id="description"
|
||||
name="description"
|
||||
label="Description"
|
||||
value={formik.values.description}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
error={
|
||||
formik.touched.description && Boolean(formik.errors.description)
|
||||
}
|
||||
helperText={formik.touched.description && formik.errors.description}
|
||||
variant="standard"
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
name="is_active"
|
||||
checked={formik.values.is_active}
|
||||
onChange={formik.handleChange}
|
||||
/>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button
|
||||
size="small"
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={formik.isSubmitting}
|
||||
>
|
||||
{formik.isSubmitting ? <CircularProgress size={24} /> : 'Submit'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserAgent;
|
||||
41
frontend/src/components/sidebar.css
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
.mantine-Stack-root .navlink {
|
||||
display: flex;
|
||||
flex-direction: row; /* Ensures horizontal layout */
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 5px 8px !important;
|
||||
border-radius: 6px;
|
||||
color: #D4D4D8; /* Default color when not active */
|
||||
background-color: transparent; /* Default background when not active */
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* Active state styles */
|
||||
.navlink.navlink-active {
|
||||
color: #FFFFFF;
|
||||
background-color: #245043;
|
||||
border: 1px solid #3BA882;
|
||||
}
|
||||
|
||||
/* Hover effect */
|
||||
.navlink:hover {
|
||||
background-color: #2A2F34; /* Gray hover effect when not active */
|
||||
border: 1px solid #3D3D42;
|
||||
}
|
||||
|
||||
/* Hover effect for active state */
|
||||
.navlink.navlink-active:hover {
|
||||
background-color: #3A3A40;
|
||||
border: 1px solid #3BA882;
|
||||
}
|
||||
|
||||
/* Collapse condition for justifyContent */
|
||||
.navlink.navlink-collapsed {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.navlink:not(.navlink-collapsed) {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
|
@ -1,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
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
|
|
@ -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}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
|
|
@ -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}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
import table from "./table";
|
||||
|
||||
export const TableHelper = table;
|
||||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
15
frontend/src/pages/Channels-test.jsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import React from 'react';
|
||||
import { Allotment } from 'allotment';
|
||||
import { Box, Container } from '@mantine/core';
|
||||
import 'allotment/dist/style.css';
|
||||
|
||||
const ChannelsPage = () => {
|
||||
return (
|
||||
<Allotment>
|
||||
<div>Pane 1</div>
|
||||
<div>Pane 1</div>
|
||||
</Allotment>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelsPage;
|
||||
|
|
@ -1,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;
|
||||
|
|
@ -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,
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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' }}>
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,26 +1,23 @@
|
|||
// frontend/src/pages/Guide.js
|
||||
import React, { useMemo, useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
Stack,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Slide,
|
||||
CircularProgress,
|
||||
Backdrop,
|
||||
} from '@mui/material';
|
||||
import dayjs from 'dayjs';
|
||||
import API from '../api';
|
||||
import useChannelsStore from '../store/channels';
|
||||
import logo from '../images/logo.png';
|
||||
import useVideoStore from '../store/useVideoStore'; // NEW import
|
||||
import useAlertStore from '../store/alerts';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import useSettingsStore from '../store/settings';
|
||||
import {
|
||||
Title,
|
||||
Box,
|
||||
Modal,
|
||||
Flex,
|
||||
Button,
|
||||
Text,
|
||||
Paper,
|
||||
Grid,
|
||||
} from '@mantine/core';
|
||||
import './guide.css';
|
||||
|
||||
/** Layout constants */
|
||||
const CHANNEL_WIDTH = 120; // Width of the channel/logo column
|
||||
|
|
@ -33,11 +30,6 @@ const MINUTE_BLOCK_WIDTH = HOUR_WIDTH / (60 / MINUTE_INCREMENT);
|
|||
const MODAL_WIDTH = 600;
|
||||
const MODAL_HEIGHT = 400;
|
||||
|
||||
// Slide transition for Dialog
|
||||
const Transition = React.forwardRef(function Transition(props, ref) {
|
||||
return <Slide direction="up" ref={ref} {...props} />;
|
||||
});
|
||||
|
||||
export default function TVChannelGuide({ startDate, endDate }) {
|
||||
const { channels } = useChannelsStore();
|
||||
|
||||
|
|
@ -46,7 +38,6 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
const [now, setNow] = useState(dayjs());
|
||||
const [selectedProgram, setSelectedProgram] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { showAlert } = useAlertStore();
|
||||
const {
|
||||
environment: { env_mode },
|
||||
} = useSettingsStore();
|
||||
|
|
@ -55,9 +46,9 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
|
||||
// Load program data once
|
||||
useEffect(() => {
|
||||
if (!channels || channels.length === 0) {
|
||||
if (!Object.keys(channels).length === 0) {
|
||||
console.warn('No channels provided or empty channels array');
|
||||
showAlert('No channels available', 'error');
|
||||
notifications.show({ title: 'No channels available', color: 'red.5' });
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
|
@ -71,7 +62,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
const programIds = [...new Set(fetched.map((p) => p.tvg_id))];
|
||||
|
||||
// Filter your Redux/Zustand channels by matching tvg_id
|
||||
const filteredChannels = channels.filter((ch) =>
|
||||
const filteredChannels = Object.values(channels).filter((ch) =>
|
||||
programIds.includes(ch.tvg_id)
|
||||
);
|
||||
console.log(
|
||||
|
|
@ -217,8 +208,9 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
|
||||
return (
|
||||
<Box
|
||||
className="guide-program-container"
|
||||
key={programKey}
|
||||
sx={{
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: leftPx,
|
||||
top: 0,
|
||||
|
|
@ -229,62 +221,45 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
>
|
||||
<Paper
|
||||
elevation={2}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
left: 2,
|
||||
className={`guide-program ${isLive ? 'live' : 'not-live'}`}
|
||||
style={{
|
||||
// position: 'relative',
|
||||
// left: 2,
|
||||
width: widthPx - 4,
|
||||
top: 2,
|
||||
// top: 2,
|
||||
height: PROGRAM_HEIGHT - 4,
|
||||
p: 1,
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
borderRadius: '8px',
|
||||
background: isLive
|
||||
? 'linear-gradient(to right, #1e3a8a, #2c5282)'
|
||||
: 'linear-gradient(to right, #2d3748, #2d3748)',
|
||||
color: '#fff',
|
||||
transition: 'background 0.3s ease',
|
||||
'&:hover': {
|
||||
background: isLive
|
||||
? 'linear-gradient(to right, #1e3a8a, #2a4365)'
|
||||
: 'linear-gradient(to right, #2d3748, #1a202c)',
|
||||
},
|
||||
// padding: 10,
|
||||
// overflow: 'hidden',
|
||||
// whiteSpace: 'nowrap',
|
||||
// textOverflow: 'ellipsis',
|
||||
// borderRadius: '8px',
|
||||
// background: isLive
|
||||
// ? 'linear-gradient(to right, #1e3a8a, #2c5282)'
|
||||
// : 'linear-gradient(to right, #2d3748, #2d3748)',
|
||||
// color: '#fff',
|
||||
// transition: 'background 0.3s ease',
|
||||
// '&:hover': {
|
||||
// background: isLive
|
||||
// ? 'linear-gradient(to right, #1e3a8a, #2a4365)'
|
||||
// : 'linear-gradient(to right, #2d3748, #1a202c)',
|
||||
// },
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" noWrap sx={{ fontWeight: 'bold' }}>
|
||||
<Text size="md" style={{ fontWeight: 'bold' }}>
|
||||
{program.title}
|
||||
</Typography>
|
||||
<Typography variant="overline" noWrap>
|
||||
</Text>
|
||||
<Text size="sm" noWrap>
|
||||
{programStart.format('h:mma')} - {programEnd.format('h:mma')}
|
||||
</Typography>
|
||||
</Text>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Backdrop
|
||||
sx={{
|
||||
// color: '#fff',
|
||||
zIndex: (theme) => theme.zIndex.drawer + 1,
|
||||
position: 'fixed', // Ensure it covers the entire page
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
open={loading}
|
||||
>
|
||||
<CircularProgress color="inherit" />
|
||||
</Backdrop>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
className="tv-guide"
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
|
|
@ -294,33 +269,27 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
}}
|
||||
>
|
||||
{/* Sticky top bar */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
<Flex
|
||||
justify="space-between"
|
||||
style={{
|
||||
backgroundColor: '#2d3748',
|
||||
color: '#fff',
|
||||
p: 2,
|
||||
padding: 20,
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 999,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
|
||||
<Title order={3} style={{ fontWeight: 'bold' }}>
|
||||
TV Guide
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{now.format('dddd, MMMM D, YYYY • h:mm A')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Title>
|
||||
<Text>{now.format('dddd, MMMM D, YYYY • h:mm A')}</Text>
|
||||
</Flex>
|
||||
|
||||
{/* Main layout */}
|
||||
<Stack direction="row">
|
||||
<Grid direction="row" style={{ padding: 8 }}>
|
||||
{/* Channel Logos Column */}
|
||||
<Box sx={{ backgroundColor: '#2d3748', color: '#fff' }}>
|
||||
<Box style={{ backgroundColor: '#2d3748', color: '#fff' }}>
|
||||
<Box
|
||||
sx={{
|
||||
style={{
|
||||
width: CHANNEL_WIDTH,
|
||||
height: '40px',
|
||||
borderBottom: '1px solid #4a5568',
|
||||
|
|
@ -329,7 +298,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
{guideChannels.map((channel) => (
|
||||
<Box
|
||||
key={channel.channel_name}
|
||||
sx={{
|
||||
style={{
|
||||
display: 'flex',
|
||||
height: PROGRAM_HEIGHT,
|
||||
alignItems: 'center',
|
||||
|
|
@ -338,7 +307,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
style={{
|
||||
width: CHANNEL_WIDTH,
|
||||
display: 'flex',
|
||||
p: 1,
|
||||
|
|
@ -364,7 +333,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
{/* Timeline & Program Blocks */}
|
||||
<Box
|
||||
ref={guideRef}
|
||||
sx={{
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowX: 'auto',
|
||||
overflowY: 'auto',
|
||||
|
|
@ -372,7 +341,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
>
|
||||
{/* Sticky timeline header */}
|
||||
<Box
|
||||
sx={{
|
||||
style={{
|
||||
display: 'flex',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
|
|
@ -381,11 +350,11 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
borderBottom: '1px solid #4a5568',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1, display: 'flex' }}>
|
||||
<Box style={{ flex: 1, display: 'flex' }}>
|
||||
{hourTimeline.map((time, hourIndex) => (
|
||||
<Box
|
||||
key={time.format()}
|
||||
sx={{
|
||||
style={{
|
||||
width: HOUR_WIDTH,
|
||||
height: '40px',
|
||||
position: 'relative',
|
||||
|
|
@ -393,9 +362,9 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
borderRight: '1px solid #4a5568',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
<Text
|
||||
size="sm"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: hourIndex === 0 ? 4 : 'calc(50% - 16px)',
|
||||
|
|
@ -403,9 +372,9 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
}}
|
||||
>
|
||||
{time.format('h:mma')}
|
||||
</Typography>
|
||||
</Text>
|
||||
<Box
|
||||
sx={{
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
top: 0,
|
||||
|
|
@ -418,7 +387,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
{[0, 1, 2, 3].map((i) => (
|
||||
<Box
|
||||
key={i}
|
||||
sx={{
|
||||
style={{
|
||||
width: '1px',
|
||||
height: '10px',
|
||||
backgroundColor: '#718096',
|
||||
|
|
@ -433,10 +402,10 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
</Box>
|
||||
|
||||
{/* Now line */}
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
<Box style={{ position: 'relative' }}>
|
||||
{nowPosition >= 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: nowPosition,
|
||||
top: 0,
|
||||
|
|
@ -456,14 +425,14 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
return (
|
||||
<Box
|
||||
key={channel.channel_name}
|
||||
sx={{
|
||||
style={{
|
||||
display: 'flex',
|
||||
position: 'relative',
|
||||
minHeight: PROGRAM_HEIGHT,
|
||||
borderBottom: '1px solid #4a5568',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1, position: 'relative' }}>
|
||||
<Box style={{ flex: 1, position: 'relative' }}>
|
||||
{channelPrograms.map((prog) => renderProgram(prog, start))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
@ -471,62 +440,36 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Grid>
|
||||
|
||||
{/* Modal for program details */}
|
||||
<Dialog
|
||||
open={Boolean(selectedProgram)}
|
||||
<Modal
|
||||
title={selectedProgram ? selectedProgram.title : ''}
|
||||
opened={Boolean(selectedProgram)}
|
||||
onClose={handleCloseModal}
|
||||
TransitionComponent={Transition}
|
||||
keepMounted
|
||||
PaperProps={{
|
||||
sx: {
|
||||
width: MODAL_WIDTH,
|
||||
height: MODAL_HEIGHT,
|
||||
m: 'auto',
|
||||
backgroundColor: '#1a202c',
|
||||
border: '2px solid #718096',
|
||||
},
|
||||
}}
|
||||
sx={{
|
||||
'& .MuiDialog-container': {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
}}
|
||||
yOffset="25vh"
|
||||
>
|
||||
{selectedProgram && (
|
||||
<>
|
||||
<DialogTitle sx={{ color: '#fff' }}>
|
||||
{selectedProgram.title}
|
||||
</DialogTitle>
|
||||
<DialogContent sx={{ color: '#a0aec0' }}>
|
||||
<Typography variant="caption" display="block">
|
||||
{dayjs(selectedProgram.start_time).format('h:mma')} -{' '}
|
||||
{dayjs(selectedProgram.end_time).format('h:mma')}
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ mt: 2, color: '#fff' }}>
|
||||
{selectedProgram.description || 'No description available.'}
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
{/* Only show the Watch button if currently live */}
|
||||
{now.isAfter(dayjs(selectedProgram.start_time)) &&
|
||||
now.isBefore(dayjs(selectedProgram.end_time)) && (
|
||||
<Button
|
||||
onClick={() => handleWatchStream(selectedProgram)}
|
||||
sx={{ color: '#38b2ac' }}
|
||||
>
|
||||
<Text size="sm">
|
||||
{dayjs(selectedProgram.start_time).format('h:mma')} -{' '}
|
||||
{dayjs(selectedProgram.end_time).format('h:mma')}
|
||||
</Text>
|
||||
<Text style={{ mt: 2, color: '#fff' }}>
|
||||
{selectedProgram.description || 'No description available.'}
|
||||
</Text>
|
||||
{/* Only show the Watch button if currently live */}
|
||||
{now.isAfter(dayjs(selectedProgram.start_time)) &&
|
||||
now.isBefore(dayjs(selectedProgram.end_time)) && (
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
<Button onClick={() => handleWatchStream(selectedProgram)}>
|
||||
Watch Now
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleCloseModal} sx={{ color: '#38b2ac' }}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Flex>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,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;
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
import React from "react";
|
||||
import LoginForm from "../components/forms/LoginForm";
|
||||
|
||||
const Login = () => {
|
||||
return <LoginForm />;
|
||||
};
|
||||
|
||||
export default Login;
|
||||
16
frontend/src/pages/Login.jsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import React from 'react';
|
||||
import LoginForm from '../components/forms/LoginForm';
|
||||
import SuperuserForm from '../components/forms/SuperuserForm';
|
||||
import useAuthStore from '../store/auth';
|
||||
|
||||
const Login = ({}) => {
|
||||
const { superuserExists } = useAuthStore();
|
||||
|
||||
if (!superuserExists) {
|
||||
return <SuperuserForm />;
|
||||
}
|
||||
|
||||
return <LoginForm />;
|
||||
};
|
||||
|
||||
export default Login;
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
import React, { useState } from "react";
|
||||
import useUserAgentsStore from "../store/userAgents";
|
||||
import { Box } from "@mui/material";
|
||||
import M3UsTable from "../components/tables/M3UsTable";
|
||||
import UserAgentsTable from "../components/tables/UserAgentsTable";
|
||||
import usePlaylistsStore from "../store/playlists";
|
||||
import API from "../api";
|
||||
import M3UForm from "../components/forms/M3U";
|
||||
|
||||
const M3UPage = () => {
|
||||
const isLoading = useUserAgentsStore((state) => state.isLoading);
|
||||
const error = useUserAgentsStore((state) => state.error);
|
||||
const playlists = usePlaylistsStore((state) => state.playlists);
|
||||
|
||||
const [playlist, setPlaylist] = useState(null);
|
||||
const [playlistModalOpen, setPlaylistModalOpen] = useState(false);
|
||||
|
||||
const [userAgent, setUserAgent] = useState(null);
|
||||
const [userAgentModalOpen, setUserAgentModalOpen] = useState(false);
|
||||
|
||||
const editUserAgent = async (userAgent = null) => {
|
||||
setUserAgent(userAgent);
|
||||
setUserAgentModalOpen(true);
|
||||
};
|
||||
|
||||
const editPlaylist = async (playlist = null) => {
|
||||
setPlaylist(playlist);
|
||||
setPlaylistModalOpen(true);
|
||||
};
|
||||
|
||||
const deleteUserAgent = async (ids) => {
|
||||
if (Array.isArray(ids)) {
|
||||
await API.deleteUserAgents(ids);
|
||||
} else {
|
||||
await API.deleteUserAgent(ids);
|
||||
}
|
||||
};
|
||||
|
||||
const deletePlaylist = async (id) => {
|
||||
await API.deletePlaylist(id);
|
||||
};
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
if (error) return <div>Error: {error}</div>;
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100vh",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: "1 1 50%", overflow: "hidden" }}>
|
||||
<M3UsTable />
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flex: "1 1 50%", overflow: "hidden" }}>
|
||||
<UserAgentsTable />
|
||||
</Box>
|
||||
|
||||
<M3UForm
|
||||
playlist={playlist}
|
||||
isOpen={playlistModalOpen}
|
||||
onClose={() => setPlaylistModalOpen(false)}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default M3UPage;
|
||||
|
|
@ -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' }}>
|
||||
|
|
@ -1,197 +0,0 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import {
|
||||
Grid as Grid2,
|
||||
Box,
|
||||
Container,
|
||||
Typography,
|
||||
FormControl,
|
||||
Select,
|
||||
MenuItem,
|
||||
CircularProgress,
|
||||
InputLabel,
|
||||
Button,
|
||||
} from '@mui/material';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
import API from '../api';
|
||||
import useSettingsStore from '../store/settings';
|
||||
import useUserAgentsStore from '../store/userAgents';
|
||||
import useStreamProfilesStore from '../store/streamProfiles';
|
||||
|
||||
const SettingsPage = () => {
|
||||
const { settings } = useSettingsStore();
|
||||
const { userAgents } = useUserAgentsStore();
|
||||
const { profiles: streamProfiles } = useStreamProfilesStore();
|
||||
|
||||
// Add your region choices here:
|
||||
const regionChoices = [
|
||||
{ value: 'us', label: 'US' },
|
||||
{ value: 'uk', label: 'UK' },
|
||||
{ value: 'nl', label: 'NL' },
|
||||
{ value: 'de', label: 'DE' },
|
||||
// Add more if needed
|
||||
];
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
'default-user-agent': '',
|
||||
'default-stream-profile': '',
|
||||
'preferred-region': '',
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
'default-user-agent': Yup.string().required('User-Agent is required'),
|
||||
'default-stream-profile': Yup.string().required(
|
||||
'Stream Profile is required'
|
||||
),
|
||||
// The region is optional or required as you prefer
|
||||
// 'preferred-region': Yup.string().required('Region is required'),
|
||||
}),
|
||||
onSubmit: async (values, { setSubmitting, resetForm }) => {
|
||||
const changedSettings = {};
|
||||
for (const settingKey in values) {
|
||||
// If the user changed the setting’s value from what’s in the DB:
|
||||
if (String(values[settingKey]) !== String(settings[settingKey].value)) {
|
||||
changedSettings[settingKey] = values[settingKey];
|
||||
}
|
||||
}
|
||||
|
||||
// Update each changed setting in the backend
|
||||
for (const updatedKey in changedSettings) {
|
||||
await API.updateSetting({
|
||||
...settings[updatedKey],
|
||||
value: changedSettings[updatedKey],
|
||||
});
|
||||
}
|
||||
|
||||
setSubmitting(false);
|
||||
// Don’t necessarily resetForm, in case the user wants to see new values
|
||||
},
|
||||
});
|
||||
|
||||
// Initialize form values once settings / userAgents / profiles are loaded
|
||||
useEffect(() => {
|
||||
formik.setValues(
|
||||
Object.values(settings).reduce((acc, setting) => {
|
||||
// If the setting’s value is numeric, parse it
|
||||
// Otherwise, just store as string
|
||||
const possibleNumber = parseInt(setting.value, 10);
|
||||
acc[setting.key] = isNaN(possibleNumber)
|
||||
? setting.value
|
||||
: possibleNumber;
|
||||
return acc;
|
||||
}, {})
|
||||
);
|
||||
// eslint-disable-next-line
|
||||
}, [settings, userAgents, streamProfiles]);
|
||||
|
||||
return (
|
||||
<Container maxWidth="md">
|
||||
<Box mt={4}>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Settings
|
||||
</Typography>
|
||||
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<Grid2 container spacing={3}>
|
||||
{/* Default User-Agent */}
|
||||
<Grid2 xs={12}>
|
||||
<FormControl variant="standard" fullWidth>
|
||||
<InputLabel id="user-agent-label">Default User-Agent</InputLabel>
|
||||
<Select
|
||||
labelId="user-agent-label"
|
||||
id={settings['default-user-agent']?.id}
|
||||
name={settings['default-user-agent']?.key}
|
||||
label={settings['default-user-agent']?.name}
|
||||
value={formik.values['default-user-agent'] || ''}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
error={
|
||||
formik.touched['default-user-agent'] &&
|
||||
Boolean(formik.errors['default-user-agent'])
|
||||
}
|
||||
variant="standard"
|
||||
>
|
||||
{userAgents.map((option) => (
|
||||
<MenuItem key={option.id} value={option.id}>
|
||||
{option.user_agent_name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid2>
|
||||
|
||||
{/* Default Stream Profile */}
|
||||
<Grid2 xs={12}>
|
||||
<FormControl variant="standard" fullWidth>
|
||||
<InputLabel id="stream-profile-label">
|
||||
Default Stream Profile
|
||||
</InputLabel>
|
||||
<Select
|
||||
labelId="stream-profile-label"
|
||||
id={settings['default-stream-profile']?.id}
|
||||
name={settings['default-stream-profile']?.key}
|
||||
label={settings['default-stream-profile']?.name}
|
||||
value={formik.values['default-stream-profile'] || ''}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
error={
|
||||
formik.touched['default-stream-profile'] &&
|
||||
Boolean(formik.errors['default-stream-profile'])
|
||||
}
|
||||
variant="standard"
|
||||
>
|
||||
{streamProfiles.map((profile) => (
|
||||
<MenuItem key={profile.id} value={profile.id}>
|
||||
{profile.profile_name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid2>
|
||||
|
||||
{/* Preferred Region */}
|
||||
<Grid2 xs={12}>
|
||||
{/* Only render if you do indeed have "preferred-region" in the DB */}
|
||||
{settings['preferred-region'] && (
|
||||
<FormControl variant="standard" fullWidth>
|
||||
<InputLabel id="region-label">Preferred Region</InputLabel>
|
||||
<Select
|
||||
labelId="region-label"
|
||||
id={settings['preferred-region'].id}
|
||||
name={settings['preferred-region'].key}
|
||||
label={settings['preferred-region'].name}
|
||||
value={formik.values['preferred-region'] || ''}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
variant="standard"
|
||||
>
|
||||
{regionChoices.map((r) => (
|
||||
<MenuItem key={r.value} value={r.value}>
|
||||
{r.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
</Grid2>
|
||||
</Grid2>
|
||||
|
||||
<Box mt={4} display="flex" justifyContent="flex-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={formik.isSubmitting}
|
||||
size="small"
|
||||
>
|
||||
{formik.isSubmitting ? <CircularProgress size={24} /> : 'Submit'}
|
||||
</Button>
|
||||
</Box>
|
||||
</form>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsPage;
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
import React from "react";
|
||||
import StreamProfilesTable from "../components/tables/StreamProfilesTable";
|
||||
|
||||
const StreamProfilesPage = () => {
|
||||
return <StreamProfilesTable />;
|
||||
};
|
||||
|
||||
export default StreamProfilesPage;
|
||||
|
|
@ -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;
|
||||
25
frontend/src/pages/guide.css
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
.tv-guide .guide-program-container .guide-program {
|
||||
position: relative;
|
||||
left: 2px;
|
||||
top: 2px;
|
||||
padding: 10px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(to right, #2d3748, #2d3748); /* Default background */
|
||||
color: #fff;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.tv-guide .guide-program-container .guide-program.live {
|
||||
background: linear-gradient(to right, #1e3a8a, #2c5282);
|
||||
}
|
||||
|
||||
.tv-guide .guide-program-container .guide-program.live:hover {
|
||||
background: linear-gradient(to right, #1e3a8a, #2a4365);
|
||||
}
|
||||
|
||||
.tv-guide .guide-program-container .guide-program.not-live:hover {
|
||||
background: linear-gradient(to right, #2d3748, #1a202c);
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
const reportWebVitals = onPerfEntry => {
|
||||
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||
getCLS(onPerfEntry);
|
||||
getFID(onPerfEntry);
|
||||
getFCP(onPerfEntry);
|
||||
getLCP(onPerfEntry);
|
||||
getTTFB(onPerfEntry);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default reportWebVitals;
|
||||