Enhancement: Redesigned the User settings modal with a tabbed layout

This commit is contained in:
SergeantPanda 2026-04-12 16:39:35 -05:00
parent ba967f47d9
commit 123509eb9d
2 changed files with 177 additions and 181 deletions

View file

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

View file

@ -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 &amp; 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>
);