From 28cff8e87b7f27dcfd0dfd7f15a6e802a2aae74d Mon Sep 17 00:00:00 2001
From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date: Sat, 27 Jun 2026 01:38:33 -0700
Subject: [PATCH] Refactored components
---
.../components/tables/ChannelTableStreams.jsx | 200 ++------
.../src/components/tables/ChannelsTable.jsx | 354 +++++---------
.../ChannelsTable/ChannelTableHeader.jsx | 132 +++--
.../tables/ChannelsTable/EditableCell.jsx | 134 +----
.../tables/CustomTable/CustomTable.jsx | 11 +-
.../tables/CustomTable/CustomTableHeader.jsx | 22 +-
.../components/tables/CustomTable/index.jsx | 14 +-
frontend/src/components/tables/EPGsTable.jsx | 183 ++-----
frontend/src/components/tables/LogosTable.jsx | 138 ++----
frontend/src/components/tables/M3UsTable.jsx | 458 ++++--------------
.../components/tables/OutputProfilesTable.jsx | 51 +-
.../components/tables/StreamProfilesTable.jsx | 57 +--
.../src/components/tables/StreamsTable.jsx | 378 +++++----------
.../src/components/tables/UserAgentsTable.jsx | 45 +-
frontend/src/components/tables/UsersTable.jsx | 17 +-
.../src/components/tables/VODLogosTable.jsx | 24 +-
16 files changed, 629 insertions(+), 1589 deletions(-)
diff --git a/frontend/src/components/tables/ChannelTableStreams.jsx b/frontend/src/components/tables/ChannelTableStreams.jsx
index a38795a2..71da2cee 100644
--- a/frontend/src/components/tables/ChannelTableStreams.jsx
+++ b/frontend/src/components/tables/ChannelTableStreams.jsx
@@ -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 (
-
-
-
- {flexRender(cell.column.columnDef.cell, cell.getContext())}
-
-
-
- );
- })}
+ {row.getVisibleCells().map((cell) => (
+
+
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+
+
+
+ ))}
);
},
@@ -260,7 +146,7 @@ const StatsCategory = ({ categoryName, stats }) => {
{Object.entries(stats).map(([key, value]) => (
- {key.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())}:{' '}
+ {formatStatKey(key)}:{' '}
{formatStatValue(key, value)}
@@ -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)
);
diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx
index 797471ae..e21a003c 100644
--- a/frontend/src/components/tables/ChannelsTable.jsx
+++ b/frontend/src/components/tables/ChannelsTable.jsx
@@ -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(
@@ -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
>
-
+
}
size="compact-sm"
@@ -1312,8 +1194,8 @@ const ChannelsTable = ({ onReady }) => {
>
HDHR
-
-
+
+
{
clients.
{
.map((p) => ({ value: `${p.id}`, label: p.name }))}
/>
-
+
{
position="bottom-start"
withinPortal
>
-
+
}
size="compact-sm"
@@ -1381,8 +1263,8 @@ const ChannelsTable = ({ onReady }) => {
>
M3U
-
-
+
+
{
channel list.
{
.map((p) => ({ value: `${p.id}`, label: p.name }))}
/>
-
+
{
position="bottom-start"
withinPortal
>
-
+
}
size="compact-sm"
@@ -1515,8 +1397,8 @@ const ChannelsTable = ({ onReady }) => {
>
EPG
-
-
+
+
{
clients.
{
}
/>
-
+
@@ -1626,7 +1508,7 @@ const ChannelsTable = ({ onReady }) => {
{
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"
>
-
+
{
>
-
+
-
+
{
-
+
);
});
@@ -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 = ({
>
diff --git a/frontend/src/components/tables/ChannelsTable/EditableCell.jsx b/frontend/src/components/tables/ChannelsTable/EditableCell.jsx
index 55bd44e3..3d0394a4 100644
--- a/frontend/src/components/tables/ChannelsTable/EditableCell.jsx
+++ b/frontend/src/components/tables/ChannelsTable/EditableCell.jsx
@@ -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
diff --git a/frontend/src/components/tables/CustomTable/CustomTable.jsx b/frontend/src/components/tables/CustomTable/CustomTable.jsx
index d0dcdbd8..440b8412 100644
--- a/frontend/src/components/tables/CustomTable/CustomTable.jsx
+++ b/frontend/src/components/tables/CustomTable/CustomTable.jsx
@@ -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.
diff --git a/frontend/src/components/tables/CustomTable/CustomTableHeader.jsx b/frontend/src/components/tables/CustomTable/CustomTableHeader.jsx
index 0083abb4..bd52feb4 100644
--- a/frontend/src/components/tables/CustomTable/CustomTableHeader.jsx
+++ b/frontend/src/components/tables/CustomTable/CustomTableHeader.jsx
@@ -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 {content};
};
- // 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 })),
}}
>
{
+ 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]) {
diff --git a/frontend/src/components/tables/EPGsTable.jsx b/frontend/src/components/tables/EPGsTable.jsx
index 56a3de62..80da7c24 100644
--- a/frontend/src/components/tables/EPGsTable.jsx
+++ b/frontend/src/components/tables/EPGsTable.jsx
@@ -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 (
@@ -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 (
-
-
- {header.column.columnDef.header}
-
- {header.column.columnDef.sortable && (
-
- {React.createElement(sortingIcon, {
- onClick: () => onSortingChange(header.id),
- size: 14,
- })}
-
- )}
-
- );
- }
- };
-
- 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
@@ -668,11 +576,8 @@ const EPGsTable = () => {
diff --git a/frontend/src/components/tables/LogosTable.jsx b/frontend/src/components/tables/LogosTable.jsx
index 7c718590..ff5784d5 100644
--- a/frontend/src/components/tables/LogosTable.jsx
+++ b/frontend/src/components/tables/LogosTable.jsx
@@ -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 (
{
theme={theme}
row={row}
editLogo={editLogo}
- deleteLogo={deleteLogo}
+ handleDeleteLogo={handleDeleteLogo}
/>
),
},
@@ -536,7 +488,7 @@ const LogosTable = () => {
[
theme,
editLogo,
- deleteLogo,
+ handleDeleteLogo,
selectedRows,
handleSelectRow,
handleSelectAll,
diff --git a/frontend/src/components/tables/M3UsTable.jsx b/frontend/src/components/tables/M3UsTable.jsx
index 4a1d7e6f..fa21191e 100644
--- a/frontend/src/components/tables/M3UsTable.jsx
+++ b/frontend/src/components/tables/M3UsTable.jsx
@@ -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 }) => (
+
+ {label}{value ? ':' : ''}
+ {value && {value}}
+
+);
-// 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 }) => (
+
+ {children}
+
+);
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)}
>
@@ -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}
>
@@ -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 (
+
+ );
case 'downloading':
- return buildDownloadingStats(data);
-
- case 'processing_groups':
- return buildGroupProcessingStats(data);
-
+ return (
+
+
+
+
+
+ );
+ case 'groups':
+ return (
+
+
+ {content.elapsedTime && }
+ {content.groupsProcessed && }
+
+ );
case 'parsing':
- return buildParsingStats(data);
-
+ return (
+
+
+ {content.elapsedTime && }
+ {content.timeRemaining && }
+ {content.streamsProcessed && }
+
+ );
+ case 'error':
+ return (
+
+ Error:
+
+ {content.error || 'Unknown error occurred'}
+
+
+ );
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 (
-
-
-
-
- Downloading:
-
- {parseInt(data.progress)}%
-
-
-
- Speed:
-
- {speed}
-
-
-
- Time left:
-
- {timeRemaining}
-
-
-
- );
- };
-
- 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 (
-
-
-
-
- Processing groups:
-
- {parseInt(data.progress)}%
-
- {elapsedTime && (
-
-
- Elapsed:
-
- {elapsedTime}
-
- )}
- {data.groups_processed && (
-
-
- Groups:
-
- {data.groups_processed}
-
- )}
-
-
- );
- };
-
- const buildErrorStats = (data) => {
- return (
-
-
-
-
- Error:
-
-
-
- {data.error || 'Unknown error occurred'}
-
-
-
- );
- };
-
- 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 (
-
-
-
-
- Parsing:
-
- {parseInt(data.progress)}%
-
- {data.elapsed_time && (
-
-
- Elapsed:
-
- {elapsedTime}
-
- )}
- {data.time_remaining && (
-
-
- Remaining:
-
- {timeRemaining}
-
- )}
- {data.streams_processed && (
-
-
- Streams:
-
- {data.streams_processed}
-
- )}
-
-
- );
- };
-
- const buildInitializingStats = () => {
- return (
-
-
-
-
- Initializing refresh...
-
-
-
-
- );
- };
-
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 (
{
},
],
[
- 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 (
-
-
- {header.column.columnDef.header}
-
- {header.column.columnDef.sortable && (
-
- {React.createElement(sortingIcon, {
- onClick: () => onSortingChange(header.id),
- size: 14,
- })}
-
- )}
-
- );
- }
- };
+ const renderHeaderCell = makeHeaderCellRenderer(sorting, onSortingChange);
const renderBodyCell = useCallback(({ cell, row }) => {
switch (cell.column.id) {
@@ -899,9 +633,9 @@ const M3UTable = () => {
);
}
@@ -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 = () => {
@@ -1115,7 +845,7 @@ This action cannot be undone.`}
}}
>
- {`${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.`}
) : null}
diff --git a/frontend/src/components/tables/OutputProfilesTable.jsx b/frontend/src/components/tables/OutputProfilesTable.jsx
index b72d3463..15c91ada 100644
--- a/frontend/src/components/tables/OutputProfilesTable.jsx
+++ b/frontend/src/components/tables/OutputProfilesTable.jsx
@@ -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) => (
diff --git a/frontend/src/components/tables/StreamProfilesTable.jsx b/frontend/src/components/tables/StreamProfilesTable.jsx
index d34d58ea..537ffaad 100644
--- a/frontend/src/components/tables/StreamProfilesTable.jsx
+++ b/frontend/src/components/tables/StreamProfilesTable.jsx
@@ -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 (
<>
{
size="sm"
color="red.9"
disabled={row.original.locked}
- onClick={() => deleteStreamProfile(row.original.id)}
+ onClick={() => handleDeleteStreamProfile(row.original.id)}
>
{/* Small icon size */}
@@ -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 = () => {
);
}
@@ -255,11 +235,8 @@ const StreamProfiles = () => {
diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx
index 14fed719..aaeba59d 100644
--- a/frontend/src/components/tables/StreamsTable.jsx
+++ b/frontend/src/components/tables/StreamsTable.jsx
@@ -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)}
>
>
);
@@ -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 }) => {
);
- // 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 (
{
// 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 }) => {
@@ -1603,7 +1485,7 @@ const StreamsTable = ({ onReady }) => {
leftSection={}
variant="default"
size="xs"
- onClick={deleteStreams}
+ onClick={handleDeleteStreams}
disabled={selectedStreamIds.length == 0}
>
Delete
@@ -1611,17 +1493,17 @@ const StreamsTable = ({ onReady }) => {
diff --git a/frontend/src/components/tables/UserAgentsTable.jsx b/frontend/src/components/tables/UserAgentsTable.jsx
index ffd47719..585d6f7f 100644
--- a/frontend/src/components/tables/UserAgentsTable.jsx
+++ b/frontend/src/components/tables/UserAgentsTable.jsx
@@ -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 (
<>
{
variant="transparent"
size="sm"
color="red.9" // Red color for delete actions
- onClick={() => deleteUserAgent(row.original.id)}
+ onClick={() => handleDeleteUserAgent(row.original.id)}
>
{/* Small icon size */}
@@ -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 = () => {
);
}
diff --git a/frontend/src/components/tables/UsersTable.jsx b/frontend/src/components/tables/UsersTable.jsx
index 7ec9504c..541452c7 100644
--- a/frontend/src/components/tables/UsersTable.jsx
+++ b/frontend/src/components/tables/UsersTable.jsx
@@ -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 = () => {
diff --git a/frontend/src/components/tables/VODLogosTable.jsx b/frontend/src/components/tables/VODLogosTable.jsx
index b5529a57..591b99dc 100644
--- a/frontend/src/components/tables/VODLogosTable.jsx
+++ b/frontend/src/components/tables/VODLogosTable.jsx
@@ -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 (