Merge branch 'mantine' of https://github.com/Dispatcharr/Dispatcharr into Proxy-Redis
2
.gitignore
vendored
|
|
@ -12,3 +12,5 @@ static/
|
|||
docker/DockerfileAIO
|
||||
docker/Dockerfile DEV
|
||||
data/
|
||||
.next
|
||||
next-env.d.ts
|
||||
|
|
|
|||
|
|
@ -359,7 +359,7 @@ class BulkDeleteChannelsAPIView(APIView):
|
|||
),
|
||||
responses={204: "Channels deleted"}
|
||||
)
|
||||
def destroy(self, request):
|
||||
def delete(self, request):
|
||||
channel_ids = request.data.get('channel_ids', [])
|
||||
Channel.objects.filter(id__in=channel_ids).delete()
|
||||
return Response({"message": "Channels deleted"}, status=status.HTTP_204_NO_CONTENT)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,34 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
LOCK_EXPIRE = 120 # Lock expires after 120 seconds
|
||||
|
||||
def parse_extinf_line(line: str) -> dict:
|
||||
"""
|
||||
Parse an EXTINF line from an M3U file.
|
||||
This function removes the "#EXTINF:" prefix, then splits the remaining
|
||||
string on the first comma that is not enclosed in quotes.
|
||||
|
||||
Returns a dictionary with:
|
||||
- 'attributes': a dict of attribute key/value pairs (e.g. tvg-id, tvg-logo, group-title)
|
||||
- 'display_name': the text after the comma (the fallback display name)
|
||||
- 'name': the value from tvg-name (if present) or the display name otherwise.
|
||||
"""
|
||||
if not line.startswith("#EXTINF:"):
|
||||
return None
|
||||
content = line[len("#EXTINF:"):].strip()
|
||||
# Split on the first comma that is not inside quotes.
|
||||
parts = re.split(r',(?=(?:[^"]*"[^"]*")*[^"]*$)', content, maxsplit=1)
|
||||
if len(parts) != 2:
|
||||
return None
|
||||
attributes_part, display_name = parts[0], parts[1].strip()
|
||||
attrs = dict(re.findall(r'(\w+)=["\']([^"\']+)["\']', attributes_part))
|
||||
# Use tvg-name attribute if available; otherwise, use the display name.
|
||||
name = attrs.get('tvg-name', display_name)
|
||||
return {
|
||||
'attributes': attrs,
|
||||
'display_name': display_name,
|
||||
'name': name
|
||||
}
|
||||
|
||||
def _get_group_title(extinf_line: str) -> str:
|
||||
"""Extract group title from EXTINF line."""
|
||||
match = re.search(r'group-title="([^"]*)"', extinf_line)
|
||||
|
|
@ -108,7 +136,7 @@ def refresh_single_m3u_account(account_id):
|
|||
return err_msg
|
||||
|
||||
logger.info(f"M3U has {len(lines)} lines. Now parsing for Streams.")
|
||||
skip_exts = ('.mkv', '.mp4', '.ts', '.m4v', '.wav', '.avi', '.flv', '.m4p', '.mpg',
|
||||
skip_exts = ('.mkv', '.mp4', '.m4v', '.wav', '.avi', '.flv', '.m4p', '.mpg',
|
||||
'.mpeg', '.m2v', '.mp2', '.mpe', '.mpv')
|
||||
|
||||
created_count, updated_count, excluded_count = 0, 0, 0
|
||||
|
|
@ -117,26 +145,21 @@ def refresh_single_m3u_account(account_id):
|
|||
for line in lines:
|
||||
line = line.strip()
|
||||
if line.startswith('#EXTINF'):
|
||||
tvg_name_match = re.search(r'tvg-name="([^"]*)"', line)
|
||||
tvg_logo_match = re.search(r'tvg-logo="([^"]*)"', line)
|
||||
# Extract tvg-id
|
||||
tvg_id_match = re.search(r'tvg-id="([^"]*)"', line)
|
||||
tvg_id = tvg_id_match.group(1) if tvg_id_match else ""
|
||||
|
||||
fallback_name = line.split(",", 1)[-1].strip() if "," in line else "Default Stream"
|
||||
|
||||
name = tvg_name_match.group(1) if tvg_name_match else fallback_name
|
||||
logo_url = tvg_logo_match.group(1) if tvg_logo_match else ""
|
||||
group_title = _get_group_title(line)
|
||||
|
||||
logger.debug(f"Parsed EXTINF: name={name}, logo_url={logo_url}, tvg_id={tvg_id}, group_title={group_title}")
|
||||
extinf = parse_extinf_line(line)
|
||||
if not extinf:
|
||||
continue
|
||||
name = extinf['name']
|
||||
tvg_id = extinf['attributes'].get('tvg-id', '')
|
||||
tvg_logo = extinf['attributes'].get('tvg-logo', '')
|
||||
# Prefer group-title from attributes if available.
|
||||
group_title = extinf['attributes'].get('group-title', _get_group_title(line))
|
||||
logger.debug(f"Parsed EXTINF: name={name}, logo_url={tvg_logo}, tvg_id={tvg_id}, group_title={group_title}")
|
||||
current_info = {
|
||||
"name": name,
|
||||
"logo_url": logo_url,
|
||||
"logo_url": tvg_logo,
|
||||
"group_title": group_title,
|
||||
"tvg_id": tvg_id, # save the tvg-id here
|
||||
"tvg_id": tvg_id,
|
||||
}
|
||||
|
||||
elif current_info and line.startswith('http'):
|
||||
lower_line = line.lower()
|
||||
if any(lower_line.endswith(ext) for ext in skip_exts):
|
||||
|
|
@ -156,7 +179,6 @@ def refresh_single_m3u_account(account_id):
|
|||
current_info = None
|
||||
continue
|
||||
|
||||
# Include tvg_id in the defaults so it gets saved
|
||||
defaults = {
|
||||
"logo_url": current_info["logo_url"],
|
||||
"tvg_id": current_info["tvg_id"]
|
||||
|
|
@ -223,17 +245,13 @@ def parse_m3u_file(file_path, account):
|
|||
for line in lines:
|
||||
line = line.strip()
|
||||
if line.startswith('#EXTINF'):
|
||||
tvg_name_match = re.search(r'tvg-name="([^"]*)"', line)
|
||||
tvg_logo_match = re.search(r'tvg-logo="([^"]*)"', line)
|
||||
fallback_name = line.split(",", 1)[-1].strip() if "," in line else "Stream"
|
||||
tvg_id_match = re.search(r'tvg-id="([^"]*)"', line)
|
||||
tvg_id = tvg_id_match.group(1) if tvg_id_match else ""
|
||||
|
||||
name = tvg_name_match.group(1) if tvg_name_match else fallback_name
|
||||
logo_url = tvg_logo_match.group(1) if tvg_logo_match else ""
|
||||
|
||||
current_info = {"name": name, "logo_url": logo_url, "tvg_id": tvg_id}
|
||||
|
||||
extinf = parse_extinf_line(line)
|
||||
if not extinf:
|
||||
continue
|
||||
name = extinf['name']
|
||||
tvg_id = extinf['attributes'].get('tvg-id', '')
|
||||
tvg_logo = extinf['attributes'].get('tvg-logo', '')
|
||||
current_info = {"name": name, "logo_url": tvg_logo, "tvg_id": tvg_id}
|
||||
elif current_info and line.startswith('http'):
|
||||
lower_line = line.lower()
|
||||
if any(lower_line.endswith(ext) for ext in skip_exts):
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ TEMPLATES = [
|
|||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [
|
||||
os.path.join(BASE_DIR, 'frontend/build'),
|
||||
os.path.join(BASE_DIR, 'frontend/dist'),
|
||||
BASE_DIR / "templates"
|
||||
],
|
||||
'APP_DIRS': True,
|
||||
|
|
@ -140,7 +140,7 @@ STATIC_ROOT = BASE_DIR / 'static' # Directory where static files will be collec
|
|||
|
||||
# Adjust STATICFILES_DIRS to include the paths to the directories that contain your static files.
|
||||
STATICFILES_DIRS = [
|
||||
os.path.join(BASE_DIR, 'frontend/build/static'), # React build static files
|
||||
os.path.join(BASE_DIR, 'frontend/dist'), # React build static files
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ RUN apt-get update && \
|
|||
cd /app/frontend && \
|
||||
npm install && \
|
||||
npm run build && \
|
||||
find . -maxdepth 1 ! -name '.' ! -name 'build' -exec rm -rf '{}' \;
|
||||
find . -maxdepth 1 ! -name '.' ! -name 'dist' -exec rm -rf '{}' \;
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@ services:
|
|||
image: dispatcharr/dispatcharr
|
||||
container_name: dispatcharr_dev
|
||||
ports:
|
||||
- "5656:5656"
|
||||
- 5656:5656
|
||||
- 9191:9191
|
||||
- 8001:8001
|
||||
volumes:
|
||||
- ../:/app
|
||||
environment:
|
||||
|
|
|
|||
|
|
@ -46,11 +46,12 @@ if [[ ! -f /etc/profile.d/dispatcharr.sh ]]; then
|
|||
echo "export POSTGRES_HOST=$POSTGRES_HOST" >> /etc/profile.d/dispatcharr.sh
|
||||
echo "export POSTGRES_PORT=$POSTGRES_PORT" >> /etc/profile.d/dispatcharr.sh
|
||||
echo "export DISPATCHARR_ENV=$DISPATCHARR_ENV" >> /etc/profile.d/dispatcharr.sh
|
||||
echo "export REACT_APP_ENV_MODE=$DISPATCHARR_ENV" >> /etc/profile.d/dispatcharr.sh
|
||||
fi
|
||||
|
||||
chmod +x /etc/profile.d/dispatcharr.sh
|
||||
|
||||
pip install django-filter
|
||||
|
||||
# Run init scripts
|
||||
echo "Starting init process..."
|
||||
. /app/docker/init/01-user-setup.sh
|
||||
|
|
|
|||
|
|
@ -15,3 +15,5 @@ fi
|
|||
|
||||
# Install frontend dependencies
|
||||
cd /app/frontend && npm install
|
||||
|
||||
cd /app && pip install -r requirements.txt
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ server {
|
|||
uwsgi_pass unix:/app/uwsgi.sock;
|
||||
}
|
||||
|
||||
location /static/ {
|
||||
root /app; # Base directory for static files
|
||||
location /assets/ {
|
||||
root /app/static; # Base directory for static files
|
||||
}
|
||||
|
||||
# admin disabled when not in dev mode
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
attach-daemon = celery -A dispatcharr worker -l info
|
||||
attach-daemon = redis-server
|
||||
attach-daemon = daphne -b 0.0.0.0 -p 8001 dispatcharr.asgi:application
|
||||
attach-daemon = cd /app/frontend && npm run start
|
||||
attach-daemon = cd /app/frontend && npm run dev
|
||||
|
||||
# Core settings
|
||||
chdir = /app
|
||||
|
|
|
|||
|
|
@ -68,5 +68,21 @@
|
|||
"name": "Default Stream Profile",
|
||||
"value": "1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "core.coresettings",
|
||||
"fields": {
|
||||
"key": "preferred-region",
|
||||
"name": "Preferred Region",
|
||||
"value": "us"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "core.coresettings",
|
||||
"fields": {
|
||||
"key": "cache-images",
|
||||
"name": "Cache Images",
|
||||
"value": "true"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
41
frontend/.gitignore
vendored
|
|
@ -1,23 +1,24 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
|
|
|||
|
|
@ -1,70 +1,12 @@
|
|||
# Getting Started with Create React App
|
||||
# React + Vite
|
||||
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
## Available Scripts
|
||||
Currently, two official plugins are available:
|
||||
|
||||
In the project directory, you can run:
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
### `npm start`
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
Runs the app in the development mode.\
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
|
||||
|
||||
The page will reload when you make changes.\
|
||||
You may also see any lint errors in the console.
|
||||
|
||||
### `npm test`
|
||||
|
||||
Launches the test runner in the interactive watch mode.\
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||
|
||||
### `npm run build`
|
||||
|
||||
Builds the app for production to the `build` folder.\
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
|
||||
The build is minified and the filenames include the hashes.\
|
||||
Your app is ready to be deployed!
|
||||
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||
|
||||
### `npm run eject`
|
||||
|
||||
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
|
||||
|
||||
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||
|
||||
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
|
||||
|
||||
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
|
||||
|
||||
## Learn More
|
||||
|
||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||
|
||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||
|
||||
### Code Splitting
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
|
||||
|
||||
### Analyzing the Bundle Size
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
|
||||
|
||||
### Making a Progressive Web App
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
|
||||
|
||||
### Advanced Configuration
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
|
||||
|
||||
### Deployment
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
|
||||
|
||||
### `npm run build` fails to minify
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
|
||||
If you are developing a production application, we recommend using TypeScript and enable type-aware lint rules. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
#!/bin/sh
|
||||
|
||||
apk add nodejs npm
|
||||
cd /app/
|
||||
npm i
|
||||
PORT=9191 npm run start
|
||||
33
frontend/eslint.config.js
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
|
||||
export default [
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...js.configs.recommended.rules,
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import globals from "globals";
|
||||
import pluginJs from "@eslint/js";
|
||||
import pluginReact from "eslint-plugin-react";
|
||||
|
||||
|
||||
/** @type {import('eslint').Linter.Config[]} */
|
||||
export default [
|
||||
{files: ["**/*.{js,mjs,cjs,jsx}"]},
|
||||
{languageOptions: { globals: globals.browser }},
|
||||
pluginJs.configs.recommended,
|
||||
pluginReact.configs.flat.recommended,
|
||||
];
|
||||
13
frontend/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Dispatcharr</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
18169
frontend/package-lock.json
generated
|
|
@ -1,60 +1,54 @@
|
|||
{
|
||||
"name": "frontend",
|
||||
"version": "0.1.0",
|
||||
"name": "vite",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@fontsource/roboto": "^5.1.1",
|
||||
"@mui/icons-material": "^6.4.5",
|
||||
"@mui/material": "^6.4.5",
|
||||
"@videojs/http-streaming": "^3.17.0",
|
||||
"axios": "^1.7.9",
|
||||
"@mantine/core": "^7.17.0",
|
||||
"@mantine/dates": "^7.17.0",
|
||||
"@mantine/hooks": "^7.17.0",
|
||||
"@mantine/notifications": "^7.17.1",
|
||||
"@mui/icons-material": "^6.4.7",
|
||||
"@mui/material": "^6.4.7",
|
||||
"@mui/x-date-pickers": "^7.27.3",
|
||||
"@tabler/icons-react": "^3.31.0",
|
||||
"allotment": "^1.20.3",
|
||||
"axios": "^1.8.2",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"eslint": "^8.57.1",
|
||||
"formik": "^2.4.6",
|
||||
"hls.js": "^1.5.20",
|
||||
"lucide-react": "^0.479.0",
|
||||
"material-react-table": "^3.2.0",
|
||||
"mpegts.js": "^1.4.2",
|
||||
"planby": "^1.1.7",
|
||||
"pm2": "^5.4.3",
|
||||
"prettier": "^3.5.2",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-draggable": "4.4.6",
|
||||
"react-router-dom": "^7.2.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"react-window": "^1.8.11",
|
||||
"mantine-react-table": "^2.0.0-beta.9",
|
||||
"material-react-table": "^3.2.1",
|
||||
"mpegts.js": "^1.8.0",
|
||||
"prettier": "^3.5.3",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-draggable": "^4.4.6",
|
||||
"react-pro-sidebar": "^1.1.0",
|
||||
"react-router-dom": "^7.3.0",
|
||||
"video.js": "^8.21.0",
|
||||
"web-vitals": "^2.1.4",
|
||||
"yup": "^1.6.1",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "PORT=9191 react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"proxy": "http://localhost:5656"
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.21.0",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@vitejs/plugin-react-swc": "^3.8.0",
|
||||
"eslint": "^9.21.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"globals": "^15.15.0",
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// prettier.config.js or .prettierrc.js
|
||||
module.exports = {
|
||||
export default {
|
||||
semi: true, // Add semicolons at the end of statements
|
||||
singleQuote: true, // Use single quotes instead of double
|
||||
tabWidth: 2, // Set the indentation width
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 778 B |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
|
@ -1,61 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="%PUBLIC_URL%/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="%PUBLIC_URL%/favicon-16x16.png">
|
||||
<link rel="manifest" href="/site.webmanifest">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="IPTV Master Control"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/icon?family=Material+Icons"
|
||||
/>
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap"
|
||||
/>
|
||||
|
||||
|
||||
|
||||
<title>Dispatcharr</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
</html>
|
||||
BIN
frontend/public/logo.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 9.4 KiB |
|
|
@ -1,25 +0,0 @@
|
|||
{
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||
1
frontend/public/vite.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
|
|
@ -1,151 +0,0 @@
|
|||
// frontend/src/App.js
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
BrowserRouter as Router,
|
||||
Route,
|
||||
Routes,
|
||||
Navigate,
|
||||
} from 'react-router-dom';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import Login from './pages/Login';
|
||||
import Channels from './pages/Channels';
|
||||
import M3U from './pages/M3U';
|
||||
import { ThemeProvider } from '@mui/material/styles';
|
||||
import { Box, CssBaseline, GlobalStyles } from '@mui/material';
|
||||
import theme from './theme';
|
||||
import EPG from './pages/EPG';
|
||||
import Guide from './pages/Guide';
|
||||
import Settings from './pages/Settings';
|
||||
import StreamProfiles from './pages/StreamProfiles';
|
||||
import useAuthStore from './store/auth';
|
||||
import Alert from './components/Alert';
|
||||
import FloatingVideo from './components/FloatingVideo';
|
||||
import SuperuserForm from './components/forms/SuperuserForm';
|
||||
import { WebsocketProvider } from './WebSocket';
|
||||
|
||||
const drawerWidth = 240;
|
||||
const miniDrawerWidth = 60;
|
||||
const defaultRoute = '/channels';
|
||||
|
||||
const App = () => {
|
||||
const [open, setOpen] = useState(true);
|
||||
const [needsSuperuser, setNeedsSuperuser] = useState(false);
|
||||
const {
|
||||
isAuthenticated,
|
||||
setIsAuthenticated,
|
||||
logout,
|
||||
initData,
|
||||
initializeAuth,
|
||||
} = useAuthStore();
|
||||
|
||||
const toggleDrawer = () => {
|
||||
setOpen(!open);
|
||||
};
|
||||
|
||||
// Check if a superuser exists on first load.
|
||||
useEffect(() => {
|
||||
async function checkSuperuser() {
|
||||
try {
|
||||
const res = await axios.get('/api/accounts/initialize-superuser/');
|
||||
if (!res.data.superuser_exists) {
|
||||
setNeedsSuperuser(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking superuser status:', error);
|
||||
}
|
||||
}
|
||||
checkSuperuser();
|
||||
}, []);
|
||||
|
||||
// Authentication check
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
const loggedIn = await initializeAuth();
|
||||
if (loggedIn) {
|
||||
await initData();
|
||||
setIsAuthenticated(true);
|
||||
} else {
|
||||
await logout();
|
||||
}
|
||||
};
|
||||
checkAuth();
|
||||
}, [initializeAuth, initData, setIsAuthenticated, logout]);
|
||||
|
||||
// If no superuser exists, show the initialization form
|
||||
if (needsSuperuser) {
|
||||
return <SuperuserForm onSuccess={() => setNeedsSuperuser(false)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<GlobalStyles
|
||||
styles={{
|
||||
'.Mui-TableHeadCell-Content': {
|
||||
height: '100%',
|
||||
alignItems: 'flex-end !important',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<WebsocketProvider>
|
||||
<Router>
|
||||
{/* Sidebar on the left */}
|
||||
<Sidebar
|
||||
open={open}
|
||||
miniDrawerWidth={miniDrawerWidth}
|
||||
drawerWidth={drawerWidth}
|
||||
toggleDrawer={toggleDrawer}
|
||||
/>
|
||||
|
||||
{/* Main content area */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
ml: `${open ? drawerWidth : miniDrawerWidth}px`,
|
||||
transition: 'margin-left 0.3s',
|
||||
backgroundColor: 'background.default',
|
||||
minHeight: '100vh',
|
||||
color: 'text.primary',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ p: 2, flex: 1, overflow: 'auto' }}>
|
||||
<Routes>
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<Route path="/channels" element={<Channels />} />
|
||||
<Route path="/m3u" element={<M3U />} />
|
||||
<Route path="/epg" element={<EPG />} />
|
||||
<Route
|
||||
path="/stream-profiles"
|
||||
element={<StreamProfiles />}
|
||||
/>
|
||||
<Route path="/guide" element={<Guide />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</>
|
||||
) : (
|
||||
<Route path="/login" element={<Login />} />
|
||||
)}
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<Navigate
|
||||
to={isAuthenticated ? defaultRoute : '/login'}
|
||||
replace
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</Box>
|
||||
</Box>
|
||||
</Router>
|
||||
|
||||
<Alert />
|
||||
<FloatingVideo />
|
||||
</WebsocketProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
152
frontend/src/App.jsx
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
// frontend/src/App.js
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
BrowserRouter as Router,
|
||||
Route,
|
||||
Routes,
|
||||
Navigate,
|
||||
} from 'react-router-dom';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import Login from './pages/Login';
|
||||
import Channels from './pages/Channels';
|
||||
import M3U from './pages/M3U';
|
||||
import EPG from './pages/EPG';
|
||||
import Guide from './pages/Guide';
|
||||
import Settings from './pages/Settings';
|
||||
import StreamProfiles from './pages/StreamProfiles';
|
||||
import useAuthStore from './store/auth';
|
||||
import FloatingVideo from './components/FloatingVideo';
|
||||
import { WebsocketProvider } from './WebSocket';
|
||||
import { Box, AppShell, MantineProvider } from '@mantine/core';
|
||||
import '@mantine/core/styles.css'; // Ensure Mantine global styles load
|
||||
import '@mantine/notifications/styles.css';
|
||||
import 'mantine-react-table/styles.css';
|
||||
import './index.css';
|
||||
import mantineTheme from './mantineTheme';
|
||||
import API from './api';
|
||||
import { Notifications } from '@mantine/notifications';
|
||||
|
||||
const drawerWidth = 240;
|
||||
const miniDrawerWidth = 60;
|
||||
const defaultRoute = '/channels';
|
||||
|
||||
const App = () => {
|
||||
const [open, setOpen] = useState(true);
|
||||
const {
|
||||
isAuthenticated,
|
||||
setIsAuthenticated,
|
||||
logout,
|
||||
initData,
|
||||
initializeAuth,
|
||||
setSuperuserExists,
|
||||
} = useAuthStore();
|
||||
|
||||
const toggleDrawer = () => {
|
||||
setOpen(!open);
|
||||
};
|
||||
|
||||
// Check if a superuser exists on first load.
|
||||
useEffect(() => {
|
||||
async function checkSuperuser() {
|
||||
try {
|
||||
const response = await API.fetchSuperUser();
|
||||
if (!response.superuser_exists) {
|
||||
setSuperuserExists(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking superuser status:', error);
|
||||
}
|
||||
}
|
||||
checkSuperuser();
|
||||
}, []);
|
||||
|
||||
// Authentication check
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
const loggedIn = await initializeAuth();
|
||||
if (loggedIn) {
|
||||
await initData();
|
||||
setIsAuthenticated(true);
|
||||
} else {
|
||||
await logout();
|
||||
}
|
||||
};
|
||||
checkAuth();
|
||||
}, [initializeAuth, initData, setIsAuthenticated, logout]);
|
||||
|
||||
return (
|
||||
<MantineProvider
|
||||
defaultColorScheme="dark"
|
||||
theme={mantineTheme}
|
||||
withGlobalStyles
|
||||
withNormalizeCSS
|
||||
>
|
||||
<WebsocketProvider>
|
||||
<Notifications containerWidth={250} />
|
||||
<Router>
|
||||
<AppShell
|
||||
header={{
|
||||
height: 0,
|
||||
}}
|
||||
navbar={{
|
||||
width: open ? drawerWidth : miniDrawerWidth,
|
||||
}}
|
||||
>
|
||||
<Sidebar
|
||||
drawerWidth
|
||||
miniDrawerWidth
|
||||
collapsed={!open}
|
||||
toggleDrawer={toggleDrawer}
|
||||
/>
|
||||
|
||||
<AppShell.Main>
|
||||
<Box
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
// transition: 'margin-left 0.3s',
|
||||
backgroundColor: '#18181b',
|
||||
height: '100vh',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ p: 2, flex: 1, overflow: 'auto' }}>
|
||||
<Routes>
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<Route path="/channels" element={<Channels />} />
|
||||
<Route path="/m3u" element={<M3U />} />
|
||||
<Route path="/epg" element={<EPG />} />
|
||||
<Route
|
||||
path="/stream-profiles"
|
||||
element={<StreamProfiles />}
|
||||
/>
|
||||
<Route path="/guide" element={<Guide />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</>
|
||||
) : (
|
||||
<Route path="/login" element={<Login needsSuperuser />} />
|
||||
)}
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<Navigate
|
||||
to={isAuthenticated ? defaultRoute : '/login'}
|
||||
replace
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</Box>
|
||||
</Box>
|
||||
</AppShell.Main>
|
||||
</AppShell>
|
||||
</Router>
|
||||
|
||||
<FloatingVideo />
|
||||
</WebsocketProvider>
|
||||
</MantineProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
import { render, screen } from '@testing-library/react';
|
||||
import App from './App';
|
||||
|
||||
test('renders learn react link', () => {
|
||||
render(<App />);
|
||||
const linkElement = screen.getByText(/learn react/i);
|
||||
expect(linkElement).toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -6,7 +6,7 @@ import React, {
|
|||
useContext,
|
||||
} from 'react';
|
||||
import useStreamsStore from './store/streams';
|
||||
import useAlertStore from './store/alerts';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
|
||||
export const WebsocketContext = createContext(false, null, () => {});
|
||||
|
||||
|
|
@ -14,14 +14,13 @@ export const WebsocketProvider = ({ children }) => {
|
|||
const [isReady, setIsReady] = useState(false);
|
||||
const [val, setVal] = useState(null);
|
||||
|
||||
const { showAlert } = useAlertStore();
|
||||
const { fetchStreams } = useStreamsStore();
|
||||
|
||||
const ws = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
let wsUrl = `${window.location.host}/ws/`;
|
||||
if (process.env.REACT_APP_ENV_MODE == 'dev') {
|
||||
if (import.meta.env.DEV) {
|
||||
wsUrl = `${window.location.hostname}:8001/ws/`;
|
||||
}
|
||||
|
||||
|
|
@ -53,7 +52,10 @@ export const WebsocketProvider = ({ children }) => {
|
|||
case 'm3u_refresh':
|
||||
if (event.message?.success) {
|
||||
fetchStreams();
|
||||
showAlert(event.message.message, 'success');
|
||||
notifications.show({
|
||||
message: event.message.message,
|
||||
color: 'green.5',
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
|
|
@ -9,7 +9,9 @@ import useStreamProfilesStore from './store/streamProfiles';
|
|||
import useSettingsStore from './store/settings';
|
||||
|
||||
// If needed, you can set a base host or keep it empty if relative requests
|
||||
const host = '';
|
||||
const host = import.meta.env.DEV
|
||||
? `http://${window.location.hostname}:5656`
|
||||
: '';
|
||||
|
||||
export default class API {
|
||||
/**
|
||||
|
|
@ -19,6 +21,27 @@ export default class API {
|
|||
return await useAuthStore.getState().getToken();
|
||||
}
|
||||
|
||||
static async fetchSuperUser() {
|
||||
const response = await fetch(`${host}/api/accounts/initialize-superuser/`);
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
static async createSuperUser({ username, email, password }) {
|
||||
const response = await fetch(`${host}/api/accounts/initialize-superuser/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
password,
|
||||
email,
|
||||
}),
|
||||
});
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
static async login(username, password) {
|
||||
const response = await fetch(`${host}/api/accounts/token/`, {
|
||||
method: 'POST',
|
||||
|
|
@ -456,13 +479,29 @@ export default class API {
|
|||
}
|
||||
|
||||
static async addPlaylist(values) {
|
||||
let body = null;
|
||||
if (values.uploaded_file) {
|
||||
body = new FormData();
|
||||
for (const prop in values) {
|
||||
body.append(prop, values[prop]);
|
||||
}
|
||||
} else {
|
||||
body = { ...values };
|
||||
delete body.uploaded_file;
|
||||
body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
const response = await fetch(`${host}/api/m3u/accounts/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
'Content-Type': 'application/json',
|
||||
...(values.uploaded_file
|
||||
? {}
|
||||
: {
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
},
|
||||
body: JSON.stringify(values),
|
||||
body,
|
||||
});
|
||||
|
||||
const retval = await response.json();
|
||||
|
|
|
|||
1
frontend/src/assets/react.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4 KiB |
|
|
@ -1,26 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Snackbar, Alert, Button } from '@mui/material';
|
||||
import useAlertStore from '../store/alerts';
|
||||
|
||||
const AlertPopup = () => {
|
||||
const { open, message, severity, hideAlert } = useAlertStore();
|
||||
|
||||
const handleClose = () => {
|
||||
hideAlert();
|
||||
};
|
||||
|
||||
return (
|
||||
<Snackbar
|
||||
open={open}
|
||||
autoHideDuration={5000}
|
||||
onClose={handleClose}
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
>
|
||||
<Alert onClose={handleClose} severity={severity} sx={{ width: '100%' }}>
|
||||
{message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertPopup;
|
||||
|
|
@ -8,6 +8,7 @@ export default function FloatingVideo() {
|
|||
const { isVisible, streamUrl, hideVideo } = useVideoStore();
|
||||
const videoRef = useRef(null);
|
||||
const playerRef = useRef(null);
|
||||
const videoContainerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible || !streamUrl) {
|
||||
|
|
@ -48,8 +49,9 @@ export default function FloatingVideo() {
|
|||
}
|
||||
|
||||
return (
|
||||
<Draggable>
|
||||
<Draggable nodeRef={videoContainerRef}>
|
||||
<div
|
||||
ref={videoContainerRef}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: '20px',
|
||||
|
|
@ -1,182 +0,0 @@
|
|||
import React from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
Drawer,
|
||||
Toolbar,
|
||||
Box,
|
||||
Typography,
|
||||
Avatar,
|
||||
List,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
} from '@mui/material';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import {
|
||||
ListOrdered,
|
||||
Play,
|
||||
Database,
|
||||
SlidersHorizontal,
|
||||
LayoutGrid,
|
||||
Settings as LucideSettings,
|
||||
} from 'lucide-react';
|
||||
import logo from '../images/logo.png';
|
||||
|
||||
const navItems = [
|
||||
{ label: 'Channels', icon: <ListOrdered size={20} />, path: '/channels' },
|
||||
{ label: 'M3U', icon: <Play size={20} />, path: '/m3u' },
|
||||
{ label: 'EPG', icon: <Database size={20} />, path: '/epg' },
|
||||
{ label: 'Stream Profiles', icon: <SlidersHorizontal size={20} />, path: '/stream-profiles' },
|
||||
{ label: 'TV Guide', icon: <LayoutGrid size={20} />, path: '/guide' },
|
||||
{ label: 'Settings', icon: <LucideSettings size={20} />, path: '/settings' },
|
||||
];
|
||||
|
||||
const Sidebar = ({ open, drawerWidth, miniDrawerWidth, toggleDrawer }) => {
|
||||
const location = useLocation();
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
variant="permanent"
|
||||
PaperProps={{
|
||||
sx: {
|
||||
width: open ? drawerWidth : miniDrawerWidth,
|
||||
overflowX: 'hidden',
|
||||
transition: 'width 0.3s',
|
||||
backgroundColor: theme.palette.background.default,
|
||||
color: 'text.primary',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
boxShadow: 'none',
|
||||
border: 'none',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Toolbar
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: open ? 'space-between' : 'center',
|
||||
minHeight: '64px !important',
|
||||
px: 2,
|
||||
}}
|
||||
>
|
||||
{open ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={toggleDrawer}
|
||||
>
|
||||
<img
|
||||
src={logo}
|
||||
alt="Dispatcharr Logo"
|
||||
style={{ width: 28, height: 'auto' }}
|
||||
/>
|
||||
<Typography variant="h6" noWrap sx={{ color: 'text.primary' }}>
|
||||
Dispatcharr
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<img
|
||||
src={logo}
|
||||
alt="Dispatcharr Logo"
|
||||
style={{ width: 28, height: 'auto', cursor: 'pointer' }}
|
||||
onClick={toggleDrawer}
|
||||
/>
|
||||
)}
|
||||
</Toolbar>
|
||||
|
||||
<List disablePadding sx={{ pt: 0 }}>
|
||||
{navItems.map((item) => {
|
||||
const isActive = location.pathname.startsWith(item.path);
|
||||
return (
|
||||
<ListItemButton
|
||||
key={item.path}
|
||||
component={Link}
|
||||
to={item.path}
|
||||
sx={{
|
||||
px: 2,
|
||||
py: 0.5,
|
||||
mx: 'auto',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
color: 'inherit',
|
||||
width: '100%',
|
||||
'&:hover': { backgroundColor: 'unset !important' },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
borderRadius: 1,
|
||||
width: open ? '208px' : 'auto',
|
||||
transition: 'all 0.2s ease',
|
||||
bgcolor: isActive
|
||||
? theme.custom.sidebar.activeBackground
|
||||
: 'transparent',
|
||||
border: isActive
|
||||
? `1px solid ${theme.custom.sidebar.activeBorder}`
|
||||
: '1px solid transparent',
|
||||
color: 'text.primary',
|
||||
px: 1,
|
||||
py: 0.25,
|
||||
'&:hover': {
|
||||
bgcolor: theme.custom.sidebar.hoverBackground,
|
||||
border: `1px solid ${theme.custom.sidebar.hoverBorder}`,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemIcon
|
||||
sx={{
|
||||
color: 'text.primary',
|
||||
minWidth: 0,
|
||||
mr: open ? 1 : 'auto',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{item.icon}
|
||||
</ListItemIcon>
|
||||
{open && (
|
||||
<ListItemText
|
||||
primary={item.label}
|
||||
primaryTypographyProps={{
|
||||
sx: {
|
||||
fontSize: '14px',
|
||||
fontWeight: 400,
|
||||
fontFamily: theme.custom.sidebar.fontFamily,
|
||||
letterSpacing: '-0.3px',
|
||||
// Keeping the text color as it is in your original
|
||||
color: isActive ? '#d4d4d8' : '#d4d4d8',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</ListItemButton>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
<Box sx={{ p: 2, display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Avatar
|
||||
alt="John Doe"
|
||||
src="/static/images/avatar.png"
|
||||
sx={{ width: 32, height: 32 }}
|
||||
/>
|
||||
{open && (
|
||||
<Typography variant="body2" noWrap sx={{ color: 'text.primary' }}>
|
||||
John Doe
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
227
frontend/src/components/Sidebar.jsx
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
import React, { useRef } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
ListOrdered,
|
||||
Play,
|
||||
Database,
|
||||
SlidersHorizontal,
|
||||
LayoutGrid,
|
||||
Settings as LucideSettings,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Avatar,
|
||||
AppShell,
|
||||
Group,
|
||||
Stack,
|
||||
Box,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
TextInput,
|
||||
ActionIcon,
|
||||
} from '@mantine/core';
|
||||
import logo from '../images/logo.png';
|
||||
import useChannelsStore from '../store/channels';
|
||||
import './sidebar.css';
|
||||
import useSettingsStore from '../store/settings';
|
||||
import { ContentCopy } from '@mui/icons-material';
|
||||
|
||||
const NavLink = ({ item, isActive, collapsed }) => {
|
||||
return (
|
||||
<UnstyledButton
|
||||
key={item.path}
|
||||
component={Link}
|
||||
to={item.path}
|
||||
className={`navlink ${isActive ? 'navlink-active' : ''} ${collapsed ? 'navlink-collapsed' : ''}`}
|
||||
>
|
||||
{item.icon}
|
||||
{!collapsed && (
|
||||
<Text
|
||||
sx={{
|
||||
opacity: collapsed ? 0 : 1,
|
||||
transition: 'opacity 0.2s ease-in-out',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
minWidth: collapsed ? 0 : 150,
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</Text>
|
||||
)}
|
||||
{!collapsed && item.badge && (
|
||||
<Text size="sm" style={{ color: '#D4D4D8', whiteSpace: 'nowrap' }}>
|
||||
{item.badge}
|
||||
</Text>
|
||||
)}
|
||||
</UnstyledButton>
|
||||
);
|
||||
};
|
||||
|
||||
const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
|
||||
const location = useLocation();
|
||||
const { channels } = useChannelsStore();
|
||||
const { environment } = useSettingsStore();
|
||||
const publicIPRef = useRef(null);
|
||||
|
||||
// Navigation Items
|
||||
const navItems = [
|
||||
{
|
||||
label: 'Channels',
|
||||
icon: <ListOrdered size={20} />,
|
||||
path: '/channels',
|
||||
badge: `(${Object.keys(channels).length})`,
|
||||
},
|
||||
{ label: 'M3U', icon: <Play size={20} />, path: '/m3u' },
|
||||
{ label: 'EPG', icon: <Database size={20} />, path: '/epg' },
|
||||
{
|
||||
label: 'Stream Profiles',
|
||||
icon: <SlidersHorizontal size={20} />,
|
||||
path: '/stream-profiles',
|
||||
},
|
||||
{ label: 'TV Guide', icon: <LayoutGrid size={20} />, path: '/guide' },
|
||||
{
|
||||
label: 'Settings',
|
||||
icon: <LucideSettings size={20} />,
|
||||
path: '/settings',
|
||||
},
|
||||
];
|
||||
|
||||
const copyPublicIP = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(environment.public_ip);
|
||||
} catch (err) {
|
||||
const inputElement = publicIPRef.current; // Get the actual input
|
||||
console.log(inputElement);
|
||||
|
||||
if (inputElement) {
|
||||
inputElement.focus();
|
||||
inputElement.select();
|
||||
|
||||
// For older browsers
|
||||
document.execCommand('copy');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AppShell.Navbar
|
||||
width={{ base: collapsed ? miniDrawerWidth : drawerWidth }}
|
||||
p="xs"
|
||||
style={{
|
||||
backgroundColor: '#1A1A1E',
|
||||
// transition: 'width 0.3s ease',
|
||||
borderRight: '1px solid #2A2A2E',
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Brand - Click to Toggle */}
|
||||
<Group
|
||||
onClick={toggleDrawer}
|
||||
spacing="sm"
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '16px 12px',
|
||||
fontSize: 18,
|
||||
fontWeight: 600,
|
||||
color: '#FFFFFF',
|
||||
justifyContent: collapsed ? 'center' : 'flex-start',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{/* <ListOrdered size={24} /> */}
|
||||
<img width={30} src={logo} />
|
||||
{!collapsed && (
|
||||
<Text
|
||||
sx={{
|
||||
opacity: collapsed ? 0 : 1,
|
||||
transition: 'opacity 0.2s ease-in-out',
|
||||
whiteSpace: 'nowrap', // Ensures text never wraps
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
minWidth: collapsed ? 0 : 150, // Prevents reflow
|
||||
}}
|
||||
>
|
||||
Dispatcharr
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{/* Navigation Links */}
|
||||
<Stack gap="xs" mt="lg">
|
||||
{navItems.map((item) => {
|
||||
const isActive = location.pathname === item.path;
|
||||
|
||||
return (
|
||||
<NavLink item={item} collapsed={collapsed} isActive={isActive} />
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
{/* Profile Section */}
|
||||
<Box
|
||||
style={{
|
||||
marginTop: 'auto',
|
||||
padding: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
borderTop: '1px solid #2A2A2E',
|
||||
justifyContent: collapsed ? 'center' : 'flex-start',
|
||||
}}
|
||||
>
|
||||
<Group>
|
||||
{!collapsed && (
|
||||
<TextInput
|
||||
label="Public IP"
|
||||
ref={publicIPRef}
|
||||
value={environment.public_ip}
|
||||
leftSection={
|
||||
environment.country_code && (
|
||||
<img
|
||||
src={`https://flagcdn.com/16x12/${environment.country_code.toLowerCase()}.png`}
|
||||
alt={environment.country_name || environment.country_code}
|
||||
title={environment.country_name || environment.country_code}
|
||||
/>
|
||||
)
|
||||
}
|
||||
rightSection={
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
color="gray.9"
|
||||
onClick={copyPublicIP}
|
||||
>
|
||||
<ContentCopy />
|
||||
</ActionIcon>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Avatar src="https://via.placeholder.com/40" radius="xl" />
|
||||
{!collapsed && (
|
||||
<Group
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: 'space-between',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<Text size="sm" color="white">
|
||||
John Doe
|
||||
</Text>
|
||||
<Text size="sm" color="white">
|
||||
•••
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
</Box>
|
||||
</AppShell.Navbar>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
|
|
@ -1,491 +0,0 @@
|
|||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Stack,
|
||||
TextField,
|
||||
Button,
|
||||
Select,
|
||||
MenuItem,
|
||||
Grid2,
|
||||
InputLabel,
|
||||
FormControl,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
FormHelperText,
|
||||
} from '@mui/material';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import useChannelsStore from '../../store/channels';
|
||||
import API from '../../api';
|
||||
import useStreamProfilesStore from '../../store/streamProfiles';
|
||||
import { Add as AddIcon, Remove as RemoveIcon } from '@mui/icons-material';
|
||||
import useStreamsStore from '../../store/streams';
|
||||
import {
|
||||
MaterialReactTable,
|
||||
useMaterialReactTable,
|
||||
} from 'material-react-table';
|
||||
import ChannelGroupForm from './ChannelGroup';
|
||||
import usePlaylistsStore from '../../store/playlists';
|
||||
import logo from '../../images/logo.png';
|
||||
|
||||
const Channel = ({ channel = null, isOpen, onClose }) => {
|
||||
const channelGroups = useChannelsStore((state) => state.channelGroups);
|
||||
const streams = useStreamsStore((state) => state.streams);
|
||||
const { profiles: streamProfiles } = useStreamProfilesStore();
|
||||
const { playlists } = usePlaylistsStore();
|
||||
|
||||
const [logoFile, setLogoFile] = useState(null);
|
||||
const [logoPreview, setLogoPreview] = useState(logo);
|
||||
const [channelStreams, setChannelStreams] = useState([]);
|
||||
const [channelGroupModelOpen, setChannelGroupModalOpen] = useState(false);
|
||||
|
||||
const addStream = (stream) => {
|
||||
const streamSet = new Set(channelStreams);
|
||||
streamSet.add(stream);
|
||||
setChannelStreams(Array.from(streamSet));
|
||||
};
|
||||
|
||||
const removeStream = (stream) => {
|
||||
const streamSet = new Set(channelStreams);
|
||||
streamSet.delete(stream);
|
||||
setChannelStreams(Array.from(streamSet));
|
||||
};
|
||||
|
||||
const handleLogoChange = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
setLogoFile(file);
|
||||
setLogoPreview(URL.createObjectURL(file));
|
||||
}
|
||||
};
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
channel_name: '',
|
||||
channel_number: '',
|
||||
channel_group_id: '',
|
||||
stream_profile_id: '0',
|
||||
tvg_id: '',
|
||||
tvg_name: '',
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
channel_name: Yup.string().required('Name is required'),
|
||||
channel_number: Yup.string().required('Invalid channel number').min(0),
|
||||
channel_group_id: Yup.string().required('Channel group is required'),
|
||||
}),
|
||||
onSubmit: async (values, { setSubmitting, resetForm }) => {
|
||||
if (values.stream_profile_id == '0') {
|
||||
values.stream_profile_id = null;
|
||||
}
|
||||
|
||||
console.log(values);
|
||||
if (channel?.id) {
|
||||
await API.updateChannel({
|
||||
id: channel.id,
|
||||
...values,
|
||||
logo_file: logoFile,
|
||||
streams: channelStreams.map((stream) => stream.id),
|
||||
});
|
||||
} else {
|
||||
await API.addChannel({
|
||||
...values,
|
||||
logo_file: logoFile,
|
||||
streams: channelStreams.map((stream) => stream.id),
|
||||
});
|
||||
}
|
||||
|
||||
resetForm();
|
||||
setLogoFile(null);
|
||||
setLogoPreview(logo);
|
||||
setSubmitting(false);
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (channel) {
|
||||
formik.setValues({
|
||||
channel_name: channel.channel_name,
|
||||
channel_number: channel.channel_number,
|
||||
channel_group_id: channel.channel_group?.id,
|
||||
stream_profile_id: channel.stream_profile_id || '0',
|
||||
tvg_id: channel.tvg_id,
|
||||
tvg_name: channel.tvg_name,
|
||||
});
|
||||
|
||||
console.log(channel);
|
||||
const filteredStreams = streams
|
||||
.filter((stream) => channel.stream_ids.includes(stream.id))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
channel.stream_ids.indexOf(a.id) - channel.stream_ids.indexOf(b.id)
|
||||
);
|
||||
setChannelStreams(filteredStreams);
|
||||
} else {
|
||||
formik.resetForm();
|
||||
}
|
||||
}, [channel]);
|
||||
|
||||
const activeStreamsTable = useMaterialReactTable({
|
||||
data: channelStreams,
|
||||
columns: useMemo(
|
||||
() => [
|
||||
{
|
||||
header: 'Name',
|
||||
accessorKey: 'name',
|
||||
},
|
||||
{
|
||||
header: 'M3U',
|
||||
accessorKey: 'group_name',
|
||||
},
|
||||
],
|
||||
[]
|
||||
),
|
||||
enableSorting: false,
|
||||
enableBottomToolbar: false,
|
||||
enableTopToolbar: false,
|
||||
columnFilterDisplayMode: 'popover',
|
||||
enablePagination: false,
|
||||
enableRowVirtualization: true,
|
||||
enableRowOrdering: true,
|
||||
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
|
||||
initialState: {
|
||||
density: 'compact',
|
||||
},
|
||||
enableRowActions: true,
|
||||
positionActionsColumn: 'last',
|
||||
renderRowActions: ({ row }) => (
|
||||
<>
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="error" // Red color for delete actions
|
||||
onClick={() => removeStream(row.original)}
|
||||
>
|
||||
<RemoveIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
</>
|
||||
),
|
||||
muiTableContainerProps: {
|
||||
sx: {
|
||||
height: '200px',
|
||||
},
|
||||
},
|
||||
muiRowDragHandleProps: ({ table }) => ({
|
||||
onDragEnd: () => {
|
||||
const { draggingRow, hoveredRow } = table.getState();
|
||||
|
||||
if (hoveredRow && draggingRow) {
|
||||
channelStreams.splice(
|
||||
hoveredRow.index,
|
||||
0,
|
||||
channelStreams.splice(draggingRow.index, 1)[0]
|
||||
);
|
||||
|
||||
setChannelStreams([...channelStreams]);
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const availableStreamsTable = useMaterialReactTable({
|
||||
data: streams,
|
||||
columns: useMemo(
|
||||
() => [
|
||||
{
|
||||
header: 'Name',
|
||||
accessorKey: 'name',
|
||||
},
|
||||
{
|
||||
header: 'M3U',
|
||||
accessorFn: (row) =>
|
||||
playlists.find((playlist) => playlist.id === row.m3u_account)?.name,
|
||||
},
|
||||
],
|
||||
[]
|
||||
),
|
||||
enableBottomToolbar: false,
|
||||
enableTopToolbar: false,
|
||||
columnFilterDisplayMode: 'popover',
|
||||
enablePagination: false,
|
||||
enableRowVirtualization: true,
|
||||
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
|
||||
initialState: {
|
||||
density: 'compact',
|
||||
},
|
||||
enableRowActions: true,
|
||||
renderRowActions: ({ row }) => (
|
||||
<>
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="success" // Red color for delete actions
|
||||
onClick={() => addStream(row.original)}
|
||||
>
|
||||
<AddIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
</>
|
||||
),
|
||||
positionActionsColumn: 'last',
|
||||
muiTableContainerProps: {
|
||||
sx: {
|
||||
height: '200px',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!isOpen) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onClose={onClose} fullWidth maxWidth="lg">
|
||||
<DialogTitle
|
||||
sx={{
|
||||
backgroundColor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
}}
|
||||
>
|
||||
Channel
|
||||
</DialogTitle>
|
||||
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<DialogContent>
|
||||
<Grid2 container spacing={2}>
|
||||
<Grid2 size={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
id="channel_name"
|
||||
name="channel_name"
|
||||
label="Channel Name"
|
||||
value={formik.values.channel_name}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
error={
|
||||
formik.touched.channel_name &&
|
||||
Boolean(formik.errors.channel_name)
|
||||
}
|
||||
helperText={
|
||||
formik.touched.channel_name && formik.errors.channel_name
|
||||
}
|
||||
variant="standard"
|
||||
/>
|
||||
|
||||
<Grid2
|
||||
container
|
||||
spacing={1}
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Grid2 size={11}>
|
||||
<FormControl variant="standard" fullWidth>
|
||||
<InputLabel id="channel-group-label">
|
||||
Channel Group
|
||||
</InputLabel>
|
||||
<Select
|
||||
labelId="channel-group-label"
|
||||
id="channel_group_id"
|
||||
name="channel_group_id"
|
||||
label="Channel Group"
|
||||
value={formik.values.channel_group_id}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
error={
|
||||
formik.touched.channel_group_id &&
|
||||
Boolean(formik.errors.channel_group_id)
|
||||
}
|
||||
// helperText={formik.touched.channel_group_id && formik.errors.channel_group_id}
|
||||
variant="standard"
|
||||
>
|
||||
{channelGroups.map((option, index) => (
|
||||
<MenuItem key={index} value={option.id}>
|
||||
{option.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
<FormHelperText sx={{ color: 'error.main' }}>
|
||||
{formik.touched.channel_group_id &&
|
||||
formik.errors.channel_group_id}
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
</Grid2>
|
||||
<Grid2 size={1}>
|
||||
<IconButton
|
||||
color="success"
|
||||
onClick={() => setChannelGroupModalOpen(true)}
|
||||
title="Create new group"
|
||||
size="small"
|
||||
variant="filled"
|
||||
>
|
||||
<AddIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Grid2>
|
||||
</Grid2>
|
||||
|
||||
<FormControl variant="standard" fullWidth>
|
||||
<InputLabel id="stream-profile-label">
|
||||
Stream Profile
|
||||
</InputLabel>
|
||||
<Select
|
||||
labelId="stream-profile-label"
|
||||
id="stream_profile_id"
|
||||
name="stream_profile_id"
|
||||
value={formik.values.stream_profile_id}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
error={
|
||||
formik.touched.stream_profile_id &&
|
||||
Boolean(formik.errors.stream_profile_id)
|
||||
}
|
||||
// helperText={formik.touched.channel_group_id && formik.errors.stream_profile_id}
|
||||
variant="standard"
|
||||
>
|
||||
<MenuItem value="0" selected>
|
||||
<em>Use Default</em>
|
||||
</MenuItem>
|
||||
{streamProfiles.map((option, index) => (
|
||||
<MenuItem key={index} value={option.id}>
|
||||
{option.profile_name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
id="channel_number"
|
||||
name="channel_number"
|
||||
label="Channel #"
|
||||
value={formik.values.channel_number}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
error={
|
||||
formik.touched.channel_number &&
|
||||
Boolean(formik.errors.channel_number)
|
||||
}
|
||||
helperText={
|
||||
formik.touched.channel_number &&
|
||||
formik.errors.channel_number
|
||||
}
|
||||
variant="standard"
|
||||
/>
|
||||
</Grid2>
|
||||
|
||||
<Grid2 size={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
id="tvg_name"
|
||||
name="tvg_name"
|
||||
label="TVG Name"
|
||||
value={formik.values.tvg_name}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
error={
|
||||
formik.touched.tvg_name && Boolean(formik.errors.tvg_name)
|
||||
}
|
||||
helperText={formik.touched.tvg_name && formik.errors.tvg_name}
|
||||
variant="standard"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
id="tvg_id"
|
||||
name="tvg_id"
|
||||
label="TVG ID"
|
||||
value={formik.values.tvg_id}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
error={formik.touched.tvg_id && Boolean(formik.errors.tvg_id)}
|
||||
helperText={formik.touched.tvg_id && formik.errors.tvg_id}
|
||||
variant="standard"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
id="logo_url"
|
||||
name="logo_url"
|
||||
label="Logo URL (Optional)"
|
||||
variant="standard"
|
||||
sx={{ marginBottom: 2 }}
|
||||
value={formik.values.logo_url}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
helperText="If you have a direct image URL, set it here."
|
||||
/>
|
||||
|
||||
<Box mt={2} mb={2}>
|
||||
{/* File upload input */}
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography>Logo</Typography>
|
||||
{/* Display selected image */}
|
||||
<Box mb={2}>
|
||||
<img
|
||||
src={logoPreview}
|
||||
alt="Selected"
|
||||
style={{ maxWidth: 50, height: 'auto' }}
|
||||
/>
|
||||
</Box>
|
||||
<input
|
||||
type="file"
|
||||
id="logo"
|
||||
name="logo"
|
||||
accept="image/*"
|
||||
onChange={(event) => handleLogoChange(event)}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<label htmlFor="logo">
|
||||
<Button variant="contained" component="span" size="small">
|
||||
Browse...
|
||||
</Button>
|
||||
</label>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Grid2>
|
||||
</Grid2>
|
||||
|
||||
<Grid2 container spacing={2}>
|
||||
<Grid2 size={6}>
|
||||
<Typography>Active Streams</Typography>
|
||||
<MaterialReactTable table={activeStreamsTable} />
|
||||
</Grid2>
|
||||
|
||||
<Grid2 size={6}>
|
||||
<Typography>Available Streams</Typography>
|
||||
<MaterialReactTable table={availableStreamsTable} />
|
||||
</Grid2>
|
||||
</Grid2>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
{/* Submit button */}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={formik.isSubmitting}
|
||||
>
|
||||
{formik.isSubmitting ? <CircularProgress size={24} /> : 'Submit'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
</Dialog>
|
||||
<ChannelGroupForm
|
||||
isOpen={channelGroupModelOpen}
|
||||
onClose={() => setChannelGroupModalOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Channel;
|
||||
420
frontend/src/components/forms/Channel.jsx
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import useChannelsStore from '../../store/channels';
|
||||
import API from '../../api';
|
||||
import useStreamProfilesStore from '../../store/streamProfiles';
|
||||
import { Add as AddIcon, Remove as RemoveIcon } from '@mui/icons-material';
|
||||
import useStreamsStore from '../../store/streams';
|
||||
import { MantineReactTable, useMantineReactTable } from 'mantine-react-table';
|
||||
import ChannelGroupForm from './ChannelGroup';
|
||||
import usePlaylistsStore from '../../store/playlists';
|
||||
import logo from '../../images/logo.png';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Modal,
|
||||
TextInput,
|
||||
NativeSelect,
|
||||
Text,
|
||||
Group,
|
||||
ActionIcon,
|
||||
Center,
|
||||
Grid,
|
||||
Flex,
|
||||
} from '@mantine/core';
|
||||
import { SquarePlus } from 'lucide-react';
|
||||
|
||||
const Channel = ({ channel = null, isOpen, onClose }) => {
|
||||
const channelGroups = useChannelsStore((state) => state.channelGroups);
|
||||
const streams = useStreamsStore((state) => state.streams);
|
||||
const { profiles: streamProfiles } = useStreamProfilesStore();
|
||||
const { playlists } = usePlaylistsStore();
|
||||
|
||||
const [logoFile, setLogoFile] = useState(null);
|
||||
const [logoPreview, setLogoPreview] = useState(logo);
|
||||
const [channelStreams, setChannelStreams] = useState([]);
|
||||
const [channelGroupModelOpen, setChannelGroupModalOpen] = useState(false);
|
||||
|
||||
const addStream = (stream) => {
|
||||
const streamSet = new Set(channelStreams);
|
||||
streamSet.add(stream);
|
||||
setChannelStreams(Array.from(streamSet));
|
||||
};
|
||||
|
||||
const removeStream = (stream) => {
|
||||
const streamSet = new Set(channelStreams);
|
||||
streamSet.delete(stream);
|
||||
setChannelStreams(Array.from(streamSet));
|
||||
};
|
||||
|
||||
const handleLogoChange = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
setLogoFile(file);
|
||||
setLogoPreview(URL.createObjectURL(file));
|
||||
}
|
||||
};
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
channel_name: '',
|
||||
channel_number: '',
|
||||
channel_group_id: '',
|
||||
stream_profile_id: '0',
|
||||
tvg_id: '',
|
||||
tvg_name: '',
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
channel_name: Yup.string().required('Name is required'),
|
||||
channel_number: Yup.string().required('Invalid channel number').min(0),
|
||||
channel_group_id: Yup.string().required('Channel group is required'),
|
||||
}),
|
||||
onSubmit: async (values, { setSubmitting, resetForm }) => {
|
||||
if (values.stream_profile_id == '0') {
|
||||
values.stream_profile_id = null;
|
||||
}
|
||||
|
||||
console.log(values);
|
||||
if (channel?.id) {
|
||||
await API.updateChannel({
|
||||
id: channel.id,
|
||||
...values,
|
||||
logo_file: logoFile,
|
||||
streams: channelStreams.map((stream) => stream.id),
|
||||
});
|
||||
} else {
|
||||
await API.addChannel({
|
||||
...values,
|
||||
logo_file: logoFile,
|
||||
streams: channelStreams.map((stream) => stream.id),
|
||||
});
|
||||
}
|
||||
|
||||
resetForm();
|
||||
setLogoFile(null);
|
||||
setLogoPreview(logo);
|
||||
setSubmitting(false);
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (channel) {
|
||||
formik.setValues({
|
||||
channel_name: channel.channel_name,
|
||||
channel_number: channel.channel_number,
|
||||
channel_group_id: channel.channel_group?.id,
|
||||
stream_profile_id: channel.stream_profile_id || '0',
|
||||
tvg_id: channel.tvg_id,
|
||||
tvg_name: channel.tvg_name,
|
||||
});
|
||||
|
||||
console.log(channel);
|
||||
setChannelStreams(channel.streams);
|
||||
} else {
|
||||
formik.resetForm();
|
||||
}
|
||||
}, [channel]);
|
||||
|
||||
// const activeStreamsTable = useMantineReactTable({
|
||||
// data: channelStreams,
|
||||
// columns: useMemo(
|
||||
// () => [
|
||||
// {
|
||||
// header: 'Name',
|
||||
// accessorKey: 'name',
|
||||
// Cell: ({ cell }) => (
|
||||
// <div
|
||||
// style={{
|
||||
// whiteSpace: 'nowrap',
|
||||
// overflow: 'hidden',
|
||||
// textOverflow: 'ellipsis',
|
||||
// }}
|
||||
// >
|
||||
// {cell.getValue()}
|
||||
// </div>
|
||||
// ),
|
||||
// },
|
||||
// {
|
||||
// header: 'M3U',
|
||||
// accessorKey: 'group_name',
|
||||
// Cell: ({ cell }) => (
|
||||
// <div
|
||||
// style={{
|
||||
// whiteSpace: 'nowrap',
|
||||
// overflow: 'hidden',
|
||||
// textOverflow: 'ellipsis',
|
||||
// }}
|
||||
// >
|
||||
// {cell.getValue()}
|
||||
// </div>
|
||||
// ),
|
||||
// },
|
||||
// ],
|
||||
// []
|
||||
// ),
|
||||
// enableSorting: false,
|
||||
// enableBottomToolbar: false,
|
||||
// enableTopToolbar: false,
|
||||
// columnFilterDisplayMode: 'popover',
|
||||
// enablePagination: false,
|
||||
// enableRowVirtualization: true,
|
||||
// enableRowOrdering: true,
|
||||
// rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
|
||||
// initialState: {
|
||||
// density: 'compact',
|
||||
// },
|
||||
// enableRowActions: true,
|
||||
// positionActionsColumn: 'last',
|
||||
// renderRowActions: ({ row }) => (
|
||||
// <>
|
||||
// <IconButton
|
||||
// size="small" // Makes the button smaller
|
||||
// color="error" // Red color for delete actions
|
||||
// onClick={() => removeStream(row.original)}
|
||||
// >
|
||||
// <RemoveIcon fontSize="small" /> {/* Small icon size */}
|
||||
// </IconButton>
|
||||
// </>
|
||||
// ),
|
||||
// mantineTableContainerProps: {
|
||||
// style: {
|
||||
// height: '200px',
|
||||
// },
|
||||
// },
|
||||
// mantineRowDragHandleProps: ({ table }) => ({
|
||||
// onDragEnd: () => {
|
||||
// const { draggingRow, hoveredRow } = table.getState();
|
||||
|
||||
// if (hoveredRow && draggingRow) {
|
||||
// channelStreams.splice(
|
||||
// hoveredRow.index,
|
||||
// 0,
|
||||
// channelStreams.splice(draggingRow.index, 1)[0]
|
||||
// );
|
||||
|
||||
// setChannelStreams([...channelStreams]);
|
||||
// }
|
||||
// },
|
||||
// }),
|
||||
// });
|
||||
|
||||
// const availableStreamsTable = useMantineReactTable({
|
||||
// data: streams,
|
||||
// columns: useMemo(
|
||||
// () => [
|
||||
// {
|
||||
// header: 'Name',
|
||||
// accessorKey: 'name',
|
||||
// },
|
||||
// {
|
||||
// header: 'M3U',
|
||||
// accessorFn: (row) =>
|
||||
// playlists.find((playlist) => playlist.id === row.m3u_account)?.name,
|
||||
// },
|
||||
// ],
|
||||
// []
|
||||
// ),
|
||||
// enableBottomToolbar: false,
|
||||
// enableTopToolbar: false,
|
||||
// columnFilterDisplayMode: 'popover',
|
||||
// enablePagination: false,
|
||||
// enableRowVirtualization: true,
|
||||
// rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
|
||||
// initialState: {
|
||||
// density: 'compact',
|
||||
// },
|
||||
// enableRowActions: true,
|
||||
// renderRowActions: ({ row }) => (
|
||||
// <>
|
||||
// <IconButton
|
||||
// size="small" // Makes the button smaller
|
||||
// color="success" // Red color for delete actions
|
||||
// onClick={() => addStream(row.original)}
|
||||
// >
|
||||
// <AddIcon fontSize="small" /> {/* Small icon size */}
|
||||
// </IconButton>
|
||||
// </>
|
||||
// ),
|
||||
// positionActionsColumn: 'last',
|
||||
// mantineTableContainerProps: {
|
||||
// style: {
|
||||
// height: '200px',
|
||||
// },
|
||||
// },
|
||||
// });
|
||||
|
||||
if (!isOpen) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal opened={isOpen} onClose={onClose} size={800} title="Channel">
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<Grid gap={2}>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
id="channel_name"
|
||||
name="channel_name"
|
||||
label="Channel Name"
|
||||
value={formik.values.channel_name}
|
||||
onChange={formik.handleChange}
|
||||
error={
|
||||
formik.errors.channel_name ? formik.touched.channel_name : ''
|
||||
}
|
||||
/>
|
||||
|
||||
<Grid>
|
||||
<Grid.Col span={11}>
|
||||
<NativeSelect
|
||||
id="channel_group_id"
|
||||
name="channel_group_id"
|
||||
label="Channel Group"
|
||||
value={formik.values.channel_group_id}
|
||||
onChange={formik.handleChange}
|
||||
error={
|
||||
formik.errors.channel_group_id
|
||||
? formik.touched.channel_group_id
|
||||
: ''
|
||||
}
|
||||
data={channelGroups.map((option, index) => ({
|
||||
value: `${option.id}`,
|
||||
label: option.name,
|
||||
}))}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={1}>
|
||||
<ActionIcon
|
||||
color="green.5"
|
||||
onClick={() => setChannelGroupModalOpen(true)}
|
||||
title="Create new group"
|
||||
size="small"
|
||||
variant="light"
|
||||
style={{ marginTop: '175%' }} // @TODO: I don't like this, figure out better placement
|
||||
>
|
||||
<SquarePlus />
|
||||
</ActionIcon>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
<NativeSelect
|
||||
id="stream_profile_id"
|
||||
label="Stream Profile"
|
||||
name="stream_profile_id"
|
||||
value={formik.values.stream_profile_id}
|
||||
onChange={formik.handleChange}
|
||||
error={
|
||||
formik.errors.stream_profile_id
|
||||
? formik.touched.stream_profile_id
|
||||
: ''
|
||||
}
|
||||
data={streamProfiles.map((option) => ({
|
||||
value: `${option.id}`,
|
||||
label: option.profile_name,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
id="channel_number"
|
||||
name="channel_number"
|
||||
label="Channel #"
|
||||
value={formik.values.channel_number}
|
||||
onChange={formik.handleChange}
|
||||
error={
|
||||
formik.errors.channel_number
|
||||
? formik.touched.channel_number
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
id="tvg_name"
|
||||
name="tvg_name"
|
||||
label="TVG Name"
|
||||
value={formik.values.tvg_name}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.errors.tvg_name ? formik.touched.tvg_name : ''}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
id="tvg_id"
|
||||
name="tvg_id"
|
||||
label="TVG ID"
|
||||
value={formik.values.tvg_id}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.errors.tvg_id ? formik.touched.tvg_id : ''}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
id="logo_url"
|
||||
name="logo_url"
|
||||
label="Logo URL (Optional)"
|
||||
style={{ marginBottom: 2 }}
|
||||
value={formik.values.logo_url}
|
||||
onChange={formik.handleChange}
|
||||
/>
|
||||
|
||||
<Group style={{ paddingTop: 10 }}>
|
||||
<Text>Logo</Text>
|
||||
{/* Display selected image */}
|
||||
<Box>
|
||||
<img
|
||||
src={logoPreview}
|
||||
alt="Selected"
|
||||
style={{ maxWidth: 50, height: 'auto' }}
|
||||
/>
|
||||
</Box>
|
||||
<input
|
||||
type="file"
|
||||
id="logo"
|
||||
name="logo"
|
||||
accept="image/*"
|
||||
onChange={(event) => handleLogoChange(event)}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<label htmlFor="logo">
|
||||
<Button variant="contained" component="span" size="small">
|
||||
Browse...
|
||||
</Button>
|
||||
</label>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
{/* <Grid gap={2}>
|
||||
<Grid.Col span={6}>
|
||||
<Typography>Active Streams</Typography>
|
||||
<MantineReactTable table={activeStreamsTable} />
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={6}>
|
||||
<Typography>Available Streams</Typography>
|
||||
<MantineReactTable table={availableStreamsTable} />
|
||||
</Grid.Col>
|
||||
</Grid> */}
|
||||
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={formik.isSubmitting}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
</Modal>
|
||||
<ChannelGroupForm
|
||||
isOpen={channelGroupModelOpen}
|
||||
onClose={() => setChannelGroupModalOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Channel;
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
// Modal.js
|
||||
import React, { useEffect } from "react";
|
||||
import {
|
||||
TextField,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
} from "@mui/material";
|
||||
import { useFormik } from "formik";
|
||||
import * as Yup from "yup";
|
||||
import API from "../../api";
|
||||
|
||||
const ChannelGroup = ({ channelGroup = null, isOpen, onClose }) => {
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
name: "",
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
name: Yup.string().required("Name is required"),
|
||||
}),
|
||||
onSubmit: async (values, { setSubmitting, resetForm }) => {
|
||||
if (channelGroup?.id) {
|
||||
await API.updateChannelGroup({ id: channelGroup.id, ...values });
|
||||
} else {
|
||||
await API.addChannelGroup(values);
|
||||
}
|
||||
|
||||
resetForm();
|
||||
setSubmitting(false);
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (channelGroup) {
|
||||
formik.setValues({
|
||||
name: channelGroup.name,
|
||||
});
|
||||
} else {
|
||||
formik.resetForm();
|
||||
}
|
||||
}, [channelGroup]);
|
||||
|
||||
if (!isOpen) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onClose={onClose}>
|
||||
<DialogTitle
|
||||
sx={{
|
||||
backgroundColor: "primary.main",
|
||||
color: "primary.contrastText",
|
||||
}}
|
||||
>
|
||||
Channel Group
|
||||
</DialogTitle>
|
||||
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
fullWidth
|
||||
id="name"
|
||||
name="name"
|
||||
label="Name"
|
||||
value={formik.values.name}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
error={formik.touched.name && Boolean(formik.errors.name)}
|
||||
helperText={formik.touched.name && formik.errors.name}
|
||||
variant="standard"
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
{/* Submit button */}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={formik.isSubmitting}
|
||||
size="small"
|
||||
>
|
||||
{formik.isSubmitting ? <CircularProgress size={24} /> : "Submit"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelGroup;
|
||||
71
frontend/src/components/forms/ChannelGroup.jsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
// Modal.js
|
||||
import React, { useEffect } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import API from '../../api';
|
||||
import { Flex, TextInput, Button, Modal } from '@mantine/core';
|
||||
|
||||
const ChannelGroup = ({ channelGroup = null, isOpen, onClose }) => {
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
name: '',
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
name: Yup.string().required('Name is required'),
|
||||
}),
|
||||
onSubmit: async (values, { setSubmitting, resetForm }) => {
|
||||
if (channelGroup?.id) {
|
||||
await API.updateChannelGroup({ id: channelGroup.id, ...values });
|
||||
} else {
|
||||
await API.addChannelGroup(values);
|
||||
}
|
||||
|
||||
resetForm();
|
||||
setSubmitting(false);
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (channelGroup) {
|
||||
formik.setValues({
|
||||
name: channelGroup.name,
|
||||
});
|
||||
} else {
|
||||
formik.resetForm();
|
||||
}
|
||||
}, [channelGroup]);
|
||||
|
||||
if (!isOpen) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal opened={isOpen} onClose={onClose} title="Channel Group">
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<TextInput
|
||||
id="name"
|
||||
name="name"
|
||||
label="Name"
|
||||
value={formik.values.name}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.touched.name}
|
||||
/>
|
||||
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={formik.isSubmitting}
|
||||
size="small"
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelGroup;
|
||||
|
|
@ -1,175 +0,0 @@
|
|||
// Modal.js
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
TextField,
|
||||
Button,
|
||||
Select,
|
||||
MenuItem,
|
||||
InputLabel,
|
||||
FormControl,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
} from '@mui/material';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import API from '../../api';
|
||||
import useEPGsStore from '../../store/epgs';
|
||||
|
||||
const EPG = ({ epg = null, isOpen, onClose }) => {
|
||||
const epgs = useEPGsStore((state) => state.epgs);
|
||||
const [file, setFile] = useState(null);
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
setFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
name: '',
|
||||
source_type: '',
|
||||
url: '',
|
||||
api_key: '',
|
||||
is_active: true,
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
name: Yup.string().required('Name is required'),
|
||||
source_type: Yup.string().required('Source type is required'),
|
||||
}),
|
||||
onSubmit: async (values, { setSubmitting, resetForm }) => {
|
||||
if (epg?.id) {
|
||||
await API.updateEPG({ id: epg.id, ...values, epg_file: file });
|
||||
} else {
|
||||
await API.addEPG({
|
||||
...values,
|
||||
epg_file: file,
|
||||
});
|
||||
}
|
||||
|
||||
resetForm();
|
||||
setFile(null);
|
||||
setSubmitting(false);
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (epg) {
|
||||
formik.setValues({
|
||||
name: epg.name,
|
||||
source_type: epg.source_type,
|
||||
url: epg.url,
|
||||
api_key: epg.api_key,
|
||||
is_active: epg.is_active,
|
||||
});
|
||||
} else {
|
||||
formik.resetForm();
|
||||
}
|
||||
}, [epg]);
|
||||
|
||||
if (!isOpen) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onClose={onClose}>
|
||||
<DialogTitle
|
||||
sx={{
|
||||
backgroundColor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
}}
|
||||
>
|
||||
EPG Source
|
||||
</DialogTitle>
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
fullWidth
|
||||
id="name"
|
||||
name="name"
|
||||
label="Name"
|
||||
value={formik.values.name}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
error={formik.touched.name && Boolean(formik.errors.name)}
|
||||
helperText={formik.touched.name && formik.errors.name}
|
||||
variant="standard"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
id="url"
|
||||
name="url"
|
||||
label="URL"
|
||||
value={formik.values.url}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
error={formik.touched.url && Boolean(formik.errors.url)}
|
||||
helperText={formik.touched.url && formik.errors.url}
|
||||
variant="standard"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
id="api_key"
|
||||
name="api_key"
|
||||
label="API Key"
|
||||
value={formik.values.api_key}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
error={formik.touched.api_key && Boolean(formik.errors.api_key)}
|
||||
helperText={formik.touched.api_key && formik.errors.api_key}
|
||||
variant="standard"
|
||||
/>
|
||||
|
||||
<FormControl variant="standard" fullWidth>
|
||||
<InputLabel id="source-type-label">Source Type</InputLabel>
|
||||
<Select
|
||||
labelId="source-type-label"
|
||||
id="source_type"
|
||||
name="source_type"
|
||||
label="Source Type"
|
||||
value={formik.values.source_type}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
error={
|
||||
formik.touched.source_type && Boolean(formik.errors.source_type)
|
||||
}
|
||||
helperText={
|
||||
formik.touched.source_type && formik.errors.source_type
|
||||
}
|
||||
variant="standard"
|
||||
>
|
||||
<MenuItem key="0" value="xmltv">
|
||||
XMLTV
|
||||
</MenuItem>
|
||||
<MenuItem key="1" value="schedules_direct">
|
||||
Schedules Direct
|
||||
</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
{/* Submit button */}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={formik.isSubmitting}
|
||||
size="small"
|
||||
>
|
||||
{formik.isSubmitting ? <CircularProgress size={24} /> : 'Submit'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default EPG;
|
||||
143
frontend/src/components/forms/EPG.jsx
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
// Modal.js
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import API from '../../api';
|
||||
import useEPGsStore from '../../store/epgs';
|
||||
import {
|
||||
LoadingOverlay,
|
||||
TextInput,
|
||||
Button,
|
||||
Checkbox,
|
||||
Modal,
|
||||
Flex,
|
||||
NativeSelect,
|
||||
FileInput,
|
||||
Space,
|
||||
} from '@mantine/core';
|
||||
|
||||
const EPG = ({ epg = null, isOpen, onClose }) => {
|
||||
const epgs = useEPGsStore((state) => state.epgs);
|
||||
const [file, setFile] = useState(null);
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
setFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
name: '',
|
||||
source_type: 'xmltv',
|
||||
url: '',
|
||||
api_key: '',
|
||||
is_active: true,
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
name: Yup.string().required('Name is required'),
|
||||
source_type: Yup.string().required('Source type is required'),
|
||||
}),
|
||||
onSubmit: async (values, { setSubmitting, resetForm }) => {
|
||||
if (epg?.id) {
|
||||
await API.updateEPG({ id: epg.id, ...values, epg_file: file });
|
||||
} else {
|
||||
await API.addEPG({
|
||||
...values,
|
||||
epg_file: file,
|
||||
});
|
||||
}
|
||||
|
||||
resetForm();
|
||||
setFile(null);
|
||||
setSubmitting(false);
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (epg) {
|
||||
formik.setValues({
|
||||
name: epg.name,
|
||||
source_type: epg.source_type,
|
||||
url: epg.url,
|
||||
api_key: epg.api_key,
|
||||
is_active: epg.is_active,
|
||||
});
|
||||
} else {
|
||||
formik.resetForm();
|
||||
}
|
||||
}, [epg]);
|
||||
|
||||
if (!isOpen) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal opened={isOpen} onClose={onClose} title="EPG Source">
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<TextInput
|
||||
id="name"
|
||||
name="name"
|
||||
label="Name"
|
||||
value={formik.values.name}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.touched.name && Boolean(formik.errors.name)}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
id="url"
|
||||
name="url"
|
||||
label="URL"
|
||||
value={formik.values.url}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.touched.url && Boolean(formik.errors.url)}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
id="api_key"
|
||||
name="api_key"
|
||||
label="API Key"
|
||||
value={formik.values.api_key}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.touched.api_key && Boolean(formik.errors.api_key)}
|
||||
/>
|
||||
|
||||
<NativeSelect
|
||||
id="source_type"
|
||||
name="source_type"
|
||||
label="Source Type"
|
||||
value={formik.values.source_type}
|
||||
onChange={formik.handleChange}
|
||||
error={
|
||||
formik.touched.source_type && Boolean(formik.errors.source_type)
|
||||
}
|
||||
data={[
|
||||
{
|
||||
label: 'XMLTV',
|
||||
value: 'xmltv',
|
||||
},
|
||||
{
|
||||
label: 'Schedules Direct',
|
||||
value: 'schedules_direct',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={formik.isSubmitting}
|
||||
size="small"
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default EPG;
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import useAuthStore from '../../store/auth';
|
||||
import {
|
||||
Box,
|
||||
TextField,
|
||||
Button,
|
||||
Typography,
|
||||
Grid2,
|
||||
Paper,
|
||||
} from '@mui/material';
|
||||
|
||||
const LoginForm = () => {
|
||||
const { login, isAuthenticated, initData } = useAuthStore(); // Get login function from AuthContext
|
||||
const navigate = useNavigate(); // Hook to navigate to other routes
|
||||
const [formData, setFormData] = useState({ username: '', password: '' });
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
navigate('/channels');
|
||||
}
|
||||
}, [isAuthenticated, navigate]);
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
await login(formData);
|
||||
initData();
|
||||
navigate('/channels'); // Or any other route you'd like
|
||||
};
|
||||
|
||||
// // Handle form submission
|
||||
// const handleSubmit = async (e) => {
|
||||
// e.preventDefault();
|
||||
// setLoading(true);
|
||||
// setError(''); // Reset error on each new submission
|
||||
|
||||
// await login(username, password)
|
||||
// navigate('/channels'); // Or any other route you'd like
|
||||
// };
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
backgroundColor: '#f5f5f5',
|
||||
}}
|
||||
>
|
||||
<Paper elevation={3} sx={{ padding: 3, width: '100%', maxWidth: 400 }}>
|
||||
<Typography variant="h5" align="center" gutterBottom>
|
||||
Login
|
||||
</Typography>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Grid2
|
||||
container
|
||||
spacing={2}
|
||||
justifyContent="center"
|
||||
direction="column"
|
||||
>
|
||||
<Grid2 xs={12}>
|
||||
<TextField
|
||||
label="Username"
|
||||
variant="standard"
|
||||
fullWidth
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
size="small"
|
||||
/>
|
||||
</Grid2>
|
||||
<Grid2 xs={12}>
|
||||
<TextField
|
||||
label="Password"
|
||||
variant="standard"
|
||||
type="password"
|
||||
fullWidth
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
size="small"
|
||||
/>
|
||||
</Grid2>
|
||||
<Grid2 xs={12}>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
fullWidth
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</Grid2>
|
||||
</Grid2>
|
||||
</form>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginForm;
|
||||
93
frontend/src/components/forms/LoginForm.jsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import useAuthStore from '../../store/auth';
|
||||
import {
|
||||
Paper,
|
||||
Title,
|
||||
TextInput,
|
||||
Button,
|
||||
Checkbox,
|
||||
Modal,
|
||||
Box,
|
||||
Center,
|
||||
Stack,
|
||||
} from '@mantine/core';
|
||||
|
||||
const LoginForm = () => {
|
||||
const { login, isAuthenticated, initData } = useAuthStore(); // Get login function from AuthContext
|
||||
const navigate = useNavigate(); // Hook to navigate to other routes
|
||||
const [formData, setFormData] = useState({ username: '', password: '' });
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
navigate('/channels');
|
||||
}
|
||||
}, [isAuthenticated, navigate]);
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
await login(formData);
|
||||
initData();
|
||||
navigate('/channels'); // Or any other route you'd like
|
||||
};
|
||||
|
||||
// // Handle form submission
|
||||
// const handleSubmit = async (e) => {
|
||||
// e.preventDefault();
|
||||
// setLoading(true);
|
||||
// setError(''); // Reset error on each new submission
|
||||
|
||||
// await login(username, password)
|
||||
// navigate('/channels'); // Or any other route you'd like
|
||||
// };
|
||||
|
||||
return (
|
||||
<Center
|
||||
style={{
|
||||
height: '100vh',
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
elevation={3}
|
||||
style={{ padding: 30, width: '100%', maxWidth: 400 }}
|
||||
>
|
||||
<Title order={4} align="center">
|
||||
Login
|
||||
</Title>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack>
|
||||
<TextInput
|
||||
label="Username"
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Password"
|
||||
type="password"
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
/>
|
||||
|
||||
<Button type="submit" size="sm" sx={{ pt: 1 }}>
|
||||
Submit
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
</Center>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginForm;
|
||||
|
|
@ -1,246 +0,0 @@
|
|||
// Modal.js
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Stack,
|
||||
TextField,
|
||||
Button,
|
||||
Select,
|
||||
MenuItem,
|
||||
InputLabel,
|
||||
FormControl,
|
||||
CircularProgress,
|
||||
FormControlLabel,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
} from '@mui/material';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import API from '../../api';
|
||||
import useUserAgentsStore from '../../store/userAgents';
|
||||
import M3UProfiles from './M3UProfiles';
|
||||
|
||||
const M3U = ({ playlist = null, isOpen, onClose }) => {
|
||||
const userAgents = useUserAgentsStore((state) => state.userAgents);
|
||||
const [file, setFile] = useState(null);
|
||||
const [profileModalOpen, setProfileModalOpen] = useState(false);
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
setFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
name: '',
|
||||
server_url: '',
|
||||
user_agent: '',
|
||||
is_active: true,
|
||||
max_streams: 0,
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
name: Yup.string().required('Name is required'),
|
||||
server_url: Yup.string().required('Server URL is required'),
|
||||
user_agent: Yup.string().required('User-Agent is required'),
|
||||
max_streams: Yup.string().required('Max streams is required'),
|
||||
}),
|
||||
onSubmit: async (values, { setSubmitting, resetForm }) => {
|
||||
if (playlist?.id) {
|
||||
await API.updatePlaylist({
|
||||
id: playlist.id,
|
||||
...values,
|
||||
uploaded_file: file,
|
||||
});
|
||||
} else {
|
||||
await API.addPlaylist({
|
||||
...values,
|
||||
uploaded_file: file,
|
||||
});
|
||||
}
|
||||
|
||||
resetForm();
|
||||
setFile(null);
|
||||
setSubmitting(false);
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (playlist) {
|
||||
formik.setValues({
|
||||
name: playlist.name,
|
||||
server_url: playlist.server_url,
|
||||
max_streams: playlist.max_streams,
|
||||
user_agent: playlist.user_agent,
|
||||
is_active: playlist.is_active,
|
||||
});
|
||||
} else {
|
||||
formik.resetForm();
|
||||
}
|
||||
}, [playlist]);
|
||||
|
||||
if (!isOpen) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onClose={onClose}>
|
||||
<DialogTitle
|
||||
sx={{
|
||||
backgroundColor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
}}
|
||||
>
|
||||
M3U Account
|
||||
</DialogTitle>
|
||||
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
fullWidth
|
||||
id="name"
|
||||
name="name"
|
||||
label="Name"
|
||||
value={formik.values.name}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
error={formik.touched.name && Boolean(formik.errors.name)}
|
||||
helperText={formik.touched.name && formik.errors.name}
|
||||
variant="standard"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
id="server_url"
|
||||
name="server_url"
|
||||
label="URL"
|
||||
value={formik.values.server_url}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
error={
|
||||
formik.touched.server_url && Boolean(formik.errors.server_url)
|
||||
}
|
||||
helperText={formik.touched.server_url && formik.errors.server_url}
|
||||
variant="standard"
|
||||
/>
|
||||
|
||||
<Box mb={2}>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
pt: 2,
|
||||
}}
|
||||
>
|
||||
<Typography>File</Typography>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
id="uploaded_file"
|
||||
name="uploaded_file"
|
||||
accept="image/*"
|
||||
onChange={(event) => handleFileChange(event)}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<label htmlFor="uploaded_file">
|
||||
<Button variant="contained" component="span">
|
||||
Browse...
|
||||
</Button>
|
||||
</label>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
id="max_streams"
|
||||
name="max_streams"
|
||||
label="Max Streams (0 = unlimited)"
|
||||
value={formik.values.max_streams}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
error={
|
||||
formik.touched.max_streams && Boolean(formik.errors.max_streams)
|
||||
}
|
||||
helperText={formik.touched.max_streams && formik.errors.max_streams}
|
||||
variant="standard"
|
||||
/>
|
||||
|
||||
<FormControl variant="standard" fullWidth>
|
||||
<InputLabel id="user-agent-label">User-Agent</InputLabel>
|
||||
<Select
|
||||
labelId="user-agent-label"
|
||||
id="user_agent"
|
||||
name="user_agent"
|
||||
label="User-Agent"
|
||||
value={formik.values.user_agent}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
error={
|
||||
formik.touched.user_agent && Boolean(formik.errors.user_agent)
|
||||
}
|
||||
// helperText={formik.touched.user_agent && formik.errors.user_agent}
|
||||
variant="standard"
|
||||
>
|
||||
{userAgents.map((option, index) => (
|
||||
<MenuItem key={index} value={option.id}>
|
||||
{option.user_agent_name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
name="is_active"
|
||||
checked={formik.values.is_active}
|
||||
onChange={(e) =>
|
||||
formik.setFieldValue('is_active', e.target.checked)
|
||||
}
|
||||
/>
|
||||
}
|
||||
label="Is Active"
|
||||
/>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="small"
|
||||
onClick={() => setProfileModalOpen(true)}
|
||||
>
|
||||
Profiles
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={formik.isSubmitting}
|
||||
size="small"
|
||||
>
|
||||
{formik.isSubmitting ? <CircularProgress size={24} /> : 'Submit'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
|
||||
{playlist && (
|
||||
<M3UProfiles
|
||||
playlist={playlist}
|
||||
isOpen={profileModalOpen}
|
||||
onClose={() => setProfileModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default M3U;
|
||||
191
frontend/src/components/forms/M3U.jsx
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
// Modal.js
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import API from '../../api';
|
||||
import useUserAgentsStore from '../../store/userAgents';
|
||||
import M3UProfiles from './M3UProfiles';
|
||||
import {
|
||||
LoadingOverlay,
|
||||
TextInput,
|
||||
Button,
|
||||
Checkbox,
|
||||
Modal,
|
||||
Flex,
|
||||
NativeSelect,
|
||||
FileInput,
|
||||
Select,
|
||||
Space,
|
||||
} from '@mantine/core';
|
||||
|
||||
const M3U = ({ playlist = null, isOpen, onClose }) => {
|
||||
const userAgents = useUserAgentsStore((state) => state.userAgents);
|
||||
const [file, setFile] = useState(null);
|
||||
const [profileModalOpen, setProfileModalOpen] = useState(false);
|
||||
|
||||
const handleFileChange = (file) => {
|
||||
if (file) {
|
||||
setFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
name: '',
|
||||
server_url: '',
|
||||
user_agent: `${userAgents[0].id}`,
|
||||
is_active: true,
|
||||
max_streams: 0,
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
name: Yup.string().required('Name is required'),
|
||||
user_agent: Yup.string().required('User-Agent is required'),
|
||||
max_streams: Yup.string().required('Max streams is required'),
|
||||
}),
|
||||
onSubmit: async (values, { setSubmitting, resetForm }) => {
|
||||
if (playlist?.id) {
|
||||
await API.updatePlaylist({
|
||||
id: playlist.id,
|
||||
...values,
|
||||
uploaded_file: file,
|
||||
});
|
||||
} else {
|
||||
await API.addPlaylist({
|
||||
...values,
|
||||
uploaded_file: file,
|
||||
});
|
||||
}
|
||||
|
||||
resetForm();
|
||||
setFile(null);
|
||||
setSubmitting(false);
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (playlist) {
|
||||
formik.setValues({
|
||||
name: playlist.name,
|
||||
server_url: playlist.server_url,
|
||||
max_streams: playlist.max_streams,
|
||||
user_agent: playlist.user_agent,
|
||||
is_active: playlist.is_active,
|
||||
});
|
||||
} else {
|
||||
formik.resetForm();
|
||||
}
|
||||
}, [playlist]);
|
||||
|
||||
if (!isOpen) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal opened={isOpen} onClose={onClose} title="M3U Account">
|
||||
<div style={{ width: 400, position: 'relative' }}>
|
||||
<LoadingOverlay visible={formik.isSubmitting} overlayBlur={2} />
|
||||
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<TextInput
|
||||
fullWidth
|
||||
id="name"
|
||||
name="name"
|
||||
label="Name"
|
||||
value={formik.values.name}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.touched.name && Boolean(formik.errors.name)}
|
||||
helperText={formik.touched.name && formik.errors.name}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
fullWidth
|
||||
id="server_url"
|
||||
name="server_url"
|
||||
label="URL"
|
||||
value={formik.values.server_url}
|
||||
onChange={formik.handleChange}
|
||||
error={
|
||||
formik.touched.server_url && Boolean(formik.errors.server_url)
|
||||
}
|
||||
helperText={formik.touched.server_url && formik.errors.server_url}
|
||||
/>
|
||||
|
||||
<FileInput
|
||||
id="uploaded_file"
|
||||
label="Upload files"
|
||||
placeholder="Upload files"
|
||||
value={formik.uploaded_file}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
fullWidth
|
||||
id="max_streams"
|
||||
name="max_streams"
|
||||
label="Max Streams"
|
||||
placeholder="0 = Unlimited"
|
||||
value={formik.values.max_streams}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.errors.max_streams ? formik.touched.max_streams : ''}
|
||||
/>
|
||||
|
||||
<NativeSelect
|
||||
id="user_agent"
|
||||
name="user_agent"
|
||||
label="User-Agent"
|
||||
value={formik.values.user_agent}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.errors.user_agent ? formik.touched.user_agent : ''}
|
||||
data={userAgents.map((ua) => ({
|
||||
label: ua.user_agent_name,
|
||||
value: `${ua.id}`,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<Space h="md" />
|
||||
|
||||
<Checkbox
|
||||
label="Is Active"
|
||||
name="is_active"
|
||||
checked={formik.values.is_active}
|
||||
onChange={(e) =>
|
||||
formik.setFieldValue('is_active', e.target.checked)
|
||||
}
|
||||
/>
|
||||
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
{playlist && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="small"
|
||||
onClick={() => setProfileModalOpen(true)}
|
||||
>
|
||||
Profiles
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={formik.isSubmitting}
|
||||
size="small"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Flex>
|
||||
{playlist && (
|
||||
<M3UProfiles
|
||||
playlist={playlist}
|
||||
isOpen={profileModalOpen}
|
||||
onClose={() => setProfileModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default M3U;
|
||||
|
|
@ -1,195 +0,0 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
TextField,
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogActions,
|
||||
Button,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import API from '../../api';
|
||||
|
||||
const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
|
||||
const [searchPattern, setSearchPattern] = useState('');
|
||||
const [replacePattern, setReplacePattern] = useState('');
|
||||
|
||||
let regex;
|
||||
try {
|
||||
regex = new RegExp(searchPattern, 'g');
|
||||
} catch (e) {
|
||||
regex = null;
|
||||
}
|
||||
|
||||
const highlightedUrl = regex
|
||||
? m3u.server_url.replace(regex, (match) => `<mark>${match}</mark>`)
|
||||
: m3u.server_url;
|
||||
|
||||
const resultUrl = regex
|
||||
? m3u.server_url.replace(regex, replacePattern)
|
||||
: m3u.server_url;
|
||||
|
||||
const onSearchPatternUpdate = (e) => {
|
||||
formik.handleChange(e);
|
||||
setSearchPattern(e.target.value);
|
||||
};
|
||||
|
||||
const onReplacePatternUpdate = (e) => {
|
||||
formik.handleChange(e);
|
||||
setReplacePattern(e.target.value);
|
||||
};
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
name: '',
|
||||
max_streams: 0,
|
||||
search_pattern: '',
|
||||
replace_pattern: '',
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
name: Yup.string().required('Name is required'),
|
||||
search_pattern: Yup.string().required('Search pattern is required'),
|
||||
replace_pattern: Yup.string().required('Replace pattern is required'),
|
||||
}),
|
||||
onSubmit: async (values, { setSubmitting, resetForm }) => {
|
||||
console.log('submiting');
|
||||
if (profile?.id) {
|
||||
await API.updateM3UProfile(m3u.id, {
|
||||
id: profile.id,
|
||||
...values,
|
||||
});
|
||||
} else {
|
||||
await API.addM3UProfile(m3u.id, values);
|
||||
}
|
||||
|
||||
resetForm();
|
||||
setSubmitting(false);
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (profile) {
|
||||
setSearchPattern(profile.search_pattern);
|
||||
setReplacePattern(profile.replace_pattern);
|
||||
formik.setValues({
|
||||
name: profile.name,
|
||||
max_streams: profile.max_streams,
|
||||
search_pattern: profile.search_pattern,
|
||||
replace_pattern: profile.replace_pattern,
|
||||
});
|
||||
} else {
|
||||
formik.resetForm();
|
||||
}
|
||||
}, [profile]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onClose={onClose}>
|
||||
<DialogTitle
|
||||
sx={{ backgroundColor: 'primary.main', color: 'primary.contrastText' }}
|
||||
>
|
||||
M3U Profile
|
||||
</DialogTitle>
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
fullWidth
|
||||
id="name"
|
||||
name="name"
|
||||
label="Name"
|
||||
value={formik.values.name}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
error={formik.touched.name && Boolean(formik.errors.name)}
|
||||
helperText={formik.touched.name && formik.errors.name}
|
||||
variant="standard"
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
id="max_streams"
|
||||
name="max_streams"
|
||||
label="Max Streams"
|
||||
value={formik.values.max_streams}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
error={
|
||||
formik.touched.max_streams && Boolean(formik.errors.max_streams)
|
||||
}
|
||||
helperText={formik.touched.max_streams && formik.errors.max_streams}
|
||||
variant="standard"
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
id="search_pattern"
|
||||
name="search_pattern"
|
||||
label="Search Pattern (Regex)"
|
||||
value={searchPattern}
|
||||
onChange={onSearchPatternUpdate}
|
||||
onBlur={formik.handleBlur}
|
||||
error={
|
||||
formik.touched.search_pattern &&
|
||||
Boolean(formik.errors.search_pattern)
|
||||
}
|
||||
helperText={
|
||||
formik.touched.search_pattern && formik.errors.search_pattern
|
||||
}
|
||||
variant="standard"
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
id="replace_pattern"
|
||||
name="replace_pattern"
|
||||
label="Replace Pattern"
|
||||
value={replacePattern}
|
||||
onChange={onReplacePatternUpdate}
|
||||
onBlur={formik.handleBlur}
|
||||
error={
|
||||
formik.touched.replace_pattern &&
|
||||
Boolean(formik.errors.replace_pattern)
|
||||
}
|
||||
helperText={
|
||||
formik.touched.replace_pattern && formik.errors.replace_pattern
|
||||
}
|
||||
variant="standard"
|
||||
/>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={formik.isSubmitting}
|
||||
size="small"
|
||||
>
|
||||
{formik.isSubmitting ? <CircularProgress size={24} /> : 'Submit'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6">Search</Typography>
|
||||
<Typography
|
||||
dangerouslySetInnerHTML={{ __html: highlightedUrl }}
|
||||
sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6">Replace</Typography>
|
||||
<Typography>{resultUrl}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegexFormAndView;
|
||||
158
frontend/src/components/forms/M3UProfile.jsx
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import API from '../../api';
|
||||
import {
|
||||
Flex,
|
||||
Modal,
|
||||
TextInput,
|
||||
Button,
|
||||
Title,
|
||||
Text,
|
||||
Paper,
|
||||
} from '@mantine/core';
|
||||
|
||||
const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
|
||||
const [searchPattern, setSearchPattern] = useState('');
|
||||
const [replacePattern, setReplacePattern] = useState('');
|
||||
|
||||
let regex;
|
||||
try {
|
||||
regex = new RegExp(searchPattern, 'g');
|
||||
} catch (e) {
|
||||
regex = null;
|
||||
}
|
||||
|
||||
const highlightedUrl = regex
|
||||
? m3u.server_url.replace(regex, (match) => `<mark>${match}</mark>`)
|
||||
: m3u.server_url;
|
||||
|
||||
const resultUrl =
|
||||
regex && replacePattern
|
||||
? m3u.server_url.replace(regex, replacePattern)
|
||||
: m3u.server_url;
|
||||
|
||||
const onSearchPatternUpdate = (e) => {
|
||||
formik.handleChange(e);
|
||||
setSearchPattern(e.target.value);
|
||||
};
|
||||
|
||||
const onReplacePatternUpdate = (e) => {
|
||||
formik.handleChange(e);
|
||||
setReplacePattern(e.target.value);
|
||||
};
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
name: '',
|
||||
max_streams: 0,
|
||||
search_pattern: '',
|
||||
replace_pattern: '',
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
name: Yup.string().required('Name is required'),
|
||||
search_pattern: Yup.string().required('Search pattern is required'),
|
||||
replace_pattern: Yup.string().required('Replace pattern is required'),
|
||||
}),
|
||||
onSubmit: async (values, { setSubmitting, resetForm }) => {
|
||||
console.log('submiting');
|
||||
if (profile?.id) {
|
||||
await API.updateM3UProfile(m3u.id, {
|
||||
id: profile.id,
|
||||
...values,
|
||||
});
|
||||
} else {
|
||||
await API.addM3UProfile(m3u.id, values);
|
||||
}
|
||||
|
||||
resetForm();
|
||||
setSubmitting(false);
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (profile) {
|
||||
setSearchPattern(profile.search_pattern);
|
||||
setReplacePattern(profile.replace_pattern);
|
||||
formik.setValues({
|
||||
name: profile.name,
|
||||
max_streams: profile.max_streams,
|
||||
search_pattern: profile.search_pattern,
|
||||
replace_pattern: profile.replace_pattern,
|
||||
});
|
||||
} else {
|
||||
formik.resetForm();
|
||||
}
|
||||
}, [profile]);
|
||||
|
||||
return (
|
||||
<Modal opened={isOpen} onClose={onClose} title="M3U Profile">
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<TextInput
|
||||
id="name"
|
||||
name="name"
|
||||
label="Name"
|
||||
value={formik.values.name}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.errors.name ? formik.touched.name : ''}
|
||||
/>
|
||||
<TextInput
|
||||
id="max_streams"
|
||||
name="max_streams"
|
||||
label="Max Streams"
|
||||
value={formik.values.max_streams}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.errors.max_streams ? formik.touched.max_streams : ''}
|
||||
/>
|
||||
<TextInput
|
||||
id="search_pattern"
|
||||
name="search_pattern"
|
||||
label="Search Pattern (Regex)"
|
||||
value={searchPattern}
|
||||
onChange={onSearchPatternUpdate}
|
||||
error={
|
||||
formik.errors.search_pattern ? formik.touched.search_pattern : ''
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
id="replace_pattern"
|
||||
name="replace_pattern"
|
||||
label="Replace Pattern"
|
||||
value={replacePattern}
|
||||
onChange={onReplacePatternUpdate}
|
||||
error={
|
||||
formik.errors.replace_pattern ? formik.touched.replace_pattern : ''
|
||||
}
|
||||
/>
|
||||
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={formik.isSubmitting}
|
||||
size="small"
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
|
||||
<Paper shadow="sm" p="md" radius="md" withBorder>
|
||||
<Text>Search</Text>
|
||||
<Text
|
||||
dangerouslySetInnerHTML={{ __html: highlightedUrl }}
|
||||
sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<Paper p="md" withBorder>
|
||||
<Text>Replace</Text>
|
||||
<Text>{resultUrl}</Text>
|
||||
</Paper>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegexFormAndView;
|
||||
|
|
@ -1,133 +0,0 @@
|
|||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
Typography,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogActions,
|
||||
Button,
|
||||
Box,
|
||||
Switch,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
} from '@mui/material';
|
||||
import API from '../../api';
|
||||
import M3UProfile from './M3UProfile';
|
||||
import { Delete as DeleteIcon, Edit as EditIcon } from '@mui/icons-material';
|
||||
import usePlaylistsStore from '../../store/playlists';
|
||||
|
||||
const M3UProfiles = ({ playlist = null, isOpen, onClose }) => {
|
||||
const profiles = usePlaylistsStore((state) => state.profiles[playlist.id]);
|
||||
const [profileEditorOpen, setProfileEditorOpen] = useState(false);
|
||||
const [profile, setProfile] = useState(null);
|
||||
|
||||
const editProfile = (profile = null) => {
|
||||
if (profile) {
|
||||
setProfile(profile);
|
||||
}
|
||||
|
||||
setProfileEditorOpen(true);
|
||||
};
|
||||
|
||||
const deleteProfile = async (id) => {
|
||||
await API.deleteM3UProfile(playlist.id, id);
|
||||
};
|
||||
|
||||
const toggleActive = async (values) => {
|
||||
await API.updateM3UProfile(playlist.id, {
|
||||
...values,
|
||||
is_active: !values.is_active,
|
||||
});
|
||||
};
|
||||
|
||||
const closeEditor = () => {
|
||||
setProfile(null);
|
||||
setProfileEditorOpen(false);
|
||||
};
|
||||
|
||||
if (!isOpen || !profiles) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onClose={onClose}>
|
||||
<DialogTitle
|
||||
sx={{
|
||||
backgroundColor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
}}
|
||||
>
|
||||
Profiles
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<List>
|
||||
{profiles
|
||||
.filter((playlist) => playlist.is_default == false)
|
||||
.map((item) => (
|
||||
<ListItem
|
||||
key={item.id}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
marginBottom: 2,
|
||||
}}
|
||||
>
|
||||
<ListItemText
|
||||
primary={item.name}
|
||||
secondary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Typography variant="body2" sx={{ marginRight: 2 }}>
|
||||
Max Streams: {item.max_streams}
|
||||
</Typography>
|
||||
<Switch
|
||||
checked={item.is_active}
|
||||
onChange={() => toggleActive(item)}
|
||||
color="primary"
|
||||
inputProps={{ 'aria-label': 'active switch' }}
|
||||
/>
|
||||
<IconButton
|
||||
onClick={() => editProfile(item)}
|
||||
color="warning"
|
||||
>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={() => deleteProfile(item.id)}
|
||||
color="error"
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="small"
|
||||
onClick={editProfile}
|
||||
>
|
||||
New
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<M3UProfile
|
||||
m3u={playlist}
|
||||
profile={profile}
|
||||
isOpen={profileEditorOpen}
|
||||
onClose={closeEditor}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default M3UProfiles;
|
||||
107
frontend/src/components/forms/M3UProfiles.jsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import React, { useState, useMemo } from 'react';
|
||||
import API from '../../api';
|
||||
import M3UProfile from './M3UProfile';
|
||||
import { Delete as DeleteIcon, Edit as EditIcon } from '@mui/icons-material';
|
||||
import usePlaylistsStore from '../../store/playlists';
|
||||
import {
|
||||
Card,
|
||||
Checkbox,
|
||||
Flex,
|
||||
Modal,
|
||||
Button,
|
||||
Box,
|
||||
ActionIcon,
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
|
||||
const M3UProfiles = ({ playlist = null, isOpen, onClose }) => {
|
||||
const profiles = usePlaylistsStore((state) => state.profiles[playlist.id]);
|
||||
const [profileEditorOpen, setProfileEditorOpen] = useState(false);
|
||||
const [profile, setProfile] = useState(null);
|
||||
|
||||
const editProfile = (profile = null) => {
|
||||
if (profile) {
|
||||
setProfile(profile);
|
||||
}
|
||||
|
||||
setProfileEditorOpen(true);
|
||||
};
|
||||
|
||||
const deleteProfile = async (id) => {
|
||||
await API.deleteM3UProfile(playlist.id, id);
|
||||
};
|
||||
|
||||
const toggleActive = async (values) => {
|
||||
await API.updateM3UProfile(playlist.id, {
|
||||
...values,
|
||||
is_active: !values.is_active,
|
||||
});
|
||||
};
|
||||
|
||||
const closeEditor = () => {
|
||||
setProfile(null);
|
||||
setProfileEditorOpen(false);
|
||||
};
|
||||
|
||||
if (!isOpen || !profiles) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal opened={isOpen} onClose={onClose} title="Profiles">
|
||||
{profiles
|
||||
.filter((playlist) => playlist.is_default == false)
|
||||
.map((item) => (
|
||||
<Card
|
||||
// key={item.id}
|
||||
// sx={{
|
||||
// display: 'flex',
|
||||
// alignItems: 'center',
|
||||
// marginBottom: 2,
|
||||
// }}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Text>Max Streams: {item.max_streams}</Text>
|
||||
<Checkbox
|
||||
label="Is Active"
|
||||
checked={item.is_active}
|
||||
onChange={() => toggleActive(item)}
|
||||
color="primary"
|
||||
/>
|
||||
<ActionIcon onClick={() => editProfile(item)} color="yellow.5">
|
||||
<EditIcon />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
onClick={() => deleteProfile(item.id)}
|
||||
color="error"
|
||||
>
|
||||
<DeleteIcon />
|
||||
</ActionIcon>
|
||||
</Box>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="small"
|
||||
onClick={editProfile}
|
||||
>
|
||||
New
|
||||
</Button>
|
||||
</Flex>
|
||||
</Modal>
|
||||
|
||||
<M3UProfile
|
||||
m3u={playlist}
|
||||
profile={profile}
|
||||
isOpen={profileEditorOpen}
|
||||
onClose={closeEditor}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default M3UProfiles;
|
||||
|
|
@ -1,151 +0,0 @@
|
|||
// Modal.js
|
||||
import React, { useEffect } from 'react';
|
||||
import {
|
||||
TextField,
|
||||
Button,
|
||||
Select,
|
||||
MenuItem,
|
||||
Grid2,
|
||||
InputLabel,
|
||||
FormControl,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
} from '@mui/material';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import API from '../../api';
|
||||
import useStreamProfilesStore from '../../store/streamProfiles';
|
||||
|
||||
const Stream = ({ stream = null, isOpen, onClose }) => {
|
||||
const streamProfiles = useStreamProfilesStore((state) => state.profiles);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
name: '',
|
||||
url: '',
|
||||
stream_profile_id: '',
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
name: Yup.string().required('Name is required'),
|
||||
url: Yup.string().required('URL is required').min(0),
|
||||
// stream_profile_id: Yup.string().required('Stream profile is required'),
|
||||
}),
|
||||
onSubmit: async (values, { setSubmitting, resetForm }) => {
|
||||
if (stream?.id) {
|
||||
await API.updateStream({ id: stream.id, ...values });
|
||||
} else {
|
||||
await API.addStream(values);
|
||||
}
|
||||
|
||||
resetForm();
|
||||
setSubmitting(false);
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (stream) {
|
||||
formik.setValues({
|
||||
name: stream.name,
|
||||
url: stream.url,
|
||||
stream_profile_id: stream.stream_profile_id,
|
||||
});
|
||||
} else {
|
||||
formik.resetForm();
|
||||
}
|
||||
}, [stream]);
|
||||
|
||||
if (!isOpen) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onClose={onClose}>
|
||||
<DialogTitle
|
||||
sx={{
|
||||
backgroundColor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
}}
|
||||
>
|
||||
Stream
|
||||
</DialogTitle>
|
||||
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<DialogContent>
|
||||
<Grid2 container spacing={2}>
|
||||
<Grid2 size={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
id="name"
|
||||
name="name"
|
||||
label="Stream Name"
|
||||
value={formik.values.name}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
error={formik.touched.name && Boolean(formik.errors.name)}
|
||||
helperText={formik.touched.name && formik.errors.name}
|
||||
variant="standard"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
id="url"
|
||||
name="url"
|
||||
label="Stream URL"
|
||||
value={formik.values.url}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
error={formik.touched.url && Boolean(formik.errors.url)}
|
||||
helperText={formik.touched.url && formik.errors.url}
|
||||
variant="standard"
|
||||
/>
|
||||
|
||||
<FormControl variant="standard" fullWidth>
|
||||
<InputLabel id="stream-profile-label">
|
||||
Stream Profile
|
||||
</InputLabel>
|
||||
<Select
|
||||
labelId="stream-profile-label"
|
||||
id="stream_profile_id"
|
||||
name="stream_profile_id"
|
||||
label="Stream Profile (optional)"
|
||||
value={formik.values.stream_profile_id}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
error={
|
||||
formik.touched.stream_profile_id &&
|
||||
Boolean(formik.errors.stream_profile_id)
|
||||
}
|
||||
// helperText={formik.touched.channel_group_id && formik.errors.stream_profile_id}
|
||||
variant="standard"
|
||||
>
|
||||
{streamProfiles.map((option, index) => (
|
||||
<MenuItem key={index} value={option.id}>
|
||||
{option.profile_name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid2>
|
||||
</Grid2>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={formik.isSubmitting}
|
||||
>
|
||||
{formik.isSubmitting ? <CircularProgress size={24} /> : 'Submit'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default Stream;
|
||||
115
frontend/src/components/forms/Stream.jsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
// Modal.js
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import API from '../../api';
|
||||
import useStreamProfilesStore from '../../store/streamProfiles';
|
||||
import { Modal, TextInput, Select, Button, Flex } from '@mantine/core';
|
||||
|
||||
const Stream = ({ stream = null, isOpen, onClose }) => {
|
||||
const streamProfiles = useStreamProfilesStore((state) => state.profiles);
|
||||
const [selectedStreamProfile, setSelectedStreamProfile] = useState('');
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
name: '',
|
||||
url: '',
|
||||
group_name: '',
|
||||
stream_profile_id: '',
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
name: Yup.string().required('Name is required'),
|
||||
url: Yup.string().required('URL is required').min(0),
|
||||
// stream_profile_id: Yup.string().required('Stream profile is required'),
|
||||
}),
|
||||
onSubmit: async (values, { setSubmitting, resetForm }) => {
|
||||
if (stream?.id) {
|
||||
await API.updateStream({ id: stream.id, ...values });
|
||||
} else {
|
||||
await API.addStream(values);
|
||||
}
|
||||
|
||||
resetForm();
|
||||
setSubmitting(false);
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (stream) {
|
||||
formik.setValues({
|
||||
name: stream.name,
|
||||
url: stream.url,
|
||||
group_name: stream.group_name,
|
||||
stream_profile_id: stream.stream_profile_id,
|
||||
});
|
||||
} else {
|
||||
formik.resetForm();
|
||||
}
|
||||
}, [stream]);
|
||||
|
||||
if (!isOpen) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal opened={isOpen} onClose={onClose} title="Stream" zIndex={10}>
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<TextInput
|
||||
id="name"
|
||||
name="name"
|
||||
label="Stream Name"
|
||||
value={formik.values.name}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.errors.name}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
id="url"
|
||||
name="url"
|
||||
label="Stream URL"
|
||||
value={formik.values.url}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.errors.url}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
id="group_name"
|
||||
name="group_name"
|
||||
label="Group"
|
||||
value={formik.values.group_name}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.errors.group_name}
|
||||
/>
|
||||
|
||||
<Select
|
||||
id="stream_profile_id"
|
||||
name="stream_profile_id"
|
||||
label="Stream Profile"
|
||||
placeholder="Optional"
|
||||
value={selectedStreamProfile}
|
||||
onChange={setSelectedStreamProfile}
|
||||
error={formik.errors.stream_profile_id}
|
||||
data={streamProfiles.map((profile) => ({
|
||||
label: profile.profile_name,
|
||||
value: `${profile.id}`,
|
||||
}))}
|
||||
comboboxProps={{ withinPortal: false, zIndex: 1000 }}
|
||||
/>
|
||||
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={formik.isSubmitting}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default Stream;
|
||||
|
|
@ -1,165 +0,0 @@
|
|||
// Modal.js
|
||||
import React, { useEffect } from "react";
|
||||
import {
|
||||
TextField,
|
||||
Button,
|
||||
Select,
|
||||
MenuItem,
|
||||
InputLabel,
|
||||
FormControl,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
} from "@mui/material";
|
||||
import { useFormik } from "formik";
|
||||
import * as Yup from "yup";
|
||||
import API from "../../api";
|
||||
import useUserAgentsStore from "../../store/userAgents";
|
||||
|
||||
const StreamProfile = ({ profile = null, isOpen, onClose }) => {
|
||||
const userAgents = useUserAgentsStore((state) => state.userAgents);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
profile_name: "",
|
||||
command: "",
|
||||
parameters: "",
|
||||
is_active: true,
|
||||
user_agent: "",
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
profile_name: Yup.string().required("Name is required"),
|
||||
command: Yup.string().required("Command is required"),
|
||||
parameters: Yup.string().required("Parameters are is required"),
|
||||
}),
|
||||
onSubmit: async (values, { setSubmitting, resetForm }) => {
|
||||
if (profile?.id) {
|
||||
await API.updateStreamProfile({ id: profile.id, ...values });
|
||||
} else {
|
||||
await API.addStreamProfile(values);
|
||||
}
|
||||
|
||||
resetForm();
|
||||
setSubmitting(false);
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (profile) {
|
||||
formik.setValues({
|
||||
profile_name: profile.profile_name,
|
||||
command: profile.command,
|
||||
parameters: profile.parameters,
|
||||
is_active: profile.is_active,
|
||||
user_agent: profile.user_agent,
|
||||
});
|
||||
} else {
|
||||
formik.resetForm();
|
||||
}
|
||||
}, [profile]);
|
||||
|
||||
if (!isOpen) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onClose={onClose}>
|
||||
<DialogTitle
|
||||
sx={{
|
||||
backgroundColor: "primary.main",
|
||||
color: "primary.contrastText",
|
||||
}}
|
||||
>
|
||||
Stream Profile
|
||||
</DialogTitle>
|
||||
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
fullWidth
|
||||
id="profile_name"
|
||||
name="profile_name"
|
||||
label="Name"
|
||||
value={formik.values.profile_name}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
error={
|
||||
formik.touched.profile_name && Boolean(formik.errors.profile_name)
|
||||
}
|
||||
helperText={
|
||||
formik.touched.profile_name && formik.errors.profile_name
|
||||
}
|
||||
variant="standard"
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
id="command"
|
||||
name="command"
|
||||
label="Command"
|
||||
value={formik.values.command}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
error={formik.touched.command && Boolean(formik.errors.command)}
|
||||
helperText={formik.touched.command && formik.errors.command}
|
||||
variant="standard"
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
id="parameters"
|
||||
name="parameters"
|
||||
label="Parameters"
|
||||
value={formik.values.parameters}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
error={
|
||||
formik.touched.parameters && Boolean(formik.errors.parameters)
|
||||
}
|
||||
helperText={formik.touched.parameters && formik.errors.parameters}
|
||||
variant="standard"
|
||||
/>
|
||||
|
||||
<FormControl variant="standard" fullWidth>
|
||||
<InputLabel id="channel-group-label">User-Agent</InputLabel>
|
||||
<Select
|
||||
labelId="channel-group-label"
|
||||
id="user_agent"
|
||||
name="user_agent"
|
||||
label="User-Agent"
|
||||
value={formik.values.user_agent}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
error={
|
||||
formik.touched.user_agent && Boolean(formik.errors.user_agent)
|
||||
}
|
||||
// helperText={formik.touched.user_agent && formik.errors.user_agent}
|
||||
variant="standard"
|
||||
>
|
||||
{userAgents.map((option, index) => (
|
||||
<MenuItem key={index} value={option.id}>
|
||||
{option.user_agent_name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={formik.isSubmitting}
|
||||
size="small"
|
||||
>
|
||||
{formik.isSubmitting ? <CircularProgress size={24} /> : "Submit"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default StreamProfile;
|
||||
113
frontend/src/components/forms/StreamProfile.jsx
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
// Modal.js
|
||||
import React, { useEffect } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import API from '../../api';
|
||||
import useUserAgentsStore from '../../store/userAgents';
|
||||
import { Modal, TextInput, Select, Button, Flex } from '@mantine/core';
|
||||
|
||||
const StreamProfile = ({ profile = null, isOpen, onClose }) => {
|
||||
const userAgents = useUserAgentsStore((state) => state.userAgents);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
profile_name: '',
|
||||
command: '',
|
||||
parameters: '',
|
||||
is_active: true,
|
||||
user_agent: '',
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
profile_name: Yup.string().required('Name is required'),
|
||||
command: Yup.string().required('Command is required'),
|
||||
parameters: Yup.string().required('Parameters are is required'),
|
||||
}),
|
||||
onSubmit: async (values, { setSubmitting, resetForm }) => {
|
||||
if (profile?.id) {
|
||||
await API.updateStreamProfile({ id: profile.id, ...values });
|
||||
} else {
|
||||
await API.addStreamProfile(values);
|
||||
}
|
||||
|
||||
resetForm();
|
||||
setSubmitting(false);
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (profile) {
|
||||
formik.setValues({
|
||||
profile_name: profile.profile_name,
|
||||
command: profile.command,
|
||||
parameters: profile.parameters,
|
||||
is_active: profile.is_active,
|
||||
user_agent: profile.user_agent,
|
||||
});
|
||||
} else {
|
||||
formik.resetForm();
|
||||
}
|
||||
}, [profile]);
|
||||
|
||||
if (!isOpen) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal opened={isOpen} onClose={onClose} title="Stream Profile">
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<TextInput
|
||||
id="profile_name"
|
||||
name="profile_name"
|
||||
label="Name"
|
||||
value={formik.values.profile_name}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.errors.profile_name}
|
||||
/>
|
||||
<TextInput
|
||||
id="command"
|
||||
name="command"
|
||||
label="Command"
|
||||
value={formik.values.command}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.errors.command}
|
||||
/>
|
||||
<TextInput
|
||||
id="parameters"
|
||||
name="parameters"
|
||||
label="Parameters"
|
||||
value={formik.values.parameters}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.errors.parameters}
|
||||
/>
|
||||
|
||||
<Select
|
||||
id="user_agent"
|
||||
name="user_agent"
|
||||
label="User-Agent"
|
||||
value={formik.values.user_agent}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.errors.user_agent}
|
||||
data={userAgents.map((ua) => ({
|
||||
label: ua.user_agent_name,
|
||||
value: `${ua.id}`,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={formik.isSubmitting}
|
||||
size="small"
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default StreamProfile;
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
// frontend/src/components/forms/SuperuserForm.js
|
||||
import React, { useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
Grid2,
|
||||
TextField,
|
||||
Button,
|
||||
} from '@mui/material';
|
||||
|
||||
function SuperuserForm({ onSuccess }) {
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
password: '',
|
||||
email: '',
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleChange = (e) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[e.target.name]: e.target.value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const res = await axios.post('/api/accounts/initialize-superuser/', {
|
||||
username: formData.username,
|
||||
password: formData.password,
|
||||
email: formData.email,
|
||||
});
|
||||
if (res.data.superuser_exists) {
|
||||
onSuccess();
|
||||
}
|
||||
} catch (err) {
|
||||
let msg = 'Failed to create superuser.';
|
||||
if (err.response && err.response.data && err.response.data.error) {
|
||||
msg += ` ${err.response.data.error}`;
|
||||
}
|
||||
setError(msg);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
backgroundColor: '#f5f5f5',
|
||||
}}
|
||||
>
|
||||
<Paper elevation={3} sx={{ padding: 3, width: '100%', maxWidth: 400 }}>
|
||||
<Typography variant="h5" align="center" gutterBottom>
|
||||
Create your Super User Account
|
||||
</Typography>
|
||||
{error && (
|
||||
<Typography variant="body2" color="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Grid2
|
||||
container
|
||||
spacing={2}
|
||||
justifyContent="center"
|
||||
direction="column"
|
||||
>
|
||||
<Grid2 xs={12}>
|
||||
<TextField
|
||||
label="Username"
|
||||
variant="standard"
|
||||
fullWidth
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleChange}
|
||||
required
|
||||
size="small"
|
||||
/>
|
||||
</Grid2>
|
||||
<Grid2 xs={12}>
|
||||
<TextField
|
||||
label="Password"
|
||||
variant="standard"
|
||||
type="password"
|
||||
fullWidth
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
required
|
||||
size="small"
|
||||
/>
|
||||
</Grid2>
|
||||
<Grid2 xs={12}>
|
||||
<TextField
|
||||
label="Email (optional)"
|
||||
variant="standard"
|
||||
type="email"
|
||||
fullWidth
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
size="small"
|
||||
/>
|
||||
</Grid2>
|
||||
<Grid2 xs={12}>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
fullWidth
|
||||
>
|
||||
Create Superuser
|
||||
</Button>
|
||||
</Grid2>
|
||||
</Grid2>
|
||||
</form>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default SuperuserForm;
|
||||
94
frontend/src/components/forms/SuperuserForm.jsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
// frontend/src/components/forms/SuperuserForm.js
|
||||
import React, { useState } from 'react';
|
||||
import { TextInput, Center, Button, Paper, Title, Stack } from '@mantine/core';
|
||||
import API from '../../api';
|
||||
import useAuthStore from '../../store/auth';
|
||||
|
||||
function SuperuserForm({}) {
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
password: '',
|
||||
email: '',
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
const { setSuperuserExists } = useAuthStore();
|
||||
|
||||
const handleChange = (e) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[e.target.name]: e.target.value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
console.log(formData);
|
||||
const response = await API.createSuperUser({
|
||||
username: formData.username,
|
||||
password: formData.password,
|
||||
email: formData.email,
|
||||
});
|
||||
if (response.superuser_exists) {
|
||||
setSuperuserExists(true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
// let msg = 'Failed to create superuser.';
|
||||
// if (err.response && err.response.data && err.response.data.error) {
|
||||
// msg += ` ${err.response.data.error}`;
|
||||
// }
|
||||
// setError(msg);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Center
|
||||
style={{
|
||||
height: '100vh',
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
elevation={3}
|
||||
style={{ padding: 30, width: '100%', maxWidth: 400 }}
|
||||
>
|
||||
<Title order={4} align="center">
|
||||
Create your Super User Account
|
||||
</Title>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack>
|
||||
<TextInput
|
||||
label="Username"
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
label="Password"
|
||||
type="password"
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Email (optional)"
|
||||
type="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
|
||||
<Button type="submit" size="sm" sx={{ pt: 1 }}>
|
||||
Submit
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
export default SuperuserForm;
|
||||
|
|
@ -1,144 +0,0 @@
|
|||
// Modal.js
|
||||
import React, { useEffect } from 'react';
|
||||
import {
|
||||
TextField,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
} from '@mui/material';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import API from '../../api';
|
||||
import useSettingsStore from '../../store/settings';
|
||||
|
||||
const UserAgent = ({ userAgent = null, isOpen, onClose }) => {
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
user_agent_name: '',
|
||||
user_agent: '',
|
||||
description: '',
|
||||
is_active: true,
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
user_agent_name: Yup.string().required('Name is required'),
|
||||
user_agent: Yup.string().required('User-Agent is required'),
|
||||
}),
|
||||
onSubmit: async (values, { setSubmitting, resetForm }) => {
|
||||
if (userAgent?.id) {
|
||||
await API.updateUserAgent({ id: userAgent.id, ...values });
|
||||
} else {
|
||||
await API.addUserAgent(values);
|
||||
}
|
||||
|
||||
resetForm();
|
||||
setSubmitting(false);
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (userAgent) {
|
||||
formik.setValues({
|
||||
user_agent_name: userAgent.user_agent_name,
|
||||
user_agent: userAgent.user_agent,
|
||||
description: userAgent.description,
|
||||
is_active: userAgent.is_active,
|
||||
});
|
||||
} else {
|
||||
formik.resetForm();
|
||||
}
|
||||
}, [userAgent]);
|
||||
|
||||
if (!isOpen) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onClose={onClose}>
|
||||
<DialogTitle
|
||||
sx={{
|
||||
backgroundColor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
}}
|
||||
>
|
||||
User-Agent
|
||||
</DialogTitle>
|
||||
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
fullWidth
|
||||
id="user_agent_name"
|
||||
name="user_agent_name"
|
||||
label="Name"
|
||||
value={formik.values.user_agent_name}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
error={
|
||||
formik.touched.user_agent_name &&
|
||||
Boolean(formik.errors.user_agent_name)
|
||||
}
|
||||
helperText={
|
||||
formik.touched.user_agent_name && formik.errors.user_agent_name
|
||||
}
|
||||
variant="standard"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
id="user_agent"
|
||||
name="user_agent"
|
||||
label="User-Agent"
|
||||
value={formik.values.user_agent}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
error={
|
||||
formik.touched.user_agent && Boolean(formik.errors.user_agent)
|
||||
}
|
||||
helperText={formik.touched.user_agent && formik.errors.user_agent}
|
||||
variant="standard"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
id="description"
|
||||
name="description"
|
||||
label="Description"
|
||||
value={formik.values.description}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
error={
|
||||
formik.touched.description && Boolean(formik.errors.description)
|
||||
}
|
||||
helperText={formik.touched.description && formik.errors.description}
|
||||
variant="standard"
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
name="is_active"
|
||||
checked={formik.values.is_active}
|
||||
onChange={formik.handleChange}
|
||||
/>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button
|
||||
size="small"
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={formik.isSubmitting}
|
||||
>
|
||||
{formik.isSubmitting ? <CircularProgress size={24} /> : 'Submit'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserAgent;
|
||||
119
frontend/src/components/forms/UserAgent.jsx
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
// Modal.js
|
||||
import React, { useEffect } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import API from '../../api';
|
||||
import {
|
||||
LoadingOverlay,
|
||||
TextInput,
|
||||
Button,
|
||||
Checkbox,
|
||||
Modal,
|
||||
Flex,
|
||||
NativeSelect,
|
||||
FileInput,
|
||||
Space,
|
||||
} from '@mantine/core';
|
||||
|
||||
const UserAgent = ({ userAgent = null, isOpen, onClose }) => {
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
user_agent_name: '',
|
||||
user_agent: '',
|
||||
description: '',
|
||||
is_active: true,
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
user_agent_name: Yup.string().required('Name is required'),
|
||||
user_agent: Yup.string().required('User-Agent is required'),
|
||||
}),
|
||||
onSubmit: async (values, { setSubmitting, resetForm }) => {
|
||||
if (userAgent?.id) {
|
||||
await API.updateUserAgent({ id: userAgent.id, ...values });
|
||||
} else {
|
||||
await API.addUserAgent(values);
|
||||
}
|
||||
|
||||
resetForm();
|
||||
setSubmitting(false);
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (userAgent) {
|
||||
formik.setValues({
|
||||
user_agent_name: userAgent.user_agent_name,
|
||||
user_agent: userAgent.user_agent,
|
||||
description: userAgent.description,
|
||||
is_active: userAgent.is_active,
|
||||
});
|
||||
} else {
|
||||
formik.resetForm();
|
||||
}
|
||||
}, [userAgent]);
|
||||
|
||||
if (!isOpen) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal opened={isOpen} onClose={onClose} title="User-Agent">
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<TextInput
|
||||
id="user_agent_name"
|
||||
name="user_agent_name"
|
||||
label="Name"
|
||||
value={formik.values.user_agent_name}
|
||||
onChange={formik.handleChange}
|
||||
error={
|
||||
formik.touched.user_agent_name &&
|
||||
Boolean(formik.errors.user_agent_name)
|
||||
}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
id="user_agent"
|
||||
name="user_agent"
|
||||
label="User-Agent"
|
||||
value={formik.values.user_agent}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.touched.user_agent && Boolean(formik.errors.user_agent)}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
id="description"
|
||||
name="description"
|
||||
label="Description"
|
||||
value={formik.values.description}
|
||||
onChange={formik.handleChange}
|
||||
error={
|
||||
formik.touched.description && Boolean(formik.errors.description)
|
||||
}
|
||||
/>
|
||||
|
||||
<Space h="md" />
|
||||
|
||||
<Checkbox
|
||||
name="is_active"
|
||||
label="Is Active"
|
||||
checked={formik.values.is_active}
|
||||
onChange={formik.handleChange}
|
||||
/>
|
||||
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
<Button
|
||||
size="small"
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={formik.isSubmitting}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserAgent;
|
||||
41
frontend/src/components/sidebar.css
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
.mantine-Stack-root .navlink {
|
||||
display: flex;
|
||||
flex-direction: row; /* Ensures horizontal layout */
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 5px 8px !important;
|
||||
border-radius: 6px;
|
||||
color: #D4D4D8; /* Default color when not active */
|
||||
background-color: transparent; /* Default background when not active */
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* Active state styles */
|
||||
.navlink.navlink-active {
|
||||
color: #FFFFFF;
|
||||
background-color: #245043;
|
||||
border: 1px solid #3BA882;
|
||||
}
|
||||
|
||||
/* Hover effect */
|
||||
.navlink:hover {
|
||||
background-color: #2A2F34; /* Gray hover effect when not active */
|
||||
border: 1px solid #3D3D42;
|
||||
}
|
||||
|
||||
/* Hover effect for active state */
|
||||
.navlink.navlink-active:hover {
|
||||
background-color: #3A3A40;
|
||||
border: 1px solid #3BA882;
|
||||
}
|
||||
|
||||
/* Collapse condition for justifyContent */
|
||||
.navlink.navlink-collapsed {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.navlink:not(.navlink-collapsed) {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
|
@ -1,938 +0,0 @@
|
|||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
MaterialReactTable,
|
||||
useMaterialReactTable,
|
||||
MRT_ShowHideColumnsButton
|
||||
} from 'material-react-table';
|
||||
import {
|
||||
Box,
|
||||
Grid2,
|
||||
Typography,
|
||||
Tooltip,
|
||||
IconButton,
|
||||
Button,
|
||||
Snackbar,
|
||||
Popover,
|
||||
TextField,
|
||||
Autocomplete,
|
||||
InputAdornment,
|
||||
Paper,
|
||||
} from '@mui/material';
|
||||
import useChannelsStore from '../../store/channels';
|
||||
import {
|
||||
Delete as DeleteIcon,
|
||||
Edit as EditIcon,
|
||||
Add as AddIcon,
|
||||
ContentCopy,
|
||||
Clear as ClearIcon,
|
||||
IndeterminateCheckBox,
|
||||
CompareArrows,
|
||||
Code,
|
||||
AddBox,
|
||||
LiveTv as LiveTvIcon,
|
||||
} from '@mui/icons-material';
|
||||
import API from '../../api';
|
||||
import ChannelForm from '../forms/Channel';
|
||||
import { TableHelper } from '../../helpers';
|
||||
import utils from '../../utils';
|
||||
import logo from '../../images/logo.png';
|
||||
import useVideoStore from '../../store/useVideoStore';
|
||||
import useSettingsStore from '../../store/settings';
|
||||
import usePlaylistsStore from '../../store/playlists';
|
||||
import { Tv2, ScreenShare, Scroll, SquareMinus, Pencil } from 'lucide-react';
|
||||
import { styled, useTheme } from '@mui/material/styles';
|
||||
import ghostImage from '../../images/ghost.svg';
|
||||
|
||||
/* -----------------------------------------------------------
|
||||
Child table for streams
|
||||
------------------------------------------------------------ */
|
||||
const ChannelStreams = ({ channel, isExpanded }) => {
|
||||
const channelStreams = useChannelsStore(
|
||||
(state) => state.channels[channel.id]?.streams
|
||||
);
|
||||
const { playlists } = usePlaylistsStore();
|
||||
|
||||
const removeStream = async (stream) => {
|
||||
const newStreamList = channelStreams.filter((s) => s.id !== stream.id);
|
||||
await API.updateChannel({
|
||||
...channel,
|
||||
stream_ids: newStreamList.map((s) => s.id),
|
||||
});
|
||||
};
|
||||
|
||||
const channelStreamsTable = useMaterialReactTable({
|
||||
...TableHelper.defaultProperties,
|
||||
data: channelStreams,
|
||||
columns: useMemo(
|
||||
() => [
|
||||
{
|
||||
header: 'Name',
|
||||
accessorKey: 'name',
|
||||
},
|
||||
{
|
||||
header: 'M3U',
|
||||
accessorFn: (row) =>
|
||||
playlists.find((playlist) => playlist.id === row.m3u_account)?.name,
|
||||
},
|
||||
],
|
||||
[playlists]
|
||||
),
|
||||
enableKeyboardShortcuts: false,
|
||||
enableColumnFilters: false,
|
||||
enableSorting: false,
|
||||
enableBottomToolbar: false,
|
||||
enableTopToolbar: false,
|
||||
enablePagination: false,
|
||||
enableRowVirtualization: true,
|
||||
enableColumnHeaders: false,
|
||||
initialState: { density: 'compact' },
|
||||
columnFilterDisplayMode: 'popover',
|
||||
enableRowActions: true,
|
||||
enableRowOrdering: true,
|
||||
muiRowDragHandleProps: ({ table }) => ({
|
||||
onDragEnd: async () => {
|
||||
const { draggingRow, hoveredRow } = table.getState();
|
||||
if (hoveredRow && draggingRow) {
|
||||
channelStreams.splice(
|
||||
hoveredRow.index,
|
||||
0,
|
||||
channelStreams.splice(draggingRow.index, 1)[0]
|
||||
);
|
||||
const { streams: oldStreams, ...channelUpdate } = channel;
|
||||
await API.updateChannel({
|
||||
...channelUpdate,
|
||||
stream_ids: channelStreams.map((s) => s.id),
|
||||
});
|
||||
}
|
||||
},
|
||||
}),
|
||||
renderRowActions: ({ row }) => (
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => removeStream(row.original)}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
),
|
||||
});
|
||||
|
||||
if (!isExpanded) return null;
|
||||
|
||||
return (
|
||||
<Box sx={{ backgroundColor: 'primary.main', pt: 1, pb: 1, width: '100%' }}>
|
||||
<MaterialReactTable table={channelStreamsTable} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/* -----------------------------------------------------------
|
||||
Custom-styled buttons (HDHR, M3U, EPG)
|
||||
------------------------------------------------------------ */
|
||||
const HDHRButton = styled(Button)(() => ({
|
||||
border: '1px solid #a3d977',
|
||||
color: '#a3d977',
|
||||
backgroundColor: 'transparent',
|
||||
textTransform: 'none',
|
||||
fontSize: '0.85rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '2px 8px',
|
||||
minWidth: 'auto',
|
||||
'&:hover': {
|
||||
borderColor: '#c2e583',
|
||||
color: '#c2e583',
|
||||
backgroundColor: 'rgba(163,217,119,0.1)',
|
||||
},
|
||||
}));
|
||||
|
||||
const M3UButton = styled(Button)(() => ({
|
||||
border: '1px solid #5f6dc6',
|
||||
color: '#5f6dc6',
|
||||
backgroundColor: 'transparent',
|
||||
textTransform: 'none',
|
||||
fontSize: '0.85rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '2px 8px',
|
||||
minWidth: 'auto',
|
||||
'&:hover': {
|
||||
borderColor: '#7f8de6',
|
||||
color: '#7f8de6',
|
||||
backgroundColor: 'rgba(95,109,198,0.1)',
|
||||
},
|
||||
}));
|
||||
|
||||
const EPGButton = styled(Button)(() => ({
|
||||
border: '1px solid #707070',
|
||||
color: '#a0a0a0',
|
||||
backgroundColor: 'transparent',
|
||||
textTransform: 'none',
|
||||
fontSize: '0.85rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '2px 8px',
|
||||
minWidth: 'auto',
|
||||
'&:hover': {
|
||||
borderColor: '#a0a0a0',
|
||||
color: '#c0c0c0',
|
||||
backgroundColor: 'rgba(112,112,112,0.1)',
|
||||
},
|
||||
}));
|
||||
|
||||
/* -----------------------------------------------------------
|
||||
Main ChannelsTable component
|
||||
------------------------------------------------------------ */
|
||||
const ChannelsTable = () => {
|
||||
const [channel, setChannel] = useState(null);
|
||||
const [channelModalOpen, setChannelModalOpen] = useState(false);
|
||||
const [rowSelection, setRowSelection] = useState([]);
|
||||
const [channelGroupOptions, setChannelGroupOptions] = useState([]);
|
||||
const [anchorEl, setAnchorEl] = useState(null);
|
||||
const [textToCopy, setTextToCopy] = useState('');
|
||||
const [snackbarMessage, setSnackbarMessage] = useState('');
|
||||
const [snackbarOpen, setSnackbarOpen] = useState(false);
|
||||
const [filterValues, setFilterValues] = useState({});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [sorting, setSorting] = useState([]);
|
||||
|
||||
const theme = useTheme();
|
||||
const outputUrlRef = useRef(null);
|
||||
|
||||
const {
|
||||
channels,
|
||||
isLoading: channelsLoading,
|
||||
fetchChannels,
|
||||
setChannelsPageSelection,
|
||||
} = useChannelsStore();
|
||||
const { showVideo } = useVideoStore();
|
||||
const {
|
||||
environment: { env_mode },
|
||||
} = useSettingsStore();
|
||||
|
||||
// Gather unique group names
|
||||
useEffect(() => {
|
||||
setChannelGroupOptions([
|
||||
...new Set(Object.values(channels).map((ch) => ch.channel_group?.name)),
|
||||
]);
|
||||
}, [channels]);
|
||||
|
||||
// Handle filters
|
||||
const handleFilterChange = (columnId, value) => {
|
||||
setFilterValues((prev) => ({
|
||||
...prev,
|
||||
[columnId]: value ? value.toLowerCase() : '',
|
||||
}));
|
||||
};
|
||||
|
||||
// Close the top-right snackbar
|
||||
const closeSnackbar = () => setSnackbarOpen(false);
|
||||
|
||||
// Open the Channel form
|
||||
const editChannel = (ch = null) => {
|
||||
setChannel(ch);
|
||||
setChannelModalOpen(true);
|
||||
};
|
||||
|
||||
// Close the Channel form
|
||||
const closeChannelForm = () => {
|
||||
setChannel(null);
|
||||
setChannelModalOpen(false);
|
||||
};
|
||||
|
||||
// Single channel delete
|
||||
const deleteChannel = async (id) => {
|
||||
await API.deleteChannel(id);
|
||||
};
|
||||
|
||||
// Bulk delete channels
|
||||
const deleteChannels = async () => {
|
||||
setIsLoading(true);
|
||||
const selected = table.getRowModel().rows.filter((row) => row.getIsSelected());
|
||||
await utils.Limiter(
|
||||
4,
|
||||
selected.map((chan) => () => deleteChannel(chan.original.id))
|
||||
);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
// Watch stream
|
||||
const handleWatchStream = (channelNumber) => {
|
||||
let vidUrl = `/output/stream/${channelNumber}/`;
|
||||
if (env_mode === 'dev') {
|
||||
vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`;
|
||||
}
|
||||
showVideo(vidUrl);
|
||||
};
|
||||
|
||||
// Assign Channels
|
||||
const assignChannels = async () => {
|
||||
try {
|
||||
const rowOrder = table.getRowModel().rows.map((row) => row.original.id);
|
||||
setIsLoading(true);
|
||||
const result = await API.assignChannelNumbers(rowOrder);
|
||||
setIsLoading(false);
|
||||
setSnackbarMessage(result.message || 'Channels assigned');
|
||||
setSnackbarOpen(true);
|
||||
await fetchChannels();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setSnackbarMessage('Failed to assign channels');
|
||||
setSnackbarOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
// Match EPG
|
||||
const matchEpg = async () => {
|
||||
try {
|
||||
const resp = await fetch('/api/channels/channels/match-epg/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
},
|
||||
});
|
||||
if (resp.ok) {
|
||||
setSnackbarMessage('EPG matching task started!');
|
||||
} else {
|
||||
const text = await resp.text();
|
||||
setSnackbarMessage(`Failed to start EPG matching: ${text}`);
|
||||
}
|
||||
} catch (err) {
|
||||
setSnackbarMessage(`Error: ${err.message}`);
|
||||
}
|
||||
setSnackbarOpen(true);
|
||||
};
|
||||
|
||||
// Copy popover logic
|
||||
const closePopover = () => {
|
||||
setAnchorEl(null);
|
||||
setSnackbarMessage('');
|
||||
};
|
||||
const openPopover = Boolean(anchorEl);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(textToCopy);
|
||||
setSnackbarMessage('Copied!');
|
||||
} catch (err) {
|
||||
const inputElement = outputUrlRef.current?.querySelector('input');
|
||||
if (inputElement) {
|
||||
inputElement.focus();
|
||||
inputElement.select();
|
||||
document.execCommand('copy');
|
||||
setSnackbarMessage('Copied!');
|
||||
}
|
||||
}
|
||||
setSnackbarOpen(true);
|
||||
};
|
||||
|
||||
// Copy HDHR/M3U/EPG URL
|
||||
const copyM3UUrl = (event) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
setTextToCopy(`${window.location.protocol}//${window.location.host}/output/m3u`);
|
||||
};
|
||||
const copyEPGUrl = (event) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
setTextToCopy(`${window.location.protocol}//${window.location.host}/output/epg`);
|
||||
};
|
||||
const copyHDHRUrl = (event) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
setTextToCopy(`${window.location.protocol}//${window.location.host}/output/hdhr`);
|
||||
};
|
||||
|
||||
// When component mounts
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Scroll to top on sorting
|
||||
const rowVirtualizerInstanceRef = useRef(null);
|
||||
useEffect(() => {
|
||||
try {
|
||||
rowVirtualizerInstanceRef.current?.scrollToIndex?.(0);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}, [sorting]);
|
||||
|
||||
// Build the columns
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
header: '#',
|
||||
size: 50,
|
||||
accessorKey: 'channel_number',
|
||||
},
|
||||
{
|
||||
header: 'Name',
|
||||
accessorKey: 'channel_name',
|
||||
muiTableHeadCellProps: { sx: { textAlign: 'center' } },
|
||||
Header: ({ column }) => (
|
||||
<TextField
|
||||
variant="standard"
|
||||
label="Name"
|
||||
value={filterValues[column.id] || ''}
|
||||
onChange={(e) => handleFilterChange(column.id, e.target.value)}
|
||||
size="small"
|
||||
margin="none"
|
||||
fullWidth
|
||||
slotProps={{
|
||||
input: {
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
onClick={() => handleFilterChange(column.id, '')}
|
||||
edge="end"
|
||||
size="small"
|
||||
>
|
||||
<ClearIcon sx={{ fontSize: '1rem' }} />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Group',
|
||||
accessorFn: (row) => row.channel_group?.name || '',
|
||||
Header: ({ column }) => (
|
||||
<Autocomplete
|
||||
disablePortal
|
||||
options={channelGroupOptions}
|
||||
size="small"
|
||||
sx={{ width: 300 }}
|
||||
clearOnEscape
|
||||
onChange={(event, newValue) => {
|
||||
event.stopPropagation();
|
||||
handleFilterChange(column.id, newValue);
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Group"
|
||||
size="small"
|
||||
variant="standard"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
sx={{ pb: 0.8 }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Logo',
|
||||
accessorKey: 'logo_url',
|
||||
enableSorting: false,
|
||||
size: 55,
|
||||
Cell: ({ cell }) => (
|
||||
<Grid2 container direction="row" sx={{ justifyContent: 'center', alignItems: 'center' }}>
|
||||
<img src={cell.getValue() || logo} width="20" alt="channel logo" />
|
||||
</Grid2>
|
||||
),
|
||||
},
|
||||
], [channelGroupOptions, filterValues]);
|
||||
|
||||
// Filter the data
|
||||
const filteredData = Object.values(channels).filter((row) =>
|
||||
columns.every(({ accessorKey }) =>
|
||||
filterValues[accessorKey]
|
||||
? row[accessorKey]?.toLowerCase().includes(filterValues[accessorKey])
|
||||
: true
|
||||
)
|
||||
);
|
||||
|
||||
// Build the MRT instance
|
||||
const table = useMaterialReactTable({
|
||||
...TableHelper.defaultProperties,
|
||||
columns,
|
||||
data: filteredData,
|
||||
enablePagination: false,
|
||||
enableColumnActions: false,
|
||||
enableRowVirtualization: true,
|
||||
enableRowSelection: true,
|
||||
enableRowActions: true,
|
||||
enableExpandAll: false,
|
||||
// Fully disable MRT's built-in top toolbar
|
||||
enableTopToolbar: false,
|
||||
renderTopToolbar: () => null,
|
||||
renderToolbarInternalActions: () => null,
|
||||
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
state: {
|
||||
isLoading: isLoading || channelsLoading,
|
||||
sorting,
|
||||
rowSelection,
|
||||
},
|
||||
|
||||
rowVirtualizerInstanceRef,
|
||||
rowVirtualizerOptions: { overscan: 5 },
|
||||
initialState: { density: 'compact' },
|
||||
|
||||
displayColumnDefOptions: {
|
||||
'mrt-row-select': { size: 50 },
|
||||
'mrt-row-expand': {
|
||||
size: 10,
|
||||
header: '',
|
||||
muiTableHeadCellProps: { sx: { width: 38, minWidth: 38, maxWidth: 38 } },
|
||||
muiTableBodyCellProps: { sx: { width: 38, minWidth: 38, maxWidth: 38 } },
|
||||
},
|
||||
'mrt-row-actions': { size: 68 },
|
||||
},
|
||||
|
||||
muiExpandButtonProps: ({ row, table }) => ({
|
||||
onClick: () => {
|
||||
setRowSelection({ [row.index]: true });
|
||||
table.setExpanded({ [row.id]: !row.getIsExpanded() });
|
||||
},
|
||||
sx: {
|
||||
transform: row.getIsExpanded() ? 'rotate(180deg)' : 'rotate(-90deg)',
|
||||
transition: 'transform 0.2s',
|
||||
},
|
||||
}),
|
||||
|
||||
// Expand child table
|
||||
renderDetailPanel: ({ row }) => (
|
||||
<ChannelStreams channel={row.original} isExpanded={row.getIsExpanded()} />
|
||||
),
|
||||
|
||||
// Row actions
|
||||
renderRowActions: ({ row }) => (
|
||||
<Box sx={{ justifyContent: 'right' }}>
|
||||
<Tooltip title="Edit Channel">
|
||||
<IconButton
|
||||
size="small"
|
||||
color="warning"
|
||||
onClick={() => editChannel(row.original)}
|
||||
sx={{ py: 0, px: 0.5 }}
|
||||
>
|
||||
<Pencil size="18" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete Channel">
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => deleteChannel(row.original.id)}
|
||||
sx={{ py: 0, px: 0.5 }}
|
||||
>
|
||||
<SquareMinus size="18" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Preview Channel">
|
||||
<IconButton
|
||||
size="small"
|
||||
color="info"
|
||||
onClick={() => handleWatchStream(row.original.channel_number)}
|
||||
sx={{ py: 0, px: 0.5 }}
|
||||
>
|
||||
<LiveTvIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
),
|
||||
|
||||
muiTableContainerProps: {
|
||||
sx: {
|
||||
height: 'calc(100vh - 75px)',
|
||||
overflowY: 'auto',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Sync the selection with your store
|
||||
useEffect(() => {
|
||||
const selectedRows = table.getSelectedRowModel().rows.map((row) => row.original);
|
||||
setChannelsPageSelection(selectedRows);
|
||||
}, [rowSelection, table, setChannelsPageSelection]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Header row, outside the Paper */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', pb: 1 }}>
|
||||
<Typography
|
||||
sx={{
|
||||
width: 88,
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
fontWeight: 500,
|
||||
fontSize: '20px',
|
||||
lineHeight: 1,
|
||||
letterSpacing: '-0.3px',
|
||||
color: theme.palette.text.secondary,
|
||||
mb: 0,
|
||||
}}
|
||||
>
|
||||
Channels
|
||||
</Typography>
|
||||
|
||||
{/* "Links" label and HDHR/M3U/EPG buttons */}
|
||||
<Box sx={{ width: 43, height: 25, display: 'flex', alignItems: 'center', ml: 3 }}>
|
||||
<Typography
|
||||
sx={{
|
||||
width: 37,
|
||||
height: 17,
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
fontWeight: 400,
|
||||
fontSize: '14px',
|
||||
lineHeight: 1,
|
||||
letterSpacing: '-0.3px',
|
||||
color: theme.palette.text.secondary,
|
||||
}}
|
||||
>
|
||||
Links:
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: '6px', ml: 0.75 }}>
|
||||
<Button
|
||||
onClick={copyHDHRUrl}
|
||||
sx={{
|
||||
width: '71px',
|
||||
height: '25px',
|
||||
borderRadius: '4px',
|
||||
border: `1px solid ${theme.palette.custom.greenMain}`,
|
||||
backgroundColor: 'transparent',
|
||||
color: theme.palette.custom.greenMain,
|
||||
fontSize: '14px',
|
||||
lineHeight: 1,
|
||||
letterSpacing: '-0.3px',
|
||||
textTransform: 'none',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '6px',
|
||||
padding: 0,
|
||||
minWidth: 0,
|
||||
'&:hover': { backgroundColor: theme.palette.custom.greenHoverBg },
|
||||
}}
|
||||
>
|
||||
<Box sx={{ width: 14, height: 14, display: 'flex', alignItems: 'center' }}>
|
||||
<Tv2 size={14} color={theme.palette.custom.greenMain} />
|
||||
</Box>
|
||||
HDHR
|
||||
</Button>
|
||||
<Button
|
||||
onClick={copyM3UUrl}
|
||||
sx={{
|
||||
width: '64px',
|
||||
height: '25px',
|
||||
borderRadius: '4px',
|
||||
border: `1px solid ${theme.palette.custom.indigoMain}`,
|
||||
backgroundColor: 'transparent',
|
||||
color: theme.palette.custom.indigoMain,
|
||||
fontSize: '14px',
|
||||
lineHeight: 1,
|
||||
letterSpacing: '-0.3px',
|
||||
textTransform: 'none',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '6px',
|
||||
padding: 0,
|
||||
minWidth: 0,
|
||||
'&:hover': { backgroundColor: theme.palette.custom.indigoHoverBg },
|
||||
}}
|
||||
>
|
||||
<Box sx={{ width: 14, height: 14, display: 'flex', alignItems: 'center' }}>
|
||||
<ScreenShare size={14} color={theme.palette.custom.indigoMain} />
|
||||
</Box>
|
||||
M3U
|
||||
</Button>
|
||||
<Button
|
||||
onClick={copyEPGUrl}
|
||||
sx={{
|
||||
width: '60px',
|
||||
height: '25px',
|
||||
borderRadius: '4px',
|
||||
border: `1px solid ${theme.palette.custom.greyBorder}`,
|
||||
backgroundColor: 'transparent',
|
||||
color: theme.palette.custom.greyText,
|
||||
fontSize: '14px',
|
||||
lineHeight: 1,
|
||||
letterSpacing: '-0.3px',
|
||||
textTransform: 'none',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '6px',
|
||||
padding: 0,
|
||||
minWidth: 0,
|
||||
'&:hover': { backgroundColor: theme.palette.custom.greyHoverBg },
|
||||
}}
|
||||
>
|
||||
<Box sx={{ width: 14, height: 14, display: 'flex', alignItems: 'center' }}>
|
||||
<Scroll size={14} color={theme.palette.custom.greyText} />
|
||||
</Box>
|
||||
EPG
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Paper with your custom top bar + table */}
|
||||
<Paper
|
||||
sx={{
|
||||
bgcolor: theme.palette.background.paper,
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
height: 'calc(100vh - 75px)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Our own top toolbar to mimic StreamsTable's style */}
|
||||
{/*
|
||||
define selectedCount in JS, NOT inline:
|
||||
*/}
|
||||
{(() => {
|
||||
const selectedCount = table.getSelectedRowModel().rows.length;
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
justifyContent: 'flex-end',
|
||||
p: 1,
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Tooltip title="Remove">
|
||||
<Button
|
||||
onClick={deleteChannels}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
disabled={selectedCount === 0}
|
||||
startIcon={<IndeterminateCheckBox sx={{ fontSize: 16, color: theme.palette.text.secondary }} />}
|
||||
sx={{
|
||||
borderColor: theme.palette.custom.borderDefault,
|
||||
borderRadius: '4px',
|
||||
borderWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
height: '25px',
|
||||
opacity: selectedCount ? 1 : 0.4,
|
||||
color: theme.palette.text.secondary,
|
||||
fontSize: '0.85rem',
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
'&:hover': { borderColor: theme.palette.custom.borderHover },
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="Assign">
|
||||
<Button
|
||||
onClick={assignChannels}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
disabled={selectedCount === 0}
|
||||
startIcon={<CompareArrows sx={{ fontSize: 16, color: theme.palette.text.secondary }} />}
|
||||
sx={{
|
||||
borderColor: theme.palette.custom.borderDefault,
|
||||
borderRadius: '4px',
|
||||
borderWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
height: '25px',
|
||||
opacity: selectedCount ? 1 : 0.4,
|
||||
color: theme.palette.text.secondary,
|
||||
fontSize: '0.85rem',
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
'&:hover': { borderColor: theme.palette.custom.borderHover },
|
||||
}}
|
||||
>
|
||||
Assign
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="Auto-match">
|
||||
<Button
|
||||
onClick={matchEpg}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<Code sx={{ fontSize: 16, color: theme.palette.text.secondary }} />}
|
||||
sx={{
|
||||
minWidth: '106px',
|
||||
borderColor: theme.palette.custom.borderDefault,
|
||||
borderRadius: '4px',
|
||||
borderWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
height: '25px',
|
||||
color: theme.palette.text.secondary,
|
||||
fontSize: '0.85rem',
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
'&:hover': { borderColor: theme.palette.custom.borderHover },
|
||||
}}
|
||||
>
|
||||
Auto-match
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="Add Channel">
|
||||
<Button
|
||||
onClick={() => editChannel()}
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={<AddBox sx={{ fontSize: 16, color: theme.palette.custom.successIcon }} />}
|
||||
sx={{
|
||||
minWidth: '57px',
|
||||
height: '25px',
|
||||
borderRadius: '4px',
|
||||
borderColor: theme.palette.custom.successBorder,
|
||||
borderWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
backgroundColor: theme.palette.custom.successBg,
|
||||
color: '#fff',
|
||||
fontSize: '0.85rem',
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
'&:hover': { backgroundColor: theme.palette.custom.successBgHover },
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="Show/Hide Columns">
|
||||
<MRT_ShowHideColumnsButton table={table} />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Table or ghost empty state */}
|
||||
<Box sx={{ flex: 1, position: 'relative' }}>
|
||||
{filteredData.length === 0 ? (
|
||||
<Box sx={{ position: 'relative', width: '100%', height: '100%', bgcolor: theme.palette.background.paper }}>
|
||||
<Box
|
||||
component="img"
|
||||
src={ghostImage}
|
||||
alt="Ghost"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
width: '120px',
|
||||
height: 'auto',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
opacity: 0.2,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: '25%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
textAlign: 'center',
|
||||
zIndex: 2,
|
||||
width: 467,
|
||||
px: 2,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
fontWeight: 400,
|
||||
fontSize: '20px',
|
||||
lineHeight: '28px',
|
||||
letterSpacing: '-0.3px',
|
||||
color: theme.palette.text.secondary,
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
It’s recommended to create channels after adding your M3U or streams.
|
||||
</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
fontWeight: 400,
|
||||
fontSize: '16px',
|
||||
lineHeight: '24px',
|
||||
letterSpacing: '-0.2px',
|
||||
color: theme.palette.text.secondary,
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
You can still create channels without streams if you’d like, and map them later.
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => editChannel()}
|
||||
startIcon={<AddIcon sx={{ fontSize: 16 }} />}
|
||||
sx={{
|
||||
minWidth: '127px',
|
||||
height: '25px',
|
||||
borderRadius: '4px',
|
||||
borderWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
color: theme.palette.text.secondary,
|
||||
borderColor: theme.palette.custom.borderHover,
|
||||
backgroundColor: '#1f1f23',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
fontWeight: 400,
|
||||
fontSize: '0.85rem',
|
||||
letterSpacing: '-0.2px',
|
||||
textTransform: 'none',
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
'&:hover': {
|
||||
borderColor: theme.palette.custom.borderDefault,
|
||||
backgroundColor: '#17171B',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Create channel
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ flex: 1, overflow: 'auto' }}>
|
||||
<MaterialReactTable table={table} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Channel Form */}
|
||||
<ChannelForm channel={channel} isOpen={channelModalOpen} onClose={closeChannelForm} />
|
||||
|
||||
{/* Copy popover */}
|
||||
<Popover
|
||||
open={openPopover}
|
||||
anchorEl={anchorEl}
|
||||
onClose={closePopover}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||||
>
|
||||
<Box sx={{ p: 2, display: 'flex', alignItems: 'center' }}>
|
||||
<TextField
|
||||
id="output-url"
|
||||
value={textToCopy}
|
||||
variant="standard"
|
||||
size="small"
|
||||
sx={{ mr: 1 }}
|
||||
inputRef={outputUrlRef}
|
||||
/>
|
||||
<IconButton onClick={handleCopy} color="primary">
|
||||
<ContentCopy />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Popover>
|
||||
|
||||
<Snackbar
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
open={snackbarOpen}
|
||||
autoHideDuration={5000}
|
||||
onClose={closeSnackbar}
|
||||
message={snackbarMessage}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelsTable;
|
||||
880
frontend/src/components/tables/ChannelsTable.jsx
Normal file
|
|
@ -0,0 +1,880 @@
|
|||
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
||||
import { MantineReactTable, useMantineReactTable } from 'mantine-react-table';
|
||||
import useChannelsStore from '../../store/channels';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import {
|
||||
Add as AddIcon,
|
||||
LiveTv as LiveTvIcon,
|
||||
ContentCopy,
|
||||
IndeterminateCheckBox,
|
||||
CompareArrows,
|
||||
Code,
|
||||
AddBox,
|
||||
} from '@mui/icons-material';
|
||||
import API from '../../api';
|
||||
import ChannelForm from '../forms/Channel';
|
||||
import { TableHelper } from '../../helpers';
|
||||
import utils from '../../utils';
|
||||
import logo from '../../images/logo.png';
|
||||
import useVideoStore from '../../store/useVideoStore';
|
||||
import useSettingsStore from '../../store/settings';
|
||||
import usePlaylistsStore from '../../store/playlists';
|
||||
import {
|
||||
Tv2,
|
||||
ScreenShare,
|
||||
Scroll,
|
||||
SquareMinus,
|
||||
CirclePlay,
|
||||
SquarePen,
|
||||
Binary,
|
||||
ArrowDown01,
|
||||
SquarePlus,
|
||||
} from 'lucide-react';
|
||||
import ghostImage from '../../images/ghost.svg';
|
||||
import {
|
||||
Box,
|
||||
TextInput,
|
||||
Popover,
|
||||
ActionIcon,
|
||||
Select,
|
||||
Button,
|
||||
Paper,
|
||||
Flex,
|
||||
Text,
|
||||
Tooltip,
|
||||
Grid,
|
||||
Group,
|
||||
useMantineTheme,
|
||||
Center,
|
||||
Container,
|
||||
} from '@mantine/core';
|
||||
|
||||
const ChannelStreams = ({ channel, isExpanded }) => {
|
||||
const channelStreams = useChannelsStore(
|
||||
(state) => state.channels[channel.id]?.streams
|
||||
);
|
||||
const { playlists } = usePlaylistsStore();
|
||||
|
||||
const removeStream = async (stream) => {
|
||||
const newStreamList = channelStreams.filter((s) => s.id !== stream.id);
|
||||
await API.updateChannel({
|
||||
...channel,
|
||||
stream_ids: newStreamList.map((s) => s.id),
|
||||
});
|
||||
};
|
||||
|
||||
const channelStreamsTable = useMantineReactTable({
|
||||
...TableHelper.defaultProperties,
|
||||
data: channelStreams,
|
||||
columns: useMemo(
|
||||
() => [
|
||||
{
|
||||
size: 400,
|
||||
header: 'Name',
|
||||
accessorKey: 'name',
|
||||
Cell: ({ cell }) => (
|
||||
<div
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{cell.getValue()}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
size: 100,
|
||||
header: 'M3U',
|
||||
accessorFn: (row) =>
|
||||
playlists.find((playlist) => playlist.id === row.m3u_account)?.name,
|
||||
Cell: ({ cell }) => (
|
||||
<div
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{cell.getValue()}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[playlists]
|
||||
),
|
||||
displayColumnDefOptions: {
|
||||
'mrt-row-actions': {
|
||||
size: 10,
|
||||
},
|
||||
},
|
||||
enableKeyboardShortcuts: false,
|
||||
enableColumnFilters: false,
|
||||
enableBottomToolbar: false,
|
||||
enableTopToolbar: false,
|
||||
columnFilterDisplayMode: 'popover',
|
||||
enablePagination: false,
|
||||
enableRowVirtualization: true,
|
||||
enableColumnHeaders: false,
|
||||
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
|
||||
initialState: {
|
||||
density: 'compact',
|
||||
},
|
||||
enableRowActions: true,
|
||||
enableRowOrdering: true,
|
||||
mantineTableHeadRowProps: {
|
||||
style: { display: 'none' },
|
||||
},
|
||||
mantineTableBodyCellProps: {
|
||||
style: {
|
||||
// py: 0,
|
||||
padding: 4,
|
||||
borderColor: '#444',
|
||||
color: '#E0E0E0',
|
||||
fontSize: '0.85rem',
|
||||
},
|
||||
},
|
||||
mantineRowDragHandleProps: ({ table }) => ({
|
||||
onDragEnd: async () => {
|
||||
const { draggingRow, hoveredRow } = table.getState();
|
||||
|
||||
if (hoveredRow && draggingRow) {
|
||||
channelStreams.splice(
|
||||
hoveredRow.index,
|
||||
0,
|
||||
channelStreams.splice(draggingRow.index, 1)[0]
|
||||
);
|
||||
|
||||
const { streams: _, ...channelUpdate } = channel;
|
||||
|
||||
API.updateChannel({
|
||||
...channelUpdate,
|
||||
stream_ids: channelStreams.map((stream) => stream.id),
|
||||
});
|
||||
}
|
||||
},
|
||||
}),
|
||||
renderRowActions: ({ row }) => (
|
||||
<Tooltip label="Remove stream">
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
color="red.9"
|
||||
variant="transparent"
|
||||
onClick={() => removeStream(row.original)}
|
||||
>
|
||||
<SquareMinus size="18" fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
),
|
||||
});
|
||||
|
||||
if (!isExpanded) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box style={{ width: '100%' }}>
|
||||
<MantineReactTable table={channelStreamsTable} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const m3uUrl = `${window.location.protocol}//${window.location.host}/output/m3u`;
|
||||
const epgUrl = `${window.location.protocol}//${window.location.host}/output/epg`;
|
||||
const hdhrUrl = `${window.location.protocol}//${window.location.host}/output/hdhr`;
|
||||
|
||||
const ChannelsTable = ({}) => {
|
||||
const [channel, setChannel] = useState(null);
|
||||
const [channelModalOpen, setChannelModalOpen] = useState(false);
|
||||
const [rowSelection, setRowSelection] = useState([]);
|
||||
const [channelGroupOptions, setChannelGroupOptions] = useState([]);
|
||||
|
||||
const [textToCopy, setTextToCopy] = useState('');
|
||||
|
||||
const [filterValues, setFilterValues] = useState({});
|
||||
|
||||
// const theme = useTheme();
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const { showVideo } = useVideoStore();
|
||||
const {
|
||||
channels,
|
||||
isLoading: channelsLoading,
|
||||
fetchChannels,
|
||||
setChannelsPageSelection,
|
||||
} = useChannelsStore();
|
||||
|
||||
useEffect(() => {
|
||||
setChannelGroupOptions([
|
||||
...new Set(
|
||||
Object.values(channels).map((channel) => channel.channel_group?.name)
|
||||
),
|
||||
]);
|
||||
}, [channels]);
|
||||
|
||||
const handleFilterChange = (columnId, value) => {
|
||||
setFilterValues((prev) => ({
|
||||
...prev,
|
||||
[columnId]: value ? value.toLowerCase() : '',
|
||||
}));
|
||||
};
|
||||
|
||||
const hdhrUrlRef = useRef(null);
|
||||
const m3uUrlRef = useRef(null);
|
||||
const epgUrlRef = useRef(null);
|
||||
|
||||
const {
|
||||
environment: { env_mode },
|
||||
} = useSettingsStore();
|
||||
|
||||
// Configure columns
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
header: '#',
|
||||
size: 50,
|
||||
accessorKey: 'channel_number',
|
||||
},
|
||||
{
|
||||
header: 'Name',
|
||||
accessorKey: 'channel_name',
|
||||
mantineTableHeadCellProps: {
|
||||
sx: { textAlign: 'center' },
|
||||
},
|
||||
Header: ({ column }) => (
|
||||
<TextInput
|
||||
name="name"
|
||||
placeholder="Name"
|
||||
value={filterValues[column.id]}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
handleFilterChange(column.id, e.target.value);
|
||||
}}
|
||||
size="xs"
|
||||
/>
|
||||
),
|
||||
Cell: ({ cell }) => (
|
||||
<div
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{cell.getValue()}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Group',
|
||||
accessorFn: (row) => row.channel_group?.name || '',
|
||||
Header: ({ column }) => (
|
||||
<Select
|
||||
placeholder="Group"
|
||||
searchable
|
||||
size="xs"
|
||||
nothingFound="No options"
|
||||
onChange={(e, value) => {
|
||||
e.stopPropagation();
|
||||
handleGroupChange(value);
|
||||
}}
|
||||
data={channelGroupOptions}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Logo',
|
||||
accessorKey: 'logo_url',
|
||||
enableSorting: false,
|
||||
size: 55,
|
||||
Cell: ({ cell }) => (
|
||||
<Grid
|
||||
container
|
||||
direction="row"
|
||||
sx={{
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<img src={cell.getValue() || logo} width="20" alt="channel logo" />
|
||||
</Grid>
|
||||
),
|
||||
meta: {
|
||||
filterVariant: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
[channelGroupOptions, filterValues]
|
||||
);
|
||||
|
||||
// Access the row virtualizer instance (optional)
|
||||
const rowVirtualizerInstanceRef = useRef(null);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [sorting, setSorting] = useState([]);
|
||||
|
||||
const editChannel = async (ch = null) => {
|
||||
setChannel(ch);
|
||||
setChannelModalOpen(true);
|
||||
};
|
||||
|
||||
const deleteChannel = async (id) => {
|
||||
await API.deleteChannel(id);
|
||||
};
|
||||
|
||||
function handleWatchStream(channelNumber) {
|
||||
let vidUrl = `/output/stream/${channelNumber}/`;
|
||||
if (env_mode == 'dev') {
|
||||
vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`;
|
||||
}
|
||||
showVideo(vidUrl);
|
||||
}
|
||||
|
||||
// (Optional) bulk delete, but your endpoint is @TODO
|
||||
const deleteChannels = async () => {
|
||||
setIsLoading(true);
|
||||
const selected = table
|
||||
.getRowModel()
|
||||
.rows.filter((row) => row.getIsSelected());
|
||||
|
||||
await API.deleteChannels(selected.map((row) => row.original.id));
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// The "Assign Channels" button logic
|
||||
// ─────────────────────────────────────────────────────────
|
||||
const assignChannels = async () => {
|
||||
try {
|
||||
// Get row order from the table
|
||||
const rowOrder = table.getRowModel().rows.map((row) => row.original.id);
|
||||
|
||||
// Call our custom API endpoint
|
||||
setIsLoading(true);
|
||||
const result = await API.assignChannelNumbers(rowOrder);
|
||||
setIsLoading(false);
|
||||
|
||||
// We might get { message: "Channels have been auto-assigned!" }
|
||||
notifications.show({
|
||||
title: result.message || 'Channels assigned',
|
||||
color: 'green.5',
|
||||
});
|
||||
|
||||
// Refresh the channel list
|
||||
await fetchChannels();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
notifications.show({
|
||||
title: 'Failed to assign channels',
|
||||
color: 'red.5',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// The new "Match EPG" button logic
|
||||
// ─────────────────────────────────────────────────────────
|
||||
const matchEpg = async () => {
|
||||
try {
|
||||
// Hit our new endpoint that triggers the fuzzy matching Celery task
|
||||
const resp = await fetch('/api/channels/channels/match-epg/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${await API.getAuthToken()}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
showAlert('EPG matching task started!');
|
||||
} else {
|
||||
const text = await resp.text();
|
||||
showAlert(`Failed to start EPG matching: ${text}`);
|
||||
}
|
||||
} catch (err) {
|
||||
showAlert(`Error: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const closeChannelForm = () => {
|
||||
setChannel(null);
|
||||
setChannelModalOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Scroll to the top of the table when sorting changes
|
||||
try {
|
||||
rowVirtualizerInstanceRef.current?.scrollToIndex?.(0);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}, [sorting]);
|
||||
|
||||
const handleCopy = async (textToCopy, ref) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(textToCopy);
|
||||
notifications.show({
|
||||
title: 'Copied!',
|
||||
// style: { width: '200px', left: '200px' },
|
||||
});
|
||||
} catch (err) {
|
||||
const inputElement = ref.current; // Get the actual input
|
||||
|
||||
if (inputElement) {
|
||||
inputElement.focus();
|
||||
inputElement.select();
|
||||
|
||||
// For older browsers
|
||||
document.execCommand('copy');
|
||||
notifications.show({ title: 'Copied!' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Example copy URLs
|
||||
const copyM3UUrl = () => {
|
||||
handleCopy(
|
||||
`${window.location.protocol}//${window.location.host}/output/m3u`,
|
||||
m3uUrlRef
|
||||
);
|
||||
};
|
||||
const copyEPGUrl = () => {
|
||||
handleCopy(
|
||||
`${window.location.protocol}//${window.location.host}/output/epg`,
|
||||
epgUrlRef
|
||||
);
|
||||
};
|
||||
const copyHDHRUrl = () => {
|
||||
handleCopy(
|
||||
`${window.location.protocol}//${window.location.host}/output/hdhr`,
|
||||
hdhrUrlRef
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const selectedRows = table
|
||||
.getSelectedRowModel()
|
||||
.rows.map((row) => row.original);
|
||||
setChannelsPageSelection(selectedRows);
|
||||
}, [rowSelection]);
|
||||
|
||||
const filteredData = Object.values(channels).filter((row) =>
|
||||
columns.every(({ accessorKey }) =>
|
||||
filterValues[accessorKey]
|
||||
? row[accessorKey]?.toLowerCase().includes(filterValues[accessorKey])
|
||||
: true
|
||||
)
|
||||
);
|
||||
|
||||
const table = useMantineReactTable({
|
||||
...TableHelper.defaultProperties,
|
||||
columns,
|
||||
data: filteredData,
|
||||
enablePagination: false,
|
||||
enableColumnActions: false,
|
||||
enableRowVirtualization: true,
|
||||
enableRowSelection: true,
|
||||
renderTopToolbar: false,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
state: {
|
||||
isLoading: isLoading || channelsLoading,
|
||||
sorting,
|
||||
rowSelection,
|
||||
},
|
||||
rowVirtualizerInstanceRef,
|
||||
rowVirtualizerOptions: { overscan: 5 },
|
||||
initialState: {
|
||||
density: 'compact',
|
||||
},
|
||||
enableRowActions: true,
|
||||
enableExpandAll: false,
|
||||
displayColumnDefOptions: {
|
||||
'mrt-row-select': {
|
||||
size: 20,
|
||||
},
|
||||
'mrt-row-expand': {
|
||||
size: 10,
|
||||
header: '',
|
||||
},
|
||||
'mrt-row-actions': {
|
||||
size: 74,
|
||||
},
|
||||
},
|
||||
mantineExpandButtonProps: ({ row, table }) => ({
|
||||
onClick: () => {
|
||||
setRowSelection({ [row.index]: true });
|
||||
table.setExpanded({ [row.id]: !row.getIsExpanded() });
|
||||
},
|
||||
size: 'xs',
|
||||
style: {
|
||||
transform: row.getIsExpanded() ? 'rotate(180deg)' : 'rotate(-90deg)',
|
||||
transition: 'transform 0.2s',
|
||||
},
|
||||
}),
|
||||
renderDetailPanel: ({ row }) => (
|
||||
<ChannelStreams channel={row.original} isExpanded={row.getIsExpanded()} />
|
||||
),
|
||||
renderRowActions: ({ row }) => (
|
||||
<Box sx={{ justifyContent: 'right' }}>
|
||||
<Center>
|
||||
<Tooltip label="Edit Channel">
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="transparent"
|
||||
color="yellow.5"
|
||||
onClick={() => {
|
||||
editChannel(row.original);
|
||||
}}
|
||||
>
|
||||
<SquarePen size="18" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Delete Channel">
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="transparent"
|
||||
color="red.9"
|
||||
onClick={() => deleteChannel(row.original.id)}
|
||||
>
|
||||
<SquareMinus size="18" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Preview Channel">
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="transparent"
|
||||
color="green.5"
|
||||
onClick={() => handleWatchStream(row.original.channel_number)}
|
||||
>
|
||||
<CirclePlay size="18" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Center>
|
||||
</Box>
|
||||
),
|
||||
mantineTableContainerProps: {
|
||||
style: {
|
||||
height: 'calc(100vh - 127px)',
|
||||
overflowY: 'auto',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Header Row: outside the Paper */}
|
||||
<Flex
|
||||
style={{ display: 'flex', alignItems: 'center', paddingBottom: 10 }}
|
||||
gap={15}
|
||||
>
|
||||
<Text
|
||||
w={88}
|
||||
h={24}
|
||||
style={{
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
fontWeight: 500,
|
||||
fontSize: '20px',
|
||||
lineHeight: 1,
|
||||
letterSpacing: '-0.3px',
|
||||
color: 'gray.6', // Adjust this to match MUI's theme.palette.text.secondary
|
||||
marginBottom: 0,
|
||||
}}
|
||||
>
|
||||
Channels
|
||||
</Text>
|
||||
<Flex
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
marginLeft: 10,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
w={37}
|
||||
h={17}
|
||||
style={{
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
fontWeight: 400,
|
||||
fontSize: '14px',
|
||||
lineHeight: 1,
|
||||
letterSpacing: '-0.3px',
|
||||
color: 'gray.6', // Adjust this to match MUI's theme.palette.text.secondary
|
||||
}}
|
||||
>
|
||||
Links:
|
||||
</Text>
|
||||
|
||||
<Group gap={5} style={{ paddingLeft: 10 }}>
|
||||
<Popover withArrow shadow="md">
|
||||
<Popover.Target>
|
||||
<Button
|
||||
leftSection={<Tv2 size={18} />}
|
||||
size="compact-sm"
|
||||
p={5}
|
||||
color="green"
|
||||
variant="subtle"
|
||||
style={{
|
||||
borderColor: theme.palette.custom.greenMain,
|
||||
color: theme.palette.custom.greenMain,
|
||||
}}
|
||||
>
|
||||
HDHR
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Group>
|
||||
<TextInput ref={hdhrUrlRef} value={hdhrUrl} size="small" />
|
||||
<ActionIcon
|
||||
onClick={copyHDHRUrl}
|
||||
size="sm"
|
||||
variant="transparent"
|
||||
color="gray.5"
|
||||
>
|
||||
<ContentCopy size="18" fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
|
||||
<Popover withArrow shadow="md">
|
||||
<Popover.Target>
|
||||
<Button
|
||||
leftSection={<ScreenShare size={18} />}
|
||||
size="compact-sm"
|
||||
p={5}
|
||||
variant="subtle"
|
||||
style={{
|
||||
borderColor: theme.palette.custom.indigoMain,
|
||||
color: theme.palette.custom.indigoMain,
|
||||
}}
|
||||
>
|
||||
M3U
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Group>
|
||||
<TextInput ref={m3uUrlRef} value={m3uUrl} size="small" />
|
||||
<ActionIcon
|
||||
onClick={copyM3UUrl}
|
||||
size="sm"
|
||||
variant="transparent"
|
||||
color="gray.5"
|
||||
>
|
||||
<ContentCopy size="18" fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
|
||||
<Popover withArrow shadow="md">
|
||||
<Popover.Target>
|
||||
<Button
|
||||
leftSection={<Scroll size={18} />}
|
||||
size="compact-sm"
|
||||
p={5}
|
||||
variant="subtle"
|
||||
color="gray.5"
|
||||
style={{
|
||||
borderColor: theme.palette.custom.greyBorder,
|
||||
color: theme.palette.custom.greyBorder,
|
||||
}}
|
||||
>
|
||||
EPG
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Group>
|
||||
<TextInput ref={epgUrlRef} value={epgUrl} size="small" />
|
||||
<ActionIcon
|
||||
onClick={copyEPGUrl}
|
||||
size="sm"
|
||||
variant="transparent"
|
||||
color="gray.5"
|
||||
>
|
||||
<ContentCopy size="18" fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Group>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
{/* Paper container: contains top toolbar and table (or ghost state) */}
|
||||
<Paper
|
||||
style={{
|
||||
// bgcolor: theme.palette.background.paper,
|
||||
// borderRadius: 2,
|
||||
// overflow: 'hidden',
|
||||
// display: 'flex',
|
||||
// flexDirection: 'column',
|
||||
height: 'calc(100vh - 75px)',
|
||||
}}
|
||||
>
|
||||
{/* Top toolbar with Remove, Assign, Auto-match, and Add buttons */}
|
||||
<Box
|
||||
style={{
|
||||
display: 'flex',
|
||||
// alignItems: 'center',
|
||||
// backgroundColor: theme.palette.background.paper,
|
||||
justifyContent: 'flex-end',
|
||||
padding: 10,
|
||||
// gap: 1,
|
||||
}}
|
||||
>
|
||||
<Flex gap={6}>
|
||||
<Button
|
||||
leftSection={<SquareMinus size={18} />}
|
||||
variant="default"
|
||||
size="xs"
|
||||
onClick={deleteChannels}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
|
||||
<Tooltip label="Assign Channel #s">
|
||||
<Button
|
||||
leftSection={<ArrowDown01 size={18} />}
|
||||
variant="default"
|
||||
size="xs"
|
||||
onClick={assignChannels}
|
||||
p={5}
|
||||
>
|
||||
Assign
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Auto-Match EPG">
|
||||
<Button
|
||||
leftSection={<Binary size={18} />}
|
||||
variant="default"
|
||||
size="xs"
|
||||
onClick={matchEpg}
|
||||
p={5}
|
||||
>
|
||||
Auto-Match
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Button
|
||||
leftSection={<SquarePlus size={18} />}
|
||||
variant="light"
|
||||
size="xs"
|
||||
onClick={() => editChannel()}
|
||||
p={5}
|
||||
color="green"
|
||||
style={{
|
||||
borderWidth: '1px',
|
||||
borderColor: 'green',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
{/* Table or ghost empty state inside Paper */}
|
||||
<Box>
|
||||
{Object.keys(channels).length === 0 && (
|
||||
<Box
|
||||
style={{
|
||||
paddingTop: 20,
|
||||
bgcolor: theme.palette.background.paper,
|
||||
}}
|
||||
>
|
||||
<Center>
|
||||
<Box
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
width: '55%',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
fontWeight: 400,
|
||||
fontSize: '20px',
|
||||
lineHeight: '28px',
|
||||
letterSpacing: '-0.3px',
|
||||
color: theme.palette.text.secondary,
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
It’s recommended to create channels after adding your M3U or
|
||||
streams.
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
fontWeight: 400,
|
||||
fontSize: '16px',
|
||||
lineHeight: '24px',
|
||||
letterSpacing: '-0.2px',
|
||||
color: theme.palette.text.secondary,
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
You can still create channels without streams if you’d like,
|
||||
and map them later.
|
||||
</Text>
|
||||
<Button
|
||||
leftSection={<SquarePlus size={18} />}
|
||||
variant="light"
|
||||
size="xs"
|
||||
onClick={() => editChannel()}
|
||||
color="gray"
|
||||
style={{
|
||||
marginTop: 20,
|
||||
borderWidth: '1px',
|
||||
borderColor: 'gray',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
Create Channel
|
||||
</Button>
|
||||
</Box>
|
||||
</Center>
|
||||
|
||||
<Center>
|
||||
<Box
|
||||
component="img"
|
||||
src={ghostImage}
|
||||
alt="Ghost"
|
||||
style={{
|
||||
paddingTop: 30,
|
||||
width: '120px',
|
||||
height: 'auto',
|
||||
opacity: 0.2,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
</Center>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{Object.keys(channels).length > 0 && (
|
||||
<MantineReactTable table={table} />
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<ChannelForm
|
||||
channel={channel}
|
||||
isOpen={channelModalOpen}
|
||||
onClose={closeChannelForm}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelsTable;
|
||||
|
|
@ -1,205 +0,0 @@
|
|||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
MaterialReactTable,
|
||||
MRT_ShowHideColumnsButton,
|
||||
MRT_ToggleFullScreenButton,
|
||||
useMaterialReactTable,
|
||||
} from 'material-react-table';
|
||||
import {
|
||||
Box,
|
||||
Grid2,
|
||||
Stack,
|
||||
Typography,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Checkbox,
|
||||
Select,
|
||||
MenuItem,
|
||||
Snackbar,
|
||||
} from '@mui/material';
|
||||
import API from '../../api';
|
||||
import {
|
||||
Delete as DeleteIcon,
|
||||
Edit as EditIcon,
|
||||
Add as AddIcon,
|
||||
SwapVert as SwapVertIcon,
|
||||
Check as CheckIcon,
|
||||
Close as CloseIcon,
|
||||
Refresh as RefreshIcon,
|
||||
} from '@mui/icons-material';
|
||||
import useEPGsStore from '../../store/epgs';
|
||||
import EPGForm from '../forms/EPG';
|
||||
import { TableHelper } from '../../helpers';
|
||||
|
||||
const EPGsTable = () => {
|
||||
const [epg, setEPG] = useState(null);
|
||||
const [epgModalOpen, setEPGModalOpen] = useState(false);
|
||||
const [rowSelection, setRowSelection] = useState([]);
|
||||
const [snackbarMessage, setSnackbarMessage] = useState('');
|
||||
const [snackbarOpen, setSnackbarOpen] = useState(false);
|
||||
|
||||
const epgs = useEPGsStore((state) => state.epgs);
|
||||
|
||||
const columns = useMemo(
|
||||
//column definitions...
|
||||
() => [
|
||||
{
|
||||
header: 'Name',
|
||||
accessorKey: 'name',
|
||||
},
|
||||
{
|
||||
header: 'Source Type',
|
||||
accessorKey: 'source_type',
|
||||
},
|
||||
{
|
||||
header: 'URL / API Key',
|
||||
accessorKey: 'max_streams',
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
//optionally access the underlying virtualizer instance
|
||||
const rowVirtualizerInstanceRef = useRef(null);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [sorting, setSorting] = useState([]);
|
||||
|
||||
const closeSnackbar = () => {
|
||||
setSnackbarOpen(false);
|
||||
};
|
||||
|
||||
const editEPG = async (epg = null) => {
|
||||
setEPG(epg);
|
||||
setEPGModalOpen(true);
|
||||
};
|
||||
|
||||
const deleteEPG = async (id) => {
|
||||
setIsLoading(true);
|
||||
await API.deleteEPG(id);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const refreshEPG = async (id) => {
|
||||
await API.refreshEPG(id);
|
||||
setSnackbarMessage('EPG refresh initiated');
|
||||
setSnackbarOpen(true);
|
||||
};
|
||||
|
||||
const closeEPGForm = () => {
|
||||
setEPG(null);
|
||||
setEPGModalOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
//scroll to the top of the table when the sorting changes
|
||||
try {
|
||||
rowVirtualizerInstanceRef.current?.scrollToIndex?.(0);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}, [sorting]);
|
||||
|
||||
const table = useMaterialReactTable({
|
||||
...TableHelper.defaultProperties,
|
||||
columns,
|
||||
data: epgs,
|
||||
enablePagination: false,
|
||||
enableRowVirtualization: true,
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
state: {
|
||||
isLoading,
|
||||
sorting,
|
||||
rowSelection,
|
||||
},
|
||||
rowVirtualizerInstanceRef, //optional
|
||||
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
|
||||
initialState: {
|
||||
density: 'compact',
|
||||
},
|
||||
enableRowActions: true,
|
||||
renderRowActions: ({ row }) => (
|
||||
<>
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="info" // Red color for delete actions
|
||||
onClick={() => editEPG(row.original)}
|
||||
sx={{ pt: 0, pb: 0 }}
|
||||
>
|
||||
<EditIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="error" // Red color for delete actions
|
||||
onClick={() => deleteEPG(row.original.id)}
|
||||
sx={{ pt: 0, pb: 0 }}
|
||||
>
|
||||
<DeleteIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="info" // Red color for delete actions
|
||||
onClick={() => refreshEPG(row.original.id)}
|
||||
sx={{ pt: 0, pb: 0 }}
|
||||
>
|
||||
<RefreshIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
</>
|
||||
),
|
||||
muiTableContainerProps: {
|
||||
sx: {
|
||||
height: 'calc(43vh - 0px)',
|
||||
},
|
||||
},
|
||||
renderTopToolbarCustomActions: ({ table }) => (
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography>EPGs</Typography>
|
||||
<Tooltip title="Add New EPG">
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="success" // Red color for delete actions
|
||||
variant="contained"
|
||||
onClick={() => editEPG()}
|
||||
>
|
||||
<AddIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
padding: 1,
|
||||
}}
|
||||
>
|
||||
<MaterialReactTable table={table} />
|
||||
|
||||
<EPGForm epg={epg} isOpen={epgModalOpen} onClose={closeEPGForm} />
|
||||
|
||||
<Snackbar
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
open={snackbarOpen}
|
||||
autoHideDuration={5000}
|
||||
onClose={closeSnackbar}
|
||||
message={snackbarMessage}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EPGsTable;
|
||||
226
frontend/src/components/tables/EPGsTable.jsx
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { MantineReactTable, useMantineReactTable } from 'mantine-react-table';
|
||||
import API from '../../api';
|
||||
import {
|
||||
Delete as DeleteIcon,
|
||||
Edit as EditIcon,
|
||||
Add as AddIcon,
|
||||
Refresh as RefreshIcon,
|
||||
} from '@mui/icons-material';
|
||||
import useEPGsStore from '../../store/epgs';
|
||||
import EPGForm from '../forms/EPG';
|
||||
import { TableHelper } from '../../helpers';
|
||||
import {
|
||||
ActionIcon,
|
||||
Text,
|
||||
Tooltip,
|
||||
Box,
|
||||
Paper,
|
||||
Button,
|
||||
Flex,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconSquarePlus } from '@tabler/icons-react';
|
||||
import { RefreshCcw, SquareMinus, SquarePen } from 'lucide-react';
|
||||
|
||||
const EPGsTable = () => {
|
||||
const [epg, setEPG] = useState(null);
|
||||
const [epgModalOpen, setEPGModalOpen] = useState(false);
|
||||
const [rowSelection, setRowSelection] = useState([]);
|
||||
|
||||
const epgs = useEPGsStore((state) => state.epgs);
|
||||
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const columns = useMemo(
|
||||
//column definitions...
|
||||
() => [
|
||||
{
|
||||
header: 'Name',
|
||||
accessorKey: 'name',
|
||||
},
|
||||
{
|
||||
header: 'Source Type',
|
||||
accessorKey: 'source_type',
|
||||
},
|
||||
{
|
||||
header: 'URL / API Key',
|
||||
accessorKey: 'max_streams',
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
//optionally access the underlying virtualizer instance
|
||||
const rowVirtualizerInstanceRef = useRef(null);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [sorting, setSorting] = useState([]);
|
||||
|
||||
const editEPG = async (epg = null) => {
|
||||
setEPG(epg);
|
||||
setEPGModalOpen(true);
|
||||
};
|
||||
|
||||
const deleteEPG = async (id) => {
|
||||
setIsLoading(true);
|
||||
await API.deleteEPG(id);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const refreshEPG = async (id) => {
|
||||
await API.refreshEPG(id);
|
||||
notifications.show({
|
||||
title: 'EPG refresh initiated',
|
||||
});
|
||||
};
|
||||
|
||||
const closeEPGForm = () => {
|
||||
setEPG(null);
|
||||
setEPGModalOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
//scroll to the top of the table when the sorting changes
|
||||
try {
|
||||
rowVirtualizerInstanceRef.current?.scrollToIndex?.(0);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}, [sorting]);
|
||||
|
||||
const table = useMantineReactTable({
|
||||
...TableHelper.defaultProperties,
|
||||
columns,
|
||||
data: epgs,
|
||||
enablePagination: false,
|
||||
enableRowVirtualization: true,
|
||||
enableRowSelection: false,
|
||||
renderTopToolbar: false,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
state: {
|
||||
isLoading,
|
||||
sorting,
|
||||
rowSelection,
|
||||
},
|
||||
rowVirtualizerInstanceRef, //optional
|
||||
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
|
||||
initialState: {
|
||||
density: 'compact',
|
||||
},
|
||||
enableRowActions: true,
|
||||
renderRowActions: ({ row }) => (
|
||||
<>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
size="sm" // Makes the button smaller
|
||||
color="yellow.5" // Red color for delete actions
|
||||
onClick={() => editEPG(row.original)}
|
||||
>
|
||||
<SquarePen size="18" /> {/* Small icon size */}
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
size="sm" // Makes the button smaller
|
||||
color="red.9" // Red color for delete actions
|
||||
onClick={() => deleteEPG(row.original.id)}
|
||||
>
|
||||
<SquareMinus size="18" /> {/* Small icon size */}
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
size="sm" // Makes the button smaller
|
||||
color="blue.5" // Red color for delete actions
|
||||
onClick={() => refreshEPG(row.original.id)}
|
||||
>
|
||||
<RefreshCcw size="18" /> {/* Small icon size */}
|
||||
</ActionIcon>
|
||||
</>
|
||||
),
|
||||
mantineTableContainerProps: {
|
||||
style: {
|
||||
height: 'calc(40vh - 0px)',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Flex
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
paddingBottom: 10,
|
||||
}}
|
||||
gap={15}
|
||||
>
|
||||
<Text
|
||||
h={24}
|
||||
style={{
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
fontWeight: 500,
|
||||
fontSize: '20px',
|
||||
lineHeight: 1,
|
||||
letterSpacing: '-0.3px',
|
||||
color: 'gray.6', // Adjust this to match MUI's theme.palette.text.secondary
|
||||
marginBottom: 0,
|
||||
}}
|
||||
>
|
||||
EPGs
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Paper
|
||||
style={{
|
||||
bgcolor: theme.palette.background.paper,
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
{/* Top toolbar with Remove, Assign, Auto-match, and Add buttons */}
|
||||
<Box
|
||||
style={{
|
||||
display: 'flex',
|
||||
// alignItems: 'center',
|
||||
// backgroundColor: theme.palette.background.paper,
|
||||
justifyContent: 'flex-end',
|
||||
padding: 10,
|
||||
// gap: 1,
|
||||
}}
|
||||
>
|
||||
<Flex gap={6}>
|
||||
<Tooltip label="Assign">
|
||||
<Button
|
||||
leftSection={<IconSquarePlus size={18} />}
|
||||
variant="light"
|
||||
size="xs"
|
||||
onClick={() => editEPG()}
|
||||
p={5}
|
||||
color="green"
|
||||
style={{
|
||||
borderWidth: '1px',
|
||||
borderColor: 'green',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
Add EPG
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<MantineReactTable table={table} />
|
||||
<EPGForm epg={epg} isOpen={epgModalOpen} onClose={closeEPGForm} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EPGsTable;
|
||||
|
|
@ -1,17 +1,5 @@
|
|||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
MaterialReactTable,
|
||||
useMaterialReactTable,
|
||||
} from 'material-react-table';
|
||||
import {
|
||||
Box,
|
||||
Stack,
|
||||
Typography,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Select,
|
||||
MenuItem,
|
||||
} from '@mui/material';
|
||||
import { MantineReactTable, useMantineReactTable } from 'mantine-react-table';
|
||||
import API from '../../api';
|
||||
import {
|
||||
Delete as DeleteIcon,
|
||||
|
|
@ -20,11 +8,53 @@ import {
|
|||
SwapVert as SwapVertIcon,
|
||||
Check as CheckIcon,
|
||||
Close as CloseIcon,
|
||||
Refresh as RefreshIcon,
|
||||
} from '@mui/icons-material';
|
||||
import {
|
||||
LiveTv as LiveTvIcon,
|
||||
ContentCopy,
|
||||
Tv as TvIcon,
|
||||
Clear as ClearIcon,
|
||||
IndeterminateCheckBox,
|
||||
CompareArrows,
|
||||
Code,
|
||||
AddBox,
|
||||
Hd as HdIcon,
|
||||
} from '@mui/icons-material';
|
||||
import usePlaylistsStore from '../../store/playlists';
|
||||
import M3UForm from '../forms/M3U';
|
||||
import { TableHelper } from '../../helpers';
|
||||
import {
|
||||
useMantineTheme,
|
||||
Paper,
|
||||
Button,
|
||||
Flex,
|
||||
Text,
|
||||
Box,
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
Select,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
Tv2,
|
||||
ScreenShare,
|
||||
Scroll,
|
||||
SquareMinus,
|
||||
Pencil,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
ArrowUpDown,
|
||||
TvMinimalPlay,
|
||||
SquarePen,
|
||||
RefreshCcw,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
IconArrowDown,
|
||||
IconArrowUp,
|
||||
IconDeviceDesktopSearch,
|
||||
IconSelector,
|
||||
IconSortAscendingNumbers,
|
||||
IconSquarePlus,
|
||||
} from '@tabler/icons-react'; // Import custom icons
|
||||
|
||||
const Example = () => {
|
||||
const [playlist, setPlaylist] = useState(null);
|
||||
|
|
@ -34,6 +64,8 @@ const Example = () => {
|
|||
|
||||
const playlists = usePlaylistsStore((state) => state.playlists);
|
||||
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const columns = useMemo(
|
||||
//column definitions...
|
||||
() => [
|
||||
|
|
@ -44,6 +76,17 @@ const Example = () => {
|
|||
{
|
||||
header: 'URL / File',
|
||||
accessorKey: 'server_url',
|
||||
Cell: ({ cell }) => (
|
||||
<div
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{cell.getValue()}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Max Streams',
|
||||
|
|
@ -55,7 +98,7 @@ const Example = () => {
|
|||
accessorKey: 'is_active',
|
||||
size: 100,
|
||||
sortingFn: 'basic',
|
||||
muiTableBodyCellProps: {
|
||||
mantineTableBodyCellProps: {
|
||||
align: 'left',
|
||||
},
|
||||
Cell: ({ cell }) => (
|
||||
|
|
@ -67,29 +110,6 @@ const Example = () => {
|
|||
)}
|
||||
</Box>
|
||||
),
|
||||
Filter: ({ column }) => (
|
||||
<Box>
|
||||
<Select
|
||||
size="small"
|
||||
variant="standard"
|
||||
value={activeFilterValue}
|
||||
onChange={(e) => {
|
||||
setActiveFilterValue(e.target.value);
|
||||
column.setFilterValue(e.target.value);
|
||||
}}
|
||||
displayEmpty
|
||||
fullWidth
|
||||
>
|
||||
<MenuItem value="all">All</MenuItem>
|
||||
<MenuItem value="true">Active</MenuItem>
|
||||
<MenuItem value="false">Inactive</MenuItem>
|
||||
</Select>
|
||||
</Box>
|
||||
),
|
||||
filterFn: (row, _columnId, activeFilterValue) => {
|
||||
if (!activeFilterValue) return true; // Show all if no filter
|
||||
return String(row.getValue('is_active')) === activeFilterValue;
|
||||
},
|
||||
},
|
||||
],
|
||||
[]
|
||||
|
|
@ -143,14 +163,15 @@ const Example = () => {
|
|||
}
|
||||
}, [sorting]);
|
||||
|
||||
const table = useMaterialReactTable({
|
||||
const table = useMantineReactTable({
|
||||
...TableHelper.defaultProperties,
|
||||
columns,
|
||||
data: playlists,
|
||||
enablePagination: false,
|
||||
enableRowVirtualization: true,
|
||||
// enableRowSelection: true,
|
||||
enableRowSelection: false,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
renderTopToolbar: false,
|
||||
onSortingChange: setSorting,
|
||||
state: {
|
||||
isLoading,
|
||||
|
|
@ -165,71 +186,103 @@ const Example = () => {
|
|||
enableRowActions: true,
|
||||
renderRowActions: ({ row }) => (
|
||||
<>
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="warning" // Red color for delete actions
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
size="sm"
|
||||
color="yellow.5"
|
||||
onClick={() => {
|
||||
editPlaylist(row.original);
|
||||
}}
|
||||
sx={{ pt: 0, pb: 0 }}
|
||||
>
|
||||
<EditIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="error" // Red color for delete actions
|
||||
<SquarePen size="18" />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
size="sm"
|
||||
color="red.9"
|
||||
onClick={() => deletePlaylist(row.original.id)}
|
||||
sx={{ pt: 0, pb: 0 }}
|
||||
>
|
||||
<DeleteIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="info" // Red color for delete actions
|
||||
variant="contained"
|
||||
<SquareMinus size="18" />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
size="sm"
|
||||
color="blue.5"
|
||||
onClick={() => refreshPlaylist(row.original.id)}
|
||||
sx={{ pt: 0, pb: 0 }}
|
||||
>
|
||||
<RefreshIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
<RefreshCcw size="18" />
|
||||
</ActionIcon>
|
||||
</>
|
||||
),
|
||||
muiTableContainerProps: {
|
||||
sx: {
|
||||
height: 'calc(43vh - 0px)',
|
||||
pr: 1,
|
||||
pl: 1,
|
||||
mantineTableContainerProps: {
|
||||
style: {
|
||||
height: 'calc(40vh - 0px)',
|
||||
},
|
||||
},
|
||||
renderTopToolbarCustomActions: ({ table }) => (
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography>M3U Accounts</Typography>
|
||||
<Tooltip title="Add New M3U Account">
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="success" // Red color for delete actions
|
||||
variant="contained"
|
||||
onClick={() => editPlaylist()}
|
||||
>
|
||||
<AddIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
padding: 1,
|
||||
}}
|
||||
>
|
||||
<MaterialReactTable table={table} />
|
||||
<Box>
|
||||
<Flex
|
||||
style={{ display: 'flex', alignItems: 'center', paddingBottom: 10 }}
|
||||
gap={15}
|
||||
>
|
||||
<Text
|
||||
h={24}
|
||||
style={{
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
fontWeight: 500,
|
||||
fontSize: '20px',
|
||||
lineHeight: 1,
|
||||
letterSpacing: '-0.3px',
|
||||
color: 'gray.6', // Adjust this to match MUI's theme.palette.text.secondary
|
||||
marginBottom: 0,
|
||||
}}
|
||||
>
|
||||
M3U Accounts
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Paper
|
||||
style={{
|
||||
bgcolor: theme.palette.background.paper,
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
{/* Top toolbar with Remove, Assign, Auto-match, and Add buttons */}
|
||||
<Box
|
||||
style={{
|
||||
display: 'flex',
|
||||
// alignItems: 'center',
|
||||
// backgroundColor: theme.palette.background.paper,
|
||||
justifyContent: 'flex-end',
|
||||
padding: 10,
|
||||
// gap: 1,
|
||||
}}
|
||||
>
|
||||
<Flex gap={6}>
|
||||
<Tooltip label="Assign">
|
||||
<Button
|
||||
leftSection={<IconSquarePlus size={14} />}
|
||||
variant="light"
|
||||
size="xs"
|
||||
onClick={() => editPlaylist()}
|
||||
p={5}
|
||||
color="green"
|
||||
style={{
|
||||
borderWidth: '1px',
|
||||
borderColor: 'green',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Paper>
|
||||
<MantineReactTable table={table} />
|
||||
|
||||
<M3UForm
|
||||
playlist={playlist}
|
||||
isOpen={playlistModalOpen}
|
||||
|
|
@ -1,49 +1,41 @@
|
|||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
MaterialReactTable,
|
||||
MRT_ShowHideColumnsButton,
|
||||
MRT_ToggleFullScreenButton,
|
||||
useMaterialReactTable,
|
||||
} from 'material-react-table';
|
||||
import {
|
||||
Box,
|
||||
Grid2,
|
||||
Stack,
|
||||
Typography,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Checkbox,
|
||||
Select,
|
||||
MenuItem,
|
||||
} from '@mui/material';
|
||||
import { MantineReactTable, useMantineReactTable } from 'mantine-react-table';
|
||||
import API from '../../api';
|
||||
import {
|
||||
Delete as DeleteIcon,
|
||||
Edit as EditIcon,
|
||||
Add as AddIcon,
|
||||
SwapVert as SwapVertIcon,
|
||||
Check as CheckIcon,
|
||||
Close as CloseIcon,
|
||||
Refresh as RefreshIcon,
|
||||
} from '@mui/icons-material';
|
||||
import useEPGsStore from '../../store/epgs';
|
||||
import StreamProfileForm from '../forms/StreamProfile';
|
||||
import useStreamProfilesStore from '../../store/streamProfiles';
|
||||
import { TableHelper } from '../../helpers';
|
||||
import useSettingsStore from '../../store/settings';
|
||||
import useAlertStore from '../../store/alerts';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import {
|
||||
Box,
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
Text,
|
||||
Paper,
|
||||
Flex,
|
||||
Button,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import { IconSquarePlus } from '@tabler/icons-react';
|
||||
import { SquareMinus, SquarePen } from 'lucide-react';
|
||||
|
||||
const StreamProfiles = () => {
|
||||
const [profile, setProfile] = useState(null);
|
||||
const [profileModalOpen, setProfileModalOpen] = useState(false);
|
||||
const [rowSelection, setRowSelection] = useState([]);
|
||||
const [snackbarMessage, setSnackbarMessage] = useState('');
|
||||
const [snackbarOpen, setSnackbarOpen] = useState(false);
|
||||
const [activeFilterValue, setActiveFilterValue] = useState('all');
|
||||
|
||||
const streamProfiles = useStreamProfilesStore((state) => state.profiles);
|
||||
const { settings } = useSettingsStore();
|
||||
const { showAlert } = useAlertStore();
|
||||
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const columns = useMemo(
|
||||
//column definitions...
|
||||
|
|
@ -81,23 +73,18 @@ const StreamProfiles = () => {
|
|||
<Box>
|
||||
<Select
|
||||
size="small"
|
||||
variant="standard"
|
||||
value={activeFilterValue}
|
||||
onChange={(e) => {
|
||||
setActiveFilterValue(e.target.value);
|
||||
column.setFilterValue(e.target.value);
|
||||
}}
|
||||
displayEmpty
|
||||
fullWidth
|
||||
>
|
||||
<MenuItem value="all">All</MenuItem>
|
||||
<MenuItem value="true">Active</MenuItem>
|
||||
<MenuItem value="false">Inactive</MenuItem>
|
||||
</Select>
|
||||
data={['All', 'Active', 'Inactive']}
|
||||
/>
|
||||
</Box>
|
||||
),
|
||||
filterFn: (row, _columnId, filterValue) => {
|
||||
if (filterValue == 'all') return true; // Show all if no filter
|
||||
if (filterValue == 'all') return true;
|
||||
return String(row.getValue('is_active')) === filterValue;
|
||||
},
|
||||
},
|
||||
|
|
@ -118,7 +105,10 @@ const StreamProfiles = () => {
|
|||
|
||||
const deleteStreamProfile = async (id) => {
|
||||
if (id == settings['default-stream-profile'].value) {
|
||||
showAlert('Cannot delete default stream-profile', 'error');
|
||||
notifications.show({
|
||||
title: 'Cannot delete default stream-profile',
|
||||
color: 'red.5',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -145,19 +135,20 @@ const StreamProfiles = () => {
|
|||
}
|
||||
}, [sorting]);
|
||||
|
||||
const table = useMaterialReactTable({
|
||||
const table = useMantineReactTable({
|
||||
...TableHelper.defaultProperties,
|
||||
columns,
|
||||
data: streamProfiles,
|
||||
enablePagination: false,
|
||||
enableRowVirtualization: true,
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
// enableRowSelection: true,
|
||||
renderTopToolbar: false,
|
||||
// onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
state: {
|
||||
isLoading,
|
||||
sorting,
|
||||
rowSelection,
|
||||
// rowSelection,
|
||||
},
|
||||
rowVirtualizerInstanceRef, //optional
|
||||
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
|
||||
|
|
@ -167,59 +158,98 @@ const StreamProfiles = () => {
|
|||
enableRowActions: true,
|
||||
renderRowActions: ({ row }) => (
|
||||
<>
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="warning" // Red color for delete actions
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
color="yellow.5"
|
||||
size="sm"
|
||||
onClick={() => editStreamProfile(row.original)}
|
||||
sx={{ pt: 0, pb: 0 }}
|
||||
>
|
||||
<EditIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="error" // Red color for delete actions
|
||||
<SquarePen size="18" /> {/* Small icon size */}
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
size="sm"
|
||||
color="red.9"
|
||||
onClick={() => deleteStreamProfile(row.original.id)}
|
||||
sx={{ pt: 0, pb: 0 }}
|
||||
>
|
||||
<DeleteIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
<SquareMinus fontSize="small" /> {/* Small icon size */}
|
||||
</ActionIcon>
|
||||
</>
|
||||
),
|
||||
muiTableContainerProps: {
|
||||
sx: {
|
||||
height: 'calc(100vh - 73px)', // Subtract padding to avoid cutoff
|
||||
overflowY: 'auto', // Internal scrolling for the table
|
||||
mantineTableContainerProps: {
|
||||
style: {
|
||||
height: 'calc(100vh - 120px)',
|
||||
overflowY: 'auto',
|
||||
},
|
||||
},
|
||||
renderTopToolbarCustomActions: ({ table }) => (
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography>Stream Profiles</Typography>
|
||||
<Tooltip title="Add New Stream Profile">
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="success" // Red color for delete actions
|
||||
variant="contained"
|
||||
onClick={() => editStreamProfile()}
|
||||
>
|
||||
<AddIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
padding: 1,
|
||||
}}
|
||||
>
|
||||
<MaterialReactTable table={table} />
|
||||
<Box>
|
||||
<Flex
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
paddingBottom: 10,
|
||||
}}
|
||||
gap={15}
|
||||
>
|
||||
<Text
|
||||
h={24}
|
||||
style={{
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
fontWeight: 500,
|
||||
fontSize: '20px',
|
||||
lineHeight: 1,
|
||||
letterSpacing: '-0.3px',
|
||||
color: 'gray.6', // Adjust this to match MUI's theme.palette.text.secondary
|
||||
marginBottom: 0,
|
||||
}}
|
||||
>
|
||||
Stream Profiles
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Paper
|
||||
style={{
|
||||
bgcolor: theme.palette.background.paper,
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
{/* Top toolbar with Remove, Assign, Auto-match, and Add buttons */}
|
||||
<Box
|
||||
style={{
|
||||
display: 'flex',
|
||||
// alignItems: 'center',
|
||||
// backgroundColor: theme.palette.background.paper,
|
||||
justifyContent: 'flex-end',
|
||||
padding: 10,
|
||||
// gap: 1,
|
||||
}}
|
||||
>
|
||||
<Flex gap={6}>
|
||||
<Tooltip label="Assign">
|
||||
<Button
|
||||
leftSection={<IconSquarePlus size={18} />}
|
||||
variant="light"
|
||||
size="xs"
|
||||
onClick={() => editStreamProfile()}
|
||||
p={5}
|
||||
color="green"
|
||||
style={{
|
||||
borderWidth: '1px',
|
||||
borderColor: 'green',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
Add Stream Profile
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<MantineReactTable table={table} />
|
||||
|
||||
<StreamProfileForm
|
||||
profile={profile}
|
||||
|
|
@ -1,844 +0,0 @@
|
|||
import { useEffect, useMemo, useCallback, useState, useRef } from 'react';
|
||||
import {
|
||||
MaterialReactTable,
|
||||
useMaterialReactTable,
|
||||
MRT_ShowHideColumnsButton, // <-- import this
|
||||
} from 'material-react-table';
|
||||
import {
|
||||
Box,
|
||||
Stack,
|
||||
Typography,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Button,
|
||||
Menu,
|
||||
MenuItem,
|
||||
TextField,
|
||||
Autocomplete,
|
||||
InputAdornment,
|
||||
Paper,
|
||||
} from '@mui/material';
|
||||
import API from '../../api';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import {
|
||||
Delete as DeleteIcon,
|
||||
Edit as EditIcon,
|
||||
Add as AddIcon,
|
||||
MoreVert as MoreVertIcon,
|
||||
PlaylistAdd as PlaylistAddIcon,
|
||||
IndeterminateCheckBox,
|
||||
AddBox,
|
||||
} from '@mui/icons-material';
|
||||
import { TableHelper } from '../../helpers';
|
||||
import StreamForm from '../forms/Stream';
|
||||
import usePlaylistsStore from '../../store/playlists';
|
||||
import useChannelsStore from '../../store/channels';
|
||||
import { useDebounce } from '../../utils';
|
||||
import { SquarePlus, ListPlus } from 'lucide-react';
|
||||
|
||||
const StreamsTable = ({}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
/**
|
||||
* useState
|
||||
*/
|
||||
const [rowSelection, setRowSelection] = useState([]);
|
||||
const [stream, setStream] = useState(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [moreActionsAnchorEl, setMoreActionsAnchorEl] = useState(null);
|
||||
const [groupOptions, setGroupOptions] = useState([]);
|
||||
const [m3uOptions, setM3uOptions] = useState([]);
|
||||
const [actionsOpenRow, setActionsOpenRow] = useState(null);
|
||||
|
||||
const [data, setData] = useState([]); // Holds fetched data
|
||||
const [rowCount, setRowCount] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [sorting, setSorting] = useState([]);
|
||||
const [selectedStreamIds, setSelectedStreamIds] = useState([]);
|
||||
const [unselectedStreamIds, setUnselectedStreamIds] = useState([]);
|
||||
// const [allRowsSelected, setAllRowsSelected] = useState(false);
|
||||
const [pagination, setPagination] = useState({
|
||||
pageIndex: 0,
|
||||
pageSize: 25,
|
||||
});
|
||||
const [filters, setFilters] = useState({
|
||||
name: '',
|
||||
group_name: '',
|
||||
m3u_account: '',
|
||||
});
|
||||
const debouncedFilters = useDebounce(filters, 500);
|
||||
const hasData = data.length > 0;
|
||||
|
||||
/**
|
||||
* Stores
|
||||
*/
|
||||
const { playlists } = usePlaylistsStore();
|
||||
const { channelsPageSelection } = useChannelsStore();
|
||||
const channelSelectionStreams = useChannelsStore(
|
||||
(state) => state.channels[state.channelsPageSelection[0]?.id]?.streams
|
||||
);
|
||||
|
||||
const isMoreActionsOpen = Boolean(moreActionsAnchorEl);
|
||||
|
||||
// Access the row virtualizer instance (optional)
|
||||
const rowVirtualizerInstanceRef = useRef(null);
|
||||
|
||||
const eligibleSelectedStreamId = selectedStreamIds.find(
|
||||
(id) =>
|
||||
channelsPageSelection.length === 1 &&
|
||||
!(
|
||||
channelSelectionStreams &&
|
||||
channelSelectionStreams.map((stream) => stream.id).includes(id)
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* useMemo
|
||||
*/
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
header: 'Name',
|
||||
accessorKey: 'name',
|
||||
muiTableHeadCellProps: {
|
||||
sx: { textAlign: 'center' }, // Center-align the header
|
||||
},
|
||||
Header: ({ column }) => (
|
||||
<TextField
|
||||
variant="standard"
|
||||
name="name"
|
||||
label="Name"
|
||||
value={filters.name || ''}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={handleFilterChange}
|
||||
size="small"
|
||||
margin="none"
|
||||
fullWidth
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Group',
|
||||
accessorKey: 'group_name',
|
||||
Header: ({ column }) => (
|
||||
<Autocomplete
|
||||
disablePortal
|
||||
options={groupOptions}
|
||||
size="small"
|
||||
clearOnEscape
|
||||
onChange={(e, value) => {
|
||||
e.stopPropagation();
|
||||
handleGroupChange(value);
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Group"
|
||||
size="small"
|
||||
variant="standard"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
sx={{
|
||||
pb: 0.8,
|
||||
'& .MuiInputBase-root': { fontSize: '0.875rem' },
|
||||
'& .MuiInputLabel-root': { fontSize: '0.75rem' },
|
||||
width: '200px',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'M3U',
|
||||
size: 100,
|
||||
accessorFn: (row) =>
|
||||
playlists.find((playlist) => playlist.id === row.m3u_account)?.name,
|
||||
Header: ({ column }) => (
|
||||
<Autocomplete
|
||||
disablePortal
|
||||
options={playlists.map((playlist) => ({
|
||||
label: playlist.name,
|
||||
value: playlist.id,
|
||||
}))}
|
||||
size="small"
|
||||
clearOnEscape
|
||||
onChange={(e, value) => {
|
||||
e.stopPropagation();
|
||||
handleM3UChange(value);
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="M3U"
|
||||
size="small"
|
||||
variant="standard"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
sx={{
|
||||
pb: 0.8,
|
||||
'& .MuiInputBase-root': { fontSize: '0.875rem' },
|
||||
'& .MuiInputLabel-root': { fontSize: '0.75rem' },
|
||||
width: '200px',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
[playlists, groupOptions, filters]
|
||||
);
|
||||
|
||||
/**
|
||||
* Functions
|
||||
*/
|
||||
const handleFilterChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
[name]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleGroupChange = (value) => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
group_name: value ? value.value : '',
|
||||
}));
|
||||
};
|
||||
|
||||
const handleM3UChange = (value) => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
m3u_account: value ? value.value : '',
|
||||
}));
|
||||
};
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.append('page', pagination.pageIndex + 1);
|
||||
params.append('page_size', pagination.pageSize);
|
||||
|
||||
// Apply sorting
|
||||
if (sorting.length > 0) {
|
||||
const sortField = sorting[0].id;
|
||||
const sortDirection = sorting[0].desc ? '-' : '';
|
||||
params.append('ordering', `${sortDirection}${sortField}`);
|
||||
}
|
||||
|
||||
// Apply debounced filters
|
||||
Object.entries(debouncedFilters).forEach(([key, value]) => {
|
||||
if (value) params.append(key, value);
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await API.queryStreams(params);
|
||||
setData(result.results);
|
||||
setRowCount(result.count);
|
||||
|
||||
const newSelection = {};
|
||||
result.results.forEach((item, index) => {
|
||||
if (selectedStreamIds.includes(item.id)) {
|
||||
newSelection[index] = true;
|
||||
}
|
||||
});
|
||||
|
||||
// ✅ Only update rowSelection if it's different
|
||||
if (JSON.stringify(newSelection) !== JSON.stringify(rowSelection)) {
|
||||
setRowSelection(newSelection);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error);
|
||||
}
|
||||
|
||||
const groups = await API.getStreamGroups();
|
||||
setGroupOptions(groups);
|
||||
|
||||
setIsLoading(false);
|
||||
}, [pagination, sorting, debouncedFilters]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log(pagination);
|
||||
}, [pagination]);
|
||||
|
||||
// Fallback: Individual creation (optional)
|
||||
const createChannelFromStream = async (stream) => {
|
||||
await API.createChannelFromStream({
|
||||
channel_name: stream.name,
|
||||
channel_number: null,
|
||||
stream_id: stream.id,
|
||||
});
|
||||
};
|
||||
|
||||
// Bulk creation: create channels from selected streams in one API call
|
||||
const createChannelsFromStreams = async () => {
|
||||
setIsLoading(true);
|
||||
await API.createChannelsFromStreams(
|
||||
selectedStreamIds.map((stream_id) => ({
|
||||
stream_id,
|
||||
}))
|
||||
);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const editStream = async (stream = null) => {
|
||||
setStream(stream);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const deleteStream = async (id) => {
|
||||
await API.deleteStream(id);
|
||||
};
|
||||
|
||||
const deleteStreams = async () => {
|
||||
setIsLoading(true);
|
||||
await API.deleteStreams(selectedStreamIds);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const closeStreamForm = () => {
|
||||
setStream(null);
|
||||
setModalOpen(false);
|
||||
};
|
||||
|
||||
const addStreamsToChannel = async () => {
|
||||
const { streams, ...channel } = { ...channelsPageSelection[0] };
|
||||
await API.updateChannel({
|
||||
...channel,
|
||||
stream_ids: [
|
||||
...new Set(
|
||||
channelSelectionStreams
|
||||
.map((stream) => stream.id)
|
||||
.concat(selectedStreamIds)
|
||||
),
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const addStreamToChannel = async (streamId) => {
|
||||
const { streams, ...channel } = { ...channelsPageSelection[0] };
|
||||
await API.updateChannel({
|
||||
...channel,
|
||||
stream_ids: [
|
||||
...new Set(
|
||||
channelSelectionStreams.map((stream) => stream.id).concat([streamId])
|
||||
),
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const handleMoreActionsClick = (event, rowId) => {
|
||||
setMoreActionsAnchorEl(event.currentTarget);
|
||||
setActionsOpenRow(rowId);
|
||||
};
|
||||
|
||||
const handleMoreActionsClose = () => {
|
||||
setMoreActionsAnchorEl(null);
|
||||
setActionsOpenRow(null);
|
||||
};
|
||||
|
||||
const onRowSelectionChange = (updater) => {
|
||||
setRowSelection((prevRowSelection) => {
|
||||
const newRowSelection =
|
||||
typeof updater === 'function' ? updater(prevRowSelection) : updater;
|
||||
|
||||
const updatedSelected = new Set([...selectedStreamIds]);
|
||||
table.getRowModel().rows.forEach((row) => {
|
||||
if (newRowSelection[row.id] === undefined || !newRowSelection[row.id]) {
|
||||
updatedSelected.delete(row.original.id);
|
||||
} else {
|
||||
updatedSelected.add(row.original.id);
|
||||
}
|
||||
});
|
||||
setSelectedStreamIds([...updatedSelected]);
|
||||
|
||||
return newRowSelection;
|
||||
});
|
||||
};
|
||||
|
||||
const onSelectAllChange = async (e) => {
|
||||
const selectAll = e.target.checked;
|
||||
if (selectAll) {
|
||||
// Get all stream IDs for current view
|
||||
const params = new URLSearchParams();
|
||||
Object.entries(debouncedFilters).forEach(([key, value]) => {
|
||||
if (value) params.append(key, value);
|
||||
});
|
||||
const ids = await API.getAllStreamIds(params);
|
||||
setSelectedStreamIds(ids);
|
||||
} else {
|
||||
setSelectedStreamIds([]);
|
||||
}
|
||||
|
||||
const newSelection = {};
|
||||
table.getRowModel().rows.forEach((item, index) => {
|
||||
newSelection[index] = selectAll;
|
||||
});
|
||||
setRowSelection(newSelection);
|
||||
};
|
||||
|
||||
const onPaginationChange = (updater) => {
|
||||
const newPagination = updater(pagination);
|
||||
if (JSON.stringify(newPagination) === JSON.stringify(pagination)) {
|
||||
// Prevent infinite re-render when there are no results
|
||||
return;
|
||||
}
|
||||
|
||||
setPagination(updater);
|
||||
};
|
||||
|
||||
const table = useMaterialReactTable({
|
||||
...TableHelper.defaultProperties,
|
||||
columns,
|
||||
data,
|
||||
enablePagination: true,
|
||||
manualPagination: true,
|
||||
enableTopToolbar: false, // completely removes MRT's built-in top toolbar
|
||||
enableRowVirtualization: true,
|
||||
renderTopToolbar: () => null, // Removes the entire top toolbar
|
||||
renderToolbarInternalActions: () => null,
|
||||
rowVirtualizerInstanceRef,
|
||||
rowVirtualizerOptions: { overscan: 5 }, // optionally customize the row virtualizer
|
||||
manualSorting: true,
|
||||
enableBottomToolbar: true,
|
||||
enableStickyHeader: true,
|
||||
onPaginationChange: onPaginationChange,
|
||||
onSortingChange: setSorting,
|
||||
rowCount: rowCount,
|
||||
enableRowSelection: true,
|
||||
muiSelectAllCheckboxProps: {
|
||||
checked: selectedStreamIds.length === rowCount && rowCount > 0,
|
||||
indeterminate:
|
||||
selectedStreamIds.length > 0 && selectedStreamIds.length !== rowCount,
|
||||
onChange: onSelectAllChange,
|
||||
},
|
||||
onRowSelectionChange: onRowSelectionChange,
|
||||
initialState: {
|
||||
density: 'compact',
|
||||
},
|
||||
state: {
|
||||
isLoading,
|
||||
sorting,
|
||||
pagination,
|
||||
rowSelection,
|
||||
},
|
||||
enableRowActions: true,
|
||||
positionActionsColumn: 'first',
|
||||
|
||||
enableHiding: false,
|
||||
|
||||
// you can still use the custom toolbar callback if you like
|
||||
renderTopToolbarCustomActions: ({ table }) => {
|
||||
const selectedRowCount = table.getSelectedRowModel().rows.length;
|
||||
// optionally do something with selectedRowCount
|
||||
},
|
||||
|
||||
renderRowActions: ({ row }) => (
|
||||
<>
|
||||
<Tooltip title="Add to Channel">
|
||||
<IconButton
|
||||
size="small"
|
||||
color="info"
|
||||
onClick={() => addStreamToChannel(row.original.id)}
|
||||
sx={{ py: 0, px: 0.5 }}
|
||||
disabled={
|
||||
channelsPageSelection.length !== 1 ||
|
||||
(channelSelectionStreams &&
|
||||
channelSelectionStreams
|
||||
.map((stream) => stream.id)
|
||||
.includes(row.original.id))
|
||||
}
|
||||
>
|
||||
<ListPlus size="18" fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Create New Channel">
|
||||
<IconButton
|
||||
size="small"
|
||||
color="success"
|
||||
onClick={() => createChannelFromStream(row.original)}
|
||||
sx={{ py: 0, px: 0.5 }}
|
||||
>
|
||||
<SquarePlus size="18" fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<IconButton
|
||||
onClick={(event) => handleMoreActionsClick(event, row.original.id)}
|
||||
size="small"
|
||||
sx={{ py: 0, px: 0.5 }}
|
||||
>
|
||||
<MoreVertIcon />
|
||||
</IconButton>
|
||||
<Menu
|
||||
anchorEl={moreActionsAnchorEl}
|
||||
open={isMoreActionsOpen && actionsOpenRow === row.original.id}
|
||||
onClose={handleMoreActionsClose}
|
||||
>
|
||||
<MenuItem
|
||||
onClick={() => editStream(row.original.id)}
|
||||
disabled={row.original.m3u_account ? true : false}
|
||||
>
|
||||
Edit
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => deleteStream(row.original.id)}>
|
||||
Delete Stream
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
),
|
||||
muiPaginationProps: {
|
||||
size: 'small',
|
||||
rowsPerPageOptions: [25, 50, 100, 250, 500, 1000, 10000],
|
||||
labelRowsPerPage: 'Rows per page',
|
||||
},
|
||||
muiTableContainerProps: {
|
||||
sx: {
|
||||
height: 'calc(100vh - 145px)',
|
||||
overflowY: 'auto',
|
||||
},
|
||||
},
|
||||
displayColumnDefOptions: {
|
||||
'mrt-row-actions': {
|
||||
size: 68,
|
||||
},
|
||||
'mrt-row-select': {
|
||||
size: 50,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* useEffects
|
||||
*/
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Scroll to the top of the table when sorting changes
|
||||
try {
|
||||
rowVirtualizerInstanceRef.current?.scrollToIndex?.(0);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}, [sorting]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Header Row */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', pb: 1 }}>
|
||||
<Typography
|
||||
sx={{
|
||||
width: 88,
|
||||
height: 24,
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
fontWeight: 500,
|
||||
fontSize: '20px',
|
||||
lineHeight: 1,
|
||||
letterSpacing: '-0.3px',
|
||||
color: theme.palette.text.secondary,
|
||||
mb: 0,
|
||||
}}
|
||||
>
|
||||
Streams
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Paper container with ghost state vs table */}
|
||||
<Paper
|
||||
sx={{
|
||||
bgcolor: theme.palette.background.paper,
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
height: 'calc(100vh - 75px)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Top toolbar: always visible */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
justifyContent: 'flex-end',
|
||||
p: 1,
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Tooltip title="Remove">
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={
|
||||
<IndeterminateCheckBox
|
||||
sx={{ fontSize: 16, color: theme.palette.text.secondary }}
|
||||
/>
|
||||
}
|
||||
sx={{
|
||||
borderColor: theme.palette.custom.borderDefault,
|
||||
borderRadius: '4px',
|
||||
borderWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
height: '25px',
|
||||
opacity: 0.4,
|
||||
color: theme.palette.text.secondary,
|
||||
fontSize: '0.85rem',
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
'&:hover': {
|
||||
borderColor: theme.palette.custom.borderHover,
|
||||
},
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="Add to Channel">
|
||||
<span>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={
|
||||
<AddBox
|
||||
sx={{ fontSize: 16, color: theme.palette.text.secondary }}
|
||||
/>
|
||||
}
|
||||
disabled={
|
||||
channelsPageSelection.length !== 1 ||
|
||||
!eligibleSelectedStreamId
|
||||
}
|
||||
onClick={() => {
|
||||
if (eligibleSelectedStreamId) {
|
||||
addStreamToChannel(eligibleSelectedStreamId);
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
minWidth: '57px',
|
||||
height: '25px',
|
||||
borderRadius: '4px',
|
||||
borderColor: theme.palette.custom.successBorder,
|
||||
borderWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
backgroundColor: theme.palette.custom.successBg,
|
||||
color: '#fff',
|
||||
fontSize: '0.85rem',
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.custom.successBgHover,
|
||||
},
|
||||
}}
|
||||
>
|
||||
Add to Channel
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title="Create Channels">
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={
|
||||
<AddBox
|
||||
sx={{ fontSize: 16, color: theme.palette.text.secondary }}
|
||||
/>
|
||||
}
|
||||
disabled={selectedStreamIds.length === 0}
|
||||
sx={{
|
||||
minWidth: '57px',
|
||||
height: '25px',
|
||||
borderRadius: '4px',
|
||||
borderColor: theme.palette.custom.successBorder,
|
||||
borderWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
backgroundColor: theme.palette.custom.successBg,
|
||||
color: '#fff',
|
||||
fontSize: '0.85rem',
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.custom.successBgHover,
|
||||
},
|
||||
}}
|
||||
onClick={() => createChannelsFromStreams()}
|
||||
>
|
||||
Create Channels
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="Add Channel">
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={() => editStream()}
|
||||
startIcon={
|
||||
<AddBox
|
||||
sx={{
|
||||
fontSize: 16,
|
||||
color: theme.palette.custom.successIcon,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
sx={{
|
||||
minWidth: '57px',
|
||||
height: '25px',
|
||||
borderRadius: '4px',
|
||||
borderColor: theme.palette.custom.successBorder,
|
||||
borderWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
backgroundColor: theme.palette.custom.successBg,
|
||||
color: '#fff',
|
||||
fontSize: '0.85rem',
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.custom.successBgHover,
|
||||
},
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Show/Hide Columns Button added to top bar */}
|
||||
<Tooltip title="Show/Hide Columns">
|
||||
<MRT_ShowHideColumnsButton table={table} />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Main content */}
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
position: 'relative',
|
||||
bgcolor: theme.palette.background.paper,
|
||||
}}
|
||||
>
|
||||
<StreamForm stream={stream} isOpen={modalOpen} onClose={closeStreamForm} />
|
||||
{hasData ? (
|
||||
<Box sx={{ width: "100%", height: "calc(102vh - 150px)", overflow: "auto" }}>
|
||||
<MaterialReactTable
|
||||
table={table}
|
||||
sx={{ height: "100%" }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
) : (
|
||||
// Ghost state placeholder, shown when there is no data
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: '25%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: '420px',
|
||||
height: '247px',
|
||||
border: '1px solid #52525C',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
p: 2,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
fontWeight: 400,
|
||||
fontSize: '20px',
|
||||
lineHeight: '28px',
|
||||
letterSpacing: '-0.3px',
|
||||
color: '#D4D4D8',
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
Getting started
|
||||
</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
fontWeight: 400,
|
||||
fontSize: '16px',
|
||||
lineHeight: '24px',
|
||||
letterSpacing: '-0.2px',
|
||||
color: '#9FA3A9',
|
||||
width: '372px',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
In order to get started, add your M3U or start adding custom streams.
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
sx={{
|
||||
minWidth: '127px',
|
||||
height: '25px',
|
||||
borderRadius: '4px',
|
||||
borderWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
color: theme.palette.text.secondary,
|
||||
borderColor: theme.palette.custom.borderHover,
|
||||
backgroundColor: '#1f1f23',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
fontWeight: 400,
|
||||
fontSize: '0.85rem',
|
||||
letterSpacing: '-0.2px',
|
||||
textTransform: 'none',
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
'&:hover': {
|
||||
borderColor: theme.palette.custom.borderDefault,
|
||||
backgroundColor: '#17171B',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Add M3U
|
||||
</Button>
|
||||
<Typography sx={{ fontSize: '14px', color: '#71717B', mb: 1 }}>
|
||||
or
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
sx={{
|
||||
minWidth: '127px',
|
||||
height: '25px',
|
||||
borderRadius: '4px',
|
||||
borderWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
color: theme.palette.text.secondary,
|
||||
borderColor: theme.palette.custom.borderHover,
|
||||
backgroundColor: '#1f1f23',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
fontWeight: 400,
|
||||
fontSize: '0.85rem',
|
||||
letterSpacing: '-0.2px',
|
||||
textTransform: 'none',
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
'&:hover': {
|
||||
borderColor: theme.palette.custom.borderDefault,
|
||||
backgroundColor: '#17171B',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Add Individual Stream
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default StreamsTable;
|
||||
746
frontend/src/components/tables/StreamsTable.jsx
Normal file
|
|
@ -0,0 +1,746 @@
|
|||
import { useEffect, useMemo, useCallback, useState, useRef } from 'react';
|
||||
import { MantineReactTable, useMantineReactTable } from 'mantine-react-table';
|
||||
import API from '../../api';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import {
|
||||
Delete as DeleteIcon,
|
||||
Edit as EditIcon,
|
||||
Add as AddIcon,
|
||||
MoreVert as MoreVertIcon,
|
||||
PlaylistAdd as PlaylistAddIcon,
|
||||
IndeterminateCheckBox,
|
||||
AddBox,
|
||||
} from '@mui/icons-material';
|
||||
import { TableHelper } from '../../helpers';
|
||||
import StreamForm from '../forms/Stream';
|
||||
import usePlaylistsStore from '../../store/playlists';
|
||||
import useChannelsStore from '../../store/channels';
|
||||
import { useDebounce } from '../../utils';
|
||||
import { SquarePlus, ListPlus, SquareMinus } from 'lucide-react';
|
||||
import {
|
||||
TextInput,
|
||||
ActionIcon,
|
||||
Select,
|
||||
Tooltip,
|
||||
Menu,
|
||||
Flex,
|
||||
Box,
|
||||
Text,
|
||||
Paper,
|
||||
Button,
|
||||
Card,
|
||||
Stack,
|
||||
Title,
|
||||
Divider,
|
||||
Center,
|
||||
Pagination,
|
||||
Group,
|
||||
NumberInput,
|
||||
NativeSelect,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconArrowDown,
|
||||
IconArrowUp,
|
||||
IconDeviceDesktopSearch,
|
||||
IconSelector,
|
||||
IconSortAscendingNumbers,
|
||||
IconSquarePlus,
|
||||
} from '@tabler/icons-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const StreamsTable = ({}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
/**
|
||||
* useState
|
||||
*/
|
||||
const [rowSelection, setRowSelection] = useState([]);
|
||||
const [stream, setStream] = useState(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [moreActionsAnchorEl, setMoreActionsAnchorEl] = useState(null);
|
||||
const [groupOptions, setGroupOptions] = useState([]);
|
||||
const [m3uOptions, setM3uOptions] = useState([]);
|
||||
const [actionsOpenRow, setActionsOpenRow] = useState(null);
|
||||
const [initialDataCount, setInitialDataCount] = useState(null);
|
||||
|
||||
const [data, setData] = useState([]); // Holds fetched data
|
||||
const [rowCount, setRowCount] = useState(0);
|
||||
const [pageCount, setPageCount] = useState(0);
|
||||
const [paginationString, setPaginationString] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [sorting, setSorting] = useState([]);
|
||||
const [selectedStreamIds, setSelectedStreamIds] = useState([]);
|
||||
const [unselectedStreamIds, setUnselectedStreamIds] = useState([]);
|
||||
// const [allRowsSelected, setAllRowsSelected] = useState(false);
|
||||
const [pagination, setPagination] = useState({
|
||||
pageIndex: 0,
|
||||
pageSize: 250,
|
||||
});
|
||||
const [filters, setFilters] = useState({
|
||||
name: '',
|
||||
group_name: '',
|
||||
m3u_account: '',
|
||||
});
|
||||
const debouncedFilters = useDebounce(filters, 500);
|
||||
const hasData = data.length > 0;
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
/**
|
||||
* Stores
|
||||
*/
|
||||
const { playlists } = usePlaylistsStore();
|
||||
const { channelsPageSelection } = useChannelsStore();
|
||||
const channelSelectionStreams = useChannelsStore(
|
||||
(state) => state.channels[state.channelsPageSelection[0]?.id]?.streams
|
||||
);
|
||||
|
||||
const isMoreActionsOpen = Boolean(moreActionsAnchorEl);
|
||||
|
||||
// Access the row virtualizer instance (optional)
|
||||
const rowVirtualizerInstanceRef = useRef(null);
|
||||
|
||||
const handleSelectClick = (e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
/**
|
||||
* useMemo
|
||||
*/
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
header: 'Name',
|
||||
accessorKey: 'name',
|
||||
mantineTableHeadCellProps: {
|
||||
style: { textAlign: 'center' }, // Center-align the header
|
||||
},
|
||||
Header: ({ column }) => (
|
||||
<TextInput
|
||||
name="name"
|
||||
placeholder="Name"
|
||||
value={filters.name || ''}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={handleFilterChange}
|
||||
size="xs"
|
||||
/>
|
||||
),
|
||||
Cell: ({ cell }) => (
|
||||
<div
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{cell.getValue()}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Group',
|
||||
accessorKey: 'group_name',
|
||||
size: 100,
|
||||
Header: ({ column }) => (
|
||||
<Box onClick={handleSelectClick}>
|
||||
<Select
|
||||
placeholder="Group"
|
||||
searchable
|
||||
size="xs"
|
||||
nothingFound="No options"
|
||||
onClick={handleSelectClick}
|
||||
onChange={handleGroupChange}
|
||||
data={groupOptions}
|
||||
/>
|
||||
</Box>
|
||||
),
|
||||
Cell: ({ cell }) => (
|
||||
<div
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{cell.getValue()}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'M3U',
|
||||
size: 75,
|
||||
accessorFn: (row) =>
|
||||
playlists.find((playlist) => playlist.id === row.m3u_account)?.name,
|
||||
Header: ({ column }) => (
|
||||
<Box onClick={handleSelectClick}>
|
||||
<Select
|
||||
placeholder="M3U"
|
||||
searchable
|
||||
size="xs"
|
||||
nothingFound="No options"
|
||||
onClick={handleSelectClick}
|
||||
onChange={handleM3UChange}
|
||||
data={playlists.map((playlist) => ({
|
||||
label: playlist.name,
|
||||
value: `${playlist.id}`,
|
||||
}))}
|
||||
/>
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
],
|
||||
[playlists, groupOptions, filters]
|
||||
);
|
||||
|
||||
/**
|
||||
* Functions
|
||||
*/
|
||||
const handleFilterChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
[name]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleGroupChange = (value) => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
group_name: value ? value : '',
|
||||
}));
|
||||
};
|
||||
|
||||
const handleM3UChange = (value) => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
m3u_account: value ? value : '',
|
||||
}));
|
||||
};
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.append('page', pagination.pageIndex + 1);
|
||||
params.append('page_size', pagination.pageSize);
|
||||
|
||||
// Apply sorting
|
||||
if (sorting.length > 0) {
|
||||
const sortField = sorting[0].id;
|
||||
const sortDirection = sorting[0].desc ? '-' : '';
|
||||
params.append('ordering', `${sortDirection}${sortField}`);
|
||||
}
|
||||
|
||||
// Apply debounced filters
|
||||
Object.entries(debouncedFilters).forEach(([key, value]) => {
|
||||
if (value) params.append(key, value);
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await API.queryStreams(params);
|
||||
setData(result.results);
|
||||
setRowCount(result.count);
|
||||
setPageCount(Math.ceil(result.count / pagination.pageSize));
|
||||
|
||||
// Calculate the starting and ending item indexes
|
||||
const startItem = pagination.pageIndex * pagination.pageSize + 1; // +1 to start from 1, not 0
|
||||
const endItem = Math.min(
|
||||
(pagination.pageIndex + 1) * pagination.pageSize,
|
||||
result.count
|
||||
);
|
||||
|
||||
if (initialDataCount === null) {
|
||||
setInitialDataCount(result.count);
|
||||
}
|
||||
|
||||
// Generate the string
|
||||
setPaginationString(`${startItem} to ${endItem} of ${result.count}`);
|
||||
|
||||
const newSelection = {};
|
||||
result.results.forEach((item, index) => {
|
||||
if (selectedStreamIds.includes(item.id)) {
|
||||
newSelection[index] = true;
|
||||
}
|
||||
});
|
||||
|
||||
// ✅ Only update rowSelection if it's different
|
||||
if (JSON.stringify(newSelection) !== JSON.stringify(rowSelection)) {
|
||||
setRowSelection(newSelection);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error);
|
||||
}
|
||||
|
||||
const groups = await API.getStreamGroups();
|
||||
setGroupOptions(groups);
|
||||
|
||||
setIsLoading(false);
|
||||
}, [pagination, sorting, debouncedFilters]);
|
||||
|
||||
// Fallback: Individual creation (optional)
|
||||
const createChannelFromStream = async (stream) => {
|
||||
await API.createChannelFromStream({
|
||||
channel_name: stream.name,
|
||||
channel_number: null,
|
||||
stream_id: stream.id,
|
||||
});
|
||||
};
|
||||
|
||||
// Bulk creation: create channels from selected streams in one API call
|
||||
const createChannelsFromStreams = async () => {
|
||||
setIsLoading(true);
|
||||
await API.createChannelsFromStreams(
|
||||
selectedStreamIds.map((stream_id) => ({
|
||||
stream_id,
|
||||
}))
|
||||
);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const editStream = async (stream = null) => {
|
||||
setStream(stream);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const deleteStream = async (id) => {
|
||||
await API.deleteStream(id);
|
||||
};
|
||||
|
||||
const deleteStreams = async () => {
|
||||
setIsLoading(true);
|
||||
await API.deleteStreams(selectedStreamIds);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const closeStreamForm = () => {
|
||||
setStream(null);
|
||||
setModalOpen(false);
|
||||
};
|
||||
|
||||
const addStreamsToChannel = async () => {
|
||||
const { streams, ...channel } = { ...channelsPageSelection[0] };
|
||||
await API.updateChannel({
|
||||
...channel,
|
||||
stream_ids: [
|
||||
...new Set(
|
||||
channelSelectionStreams
|
||||
.map((stream) => stream.id)
|
||||
.concat(selectedStreamIds)
|
||||
),
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const addStreamToChannel = async (streamId) => {
|
||||
const { streams, ...channel } = { ...channelsPageSelection[0] };
|
||||
await API.updateChannel({
|
||||
...channel,
|
||||
stream_ids: [
|
||||
...new Set(
|
||||
channelSelectionStreams.map((stream) => stream.id).concat([streamId])
|
||||
),
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const handleMoreActionsClick = (event, rowId) => {
|
||||
setMoreActionsAnchorEl(event.currentTarget);
|
||||
setActionsOpenRow(rowId);
|
||||
};
|
||||
|
||||
const handleMoreActionsClose = () => {
|
||||
setMoreActionsAnchorEl(null);
|
||||
setActionsOpenRow(null);
|
||||
};
|
||||
|
||||
const onRowSelectionChange = (updater) => {
|
||||
setRowSelection((prevRowSelection) => {
|
||||
const newRowSelection =
|
||||
typeof updater === 'function' ? updater(prevRowSelection) : updater;
|
||||
|
||||
const updatedSelected = new Set([...selectedStreamIds]);
|
||||
table.getRowModel().rows.forEach((row) => {
|
||||
if (newRowSelection[row.id] === undefined || !newRowSelection[row.id]) {
|
||||
updatedSelected.delete(row.original.id);
|
||||
} else {
|
||||
updatedSelected.add(row.original.id);
|
||||
}
|
||||
});
|
||||
setSelectedStreamIds([...updatedSelected]);
|
||||
|
||||
return newRowSelection;
|
||||
});
|
||||
};
|
||||
|
||||
const onSelectAllChange = async (e) => {
|
||||
const selectAll = e.target.checked;
|
||||
if (selectAll) {
|
||||
// Get all stream IDs for current view
|
||||
const params = new URLSearchParams();
|
||||
Object.entries(debouncedFilters).forEach(([key, value]) => {
|
||||
if (value) params.append(key, value);
|
||||
});
|
||||
const ids = await API.getAllStreamIds(params);
|
||||
setSelectedStreamIds(ids);
|
||||
} else {
|
||||
setSelectedStreamIds([]);
|
||||
}
|
||||
|
||||
const newSelection = {};
|
||||
table.getRowModel().rows.forEach((item, index) => {
|
||||
newSelection[index] = selectAll;
|
||||
});
|
||||
setRowSelection(newSelection);
|
||||
};
|
||||
|
||||
const onPageSizeChange = (e) => {
|
||||
setPagination({
|
||||
...pagination,
|
||||
pageSize: e.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
const onPageIndexChange = (pageIndex) => {
|
||||
if (!pageIndex || pageIndex > pageCount) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPagination({
|
||||
...pagination,
|
||||
pageIndex: pageIndex - 1,
|
||||
});
|
||||
};
|
||||
|
||||
const onPaginationChange = (updater) => {
|
||||
const newPagination = updater(pagination);
|
||||
if (JSON.stringify(newPagination) === JSON.stringify(pagination)) {
|
||||
// Prevent infinite re-render when there are no results
|
||||
return;
|
||||
}
|
||||
|
||||
setPagination(updater);
|
||||
};
|
||||
|
||||
const table = useMantineReactTable({
|
||||
...TableHelper.defaultProperties,
|
||||
columns,
|
||||
data,
|
||||
enablePagination: true,
|
||||
manualPagination: true,
|
||||
enableTopToolbar: false,
|
||||
enableRowVirtualization: true,
|
||||
renderTopToolbar: () => null, // Removes the entire top toolbar
|
||||
renderToolbarInternalActions: () => null,
|
||||
rowVirtualizerInstanceRef,
|
||||
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
|
||||
enableBottomToolbar: true,
|
||||
renderBottomToolbar: ({ table }) => (
|
||||
<Group
|
||||
gap={5}
|
||||
justify="center"
|
||||
style={{ padding: 8, borderTop: '1px solid #666' }}
|
||||
>
|
||||
<Text size="xs">Page Size</Text>
|
||||
<NativeSelect
|
||||
size="xxs"
|
||||
value={pagination.pageSize}
|
||||
data={['25', '50', '100', '250', '500', '1000']}
|
||||
onChange={onPageSizeChange}
|
||||
style={{ paddingRight: 20 }}
|
||||
/>
|
||||
<Pagination
|
||||
total={pageCount}
|
||||
value={pagination.pageIndex + 1}
|
||||
onChange={onPageIndexChange}
|
||||
size="xs"
|
||||
withEdges
|
||||
style={{ paddingRight: 20 }}
|
||||
/>
|
||||
<Text size="xs">{paginationString}</Text>
|
||||
</Group>
|
||||
),
|
||||
enableStickyHeader: true,
|
||||
// onPaginationChange: onPaginationChange,
|
||||
rowCount: rowCount,
|
||||
enableRowSelection: true,
|
||||
mantineSelectAllCheckboxProps: {
|
||||
checked: selectedStreamIds.length == rowCount,
|
||||
indeterminate:
|
||||
selectedStreamIds.length > 0 && selectedStreamIds.length !== rowCount,
|
||||
onChange: onSelectAllChange,
|
||||
size: 'xs',
|
||||
},
|
||||
muiPaginationProps: {
|
||||
size: 'small',
|
||||
rowsPerPageOptions: [25, 50, 100, 250, 500, 1000, 10000],
|
||||
labelRowsPerPage: 'Rows per page',
|
||||
},
|
||||
onSortingChange: setSorting,
|
||||
onRowSelectionChange: onRowSelectionChange,
|
||||
initialState: {
|
||||
density: 'compact',
|
||||
},
|
||||
state: {
|
||||
isLoading,
|
||||
sorting,
|
||||
// pagination,
|
||||
rowSelection,
|
||||
},
|
||||
enableRowActions: true,
|
||||
positionActionsColumn: 'first',
|
||||
|
||||
enableHiding: false,
|
||||
|
||||
// you can still use the custom toolbar callback if you like
|
||||
renderTopToolbarCustomActions: ({ table }) => {
|
||||
const selectedRowCount = table.getSelectedRowModel().rows.length;
|
||||
// optionally do something with selectedRowCount
|
||||
},
|
||||
|
||||
renderRowActions: ({ row }) => (
|
||||
<>
|
||||
<Tooltip label="Add to Channel">
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
color="blue.5"
|
||||
variant="transparent"
|
||||
onClick={() => addStreamToChannel(row.original.id)}
|
||||
disabled={
|
||||
channelsPageSelection.length !== 1 ||
|
||||
(channelSelectionStreams &&
|
||||
channelSelectionStreams
|
||||
.map((stream) => stream.id)
|
||||
.includes(row.original.id))
|
||||
}
|
||||
>
|
||||
<ListPlus size="18" fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Create New Channel">
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
color="green.5"
|
||||
variant="transparent"
|
||||
onClick={() => createChannelFromStream(row.original)}
|
||||
>
|
||||
<SquarePlus size="18" fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Menu>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
onClick={(event) =>
|
||||
handleMoreActionsClick(event, row.original.id)
|
||||
}
|
||||
variant="transparent"
|
||||
size="sm"
|
||||
>
|
||||
<MoreVertIcon />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
onClick={() => editStream(row.original)}
|
||||
disabled={row.original.m3u_account ? true : false}
|
||||
>
|
||||
Edit
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
onClick={() => deleteStream(row.original.id)}
|
||||
disabled={row.original.m3u_account ? true : false}
|
||||
>
|
||||
Delete Stream
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</>
|
||||
),
|
||||
mantineTableContainerProps: {
|
||||
style: {
|
||||
height: 'calc(100vh - 167px)',
|
||||
overflowY: 'auto',
|
||||
},
|
||||
},
|
||||
displayColumnDefOptions: {
|
||||
'mrt-row-actions': {
|
||||
size: 30,
|
||||
},
|
||||
'mrt-row-select': {
|
||||
size: 20,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* useEffects
|
||||
*/
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Scroll to the top of the table when sorting changes
|
||||
try {
|
||||
rowVirtualizerInstanceRef.current?.scrollToIndex?.(0);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}, [sorting]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex
|
||||
style={{ display: 'flex', alignItems: 'center', paddingBottom: 12 }}
|
||||
gap={15}
|
||||
>
|
||||
<Text
|
||||
w={88}
|
||||
h={24}
|
||||
style={{
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
fontWeight: 500,
|
||||
fontSize: '20px',
|
||||
lineHeight: 1,
|
||||
letterSpacing: '-0.3px',
|
||||
// color: 'gray.6', // Adjust this to match MUI's theme.palette.text.secondary
|
||||
marginBottom: 0,
|
||||
}}
|
||||
>
|
||||
Streams
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Paper
|
||||
style={{
|
||||
// bgcolor: theme.palette.background.paper,
|
||||
// borderRadius: 2,
|
||||
// overflow: 'hidden',
|
||||
height: 'calc(100vh - 75px)',
|
||||
// display: 'flex',
|
||||
// flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Top toolbar with Remove, Assign, Auto-match, and Add buttons */}
|
||||
<Box
|
||||
style={{
|
||||
display: 'flex',
|
||||
// alignItems: 'center',
|
||||
// backgroundColor: theme.palette.background.paper,
|
||||
justifyContent: 'flex-end',
|
||||
padding: 10,
|
||||
// gap: 1,
|
||||
}}
|
||||
>
|
||||
<Flex gap={6}>
|
||||
<Button
|
||||
leftSection={<SquareMinus size={18} />}
|
||||
variant="default"
|
||||
size="xs"
|
||||
onClick={deleteStreams}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
leftSection={<IconSquarePlus size={18} />}
|
||||
variant="default"
|
||||
size="xs"
|
||||
onClick={createChannelsFromStreams}
|
||||
p={5}
|
||||
>
|
||||
Create Channels
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
leftSection={<IconSquarePlus size={18} />}
|
||||
variant="light"
|
||||
size="xs"
|
||||
onClick={() => editStream()}
|
||||
p={5}
|
||||
color="green"
|
||||
style={{
|
||||
borderWidth: '1px',
|
||||
borderColor: 'green',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
Add Stream
|
||||
</Button>
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
{initialDataCount === 0 && (
|
||||
<Center style={{ paddingTop: 20 }}>
|
||||
<Card
|
||||
shadow="sm"
|
||||
padding="lg"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={{
|
||||
backgroundColor: '#222',
|
||||
borderColor: '#444',
|
||||
textAlign: 'center',
|
||||
width: '400px',
|
||||
}}
|
||||
>
|
||||
<Stack align="center">
|
||||
<Title order={3} style={{ color: '#d4d4d8' }}>
|
||||
Getting started
|
||||
</Title>
|
||||
<Text size="sm" color="dimmed">
|
||||
In order to get started, add your M3U or start <br />
|
||||
adding custom streams.
|
||||
</Text>
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
size="md"
|
||||
onClick={() => navigate('/m3u')}
|
||||
style={{
|
||||
backgroundColor: '#444',
|
||||
color: '#d4d4d8',
|
||||
border: '1px solid #666',
|
||||
}}
|
||||
>
|
||||
Add M3U
|
||||
</Button>
|
||||
<Divider label="or" labelPosition="center" color="gray" />
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
size="md"
|
||||
onClick={() => editStream()}
|
||||
style={{
|
||||
backgroundColor: '#333',
|
||||
color: '#d4d4d8',
|
||||
border: '1px solid #666',
|
||||
}}
|
||||
>
|
||||
Add Individual Stream
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Center>
|
||||
)}
|
||||
{initialDataCount > 0 && <MantineReactTable table={table} />}
|
||||
</Paper>
|
||||
<StreamForm
|
||||
stream={stream}
|
||||
isOpen={modalOpen}
|
||||
onClose={closeStreamForm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default StreamsTable;
|
||||
|
|
@ -1,238 +0,0 @@
|
|||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
MaterialReactTable,
|
||||
MRT_ShowHideColumnsButton,
|
||||
MRT_ToggleFullScreenButton,
|
||||
useMaterialReactTable,
|
||||
} from 'material-react-table';
|
||||
import {
|
||||
Box,
|
||||
Grid2,
|
||||
Stack,
|
||||
Typography,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Select,
|
||||
MenuItem,
|
||||
} from '@mui/material';
|
||||
import API from '../../api';
|
||||
import {
|
||||
Delete as DeleteIcon,
|
||||
Edit as EditIcon,
|
||||
Add as AddIcon,
|
||||
Check as CheckIcon,
|
||||
Close as CloseIcon,
|
||||
} from '@mui/icons-material';
|
||||
import useUserAgentsStore from '../../store/userAgents';
|
||||
import UserAgentForm from '../forms/UserAgent';
|
||||
import { TableHelper } from '../../helpers';
|
||||
import useSettingsStore from '../../store/settings';
|
||||
import useAlertStore from '../../store/alerts';
|
||||
|
||||
const UserAgentsTable = () => {
|
||||
const [userAgent, setUserAgent] = useState(null);
|
||||
const [userAgentModalOpen, setUserAgentModalOpen] = useState(false);
|
||||
const [rowSelection, setRowSelection] = useState([]);
|
||||
const [activeFilterValue, setActiveFilterValue] = useState('all');
|
||||
|
||||
const userAgents = useUserAgentsStore((state) => state.userAgents);
|
||||
const { settings } = useSettingsStore();
|
||||
const { showAlert } = useAlertStore();
|
||||
|
||||
const columns = useMemo(
|
||||
//column definitions...
|
||||
() => [
|
||||
{
|
||||
header: 'Name',
|
||||
accessorKey: 'user_agent_name',
|
||||
},
|
||||
{
|
||||
header: 'User-Agent',
|
||||
accessorKey: 'user_agent',
|
||||
},
|
||||
{
|
||||
header: 'Desecription',
|
||||
accessorKey: 'description',
|
||||
},
|
||||
{
|
||||
header: 'Active',
|
||||
accessorKey: 'is_active',
|
||||
size: 100,
|
||||
sortingFn: 'basic',
|
||||
muiTableBodyCellProps: {
|
||||
align: 'left',
|
||||
},
|
||||
Cell: ({ cell }) => (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||
{cell.getValue() ? (
|
||||
<CheckIcon color="success" />
|
||||
) : (
|
||||
<CloseIcon color="error" />
|
||||
)}
|
||||
</Box>
|
||||
),
|
||||
Filter: ({ column }) => (
|
||||
<Box>
|
||||
<Select
|
||||
size="small"
|
||||
variant="standard"
|
||||
value={activeFilterValue}
|
||||
onChange={(e) => {
|
||||
setActiveFilterValue(e.target.value);
|
||||
column.setFilterValue(e.target.value);
|
||||
}}
|
||||
displayEmpty
|
||||
fullWidth
|
||||
>
|
||||
<MenuItem value="all">All</MenuItem>
|
||||
<MenuItem value="true">Active</MenuItem>
|
||||
<MenuItem value="false">Inactive</MenuItem>
|
||||
</Select>
|
||||
</Box>
|
||||
),
|
||||
filterFn: (row, _columnId, activeFilterValue) => {
|
||||
if (activeFilterValue == 'all') return true; // Show all if no filter
|
||||
return String(row.getValue('is_active')) === activeFilterValue;
|
||||
},
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
//optionally access the underlying virtualizer instance
|
||||
const rowVirtualizerInstanceRef = useRef(null);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [sorting, setSorting] = useState([]);
|
||||
|
||||
const editUserAgent = async (userAgent = null) => {
|
||||
setUserAgent(userAgent);
|
||||
setUserAgentModalOpen(true);
|
||||
};
|
||||
|
||||
const deleteUserAgent = async (ids) => {
|
||||
if (Array.isArray(ids)) {
|
||||
if (ids.includes(settings['default-user-agent'].value)) {
|
||||
showAlert('Cannot delete default user-agent', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
await API.deleteUserAgents(ids);
|
||||
} else {
|
||||
if (ids == settings['default-user-agent'].value) {
|
||||
showAlert('Cannot delete default user-agent', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
await API.deleteUserAgent(ids);
|
||||
}
|
||||
};
|
||||
|
||||
const closeUserAgentForm = () => {
|
||||
setUserAgent(null);
|
||||
setUserAgentModalOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
//scroll to the top of the table when the sorting changes
|
||||
try {
|
||||
rowVirtualizerInstanceRef.current?.scrollToIndex?.(0);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}, [sorting]);
|
||||
|
||||
const table = useMaterialReactTable({
|
||||
...TableHelper.defaultProperties,
|
||||
columns,
|
||||
data: userAgents,
|
||||
enablePagination: false,
|
||||
enableRowVirtualization: true,
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
state: {
|
||||
isLoading,
|
||||
sorting,
|
||||
rowSelection,
|
||||
},
|
||||
rowVirtualizerInstanceRef, //optional
|
||||
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
|
||||
initialState: {
|
||||
density: 'compact',
|
||||
},
|
||||
enableRowActions: true,
|
||||
renderRowActions: ({ row }) => (
|
||||
<>
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="warning" // Red color for delete actions
|
||||
onClick={() => {
|
||||
editUserAgent(row.original);
|
||||
}}
|
||||
sx={{ pt: 0, pb: 0 }}
|
||||
>
|
||||
<EditIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="error" // Red color for delete actions
|
||||
onClick={() => deleteUserAgent(row.original.id)}
|
||||
sx={{ pt: 0, pb: 0 }}
|
||||
>
|
||||
<DeleteIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
</>
|
||||
),
|
||||
muiTableContainerProps: {
|
||||
sx: {
|
||||
height: 'calc(42vh + 5px)',
|
||||
},
|
||||
},
|
||||
renderTopToolbarCustomActions: ({ table }) => (
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography>User-Agents</Typography>
|
||||
<Tooltip title="Add New User Agent">
|
||||
<IconButton
|
||||
size="small" // Makes the button smaller
|
||||
color="success" // Red color for delete actions
|
||||
variant="contained"
|
||||
onClick={() => editUserAgent()}
|
||||
>
|
||||
<AddIcon fontSize="small" /> {/* Small icon size */}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
padding: 1,
|
||||
}}
|
||||
>
|
||||
<MaterialReactTable table={table} />
|
||||
</Box>
|
||||
<UserAgentForm
|
||||
userAgent={userAgent}
|
||||
isOpen={userAgentModalOpen}
|
||||
onClose={closeUserAgentForm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserAgentsTable;
|
||||
321
frontend/src/components/tables/UserAgentsTable.jsx
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { MantineReactTable, useMantineReactTable } from 'mantine-react-table';
|
||||
import API from '../../api';
|
||||
import {
|
||||
Delete as DeleteIcon,
|
||||
Edit as EditIcon,
|
||||
Add as AddIcon,
|
||||
Check as CheckIcon,
|
||||
Close as CloseIcon,
|
||||
} from '@mui/icons-material';
|
||||
import useUserAgentsStore from '../../store/userAgents';
|
||||
import UserAgentForm from '../forms/UserAgent';
|
||||
import { TableHelper } from '../../helpers';
|
||||
import useSettingsStore from '../../store/settings';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import {
|
||||
ActionIcon,
|
||||
Center,
|
||||
Flex,
|
||||
Select,
|
||||
Tooltip,
|
||||
Text,
|
||||
Paper,
|
||||
Box,
|
||||
Button,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconArrowDown,
|
||||
IconArrowUp,
|
||||
IconDeviceDesktopSearch,
|
||||
IconSelector,
|
||||
IconSortAscendingNumbers,
|
||||
IconSquarePlus,
|
||||
} from '@tabler/icons-react';
|
||||
import { SquareMinus, SquarePen } from 'lucide-react';
|
||||
|
||||
const UserAgentsTable = () => {
|
||||
const [userAgent, setUserAgent] = useState(null);
|
||||
const [userAgentModalOpen, setUserAgentModalOpen] = useState(false);
|
||||
const [rowSelection, setRowSelection] = useState([]);
|
||||
const [activeFilterValue, setActiveFilterValue] = useState('all');
|
||||
|
||||
const userAgents = useUserAgentsStore((state) => state.userAgents);
|
||||
const { settings } = useSettingsStore();
|
||||
|
||||
const columns = useMemo(
|
||||
//column definitions...
|
||||
() => [
|
||||
{
|
||||
header: 'Name',
|
||||
accessorKey: 'user_agent_name',
|
||||
},
|
||||
{
|
||||
header: 'User-Agent',
|
||||
accessorKey: 'user_agent',
|
||||
Cell: ({ cell }) => (
|
||||
<div
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{cell.getValue()}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Desecription',
|
||||
accessorKey: 'description',
|
||||
Cell: ({ cell }) => (
|
||||
<div
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{cell.getValue()}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Active',
|
||||
accessorKey: 'is_active',
|
||||
size: 100,
|
||||
sortingFn: 'basic',
|
||||
mantineTableBodyCellProps: {
|
||||
align: 'left',
|
||||
},
|
||||
Cell: ({ cell }) => (
|
||||
<Center>
|
||||
{cell.getValue() ? (
|
||||
<CheckIcon color="success" />
|
||||
) : (
|
||||
<CloseIcon color="error" />
|
||||
)}
|
||||
</Center>
|
||||
),
|
||||
Filter: ({ column }) => (
|
||||
<Select
|
||||
size="small"
|
||||
value={activeFilterValue}
|
||||
onChange={(e) => {
|
||||
setActiveFilterValue(e.target.value);
|
||||
column.setFilterValue(e.target.value);
|
||||
}}
|
||||
displayEmpty
|
||||
data={[
|
||||
{
|
||||
value: 'all',
|
||||
label: 'All',
|
||||
},
|
||||
{
|
||||
value: 'active',
|
||||
label: 'Active',
|
||||
},
|
||||
{
|
||||
value: 'inactive',
|
||||
label: 'Inactive',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
filterFn: (row, _columnId, activeFilterValue) => {
|
||||
if (activeFilterValue == 'all') return true; // Show all if no filter
|
||||
return String(row.getValue('is_active')) === activeFilterValue;
|
||||
},
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
//optionally access the underlying virtualizer instance
|
||||
const rowVirtualizerInstanceRef = useRef(null);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [sorting, setSorting] = useState([]);
|
||||
|
||||
const editUserAgent = async (userAgent = null) => {
|
||||
setUserAgent(userAgent);
|
||||
setUserAgentModalOpen(true);
|
||||
};
|
||||
|
||||
const deleteUserAgent = async (ids) => {
|
||||
if (Array.isArray(ids)) {
|
||||
if (ids.includes(settings['default-user-agent'].value)) {
|
||||
notifications.show({
|
||||
title: 'Cannot delete default user-agent',
|
||||
color: 'red.5',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await API.deleteUserAgents(ids);
|
||||
} else {
|
||||
if (ids == settings['default-user-agent'].value) {
|
||||
notifications.show({
|
||||
title: 'Cannot delete default user-agent',
|
||||
color: 'red.5',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await API.deleteUserAgent(ids);
|
||||
}
|
||||
};
|
||||
|
||||
const closeUserAgentForm = () => {
|
||||
setUserAgent(null);
|
||||
setUserAgentModalOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
//scroll to the top of the table when the sorting changes
|
||||
try {
|
||||
rowVirtualizerInstanceRef.current?.scrollToIndex?.(0);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}, [sorting]);
|
||||
|
||||
const table = useMantineReactTable({
|
||||
...TableHelper.defaultProperties,
|
||||
columns,
|
||||
data: userAgents,
|
||||
enablePagination: false,
|
||||
enableRowVirtualization: true,
|
||||
// enableRowSelection: true,
|
||||
renderTopToolbar: false,
|
||||
// onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
state: {
|
||||
isLoading,
|
||||
sorting,
|
||||
// rowSelection,
|
||||
},
|
||||
rowVirtualizerInstanceRef, //optional
|
||||
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
|
||||
initialState: {
|
||||
density: 'compact',
|
||||
},
|
||||
enableRowActions: true,
|
||||
renderRowActions: ({ row }) => (
|
||||
<>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
size="sm" // Makes the button smaller
|
||||
color="yellow.5" // Red color for delete actions
|
||||
onClick={() => {
|
||||
editUserAgent(row.original);
|
||||
}}
|
||||
>
|
||||
<SquarePen size="18" /> {/* Small icon size */}
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
size="sm"
|
||||
color="red.9" // Red color for delete actions
|
||||
onClick={() => deleteUserAgent(row.original.id)}
|
||||
>
|
||||
<SquareMinus size="18" /> {/* Small icon size */}
|
||||
</ActionIcon>
|
||||
</>
|
||||
),
|
||||
mantineTableContainerProps: {
|
||||
style: {
|
||||
height: 'calc(43vh - 55px)',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
paddingTop: 10,
|
||||
paddingBottom: 10,
|
||||
}}
|
||||
gap={15}
|
||||
>
|
||||
<Text
|
||||
h={24}
|
||||
style={{
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
fontWeight: 500,
|
||||
fontSize: '20px',
|
||||
lineHeight: 1,
|
||||
letterSpacing: '-0.3px',
|
||||
color: 'gray.6', // Adjust this to match MUI's theme.palette.text.secondary
|
||||
marginBottom: 0,
|
||||
}}
|
||||
>
|
||||
User-Agents
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Paper
|
||||
style={
|
||||
{
|
||||
// bgcolor: theme.palette.background.paper,
|
||||
// borderRadius: 2,
|
||||
// overflow: 'hidden',
|
||||
// height: 'calc(100vh - 75px)',
|
||||
// display: 'flex',
|
||||
// flexDirection: 'column',
|
||||
}
|
||||
}
|
||||
>
|
||||
{/* Top toolbar with Remove, Assign, Auto-match, and Add buttons */}
|
||||
<Box
|
||||
style={{
|
||||
display: 'flex',
|
||||
// alignItems: 'center',
|
||||
// backgroundColor: theme.palette.background.paper,
|
||||
justifyContent: 'flex-end',
|
||||
padding: 10,
|
||||
// gap: 1,
|
||||
}}
|
||||
>
|
||||
<Flex gap={6}>
|
||||
<Tooltip label="Assign">
|
||||
<Button
|
||||
leftSection={<IconSquarePlus size={18} />}
|
||||
variant="light"
|
||||
size="xs"
|
||||
onClick={() => editUserAgent()}
|
||||
p={5}
|
||||
color="green"
|
||||
style={{
|
||||
borderWidth: '1px',
|
||||
borderColor: 'green',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
Add User-Agent
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<MantineReactTable table={table} />
|
||||
<UserAgentForm
|
||||
userAgent={userAgent}
|
||||
isOpen={userAgentModalOpen}
|
||||
onClose={closeUserAgentForm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserAgentsTable;
|
||||
|
|
@ -2,12 +2,10 @@
|
|||
|
||||
export default {
|
||||
defaultProperties: {
|
||||
enableGlobalFilter: false,
|
||||
enableBottomToolbar: false,
|
||||
enableDensityToggle: false,
|
||||
enableFullScreenToggle: false,
|
||||
positionToolbarAlertBanner: 'none',
|
||||
// columnFilterDisplayMode: 'popover',
|
||||
enableRowNumbers: false,
|
||||
positionActionsColumn: 'last',
|
||||
enableColumnActions: false,
|
||||
|
|
@ -16,26 +14,49 @@ export default {
|
|||
initialState: {
|
||||
density: 'compact',
|
||||
},
|
||||
muiTableBodyCellProps: {
|
||||
sx: {
|
||||
py: 0,
|
||||
mantinePaperProps: {
|
||||
style: {
|
||||
'--mrt-selected-row-background-color': '#163632',
|
||||
},
|
||||
},
|
||||
mantineSelectAllCheckboxProps: {
|
||||
size: 'xs',
|
||||
},
|
||||
mantineSelectCheckboxProps: {
|
||||
size: 'xs',
|
||||
},
|
||||
mantineTableBodyRowProps: ({ isDetailPanel, row }) => {
|
||||
if (isDetailPanel && row.getIsSelected()) {
|
||||
return {
|
||||
style: {
|
||||
backgroundColor: '#163632',
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
mantineTableBodyCellProps: {
|
||||
style: {
|
||||
// py: 0,
|
||||
paddingLeft: 10,
|
||||
paddingRight: 10,
|
||||
borderColor: '#444',
|
||||
color: '#E0E0E0',
|
||||
fontSize: '0.85rem',
|
||||
},
|
||||
},
|
||||
muiTableHeadCellProps: {
|
||||
sx: {
|
||||
py: 0,
|
||||
mantineTableHeadCellProps: {
|
||||
style: {
|
||||
paddingLeft: 10,
|
||||
paddingRight: 10,
|
||||
color: '#CFCFCF',
|
||||
backgroundColor: '#383A3F',
|
||||
borderColor: '#444',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.8rem',
|
||||
// fontWeight: 600,
|
||||
// fontSize: '0.8rem',
|
||||
},
|
||||
},
|
||||
muiTableBodyProps: {
|
||||
sx: {
|
||||
mantineTableBodyProps: {
|
||||
style: {
|
||||
// Subtle row striping
|
||||
'& tr:nth-of-type(odd)': {
|
||||
backgroundColor: '#2F3034',
|
||||
|
|
@ -29,3 +29,7 @@ code {
|
|||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #777;
|
||||
}
|
||||
|
||||
table.mrt-table tr.mantine-Table-tr.mantine-Table-tr-detail-panel td.mantine-Table-td-detail-panel {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client'; // Import the "react-dom/client" for React 18
|
||||
import './index.css'; // Optional styles
|
||||
import App from './App'; // Import your App component
|
||||
|
||||
// Create a root element
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
|
||||
// Render your app using the "root.render" method
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
9
frontend/src/main.jsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
58
frontend/src/mantineTheme.jsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { createTheme, MantineProvider, rem } from '@mantine/core';
|
||||
|
||||
const theme = createTheme({
|
||||
palette: {
|
||||
mode: 'dark',
|
||||
background: {
|
||||
default: '#18181b', // Global background color (Tailwind zinc-900)
|
||||
paper: '#27272a', // Paper background (Tailwind zinc-800)
|
||||
},
|
||||
primary: {
|
||||
main: '#4A90E2',
|
||||
contrastText: '#FFFFFF',
|
||||
},
|
||||
secondary: {
|
||||
main: '#F5A623',
|
||||
contrastText: '#FFFFFF',
|
||||
},
|
||||
text: {
|
||||
primary: '#FFFFFF',
|
||||
secondary: '#d4d4d8', // Updated secondary text color (Tailwind zinc-300)
|
||||
},
|
||||
// Custom colors for components (chip buttons, borders, etc.)
|
||||
custom: {
|
||||
// For chip buttons:
|
||||
greenMain: '#90C43E',
|
||||
greenHoverBg: 'rgba(144,196,62,0.1)',
|
||||
|
||||
indigoMain: '#4F39F6',
|
||||
indigoHoverBg: 'rgba(79,57,246,0.1)',
|
||||
|
||||
greyBorder: '#707070',
|
||||
greyHoverBg: 'rgba(112,112,112,0.1)',
|
||||
greyText: '#a0a0a0',
|
||||
|
||||
// Common border colors:
|
||||
borderDefault: '#3f3f46', // Tailwind zinc-700
|
||||
borderHover: '#5f5f66', // Approximate Tailwind zinc-600
|
||||
|
||||
// For the "Add" button:
|
||||
successBorder: '#00a63e',
|
||||
successBg: '#0d542b',
|
||||
successBgHover: '#0a4020',
|
||||
successIcon: '#05DF72',
|
||||
},
|
||||
},
|
||||
|
||||
custom: {
|
||||
sidebar: {
|
||||
activeBackground: 'rgba(21, 69, 62, 0.67)',
|
||||
activeBorder: '#14917e',
|
||||
hoverBackground: '#27272a',
|
||||
hoverBorder: '#3f3f46',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default theme;
|
||||
15
frontend/src/pages/Channels-test.jsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import React from 'react';
|
||||
import { Allotment } from 'allotment';
|
||||
import { Box, Container } from '@mantine/core';
|
||||
import 'allotment/dist/style.css';
|
||||
|
||||
const ChannelsPage = () => {
|
||||
return (
|
||||
<Allotment>
|
||||
<div>Pane 1</div>
|
||||
<div>Pane 1</div>
|
||||
</Allotment>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelsPage;
|
||||
|
|
@ -1,15 +1,15 @@
|
|||
import React, { useState } from 'react';
|
||||
import ChannelsTable from '../components/tables/ChannelsTable';
|
||||
import StreamsTable from '../components/tables/StreamsTable';
|
||||
import { Grid2, Box } from '@mui/material';
|
||||
import { Box, Grid } from '@mantine/core';
|
||||
|
||||
const ChannelsPage = () => {
|
||||
return (
|
||||
<Grid2 container>
|
||||
<Grid2 size={6}>
|
||||
<Grid style={{ padding: 18 }}>
|
||||
<Grid.Col span={6}>
|
||||
<Box
|
||||
sx={{
|
||||
height: '100vh', // Full viewport height
|
||||
style={{
|
||||
height: '100vh - 20px', // Full viewport height
|
||||
paddingTop: 0, // Top padding
|
||||
paddingBottom: 1, // Bottom padding
|
||||
paddingRight: 0.5,
|
||||
|
|
@ -20,11 +20,11 @@ const ChannelsPage = () => {
|
|||
>
|
||||
<ChannelsTable />
|
||||
</Box>
|
||||
</Grid2>
|
||||
<Grid2 size={6}>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<Box
|
||||
sx={{
|
||||
height: '100vh', // Full viewport height
|
||||
style={{
|
||||
height: '100vh - 20px', // Full viewport height
|
||||
paddingTop: 0, // Top padding
|
||||
paddingBottom: 1, // Bottom padding
|
||||
paddingRight: 0,
|
||||
|
|
@ -35,8 +35,8 @@ const ChannelsPage = () => {
|
|||
>
|
||||
<StreamsTable />
|
||||
</Box>
|
||||
</Grid2>
|
||||
</Grid2>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
import React from "react";
|
||||
import { Box } from "@mui/material";
|
||||
import UserAgentsTable from "../components/tables/UserAgentsTable";
|
||||
import EPGsTable from "../components/tables/EPGsTable";
|
||||
|
||||
const EPGPage = () => {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100vh",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: "1 1 50%", overflow: "hidden" }}>
|
||||
<EPGsTable />
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flex: "1 1 50%", overflow: "hidden" }}>
|
||||
<UserAgentsTable />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EPGPage;
|
||||
28
frontend/src/pages/EPG.jsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import React from 'react';
|
||||
import { Box } from '@mantine/core';
|
||||
import UserAgentsTable from '../components/tables/UserAgentsTable';
|
||||
import EPGsTable from '../components/tables/EPGsTable';
|
||||
|
||||
const EPGPage = () => {
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100vh',
|
||||
overflow: 'hidden',
|
||||
padding: 16,
|
||||
}}
|
||||
>
|
||||
<Box style={{ flex: '1 1 50%', overflow: 'hidden' }}>
|
||||
<EPGsTable />
|
||||
</Box>
|
||||
|
||||
<Box style={{ flex: '1 1 50%', overflow: 'hidden' }}>
|
||||
<UserAgentsTable />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EPGPage;
|
||||
|
|
@ -1,26 +1,23 @@
|
|||
// frontend/src/pages/Guide.js
|
||||
import React, { useMemo, useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
Stack,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Slide,
|
||||
CircularProgress,
|
||||
Backdrop,
|
||||
} from '@mui/material';
|
||||
import dayjs from 'dayjs';
|
||||
import API from '../api';
|
||||
import useChannelsStore from '../store/channels';
|
||||
import logo from '../images/logo.png';
|
||||
import useVideoStore from '../store/useVideoStore'; // NEW import
|
||||
import useAlertStore from '../store/alerts';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import useSettingsStore from '../store/settings';
|
||||
import {
|
||||
Title,
|
||||
Box,
|
||||
Modal,
|
||||
Flex,
|
||||
Button,
|
||||
Text,
|
||||
Paper,
|
||||
Grid,
|
||||
} from '@mantine/core';
|
||||
import './guide.css';
|
||||
|
||||
/** Layout constants */
|
||||
const CHANNEL_WIDTH = 120; // Width of the channel/logo column
|
||||
|
|
@ -33,11 +30,6 @@ const MINUTE_BLOCK_WIDTH = HOUR_WIDTH / (60 / MINUTE_INCREMENT);
|
|||
const MODAL_WIDTH = 600;
|
||||
const MODAL_HEIGHT = 400;
|
||||
|
||||
// Slide transition for Dialog
|
||||
const Transition = React.forwardRef(function Transition(props, ref) {
|
||||
return <Slide direction="up" ref={ref} {...props} />;
|
||||
});
|
||||
|
||||
export default function TVChannelGuide({ startDate, endDate }) {
|
||||
const { channels } = useChannelsStore();
|
||||
|
||||
|
|
@ -46,7 +38,6 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
const [now, setNow] = useState(dayjs());
|
||||
const [selectedProgram, setSelectedProgram] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { showAlert } = useAlertStore();
|
||||
const {
|
||||
environment: { env_mode },
|
||||
} = useSettingsStore();
|
||||
|
|
@ -55,9 +46,9 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
|
||||
// Load program data once
|
||||
useEffect(() => {
|
||||
if (!channels || channels.length === 0) {
|
||||
if (!Object.keys(channels).length === 0) {
|
||||
console.warn('No channels provided or empty channels array');
|
||||
showAlert('No channels available', 'error');
|
||||
notifications.show({ title: 'No channels available', color: 'red.5' });
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
|
@ -71,7 +62,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
const programIds = [...new Set(fetched.map((p) => p.tvg_id))];
|
||||
|
||||
// Filter your Redux/Zustand channels by matching tvg_id
|
||||
const filteredChannels = channels.filter((ch) =>
|
||||
const filteredChannels = Object.values(channels).filter((ch) =>
|
||||
programIds.includes(ch.tvg_id)
|
||||
);
|
||||
console.log(
|
||||
|
|
@ -217,8 +208,9 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
|
||||
return (
|
||||
<Box
|
||||
className="guide-program-container"
|
||||
key={programKey}
|
||||
sx={{
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: leftPx,
|
||||
top: 0,
|
||||
|
|
@ -229,62 +221,45 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
>
|
||||
<Paper
|
||||
elevation={2}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
left: 2,
|
||||
className={`guide-program ${isLive ? 'live' : 'not-live'}`}
|
||||
style={{
|
||||
// position: 'relative',
|
||||
// left: 2,
|
||||
width: widthPx - 4,
|
||||
top: 2,
|
||||
// top: 2,
|
||||
height: PROGRAM_HEIGHT - 4,
|
||||
p: 1,
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
borderRadius: '8px',
|
||||
background: isLive
|
||||
? 'linear-gradient(to right, #1e3a8a, #2c5282)'
|
||||
: 'linear-gradient(to right, #2d3748, #2d3748)',
|
||||
color: '#fff',
|
||||
transition: 'background 0.3s ease',
|
||||
'&:hover': {
|
||||
background: isLive
|
||||
? 'linear-gradient(to right, #1e3a8a, #2a4365)'
|
||||
: 'linear-gradient(to right, #2d3748, #1a202c)',
|
||||
},
|
||||
// padding: 10,
|
||||
// overflow: 'hidden',
|
||||
// whiteSpace: 'nowrap',
|
||||
// textOverflow: 'ellipsis',
|
||||
// borderRadius: '8px',
|
||||
// background: isLive
|
||||
// ? 'linear-gradient(to right, #1e3a8a, #2c5282)'
|
||||
// : 'linear-gradient(to right, #2d3748, #2d3748)',
|
||||
// color: '#fff',
|
||||
// transition: 'background 0.3s ease',
|
||||
// '&:hover': {
|
||||
// background: isLive
|
||||
// ? 'linear-gradient(to right, #1e3a8a, #2a4365)'
|
||||
// : 'linear-gradient(to right, #2d3748, #1a202c)',
|
||||
// },
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" noWrap sx={{ fontWeight: 'bold' }}>
|
||||
<Text size="md" style={{ fontWeight: 'bold' }}>
|
||||
{program.title}
|
||||
</Typography>
|
||||
<Typography variant="overline" noWrap>
|
||||
</Text>
|
||||
<Text size="sm" noWrap>
|
||||
{programStart.format('h:mma')} - {programEnd.format('h:mma')}
|
||||
</Typography>
|
||||
</Text>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Backdrop
|
||||
sx={{
|
||||
// color: '#fff',
|
||||
zIndex: (theme) => theme.zIndex.drawer + 1,
|
||||
position: 'fixed', // Ensure it covers the entire page
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
open={loading}
|
||||
>
|
||||
<CircularProgress color="inherit" />
|
||||
</Backdrop>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
className="tv-guide"
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
|
|
@ -294,33 +269,27 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
}}
|
||||
>
|
||||
{/* Sticky top bar */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
<Flex
|
||||
justify="space-between"
|
||||
style={{
|
||||
backgroundColor: '#2d3748',
|
||||
color: '#fff',
|
||||
p: 2,
|
||||
padding: 20,
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 999,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
|
||||
<Title order={3} style={{ fontWeight: 'bold' }}>
|
||||
TV Guide
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{now.format('dddd, MMMM D, YYYY • h:mm A')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Title>
|
||||
<Text>{now.format('dddd, MMMM D, YYYY • h:mm A')}</Text>
|
||||
</Flex>
|
||||
|
||||
{/* Main layout */}
|
||||
<Stack direction="row">
|
||||
<Grid direction="row" style={{ padding: 8 }}>
|
||||
{/* Channel Logos Column */}
|
||||
<Box sx={{ backgroundColor: '#2d3748', color: '#fff' }}>
|
||||
<Box style={{ backgroundColor: '#2d3748', color: '#fff' }}>
|
||||
<Box
|
||||
sx={{
|
||||
style={{
|
||||
width: CHANNEL_WIDTH,
|
||||
height: '40px',
|
||||
borderBottom: '1px solid #4a5568',
|
||||
|
|
@ -329,7 +298,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
{guideChannels.map((channel) => (
|
||||
<Box
|
||||
key={channel.channel_name}
|
||||
sx={{
|
||||
style={{
|
||||
display: 'flex',
|
||||
height: PROGRAM_HEIGHT,
|
||||
alignItems: 'center',
|
||||
|
|
@ -338,7 +307,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
style={{
|
||||
width: CHANNEL_WIDTH,
|
||||
display: 'flex',
|
||||
p: 1,
|
||||
|
|
@ -364,7 +333,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
{/* Timeline & Program Blocks */}
|
||||
<Box
|
||||
ref={guideRef}
|
||||
sx={{
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowX: 'auto',
|
||||
overflowY: 'auto',
|
||||
|
|
@ -372,7 +341,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
>
|
||||
{/* Sticky timeline header */}
|
||||
<Box
|
||||
sx={{
|
||||
style={{
|
||||
display: 'flex',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
|
|
@ -381,11 +350,11 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
borderBottom: '1px solid #4a5568',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1, display: 'flex' }}>
|
||||
<Box style={{ flex: 1, display: 'flex' }}>
|
||||
{hourTimeline.map((time, hourIndex) => (
|
||||
<Box
|
||||
key={time.format()}
|
||||
sx={{
|
||||
style={{
|
||||
width: HOUR_WIDTH,
|
||||
height: '40px',
|
||||
position: 'relative',
|
||||
|
|
@ -393,9 +362,9 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
borderRight: '1px solid #4a5568',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
<Text
|
||||
size="sm"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: hourIndex === 0 ? 4 : 'calc(50% - 16px)',
|
||||
|
|
@ -403,9 +372,9 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
}}
|
||||
>
|
||||
{time.format('h:mma')}
|
||||
</Typography>
|
||||
</Text>
|
||||
<Box
|
||||
sx={{
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
top: 0,
|
||||
|
|
@ -418,7 +387,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
{[0, 1, 2, 3].map((i) => (
|
||||
<Box
|
||||
key={i}
|
||||
sx={{
|
||||
style={{
|
||||
width: '1px',
|
||||
height: '10px',
|
||||
backgroundColor: '#718096',
|
||||
|
|
@ -433,10 +402,10 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
</Box>
|
||||
|
||||
{/* Now line */}
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
<Box style={{ position: 'relative' }}>
|
||||
{nowPosition >= 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: nowPosition,
|
||||
top: 0,
|
||||
|
|
@ -456,14 +425,14 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
return (
|
||||
<Box
|
||||
key={channel.channel_name}
|
||||
sx={{
|
||||
style={{
|
||||
display: 'flex',
|
||||
position: 'relative',
|
||||
minHeight: PROGRAM_HEIGHT,
|
||||
borderBottom: '1px solid #4a5568',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1, position: 'relative' }}>
|
||||
<Box style={{ flex: 1, position: 'relative' }}>
|
||||
{channelPrograms.map((prog) => renderProgram(prog, start))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
@ -471,62 +440,36 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Grid>
|
||||
|
||||
{/* Modal for program details */}
|
||||
<Dialog
|
||||
open={Boolean(selectedProgram)}
|
||||
<Modal
|
||||
title={selectedProgram ? selectedProgram.title : ''}
|
||||
opened={Boolean(selectedProgram)}
|
||||
onClose={handleCloseModal}
|
||||
TransitionComponent={Transition}
|
||||
keepMounted
|
||||
PaperProps={{
|
||||
sx: {
|
||||
width: MODAL_WIDTH,
|
||||
height: MODAL_HEIGHT,
|
||||
m: 'auto',
|
||||
backgroundColor: '#1a202c',
|
||||
border: '2px solid #718096',
|
||||
},
|
||||
}}
|
||||
sx={{
|
||||
'& .MuiDialog-container': {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
}}
|
||||
yOffset="25vh"
|
||||
>
|
||||
{selectedProgram && (
|
||||
<>
|
||||
<DialogTitle sx={{ color: '#fff' }}>
|
||||
{selectedProgram.title}
|
||||
</DialogTitle>
|
||||
<DialogContent sx={{ color: '#a0aec0' }}>
|
||||
<Typography variant="caption" display="block">
|
||||
{dayjs(selectedProgram.start_time).format('h:mma')} -{' '}
|
||||
{dayjs(selectedProgram.end_time).format('h:mma')}
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ mt: 2, color: '#fff' }}>
|
||||
{selectedProgram.description || 'No description available.'}
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
{/* Only show the Watch button if currently live */}
|
||||
{now.isAfter(dayjs(selectedProgram.start_time)) &&
|
||||
now.isBefore(dayjs(selectedProgram.end_time)) && (
|
||||
<Button
|
||||
onClick={() => handleWatchStream(selectedProgram)}
|
||||
sx={{ color: '#38b2ac' }}
|
||||
>
|
||||
<Text size="sm">
|
||||
{dayjs(selectedProgram.start_time).format('h:mma')} -{' '}
|
||||
{dayjs(selectedProgram.end_time).format('h:mma')}
|
||||
</Text>
|
||||
<Text style={{ mt: 2, color: '#fff' }}>
|
||||
{selectedProgram.description || 'No description available.'}
|
||||
</Text>
|
||||
{/* Only show the Watch button if currently live */}
|
||||
{now.isAfter(dayjs(selectedProgram.start_time)) &&
|
||||
now.isBefore(dayjs(selectedProgram.end_time)) && (
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
<Button onClick={() => handleWatchStream(selectedProgram)}>
|
||||
Watch Now
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleCloseModal} sx={{ color: '#38b2ac' }}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Flex>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
import React from "react";
|
||||
import LoginForm from "../components/forms/LoginForm";
|
||||
|
||||
const Login = () => {
|
||||
return <LoginForm />;
|
||||
};
|
||||
|
||||
export default Login;
|
||||
16
frontend/src/pages/Login.jsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import React from 'react';
|
||||
import LoginForm from '../components/forms/LoginForm';
|
||||
import SuperuserForm from '../components/forms/SuperuserForm';
|
||||
import useAuthStore from '../store/auth';
|
||||
|
||||
const Login = ({}) => {
|
||||
const { superuserExists } = useAuthStore();
|
||||
|
||||
if (!superuserExists) {
|
||||
return <SuperuserForm />;
|
||||
}
|
||||
|
||||
return <LoginForm />;
|
||||
};
|
||||
|
||||
export default Login;
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
import React, { useState } from "react";
|
||||
import useUserAgentsStore from "../store/userAgents";
|
||||
import { Box } from "@mui/material";
|
||||
import M3UsTable from "../components/tables/M3UsTable";
|
||||
import UserAgentsTable from "../components/tables/UserAgentsTable";
|
||||
import usePlaylistsStore from "../store/playlists";
|
||||
import API from "../api";
|
||||
import M3UForm from "../components/forms/M3U";
|
||||
|
||||
const M3UPage = () => {
|
||||
const isLoading = useUserAgentsStore((state) => state.isLoading);
|
||||
const error = useUserAgentsStore((state) => state.error);
|
||||
const playlists = usePlaylistsStore((state) => state.playlists);
|
||||
|
||||
const [playlist, setPlaylist] = useState(null);
|
||||
const [playlistModalOpen, setPlaylistModalOpen] = useState(false);
|
||||
|
||||
const [userAgent, setUserAgent] = useState(null);
|
||||
const [userAgentModalOpen, setUserAgentModalOpen] = useState(false);
|
||||
|
||||
const editUserAgent = async (userAgent = null) => {
|
||||
setUserAgent(userAgent);
|
||||
setUserAgentModalOpen(true);
|
||||
};
|
||||
|
||||
const editPlaylist = async (playlist = null) => {
|
||||
setPlaylist(playlist);
|
||||
setPlaylistModalOpen(true);
|
||||
};
|
||||
|
||||
const deleteUserAgent = async (ids) => {
|
||||
if (Array.isArray(ids)) {
|
||||
await API.deleteUserAgents(ids);
|
||||
} else {
|
||||
await API.deleteUserAgent(ids);
|
||||
}
|
||||
};
|
||||
|
||||
const deletePlaylist = async (id) => {
|
||||
await API.deletePlaylist(id);
|
||||
};
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
if (error) return <div>Error: {error}</div>;
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100vh",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: "1 1 50%", overflow: "hidden" }}>
|
||||
<M3UsTable />
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flex: "1 1 50%", overflow: "hidden" }}>
|
||||
<UserAgentsTable />
|
||||
</Box>
|
||||
|
||||
<M3UForm
|
||||
playlist={playlist}
|
||||
isOpen={playlistModalOpen}
|
||||
onClose={() => setPlaylistModalOpen(false)}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default M3UPage;
|
||||
35
frontend/src/pages/M3U.jsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import React, { useState } from 'react';
|
||||
import useUserAgentsStore from '../store/userAgents';
|
||||
import M3UsTable from '../components/tables/M3UsTable';
|
||||
import UserAgentsTable from '../components/tables/UserAgentsTable';
|
||||
import { Box } from '@mantine/core';
|
||||
|
||||
const M3UPage = () => {
|
||||
const isLoading = useUserAgentsStore((state) => state.isLoading);
|
||||
const error = useUserAgentsStore((state) => state.error);
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
if (error) return <div>Error: {error}</div>;
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100vh',
|
||||
overflow: 'hidden',
|
||||
padding: 16,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: '1 1 50%', overflow: 'hidden' }}>
|
||||
<M3UsTable />
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flex: '1 1 50%', overflow: 'hidden' }}>
|
||||
<UserAgentsTable />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default M3UPage;
|
||||
|
|
@ -1,197 +0,0 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import {
|
||||
Grid as Grid2,
|
||||
Box,
|
||||
Container,
|
||||
Typography,
|
||||
FormControl,
|
||||
Select,
|
||||
MenuItem,
|
||||
CircularProgress,
|
||||
InputLabel,
|
||||
Button,
|
||||
} from '@mui/material';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
import API from '../api';
|
||||
import useSettingsStore from '../store/settings';
|
||||
import useUserAgentsStore from '../store/userAgents';
|
||||
import useStreamProfilesStore from '../store/streamProfiles';
|
||||
|
||||
const SettingsPage = () => {
|
||||
const { settings } = useSettingsStore();
|
||||
const { userAgents } = useUserAgentsStore();
|
||||
const { profiles: streamProfiles } = useStreamProfilesStore();
|
||||
|
||||
// Add your region choices here:
|
||||
const regionChoices = [
|
||||
{ value: 'us', label: 'US' },
|
||||
{ value: 'uk', label: 'UK' },
|
||||
{ value: 'nl', label: 'NL' },
|
||||
{ value: 'de', label: 'DE' },
|
||||
// Add more if needed
|
||||
];
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
'default-user-agent': '',
|
||||
'default-stream-profile': '',
|
||||
'preferred-region': '',
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
'default-user-agent': Yup.string().required('User-Agent is required'),
|
||||
'default-stream-profile': Yup.string().required(
|
||||
'Stream Profile is required'
|
||||
),
|
||||
// The region is optional or required as you prefer
|
||||
// 'preferred-region': Yup.string().required('Region is required'),
|
||||
}),
|
||||
onSubmit: async (values, { setSubmitting, resetForm }) => {
|
||||
const changedSettings = {};
|
||||
for (const settingKey in values) {
|
||||
// If the user changed the setting’s value from what’s in the DB:
|
||||
if (String(values[settingKey]) !== String(settings[settingKey].value)) {
|
||||
changedSettings[settingKey] = values[settingKey];
|
||||
}
|
||||
}
|
||||
|
||||
// Update each changed setting in the backend
|
||||
for (const updatedKey in changedSettings) {
|
||||
await API.updateSetting({
|
||||
...settings[updatedKey],
|
||||
value: changedSettings[updatedKey],
|
||||
});
|
||||
}
|
||||
|
||||
setSubmitting(false);
|
||||
// Don’t necessarily resetForm, in case the user wants to see new values
|
||||
},
|
||||
});
|
||||
|
||||
// Initialize form values once settings / userAgents / profiles are loaded
|
||||
useEffect(() => {
|
||||
formik.setValues(
|
||||
Object.values(settings).reduce((acc, setting) => {
|
||||
// If the setting’s value is numeric, parse it
|
||||
// Otherwise, just store as string
|
||||
const possibleNumber = parseInt(setting.value, 10);
|
||||
acc[setting.key] = isNaN(possibleNumber)
|
||||
? setting.value
|
||||
: possibleNumber;
|
||||
return acc;
|
||||
}, {})
|
||||
);
|
||||
// eslint-disable-next-line
|
||||
}, [settings, userAgents, streamProfiles]);
|
||||
|
||||
return (
|
||||
<Container maxWidth="md">
|
||||
<Box mt={4}>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Settings
|
||||
</Typography>
|
||||
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<Grid2 container spacing={3}>
|
||||
{/* Default User-Agent */}
|
||||
<Grid2 xs={12}>
|
||||
<FormControl variant="standard" fullWidth>
|
||||
<InputLabel id="user-agent-label">Default User-Agent</InputLabel>
|
||||
<Select
|
||||
labelId="user-agent-label"
|
||||
id={settings['default-user-agent']?.id}
|
||||
name={settings['default-user-agent']?.key}
|
||||
label={settings['default-user-agent']?.name}
|
||||
value={formik.values['default-user-agent'] || ''}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
error={
|
||||
formik.touched['default-user-agent'] &&
|
||||
Boolean(formik.errors['default-user-agent'])
|
||||
}
|
||||
variant="standard"
|
||||
>
|
||||
{userAgents.map((option) => (
|
||||
<MenuItem key={option.id} value={option.id}>
|
||||
{option.user_agent_name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid2>
|
||||
|
||||
{/* Default Stream Profile */}
|
||||
<Grid2 xs={12}>
|
||||
<FormControl variant="standard" fullWidth>
|
||||
<InputLabel id="stream-profile-label">
|
||||
Default Stream Profile
|
||||
</InputLabel>
|
||||
<Select
|
||||
labelId="stream-profile-label"
|
||||
id={settings['default-stream-profile']?.id}
|
||||
name={settings['default-stream-profile']?.key}
|
||||
label={settings['default-stream-profile']?.name}
|
||||
value={formik.values['default-stream-profile'] || ''}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
error={
|
||||
formik.touched['default-stream-profile'] &&
|
||||
Boolean(formik.errors['default-stream-profile'])
|
||||
}
|
||||
variant="standard"
|
||||
>
|
||||
{streamProfiles.map((profile) => (
|
||||
<MenuItem key={profile.id} value={profile.id}>
|
||||
{profile.profile_name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid2>
|
||||
|
||||
{/* Preferred Region */}
|
||||
<Grid2 xs={12}>
|
||||
{/* Only render if you do indeed have "preferred-region" in the DB */}
|
||||
{settings['preferred-region'] && (
|
||||
<FormControl variant="standard" fullWidth>
|
||||
<InputLabel id="region-label">Preferred Region</InputLabel>
|
||||
<Select
|
||||
labelId="region-label"
|
||||
id={settings['preferred-region'].id}
|
||||
name={settings['preferred-region'].key}
|
||||
label={settings['preferred-region'].name}
|
||||
value={formik.values['preferred-region'] || ''}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
variant="standard"
|
||||
>
|
||||
{regionChoices.map((r) => (
|
||||
<MenuItem key={r.value} value={r.value}>
|
||||
{r.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
</Grid2>
|
||||
</Grid2>
|
||||
|
||||
<Box mt={4} display="flex" justifyContent="flex-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={formik.isSubmitting}
|
||||
size="small"
|
||||
>
|
||||
{formik.isSubmitting ? <CircularProgress size={24} /> : 'Submit'}
|
||||
</Button>
|
||||
</Box>
|
||||
</form>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsPage;
|
||||
154
frontend/src/pages/Settings.jsx
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
import API from '../api';
|
||||
import useSettingsStore from '../store/settings';
|
||||
import useUserAgentsStore from '../store/userAgents';
|
||||
import useStreamProfilesStore from '../store/streamProfiles';
|
||||
import {
|
||||
Button,
|
||||
Center,
|
||||
Flex,
|
||||
Paper,
|
||||
NativeSelect,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
|
||||
const SettingsPage = () => {
|
||||
const { settings } = useSettingsStore();
|
||||
const { userAgents } = useUserAgentsStore();
|
||||
const { profiles: streamProfiles } = useStreamProfilesStore();
|
||||
|
||||
// Add your region choices here:
|
||||
const regionChoices = [
|
||||
{ value: 'us', label: 'US' },
|
||||
{ value: 'uk', label: 'UK' },
|
||||
{ value: 'nl', label: 'NL' },
|
||||
{ value: 'de', label: 'DE' },
|
||||
// Add more if needed
|
||||
];
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
'default-user-agent': `${settings['default-user-agent'].id}`,
|
||||
'default-stream-profile': `${settings['default-stream-profile'].id}`,
|
||||
// 'preferred-region': '',
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
'default-user-agent': Yup.string().required('User-Agent is required'),
|
||||
'default-stream-profile': Yup.string().required(
|
||||
'Stream Profile is required'
|
||||
),
|
||||
// The region is optional or required as you prefer
|
||||
// 'preferred-region': Yup.string().required('Region is required'),
|
||||
}),
|
||||
onSubmit: async (values, { setSubmitting, resetForm }) => {
|
||||
console.log(values);
|
||||
const changedSettings = {};
|
||||
for (const settingKey in values) {
|
||||
// If the user changed the setting’s value from what’s in the DB:
|
||||
if (String(values[settingKey]) !== String(settings[settingKey].value)) {
|
||||
changedSettings[settingKey] = values[settingKey];
|
||||
}
|
||||
}
|
||||
|
||||
// Update each changed setting in the backend
|
||||
for (const updatedKey in changedSettings) {
|
||||
await API.updateSetting({
|
||||
...settings[updatedKey],
|
||||
value: changedSettings[updatedKey],
|
||||
});
|
||||
}
|
||||
|
||||
setSubmitting(false);
|
||||
// Don’t necessarily resetForm, in case the user wants to see new values
|
||||
},
|
||||
});
|
||||
|
||||
// Initialize form values once settings / userAgents / profiles are loaded
|
||||
useEffect(() => {
|
||||
formik.setValues(
|
||||
Object.values(settings).reduce((acc, setting) => {
|
||||
// If the setting’s value is numeric, parse it
|
||||
// Otherwise, just store as string
|
||||
const possibleNumber = parseInt(setting.value, 10);
|
||||
acc[setting.key] = isNaN(possibleNumber)
|
||||
? setting.value
|
||||
: possibleNumber;
|
||||
return acc;
|
||||
}, {})
|
||||
);
|
||||
// eslint-disable-next-line
|
||||
}, [settings, userAgents, streamProfiles]);
|
||||
|
||||
return (
|
||||
<Center
|
||||
style={{
|
||||
height: '100vh',
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
elevation={3}
|
||||
style={{ padding: 30, width: '100%', maxWidth: 400 }}
|
||||
>
|
||||
<Title order={4} align="center">
|
||||
Settings
|
||||
</Title>
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<NativeSelect
|
||||
id={settings['default-user-agent']?.id}
|
||||
name={settings['default-user-agent']?.key}
|
||||
label={settings['default-user-agent']?.name}
|
||||
value={formik.values['default-user-agent'] || ''}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.errors['default-user-agent']}
|
||||
data={userAgents.map((option) => ({
|
||||
value: `${option.id}`,
|
||||
label: option.user_agent_name,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<NativeSelect
|
||||
id={settings['default-stream-profile']?.id}
|
||||
name={settings['default-stream-profile']?.key}
|
||||
label={settings['default-stream-profile']?.name}
|
||||
value={formik.values['default-stream-profile'] || ''}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.errors['default-stream-profile']}
|
||||
data={streamProfiles.map((option) => ({
|
||||
value: `${option.id}`,
|
||||
label: option.profile_name,
|
||||
}))}
|
||||
/>
|
||||
{/* <Select
|
||||
labelId="region-label"
|
||||
id={settings['preferred-region'].id}
|
||||
name={settings['preferred-region'].key}
|
||||
label={settings['preferred-region'].name}
|
||||
value={formik.values['preferred-region'] || ''}
|
||||
onChange={formik.handleChange}
|
||||
data={regionChoices.map((r) => ({
|
||||
label: r.label,
|
||||
value: `${r.value}`,
|
||||
}))}
|
||||
/> */}
|
||||
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={formik.isSubmitting}
|
||||
size="small"
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
</Paper>
|
||||
</Center>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsPage;
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
import React from "react";
|
||||
import StreamProfilesTable from "../components/tables/StreamProfilesTable";
|
||||
|
||||
const StreamProfilesPage = () => {
|
||||
return <StreamProfilesTable />;
|
||||
};
|
||||
|
||||
export default StreamProfilesPage;
|
||||
13
frontend/src/pages/StreamProfiles.jsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import React from 'react';
|
||||
import StreamProfilesTable from '../components/tables/StreamProfilesTable';
|
||||
import { Box } from '@mantine/core';
|
||||
|
||||
const StreamProfilesPage = () => {
|
||||
return (
|
||||
<Box style={{ padding: 16 }}>
|
||||
<StreamProfilesTable />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default StreamProfilesPage;
|
||||
25
frontend/src/pages/guide.css
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
.tv-guide .guide-program-container .guide-program {
|
||||
position: relative;
|
||||
left: 2px;
|
||||
top: 2px;
|
||||
padding: 10px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(to right, #2d3748, #2d3748); /* Default background */
|
||||
color: #fff;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.tv-guide .guide-program-container .guide-program.live {
|
||||
background: linear-gradient(to right, #1e3a8a, #2c5282);
|
||||
}
|
||||
|
||||
.tv-guide .guide-program-container .guide-program.live:hover {
|
||||
background: linear-gradient(to right, #1e3a8a, #2a4365);
|
||||
}
|
||||
|
||||
.tv-guide .guide-program-container .guide-program.not-live:hover {
|
||||
background: linear-gradient(to right, #2d3748, #1a202c);
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
const reportWebVitals = onPerfEntry => {
|
||||
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||
getCLS(onPerfEntry);
|
||||
getFID(onPerfEntry);
|
||||
getFCP(onPerfEntry);
|
||||
getLCP(onPerfEntry);
|
||||
getTTFB(onPerfEntry);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default reportWebVitals;
|
||||