From 0dc62fb0398071e868fe618dd567a4adce41b16e Mon Sep 17 00:00:00 2001 From: dekzter Date: Wed, 23 Apr 2025 18:04:00 -0400 Subject: [PATCH] refactored streams table as well, broke out custom table into smaller components --- frontend/src/components/forms/Channel.jsx | 92 ++- .../src/components/tables/ChannelsTable.jsx | 591 ++++----------- .../ChannelsTable/ChannelTableHeader.jsx | 241 +++++++ .../ChannelsTable/ChannelsTableOnboarding.jsx | 86 +++ .../tables/CustomTable/CustomTable.jsx | 1 + .../tables/CustomTable/CustomTableBody.jsx | 13 +- .../components/tables/CustomTable/index.jsx | 87 +-- .../src/components/tables/StreamsTable.jsx | 678 +++++++++--------- frontend/src/store/channelsTable.jsx | 2 + frontend/src/utils.js | 23 + 10 files changed, 948 insertions(+), 866 deletions(-) create mode 100644 frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx create mode 100644 frontend/src/components/tables/ChannelsTable/ChannelsTableOnboarding.jsx diff --git a/frontend/src/components/forms/Channel.jsx b/frontend/src/components/forms/Channel.jsx index 6e53884e..da03dfa3 100644 --- a/frontend/src/components/forms/Channel.jsx +++ b/frontend/src/components/forms/Channel.jsx @@ -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 ( <> { /> - { }))} size="xs" style={{ flex: 1 }} - /> + /> */} { - 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 ( - - - - - - - - - - setName(event.currentTarget.value)} - size="xs" - /> - - - - - - - - ); -}); - 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( }> - - {({ copied, copy }) => ( - - {copied ? 'Copied!' : 'Copy URL'} - - )} - + copyToClipboard(getChannelURL(row.original))} + > + Copy URL + { + 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 ; - }, [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 ( - - {option.label} - {option.value != '0' && ( - { - e.stopPropagation(); - deleteProfile(option.value); - }} - > - - - )} - + const EnabledHeaderSwitch = useCallback(() => { + let enabled = false; + for (const id of selectedChannelIds) { + if (selectedProfileChannelIds.has(id)) { + enabled = true; + break; + } + } + + const toggleSelected = () => { + toggleChannelEnabled(selectedChannelIds, !enabled); + }; + + return ; + }, [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, }} > {getValue()} ), - style: { - justifyContent: 'left', - }, }, { + id: 'channel_group', accessorFn: (row) => row.channel_group_id && channelGroups ? channelGroups[row.channel_group_id].name : '', - id: 'channel_group', cell: ({ getValue }) => ( { ); }, - 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 (
@@ -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 ( { headerCellRenderFns: { name: renderHeaderCell, channel_group: renderHeaderCell, - enabled: () => ( -
- -
- ), + enabled: renderHeaderCell, }, }); + const rows = table.getRowModel().rows; + return ( {/* Header Row: outside the Paper */} @@ -952,7 +766,7 @@ const ChannelsTable = ({}) => { - + { - + { - + { backgroundColor: '#27272A', }} > - {/* Top toolbar with Remove, Assign, Auto-match, and Add buttons */} - - - ({ + label: profile.name, + value: `${profile.id}`, + }))} + renderOption={renderProfileOption} + /> + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default ChannelTableHeader; diff --git a/frontend/src/components/tables/ChannelsTable/ChannelsTableOnboarding.jsx b/frontend/src/components/tables/ChannelsTable/ChannelsTableOnboarding.jsx new file mode 100644 index 00000000..0cedcd00 --- /dev/null +++ b/frontend/src/components/tables/ChannelsTable/ChannelsTableOnboarding.jsx @@ -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 ( + +
+ + + It’s recommended to create channels after adding your M3U or + streams. + + + You can still create channels without streams if you’d like, and map + them later. + + + +
+ +
+ +
+
+ ); +}; + +export default ChannelsTableOnboarding; diff --git a/frontend/src/components/tables/CustomTable/CustomTable.jsx b/frontend/src/components/tables/CustomTable/CustomTable.jsx index 4aba45d5..16138f7c 100644 --- a/frontend/src/components/tables/CustomTable/CustomTable.jsx +++ b/frontend/src/components/tables/CustomTable/CustomTable.jsx @@ -30,6 +30,7 @@ const CustomTable = ({ table }) => { bodyCellRenderFns={table.bodyCellRenderFns} expandedRowIds={table.expandedRowIds} expandedRowRenderer={table.expandedRowRenderer} + renderBodyCell={table.renderBodyCell} />
); diff --git a/frontend/src/components/tables/CustomTable/CustomTableBody.jsx b/frontend/src/components/tables/CustomTable/CustomTableBody.jsx index 4b6657b1..ad523a93 100644 --- a/frontend/src/components/tables/CustomTable/CustomTableBody.jsx +++ b/frontend/src/components/tables/CustomTable/CustomTableBody.jsx @@ -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 ( - {getRowModel().rows.map((row, index) => ( + {rows.map((row, index) => ( - {bodyCellRenderFns[cell.column.id] - ? bodyCellRenderFns[cell.column.id](cell) - : flexRender( - cell.column.columnDef.cell, - cell.getContext() - )} + {renderBodyCell({ row, cell })} ); diff --git a/frontend/src/components/tables/CustomTable/index.jsx b/frontend/src/components/tables/CustomTable/index.jsx index 115e0ff6..19d2428c 100644 --- a/frontend/src/components/tables/CustomTable/index.jsx +++ b/frontend/src/components/tables/CustomTable/index.jsx @@ -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 ( -
- 0 && - selectedTableIds.length !== rowCount - } - onChange={onSelectAllChange} - /> -
- ); - - 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 (
); - }, - [rows, selectedTableIdsSet] - ), - expand: useCallback(({ row }) => { - const isExpanded = expandedRowIds.includes(row.original.id); + case 'expand': + return ( +
{ + onRowExpansion(row); + }} + > + {isExpanded ? ( + + ) : ( + + )} +
+ ); - return ( -
{ - onRowExpansion(row); - }} - > - {isExpanded ? : } -
- ); - }), + 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, }; }; diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx index 4a27baa1..954c631a 100644 --- a/frontend/src/components/tables/StreamsTable.jsx +++ b/frontend/src/components/tables/StreamsTable.jsx @@ -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 ( + <> + + stream.id) + .includes(row.original.id)) + } + > + + + + + + + + + + + + + + + + + + + }> + + {({ copied, copy }) => ( + + {copied ? 'Copied!' : 'Copy URL'} + + )} + + + + Edit + + + Delete Stream + + + Preview Stream + + + + + ); +}; 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 }) => ( - e.stopPropagation()} - onChange={handleFilterChange} - size="xs" - variant="unstyled" - className="table-input-header" - /> - ), - Cell: ({ cell }) => ( -
( + - {cell.getValue()} -
+ {getValue()} +
), }, { - header: 'Group', + id: 'group', accessorFn: (row) => channelGroups[row.channel_group] ? channelGroups[row.channel_group].name : '', - size: 100, - Header: ({ column }) => ( - - - - ), - Cell: ({ cell }) => ( -
( + - {cell.getValue()} -
+ {getValue()} +
), }, { - header: 'M3U', - size: 75, + id: 'm3u', + size: 150, accessorFn: (row) => playlists.find((playlist) => playlist.id === row.m3u_account)?.name, - Header: ({ column }) => ( - - ({ + label: playlist.name, + value: `${playlist.id}`, + }))} + variant="unstyled" + className="table-input-header" + /> + + ); + } + }; + + const renderBodyCell = useCallback( + ({ cell, row }) => { + switch (cell.column.id) { + case 'actions': + return ( + + ); + } + }, + [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 }) => ( - - Page Size - - - {paginationString} - - ), - 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 }) => ( - <> - - addStreamToChannel(row.original.id)} - style={{ background: 'none' }} - disabled={ - selectedChannelIds.length !== 1 || - (channelSelectionStreams && - channelSelectionStreams - .map((stream) => stream.id) - .includes(row.original.id)) - } - > - - - - - - createChannelFromStream(row.original)} - > - - - - - - - - - - - - - }> - - {({ copied, copy }) => ( - - {copied ? 'Copied!' : 'Copy URL'} - - )} - - - editStream(row.original)} - disabled={!row.original.is_custom} - > - Edit - - deleteStream(row.original.id)} - disabled={!row.original.is_custom} - > - Delete Stream - - handleWatchStream(row.original.stream_hash)} - > - Preview Stream - - - - - ), - 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 = ({}) => {
)} - {initialDataCount > 0 && } + {initialDataCount > 0 && ( + + + + + + + + Page Size + + + {paginationString} + + + + )} ({ 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')), }; }); diff --git a/frontend/src/utils.js b/frontend/src/utils.js index bd62e5c0..619823c8 100644 --- a/frontend/src/utils.js +++ b/frontend/src/utils.js @@ -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); + } +};