Logo manager initial commit.

This commit is contained in:
SergeantPanda 2025-07-15 18:19:10 -05:00
parent 7292ad460f
commit 489851906e
7 changed files with 560 additions and 4 deletions

View file

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

View file

@ -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 = () => {
<Route path="/stats" element={<Stats />} />
<Route path="/users" element={<Users />} />
<Route path="/settings" element={<Settings />} />
<Route path="/logos" element={<LogosPage />} />
</>
) : (
<Route path="/login" element={<Login needsSuperuser />} />

View file

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

View file

@ -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: <User size={20} />,
path: '/users',
},
{
label: 'Logos',
icon: <FileImage size={20} />,
path: '/logos',
},
{
label: 'Settings',
icon: <LucideSettings size={20} />,

View file

@ -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 (
<Modal
opened={isOpen}
onClose={onClose}
title={logo ? 'Edit Logo' : 'Add Logo'}
size="md"
>
<form onSubmit={formik.handleSubmit}>
<Stack spacing="md">
{/* Logo Preview */}
{logoPreview && (
<Center>
<Box>
<Text size="sm" color="dimmed" mb="xs" ta="center">
Preview
</Text>
<Image
src={logoPreview}
alt="Logo preview"
width={100}
height={75}
fit="contain"
fallbackSrc="/logo.png"
/>
</Box>
</Center>
)}
{/* File Upload */}
<Box>
<Text size="sm" fw={500} mb="xs">
Upload Logo File
</Text>
<Dropzone
onDrop={handleFileUpload}
accept={['image/png', 'image/jpeg', 'image/gif', 'image/webp']}
maxFiles={1}
loading={uploading}
>
<Group justify="center" gap="xl" mih={120} style={{ pointerEvents: 'none' }}>
<Dropzone.Accept>
<Upload size={50} color="green" />
</Dropzone.Accept>
<Dropzone.Reject>
<X size={50} color="red" />
</Dropzone.Reject>
<Dropzone.Idle>
<FileImage size={50} />
</Dropzone.Idle>
<div>
<Text size="xl" inline>
Drag image here or click to select
</Text>
<Text size="sm" color="dimmed" inline mt={7}>
Supports PNG, JPEG, GIF, WebP files
</Text>
</div>
</Group>
</Dropzone>
</Box>
<Divider label="OR" labelPosition="center" />
{/* Manual URL Input */}
<TextInput
label="Logo URL"
placeholder="https://example.com/logo.png"
{...formik.getFieldProps('url')}
onChange={handleUrlChange}
error={formik.touched.url && formik.errors.url}
/>
<TextInput
label="Name"
placeholder="Enter logo name"
{...formik.getFieldProps('name')}
error={formik.touched.name && formik.errors.name}
/>
<Group justify="flex-end" mt="md">
<Button variant="light" onClick={onClose}>
Cancel
</Button>
<Button type="submit" loading={formik.isSubmitting || uploading}>
{logo ? 'Update' : 'Create'}
</Button>
</Group>
</Stack>
</form>
</Modal>
);
};
export default LogoForm;

View file

@ -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) => (
<Table.Tr key={logo.id}>
<Table.Td>
<Center>
<Image
src={logo.cache_url}
alt={logo.name}
width={40}
height={30}
fit="contain"
fallbackSrc="/logo.png"
/>
</Center>
</Table.Td>
<Table.Td>
<Text fw={500}>{logo.name}</Text>
</Table.Td>
<Table.Td>
<Group spacing="xs" align="center">
<Text size="sm" color="dimmed" style={{ wordBreak: 'break-all', maxWidth: 300 }}>
{logo.url}
</Text>
{logo.url.startsWith('http') && (
<ActionIcon
size="sm"
variant="subtle"
onClick={() => window.open(logo.url, '_blank')}
>
<ExternalLink size={14} />
</ActionIcon>
)}
</Group>
</Table.Td>
<Table.Td>
<Group spacing="xs">
<ActionIcon
variant="subtle"
onClick={() => handleEditLogo(logo)}
color="blue"
>
<SquarePen size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
onClick={() => handleDeleteLogo(logo)}
color="red"
>
<Trash2 size={16} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
));
return (
<>
<Container size="xl" py="md">
<Group justify="space-between" mb="md">
<Title order={2}>Logos</Title>
<Button leftSection={<Plus size={16} />} onClick={handleCreateLogo}>
Add Logo
</Button>
</Group>
{loading ? (
<Center py="xl">
<Text>Loading logos...</Text>
</Center>
) : logosArray.length === 0 ? (
<Center py="xl">
<Stack align="center" spacing="md">
<Text size="lg" color="dimmed">No logos found</Text>
<Text size="sm" color="dimmed">Click "Add Logo" to create your first logo</Text>
</Stack>
</Center>
) : (
<Box>
<Text size="sm" color="dimmed" mb="sm">
Total: {logosArray.length} logo{logosArray.length !== 1 ? 's' : ''}
</Text>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Preview</Table.Th>
<Table.Th>Name</Table.Th>
<Table.Th>URL</Table.Th>
<Table.Th>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{rows}</Table.Tbody>
</Table>
</Box>
)}
</Container>
<LogoForm
logo={editingLogo}
isOpen={logoFormOpen}
onClose={handleFormClose}
/>
<ConfirmationDialog
opened={deleteConfirmOpen}
onClose={() => setDeleteConfirmOpen(false)}
onConfirm={confirmDeleteLogo}
title="Delete Logo"
message={
logoToDelete ? (
<div>
Are you sure you want to delete the logo "{logoToDelete.name}"?
<br />
<Text size="sm" color="dimmed" mt="xs">
This action cannot be undone.
</Text>
</div>
) : (
'Are you sure you want to delete this logo?'
)
}
confirmLabel="Delete"
cancelLabel="Cancel"
/>
</>
);
};
export default LogosPage;

View file

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