mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 00:55:50 +00:00
Refactored components
This commit is contained in:
parent
1a46ca4be4
commit
28cff8e87b
16 changed files with 629 additions and 1589 deletions
|
|
@ -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)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue