mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 10:45:27 +00:00
more table bug fixes, query optimizations, re-added channel expansion stream table with reworked drag-and-drop
This commit is contained in:
parent
8d1bfcb975
commit
ccdb8ab00d
21 changed files with 1054 additions and 355 deletions
|
|
@ -127,6 +127,13 @@ class ChannelPagination(PageNumberPagination):
|
|||
page_size_query_param = 'page_size' # Allow clients to specify page size
|
||||
max_page_size = 10000 # Prevent excessive page sizes
|
||||
|
||||
|
||||
def paginate_queryset(self, queryset, request, view=None):
|
||||
if not request.query_params.get(self.page_query_param):
|
||||
return None # disables pagination, returns full queryset
|
||||
|
||||
return super().paginate_queryset(queryset, request, view)
|
||||
|
||||
class ChannelFilter(django_filters.FilterSet):
|
||||
name = django_filters.CharFilter(lookup_expr='icontains')
|
||||
channel_group_name = OrInFilter(field_name="channel_group__name", lookup_expr="icontains")
|
||||
|
|
@ -148,7 +155,12 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
|||
ordering = ['-channel_number']
|
||||
|
||||
def get_queryset(self):
|
||||
qs = super().get_queryset()
|
||||
qs = super().get_queryset().select_related(
|
||||
'channel_group',
|
||||
'logo',
|
||||
'epg_data',
|
||||
'stream_profile',
|
||||
).prefetch_related('streams')
|
||||
|
||||
channel_group = self.request.query_params.get('channel_group')
|
||||
if channel_group:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
# Generated by Django 5.1.6 on 2025-04-18 16:21
|
||||
|
||||
from django.db import migrations, models
|
||||
from django.db.models import Count
|
||||
|
||||
def remove_duplicate_channel_streams(apps, schema_editor):
|
||||
ChannelStream = apps.get_model('dispatcharr_channels', 'ChannelStream')
|
||||
# Find duplicates by (channel, stream)
|
||||
duplicates = (
|
||||
ChannelStream.objects
|
||||
.values('channel', 'stream')
|
||||
.annotate(count=Count('id'))
|
||||
.filter(count__gt=1)
|
||||
)
|
||||
|
||||
for dupe in duplicates:
|
||||
# Get all duplicates for this pair
|
||||
dups = ChannelStream.objects.filter(
|
||||
channel=dupe['channel'],
|
||||
stream=dupe['stream']
|
||||
).order_by('id')
|
||||
|
||||
# Keep the first one, delete the rest
|
||||
dups.exclude(id=dups.first().id).delete()
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dispatcharr_channels', '0015_recording_custom_properties'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(remove_duplicate_channel_streams),
|
||||
migrations.AddConstraint(
|
||||
model_name='channelstream',
|
||||
constraint=models.UniqueConstraint(fields=('channel', 'stream'), name='unique_channel_stream'),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
# Generated by Django 5.1.6 on 2025-04-19 12:08
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dispatcharr_channels', '0016_channelstream_unique_channel_stream'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='channel',
|
||||
name='channel_number',
|
||||
field=models.IntegerField(db_index=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='channelgroup',
|
||||
name='name',
|
||||
field=models.CharField(db_index=True, max_length=100, unique=True),
|
||||
),
|
||||
]
|
||||
|
|
@ -27,7 +27,7 @@ def get_total_viewers(channel_id):
|
|||
return 0
|
||||
|
||||
class ChannelGroup(models.Model):
|
||||
name = models.CharField(max_length=100, unique=True)
|
||||
name = models.CharField(max_length=100, unique=True, db_index=True)
|
||||
|
||||
def related_channels(self):
|
||||
# local import if needed to avoid cyc. Usually fine in a single file though
|
||||
|
|
@ -210,7 +210,7 @@ class ChannelManager(models.Manager):
|
|||
|
||||
|
||||
class Channel(models.Model):
|
||||
channel_number = models.IntegerField()
|
||||
channel_number = models.IntegerField(db_index=True)
|
||||
name = models.CharField(max_length=255)
|
||||
logo = models.ForeignKey(
|
||||
'Logo',
|
||||
|
|
@ -426,6 +426,9 @@ class ChannelStream(models.Model):
|
|||
|
||||
class Meta:
|
||||
ordering = ['order'] # Ensure streams are retrieved in order
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['channel', 'stream'], name='unique_channel_stream')
|
||||
]
|
||||
|
||||
class ChannelGroupM3UAccount(models.Model):
|
||||
channel_group = models.ForeignKey(
|
||||
|
|
|
|||
|
|
@ -115,18 +115,14 @@ class BulkChannelProfileMembershipSerializer(serializers.Serializer):
|
|||
class ChannelSerializer(serializers.ModelSerializer):
|
||||
# Show nested group data, or ID
|
||||
channel_number = serializers.IntegerField(allow_null=True, required=False)
|
||||
channel_group = ChannelGroupSerializer(read_only=True)
|
||||
channel_group_id = serializers.PrimaryKeyRelatedField(
|
||||
queryset=ChannelGroup.objects.all(),
|
||||
source="channel_group",
|
||||
write_only=True,
|
||||
required=False
|
||||
)
|
||||
epg_data = EPGDataSerializer(read_only=True)
|
||||
epg_data_id = serializers.PrimaryKeyRelatedField(
|
||||
queryset=EPGData.objects.all(),
|
||||
source="epg_data",
|
||||
write_only=True,
|
||||
required=False,
|
||||
allow_null=True,
|
||||
)
|
||||
|
|
@ -143,13 +139,11 @@ class ChannelSerializer(serializers.ModelSerializer):
|
|||
queryset=Stream.objects.all(), many=True, write_only=True, required=False
|
||||
)
|
||||
|
||||
logo = LogoSerializer(read_only=True)
|
||||
logo_id = serializers.PrimaryKeyRelatedField(
|
||||
queryset=Logo.objects.all(),
|
||||
source='logo',
|
||||
allow_null=True,
|
||||
required=False,
|
||||
write_only=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
|
@ -158,16 +152,13 @@ class ChannelSerializer(serializers.ModelSerializer):
|
|||
'id',
|
||||
'channel_number',
|
||||
'name',
|
||||
'channel_group',
|
||||
'channel_group_id',
|
||||
'tvg_id',
|
||||
'epg_data',
|
||||
'epg_data_id',
|
||||
'streams',
|
||||
'stream_ids',
|
||||
'stream_profile_id',
|
||||
'uuid',
|
||||
'logo',
|
||||
'logo_id',
|
||||
]
|
||||
|
||||
|
|
@ -194,34 +185,53 @@ class ChannelSerializer(serializers.ModelSerializer):
|
|||
ChannelStream.objects.create(channel=channel, stream_id=stream_id, order=index)
|
||||
|
||||
return channel
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
stream_ids = validated_data.pop('stream_ids', None)
|
||||
|
||||
# Update all fields from validated_data
|
||||
# Update standard fields
|
||||
for attr, value in validated_data.items():
|
||||
setattr(instance, attr, value)
|
||||
|
||||
instance.save()
|
||||
|
||||
# Handle streams if provided
|
||||
if stream_ids is not None:
|
||||
# Clear existing associations
|
||||
instance.channelstream_set.all().delete()
|
||||
# Normalize stream IDs
|
||||
normalized_ids = [
|
||||
stream.id if hasattr(stream, "id") else stream
|
||||
for stream in stream_ids
|
||||
]
|
||||
|
||||
# Create new associations with proper ordering
|
||||
for index, stream in enumerate(stream_ids):
|
||||
# Extract the ID from the Stream object
|
||||
actual_stream_id = stream.id if hasattr(stream, "id") else stream
|
||||
print(f'Setting stream {actual_stream_id} to index {index}')
|
||||
ChannelStream.objects.create(
|
||||
channel=instance,
|
||||
stream_id=actual_stream_id,
|
||||
order=index
|
||||
)
|
||||
# Get current mapping of stream_id -> ChannelStream
|
||||
current_links = {
|
||||
cs.stream_id: cs for cs in instance.channelstream_set.all()
|
||||
}
|
||||
|
||||
# Track existing stream IDs
|
||||
existing_ids = set(current_links.keys())
|
||||
new_ids = set(normalized_ids)
|
||||
|
||||
# Delete any links not in the new list
|
||||
to_remove = existing_ids - new_ids
|
||||
if to_remove:
|
||||
instance.channelstream_set.filter(stream_id__in=to_remove).delete()
|
||||
|
||||
# Update or create with new order
|
||||
for order, stream_id in enumerate(normalized_ids):
|
||||
if stream_id in current_links:
|
||||
cs = current_links[stream_id]
|
||||
if cs.order != order:
|
||||
cs.order = order
|
||||
cs.save(update_fields=["order"])
|
||||
else:
|
||||
ChannelStream.objects.create(
|
||||
channel=instance,
|
||||
stream_id=stream_id,
|
||||
order=order
|
||||
)
|
||||
|
||||
return instance
|
||||
|
||||
|
||||
def validate_stream_profile(self, value):
|
||||
"""Handle special case where empty/0 values mean 'use default' (null)"""
|
||||
if value == '0' or value == 0 or value == '' or value is None:
|
||||
|
|
|
|||
71
frontend/package-lock.json
generated
71
frontend/package-lock.json
generated
|
|
@ -8,6 +8,10 @@
|
|||
"name": "vite",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@mantine/charts": "^7.17.2",
|
||||
"@mantine/core": "^7.17.2",
|
||||
"@mantine/dates": "^7.17.2",
|
||||
|
|
@ -193,6 +197,73 @@
|
|||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/modifiers": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz",
|
||||
"integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^6.3.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/sortable": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^6.3.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emotion/babel-plugin": {
|
||||
"version": "11.13.5",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@
|
|||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@mantine/charts": "^7.17.2",
|
||||
"@mantine/core": "^7.17.2",
|
||||
"@mantine/dates": "^7.17.2",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import useStreamsStore from './store/streams';
|
|||
import useStreamProfilesStore from './store/streamProfiles';
|
||||
import useSettingsStore from './store/settings';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import useChannelsTableStore from './store/channelsTable';
|
||||
|
||||
// If needed, you can set a base host or keep it empty if relative requests
|
||||
const host = import.meta.env.DEV
|
||||
|
|
@ -91,6 +92,8 @@ const request = async (url, options = {}) => {
|
|||
};
|
||||
|
||||
export default class API {
|
||||
static lastQueryParams = new URLSearchParams();
|
||||
|
||||
/**
|
||||
* A static method so we can do: await API.getAuthToken()
|
||||
*/
|
||||
|
|
@ -172,10 +175,30 @@ export default class API {
|
|||
|
||||
static async queryChannels(params) {
|
||||
try {
|
||||
API.lastQueryParams = params;
|
||||
|
||||
const response = await request(
|
||||
`${host}/api/channels/channels/?${params.toString()}`
|
||||
);
|
||||
|
||||
useChannelsTableStore.getState().queryChannels(response, params);
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to fetch channels', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async requeryChannels() {
|
||||
try {
|
||||
const response = await request(
|
||||
`${host}/api/channels/channels/?${API.lastQueryParams.toString()}`
|
||||
);
|
||||
|
||||
useChannelsTableStore
|
||||
.getState()
|
||||
.queryChannels(response, API.lastQueryParams);
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to fetch channels', e);
|
||||
|
|
@ -258,6 +281,8 @@ export default class API {
|
|||
body: body,
|
||||
});
|
||||
|
||||
API.getLogos();
|
||||
|
||||
if (response.id) {
|
||||
useChannelsStore.getState().addChannel(response);
|
||||
}
|
||||
|
|
@ -300,7 +325,10 @@ export default class API {
|
|||
const payload = { ...values };
|
||||
|
||||
// Handle special values
|
||||
if (payload.stream_profile_id === '0' || payload.stream_profile_id === 0) {
|
||||
if (
|
||||
payload.stream_profile_id === '0' ||
|
||||
payload.stream_profile_id === 0
|
||||
) {
|
||||
payload.stream_profile_id = null;
|
||||
}
|
||||
|
||||
|
|
@ -312,15 +340,21 @@ export default class API {
|
|||
// Handle channel_number properly
|
||||
if (payload.channel_number === '') {
|
||||
payload.channel_number = null;
|
||||
} else if (payload.channel_number !== null && payload.channel_number !== undefined) {
|
||||
} else if (
|
||||
payload.channel_number !== null &&
|
||||
payload.channel_number !== undefined
|
||||
) {
|
||||
const parsedNumber = parseInt(payload.channel_number, 10);
|
||||
payload.channel_number = isNaN(parsedNumber) ? null : parsedNumber;
|
||||
}
|
||||
|
||||
const response = await request(`${host}/api/channels/channels/${payload.id}/`, {
|
||||
method: 'PATCH',
|
||||
body: payload,
|
||||
});
|
||||
const response = await request(
|
||||
`${host}/api/channels/channels/${payload.id}/`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
body: payload,
|
||||
}
|
||||
);
|
||||
|
||||
useChannelsStore.getState().updateChannel(response);
|
||||
return response;
|
||||
|
|
@ -349,7 +383,7 @@ export default class API {
|
|||
notifications.show({
|
||||
title: 'EPG Status',
|
||||
message: response.task_status,
|
||||
color: 'blue'
|
||||
color: 'blue',
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -102,7 +102,10 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
|
|||
const formattedValues = { ...values };
|
||||
|
||||
// Convert empty or "0" stream_profile_id to null for the API
|
||||
if (!formattedValues.stream_profile_id || formattedValues.stream_profile_id === '0') {
|
||||
if (
|
||||
!formattedValues.stream_profile_id ||
|
||||
formattedValues.stream_profile_id === '0'
|
||||
) {
|
||||
formattedValues.stream_profile_id = null;
|
||||
}
|
||||
|
||||
|
|
@ -111,9 +114,12 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
|
|||
|
||||
if (channel) {
|
||||
// If there's an EPG to set, use our enhanced endpoint
|
||||
if (values.epg_data_id !== (channel.epg_data ? `${channel.epg_data.id}` : '')) {
|
||||
if (values.epg_data_id !== (channel.epg_data_id ?? '')) {
|
||||
// Use the special endpoint to set EPG and trigger refresh
|
||||
const epgResponse = await API.setChannelEPG(channel.id, values.epg_data_id);
|
||||
const epgResponse = await API.setChannelEPG(
|
||||
channel.id,
|
||||
values.epg_data_id
|
||||
);
|
||||
|
||||
// Remove epg_data_id from values since we've handled it separately
|
||||
const { epg_data_id, ...otherValues } = formattedValues;
|
||||
|
|
@ -142,7 +148,7 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
|
|||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error saving channel:", error);
|
||||
console.error('Error saving channel:', error);
|
||||
}
|
||||
|
||||
setSubmitting(false);
|
||||
|
|
@ -154,8 +160,8 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
|
|||
|
||||
useEffect(() => {
|
||||
if (channel) {
|
||||
if (channel.epg_data) {
|
||||
const epgSource = epgs[channel.epg_data.epg_source];
|
||||
if (channel.epg_data_id) {
|
||||
const epgSource = epgs[tvgsById[channel.epg_data_id].epg_source];
|
||||
setSelectedEPG(`${epgSource.id}`);
|
||||
}
|
||||
|
||||
|
|
@ -167,8 +173,8 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
|
|||
? `${channel.stream_profile_id}`
|
||||
: '0',
|
||||
tvg_id: channel.tvg_id,
|
||||
epg_data_id: channel.epg_data ? `${channel.epg_data?.id}` : '',
|
||||
logo_id: `${channel.logo?.id}`,
|
||||
epg_data_id: channel.epg_data_id ?? '',
|
||||
logo_id: `${channel.logo_id}`,
|
||||
});
|
||||
|
||||
setChannelStreams(channel.streams);
|
||||
|
|
@ -535,7 +541,9 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
|
|||
name="channel_number"
|
||||
label="Channel # (blank to auto-assign)"
|
||||
value={formik.values.channel_number}
|
||||
onChange={(value) => formik.setFieldValue('channel_number', value)}
|
||||
onChange={(value) =>
|
||||
formik.setFieldValue('channel_number', value)
|
||||
}
|
||||
error={
|
||||
formik.errors.channel_number
|
||||
? formik.touched.channel_number
|
||||
|
|
|
|||
252
frontend/src/components/tables/ChannelTableStreams.jsx
Normal file
252
frontend/src/components/tables/ChannelTableStreams.jsx
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import API from '../../api';
|
||||
import { GripHorizontal, SquareMinus } from 'lucide-react';
|
||||
import {
|
||||
Box,
|
||||
ActionIcon,
|
||||
Flex,
|
||||
Text,
|
||||
useMantineTheme,
|
||||
Center,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
flexRender,
|
||||
} from '@tanstack/react-table';
|
||||
import './table.css';
|
||||
import useChannelsTableStore from '../../store/channelsTable';
|
||||
import usePlaylistsStore from '../../store/playlists';
|
||||
import {
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
closestCenter,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
|
||||
// Cell Component
|
||||
const RowDragHandleCell = ({ rowId }) => {
|
||||
const { attributes, listeners } = useSortable({
|
||||
id: rowId,
|
||||
});
|
||||
return (
|
||||
// Alternatively, you could set these attributes on the rows themselves
|
||||
<ActionIcon {...attributes} {...listeners} variant="transparent" size="xs">
|
||||
<GripHorizontal color="white" />
|
||||
</ActionIcon>
|
||||
);
|
||||
};
|
||||
|
||||
// Row Component
|
||||
const DraggableRow = ({ row }) => {
|
||||
const { transform, transition, setNodeRef, isDragging } = useSortable({
|
||||
id: row.original.id,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform), //let dnd-kit do its thing
|
||||
transition: transition,
|
||||
opacity: isDragging ? 0.8 : 1,
|
||||
zIndex: isDragging ? 1 : 0,
|
||||
position: 'relative',
|
||||
};
|
||||
return (
|
||||
<Box
|
||||
ref={setNodeRef}
|
||||
key={row.id}
|
||||
className="tr"
|
||||
style={{
|
||||
...style,
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
...(row.getIsSelected() && {
|
||||
backgroundColor: '#163632',
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
return (
|
||||
<Box
|
||||
className="td"
|
||||
key={cell.id}
|
||||
style={{
|
||||
flex: cell.column.columnDef.size ? '0 0 auto' : '1 1 0',
|
||||
width: cell.column.columnDef.size
|
||||
? cell.column.getSize()
|
||||
: undefined,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<Flex align="center" style={{ height: '100%' }}>
|
||||
<Text size="xs">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const ChannelStreams = ({ channel, isExpanded }) => {
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const channelStreams = useChannelsTableStore(
|
||||
(state) => state.getChannelStreams(channel.id),
|
||||
shallow
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setData(channelStreams);
|
||||
}, [channelStreams]);
|
||||
|
||||
const [data, setData] = useState(channelStreams || []);
|
||||
|
||||
const dataIds = data?.map(({ id }) => id);
|
||||
|
||||
const { playlists } = usePlaylistsStore();
|
||||
|
||||
const removeStream = async (stream) => {
|
||||
const newStreamList = channelStreams.filter((s) => s.id !== stream.id);
|
||||
await API.updateChannel({
|
||||
...channel,
|
||||
stream_ids: newStreamList.map((s) => s.id),
|
||||
});
|
||||
await API.requeryChannels();
|
||||
};
|
||||
|
||||
const table = useReactTable({
|
||||
columns: useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 'drag-handle',
|
||||
header: 'Move',
|
||||
cell: ({ row }) => <RowDragHandleCell rowId={row.id} />,
|
||||
size: 30,
|
||||
},
|
||||
{
|
||||
id: 'name',
|
||||
header: 'Name',
|
||||
accessorKey: 'name',
|
||||
},
|
||||
{
|
||||
id: 'm3u',
|
||||
header: 'M3U',
|
||||
accessorFn: (row) =>
|
||||
playlists.find((playlist) => playlist.id === row.m3u_account)?.name,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '',
|
||||
size: 30,
|
||||
cell: ({ row }) => (
|
||||
<Center>
|
||||
<ActionIcon variant="transparent" size="xs">
|
||||
<SquareMinus
|
||||
color={theme.tailwind.red[6]}
|
||||
onClick={() => removeStream(row.original)}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Center>
|
||||
),
|
||||
},
|
||||
],
|
||||
[playlists]
|
||||
),
|
||||
data: data,
|
||||
state: {
|
||||
data,
|
||||
},
|
||||
defaultColumn: {
|
||||
size: undefined,
|
||||
minSize: 0,
|
||||
},
|
||||
manualPagination: true,
|
||||
manualSorting: true,
|
||||
manualFiltering: true,
|
||||
enableRowSelection: true,
|
||||
getRowId: (row) => row.id,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
// getFilteredRowModel: getFilteredRowModel(),
|
||||
// getSortedRowModel: getSortedRowModel(),
|
||||
// getPaginationRowModel: getPaginationRowModel(),
|
||||
});
|
||||
|
||||
function handleDragEnd(event) {
|
||||
const { active, over } = event;
|
||||
if (active && over && active.id !== over.id) {
|
||||
setData((data) => {
|
||||
const oldIndex = dataIds.indexOf(active.id);
|
||||
const newIndex = dataIds.indexOf(over.id);
|
||||
const retval = arrayMove(data, oldIndex, newIndex);
|
||||
|
||||
const { streams: _, ...channelUpdate } = channel;
|
||||
API.updateChannel({
|
||||
...channelUpdate,
|
||||
stream_ids: retval.map((row) => row.id),
|
||||
}).then(() => {
|
||||
API.requeryChannels();
|
||||
});
|
||||
|
||||
return retval; //this is just a splice util
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {}),
|
||||
useSensor(TouchSensor, {}),
|
||||
useSensor(KeyboardSensor, {})
|
||||
);
|
||||
|
||||
if (!isExpanded) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box style={{ width: '100%', padding: 10, backgroundColor: '#163632' }}>
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToVerticalAxis]}
|
||||
onDragEnd={handleDragEnd}
|
||||
sensors={sensors}
|
||||
>
|
||||
{' '}
|
||||
<Box
|
||||
className="divTable table-striped"
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Box className="tbody">
|
||||
<SortableContext
|
||||
items={dataIds}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<DraggableRow key={row.id} row={row} />
|
||||
))}
|
||||
</SortableContext>
|
||||
</Box>
|
||||
</Box>
|
||||
</DndContext>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelStreams;
|
||||
|
|
@ -15,7 +15,6 @@ import { useDebounce } from '../../utils';
|
|||
import logo from '../../images/logo.png';
|
||||
import useVideoStore from '../../store/useVideoStore';
|
||||
import useSettingsStore from '../../store/settings';
|
||||
import usePlaylistsStore from '../../store/playlists';
|
||||
import {
|
||||
Tv2,
|
||||
ScreenShare,
|
||||
|
|
@ -35,6 +34,8 @@ import {
|
|||
ArrowUpNarrowWide,
|
||||
ArrowUpDown,
|
||||
ArrowDownWideNarrow,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import ghostImage from '../../images/ghost.svg';
|
||||
import {
|
||||
|
|
@ -70,6 +71,26 @@ import {
|
|||
} from '@tanstack/react-table';
|
||||
import './table.css';
|
||||
import useChannelsTableStore from '../../store/channelsTable';
|
||||
import usePlaylistsStore from '../../store/playlists';
|
||||
import { MantineReactTable, useMantineReactTable } from 'mantine-react-table';
|
||||
import {
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
closestCenter,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import ChannelTableStreams from './ChannelTableStreams';
|
||||
|
||||
const m3uUrlBase = `${window.location.protocol}//${window.location.host}/output/m3u`;
|
||||
const epgUrlBase = `${window.location.protocol}//${window.location.host}/output/epg`;
|
||||
|
|
@ -189,38 +210,32 @@ const ChannelRowActions = React.memo(
|
|||
return (
|
||||
<Box style={{ width: '100%', justifyContent: 'left' }}>
|
||||
<Center>
|
||||
<Tooltip label="Edit Channel">
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="transparent"
|
||||
color={theme.tailwind.yellow[3]}
|
||||
onClick={onEdit}
|
||||
>
|
||||
<SquarePen size="18" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="transparent"
|
||||
color={theme.tailwind.yellow[3]}
|
||||
onClick={onEdit}
|
||||
>
|
||||
<SquarePen size="18" />
|
||||
</ActionIcon>
|
||||
|
||||
<Tooltip label="Delete Channel">
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="transparent"
|
||||
color={theme.tailwind.red[6]}
|
||||
onClick={onDelete}
|
||||
>
|
||||
<SquareMinus size="18" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="transparent"
|
||||
color={theme.tailwind.red[6]}
|
||||
onClick={onDelete}
|
||||
>
|
||||
<SquareMinus size="18" />
|
||||
</ActionIcon>
|
||||
|
||||
<Tooltip label="Preview Channel">
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="transparent"
|
||||
color={theme.tailwind.green[5]}
|
||||
onClick={onPreview}
|
||||
>
|
||||
<CirclePlay size="18" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="transparent"
|
||||
color={theme.tailwind.green[5]}
|
||||
onClick={onPreview}
|
||||
>
|
||||
<CirclePlay size="18" />
|
||||
</ActionIcon>
|
||||
|
||||
<Menu>
|
||||
<Menu.Target>
|
||||
|
|
@ -255,16 +270,17 @@ const ChannelRowActions = React.memo(
|
|||
);
|
||||
|
||||
const ChannelsTable = ({}) => {
|
||||
const data = useChannelsTableStore((s) => s.channels);
|
||||
const rowCount = useChannelsTableStore((s) => s.count);
|
||||
const pageCount = useChannelsTableStore((s) => s.pageCount);
|
||||
const setSelectedTableIds = useChannelsTableStore(
|
||||
(s) => s.setSelectedChannelIds
|
||||
);
|
||||
const profiles = useChannelsStore((s) => s.profiles);
|
||||
const selectedProfileId = useChannelsStore((s) => s.selectedProfileId);
|
||||
const setSelectedProfileId = useChannelsStore((s) => s.setSelectedProfileId);
|
||||
const channelGroups = useChannelsStore((s) => s.channelGroups);
|
||||
|
||||
const queryChannels = useChannelsTableStore((s) => s.queryChannels);
|
||||
const requeryChannels = useChannelsTableStore((s) => s.requeryChannels);
|
||||
const data = useChannelsTableStore((s) => s.channels);
|
||||
const rowCount = useChannelsTableStore((s) => s.count);
|
||||
const pageCount = useChannelsTableStore((s) => s.pageCount);
|
||||
const logos = useChannelsStore((s) => s.logos);
|
||||
|
||||
const selectedProfileChannels = useChannelsStore(
|
||||
(s) => s.profiles[selectedProfileId]?.channels
|
||||
|
|
@ -303,34 +319,49 @@ const ChannelsTable = ({}) => {
|
|||
const [sorting, setSorting] = useState([
|
||||
{ id: 'channel_number', desc: false },
|
||||
]);
|
||||
const [expandedRowId, setExpandedRowId] = useState(null);
|
||||
|
||||
const [hdhrUrl, setHDHRUrl] = useState(hdhrUrlBase);
|
||||
const [epgUrl, setEPGUrl] = useState(epgUrlBase);
|
||||
const [m3uUrl, setM3UUrl] = useState(m3uUrlBase);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = useCallback(async () => {
|
||||
const params = new URLSearchParams();
|
||||
params.append('page', pagination.pageIndex + 1);
|
||||
params.append('page_size', pagination.pageSize);
|
||||
|
||||
// Apply sorting
|
||||
if (sorting.length > 0) {
|
||||
const sortField = sorting[0].id;
|
||||
const sortDirection = sorting[0].desc ? '-' : '';
|
||||
params.append('ordering', `${sortDirection}${sortField}`);
|
||||
}
|
||||
|
||||
// Apply debounced filters
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value) params.append(key, value);
|
||||
});
|
||||
|
||||
const results = await API.queryChannels(params);
|
||||
|
||||
const startItem = pagination.pageIndex * pagination.pageSize + 1; // +1 to start from 1, not 0
|
||||
const endItem = Math.min(
|
||||
(pagination.pageIndex + 1) * pagination.pageSize,
|
||||
rowCount
|
||||
results.count
|
||||
);
|
||||
|
||||
if (initialDataCount === null) {
|
||||
setInitialDataCount(rowCount);
|
||||
setInitialDataCount(results.count);
|
||||
}
|
||||
|
||||
// Generate the string
|
||||
setPaginationString(`${startItem} to ${endItem} of ${rowCount}`);
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
queryChannels({ pagination, sorting, filters });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
queryChannels({ pagination, sorting, filters });
|
||||
setPaginationString(`${startItem} to ${endItem} of ${results.count}`);
|
||||
}, [pagination, sorting, debouncedFilters]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
// const theme = useTheme();
|
||||
const theme = useMantineTheme();
|
||||
|
||||
|
|
@ -408,7 +439,9 @@ const ChannelsTable = ({}) => {
|
|||
updatedSelected.add(row.original.id);
|
||||
}
|
||||
});
|
||||
setSelectedChannelIds([...updatedSelected]);
|
||||
const newSelection = [...updatedSelected];
|
||||
setSelectedChannelIds(newSelection);
|
||||
setSelectedTableIds(newSelection);
|
||||
|
||||
return newRowSelection;
|
||||
});
|
||||
|
|
@ -423,8 +456,10 @@ const ChannelsTable = ({}) => {
|
|||
if (value) params.append(key, value);
|
||||
});
|
||||
const ids = await API.getAllChannelIds(params);
|
||||
setSelectedTableIds(ids);
|
||||
setSelectedChannelIds(ids);
|
||||
} else {
|
||||
setSelectedTableIds([]);
|
||||
setSelectedChannelIds([]);
|
||||
}
|
||||
|
||||
|
|
@ -488,7 +523,9 @@ const ChannelsTable = ({}) => {
|
|||
const deleteChannels = async () => {
|
||||
setIsLoading(true);
|
||||
await API.deleteChannels(selectedChannelIds);
|
||||
requeryChannels();
|
||||
await API.requeryChannels();
|
||||
setSelectedChannelIds([]);
|
||||
setRowSelection([]);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
|
|
@ -513,7 +550,7 @@ const ChannelsTable = ({}) => {
|
|||
|
||||
// Refresh the channel list
|
||||
// await fetchChannels();
|
||||
requeryChannels();
|
||||
API.requeryChannels();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
notifications.show({
|
||||
|
|
@ -589,7 +626,6 @@ const ChannelsTable = ({}) => {
|
|||
};
|
||||
|
||||
const onSortingChange = (column) => {
|
||||
console.log(sorting);
|
||||
const sortField = sorting[0]?.id;
|
||||
const sortDirection = sorting[0]?.desc;
|
||||
|
||||
|
|
@ -637,6 +673,12 @@ const ChannelsTable = ({}) => {
|
|||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 'expand',
|
||||
size: 20,
|
||||
enableSorting: false,
|
||||
enableColumnFilter: false,
|
||||
},
|
||||
{
|
||||
id: 'select',
|
||||
size: 30,
|
||||
|
|
@ -704,7 +746,8 @@ const ChannelsTable = ({}) => {
|
|||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'logo',
|
||||
id: 'logo',
|
||||
accessorFn: (row) => logos[row.logo_id] ?? logo,
|
||||
size: 75,
|
||||
header: '',
|
||||
cell: ({ getValue }) => {
|
||||
|
|
@ -772,6 +815,17 @@ const ChannelsTable = ({}) => {
|
|||
|
||||
const rows = getRowModel().rows;
|
||||
|
||||
const onRowExpansion = (row) => {
|
||||
let isExpanded = false;
|
||||
setExpandedRowId((prev) => {
|
||||
isExpanded = prev === row.original.id ? null : row.original.id;
|
||||
return isExpanded;
|
||||
});
|
||||
setRowSelection({ [row.index]: true });
|
||||
setSelectedChannelIds([row.original.id]);
|
||||
setSelectedTableIds([row.original.id]);
|
||||
};
|
||||
|
||||
const renderHeaderCell = (header) => {
|
||||
let sortingIcon = ArrowUpDown;
|
||||
if (sorting[0]?.id == header.id) {
|
||||
|
|
@ -802,12 +856,12 @@ const ChannelsTable = ({}) => {
|
|||
return (
|
||||
<Flex gap={2}>
|
||||
#
|
||||
{/* <Center>
|
||||
<Center>
|
||||
{React.createElement(sortingIcon, {
|
||||
onClick: () => onSortingChange('name'),
|
||||
onClick: () => onSortingChange('channel_number'),
|
||||
size: 14,
|
||||
})}
|
||||
</Center> */}
|
||||
</Center>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
|
|
@ -853,6 +907,37 @@ const ChannelsTable = ({}) => {
|
|||
}
|
||||
};
|
||||
|
||||
const renderBodyCell = (cell) => {
|
||||
switch (cell.column.id) {
|
||||
case 'select':
|
||||
return ChannelRowSelectCell({ row: cell.row });
|
||||
|
||||
case 'expand':
|
||||
return ChannelExpandCell({ row: cell.row });
|
||||
|
||||
default:
|
||||
return flexRender(cell.column.columnDef.cell, cell.getContext());
|
||||
}
|
||||
};
|
||||
|
||||
const ChannelExpandCell = useCallback(
|
||||
({ row }) => {
|
||||
const isExpanded = expandedRowId === row.original.id;
|
||||
|
||||
return (
|
||||
<Center
|
||||
style={{ width: '100%', cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
onRowExpansion(row);
|
||||
}}
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</Center>
|
||||
);
|
||||
},
|
||||
[expandedRowId]
|
||||
);
|
||||
|
||||
const ChannelRowSelectCell = useCallback(
|
||||
({ row }) => {
|
||||
return (
|
||||
|
|
@ -1031,7 +1116,7 @@ const ChannelsTable = ({}) => {
|
|||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: 'calc(100vh - 60px)',
|
||||
height: 'calc(100vh - 58px)',
|
||||
backgroundColor: '#27272A',
|
||||
}}
|
||||
>
|
||||
|
|
@ -1200,7 +1285,7 @@ const ChannelsTable = ({}) => {
|
|||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: 'calc(100vh - 120px)',
|
||||
height: 'calc(100vh - 110px)',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
|
|
@ -1269,38 +1354,53 @@ const ChannelsTable = ({}) => {
|
|||
</Box>
|
||||
<Box className="tbody">
|
||||
{getRowModel().rows.map((row) => (
|
||||
<Box
|
||||
key={row.id}
|
||||
className="tr"
|
||||
style={{ display: 'flex', width: '100%' }}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
const width = cell.column.getSize();
|
||||
return (
|
||||
<Box
|
||||
className="td"
|
||||
key={cell.id}
|
||||
style={{
|
||||
flex: cell.column.columnDef.size
|
||||
? '0 0 auto'
|
||||
: '1 1 0',
|
||||
width: cell.column.columnDef.size
|
||||
? cell.column.getSize()
|
||||
: undefined,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<Flex align="center" style={{ height: '100%' }}>
|
||||
{cell.column.id === 'select'
|
||||
? ChannelRowSelectCell({ row: cell.row })
|
||||
: flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
<Box>
|
||||
<Box
|
||||
key={row.id}
|
||||
className="tr"
|
||||
style={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
...(row.getIsSelected() && {
|
||||
backgroundColor: '#163632',
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
const width = cell.column.getSize();
|
||||
return (
|
||||
<Box
|
||||
className="td"
|
||||
key={cell.id}
|
||||
style={{
|
||||
flex: cell.column.columnDef.size
|
||||
? '0 0 auto'
|
||||
: '1 1 0',
|
||||
width: cell.column.columnDef.size
|
||||
? cell.column.getSize()
|
||||
: undefined,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<Flex align="center" style={{ height: '100%' }}>
|
||||
{renderBodyCell(cell)}
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
{row.original.id === expandedRowId && (
|
||||
<Box
|
||||
key={row.id}
|
||||
className="tr"
|
||||
style={{ display: 'flex', width: '100%' }}
|
||||
>
|
||||
<ChannelTableStreams
|
||||
channel={row.original}
|
||||
isExpanded={true}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
|
@ -1318,7 +1418,10 @@ const ChannelsTable = ({}) => {
|
|||
<Group
|
||||
gap={5}
|
||||
justify="center"
|
||||
style={{ padding: 8, borderTop: '1px solid #666' }}
|
||||
style={{
|
||||
padding: 8,
|
||||
borderTop: '1px solid #666',
|
||||
}}
|
||||
>
|
||||
<Text size="xs">Page Size</Text>
|
||||
<NativeSelect
|
||||
|
|
|
|||
|
|
@ -1,86 +0,0 @@
|
|||
// HeadlessChannelsTable.jsx
|
||||
import React, { useMemo, useState, useCallback, useRef } from 'react';
|
||||
import { FixedSizeList as List } from 'react-window';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
flexRender,
|
||||
getExpandedRowModel,
|
||||
} from '@tanstack/react-table';
|
||||
import {
|
||||
Table,
|
||||
Box,
|
||||
Checkbox,
|
||||
ActionIcon,
|
||||
ScrollArea,
|
||||
} from '@mantine/core';
|
||||
import { ChevronRight, ChevronDown } from 'lucide-react';
|
||||
import ChannelsTableRow from './ChannelsTableRow';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
|
||||
const ChannelsTableBody = ({ rows, height, onEdit, onDelete, onPreview, onRecord, virtualizedItems }) => {
|
||||
const rowHeight = 48;
|
||||
|
||||
// return (
|
||||
// <tbody>
|
||||
// <AutoSizer disableWidth>
|
||||
// {({ height }) => (
|
||||
// <List
|
||||
// height={height}
|
||||
// itemCount={rows.length}
|
||||
// itemSize={rowHeight}
|
||||
// width="100%"
|
||||
// >
|
||||
// {({ index, style }) => {
|
||||
// const row = rows[index];
|
||||
// return (
|
||||
// <React.Fragment key={row.id}>
|
||||
// <ChannelsTableRow
|
||||
// row={row}
|
||||
// onEdit={onEdit}
|
||||
// onDelete={onDelete}
|
||||
// onPreview={onPreview}
|
||||
// onRecord={onRecord}
|
||||
// />
|
||||
// {row.getIsExpanded() && <ChannelsDetailPanel row={row} />}
|
||||
// </React.Fragment>
|
||||
// );
|
||||
// }}
|
||||
// </List>
|
||||
// )}
|
||||
// </AutoSizer>
|
||||
// </tbody>
|
||||
// );
|
||||
|
||||
return (
|
||||
<Table.Tbody style={{
|
||||
position: 'relative',
|
||||
// display: 'block',
|
||||
height: `${height}px`,
|
||||
// overflowY: 'auto',
|
||||
}}>
|
||||
{virtualizedItems.map((virtualRow, index) => {
|
||||
const row = rows[virtualRow.index]
|
||||
return (
|
||||
<ChannelsTableRow
|
||||
row={row}
|
||||
virtualRow={virtualRow}
|
||||
index={index}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onPreview={onPreview}
|
||||
onRecord={onRecord}
|
||||
style={{
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelsTableBody;
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
// HeadlessChannelsTable.jsx
|
||||
import React, { useMemo, useState, useCallback } from 'react';
|
||||
import { FixedSizeList as List } from 'react-window';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
flexRender,
|
||||
getExpandedRowModel,
|
||||
} from '@tanstack/react-table';
|
||||
import {
|
||||
Table,
|
||||
Box,
|
||||
Checkbox,
|
||||
ActionIcon,
|
||||
ScrollArea,
|
||||
Center,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import { ChevronRight, ChevronDown } from 'lucide-react';
|
||||
import useSettingsStore from '../../../store/settings';
|
||||
import useChannelsStore from '../../../store/channels';
|
||||
|
||||
const ExpandIcon = ({ row, toggle }) => (
|
||||
<ActionIcon size="xs" onClick={toggle}>
|
||||
{row.getIsExpanded() ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</ActionIcon>
|
||||
);
|
||||
|
||||
const ChannelsTableRow = ({ row, virtualRow, index, style, onEdit, onDelete, onPreview, onRecord }) => {
|
||||
return (
|
||||
<Table.Tr style={{
|
||||
...style,
|
||||
position: 'absolute',
|
||||
// top: 0,
|
||||
display: 'table',
|
||||
tableLayout: 'fixed',
|
||||
width: '100%',
|
||||
}}>
|
||||
{row.getVisibleCells().map(cell => {
|
||||
return (
|
||||
<Table.Td key={cell.id} align={cell.column.columnDef.meta?.align} style={{
|
||||
padding: 0,
|
||||
width: cell.column.getSize(),
|
||||
minWidth: cell.column.columnDef.meta?.minWidth,
|
||||
maxWidth: cell.column.columnDef.meta?.maxWidth,
|
||||
// maxWidth: cell.column.getSize(),
|
||||
}}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</Table.Td>
|
||||
)
|
||||
})}
|
||||
</Table.Tr>
|
||||
)
|
||||
};
|
||||
|
||||
export default ChannelsTableRow
|
||||
134
frontend/src/components/tables/CustomTable/CustomTable.jsx
Normal file
134
frontend/src/components/tables/CustomTable/CustomTable.jsx
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { Box, Flex } from '@mantine/core';
|
||||
import CustomTableHeader from './CustomTableHeader';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { flexRender } from '@tanstack/react-table';
|
||||
|
||||
const CustomTable = ({
|
||||
table,
|
||||
headerCellRenderer,
|
||||
rowDetailRenderer,
|
||||
bodyCellRenderFns,
|
||||
rowCount,
|
||||
}) => {
|
||||
const [expandedRowId, setExpandedRowId] = useState(null);
|
||||
|
||||
const rows = table.getRowModel().rows;
|
||||
|
||||
const ChannelExpandCell = useCallback(
|
||||
({ row }) => {
|
||||
const isExpanded = expandedRowId === row.original.id;
|
||||
|
||||
return (
|
||||
<Center
|
||||
style={{ width: '100%', cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
setExpandedRowId((prev) =>
|
||||
prev === row.original.id ? null : row.original.id
|
||||
);
|
||||
}}
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</Center>
|
||||
);
|
||||
},
|
||||
[expandedRowId]
|
||||
);
|
||||
|
||||
const ChannelRowSelectCell = useCallback(
|
||||
({ row }) => {
|
||||
return (
|
||||
<Center style={{ width: '100%' }}>
|
||||
<Checkbox
|
||||
size="xs"
|
||||
checked={row.getIsSelected()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
/>
|
||||
</Center>
|
||||
);
|
||||
},
|
||||
[rows]
|
||||
);
|
||||
|
||||
const bodyCellRenderer = (cell) => {
|
||||
if (bodyCellRenderFns[cell.column.id]) {
|
||||
return bodyCellRenderFns(cell);
|
||||
}
|
||||
|
||||
switch (cell.column.id) {
|
||||
case 'select':
|
||||
return ChannelRowSelectCell({ row: cell.row });
|
||||
|
||||
case 'expand':
|
||||
return ChannelExpandCell({ row: cell.row });
|
||||
|
||||
default:
|
||||
return flexRender(cell.column.columnDef.cell, cell.getContext());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="divTable table-striped"
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<CustomTableHeader
|
||||
table={table}
|
||||
headerCellRenderer={headerCellRenderer}
|
||||
rowCount={rowCount}
|
||||
onSelectAllChange={onSelectAllChange}
|
||||
/>
|
||||
<Box className="tbody">
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<Box>
|
||||
<Box
|
||||
key={row.id}
|
||||
className="tr"
|
||||
style={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
...(row.getIsSelected() && {
|
||||
backgroundColor: '#163632',
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
return (
|
||||
<Box
|
||||
className="td"
|
||||
key={cell.id}
|
||||
style={{
|
||||
flex: cell.column.columnDef.size ? '0 0 auto' : '1 1 0',
|
||||
width: cell.column.columnDef.size
|
||||
? cell.column.getSize()
|
||||
: undefined,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<Flex align="center" style={{ height: '100%' }}>
|
||||
{bodyCellRenderer(cell)}
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
{row.original.id === expandedRowId && (
|
||||
<Box
|
||||
key={row.id}
|
||||
className="tr"
|
||||
style={{ display: 'flex', width: '100%' }}
|
||||
>
|
||||
<ChannelStreams channel={row.original} isExpanded={true} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomTable;
|
||||
171
frontend/src/components/tables/CustomTable/CustomTableHeader.jsx
Normal file
171
frontend/src/components/tables/CustomTable/CustomTableHeader.jsx
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import { Box, Flex } from '@mantine/core';
|
||||
import {
|
||||
ArrowDownWideNarrow,
|
||||
ArrowUpDown,
|
||||
ArrowUpNarrowWide,
|
||||
} from 'lucide-react';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
const CustomTableHeader = ({
|
||||
table,
|
||||
headerCellRenderFns,
|
||||
rowCount,
|
||||
onSelectAllChange,
|
||||
}) => {
|
||||
const ChannelRowSelectHeader = useCallback(
|
||||
({ selectedChannelIds }) => {
|
||||
return (
|
||||
<Center style={{ width: '100%' }}>
|
||||
<Checkbox
|
||||
size="xs"
|
||||
checked={
|
||||
rowCount == 0 ? false : selectedChannelIds.length == rowCount
|
||||
}
|
||||
indeterminate={
|
||||
selectedChannelIds.length > 0 &&
|
||||
selectedChannelIds.length !== rowCount
|
||||
}
|
||||
onChange={onSelectAllChange}
|
||||
/>
|
||||
</Center>
|
||||
);
|
||||
},
|
||||
[rows, rowCount]
|
||||
);
|
||||
|
||||
const onSelectAll = (e) => {
|
||||
if (onSelectAllChange) {
|
||||
onSelectAllChange(e);
|
||||
}
|
||||
};
|
||||
|
||||
const headerCellRenderer = (header) => {
|
||||
let sortingIcon = ArrowUpDown;
|
||||
if (sorting[0]?.id == header.id) {
|
||||
if (sorting[0].desc === false) {
|
||||
sortingIcon = ArrowUpNarrowWide;
|
||||
} else {
|
||||
sortingIcon = ArrowDownWideNarrow;
|
||||
}
|
||||
}
|
||||
|
||||
switch (header.id) {
|
||||
case 'select':
|
||||
return ChannelRowSelectHeader({
|
||||
selectedChannelIds,
|
||||
});
|
||||
|
||||
case 'enabled':
|
||||
if (selectedProfileId !== '0' && selectedChannelIds.length > 0) {
|
||||
// return EnabledHeaderSwitch();
|
||||
}
|
||||
return (
|
||||
<Center style={{ width: '100%' }}>
|
||||
<ScanEye size="16" />
|
||||
</Center>
|
||||
);
|
||||
|
||||
// case 'channel_number':
|
||||
// return (
|
||||
// <Flex gap={2}>
|
||||
// #
|
||||
// {/* <Center>
|
||||
// {React.createElement(sortingIcon, {
|
||||
// onClick: () => onSortingChange('name'),
|
||||
// size: 14,
|
||||
// })}
|
||||
// </Center> */}
|
||||
// </Flex>
|
||||
// );
|
||||
|
||||
// case 'name':
|
||||
// return (
|
||||
// <Flex gap="sm">
|
||||
// <TextInput
|
||||
// name="name"
|
||||
// placeholder="Name"
|
||||
// value={filters.name || ''}
|
||||
// onClick={(e) => e.stopPropagation()}
|
||||
// onChange={handleFilterChange}
|
||||
// size="xs"
|
||||
// variant="unstyled"
|
||||
// className="table-input-header"
|
||||
// />
|
||||
// <Center>
|
||||
// {React.createElement(sortingIcon, {
|
||||
// onClick: () => onSortingChange('name'),
|
||||
// size: 14,
|
||||
// })}
|
||||
// </Center>
|
||||
// </Flex>
|
||||
// );
|
||||
|
||||
// case 'channel_group':
|
||||
// return (
|
||||
// <MultiSelect
|
||||
// placeholder="Group"
|
||||
// variant="unstyled"
|
||||
// data={groupOptions}
|
||||
// size="xs"
|
||||
// searchable
|
||||
// clearable
|
||||
// onClick={stopPropagation}
|
||||
// onChange={handleGroupChange}
|
||||
// style={{ width: '100%' }}
|
||||
// />
|
||||
// );
|
||||
|
||||
default:
|
||||
return flexRender(header.column.columnDef.header, header.getContext());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="thead"
|
||||
style={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
backgroundColor: '#3E3E45',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<Box
|
||||
className="tr"
|
||||
key={headerGroup.id}
|
||||
style={{ display: 'flex', width: '100%' }}
|
||||
>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<Box
|
||||
className="th"
|
||||
key={header.id}
|
||||
style={{
|
||||
flex: header.column.columnDef.size ? '0 0 auto' : '1 1 0',
|
||||
width: header.column.columnDef.size
|
||||
? header.getSize()
|
||||
: undefined,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
align="center"
|
||||
style={{
|
||||
...(header.column.columnDef.style &&
|
||||
header.column.columnDef.style),
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
{headerCellRenderer(header)}
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomTableHeader;
|
||||
|
|
@ -78,15 +78,14 @@ const StreamsTable = ({}) => {
|
|||
* Stores
|
||||
*/
|
||||
const { playlists } = usePlaylistsStore();
|
||||
|
||||
const channelGroups = useChannelsStore((s) => s.channelGroups);
|
||||
const channelsPageSelection = useChannelsStore(
|
||||
(s) => s.channelsPageSelection
|
||||
);
|
||||
const selectedChannelIds = useChannelsTableStore((s) => s.selectedChannelIds);
|
||||
const fetchLogos = useChannelsStore((s) => s.fetchLogos);
|
||||
const channelSelectionStreams = useChannelsStore(
|
||||
(state) => state.channels[state.channelsPageSelection[0]?.id]?.streams
|
||||
const channelSelectionStreams = useChannelsTableStore(
|
||||
(state) =>
|
||||
state.channels.find((chan) => chan.id === selectedChannelIds[0])?.streams
|
||||
);
|
||||
const requeryChannels = useChannelsTableStore((s) => s.requeryChannels);
|
||||
const {
|
||||
environment: { env_mode },
|
||||
} = useSettingsStore();
|
||||
|
|
@ -287,6 +286,7 @@ const StreamsTable = ({}) => {
|
|||
channel_number: null,
|
||||
stream_id: stream.id,
|
||||
});
|
||||
await API.requeryChannels();
|
||||
fetchLogos();
|
||||
};
|
||||
|
||||
|
|
@ -298,7 +298,7 @@ const StreamsTable = ({}) => {
|
|||
stream_id,
|
||||
}))
|
||||
);
|
||||
requeryChannels();
|
||||
await API.requeryChannels();
|
||||
fetchLogos();
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
|
@ -325,9 +325,8 @@ const StreamsTable = ({}) => {
|
|||
};
|
||||
|
||||
const addStreamsToChannel = async () => {
|
||||
const { streams, ...channel } = { ...channelsPageSelection[0] };
|
||||
await API.updateChannel({
|
||||
...channel,
|
||||
id: selectedChannelIds[0],
|
||||
stream_ids: [
|
||||
...new Set(
|
||||
channelSelectionStreams
|
||||
|
|
@ -336,18 +335,19 @@ const StreamsTable = ({}) => {
|
|||
),
|
||||
],
|
||||
});
|
||||
await API.requeryChannels();
|
||||
};
|
||||
|
||||
const addStreamToChannel = async (streamId) => {
|
||||
const { streams, ...channel } = { ...channelsPageSelection[0] };
|
||||
await API.updateChannel({
|
||||
...channel,
|
||||
id: selectedChannelIds[0],
|
||||
stream_ids: [
|
||||
...new Set(
|
||||
channelSelectionStreams.map((stream) => stream.id).concat([streamId])
|
||||
),
|
||||
],
|
||||
});
|
||||
await API.requeryChannels();
|
||||
};
|
||||
|
||||
const onRowSelectionChange = (updater) => {
|
||||
|
|
@ -512,7 +512,7 @@ const StreamsTable = ({}) => {
|
|||
onClick={() => addStreamToChannel(row.original.id)}
|
||||
style={{ background: 'none' }}
|
||||
disabled={
|
||||
channelsPageSelection.length !== 1 ||
|
||||
selectedChannelIds.length !== 1 ||
|
||||
(channelSelectionStreams &&
|
||||
channelSelectionStreams
|
||||
.map((stream) => stream.id)
|
||||
|
|
|
|||
|
|
@ -86,5 +86,5 @@
|
|||
|
||||
.table-striped .tbody .tr:nth-child(even),
|
||||
.table-striped .tbody .tr-even {
|
||||
/* background-color: #ffffff; */
|
||||
background-color: #27272A;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
} from '@mantine/core';
|
||||
import { Search, X, Clock, Video, Calendar, Play } from 'lucide-react';
|
||||
import './guide.css';
|
||||
import useEPGsStore from '../store/epgs';
|
||||
|
||||
/** Layout constants */
|
||||
const CHANNEL_WIDTH = 120; // Width of the channel/logo column
|
||||
|
|
@ -33,7 +34,13 @@ const MINUTE_INCREMENT = 15; // For positioning programs every 15 min
|
|||
const MINUTE_BLOCK_WIDTH = HOUR_WIDTH / (60 / MINUTE_INCREMENT);
|
||||
|
||||
export default function TVChannelGuide({ startDate, endDate }) {
|
||||
const { channels, recordings, channelGroups, profiles } = useChannelsStore();
|
||||
const channels = useChannelsStore((s) => s.channels);
|
||||
const recordings = useChannelsStore((s) => s.recordings);
|
||||
const channelGroups = useChannelsStore((s) => s.channelGroups);
|
||||
const profiles = useChannelsStore((s) => s.profiles);
|
||||
const logos = useChannelsStore((s) => s.logos);
|
||||
|
||||
const tvgsById = useEPGsStore((s) => s.tvgsById);
|
||||
|
||||
const [programs, setPrograms] = useState([]);
|
||||
const [guideChannels, setGuideChannels] = useState([]);
|
||||
|
|
@ -79,7 +86,12 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
// Filter your Redux/Zustand channels by matching tvg_id
|
||||
const filteredChannels = Object.values(channels)
|
||||
// Include channels with matching tvg_ids OR channels with null epg_data
|
||||
.filter((ch) => programIds.includes(ch.epg_data?.tvg_id) || programIds.includes(ch.uuid) || ch.epg_data === null)
|
||||
.filter(
|
||||
(ch) =>
|
||||
programIds.includes(tvgsById[ch.epg_data_id]?.tvg_id) ||
|
||||
programIds.includes(ch.uuid) ||
|
||||
ch.epg_data_id === null
|
||||
)
|
||||
// Add sorting by channel_number
|
||||
.sort(
|
||||
(a, b) =>
|
||||
|
|
@ -276,7 +288,9 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
// Helper: find channel by tvg_id
|
||||
function findChannelByTvgId(tvgId) {
|
||||
return guideChannels.find(
|
||||
(ch) => ch.epg_data?.tvg_id === tvgId || (!ch.epg_data && ch.uuid === tvgId)
|
||||
(ch) =>
|
||||
tvgsById[ch.epg_data_id]?.tvg_id === tvgId ||
|
||||
(!ch.epg_data_id && ch.uuid === tvgId)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -839,10 +853,10 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
{(searchQuery !== '' ||
|
||||
selectedGroupId !== 'all' ||
|
||||
selectedProfileId !== 'all') && (
|
||||
<Button variant="subtle" onClick={clearFilters} size="sm" compact>
|
||||
Clear Filters
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="subtle" onClick={clearFilters} size="sm" compact>
|
||||
Clear Filters
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Text size="sm" color="dimmed">
|
||||
{filteredChannels.length}{' '}
|
||||
|
|
@ -1049,8 +1063,10 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
{filteredChannels.length > 0 ? (
|
||||
filteredChannels.map((channel) => {
|
||||
const channelPrograms = programs.filter(
|
||||
(p) => (channel.epg_data && p.tvg_id === channel.epg_data.tvg_id) ||
|
||||
(!channel.epg_data && p.tvg_id === channel.uuid)
|
||||
(p) =>
|
||||
(channel.epg_data_id &&
|
||||
p.tvg_id === tvgsById[channel.epg_data_id].tvg_id) ||
|
||||
(!channel.epg_data_id && p.tvg_id === channel.uuid)
|
||||
);
|
||||
// Check if any program in this channel is expanded
|
||||
const hasExpandedProgram = channelPrograms.some(
|
||||
|
|
@ -1149,7 +1165,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
|
|||
}}
|
||||
>
|
||||
<img
|
||||
src={channel.logo?.cache_url || logo}
|
||||
src={logos[channel.logo_id]?.cache_url || logo}
|
||||
alt={channel.name}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ const useAuthStore = create((set, get) => ({
|
|||
|
||||
initData: async () => {
|
||||
await Promise.all([
|
||||
// useChannelsStore.getState().fetchChannels(),
|
||||
useChannelsStore.getState().fetchChannels(),
|
||||
useChannelsStore.getState().fetchChannelGroups(),
|
||||
useChannelsStore.getState().fetchLogos(),
|
||||
useChannelsStore.getState().fetchChannelProfiles(),
|
||||
|
|
|
|||
|
|
@ -113,16 +113,12 @@ const useChannelsStore = create((set, get) => ({
|
|||
const channelsByID = newChannels.reduce((acc, channel) => {
|
||||
acc[channel.id] = channel;
|
||||
channelsByUUID[channel.uuid] = channel.id;
|
||||
if (channel.logo) {
|
||||
logos[channel.logo.id] = channel.logo;
|
||||
}
|
||||
|
||||
profileChannels.add(channel.id);
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const newProfiles = {};
|
||||
const newProfiles = { ...defaultProfiles };
|
||||
Object.entries(state.profiles).forEach(([id, profile]) => {
|
||||
newProfiles[id] = {
|
||||
...profile,
|
||||
|
|
@ -139,10 +135,6 @@ const useChannelsStore = create((set, get) => ({
|
|||
...state.channelsByUUID,
|
||||
...channelsByUUID,
|
||||
},
|
||||
logos: {
|
||||
...state.logos,
|
||||
...logos,
|
||||
},
|
||||
profiles: newProfiles,
|
||||
};
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -3,56 +3,31 @@ import api from '../api';
|
|||
import { notifications } from '@mantine/notifications';
|
||||
import API from '../api';
|
||||
|
||||
const defaultProfiles = { 0: { id: '0', name: 'All', channels: new Set() } };
|
||||
|
||||
const useChannelsTableStore = create((set, get) => ({
|
||||
channels: [],
|
||||
count: 0,
|
||||
pageCount: 0,
|
||||
lastParams: new URLSearchParams(),
|
||||
selectedChannelIds: [],
|
||||
|
||||
requeryChannels: async () => {
|
||||
const lastParams = get().lastParams;
|
||||
console.log(lastParams);
|
||||
const result = await API.queryChannels(lastParams);
|
||||
const pageSize = parseInt(lastParams.get?.('page_size') || '25');
|
||||
|
||||
set({
|
||||
channels: result.results,
|
||||
count: result.count,
|
||||
pageCount: Math.ceil(result.count / pageSize),
|
||||
queryChannels: ({ results, count }, params) => {
|
||||
set((state) => {
|
||||
return {
|
||||
channels: results,
|
||||
count: count,
|
||||
pageCount: Math.ceil(count / params.page_size),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
queryChannels: async ({ pagination, sorting, filters }) => {
|
||||
const params = new URLSearchParams();
|
||||
params.append('page', pagination.pageIndex + 1);
|
||||
params.append('page_size', pagination.pageSize);
|
||||
|
||||
// Apply sorting
|
||||
if (sorting.length > 0) {
|
||||
const sortField = sorting[0].id;
|
||||
const sortDirection = sorting[0].desc ? '-' : '';
|
||||
params.append('ordering', `${sortDirection}${sortField}`);
|
||||
}
|
||||
|
||||
// Apply debounced filters
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value) params.append(key, value);
|
||||
setSelectedChannelIds: (selectedChannelIds) => {
|
||||
set({
|
||||
selectedChannelIds,
|
||||
});
|
||||
},
|
||||
|
||||
try {
|
||||
const result = await API.queryChannels(params);
|
||||
|
||||
set((state) => ({
|
||||
channels: result.results,
|
||||
count: result.count,
|
||||
pageCount: Math.ceil(result.count / pagination.pageSize),
|
||||
lastParams: params,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error);
|
||||
}
|
||||
getChannelStreams: (id) => {
|
||||
const channel = get().channels.find((c) => c.id === id);
|
||||
return channel?.streams ?? [];
|
||||
},
|
||||
}));
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue