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( - + - + - - }> + + }> copyToClipboard(getChannelURL(row.original))} > Copy URL - - + Record - - + + @@ -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 > - + - - + + { clients. { .map((p) => ({ value: `${p.id}`, label: p.name }))} /> - + { position="bottom-start" withinPortal > - + - - + + { channel list. { .map((p) => ({ value: `${p.id}`, label: p.name }))} /> - + { position="bottom-start" withinPortal > - + - - + + { 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 = ({ > - + - + - - + : @@ -319,9 +301,9 @@ const ChannelTableHeader = ({ {showDisabled ? 'Hide Disabled' : 'Show Disabled'} - + - Only Empty Channels - + - Has Stale Streams - + - Has Overrides - + - - + + Visibility - + {[ { value: 'active', label: 'Active Only' }, { value: 'hidden', label: 'Hidden Only' }, { value: 'all', label: 'Show All' }, ].map(({ value, label }) => ( - setVisibilityFilter && setVisibilityFilter(value) @@ -384,9 +366,9 @@ const ChannelTableHeader = ({ } > {label} - + ))} - + - - - - Standard EPG Source - - Dummy EPG Source - + + + Standard EPG Source + Dummy EPG Source + @@ -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)} >
- + - + - - }> + + }> Copy URL - - + + Edit - - + + Delete Stream - - + + Preview Stream - - + + ); @@ -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 }) => { - + - + - - + { } > Only Unassociated - - + { } > Hide Stale - - + + @@ -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 }) => { - + - + - - Toggle Columns - + Toggle Columns + toggleColumnVisibility('name')} leftSection={ columnVisibility.name !== false ? ( @@ -1632,8 +1514,8 @@ const StreamsTable = ({ onReady }) => { } > Name - - + toggleColumnVisibility('group')} leftSection={ columnVisibility.group !== false ? ( @@ -1644,8 +1526,8 @@ const StreamsTable = ({ onReady }) => { } > Group - - + toggleColumnVisibility('m3u')} leftSection={ columnVisibility.m3u !== false ? ( @@ -1656,8 +1538,8 @@ const StreamsTable = ({ onReady }) => { } > M3U - - + toggleColumnVisibility('tvg_id')} leftSection={ columnVisibility.tvg_id !== false ? ( @@ -1668,8 +1550,8 @@ const StreamsTable = ({ onReady }) => { } > TVG-ID - - + toggleColumnVisibility('stats')} leftSection={ columnVisibility.stats !== false ? ( @@ -1680,15 +1562,15 @@ const StreamsTable = ({ onReady }) => { } > Stats - - - + + } > Reset to Default - - + + 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 (