mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +00:00
Merge pull request #812 from Dispatcharr/React-Hooke-Form
Some checks failed
CI Pipeline / prepare (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Has been cancelled
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
CI Pipeline / create-manifest (push) Has been cancelled
Some checks failed
CI Pipeline / prepare (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Has been cancelled
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
CI Pipeline / create-manifest (push) Has been cancelled
Refactor forms to use react-hook-form and Yup validation
This commit is contained in:
commit
cc09c89156
12 changed files with 637 additions and 709 deletions
|
|
@ -10,10 +10,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
### Changed
|
||||
|
||||
- VOD upstream read timeout reduced from 30 seconds to 10 seconds to minimize lock hold time when clients disconnect during connection phase
|
||||
- Form management refactored across application: Migrated Channel, Stream, M3U Profile, Stream Profile, Logo, and User Agent forms from Formik to React Hook Form (RHF) with Yup validation for improved form handling, better validation feedback, and enhanced code maintainability
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed VOD profile connection count not being decremented when stream connection fails (timeout, 404, etc.), preventing profiles from reaching capacity limits and rejecting valid stream requests
|
||||
- Fixed React warning in Channel form by removing invalid `removeTrailingZeros` prop from NumberInput component
|
||||
- Release workflow Docker tagging: Fixed issue where `latest` and version tags (e.g., `0.16.0`) were creating separate manifests instead of pointing to the same image digest, which caused old `latest` tags to become orphaned/untagged after new releases. Now creates a single multi-arch manifest with both tags, maintaining proper tag relationships and download statistics visibility on GitHub.
|
||||
- Fixed onboarding message appearing in the Channels Table when filtered results are empty. The onboarding message now only displays when there are no channels created at all, not when channels exist but are filtered out by current filters.
|
||||
- Fixed `M3UMovieRelation.get_stream_url()` and `M3UEpisodeRelation.get_stream_url()` to use XC client's `_normalize_url()` method instead of simple `rstrip('/')`. This properly handles malformed M3U account URLs (e.g., containing `/player_api.php` or query parameters) before constructing VOD stream endpoints, matching behavior of live channel URL building. (Closes #722)
|
||||
|
|
|
|||
|
|
@ -236,12 +236,8 @@ class ChannelGroupViewSet(viewsets.ModelViewSet):
|
|||
return [Authenticated()]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Add annotation for association counts"""
|
||||
from django.db.models import Count
|
||||
return ChannelGroup.objects.annotate(
|
||||
channel_count=Count('channels', distinct=True),
|
||||
m3u_account_count=Count('m3u_accounts', distinct=True)
|
||||
)
|
||||
"""Return channel groups with prefetched relations for efficient counting"""
|
||||
return ChannelGroup.objects.prefetch_related('channels', 'm3u_accounts').all()
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
"""Override update to check M3U associations"""
|
||||
|
|
@ -277,15 +273,20 @@ class ChannelGroupViewSet(viewsets.ModelViewSet):
|
|||
@action(detail=False, methods=["post"], url_path="cleanup")
|
||||
def cleanup_unused_groups(self, request):
|
||||
"""Delete all channel groups with no channels or M3U account associations"""
|
||||
from django.db.models import Count
|
||||
from django.db.models import Q, Exists, OuterRef
|
||||
|
||||
# Find groups with no channels and no M3U account associations using Exists subqueries
|
||||
from .models import Channel, ChannelGroupM3UAccount
|
||||
|
||||
has_channels = Channel.objects.filter(channel_group_id=OuterRef('pk'))
|
||||
has_accounts = ChannelGroupM3UAccount.objects.filter(channel_group_id=OuterRef('pk'))
|
||||
|
||||
# Find groups with no channels and no M3U account associations
|
||||
unused_groups = ChannelGroup.objects.annotate(
|
||||
channel_count=Count('channels', distinct=True),
|
||||
m3u_account_count=Count('m3u_accounts', distinct=True)
|
||||
has_channels=Exists(has_channels),
|
||||
has_accounts=Exists(has_accounts)
|
||||
).filter(
|
||||
channel_count=0,
|
||||
m3u_account_count=0
|
||||
has_channels=False,
|
||||
has_accounts=False
|
||||
)
|
||||
|
||||
deleted_count = unused_groups.count()
|
||||
|
|
|
|||
|
|
@ -179,8 +179,8 @@ class ChannelGroupM3UAccountSerializer(serializers.ModelSerializer):
|
|||
# Channel Group
|
||||
#
|
||||
class ChannelGroupSerializer(serializers.ModelSerializer):
|
||||
channel_count = serializers.IntegerField(read_only=True)
|
||||
m3u_account_count = serializers.IntegerField(read_only=True)
|
||||
channel_count = serializers.SerializerMethodField()
|
||||
m3u_account_count = serializers.SerializerMethodField()
|
||||
m3u_accounts = ChannelGroupM3UAccountSerializer(
|
||||
many=True,
|
||||
read_only=True
|
||||
|
|
@ -190,6 +190,14 @@ class ChannelGroupSerializer(serializers.ModelSerializer):
|
|||
model = ChannelGroup
|
||||
fields = ["id", "name", "channel_count", "m3u_account_count", "m3u_accounts"]
|
||||
|
||||
def get_channel_count(self, obj):
|
||||
"""Get count of channels in this group"""
|
||||
return obj.channels.count()
|
||||
|
||||
def get_m3u_account_count(self, obj):
|
||||
"""Get count of M3U accounts associated with this group"""
|
||||
return obj.m3u_accounts.count()
|
||||
|
||||
|
||||
class ChannelProfileSerializer(serializers.ModelSerializer):
|
||||
channels = serializers.SerializerMethodField()
|
||||
|
|
|
|||
100
frontend/package-lock.json
generated
100
frontend/package-lock.json
generated
|
|
@ -12,6 +12,7 @@
|
|||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@mantine/charts": "~8.0.1",
|
||||
"@mantine/core": "~8.0.1",
|
||||
"@mantine/dates": "~8.0.1",
|
||||
|
|
@ -22,13 +23,13 @@
|
|||
"@tanstack/react-table": "^8.21.2",
|
||||
"allotment": "^1.20.4",
|
||||
"dayjs": "^1.11.13",
|
||||
"formik": "^2.4.6",
|
||||
"hls.js": "^1.5.20",
|
||||
"lucide-react": "^0.511.0",
|
||||
"mpegts.js": "^1.8.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-draggable": "^4.4.6",
|
||||
"react-hook-form": "^7.70.0",
|
||||
"react-pro-sidebar": "^1.1.0",
|
||||
"react-router-dom": "^7.3.0",
|
||||
"react-virtualized": "^9.22.6",
|
||||
|
|
@ -1248,6 +1249,18 @@
|
|||
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@hookform/resolvers": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz",
|
||||
"integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/utils": "^0.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react-hook-form": "^7.55.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||
|
|
@ -1776,6 +1789,12 @@
|
|||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@swc/core": {
|
||||
"name": "@swc/wasm",
|
||||
"version": "1.13.20",
|
||||
|
|
@ -2008,18 +2027,6 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/hoist-non-react-statics": {
|
||||
"version": "3.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.7.tgz",
|
||||
"integrity": "sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hoist-non-react-statics": "^3.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
|
|
@ -2037,6 +2044,7 @@
|
|||
"version": "19.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
|
||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
|
|
@ -2833,15 +2841,6 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deepmerge": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz",
|
||||
"integrity": "sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dequal": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
|
|
@ -3288,31 +3287,6 @@
|
|||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/formik": {
|
||||
"version": "2.4.9",
|
||||
"resolved": "https://registry.npmjs.org/formik/-/formik-2.4.9.tgz",
|
||||
"integrity": "sha512-5nI94BMnlFDdQRBY4Sz39WkhxajZJ57Fzs8wVbtsQlm5ScKIR1QLYqv/ultBnobObtlUyxpxoLodpixrsf36Og==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://opencollective.com/formik"
|
||||
}
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@types/hoist-non-react-statics": "^3.3.1",
|
||||
"deepmerge": "^2.1.1",
|
||||
"hoist-non-react-statics": "^3.3.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash-es": "^4.17.21",
|
||||
"react-fast-compare": "^2.0.1",
|
||||
"tiny-warning": "^1.0.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
|
|
@ -3751,12 +3725,6 @@
|
|||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.17.22",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.22.tgz",
|
||||
"integrity": "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.clamp": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.clamp/-/lodash.clamp-4.0.3.tgz",
|
||||
|
|
@ -4334,11 +4302,21 @@
|
|||
"react": ">= 16.8 || 18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-fast-compare": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz",
|
||||
"integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==",
|
||||
"license": "MIT"
|
||||
"node_modules/react-hook-form": {
|
||||
"version": "7.70.0",
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.70.0.tgz",
|
||||
"integrity": "sha512-COOMajS4FI3Wuwrs3GPpi/Jeef/5W1DRR84Yl5/ShlT3dKVFUfoGiEZ/QE6Uw8P4T2/CLJdcTVYKvWBMQTEpvw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/react-hook-form"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
|
|
@ -4923,12 +4901,6 @@
|
|||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tiny-warning": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
|
||||
"integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
|
|
|
|||
|
|
@ -23,11 +23,12 @@
|
|||
"@mantine/form": "~8.0.1",
|
||||
"@mantine/hooks": "~8.0.1",
|
||||
"@mantine/notifications": "~8.0.1",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@tanstack/react-table": "^8.21.2",
|
||||
"allotment": "^1.20.4",
|
||||
"dayjs": "^1.11.13",
|
||||
"formik": "^2.4.6",
|
||||
"hls.js": "^1.5.20",
|
||||
"react-hook-form": "^7.70.0",
|
||||
"lucide-react": "^0.511.0",
|
||||
"mpegts.js": "^1.8.0",
|
||||
"react": "^19.1.0",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import * as Yup from 'yup';
|
||||
import useChannelsStore from '../../store/channels';
|
||||
import API from '../../api';
|
||||
|
|
@ -42,6 +43,11 @@ import useEPGsStore from '../../store/epgs';
|
|||
import { FixedSizeList as List } from 'react-window';
|
||||
import { USER_LEVELS, USER_LEVEL_LABELS } from '../../constants';
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
name: Yup.string().required('Name is required'),
|
||||
channel_group_id: Yup.string().required('Channel group is required'),
|
||||
});
|
||||
|
||||
const ChannelForm = ({ channel = null, isOpen, onClose }) => {
|
||||
const theme = useMantineTheme();
|
||||
|
||||
|
|
@ -100,7 +106,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
|
|||
|
||||
const handleLogoSuccess = ({ logo }) => {
|
||||
if (logo && logo.id) {
|
||||
formik.setFieldValue('logo_id', logo.id);
|
||||
setValue('logo_id', logo.id);
|
||||
ensureLogosLoaded(); // Refresh logos
|
||||
}
|
||||
setLogoModalOpen(false);
|
||||
|
|
@ -124,7 +130,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
|
|||
if (response.matched) {
|
||||
// Update the form with the new EPG data
|
||||
if (response.channel && response.channel.epg_data_id) {
|
||||
formik.setFieldValue('epg_data_id', response.channel.epg_data_id);
|
||||
setValue('epg_data_id', response.channel.epg_data_id);
|
||||
}
|
||||
|
||||
notifications.show({
|
||||
|
|
@ -152,7 +158,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
|
|||
};
|
||||
|
||||
const handleSetNameFromEpg = () => {
|
||||
const epgDataId = formik.values.epg_data_id;
|
||||
const epgDataId = watch('epg_data_id');
|
||||
if (!epgDataId) {
|
||||
notifications.show({
|
||||
title: 'No EPG Selected',
|
||||
|
|
@ -164,7 +170,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
|
|||
|
||||
const tvg = tvgsById[epgDataId];
|
||||
if (tvg && tvg.name) {
|
||||
formik.setFieldValue('name', tvg.name);
|
||||
setValue('name', tvg.name);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: `Channel name set to "${tvg.name}"`,
|
||||
|
|
@ -180,7 +186,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
|
|||
};
|
||||
|
||||
const handleSetLogoFromEpg = async () => {
|
||||
const epgDataId = formik.values.epg_data_id;
|
||||
const epgDataId = watch('epg_data_id');
|
||||
if (!epgDataId) {
|
||||
notifications.show({
|
||||
title: 'No EPG Selected',
|
||||
|
|
@ -207,7 +213,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
|
|||
);
|
||||
|
||||
if (matchingLogo) {
|
||||
formik.setFieldValue('logo_id', matchingLogo.id);
|
||||
setValue('logo_id', matchingLogo.id);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: `Logo set to "${matchingLogo.name}"`,
|
||||
|
|
@ -231,7 +237,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
|
|||
// Create logo by calling the Logo API directly
|
||||
const newLogo = await API.createLogo(newLogoData);
|
||||
|
||||
formik.setFieldValue('logo_id', newLogo.id);
|
||||
setValue('logo_id', newLogo.id);
|
||||
|
||||
notifications.update({
|
||||
id: 'creating-logo',
|
||||
|
|
@ -264,7 +270,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
|
|||
};
|
||||
|
||||
const handleSetTvgIdFromEpg = () => {
|
||||
const epgDataId = formik.values.epg_data_id;
|
||||
const epgDataId = watch('epg_data_id');
|
||||
if (!epgDataId) {
|
||||
notifications.show({
|
||||
title: 'No EPG Selected',
|
||||
|
|
@ -276,7 +282,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
|
|||
|
||||
const tvg = tvgsById[epgDataId];
|
||||
if (tvg && tvg.tvg_id) {
|
||||
formik.setFieldValue('tvg_id', tvg.tvg_id);
|
||||
setValue('tvg_id', tvg.tvg_id);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: `TVG-ID set to "${tvg.tvg_id}"`,
|
||||
|
|
@ -291,130 +297,130 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
|
|||
}
|
||||
};
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
name: '',
|
||||
channel_number: '', // Change from 0 to empty string for consistency
|
||||
channel_group_id:
|
||||
Object.keys(channelGroups).length > 0
|
||||
const defaultValues = useMemo(
|
||||
() => ({
|
||||
name: channel?.name || '',
|
||||
channel_number:
|
||||
channel?.channel_number !== null &&
|
||||
channel?.channel_number !== undefined
|
||||
? channel.channel_number
|
||||
: '',
|
||||
channel_group_id: channel?.channel_group_id
|
||||
? `${channel.channel_group_id}`
|
||||
: Object.keys(channelGroups).length > 0
|
||||
? Object.keys(channelGroups)[0]
|
||||
: '',
|
||||
stream_profile_id: '0',
|
||||
tvg_id: '',
|
||||
tvc_guide_stationid: '',
|
||||
epg_data_id: '',
|
||||
logo_id: '',
|
||||
user_level: '0',
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
name: Yup.string().required('Name is required'),
|
||||
channel_group_id: Yup.string().required('Channel group is required'),
|
||||
stream_profile_id: channel?.stream_profile_id
|
||||
? `${channel.stream_profile_id}`
|
||||
: '0',
|
||||
tvg_id: channel?.tvg_id || '',
|
||||
tvc_guide_stationid: channel?.tvc_guide_stationid || '',
|
||||
epg_data_id: channel?.epg_data_id ?? '',
|
||||
logo_id: channel?.logo_id ? `${channel.logo_id}` : '',
|
||||
user_level: `${channel?.user_level ?? '0'}`,
|
||||
}),
|
||||
onSubmit: async (values, { setSubmitting }) => {
|
||||
let response;
|
||||
[channel, channelGroups]
|
||||
);
|
||||
|
||||
try {
|
||||
const formattedValues = { ...values };
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm({
|
||||
defaultValues,
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
// Convert empty or "0" stream_profile_id to null for the API
|
||||
if (
|
||||
!formattedValues.stream_profile_id ||
|
||||
formattedValues.stream_profile_id === '0'
|
||||
) {
|
||||
formattedValues.stream_profile_id = null;
|
||||
}
|
||||
const onSubmit = async (values) => {
|
||||
let response;
|
||||
|
||||
// Ensure tvg_id is properly included (no empty strings)
|
||||
formattedValues.tvg_id = formattedValues.tvg_id || null;
|
||||
try {
|
||||
const formattedValues = { ...values };
|
||||
|
||||
// Ensure tvc_guide_stationid is properly included (no empty strings)
|
||||
formattedValues.tvc_guide_stationid =
|
||||
formattedValues.tvc_guide_stationid || null;
|
||||
// Convert empty or "0" stream_profile_id to null for the API
|
||||
if (
|
||||
!formattedValues.stream_profile_id ||
|
||||
formattedValues.stream_profile_id === '0'
|
||||
) {
|
||||
formattedValues.stream_profile_id = null;
|
||||
}
|
||||
|
||||
if (channel) {
|
||||
// If there's an EPG to set, use our enhanced endpoint
|
||||
if (values.epg_data_id !== (channel.epg_data_id ?? '')) {
|
||||
// Use the special endpoint to set EPG and trigger refresh
|
||||
const epgResponse = await API.setChannelEPG(
|
||||
channel.id,
|
||||
values.epg_data_id
|
||||
);
|
||||
// Ensure tvg_id is properly included (no empty strings)
|
||||
formattedValues.tvg_id = formattedValues.tvg_id || null;
|
||||
|
||||
// Remove epg_data_id from values since we've handled it separately
|
||||
const { epg_data_id, ...otherValues } = formattedValues;
|
||||
// Ensure tvc_guide_stationid is properly included (no empty strings)
|
||||
formattedValues.tvc_guide_stationid =
|
||||
formattedValues.tvc_guide_stationid || null;
|
||||
|
||||
// Update other channel fields if needed
|
||||
if (Object.keys(otherValues).length > 0) {
|
||||
response = await API.updateChannel({
|
||||
id: channel.id,
|
||||
...otherValues,
|
||||
streams: channelStreams.map((stream) => stream.id),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// No EPG change, regular update
|
||||
if (channel) {
|
||||
// If there's an EPG to set, use our enhanced endpoint
|
||||
if (values.epg_data_id !== (channel.epg_data_id ?? '')) {
|
||||
// Use the special endpoint to set EPG and trigger refresh
|
||||
const epgResponse = await API.setChannelEPG(
|
||||
channel.id,
|
||||
values.epg_data_id
|
||||
);
|
||||
|
||||
// Remove epg_data_id from values since we've handled it separately
|
||||
const { epg_data_id, ...otherValues } = formattedValues;
|
||||
|
||||
// Update other channel fields if needed
|
||||
if (Object.keys(otherValues).length > 0) {
|
||||
response = await API.updateChannel({
|
||||
id: channel.id,
|
||||
...formattedValues,
|
||||
...otherValues,
|
||||
streams: channelStreams.map((stream) => stream.id),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// New channel creation - use the standard method
|
||||
response = await API.addChannel({
|
||||
// No EPG change, regular update
|
||||
response = await API.updateChannel({
|
||||
id: channel.id,
|
||||
...formattedValues,
|
||||
streams: channelStreams.map((stream) => stream.id),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving channel:', error);
|
||||
} else {
|
||||
// New channel creation - use the standard method
|
||||
response = await API.addChannel({
|
||||
...formattedValues,
|
||||
streams: channelStreams.map((stream) => stream.id),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving channel:', error);
|
||||
}
|
||||
|
||||
formik.resetForm();
|
||||
API.requeryChannels();
|
||||
reset();
|
||||
API.requeryChannels();
|
||||
|
||||
// Refresh channel profiles to update the membership information
|
||||
useChannelsStore.getState().fetchChannelProfiles();
|
||||
// Refresh channel profiles to update the membership information
|
||||
useChannelsStore.getState().fetchChannelProfiles();
|
||||
|
||||
setSubmitting(false);
|
||||
setTvgFilter('');
|
||||
setLogoFilter('');
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
setTvgFilter('');
|
||||
setLogoFilter('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (channel) {
|
||||
if (channel.epg_data_id) {
|
||||
const epgSource = epgs[tvgsById[channel.epg_data_id]?.epg_source];
|
||||
setSelectedEPG(epgSource ? `${epgSource.id}` : '');
|
||||
}
|
||||
reset(defaultValues);
|
||||
setChannelStreams(channel?.streams || []);
|
||||
|
||||
formik.setValues({
|
||||
name: channel.name || '',
|
||||
channel_number:
|
||||
channel.channel_number !== null ? channel.channel_number : '',
|
||||
channel_group_id: channel.channel_group_id
|
||||
? `${channel.channel_group_id}`
|
||||
: '',
|
||||
stream_profile_id: channel.stream_profile_id
|
||||
? `${channel.stream_profile_id}`
|
||||
: '0',
|
||||
tvg_id: channel.tvg_id || '',
|
||||
tvc_guide_stationid: channel.tvc_guide_stationid || '',
|
||||
epg_data_id: channel.epg_data_id ?? '',
|
||||
logo_id: channel.logo_id ? `${channel.logo_id}` : '',
|
||||
user_level: `${channel.user_level}`,
|
||||
});
|
||||
|
||||
setChannelStreams(channel.streams || []);
|
||||
if (channel?.epg_data_id) {
|
||||
const epgSource = epgs[tvgsById[channel.epg_data_id]?.epg_source];
|
||||
setSelectedEPG(epgSource ? `${epgSource.id}` : '');
|
||||
} else {
|
||||
formik.resetForm();
|
||||
setSelectedEPG('');
|
||||
}
|
||||
|
||||
if (!channel) {
|
||||
setTvgFilter('');
|
||||
setLogoFilter('');
|
||||
setChannelStreams([]); // Ensure streams are cleared when adding a new channel
|
||||
}
|
||||
}, [channel, tvgsById, channelGroups]);
|
||||
}, [defaultValues, channel, reset, epgs, tvgsById]);
|
||||
|
||||
// Memoize logo options to prevent infinite re-renders during background loading
|
||||
const logoOptions = useMemo(() => {
|
||||
|
|
@ -431,10 +437,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
|
|||
// If a new group was created and returned, update the form with it
|
||||
if (newGroup && newGroup.id) {
|
||||
// Preserve all current form values while updating just the channel_group_id
|
||||
formik.setValues({
|
||||
...formik.values,
|
||||
channel_group_id: `${newGroup.id}`,
|
||||
});
|
||||
setValue('channel_group_id', `${newGroup.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -472,7 +475,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
|
|||
}
|
||||
styles={{ content: { '--mantine-color-body': '#27272A' } }}
|
||||
>
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Group justify="space-between" align="top">
|
||||
<Stack gap="5" style={{ flex: 1 }}>
|
||||
<TextInput
|
||||
|
|
@ -481,7 +484,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
|
|||
label={
|
||||
<Group gap="xs">
|
||||
<span>Channel Name</span>
|
||||
{formik.values.epg_data_id && (
|
||||
{watch('epg_data_id') && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="transparent"
|
||||
|
|
@ -495,9 +498,8 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
|
|||
)}
|
||||
</Group>
|
||||
}
|
||||
value={formik.values.name}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.errors.name ? formik.touched.name : ''}
|
||||
{...register('name')}
|
||||
error={errors.name?.message}
|
||||
size="xs"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
|
|
@ -516,8 +518,8 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
|
|||
label="Channel Group"
|
||||
readOnly
|
||||
value={
|
||||
channelGroups[formik.values.channel_group_id]
|
||||
? channelGroups[formik.values.channel_group_id].name
|
||||
channelGroups[watch('channel_group_id')]
|
||||
? channelGroups[watch('channel_group_id')].name
|
||||
: ''
|
||||
}
|
||||
onClick={() => setGroupPopoverOpened(true)}
|
||||
|
|
@ -557,7 +559,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
|
|||
>
|
||||
<UnstyledButton
|
||||
onClick={() => {
|
||||
formik.setFieldValue(
|
||||
setValue(
|
||||
'channel_group_id',
|
||||
filteredGroups[index].id
|
||||
);
|
||||
|
|
@ -587,16 +589,12 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
|
|||
id="channel_group_id"
|
||||
name="channel_group_id"
|
||||
label="Channel Group"
|
||||
value={formik.values.channel_group_id}
|
||||
value={watch('channel_group_id')}
|
||||
searchable
|
||||
onChange={(value) => {
|
||||
formik.setFieldValue('channel_group_id', value); // Update Formik's state with the new value
|
||||
setValue('channel_group_id', value);
|
||||
}}
|
||||
error={
|
||||
formik.errors.channel_group_id
|
||||
? formik.touched.channel_group_id
|
||||
: ''
|
||||
}
|
||||
error={errors.channel_group_id?.message}
|
||||
data={Object.values(channelGroups).map((option, index) => ({
|
||||
value: `${option.id}`,
|
||||
label: option.name,
|
||||
|
|
@ -622,15 +620,11 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
|
|||
id="stream_profile_id"
|
||||
label="Stream Profile"
|
||||
name="stream_profile_id"
|
||||
value={formik.values.stream_profile_id}
|
||||
value={watch('stream_profile_id')}
|
||||
onChange={(value) => {
|
||||
formik.setFieldValue('stream_profile_id', value); // Update Formik's state with the new value
|
||||
setValue('stream_profile_id', value);
|
||||
}}
|
||||
error={
|
||||
formik.errors.stream_profile_id
|
||||
? formik.touched.stream_profile_id
|
||||
: ''
|
||||
}
|
||||
error={errors.stream_profile_id?.message}
|
||||
data={[{ value: '0', label: '(use default)' }].concat(
|
||||
streamProfiles.map((option) => ({
|
||||
value: `${option.id}`,
|
||||
|
|
@ -648,13 +642,11 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
|
|||
value: `${value}`,
|
||||
};
|
||||
})}
|
||||
value={formik.values.user_level}
|
||||
value={watch('user_level')}
|
||||
onChange={(value) => {
|
||||
formik.setFieldValue('user_level', value);
|
||||
setValue('user_level', value);
|
||||
}}
|
||||
error={
|
||||
formik.errors.user_level ? formik.touched.user_level : ''
|
||||
}
|
||||
error={errors.user_level?.message}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
|
|
@ -684,7 +676,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
|
|||
label={
|
||||
<Group gap="xs">
|
||||
<span>Logo</span>
|
||||
{formik.values.epg_data_id && (
|
||||
{watch('epg_data_id') && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="transparent"
|
||||
|
|
@ -699,9 +691,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
|
|||
</Group>
|
||||
}
|
||||
readOnly
|
||||
value={
|
||||
channelLogos[formik.values.logo_id]?.name || 'Default'
|
||||
}
|
||||
value={channelLogos[watch('logo_id')]?.name || 'Default'}
|
||||
onClick={() => {
|
||||
console.log(
|
||||
'Logo input clicked, setting popover opened to true'
|
||||
|
|
@ -756,10 +746,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
|
|||
borderRadius: '4px',
|
||||
}}
|
||||
onClick={() => {
|
||||
formik.setFieldValue(
|
||||
'logo_id',
|
||||
filteredLogos[index].id
|
||||
);
|
||||
setValue('logo_id', filteredLogos[index].id);
|
||||
setLogoPopoverOpened(false);
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
|
|
@ -810,7 +797,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
|
|||
|
||||
<Stack gap="xs" align="center">
|
||||
<LazyLogo
|
||||
logoId={formik.values.logo_id}
|
||||
logoId={watch('logo_id')}
|
||||
alt="channel logo"
|
||||
style={{ height: 40 }}
|
||||
/>
|
||||
|
|
@ -833,19 +820,12 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
|
|||
id="channel_number"
|
||||
name="channel_number"
|
||||
label="Channel # (blank to auto-assign)"
|
||||
value={formik.values.channel_number}
|
||||
onChange={(value) =>
|
||||
formik.setFieldValue('channel_number', value)
|
||||
}
|
||||
error={
|
||||
formik.errors.channel_number
|
||||
? formik.touched.channel_number
|
||||
: ''
|
||||
}
|
||||
value={watch('channel_number')}
|
||||
onChange={(value) => setValue('channel_number', value)}
|
||||
error={errors.channel_number?.message}
|
||||
size="xs"
|
||||
step={0.1} // Add step prop to allow decimal inputs
|
||||
precision={1} // Specify decimal precision
|
||||
removeTrailingZeros // Optional: remove trailing zeros for cleaner display
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
|
|
@ -854,7 +834,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
|
|||
label={
|
||||
<Group gap="xs">
|
||||
<span>TVG-ID</span>
|
||||
{formik.values.epg_data_id && (
|
||||
{watch('epg_data_id') && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="transparent"
|
||||
|
|
@ -868,9 +848,8 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
|
|||
)}
|
||||
</Group>
|
||||
}
|
||||
value={formik.values.tvg_id}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.errors.tvg_id ? formik.touched.tvg_id : ''}
|
||||
{...register('tvg_id')}
|
||||
error={errors.tvg_id?.message}
|
||||
size="xs"
|
||||
/>
|
||||
|
||||
|
|
@ -878,13 +857,8 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
|
|||
id="tvc_guide_stationid"
|
||||
name="tvc_guide_stationid"
|
||||
label="Gracenote StationId"
|
||||
value={formik.values.tvc_guide_stationid}
|
||||
onChange={formik.handleChange}
|
||||
error={
|
||||
formik.errors.tvc_guide_stationid
|
||||
? formik.touched.tvc_guide_stationid
|
||||
: ''
|
||||
}
|
||||
{...register('tvc_guide_stationid')}
|
||||
error={errors.tvc_guide_stationid?.message}
|
||||
size="xs"
|
||||
/>
|
||||
|
||||
|
|
@ -904,9 +878,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
|
|||
<Button
|
||||
size="xs"
|
||||
variant="transparent"
|
||||
onClick={() =>
|
||||
formik.setFieldValue('epg_data_id', null)
|
||||
}
|
||||
onClick={() => setValue('epg_data_id', null)}
|
||||
>
|
||||
Use Dummy
|
||||
</Button>
|
||||
|
|
@ -933,7 +905,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
|
|||
}
|
||||
readOnly
|
||||
value={(() => {
|
||||
const tvg = tvgsById[formik.values.epg_data_id];
|
||||
const tvg = tvgsById[watch('epg_data_id')];
|
||||
const epgSource = tvg && epgs[tvg.epg_source];
|
||||
const tvgLabel = tvg ? tvg.name || tvg.id : '';
|
||||
if (epgSource && tvgLabel) {
|
||||
|
|
@ -953,7 +925,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
|
|||
color="white"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
formik.setFieldValue('epg_data_id', null);
|
||||
setValue('epg_data_id', null);
|
||||
}}
|
||||
title="Create new group"
|
||||
size="small"
|
||||
|
|
@ -1012,12 +984,9 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
|
|||
size="xs"
|
||||
onClick={() => {
|
||||
if (filteredTvgs[index].id == '0') {
|
||||
formik.setFieldValue('epg_data_id', null);
|
||||
setValue('epg_data_id', null);
|
||||
} else {
|
||||
formik.setFieldValue(
|
||||
'epg_data_id',
|
||||
filteredTvgs[index].id
|
||||
);
|
||||
setValue('epg_data_id', filteredTvgs[index].id);
|
||||
// Also update selectedEPG to match the EPG source of the selected tvg
|
||||
if (filteredTvgs[index].epg_source) {
|
||||
setSelectedEPG(
|
||||
|
|
@ -1047,11 +1016,11 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
|
|||
<Button
|
||||
type="submit"
|
||||
variant="default"
|
||||
disabled={formik.isSubmitting}
|
||||
loading={formik.isSubmitting}
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
loaderProps={{ type: 'dots' }}
|
||||
>
|
||||
{formik.isSubmitting ? 'Saving...' : 'Submit'}
|
||||
{isSubmitting ? 'Saving...' : 'Submit'}
|
||||
</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import * as Yup from 'yup';
|
||||
import {
|
||||
Modal,
|
||||
|
|
@ -18,143 +19,148 @@ import { Upload, FileImage, X } from 'lucide-react';
|
|||
import { notifications } from '@mantine/notifications';
|
||||
import API from '../../api';
|
||||
|
||||
const schema = Yup.object({
|
||||
name: Yup.string().required('Name is required'),
|
||||
url: Yup.string()
|
||||
.required('URL is required')
|
||||
.test(
|
||||
'valid-url-or-path',
|
||||
'Must be a valid URL or local file path',
|
||||
(value) => {
|
||||
if (!value) return false;
|
||||
// Allow local file paths starting with /data/logos/
|
||||
if (value.startsWith('/data/logos/')) return true;
|
||||
// Allow valid URLs
|
||||
try {
|
||||
new URL(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
),
|
||||
});
|
||||
|
||||
const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => {
|
||||
const [logoPreview, setLogoPreview] = useState(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState(null); // Store selected file
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
name: '',
|
||||
url: '',
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
name: Yup.string().required('Name is required'),
|
||||
url: Yup.string()
|
||||
.required('URL is required')
|
||||
.test(
|
||||
'valid-url-or-path',
|
||||
'Must be a valid URL or local file path',
|
||||
(value) => {
|
||||
if (!value) return false;
|
||||
// Allow local file paths starting with /data/logos/
|
||||
if (value.startsWith('/data/logos/')) return true;
|
||||
// Allow valid URLs
|
||||
try {
|
||||
new URL(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
),
|
||||
const defaultValues = useMemo(
|
||||
() => ({
|
||||
name: logo?.name || '',
|
||||
url: logo?.url || '',
|
||||
}),
|
||||
onSubmit: async (values, { setSubmitting }) => {
|
||||
try {
|
||||
setUploading(true);
|
||||
let uploadResponse = null; // Store upload response for later use
|
||||
[logo]
|
||||
);
|
||||
|
||||
// If we have a selected file, upload it first
|
||||
if (selectedFile) {
|
||||
try {
|
||||
uploadResponse = await API.uploadLogo(selectedFile, values.name);
|
||||
// Use the uploaded file data instead of form values
|
||||
values.name = uploadResponse.name;
|
||||
values.url = uploadResponse.url;
|
||||
} catch (uploadError) {
|
||||
let errorMessage = 'Failed to upload logo file';
|
||||
|
||||
if (
|
||||
uploadError.code === 'NETWORK_ERROR' ||
|
||||
uploadError.message?.includes('timeout')
|
||||
) {
|
||||
errorMessage = 'Upload timed out. Please try again.';
|
||||
} else if (uploadError.status === 413) {
|
||||
errorMessage = 'File too large. Please choose a smaller file.';
|
||||
} else if (uploadError.body?.error) {
|
||||
errorMessage = uploadError.body.error;
|
||||
}
|
||||
|
||||
notifications.show({
|
||||
title: 'Upload Error',
|
||||
message: errorMessage,
|
||||
color: 'red',
|
||||
});
|
||||
return; // Don't proceed with creation if upload fails
|
||||
}
|
||||
}
|
||||
|
||||
// Now create or update the logo with the final values
|
||||
// Only proceed if we don't already have a logo from file upload
|
||||
if (logo) {
|
||||
const updatedLogo = await API.updateLogo(logo.id, values);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Logo updated successfully',
|
||||
color: 'green',
|
||||
});
|
||||
onSuccess?.({ type: 'update', logo: updatedLogo }); // Call onSuccess for updates
|
||||
} else if (!selectedFile) {
|
||||
// Only create a new logo entry if we're not uploading a file
|
||||
// (file upload already created the logo entry)
|
||||
const newLogo = await API.createLogo(values);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Logo created successfully',
|
||||
color: 'green',
|
||||
});
|
||||
onSuccess?.({ type: 'create', logo: newLogo }); // Call onSuccess for creates
|
||||
} else {
|
||||
// File was uploaded and logo was already created
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Logo uploaded successfully',
|
||||
color: 'green',
|
||||
});
|
||||
onSuccess?.({ type: 'create', logo: uploadResponse });
|
||||
}
|
||||
onClose();
|
||||
} catch (error) {
|
||||
let errorMessage = logo
|
||||
? 'Failed to update logo'
|
||||
: 'Failed to create logo';
|
||||
|
||||
// Handle specific timeout errors
|
||||
if (
|
||||
error.code === 'NETWORK_ERROR' ||
|
||||
error.message?.includes('timeout')
|
||||
) {
|
||||
errorMessage = 'Request timed out. Please try again.';
|
||||
} else if (error.response?.data?.error) {
|
||||
errorMessage = error.response.data.error;
|
||||
}
|
||||
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: errorMessage,
|
||||
color: 'red',
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
setUploading(false);
|
||||
}
|
||||
},
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
reset,
|
||||
setValue,
|
||||
watch,
|
||||
} = useForm({
|
||||
defaultValues,
|
||||
resolver: yupResolver(schema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (logo) {
|
||||
formik.setValues({
|
||||
name: logo.name || '',
|
||||
url: logo.url || '',
|
||||
const onSubmit = async (values) => {
|
||||
try {
|
||||
setUploading(true);
|
||||
let uploadResponse = null; // Store upload response for later use
|
||||
|
||||
// If we have a selected file, upload it first
|
||||
if (selectedFile) {
|
||||
try {
|
||||
uploadResponse = await API.uploadLogo(selectedFile, values.name);
|
||||
// Use the uploaded file data instead of form values
|
||||
values.name = uploadResponse.name;
|
||||
values.url = uploadResponse.url;
|
||||
} catch (uploadError) {
|
||||
let errorMessage = 'Failed to upload logo file';
|
||||
|
||||
if (
|
||||
uploadError.code === 'NETWORK_ERROR' ||
|
||||
uploadError.message?.includes('timeout')
|
||||
) {
|
||||
errorMessage = 'Upload timed out. Please try again.';
|
||||
} else if (uploadError.status === 413) {
|
||||
errorMessage = 'File too large. Please choose a smaller file.';
|
||||
} else if (uploadError.body?.error) {
|
||||
errorMessage = uploadError.body.error;
|
||||
}
|
||||
|
||||
notifications.show({
|
||||
title: 'Upload Error',
|
||||
message: errorMessage,
|
||||
color: 'red',
|
||||
});
|
||||
return; // Don't proceed with creation if upload fails
|
||||
}
|
||||
}
|
||||
|
||||
// Now create or update the logo with the final values
|
||||
// Only proceed if we don't already have a logo from file upload
|
||||
if (logo) {
|
||||
const updatedLogo = await API.updateLogo(logo.id, values);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Logo updated successfully',
|
||||
color: 'green',
|
||||
});
|
||||
onSuccess?.({ type: 'update', logo: updatedLogo }); // Call onSuccess for updates
|
||||
} else if (!selectedFile) {
|
||||
// Only create a new logo entry if we're not uploading a file
|
||||
// (file upload already created the logo entry)
|
||||
const newLogo = await API.createLogo(values);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Logo created successfully',
|
||||
color: 'green',
|
||||
});
|
||||
onSuccess?.({ type: 'create', logo: newLogo }); // Call onSuccess for creates
|
||||
} else {
|
||||
// File was uploaded and logo was already created
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Logo uploaded successfully',
|
||||
color: 'green',
|
||||
});
|
||||
onSuccess?.({ type: 'create', logo: uploadResponse });
|
||||
}
|
||||
onClose();
|
||||
} catch (error) {
|
||||
let errorMessage = logo
|
||||
? 'Failed to update logo'
|
||||
: 'Failed to create logo';
|
||||
|
||||
// Handle specific timeout errors
|
||||
if (
|
||||
error.code === 'NETWORK_ERROR' ||
|
||||
error.message?.includes('timeout')
|
||||
) {
|
||||
errorMessage = 'Request timed out. Please try again.';
|
||||
} else if (error.response?.data?.error) {
|
||||
errorMessage = error.response.data.error;
|
||||
}
|
||||
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: errorMessage,
|
||||
color: 'red',
|
||||
});
|
||||
setLogoPreview(logo.cache_url);
|
||||
} else {
|
||||
formik.resetForm();
|
||||
setLogoPreview(null);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
// Clear any selected file when logo changes
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
reset(defaultValues);
|
||||
setLogoPreview(logo?.cache_url || null);
|
||||
setSelectedFile(null);
|
||||
}, [logo, isOpen]);
|
||||
}, [defaultValues, logo, reset]);
|
||||
|
||||
const handleFileSelect = (files) => {
|
||||
if (files.length === 0) return;
|
||||
|
|
@ -180,18 +186,19 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => {
|
|||
setLogoPreview(previewUrl);
|
||||
|
||||
// Auto-fill the name field if empty
|
||||
if (!formik.values.name) {
|
||||
const currentName = watch('name');
|
||||
if (!currentName) {
|
||||
const nameWithoutExtension = file.name.replace(/\.[^/.]+$/, '');
|
||||
formik.setFieldValue('name', nameWithoutExtension);
|
||||
setValue('name', nameWithoutExtension);
|
||||
}
|
||||
|
||||
// Set a placeholder URL (will be replaced after upload)
|
||||
formik.setFieldValue('url', 'file://pending-upload');
|
||||
setValue('url', 'file://pending-upload');
|
||||
};
|
||||
|
||||
const handleUrlChange = (event) => {
|
||||
const url = event.target.value;
|
||||
formik.setFieldValue('url', url);
|
||||
setValue('url', url);
|
||||
|
||||
// Clear any selected file when manually entering URL
|
||||
if (selectedFile) {
|
||||
|
|
@ -219,7 +226,7 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => {
|
|||
const filename = pathname.substring(pathname.lastIndexOf('/') + 1);
|
||||
const nameWithoutExtension = filename.replace(/\.[^/.]+$/, '');
|
||||
if (nameWithoutExtension) {
|
||||
formik.setFieldValue('name', nameWithoutExtension);
|
||||
setValue('name', nameWithoutExtension);
|
||||
}
|
||||
} catch (error) {
|
||||
// If the URL is invalid, do nothing.
|
||||
|
|
@ -244,7 +251,7 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => {
|
|||
title={logo ? 'Edit Logo' : 'Add Logo'}
|
||||
size="md"
|
||||
>
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack spacing="md">
|
||||
{/* Logo Preview */}
|
||||
{logoPreview && (
|
||||
|
|
@ -338,18 +345,18 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => {
|
|||
<TextInput
|
||||
label="Logo URL"
|
||||
placeholder="https://example.com/logo.png"
|
||||
{...formik.getFieldProps('url')}
|
||||
{...register('url')}
|
||||
onChange={handleUrlChange}
|
||||
onBlur={handleUrlBlur}
|
||||
error={formik.touched.url && formik.errors.url}
|
||||
error={errors.url?.message}
|
||||
disabled={!!selectedFile} // Disable when file is selected
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Name"
|
||||
placeholder="Enter logo name"
|
||||
{...formik.getFieldProps('name')}
|
||||
error={formik.touched.name && formik.errors.name}
|
||||
{...register('name')}
|
||||
error={errors.name?.message}
|
||||
/>
|
||||
|
||||
{selectedFile && (
|
||||
|
|
@ -363,7 +370,7 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => {
|
|||
<Button variant="light" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" loading={formik.isSubmitting || uploading}>
|
||||
<Button type="submit" loading={isSubmitting || uploading}>
|
||||
{logo ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</Group>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
// Modal.js
|
||||
import React, { useState, useEffect, forwardRef } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import API from '../../api';
|
||||
import M3UProfiles from './M3UProfiles';
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import * as Yup from 'yup';
|
||||
import API from '../../api';
|
||||
import {
|
||||
|
|
@ -31,6 +32,89 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
|
|||
const [sampleInput, setSampleInput] = useState('');
|
||||
const isDefaultProfile = profile?.is_default;
|
||||
|
||||
const defaultValues = useMemo(
|
||||
() => ({
|
||||
name: profile?.name || '',
|
||||
max_streams: profile?.max_streams || 0,
|
||||
search_pattern: profile?.search_pattern || '',
|
||||
replace_pattern: profile?.replace_pattern || '',
|
||||
notes: profile?.custom_properties?.notes || '',
|
||||
}),
|
||||
[profile]
|
||||
);
|
||||
|
||||
const schema = Yup.object({
|
||||
name: Yup.string().required('Name is required'),
|
||||
search_pattern: Yup.string().when([], {
|
||||
is: () => !isDefaultProfile,
|
||||
then: (schema) => schema.required('Search pattern is required'),
|
||||
otherwise: (schema) => schema.notRequired(),
|
||||
}),
|
||||
replace_pattern: Yup.string().when([], {
|
||||
is: () => !isDefaultProfile,
|
||||
then: (schema) => schema.required('Replace pattern is required'),
|
||||
otherwise: (schema) => schema.notRequired(),
|
||||
}),
|
||||
notes: Yup.string(), // Optional field
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
reset,
|
||||
setValue,
|
||||
watch,
|
||||
} = useForm({
|
||||
defaultValues,
|
||||
resolver: yupResolver(schema),
|
||||
});
|
||||
|
||||
const onSubmit = async (values) => {
|
||||
console.log('submiting');
|
||||
|
||||
// For default profiles, only send name and custom_properties (notes)
|
||||
let submitValues;
|
||||
if (isDefaultProfile) {
|
||||
submitValues = {
|
||||
name: values.name,
|
||||
custom_properties: {
|
||||
// Preserve existing custom_properties and add/update notes
|
||||
...(profile?.custom_properties || {}),
|
||||
notes: values.notes || '',
|
||||
},
|
||||
};
|
||||
} else {
|
||||
// For regular profiles, send all fields
|
||||
submitValues = {
|
||||
name: values.name,
|
||||
max_streams: values.max_streams,
|
||||
search_pattern: values.search_pattern,
|
||||
replace_pattern: values.replace_pattern,
|
||||
custom_properties: {
|
||||
// Preserve existing custom_properties and add/update notes
|
||||
...(profile?.custom_properties || {}),
|
||||
notes: values.notes || '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (profile?.id) {
|
||||
await API.updateM3UProfile(m3u.id, {
|
||||
id: profile.id,
|
||||
...submitValues,
|
||||
});
|
||||
} else {
|
||||
await API.addM3UProfile(m3u.id, submitValues);
|
||||
}
|
||||
|
||||
reset();
|
||||
// Reset local state to sync with form reset
|
||||
setSearchPattern('');
|
||||
setReplacePattern('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchStreamUrl() {
|
||||
try {
|
||||
|
|
@ -79,99 +163,22 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
|
|||
}, [searchPattern, replacePattern]);
|
||||
|
||||
const onSearchPatternUpdate = (e) => {
|
||||
formik.handleChange(e);
|
||||
setSearchPattern(e.target.value);
|
||||
const value = e.target.value;
|
||||
setSearchPattern(value);
|
||||
setValue('search_pattern', value);
|
||||
};
|
||||
|
||||
const onReplacePatternUpdate = (e) => {
|
||||
formik.handleChange(e);
|
||||
setReplacePattern(e.target.value);
|
||||
const value = e.target.value;
|
||||
setReplacePattern(value);
|
||||
setValue('replace_pattern', value);
|
||||
};
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
name: '',
|
||||
max_streams: 0,
|
||||
search_pattern: '',
|
||||
replace_pattern: '',
|
||||
notes: '',
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
name: Yup.string().required('Name is required'),
|
||||
search_pattern: Yup.string().when([], {
|
||||
is: () => !isDefaultProfile,
|
||||
then: (schema) => schema.required('Search pattern is required'),
|
||||
otherwise: (schema) => schema.notRequired(),
|
||||
}),
|
||||
replace_pattern: Yup.string().when([], {
|
||||
is: () => !isDefaultProfile,
|
||||
then: (schema) => schema.required('Replace pattern is required'),
|
||||
otherwise: (schema) => schema.notRequired(),
|
||||
}),
|
||||
notes: Yup.string(), // Optional field
|
||||
}),
|
||||
onSubmit: async (values, { setSubmitting, resetForm }) => {
|
||||
console.log('submiting');
|
||||
|
||||
// For default profiles, only send name and custom_properties (notes)
|
||||
let submitValues;
|
||||
if (isDefaultProfile) {
|
||||
submitValues = {
|
||||
name: values.name,
|
||||
custom_properties: {
|
||||
// Preserve existing custom_properties and add/update notes
|
||||
...(profile?.custom_properties || {}),
|
||||
notes: values.notes || '',
|
||||
},
|
||||
};
|
||||
} else {
|
||||
// For regular profiles, send all fields
|
||||
submitValues = {
|
||||
name: values.name,
|
||||
max_streams: values.max_streams,
|
||||
search_pattern: values.search_pattern,
|
||||
replace_pattern: values.replace_pattern,
|
||||
custom_properties: {
|
||||
// Preserve existing custom_properties and add/update notes
|
||||
...(profile?.custom_properties || {}),
|
||||
notes: values.notes || '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (profile?.id) {
|
||||
await API.updateM3UProfile(m3u.id, {
|
||||
id: profile.id,
|
||||
...submitValues,
|
||||
});
|
||||
} else {
|
||||
await API.addM3UProfile(m3u.id, submitValues);
|
||||
}
|
||||
|
||||
resetForm();
|
||||
// Reset local state to sync with formik reset
|
||||
setSearchPattern('');
|
||||
setReplacePattern('');
|
||||
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,
|
||||
notes: profile.custom_properties?.notes || '',
|
||||
});
|
||||
} else {
|
||||
formik.resetForm();
|
||||
}
|
||||
}, [profile]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
reset(defaultValues);
|
||||
setSearchPattern(profile?.search_pattern || '');
|
||||
setReplacePattern(profile?.replace_pattern || '');
|
||||
}, [defaultValues, profile, reset]);
|
||||
|
||||
const handleSampleInputChange = (e) => {
|
||||
setSampleInput(e.target.value);
|
||||
|
|
@ -212,27 +219,21 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
|
|||
}
|
||||
size="lg"
|
||||
>
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<TextInput
|
||||
id="name"
|
||||
name="name"
|
||||
label="Name"
|
||||
value={formik.values.name}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.errors.name ? formik.touched.name : ''}
|
||||
{...register('name')}
|
||||
error={errors.name?.message}
|
||||
/>
|
||||
|
||||
{/* Only show max streams field for non-default profiles */}
|
||||
{!isDefaultProfile && (
|
||||
<NumberInput
|
||||
id="max_streams"
|
||||
name="max_streams"
|
||||
label="Max Streams"
|
||||
value={formik.values.max_streams}
|
||||
onChange={(value) =>
|
||||
formik.setFieldValue('max_streams', value || 0)
|
||||
}
|
||||
error={formik.errors.max_streams ? formik.touched.max_streams : ''}
|
||||
{...register('max_streams')}
|
||||
value={watch('max_streams')}
|
||||
onChange={(value) => setValue('max_streams', value || 0)}
|
||||
error={errors.max_streams?.message}
|
||||
min={0}
|
||||
placeholder="0 = unlimited"
|
||||
/>
|
||||
|
|
@ -242,40 +243,25 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
|
|||
{!isDefaultProfile && (
|
||||
<>
|
||||
<TextInput
|
||||
id="search_pattern"
|
||||
name="search_pattern"
|
||||
label="Search Pattern (Regex)"
|
||||
value={searchPattern}
|
||||
onChange={onSearchPatternUpdate}
|
||||
error={
|
||||
formik.errors.search_pattern
|
||||
? formik.touched.search_pattern
|
||||
: ''
|
||||
}
|
||||
error={errors.search_pattern?.message}
|
||||
/>
|
||||
<TextInput
|
||||
id="replace_pattern"
|
||||
name="replace_pattern"
|
||||
label="Replace Pattern"
|
||||
value={replacePattern}
|
||||
onChange={onReplacePatternUpdate}
|
||||
error={
|
||||
formik.errors.replace_pattern
|
||||
? formik.touched.replace_pattern
|
||||
: ''
|
||||
}
|
||||
error={errors.replace_pattern?.message}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
id="notes"
|
||||
name="notes"
|
||||
label="Notes"
|
||||
placeholder="Add any notes or comments about this profile..."
|
||||
value={formik.values.notes}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.errors.notes ? formik.touched.notes : ''}
|
||||
{...register('notes')}
|
||||
error={errors.notes?.message}
|
||||
minRows={2}
|
||||
maxRows={4}
|
||||
autosize
|
||||
|
|
@ -290,9 +276,9 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
|
|||
>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={formik.isSubmitting}
|
||||
disabled={isSubmitting}
|
||||
size="xs"
|
||||
style={{ width: formik.isSubmitting ? 'auto' : 'auto' }}
|
||||
style={{ width: isSubmitting ? 'auto' : 'auto' }}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,108 +1,104 @@
|
|||
// Modal.js
|
||||
import React, { useEffect } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import * as Yup from 'yup';
|
||||
import API from '../../api';
|
||||
import useStreamProfilesStore from '../../store/streamProfiles';
|
||||
import { Modal, TextInput, Select, Button, Flex } from '@mantine/core';
|
||||
import useChannelsStore from '../../store/channels';
|
||||
|
||||
const schema = Yup.object({
|
||||
name: Yup.string().required('Name is required'),
|
||||
url: Yup.string().required('URL is required').min(0),
|
||||
});
|
||||
|
||||
const Stream = ({ stream = null, isOpen, onClose }) => {
|
||||
const streamProfiles = useStreamProfilesStore((state) => state.profiles);
|
||||
const channelGroups = useChannelsStore((s) => s.channelGroups);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
name: '',
|
||||
url: '',
|
||||
channel_group: null,
|
||||
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'),
|
||||
const defaultValues = useMemo(
|
||||
() => ({
|
||||
name: stream?.name || '',
|
||||
url: stream?.url || '',
|
||||
channel_group: stream?.channel_group
|
||||
? String(stream.channel_group)
|
||||
: null,
|
||||
stream_profile_id: stream?.stream_profile_id
|
||||
? String(stream.stream_profile_id)
|
||||
: '',
|
||||
}),
|
||||
onSubmit: async (values, { setSubmitting, resetForm }) => {
|
||||
console.log(values);
|
||||
[stream]
|
||||
);
|
||||
|
||||
// Convert string IDs back to integers for the API
|
||||
const payload = {
|
||||
...values,
|
||||
channel_group: values.channel_group
|
||||
? parseInt(values.channel_group, 10)
|
||||
: null,
|
||||
stream_profile_id: values.stream_profile_id
|
||||
? parseInt(values.stream_profile_id, 10)
|
||||
: null,
|
||||
};
|
||||
|
||||
if (stream?.id) {
|
||||
await API.updateStream({ id: stream.id, ...payload });
|
||||
} else {
|
||||
await API.addStream(payload);
|
||||
}
|
||||
|
||||
resetForm();
|
||||
setSubmitting(false);
|
||||
onClose();
|
||||
},
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
reset,
|
||||
setValue,
|
||||
watch,
|
||||
} = useForm({
|
||||
defaultValues,
|
||||
resolver: yupResolver(schema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (stream) {
|
||||
formik.setValues({
|
||||
name: stream.name,
|
||||
url: stream.url,
|
||||
// Convert IDs to strings to match Select component values
|
||||
channel_group: stream.channel_group
|
||||
? String(stream.channel_group)
|
||||
: null,
|
||||
stream_profile_id: stream.stream_profile_id
|
||||
? String(stream.stream_profile_id)
|
||||
: '',
|
||||
});
|
||||
const onSubmit = async (values) => {
|
||||
console.log(values);
|
||||
|
||||
// Convert string IDs back to integers for the API
|
||||
const payload = {
|
||||
...values,
|
||||
channel_group: values.channel_group
|
||||
? parseInt(values.channel_group, 10)
|
||||
: null,
|
||||
stream_profile_id: values.stream_profile_id
|
||||
? parseInt(values.stream_profile_id, 10)
|
||||
: null,
|
||||
};
|
||||
|
||||
if (stream?.id) {
|
||||
await API.updateStream({ id: stream.id, ...payload });
|
||||
} else {
|
||||
formik.resetForm();
|
||||
await API.addStream(payload);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [stream]);
|
||||
|
||||
reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
reset(defaultValues);
|
||||
}, [defaultValues, reset]);
|
||||
|
||||
if (!isOpen) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const channelGroupValue = watch('channel_group');
|
||||
const streamProfileValue = watch('stream_profile_id');
|
||||
|
||||
return (
|
||||
<Modal opened={isOpen} onClose={onClose} title="Stream" zIndex={10}>
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<TextInput
|
||||
id="name"
|
||||
name="name"
|
||||
label="Stream Name"
|
||||
value={formik.values.name}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.errors.name}
|
||||
{...register('name')}
|
||||
error={errors.name?.message}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
id="url"
|
||||
name="url"
|
||||
label="Stream URL"
|
||||
value={formik.values.url}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.errors.url}
|
||||
{...register('url')}
|
||||
error={errors.url?.message}
|
||||
/>
|
||||
|
||||
<Select
|
||||
id="channel_group"
|
||||
name="channel_group"
|
||||
label="Group"
|
||||
searchable
|
||||
value={formik.values.channel_group}
|
||||
onChange={(value) => {
|
||||
formik.setFieldValue('channel_group', value); // Update Formik's state with the new value
|
||||
}}
|
||||
error={formik.errors.channel_group}
|
||||
value={channelGroupValue}
|
||||
onChange={(value) => setValue('channel_group', value)}
|
||||
error={errors.channel_group?.message}
|
||||
data={Object.values(channelGroups).map((group) => ({
|
||||
label: group.name,
|
||||
value: `${group.id}`,
|
||||
|
|
@ -110,16 +106,12 @@ const Stream = ({ stream = null, isOpen, onClose }) => {
|
|||
/>
|
||||
|
||||
<Select
|
||||
id="stream_profile_id"
|
||||
name="stream_profile_id"
|
||||
label="Stream Profile"
|
||||
placeholder="Optional"
|
||||
searchable
|
||||
value={formik.values.stream_profile_id}
|
||||
onChange={(value) => {
|
||||
formik.setFieldValue('stream_profile_id', value); // Update Formik's state with the new value
|
||||
}}
|
||||
error={formik.errors.stream_profile_id}
|
||||
value={streamProfileValue}
|
||||
onChange={(value) => setValue('stream_profile_id', value)}
|
||||
error={errors.stream_profile_id?.message}
|
||||
data={streamProfiles.map((profile) => ({
|
||||
label: profile.name,
|
||||
value: `${profile.id}`,
|
||||
|
|
@ -132,7 +124,7 @@ const Stream = ({ stream = null, isOpen, onClose }) => {
|
|||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={formik.isSubmitting}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,96 +1,91 @@
|
|||
// Modal.js
|
||||
import React, { useEffect } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import * as Yup from 'yup';
|
||||
import API from '../../api';
|
||||
import useUserAgentsStore from '../../store/userAgents';
|
||||
import { Modal, TextInput, Select, Button, Flex } from '@mantine/core';
|
||||
|
||||
const schema = Yup.object({
|
||||
name: Yup.string().required('Name is required'),
|
||||
command: Yup.string().required('Command is required'),
|
||||
parameters: Yup.string().required('Parameters are is required'),
|
||||
});
|
||||
|
||||
const StreamProfile = ({ profile = null, isOpen, onClose }) => {
|
||||
const userAgents = useUserAgentsStore((state) => state.userAgents);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
name: '',
|
||||
command: '',
|
||||
parameters: '',
|
||||
is_active: true,
|
||||
user_agent: '',
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
name: Yup.string().required('Name is required'),
|
||||
command: Yup.string().required('Command is required'),
|
||||
parameters: Yup.string().required('Parameters are is required'),
|
||||
const defaultValues = useMemo(
|
||||
() => ({
|
||||
name: profile?.name || '',
|
||||
command: profile?.command || '',
|
||||
parameters: profile?.parameters || '',
|
||||
is_active: profile?.is_active ?? true,
|
||||
user_agent: profile?.user_agent || '',
|
||||
}),
|
||||
onSubmit: async (values, { setSubmitting, resetForm }) => {
|
||||
if (profile?.id) {
|
||||
await API.updateStreamProfile({ id: profile.id, ...values });
|
||||
} else {
|
||||
await API.addStreamProfile(values);
|
||||
}
|
||||
[profile]
|
||||
);
|
||||
|
||||
resetForm();
|
||||
setSubmitting(false);
|
||||
onClose();
|
||||
},
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
reset,
|
||||
watch,
|
||||
} = useForm({
|
||||
defaultValues,
|
||||
resolver: yupResolver(schema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (profile) {
|
||||
formik.setValues({
|
||||
name: profile.name,
|
||||
command: profile.command,
|
||||
parameters: profile.parameters,
|
||||
is_active: profile.is_active,
|
||||
user_agent: profile.user_agent,
|
||||
});
|
||||
const onSubmit = async (values) => {
|
||||
if (profile?.id) {
|
||||
await API.updateStreamProfile({ id: profile.id, ...values });
|
||||
} else {
|
||||
formik.resetForm();
|
||||
await API.addStreamProfile(values);
|
||||
}
|
||||
}, [profile]);
|
||||
|
||||
reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
reset(defaultValues);
|
||||
}, [defaultValues, reset]);
|
||||
|
||||
if (!isOpen) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const userAgentValue = watch('user_agent');
|
||||
|
||||
return (
|
||||
<Modal opened={isOpen} onClose={onClose} title="Stream Profile">
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<TextInput
|
||||
id="name"
|
||||
name="name"
|
||||
label="Name"
|
||||
value={formik.values.name}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.errors.name}
|
||||
{...register('name')}
|
||||
error={errors.name?.message}
|
||||
disabled={profile ? profile.locked : false}
|
||||
/>
|
||||
<TextInput
|
||||
id="command"
|
||||
name="command"
|
||||
label="Command"
|
||||
value={formik.values.command}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.errors.command}
|
||||
{...register('command')}
|
||||
error={errors.command?.message}
|
||||
disabled={profile ? profile.locked : false}
|
||||
/>
|
||||
<TextInput
|
||||
id="parameters"
|
||||
name="parameters"
|
||||
label="Parameters"
|
||||
value={formik.values.parameters}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.errors.parameters}
|
||||
{...register('parameters')}
|
||||
error={errors.parameters?.message}
|
||||
disabled={profile ? profile.locked : false}
|
||||
/>
|
||||
|
||||
<Select
|
||||
id="user_agent"
|
||||
name="user_agent"
|
||||
label="User-Agent"
|
||||
value={formik.values.user_agent}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.errors.user_agent}
|
||||
{...register('user_agent')}
|
||||
value={userAgentValue}
|
||||
error={errors.user_agent?.message}
|
||||
data={userAgents.map((ua) => ({
|
||||
label: ua.name,
|
||||
value: `${ua.id}`,
|
||||
|
|
@ -102,7 +97,7 @@ const StreamProfile = ({ profile = null, isOpen, onClose }) => {
|
|||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={formik.isSubmitting}
|
||||
disabled={isSubmitting}
|
||||
size="small"
|
||||
>
|
||||
Submit
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// Modal.js
|
||||
import React, { useEffect } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import * as Yup from 'yup';
|
||||
import API from '../../api';
|
||||
import {
|
||||
|
|
@ -16,87 +17,82 @@ import {
|
|||
} from '@mantine/core';
|
||||
import { NETWORK_ACCESS_OPTIONS } from '../../constants';
|
||||
|
||||
const UserAgent = ({ userAgent = null, isOpen, onClose }) => {
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
name: '',
|
||||
user_agent: '',
|
||||
description: '',
|
||||
is_active: true,
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
name: Yup.string().required('Name is required'),
|
||||
user_agent: Yup.string().required('User-Agent is required'),
|
||||
}),
|
||||
onSubmit: async (values, { setSubmitting, resetForm }) => {
|
||||
if (userAgent?.id) {
|
||||
await API.updateUserAgent({ id: userAgent.id, ...values });
|
||||
} else {
|
||||
await API.addUserAgent(values);
|
||||
}
|
||||
const schema = Yup.object({
|
||||
name: Yup.string().required('Name is required'),
|
||||
user_agent: Yup.string().required('User-Agent is required'),
|
||||
});
|
||||
|
||||
resetForm();
|
||||
setSubmitting(false);
|
||||
onClose();
|
||||
},
|
||||
const UserAgent = ({ userAgent = null, isOpen, onClose }) => {
|
||||
const defaultValues = useMemo(
|
||||
() => ({
|
||||
name: userAgent?.name || '',
|
||||
user_agent: userAgent?.user_agent || '',
|
||||
description: userAgent?.description || '',
|
||||
is_active: userAgent?.is_active ?? true,
|
||||
}),
|
||||
[userAgent]
|
||||
);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
reset,
|
||||
setValue,
|
||||
watch,
|
||||
} = useForm({
|
||||
defaultValues,
|
||||
resolver: yupResolver(schema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (userAgent) {
|
||||
formik.setValues({
|
||||
name: userAgent.name,
|
||||
user_agent: userAgent.user_agent,
|
||||
description: userAgent.description,
|
||||
is_active: userAgent.is_active,
|
||||
});
|
||||
const onSubmit = async (values) => {
|
||||
if (userAgent?.id) {
|
||||
await API.updateUserAgent({ id: userAgent.id, ...values });
|
||||
} else {
|
||||
formik.resetForm();
|
||||
await API.addUserAgent(values);
|
||||
}
|
||||
}, [userAgent]);
|
||||
|
||||
reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
reset(defaultValues);
|
||||
}, [defaultValues, reset]);
|
||||
|
||||
if (!isOpen) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const isActive = watch('is_active');
|
||||
|
||||
return (
|
||||
<Modal opened={isOpen} onClose={onClose} title="User-Agent">
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<TextInput
|
||||
id="name"
|
||||
name="name"
|
||||
label="Name"
|
||||
value={formik.values.name}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.touched.name && Boolean(formik.errors.name)}
|
||||
{...register('name')}
|
||||
error={errors.name?.message}
|
||||
/>
|
||||
|
||||
<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)}
|
||||
{...register('user_agent')}
|
||||
error={errors.user_agent?.message}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
id="description"
|
||||
name="description"
|
||||
label="Description"
|
||||
value={formik.values.description}
|
||||
onChange={formik.handleChange}
|
||||
error={
|
||||
formik.touched.description && Boolean(formik.errors.description)
|
||||
}
|
||||
{...register('description')}
|
||||
error={errors.description?.message}
|
||||
/>
|
||||
|
||||
<Space h="md" />
|
||||
|
||||
<Checkbox
|
||||
name="is_active"
|
||||
label="Is Active"
|
||||
checked={formik.values.is_active}
|
||||
onChange={formik.handleChange}
|
||||
checked={isActive}
|
||||
onChange={(e) => setValue('is_active', e.currentTarget.checked)}
|
||||
/>
|
||||
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
|
|
@ -104,7 +100,7 @@ const UserAgent = ({ userAgent = null, isOpen, onClose }) => {
|
|||
size="small"
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={formik.isSubmitting}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue