mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 00:55:50 +00:00
Enhancement: Redesigned the User settings modal with a tabbed layout
This commit is contained in:
parent
ba967f47d9
commit
123509eb9d
2 changed files with 177 additions and 181 deletions
|
|
@ -64,6 +64,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
### Changed
|
||||
|
||||
- Redesigned the User settings modal with a tabbed layout: **Account** (username, email, name, password), **Permissions** (user level, stream limit, channel profiles, mature content filter - admin only), **EPG Defaults** (days forward/back), and **API & XC** (XC password, API key management). Fields are now logically grouped rather than split across two ad-hoc columns.
|
||||
- EPG channel scanning now automatically removes stale `EPGData` entries. tvg-ids that were present in a previous scan but are no longer found in the upstream source, provided they are not mapped to any channel. This prevents unbounded database bloat over time. Entries mapped to at least one channel are always preserved.
|
||||
- Rewrote the M3U line parser as an `iter_m3u_entries` generator that owns the full per-entry state machine. Intermediate directive lines between `#EXTINF` and the stream URL are now handled correctly rather than corrupting the pending entry or being silently misassigned. A `#EXTINF` with no following URL is discarded with a warning instead of carrying over a `url`-less entry into batch processing. Attribute keys are normalised to lowercase during parsing (provider attribute names remain case-insensitive end-to-end). The `#EXTINF` attribute regex is pre-compiled at module load, and attribute lookups use O(1) `dict.get()` instead of linear scans — approximately 10% faster parsing on large M3U files.
|
||||
- Added support for the `#EXTGRP` directive in M3U files. When a `group-title` attribute is absent from the `#EXTINF` line, the value from a following `#EXTGRP:` line is used as the group. An explicit `group-title` attribute always takes priority. (Closes #1088)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
// Modal.js
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import API from '../../api';
|
||||
import {
|
||||
TextInput,
|
||||
Button,
|
||||
Modal,
|
||||
Flex,
|
||||
Select,
|
||||
PasswordInput,
|
||||
Group,
|
||||
|
|
@ -13,14 +11,12 @@ import {
|
|||
MultiSelect,
|
||||
ActionIcon,
|
||||
Switch,
|
||||
Box,
|
||||
Tooltip,
|
||||
Grid,
|
||||
SimpleGrid,
|
||||
NumberInput,
|
||||
Tabs,
|
||||
Text,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import { RotateCcwKey, RotateCw, X } from 'lucide-react';
|
||||
import { RotateCcwKey, X } from 'lucide-react';
|
||||
import { Copy, Key } from 'lucide-react';
|
||||
import { useForm } from '@mantine/form';
|
||||
import useChannelsStore from '../../store/channels';
|
||||
|
|
@ -247,198 +243,197 @@ const User = ({ user = null, isOpen, onClose }) => {
|
|||
return (
|
||||
<Modal opened={isOpen} onClose={onClose} title="User" size="xl">
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<Group justify="space-between" align="top">
|
||||
<Stack gap="xs" style={{ flex: 1 }}>
|
||||
<TextInput
|
||||
id="username"
|
||||
name="username"
|
||||
label="Username"
|
||||
disabled={!isAdmin}
|
||||
{...form.getInputProps('username')}
|
||||
key={form.key('username')}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
id="first_name"
|
||||
name="first_name"
|
||||
label="First Name"
|
||||
{...form.getInputProps('first_name')}
|
||||
key={form.key('first_name')}
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
description="Used for UI authentication"
|
||||
{...form.getInputProps('password')}
|
||||
key={form.key('password')}
|
||||
disabled={form.getValues().user_level == USER_LEVELS.STREAMER}
|
||||
/>
|
||||
|
||||
<Tabs defaultValue="account">
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab value="account">Account</Tabs.Tab>
|
||||
{showPermissions && (
|
||||
<>
|
||||
<Select
|
||||
label="User Level"
|
||||
data={Object.entries(USER_LEVELS).map(([, value]) => {
|
||||
return {
|
||||
<Tabs.Tab value="permissions">Permissions</Tabs.Tab>
|
||||
)}
|
||||
<Tabs.Tab value="epg">EPG Defaults</Tabs.Tab>
|
||||
<Tabs.Tab value="api">API & XC</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="account">
|
||||
<Stack gap="sm">
|
||||
<Group grow align="flex-start">
|
||||
<TextInput
|
||||
label="Username"
|
||||
disabled={!isAdmin}
|
||||
{...form.getInputProps('username')}
|
||||
key={form.key('username')}
|
||||
/>
|
||||
<TextInput
|
||||
label="E-Mail"
|
||||
{...form.getInputProps('email')}
|
||||
key={form.key('email')}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow align="flex-start">
|
||||
<TextInput
|
||||
label="First Name"
|
||||
{...form.getInputProps('first_name')}
|
||||
key={form.key('first_name')}
|
||||
/>
|
||||
<TextInput
|
||||
label="Last Name"
|
||||
{...form.getInputProps('last_name')}
|
||||
key={form.key('last_name')}
|
||||
/>
|
||||
</Group>
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
description="Used for UI authentication"
|
||||
{...form.getInputProps('password')}
|
||||
key={form.key('password')}
|
||||
disabled={form.getValues().user_level == USER_LEVELS.STREAMER}
|
||||
/>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
{showPermissions && (
|
||||
<Tabs.Panel value="permissions">
|
||||
<Stack gap="sm">
|
||||
<Group grow align="flex-start">
|
||||
<Select
|
||||
label="User Level"
|
||||
data={Object.entries(USER_LEVELS).map(([, value]) => ({
|
||||
label: USER_LEVEL_LABELS[value],
|
||||
value: `${value}`,
|
||||
};
|
||||
}))}
|
||||
{...form.getInputProps('user_level')}
|
||||
key={form.key('user_level')}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Stream Limit (0 = unlimited)"
|
||||
{...form.getInputProps('stream_limit')}
|
||||
key={form.key('stream_limit')}
|
||||
/>
|
||||
</Group>
|
||||
<MultiSelect
|
||||
label="Channel Profiles"
|
||||
{...form.getInputProps('channel_profiles')}
|
||||
key={form.key('channel_profiles')}
|
||||
onChange={onChannelProfilesChange}
|
||||
data={Object.values(profiles).map((profile) => ({
|
||||
label: profile.name,
|
||||
value: `${profile.id}`,
|
||||
}))}
|
||||
/>
|
||||
<Switch
|
||||
label="Hide Mature Content"
|
||||
description="Hide channels marked as mature content (admin users not affected)"
|
||||
{...form.getInputProps('hide_adult_content', {
|
||||
type: 'checkbox',
|
||||
})}
|
||||
{...form.getInputProps('user_level')}
|
||||
key={form.key('user_level')}
|
||||
key={form.key('hide_adult_content')}
|
||||
/>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
)}
|
||||
|
||||
<Tabs.Panel value="epg">
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
These defaults apply when no URL parameters are specified —
|
||||
useful for XC clients that cannot pass custom query parameters.
|
||||
</Text>
|
||||
<Group grow align="flex-start">
|
||||
<NumberInput
|
||||
label="Stream Limit (0 = unlimited)"
|
||||
{...form.getInputProps('stream_limit')}
|
||||
key={form.key('stream_limit')}
|
||||
label="Days forward (0 = all)"
|
||||
description="How many future days of EPG data to include"
|
||||
min={0}
|
||||
max={365}
|
||||
{...form.getInputProps('epg_days')}
|
||||
key={form.key('epg_days')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
<NumberInput
|
||||
label="Days back (0 = none)"
|
||||
description="How many past days of EPG data to include (max 30)"
|
||||
min={0}
|
||||
max={30}
|
||||
{...form.getInputProps('epg_prev_days')}
|
||||
key={form.key('epg_prev_days')}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Stack gap="xs" style={{ flex: 1 }}>
|
||||
<TextInput
|
||||
id="email"
|
||||
name="email"
|
||||
label="E-Mail"
|
||||
{...form.getInputProps('email')}
|
||||
key={form.key('email')}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
id="last_name"
|
||||
name="last_name"
|
||||
label="Last Name"
|
||||
{...form.getInputProps('last_name')}
|
||||
key={form.key('last_name')}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="XC Password"
|
||||
description="Clear to disable XC API"
|
||||
{...form.getInputProps('xc_password')}
|
||||
key={form.key('xc_password')}
|
||||
rightSectionWidth={30}
|
||||
rightSection={
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
size="sm"
|
||||
color="white"
|
||||
onClick={generateXCPassword}
|
||||
>
|
||||
<RotateCcwKey />
|
||||
</ActionIcon>
|
||||
}
|
||||
/>
|
||||
|
||||
{showPermissions && (
|
||||
<MultiSelect
|
||||
label="Channel Profiles"
|
||||
{...form.getInputProps('channel_profiles')}
|
||||
key={form.key('channel_profiles')}
|
||||
onChange={onChannelProfilesChange}
|
||||
data={Object.values(profiles).map((profile) => ({
|
||||
label: profile.name,
|
||||
value: `${profile.id}`,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showPermissions && (
|
||||
<Box>
|
||||
<Tooltip
|
||||
label="Hide channels marked as mature content (admin users not affected)"
|
||||
position="top"
|
||||
withArrow
|
||||
>
|
||||
<Switch
|
||||
label="Hide Mature Content"
|
||||
{...form.getInputProps('hide_adult_content', {
|
||||
type: 'checkbox',
|
||||
})}
|
||||
key={form.key('hide_adult_content')}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<NumberInput
|
||||
label="EPG: Default days forward (0 = all)"
|
||||
description="How many future days of EPG data to include by default"
|
||||
min={0}
|
||||
max={365}
|
||||
{...form.getInputProps('epg_days')}
|
||||
key={form.key('epg_days')}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="EPG: Default days back (0 = none)"
|
||||
description="How many past days of EPG data to include by default (max 30)"
|
||||
min={0}
|
||||
max={30}
|
||||
{...form.getInputProps('epg_prev_days')}
|
||||
key={form.key('epg_prev_days')}
|
||||
/>
|
||||
|
||||
{canGenerateKey && (
|
||||
<Stack>
|
||||
{userAPIKey && (
|
||||
<TextInput
|
||||
label="API Key"
|
||||
disabled={true}
|
||||
value={userAPIKey}
|
||||
rightSection={
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
size="sm"
|
||||
color="white"
|
||||
onClick={() =>
|
||||
copyToClipboard(userAPIKey, {
|
||||
successTitle: 'API Key Copied!',
|
||||
successMessage:
|
||||
'The API Key has been copied to your clipboard.',
|
||||
})
|
||||
}
|
||||
>
|
||||
<Copy />
|
||||
</ActionIcon>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Group gap="xs" grow>
|
||||
<Button
|
||||
leftSection={<Key size={14} />}
|
||||
size="xs"
|
||||
onClick={onGenerateKey}
|
||||
loading={generating}
|
||||
variant="light"
|
||||
fullWidth
|
||||
<Tabs.Panel value="api">
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label="XC Password"
|
||||
description="Clear to disable XC API"
|
||||
{...form.getInputProps('xc_password')}
|
||||
key={form.key('xc_password')}
|
||||
rightSectionWidth={30}
|
||||
rightSection={
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
size="sm"
|
||||
color="white"
|
||||
onClick={generateXCPassword}
|
||||
>
|
||||
{userAPIKey ? 'Regenerate API Key' : 'Generate API Key'}
|
||||
</Button>
|
||||
|
||||
<RotateCcwKey />
|
||||
</ActionIcon>
|
||||
}
|
||||
/>
|
||||
{canGenerateKey && (
|
||||
<Stack gap="xs">
|
||||
{userAPIKey && (
|
||||
<TextInput
|
||||
label="API Key"
|
||||
disabled={true}
|
||||
value={userAPIKey}
|
||||
rightSection={
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
size="sm"
|
||||
color="white"
|
||||
onClick={() =>
|
||||
copyToClipboard(userAPIKey, {
|
||||
successTitle: 'API Key Copied!',
|
||||
successMessage:
|
||||
'The API Key has been copied to your clipboard.',
|
||||
})
|
||||
}
|
||||
>
|
||||
<Copy />
|
||||
</ActionIcon>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Group gap="xs" grow>
|
||||
<Button
|
||||
leftSection={<X size={14} />}
|
||||
leftSection={<Key size={14} />}
|
||||
size="xs"
|
||||
onClick={onRevokeKey}
|
||||
onClick={onGenerateKey}
|
||||
loading={generating}
|
||||
color={theme.colors.red[5]}
|
||||
variant="light"
|
||||
fullWidth
|
||||
>
|
||||
Revoke API Key
|
||||
{userAPIKey ? 'Regenerate API Key' : 'Generate API Key'}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
{userAPIKey && (
|
||||
<Button
|
||||
leftSection={<X size={14} />}
|
||||
size="xs"
|
||||
onClick={onRevokeKey}
|
||||
loading={generating}
|
||||
color={theme.colors.red[5]}
|
||||
variant="light"
|
||||
fullWidth
|
||||
>
|
||||
Revoke API Key
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
|
|
@ -447,7 +442,7 @@ const User = ({ user = null, isOpen, onClose }) => {
|
|||
>
|
||||
Save
|
||||
</Button>
|
||||
</Flex>
|
||||
</Group>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue