diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py index 0dd1f20d..aad7b7c6 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -112,6 +112,9 @@ REST_FRAMEWORK = { 'rest_framework.renderers.JSONRenderer', 'rest_framework.renderers.BrowsableAPIRenderer', ], + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework_simplejwt.authentication.JWTAuthentication', + ], } SWAGGER_SETTINGS = { @@ -158,12 +161,6 @@ CSRF_TRUSTED_ORIGINS = [ ] APPEND_SLASH = True -REST_FRAMEWORK = { - 'DEFAULT_AUTHENTICATION_CLASSES': [ - 'rest_framework_simplejwt.authentication.JWTAuthentication', - ], -} - SIMPLE_JWT = { 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=30), 'REFRESH_TOKEN_LIFETIME': timedelta(days=1), diff --git a/frontend/src/App.js b/frontend/src/App.js index def311ff..b40b1a37 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, GlobalStyles } from '@mui/material'; import theme from './theme'; import EPG from './pages/EPG'; import Guide from './pages/Guide'; @@ -80,6 +80,14 @@ const App = () => { return ( + { [playlists] ), enableKeyboardShortcuts: false, - enableColumnActions: false, enableColumnFilters: false, enableSorting: false, enableBottomToolbar: false, @@ -150,23 +152,44 @@ const ChannelStreams = ({ channel, isExpanded }) => { ); }; -const ChannelsTable = ({ setSelectedChannels }) => { +const ChannelsTable = ({}) => { const [channel, setChannel] = useState(null); const [channelModalOpen, setChannelModalOpen] = useState(false); const [rowSelection, setRowSelection] = useState([]); + const [channelGroupOptions, setChannelGroupOptions] = useState([]); const [anchorEl, setAnchorEl] = useState(null); const [textToCopy, setTextToCopy] = useState(''); const [snackbarMessage, setSnackbarMessage] = useState(''); const [snackbarOpen, setSnackbarOpen] = useState(false); - const { showVideo } = useVideoStore(); // or useVideoStore() + const [filterValues, setFilterValues] = useState({}); + + 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) => { + console.log(columnId); + console.log(value); + setFilterValues((prev) => ({ + ...prev, + [columnId]: value ? value.toLowerCase() : '', + })); + }; + const outputUrlRef = useRef(null); const { @@ -184,14 +207,81 @@ const ChannelsTable = ({ setSelectedChannels }) => { { header: 'Name', accessorKey: 'channel_name', + muiTableHeadCellProps: { + sx: { textAlign: 'center' }, // Center-align the header + }, + Header: ({ column }) => ( + handleFilterChange(column.id, e.target.value)} + size="small" + margin="none" + fullWidth + sx={ + { + // '& .MuiInputBase-root': { fontSize: '0.875rem' }, // Text size + // '& .MuiInputLabel-root': { fontSize: '0.75rem' }, // Label size + // width: '200px', // Optional: Adjust width + } + } + slotProps={{ + input: { + endAdornment: ( + + handleFilterChange(column.id, '')} // Clear text on click + edge="end" + size="small" + > + + + + ), + }, + }} + /> + ), + meta: { + filterVariant: null, + }, }, { header: 'Group', accessorFn: (row) => row.channel_group?.name || '', + Header: ({ column }) => ( + + handleFilterChange(column.id, newValue) + } + renderInput={(params) => ( + e.stopPropagation()} + sx={{ + pb: 0.8, + // '& .MuiInputBase-root': { fontSize: '0.875rem' }, // Text size + // '& .MuiInputLabel-root': { fontSize: '0.75rem' }, // Label size + // width: '200px', // Optional: Adjust width + }} + /> + )} + /> + ), }, { header: 'Logo', accessorKey: 'logo_url', + enableSorting: false, size: 55, Cell: ({ cell }) => ( { }, }, ], - [] + [channelGroupOptions] ); // Access the row virtualizer instance (optional) @@ -370,22 +460,36 @@ const ChannelsTable = ({ setSelectedChannels }) => { ); }; + const onRowSelectionChange = (e, test) => { + console.log(e()); + console.log(test); + setRowSelection(e); + }; + useEffect(() => { const selectedRows = table .getSelectedRowModel() .rows.map((row) => row.original); - setSelectedChannels(selectedRows); + setChannelsPageSelection(selectedRows); }, [rowSelection]); - // Configure the MaterialReactTable + const filteredData = Object.values(channels).filter((row) => + columns.every(({ accessorKey }) => + filterValues[accessorKey] + ? row[accessorKey]?.toLowerCase().includes(filterValues[accessorKey]) + : true + ) + ); + const table = useMaterialReactTable({ ...TableHelper.defaultProperties, columns, - data: Object.values(channels), + data: filteredData, enablePagination: false, + enableColumnActions: false, enableRowVirtualization: true, enableRowSelection: true, - onRowSelectionChange: setRowSelection, + onRowSelectionChange: onRowSelectionChange, onSortingChange: setSorting, state: { isLoading: isLoading || channelsLoading, @@ -400,18 +504,21 @@ const ChannelsTable = ({ setSelectedChannels }) => { enableRowActions: true, enableExpandAll: false, displayColumnDefOptions: { + 'mrt-row-select': { + size: 50, // Set custom width (default is ~40px) + }, 'mrt-row-expand': { size: 10, // Set custom width (default is ~40px) header: '', muiTableHeadCellProps: { - sx: { width: 30, minWidth: 30, maxWidth: 30 }, + sx: { width: 38, minWidth: 38, maxWidth: 38, height: '100%' }, }, muiTableBodyCellProps: { - sx: { width: 30, minWidth: 30, maxWidth: 30 }, + sx: { width: 38, minWidth: 38, maxWidth: 38 }, }, }, 'mrt-row-actions': { - size: 50, // Set custom width (default is ~40px) + size: 68, // Set custom width (default is ~40px) }, }, muiExpandButtonProps: ({ row, table }) => ({ @@ -429,32 +536,40 @@ const ChannelsTable = ({ setSelectedChannels }) => { ), renderRowActions: ({ row }) => ( - { - editChannel(row.original); - }} - sx={{ p: 0 }} - > - - - deleteChannel(row.original.id)} - sx={{ p: 0 }} - > - - - handleWatchStream(row.original.channel_number)} - sx={{ p: 0 }} - > - - + + { + editChannel(row.original); + }} + sx={{ py: 0, px: 0.5 }} + > + + + + + + deleteChannel(row.original.id)} + sx={{ py: 0, px: 0.5 }} + > + + + + + + handleWatchStream(row.original.channel_number)} + sx={{ py: 0, px: 0.5 }} + > + + + ), muiTableContainerProps: { diff --git a/frontend/src/components/tables/StreamsTable.js b/frontend/src/components/tables/StreamsTable.js index ebdd8f19..062b606a 100644 --- a/frontend/src/components/tables/StreamsTable.js +++ b/frontend/src/components/tables/StreamsTable.js @@ -10,6 +10,11 @@ import { IconButton, Tooltip, Button, + Menu, + MenuItem, + TextField, + Autocomplete, + InputAdornment, } from '@mui/material'; import useStreamsStore from '../../store/streams'; import API from '../../api'; @@ -17,30 +22,158 @@ import { Delete as DeleteIcon, Edit as EditIcon, Add as AddIcon, + MoreVert as MoreVertIcon, + PlaylistAdd as PlaylistAddIcon, + Clear as ClearIcon, } from '@mui/icons-material'; import { TableHelper } from '../../helpers'; import StreamForm from '../forms/Stream'; import usePlaylistsStore from '../../store/playlists'; +import useChannelsStore from '../../store/channels'; -const StreamsTable = ({ selectedChannels }) => { +const StreamsTable = ({}) => { const [rowSelection, setRowSelection] = useState([]); const [stream, setStream] = useState(null); const [modalOpen, setModalOpen] = useState(false); + const [moreActionsAnchorEl, setMoreActionsAnchorEl] = useState(null); + const [filterValues, setFilterValues] = useState({}); + const [groupOptions, setGroupOptions] = useState([]); + const [m3uOptions, setM3uOptions] = useState([]); + const [actionsOpenRow, setActionsOpenRow] = useState(null); + const { streams, isLoading: streamsLoading } = useStreamsStore(); const { playlists } = usePlaylistsStore(); + const { channelsPageSelection } = useChannelsStore(); + + const isMoreActionsOpen = Boolean(moreActionsAnchorEl); + + const handleFilterChange = (columnId, value) => { + setFilterValues((prev) => { + return { + ...prev, + [columnId]: value ? value.toLowerCase() : '', + }; + }); + }; + + useEffect(() => { + setGroupOptions([...new Set(streams.map((stream) => stream.group_name))]); + setM3uOptions([...new Set(playlists.map((playlist) => playlist.name))]); + }, [streams, playlists]); const columns = useMemo( () => [ - { header: 'Name', accessorKey: 'name' }, - { header: 'Group', accessorKey: 'group_name' }, + { + header: 'Name', + accessorKey: 'name', + muiTableHeadCellProps: { + sx: { textAlign: 'center' }, // Center-align the header + }, + Header: ({ column }) => ( + e.stopPropagation()} + onChange={(e) => handleFilterChange(column.id, e.target.value)} + size="small" + margin="none" + fullWidth + sx={ + { + // '& .MuiInputBase-root': { fontSize: '0.875rem' }, // Text size + // '& .MuiInputLabel-root': { fontSize: '0.75rem' }, // Label size + // width: '200px', // Optional: Adjust width + } + } + slotProps={{ + input: { + endAdornment: ( + + handleFilterChange(column.id, '')} // Clear text on click + edge="end" + size="small" + sx={{ p: 0 }} + > + + + + ), + }, + }} + /> + ), + meta: { + filterVariant: null, + }, + }, + { + header: 'Group', + accessorKey: 'group_name', + Header: ({ column }) => ( + + handleFilterChange(column.id, newValue) + } + renderInput={(params) => ( + e.stopPropagation()} + sx={{ + pb: 0.8, + '& .MuiInputBase-root': { fontSize: '0.875rem' }, // Text size + '& .MuiInputLabel-root': { fontSize: '0.75rem' }, // Label size + width: '200px', // Optional: Adjust width + }} + /> + )} + /> + ), + }, { header: 'M3U', size: 100, accessorFn: (row) => playlists.find((playlist) => playlist.id === row.m3u_account)?.name, + Header: ({ column }) => ( + + handleFilterChange(column.id, newValue) + } + renderInput={(params) => ( + e.stopPropagation()} + sx={{ + pb: 0.8, + '& .MuiInputBase-root': { fontSize: '0.875rem' }, // Text size + '& .MuiInputLabel-root': { fontSize: '0.75rem' }, // Label size + width: '200px', // Optional: Adjust width + }} + /> + )} + /> + ), }, ], - [playlists] + [playlists, groupOptions, m3uOptions] ); const rowVirtualizerInstanceRef = useRef(null); @@ -110,8 +243,8 @@ const StreamsTable = ({ selectedChannels }) => { } }, [sorting]); - const addStreamsToChannel = async (stream) => { - const channel = selectedChannels[0]; + const addStreamsToChannel = async () => { + const channel = channelsPageSelection[0]; const selectedRows = table.getSelectedRowModel().rows; await API.updateChannel({ ...channel, @@ -123,10 +256,36 @@ const StreamsTable = ({ selectedChannels }) => { }); }; + const addStreamToChannel = async (streamId) => { + const channel = channelsPageSelection[0]; + await API.updateChannel({ + ...channel, + streams: [...new Set(channel.stream_ids.concat([streamId]))], + }); + }; + + const handleMoreActionsClick = (event, rowId) => { + setMoreActionsAnchorEl(event.currentTarget); + setActionsOpenRow(rowId); + }; + + const handleMoreActionsClose = () => { + setMoreActionsAnchorEl(null); + setActionsOpenRow(null); + }; + + const filteredData = streams.filter((row) => + columns.every(({ accessorKey }) => + filterValues[accessorKey] + ? row[accessorKey]?.toLowerCase().includes(filterValues[accessorKey]) + : true + ) + ); + const table = useMaterialReactTable({ ...TableHelper.defaultProperties, columns, - data: streams, + data: filteredData, enablePagination: false, enableRowVirtualization: true, enableRowSelection: true, @@ -140,33 +299,57 @@ const StreamsTable = ({ selectedChannels }) => { rowVirtualizerInstanceRef, rowVirtualizerOptions: { overscan: 5 }, enableRowActions: true, + positionActionsColumn: 'first', renderRowActions: ({ row }) => ( <> + + addStreamToChannel(row.original.id)} + sx={{ py: 0, px: 0.5 }} + disabled={ + channelsPageSelection.length != 1 || + channelsPageSelection[0]?.stream_ids.includes(row.original.id) + } + > + + + + + + createChannelFromStream(row.original)} + sx={{ py: 0, px: 0.5 }} + > + + + + handleMoreActionsClick(event, row.original.id)} size="small" - color="warning" - onClick={() => editStream(row.original)} - disabled={row.original.m3u_account ? true : false} - sx={{ p: 0 }} + sx={{ py: 0, px: 0.5 }} > - + - deleteStream(row.original.id)} - sx={{ p: 0 }} + - - - createChannelFromStream(row.original)} - sx={{ p: 0 }} - > - - + editStream(row.original.id)} + disabled={row.original.m3u_account ? true : false} + > + Edit + + deleteStream(row.original.id)}> + Delete Stream + + ), muiTableContainerProps: { @@ -175,6 +358,14 @@ const StreamsTable = ({ selectedChannels }) => { overflowY: 'auto', }, }, + displayColumnDefOptions: { + 'mrt-row-actions': { + size: 68, + }, + 'mrt-row-select': { + size: 50, + }, + }, renderTopToolbarCustomActions: ({ table }) => { const selectedRowCount = table.getSelectedRowModel().rows.length; @@ -216,7 +407,9 @@ const StreamsTable = ({ selectedChannels }) => { onClick={addStreamsToChannel} size="small" sx={{ marginLeft: 1 }} - disabled={selectedChannels.length != 1 || selectedRowCount == 0} + disabled={ + channelsPageSelection.length != 1 || selectedRowCount == 0 + } > Add to Channel diff --git a/frontend/src/helpers/table.js b/frontend/src/helpers/table.js index da4ee2c7..9f898843 100644 --- a/frontend/src/helpers/table.js +++ b/frontend/src/helpers/table.js @@ -5,9 +5,12 @@ export default { enableDensityToggle: false, enableFullScreenToggle: false, positionToolbarAlertBanner: 'none', - columnFilterDisplayMode: 'popover', + // columnFilterDisplayMode: 'popover', enableRowNumbers: false, positionActionsColumn: 'last', + enableColumnActions: false, + enableColumnFilters: false, + enableGlobalFilter: false, initialState: { density: 'compact', }, diff --git a/frontend/src/pages/Channels.js b/frontend/src/pages/Channels.js index 30a8f458..b4c00028 100644 --- a/frontend/src/pages/Channels.js +++ b/frontend/src/pages/Channels.js @@ -4,8 +4,6 @@ import StreamsTable from '../components/tables/StreamsTable'; import { Grid2, Box } from '@mui/material'; const ChannelsPage = () => { - const [selectedChannels, setSelectedChannels] = useState([]); - return ( @@ -20,7 +18,7 @@ const ChannelsPage = () => { overflow: 'hidden', // Prevent parent scrolling }} > - + @@ -35,7 +33,7 @@ const ChannelsPage = () => { overflow: 'hidden', // Prevent parent scrolling }} > - + diff --git a/frontend/src/pages/Guide.js b/frontend/src/pages/Guide.js index 27013e8b..d60ec8ba 100644 --- a/frontend/src/pages/Guide.js +++ b/frontend/src/pages/Guide.js @@ -18,7 +18,7 @@ import dayjs from 'dayjs'; import API from '../api'; import useChannelsStore from '../store/channels'; import logo from '../images/logo.png'; -import useVideoStore from '../store/video'; // NEW import +import useVideoStore from '../store/useVideoStore'; // NEW import import useAlertStore from '../store/alerts'; import useSettingsStore from '../store/settings'; diff --git a/frontend/src/store/channels.js b/frontend/src/store/channels.js index 6f834705..dde00729 100644 --- a/frontend/src/store/channels.js +++ b/frontend/src/store/channels.js @@ -4,6 +4,7 @@ import api from '../api'; const useChannelsStore = create((set) => ({ channels: [], channelGroups: [], + channelsPageSelection: [], isLoading: false, error: null, @@ -80,6 +81,9 @@ const useChannelsStore = create((set) => ({ group.id === channelGroup.id ? channelGroup : group ), })), + + setChannelsPageSelection: (channelsPageSelection) => + set((state) => ({ channelsPageSelection })), })); export default useChannelsStore; diff --git a/frontend/src/store/video.js b/frontend/src/store/useVideoStore.js similarity index 100% rename from frontend/src/store/video.js rename to frontend/src/store/useVideoStore.js