diff --git a/apps/channels/api_urls.py b/apps/channels/api_urls.py index 4246373e..425a6792 100644 --- a/apps/channels/api_urls.py +++ b/apps/channels/api_urls.py @@ -6,6 +6,7 @@ from .api_views import ( ChannelGroupViewSet, BulkDeleteStreamsAPIView, BulkDeleteChannelsAPIView, + BulkDeleteLogosAPIView, LogoViewSet, ChannelProfileViewSet, UpdateChannelMembershipAPIView, @@ -28,6 +29,8 @@ urlpatterns = [ # Bulk delete is a single APIView, not a ViewSet path('streams/bulk-delete/', BulkDeleteStreamsAPIView.as_view(), name='bulk_delete_streams'), path('channels/bulk-delete/', BulkDeleteChannelsAPIView.as_view(), name='bulk_delete_channels'), + path('logos/bulk-delete/', BulkDeleteLogosAPIView.as_view(), name='bulk_delete_logos'), + path('logos/cleanup/', BulkDeleteLogosAPIView.as_view(), {'action': 'cleanup_unused_logos'}, name='cleanup_unused_logos'), path('channels//streams/', GetChannelStreamsAPIView.as_view(), name='get_channel_streams'), path('profiles//channels//', UpdateChannelMembershipAPIView.as_view(), name='update_channel_membership'), path('profiles//channels/bulk-update/', BulkUpdateChannelMembershipAPIView.as_view(), name='bulk_update_channel_membership'), diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 97d0b074..9a04fed4 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -1023,6 +1023,79 @@ class BulkDeleteChannelsAPIView(APIView): ) +# ───────────────────────────────────────────────────────── +# 6) Bulk Delete Logos +# ───────────────────────────────────────────────────────── +class BulkDeleteLogosAPIView(APIView): + def get_permissions(self): + try: + return [ + perm() for perm in permission_classes_by_method[self.request.method] + ] + except KeyError: + return [Authenticated()] + + @swagger_auto_schema( + operation_description="Bulk delete logos by ID", + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + required=["logo_ids"], + properties={ + "logo_ids": openapi.Schema( + type=openapi.TYPE_ARRAY, + items=openapi.Items(type=openapi.TYPE_INTEGER), + description="Logo IDs to delete", + ) + }, + ), + responses={204: "Logos deleted"}, + ) + def delete(self, request): + logo_ids = request.data.get("logo_ids", []) + + # Check if any logos are being used by channels + used_logos = Logo.objects.filter( + id__in=logo_ids, + channels__isnull=False + ).distinct() + + if used_logos.exists(): + used_names = list(used_logos.values_list('name', flat=True)) + return Response( + {"error": f"Cannot delete logos that are in use: {', '.join(used_names)}"}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Delete logos that are not in use + deleted_count = Logo.objects.filter(id__in=logo_ids).delete()[0] + + return Response( + {"message": f"Successfully deleted {deleted_count} logos"}, + status=status.HTTP_204_NO_CONTENT + ) + + @swagger_auto_schema( + method="post", + operation_description="Delete all logos that are not used by any channels", + responses={200: "Cleanup completed"}, + ) + @action(detail=False, methods=["post"], url_path="cleanup") + def cleanup_unused_logos(self, request): + """Delete all logos with no channel associations""" + unused_logos = Logo.objects.filter(channels__isnull=True) + deleted_count = unused_logos.count() + logo_names = list(unused_logos.values_list('name', flat=True)) + + # Delete the unused logos + unused_logos.delete() + + return Response({ + "message": f"Successfully deleted {deleted_count} unused logos", + "deleted_count": deleted_count, + "deleted_logos": logo_names + }) + + class LogoViewSet(viewsets.ModelViewSet): queryset = Logo.objects.all() serializer_class = LogoSerializer @@ -1043,19 +1116,19 @@ class LogoViewSet(viewsets.ModelViewSet): def get_queryset(self): """Optimize queryset with prefetch and add filtering""" queryset = Logo.objects.prefetch_related('channels').order_by('name') - + # Filter by usage used_filter = self.request.query_params.get('used', None) if used_filter == 'true': queryset = queryset.filter(channels__isnull=False).distinct() elif used_filter == 'false': queryset = queryset.filter(channels__isnull=True) - + # Filter by name name_filter = self.request.query_params.get('name', None) if name_filter: queryset = queryset.filter(name__icontains=name_filter) - + return queryset def create(self, request, *args, **kwargs): diff --git a/frontend/src/api.js b/frontend/src/api.js index 3263eaf5..c63b73fb 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1308,6 +1308,40 @@ export default class API { } } + static async deleteLogos(ids) { + try { + await request(`${host}/api/channels/logos/bulk-delete/`, { + method: 'DELETE', + body: { logo_ids: ids }, + }); + + // Remove multiple logos from store + ids.forEach(id => { + useChannelsStore.getState().removeLogo(id); + }); + + return true; + } catch (e) { + errorNotification('Failed to delete logos', e); + } + } + + static async cleanupUnusedLogos() { + try { + const response = await request(`${host}/api/channels/logos/cleanup/`, { + method: 'POST', + }); + + // Refresh logos to update the UI + await this.fetchLogos(); + + return response; + } catch (e) { + errorNotification('Failed to cleanup unused logos', e); + throw e; + } + } + static async getChannelProfiles() { try { const response = await request(`${host}/api/channels/profiles/`); diff --git a/frontend/src/components/tables/LogosTable.jsx b/frontend/src/components/tables/LogosTable.jsx index a828a24a..608be714 100644 --- a/frontend/src/components/tables/LogosTable.jsx +++ b/frontend/src/components/tables/LogosTable.jsx @@ -9,6 +9,8 @@ import { SquarePen, ExternalLink, Filter, + Trash2, + Trash, } from 'lucide-react'; import { ActionIcon, @@ -28,6 +30,7 @@ import { Select, TextInput, Menu, + Checkbox, } from '@mantine/core'; import { CustomTable, useTable } from './CustomTable'; import ConfirmationDialog from '../ConfirmationDialog'; @@ -89,11 +92,15 @@ const LogosTable = () => { const [deleteTarget, setDeleteTarget] = useState(null); const [logoToDelete, setLogoToDelete] = useState(null); const [isLoading, setIsLoading] = useState(false); + const [confirmCleanupOpen, setConfirmCleanupOpen] = useState(false); + const [isBulkDelete, setIsBulkDelete] = useState(false); + const [isCleaningUp, setIsCleaningUp] = useState(false); const [filters, setFilters] = useState({ name: '', used: 'all' }); const [debouncedNameFilter, setDebouncedNameFilter] = useState(''); + const [selectedRows, setSelectedRows] = useState(new Set()); // Debounce the name filter useEffect(() => { @@ -104,6 +111,27 @@ const LogosTable = () => { return () => clearTimeout(timer); }, [filters.name]); + const data = useMemo(() => { + const logosArray = Object.values(logos || {}); + + // Apply filters + let filteredLogos = logosArray; + + if (debouncedNameFilter) { + filteredLogos = filteredLogos.filter(logo => + logo.name.toLowerCase().includes(debouncedNameFilter.toLowerCase()) + ); + } + + if (filters.used === 'used') { + filteredLogos = filteredLogos.filter(logo => logo.is_used); + } else if (filters.used === 'unused') { + filteredLogos = filteredLogos.filter(logo => !logo.is_used); + } + + return filteredLogos.sort((a, b) => a.id - b.id); + }, [logos, debouncedNameFilter, filters.used]); + /** * Functions */ @@ -126,6 +154,61 @@ const LogosTable = () => { } finally { setIsLoading(false); setConfirmDeleteOpen(false); + setDeleteTarget(null); + setLogoToDelete(null); + setIsBulkDelete(false); + setSelectedRows(new Set()); // Clear selections + } + }, [fetchLogos]); + + const executeBulkDelete = useCallback(async () => { + if (selectedRows.size === 0) return; + + setIsLoading(true); + try { + await API.deleteLogos(Array.from(selectedRows)); + await fetchLogos(); + + notifications.show({ + title: 'Success', + message: `${selectedRows.size} logos deleted successfully`, + color: 'green', + }); + } catch (error) { + notifications.show({ + title: 'Error', + message: 'Failed to delete logos', + color: 'red', + }); + } finally { + setIsLoading(false); + setConfirmDeleteOpen(false); + setIsBulkDelete(false); + setSelectedRows(new Set()); // Clear selections + } + }, [selectedRows, fetchLogos]); + + const executeCleanupUnused = useCallback(async () => { + setIsCleaningUp(true); + try { + const result = await API.cleanupUnusedLogos(); + await fetchLogos(); // Refresh the logos list + + notifications.show({ + title: 'Cleanup Complete', + message: `Successfully deleted ${result.deleted_count} unused logos`, + color: 'green', + }); + } catch (error) { + notifications.show({ + title: 'Cleanup Failed', + message: 'Failed to cleanup unused logos', + color: 'red', + }); + } finally { + setIsCleaningUp(false); + setConfirmCleanupOpen(false); + setSelectedRows(new Set()); // Clear selections after cleanup } }, [fetchLogos]); @@ -139,14 +222,73 @@ const LogosTable = () => { const logo = logosArray.find((l) => l.id === id); setLogoToDelete(logo); setDeleteTarget(id); + setIsBulkDelete(false); setConfirmDeleteOpen(true); }, [logos]); + const handleSelectRow = useCallback((id, checked) => { + setSelectedRows(prev => { + const newSet = new Set(prev); + if (checked) { + newSet.add(id); + } else { + newSet.delete(id); + } + return newSet; + }); + }, []); + + const handleSelectAll = useCallback((checked) => { + if (checked) { + setSelectedRows(new Set(data.map(logo => logo.id))); + } else { + setSelectedRows(new Set()); + } + }, [data]); + + const deleteBulkLogos = useCallback(() => { + if (selectedRows.size === 0) return; + + setIsBulkDelete(true); + setLogoToDelete(null); + setDeleteTarget(Array.from(selectedRows)); + setConfirmDeleteOpen(true); + }, [selectedRows]); + + const handleCleanupUnused = useCallback(() => { + setConfirmCleanupOpen(true); + }, []); + + // Clear selections when logos data changes (e.g., after filtering) + useEffect(() => { + setSelectedRows(new Set()); + }, [data.length]); + /** * useMemo */ const columns = useMemo( () => [ + { + id: 'select', + header: ({ table }) => ( + 0 && selectedRows.size === data.length} + indeterminate={selectedRows.size > 0 && selectedRows.size < data.length} + onChange={(event) => handleSelectAll(event.currentTarget.checked)} + size="sm" + /> + ), + cell: ({ row }) => ( + handleSelectRow(row.original.id, event.currentTarget.checked)} + size="sm" + /> + ), + size: 50, + enableSorting: false, + }, { header: 'Preview', accessorKey: 'cache_url', @@ -256,7 +398,7 @@ const LogosTable = () => { ), }, ], - [theme, editLogo, deleteLogo] + [theme, editLogo, deleteLogo, selectedRows, handleSelectRow, handleSelectAll, data.length] ); const closeLogoForm = () => { @@ -265,27 +407,6 @@ const LogosTable = () => { fetchLogos(); // Refresh the logos list }; - const data = useMemo(() => { - const logosArray = Object.values(logos || {}); - - // Apply filters - let filteredLogos = logosArray; - - if (debouncedNameFilter) { - filteredLogos = filteredLogos.filter(logo => - logo.name.toLowerCase().includes(debouncedNameFilter.toLowerCase()) - ); - } - - if (filters.used === 'used') { - filteredLogos = filteredLogos.filter(logo => logo.is_used); - } else if (filters.used === 'unused') { - filteredLogos = filteredLogos.filter(logo => !logo.is_used); - } - - return filteredLogos.sort((a, b) => a.id - b.id); - }, [logos, debouncedNameFilter, filters.used]); - const renderHeaderCell = (header) => { return ( @@ -294,17 +415,22 @@ const LogosTable = () => { ); }; + const onRowSelectionChange = useCallback((newSelection) => { + setSelectedRows(new Set(newSelection)); + }, []); + const table = useTable({ columns, data, allRowIds: data.map((logo) => logo.id), enablePagination: false, - enableRowSelection: false, + enableRowSelection: true, enableRowVirtualization: false, renderTopToolbar: false, manualSorting: false, manualFiltering: false, manualPagination: false, + onRowSelectionChange: onRowSelectionChange, headerCellRenderFns: { actions: renderHeaderCell, cache_url: renderHeaderCell, @@ -394,21 +520,44 @@ const LogosTable = () => { /> - + + + + + + + {/* Table container */} @@ -437,10 +586,23 @@ const LogosTable = () => { setConfirmDeleteOpen(false)} - onConfirm={() => executeDeleteLogo(deleteTarget)} - title="Delete Logo" + onConfirm={() => { + if (isBulkDelete) { + executeBulkDelete(); + } else { + executeDeleteLogo(deleteTarget); + } + }} + title={isBulkDelete ? "Delete Multiple Logos" : "Delete Logo"} message={ - logoToDelete ? ( + isBulkDelete ? ( +
+ Are you sure you want to delete {selectedRows.size} selected logos? + + This action cannot be undone. + +
+ ) : logoToDelete ? (
Are you sure you want to delete the logo "{logoToDelete.name}"? {logoToDelete.channel_count > 0 && ( @@ -460,6 +622,27 @@ const LogosTable = () => { cancelLabel="Cancel" size="md" /> + + setConfirmCleanupOpen(false)} + onConfirm={executeCleanupUnused} + title="Cleanup Unused Logos" + message={ +
+ Are you sure you want to cleanup all unused logos? + + This will permanently delete all logos that are not currently used by any channels. + + + This action cannot be undone. + +
+ } + confirmLabel="Cleanup" + cancelLabel="Cancel" + size="md" + /> ); };