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 }) => (
-
+
),
- 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 */}
+ }
+ sx={{
+ minWidth: '64px',
+ height: '25px',
+ borderRadius: '4px',
+ borderColor: '#a3d977',
+ borderWidth: '1px',
+ borderStyle: 'solid',
+ backgroundColor: 'transparent',
+ color: '#a3d977',
+ textTransform: 'none',
+ fontSize: '0.85rem',
+ px: '6px',
+ py: '4px',
+ '&:hover': {
+ borderColor: '#c2e583',
+ color: '#c2e583',
+ backgroundColor: 'rgba(163,217,119,0.1)',
+ },
+ }}
+ >
+ HDHR
+
+
+ {/* M3U chip */}
+ }
+ sx={{
+ minWidth: '71px',
+ height: '25px',
+ borderRadius: '4px',
+ borderColor: '#5f6dc6',
+ borderWidth: '1px',
+ borderStyle: 'solid',
+ backgroundColor: 'transparent',
+ color: '#5f6dc6',
+ textTransform: 'none',
+ fontSize: '0.85rem',
+ px: '6px',
+ py: '4px',
+ '&:hover': {
+ borderColor: '#7f8de6',
+ color: '#7f8de6',
+ backgroundColor: 'rgba(95,109,198,0.1)',
+ },
+ }}
+ >
+ M3U
+
+
+ {/* EPG chip */}
+ }
+ sx={{
+ minWidth: '60px',
+ height: '25px',
+ borderRadius: '4px',
+ borderColor: '#707070',
+ borderWidth: '1px',
+ borderStyle: 'solid',
+ backgroundColor: 'transparent',
+ color: '#a0a0a0',
+ textTransform: 'none',
+ fontSize: '0.85rem',
+ px: '6px',
+ py: '4px',
+ '&:hover': {
+ borderColor: '#a0a0a0',
+ color: '#c0c0c0',
+ backgroundColor: 'rgba(112,112,112,0.1)',
+ },
+ }}
+ >
+ EPG
+
+
+
+
+ {/* The Paper box with dark background, rounding, etc. */}
+
+ {/* Toolbar row with "Remove/Assign/Auto-match/Add" */}
+
+ {/* Right side: "Remove / Assign / Auto-match / Add" */}
+
+ {/* Remove */}
+
+
+ }
+ sx={{
+ borderColor: '#3f3f46',
+ borderRadius: '4px',
+ borderWidth: '1px',
+ borderStyle: 'solid',
+ height: '25px',
+ opacity: 0.4,
+ color: '#d4d4d8',
+ textTransform: 'none',
+ fontSize: '0.85rem',
+ px: '6px',
+ py: '4px',
+ '&:hover': {
+ borderColor: '#5f5f66',
+ },
+ }}
+ >
+ Remove
+
+
+
+ {/* Assign */}
+
+
+ }
+ sx={{
+ borderColor: '#3f3f46',
+ borderRadius: '4px',
+ borderWidth: '1px',
+ borderStyle: 'solid',
+ height: '25px',
+ opacity: 0.4,
+ color: '#d4d4d8',
+ textTransform: 'none',
+ fontSize: '0.85rem',
+ px: '6px',
+ py: '4px',
+ '&:hover': {
+ borderColor: '#5f5f66',
+ },
+ }}
+ >
+ Assign
+
+
+
+ {/* Auto-match */}
+
+ }
+ sx={{
+ minWidth: '106px',
+ borderColor: '#3f3f46',
+ borderRadius: '4px',
+ borderWidth: '1px',
+ borderStyle: 'solid',
+ height: '25px',
+ opacity: 0.4,
+ color: '#d4d4d8',
+ textTransform: 'none',
+ fontSize: '0.85rem',
+ px: '6px',
+ py: '4px',
+ '&:hover': {
+ borderColor: '#5f5f66',
+ },
+ }}
+ >
+ 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 = () => {
{