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: {