Merge pull request #19 from Dispatcharr/mantine

Mantine Overhaul
This commit is contained in:
dekzter 2025-03-13 10:20:45 -04:00 committed by GitHub
commit aed6525582
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
121 changed files with 7812 additions and 20467 deletions

3
.gitignore vendored
View file

@ -7,3 +7,6 @@ node_modules/
.history/
staticfiles/
static/
data/
.next
next-env.d.ts

View file

@ -5,7 +5,7 @@ from .api_views import (
ChannelViewSet,
ChannelGroupViewSet,
BulkDeleteStreamsAPIView,
BulkDeleteChannelsAPIView
BulkDeleteChannelsAPIView,
)
app_name = 'channels' # for DRF routing

View file

@ -10,7 +10,27 @@ from django.shortcuts import get_object_or_404
from .models import Stream, Channel, ChannelGroup
from .serializers import StreamSerializer, ChannelSerializer, ChannelGroupSerializer
from .tasks import match_epg_channels
import django_filters
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import SearchFilter, OrderingFilter
from rest_framework.pagination import PageNumberPagination
class StreamPagination(PageNumberPagination):
page_size = 25 # Default page size
page_size_query_param = 'page_size' # Allow clients to specify page size
max_page_size = 10000 # Prevent excessive page sizes
class StreamFilter(django_filters.FilterSet):
name = django_filters.CharFilter(lookup_expr='icontains')
group_name = django_filters.CharFilter(lookup_expr='icontains')
m3u_account = django_filters.NumberFilter(field_name="m3u_account__id")
m3u_account_name = django_filters.CharFilter(field_name="m3u_account__name", lookup_expr="icontains")
m3u_account_is_active = django_filters.BooleanFilter(field_name="m3u_account__is_active")
class Meta:
model = Stream
fields = ['name', 'group_name', 'm3u_account', 'm3u_account_name', 'm3u_account_is_active']
# ─────────────────────────────────────────────────────────
# 1) Stream API (CRUD)
@ -19,6 +39,13 @@ class StreamViewSet(viewsets.ModelViewSet):
queryset = Stream.objects.all()
serializer_class = StreamSerializer
permission_classes = [IsAuthenticated]
pagination_class = StreamPagination
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
filterset_class = StreamFilter
search_fields = ['name', 'group_name']
ordering_fields = ['name', 'group_name']
ordering = ['-name']
def get_queryset(self):
qs = super().get_queryset()
@ -32,6 +59,26 @@ class StreamViewSet(viewsets.ModelViewSet):
qs = qs.filter(channels__isnull=True)
return qs
@action(detail=False, methods=['get'], url_path='ids')
def get_ids(self, request, *args, **kwargs):
# Get the filtered queryset
queryset = self.get_queryset()
# Apply filtering, search, and ordering
queryset = self.filter_queryset(queryset)
# Return only the IDs from the queryset
stream_ids = queryset.values_list('id', flat=True)
# Return the response with the list of IDs
return Response(list(stream_ids))
@action(detail=False, methods=['get'], url_path='groups')
def get_groups(self, request, *args, **kwargs):
group_names = Stream.objects.exclude(group_name__isnull=True).exclude(group_name="").order_by().values_list('group_name', flat=True).distinct()
# Return the response with the list of unique group names
return Response(list(group_names))
# ─────────────────────────────────────────────────────────
# 2) Channel Group Management (CRUD)
@ -89,7 +136,7 @@ class ChannelViewSet(viewsets.ModelViewSet):
),
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=["stream_id", "channel_name"],
required=["stream_id"],
properties={
"stream_id": openapi.Schema(
type=openapi.TYPE_INTEGER, description="ID of the stream to link"
@ -129,9 +176,13 @@ class ChannelViewSet(viewsets.ModelViewSet):
status=status.HTTP_400_BAD_REQUEST
)
channel_name = request.data.get('channel_name')
if channel_name is None:
channel_name = stream.name
channel_data = {
'channel_number': channel_number,
'channel_name': request.data.get('channel_name', f"Channel from {stream.name}"),
'channel_name': channel_name,
'tvg_id': stream.tvg_id,
'channel_group_id': channel_group.id,
'logo_url': stream.logo_url,
@ -154,7 +205,7 @@ class ChannelViewSet(viewsets.ModelViewSet):
type=openapi.TYPE_ARRAY,
items=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=["stream_id", "channel_name"],
required=["stream_id"],
properties={
"stream_id": openapi.Schema(
type=openapi.TYPE_INTEGER, description="ID of the stream to link"
@ -193,8 +244,7 @@ class ChannelViewSet(viewsets.ModelViewSet):
for item in data_list:
stream_id = item.get('stream_id')
channel_name = item.get('channel_name')
if not all([stream_id, channel_name]):
if not all([stream_id]):
errors.append({"item": item, "error": "Missing required fields: stream_id and channel_name are required."})
continue
@ -221,6 +271,10 @@ class ChannelViewSet(viewsets.ModelViewSet):
continue
used_numbers.add(channel_number)
channel_name = item.get('channel_name')
if channel_name is None:
channel_name = stream.name
channel_data = {
"channel_number": channel_number,
"channel_name": channel_name,
@ -305,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)

View file

@ -74,11 +74,10 @@ class ChannelSerializer(serializers.ModelSerializer):
required=False
)
streams = serializers.ListField(
child=serializers.IntegerField(),
write_only=True
streams = serializers.SerializerMethodField()
stream_ids = serializers.PrimaryKeyRelatedField(
queryset=Stream.objects.all(), many=True, write_only=True, required=False
)
stream_ids = serializers.SerializerMethodField()
class Meta:
model = Channel
@ -97,9 +96,17 @@ class ChannelSerializer(serializers.ModelSerializer):
'stream_profile_id',
]
def get_stream_ids(self, obj):
"""Retrieve ordered stream IDs for GET requests."""
return list(obj.streams.all().order_by('channelstream__order').values_list('id', flat=True))
def get_streams(self, obj):
"""Retrieve ordered stream objects for GET requests."""
ordered_streams = obj.streams.all().order_by('channelstream__order')
print(f'Retrieving streams in order')
for index, stream in enumerate(ordered_streams):
print(f'Stream {stream.id}, index {index}')
return StreamSerializer(ordered_streams, many=True).data
# def get_stream_ids(self, obj):
# """Retrieve ordered stream IDs for GET requests."""
# return list(obj.streams.all().order_by('channelstream__order').values_list('id', flat=True))
def create(self, validated_data):
stream_ids = validated_data.pop('streams', [])
@ -113,8 +120,8 @@ class ChannelSerializer(serializers.ModelSerializer):
def update(self, instance, validated_data):
print("Validated Data:", validated_data)
stream_ids = validated_data.pop('streams', None)
print(f'stream ids: {stream_ids}')
streams = validated_data.pop('stream_ids', None)
print(f'stream ids: {streams}')
# Update the actual Channel fields
instance.channel_number = validated_data.get('channel_number', instance.channel_number)
@ -132,11 +139,12 @@ class ChannelSerializer(serializers.ModelSerializer):
instance.save()
# Handle the many-to-many 'streams'
if stream_ids is not None:
if streams is not None:
# Clear existing relationships
instance.channelstream_set.all().delete()
# Add new streams in order
for index, stream_id in enumerate(stream_ids):
ChannelStream.objects.create(channel=instance, stream_id=stream_id, order=index)
for index, stream in enumerate(streams):
print(f'Setting stream {stream.id} to index {index}')
ChannelStream.objects.create(channel=instance, stream_id=stream.id, order=index)
return instance

View file

@ -83,7 +83,10 @@ def stream_view(request, stream_id):
logger.debug(f'Profile has a max streams of {profile.max_streams}, checking if any are available')
stream_index = 0
while stream_index < profile.max_streams:
max_streams = profile.max_streams
if max_streams == 0:
max_streams = 999999 # maybe a better way than just hardcoding a high number...
while stream_index < max_streams:
stream_index += 1
lock_key = f"lock:{profile.id}:{stream_index}"

View file

@ -32,6 +32,7 @@ INSTALLED_APPS = [
'django.contrib.staticfiles',
'rest_framework',
'corsheaders',
'django_filters',
]
@ -54,7 +55,7 @@ TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [
os.path.join(BASE_DIR, 'frontend/build'),
os.path.join(BASE_DIR, 'frontend/dist'),
BASE_DIR / "templates"
],
'APP_DIRS': True,
@ -112,6 +113,10 @@ REST_FRAMEWORK = {
'rest_framework.renderers.JSONRenderer',
'rest_framework.renderers.BrowsableAPIRenderer',
],
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework_simplejwt.authentication.JWTAuthentication',
],
'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend'],
}
SWAGGER_SETTINGS = {
@ -134,7 +139,7 @@ STATIC_ROOT = BASE_DIR / 'static' # Directory where static files will be collec
# Adjust STATICFILES_DIRS to include the paths to the directories that contain your static files.
STATICFILES_DIRS = [
os.path.join(BASE_DIR, 'frontend/build/static'), # React build static files
os.path.join(BASE_DIR, 'frontend/dist'), # React build static files
]
@ -158,12 +163,6 @@ CSRF_TRUSTED_ORIGINS = [
]
APPEND_SLASH = True
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework_simplejwt.authentication.JWTAuthentication',
],
}
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=30),
'REFRESH_TOKEN_LIFETIME': timedelta(days=1),

