forked from Mirrors/Dispatcharr
initial commit of react frontend
This commit is contained in:
parent
3a15cf6b7f
commit
0dfa001f3a
51 changed files with 35787 additions and 5 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -4,4 +4,5 @@ __pycache__/
|
|||
.vscode/launch.json
|
||||
.vscode/tasks.json
|
||||
*.pyc
|
||||
|
||||
node_modules/
|
||||
.history/
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .api_views import (
|
||||
AuthViewSet, UserViewSet, GroupViewSet,
|
||||
AuthViewSet, UserViewSet, GroupViewSet,
|
||||
list_permissions
|
||||
)
|
||||
from rest_framework_simplejwt import views as jwt_views
|
||||
|
||||
app_name = 'accounts'
|
||||
|
||||
|
|
@ -29,6 +30,9 @@ urlpatterns = [
|
|||
|
||||
# Permissions API
|
||||
path('permissions/', list_permissions, name='list-permissions'),
|
||||
|
||||
path('token/', jwt_views.TokenObtainPairView.as_view(), name='token_obtain_pair'),
|
||||
path('token/refresh/', jwt_views.TokenRefreshView.as_view(), name='token_refresh'),
|
||||
]
|
||||
|
||||
# 🔹 Include ViewSet routes
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import os
|
||||
from pathlib import Path
|
||||
from datetime import timedelta
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
|
@ -26,6 +27,7 @@ INSTALLED_APPS = [
|
|||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'rest_framework',
|
||||
'corsheaders',
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -38,6 +40,7 @@ MIDDLEWARE = [
|
|||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
'corsheaders.middleware.CorsMiddleware',
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -109,3 +112,20 @@ MEDIA_URL = '/media/'
|
|||
|
||||
|
||||
SERVER_IP = "127.0.0.1"
|
||||
|
||||
CORS_ALLOW_ALL_ORIGINS = True
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
|
||||
if os.getenv('REACT_UI', False):
|
||||
REST_FRAMEWORK = {
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': [
|
||||
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||
],
|
||||
}
|
||||
|
||||
SIMPLE_JWT = {
|
||||
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=30),
|
||||
'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
|
||||
'ROTATE_REFRESH_TOKENS': False, # Optional: Whether to rotate refresh tokens
|
||||
'BLACKLIST_AFTER_ROTATION': True, # Optional: Whether to blacklist refresh tokens
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,16 @@ services:
|
|||
- POSTGRES_PASSWORD=secret
|
||||
- REDIS_HOST=redis
|
||||
- CELERY_BROKER_URL=redis://redis:6379/0
|
||||
- REACT_UI=true
|
||||
|
||||
ui:
|
||||
image: alpine
|
||||
container_name: dispatcharr_ui
|
||||
volumes:
|
||||
- ../frontend:/app
|
||||
entrypoint: ["/bin/sh", "/app/entrypoint.sh"]
|
||||
ports:
|
||||
- 3031:3031
|
||||
|
||||
celery:
|
||||
build:
|
||||
|
|
@ -34,7 +44,7 @@ services:
|
|||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
environment:
|
||||
- POSTGRES_HOST=localhost
|
||||
- POSTGRES_HOST=dispatcharr_db
|
||||
- POSTGRES_DB=dispatcharr
|
||||
- POSTGRES_USER=dispatch
|
||||
- POSTGRES_PASSWORD=secret
|
||||
|
|
@ -55,8 +65,8 @@ services:
|
|||
- POSTGRES_DB=dispatcharr
|
||||
- POSTGRES_USER=dispatch
|
||||
- POSTGRES_PASSWORD=secret
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
# volumes:
|
||||
# - postgres_data:/var/lib/postgresql/data
|
||||
|
||||
redis:
|
||||
image: redis:latest
|
||||
|
|
|
|||
23
frontend/.gitignore
vendored
Normal file
23
frontend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# 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
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
70
frontend/README.md
Normal file
70
frontend/README.md
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# Getting Started with Create React App
|
||||
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
|
||||
## Available Scripts
|
||||
|
||||
In the project directory, you can run:
|
||||
|
||||
### `npm start`
|
||||
|
||||
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)
|
||||
6
frontend/entrypoint.sh
Normal file
6
frontend/entrypoint.sh
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
#!/bin/sh
|
||||
|
||||
apk add nodejs npm
|
||||
cd /app/
|
||||
npm i
|
||||
PORT=3031 npm run start
|
||||
17097
frontend/package-lock.json
generated
Normal file
17097
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
54
frontend/package.json
Normal file
54
frontend/package.json
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
{
|
||||
"name": "frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"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",
|
||||
"@reduxjs/toolkit": "^2.5.1",
|
||||
"@tanstack/react-table": "^8.21.2",
|
||||
"@tanstack/react-virtual": "^3.13.0",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"axios": "^1.7.9",
|
||||
"formik": "^2.4.6",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router-dom": "^7.2.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"react-virtualized": "^9.22.6",
|
||||
"web-vitals": "^2.1.4",
|
||||
"yup": "^1.6.1",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
15379
frontend/public/css/adminlte.css
Normal file
15379
frontend/public/css/adminlte.css
Normal file
File diff suppressed because it is too large
Load diff
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
BIN
frontend/public/images/logo.png
Normal file
BIN
frontend/public/images/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
58
frontend/public/index.html
Normal file
58
frontend/public/index.html
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Web site created using create-react-app"
|
||||
/>
|
||||
<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>React App</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>
|
||||
BIN
frontend/public/logo192.png
Normal file
BIN
frontend/public/logo192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
BIN
frontend/public/logo512.png
Normal file
BIN
frontend/public/logo512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
25
frontend/public/manifest.json
Normal file
25
frontend/public/manifest.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
3
frontend/public/robots.txt
Normal file
3
frontend/public/robots.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
38
frontend/src/App.css
Normal file
38
frontend/src/App.css
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
.App {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.App-logo {
|
||||
height: 40vmin;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.App-logo {
|
||||
animation: App-logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.App-header {
|
||||
background-color: #282c34;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: calc(10px + 2vmin);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.App-link {
|
||||
color: #61dafb;
|
||||
}
|
||||
|
||||
@keyframes App-logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
131
frontend/src/App.js
Normal file
131
frontend/src/App.js
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
// src/App.js
|
||||
import React, { useState } from 'react';
|
||||
import { BrowserRouter as Router, Route, Routes, Navigate, Link } from 'react-router-dom';
|
||||
import HeaderBar from './components/HeaderBar';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import Home from './pages/Home';
|
||||
import Login from './pages/Login';
|
||||
import useAuthStore from './store/auth';
|
||||
import Channels from './pages/Channels';
|
||||
import M3U from './pages/M3U';
|
||||
import { createTheme, ThemeProvider } from '@mui/material/styles'; // Import theme tools
|
||||
import {
|
||||
Box,
|
||||
CssBaseline,
|
||||
AppBar,
|
||||
Toolbar,
|
||||
Typography,
|
||||
Drawer,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Divider,
|
||||
} from '@mui/material';
|
||||
import theme from './theme'
|
||||
|
||||
import {
|
||||
Menu as MenuIcon,
|
||||
Home as HomeIcon,
|
||||
Settings as SettingsIcon,
|
||||
Info as InfoIcon,
|
||||
Description as DescriptionIcon,
|
||||
Tv as TvIcon,
|
||||
CalendarMonth as CalendarMonthIcon,
|
||||
} from '@mui/icons-material';
|
||||
import EPG from './pages/EPG';
|
||||
|
||||
const drawerWidth = 240;
|
||||
const miniDrawerWidth = 60;
|
||||
|
||||
const items = [
|
||||
{ text: 'Channels', icon: <TvIcon />, route: "/channels" },
|
||||
{ text: 'M3U', icon: <DescriptionIcon />, route: "/m3u" },
|
||||
{ text: 'EPG', icon: <CalendarMonthIcon />, route: "/epg" },
|
||||
];
|
||||
|
||||
// Protected Route Component
|
||||
const ProtectedRoute = ({ element, ...rest }) => {
|
||||
const { isAuthenticated } = useAuthStore();
|
||||
|
||||
return isAuthenticated ? element : <Navigate to="/login" />;
|
||||
};
|
||||
|
||||
const App = () => {
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
const toggleDrawer = () => {
|
||||
setOpen(!open);
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<Router>
|
||||
<Drawer
|
||||
variant="permanent"
|
||||
open={open}
|
||||
sx={{
|
||||
width: open ? drawerWidth : miniDrawerWidth,
|
||||
flexShrink: 0,
|
||||
'& .MuiDrawer-paper': {
|
||||
width: open ? drawerWidth : miniDrawerWidth,
|
||||
transition: 'width 0.3s',
|
||||
overflowX: 'hidden',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Drawer Toggle Button */}
|
||||
<List>
|
||||
<ListItem disablePadding>
|
||||
<ListItemButton onClick={toggleDrawer}>
|
||||
<img src="/images/logo.png" width="35x" />
|
||||
{open && <ListItemText primary="Dispatcharr" sx={{paddingLeft: 3}}/>}
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
</List>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Drawer Navigation Items */}
|
||||
<List>
|
||||
{items.map((item) => (
|
||||
<ListItem key={item.text} disablePadding>
|
||||
<ListItemButton component={Link} to={item.route}>
|
||||
<ListItemIcon>{item.icon}</ListItemIcon>
|
||||
{open && <ListItemText primary={item.text} />}
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Drawer>
|
||||
<Box sx={{
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
ml: `${open ? drawerWidth : miniDrawerWidth}px`,
|
||||
transition: 'width 0.3s, margin-left 0.3s',
|
||||
}}>
|
||||
{/* Fixed Header */}
|
||||
<Box sx={{ height: '67px', backgroundColor: '#495057', color: '#fff', display: 'flex', alignItems: 'center', padding: '0 16px' }}>
|
||||
|
||||
</Box>
|
||||
|
||||
{/* Main Content Area between Header and Footer */}
|
||||
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route exact path="/channels" element={<ProtectedRoute element={<Channels />}/>} />
|
||||
<Route exact path="/m3u" element={<ProtectedRoute element={<M3U />}/>} />
|
||||
<Route exact path="/epg" element={<ProtectedRoute element={<EPG />}/>} />
|
||||
</Routes>
|
||||
</Box>
|
||||
</Box>
|
||||
</Router>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
8
frontend/src/App.test.js
Normal file
8
frontend/src/App.test.js
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
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();
|
||||
});
|
||||
349
frontend/src/api.js
Normal file
349
frontend/src/api.js
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
import Axios from 'axios'
|
||||
import useAuthStore from './store/auth';
|
||||
import useChannelsStore from './store/channels';
|
||||
import useUserAgentsStore from './store/userAgents';
|
||||
import usePlaylistsStore from './store/playlists';
|
||||
import useEPGsStore from './store/epgs';
|
||||
|
||||
const axios = Axios.create({
|
||||
withCredentials: true,
|
||||
})
|
||||
|
||||
const host = "http://192.168.1.151:9191"
|
||||
|
||||
const getAuthToken = async () => {
|
||||
const token = await useAuthStore.getState().getToken(); // Assuming token is stored in Zustand store
|
||||
return token;
|
||||
};
|
||||
|
||||
export default class API {
|
||||
static async login(username, password) {
|
||||
const response = await fetch(`${host}/api/accounts/token/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
static async refreshToken(refreshToken) {
|
||||
const response = await fetch(`${host}/api/accounts/token/refresh/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refresh: refreshToken })
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async logout() {
|
||||
const response = await fetch(`${host}/api/accounts/auth/logout/`, {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
return response.data.data
|
||||
}
|
||||
|
||||
static async getChannels() {
|
||||
const response = await fetch(`${host}/api/channels/channels/`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${await getAuthToken()}`,
|
||||
},
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async getChannelGroups() {
|
||||
const response = await fetch(`${host}/api/channels/groups/`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${await getAuthToken()}`,
|
||||
},
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async addChannel(channel) {
|
||||
let body = null
|
||||
if (channel.logo_file) {
|
||||
body = new FormData();
|
||||
for (const prop in channel) {
|
||||
body.append(prop, channel[prop])
|
||||
}
|
||||
} else {
|
||||
body = {...channel}
|
||||
delete body.logo_file
|
||||
body = JSON.stringify(body)
|
||||
}
|
||||
|
||||
const response = await fetch(`${host}/api/channels/channels/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await getAuthToken()}`,
|
||||
...(channel.logo_file ? {} :{
|
||||
'Content-Type': 'application/json',
|
||||
})
|
||||
},
|
||||
body: body,
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
if (retval.id) {
|
||||
useChannelsStore.getState().addChannel(retval)
|
||||
}
|
||||
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async deleteChannel(id) {
|
||||
const response = await fetch(`${host}/api/channels/channels/${id}/`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
useChannelsStore.getState().removeChannels([id])
|
||||
}
|
||||
|
||||
static async deleteChannels(channel_ids) {
|
||||
const response = await fetch(`${host}/api/channels/bulk-delete-channels/`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ channel_ids }),
|
||||
});
|
||||
|
||||
useChannelsStore.getState().removeChannels(channel_ids)
|
||||
}
|
||||
|
||||
static async getStreams() {
|
||||
const response = await fetch(`${host}/api/channels/streams/`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${await getAuthToken()}`,
|
||||
},
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async getUserAgents() {
|
||||
const response = await fetch(`${host}/api/core/useragents/`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${await getAuthToken()}`,
|
||||
},
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async addUserAgent(values) {
|
||||
const response = await fetch(`${host}/api/core/useragents/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
if (retval.id) {
|
||||
useUserAgentsStore.getState().addUserAgent(retval)
|
||||
}
|
||||
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async updateUserAgent(values) {
|
||||
console.log(values)
|
||||
const {id, ...payload} = values
|
||||
const response = await fetch(`${host}/api/core/useragents/${id}/`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
if (retval.id) {
|
||||
useUserAgentsStore.getState().updateUserAgent(retval)
|
||||
}
|
||||
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async deleteUserAgent(id) {
|
||||
const response = await fetch(`${host}/api/core/useragents/${id}/`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
useUserAgentsStore.getState().removeUserAgents([id])
|
||||
}
|
||||
|
||||
static async getPlaylists() {
|
||||
const response = await fetch(`${host}/api/m3u/accounts/`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${await getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async addPlaylist(values) {
|
||||
const response = await fetch(`${host}/api/m3u/accounts/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
if (retval.id) {
|
||||
usePlaylistsStore.getState().addPlaylist(retval)
|
||||
}
|
||||
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async deletePlaylist(id) {
|
||||
const response = await fetch(`${host}/api/m3u/accounts/${id}/`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
useUserAgentsStore.getState().removePlaylists([id])
|
||||
}
|
||||
|
||||
static async updatePlaylist(values) {
|
||||
const {id, ...payload} = values
|
||||
const response = await fetch(`${host}/api/m3u/accounts/${id}/`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
if (retval.id) {
|
||||
useUserAgentsStore.getState().updatePlaylist(retval)
|
||||
}
|
||||
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async getEPGs() {
|
||||
const response = await fetch(`${host}/api/epg/sources/`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${await getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async refreshPlaylist(id) {
|
||||
const response = await fetch(`${host}/api/m3u/refresh/${id}/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async addEPG(values) {
|
||||
let body = null
|
||||
if (values.epg_file) {
|
||||
body = new FormData();
|
||||
for (const prop in values) {
|
||||
body.append(prop, values[prop])
|
||||
}
|
||||
} else {
|
||||
body = {...values}
|
||||
delete body.epg_file
|
||||
body = JSON.stringify(body)
|
||||
}
|
||||
|
||||
const response = await fetch(`${host}/api/epg/sources/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await getAuthToken()}`,
|
||||
...(values.epg_file ? {} :{
|
||||
'Content-Type': 'application/json',
|
||||
})
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
if (retval.id) {
|
||||
useEPGsStore.getState().addEPG(retval)
|
||||
}
|
||||
|
||||
return retval;
|
||||
}
|
||||
|
||||
static async deleteEPG(id) {
|
||||
const response = await fetch(`${host}/api/epg/sources/${id}/`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
useEPGsStore.getState().removeEPGs([id])
|
||||
}
|
||||
|
||||
static async refreshEPG(id) {
|
||||
const response = await fetch(`${host}/api/epg/import/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ id }),
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
return retval;
|
||||
}
|
||||
}
|
||||
55
frontend/src/components/HeaderBar.js
Normal file
55
frontend/src/components/HeaderBar.js
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const HeaderBar = () => {
|
||||
return (
|
||||
<div class="container-fluid">
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-lte-toggle="sidebar" href="#" role="button">
|
||||
<i class="bi bi-list"></i>
|
||||
</a>
|
||||
</li>
|
||||
{/* <li class="nav-item d-none d-md-block">
|
||||
<a href="{% url 'dashboard:dashboard' %}" class="nav-link">Home</a>
|
||||
</li> */}
|
||||
</ul>
|
||||
<ul class="navbar-nav ms-auto">
|
||||
<li class="nav-item">
|
||||
<Link to="/login" class="nav-link">Login</Link>
|
||||
</li>
|
||||
|
||||
<li class="nav-item dropdown">
|
||||
<button class="btn btn-link nav-link py-2 px-0 px-lg-2 dropdown-toggle d-flex align-items-center"
|
||||
id="themeToggleBtn" type="button" aria-expanded="false"
|
||||
data-bs-toggle="dropdown" data-bs-display="static">
|
||||
<span class="theme-icon-active"><i class="bi bi-sun-fill my-1"></i></span>
|
||||
<span class="d-lg-none ms-2" id="theme-toggle-text">Toggle theme</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="themeToggleBtn">
|
||||
<li>
|
||||
<button type="button" class="dropdown-item d-flex align-items-center active" data-bs-theme-value="light">
|
||||
<i class="bi bi-sun-fill me-2"></i> Light
|
||||
<i class="bi bi-check-lg ms-auto d-none"></i>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="dark">
|
||||
<i class="bi bi-moon-fill me-2"></i> Dark
|
||||
<i class="bi bi-check-lg ms-auto d-none"></i>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="auto">
|
||||
<i class="bi bi-circle-half me-2"></i> Auto
|
||||
<i class="bi bi-check-lg ms-auto d-none"></i>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeaderBar;
|
||||
54
frontend/src/components/Sidebar.js
Normal file
54
frontend/src/components/Sidebar.js
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import DescriptionIcon from '@mui/icons-material/Description';
|
||||
|
||||
const Sidebar = () => {
|
||||
return (
|
||||
<>
|
||||
<div class="sidebar-brand">
|
||||
<Link to="/daskboard" className="brand-link">
|
||||
<img src="/images/logo.png" alt="Dispatcharr Logo" class="brand-image opacity-75 shadow" />
|
||||
<span class="brand-text fw-light">Dispatcharr</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div class="sidebar-wrapper">
|
||||
<nav class="mt-2">
|
||||
<ul class="nav sidebar-menu flex-column" data-lte-toggle="treeview" role="menu" data-accordion="false">
|
||||
<li class="nav-item">
|
||||
<Link to="/dashboard" className="nav-link">
|
||||
<i class="nav-icon bi bi-speedometer"></i>
|
||||
<p>Dashboard</p>
|
||||
</Link>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<Link to="/channels" className="nav-link">
|
||||
<i class="nav-icon bi bi-tv"></i>
|
||||
<p>Channels</p>
|
||||
</Link>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<Link to="/m3u" className="nav-link">
|
||||
<i class="nav-icon bi bi-file-earmark-text"></i>
|
||||
<p>M3U</p>
|
||||
</Link>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<Link to="/epg" className="nav-link">
|
||||
<i class="nav-icon bi bi-calendar3"></i>
|
||||
<p>EPG</p>
|
||||
</Link>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<Link to="/settings" className="nav-link">
|
||||
<i class="nav-icon bi bi-gear"></i>
|
||||
<p>Settings</p>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
11
frontend/src/components/StreamTableToolbar.js
Normal file
11
frontend/src/components/StreamTableToolbar.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { ButtonGroup, Button } from "@mui/material"
|
||||
|
||||
const StreamTableToolbar = ({ selectedItems }) => {
|
||||
return (
|
||||
<ButtonGroup>
|
||||
{/* <Button size="small">Create Channels</Button> */}
|
||||
</ButtonGroup>
|
||||
)
|
||||
}
|
||||
|
||||
export default StreamTableToolbar;
|
||||
204
frontend/src/components/StreamsTable.js
Normal file
204
frontend/src/components/StreamsTable.js
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
import React, { useMemo, useState } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
getFilteredRowModel,
|
||||
} from '@tanstack/react-table'
|
||||
import useStreamsStore from '../store/streams'
|
||||
import ChannelForm from './forms/Channel'
|
||||
import API from '../api'
|
||||
import { Button, ButtonGroup, Checkbox, Table, TableHead, TableRow, TableCell, TableBody, Box, TextField, IconButton } from '@mui/material'
|
||||
import DeleteIcon from '@mui/icons-material/Delete'
|
||||
import Filter from './tables/Filter';
|
||||
|
||||
// Styles for fixed header and table container
|
||||
const styles = {
|
||||
fixedHeader: {
|
||||
position: "sticky", // Make it sticky
|
||||
top: 0, // Stick to the top
|
||||
backgroundColor: "#fff", // Ensure it has a background
|
||||
zIndex: 10, // Ensure it sits above the table
|
||||
padding: "10px",
|
||||
borderBottom: "2px solid #ccc",
|
||||
},
|
||||
tableContainer: {
|
||||
maxHeight: "400px", // Limit the height for scrolling
|
||||
overflowY: "scroll", // Make it scrollable
|
||||
marginTop: "50px", // Adjust margin to avoid overlap with fixed header
|
||||
},
|
||||
table: {
|
||||
width: "100%",
|
||||
borderCollapse: "collapse",
|
||||
},
|
||||
};
|
||||
|
||||
const StreamsTable = () => {
|
||||
const sterams = useStreamsStore(state => state.streams)
|
||||
const [rowSelection, setRowSelection] = useState({})
|
||||
const [isModalOpen, setIsModalOpen] = useState(false); // State to control modal visibility
|
||||
const [columnFilters, setColumnFilters] = useState([])
|
||||
|
||||
// Define columns with useMemo, this is a stable object and doesn't change unless explicitly modified
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
id: 'select-col',
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={table.getIsAllRowsSelected()}
|
||||
indeterminate={table.getIsSomeRowsSelected()}
|
||||
onChange={table.getToggleAllRowsSelectedHandler()} //or getToggleAllPageRowsSelectedHandler
|
||||
/>
|
||||
),
|
||||
size: 10,
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={row.getIsSelected()}
|
||||
disabled={!row.getCanSelect()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Name',
|
||||
accessorKey: 'name',
|
||||
},
|
||||
{
|
||||
header: 'Group',
|
||||
accessorKey: 'group_name',
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Actions',
|
||||
cell: ({ row }) => {
|
||||
console.log(row)
|
||||
return (
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="error" // Red color for delete actions
|
||||
onClick={() => deleteStream(row.original.id)}
|
||||
>
|
||||
<DeleteIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
},
|
||||
], []);
|
||||
|
||||
const table = useReactTable({
|
||||
data: streams,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
debugTable: true,
|
||||
|
||||
filterFns: {},
|
||||
|
||||
onRowSelectionChange: setRowSelection, //hoist up the row selection state to your own scope
|
||||
state: {
|
||||
rowSelection, //pass the row selection state back to the table instance
|
||||
columnFilters,
|
||||
},
|
||||
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
})
|
||||
|
||||
const deleteStream = async (id) => {
|
||||
// await API.deleteChannel(id)
|
||||
}
|
||||
|
||||
const parentRef = React.useRef(null)
|
||||
|
||||
const { rows } = table.getRowModel()
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 34,
|
||||
overscan: 20,
|
||||
})
|
||||
|
||||
return (
|
||||
<div ref={parentRef}>
|
||||
{/* Fixed header */}
|
||||
<div style={styles.fixedHeader}>
|
||||
<ButtonGroup size="small">
|
||||
<Button size="small" onClick={() => setIsModalOpen(true)} variant="contained">Add Channel</Button>
|
||||
</ButtonGroup>
|
||||
{/* Add more buttons as needed */}
|
||||
</div>
|
||||
<Box sx={{ height: '500px', overflow: 'auto' }} ref={parentRef}>
|
||||
<Table stickyHeader style={{ width: '100%' }} size="">
|
||||
<TableHead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableCell key={header.id} className="text-xs" sx={{
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
}} >
|
||||
{header.column.getCanFilter() ? (
|
||||
<div>
|
||||
<Filter column={header.column} />
|
||||
</div>
|
||||
) : header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{/* Virtualized rows */}
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow, index) => {
|
||||
const row = rows[virtualRow.index];
|
||||
return (
|
||||
<TableRow key={row.original.id} sx={{
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
backgroundColor: index % 2 === 0 ? 'grey.100' : 'white',
|
||||
'&:hover': {
|
||||
backgroundColor: 'grey.200',
|
||||
},
|
||||
}}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
// onClick={() => onClickRow?.(cell, row)}
|
||||
key={cell.id}
|
||||
className="text-xs font-graphik"
|
||||
sx={{
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
}}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
|
||||
<ChannelForm
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StreamsTable;
|
||||
193
frontend/src/components/forms/Channel.js
Normal file
193
frontend/src/components/forms/Channel.js
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
// Modal.js
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Box, Modal, Typography, Stack, TextField, Button, Select, MenuItem, Grid2, InputLabel, FormControl, CircularProgress } from "@mui/material";
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import useChannelsStore from "../../store/channels";
|
||||
import API from "../../api"
|
||||
|
||||
const Channel = ({ channel = null, isOpen, onClose }) => {
|
||||
const channelGroups = useChannelsStore((state) => state.channelGroups);
|
||||
const [logo, setLogo] = useState(null)
|
||||
const [logoPreview, setLogoPreview] = useState(null)
|
||||
|
||||
const handleLogoChange = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
setLogo(file)
|
||||
setLogoPreview(URL.createObjectURL(file));
|
||||
}
|
||||
};
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
channel_name: '',
|
||||
channel_number: '',
|
||||
channel_group_id: '',
|
||||
},
|
||||
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 (channel?.id) {
|
||||
await API.updateChannel({id: channel.id, ...values, logo_file: logo})
|
||||
} else {
|
||||
await API.addChannel({
|
||||
...values,
|
||||
logo_file: logo,
|
||||
})
|
||||
}
|
||||
|
||||
resetForm();
|
||||
setLogo(null)
|
||||
setLogoPreview(null)
|
||||
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,
|
||||
});
|
||||
} else {
|
||||
formik.resetForm();
|
||||
}
|
||||
}, [channel]);
|
||||
|
||||
if (!isOpen) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Box sx={style}>
|
||||
<Typography id="form-modal-title" variant="h6" mb={2}>
|
||||
Channel
|
||||
</Typography>
|
||||
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<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"
|
||||
/>
|
||||
|
||||
<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>
|
||||
</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}>
|
||||
<Box mb={2}>
|
||||
{/* File upload input */}
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Typography>Logo</Typography>
|
||||
{/* Display selected image */}
|
||||
<Box mb={2}>
|
||||
{logo && (
|
||||
<img
|
||||
src={logo}
|
||||
alt="Selected"
|
||||
style={{ maxWidth: 50, height: 'auto' }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<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>
|
||||
</Box>
|
||||
</Grid2>
|
||||
</Grid2>
|
||||
<Box mb={2}>
|
||||
{/* Submit button */}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={formik.isSubmitting}
|
||||
fullWidth
|
||||
>
|
||||
{formik.isSubmitting ? <CircularProgress size={24} /> : 'Submit'}
|
||||
</Button>
|
||||
</Box>
|
||||
</form>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const style = {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: 600,
|
||||
bgcolor: 'background.paper',
|
||||
boxShadow: 24,
|
||||
p: 4,
|
||||
};
|
||||
|
||||
export default Channel;
|
||||
165
frontend/src/components/forms/EPG.js
Normal file
165
frontend/src/components/forms/EPG.js
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
// Modal.js
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Box, Modal, Typography, Stack, TextField, Button, Select, MenuItem, Grid2, InputLabel, FormControl, CircularProgress } 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({
|
||||
});
|
||||
} else {
|
||||
formik.resetForm();
|
||||
}
|
||||
}, [epg]);
|
||||
|
||||
if (!isOpen) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Box sx={style}>
|
||||
<Typography id="form-modal-title" variant="h6" mb={2}>
|
||||
EPG Source
|
||||
</Typography>
|
||||
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<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>
|
||||
|
||||
<Box mb={2}>
|
||||
{/* Submit button */}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={formik.isSubmitting}
|
||||
fullWidth
|
||||
size="small"
|
||||
>
|
||||
{formik.isSubmitting ? <CircularProgress size={24} /> : 'Submit'}
|
||||
</Button>
|
||||
</Box>
|
||||
</form>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const style = {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: 400,
|
||||
bgcolor: 'background.paper',
|
||||
boxShadow: 24,
|
||||
p: 4,
|
||||
};
|
||||
|
||||
export default EPG;
|
||||
98
frontend/src/components/forms/LoginForm.js
Normal file
98
frontend/src/components/forms/LoginForm.js
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom'; // For redirection
|
||||
import useAuthStore from '../../store/auth'
|
||||
import { Box, TextField, Button, Typography, Grid2, Paper } from '@mui/material';
|
||||
|
||||
const LoginForm = () => {
|
||||
const { login, isAuthenticated } = 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)
|
||||
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 item xs={12}>
|
||||
<TextField
|
||||
label="Username"
|
||||
variant="standard"
|
||||
fullWidth
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
size="small"
|
||||
/>
|
||||
</Grid2>
|
||||
<Grid2 item xs={12}>
|
||||
<TextField
|
||||
label="Password"
|
||||
variant="standard"
|
||||
type="password"
|
||||
fullWidth
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
size="small"
|
||||
/>
|
||||
</Grid2>
|
||||
<Grid2 item xs={12}>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
fullWidth
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</Grid2>
|
||||
</Grid2>
|
||||
</form>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginForm;
|
||||
201
frontend/src/components/forms/M3U.js
Normal file
201
frontend/src/components/forms/M3U.js
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
// Modal.js
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Box, Modal, Typography, Stack, TextField, Button, Select, MenuItem, Grid2, InputLabel, FormControl, CircularProgress, FormControlLabel, Checkbox } from "@mui/material";
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import API from "../../api"
|
||||
import usePlaylistsStore from "../../store/playlists";
|
||||
import useUserAgentsStore from "../../store/userAgents";
|
||||
|
||||
const M3U = ({ playlist = null, isOpen, onClose }) => {
|
||||
const userAgents = useUserAgentsStore(state => state.userAgents)
|
||||
const [file, setFile] = useState(null)
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
setFile(file)
|
||||
}
|
||||
};
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
name: '',
|
||||
server_url: '',
|
||||
max_streams: 0,
|
||||
user_agent: '',
|
||||
is_active: true,
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
name: Yup.string().required('Name is required'),
|
||||
user_agent: Yup.string().required('User-Agent is required'),
|
||||
}),
|
||||
onSubmit: async (values, { setSubmitting, resetForm }) => {
|
||||
if (playlist?.id) {
|
||||
await API.updatePlaylist({id: playlist.id, ...values, uploaded_file: file})
|
||||
} else {
|
||||
await API.addChannel({
|
||||
...values,
|
||||
uploaded_file: file,
|
||||
})
|
||||
}
|
||||
|
||||
resetForm();
|
||||
setFile(null)
|
||||
setSubmitting(false);
|
||||
onClose()
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (playlist) {
|
||||
formik.setValues({
|
||||
name: playlist.name,
|
||||
server_url: playlist.server_url,
|
||||
max_streams: playlist.max_streams,
|
||||
user_agent: playlist.user_agent,
|
||||
is_active: playlist.is_active,
|
||||
});
|
||||
} else {
|
||||
formik.resetForm();
|
||||
}
|
||||
}, [playlist]);
|
||||
|
||||
if (!isOpen) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Box sx={style}>
|
||||
<Typography id="form-modal-title" variant="h6" mb={2}>
|
||||
M3U Account
|
||||
</Typography>
|
||||
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<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}>
|
||||
{/* File upload input */}
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Typography>File</Typography>
|
||||
</Stack>
|
||||
|
||||
<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>
|
||||
</Box>
|
||||
|
||||
<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"
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
name="is_active"
|
||||
checked={formik.values.is_active}
|
||||
onChange={(e) => formik.setFieldValue('is_active', e.target.checked)}
|
||||
/>
|
||||
} label="Is Active"
|
||||
/>
|
||||
|
||||
<Box mb={2}>
|
||||
{/* Submit button */}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={formik.isSubmitting}
|
||||
fullWidth
|
||||
size="small"
|
||||
>
|
||||
{formik.isSubmitting ? <CircularProgress size={24} /> : 'Submit'}
|
||||
</Button>
|
||||
</Box>
|
||||
</form>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const style = {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: 400,
|
||||
bgcolor: 'background.paper',
|
||||
boxShadow: 24,
|
||||
p: 4,
|
||||
};
|
||||
|
||||
export default M3U;
|
||||
137
frontend/src/components/forms/UserAgent.js
Normal file
137
frontend/src/components/forms/UserAgent.js
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
// Modal.js
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Box, Modal, Typography, Stack, TextField, Button, Select, MenuItem, Grid2, InputLabel, FormControl, CircularProgress, Checkbox } from "@mui/material";
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import API from "../../api"
|
||||
|
||||
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 <></>
|
||||
}
|
||||
|
||||
console.log(userAgent)
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Box sx={style}>
|
||||
<Typography id="form-modal-title" variant="h6" mb={2}>
|
||||
User-Agent
|
||||
</Typography>
|
||||
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<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}
|
||||
/>
|
||||
<Box mb={2}>
|
||||
{/* Submit button */}
|
||||
<Button
|
||||
size="small"
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={formik.isSubmitting}
|
||||
fullWidth
|
||||
>
|
||||
{formik.isSubmitting ? <CircularProgress size={24} /> : 'Submit'}
|
||||
</Button>
|
||||
</Box>
|
||||
</form>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const style = {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: 400,
|
||||
bgcolor: 'background.paper',
|
||||
boxShadow: 24,
|
||||
p: 4,
|
||||
};
|
||||
|
||||
export default UserAgent;
|
||||
86
frontend/src/components/tables/Filter.js
Normal file
86
frontend/src/components/tables/Filter.js
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import React from 'react';
|
||||
import { TableCell, TextField } from '@mui/material'
|
||||
|
||||
const Filter = ({ column }) => {
|
||||
const columnFilterValue = column.getFilterValue()
|
||||
const { filterVariant } = column.columnDef.meta ?? {}
|
||||
|
||||
if (filterVariant === null) {
|
||||
return <TableCell>{column.columnDef.header}</TableCell>
|
||||
}
|
||||
|
||||
return filterVariant === 'range' ? (
|
||||
<div>
|
||||
<div className="flex space-x-2">
|
||||
{/* See faceted column filters example for min max values functionality */}
|
||||
<DebouncedInput
|
||||
type="number"
|
||||
value={(columnFilterValue)?.[0] ?? ''}
|
||||
onChange={value =>
|
||||
column.setFilterValue((old) => [value, old?.[1]])
|
||||
}
|
||||
placeholder={`Min`}
|
||||
className="w-24 border shadow rounded"
|
||||
/>
|
||||
<DebouncedInput
|
||||
type="number"
|
||||
value={(columnFilterValue)?.[1] ?? ''}
|
||||
onChange={value =>
|
||||
column.setFilterValue((old) => [old?.[0], value])
|
||||
}
|
||||
placeholder={`Max`}
|
||||
className="w-24 border shadow rounded"
|
||||
/>
|
||||
</div>
|
||||
<div className="h-1" />
|
||||
</div>
|
||||
) : filterVariant === 'select' ? (
|
||||
<select
|
||||
onChange={e => column.setFilterValue(e.target.value)}
|
||||
value={columnFilterValue?.toString()}
|
||||
>
|
||||
{/* See faceted column filters example for dynamic select options */}
|
||||
<option value="">All</option>
|
||||
<option value="complicated">complicated</option>
|
||||
<option value="relationship">relationship</option>
|
||||
<option value="single">single</option>
|
||||
</select>
|
||||
) : (
|
||||
<DebouncedInput
|
||||
className="w-36 border shadow rounded"
|
||||
onChange={value => column.setFilterValue(value)}
|
||||
placeholder={column.columnDef.header}
|
||||
type="text"
|
||||
value={(columnFilterValue ?? '')}
|
||||
/>
|
||||
// See faceted column filters example for datalist search suggestions
|
||||
)
|
||||
}
|
||||
|
||||
// A typical debounced input react component
|
||||
const DebouncedInput = ({
|
||||
value: initialValue,
|
||||
onChange,
|
||||
debounce = 500,
|
||||
...props
|
||||
}) => {
|
||||
const [value, setValue] = React.useState(initialValue)
|
||||
|
||||
React.useEffect(() => {
|
||||
setValue(initialValue)
|
||||
}, [initialValue])
|
||||
|
||||
React.useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
onChange(value)
|
||||
}, debounce)
|
||||
|
||||
return () => clearTimeout(timeout)
|
||||
}, [value])
|
||||
|
||||
return (
|
||||
<TextField size="small" variant="standard" {...props} value={value} onChange={e => setValue(e.target.value)} />
|
||||
)
|
||||
}
|
||||
|
||||
export default Filter;
|
||||
198
frontend/src/components/tables/Table.js
Normal file
198
frontend/src/components/tables/Table.js
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
import React, { useMemo, useState } from 'react'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
getFilteredRowModel,
|
||||
} from '@tanstack/react-table'
|
||||
import API from '../../api'
|
||||
import { Checkbox, Table as MuiTable, TableHead, TableRow, TableCell, TableBody, Box, TextField, IconButton, TableContainer, Paper, Button, ButtonGroup, Typography, Grid2 } from '@mui/material'
|
||||
import {
|
||||
Delete as DeleteIcon,
|
||||
Edit as EditIcon,
|
||||
Add as AddIcon,
|
||||
} from '@mui/icons-material'
|
||||
import Filter from './Filter'
|
||||
|
||||
// Styles for fixed header and table container
|
||||
const styles = {
|
||||
fixedHeader: {
|
||||
position: "sticky", // Make it sticky
|
||||
top: 0, // Stick to the top
|
||||
backgroundColor: "#fff", // Ensure it has a background
|
||||
zIndex: 10, // Ensure it sits above the table
|
||||
padding: "10px",
|
||||
borderBottom: "2px solid #ccc",
|
||||
},
|
||||
tableContainer: {
|
||||
maxHeight: "400px", // Limit the height for scrolling
|
||||
overflowY: "scroll", // Make it scrollable
|
||||
marginTop: "50px", // Adjust margin to avoid overlap with fixed header
|
||||
},
|
||||
table: {
|
||||
width: "100%",
|
||||
borderCollapse: "collapse",
|
||||
},
|
||||
};
|
||||
|
||||
const Table = ({ customToolbar: CustomToolbar , name = null, tableHeight = null, data, columnDef, addAction = null, bulkDeleteAction = null }) => {
|
||||
const [rowSelection, setRowSelection] = useState({})
|
||||
const [columnFilters, setColumnFilters] = useState([])
|
||||
|
||||
// Define columns with useMemo, this is a stable object and doesn't change unless explicitly modified
|
||||
const columns = useMemo(() => columnDef, []);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
debugTable: true,
|
||||
|
||||
filterFns: {},
|
||||
|
||||
onRowSelectionChange: setRowSelection, //hoist up the row selection state to your own scope
|
||||
state: {
|
||||
rowSelection, //pass the row selection state back to the table instance
|
||||
columnFilters,
|
||||
},
|
||||
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
})
|
||||
|
||||
const parentRef = React.useRef(null)
|
||||
|
||||
const { rows } = table.getRowModel()
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 34,
|
||||
overscan: 20,
|
||||
})
|
||||
|
||||
const deleteSelected = async () => {
|
||||
const ids = Object.keys(rowSelection).map(index => data[parseInt(index)].id)
|
||||
bulkDeleteAction(ids)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Sticky Toolbar */}
|
||||
<Box
|
||||
sx={{
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
zIndex: 1100,
|
||||
backgroundColor: "white",
|
||||
borderTop: "1px solid #ddd",
|
||||
borderBottom: "1px solid #ddd",
|
||||
padding: "8px",
|
||||
}}
|
||||
>
|
||||
<Grid2 container direction="row" spacing={3} sx={{
|
||||
// justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: 30,
|
||||
}}>
|
||||
{name && <Typography>{name}</Typography>}
|
||||
{CustomToolbar && <CustomToolbar rowSelection={rowSelection} />}
|
||||
{!CustomToolbar && <Grid2>
|
||||
{addAction && <IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="info" // Red color for delete actions
|
||||
variant="contained"
|
||||
onClick={addAction}
|
||||
>
|
||||
<AddIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>}
|
||||
{bulkDeleteAction && <IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="error" // Red color for delete actions
|
||||
variant="contained"
|
||||
onClick={deleteSelected}
|
||||
>
|
||||
<DeleteIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>}
|
||||
</Grid2>
|
||||
}
|
||||
</Grid2>
|
||||
</Box>
|
||||
<div ref={parentRef}>
|
||||
<Box
|
||||
ref={parentRef}
|
||||
sx={{
|
||||
height: tableHeight || "calc(100vh - 40px)", // 50% of the viewport height
|
||||
overflow: "auto", // Enable scrollbars
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<TableContainer component={Paper} sx={{ maxHeight: "calc(100vh - 64px - 40px)" }}>
|
||||
<MuiTable stickyHeader style={{ width: '100%' }} size="small">
|
||||
<TableHead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableCell key={header.id} className="text-xs" sx={{
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
}} >
|
||||
{header.column.getCanFilter() ? (
|
||||
<div>
|
||||
<Filter column={header.column} />
|
||||
</div>
|
||||
) : header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{/* Virtualized rows */}
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow, index) => {
|
||||
const row = rows[virtualRow.index];
|
||||
return (
|
||||
<TableRow key={row.original.id} sx={{
|
||||
// transform: `translateY(${virtualRow.start}px)`,
|
||||
backgroundColor: index % 2 === 0 ? 'grey.100' : 'white',
|
||||
'&:hover': {
|
||||
backgroundColor: 'grey.200',
|
||||
},
|
||||
}}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
// onClick={() => onClickRow?.(cell, row)}
|
||||
key={cell.id}
|
||||
className="text-xs"
|
||||
sx={{
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
}}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</MuiTable>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Table;
|
||||
13
frontend/src/index.css
Normal file
13
frontend/src/index.css
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
42
frontend/src/index.js
Normal file
42
frontend/src/index.js
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
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
|
||||
import useAuthStore from './store/auth';
|
||||
import useChannelsStore from './store/channels';
|
||||
import useStreamsStore from './store/streams';
|
||||
import useUserAgentsStore from './store/userAgents';
|
||||
import usePlaylistsStore from './store/playlists';
|
||||
import useEPGsStore from './store/epgs';
|
||||
|
||||
// Create a root element
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
|
||||
const authStore = useAuthStore.getState();
|
||||
const channelsStore = useChannelsStore.getState();
|
||||
const streamsStore = useStreamsStore.getState();
|
||||
const userAgentsStore = useUserAgentsStore.getState();
|
||||
const playlistsStore = usePlaylistsStore.getState();
|
||||
const epgsStore = useEPGsStore.getState()
|
||||
|
||||
await authStore.initializeAuth();
|
||||
|
||||
console.log(authStore)
|
||||
// if (authStore.isAuthenticated) {
|
||||
await Promise.all([
|
||||
authStore.initializeAuth(),
|
||||
channelsStore.fetchChannels(),
|
||||
channelsStore.fetchChannelGroups(),
|
||||
streamsStore.fetchStreams(),
|
||||
userAgentsStore.fetchUserAgents(),
|
||||
playlistsStore.fetchPlaylists(),
|
||||
epgsStore.fetchEPGs(),
|
||||
])
|
||||
// }
|
||||
|
||||
// Render your app using the "root.render" method
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
1
frontend/src/logo.svg
Normal file
1
frontend/src/logo.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
198
frontend/src/pages/Channels.js
Normal file
198
frontend/src/pages/Channels.js
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import useChannelsStore from '../store/channels';
|
||||
import useStreamsStore from '../store/streams';
|
||||
import Table from '../components/tables/Table';
|
||||
import { ButtonGroup, Button, Checkbox, IconButton, Stack, Grid2, Grow } from '@mui/material';
|
||||
import {
|
||||
Delete as DeleteIcon,
|
||||
Edit as EditIcon,
|
||||
} from '@mui/icons-material'
|
||||
import ChannelForm from '../components/forms/Channel'
|
||||
import API from '../api'
|
||||
import StreamTableToolbar from '../components/StreamTableToolbar';
|
||||
|
||||
const ChannelsPage = () => {
|
||||
const [channel, setChannel] = useState(null)
|
||||
const [channelModelOpen, setChannelModalOpen] = useState(false);
|
||||
|
||||
const channels = useChannelsStore((state) => state.channels);
|
||||
const streams = useStreamsStore((state) => state.streams);
|
||||
const isLoading = useChannelsStore((state) => state.isLoading);
|
||||
const error = useChannelsStore((state) => state.error);
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
if (error) return <div>Error: {error}</div>;
|
||||
|
||||
const editChannel = async (channel = null) => {
|
||||
setChannel(channel)
|
||||
setChannelModalOpen(true)
|
||||
}
|
||||
|
||||
const deleteChannel = async (ids) => {
|
||||
if (Array.isArray(ids)) {
|
||||
await API.deleteChannels(ids)
|
||||
} else {
|
||||
await API.deleteChannel(ids)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Grid2 container>
|
||||
<Grid2 size={6}>
|
||||
<Table
|
||||
name="Channels"
|
||||
data={channels}
|
||||
addAction={editChannel}
|
||||
bulkDeleteAction={deleteChannel}
|
||||
columnDef={[
|
||||
{
|
||||
id: 'select-col',
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={table.getIsAllRowsSelected()}
|
||||
indeterminate={table.getIsSomeRowsSelected()}
|
||||
onChange={table.getToggleAllRowsSelectedHandler()} //or getToggleAllPageRowsSelectedHandler
|
||||
/>
|
||||
),
|
||||
size: 10,
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={row.getIsSelected()}
|
||||
disabled={!row.getCanSelect()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: '#',
|
||||
size: 10,
|
||||
accessorKey: 'channel_number',
|
||||
},
|
||||
{
|
||||
header: 'Name',
|
||||
accessorKey: 'channel_name',
|
||||
},
|
||||
{
|
||||
header: 'Group',
|
||||
// accessorFn: row => row.original.channel_group.name,
|
||||
},
|
||||
{
|
||||
header: 'Logo',
|
||||
accessorKey: 'logo_url',
|
||||
size: 50,
|
||||
cell: (info) => (
|
||||
<Grid2
|
||||
container
|
||||
direction="row"
|
||||
sx={{
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<img src={info.getValue() || "/images/logo.png"} width="20"/>
|
||||
</Grid2>
|
||||
),
|
||||
meta: {
|
||||
filterVariant: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Actions',
|
||||
cell: ({ row }) => {
|
||||
console.log(row)
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="warning" // Red color for delete actions
|
||||
onClick={() => {
|
||||
editChannel(row.original)
|
||||
}}
|
||||
>
|
||||
<EditIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="error" // Red color for delete actions
|
||||
onClick={() => deleteChannel(row.original.id)}
|
||||
>
|
||||
<DeleteIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
</>
|
||||
)
|
||||
}
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Grid2>
|
||||
<Grid2 size={6}>
|
||||
<Table
|
||||
name="Streams"
|
||||
customToolbar={StreamTableToolbar}
|
||||
data={streams}
|
||||
// addAction={editChannel}
|
||||
// bulkDeleteAction={deleteChannel}
|
||||
columnDef={[
|
||||
{
|
||||
id: 'select-col',
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={table.getIsAllRowsSelected()}
|
||||
indeterminate={table.getIsSomeRowsSelected()}
|
||||
onChange={table.getToggleAllRowsSelectedHandler()} //or getToggleAllPageRowsSelectedHandler
|
||||
/>
|
||||
),
|
||||
size: 10,
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={row.getIsSelected()}
|
||||
disabled={!row.getCanSelect()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Name',
|
||||
accessorKey: 'name',
|
||||
},
|
||||
{
|
||||
header: 'Group',
|
||||
accessorKey: 'group_name',
|
||||
},
|
||||
// {
|
||||
// id: 'actions',
|
||||
// header: 'Actions',
|
||||
// cell: ({ row }) => {
|
||||
// console.log(row)
|
||||
// return (
|
||||
// <IconButton
|
||||
// size="small" // Makes the button smaller
|
||||
// color="error" // Red color for delete actions
|
||||
// onClick={() => deleteStream(row.original.id)}
|
||||
// >
|
||||
// <DeleteIcon fontSize="small" /> {/* Small icon size */}
|
||||
// </IconButton>
|
||||
// )
|
||||
// }
|
||||
// },
|
||||
]}
|
||||
/>
|
||||
</Grid2>
|
||||
</Grid2>
|
||||
|
||||
<ChannelForm
|
||||
channel={channel}
|
||||
isOpen={channelModelOpen}
|
||||
onClose={() => setChannelModalOpen(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
};
|
||||
|
||||
export default ChannelsPage;
|
||||
27
frontend/src/pages/Dashboard.js
Normal file
27
frontend/src/pages/Dashboard.js
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
// 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;
|
||||
254
frontend/src/pages/EPG.js
Normal file
254
frontend/src/pages/EPG.js
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
import React, { useEffect, useState, useMemo, useCallback } from 'react';
|
||||
import useUserAgentsStore from '../store/userAgents';
|
||||
import { Box, Checkbox, IconButton, ButtonGroup, Button, Snackbar } from '@mui/material';
|
||||
import Table from '../components/tables/Table';
|
||||
import useEPGsStore from '../store/epgs';
|
||||
import {
|
||||
Delete as DeleteIcon,
|
||||
Edit as EditIcon,
|
||||
Refresh as RefreshIcon,
|
||||
} from '@mui/icons-material';
|
||||
import API from '../api'
|
||||
import EPGForm from '../components/forms/EPG'
|
||||
import UserAgentForm from '../components/forms/UserAgent'
|
||||
|
||||
const EPGPage = () => {
|
||||
const isLoading = useUserAgentsStore((state) => state.isLoading);
|
||||
const error = useUserAgentsStore((state) => state.error);
|
||||
const epgs = useEPGsStore(state => state.epgs)
|
||||
const userAgents = useUserAgentsStore(state => state.userAgents)
|
||||
|
||||
const [epg, setEPG] = useState(null);
|
||||
const [epgModalOpen, setEPGModalOpen] = useState(false);
|
||||
|
||||
const [userAgent, setUserAgent] = useState(null);
|
||||
const [userAgentModalOpen, setUserAgentModalOpen] = useState(false);
|
||||
|
||||
const [snackbarMessage, setSnackbarMessage] = useState("")
|
||||
const [snackbarOpen, setSnackbarOpen] = useState(false)
|
||||
|
||||
const editUserAgent = async (userAgent = null) => {
|
||||
setUserAgent(userAgent)
|
||||
setUserAgentModalOpen(true)
|
||||
}
|
||||
|
||||
const editEPG = async (epg = null) => {
|
||||
setEPG(epg)
|
||||
setEPGModalOpen(true)
|
||||
}
|
||||
|
||||
const deleteUserAgent = async (ids) => {
|
||||
if (Array.isArray(ids)) {
|
||||
await API.deleteUserAgents(ids)
|
||||
} else {
|
||||
await API.deleteUserAgent(ids)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteEPG = async (id) => {
|
||||
await API.deleteEPG(id)
|
||||
}
|
||||
|
||||
const refreshEPG = async (id) => {
|
||||
await API.refreshEPG(id)
|
||||
setSnackbarMessage("EPG refresh initiated")
|
||||
setSnackbarOpen(true)
|
||||
}
|
||||
|
||||
const closeSnackbar = () => {
|
||||
setSnackbarOpen(false)
|
||||
}
|
||||
|
||||
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' }}>
|
||||
<Table
|
||||
name="EPG Sources"
|
||||
tableHeight="calc(50vh - 40px)"
|
||||
data={epgs}
|
||||
addAction={editEPG}
|
||||
columnDef={[
|
||||
{
|
||||
id: 'select-col',
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={table.getIsAllRowsSelected()}
|
||||
indeterminate={table.getIsSomeRowsSelected()}
|
||||
onChange={table.getToggleAllRowsSelectedHandler()} //or getToggleAllPageRowsSelectedHandler
|
||||
/>
|
||||
),
|
||||
size: 10,
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={row.getIsSelected()}
|
||||
disabled={!row.getCanSelect()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Name',
|
||||
size: 10,
|
||||
accessorKey: 'name',
|
||||
},
|
||||
{
|
||||
header: 'Source Type',
|
||||
accessorKey: 'source_type',
|
||||
size: 50,
|
||||
},
|
||||
{
|
||||
header: 'URL / API Key',
|
||||
accessorKey: 'max_streams',
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Actions',
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="info" // Red color for delete actions
|
||||
onClick={() => editEPG(row.original)}
|
||||
>
|
||||
<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)}
|
||||
>
|
||||
<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)}
|
||||
>
|
||||
<RefreshIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
</>
|
||||
)
|
||||
}
|
||||
},
|
||||
]} />
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flex: '1 1 50%', overflow: 'hidden' }}>
|
||||
<Table
|
||||
name="User-Agents"
|
||||
tableHeight="calc(50vh - 40px)"
|
||||
data={userAgents}
|
||||
addAction={editUserAgent}
|
||||
columnDef={[
|
||||
{
|
||||
id: 'select-col',
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={table.getIsAllRowsSelected()}
|
||||
indeterminate={table.getIsSomeRowsSelected()}
|
||||
onChange={table.getToggleAllRowsSelectedHandler()} //or getToggleAllPageRowsSelectedHandler
|
||||
/>
|
||||
),
|
||||
size: 10,
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={row.getIsSelected()}
|
||||
disabled={!row.getCanSelect()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Name',
|
||||
size: 10,
|
||||
accessorKey: 'user_agent_name',
|
||||
},
|
||||
{
|
||||
header: 'User-Agent',
|
||||
accessorKey: 'user_agent',
|
||||
size: 50,
|
||||
},
|
||||
{
|
||||
header: 'Desecription',
|
||||
accessorKey: 'description',
|
||||
},
|
||||
{
|
||||
header: 'Active',
|
||||
accessorKey: 'is_active',
|
||||
cell: ({ row }) => {
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={row.original.is_active}
|
||||
disabled
|
||||
/>
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Actions',
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="warning" // Red color for delete actions
|
||||
onClick={() => {
|
||||
editUserAgent(row.original)
|
||||
}}
|
||||
>
|
||||
<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)}
|
||||
>
|
||||
<DeleteIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
</>
|
||||
)
|
||||
}
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<EPGForm
|
||||
epg={epg}
|
||||
isOpen={epgModalOpen}
|
||||
onClose={() => setEPGModalOpen(false)}
|
||||
/>
|
||||
|
||||
<UserAgentForm
|
||||
userAgent={userAgent}
|
||||
isOpen={userAgentModalOpen}
|
||||
onClose={() => setUserAgentModalOpen(false)}
|
||||
/>
|
||||
|
||||
<Snackbar
|
||||
anchorOrigin={{ vertical: "top", horizontal: "right"}}
|
||||
open={snackbarOpen}
|
||||
autoHideDuration={5000}
|
||||
onClose={closeSnackbar}
|
||||
message={snackbarMessage}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
};
|
||||
|
||||
export default EPGPage;
|
||||
14
frontend/src/pages/Home.js
Normal file
14
frontend/src/pages/Home.js
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
// 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;
|
||||
8
frontend/src/pages/Login.js
Normal file
8
frontend/src/pages/Login.js
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import React from 'react';
|
||||
import LoginForm from '../components/forms/LoginForm';
|
||||
|
||||
const Login = () => {
|
||||
return <LoginForm/>
|
||||
};
|
||||
|
||||
export default Login;
|
||||
242
frontend/src/pages/M3U.js
Normal file
242
frontend/src/pages/M3U.js
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
import React, { useEffect, useState, useMemo, useCallback } from 'react';
|
||||
import useUserAgentsStore from '../store/userAgents';
|
||||
import { Box, Checkbox, IconButton, ButtonGroup, Button } from '@mui/material';
|
||||
import Table from '../components/tables/Table';
|
||||
import usePlaylistsStore from '../store/playlists';
|
||||
import {
|
||||
Delete as DeleteIcon,
|
||||
Edit as EditIcon,
|
||||
Check as CheckIcon,
|
||||
} from '@mui/icons-material';
|
||||
import API from '../api'
|
||||
import M3UForm from '../components/forms/M3U'
|
||||
import UserAgentForm from '../components/forms/UserAgent'
|
||||
|
||||
const M3UPage = () => {
|
||||
const isLoading = useUserAgentsStore((state) => state.isLoading);
|
||||
const error = useUserAgentsStore((state) => state.error);
|
||||
const playlists = usePlaylistsStore(state => state.playlists)
|
||||
const userAgents = useUserAgentsStore(state => state.userAgents)
|
||||
|
||||
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' }}>
|
||||
<Table
|
||||
name="M3U Accounts"
|
||||
tableHeight="calc(50vh - 40px)"
|
||||
data={playlists}
|
||||
addAction={editPlaylist}
|
||||
columnDef={[
|
||||
{
|
||||
id: 'select-col',
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={table.getIsAllRowsSelected()}
|
||||
indeterminate={table.getIsSomeRowsSelected()}
|
||||
onChange={table.getToggleAllRowsSelectedHandler()} //or getToggleAllPageRowsSelectedHandler
|
||||
/>
|
||||
),
|
||||
size: 10,
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={row.getIsSelected()}
|
||||
disabled={!row.getCanSelect()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Name',
|
||||
size: 10,
|
||||
accessorKey: 'name',
|
||||
},
|
||||
{
|
||||
header: 'URL / File',
|
||||
accessorKey: 'server_url',
|
||||
size: 50,
|
||||
},
|
||||
{
|
||||
header: 'Max Streams',
|
||||
accessorKey: 'max_streams',
|
||||
},
|
||||
{
|
||||
header: 'Active',
|
||||
accessorKey: 'is_active',
|
||||
cell: ({ row }) => {(
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={row.original.is_active}
|
||||
disabled
|
||||
/>
|
||||
)},
|
||||
meta: {
|
||||
filterVariant: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Actions',
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="warning" // Red color for delete actions
|
||||
onClick={() => {
|
||||
editPlaylist(row.original)
|
||||
}}
|
||||
>
|
||||
<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)}
|
||||
>
|
||||
<DeleteIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
</>
|
||||
)
|
||||
}
|
||||
},
|
||||
]} />
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flex: '1 1 50%', overflow: 'hidden' }}>
|
||||
<Table
|
||||
name="User-Agents"
|
||||
tableHeight="calc(50vh - 40px)"
|
||||
data={userAgents}
|
||||
addAction={editUserAgent}
|
||||
columnDef={[
|
||||
{
|
||||
id: 'select-col',
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={table.getIsAllRowsSelected()}
|
||||
indeterminate={table.getIsSomeRowsSelected()}
|
||||
onChange={table.getToggleAllRowsSelectedHandler()} //or getToggleAllPageRowsSelectedHandler
|
||||
/>
|
||||
),
|
||||
size: 10,
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={row.getIsSelected()}
|
||||
disabled={!row.getCanSelect()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Name',
|
||||
size: 10,
|
||||
accessorKey: 'user_agent_name',
|
||||
},
|
||||
{
|
||||
header: 'User-Agent',
|
||||
accessorKey: 'user_agent',
|
||||
size: 50,
|
||||
},
|
||||
{
|
||||
header: 'Desecription',
|
||||
accessorKey: 'description',
|
||||
},
|
||||
{
|
||||
header: 'Active',
|
||||
accessorKey: 'is_active',
|
||||
cell: ({ row }) => {
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={row.original.is_active}
|
||||
disabled
|
||||
/>
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Actions',
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="warning" // Red color for delete actions
|
||||
onClick={() => {
|
||||
editUserAgent(row.original)
|
||||
}}
|
||||
>
|
||||
<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)}
|
||||
>
|
||||
<DeleteIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
</>
|
||||
)
|
||||
}
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<M3UForm
|
||||
playlist={playlist}
|
||||
isOpen={playlistModalOpen}
|
||||
onClose={() => setPlaylistModalOpen(false)}
|
||||
/>
|
||||
|
||||
<UserAgentForm
|
||||
userAgent={userAgent}
|
||||
isOpen={userAgentModalOpen}
|
||||
onClose={() => setUserAgentModalOpen(false)}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
};
|
||||
|
||||
export default M3UPage;
|
||||
13
frontend/src/reportWebVitals.js
Normal file
13
frontend/src/reportWebVitals.js
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
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;
|
||||
5
frontend/src/setupTests.js
Normal file
5
frontend/src/setupTests.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||
// allows you to do things like:
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom';
|
||||
109
frontend/src/store/auth.js
Normal file
109
frontend/src/store/auth.js
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
// src/auth/authStore.js
|
||||
|
||||
import {create} from 'zustand';
|
||||
import API from '../api'
|
||||
|
||||
const decodeToken = (token) => {
|
||||
if (!token) return null;
|
||||
const payload = token.split('.')[1];
|
||||
const decodedPayload = JSON.parse(atob(payload));
|
||||
return decodedPayload.exp;
|
||||
};
|
||||
|
||||
const isTokenExpired = (expirationTime) => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return now >= expirationTime;
|
||||
};
|
||||
|
||||
const useAuthStore = create((set, get) => ({
|
||||
accessToken: localStorage.getItem('accessToken') || null,
|
||||
refreshToken: localStorage.getItem('refreshToken') || null,
|
||||
tokenExpiration: localStorage.getItem('tokenExpiration') || null,
|
||||
isAuthenticated: false,
|
||||
|
||||
getToken: async () => {
|
||||
const expiration = localStorage.getItem('tokenExpiration')
|
||||
const tokenExpiration = localStorage.getItem('tokenExpiration');
|
||||
let accessToken = null;
|
||||
if (isTokenExpired(tokenExpiration)) {
|
||||
accessToken = await get().refreshToken();
|
||||
} else {
|
||||
accessToken = localStorage.getItem('accessToken');
|
||||
}
|
||||
|
||||
return accessToken;
|
||||
},
|
||||
|
||||
// Action to login
|
||||
login: async ({username, password}) => {
|
||||
try {
|
||||
const response = await API.login(username, password)
|
||||
if (response.access) {
|
||||
const expiration = decodeToken(response.access)
|
||||
set({
|
||||
accessToken: response.access,
|
||||
refreshToken: response.refresh,
|
||||
tokenExpiration: expiration, // 1 hour from now
|
||||
isAuthenticated: true
|
||||
});
|
||||
// Store in localStorage
|
||||
localStorage.setItem('accessToken', response.access);
|
||||
localStorage.setItem('refreshToken', response.refresh);
|
||||
localStorage.setItem('tokenExpiration', expiration);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// Action to refresh the token
|
||||
refreshToken: async () => {
|
||||
const refreshToken = localStorage.getItem('refreshToken');
|
||||
if (!refreshToken) return;
|
||||
|
||||
try {
|
||||
const data = await API.refreshToken(refreshToken)
|
||||
if (data.access) {
|
||||
set({
|
||||
accessToken: data.access,
|
||||
tokenExpiration: decodeToken(data.access),
|
||||
isAuthenticated: true,
|
||||
});
|
||||
localStorage.setItem('accessToken', data.access);
|
||||
localStorage.setItem('tokenExpiration', decodeToken(data.access));
|
||||
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Token refresh failed:', error);
|
||||
get().logout()
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
// Action to logout
|
||||
logout: () => {
|
||||
set({
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
tokenExpiration: null,
|
||||
isAuthenticated: false
|
||||
});
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
localStorage.removeItem('tokenExpiration');
|
||||
},
|
||||
|
||||
initializeAuth: async () => {
|
||||
const refreshToken = localStorage.getItem('refreshToken') || null;
|
||||
|
||||
if (refreshToken) {
|
||||
await get().refreshToken()
|
||||
} else {
|
||||
await get().logout()
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
export default useAuthStore;
|
||||
43
frontend/src/store/channels.js
Normal file
43
frontend/src/store/channels.js
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
// src/stores/channelsStore.js
|
||||
|
||||
import { create } from 'zustand';
|
||||
import api from '../api'; // Your API helper that manages token & requests
|
||||
|
||||
const useChannelsStore = create((set) => ({
|
||||
channels: [],
|
||||
channelGroups: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
fetchChannels: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const channels = await api.getChannels();
|
||||
set({ channels: channels, isLoading: false });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch channels:', error);
|
||||
set({ error: 'Failed to load channels.', isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
fetchChannelGroups: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const channelGroups = await api.getChannelGroups();
|
||||
set({ channelGroups: channelGroups, isLoading: false });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch channel groups:', error);
|
||||
set({ error: 'Failed to load channel groups.', isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
addChannel: (newChannel) => set((state) => ({
|
||||
channels: [...state.channels, newChannel],
|
||||
})),
|
||||
|
||||
removeChannels: (channelIds) => set((state) => ({
|
||||
channels: state.channels.filter((channel) => !channelIds.includes(channel.id)),
|
||||
})),
|
||||
}));
|
||||
|
||||
export default useChannelsStore;
|
||||
29
frontend/src/store/epgs.js
Normal file
29
frontend/src/store/epgs.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { create } from 'zustand';
|
||||
import api from '../api'; // Your API helper that manages token & requests
|
||||
|
||||
const useEPGsStore = create((set) => ({
|
||||
epgs: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
fetchEPGs: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const epgs = await api.getEPGs();
|
||||
set({ epgs: epgs, isLoading: false });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch epgs:', error);
|
||||
set({ error: 'Failed to load epgs.', isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
addEPG: (newPlaylist) => set((state) => ({
|
||||
epgs: [...state.epgs, newPlaylist],
|
||||
})),
|
||||
|
||||
removeEGPs: (epgIds) => set((state) => ({
|
||||
epgs: state.epgs.filter((epg) => !epgIds.includes(epg.id)),
|
||||
})),
|
||||
}));
|
||||
|
||||
export default useEPGsStore;
|
||||
29
frontend/src/store/playlists.js
Normal file
29
frontend/src/store/playlists.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { create } from 'zustand';
|
||||
import api from '../api'; // Your API helper that manages token & requests
|
||||
|
||||
const usePlaylistsStore = create((set) => ({
|
||||
playlists: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
fetchPlaylists: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const playlists = await api.getPlaylists();
|
||||
set({ playlists: playlists, isLoading: false });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch playlists:', error);
|
||||
set({ error: 'Failed to load playlists.', isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
addPlaylist: (newPlaylist) => set((state) => ({
|
||||
playlists: [...state.playlists, newPlaylist],
|
||||
})),
|
||||
|
||||
removePlaylists: (playlistIds) => set((state) => ({
|
||||
playlists: state.playlists.filter((playlist) => !playlistIds.includes(playlist.id)),
|
||||
})),
|
||||
}));
|
||||
|
||||
export default usePlaylistsStore;
|
||||
21
frontend/src/store/streams.js
Normal file
21
frontend/src/store/streams.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { create } from 'zustand';
|
||||
import api from '../api'; // Your API helper that manages token & requests
|
||||
|
||||
const useStreamsStore = create((set) => ({
|
||||
streams: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
fetchStreams: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const streams = await api.getStreams();
|
||||
set({ streams: streams, isLoading: false });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch streams:', error);
|
||||
set({ error: 'Failed to load streams.', isLoading: false });
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
export default useStreamsStore;
|
||||
33
frontend/src/store/userAgents.js
Normal file
33
frontend/src/store/userAgents.js
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { create } from 'zustand';
|
||||
import api from '../api'; // Your API helper that manages token & requests
|
||||
|
||||
const useUserAgentsStore = create((set) => ({
|
||||
userAgents: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
fetchUserAgents: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const userAgents = await api.getUserAgents();
|
||||
set({ userAgents: userAgents, isLoading: false });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch userAgents:', error);
|
||||
set({ error: 'Failed to load userAgents.', isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
addUserAgent: (userAgent) => set((state) => ({
|
||||
userAgents: [...state.userAgents, userAgent],
|
||||
})),
|
||||
|
||||
updateUserAgent: (userAgent) => set((state) => ({
|
||||
userAgents: state.userAgents.map(ua => ua.id === userAgent.id ? userAgent : ua),
|
||||
})),
|
||||
|
||||
removeUserAgents: (userAgentIds) => set((state) => ({
|
||||
userAgents: state.userAgents.filter((userAgent) => !userAgentIds.includes(userAgent.id)),
|
||||
})),
|
||||
}));
|
||||
|
||||
export default useUserAgentsStore;
|
||||
21
frontend/src/theme.js
Normal file
21
frontend/src/theme.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { createTheme } from '@mui/material/styles';
|
||||
|
||||
const theme = createTheme({
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#495057',
|
||||
contrastText: '#ffffff', // Ensure text is visible on primary color
|
||||
},
|
||||
},
|
||||
components: {
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
// textTransform: 'none', // Disable uppercase on buttons
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default theme;
|
||||
|
|
@ -12,3 +12,5 @@ streamlink
|
|||
python-vlc
|
||||
yt-dlp
|
||||
gevent==24.11.1
|
||||
django-cors-headers
|
||||
djangorestframework-simplejwt
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue