diff --git a/frontend/src/components/tables/ChannelsTable.js b/frontend/src/components/tables/ChannelsTable.js index 05d3bae0..9c88b188 100644 --- a/frontend/src/components/tables/ChannelsTable.js +++ b/frontend/src/components/tables/ChannelsTable.js @@ -1,44 +1,56 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; -import { - MaterialReactTable, - useMaterialReactTable, -} from 'material-react-table'; +// src/components/tables/ChannelsTable.js + +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Box, - Grid2, - Stack, Typography, - Tooltip, - IconButton, Button, - ButtonGroup, - Snackbar, + IconButton, + Tooltip, Popover, + Snackbar, TextField, Autocomplete, InputAdornment, + Paper, + Grid as Grid2, } from '@mui/material'; -import useChannelsStore from '../../store/channels'; import { - Delete as DeleteIcon, - Edit as EditIcon, Add as AddIcon, + Delete as DeleteIcon, SwapVert as SwapVertIcon, - LiveTv as LiveTvIcon, - ContentCopy, Tv as TvIcon, + LiveTv as LiveTvIcon, + Edit as EditIcon, Clear as ClearIcon, + ContentCopy, + Computer as ComputerIcon, + Description as DescriptionIcon, + Hd as HdIcon, + IndeterminateCheckBox, + CompareArrows, + Code, + AddBox, } from '@mui/icons-material'; -import API from '../../api'; +import { MaterialReactTable, useMaterialReactTable } from 'material-react-table'; +import { styled } from '@mui/material/styles'; + import ChannelForm from '../forms/Channel'; -import { TableHelper } from '../../helpers'; -import utils from '../../utils'; -import logo from '../../images/logo.png'; -import useVideoStore from '../../store/useVideoStore'; +import useChannelsStore from '../../store/channels'; import useSettingsStore from '../../store/settings'; import useStreamsStore from '../../store/streams'; import usePlaylistsStore from '../../store/playlists'; +import useVideoStore from '../../store/useVideoStore'; +import API from '../../api'; +import utils from '../../utils'; +import { TableHelper } from '../../helpers'; +import logo from '../../images/logo.png'; +import ghostImage from '../../images/ghost.svg'; + +/* ----------------------------------------------------------- + 1) Child component: shows Streams when a channel row expands +------------------------------------------------------------ */ const ChannelStreams = ({ channel, isExpanded }) => { const [channelStreams, setChannelStreams] = useState([]); const channelStreamIds = useChannelsStore( @@ -47,26 +59,21 @@ const ChannelStreams = ({ channel, isExpanded }) => { const { playlists } = usePlaylistsStore(); const { streams } = useStreamsStore(); - useEffect( - () => - setChannelStreams( - streams - .filter((stream) => channelStreamIds.includes(stream.id)) - .sort( - (a, b) => - channelStreamIds.indexOf(a.id) - channelStreamIds.indexOf(b.id) - ) - ), - [streams, channelStreamIds] - ); + useEffect(() => { + if (!channelStreamIds) return; + const sorted = streams + .filter((s) => channelStreamIds.includes(s.id)) + .sort( + (a, b) => channelStreamIds.indexOf(a.id) - channelStreamIds.indexOf(b.id) + ); + setChannelStreams(sorted); + }, [streams, channelStreamIds]); const removeStream = async (stream) => { - let streamSet = new Set(channelStreams); - streamSet.delete(stream); - streamSet = Array.from(streamSet); + const newStreamList = channelStreams.filter((s) => s.id !== stream.id); await API.updateChannel({ ...channel, - streams: streamSet.map((stream) => stream.id), + streams: newStreamList.map((s) => s.id), }); }; @@ -75,95 +82,131 @@ const ChannelStreams = ({ channel, isExpanded }) => { data: channelStreams, columns: useMemo( () => [ - { - header: 'Name', - accessorKey: 'name', - }, + { header: 'Name', accessorKey: 'name' }, { header: 'M3U', accessorFn: (row) => - playlists.find((playlist) => playlist.id === row.m3u_account)?.name, + playlists.find((pl) => pl.id === row.m3u_account)?.name, }, ], [playlists] ), - enableKeyboardShortcuts: false, - enableColumnFilters: false, - enableSorting: false, enableBottomToolbar: false, enableTopToolbar: false, - columnFilterDisplayMode: 'popover', - enablePagination: false, - enableRowVirtualization: true, - enableColumnHeaders: false, - rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer - initialState: { - density: 'compact', - }, enableRowActions: true, enableRowOrdering: true, + enableColumnHeaders: false, + enableColumnFilters: false, + enableSorting: false, + enablePagination: false, muiRowDragHandleProps: ({ table }) => ({ onDragEnd: async () => { const { draggingRow, hoveredRow } = table.getState(); - if (hoveredRow && draggingRow) { channelStreams.splice( hoveredRow.index, 0, channelStreams.splice(draggingRow.index, 1)[0] ); - - // setChannelStreams([...channelStreams]); - API.updateChannel({ + await API.updateChannel({ ...channel, - streams: channelStreams.map((stream) => stream.id), + streams: channelStreams.map((s) => s.id), }); } }, }), renderRowActions: ({ row }) => ( - <> - removeStream(row.original)} - > - {/* Small icon size */} - - + removeStream(row.original)}> + + ), }); - if (!isExpanded) { - return <>; - } + if (!isExpanded) return null; return ( - + ); }; -const ChannelsTable = ({}) => { +/* ----------------------------------------------------------- + 2) Custom-styled "chip" buttons for HDHR, M3U, EPG +------------------------------------------------------------ */ +const HDHRButton = styled(Button)(() => ({ + border: '1px solid #a3d977', + color: '#a3d977', + backgroundColor: 'transparent', + textTransform: 'none', + fontSize: '0.85rem', + display: 'flex', + alignItems: 'center', + gap: '4px', + padding: '2px 8px', + minWidth: 'auto', + '&:hover': { + borderColor: '#c2e583', + color: '#c2e583', + backgroundColor: 'rgba(163,217,119,0.1)', + }, +})); + +const M3UButton = styled(Button)(() => ({ + border: '1px solid #5f6dc6', + color: '#5f6dc6', + backgroundColor: 'transparent', + textTransform: 'none', + fontSize: '0.85rem', + display: 'flex', + alignItems: 'center', + gap: '4px', + padding: '2px 8px', + minWidth: 'auto', + '&:hover': { + borderColor: '#7f8de6', + color: '#7f8de6', + backgroundColor: 'rgba(95,109,198,0.1)', + }, +})); + +const EPGButton = styled(Button)(() => ({ + border: '1px solid #707070', + color: '#a0a0a0', + backgroundColor: 'transparent', + textTransform: 'none', + fontSize: '0.85rem', + display: 'flex', + alignItems: 'center', + gap: '4px', + padding: '2px 8px', + minWidth: 'auto', + '&:hover': { + borderColor: '#a0a0a0', + color: '#c0c0c0', + backgroundColor: 'rgba(112,112,112,0.1)', + }, +})); + +/* ----------------------------------------------------------- + 3) Main ChannelsTable component +------------------------------------------------------------ */ +const ChannelsTable = () => { const [channel, setChannel] = useState(null); const [channelModalOpen, setChannelModalOpen] = useState(false); + const [rowSelection, setRowSelection] = useState([]); const [channelGroupOptions, setChannelGroupOptions] = useState([]); - const [anchorEl, setAnchorEl] = useState(null); const [textToCopy, setTextToCopy] = useState(''); const [snackbarMessage, setSnackbarMessage] = useState(''); const [snackbarOpen, setSnackbarOpen] = useState(false); - const [filterValues, setFilterValues] = useState({}); + const [sorting, setSorting] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + const rowVirtualizerInstanceRef = useRef(null); + const outputUrlRef = useRef(null); const { showVideo } = useVideoStore(); const { @@ -172,31 +215,153 @@ const ChannelsTable = ({}) => { fetchChannels, setChannelsPageSelection, } = useChannelsStore(); + const { + environment: { env_mode }, + } = useSettingsStore(); + + useEffect(() => { + if (typeof window !== 'undefined') { + setIsLoading(false); + } + }, []); useEffect(() => { setChannelGroupOptions([ - ...new Set( - Object.values(channels).map((channel) => channel.channel_group?.name) - ), + ...new Set(Object.values(channels).map((c) => c.channel_group?.name)), ]); }, [channels]); + useEffect(() => { + try { + rowVirtualizerInstanceRef.current?.scrollToIndex?.(0); + } catch {} + }, [sorting]); + + useEffect(() => { + const selectedRows = table + .getSelectedRowModel() + .rows.map((row) => row.original); + setChannelsPageSelection(selectedRows); + }, [rowSelection]); + const handleFilterChange = (columnId, value) => { - console.log(columnId); - console.log(value); setFilterValues((prev) => ({ ...prev, [columnId]: value ? value.toLowerCase() : '', })); }; - const outputUrlRef = useRef(null); + const editChannel = (ch = null) => { + setChannel(ch); + setChannelModalOpen(true); + }; + const closeChannelForm = () => { + setChannel(null); + setChannelModalOpen(false); + }; - const { - environment: { env_mode }, - } = useSettingsStore(); + const deleteChannel = async (id) => { + await API.deleteChannel(id); + }; - // Configure columns + const handleWatchStream = (channelNumber) => { + let vidUrl = `/output/stream/${channelNumber}/`; + if (env_mode === 'dev') { + vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`; + } + showVideo(vidUrl); + }; + + const deleteChannels = async () => { + setIsLoading(true); + const selected = table + .getRowModel() + .rows.filter((row) => row.getIsSelected()); + await utils.Limiter( + 4, + selected.map((chan) => () => deleteChannel(chan.original.id)) + ); + setIsLoading(false); + }; + + const assignChannels = async () => { + try { + const rowOrder = table.getRowModel().rows.map((row) => row.original.id); + setIsLoading(true); + const result = await API.assignChannelNumbers(rowOrder); + setIsLoading(false); + setSnackbarMessage(result.message || 'Channels assigned'); + setSnackbarOpen(true); + await fetchChannels(); + } catch (err) { + console.error(err); + setSnackbarMessage('Failed to assign channels'); + setSnackbarOpen(true); + } + }; + + const matchEpg = async () => { + try { + const resp = await fetch('/api/channels/channels/match-epg/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${await API.getAuthToken()}`, + }, + }); + if (resp.ok) { + setSnackbarMessage('EPG matching task started!'); + } else { + const text = await resp.text(); + setSnackbarMessage(`Failed to start EPG matching: ${text}`); + } + } catch (err) { + setSnackbarMessage(`Error: ${err.message}`); + } + setSnackbarOpen(true); + }; + + const closeSnackbar = () => setSnackbarOpen(false); + + // Copy popover logic + const openPopover = Boolean(anchorEl); + const closePopover = () => { + setAnchorEl(null); + setSnackbarMessage(''); + }; + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(textToCopy); + setSnackbarMessage('Copied!'); + } catch (err) { + const inputElement = outputUrlRef.current?.querySelector('input'); + if (inputElement) { + inputElement.focus(); + inputElement.select(); + document.execCommand('copy'); + setSnackbarMessage('Copied!'); + } + } + setSnackbarOpen(true); + }; + + const copyM3UUrl = (event) => { + setAnchorEl(event.currentTarget); + setTextToCopy(`${window.location.protocol}//${window.location.host}/output/m3u`); + }; + const copyEPGUrl = (event) => { + setAnchorEl(event.currentTarget); + setTextToCopy(`${window.location.protocol}//${window.location.host}/output/epg`); + }; + const copyHDHRUrl = (event) => { + setAnchorEl(event.currentTarget); + setTextToCopy(`${window.location.protocol}//${window.location.host}/discover.json`); + }; + + /* -------------------------------------- + Table configuration + --------------------------------------- */ const columns = useMemo( () => [ { @@ -207,45 +372,31 @@ const ChannelsTable = ({}) => { { header: 'Name', accessorKey: 'channel_name', - muiTableHeadCellProps: { - sx: { textAlign: 'center' }, // Center-align the header - }, + muiTableHeadCellProps: { sx: { textAlign: 'center' } }, Header: ({ column }) => ( handleFilterChange(column.id, e.target.value)} size="small" margin="none" fullWidth - sx={ - { - // '& .MuiInputBase-root': { fontSize: '0.875rem' }, // Text size - // '& .MuiInputLabel-root': { fontSize: '0.75rem' }, // Label size - // width: '200px', // Optional: Adjust width - } - } - slotProps={{ - input: { - endAdornment: ( - - handleFilterChange(column.id, '')} // Clear text on click - edge="end" - size="small" - > - - - - ), - }, + InputProps={{ + endAdornment: ( + + handleFilterChange(column.id, '')} + edge="end" + size="small" + > + + + + ), }} /> ), - meta: { - filterVariant: null, - }, }, { header: 'Group', @@ -264,15 +415,9 @@ const ChannelsTable = ({}) => { e.stopPropagation()} - sx={{ - pb: 0.8, - // '& .MuiInputBase-root': { fontSize: '0.875rem' }, // Text size - // '& .MuiInputLabel-root': { fontSize: '0.75rem' }, // Label size - // width: '200px', // Optional: Adjust width - }} + sx={{ pb: 0.8 }} /> )} /> @@ -284,195 +429,16 @@ const ChannelsTable = ({}) => { enableSorting: false, size: 55, Cell: ({ cell }) => ( - + channel logo ), - meta: { - filterVariant: null, - }, }, ], - [channelGroupOptions] + [channelGroupOptions, filterValues] ); - // Access the row virtualizer instance (optional) - const rowVirtualizerInstanceRef = useRef(null); - - const [isLoading, setIsLoading] = useState(true); - const [sorting, setSorting] = useState([]); - - const closeSnackbar = () => setSnackbarOpen(false); - - const editChannel = async (ch = null) => { - setChannel(ch); - setChannelModalOpen(true); - }; - - const deleteChannel = async (id) => { - await API.deleteChannel(id); - }; - - function handleWatchStream(channelNumber) { - let vidUrl = `/output/stream/${channelNumber}/`; - if (env_mode == 'dev') { - vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`; - } - showVideo(vidUrl); - } - - // (Optional) bulk delete, but your endpoint is @TODO - const deleteChannels = async () => { - setIsLoading(true); - const selected = table - .getRowModel() - .rows.filter((row) => row.getIsSelected()); - await utils.Limiter( - 4, - selected.map((chan) => () => deleteChannel(chan.original.id)) - ); - // If you have a real bulk-delete endpoint, call it here: - // await API.deleteChannels(selected.map((sel) => sel.id)); - setIsLoading(false); - }; - - // ───────────────────────────────────────────────────────── - // The "Assign Channels" button logic - // ───────────────────────────────────────────────────────── - const assignChannels = async () => { - try { - // Get row order from the table - const rowOrder = table.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!" } - setSnackbarMessage(result.message || 'Channels assigned'); - setSnackbarOpen(true); - - // Refresh the channel list - await fetchChannels(); - } catch (err) { - console.error(err); - setSnackbarMessage('Failed to assign channels'); - setSnackbarOpen(true); - } - }; - - // ───────────────────────────────────────────────────────── - // The new "Match EPG" button logic - // ───────────────────────────────────────────────────────── - const matchEpg = async () => { - try { - // Hit our new endpoint that triggers the fuzzy matching Celery task - const resp = await fetch('/api/channels/channels/match-epg/', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${await API.getAuthToken()}`, - }, - }); - - if (resp.ok) { - setSnackbarMessage('EPG matching task started!'); - } else { - const text = await resp.text(); - setSnackbarMessage(`Failed to start EPG matching: ${text}`); - } - } catch (err) { - setSnackbarMessage(`Error: ${err.message}`); - } - setSnackbarOpen(true); - }; - - const closeChannelForm = () => { - setChannel(null); - setChannelModalOpen(false); - }; - - useEffect(() => { - if (typeof window !== 'undefined') { - setIsLoading(false); - } - }, []); - - useEffect(() => { - // Scroll to the top of the table when sorting changes - try { - rowVirtualizerInstanceRef.current?.scrollToIndex?.(0); - } catch (error) { - console.error(error); - } - }, [sorting]); - - const closePopover = () => { - setAnchorEl(null); - setSnackbarMessage(''); - }; - const openPopover = Boolean(anchorEl); - - const handleCopy = async () => { - try { - await navigator.clipboard.writeText(textToCopy); - setSnackbarMessage('Copied!'); - } catch (err) { - const inputElement = outputUrlRef.current.querySelector('input'); // Get the actual input - - if (inputElement) { - inputElement.focus(); - inputElement.select(); - - // For older browsers - document.execCommand('copy'); - setSnackbarMessage('Copied!'); - } - } - setSnackbarOpen(true); - }; - - // Example copy URLs - const copyM3UUrl = (event) => { - setAnchorEl(event.currentTarget); - setTextToCopy( - `${window.location.protocol}//${window.location.host}/output/m3u` - ); - }; - const copyEPGUrl = (event) => { - setAnchorEl(event.currentTarget); - setTextToCopy( - `${window.location.protocol}//${window.location.host}/output/epg` - ); - }; - const copyHDHRUrl = (event) => { - setAnchorEl(event.currentTarget); - setTextToCopy( - `${window.location.protocol}//${window.location.host}/output/hdhr` - ); - }; - - const onRowSelectionChange = (e, test) => { - console.log(e()); - console.log(test); - setRowSelection(e); - }; - - useEffect(() => { - const selectedRows = table - .getSelectedRowModel() - .rows.map((row) => row.original); - setChannelsPageSelection(selectedRows); - }, [rowSelection]); - + // Filter data const filteredData = Object.values(channels).filter((row) => columns.every(({ accessorKey }) => filterValues[accessorKey] @@ -485,30 +451,24 @@ const ChannelsTable = ({}) => { ...TableHelper.defaultProperties, columns, data: filteredData, - enablePagination: false, enableColumnActions: false, - enableRowVirtualization: true, enableRowSelection: true, - onRowSelectionChange: onRowSelectionChange, + enableRowVirtualization: true, + enableExpandAll: false, + onRowSelectionChange: setRowSelection, onSortingChange: setSorting, + rowVirtualizerInstanceRef, + rowVirtualizerOptions: { overscan: 5 }, + initialState: { density: 'compact' }, state: { isLoading: isLoading || channelsLoading, sorting, rowSelection, }, - rowVirtualizerInstanceRef, // optional - rowVirtualizerOptions: { overscan: 5 }, - initialState: { - density: 'compact', - }, - enableRowActions: true, - enableExpandAll: false, displayColumnDefOptions: { - 'mrt-row-select': { - size: 50, // Set custom width (default is ~40px) - }, + 'mrt-row-select': { size: 50 }, 'mrt-row-expand': { - size: 10, // Set custom width (default is ~40px) + size: 10, header: '', muiTableHeadCellProps: { sx: { width: 38, minWidth: 38, maxWidth: 38, height: '100%' }, @@ -517,14 +477,12 @@ const ChannelsTable = ({}) => { sx: { width: 38, minWidth: 38, maxWidth: 38 }, }, }, - 'mrt-row-actions': { - size: 68, // Set custom width (default is ~40px) - }, + 'mrt-row-actions': { size: 68 }, }, muiExpandButtonProps: ({ row, table }) => ({ onClick: () => { setRowSelection({ [row.index]: true }); - table.setExpanded({ [row.id]: !row.getIsExpanded() }); //only 1 detail panel open at a time + table.setExpanded({ [row.id]: !row.getIsExpanded() }); }, sx: { transform: row.getIsExpanded() ? 'rotate(180deg)' : 'rotate(-90deg)', @@ -540,15 +498,12 @@ const ChannelsTable = ({}) => { { - editChannel(row.original); - }} + onClick={() => editChannel(row.original)} sx={{ py: 0, px: 0.5 }} > - { - { ), - muiTableContainerProps: { - sx: { - height: 'calc(100vh - 75px)', - overflowY: 'auto', - }, - }, - muiSearchTextFieldProps: { - variant: 'standard', - }, - renderTopToolbarCustomActions: ({ table }) => { - const selectedRowCount = table.getSelectedRowModel().rows.length; - - return ( - - Channels - - editChannel()} - > - - - - - - - - - - - - - - - {/* Our brand-new button for EPG matching */} - - - - - - - - - - - - - ); - }, + // No default MRT top toolbar + renderTopToolbar: () => null, + enableTopToolbar: false, }); + const hasNoData = filteredData.length === 0; + + /* ------------------------------------------------------------------ + 4) Return the layout with a Paper "box" around the table + (like your old snippet). We'll also show a "Channels" heading. + ------------------------------------------------------------------- */ return ( - - + + {/* We only need 1 column, so use Grid2 item xs={12} */} + + {/* Title above the Paper box */} + {/* Row with "Channels" text, "Links:" label, and HDHR/M3U/EPG chip buttons */} + + {/* "Channels" */} + + Channels + + + {/* "Links:" */} + + Links: + + + + {/* HDHR chip */} + + + {/* M3U chip */} + + + {/* EPG chip */} + + + + + {/* The Paper box with dark background, rounding, etc. */} + + {/* Toolbar row with "Remove/Assign/Auto-match/Add" */} + + {/* Right side: "Remove / Assign / Auto-match / Add" */} + + {/* Remove */} + + + + + {/* Assign */} + + + + + {/* Auto-match */} + + + + + {/* Add */} + + + + + + + {/* Table or ghost empty state */} + + {hasNoData ? ( + // Ghost empty state + + {/* Ghost in the center (behind content) */} + + + {/* Instructions text & button in front */} + + + 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. + + + + + + ) : ( + // Render the MRT table if data is present + + + + )} + + + {/* Channel Form Modal */} { onClose={closeChannelForm} /> - {/* Popover for the "copy" URLs */} + {/* Popover for copy URLs */} -
+ -
+
{/* Snackbar for feedback */} @@ -693,7 +937,7 @@ const ChannelsTable = ({}) => { onClose={closeSnackbar} message={snackbarMessage} /> - + ); }; diff --git a/frontend/src/images/ghost.svg b/frontend/src/images/ghost.svg new file mode 100644 index 00000000..2206100d --- /dev/null +++ b/frontend/src/images/ghost.svg @@ -0,0 +1,66 @@ + + + + + + + + + + + diff --git a/frontend/src/pages/Channels.js b/frontend/src/pages/Channels.js index b4c00028..73eccb9a 100644 --- a/frontend/src/pages/Channels.js +++ b/frontend/src/pages/Channels.js @@ -10,10 +10,10 @@ const ChannelsPage = () => { {