View file

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

View file

@ -6,8 +6,9 @@ services:
image: dispatcharr/dispatcharr
container_name: dispatcharr_dev
ports:
- "5656:5656"
- 5656:5656
- 9191:9191
- 8001:8001
volumes:
- ../:/app
environment:

View file

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

View file

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

View file

@ -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
@ -17,7 +17,7 @@ server {
}
# Serve FFmpeg streams efficiently
location /stream/ {
location /output/stream/ {
proxy_pass http://127.0.0.1:5656;
proxy_buffering off;
proxy_set_header Connection keep-alive;

View file

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

41
frontend/.gitignore vendored
View file

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

View file

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

View file

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

33
frontend/eslint.config.js Normal file
View 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 },
],
},
},
]

View file

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

13
frontend/index.html Normal file
View 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>

18175
frontend/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,59 +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",
"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",
"lucide-react": "^0.479.0",
"mantine-react-table": "^2.0.0-beta.9",
"material-react-table": "^3.2.1",
"mpegts.js": "^1.8.0",
"prettier": "^3.5.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-draggable": "^4.4.6",
"react-pro-sidebar": "^1.1.0",
"react-router-dom": "^7.3.0",
"video.js": "^8.21.0",
"web-vitals": "^2.1.4",
"yup": "^1.6.1",
"zustand": "^5.0.3"
},
"scripts": {
"start": "PORT=9191 react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"format": "prettier --write ."
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"proxy": "http://localhost:5656"
"devDependencies": {
"@eslint/js": "^9.21.0",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react-swc": "^3.8.0",
"eslint": "^9.21.0",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^15.15.0",
"vite": "^6.2.0"
}
}

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 778 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View file

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

BIN
frontend/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

View file

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

View file

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

View file

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

1
frontend/public/vite.svg Normal file
View 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

View file

