mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-25 19:14:00 +00:00
more user management features and bug fixes
This commit is contained in:
parent
e979113935
commit
e3553b04ad
9 changed files with 624 additions and 160 deletions
|
|
@ -14,20 +14,34 @@ import json
|
|||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
def generate_m3u(request, user):
|
||||
if user.user_level == 0:
|
||||
channel_profiles = user.channel_profiles.all()
|
||||
filters = {
|
||||
"channelprofilemembership__channel_profile__in": channel_profiles,
|
||||
"channelprofilemembership__enabled": True,
|
||||
"user_level__lte": user.user_level,
|
||||
}
|
||||
def generate_m3u(request, profile_name=None, user=None):
|
||||
"""
|
||||
Dynamically generate an M3U file from channels.
|
||||
The stream URL now points to the new stream_view that uses StreamProfile.
|
||||
"""
|
||||
if user is not None:
|
||||
if user.user_level == 0:
|
||||
channel_profiles = user.channel_profiles.all()
|
||||
filters = {
|
||||
"channelprofilemembership__channel_profile__in": channel_profiles,
|
||||
"channelprofilemembership__enabled": True,
|
||||
"user_level__lte": user.user_level,
|
||||
}
|
||||
|
||||
channels = Channel.objects.filter(**filters).order_by("channel_number")
|
||||
channels = Channel.objects.filter(**filters).order_by("channel_number")
|
||||
else:
|
||||
channels = Channel.objects.filter(user_level__lte=user.user_level).order_by(
|
||||
"channel_number"
|
||||
)
|
||||
else:
|
||||
channels = Channel.objects.filter(user_level__lte=user.user_level).order_by(
|
||||
"channel_number"
|
||||
)
|
||||
if profile_name is not None:
|
||||
channel_profile = ChannelProfile.objects.get(name=profile_name)
|
||||
channels = Channel.objects.filter(
|
||||
channelprofilemembership__channel_profile=channel_profile,
|
||||
channelprofilemembership__enabled=True,
|
||||
).order_by("channel_number")
|
||||
else:
|
||||
channels = Channel.objects.order_by("channel_number")
|
||||
|
||||
m3u_content = "#EXTM3U\n"
|
||||
for channel in channels:
|
||||
|
|
@ -177,7 +191,7 @@ def generate_dummy_epg(
|
|||
return xml_lines
|
||||
|
||||
|
||||
def generate_epg(request, user):
|
||||
def generate_epg(request, profile_name=None, user=None):
|
||||
"""
|
||||
Dynamically generate an XMLTV (EPG) file using the new EPGData/ProgramData models.
|
||||
Since the EPG data is stored independently of Channels, we group programmes
|
||||
|
|
@ -190,19 +204,29 @@ def generate_epg(request, user):
|
|||
'<tv generator-info-name="Dispatcharr" generator-info-url="https://github.com/Dispatcharr/Dispatcharr">'
|
||||
)
|
||||
|
||||
if user.user_level == 0:
|
||||
channel_profiles = user.channel_profiles.all()
|
||||
filters = {
|
||||
"channelprofilemembership__channel_profile__in": channel_profiles,
|
||||
"channelprofilemembership__enabled": True,
|
||||
"user_level__lte": user.user_level,
|
||||
}
|
||||
if user is not None:
|
||||
if user.user_level == 0:
|
||||
channel_profiles = user.channel_profiles.all()
|
||||
filters = {
|
||||
"channelprofilemembership__channel_profile__in": channel_profiles,
|
||||
"channelprofilemembership__enabled": True,
|
||||
"user_level__lte": user.user_level,
|
||||
}
|
||||
|
||||
channels = Channel.objects.filter(**filters).order_by("channel_number")
|
||||
channels = Channel.objects.filter(**filters).order_by("channel_number")
|
||||
else:
|
||||
channels = Channel.objects.filter(user_level__lte=user.user_level).order_by(
|
||||
"channel_number"
|
||||
)
|
||||
else:
|
||||
channels = Channel.objects.filter(user_level__lte=user.user_level).order_by(
|
||||
"channel_number"
|
||||
)
|
||||
if profile_name is not None:
|
||||
channel_profile = ChannelProfile.objects.get(name=profile_name)
|
||||
channels = Channel.objects.filter(
|
||||
channelprofilemembership__channel_profile=channel_profile,
|
||||
channelprofilemembership__enabled=True,
|
||||
)
|
||||
else:
|
||||
channels = Channel.objects.all()
|
||||
|
||||
# Retrieve all active channels
|
||||
for channel in channels:
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import useSettingsStore from '../store/settings';
|
|||
import useAuthStore from '../store/auth'; // Add this import
|
||||
import API from '../api';
|
||||
import { USER_LEVELS } from '../constants';
|
||||
import UserForm from './forms/User';
|
||||
|
||||
const NavLink = ({ item, isActive, collapsed }) => {
|
||||
return (
|
||||
|
|
@ -81,6 +82,9 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
|
|||
version: '',
|
||||
timestamp: null,
|
||||
});
|
||||
const [userFormOpen, setUserFormOpen] = useState(false);
|
||||
|
||||
const closeUserForm = () => setUserFormOpen(false);
|
||||
|
||||
// Navigation Items
|
||||
const navItems =
|
||||
|
|
@ -295,9 +299,9 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
|
|||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<Text size="sm" color="white">
|
||||
<UnstyledButton onClick={() => setUserFormOpen(true)}>
|
||||
{authUser.username}
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
|
||||
<ActionIcon variant="transparent" color="white" size="sm">
|
||||
<LogOut onClick={logout} />
|
||||
|
|
@ -315,6 +319,8 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
|
|||
{appVersion?.timestamp ? `-${appVersion.timestamp}` : ''}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<UserForm user={authUser} isOpen={userFormOpen} onClose={closeUserForm} />
|
||||
</AppShell.Navbar>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
88
frontend/src/components/forms/AssignChannelNumbers.jsx
Normal file
88
frontend/src/components/forms/AssignChannelNumbers.jsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import API from '../../api';
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
Text,
|
||||
Group,
|
||||
Flex,
|
||||
useMantineTheme,
|
||||
NumberInput,
|
||||
} from '@mantine/core';
|
||||
import { ListOrdered } from 'lucide-react';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
|
||||
const AssignChannelNumbers = ({ channelIds, isOpen, onClose }) => {
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const form = useForm({
|
||||
mode: 'uncontrolled',
|
||||
initialValues: {
|
||||
starting_number: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async () => {
|
||||
const { starting_number } = form.getValues();
|
||||
|
||||
try {
|
||||
const result = await API.assignChannelNumbers(
|
||||
channelIds,
|
||||
starting_number
|
||||
);
|
||||
|
||||
notifications.show({
|
||||
title: result.message || 'Channels assigned',
|
||||
color: 'green.5',
|
||||
});
|
||||
|
||||
API.requeryChannels();
|
||||
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
notifications.show({
|
||||
title: 'Failed to assign channels',
|
||||
color: 'red.5',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={isOpen}
|
||||
onClose={onClose}
|
||||
size="xs"
|
||||
title={
|
||||
<Group gap="5">
|
||||
<ListOrdered size="20" />
|
||||
<Text>Assign Channel #s</Text>
|
||||
</Group>
|
||||
}
|
||||
styles={{ hannontent: { '--mantine-color-body': '#27272A' } }}
|
||||
>
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<NumberInput
|
||||
placeholder="Starting #"
|
||||
mb="xs"
|
||||
size="xs"
|
||||
{...form.getInputProps('starting_number')}
|
||||
key={form.key('starting_number')}
|
||||
/>
|
||||
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
<Button type="submit" variant="default" disabled={form.submitting}>
|
||||
Submit
|
||||
</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AssignChannelNumbers;
|
||||
294
frontend/src/components/forms/ChannelBatch.jsx
Normal file
294
frontend/src/components/forms/ChannelBatch.jsx
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import useChannelsStore from '../../store/channels';
|
||||
import API from '../../api';
|
||||
import useStreamProfilesStore from '../../store/streamProfiles';
|
||||
import ChannelGroupForm from './ChannelGroup';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Modal,
|
||||
TextInput,
|
||||
Text,
|
||||
Group,
|
||||
ActionIcon,
|
||||
Flex,
|
||||
Select,
|
||||
Stack,
|
||||
useMantineTheme,
|
||||
Popover,
|
||||
ScrollArea,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
Center,
|
||||
} from '@mantine/core';
|
||||
import { ListOrdered, SquarePlus, SquareX, X } from 'lucide-react';
|
||||
import { FixedSizeList as List } from 'react-window';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { USER_LEVELS, USER_LEVEL_LABELS } from '../../constants';
|
||||
|
||||
const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => {
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const groupListRef = useRef(null);
|
||||
|
||||
const channelGroups = useChannelsStore((s) => s.channelGroups);
|
||||
const streamProfiles = useStreamProfilesStore((s) => s.profiles);
|
||||
|
||||
const [channelGroupModelOpen, setChannelGroupModalOpen] = useState(false);
|
||||
const [selectedChannelGroup, setSelectedChannelGroup] = useState('');
|
||||
|
||||
const [groupPopoverOpened, setGroupPopoverOpened] = useState(false);
|
||||
const [groupFilter, setGroupFilter] = useState('');
|
||||
const groupOptions = Object.values(channelGroups);
|
||||
|
||||
const form = useForm({
|
||||
mode: 'uncontrolled',
|
||||
initialValues: {
|
||||
channel_group: '',
|
||||
stream_profile_id: '0',
|
||||
user_level: '-1',
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async () => {
|
||||
const values = {
|
||||
...form.getValues(),
|
||||
channel_group_id: selectedChannelGroup,
|
||||
};
|
||||
|
||||
if (!values.stream_profile_id || values.stream_profile_id === '0') {
|
||||
values.stream_profile_id = null;
|
||||
}
|
||||
|
||||
if (!values.channel_group_id) {
|
||||
delete values.channel_group_id;
|
||||
}
|
||||
|
||||
if (values.user_level == '-1') {
|
||||
delete values.user_level;
|
||||
}
|
||||
|
||||
await API.batchUpdateChannels({
|
||||
ids: channelIds,
|
||||
values,
|
||||
});
|
||||
};
|
||||
|
||||
// useEffect(() => {
|
||||
// // const sameStreamProfile = channels.every(
|
||||
// // (channel) => channel.stream_profile_id == channels[0].stream_profile_id
|
||||
// // );
|
||||
// // const sameChannelGroup = channels.every(
|
||||
// // (channel) => channel.channel_group_id == channels[0].channel_group_id
|
||||
// // );
|
||||
// // const sameUserLevel = channels.every(
|
||||
// // (channel) => channel.user_level == channels[0].user_level
|
||||
// // );
|
||||
// // form.setValues({
|
||||
// // ...(sameStreamProfile && {
|
||||
// // stream_profile_id: `${channels[0].stream_profile_id}`,
|
||||
// // }),
|
||||
// // ...(sameChannelGroup && {
|
||||
// // channel_group_id: `${channels[0].channel_group_id}`,
|
||||
// // }),
|
||||
// // ...(sameUserLevel && {
|
||||
// // user_level: `${channels[0].user_level}`,
|
||||
// // }),
|
||||
// // });
|
||||
// }, [channelIds, streamProfiles, channelGroups]);
|
||||
|
||||
const handleChannelGroupModalClose = (newGroup) => {
|
||||
setChannelGroupModalOpen(false);
|
||||
|
||||
if (newGroup && newGroup.id) {
|
||||
setSelectedChannelGroup(newGroup.id);
|
||||
form.setValues({
|
||||
channel_group: `${newGroup.name}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const filteredGroups = groupOptions.filter((group) =>
|
||||
group.name.toLowerCase().includes(groupFilter.toLowerCase())
|
||||
);
|
||||
|
||||
if (!isOpen) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
opened={isOpen}
|
||||
onClose={onClose}
|
||||
size="xs"
|
||||
title={
|
||||
<Group gap="5">
|
||||
<ListOrdered size="20" />
|
||||
<Text>Channels</Text>
|
||||
</Group>
|
||||
}
|
||||
styles={{ hannontent: { '--mantine-color-body': '#27272A' } }}
|
||||
>
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<Group justify="space-between" align="top">
|
||||
<Stack gap="5" style={{ flex: 1 }}>
|
||||
<Popover
|
||||
opened={groupPopoverOpened}
|
||||
onChange={setGroupPopoverOpened}
|
||||
// position="bottom-start"
|
||||
withArrow
|
||||
>
|
||||
<Popover.Target>
|
||||
<Group style={{ width: '100%' }} align="flex-end">
|
||||
<TextInput
|
||||
id="channel_group"
|
||||
name="channel_group"
|
||||
label="Channel Group"
|
||||
readOnly
|
||||
{...form.getInputProps('channel_group')}
|
||||
key={form.key('channel_group')}
|
||||
onClick={() => setGroupPopoverOpened(true)}
|
||||
size="xs"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
|
||||
<ActionIcon
|
||||
color={theme.tailwind.green[5]}
|
||||
onClick={() => setChannelGroupModalOpen(true)}
|
||||
title="Create new group"
|
||||
size="small"
|
||||
variant="transparent"
|
||||
style={{ marginBottom: 5 }}
|
||||
>
|
||||
<SquarePlus size="20" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Popover.Target>
|
||||
|
||||
<Popover.Dropdown onMouseDown={(e) => e.stopPropagation()}>
|
||||
<Group style={{ width: '100%' }} spacing="xs">
|
||||
<TextInput
|
||||
placeholder="Filter"
|
||||
value={groupFilter}
|
||||
onChange={(event) =>
|
||||
setGroupFilter(event.currentTarget.value)
|
||||
}
|
||||
mb="xs"
|
||||
size="xs"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
|
||||
<ActionIcon
|
||||
color={theme.tailwind.green[5]}
|
||||
onClick={() => setChannelGroupModalOpen(true)}
|
||||
title="Create new group"
|
||||
size="small"
|
||||
variant="transparent"
|
||||
style={{ marginBottom: 5 }}
|
||||
>
|
||||
<SquarePlus size="20" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
|
||||
<ScrollArea style={{ height: 200 }}>
|
||||
<List
|
||||
height={200} // Set max height for visible items
|
||||
itemCount={filteredGroups.length}
|
||||
itemSize={20} // Adjust row height for each item
|
||||
width={200}
|
||||
ref={groupListRef}
|
||||
>
|
||||
{({ index, style }) => (
|
||||
<Box
|
||||
style={{ ...style, height: 20, overflow: 'hidden' }}
|
||||
>
|
||||
<Tooltip
|
||||
openDelay={500}
|
||||
label={filteredGroups[index].name}
|
||||
size="xs"
|
||||
>
|
||||
<UnstyledButton
|
||||
onClick={() => {
|
||||
setSelectedChannelGroup(
|
||||
filteredGroups[index].id
|
||||
);
|
||||
form.setValues({
|
||||
channel_group: filteredGroups[index].name,
|
||||
});
|
||||
setGroupPopoverOpened(false);
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
size="xs"
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{filteredGroups[index].name}
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
)}
|
||||
</List>
|
||||
</ScrollArea>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
|
||||
<Select
|
||||
id="stream_profile_id"
|
||||
label="Stream Profile"
|
||||
name="stream_profile_id"
|
||||
{...form.getInputProps('stream_profile_id')}
|
||||
key={form.key('stream_profile_id')}
|
||||
data={[{ value: '0', label: '(use default)' }].concat(
|
||||
streamProfiles.map((option) => ({
|
||||
value: `${option.id}`,
|
||||
label: option.name,
|
||||
}))
|
||||
)}
|
||||
size="xs"
|
||||
/>
|
||||
|
||||
<Select
|
||||
size="xs"
|
||||
label="User Level Access"
|
||||
{...form.getInputProps('user_level')}
|
||||
key={form.key('user_level')}
|
||||
data={[
|
||||
{
|
||||
value: '-1',
|
||||
label: '(no change)',
|
||||
},
|
||||
].concat(
|
||||
Object.entries(USER_LEVELS).map(([label, value]) => {
|
||||
return {
|
||||
label: USER_LEVEL_LABELS[value],
|
||||
value: `${value}`,
|
||||
};
|
||||
})
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
<Button type="submit" variant="default" disabled={form.submitting}>
|
||||
Submit
|
||||
</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<ChannelGroupForm
|
||||
isOpen={channelGroupModelOpen}
|
||||
onClose={handleChannelGroupModalClose}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelBatchForm;
|
||||
|
|
@ -20,12 +20,13 @@ import {
|
|||
MultiSelect,
|
||||
} from '@mantine/core';
|
||||
import { isNotEmpty, useForm } from '@mantine/form';
|
||||
import useUsersStore from '../../store/users';
|
||||
import useChannelsStore from '../../store/channels';
|
||||
import { USER_LEVELS, USER_LEVEL_LABELS } from '../../constants';
|
||||
import useAuthStore from '../../store/auth';
|
||||
|
||||
const User = ({ user = null, isOpen, onClose }) => {
|
||||
const profiles = useChannelsStore((s) => s.profiles);
|
||||
const authUser = useAuthStore((s) => s.user);
|
||||
|
||||
console.log(user);
|
||||
|
||||
|
|
@ -77,7 +78,7 @@ const User = ({ user = null, isOpen, onClose }) => {
|
|||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
if (user?.id) {
|
||||
form.setValues({
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
|
|
@ -93,10 +94,16 @@ const User = ({ user = null, isOpen, onClose }) => {
|
|||
return <></>;
|
||||
}
|
||||
|
||||
console.log(user);
|
||||
const showPermissions =
|
||||
authUser.user_level == USER_LEVELS.ADMIN && authUser.id !== user?.id;
|
||||
|
||||
return (
|
||||
<Modal opened={isOpen} onClose={onClose} title="User" size="xl">
|
||||
<Modal
|
||||
opened={isOpen}
|
||||
onClose={onClose}
|
||||
title="User"
|
||||
size={showPermissions ? 'xl' : 'md'}
|
||||
>
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<Group justify="space-between" align="top">
|
||||
<Stack gap="xs" style={{ flex: 1 }}>
|
||||
|
|
@ -123,31 +130,33 @@ const User = ({ user = null, isOpen, onClose }) => {
|
|||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="xs" style={{ flex: 1 }}>
|
||||
<Select
|
||||
label="User Level"
|
||||
data={Object.entries(USER_LEVELS).map(([label, value]) => {
|
||||
return {
|
||||
label: USER_LEVEL_LABELS[value],
|
||||
value: `${value}`,
|
||||
};
|
||||
})}
|
||||
{...form.getInputProps('user_level')}
|
||||
key={form.key('user_level')}
|
||||
/>
|
||||
{showPermissions && (
|
||||
<Stack gap="xs" style={{ flex: 1 }}>
|
||||
<Select
|
||||
label="User Level"
|
||||
data={Object.entries(USER_LEVELS).map(([label, value]) => {
|
||||
return {
|
||||
label: USER_LEVEL_LABELS[value],
|
||||
value: `${value}`,
|
||||
};
|
||||
})}
|
||||
{...form.getInputProps('user_level')}
|
||||
key={form.key('user_level')}
|
||||
/>
|
||||
|
||||
<MultiSelect
|
||||
label="Channel Profiles"
|
||||
{...form.getInputProps('channel_profiles')}
|
||||
key={form.key('channel_profiles')}
|
||||
data={Object.values(profiles)
|
||||
.filter((profile) => profile.id != 0)
|
||||
.map((profile) => ({
|
||||
label: profile.name,
|
||||
value: `${profile.id}`,
|
||||
}))}
|
||||
/>
|
||||
</Stack>
|
||||
<MultiSelect
|
||||
label="Channel Profiles"
|
||||
{...form.getInputProps('channel_profiles')}
|
||||
key={form.key('channel_profiles')}
|
||||
data={Object.values(profiles)
|
||||
.filter((profile) => profile.id != 0)
|
||||
.map((profile) => ({
|
||||
label: profile.name,
|
||||
value: `${profile.id}`,
|
||||
}))}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import useChannelsStore from '../../store/channels';
|
|||
import { notifications } from '@mantine/notifications';
|
||||
import API from '../../api';
|
||||
import ChannelForm from '../forms/Channel';
|
||||
import ChannelBatchForm from '../forms/ChannelBatch';
|
||||
import RecordingForm from '../forms/Recording';
|
||||
import { useDebounce, copyToClipboard } from '../../utils';
|
||||
import logo from '../../images/logo.png';
|
||||
|
|
@ -268,6 +269,7 @@ const ChannelsTable = ({}) => {
|
|||
*/
|
||||
const [channel, setChannel] = useState(null);
|
||||
const [channelModalOpen, setChannelModalOpen] = useState(false);
|
||||
const [channelBatchModalOpen, setChannelBatchModalOpen] = useState(false);
|
||||
const [recordingModalOpen, setRecordingModalOpen] = useState(false);
|
||||
const [selectedProfile, setSelectedProfile] = useState(
|
||||
profiles[selectedProfileId]
|
||||
|
|
@ -365,8 +367,12 @@ const ChannelsTable = ({}) => {
|
|||
};
|
||||
|
||||
const editChannel = async (ch = null) => {
|
||||
setChannel(ch);
|
||||
setChannelModalOpen(true);
|
||||
if (selectedChannelIds.length > 0) {
|
||||
setChannelBatchModalOpen(true);
|
||||
} else {
|
||||
setChannel(ch);
|
||||
setChannelModalOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteChannel = async (id) => {
|
||||
|
|
@ -480,6 +486,10 @@ const ChannelsTable = ({}) => {
|
|||
});
|
||||
};
|
||||
|
||||
const closeChannelBatchForm = () => {
|
||||
setChannelBatchModalOpen(false);
|
||||
};
|
||||
|
||||
const closeChannelForm = () => {
|
||||
setChannel(null);
|
||||
setChannelModalOpen(false);
|
||||
|
|
@ -1041,6 +1051,12 @@ const ChannelsTable = ({}) => {
|
|||
onClose={closeChannelForm}
|
||||
/>
|
||||
|
||||
<ChannelBatchForm
|
||||
channelIds={selectedChannelIds}
|
||||
isOpen={channelBatchModalOpen}
|
||||
onClose={closeChannelBatchForm}
|
||||
/>
|
||||
|
||||
<RecordingForm
|
||||
channel={channel}
|
||||
isOpen={recordingModalOpen}
|
||||
|
|
|
|||
|
|
@ -5,12 +5,14 @@ import {
|
|||
Button,
|
||||
Flex,
|
||||
Group,
|
||||
Menu,
|
||||
NumberInput,
|
||||
Popover,
|
||||
Select,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
|
|
@ -18,7 +20,10 @@ import {
|
|||
Binary,
|
||||
Check,
|
||||
CircleCheck,
|
||||
Ellipsis,
|
||||
EllipsisVertical,
|
||||
SquareMinus,
|
||||
SquarePen,
|
||||
SquarePlus,
|
||||
} from 'lucide-react';
|
||||
import API from '../../../api';
|
||||
|
|
@ -26,6 +31,7 @@ import { notifications } from '@mantine/notifications';
|
|||
import useChannelsStore from '../../../store/channels';
|
||||
import useAuthStore from '../../../store/auth';
|
||||
import { USER_LEVELS } from '../../../constants';
|
||||
import AssignChannelNumbersForm from '../../forms/AssignChannelNumbers';
|
||||
|
||||
const CreateProfilePopover = React.memo(() => {
|
||||
const [opened, setOpened] = useState(false);
|
||||
|
|
@ -96,12 +102,17 @@ const ChannelTableHeader = ({
|
|||
const theme = useMantineTheme();
|
||||
|
||||
const [channelNumAssignmentStart, setChannelNumAssignmentStart] = useState(1);
|
||||
const [assignNumbersModalOpen, setAssignNumbersModalOpen] = useState(false);
|
||||
|
||||
const profiles = useChannelsStore((s) => s.profiles);
|
||||
const selectedProfileId = useChannelsStore((s) => s.selectedProfileId);
|
||||
const setSelectedProfileId = useChannelsStore((s) => s.setSelectedProfileId);
|
||||
const authUser = useAuthStore((s) => s.user);
|
||||
|
||||
const closeAssignChannelNumbersModal = () => {
|
||||
setAssignNumbersModalOpen(false);
|
||||
};
|
||||
|
||||
const deleteProfile = async (id) => {
|
||||
await API.deleteChannelProfile(id);
|
||||
};
|
||||
|
|
@ -195,6 +206,19 @@ const ChannelTableHeader = ({
|
|||
}}
|
||||
>
|
||||
<Flex gap={6}>
|
||||
<Button
|
||||
leftSection={<SquarePen size={18} />}
|
||||
variant="default"
|
||||
size="xs"
|
||||
onClick={editChannel}
|
||||
disabled={
|
||||
selectedTableIds.length == 0 ||
|
||||
authUser.user_level != USER_LEVELS.ADMIN
|
||||
}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
leftSection={<SquareMinus size={18} />}
|
||||
variant="default"
|
||||
|
|
@ -205,60 +229,9 @@ const ChannelTableHeader = ({
|
|||
authUser.user_level != USER_LEVELS.ADMIN
|
||||
}
|
||||
>
|
||||
Remove
|
||||
Delete
|
||||
</Button>
|
||||
|
||||
<Tooltip label="Assign Channel #s">
|
||||
<Popover withArrow shadow="md">
|
||||
<Popover.Target>
|
||||
<Button
|
||||
leftSection={<ArrowDown01 size={18} />}
|
||||
variant="default"
|
||||
size="xs"
|
||||
p={5}
|
||||
disabled={
|
||||
selectedTableIds.length == 0 ||
|
||||
authUser.user_level != USER_LEVELS.ADMIN
|
||||
}
|
||||
>
|
||||
Assign
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Group>
|
||||
<Text>Start #</Text>
|
||||
<NumberInput
|
||||
value={channelNumAssignmentStart}
|
||||
onChange={setChannelNumAssignmentStart}
|
||||
size="small"
|
||||
style={{ width: 50 }}
|
||||
/>
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
color={theme.tailwind.green[5]}
|
||||
variant="transparent"
|
||||
onClick={assignChannels}
|
||||
>
|
||||
<Check />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Auto-Match EPG">
|
||||
<Button
|
||||
leftSection={<Binary size={18} />}
|
||||
variant="default"
|
||||
size="xs"
|
||||
onClick={matchEpg}
|
||||
p={5}
|
||||
disabled={authUser.user_level != USER_LEVELS.ADMIN}
|
||||
>
|
||||
Auto-Match
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Button
|
||||
leftSection={<SquarePlus size={18} />}
|
||||
variant="light"
|
||||
|
|
@ -277,8 +250,48 @@ const ChannelTableHeader = ({
|
|||
>
|
||||
Add
|
||||
</Button>
|
||||
|
||||
<Menu>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="default" size={30}>
|
||||
<EllipsisVertical size={18} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<ArrowDown01 size={18} />}
|
||||
disabled={
|
||||
selectedTableIds.length == 0 ||
|
||||
authUser.user_level != USER_LEVELS.ADMIN
|
||||
}
|
||||
>
|
||||
<UnstyledButton
|
||||
size="xs"
|
||||
onClick={() => setAssignNumbersModalOpen(true)}
|
||||
>
|
||||
<Text size="xs">Assign #s</Text>
|
||||
</UnstyledButton>
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<Binary size={18} />}
|
||||
disabled={authUser.user_level != USER_LEVELS.ADMIN}
|
||||
>
|
||||
<UnstyledButton size="xs" onClick={matchEpg}>
|
||||
<Text size="xs">Auto-Match</Text>
|
||||
</UnstyledButton>
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
<AssignChannelNumbersForm
|
||||
channelIds={selectedTableIds}
|
||||
isOpen={assignNumbersModalOpen}
|
||||
onClose={closeAssignChannelNumbersModal}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,9 +5,12 @@ import {
|
|||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Divider,
|
||||
Group,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import { SquareMinus, SquarePen, SquarePlus } from 'lucide-react';
|
||||
|
|
@ -46,63 +49,74 @@ const UsersPage = () => {
|
|||
<Center>
|
||||
<Paper
|
||||
style={{
|
||||
minWidth: 400,
|
||||
minWidth: 600,
|
||||
padding: 10,
|
||||
margin: 20,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
leftSection={<SquarePlus size={18} />}
|
||||
variant="light"
|
||||
size="xs"
|
||||
onClick={() => editUser()}
|
||||
p={5}
|
||||
color="green"
|
||||
style={{
|
||||
borderWidth: '1px',
|
||||
borderColor: 'green',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
Add User
|
||||
</Button>
|
||||
{Object.values(users)
|
||||
.sort((a, b) => a.id > b.id)
|
||||
.map((user) => {
|
||||
return (
|
||||
<Group justify="space-between">
|
||||
<Box flex={1} style={{ alignContent: 'flex-start' }}>
|
||||
{user.username}
|
||||
</Box>
|
||||
<Stack>
|
||||
<Box>
|
||||
<Button
|
||||
leftSection={<SquarePlus size={18} />}
|
||||
variant="light"
|
||||
size="xs"
|
||||
onClick={() => editUser()}
|
||||
p={5}
|
||||
color="green"
|
||||
style={{
|
||||
borderWidth: '1px',
|
||||
borderColor: 'green',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
Add User
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box flex={1} style={{ alignContent: 'flex-start' }}>
|
||||
{user.email}
|
||||
</Box>
|
||||
{users
|
||||
.sort((a, b) => a.id > b.id)
|
||||
.map((user) => {
|
||||
if (!user) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
{authUser.user_level == USER_LEVELS.ADMIN && (
|
||||
<Group>
|
||||
<ActionIcon
|
||||
size={18}
|
||||
variant="transparent"
|
||||
color={theme.tailwind.yellow[3]}
|
||||
onClick={() => editUser(user)}
|
||||
>
|
||||
<SquarePen size="18" />
|
||||
</ActionIcon>
|
||||
return (
|
||||
<Group justify="space-between">
|
||||
<Box flex={1} style={{ alignContent: 'flex-start' }}>
|
||||
{user.username}
|
||||
</Box>
|
||||
|
||||
<ActionIcon
|
||||
size={18}
|
||||
variant="transparent"
|
||||
color={theme.tailwind.red[6]}
|
||||
onClick={() => deleteUser(user.id)}
|
||||
disabled={authUser.id === user.id}
|
||||
>
|
||||
<SquareMinus size="18" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
<Box flex={1} style={{ alignContent: 'flex-start' }}>
|
||||
{user.email}
|
||||
</Box>
|
||||
|
||||
{authUser.user_level == USER_LEVELS.ADMIN && (
|
||||
<Group>
|
||||
<Text>{USER_LEVEL_LABELS[user.user_level]}</Text>
|
||||
<ActionIcon
|
||||
size={18}
|
||||
variant="transparent"
|
||||
color={theme.tailwind.yellow[3]}
|
||||
onClick={() => editUser(user)}
|
||||
>
|
||||
<SquarePen size="18" />
|
||||
</ActionIcon>
|
||||
|
||||
<ActionIcon
|
||||
size={18}
|
||||
variant="transparent"
|
||||
color={theme.tailwind.red[6]}
|
||||
onClick={() => deleteUser(user.id)}
|
||||
disabled={authUser.id === user.id}
|
||||
>
|
||||
<SquareMinus size="18" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Center>
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { create } from 'zustand';
|
|||
import api from '../api';
|
||||
|
||||
const useUsersStore = create((set) => ({
|
||||
users: {},
|
||||
users: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue