Merge pull request #886 from Dispatcharr/dynamic-tables

Editable Channel Table
This commit is contained in:
SergeantPanda 2026-01-23 16:55:40 -06:00 committed by GitHub
commit 037b6e62e3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1186 additions and 109 deletions

View file

@ -16,6 +16,12 @@ 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 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. (Closes #333)
- Group column uses a searchable dropdown for quick group assignment, matching the UX of EPG and logo selectors.
- 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

View file

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

View file

@ -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(

View file

@ -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 (
<Flex justify="flex-end" style={{ width: '100%' }}>
{formattedValue}
</Flex>
);
},
cell: (props) => <EditableNumberCell {...props} />,
},
{
id: 'name',
@ -840,73 +900,20 @@ const ChannelsTable = ({ onReady }) => {
size: columnSizing.name || 200,
minSize: 100,
grow: true,
cell: ({ getValue }) => (
<Box
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{getValue()}
</Box>
),
cell: (props) => <EditableTextCell {...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 (
<Box
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{epgObj && epgName ? (
<Tooltip
label={
<span style={{ whiteSpace: 'pre-line' }}>{tooltip}</span>
}
withArrow
position="top"
>
<span>
{epgObj.epg_source} - {tvgId}
</span>
</Tooltip>
) : epgObj ? (
<span>{epgObj.name}</span>
) : isEpgDataPending ? (
<Skeleton
height={14}
width={(columnSizing.epg || 200) * 0.7}
style={{ borderRadius: 4 }}
/>
) : (
<span style={{ color: '#888' }}>Not Assigned</span>
)}
</Box>
);
},
cell: (props) => (
<EditableEPGCell
{...props}
tvgsById={tvgsById}
epgs={epgs}
tvgsLoaded={tvgsLoaded}
/>
),
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 }) => (
<Box
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{getValue()}
</Box>
cell: (props) => (
<EditableGroupCell {...props} channelGroups={channelGroups} />
),
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 (
<Center style={{ width: '100%' }}>
<LazyLogo
logoId={logoId}
alt="logo"
style={{ maxHeight: 18, maxWidth: 55 }}
/>
</Center>
);
},
cell: (props) => (
<Box
onClick={() => {
// Ensure logos are loaded when user tries to edit
ensureLogosLoaded();
}}
style={{ width: '100%', height: '100%' }}
>
<EditableLogoCell
{...props}
channelLogos={channelLogos}
LazyLogo={LazyLogo}
/>
</Box>
),
},
{
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)',
}}
>
<CustomTable table={table} />
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={rows.map((row) => row.id)}
strategy={verticalListSortingStrategy}
>
<CustomTable table={table} />
</SortableContext>
</DndContext>
</Box>
<Box

View file

