mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-24 02:28:12 +00:00
commit
0996d396cf
16 changed files with 1701 additions and 32 deletions
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
127
core/tasks.py
127
core/tasks.py
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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} />,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
268
frontend/src/components/forms/Logo.jsx
Normal file
268
frontend/src/components/forms/Logo.jsx
Normal 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;
|
||||
793
frontend/src/components/tables/LogosTable.jsx
Normal file
793
frontend/src/components/tables/LogosTable.jsx
Normal 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;
|
||||
33
frontend/src/pages/Logos.jsx
Normal file
33
frontend/src/pages/Logos.jsx
Normal 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;
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue