diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b9b072dc..bf446dfe 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,6 +19,7 @@ "eslint": "^8.57.1", "formik": "^2.4.6", "hls.js": "^1.5.20", + "lucide-react": "^0.479.0", "material-react-table": "^3.2.0", "mpegts.js": "^1.4.2", "planby": "^1.1.7", @@ -11701,6 +11702,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "0.479.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.479.0.tgz", + "integrity": "sha512-aBhNnveRhorBOK7uA4gDjgaf+YlHMdMhQ/3cupk6exM10hWlEU+2QtWYOfhXhjAsmdb6LeKR+NZnow4UxRRiTQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/m3u8-parser": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/m3u8-parser/-/m3u8-parser-7.2.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index b35b8885..c64bf59f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "eslint": "^8.57.1", "formik": "^2.4.6", "hls.js": "^1.5.20", + "lucide-react": "^0.479.0", "material-react-table": "^3.2.0", "mpegts.js": "^1.4.2", "planby": "^1.1.7", diff --git a/frontend/src/App.js b/frontend/src/App.js index def311ff..ee0418a7 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -12,7 +12,7 @@ import Login from './pages/Login'; import Channels from './pages/Channels'; import M3U from './pages/M3U'; import { ThemeProvider } from '@mui/material/styles'; -import { Box, CssBaseline } from '@mui/material'; +import { Box, CssBaseline } from '@mui/material'; // removed AppBar/Toolbar import theme from './theme'; import EPG from './pages/EPG'; import Guide from './pages/Guide'; @@ -58,7 +58,7 @@ const App = () => { checkSuperuser(); }, []); - // Authentication check. + // Authentication check useEffect(() => { const checkAuth = async () => { const loggedIn = await initializeAuth(); @@ -72,7 +72,7 @@ const App = () => { checkAuth(); }, [initializeAuth, initData, setIsAuthenticated, logout]); - // If no superuser exists, show the initialization form. + // If no superuser exists, show the initialization form if (needsSuperuser) { return setNeedsSuperuser(false)} />; } @@ -82,6 +82,7 @@ const App = () => { + {/* Sidebar on the left */} { toggleDrawer={toggleDrawer} /> + {/* Main content area, no AppBar, so no marginTop */} - + {isAuthenticated ? ( <> @@ -136,6 +132,7 @@ const App = () => { + diff --git a/frontend/src/components/Sidebar.js b/frontend/src/components/Sidebar.js index 5489311d..4ddaea1d 100644 --- a/frontend/src/components/Sidebar.js +++ b/frontend/src/components/Sidebar.js @@ -1,148 +1,151 @@ import React from 'react'; -import { Link, useLocation, useNavigate } from 'react-router-dom'; +import { Link, useLocation } from 'react-router-dom'; import { - List, - ListItem, - ListItemButton, - ListItemText, - ListItemIcon, - Box, - Divider, Drawer, - TextField, + Toolbar, + Box, + Typography, + Avatar, + List, + ListItemButton, + ListItemIcon, + ListItemText, } from '@mui/material'; import { - Tv as TvIcon, - CalendarMonth as CalendarMonthIcon, - VideoFile as VideoFileIcon, - LiveTv as LiveTvIcon, - PlaylistPlay as PlaylistPlayIcon, - Settings as SettingsIcon, - Logout as LogoutIcon, -} from '@mui/icons-material'; + ListOrdered, + Play, + Database, + SlidersHorizontal, + LayoutGrid, + Settings as LucideSettings, +} from 'lucide-react'; import logo from '../images/logo.png'; -import useAuthStore from '../store/auth'; -import useSettingsStore from '../store/settings'; +import { ReactComponent as DispatcharrLogo } from '../images/dispatcharr.svg'; -const items = [ - { text: 'Channels', icon: , 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 navItems = [ + { label: 'Channels', icon: , path: '/channels' }, + { 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 Sidebar = ({ open, miniDrawerWidth, drawerWidth, toggleDrawer }) => { +const Sidebar = ({ open, drawerWidth, miniDrawerWidth, toggleDrawer }) => { const location = useLocation(); - const { isAuthenticated, logout } = useAuthStore(); - const { - environment: { public_ip, country_code, country_name }, - } = useSettingsStore(); - const navigate = useNavigate(); - - const onLogout = () => { - logout(); - navigate('/login'); - }; return ( - - - + + {open ? ( + + Dispatcharr Logo + + Dispatcharr + + + ) : ( + Dispatcharr Logo + )} + + + + {navItems.map((item) => { + const isActive = location.pathname.startsWith(item.path); + return ( - logo - {open && ( - - )} - - - - - - - {items.map((item) => ( - - - {item.icon} - {open && } - - - ))} - - - - {isAuthenticated && ( - - - - - - + + {item.icon} - - - - - {open && ( - - {/* Public IP + optional flag */} - - - {/* If we have a country code, show a small flag */} - {country_code && ( - {country_name )} - - )} - - )} + + ); + })} + + + + + + {open && ( + + John Doe + + )} + ); }; diff --git a/frontend/src/components/tables/ChannelsTable.js b/frontend/src/components/tables/ChannelsTable.js index 3fcfca7b..70fd3124 100644 --- a/frontend/src/components/tables/ChannelsTable.js +++ b/frontend/src/components/tables/ChannelsTable.js @@ -1,36 +1,33 @@ +// frontend/src/components/tables/ChannelsTable.js import { useEffect, useMemo, useRef, useState } from 'react'; -import { - MaterialReactTable, - useMaterialReactTable, -} from 'material-react-table'; +import { MaterialReactTable, useMaterialReactTable } from 'material-react-table'; import { Box, - Grid2, Stack, Typography, Tooltip, IconButton, - Button, ButtonGroup, + Button, Snackbar, Popover, TextField, } from '@mui/material'; -import useChannelsStore from '../../store/channels'; import { Delete as DeleteIcon, Edit as EditIcon, Add as AddIcon, SwapVert as SwapVertIcon, LiveTv as LiveTvIcon, + Tv as TvIcon, ContentCopy, - Tv as TvIcon, // <-- ADD THIS IMPORT } from '@mui/icons-material'; import API from '../../api'; import ChannelForm from '../forms/Channel'; import { TableHelper } from '../../helpers'; import utils from '../../utils'; import logo from '../../images/logo.png'; +import useChannelsStore from '../../store/channels'; import useVideoStore from '../../store/useVideoStore'; import useSettingsStore from '../../store/settings'; @@ -43,16 +40,20 @@ const ChannelsTable = () => { const [textToCopy, setTextToCopy] = useState(''); const [snackbarMessage, setSnackbarMessage] = useState(''); const [snackbarOpen, setSnackbarOpen] = useState(false); - const { showVideo } = useVideoStore.getState(); // or useVideoStore() const { channels, isLoading: channelsLoading } = useChannelsStore(); const { environment: { env_mode }, } = useSettingsStore(); + const { showVideo } = useVideoStore.getState(); - // Configure columns - const columns = useMemo( - () => [ + const rowVirtualizerInstanceRef = useRef(null); + const [isLoading, setIsLoading] = useState(true); + const [sorting, setSorting] = useState([]); + + // Columns + const columns = useMemo(() => { + return [ { header: '#', size: 50, @@ -69,178 +70,21 @@ const ChannelsTable = () => { { header: 'Logo', accessorKey: 'logo_url', - size: 55, + size: 60, Cell: ({ cell }) => ( - - channel logo - + + channel logo + ), - meta: { - filterVariant: null, - }, }, - ], - [] - ); - - // Access the row virtualizer instance (optional) - const rowVirtualizerInstanceRef = useRef(null); - - const [isLoading, setIsLoading] = useState(true); - const [sorting, setSorting] = useState([]); - - const closeSnackbar = () => setSnackbarOpen(false); - - const editChannel = async (ch = null) => { - setChannel(ch); - setChannelModalOpen(true); - }; - - const deleteChannel = async (id) => { - setIsLoading(true); - await API.deleteChannel(id); - setIsLoading(false); - }; - - function handleWatchStream(channelNumber) { - let vidUrl = `/output/stream/${channelNumber}/`; - if (env_mode == 'dev') { - vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`; - } - showVideo(vidUrl); - } - - // (Optional) bulk delete, but your endpoint is @TODO - const deleteChannels = async () => { - setIsLoading(true); - const selected = table - .getRowModel() - .rows.filter((row) => row.getIsSelected()); - await utils.Limiter( - 4, - selected.map((chan) => () => deleteChannel(chan.original.id)) - ); - // If you have a real bulk-delete endpoint, call it here: - // await API.deleteChannels(selected.map((sel) => sel.id)); - setIsLoading(false); - }; - - // ───────────────────────────────────────────────────────── - // The "Assign Channels" button logic - // ───────────────────────────────────────────────────────── - const assignChannels = async () => { - try { - // Get row order from the table - const rowOrder = table.getRowModel().rows.map((row) => row.original.id); - - // Call our custom API endpoint - setIsLoading(true); - const result = await API.assignChannelNumbers(rowOrder); - setIsLoading(false); - - // We might get { message: "Channels have been auto-assigned!" } - setSnackbarMessage(result.message || 'Channels assigned'); - setSnackbarOpen(true); - - // Refresh the channel list - await useChannelsStore.getState().fetchChannels(); - } catch (err) { - console.error(err); - setSnackbarMessage('Failed to assign channels'); - setSnackbarOpen(true); - } - }; - - // ───────────────────────────────────────────────────────── - // The new "Match EPG" button logic - // ───────────────────────────────────────────────────────── - const matchEpg = async () => { - try { - // Hit our new endpoint that triggers the fuzzy matching Celery task - const resp = await fetch('/api/channels/channels/match-epg/', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${await API.getAuthToken()}`, - }, - }); - - if (resp.ok) { - setSnackbarMessage('EPG matching task started!'); - } else { - const text = await resp.text(); - setSnackbarMessage(`Failed to start EPG matching: ${text}`); - } - } catch (err) { - setSnackbarMessage(`Error: ${err.message}`); - } - setSnackbarOpen(true); - }; - - const closeChannelForm = () => { - setChannel(null); - setChannelModalOpen(false); - }; - - useEffect(() => { - if (typeof window !== 'undefined') { - setIsLoading(false); - } + ]; }, []); - useEffect(() => { - // Scroll to the top of the table when sorting changes - try { - rowVirtualizerInstanceRef.current?.scrollToIndex?.(0); - } catch (error) { - console.error(error); - } - }, [sorting]); - - const closePopover = () => { - setAnchorEl(null); - setSnackbarMessage(''); - }; - const openPopover = Boolean(anchorEl); - - const handleCopy = async () => { - try { - await navigator.clipboard.writeText(textToCopy); - setSnackbarMessage('Copied!'); - } catch (err) { - setSnackbarMessage('Failed to copy'); - } - setSnackbarOpen(true); - }; - - // Example copy URLs - const copyM3UUrl = (event) => { - setAnchorEl(event.currentTarget); - setTextToCopy( - `${window.location.protocol}//${window.location.host}/output/m3u` - ); - }; - const copyEPGUrl = (event) => { - setAnchorEl(event.currentTarget); - setTextToCopy( - `${window.location.protocol}//${window.location.host}/output/epg` - ); - }; - const copyHDHRUrl = (event) => { - setAnchorEl(event.currentTarget); - setTextToCopy( - `${window.location.protocol}//${window.location.host}/output/hdhr` - ); - }; - - // Configure the MaterialReactTable + // Common table logic const table = useMaterialReactTable({ ...TableHelper.defaultProperties, columns, @@ -255,114 +99,204 @@ const ChannelsTable = () => { sorting, rowSelection, }, - rowVirtualizerInstanceRef, // optional + rowVirtualizerInstanceRef, rowVirtualizerOptions: { overscan: 5 }, - initialState: { - density: 'compact', - }, enableRowActions: true, renderRowActions: ({ row }) => ( - + + {/* Edit channel */} { - editChannel(row.original); - }} - sx={{ p: 0 }} + onClick={() => editChannel(row.original)} > + {/* Delete channel */} deleteChannel(row.original.id)} - sx={{ p: 0 }} > + {/* Watch now */} handleWatchStream(row.original.channel_number)} - sx={{ p: 0 }} > - + ), muiTableContainerProps: { sx: { - height: 'calc(100vh - 75px)', + height: 'calc(100% - 40px)', // fill parent minus a bit for your top row overflowY: 'auto', }, }, - muiSearchTextFieldProps: { - variant: 'standard', - }, - renderTopToolbarCustomActions: ({ table }) => ( - - Channels - - editChannel()} - > - - - - - - - - - - - - - - - {/* Our brand-new button for EPG matching */} - - - - - - - - - - + renderTopToolbarCustomActions: () => ( + + {/* “HDHR URL”, “M3U URL”, “EPG” ButtonGroup like your screenshot */} + + + + + + {/* Additional actions: auto-assign, auto-match, add, remove, etc. */} + + + + + + + + + + + + + + editChannel()}> + + + + + + + + + ), }); + // Lifecycle + useEffect(() => { + if (typeof window !== 'undefined') { + setIsLoading(false); + } + }, []); + + useEffect(() => { + try { + rowVirtualizerInstanceRef.current?.scrollToIndex?.(0); + } catch (error) { + console.error(error); + } + }, [sorting]); + + // Channel actions + function editChannel(channel = null) { + setChannel(channel); + setChannelModalOpen(true); + } + + async function deleteChannel(id) { + setIsLoading(true); + await API.deleteChannel(id); + setIsLoading(false); + } + + function handleWatchStream(channelNumber) { + let vidUrl = `/output/stream/${channelNumber}/`; + if (env_mode === 'dev') { + vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`; + } + showVideo(vidUrl); + } + + async function deleteChannels() { + setIsLoading(true); + const selected = table + .getRowModel() + .rows.filter((row) => row.getIsSelected()); + await utils.Limiter( + 4, + selected.map((chan) => () => deleteChannel(chan.original.id)) + ); + setIsLoading(false); + } + + async function assignChannels() { + try { + const rowOrder = table.getRowModel().rows.map((row) => row.original.id); + setIsLoading(true); + const result = await API.assignChannelNumbers(rowOrder); + setIsLoading(false); + setSnackbarMessage(result.message || 'Channels assigned'); + setSnackbarOpen(true); + await useChannelsStore.getState().fetchChannels(); + } catch (err) { + console.error(err); + setSnackbarMessage('Failed to assign channels'); + setSnackbarOpen(true); + } + } + + async function matchEpg() { + try { + const resp = await fetch('/api/channels/channels/match-epg/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${await API.getAuthToken()}`, + }, + }); + if (resp.ok) { + setSnackbarMessage('EPG matching task started!'); + } else { + const text = await resp.text(); + setSnackbarMessage(`Failed to start EPG matching: ${text}`); + } + } catch (err) { + setSnackbarMessage(`Error: ${err.message}`); + } + setSnackbarOpen(true); + } + + // Copy popover + const openPopover = Boolean(anchorEl); + function closePopover() { + setAnchorEl(null); + } + async function handleCopy() { + try { + await navigator.clipboard.writeText(textToCopy); + setSnackbarMessage('Copied!'); + } catch (err) { + setSnackbarMessage('Failed to copy'); + } + setSnackbarOpen(true); + } + function copyHDHRUrl(event) { + setAnchorEl(event.currentTarget); + setTextToCopy(`${window.location.protocol}//${window.location.host}/output/hdhr`); + } + function copyM3UUrl(event) { + setAnchorEl(event.currentTarget); + setTextToCopy(`${window.location.protocol}//${window.location.host}/output/m3u`); + } + function copyEPGUrl(event) { + setAnchorEl(event.currentTarget); + setTextToCopy(`${window.location.protocol}//${window.location.host}/output/epg`); + } + + // Channel form close + function closeChannelForm() { + setChannel(null); + setChannelModalOpen(false); + } + + // Snackbar + const handleSnackbarClose = () => { + setSnackbarOpen(false); + }; + return ( - + {/* Channel Form Modal */} @@ -372,36 +306,33 @@ const ChannelsTable = () => { onClose={closeChannelForm} /> - {/* Popover for the "copy" URLs */} + {/* Popover for "copy" URLs */} -
+ -
+
- {/* Snackbar for feedback */} + {/* Snackbar messages */}
diff --git a/frontend/src/helpers/table.js b/frontend/src/helpers/table.js index 84aeb328..320c0a87 100644 --- a/frontend/src/helpers/table.js +++ b/frontend/src/helpers/table.js @@ -1,3 +1,5 @@ +// frontend/src/helpers/table.js + export default { defaultProperties: { enableGlobalFilter: false, @@ -13,19 +15,34 @@ export default { }, muiTableBodyCellProps: { sx: { - padding: 0, + padding: '6px', + borderColor: '#444', + color: '#E0E0E0', + fontSize: '0.85rem', }, }, muiTableHeadCellProps: { sx: { - padding: 0, + padding: '6px', + color: '#CFCFCF', + backgroundColor: '#383A3F', + borderColor: '#444', + fontWeight: 600, + fontSize: '0.8rem', }, }, muiTableBodyProps: { sx: { - //stripe the rows, make odd rows a darker color - '& tr:nth-of-type(odd) > td': { - // backgroundColor: '#f5f5f5', + // Subtle row striping + '& tr:nth-of-type(odd)': { + backgroundColor: '#2F3034', + }, + '& tr:nth-of-type(even)': { + backgroundColor: '#333539', + }, + // Row hover effect + '& tr:hover td': { + backgroundColor: '#3B3D41', }, }, }, diff --git a/frontend/src/images/dispatcharr.svg b/frontend/src/images/dispatcharr.svg new file mode 100644 index 00000000..3ddd8e2b --- /dev/null +++ b/frontend/src/images/dispatcharr.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/images/logo.png b/frontend/src/images/logo.png index afadd08e..99c3c19f 100644 Binary files a/frontend/src/images/logo.png and b/frontend/src/images/logo.png differ diff --git a/frontend/src/index.css b/frontend/src/index.css index ec2585e8..06515741 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,3 +1,5 @@ +/* frontend/src/index.css */ + body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', @@ -5,9 +7,25 @@ body { sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; + background-color: #2E2F34; /* Ensure the global background is dark */ + color: #ffffff; } code { font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; } + +/* Example scrollbars - optional, to match a dark theme. */ +::-webkit-scrollbar { + width: 8px; +} +::-webkit-scrollbar-track { + background: #3B3C41; +} +::-webkit-scrollbar-thumb { + background: #555; +} +::-webkit-scrollbar-thumb:hover { + background: #777; +} diff --git a/frontend/src/pages/Channels.js b/frontend/src/pages/Channels.js index 68a32df1..2b071633 100644 --- a/frontend/src/pages/Channels.js +++ b/frontend/src/pages/Channels.js @@ -1,42 +1,911 @@ -import React from 'react'; -import ChannelsTable from '../components/tables/ChannelsTable'; -import StreamsTable from '../components/tables/StreamsTable'; -import { Grid2, Box } from '@mui/material'; +import React, { useState, useEffect } from 'react'; +import { + Box, + Grid, + Paper, + Stack, + Button, + Chip, + Typography, + IconButton, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, +} from '@mui/material'; + +// MUI icons used to replicate the Figma design +import AddBox from '@mui/icons-material/AddBox'; +import ArrowDownward from '@mui/icons-material/ArrowDownward'; +import CancelOutlined from '@mui/icons-material/CancelOutlined'; +import CheckBoxOutlineBlank from '@mui/icons-material/CheckBoxOutlineBlank'; +import Code from '@mui/icons-material/Code'; +import CompareArrows from '@mui/icons-material/CompareArrows'; +import IndeterminateCheckBox from '@mui/icons-material/IndeterminateCheckBox'; +import MoreHoriz from '@mui/icons-material/MoreHoriz'; +import PlayArrow from '@mui/icons-material/PlayArrow'; +import PlayCircle from '@mui/icons-material/PlayCircle'; +import Sort from '@mui/icons-material/Sort'; +import Edit from '@mui/icons-material/Edit'; + +// Zustand stores & API +import useChannelsStore from '../store/channels'; +import useStreamsStore from '../store/streams'; +import useVideoStore from '../store/useVideoStore'; +import useAlertStore from '../store/alerts'; +import API from '../api'; + +// If you have ChannelForm / StreamForm modals, import them: +import ChannelForm from '../components/forms/Channel'; +import StreamForm from '../components/forms/Stream'; const ChannelsPage = () => { + // + // ----------------------------- + // 1) HOOKS & GLOBAL STORE DATA + // ----------------------------- + // + const { channels, fetchChannels } = useChannelsStore(); + const { streams, fetchStreams } = useStreamsStore(); + const { showVideo } = useVideoStore.getState(); + const { showAlert } = useAlertStore(); + + // We fetch channels/streams if needed + useEffect(() => { + // If not loaded yet, fetch them: + fetchChannels().catch((err) => console.error('Failed to fetch channels', err)); + fetchStreams().catch((err) => console.error('Failed to fetch streams', err)); + // eslint-disable-next-line + }, []); + + // + // ----------------------------- + // 2) LOCAL STATE FOR SELECTION + // ----------------------------- + // + const [selectedChannelIds, setSelectedChannelIds] = useState([]); + const [selectedStreamIds, setSelectedStreamIds] = useState([]); + + // For opening the Channel/Stream forms + const [channelFormOpen, setChannelFormOpen] = useState(false); + const [editingChannel, setEditingChannel] = useState(null); + + const [streamFormOpen, setStreamFormOpen] = useState(false); + const [editingStream, setEditingStream] = useState(null); + + // + // ----------------------------- + // 3) CHANNEL ACTIONS + // ----------------------------- + // + function handleToggleChannel(channelId) { + setSelectedChannelIds((prev) => { + if (prev.includes(channelId)) { + return prev.filter((id) => id !== channelId); + } else { + return [...prev, channelId]; + } + }); + } + + function handleSelectAllChannels() { + if (selectedChannelIds.length === channels.length) { + setSelectedChannelIds([]); + } else { + setSelectedChannelIds(channels.map((c) => c.id)); + } + } + + async function handleRemoveChannels() { + if (selectedChannelIds.length === 0) return; + // This calls your existing bulk delete method + try { + await API.deleteChannels(selectedChannelIds); + setSelectedChannelIds([]); + showAlert(`Deleted ${selectedChannelIds.length} channels`, 'success'); + } catch (err) { + console.error(err); + showAlert('Failed to remove channels', 'error'); + } + } + + async function handleAssignChannels() { + // The example calls a reorder method. If you have a different approach, adapt here + const channelIdsInCurrentOrder = channels.map((ch) => ch.id); + try { + await API.assignChannelNumbers(channelIdsInCurrentOrder); + showAlert('Channels assigned successfully!', 'success'); + } catch (err) { + console.error(err); + showAlert('Failed to assign channels', 'error'); + } + } + + async function handleAutoMatch() { + // Example "match-epg" call from your code: + try { + const resp = await fetch('/api/channels/channels/match-epg/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${await API.getAuthToken()}`, + }, + }); + if (resp.ok) { + showAlert('EPG matching task started!', 'success'); + } else { + const text = await resp.text(); + showAlert(`Failed to start EPG matching: ${text}`, 'error'); + } + } catch (err) { + showAlert(`Error: ${err.message}`, 'error'); + } + } + + function handleAddChannel() { + setEditingChannel(null); + setChannelFormOpen(true); + } + + function handleEditChannel(channel) { + setEditingChannel(channel); + setChannelFormOpen(true); + } + + async function handleDeleteChannel(channelId) { + try { + await API.deleteChannel(channelId); + showAlert('Channel deleted', 'success'); + } catch (err) { + console.error(err); + showAlert('Failed to delete channel', 'error'); + } + } + + function handlePlayChannel(channel) { + // For your environment logic, adapt as needed + const vidUrl = `/output/stream/${channel.channel_number}`; + showVideo(vidUrl); + } + + // + // ----------------------------- + // 4) STREAM ACTIONS + // ----------------------------- + // + function handleToggleStream(streamId) { + setSelectedStreamIds((prev) => { + if (prev.includes(streamId)) { + return prev.filter((id) => id !== streamId); + } else { + return [...prev, streamId]; + } + }); + } + + function handleSelectAllStreams() { + if (selectedStreamIds.length === streams.length) { + setSelectedStreamIds([]); + } else { + setSelectedStreamIds(streams.map((s) => s.id)); + } + } + + async function handleRemoveStreams() { + if (selectedStreamIds.length === 0) return; + try { + await API.deleteStreams(selectedStreamIds); + setSelectedStreamIds([]); + showAlert(`Deleted ${selectedStreamIds.length} streams`, 'success'); + } catch (err) { + console.error(err); + showAlert('Failed to remove streams', 'error'); + } + } + + // Bulk "create channels" from selected streams + async function handleCreateChannelsFromStreams() { + if (selectedStreamIds.length === 0) return; + // If your API is `createChannelsFromStreams()`, adapt below + const payload = selectedStreamIds.map((streamId) => { + const st = streams.find((s) => s.id === streamId); + return { + stream_id: st.id, + channel_name: st.name, + }; + }); + try { + await API.createChannelsFromStreams(payload); + showAlert(`Created channels from ${selectedStreamIds.length} streams`, 'success'); + } catch (err) { + console.error(err); + showAlert('Failed to create channels', 'error'); + } + } + + function handleAddStream() { + setEditingStream(null); + setStreamFormOpen(true); + } + + function handleEditStream(stream) { + setEditingStream(stream); + setStreamFormOpen(true); + } + + async function handleDeleteStream(streamId) { + try { + await API.deleteStream(streamId); + showAlert('Stream deleted', 'success'); + } catch (err) { + console.error(err); + showAlert('Failed to delete stream', 'error'); + } + } + + function handlePlayStream(stream) { + // If your environment logic differs, adapt as needed + const vidUrl = `/output/stream/${stream.id}`; + showVideo(vidUrl); + } + + // + // ----------------------------- + // 5) RENDER + // ----------------------------- + // return ( - - - + {/* We do NOT replicate the example's built-in sidebar here + because your App.js + is already handling that. + So we skip the sidebar portion from the Figma code. */} + + {/* Main content: 2 columns => Channels (left), Streams (right) */} + + {/* ------------------------ */} + {/* CHANNELS SECTION */} + {/* ------------------------ */} + + + Channels + + + + {/* Toolbar for Channels */} + + + + Links: + + {['HDHR', 'M3U', 'EPG'].map((link) => ( + { + // If you have a real link action, put it here + showAlert(`Clicked ${link}`, 'info'); + }} + /> + ))} + + + + + + + + + + + {/* Channels Table */} + + + + + + {/* "Select All" for Channels */} + + + + + + #{' '} + + + + Name{' '} + + + + Group{' '} + + + + Logo{' '} + + + + Actions + + + + + {channels.map((channel) => { + const isSelected = selectedChannelIds.includes(channel.id); + return ( + + + handleToggleChannel(channel.id)} + > + {isSelected ? ( + + ) : ( + + )} + + + + {channel.channel_number || channel.id} + + + {channel.channel_name} + + + {channel.channel_group + ? channel.channel_group.name + : ''} + + + {channel.logo_url ? ( + + ) : ( + + )} + + + + + handleEditChannel(channel)} + > + + + handlePlayChannel(channel)} + > + + + + handleDeleteChannel(channel.id)} + > + + + + + + ); + })} + +
+
+
+
+ + {/* ------------------------ */} + {/* STREAMS SECTION */} + {/* ------------------------ */} + + + Streams + + + + {/* Toolbar for Streams */} + + + + + + + + + {/* Streams Table */} + + + + + + {/* "Select All" for Streams */} + + + + + + Name{' '} + + + + Group{' '} + + + + M3U{' '} + + + + Actions + + + + + {streams.map((stream) => { + const isSelected = selectedStreamIds.includes(stream.id); + return ( + + + handleToggleStream(stream.id)} + > + {isSelected ? ( + + ) : ( + + )} + + + + {stream.name} + + + {stream.group_name || ''} + + + {/* If your store uses something else for "m3u" or "m3u_account", + adapt this line accordingly */} + {stream.m3u_account ? 'Yes' : 'No'} + + + + + handleEditStream(stream)} + > + + + handlePlayStream(stream)} + > + + + + handleDeleteStream(stream.id)} + > + + + + + + ); + })} + +
+
+
+
+
+ + {/* Channel Form Modal */} + {channelFormOpen && ( + { + setChannelFormOpen(false); + setEditingChannel(null); }} - > - -
-
- - + )} + + {/* Stream Form Modal */} + {streamFormOpen && ( + { + setStreamFormOpen(false); + setEditingStream(null); }} - > - - - -
+ /> + )} + ); }; diff --git a/frontend/src/theme.js b/frontend/src/theme.js index 258d415a..9c70a255 100644 --- a/frontend/src/theme.js +++ b/frontend/src/theme.js @@ -1,21 +1,65 @@ +// frontend/src/theme.js import { createTheme } from '@mui/material/styles'; const theme = createTheme({ palette: { mode: 'dark', + background: { + default: '#2B2C30', // Dark background + paper: '#333539', // Slightly lighter panel background + }, primary: { - main: '#495057', - contrastText: '#ffffff', // Ensure text is visible on primary color + // Adjust accent color if your Figma calls for a different highlight + main: '#4A90E2', + contrastText: '#FFFFFF', + }, + secondary: { + main: '#F5A623', + contrastText: '#FFFFFF', + }, + text: { + primary: '#FFFFFF', + secondary: '#C3C3C3', + }, + }, + typography: { + fontFamily: ['Roboto', 'Helvetica', 'Arial', 'sans-serif'].join(','), + // Example typography tweaks + h6: { + fontWeight: 500, + fontSize: '0.95rem', + }, + body1: { + fontSize: '0.875rem', }, }, components: { MuiButton: { styleOverrides: { root: { - // textTransform: 'none', // Disable uppercase on buttons + borderRadius: 4, + textTransform: 'none', + fontWeight: 500, }, }, }, + MuiDrawer: { + styleOverrides: { + paper: { + backgroundColor: '#333539', + color: '#FFFFFF', + }, + }, + }, + // We remove the AppBar override since we won't be using it in App.js anymore + MuiAppBar: { + styleOverrides: { + root: { + backgroundColor: '#2B2C30', + }, + }, + }, + // Feel free to override more MUI components as needed... }, });