From 489851906ea2385647ea48c7989833551f775caa Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 15 Jul 2025 18:19:10 -0500 Subject: [PATCH 01/27] Logo manager initial commit. --- apps/channels/api_views.py | 44 ++++- frontend/src/App.jsx | 2 + frontend/src/api.js | 44 +++++ frontend/src/components/Sidebar.jsx | 6 + frontend/src/components/forms/Logo.jsx | 225 +++++++++++++++++++++++++ frontend/src/pages/Logos.jsx | 223 ++++++++++++++++++++++++ frontend/src/store/channels.jsx | 20 ++- 7 files changed, 560 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/forms/Logo.jsx create mode 100644 frontend/src/pages/Logos.jsx diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index f0f59f29..96b7362f 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -1038,6 +1038,31 @@ class LogoViewSet(viewsets.ModelViewSet): except KeyError: return [Authenticated()] + def create(self, request, *args, **kwargs): + """Create a new logo entry""" + serializer = self.get_serializer(data=request.data) + if serializer.is_valid(): + logo = serializer.save() + return Response(self.get_serializer(logo).data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def update(self, request, *args, **kwargs): + """Update an existing logo""" + return super().update(request, *args, **kwargs) + + def destroy(self, request, *args, **kwargs): + """Delete a logo""" + logo = self.get_object() + + # Check if logo is being used by any channels + if logo.channels.exists(): + return Response( + {"error": f"Cannot delete logo as it is used by {logo.channels.count()} channel(s)"}, + status=status.HTTP_400_BAD_REQUEST + ) + + return super().destroy(request, *args, **kwargs) + @action(detail=False, methods=["post"]) def upload(self, request): if "file" not in request.FILES: @@ -1062,7 +1087,7 @@ class LogoViewSet(viewsets.ModelViewSet): ) return Response( - {"id": logo.id, "name": logo.name, "url": logo.url}, + LogoSerializer(logo, context={'request': request}).data, status=status.HTTP_201_CREATED, ) @@ -1092,7 +1117,13 @@ class LogoViewSet(viewsets.ModelViewSet): else: # Remote image try: - remote_response = requests.get(logo_url, stream=True) + # Add proper timeouts to prevent hanging + remote_response = requests.get( + logo_url, + stream=True, + timeout=(10, 30), # (connect_timeout, read_timeout) + headers={'User-Agent': 'Dispatcharr/1.0'} + ) if remote_response.status_code == 200: # Try to get content type from response headers first content_type = remote_response.headers.get("Content-Type") @@ -1114,7 +1145,14 @@ class LogoViewSet(viewsets.ModelViewSet): ) return response raise Http404("Remote image not found") - except requests.RequestException: + except requests.exceptions.Timeout: + logger.warning(f"Timeout fetching logo from {logo_url}") + raise Http404("Logo request timed out") + except requests.exceptions.ConnectionError: + logger.warning(f"Connection error fetching logo from {logo_url}") + raise Http404("Unable to connect to logo server") + except requests.RequestException as e: + logger.warning(f"Error fetching logo from {logo_url}: {e}") raise Http404("Error fetching remote image") diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a057be50..4467759e 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -15,6 +15,7 @@ import Stats from './pages/Stats'; import DVR from './pages/DVR'; import Settings from './pages/Settings'; import Users from './pages/Users'; +import LogosPage from './pages/Logos'; import useAuthStore from './store/auth'; import FloatingVideo from './components/FloatingVideo'; import { WebsocketProvider } from './WebSocket'; @@ -133,6 +134,7 @@ const App = () => { } /> } /> } /> + } /> ) : ( } /> diff --git a/frontend/src/api.js b/frontend/src/api.js index e34dabe2..cbd8950a 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1253,6 +1253,50 @@ export default class API { } } + static async createLogo(values) { + try { + const response = await request(`${host}/api/channels/logos/`, { + method: 'POST', + body: values, + }); + + useChannelsStore.getState().addLogo(response); + + return response; + } catch (e) { + errorNotification('Failed to create logo', e); + } + } + + static async updateLogo(id, values) { + try { + const response = await request(`${host}/api/channels/logos/${id}/`, { + method: 'PUT', + body: values, + }); + + useChannelsStore.getState().updateLogo(response); + + return response; + } catch (e) { + errorNotification('Failed to update logo', e); + } + } + + static async deleteLogo(id) { + try { + await request(`${host}/api/channels/logos/${id}/`, { + method: 'DELETE', + }); + + useChannelsStore.getState().removeLogo(id); + + return true; + } catch (e) { + errorNotification('Failed to delete logo', e); + } + } + static async getChannelProfiles() { try { const response = await request(`${host}/api/channels/profiles/`); diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index 6d69e9e7..96a0746a 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -13,6 +13,7 @@ import { Ellipsis, LogOut, User, + FileImage, } from 'lucide-react'; import { Avatar, @@ -109,6 +110,11 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { icon: , path: '/users', }, + { + label: 'Logos', + icon: , + path: '/logos', + }, { label: 'Settings', icon: , diff --git a/frontend/src/components/forms/Logo.jsx b/frontend/src/components/forms/Logo.jsx new file mode 100644 index 00000000..c3e48d5d --- /dev/null +++ b/frontend/src/components/forms/Logo.jsx @@ -0,0 +1,225 @@ +import React, { useState, useEffect } from 'react'; +import { useFormik } from 'formik'; +import * as Yup from 'yup'; +import { + Modal, + TextInput, + Button, + Group, + Stack, + Image, + Text, + Center, + Box, + Divider, +} from '@mantine/core'; +import { Dropzone } from '@mantine/dropzone'; +import { Upload, FileImage, X } from 'lucide-react'; +import { notifications } from '@mantine/notifications'; +import API from '../../api'; + +const LogoForm = ({ logo = null, isOpen, onClose }) => { + const [logoPreview, setLogoPreview] = useState(null); + const [uploading, setUploading] = useState(false); + + const formik = useFormik({ + initialValues: { + name: '', + url: '', + }, + validationSchema: Yup.object({ + name: Yup.string().required('Name is required'), + url: Yup.string().url('Must be a valid URL').required('URL is required'), + }), + onSubmit: async (values, { setSubmitting }) => { + try { + if (logo) { + await API.updateLogo(logo.id, values); + notifications.show({ + title: 'Success', + message: 'Logo updated successfully', + color: 'green', + }); + } else { + await API.createLogo(values); + notifications.show({ + title: 'Success', + message: 'Logo created successfully', + color: 'green', + }); + } + onClose(); + } catch (error) { + let errorMessage = logo ? 'Failed to update logo' : 'Failed to create logo'; + + // Handle specific timeout errors + if (error.code === 'NETWORK_ERROR' || error.message?.includes('timeout')) { + errorMessage = 'Request timed out. Please try again.'; + } + + notifications.show({ + title: 'Error', + message: errorMessage, + color: 'red', + }); + } finally { + setSubmitting(false); + } + }, + }); + + useEffect(() => { + if (logo) { + formik.setValues({ + name: logo.name || '', + url: logo.url || '', + }); + setLogoPreview(logo.cache_url); + } else { + formik.resetForm(); + setLogoPreview(null); + } + }, [logo, isOpen]); + + const handleFileUpload = async (files) => { + if (files.length === 0) return; + + const file = files[0]; + setUploading(true); + + try { + const response = await API.uploadLogo(file); + + // Update form with uploaded file info + formik.setFieldValue('name', response.name); + formik.setFieldValue('url', response.url); + setLogoPreview(response.cache_url); + + notifications.show({ + title: 'Success', + message: 'Logo uploaded successfully', + color: 'green', + }); + } catch (error) { + let errorMessage = 'Failed to upload logo'; + + // Handle specific timeout errors + if (error.code === 'NETWORK_ERROR' || error.message?.includes('timeout')) { + errorMessage = 'Upload timed out. Please try again.'; + } + + notifications.show({ + title: 'Error', + message: errorMessage, + color: 'red', + }); + } finally { + setUploading(false); + } + }; + + const handleUrlChange = (event) => { + const url = event.target.value; + formik.setFieldValue('url', url); + + // Update preview for remote URLs + if (url && url.startsWith('http')) { + setLogoPreview(url); + } + }; + + return ( + +
+ + {/* Logo Preview */} + {logoPreview && ( +
+ + + Preview + + Logo preview + +
+ )} + + {/* File Upload */} + + + Upload Logo File + + + + + + + + + + + + + +
+ + Drag image here or click to select + + + Supports PNG, JPEG, GIF, WebP files + +
+
+
+
+ + + + {/* Manual URL Input */} + + + + + + + + +
+
+
+ ); +}; + +export default LogoForm; diff --git a/frontend/src/pages/Logos.jsx b/frontend/src/pages/Logos.jsx new file mode 100644 index 00000000..7ca879f6 --- /dev/null +++ b/frontend/src/pages/Logos.jsx @@ -0,0 +1,223 @@ +import React, { useState, useEffect } from 'react'; +import { + Container, + Title, + Button, + Table, + Group, + ActionIcon, + Text, + Image, + Box, + Center, + Stack, + Badge, +} from '@mantine/core'; +import { SquarePen, Trash2, Plus, ExternalLink } from 'lucide-react'; +import { notifications } from '@mantine/notifications'; +import useChannelsStore from '../store/channels'; +import API from '../api'; +import LogoForm from '../components/forms/Logo'; +import ConfirmationDialog from '../components/ConfirmationDialog'; + +const LogosPage = () => { + const { logos, fetchLogos } = useChannelsStore(); + const [logoFormOpen, setLogoFormOpen] = useState(false); + const [editingLogo, setEditingLogo] = useState(null); + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [logoToDelete, setLogoToDelete] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + loadLogos(); + }, []); + + const loadLogos = async () => { + setLoading(true); + try { + await fetchLogos(); + } catch (error) { + notifications.show({ + title: 'Error', + message: 'Failed to load logos', + color: 'red', + }); + } finally { + setLoading(false); + } + }; + + const handleCreateLogo = () => { + setEditingLogo(null); + setLogoFormOpen(true); + }; + + const handleEditLogo = (logo) => { + setEditingLogo(logo); + setLogoFormOpen(true); + }; + + const handleDeleteLogo = (logo) => { + setLogoToDelete(logo); + setDeleteConfirmOpen(true); + }; + + const confirmDeleteLogo = async () => { + if (!logoToDelete) return; + + try { + await API.deleteLogo(logoToDelete.id); + await fetchLogos(); + notifications.show({ + title: 'Success', + message: 'Logo deleted successfully', + color: 'green', + }); + } catch (error) { + notifications.show({ + title: 'Error', + message: 'Failed to delete logo', + color: 'red', + }); + } finally { + setDeleteConfirmOpen(false); + setLogoToDelete(null); + } + }; + + const handleFormClose = () => { + setLogoFormOpen(false); + setEditingLogo(null); + loadLogos(); // Refresh the logos list + }; + + const logosArray = Object.values(logos || {}); + + const rows = logosArray.map((logo) => ( + + +
+ {logo.name} +
+
+ + {logo.name} + + + + + {logo.url} + + {logo.url.startsWith('http') && ( + window.open(logo.url, '_blank')} + > + + + )} + + + + + handleEditLogo(logo)} + color="blue" + > + + + handleDeleteLogo(logo)} + color="red" + > + + + + +
+ )); + + return ( + <> + + + Logos + + + + {loading ? ( +
+ Loading logos... +
+ ) : logosArray.length === 0 ? ( +
+ + No logos found + Click "Add Logo" to create your first logo + +
+ ) : ( + + + Total: {logosArray.length} logo{logosArray.length !== 1 ? 's' : ''} + + + + + + Preview + Name + URL + Actions + + + {rows} +
+
+ )} +
+ + + + setDeleteConfirmOpen(false)} + onConfirm={confirmDeleteLogo} + title="Delete Logo" + message={ + logoToDelete ? ( +
+ Are you sure you want to delete the logo "{logoToDelete.name}"? +
+ + This action cannot be undone. + +
+ ) : ( + 'Are you sure you want to delete this logo?' + ) + } + confirmLabel="Delete" + cancelLabel="Cancel" + /> + + ); +}; + +export default LogosPage; diff --git a/frontend/src/store/channels.jsx b/frontend/src/store/channels.jsx index 40791cf4..a4c61149 100644 --- a/frontend/src/store/channels.jsx +++ b/frontend/src/store/channels.jsx @@ -21,7 +21,7 @@ const useChannelsStore = create((set, get) => ({ forceUpdate: 0, triggerUpdate: () => { - set({ forecUpdate: new Date() }); + set({ forceUpdate: new Date() }); }, fetchChannels: async () => { @@ -255,6 +255,24 @@ const useChannelsStore = create((set, get) => ({ }, })), + updateLogo: (logo) => + set((state) => ({ + logos: { + ...state.logos, + [logo.id]: { + ...logo, + url: logo.url.replace(/^\/data/, ''), + }, + }, + })), + + removeLogo: (logoId) => + set((state) => { + const newLogos = { ...state.logos }; + delete newLogos[logoId]; + return { logos: newLogos }; + }), + addProfile: (profile) => set((state) => ({ profiles: { From cea078f6ef5b20cbbb8c0fd6991964ca76527bba Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 15 Jul 2025 18:37:22 -0500 Subject: [PATCH 02/27] Use default user-agent and adjust timeouts. --- apps/channels/api_views.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 96b7362f..310fccbb 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -17,6 +17,8 @@ from apps.accounts.permissions import ( permission_classes_by_method, ) +from core.models import UserAgent, CoreSettings + from .models import ( Stream, Channel, @@ -1053,14 +1055,14 @@ class LogoViewSet(viewsets.ModelViewSet): def destroy(self, request, *args, **kwargs): """Delete a logo""" logo = self.get_object() - + # Check if logo is being used by any channels if logo.channels.exists(): return Response( {"error": f"Cannot delete logo as it is used by {logo.channels.count()} channel(s)"}, status=status.HTTP_400_BAD_REQUEST ) - + return super().destroy(request, *args, **kwargs) @action(detail=False, methods=["post"]) @@ -1117,12 +1119,21 @@ class LogoViewSet(viewsets.ModelViewSet): else: # Remote image try: + # Get the default user agent + try: + default_user_agent_id = CoreSettings.get_default_user_agent_id() + user_agent_obj = UserAgent.objects.get(id=int(default_user_agent_id)) + user_agent = user_agent_obj.user_agent + except (CoreSettings.DoesNotExist, UserAgent.DoesNotExist, ValueError): + # Fallback to hardcoded if default not found + user_agent = 'Dispatcharr/1.0' + # Add proper timeouts to prevent hanging remote_response = requests.get( - logo_url, - stream=True, - timeout=(10, 30), # (connect_timeout, read_timeout) - headers={'User-Agent': 'Dispatcharr/1.0'} + logo_url, + stream=True, + timeout=(3, 5), # (connect_timeout, read_timeout) + headers={'User-Agent': user_agent} ) if remote_response.status_code == 200: # Try to get content type from response headers first From 2bba31940d1ac4927827f7a97f994c59291f4ceb Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 15 Jul 2025 20:02:21 -0500 Subject: [PATCH 03/27] Use our custom table for displaying logos --- frontend/src/components/tables/LogosTable.jsx | 356 ++++++++++++++++++ frontend/src/pages/Logos.jsx | 204 +--------- 2 files changed, 363 insertions(+), 197 deletions(-) create mode 100644 frontend/src/components/tables/LogosTable.jsx diff --git a/frontend/src/components/tables/LogosTable.jsx b/frontend/src/components/tables/LogosTable.jsx new file mode 100644 index 00000000..df6605d1 --- /dev/null +++ b/frontend/src/components/tables/LogosTable.jsx @@ -0,0 +1,356 @@ +import React, { useMemo, useCallback, useState } from 'react'; +import API from '../../api'; +import LogoForm from '../forms/Logo'; +import useChannelsStore from '../../store/channels'; +import useLocalStorage from '../../hooks/useLocalStorage'; +import { + SquarePlus, + SquareMinus, + SquarePen, + ExternalLink, +} from 'lucide-react'; +import { + ActionIcon, + Box, + Text, + Paper, + Button, + Flex, + Group, + useMantineTheme, + LoadingOverlay, + Stack, + Image, + Center, +} from '@mantine/core'; +import { CustomTable, useTable } from './CustomTable'; +import ConfirmationDialog from '../ConfirmationDialog'; +import { notifications } from '@mantine/notifications'; + +const LogoRowActions = ({ theme, row, editLogo, deleteLogo }) => { + const [tableSize, _] = useLocalStorage('table-size', 'default'); + + const onEdit = useCallback(() => { + editLogo(row.original); + }, [row.original, editLogo]); + + const onDelete = useCallback(() => { + deleteLogo(row.original.id); + }, [row.original.id, deleteLogo]); + + const iconSize = + tableSize == 'default' ? 'sm' : tableSize == 'compact' ? 'xs' : 'md'; + + return ( + + + + + + + + + + + + ); +}; + +const LogosTable = () => { + const theme = useMantineTheme(); + + /** + * STORES + */ + const { logos, fetchLogos } = useChannelsStore(); + + /** + * useState + */ + const [selectedLogo, setSelectedLogo] = useState(null); + const [logoModalOpen, setLogoModalOpen] = useState(false); + const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + const [logoToDelete, setLogoToDelete] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + /** + * Functions + */ + const executeDeleteLogo = useCallback(async (id) => { + setIsLoading(true); + try { + await API.deleteLogo(id); + await fetchLogos(); + notifications.show({ + title: 'Success', + message: 'Logo deleted successfully', + color: 'green', + }); + } catch (error) { + notifications.show({ + title: 'Error', + message: 'Failed to delete logo', + color: 'red', + }); + } finally { + setIsLoading(false); + setConfirmDeleteOpen(false); + } + }, [fetchLogos]); + + const editLogo = useCallback(async (logo = null) => { + setSelectedLogo(logo); + setLogoModalOpen(true); + }, []); + + const deleteLogo = useCallback(async (id) => { + const logosArray = Object.values(logos || {}); + const logo = logosArray.find((l) => l.id === id); + setLogoToDelete(logo); + setDeleteTarget(id); + setConfirmDeleteOpen(true); + }, [logos]); + + /** + * useMemo + */ + const columns = useMemo( + () => [ + { + header: 'Preview', + accessorKey: 'cache_url', + size: 80, + enableSorting: false, + cell: ({ getValue, row }) => ( +
+ {row.original.name} +
+ ), + }, + { + header: 'Name', + accessorKey: 'name', + size: 200, + cell: ({ getValue }) => ( + + {getValue()} + + ), + }, + { + header: 'URL', + accessorKey: 'url', + cell: ({ getValue }) => ( + + + + {getValue()} + + + {getValue()?.startsWith('http') && ( + window.open(getValue(), '_blank')} + > + + + )} + + ), + }, + { + id: 'actions', + size: 80, + header: 'Actions', + enableSorting: false, + cell: ({ row }) => ( + + ), + }, + ], + [theme, editLogo, deleteLogo] + ); + + const closeLogoForm = () => { + setSelectedLogo(null); + setLogoModalOpen(false); + fetchLogos(); // Refresh the logos list + }; + + const data = useMemo(() => { + const logosArray = Object.values(logos || {}); + return logosArray.sort((a, b) => a.id - b.id); + }, [logos]); + + const renderHeaderCell = (header) => { + return ( + + {header.column.columnDef.header} + + ); + }; + + const table = useTable({ + columns, + data, + allRowIds: data.map((logo) => logo.id), + enablePagination: false, + enableRowSelection: false, + enableRowVirtualization: false, + renderTopToolbar: false, + manualSorting: false, + manualFiltering: false, + manualPagination: false, + headerCellRenderFns: { + actions: renderHeaderCell, + cache_url: renderHeaderCell, + name: renderHeaderCell, + url: renderHeaderCell, + }, + }); + + return ( + <> + + + + + Logos + + + ({data.length} logo{data.length !== 1 ? 's' : ''}) + + + + + {/* Top toolbar */} + + + + + {/* Table container */} + +
+ + +
+
+
+
+
+ + + + setConfirmDeleteOpen(false)} + onConfirm={() => executeDeleteLogo(deleteTarget)} + title="Delete Logo" + message={ + logoToDelete ? ( +
+ Are you sure you want to delete the logo "{logoToDelete.name}"? +
+ + This action cannot be undone. + +
+ ) : ( + 'Are you sure you want to delete this logo?' + ) + } + confirmLabel="Delete" + cancelLabel="Cancel" + size="md" + /> + + ); +}; + +export default LogosTable; diff --git a/frontend/src/pages/Logos.jsx b/frontend/src/pages/Logos.jsx index 7ca879f6..ee26c51e 100644 --- a/frontend/src/pages/Logos.jsx +++ b/frontend/src/pages/Logos.jsx @@ -1,39 +1,17 @@ -import React, { useState, useEffect } from 'react'; -import { - Container, - Title, - Button, - Table, - Group, - ActionIcon, - Text, - Image, - Box, - Center, - Stack, - Badge, -} from '@mantine/core'; -import { SquarePen, Trash2, Plus, ExternalLink } from 'lucide-react'; +import React, { useEffect } from 'react'; +import { Box } from '@mantine/core'; import { notifications } from '@mantine/notifications'; import useChannelsStore from '../store/channels'; -import API from '../api'; -import LogoForm from '../components/forms/Logo'; -import ConfirmationDialog from '../components/ConfirmationDialog'; +import LogosTable from '../components/tables/LogosTable'; const LogosPage = () => { - const { logos, fetchLogos } = useChannelsStore(); - const [logoFormOpen, setLogoFormOpen] = useState(false); - const [editingLogo, setEditingLogo] = useState(null); - const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); - const [logoToDelete, setLogoToDelete] = useState(null); - const [loading, setLoading] = useState(true); + const { fetchLogos } = useChannelsStore(); useEffect(() => { loadLogos(); }, []); const loadLogos = async () => { - setLoading(true); try { await fetchLogos(); } catch (error) { @@ -42,181 +20,13 @@ const LogosPage = () => { message: 'Failed to load logos', color: 'red', }); - } finally { - setLoading(false); } }; - const handleCreateLogo = () => { - setEditingLogo(null); - setLogoFormOpen(true); - }; - - const handleEditLogo = (logo) => { - setEditingLogo(logo); - setLogoFormOpen(true); - }; - - const handleDeleteLogo = (logo) => { - setLogoToDelete(logo); - setDeleteConfirmOpen(true); - }; - - const confirmDeleteLogo = async () => { - if (!logoToDelete) return; - - try { - await API.deleteLogo(logoToDelete.id); - await fetchLogos(); - notifications.show({ - title: 'Success', - message: 'Logo deleted successfully', - color: 'green', - }); - } catch (error) { - notifications.show({ - title: 'Error', - message: 'Failed to delete logo', - color: 'red', - }); - } finally { - setDeleteConfirmOpen(false); - setLogoToDelete(null); - } - }; - - const handleFormClose = () => { - setLogoFormOpen(false); - setEditingLogo(null); - loadLogos(); // Refresh the logos list - }; - - const logosArray = Object.values(logos || {}); - - const rows = logosArray.map((logo) => ( - - -
- {logo.name} -
-
- - {logo.name} - - - - - {logo.url} - - {logo.url.startsWith('http') && ( - window.open(logo.url, '_blank')} - > - - - )} - - - - - handleEditLogo(logo)} - color="blue" - > - - - handleDeleteLogo(logo)} - color="red" - > - - - - -
- )); - return ( - <> - - - Logos - - - - {loading ? ( -
- Loading logos... -
- ) : logosArray.length === 0 ? ( -
- - No logos found - Click "Add Logo" to create your first logo - -
- ) : ( - - - Total: {logosArray.length} logo{logosArray.length !== 1 ? 's' : ''} - - - - - - Preview - Name - URL - Actions - - - {rows} -
-
- )} -
- - - - setDeleteConfirmOpen(false)} - onConfirm={confirmDeleteLogo} - title="Delete Logo" - message={ - logoToDelete ? ( -
- Are you sure you want to delete the logo "{logoToDelete.name}"? -
- - This action cannot be undone. - -
- ) : ( - 'Are you sure you want to delete this logo?' - ) - } - confirmLabel="Delete" - cancelLabel="Cancel" - /> - + + + ); }; From 500df533bbe3ca68ab824ec5fc8f477a25a93086 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 15 Jul 2025 20:12:25 -0500 Subject: [PATCH 04/27] Center logos in the column. --- frontend/src/components/tables/LogosTable.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/tables/LogosTable.jsx b/frontend/src/components/tables/LogosTable.jsx index df6605d1..257bb17f 100644 --- a/frontend/src/components/tables/LogosTable.jsx +++ b/frontend/src/components/tables/LogosTable.jsx @@ -133,7 +133,7 @@ const LogosTable = () => { size: 80, enableSorting: false, cell: ({ getValue, row }) => ( -
+
{row.original.name} Date: Tue, 15 Jul 2025 20:14:34 -0500 Subject: [PATCH 05/27] Add padding to logos. --- frontend/src/components/tables/LogosTable.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/tables/LogosTable.jsx b/frontend/src/components/tables/LogosTable.jsx index 257bb17f..9138aeb7 100644 --- a/frontend/src/components/tables/LogosTable.jsx +++ b/frontend/src/components/tables/LogosTable.jsx @@ -133,7 +133,7 @@ const LogosTable = () => { size: 80, enableSorting: false, cell: ({ getValue, row }) => ( -
+
{row.original.name} Date: Tue, 15 Jul 2025 20:26:02 -0500 Subject: [PATCH 06/27] Enhance Logo management with filtering and usage details in API and UI --- apps/channels/api_views.py | 18 +++ apps/channels/serializers.py | 21 +++- frontend/src/api.js | 15 ++- frontend/src/components/tables/LogosTable.jsx | 108 +++++++++++++++++- 4 files changed, 155 insertions(+), 7 deletions(-) diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 310fccbb..97d0b074 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -1040,6 +1040,24 @@ class LogoViewSet(viewsets.ModelViewSet): except KeyError: return [Authenticated()] + 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): """Create a new logo entry""" serializer = self.get_serializer(data=request.data) diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py index 278399dd..3346495e 100644 --- a/apps/channels/serializers.py +++ b/apps/channels/serializers.py @@ -20,10 +20,13 @@ from django.utils import timezone class LogoSerializer(serializers.ModelSerializer): cache_url = serializers.SerializerMethodField() + channel_count = serializers.SerializerMethodField() + is_used = serializers.SerializerMethodField() + channel_names = serializers.SerializerMethodField() class Meta: model = Logo - fields = ["id", "name", "url", "cache_url"] + fields = ["id", "name", "url", "cache_url", "channel_count", "is_used", "channel_names"] def get_cache_url(self, obj): # return f"/api/channels/logos/{obj.id}/cache/" @@ -34,6 +37,22 @@ class LogoSerializer(serializers.ModelSerializer): ) return reverse("api:channels:logo-cache", args=[obj.id]) + def get_channel_count(self, obj): + """Get the number of channels using this logo""" + return obj.channels.count() + + def get_is_used(self, obj): + """Check if this logo is used by any channels""" + return obj.channels.exists() + + def get_channel_names(self, obj): + """Get the names of channels using this logo (limited to first 5)""" + channels = obj.channels.all()[:5] + names = [channel.name for channel in channels] + if obj.channels.count() > 5: + names.append(f"...and {obj.channels.count() - 5} more") + return names + # # Stream diff --git a/frontend/src/api.js b/frontend/src/api.js index cbd8950a..3263eaf5 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1225,9 +1225,10 @@ export default class API { } } - static async getLogos() { + static async getLogos(params = {}) { try { - const response = await request(`${host}/api/channels/logos/`); + const queryParams = new URLSearchParams(params); + const response = await request(`${host}/api/channels/logos/?${queryParams.toString()}`); return response; } catch (e) { @@ -1235,6 +1236,16 @@ export default class API { } } + static async fetchLogos() { + try { + const response = await this.getLogos(); + useChannelsStore.getState().setLogos(response); + return response; + } catch (e) { + errorNotification('Failed to fetch logos', e); + } + } + static async uploadLogo(file) { try { const formData = new FormData(); diff --git a/frontend/src/components/tables/LogosTable.jsx b/frontend/src/components/tables/LogosTable.jsx index 9138aeb7..872b9dca 100644 --- a/frontend/src/components/tables/LogosTable.jsx +++ b/frontend/src/components/tables/LogosTable.jsx @@ -8,6 +8,7 @@ import { SquareMinus, SquarePen, ExternalLink, + Filter, } from 'lucide-react'; import { ActionIcon, @@ -22,6 +23,11 @@ import { Stack, Image, Center, + Badge, + Tooltip, + Select, + TextInput, + Menu, } from '@mantine/core'; import { CustomTable, useTable } from './CustomTable'; import ConfirmationDialog from '../ConfirmationDialog'; @@ -83,6 +89,10 @@ const LogosTable = () => { const [deleteTarget, setDeleteTarget] = useState(null); const [logoToDelete, setLogoToDelete] = useState(null); const [isLoading, setIsLoading] = useState(false); + const [filters, setFilters] = useState({ + name: '', + used: 'all' + }); /** * Functions @@ -155,6 +165,42 @@ const LogosTable = () => { ), }, + { + header: 'Usage', + accessorKey: 'channel_count', + size: 120, + cell: ({ getValue, row }) => { + const count = getValue(); + const channelNames = row.original.channel_names || []; + + if (count === 0) { + return ( + + Unused + + ); + } + + return ( + + Used by {count} channel{count !== 1 ? 's' : ''}: + {channelNames.map((name, index) => ( + • {name} + ))} + + } + multiline + width={220} + > + + {count} channel{count !== 1 ? 's' : ''} + + + ); + }, + }, { header: 'URL', accessorKey: 'url', @@ -211,8 +257,24 @@ const LogosTable = () => { const data = useMemo(() => { const logosArray = Object.values(logos || {}); - return logosArray.sort((a, b) => a.id - b.id); - }, [logos]); + + // Apply filters + let filteredLogos = logosArray; + + if (filters.name) { + filteredLogos = filteredLogos.filter(logo => + logo.name.toLowerCase().includes(filters.name.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, filters]); const renderHeaderCell = (header) => { return ( @@ -238,6 +300,7 @@ const LogosTable = () => { cache_url: renderHeaderCell, name: renderHeaderCell, url: renderHeaderCell, + channel_count: renderHeaderCell, }, }); @@ -282,11 +345,44 @@ const LogosTable = () => { + + + setFilters(prev => ({ + ...prev, + name: event.currentTarget.value + })) + } + size="xs" + style={{ width: 200 }} + /> +