Merge pull request #244 from Dispatcharr/group-management

Group management
This commit is contained in:
SergeantPanda 2025-07-12 19:20:33 -05:00 committed by GitHub
commit 2cf9ade105
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 895 additions and 26 deletions

View file

@ -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)

View file

@ -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):

View file

@ -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;

View file

@ -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 && (

View file

@ -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);

View file

@ -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);

View file

@ -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

View 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;

View file

@ -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,

View file

@ -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)}

View file

@ -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;