mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-21 01:05:30 +00:00
1249 lines
43 KiB
JavaScript
1249 lines
43 KiB
JavaScript
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { useForm } from 'react-hook-form';
|
|
import { yupResolver } from '@hookform/resolvers/yup';
|
|
import * as Yup from 'yup';
|
|
import useChannelsStore from '../../store/channels';
|
|
import useStreamProfilesStore from '../../store/streamProfiles';
|
|
import ChannelGroupForm from './ChannelGroup';
|
|
import logo from '../../images/logo.png';
|
|
import { useChannelLogoSelection } from '../../hooks/useSmartLogos';
|
|
import useLogosStore from '../../store/logos';
|
|
import LazyLogo from '../LazyLogo';
|
|
import LogoForm from './Logo';
|
|
import {
|
|
ActionIcon,
|
|
Box,
|
|
Button,
|
|
Center,
|
|
Divider,
|
|
Flex,
|
|
Group,
|
|
Modal,
|
|
NumberInput,
|
|
Popover,
|
|
PopoverDropdown,
|
|
PopoverTarget,
|
|
ScrollArea,
|
|
Select,
|
|
Stack,
|
|
Switch,
|
|
Text,
|
|
TextInput,
|
|
Tooltip,
|
|
UnstyledButton,
|
|
useMantineTheme,
|
|
} from '@mantine/core';
|
|
import { ListOrdered, SquarePlus, Undo2, X, Zap } from 'lucide-react';
|
|
import useEPGsStore from '../../store/epgs';
|
|
import { FixedSizeList as List } from 'react-window';
|
|
import { USER_LEVEL_LABELS, USER_LEVELS } from '../../constants';
|
|
import {
|
|
showNotification,
|
|
updateNotification,
|
|
} from '../../utils/notificationUtils.js';
|
|
import {
|
|
addChannel,
|
|
clearChannelOverrides,
|
|
createLogo,
|
|
getChannelFormDefaultValues,
|
|
getFkProviderHint,
|
|
getFormattedValues,
|
|
getProviderFormValue,
|
|
getProviderHint,
|
|
handleEpgUpdate,
|
|
isFormFieldOverridden,
|
|
matchChannelEpg,
|
|
OVERRIDABLE_FIELDS,
|
|
OVERRIDE_FIELD_LABELS,
|
|
requeryChannels,
|
|
} from '../../utils/forms/ChannelUtils.js';
|
|
|
|
const validationSchema = Yup.object({
|
|
name: Yup.string().required('Name is required'),
|
|
channel_group_id: Yup.string().required('Channel group is required'),
|
|
});
|
|
|
|
// Provider hint plus a reset-to-provider icon for auto-synced
|
|
// channels; rendered as the field's `description` prop.
|
|
const ProviderHintRow = ({ channel, field, formValue, hintText, onReset }) => {
|
|
if (!hintText) return null;
|
|
const overridden = isFormFieldOverridden(channel, field, formValue);
|
|
return (
|
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
|
<Text size="xs" c="dimmed" component="span">
|
|
{hintText}
|
|
</Text>
|
|
{overridden && (
|
|
<Tooltip label="Reset to provider value" withArrow>
|
|
<ActionIcon
|
|
size="xs"
|
|
variant="subtle"
|
|
color="orange"
|
|
onClick={onReset}
|
|
aria-label={`Reset ${field} to provider value`}
|
|
>
|
|
<Undo2 size={11} />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
)}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
const ChannelForm = ({ channel: channelProp = null, isOpen, onClose }) => {
|
|
const theme = useMantineTheme();
|
|
|
|
const listRef = useRef(null);
|
|
const logoListRef = useRef(null);
|
|
const groupListRef = useRef(null);
|
|
|
|
// Local copy so in-modal mutations (clear overrides, etc.) update the
|
|
// form immediately. Reset on each open from `channelProp`; mutated in
|
|
// place with API responses.
|
|
const [channel, setChannel] = useState(channelProp);
|
|
useEffect(() => {
|
|
setChannel(channelProp);
|
|
}, [channelProp]);
|
|
|
|
const channelGroups = useChannelsStore((s) => s.channelGroups);
|
|
|
|
const {
|
|
logos: channelLogos,
|
|
ensureLogosLoaded,
|
|
isLoading: logosLoading,
|
|
} = useChannelLogoSelection();
|
|
|
|
// Import the full logos store for duplicate checking
|
|
const allLogos = useLogosStore((s) => s.logos);
|
|
|
|
// Ensure logos are loaded when component mounts
|
|
useEffect(() => {
|
|
ensureLogosLoaded();
|
|
}, [ensureLogosLoaded]);
|
|
|
|
const streamProfiles = useStreamProfilesStore((s) => s.profiles);
|
|
const epgs = useEPGsStore((s) => s.epgs);
|
|
const tvgs = useEPGsStore((s) => s.tvgs);
|
|
const tvgsById = useEPGsStore((s) => s.tvgsById);
|
|
|
|
const [logoModalOpen, setLogoModalOpen] = useState(false);
|
|
const [channelStreams, setChannelStreams] = useState([]);
|
|
const [channelGroupModelOpen, setChannelGroupModalOpen] = useState(false);
|
|
const [epgPopoverOpened, setEpgPopoverOpened] = useState(false);
|
|
const [logoPopoverOpened, setLogoPopoverOpened] = useState(false);
|
|
const [selectedEPG, setSelectedEPG] = useState('');
|
|
const [tvgFilter, setTvgFilter] = useState('');
|
|
const [logoFilter, setLogoFilter] = useState('');
|
|
|
|
const [groupPopoverOpened, setGroupPopoverOpened] = useState(false);
|
|
const [groupFilter, setGroupFilter] = useState('');
|
|
const [autoMatchLoading, setAutoMatchLoading] = useState(false);
|
|
const groupOptions = Object.values(channelGroups);
|
|
|
|
const handleLogoSuccess = ({ logo }) => {
|
|
if (logo && logo.id) {
|
|
setValue('logo_id', logo.id);
|
|
ensureLogosLoaded(); // Refresh logos
|
|
}
|
|
setLogoModalOpen(false);
|
|
};
|
|
|
|
const handleAutoMatchEpg = async () => {
|
|
// Only attempt auto-match for existing channels (editing mode)
|
|
if (!channel || !channel.id) {
|
|
showNotification({
|
|
title: 'Info',
|
|
message: 'Auto-match is only available when editing existing channels.',
|
|
color: 'blue',
|
|
});
|
|
return;
|
|
}
|
|
|
|
setAutoMatchLoading(true);
|
|
try {
|
|
const response = await matchChannelEpg(channel);
|
|
|
|
if (response.matched) {
|
|
// Update the form with the new EPG data
|
|
if (response.channel && response.channel.epg_data_id) {
|
|
setValue('epg_data_id', response.channel.epg_data_id);
|
|
}
|
|
|
|
showNotification({
|
|
title: 'Success',
|
|
message: response.message,
|
|
color: 'green',
|
|
});
|
|
} else {
|
|
showNotification({
|
|
title: 'No Match Found',
|
|
message: response.message,
|
|
color: 'orange',
|
|
});
|
|
}
|
|
} catch (error) {
|
|
showNotification({
|
|
title: 'Error',
|
|
message: 'Failed to auto-match EPG data',
|
|
color: 'red',
|
|
});
|
|
console.error('Auto-match error:', error);
|
|
} finally {
|
|
setAutoMatchLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleSetNameFromEpg = () => {
|
|
const epgDataId = watch('epg_data_id');
|
|
if (!epgDataId) {
|
|
showNotification({
|
|
title: 'No EPG Selected',
|
|
message: 'Please select an EPG source first.',
|
|
color: 'orange',
|
|
});
|
|
return;
|
|
}
|
|
|
|
const tvg = tvgsById[epgDataId];
|
|
if (tvg && tvg.name) {
|
|
setValue('name', tvg.name);
|
|
showNotification({
|
|
title: 'Success',
|
|
message: `Channel name set to "${tvg.name}"`,
|
|
color: 'green',
|
|
});
|
|
} else {
|
|
showNotification({
|
|
title: 'No Name Available',
|
|
message: 'No name found in the selected EPG data.',
|
|
color: 'orange',
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleSetLogoFromEpg = async () => {
|
|
const epgDataId = watch('epg_data_id');
|
|
if (!epgDataId) {
|
|
showNotification({
|
|
title: 'No EPG Selected',
|
|
message: 'Please select an EPG source first.',
|
|
color: 'orange',
|
|
});
|
|
return;
|
|
}
|
|
|
|
const tvg = tvgsById[epgDataId];
|
|
if (!tvg || !tvg.icon_url) {
|
|
showNotification({
|
|
title: 'No EPG Icon',
|
|
message: 'EPG data does not have an icon URL.',
|
|
color: 'orange',
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Try to find a logo that matches the EPG icon URL - check ALL logos to avoid duplicates
|
|
const matchingLogo = Object.values(allLogos).find(
|
|
(logo) => logo.url === tvg.icon_url
|
|
);
|
|
|
|
if (matchingLogo) {
|
|
setValue('logo_id', matchingLogo.id);
|
|
showNotification({
|
|
title: 'Success',
|
|
message: `Logo set to "${matchingLogo.name}"`,
|
|
color: 'green',
|
|
});
|
|
} else {
|
|
// Logo doesn't exist - create it
|
|
showNotification({
|
|
id: 'creating-logo',
|
|
title: 'Creating Logo',
|
|
message: `Creating new logo from EPG icon URL...`,
|
|
loading: true,
|
|
});
|
|
|
|
try {
|
|
const newLogoData = {
|
|
name: tvg.name || `Logo for ${tvg.icon_url}`,
|
|
url: tvg.icon_url,
|
|
};
|
|
|
|
// Create logo by calling the Logo API directly
|
|
const newLogo = await createLogo(newLogoData);
|
|
|
|
setValue('logo_id', newLogo.id);
|
|
|
|
updateNotification({
|
|
id: 'creating-logo',
|
|
title: 'Success',
|
|
message: `Created and assigned new logo "${newLogo.name}"`,
|
|
loading: false,
|
|
color: 'green',
|
|
autoClose: 5000,
|
|
});
|
|
} catch (createError) {
|
|
updateNotification({
|
|
id: 'creating-logo',
|
|
title: 'Error',
|
|
message: 'Failed to create logo from EPG icon URL',
|
|
loading: false,
|
|
color: 'red',
|
|
autoClose: 5000,
|
|
});
|
|
throw createError;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
showNotification({
|
|
title: 'Error',
|
|
message: 'Failed to set logo from EPG data',
|
|
color: 'red',
|
|
});
|
|
console.error('Set logo from EPG error:', error);
|
|
}
|
|
};
|
|
|
|
const handleSetTvgIdFromEpg = () => {
|
|
const epgDataId = watch('epg_data_id');
|
|
if (!epgDataId) {
|
|
showNotification({
|
|
title: 'No EPG Selected',
|
|
message: 'Please select an EPG source first.',
|
|
color: 'orange',
|
|
});
|
|
return;
|
|
}
|
|
|
|
const tvg = tvgsById[epgDataId];
|
|
if (tvg && tvg.tvg_id) {
|
|
setValue('tvg_id', tvg.tvg_id);
|
|
showNotification({
|
|
title: 'Success',
|
|
message: `TVG-ID set to "${tvg.tvg_id}"`,
|
|
color: 'green',
|
|
});
|
|
} else {
|
|
showNotification({
|
|
title: 'No TVG-ID Available',
|
|
message: 'No TVG-ID found in the selected EPG data.',
|
|
color: 'orange',
|
|
});
|
|
}
|
|
};
|
|
|
|
const defaultValues = useMemo(
|
|
() => getChannelFormDefaultValues(channel, channelGroups),
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[channel]
|
|
);
|
|
|
|
const {
|
|
register,
|
|
handleSubmit,
|
|
setValue,
|
|
watch,
|
|
reset,
|
|
formState: { errors, isSubmitting },
|
|
} = useForm({
|
|
defaultValues,
|
|
resolver: yupResolver(validationSchema),
|
|
});
|
|
|
|
const clearOverrides = async () => {
|
|
if (!channel) return;
|
|
try {
|
|
const updated = await clearChannelOverrides(channel.id);
|
|
// Update local state first so the form reflects the cleared
|
|
// overrides immediately; the table-store refresh is best-effort.
|
|
if (updated && typeof updated === 'object') {
|
|
setChannel(updated);
|
|
}
|
|
requeryChannels();
|
|
showNotification({
|
|
title: 'Overrides Cleared',
|
|
message: 'Channel values now follow the provider.',
|
|
color: 'green',
|
|
});
|
|
} catch (error) {
|
|
showNotification({
|
|
title: 'Clear Failed',
|
|
message:
|
|
error?.body?.detail || error?.message || 'Could not clear overrides.',
|
|
color: 'red',
|
|
});
|
|
}
|
|
};
|
|
|
|
// Computed from live form values so per-field resets update the
|
|
// Clear-all button immediately, before submit.
|
|
const watchedFormValues = watch();
|
|
const overriddenFieldLabels = useMemo(() => {
|
|
if (!channel) return [];
|
|
return OVERRIDABLE_FIELDS.filter((field) =>
|
|
isFormFieldOverridden(channel, field, watchedFormValues[field])
|
|
).map((field) => OVERRIDE_FIELD_LABELS[field]);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [channel, JSON.stringify(watchedFormValues)]);
|
|
const hasAnyOverride = overriddenFieldLabels.length > 0;
|
|
|
|
const onSubmit = async (values) => {
|
|
let saveFailed = false;
|
|
try {
|
|
const formattedValues = getFormattedValues(values);
|
|
|
|
if (channel) {
|
|
await handleEpgUpdate(channel, values, formattedValues, channelStreams);
|
|
} else {
|
|
// New channel creation - use the standard method
|
|
await addChannel({
|
|
...formattedValues,
|
|
streams: channelStreams.map((stream) => stream.id),
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('Error saving channel:', error);
|
|
saveFailed = true;
|
|
showNotification({
|
|
title: 'Save Failed',
|
|
message:
|
|
error?.body?.detail || error?.message || 'Failed to save channel.',
|
|
color: 'red',
|
|
});
|
|
}
|
|
|
|
if (saveFailed) {
|
|
// Keep the form open with the user's edits intact so they can correct
|
|
// a validation error without retyping.
|
|
return;
|
|
}
|
|
|
|
showNotification({
|
|
title: 'Saved',
|
|
message: channel
|
|
? `Channel "${values.name}" updated.`
|
|
: `Channel "${values.name}" created.`,
|
|
color: 'green',
|
|
});
|
|
|
|
reset();
|
|
requeryChannels();
|
|
|
|
// Refresh channel profiles to update the membership information
|
|
useChannelsStore.getState().fetchChannelProfiles();
|
|
|
|
setTvgFilter('');
|
|
setLogoFilter('');
|
|
onClose();
|
|
};
|
|
|
|
useEffect(() => {
|
|
reset(defaultValues);
|
|
setChannelStreams(channel?.streams || []);
|
|
|
|
if (channel?.epg_data_id) {
|
|
const epgSource = epgs[tvgsById[channel.epg_data_id]?.epg_source];
|
|
setSelectedEPG(epgSource ? `${epgSource.id}` : '');
|
|
} else {
|
|
setSelectedEPG('');
|
|
}
|
|
|
|
if (!channel) {
|
|
setTvgFilter('');
|
|
setLogoFilter('');
|
|
}
|
|
}, [defaultValues, channel, reset, epgs, tvgsById]);
|
|
|
|
// Memoize logo options to prevent infinite re-renders during background loading
|
|
const logoOptions = useMemo(() => {
|
|
const options = [{ id: '0', name: 'Default' }].concat(
|
|
Object.values(channelLogos)
|
|
);
|
|
return options;
|
|
}, [channelLogos]); // Only depend on channelLogos object
|
|
|
|
// Update the handler for when channel group modal is closed
|
|
const handleChannelGroupModalClose = (newGroup) => {
|
|
setChannelGroupModalOpen(false);
|
|
|
|
// 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
|
|
setValue('channel_group_id', `${newGroup.id}`);
|
|
}
|
|
};
|
|
|
|
if (!isOpen) {
|
|
return <></>;
|
|
}
|
|
|
|
const filteredTvgs = tvgs
|
|
.filter((tvg) => tvg.epg_source == selectedEPG)
|
|
.filter(
|
|
(tvg) =>
|
|
tvg.name.toLowerCase().includes(tvgFilter.toLowerCase()) ||
|
|
tvg.tvg_id.toLowerCase().includes(tvgFilter.toLowerCase())
|
|
);
|
|
|
|
const filteredLogos = logoOptions.filter((logo) =>
|
|
logo.name.toLowerCase().includes(logoFilter.toLowerCase())
|
|
);
|
|
|
|
const filteredGroups = groupOptions.filter((group) =>
|
|
group.name.toLowerCase().includes(groupFilter.toLowerCase())
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<Modal
|
|
opened={isOpen}
|
|
onClose={onClose}
|
|
size={1000}
|
|
title={
|
|
<Group gap="5">
|
|
<ListOrdered size="20" />
|
|
<Text>Channels</Text>
|
|
</Group>
|
|
}
|
|
styles={{ content: { '--mantine-color-body': '#27272A' } }}
|
|
>
|
|
<form onSubmit={handleSubmit(onSubmit)}>
|
|
{channel?.auto_created && channel?.source_stream && (
|
|
<Text size="xs" c="dimmed" mb="xs">
|
|
Auto-created from:{' '}
|
|
<Text component="span" fw={500} c="gray.3">
|
|
{channel.source_stream.account_name || 'Unknown provider'}
|
|
</Text>
|
|
{channel.source_stream.name
|
|
? ` / ${channel.source_stream.name}`
|
|
: ''}
|
|
</Text>
|
|
)}
|
|
<Group justify="space-between" align="top">
|
|
<Stack gap="5" style={{ flex: 1 }}>
|
|
<TextInput
|
|
id="name"
|
|
name="name"
|
|
label={
|
|
<Group gap="xs">
|
|
<span>Channel Name</span>
|
|
{watch('epg_data_id') && (
|
|
<Button
|
|
size="xs"
|
|
variant="transparent"
|
|
onClick={handleSetNameFromEpg}
|
|
title="Set channel name from EPG data"
|
|
p={0}
|
|
h="auto"
|
|
>
|
|
Use EPG Name
|
|
</Button>
|
|
)}
|
|
</Group>
|
|
}
|
|
description={
|
|
<ProviderHintRow
|
|
channel={channel}
|
|
field="name"
|
|
formValue={watch('name')}
|
|
hintText={getProviderHint(channel, 'name')}
|
|
onReset={() =>
|
|
setValue('name', getProviderFormValue(channel, 'name'), {
|
|
shouldDirty: true,
|
|
})
|
|
}
|
|
/>
|
|
}
|
|
{...register('name')}
|
|
error={errors.name?.message}
|
|
size="xs"
|
|
style={{ flex: 1 }}
|
|
/>
|
|
|
|
<Flex gap="sm">
|
|
<Popover
|
|
opened={groupPopoverOpened}
|
|
onChange={setGroupPopoverOpened}
|
|
// position="bottom-start"
|
|
withArrow
|
|
>
|
|
<PopoverTarget>
|
|
<TextInput
|
|
id="channel_group_id"
|
|
name="channel_group_id"
|
|
label="Channel Group"
|
|
description={
|
|
<ProviderHintRow
|
|
channel={channel}
|
|
field="channel_group_id"
|
|
formValue={watch('channel_group_id')}
|
|
hintText={getFkProviderHint(
|
|
channel,
|
|
'channel_group_id',
|
|
channelGroups
|
|
)}
|
|
onReset={() =>
|
|
setValue(
|
|
'channel_group_id',
|
|
getProviderFormValue(channel, 'channel_group_id')
|
|
)
|
|
}
|
|
/>
|
|
}
|
|
readOnly
|
|
value={
|
|
channelGroups[watch('channel_group_id')]
|
|
? channelGroups[watch('channel_group_id')].name
|
|
: ''
|
|
}
|
|
onClick={() => setGroupPopoverOpened(true)}
|
|
size="xs"
|
|
/>
|
|
</PopoverTarget>
|
|
|
|
<PopoverDropdown onMouseDown={(e) => e.stopPropagation()}>
|
|
<Group>
|
|
<TextInput
|
|
placeholder="Filter"
|
|
value={groupFilter}
|
|
onChange={(event) =>
|
|
setGroupFilter(event.currentTarget.value)
|
|
}
|
|
mb="xs"
|
|
size="xs"
|
|
/>
|
|
</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={() => {
|
|
setValue(
|
|
'channel_group_id',
|
|
filteredGroups[index].id
|
|
);
|
|
setGroupPopoverOpened(false);
|
|
}}
|
|
>
|
|
<Text
|
|
size="xs"
|
|
style={{
|
|
whiteSpace: 'nowrap',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
}}
|
|
>
|
|
{filteredGroups[index].name}
|
|
</Text>
|
|
</UnstyledButton>
|
|
</Tooltip>
|
|
</Box>
|
|
)}
|
|
</List>
|
|
</ScrollArea>
|
|
</PopoverDropdown>
|
|
</Popover>
|
|
|
|
<Flex align="flex-end">
|
|
<ActionIcon
|
|
color={theme.tailwind.green[5]}
|
|
onClick={() => setChannelGroupModalOpen(true)}
|
|
title="Create new group"
|
|
size="small"
|
|
variant="transparent"
|
|
style={{ marginBottom: 5 }}
|
|
>
|
|
<SquarePlus size="20" />
|
|
</ActionIcon>
|
|
</Flex>
|
|
</Flex>
|
|
|
|
<Select
|
|
id="stream_profile_id"
|
|
label="Stream Profile"
|
|
name="stream_profile_id"
|
|
description={
|
|
<ProviderHintRow
|
|
channel={channel}
|
|
field="stream_profile_id"
|
|
formValue={watch('stream_profile_id')}
|
|
hintText={getFkProviderHint(
|
|
channel,
|
|
'stream_profile_id',
|
|
streamProfiles.reduce((acc, p) => {
|
|
acc[p.id] = p;
|
|
return acc;
|
|
}, {})
|
|
)}
|
|
onReset={() =>
|
|
setValue(
|
|
'stream_profile_id',
|
|
getProviderFormValue(channel, 'stream_profile_id')
|
|
)
|
|
}
|
|
/>
|
|
}
|
|
value={watch('stream_profile_id')}
|
|
onChange={(value) => {
|
|
setValue('stream_profile_id', value);
|
|
}}
|
|
error={errors.stream_profile_id?.message}
|
|
data={[{ value: '0', label: '(use default)' }].concat(
|
|
streamProfiles.map((option) => ({
|
|
value: `${option.id}`,
|
|
label: option.name,
|
|
}))
|
|
)}
|
|
size="xs"
|
|
/>
|
|
|
|
<Select
|
|
label="User Level Access"
|
|
data={Object.entries(USER_LEVELS).map(([, value]) => {
|
|
return {
|
|
label: USER_LEVEL_LABELS[value],
|
|
value: `${value}`,
|
|
};
|
|
})}
|
|
value={watch('user_level')}
|
|
onChange={(value) => {
|
|
setValue('user_level', value);
|
|
}}
|
|
error={errors.user_level?.message}
|
|
/>
|
|
</Stack>
|
|
|
|
<Divider size="sm" orientation="vertical" />
|
|
|
|
<Stack justify="flex-start" style={{ flex: 1 }}>
|
|
<Group justify="space-between">
|
|
<Popover
|
|
opened={logoPopoverOpened}
|
|
onChange={(opened) => {
|
|
setLogoPopoverOpened(opened);
|
|
// Load all logos when popover is opened
|
|
if (opened) {
|
|
console.log(
|
|
'Popover opened, calling ensureLogosLoaded...'
|
|
);
|
|
ensureLogosLoaded();
|
|
}
|
|
}}
|
|
// position="bottom-start"
|
|
withArrow
|
|
>
|
|
<PopoverTarget>
|
|
<TextInput
|
|
id="logo_id"
|
|
name="logo_id"
|
|
label={
|
|
<Group gap="xs">
|
|
<span>Logo</span>
|
|
{watch('epg_data_id') && (
|
|
<Button
|
|
size="xs"
|
|
variant="transparent"
|
|
onClick={handleSetLogoFromEpg}
|
|
title="Find matching logo based on EPG icon URL"
|
|
p={0}
|
|
h="auto"
|
|
>
|
|
Use EPG Logo
|
|
</Button>
|
|
)}
|
|
</Group>
|
|
}
|
|
description={
|
|
<ProviderHintRow
|
|
channel={channel}
|
|
field="logo_id"
|
|
formValue={watch('logo_id')}
|
|
hintText={getFkProviderHint(
|
|
channel,
|
|
'logo_id',
|
|
channelLogos
|
|
)}
|
|
onReset={() =>
|
|
setValue(
|
|
'logo_id',
|
|
getProviderFormValue(channel, 'logo_id')
|
|
)
|
|
}
|
|
/>
|
|
}
|
|
readOnly
|
|
value={channelLogos[watch('logo_id')]?.name || 'Default'}
|
|
onClick={() => {
|
|
console.log(
|
|
'Logo input clicked, setting popover opened to true'
|
|
);
|
|
setLogoPopoverOpened(true);
|
|
}}
|
|
size="xs"
|
|
/>
|
|
</PopoverTarget>
|
|
|
|
<PopoverDropdown 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} // Set max height for visible items
|
|
itemCount={filteredLogos.length}
|
|
itemSize={55} // Increased row height for logo + text
|
|
style={{ width: '100%' }}
|
|
ref={logoListRef}
|
|
>
|
|
{({ index, style }) => (
|
|
<div
|
|
style={{
|
|
...style,
|
|
cursor: 'pointer',
|
|
padding: '5px',
|
|
borderRadius: '4px',
|
|
}}
|
|
onClick={() => {
|
|
setValue('logo_id', filteredLogos[index].id);
|
|
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' }}
|
|
>
|
|
<img
|
|
src={filteredLogos[index].cache_url || logo}
|
|
height="30"
|
|
style={{ maxWidth: 80, objectFit: 'contain' }}
|
|
alt={filteredLogos[index].name || 'Logo'}
|
|
onError={(e) => {
|
|
// Fallback to default logo if image fails to load
|
|
if (e.target.src !== logo) {
|
|
e.target.src = logo;
|
|
}
|
|
}}
|
|
/>
|
|
<Text
|
|
size="xs"
|
|
c="dimmed"
|
|
ta="center"
|
|
style={{
|
|
maxWidth: 80,
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
}}
|
|
>
|
|
{filteredLogos[index].name || 'Default'}
|
|
</Text>
|
|
</Center>
|
|
</div>
|
|
)}
|
|
</List>
|
|
)}
|
|
</ScrollArea>
|
|
</PopoverDropdown>
|
|
</Popover>
|
|
|
|
<Stack gap="xs" align="center">
|
|
<LazyLogo
|
|
logoId={watch('logo_id')}
|
|
alt="channel logo"
|
|
style={{ height: 40 }}
|
|
/>
|
|
</Stack>
|
|
</Group>
|
|
|
|
<Button
|
|
onClick={() => setLogoModalOpen(true)}
|
|
fullWidth
|
|
variant="default"
|
|
>
|
|
Upload or Create Logo
|
|
</Button>
|
|
<Tooltip label="Mark as mature/adult content (18+)" withArrow>
|
|
<Box>
|
|
<Switch
|
|
label="Mature Content"
|
|
checked={watch('is_adult')}
|
|
onChange={(event) =>
|
|
setValue('is_adult', event.currentTarget.checked)
|
|
}
|
|
size="md"
|
|
/>
|
|
</Box>
|
|
</Tooltip>
|
|
<Tooltip
|
|
label="Hides this channel from HDHR, M3U, EPG, and XC client output and preserves it from auto-cleanup. To hide channels per-user, use channel profiles instead."
|
|
withArrow
|
|
multiline
|
|
w={320}
|
|
>
|
|
<Box>
|
|
<Switch
|
|
label="Hidden"
|
|
checked={watch('hidden_from_output')}
|
|
onChange={(event) =>
|
|
setValue('hidden_from_output', event.currentTarget.checked)
|
|
}
|
|
size="md"
|
|
/>
|
|
</Box>
|
|
</Tooltip>
|
|
{channel?.auto_created && hasAnyOverride && (
|
|
<Tooltip
|
|
label={`Currently overriding: ${overriddenFieldLabels.join(', ')}. Clear all overrides to follow the provider values again on the next refresh.`}
|
|
withArrow
|
|
>
|
|
<Button
|
|
variant="light"
|
|
color="orange"
|
|
size="xs"
|
|
onClick={clearOverrides}
|
|
>
|
|
Clear All Overrides ({overriddenFieldLabels.length})
|
|
</Button>
|
|
</Tooltip>
|
|
)}
|
|
</Stack>
|
|
|
|
<Divider size="sm" orientation="vertical" />
|
|
|
|
<Stack gap="5" style={{ flex: 1 }} justify="flex-start">
|
|
<NumberInput
|
|
id="channel_number"
|
|
name="channel_number"
|
|
label="Channel # (blank to auto-assign)"
|
|
description={
|
|
<ProviderHintRow
|
|
channel={channel}
|
|
field="channel_number"
|
|
formValue={watch('channel_number')}
|
|
hintText={getProviderHint(channel, 'channel_number')}
|
|
onReset={() =>
|
|
setValue(
|
|
'channel_number',
|
|
getProviderFormValue(channel, 'channel_number'),
|
|
{ shouldDirty: true }
|
|
)
|
|
}
|
|
/>
|
|
}
|
|
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
|
|
/>
|
|
|
|
<TextInput
|
|
id="tvg_id"
|
|
name="tvg_id"
|
|
label={
|
|
<Group gap="xs">
|
|
<span>TVG-ID</span>
|
|
{watch('epg_data_id') && (
|
|
<Button
|
|
size="xs"
|
|
variant="transparent"
|
|
onClick={handleSetTvgIdFromEpg}
|
|
title="Set TVG-ID from EPG data"
|
|
p={0}
|
|
h="auto"
|
|
>
|
|
Use EPG TVG-ID
|
|
</Button>
|
|
)}
|
|
</Group>
|
|
}
|
|
description={
|
|
<ProviderHintRow
|
|
channel={channel}
|
|
field="tvg_id"
|
|
formValue={watch('tvg_id')}
|
|
hintText={getProviderHint(channel, 'tvg_id')}
|
|
onReset={() =>
|
|
setValue(
|
|
'tvg_id',
|
|
getProviderFormValue(channel, 'tvg_id'),
|
|
{ shouldDirty: true }
|
|
)
|
|
}
|
|
/>
|
|
}
|
|
{...register('tvg_id')}
|
|
error={errors.tvg_id?.message}
|
|
size="xs"
|
|
/>
|
|
|
|
<TextInput
|
|
id="tvc_guide_stationid"
|
|
name="tvc_guide_stationid"
|
|
label="Gracenote StationId"
|
|
description={
|
|
<ProviderHintRow
|
|
channel={channel}
|
|
field="tvc_guide_stationid"
|
|
formValue={watch('tvc_guide_stationid')}
|
|
hintText={getProviderHint(channel, 'tvc_guide_stationid')}
|
|
onReset={() =>
|
|
setValue(
|
|
'tvc_guide_stationid',
|
|
getProviderFormValue(channel, 'tvc_guide_stationid'),
|
|
{ shouldDirty: true }
|
|
)
|
|
}
|
|
/>
|
|
}
|
|
{...register('tvc_guide_stationid')}
|
|
error={errors.tvc_guide_stationid?.message}
|
|
size="xs"
|
|
/>
|
|
|
|
<Popover
|
|
opened={epgPopoverOpened}
|
|
onChange={setEpgPopoverOpened}
|
|
withArrow
|
|
>
|
|
<PopoverTarget>
|
|
<TextInput
|
|
id="epg_data_id"
|
|
name="epg_data_id"
|
|
label={
|
|
<Group style={{ width: '100%' }}>
|
|
<Box>EPG</Box>
|
|
<Button
|
|
size="xs"
|
|
variant="transparent"
|
|
onClick={() => setValue('epg_data_id', null)}
|
|
>
|
|
Use Dummy
|
|
</Button>
|
|
<Button
|
|
size="xs"
|
|
variant="transparent"
|
|
color="blue"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleAutoMatchEpg();
|
|
}}
|
|
disabled={!channel || !channel.id}
|
|
loading={autoMatchLoading}
|
|
title={
|
|
!channel || !channel.id
|
|
? 'Auto-match is only available for existing channels'
|
|
: 'Automatically match EPG data'
|
|
}
|
|
leftSection={<Zap size="14" />}
|
|
>
|
|
Auto Match
|
|
</Button>
|
|
</Group>
|
|
}
|
|
description={
|
|
<ProviderHintRow
|
|
channel={channel}
|
|
field="epg_data_id"
|
|
formValue={watch('epg_data_id')}
|
|
hintText={getFkProviderHint(
|
|
channel,
|
|
'epg_data_id',
|
|
tvgsById
|
|
)}
|
|
onReset={() =>
|
|
setValue(
|
|
'epg_data_id',
|
|
getProviderFormValue(channel, 'epg_data_id')
|
|
)
|
|
}
|
|
/>
|
|
}
|
|
readOnly
|
|
value={(() => {
|
|
const tvg = tvgsById[watch('epg_data_id')];
|
|
const epgSource = tvg && epgs[tvg.epg_source];
|
|
const tvgLabel = tvg ? tvg.name || tvg.id : '';
|
|
if (epgSource && tvgLabel) {
|
|
return `${epgSource.name} - ${tvgLabel}`;
|
|
} else if (tvgLabel) {
|
|
return tvgLabel;
|
|
} else {
|
|
return 'Dummy';
|
|
}
|
|
})()}
|
|
onClick={() => setEpgPopoverOpened(true)}
|
|
size="xs"
|
|
rightSection={
|
|
<Tooltip label="Use dummy EPG">
|
|
<ActionIcon
|
|
// color={theme.tailwind.green[5]}
|
|
color="white"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setValue('epg_data_id', null);
|
|
}}
|
|
title="Create new group"
|
|
size="small"
|
|
variant="transparent"
|
|
>
|
|
<X size="20" />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
}
|
|
/>
|
|
</PopoverTarget>
|
|
|
|
<PopoverDropdown onMouseDown={(e) => e.stopPropagation()}>
|
|
<Group>
|
|
<Select
|
|
label="Source"
|
|
value={selectedEPG}
|
|
onChange={setSelectedEPG}
|
|
data={Object.values(epgs)
|
|
.filter((epg) => epg.is_active)
|
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
.map((epg) => ({
|
|
value: `${epg.id}`,
|
|
label: epg.name,
|
|
}))}
|
|
size="xs"
|
|
mb="xs"
|
|
/>
|
|
|
|
{/* Filter Input */}
|
|
<TextInput
|
|
label="Filter"
|
|
value={tvgFilter}
|
|
onChange={(event) =>
|
|
setTvgFilter(event.currentTarget.value)
|
|
}
|
|
mb="xs"
|
|
size="xs"
|
|
autoFocus
|
|
/>
|
|
</Group>
|
|
|
|
<ScrollArea style={{ height: 200 }}>
|
|
<List
|
|
height={200} // Set max height for visible items
|
|
itemCount={filteredTvgs.length}
|
|
itemSize={40} // Adjust row height for each item
|
|
style={{ width: '100%' }}
|
|
ref={listRef}
|
|
>
|
|
{({ index, style }) => (
|
|
<div style={style}>
|
|
<Button
|
|
key={filteredTvgs[index].id}
|
|
variant="subtle"
|
|
color="gray"
|
|
style={{ width: '100%' }}
|
|
justify="left"
|
|
size="xs"
|
|
onClick={() => {
|
|
if (filteredTvgs[index].id == '0') {
|
|
setValue('epg_data_id', null);
|
|
} else {
|
|
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(
|
|
`${filteredTvgs[index].epg_source}`
|
|
);
|
|
}
|
|
}
|
|
setEpgPopoverOpened(false);
|
|
}}
|
|
>
|
|
{filteredTvgs[index].name &&
|
|
filteredTvgs[index].tvg_id
|
|
? `${filteredTvgs[index].name} (${filteredTvgs[index].tvg_id})`
|
|
: filteredTvgs[index].name ||
|
|
filteredTvgs[index].tvg_id}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</List>
|
|
</ScrollArea>
|
|
</PopoverDropdown>
|
|
</Popover>
|
|
</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}
|
|
/>
|
|
|
|
<LogoForm
|
|
isOpen={logoModalOpen}
|
|
onClose={() => setLogoModalOpen(false)}
|
|
onSuccess={handleLogoSuccess}
|
|
/>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default ChannelForm;
|