mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-21 01:05:30 +00:00
785 lines
22 KiB
JavaScript
785 lines
22 KiB
JavaScript
import React, {
|
|
useMemo,
|
|
useState,
|
|
useEffect,
|
|
useCallback,
|
|
useRef,
|
|
} from 'react';
|
|
import API from '../../api';
|
|
import { copyToClipboard } from '../../utils';
|
|
import {
|
|
GripHorizontal,
|
|
SquareMinus,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
Eye,
|
|
} from 'lucide-react';
|
|
import {
|
|
Box,
|
|
ActionIcon,
|
|
Flex,
|
|
Text,
|
|
useMantineTheme,
|
|
Center,
|
|
Badge,
|
|
Group,
|
|
Tooltip,
|
|
Collapse,
|
|
Button,
|
|
} from '@mantine/core';
|
|
import {
|
|
useReactTable,
|
|
getCoreRowModel,
|
|
flexRender,
|
|
} from '@tanstack/react-table';
|
|
import './table.css';
|
|
import useChannelsTableStore from '../../store/channelsTable';
|
|
import usePlaylistsStore from '../../store/playlists';
|
|
import useVideoStore from '../../store/useVideoStore';
|
|
import useSettingsStore from '../../store/settings';
|
|
import {
|
|
DndContext,
|
|
KeyboardSensor,
|
|
MouseSensor,
|
|
TouchSensor,
|
|
closestCenter,
|
|
useDraggable,
|
|
useSensor,
|
|
useSensors,
|
|
} from '@dnd-kit/core';
|
|
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
|
|
import {
|
|
arrayMove,
|
|
SortableContext,
|
|
verticalListSortingStrategy,
|
|
} from '@dnd-kit/sortable';
|
|
import { useSortable } from '@dnd-kit/sortable';
|
|
import { CSS } from '@dnd-kit/utilities';
|
|
import { shallow } from 'zustand/shallow';
|
|
import useAuthStore from '../../store/auth';
|
|
import { USER_LEVELS } from '../../constants';
|
|
|
|
// ── Static values (created once, shared across all instances) ────────────────
|
|
|
|
const coreRowModel = getCoreRowModel();
|
|
|
|
const defaultColumnConfig = {
|
|
size: undefined,
|
|
minSize: 0,
|
|
};
|
|
|
|
const categoryMapping = {
|
|
basic: [
|
|
'resolution',
|
|
'video_codec',
|
|
'source_fps',
|
|
'audio_codec',
|
|
'audio_channels',
|
|
],
|
|
video: [
|
|
'video_bitrate',
|
|
'pixel_format',
|
|
'width',
|
|
'height',
|
|
'aspect_ratio',
|
|
'frame_rate',
|
|
],
|
|
audio: [
|
|
'audio_bitrate',
|
|
'sample_rate',
|
|
'audio_format',
|
|
'audio_channels_layout',
|
|
],
|
|
technical: [
|
|
'stream_type',
|
|
'container_format',
|
|
'duration',
|
|
'file_size',
|
|
'ffmpeg_output_bitrate',
|
|
'input_bitrate',
|
|
],
|
|
other: [],
|
|
};
|
|
|
|
const categorizeStreamStats = (stats) => {
|
|
if (!stats)
|
|
return { basic: {}, video: {}, audio: {}, technical: {}, other: {} };
|
|
|
|
const categories = {
|
|
basic: {},
|
|
video: {},
|
|
audio: {},
|
|
technical: {},
|
|
other: {},
|
|
};
|
|
|
|
Object.entries(stats).forEach(([key, value]) => {
|
|
let categorized = false;
|
|
for (const [category, keys] of Object.entries(categoryMapping)) {
|
|
if (keys.includes(key)) {
|
|
categories[category][key] = value;
|
|
categorized = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!categorized) {
|
|
categories.other[key] = value;
|
|
}
|
|
});
|
|
|
|
return categories;
|
|
};
|
|
|
|
const formatStatValue = (key, value) => {
|
|
if (value === null || value === undefined) return 'N/A';
|
|
|
|
switch (key) {
|
|
case 'video_bitrate':
|
|
case 'audio_bitrate':
|
|
case 'ffmpeg_output_bitrate':
|
|
return `${value} kbps`;
|
|
case 'source_fps':
|
|
case 'frame_rate':
|
|
return `${value} fps`;
|
|
case 'sample_rate':
|
|
return `${value} Hz`;
|
|
case 'file_size':
|
|
if (typeof value === 'number') {
|
|
if (value < 1024) return `${value} B`;
|
|
if (value < 1024 * 1024) return `${(value / 1024).toFixed(2)} KB`;
|
|
if (value < 1024 * 1024 * 1024)
|
|
return `${(value / (1024 * 1024)).toFixed(2)} MB`;
|
|
return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
|
}
|
|
return value;
|
|
case 'duration':
|
|
if (typeof value === 'number') {
|
|
const hours = Math.floor(value / 3600);
|
|
const minutes = Math.floor((value % 3600) / 60);
|
|
const seconds = Math.floor(value % 60);
|
|
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
|
}
|
|
return value;
|
|
default:
|
|
return value.toString();
|
|
}
|
|
};
|
|
|
|
// ── Sub-components ───────────────────────────────────────────────────────────
|
|
|
|
const RowDragHandleCell = ({ rowId }) => {
|
|
const { attributes, listeners, setNodeRef } = useDraggable({
|
|
id: rowId,
|
|
});
|
|
|
|
return (
|
|
<Center>
|
|
<ActionIcon
|
|
ref={setNodeRef}
|
|
{...listeners}
|
|
{...attributes}
|
|
variant="transparent"
|
|
size="xs"
|
|
style={{
|
|
cursor: 'grab', // this is enough
|
|
}}
|
|
>
|
|
<GripHorizontal color="white" />
|
|
</ActionIcon>
|
|
</Center>
|
|
);
|
|
};
|
|
|
|
// Row Component
|
|
const DraggableRow = React.memo(
|
|
({ row, index }) => {
|
|
const { transform, transition, setNodeRef, isDragging } = useSortable({
|
|
id: row.original.id,
|
|
});
|
|
|
|
const style = {
|
|
transform: CSS.Transform.toString(transform), //let dnd-kit do its thing
|
|
transition: transition,
|
|
opacity: isDragging ? 0.8 : 1,
|
|
zIndex: isDragging ? 1 : 0,
|
|
position: 'relative',
|
|
};
|
|
return (
|
|
<Box
|
|
ref={setNodeRef}
|
|
key={row.id}
|
|
className={`tr ${index % 2 == 0 ? 'tr-even' : 'tr-odd'}${row.original.is_stale ? ' stale-stream-row' : ''}`}
|
|
style={{
|
|
...style,
|
|
display: 'flex',
|
|
width: '100%',
|
|
...(row.getIsSelected() && {
|
|
backgroundColor: '#163632',
|
|
}),
|
|
}}
|
|
>
|
|
{row.getVisibleCells().map((cell) => {
|
|
return (
|
|
<Box
|
|
className="td"
|
|
key={cell.id}
|
|
style={{
|
|
flex: cell.column.columnDef.size ? '0 0 auto' : '1 1 0',
|
|
width: cell.column.columnDef.size
|
|
? cell.column.getSize()
|
|
: undefined,
|
|
minWidth: 0,
|
|
}}
|
|
>
|
|
<Flex align="center" style={{ height: '100%' }}>
|
|
<Text component="div" size="xs">
|
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
</Text>
|
|
</Flex>
|
|
</Box>
|
|
);
|
|
})}
|
|
</Box>
|
|
);
|
|
},
|
|
(prev, next) =>
|
|
prev.row.original === next.row.original && prev.index === next.index
|
|
);
|
|
|
|
// Stats category display component
|
|
const StatsCategory = ({ categoryName, stats }) => {
|
|
if (!stats || Object.keys(stats).length === 0) return null;
|
|
|
|
return (
|
|
<Box key={categoryName} mb="xs">
|
|
<Text size="xs" fw={600} mb={4} tt="uppercase" c="dimmed">
|
|
{categoryName}
|
|
</Text>
|
|
<Group gap={4} mb="xs">
|
|
{Object.entries(stats).map(([key, value]) => (
|
|
<Tooltip key={key} label={`${key}: ${formatStatValue(key, value)}`}>
|
|
<Badge size="xs" variant="light" color="gray">
|
|
{key.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())}:{' '}
|
|
{formatStatValue(key, value)}
|
|
</Badge>
|
|
</Tooltip>
|
|
))}
|
|
</Group>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
// Extracted stream info cell — reads expandedAdvancedStats from a ref
|
|
// so toggling advanced stats doesn't recreate the columns array.
|
|
const StreamInfoCell = React.memo(
|
|
({
|
|
stream,
|
|
accountName,
|
|
expandedAdvancedStatsRef,
|
|
toggleAdvancedStats,
|
|
handleWatchStream,
|
|
}) => {
|
|
const [, forceUpdate] = useState(0);
|
|
const isExpanded = expandedAdvancedStatsRef.current.has(stream.id);
|
|
|
|
const categorizedStats = useMemo(
|
|
() => categorizeStreamStats(stream.stream_stats),
|
|
[stream.stream_stats]
|
|
);
|
|
const hasAdvancedStats = useMemo(
|
|
() =>
|
|
Object.values(categorizedStats).some(
|
|
(category) => Object.keys(category).length > 0
|
|
),
|
|
[categorizedStats]
|
|
);
|
|
|
|
const onToggle = useCallback(() => {
|
|
toggleAdvancedStats(stream.id);
|
|
forceUpdate((n) => n + 1);
|
|
}, [stream.id, toggleAdvancedStats]);
|
|
|
|
return (
|
|
<Box>
|
|
<Group gap="xs" align="center">
|
|
<Text fw={500} size="sm">
|
|
{stream.name}
|
|
</Text>
|
|
<Badge size="xs" variant="light" color="teal">
|
|
{accountName}
|
|
</Badge>
|
|
{stream.quality && (
|
|
<Badge size="xs" variant="light" color="gray">
|
|
{stream.quality}
|
|
</Badge>
|
|
)}
|
|
{stream.url && (
|
|
<>
|
|
<Tooltip label={stream.url}>
|
|
<Badge
|
|
size="xs"
|
|
variant="light"
|
|
color="indigo"
|
|
style={{ cursor: 'pointer' }}
|
|
onClick={async (e) => {
|
|
e.stopPropagation();
|
|
await copyToClipboard(stream.url, {
|
|
successTitle: 'URL Copied',
|
|
successMessage: 'Stream URL copied to clipboard',
|
|
});
|
|
}}
|
|
>
|
|
URL
|
|
</Badge>
|
|
</Tooltip>
|
|
<Tooltip label="Preview Stream">
|
|
<ActionIcon
|
|
size="xs"
|
|
color="blue"
|
|
variant="light"
|
|
onClick={() =>
|
|
handleWatchStream(
|
|
stream.stream_hash || stream.id,
|
|
stream.name,
|
|
stream.id
|
|
)
|
|
}
|
|
style={{ marginLeft: 2 }}
|
|
>
|
|
<Eye size={16} />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
</>
|
|
)}
|
|
</Group>
|
|
|
|
{/* Basic Stream Stats (always shown) */}
|
|
{stream.stream_stats && (
|
|
<Group gap="xs" mt={4} align="center">
|
|
{(stream.stream_stats.video_codec ||
|
|
stream.stream_stats.resolution ||
|
|
stream.stream_stats.video_bitrate ||
|
|
stream.stream_stats.source_fps) && (
|
|
<>
|
|
<Text size="xs" c="dimmed" fw={500}>
|
|
Video:
|
|
</Text>
|
|
{stream.stream_stats.resolution && (
|
|
<Badge size="xs" variant="light" color="red">
|
|
{stream.stream_stats.resolution}
|
|
</Badge>
|
|
)}
|
|
{stream.stream_stats.video_bitrate && (
|
|
<Badge
|
|
size="xs"
|
|
variant="light"
|
|
color="orange"
|
|
style={{ textTransform: 'none' }}
|
|
>
|
|
{stream.stream_stats.video_bitrate} kbps
|
|
</Badge>
|
|
)}
|
|
{stream.stream_stats.source_fps && (
|
|
<Badge size="xs" variant="light" color="orange">
|
|
{stream.stream_stats.source_fps} FPS
|
|
</Badge>
|
|
)}
|
|
{stream.stream_stats.video_codec && (
|
|
<Badge size="xs" variant="light" color="blue">
|
|
{stream.stream_stats.video_codec.toUpperCase()}
|
|
</Badge>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{(stream.stream_stats.audio_codec ||
|
|
stream.stream_stats.audio_channels) && (
|
|
<>
|
|
<Text size="xs" c="dimmed" fw={500}>
|
|
Audio:
|
|
</Text>
|
|
{stream.stream_stats.audio_channels && (
|
|
<Badge size="xs" variant="light" color="pink">
|
|
{stream.stream_stats.audio_channels}
|
|
</Badge>
|
|
)}
|
|
{stream.stream_stats.audio_codec && (
|
|
<Badge size="xs" variant="light" color="pink">
|
|
{stream.stream_stats.audio_codec.toUpperCase()}
|
|
</Badge>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{stream.stream_stats.ffmpeg_output_bitrate && (
|
|
<>
|
|
<Text size="xs" c="dimmed" fw={500}>
|
|
Output Bitrate:
|
|
</Text>
|
|
<Badge
|
|
size="xs"
|
|
variant="light"
|
|
color="orange"
|
|
style={{ textTransform: 'none' }}
|
|
>
|
|
{stream.stream_stats.ffmpeg_output_bitrate} kbps
|
|
</Badge>
|
|
</>
|
|
)}
|
|
</Group>
|
|
)}
|
|
|
|
{hasAdvancedStats && (
|
|
<Group gap="xs" mt={6}>
|
|
<Button
|
|
variant="subtle"
|
|
size="xs"
|
|
leftSection={
|
|
isExpanded ? (
|
|
<ChevronDown size={12} />
|
|
) : (
|
|
<ChevronRight size={12} />
|
|
)
|
|
}
|
|
onClick={onToggle}
|
|
c="dimmed"
|
|
>
|
|
{isExpanded ? 'Hide' : 'Show'} Advanced Stats
|
|
</Button>
|
|
</Group>
|
|
)}
|
|
|
|
<Collapse in={isExpanded}>
|
|
<Box
|
|
mt="sm"
|
|
p="xs"
|
|
style={{
|
|
backgroundColor: 'rgba(0,0,0,0.1)',
|
|
borderRadius: '4px',
|
|
}}
|
|
>
|
|
<StatsCategory
|
|
categoryName="Video"
|
|
stats={categorizedStats.video}
|
|
/>
|
|
<StatsCategory
|
|
categoryName="Audio"
|
|
stats={categorizedStats.audio}
|
|
/>
|
|
<StatsCategory
|
|
categoryName="Technical"
|
|
stats={categorizedStats.technical}
|
|
/>
|
|
<StatsCategory
|
|
categoryName="Other"
|
|
stats={categorizedStats.other}
|
|
/>
|
|
|
|
{stream.stream_stats_updated_at && (
|
|
<Text size="xs" c="dimmed" mt="xs">
|
|
Last updated:{' '}
|
|
{new Date(stream.stream_stats_updated_at).toLocaleString()}
|
|
</Text>
|
|
)}
|
|
</Box>
|
|
</Collapse>
|
|
</Box>
|
|
);
|
|
}
|
|
);
|
|
|
|
// ── Main component ───────────────────────────────────────────────────────────
|
|
|
|
const ChannelStreams = ({ channel }) => {
|
|
const theme = useMantineTheme();
|
|
|
|
const channelStreams = useChannelsTableStore(
|
|
(state) => state.getChannelStreams(channel.id),
|
|
shallow
|
|
);
|
|
const patchChannelStreamStats = useChannelsTableStore(
|
|
(s) => s.patchChannelStreamStats
|
|
);
|
|
const playlists = usePlaylistsStore((s) => s.playlists);
|
|
const authUser = useAuthStore((s) => s.user);
|
|
const showVideo = useVideoStore((s) => s.showVideo);
|
|
const isVideoVisible = useVideoStore((s) => s.isVisible);
|
|
const env_mode = useSettingsStore((s) => s.environment.env_mode);
|
|
|
|
const handleWatchStream = useCallback(
|
|
(streamHash, streamName, streamId) => {
|
|
let vidUrl = `/proxy/ts/stream/${streamHash}`;
|
|
if (env_mode === 'dev') {
|
|
vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`;
|
|
}
|
|
const meta = {};
|
|
if (streamName) meta.name = streamName;
|
|
if (streamId != null) meta.streamId = streamId;
|
|
showVideo(vidUrl, 'live', Object.keys(meta).length ? meta : null);
|
|
},
|
|
[env_mode, showVideo]
|
|
);
|
|
|
|
const [data, setData] = useState(channelStreams || []);
|
|
|
|
useEffect(() => {
|
|
setData(channelStreams);
|
|
}, [channelStreams]);
|
|
|
|
// Refs so stable callbacks always see the latest values without being in deps
|
|
const channelRef = useRef(channel);
|
|
channelRef.current = channel;
|
|
const dataRef = useRef(data);
|
|
dataRef.current = data;
|
|
|
|
const dataIds = useMemo(() => data?.map(({ id }) => id), [data]);
|
|
|
|
// Fire-and-forget refresh of stream stats. Cursor is the newest
|
|
// stream_stats_updated_at already in the store; server returns only
|
|
// entries strictly newer than that (empty array when nothing changed).
|
|
const refreshStats = useCallback(
|
|
(opts) => {
|
|
const channelId = channelRef.current?.id;
|
|
if (!channelId) return;
|
|
const streams = dataRef.current || [];
|
|
let since = null;
|
|
for (const s of streams) {
|
|
const t = s.stream_stats_updated_at;
|
|
if (t && (since === null || t > since)) since = t;
|
|
}
|
|
const ids = opts && opts.ids;
|
|
API.getChannelStreamStats(channelId, since, ids).then((updates) => {
|
|
if (!updates || updates.length === 0) return;
|
|
patchChannelStreamStats(channelId, updates);
|
|
});
|
|
},
|
|
[patchChannelStreamStats]
|
|
);
|
|
|
|
// Refresh once when the row is expanded.
|
|
useEffect(() => {
|
|
refreshStats();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
// Refresh just the previewed stream when the floating player closes.
|
|
// Metadata is captured while visible because hideVideo clears it.
|
|
const prevVisibleRef = useRef(isVideoVisible);
|
|
const lastPreviewMetaRef = useRef(null);
|
|
useEffect(() => {
|
|
if (isVideoVisible) {
|
|
lastPreviewMetaRef.current = useVideoStore.getState().metadata;
|
|
}
|
|
const wasVisible = prevVisibleRef.current;
|
|
prevVisibleRef.current = isVideoVisible;
|
|
if (wasVisible && !isVideoVisible) {
|
|
const meta = lastPreviewMetaRef.current;
|
|
lastPreviewMetaRef.current = null;
|
|
const streamId = meta && meta.streamId;
|
|
if (
|
|
streamId != null &&
|
|
(dataRef.current || []).some((s) => s.id === streamId)
|
|
) {
|
|
refreshStats({ ids: [streamId] });
|
|
}
|
|
}
|
|
}, [isVideoVisible, refreshStats]);
|
|
|
|
const removeStream = useCallback(async (stream) => {
|
|
const newStreamList = dataRef.current.filter((s) => s.id !== stream.id);
|
|
setData(newStreamList);
|
|
await API.reorderChannelStreams(
|
|
channelRef.current.id,
|
|
newStreamList.map((s) => s.id)
|
|
);
|
|
}, []);
|
|
|
|
// M3U account map for quick lookup
|
|
const m3uAccountsMap = useMemo(() => {
|
|
const map = {};
|
|
if (playlists && Array.isArray(playlists)) {
|
|
playlists.forEach((account) => {
|
|
if (account.id) {
|
|
map[account.id] = account.name;
|
|
}
|
|
});
|
|
}
|
|
return map;
|
|
}, [playlists]);
|
|
|
|
// Track expanded advanced stats via ref so toggling doesn't recreate columns
|
|
const expandedAdvancedStatsRef = useRef(new Set());
|
|
|
|
const toggleAdvancedStats = useCallback((streamId) => {
|
|
const current = expandedAdvancedStatsRef.current;
|
|
if (current.has(streamId)) {
|
|
current.delete(streamId);
|
|
} else {
|
|
current.add(streamId);
|
|
}
|
|
}, []);
|
|
|
|
// Columns are now stable — they don't depend on data or expandedAdvancedStats.
|
|
// Cell renderers receive the row from TanStack at render time.
|
|
// StreamInfoCell reads the expanded stats ref directly.
|
|
const columns = useMemo(
|
|
() => [
|
|
{
|
|
id: 'drag-handle',
|
|
header: 'Move',
|
|
cell: ({ row }) => <RowDragHandleCell rowId={row.id} />,
|
|
size: 30,
|
|
},
|
|
{
|
|
id: 'name',
|
|
header: 'Stream Info',
|
|
accessorKey: 'name',
|
|
cell: ({ row }) => {
|
|
const stream = row.original;
|
|
const accountName = m3uAccountsMap[stream.m3u_account] || 'Unknown';
|
|
|
|
return (
|
|
<StreamInfoCell
|
|
stream={stream}
|
|
accountName={accountName}
|
|
expandedAdvancedStatsRef={expandedAdvancedStatsRef}
|
|
toggleAdvancedStats={toggleAdvancedStats}
|
|
handleWatchStream={handleWatchStream}
|
|
/>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
id: 'actions',
|
|
header: '',
|
|
size: 30,
|
|
cell: ({ row }) => (
|
|
<Center>
|
|
<ActionIcon variant="transparent" size="xs">
|
|
<SquareMinus
|
|
color={theme.tailwind.red[6]}
|
|
onClick={() => removeStream(row.original)}
|
|
disabled={authUser.user_level != USER_LEVELS.ADMIN}
|
|
/>
|
|
</ActionIcon>
|
|
</Center>
|
|
),
|
|
},
|
|
],
|
|
[
|
|
m3uAccountsMap,
|
|
theme,
|
|
authUser.user_level,
|
|
toggleAdvancedStats,
|
|
handleWatchStream,
|
|
]
|
|
);
|
|
|
|
const table = useReactTable({
|
|
columns,
|
|
data,
|
|
defaultColumn: defaultColumnConfig,
|
|
manualPagination: true,
|
|
manualSorting: true,
|
|
manualFiltering: true,
|
|
enableRowSelection: true,
|
|
getRowId: (row) => row.id,
|
|
getCoreRowModel: coreRowModel,
|
|
});
|
|
|
|
const handleDragEnd = useCallback(
|
|
(event) => {
|
|
if (authUser.user_level != USER_LEVELS.ADMIN) {
|
|
return;
|
|
}
|
|
|
|
const { active, over } = event;
|
|
if (active && over && active.id !== over.id) {
|
|
setData((prevData) => {
|
|
const currentIds = prevData.map(({ id }) => id);
|
|
const oldIndex = currentIds.indexOf(active.id);
|
|
const newIndex = currentIds.indexOf(over.id);
|
|
const retval = arrayMove(prevData, oldIndex, newIndex);
|
|
|
|
API.reorderChannelStreams(
|
|
channel.id,
|
|
retval.map((row) => row.id)
|
|
);
|
|
|
|
return retval;
|
|
});
|
|
}
|
|
},
|
|
[authUser.user_level, channel]
|
|
);
|
|
|
|
const sensors = useSensors(
|
|
useSensor(MouseSensor, {}),
|
|
useSensor(TouchSensor, {}),
|
|
useSensor(KeyboardSensor, {})
|
|
);
|
|
|
|
const rows = table.getRowModel().rows;
|
|
|
|
return (
|
|
<Box
|
|
className="channel-streams-container"
|
|
style={{ width: '100%', padding: 10, backgroundColor: '#163632' }}
|
|
>
|
|
<DndContext
|
|
collisionDetection={closestCenter}
|
|
modifiers={[restrictToVerticalAxis]}
|
|
onDragEnd={handleDragEnd}
|
|
sensors={sensors}
|
|
>
|
|
<Box
|
|
className="divTable table-striped"
|
|
style={{
|
|
width: '100%',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
}}
|
|
>
|
|
<Box className="tbody">
|
|
<SortableContext
|
|
items={dataIds}
|
|
strategy={verticalListSortingStrategy}
|
|
>
|
|
{rows.length === 0 && (
|
|
<Box
|
|
className="tr"
|
|
style={{
|
|
display: 'flex',
|
|
width: '100%',
|
|
}}
|
|
>
|
|
<Box
|
|
className="td"
|
|
style={{
|
|
flex: '1 1 0',
|
|
minWidth: 0,
|
|
}}
|
|
>
|
|
<Flex
|
|
align="center"
|
|
justify="center"
|
|
style={{ height: '100%' }}
|
|
>
|
|
<Text size="xs">No Data</Text>
|
|
</Flex>
|
|
</Box>
|
|
</Box>
|
|
)}
|
|
{rows.length > 0 &&
|
|
rows.map((row, index) => (
|
|
<DraggableRow key={row.id} row={row} index={index} />
|
|
))}
|
|
</SortableContext>
|
|
</Box>
|
|
</Box>
|
|
</DndContext>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
export default ChannelStreams;
|