mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-19 09:34:38 +00:00
Logo manager initial commit.
This commit is contained in:
parent
7292ad460f
commit
489851906e
7 changed files with 560 additions and 4 deletions
|
|
@ -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")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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/`);
|
||||
|
|
|
|||
|
|
@ -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} />,
|
||||
|
|
|
|||
225
frontend/src/components/forms/Logo.jsx
Normal file
225
frontend/src/components/forms/Logo.jsx
Normal 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;
|
||||
223
frontend/src/pages/Logos.jsx
Normal file
223
frontend/src/pages/Logos.jsx
Normal 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;
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue