mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-25 02:57:57 +00:00
1167 lines
39 KiB
JavaScript
1167 lines
39 KiB
JavaScript
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
|
import useChannelsStore from '../../store/channels';
|
|
import useChannelsTableStore from '../../store/channelsTable.jsx';
|
|
import API from '../../api';
|
|
import useStreamProfilesStore from '../../store/streamProfiles';
|
|
import useEPGsStore from '../../store/epgs';
|
|
import ChannelGroupForm from './ChannelGroup';
|
|
import {
|
|
Box,
|
|
Button,
|
|
Modal,
|
|
TextInput,
|
|
Text,
|
|
Group,
|
|
ActionIcon,
|
|
Flex,
|
|
Select,
|
|
Stack,
|
|
useMantineTheme,
|
|
Popover,
|
|
ScrollArea,
|
|
Tooltip,
|
|
UnstyledButton,
|
|
Center,
|
|
Divider,
|
|
Checkbox,
|
|
Paper,
|
|
} from '@mantine/core';
|
|
import { ListOrdered, SquarePlus, SquareX, X } from 'lucide-react';
|
|
import { FixedSizeList as List } from 'react-window';
|
|
import { useForm } from '@mantine/form';
|
|
import { notifications } from '@mantine/notifications';
|
|
import { USER_LEVELS, USER_LEVEL_LABELS } from '../../constants';
|
|
import { useChannelLogoSelection } from '../../hooks/useSmartLogos';
|
|
import LazyLogo from '../LazyLogo';
|
|
import logo from '../../images/logo.png';
|
|
import ConfirmationDialog from '../ConfirmationDialog';
|
|
import useWarningsStore from '../../store/warnings';
|
|
|
|
const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => {
|
|
const theme = useMantineTheme();
|
|
|
|
const groupListRef = useRef(null);
|
|
const logoListRef = useRef(null);
|
|
|
|
const channelGroups = useChannelsStore((s) => s.channelGroups);
|
|
const {
|
|
logos: channelLogos,
|
|
ensureLogosLoaded,
|
|
isLoading: logosLoading,
|
|
} = useChannelLogoSelection();
|
|
|
|
useEffect(() => {
|
|
ensureLogosLoaded();
|
|
}, [ensureLogosLoaded]);
|
|
|
|
const streamProfiles = useStreamProfilesStore((s) => s.profiles);
|
|
const epgs = useEPGsStore((s) => s.epgs);
|
|
const tvgs = useEPGsStore((s) => s.tvgs);
|
|
const fetchEPGs = useEPGsStore((s) => s.fetchEPGs);
|
|
|
|
const [channelGroupModelOpen, setChannelGroupModalOpen] = useState(false);
|
|
const [selectedChannelGroup, setSelectedChannelGroup] = useState('-1');
|
|
const [selectedLogoId, setSelectedLogoId] = useState('-1');
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [regexFind, setRegexFind] = useState('');
|
|
const [regexReplace, setRegexReplace] = useState('');
|
|
const [selectedDummyEpgId, setSelectedDummyEpgId] = useState(null);
|
|
|
|
const [groupPopoverOpened, setGroupPopoverOpened] = useState(false);
|
|
const [groupFilter, setGroupFilter] = useState('');
|
|
const groupOptions = Object.values(channelGroups);
|
|
|
|
const [logoPopoverOpened, setLogoPopoverOpened] = useState(false);
|
|
const [logoFilter, setLogoFilter] = useState('');
|
|
// Confirmation dialog states
|
|
const [confirmSetNamesOpen, setConfirmSetNamesOpen] = useState(false);
|
|
const [confirmSetLogosOpen, setConfirmSetLogosOpen] = useState(false);
|
|
const [confirmSetTvgIdsOpen, setConfirmSetTvgIdsOpen] = useState(false);
|
|
const [confirmBatchUpdateOpen, setConfirmBatchUpdateOpen] = useState(false);
|
|
const [settingNames, setSettingNames] = useState(false);
|
|
const [settingLogos, setSettingLogos] = useState(false);
|
|
const [settingTvgIds, setSettingTvgIds] = useState(false);
|
|
const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
|
|
const suppressWarning = useWarningsStore((s) => s.suppressWarning);
|
|
|
|
// Fetch EPG sources when modal opens
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
fetchEPGs();
|
|
}
|
|
}, [isOpen, fetchEPGs]);
|
|
|
|
// Get dummy EPG sources
|
|
const dummyEpgSources = useMemo(() => {
|
|
return Object.values(epgs).filter((epg) => epg.source_type === 'dummy');
|
|
}, [epgs]);
|
|
|
|
const form = useForm({
|
|
mode: 'uncontrolled',
|
|
initialValues: {
|
|
channel_group: '(no change)',
|
|
logo: '(no change)',
|
|
stream_profile_id: '-1',
|
|
user_level: '-1',
|
|
is_adult: '-1',
|
|
},
|
|
});
|
|
|
|
// Build confirmation message based on selected changes
|
|
const getConfirmationMessage = () => {
|
|
const changes = [];
|
|
const values = form.getValues();
|
|
|
|
// Check for regex name changes
|
|
if (regexFind.trim().length > 0) {
|
|
changes.push(
|
|
`• Name Change: Apply regex find "${regexFind}" replace with "${regexReplace || ''}"`
|
|
);
|
|
}
|
|
|
|
// Check channel group
|
|
if (selectedChannelGroup && selectedChannelGroup !== '-1') {
|
|
const groupName = channelGroups[selectedChannelGroup]?.name || 'Unknown';
|
|
changes.push(`• Channel Group: ${groupName}`);
|
|
}
|
|
|
|
// Check logo
|
|
if (selectedLogoId && selectedLogoId !== '-1') {
|
|
if (selectedLogoId === '0') {
|
|
changes.push(`• Logo: Use Default`);
|
|
} else {
|
|
const logoName = channelLogos[selectedLogoId]?.name || 'Selected Logo';
|
|
changes.push(`• Logo: ${logoName}`);
|
|
}
|
|
}
|
|
|
|
// Check stream profile
|
|
if (values.stream_profile_id && values.stream_profile_id !== '-1') {
|
|
if (values.stream_profile_id === '0') {
|
|
changes.push(`• Stream Profile: Use Default`);
|
|
} else {
|
|
const profile = streamProfiles.find(
|
|
(p) => `${p.id}` === `${values.stream_profile_id}`
|
|
);
|
|
const profileName = profile?.name || 'Selected Profile';
|
|
changes.push(`• Stream Profile: ${profileName}`);
|
|
}
|
|
}
|
|
|
|
// Check user level
|
|
if (values.user_level && values.user_level !== '-1') {
|
|
const userLevelLabel =
|
|
USER_LEVEL_LABELS[values.user_level] || values.user_level;
|
|
changes.push(`• User Level: ${userLevelLabel}`);
|
|
}
|
|
|
|
// Check mature content flag
|
|
if (values.is_adult && values.is_adult !== '-1') {
|
|
changes.push(
|
|
`• Mature Content: ${values.is_adult === 'true' ? 'Yes' : 'No'}`
|
|
);
|
|
}
|
|
|
|
// Check dummy EPG
|
|
if (selectedDummyEpgId) {
|
|
if (selectedDummyEpgId === 'clear') {
|
|
changes.push(`• EPG: Clear Assignment (use default dummy)`);
|
|
} else {
|
|
const epgName = epgs[selectedDummyEpgId]?.name || 'Selected EPG';
|
|
changes.push(`• Dummy EPG: ${epgName}`);
|
|
}
|
|
}
|
|
|
|
return changes;
|
|
};
|
|
|
|
const handleSubmit = () => {
|
|
const changes = getConfirmationMessage();
|
|
|
|
// If no changes detected, show notification
|
|
if (changes.length === 0) {
|
|
notifications.show({
|
|
title: 'No Changes',
|
|
message: 'Please select at least one field to update.',
|
|
color: 'orange',
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Skip warning if suppressed
|
|
if (isWarningSuppressed('batch-update-channels')) {
|
|
return onSubmit();
|
|
}
|
|
|
|
setConfirmBatchUpdateOpen(true);
|
|
};
|
|
|
|
const onSubmit = async () => {
|
|
setConfirmBatchUpdateOpen(false);
|
|
setIsSubmitting(true);
|
|
|
|
const values = {
|
|
...form.getValues(),
|
|
}; // Handle channel group ID - convert to integer if it exists
|
|
if (selectedChannelGroup && selectedChannelGroup !== '-1') {
|
|
values.channel_group_id = parseInt(selectedChannelGroup);
|
|
} else {
|
|
delete values.channel_group_id;
|
|
}
|
|
|
|
if (selectedLogoId && selectedLogoId !== '-1') {
|
|
if (selectedLogoId === '0') {
|
|
values.logo_id = null;
|
|
} else {
|
|
values.logo_id = parseInt(selectedLogoId);
|
|
}
|
|
}
|
|
delete values.logo;
|
|
|
|
// Handle stream profile ID - convert special values
|
|
if (!values.stream_profile_id || values.stream_profile_id === '-1') {
|
|
delete values.stream_profile_id;
|
|
} else if (
|
|
values.stream_profile_id === '0' ||
|
|
values.stream_profile_id === 0
|
|
) {
|
|
values.stream_profile_id = null; // Convert "use default" to null
|
|
}
|
|
|
|
if (values.user_level == '-1') {
|
|
delete values.user_level;
|
|
}
|
|
|
|
if (values.is_adult === '-1') {
|
|
delete values.is_adult;
|
|
} else if (values.is_adult === 'true') {
|
|
values.is_adult = true;
|
|
} else if (values.is_adult === 'false') {
|
|
values.is_adult = false;
|
|
}
|
|
|
|
// Remove the channel_group field from form values as we use channel_group_id
|
|
delete values.channel_group;
|
|
|
|
try {
|
|
const applyRegex = regexFind.trim().length > 0;
|
|
|
|
// First, handle standard field updates (group, logo, profile, etc.)
|
|
if (Object.keys(values).length > 0) {
|
|
await API.updateChannels(channelIds, values);
|
|
}
|
|
|
|
// Then, handle name changes via server-side regex to avoid loading all channels client-side
|
|
if (applyRegex) {
|
|
// Default global replace; case-insensitive could be added later via UI if needed
|
|
const flags = 'g';
|
|
await API.bulkRegexRenameChannels(
|
|
channelIds,
|
|
regexFind,
|
|
regexReplace ?? '',
|
|
flags
|
|
);
|
|
}
|
|
|
|
// Then, handle EPG assignment if a dummy EPG was selected
|
|
if (selectedDummyEpgId) {
|
|
if (selectedDummyEpgId === 'clear') {
|
|
// Clear EPG assignments
|
|
const associations = channelIds.map((id) => ({
|
|
channel_id: id,
|
|
epg_data_id: null,
|
|
}));
|
|
await API.batchSetEPG(associations);
|
|
} else {
|
|
// Assign the selected dummy EPG
|
|
const selectedEpg = epgs[selectedDummyEpgId];
|
|
if (selectedEpg && selectedEpg.epg_data_count > 0) {
|
|
// Convert to number for comparison since Select returns string
|
|
const epgSourceId = parseInt(selectedDummyEpgId, 10);
|
|
|
|
// Check if we already have EPG data loaded in the store
|
|
let epgData = tvgs.find((data) => data.epg_source === epgSourceId);
|
|
|
|
// If not in store, fetch it
|
|
if (!epgData) {
|
|
const epgDataList = await API.getEPGData();
|
|
epgData = epgDataList.find(
|
|
(data) => data.epg_source === epgSourceId
|
|
);
|
|
}
|
|
|
|
if (epgData) {
|
|
const associations = channelIds.map((id) => ({
|
|
channel_id: id,
|
|
epg_data_id: epgData.id,
|
|
}));
|
|
await API.batchSetEPG(associations);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Refresh both the channels table data and the main channels store
|
|
await Promise.all([
|
|
API.requeryChannels(),
|
|
useChannelsStore.getState().fetchChannelIds(),
|
|
]);
|
|
onClose();
|
|
} catch (error) {
|
|
console.error('Failed to update channels:', error);
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleSetNamesFromEpg = async () => {
|
|
if (!channelIds || channelIds.length === 0) {
|
|
notifications.show({
|
|
title: 'No Channels Selected',
|
|
message: 'No channels to update.',
|
|
color: 'orange',
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Skip warning if suppressed
|
|
if (isWarningSuppressed('batch-set-names-from-epg')) {
|
|
return executeSetNamesFromEpg();
|
|
}
|
|
|
|
setConfirmSetNamesOpen(true);
|
|
};
|
|
|
|
const executeSetNamesFromEpg = async () => {
|
|
setSettingNames(true);
|
|
try {
|
|
// Start the backend task
|
|
await API.setChannelNamesFromEpg(channelIds);
|
|
|
|
// The task will send WebSocket updates for progress
|
|
// Just show that it started successfully
|
|
notifications.show({
|
|
title: 'Task Started',
|
|
message: `Started setting names from EPG for ${channelIds.length} channels. Progress will be shown in notifications.`,
|
|
color: 'blue',
|
|
});
|
|
|
|
// Close the modal since the task is now running in background
|
|
onClose();
|
|
} catch (error) {
|
|
console.error('Failed to start EPG name setting task:', error);
|
|
notifications.show({
|
|
title: 'Error',
|
|
message: 'Failed to start EPG name setting task.',
|
|
color: 'red',
|
|
});
|
|
} finally {
|
|
setSettingNames(false);
|
|
setConfirmSetNamesOpen(false);
|
|
}
|
|
};
|
|
|
|
const handleSetLogosFromEpg = async () => {
|
|
if (!channelIds || channelIds.length === 0) {
|
|
notifications.show({
|
|
title: 'No Channels Selected',
|
|
message: 'No channels to update.',
|
|
color: 'orange',
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Skip warning if suppressed
|
|
if (isWarningSuppressed('batch-set-logos-from-epg')) {
|
|
return executeSetLogosFromEpg();
|
|
}
|
|
|
|
setConfirmSetLogosOpen(true);
|
|
};
|
|
|
|
const executeSetLogosFromEpg = async () => {
|
|
setSettingLogos(true);
|
|
try {
|
|
// Start the backend task
|
|
await API.setChannelLogosFromEpg(channelIds);
|
|
|
|
// The task will send WebSocket updates for progress
|
|
// Just show that it started successfully
|
|
notifications.show({
|
|
title: 'Task Started',
|
|
message: `Started setting logos from EPG for ${channelIds.length} channels. Progress will be shown in notifications.`,
|
|
color: 'blue',
|
|
});
|
|
|
|
// Close the modal since the task is now running in background
|
|
onClose();
|
|
} catch (error) {
|
|
console.error('Failed to start EPG logo setting task:', error);
|
|
notifications.show({
|
|
title: 'Error',
|
|
message: 'Failed to start EPG logo setting task.',
|
|
color: 'red',
|
|
});
|
|
} finally {
|
|
setSettingLogos(false);
|
|
setConfirmSetLogosOpen(false);
|
|
}
|
|
};
|
|
|
|
const handleSetTvgIdsFromEpg = async () => {
|
|
if (!channelIds || channelIds.length === 0) {
|
|
notifications.show({
|
|
title: 'No Channels Selected',
|
|
message: 'No channels to update.',
|
|
color: 'orange',
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Skip warning if suppressed
|
|
if (isWarningSuppressed('batch-set-tvg-ids-from-epg')) {
|
|
return executeSetTvgIdsFromEpg();
|
|
}
|
|
|
|
setConfirmSetTvgIdsOpen(true);
|
|
};
|
|
|
|
const executeSetTvgIdsFromEpg = async () => {
|
|
setSettingTvgIds(true);
|
|
try {
|
|
// Start the backend task
|
|
await API.setChannelTvgIdsFromEpg(channelIds);
|
|
|
|
// The task will send WebSocket updates for progress
|
|
// Just show that it started successfully
|
|
notifications.show({
|
|
title: 'Task Started',
|
|
message: `Started setting TVG-IDs from EPG for ${channelIds.length} channels. Progress will be shown in notifications.`,
|
|
color: 'blue',
|
|
});
|
|
|
|
// Close the modal since the task is now running in background
|
|
onClose();
|
|
} catch (error) {
|
|
console.error('Failed to start EPG TVG-ID setting task:', error);
|
|
notifications.show({
|
|
title: 'Error',
|
|
message: 'Failed to start EPG TVG-ID setting task.',
|
|
color: 'red',
|
|
});
|
|
} finally {
|
|
setSettingTvgIds(false);
|
|
setConfirmSetTvgIdsOpen(false);
|
|
}
|
|
};
|
|
|
|
// useEffect(() => {
|
|
// // const sameStreamProfile = channels.every(
|
|
// // (channel) => channel.stream_profile_id == channels[0].stream_profile_id
|
|
// // );
|
|
// // const sameChannelGroup = channels.every(
|
|
// // (channel) => channel.channel_group_id == channels[0].channel_group_id
|
|
// // );
|
|
// // const sameUserLevel = channels.every(
|
|
// // (channel) => channel.user_level == channels[0].user_level
|
|
// // );
|
|
// // form.setValues({
|
|
// // ...(sameStreamProfile && {
|
|
// // stream_profile_id: `${channels[0].stream_profile_id}`,
|
|
// // }),
|
|
// // ...(sameChannelGroup && {
|
|
// // channel_group_id: `${channels[0].channel_group_id}`,
|
|
// // }),
|
|
// // ...(sameUserLevel && {
|
|
// // user_level: `${channels[0].user_level}`,
|
|
// // }),
|
|
// // });
|
|
// }, [channelIds, streamProfiles, channelGroups]);
|
|
|
|
const handleChannelGroupModalClose = (newGroup) => {
|
|
setChannelGroupModalOpen(false);
|
|
|
|
if (newGroup && newGroup.id) {
|
|
setSelectedChannelGroup(newGroup.id);
|
|
form.setValues({
|
|
channel_group: `${newGroup.name}`,
|
|
});
|
|
}
|
|
};
|
|
const filteredGroups = [
|
|
{ id: '-1', name: '(no change)' },
|
|
...groupOptions.filter((group) =>
|
|
group.name.toLowerCase().includes(groupFilter.toLowerCase())
|
|
),
|
|
];
|
|
|
|
const logoOptions = useMemo(() => {
|
|
return [
|
|
{ id: '-1', name: '(no change)' },
|
|
{ id: '0', name: 'Use Default', isDefault: true },
|
|
...Object.values(channelLogos),
|
|
];
|
|
}, [channelLogos]);
|
|
|
|
const filteredLogos = logoOptions.filter((logo) =>
|
|
logo.name.toLowerCase().includes(logoFilter.toLowerCase())
|
|
);
|
|
|
|
if (!isOpen) {
|
|
return <></>;
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Modal
|
|
opened={isOpen}
|
|
onClose={onClose}
|
|
size={'lg'}
|
|
title={
|
|
<Group gap="5">
|
|
<ListOrdered size="20" />
|
|
<Text>Channels</Text>
|
|
</Group>
|
|
}
|
|
styles={{ hannontent: { '--mantine-color-body': '#27272A' } }}
|
|
>
|
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
|
<Group justify="space-between" align="top">
|
|
<Stack gap="5" style={{ flex: 1 }}>
|
|
<Paper withBorder p="xs" radius="md">
|
|
<Group justify="space-between" align="center" mb={6}>
|
|
<Text size="sm" fw={600}>
|
|
Channel Name
|
|
</Text>
|
|
</Group>
|
|
<Group align="end" gap="xs" wrap="nowrap">
|
|
<TextInput
|
|
size="xs"
|
|
label="Find (Regex)"
|
|
placeholder="e.g. ^(.*) HD$"
|
|
value={regexFind}
|
|
onChange={(e) => setRegexFind(e.currentTarget.value)}
|
|
style={{ flex: 1 }}
|
|
/>
|
|
<TextInput
|
|
size="xs"
|
|
label="Replace"
|
|
placeholder="e.g. $1"
|
|
value={regexReplace}
|
|
onChange={(e) => setRegexReplace(e.currentTarget.value)}
|
|
style={{ flex: 1 }}
|
|
/>
|
|
</Group>
|
|
<RegexPreview
|
|
channelIds={channelIds}
|
|
find={regexFind}
|
|
replace={regexReplace}
|
|
/>
|
|
</Paper>
|
|
|
|
<Paper withBorder p="xs" radius="md">
|
|
<Group justify="space-between" align="center" mb={6}>
|
|
<Text size="sm" fw={600}>
|
|
EPG Operations
|
|
</Text>
|
|
</Group>
|
|
<Group gap="xs" wrap="nowrap">
|
|
<Button
|
|
size="xs"
|
|
variant="light"
|
|
onClick={handleSetNamesFromEpg}
|
|
style={{ flex: 1 }}
|
|
>
|
|
Set Names from EPG
|
|
</Button>
|
|
<Button
|
|
size="xs"
|
|
variant="light"
|
|
onClick={handleSetLogosFromEpg}
|
|
style={{ flex: 1 }}
|
|
>
|
|
Set Logos from EPG
|
|
</Button>
|
|
<Button
|
|
size="xs"
|
|
variant="light"
|
|
onClick={handleSetTvgIdsFromEpg}
|
|
style={{ flex: 1 }}
|
|
>
|
|
Set TVG-IDs from EPG
|
|
</Button>
|
|
</Group>
|
|
<Divider my="xs" />
|
|
<Stack gap="xs">
|
|
<Text size="xs" fw={600}>
|
|
Assign Dummy EPG
|
|
</Text>
|
|
<Select
|
|
size="xs"
|
|
placeholder="Select a dummy EPG..."
|
|
data={[
|
|
{ value: 'clear', label: '(Clear EPG Assignment)' },
|
|
...dummyEpgSources.map((epg) => ({
|
|
value: String(epg.id),
|
|
label: epg.name,
|
|
})),
|
|
]}
|
|
value={selectedDummyEpgId}
|
|
onChange={setSelectedDummyEpgId}
|
|
clearable
|
|
/>
|
|
</Stack>
|
|
<Text size="xs" c="dimmed" mt="xs">
|
|
Updates channel names, logos, and TVG-IDs based on their
|
|
assigned EPG data, or assign a custom dummy EPG to selected
|
|
channels
|
|
</Text>
|
|
</Paper>
|
|
|
|
<Popover
|
|
opened={groupPopoverOpened}
|
|
onChange={setGroupPopoverOpened}
|
|
// position="bottom-start"
|
|
withArrow
|
|
>
|
|
<Popover.Target>
|
|
<Group style={{ width: '100%' }} align="flex-end">
|
|
<TextInput
|
|
id="channel_group"
|
|
name="channel_group"
|
|
label="Channel Group"
|
|
readOnly
|
|
{...form.getInputProps('channel_group')}
|
|
key={form.key('channel_group')}
|
|
onClick={() => setGroupPopoverOpened(true)}
|
|
size="xs"
|
|
style={{ flex: 1 }}
|
|
rightSection={
|
|
form.getValues().channel_group &&
|
|
form.getValues().channel_group !== '(no change)' && (
|
|
<ActionIcon
|
|
size="xs"
|
|
variant="subtle"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setSelectedChannelGroup('-1');
|
|
form.setValues({ channel_group: '(no change)' });
|
|
}}
|
|
>
|
|
<X size={12} />
|
|
</ActionIcon>
|
|
)
|
|
}
|
|
/>
|
|
|
|
<ActionIcon
|
|
color={theme.tailwind.green[5]}
|
|
onClick={() => setChannelGroupModalOpen(true)}
|
|
title="Create new group"
|
|
size="small"
|
|
variant="transparent"
|
|
style={{ marginBottom: 5 }}
|
|
>
|
|
<SquarePlus size="20" />
|
|
</ActionIcon>
|
|
</Group>
|
|
</Popover.Target>
|
|
|
|
<Popover.Dropdown onMouseDown={(e) => e.stopPropagation()}>
|
|
<Group style={{ width: '100%' }} spacing="xs">
|
|
<TextInput
|
|
placeholder="Filter"
|
|
value={groupFilter}
|
|
onChange={(event) =>
|
|
setGroupFilter(event.currentTarget.value)
|
|
}
|
|
mb="xs"
|
|
size="xs"
|
|
style={{ flex: 1 }}
|
|
/>
|
|
|
|
<ActionIcon
|
|
color={theme.tailwind.green[5]}
|
|
onClick={() => setChannelGroupModalOpen(true)}
|
|
title="Create new group"
|
|
size="small"
|
|
variant="transparent"
|
|
style={{ marginBottom: 5 }}
|
|
>
|
|
<SquarePlus size="20" />
|
|
</ActionIcon>
|
|
</Group>
|
|
|
|
<ScrollArea style={{ height: 200 }}>
|
|
<List
|
|
height={200} // Set max height for visible items
|
|
itemCount={filteredGroups.length}
|
|
itemSize={20} // Adjust row height for each item
|
|
width={200}
|
|
ref={groupListRef}
|
|
>
|
|
{({ index, style }) => (
|
|
<Box
|
|
style={{ ...style, height: 20, overflow: 'hidden' }}
|
|
>
|
|
<Tooltip
|
|
openDelay={500}
|
|
label={filteredGroups[index].name}
|
|
size="xs"
|
|
>
|
|
<UnstyledButton
|
|
onClick={() => {
|
|
setSelectedChannelGroup(
|
|
filteredGroups[index].id
|
|
);
|
|
form.setValues({
|
|
channel_group: filteredGroups[index].name,
|
|
});
|
|
setGroupPopoverOpened(false);
|
|
}}
|
|
>
|
|
<Text
|
|
size="xs"
|
|
style={{
|
|
whiteSpace: 'nowrap',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
}}
|
|
>
|
|
{filteredGroups[index].name}
|
|
</Text>
|
|
</UnstyledButton>
|
|
</Tooltip>
|
|
</Box>
|
|
)}
|
|
</List>
|
|
</ScrollArea>
|
|
</Popover.Dropdown>
|
|
</Popover>
|
|
|
|
<Group style={{ width: '100%' }} align="flex-end" gap="xs">
|
|
<Popover
|
|
opened={logoPopoverOpened}
|
|
onChange={setLogoPopoverOpened}
|
|
withArrow
|
|
>
|
|
<Popover.Target>
|
|
<TextInput
|
|
label="Logo"
|
|
readOnly
|
|
{...form.getInputProps('logo')}
|
|
key={form.key('logo')}
|
|
onClick={() => setLogoPopoverOpened(true)}
|
|
size="xs"
|
|
style={{ flex: 1 }}
|
|
rightSection={
|
|
selectedLogoId !== '-1' && (
|
|
<ActionIcon
|
|
size="xs"
|
|
variant="subtle"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setSelectedLogoId('-1');
|
|
form.setValues({ logo: '(no change)' });
|
|
}}
|
|
>
|
|
<X size={12} />
|
|
</ActionIcon>
|
|
)
|
|
}
|
|
/>
|
|
</Popover.Target>
|
|
<Popover.Dropdown onMouseDown={(e) => e.stopPropagation()}>
|
|
<Group>
|
|
<TextInput
|
|
placeholder="Filter"
|
|
value={logoFilter}
|
|
onChange={(event) =>
|
|
setLogoFilter(event.currentTarget.value)
|
|
}
|
|
mb="xs"
|
|
size="xs"
|
|
/>
|
|
{logosLoading && (
|
|
<Text size="xs" c="dimmed">
|
|
Loading...
|
|
</Text>
|
|
)}
|
|
</Group>
|
|
<ScrollArea style={{ height: 200 }}>
|
|
{filteredLogos.length === 0 ? (
|
|
<Center style={{ height: 200 }}>
|
|
<Text size="sm" c="dimmed">
|
|
{logoFilter
|
|
? 'No logos match your filter'
|
|
: 'No logos available'}
|
|
</Text>
|
|
</Center>
|
|
) : (
|
|
<List
|
|
height={200}
|
|
itemCount={filteredLogos.length}
|
|
itemSize={55}
|
|
style={{ width: '100%' }}
|
|
ref={logoListRef}
|
|
>
|
|
{({ index, style }) => {
|
|
const item = filteredLogos[index];
|
|
return (
|
|
<div
|
|
style={{
|
|
...style,
|
|
cursor: 'pointer',
|
|
padding: '5px',
|
|
borderRadius: '4px',
|
|
}}
|
|
onClick={() => {
|
|
setSelectedLogoId(item.id);
|
|
form.setValues({
|
|
logo: item.name,
|
|
});
|
|
setLogoPopoverOpened(false);
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.backgroundColor =
|
|
'rgb(68, 68, 68)';
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.backgroundColor =
|
|
'transparent';
|
|
}}
|
|
>
|
|
<Center
|
|
style={{
|
|
flexDirection: 'column',
|
|
gap: '2px',
|
|
}}
|
|
>
|
|
{item.isDefault ? (
|
|
<img
|
|
src={logo}
|
|
height="30"
|
|
style={{
|
|
maxWidth: 80,
|
|
objectFit: 'contain',
|
|
}}
|
|
alt="Default Logo"
|
|
/>
|
|
) : item.id > 0 ? (
|
|
<img
|
|
src={item.cache_url || logo}
|
|
height="30"
|
|
style={{
|
|
maxWidth: 80,
|
|
objectFit: 'contain',
|
|
}}
|
|
alt={item.name || 'Logo'}
|
|
onError={(e) => {
|
|
if (e.target.src !== logo) {
|
|
e.target.src = logo;
|
|
}
|
|
}}
|
|
/>
|
|
) : (
|
|
<Box h={30} />
|
|
)}
|
|
<Text
|
|
size="xs"
|
|
c="dimmed"
|
|
ta="center"
|
|
style={{
|
|
maxWidth: 80,
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
}}
|
|
>
|
|
{item.name}
|
|
</Text>
|
|
</Center>
|
|
</div>
|
|
);
|
|
}}
|
|
</List>
|
|
)}
|
|
</ScrollArea>
|
|
</Popover.Dropdown>
|
|
</Popover>
|
|
{selectedLogoId > 0 && (
|
|
<LazyLogo
|
|
logoId={selectedLogoId}
|
|
alt="channel logo"
|
|
style={{ height: 24, marginBottom: 5 }}
|
|
/>
|
|
)}
|
|
</Group>
|
|
|
|
<Select
|
|
id="stream_profile_id"
|
|
label="Stream Profile"
|
|
name="stream_profile_id"
|
|
{...form.getInputProps('stream_profile_id')}
|
|
key={form.key('stream_profile_id')}
|
|
data={[
|
|
{ value: '-1', label: '(no change)' },
|
|
{ value: '0', label: '(use default)' },
|
|
].concat(
|
|
streamProfiles.map((option) => ({
|
|
value: `${option.id}`,
|
|
label: option.name,
|
|
}))
|
|
)}
|
|
size="xs"
|
|
/>
|
|
|
|
<Select
|
|
size="xs"
|
|
label="User Level Access"
|
|
{...form.getInputProps('user_level')}
|
|
key={form.key('user_level')}
|
|
data={[
|
|
{
|
|
value: '-1',
|
|
label: '(no change)',
|
|
},
|
|
].concat(
|
|
Object.entries(USER_LEVELS).map(([, value]) => {
|
|
return {
|
|
label: USER_LEVEL_LABELS[value],
|
|
value: `${value}`,
|
|
};
|
|
})
|
|
)}
|
|
/>
|
|
|
|
<Select
|
|
size="xs"
|
|
label="Mature Content"
|
|
{...form.getInputProps('is_adult')}
|
|
key={form.key('is_adult')}
|
|
data={[
|
|
{ value: '-1', label: '(no change)' },
|
|
{ value: 'true', label: 'Yes' },
|
|
{ value: 'false', label: 'No' },
|
|
]}
|
|
/>
|
|
</Stack>
|
|
</Group>
|
|
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
|
<Button
|
|
type="submit"
|
|
variant="default"
|
|
disabled={isSubmitting}
|
|
loading={isSubmitting}
|
|
loaderProps={{ type: 'dots' }}
|
|
>
|
|
{isSubmitting ? 'Saving...' : 'Submit'}
|
|
</Button>
|
|
</Flex>
|
|
</form>
|
|
</Modal>
|
|
|
|
<ChannelGroupForm
|
|
isOpen={channelGroupModelOpen}
|
|
onClose={handleChannelGroupModalClose}
|
|
/>
|
|
|
|
<ConfirmationDialog
|
|
opened={confirmSetNamesOpen}
|
|
onClose={() => setConfirmSetNamesOpen(false)}
|
|
onConfirm={executeSetNamesFromEpg}
|
|
loading={settingNames}
|
|
title="Confirm Set Names from EPG"
|
|
message={
|
|
<div style={{ whiteSpace: 'pre-line' }}>
|
|
{`Are you sure you want to set names from EPG for ${channelIds?.length || 0} selected channels?
|
|
|
|
This will replace the current channel names with the names from their assigned EPG data.
|
|
|
|
This action cannot be undone.`}
|
|
</div>
|
|
}
|
|
confirmLabel="Set Names"
|
|
cancelLabel="Cancel"
|
|
actionKey="batch-set-names-from-epg"
|
|
onSuppressChange={suppressWarning}
|
|
size="md"
|
|
/>
|
|
|
|
<ConfirmationDialog
|
|
opened={confirmSetLogosOpen}
|
|
onClose={() => setConfirmSetLogosOpen(false)}
|
|
onConfirm={executeSetLogosFromEpg}
|
|
loading={settingLogos}
|
|
title="Confirm Set Logos from EPG"
|
|
message={
|
|
<div style={{ whiteSpace: 'pre-line' }}>
|
|
{`Are you sure you want to set logos from EPG for ${channelIds?.length || 0} selected channels?
|
|
|
|
This will replace the current channel logos with logos from their assigned EPG data. New logos will be created if needed.
|
|
|
|
This action cannot be undone.`}
|
|
</div>
|
|
}
|
|
confirmLabel="Set Logos"
|
|
cancelLabel="Cancel"
|
|
actionKey="batch-set-logos-from-epg"
|
|
onSuppressChange={suppressWarning}
|
|
size="md"
|
|
/>
|
|
|
|
<ConfirmationDialog
|
|
opened={confirmSetTvgIdsOpen}
|
|
onClose={() => setConfirmSetTvgIdsOpen(false)}
|
|
onConfirm={executeSetTvgIdsFromEpg}
|
|
loading={settingTvgIds}
|
|
title="Confirm Set TVG-IDs from EPG"
|
|
message={
|
|
<div style={{ whiteSpace: 'pre-line' }}>
|
|
{`Are you sure you want to set TVG-IDs from EPG for ${channelIds?.length || 0} selected channels?
|
|
|
|
This will replace the current TVG-IDs with the TVG-IDs from their assigned EPG data.
|
|
|
|
This action cannot be undone.`}
|
|
</div>
|
|
}
|
|
confirmLabel="Set TVG-IDs"
|
|
cancelLabel="Cancel"
|
|
actionKey="batch-set-tvg-ids-from-epg"
|
|
onSuppressChange={suppressWarning}
|
|
size="md"
|
|
/>
|
|
|
|
<ConfirmationDialog
|
|
opened={confirmBatchUpdateOpen}
|
|
onClose={() => setConfirmBatchUpdateOpen(false)}
|
|
onConfirm={onSubmit}
|
|
loading={isSubmitting}
|
|
title="Confirm Batch Update"
|
|
message={
|
|
<div>
|
|
<Text mb="md">
|
|
You are about to apply the following changes to{' '}
|
|
<strong>{channelIds?.length || 0}</strong> selected channel
|
|
{(channelIds?.length || 0) !== 1 ? 's' : ''}:
|
|
</Text>
|
|
<Paper
|
|
withBorder
|
|
p="sm"
|
|
style={{ backgroundColor: 'rgba(0, 0, 0, 0.2)' }}
|
|
>
|
|
<Stack gap="xs">
|
|
{getConfirmationMessage().map((change, index) => (
|
|
<Text
|
|
key={index}
|
|
size="sm"
|
|
style={{ fontFamily: 'monospace' }}
|
|
>
|
|
{change}
|
|
</Text>
|
|
))}
|
|
</Stack>
|
|
</Paper>
|
|
<Text mt="md" size="sm" c="dimmed">
|
|
This action cannot be undone.
|
|
</Text>
|
|
</div>
|
|
}
|
|
confirmLabel="Apply Changes"
|
|
cancelLabel="Cancel"
|
|
actionKey="batch-update-channels"
|
|
onSuppressChange={suppressWarning}
|
|
size="md"
|
|
/>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default ChannelBatchForm;
|
|
|
|
// Lightweight inline preview component to visualize rename results for a subset
|
|
const RegexPreview = ({ channelIds, find, replace }) => {
|
|
// Use only current page data from the channels table for preview
|
|
const pageChannels = useChannelsTableStore((s) => s.channels);
|
|
const nameById = useMemo(() => {
|
|
const map = {};
|
|
if (Array.isArray(pageChannels)) {
|
|
for (const ch of pageChannels) {
|
|
if (ch?.id != null) map[ch.id] = ch.name || '';
|
|
}
|
|
}
|
|
return map;
|
|
}, [pageChannels]);
|
|
const previewItems = useMemo(() => {
|
|
const items = [];
|
|
if (!find) return items;
|
|
let flags = 'g';
|
|
let re;
|
|
try {
|
|
re = new RegExp(find, flags);
|
|
} catch (error) {
|
|
console.error('Invalid regex:', error);
|
|
return [{ before: 'Invalid regex', after: '' }];
|
|
}
|
|
// Limit preview to items that exist on the current page
|
|
const pageOnlyIds = channelIds.filter((id) => nameById[id] !== undefined);
|
|
for (let i = 0; i < Math.min(pageOnlyIds.length, 25); i++) {
|
|
const id = pageOnlyIds[i];
|
|
const before = nameById[id] ?? '';
|
|
const after = before.replace(re, replace ?? '');
|
|
if (before !== after) {
|
|
items.push({ before, after });
|
|
}
|
|
}
|
|
return items;
|
|
}, [channelIds, nameById, find, replace]);
|
|
|
|
if (!find) return null;
|
|
|
|
return (
|
|
<Box mt={8}>
|
|
<Text size="xs" c="dimmed" mb={4}>
|
|
Preview shows matches from the current page only (up to 25).
|
|
</Text>
|
|
<ScrollArea h={120} offsetScrollbars>
|
|
<Stack gap={4}>
|
|
{previewItems.length === 0 ? (
|
|
<Text size="xs" c="dimmed">
|
|
No changes with current pattern.
|
|
</Text>
|
|
) : (
|
|
previewItems.map((row, idx) => (
|
|
<Group key={idx} gap={8} wrap="nowrap" align="center">
|
|
<Text
|
|
size="xs"
|
|
style={{
|
|
flex: 1,
|
|
whiteSpace: 'nowrap',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
}}
|
|
>
|
|
{row.before}
|
|
</Text>
|
|
<Text size="xs" c="gray.6">
|
|
→
|
|
</Text>
|
|
<Text
|
|
size="xs"
|
|
style={{
|
|
flex: 1,
|
|
whiteSpace: 'nowrap',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
}}
|
|
>
|
|
{row.after}
|
|
</Text>
|
|
</Group>
|
|
))
|
|
)}
|
|
</Stack>
|
|
</ScrollArea>
|
|
</Box>
|
|
);
|
|
};
|