refactored streams table as well, broke out custom table into smaller components

This commit is contained in:
dekzter 2025-04-23 18:04:00 -04:00
parent 03f6c77391
commit 0dc62fb039
10 changed files with 948 additions and 866 deletions

View file

@ -30,6 +30,7 @@ import {
Tooltip,
NumberInput,
Image,
UnstyledButton,
} from '@mantine/core';
import { ListOrdered, SquarePlus, SquareX, X } from 'lucide-react';
import useEPGsStore from '../../store/epgs';
@ -41,6 +42,7 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
const listRef = useRef(null);
const logoListRef = useRef(null);
const groupListRef = useRef(null);
const channelGroups = useChannelsStore((s) => s.channelGroups);
const logos = useChannelsStore((s) => s.logos);
@ -62,6 +64,10 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
const [logoFilter, setLogoFilter] = useState('');
const [logoOptions, setLogoOptions] = useState([]);
const [groupPopoverOpened, setGroupPopoverOpened] = useState(false);
const [groupFilter, setGroupFilter] = useState('');
const groupOptions = Object.values(channelGroups);
const addStream = (stream) => {
const streamSet = new Set(channelStreams);
streamSet.add(stream);
@ -89,7 +95,7 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
initialValues: {
name: '',
channel_number: 0,
channel_group_id: '',
channel_group_id: Object.keys(channelGroups)[0],
stream_profile_id: '0',
tvg_id: '',
epg_data_id: '',
@ -348,6 +354,10 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
logo.name.toLowerCase().includes(logoFilter.toLowerCase())
);
const filteredGroups = groupOptions.filter((group) =>
group.name.toLowerCase().includes(groupFilter.toLowerCase())
);
return (
<>
<Modal
@ -376,7 +386,83 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
/>
<Flex gap="sm">
<Select
<Popover
opened={groupPopoverOpened}
onChange={setGroupPopoverOpened}
// position="bottom-start"
withArrow
>
<Popover.Target>
<TextInput
id="channel_group_id"
name="channel_group_id"
label="Channel Group"
readOnly
value={channelGroups[formik.values.channel_group_id].name}
onClick={() => setGroupPopoverOpened(true)}
size="xs"
/>
</Popover.Target>
<Popover.Dropdown 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={() => {
formik.setFieldValue(
'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>
</Popover.Dropdown>
</Popover>
{/* <Select
id="channel_group_id"
name="channel_group_id"
label="Channel Group"
@ -396,7 +482,7 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
}))}
size="xs"
style={{ flex: 1 }}
/>
/> */}
<Flex align="flex-end">
<ActionIcon
color={theme.tailwind.green[5]}

View file

@ -1,16 +1,10 @@
import React, {
useEffect,
useMemo,
useRef,
useState,
useCallback,
} from 'react';
import React, { useEffect, useMemo, useState, useCallback } from 'react';
import useChannelsStore from '../../store/channels';
import { notifications } from '@mantine/notifications';
import API from '../../api';
import ChannelForm from '../forms/Channel';
import RecordingForm from '../forms/Recording';
import { useDebounce } from '../../utils';
import { useDebounce, copyToClipboard } from '../../utils';
import logo from '../../images/logo.png';
import useVideoStore from '../../store/useVideoStore';
import useSettingsStore from '../../store/settings';
@ -21,29 +15,22 @@ import {
SquareMinus,
CirclePlay,
SquarePen,
Binary,
ArrowDown01,
SquarePlus,
Copy,
CircleCheck,
ScanEye,
EllipsisVertical,
ArrowUpNarrowWide,
ArrowUpDown,
ArrowDownWideNarrow,
} from 'lucide-react';
import ghostImage from '../../images/ghost.svg';
import {
Box,
TextInput,
Popover,
ActionIcon,
Select,
Button,
Paper,
Flex,
Text,
Tooltip,
Group,
useMantineTheme,
Center,
@ -53,7 +40,6 @@ import {
Pagination,
NativeSelect,
UnstyledButton,
CopyButton,
} from '@mantine/core';
import { getCoreRowModel, flexRender } from '@tanstack/react-table';
import './table.css';
@ -61,68 +47,13 @@ import useChannelsTableStore from '../../store/channelsTable';
import ChannelTableStreams from './ChannelTableStreams';
import useLocalStorage from '../../hooks/useLocalStorage';
import { CustomTable, useTable } from './CustomTable';
import ChannelsTableOnboarding from './ChannelsTable/ChannelsTableOnboarding';
import ChannelTableHeader from './ChannelsTable/ChannelTableHeader';
const m3uUrlBase = `${window.location.protocol}//${window.location.host}/output/m3u`;
const epgUrlBase = `${window.location.protocol}//${window.location.host}/output/epg`;
const hdhrUrlBase = `${window.location.protocol}//${window.location.host}/hdhr`;
const CreateProfilePopover = React.memo(() => {
const [opened, setOpened] = useState(false);
const [name, setName] = useState('');
const theme = useMantineTheme();
const setOpen = () => {
setName('');
setOpened(!opened);
};
const submit = async () => {
await API.addChannelProfile({ name });
setName('');
setOpened(false);
};
return (
<Popover
opened={opened}
onChange={setOpen}
position="bottom"
withArrow
shadow="md"
>
<Popover.Target>
<ActionIcon
variant="transparent"
color={theme.tailwind.green[5]}
onClick={setOpen}
>
<SquarePlus />
</ActionIcon>
</Popover.Target>
<Popover.Dropdown>
<Group>
<TextInput
placeholder="Profile Name"
value={name}
onChange={(event) => setName(event.currentTarget.value)}
size="xs"
/>
<ActionIcon
variant="transparent"
color={theme.tailwind.green[5]}
size="sm"
onClick={submit}
>
<CircleCheck />
</ActionIcon>
</Group>
</Popover.Dropdown>
</Popover>
);
});
const ChannelEnabledSwitch = React.memo(
({ rowId, selectedProfileId, toggleChannelEnabled }) => {
// Directly extract the channels set once to avoid re-renders on every change.
@ -217,13 +148,12 @@ const ChannelRowActions = React.memo(
<Menu.Dropdown>
<Menu.Item leftSection={<Copy size="14" />}>
<CopyButton value={getChannelURL(row.original)}>
{({ copied, copy }) => (
<UnstyledButton variant="unstyled" size="xs" onClick={copy}>
<Text size="xs">{copied ? 'Copied!' : 'Copy URL'}</Text>
</UnstyledButton>
)}
</CopyButton>
<UnstyledButton
size="xs"
onClick={() => copyToClipboard(getChannelURL(row.original))}
>
<Text size="xs">Copy URL</Text>
</UnstyledButton>
</Menu.Item>
<Menu.Item
onClick={onRecord}
@ -250,64 +180,86 @@ const ChannelRowActions = React.memo(
);
const ChannelsTable = ({}) => {
const theme = useMantineTheme();
/**
* STORES
*/
// store/channelsTable
const data = useChannelsTableStore((s) => s.channels);
const pageCount = useChannelsTableStore((s) => s.pageCount);
const setSelectedTableIds = useChannelsTableStore(
const setSelectedChannelIds = useChannelsTableStore(
(s) => s.setSelectedChannelIds
);
const selectedChannelIds = useChannelsTableStore((s) => s.selectedChannelIds);
const pagination = useChannelsTableStore((s) => s.pagination);
const setPagination = useChannelsTableStore((s) => s.setPagination);
const sorting = useChannelsTableStore((s) => s.sorting);
const setSorting = useChannelsTableStore((s) => s.setSorting);
const totalCount = useChannelsTableStore((s) => s.totalCount);
// store/channels
const channels = useChannelsStore((s) => s.channels);
const profiles = useChannelsStore((s) => s.profiles);
const selectedProfileId = useChannelsStore((s) => s.selectedProfileId);
const setSelectedProfileId = useChannelsStore((s) => s.setSelectedProfileId);
const channelGroups = useChannelsStore((s) => s.channelGroups);
const logos = useChannelsStore((s) => s.logos);
const [tablePrefs, setTablePrefs] = useLocalStorage('channel-table-prefs', {
pageSize: 50,
});
const selectedProfileChannels = useChannelsStore(
(s) => s.profiles[selectedProfileId]?.channels
);
// store/settings
const env_mode = useSettingsStore((s) => s.environment.env_mode);
const showVideo = useVideoStore((s) => s.showVideo);
/**
* useMemo
*/
const selectedProfileChannelIds = useMemo(
() => new Set(selectedProfileChannels),
[selectedProfileChannels]
);
/**
* useState
*/
const [allRowIds, setAllRowIds] = useState([]);
const [channel, setChannel] = useState(null);
const [channelModalOpen, setChannelModalOpen] = useState(false);
const [recordingModalOpen, setRecordingModalOpen] = useState(false);
const [selectedProfile, setSelectedProfile] = useState(
profiles[selectedProfileId]
);
const [paginationString, setPaginationString] = useState('');
const [filters, setFilters] = useState({
name: '',
channel_group: '',
});
const [isLoading, setIsLoading] = useState(true);
const [hdhrUrl, setHDHRUrl] = useState(hdhrUrlBase);
const [epgUrl, setEPGUrl] = useState(epgUrlBase);
const [m3uUrl, setM3UUrl] = useState(m3uUrlBase);
/**
* Dereived variables
*/
const activeGroupIds = new Set(
Object.values(channels).map((channel) => channel.channel_group_id)
);
const groupOptions = Object.values(channelGroups)
.filter((group) => activeGroupIds.has(group.id))
.map((group) => group.name);
const env_mode = useSettingsStore((s) => s.environment.env_mode);
const [allRowIds, setAllRowIds] = useState([]);
const [channel, setChannel] = useState(null);
const [channelModalOpen, setChannelModalOpen] = useState(false);
const [recordingModalOpen, setRecordingModalOpen] = useState(false);
const [rowSelection, setRowSelection] = useState([]);
const [selectedProfile, setSelectedProfile] = useState(
profiles[selectedProfileId]
);
const pagination = useChannelsTableStore((s) => s.pagination);
const setPagination = useChannelsTableStore((s) => s.setPagination);
const [paginationString, setPaginationString] = useState('');
const [filters, setFilters] = useState({
name: '',
channel_group: '',
});
const debouncedFilters = useDebounce(filters, 500);
const [isLoading, setIsLoading] = useState(true);
const [selectedChannelIds, setSelectedChannelIds] = useState([]);
const sorting = useChannelsTableStore((s) => s.sorting);
const setSorting = useChannelsTableStore((s) => s.setSorting);
const [expandedRowIds, setExpandedRowIds] = useState([]);
const [hdhrUrl, setHDHRUrl] = useState(hdhrUrlBase);
const [epgUrl, setEPGUrl] = useState(epgUrlBase);
const [m3uUrl, setM3UUrl] = useState(m3uUrlBase);
/**
* Functions
*/
const fetchData = useCallback(async () => {
const params = new URLSearchParams();
params.append('page', pagination.pageIndex + 1);
@ -328,39 +280,12 @@ const ChannelsTable = ({}) => {
const results = await API.queryChannels(params);
const ids = await API.getAllChannelIds(params);
const startItem = pagination.pageIndex * pagination.pageSize + 1; // +1 to start from 1, not 0
const endItem = Math.min(
(pagination.pageIndex + 1) * pagination.pageSize,
results.count
);
// Generate the string
setPaginationString(`${startItem} to ${endItem} of ${results.count}`);
setTablePrefs({
pageSize: pagination.pageSize,
});
setAllRowIds(ids);
}, [pagination, sorting, debouncedFilters]);
useEffect(() => {
fetchData();
}, [fetchData]);
// const theme = useTheme();
const theme = useMantineTheme();
const showVideo = useVideoStore((s) => s.showVideo);
useEffect(() => {
setSelectedProfile(profiles[selectedProfileId]);
const profileString =
selectedProfileId != '0' ? `/${profiles[selectedProfileId].name}` : '';
setHDHRUrl(`${hdhrUrlBase}${profileString}`);
setEPGUrl(`${epgUrlBase}${profileString}`);
setM3UUrl(`${m3uUrlBase}${profileString}`);
}, [selectedProfileId]);
const stopPropagation = useCallback((e) => {
e.stopPropagation();
}, []);
@ -380,17 +305,13 @@ const ChannelsTable = ({}) => {
}));
};
const hdhrUrlRef = useRef(null);
const m3uUrlRef = useRef(null);
const epgUrlRef = useRef(null);
const editChannel = async (ch = null) => {
setChannel(ch);
setChannelModalOpen(true);
};
const deleteChannel = async (id) => {
setRowSelection([]);
table.setSelectedTableIds([]);
if (selectedChannelIds.length > 0) {
return deleteChannels();
}
@ -413,36 +334,13 @@ const ChannelsTable = ({}) => {
return channelUrl;
};
function handleWatchStream(channel) {
const handleWatchStream = (channel) => {
showVideo(getChannelURL(channel));
}
const onRowSelectionChange = (newSelection) => {
setSelectedTableIds(newSelection);
};
// const onSelectAllChange = async (e) => {
// const selectAll = e.target.checked;
// if (selectAll) {
// // Get all channel IDs for current view
// const params = new URLSearchParams();
// Object.entries(debouncedFilters).forEach(([key, value]) => {
// if (value) params.append(key, value);
// });
// const ids = await API.getAllChannelIds(params);
// setSelectedTableIds(ids);
// setSelectedChannelIds(ids);
// } else {
// setSelectedTableIds([]);
// setSelectedChannelIds([]);
// }
// const newSelection = {};
// getRowModel().rows.forEach((item, index) => {
// newSelection[index] = selectAll;
// });
// setRowSelection(newSelection);
// };
const onRowSelectionChange = (newSelection) => {
setSelectedChannelIds(newSelection);
};
const onPageSizeChange = (e) => {
setPagination({
@ -477,76 +375,16 @@ const ChannelsTable = ({}) => {
[selectedProfileId]
);
const EnabledHeaderSwitch = useCallback(() => {
let enabled = false;
for (const id of selectedChannelIds) {
if (selectedProfileChannelIds.has(id)) {
enabled = true;
break;
}
}
const toggleSelected = () => {
toggleChannelEnabled(selectedChannelIds, !enabled);
};
return <Switch size="xs" checked={enabled} onChange={toggleSelected} />;
}, [selectedChannelIds, selectedProfileChannelIds, data]);
// (Optional) bulk delete, but your endpoint is @TODO
const deleteChannels = async () => {
setIsLoading(true);
await API.deleteChannels(selectedChannelIds);
await API.deleteChannels(table.selectedTableIds);
await API.requeryChannels();
setSelectedChannelIds([]);
setRowSelection([]);
table.setSelectedTableIds([]);
setIsLoading(false);
};
//
// The "Assign Channels" button logic
//
const assignChannels = async () => {
try {
// Get row order from the table
const rowOrder = getRowModel().rows.map((row) => row.original.id);
// Call our custom API endpoint
setIsLoading(true);
const result = await API.assignChannelNumbers(rowOrder);
setIsLoading(false);
// We might get { message: "Channels have been auto-assigned!" }
notifications.show({
title: result.message || 'Channels assigned',
color: 'green.5',
});
// Refresh the channel list
// await fetchChannels();
API.requeryChannels();
} catch (err) {
console.error(err);
notifications.show({
title: 'Failed to assign channels',
color: 'red.5',
});
}
};
const matchEpg = async () => {
try {
// Hit our new endpoint that triggers the fuzzy matching Celery task
await API.matchEpg();
notifications.show({
title: 'EPG matching task started!',
});
} catch (err) {
notifications.show(`Error: ${err.message}`);
}
};
const closeChannelForm = () => {
setChannel(null);
setChannelModalOpen(false);
@ -557,12 +395,6 @@ const ChannelsTable = ({}) => {
setRecordingModalOpen(false);
};
useEffect(() => {
if (typeof window !== 'undefined') {
setIsLoading(false);
}
}, []);
const handleCopy = async (textToCopy, ref) => {
try {
await navigator.clipboard.writeText(textToCopy);
@ -586,17 +418,13 @@ const ChannelsTable = ({}) => {
// Example copy URLs
const copyM3UUrl = () => {
handleCopy(m3uUrl, m3uUrlRef);
copyToClipboard(m3uUrl);
};
const copyEPGUrl = () => {
handleCopy(epgUrl, epgUrlRef);
copyToClipboard(epgUrl);
};
const copyHDHRUrl = () => {
handleCopy(hdhrUrl, hdhrUrlRef);
};
const deleteProfile = async (id) => {
await API.deleteChannelProfile(id);
copyToClipboard(hdhrUrl);
};
const onSortingChange = (column) => {
@ -624,40 +452,57 @@ const ChannelsTable = ({}) => {
}
};
const renderProfileOption = ({ option, checked }) => {
return (
<Group justify="space-between" style={{ width: '100%' }}>
<Box>{option.label}</Box>
{option.value != '0' && (
<ActionIcon
size="xs"
variant="transparent"
color={theme.tailwind.red[6]}
onClick={(e) => {
e.stopPropagation();
deleteProfile(option.value);
}}
>
<SquareMinus />
</ActionIcon>
)}
</Group>
const EnabledHeaderSwitch = useCallback(() => {
let enabled = false;
for (const id of selectedChannelIds) {
if (selectedProfileChannelIds.has(id)) {
enabled = true;
break;
}
}
const toggleSelected = () => {
toggleChannelEnabled(selectedChannelIds, !enabled);
};
return <Switch size="xs" checked={enabled} onChange={toggleSelected} />;
}, [selectedChannelIds, selectedProfileChannelIds, data]);
/**
* useEffect
*/
useEffect(() => {
fetchData();
}, [fetchData]);
useEffect(() => {
setSelectedProfile(profiles[selectedProfileId]);
const profileString =
selectedProfileId != '0' ? `/${profiles[selectedProfileId].name}` : '';
setHDHRUrl(`${hdhrUrlBase}${profileString}`);
setEPGUrl(`${epgUrlBase}${profileString}`);
setM3UUrl(`${m3uUrlBase}${profileString}`);
}, [selectedProfileId]);
useEffect(() => {
const startItem = pagination.pageIndex * pagination.pageSize + 1; // +1 to start from 1, not 0
const endItem = Math.min(
(pagination.pageIndex + 1) * pagination.pageSize,
totalCount
);
};
setPaginationString(`${startItem} to ${endItem} of ${totalCount}`);
}, [data]);
const columns = useMemo(
() => [
{
id: 'expand',
size: 20,
enableSorting: false,
enableColumnFilter: false,
},
{
id: 'select',
size: 30,
enableSorting: false,
enableColumnFilter: false,
},
{
id: 'enabled',
@ -671,7 +516,6 @@ const ChannelsTable = ({}) => {
/>
);
},
enableSorting: false,
},
{
accessorKey: 'channel_number',
@ -692,24 +536,18 @@ const ChannelsTable = ({}) => {
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
// position: 'absolute',
// left: 0,
// top: 0,
}}
>
<Text size="sm">{getValue()}</Text>
</Box>
),
style: {
justifyContent: 'left',
},
},
{
id: 'channel_group',
accessorFn: (row) =>
row.channel_group_id && channelGroups
? channelGroups[row.channel_group_id].name
: '',
id: 'channel_group',
cell: ({ getValue }) => (
<Box
style={{
@ -740,7 +578,6 @@ const ChannelsTable = ({}) => {
</Center>
);
},
enableSorting: false,
},
{
id: 'actions',
@ -757,7 +594,6 @@ const ChannelsTable = ({}) => {
getChannelURL={getChannelURL}
/>
),
enableSorting: false,
},
],
[selectedProfileId, data, channelGroups]
@ -775,9 +611,9 @@ const ChannelsTable = ({}) => {
switch (header.id) {
case 'enabled':
if (selectedProfileId !== '0' && selectedChannelIds.length > 0) {
// return EnabledHeaderSwitch();
}
// if (selectedProfileId !== '0' && table.selectedTableIds.length > 0) {
// return EnabledHeaderSwitch();
// }
return (
<Center style={{ width: '100%' }}>
<ScanEye size="16" />
@ -833,9 +669,6 @@ const ChannelsTable = ({}) => {
style={{ width: '100%' }}
/>
);
default:
return flexRender(header.column.columnDef.header, header.getContext());
}
};
@ -843,33 +676,16 @@ const ChannelsTable = ({}) => {
data,
columns,
allRowIds,
defaultColumn: {
size: undefined,
minSize: 0,
},
pageCount,
// state: {
// data,
// rowCount,
// sorting,
// filters,
// pagination,
// rowSelection,
// },
filters,
pagination,
sorting,
expandedRowIds,
manualPagination: true,
manualSorting: true,
manualFiltering: true,
enableRowSelection: true,
onRowSelectionChange: onRowSelectionChange,
getCoreRowModel: getCoreRowModel(),
// getFilteredRowModel: getFilteredRowModel(),
// getSortedRowModel: getSortedRowModel(),
// getPaginationRowModel: getPaginationRowModel(),
// debugTable: true,
expandedRowRenderer: ({ row }) => {
return (
<Box
@ -884,14 +700,12 @@ const ChannelsTable = ({}) => {
headerCellRenderFns: {
name: renderHeaderCell,
channel_group: renderHeaderCell,
enabled: () => (
<Center style={{ width: '100%' }}>
<ScanEye size="16" />
</Center>
),
enabled: renderHeaderCell,
},
});
const rows = table.getRowModel().rows;
return (
<Box>
{/* Header Row: outside the Paper */}
@ -952,7 +766,7 @@ const ChannelsTable = ({}) => {
</Popover.Target>
<Popover.Dropdown>
<Group>
<TextInput ref={hdhrUrlRef} value={hdhrUrl} size="small" />
<TextInput value={hdhrUrl} size="small" readOnly />
<ActionIcon
onClick={copyHDHRUrl}
size="sm"
@ -982,7 +796,7 @@ const ChannelsTable = ({}) => {
</Popover.Target>
<Popover.Dropdown>
<Group>
<TextInput ref={m3uUrlRef} value={m3uUrl} size="small" />
<TextInput value={m3uUrl} size="small" readOnly />
<ActionIcon
onClick={copyM3UUrl}
size="sm"
@ -1013,7 +827,7 @@ const ChannelsTable = ({}) => {
</Popover.Target>
<Popover.Dropdown>
<Group>
<TextInput ref={epgUrlRef} value={epgUrl} size="small" />
<TextInput value={epgUrl} size="small" readOnly />
<ActionIcon
onClick={copyEPGUrl}
size="sm"
@ -1038,164 +852,17 @@ const ChannelsTable = ({}) => {
backgroundColor: '#27272A',
}}
>
{/* Top toolbar with Remove, Assign, Auto-match, and Add buttons */}
<Group justify="space-between">
<Group gap={5} style={{ paddingLeft: 10 }}>
<Select
size="xs"
allowDeselect={false}
value={selectedProfileId}
onChange={setSelectedProfileId}
data={Object.values(profiles).map((profile) => ({
label: profile.name,
value: `${profile.id}`,
}))}
renderOption={renderProfileOption}
/>
<Tooltip label="Create Profile">
<CreateProfilePopover />
</Tooltip>
</Group>
<Box
style={{
display: 'flex',
justifyContent: 'flex-end',
padding: 10,
}}
>
<Flex gap={6}>
<Button
leftSection={<SquareMinus size={18} />}
variant="default"
size="xs"
onClick={deleteChannels}
disabled={selectedChannelIds.length == 0}
>
Remove
</Button>
<Tooltip label="Assign Channel #s">
<Button
leftSection={<ArrowDown01 size={18} />}
variant="default"
size="xs"
onClick={assignChannels}
p={5}
>
Assign
</Button>
</Tooltip>
<Tooltip label="Auto-Match EPG">
<Button
leftSection={<Binary size={18} />}
variant="default"
size="xs"
onClick={matchEpg}
p={5}
>
Auto-Match
</Button>
</Tooltip>
<Button
leftSection={<SquarePlus size={18} />}
variant="light"
size="xs"
onClick={() => editChannel()}
p={5}
color={theme.tailwind.green[5]}
style={{
borderWidth: '1px',
borderColor: theme.tailwind.green[5],
color: 'white',
}}
>
Add
</Button>
</Flex>
</Box>
</Group>
<ChannelTableHeader
rows={rows}
editChannel={editChannel}
deleteChannels={deleteChannels}
selectedTableIds={table.selectedTableIds}
/>
{/* Table or ghost empty state inside Paper */}
<Box>
{Object.keys(channels).length === 0 && (
<Box
style={{
paddingTop: 20,
bgcolor: theme.palette.background.paper,
}}
>
<Center>
<Box
style={{
textAlign: 'center',
width: '55%',
}}
>
<Text
style={{
fontFamily: 'Inter, sans-serif',
fontWeight: 400,
fontSize: '20px',
lineHeight: '28px',
letterSpacing: '-0.3px',
color: theme.palette.text.secondary,
mb: 1,
}}
>
Its recommended to create channels after adding your M3U or
streams.
</Text>
<Text
style={{
fontFamily: 'Inter, sans-serif',
fontWeight: 400,
fontSize: '16px',
lineHeight: '24px',
letterSpacing: '-0.2px',
color: theme.palette.text.secondary,
mb: 2,
}}
>
You can still create channels without streams if youd like,
and map them later.
</Text>
<Button
leftSection={<SquarePlus size={18} />}
variant="light"
size="xs"
onClick={() => editChannel()}
color="gray"
style={{
marginTop: 20,
borderWidth: '1px',
borderColor: 'gray',
color: 'white',
}}
>
Create Channel
</Button>
</Box>
</Center>
<Center>
<Box
component="img"
src={ghostImage}
alt="Ghost"
style={{
paddingTop: 30,
width: '120px',
height: 'auto',
opacity: 0.2,
pointerEvents: 'none',
}}
/>
</Center>
</Box>
<ChannelsTableOnboarding editChannel={editChannel} />
)}
</Box>

View file

@ -0,0 +1,241 @@
import React, { useState } from 'react';
import {
ActionIcon,
Box,
Button,
Flex,
Group,
Popover,
Select,
TextInput,
Tooltip,
useMantineTheme,
} from '@mantine/core';
import {
ArrowDown01,
Binary,
CircleCheck,
SquareMinus,
SquarePlus,
} from 'lucide-react';
import API from '../../../api';
import { notifications } from '@mantine/notifications';
import useChannelsStore from '../../../store/channels';
const CreateProfilePopover = React.memo(() => {
const [opened, setOpened] = useState(false);
const [name, setName] = useState('');
const theme = useMantineTheme();
const setOpen = () => {
setName('');
setOpened(!opened);
};
const submit = async () => {
await API.addChannelProfile({ name });
setName('');
setOpened(false);
};
return (
<Popover
opened={opened}
onChange={setOpen}
position="bottom"
withArrow
shadow="md"
>
<Popover.Target>
<ActionIcon
variant="transparent"
color={theme.tailwind.green[5]}
onClick={setOpen}
>
<SquarePlus />
</ActionIcon>
</Popover.Target>
<Popover.Dropdown>
<Group>
<TextInput
placeholder="Profile Name"
value={name}
onChange={(event) => setName(event.currentTarget.value)}
size="xs"
/>
<ActionIcon
variant="transparent"
color={theme.tailwind.green[5]}
size="sm"
onClick={submit}
>
<CircleCheck />
</ActionIcon>
</Group>
</Popover.Dropdown>
</Popover>
);
});
const ChannelTableHeader = ({
rows,
editChannel,
deleteChannels,
selectedTableIds,
}) => {
const theme = useMantineTheme();
const profiles = useChannelsStore((s) => s.profiles);
const selectedProfileId = useChannelsStore((s) => s.selectedProfileId);
const setSelectedProfileId = useChannelsStore((s) => s.setSelectedProfileId);
const deleteProfile = async (id) => {
await API.deleteChannelProfile(id);
};
const matchEpg = async () => {
try {
// Hit our new endpoint that triggers the fuzzy matching Celery task
await API.matchEpg();
notifications.show({
title: 'EPG matching task started!',
});
} catch (err) {
notifications.show(`Error: ${err.message}`);
}
};
const assignChannels = async () => {
try {
// Get row order from the table
const rowOrder = rows.map((row) => row.original.id);
// Call our custom API endpoint
const result = await API.assignChannelNumbers(rowOrder);
// We might get { message: "Channels have been auto-assigned!" }
notifications.show({
title: result.message || 'Channels assigned',
color: 'green.5',
});
// Refresh the channel list
// await fetchChannels();
API.requeryChannels();
} catch (err) {
console.error(err);
notifications.show({
title: 'Failed to assign channels',
color: 'red.5',
});
}
};
const renderProfileOption = ({ option, checked }) => {
return (
<Group justify="space-between" style={{ width: '100%' }}>
<Box>{option.label}</Box>
{option.value != '0' && (
<ActionIcon
size="xs"
variant="transparent"
color={theme.tailwind.red[6]}
onClick={(e) => {
e.stopPropagation();
deleteProfile(option.value);
}}
>
<SquareMinus />
</ActionIcon>
)}
</Group>
);
};
return (
<Group justify="space-between">
<Group gap={5} style={{ paddingLeft: 10 }}>
<Select
size="xs"
allowDeselect={false}
value={selectedProfileId}
onChange={setSelectedProfileId}
data={Object.values(profiles).map((profile) => ({
label: profile.name,
value: `${profile.id}`,
}))}
renderOption={renderProfileOption}
/>
<Tooltip label="Create Profile">
<CreateProfilePopover />
</Tooltip>
</Group>
<Box
style={{
display: 'flex',
justifyContent: 'flex-end',
padding: 10,
}}
>
<Flex gap={6}>
<Button
leftSection={<SquareMinus size={18} />}
variant="default"
size="xs"
onClick={deleteChannels}
disabled={selectedTableIds.length == 0}
>
Remove
</Button>
<Tooltip label="Assign Channel #s">
<Button
leftSection={<ArrowDown01 size={18} />}
variant="default"
size="xs"
onClick={assignChannels}
p={5}
>
Assign
</Button>
</Tooltip>
<Tooltip label="Auto-Match EPG">
<Button
leftSection={<Binary size={18} />}
variant="default"
size="xs"
onClick={matchEpg}
p={5}
>
Auto-Match
</Button>
</Tooltip>
<Button
leftSection={<SquarePlus size={18} />}
variant="light"
size="xs"
onClick={() => editChannel()}
p={5}
color={theme.tailwind.green[5]}
style={{
borderWidth: '1px',
borderColor: theme.tailwind.green[5],
color: 'white',
}}
>
Add
</Button>
</Flex>
</Box>
</Group>
);
};
export default ChannelTableHeader;

View file

@ -0,0 +1,86 @@
import { Box, Button, Center, Text, useMantineTheme } from '@mantine/core';
import ghostImage from '../../../images/ghost.svg';
import { SquarePlus } from 'lucide-react';
const ChannelsTableOnboarding = ({ editChannel }) => {
const theme = useMantineTheme();
return (
<Box
style={{
paddingTop: 20,
bgcolor: theme.palette.background.paper,
}}
>
<Center>
<Box
style={{
textAlign: 'center',
width: '55%',
}}
>
<Text
style={{
fontFamily: 'Inter, sans-serif',
fontWeight: 400,
fontSize: '20px',
lineHeight: '28px',
letterSpacing: '-0.3px',
color: theme.palette.text.secondary,
mb: 1,
}}
>
Its recommended to create channels after adding your M3U or
streams.
</Text>
<Text
style={{
fontFamily: 'Inter, sans-serif',
fontWeight: 400,
fontSize: '16px',
lineHeight: '24px',
letterSpacing: '-0.2px',
color: theme.palette.text.secondary,
mb: 2,
}}
>
You can still create channels without streams if youd like, and map
them later.
</Text>
<Button
leftSection={<SquarePlus size={18} />}
variant="light"
size="xs"
onClick={() => editChannel()}
color="gray"
style={{
marginTop: 20,
borderWidth: '1px',
borderColor: 'gray',
color: 'white',
}}
>
Create Channel
</Button>
</Box>
</Center>
<Center>
<Box
component="img"
src={ghostImage}
alt="Ghost"
style={{
paddingTop: 30,
width: '120px',
height: 'auto',
opacity: 0.2,
pointerEvents: 'none',
}}
/>
</Center>
</Box>
);
};
export default ChannelsTableOnboarding;

View file

@ -30,6 +30,7 @@ const CustomTable = ({ table }) => {
bodyCellRenderFns={table.bodyCellRenderFns}
expandedRowIds={table.expandedRowIds}
expandedRowRenderer={table.expandedRowRenderer}
renderBodyCell={table.renderBodyCell}
/>
</Box>
);

View file

@ -3,9 +3,9 @@ import { flexRender } from '@tanstack/react-table';
const CustomTableBody = ({
getRowModel,
bodyCellRenderFns,
expandedRowIds,
expandedRowRenderer,
renderBodyCell,
}) => {
const renderExpandedRow = (row) => {
if (expandedRowRenderer) {
@ -15,9 +15,11 @@ const CustomTableBody = ({
return <></>;
};
const rows = getRowModel().rows;
return (
<Box className="tbody">
{getRowModel().rows.map((row, index) => (
{rows.map((row, index) => (
<Box>
<Box
key={`tr-${row.id}`}
@ -44,12 +46,7 @@ const CustomTableBody = ({
}}
>
<Flex align="center" style={{ height: '100%' }}>
{bodyCellRenderFns[cell.column.id]
? bodyCellRenderFns[cell.column.id](cell)
: flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
{renderBodyCell({ row, cell })}
</Flex>
</Box>
);

View file

@ -13,6 +13,7 @@ import { ChevronDown, ChevronRight } from 'lucide-react';
const useTable = ({
allRowIds,
headerCellRenderFns = {},
bodyCellRenderFns = {},
filters = {},
sorting = [],
expandedRowRenderer = () => <></>,
@ -25,6 +26,10 @@ const useTable = ({
const rowCount = allRowIds.length;
const table = useReactTable({
defaultColumn: {
size: undefined,
minSize: 0,
},
...options,
state: {
data: options.data,
@ -64,8 +69,6 @@ const useTable = ({
}
};
const rows = table.getRowModel().rows;
const onRowExpansion = (row) => {
let isExpanded = false;
setExpandedRowIds((prev) => {
@ -75,43 +78,14 @@ const useTable = ({
updateSelectedTableIds([row.original.id]);
};
const renderHeaderCell = useCallback(
(header) => {
if (table.headerCellRenderFns && table.headerCellRenderFns[header.id]) {
return table.headerCellRenderFns[header.id](header);
}
const renderBodyCell = ({ row, cell }) => {
if (bodyCellRenderFns[cell.column.id]) {
return bodyCellRenderFns[cell.column.id]({ row, cell });
}
switch (header.id) {
case 'select':
return (
<Center style={{ width: '100%' }}>
<Checkbox
size="xs"
checked={
rowCount == 0 ? false : selectedTableIds.length == rowCount
}
indeterminate={
selectedTableIds.length > 0 &&
selectedTableIds.length !== rowCount
}
onChange={onSelectAllChange}
/>
</Center>
);
default:
return flexRender(
header.column.columnDef.header,
header.getContext()
);
}
},
[filters, selectedTableIds, rowCount, onSelectAllChange, sorting]
);
const bodyCellRenderFns = {
select: useCallback(
({ row }) => {
const isExpanded = expandedRowIds.includes(row.original.id);
switch (cell.column.id) {
case 'select':
return (
<Center style={{ width: '100%' }}>
<Checkbox
@ -129,23 +103,25 @@ const useTable = ({
/>
</Center>
);
},
[rows, selectedTableIdsSet]
),
expand: useCallback(({ row }) => {
const isExpanded = expandedRowIds.includes(row.original.id);
case 'expand':
return (
<Center
style={{ width: '100%', cursor: 'pointer' }}
onClick={() => {
onRowExpansion(row);
}}
>
{isExpanded ? (
<ChevronDown size={16} />
) : (
<ChevronRight size={16} />
)}
</Center>
);
return (
<Center
style={{ width: '100%', cursor: 'pointer' }}
onClick={() => {
onRowExpansion(row);
}}
>
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</Center>
);
}),
default:
return flexRender(cell.column.columnDef.cell, cell.getContext());
}
};
// Return both the table instance and your custom methods
@ -162,6 +138,7 @@ const useTable = ({
selectedTableIdsSet,
expandedRowIds,
expandedRowRenderer,
setSelectedTableIds,
}),
[selectedTableIdsSet, expandedRowIds]
);
@ -169,8 +146,8 @@ const useTable = ({
return {
...tableInstance,
headerCellRenderFns,
renderHeaderCell,
bodyCellRenderFns,
renderBodyCell,
};
};

View file

@ -1,4 +1,10 @@
import { useEffect, useMemo, useCallback, useState, useRef } from 'react';
import React, {
useEffect,
useMemo,
useCallback,
useState,
useRef,
} from 'react';
import { MantineReactTable, useMantineReactTable } from 'mantine-react-table';
import API from '../../api';
import { TableHelper } from '../../helpers';
@ -12,6 +18,9 @@ import {
SquareMinus,
EllipsisVertical,
Copy,
ArrowUpDown,
ArrowUpNarrowWide,
ArrowDownWideNarrow,
} from 'lucide-react';
import {
TextInput,
@ -43,6 +52,121 @@ import { useNavigate } from 'react-router-dom';
import useSettingsStore from '../../store/settings';
import useVideoStore from '../../store/useVideoStore';
import useChannelsTableStore from '../../store/channelsTable';
import { CustomTable, useTable } from './CustomTable';
const StreamRowActions = ({
theme,
row,
editStream,
deleteStream,
handleWatchStream,
selectedChannelIds,
}) => {
const channelSelectionStreams = useChannelsTableStore(
(state) =>
state.channels.find((chan) => chan.id === selectedChannelIds[0])?.streams
);
const fetchLogos = useChannelsStore((s) => s.fetchLogos);
const createChannelFromStream = async () => {
await API.createChannelFromStream({
name: row.original.name,
channel_number: null,
stream_id: row.original.id,
});
await API.requeryChannels();
fetchLogos();
};
const addStreamToChannel = async () => {
await API.updateChannel({
id: selectedChannelIds[0],
stream_ids: [
...new Set(
channelSelectionStreams
.map((stream) => stream.id)
.concat([row.original.id])
),
],
});
await API.requeryChannels();
};
const onEdit = useCallback(() => {
editStream(row.original);
}, []);
const onDelete = useCallback(() => {
deleteStream(row.original.id);
}, []);
const onPreview = useCallback(() => {
handleWatchStream(row.original.stream_hash);
}, []);
return (
<>
<Tooltip label="Add to Channel">
<ActionIcon
size="xs"
color={theme.tailwind.blue[6]}
variant="transparent"
onClick={addStreamToChannel}
style={{ background: 'none' }}
disabled={
selectedChannelIds.length !== 1 ||
(channelSelectionStreams &&
channelSelectionStreams
.map((stream) => stream.id)
.includes(row.original.id))
}
>
<ListPlus size="18" fontSize="small" />
</ActionIcon>
</Tooltip>
<Tooltip label="Create New Channel">
<ActionIcon
size="xs"
color={theme.tailwind.green[5]}
variant="transparent"
onClick={createChannelFromStream}
>
<SquarePlus size="18" fontSize="small" />
</ActionIcon>
</Tooltip>
<Menu>
<Menu.Target>
<ActionIcon variant="transparent" size="xs">
<EllipsisVertical size="18" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item leftSection={<Copy size="14" />}>
<CopyButton value={row.original.url}>
{({ copied, copy }) => (
<UnstyledButton variant="unstyled" size="xs" onClick={copy}>
<Text size="xs">{copied ? 'Copied!' : 'Copy URL'}</Text>
</UnstyledButton>
)}
</CopyButton>
</Menu.Item>
<Menu.Item onClick={onEdit} disabled={!row.original.is_custom}>
<Text size="xs">Edit</Text>
</Menu.Item>
<Menu.Item onClick={onDelete} disabled={!row.original.is_custom}>
<Text size="xs">Delete Stream</Text>
</Menu.Item>
<Menu.Item onClick={onPreview}>
<Text size="xs">Preview Stream</Text>
</Menu.Item>
</Menu.Dropdown>
</Menu>
</>
);
};
const StreamsTable = ({}) => {
const theme = useMantineTheme();
@ -50,7 +174,7 @@ const StreamsTable = ({}) => {
/**
* useState
*/
const [rowSelection, setRowSelection] = useState([]);
const [allRowIds, setAllRowIds] = useState([]);
const [stream, setStream] = useState(null);
const [modalOpen, setModalOpen] = useState(false);
const [groupOptions, setGroupOptions] = useState([]);
@ -105,91 +229,52 @@ const StreamsTable = ({}) => {
*/
const columns = useMemo(
() => [
{
id: 'actions',
size: 60,
},
{
id: 'select',
size: 30,
},
{
header: 'Name',
accessorKey: 'name',
Header: ({ column }) => (
<TextInput
name="name"
placeholder="Name"
value={filters.name || ''}
onClick={(e) => e.stopPropagation()}
onChange={handleFilterChange}
size="xs"
variant="unstyled"
className="table-input-header"
/>
),
Cell: ({ cell }) => (
<div
cell: ({ getValue }) => (
<Box
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{cell.getValue()}
</div>
<Text size="sm">{getValue()}</Text>
</Box>
),
},
{
header: 'Group',
id: 'group',
accessorFn: (row) =>
channelGroups[row.channel_group]
? channelGroups[row.channel_group].name
: '',
size: 100,
Header: ({ column }) => (
<Box onClick={handleSelectClick} style={{ width: '100%' }}>
<MultiSelect
placeholder="Group"
searchable
size="xs"
nothingFoundMessage="No options"
onClick={handleSelectClick}
onChange={handleGroupChange}
data={groupOptions}
variant="unstyled"
className="table-input-header custom-multiselect"
clearable
/>
</Box>
),
Cell: ({ cell }) => (
<div
cell: ({ getValue }) => (
<Box
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{cell.getValue()}
</div>
<Text size="xs">{getValue()}</Text>
</Box>
),
},
{
header: 'M3U',
size: 75,
id: 'm3u',
size: 150,
accessorFn: (row) =>
playlists.find((playlist) => playlist.id === row.m3u_account)?.name,
Header: ({ column }) => (
<Box onClick={handleSelectClick}>
<Select
placeholder="M3U"
searchable
size="xs"
nothingFoundMessage="No options"
onClick={handleSelectClick}
onChange={handleM3UChange}
data={playlists.map((playlist) => ({
label: playlist.name,
value: `${playlist.id}`,
}))}
variant="unstyled"
className="table-input-header"
/>
</Box>
),
},
],
[playlists, groupOptions, filters, channelGroups]
@ -221,8 +306,6 @@ const StreamsTable = ({}) => {
};
const fetchData = useCallback(async () => {
setIsLoading(true);
const params = new URLSearchParams();
params.append('page', pagination.pageIndex + 1);
params.append('page_size', pagination.pageSize);
@ -241,6 +324,8 @@ const StreamsTable = ({}) => {
try {
const result = await API.queryStreams(params);
const ids = await API.getAllStreamIds(params);
setAllRowIds(ids);
setData(result.results);
setRowCount(result.count);
setPageCount(Math.ceil(result.count / pagination.pageSize));
@ -258,18 +343,6 @@ const StreamsTable = ({}) => {
// Generate the string
setPaginationString(`${startItem} to ${endItem} of ${result.count}`);
const newSelection = {};
result.results.forEach((item, index) => {
if (selectedStreamIds.includes(item.id)) {
newSelection[index] = true;
}
});
// Only update rowSelection if it's different
if (JSON.stringify(newSelection) !== JSON.stringify(rowSelection)) {
setRowSelection(newSelection);
}
} catch (error) {
console.error('Error fetching data:', error);
}
@ -280,17 +353,6 @@ const StreamsTable = ({}) => {
setIsLoading(false);
}, [pagination, sorting, debouncedFilters]);
// Fallback: Individual creation (optional)
const createChannelFromStream = async (stream) => {
await API.createChannelFromStream({
name: stream.name,
channel_number: null,
stream_id: stream.id,
});
await API.requeryChannels();
fetchLogos();
};
// Bulk creation: create channels from selected streams in one API call
const createChannelsFromStreams = async () => {
setIsLoading(true);
@ -339,56 +401,8 @@ const StreamsTable = ({}) => {
await API.requeryChannels();
};
const addStreamToChannel = async (streamId) => {
await API.updateChannel({
id: selectedChannelIds[0],
stream_ids: [
...new Set(
channelSelectionStreams.map((stream) => stream.id).concat([streamId])
),
],
});
await API.requeryChannels();
};
const onRowSelectionChange = (updater) => {
setRowSelection((prevRowSelection) => {
const newRowSelection =
typeof updater === 'function' ? updater(prevRowSelection) : updater;
const updatedSelected = new Set([...selectedStreamIds]);
table.getRowModel().rows.forEach((row) => {
if (newRowSelection[row.id] === undefined || !newRowSelection[row.id]) {
updatedSelected.delete(row.original.id);
} else {
updatedSelected.add(row.original.id);
}
});
setSelectedStreamIds([...updatedSelected]);
return newRowSelection;
});
};
const onSelectAllChange = async (e) => {
const selectAll = e.target.checked;
if (selectAll) {
// Get all stream IDs for current view
const params = new URLSearchParams();
Object.entries(debouncedFilters).forEach(([key, value]) => {
if (value) params.append(key, value);
});
const ids = await API.getAllStreamIds(params);
setSelectedStreamIds(ids);
} else {
setSelectedStreamIds([]);
}
const newSelection = {};
table.getRowModel().rows.forEach((item, index) => {
newSelection[index] = selectAll;
});
setRowSelection(newSelection);
const onRowSelectionChange = (updatedIds) => {
setSelectedStreamIds(updatedIds);
};
const onPageSizeChange = (e) => {
@ -409,16 +423,6 @@ const StreamsTable = ({}) => {
});
};
const onPaginationChange = (updater) => {
const newPagination = updater(pagination);
if (JSON.stringify(newPagination) === JSON.stringify(pagination)) {
// Prevent infinite re-render when there are no results
return;
}
setPagination(updater);
};
function handleWatchStream(streamHash) {
let vidUrl = `/proxy/ts/stream/${streamHash}`;
if (env_mode == 'dev') {
@ -427,202 +431,144 @@ const StreamsTable = ({}) => {
showVideo(vidUrl);
}
const table = useMantineReactTable({
...TableHelper.defaultProperties,
const onSortingChange = (column) => {
const sortField = sorting[0]?.id;
const sortDirection = sorting[0]?.desc;
if (sortField == column) {
if (sortDirection == false) {
setSorting([
{
id: column,
desc: true,
},
]);
} else {
setSorting([]);
}
} else {
setSorting([
{
id: column,
desc: false,
},
]);
}
};
const renderHeaderCell = (header) => {
let sortingIcon = ArrowUpDown;
if (sorting[0]?.id == header.id) {
if (sorting[0].desc === false) {
sortingIcon = ArrowUpNarrowWide;
} else {
sortingIcon = ArrowDownWideNarrow;
}
}
switch (header.id) {
case 'name':
return (
<Flex gap="sm">
<TextInput
name="name"
placeholder="Name"
value={filters.name || ''}
onClick={(e) => e.stopPropagation()}
onChange={handleFilterChange}
size="xs"
variant="unstyled"
className="table-input-header"
/>
<Center>
{React.createElement(sortingIcon, {
onClick: () => onSortingChange('name'),
size: 14,
})}
</Center>
</Flex>
);
case 'group':
return (
<Box onClick={handleSelectClick} style={{ width: '100%' }}>
<MultiSelect
placeholder="Group"
searchable
size="xs"
nothingFoundMessage="No options"
onClick={handleSelectClick}
onChange={handleGroupChange}
data={groupOptions}
variant="unstyled"
className="table-input-header custom-multiselect"
clearable
/>
</Box>
);
case 'm3u':
return (
<Box onClick={handleSelectClick}>
<Select
placeholder="M3U"
searchable
clearable
size="xs"
nothingFoundMessage="No options"
onClick={handleSelectClick}
onChange={handleM3UChange}
data={playlists.map((playlist) => ({
label: playlist.name,
value: `${playlist.id}`,
}))}
variant="unstyled"
className="table-input-header"
/>
</Box>
);
}
};
const renderBodyCell = useCallback(
({ cell, row }) => {
switch (cell.column.id) {
case 'actions':
return (
<StreamRowActions
theme={theme}
row={row}
editStream={editStream}
deleteStream={deleteStream}
handleWatchStream={handleWatchStream}
selectedChannelIds={selectedChannelIds}
channelSelectionStreams={channelSelectionStreams}
/>
);
}
},
[selectedChannelIds, channelSelectionStreams]
);
const table = useTable({
columns,
data,
enablePagination: true,
manualPagination: true,
enableTopToolbar: false,
enableRowVirtualization: true,
renderTopToolbar: () => null, // Removes the entire top toolbar
renderToolbarInternalActions: () => null,
rowVirtualizerInstanceRef,
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
enableBottomToolbar: true,
renderBottomToolbar: ({ table }) => (
<Group
gap={5}
justify="center"
style={{ padding: 8, borderTop: '1px solid #666' }}
>
<Text size="xs">Page Size</Text>
<NativeSelect
size="xxs"
value={pagination.pageSize}
data={['25', '50', '100', '250', '500', '1000']}
onChange={onPageSizeChange}
style={{ paddingRight: 20 }}
/>
<Pagination
total={pageCount}
value={pagination.pageIndex + 1}
onChange={onPageIndexChange}
size="xs"
withEdges
style={{ paddingRight: 20 }}
/>
<Text size="xs">{paginationString}</Text>
</Group>
),
enableStickyHeader: true,
// onPaginationChange: onPaginationChange,
rowCount: rowCount,
enableRowSelection: true,
mantineSelectAllCheckboxProps: {
checked: selectedStreamIds.length == rowCount,
indeterminate:
selectedStreamIds.length > 0 && selectedStreamIds.length !== rowCount,
onChange: onSelectAllChange,
size: 'xs',
},
muiPaginationProps: {
size: 'small',
rowsPerPageOptions: [25, 50, 100, 250, 500, 1000, 10000],
labelRowsPerPage: 'Rows per page',
},
onSortingChange: setSorting,
allRowIds,
filters,
pagination,
sorting,
onRowSelectionChange: onRowSelectionChange,
initialState: {
density: 'compact',
manualPagination: true,
manualSorting: true,
manualFiltering: true,
enableRowSelection: true,
headerCellRenderFns: {
name: renderHeaderCell,
group: renderHeaderCell,
m3u: renderHeaderCell,
},
state: {
isLoading,
sorting,
// pagination,
rowSelection,
},
enableRowActions: true,
positionActionsColumn: 'first',
enableHiding: false,
// you can still use the custom toolbar callback if you like
renderTopToolbarCustomActions: ({ table }) => {
const selectedRowCount = table.getSelectedRowModel().rows.length;
// optionally do something with selectedRowCount
},
renderRowActions: ({ row }) => (
<>
<Tooltip label="Add to Channel">
<ActionIcon
size="xs"
color={theme.tailwind.blue[6]}
variant="transparent"
onClick={() => addStreamToChannel(row.original.id)}
style={{ background: 'none' }}
disabled={
selectedChannelIds.length !== 1 ||
(channelSelectionStreams &&
channelSelectionStreams
.map((stream) => stream.id)
.includes(row.original.id))
}
>
<ListPlus size="18" fontSize="small" />
</ActionIcon>
</Tooltip>
<Tooltip label="Create New Channel">
<ActionIcon
size="xs"
color={theme.tailwind.green[5]}
variant="transparent"
onClick={() => createChannelFromStream(row.original)}
>
<SquarePlus size="18" fontSize="small" />
</ActionIcon>
</Tooltip>
<Menu>
<Menu.Target>
<ActionIcon variant="transparent" size="xs">
<EllipsisVertical size="18" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item leftSection={<Copy size="14" />}>
<CopyButton value={row.original.url}>
{({ copied, copy }) => (
<UnstyledButton variant="unstyled" size="xs" onClick={copy}>
<Text size="xs">{copied ? 'Copied!' : 'Copy URL'}</Text>
</UnstyledButton>
)}
</CopyButton>
</Menu.Item>
<Menu.Item
onClick={() => editStream(row.original)}
disabled={!row.original.is_custom}
>
<Text size="xs">Edit</Text>
</Menu.Item>
<Menu.Item
onClick={() => deleteStream(row.original.id)}
disabled={!row.original.is_custom}
>
<Text size="xs">Delete Stream</Text>
</Menu.Item>
<Menu.Item
onClick={() => handleWatchStream(row.original.stream_hash)}
>
<Text size="xs">Preview Stream</Text>
</Menu.Item>
</Menu.Dropdown>
</Menu>
</>
),
mantineTableContainerProps: {
style: {
height: 'calc(100vh - 150px)',
overflowY: 'auto',
},
},
displayColumnDefOptions: {
'mrt-row-actions': {
mantineTableHeadCellProps: {
align: 'left',
style: {
minWidth: '65px',
maxWidth: '65px',
paddingLeft: 10,
fontWeight: 'normal',
color: 'rgb(207,207,207)',
backgroundColor: '#3F3F46',
},
},
mantineTableBodyCellProps: {
style: {
minWidth: '65px',
maxWidth: '65px',
// paddingLeft: 0,
// paddingRight: 10,
},
},
},
'mrt-row-select': {
size: 10,
maxSize: 10,
mantineTableHeadCellProps: {
align: 'right',
style: {
paddding: 0,
// paddingLeft: 7,
width: '20px',
minWidth: '20px',
backgroundColor: '#3F3F46',
},
},
mantineTableBodyCellProps: {
align: 'right',
style: {
paddingLeft: 0,
width: '20px',
minWidth: '20px',
},
},
},
bodyCellRenderFns: {
actions: renderBodyCell,
},
});
@ -800,7 +746,63 @@ const StreamsTable = ({}) => {
</Card>
</Center>
)}
{initialDataCount > 0 && <MantineReactTable table={table} />}
{initialDataCount > 0 && (
<Box
style={{
display: 'flex',
flexDirection: 'column',
height: 'calc(100vh - 110px)',
}}
>
<Box
style={{
flex: 1,
overflowY: 'auto',
overflowX: 'hidden',
border: 'solid 1px rgb(68,68,68)',
borderRadius: 'var(--mantine-radius-default)',
}}
>
<CustomTable table={table} />
</Box>
<Box
style={{
position: 'sticky',
bottom: 0,
zIndex: 3,
backgroundColor: '#27272A',
}}
>
<Group
gap={5}
justify="center"
style={{
padding: 8,
borderTop: '1px solid #666',
}}
>
<Text size="xs">Page Size</Text>
<NativeSelect
size="xxs"
value={pagination.pageSize}
data={['25', '50', '100', '250']}
onChange={onPageSizeChange}
style={{ paddingRight: 20 }}
/>
<Pagination
total={pageCount}
value={pagination.pageIndex + 1}
onChange={onPageIndexChange}
size="xs"
withEdges
style={{ paddingRight: 20 }}
/>
<Text size="xs">{paginationString}</Text>
</Group>
</Box>
</Box>
)}
</Paper>
<StreamForm
stream={stream}

View file

@ -6,6 +6,7 @@ import API from '../api';
const useChannelsTableStore = create((set, get) => ({
channels: [],
pageCount: 0,
totalCount: 0,
sorting: [{ id: 'channel_number', desc: false }],
pagination: {
pageIndex: 0,
@ -17,6 +18,7 @@ const useChannelsTableStore = create((set, get) => ({
set((state) => {
return {
channels: results,
totalCount: count,
pageCount: Math.ceil(count / params.get('page_size')),
};
});

View file

@ -60,3 +60,26 @@ export function sleep(ms) {
export const getDescendantProp = (obj, path) =>
path.split('.').reduce((acc, part) => acc && acc[part], obj);
export const copyToClipboard = async (value) => {
let copied = false;
if (navigator.clipboard) {
// Modern method, using navigator.clipboard
try {
await navigator.clipboard.writeText(value);
copied = true;
} catch (err) {
console.error('Failed to copy: ', err);
}
}
if (!copied) {
// Fallback method for environments without clipboard support
const textarea = document.createElement('textarea');
textarea.value = value;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
}
};