initial commit of react frontend

This commit is contained in:
kappa118 2025-02-23 14:35:19 -05:00
parent 3a15cf6b7f
commit 0dfa001f3a
51 changed files with 35787 additions and 5 deletions

3
.gitignore vendored
View file

@ -4,4 +4,5 @@ __pycache__/
.vscode/launch.json
.vscode/tasks.json
*.pyc
node_modules/
.history/

View file

@ -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

View file

@ -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
}

View file

@ -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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

54
frontend/package.json Normal file
View 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"
]
}
}

File diff suppressed because it is too large Load diff

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
frontend/public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View 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"
}

View file

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

38
frontend/src/App.css Normal file
View 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
View 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
View 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
View 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;
}
}

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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
View 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
View 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
View 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

View 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;

View 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
View 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;

View 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;

View 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
View 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;

View 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;

View 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
View 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;

View 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;

View 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;

View 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;

View 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;

View 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
View 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;

View file

@ -12,3 +12,5 @@ streamlink
python-vlc
yt-dlp
gevent==24.11.1
django-cors-headers
djangorestframework-simplejwt