@ -1,146 +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 } 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 />
<WebsocketProvider>
<Router>
<Sidebar
open={open}
miniDrawerWidth={miniDrawerWidth}
drawerWidth={drawerWidth}
toggleDrawer={toggleDrawer}
/>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
ml: `${open ? drawerWidth : miniDrawerWidth}px`,
transition: 'width 0.3s, margin-left 0.3s',
backgroundColor: '#495057',
height: '100%',
}}
>
<Box
sx={{
flex: 1,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
<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
View 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;

View file

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

View file

@ -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,13 +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/`;
}
@ -51,8 +51,11 @@ export const WebsocketProvider = ({ children }) => {
switch (event.type) {
case 'm3u_refresh':
if (event.message?.success) {
useStreamsStore.getState().fetchStreams();
showAlert(event.message.message, 'success');
fetchStreams();
notifications.show({
message: event.message.message,
color: 'green.5',
});
}
break;

View file

@ -9,7 +9,9 @@ import useStreamProfilesStore from './store/streamProfiles';
import useSettingsStore from './store/settings';
// If needed, you can set a base host or keep it empty if relative requests
const host = '';
const host = import.meta.env.DEV
? `http://${window.location.hostname}:5656`
: '';
export default class API {
/**
@ -19,6 +21,27 @@ export default class API {
return await useAuthStore.getState().getToken();
}
static async fetchSuperUser() {
const response = await fetch(`${host}/api/accounts/initialize-superuser/`);
return await response.json();
}
static async createSuperUser({ username, email, password }) {
const response = await fetch(`${host}/api/accounts/initialize-superuser/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username,
password,
email,
}),
});
return await response.json();
}
static async login(username, password) {
const response = await fetch(`${host}/api/accounts/token/`, {
method: 'POST',
@ -266,6 +289,48 @@ export default class API {
return retval;
}
static async queryStreams(params) {
const response = await fetch(
`${host}/api/channels/streams/?${params.toString()}`,
{
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${await API.getAuthToken()}`,
},
}
);
const retval = await response.json();
return retval;
}
static async getAllStreamIds(params) {
const response = await fetch(
`${host}/api/channels/streams/ids/?${params.toString()}`,
{
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${await API.getAuthToken()}`,
},
}
);
const retval = await response.json();
return retval;
}
static async getStreamGroups() {
const response = await fetch(`${host}/api/channels/streams/groups/`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${await API.getAuthToken()}`,
},
});
const retval = await response.json();
return retval;
}
static async addStream(values) {
const response = await fetch(`${host}/api/channels/streams/`, {
method: 'POST',
@ -414,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();

View 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

View file

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

View file

@ -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',
@ -63,7 +65,13 @@ export default function FloatingVideo() {
}}
>
{/* Simple header row with a close button */}
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '4px' }}>
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
padding: '4px',
}}
>
<button
onClick={hideVideo}
style={{

View file

@ -1,150 +0,0 @@
import React from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import {
List,
ListItem,
ListItemButton,
ListItemText,
ListItemIcon,
Box,
Divider,
Drawer,
TextField,
} from '@mui/material';
import {
Tv as TvIcon,
CalendarMonth as CalendarMonthIcon,
VideoFile as VideoFileIcon,
LiveTv as LiveTvIcon,
PlaylistPlay as PlaylistPlayIcon,
Settings as SettingsIcon,
Logout as LogoutIcon,
} from '@mui/icons-material';
import logo from '../images/logo.png';
import useAuthStore from '../store/auth';
import useSettingsStore from '../store/settings';
const items = [
{ text: 'Channels', icon: <TvIcon />, route: '/channels' },
{ text: 'M3U', icon: <PlaylistPlayIcon />, route: '/m3u' },
{ text: 'EPG', icon: <CalendarMonthIcon />, route: '/epg' },
{
text: 'Stream Profiles',
icon: <VideoFileIcon />,
route: '/stream-profiles',
},
{ text: 'TV Guide', icon: <LiveTvIcon />, route: '/guide' },
{ text: 'Settings', icon: <SettingsIcon />, route: '/settings' },
];
const Sidebar = ({ open, miniDrawerWidth, drawerWidth, toggleDrawer }) => {
const location = useLocation();
const { isAuthenticated, logout } = useAuthStore();
const {
environment: { public_ip, country_code, country_name },
} = useSettingsStore();
const navigate = useNavigate();
const onLogout = () => {
logout();
navigate('/login');
};
return (
<Drawer
variant="permanent"
open={open}
sx={{
width: open ? drawerWidth : miniDrawerWidth,
flexShrink: 0,
'& .MuiDrawer-paper': {
width: open ? drawerWidth : miniDrawerWidth,
transition: 'width 0.3s',
overflowX: 'hidden',
'& .MuiDrawer-paper': {
display: 'flex',
flexDirection: 'column',
height: '100vh',
},
},
}}
>
<Box sx={{ flexGrow: 1 }}>
<List sx={{ backgroundColor: '#495057', color: 'white' }}>
<ListItem disablePadding>
<ListItemButton
onClick={toggleDrawer}
size="small"
sx={{
pt: 0,
pb: 0,
}}
>
<img src={logo} width="33x" alt="logo" />
{open && (
<ListItemText primary="Dispatcharr" sx={{ paddingLeft: 3 }} />
)}
</ListItemButton>
</ListItem>
</List>
<Divider />
<List>
{items.map((item) => (
<ListItem key={item.text} disablePadding>
<ListItemButton
component={Link}
to={item.route}
selected={location.pathname == item.route}
>
<ListItemIcon>{item.icon}</ListItemIcon>
{open && <ListItemText primary={item.text} />}
</ListItemButton>
</ListItem>
))}
</List>
</Box>
{isAuthenticated && (
<Box sx={{ borderTop: '1px solid #ccc' }}>
<List>
<ListItem disablePadding>
<ListItemButton onClick={onLogout}>
<ListItemIcon>
<LogoutIcon />
</ListItemIcon>
<ListItemText primary="Logout" />
</ListItemButton>
</ListItem>
</List>
{open && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 1 }}>
{/* Public IP + optional flag */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<TextField
size="small"
label="Public IP"
value={public_ip || ''}
disabled
variant="outlined"
sx={{ flex: 1 }}
/>
{/* If we have a country code, show a small flag */}
{country_code && (
<img
src={`https://flagcdn.com/16x12/${country_code.toLowerCase()}.png`}
alt={country_name || country_code}
title={country_name || country_code}
style={{ border: '1px solid #ccc', borderRadius: 2 }}
/>
)}
</Box>
</Box>
)}
</Box>
)}
</Drawer>
);
};
export default Sidebar;

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,245 +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,
},
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"
value={formik.values.max_streams}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.max_streams && Boolean(formik.errors.max_streams)
}
helperText={formik.touched.max_streams && formik.errors.max_streams}
variant="standard"
/>
<FormControl variant="standard" fullWidth>
<InputLabel id="user-agent-label">User-Agent</InputLabel>
<Select
labelId="user-agent-label"
id="user_agent"
name="user_agent"
label="User-Agent"
value={formik.values.user_agent}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.user_agent && Boolean(formik.errors.user_agent)
}
// helperText={formik.touched.user_agent && formik.errors.user_agent}
variant="standard"
>
{userAgents.map((option, index) => (
<MenuItem key={index} value={option.id}>
{option.user_agent_name}
</MenuItem>
))}
</Select>
</FormControl>
<FormControlLabel
control={
<Checkbox
name="is_active"
checked={formik.values.is_active}
onChange={(e) =>
formik.setFieldValue('is_active', e.target.checked)
}
/>
}
label="Is Active"
/>
</DialogContent>
<DialogActions>
<Button
variant="contained"
color="primary"
size="small"
onClick={() => setProfileModalOpen(true)}
>
Profiles
</Button>
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
size="small"
>
{formik.isSubmitting ? <CircularProgress size={24} /> : 'Submit'}
</Button>
</DialogActions>
{playlist && (
<M3UProfiles
playlist={playlist}
isOpen={profileModalOpen}
onClose={() => setProfileModalOpen(false)}
/>
)}
</form>
</Dialog>
);
};
export default M3U;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,41 @@
.mantine-Stack-root .navlink {
display: flex;
flex-direction: row; /* Ensures horizontal layout */
flex-wrap: nowrap;
align-items: center;
gap: 12px;
padding: 5px 8px !important;
border-radius: 6px;
color: #D4D4D8; /* Default color when not active */
background-color: transparent; /* Default background when not active */
border: 1px solid transparent;
transition: all 0.3s ease;
}
/* Active state styles */
.navlink.navlink-active {
color: #FFFFFF;
background-color: #245043;
border: 1px solid #3BA882;
}
/* Hover effect */
.navlink:hover {
background-color: #2A2F34; /* Gray hover effect when not active */
border: 1px solid #3D3D42;
}
/* Hover effect for active state */
.navlink.navlink-active:hover {
background-color: #3A3A40;
border: 1px solid #3BA882;
}
/* Collapse condition for justifyContent */
.navlink.navlink-collapsed {
justify-content: center;
}
.navlink:not(.navlink-collapsed) {
justify-content: flex-start;
}

View file

