Merge pull request #251 from Dispatcharr/logo-manager

Logo manager
This commit is contained in:
SergeantPanda 2025-07-18 15:43:32 -05:00 committed by GitHub
commit 0996d396cf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1701 additions and 32 deletions

View file

@ -6,6 +6,8 @@ from .api_views import (
ChannelGroupViewSet,
BulkDeleteStreamsAPIView,
BulkDeleteChannelsAPIView,
BulkDeleteLogosAPIView,
CleanupUnusedLogosAPIView,
LogoViewSet,
ChannelProfileViewSet,
UpdateChannelMembershipAPIView,
@ -28,6 +30,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/', CleanupUnusedLogosAPIView.as_view(), name='cleanup_unused_logos'),
path('channels/<int:channel_id>/streams/', GetChannelStreamsAPIView.as_view(), name='get_channel_streams'),
path('profiles/<int:profile_id>/channels/<int:channel_id>/', UpdateChannelMembershipAPIView.as_view(), name='update_channel_membership'),
path('profiles/<int:profile_id>/channels/bulk-update/', BulkUpdateChannelMembershipAPIView.as_view(), name='bulk_update_channel_membership'),

View file

@ -1023,6 +1023,142 @@ 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", [])
delete_files = request.data.get("delete_files", False)
# Get logos and their usage info before deletion
logos_to_delete = Logo.objects.filter(id__in=logo_ids)
total_channels_affected = 0
local_files_deleted = 0
for logo in logos_to_delete:
# Handle file deletion for local files
if delete_files and logo.url and logo.url.startswith('/data/logos'):
try:
if os.path.exists(logo.url):
os.remove(logo.url)
local_files_deleted += 1
logger.info(f"Deleted local logo file: {logo.url}")
except Exception as e:
logger.error(f"Failed to delete logo file {logo.url}: {str(e)}")
return Response(
{"error": f"Failed to delete logo file {logo.url}: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
if logo.channels.exists():
channel_count = logo.channels.count()
total_channels_affected += channel_count
# Remove logo from channels
logo.channels.update(logo=None)
logger.info(f"Removed logo {logo.name} from {channel_count} channels before deletion")
# Delete logos
deleted_count = logos_to_delete.delete()[0]
message = f"Successfully deleted {deleted_count} logos"
if total_channels_affected > 0:
message += f" and removed them from {total_channels_affected} channels"
if local_files_deleted > 0:
message += f" and deleted {local_files_deleted} local files"
return Response(
{"message": message},
status=status.HTTP_204_NO_CONTENT
)
class CleanupUnusedLogosAPIView(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="Delete all logos that are not used by any channels",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
"delete_files": openapi.Schema(
type=openapi.TYPE_BOOLEAN,
description="Whether to delete local logo files from disk",
default=False
)
},
),
responses={200: "Cleanup completed"},
)
def post(self, request):
"""Delete all logos with no channel associations"""
delete_files = request.data.get("delete_files", False)
unused_logos = Logo.objects.filter(channels__isnull=True)
deleted_count = unused_logos.count()
logo_names = list(unused_logos.values_list('name', flat=True))
local_files_deleted = 0
# Handle file deletion for local files if requested
if delete_files:
for logo in unused_logos:
if logo.url and logo.url.startswith('/data/logos'):
try:
if os.path.exists(logo.url):
os.remove(logo.url)
local_files_deleted += 1
logger.info(f"Deleted local logo file: {logo.url}")
except Exception as e:
logger.error(f"Failed to delete logo file {logo.url}: {str(e)}")
return Response(
{"error": f"Failed to delete logo file {logo.url}: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
# Delete the unused logos
unused_logos.delete()
message = f"Successfully deleted {deleted_count} unused logos"
if local_files_deleted > 0:
message += f" and deleted {local_files_deleted} local files"
return Response({
"message": message,
"deleted_count": deleted_count,
"deleted_logos": logo_names,
"local_files_deleted": local_files_deleted
})
class LogoViewSet(viewsets.ModelViewSet):
queryset = Logo.objects.all()
serializer_class = LogoSerializer
@ -1040,6 +1176,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)
@ -1053,15 +1207,28 @@ class LogoViewSet(viewsets.ModelViewSet):
return super().update(request, *args, **kwargs)
def destroy(self, request, *args, **kwargs):
"""Delete a logo"""
"""Delete a logo and remove it from any channels using it"""
logo = self.get_object()
delete_file = request.query_params.get('delete_file', 'false').lower() == 'true'
# Check if logo is being used by any channels
# Check if it's a local file that should be deleted
if delete_file and logo.url and logo.url.startswith('/data/logos'):
try:
if os.path.exists(logo.url):
os.remove(logo.url)
logger.info(f"Deleted local logo file: {logo.url}")
except Exception as e:
logger.error(f"Failed to delete logo file {logo.url}: {str(e)}")
return Response(
{"error": f"Failed to delete logo file: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
# Instead of preventing deletion, remove the logo from 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
)
channel_count = logo.channels.count()
logo.channels.update(logo=None)
logger.info(f"Removed logo {logo.name} from {channel_count} channels before deletion")
return super().destroy(request, *args, **kwargs)
@ -1073,6 +1240,16 @@ class LogoViewSet(viewsets.ModelViewSet):
)
file = request.FILES["file"]
# Validate file
try:
from dispatcharr.utils import validate_logo_file
validate_logo_file(file)
except Exception as e:
return Response(
{"error": str(e)}, status=status.HTTP_400_BAD_REQUEST
)
file_name = file.name
file_path = os.path.join("/data/logos", file_name)
@ -1088,8 +1265,10 @@ class LogoViewSet(viewsets.ModelViewSet):
},
)
# Use get_serializer to ensure proper context
serializer = self.get_serializer(logo)
return Response(
LogoSerializer(logo, context={'request': request}).data,
serializer.data,
status=status.HTTP_201_CREATED,
)

View file

@ -20,10 +20,23 @@ 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 validate_url(self, value):
"""Validate that the URL is unique for creation or update"""
if self.instance and self.instance.url == value:
return value
if Logo.objects.filter(url=value).exists():
raise serializers.ValidationError("A logo with this URL already exists.")
return value
def get_cache_url(self, obj):
# return f"/api/channels/logos/{obj.id}/cache/"
@ -34,6 +47,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

View file

@ -21,10 +21,12 @@ logger = logging.getLogger(__name__)
EPG_WATCH_DIR = '/data/epgs'
M3U_WATCH_DIR = '/data/m3us'
LOGO_WATCH_DIR = '/data/logos'
MIN_AGE_SECONDS = 6
STARTUP_SKIP_AGE = 30
REDIS_PREFIX = "processed_file:"
REDIS_TTL = 60 * 60 * 24 * 3 # expire keys after 3 days (optional)
SUPPORTED_LOGO_FORMATS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg']
# Store the last known value to compare with new data
last_known_data = {}
@ -56,10 +58,11 @@ def scan_and_process_files():
global _first_scan_completed
redis_client = RedisClient.get_client()
now = time.time()
# Check if directories exist
dirs_exist = all(os.path.exists(d) for d in [M3U_WATCH_DIR, EPG_WATCH_DIR])
dirs_exist = all(os.path.exists(d) for d in [M3U_WATCH_DIR, EPG_WATCH_DIR, LOGO_WATCH_DIR])
if not dirs_exist:
throttled_log(logger.warning, f"Watch directories missing: M3U ({os.path.exists(M3U_WATCH_DIR)}), EPG ({os.path.exists(EPG_WATCH_DIR)})", "watch_dirs_missing")
throttled_log(logger.warning, f"Watch directories missing: M3U ({os.path.exists(M3U_WATCH_DIR)}), EPG ({os.path.exists(EPG_WATCH_DIR)}), LOGO ({os.path.exists(LOGO_WATCH_DIR)})", "watch_dirs_missing")
# Process M3U files
m3u_files = [f for f in os.listdir(M3U_WATCH_DIR)
@ -266,6 +269,126 @@ def scan_and_process_files():
logger.trace(f"EPG processing complete: {epg_processed} processed, {epg_skipped} skipped, {epg_errors} errors")
# Process Logo files (including subdirectories)
try:
logo_files = []
if os.path.exists(LOGO_WATCH_DIR):
for root, dirs, files in os.walk(LOGO_WATCH_DIR):
for filename in files:
logo_files.append(os.path.join(root, filename))
logger.trace(f"Found {len(logo_files)} files in LOGO directory (including subdirectories)")
except Exception as e:
logger.error(f"Error listing LOGO directory: {e}")
logo_files = []
logo_processed = 0
logo_skipped = 0
logo_errors = 0
for filepath in logo_files:
filename = os.path.basename(filepath)
if not os.path.isfile(filepath):
if _first_scan_completed:
logger.trace(f"Skipping {filename}: Not a file")
else:
logger.debug(f"Skipping {filename}: Not a file")
logo_skipped += 1
continue
# Check if file has supported logo extension
file_ext = os.path.splitext(filename)[1].lower()
if file_ext not in SUPPORTED_LOGO_FORMATS:
if _first_scan_completed:
logger.trace(f"Skipping {filename}: Not a supported logo format")
else:
logger.debug(f"Skipping {filename}: Not a supported logo format")
logo_skipped += 1
continue
mtime = os.path.getmtime(filepath)
age = now - mtime
redis_key = REDIS_PREFIX + filepath
stored_mtime = redis_client.get(redis_key)
# Check if logo already exists in database
if not stored_mtime and age > STARTUP_SKIP_AGE:
from apps.channels.models import Logo
existing_logo = Logo.objects.filter(url=filepath).exists()
if existing_logo:
if _first_scan_completed:
logger.trace(f"Skipping {filename}: Already exists in database")
else:
logger.debug(f"Skipping {filename}: Already exists in database")
redis_client.set(redis_key, mtime, ex=REDIS_TTL)
logo_skipped += 1
continue
else:
logger.debug(f"Processing {filename} despite age: Not found in database")
# File too new — probably still being written
if age < MIN_AGE_SECONDS:
if _first_scan_completed:
logger.trace(f"Skipping {filename}: Too new, possibly still being written (age={age}s)")
else:
logger.debug(f"Skipping {filename}: Too new, possibly still being written (age={age}s)")
logo_skipped += 1
continue
# Skip if we've already processed this mtime
if stored_mtime and float(stored_mtime) >= mtime:
if _first_scan_completed:
logger.trace(f"Skipping {filename}: Already processed this version")
else:
logger.debug(f"Skipping {filename}: Already processed this version")
logo_skipped += 1
continue
try:
from apps.channels.models import Logo
# Create logo entry with just the filename (without extension) as name
logo_name = os.path.splitext(filename)[0]
logo, created = Logo.objects.get_or_create(
url=filepath,
defaults={
"name": logo_name,
}
)
redis_client.set(redis_key, mtime, ex=REDIS_TTL)
if created:
logger.info(f"Created new logo entry: {logo_name}")
else:
logger.debug(f"Logo entry already exists: {logo_name}")
logo_processed += 1
except Exception as e:
logger.error(f"Error processing logo file {filename}: {str(e)}", exc_info=True)
logo_errors += 1
continue
logger.trace(f"LOGO processing complete: {logo_processed} processed, {logo_skipped} skipped, {logo_errors} errors")
# Send summary websocket update for logo processing
if logo_processed > 0 or logo_errors > 0:
send_websocket_update(
"updates",
"update",
{
"success": True,
"type": "logo_processing_summary",
"processed": logo_processed,
"skipped": logo_skipped,
"errors": logo_errors,
"total_files": len(logo_files),
"message": f"Logo processing complete: {logo_processed} processed, {logo_skipped} skipped, {logo_errors} errors"
}
)
# Mark that the first scan is complete
_first_scan_completed = True

View file

@ -21,11 +21,11 @@ def json_success_response(data=None, status=200):
def validate_logo_file(file):
"""Validate uploaded logo file size and MIME type."""
valid_mime_types = ["image/jpeg", "image/png", "image/gif"]
valid_mime_types = ["image/jpeg", "image/png", "image/gif", "image/webp"]
if file.content_type not in valid_mime_types:
raise ValidationError("Unsupported file type. Allowed types: JPEG, PNG, GIF.")
if file.size > 2 * 1024 * 1024:
raise ValidationError("File too large. Max 2MB.")
raise ValidationError("Unsupported file type. Allowed types: JPEG, PNG, GIF, WebP.")
if file.size > 5 * 1024 * 1024: # Increased to 5MB
raise ValidationError("File too large. Max 5MB.")
def get_client_ip(request):

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

@ -218,6 +218,7 @@ export const WebsocketProvider = ({ children }) => {
}
updatePlaylist(updateData);
fetchPlaylists(); // Refresh playlists to ensure UI is up-to-date
} else {
// Log when playlist can't be found for debugging purposes
console.warn(
@ -417,6 +418,16 @@ export const WebsocketProvider = ({ children }) => {
}
break;
case 'logo_processing_summary':
notifications.show({
title: 'Logo Processing Summary',
message: `${parsedEvent.data.message}`,
color: 'blue',
autoClose: 5000,
});
fetchLogos();
break;
default:
console.error(
`Unknown websocket event type: ${parsedEvent.data?.type}`
@ -487,6 +498,7 @@ export const WebsocketProvider = ({ children }) => {
const setProfilePreview = usePlaylistsStore((s) => s.setProfilePreview);
const fetchEPGData = useEPGsStore((s) => s.fetchEPGData);
const fetchEPGs = useEPGsStore((s) => s.fetchEPGs);
const fetchLogos = useChannelsStore((s) => s.fetchLogos);
const ret = useMemo(() => {
return [isReady, ws.current?.send.bind(ws.current), val];

View file

@ -1226,9 +1226,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) {
@ -1236,21 +1237,165 @@ 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();
formData.append('file', file);
const response = await request(`${host}/api/channels/logos/upload/`, {
// Add timeout handling for file uploads
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout
const response = await fetch(`${host}/api/channels/logos/upload/`, {
method: 'POST',
body: formData,
headers: {
Authorization: `Bearer ${await API.getAuthToken()}`,
},
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
const error = new Error(`HTTP error! Status: ${response.status}`);
let errorBody = await response.text();
try {
errorBody = JSON.parse(errorBody);
} catch (e) {
// If parsing fails, leave errorBody as the raw text
}
error.status = response.status;
error.response = response;
error.body = errorBody;
throw error;
}
const result = await response.json();
useChannelsStore.getState().addLogo(result);
return result;
} catch (e) {
if (e.name === 'AbortError') {
const timeoutError = new Error('Upload timed out. Please try again.');
timeoutError.code = 'NETWORK_ERROR';
throw timeoutError;
}
errorNotification('Failed to upload logo', e);
throw e;
}
}
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 upload logo', e);
errorNotification('Failed to create logo', e);
}
}
static async updateLogo(id, values) {
try {
// Convert values to FormData for the multipart/form-data content type
const formData = new FormData();
// Add each field to the form data
Object.keys(values).forEach(key => {
if (values[key] !== null && values[key] !== undefined) {
formData.append(key, values[key]);
}
});
const response = await request(`${host}/api/channels/logos/${id}/`, {
method: 'PUT',
body: formData, // Send as FormData instead of JSON
});
useChannelsStore.getState().updateLogo(response);
return response;
} catch (e) {
errorNotification('Failed to update logo', e);
}
}
static async deleteLogo(id, deleteFile = false) {
try {
const params = new URLSearchParams();
if (deleteFile) {
params.append('delete_file', 'true');
}
const url = `${host}/api/channels/logos/${id}/?${params.toString()}`;
await request(url, {
method: 'DELETE',
});
useChannelsStore.getState().removeLogo(id);
return true;
} catch (e) {
errorNotification('Failed to delete logo', e);
}
}
static async deleteLogos(ids, deleteFiles = false) {
try {
const body = { logo_ids: ids };
if (deleteFiles) {
body.delete_files = true;
}
await request(`${host}/api/channels/logos/bulk-delete/`, {
method: 'DELETE',
body: body,
});
// 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(deleteFiles = false) {
try {
const body = {};
if (deleteFiles) {
body.delete_files = true;
}
const response = await request(`${host}/api/channels/logos/cleanup/`, {
method: 'POST',
body: body,
});
return response;
} catch (e) {
errorNotification('Failed to cleanup unused logos', e);
throw e;
}
}

View file

@ -29,12 +29,15 @@ const ConfirmationDialog = ({
onSuppressChange,
size = 'md',
zIndex = 1000,
showDeleteFileOption = false,
deleteFileLabel = "Also delete files from disk",
}) => {
const suppressWarning = useWarningsStore((s) => s.suppressWarning);
const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
const [suppressChecked, setSuppressChecked] = useState(
isWarningSuppressed(actionKey)
);
const [deleteFiles, setDeleteFiles] = useState(false);
const handleToggleSuppress = (e) => {
setSuppressChecked(e.currentTarget.checked);
@ -47,13 +50,23 @@ const ConfirmationDialog = ({
if (suppressChecked) {
suppressWarning(actionKey);
}
onConfirm();
if (showDeleteFileOption) {
onConfirm(deleteFiles);
} else {
onConfirm();
}
setDeleteFiles(false); // Reset for next time
};
const handleClose = () => {
setDeleteFiles(false); // Reset for next time
onClose();
};
return (
<Modal
opened={opened}
onClose={onClose}
onClose={handleClose}
title={title}
size={size}
centered
@ -70,8 +83,17 @@ const ConfirmationDialog = ({
/>
)}
{showDeleteFileOption && (
<Checkbox
checked={deleteFiles}
onChange={(event) => setDeleteFiles(event.currentTarget.checked)}
label={deleteFileLabel}
mb="md"
/>
)}
<Group justify="flex-end">
<Button variant="outline" onClick={onClose}>
<Button variant="outline" onClick={handleClose}>
{cancelLabel}
</Button>
<Button color="red" onClick={handleConfirm}>

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

View file

@ -31,6 +31,7 @@ import {
Image,
UnstyledButton,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { ListOrdered, SquarePlus, SquareX, X } from 'lucide-react';
import useEPGsStore from '../../store/epgs';
import { Dropzone } from '@mantine/dropzone';
@ -84,10 +85,28 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => {
const handleLogoChange = async (files) => {
if (files.length === 1) {
const retval = await API.uploadLogo(files[0]);
await fetchLogos();
setLogoPreview(retval.cache_url);
formik.setFieldValue('logo_id', retval.id);
const file = files[0];
// Validate file size on frontend first
if (file.size > 5 * 1024 * 1024) {
// 5MB
notifications.show({
title: 'Error',
message: 'File too large. Maximum size is 5MB.',
color: 'red',
});
return;
}
try {
const retval = await API.uploadLogo(file);
await fetchLogos();
setLogoPreview(retval.cache_url);
formik.setFieldValue('logo_id', retval.id);
} catch (error) {
console.error('Logo upload failed:', error);
// Error notification is already handled in API.uploadLogo
}
} else {
setLogoPreview(null);
}

View file

@ -34,6 +34,7 @@ import {
import { ListOrdered, SquarePlus, SquareX, X } from 'lucide-react';
import useEPGsStore from '../../store/epgs';
import { Dropzone } from '@mantine/dropzone';
import { notifications } from '@mantine/notifications';
import { FixedSizeList as List } from 'react-window';
const ChannelsForm = ({ channel = null, isOpen, onClose }) => {
@ -81,10 +82,28 @@ const ChannelsForm = ({ channel = null, isOpen, onClose }) => {
const handleLogoChange = async (files) => {
if (files.length === 1) {
const retval = await API.uploadLogo(files[0]);
await fetchLogos();
setLogoPreview(retval.cache_url);
formik.setFieldValue('logo_id', retval.id);
const file = files[0];
// Validate file size on frontend first
if (file.size > 5 * 1024 * 1024) {
// 5MB
notifications.show({
title: 'Error',
message: 'File too large. Maximum size is 5MB.',
color: 'red',
});
return;
}
try {
const retval = await API.uploadLogo(file);
await fetchLogos();
setLogoPreview(retval.cache_url);
formik.setFieldValue('logo_id', retval.id);
} catch (error) {
console.error('Logo upload failed:', error);
// Error notification is already handled in API.uploadLogo
}
} else {
setLogoPreview(null);
}

View file

@ -0,0 +1,268 @@
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()
.required('URL is required')
.test('valid-url-or-path', 'Must be a valid URL or local file path', (value) => {
if (!value) return false;
// Allow local file paths starting with /data/logos/
if (value.startsWith('/data/logos/')) return true;
// Allow valid URLs
try {
new URL(value);
return true;
} catch {
return false;
}
}),
}),
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.';
} else if (error.response?.data?.error) {
errorMessage = error.response.data.error;
}
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];
// Validate file size on frontend first
if (file.size > 5 * 1024 * 1024) { // 5MB
notifications.show({
title: 'Error',
message: 'File too large. Maximum size is 5MB.',
color: 'red',
});
return;
}
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.';
} else if (error.status === 413) {
errorMessage = 'File too large. Please choose a smaller file.';
} else if (error.body?.error) {
errorMessage = error.body.error;
}
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"
style={{
transition: 'transform 0.3s ease',
cursor: 'pointer',
':hover': {
transform: 'scale(1.5)'
}
}}
onMouseEnter={(e) => {
e.target.style.transform = 'scale(1.5)';
}}
onMouseLeave={(e) => {
e.target.style.transform = 'scale(1)';
}}
/>
</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,793 @@
import React, { useMemo, useCallback, useState, useEffect } 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,
Filter,
Trash2,
Trash,
} from 'lucide-react';
import {
ActionIcon,
Box,
Text,
Paper,
Button,
Flex,
Group,
useMantineTheme,
LoadingOverlay,
Stack,
Image,
Center,
Badge,
Tooltip,
Select,
TextInput,
Menu,
Checkbox,
Pagination,
NativeSelect,
} 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 (
<Box style={{ width: '100%', justifyContent: 'left' }}>
<Group gap={2} justify="center">
<ActionIcon
size={iconSize}
variant="transparent"
color={theme.tailwind.yellow[3]}
onClick={onEdit}
>
<SquarePen size="18" />
</ActionIcon>
<ActionIcon
size={iconSize}
variant="transparent"
color={theme.tailwind.red[6]}
onClick={onDelete}
>
<SquareMinus size="18" />
</ActionIcon>
</Group>
</Box>
);
};
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);
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());
const [pageSize, setPageSize] = useLocalStorage('logos-page-size', 25);
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: pageSize,
});
const [paginationString, setPaginationString] = useState('');
// Debounce the name filter
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedNameFilter(filters.name);
}, 300); // 300ms delay
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]);
// Get paginated data
const paginatedData = useMemo(() => {
const startIndex = pagination.pageIndex * pagination.pageSize;
const endIndex = startIndex + pagination.pageSize;
return data.slice(startIndex, endIndex);
}, [data, pagination.pageIndex, pagination.pageSize]);
// Calculate unused logos count
const unusedLogosCount = useMemo(() => {
const allLogos = Object.values(logos || {});
return allLogos.filter(logo => !logo.is_used).length;
}, [logos]);
/**
* Functions
*/
const executeDeleteLogo = useCallback(async (id, deleteFile = false) => {
setIsLoading(true);
try {
await API.deleteLogo(id, deleteFile);
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);
setDeleteTarget(null);
setLogoToDelete(null);
setIsBulkDelete(false);
setSelectedRows(new Set()); // Clear selections
}
}, [fetchLogos]);
const executeBulkDelete = useCallback(async (deleteFiles = false) => {
if (selectedRows.size === 0) return;
setIsLoading(true);
try {
await API.deleteLogos(Array.from(selectedRows), deleteFiles);
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 (deleteFiles = false) => {
setIsCleaningUp(true);
try {
const result = await API.cleanupUnusedLogos(deleteFiles);
await fetchLogos(); // Refresh the logos list
let message = `Successfully deleted ${result.deleted_count} unused logos`;
if (result.local_files_deleted > 0) {
message += ` and deleted ${result.local_files_deleted} local files`;
}
notifications.show({
title: 'Cleanup Complete',
message: message,
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]);
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);
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]);
// Update pagination when pageSize changes
useEffect(() => {
setPagination(prev => ({
...prev,
pageSize: pageSize,
}));
}, [pageSize]);
// Calculate pagination string
useEffect(() => {
const startItem = pagination.pageIndex * pagination.pageSize + 1;
const endItem = Math.min(
(pagination.pageIndex + 1) * pagination.pageSize,
data.length
);
setPaginationString(`${startItem} to ${endItem} of ${data.length}`);
}, [pagination.pageIndex, pagination.pageSize, data.length]);
// Calculate page count
const pageCount = useMemo(() => {
return Math.ceil(data.length / pagination.pageSize);
}, [data.length, pagination.pageSize]);
/**
* useMemo
*/
const columns = useMemo(
() => [
{
id: 'select',
header: ({ table }) => (
<Checkbox
checked={selectedRows.size > 0 && selectedRows.size === data.length}
indeterminate={selectedRows.size > 0 && selectedRows.size < data.length}
onChange={(event) => handleSelectAll(event.currentTarget.checked)}
size="sm"
/>
),
cell: ({ row }) => (
<Checkbox
checked={selectedRows.has(row.original.id)}
onChange={(event) => handleSelectRow(row.original.id, event.currentTarget.checked)}
size="sm"
/>
),
size: 50,
enableSorting: false,
},
{
header: 'Preview',
accessorKey: 'cache_url',
size: 80,
enableSorting: false,
cell: ({ getValue, row }) => (
<Center style={{ width: '100%', padding: '4px' }}>
<Image
src={getValue()}
alt={row.original.name}
width={40}
height={30}
fit="contain"
fallbackSrc="/logo.png"
style={{
transition: 'transform 0.3s ease',
cursor: 'pointer',
}}
onMouseEnter={(e) => {
e.target.style.transform = 'scale(1.5)';
}}
onMouseLeave={(e) => {
e.target.style.transform = 'scale(1)';
}}
/>
</Center>
),
},
{
header: 'Name',
accessorKey: 'name',
size: 200,
cell: ({ getValue }) => (
<Text fw={500} size="sm">
{getValue()}
</Text>
),
},
{
header: 'Usage',
accessorKey: 'channel_count',
size: 120,
cell: ({ getValue, row }) => {
const count = getValue();
const channelNames = row.original.channel_names || [];
if (count === 0) {
return (
<Badge size="sm" variant="light" color="gray">
Unused
</Badge>
);
}
return (
<Tooltip
label={
<div>
<Text size="xs" fw={600}>Used by {count} channel{count !== 1 ? 's' : ''}:</Text>
{channelNames.map((name, index) => (
<Text key={index} size="xs"> {name}</Text>
))}
</div>
}
multiline
width={220}
>
<Badge size="sm" variant="light" color="blue">
{count} channel{count !== 1 ? 's' : ''}
</Badge>
</Tooltip>
);
},
},
{
header: 'URL',
accessorKey: 'url',
cell: ({ getValue }) => (
<Group gap={4} style={{ alignItems: 'center' }}>
<Box
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: 300,
}}
>
<Text size="sm" c="dimmed">
{getValue()}
</Text>
</Box>
{getValue()?.startsWith('http') && (
<ActionIcon
size="xs"
variant="transparent"
color="gray"
onClick={() => window.open(getValue(), '_blank')}
>
<ExternalLink size={12} />
</ActionIcon>
)}
</Group>
),
},
{
id: 'actions',
size: 80,
header: 'Actions',
enableSorting: false,
cell: ({ row }) => (
<LogoRowActions
theme={theme}
row={row}
editLogo={editLogo}
deleteLogo={deleteLogo}
/>
),
},
],
[theme, editLogo, deleteLogo, selectedRows, handleSelectRow, handleSelectAll, data.length]
);
const closeLogoForm = () => {
setSelectedLogo(null);
setLogoModalOpen(false);
fetchLogos(); // Refresh the logos list
};
const renderHeaderCell = (header) => {
return (
<Text size="sm" name={header.id}>
{header.column.columnDef.header}
</Text>
);
};
const onRowSelectionChange = useCallback((newSelection) => {
setSelectedRows(new Set(newSelection));
}, []);
const onPageSizeChange = (e) => {
const newPageSize = parseInt(e.target.value);
setPageSize(newPageSize);
setPagination(prev => ({
...prev,
pageSize: newPageSize,
pageIndex: 0, // Reset to first page
}));
};
const onPageIndexChange = (pageIndex) => {
if (!pageIndex || pageIndex > pageCount) {
return;
}
setPagination(prev => ({
...prev,
pageIndex: pageIndex - 1,
}));
};
const table = useTable({
columns,
data: paginatedData,
allRowIds: paginatedData.map((logo) => logo.id),
enablePagination: false, // Disable internal pagination since we're handling it manually
enableRowSelection: true,
enableRowVirtualization: false,
renderTopToolbar: false,
manualSorting: false,
manualFiltering: false,
manualPagination: true, // Enable manual pagination
onRowSelectionChange: onRowSelectionChange,
headerCellRenderFns: {
actions: renderHeaderCell,
cache_url: renderHeaderCell,
name: renderHeaderCell,
url: renderHeaderCell,
channel_count: renderHeaderCell,
},
});
return (
<>
<Box
style={{
display: 'flex',
justifyContent: 'center',
padding: '0px',
minHeight: 'calc(100vh - 200px)',
minWidth: '900px',
}}
>
<Stack gap="md" style={{ maxWidth: '1200px', width: '100%' }}>
<Flex style={{ alignItems: 'center', paddingBottom: 10 }} gap={15}>
<Text
style={{
fontFamily: 'Inter, sans-serif',
fontWeight: 500,
fontSize: '20px',
lineHeight: 1,
letterSpacing: '-0.3px',
color: 'gray.6',
marginBottom: 0,
}}
>
Logos
</Text>
<Text size="sm" c="dimmed">
({data.length} logo{data.length !== 1 ? 's' : ''})
</Text>
</Flex>
<Paper
style={{
backgroundColor: '#27272A',
border: '1px solid #3f3f46',
borderRadius: 'var(--mantine-radius-md)',
}}
>
{/* Top toolbar */}
<Box
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '16px',
borderBottom: '1px solid #3f3f46',
}}
>
<Group gap="sm">
<TextInput
placeholder="Filter by name..."
value={filters.name}
onChange={(event) => {
const value = event.target.value;
setFilters(prev => ({
...prev,
name: value
}));
}}
size="xs"
style={{ width: 200 }}
/>
<Select
placeholder="Usage filter"
value={filters.used}
onChange={(value) =>
setFilters(prev => ({
...prev,
used: value
}))
}
data={[
{ value: 'all', label: 'All logos' },
{ value: 'used', label: 'Used only' },
{ value: 'unused', label: 'Unused only' },
]}
size="xs"
style={{ width: 140 }}
/>
</Group>
<Group gap="sm">
<Button
leftSection={<Trash size={16} />}
variant="light"
size="xs"
color="orange"
onClick={handleCleanupUnused}
loading={isCleaningUp}
disabled={unusedLogosCount === 0}
>
Cleanup Unused {unusedLogosCount > 0 ? `(${unusedLogosCount})` : ''}
</Button>
<Button
leftSection={<SquareMinus size={18} />}
variant="default"
size="xs"
onClick={deleteBulkLogos}
disabled={selectedRows.size === 0}
>
Delete {selectedRows.size > 0 ? `(${selectedRows.size})` : ''}
</Button>
<Button
leftSection={<SquarePlus size={18} />}
variant="light"
size="xs"
onClick={() => editLogo()}
p={5}
color={theme.tailwind.green[5]}
style={{
borderWidth: '1px',
borderColor: theme.tailwind.green[5],
color: 'white',
}}
>
Add Logo
</Button>
</Group>
</Box>
{/* Table container */}
<Box
style={{
position: 'relative',
borderRadius: '0 0 var(--mantine-radius-md) var(--mantine-radius-md)',
}}
>
<Box
style={{
overflow: 'auto',
height: 'calc(100vh - 200px)',
}}
>
<div >
<LoadingOverlay visible={isLoading} />
<CustomTable table={table} />
</div>
</Box>
{/* Pagination Controls */}
<Box
style={{
position: 'sticky',
bottom: 0,
zIndex: 3,
backgroundColor: '#27272A',
borderTop: '1px solid #3f3f46',
}}
>
<Group
gap={5}
justify="center"
style={{
padding: 8,
}}
>
<Text size="xs">Page Size</Text>
<NativeSelect
size="xxs"
value={pagination.pageSize}
data={['25', '50', '100', '250']}
onChange={onPageSizeChange}
style={{ paddingRight: 20 }}
/>
<Pagination
total={pageCount}
value={pagination.pageIndex + 1}
onChange={onPageIndexChange}
size="xs"
withEdges
style={{ paddingRight: 20 }}
/>
<Text size="xs">{paginationString}</Text>
</Group>
</Box>
</Box>
</Paper>
</Stack>
</Box>
<LogoForm
logo={selectedLogo}
isOpen={logoModalOpen}
onClose={closeLogoForm}
/>
<ConfirmationDialog
opened={confirmDeleteOpen}
onClose={() => setConfirmDeleteOpen(false)}
onConfirm={(deleteFiles) => {
if (isBulkDelete) {
executeBulkDelete(deleteFiles);
} else {
executeDeleteLogo(deleteTarget, deleteFiles);
}
}}
title={isBulkDelete ? "Delete Multiple Logos" : "Delete Logo"}
message={
isBulkDelete ? (
<div>
Are you sure you want to delete {selectedRows.size} selected logos?
<Text size="sm" c="dimmed" mt="xs">
Any channels using these logos will have their logo removed.
</Text>
<Text size="sm" c="dimmed" mt="xs">
This action cannot be undone.
</Text>
</div>
) : logoToDelete ? (
<div>
Are you sure you want to delete the logo "{logoToDelete.name}"?
{logoToDelete.channel_count > 0 && (
<Text size="sm" c="orange" mt="xs">
This logo is currently used by {logoToDelete.channel_count} channel{logoToDelete.channel_count !== 1 ? 's' : ''}. They will have their logo removed.
</Text>
)}
<Text size="sm" c="dimmed" mt="xs">
This action cannot be undone.
</Text>
</div>
) : (
'Are you sure you want to delete this logo?'
)
}
confirmLabel="Delete"
cancelLabel="Cancel"
size="md"
showDeleteFileOption={
isBulkDelete
? Array.from(selectedRows).some(id => {
const logo = Object.values(logos).find(l => l.id === id);
return logo && logo.url && logo.url.startsWith('/data/logos');
})
: logoToDelete && logoToDelete.url && logoToDelete.url.startsWith('/data/logos')
}
deleteFileLabel={
isBulkDelete
? "Also delete local logo files from disk"
: "Also delete logo file from disk"
}
/>
<ConfirmationDialog
opened={confirmCleanupOpen}
onClose={() => setConfirmCleanupOpen(false)}
onConfirm={executeCleanupUnused}
title="Cleanup Unused Logos"
message={
<div>
Are you sure you want to cleanup {unusedLogosCount} unused logo{unusedLogosCount !== 1 ? 's' : ''}?
<Text size="sm" c="dimmed" mt="xs">
This will permanently delete all logos that are not currently used by any channels.
</Text>
<Text size="sm" c="dimmed" mt="xs">
This action cannot be undone.
</Text>
</div>
}
confirmLabel="Cleanup"
cancelLabel="Cancel"
size="md"
showDeleteFileOption={true}
deleteFileLabel="Also delete local logo files from disk"
/>
</>
);
};
export default LogosTable;

