mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +00:00
922 lines
25 KiB
JavaScript
922 lines
25 KiB
JavaScript
import React, {
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
useCallback,
|
|
} from 'react';
|
|
import API from '../../api';
|
|
import usePlaylistsStore from '../../store/playlists';
|
|
import M3UForm from '../forms/M3U';
|
|
import { TableHelper } from '../../helpers';
|
|
import {
|
|
useMantineTheme,
|
|
Paper,
|
|
Button,
|
|
Flex,
|
|
Text,
|
|
Box,
|
|
ActionIcon,
|
|
Tooltip,
|
|
Switch,
|
|
Progress,
|
|
Stack,
|
|
Badge,
|
|
Group,
|
|
Center,
|
|
} from '@mantine/core';
|
|
import {
|
|
SquareMinus,
|
|
SquarePen,
|
|
RefreshCcw,
|
|
Check,
|
|
X,
|
|
ArrowUpDown,
|
|
ArrowUpNarrowWide,
|
|
ArrowDownWideNarrow,
|
|
} from 'lucide-react';
|
|
import { IconSquarePlus } from '@tabler/icons-react'; // Import custom icons
|
|
import dayjs from 'dayjs';
|
|
import useSettingsStore from '../../store/settings';
|
|
import useLocalStorage from '../../hooks/useLocalStorage';
|
|
import ConfirmationDialog from '../../components/ConfirmationDialog';
|
|
import useWarningsStore from '../../store/warnings';
|
|
import { CustomTable, useTable } from './CustomTable';
|
|
|
|
// Helper function to format status text
|
|
const formatStatusText = (status) => {
|
|
switch (status) {
|
|
case 'idle':
|
|
return 'Idle';
|
|
case 'fetching':
|
|
return 'Fetching';
|
|
case 'parsing':
|
|
return 'Parsing';
|
|
case 'error':
|
|
return 'Error';
|
|
case 'success':
|
|
return 'Success';
|
|
case 'pending_setup':
|
|
return 'Pending Setup';
|
|
default:
|
|
return status
|
|
? status.charAt(0).toUpperCase() + status.slice(1)
|
|
: 'Unknown';
|
|
}
|
|
};
|
|
|
|
// Helper function to get status text color
|
|
const getStatusColor = (status) => {
|
|
switch (status) {
|
|
case 'idle':
|
|
return 'gray.5';
|
|
case 'fetching':
|
|
return 'blue.5';
|
|
case 'parsing':
|
|
return 'indigo.5';
|
|
case 'error':
|
|
return 'red.5';
|
|
case 'success':
|
|
return 'green.5';
|
|
case 'pending_setup':
|
|
return 'orange.5'; // Orange to indicate action needed
|
|
default:
|
|
return 'gray.5';
|
|
}
|
|
};
|
|
|
|
const RowActions = ({
|
|
tableSize,
|
|
editPlaylist,
|
|
deletePlaylist,
|
|
row,
|
|
refreshPlaylist,
|
|
}) => {
|
|
const iconSize =
|
|
tableSize == 'default' ? 'sm' : tableSize == 'compact' ? 'xs' : 'md';
|
|
|
|
return (
|
|
<>
|
|
<ActionIcon
|
|
variant="transparent"
|
|
size={iconSize}
|
|
color="yellow.5"
|
|
onClick={() => {
|
|
editPlaylist(row.original);
|
|
}}
|
|
>
|
|
<SquarePen size={tableSize === 'compact' ? 16 : 18} />
|
|
</ActionIcon>
|
|
<ActionIcon
|
|
variant="transparent"
|
|
size={iconSize}
|
|
color="red.9"
|
|
onClick={() => deletePlaylist(row.original.id)}
|
|
>
|
|
<SquareMinus size={tableSize === 'compact' ? 16 : 18} />
|
|
</ActionIcon>
|
|
<ActionIcon
|
|
variant="transparent"
|
|
size={iconSize}
|
|
color="blue.5"
|
|
onClick={() => refreshPlaylist(row.original.id)}
|
|
disabled={!row.original.is_active}
|
|
>
|
|
<RefreshCcw size={tableSize === 'compact' ? 16 : 18} />
|
|
</ActionIcon>
|
|
</>
|
|
);
|
|
};
|
|
|
|
const M3UTable = () => {
|
|
const [playlist, setPlaylist] = useState(null);
|
|
const [playlistModalOpen, setPlaylistModalOpen] = useState(false);
|
|
const [groupFilterModalOpen, setGroupFilterModalOpen] = useState(false);
|
|
const [rowSelection, setRowSelection] = useState([]);
|
|
const [activeFilterValue, setActiveFilterValue] = useState('all');
|
|
const [playlistCreated, setPlaylistCreated] = useState(false);
|
|
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
|
const [deleteTarget, setDeleteTarget] = useState(null);
|
|
const [playlistToDelete, setPlaylistToDelete] = useState(null);
|
|
const [data, setData] = useState([]);
|
|
const [sorting, setSorting] = useState([{ id: 'name', desc: '' }]);
|
|
|
|
const playlists = usePlaylistsStore((s) => s.playlists);
|
|
const refreshProgress = usePlaylistsStore((s) => s.refreshProgress);
|
|
const setRefreshProgress = usePlaylistsStore((s) => s.setRefreshProgress);
|
|
const editPlaylistId = usePlaylistsStore((s) => s.editPlaylistId);
|
|
const setEditPlaylistId = usePlaylistsStore((s) => s.setEditPlaylistId);
|
|
const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
|
|
const suppressWarning = useWarningsStore((s) => s.suppressWarning);
|
|
|
|
const theme = useMantineTheme();
|
|
const [tableSize] = useLocalStorage('table-size', 'default');
|
|
|
|
const generateStatusString = (data) => {
|
|
if (data.progress == 100) {
|
|
return 'Idle';
|
|
}
|
|
|
|
switch (data.action) {
|
|
case 'initializing':
|
|
return buildInitializingStats();
|
|
|
|
case 'downloading':
|
|
return buildDownloadingStats(data);
|
|
|
|
case 'processing_groups':
|
|
return buildGroupProcessingStats(data);
|
|
|
|
case 'parsing':
|
|
return buildParsingStats(data);
|
|
|
|
default:
|
|
return data.status === 'error'
|
|
? buildErrorStats(data)
|
|
: `${data.action || 'Processing'}...`;
|
|
}
|
|
};
|
|
|
|
const buildDownloadingStats = (data) => {
|
|
if (data.progress == 100) {
|
|
return 'Download complete!';
|
|
}
|
|
|
|
if (data.progress == 0) {
|
|
return 'Downloading...';
|
|
}
|
|
|
|
// Format time remaining in minutes:seconds
|
|
const timeRemaining = data.time_remaining
|
|
? `${Math.floor(data.time_remaining / 60)}:${String(Math.floor(data.time_remaining % 60)).padStart(2, '0')}`
|
|
: 'calculating...';
|
|
|
|
// Format speed with appropriate unit (KB/s or MB/s)
|
|
const speed =
|
|
data.speed >= 1024
|
|
? `${(data.speed / 1024).toFixed(2)} MB/s`
|
|
: `${Math.round(data.speed)} KB/s`;
|
|
|
|
return (
|
|
<Box>
|
|
<Flex direction="column" gap={2}>
|
|
<Flex justify="space-between" align="center">
|
|
<Text size="xs" fw={500}>
|
|
Downloading:
|
|
</Text>
|
|
<Text size="xs">{parseInt(data.progress)}%</Text>
|
|
</Flex>
|
|
<Flex justify="space-between" align="center">
|
|
<Text size="xs" fw={500}>
|
|
Speed:
|
|
</Text>
|
|
<Text size="xs">{speed}</Text>
|
|
</Flex>
|
|
<Flex justify="space-between" align="center">
|
|
<Text size="xs" fw={500}>
|
|
Time left:
|
|
</Text>
|
|
<Text size="xs">{timeRemaining}</Text>
|
|
</Flex>
|
|
</Flex>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
const buildGroupProcessingStats = (data) => {
|
|
if (data.progress == 100) {
|
|
return 'Groups processed!';
|
|
}
|
|
|
|
if (data.progress == 0) {
|
|
return 'Processing groups...';
|
|
}
|
|
|
|
// Format time displays if available
|
|
const elapsedTime = data.elapsed_time
|
|
? `${Math.floor(data.elapsed_time / 60)}:${String(Math.floor(data.elapsed_time % 60)).padStart(2, '0')}`
|
|
: null;
|
|
|
|
return (
|
|
<Box>
|
|
<Flex direction="column" gap={2}>
|
|
<Flex justify="space-between" align="center">
|
|
<Text size="xs" fw={500}>
|
|
Processing groups:
|
|
</Text>
|
|
<Text size="xs">{parseInt(data.progress)}%</Text>
|
|
</Flex>
|
|
{elapsedTime && (
|
|
<Flex justify="space-between" align="center">
|
|
<Text size="xs" fw={500}>
|
|
Elapsed:
|
|
</Text>
|
|
<Text size="xs">{elapsedTime}</Text>
|
|
</Flex>
|
|
)}
|
|
{data.groups_processed && (
|
|
<Flex justify="space-between" align="center">
|
|
<Text size="xs" fw={500}>
|
|
Groups:
|
|
</Text>
|
|
<Text size="xs">{data.groups_processed}</Text>
|
|
</Flex>
|
|
)}
|
|
</Flex>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
const buildErrorStats = (data) => {
|
|
return (
|
|
<Box>
|
|
<Flex direction="column" gap={2}>
|
|
<Flex align="center">
|
|
<Text size="xs" fw={500} color="red">
|
|
Error:
|
|
</Text>
|
|
</Flex>
|
|
<Text size="xs" color="red" style={{ lineHeight: 1.3 }}>
|
|
{data.error || 'Unknown error occurred'}
|
|
</Text>
|
|
</Flex>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
const buildParsingStats = (data) => {
|
|
if (data.progress == 100) {
|
|
return 'Parsing complete!';
|
|
}
|
|
|
|
if (data.progress == 0) {
|
|
return 'Parsing...';
|
|
}
|
|
|
|
// Format time displays
|
|
const timeRemaining = data.time_remaining
|
|
? `${Math.floor(data.time_remaining / 60)}:${String(Math.floor(data.time_remaining % 60)).padStart(2, '0')}`
|
|
: 'calculating...';
|
|
|
|
const elapsedTime = data.elapsed_time
|
|
? `${Math.floor(data.elapsed_time / 60)}:${String(Math.floor(data.elapsed_time % 60)).padStart(2, '0')}`
|
|
: '0:00';
|
|
|
|
return (
|
|
<Box>
|
|
<Flex direction="column" gap={2}>
|
|
<Flex justify="space-between" align="center">
|
|
<Text size="xs" fw={500} style={{ width: '80px' }}>
|
|
Parsing:
|
|
</Text>
|
|
<Text size="xs">{parseInt(data.progress)}%</Text>
|
|
</Flex>
|
|
{data.elapsed_time && (
|
|
<Flex justify="space-between" align="center">
|
|
<Text size="xs" fw={500} style={{ width: '80px' }}>
|
|
Elapsed:
|
|
</Text>
|
|
<Text size="xs">{elapsedTime}</Text>
|
|
</Flex>
|
|
)}
|
|
{data.time_remaining && (
|
|
<Flex justify="space-between" align="center">
|
|
<Text size="xs" fw={500} style={{ width: '60px' }}>
|
|
Remaining:
|
|
</Text>
|
|
<Text size="xs">{timeRemaining}</Text>
|
|
</Flex>
|
|
)}
|
|
{data.streams_processed && (
|
|
<Flex justify="space-between" align="center">
|
|
<Text size="xs" fw={500} style={{ width: '80px' }}>
|
|
Streams:
|
|
</Text>
|
|
<Text size="xs">{data.streams_processed}</Text>
|
|
</Flex>
|
|
)}
|
|
</Flex>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
const buildInitializingStats = () => {
|
|
return (
|
|
<Box>
|
|
<Flex direction="column" gap={2}>
|
|
<Flex align="center">
|
|
<Text size="xs" fw={500}>
|
|
Initializing refresh...
|
|
</Text>
|
|
</Flex>
|
|
</Flex>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
const editPlaylist = async (playlist = null) => {
|
|
if (playlist) {
|
|
setPlaylist(playlist);
|
|
}
|
|
setPlaylistModalOpen(true);
|
|
};
|
|
|
|
const refreshPlaylist = async (id) => {
|
|
// Provide immediate visual feedback before the API call
|
|
setRefreshProgress(id, {
|
|
action: 'initializing',
|
|
progress: 0,
|
|
account: id,
|
|
type: 'm3u_refresh',
|
|
});
|
|
|
|
try {
|
|
await API.refreshPlaylist(id);
|
|
// No need to set again since WebSocket will update us once the task starts
|
|
} catch (error) {
|
|
// If the API call fails, show an error state
|
|
setRefreshProgress(id, {
|
|
action: 'error',
|
|
progress: 0,
|
|
account: id,
|
|
type: 'm3u_refresh',
|
|
error: 'Failed to start refresh task',
|
|
status: 'error',
|
|
});
|
|
}
|
|
};
|
|
|
|
const deletePlaylist = async (id) => {
|
|
// Get playlist details for the confirmation dialog
|
|
const playlist = playlists.find((p) => p.id === id);
|
|
setPlaylistToDelete(playlist);
|
|
setDeleteTarget(id);
|
|
|
|
// Skip warning if it's been suppressed
|
|
if (isWarningSuppressed('delete-m3u')) {
|
|
return executeDeletePlaylist(id);
|
|
}
|
|
|
|
setConfirmDeleteOpen(true);
|
|
};
|
|
|
|
const executeDeletePlaylist = async (id) => {
|
|
setIsLoading(true);
|
|
await API.deletePlaylist(id);
|
|
setIsLoading(false);
|
|
setConfirmDeleteOpen(false);
|
|
};
|
|
|
|
const toggleActive = async (playlist) => {
|
|
try {
|
|
// Send only the is_active field to trigger our special handling
|
|
await API.updatePlaylist(
|
|
{
|
|
id: playlist.id,
|
|
is_active: !playlist.is_active,
|
|
},
|
|
true
|
|
); // Add a new parameter to indicate this is just a toggle
|
|
} catch (error) {
|
|
console.error('Error toggling active state:', error);
|
|
}
|
|
};
|
|
|
|
const columns = useMemo(
|
|
() => [
|
|
{
|
|
header: 'Name',
|
|
accessorKey: 'name',
|
|
size: 200,
|
|
sortable: true,
|
|
},
|
|
{
|
|
header: 'Account Type',
|
|
accessorKey: 'account_type',
|
|
sortable: true,
|
|
size: 150,
|
|
cell: ({ cell }) => {
|
|
const value = cell.getValue();
|
|
return value === 'XC' ? 'XC' : 'M3U';
|
|
},
|
|
},
|
|
{
|
|
header: 'URL / File',
|
|
accessorKey: 'server_url',
|
|
cell: ({ cell, row }) => {
|
|
const value = cell.getValue() || row.original.file_path || '';
|
|
return (
|
|
<Tooltip label={value} disabled={!value}>
|
|
<div
|
|
style={{
|
|
whiteSpace: 'nowrap',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
maxWidth: '100%',
|
|
}}
|
|
>
|
|
{value}
|
|
</div>
|
|
</Tooltip>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
header: 'Max Streams',
|
|
accessorKey: 'max_streams',
|
|
sortable: true,
|
|
size: 150,
|
|
},
|
|
{
|
|
header: 'Status',
|
|
accessorKey: 'status',
|
|
size: 150,
|
|
cell: ({ cell }) => {
|
|
const value = cell.getValue();
|
|
if (!value) return null;
|
|
|
|
// Match EPG table styling with Text component - always use xs size
|
|
return (
|
|
<Text size="xs" c={getStatusColor(value)}>
|
|
{formatStatusText(value)}
|
|
</Text>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
header: 'Status Message',
|
|
accessorKey: 'last_message',
|
|
cell: ({ cell, row }) => {
|
|
const value = cell.getValue();
|
|
const data = row.original;
|
|
|
|
// Get account id to check for refresh progress
|
|
const accountId = data.id;
|
|
const progressData = refreshProgress[accountId];
|
|
|
|
// If we have active progress data for this account, show that instead
|
|
if (progressData && progressData.progress < 100) {
|
|
return (
|
|
<Box
|
|
style={{
|
|
// Use full height of the cell with proper spacing
|
|
height: '100%',
|
|
width: '100%',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'flex-start',
|
|
// Add some padding to give content room to breathe
|
|
padding: '4px 0',
|
|
}}
|
|
>
|
|
{generateStatusString(progressData)}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
// No progress data, display normal status message
|
|
if (!value) return null;
|
|
|
|
// Show error message with red styling for errors
|
|
if (data.status === 'error') {
|
|
return (
|
|
<Tooltip label={value} multiline width={300}>
|
|
<Text
|
|
c="dimmed"
|
|
size="xs"
|
|
lineClamp={2}
|
|
style={{ color: theme.colors.red[6], lineHeight: 1.3 }}
|
|
>
|
|
{value}
|
|
</Text>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
// Show success message with green styling for success
|
|
if (data.status === 'success') {
|
|
return (
|
|
<Tooltip label={value} multiline width={300}>
|
|
<Text
|
|
c="dimmed"
|
|
size="xs"
|
|
style={{
|
|
color: theme.colors.green[6],
|
|
lineHeight: 1.3,
|
|
whiteSpace: 'nowrap',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
}}
|
|
>
|
|
{value}
|
|
</Text>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
// For all other status values, just use dimmed text
|
|
return (
|
|
<Tooltip label={value} multiline width={300}>
|
|
<Text
|
|
c="dimmed"
|
|
size="xs"
|
|
lineClamp={2}
|
|
style={{ lineHeight: 1.1 }}
|
|
>
|
|
{value}
|
|
</Text>
|
|
</Tooltip>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
header: 'Updated',
|
|
accessorKey: 'updated_at',
|
|
size: 150,
|
|
cell: ({ cell }) => {
|
|
const value = cell.getValue();
|
|
return value ? (
|
|
<Text size="xs">{new Date(value).toLocaleString()}</Text>
|
|
) : (
|
|
<Text size="xs">Never</Text>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
header: 'Active',
|
|
accessorKey: 'is_active',
|
|
size: 50,
|
|
cell: ({ cell, row }) => {
|
|
return (
|
|
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
|
|
<Switch
|
|
size="xs"
|
|
checked={cell.getValue()}
|
|
onChange={() => toggleActive(row.original)}
|
|
/>
|
|
</Box>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
id: 'actions',
|
|
header: 'Actions',
|
|
size: tableSize == 'compact' ? 75 : 100,
|
|
},
|
|
],
|
|
[refreshPlaylist, editPlaylist, deletePlaylist, toggleActive]
|
|
);
|
|
|
|
//optionally access the underlying virtualizer instance
|
|
const rowVirtualizerInstanceRef = useRef(null);
|
|
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
|
|
const closeModal = (newPlaylist = null) => {
|
|
if (newPlaylist) {
|
|
setPlaylistCreated(true);
|
|
setPlaylist(newPlaylist);
|
|
} else {
|
|
setPlaylistModalOpen(false);
|
|
setPlaylist(null);
|
|
setPlaylistCreated(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
//scroll to the top of the table when the sorting changes
|
|
try {
|
|
rowVirtualizerInstanceRef.current?.scrollToIndex?.(0);
|
|
} catch (error) {
|
|
console.error(error);
|
|
}
|
|
}, [sorting]);
|
|
|
|
// Listen for edit playlist requests from notifications
|
|
useEffect(() => {
|
|
setData(
|
|
playlists
|
|
.filter((playlist) => playlist.locked === false)
|
|
.sort((a, b) => {
|
|
// First sort by active status (active items first)
|
|
if (a.is_active !== b.is_active) {
|
|
return a.is_active ? -1 : 1;
|
|
}
|
|
// Then sort by name (case-insensitive)
|
|
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
|
})
|
|
);
|
|
|
|
if (editPlaylistId) {
|
|
const playlistToEdit = playlists.find((p) => p.id === editPlaylistId);
|
|
if (playlistToEdit) {
|
|
editPlaylist(playlistToEdit);
|
|
// Reset the ID after handling
|
|
setEditPlaylistId(null);
|
|
}
|
|
}
|
|
}, [editPlaylistId, playlists]);
|
|
|
|
const onSortingChange = (column) => {
|
|
console.log(column);
|
|
const sortField = sorting[0]?.id;
|
|
const sortDirection = sorting[0]?.desc;
|
|
|
|
const newSorting = [];
|
|
if (sortField == column) {
|
|
if (sortDirection == false) {
|
|
newSorting[0] = {
|
|
id: column,
|
|
desc: true,
|
|
};
|
|
}
|
|
} else {
|
|
newSorting[0] = {
|
|
id: column,
|
|
desc: false,
|
|
};
|
|
}
|
|
|
|
setSorting(newSorting);
|
|
if (newSorting.length > 0) {
|
|
const compareColumn = newSorting[0].id;
|
|
const compareDesc = newSorting[0].desc;
|
|
|
|
setData(
|
|
playlists
|
|
.filter((playlist) => playlist.locked === false)
|
|
.sort((a, b) => {
|
|
console.log(a);
|
|
console.log(newSorting[0].id);
|
|
if (a[compareColumn] !== b[compareColumn]) {
|
|
return compareDesc ? 1 : -1;
|
|
}
|
|
|
|
return 0;
|
|
})
|
|
);
|
|
}
|
|
};
|
|
|
|
const renderHeaderCell = (header) => {
|
|
let sortingIcon = ArrowUpDown;
|
|
if (sorting[0]?.id == header.id) {
|
|
if (sorting[0].desc === false) {
|
|
sortingIcon = ArrowUpNarrowWide;
|
|
} else {
|
|
sortingIcon = ArrowDownWideNarrow;
|
|
}
|
|
}
|
|
|
|
switch (header.id) {
|
|
default:
|
|
return (
|
|
<Group>
|
|
<Text size="sm" name={header.id}>
|
|
{header.column.columnDef.header}
|
|
</Text>
|
|
{header.column.columnDef.sortable && (
|
|
<Center>
|
|
{React.createElement(sortingIcon, {
|
|
onClick: () => onSortingChange(header.id),
|
|
size: 14,
|
|
})}
|
|
</Center>
|
|
)}
|
|
</Group>
|
|
);
|
|
}
|
|
};
|
|
|
|
const renderBodyCell = useCallback(({ cell, row }) => {
|
|
switch (cell.column.id) {
|
|
case 'actions':
|
|
return (
|
|
<RowActions
|
|
tableSize={tableSize}
|
|
editPlaylist={editPlaylist}
|
|
deletePlaylist={deletePlaylist}
|
|
row={row}
|
|
refreshPlaylist={refreshPlaylist}
|
|
/>
|
|
);
|
|
}
|
|
}, []);
|
|
|
|
const table = useTable({
|
|
columns,
|
|
// Sort data before passing to table: active first, then by name
|
|
data,
|
|
allRowIds: data.map((playlist) => playlist.id),
|
|
enablePagination: false,
|
|
enableRowVirtualization: true,
|
|
enableRowSelection: false,
|
|
onRowSelectionChange: setRowSelection,
|
|
renderTopToolbar: false,
|
|
sorting,
|
|
manualSorting: true,
|
|
rowVirtualizerInstanceRef, //optional
|
|
rowVirtualizerOptions: { overscan: 5 }, //optionally customize the row virtualizer
|
|
bodyCellRenderFns: {
|
|
actions: renderBodyCell,
|
|
},
|
|
headerCellRenderFns: {
|
|
name: renderHeaderCell,
|
|
account_type: renderHeaderCell,
|
|
server_url: renderHeaderCell,
|
|
max_streams: renderHeaderCell,
|
|
status: renderHeaderCell,
|
|
last_message: renderHeaderCell,
|
|
updated_at: renderHeaderCell,
|
|
is_active: renderHeaderCell,
|
|
actions: renderHeaderCell,
|
|
},
|
|
mantineTableContainerProps: {
|
|
style: {
|
|
height: 'calc(40vh - 10px)',
|
|
overflowX: 'auto', // Ensure horizontal scrolling works
|
|
},
|
|
},
|
|
mantineTableProps: {
|
|
...TableHelper.defaultProperties.mantineTableProps,
|
|
className: `table-size-${tableSize}`,
|
|
},
|
|
// Add custom cell styles to match CustomTable's sizing
|
|
tableCellProps: ({ cell }) => {
|
|
return {
|
|
fontSize:
|
|
tableSize === 'compact'
|
|
? 'var(--mantine-font-size-xs)'
|
|
: 'var(--mantine-font-size-sm)',
|
|
padding: tableSize === 'compact' ? '2px 8px' : '4px 10px',
|
|
};
|
|
},
|
|
// Additional text styling to match ChannelsTable
|
|
tableBodyProps: () => ({
|
|
fontSize:
|
|
tableSize === 'compact'
|
|
? 'var(--mantine-font-size-xs)'
|
|
: 'var(--mantine-font-size-sm)',
|
|
}),
|
|
});
|
|
|
|
return (
|
|
<Box>
|
|
<Flex
|
|
style={{ display: 'flex', alignItems: 'center', paddingBottom: 10 }}
|
|
gap={15}
|
|
>
|
|
<Text
|
|
h={24}
|
|
style={{
|
|
fontFamily: 'Inter, sans-serif',
|
|
fontWeight: 500,
|
|
fontSize: '20px',
|
|
lineHeight: 1,
|
|
letterSpacing: '-0.3px',
|
|
color: 'gray.6', // Adjust this to match MUI's theme.palette.text.secondary
|
|
marginBottom: 0,
|
|
}}
|
|
>
|
|
M3U Accounts
|
|
</Text>
|
|
</Flex>
|
|
|
|
<Paper
|
|
style={{
|
|
bgcolor: theme.palette.background.paper,
|
|
borderRadius: 2,
|
|
}}
|
|
>
|
|
{/* Top toolbar with Remove, Assign, Auto-match, and Add buttons */}
|
|
<Box
|
|
style={{
|
|
display: 'flex',
|
|
// alignItems: 'center',
|
|
// backgroundColor: theme.palette.background.paper,
|
|
justifyContent: 'flex-end',
|
|
padding: 10,
|
|
// gap: 1,
|
|
}}
|
|
>
|
|
<Flex gap={6}>
|
|
<Tooltip label="Assign">
|
|
<Button
|
|
leftSection={<IconSquarePlus size={14} />}
|
|
variant="light"
|
|
size="xs"
|
|
onClick={() => editPlaylist()}
|
|
p={5}
|
|
color="green"
|
|
style={{
|
|
borderWidth: '1px',
|
|
borderColor: 'green',
|
|
color: 'white',
|
|
}}
|
|
>
|
|
Add
|
|
</Button>
|
|
</Tooltip>
|
|
</Flex>
|
|
</Box>
|
|
</Paper>
|
|
|
|
<Box
|
|
style={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
height: 'calc(40vh - 10px)',
|
|
}}
|
|
>
|
|
<Box
|
|
style={{
|
|
flex: 1,
|
|
overflowY: 'auto',
|
|
overflowX: 'hidden',
|
|
border: 'solid 1px rgb(68,68,68)',
|
|
borderRadius: 'var(--mantine-radius-default)',
|
|
}}
|
|
>
|
|
<CustomTable table={table} />
|
|
</Box>
|
|
</Box>
|
|
|
|
<M3UForm
|
|
m3uAccount={playlist}
|
|
isOpen={playlistModalOpen}
|
|
onClose={closeModal}
|
|
playlistCreated={playlistCreated}
|
|
/>
|
|
|
|
<ConfirmationDialog
|
|
opened={confirmDeleteOpen}
|
|
onClose={() => setConfirmDeleteOpen(false)}
|
|
onConfirm={() => executeDeletePlaylist(deleteTarget)}
|
|
title="Confirm M3U Account Deletion"
|
|
message={
|
|
playlistToDelete ? (
|
|
<div style={{ whiteSpace: 'pre-line' }}>
|
|
{`Are you sure you want to delete the following M3U account?
|
|
|
|
Name: ${playlistToDelete.name}
|
|
Type: ${playlistToDelete.account_type === 'XC' ? 'Xtream Codes' : 'Standard'}
|
|
Server: ${playlistToDelete.server_url || 'Local file'}
|
|
|
|
This will remove all related streams and may affect channels using these streams.
|
|
This action cannot be undone.`}
|
|
</div>
|
|
) : (
|
|
'Are you sure you want to delete this M3U account? This action cannot be undone.'
|
|
)
|
|
}
|
|
confirmLabel="Delete"
|
|
cancelLabel="Cancel"
|
|
actionKey="delete-m3u"
|
|
onSuppressChange={suppressWarning}
|
|
size="lg"
|
|
/>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
export default M3UTable;
|