From fcce1a36b27236dc8f4e06f409432e78a54032a0 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 12 Jul 2025 16:08:04 -0500 Subject: [PATCH 01/12] Implement group management features: add GroupManager component. --- apps/channels/api_views.py | 20 + frontend/src/api.js | 16 + .../src/components/forms/GroupManager.jsx | 350 ++++++++++++++++++ .../ChannelsTable/ChannelTableHeader.jsx | 17 + frontend/src/store/channels.jsx | 12 +- 5 files changed, 413 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/forms/GroupManager.jsx diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index b651081e..5d00e84d 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -187,6 +187,26 @@ class ChannelGroupViewSet(viewsets.ModelViewSet): except KeyError: return [Authenticated()] + def destroy(self, request, *args, **kwargs): + """Override destroy to check for associations before deletion""" + instance = self.get_object() + + # Check if group has associated channels + if instance.channels.exists(): + return Response( + {"error": "Cannot delete group with associated channels"}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Check if group has M3U account associations + if hasattr(instance, 'm3u_account') and instance.m3u_account.exists(): + return Response( + {"error": "Cannot delete group with M3U account associations"}, + status=status.HTTP_400_BAD_REQUEST + ) + + return super().destroy(request, *args, **kwargs) + # ───────────────────────────────────────────────────────── # 3) Channel Management (CRUD) diff --git a/frontend/src/api.js b/frontend/src/api.js index d75edfa9..ff95f634 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -277,6 +277,22 @@ export default class API { } } + static async deleteChannelGroup(id) { + try { + await request(`${host}/api/channels/groups/${id}/`, { + method: 'DELETE', + }); + + // Remove from store after successful deletion + useChannelsStore.getState().removeChannelGroup(id); + + return true; + } catch (e) { + errorNotification('Failed to delete channel group', e); + throw e; + } + } + static async addChannel(channel) { try { let body = null; diff --git a/frontend/src/components/forms/GroupManager.jsx b/frontend/src/components/forms/GroupManager.jsx new file mode 100644 index 00000000..7709416f --- /dev/null +++ b/frontend/src/components/forms/GroupManager.jsx @@ -0,0 +1,350 @@ +import React, { useState, useEffect } from 'react'; +import { + Modal, + Stack, + Group, + Text, + TextInput, + Button, + ActionIcon, + Flex, + Badge, + Alert, + Divider, + ScrollArea, +} from '@mantine/core'; +import { + SquarePlus, + SquarePen, + Trash2, + Check, + X, + AlertCircle, + Database, + Tv +} from 'lucide-react'; +import { notifications } from '@mantine/notifications'; +import useChannelsStore from '../../store/channels'; +import API from '../../api'; + +const GroupManager = ({ isOpen, onClose }) => { + const channelGroups = useChannelsStore((s) => s.channelGroups); + const [editingGroup, setEditingGroup] = useState(null); + const [editName, setEditName] = useState(''); + const [newGroupName, setNewGroupName] = useState(''); + const [isCreating, setIsCreating] = useState(false); + const [groupUsage, setGroupUsage] = useState({}); + const [loading, setLoading] = useState(false); + + // Fetch group usage information when modal opens + useEffect(() => { + if (isOpen) { + fetchGroupUsage(); + } + }, [isOpen]); + + const fetchGroupUsage = async () => { + setLoading(true); + try { + // This would ideally be a dedicated API endpoint, but we'll use the existing data + // For now, we'll determine usage based on the group having associated data + const usage = {}; + + // Check which groups have channels or M3U associations + // This is a simplified check - in a real implementation you'd want a dedicated API + Object.values(channelGroups).forEach(group => { + usage[group.id] = { + hasChannels: false, // Would need API call to check + hasM3UAccounts: false, // Would need API call to check + canEdit: true, // Assume editable unless proven otherwise + canDelete: true // Assume deletable unless proven otherwise + }; + }); + + setGroupUsage(usage); + } catch (error) { + console.error('Error fetching group usage:', error); + } finally { + setLoading(false); + } + }; + + const handleEdit = (group) => { + setEditingGroup(group.id); + setEditName(group.name); + }; + + const handleSaveEdit = async () => { + if (!editName.trim()) { + notifications.show({ + title: 'Error', + message: 'Group name cannot be empty', + color: 'red', + }); + return; + } + + try { + await API.updateChannelGroup({ + id: editingGroup, + name: editName.trim(), + }); + + notifications.show({ + title: 'Success', + message: 'Group updated successfully', + color: 'green', + }); + + setEditingGroup(null); + setEditName(''); + } catch (error) { + notifications.show({ + title: 'Error', + message: 'Failed to update group', + color: 'red', + }); + } + }; + + const handleCancelEdit = () => { + setEditingGroup(null); + setEditName(''); + }; + + const handleCreate = async () => { + if (!newGroupName.trim()) { + notifications.show({ + title: 'Error', + message: 'Group name cannot be empty', + color: 'red', + }); + return; + } + + try { + await API.addChannelGroup({ + name: newGroupName.trim(), + }); + + notifications.show({ + title: 'Success', + message: 'Group created successfully', + color: 'green', + }); + + setNewGroupName(''); + setIsCreating(false); + fetchGroupUsage(); // Refresh usage data + } catch (error) { + notifications.show({ + title: 'Error', + message: 'Failed to create group', + color: 'red', + }); + } + }; + + const handleDelete = async (group) => { + const usage = groupUsage[group.id]; + + if (usage && (!usage.canDelete || usage.hasChannels || usage.hasM3UAccounts)) { + notifications.show({ + title: 'Cannot Delete', + message: 'This group is associated with channels or M3U accounts and cannot be deleted', + color: 'orange', + }); + return; + } + + try { + await API.deleteChannelGroup(group.id); + + notifications.show({ + title: 'Success', + message: 'Group deleted successfully', + color: 'green', + }); + + fetchGroupUsage(); // Refresh usage data + } catch (error) { + notifications.show({ + title: 'Error', + message: 'Failed to delete group', + color: 'red', + }); + } + }; + + const getGroupBadges = (group) => { + const usage = groupUsage[group.id]; + const badges = []; + + if (usage?.hasChannels) { + badges.push( + }> + Channels + + ); + } + + if (usage?.hasM3UAccounts) { + badges.push( + }> + M3U + + ); + } + + return badges; + }; + + const canEditGroup = (group) => { + const usage = groupUsage[group.id]; + return usage?.canEdit !== false; // Default to true if no usage data + }; + + const canDeleteGroup = (group) => { + const usage = groupUsage[group.id]; + return usage?.canDelete !== false && !usage?.hasChannels && !usage?.hasM3UAccounts; + }; + + if (!isOpen) return null; + + return ( + + + } color="blue" variant="light"> + Manage channel groups. Groups associated with M3U accounts or containing channels cannot be deleted. + + + {/* Create new group section */} + + Create New Group + + {isCreating ? ( + <> + setNewGroupName(e.target.value)} + style={{ flex: 1 }} + onKeyPress={(e) => e.key === 'Enter' && handleCreate()} + /> + + + + { + setIsCreating(false); + setNewGroupName(''); + }}> + + + + ) : ( + + )} + + + + + + {/* Existing groups */} + + Existing Groups ({Object.keys(channelGroups).length}) + + {loading ? ( + Loading group information... + ) : Object.keys(channelGroups).length === 0 ? ( + No groups found + ) : ( + + {Object.values(channelGroups) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((group) => ( + + + {editingGroup === group.id ? ( + setEditName(e.target.value)} + size="sm" + onKeyPress={(e) => e.key === 'Enter' && handleSaveEdit()} + /> + ) : ( + <> + {group.name} + + {getGroupBadges(group)} + + + )} + + + + {editingGroup === group.id ? ( + <> + + + + + + + + ) : ( + <> + handleEdit(group)} + disabled={!canEditGroup(group)} + > + + + handleDelete(group)} + disabled={!canDeleteGroup(group)} + > + + + + )} + + + ))} + + )} + + + + + + + + + + ); +}; + +export default GroupManager; diff --git a/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx b/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx index 8813ceda..1568e10d 100644 --- a/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx +++ b/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx @@ -25,6 +25,7 @@ import { SquareMinus, SquarePen, SquarePlus, + Settings, } from 'lucide-react'; import API from '../../../api'; import { notifications } from '@mantine/notifications'; @@ -32,6 +33,7 @@ import useChannelsStore from '../../../store/channels'; import useAuthStore from '../../../store/auth'; import { USER_LEVELS } from '../../../constants'; import AssignChannelNumbersForm from '../../forms/AssignChannelNumbers'; +import GroupManager from '../../forms/GroupManager'; import ConfirmationDialog from '../../ConfirmationDialog'; import useWarningsStore from '../../../store/warnings'; @@ -105,6 +107,7 @@ const ChannelTableHeader = ({ const [channelNumAssignmentStart, setChannelNumAssignmentStart] = useState(1); const [assignNumbersModalOpen, setAssignNumbersModalOpen] = useState(false); + const [groupManagerOpen, setGroupManagerOpen] = useState(false); const [confirmDeleteProfileOpen, setConfirmDeleteProfileOpen] = useState(false); const [profileToDelete, setProfileToDelete] = useState(null); @@ -301,6 +304,15 @@ const ChannelTableHeader = ({ Auto-Match + + } + disabled={authUser.user_level != USER_LEVELS.ADMIN} + > + setGroupManagerOpen(true)}> + Edit Groups + + @@ -312,6 +324,11 @@ const ChannelTableHeader = ({ onClose={closeAssignChannelNumbersModal} /> + setGroupManagerOpen(false)} + /> + setConfirmDeleteProfileOpen(false)} diff --git a/frontend/src/store/channels.jsx b/frontend/src/store/channels.jsx index beb62fe1..03cf2b86 100644 --- a/frontend/src/store/channels.jsx +++ b/frontend/src/store/channels.jsx @@ -204,10 +204,18 @@ const useChannelsStore = create((set, get) => ({ updateChannelGroup: (channelGroup) => set((state) => ({ - ...state.channelGroups, - [channelGroup.id]: channelGroup, + channelGroups: { + ...state.channelGroups, + [channelGroup.id]: channelGroup, + }, })), + removeChannelGroup: (groupId) => + set((state) => { + const { [groupId]: removed, ...remainingGroups } = state.channelGroups; + return { channelGroups: remainingGroups }; + }), + fetchLogos: async () => { set({ isLoading: true, error: null }); try { From a1d9a7cbbe22c246e8c50714e6626f184f59f856 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 12 Jul 2025 16:21:40 -0500 Subject: [PATCH 02/12] Fixed performance issue while creating group. --- .../src/components/forms/GroupManager.jsx | 262 +++++++++++------- 1 file changed, 156 insertions(+), 106 deletions(-) diff --git a/frontend/src/components/forms/GroupManager.jsx b/frontend/src/components/forms/GroupManager.jsx index 7709416f..65f4e0b6 100644 --- a/frontend/src/components/forms/GroupManager.jsx +++ b/frontend/src/components/forms/GroupManager.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { Modal, Stack, @@ -27,7 +27,114 @@ import { notifications } from '@mantine/notifications'; import useChannelsStore from '../../store/channels'; import API from '../../api'; -const GroupManager = ({ isOpen, onClose }) => { +// Move GroupItem outside to prevent recreation on every render +const GroupItem = React.memo(({ + group, + editingGroup, + editName, + onEditNameChange, + onSaveEdit, + onCancelEdit, + onEdit, + onDelete, + groupUsage +}) => { + const getGroupBadges = (group) => { + const usage = groupUsage[group.id]; + const badges = []; + + if (usage?.hasChannels) { + badges.push( + }> + Channels + + ); + } + + if (usage?.hasM3UAccounts) { + badges.push( + }> + M3U + + ); + } + + return badges; + }; + + const canEditGroup = (group) => { + const usage = groupUsage[group.id]; + return usage?.canEdit !== false; + }; + + const canDeleteGroup = (group) => { + const usage = groupUsage[group.id]; + return usage?.canDelete !== false && !usage?.hasChannels && !usage?.hasM3UAccounts; + }; + + return ( + + + {editingGroup === group.id ? ( + e.key === 'Enter' && onSaveEdit()} + autoFocus + /> + ) : ( + <> + {group.name} + + {getGroupBadges(group)} + + + )} + + + + {editingGroup === group.id ? ( + <> + + + + + + + + ) : ( + <> + onEdit(group)} + disabled={!canEditGroup(group)} + > + + + onDelete(group)} + disabled={!canDeleteGroup(group)} + > + + + + )} + + + ); +}); + +const GroupManager = React.memo(({ isOpen, onClose }) => { + // Use a more specific selector to avoid unnecessary re-renders + const fetchChannelGroups = useChannelsStore((s) => s.fetchChannelGroups); const channelGroups = useChannelsStore((s) => s.channelGroups); const [editingGroup, setEditingGroup] = useState(null); const [editName, setEditName] = useState(''); @@ -36,6 +143,18 @@ const GroupManager = ({ isOpen, onClose }) => { const [groupUsage, setGroupUsage] = useState({}); const [loading, setLoading] = useState(false); + // Memoize the channel groups array to prevent unnecessary re-renders + const channelGroupsArray = useMemo(() => + Object.values(channelGroups), + [channelGroups] + ); + + // Memoize sorted groups to prevent re-sorting on every render + const sortedGroups = useMemo(() => + channelGroupsArray.sort((a, b) => a.name.localeCompare(b.name)), + [channelGroupsArray] + ); + // Fetch group usage information when modal opens useEffect(() => { if (isOpen) { @@ -69,12 +188,12 @@ const GroupManager = ({ isOpen, onClose }) => { } }; - const handleEdit = (group) => { + const handleEdit = useCallback((group) => { setEditingGroup(group.id); setEditName(group.name); - }; + }, []); - const handleSaveEdit = async () => { + const handleSaveEdit = useCallback(async () => { if (!editName.trim()) { notifications.show({ title: 'Error', @@ -105,14 +224,14 @@ const GroupManager = ({ isOpen, onClose }) => { color: 'red', }); } - }; + }, [editName, editingGroup]); - const handleCancelEdit = () => { + const handleCancelEdit = useCallback(() => { setEditingGroup(null); setEditName(''); - }; + }, []); - const handleCreate = async () => { + const handleCreate = useCallback(async () => { if (!newGroupName.trim()) { notifications.show({ title: 'Error', @@ -143,9 +262,9 @@ const GroupManager = ({ isOpen, onClose }) => { color: 'red', }); } - }; + }, [newGroupName]); - const handleDelete = async (group) => { + const handleDelete = useCallback(async (group) => { const usage = groupUsage[group.id]; if (usage && (!usage.canDelete || usage.hasChannels || usage.hasM3UAccounts)) { @@ -174,40 +293,15 @@ const GroupManager = ({ isOpen, onClose }) => { color: 'red', }); } - }; + }, [groupUsage]); - const getGroupBadges = (group) => { - const usage = groupUsage[group.id]; - const badges = []; + const handleNewGroupNameChange = useCallback((e) => { + setNewGroupName(e.target.value); + }, []); - if (usage?.hasChannels) { - badges.push( - }> - Channels - - ); - } - - if (usage?.hasM3UAccounts) { - badges.push( - }> - M3U - - ); - } - - return badges; - }; - - const canEditGroup = (group) => { - const usage = groupUsage[group.id]; - return usage?.canEdit !== false; // Default to true if no usage data - }; - - const canDeleteGroup = (group) => { - const usage = groupUsage[group.id]; - return usage?.canDelete !== false && !usage?.hasChannels && !usage?.hasM3UAccounts; - }; + const handleEditNameChange = useCallback((e) => { + setEditName(e.target.value); + }, []); if (!isOpen) return null; @@ -233,9 +327,10 @@ const GroupManager = ({ isOpen, onClose }) => { setNewGroupName(e.target.value)} + onChange={handleNewGroupNameChange} style={{ flex: 1 }} onKeyPress={(e) => e.key === 'Enter' && handleCreate()} + autoFocus /> @@ -264,73 +359,28 @@ const GroupManager = ({ isOpen, onClose }) => { {/* Existing groups */} - Existing Groups ({Object.keys(channelGroups).length}) + Existing Groups ({channelGroupsArray.length}) {loading ? ( Loading group information... - ) : Object.keys(channelGroups).length === 0 ? ( + ) : sortedGroups.length === 0 ? ( No groups found ) : ( - {Object.values(channelGroups) - .sort((a, b) => a.name.localeCompare(b.name)) - .map((group) => ( - - - {editingGroup === group.id ? ( - setEditName(e.target.value)} - size="sm" - onKeyPress={(e) => e.key === 'Enter' && handleSaveEdit()} - /> - ) : ( - <> - {group.name} - - {getGroupBadges(group)} - - - )} - - - - {editingGroup === group.id ? ( - <> - - - - - - - - ) : ( - <> - handleEdit(group)} - disabled={!canEditGroup(group)} - > - - - handleDelete(group)} - disabled={!canDeleteGroup(group)} - > - - - - )} - - - ))} + {sortedGroups.map((group) => ( + + ))} )} @@ -345,6 +395,6 @@ const GroupManager = ({ isOpen, onClose }) => { ); -}; +}); export default GroupManager; From 9cb05a0ae1610d0e0c893cd78065d5cd31ed9c9c Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 12 Jul 2025 16:27:49 -0500 Subject: [PATCH 03/12] Add search functionality to GroupManager for filtering groups --- .../src/components/forms/GroupManager.jsx | 45 ++++++++++++++++--- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/forms/GroupManager.jsx b/frontend/src/components/forms/GroupManager.jsx index 65f4e0b6..e10b9a1c 100644 --- a/frontend/src/components/forms/GroupManager.jsx +++ b/frontend/src/components/forms/GroupManager.jsx @@ -142,6 +142,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { const [isCreating, setIsCreating] = useState(false); const [groupUsage, setGroupUsage] = useState({}); const [loading, setLoading] = useState(false); + const [searchTerm, setSearchTerm] = useState(''); // Memoize the channel groups array to prevent unnecessary re-renders const channelGroupsArray = useMemo(() => @@ -155,6 +156,14 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { [channelGroupsArray] ); + // Filter groups based on search term + const filteredGroups = useMemo(() => { + if (!searchTerm.trim()) return sortedGroups; + return sortedGroups.filter(group => + group.name.toLowerCase().includes(searchTerm.toLowerCase()) + ); + }, [sortedGroups, searchTerm]); + // Fetch group usage information when modal opens useEffect(() => { if (isOpen) { @@ -293,7 +302,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { color: 'red', }); } - }, [groupUsage]); + }, [groupUsage, fetchGroupUsage]); const handleNewGroupNameChange = useCallback((e) => { setNewGroupName(e.target.value); @@ -303,6 +312,10 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { setEditName(e.target.value); }, []); + const handleSearchChange = useCallback((e) => { + setSearchTerm(e.target.value); + }, []); + if (!isOpen) return null; return ( @@ -359,15 +372,37 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { {/* Existing groups */} - Existing Groups ({channelGroupsArray.length}) + + + Existing Groups ({filteredGroups.length}{searchTerm && ` of ${sortedGroups.length}`}) + + setSearchTerm('')} + > + + + )} + /> + {loading ? ( Loading group information... - ) : sortedGroups.length === 0 ? ( - No groups found + ) : filteredGroups.length === 0 ? ( + + {searchTerm ? 'No groups found matching your search' : 'No groups found'} + ) : ( - {sortedGroups.map((group) => ( + {filteredGroups.map((group) => ( Date: Sat, 12 Jul 2025 16:57:05 -0500 Subject: [PATCH 04/12] Disable buttons that can't be used. --- apps/channels/api_views.py | 34 +++++++++++++++++ apps/channels/serializers.py | 5 ++- frontend/src/api.js | 10 ++++- frontend/src/components/forms/Channel.jsx | 2 + .../src/components/forms/ChannelBatch.jsx | 2 + .../src/components/forms/ChannelGroup.jsx | 27 ++++++++++++- .../src/components/forms/GroupManager.jsx | 38 +++++++------------ .../src/components/tables/ChannelsTable.jsx | 4 +- frontend/src/store/channels.jsx | 35 +++++++++++++---- 9 files changed, 120 insertions(+), 37 deletions(-) diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 5d00e84d..b4df2461 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -187,6 +187,40 @@ class ChannelGroupViewSet(viewsets.ModelViewSet): except KeyError: return [Authenticated()] + def get_queryset(self): + """Add annotation for association counts""" + from django.db.models import Count + return ChannelGroup.objects.annotate( + channel_count=Count('channels', distinct=True), + m3u_account_count=Count('m3u_account', distinct=True) + ) + + def update(self, request, *args, **kwargs): + """Override update to check M3U associations""" + instance = self.get_object() + + # Check if group has M3U account associations + if hasattr(instance, 'm3u_account') and instance.m3u_account.exists(): + return Response( + {"error": "Cannot edit group with M3U account associations"}, + status=status.HTTP_400_BAD_REQUEST + ) + + return super().update(request, *args, **kwargs) + + def partial_update(self, request, *args, **kwargs): + """Override partial_update to check M3U associations""" + instance = self.get_object() + + # Check if group has M3U account associations + if hasattr(instance, 'm3u_account') and instance.m3u_account.exists(): + return Response( + {"error": "Cannot edit group with M3U account associations"}, + status=status.HTTP_400_BAD_REQUEST + ) + + return super().partial_update(request, *args, **kwargs) + def destroy(self, request, *args, **kwargs): """Override destroy to check for associations before deletion""" instance = self.get_object() diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py index cdc6ef60..4d1694dc 100644 --- a/apps/channels/serializers.py +++ b/apps/channels/serializers.py @@ -89,9 +89,12 @@ class StreamSerializer(serializers.ModelSerializer): # Channel Group # class ChannelGroupSerializer(serializers.ModelSerializer): + channel_count = serializers.IntegerField(read_only=True) + m3u_account_count = serializers.IntegerField(read_only=True) + class Meta: model = ChannelGroup - fields = ["id", "name"] + fields = ["id", "name", "channel_count", "m3u_account_count"] class ChannelProfileSerializer(serializers.ModelSerializer): diff --git a/frontend/src/api.js b/frontend/src/api.js index ff95f634..9786bb75 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -250,7 +250,15 @@ export default class API { }); if (response.id) { - useChannelsStore.getState().addChannelGroup(response); + // Add association flags for new groups + const processedGroup = { + ...response, + hasChannels: false, + hasM3UAccounts: false, + canEdit: true, + canDelete: true + }; + useChannelsStore.getState().addChannelGroup(processedGroup); } return response; diff --git a/frontend/src/components/forms/Channel.jsx b/frontend/src/components/forms/Channel.jsx index 452db052..64412cb4 100644 --- a/frontend/src/components/forms/Channel.jsx +++ b/frontend/src/components/forms/Channel.jsx @@ -45,6 +45,8 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => { const groupListRef = useRef(null); const channelGroups = useChannelsStore((s) => s.channelGroups); + const canEditChannelGroup = useChannelsStore((s) => s.canEditChannelGroup); + const logos = useChannelsStore((s) => s.logos); const fetchLogos = useChannelsStore((s) => s.fetchLogos); const streams = useStreamsStore((state) => state.streams); diff --git a/frontend/src/components/forms/ChannelBatch.jsx b/frontend/src/components/forms/ChannelBatch.jsx index 2ba3245c..693ebb11 100644 --- a/frontend/src/components/forms/ChannelBatch.jsx +++ b/frontend/src/components/forms/ChannelBatch.jsx @@ -32,6 +32,8 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { const groupListRef = useRef(null); const channelGroups = useChannelsStore((s) => s.channelGroups); + const canEditChannelGroup = useChannelsStore((s) => s.canEditChannelGroup); + const streamProfiles = useStreamProfilesStore((s) => s.profiles); const [channelGroupModelOpen, setChannelGroupModalOpen] = useState(false); diff --git a/frontend/src/components/forms/ChannelGroup.jsx b/frontend/src/components/forms/ChannelGroup.jsx index 18ed31c1..46641fb1 100644 --- a/frontend/src/components/forms/ChannelGroup.jsx +++ b/frontend/src/components/forms/ChannelGroup.jsx @@ -1,10 +1,17 @@ // Modal.js import React from 'react'; import API from '../../api'; -import { Flex, TextInput, Button, Modal } from '@mantine/core'; +import { Flex, TextInput, Button, Modal, Alert } from '@mantine/core'; +import { notifications } from '@mantine/notifications'; import { isNotEmpty, useForm } from '@mantine/form'; +import useChannelsStore from '../../store/channels'; const ChannelGroup = ({ channelGroup = null, isOpen, onClose }) => { + const canEditChannelGroup = useChannelsStore((s) => s.canEditChannelGroup); + + // Check if editing is allowed + const canEdit = !channelGroup || canEditChannelGroup(channelGroup.id); + const form = useForm({ mode: 'uncontrolled', initialValues: { @@ -17,6 +24,16 @@ const ChannelGroup = ({ channelGroup = null, isOpen, onClose }) => { }); const onSubmit = async () => { + // Prevent submission if editing is not allowed + if (channelGroup && !canEdit) { + notifications.show({ + title: 'Error', + message: 'Cannot edit group with M3U account associations', + color: 'red', + }); + return; + } + const values = form.getValues(); let newGroup; @@ -36,11 +53,17 @@ const ChannelGroup = ({ channelGroup = null, isOpen, onClose }) => { return ( + {channelGroup && !canEdit && ( + + This group cannot be edited because it has M3U account associations. + + )}
@@ -50,7 +73,7 @@ const ChannelGroup = ({ channelGroup = null, isOpen, onClose }) => { type="submit" variant="contained" color="primary" - disabled={form.submitting} + disabled={form.submitting || (channelGroup && !canEdit)} size="small" > Submit diff --git a/frontend/src/components/forms/GroupManager.jsx b/frontend/src/components/forms/GroupManager.jsx index e10b9a1c..f6bf7305 100644 --- a/frontend/src/components/forms/GroupManager.jsx +++ b/frontend/src/components/forms/GroupManager.jsx @@ -37,7 +37,9 @@ const GroupItem = React.memo(({ onCancelEdit, onEdit, onDelete, - groupUsage + groupUsage, + canEditGroup, + canDeleteGroup }) => { const getGroupBadges = (group) => { const usage = groupUsage[group.id]; @@ -62,16 +64,6 @@ const GroupItem = React.memo(({ return badges; }; - const canEditGroup = (group) => { - const usage = groupUsage[group.id]; - return usage?.canEdit !== false; - }; - - const canDeleteGroup = (group) => { - const usage = groupUsage[group.id]; - return usage?.canDelete !== false && !usage?.hasChannels && !usage?.hasM3UAccounts; - }; - return ( { - // Use a more specific selector to avoid unnecessary re-renders - const fetchChannelGroups = useChannelsStore((s) => s.fetchChannelGroups); const channelGroups = useChannelsStore((s) => s.channelGroups); + const canEditChannelGroup = useChannelsStore((s) => s.canEditChannelGroup); + const canDeleteChannelGroup = useChannelsStore((s) => s.canDeleteChannelGroup); const [editingGroup, setEditingGroup] = useState(null); const [editName, setEditName] = useState(''); const [newGroupName, setNewGroupName] = useState(''); @@ -171,21 +163,18 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { } }, [isOpen]); - const fetchGroupUsage = async () => { + const fetchGroupUsage = useCallback(async () => { setLoading(true); try { - // This would ideally be a dedicated API endpoint, but we'll use the existing data - // For now, we'll determine usage based on the group having associated data + // Use the actual channel group data that already has the flags const usage = {}; - // Check which groups have channels or M3U associations - // This is a simplified check - in a real implementation you'd want a dedicated API Object.values(channelGroups).forEach(group => { usage[group.id] = { - hasChannels: false, // Would need API call to check - hasM3UAccounts: false, // Would need API call to check - canEdit: true, // Assume editable unless proven otherwise - canDelete: true // Assume deletable unless proven otherwise + hasChannels: group.hasChannels ?? false, + hasM3UAccounts: group.hasM3UAccounts ?? false, + canEdit: group.canEdit ?? true, + canDelete: group.canDelete ?? true }; }); @@ -195,7 +184,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { } finally { setLoading(false); } - }; + }, [channelGroups]); const handleEdit = useCallback((group) => { setEditingGroup(group.id); @@ -414,6 +403,8 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { onEdit={handleEdit} onDelete={handleDelete} groupUsage={groupUsage} + canEditGroup={canEditChannelGroup} + canDeleteGroup={canDeleteChannelGroup} /> ))} @@ -431,5 +422,4 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { ); }); - export default GroupManager; diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index 7a9d5007..077602ad 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -216,6 +216,9 @@ const ChannelRowActions = React.memo( const ChannelsTable = ({ }) => { const theme = useMantineTheme(); + const channelGroups = useChannelsStore((s) => s.channelGroups); + const canEditChannelGroup = useChannelsStore((s) => s.canEditChannelGroup); + const canDeleteChannelGroup = useChannelsStore((s) => s.canDeleteChannelGroup); /** * STORES @@ -241,7 +244,6 @@ const ChannelsTable = ({ }) => { const channels = useChannelsStore((s) => s.channels); const profiles = useChannelsStore((s) => s.profiles); const selectedProfileId = useChannelsStore((s) => s.selectedProfileId); - const channelGroups = useChannelsStore((s) => s.channelGroups); const logos = useChannelsStore((s) => s.logos); const [tablePrefs, setTablePrefs] = useLocalStorage('channel-table-prefs', { pageSize: 50, diff --git a/frontend/src/store/channels.jsx b/frontend/src/store/channels.jsx index 03cf2b86..40791cf4 100644 --- a/frontend/src/store/channels.jsx +++ b/frontend/src/store/channels.jsx @@ -46,16 +46,24 @@ const useChannelsStore = create((set, get) => ({ }, fetchChannelGroups: async () => { - set({ isLoading: true, error: null }); try { const channelGroups = await api.getChannelGroups(); - set({ - channelGroups: channelGroups.reduce((acc, group) => { - acc[group.id] = group; - return acc; - }, {}), - isLoading: false, - }); + + // Process groups to add association flags + const processedGroups = channelGroups.reduce((acc, group) => { + acc[group.id] = { + ...group, + hasChannels: group.channel_count > 0, + hasM3UAccounts: group.m3u_account_count > 0, + canEdit: group.m3u_account_count === 0, + canDelete: group.channel_count === 0 && group.m3u_account_count === 0 + }; + return acc; + }, {}); + + set((state) => ({ + channelGroups: processedGroups, + })); } catch (error) { console.error('Failed to fetch channel groups:', error); set({ error: 'Failed to load channel groups.', isLoading: false }); @@ -435,6 +443,17 @@ const useChannelsStore = create((set, get) => ({ set({ error: 'Failed to load recordings.', isLoading: false }); } }, + + // Add helper methods for validation + canEditChannelGroup: (groupIdOrGroup) => { + const groupId = typeof groupIdOrGroup === 'object' ? groupIdOrGroup.id : groupIdOrGroup; + return get().channelGroups[groupId]?.canEdit ?? true; + }, + + canDeleteChannelGroup: (groupIdOrGroup) => { + const groupId = typeof groupIdOrGroup === 'object' ? groupIdOrGroup.id : groupIdOrGroup; + return get().channelGroups[groupId]?.canDelete ?? true; + }, })); export default useChannelsStore; From 9b7aa0c8946bccf65b641bf41fd330caa33fab96 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 12 Jul 2025 17:05:48 -0500 Subject: [PATCH 05/12] Add ability to cleanup all unused groups. --- apps/channels/api_views.py | 45 ++++++++++++++++--- frontend/src/api.js | 16 +++++++ .../src/components/forms/GroupManager.jsx | 41 ++++++++++++++++- 3 files changed, 93 insertions(+), 9 deletions(-) diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index b4df2461..f0f59f29 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -198,47 +198,78 @@ class ChannelGroupViewSet(viewsets.ModelViewSet): def update(self, request, *args, **kwargs): """Override update to check M3U associations""" instance = self.get_object() - + # Check if group has M3U account associations if hasattr(instance, 'm3u_account') and instance.m3u_account.exists(): return Response( {"error": "Cannot edit group with M3U account associations"}, status=status.HTTP_400_BAD_REQUEST ) - + return super().update(request, *args, **kwargs) def partial_update(self, request, *args, **kwargs): """Override partial_update to check M3U associations""" instance = self.get_object() - + # Check if group has M3U account associations if hasattr(instance, 'm3u_account') and instance.m3u_account.exists(): return Response( {"error": "Cannot edit group with M3U account associations"}, status=status.HTTP_400_BAD_REQUEST ) - + return super().partial_update(request, *args, **kwargs) + @swagger_auto_schema( + method="post", + operation_description="Delete all channel groups that have no associations (no channels or M3U accounts)", + responses={200: "Cleanup completed"}, + ) + @action(detail=False, methods=["post"], url_path="cleanup") + def cleanup_unused_groups(self, request): + """Delete all channel groups with no channels or M3U account associations""" + from django.db.models import Count + + # Find groups with no channels and no M3U account associations + unused_groups = ChannelGroup.objects.annotate( + channel_count=Count('channels', distinct=True), + m3u_account_count=Count('m3u_account', distinct=True) + ).filter( + channel_count=0, + m3u_account_count=0 + ) + + deleted_count = unused_groups.count() + group_names = list(unused_groups.values_list('name', flat=True)) + + # Delete the unused groups + unused_groups.delete() + + return Response({ + "message": f"Successfully deleted {deleted_count} unused channel groups", + "deleted_count": deleted_count, + "deleted_groups": group_names + }) + def destroy(self, request, *args, **kwargs): """Override destroy to check for associations before deletion""" instance = self.get_object() - + # Check if group has associated channels if instance.channels.exists(): return Response( {"error": "Cannot delete group with associated channels"}, status=status.HTTP_400_BAD_REQUEST ) - + # Check if group has M3U account associations if hasattr(instance, 'm3u_account') and instance.m3u_account.exists(): return Response( {"error": "Cannot delete group with M3U account associations"}, status=status.HTTP_400_BAD_REQUEST ) - + return super().destroy(request, *args, **kwargs) diff --git a/frontend/src/api.js b/frontend/src/api.js index 9786bb75..e9ab4deb 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -301,6 +301,22 @@ export default class API { } } + static async cleanupUnusedChannelGroups() { + try { + const response = await request(`${host}/api/channels/groups/cleanup/`, { + method: 'POST', + }); + + // Refresh channel groups to update the UI + useChannelsStore.getState().fetchChannelGroups(); + + return response; + } catch (e) { + errorNotification('Failed to cleanup unused channel groups', e); + throw e; + } + } + static async addChannel(channel) { try { let body = null; diff --git a/frontend/src/components/forms/GroupManager.jsx b/frontend/src/components/forms/GroupManager.jsx index f6bf7305..f89c9228 100644 --- a/frontend/src/components/forms/GroupManager.jsx +++ b/frontend/src/components/forms/GroupManager.jsx @@ -21,7 +21,8 @@ import { X, AlertCircle, Database, - Tv + Tv, + Trash } from 'lucide-react'; import { notifications } from '@mantine/notifications'; import useChannelsStore from '../../store/channels'; @@ -135,6 +136,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { const [groupUsage, setGroupUsage] = useState({}); const [loading, setLoading] = useState(false); const [searchTerm, setSearchTerm] = useState(''); + const [isCleaningUp, setIsCleaningUp] = useState(false); // Memoize the channel groups array to prevent unnecessary re-renders const channelGroupsArray = useMemo(() => @@ -305,6 +307,29 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { setSearchTerm(e.target.value); }, []); + const handleCleanup = useCallback(async () => { + setIsCleaningUp(true); + try { + const result = await API.cleanupUnusedChannelGroups(); + + notifications.show({ + title: 'Cleanup Complete', + message: `Successfully deleted ${result.deleted_count} unused groups`, + color: 'green', + }); + + fetchGroupUsage(); // Refresh usage data + } catch (error) { + notifications.show({ + title: 'Cleanup Failed', + message: 'Failed to cleanup unused groups', + color: 'red', + }); + } finally { + setIsCleaningUp(false); + } + }, [fetchGroupUsage]); + if (!isOpen) return null; return ( @@ -322,7 +347,19 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { {/* Create new group section */} - Create New Group + + Create New Group + + {isCreating ? ( <> From 171d64841a566e79f0fb7dc84d3185016c2d5b99 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 12 Jul 2025 17:28:04 -0500 Subject: [PATCH 06/12] Changed some colors to match our theme better. --- .../src/components/forms/GroupManager.jsx | 91 ++++++++++--------- 1 file changed, 47 insertions(+), 44 deletions(-) diff --git a/frontend/src/components/forms/GroupManager.jsx b/frontend/src/components/forms/GroupManager.jsx index f89c9228..edc04d20 100644 --- a/frontend/src/components/forms/GroupManager.jsx +++ b/frontend/src/components/forms/GroupManager.jsx @@ -12,11 +12,12 @@ import { Alert, Divider, ScrollArea, + useMantineTheme, } from '@mantine/core'; import { SquarePlus, SquarePen, - Trash2, + SquareMinus, Check, X, AlertCircle, @@ -42,6 +43,8 @@ const GroupItem = React.memo(({ canEditGroup, canDeleteGroup }) => { + const theme = useMantineTheme(); + const getGroupBadges = (group) => { const usage = groupUsage[group.id]; const badges = []; @@ -69,7 +72,7 @@ const GroupItem = React.memo(({ {editingGroup === group.id ? ( @@ -103,20 +106,22 @@ const GroupItem = React.memo(({ ) : ( <> onEdit(group)} disabled={!canEditGroup(group)} > - + onDelete(group)} disabled={!canDeleteGroup(group)} > - + )} @@ -346,9 +351,39 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { {/* Create new group section */} - - - Create New Group + + {isCreating ? ( + + e.key === 'Enter' && handleCreate()} + autoFocus + /> + + + + { + setIsCreating(false); + setNewGroupName(''); + }}> + + + + ) : ( + + )} + + {!isCreating && ( - - - {isCreating ? ( - <> - e.key === 'Enter' && handleCreate()} - autoFocus - /> - - - - { - setIsCreating(false); - setNewGroupName(''); - }}> - - - - ) : ( - - )} - - + )} + @@ -400,7 +403,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { - Existing Groups ({filteredGroups.length}{searchTerm && ` of ${sortedGroups.length}`}) + Groups ({filteredGroups.length}{searchTerm && ` of ${sortedGroups.length}`}) Date: Sat, 12 Jul 2025 17:37:24 -0500 Subject: [PATCH 07/12] Add filtering based on group membership. --- .../src/components/forms/GroupManager.jsx | 139 ++++++++++++++++-- 1 file changed, 125 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/forms/GroupManager.jsx b/frontend/src/components/forms/GroupManager.jsx index edc04d20..3b63b738 100644 --- a/frontend/src/components/forms/GroupManager.jsx +++ b/frontend/src/components/forms/GroupManager.jsx @@ -13,6 +13,7 @@ import { Divider, ScrollArea, useMantineTheme, + Chip, } from '@mantine/core'; import { SquarePlus, @@ -23,7 +24,8 @@ import { AlertCircle, Database, Tv, - Trash + Trash, + Filter } from 'lucide-react'; import { notifications } from '@mantine/notifications'; import useChannelsStore from '../../store/channels'; @@ -142,6 +144,9 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { const [loading, setLoading] = useState(false); const [searchTerm, setSearchTerm] = useState(''); const [isCleaningUp, setIsCleaningUp] = useState(false); + const [showChannelGroups, setShowChannelGroups] = useState(true); + const [showM3UGroups, setShowM3UGroups] = useState(true); + const [showUnusedGroups, setShowUnusedGroups] = useState(true); // Memoize the channel groups array to prevent unnecessary re-renders const channelGroupsArray = useMemo(() => @@ -155,13 +160,75 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { [channelGroupsArray] ); - // Filter groups based on search term + // Filter groups based on search term and chip filters const filteredGroups = useMemo(() => { - if (!searchTerm.trim()) return sortedGroups; - return sortedGroups.filter(group => - group.name.toLowerCase().includes(searchTerm.toLowerCase()) - ); - }, [sortedGroups, searchTerm]); + let filtered = sortedGroups; + + // Apply search filter + if (searchTerm.trim()) { + filtered = filtered.filter(group => + group.name.toLowerCase().includes(searchTerm.toLowerCase()) + ); + } + + // Apply chip filters + filtered = filtered.filter(group => { + const usage = groupUsage[group.id]; + if (!usage) return false; + + const hasChannels = usage.hasChannels; + const hasM3U = usage.hasM3UAccounts; + const isUnused = !hasChannels && !hasM3U; + + // If group is unused, only show if unused groups are enabled + if (isUnused) { + return showUnusedGroups; + } + + // For groups with channels and/or M3U, show if either filter is enabled + let shouldShow = false; + if (hasChannels && showChannelGroups) shouldShow = true; + if (hasM3U && showM3UGroups) shouldShow = true; + + return shouldShow; + }); + + return filtered; + }, [sortedGroups, searchTerm, showChannelGroups, showM3UGroups, showUnusedGroups, groupUsage]); + + // Calculate filter counts + const filterCounts = useMemo(() => { + const counts = { + channels: 0, + m3u: 0, + unused: 0 + }; + + sortedGroups.forEach(group => { + const usage = groupUsage[group.id]; + if (usage) { + const hasChannels = usage.hasChannels; + const hasM3U = usage.hasM3UAccounts; + + // Count groups with channels (including those with both) + if (hasChannels) { + counts.channels++; + } + + // Count groups with M3U (including those with both) + if (hasM3U) { + counts.m3u++; + } + + // Count truly unused groups + if (!hasChannels && !hasM3U) { + counts.unused++; + } + } + }); + + return counts; + }, [sortedGroups, groupUsage]); // Fetch group usage information when modal opens useEffect(() => { @@ -342,7 +409,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { opened={isOpen} onClose={onClose} title="Group Manager" - size="md" + size="lg" scrollAreaComponent={ScrollArea.Autosize} > @@ -399,12 +466,13 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { - {/* Existing groups */} - + {/* Filter Controls */} + - - Groups ({filteredGroups.length}{searchTerm && ` of ${sortedGroups.length}`}) - + + + Filter Groups + { /> + + Show: + + + + Channel Groups ({filterCounts.channels}) + + + + + + M3U Groups ({filterCounts.m3u}) + + + + Unused Groups ({filterCounts.unused}) + + + + + + + {/* Existing groups */} + + + Groups ({filteredGroups.length}{(searchTerm || !showChannelGroups || !showM3UGroups || !showUnusedGroups) && ` of ${sortedGroups.length}`}) + + {loading ? ( Loading group information... ) : filteredGroups.length === 0 ? ( - {searchTerm ? 'No groups found matching your search' : 'No groups found'} + {searchTerm || !showChannelGroups || !showM3UGroups || !showUnusedGroups ? 'No groups found matching your filters' : 'No groups found'} ) : ( From 2da8273de64fd19324ef4ea8769ab32141f75c3c Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 12 Jul 2025 17:41:35 -0500 Subject: [PATCH 08/12] Add confirmation for deleting and cleaning up groups. --- .../src/components/forms/GroupManager.jsx | 452 +++++++++++------- 1 file changed, 268 insertions(+), 184 deletions(-) diff --git a/frontend/src/components/forms/GroupManager.jsx b/frontend/src/components/forms/GroupManager.jsx index 3b63b738..abb44727 100644 --- a/frontend/src/components/forms/GroupManager.jsx +++ b/frontend/src/components/forms/GroupManager.jsx @@ -29,6 +29,8 @@ import { } from 'lucide-react'; import { notifications } from '@mantine/notifications'; import useChannelsStore from '../../store/channels'; +import useWarningsStore from '../../store/warnings'; +import ConfirmationDialog from '../ConfirmationDialog'; import API from '../../api'; // Move GroupItem outside to prevent recreation on every render @@ -136,6 +138,9 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { const channelGroups = useChannelsStore((s) => s.channelGroups); const canEditChannelGroup = useChannelsStore((s) => s.canEditChannelGroup); const canDeleteChannelGroup = useChannelsStore((s) => s.canDeleteChannelGroup); + const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed); + const suppressWarning = useWarningsStore((s) => s.suppressWarning); + const [editingGroup, setEditingGroup] = useState(null); const [editName, setEditName] = useState(''); const [newGroupName, setNewGroupName] = useState(''); @@ -148,6 +153,11 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { const [showM3UGroups, setShowM3UGroups] = useState(true); const [showUnusedGroups, setShowUnusedGroups] = useState(true); + // Confirmation dialog states + const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); + const [groupToDelete, setGroupToDelete] = useState(null); + const [confirmCleanupOpen, setConfirmCleanupOpen] = useState(false); + // Memoize the channel groups array to prevent unnecessary re-renders const channelGroupsArray = useMemo(() => Object.values(channelGroups), @@ -348,6 +358,18 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { return; } + // Store group for confirmation dialog + setGroupToDelete(group); + + // Skip warning if it's been suppressed + if (isWarningSuppressed('delete-group')) { + return executeDeleteGroup(group); + } + + setConfirmDeleteOpen(true); + }, [groupUsage, isWarningSuppressed]); + + const executeDeleteGroup = useCallback(async (group) => { try { await API.deleteChannelGroup(group.id); @@ -358,14 +380,50 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { }); fetchGroupUsage(); // Refresh usage data + setConfirmDeleteOpen(false); } catch (error) { notifications.show({ title: 'Error', message: 'Failed to delete group', color: 'red', }); + setConfirmDeleteOpen(false); } - }, [groupUsage, fetchGroupUsage]); + }, [fetchGroupUsage]); + + const handleCleanup = useCallback(async () => { + // Skip warning if it's been suppressed + if (isWarningSuppressed('cleanup-groups')) { + return executeCleanup(); + } + + setConfirmCleanupOpen(true); + }, [isWarningSuppressed]); + + const executeCleanup = useCallback(async () => { + setIsCleaningUp(true); + try { + const result = await API.cleanupUnusedChannelGroups(); + + notifications.show({ + title: 'Cleanup Complete', + message: `Successfully deleted ${result.deleted_count} unused groups`, + color: 'green', + }); + + fetchGroupUsage(); // Refresh usage data + setConfirmCleanupOpen(false); + } catch (error) { + notifications.show({ + title: 'Cleanup Failed', + message: 'Failed to cleanup unused groups', + color: 'red', + }); + setConfirmCleanupOpen(false); + } finally { + setIsCleaningUp(false); + } + }, [fetchGroupUsage]); const handleNewGroupNameChange = useCallback((e) => { setNewGroupName(e.target.value); @@ -379,198 +437,224 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { setSearchTerm(e.target.value); }, []); - const handleCleanup = useCallback(async () => { - setIsCleaningUp(true); - try { - const result = await API.cleanupUnusedChannelGroups(); - - notifications.show({ - title: 'Cleanup Complete', - message: `Successfully deleted ${result.deleted_count} unused groups`, - color: 'green', - }); - - fetchGroupUsage(); // Refresh usage data - } catch (error) { - notifications.show({ - title: 'Cleanup Failed', - message: 'Failed to cleanup unused groups', - color: 'red', - }); - } finally { - setIsCleaningUp(false); - } - }, [fetchGroupUsage]); - if (!isOpen) return null; return ( - - - } color="blue" variant="light"> - Manage channel groups. Groups associated with M3U accounts or containing channels cannot be deleted. - - - {/* Create new group section */} - - {isCreating ? ( - - e.key === 'Enter' && handleCreate()} - autoFocus - /> - - - - { - setIsCreating(false); - setNewGroupName(''); - }}> - - - - ) : ( - - )} - - {!isCreating && ( - - )} - - - - - {/* Filter Controls */} - - - - - Filter Groups - - setSearchTerm('')} - > - - - )} - /> - - - - Show: - - - - Channel Groups ({filterCounts.channels}) - - - - - - M3U Groups ({filterCounts.m3u}) - - - - Unused Groups ({filterCounts.unused}) - - - - - - - {/* Existing groups */} + <> + - - Groups ({filteredGroups.length}{(searchTerm || !showChannelGroups || !showM3UGroups || !showUnusedGroups) && ` of ${sortedGroups.length}`}) - + } color="blue" variant="light"> + Manage channel groups. Groups associated with M3U accounts or containing channels cannot be deleted. + - {loading ? ( - Loading group information... - ) : filteredGroups.length === 0 ? ( - - {searchTerm || !showChannelGroups || !showM3UGroups || !showUnusedGroups ? 'No groups found matching your filters' : 'No groups found'} - - ) : ( - - {filteredGroups.map((group) => ( - + {isCreating ? ( + + e.key === 'Enter' && handleCreate()} + autoFocus /> - ))} - - )} + + + + { + setIsCreating(false); + setNewGroupName(''); + }}> + + + + ) : ( + + )} + + {!isCreating && ( + + )} + + + + + {/* Filter Controls */} + + + + + Filter Groups + + setSearchTerm('')} + > + + + )} + /> + + + + Show: + + + + Channel Groups ({filterCounts.channels}) + + + + + + M3U Groups ({filterCounts.m3u}) + + + + Unused Groups ({filterCounts.unused}) + + + + + + + {/* Existing groups */} + + + Groups ({filteredGroups.length}{(searchTerm || !showChannelGroups || !showM3UGroups || !showUnusedGroups) && ` of ${sortedGroups.length}`}) + + + {loading ? ( + Loading group information... + ) : filteredGroups.length === 0 ? ( + + {searchTerm || !showChannelGroups || !showM3UGroups || !showUnusedGroups ? 'No groups found matching your filters' : 'No groups found'} + + ) : ( + + {filteredGroups.map((group) => ( + + ))} + + )} + + + + + + + + - + setConfirmDeleteOpen(false)} + onConfirm={() => executeDeleteGroup(groupToDelete)} + title="Confirm Group Deletion" + message={ + groupToDelete ? ( +
+ {`Are you sure you want to delete the following group? - - - - - +Name: ${groupToDelete.name} + +This action cannot be undone.`} +
+ ) : ( + 'Are you sure you want to delete this group? This action cannot be undone.' + ) + } + confirmLabel="Delete" + cancelLabel="Cancel" + actionKey="delete-group" + onSuppressChange={suppressWarning} + size="md" + /> + + setConfirmCleanupOpen(false)} + onConfirm={executeCleanup} + title="Confirm Group Cleanup" + message={ +
+ {`Are you sure you want to cleanup all unused groups? + +This will permanently delete all groups that are not associated with any channels or M3U accounts. + +This action cannot be undone.`} +
+ } + confirmLabel="Cleanup" + cancelLabel="Cancel" + actionKey="cleanup-groups" + onSuppressChange={suppressWarning} + size="md" + /> + ); }); + export default GroupManager; From 35d95c47c724dcf1563e7bb69b37783f11a4fbdd Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 12 Jul 2025 17:48:56 -0500 Subject: [PATCH 09/12] Fixed z index issue when stream table was refreshing. --- frontend/src/components/ConfirmationDialog.jsx | 12 ++++++++++-- frontend/src/components/forms/GroupManager.jsx | 3 +++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/ConfirmationDialog.jsx b/frontend/src/components/ConfirmationDialog.jsx index 822b46f1..8f96708d 100644 --- a/frontend/src/components/ConfirmationDialog.jsx +++ b/frontend/src/components/ConfirmationDialog.jsx @@ -27,7 +27,8 @@ const ConfirmationDialog = ({ cancelLabel = 'Cancel', actionKey, onSuppressChange, - size = 'md', // Add default size parameter - md is a medium width + size = 'md', + zIndex = 1000, }) => { const suppressWarning = useWarningsStore((s) => s.suppressWarning); const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed); @@ -50,7 +51,14 @@ const ConfirmationDialog = ({ }; return ( - + {message} {actionKey && ( diff --git a/frontend/src/components/forms/GroupManager.jsx b/frontend/src/components/forms/GroupManager.jsx index abb44727..48ca85b1 100644 --- a/frontend/src/components/forms/GroupManager.jsx +++ b/frontend/src/components/forms/GroupManager.jsx @@ -447,6 +447,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { title="Group Manager" size="lg" scrollAreaComponent={ScrollArea.Autosize} + zIndex={2000} > } color="blue" variant="light"> @@ -631,6 +632,7 @@ This action cannot be undone.`} actionKey="delete-group" onSuppressChange={suppressWarning} size="md" + zIndex={2100} /> ); From 8b361ee6466c212df64951e645b44fa0cab25cbf Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 12 Jul 2025 18:12:25 -0500 Subject: [PATCH 10/12] Fix eslint issues. --- .../src/components/forms/GroupManager.jsx | 89 ++++++++++--------- 1 file changed, 45 insertions(+), 44 deletions(-) diff --git a/frontend/src/components/forms/GroupManager.jsx b/frontend/src/components/forms/GroupManager.jsx index 48ca85b1..253a2b9c 100644 --- a/frontend/src/components/forms/GroupManager.jsx +++ b/frontend/src/components/forms/GroupManager.jsx @@ -240,13 +240,6 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { return counts; }, [sortedGroups, groupUsage]); - // Fetch group usage information when modal opens - useEffect(() => { - if (isOpen) { - fetchGroupUsage(); - } - }, [isOpen]); - const fetchGroupUsage = useCallback(async () => { setLoading(true); try { @@ -270,6 +263,13 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { } }, [channelGroups]); + // Fetch group usage information when modal opens + useEffect(() => { + if (isOpen) { + fetchGroupUsage(); + } + }, [isOpen, fetchGroupUsage]); + const handleEdit = useCallback((group) => { setEditingGroup(group.id); setEditName(group.name); @@ -299,6 +299,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { setEditingGroup(null); setEditName(''); + await fetchGroupUsage(); // Refresh usage data } catch (error) { notifications.show({ title: 'Error', @@ -306,7 +307,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { color: 'red', }); } - }, [editName, editingGroup]); + }, [editName, editingGroup, fetchGroupUsage]); const handleCancelEdit = useCallback(() => { setEditingGroup(null); @@ -336,7 +337,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { setNewGroupName(''); setIsCreating(false); - fetchGroupUsage(); // Refresh usage data + await fetchGroupUsage(); // Refresh usage data } catch (error) { notifications.show({ title: 'Error', @@ -344,7 +345,29 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { color: 'red', }); } - }, [newGroupName]); + }, [newGroupName, fetchGroupUsage]); + + const executeDeleteGroup = useCallback(async (group) => { + try { + await API.deleteChannelGroup(group.id); + + notifications.show({ + title: 'Success', + message: 'Group deleted successfully', + color: 'green', + }); + + await fetchGroupUsage(); // Refresh usage data + setConfirmDeleteOpen(false); + } catch (error) { + notifications.show({ + title: 'Error', + message: 'Failed to delete group', + color: 'red', + }); + setConfirmDeleteOpen(false); + } + }, [fetchGroupUsage]); const handleDelete = useCallback(async (group) => { const usage = groupUsage[group.id]; @@ -367,38 +390,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { } setConfirmDeleteOpen(true); - }, [groupUsage, isWarningSuppressed]); - - const executeDeleteGroup = useCallback(async (group) => { - try { - await API.deleteChannelGroup(group.id); - - notifications.show({ - title: 'Success', - message: 'Group deleted successfully', - color: 'green', - }); - - fetchGroupUsage(); // Refresh usage data - setConfirmDeleteOpen(false); - } catch (error) { - notifications.show({ - title: 'Error', - message: 'Failed to delete group', - color: 'red', - }); - setConfirmDeleteOpen(false); - } - }, [fetchGroupUsage]); - - const handleCleanup = useCallback(async () => { - // Skip warning if it's been suppressed - if (isWarningSuppressed('cleanup-groups')) { - return executeCleanup(); - } - - setConfirmCleanupOpen(true); - }, [isWarningSuppressed]); + }, [groupUsage, isWarningSuppressed, executeDeleteGroup]); const executeCleanup = useCallback(async () => { setIsCleaningUp(true); @@ -411,7 +403,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { color: 'green', }); - fetchGroupUsage(); // Refresh usage data + await fetchGroupUsage(); // Refresh usage data setConfirmCleanupOpen(false); } catch (error) { notifications.show({ @@ -425,6 +417,15 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { } }, [fetchGroupUsage]); + const handleCleanup = useCallback(async () => { + // Skip warning if it's been suppressed + if (isWarningSuppressed('cleanup-groups')) { + return executeCleanup(); + } + + setConfirmCleanupOpen(true); + }, [isWarningSuppressed, executeCleanup]); + const handleNewGroupNameChange = useCallback((e) => { setNewGroupName(e.target.value); }, []); @@ -612,7 +613,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { setConfirmDeleteOpen(false)} - onConfirm={() => executeDeleteGroup(groupToDelete)} + onConfirm={() => groupToDelete && executeDeleteGroup(groupToDelete)} title="Confirm Group Deletion" message={ groupToDelete ? ( From c4e5710b484ce8d31673d194e3636e8ed521cdca Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 12 Jul 2025 19:05:06 -0500 Subject: [PATCH 11/12] When adding a group. Fetch groups after. --- frontend/src/api.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/api.js b/frontend/src/api.js index e9ab4deb..e0a62160 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -259,6 +259,8 @@ export default class API { canDelete: true }; useChannelsStore.getState().addChannelGroup(processedGroup); + // Refresh channel groups to update the UI + useChannelsStore.getState().fetchChannelGroups(); } return response; From 69f8f426a627af88b7a8d85389501b40a3bcd4a1 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 12 Jul 2025 19:10:59 -0500 Subject: [PATCH 12/12] Refactor menu items in ChannelTableHeader to fix html error. --- .../ChannelsTable/ChannelTableHeader.jsx | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx b/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx index 1568e10d..72372cc7 100644 --- a/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx +++ b/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx @@ -287,31 +287,25 @@ const ChannelTableHeader = ({ selectedTableIds.length == 0 || authUser.user_level != USER_LEVELS.ADMIN } + onClick={() => setAssignNumbersModalOpen(true)} > - setAssignNumbersModalOpen(true)} - > - Assign #s - + Assign #s } disabled={authUser.user_level != USER_LEVELS.ADMIN} + onClick={matchEpg} > - - Auto-Match - + Auto-Match } disabled={authUser.user_level != USER_LEVELS.ADMIN} + onClick={() => setGroupManagerOpen(true)} > - setGroupManagerOpen(true)}> - Edit Groups - + Edit Groups