From 5348a4a5640b8ae937ee6dcdb2ad6a1e0b1733e7 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 23 Jan 2026 16:03:26 -0600 Subject: [PATCH 1/2] Feature: Editable Channel Table Mode: - Added a robust inline editing mode for the channels table, allowing users to quickly edit channel fields (name, number, group, EPG, logo) directly in the table without opening a modal. - EPG and logo columns now support searchable dropdowns with instant filtering and keyboard navigation for fast assignment. - Drag-and-drop reordering of channels enabled when unlocked, with persistent order updates. - Group column now uses a searchable dropdown for quick group assignment, matching the UX of EPG and logo selectors. - All editable cells provide clear focus, hover, and disabled states for improved accessibility and usability. - Table unlock/edit mode toggle with clear visual cues and safe-guarded save logic to prevent accidental edits. - All changes are saved via API with optimistic UI updates and error handling. --- CHANGELOG.md | 8 + apps/channels/api_views.py | 75 +- frontend/src/api.js | 18 + .../src/components/tables/ChannelsTable.jsx | 217 ++--- .../ChannelsTable/ChannelTableHeader.jsx | 38 + .../tables/ChannelsTable/DraggableRow.jsx | 59 ++ .../tables/ChannelsTable/EditableCell.jsx | 781 ++++++++++++++++++ .../tables/CustomTable/CustomTable.jsx | 6 +- .../tables/CustomTable/CustomTableBody.jsx | 77 +- .../tables/CustomTable/CustomTableHeader.jsx | 5 + frontend/src/store/channelsTable.jsx | 13 + 11 files changed, 1188 insertions(+), 109 deletions(-) create mode 100644 frontend/src/components/tables/ChannelsTable/DraggableRow.jsx create mode 100644 frontend/src/components/tables/ChannelsTable/EditableCell.jsx diff --git a/CHANGELOG.md b/CHANGELOG.md index bd73931a..9d920d41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Editable Channel Table Mode: + - Added a robust inline editing mode for the channels table, allowing users to quickly edit channel fields (name, number, group, EPG, logo) directly in the table without opening a modal. + - EPG and logo columns now support searchable dropdowns with instant filtering and keyboard navigation for fast assignment. + - Drag-and-drop reordering of channels enabled when unlocked, with persistent order updates. + - Group column now uses a searchable dropdown for quick group assignment, matching the UX of EPG and logo selectors. + - All editable cells provide clear focus, hover, and disabled states for improved accessibility and usability. + - Table unlock/edit mode toggle with clear visual cues and safe-guarded save logic to prevent accidental edits. + - All changes are saved via API with optimistic UI updates and error handling. - Stats page enhancements: Added "Now Playing" program information for active streams with smart polling that only fetches EPG data when programs are about to change (not on every stats refresh). Features include: - Currently playing program title displayed with live broadcast indicator (green Radio icon) - Expandable program descriptions via chevron button diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 11940177..2b2870fa 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -8,7 +8,7 @@ from drf_yasg.utils import swagger_auto_schema from drf_yasg import openapi from django.shortcuts import get_object_or_404, get_list_or_404 from django.db import transaction -from django.db.models import Count +from django.db.models import Count, F from django.db.models import Q import os, json, requests, logging, mimetypes from django.utils.http import http_date @@ -1251,6 +1251,79 @@ class ChannelViewSet(viewsets.ModelViewSet): except Exception as e: return Response({"error": str(e)}, status=400) + @swagger_auto_schema( + method="post", + operation_description=( + "Reorder a channel by moving it after another channel (or to the start if insert_after_id is null). " + "The channel will receive the next whole number after the target channel, and all subsequent " + "channels will be renumbered accordingly." + ), + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + "insert_after_id": openapi.Schema( + type=openapi.TYPE_INTEGER, + description="ID of the channel to insert after. Use null to move to the beginning.", + nullable=True, + ), + }, + ), + responses={ + 200: "Channel reordered successfully", + 404: "Channel not found", + 400: "Invalid request", + }, + ) + @action(detail=True, methods=["post"], url_path="reorder") + def reorder(self, request, pk=None): + """ + Reorder a channel by moving it near a target position. + Finds the first available channel number without unnecessarily shifting other channels. + """ + channel = self.get_object() + insert_after_id = request.data.get("insert_after_id") + old_channel_number = channel.channel_number + + with transaction.atomic(): + if insert_after_id is None: + # Move to the beginning - find first available number starting from 1 + new_channel_number = 1 + # Check if 1 is taken, find first gap + occupied = set(Channel.objects.exclude(id=channel.id).values_list('channel_number', flat=True)) + while new_channel_number in occupied: + new_channel_number += 1 + else: + try: + target_channel = Channel.objects.get(id=insert_after_id) + target_number = target_channel.channel_number or 0 + desired_position = int(target_number) + 1 + + # Get all occupied channel numbers (excluding the channel being moved) + occupied = set(Channel.objects.exclude(id=channel.id).values_list('channel_number', flat=True)) + + # Find the first available number at or after the desired position + new_channel_number = desired_position + while new_channel_number in occupied: + new_channel_number += 1 + + except Channel.DoesNotExist: + return Response( + {"error": "Target channel not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + + # Update the dragged channel's number + channel.channel_number = new_channel_number + channel.save(update_fields=['channel_number']) + + return Response( + { + "message": f"Channel {channel.name} moved to position {new_channel_number}", + "channel": self.get_serializer(channel).data, + }, + status=status.HTTP_200_OK, + ) + @swagger_auto_schema( method="post", operation_description="Associate multiple channels with EPG data without triggering a full refresh", diff --git a/frontend/src/api.js b/frontend/src/api.js index 3df0b475..a831ad22 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -568,6 +568,24 @@ export default class API { } } + static async reorderChannel(channelId, insertAfterId) { + try { + const response = await request( + `${host}/api/channels/channels/${channelId}/reorder/`, + { + method: 'POST', + body: { + insert_after_id: insertAfterId, + }, + } + ); + + return response; + } catch (e) { + errorNotification('Failed to reorder channel', e); + } + } + static async setChannelEPG(channelId, epgDataId) { try { const response = await request( diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index bcacfe98..9106fdcc 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -5,6 +5,17 @@ import React, { useCallback, useRef, } from 'react'; +import { + DndContext, + closestCenter, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { + SortableContext, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; import useChannelsStore from '../../store/channels'; import { notifications } from '@mantine/notifications'; import API from '../../api'; @@ -61,9 +72,18 @@ import ChannelTableStreams from './ChannelTableStreams'; import LazyLogo from '../LazyLogo'; import useLocalStorage from '../../hooks/useLocalStorage'; import useEPGsStore from '../../store/epgs'; +import { useChannelLogoSelection } from '../../hooks/useSmartLogos'; import { CustomTable, useTable } from './CustomTable'; import ChannelsTableOnboarding from './ChannelsTable/ChannelsTableOnboarding'; import ChannelTableHeader from './ChannelsTable/ChannelTableHeader'; +import { + EditableTextCell, + EditableNumberCell, + EditableGroupCell, + EditableEPGCell, + EditableLogoCell, +} from './ChannelsTable/EditableCell'; +import { DraggableRow } from './ChannelsTable/DraggableRow'; import useWarningsStore from '../../store/warnings'; import ConfirmationDialog from '../ConfirmationDialog'; import useAuthStore from '../../store/auth'; @@ -231,6 +251,10 @@ const ChannelsTable = ({ onReady }) => { const tvgsById = useEPGsStore((s) => s.tvgsById); const epgs = useEPGsStore((s) => s.epgs); const tvgsLoaded = useEPGsStore((s) => s.tvgsLoaded); + + // Get channel logos for logo selection + const { logos: channelLogos, ensureLogosLoaded } = useChannelLogoSelection(); + const theme = useMantineTheme(); const channelGroups = useChannelsStore((s) => s.channelGroups); const canEditChannelGroup = useChannelsStore((s) => s.canEditChannelGroup); @@ -258,6 +282,7 @@ const ChannelsTable = ({ onReady }) => { const setChannelStreams = useChannelsTableStore((s) => s.setChannelStreams); const allRowIds = useChannelsTableStore((s) => s.allQueryIds); const setAllRowIds = useChannelsTableStore((s) => s.setAllQueryIds); + const isUnlocked = useChannelsTableStore((s) => s.isUnlocked); // store/channels const channels = useChannelsStore((s) => s.channels); @@ -320,6 +345,15 @@ const ChannelsTable = ({ onReady }) => { const hasFetchedData = useRef(false); + // Drag-and-drop sensors + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, // Require 8px movement before dragging starts + }, + }) + ); + // Column sizing state for resizable columns // Store in localStorage but with empty object as default const [columnSizing, setColumnSizing] = useLocalStorage( @@ -746,6 +780,47 @@ const ChannelsTable = ({ onReady }) => { } }; + const handleDragEnd = async (event) => { + const { active, over } = event; + + if (!over || active.id === over.id) { + return; + } + + const activeIndex = rows.findIndex((row) => row.id === active.id); + const overIndex = rows.findIndex((row) => row.id === over.id); + + if (activeIndex === -1 || overIndex === -1) { + return; + } + + const activeChannel = rows[activeIndex].original; + const overChannel = rows[overIndex].original; + + try { + // Optimistically update the local state + const reorderedData = [...data]; + const [movedItem] = reorderedData.splice(activeIndex, 1); + reorderedData.splice(overIndex, 0, movedItem); + useChannelsTableStore.setState({ channels: reorderedData }); + + // Call backend to reorder + await API.reorderChannel( + activeChannel.id, + overIndex > activeIndex + ? overChannel.id + : rows[overIndex - 1]?.original.id || null + ); + + // Refetch to get updated channel numbers + await API.requeryChannels(); + } catch (error) { + // Revert on error + console.error('Failed to reorder channel:', error); + await API.requeryChannels(); + } + }; + /** * useEffect */ @@ -817,22 +892,7 @@ const ChannelsTable = ({ onReady }) => { size: columnSizing.channel_number || 40, minSize: 30, maxSize: 100, - cell: ({ getValue }) => { - const value = getValue(); - // Format as integer if no decimal component - const formattedValue = - value !== null && value !== undefined - ? value === Math.floor(value) - ? Math.floor(value) - : value - : ''; - - return ( - - {formattedValue} - - ); - }, + cell: (props) => , }, { id: 'name', @@ -840,73 +900,20 @@ const ChannelsTable = ({ onReady }) => { size: columnSizing.name || 200, minSize: 100, grow: true, - cell: ({ getValue }) => ( - - {getValue()} - - ), + cell: (props) => , }, { id: 'epg', header: 'EPG', accessorKey: 'epg_data_id', - cell: ({ getValue }) => { - const epgDataId = getValue(); - const epgObj = epgDataId ? tvgsById[epgDataId] : null; - const tvgName = epgObj?.name; - const tvgId = epgObj?.tvg_id; - const epgName = - epgObj && epgObj.epg_source - ? epgs[epgObj.epg_source]?.name || epgObj.epg_source - : null; - - const tooltip = epgObj - ? `${epgName ? `EPG Name: ${epgName}\n` : ''}${tvgName ? `TVG Name: ${tvgName}\n` : ''}${tvgId ? `TVG-ID: ${tvgId}` : ''}`.trim() - : ''; - - // If channel has an EPG assignment but tvgsById hasn't loaded yet, show loading - const isEpgDataPending = epgDataId && !epgObj && !tvgsLoaded; - - return ( - - {epgObj && epgName ? ( - {tooltip} - } - withArrow - position="top" - > - - {epgObj.epg_source} - {tvgId} - - - ) : epgObj ? ( - {epgObj.name} - ) : isEpgDataPending ? ( - - ) : ( - Not Assigned - )} - - ); - }, + cell: (props) => ( + + ), size: columnSizing.epg || 200, minSize: 80, }, @@ -916,16 +923,8 @@ const ChannelsTable = ({ onReady }) => { channelGroups[row.channel_group_id] ? channelGroups[row.channel_group_id].name : '', - cell: ({ getValue }) => ( - - {getValue()} - + cell: (props) => ( + ), size: columnSizing.channel_group || 175, minSize: 100, @@ -941,19 +940,21 @@ const ChannelsTable = ({ onReady }) => { maxSize: 120, enableResizing: false, header: '', - cell: ({ getValue }) => { - const logoId = getValue(); - - return ( -
- -
- ); - }, + cell: (props) => ( + { + // Ensure logos are loaded when user tries to edit + ensureLogosLoaded(); + }} + style={{ width: '100%', height: '100%' }} + > + + + ), }, { id: 'actions', @@ -1099,6 +1100,7 @@ const ChannelsTable = ({ onReady }) => { manualSorting: true, manualFiltering: true, enableRowSelection: true, + enableDragDrop: true, onRowSelectionChange: onRowSelectionChange, state: { pagination, @@ -1463,7 +1465,18 @@ const ChannelsTable = ({ onReady }) => { borderRadius: 'var(--mantine-radius-default)', }} > - + + row.id)} + strategy={verticalListSortingStrategy} + > + + + s.user); const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed); const suppressWarning = useWarningsStore((s) => s.suppressWarning); + const isUnlocked = useChannelsTableStore((s) => s.isUnlocked); + const setIsUnlocked = useChannelsTableStore((s) => s.setIsUnlocked); const headerPinned = table?.headerPinned ?? false; const setHeaderPinned = table?.setHeaderPinned || (() => {}); @@ -239,6 +244,10 @@ const ChannelTableHeader = ({ setHeaderPinned(!headerPinned); }; + const toggleUnlock = () => { + setIsUnlocked(!isUnlocked); + }; + return ( @@ -258,6 +267,23 @@ const ChannelTableHeader = ({ + + {isUnlocked && ( + + + Editing Mode + + )} + : + } + onClick={toggleUnlock} + disabled={authUser.user_level != USER_LEVELS.ADMIN} + > + + {isUnlocked ? 'Lock Table' : 'Unlock for Editing'} + + + { + const isUnlocked = useChannelsTableStore((s) => s.isUnlocked); + + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ + id: row.id, + disabled: !isUnlocked, + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + position: 'relative', + }; + + return ( +
+ {isUnlocked && ( + + + + )} +
+ {children} +
+
+ ); +}; diff --git a/frontend/src/components/tables/ChannelsTable/EditableCell.jsx b/frontend/src/components/tables/ChannelsTable/EditableCell.jsx new file mode 100644 index 00000000..065e4631 --- /dev/null +++ b/frontend/src/components/tables/ChannelsTable/EditableCell.jsx @@ -0,0 +1,781 @@ +import React, { + useState, + useCallback, + useEffect, + useRef, + useMemo, +} from 'react'; +import { + Box, + TextInput, + Select, + NumberInput, + Tooltip, + Center, + Skeleton, +} from '@mantine/core'; +import API from '../../../api'; +import useChannelsTableStore from '../../../store/channelsTable'; + +// Editable text cell +export const EditableTextCell = ({ row, column, getValue }) => { + const isUnlocked = useChannelsTableStore((s) => s.isUnlocked); + const initialValue = getValue() || ''; + const [value, setValue] = useState(initialValue); + const [isFocused, setIsFocused] = useState(false); + const previousValue = useRef(initialValue); + const isMounted = useRef(false); + const debounceTimer = useRef(null); + + useEffect(() => { + const currentValue = getValue() || ''; + if (!isFocused && currentValue !== previousValue.current) { + setValue(currentValue); + previousValue.current = currentValue; + } + }, [getValue, isFocused]); + + const saveValue = useCallback( + async (newValue) => { + // Don't save if not mounted, not unlocked, or value hasn't changed + if ( + !isMounted.current || + !isUnlocked || + newValue === previousValue.current + ) { + return; + } + + try { + const response = await API.updateChannel({ + id: row.original.id, + [column.id]: newValue || null, + }); + previousValue.current = newValue; + + // Update the table store to reflect the change + if (response) { + useChannelsTableStore.getState().updateChannel(response); + } + } catch (error) { + // Revert on error + setValue(previousValue.current || ''); + } + }, + [row.original.id, column.id, isUnlocked] + ); + + useEffect(() => { + isMounted.current = true; + const timer = debounceTimer.current; + return () => { + isMounted.current = false; + if (timer) { + clearTimeout(timer); + } + }; + }, []); + + const handleChange = (e) => { + if (!isUnlocked) return; + const newValue = e.currentTarget.value; + setValue(newValue); + + // Clear existing timer + if (debounceTimer.current) { + clearTimeout(debounceTimer.current); + } + + // Set new timer + debounceTimer.current = setTimeout(() => { + saveValue(newValue); + }, 500); + }; + + const handleBlur = () => { + setIsFocused(false); + if (isUnlocked) { + saveValue(value); + } + }; + + const handleClick = () => { + if (isUnlocked) { + setIsFocused(true); + } + }; + + if (!isUnlocked || !isFocused) { + return ( + + {value} + + ); + } + + return ( + + ); +}; + +// Editable number cell +export const EditableNumberCell = ({ row, column, getValue }) => { + const isUnlocked = useChannelsTableStore((s) => s.isUnlocked); + const initialValue = getValue(); + const [value, setValue] = useState(initialValue); + const [isFocused, setIsFocused] = useState(false); + const previousValue = useRef(initialValue); + const isMounted = useRef(false); + + useEffect(() => { + const currentValue = getValue(); + if (!isFocused && currentValue !== previousValue.current) { + setValue(currentValue); + previousValue.current = currentValue; + } + }, [getValue, isFocused]); + + const saveValue = useCallback( + async (newValue) => { + // Don't save if not mounted, not unlocked, or value hasn't changed + if ( + !isMounted.current || + !isUnlocked || + newValue === previousValue.current + ) { + return; + } + + // For channel_number, don't save null/undefined values + if ( + column.id === 'channel_number' && + (newValue === null || newValue === undefined || newValue === '') + ) { + // Revert to previous value + setValue(previousValue.current); + return; + } + + try { + const response = await API.updateChannel({ + id: row.original.id, + [column.id]: newValue, + }); + previousValue.current = newValue; + + // Update the table store to reflect the change + if (response) { + useChannelsTableStore.getState().updateChannel(response); + + // If channel_number was changed, refetch to reorder the table + if (column.id === 'channel_number') { + await API.requeryChannels(); + // Exit edit mode after resorting to avoid confusion + setIsFocused(false); + } + } + } catch (error) { + // Revert on error + setValue(previousValue.current); + } + }, + [row.original.id, column.id, isUnlocked] + ); + + useEffect(() => { + isMounted.current = true; + return () => { + isMounted.current = false; + }; + }, []); + + const handleChange = (newValue) => { + if (!isUnlocked) return; + setValue(newValue); + }; + + const handleBlur = () => { + setIsFocused(false); + if (isUnlocked) { + saveValue(value); + } + }; + + const handleClick = () => { + if (isUnlocked) { + setIsFocused(true); + } + }; + + const formattedValue = + value !== null && value !== undefined + ? value === Math.floor(value) + ? Math.floor(value) + : value + : ''; + + if (!isUnlocked || !isFocused) { + return ( + + {formattedValue} + + ); + } + + return ( + + ); +}; + +// Editable select cell for groups +export const EditableGroupCell = ({ row, getValue, channelGroups }) => { + const isUnlocked = useChannelsTableStore((s) => s.isUnlocked); + const groupId = row.original.channel_group_id; + const groupName = channelGroups[groupId]?.name || ''; + const previousGroupId = useRef(groupId); + const [isFocused, setIsFocused] = useState(false); + const [searchValue, setSearchValue] = useState(''); + + const saveValue = useCallback( + async (newGroupId) => { + // Don't save if not unlocked or value hasn't changed + if ( + !isUnlocked || + String(newGroupId) === String(previousGroupId.current) + ) { + return; + } + + try { + const response = await API.updateChannel({ + id: row.original.id, + channel_group_id: parseInt(newGroupId, 10), + }); + previousGroupId.current = newGroupId; + + // Update the table store to reflect the change + if (response) { + useChannelsTableStore.getState().updateChannel(response); + } + } catch (error) { + console.error('Failed to update channel group:', error); + } + }, + [row.original.id, isUnlocked] + ); + + const handleClick = () => { + if (isUnlocked) { + setIsFocused(true); + } + }; + + const handleChange = (newGroupId) => { + saveValue(newGroupId); + setIsFocused(false); + setSearchValue(''); + }; + + const groupOptions = Object.values(channelGroups).map((group) => ({ + value: String(group.id), + label: group.name, + })); + + if (!isUnlocked || !isFocused) { + return ( + + {groupName} + + ); + } + + return ( + setIsFocused(false)} + data={epgOptions} + size="xs" + variant="unstyled" + searchable + searchValue={searchValue} + onSearchChange={setSearchValue} + autoFocus + placeholder={displayText} + nothingFoundMessage="No EPG found" + styles={{ + input: { + minHeight: 'unset', + height: '100%', + padding: '0 4px', + backgroundColor: 'rgba(255, 255, 255, 0.1)', + }, + }} + /> + ); +}; + +// Editable cell for Logo selection +export const EditableLogoCell = ({ row, getValue, channelLogos, LazyLogo }) => { + const isUnlocked = useChannelsTableStore((s) => s.isUnlocked); + const logoId = getValue(); + const previousLogoId = useRef(logoId); + const [isFocused, setIsFocused] = useState(false); + const [searchValue, setSearchValue] = useState(''); + + const saveValue = useCallback( + async (newLogoId) => { + // Don't save if not unlocked or value hasn't changed + if (!isUnlocked || String(newLogoId) === String(previousLogoId.current)) { + return; + } + + try { + const response = await API.updateChannel({ + id: row.original.id, + logo_id: newLogoId === 'null' ? null : parseInt(newLogoId, 10), + }); + previousLogoId.current = newLogoId; + + // Update the table store to reflect the change + if (response) { + useChannelsTableStore.getState().updateChannel(response); + } + } catch (error) { + console.error('Failed to update logo:', error); + } + }, + [row.original.id, isUnlocked] + ); + + const handleClick = () => { + if (isUnlocked) { + setSearchValue(''); + setIsFocused(true); + } + }; + + const handleChange = (newLogoId) => { + saveValue(newLogoId); + setSearchValue(''); + setIsFocused(false); + }; + + // Build logo options with logo data + const logoOptions = useMemo(() => { + const options = [ + { + value: 'null', + label: 'Default', + logo: null, + }, + ]; + + // Convert channelLogos object to array and sort by name + const logosArray = Object.values(channelLogos); + logosArray.sort((a, b) => (a.name || '').localeCompare(b.name || '')); + + logosArray.forEach((logo) => { + options.push({ + value: String(logo.id), + label: logo.name || `Logo ${logo.id}`, + logo: logo, + }); + }); + + return options; + }, [channelLogos]); + + // Get display text for the current logo + const displayText = + logoId && channelLogos[logoId] ? channelLogos[logoId].name : 'Default'; + + // Custom option renderer to show logo images + const renderOption = ({ option }) => { + if (option.value === 'null') { + return
Default
; + } + + return ( + + {option.label} { + e.target.style.display = 'none'; + }} + /> + {option.label} + + ); + }; + + if (!isUnlocked || !isFocused) { + // When not editing, show the logo image + return ( + + {LazyLogo && ( + + )} + + ); + } + + return ( + +