View file

@ -0,0 +1,33 @@
import React, { useEffect } from 'react';
import { Box } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import useChannelsStore from '../store/channels';
import LogosTable from '../components/tables/LogosTable';
const LogosPage = () => {
const { fetchLogos } = useChannelsStore();
useEffect(() => {
loadLogos();
}, []);
const loadLogos = async () => {
try {
await fetchLogos();
} catch (error) {
notifications.show({
title: 'Error',
message: 'Failed to load logos',
color: 'red',
});
}
};
return (
<Box style={{ padding: 10 }}>
<LogosTable />
</Box>
);
};
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 () => {
@ -232,7 +232,6 @@ const useChannelsStore = create((set, get) => ({
logos: logos.reduce((acc, logo) => {
acc[logo.id] = {
...logo,
url: logo.url.replace(/^\/data/, ''),
};
return acc;
}, {}),
@ -250,11 +249,27 @@ const useChannelsStore = create((set, get) => ({
...state.logos,
[newLogo.id]: {
...newLogo,
url: newLogo.url.replace(/^\/data/, ''),
},
},
})),
updateLogo: (logo) =>
set((state) => ({
logos: {
...state.logos,
[logo.id]: {
...logo,
},
},
})),
removeLogo: (logoId) =>
set((state) => {
const newLogos = { ...state.logos };
delete newLogos[logoId];
return { logos: newLogos };
}),
addProfile: (profile) =>
set((state) => ({
profiles: {