UI Sidebar Redesign

Redesigned sidebar
This commit is contained in:
Dispatcharr 2025-03-07 17:39:34 -06:00
parent 725c21ed56
commit 0668680878
11 changed files with 1350 additions and 437 deletions

View file

@ -19,6 +19,7 @@
"eslint": "^8.57.1",
"formik": "^2.4.6",
"hls.js": "^1.5.20",
"lucide-react": "^0.479.0",
"material-react-table": "^3.2.0",
"mpegts.js": "^1.4.2",
"planby": "^1.1.7",
@ -11701,6 +11702,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "0.479.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.479.0.tgz",
"integrity": "sha512-aBhNnveRhorBOK7uA4gDjgaf+YlHMdMhQ/3cupk6exM10hWlEU+2QtWYOfhXhjAsmdb6LeKR+NZnow4UxRRiTQ==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/m3u8-parser": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/m3u8-parser/-/m3u8-parser-7.2.0.tgz",

View file

@ -14,6 +14,7 @@
"eslint": "^8.57.1",
"formik": "^2.4.6",
"hls.js": "^1.5.20",
"lucide-react": "^0.479.0",
"material-react-table": "^3.2.0",
"mpegts.js": "^1.4.2",
"planby": "^1.1.7",

View file

@ -12,7 +12,7 @@ import Login from './pages/Login';
import Channels from './pages/Channels';
import M3U from './pages/M3U';
import { ThemeProvider } from '@mui/material/styles';
import { Box, CssBaseline } from '@mui/material';
import { Box, CssBaseline } from '@mui/material'; // removed AppBar/Toolbar
import theme from './theme';
import EPG from './pages/EPG';
import Guide from './pages/Guide';
@ -58,7 +58,7 @@ const App = () => {
checkSuperuser();
}, []);
// Authentication check.
// Authentication check
useEffect(() => {
const checkAuth = async () => {
const loggedIn = await initializeAuth();
@ -72,7 +72,7 @@ const App = () => {
checkAuth();
}, [initializeAuth, initData, setIsAuthenticated, logout]);
// If no superuser exists, show the initialization form.
// If no superuser exists, show the initialization form
if (needsSuperuser) {
return <SuperuserForm onSuccess={() => setNeedsSuperuser(false)} />;
}
@ -82,6 +82,7 @@ const App = () => {
<CssBaseline />
<WebsocketProvider>
<Router>
{/* Sidebar on the left */}
<Sidebar
open={open}
miniDrawerWidth={miniDrawerWidth}
@ -89,24 +90,19 @@ const App = () => {
toggleDrawer={toggleDrawer}
/>
{/* Main content area, no AppBar, so no marginTop */}
<Box
sx={{
display: 'flex',
flexDirection: 'column',
ml: `${open ? drawerWidth : miniDrawerWidth}px`,
transition: 'width 0.3s, margin-left 0.3s',
backgroundColor: '#495057',
height: '100%',
transition: 'margin-left 0.3s',
backgroundColor: '#18181b',
minHeight: '100vh',
color: 'text.primary',
}}
>
<Box
sx={{
flex: 1,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
<Box sx={{ p: 2, flex: 1, overflow: 'auto' }}>
<Routes>
{isAuthenticated ? (
<>
@ -136,6 +132,7 @@ const App = () => {
</Box>
</Box>
</Router>
<Alert />
<FloatingVideo />
</WebsocketProvider>

View file

@ -1,148 +1,151 @@
import React from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { Link, useLocation } from 'react-router-dom';
import {
List,
ListItem,
ListItemButton,
ListItemText,
ListItemIcon,
Box,
Divider,
Drawer,
TextField,
Toolbar,
Box,
Typography,
Avatar,
List,
ListItemButton,
ListItemIcon,
ListItemText,
} from '@mui/material';
import {
Tv as TvIcon,
CalendarMonth as CalendarMonthIcon,
VideoFile as VideoFileIcon,
LiveTv as LiveTvIcon,
PlaylistPlay as PlaylistPlayIcon,
Settings as SettingsIcon,
Logout as LogoutIcon,
} from '@mui/icons-material';
ListOrdered,
Play,
Database,
SlidersHorizontal,
LayoutGrid,
Settings as LucideSettings,
} from 'lucide-react';
import logo from '../images/logo.png';
import useAuthStore from '../store/auth';
import useSettingsStore from '../store/settings';
import { ReactComponent as DispatcharrLogo } from '../images/dispatcharr.svg';
const items = [
{ text: 'Channels', icon: <TvIcon />, route: '/channels' },
{ text: 'M3U', icon: <PlaylistPlayIcon />, route: '/m3u' },
{ text: 'EPG', icon: <CalendarMonthIcon />, route: '/epg' },
{
text: 'Stream Profiles',
icon: <VideoFileIcon />,
route: '/stream-profiles',
},
{ text: 'TV Guide', icon: <LiveTvIcon />, route: '/guide' },
{ text: 'Settings', icon: <SettingsIcon />, route: '/settings' },
const navItems = [
{ label: 'Channels', icon: <ListOrdered size={20} />, path: '/channels' },
{ label: 'M3U', icon: <Play size={20} />, path: '/m3u' },
{ label: 'EPG', icon: <Database size={20} />, path: '/epg' },
{ label: 'Stream Profiles', icon: <SlidersHorizontal size={20} />, path: '/stream-profiles' },
{ label: 'TV Guide', icon: <LayoutGrid size={20} />, path: '/guide' },
{ label: 'Settings', icon: <LucideSettings size={20} />, path: '/settings' },
];
const Sidebar = ({ open, miniDrawerWidth, drawerWidth, toggleDrawer }) => {
const Sidebar = ({ open, drawerWidth, miniDrawerWidth, toggleDrawer }) => {
const location = useLocation();
const { isAuthenticated, logout } = useAuthStore();
const {
environment: { public_ip, country_code, country_name },
} = useSettingsStore();
const navigate = useNavigate();
const onLogout = () => {
logout();
navigate('/login');
};
return (
<Drawer
variant="permanent"
open={open}
sx={{
width: open ? drawerWidth : miniDrawerWidth,
flexShrink: 0,
'& .MuiDrawer-paper': {
PaperProps={{
sx: {
width: open ? drawerWidth : miniDrawerWidth,
transition: 'width 0.3s',
overflowX: 'hidden',
'& .MuiDrawer-paper': {
display: 'flex',
flexDirection: 'column',
height: '100vh',
},
transition: 'width 0.3s',
backgroundColor: '#18181b',
color: 'text.primary',
display: 'flex',
flexDirection: 'column',
boxShadow: 'none',
border: 'none',
},
}}
>
<Box sx={{ flexGrow: 1 }}>
<List sx={{ backgroundColor: '#495057', color: 'white' }}>
<ListItem disablePadding>
<Toolbar
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: open ? 'space-between' : 'center',
minHeight: '64px !important',
px: 2,
}}
>
{open ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, cursor: 'pointer' }} onClick={toggleDrawer}>
<img src={logo} alt="Dispatcharr Logo" style={{ width: 28, height: 'auto' }} />
<Typography variant="h6" noWrap sx={{ color: 'text.primary' }}>
Dispatcharr
</Typography>
</Box>
) : (
<img
src={logo}
alt="Dispatcharr Logo"
style={{ width: 28, height: 'auto', cursor: 'pointer' }}
onClick={toggleDrawer}
/>
)}
</Toolbar>
<List disablePadding sx={{ pt: 0 }}>
{navItems.map((item) => {
const isActive = location.pathname.startsWith(item.path);
return (
<ListItemButton
onClick={toggleDrawer}
size="small"
key={item.path}
component={Link}
to={item.path}
sx={{
pt: 0,
pb: 0,
px: 2,
py: 0.5,
mx: 'auto',
display: 'flex',
justifyContent: 'center',
color: 'inherit',
width: '100%',
'&:hover': { backgroundColor: 'unset !important' },
}}
>
<img src={logo} width="33x" alt="logo" />
{open && (
<ListItemText primary="Dispatcharr" sx={{ paddingLeft: 3 }} />
)}
</ListItemButton>
</ListItem>
</List>
<Divider />
<List>
{items.map((item) => (
<ListItem key={item.text} disablePadding>
<ListItemButton
component={Link}
to={item.route}
selected={location.pathname == item.route}
<Box
sx={{
display: 'flex',
alignItems: 'center',
borderRadius: 1,
width: open ? '208px' : 'auto',
transition: 'all 0.2s ease',
bgcolor: isActive ? 'rgba(21, 69, 62, 0.67)' : 'transparent',
border: isActive ? '1px solid #14917e' : '1px solid transparent',
color: 'text.primary',
px: 1,
py: 0.25,
'&:hover': {
bgcolor: '#27272a',
border: '1px solid #3f3f46',
},
}}
>
<ListItemIcon>{item.icon}</ListItemIcon>
{open && <ListItemText primary={item.text} />}
</ListItemButton>
</ListItem>
))}
</List>
</Box>
{isAuthenticated && (
<Box sx={{ borderTop: '1px solid #ccc' }}>
<List>
<ListItem disablePadding>
<ListItemButton onClick={onLogout}>
<ListItemIcon>
<LogoutIcon />
<ListItemIcon sx={{ color: 'text.primary', minWidth: 0, mr: open ? 1 : 'auto', justifyContent: 'center' }}>
{item.icon}
</ListItemIcon>
<ListItemText primary="Logout" />
</ListItemButton>
</ListItem>
</List>
{open && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 1 }}>
{/* Public IP + optional flag */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<TextField
size="small"
label="Public IP"
value={public_ip || ''}
disabled
variant="outlined"
sx={{ flex: 1 }}
/>
{/* If we have a country code, show a small flag */}
{country_code && (
<img
src={`https://flagcdn.com/16x12/${country_code.toLowerCase()}.png`}
alt={country_name || country_code}
title={country_name || country_code}
style={{ border: '1px solid #ccc', borderRadius: 2 }}
{open && (
<ListItemText
primary={item.label}
primaryTypographyProps={{
sx: {
fontSize: '14px',
fontWeight: 400,
color: isActive ? '##d4d4d8' : '##d4d4d8',
fontFamily: 'Inter, sans-serif',
letterSpacing: '-0.3px',
},
}}
/>
)}
</Box>
</Box>
)}
</Box>
)}
</ListItemButton>
);
})}
</List>
<Box sx={{ flexGrow: 1 }} />
<Box sx={{ p: 2, display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Avatar alt="John Doe" src="/static/images/avatar.png" sx={{ width: 32, height: 32 }} />
{open && (
<Typography variant="body2" noWrap sx={{ color: 'text.primary' }}>
John Doe
</Typography>
)}
</Box>
</Drawer>
);
};

View file

@ -1,36 +1,33 @@
// frontend/src/components/tables/ChannelsTable.js
import { useEffect, useMemo, useRef, useState } from 'react';
import {
MaterialReactTable,
useMaterialReactTable,
} from 'material-react-table';
import { MaterialReactTable, useMaterialReactTable } from 'material-react-table';
import {
Box,
Grid2,
Stack,
Typography,
Tooltip,
IconButton,
Button,
ButtonGroup,
Button,
Snackbar,
Popover,
TextField,
} from '@mui/material';
import useChannelsStore from '../../store/channels';
import {
Delete as DeleteIcon,
Edit as EditIcon,
Add as AddIcon,
SwapVert as SwapVertIcon,
LiveTv as LiveTvIcon,
Tv as TvIcon,
ContentCopy,
Tv as TvIcon, // <-- ADD THIS IMPORT
} from '@mui/icons-material';
import API from '../../api';
import ChannelForm from '../forms/Channel';
import { TableHelper } from '../../helpers';
import utils from '../../utils';
import logo from '../../images/logo.png';
import useChannelsStore from '../../store/channels';
import useVideoStore from '../../store/useVideoStore';
import useSettingsStore from '../../store/settings';
@ -43,16 +40,20 @@ const ChannelsTable = () => {
const [textToCopy, setTextToCopy] = useState('');
const [snackbarMessage, setSnackbarMessage] = useState('');
const [snackbarOpen, setSnackbarOpen] = useState(false);
const { showVideo } = useVideoStore.getState(); // or useVideoStore()
const { channels, isLoading: channelsLoading } = useChannelsStore();
const {
environment: { env_mode },
} = useSettingsStore();
const { showVideo } = useVideoStore.getState();
// Configure columns
const columns = useMemo(
() => [
const rowVirtualizerInstanceRef = useRef(null);
const [isLoading, setIsLoading] = useState(true);
const [sorting, setSorting] = useState([]);
// Columns
const columns = useMemo(() => {
return [
{
header: '#',
size: 50,
@ -69,178 +70,21 @@ const ChannelsTable = () => {
{
header: 'Logo',
accessorKey: 'logo_url',
size: 55,
size: 60,
Cell: ({ cell }) => (
<Grid2
container
direction="row"
sx={{
justifyContent: 'center',
alignItems: 'center',
}}
>
<img src={cell.getValue() || logo} width="20" alt="channel logo" />
</Grid2>
<Box sx={{ textAlign: 'center' }}>
<img
src={cell.getValue() || logo}
alt="channel logo"
style={{ width: 24, height: 'auto' }}
/>
</Box>
),
meta: {
filterVariant: null,
},
},
],
[]
);
// 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) => {
setIsLoading(true);
await API.deleteChannel(id);
setIsLoading(false);
};
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 useChannelsStore.getState().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) {
setSnackbarMessage('Failed to copy');
}
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`
);
};
// Configure the MaterialReactTable
// Common table logic
const table = useMaterialReactTable({
...TableHelper.defaultProperties,
columns,
@ -255,114 +99,204 @@ const ChannelsTable = () => {
sorting,
rowSelection,
},
rowVirtualizerInstanceRef, // optional
rowVirtualizerInstanceRef,
rowVirtualizerOptions: { overscan: 5 },
initialState: {
density: 'compact',
},
enableRowActions: true,
renderRowActions: ({ row }) => (
<Box sx={{ justifyContent: 'right' }}>
<Stack direction="row" spacing={1}>
{/* Edit channel */}
<IconButton
size="small"
color="warning"
onClick={() => {
editChannel(row.original);
}}
sx={{ p: 0 }}
onClick={() => editChannel(row.original)}
>
<EditIcon fontSize="small" />
</IconButton>
{/* Delete channel */}
<IconButton
size="small"
color="error"
onClick={() => deleteChannel(row.original.id)}
sx={{ p: 0 }}
>
<DeleteIcon fontSize="small" />
</IconButton>
{/* Watch now */}
<IconButton
size="small"
color="info"
onClick={() => handleWatchStream(row.original.channel_number)}
sx={{ p: 0 }}
>
<LiveTvIcon fontSize="small" />
</IconButton>
</Box>
</Stack>
),
muiTableContainerProps: {
sx: {
height: 'calc(100vh - 75px)',
height: 'calc(100% - 40px)', // fill parent minus a bit for your top row
overflowY: 'auto',
},
},
muiSearchTextFieldProps: {
variant: 'standard',
},
renderTopToolbarCustomActions: ({ table }) => (
<Stack direction="row" sx={{ alignItems: 'center' }}>
<Typography>Channels</Typography>
<Tooltip title="Add New Channel">
<IconButton
size="small"
color="success"
variant="contained"
onClick={() => editChannel()}
>
<AddIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Delete Channels">
<IconButton
size="small"
color="error"
variant="contained"
onClick={deleteChannels}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Assign Channels">
<IconButton
size="small"
color="warning"
variant="contained"
onClick={assignChannels}
>
<SwapVertIcon fontSize="small" />
</IconButton>
</Tooltip>
{/* Our brand-new button for EPG matching */}
<Tooltip title="Auto-match EPG with fuzzy logic">
<IconButton
size="small"
color="success"
variant="contained"
onClick={matchEpg}
>
<TvIcon fontSize="small" />
</IconButton>
</Tooltip>
<ButtonGroup sx={{ marginLeft: 1 }}>
<Button variant="contained" size="small" onClick={copyHDHRUrl}>
HDHR URL
</Button>
<Button variant="contained" size="small" onClick={copyM3UUrl}>
M3U URL
</Button>
<Button variant="contained" size="small" onClick={copyEPGUrl}>
EPG
</Button>
renderTopToolbarCustomActions: () => (
<Stack direction="row" spacing={1} alignItems="center">
{/* “HDHR URL”, “M3U URL”, “EPG” ButtonGroup like your screenshot */}
<ButtonGroup variant="outlined" size="small">
<Button onClick={copyHDHRUrl}>HDHR URL</Button>
<Button onClick={copyM3UUrl}>M3U URL</Button>
<Button onClick={copyEPGUrl}>EPG</Button>
</ButtonGroup>
{/* Additional actions: auto-assign, auto-match, add, remove, etc. */}
<Tooltip title="Assign Channels">
<IconButton color="warning" size="small" onClick={assignChannels}>
<SwapVertIcon />
</IconButton>
</Tooltip>
<Tooltip title="Auto-match EPG">
<IconButton color="success" size="small" onClick={matchEpg}>
<TvIcon />
</IconButton>
</Tooltip>
<Tooltip title="Add Channel">
<IconButton color="success" size="small" onClick={() => editChannel()}>
<AddIcon />
</IconButton>
</Tooltip>
<Tooltip title="Delete Channels">
<IconButton color="error" size="small" onClick={deleteChannels}>
<DeleteIcon />
</IconButton>
</Tooltip>
</Stack>
),
});
// Lifecycle
useEffect(() => {
if (typeof window !== 'undefined') {
setIsLoading(false);
}
}, []);
useEffect(() => {
try {
rowVirtualizerInstanceRef.current?.scrollToIndex?.(0);
} catch (error) {
console.error(error);
}
}, [sorting]);
// Channel actions
function editChannel(channel = null) {
setChannel(channel);
setChannelModalOpen(true);
}
async function deleteChannel(id) {
setIsLoading(true);
await API.deleteChannel(id);
setIsLoading(false);
}
function handleWatchStream(channelNumber) {
let vidUrl = `/output/stream/${channelNumber}/`;
if (env_mode === 'dev') {
vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`;
}
showVideo(vidUrl);
}
async function deleteChannels() {
setIsLoading(true);
const selected = table
.getRowModel()
.rows.filter((row) => row.getIsSelected());
await utils.Limiter(
4,
selected.map((chan) => () => deleteChannel(chan.original.id))
);
setIsLoading(false);
}
async function assignChannels() {
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 useChannelsStore.getState().fetchChannels();
} catch (err) {
console.error(err);
setSnackbarMessage('Failed to assign channels');
setSnackbarOpen(true);
}
}
async function matchEpg() {
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);
}
// Copy popover
const openPopover = Boolean(anchorEl);
function closePopover() {
setAnchorEl(null);
}
async function handleCopy() {
try {
await navigator.clipboard.writeText(textToCopy);
setSnackbarMessage('Copied!');
} catch (err) {
setSnackbarMessage('Failed to copy');
}
setSnackbarOpen(true);
}
function copyHDHRUrl(event) {
setAnchorEl(event.currentTarget);
setTextToCopy(`${window.location.protocol}//${window.location.host}/output/hdhr`);
}
function copyM3UUrl(event) {
setAnchorEl(event.currentTarget);
setTextToCopy(`${window.location.protocol}//${window.location.host}/output/m3u`);
}
function copyEPGUrl(event) {
setAnchorEl(event.currentTarget);
setTextToCopy(`${window.location.protocol}//${window.location.host}/output/epg`);
}
// Channel form close
function closeChannelForm() {
setChannel(null);
setChannelModalOpen(false);
}
// Snackbar
const handleSnackbarClose = () => {
setSnackbarOpen(false);
};
return (
<Box>
<Box sx={{ height: '100%' }}>
<MaterialReactTable table={table} />
{/* Channel Form Modal */}
@ -372,36 +306,33 @@ const ChannelsTable = () => {
onClose={closeChannelForm}
/>
{/* Popover for the "copy" URLs */}
{/* Popover for "copy" URLs */}
<Popover
open={openPopover}
anchorEl={anchorEl}
onClose={closePopover}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
>
<div style={{ padding: 16, display: 'flex', alignItems: 'center' }}>
<Box sx={{ p: 2, display: 'flex', alignItems: 'center' }}>
<TextField
value={textToCopy}
variant="standard"
disabled
size="small"
sx={{ marginRight: 1 }}
sx={{ mr: 1 }}
/>
<IconButton onClick={handleCopy} color="primary">
<ContentCopy />
</IconButton>
</div>
</Box>
</Popover>
{/* Snackbar for feedback */}
{/* Snackbar messages */}
<Snackbar
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
open={snackbarOpen}
autoHideDuration={5000}
onClose={closeSnackbar}
autoHideDuration={4000}
onClose={handleSnackbarClose}
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
message={snackbarMessage}
/>
</Box>

View file

@ -1,3 +1,5 @@
// frontend/src/helpers/table.js
export default {
defaultProperties: {
enableGlobalFilter: false,
@ -13,19 +15,34 @@ export default {
},
muiTableBodyCellProps: {
sx: {
padding: 0,
padding: '6px',
borderColor: '#444',
color: '#E0E0E0',
fontSize: '0.85rem',
},
},
muiTableHeadCellProps: {
sx: {
padding: 0,
padding: '6px',
color: '#CFCFCF',
backgroundColor: '#383A3F',
borderColor: '#444',
fontWeight: 600,
fontSize: '0.8rem',
},
},
muiTableBodyProps: {
sx: {
//stripe the rows, make odd rows a darker color
'& tr:nth-of-type(odd) > td': {
// backgroundColor: '#f5f5f5',
// Subtle row striping
'& tr:nth-of-type(odd)': {
backgroundColor: '#2F3034',
},
'& tr:nth-of-type(even)': {
backgroundColor: '#333539',
},
// Row hover effect
'& tr:hover td': {
backgroundColor: '#3B3D41',
},
},
},

View file

@ -0,0 +1,23 @@
<svg width="432" height="100" viewBox="0 0 432 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_12_1828)">
<path d="M17.1193 17.1819L17.8539 86.9621C11.9776 87.6967 7.57051 84.7585 7.57051 78.8823L6.83594 20.8545C6.83594 2.49131 23.7301 -1.91582 34.0135 5.42939L86.165 35.5451C93.5104 40.6868 94.9794 50.2356 91.3067 56.8464C90.5723 51.7047 88.3687 48.7666 83.9614 45.8285L25.1991 12.7747C20.792 9.83662 17.1193 10.5711 17.1193 17.1819Z" fill="#3E464E"/>
<path d="M12.7119 90.6348C17.1191 92.1038 21.5263 91.3693 25.1989 89.1657L85.4303 53.9083C89.1029 59.05 88.3685 64.1917 83.9612 67.1298L33.2787 96.511C25.9334 100.184 16.3846 96.511 12.7119 90.6348Z" fill="#14917E"/>
<path d="M30.3408 70.0679L66.3327 49.5011L31.0754 29.6688L30.3408 70.0679Z" fill="#14917E"/>
</g>
<path d="M137.25 69.5666H124V28.6387H137.669C141.679 28.6387 145.123 29.4581 148.001 31.0968C150.879 32.7222 153.084 35.0603 154.616 38.1113C156.161 41.1489 156.934 44.7927 156.934 49.0427C156.934 53.306 156.155 56.9698 154.596 60.0341C153.051 63.0984 150.812 65.4565 147.881 67.1085C144.95 68.7473 141.406 69.5666 137.25 69.5666ZM130.175 64.1708H136.91C140.027 64.1708 142.619 63.5846 144.684 62.4122C146.749 61.2265 148.294 59.5145 149.32 57.2763C150.346 55.0247 150.859 52.2802 150.859 49.0427C150.859 45.8319 150.346 43.1074 149.32 40.8691C148.308 38.6309 146.795 36.9322 144.784 35.7731C142.772 34.614 140.274 34.0345 137.29 34.0345H130.175V64.1708Z" fill="white"/>
<path d="M164.134 69.5666V38.8707H170.109V69.5666H164.134ZM167.151 34.1344C166.112 34.1344 165.219 33.788 164.473 33.0952C163.741 32.3891 163.374 31.5498 163.374 30.5772C163.374 29.5913 163.741 28.752 164.473 28.0592C165.219 27.3531 166.112 27 167.151 27C168.19 27 169.076 27.3531 169.809 28.0592C170.555 28.752 170.928 29.5913 170.928 30.5772C170.928 31.5498 170.555 32.3891 169.809 33.0952C169.076 33.788 168.19 34.1344 167.151 34.1344Z" fill="white"/>
<path d="M201.149 46.3648L195.734 47.3241C195.507 46.6313 195.148 45.9718 194.655 45.3456C194.175 44.7194 193.522 44.2065 192.696 43.8068C191.87 43.4071 190.838 43.2073 189.599 43.2073C187.907 43.2073 186.494 43.587 185.362 44.3464C184.229 45.0925 183.663 46.0584 183.663 47.2441C183.663 48.27 184.043 49.096 184.802 49.7222C185.562 50.3484 186.787 50.8613 188.479 51.261L193.356 52.3801C196.18 53.0329 198.285 54.0388 199.671 55.3977C201.056 56.7567 201.749 58.5219 201.749 60.6936C201.749 62.5321 201.216 64.1708 200.15 65.6097C199.098 67.0353 197.626 68.1544 195.734 68.9671C193.855 69.7798 191.677 70.1861 189.199 70.1861C185.762 70.1861 182.957 69.4534 180.785 67.9879C178.614 66.509 177.282 64.4107 176.789 61.6928L182.564 60.8135C182.924 62.319 183.663 63.4581 184.782 64.2308C185.901 64.9902 187.36 65.3699 189.159 65.3699C191.117 65.3699 192.683 64.9636 193.855 64.1509C195.028 63.3248 195.614 62.319 195.614 61.1332C195.614 60.174 195.254 59.368 194.535 58.7151C193.829 58.0623 192.743 57.5694 191.277 57.2363L186.081 56.0972C183.217 55.4444 181.099 54.4052 179.726 52.9796C178.367 51.5541 177.688 49.7488 177.688 47.5639C177.688 45.752 178.194 44.1665 179.207 42.8076C180.219 41.4487 181.618 40.3895 183.403 39.6301C185.189 38.8574 187.234 38.471 189.539 38.471C192.856 38.471 195.467 39.1904 197.372 40.6293C199.278 42.0549 200.537 43.9667 201.149 46.3648Z" fill="white"/>
<path d="M208.319 81.0776V38.8707H214.154V43.8468H214.654C215 43.2073 215.5 42.4679 216.153 41.6285C216.806 40.7892 217.712 40.0564 218.871 39.4303C220.03 38.7908 221.562 38.471 223.467 38.471C225.945 38.471 228.157 39.0972 230.102 40.3495C232.047 41.6019 233.572 43.4071 234.678 45.7653C235.797 48.1234 236.357 50.9612 236.357 54.2786C236.357 57.596 235.804 60.4404 234.698 62.8119C233.592 65.1701 232.074 66.9886 230.142 68.2676C228.21 69.5333 226.005 70.1661 223.527 70.1661C221.662 70.1661 220.136 69.8531 218.951 69.2269C217.778 68.6007 216.859 67.8679 216.193 67.0286C215.527 66.1893 215.014 65.4432 214.654 64.7904H214.294V81.0776H208.319ZM214.174 54.2187C214.174 56.377 214.487 58.2688 215.114 59.8942C215.74 61.5196 216.646 62.7919 217.831 63.7112C219.017 64.6172 220.469 65.0701 222.188 65.0701C223.973 65.0701 225.465 64.5972 226.664 63.6513C227.864 62.692 228.769 61.393 229.382 59.7543C230.009 58.1156 230.322 56.2704 230.322 54.2187C230.322 52.1936 230.015 50.375 229.402 48.7629C228.803 47.1509 227.897 45.8785 226.684 44.9459C225.485 44.0133 223.987 43.547 222.188 43.547C220.456 43.547 218.991 43.9933 217.791 44.886C216.606 45.7786 215.706 47.0243 215.094 48.623C214.481 50.2218 214.174 52.087 214.174 54.2187Z" fill="white"/>
<path d="M251.985 70.2461C250.039 70.2461 248.281 69.8864 246.709 69.1669C245.137 68.4342 243.891 67.375 242.972 65.9894C242.066 64.6038 241.613 62.9052 241.613 60.8934C241.613 59.1614 241.946 57.7359 242.612 56.6168C243.278 55.4977 244.177 54.6117 245.31 53.9589C246.442 53.306 247.708 52.8131 249.107 52.48C250.506 52.1469 251.931 51.8938 253.384 51.7206C255.222 51.5074 256.714 51.3342 257.86 51.201C259.006 51.0545 259.838 50.8213 260.358 50.5016C260.878 50.1818 261.137 49.6622 261.137 48.9428V48.8029C261.137 47.0576 260.645 45.7053 259.659 44.7461C258.686 43.7868 257.234 43.3072 255.302 43.3072C253.29 43.3072 251.705 43.7535 250.546 44.6462C249.4 45.5255 248.607 46.5047 248.168 47.5839L242.552 46.3049C243.218 44.4397 244.191 42.9342 245.47 41.7884C246.762 40.6293 248.248 39.79 249.926 39.2704C251.605 38.7375 253.37 38.471 255.222 38.471C256.448 38.471 257.747 38.6176 259.119 38.9107C260.505 39.1904 261.797 39.71 262.996 40.4694C264.208 41.2288 265.201 42.3147 265.974 43.7269C266.746 45.1258 267.133 46.9444 267.133 49.1826V69.5666H261.297V65.3699H261.058C260.671 66.1426 260.092 66.902 259.319 67.6481C258.546 68.3942 257.554 69.0137 256.341 69.5067C255.129 69.9996 253.677 70.2461 251.985 70.2461ZM253.284 65.4498C254.936 65.4498 256.348 65.1234 257.52 64.4706C258.706 63.8178 259.605 62.9651 260.218 61.9126C260.844 60.8468 261.157 59.7077 261.157 58.4953V54.5384C260.944 54.7516 260.531 54.9514 259.918 55.1379C259.319 55.3111 258.633 55.4643 257.86 55.5976C257.087 55.7175 256.335 55.8307 255.602 55.9373C254.869 56.0306 254.256 56.1105 253.763 56.1771C252.604 56.3237 251.545 56.5701 250.586 56.9165C249.64 57.2629 248.88 57.7625 248.308 58.4154C247.748 59.0549 247.468 59.9075 247.468 60.9734C247.468 62.4522 248.014 63.5713 249.107 64.3307C250.199 65.0768 251.592 65.4498 253.284 65.4498Z" fill="white"/>
<path d="M289.42 38.8707V43.6669H272.653V38.8707H289.42ZM277.15 31.5165H283.125V60.5537C283.125 61.7128 283.298 62.5854 283.645 63.1716C283.991 63.7445 284.438 64.1375 284.984 64.3507C285.543 64.5506 286.149 64.6505 286.802 64.6505C287.282 64.6505 287.702 64.6172 288.061 64.5506C288.421 64.4839 288.701 64.4306 288.901 64.3907L289.98 69.3268C289.633 69.46 289.14 69.5933 288.501 69.7265C287.861 69.873 287.062 69.953 286.103 69.9663C284.531 69.993 283.065 69.7132 281.706 69.127C280.347 68.5408 279.248 67.6348 278.409 66.4091C277.57 65.1834 277.15 63.6446 277.15 61.7927V31.5165Z" fill="white"/>
<path d="M308.63 70.1861C305.659 70.1861 303.101 69.5133 300.956 68.1677C298.825 66.8088 297.186 64.9369 296.04 62.5521C294.894 60.1673 294.321 57.4361 294.321 54.3585C294.321 51.241 294.908 48.4898 296.08 46.105C297.252 43.7069 298.904 41.835 301.036 40.4894C303.168 39.1438 305.679 38.471 308.57 38.471C310.902 38.471 312.98 38.904 314.805 39.77C316.631 40.6226 318.103 41.8217 319.222 43.3672C320.354 44.9126 321.027 46.7179 321.24 48.7829H315.425C315.105 47.344 314.372 46.105 313.227 45.0658C312.094 44.0266 310.575 43.5071 308.67 43.5071C307.005 43.5071 305.546 43.9467 304.294 44.826C303.055 45.692 302.089 46.931 301.396 48.5431C300.703 50.1419 300.357 52.0337 300.357 54.2187C300.357 56.4569 300.696 58.3887 301.376 60.0141C302.055 61.6395 303.015 62.8985 304.254 63.7911C305.506 64.6838 306.978 65.1301 308.67 65.1301C309.803 65.1301 310.828 64.9236 311.748 64.5106C312.68 64.0843 313.46 63.4781 314.086 62.692C314.725 61.906 315.172 60.96 315.425 59.8542H321.24C321.027 61.8393 320.381 63.6113 319.302 65.1701C318.223 66.7288 316.777 67.9545 314.965 68.8472C313.167 69.7398 311.055 70.1861 308.63 70.1861Z" fill="white"/>
<path d="M333.606 51.3409V69.5666H327.63V28.6387H333.526V43.8668H333.905C334.625 42.2147 335.724 40.9024 337.203 39.9299C338.682 38.9573 340.613 38.471 342.998 38.471C345.103 38.471 346.942 38.904 348.514 39.77C350.099 40.636 351.325 41.9283 352.191 43.6469C353.07 45.3523 353.51 47.4839 353.51 50.0419V69.5666H347.535V50.7614C347.535 48.5098 346.955 46.7645 345.796 45.5255C344.637 44.2731 343.025 43.6469 340.96 43.6469C339.548 43.6469 338.282 43.9467 337.163 44.5462C336.057 45.1458 335.184 46.0251 334.545 47.1842C333.919 48.3299 333.606 49.7155 333.606 51.3409Z" fill="white"/>
<path d="M370.417 70.2461C368.472 70.2461 366.713 69.8864 365.141 69.1669C363.569 68.4342 362.323 67.375 361.404 65.9894C360.498 64.6038 360.045 62.9052 360.045 60.8934C360.045 59.1614 360.378 57.7359 361.044 56.6168C361.71 55.4977 362.61 54.6117 363.742 53.9589C364.874 53.306 366.14 52.8131 367.539 52.48C368.938 52.1469 370.363 51.8938 371.816 51.7206C373.654 51.5074 375.146 51.3342 376.292 51.201C377.438 51.0545 378.271 50.8213 378.79 50.5016C379.31 50.1818 379.57 49.6622 379.57 48.9428V48.8029C379.57 47.0576 379.077 45.7053 378.091 44.7461C377.118 43.7868 375.666 43.3072 373.734 43.3072C371.722 43.3072 370.137 43.7535 368.978 44.6462C367.832 45.5255 367.039 46.5047 366.6 47.5839L360.984 46.3049C361.65 44.4397 362.623 42.9342 363.902 41.7884C365.194 40.6293 366.68 39.79 368.358 39.2704C370.037 38.7375 371.802 38.471 373.654 38.471C374.88 38.471 376.179 38.6176 377.551 38.9107C378.937 39.1904 380.229 39.71 381.428 40.4694C382.64 41.2288 383.633 42.3147 384.406 43.7269C385.179 45.1258 385.565 46.9444 385.565 49.1826V69.5666H379.729V65.3699H379.49C379.103 66.1426 378.524 66.902 377.751 67.6481C376.978 68.3942 375.986 69.0137 374.773 69.5067C373.561 69.9996 372.109 70.2461 370.417 70.2461ZM371.716 65.4498C373.368 65.4498 374.78 65.1234 375.952 64.4706C377.138 63.8178 378.037 62.9651 378.65 61.9126C379.276 60.8468 379.59 59.7077 379.59 58.4953V54.5384C379.376 54.7516 378.963 54.9514 378.351 55.1379C377.751 55.3111 377.065 55.4643 376.292 55.5976C375.519 55.7175 374.767 55.8307 374.034 55.9373C373.301 56.0306 372.688 56.1105 372.195 56.1771C371.036 56.3237 369.977 56.5701 369.018 56.9165C368.072 57.2629 367.313 57.7625 366.74 58.4154C366.18 59.0549 365.9 59.9075 365.9 60.9734C365.9 62.4522 366.447 63.5713 367.539 64.3307C368.631 65.0768 370.024 65.4498 371.716 65.4498Z" fill="white"/>
<path d="M393.524 69.5666V38.8707H399.299V43.7469H399.619C400.178 42.0948 401.164 40.7958 402.577 39.8499C404.002 38.8907 405.614 38.411 407.413 38.411C407.786 38.411 408.225 38.4244 408.732 38.451C409.251 38.4777 409.658 38.511 409.951 38.5509V44.2665C409.711 44.1998 409.285 44.1266 408.672 44.0466C408.059 43.9534 407.446 43.9067 406.833 43.9067C405.421 43.9067 404.162 44.2065 403.056 44.806C401.964 45.3922 401.098 46.2116 400.458 47.2641C399.819 48.3033 399.499 49.489 399.499 50.8213V69.5666H393.524Z" fill="white"/>
<path d="M415.122 69.5666V38.8707H420.897V43.7469H421.217C421.776 42.0948 422.762 40.7958 424.175 39.8499C425.6 38.8907 427.212 38.411 429.011 38.411C429.384 38.411 429.824 38.4244 430.33 38.451C430.849 38.4777 431.256 38.511 431.549 38.5509V44.2665C431.309 44.1998 430.883 44.1266 430.27 44.0466C429.657 43.9534 429.044 43.9067 428.431 43.9067C427.019 43.9067 425.76 44.2065 424.654 44.806C423.562 45.3922 422.696 46.2116 422.056 47.2641C421.417 48.3033 421.097 49.489 421.097 50.8213V69.5666H415.122Z" fill="white"/>
<defs>
<clipPath id="clip0_12_1828">
<rect width="100" height="100" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Before After
Before After

View file

@ -1,3 +1,5 @@
/* frontend/src/index.css */
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
@ -5,9 +7,25 @@ body {
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #2E2F34; /* Ensure the global background is dark */
color: #ffffff;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
/* Example scrollbars - optional, to match a dark theme. */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #3B3C41;
}
::-webkit-scrollbar-thumb {
background: #555;
}
::-webkit-scrollbar-thumb:hover {
background: #777;
}

View file

@ -1,42 +1,911 @@
import React from 'react';
import ChannelsTable from '../components/tables/ChannelsTable';
import StreamsTable from '../components/tables/StreamsTable';
import { Grid2, Box } from '@mui/material';
import React, { useState, useEffect } from 'react';
import {
Box,
Grid,
Paper,
Stack,
Button,
Chip,
Typography,
IconButton,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
} from '@mui/material';
// MUI icons used to replicate the Figma design
import AddBox from '@mui/icons-material/AddBox';
import ArrowDownward from '@mui/icons-material/ArrowDownward';
import CancelOutlined from '@mui/icons-material/CancelOutlined';
import CheckBoxOutlineBlank from '@mui/icons-material/CheckBoxOutlineBlank';
import Code from '@mui/icons-material/Code';
import CompareArrows from '@mui/icons-material/CompareArrows';
import IndeterminateCheckBox from '@mui/icons-material/IndeterminateCheckBox';
import MoreHoriz from '@mui/icons-material/MoreHoriz';
import PlayArrow from '@mui/icons-material/PlayArrow';
import PlayCircle from '@mui/icons-material/PlayCircle';
import Sort from '@mui/icons-material/Sort';
import Edit from '@mui/icons-material/Edit';
// Zustand stores & API
import useChannelsStore from '../store/channels';
import useStreamsStore from '../store/streams';
import useVideoStore from '../store/useVideoStore';
import useAlertStore from '../store/alerts';
import API from '../api';
// If you have ChannelForm / StreamForm modals, import them:
import ChannelForm from '../components/forms/Channel';
import StreamForm from '../components/forms/Stream';
const ChannelsPage = () => {
//
// -----------------------------
// 1) HOOKS & GLOBAL STORE DATA
// -----------------------------
//
const { channels, fetchChannels } = useChannelsStore();
const { streams, fetchStreams } = useStreamsStore();
const { showVideo } = useVideoStore.getState();
const { showAlert } = useAlertStore();
// We fetch channels/streams if needed
useEffect(() => {
// If not loaded yet, fetch them:
fetchChannels().catch((err) => console.error('Failed to fetch channels', err));
fetchStreams().catch((err) => console.error('Failed to fetch streams', err));
// eslint-disable-next-line
}, []);
//
// -----------------------------
// 2) LOCAL STATE FOR SELECTION
// -----------------------------
//
const [selectedChannelIds, setSelectedChannelIds] = useState([]);
const [selectedStreamIds, setSelectedStreamIds] = useState([]);
// For opening the Channel/Stream forms
const [channelFormOpen, setChannelFormOpen] = useState(false);
const [editingChannel, setEditingChannel] = useState(null);
const [streamFormOpen, setStreamFormOpen] = useState(false);
const [editingStream, setEditingStream] = useState(null);
//
// -----------------------------
// 3) CHANNEL ACTIONS
// -----------------------------
//
function handleToggleChannel(channelId) {
setSelectedChannelIds((prev) => {
if (prev.includes(channelId)) {
return prev.filter((id) => id !== channelId);
} else {
return [...prev, channelId];
}
});
}
function handleSelectAllChannels() {
if (selectedChannelIds.length === channels.length) {
setSelectedChannelIds([]);
} else {
setSelectedChannelIds(channels.map((c) => c.id));
}
}
async function handleRemoveChannels() {
if (selectedChannelIds.length === 0) return;
// This calls your existing bulk delete method
try {
await API.deleteChannels(selectedChannelIds);
setSelectedChannelIds([]);
showAlert(`Deleted ${selectedChannelIds.length} channels`, 'success');
} catch (err) {
console.error(err);
showAlert('Failed to remove channels', 'error');
}
}
async function handleAssignChannels() {
// The example calls a reorder method. If you have a different approach, adapt here
const channelIdsInCurrentOrder = channels.map((ch) => ch.id);
try {
await API.assignChannelNumbers(channelIdsInCurrentOrder);
showAlert('Channels assigned successfully!', 'success');
} catch (err) {
console.error(err);
showAlert('Failed to assign channels', 'error');
}
}
async function handleAutoMatch() {
// Example "match-epg" call from your code:
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) {
showAlert('EPG matching task started!', 'success');
} else {
const text = await resp.text();
showAlert(`Failed to start EPG matching: ${text}`, 'error');
}
} catch (err) {
showAlert(`Error: ${err.message}`, 'error');
}
}
function handleAddChannel() {
setEditingChannel(null);
setChannelFormOpen(true);
}
function handleEditChannel(channel) {
setEditingChannel(channel);
setChannelFormOpen(true);
}
async function handleDeleteChannel(channelId) {
try {
await API.deleteChannel(channelId);
showAlert('Channel deleted', 'success');
} catch (err) {
console.error(err);
showAlert('Failed to delete channel', 'error');
}
}
function handlePlayChannel(channel) {
// For your environment logic, adapt as needed
const vidUrl = `/output/stream/${channel.channel_number}`;
showVideo(vidUrl);
}
//
// -----------------------------
// 4) STREAM ACTIONS
// -----------------------------
//
function handleToggleStream(streamId) {
setSelectedStreamIds((prev) => {
if (prev.includes(streamId)) {
return prev.filter((id) => id !== streamId);
} else {
return [...prev, streamId];
}
});
}
function handleSelectAllStreams() {
if (selectedStreamIds.length === streams.length) {
setSelectedStreamIds([]);
} else {
setSelectedStreamIds(streams.map((s) => s.id));
}
}
async function handleRemoveStreams() {
if (selectedStreamIds.length === 0) return;
try {
await API.deleteStreams(selectedStreamIds);
setSelectedStreamIds([]);
showAlert(`Deleted ${selectedStreamIds.length} streams`, 'success');
} catch (err) {
console.error(err);
showAlert('Failed to remove streams', 'error');
}
}
// Bulk "create channels" from selected streams
async function handleCreateChannelsFromStreams() {
if (selectedStreamIds.length === 0) return;
// If your API is `createChannelsFromStreams()`, adapt below
const payload = selectedStreamIds.map((streamId) => {
const st = streams.find((s) => s.id === streamId);
return {
stream_id: st.id,
channel_name: st.name,
};
});
try {
await API.createChannelsFromStreams(payload);
showAlert(`Created channels from ${selectedStreamIds.length} streams`, 'success');
} catch (err) {
console.error(err);
showAlert('Failed to create channels', 'error');
}
}
function handleAddStream() {
setEditingStream(null);
setStreamFormOpen(true);
}
function handleEditStream(stream) {
setEditingStream(stream);
setStreamFormOpen(true);
}
async function handleDeleteStream(streamId) {
try {
await API.deleteStream(streamId);
showAlert('Stream deleted', 'success');
} catch (err) {
console.error(err);
showAlert('Failed to delete stream', 'error');
}
}
function handlePlayStream(stream) {
// If your environment logic differs, adapt as needed
const vidUrl = `/output/stream/${stream.id}`;
showVideo(vidUrl);
}
//
// -----------------------------
// 5) RENDER
// -----------------------------
//
return (
<Grid2 container>
<Grid2 size={6}>
<Box
sx={{
height: '100vh', // Full viewport height
paddingTop: 1, // Top padding
paddingBottom: 1, // Bottom padding
paddingRight: 0.5,
paddingLeft: 1,
boxSizing: 'border-box', // Include padding in height calculation
overflow: 'hidden', // Prevent parent scrolling
<Box
sx={{
display: 'flex',
bgcolor: 'background.paper',
backgroundColor: '#18181b', // Dark background from example
}}
>
{/* We do NOT replicate the example's built-in sidebar here
because your App.js + <Sidebar /> is already handling that.
So we skip the sidebar portion from the Figma code. */}
{/* Main content: 2 columns => Channels (left), Streams (right) */}
<Grid container spacing={1} sx={{ flex: 1, pt: 1 }}>
{/* ------------------------ */}
{/* CHANNELS SECTION */}
{/* ------------------------ */}
<Grid item xs={12} md={6}>
<Typography
variant="h6"
sx={{
mb: 4,
color: 'text.secondary',
fontWeight: 500,
}}
>
Channels
</Typography>
<Paper
sx={{
bgcolor: '#27272a',
borderRadius: 2,
overflow: 'hidden',
height: 'calc(100% - 40px)',
}}
>
{/* Toolbar for Channels */}
<Box
sx={{ p: 2, display: 'flex', justifyContent: 'space-between' }}
>
<Stack direction="row" spacing={1} alignItems="center">
<Typography color="text.secondary" fontSize={14}>
Links:
</Typography>
{['HDHR', 'M3U', 'EPG'].map((link) => (
<Chip
key={link}
label={link}
variant="outlined"
size="small"
sx={{
borderColor: '#3f3f46',
color: 'text.secondary',
fontSize: 14,
height: 28,
}}
onClick={() => {
// If you have a real link action, put it here
showAlert(`Clicked ${link}`, 'info');
}}
/>
))}
</Stack>
<Stack direction="row" spacing={1}>
<Button
variant="outlined"
size="small"
startIcon={<IndeterminateCheckBox />}
sx={{
borderColor: '#3f3f46',
color: 'text.secondary',
opacity: selectedChannelIds.length === 0 ? 0.4 : 1,
textTransform: 'none',
fontSize: 14,
}}
disabled={selectedChannelIds.length === 0}
onClick={handleRemoveChannels}
>
Remove
</Button>
<Button
variant="outlined"
size="small"
startIcon={<CompareArrows />}
sx={{
borderColor: '#3f3f46',
color: 'text.secondary',
textTransform: 'none',
fontSize: 14,
}}
onClick={handleAssignChannels}
>
Assign
</Button>
<Button
variant="outlined"
size="small"
startIcon={<Code />}
sx={{
borderColor: '#3f3f46',
color: 'text.secondary',
textTransform: 'none',
fontSize: 14,
}}
onClick={handleAutoMatch}
>
Auto-match
</Button>
<Button
variant="contained"
size="small"
startIcon={<AddBox sx={{ color: '#05DF72' }} />}
sx={{
bgcolor: '#0d542b',
borderColor: '#00a63e',
border: 1,
color: 'text.secondary',
textTransform: 'none',
fontSize: 14,
'&:hover': {
bgcolor: '#0a4020',
},
}}
onClick={handleAddChannel}
>
Add
</Button>
</Stack>
</Box>
{/* Channels Table */}
<TableContainer>
<Table size="small" sx={{ tableLayout: 'fixed' }}>
<TableHead>
<TableRow
sx={{
bgcolor: '#2f2f33',
borderBottom: 1,
borderColor: '#3f3f46',
}}
>
<TableCell
padding="checkbox"
sx={{ borderRight: 1, borderColor: '#3f3f46' }}
>
{/* "Select All" for Channels */}
<IconButton
size="small"
sx={{ color: 'text.secondary' }}
onClick={handleSelectAllChannels}
>
<CheckBoxOutlineBlank fontSize="small" />
</IconButton>
</TableCell>
<TableCell
sx={{
width: 40,
color: 'text.secondary',
borderRight: 1,
borderColor: '#3f3f46',
}}
>
#{' '}
<ArrowDownward
fontSize="small"
sx={{ verticalAlign: 'middle', ml: 1 }}
/>
</TableCell>
<TableCell
sx={{
color: 'text.secondary',
borderRight: 1,
borderColor: '#3f3f46',
}}
>
Name{' '}
<Sort
fontSize="small"
sx={{ verticalAlign: 'middle', ml: 1, opacity: 0.4 }}
/>
</TableCell>
<TableCell
sx={{
width: 140,
color: 'text.secondary',
borderRight: 1,
borderColor: '#3f3f46',
}}
>
Group{' '}
<MoreHoriz
fontSize="small"
sx={{ verticalAlign: 'middle', ml: 1 }}
/>
</TableCell>
<TableCell
sx={{
width: 80,
color: 'text.secondary',
borderRight: 1,
borderColor: '#3f3f46',
}}
>
Logo{' '}
<MoreHoriz
fontSize="small"
sx={{ verticalAlign: 'middle', ml: 1 }}
/>
</TableCell>
<TableCell
sx={{
width: 140,
color: 'text.secondary',
}}
>
Actions
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{channels.map((channel) => {
const isSelected = selectedChannelIds.includes(channel.id);
return (
<TableRow
key={channel.id}
sx={{
borderBottom: 1,
borderColor: '#3f3f46',
'&:hover': { bgcolor: '#2a2a2e' },
}}
>
<TableCell
padding="checkbox"
sx={{
borderRight: 1,
borderColor: '#3f3f46',
}}
>
<IconButton
size="small"
sx={{ color: 'text.secondary' }}
onClick={() => handleToggleChannel(channel.id)}
>
{isSelected ? (
<IndeterminateCheckBox fontSize="small" />
) : (
<CheckBoxOutlineBlank fontSize="small" />
)}
</IconButton>
</TableCell>
<TableCell
sx={{
color: 'text.secondary',
borderRight: 1,
borderColor: '#3f3f46',
}}
>
{channel.channel_number || channel.id}
</TableCell>
<TableCell
sx={{
color: 'text.secondary',
borderRight: 1,
borderColor: '#3f3f46',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{channel.channel_name}
</TableCell>
<TableCell
sx={{
color: 'text.secondary',
borderRight: 1,
borderColor: '#3f3f46',
}}
>
{channel.channel_group
? channel.channel_group.name
: ''}
</TableCell>
<TableCell
sx={{
borderRight: 1,
borderColor: '#3f3f46',
}}
>
{channel.logo_url ? (
<Box
component="img"
src={channel.logo_url}
sx={{
width: 28,
height: 21,
objectFit: 'cover',
}}
/>
) : (
<Box
sx={{
width: 28,
height: 21,
bgcolor: '#959595',
}}
/>
)}
</TableCell>
<TableCell>
<Stack
direction="row"
spacing={4}
alignItems="center"
>
<Stack
direction="row"
spacing={2}
sx={{
width: 60,
borderRight: 1,
borderColor: '#52525c',
}}
>
<IconButton
size="small"
sx={{ color: 'text.secondary' }}
onClick={() => handleEditChannel(channel)}
>
<Edit fontSize="small" />
</IconButton>
<IconButton
size="small"
sx={{ color: 'text.secondary' }}
onClick={() => handlePlayChannel(channel)}
>
<PlayCircle fontSize="small" />
</IconButton>
</Stack>
<IconButton
size="small"
sx={{ color: 'text.secondary' }}
onClick={() => handleDeleteChannel(channel.id)}
>
<CancelOutlined fontSize="small" />
</IconButton>
</Stack>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
</Paper>
</Grid>
{/* ------------------------ */}
{/* STREAMS SECTION */}
{/* ------------------------ */}
<Grid item xs={12} md={6}>
<Typography
variant="h6"
sx={{
mb: 4,
color: 'text.secondary',
fontWeight: 500,
}}
>
Streams
</Typography>
<Paper
sx={{
bgcolor: '#27272a',
borderRadius: 2,
border: 1,
borderColor: '#3f3f46',
overflow: 'hidden',
height: 'calc(100% - 40px)',
}}
>
{/* Toolbar for Streams */}
<Box sx={{ p: 2, display: 'flex', justifyContent: 'flex-end' }}>
<Stack direction="row" spacing={1}>
<Button
variant="outlined"
size="small"
startIcon={<IndeterminateCheckBox />}
sx={{
borderColor: '#3f3f46',
color: 'text.secondary',
opacity: selectedStreamIds.length === 0 ? 0.4 : 1,
textTransform: 'none',
fontSize: 14,
}}
disabled={selectedStreamIds.length === 0}
onClick={handleRemoveStreams}
>
Remove
</Button>
<Button
variant="outlined"
size="small"
startIcon={<AddBox />}
sx={{
borderColor: '#3f3f46',
color: 'text.secondary',
opacity: selectedStreamIds.length === 0 ? 0.5 : 1,
textTransform: 'none',
fontSize: 14,
}}
disabled={selectedStreamIds.length === 0}
onClick={handleCreateChannelsFromStreams}
>
Create channels
</Button>
<Button
variant="contained"
size="small"
sx={{
bgcolor: '#0d542b',
borderColor: '#00a63e',
border: 1,
color: 'text.secondary',
textTransform: 'none',
fontSize: 14,
'&:hover': {
bgcolor: '#0a4020',
},
}}
onClick={handleAddStream}
>
Add stream
</Button>
</Stack>
</Box>
{/* Streams Table */}
<TableContainer>
<Table size="small" sx={{ tableLayout: 'fixed' }}>
<TableHead>
<TableRow
sx={{
bgcolor: '#2f2f33',
borderBottom: 1,
borderColor: '#3f3f46',
}}
>
<TableCell
padding="checkbox"
sx={{ borderRight: 1, borderColor: '#3f3f46' }}
>
{/* "Select All" for Streams */}
<IconButton
size="small"
sx={{ color: 'text.secondary' }}
onClick={handleSelectAllStreams}
>
<CheckBoxOutlineBlank fontSize="small" />
</IconButton>
</TableCell>
<TableCell
sx={{
color: 'text.secondary',
borderRight: 1,
borderColor: '#3f3f46',
}}
>
Name{' '}
<Sort
fontSize="small"
sx={{ verticalAlign: 'middle', ml: 1, opacity: 0.4 }}
/>
</TableCell>
<TableCell
sx={{
width: 140,
color: 'text.secondary',
borderRight: 1,
borderColor: '#3f3f46',
}}
>
Group{' '}
<MoreHoriz
fontSize="small"
sx={{ verticalAlign: 'middle', ml: 1 }}
/>
</TableCell>
<TableCell
sx={{
width: 80,
color: 'text.secondary',
borderRight: 1,
borderColor: '#3f3f46',
}}
>
M3U{' '}
<MoreHoriz
fontSize="small"
sx={{ verticalAlign: 'middle', ml: 1 }}
/>
</TableCell>
<TableCell
sx={{
width: 140,
color: 'text.secondary',
}}
>
Actions
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{streams.map((stream) => {
const isSelected = selectedStreamIds.includes(stream.id);
return (
<TableRow
key={stream.id}
sx={{
borderBottom: 1,
borderColor: '#3f3f46',
'&:hover': { bgcolor: '#2a2a2e' },
}}
>
<TableCell
padding="checkbox"
sx={{
borderRight: 1,
borderColor: '#3f3f46',
}}
>
<IconButton
size="small"
sx={{ color: 'text.secondary' }}
onClick={() => handleToggleStream(stream.id)}
>
{isSelected ? (
<IndeterminateCheckBox fontSize="small" />
) : (
<CheckBoxOutlineBlank fontSize="small" />
)}
</IconButton>
</TableCell>
<TableCell
sx={{
color: 'text.secondary',
borderRight: 1,
borderColor: '#3f3f46',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{stream.name}
</TableCell>
<TableCell
sx={{
color: 'text.secondary',
borderRight: 1,
borderColor: '#3f3f46',
}}
>
{stream.group_name || ''}
</TableCell>
<TableCell
sx={{
color: 'text.secondary',
borderRight: 1,
borderColor: '#3f3f46',
}}
>
{/* If your store uses something else for "m3u" or "m3u_account",
adapt this line accordingly */}
{stream.m3u_account ? 'Yes' : 'No'}
</TableCell>
<TableCell>
<Stack
direction="row"
spacing={4}
alignItems="center"
>
<Stack
direction="row"
spacing={2}
sx={{
width: 60,
borderRight: 1,
borderColor: '#52525c',
}}
>
<IconButton
size="small"
sx={{ color: 'text.secondary' }}
onClick={() => handleEditStream(stream)}
>
<Edit fontSize="small" />
</IconButton>
<IconButton
size="small"
sx={{ color: 'text.secondary' }}
onClick={() => handlePlayStream(stream)}
>
<PlayCircle fontSize="small" />
</IconButton>
</Stack>
<IconButton
size="small"
sx={{ color: 'text.secondary' }}
onClick={() => handleDeleteStream(stream.id)}
>
<CancelOutlined fontSize="small" />
</IconButton>
</Stack>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
</Paper>
</Grid>
</Grid>
{/* Channel Form Modal */}
{channelFormOpen && (
<ChannelForm
channel={editingChannel}
isOpen={channelFormOpen}
onClose={() => {
setChannelFormOpen(false);
setEditingChannel(null);
}}
>
<ChannelsTable />
</Box>
</Grid2>
<Grid2 size={6}>
<Box
sx={{
height: '100vh', // Full viewport height
paddingTop: 1, // Top padding
paddingBottom: 1, // Bottom padding
paddingRight: 1,
paddingLeft: 0.5,
boxSizing: 'border-box', // Include padding in height calculation
overflow: 'hidden', // Prevent parent scrolling
/>
)}
{/* Stream Form Modal */}
{streamFormOpen && (
<StreamForm
stream={editingStream}
isOpen={streamFormOpen}
onClose={() => {
setStreamFormOpen(false);
setEditingStream(null);
}}
>
<StreamsTable />
</Box>
</Grid2>
</Grid2>
/>
)}
</Box>
);
};

View file

@ -1,21 +1,65 @@
// frontend/src/theme.js
import { createTheme } from '@mui/material/styles';
const theme = createTheme({
palette: {
mode: 'dark',
background: {
default: '#2B2C30', // Dark background
paper: '#333539', // Slightly lighter panel background
},
primary: {
main: '#495057',
contrastText: '#ffffff', // Ensure text is visible on primary color
// Adjust accent color if your Figma calls for a different highlight
main: '#4A90E2',
contrastText: '#FFFFFF',
},
secondary: {
main: '#F5A623',
contrastText: '#FFFFFF',
},
text: {
primary: '#FFFFFF',
secondary: '#C3C3C3',
},
},
typography: {
fontFamily: ['Roboto', 'Helvetica', 'Arial', 'sans-serif'].join(','),
// Example typography tweaks
h6: {
fontWeight: 500,
fontSize: '0.95rem',
},
body1: {
fontSize: '0.875rem',
},
},
components: {
MuiButton: {
styleOverrides: {
root: {
// textTransform: 'none', // Disable uppercase on buttons
borderRadius: 4,
textTransform: 'none',
fontWeight: 500,
},
},
},
MuiDrawer: {
styleOverrides: {
paper: {
backgroundColor: '#333539',
color: '#FFFFFF',
},
},
},
// We remove the AppBar override since we won't be using it in App.js anymore
MuiAppBar: {
styleOverrides: {
root: {
backgroundColor: '#2B2C30',
},
},
},
// Feel free to override more MUI components as needed...
},
});