diff --git a/CHANGELOG.md b/CHANGELOG.md
index bd73931a..388f2156 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
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 (
+
);
diff --git a/frontend/src/components/tables/CustomTable/CustomTableBody.jsx b/frontend/src/components/tables/CustomTable/CustomTableBody.jsx
index 9e43c57a..d579a41a 100644
--- a/frontend/src/components/tables/CustomTable/CustomTableBody.jsx
+++ b/frontend/src/components/tables/CustomTable/CustomTableBody.jsx
@@ -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 (
-
+
{expandedRowIds.includes(row.original.id) && renderExpandedRow(row)}
-
+
);
};
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 (
+
+ {shouldEnableDrag && (
+
+
+
+ )}
+
+ {children}
+
+
+ );
+};
+
export default CustomTableBody;
diff --git a/frontend/src/components/tables/CustomTable/CustomTableHeader.jsx b/frontend/src/components/tables/CustomTable/CustomTableHeader.jsx
index 0adc516e..f4b47171 100644
--- a/frontend/src/components/tables/CustomTable/CustomTableHeader.jsx
+++ b/frontend/src/components/tables/CustomTable/CustomTableHeader.jsx
@@ -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) => {
diff --git a/frontend/src/store/channelsTable.jsx b/frontend/src/store/channelsTable.jsx
index 75e14c63..0e922456 100644
--- a/frontend/src/store/channelsTable.jsx
+++ b/frontend/src/store/channelsTable.jsx
@@ -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;