mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +00:00
Merge pull request #244 from Dispatcharr/group-management
Group management
This commit is contained in:
commit
2cf9ade105
11 changed files with 895 additions and 26 deletions
|
|
@ -187,6 +187,91 @@ 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)
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# 3) Channel Management (CRUD)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -250,7 +250,17 @@ 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);
|
||||
// Refresh channel groups to update the UI
|
||||
useChannelsStore.getState().fetchChannelGroups();
|
||||
}
|
||||
|
||||
return response;
|
||||
|
|
@ -277,6 +287,38 @@ 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 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;
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Modal opened={opened} onClose={onClose} title={title} size={size} centered>
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
size={size}
|
||||
centered
|
||||
zIndex={zIndex}
|
||||
>
|
||||
<Box mb={20}>{message}</Box>
|
||||
|
||||
{actionKey && (
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Modal opened={isOpen} onClose={onClose} title="Channel Group">
|
||||
{channelGroup && !canEdit && (
|
||||
<Alert color="yellow" mb="md">
|
||||
This group cannot be edited because it has M3U account associations.
|
||||
</Alert>
|
||||
)}
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<TextInput
|
||||
id="name"
|
||||
name="name"
|
||||
label="Name"
|
||||
disabled={channelGroup && !canEdit}
|
||||
{...form.getInputProps('name')}
|
||||
key={form.key('name')}
|
||||
/>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
664
frontend/src/components/forms/GroupManager.jsx
Normal file
664
frontend/src/components/forms/GroupManager.jsx
Normal file
|
|
@ -0,0 +1,664 @@
|
|||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Stack,
|
||||
Group,
|
||||
Text,
|
||||
TextInput,
|
||||
Button,
|
||||
ActionIcon,
|
||||
Flex,
|
||||
Badge,
|
||||
Alert,
|
||||
Divider,
|
||||
ScrollArea,
|
||||
useMantineTheme,
|
||||
Chip,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
SquarePlus,
|
||||
SquarePen,
|
||||
SquareMinus,
|
||||
Check,
|
||||
X,
|
||||
AlertCircle,
|
||||
Database,
|
||||
Tv,
|
||||
Trash,
|
||||
Filter
|
||||
} 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
|
||||
const GroupItem = React.memo(({
|
||||
group,
|
||||
editingGroup,
|
||||
editName,
|
||||
onEditNameChange,
|
||||
onSaveEdit,
|
||||
onCancelEdit,
|
||||
onEdit,
|
||||
onDelete,
|
||||
groupUsage,
|
||||
canEditGroup,
|
||||
canDeleteGroup
|
||||
}) => {
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const getGroupBadges = (group) => {
|
||||
const usage = groupUsage[group.id];
|
||||
const badges = [];
|
||||
|
||||
if (usage?.hasChannels) {
|
||||
badges.push(
|
||||
<Badge key="channels" size="xs" color="blue" leftSection={<Tv size={10} />}>
|
||||
Channels
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (usage?.hasM3UAccounts) {
|
||||
badges.push(
|
||||
<Badge key="m3u" size="xs" color="purple" leftSection={<Database size={10} />}>
|
||||
M3U
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return badges;
|
||||
};
|
||||
|
||||
return (
|
||||
<Group justify="space-between" p="sm" style={{
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: editingGroup === group.id ? '#3f3f46' : 'transparent'
|
||||
}}>
|
||||
<Stack gap={4} style={{ flex: 1 }}>
|
||||
{editingGroup === group.id ? (
|
||||
<TextInput
|
||||
value={editName}
|
||||
onChange={onEditNameChange}
|
||||
size="sm"
|
||||
onKeyPress={(e) => e.key === 'Enter' && onSaveEdit()}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Text size="sm" fw={500}>{group.name}</Text>
|
||||
<Group gap={4}>
|
||||
{getGroupBadges(group)}
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Group gap="xs">
|
||||
{editingGroup === group.id ? (
|
||||
<>
|
||||
<ActionIcon color="green" size="sm" onClick={onSaveEdit}>
|
||||
<Check size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon color="gray" size="sm" onClick={onCancelEdit}>
|
||||
<X size={14} />
|
||||
</ActionIcon>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
color={theme.tailwind.yellow[3]}
|
||||
size="sm"
|
||||
onClick={() => onEdit(group)}
|
||||
disabled={!canEditGroup(group)}
|
||||
>
|
||||
<SquarePen size={18} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
color={theme.tailwind.red[6]}
|
||||
size="sm"
|
||||
onClick={() => onDelete(group)}
|
||||
disabled={!canDeleteGroup(group)}
|
||||
>
|
||||
<SquareMinus size="18" />
|
||||
</ActionIcon>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
});
|
||||
|
||||
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('');
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [groupUsage, setGroupUsage] = useState({});
|
||||
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);
|
||||
|
||||
// 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),
|
||||
[channelGroups]
|
||||
);
|
||||
|
||||
// Memoize sorted groups to prevent re-sorting on every render
|
||||
const sortedGroups = useMemo(() =>
|
||||
channelGroupsArray.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[channelGroupsArray]
|
||||
);
|
||||
|
||||
// Filter groups based on search term and chip filters
|
||||
const filteredGroups = useMemo(() => {
|
||||
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]);
|
||||
|
||||
const fetchGroupUsage = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Use the actual channel group data that already has the flags
|
||||
const usage = {};
|
||||
|
||||
Object.values(channelGroups).forEach(group => {
|
||||
usage[group.id] = {
|
||||
hasChannels: group.hasChannels ?? false,
|
||||
hasM3UAccounts: group.hasM3UAccounts ?? false,
|
||||
canEdit: group.canEdit ?? true,
|
||||
canDelete: group.canDelete ?? true
|
||||
};
|
||||
});
|
||||
|
||||
setGroupUsage(usage);
|
||||
} catch (error) {
|
||||
console.error('Error fetching group usage:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [channelGroups]);
|
||||
|
||||
// Fetch group usage information when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchGroupUsage();
|
||||
}
|
||||
}, [isOpen, fetchGroupUsage]);
|
||||
|
||||
const handleEdit = useCallback((group) => {
|
||||
setEditingGroup(group.id);
|
||||
setEditName(group.name);
|
||||
}, []);
|
||||
|
||||
const handleSaveEdit = useCallback(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('');
|
||||
await fetchGroupUsage(); // Refresh usage data
|
||||
} catch (error) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Failed to update group',
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
}, [editName, editingGroup, fetchGroupUsage]);
|
||||
|
||||
const handleCancelEdit = useCallback(() => {
|
||||
setEditingGroup(null);
|
||||
setEditName('');
|
||||
}, []);
|
||||
|
||||
const handleCreate = useCallback(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);
|
||||
await fetchGroupUsage(); // Refresh usage data
|
||||
} catch (error) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Failed to create group',
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
}, [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];
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 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, executeDeleteGroup]);
|
||||
|
||||
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',
|
||||
});
|
||||
|
||||
await 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 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);
|
||||
}, []);
|
||||
|
||||
const handleEditNameChange = useCallback((e) => {
|
||||
setEditName(e.target.value);
|
||||
}, []);
|
||||
|
||||
const handleSearchChange = useCallback((e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
}, []);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
opened={isOpen}
|
||||
onClose={onClose}
|
||||
title="Group Manager"
|
||||
size="lg"
|
||||
scrollAreaComponent={ScrollArea.Autosize}
|
||||
zIndex={2000}
|
||||
>
|
||||
<Stack>
|
||||
<Alert icon={<AlertCircle size={16} />} color="blue" variant="light">
|
||||
Manage channel groups. Groups associated with M3U accounts or containing channels cannot be deleted.
|
||||
</Alert>
|
||||
|
||||
{/* Create new group section */}
|
||||
<Group justify="space-between">
|
||||
{isCreating ? (
|
||||
<Group style={{ flex: 1 }}>
|
||||
<TextInput
|
||||
placeholder="Enter group name"
|
||||
value={newGroupName}
|
||||
onChange={handleNewGroupNameChange}
|
||||
style={{ flex: 1 }}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleCreate()}
|
||||
autoFocus
|
||||
/>
|
||||
<ActionIcon color="green" onClick={handleCreate}>
|
||||
<Check size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon color="gray" onClick={() => {
|
||||
setIsCreating(false);
|
||||
setNewGroupName('');
|
||||
}}>
|
||||
<X size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
) : (
|
||||
<Button
|
||||
leftSection={<SquarePlus size={16} />}
|
||||
variant="light"
|
||||
size="sm"
|
||||
onClick={() => setIsCreating(true)}
|
||||
>
|
||||
Add Group
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!isCreating && (
|
||||
<Button
|
||||
leftSection={<Trash size={16} />}
|
||||
variant="light"
|
||||
size="sm"
|
||||
color="orange"
|
||||
onClick={handleCleanup}
|
||||
loading={isCleaningUp}
|
||||
>
|
||||
Cleanup Unused
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Filter Controls */}
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="center">
|
||||
<Group align="center" gap="sm">
|
||||
<Filter size={16} />
|
||||
<Text size="sm" fw={600}>Filter Groups</Text>
|
||||
</Group>
|
||||
<TextInput
|
||||
placeholder="Search groups..."
|
||||
value={searchTerm}
|
||||
onChange={handleSearchChange}
|
||||
size="sm"
|
||||
style={{ width: '200px' }}
|
||||
rightSection={searchTerm && (
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
onClick={() => setSearchTerm('')}
|
||||
>
|
||||
<X size={14} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs" align="center">
|
||||
<Text size="xs" c="dimmed">Show:</Text>
|
||||
<Chip
|
||||
checked={showChannelGroups}
|
||||
onChange={setShowChannelGroups}
|
||||
size="sm"
|
||||
color="blue"
|
||||
>
|
||||
<Group gap={4}>
|
||||
<Tv size={10} />
|
||||
Channel Groups ({filterCounts.channels})
|
||||
</Group>
|
||||
</Chip>
|
||||
<Chip
|
||||
checked={showM3UGroups}
|
||||
onChange={setShowM3UGroups}
|
||||
size="sm"
|
||||
color="purple"
|
||||
>
|
||||
<Group gap={4}>
|
||||
<Database size={10} />
|
||||
M3U Groups ({filterCounts.m3u})
|
||||
</Group>
|
||||
</Chip>
|
||||
<Chip
|
||||
checked={showUnusedGroups}
|
||||
onChange={setShowUnusedGroups}
|
||||
size="sm"
|
||||
color="gray"
|
||||
>
|
||||
Unused Groups ({filterCounts.unused})
|
||||
</Chip>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Existing groups */}
|
||||
<Stack>
|
||||
<Text size="sm" fw={600}>
|
||||
Groups ({filteredGroups.length}{(searchTerm || !showChannelGroups || !showM3UGroups || !showUnusedGroups) && ` of ${sortedGroups.length}`})
|
||||
</Text>
|
||||
|
||||
{loading ? (
|
||||
<Text size="sm" c="dimmed">Loading group information...</Text>
|
||||
) : filteredGroups.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
{searchTerm || !showChannelGroups || !showM3UGroups || !showUnusedGroups ? 'No groups found matching your filters' : 'No groups found'}
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
{filteredGroups.map((group) => (
|
||||
<GroupItem
|
||||
key={group.id}
|
||||
group={group}
|
||||
editingGroup={editingGroup}
|
||||
editName={editName}
|
||||
onEditNameChange={handleEditNameChange}
|
||||
onSaveEdit={handleSaveEdit}
|
||||
onCancelEdit={handleCancelEdit}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
groupUsage={groupUsage}
|
||||
canEditGroup={canEditChannelGroup}
|
||||
canDeleteGroup={canDeleteChannelGroup}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Flex justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</Flex>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<ConfirmationDialog
|
||||
opened={confirmDeleteOpen}
|
||||
onClose={() => setConfirmDeleteOpen(false)}
|
||||
onConfirm={() => groupToDelete && executeDeleteGroup(groupToDelete)}
|
||||
title="Confirm Group Deletion"
|
||||
message={
|
||||
groupToDelete ? (
|
||||
<div style={{ whiteSpace: 'pre-line' }}>
|
||||
{`Are you sure you want to delete the following group?
|
||||
|
||||
Name: ${groupToDelete.name}
|
||||
|
||||
This action cannot be undone.`}
|
||||
</div>
|
||||
) : (
|
||||
'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"
|
||||
zIndex={2100}
|
||||
/>
|
||||
|
||||
<ConfirmationDialog
|
||||
opened={confirmCleanupOpen}
|
||||
onClose={() => setConfirmCleanupOpen(false)}
|
||||
onConfirm={executeCleanup}
|
||||
title="Confirm Group Cleanup"
|
||||
message={
|
||||
<div style={{ whiteSpace: 'pre-line' }}>
|
||||
{`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.`}
|
||||
</div>
|
||||
}
|
||||
confirmLabel="Cleanup"
|
||||
cancelLabel="Cancel"
|
||||
actionKey="cleanup-groups"
|
||||
onSuppressChange={suppressWarning}
|
||||
size="md"
|
||||
zIndex={2100}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default GroupManager;
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
@ -284,22 +287,25 @@ const ChannelTableHeader = ({
|
|||
selectedTableIds.length == 0 ||
|
||||
authUser.user_level != USER_LEVELS.ADMIN
|
||||
}
|
||||
onClick={() => setAssignNumbersModalOpen(true)}
|
||||
>
|
||||
<UnstyledButton
|
||||
size="xs"
|
||||
onClick={() => setAssignNumbersModalOpen(true)}
|
||||
>
|
||||
<Text size="xs">Assign #s</Text>
|
||||
</UnstyledButton>
|
||||
<Text size="xs">Assign #s</Text>
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<Binary size={18} />}
|
||||
disabled={authUser.user_level != USER_LEVELS.ADMIN}
|
||||
onClick={matchEpg}
|
||||
>
|
||||
<UnstyledButton size="xs" onClick={matchEpg}>
|
||||
<Text size="xs">Auto-Match</Text>
|
||||
</UnstyledButton>
|
||||
<Text size="xs">Auto-Match</Text>
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<Settings size={18} />}
|
||||
disabled={authUser.user_level != USER_LEVELS.ADMIN}
|
||||
onClick={() => setGroupManagerOpen(true)}
|
||||
>
|
||||
<Text size="xs">Edit Groups</Text>
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
|
@ -312,6 +318,11 @@ const ChannelTableHeader = ({
|
|||
onClose={closeAssignChannelNumbersModal}
|
||||
/>
|
||||
|
||||
<GroupManager
|
||||
isOpen={groupManagerOpen}
|
||||
onClose={() => setGroupManagerOpen(false)}
|
||||
/>
|
||||
|
||||
<ConfirmationDialog
|
||||
opened={confirmDeleteProfileOpen}
|
||||
onClose={() => setConfirmDeleteProfileOpen(false)}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
@ -204,10 +212,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 {
|
||||
|
|
@ -427,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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue