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, 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, createLogo, getChannelFormDefaultValues, getFormattedValues, handleEpgUpdate, matchChannelEpg, 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'), }); const ChannelForm = ({ channel = null, isOpen, onClose }) => { const theme = useMantineTheme(); const listRef = useRef(null); const logoListRef = useRef(null); const groupListRef = useRef(null); 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), [channel, channelGroups] ); const { register, handleSubmit, setValue, watch, reset, formState: { errors, isSubmitting }, } = useForm({ defaultValues, resolver: yupResolver(validationSchema), }); const onSubmit = async (values) => { 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); } 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 ( <> Channels } styles={{ content: { '--mantine-color-body': '#27272A' } }} >
Channel Name {watch('epg_data_id') && ( )} } {...register('name')} error={errors.name?.message} size="xs" style={{ flex: 1 }} /> setGroupPopoverOpened(true)} size="xs" /> e.stopPropagation()}> setGroupFilter(event.currentTarget.value) } mb="xs" size="xs" /> {({ index, style }) => ( { setValue( 'channel_group_id', filteredGroups[index].id ); setGroupPopoverOpened(false); }} > {filteredGroups[index].name} )} setChannelGroupModalOpen(true)} title="Create new group" size="small" variant="transparent" style={{ marginBottom: 5 }} > { return { label: USER_LEVEL_LABELS[value], value: `${value}`, }; })} value={watch('user_level')} onChange={(value) => { setValue('user_level', value); }} error={errors.user_level?.message} /> { setLogoPopoverOpened(opened); // Load all logos when popover is opened if (opened) { console.log( 'Popover opened, calling ensureLogosLoaded...' ); ensureLogosLoaded(); } }} // position="bottom-start" withArrow > Logo {watch('epg_data_id') && ( )} } readOnly value={channelLogos[watch('logo_id')]?.name || 'Default'} onClick={() => { console.log( 'Logo input clicked, setting popover opened to true' ); setLogoPopoverOpened(true); }} size="xs" /> e.stopPropagation()}> setLogoFilter(event.currentTarget.value) } mb="xs" size="xs" /> {logosLoading && ( Loading... )} {filteredLogos.length === 0 ? (
{logoFilter ? 'No logos match your filter' : 'No logos available'}
) : ( {({ index, style }) => (
{ 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'; }} >
{filteredLogos[index].name { // Fallback to default logo if image fails to load if (e.target.src !== logo) { e.target.src = logo; } }} /> {filteredLogos[index].name || 'Default'}
)}
)}
setValue('is_adult', event.currentTarget.checked) } size="md" />
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 /> TVG-ID {watch('epg_data_id') && ( )} } {...register('tvg_id')} error={errors.tvg_id?.message} size="xs" /> EPG } 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={ { e.stopPropagation(); setValue('epg_data_id', null); }} title="Create new group" size="small" variant="transparent" > } /> e.stopPropagation()}>