@ -30,10 +30,13 @@ import {
SquareCheck,
Pin,
PinOff,
Lock,
LockOpen,
} from 'lucide-react';
import API from '../../../api';
import { notifications } from '@mantine/notifications';
import useChannelsStore from '../../../store/channels';
import useChannelsTableStore from '../../../store/channelsTable';
import useAuthStore from '../../../store/auth';
import { USER_LEVELS } from '../../../constants';
import AssignChannelNumbersForm from '../../forms/AssignChannelNumbers';
@ -134,6 +137,8 @@ const ChannelTableHeader = ({
const authUser = useAuthStore((s) => 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 (
<Group justify="space-between">
<Group gap={5} style={{ paddingLeft: 10 }}>
@ -258,6 +267,23 @@ const ChannelTableHeader = ({
<Tooltip label="Create Profile">
<CreateProfilePopover />
</Tooltip>
{isUnlocked && (
<Text
size="xs"
c="yellow.5"
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
paddingLeft: 10,
fontWeight: 500,
}}
>
<LockOpen size={14} />
Editing Mode
</Text>
)}
</Group>
<Box
@ -367,6 +393,18 @@ const ChannelTableHeader = ({
</Text>
</Menu.Item>
<Menu.Item
leftSection={
isUnlocked ? <LockOpen size={18} /> : <Lock size={18} />
}
onClick={toggleUnlock}
disabled={authUser.user_level != USER_LEVELS.ADMIN}
>
<Text size="xs">
{isUnlocked ? 'Lock Table' : 'Unlock for Editing'}
</Text>
</Menu.Item>
<Menu.Divider />
<Menu.Item

View file

@ -0,0 +1,59 @@
import React from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { GripVertical } from 'lucide-react';
import { Box } from '@mantine/core';
import useChannelsTableStore from '../../../store/channelsTable';
export const DraggableRow = ({ row, children }) => {
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 (
<div ref={setNodeRef} style={style} className="tr">
{isUnlocked && (
<Box
{...attributes}
{...listeners}
style={{
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: 24,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: isDragging ? 'grabbing' : 'grab',
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderRight: '1px solid rgba(255, 255, 255, 0.1)',
zIndex: 1,
}}
>
<GripVertical size={16} opacity={0.5} />
</Box>
)}
<div style={{ paddingLeft: isUnlocked ? 28 : 0, width: '100%' }}>
{children}
</div>
</div>
);
};

View file

@ -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 (
<Box
onClick={handleClick}
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
cursor: isUnlocked ? 'text' : 'default',
padding: '0 4px',
...(isUnlocked && {
'&:hover': {
backgroundColor: 'rgba(255, 255, 255, 0.05)',
},
}),
}}
>
{value}
</Box>
);
}
return (
<TextInput
value={value}
onChange={handleChange}
onBlur={handleBlur}
autoFocus
size="xs"
variant="unstyled"
styles={{
root: {
width: '100%',
},
input: {
minHeight: 'unset',
height: '100%',
width: '100%',
padding: '0 4px',
backgroundColor: 'rgba(255, 255, 255, 0.1)',
},
}}
/>
);
};
// 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 (
<Box
onClick={handleClick}
style={{
textAlign: 'right',
width: '100%',
cursor: isUnlocked ? 'text' : 'default',
padding: '0 4px',
...(isUnlocked && {
'&:hover': {
backgroundColor: 'rgba(255, 255, 255, 0.05)',
},
}),
}}
>
{formattedValue}
</Box>
);
}
return (
<NumberInput
value={value}
onChange={handleChange}
onBlur={handleBlur}
autoFocus
size="xs"
variant="unstyled"
hideControls
styles={{
input: {
minHeight: 'unset',
height: '100%',
padding: '0 4px',
textAlign: 'right',
backgroundColor: 'rgba(255, 255, 255, 0.1)',
},
}}
/>
);
};
// 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 (
<Box
onClick={handleClick}
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
cursor: isUnlocked ? 'pointer' : 'default',
padding: '0 4px',
...(isUnlocked && {
'&:hover': {
backgroundColor: 'rgba(255, 255, 255, 0.05)',
},
}),
}}
>
{groupName}
</Box>
);
}
return (
<Select
value={null}
onChange={handleChange}
onBlur={() => setIsFocused(false)}
data={groupOptions}
size="xs"
variant="unstyled"
searchable
searchValue={searchValue}
onSearchChange={setSearchValue}
autoFocus
placeholder={groupName}
nothingFoundMessage="No groups found"
styles={{
input: {
minHeight: 'unset',
height: '100%',
padding: '0 4px',
backgroundColor: 'rgba(255, 255, 255, 0.1)',
},
}}
/>
);
};
// Editable select cell for EPG
export const EditableEPGCell = ({
row,
getValue,
tvgsById,
epgs,
tvgsLoaded,
}) => {
const isUnlocked = useChannelsTableStore((s) => s.isUnlocked);
const epgDataId = getValue();
const previousEpgDataId = useRef(epgDataId);
const [isFocused, setIsFocused] = useState(false);
const [searchValue, setSearchValue] = useState('');
// Format display text
const epgObj = epgDataId ? tvgsById[epgDataId] : null;
const tvgId = epgObj?.tvg_id;
const epgName =
epgObj && epgObj.epg_source
? epgs[epgObj.epg_source]?.name || epgObj.epg_source
: null;
const displayText =
epgObj && epgName
? `${epgObj.epg_source} - ${tvgId}`
: epgObj
? epgObj.name
: 'Not Assigned';
// Show skeleton while EPG data is loading (only if channel has an EPG assignment)
const isEpgDataPending = epgDataId && !epgObj && !tvgsLoaded;
const saveValue = useCallback(
async (newEpgDataId) => {
// Don't save if not unlocked or value hasn't changed
if (
!isUnlocked ||
String(newEpgDataId) === String(previousEpgDataId.current)
) {
return;
}
try {
const response = await API.updateChannel({
id: row.original.id,
epg_data_id:
newEpgDataId === 'null' ? null : parseInt(newEpgDataId, 10),
});
previousEpgDataId.current = newEpgDataId;
// Update the table store to reflect the change
if (response) {
useChannelsTableStore.getState().updateChannel(response);
}
} catch (error) {
console.error('Failed to update EPG:', error);
}
},
[row.original.id, isUnlocked]
);
const handleClick = () => {
if (isUnlocked) {
setSearchValue(''); // Start with empty search
setIsFocused(true);
}
};
const handleChange = (newEpgDataId) => {
saveValue(newEpgDataId);
setSearchValue('');
setIsFocused(false);
};
// Build EPG options
const epgOptions = useMemo(() => {
const options = [{ value: 'null', label: 'Not Assigned' }];
// Convert tvgsById to an array and sort by EPG source name, then by tvg_id
const tvgsArray = Object.values(tvgsById);
tvgsArray.sort((a, b) => {
const aEpgName =
a.epg_source && epgs[a.epg_source]
? epgs[a.epg_source].name
: a.epg_source || '';
const bEpgName =
b.epg_source && epgs[b.epg_source]
? epgs[b.epg_source].name
: b.epg_source || '';
const epgCompare = aEpgName.localeCompare(bEpgName);
if (epgCompare !== 0) return epgCompare;
// Secondary sort by tvg_id
return (a.tvg_id || '').localeCompare(b.tvg_id || '');
});
tvgsArray.forEach((tvg) => {
const epgSourceName =
tvg.epg_source && epgs[tvg.epg_source]
? epgs[tvg.epg_source].name
: tvg.epg_source;
const tvgName = tvg.name;
// Create a comprehensive label: "EPG Name | TVG-ID | TVG Name"
let label;
if (epgSourceName && tvg.tvg_id) {
label = `${epgSourceName} | ${tvg.tvg_id}`;
if (tvgName && tvgName !== tvg.tvg_id) {
label += ` | ${tvgName}`;
}
} else if (tvgName) {
label = tvgName;
} else {
label = `ID: ${tvg.id}`;
}
options.push({
value: String(tvg.id),
label: label,
});
});
return options;
}, [tvgsById, epgs]);
// Build tooltip content
const tooltip = epgObj
? `${epgName ? `EPG Name: ${epgName}\n` : ''}${epgObj.name ? `TVG Name: ${epgObj.name}\n` : ''}${tvgId ? `TVG-ID: ${tvgId}` : ''}`.trim()
: '';
if (!isUnlocked || !isFocused) {
// If loading EPG data, show skeleton
if (isEpgDataPending) {
return (
<Box
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
padding: '0 4px',
}}
>
<Skeleton
height={18}
width="70%"
visible={true}
animate={true}
style={{ borderRadius: 4 }}
/>
</Box>
);
}
// Otherwise, show the normal EPG assignment cell
return (
<Tooltip
label={<span style={{ whiteSpace: 'pre-line' }}>{tooltip}</span>}
withArrow
position="top"
disabled={!epgObj}
openDelay={500}
>
<Box
onClick={handleClick}
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
cursor: isUnlocked ? 'pointer' : 'default',
padding: '0 4px',
...(isUnlocked && {
'&:hover': {
backgroundColor: 'rgba(255, 255, 255, 0.05)',
},
}),
}}
>
{displayText}
</Box>
</Tooltip>
);
}
return (
<Select
value={null}
onChange={handleChange}
onBlur={() => 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 <div style={{ padding: '8px 12px' }}>Default</div>;
}
return (
<Box
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '8px 12px',
minHeight: '50px',
}}
>
<img
src={option.logo?.cache_url}
alt={option.label}
style={{
height: '40px',
maxWidth: '100px',
objectFit: 'contain',
}}
onError={(e) => {
e.target.style.display = 'none';
}}
/>
<span style={{ fontSize: '13px' }}>{option.label}</span>
</Box>
);
};
if (!isUnlocked || !isFocused) {
// When not editing, show the logo image
return (
<Box
onClick={handleClick}
style={{
cursor: isUnlocked ? 'pointer' : 'default',
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
...(isUnlocked && {
'&:hover': {
backgroundColor: 'rgba(255, 255, 255, 0.05)',
},
}),
}}
>
{LazyLogo && (
<LazyLogo
logoId={logoId}
alt="logo"
style={{ maxHeight: 18, maxWidth: 55 }}
/>
)}
</Box>
);
}
return (
<Box
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
}}
>
<Select
value={null}
onChange={handleChange}
onBlur={() => setIsFocused(false)}
data={logoOptions}
size="xs"
variant="unstyled"
searchable
searchValue={searchValue}
onSearchChange={setSearchValue}
autoFocus
placeholder={displayText}
nothingFoundMessage="No logos found"
renderOption={renderOption}
maxDropdownHeight={400}
comboboxProps={{ width: 250, position: 'bottom-start' }}
styles={{
input: {
minHeight: 'unset',
height: '100%',
padding: '0 4px',
backgroundColor: 'rgba(255, 255, 255, 0.1)',
},
option: {
padding: 0,
},
dropdown: {
minWidth: '250px',
},
}}
/>
</Box>
);
};

View file

@ -1,8 +1,6 @@
import { Box, Flex } from '@mantine/core';
import CustomTableHeader from './CustomTableHeader';
import { useCallback, useState, useRef, useMemo } from 'react';
import { flexRender } from '@tanstack/react-table';
import table from '../../../helpers/table';
import CustomTableBody from './CustomTableBody';
const CustomTable = ({ table }) => {
@ -46,6 +44,7 @@ const CustomTable = ({ table }) => {
selectedTableIds={table.selectedTableIds}
tableCellProps={table.tableCellProps}
headerPinned={table.headerPinned}
enableDragDrop={table.enableDragDrop}
/>
<CustomTableBody
getRowModel={table.getRowModel}
@ -54,9 +53,10 @@ const CustomTable = ({ table }) => {
expandedRowRenderer={table.expandedRowRenderer}
renderBodyCell={table.renderBodyCell}
getExpandedRowHeight={table.getExpandedRowHeight}
getRowStyles={table.getRowStyles} // Pass the getRowStyles function
getRowStyles={table.getRowStyles}
tableBodyProps={table.tableBodyProps}
tableCellProps={table.tableCellProps}
enableDragDrop={table.enableDragDrop}
/>
</Box>
);

View file

@ -3,6 +3,10 @@ import { VariableSizeList as List } from 'react-window';
import AutoSizer from 'react-virtualized-auto-sizer';
import { useMemo } from 'react';
import table from '../../../helpers/table';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { GripVertical } from 'lucide-react';
import useChannelsTableStore from '../../../store/channelsTable';
const CustomTableBody = ({
getRowModel,
@ -10,9 +14,10 @@ const CustomTableBody = ({
expandedRowRenderer,
renderBodyCell,
getExpandedRowHeight,
getRowStyles, // Add this prop to receive row styles
getRowStyles,
tableBodyProps,
tableCellProps,
enableDragDrop = false,
}) => {
const renderExpandedRow = (row) => {
if (expandedRowRenderer) {
@ -101,7 +106,12 @@ const CustomTableBody = ({
delete customRowStyles.className; // Remove from object so it doesn't get applied as inline style
return (
<Box style={style} key={`row-${row.id}`}>
<DraggableRowWrapper
row={row}
key={`row-${row.id}`}
style={style}
enableDragDrop={enableDragDrop}
>
<Box
key={`tr-${row.id}`}
className={`tr ${index % 2 == 0 ? 'tr-even' : 'tr-odd'} ${customClassName}`}
@ -145,11 +155,72 @@ const CustomTableBody = ({
})}
</Box>
{expandedRowIds.includes(row.original.id) && renderExpandedRow(row)}
</Box>
</DraggableRowWrapper>
);
};
return renderTableBodyContents();
};
const DraggableRowWrapper = ({
row,
children,
style = {},
enableDragDrop = false,
}) => {
const isUnlocked = useChannelsTableStore((s) => s.isUnlocked);
const shouldEnableDrag = enableDragDrop && isUnlocked;
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: row.id,
disabled: !shouldEnableDrag,
});
const dragStyle = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
position: 'relative',
...style,
};
return (
<Box ref={setNodeRef} style={dragStyle}>
{shouldEnableDrag && (
<Box
{...attributes}
{...listeners}
style={{
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: 24,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: isDragging ? 'grabbing' : 'grab',
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderRight: '1px solid rgba(255, 255, 255, 0.1)',
borderBottom: '1px solid rgba(255, 255, 255, 0.05)',
zIndex: 1,
}}
>
<GripVertical size={16} opacity={0.5} />
</Box>
)}
<div style={{ paddingLeft: shouldEnableDrag ? 28 : 0, width: '100%' }}>
{children}
</div>
</Box>
);
};
export default CustomTableBody;

View file

@ -2,6 +2,7 @@ import { Box, Center, Checkbox, Flex } from '@mantine/core';
import { flexRender } from '@tanstack/react-table';
import { useCallback, useMemo } from 'react';
import MultiSelectHeaderWrapper from './MultiSelectHeaderWrapper';
import useChannelsTableStore from '../../../store/channelsTable';
const CustomTableHeader = ({
getHeaderGroups,
@ -11,7 +12,10 @@ const CustomTableHeader = ({
onSelectAllChange,
tableCellProps,
headerPinned = true,
enableDragDrop = false,
}) => {
const isUnlocked = useChannelsTableStore((s) => s.isUnlocked);
const shouldEnableDrag = enableDragDrop && isUnlocked;
const renderHeaderCell = (header) => {
let content;
@ -95,6 +99,7 @@ const CustomTableHeader = ({
display: 'flex',
width: '100%',
minWidth: '100%', // Force full width
paddingLeft: shouldEnableDrag ? 28 : 0,
}}
>
{headerGroup.headers.map((header) => {

View file

@ -15,6 +15,7 @@ const useChannelsTableStore = create((set, get) => ({
},
selectedChannelIds: [],
allQueryIds: [],
isUnlocked: false,
queryChannels: ({ results, count }, params) => {
set((state) => {
@ -54,6 +55,18 @@ const useChannelsTableStore = create((set, get) => ({
sorting,
}));
},
setIsUnlocked: (isUnlocked) => {
set({ isUnlocked });
},
updateChannel: (updatedChannel) => {
set((state) => ({
channels: state.channels.map((channel) =>
channel.id === updatedChannel.id ? updatedChannel : channel
),
}));
},
}));
export default useChannelsTableStore;