, route: '/channels' },
- { text: 'M3U', icon:
, route: '/m3u' },
- { text: 'EPG', icon:
, route: '/epg' },
- {
- text: 'Stream Profiles',
- icon:
,
- route: '/stream-profiles',
- },
- { text: 'TV Guide', icon:
, route: '/guide' },
- { text: 'Settings', icon:
, route: '/settings' },
-];
-
-const Sidebar = ({ open, miniDrawerWidth, drawerWidth, toggleDrawer }) => {
- const location = useLocation();
- const { isAuthenticated, logout } = useAuthStore();
- const {
- environment: { public_ip, country_code, country_name },
- } = useSettingsStore();
- const navigate = useNavigate();
-
- const onLogout = () => {
- logout();
- navigate('/login');
- };
-
- return (
-
-
-
-
-
-
- {open && (
-
- )}
-
-
-
-
-
-
- {items.map((item) => (
-
-
- {item.icon}
- {open && }
-
-
- ))}
-
-
-
- {isAuthenticated && (
-
-
-
-
-
-
-
-
-
-
-
- {open && (
-
- {/* Public IP + optional flag */}
-
-
- {/* If we have a country code, show a small flag */}
- {country_code && (
-
- )}
-
-
- )}
-
- )}
-
- );
-};
-
-export default Sidebar;
diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx
new file mode 100644
index 00000000..34e830dd
--- /dev/null
+++ b/frontend/src/components/Sidebar.jsx
@@ -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 (
+
+ {item.icon}
+ {!collapsed && (
+
+ {item.label}
+
+ )}
+ {!collapsed && item.badge && (
+
+ {item.badge}
+
+ )}
+
+ );
+};
+
+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:
,
+ path: '/channels',
+ badge: `(${Object.keys(channels).length})`,
+ },
+ { label: 'M3U', icon:
, path: '/m3u' },
+ { label: 'EPG', icon:
, path: '/epg' },
+ {
+ label: 'Stream Profiles',
+ icon:
,
+ path: '/stream-profiles',
+ },
+ { label: 'TV Guide', icon:
, path: '/guide' },
+ {
+ label: 'Settings',
+ icon:
,
+ 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 (
+
+ {/* Brand - Click to Toggle */}
+
+ {/* */}
+
+ {!collapsed && (
+
+ Dispatcharr
+
+ )}
+
+
+ {/* Navigation Links */}
+
+ {navItems.map((item) => {
+ const isActive = location.pathname === item.path;
+
+ return (
+
+ );
+ })}
+
+
+ {/* Profile Section */}
+
+
+ {!collapsed && (
+
+ )
+ }
+ rightSection={
+
+
+
+ }
+ />
+ )}
+
+
+ {!collapsed && (
+
+
+ John Doe
+
+
+ •••
+
+
+ )}
+
+
+
+ );
+};
+
+export default Sidebar;
diff --git a/frontend/src/components/forms/Channel.js b/frontend/src/components/forms/Channel.js
deleted file mode 100644
index 2b5d84e2..00000000
--- a/frontend/src/components/forms/Channel.js
+++ /dev/null
@@ -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 }) => (
- <>
-
removeStream(row.original)}
- >
- {/* Small icon size */}
-
- >
- ),
- 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 }) => (
- <>
-
addStream(row.original)}
- >
- {/* Small icon size */}
-
- >
- ),
- positionActionsColumn: 'last',
- muiTableContainerProps: {
- sx: {
- height: '200px',
- },
- },
- });
-
- if (!isOpen) {
- return <>>;
- }
-
- return (
- <>
-
-
setChannelGroupModalOpen(false)}
- />
- >
- );
-};
-
-export default Channel;
diff --git a/frontend/src/components/forms/Channel.jsx b/frontend/src/components/forms/Channel.jsx
new file mode 100644
index 00000000..28a0fab1
--- /dev/null
+++ b/frontend/src/components/forms/Channel.jsx
@@ -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 }) => (
+ //
+ // {cell.getValue()}
+ //
+ // ),
+ // },
+ // {
+ // header: 'M3U',
+ // accessorKey: 'group_name',
+ // Cell: ({ cell }) => (
+ //
+ // {cell.getValue()}
+ //
+ // ),
+ // },
+ // ],
+ // []
+ // ),
+ // 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 }) => (
+ // <>
+ // removeStream(row.original)}
+ // >
+ // {/* Small icon size */}
+ //
+ // >
+ // ),
+ // 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 }) => (
+ // <>
+ // addStream(row.original)}
+ // >
+ // {/* Small icon size */}
+ //
+ // >
+ // ),
+ // positionActionsColumn: 'last',
+ // mantineTableContainerProps: {
+ // style: {
+ // height: '200px',
+ // },
+ // },
+ // });
+
+ if (!isOpen) {
+ return <>>;
+ }
+
+ return (
+ <>
+
+
+
+ setChannelGroupModalOpen(false)}
+ />
+ >
+ );
+};
+
+export default Channel;
diff --git a/frontend/src/components/forms/ChannelGroup.js b/frontend/src/components/forms/ChannelGroup.js
deleted file mode 100644
index dd4f2ee4..00000000
--- a/frontend/src/components/forms/ChannelGroup.js
+++ /dev/null
@@ -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 (
-
- );
-};
-
-export default ChannelGroup;
diff --git a/frontend/src/components/forms/ChannelGroup.jsx b/frontend/src/components/forms/ChannelGroup.jsx
new file mode 100644
index 00000000..93741ef1
--- /dev/null
+++ b/frontend/src/components/forms/ChannelGroup.jsx
@@ -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 (
+
+
+
+ );
+};
+
+export default ChannelGroup;
diff --git a/frontend/src/components/forms/EPG.js b/frontend/src/components/forms/EPG.js
deleted file mode 100644
index 232a2791..00000000
--- a/frontend/src/components/forms/EPG.js
+++ /dev/null
@@ -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 (
-
- );
-};
-
-export default EPG;
diff --git a/frontend/src/components/forms/EPG.jsx b/frontend/src/components/forms/EPG.jsx
new file mode 100644
index 00000000..4fd46bcb
--- /dev/null
+++ b/frontend/src/components/forms/EPG.jsx
@@ -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 (
+
+
+
+ );
+};
+
+export default EPG;
diff --git a/frontend/src/components/forms/LoginForm.js b/frontend/src/components/forms/LoginForm.js
deleted file mode 100644
index f7b4445e..00000000
--- a/frontend/src/components/forms/LoginForm.js
+++ /dev/null
@@ -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 (
-
-
-
- Login
-
-
-
-
- );
-};
-
-export default LoginForm;
diff --git a/frontend/src/components/forms/LoginForm.jsx b/frontend/src/components/forms/LoginForm.jsx
new file mode 100644
index 00000000..8eb8c183
--- /dev/null
+++ b/frontend/src/components/forms/LoginForm.jsx
@@ -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 (
+
+
+
+ Login
+
+
+
+
+ );
+};
+
+export default LoginForm;
diff --git a/frontend/src/components/forms/M3U.js b/frontend/src/components/forms/M3U.js
deleted file mode 100644
index b99a36e7..00000000
--- a/frontend/src/components/forms/M3U.js
+++ /dev/null
@@ -1,245 +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,
- },
- 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 (
-
- );
-};
-
-export default M3U;
diff --git a/frontend/src/components/forms/M3U.jsx b/frontend/src/components/forms/M3U.jsx
new file mode 100644
index 00000000..051826ac
--- /dev/null
+++ b/frontend/src/components/forms/M3U.jsx
@@ -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 (
+
+
+
+
+
+
+
+ );
+};
+
+export default M3U;
diff --git a/frontend/src/components/forms/M3UProfile.js b/frontend/src/components/forms/M3UProfile.js
deleted file mode 100644
index c2c00cb1..00000000
--- a/frontend/src/components/forms/M3UProfile.js
+++ /dev/null
@@ -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) => `${match}`)
- : 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 (
-
- );
-};
-
-export default RegexFormAndView;
diff --git a/frontend/src/components/forms/M3UProfile.jsx b/frontend/src/components/forms/M3UProfile.jsx
new file mode 100644
index 00000000..d01bbd5e
--- /dev/null
+++ b/frontend/src/components/forms/M3UProfile.jsx
@@ -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) => `${match}`)
+ : 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 (
+
+
+
+
+ Search
+
+
+
+
+ Replace
+ {resultUrl}
+
+
+ );
+};
+
+export default RegexFormAndView;
diff --git a/frontend/src/components/forms/M3UProfiles.js b/frontend/src/components/forms/M3UProfiles.js
deleted file mode 100644
index 24694811..00000000
--- a/frontend/src/components/forms/M3UProfiles.js
+++ /dev/null
@@ -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 (
- <>
-
-
-
- >
- );
-};
-
-export default M3UProfiles;
diff --git a/frontend/src/components/forms/M3UProfiles.jsx b/frontend/src/components/forms/M3UProfiles.jsx
new file mode 100644
index 00000000..7579db4f
--- /dev/null
+++ b/frontend/src/components/forms/M3UProfiles.jsx
@@ -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 (
+ <>
+
+ {profiles
+ .filter((playlist) => playlist.is_default == false)
+ .map((item) => (
+
+
+ Max Streams: {item.max_streams}
+ toggleActive(item)}
+ color="primary"
+ />
+ editProfile(item)} color="yellow.5">
+
+
+ deleteProfile(item.id)}
+ color="error"
+ >
+
+
+
+
+ ))}
+
+
+
+
+
+
+
+ >
+ );
+};
+
+export default M3UProfiles;
diff --git a/frontend/src/components/forms/Stream.js b/frontend/src/components/forms/Stream.js
deleted file mode 100644
index 714876fa..00000000
--- a/frontend/src/components/forms/Stream.js
+++ /dev/null
@@ -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 (
-
- );
-};
-
-export default Stream;
diff --git a/frontend/src/components/forms/Stream.jsx b/frontend/src/components/forms/Stream.jsx
new file mode 100644
index 00000000..adc42485
--- /dev/null
+++ b/frontend/src/components/forms/Stream.jsx
@@ -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 (
+
+
+
+ );
+};
+
+export default Stream;
diff --git a/frontend/src/components/forms/StreamProfile.js b/frontend/src/components/forms/StreamProfile.js
deleted file mode 100644
index 302fd5f7..00000000
--- a/frontend/src/components/forms/StreamProfile.js
+++ /dev/null
@@ -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 (
-
- );
-};
-
-export default StreamProfile;
diff --git a/frontend/src/components/forms/StreamProfile.jsx b/frontend/src/components/forms/StreamProfile.jsx
new file mode 100644
index 00000000..eadcca13
--- /dev/null
+++ b/frontend/src/components/forms/StreamProfile.jsx
@@ -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 (
+
+
+
+ );
+};
+
+export default StreamProfile;
diff --git a/frontend/src/components/forms/SuperuserForm.js b/frontend/src/components/forms/SuperuserForm.js
deleted file mode 100644
index 32eb5e14..00000000
--- a/frontend/src/components/forms/SuperuserForm.js
+++ /dev/null
@@ -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 (
-
-
-
- Create your Super User Account
-
- {error && (
-
- {error}
-
- )}
-
-
-
- );
-}
-
-export default SuperuserForm;
diff --git a/frontend/src/components/forms/SuperuserForm.jsx b/frontend/src/components/forms/SuperuserForm.jsx
new file mode 100644
index 00000000..66aa5ced
--- /dev/null
+++ b/frontend/src/components/forms/SuperuserForm.jsx
@@ -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 (
+
+
+
+ Create your Super User Account
+
+
+
+
+ );
+}
+
+export default SuperuserForm;
diff --git a/frontend/src/components/forms/UserAgent.js b/frontend/src/components/forms/UserAgent.js
deleted file mode 100644
index db13429f..00000000
--- a/frontend/src/components/forms/UserAgent.js
+++ /dev/null
@@ -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 (
-
- );
-};
-
-export default UserAgent;
diff --git a/frontend/src/components/forms/UserAgent.jsx b/frontend/src/components/forms/UserAgent.jsx
new file mode 100644
index 00000000..924a47fe
--- /dev/null
+++ b/frontend/src/components/forms/UserAgent.jsx
@@ -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 (
+
+
+
+ );
+};
+
+export default UserAgent;
diff --git a/frontend/src/components/sidebar.css b/frontend/src/components/sidebar.css
new file mode 100644
index 00000000..fa3756be
--- /dev/null
+++ b/frontend/src/components/sidebar.css
@@ -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;
+ }
diff --git a/frontend/src/components/tables/ChannelsTable.js b/frontend/src/components/tables/ChannelsTable.js
deleted file mode 100644
index 3fcfca7b..00000000
--- a/frontend/src/components/tables/ChannelsTable.js
+++ /dev/null
@@ -1,411 +0,0 @@
-import { useEffect, useMemo, useRef, useState } from 'react';
-import {
- MaterialReactTable,
- useMaterialReactTable,
-} from 'material-react-table';
-import {
- Box,
- Grid2,
- Stack,
- Typography,
- Tooltip,
- IconButton,
- Button,
- ButtonGroup,
- Snackbar,
- Popover,
- TextField,
-} from '@mui/material';
-import useChannelsStore from '../../store/channels';
-import {
- Delete as DeleteIcon,
- Edit as EditIcon,
- Add as AddIcon,
- SwapVert as SwapVertIcon,
- LiveTv as LiveTvIcon,
- ContentCopy,
- Tv as TvIcon, // <-- ADD THIS IMPORT
-} from '@mui/icons-material';
-import API from '../../api';
-import ChannelForm from '../forms/Channel';
-import { TableHelper } from '../../helpers';
-import utils from '../../utils';
-import logo from '../../images/logo.png';
-import useVideoStore from '../../store/useVideoStore';
-import useSettingsStore from '../../store/settings';
-
-const ChannelsTable = () => {
- const [channel, setChannel] = useState(null);
- const [channelModalOpen, setChannelModalOpen] = useState(false);
- const [rowSelection, setRowSelection] = useState([]);
-
- const [anchorEl, setAnchorEl] = useState(null);
- const [textToCopy, setTextToCopy] = useState('');
- const [snackbarMessage, setSnackbarMessage] = useState('');
- const [snackbarOpen, setSnackbarOpen] = useState(false);
- const { showVideo } = useVideoStore.getState(); // or useVideoStore()
-
- const { channels, isLoading: channelsLoading } = useChannelsStore();
- const {
- environment: { env_mode },
- } = useSettingsStore();
-
- // Configure columns
- const columns = useMemo(
- () => [
- {
- header: '#',
- size: 50,
- accessorKey: 'channel_number',
- },
- {
- header: 'Name',
- accessorKey: 'channel_name',
- },
- {
- header: 'Group',
- accessorFn: (row) => row.channel_group?.name || '',
- },
- {
- header: 'Logo',
- accessorKey: 'logo_url',
- size: 55,
- Cell: ({ cell }) => (
-
-
-
- ),
- meta: {
- filterVariant: null,
- },
- },
- ],
- []
- );
-
- // Access the row virtualizer instance (optional)
- const rowVirtualizerInstanceRef = useRef(null);
-
- const [isLoading, setIsLoading] = useState(true);
- const [sorting, setSorting] = useState([]);
-
- const closeSnackbar = () => setSnackbarOpen(false);
-
- const editChannel = async (ch = null) => {
- setChannel(ch);
- setChannelModalOpen(true);
- };
-
- const deleteChannel = async (id) => {
- setIsLoading(true);
- await API.deleteChannel(id);
- setIsLoading(false);
- };
-
- function handleWatchStream(channelNumber) {
- let vidUrl = `/output/stream/${channelNumber}/`;
- if (env_mode == 'dev') {
- vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`;
- }
- showVideo(vidUrl);
- }
-
- // (Optional) bulk delete, but your endpoint is @TODO
- const deleteChannels = async () => {
- setIsLoading(true);
- const selected = table
- .getRowModel()
- .rows.filter((row) => row.getIsSelected());
- await utils.Limiter(
- 4,
- selected.map((chan) => () => deleteChannel(chan.original.id))
- );
- // If you have a real bulk-delete endpoint, call it here:
- // await API.deleteChannels(selected.map((sel) => sel.id));
- setIsLoading(false);
- };
-
- // ─────────────────────────────────────────────────────────
- // The "Assign Channels" button logic
- // ─────────────────────────────────────────────────────────
- const assignChannels = async () => {
- try {
- // Get row order from the table
- const rowOrder = table.getRowModel().rows.map((row) => row.original.id);
-
- // Call our custom API endpoint
- setIsLoading(true);
- const result = await API.assignChannelNumbers(rowOrder);
- setIsLoading(false);
-
- // We might get { message: "Channels have been auto-assigned!" }
- setSnackbarMessage(result.message || 'Channels assigned');
- setSnackbarOpen(true);
-
- // Refresh the channel list
- await useChannelsStore.getState().fetchChannels();
- } catch (err) {
- console.error(err);
- setSnackbarMessage('Failed to assign channels');
- setSnackbarOpen(true);
- }
- };
-
- // ─────────────────────────────────────────────────────────
- // The new "Match EPG" button logic
- // ─────────────────────────────────────────────────────────
- const matchEpg = async () => {
- try {
- // Hit our new endpoint that triggers the fuzzy matching Celery task
- const resp = await fetch('/api/channels/channels/match-epg/', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Authorization: `Bearer ${await API.getAuthToken()}`,
- },
- });
-
- if (resp.ok) {
- setSnackbarMessage('EPG matching task started!');
- } else {
- const text = await resp.text();
- setSnackbarMessage(`Failed to start EPG matching: ${text}`);
- }
- } catch (err) {
- setSnackbarMessage(`Error: ${err.message}`);
- }
- setSnackbarOpen(true);
- };
-
- const closeChannelForm = () => {
- setChannel(null);
- setChannelModalOpen(false);
- };
-
- useEffect(() => {
- if (typeof window !== 'undefined') {
- setIsLoading(false);
- }
- }, []);
-
- useEffect(() => {
- // Scroll to the top of the table when sorting changes
- try {
- rowVirtualizerInstanceRef.current?.scrollToIndex?.(0);
- } catch (error) {
- console.error(error);
- }
- }, [sorting]);
-
- const closePopover = () => {
- setAnchorEl(null);
- setSnackbarMessage('');
- };
- const openPopover = Boolean(anchorEl);
-
- const handleCopy = async () => {
- try {
- await navigator.clipboard.writeText(textToCopy);
- setSnackbarMessage('Copied!');
- } catch (err) {
- setSnackbarMessage('Failed to copy');
- }
- setSnackbarOpen(true);
- };
-
- // Example copy URLs
- const copyM3UUrl = (event) => {
- setAnchorEl(event.currentTarget);
- setTextToCopy(
- `${window.location.protocol}//${window.location.host}/output/m3u`
- );
- };
- const copyEPGUrl = (event) => {
- setAnchorEl(event.currentTarget);
- setTextToCopy(
- `${window.location.protocol}//${window.location.host}/output/epg`
- );
- };
- const copyHDHRUrl = (event) => {
- setAnchorEl(event.currentTarget);
- setTextToCopy(
- `${window.location.protocol}//${window.location.host}/output/hdhr`
- );
- };
-
- // Configure the MaterialReactTable
- const table = useMaterialReactTable({
- ...TableHelper.defaultProperties,
- columns,
- data: channels,
- enablePagination: false,
- enableRowVirtualization: true,
- enableRowSelection: true,
- onRowSelectionChange: setRowSelection,
- onSortingChange: setSorting,
- state: {
- isLoading: isLoading || channelsLoading,
- sorting,
- rowSelection,
- },
- rowVirtualizerInstanceRef, // optional
- rowVirtualizerOptions: { overscan: 5 },
- initialState: {
- density: 'compact',
- },
- enableRowActions: true,
- renderRowActions: ({ row }) => (
-
- {
- editChannel(row.original);
- }}
- sx={{ p: 0 }}
- >
-
-
- deleteChannel(row.original.id)}
- sx={{ p: 0 }}
- >
-
-
- handleWatchStream(row.original.channel_number)}
- sx={{ p: 0 }}
- >
-
-
-
- ),
- muiTableContainerProps: {
- sx: {
- height: 'calc(100vh - 75px)',
- overflowY: 'auto',
- },
- },
- muiSearchTextFieldProps: {
- variant: 'standard',
- },
- renderTopToolbarCustomActions: ({ table }) => (
-
- Channels
-
- editChannel()}
- >
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {/* Our brand-new button for EPG matching */}
-
-
-
-
-
-
-
-
-
-
-
-
- ),
- });
-
- return (
-
-
-
- {/* Channel Form Modal */}
-
-
- {/* Popover for the "copy" URLs */}
-
-
-
-
-
-
-
-
-
- {/* Snackbar for feedback */}
-
-
- );
-};
-
-export default ChannelsTable;
diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx
new file mode 100644
index 00000000..bbf4322b
--- /dev/null
+++ b/frontend/src/components/tables/ChannelsTable.jsx
@@ -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 }) => (
+
+ {cell.getValue()}
+
+ ),
+ },
+ {
+ size: 100,
+ header: 'M3U',
+ accessorFn: (row) =>
+ playlists.find((playlist) => playlist.id === row.m3u_account)?.name,
+ Cell: ({ cell }) => (
+
+ {cell.getValue()}
+
+ ),
+ },
+ ],
+ [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 }) => (
+
+ removeStream(row.original)}
+ >
+
+
+
+ ),
+ });
+
+ if (!isExpanded) {
+ return <>>;
+ }
+
+ return (
+
+
+
+ );
+};
+
+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 }) => (
+ {
+ e.stopPropagation();
+ handleFilterChange(column.id, e.target.value);
+ }}
+ size="xs"
+ />
+ ),
+ Cell: ({ cell }) => (
+
+ {cell.getValue()}
+
+ ),
+ },
+ {
+ header: 'Group',
+ accessorFn: (row) => row.channel_group?.name || '',
+ Header: ({ column }) => (
+