@ -1,411 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
MaterialReactTable,
useMaterialReactTable,
} from 'material-react-table';
import {
Box,
Grid2,
Stack,
Typography,
Tooltip,
IconButton,
Button,
ButtonGroup,
Snackbar,
Popover,
TextField,
} from '@mui/material';
import useChannelsStore from '../../store/channels';
import {
Delete as DeleteIcon,
Edit as EditIcon,
Add as AddIcon,
SwapVert as SwapVertIcon,
LiveTv as LiveTvIcon,
ContentCopy,
Tv as TvIcon, // <-- ADD THIS IMPORT
} 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';
const ChannelsTable = () => {
const [channel, setChannel] = useState(null);
const [channelModalOpen, setChannelModalOpen] = useState(false);
const [rowSelection, setRowSelection] = useState([]);
const [anchorEl, setAnchorEl] = useState(null);
const [textToCopy, setTextToCopy] = useState('');
const [snackbarMessage, setSnackbarMessage] = useState('');
const [snackbarOpen, setSnackbarOpen] = useState(false);
const { showVideo } = useVideoStore.getState(); // or useVideoStore()
const { channels, isLoading: channelsLoading } = useChannelsStore();
const {
environment: { env_mode },
} = useSettingsStore();
// Configure columns
const columns = useMemo(
() => [
{
header: '#',
size: 50,
accessorKey: 'channel_number',
},
{
header: 'Name',
accessorKey: 'channel_name',
},
{
header: 'Group',
accessorFn: (row) => row.channel_group?.name || '',
},
{
header: 'Logo',
accessorKey: 'logo_url',
size: 55,
Cell: ({ cell }) => (
<Grid2
container
direction="row"
sx={{
justifyContent: 'center',
alignItems: 'center',
}}
>
<img src={cell.getValue() || logo} width="20" alt="channel logo" />
</Grid2>
),
meta: {
filterVariant: null,
},
},
],
[]
);
// Access the row virtualizer instance (optional)
const rowVirtualizerInstanceRef = useRef(null);
const [isLoading, setIsLoading] = useState(true);
const [sorting, setSorting] = useState([]);
const closeSnackbar = () => setSnackbarOpen(false);
const editChannel = async (ch = null) => {
setChannel(ch);
setChannelModalOpen(true);
};
const deleteChannel = async (id) => {
setIsLoading(true);
await API.deleteChannel(id);
setIsLoading(false);
};
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 utils.Limiter(
4,
selected.map((chan) => () => deleteChannel(chan.original.id))
);
// If you have a real bulk-delete endpoint, call it here:
// await API.deleteChannels(selected.map((sel) => sel.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!" }
setSnackbarMessage(result.message || 'Channels assigned');
setSnackbarOpen(true);
// Refresh the channel list
await useChannelsStore.getState().fetchChannels();
} catch (err) {
console.error(err);
setSnackbarMessage('Failed to assign channels');
setSnackbarOpen(true);
}
};
// ─────────────────────────────────────────────────────────
// 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) {
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);
};
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 closePopover = () => {
setAnchorEl(null);
setSnackbarMessage('');
};
const openPopover = Boolean(anchorEl);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(textToCopy);
setSnackbarMessage('Copied!');
} catch (err) {
setSnackbarMessage('Failed to copy');
}
setSnackbarOpen(true);
};
// Example copy URLs
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`
);
};
// Configure the MaterialReactTable
const table = useMaterialReactTable({
...TableHelper.defaultProperties,
columns,
data: channels,
enablePagination: false,
enableRowVirtualization: true,
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
state: {
isLoading: isLoading || channelsLoading,
sorting,
rowSelection,
},
rowVirtualizerInstanceRef, // optional
rowVirtualizerOptions: { overscan: 5 },
initialState: {
density: 'compact',
},
enableRowActions: true,
renderRowActions: ({ row }) => (
<Box sx={{ justifyContent: 'right' }}>
<IconButton
size="small"
color="warning"
onClick={() => {
editChannel(row.original);
}}
sx={{ p: 0 }}
>
<EditIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
color="error"
onClick={() => deleteChannel(row.original.id)}
sx={{ p: 0 }}
>
<DeleteIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
color="info"
onClick={() => handleWatchStream(row.original.channel_number)}
sx={{ p: 0 }}
>
<LiveTvIcon fontSize="small" />
</IconButton>
</Box>
),
muiTableContainerProps: {
sx: {
height: 'calc(100vh - 75px)',
overflowY: 'auto',
},
},
muiSearchTextFieldProps: {
variant: 'standard',
},
renderTopToolbarCustomActions: ({ table }) => (
<Stack direction="row" sx={{ alignItems: 'center' }}>
<Typography>Channels</Typography>
<Tooltip title="Add New Channel">
<IconButton
size="small"
color="success"
variant="contained"
onClick={() => editChannel()}
>
<AddIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Delete Channels">
<IconButton
size="small"
color="error"
variant="contained"
onClick={deleteChannels}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Assign Channels">
<IconButton
size="small"
color="warning"
variant="contained"
onClick={assignChannels}
>
<SwapVertIcon fontSize="small" />
</IconButton>
</Tooltip>
{/* Our brand-new button for EPG matching */}
<Tooltip title="Auto-match EPG with fuzzy logic">
<IconButton
size="small"
color="success"
variant="contained"
onClick={matchEpg}
>
<TvIcon fontSize="small" />
</IconButton>
</Tooltip>
<ButtonGroup sx={{ marginLeft: 1 }}>
<Button variant="contained" size="small" onClick={copyHDHRUrl}>
HDHR URL
</Button>
<Button variant="contained" size="small" onClick={copyM3UUrl}>
M3U URL
</Button>
<Button variant="contained" size="small" onClick={copyEPGUrl}>
EPG
</Button>
</ButtonGroup>
</Stack>
),
});
return (
<Box>
<MaterialReactTable table={table} />
{/* Channel Form Modal */}
<ChannelForm
channel={channel}
isOpen={channelModalOpen}
onClose={closeChannelForm}
/>
{/* Popover for the "copy" URLs */}
<Popover
open={openPopover}
anchorEl={anchorEl}
onClose={closePopover}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
>
<div style={{ padding: 16, display: 'flex', alignItems: 'center' }}>
<TextField
value={textToCopy}
variant="standard"
disabled
size="small"
sx={{ marginRight: 1 }}
/>
<IconButton onClick={handleCopy} color="primary">
<ContentCopy />
</IconButton>
</div>
</Popover>
{/* Snackbar for feedback */}
<Snackbar
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
open={snackbarOpen}
autoHideDuration={5000}
onClose={closeSnackbar}
message={snackbarMessage}
/>
</Box>
);
};
export default ChannelsTable;

View 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,
}}
>
Its 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 youd 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;

View file

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

View file

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

View file

@ -1,17 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
MaterialReactTable,
useMaterialReactTable,
} from 'material-react-table';
import {
Box,
Stack,
Typography,
IconButton,
Tooltip,
Select,
MenuItem,
} from '@mui/material';
import { MantineReactTable, useMantineReactTable } from 'mantine-react-table';
import API from '../../api';
import {
Delete as DeleteIcon,
@ -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,69 +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)',
mantineTableContainerProps: {
style: {
height: 'calc(40vh - 0px)',
},
},
renderTopToolbarCustomActions: ({ table }) => (
<Stack
direction="row"
sx={{
alignItems: 'center',
}}
>
<Typography>M3U Accounts</Typography>
<Tooltip title="Add New M3U Account">
<IconButton
size="small" // Makes the button smaller
color="success" // Red color for delete actions
variant="contained"
onClick={() => editPlaylist()}
>
<AddIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
</Tooltip>
</Stack>
),
});
return (
<Box
sx={{
padding: 1,
}}
>
<MaterialReactTable table={table} />
<Box>
<Flex
style={{ display: 'flex', alignItems: 'center', paddingBottom: 10 }}
gap={15}
>
<Text
h={24}
style={{
fontFamily: 'Inter, sans-serif',
fontWeight: 500,
fontSize: '20px',
lineHeight: 1,
letterSpacing: '-0.3px',
color: 'gray.6', // Adjust this to match MUI's theme.palette.text.secondary
marginBottom: 0,
}}
>
M3U Accounts
</Text>
</Flex>
<Paper
style={{
bgcolor: theme.palette.background.paper,
borderRadius: 2,
}}
>
{/* Top toolbar with Remove, Assign, Auto-match, and Add buttons */}
<Box
style={{
display: 'flex',
// alignItems: 'center',
// backgroundColor: theme.palette.background.paper,
justifyContent: 'flex-end',
padding: 10,
// gap: 1,
}}
>
<Flex gap={6}>
<Tooltip label="Assign">
<Button
leftSection={<IconSquarePlus size={14} />}
variant="light"
size="xs"
onClick={() => editPlaylist()}
p={5}
color="green"
style={{
borderWidth: '1px',
borderColor: 'green',
color: 'white',
}}
>
Add
</Button>
</Tooltip>
</Flex>
</Box>
</Paper>
<MantineReactTable table={table} />
<M3UForm
playlist={playlist}
isOpen={playlistModalOpen}

View file

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

View file

@ -1,220 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
MaterialReactTable,
useMaterialReactTable,
} from 'material-react-table';
import {
Box,
Stack,
Typography,
IconButton,
Tooltip,
Button,
} from '@mui/material';
import useStreamsStore from '../../store/streams';
import useChannelsStore from '../../store/channels'; // NEW: Import channels store
import API from '../../api';
// Make sure your api.js exports getAuthToken as a named export:
// e.g. export const getAuthToken = async () => { ... }
import { getAuthToken } from '../../api';
import {
Delete as DeleteIcon,
Edit as EditIcon,
Add as AddIcon,
} from '@mui/icons-material';
import { TableHelper } from '../../helpers';
import StreamForm from '../forms/Stream';
import usePlaylistsStore from '../../store/playlists';
const StreamsTable = () => {
const [rowSelection, setRowSelection] = useState([]);
const [stream, setStream] = useState(null);
const [modalOpen, setModalOpen] = useState(false);
const { streams, isLoading: streamsLoading } = useStreamsStore();
const { playlists } = usePlaylistsStore();
const columns = useMemo(
() => [
{ header: 'Name', accessorKey: 'name' },
{ header: 'Group', accessorKey: 'group_name' },
{
header: 'M3U',
size: 100,
accessorFn: (row) =>
playlists.find((playlist) => playlist.id === row.m3u_account)?.name,
},
],
[playlists]
);
const rowVirtualizerInstanceRef = useRef(null);
const [isLoading, setIsLoading] = useState(true);
const [sorting, setSorting] = useState([]);
// Fallback: Individual creation (optional)
const createChannelFromStream = async (stream) => {
setIsLoading(true);
await API.createChannelFromStream({
channel_name: stream.name,
channel_number: null,
stream_id: stream.id,
});
setIsLoading(false);
};
// Bulk creation: create channels from selected streams in one API call
const createChannelsFromStreams = async () => {
// Get all selected streams from the table
const selected = table
.getRowModel()
.rows.filter((row) => row.getIsSelected());
setIsLoading(true);
await API.createChannelsFromStreams(
selected.map((sel) => ({
stream_id: sel.original.id,
channel_name: sel.original.name,
}))
);
setIsLoading(false);
};
const editStream = async (stream = null) => {
setStream(stream);
setModalOpen(true);
};
const deleteStream = async (id) => {
setIsLoading(true);
await API.deleteStream(id);
setIsLoading(false);
};
const deleteStreams = async () => {
setIsLoading(true);
const selected = table
.getRowModel()
.rows.filter((row) => row.getIsSelected());
await API.deleteStreams(selected.map((stream) => stream.original.id));
setIsLoading(false);
};
const closeStreamForm = () => {
setStream(null);
setModalOpen(false);
};
useEffect(() => {
if (typeof window !== 'undefined') {
setIsLoading(false);
}
}, []);
useEffect(() => {
try {
rowVirtualizerInstanceRef.current?.scrollToIndex?.(0);
} catch (error) {
console.error(error);
}
}, [sorting]);
const table = useMaterialReactTable({
...TableHelper.defaultProperties,
columns,
data: streams,
enablePagination: false,
enableRowVirtualization: true,
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
state: {
isLoading: isLoading || streamsLoading,
sorting,
rowSelection,
},
rowVirtualizerInstanceRef,
rowVirtualizerOptions: { overscan: 5 },
enableRowActions: true,
renderRowActions: ({ row }) => (
<>
<IconButton
size="small"
color="warning"
onClick={() => editStream(row.original)}
disabled={row.original.m3u_account ? true : false}
sx={{ p: 0 }}
>
<EditIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
color="error"
onClick={() => deleteStream(row.original.id)}
sx={{ p: 0 }}
>
<DeleteIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
color="success"
onClick={() => createChannelFromStream(row.original)}
sx={{ p: 0 }}
>
<AddIcon fontSize="small" />
</IconButton>
</>
),
muiTableContainerProps: {
sx: {
height: 'calc(100vh - 75px)',
overflowY: 'auto',
},
},
renderTopToolbarCustomActions: ({ table }) => (
<Stack direction="row" sx={{ alignItems: 'center' }}>
<Typography>Streams</Typography>
<Tooltip title="Add New Stream">
<IconButton
size="small"
color="success"
variant="contained"
onClick={() => editStream()}
>
<AddIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Delete Streams">
<IconButton
size="small"
color="error"
variant="contained"
onClick={deleteStreams}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
<Button
variant="contained"
onClick={createChannelsFromStreams}
size="small"
sx={{ marginLeft: 1 }}
>
Create Channels
</Button>
</Stack>
),
});
return (
<Box>
<MaterialReactTable table={table} />
<StreamForm
stream={stream}
isOpen={modalOpen}
onClose={closeStreamForm}
/>
</Box>
);
};
export default StreamsTable;

View file

@ -0,0 +1,732 @@
import { useEffect, useMemo, useCallback, useState, useRef } from 'react';
import { MantineReactTable, useMantineReactTable } from 'mantine-react-table';
import API from '../../api';
import {
Delete as DeleteIcon,
Edit as EditIcon,
Add as AddIcon,
MoreVert as MoreVertIcon,
PlaylistAdd as PlaylistAddIcon,
} from '@mui/icons-material';
import { TableHelper } from '../../helpers';
import StreamForm from '../forms/Stream';
import usePlaylistsStore from '../../store/playlists';
import useChannelsStore from '../../store/channels';
import { useDebounce } from '../../utils';
import { SquarePlus, ListPlus, 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 = ({}) => {
/**
* 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 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.map((row) => {
if (newRowSelection[row.id] === undefined || !newRowSelection[row.id]) {
updatedSelected.delete(row.original.id);
} else {
updatedSelected.add(row.original.id);
}
});
setSelectedStreamIds([...updatedSelected]);
return newRowSelection;
});
};
const onSelectAllChange = async (e) => {
const selectAll = e.target.checked;
if (selectAll) {
// Get all stream IDs for current view
const params = new URLSearchParams();
// Apply debounced filters
Object.entries(debouncedFilters).forEach(([key, value]) => {
if (value) params.append(key, value);
});
const ids = await API.getAllStreamIds(params);
setSelectedStreamIds(ids);
} else {
setSelectedStreamIds([]);
}
const newSelection = {};
table.getRowModel().rows.forEach((item, index) => {
newSelection[index] = selectAll;
});
setRowSelection(newSelection);
};
const 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,
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: isLoading,
sorting,
// pagination,
rowSelection,
},
enableRowActions: true,
positionActionsColumn: 'first',
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;

View file

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

View file

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

View file

@ -1,33 +0,0 @@
export default {
defaultProperties: {
enableGlobalFilter: false,
enableBottomToolbar: false,
enableDensityToggle: false,
enableFullScreenToggle: false,
positionToolbarAlertBanner: 'none',
columnFilterDisplayMode: 'popover',
enableRowNumbers: false,
positionActionsColumn: 'last',
initialState: {
density: 'compact',
},
muiTableBodyCellProps: {
sx: {
padding: 0,
},
},
muiTableHeadCellProps: {
sx: {
padding: 0,
},
},
muiTableBodyProps: {
sx: {
//stripe the rows, make odd rows a darker color
'& tr:nth-of-type(odd) > td': {
// backgroundColor: '#f5f5f5',
},
},
},
},
};

View file

@ -0,0 +1,74 @@
// frontend/src/helpers/table.js
export default {
defaultProperties: {
enableBottomToolbar: false,
enableDensityToggle: false,
enableFullScreenToggle: false,
positionToolbarAlertBanner: 'none',
enableRowNumbers: false,
positionActionsColumn: 'last',
enableColumnActions: false,
enableColumnFilters: false,
enableGlobalFilter: false,
initialState: {
density: 'compact',
},
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',
},
},
mantineTableHeadCellProps: {
style: {
paddingLeft: 10,
paddingRight: 10,
color: '#CFCFCF',
backgroundColor: '#383A3F',
borderColor: '#444',
// fontWeight: 600,
// fontSize: '0.8rem',
},
},
mantineTableBodyProps: {
style: {
// Subtle row striping
'& tr:nth-of-type(odd)': {
backgroundColor: '#2F3034',
},
'& tr:nth-of-type(even)': {
backgroundColor: '#333539',
},
// Row hover effect
'& tr:hover td': {
backgroundColor: '#3B3D41',
},
},
},
},
};

View file

@ -0,0 +1,23 @@
<svg width="432" height="100" viewBox="0 0 432 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_12_1828)">
<path d="M17.1193 17.1819L17.8539 86.9621C11.9776 87.6967 7.57051 84.7585 7.57051 78.8823L6.83594 20.8545C6.83594 2.49131 23.7301 -1.91582 34.0135 5.42939L86.165 35.5451C93.5104 40.6868 94.9794 50.2356 91.3067 56.8464C90.5723 51.7047 88.3687 48.7666 83.9614 45.8285L25.1991 12.7747C20.792 9.83662 17.1193 10.5711 17.1193 17.1819Z" fill="#3E464E"/>
<path d="M12.7119 90.6348C17.1191 92.1038 21.5263 91.3693 25.1989 89.1657L85.4303 53.9083C89.1029 59.05 88.3685 64.1917 83.9612 67.1298L33.2787 96.511C25.9334 100.184 16.3846 96.511 12.7119 90.6348Z" fill="#14917E"/>
<path d="M30.3408 70.0679L66.3327 49.5011L31.0754 29.6688L30.3408 70.0679Z" fill="#14917E"/>
</g>
<path d="M137.25 69.5666H124V28.6387H137.669C141.679 28.6387 145.123 29.4581 148.001 31.0968C150.879 32.7222 153.084 35.0603 154.616 38.1113C156.161 41.1489 156.934 44.7927 156.934 49.0427C156.934 53.306 156.155 56.9698 154.596 60.0341C153.051 63.0984 150.812 65.4565 147.881 67.1085C144.95 68.7473 141.406 69.5666 137.25 69.5666ZM130.175 64.1708H136.91C140.027 64.1708 142.619 63.5846 144.684 62.4122C146.749 61.2265 148.294 59.5145 149.32 57.2763C150.346 55.0247 150.859 52.2802 150.859 49.0427C150.859 45.8319 150.346 43.1074 149.32 40.8691C148.308 38.6309 146.795 36.9322 144.784 35.7731C142.772 34.614 140.274 34.0345 137.29 34.0345H130.175V64.1708Z" fill="white"/>
<path d="M164.134 69.5666V38.8707H170.109V69.5666H164.134ZM167.151 34.1344C166.112 34.1344 165.219 33.788 164.473 33.0952C163.741 32.3891 163.374 31.5498 163.374 30.5772C163.374 29.5913 163.741 28.752 164.473 28.0592C165.219 27.3531 166.112 27 167.151 27C168.19 27 169.076 27.3531 169.809 28.0592C170.555 28.752 170.928 29.5913 170.928 30.5772C170.928 31.5498 170.555 32.3891 169.809 33.0952C169.076 33.788 168.19 34.1344 167.151 34.1344Z" fill="white"/>
<path d="M201.149 46.3648L195.734 47.3241C195.507 46.6313 195.148 45.9718 194.655 45.3456C194.175 44.7194 193.522 44.2065 192.696 43.8068C191.87 43.4071 190.838 43.2073 189.599 43.2073C187.907 43.2073 186.494 43.587 185.362 44.3464C184.229 45.0925 183.663 46.0584 183.663 47.2441C183.663 48.27 184.043 49.096 184.802 49.7222C185.562 50.3484 186.787 50.8613 188.479 51.261L193.356 52.3801C196.18 53.0329 198.285 54.0388 199.671 55.3977C201.056 56.7567 201.749 58.5219 201.749 60.6936C201.749 62.5321 201.216 64.1708 200.15 65.6097C199.098 67.0353 197.626 68.1544 195.734 68.9671C193.855 69.7798 191.677 70.1861 189.199 70.1861C185.762 70.1861 182.957 69.4534 180.785 67.9879C178.614 66.509 177.282 64.4107 176.789 61.6928L182.564 60.8135C182.924 62.319 183.663 63.4581 184.782 64.2308C185.901 64.9902 187.36 65.3699 189.159 65.3699C191.117 65.3699 192.683 64.9636 193.855 64.1509C195.028 63.3248 195.614 62.319 195.614 61.1332C195.614 60.174 195.254 59.368 194.535 58.7151C193.829 58.0623 192.743 57.5694 191.277 57.2363L186.081 56.0972C183.217 55.4444 181.099 54.4052 179.726 52.9796C178.367 51.5541 177.688 49.7488 177.688 47.5639C177.688 45.752 178.194 44.1665 179.207 42.8076C180.219 41.4487 181.618 40.3895 183.403 39.6301C185.189 38.8574 187.234 38.471 189.539 38.471C192.856 38.471 195.467 39.1904 197.372 40.6293C199.278 42.0549 200.537 43.9667 201.149 46.3648Z" fill="white"/>
<path d="M208.319 81.0776V38.8707H214.154V43.8468H214.654C215 43.2073 215.5 42.4679 216.153 41.6285C216.806 40.7892 217.712 40.0564 218.871 39.4303C220.03 38.7908 221.562 38.471 223.467 38.471C225.945 38.471 228.157 39.0972 230.102 40.3495C232.047 41.6019 233.572 43.4071 234.678 45.7653C235.797 48.1234 236.357 50.9612 236.357 54.2786C236.357 57.596 235.804 60.4404 234.698 62.8119C233.592 65.1701 232.074 66.9886 230.142 68.2676C228.21 69.5333 226.005 70.1661 223.527 70.1661C221.662 70.1661 220.136 69.8531 218.951 69.2269C217.778 68.6007 216.859 67.8679 216.193 67.0286C215.527 66.1893 215.014 65.4432 214.654 64.7904H214.294V81.0776H208.319ZM214.174 54.2187C214.174 56.377 214.487 58.2688 215.114 59.8942C215.74 61.5196 216.646 62.7919 217.831 63.7112C219.017 64.6172 220.469 65.0701 222.188 65.0701C223.973 65.0701 225.465 64.5972 226.664 63.6513C227.864 62.692 228.769 61.393 229.382 59.7543C230.009 58.1156 230.322 56.2704 230.322 54.2187C230.322 52.1936 230.015 50.375 229.402 48.7629C228.803 47.1509 227.897 45.8785 226.684 44.9459C225.485 44.0133 223.987 43.547 222.188 43.547C220.456 43.547 218.991 43.9933 217.791 44.886C216.606 45.7786 215.706 47.0243 215.094 48.623C214.481 50.2218 214.174 52.087 214.174 54.2187Z" fill="white"/>
<path d="M251.985 70.2461C250.039 70.2461 248.281 69.8864 246.709 69.1669C245.137 68.4342 243.891 67.375 242.972 65.9894C242.066 64.6038 241.613 62.9052 241.613 60.8934C241.613 59.1614 241.946 57.7359 242.612 56.6168C243.278 55.4977 244.177 54.6117 245.31 53.9589C246.442 53.306 247.708 52.8131 249.107 52.48C250.506 52.1469 251.931 51.8938 253.384 51.7206C255.222 51.5074 256.714 51.3342 257.86 51.201C259.006 51.0545 259.838 50.8213 260.358 50.5016C260.878 50.1818 261.137 49.6622 261.137 48.9428V48.8029C261.137 47.0576 260.645 45.7053 259.659 44.7461C258.686 43.7868 257.234 43.3072 255.302 43.3072C253.29 43.3072 251.705 43.7535 250.546 44.6462C249.4 45.5255 248.607 46.5047 248.168 47.5839L242.552 46.3049C243.218 44.4397 244.191 42.9342 245.47 41.7884C246.762 40.6293 248.248 39.79 249.926 39.2704C251.605 38.7375 253.37 38.471 255.222 38.471C256.448 38.471 257.747 38.6176 259.119 38.9107C260.505 39.1904 261.797 39.71 262.996 40.4694C264.208 41.2288 265.201 42.3147 265.974 43.7269C266.746 45.1258 267.133 46.9444 267.133 49.1826V69.5666H261.297V65.3699H261.058C260.671 66.1426 260.092 66.902 259.319 67.6481C258.546 68.3942 257.554 69.0137 256.341 69.5067C255.129 69.9996 253.677 70.2461 251.985 70.2461ZM253.284 65.4498C254.936 65.4498 256.348 65.1234 257.52 64.4706C258.706 63.8178 259.605 62.9651 260.218 61.9126C260.844 60.8468 261.157 59.7077 261.157 58.4953V54.5384C260.944 54.7516 260.531 54.9514 259.918 55.1379C259.319 55.3111 258.633 55.4643 257.86 55.5976C257.087 55.7175 256.335 55.8307 255.602 55.9373C254.869 56.0306 254.256 56.1105 253.763 56.1771C252.604 56.3237 251.545 56.5701 250.586 56.9165C249.64 57.2629 248.88 57.7625 248.308 58.4154C247.748 59.0549 247.468 59.9075 247.468 60.9734C247.468 62.4522 248.014 63.5713 249.107 64.3307C250.199 65.0768 251.592 65.4498 253.284 65.4498Z" fill="white"/>
<path d="M289.42 38.8707V43.6669H272.653V38.8707H289.42ZM277.15 31.5165H283.125V60.5537C283.125 61.7128 283.298 62.5854 283.645 63.1716C283.991 63.7445 284.438 64.1375 284.984 64.3507C285.543 64.5506 286.149 64.6505 286.802 64.6505C287.282 64.6505 287.702 64.6172 288.061 64.5506C288.421 64.4839 288.701 64.4306 288.901 64.3907L289.98 69.3268C289.633 69.46 289.14 69.5933 288.501 69.7265C287.861 69.873 287.062 69.953 286.103 69.9663C284.531 69.993 283.065 69.7132 281.706 69.127C280.347 68.5408 279.248 67.6348 278.409 66.4091C277.57 65.1834 277.15 63.6446 277.15 61.7927V31.5165Z" fill="white"/>
<path d="M308.63 70.1861C305.659 70.1861 303.101 69.5133 300.956 68.1677C298.825 66.8088 297.186 64.9369 296.04 62.5521C294.894 60.1673 294.321 57.4361 294.321 54.3585C294.321 51.241 294.908 48.4898 296.08 46.105C297.252 43.7069 298.904 41.835 301.036 40.4894C303.168 39.1438 305.679 38.471 308.57 38.471C310.902 38.471 312.98 38.904 314.805 39.77C316.631 40.6226 318.103 41.8217 319.222 43.3672C320.354 44.9126 321.027 46.7179 321.24 48.7829H315.425C315.105 47.344 314.372 46.105 313.227 45.0658C312.094 44.0266 310.575 43.5071 308.67 43.5071C307.005 43.5071 305.546 43.9467 304.294 44.826C303.055 45.692 302.089 46.931 301.396 48.5431C300.703 50.1419 300.357 52.0337 300.357 54.2187C300.357 56.4569 300.696 58.3887 301.376 60.0141C302.055 61.6395 303.015 62.8985 304.254 63.7911C305.506 64.6838 306.978 65.1301 308.67 65.1301C309.803 65.1301 310.828 64.9236 311.748 64.5106C312.68 64.0843 313.46 63.4781 314.086 62.692C314.725 61.906 315.172 60.96 315.425 59.8542H321.24C321.027 61.8393 320.381 63.6113 319.302 65.1701C318.223 66.7288 316.777 67.9545 314.965 68.8472C313.167 69.7398 311.055 70.1861 308.63 70.1861Z" fill="white"/>
<path d="M333.606 51.3409V69.5666H327.63V28.6387H333.526V43.8668H333.905C334.625 42.2147 335.724 40.9024 337.203 39.9299C338.682 38.9573 340.613 38.471 342.998 38.471C345.103 38.471 346.942 38.904 348.514 39.77C350.099 40.636 351.325 41.9283 352.191 43.6469C353.07 45.3523 353.51 47.4839 353.51 50.0419V69.5666H347.535V50.7614C347.535 48.5098 346.955 46.7645 345.796 45.5255C344.637 44.2731 343.025 43.6469 340.96 43.6469C339.548 43.6469 338.282 43.9467 337.163 44.5462C336.057 45.1458 335.184 46.0251 334.545 47.1842C333.919 48.3299 333.606 49.7155 333.606 51.3409Z" fill="white"/>
<path d="M370.417 70.2461C368.472 70.2461 366.713 69.8864 365.141 69.1669C363.569 68.4342 362.323 67.375 361.404 65.9894C360.498 64.6038 360.045 62.9052 360.045 60.8934C360.045 59.1614 360.378 57.7359 361.044 56.6168C361.71 55.4977 362.61 54.6117 363.742 53.9589C364.874 53.306 366.14 52.8131 367.539 52.48C368.938 52.1469 370.363 51.8938 371.816 51.7206C373.654 51.5074 375.146 51.3342 376.292 51.201C377.438 51.0545 378.271 50.8213 378.79 50.5016C379.31 50.1818 379.57 49.6622 379.57 48.9428V48.8029C379.57 47.0576 379.077 45.7053 378.091 44.7461C377.118 43.7868 375.666 43.3072 373.734 43.3072C371.722 43.3072 370.137 43.7535 368.978 44.6462C367.832 45.5255 367.039 46.5047 366.6 47.5839L360.984 46.3049C361.65 44.4397 362.623 42.9342 363.902 41.7884C365.194 40.6293 366.68 39.79 368.358 39.2704C370.037 38.7375 371.802 38.471 373.654 38.471C374.88 38.471 376.179 38.6176 377.551 38.9107C378.937 39.1904 380.229 39.71 381.428 40.4694C382.64 41.2288 383.633 42.3147 384.406 43.7269C385.179 45.1258 385.565 46.9444 385.565 49.1826V69.5666H379.729V65.3699H379.49C379.103 66.1426 378.524 66.902 377.751 67.6481C376.978 68.3942 375.986 69.0137 374.773 69.5067C373.561 69.9996 372.109 70.2461 370.417 70.2461ZM371.716 65.4498C373.368 65.4498 374.78 65.1234 375.952 64.4706C377.138 63.8178 378.037 62.9651 378.65 61.9126C379.276 60.8468 379.59 59.7077 379.59 58.4953V54.5384C379.376 54.7516 378.963 54.9514 378.351 55.1379C377.751 55.3111 377.065 55.4643 376.292 55.5976C375.519 55.7175 374.767 55.8307 374.034 55.9373C373.301 56.0306 372.688 56.1105 372.195 56.1771C371.036 56.3237 369.977 56.5701 369.018 56.9165C368.072 57.2629 367.313 57.7625 366.74 58.4154C366.18 59.0549 365.9 59.9075 365.9 60.9734C365.9 62.4522 366.447 63.5713 367.539 64.3307C368.631 65.0768 370.024 65.4498 371.716 65.4498Z" fill="white"/>
<path d="M393.524 69.5666V38.8707H399.299V43.7469H399.619C400.178 42.0948 401.164 40.7958 402.577 39.8499C404.002 38.8907 405.614 38.411 407.413 38.411C407.786 38.411 408.225 38.4244 408.732 38.451C409.251 38.4777 409.658 38.511 409.951 38.5509V44.2665C409.711 44.1998 409.285 44.1266 408.672 44.0466C408.059 43.9534 407.446 43.9067 406.833 43.9067C405.421 43.9067 404.162 44.2065 403.056 44.806C401.964 45.3922 401.098 46.2116 400.458 47.2641C399.819 48.3033 399.499 49.489 399.499 50.8213V69.5666H393.524Z" fill="white"/>
<path d="M415.122 69.5666V38.8707H420.897V43.7469H421.217C421.776 42.0948 422.762 40.7958 424.175 39.8499C425.6 38.8907 427.212 38.411 429.011 38.411C429.384 38.411 429.824 38.4244 430.33 38.451C430.849 38.4777 431.256 38.511 431.549 38.5509V44.2665C431.309 44.1998 430.883 44.1266 430.27 44.0466C429.657 43.9534 429.044 43.9067 428.431 43.9067C427.019 43.9067 425.76 44.2065 424.654 44.806C423.562 45.3922 422.696 46.2116 422.056 47.2641C421.417 48.3033 421.097 49.489 421.097 50.8213V69.5666H415.122Z" fill="white"/>
<defs>
<clipPath id="clip0_12_1828">
<rect width="100" height="100" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 11 KiB

View file

@ -0,0 +1,66 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="961.698874pt" height="1252.160897pt" viewBox="0 0 961.698874 1252.160897"
preserveAspectRatio="xMidYMid meet">
<g transform="translate(-521.000000,1664.561816) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M10300 16643 c-314 -15 -716 -106 -967 -219 -158 -71 -235 -109 -308
-151 -623 -361 -1100 -916 -1393 -1623 -62 -150 -88 -222 -117 -325 -10 -33
-23 -80 -30 -105 -7 -25 -18 -65 -23 -90 -6 -25 -18 -74 -26 -110 -25 -106
-42 -209 -76 -465 -5 -38 -16 -275 -25 -525 -15 -461 -39 -871 -56 -990 -5
-36 -16 -117 -24 -180 -21 -162 -77 -466 -110 -605 -86 -359 -182 -695 -272
-950 -170 -483 -375 -929 -618 -1345 -131 -227 -160 -271 -451 -706 -533 -795
-594 -914 -594 -1156 0 -295 169 -481 495 -544 106 -20 450 -24 555 -6 36 6
110 18 165 28 55 9 195 43 310 76 458 131 515 140 587 85 141 -107 181 -295
157 -743 -18 -354 -8 -504 42 -619 23 -52 97 -152 138 -184 49 -39 140 -80
195 -87 90 -13 231 35 340 113 88 64 193 171 348 355 82 98 160 185 174 194
24 16 26 16 60 -17 75 -72 141 -219 234 -519 92 -297 161 -505 171 -515 5 -5
9 -15 9 -23 0 -22 109 -242 150 -303 129 -189 329 -291 507 -259 108 19 216
76 304 162 128 123 327 410 665 958 144 235 219 330 260 330 33 0 66 -36 196
-211 195 -263 341 -411 463 -472 166 -81 375 -47 505 83 133 133 186 305 210
685 26 408 50 542 121 691 57 118 138 184 225 184 44 0 194 -47 289 -90 366
-168 662 -267 943 -316 140 -25 343 -16 439 18 81 29 186 99 232 155 58 71
110 191 121 282 30 234 -26 391 -454 1284 -36 76 -66 140 -66 142 0 3 -11 28
-24 57 -285 623 -511 1324 -632 1963 -15 80 -18 97 -49 295 -71 453 -96 775
-102 1295 -5 429 3 644 39 1030 19 203 16 790 -5 957 -23 181 -43 315 -63 413
-9 44 -18 91 -21 105 -24 119 -106 379 -174 548 -134 336 -378 728 -609 977
-317 343 -639 580 -1017 749 -213 95 -366 141 -648 196 -164 31 -503 53 -695
43z m400 -414 c206 -19 408 -61 592 -125 124 -43 392 -172 520 -250 461 -283
863 -763 1073 -1279 20 -49 40 -99 45 -110 23 -49 90 -260 113 -355 92 -375
108 -518 108 -945 -1 -181 -3 -343 -6 -360 -6 -34 -32 -427 -45 -675 -31 -593
13 -1315 120 -1950 45 -268 105 -561 150 -730 5 -19 23 -89 40 -155 17 -66 34
-131 39 -145 4 -14 16 -50 25 -80 26 -90 52 -166 111 -335 31 -88 60 -171 65
-185 16 -53 94 -251 157 -400 101 -241 146 -341 308 -675 74 -153 124 -263
246 -531 32 -71 69 -191 69 -224 0 -47 -36 -107 -82 -137 -38 -26 -47 -27
-142 -26 -189 3 -420 72 -911 271 -333 135 -539 176 -676 136 -121 -35 -258
-140 -338 -257 -51 -75 -116 -222 -144 -327 -33 -120 -68 -395 -82 -635 -9
-139 -23 -272 -36 -322 -13 -49 -72 -123 -98 -123 -49 0 -183 144 -356 385
-96 133 -240 281 -314 323 -104 59 -241 63 -345 10 -46 -23 -175 -149 -238
-233 -37 -50 -115 -167 -293 -445 -12 -19 -62 -100 -111 -180 -126 -207 -240
-368 -369 -518 -27 -32 -78 -62 -104 -62 -56 0 -132 62 -170 137 -33 67 -48
111 -141 413 -47 155 -128 397 -155 465 -64 163 -87 216 -120 285 -81 165
-193 299 -290 344 -43 21 -69 26 -129 26 -146 0 -231 -64 -560 -420 -153 -165
-219 -229 -252 -245 -32 -15 -37 -15 -59 -1 -38 25 -50 121 -36 285 16 189 14
474 -4 620 -34 274 -106 444 -244 573 -181 171 -406 182 -868 43 -369 -112
-653 -155 -885 -134 -136 12 -178 28 -223 81 -25 29 -27 38 -23 98 3 54 12 84
49 161 54 111 202 351 331 534 463 660 782 1207 981 1680 30 72 64 151 75 175
62 139 215 576 292 830 29 96 114 426 135 522 24 113 70 359 86 463 32 211 43
294 74 545 19 155 51 640 70 1060 8 190 20 379 25 420 14 98 38 245 50 295 74
312 132 504 205 675 48 114 181 367 250 479 184 298 468 595 753 789 67 45
124 82 127 82 2 0 28 15 57 34 94 60 335 170 493 224 67 24 264 73 365 91 162
30 450 39 650 20z"/>
<path d="M9079 13621 c-58 -12 -122 -56 -190 -130 -77 -82 -121 -153 -165
-266 -80 -200 -107 -358 -107 -625 1 -183 3 -219 27 -335 50 -244 125 -405
236 -509 127 -119 249 -112 386 22 131 127 228 326 274 561 19 95 40 312 40
406 -1 213 -57 471 -138 631 -96 189 -222 275 -363 245z"/>
<path d="M11395 13541 c-145 -38 -289 -221 -372 -471 -91 -275 -111 -549 -63
-870 39 -260 149 -494 272 -578 309 -212 686 347 688 1019 1 425 -132 778
-331 879 -44 23 -146 34 -194 21z"/>
<path d="M9829 11141 c-75 -14 -125 -48 -157 -106 -25 -44 -23 -378 2 -525 30
-171 82 -306 152 -390 62 -73 186 -100 271 -59 128 62 240 251 303 514 37 156
52 241 57 332 l6 92 -34 34 c-39 39 -122 73 -227 93 -91 17 -311 26 -373 15z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Before After
Before After

View file

@ -1,3 +1,5 @@
/* frontend/src/index.css */
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
@ -5,9 +7,35 @@ body {
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #2E2F34; /* Ensure the global background is dark */
color: #ffffff;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
/* Example scrollbars - optional, to match a dark theme. */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #3B3C41;
}
::-webkit-scrollbar-thumb {
background: #555;
}
::-webkit-scrollbar-thumb:hover {
background: #777;
}
@supports (-moz-appearance: none) {
input[readonly][aria-haspopup] {
pointer-events: auto !important;
}
}
table.mrt-table tr.mantine-Table-tr.mantine-Table-tr-detail-panel td.mantine-Table-td-detail-panel {
width: 100% !important;
}

View file

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

9
frontend/src/main.jsx Normal file
View 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>
);

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

View file

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

View file

@ -1,33 +1,33 @@
import React from 'react';
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
paddingTop: 1, // Top padding
style={{
height: '100vh - 20px', // Full viewport height
paddingTop: 0, // Top padding
paddingBottom: 1, // Bottom padding
paddingRight: 0.5,
paddingLeft: 1,
paddingLeft: 0,
boxSizing: 'border-box', // Include padding in height calculation
overflow: 'hidden', // Prevent parent scrolling
}}
>
<ChannelsTable />
</Box>
</Grid2>
<Grid2 size={6}>
</Grid.Col>
<Grid.Col span={6}>
<Box
sx={{
height: '100vh', // Full viewport height
paddingTop: 1, // Top padding
style={{
height: '100vh - 20px', // Full viewport height
paddingTop: 0, // Top padding
paddingBottom: 1, // Bottom padding
paddingRight: 1,
paddingRight: 0,
paddingLeft: 0.5,
boxSizing: 'border-box', // Include padding in height calculation
overflow: 'hidden', // Prevent parent scrolling
@ -35,8 +35,8 @@ const ChannelsPage = () => {
>
<StreamsTable />
</Box>
</Grid2>
</Grid2>
</Grid.Col>
</Grid>
);
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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