mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-24 02:28:12 +00:00
refactored streams table as well, broke out custom table into smaller components
This commit is contained in:
parent
03f6c77391
commit
0dc62fb039
10 changed files with 948 additions and 866 deletions
|
|
@ -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]}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}}
|
||||
>
|
||||
It’s 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 you’d 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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
}}
|
||||
>
|
||||
It’s 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 you’d 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;
|
||||
|
|
@ -30,6 +30,7 @@ const CustomTable = ({ table }) => {
|
|||
bodyCellRenderFns={table.bodyCellRenderFns}
|
||||
expandedRowIds={table.expandedRowIds}
|
||||
expandedRowRenderer={table.expandedRowRenderer}
|
||||
renderBodyCell={table.renderBodyCell}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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')),
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue