Merge branch 'mantine' of https://github.com/Dispatcharr/Dispatcharr into Proxy-Redis

This commit is contained in:
SergeantPanda 2025-03-12 16:49:33 -05:00
commit b300ee5bcf
113 changed files with 7430 additions and 21597 deletions

View file

@ -1,26 +0,0 @@
import React, { useState } from 'react';
import { Snackbar, Alert, Button } from '@mui/material';
import useAlertStore from '../store/alerts';
const AlertPopup = () => {
const { open, message, severity, hideAlert } = useAlertStore();
const handleClose = () => {
hideAlert();
};
return (
<Snackbar
open={open}
autoHideDuration={5000}
onClose={handleClose}
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
>
<Alert onClose={handleClose} severity={severity} sx={{ width: '100%' }}>
{message}
</Alert>
</Snackbar>
);
};
export default AlertPopup;

View file

@ -8,6 +8,7 @@ export default function FloatingVideo() {
const { isVisible, streamUrl, hideVideo } = useVideoStore();
const videoRef = useRef(null);
const playerRef = useRef(null);
const videoContainerRef = useRef(null);
useEffect(() => {
if (!isVisible || !streamUrl) {
@ -48,8 +49,9 @@ export default function FloatingVideo() {
}
return (
<Draggable>
<Draggable nodeRef={videoContainerRef}>
<div
ref={videoContainerRef}
style={{
position: 'fixed',
bottom: '20px',

View file

@ -1,182 +0,0 @@
import React from 'react';
import { Link, useLocation } from 'react-router-dom';
import {
Drawer,
Toolbar,
Box,
Typography,
Avatar,
List,
ListItemButton,
ListItemIcon,
ListItemText,
} from '@mui/material';
import { useTheme } from '@mui/material/styles';
import {
ListOrdered,
Play,
Database,
SlidersHorizontal,
LayoutGrid,
Settings as LucideSettings,
} from 'lucide-react';
import logo from '../images/logo.png';
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, drawerWidth, miniDrawerWidth, toggleDrawer }) => {
const location = useLocation();
const theme = useTheme();
return (
<Drawer
variant="permanent"
PaperProps={{
sx: {
width: open ? drawerWidth : miniDrawerWidth,
overflowX: 'hidden',
transition: 'width 0.3s',
backgroundColor: theme.palette.background.default,
color: 'text.primary',
display: 'flex',
flexDirection: 'column',
boxShadow: 'none',
border: 'none',
},
}}
>
<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
key={item.path}
component={Link}
to={item.path}
sx={{
px: 2,
py: 0.5,
mx: 'auto',
display: 'flex',
justifyContent: 'center',
color: 'inherit',
width: '100%',
'&:hover': { backgroundColor: 'unset !important' },
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
borderRadius: 1,
width: open ? '208px' : 'auto',
transition: 'all 0.2s ease',
bgcolor: isActive
? theme.custom.sidebar.activeBackground
: 'transparent',
border: isActive
? `1px solid ${theme.custom.sidebar.activeBorder}`
: '1px solid transparent',
color: 'text.primary',
px: 1,
py: 0.25,
'&:hover': {
bgcolor: theme.custom.sidebar.hoverBackground,
border: `1px solid ${theme.custom.sidebar.hoverBorder}`,
},
}}
>
<ListItemIcon
sx={{
color: 'text.primary',
minWidth: 0,
mr: open ? 1 : 'auto',
justifyContent: 'center',
}}
>
{item.icon}
</ListItemIcon>
{open && (
<ListItemText
primary={item.label}
primaryTypographyProps={{
sx: {
fontSize: '14px',
fontWeight: 400,
fontFamily: theme.custom.sidebar.fontFamily,
letterSpacing: '-0.3px',
// Keeping the text color as it is in your original
color: isActive ? '#d4d4d8' : '#d4d4d8',
},
}}
/>
)}
</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>
);
};
export default Sidebar;

View file

@ -0,0 +1,227 @@
import React, { useRef } from 'react';
import { Link, useLocation } from 'react-router-dom';
import {
ListOrdered,
Play,
Database,
SlidersHorizontal,
LayoutGrid,
Settings as LucideSettings,
} from 'lucide-react';
import {
Avatar,
AppShell,
Group,
Stack,
Box,
Text,
UnstyledButton,
TextInput,
ActionIcon,
} from '@mantine/core';
import logo from '../images/logo.png';
import useChannelsStore from '../store/channels';
import './sidebar.css';
import useSettingsStore from '../store/settings';
import { ContentCopy } from '@mui/icons-material';
const NavLink = ({ item, isActive, collapsed }) => {
return (
<UnstyledButton
key={item.path}
component={Link}
to={item.path}
className={`navlink ${isActive ? 'navlink-active' : ''} ${collapsed ? 'navlink-collapsed' : ''}`}
>
{item.icon}
{!collapsed && (
<Text
sx={{
opacity: collapsed ? 0 : 1,
transition: 'opacity 0.2s ease-in-out',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
minWidth: collapsed ? 0 : 150,
}}
>
{item.label}
</Text>
)}
{!collapsed && item.badge && (
<Text size="sm" style={{ color: '#D4D4D8', whiteSpace: 'nowrap' }}>
{item.badge}
</Text>
)}
</UnstyledButton>
);
};
const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
const location = useLocation();
const { channels } = useChannelsStore();
const { environment } = useSettingsStore();
const publicIPRef = useRef(null);
// Navigation Items
const navItems = [
{
label: 'Channels',
icon: <ListOrdered size={20} />,
path: '/channels',
badge: `(${Object.keys(channels).length})`,
},
{ 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 copyPublicIP = async () => {
try {
await navigator.clipboard.writeText(environment.public_ip);
} catch (err) {
const inputElement = publicIPRef.current; // Get the actual input
console.log(inputElement);
if (inputElement) {
inputElement.focus();
inputElement.select();
// For older browsers
document.execCommand('copy');
}
}
};
return (
<AppShell.Navbar
width={{ base: collapsed ? miniDrawerWidth : drawerWidth }}
p="xs"
style={{
backgroundColor: '#1A1A1E',
// transition: 'width 0.3s ease',
borderRight: '1px solid #2A2A2E',
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
}}
>
{/* Brand - Click to Toggle */}
<Group
onClick={toggleDrawer}
spacing="sm"
style={{
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '16px 12px',
fontSize: 18,
fontWeight: 600,
color: '#FFFFFF',
justifyContent: collapsed ? 'center' : 'flex-start',
whiteSpace: 'nowrap',
}}
>
{/* <ListOrdered size={24} /> */}
<img width={30} src={logo} />
{!collapsed && (
<Text
sx={{
opacity: collapsed ? 0 : 1,
transition: 'opacity 0.2s ease-in-out',
whiteSpace: 'nowrap', // Ensures text never wraps
overflow: 'hidden',
textOverflow: 'ellipsis',
minWidth: collapsed ? 0 : 150, // Prevents reflow
}}
>
Dispatcharr
</Text>
)}
</Group>
{/* Navigation Links */}
<Stack gap="xs" mt="lg">
{navItems.map((item) => {
const isActive = location.pathname === item.path;
return (
<NavLink item={item} collapsed={collapsed} isActive={isActive} />
);
})}
</Stack>
{/* Profile Section */}
<Box
style={{
marginTop: 'auto',
padding: '16px',
display: 'flex',
alignItems: 'center',
gap: 10,
borderTop: '1px solid #2A2A2E',
justifyContent: collapsed ? 'center' : 'flex-start',
}}
>
<Group>
{!collapsed && (
<TextInput
label="Public IP"
ref={publicIPRef}
value={environment.public_ip}
leftSection={
environment.country_code && (
<img
src={`https://flagcdn.com/16x12/${environment.country_code.toLowerCase()}.png`}
alt={environment.country_name || environment.country_code}
title={environment.country_name || environment.country_code}
/>
)
}
rightSection={
<ActionIcon
variant="transparent"
color="gray.9"
onClick={copyPublicIP}
>
<ContentCopy />
</ActionIcon>
}
/>
)}
<Avatar src="https://via.placeholder.com/40" radius="xl" />
{!collapsed && (
<Group
style={{
flex: 1,
justifyContent: 'space-between',
whiteSpace: 'nowrap',
}}
>
<Text size="sm" color="white">
John Doe
</Text>
<Text size="sm" color="white">
</Text>
</Group>
)}
</Group>
</Box>
</AppShell.Navbar>
);
};
export default Sidebar;

View file

@ -1,491 +0,0 @@
import React, { useState, useEffect, useMemo } from 'react';
import {
Box,
Typography,
Stack,
TextField,
Button,
Select,
MenuItem,
Grid2,
InputLabel,
FormControl,
CircularProgress,
IconButton,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
FormHelperText,
} from '@mui/material';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import useChannelsStore from '../../store/channels';
import API from '../../api';
import useStreamProfilesStore from '../../store/streamProfiles';
import { Add as AddIcon, Remove as RemoveIcon } from '@mui/icons-material';
import useStreamsStore from '../../store/streams';
import {
MaterialReactTable,
useMaterialReactTable,
} from 'material-react-table';
import ChannelGroupForm from './ChannelGroup';
import usePlaylistsStore from '../../store/playlists';
import logo from '../../images/logo.png';
const Channel = ({ channel = null, isOpen, onClose }) => {
const channelGroups = useChannelsStore((state) => state.channelGroups);
const streams = useStreamsStore((state) => state.streams);
const { profiles: streamProfiles } = useStreamProfilesStore();
const { playlists } = usePlaylistsStore();
const [logoFile, setLogoFile] = useState(null);
const [logoPreview, setLogoPreview] = useState(logo);
const [channelStreams, setChannelStreams] = useState([]);
const [channelGroupModelOpen, setChannelGroupModalOpen] = useState(false);
const addStream = (stream) => {
const streamSet = new Set(channelStreams);
streamSet.add(stream);
setChannelStreams(Array.from(streamSet));
};
const removeStream = (stream) => {
const streamSet = new Set(channelStreams);
streamSet.delete(stream);
setChannelStreams(Array.from(streamSet));
};
const handleLogoChange = (e) => {
const file = e.target.files[0];
if (file) {
setLogoFile(file);
setLogoPreview(URL.createObjectURL(file));
}
};
const formik = useFormik({
initialValues: {
channel_name: '',
channel_number: '',
channel_group_id: '',
stream_profile_id: '0',
tvg_id: '',
tvg_name: '',
},
validationSchema: Yup.object({
channel_name: Yup.string().required('Name is required'),
channel_number: Yup.string().required('Invalid channel number').min(0),
channel_group_id: Yup.string().required('Channel group is required'),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
if (values.stream_profile_id == '0') {
values.stream_profile_id = null;
}
console.log(values);
if (channel?.id) {
await API.updateChannel({
id: channel.id,
...values,
logo_file: logoFile,
streams: channelStreams.map((stream) => stream.id),
});
} else {
await API.addChannel({
...values,
logo_file: logoFile,
streams: channelStreams.map((stream) => stream.id),
});
}
resetForm();
setLogoFile(null);
setLogoPreview(logo);
setSubmitting(false);
onClose();
},
});
useEffect(() => {
if (channel) {
formik.setValues({
channel_name: channel.channel_name,
channel_number: channel.channel_number,
channel_group_id: channel.channel_group?.id,
stream_profile_id: channel.stream_profile_id || '0',
tvg_id: channel.tvg_id,
tvg_name: channel.tvg_name,
});
console.log(channel);
const filteredStreams = streams
.filter((stream) => channel.stream_ids.includes(stream.id))
.sort(
(a, b) =>
channel.stream_ids.indexOf(a.id) - channel.stream_ids.indexOf(b.id)
);
setChannelStreams(filteredStreams);
} else {
formik.resetForm();
}
}, [channel]);
const activeStreamsTable = useMaterialReactTable({
data: channelStreams,
columns: useMemo(
() => [
{
header: 'Name',
accessorKey: 'name',
},
{
header: 'M3U',
accessorKey: 'group_name',
},
],
[]
),
enableSorting: false,
enableBottomToolbar: false,
enableTopToolbar: false,
columnFilterDisplayMode: 'popover',
enablePagination: false,
enableRowVirtualization: true,
enableRowOrdering: true,
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
initialState: {
density: 'compact',
},
enableRowActions: true,
positionActionsColumn: 'last',
renderRowActions: ({ row }) => (
<>
<IconButton
size="small" // Makes the button smaller
color="error" // Red color for delete actions
onClick={() => removeStream(row.original)}
>
<RemoveIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
</>
),
muiTableContainerProps: {
sx: {
height: '200px',
},
},
muiRowDragHandleProps: ({ table }) => ({
onDragEnd: () => {
const { draggingRow, hoveredRow } = table.getState();
if (hoveredRow && draggingRow) {
channelStreams.splice(
hoveredRow.index,
0,
channelStreams.splice(draggingRow.index, 1)[0]
);
setChannelStreams([...channelStreams]);
}
},
}),
});
const availableStreamsTable = useMaterialReactTable({
data: streams,
columns: useMemo(
() => [
{
header: 'Name',
accessorKey: 'name',
},
{
header: 'M3U',
accessorFn: (row) =>
playlists.find((playlist) => playlist.id === row.m3u_account)?.name,
},
],
[]
),
enableBottomToolbar: false,
enableTopToolbar: false,
columnFilterDisplayMode: 'popover',
enablePagination: false,
enableRowVirtualization: true,
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
initialState: {
density: 'compact',
},
enableRowActions: true,
renderRowActions: ({ row }) => (
<>
<IconButton
size="small" // Makes the button smaller
color="success" // Red color for delete actions
onClick={() => addStream(row.original)}
>
<AddIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
</>
),
positionActionsColumn: 'last',
muiTableContainerProps: {
sx: {
height: '200px',
},
},
});
if (!isOpen) {
return <></>;
}
return (
<>
<Dialog open={isOpen} onClose={onClose} fullWidth maxWidth="lg">
<DialogTitle
sx={{
backgroundColor: 'primary.main',
color: 'primary.contrastText',
}}
>
Channel
</DialogTitle>
<form onSubmit={formik.handleSubmit}>
<DialogContent>
<Grid2 container spacing={2}>
<Grid2 size={6}>
<TextField
fullWidth
id="channel_name"
name="channel_name"
label="Channel Name"
value={formik.values.channel_name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.channel_name &&
Boolean(formik.errors.channel_name)
}
helperText={
formik.touched.channel_name && formik.errors.channel_name
}
variant="standard"
/>
<Grid2
container
spacing={1}
sx={{
alignItems: 'center',
}}
>
<Grid2 size={11}>
<FormControl variant="standard" fullWidth>
<InputLabel id="channel-group-label">
Channel Group
</InputLabel>
<Select
labelId="channel-group-label"
id="channel_group_id"
name="channel_group_id"
label="Channel Group"
value={formik.values.channel_group_id}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.channel_group_id &&
Boolean(formik.errors.channel_group_id)
}
// helperText={formik.touched.channel_group_id && formik.errors.channel_group_id}
variant="standard"
>
{channelGroups.map((option, index) => (
<MenuItem key={index} value={option.id}>
{option.name}
</MenuItem>
))}
</Select>
<FormHelperText sx={{ color: 'error.main' }}>
{formik.touched.channel_group_id &&
formik.errors.channel_group_id}
</FormHelperText>
</FormControl>
</Grid2>
<Grid2 size={1}>
<IconButton
color="success"
onClick={() => setChannelGroupModalOpen(true)}
title="Create new group"
size="small"
variant="filled"
>
<AddIcon fontSize="small" />
</IconButton>
</Grid2>
</Grid2>
<FormControl variant="standard" fullWidth>
<InputLabel id="stream-profile-label">
Stream Profile
</InputLabel>
<Select
labelId="stream-profile-label"
id="stream_profile_id"
name="stream_profile_id"
value={formik.values.stream_profile_id}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.stream_profile_id &&
Boolean(formik.errors.stream_profile_id)
}
// helperText={formik.touched.channel_group_id && formik.errors.stream_profile_id}
variant="standard"
>
<MenuItem value="0" selected>
<em>Use Default</em>
</MenuItem>
{streamProfiles.map((option, index) => (
<MenuItem key={index} value={option.id}>
{option.profile_name}
</MenuItem>
))}
</Select>
</FormControl>
<TextField
fullWidth
id="channel_number"
name="channel_number"
label="Channel #"
value={formik.values.channel_number}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.channel_number &&
Boolean(formik.errors.channel_number)
}
helperText={
formik.touched.channel_number &&
formik.errors.channel_number
}
variant="standard"
/>
</Grid2>
<Grid2 size={6}>
<TextField
fullWidth
id="tvg_name"
name="tvg_name"
label="TVG Name"
value={formik.values.tvg_name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.tvg_name && Boolean(formik.errors.tvg_name)
}
helperText={formik.touched.tvg_name && formik.errors.tvg_name}
variant="standard"
/>
<TextField
fullWidth
id="tvg_id"
name="tvg_id"
label="TVG ID"
value={formik.values.tvg_id}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={formik.touched.tvg_id && Boolean(formik.errors.tvg_id)}
helperText={formik.touched.tvg_id && formik.errors.tvg_id}
variant="standard"
/>
<TextField
fullWidth
id="logo_url"
name="logo_url"
label="Logo URL (Optional)"
variant="standard"
sx={{ marginBottom: 2 }}
value={formik.values.logo_url}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
helperText="If you have a direct image URL, set it here."
/>
<Box mt={2} mb={2}>
{/* File upload input */}
<Stack
direction="row"
spacing={2}
sx={{
alignItems: 'center',
}}
>
<Typography>Logo</Typography>
{/* Display selected image */}
<Box mb={2}>
<img
src={logoPreview}
alt="Selected"
style={{ maxWidth: 50, height: 'auto' }}
/>
</Box>
<input
type="file"
id="logo"
name="logo"
accept="image/*"
onChange={(event) => handleLogoChange(event)}
style={{ display: 'none' }}
/>
<label htmlFor="logo">
<Button variant="contained" component="span" size="small">
Browse...
</Button>
</label>
</Stack>
</Box>
</Grid2>
</Grid2>
<Grid2 container spacing={2}>
<Grid2 size={6}>
<Typography>Active Streams</Typography>
<MaterialReactTable table={activeStreamsTable} />
</Grid2>
<Grid2 size={6}>
<Typography>Available Streams</Typography>
<MaterialReactTable table={availableStreamsTable} />
</Grid2>
</Grid2>
</DialogContent>
<DialogActions>
{/* Submit button */}
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
>
{formik.isSubmitting ? <CircularProgress size={24} /> : 'Submit'}
</Button>
</DialogActions>
</form>
</Dialog>
<ChannelGroupForm
isOpen={channelGroupModelOpen}
onClose={() => setChannelGroupModalOpen(false)}
/>
</>
);
};
export default Channel;

View file

@ -0,0 +1,420 @@
import React, { useState, useEffect, useMemo } from 'react';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import useChannelsStore from '../../store/channels';
import API from '../../api';
import useStreamProfilesStore from '../../store/streamProfiles';
import { Add as AddIcon, Remove as RemoveIcon } from '@mui/icons-material';
import useStreamsStore from '../../store/streams';
import { MantineReactTable, useMantineReactTable } from 'mantine-react-table';
import ChannelGroupForm from './ChannelGroup';
import usePlaylistsStore from '../../store/playlists';
import logo from '../../images/logo.png';
import {
Box,
Button,
Modal,
TextInput,
NativeSelect,
Text,
Group,
ActionIcon,
Center,
Grid,
Flex,
} from '@mantine/core';
import { SquarePlus } from 'lucide-react';
const Channel = ({ channel = null, isOpen, onClose }) => {
const channelGroups = useChannelsStore((state) => state.channelGroups);
const streams = useStreamsStore((state) => state.streams);
const { profiles: streamProfiles } = useStreamProfilesStore();
const { playlists } = usePlaylistsStore();
const [logoFile, setLogoFile] = useState(null);
const [logoPreview, setLogoPreview] = useState(logo);
const [channelStreams, setChannelStreams] = useState([]);
const [channelGroupModelOpen, setChannelGroupModalOpen] = useState(false);
const addStream = (stream) => {
const streamSet = new Set(channelStreams);
streamSet.add(stream);
setChannelStreams(Array.from(streamSet));
};
const removeStream = (stream) => {
const streamSet = new Set(channelStreams);
streamSet.delete(stream);
setChannelStreams(Array.from(streamSet));
};
const handleLogoChange = (e) => {
const file = e.target.files[0];
if (file) {
setLogoFile(file);
setLogoPreview(URL.createObjectURL(file));
}
};
const formik = useFormik({
initialValues: {
channel_name: '',
channel_number: '',
channel_group_id: '',
stream_profile_id: '0',
tvg_id: '',
tvg_name: '',
},
validationSchema: Yup.object({
channel_name: Yup.string().required('Name is required'),
channel_number: Yup.string().required('Invalid channel number').min(0),
channel_group_id: Yup.string().required('Channel group is required'),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
if (values.stream_profile_id == '0') {
values.stream_profile_id = null;
}
console.log(values);
if (channel?.id) {
await API.updateChannel({
id: channel.id,
...values,
logo_file: logoFile,
streams: channelStreams.map((stream) => stream.id),
});
} else {
await API.addChannel({
...values,
logo_file: logoFile,
streams: channelStreams.map((stream) => stream.id),
});
}
resetForm();
setLogoFile(null);
setLogoPreview(logo);
setSubmitting(false);
onClose();
},
});
useEffect(() => {
if (channel) {
formik.setValues({
channel_name: channel.channel_name,
channel_number: channel.channel_number,
channel_group_id: channel.channel_group?.id,
stream_profile_id: channel.stream_profile_id || '0',
tvg_id: channel.tvg_id,
tvg_name: channel.tvg_name,
});
console.log(channel);
setChannelStreams(channel.streams);
} else {
formik.resetForm();
}
}, [channel]);
// const activeStreamsTable = useMantineReactTable({
// data: channelStreams,
// columns: useMemo(
// () => [
// {
// header: 'Name',
// accessorKey: 'name',
// Cell: ({ cell }) => (
// <div
// style={{
// whiteSpace: 'nowrap',
// overflow: 'hidden',
// textOverflow: 'ellipsis',
// }}
// >
// {cell.getValue()}
// </div>
// ),
// },
// {
// header: 'M3U',
// accessorKey: 'group_name',
// Cell: ({ cell }) => (
// <div
// style={{
// whiteSpace: 'nowrap',
// overflow: 'hidden',
// textOverflow: 'ellipsis',
// }}
// >
// {cell.getValue()}
// </div>
// ),
// },
// ],
// []
// ),
// enableSorting: false,
// enableBottomToolbar: false,
// enableTopToolbar: false,
// columnFilterDisplayMode: 'popover',
// enablePagination: false,
// enableRowVirtualization: true,
// enableRowOrdering: true,
// rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
// initialState: {
// density: 'compact',
// },
// enableRowActions: true,
// positionActionsColumn: 'last',
// renderRowActions: ({ row }) => (
// <>
// <IconButton
// size="small" // Makes the button smaller
// color="error" // Red color for delete actions
// onClick={() => removeStream(row.original)}
// >
// <RemoveIcon fontSize="small" /> {/* Small icon size */}
// </IconButton>
// </>
// ),
// mantineTableContainerProps: {
// style: {
// height: '200px',
// },
// },
// mantineRowDragHandleProps: ({ table }) => ({
// onDragEnd: () => {
// const { draggingRow, hoveredRow } = table.getState();
// if (hoveredRow && draggingRow) {
// channelStreams.splice(
// hoveredRow.index,
// 0,
// channelStreams.splice(draggingRow.index, 1)[0]
// );
// setChannelStreams([...channelStreams]);
// }
// },
// }),
// });
// const availableStreamsTable = useMantineReactTable({
// data: streams,
// columns: useMemo(
// () => [
// {
// header: 'Name',
// accessorKey: 'name',
// },
// {
// header: 'M3U',
// accessorFn: (row) =>
// playlists.find((playlist) => playlist.id === row.m3u_account)?.name,
// },
// ],
// []
// ),
// enableBottomToolbar: false,
// enableTopToolbar: false,
// columnFilterDisplayMode: 'popover',
// enablePagination: false,
// enableRowVirtualization: true,
// rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
// initialState: {
// density: 'compact',
// },
// enableRowActions: true,
// renderRowActions: ({ row }) => (
// <>
// <IconButton
// size="small" // Makes the button smaller
// color="success" // Red color for delete actions
// onClick={() => addStream(row.original)}
// >
// <AddIcon fontSize="small" /> {/* Small icon size */}
// </IconButton>
// </>
// ),
// positionActionsColumn: 'last',
// mantineTableContainerProps: {
// style: {
// height: '200px',
// },
// },
// });
if (!isOpen) {
return <></>;
}
return (
<>
<Modal opened={isOpen} onClose={onClose} size={800} title="Channel">
<form onSubmit={formik.handleSubmit}>
<Grid gap={2}>
<Grid.Col span={6}>
<TextInput
id="channel_name"
name="channel_name"
label="Channel Name"
value={formik.values.channel_name}
onChange={formik.handleChange}
error={
formik.errors.channel_name ? formik.touched.channel_name : ''
}
/>
<Grid>
<Grid.Col span={11}>
<NativeSelect
id="channel_group_id"
name="channel_group_id"
label="Channel Group"
value={formik.values.channel_group_id}
onChange={formik.handleChange}
error={
formik.errors.channel_group_id
? formik.touched.channel_group_id
: ''
}
data={channelGroups.map((option, index) => ({
value: `${option.id}`,
label: option.name,
}))}
/>
</Grid.Col>
<Grid.Col span={1}>
<ActionIcon
color="green.5"
onClick={() => setChannelGroupModalOpen(true)}
title="Create new group"
size="small"
variant="light"
style={{ marginTop: '175%' }} // @TODO: I don't like this, figure out better placement
>
<SquarePlus />
</ActionIcon>
</Grid.Col>
</Grid>
<NativeSelect
id="stream_profile_id"
label="Stream Profile"
name="stream_profile_id"
value={formik.values.stream_profile_id}
onChange={formik.handleChange}
error={
formik.errors.stream_profile_id
? formik.touched.stream_profile_id
: ''
}
data={streamProfiles.map((option) => ({
value: `${option.id}`,
label: option.profile_name,
}))}
/>
<TextInput
id="channel_number"
name="channel_number"
label="Channel #"
value={formik.values.channel_number}
onChange={formik.handleChange}
error={
formik.errors.channel_number
? formik.touched.channel_number
: ''
}
/>
</Grid.Col>
<Grid.Col span={6}>
<TextInput
id="tvg_name"
name="tvg_name"
label="TVG Name"
value={formik.values.tvg_name}
onChange={formik.handleChange}
error={formik.errors.tvg_name ? formik.touched.tvg_name : ''}
/>
<TextInput
id="tvg_id"
name="tvg_id"
label="TVG ID"
value={formik.values.tvg_id}
onChange={formik.handleChange}
error={formik.errors.tvg_id ? formik.touched.tvg_id : ''}
/>
<TextInput
id="logo_url"
name="logo_url"
label="Logo URL (Optional)"
style={{ marginBottom: 2 }}
value={formik.values.logo_url}
onChange={formik.handleChange}
/>
<Group style={{ paddingTop: 10 }}>
<Text>Logo</Text>
{/* Display selected image */}
<Box>
<img
src={logoPreview}
alt="Selected"
style={{ maxWidth: 50, height: 'auto' }}
/>
</Box>
<input
type="file"
id="logo"
name="logo"
accept="image/*"
onChange={(event) => handleLogoChange(event)}
style={{ display: 'none' }}
/>
<label htmlFor="logo">
<Button variant="contained" component="span" size="small">
Browse...
</Button>
</label>
</Group>
</Grid.Col>
</Grid>
{/* <Grid gap={2}>
<Grid.Col span={6}>
<Typography>Active Streams</Typography>
<MantineReactTable table={activeStreamsTable} />
</Grid.Col>
<Grid.Col span={6}>
<Typography>Available Streams</Typography>
<MantineReactTable table={availableStreamsTable} />
</Grid.Col>
</Grid> */}
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
>
Submit
</Button>
</Flex>
</form>
</Modal>
<ChannelGroupForm
isOpen={channelGroupModelOpen}
onClose={() => setChannelGroupModalOpen(false)}
/>
</>
);
};
export default Channel;

View file

@ -1,94 +0,0 @@
// Modal.js
import React, { useEffect } from "react";
import {
TextField,
Button,
CircularProgress,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from "@mui/material";
import { useFormik } from "formik";
import * as Yup from "yup";
import API from "../../api";
const ChannelGroup = ({ channelGroup = null, isOpen, onClose }) => {
const formik = useFormik({
initialValues: {
name: "",
},
validationSchema: Yup.object({
name: Yup.string().required("Name is required"),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
if (channelGroup?.id) {
await API.updateChannelGroup({ id: channelGroup.id, ...values });
} else {
await API.addChannelGroup(values);
}
resetForm();
setSubmitting(false);
onClose();
},
});
useEffect(() => {
if (channelGroup) {
formik.setValues({
name: channelGroup.name,
});
} else {
formik.resetForm();
}
}, [channelGroup]);
if (!isOpen) {
return <></>;
}
return (
<Dialog open={isOpen} onClose={onClose}>
<DialogTitle
sx={{
backgroundColor: "primary.main",
color: "primary.contrastText",
}}
>
Channel Group
</DialogTitle>
<form onSubmit={formik.handleSubmit}>
<DialogContent>
<TextField
fullWidth
id="name"
name="name"
label="Name"
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={formik.touched.name && Boolean(formik.errors.name)}
helperText={formik.touched.name && formik.errors.name}
variant="standard"
/>
</DialogContent>
<DialogActions>
{/* Submit button */}
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
size="small"
>
{formik.isSubmitting ? <CircularProgress size={24} /> : "Submit"}
</Button>
</DialogActions>
</form>
</Dialog>
);
};
export default ChannelGroup;

View file

@ -0,0 +1,71 @@
// Modal.js
import React, { useEffect } from 'react';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import API from '../../api';
import { Flex, TextInput, Button, Modal } from '@mantine/core';
const ChannelGroup = ({ channelGroup = null, isOpen, onClose }) => {
const formik = useFormik({
initialValues: {
name: '',
},
validationSchema: Yup.object({
name: Yup.string().required('Name is required'),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
if (channelGroup?.id) {
await API.updateChannelGroup({ id: channelGroup.id, ...values });
} else {
await API.addChannelGroup(values);
}
resetForm();
setSubmitting(false);
onClose();
},
});
useEffect(() => {
if (channelGroup) {
formik.setValues({
name: channelGroup.name,
});
} else {
formik.resetForm();
}
}, [channelGroup]);
if (!isOpen) {
return <></>;
}
return (
<Modal opened={isOpen} onClose={onClose} title="Channel Group">
<form onSubmit={formik.handleSubmit}>
<TextInput
id="name"
name="name"
label="Name"
value={formik.values.name}
onChange={formik.handleChange}
error={formik.touched.name}
/>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
size="small"
>
Submit
</Button>
</Flex>
</form>
</Modal>
);
};
export default ChannelGroup;

View file

@ -1,175 +0,0 @@
// Modal.js
import React, { useState, useEffect } from 'react';
import {
TextField,
Button,
Select,
MenuItem,
InputLabel,
FormControl,
CircularProgress,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from '@mui/material';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import API from '../../api';
import useEPGsStore from '../../store/epgs';
const EPG = ({ epg = null, isOpen, onClose }) => {
const epgs = useEPGsStore((state) => state.epgs);
const [file, setFile] = useState(null);
const handleFileChange = (e) => {
const file = e.target.files[0];
if (file) {
setFile(file);
}
};
const formik = useFormik({
initialValues: {
name: '',
source_type: '',
url: '',
api_key: '',
is_active: true,
},
validationSchema: Yup.object({
name: Yup.string().required('Name is required'),
source_type: Yup.string().required('Source type is required'),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
if (epg?.id) {
await API.updateEPG({ id: epg.id, ...values, epg_file: file });
} else {
await API.addEPG({
...values,
epg_file: file,
});
}
resetForm();
setFile(null);
setSubmitting(false);
onClose();
},
});
useEffect(() => {
if (epg) {
formik.setValues({
name: epg.name,
source_type: epg.source_type,
url: epg.url,
api_key: epg.api_key,
is_active: epg.is_active,
});
} else {
formik.resetForm();
}
}, [epg]);
if (!isOpen) {
return <></>;
}
return (
<Dialog open={isOpen} onClose={onClose}>
<DialogTitle
sx={{
backgroundColor: 'primary.main',
color: 'primary.contrastText',
}}
>
EPG Source
</DialogTitle>
<form onSubmit={formik.handleSubmit}>
<DialogContent>
<TextField
fullWidth
id="name"
name="name"
label="Name"
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={formik.touched.name && Boolean(formik.errors.name)}
helperText={formik.touched.name && formik.errors.name}
variant="standard"
/>
<TextField
fullWidth
id="url"
name="url"
label="URL"
value={formik.values.url}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={formik.touched.url && Boolean(formik.errors.url)}
helperText={formik.touched.url && formik.errors.url}
variant="standard"
/>
<TextField
fullWidth
id="api_key"
name="api_key"
label="API Key"
value={formik.values.api_key}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={formik.touched.api_key && Boolean(formik.errors.api_key)}
helperText={formik.touched.api_key && formik.errors.api_key}
variant="standard"
/>
<FormControl variant="standard" fullWidth>
<InputLabel id="source-type-label">Source Type</InputLabel>
<Select
labelId="source-type-label"
id="source_type"
name="source_type"
label="Source Type"
value={formik.values.source_type}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.source_type && Boolean(formik.errors.source_type)
}
helperText={
formik.touched.source_type && formik.errors.source_type
}
variant="standard"
>
<MenuItem key="0" value="xmltv">
XMLTV
</MenuItem>
<MenuItem key="1" value="schedules_direct">
Schedules Direct
</MenuItem>
</Select>
</FormControl>
</DialogContent>
<DialogActions>
{/* Submit button */}
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
size="small"
>
{formik.isSubmitting ? <CircularProgress size={24} /> : 'Submit'}
</Button>
</DialogActions>
</form>
</Dialog>
);
};
export default EPG;

View file

@ -0,0 +1,143 @@
// Modal.js
import React, { useState, useEffect } from 'react';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import API from '../../api';
import useEPGsStore from '../../store/epgs';
import {
LoadingOverlay,
TextInput,
Button,
Checkbox,
Modal,
Flex,
NativeSelect,
FileInput,
Space,
} from '@mantine/core';
const EPG = ({ epg = null, isOpen, onClose }) => {
const epgs = useEPGsStore((state) => state.epgs);
const [file, setFile] = useState(null);
const handleFileChange = (e) => {
const file = e.target.files[0];
if (file) {
setFile(file);
}
};
const formik = useFormik({
initialValues: {
name: '',
source_type: 'xmltv',
url: '',
api_key: '',
is_active: true,
},
validationSchema: Yup.object({
name: Yup.string().required('Name is required'),
source_type: Yup.string().required('Source type is required'),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
if (epg?.id) {
await API.updateEPG({ id: epg.id, ...values, epg_file: file });
} else {
await API.addEPG({
...values,
epg_file: file,
});
}
resetForm();
setFile(null);
setSubmitting(false);
onClose();
},
});
useEffect(() => {
if (epg) {
formik.setValues({
name: epg.name,
source_type: epg.source_type,
url: epg.url,
api_key: epg.api_key,
is_active: epg.is_active,
});
} else {
formik.resetForm();
}
}, [epg]);
if (!isOpen) {
return <></>;
}
return (
<Modal opened={isOpen} onClose={onClose} title="EPG Source">
<form onSubmit={formik.handleSubmit}>
<TextInput
id="name"
name="name"
label="Name"
value={formik.values.name}
onChange={formik.handleChange}
error={formik.touched.name && Boolean(formik.errors.name)}
/>
<TextInput
id="url"
name="url"
label="URL"
value={formik.values.url}
onChange={formik.handleChange}
error={formik.touched.url && Boolean(formik.errors.url)}
/>
<TextInput
id="api_key"
name="api_key"
label="API Key"
value={formik.values.api_key}
onChange={formik.handleChange}
error={formik.touched.api_key && Boolean(formik.errors.api_key)}
/>
<NativeSelect
id="source_type"
name="source_type"
label="Source Type"
value={formik.values.source_type}
onChange={formik.handleChange}
error={
formik.touched.source_type && Boolean(formik.errors.source_type)
}
data={[
{
label: 'XMLTV',
value: 'xmltv',
},
{
label: 'Schedules Direct',
value: 'schedules_direct',
},
]}
/>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button
type="submit"
variant="contained"
disabled={formik.isSubmitting}
size="small"
>
Submit
</Button>
</Flex>
</form>
</Modal>
);
};
export default EPG;

View file

@ -1,111 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import useAuthStore from '../../store/auth';
import {
Box,
TextField,
Button,
Typography,
Grid2,
Paper,
} from '@mui/material';
const LoginForm = () => {
const { login, isAuthenticated, initData } = useAuthStore(); // Get login function from AuthContext
const navigate = useNavigate(); // Hook to navigate to other routes
const [formData, setFormData] = useState({ username: '', password: '' });
useEffect(() => {
if (isAuthenticated) {
navigate('/channels');
}
}, [isAuthenticated, navigate]);
const handleInputChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value,
});
};
const handleSubmit = async (e) => {
e.preventDefault();
await login(formData);
initData();
navigate('/channels'); // Or any other route you'd like
};
// // Handle form submission
// const handleSubmit = async (e) => {
// e.preventDefault();
// setLoading(true);
// setError(''); // Reset error on each new submission
// await login(username, password)
// navigate('/channels'); // Or any other route you'd like
// };
return (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
backgroundColor: '#f5f5f5',
}}
>
<Paper elevation={3} sx={{ padding: 3, width: '100%', maxWidth: 400 }}>
<Typography variant="h5" align="center" gutterBottom>
Login
</Typography>
<form onSubmit={handleSubmit}>
<Grid2
container
spacing={2}
justifyContent="center"
direction="column"
>
<Grid2 xs={12}>
<TextField
label="Username"
variant="standard"
fullWidth
name="username"
value={formData.username}
onChange={handleInputChange}
required
size="small"
/>
</Grid2>
<Grid2 xs={12}>
<TextField
label="Password"
variant="standard"
type="password"
fullWidth
name="password"
value={formData.password}
onChange={handleInputChange}
required
size="small"
/>
</Grid2>
<Grid2 xs={12}>
<Button
type="submit"
variant="contained"
color="primary"
fullWidth
>
Submit
</Button>
</Grid2>
</Grid2>
</form>
</Paper>
</Box>
);
};
export default LoginForm;

View file

@ -0,0 +1,93 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import useAuthStore from '../../store/auth';
import {
Paper,
Title,
TextInput,
Button,
Checkbox,
Modal,
Box,
Center,
Stack,
} from '@mantine/core';
const LoginForm = () => {
const { login, isAuthenticated, initData } = useAuthStore(); // Get login function from AuthContext
const navigate = useNavigate(); // Hook to navigate to other routes
const [formData, setFormData] = useState({ username: '', password: '' });
useEffect(() => {
if (isAuthenticated) {
navigate('/channels');
}
}, [isAuthenticated, navigate]);
const handleInputChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value,
});
};
const handleSubmit = async (e) => {
e.preventDefault();
await login(formData);
initData();
navigate('/channels'); // Or any other route you'd like
};
// // Handle form submission
// const handleSubmit = async (e) => {
// e.preventDefault();
// setLoading(true);
// setError(''); // Reset error on each new submission
// await login(username, password)
// navigate('/channels'); // Or any other route you'd like
// };
return (
<Center
style={{
height: '100vh',
}}
>
<Paper
elevation={3}
style={{ padding: 30, width: '100%', maxWidth: 400 }}
>
<Title order={4} align="center">
Login
</Title>
<form onSubmit={handleSubmit}>
<Stack>
<TextInput
label="Username"
name="username"
value={formData.username}
onChange={handleInputChange}
required
/>
<TextInput
label="Password"
type="password"
name="password"
value={formData.password}
onChange={handleInputChange}
required
/>
<Button type="submit" size="sm" sx={{ pt: 1 }}>
Submit
</Button>
</Stack>
</form>
</Paper>
</Center>
);
};
export default LoginForm;

View file

@ -1,246 +0,0 @@
// Modal.js
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
Stack,
TextField,
Button,
Select,
MenuItem,
InputLabel,
FormControl,
CircularProgress,
FormControlLabel,
Checkbox,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from '@mui/material';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import API from '../../api';
import useUserAgentsStore from '../../store/userAgents';
import M3UProfiles from './M3UProfiles';
const M3U = ({ playlist = null, isOpen, onClose }) => {
const userAgents = useUserAgentsStore((state) => state.userAgents);
const [file, setFile] = useState(null);
const [profileModalOpen, setProfileModalOpen] = useState(false);
const handleFileChange = (e) => {
const file = e.target.files[0];
if (file) {
setFile(file);
}
};
const formik = useFormik({
initialValues: {
name: '',
server_url: '',
user_agent: '',
is_active: true,
max_streams: 0,
},
validationSchema: Yup.object({
name: Yup.string().required('Name is required'),
server_url: Yup.string().required('Server URL is required'),
user_agent: Yup.string().required('User-Agent is required'),
max_streams: Yup.string().required('Max streams is required'),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
if (playlist?.id) {
await API.updatePlaylist({
id: playlist.id,
...values,
uploaded_file: file,
});
} else {
await API.addPlaylist({
...values,
uploaded_file: file,
});
}
resetForm();
setFile(null);
setSubmitting(false);
onClose();
},
});
useEffect(() => {
if (playlist) {
formik.setValues({
name: playlist.name,
server_url: playlist.server_url,
max_streams: playlist.max_streams,
user_agent: playlist.user_agent,
is_active: playlist.is_active,
});
} else {
formik.resetForm();
}
}, [playlist]);
if (!isOpen) {
return <></>;
}
return (
<Dialog open={isOpen} onClose={onClose}>
<DialogTitle
sx={{
backgroundColor: 'primary.main',
color: 'primary.contrastText',
}}
>
M3U Account
</DialogTitle>
<form onSubmit={formik.handleSubmit}>
<DialogContent>
<TextField
fullWidth
id="name"
name="name"
label="Name"
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={formik.touched.name && Boolean(formik.errors.name)}
helperText={formik.touched.name && formik.errors.name}
variant="standard"
/>
<TextField
fullWidth
id="server_url"
name="server_url"
label="URL"
value={formik.values.server_url}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.server_url && Boolean(formik.errors.server_url)
}
helperText={formik.touched.server_url && formik.errors.server_url}
variant="standard"
/>
<Box mb={2}>
<Stack
direction="row"
spacing={2}
sx={{
alignItems: 'center',
pt: 2,
}}
>
<Typography>File</Typography>
<input
type="file"
id="uploaded_file"
name="uploaded_file"
accept="image/*"
onChange={(event) => handleFileChange(event)}
style={{ display: 'none' }}
/>
<label htmlFor="uploaded_file">
<Button variant="contained" component="span">
Browse...
</Button>
</label>
</Stack>
</Box>
<TextField
fullWidth
id="max_streams"
name="max_streams"
label="Max Streams (0 = unlimited)"
value={formik.values.max_streams}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.max_streams && Boolean(formik.errors.max_streams)
}
helperText={formik.touched.max_streams && formik.errors.max_streams}
variant="standard"
/>
<FormControl variant="standard" fullWidth>
<InputLabel id="user-agent-label">User-Agent</InputLabel>
<Select
labelId="user-agent-label"
id="user_agent"
name="user_agent"
label="User-Agent"
value={formik.values.user_agent}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.user_agent && Boolean(formik.errors.user_agent)
}
// helperText={formik.touched.user_agent && formik.errors.user_agent}
variant="standard"
>
{userAgents.map((option, index) => (
<MenuItem key={index} value={option.id}>
{option.user_agent_name}
</MenuItem>
))}
</Select>
</FormControl>
<FormControlLabel
control={
<Checkbox
name="is_active"
checked={formik.values.is_active}
onChange={(e) =>
formik.setFieldValue('is_active', e.target.checked)
}
/>
}
label="Is Active"
/>
</DialogContent>
<DialogActions>
<Button
variant="contained"
color="primary"
size="small"
onClick={() => setProfileModalOpen(true)}
>
Profiles
</Button>
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
size="small"
>
{formik.isSubmitting ? <CircularProgress size={24} /> : 'Submit'}
</Button>
</DialogActions>
{playlist && (
<M3UProfiles
playlist={playlist}
isOpen={profileModalOpen}
onClose={() => setProfileModalOpen(false)}
/>
)}
</form>
</Dialog>
);
};
export default M3U;

View file

@ -0,0 +1,191 @@
// Modal.js
import React, { useState, useEffect } from 'react';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import API from '../../api';
import useUserAgentsStore from '../../store/userAgents';
import M3UProfiles from './M3UProfiles';
import {
LoadingOverlay,
TextInput,
Button,
Checkbox,
Modal,
Flex,
NativeSelect,
FileInput,
Select,
Space,
} from '@mantine/core';
const M3U = ({ playlist = null, isOpen, onClose }) => {
const userAgents = useUserAgentsStore((state) => state.userAgents);
const [file, setFile] = useState(null);
const [profileModalOpen, setProfileModalOpen] = useState(false);
const handleFileChange = (file) => {
if (file) {
setFile(file);
}
};
const formik = useFormik({
initialValues: {
name: '',
server_url: '',
user_agent: `${userAgents[0].id}`,
is_active: true,
max_streams: 0,
},
validationSchema: Yup.object({
name: Yup.string().required('Name is required'),
user_agent: Yup.string().required('User-Agent is required'),
max_streams: Yup.string().required('Max streams is required'),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
if (playlist?.id) {
await API.updatePlaylist({
id: playlist.id,
...values,
uploaded_file: file,
});
} else {
await API.addPlaylist({
...values,
uploaded_file: file,
});
}
resetForm();
setFile(null);
setSubmitting(false);
onClose();
},
});
useEffect(() => {
if (playlist) {
formik.setValues({
name: playlist.name,
server_url: playlist.server_url,
max_streams: playlist.max_streams,
user_agent: playlist.user_agent,
is_active: playlist.is_active,
});
} else {
formik.resetForm();
}
}, [playlist]);
if (!isOpen) {
return <></>;
}
return (
<Modal opened={isOpen} onClose={onClose} title="M3U Account">
<div style={{ width: 400, position: 'relative' }}>
<LoadingOverlay visible={formik.isSubmitting} overlayBlur={2} />
<form onSubmit={formik.handleSubmit}>
<TextInput
fullWidth
id="name"
name="name"
label="Name"
value={formik.values.name}
onChange={formik.handleChange}
error={formik.touched.name && Boolean(formik.errors.name)}
helperText={formik.touched.name && formik.errors.name}
/>
<TextInput
fullWidth
id="server_url"
name="server_url"
label="URL"
value={formik.values.server_url}
onChange={formik.handleChange}
error={
formik.touched.server_url && Boolean(formik.errors.server_url)
}
helperText={formik.touched.server_url && formik.errors.server_url}
/>
<FileInput
id="uploaded_file"
label="Upload files"
placeholder="Upload files"
value={formik.uploaded_file}
onChange={handleFileChange}
/>
<TextInput
fullWidth
id="max_streams"
name="max_streams"
label="Max Streams"
placeholder="0 = Unlimited"
value={formik.values.max_streams}
onChange={formik.handleChange}
error={formik.errors.max_streams ? formik.touched.max_streams : ''}
/>
<NativeSelect
id="user_agent"
name="user_agent"
label="User-Agent"
value={formik.values.user_agent}
onChange={formik.handleChange}
error={formik.errors.user_agent ? formik.touched.user_agent : ''}
data={userAgents.map((ua) => ({
label: ua.user_agent_name,
value: `${ua.id}`,
}))}
/>
<Space h="md" />
<Checkbox
label="Is Active"
name="is_active"
checked={formik.values.is_active}
onChange={(e) =>
formik.setFieldValue('is_active', e.target.checked)
}
/>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
{playlist && (
<Button
variant="contained"
color="primary"
size="small"
onClick={() => setProfileModalOpen(true)}
>
Profiles
</Button>
)}
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
size="small"
>
Save
</Button>
</Flex>
{playlist && (
<M3UProfiles
playlist={playlist}
isOpen={profileModalOpen}
onClose={() => setProfileModalOpen(false)}
/>
)}
</form>
</div>
</Modal>
);
};
export default M3U;

View file

@ -1,195 +0,0 @@
import React, { useState, useEffect } from 'react';
import {
TextField,
Typography,
Card,
CardContent,
Dialog,
DialogContent,
DialogTitle,
DialogActions,
Button,
CircularProgress,
} from '@mui/material';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import API from '../../api';
const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
const [searchPattern, setSearchPattern] = useState('');
const [replacePattern, setReplacePattern] = useState('');
let regex;
try {
regex = new RegExp(searchPattern, 'g');
} catch (e) {
regex = null;
}
const highlightedUrl = regex
? m3u.server_url.replace(regex, (match) => `<mark>${match}</mark>`)
: m3u.server_url;
const resultUrl = regex
? m3u.server_url.replace(regex, replacePattern)
: m3u.server_url;
const onSearchPatternUpdate = (e) => {
formik.handleChange(e);
setSearchPattern(e.target.value);
};
const onReplacePatternUpdate = (e) => {
formik.handleChange(e);
setReplacePattern(e.target.value);
};
const formik = useFormik({
initialValues: {
name: '',
max_streams: 0,
search_pattern: '',
replace_pattern: '',
},
validationSchema: Yup.object({
name: Yup.string().required('Name is required'),
search_pattern: Yup.string().required('Search pattern is required'),
replace_pattern: Yup.string().required('Replace pattern is required'),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
console.log('submiting');
if (profile?.id) {
await API.updateM3UProfile(m3u.id, {
id: profile.id,
...values,
});
} else {
await API.addM3UProfile(m3u.id, values);
}
resetForm();
setSubmitting(false);
onClose();
},
});
useEffect(() => {
if (profile) {
setSearchPattern(profile.search_pattern);
setReplacePattern(profile.replace_pattern);
formik.setValues({
name: profile.name,
max_streams: profile.max_streams,
search_pattern: profile.search_pattern,
replace_pattern: profile.replace_pattern,
});
} else {
formik.resetForm();
}
}, [profile]);
return (
<Dialog open={isOpen} onClose={onClose}>
<DialogTitle
sx={{ backgroundColor: 'primary.main', color: 'primary.contrastText' }}
>
M3U Profile
</DialogTitle>
<form onSubmit={formik.handleSubmit}>
<DialogContent>
<TextField
fullWidth
id="name"
name="name"
label="Name"
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={formik.touched.name && Boolean(formik.errors.name)}
helperText={formik.touched.name && formik.errors.name}
variant="standard"
/>
<TextField
fullWidth
id="max_streams"
name="max_streams"
label="Max Streams"
value={formik.values.max_streams}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.max_streams && Boolean(formik.errors.max_streams)
}
helperText={formik.touched.max_streams && formik.errors.max_streams}
variant="standard"
/>
<TextField
fullWidth
id="search_pattern"
name="search_pattern"
label="Search Pattern (Regex)"
value={searchPattern}
onChange={onSearchPatternUpdate}
onBlur={formik.handleBlur}
error={
formik.touched.search_pattern &&
Boolean(formik.errors.search_pattern)
}
helperText={
formik.touched.search_pattern && formik.errors.search_pattern
}
variant="standard"
/>
<TextField
fullWidth
id="replace_pattern"
name="replace_pattern"
label="Replace Pattern"
value={replacePattern}
onChange={onReplacePatternUpdate}
onBlur={formik.handleBlur}
error={
formik.touched.replace_pattern &&
Boolean(formik.errors.replace_pattern)
}
helperText={
formik.touched.replace_pattern && formik.errors.replace_pattern
}
variant="standard"
/>
</DialogContent>
<DialogActions>
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
size="small"
>
{formik.isSubmitting ? <CircularProgress size={24} /> : 'Submit'}
</Button>
</DialogActions>
</form>
<Card>
<CardContent>
<Typography variant="h6">Search</Typography>
<Typography
dangerouslySetInnerHTML={{ __html: highlightedUrl }}
sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
/>
</CardContent>
</Card>
<Card>
<CardContent>
<Typography variant="h6">Replace</Typography>
<Typography>{resultUrl}</Typography>
</CardContent>
</Card>
</Dialog>
);
};
export default RegexFormAndView;

View file

@ -0,0 +1,158 @@
import React, { useState, useEffect } from 'react';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import API from '../../api';
import {
Flex,
Modal,
TextInput,
Button,
Title,
Text,
Paper,
} from '@mantine/core';
const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
const [searchPattern, setSearchPattern] = useState('');
const [replacePattern, setReplacePattern] = useState('');
let regex;
try {
regex = new RegExp(searchPattern, 'g');
} catch (e) {
regex = null;
}
const highlightedUrl = regex
? m3u.server_url.replace(regex, (match) => `<mark>${match}</mark>`)
: m3u.server_url;
const resultUrl =
regex && replacePattern
? m3u.server_url.replace(regex, replacePattern)
: m3u.server_url;
const onSearchPatternUpdate = (e) => {
formik.handleChange(e);
setSearchPattern(e.target.value);
};
const onReplacePatternUpdate = (e) => {
formik.handleChange(e);
setReplacePattern(e.target.value);
};
const formik = useFormik({
initialValues: {
name: '',
max_streams: 0,
search_pattern: '',
replace_pattern: '',
},
validationSchema: Yup.object({
name: Yup.string().required('Name is required'),
search_pattern: Yup.string().required('Search pattern is required'),
replace_pattern: Yup.string().required('Replace pattern is required'),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
console.log('submiting');
if (profile?.id) {
await API.updateM3UProfile(m3u.id, {
id: profile.id,
...values,
});
} else {
await API.addM3UProfile(m3u.id, values);
}
resetForm();
setSubmitting(false);
onClose();
},
});
useEffect(() => {
if (profile) {
setSearchPattern(profile.search_pattern);
setReplacePattern(profile.replace_pattern);
formik.setValues({
name: profile.name,
max_streams: profile.max_streams,
search_pattern: profile.search_pattern,
replace_pattern: profile.replace_pattern,
});
} else {
formik.resetForm();
}
}, [profile]);
return (
<Modal opened={isOpen} onClose={onClose} title="M3U Profile">
<form onSubmit={formik.handleSubmit}>
<TextInput
id="name"
name="name"
label="Name"
value={formik.values.name}
onChange={formik.handleChange}
error={formik.errors.name ? formik.touched.name : ''}
/>
<TextInput
id="max_streams"
name="max_streams"
label="Max Streams"
value={formik.values.max_streams}
onChange={formik.handleChange}
error={formik.errors.max_streams ? formik.touched.max_streams : ''}
/>
<TextInput
id="search_pattern"
name="search_pattern"
label="Search Pattern (Regex)"
value={searchPattern}
onChange={onSearchPatternUpdate}
error={
formik.errors.search_pattern ? formik.touched.search_pattern : ''
}
/>
<TextInput
id="replace_pattern"
name="replace_pattern"
label="Replace Pattern"
value={replacePattern}
onChange={onReplacePatternUpdate}
error={
formik.errors.replace_pattern ? formik.touched.replace_pattern : ''
}
/>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
size="small"
>
Submit
</Button>
</Flex>
</form>
<Paper shadow="sm" p="md" radius="md" withBorder>
<Text>Search</Text>
<Text
dangerouslySetInnerHTML={{ __html: highlightedUrl }}
sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
/>
</Paper>
<Paper p="md" withBorder>
<Text>Replace</Text>
<Text>{resultUrl}</Text>
</Paper>
</Modal>
);
};
export default RegexFormAndView;

View file

@ -1,133 +0,0 @@
import React, { useState, useMemo } from 'react';
import {
Typography,
Dialog,
DialogContent,
DialogTitle,
DialogActions,
Button,
Box,
Switch,
IconButton,
List,
ListItem,
ListItemText,
} from '@mui/material';
import API from '../../api';
import M3UProfile from './M3UProfile';
import { Delete as DeleteIcon, Edit as EditIcon } from '@mui/icons-material';
import usePlaylistsStore from '../../store/playlists';
const M3UProfiles = ({ playlist = null, isOpen, onClose }) => {
const profiles = usePlaylistsStore((state) => state.profiles[playlist.id]);
const [profileEditorOpen, setProfileEditorOpen] = useState(false);
const [profile, setProfile] = useState(null);
const editProfile = (profile = null) => {
if (profile) {
setProfile(profile);
}
setProfileEditorOpen(true);
};
const deleteProfile = async (id) => {
await API.deleteM3UProfile(playlist.id, id);
};
const toggleActive = async (values) => {
await API.updateM3UProfile(playlist.id, {
...values,
is_active: !values.is_active,
});
};
const closeEditor = () => {
setProfile(null);
setProfileEditorOpen(false);
};
if (!isOpen || !profiles) {
return <></>;
}
return (
<>
<Dialog open={isOpen} onClose={onClose}>
<DialogTitle
sx={{
backgroundColor: 'primary.main',
color: 'primary.contrastText',
}}
>
Profiles
</DialogTitle>
<DialogContent>
<List>
{profiles
.filter((playlist) => playlist.is_default == false)
.map((item) => (
<ListItem
key={item.id}
sx={{
display: 'flex',
alignItems: 'center',
marginBottom: 2,
}}
>
<ListItemText
primary={item.name}
secondary={
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Typography variant="body2" sx={{ marginRight: 2 }}>
Max Streams: {item.max_streams}
</Typography>
<Switch
checked={item.is_active}
onChange={() => toggleActive(item)}
color="primary"
inputProps={{ 'aria-label': 'active switch' }}
/>
<IconButton
onClick={() => editProfile(item)}
color="warning"
>
<EditIcon />
</IconButton>
<IconButton
onClick={() => deleteProfile(item.id)}
color="error"
>
<DeleteIcon />
</IconButton>
</Box>
}
/>
</ListItem>
))}
</List>
</DialogContent>
<DialogActions>
<Button
variant="contained"
color="primary"
size="small"
onClick={editProfile}
>
New
</Button>
</DialogActions>
</Dialog>
<M3UProfile
m3u={playlist}
profile={profile}
isOpen={profileEditorOpen}
onClose={closeEditor}
/>
</>
);
};
export default M3UProfiles;

View file

@ -0,0 +1,107 @@
import React, { useState, useMemo } from 'react';
import API from '../../api';
import M3UProfile from './M3UProfile';
import { Delete as DeleteIcon, Edit as EditIcon } from '@mui/icons-material';
import usePlaylistsStore from '../../store/playlists';
import {
Card,
Checkbox,
Flex,
Modal,
Button,
Box,
ActionIcon,
Text,
} from '@mantine/core';
const M3UProfiles = ({ playlist = null, isOpen, onClose }) => {
const profiles = usePlaylistsStore((state) => state.profiles[playlist.id]);
const [profileEditorOpen, setProfileEditorOpen] = useState(false);
const [profile, setProfile] = useState(null);
const editProfile = (profile = null) => {
if (profile) {
setProfile(profile);
}
setProfileEditorOpen(true);
};
const deleteProfile = async (id) => {
await API.deleteM3UProfile(playlist.id, id);
};
const toggleActive = async (values) => {
await API.updateM3UProfile(playlist.id, {
...values,
is_active: !values.is_active,
});
};
const closeEditor = () => {
setProfile(null);
setProfileEditorOpen(false);
};
if (!isOpen || !profiles) {
return <></>;
}
return (
<>
<Modal opened={isOpen} onClose={onClose} title="Profiles">
{profiles
.filter((playlist) => playlist.is_default == false)
.map((item) => (
<Card
// key={item.id}
// sx={{
// display: 'flex',
// alignItems: 'center',
// marginBottom: 2,
// }}
>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Text>Max Streams: {item.max_streams}</Text>
<Checkbox
label="Is Active"
checked={item.is_active}
onChange={() => toggleActive(item)}
color="primary"
/>
<ActionIcon onClick={() => editProfile(item)} color="yellow.5">
<EditIcon />
</ActionIcon>
<ActionIcon
onClick={() => deleteProfile(item.id)}
color="error"
>
<DeleteIcon />
</ActionIcon>
</Box>
</Card>
))}
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button
variant="contained"
color="primary"
size="small"
onClick={editProfile}
>
New
</Button>
</Flex>
</Modal>
<M3UProfile
m3u={playlist}
profile={profile}
isOpen={profileEditorOpen}
onClose={closeEditor}
/>
</>
);
};
export default M3UProfiles;

View file

@ -1,151 +0,0 @@
// Modal.js
import React, { useEffect } from 'react';
import {
TextField,
Button,
Select,
MenuItem,
Grid2,
InputLabel,
FormControl,
CircularProgress,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from '@mui/material';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import API from '../../api';
import useStreamProfilesStore from '../../store/streamProfiles';
const Stream = ({ stream = null, isOpen, onClose }) => {
const streamProfiles = useStreamProfilesStore((state) => state.profiles);
const formik = useFormik({
initialValues: {
name: '',
url: '',
stream_profile_id: '',
},
validationSchema: Yup.object({
name: Yup.string().required('Name is required'),
url: Yup.string().required('URL is required').min(0),
// stream_profile_id: Yup.string().required('Stream profile is required'),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
if (stream?.id) {
await API.updateStream({ id: stream.id, ...values });
} else {
await API.addStream(values);
}
resetForm();
setSubmitting(false);
onClose();
},
});
useEffect(() => {
if (stream) {
formik.setValues({
name: stream.name,
url: stream.url,
stream_profile_id: stream.stream_profile_id,
});
} else {
formik.resetForm();
}
}, [stream]);
if (!isOpen) {
return <></>;
}
return (
<Dialog open={isOpen} onClose={onClose}>
<DialogTitle
sx={{
backgroundColor: 'primary.main',
color: 'primary.contrastText',
}}
>
Stream
</DialogTitle>
<form onSubmit={formik.handleSubmit}>
<DialogContent>
<Grid2 container spacing={2}>
<Grid2 size={12}>
<TextField
fullWidth
id="name"
name="name"
label="Stream Name"
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={formik.touched.name && Boolean(formik.errors.name)}
helperText={formik.touched.name && formik.errors.name}
variant="standard"
/>
<TextField
fullWidth
id="url"
name="url"
label="Stream URL"
value={formik.values.url}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={formik.touched.url && Boolean(formik.errors.url)}
helperText={formik.touched.url && formik.errors.url}
variant="standard"
/>
<FormControl variant="standard" fullWidth>
<InputLabel id="stream-profile-label">
Stream Profile
</InputLabel>
<Select
labelId="stream-profile-label"
id="stream_profile_id"
name="stream_profile_id"
label="Stream Profile (optional)"
value={formik.values.stream_profile_id}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.stream_profile_id &&
Boolean(formik.errors.stream_profile_id)
}
// helperText={formik.touched.channel_group_id && formik.errors.stream_profile_id}
variant="standard"
>
{streamProfiles.map((option, index) => (
<MenuItem key={index} value={option.id}>
{option.profile_name}
</MenuItem>
))}
</Select>
</FormControl>
</Grid2>
</Grid2>
</DialogContent>
<DialogActions>
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
>
{formik.isSubmitting ? <CircularProgress size={24} /> : 'Submit'}
</Button>
</DialogActions>
</form>
</Dialog>
);
};
export default Stream;

View file

@ -0,0 +1,115 @@
// Modal.js
import React, { useEffect, useState } from 'react';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import API from '../../api';
import useStreamProfilesStore from '../../store/streamProfiles';
import { Modal, TextInput, Select, Button, Flex } from '@mantine/core';
const Stream = ({ stream = null, isOpen, onClose }) => {
const streamProfiles = useStreamProfilesStore((state) => state.profiles);
const [selectedStreamProfile, setSelectedStreamProfile] = useState('');
const formik = useFormik({
initialValues: {
name: '',
url: '',
group_name: '',
stream_profile_id: '',
},
validationSchema: Yup.object({
name: Yup.string().required('Name is required'),
url: Yup.string().required('URL is required').min(0),
// stream_profile_id: Yup.string().required('Stream profile is required'),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
if (stream?.id) {
await API.updateStream({ id: stream.id, ...values });
} else {
await API.addStream(values);
}
resetForm();
setSubmitting(false);
onClose();
},
});
useEffect(() => {
if (stream) {
formik.setValues({
name: stream.name,
url: stream.url,
group_name: stream.group_name,
stream_profile_id: stream.stream_profile_id,
});
} else {
formik.resetForm();
}
}, [stream]);
if (!isOpen) {
return <></>;
}
return (
<Modal opened={isOpen} onClose={onClose} title="Stream" zIndex={10}>
<form onSubmit={formik.handleSubmit}>
<TextInput
id="name"
name="name"
label="Stream Name"
value={formik.values.name}
onChange={formik.handleChange}
error={formik.errors.name}
/>
<TextInput
id="url"
name="url"
label="Stream URL"
value={formik.values.url}
onChange={formik.handleChange}
error={formik.errors.url}
/>
<TextInput
id="group_name"
name="group_name"
label="Group"
value={formik.values.group_name}
onChange={formik.handleChange}
error={formik.errors.group_name}
/>
<Select
id="stream_profile_id"
name="stream_profile_id"
label="Stream Profile"
placeholder="Optional"
value={selectedStreamProfile}
onChange={setSelectedStreamProfile}
error={formik.errors.stream_profile_id}
data={streamProfiles.map((profile) => ({
label: profile.profile_name,
value: `${profile.id}`,
}))}
comboboxProps={{ withinPortal: false, zIndex: 1000 }}
/>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
>
Submit
</Button>
</Flex>
</form>
</Modal>
);
};
export default Stream;

View file

@ -1,165 +0,0 @@
// Modal.js
import React, { useEffect } from "react";
import {
TextField,
Button,
Select,
MenuItem,
InputLabel,
FormControl,
CircularProgress,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from "@mui/material";
import { useFormik } from "formik";
import * as Yup from "yup";
import API from "../../api";
import useUserAgentsStore from "../../store/userAgents";
const StreamProfile = ({ profile = null, isOpen, onClose }) => {
const userAgents = useUserAgentsStore((state) => state.userAgents);
const formik = useFormik({
initialValues: {
profile_name: "",
command: "",
parameters: "",
is_active: true,
user_agent: "",
},
validationSchema: Yup.object({
profile_name: Yup.string().required("Name is required"),
command: Yup.string().required("Command is required"),
parameters: Yup.string().required("Parameters are is required"),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
if (profile?.id) {
await API.updateStreamProfile({ id: profile.id, ...values });
} else {
await API.addStreamProfile(values);
}
resetForm();
setSubmitting(false);
onClose();
},
});
useEffect(() => {
if (profile) {
formik.setValues({
profile_name: profile.profile_name,
command: profile.command,
parameters: profile.parameters,
is_active: profile.is_active,
user_agent: profile.user_agent,
});
} else {
formik.resetForm();
}
}, [profile]);
if (!isOpen) {
return <></>;
}
return (
<Dialog open={isOpen} onClose={onClose}>
<DialogTitle
sx={{
backgroundColor: "primary.main",
color: "primary.contrastText",
}}
>
Stream Profile
</DialogTitle>
<form onSubmit={formik.handleSubmit}>
<DialogContent>
<TextField
fullWidth
id="profile_name"
name="profile_name"
label="Name"
value={formik.values.profile_name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.profile_name && Boolean(formik.errors.profile_name)
}
helperText={
formik.touched.profile_name && formik.errors.profile_name
}
variant="standard"
/>
<TextField
fullWidth
id="command"
name="command"
label="Command"
value={formik.values.command}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={formik.touched.command && Boolean(formik.errors.command)}
helperText={formik.touched.command && formik.errors.command}
variant="standard"
/>
<TextField
fullWidth
id="parameters"
name="parameters"
label="Parameters"
value={formik.values.parameters}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.parameters && Boolean(formik.errors.parameters)
}
helperText={formik.touched.parameters && formik.errors.parameters}
variant="standard"
/>
<FormControl variant="standard" fullWidth>
<InputLabel id="channel-group-label">User-Agent</InputLabel>
<Select
labelId="channel-group-label"
id="user_agent"
name="user_agent"
label="User-Agent"
value={formik.values.user_agent}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.user_agent && Boolean(formik.errors.user_agent)
}
// helperText={formik.touched.user_agent && formik.errors.user_agent}
variant="standard"
>
{userAgents.map((option, index) => (
<MenuItem key={index} value={option.id}>
{option.user_agent_name}
</MenuItem>
))}
</Select>
</FormControl>
</DialogContent>
<DialogActions>
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
size="small"
>
{formik.isSubmitting ? <CircularProgress size={24} /> : "Submit"}
</Button>
</DialogActions>
</form>
</Dialog>
);
};
export default StreamProfile;

View file

@ -0,0 +1,113 @@
// Modal.js
import React, { useEffect } from 'react';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import API from '../../api';
import useUserAgentsStore from '../../store/userAgents';
import { Modal, TextInput, Select, Button, Flex } from '@mantine/core';
const StreamProfile = ({ profile = null, isOpen, onClose }) => {
const userAgents = useUserAgentsStore((state) => state.userAgents);
const formik = useFormik({
initialValues: {
profile_name: '',
command: '',
parameters: '',
is_active: true,
user_agent: '',
},
validationSchema: Yup.object({
profile_name: Yup.string().required('Name is required'),
command: Yup.string().required('Command is required'),
parameters: Yup.string().required('Parameters are is required'),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
if (profile?.id) {
await API.updateStreamProfile({ id: profile.id, ...values });
} else {
await API.addStreamProfile(values);
}
resetForm();
setSubmitting(false);
onClose();
},
});
useEffect(() => {
if (profile) {
formik.setValues({
profile_name: profile.profile_name,
command: profile.command,
parameters: profile.parameters,
is_active: profile.is_active,
user_agent: profile.user_agent,
});
} else {
formik.resetForm();
}
}, [profile]);
if (!isOpen) {
return <></>;
}
return (
<Modal opened={isOpen} onClose={onClose} title="Stream Profile">
<form onSubmit={formik.handleSubmit}>
<TextInput
id="profile_name"
name="profile_name"
label="Name"
value={formik.values.profile_name}
onChange={formik.handleChange}
error={formik.errors.profile_name}
/>
<TextInput
id="command"
name="command"
label="Command"
value={formik.values.command}
onChange={formik.handleChange}
error={formik.errors.command}
/>
<TextInput
id="parameters"
name="parameters"
label="Parameters"
value={formik.values.parameters}
onChange={formik.handleChange}
error={formik.errors.parameters}
/>
<Select
id="user_agent"
name="user_agent"
label="User-Agent"
value={formik.values.user_agent}
onChange={formik.handleChange}
error={formik.errors.user_agent}
data={userAgents.map((ua) => ({
label: ua.user_agent_name,
value: `${ua.id}`,
}))}
/>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
size="small"
>
Submit
</Button>
</Flex>
</form>
</Modal>
);
};
export default StreamProfile;

View file

@ -1,128 +0,0 @@
// frontend/src/components/forms/SuperuserForm.js
import React, { useState } from 'react';
import axios from 'axios';
import {
Box,
Paper,
Typography,
Grid2,
TextField,
Button,
} from '@mui/material';
function SuperuserForm({ onSuccess }) {
const [formData, setFormData] = useState({
username: '',
password: '',
email: '',
});
const [error, setError] = useState('');
const handleChange = (e) => {
setFormData((prev) => ({
...prev,
[e.target.name]: e.target.value,
}));
};
const handleSubmit = async (e) => {
e.preventDefault();
try {
const res = await axios.post('/api/accounts/initialize-superuser/', {
username: formData.username,
password: formData.password,
email: formData.email,
});
if (res.data.superuser_exists) {
onSuccess();
}
} catch (err) {
let msg = 'Failed to create superuser.';
if (err.response && err.response.data && err.response.data.error) {
msg += ` ${err.response.data.error}`;
}
setError(msg);
}
};
return (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
backgroundColor: '#f5f5f5',
}}
>
<Paper elevation={3} sx={{ padding: 3, width: '100%', maxWidth: 400 }}>
<Typography variant="h5" align="center" gutterBottom>
Create your Super User Account
</Typography>
{error && (
<Typography variant="body2" color="error" sx={{ mb: 2 }}>
{error}
</Typography>
)}
<form onSubmit={handleSubmit}>
<Grid2
container
spacing={2}
justifyContent="center"
direction="column"
>
<Grid2 xs={12}>
<TextField
label="Username"
variant="standard"
fullWidth
name="username"
value={formData.username}
onChange={handleChange}
required
size="small"
/>
</Grid2>
<Grid2 xs={12}>
<TextField
label="Password"
variant="standard"
type="password"
fullWidth
name="password"
value={formData.password}
onChange={handleChange}
required
size="small"
/>
</Grid2>
<Grid2 xs={12}>
<TextField
label="Email (optional)"
variant="standard"
type="email"
fullWidth
name="email"
value={formData.email}
onChange={handleChange}
size="small"
/>
</Grid2>
<Grid2 xs={12}>
<Button
type="submit"
variant="contained"
color="primary"
fullWidth
>
Create Superuser
</Button>
</Grid2>
</Grid2>
</form>
</Paper>
</Box>
);
}
export default SuperuserForm;

View file

@ -0,0 +1,94 @@
// frontend/src/components/forms/SuperuserForm.js
import React, { useState } from 'react';
import { TextInput, Center, Button, Paper, Title, Stack } from '@mantine/core';
import API from '../../api';
import useAuthStore from '../../store/auth';
function SuperuserForm({}) {
const [formData, setFormData] = useState({
username: '',
password: '',
email: '',
});
const [error, setError] = useState('');
const { setSuperuserExists } = useAuthStore();
const handleChange = (e) => {
setFormData((prev) => ({
...prev,
[e.target.name]: e.target.value,
}));
};
const handleSubmit = async (e) => {
e.preventDefault();
try {
console.log(formData);
const response = await API.createSuperUser({
username: formData.username,
password: formData.password,
email: formData.email,
});
if (response.superuser_exists) {
setSuperuserExists(true);
}
} catch (err) {
console.log(err);
// let msg = 'Failed to create superuser.';
// if (err.response && err.response.data && err.response.data.error) {
// msg += ` ${err.response.data.error}`;
// }
// setError(msg);
}
};
return (
<Center
style={{
height: '100vh',
}}
>
<Paper
elevation={3}
style={{ padding: 30, width: '100%', maxWidth: 400 }}
>
<Title order={4} align="center">
Create your Super User Account
</Title>
<form onSubmit={handleSubmit}>
<Stack>
<TextInput
label="Username"
name="username"
value={formData.username}
onChange={handleChange}
required
/>
<TextInput
label="Password"
type="password"
name="password"
value={formData.password}
onChange={handleChange}
required
/>
<TextInput
label="Email (optional)"
type="email"
name="email"
value={formData.email}
onChange={handleChange}
/>
<Button type="submit" size="sm" sx={{ pt: 1 }}>
Submit
</Button>
</Stack>
</form>
</Paper>
</Center>
);
}
export default SuperuserForm;

View file

@ -1,144 +0,0 @@
// Modal.js
import React, { useEffect } from 'react';
import {
TextField,
Button,
CircularProgress,
Checkbox,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from '@mui/material';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import API from '../../api';
import useSettingsStore from '../../store/settings';
const UserAgent = ({ userAgent = null, isOpen, onClose }) => {
const formik = useFormik({
initialValues: {
user_agent_name: '',
user_agent: '',
description: '',
is_active: true,
},
validationSchema: Yup.object({
user_agent_name: Yup.string().required('Name is required'),
user_agent: Yup.string().required('User-Agent is required'),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
if (userAgent?.id) {
await API.updateUserAgent({ id: userAgent.id, ...values });
} else {
await API.addUserAgent(values);
}
resetForm();
setSubmitting(false);
onClose();
},
});
useEffect(() => {
if (userAgent) {
formik.setValues({
user_agent_name: userAgent.user_agent_name,
user_agent: userAgent.user_agent,
description: userAgent.description,
is_active: userAgent.is_active,
});
} else {
formik.resetForm();
}
}, [userAgent]);
if (!isOpen) {
return <></>;
}
return (
<Dialog open={isOpen} onClose={onClose}>
<DialogTitle
sx={{
backgroundColor: 'primary.main',
color: 'primary.contrastText',
}}
>
User-Agent
</DialogTitle>
<form onSubmit={formik.handleSubmit}>
<DialogContent>
<TextField
fullWidth
id="user_agent_name"
name="user_agent_name"
label="Name"
value={formik.values.user_agent_name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.user_agent_name &&
Boolean(formik.errors.user_agent_name)
}
helperText={
formik.touched.user_agent_name && formik.errors.user_agent_name
}
variant="standard"
/>
<TextField
fullWidth
id="user_agent"
name="user_agent"
label="User-Agent"
value={formik.values.user_agent}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.user_agent && Boolean(formik.errors.user_agent)
}
helperText={formik.touched.user_agent && formik.errors.user_agent}
variant="standard"
/>
<TextField
fullWidth
id="description"
name="description"
label="Description"
value={formik.values.description}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.description && Boolean(formik.errors.description)
}
helperText={formik.touched.description && formik.errors.description}
variant="standard"
/>
<Checkbox
name="is_active"
checked={formik.values.is_active}
onChange={formik.handleChange}
/>
</DialogContent>
<DialogActions>
<Button
size="small"
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
>
{formik.isSubmitting ? <CircularProgress size={24} /> : 'Submit'}
</Button>
</DialogActions>
</form>
</Dialog>
);
};
export default UserAgent;

View file

@ -0,0 +1,119 @@
// Modal.js
import React, { useEffect } from 'react';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import API from '../../api';
import {
LoadingOverlay,
TextInput,
Button,
Checkbox,
Modal,
Flex,
NativeSelect,
FileInput,
Space,
} from '@mantine/core';
const UserAgent = ({ userAgent = null, isOpen, onClose }) => {
const formik = useFormik({
initialValues: {
user_agent_name: '',
user_agent: '',
description: '',
is_active: true,
},
validationSchema: Yup.object({
user_agent_name: Yup.string().required('Name is required'),
user_agent: Yup.string().required('User-Agent is required'),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
if (userAgent?.id) {
await API.updateUserAgent({ id: userAgent.id, ...values });
} else {
await API.addUserAgent(values);
}
resetForm();
setSubmitting(false);
onClose();
},
});
useEffect(() => {
if (userAgent) {
formik.setValues({
user_agent_name: userAgent.user_agent_name,
user_agent: userAgent.user_agent,
description: userAgent.description,
is_active: userAgent.is_active,
});
} else {
formik.resetForm();
}
}, [userAgent]);
if (!isOpen) {
return <></>;
}
return (
<Modal opened={isOpen} onClose={onClose} title="User-Agent">
<form onSubmit={formik.handleSubmit}>
<TextInput
id="user_agent_name"
name="user_agent_name"
label="Name"
value={formik.values.user_agent_name}
onChange={formik.handleChange}
error={
formik.touched.user_agent_name &&
Boolean(formik.errors.user_agent_name)
}
/>
<TextInput
id="user_agent"
name="user_agent"
label="User-Agent"
value={formik.values.user_agent}
onChange={formik.handleChange}
error={formik.touched.user_agent && Boolean(formik.errors.user_agent)}
/>
<TextInput
id="description"
name="description"
label="Description"
value={formik.values.description}
onChange={formik.handleChange}
error={
formik.touched.description && Boolean(formik.errors.description)
}
/>
<Space h="md" />
<Checkbox
name="is_active"
label="Is Active"
checked={formik.values.is_active}
onChange={formik.handleChange}
/>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button
size="small"
type="submit"
variant="contained"
disabled={formik.isSubmitting}
>
Submit
</Button>
</Flex>
</form>
</Modal>
);
};
export default UserAgent;

View file

@ -0,0 +1,41 @@
.mantine-Stack-root .navlink {
display: flex;
flex-direction: row; /* Ensures horizontal layout */
flex-wrap: nowrap;
align-items: center;
gap: 12px;
padding: 5px 8px !important;
border-radius: 6px;
color: #D4D4D8; /* Default color when not active */
background-color: transparent; /* Default background when not active */
border: 1px solid transparent;
transition: all 0.3s ease;
}
/* Active state styles */
.navlink.navlink-active {
color: #FFFFFF;
background-color: #245043;
border: 1px solid #3BA882;
}
/* Hover effect */
.navlink:hover {
background-color: #2A2F34; /* Gray hover effect when not active */
border: 1px solid #3D3D42;
}
/* Hover effect for active state */
.navlink.navlink-active:hover {
background-color: #3A3A40;
border: 1px solid #3BA882;
}
/* Collapse condition for justifyContent */
.navlink.navlink-collapsed {
justify-content: center;
}
.navlink:not(.navlink-collapsed) {
justify-content: flex-start;
}

View file

@ -1,938 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
MaterialReactTable,
useMaterialReactTable,
MRT_ShowHideColumnsButton
} from 'material-react-table';
import {
Box,
Grid2,
Typography,
Tooltip,
IconButton,
Button,
Snackbar,
Popover,
TextField,
Autocomplete,
InputAdornment,
Paper,
} from '@mui/material';
import useChannelsStore from '../../store/channels';
import {
Delete as DeleteIcon,
Edit as EditIcon,
Add as AddIcon,
ContentCopy,
Clear as ClearIcon,
IndeterminateCheckBox,
CompareArrows,
Code,
AddBox,
LiveTv as LiveTvIcon,
} 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 useVideoStore from '../../store/useVideoStore';
import useSettingsStore from '../../store/settings';
import usePlaylistsStore from '../../store/playlists';
import { Tv2, ScreenShare, Scroll, SquareMinus, Pencil } from 'lucide-react';
import { styled, useTheme } from '@mui/material/styles';
import ghostImage from '../../images/ghost.svg';
/* -----------------------------------------------------------
Child table for streams
------------------------------------------------------------ */
const ChannelStreams = ({ channel, isExpanded }) => {
const channelStreams = useChannelsStore(
(state) => state.channels[channel.id]?.streams
);
const { playlists } = usePlaylistsStore();
const removeStream = async (stream) => {
const newStreamList = channelStreams.filter((s) => s.id !== stream.id);
await API.updateChannel({
...channel,
stream_ids: newStreamList.map((s) => s.id),
});
};
const channelStreamsTable = useMaterialReactTable({
...TableHelper.defaultProperties,
data: channelStreams,
columns: useMemo(
() => [
{
header: 'Name',
accessorKey: 'name',
},
{
header: 'M3U',
accessorFn: (row) =>
playlists.find((playlist) => playlist.id === row.m3u_account)?.name,
},
],
[playlists]
),
enableKeyboardShortcuts: false,
enableColumnFilters: false,
enableSorting: false,
enableBottomToolbar: false,
enableTopToolbar: false,
enablePagination: false,
enableRowVirtualization: true,
enableColumnHeaders: false,
initialState: { density: 'compact' },
columnFilterDisplayMode: 'popover',
enableRowActions: true,
enableRowOrdering: true,
muiRowDragHandleProps: ({ table }) => ({
onDragEnd: async () => {
const { draggingRow, hoveredRow } = table.getState();
if (hoveredRow && draggingRow) {
channelStreams.splice(
hoveredRow.index,
0,
channelStreams.splice(draggingRow.index, 1)[0]
);
const { streams: oldStreams, ...channelUpdate } = channel;
await API.updateChannel({
...channelUpdate,
stream_ids: channelStreams.map((s) => s.id),
});
}
},
}),
renderRowActions: ({ row }) => (
<IconButton
size="small"
color="error"
onClick={() => removeStream(row.original)}
>
<DeleteIcon fontSize="small" />
</IconButton>
),
});
if (!isExpanded) return null;
return (
<Box sx={{ backgroundColor: 'primary.main', pt: 1, pb: 1, width: '100%' }}>
<MaterialReactTable table={channelStreamsTable} />
</Box>
);
};
/* -----------------------------------------------------------
Custom-styled buttons (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)',
},
}));
/* -----------------------------------------------------------
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 [isLoading, setIsLoading] = useState(true);
const [sorting, setSorting] = useState([]);
const theme = useTheme();
const outputUrlRef = useRef(null);
const {
channels,
isLoading: channelsLoading,
fetchChannels,
setChannelsPageSelection,
} = useChannelsStore();
const { showVideo } = useVideoStore();
const {
environment: { env_mode },
} = useSettingsStore();
// Gather unique group names
useEffect(() => {
setChannelGroupOptions([
...new Set(Object.values(channels).map((ch) => ch.channel_group?.name)),
]);
}, [channels]);
// Handle filters
const handleFilterChange = (columnId, value) => {
setFilterValues((prev) => ({
...prev,
[columnId]: value ? value.toLowerCase() : '',
}));
};
// Close the top-right snackbar
const closeSnackbar = () => setSnackbarOpen(false);
// Open the Channel form
const editChannel = (ch = null) => {
setChannel(ch);
setChannelModalOpen(true);
};
// Close the Channel form
const closeChannelForm = () => {
setChannel(null);
setChannelModalOpen(false);
};
// Single channel delete
const deleteChannel = async (id) => {
await API.deleteChannel(id);
};
// Bulk delete channels
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);
};
// Watch stream
const handleWatchStream = (channelNumber) => {
let vidUrl = `/output/stream/${channelNumber}/`;
if (env_mode === 'dev') {
vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`;
}
showVideo(vidUrl);
};
// Assign Channels
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);
}
};
// Match EPG
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);
};
// Copy popover logic
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');
if (inputElement) {
inputElement.focus();
inputElement.select();
document.execCommand('copy');
setSnackbarMessage('Copied!');
}
}
setSnackbarOpen(true);
};
// Copy HDHR/M3U/EPG URL
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`);
};
// When component mounts
useEffect(() => {
if (typeof window !== 'undefined') {
setIsLoading(false);
}
}, []);
// Scroll to top on sorting
const rowVirtualizerInstanceRef = useRef(null);
useEffect(() => {
try {
rowVirtualizerInstanceRef.current?.scrollToIndex?.(0);
} catch (error) {
console.error(error);
}
}, [sorting]);
// Build the columns
const columns = useMemo(() => [
{
header: '#',
size: 50,
accessorKey: 'channel_number',
},
{
header: 'Name',
accessorKey: 'channel_name',
muiTableHeadCellProps: { sx: { textAlign: 'center' } },
Header: ({ column }) => (
<TextField
variant="standard"
label="Name"
value={filterValues[column.id] || ''}
onChange={(e) => handleFilterChange(column.id, e.target.value)}
size="small"
margin="none"
fullWidth
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => handleFilterChange(column.id, '')}
edge="end"
size="small"
>
<ClearIcon sx={{ fontSize: '1rem' }} />
</IconButton>
</InputAdornment>
),
},
}}
/>
),
},
{
header: 'Group',
accessorFn: (row) => row.channel_group?.name || '',
Header: ({ column }) => (
<Autocomplete
disablePortal
options={channelGroupOptions}
size="small"
sx={{ width: 300 }}
clearOnEscape
onChange={(event, newValue) => {
event.stopPropagation();
handleFilterChange(column.id, newValue);
}}
renderInput={(params) => (
<TextField
{...params}
label="Group"
size="small"
variant="standard"
onClick={(e) => e.stopPropagation()}
sx={{ pb: 0.8 }}
/>
)}
/>
),
},
{
header: 'Logo',
accessorKey: 'logo_url',
enableSorting: false,
size: 55,
Cell: ({ cell }) => (
<Grid2 container direction="row" sx={{ justifyContent: 'center', alignItems: 'center' }}>
<img src={cell.getValue() || logo} width="20" alt="channel logo" />
</Grid2>
),
},
], [channelGroupOptions, filterValues]);
// Filter the data
const filteredData = Object.values(channels).filter((row) =>
columns.every(({ accessorKey }) =>
filterValues[accessorKey]
? row[accessorKey]?.toLowerCase().includes(filterValues[accessorKey])
: true
)
);
// Build the MRT instance
const table = useMaterialReactTable({
...TableHelper.defaultProperties,
columns,
data: filteredData,
enablePagination: false,
enableColumnActions: false,
enableRowVirtualization: true,
enableRowSelection: true,
enableRowActions: true,
enableExpandAll: false,
// Fully disable MRT's built-in top toolbar
enableTopToolbar: false,
renderTopToolbar: () => null,
renderToolbarInternalActions: () => null,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
state: {
isLoading: isLoading || channelsLoading,
sorting,
rowSelection,
},
rowVirtualizerInstanceRef,
rowVirtualizerOptions: { overscan: 5 },
initialState: { density: 'compact' },
displayColumnDefOptions: {
'mrt-row-select': { size: 50 },
'mrt-row-expand': {
size: 10,
header: '',
muiTableHeadCellProps: { sx: { width: 38, minWidth: 38, maxWidth: 38 } },
muiTableBodyCellProps: { sx: { width: 38, minWidth: 38, maxWidth: 38 } },
},
'mrt-row-actions': { size: 68 },
},
muiExpandButtonProps: ({ row, table }) => ({
onClick: () => {
setRowSelection({ [row.index]: true });
table.setExpanded({ [row.id]: !row.getIsExpanded() });
},
sx: {
transform: row.getIsExpanded() ? 'rotate(180deg)' : 'rotate(-90deg)',
transition: 'transform 0.2s',
},
}),
// Expand child table
renderDetailPanel: ({ row }) => (
<ChannelStreams channel={row.original} isExpanded={row.getIsExpanded()} />
),
// Row actions
renderRowActions: ({ row }) => (
<Box sx={{ justifyContent: 'right' }}>
<Tooltip title="Edit Channel">
<IconButton
size="small"
color="warning"
onClick={() => editChannel(row.original)}
sx={{ py: 0, px: 0.5 }}
>
<Pencil size="18" />
</IconButton>
</Tooltip>
<Tooltip title="Delete Channel">
<IconButton
size="small"
color="error"
onClick={() => deleteChannel(row.original.id)}
sx={{ py: 0, px: 0.5 }}
>
<SquareMinus size="18" />
</IconButton>
</Tooltip>
<Tooltip title="Preview Channel">
<IconButton
size="small"
color="info"
onClick={() => handleWatchStream(row.original.channel_number)}
sx={{ py: 0, px: 0.5 }}
>
<LiveTvIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
),
muiTableContainerProps: {
sx: {
height: 'calc(100vh - 75px)',
overflowY: 'auto',
},
},
});
// Sync the selection with your store
useEffect(() => {
const selectedRows = table.getSelectedRowModel().rows.map((row) => row.original);
setChannelsPageSelection(selectedRows);
}, [rowSelection, table, setChannelsPageSelection]);
return (
<Box>
{/* Header row, outside the Paper */}
<Box sx={{ display: 'flex', alignItems: 'center', pb: 1 }}>
<Typography
sx={{
width: 88,
fontFamily: 'Inter, sans-serif',
fontWeight: 500,
fontSize: '20px',
lineHeight: 1,
letterSpacing: '-0.3px',
color: theme.palette.text.secondary,
mb: 0,
}}
>
Channels
</Typography>
{/* "Links" label and HDHR/M3U/EPG buttons */}
<Box sx={{ width: 43, height: 25, display: 'flex', alignItems: 'center', ml: 3 }}>
<Typography
sx={{
width: 37,
height: 17,
fontFamily: 'Inter, sans-serif',
fontWeight: 400,
fontSize: '14px',
lineHeight: 1,
letterSpacing: '-0.3px',
color: theme.palette.text.secondary,
}}
>
Links:
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: '6px', ml: 0.75 }}>
<Button
onClick={copyHDHRUrl}
sx={{
width: '71px',
height: '25px',
borderRadius: '4px',
border: `1px solid ${theme.palette.custom.greenMain}`,
backgroundColor: 'transparent',
color: theme.palette.custom.greenMain,
fontSize: '14px',
lineHeight: 1,
letterSpacing: '-0.3px',
textTransform: 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px',
padding: 0,
minWidth: 0,
'&:hover': { backgroundColor: theme.palette.custom.greenHoverBg },
}}
>
<Box sx={{ width: 14, height: 14, display: 'flex', alignItems: 'center' }}>
<Tv2 size={14} color={theme.palette.custom.greenMain} />
</Box>
HDHR
</Button>
<Button
onClick={copyM3UUrl}
sx={{
width: '64px',
height: '25px',
borderRadius: '4px',
border: `1px solid ${theme.palette.custom.indigoMain}`,
backgroundColor: 'transparent',
color: theme.palette.custom.indigoMain,
fontSize: '14px',
lineHeight: 1,
letterSpacing: '-0.3px',
textTransform: 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px',
padding: 0,
minWidth: 0,
'&:hover': { backgroundColor: theme.palette.custom.indigoHoverBg },
}}
>
<Box sx={{ width: 14, height: 14, display: 'flex', alignItems: 'center' }}>
<ScreenShare size={14} color={theme.palette.custom.indigoMain} />
</Box>
M3U
</Button>
<Button
onClick={copyEPGUrl}
sx={{
width: '60px',
height: '25px',
borderRadius: '4px',
border: `1px solid ${theme.palette.custom.greyBorder}`,
backgroundColor: 'transparent',
color: theme.palette.custom.greyText,
fontSize: '14px',
lineHeight: 1,
letterSpacing: '-0.3px',
textTransform: 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px',
padding: 0,
minWidth: 0,
'&:hover': { backgroundColor: theme.palette.custom.greyHoverBg },
}}
>
<Box sx={{ width: 14, height: 14, display: 'flex', alignItems: 'center' }}>
<Scroll size={14} color={theme.palette.custom.greyText} />
</Box>
EPG
</Button>
</Box>
</Box>
{/* Paper with your custom top bar + table */}
<Paper
sx={{
bgcolor: theme.palette.background.paper,
borderRadius: 2,
overflow: 'hidden',
height: 'calc(100vh - 75px)',
display: 'flex',
flexDirection: 'column',
}}
>
{/* Our own top toolbar to mimic StreamsTable's style */}
{/*
define selectedCount in JS, NOT inline:
*/}
{(() => {
const selectedCount = table.getSelectedRowModel().rows.length;
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
backgroundColor: theme.palette.background.paper,
justifyContent: 'flex-end',
p: 1,
gap: 1,
}}
>
<Tooltip title="Remove">
<Button
onClick={deleteChannels}
variant="outlined"
size="small"
disabled={selectedCount === 0}
startIcon={<IndeterminateCheckBox sx={{ fontSize: 16, color: theme.palette.text.secondary }} />}
sx={{
borderColor: theme.palette.custom.borderDefault,
borderRadius: '4px',
borderWidth: '1px',
borderStyle: 'solid',
height: '25px',
opacity: selectedCount ? 1 : 0.4,
color: theme.palette.text.secondary,
fontSize: '0.85rem',
px: 1,
py: 0.5,
'&:hover': { borderColor: theme.palette.custom.borderHover },
}}
>
Remove
</Button>
</Tooltip>
<Tooltip title="Assign">
<Button
onClick={assignChannels}
variant="outlined"
size="small"
disabled={selectedCount === 0}
startIcon={<CompareArrows sx={{ fontSize: 16, color: theme.palette.text.secondary }} />}
sx={{
borderColor: theme.palette.custom.borderDefault,
borderRadius: '4px',
borderWidth: '1px',
borderStyle: 'solid',
height: '25px',
opacity: selectedCount ? 1 : 0.4,
color: theme.palette.text.secondary,
fontSize: '0.85rem',
px: 1,
py: 0.5,
'&:hover': { borderColor: theme.palette.custom.borderHover },
}}
>
Assign
</Button>
</Tooltip>
<Tooltip title="Auto-match">
<Button
onClick={matchEpg}
variant="outlined"
size="small"
startIcon={<Code sx={{ fontSize: 16, color: theme.palette.text.secondary }} />}
sx={{
minWidth: '106px',
borderColor: theme.palette.custom.borderDefault,
borderRadius: '4px',
borderWidth: '1px',
borderStyle: 'solid',
height: '25px',
color: theme.palette.text.secondary,
fontSize: '0.85rem',
px: 1,
py: 0.5,
'&:hover': { borderColor: theme.palette.custom.borderHover },
}}
>
Auto-match
</Button>
</Tooltip>
<Tooltip title="Add Channel">
<Button
onClick={() => editChannel()}
variant="contained"
size="small"
startIcon={<AddBox sx={{ fontSize: 16, color: theme.palette.custom.successIcon }} />}
sx={{
minWidth: '57px',
height: '25px',
borderRadius: '4px',
borderColor: theme.palette.custom.successBorder,
borderWidth: '1px',
borderStyle: 'solid',
backgroundColor: theme.palette.custom.successBg,
color: '#fff',
fontSize: '0.85rem',
px: 1,
py: 0.5,
'&:hover': { backgroundColor: theme.palette.custom.successBgHover },
}}
>
Add
</Button>
</Tooltip>
<Tooltip title="Show/Hide Columns">
<MRT_ShowHideColumnsButton table={table} />
</Tooltip>
</Box>
);
})()}
{/* Table or ghost empty state */}
<Box sx={{ flex: 1, position: 'relative' }}>
{filteredData.length === 0 ? (
<Box sx={{ position: 'relative', width: '100%', height: '100%', bgcolor: theme.palette.background.paper }}>
<Box
component="img"
src={ghostImage}
alt="Ghost"
sx={{
position: 'absolute',
top: '50%',
left: '50%',
width: '120px',
height: 'auto',
transform: 'translate(-50%, -50%)',
opacity: 0.2,
pointerEvents: 'none',
}}
/>
<Box
sx={{
position: 'absolute',
top: '25%',
left: '50%',
transform: 'translate(-50%, -50%)',
textAlign: 'center',
zIndex: 2,
width: 467,
px: 2,
}}
>
<Typography
sx={{
fontFamily: 'Inter, sans-serif',
fontWeight: 400,
fontSize: '20px',
lineHeight: '28px',
letterSpacing: '-0.3px',
color: theme.palette.text.secondary,
mb: 1,
}}
>
Its recommended to create channels after adding your M3U or streams.
</Typography>
<Typography
sx={{
fontFamily: 'Inter, sans-serif',
fontWeight: 400,
fontSize: '16px',
lineHeight: '24px',
letterSpacing: '-0.2px',
color: theme.palette.text.secondary,
mb: 2,
}}
>
You can still create channels without streams if youd like, and map them later.
</Typography>
<Button
variant="contained"
onClick={() => editChannel()}
startIcon={<AddIcon sx={{ fontSize: 16 }} />}
sx={{
minWidth: '127px',
height: '25px',
borderRadius: '4px',
borderWidth: '1px',
borderStyle: 'solid',
color: theme.palette.text.secondary,
borderColor: theme.palette.custom.borderHover,
backgroundColor: '#1f1f23',
fontFamily: 'Inter, sans-serif',
fontWeight: 400,
fontSize: '0.85rem',
letterSpacing: '-0.2px',
textTransform: 'none',
px: 1,
py: 0.5,
'&:hover': {
borderColor: theme.palette.custom.borderDefault,
backgroundColor: '#17171B',
},
}}
>
Create channel
</Button>
</Box>
</Box>
) : (
<Box sx={{ flex: 1, overflow: 'auto' }}>
<MaterialReactTable table={table} />
</Box>
)}
</Box>
</Paper>
{/* Channel Form */}
<ChannelForm channel={channel} isOpen={channelModalOpen} onClose={closeChannelForm} />
{/* Copy popover */}
<Popover
open={openPopover}
anchorEl={anchorEl}
onClose={closePopover}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
>
<Box sx={{ p: 2, display: 'flex', alignItems: 'center' }}>
<TextField
id="output-url"
value={textToCopy}
variant="standard"
size="small"
sx={{ mr: 1 }}
inputRef={outputUrlRef}
/>
<IconButton onClick={handleCopy} color="primary">
<ContentCopy />
</IconButton>
</Box>
</Popover>
<Snackbar
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
open={snackbarOpen}
autoHideDuration={5000}
onClose={closeSnackbar}
message={snackbarMessage}
/>
</Box>
);
};
export default ChannelsTable;

View file

@ -0,0 +1,880 @@
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import { MantineReactTable, useMantineReactTable } from 'mantine-react-table';
import useChannelsStore from '../../store/channels';
import { notifications } from '@mantine/notifications';
import {
Add as AddIcon,
LiveTv as LiveTvIcon,
ContentCopy,
IndeterminateCheckBox,
CompareArrows,
Code,
AddBox,
} 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 useVideoStore from '../../store/useVideoStore';
import useSettingsStore from '../../store/settings';
import usePlaylistsStore from '../../store/playlists';
import {
Tv2,
ScreenShare,
Scroll,
SquareMinus,
CirclePlay,
SquarePen,
Binary,
ArrowDown01,
SquarePlus,
} from 'lucide-react';
import ghostImage from '../../images/ghost.svg';
import {
Box,
TextInput,
Popover,
ActionIcon,
Select,
Button,
Paper,
Flex,
Text,
Tooltip,
Grid,
Group,
useMantineTheme,
Center,
Container,
} from '@mantine/core';
const ChannelStreams = ({ channel, isExpanded }) => {
const channelStreams = useChannelsStore(
(state) => state.channels[channel.id]?.streams
);
const { playlists } = usePlaylistsStore();
const removeStream = async (stream) => {
const newStreamList = channelStreams.filter((s) => s.id !== stream.id);
await API.updateChannel({
...channel,
stream_ids: newStreamList.map((s) => s.id),
});
};
const channelStreamsTable = useMantineReactTable({
...TableHelper.defaultProperties,
data: channelStreams,
columns: useMemo(
() => [
{
size: 400,
header: 'Name',
accessorKey: 'name',
Cell: ({ cell }) => (
<div
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{cell.getValue()}
</div>
),
},
{
size: 100,
header: 'M3U',
accessorFn: (row) =>
playlists.find((playlist) => playlist.id === row.m3u_account)?.name,
Cell: ({ cell }) => (
<div
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{cell.getValue()}
</div>
),
},
],
[playlists]
),
displayColumnDefOptions: {
'mrt-row-actions': {
size: 10,
},
},
enableKeyboardShortcuts: false,
enableColumnFilters: 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,
mantineTableHeadRowProps: {
style: { display: 'none' },
},
mantineTableBodyCellProps: {
style: {
// py: 0,
padding: 4,
borderColor: '#444',
color: '#E0E0E0',
fontSize: '0.85rem',
},
},
mantineRowDragHandleProps: ({ table }) => ({
onDragEnd: async () => {
const { draggingRow, hoveredRow } = table.getState();
if (hoveredRow && draggingRow) {
channelStreams.splice(
hoveredRow.index,
0,
channelStreams.splice(draggingRow.index, 1)[0]
);
const { streams: _, ...channelUpdate } = channel;
API.updateChannel({
...channelUpdate,
stream_ids: channelStreams.map((stream) => stream.id),
});
}
},
}),
renderRowActions: ({ row }) => (
<Tooltip label="Remove stream">
<ActionIcon
size="sm"
color="red.9"
variant="transparent"
onClick={() => removeStream(row.original)}
>
<SquareMinus size="18" fontSize="small" />
</ActionIcon>
</Tooltip>
),
});
if (!isExpanded) {
return <></>;
}
return (
<Box style={{ width: '100%' }}>
<MantineReactTable table={channelStreamsTable} />
</Box>
);
};
const m3uUrl = `${window.location.protocol}//${window.location.host}/output/m3u`;
const epgUrl = `${window.location.protocol}//${window.location.host}/output/epg`;
const hdhrUrl = `${window.location.protocol}//${window.location.host}/output/hdhr`;
const ChannelsTable = ({}) => {
const [channel, setChannel] = useState(null);
const [channelModalOpen, setChannelModalOpen] = useState(false);
const [rowSelection, setRowSelection] = useState([]);
const [channelGroupOptions, setChannelGroupOptions] = useState([]);
const [textToCopy, setTextToCopy] = useState('');
const [filterValues, setFilterValues] = useState({});
// const theme = useTheme();
const theme = useMantineTheme();
const { showVideo } = useVideoStore();
const {
channels,
isLoading: channelsLoading,
fetchChannels,
setChannelsPageSelection,
} = useChannelsStore();
useEffect(() => {
setChannelGroupOptions([
...new Set(
Object.values(channels).map((channel) => channel.channel_group?.name)
),
]);
}, [channels]);
const handleFilterChange = (columnId, value) => {
setFilterValues((prev) => ({
...prev,
[columnId]: value ? value.toLowerCase() : '',
}));
};
const hdhrUrlRef = useRef(null);
const m3uUrlRef = useRef(null);
const epgUrlRef = useRef(null);
const {
environment: { env_mode },
} = useSettingsStore();
// Configure columns
const columns = useMemo(
() => [
{
header: '#',
size: 50,
accessorKey: 'channel_number',
},
{
header: 'Name',
accessorKey: 'channel_name',
mantineTableHeadCellProps: {
sx: { textAlign: 'center' },
},
Header: ({ column }) => (
<TextInput
name="name"
placeholder="Name"
value={filterValues[column.id]}
onChange={(e) => {
e.stopPropagation();
handleFilterChange(column.id, e.target.value);
}}
size="xs"
/>
),
Cell: ({ cell }) => (
<div
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{cell.getValue()}
</div>
),
},
{
header: 'Group',
accessorFn: (row) => row.channel_group?.name || '',
Header: ({ column }) => (
<Select
placeholder="Group"
searchable
size="xs"
nothingFound="No options"
onChange={(e, value) => {
e.stopPropagation();
handleGroupChange(value);
}}
data={channelGroupOptions}
/>
),
},
{
header: 'Logo',
accessorKey: 'logo_url',
enableSorting: false,
size: 55,
Cell: ({ cell }) => (
<Grid
container
direction="row"
sx={{
justifyContent: 'center',
alignItems: 'center',
}}
>
<img src={cell.getValue() || logo} width="20" alt="channel logo" />
</Grid>
),
meta: {
filterVariant: null,
},
},
],
[channelGroupOptions, filterValues]
);
// Access the row virtualizer instance (optional)
const rowVirtualizerInstanceRef = useRef(null);
const [isLoading, setIsLoading] = useState(true);
const [sorting, setSorting] = useState([]);
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 API.deleteChannels(selected.map((row) => row.original.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!" }
notifications.show({
title: result.message || 'Channels assigned',
color: 'green.5',
});
// Refresh the channel list
await fetchChannels();
} catch (err) {
console.error(err);
notifications.show({
title: 'Failed to assign channels',
color: 'red.5',
});
}
};
//
// 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) {
showAlert('EPG matching task started!');
} else {
const text = await resp.text();
showAlert(`Failed to start EPG matching: ${text}`);
}
} catch (err) {
showAlert(`Error: ${err.message}`);
}
};
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 handleCopy = async (textToCopy, ref) => {
try {
await navigator.clipboard.writeText(textToCopy);
notifications.show({
title: 'Copied!',
// style: { width: '200px', left: '200px' },
});
} catch (err) {
const inputElement = ref.current; // Get the actual input
if (inputElement) {
inputElement.focus();
inputElement.select();
// For older browsers
document.execCommand('copy');
notifications.show({ title: 'Copied!' });
}
}
};
// Example copy URLs
const copyM3UUrl = () => {
handleCopy(
`${window.location.protocol}//${window.location.host}/output/m3u`,
m3uUrlRef
);
};
const copyEPGUrl = () => {
handleCopy(
`${window.location.protocol}//${window.location.host}/output/epg`,
epgUrlRef
);
};
const copyHDHRUrl = () => {
handleCopy(
`${window.location.protocol}//${window.location.host}/output/hdhr`,
hdhrUrlRef
);
};
useEffect(() => {
const selectedRows = table
.getSelectedRowModel()
.rows.map((row) => row.original);
setChannelsPageSelection(selectedRows);
}, [rowSelection]);
const filteredData = Object.values(channels).filter((row) =>
columns.every(({ accessorKey }) =>
filterValues[accessorKey]
? row[accessorKey]?.toLowerCase().includes(filterValues[accessorKey])
: true
)
);
const table = useMantineReactTable({
...TableHelper.defaultProperties,
columns,
data: filteredData,
enablePagination: false,
enableColumnActions: false,
enableRowVirtualization: true,
enableRowSelection: true,
renderTopToolbar: false,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
state: {
isLoading: isLoading || channelsLoading,
sorting,
rowSelection,
},
rowVirtualizerInstanceRef,
rowVirtualizerOptions: { overscan: 5 },
initialState: {
density: 'compact',
},
enableRowActions: true,
enableExpandAll: false,
displayColumnDefOptions: {
'mrt-row-select': {
size: 20,
},
'mrt-row-expand': {
size: 10,
header: '',
},
'mrt-row-actions': {
size: 74,
},
},
mantineExpandButtonProps: ({ row, table }) => ({
onClick: () => {
setRowSelection({ [row.index]: true });
table.setExpanded({ [row.id]: !row.getIsExpanded() });
},
size: 'xs',
style: {
transform: row.getIsExpanded() ? 'rotate(180deg)' : 'rotate(-90deg)',
transition: 'transform 0.2s',
},
}),
renderDetailPanel: ({ row }) => (
<ChannelStreams channel={row.original} isExpanded={row.getIsExpanded()} />
),
renderRowActions: ({ row }) => (
<Box sx={{ justifyContent: 'right' }}>
<Center>
<Tooltip label="Edit Channel">
<ActionIcon
size="sm"
variant="transparent"
color="yellow.5"
onClick={() => {
editChannel(row.original);
}}
>
<SquarePen size="18" />
</ActionIcon>
</Tooltip>
<Tooltip label="Delete Channel">
<ActionIcon
size="sm"
variant="transparent"
color="red.9"
onClick={() => deleteChannel(row.original.id)}
>
<SquareMinus size="18" />
</ActionIcon>
</Tooltip>
<Tooltip label="Preview Channel">
<ActionIcon
size="sm"
variant="transparent"
color="green.5"
onClick={() => handleWatchStream(row.original.channel_number)}
>
<CirclePlay size="18" />
</ActionIcon>
</Tooltip>
</Center>
</Box>
),
mantineTableContainerProps: {
style: {
height: 'calc(100vh - 127px)',
overflowY: 'auto',
},
},
});
return (
<Box>
{/* Header Row: outside the Paper */}
<Flex
style={{ display: 'flex', alignItems: 'center', paddingBottom: 10 }}
gap={15}
>
<Text
w={88}
h={24}
style={{
fontFamily: 'Inter, sans-serif',
fontWeight: 500,
fontSize: '20px',
lineHeight: 1,
letterSpacing: '-0.3px',
color: 'gray.6', // Adjust this to match MUI's theme.palette.text.secondary
marginBottom: 0,
}}
>
Channels
</Text>
<Flex
style={{
display: 'flex',
alignItems: 'center',
marginLeft: 10,
}}
>
<Text
w={37}
h={17}
style={{
fontFamily: 'Inter, sans-serif',
fontWeight: 400,
fontSize: '14px',
lineHeight: 1,
letterSpacing: '-0.3px',
color: 'gray.6', // Adjust this to match MUI's theme.palette.text.secondary
}}
>
Links:
</Text>
<Group gap={5} style={{ paddingLeft: 10 }}>
<Popover withArrow shadow="md">
<Popover.Target>
<Button
leftSection={<Tv2 size={18} />}
size="compact-sm"
p={5}
color="green"
variant="subtle"
style={{
borderColor: theme.palette.custom.greenMain,
color: theme.palette.custom.greenMain,
}}
>
HDHR
</Button>
</Popover.Target>
<Popover.Dropdown>
<Group>
<TextInput ref={hdhrUrlRef} value={hdhrUrl} size="small" />
<ActionIcon
onClick={copyHDHRUrl}
size="sm"
variant="transparent"
color="gray.5"
>
<ContentCopy size="18" fontSize="small" />
</ActionIcon>
</Group>
</Popover.Dropdown>
</Popover>
<Popover withArrow shadow="md">
<Popover.Target>
<Button
leftSection={<ScreenShare size={18} />}
size="compact-sm"
p={5}
variant="subtle"
style={{
borderColor: theme.palette.custom.indigoMain,
color: theme.palette.custom.indigoMain,
}}
>
M3U
</Button>
</Popover.Target>
<Popover.Dropdown>
<Group>
<TextInput ref={m3uUrlRef} value={m3uUrl} size="small" />
<ActionIcon
onClick={copyM3UUrl}
size="sm"
variant="transparent"
color="gray.5"
>
<ContentCopy size="18" fontSize="small" />
</ActionIcon>
</Group>
</Popover.Dropdown>
</Popover>
<Popover withArrow shadow="md">
<Popover.Target>
<Button
leftSection={<Scroll size={18} />}
size="compact-sm"
p={5}
variant="subtle"
color="gray.5"
style={{
borderColor: theme.palette.custom.greyBorder,
color: theme.palette.custom.greyBorder,
}}
>
EPG
</Button>
</Popover.Target>
<Popover.Dropdown>
<Group>
<TextInput ref={epgUrlRef} value={epgUrl} size="small" />
<ActionIcon
onClick={copyEPGUrl}
size="sm"
variant="transparent"
color="gray.5"
>
<ContentCopy size="18" fontSize="small" />
</ActionIcon>
</Group>
</Popover.Dropdown>
</Popover>
</Group>
</Flex>
</Flex>
{/* Paper container: contains top toolbar and table (or ghost state) */}
<Paper
style={{
// bgcolor: theme.palette.background.paper,
// borderRadius: 2,
// overflow: 'hidden',
// display: 'flex',
// flexDirection: 'column',
height: 'calc(100vh - 75px)',
}}
>
{/* Top toolbar with Remove, Assign, Auto-match, and Add buttons */}
<Box
style={{
display: 'flex',
// alignItems: 'center',
// backgroundColor: theme.palette.background.paper,
justifyContent: 'flex-end',
padding: 10,
// gap: 1,
}}
>
<Flex gap={6}>
<Button
leftSection={<SquareMinus size={18} />}
variant="default"
size="xs"
onClick={deleteChannels}
>
Remove
</Button>
<Tooltip label="Assign Channel #s">
<Button
leftSection={<ArrowDown01 size={18} />}
variant="default"
size="xs"
onClick={assignChannels}
p={5}
>
Assign
</Button>
</Tooltip>
<Tooltip label="Auto-Match EPG">
<Button
leftSection={<Binary size={18} />}
variant="default"
size="xs"
onClick={matchEpg}
p={5}
>
Auto-Match
</Button>
</Tooltip>
<Button
leftSection={<SquarePlus size={18} />}
variant="light"
size="xs"
onClick={() => editChannel()}
p={5}
color="green"
style={{
borderWidth: '1px',
borderColor: 'green',
color: 'white',
}}
>
Add
</Button>
</Flex>
</Box>
{/* Table or ghost empty state inside Paper */}
<Box>
{Object.keys(channels).length === 0 && (
<Box
style={{
paddingTop: 20,
bgcolor: theme.palette.background.paper,
}}
>
<Center>
<Box
style={{
textAlign: 'center',
width: '55%',
}}
>
<Text
style={{
fontFamily: 'Inter, sans-serif',
fontWeight: 400,
fontSize: '20px',
lineHeight: '28px',
letterSpacing: '-0.3px',
color: theme.palette.text.secondary,
mb: 1,
}}
>
Its recommended to create channels after adding your M3U or
streams.
</Text>
<Text
style={{
fontFamily: 'Inter, sans-serif',
fontWeight: 400,
fontSize: '16px',
lineHeight: '24px',
letterSpacing: '-0.2px',
color: theme.palette.text.secondary,
mb: 2,
}}
>
You can still create channels without streams if youd like,
and map them later.
</Text>
<Button
leftSection={<SquarePlus size={18} />}
variant="light"
size="xs"
onClick={() => editChannel()}
color="gray"
style={{
marginTop: 20,
borderWidth: '1px',
borderColor: 'gray',
color: 'white',
}}
>
Create Channel
</Button>
</Box>
</Center>
<Center>
<Box
component="img"
src={ghostImage}
alt="Ghost"
style={{
paddingTop: 30,
width: '120px',
height: 'auto',
opacity: 0.2,
pointerEvents: 'none',
}}
/>
</Center>
</Box>
)}
</Box>
{Object.keys(channels).length > 0 && (
<MantineReactTable table={table} />
)}
</Paper>
<ChannelForm
channel={channel}
isOpen={channelModalOpen}
onClose={closeChannelForm}
/>
</Box>
);
};
export default ChannelsTable;

View file

@ -1,205 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
MaterialReactTable,
MRT_ShowHideColumnsButton,
MRT_ToggleFullScreenButton,
useMaterialReactTable,
} from 'material-react-table';
import {
Box,
Grid2,
Stack,
Typography,
IconButton,
Tooltip,
Checkbox,
Select,
MenuItem,
Snackbar,
} from '@mui/material';
import API from '../../api';
import {
Delete as DeleteIcon,
Edit as EditIcon,
Add as AddIcon,
SwapVert as SwapVertIcon,
Check as CheckIcon,
Close as CloseIcon,
Refresh as RefreshIcon,
} from '@mui/icons-material';
import useEPGsStore from '../../store/epgs';
import EPGForm from '../forms/EPG';
import { TableHelper } from '../../helpers';
const EPGsTable = () => {
const [epg, setEPG] = useState(null);
const [epgModalOpen, setEPGModalOpen] = useState(false);
const [rowSelection, setRowSelection] = useState([]);
const [snackbarMessage, setSnackbarMessage] = useState('');
const [snackbarOpen, setSnackbarOpen] = useState(false);
const epgs = useEPGsStore((state) => state.epgs);
const columns = useMemo(
//column definitions...
() => [
{
header: 'Name',
accessorKey: 'name',
},
{
header: 'Source Type',
accessorKey: 'source_type',
},
{
header: 'URL / API Key',
accessorKey: 'max_streams',
},
],
[]
);
//optionally access the underlying virtualizer instance
const rowVirtualizerInstanceRef = useRef(null);
const [isLoading, setIsLoading] = useState(true);
const [sorting, setSorting] = useState([]);
const closeSnackbar = () => {
setSnackbarOpen(false);
};
const editEPG = async (epg = null) => {
setEPG(epg);
setEPGModalOpen(true);
};
const deleteEPG = async (id) => {
setIsLoading(true);
await API.deleteEPG(id);
setIsLoading(false);
};
const refreshEPG = async (id) => {
await API.refreshEPG(id);
setSnackbarMessage('EPG refresh initiated');
setSnackbarOpen(true);
};
const closeEPGForm = () => {
setEPG(null);
setEPGModalOpen(false);
};
useEffect(() => {
if (typeof window !== 'undefined') {
setIsLoading(false);
}
}, []);
useEffect(() => {
//scroll to the top of the table when the sorting changes
try {
rowVirtualizerInstanceRef.current?.scrollToIndex?.(0);
} catch (error) {
console.error(error);
}
}, [sorting]);
const table = useMaterialReactTable({
...TableHelper.defaultProperties,
columns,
data: epgs,
enablePagination: false,
enableRowVirtualization: true,
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
state: {
isLoading,
sorting,
rowSelection,
},
rowVirtualizerInstanceRef, //optional
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
initialState: {
density: 'compact',
},
enableRowActions: true,
renderRowActions: ({ row }) => (
<>
<IconButton
size="small" // Makes the button smaller
color="info" // Red color for delete actions
onClick={() => editEPG(row.original)}
sx={{ pt: 0, pb: 0 }}
>
<EditIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
<IconButton
size="small" // Makes the button smaller
color="error" // Red color for delete actions
onClick={() => deleteEPG(row.original.id)}
sx={{ pt: 0, pb: 0 }}
>
<DeleteIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
<IconButton
size="small" // Makes the button smaller
color="info" // Red color for delete actions
onClick={() => refreshEPG(row.original.id)}
sx={{ pt: 0, pb: 0 }}
>
<RefreshIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
</>
),
muiTableContainerProps: {
sx: {
height: 'calc(43vh - 0px)',
},
},
renderTopToolbarCustomActions: ({ table }) => (
<Stack
direction="row"
sx={{
alignItems: 'center',
}}
>
<Typography>EPGs</Typography>
<Tooltip title="Add New EPG">
<IconButton
size="small" // Makes the button smaller
color="success" // Red color for delete actions
variant="contained"
onClick={() => editEPG()}
>
<AddIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
</Tooltip>
</Stack>
),
});
return (
<Box
sx={{
padding: 1,
}}
>
<MaterialReactTable table={table} />
<EPGForm epg={epg} isOpen={epgModalOpen} onClose={closeEPGForm} />
<Snackbar
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
open={snackbarOpen}
autoHideDuration={5000}
onClose={closeSnackbar}
message={snackbarMessage}
/>
</Box>
);
};
export default EPGsTable;

View file

@ -0,0 +1,226 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { MantineReactTable, useMantineReactTable } from 'mantine-react-table';
import API from '../../api';
import {
Delete as DeleteIcon,
Edit as EditIcon,
Add as AddIcon,
Refresh as RefreshIcon,
} from '@mui/icons-material';
import useEPGsStore from '../../store/epgs';
import EPGForm from '../forms/EPG';
import { TableHelper } from '../../helpers';
import {
ActionIcon,
Text,
Tooltip,
Box,
Paper,
Button,
Flex,
useMantineTheme,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { IconSquarePlus } from '@tabler/icons-react';
import { RefreshCcw, SquareMinus, SquarePen } from 'lucide-react';
const EPGsTable = () => {
const [epg, setEPG] = useState(null);
const [epgModalOpen, setEPGModalOpen] = useState(false);
const [rowSelection, setRowSelection] = useState([]);
const epgs = useEPGsStore((state) => state.epgs);
const theme = useMantineTheme();
const columns = useMemo(
//column definitions...
() => [
{
header: 'Name',
accessorKey: 'name',
},
{
header: 'Source Type',
accessorKey: 'source_type',
},
{
header: 'URL / API Key',
accessorKey: 'max_streams',
},
],
[]
);
//optionally access the underlying virtualizer instance
const rowVirtualizerInstanceRef = useRef(null);
const [isLoading, setIsLoading] = useState(true);
const [sorting, setSorting] = useState([]);
const editEPG = async (epg = null) => {
setEPG(epg);
setEPGModalOpen(true);
};
const deleteEPG = async (id) => {
setIsLoading(true);
await API.deleteEPG(id);
setIsLoading(false);
};
const refreshEPG = async (id) => {
await API.refreshEPG(id);
notifications.show({
title: 'EPG refresh initiated',
});
};
const closeEPGForm = () => {
setEPG(null);
setEPGModalOpen(false);
};
useEffect(() => {
if (typeof window !== 'undefined') {
setIsLoading(false);
}
}, []);
useEffect(() => {
//scroll to the top of the table when the sorting changes
try {
rowVirtualizerInstanceRef.current?.scrollToIndex?.(0);
} catch (error) {
console.error(error);
}
}, [sorting]);
const table = useMantineReactTable({
...TableHelper.defaultProperties,
columns,
data: epgs,
enablePagination: false,
enableRowVirtualization: true,
enableRowSelection: false,
renderTopToolbar: false,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
state: {
isLoading,
sorting,
rowSelection,
},
rowVirtualizerInstanceRef, //optional
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
initialState: {
density: 'compact',
},
enableRowActions: true,
renderRowActions: ({ row }) => (
<>
<ActionIcon
variant="transparent"
size="sm" // Makes the button smaller
color="yellow.5" // Red color for delete actions
onClick={() => editEPG(row.original)}
>
<SquarePen size="18" /> {/* Small icon size */}
</ActionIcon>
<ActionIcon
variant="transparent"
size="sm" // Makes the button smaller
color="red.9" // Red color for delete actions
onClick={() => deleteEPG(row.original.id)}
>
<SquareMinus size="18" /> {/* Small icon size */}
</ActionIcon>
<ActionIcon
variant="transparent"
size="sm" // Makes the button smaller
color="blue.5" // Red color for delete actions
onClick={() => refreshEPG(row.original.id)}
>
<RefreshCcw size="18" /> {/* Small icon size */}
</ActionIcon>
</>
),
mantineTableContainerProps: {
style: {
height: 'calc(40vh - 0px)',
},
},
});
return (
<Box>
<Flex
style={{
display: 'flex',
alignItems: 'center',
paddingBottom: 10,
}}
gap={15}
>
<Text
h={24}
style={{
fontFamily: 'Inter, sans-serif',
fontWeight: 500,
fontSize: '20px',
lineHeight: 1,
letterSpacing: '-0.3px',
color: 'gray.6', // Adjust this to match MUI's theme.palette.text.secondary
marginBottom: 0,
}}
>
EPGs
</Text>
</Flex>
<Paper
style={{
bgcolor: theme.palette.background.paper,
borderRadius: 2,
}}
>
{/* Top toolbar with Remove, Assign, Auto-match, and Add buttons */}
<Box
style={{
display: 'flex',
// alignItems: 'center',
// backgroundColor: theme.palette.background.paper,
justifyContent: 'flex-end',
padding: 10,
// gap: 1,
}}
>
<Flex gap={6}>
<Tooltip label="Assign">
<Button
leftSection={<IconSquarePlus size={18} />}
variant="light"
size="xs"
onClick={() => editEPG()}
p={5}
color="green"
style={{
borderWidth: '1px',
borderColor: 'green',
color: 'white',
}}
>
Add EPG
</Button>
</Tooltip>
</Flex>
</Box>
</Paper>
<MantineReactTable table={table} />
<EPGForm epg={epg} isOpen={epgModalOpen} onClose={closeEPGForm} />
</Box>
);
};
export default EPGsTable;

View file

@ -1,17 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
MaterialReactTable,
useMaterialReactTable,
} from 'material-react-table';
import {
Box,
Stack,
Typography,
IconButton,
Tooltip,
Select,
MenuItem,
} from '@mui/material';
import { MantineReactTable, useMantineReactTable } from 'mantine-react-table';
import API from '../../api';
import {
Delete as DeleteIcon,
@ -20,11 +8,53 @@ import {
SwapVert as SwapVertIcon,
Check as CheckIcon,
Close as CloseIcon,
Refresh as RefreshIcon,
} from '@mui/icons-material';
import {
LiveTv as LiveTvIcon,
ContentCopy,
Tv as TvIcon,
Clear as ClearIcon,
IndeterminateCheckBox,
CompareArrows,
Code,
AddBox,
Hd as HdIcon,
} from '@mui/icons-material';
import usePlaylistsStore from '../../store/playlists';
import M3UForm from '../forms/M3U';
import { TableHelper } from '../../helpers';
import {
useMantineTheme,
Paper,
Button,
Flex,
Text,
Box,
ActionIcon,
Tooltip,
Select,
} from '@mantine/core';
import {
Tv2,
ScreenShare,
Scroll,
SquareMinus,
Pencil,
ArrowUp,
ArrowDown,
ArrowUpDown,
TvMinimalPlay,
SquarePen,
RefreshCcw,
} from 'lucide-react';
import {
IconArrowDown,
IconArrowUp,
IconDeviceDesktopSearch,
IconSelector,
IconSortAscendingNumbers,
IconSquarePlus,
} from '@tabler/icons-react'; // Import custom icons
const Example = () => {
const [playlist, setPlaylist] = useState(null);
@ -34,6 +64,8 @@ const Example = () => {
const playlists = usePlaylistsStore((state) => state.playlists);
const theme = useMantineTheme();
const columns = useMemo(
//column definitions...
() => [
@ -44,6 +76,17 @@ const Example = () => {
{
header: 'URL / File',
accessorKey: 'server_url',
Cell: ({ cell }) => (
<div
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{cell.getValue()}
</div>
),
},
{
header: 'Max Streams',
@ -55,7 +98,7 @@ const Example = () => {
accessorKey: 'is_active',
size: 100,
sortingFn: 'basic',
muiTableBodyCellProps: {
mantineTableBodyCellProps: {
align: 'left',
},
Cell: ({ cell }) => (
@ -67,29 +110,6 @@ const Example = () => {
)}
</Box>
),
Filter: ({ column }) => (
<Box>
<Select
size="small"
variant="standard"
value={activeFilterValue}
onChange={(e) => {
setActiveFilterValue(e.target.value);
column.setFilterValue(e.target.value);
}}
displayEmpty
fullWidth
>
<MenuItem value="all">All</MenuItem>
<MenuItem value="true">Active</MenuItem>
<MenuItem value="false">Inactive</MenuItem>
</Select>
</Box>
),
filterFn: (row, _columnId, activeFilterValue) => {
if (!activeFilterValue) return true; // Show all if no filter
return String(row.getValue('is_active')) === activeFilterValue;
},
},
],
[]
@ -143,14 +163,15 @@ const Example = () => {
}
}, [sorting]);
const table = useMaterialReactTable({
const table = useMantineReactTable({
...TableHelper.defaultProperties,
columns,
data: playlists,
enablePagination: false,
enableRowVirtualization: true,
// enableRowSelection: true,
enableRowSelection: false,
onRowSelectionChange: setRowSelection,
renderTopToolbar: false,
onSortingChange: setSorting,
state: {
isLoading,
@ -165,71 +186,103 @@ const Example = () => {
enableRowActions: true,
renderRowActions: ({ row }) => (
<>
<IconButton
size="small" // Makes the button smaller
color="warning" // Red color for delete actions
<ActionIcon
variant="transparent"
size="sm"
color="yellow.5"
onClick={() => {
editPlaylist(row.original);
}}
sx={{ pt: 0, pb: 0 }}
>
<EditIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
<IconButton
size="small" // Makes the button smaller
color="error" // Red color for delete actions
<SquarePen size="18" />
</ActionIcon>
<ActionIcon
variant="transparent"
size="sm"
color="red.9"
onClick={() => deletePlaylist(row.original.id)}
sx={{ pt: 0, pb: 0 }}
>
<DeleteIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
<IconButton
size="small" // Makes the button smaller
color="info" // Red color for delete actions
variant="contained"
<SquareMinus size="18" />
</ActionIcon>
<ActionIcon
variant="transparent"
size="sm"
color="blue.5"
onClick={() => refreshPlaylist(row.original.id)}
sx={{ pt: 0, pb: 0 }}
>
<RefreshIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
<RefreshCcw size="18" />
</ActionIcon>
</>
),
muiTableContainerProps: {
sx: {
height: 'calc(43vh - 0px)',
pr: 1,
pl: 1,
mantineTableContainerProps: {
style: {
height: 'calc(40vh - 0px)',
},
},
renderTopToolbarCustomActions: ({ table }) => (
<Stack
direction="row"
sx={{
alignItems: 'center',
}}
>
<Typography>M3U Accounts</Typography>
<Tooltip title="Add New M3U Account">
<IconButton
size="small" // Makes the button smaller
color="success" // Red color for delete actions
variant="contained"
onClick={() => editPlaylist()}
>
<AddIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
</Tooltip>
</Stack>
),
});
return (
<Box
sx={{
padding: 1,
}}
>
<MaterialReactTable table={table} />
<Box>
<Flex
style={{ display: 'flex', alignItems: 'center', paddingBottom: 10 }}
gap={15}
>
<Text
h={24}
style={{
fontFamily: 'Inter, sans-serif',
fontWeight: 500,
fontSize: '20px',
lineHeight: 1,
letterSpacing: '-0.3px',
color: 'gray.6', // Adjust this to match MUI's theme.palette.text.secondary
marginBottom: 0,
}}
>
M3U Accounts
</Text>
</Flex>
<Paper
style={{
bgcolor: theme.palette.background.paper,
borderRadius: 2,
}}
>
{/* Top toolbar with Remove, Assign, Auto-match, and Add buttons */}
<Box
style={{
display: 'flex',
// alignItems: 'center',
// backgroundColor: theme.palette.background.paper,
justifyContent: 'flex-end',
padding: 10,
// gap: 1,
}}
>
<Flex gap={6}>
<Tooltip label="Assign">
<Button
leftSection={<IconSquarePlus size={14} />}
variant="light"
size="xs"
onClick={() => editPlaylist()}
p={5}
color="green"
style={{
borderWidth: '1px',
borderColor: 'green',
color: 'white',
}}
>
Add
</Button>
</Tooltip>
</Flex>
</Box>
</Paper>
<MantineReactTable table={table} />
<M3UForm
playlist={playlist}
isOpen={playlistModalOpen}

View file

@ -1,49 +1,41 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
MaterialReactTable,
MRT_ShowHideColumnsButton,
MRT_ToggleFullScreenButton,
useMaterialReactTable,
} from 'material-react-table';
import {
Box,
Grid2,
Stack,
Typography,
IconButton,
Tooltip,
Checkbox,
Select,
MenuItem,
} from '@mui/material';
import { MantineReactTable, useMantineReactTable } from 'mantine-react-table';
import API from '../../api';
import {
Delete as DeleteIcon,
Edit as EditIcon,
Add as AddIcon,
SwapVert as SwapVertIcon,
Check as CheckIcon,
Close as CloseIcon,
Refresh as RefreshIcon,
} from '@mui/icons-material';
import useEPGsStore from '../../store/epgs';
import StreamProfileForm from '../forms/StreamProfile';
import useStreamProfilesStore from '../../store/streamProfiles';
import { TableHelper } from '../../helpers';
import useSettingsStore from '../../store/settings';
import useAlertStore from '../../store/alerts';
import { notifications } from '@mantine/notifications';
import {
Box,
ActionIcon,
Tooltip,
Text,
Paper,
Flex,
Button,
useMantineTheme,
} from '@mantine/core';
import { IconSquarePlus } from '@tabler/icons-react';
import { SquareMinus, SquarePen } from 'lucide-react';
const StreamProfiles = () => {
const [profile, setProfile] = useState(null);
const [profileModalOpen, setProfileModalOpen] = useState(false);
const [rowSelection, setRowSelection] = useState([]);
const [snackbarMessage, setSnackbarMessage] = useState('');
const [snackbarOpen, setSnackbarOpen] = useState(false);
const [activeFilterValue, setActiveFilterValue] = useState('all');
const streamProfiles = useStreamProfilesStore((state) => state.profiles);
const { settings } = useSettingsStore();
const { showAlert } = useAlertStore();
const theme = useMantineTheme();
const columns = useMemo(
//column definitions...
@ -81,23 +73,18 @@ const StreamProfiles = () => {
<Box>
<Select
size="small"
variant="standard"
value={activeFilterValue}
onChange={(e) => {
setActiveFilterValue(e.target.value);
column.setFilterValue(e.target.value);
}}
displayEmpty
fullWidth
>
<MenuItem value="all">All</MenuItem>
<MenuItem value="true">Active</MenuItem>
<MenuItem value="false">Inactive</MenuItem>
</Select>
data={['All', 'Active', 'Inactive']}
/>
</Box>
),
filterFn: (row, _columnId, filterValue) => {
if (filterValue == 'all') return true; // Show all if no filter
if (filterValue == 'all') return true;
return String(row.getValue('is_active')) === filterValue;
},
},
@ -118,7 +105,10 @@ const StreamProfiles = () => {
const deleteStreamProfile = async (id) => {
if (id == settings['default-stream-profile'].value) {
showAlert('Cannot delete default stream-profile', 'error');
notifications.show({
title: 'Cannot delete default stream-profile',
color: 'red.5',
});
return;
}
@ -145,19 +135,20 @@ const StreamProfiles = () => {
}
}, [sorting]);
const table = useMaterialReactTable({
const table = useMantineReactTable({
...TableHelper.defaultProperties,
columns,
data: streamProfiles,
enablePagination: false,
enableRowVirtualization: true,
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
// enableRowSelection: true,
renderTopToolbar: false,
// onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
state: {
isLoading,
sorting,
rowSelection,
// rowSelection,
},
rowVirtualizerInstanceRef, //optional
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
@ -167,59 +158,98 @@ const StreamProfiles = () => {
enableRowActions: true,
renderRowActions: ({ row }) => (
<>
<IconButton
size="small" // Makes the button smaller
color="warning" // Red color for delete actions
<ActionIcon
variant="transparent"
color="yellow.5"
size="sm"
onClick={() => editStreamProfile(row.original)}
sx={{ pt: 0, pb: 0 }}
>
<EditIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
<IconButton
size="small" // Makes the button smaller
color="error" // Red color for delete actions
<SquarePen size="18" /> {/* Small icon size */}
</ActionIcon>
<ActionIcon
variant="transparent"
size="sm"
color="red.9"
onClick={() => deleteStreamProfile(row.original.id)}
sx={{ pt: 0, pb: 0 }}
>
<DeleteIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
<SquareMinus fontSize="small" /> {/* Small icon size */}
</ActionIcon>
</>
),
muiTableContainerProps: {
sx: {
height: 'calc(100vh - 73px)', // Subtract padding to avoid cutoff
overflowY: 'auto', // Internal scrolling for the table
mantineTableContainerProps: {
style: {
height: 'calc(100vh - 120px)',
overflowY: 'auto',
},
},
renderTopToolbarCustomActions: ({ table }) => (
<Stack
direction="row"
sx={{
alignItems: 'center',
}}
>
<Typography>Stream Profiles</Typography>
<Tooltip title="Add New Stream Profile">
<IconButton
size="small" // Makes the button smaller
color="success" // Red color for delete actions
variant="contained"
onClick={() => editStreamProfile()}
>
<AddIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
</Tooltip>
</Stack>
),
});
return (
<Box
sx={{
padding: 1,
}}
>
<MaterialReactTable table={table} />
<Box>
<Flex
style={{
display: 'flex',
alignItems: 'center',
paddingBottom: 10,
}}
gap={15}
>
<Text
h={24}
style={{
fontFamily: 'Inter, sans-serif',
fontWeight: 500,
fontSize: '20px',
lineHeight: 1,
letterSpacing: '-0.3px',
color: 'gray.6', // Adjust this to match MUI's theme.palette.text.secondary
marginBottom: 0,
}}
>
Stream Profiles
</Text>
</Flex>
<Paper
style={{
bgcolor: theme.palette.background.paper,
borderRadius: 2,
}}
>
{/* Top toolbar with Remove, Assign, Auto-match, and Add buttons */}
<Box
style={{
display: 'flex',
// alignItems: 'center',
// backgroundColor: theme.palette.background.paper,
justifyContent: 'flex-end',
padding: 10,
// gap: 1,
}}
>
<Flex gap={6}>
<Tooltip label="Assign">
<Button
leftSection={<IconSquarePlus size={18} />}
variant="light"
size="xs"
onClick={() => editStreamProfile()}
p={5}
color="green"
style={{
borderWidth: '1px',
borderColor: 'green',
color: 'white',
}}
>
Add Stream Profile
</Button>
</Tooltip>
</Flex>
</Box>
</Paper>
<MantineReactTable table={table} />
<StreamProfileForm
profile={profile}

View file

@ -1,844 +0,0 @@
import { useEffect, useMemo, useCallback, useState, useRef } from 'react';
import {
MaterialReactTable,
useMaterialReactTable,
MRT_ShowHideColumnsButton, // <-- import this
} from 'material-react-table';
import {
Box,
Stack,
Typography,
IconButton,
Tooltip,
Button,
Menu,
MenuItem,
TextField,
Autocomplete,
InputAdornment,
Paper,
} from '@mui/material';
import API from '../../api';
import { useTheme } from '@mui/material/styles';
import {
Delete as DeleteIcon,
Edit as EditIcon,
Add as AddIcon,
MoreVert as MoreVertIcon,
PlaylistAdd as PlaylistAddIcon,
IndeterminateCheckBox,
AddBox,
} from '@mui/icons-material';
import { TableHelper } from '../../helpers';
import StreamForm from '../forms/Stream';
import usePlaylistsStore from '../../store/playlists';
import useChannelsStore from '../../store/channels';
import { useDebounce } from '../../utils';
import { SquarePlus, ListPlus } from 'lucide-react';
const StreamsTable = ({}) => {
const theme = useTheme();
/**
* useState
*/
const [rowSelection, setRowSelection] = useState([]);
const [stream, setStream] = useState(null);
const [modalOpen, setModalOpen] = useState(false);
const [moreActionsAnchorEl, setMoreActionsAnchorEl] = useState(null);
const [groupOptions, setGroupOptions] = useState([]);
const [m3uOptions, setM3uOptions] = useState([]);
const [actionsOpenRow, setActionsOpenRow] = useState(null);
const [data, setData] = useState([]); // Holds fetched data
const [rowCount, setRowCount] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [sorting, setSorting] = useState([]);
const [selectedStreamIds, setSelectedStreamIds] = useState([]);
const [unselectedStreamIds, setUnselectedStreamIds] = useState([]);
// const [allRowsSelected, setAllRowsSelected] = useState(false);
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: 25,
});
const [filters, setFilters] = useState({
name: '',
group_name: '',
m3u_account: '',
});
const debouncedFilters = useDebounce(filters, 500);
const hasData = data.length > 0;
/**
* Stores
*/
const { playlists } = usePlaylistsStore();
const { channelsPageSelection } = useChannelsStore();
const channelSelectionStreams = useChannelsStore(
(state) => state.channels[state.channelsPageSelection[0]?.id]?.streams
);
const isMoreActionsOpen = Boolean(moreActionsAnchorEl);
// Access the row virtualizer instance (optional)
const rowVirtualizerInstanceRef = useRef(null);
const eligibleSelectedStreamId = selectedStreamIds.find(
(id) =>
channelsPageSelection.length === 1 &&
!(
channelSelectionStreams &&
channelSelectionStreams.map((stream) => stream.id).includes(id)
)
);
/**
* useMemo
*/
const columns = useMemo(
() => [
{
header: 'Name',
accessorKey: 'name',
muiTableHeadCellProps: {
sx: { textAlign: 'center' }, // Center-align the header
},
Header: ({ column }) => (
<TextField
variant="standard"
name="name"
label="Name"
value={filters.name || ''}
onClick={(e) => e.stopPropagation()}
onChange={handleFilterChange}
size="small"
margin="none"
fullWidth
/>
),
},
{
header: 'Group',
accessorKey: 'group_name',
Header: ({ column }) => (
<Autocomplete
disablePortal
options={groupOptions}
size="small"
clearOnEscape
onChange={(e, value) => {
e.stopPropagation();
handleGroupChange(value);
}}
renderInput={(params) => (
<TextField
{...params}
label="Group"
size="small"
variant="standard"
onClick={(e) => e.stopPropagation()}
sx={{
pb: 0.8,
'& .MuiInputBase-root': { fontSize: '0.875rem' },
'& .MuiInputLabel-root': { fontSize: '0.75rem' },
width: '200px',
}}
/>
)}
/>
),
},
{
header: 'M3U',
size: 100,
accessorFn: (row) =>
playlists.find((playlist) => playlist.id === row.m3u_account)?.name,
Header: ({ column }) => (
<Autocomplete
disablePortal
options={playlists.map((playlist) => ({
label: playlist.name,
value: playlist.id,
}))}
size="small"
clearOnEscape
onChange={(e, value) => {
e.stopPropagation();
handleM3UChange(value);
}}
renderInput={(params) => (
<TextField
{...params}
label="M3U"
size="small"
variant="standard"
onClick={(e) => e.stopPropagation()}
sx={{
pb: 0.8,
'& .MuiInputBase-root': { fontSize: '0.875rem' },
'& .MuiInputLabel-root': { fontSize: '0.75rem' },
width: '200px',
}}
/>
)}
/>
),
},
],
[playlists, groupOptions, filters]
);
/**
* Functions
*/
const handleFilterChange = (e) => {
const { name, value } = e.target;
setFilters((prev) => ({
...prev,
[name]: value,
}));
};
const handleGroupChange = (value) => {
setFilters((prev) => ({
...prev,
group_name: value ? value.value : '',
}));
};
const handleM3UChange = (value) => {
setFilters((prev) => ({
...prev,
m3u_account: value ? value.value : '',
}));
};
const fetchData = useCallback(async () => {
setIsLoading(true);
const params = new URLSearchParams();
params.append('page', pagination.pageIndex + 1);
params.append('page_size', pagination.pageSize);
// Apply sorting
if (sorting.length > 0) {
const sortField = sorting[0].id;
const sortDirection = sorting[0].desc ? '-' : '';
params.append('ordering', `${sortDirection}${sortField}`);
}
// Apply debounced filters
Object.entries(debouncedFilters).forEach(([key, value]) => {
if (value) params.append(key, value);
});
try {
const result = await API.queryStreams(params);
setData(result.results);
setRowCount(result.count);
const newSelection = {};
result.results.forEach((item, index) => {
if (selectedStreamIds.includes(item.id)) {
newSelection[index] = true;
}
});
// ✅ Only update rowSelection if it's different
if (JSON.stringify(newSelection) !== JSON.stringify(rowSelection)) {
setRowSelection(newSelection);
}
} catch (error) {
console.error('Error fetching data:', error);
}
const groups = await API.getStreamGroups();
setGroupOptions(groups);
setIsLoading(false);
}, [pagination, sorting, debouncedFilters]);
useEffect(() => {
console.log(pagination);
}, [pagination]);
// Fallback: Individual creation (optional)
const createChannelFromStream = async (stream) => {
await API.createChannelFromStream({
channel_name: stream.name,
channel_number: null,
stream_id: stream.id,
});
};
// Bulk creation: create channels from selected streams in one API call
const createChannelsFromStreams = async () => {
setIsLoading(true);
await API.createChannelsFromStreams(
selectedStreamIds.map((stream_id) => ({
stream_id,
}))
);
setIsLoading(false);
};
const editStream = async (stream = null) => {
setStream(stream);
setModalOpen(true);
};
const deleteStream = async (id) => {
await API.deleteStream(id);
};
const deleteStreams = async () => {
setIsLoading(true);
await API.deleteStreams(selectedStreamIds);
setIsLoading(false);
};
const closeStreamForm = () => {
setStream(null);
setModalOpen(false);
};
const addStreamsToChannel = async () => {
const { streams, ...channel } = { ...channelsPageSelection[0] };
await API.updateChannel({
...channel,
stream_ids: [
...new Set(
channelSelectionStreams
.map((stream) => stream.id)
.concat(selectedStreamIds)
),
],
});
};
const addStreamToChannel = async (streamId) => {
const { streams, ...channel } = { ...channelsPageSelection[0] };
await API.updateChannel({
...channel,
stream_ids: [
...new Set(
channelSelectionStreams.map((stream) => stream.id).concat([streamId])
),
],
});
};
const handleMoreActionsClick = (event, rowId) => {
setMoreActionsAnchorEl(event.currentTarget);
setActionsOpenRow(rowId);
};
const handleMoreActionsClose = () => {
setMoreActionsAnchorEl(null);
setActionsOpenRow(null);
};
const onRowSelectionChange = (updater) => {
setRowSelection((prevRowSelection) => {
const newRowSelection =
typeof updater === 'function' ? updater(prevRowSelection) : updater;
const updatedSelected = new Set([...selectedStreamIds]);
table.getRowModel().rows.forEach((row) => {
if (newRowSelection[row.id] === undefined || !newRowSelection[row.id]) {
updatedSelected.delete(row.original.id);
} else {
updatedSelected.add(row.original.id);
}
});
setSelectedStreamIds([...updatedSelected]);
return newRowSelection;
});
};
const onSelectAllChange = async (e) => {
const selectAll = e.target.checked;
if (selectAll) {
// Get all stream IDs for current view
const params = new URLSearchParams();
Object.entries(debouncedFilters).forEach(([key, value]) => {
if (value) params.append(key, value);
});
const ids = await API.getAllStreamIds(params);
setSelectedStreamIds(ids);
} else {
setSelectedStreamIds([]);
}
const newSelection = {};
table.getRowModel().rows.forEach((item, index) => {
newSelection[index] = selectAll;
});
setRowSelection(newSelection);
};
const onPaginationChange = (updater) => {
const newPagination = updater(pagination);
if (JSON.stringify(newPagination) === JSON.stringify(pagination)) {
// Prevent infinite re-render when there are no results
return;
}
setPagination(updater);
};
const table = useMaterialReactTable({
...TableHelper.defaultProperties,
columns,
data,
enablePagination: true,
manualPagination: true,
enableTopToolbar: false, // completely removes MRT's built-in top toolbar
enableRowVirtualization: true,
renderTopToolbar: () => null, // Removes the entire top toolbar
renderToolbarInternalActions: () => null,
rowVirtualizerInstanceRef,
rowVirtualizerOptions: { overscan: 5 }, // optionally customize the row virtualizer
manualSorting: true,
enableBottomToolbar: true,
enableStickyHeader: true,
onPaginationChange: onPaginationChange,
onSortingChange: setSorting,
rowCount: rowCount,
enableRowSelection: true,
muiSelectAllCheckboxProps: {
checked: selectedStreamIds.length === rowCount && rowCount > 0,
indeterminate:
selectedStreamIds.length > 0 && selectedStreamIds.length !== rowCount,
onChange: onSelectAllChange,
},
onRowSelectionChange: onRowSelectionChange,
initialState: {
density: 'compact',
},
state: {
isLoading,
sorting,
pagination,
rowSelection,
},
enableRowActions: true,
positionActionsColumn: 'first',
enableHiding: false,
// you can still use the custom toolbar callback if you like
renderTopToolbarCustomActions: ({ table }) => {
const selectedRowCount = table.getSelectedRowModel().rows.length;
// optionally do something with selectedRowCount
},
renderRowActions: ({ row }) => (
<>
<Tooltip title="Add to Channel">
<IconButton
size="small"
color="info"
onClick={() => addStreamToChannel(row.original.id)}
sx={{ py: 0, px: 0.5 }}
disabled={
channelsPageSelection.length !== 1 ||
(channelSelectionStreams &&
channelSelectionStreams
.map((stream) => stream.id)
.includes(row.original.id))
}
>
<ListPlus size="18" fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Create New Channel">
<IconButton
size="small"
color="success"
onClick={() => createChannelFromStream(row.original)}
sx={{ py: 0, px: 0.5 }}
>
<SquarePlus size="18" fontSize="small" />
</IconButton>
</Tooltip>
<IconButton
onClick={(event) => handleMoreActionsClick(event, row.original.id)}
size="small"
sx={{ py: 0, px: 0.5 }}
>
<MoreVertIcon />
</IconButton>
<Menu
anchorEl={moreActionsAnchorEl}
open={isMoreActionsOpen && actionsOpenRow === row.original.id}
onClose={handleMoreActionsClose}
>
<MenuItem
onClick={() => editStream(row.original.id)}
disabled={row.original.m3u_account ? true : false}
>
Edit
</MenuItem>
<MenuItem onClick={() => deleteStream(row.original.id)}>
Delete Stream
</MenuItem>
</Menu>
</>
),
muiPaginationProps: {
size: 'small',
rowsPerPageOptions: [25, 50, 100, 250, 500, 1000, 10000],
labelRowsPerPage: 'Rows per page',
},
muiTableContainerProps: {
sx: {
height: 'calc(100vh - 145px)',
overflowY: 'auto',
},
},
displayColumnDefOptions: {
'mrt-row-actions': {
size: 68,
},
'mrt-row-select': {
size: 50,
},
},
});
/**
* useEffects
*/
useEffect(() => {
fetchData();
}, [fetchData]);
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]);
return (
<Box>
{/* Header Row */}
<Box sx={{ display: 'flex', alignItems: 'center', pb: 1 }}>
<Typography
sx={{
width: 88,
height: 24,
fontFamily: 'Inter, sans-serif',
fontWeight: 500,
fontSize: '20px',
lineHeight: 1,
letterSpacing: '-0.3px',
color: theme.palette.text.secondary,
mb: 0,
}}
>
Streams
</Typography>
</Box>
{/* Paper container with ghost state vs table */}
<Paper
sx={{
bgcolor: theme.palette.background.paper,
borderRadius: 2,
overflow: 'hidden',
height: 'calc(100vh - 75px)',
display: 'flex',
flexDirection: 'column',
}}
>
{/* Top toolbar: always visible */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
backgroundColor: theme.palette.background.paper,
justifyContent: 'flex-end',
p: 1,
gap: 1,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Tooltip title="Remove">
<Button
variant="outlined"
size="small"
startIcon={
<IndeterminateCheckBox
sx={{ fontSize: 16, color: theme.palette.text.secondary }}
/>
}
sx={{
borderColor: theme.palette.custom.borderDefault,
borderRadius: '4px',
borderWidth: '1px',
borderStyle: 'solid',
height: '25px',
opacity: 0.4,
color: theme.palette.text.secondary,
fontSize: '0.85rem',
px: 1,
py: 0.5,
'&:hover': {
borderColor: theme.palette.custom.borderHover,
},
}}
>
Remove
</Button>
</Tooltip>
<Tooltip title="Add to Channel">
<span>
<Button
variant="outlined"
size="small"
startIcon={
<AddBox
sx={{ fontSize: 16, color: theme.palette.text.secondary }}
/>
}
disabled={
channelsPageSelection.length !== 1 ||
!eligibleSelectedStreamId
}
onClick={() => {
if (eligibleSelectedStreamId) {
addStreamToChannel(eligibleSelectedStreamId);
}
}}
sx={{
minWidth: '57px',
height: '25px',
borderRadius: '4px',
borderColor: theme.palette.custom.successBorder,
borderWidth: '1px',
borderStyle: 'solid',
backgroundColor: theme.palette.custom.successBg,
color: '#fff',
fontSize: '0.85rem',
px: 1,
py: 0.5,
'&:hover': {
backgroundColor: theme.palette.custom.successBgHover,
},
}}
>
Add to Channel
</Button>
</span>
</Tooltip>
<Tooltip title="Create Channels">
<Button
variant="outlined"
size="small"
startIcon={
<AddBox
sx={{ fontSize: 16, color: theme.palette.text.secondary }}
/>
}
disabled={selectedStreamIds.length === 0}
sx={{
minWidth: '57px',
height: '25px',
borderRadius: '4px',
borderColor: theme.palette.custom.successBorder,
borderWidth: '1px',
borderStyle: 'solid',
backgroundColor: theme.palette.custom.successBg,
color: '#fff',
fontSize: '0.85rem',
px: 1,
py: 0.5,
'&:hover': {
backgroundColor: theme.palette.custom.successBgHover,
},
}}
onClick={() => createChannelsFromStreams()}
>
Create Channels
</Button>
</Tooltip>
<Tooltip title="Add Channel">
<Button
variant="contained"
size="small"
onClick={() => editStream()}
startIcon={
<AddBox
sx={{
fontSize: 16,
color: theme.palette.custom.successIcon,
}}
/>
}
sx={{
minWidth: '57px',
height: '25px',
borderRadius: '4px',
borderColor: theme.palette.custom.successBorder,
borderWidth: '1px',
borderStyle: 'solid',
backgroundColor: theme.palette.custom.successBg,
color: '#fff',
fontSize: '0.85rem',
px: 1,
py: 0.5,
'&:hover': {
backgroundColor: theme.palette.custom.successBgHover,
},
}}
>
Add
</Button>
</Tooltip>
{/* Show/Hide Columns Button added to top bar */}
<Tooltip title="Show/Hide Columns">
<MRT_ShowHideColumnsButton table={table} />
</Tooltip>
</Box>
</Box>
{/* Main content */}
<Box
sx={{
flex: 1,
position: 'relative',
bgcolor: theme.palette.background.paper,
}}
>
<StreamForm stream={stream} isOpen={modalOpen} onClose={closeStreamForm} />
{hasData ? (
<Box sx={{ width: "100%", height: "calc(102vh - 150px)", overflow: "auto" }}>
<MaterialReactTable
table={table}
sx={{ height: "100%" }}
/>
</Box>
) : (
// Ghost state placeholder, shown when there is no data
<Box
sx={{
position: 'absolute',
top: '25%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: '420px',
height: '247px',
border: '1px solid #52525C',
borderRadius: '8px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
p: 2,
textAlign: 'center',
}}
>
<Typography
sx={{
fontWeight: 400,
fontSize: '20px',
lineHeight: '28px',
letterSpacing: '-0.3px',
color: '#D4D4D8',
mb: 1,
}}
>
Getting started
</Typography>
<Typography
sx={{
fontWeight: 400,
fontSize: '16px',
lineHeight: '24px',
letterSpacing: '-0.2px',
color: '#9FA3A9',
width: '372px',
mb: 2,
}}
>
In order to get started, add your M3U or start adding custom streams.
</Typography>
<Button
variant="contained"
sx={{
minWidth: '127px',
height: '25px',
borderRadius: '4px',
borderWidth: '1px',
borderStyle: 'solid',
color: theme.palette.text.secondary,
borderColor: theme.palette.custom.borderHover,
backgroundColor: '#1f1f23',
fontFamily: 'Inter, sans-serif',
fontWeight: 400,
fontSize: '0.85rem',
letterSpacing: '-0.2px',
textTransform: 'none',
px: 1,
py: 0.5,
'&:hover': {
borderColor: theme.palette.custom.borderDefault,
backgroundColor: '#17171B',
},
}}
>
Add M3U
</Button>
<Typography sx={{ fontSize: '14px', color: '#71717B', mb: 1 }}>
or
</Typography>
<Button
variant="contained"
sx={{
minWidth: '127px',
height: '25px',
borderRadius: '4px',
borderWidth: '1px',
borderStyle: 'solid',
color: theme.palette.text.secondary,
borderColor: theme.palette.custom.borderHover,
backgroundColor: '#1f1f23',
fontFamily: 'Inter, sans-serif',
fontWeight: 400,
fontSize: '0.85rem',
letterSpacing: '-0.2px',
textTransform: 'none',
px: 1,
py: 0.5,
'&:hover': {
borderColor: theme.palette.custom.borderDefault,
backgroundColor: '#17171B',
},
}}
>
Add Individual Stream
</Button>
</Box>
)}
</Box>
</Paper>
</Box>
);
};
export default StreamsTable;

View file

@ -0,0 +1,746 @@
import { useEffect, useMemo, useCallback, useState, useRef } from 'react';
import { MantineReactTable, useMantineReactTable } from 'mantine-react-table';
import API from '../../api';
import { useTheme } from '@mui/material/styles';
import {
Delete as DeleteIcon,
Edit as EditIcon,
Add as AddIcon,
MoreVert as MoreVertIcon,
PlaylistAdd as PlaylistAddIcon,
IndeterminateCheckBox,
AddBox,
} from '@mui/icons-material';
import { TableHelper } from '../../helpers';
import StreamForm from '../forms/Stream';
import usePlaylistsStore from '../../store/playlists';
import useChannelsStore from '../../store/channels';
import { useDebounce } from '../../utils';
import { SquarePlus, ListPlus, SquareMinus } from 'lucide-react';
import {
TextInput,
ActionIcon,
Select,
Tooltip,
Menu,
Flex,
Box,
Text,
Paper,
Button,
Card,
Stack,
Title,
Divider,
Center,
Pagination,
Group,
NumberInput,
NativeSelect,
} from '@mantine/core';
import {
IconArrowDown,
IconArrowUp,
IconDeviceDesktopSearch,
IconSelector,
IconSortAscendingNumbers,
IconSquarePlus,
} from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
const StreamsTable = ({}) => {
const theme = useTheme();
/**
* useState
*/
const [rowSelection, setRowSelection] = useState([]);
const [stream, setStream] = useState(null);
const [modalOpen, setModalOpen] = useState(false);
const [moreActionsAnchorEl, setMoreActionsAnchorEl] = useState(null);
const [groupOptions, setGroupOptions] = useState([]);
const [m3uOptions, setM3uOptions] = useState([]);
const [actionsOpenRow, setActionsOpenRow] = useState(null);
const [initialDataCount, setInitialDataCount] = useState(null);
const [data, setData] = useState([]); // Holds fetched data
const [rowCount, setRowCount] = useState(0);
const [pageCount, setPageCount] = useState(0);
const [paginationString, setPaginationString] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [sorting, setSorting] = useState([]);
const [selectedStreamIds, setSelectedStreamIds] = useState([]);
const [unselectedStreamIds, setUnselectedStreamIds] = useState([]);
// const [allRowsSelected, setAllRowsSelected] = useState(false);
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: 250,
});
const [filters, setFilters] = useState({
name: '',
group_name: '',
m3u_account: '',
});
const debouncedFilters = useDebounce(filters, 500);
const hasData = data.length > 0;
const navigate = useNavigate();
/**
* Stores
*/
const { playlists } = usePlaylistsStore();
const { channelsPageSelection } = useChannelsStore();
const channelSelectionStreams = useChannelsStore(
(state) => state.channels[state.channelsPageSelection[0]?.id]?.streams
);
const isMoreActionsOpen = Boolean(moreActionsAnchorEl);
// Access the row virtualizer instance (optional)
const rowVirtualizerInstanceRef = useRef(null);
const handleSelectClick = (e) => {
e.stopPropagation();
e.preventDefault();
};
/**
* useMemo
*/
const columns = useMemo(
() => [
{
header: 'Name',
accessorKey: 'name',
mantineTableHeadCellProps: {
style: { textAlign: 'center' }, // Center-align the header
},
Header: ({ column }) => (
<TextInput
name="name"
placeholder="Name"
value={filters.name || ''}
onClick={(e) => e.stopPropagation()}
onChange={handleFilterChange}
size="xs"
/>
),
Cell: ({ cell }) => (
<div
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{cell.getValue()}
</div>
),
},
{
header: 'Group',
accessorKey: 'group_name',
size: 100,
Header: ({ column }) => (
<Box onClick={handleSelectClick}>
<Select
placeholder="Group"
searchable
size="xs"
nothingFound="No options"
onClick={handleSelectClick}
onChange={handleGroupChange}
data={groupOptions}
/>
</Box>
),
Cell: ({ cell }) => (
<div
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{cell.getValue()}
</div>
),
},
{
header: 'M3U',
size: 75,
accessorFn: (row) =>
playlists.find((playlist) => playlist.id === row.m3u_account)?.name,
Header: ({ column }) => (
<Box onClick={handleSelectClick}>
<Select
placeholder="M3U"
searchable
size="xs"
nothingFound="No options"
onClick={handleSelectClick}
onChange={handleM3UChange}
data={playlists.map((playlist) => ({
label: playlist.name,
value: `${playlist.id}`,
}))}
/>
</Box>
),
},
],
[playlists, groupOptions, filters]
);
/**
* Functions
*/
const handleFilterChange = (e) => {
const { name, value } = e.target;
setFilters((prev) => ({
...prev,
[name]: value,
}));
};
const handleGroupChange = (value) => {
setFilters((prev) => ({
...prev,
group_name: value ? value : '',
}));
};
const handleM3UChange = (value) => {
setFilters((prev) => ({
...prev,
m3u_account: value ? value : '',
}));
};
const fetchData = useCallback(async () => {
setIsLoading(true);
const params = new URLSearchParams();
params.append('page', pagination.pageIndex + 1);
params.append('page_size', pagination.pageSize);
// Apply sorting
if (sorting.length > 0) {
const sortField = sorting[0].id;
const sortDirection = sorting[0].desc ? '-' : '';
params.append('ordering', `${sortDirection}${sortField}`);
}
// Apply debounced filters
Object.entries(debouncedFilters).forEach(([key, value]) => {
if (value) params.append(key, value);
});
try {
const result = await API.queryStreams(params);
setData(result.results);
setRowCount(result.count);
setPageCount(Math.ceil(result.count / pagination.pageSize));
// Calculate the starting and ending item indexes
const startItem = pagination.pageIndex * pagination.pageSize + 1; // +1 to start from 1, not 0
const endItem = Math.min(
(pagination.pageIndex + 1) * pagination.pageSize,
result.count
);
if (initialDataCount === null) {
setInitialDataCount(result.count);
}
// Generate the string
setPaginationString(`${startItem} to ${endItem} of ${result.count}`);
const newSelection = {};
result.results.forEach((item, index) => {
if (selectedStreamIds.includes(item.id)) {
newSelection[index] = true;
}
});
// Only update rowSelection if it's different
if (JSON.stringify(newSelection) !== JSON.stringify(rowSelection)) {
setRowSelection(newSelection);
}
} catch (error) {
console.error('Error fetching data:', error);
}
const groups = await API.getStreamGroups();
setGroupOptions(groups);
setIsLoading(false);
}, [pagination, sorting, debouncedFilters]);
// Fallback: Individual creation (optional)
const createChannelFromStream = async (stream) => {
await API.createChannelFromStream({
channel_name: stream.name,
channel_number: null,
stream_id: stream.id,
});
};
// Bulk creation: create channels from selected streams in one API call
const createChannelsFromStreams = async () => {
setIsLoading(true);
await API.createChannelsFromStreams(
selectedStreamIds.map((stream_id) => ({
stream_id,
}))
);
setIsLoading(false);
};
const editStream = async (stream = null) => {
setStream(stream);
setModalOpen(true);
};
const deleteStream = async (id) => {
await API.deleteStream(id);
};
const deleteStreams = async () => {
setIsLoading(true);
await API.deleteStreams(selectedStreamIds);
setIsLoading(false);
};
const closeStreamForm = () => {
setStream(null);
setModalOpen(false);
};
const addStreamsToChannel = async () => {
const { streams, ...channel } = { ...channelsPageSelection[0] };
await API.updateChannel({
...channel,
stream_ids: [
...new Set(
channelSelectionStreams
.map((stream) => stream.id)
.concat(selectedStreamIds)
),
],
});
};
const addStreamToChannel = async (streamId) => {
const { streams, ...channel } = { ...channelsPageSelection[0] };
await API.updateChannel({
...channel,
stream_ids: [
...new Set(
channelSelectionStreams.map((stream) => stream.id).concat([streamId])
),
],
});
};
const handleMoreActionsClick = (event, rowId) => {
setMoreActionsAnchorEl(event.currentTarget);
setActionsOpenRow(rowId);
};
const handleMoreActionsClose = () => {
setMoreActionsAnchorEl(null);
setActionsOpenRow(null);
};
const onRowSelectionChange = (updater) => {
setRowSelection((prevRowSelection) => {
const newRowSelection =
typeof updater === 'function' ? updater(prevRowSelection) : updater;
const updatedSelected = new Set([...selectedStreamIds]);
table.getRowModel().rows.forEach((row) => {
if (newRowSelection[row.id] === undefined || !newRowSelection[row.id]) {
updatedSelected.delete(row.original.id);
} else {
updatedSelected.add(row.original.id);
}
});
setSelectedStreamIds([...updatedSelected]);
return newRowSelection;
});
};
const onSelectAllChange = async (e) => {
const selectAll = e.target.checked;
if (selectAll) {
// Get all stream IDs for current view
const params = new URLSearchParams();
Object.entries(debouncedFilters).forEach(([key, value]) => {
if (value) params.append(key, value);
});
const ids = await API.getAllStreamIds(params);
setSelectedStreamIds(ids);
} else {
setSelectedStreamIds([]);
}
const newSelection = {};
table.getRowModel().rows.forEach((item, index) => {
newSelection[index] = selectAll;
});
setRowSelection(newSelection);
};
const onPageSizeChange = (e) => {
setPagination({
...pagination,
pageSize: e.target.value,
});
};
const onPageIndexChange = (pageIndex) => {
if (!pageIndex || pageIndex > pageCount) {
return;
}
setPagination({
...pagination,
pageIndex: pageIndex - 1,
});
};
const onPaginationChange = (updater) => {
const newPagination = updater(pagination);
if (JSON.stringify(newPagination) === JSON.stringify(pagination)) {
// Prevent infinite re-render when there are no results
return;
}
setPagination(updater);
};
const table = useMantineReactTable({
...TableHelper.defaultProperties,
columns,
data,
enablePagination: true,
manualPagination: true,
enableTopToolbar: false,
enableRowVirtualization: true,
renderTopToolbar: () => null, // Removes the entire top toolbar
renderToolbarInternalActions: () => null,
rowVirtualizerInstanceRef,
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
enableBottomToolbar: true,
renderBottomToolbar: ({ table }) => (
<Group
gap={5}
justify="center"
style={{ padding: 8, borderTop: '1px solid #666' }}
>
<Text size="xs">Page Size</Text>
<NativeSelect
size="xxs"
value={pagination.pageSize}
data={['25', '50', '100', '250', '500', '1000']}
onChange={onPageSizeChange}
style={{ paddingRight: 20 }}
/>
<Pagination
total={pageCount}
value={pagination.pageIndex + 1}
onChange={onPageIndexChange}
size="xs"
withEdges
style={{ paddingRight: 20 }}
/>
<Text size="xs">{paginationString}</Text>
</Group>
),
enableStickyHeader: true,
// onPaginationChange: onPaginationChange,
rowCount: rowCount,
enableRowSelection: true,
mantineSelectAllCheckboxProps: {
checked: selectedStreamIds.length == rowCount,
indeterminate:
selectedStreamIds.length > 0 && selectedStreamIds.length !== rowCount,
onChange: onSelectAllChange,
size: 'xs',
},
muiPaginationProps: {
size: 'small',
rowsPerPageOptions: [25, 50, 100, 250, 500, 1000, 10000],
labelRowsPerPage: 'Rows per page',
},
onSortingChange: setSorting,
onRowSelectionChange: onRowSelectionChange,
initialState: {
density: 'compact',
},
state: {
isLoading,
sorting,
// pagination,
rowSelection,
},
enableRowActions: true,
positionActionsColumn: 'first',
enableHiding: false,
// you can still use the custom toolbar callback if you like
renderTopToolbarCustomActions: ({ table }) => {
const selectedRowCount = table.getSelectedRowModel().rows.length;
// optionally do something with selectedRowCount
},
renderRowActions: ({ row }) => (
<>
<Tooltip label="Add to Channel">
<ActionIcon
size="sm"
color="blue.5"
variant="transparent"
onClick={() => addStreamToChannel(row.original.id)}
disabled={
channelsPageSelection.length !== 1 ||
(channelSelectionStreams &&
channelSelectionStreams
.map((stream) => stream.id)
.includes(row.original.id))
}
>
<ListPlus size="18" fontSize="small" />
</ActionIcon>
</Tooltip>
<Tooltip label="Create New Channel">
<ActionIcon
size="sm"
color="green.5"
variant="transparent"
onClick={() => createChannelFromStream(row.original)}
>
<SquarePlus size="18" fontSize="small" />
</ActionIcon>
</Tooltip>
<Menu>
<Menu.Target>
<ActionIcon
onClick={(event) =>
handleMoreActionsClick(event, row.original.id)
}
variant="transparent"
size="sm"
>
<MoreVertIcon />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
onClick={() => editStream(row.original)}
disabled={row.original.m3u_account ? true : false}
>
Edit
</Menu.Item>
<Menu.Item
onClick={() => deleteStream(row.original.id)}
disabled={row.original.m3u_account ? true : false}
>
Delete Stream
</Menu.Item>
</Menu.Dropdown>
</Menu>
</>
),
mantineTableContainerProps: {
style: {
height: 'calc(100vh - 167px)',
overflowY: 'auto',
},
},
displayColumnDefOptions: {
'mrt-row-actions': {
size: 30,
},
'mrt-row-select': {
size: 20,
},
},
});
/**
* useEffects
*/
useEffect(() => {
fetchData();
}, [fetchData]);
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]);
return (
<>
<Flex
style={{ display: 'flex', alignItems: 'center', paddingBottom: 12 }}
gap={15}
>
<Text
w={88}
h={24}
style={{
fontFamily: 'Inter, sans-serif',
fontWeight: 500,
fontSize: '20px',
lineHeight: 1,
letterSpacing: '-0.3px',
// color: 'gray.6', // Adjust this to match MUI's theme.palette.text.secondary
marginBottom: 0,
}}
>
Streams
</Text>
</Flex>
<Paper
style={{
// bgcolor: theme.palette.background.paper,
// borderRadius: 2,
// overflow: 'hidden',
height: 'calc(100vh - 75px)',
// display: 'flex',
// flexDirection: 'column',
}}
>
{/* Top toolbar with Remove, Assign, Auto-match, and Add buttons */}
<Box
style={{
display: 'flex',
// alignItems: 'center',
// backgroundColor: theme.palette.background.paper,
justifyContent: 'flex-end',
padding: 10,
// gap: 1,
}}
>
<Flex gap={6}>
<Button
leftSection={<SquareMinus size={18} />}
variant="default"
size="xs"
onClick={deleteStreams}
>
Remove
</Button>
<Button
leftSection={<IconSquarePlus size={18} />}
variant="default"
size="xs"
onClick={createChannelsFromStreams}
p={5}
>
Create Channels
</Button>
<Button
leftSection={<IconSquarePlus size={18} />}
variant="light"
size="xs"
onClick={() => editStream()}
p={5}
color="green"
style={{
borderWidth: '1px',
borderColor: 'green',
color: 'white',
}}
>
Add Stream
</Button>
</Flex>
</Box>
{initialDataCount === 0 && (
<Center style={{ paddingTop: 20 }}>
<Card
shadow="sm"
padding="lg"
radius="md"
withBorder
style={{
backgroundColor: '#222',
borderColor: '#444',
textAlign: 'center',
width: '400px',
}}
>
<Stack align="center">
<Title order={3} style={{ color: '#d4d4d8' }}>
Getting started
</Title>
<Text size="sm" color="dimmed">
In order to get started, add your M3U or start <br />
adding custom streams.
</Text>
<Button
variant="default"
radius="md"
size="md"
onClick={() => navigate('/m3u')}
style={{
backgroundColor: '#444',
color: '#d4d4d8',
border: '1px solid #666',
}}
>
Add M3U
</Button>
<Divider label="or" labelPosition="center" color="gray" />
<Button
variant="default"
radius="md"
size="md"
onClick={() => editStream()}
style={{
backgroundColor: '#333',
color: '#d4d4d8',
border: '1px solid #666',
}}
>
Add Individual Stream
</Button>
</Stack>
</Card>
</Center>
)}
{initialDataCount > 0 && <MantineReactTable table={table} />}
</Paper>
<StreamForm
stream={stream}
isOpen={modalOpen}
onClose={closeStreamForm}
/>
</>
);
};
export default StreamsTable;

View file

@ -1,238 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
MaterialReactTable,
MRT_ShowHideColumnsButton,
MRT_ToggleFullScreenButton,
useMaterialReactTable,
} from 'material-react-table';
import {
Box,
Grid2,
Stack,
Typography,
IconButton,
Tooltip,
Select,
MenuItem,
} from '@mui/material';
import API from '../../api';
import {
Delete as DeleteIcon,
Edit as EditIcon,
Add as AddIcon,
Check as CheckIcon,
Close as CloseIcon,
} from '@mui/icons-material';
import useUserAgentsStore from '../../store/userAgents';
import UserAgentForm from '../forms/UserAgent';
import { TableHelper } from '../../helpers';
import useSettingsStore from '../../store/settings';
import useAlertStore from '../../store/alerts';
const UserAgentsTable = () => {
const [userAgent, setUserAgent] = useState(null);
const [userAgentModalOpen, setUserAgentModalOpen] = useState(false);
const [rowSelection, setRowSelection] = useState([]);
const [activeFilterValue, setActiveFilterValue] = useState('all');
const userAgents = useUserAgentsStore((state) => state.userAgents);
const { settings } = useSettingsStore();
const { showAlert } = useAlertStore();
const columns = useMemo(
//column definitions...
() => [
{
header: 'Name',
accessorKey: 'user_agent_name',
},
{
header: 'User-Agent',
accessorKey: 'user_agent',
},
{
header: 'Desecription',
accessorKey: 'description',
},
{
header: 'Active',
accessorKey: 'is_active',
size: 100,
sortingFn: 'basic',
muiTableBodyCellProps: {
align: 'left',
},
Cell: ({ cell }) => (
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
{cell.getValue() ? (
<CheckIcon color="success" />
) : (
<CloseIcon color="error" />
)}
</Box>
),
Filter: ({ column }) => (
<Box>
<Select
size="small"
variant="standard"
value={activeFilterValue}
onChange={(e) => {
setActiveFilterValue(e.target.value);
column.setFilterValue(e.target.value);
}}
displayEmpty
fullWidth
>
<MenuItem value="all">All</MenuItem>
<MenuItem value="true">Active</MenuItem>
<MenuItem value="false">Inactive</MenuItem>
</Select>
</Box>
),
filterFn: (row, _columnId, activeFilterValue) => {
if (activeFilterValue == 'all') return true; // Show all if no filter
return String(row.getValue('is_active')) === activeFilterValue;
},
},
],
[]
);
//optionally access the underlying virtualizer instance
const rowVirtualizerInstanceRef = useRef(null);
const [isLoading, setIsLoading] = useState(true);
const [sorting, setSorting] = useState([]);
const editUserAgent = async (userAgent = null) => {
setUserAgent(userAgent);
setUserAgentModalOpen(true);
};
const deleteUserAgent = async (ids) => {
if (Array.isArray(ids)) {
if (ids.includes(settings['default-user-agent'].value)) {
showAlert('Cannot delete default user-agent', 'error');
return;
}
await API.deleteUserAgents(ids);
} else {
if (ids == settings['default-user-agent'].value) {
showAlert('Cannot delete default user-agent', 'error');
return;
}
await API.deleteUserAgent(ids);
}
};
const closeUserAgentForm = () => {
setUserAgent(null);
setUserAgentModalOpen(false);
};
useEffect(() => {
if (typeof window !== 'undefined') {
setIsLoading(false);
}
}, []);
useEffect(() => {
//scroll to the top of the table when the sorting changes
try {
rowVirtualizerInstanceRef.current?.scrollToIndex?.(0);
} catch (error) {
console.error(error);
}
}, [sorting]);
const table = useMaterialReactTable({
...TableHelper.defaultProperties,
columns,
data: userAgents,
enablePagination: false,
enableRowVirtualization: true,
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
state: {
isLoading,
sorting,
rowSelection,
},
rowVirtualizerInstanceRef, //optional
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
initialState: {
density: 'compact',
},
enableRowActions: true,
renderRowActions: ({ row }) => (
<>
<IconButton
size="small" // Makes the button smaller
color="warning" // Red color for delete actions
onClick={() => {
editUserAgent(row.original);
}}
sx={{ pt: 0, pb: 0 }}
>
<EditIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
<IconButton
size="small" // Makes the button smaller
color="error" // Red color for delete actions
onClick={() => deleteUserAgent(row.original.id)}
sx={{ pt: 0, pb: 0 }}
>
<DeleteIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
</>
),
muiTableContainerProps: {
sx: {
height: 'calc(42vh + 5px)',
},
},
renderTopToolbarCustomActions: ({ table }) => (
<Stack
direction="row"
sx={{
alignItems: 'center',
}}
>
<Typography>User-Agents</Typography>
<Tooltip title="Add New User Agent">
<IconButton
size="small" // Makes the button smaller
color="success" // Red color for delete actions
variant="contained"
onClick={() => editUserAgent()}
>
<AddIcon fontSize="small" /> {/* Small icon size */}
</IconButton>
</Tooltip>
</Stack>
),
});
return (
<>
<Box
sx={{
padding: 1,
}}
>
<MaterialReactTable table={table} />
</Box>
<UserAgentForm
userAgent={userAgent}
isOpen={userAgentModalOpen}
onClose={closeUserAgentForm}
/>
</>
);
};
export default UserAgentsTable;

View file

@ -0,0 +1,321 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { MantineReactTable, useMantineReactTable } from 'mantine-react-table';
import API from '../../api';
import {
Delete as DeleteIcon,
Edit as EditIcon,
Add as AddIcon,
Check as CheckIcon,
Close as CloseIcon,
} from '@mui/icons-material';
import useUserAgentsStore from '../../store/userAgents';
import UserAgentForm from '../forms/UserAgent';
import { TableHelper } from '../../helpers';
import useSettingsStore from '../../store/settings';
import { notifications } from '@mantine/notifications';
import {
ActionIcon,
Center,
Flex,
Select,
Tooltip,
Text,
Paper,
Box,
Button,
} from '@mantine/core';
import {
IconArrowDown,
IconArrowUp,
IconDeviceDesktopSearch,
IconSelector,
IconSortAscendingNumbers,
IconSquarePlus,
} from '@tabler/icons-react';
import { SquareMinus, SquarePen } from 'lucide-react';
const UserAgentsTable = () => {
const [userAgent, setUserAgent] = useState(null);
const [userAgentModalOpen, setUserAgentModalOpen] = useState(false);
const [rowSelection, setRowSelection] = useState([]);
const [activeFilterValue, setActiveFilterValue] = useState('all');
const userAgents = useUserAgentsStore((state) => state.userAgents);
const { settings } = useSettingsStore();
const columns = useMemo(
//column definitions...
() => [
{
header: 'Name',
accessorKey: 'user_agent_name',
},
{
header: 'User-Agent',
accessorKey: 'user_agent',
Cell: ({ cell }) => (
<div
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{cell.getValue()}
</div>
),
},
{
header: 'Desecription',
accessorKey: 'description',
Cell: ({ cell }) => (
<div
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{cell.getValue()}
</div>
),
},
{
header: 'Active',
accessorKey: 'is_active',
size: 100,
sortingFn: 'basic',
mantineTableBodyCellProps: {
align: 'left',
},
Cell: ({ cell }) => (
<Center>
{cell.getValue() ? (
<CheckIcon color="success" />
) : (
<CloseIcon color="error" />
)}
</Center>
),
Filter: ({ column }) => (
<Select
size="small"
value={activeFilterValue}
onChange={(e) => {
setActiveFilterValue(e.target.value);
column.setFilterValue(e.target.value);
}}
displayEmpty
data={[
{
value: 'all',
label: 'All',
},
{
value: 'active',
label: 'Active',
},
{
value: 'inactive',
label: 'Inactive',
},
]}
/>
),
filterFn: (row, _columnId, activeFilterValue) => {
if (activeFilterValue == 'all') return true; // Show all if no filter
return String(row.getValue('is_active')) === activeFilterValue;
},
},
],
[]
);
//optionally access the underlying virtualizer instance
const rowVirtualizerInstanceRef = useRef(null);
const [isLoading, setIsLoading] = useState(true);
const [sorting, setSorting] = useState([]);
const editUserAgent = async (userAgent = null) => {
setUserAgent(userAgent);
setUserAgentModalOpen(true);
};
const deleteUserAgent = async (ids) => {
if (Array.isArray(ids)) {
if (ids.includes(settings['default-user-agent'].value)) {
notifications.show({
title: 'Cannot delete default user-agent',
color: 'red.5',
});
return;
}
await API.deleteUserAgents(ids);
} else {
if (ids == settings['default-user-agent'].value) {
notifications.show({
title: 'Cannot delete default user-agent',
color: 'red.5',
});
return;
}
await API.deleteUserAgent(ids);
}
};
const closeUserAgentForm = () => {
setUserAgent(null);
setUserAgentModalOpen(false);
};
useEffect(() => {
if (typeof window !== 'undefined') {
setIsLoading(false);
}
}, []);
useEffect(() => {
//scroll to the top of the table when the sorting changes
try {
rowVirtualizerInstanceRef.current?.scrollToIndex?.(0);
} catch (error) {
console.error(error);
}
}, [sorting]);
const table = useMantineReactTable({
...TableHelper.defaultProperties,
columns,
data: userAgents,
enablePagination: false,
enableRowVirtualization: true,
// enableRowSelection: true,
renderTopToolbar: false,
// onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
state: {
isLoading,
sorting,
// rowSelection,
},
rowVirtualizerInstanceRef, //optional
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
initialState: {
density: 'compact',
},
enableRowActions: true,
renderRowActions: ({ row }) => (
<>
<ActionIcon
variant="transparent"
size="sm" // Makes the button smaller
color="yellow.5" // Red color for delete actions
onClick={() => {
editUserAgent(row.original);
}}
>
<SquarePen size="18" /> {/* Small icon size */}
</ActionIcon>
<ActionIcon
variant="transparent"
size="sm"
color="red.9" // Red color for delete actions
onClick={() => deleteUserAgent(row.original.id)}
>
<SquareMinus size="18" /> {/* Small icon size */}
</ActionIcon>
</>
),
mantineTableContainerProps: {
style: {
height: 'calc(43vh - 55px)',
},
},
});
return (
<>
<Flex
style={{
display: 'flex',
alignItems: 'center',
paddingTop: 10,
paddingBottom: 10,
}}
gap={15}
>
<Text
h={24}
style={{
fontFamily: 'Inter, sans-serif',
fontWeight: 500,
fontSize: '20px',
lineHeight: 1,
letterSpacing: '-0.3px',
color: 'gray.6', // Adjust this to match MUI's theme.palette.text.secondary
marginBottom: 0,
}}
>
User-Agents
</Text>
</Flex>
<Paper
style={
{
// bgcolor: theme.palette.background.paper,
// borderRadius: 2,
// overflow: 'hidden',
// height: 'calc(100vh - 75px)',
// display: 'flex',
// flexDirection: 'column',
}
}
>
{/* Top toolbar with Remove, Assign, Auto-match, and Add buttons */}
<Box
style={{
display: 'flex',
// alignItems: 'center',
// backgroundColor: theme.palette.background.paper,
justifyContent: 'flex-end',
padding: 10,
// gap: 1,
}}
>
<Flex gap={6}>
<Tooltip label="Assign">
<Button
leftSection={<IconSquarePlus size={18} />}
variant="light"
size="xs"
onClick={() => editUserAgent()}
p={5}
color="green"
style={{
borderWidth: '1px',
borderColor: 'green',
color: 'white',
}}
>
Add User-Agent
</Button>
</Tooltip>
</Flex>
</Box>
</Paper>
<MantineReactTable table={table} />
<UserAgentForm
userAgent={userAgent}
isOpen={userAgentModalOpen}
onClose={closeUserAgentForm}
/>
</>
);
};
export default UserAgentsTable;