Refactored components

This commit is contained in:
Nick Sandstrom 2026-06-27 01:38:33 -07:00
parent 1a46ca4be4
commit 28cff8e87b
16 changed files with 629 additions and 1589 deletions

View file

@ -1,64 +1,49 @@
import React, {
useMemo,
useState,
useEffect,
useCallback,
useRef,
} from 'react';
import API from '../../api';
import React, { useCallback, useEffect, useMemo, useRef, useState, } from 'react';
import { copyToClipboard } from '../../utils';
import { buildLiveStreamUrl } from '../../utils/components/FloatingVideoUtils.js';
import { ChevronDown, ChevronRight, Eye, GripHorizontal, SquareMinus, } from 'lucide-react';
import {
GripHorizontal,
SquareMinus,
ChevronDown,
ChevronRight,
Eye,
} from 'lucide-react';
import {
Box,
ActionIcon,
Flex,
Text,
useMantineTheme,
Center,
Badge,
Group,
Tooltip,
Collapse,
Box,
Button,
Center,
Collapse,
Flex,
Group,
Text,
Tooltip,
useMantineTheme,
} from '@mantine/core';
import {
useReactTable,
getCoreRowModel,
flexRender,
} from '@tanstack/react-table';
import { flexRender, getCoreRowModel, useReactTable, } 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 {
closestCenter,
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 { arrayMove, SortableContext, useSortable, verticalListSortingStrategy, } 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';
import {
categorizeStreamStats,
formatStatKey,
formatStatValue,
getChannelStreamStats,
reorderChannelStreams,
} from '../../utils/tables/ChannelTableStreamsUtils.js';
// Static values (created once, shared across all instances)
@ -69,103 +54,6 @@ const defaultColumnConfig = {
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 }) => {
@ -219,27 +107,25 @@ const DraggableRow = React.memo(
}),
}}
>
{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>
);
})}
{row.getVisibleCells().map((cell) => (
<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>
);
},
@ -260,7 +146,7 @@ const StatsCategory = ({ categoryName, stats }) => {
{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())}:{' '}
{formatStatKey(key)}:{' '}
{formatStatValue(key, value)}
</Badge>
</Tooltip>
@ -549,7 +435,7 @@ const ChannelStreams = ({ channel }) => {
if (t && (since === null || t > since)) since = t;
}
const ids = opts && opts.ids;
API.getChannelStreamStats(channelId, since, ids).then((updates) => {
getChannelStreamStats(channelId, since, ids).then((updates) => {
if (!updates || updates.length === 0) return;
patchChannelStreamStats(channelId, updates);
});
@ -596,7 +482,7 @@ const ChannelStreams = ({ channel }) => {
const removeStream = useCallback(async (stream) => {
const newStreamList = dataRef.current.filter((s) => s.id !== stream.id);
setData(newStreamList);
await API.reorderChannelStreams(
await reorderChannelStreams(
channelRef.current.id,
newStreamList.map((s) => s.id)
);
@ -709,7 +595,7 @@ const ChannelStreams = ({ channel }) => {
const newIndex = currentIds.indexOf(over.id);
const retval = arrayMove(prevData, oldIndex, newIndex);
API.reorderChannelStreams(
reorderChannelStreams(
channel.id,
retval.map((row) => row.id)
);

View file

@ -1,71 +1,59 @@
import React, {
useEffect,
useMemo,
useState,
useCallback,
useRef,
} from 'react';
import {
DndContext,
closestCenter,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
SortableContext,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import React, { useCallback, useEffect, useMemo, useRef, useState, } from 'react';
import { closestCenter, DndContext, PointerSensor, useSensor, useSensors, } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy, } from '@dnd-kit/sortable';
import useChannelsStore from '../../store/channels';
import API from '../../api';
import ChannelForm from '../forms/Channel';
import ChannelBatchForm from '../forms/ChannelBatch';
import RecordingForm from '../forms/Recording';
import { useDebounce, copyToClipboard } from '../../utils';
import { copyToClipboard, useDebounce } from '../../utils';
import useVideoStore from '../../store/useVideoStore';
import useSettingsStore from '../../store/settings';
import {
Tv2,
ScreenShare,
Scroll,
SquareMinus,
CirclePlay,
SquarePen,
Copy,
ScanEye,
EllipsisVertical,
ArrowUpNarrowWide,
ArrowUpDown,
ArrowDownWideNarrow,
Search,
ArrowUpDown,
ArrowUpNarrowWide,
CirclePlay,
Copy,
EllipsisVertical,
EyeOff,
Pencil,
ScanEye,
ScreenShare,
Scroll,
Search,
SquareMinus,
SquarePen,
Tv2,
} from 'lucide-react';
import { listOverriddenFields } from '../../utils/forms/ChannelUtils.js';
import { listOverriddenFields, requeryChannels, } from '../../utils/forms/ChannelUtils.js';
import { buildLiveStreamUrl } from '../../utils/components/FloatingVideoUtils.js';
import {
Box,
TextInput,
Popover,
ActionIcon,
Box,
Button,
Paper,
Flex,
Text,
Group,
useMantineTheme,
Center,
Switch,
Flex,
Group,
Menu,
MenuDropdown,
MenuItem,
MenuTarget,
MultiSelect,
Pagination,
NativeSelect,
UnstyledButton,
Stack,
Select,
NumberInput,
Pagination,
Paper,
Popover,
PopoverDropdown,
PopoverTarget,
Select,
Stack,
Switch,
Text,
TextInput,
Tooltip,
Skeleton,
UnstyledButton,
useMantineTheme,
} from '@mantine/core';
import './table.css';
import useChannelsTableStore from '../../store/channelsTable';
@ -79,21 +67,33 @@ import ChannelsTableOnboarding from './ChannelsTable/ChannelsTableOnboarding';
import ChannelTableHeader from './ChannelsTable/ChannelTableHeader';
import useOutputProfilesStore from '../../store/outputProfiles';
import {
EditableTextCell,
EditableNumberCell,
EditableGroupCell,
EditableEPGCell,
EditableGroupCell,
EditableLogoCell,
EditableNumberCell,
EditableTextCell,
} from './ChannelsTable/EditableCell';
import { DraggableRow } from './ChannelsTable/DraggableRow';
import useWarningsStore from '../../store/warnings';
import ConfirmationDialog from '../ConfirmationDialog';
import useAuthStore from '../../store/auth';
import { USER_LEVELS } from '../../constants';
const m3uUrlBase = `${window.location.protocol}//${window.location.host}/output/m3u`;
const epgUrlBase = `${window.location.protocol}//${window.location.host}/output/epg`;
const hdhrUrlBase = `${window.location.protocol}//${window.location.host}/hdhr`;
import { getShowVideoUrl } from '../../utils/cards/RecordingCardUtils.js';
import {
buildEPGUrl,
buildFetchParams,
buildHDHRUrl,
buildM3UUrl,
deleteChannel,
deleteChannels,
epgUrlBase,
getAllChannelIds,
hdhrUrlBase,
m3uUrlBase,
queryChannels,
reorderChannel,
updateProfileChannel,
updateProfileChannels,
} from '../../utils/tables/ChannelsTableUtils.js';
const ChannelEnabledSwitch = React.memo(
({ rowId, selectedProfileId, selectedTableIds }) => {
@ -109,13 +109,13 @@ const ChannelEnabledSwitch = React.memo(
const handleToggle = () => {
if (selectedTableIds.length > 1) {
API.updateProfileChannels(
updateProfileChannels(
selectedTableIds,
selectedProfileId,
!isEnabled
);
} else {
API.updateProfileChannel(rowId, selectedProfileId, !isEnabled);
updateProfileChannel(rowId, selectedProfileId, !isEnabled);
}
};
@ -208,22 +208,22 @@ const ChannelRowActions = React.memo(
</ActionIcon>
<Menu>
<Menu.Target>
<MenuTarget>
<ActionIcon variant="transparent" size={iconSize}>
<EllipsisVertical size="18" />
</ActionIcon>
</Menu.Target>
</MenuTarget>
<Menu.Dropdown>
<Menu.Item leftSection={<Copy size="14" />}>
<MenuDropdown>
<MenuItem leftSection={<Copy size="14" />}>
<UnstyledButton
size="xs"
onClick={() => copyToClipboard(getChannelURL(row.original))}
>
<Text size="xs">Copy URL</Text>
</UnstyledButton>
</Menu.Item>
<Menu.Item
</MenuItem>
<MenuItem
onClick={onRecord}
disabled={authUser.user_level != USER_LEVELS.ADMIN}
leftSection={
@ -239,8 +239,8 @@ const ChannelRowActions = React.memo(
}
>
<Text size="xs">Record</Text>
</Menu.Item>
</Menu.Dropdown>
</MenuItem>
</MenuDropdown>
</Menu>
</Center>
</Box>
@ -421,130 +421,47 @@ const ChannelsTable = ({ onReady }) => {
/**
* Functions
*/
const handleFetchSuccess = useCallback((ids) => {
setTablePrefs((prev) => ({ ...prev, pageSize: pagination.pageSize }));
setAllRowIds(ids);
hasFetchedData.current = true;
if (!hasSignaledReady.current && onReady && tvgsLoaded) {
hasSignaledReady.current = true;
onReady();
}
}, [pagination.pageSize, setTablePrefs, setAllRowIds, onReady, tvgsLoaded]);
const fetchData = useCallback(async () => {
// Build params first to check for duplicates
const params = new URLSearchParams();
params.append('page', pagination.pageIndex + 1);
params.append('page_size', pagination.pageSize);
params.append('include_streams', 'true');
if (selectedProfileId !== '0') {
params.append('channel_profile_id', selectedProfileId);
}
if (showDisabled === true) {
params.append('show_disabled', true);
}
if (showOnlyStreamlessChannels === true) {
params.append('only_streamless', true);
}
if (showOnlyStaleChannels === true) {
params.append('only_stale', true);
}
if (showOnlyOverriddenChannels === true) {
params.append('only_has_overrides', true);
}
// The backend defaults to "active"; send other choices explicitly so
// hidden rows surface when the user opts into "Hidden Only" or "Show All".
if (visibilityFilter && visibilityFilter !== 'active') {
params.append('visibility_filter', visibilityFilter);
}
// Apply sorting
if (sorting.length > 0) {
let sortField = sorting[0].id;
// Map frontend column ids to backend ordering field names
const fieldMapping = {
channel_group: 'channel_group__name',
epg: 'epg_data__name',
};
if (fieldMapping[sortField]) {
sortField = fieldMapping[sortField];
}
const sortDirection = sorting[0].desc ? '-' : '';
params.append('ordering', `${sortDirection}${sortField}`);
}
// Apply debounced filters
Object.entries(debouncedFilters).forEach(([key, value]) => {
if (value) {
if (Array.isArray(value)) {
// Convert null values to "null" string for URL parameter
const processedValue = value
.map((v) => (v === null ? 'null' : v))
.join(',');
params.append(key, processedValue);
} else {
params.append(key, value);
}
}
const params = buildFetchParams({
pagination, sorting, debouncedFilters, selectedProfileId,
showDisabled, showOnlyStreamlessChannels, showOnlyStaleChannels,
showOnlyOverriddenChannels, visibilityFilter,
});
const paramsString = params.toString();
// Skip if same fetch is already in progress (prevents StrictMode double-fetch)
if (
fetchInProgressRef.current &&
lastFetchParamsRef.current === paramsString
) {
return;
}
if (fetchInProgressRef.current && lastFetchParamsRef.current === paramsString) return;
// Increment fetch version to track this specific fetch request
const currentFetchVersion = ++fetchVersionRef.current;
lastFetchParamsRef.current = paramsString;
fetchInProgressRef.current = true;
setIsLoading(true);
try {
const [, ids] = await Promise.all([
API.queryChannels(params),
API.getAllChannelIds(params),
]);
const [, ids] = await Promise.all([queryChannels(params), getAllChannelIds(params)]);
fetchInProgressRef.current = false;
// Skip state updates if a newer fetch has been initiated
if (currentFetchVersion !== fetchVersionRef.current) {
return;
}
if (currentFetchVersion !== fetchVersionRef.current) return;
setIsLoading(false);
hasFetchedData.current = true;
setTablePrefs((prev) => ({
...prev,
pageSize: pagination.pageSize,
}));
setAllRowIds(ids);
// Signal ready after first successful data fetch AND EPG data is loaded
// This prevents the EPG column from showing "Not Assigned" while EPG data is still loading
if (!hasSignaledReady.current && onReady && tvgsLoaded) {
hasSignaledReady.current = true;
onReady();
}
handleFetchSuccess(ids);
} catch (error) {
fetchInProgressRef.current = false;
// Skip state updates if a newer fetch has been initiated
if (currentFetchVersion !== fetchVersionRef.current) {
return;
}
if (currentFetchVersion !== fetchVersionRef.current) return;
setIsLoading(false);
// API layer handles "Invalid page" errors by resetting and retrying
// Just re-throw to show notification for actual errors
throw error;
}
}, [
pagination,
sorting,
debouncedFilters,
showDisabled,
selectedProfileId,
showOnlyStreamlessChannels,
showOnlyStaleChannels,
showOnlyOverriddenChannels,
visibilityFilter,
pagination, sorting, debouncedFilters, selectedProfileId,
showDisabled, showOnlyStreamlessChannels, showOnlyStaleChannels,
showOnlyOverriddenChannels, visibilityFilter, handleFetchSuccess,
]);
const stopPropagation = useCallback((e) => {
@ -604,7 +521,7 @@ const ChannelsTable = ({ onReady }) => {
}
}, []);
const deleteChannel = async (id) => {
const handleDeleteChannel = async (id) => {
console.log(`Deleting channel with ID: ${id}`);
const rows = table.getRowModel().rows;
@ -642,15 +559,15 @@ const ChannelsTable = ({ onReady }) => {
const executeDeleteChannel = async (id) => {
setDeleting(true);
try {
await API.deleteChannel(id);
API.requeryChannels();
await deleteChannel(id);
requeryChannels();
} finally {
setDeleting(false);
setConfirmDeleteOpen(false);
}
};
const deleteChannels = async () => {
const handleDeleteChannels = async () => {
if (isWarningSuppressed('delete-channels')) {
// Skip warning if suppressed
return executeDeleteChannels();
@ -664,8 +581,8 @@ const ChannelsTable = ({ onReady }) => {
setIsLoading(true);
setDeleting(true);
try {
await API.deleteChannels(table.selectedTableIds);
await API.requeryChannels();
await deleteChannels(table.selectedTableIds);
await requeryChannels();
setSelectedChannelIds([]);
table.setSelectedTableIds([]);
} finally {
@ -688,9 +605,9 @@ const ChannelsTable = ({ onReady }) => {
return '';
}
const path = `/proxy/ts/stream/${channel.uuid}`;
const path = getShowVideoUrl(channel, env_mode);
if (env_mode == 'dev') {
return `${window.location.protocol}//${window.location.hostname}:5656${path}`;
return path;
}
return `${window.location.protocol}//${window.location.host}${path}`;
},
@ -754,51 +671,23 @@ const ChannelsTable = ({ onReady }) => {
setRecordingModalOpen(false);
};
// Build URLs with parameters
const buildM3UUrl = () => {
const params = new URLSearchParams();
if (!m3uParams.cachedlogos) params.append('cachedlogos', 'false');
if (m3uParams.direct) params.append('direct', 'true');
if (m3uParams.tvg_id_source !== 'channel_number')
params.append('tvg_id_source', m3uParams.tvg_id_source);
if (m3uParams.output_format)
params.append('output_format', m3uParams.output_format);
if (m3uParams.output_profile)
params.append('output_profile', m3uParams.output_profile);
const baseUrl = m3uUrl;
return params.toString() ? `${baseUrl}?${params.toString()}` : baseUrl;
};
const buildEPGUrl = () => {
const params = new URLSearchParams();
if (!epgParams.cachedlogos) params.append('cachedlogos', 'false');
if (epgParams.tvg_id_source !== 'channel_number')
params.append('tvg_id_source', epgParams.tvg_id_source);
if (epgParams.days > 0) params.append('days', epgParams.days.toString());
if (epgParams.prev_days > 0)
params.append('prev_days', epgParams.prev_days.toString());
const baseUrl = epgUrl;
return params.toString() ? `${baseUrl}?${params.toString()}` : baseUrl;
};
// Example copy URLs
const copyM3UUrl = async () => {
await copyToClipboard(buildM3UUrl(), {
await copyToClipboard(buildM3UUrl(m3uParams, m3uUrl), {
successTitle: 'M3U URL Copied!',
successMessage: 'The M3U URL has been copied to your clipboard.',
});
};
const copyEPGUrl = async () => {
await copyToClipboard(buildEPGUrl(), {
await copyToClipboard(buildEPGUrl(epgParams, epgUrl), {
successTitle: 'EPG URL Copied!',
successMessage: 'The EPG URL has been copied to your clipboard.',
});
};
const copyHDHRUrl = async () => {
await copyToClipboard(buildHDHRUrl(), {
await copyToClipboard(buildHDHRUrl(hdhrOutputProfileId, hdhrUrl), {
successTitle: 'HDHR URL Copied!',
successMessage: 'The HDHR URL has been copied to your clipboard.',
});
@ -854,7 +743,7 @@ const ChannelsTable = ({ onReady }) => {
useChannelsTableStore.setState({ channels: reorderedData });
// Call backend to reorder
await API.reorderChannel(
await reorderChannel(
activeChannel.id,
overIndex > activeIndex
? overChannel.id
@ -862,11 +751,11 @@ const ChannelsTable = ({ onReady }) => {
);
// Refetch to get updated channel numbers
await API.requeryChannels();
await requeryChannels();
} catch (error) {
// Revert on error
console.error('Failed to reorder channel:', error);
await API.requeryChannels();
await requeryChannels();
}
};
@ -885,13 +774,6 @@ const ChannelsTable = ({ onReady }) => {
setM3UUrl(`${m3uUrlBase}${profileString}`);
}, [selectedProfileId, profiles]);
const buildHDHRUrl = () => {
if (!hdhrOutputProfileId) return hdhrUrl;
// Insert output_profile segment before the trailing slash (or at end)
const base = hdhrUrl.replace(/\/$/, '');
return `${base}/output_profile/${hdhrOutputProfileId}`;
};
useEffect(() => {
const startItem = pagination.pageIndex * pagination.pageSize + 1; // +1 to start from 1, not 0
const endItem = Math.min(
@ -1053,7 +935,7 @@ const ChannelsTable = ({ onReady }) => {
row={row}
table={table}
editChannel={editChannel}
deleteChannel={deleteChannel}
deleteChannel={handleDeleteChannel}
handleWatchStream={handleWatchStream}
createRecording={createRecording}
getChannelURL={getChannelURL}
@ -1298,7 +1180,7 @@ const ChannelsTable = ({ onReady }) => {
position="bottom-start"
withinPortal
>
<Popover.Target>
<PopoverTarget>
<Button
leftSection={<Tv2 size={18} />}
size="compact-sm"
@ -1312,8 +1194,8 @@ const ChannelsTable = ({ onReady }) => {
>
HDHR
</Button>
</Popover.Target>
<Popover.Dropdown>
</PopoverTarget>
<PopoverDropdown>
<Stack
gap="sm"
style={{
@ -1329,7 +1211,7 @@ const ChannelsTable = ({ onReady }) => {
clients.
</Text>
<TextInput
value={buildHDHRUrl()}
value={buildHDHRUrl(hdhrOutputProfileId, hdhrUrl)}
size="sm"
readOnly
label="Generated URL"
@ -1359,7 +1241,7 @@ const ChannelsTable = ({ onReady }) => {
.map((p) => ({ value: `${p.id}`, label: p.name }))}
/>
</Stack>
</Popover.Dropdown>
</PopoverDropdown>
</Popover>
<Popover
withArrow
@ -1368,7 +1250,7 @@ const ChannelsTable = ({ onReady }) => {
position="bottom-start"
withinPortal
>
<Popover.Target>
<PopoverTarget>
<Button
leftSection={<ScreenShare size={18} />}
size="compact-sm"
@ -1381,8 +1263,8 @@ const ChannelsTable = ({ onReady }) => {
>
M3U
</Button>
</Popover.Target>
<Popover.Dropdown>
</PopoverTarget>
<PopoverDropdown>
<Stack
gap="sm"
style={{
@ -1398,7 +1280,7 @@ const ChannelsTable = ({ onReady }) => {
channel list.
</Text>
<TextInput
value={buildM3UUrl()}
value={buildM3UUrl(m3uParams, m3uUrl)}
size="sm"
readOnly
label="Generated URL"
@ -1492,7 +1374,7 @@ const ChannelsTable = ({ onReady }) => {
.map((p) => ({ value: `${p.id}`, label: p.name }))}
/>
</Stack>
</Popover.Dropdown>
</PopoverDropdown>
</Popover>
<Popover
withArrow
@ -1501,7 +1383,7 @@ const ChannelsTable = ({ onReady }) => {
position="bottom-start"
withinPortal
>
<Popover.Target>
<PopoverTarget>
<Button
leftSection={<Scroll size={18} />}
size="compact-sm"
@ -1515,8 +1397,8 @@ const ChannelsTable = ({ onReady }) => {
>
EPG
</Button>
</Popover.Target>
<Popover.Dropdown>
</PopoverTarget>
<PopoverDropdown>
<Stack
gap="sm"
style={{
@ -1534,7 +1416,7 @@ const ChannelsTable = ({ onReady }) => {
clients.
</Text>
<TextInput
value={buildEPGUrl()}
value={buildEPGUrl(epgParams, epgUrl)}
size="sm"
readOnly
label="Generated URL"
@ -1608,7 +1490,7 @@ const ChannelsTable = ({ onReady }) => {
}
/>
</Stack>
</Popover.Dropdown>
</PopoverDropdown>
</Popover>
</Group>
</Flex>
@ -1626,7 +1508,7 @@ const ChannelsTable = ({ onReady }) => {
<ChannelTableHeader
rows={rows}
editChannel={editChannel}
deleteChannels={deleteChannels}
deleteChannels={handleDeleteChannels}
selectedTableIds={table.selectedTableIds}
table={table}
showDisabled={showDisabled}

View file

@ -6,8 +6,14 @@ import {
Flex,
Group,
Menu,
NumberInput,
MenuDivider,
MenuDropdown,
MenuItem,
MenuLabel,
MenuTarget,
Popover,
PopoverDropdown,
PopoverTarget,
Select,
Text,
TextInput,
@ -19,22 +25,20 @@ import {
Binary,
CircleCheck,
EllipsisVertical,
SquareMinus,
SquarePen,
SquarePlus,
Settings,
Eye,
EyeOff,
Filter,
Square,
SquareCheck,
Pin,
PinOff,
Lock,
LockOpen,
Pin,
PinOff,
Settings,
Square,
SquareCheck,
SquareMinus,
SquarePen,
SquarePlus,
} from 'lucide-react';
import API from '../../../api';
import { notifications } from '@mantine/notifications';
import useChannelsStore from '../../../store/channels';
import useChannelsTableStore from '../../../store/channelsTable';
import useAuthStore from '../../../store/auth';
@ -45,6 +49,10 @@ import ConfirmationDialog from '../../ConfirmationDialog';
import useWarningsStore from '../../../store/warnings';
import ProfileModal, { renderProfileOption } from '../../modals/ProfileModal';
import EPGMatchModal from '../../modals/EPGMatchModal';
import {
addChannelProfile,
deleteChannelProfile,
} from '../../../utils/tables/ChannelsTableUtils.js';
const CreateProfilePopover = React.memo(() => {
const [opened, setOpened] = useState(false);
@ -59,7 +67,7 @@ const CreateProfilePopover = React.memo(() => {
};
const submit = async () => {
await API.addChannelProfile({ name });
await addChannelProfile({ name });
setName('');
setOpened(false);
};
@ -72,7 +80,7 @@ const CreateProfilePopover = React.memo(() => {
withArrow
shadow="md"
>
<Popover.Target>
<PopoverTarget>
<ActionIcon
variant="transparent"
color={theme.tailwind.green[5]}
@ -81,9 +89,9 @@ const CreateProfilePopover = React.memo(() => {
>
<SquarePlus />
</ActionIcon>
</Popover.Target>
</PopoverTarget>
<Popover.Dropdown>
<PopoverDropdown>
<Group>
<TextInput
placeholder="Profile Name"
@ -101,7 +109,7 @@ const CreateProfilePopover = React.memo(() => {
<CircleCheck />
</ActionIcon>
</Group>
</Popover.Dropdown>
</PopoverDropdown>
</Popover>
);
});
@ -125,7 +133,6 @@ const ChannelTableHeader = ({
}) => {
const theme = useMantineTheme();
const [channelNumAssignmentStart, setChannelNumAssignmentStart] = useState(1);
const [assignNumbersModalOpen, setAssignNumbersModalOpen] = useState(false);
const [groupManagerOpen, setGroupManagerOpen] = useState(false);
const [epgMatchModalOpen, setEpgMatchModalOpen] = useState(false);
@ -179,38 +186,13 @@ const ChannelTableHeader = ({
const executeDeleteProfile = async (id) => {
setDeletingProfile(true);
try {
await API.deleteChannelProfile(id);
await deleteChannelProfile(id);
} finally {
setDeletingProfile(false);
setConfirmDeleteProfileOpen(false);
}
};
const assignChannels = async () => {
try {
// Call our custom API endpoint
const result = await API.assignChannelNumbers(
selectedTableIds,
channelNumAssignmentStart
);
// We might get { message: "Channels have been auto-assigned!" }
notifications.show({
title: result.message || 'Channels assigned',
color: 'green.5',
});
// Refresh the channel list
API.requeryChannels();
} catch (err) {
console.error(err);
notifications.show({
title: 'Failed to assign channels',
color: 'red.5',
});
}
};
const renderModalOption = renderProfileOption(
theme,
profiles,
@ -302,14 +284,14 @@ const ChannelTableHeader = ({
>
<Flex gap={6}>
<Menu shadow="md" width={200}>
<Menu.Target>
<MenuTarget>
<Button size="xs" variant="default" onClick={() => {}}>
<Filter size={18} />
</Button>
</Menu.Target>
</MenuTarget>
<Menu.Dropdown>
<Menu.Item
<MenuDropdown>
<MenuItem
onClick={toggleShowDisabled}
leftSection={
showDisabled ? <Eye size={18} /> : <EyeOff size={18} />
@ -319,9 +301,9 @@ const ChannelTableHeader = ({
<Text size="xs">
{showDisabled ? 'Hide Disabled' : 'Show Disabled'}
</Text>
</Menu.Item>
</MenuItem>
<Menu.Item
<MenuItem
onClick={toggleShowOnlyStreamlessChannels}
leftSection={
showOnlyStreamlessChannels ? (
@ -332,9 +314,9 @@ const ChannelTableHeader = ({
}
>
<Text size="xs">Only Empty Channels</Text>
</Menu.Item>
</MenuItem>
<Menu.Item
<MenuItem
onClick={toggleShowOnlyStaleChannels}
leftSection={
showOnlyStaleChannels ? (
@ -345,9 +327,9 @@ const ChannelTableHeader = ({
}
>
<Text size="xs">Has Stale Streams</Text>
</Menu.Item>
</MenuItem>
<Menu.Item
<MenuItem
onClick={toggleShowOnlyOverriddenChannels}
leftSection={
showOnlyOverriddenChannels ? (
@ -358,19 +340,19 @@ const ChannelTableHeader = ({
}
>
<Text size="xs">Has Overrides</Text>
</Menu.Item>
</MenuItem>
<Menu.Divider />
<Menu.Label>
<MenuDivider />
<MenuLabel>
<Text size="xs">Visibility</Text>
</Menu.Label>
</MenuLabel>
{[
{ value: 'active', label: 'Active Only' },
{ value: 'hidden', label: 'Hidden Only' },
{ value: 'all', label: 'Show All' },
].map(({ value, label }) => (
<Menu.Item
<MenuItem
key={value}
onClick={() =>
setVisibilityFilter && setVisibilityFilter(value)
@ -384,9 +366,9 @@ const ChannelTableHeader = ({
}
>
<Text size="xs">{label}</Text>
</Menu.Item>
</MenuItem>
))}
</Menu.Dropdown>
</MenuDropdown>
</Menu>
<Button
@ -435,14 +417,14 @@ const ChannelTableHeader = ({
</Button>
<Menu>
<Menu.Target>
<MenuTarget>
<ActionIcon variant="default" size={30}>
<EllipsisVertical size={18} />
</ActionIcon>
</Menu.Target>
</MenuTarget>
<Menu.Dropdown>
<Menu.Item
<MenuDropdown>
<MenuItem
leftSection={
headerPinned ? <Pin size={18} /> : <PinOff size={18} />
}
@ -451,9 +433,9 @@ const ChannelTableHeader = ({
<Text size="xs">
{headerPinned ? 'Unpin Headers' : 'Pin Headers'}
</Text>
</Menu.Item>
</MenuItem>
<Menu.Item
<MenuItem
leftSection={
isUnlocked ? <LockOpen size={18} /> : <Lock size={18} />
}
@ -463,11 +445,11 @@ const ChannelTableHeader = ({
<Text size="xs">
{isUnlocked ? 'Lock Table' : 'Unlock for Editing'}
</Text>
</Menu.Item>
</MenuItem>
<Menu.Divider />
<MenuDivider />
<Menu.Item
<MenuItem
leftSection={<ArrowDown01 size={18} />}
disabled={
selectedTableIds.length == 0 ||
@ -476,9 +458,9 @@ const ChannelTableHeader = ({
onClick={() => setAssignNumbersModalOpen(true)}
>
<Text size="xs">Assign #s</Text>
</Menu.Item>
</MenuItem>
<Menu.Item
<MenuItem
leftSection={<Binary size={18} />}
disabled={authUser.user_level != USER_LEVELS.ADMIN}
onClick={() => setEpgMatchModalOpen(true)}
@ -488,16 +470,16 @@ const ChannelTableHeader = ({
? `Auto-Match (${selectedTableIds.length} selected)`
: 'Auto-Match EPG'}
</Text>
</Menu.Item>
</MenuItem>
<Menu.Item
<MenuItem
leftSection={<Settings size={18} />}
disabled={authUser.user_level != USER_LEVELS.ADMIN}
onClick={() => setGroupManagerOpen(true)}
>
<Text size="xs">Edit Groups</Text>
</Menu.Item>
</Menu.Dropdown>
</MenuItem>
</MenuDropdown>
</Menu>
</Flex>
</Box>

View file

@ -1,28 +1,14 @@
import React, {
useState,
useCallback,
useEffect,
useRef,
useMemo,
memo,
} from 'react';
import {
Box,
TextInput,
Select,
NumberInput,
Tooltip,
Center,
Skeleton,
} from '@mantine/core';
import API from '../../../api';
import React, { memo, useCallback, useEffect, useMemo, useRef, useState, } from 'react';
import { Box, NumberInput, Select, Skeleton, TextInput, Tooltip, } from '@mantine/core';
import useChannelsTableStore from '../../../store/channelsTable';
import useLogosStore from '../../../store/logos';
import {
OVERRIDABLE_FIELDS,
normalizeFieldValue,
} from '../../../utils/forms/ChannelUtils.js';
import { requeryChannels, updateChannel, } from '../../../utils/forms/ChannelUtils.js';
import { showNotification } from '../../../utils/notificationUtils.js';
import {
buildInlinePatch,
getEpgOptions,
getLogoOptions,
} from '../../../utils/tables/ChannelsTableUtils.js';
// Surfaces server-side validation failures so the user knows the
// inline edit was rejected (otherwise the cell silently reverts).
@ -43,30 +29,6 @@ export const notifyInlineSaveError = (columnId, error) => {
});
};
// Inline edits on auto-synced channels route into the override row so
// sync cannot overwrite them. If the new value matches the provider's,
// clear that field's override instead of writing a duplicate. Manual
// channels keep direct Channel.* writes.
const buildInlinePatch = (rowOriginal, fieldId, newValue) => {
if (rowOriginal?.auto_created && OVERRIDABLE_FIELDS.includes(fieldId)) {
// Normalize both sides so a stringified form value compares
// cleanly against the typed provider value.
const formValue = normalizeFieldValue(fieldId, newValue);
const providerValue = normalizeFieldValue(fieldId, rowOriginal[fieldId]);
const overrideFieldValue = formValue === providerValue ? null : formValue;
return {
id: rowOriginal.id,
override: { [fieldId]: overrideFieldValue },
};
}
const normalized =
newValue === undefined || newValue === '' ? null : newValue;
return {
id: rowOriginal.id,
[fieldId]: normalized,
};
};
// Lightweight wrapper that only renders full editable cell when unlocked
// This prevents 250+ heavy component instances when table is locked
const EditableCellWrapper = memo(
@ -151,7 +113,7 @@ const EditableTextCellInner = ({ row, column, getValue, onBlur }) => {
}
try {
const response = await API.updateChannel(
const response = await updateChannel(
buildInlinePatch(row.original, column.id, newValue)
);
previousValue.current = newValue;
@ -298,7 +260,7 @@ const EditableNumberCellInner = ({ row, column, getValue, onBlur }) => {
}
try {
const response = await API.updateChannel(
const response = await updateChannel(
buildInlinePatch(row.original, column.id, newValue)
);
previousValue.current = newValue;
@ -309,7 +271,7 @@ const EditableNumberCellInner = ({ row, column, getValue, onBlur }) => {
// If channel_number was changed, refetch to reorder the table
if (column.id === 'channel_number') {
await API.requeryChannels();
await requeryChannels();
onBlur();
}
}
@ -416,7 +378,7 @@ const EditableGroupCellInner = ({
}
try {
const response = await API.updateChannel(
const response = await updateChannel(
buildInlinePatch(
row.original,
'channel_group_id',
@ -591,7 +553,7 @@ const EditableEPGCellInner = ({
}
try {
const response = await API.updateChannel(
const response = await updateChannel(
buildInlinePatch(
row.original,
'epg_data_id',
@ -620,51 +582,7 @@ const EditableEPGCellInner = ({
// Build EPG options
const epgOptions = useMemo(() => {
const options = [{ value: 'null', label: 'Not Assigned' }];
// Convert tvgsById to an array and sort by EPG source name, then by tvg_id
const tvgsArray = Object.values(tvgsById);
tvgsArray.sort((a, b) => {
const aEpgName =
a.epg_source && epgs[a.epg_source]
? epgs[a.epg_source].name
: a.epg_source || '';
const bEpgName =
b.epg_source && epgs[b.epg_source]
? epgs[b.epg_source].name
: b.epg_source || '';
const epgCompare = aEpgName.localeCompare(bEpgName);
if (epgCompare !== 0) return epgCompare;
// Secondary sort by tvg_id
return (a.tvg_id || '').localeCompare(b.tvg_id || '');
});
tvgsArray.forEach((tvg) => {
const epgSourceName =
tvg.epg_source && epgs[tvg.epg_source]
? epgs[tvg.epg_source].name
: tvg.epg_source;
const tvgName = tvg.name;
// Create a comprehensive label: "EPG Name | TVG-ID | TVG Name"
let label;
if (epgSourceName && tvg.tvg_id) {
label = `${epgSourceName} | ${tvg.tvg_id}`;
if (tvgName && tvgName !== tvg.tvg_id) {
label += ` | ${tvgName}`;
}
} else if (tvgName) {
label = tvgName;
} else {
label = `ID: ${tvg.id}`;
}
options.push({
value: String(tvg.id),
label: label,
});
});
return options;
return getEpgOptions(tvgsById, epgs);
}, [tvgsById, epgs]);
return (
@ -761,7 +679,7 @@ const EditableLogoCellInner = ({ row, logoId, onBlur }) => {
}
try {
const response = await API.updateChannel(
const response = await updateChannel(
buildInlinePatch(
row.original,
'logo_id',
@ -790,27 +708,7 @@ const EditableLogoCellInner = ({ row, logoId, onBlur }) => {
// Build logo options with logo data
const logoOptions = useMemo(() => {
const options = [
{
value: 'null',
label: 'Default',
logo: null,
},
];
// Convert channelLogos object to array and sort by name
const logosArray = Object.values(channelLogos);
logosArray.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
logosArray.forEach((logo) => {
options.push({
value: String(logo.id),
label: logo.name || `Logo ${logo.id}`,
logo: logo,
});
});
return options;
return getLogoOptions(channelLogos);
}, [channelLogos]);
// Get display text for the current logo

View file

@ -1,6 +1,6 @@
import { Box, Flex } from '@mantine/core';
import { Box } from '@mantine/core';
import CustomTableHeader from './CustomTableHeader';
import { useCallback, useState, useRef, useMemo } from 'react';
import { useMemo } from 'react';
import CustomTableBody from './CustomTableBody';
const CustomTable = ({ table }) => {
@ -17,14 +17,13 @@ const CustomTable = ({ table }) => {
const headerGroups = table.getHeaderGroups();
if (!headerGroups || headerGroups.length === 0) return 0;
const width =
return (
headerGroups[0]?.headers.reduce((total, header) => {
const colDef = header.column.columnDef;
const size = colDef.grow ? colDef.minSize || 0 : header.getSize();
return total + size;
}, 0) || 0;
return width;
}, 0) || 0
);
}, [table, columnSizing]);
// CSS custom properties for each fixed-width column's current size.

View file

@ -1,6 +1,6 @@
import { Box, Center, Checkbox, Flex } from '@mantine/core';
import { flexRender } from '@tanstack/react-table';
import { useCallback, useMemo } from 'react';
import { useMemo } from 'react';
import MultiSelectHeaderWrapper from './MultiSelectHeaderWrapper';
import useChannelsTableStore from '../../../store/channelsTable';
@ -55,25 +55,6 @@ const CustomTableHeader = ({
return <MultiSelectHeaderWrapper>{content}</MultiSelectHeaderWrapper>;
};
// Get header groups for dependency tracking
const headerGroups = getHeaderGroups();
// Calculate minimum width based only on fixed-size columns
const minTableWidth = useMemo(() => {
if (!headerGroups || headerGroups.length === 0) return 0;
const width =
headerGroups[0]?.headers.reduce((total, header) => {
// Only count columns with fixed sizes, flexible columns will expand
const columnSize = header.column.columnDef.size
? header.getSize()
: header.column.columnDef.minSize || 150; // Default min for flexible columns
return total + columnSize;
}, 0) || 0;
return width;
}, [headerGroups]);
// Memoize the style object to ensure it updates when headerPinned changes
const headerStyle = useMemo(
() => ({
@ -122,7 +103,6 @@ const CustomTableHeader = ({
maxWidth: `var(--header-${header.id}-size)`,
}),
position: 'relative',
// ...(tableCellProps && tableCellProps({ cell: header })),
}}
>
<Flex

View file

@ -2,13 +2,8 @@ import { Center, Checkbox } from '@mantine/core';
import CustomTable from './CustomTable';
import CustomTableHeader from './CustomTableHeader';
import useTablePreferences from '../../../hooks/useTablePreferences';
import {
useReactTable,
getCoreRowModel,
flexRender,
} from '@tanstack/react-table';
import { useCallback, useMemo, useRef, useState, useEffect } from 'react';
import { flexRender, getCoreRowModel, useReactTable, } from '@tanstack/react-table';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ChevronDown, ChevronRight } from 'lucide-react';
const useTable = ({
@ -84,8 +79,6 @@ const useTable = ({
};
}, [handleKeyDown, handleKeyUp]);
const rowCount = allRowIds.length;
const table = useReactTable({
defaultColumn: {
minSize: 0,
@ -172,7 +165,7 @@ const useTable = ({
return true; // Return true to indicate we've handled it
};
const handleRowClick = (rowId, e) => {
handleRowClickRef.current = (rowId, e) => {
if (
e.target.closest(
'button, a, input, select, textarea, [role="menuitem"], [role="option"], [role="button"]'
@ -193,7 +186,6 @@ const useTable = ({
updateSelectedTableIds([...newSet]);
}
};
handleRowClickRef.current = handleRowClick;
const renderBodyCell = ({ row, cell }) => {
if (bodyCellRenderFns[cell.column.id]) {

View file

@ -1,46 +1,50 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import API from '../../api';
import { useEffect, useMemo, useState } from 'react';
import useEPGsStore from '../../store/epgs';
import EPGForm from '../forms/EPG';
import DummyEPGForm from '../forms/DummyEPG';
import {
ActionIcon,
Text,
Tooltip,
Box,
Paper,
Button,
Flex,
useMantineTheme,
Switch,
Menu,
MenuDropdown,
MenuItem,
MenuTarget,
Paper,
Progress,
Stack,
Group,
Menu,
Switch,
Text,
Tooltip,
useMantineTheme,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import {
ArrowDownWideNarrow,
ArrowUpDown,
ArrowUpNarrowWide,
ChevronDown,
RefreshCcw,
SquareMinus,
SquarePen,
SquarePlus,
ChevronDown,
} from 'lucide-react';
import { format } from '../../utils/dateTimeUtils.js';
import { format, useDateTimeFormat } from '../../utils/dateTimeUtils.js';
import useLocalStorage from '../../hooks/useLocalStorage';
import { useDateTimeFormat } from '../../utils/dateTimeUtils.js';
import ConfirmationDialog from '../../components/ConfirmationDialog';
import useWarningsStore from '../../store/warnings';
import { CustomTable, useTable } from './CustomTable';
// Helper function to format status text
const formatStatusText = (status) => {
if (!status) return 'Unknown';
return status.charAt(0).toUpperCase() + status.slice(1).toLowerCase();
};
import { showNotification } from '../../utils/notificationUtils.js';
import {
deleteEpg,
formatStatusText,
getProgressInfo,
getProgressLabel,
getSortedEpgs,
refreshEpg,
updateEpg,
} from '../../utils/tables/EPGsTableUtils.js';
import {
makeHeaderCellRenderer,
makeSortingChangeHandler,
} from './M3uTableUtils.jsx';
// Helper function to get status text color
const getStatusColor = (status) => {
@ -116,38 +120,10 @@ const EPGStatusCell = ({ epg }) => {
progress.status === 'in_progress' ||
(progress.action === 'parsing_channels' && epg.status === 'parsing'))
) {
let label = '';
switch (progress.action) {
case 'downloading':
label = 'Downloading';
break;
case 'extracting':
label = 'Extracting';
break;
case 'parsing_channels':
label = 'Parsing Channels';
break;
case 'parsing_programs':
label = 'Parsing Programs';
break;
default:
return null;
}
const label = getProgressLabel(progress.action);
if (!label) return null;
let additionalInfo = '';
if (progress.message) {
additionalInfo = progress.message;
} else if (
progress.processed !== undefined &&
progress.channels !== undefined
) {
additionalInfo = `${progress.processed.toLocaleString()} programs for ${progress.channels} channels`;
} else if (
progress.processed !== undefined &&
progress.total !== undefined
) {
additionalInfo = `${progress.processed.toLocaleString()} / ${progress.total.toLocaleString()}`;
}
const additionalInfo = getProgressInfo(progress);
return (
<Stack spacing={2}>
@ -225,7 +201,6 @@ const EPGsTable = () => {
const [epg, setEPG] = useState(null);
const [epgModalOpen, setEPGModalOpen] = useState(false);
const [dummyEpgModalOpen, setDummyEpgModalOpen] = useState(false);
const [rowSelection, setRowSelection] = useState([]);
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState(null);
const [epgToDelete, setEpgToDelete] = useState(null);
@ -251,11 +226,11 @@ const EPGsTable = () => {
}
// Send only the is_active field to trigger our special handling
await API.updateEPG(
await updateEpg(
{
id: epg.id,
is_active: !epg.is_active,
},
epg,
true
); // Add a new parameter to indicate this is just a toggle
} catch (error) {
@ -395,7 +370,6 @@ const EPGsTable = () => {
[fullDateTimeFormat]
);
const [isLoading, setIsLoading] = useState(true);
const [sorting, setSorting] = useState([]);
const editEPG = async (epg = null) => {
@ -436,7 +410,7 @@ const EPGsTable = () => {
const executeDeleteEPG = async (id) => {
setDeleting(true);
try {
await API.deleteEPG(id);
await deleteEpg(id);
} finally {
setDeleting(false);
setConfirmDeleteOpen(false);
@ -444,8 +418,8 @@ const EPGsTable = () => {
};
const refreshEPG = async (id, force = false) => {
await API.refreshEPG(id, force);
notifications.show({
await refreshEpg(id, force);
showNotification({
title: 'EPG refresh initiated',
});
};
@ -502,74 +476,11 @@ const EPGsTable = () => {
}
};
const renderHeaderCell = (header) => {
let sortingIcon = ArrowUpDown;
if (sorting[0]?.id == header.id) {
if (sorting[0].desc === false) {
sortingIcon = ArrowUpNarrowWide;
} else {
sortingIcon = ArrowDownWideNarrow;
}
}
const onSortingChange = makeSortingChangeHandler(sorting, setSorting, (col, desc) =>
setData(getSortedEpgs(epgs, col, desc))
);
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 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(
epgs.sort((a, b) => {
console.log(a);
console.log(newSorting[0].id);
if (a[compareColumn] !== b[compareColumn]) {
return compareDesc ? 1 : -1;
}
return 0;
})
);
}
};
const renderHeaderCell = makeHeaderCellRenderer(sorting, onSortingChange);
const table = useTable({
columns,
@ -578,7 +489,6 @@ const EPGsTable = () => {
enablePagination: false,
enableRowSelection: false,
renderTopToolbar: false,
onRowSelectionChange: setRowSelection,
manualSorting: true,
bodyCellRenderFns: {
actions: renderBodyCell,
@ -632,7 +542,7 @@ const EPGsTable = () => {
EPGs
</Text>
<Menu shadow="md" width={200}>
<Menu.Target>
<MenuTarget>
<Button
leftSection={<SquarePlus size={18} />}
rightSection={<ChevronDown size={16} />}
@ -648,13 +558,11 @@ const EPGsTable = () => {
>
Add EPG
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={createStandardEPG}>
Standard EPG Source
</Menu.Item>
<Menu.Item onClick={createDummyEPG}>Dummy EPG Source</Menu.Item>
</Menu.Dropdown>
</MenuTarget>
<MenuDropdown>
<MenuItem onClick={createStandardEPG}>Standard EPG Source</MenuItem>
<MenuItem onClick={createDummyEPG}>Dummy EPG Source</MenuItem>
</MenuDropdown>
</Menu>
</Flex>
@ -668,11 +576,8 @@ const EPGsTable = () => {
<Box
style={{
display: 'flex',
// alignItems: 'center',
// backgroundColor: theme.palette.background.paper,
justifyContent: 'flex-end',
padding: 0,
// gap: 1,
}}
></Box>
</Paper>

View file

@ -1,44 +1,46 @@
import React, { useMemo, useCallback, useState, useEffect } from 'react';
import API from '../../api';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import LogoForm from '../forms/Logo';
import useLogosStore from '../../store/logos';
import useLocalStorage from '../../hooks/useLocalStorage';
import {
SquarePlus,
ExternalLink,
SquareMinus,
SquarePen,
ExternalLink,
Filter,
Trash2,
SquarePlus,
Trash,
} from 'lucide-react';
import {
ActionIcon,
Box,
Text,
Paper,
Button,
Flex,
Group,
useMantineTheme,
LoadingOverlay,
Stack,
Image,
Center,
Badge,
Tooltip,
Select,
TextInput,
Menu,
Box,
Button,
Center,
Checkbox,
Pagination,
Group,
Image,
LoadingOverlay,
NativeSelect,
Pagination,
Paper,
Select,
Stack,
Text,
TextInput,
Tooltip,
useMantineTheme,
} from '@mantine/core';
import { CustomTable, useTable } from './CustomTable';
import ConfirmationDialog from '../ConfirmationDialog';
import { notifications } from '@mantine/notifications';
import { showNotification } from '../../utils/notificationUtils.js';
import {
cleanupUnusedLogos,
deleteLogo,
deleteLogos,
generateUsageLabel,
getFilteredLogos,
} from '../../utils/tables/LogosTableUtils.js';
const LogoRowActions = ({ theme, row, editLogo, deleteLogo }) => {
const LogoRowActions = ({ theme, row, editLogo, handleDeleteLogo }) => {
const [tableSize, _] = useLocalStorage('table-size', 'default');
const onEdit = useCallback(() => {
@ -46,8 +48,8 @@ const LogoRowActions = ({ theme, row, editLogo, deleteLogo }) => {
}, [row.original, editLogo]);
const onDelete = useCallback(() => {
deleteLogo(row.original.id);
}, [row.original.id, deleteLogo]);
handleDeleteLogo(row.original.id);
}, [row.original.id, handleDeleteLogo]);
const iconSize =
tableSize == 'default' ? 'sm' : tableSize == 'compact' ? 'xs' : 'md';
@ -127,24 +129,7 @@ const LogosTable = () => {
}, [filters.name]);
const data = useMemo(() => {
const logosArray = Object.values(logos || {});
// Apply filters
let filteredLogos = logosArray;
if (debouncedNameFilter) {
filteredLogos = filteredLogos.filter((logo) =>
logo.name.toLowerCase().includes(debouncedNameFilter.toLowerCase())
);
}
if (filters.used === 'used') {
filteredLogos = filteredLogos.filter((logo) => logo.is_used);
} else if (filters.used === 'unused') {
filteredLogos = filteredLogos.filter((logo) => !logo.is_used);
}
return filteredLogos.sort((a, b) => a.id - b.id);
return getFilteredLogos(logos, debouncedNameFilter, filters.used);
}, [logos, debouncedNameFilter, filters.used]);
// Get paginated data
@ -175,15 +160,15 @@ const LogosTable = () => {
async (id, deleteFile = false) => {
setIsLoading(true);
try {
await API.deleteLogo(id, deleteFile);
await deleteLogo(id, deleteFile);
await fetchAllLogos(); // Refresh all logos to maintain full view
notifications.show({
showNotification({
title: 'Success',
message: 'Logo deleted successfully',
color: 'green',
});
} catch (error) {
notifications.show({
} catch {
showNotification({
title: 'Error',
message: 'Failed to delete logo',
color: 'red',
@ -206,16 +191,16 @@ const LogosTable = () => {
setIsLoading(true);
try {
await API.deleteLogos(Array.from(selectedRows), deleteFiles);
await deleteLogos(Array.from(selectedRows), deleteFiles);
await fetchAllLogos(); // Refresh all logos to maintain full view
notifications.show({
showNotification({
title: 'Success',
message: `${selectedRows.size} logos deleted successfully`,
color: 'green',
});
} catch (error) {
notifications.show({
} catch {
showNotification({
title: 'Error',
message: 'Failed to delete logos',
color: 'red',
@ -234,14 +219,14 @@ const LogosTable = () => {
async (deleteFiles = false) => {
setIsCleaningUp(true);
try {
const result = await API.cleanupUnusedLogos(deleteFiles);
const result = await cleanupUnusedLogos(deleteFiles);
let message = `Successfully deleted ${result.deleted_count} unused logos`;
if (result.local_files_deleted > 0) {
message += ` and deleted ${result.local_files_deleted} local files`;
}
notifications.show({
showNotification({
title: 'Cleanup Complete',
message: message,
color: 'green',
@ -249,8 +234,8 @@ const LogosTable = () => {
// Force refresh all logos after cleanup to maintain full view
await fetchAllLogos(true);
} catch (error) {
notifications.show({
} catch {
showNotification({
title: 'Cleanup Failed',
message: 'Failed to cleanup unused logos',
color: 'red',
@ -269,7 +254,7 @@ const LogosTable = () => {
setLogoModalOpen(true);
}, []);
const deleteLogo = useCallback(
const handleDeleteLogo = useCallback(
async (id) => {
const logosArray = Object.values(logos || {});
const logo = logosArray.find((l) => l.id === id);
@ -428,40 +413,7 @@ const LogosTable = () => {
);
}
// Analyze channel_names to categorize types
const categorizeUsage = (names) => {
const types = { channels: 0, movies: 0, series: 0 };
names.forEach((name) => {
if (name.startsWith('Channel:')) types.channels++;
else if (name.startsWith('Movie:')) types.movies++;
else if (name.startsWith('Series:')) types.series++;
});
return types;
};
const types = categorizeUsage(channelNames);
const typeCount = Object.values(types).filter(
(count) => count > 0
).length;
// Generate smart label based on usage
const generateLabel = () => {
if (typeCount === 1) {
// Only one type - be specific
if (types.channels > 0)
return `${types.channels} channel${types.channels !== 1 ? 's' : ''}`;
if (types.movies > 0)
return `${types.movies} movie${types.movies !== 1 ? 's' : ''}`;
if (types.series > 0) return `${types.series} series`;
} else {
// Multiple types - use generic "items"
return `${count} item${count !== 1 ? 's' : ''}`;
}
};
const label = generateLabel();
const label = generateUsageLabel(channelNames, count);
return (
<Tooltip
@ -528,7 +480,7 @@ const LogosTable = () => {
theme={theme}
row={row}
editLogo={editLogo}
deleteLogo={deleteLogo}
handleDeleteLogo={handleDeleteLogo}
/>
),
},
@ -536,7 +488,7 @@ const LogosTable = () => {
[
theme,
editLogo,
deleteLogo,
handleDeleteLogo,
selectedRows,
handleSelectRow,
handleSelectAll,

View file

@ -1,11 +1,10 @@
import React, {
import {
useEffect,
useMemo,
useRef,
useState,
useCallback,
} from 'react';
import API from '../../api';
import usePlaylistsStore from '../../store/playlists';
import M3UForm from '../forms/M3U';
import ServerGroupsManagerModal from '../ServerGroupsManagerModal';
@ -20,16 +19,11 @@ import {
ActionIcon,
Tooltip,
Switch,
Group,
Center,
} from '@mantine/core';
import {
SquareMinus,
SquarePen,
RefreshCcw,
ArrowUpDown,
ArrowUpNarrowWide,
ArrowDownWideNarrow,
SquarePlus,
} from 'lucide-react';
import useLocalStorage from '../../hooks/useLocalStorage';
@ -42,55 +36,42 @@ import {
import ConfirmationDialog from '../../components/ConfirmationDialog';
import useWarningsStore from '../../store/warnings';
import { CustomTable, useTable } from './CustomTable';
import {
deletePlaylist,
getExpirationInfo,
getExpirationTooltip,
getPlaylistAutoCreatedChannelsCount,
getSortedPlaylists,
getStatusColor,
getStatusContent,
formatStatusText,
refreshPlaylist,
updatePlaylist,
} from '../../utils/tables/M3UsTableUtils.js';
import {
makeHeaderCellRenderer,
makeSortingChangeHandler,
} from './M3uTableUtils.jsx';
// 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';
}
};
const StatusRow = ({ label, value }) => (
<Flex justify="space-between" align="center">
<Text size="xs" fw={500}>{label}{value ? ':' : ''}</Text>
{value && <Text size="xs">{value}</Text>}
</Flex>
);
// 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 StatusBox = ({ children }) => (
<Box>
<Flex direction="column" gap={2}>{children}</Flex>
</Box>
);
const RowActions = ({
tableSize,
editPlaylist,
deletePlaylist,
handleDeletePlaylist,
row,
refreshPlaylist,
handleRefreshPlaylist,
}) => {
const iconSize =
tableSize == 'default' ? 'sm' : tableSize == 'compact' ? 'xs' : 'md';
@ -111,7 +92,7 @@ const RowActions = ({
variant="transparent"
size={iconSize}
color="red.9"
onClick={() => deletePlaylist(row.original.id)}
onClick={() => handleDeletePlaylist(row.original.id)}
>
<SquareMinus size={tableSize === 'compact' ? 16 : 18} />
</ActionIcon>
@ -119,7 +100,7 @@ const RowActions = ({
variant="transparent"
size={iconSize}
color="blue.5"
onClick={() => refreshPlaylist(row.original.id)}
onClick={() => handleRefreshPlaylist(row.original.id)}
disabled={!row.original.is_active}
>
<RefreshCcw size={tableSize === 'compact' ? 16 : 18} />
@ -128,10 +109,10 @@ const RowActions = ({
);
};
const M3UTable = () => {
const [playlist, setPlaylist] = useState(null);
const [playlistModalOpen, setPlaylistModalOpen] = useState(false);
const [rowSelection, setRowSelection] = useState([]);
const [playlistCreated, setPlaylistCreated] = useState(false);
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState(null);
@ -179,209 +160,58 @@ const M3UTable = () => {
return 'Idle';
}
switch (data.action) {
const content = getStatusContent(data);
switch (content.type) {
case 'initializing':
return buildInitializingStats();
return (
<StatusBox><StatusRow label="Initializing refresh..." /></StatusBox>
);
case 'downloading':
return buildDownloadingStats(data);
case 'processing_groups':
return buildGroupProcessingStats(data);
return (
<StatusBox>
<StatusRow label="Downloading" value={`${content.progress}%`} />
<StatusRow label="Speed" value={content.speed} />
<StatusRow label="Time left" value={content.timeRemaining} />
</StatusBox>
);
case 'groups':
return (
<StatusBox>
<StatusRow label="Processing groups" value={`${content.progress}%`} />
{content.elapsedTime && <StatusRow label="Elapsed" value={content.elapsedTime} />}
{content.groupsProcessed && <StatusRow label="Groups" value={content.groupsProcessed} />}
</StatusBox>
);
case 'parsing':
return buildParsingStats(data);
return (
<StatusBox>
<StatusRow label="Parsing" value={`${content.progress}%`} />
{content.elapsedTime && <StatusRow label="Elapsed" value={content.elapsedTime} />}
{content.timeRemaining && <StatusRow label="Remaining" value={content.timeRemaining} />}
{content.streamsProcessed && <StatusRow label="Streams" value={content.streamsProcessed} />}
</StatusBox>
);
case 'error':
return (
<StatusBox>
<Text size="xs" fw={500} color="red">Error:</Text>
<Text size="xs" color="red" style={{ lineHeight: 1.3 }}>
{content.error || 'Unknown error occurred'}
</Text>
</StatusBox>
);
default:
return data.status === 'error'
? buildErrorStats(data)
: `${data.action || 'Processing'}...`;
return content.label;
}
};
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) => {
setPlaylist(playlist);
setPlaylistModalOpen(true);
};
const refreshPlaylist = async (id) => {
const handleRefreshPlaylist = async (id) => {
// Provide immediate visual feedback before the API call
setRefreshProgress(id, {
action: 'initializing',
@ -391,9 +221,9 @@ const M3UTable = () => {
});
try {
await API.refreshPlaylist(id);
await refreshPlaylist(id);
// No need to set again since WebSocket will update us once the task starts
} catch (error) {
} catch {
// If the API call fails, show an error state
setRefreshProgress(id, {
action: 'error',
@ -406,7 +236,7 @@ const M3UTable = () => {
}
};
const deletePlaylist = async (id) => {
const handleDeletePlaylist = async (id) => {
// Get playlist details for the confirmation dialog
const playlist = playlists.find((p) => p.id === id);
setPlaylistToDelete(playlist);
@ -418,7 +248,7 @@ const M3UTable = () => {
// thinking there are zero auto-created channels.
let info;
try {
const result = await API.getPlaylistAutoCreatedChannelsCount(id);
const result = await getPlaylistAutoCreatedChannelsCount(id);
info = result || { count: 0, sample_names: [] };
} catch {
info = {
@ -448,7 +278,9 @@ const M3UTable = () => {
setIsLoading(true);
setDeleting(true);
try {
await API.deletePlaylist(id);
await deletePlaylist(id);
} catch (error) {
console.error('Error deleting playlist:', error);
} finally {
setDeleting(false);
setIsLoading(false);
@ -460,11 +292,11 @@ const M3UTable = () => {
const toggleActive = async (playlist) => {
try {
// Send only the is_active field to trigger our special handling
await API.updatePlaylist(
await updatePlaylist(
{
id: playlist.id,
is_active: !playlist.is_active,
},
playlist,
true
); // Add a new parameter to indicate this is just a toggle
} catch (error) {
@ -685,34 +517,10 @@ const M3UTable = () => {
const now = getNow();
const daysLeft = diff(earliest, now, 'day');
let color;
let label;
if (daysLeft < 0) {
color = 'red.7';
label = 'Expired';
} else if (daysLeft === 0) {
color = 'red.5';
label = 'Expires today';
} else if (daysLeft <= 7) {
color = 'orange.5';
label = `${daysLeft}d left`;
} else if (daysLeft <= 30) {
color = 'yellow.5';
label = `${daysLeft}d left`;
} else {
label = format(earliest, fullDateFormat);
}
const { color, label } = getExpirationInfo(daysLeft, earliest, fullDateFormat);
const allExpirations = data.all_expirations || [];
const tooltipContent =
allExpirations.length > 0
? allExpirations
.map(
(e) =>
`${e.profile_name}: ${format(e.exp_date, fullDateTimeFormat)}${!e.is_active ? ' (inactive)' : ''}`
)
.join('\n')
: label;
const tooltipContent = getExpirationTooltip(allExpirations, fullDateTimeFormat, label);
return (
<Tooltip
@ -764,9 +572,9 @@ const M3UTable = () => {
},
],
[
refreshPlaylist,
handleRefreshPlaylist,
editPlaylist,
deletePlaylist,
handleDeletePlaylist,
toggleActive,
fullDateFormat,
fullDateTimeFormat,
@ -776,7 +584,7 @@ const M3UTable = () => {
//optionally access the underlying virtualizer instance
const rowVirtualizerInstanceRef = useRef(null);
const [isLoading, setIsLoading] = useState(true);
const [_isLoading, setIsLoading] = useState(true);
const closeModal = (newPlaylist = null) => {
if (newPlaylist) {
@ -812,85 +620,11 @@ const M3UTable = () => {
}
}, [editPlaylistId, processedData, playlists, setEditPlaylistId]);
const onSortingChange = (column) => {
console.log(column);
const sortField = sorting[0]?.id;
const sortDirection = sorting[0]?.desc;
const onSortingChange = makeSortingChangeHandler(sorting, setSorting, (col, desc) =>
setData(getSortedPlaylists(playlists, col, 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) => {
const aVal = a[compareColumn];
const bVal = b[compareColumn];
// Always sort nulls/undefined to the end regardless of direction
if (aVal == null && bVal == null) return 0;
if (aVal == null) return 1;
if (bVal == null) return -1;
let comparison;
if (typeof aVal === 'string') {
comparison = aVal.localeCompare(bVal);
} else {
comparison = aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
}
return compareDesc ? -comparison : comparison;
})
);
}
};
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 renderHeaderCell = makeHeaderCellRenderer(sorting, onSortingChange);
const renderBodyCell = useCallback(({ cell, row }) => {
switch (cell.column.id) {
@ -899,9 +633,9 @@ const M3UTable = () => {
<RowActions
tableSize={tableSize}
editPlaylist={editPlaylist}
deletePlaylist={deletePlaylist}
handleDeletePlaylist={handleDeletePlaylist}
row={row}
refreshPlaylist={refreshPlaylist}
handleRefreshPlaylist={handleRefreshPlaylist}
/>
);
}
@ -915,7 +649,6 @@ const M3UTable = () => {
enablePagination: false,
enableRowVirtualization: true,
enableRowSelection: false,
onRowSelectionChange: setRowSelection,
renderTopToolbar: false,
sorting,
manualSorting: true,
@ -1027,11 +760,8 @@ const M3UTable = () => {
<Box
style={{
display: 'flex',
// alignItems: 'center',
// backgroundColor: theme.palette.background.paper,
justifyContent: 'flex-end',
padding: 0,
// gap: 1,
}}
></Box>
</Paper>
@ -1115,7 +845,7 @@ This action cannot be undone.`}
}}
>
<Text size="sm" fw={600}>
{`${autoChannelsInfo.count} auto-synced channel${autoChannelsInfo.count === 1 ? '' : 's'} created by this provider will also be deleted.`}
{`${autoChannelsInfo.count} auto-synced ${autoChannelsInfo.count === 1 ? 'channel' : 'channels'} created by this provider will also be deleted.`}
</Text>
</div>
) : null}

View file

@ -1,23 +1,26 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import API from '../../api';
import { useEffect, useMemo, useState } from 'react';
import OutputProfileForm from '../forms/OutputProfile';
import useOutputProfilesStore from '../../store/outputProfiles';
import {
Box,
ActionIcon,
Tooltip,
Text,
Paper,
Flex,
Box,
Button,
useMantineTheme,
Center,
Switch,
Flex,
Paper,
Stack,
Switch,
Text,
Tooltip,
useMantineTheme,
} from '@mantine/core';
import { SquareMinus, SquarePen, Eye, EyeOff, SquarePlus } from 'lucide-react';
import { Eye, EyeOff, SquareMinus, SquarePen, SquarePlus } from 'lucide-react';
import { CustomTable, useTable } from './CustomTable';
import useLocalStorage from '../../hooks/useLocalStorage';
import {
deleteOutputProfile,
updateOutputProfile,
} from '../../utils/tables/OutputProfilesTableUtils.js';
const RowActions = ({ row, editOutputProfile, deleteOutputProfile }) => {
return (
@ -54,10 +57,6 @@ const OutputProfiles = () => {
const [tableSize] = useLocalStorage('table-size', 'default');
const theme = useMantineTheme();
const rowVirtualizerInstanceRef = useRef(null);
const [isLoading, setIsLoading] = useState(true);
const [sorting, setSorting] = useState([]);
const columns = useMemo(
() => [
{
@ -139,10 +138,6 @@ const OutputProfiles = () => {
setProfileModalOpen(true);
};
const deleteOutputProfile = async (id) => {
await API.deleteOutputProfile(id);
};
const closeOutputProfileForm = () => {
setProfile(null);
setProfileModalOpen(false);
@ -151,7 +146,7 @@ const OutputProfiles = () => {
const toggleHideInactive = () => setHideInactive((v) => !v);
const toggleProfileIsActive = async (profile) => {
await API.updateOutputProfile({
await updateOutputProfile({
id: profile.id,
...profile,
is_active: !profile.is_active,
@ -159,23 +154,7 @@ const OutputProfiles = () => {
};
useEffect(() => {
if (typeof window !== 'undefined') setIsLoading(false);
}, []);
useEffect(() => {
try {
rowVirtualizerInstanceRef.current?.scrollToIndex?.(0);
} catch (error) {
console.error(error);
}
}, [sorting]);
useEffect(() => {
setData(
outputProfiles.filter((p) =>
hideInactive && !p.is_active ? false : true
)
);
setData(outputProfiles.filter((p) => !(hideInactive && !p.is_active)));
}, [outputProfiles, hideInactive]);
const renderHeaderCell = (header) => (

View file

@ -1,10 +1,8 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import API from '../../api';
import StreamProfileForm from '../forms/StreamProfile';
import useStreamProfilesStore from '../../store/streamProfiles';
import { TableHelper } from '../../helpers';
import useSettingsStore from '../../store/settings';
import { notifications } from '@mantine/notifications';
import {
Box,
ActionIcon,
@ -21,16 +19,16 @@ import {
import {
SquareMinus,
SquarePen,
Check,
X,
Eye,
EyeOff,
SquarePlus,
} from 'lucide-react';
import { CustomTable, useTable } from './CustomTable';
import useLocalStorage from '../../hooks/useLocalStorage';
import { showNotification } from '../../utils/notificationUtils.js';
import { updateStreamProfile } from '../../utils/forms/StreamProfileUtils.js';
const RowActions = ({ row, editStreamProfile, deleteStreamProfile }) => {
const RowActions = ({ row, editStreamProfile, handleDeleteStreamProfile }) => {
return (
<>
<ActionIcon
@ -47,7 +45,7 @@ const RowActions = ({ row, editStreamProfile, deleteStreamProfile }) => {
size="sm"
color="red.9"
disabled={row.original.locked}
onClick={() => deleteStreamProfile(row.original.id)}
onClick={() => handleDeleteStreamProfile(row.original.id)}
>
<SquareMinus fontSize="small" /> {/* Small icon size */}
</ActionIcon>
@ -55,6 +53,10 @@ const RowActions = ({ row, editStreamProfile, deleteStreamProfile }) => {
);
};
const deleteStreamProfile = (id) => {
return API.deleteStreamProfile(id);
}
const StreamProfiles = () => {
const [profile, setProfile] = useState(null);
const [profileModalOpen, setProfileModalOpen] = useState(false);
@ -143,27 +145,21 @@ const StreamProfiles = () => {
[]
);
//optionally access the underlying virtualizer instance
const rowVirtualizerInstanceRef = useRef(null);
const [isLoading, setIsLoading] = useState(true);
const [sorting, setSorting] = useState([]);
const editStreamProfile = async (profile = null) => {
setProfile(profile);
setProfileModalOpen(true);
};
const deleteStreamProfile = async (id) => {
const handleDeleteStreamProfile = async (id) => {
if (id == settings.default_stream_profile) {
notifications.show({
showNotification({
title: 'Cannot delete default stream-profile',
color: 'red.5',
});
return;
}
await API.deleteStreamProfile(id);
await deleteStreamProfile(id);
};
const closeStreamProfileForm = () => {
@ -171,28 +167,14 @@ const StreamProfiles = () => {
setProfileModalOpen(false);
};
useEffect(() => {
if (typeof window !== 'undefined') {
setIsLoading(false);
}
}, []);
useEffect(() => {
//scroll to the top of the table when the sorting changes
try {
rowVirtualizerInstanceRef.current?.scrollToIndex?.(0);
} catch (error) {
console.error(error);
}
}, [sorting]);
const toggleHideInactive = () => {
setHideInactive(!hideInactive);
};
const toggleProfileIsActive = async (profile) => {
await API.updateStreamProfile({
id: profile.id,
await updateStreamProfile(
profile.id,
{
...profile,
is_active: !profile.is_active,
});
@ -200,9 +182,7 @@ const StreamProfiles = () => {
useEffect(() => {
setData(
streamProfiles.filter((profile) =>
hideInactive && !profile.is_active ? false : true
)
streamProfiles.filter((profile) => !(hideInactive && !profile.is_active))
);
}, [streamProfiles, hideInactive]);
@ -221,7 +201,7 @@ const StreamProfiles = () => {
<RowActions
row={row}
editStreamProfile={editStreamProfile}
deleteStreamProfile={deleteStreamProfile}
handleDeleteStreamProfile={handleDeleteStreamProfile}
/>
);
}
@ -255,11 +235,8 @@ const StreamProfiles = () => {
<Box
style={{
display: 'flex',
// alignItems: 'center',
// backgroundColor: theme.palette.background.paper,
justifyContent: 'flex-end',
padding: 10,
// gap: 1,
}}
>
<Flex gap={6}>

View file

@ -1,63 +1,54 @@
import React, {
useEffect,
useMemo,
useCallback,
useState,
useRef,
} from 'react';
import API from '../../api';
import React, { useCallback, useEffect, useMemo, useRef, useState, } from 'react';
import StreamForm from '../forms/Stream';
import usePlaylistsStore from '../../store/playlists';
import useChannelsStore from '../../store/channels';
import { copyToClipboard, useDebounce } from '../../utils';
import { buildLiveStreamUrl } from '../../utils/components/FloatingVideoUtils.js';
import {
SquarePlus,
ListPlus,
SquareMinus,
EllipsisVertical,
Copy,
ArrowDownWideNarrow,
ArrowUpDown,
ArrowUpNarrowWide,
ArrowDownWideNarrow,
Search,
Filter,
Square,
SquareCheck,
Copy,
EllipsisVertical,
Eye,
EyeOff,
Filter,
ListPlus,
RotateCcw,
Search,
Square,
SquareCheck,
SquareMinus,
SquarePlus,
} from 'lucide-react';
import {
TextInput,
ActionIcon,
Select,
Tooltip,
Menu,
Flex,
Box,
Text,
Paper,
Button,
Card,
Stack,
Title,
Divider,
Center,
Pagination,
Divider,
Flex,
Group,
NativeSelect,
MultiSelect,
useMantineTheme,
UnstyledButton,
Skeleton,
Modal,
NumberInput,
Radio,
LoadingOverlay,
Pill,
Menu,
MenuDivider,
MenuDropdown,
MenuItem,
MenuLabel,
MenuTarget,
MultiSelect,
NativeSelect,
Pagination,
Paper,
Stack,
Text,
TextInput,
Title,
Tooltip,
UnstyledButton,
useMantineTheme,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { useNavigate } from 'react-router-dom';
import useSettingsStore from '../../store/settings';
import useVideoStore from '../../store/useVideoStore';
@ -68,14 +59,33 @@ import useLocalStorage from '../../hooks/useLocalStorage';
import ConfirmationDialog from '../ConfirmationDialog';
import CreateChannelModal from '../modals/CreateChannelModal';
import useStreamsTableStore from '../../store/streamsTable';
import { showNotification } from '../../utils/notificationUtils.js';
import { requeryChannels } from '../../utils/forms/ChannelUtils.js';
import {
addStreamsToChannel,
appendFetchPageParams,
createChannelFromStream,
createChannelsFromStreamsAsync,
deleteStream,
deleteStreams,
getAllStreamIds,
getChannelNumberValue,
getChannelProfileIds,
getFilterParams,
getStatsTooltip,
getStreamFilterOptions,
getStreams,
queryStreamsTable,
requeryStreams,
} from '../../utils/tables/StreamsTableUtils.js';
const StreamRowActions = ({
theme,
row,
editStream,
deleteStream,
handleDeleteStream,
handleWatchStream,
createChannelFromStream,
handleCreateChannelFromStream,
table,
}) => {
const tableSize = table?.tableSize ?? 'default';
@ -90,7 +100,7 @@ const StreamRowActions = ({
);
const addStreamToChannel = async () => {
await API.addStreamsToChannel(targetChannelId, channelSelectionStreams, [
await addStreamsToChannel(targetChannelId, channelSelectionStreams, [
row.original,
]);
};
@ -100,8 +110,8 @@ const StreamRowActions = ({
}, [row.original, editStream]);
const onDelete = useCallback(() => {
deleteStream(row.original.id);
}, [row.original.id, deleteStream]);
handleDeleteStream(row.original.id);
}, [row.original.id, handleDeleteStream]);
const onPreview = useCallback(() => {
console.log(
@ -144,21 +154,21 @@ const StreamRowActions = ({
size={iconSize}
color={theme.tailwind.green[5]}
variant="transparent"
onClick={() => createChannelFromStream(row.original)}
onClick={() => handleCreateChannelFromStream(row.original)}
>
<SquarePlus size="18" fontSize="small" />
</ActionIcon>
</Tooltip>
<Menu>
<Menu.Target>
<MenuTarget>
<ActionIcon variant="transparent" size={iconSize}>
<EllipsisVertical size="18" />
</ActionIcon>
</Menu.Target>
</MenuTarget>
<Menu.Dropdown>
<Menu.Item leftSection={<Copy size="14" />}>
<MenuDropdown>
<MenuItem leftSection={<Copy size="14" />}>
<UnstyledButton
variant="unstyled"
size="xs"
@ -166,17 +176,17 @@ const StreamRowActions = ({
>
<Text size="xs">Copy URL</Text>
</UnstyledButton>
</Menu.Item>
<Menu.Item onClick={onEdit} disabled={!row.original.is_custom}>
</MenuItem>
<MenuItem onClick={onEdit} disabled={!row.original.is_custom}>
<Text size="xs">Edit</Text>
</Menu.Item>
<Menu.Item onClick={onDelete} disabled={!row.original.is_custom}>
</MenuItem>
<MenuItem onClick={onDelete} disabled={!row.original.is_custom}>
<Text size="xs">Delete Stream</Text>
</Menu.Item>
<Menu.Item onClick={onPreview}>
</MenuItem>
<MenuItem onClick={onPreview}>
<Text size="xs">Preview Stream</Text>
</Menu.Item>
</Menu.Dropdown>
</MenuItem>
</MenuDropdown>
</Menu>
</>
);
@ -230,13 +240,6 @@ const StreamsTable = ({ onReady }) => {
const [isBulkDelete, setIsBulkDelete] = useState(false);
const [deleting, setDeleting] = useState(false);
// const [allRowsSelected, setAllRowsSelected] = useState(false);
// Add local storage for page size
const [storedPageSize, setStoredPageSize] = useLocalStorage(
'streams-page-size',
50
);
const [filters, setFilters] = useState({
name: '',
channel_group: '',
@ -485,43 +488,7 @@ const StreamsTable = ({ onReady }) => {
</Text>
);
// Build compact display (resolution + video codec)
const parts = [];
if (stats.resolution) {
// Convert "1920x1080" to "1080p" format
const height = stats.resolution.split('x')[1];
if (height) parts.push(`${height}p`);
}
if (stats.video_codec) {
parts.push(stats.video_codec.toUpperCase());
}
const compactDisplay = parts.length > 0 ? parts.join(' ') : '-';
// Build tooltip content with friendly labels
const tooltipLines = [];
if (stats.resolution)
tooltipLines.push(`Resolution: ${stats.resolution}`);
if (stats.video_codec)
tooltipLines.push(
`Video Codec: ${stats.video_codec.toUpperCase()}`
);
if (stats.video_bitrate)
tooltipLines.push(`Video Bitrate: ${stats.video_bitrate} kbps`);
if (stats.source_fps)
tooltipLines.push(`Frame Rate: ${stats.source_fps} FPS`);
if (stats.audio_codec)
tooltipLines.push(
`Audio Codec: ${stats.audio_codec.toUpperCase()}`
);
if (stats.audio_channels)
tooltipLines.push(`Audio Channels: ${stats.audio_channels}`);
if (stats.audio_bitrate)
tooltipLines.push(`Audio Bitrate: ${stats.audio_bitrate} kbps`);
const tooltipContent =
tooltipLines.length > 0
? tooltipLines.join('\n')
: 'No source info available';
const { compactDisplay, tooltipContent } = getStatsTooltip(stats);
return (
<Tooltip
@ -593,15 +560,7 @@ const StreamsTable = ({ onReady }) => {
// Build a URLSearchParams object containing only the filter portion of the
// query. Page-rows fetches add page/page_size/ordering on top of this.
const buildFilterParams = useCallback(() => {
const params = new URLSearchParams();
Object.entries(debouncedFilters).forEach(([key, value]) => {
if (typeof value === 'boolean') {
if (value) params.append(key, 'true');
} else if (value !== null && value !== undefined && value !== '') {
params.append(key, String(value));
}
});
return params;
return getFilterParams(debouncedFilters);
}, [debouncedFilters]);
// Fetch the visible page of stream rows. Depends on pagination, sorting,
@ -609,21 +568,7 @@ const StreamsTable = ({ onReady }) => {
const fetchPageData = useCallback(
async ({ showLoader = true } = {}) => {
const params = buildFilterParams();
params.append('page', pagination.pageIndex + 1);
params.append('page_size', pagination.pageSize);
if (sorting.length > 0) {
const columnId = sorting[0].id;
const fieldMapping = {
name: 'name',
group: 'channel_group__name',
m3u: 'm3u_account__name',
tvg_id: 'tvg_id',
};
const sortField = fieldMapping[columnId] || columnId;
const sortDirection = sorting[0].desc ? '-' : '';
params.append('ordering', `${sortDirection}${sortField}`);
}
appendFetchPageParams(params, pagination, sorting);
const paramsString = params.toString();
@ -644,7 +589,7 @@ const StreamsTable = ({ onReady }) => {
}
try {
const result = await API.queryStreamsTable(params);
const result = await queryStreamsTable(params);
fetchInProgressRef.current = false;
@ -700,16 +645,8 @@ const StreamsTable = ({ onReady }) => {
const savedStartNumber =
localStorage.getItem('channel-numbering-start') || '1';
let startingChannelNumberValue;
if (savedMode === 'provider') {
startingChannelNumberValue = null;
} else if (savedMode === 'auto') {
startingChannelNumberValue = 0;
} else if (savedMode === 'highest') {
startingChannelNumberValue = -1;
} else {
startingChannelNumberValue = Number(savedStartNumber);
}
const startingChannelNumberValue =
getChannelNumberValue(savedMode, savedStartNumber);
await executeChannelCreation(
startingChannelNumberValue,
@ -728,7 +665,7 @@ const StreamsTable = ({ onReady }) => {
return streamFromCurrentPage;
}
const response = await API.getStreams([streamId]);
const response = await getStreams([streamId]);
return (
response?.find(
(candidate) => Number(candidate.id) === Number(streamId)
@ -740,9 +677,9 @@ const StreamsTable = ({ onReady }) => {
if (selectedStreamIds.length === 1) {
const selectedStream = await resolveSelectedStream(selectedStreamIds[0]);
if (selectedStream) {
await createChannelFromStream(selectedStream);
await handleCreateChannelFromStream(selectedStream);
} else {
notifications.show({
showNotification({
color: 'red',
title: 'Stream not found',
message:
@ -761,25 +698,13 @@ const StreamsTable = ({ onReady }) => {
profileIds = null
) => {
try {
// Convert profile selection: 'all' means all profiles (null), 'none' means no profiles ([]), specific IDs otherwise
let channelProfileIds;
if (profileIds) {
if (profileIds.includes('none')) {
channelProfileIds = [];
} else if (profileIds.includes('all')) {
channelProfileIds = null;
} else {
channelProfileIds = profileIds
.filter((id) => id !== 'all' && id !== 'none')
.map((id) => parseInt(id));
}
} else {
channelProfileIds =
selectedProfileId !== '0' ? [parseInt(selectedProfileId)] : null;
}
const channelProfileIds = getChannelProfileIds(
profileIds,
selectedProfileId
);
// Use the async API for all bulk operations
const response = await API.createChannelsFromStreamsAsync(
const response = await createChannelsFromStreamsAsync(
selectedStreamIds,
channelProfileIds,
startingChannelNumberValue
@ -814,14 +739,7 @@ const StreamsTable = ({ onReady }) => {
}
// Convert mode to API value
const startingChannelNumberValue =
numberingMode === 'provider'
? null
: numberingMode === 'auto'
? 0
: numberingMode === 'highest'
? -1
: Number(customStartNumber);
const startingChannelNumberValue = getChannelNumberValue(numberingMode, customStartNumber);
setChannelNumberingModalOpen(false);
await executeChannelCreation(
@ -839,7 +757,7 @@ const StreamsTable = ({ onReady }) => {
setModalOpen(true);
};
const deleteStream = async (id) => {
const handleDeleteStream = async (id) => {
// Get stream details for the confirmation dialog
const streamObj = data.find((s) => s.id === id);
setStreamToDelete(streamObj);
@ -858,7 +776,7 @@ const StreamsTable = ({ onReady }) => {
setDeleting(true);
setIsLoading(true);
try {
await API.deleteStream(id);
await deleteStream(id);
// Clear the selection for the deleted stream
setSelectedStreamIds([]);
table.setSelectedTableIds([]);
@ -869,7 +787,7 @@ const StreamsTable = ({ onReady }) => {
}
};
const deleteStreams = async () => {
const handleDeleteStreams = async () => {
setIsBulkDelete(true);
setStreamToDelete(null);
@ -885,7 +803,7 @@ const StreamsTable = ({ onReady }) => {
setDeleting(true);
setIsLoading(true);
try {
await API.deleteStreams(selectedStreamIds);
await deleteStreams(selectedStreamIds);
setSelectedStreamIds([]);
table.setSelectedTableIds([]);
} finally {
@ -900,14 +818,14 @@ const StreamsTable = ({ onReady }) => {
setModalOpen(false);
setIsLoading(true);
try {
await API.requeryStreams();
await requeryStreams();
} finally {
setIsLoading(false);
}
};
// Single channel creation functions
const createChannelFromStream = async (stream) => {
const handleCreateChannelFromStream = async (stream) => {
// Set default profile selection based on current profile filter
const defaultProfileIds =
selectedProfileId === '0' ? ['all'] : [selectedProfileId];
@ -922,16 +840,7 @@ const StreamsTable = ({ onReady }) => {
const savedChannelNumber =
localStorage.getItem('single-channel-numbering-specific') || '1';
let channelNumberValue;
if (savedMode === 'provider') {
channelNumberValue = null;
} else if (savedMode === 'auto') {
channelNumberValue = 0;
} else if (savedMode === 'highest') {
channelNumberValue = -1;
} else {
channelNumberValue = Number(savedChannelNumber);
}
const channelNumberValue = getChannelNumberValue(savedMode, savedChannelNumber);
await executeSingleChannelCreation(
stream,
@ -951,30 +860,14 @@ const StreamsTable = ({ onReady }) => {
channelNumber = null,
profileIds = null
) => {
// Convert profile selection: 'all' means all profiles (null), 'none' means no profiles ([]), specific IDs otherwise
let channelProfileIds;
if (profileIds) {
if (profileIds.includes('none')) {
channelProfileIds = [];
} else if (profileIds.includes('all')) {
channelProfileIds = null;
} else {
channelProfileIds = profileIds
.filter((id) => id !== 'all' && id !== 'none')
.map((id) => parseInt(id));
}
} else {
channelProfileIds =
selectedProfileId !== '0' ? [parseInt(selectedProfileId)] : null;
}
await API.createChannelFromStream({
const channelProfileIds = getChannelProfileIds(profileIds, selectedProfileId);
await createChannelFromStream({
name: stream.name,
channel_number: channelNumber,
stream_id: stream.id,
channel_profile_ids: channelProfileIds,
});
await API.requeryChannels();
await requeryChannels();
};
// Handle confirming the single channel numbering modal
@ -992,14 +885,7 @@ const StreamsTable = ({ onReady }) => {
}
// Convert mode to API value
const channelNumberValue =
singleChannelMode === 'provider'
? null
: singleChannelMode === 'auto'
? 0
: singleChannelMode === 'highest'
? -1
: Number(specificChannelNumber);
const channelNumberValue = getChannelNumberValue(singleChannelMode, specificChannelNumber);
setSingleChannelModalOpen(false);
await executeSingleChannelCreation(
@ -1013,11 +899,11 @@ const StreamsTable = ({ onReady }) => {
setSingleChannelMode(nextMode);
};
const addStreamsToChannel = async () => {
const handleAddStreamsToChannel = async () => {
// Look up full stream objects from the current page data
const selectedIdSet = new Set(selectedStreamIds);
const newStreams = data.filter((s) => selectedIdSet.has(s.id));
await API.addStreamsToChannel(
await addStreamsToChannel(
targetChannelId,
channelSelectionStreams,
newStreams
@ -1030,7 +916,6 @@ const StreamsTable = ({ onReady }) => {
const onPageSizeChange = (e) => {
const newPageSize = parseInt(e.target.value);
setStoredPageSize(newPageSize);
setPagination({
...pagination,
pageSize: newPageSize,
@ -1246,14 +1131,14 @@ const StreamsTable = ({ onReady }) => {
theme={theme}
row={row}
editStream={editStream}
deleteStream={deleteStream}
handleDeleteStream={handleDeleteStream}
handleWatchStream={handleWatchStream}
createChannelFromStream={createChannelFromStream}
handleCreateChannelFromStream={handleCreateChannelFromStream}
/>
);
}
},
[theme, editStream, deleteStream, handleWatchStream]
[theme, editStream, handleDeleteStream, handleWatchStream]
);
const table = useTable({
@ -1316,7 +1201,7 @@ const StreamsTable = ({ onReady }) => {
lastIdsParamsRef.current = paramsString;
let cancelled = false;
(async () => {
const ids = await API.getAllStreamIds(params);
const ids = await getAllStreamIds(params);
if (!cancelled && ids) {
setAllRowIds(ids);
}
@ -1335,7 +1220,7 @@ const StreamsTable = ({ onReady }) => {
lastFilterOptionsParamsRef.current = paramsString;
let cancelled = false;
(async () => {
const filterOptions = await API.getStreamFilterOptions(params);
const filterOptions = await getStreamFilterOptions(params);
if (cancelled || !filterOptions || typeof filterOptions !== 'object') {
return;
}
@ -1377,16 +1262,14 @@ const StreamsTable = ({ onReady }) => {
return;
}
const loadGroups = async () => {
(async () => {
hasFetchedChannelGroups.current = true;
try {
await fetchChannelGroups();
} catch (error) {
console.error('Error fetching channel groups:', error);
}
};
loadGroups();
})();
}, [channelGroups, fetchChannelGroups]);
useEffect(() => {
@ -1466,7 +1349,6 @@ const StreamsTable = ({ onReady }) => {
fontSize: '20px',
lineHeight: 1,
letterSpacing: '-0.3px',
// color: 'gray.6', // Adjust this to match MUI's theme.palette.text.secondary
marginBottom: 0,
}}
>
@ -1501,7 +1383,7 @@ const StreamsTable = ({ onReady }) => {
: 'default'
}
size="xs"
onClick={addStreamsToChannel}
onClick={handleAddStreamsToChannel}
p={5}
color={
selectedStreamIds.length > 0 && targetChannelId
@ -1544,16 +1426,16 @@ const StreamsTable = ({ onReady }) => {
<Flex gap={6} wrap="nowrap" style={{ flexShrink: 0 }}>
<Menu shadow="md" width={200}>
<Menu.Target>
<MenuTarget>
<Tooltip label="Filters" openDelay={500}>
<Button size="xs" variant="default">
<Filter size={18} />
</Button>
</Tooltip>
</Menu.Target>
</MenuTarget>
<Menu.Dropdown>
<Menu.Item
<MenuDropdown>
<MenuItem
onClick={toggleUnassignedOnly}
leftSection={
filters.unassigned === true ? (
@ -1564,8 +1446,8 @@ const StreamsTable = ({ onReady }) => {
}
>
<Text size="xs">Only Unassociated</Text>
</Menu.Item>
<Menu.Item
</MenuItem>
<MenuItem
onClick={toggleHideStale}
leftSection={
filters.hide_stale === true ? (
@ -1576,8 +1458,8 @@ const StreamsTable = ({ onReady }) => {
}
>
<Text size="xs">Hide Stale</Text>
</Menu.Item>
</Menu.Dropdown>
</MenuItem>
</MenuDropdown>
</Menu>
<Tooltip label="Create a new custom stream" openDelay={500}>
@ -1603,7 +1485,7 @@ const StreamsTable = ({ onReady }) => {
leftSection={<SquareMinus size={18} />}
variant="default"
size="xs"
onClick={deleteStreams}
onClick={handleDeleteStreams}
disabled={selectedStreamIds.length == 0}
>
Delete
@ -1611,17 +1493,17 @@ const StreamsTable = ({ onReady }) => {
</Tooltip>
<Menu shadow="md" width={200}>
<Menu.Target>
<MenuTarget>
<Tooltip label="Table Settings" openDelay={500}>
<ActionIcon variant="default" size={30}>
<EllipsisVertical size={18} />
</ActionIcon>
</Tooltip>
</Menu.Target>
</MenuTarget>
<Menu.Dropdown>
<Menu.Label>Toggle Columns</Menu.Label>
<Menu.Item
<MenuDropdown>
<MenuLabel>Toggle Columns</MenuLabel>
<MenuItem
onClick={() => toggleColumnVisibility('name')}
leftSection={
columnVisibility.name !== false ? (
@ -1632,8 +1514,8 @@ const StreamsTable = ({ onReady }) => {
}
>
<Text size="xs">Name</Text>
</Menu.Item>
<Menu.Item
</MenuItem>
<MenuItem
onClick={() => toggleColumnVisibility('group')}
leftSection={
columnVisibility.group !== false ? (
@ -1644,8 +1526,8 @@ const StreamsTable = ({ onReady }) => {
}
>
<Text size="xs">Group</Text>
</Menu.Item>
<Menu.Item
</MenuItem>
<MenuItem
onClick={() => toggleColumnVisibility('m3u')}
leftSection={
columnVisibility.m3u !== false ? (
@ -1656,8 +1538,8 @@ const StreamsTable = ({ onReady }) => {
}
>
<Text size="xs">M3U</Text>
</Menu.Item>
<Menu.Item
</MenuItem>
<MenuItem
onClick={() => toggleColumnVisibility('tvg_id')}
leftSection={
columnVisibility.tvg_id !== false ? (
@ -1668,8 +1550,8 @@ const StreamsTable = ({ onReady }) => {
}
>
<Text size="xs">TVG-ID</Text>
</Menu.Item>
<Menu.Item
</MenuItem>
<MenuItem
onClick={() => toggleColumnVisibility('stats')}
leftSection={
columnVisibility.stats !== false ? (
@ -1680,15 +1562,15 @@ const StreamsTable = ({ onReady }) => {
}
>
<Text size="xs">Stats</Text>
</Menu.Item>
<Menu.Divider />
<Menu.Item
</MenuItem>
<MenuDivider />
<MenuItem
onClick={resetColumnVisibility}
leftSection={<RotateCcw size={18} />}
>
<Text size="xs">Reset to Default</Text>
</Menu.Item>
</Menu.Dropdown>
</MenuItem>
</MenuDropdown>
</Menu>
</Flex>
</Flex>

View file

@ -1,15 +1,12 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useMemo, useState } from 'react';
import API from '../../api';
import useUserAgentsStore from '../../store/userAgents';
import UserAgentForm from '../forms/UserAgent';
import { TableHelper } from '../../helpers';
import useSettingsStore from '../../store/settings';
import { notifications } from '@mantine/notifications';
import {
ActionIcon,
Center,
Flex,
Select,
Tooltip,
Text,
Paper,
@ -20,8 +17,20 @@ import {
import { SquareMinus, SquarePen, Check, X, SquarePlus } from 'lucide-react';
import { CustomTable, useTable } from './CustomTable';
import useLocalStorage from '../../hooks/useLocalStorage';
import { showNotification } from '../../utils/notificationUtils.js';
const RowActions = ({ row, editUserAgent, deleteUserAgent }) => {
const deleteUserAgents = async (ids) => {
for (const id of ids) {
try {
await API.deleteUserAgent(id);
} catch {
/* empty */
}
}
};
const deleteUserAgent = (id) => API.deleteUserAgent(id);
const RowActions = ({ row, editUserAgent, handleDeleteUserAgent }) => {
return (
<>
<ActionIcon
@ -38,7 +47,7 @@ const RowActions = ({ row, editUserAgent, deleteUserAgent }) => {
variant="transparent"
size="sm"
color="red.9" // Red color for delete actions
onClick={() => deleteUserAgent(row.original.id)}
onClick={() => handleDeleteUserAgent(row.original.id)}
>
<SquareMinus size="18" /> {/* Small icon size */}
</ActionIcon>
@ -49,7 +58,6 @@ const RowActions = ({ row, editUserAgent, deleteUserAgent }) => {
const UserAgentsTable = () => {
const [userAgent, setUserAgent] = useState(null);
const [userAgentModalOpen, setUserAgentModalOpen] = useState(false);
const [activeFilterValue, setActiveFilterValue] = useState('all');
const userAgents = useUserAgentsStore((state) => state.userAgents);
const settings = useSettingsStore((s) => s.settings);
@ -117,35 +125,30 @@ const UserAgentsTable = () => {
[]
);
const [isLoading, setIsLoading] = useState(true);
const [sorting, setSorting] = useState([]);
const editUserAgent = async (userAgent = null) => {
setUserAgent(userAgent);
setUserAgentModalOpen(true);
};
const deleteUserAgent = async (ids) => {
const handleDeleteUserAgent = async (ids) => {
if (Array.isArray(ids)) {
if (ids.includes(settings.default_user_agent)) {
notifications.show({
showNotification({
title: 'Cannot delete default user-agent',
color: 'red.5',
});
return;
}
await API.deleteUserAgents(ids);
await deleteUserAgents(ids);
} else {
if (ids == settings.default_user_agent) {
notifications.show({
showNotification({
title: 'Cannot delete default user-agent',
color: 'red.5',
});
return;
}
await API.deleteUserAgent(ids);
await deleteUserAgent(ids);
}
};
@ -154,12 +157,6 @@ const UserAgentsTable = () => {
setUserAgentModalOpen(false);
};
useEffect(() => {
if (typeof window !== 'undefined') {
setIsLoading(false);
}
}, []);
const renderHeaderCell = (header) => {
switch (header.id) {
default:
@ -178,7 +175,7 @@ const UserAgentsTable = () => {
<RowActions
row={row}
editUserAgent={editUserAgent}
deleteUserAgent={deleteUserAgent}
handleDeleteUserAgent={handleDeleteUserAgent}
/>
);
}

View file

@ -26,6 +26,9 @@ import ConfirmationDialog from '../ConfirmationDialog';
import useLocalStorage from '../../hooks/useLocalStorage';
import { useDateTimeFormat, format } from '../../utils/dateTimeUtils.js';
const deleteUser = (id) => {
return API.deleteUser(id);
};
const XCPasswordCell = ({ getValue }) => {
const [isVisible, setIsVisible] = useState(false);
const customProps = getValue() || {};
@ -67,7 +70,7 @@ const XCPasswordCell = ({ getValue }) => {
);
};
const UserRowActions = ({ theme, row, editUser, deleteUser }) => {
const UserRowActions = ({ theme, row, editUser, handleDeleteUser }) => {
const [tableSize, _] = useLocalStorage('table-size', 'default');
const authUser = useAuthStore((s) => s.user);
@ -76,8 +79,8 @@ const UserRowActions = ({ theme, row, editUser, deleteUser }) => {
}, [row.original, editUser]);
const onDelete = useCallback(() => {
deleteUser(row.original.id);
}, [row.original.id, deleteUser]);
handleDeleteUser(row.original.id);
}, [row.original.id, handleDeleteUser]);
const iconSize =
tableSize == 'default' ? 'sm' : tableSize == 'compact' ? 'xs' : 'md';
@ -138,7 +141,7 @@ const UsersTable = () => {
setIsLoading(true);
setDeleting(true);
try {
await API.deleteUser(id);
await deleteUser(id);
} finally {
setDeleting(false);
setIsLoading(false);
@ -151,7 +154,7 @@ const UsersTable = () => {
setUserModalOpen(true);
}, []);
const deleteUser = useCallback(
const handleDeleteUser = useCallback(
async (id) => {
const user = users.find((u) => u.id === id);
setUserToDelete(user);
@ -317,12 +320,12 @@ const UsersTable = () => {
theme={theme}
row={row}
editUser={editUser}
deleteUser={deleteUser}
handleDeleteUser={handleDeleteUser}
/>
),
},
],
[theme, editUser, deleteUser, fullDateFormat, fullDateTimeFormat]
[theme, editUser, handleDeleteUser, fullDateFormat, fullDateTimeFormat]
);
const closeUserForm = () => {

View file

@ -6,7 +6,6 @@ import {
Button,
Center,
Checkbox,
Flex,
Group,
Image,
LoadingOverlay,
@ -20,12 +19,12 @@ import {
Tooltip,
useMantineTheme,
} from '@mantine/core';
import { ExternalLink, Search, Trash2, Trash, SquareMinus } from 'lucide-react';
import { ExternalLink, Trash, SquareMinus } from 'lucide-react';
import useVODLogosStore from '../../store/vodLogos';
import useLocalStorage from '../../hooks/useLocalStorage';
import { CustomTable, useTable } from './CustomTable';
import ConfirmationDialog from '../ConfirmationDialog';
import { notifications } from '@mantine/notifications';
import { showNotification } from '../../utils/notificationUtils.js';
const VODLogoRowActions = ({ theme, row, deleteLogo }) => {
const [tableSize] = useLocalStorage('table-size', 'default');
@ -79,8 +78,8 @@ export default function VODLogosTable() {
const [paginationString, setPaginationString] = useState('');
const [isCleaningUp, setIsCleaningUp] = useState(false);
const [unusedLogosCount, setUnusedLogosCount] = useState(0);
const [loadingUnusedCount, setLoadingUnusedCount] = useState(false);
const tableRef = React.useRef(null);
useEffect(() => {
fetchVODLogos({
page: currentPage,
@ -93,14 +92,11 @@ export default function VODLogosTable() {
// Fetch the total count of unused logos
useEffect(() => {
const fetchUnusedCount = async () => {
setLoadingUnusedCount(true);
try {
const count = await getUnusedLogosCount();
setUnusedLogosCount(count);
} catch (error) {
console.error('Failed to fetch unused logos count:', error);
} finally {
setLoadingUnusedCount(false);
}
};
@ -157,21 +153,21 @@ export default function VODLogosTable() {
try {
if (deleteTarget.length === 1) {
await deleteVODLogo(deleteTarget[0]);
notifications.show({
showNotification({
title: 'Success',
message: 'VOD logo deleted successfully',
color: 'green',
});
} else {
await deleteVODLogos(deleteTarget);
notifications.show({
showNotification({
title: 'Success',
message: `${deleteTarget.length} VOD logos deleted successfully`,
color: 'green',
});
}
} catch (error) {
notifications.show({
showNotification({
title: 'Error',
message: error.message || 'Failed to delete VOD logos',
color: 'red',
@ -193,7 +189,7 @@ export default function VODLogosTable() {
setIsCleaningUp(true);
try {
const result = await cleanupUnusedVODLogos();
notifications.show({
showNotification({
title: 'Success',
message: `Cleaned up ${result.deleted_count} unused VOD logos`,
color: 'green',
@ -202,7 +198,7 @@ export default function VODLogosTable() {
const newCount = await getUnusedLogosCount();
setUnusedLogosCount(newCount);
} catch (error) {
notifications.show({
showNotification({
title: 'Error',
message: error.message || 'Failed to cleanup unused VOD logos',
color: 'red',
@ -315,7 +311,7 @@ export default function VODLogosTable() {
const usageParts = [];
if (movie_count > 0) {
usageParts.push(
`${movie_count} movie${movie_count !== 1 ? 's' : ''}`
`${movie_count} ${movie_count !== 1 ? 'movies' : 'movie'}`
);
}
if (series_count > 0) {
@ -325,7 +321,7 @@ export default function VODLogosTable() {
const label =
usageParts.length === 1
? usageParts[0]
: `${totalUsage} item${totalUsage !== 1 ? 's' : ''}`;
: `${totalUsage} ${totalUsage !== 1 ? 'items' : 'item'}`;
return (
<Tooltip