mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +00:00
Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into pr/nick4810/811
This commit is contained in:
commit
10447f8c86
29 changed files with 955 additions and 865 deletions
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>
|
||||
|
|
|
|||
|
|
@ -369,7 +369,8 @@ const LiveGroupFilter = ({
|
|||
if (
|
||||
group.custom_properties?.custom_epg_id !==
|
||||
undefined ||
|
||||
group.custom_properties?.force_dummy_epg
|
||||
group.custom_properties?.force_dummy_epg ||
|
||||
group.custom_properties?.force_epg_selected
|
||||
) {
|
||||
selectedValues.push('force_epg');
|
||||
}
|
||||
|
|
@ -432,23 +433,20 @@ const LiveGroupFilter = ({
|
|||
|
||||
// Handle force_epg
|
||||
if (selectedOptions.includes('force_epg')) {
|
||||
// Migrate from old force_dummy_epg if present
|
||||
// Set default to force_dummy_epg if no EPG settings exist yet
|
||||
if (
|
||||
newCustomProps.force_dummy_epg &&
|
||||
newCustomProps.custom_epg_id === undefined
|
||||
newCustomProps.custom_epg_id ===
|
||||
undefined &&
|
||||
!newCustomProps.force_dummy_epg
|
||||
) {
|
||||
// Migrate: force_dummy_epg=true becomes custom_epg_id=null
|
||||
newCustomProps.custom_epg_id = null;
|
||||
delete newCustomProps.force_dummy_epg;
|
||||
} else if (
|
||||
newCustomProps.custom_epg_id === undefined
|
||||
) {
|
||||
// New configuration: initialize with null (no EPG/default dummy)
|
||||
newCustomProps.custom_epg_id = null;
|
||||
// Default to "No EPG (Disabled)"
|
||||
newCustomProps.force_dummy_epg = true;
|
||||
}
|
||||
} else {
|
||||
// Only remove custom_epg_id when deselected
|
||||
// Remove all EPG settings when deselected
|
||||
delete newCustomProps.custom_epg_id;
|
||||
delete newCustomProps.force_dummy_epg;
|
||||
delete newCustomProps.force_epg_selected;
|
||||
}
|
||||
|
||||
// Handle group_override
|
||||
|
|
@ -1124,7 +1122,8 @@ const LiveGroupFilter = ({
|
|||
|
||||
{/* Show EPG selector when force_epg is selected */}
|
||||
{(group.custom_properties?.custom_epg_id !== undefined ||
|
||||
group.custom_properties?.force_dummy_epg) && (
|
||||
group.custom_properties?.force_dummy_epg ||
|
||||
group.custom_properties?.force_epg_selected) && (
|
||||
<Tooltip
|
||||
label="Force a specific EPG source for all auto-synced channels in this group. For dummy EPGs, all channels will share the same EPG data. For regular EPG sources (XMLTV, Schedules Direct), channels will be matched by their tvg_id within that source. Select 'No EPG' to disable EPG assignment."
|
||||
withArrow
|
||||
|
|
@ -1133,44 +1132,90 @@ const LiveGroupFilter = ({
|
|||
label="EPG Source"
|
||||
placeholder="No EPG (Disabled)"
|
||||
value={(() => {
|
||||
// Handle migration from force_dummy_epg
|
||||
// Show custom EPG if set
|
||||
if (
|
||||
group.custom_properties?.custom_epg_id !==
|
||||
undefined
|
||||
undefined &&
|
||||
group.custom_properties?.custom_epg_id !== null
|
||||
) {
|
||||
// Convert to string, use '0' for null/no EPG
|
||||
return group.custom_properties.custom_epg_id ===
|
||||
null
|
||||
? '0'
|
||||
: group.custom_properties.custom_epg_id.toString();
|
||||
} else if (
|
||||
group.custom_properties?.force_dummy_epg
|
||||
) {
|
||||
// Show "No EPG" for old force_dummy_epg configs
|
||||
return group.custom_properties.custom_epg_id.toString();
|
||||
}
|
||||
// Show "No EPG" if force_dummy_epg is set
|
||||
if (group.custom_properties?.force_dummy_epg) {
|
||||
return '0';
|
||||
}
|
||||
return '0';
|
||||
// Otherwise show empty/placeholder
|
||||
return null;
|
||||
})()}
|
||||
onChange={(value) => {
|
||||
// Convert back: '0' means no EPG (null)
|
||||
const newValue =
|
||||
value === '0' ? null : parseInt(value);
|
||||
setGroupStates(
|
||||
groupStates.map((state) => {
|
||||
if (
|
||||
state.channel_group === group.channel_group
|
||||
) {
|
||||
return {
|
||||
...state,
|
||||
custom_properties: {
|
||||
if (value === '0') {
|
||||
// "No EPG (Disabled)" selected - use force_dummy_epg
|
||||
setGroupStates(
|
||||
groupStates.map((state) => {
|
||||
if (
|
||||
state.channel_group ===
|
||||
group.channel_group
|
||||
) {
|
||||
const newProps = {
|
||||
...state.custom_properties,
|
||||
custom_epg_id: newValue,
|
||||
},
|
||||
};
|
||||
}
|
||||
return state;
|
||||
})
|
||||
);
|
||||
};
|
||||
delete newProps.custom_epg_id;
|
||||
delete newProps.force_epg_selected;
|
||||
newProps.force_dummy_epg = true;
|
||||
return {
|
||||
...state,
|
||||
custom_properties: newProps,
|
||||
};
|
||||
}
|
||||
return state;
|
||||
})
|
||||
);
|
||||
} else if (value) {
|
||||
// Specific EPG source selected
|
||||
const epgId = parseInt(value);
|
||||
setGroupStates(
|
||||
groupStates.map((state) => {
|
||||
if (
|
||||
state.channel_group ===
|
||||
group.channel_group
|
||||
) {
|
||||
const newProps = {
|
||||
...state.custom_properties,
|
||||
};
|
||||
newProps.custom_epg_id = epgId;
|
||||
delete newProps.force_dummy_epg;
|
||||
delete newProps.force_epg_selected;
|
||||
return {
|
||||
...state,
|
||||
custom_properties: newProps,
|
||||
};
|
||||
}
|
||||
return state;
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// Cleared - remove all EPG settings
|
||||
setGroupStates(
|
||||
groupStates.map((state) => {
|
||||
if (
|
||||
state.channel_group ===
|
||||
group.channel_group
|
||||
) {
|
||||
const newProps = {
|
||||
...state.custom_properties,
|
||||
};
|
||||
delete newProps.custom_epg_id;
|
||||
delete newProps.force_dummy_epg;
|
||||
delete newProps.force_epg_selected;
|
||||
return {
|
||||
...state,
|
||||
custom_properties: newProps,
|
||||
};
|
||||
}
|
||||
return state;
|
||||
})
|
||||
);
|
||||
}
|
||||
}}
|
||||
data={[
|
||||
{ value: '0', label: 'No EPG (Disabled)' },
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ import {
|
|||
Select,
|
||||
NumberInput,
|
||||
Tooltip,
|
||||
Skeleton,
|
||||
} from '@mantine/core';
|
||||
import { getCoreRowModel, flexRender } from '@tanstack/react-table';
|
||||
import './table.css';
|
||||
|
|
@ -228,6 +229,7 @@ const ChannelsTable = ({ onReady }) => {
|
|||
// EPG data lookup
|
||||
const tvgsById = useEPGsStore((s) => s.tvgsById);
|
||||
const epgs = useEPGsStore((s) => s.epgs);
|
||||
const tvgsLoaded = useEPGsStore((s) => s.tvgsLoaded);
|
||||
const theme = useMantineTheme();
|
||||
const channelGroups = useChannelsStore((s) => s.channelGroups);
|
||||
const canEditChannelGroup = useChannelsStore((s) => s.canEditChannelGroup);
|
||||
|
|
@ -431,9 +433,9 @@ const ChannelsTable = ({ onReady }) => {
|
|||
});
|
||||
setAllRowIds(ids);
|
||||
|
||||
// Signal ready after first successful data fetch
|
||||
// EPG data is already loaded in initData before this component mounts
|
||||
if (!hasSignaledReady.current && onReady) {
|
||||
// Signal ready after first successful data fetch AND EPG data is loaded
|
||||
// This prevents the EPG column from showing "Not Assigned" while EPG data is still loading
|
||||
if (!hasSignaledReady.current && onReady && tvgsLoaded) {
|
||||
hasSignaledReady.current = true;
|
||||
onReady();
|
||||
}
|
||||
|
|
@ -445,6 +447,7 @@ const ChannelsTable = ({ onReady }) => {
|
|||
showDisabled,
|
||||
selectedProfileId,
|
||||
showOnlyStreamlessChannels,
|
||||
tvgsLoaded,
|
||||
]);
|
||||
|
||||
const stopPropagation = useCallback((e) => {
|
||||
|
|
@ -750,6 +753,19 @@ const ChannelsTable = ({ onReady }) => {
|
|||
setPaginationString(`${startItem} to ${endItem} of ${totalCount}`);
|
||||
}, [pagination.pageIndex, pagination.pageSize, totalCount]);
|
||||
|
||||
// Signal ready when EPG data finishes loading (if channels were already fetched)
|
||||
useEffect(() => {
|
||||
if (
|
||||
hasFetchedData.current &&
|
||||
!hasSignaledReady.current &&
|
||||
onReady &&
|
||||
tvgsLoaded
|
||||
) {
|
||||
hasSignaledReady.current = true;
|
||||
onReady();
|
||||
}
|
||||
}, [tvgsLoaded, onReady]);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
|
|
@ -834,6 +850,10 @@ const ChannelsTable = ({ onReady }) => {
|
|||
const tooltip = epgObj
|
||||
? `${epgName ? `EPG Name: ${epgName}\n` : ''}${tvgName ? `TVG Name: ${tvgName}\n` : ''}${tvgId ? `TVG-ID: ${tvgId}` : ''}`.trim()
|
||||
: '';
|
||||
|
||||
// If channel has an EPG assignment but tvgsById hasn't loaded yet, show loading
|
||||
const isEpgDataPending = epgDataId && !epgObj && !tvgsLoaded;
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
|
|
@ -856,6 +876,12 @@ const ChannelsTable = ({ onReady }) => {
|
|||
</Tooltip>
|
||||
) : epgObj ? (
|
||||
<span>{epgObj.name}</span>
|
||||
) : isEpgDataPending ? (
|
||||
<Skeleton
|
||||
height={14}
|
||||
width={(columnSizing.epg || 200) * 0.7}
|
||||
style={{ borderRadius: 4 }}
|
||||
/>
|
||||
) : (
|
||||
<span style={{ color: '#888' }}>Not Assigned</span>
|
||||
)}
|
||||
|
|
@ -935,7 +961,7 @@ const ChannelsTable = ({ onReady }) => {
|
|||
// Note: logos is intentionally excluded - LazyLogo components handle their own logo data
|
||||
// from the store, so we don't need to recreate columns when logos load.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[selectedProfileId, channelGroups, theme]
|
||||
[selectedProfileId, channelGroups, theme, tvgsById, epgs, tvgsLoaded]
|
||||
);
|
||||
|
||||
const renderHeaderCell = (header) => {
|
||||
|
|
@ -1380,12 +1406,13 @@ const ChannelsTable = ({ onReady }) => {
|
|||
|
||||
{/* Table or ghost empty state inside Paper */}
|
||||
<Box>
|
||||
{channelsTableLength === 0 && (
|
||||
<ChannelsTableOnboarding editChannel={editChannel} />
|
||||
)}
|
||||
{channelsTableLength === 0 &&
|
||||
Object.keys(channels).length === 0 && (
|
||||
<ChannelsTableOnboarding editChannel={editChannel} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{channelsTableLength > 0 && (
|
||||
{(channelsTableLength > 0 || Object.keys(channels).length > 0) && (
|
||||
<Box
|
||||
style={{
|
||||
display: 'flex',
|
||||
|
|
|
|||
|
|
@ -303,6 +303,7 @@ export const REGION_CHOICES = [
|
|||
{ value: 'tz', label: 'TZ' },
|
||||
{ value: 'ua', label: 'UA' },
|
||||
{ value: 'ug', label: 'UG' },
|
||||
{ value: 'uk', label: 'UK' },
|
||||
{ value: 'um', label: 'UM' },
|
||||
{ value: 'us', label: 'US' },
|
||||
{ value: 'uy', label: 'UY' },
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ const useEPGsStore = create((set) => ({
|
|||
epgs: {},
|
||||
tvgs: [],
|
||||
tvgsById: {},
|
||||
tvgsLoaded: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refreshProgress: {},
|
||||
|
|
@ -36,11 +37,16 @@ const useEPGsStore = create((set) => ({
|
|||
acc[tvg.id] = tvg;
|
||||
return acc;
|
||||
}, {}),
|
||||
tvgsLoaded: true,
|
||||
isLoading: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch tvgs:', error);
|
||||
set({ error: 'Failed to load tvgs.', isLoading: false });
|
||||
set({
|
||||
error: 'Failed to load tvgs.',
|
||||
tvgsLoaded: true,
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue