mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-17 16:50:53 +00:00
feat(channel-form): reorganize channel edit form into three semantic columns for improved layout and usability
This commit is contained in:
parent
f2a4c1f665
commit
1fd37b9a51
2 changed files with 144 additions and 135 deletions
|
|
@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
### Changed
|
||||
|
||||
- **Channel edit form reorganized into three semantic columns.** Fields are now grouped as Identity (name, number, group, logo), Guide Data (TVG-ID, Gracenote StationId, EPG picker, current program preview), and Behavior/Access (stream profile, user level, mature content, hidden). The EPG section having its own column gives the `ProgramPreview` card enough width to truncate long titles correctly rather than expanding the column.
|
||||
- **IP lookup result delivered via WebSocket push.** When the background lookup completes, an `ip_lookup_complete` event is pushed to all connected clients so the sidebar IP field populates without polling. A `Skeleton` placeholder is shown while the lookup is in progress.
|
||||
- **`get_host_and_port` and `build_absolute_uri_with_port` moved from `apps/output/views.py` to `core/utils.py`.** Both helpers have no dependencies on anything in `apps/output` and are now used in `apps/channels/serializers.py` as well. Moving them to `core/utils` eliminates the need for a local import inside `LogoSerializer` and makes them available to the rest of the codebase without circular-import risk. `LogoSerializer.get_cache_url()` was also updated to use `build_absolute_uri_with_port` instead of `request.build_absolute_uri()`, so logo cache URLs now correctly include non-standard ports (fixing port-stripping for logo URLs behind reverse proxies, matching the existing fix applied to M3U and EPG URLs).
|
||||
- **`debian_install.sh` switched from Gunicorn to uWSGI with gevent workers.** The Debian/LXC bare-metal installer now deploys Dispatcharr under the same uWSGI + gevent stack used by the Docker image, eliminating a class of compatibility differences between the two deployment paths. The installer writes a `uwsgi-debian.ini` next to the app and manages uWSGI via a systemd service. The nginx site config now uses `uwsgi_pass` + `include uwsgi_params` instead of `proxy_pass`, which correctly populates `SERVER_PORT` in the WSGI environ so M3U playlist URLs include the configured port number (fixing the port-stripping bug from #1267 for bare-metal installs). Python 3.13 is now provisioned through uv's managed runtime so the install works on Debian 12 and Ubuntu 24.04 LTS regardless of the system Python version.
|
||||
|
|
|
|||
|
|
@ -528,7 +528,8 @@ const ChannelForm = ({ channel: channelProp = null, isOpen, onClose }) => {
|
|||
</Text>
|
||||
)}
|
||||
<Group justify="space-between" align="top">
|
||||
<Stack gap="5" style={{ flex: 1 }}>
|
||||
{/* Col 1: Identity - Channel Name, Number, Group, Logo */}
|
||||
<Stack gap="5" style={{ flex: 1, minWidth: 0 }}>
|
||||
<TextInput
|
||||
id="name"
|
||||
name="name"
|
||||
|
|
@ -568,6 +569,33 @@ const ChannelForm = ({ channel: channelProp = null, isOpen, onClose }) => {
|
|||
style={{ flex: 1 }}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
id="channel_number"
|
||||
name="channel_number"
|
||||
label="Channel # (blank to auto-assign)"
|
||||
description={
|
||||
<ProviderHintRow
|
||||
channel={channel}
|
||||
field="channel_number"
|
||||
formValue={watch('channel_number')}
|
||||
hintText={getProviderHint(channel, 'channel_number')}
|
||||
onReset={() =>
|
||||
setValue(
|
||||
'channel_number',
|
||||
getProviderFormValue(channel, 'channel_number'),
|
||||
{ shouldDirty: true }
|
||||
)
|
||||
}
|
||||
/>
|
||||
}
|
||||
value={watch('channel_number')}
|
||||
onChange={(value) => setValue('channel_number', value)}
|
||||
error={errors.channel_number?.message}
|
||||
size="xs"
|
||||
step={0.1}
|
||||
precision={1}
|
||||
/>
|
||||
|
||||
<Flex gap="sm">
|
||||
<Popover
|
||||
opened={groupPopoverOpened}
|
||||
|
|
@ -681,64 +709,6 @@ const ChannelForm = ({ channel: channelProp = null, isOpen, onClose }) => {
|
|||
</Flex>
|
||||
</Flex>
|
||||
|
||||
<Select
|
||||
id="stream_profile_id"
|
||||
label="Stream Profile"
|
||||
name="stream_profile_id"
|
||||
description={
|
||||
<ProviderHintRow
|
||||
channel={channel}
|
||||
field="stream_profile_id"
|
||||
formValue={watch('stream_profile_id')}
|
||||
hintText={getFkProviderHint(
|
||||
channel,
|
||||
'stream_profile_id',
|
||||
streamProfiles.reduce((acc, p) => {
|
||||
acc[p.id] = p;
|
||||
return acc;
|
||||
}, {})
|
||||
)}
|
||||
onReset={() =>
|
||||
setValue(
|
||||
'stream_profile_id',
|
||||
getProviderFormValue(channel, 'stream_profile_id')
|
||||
)
|
||||
}
|
||||
/>
|
||||
}
|
||||
value={watch('stream_profile_id')}
|
||||
onChange={(value) => {
|
||||
setValue('stream_profile_id', value);
|
||||
}}
|
||||
error={errors.stream_profile_id?.message}
|
||||
data={[{ value: '0', label: '(use default)' }].concat(
|
||||
streamProfiles.map((option) => ({
|
||||
value: `${option.id}`,
|
||||
label: option.name,
|
||||
}))
|
||||
)}
|
||||
size="xs"
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="User Level Access"
|
||||
data={Object.entries(USER_LEVELS).map(([, value]) => {
|
||||
return {
|
||||
label: USER_LEVEL_LABELS[value],
|
||||
value: `${value}`,
|
||||
};
|
||||
})}
|
||||
value={watch('user_level')}
|
||||
onChange={(value) => {
|
||||
setValue('user_level', value);
|
||||
}}
|
||||
error={errors.user_level?.message}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Divider size="sm" orientation="vertical" />
|
||||
|
||||
<Stack justify="flex-start" style={{ flex: 1 }}>
|
||||
<Group justify="space-between">
|
||||
<Popover
|
||||
opened={logoPopoverOpened}
|
||||
|
|
@ -915,85 +885,16 @@ const ChannelForm = ({ channel: channelProp = null, isOpen, onClose }) => {
|
|||
>
|
||||
Upload or Create Logo
|
||||
</Button>
|
||||
<Tooltip label="Mark as mature/adult content (18+)" withArrow>
|
||||
<Box>
|
||||
<Switch
|
||||
label="Mature Content"
|
||||
checked={watch('is_adult')}
|
||||
onChange={(event) =>
|
||||
setValue('is_adult', event.currentTarget.checked)
|
||||
}
|
||||
size="md"
|
||||
/>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
label="Hides this channel from HDHR, M3U, EPG, and XC client output and preserves it from auto-cleanup. To hide channels per-user, use channel profiles instead."
|
||||
withArrow
|
||||
multiline
|
||||
w={320}
|
||||
>
|
||||
<Box>
|
||||
<Switch
|
||||
label="Hidden"
|
||||
checked={watch('hidden_from_output')}
|
||||
onChange={(event) =>
|
||||
setValue(
|
||||
'hidden_from_output',
|
||||
event.currentTarget.checked
|
||||
)
|
||||
}
|
||||
size="md"
|
||||
/>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
{channel?.auto_created && hasAnyOverride && (
|
||||
<Tooltip
|
||||
label={`Currently overriding: ${overriddenFieldLabels.join(', ')}. Clear all overrides to follow the provider values again on the next refresh.`}
|
||||
withArrow
|
||||
>
|
||||
<Button
|
||||
variant="light"
|
||||
color="orange"
|
||||
size="xs"
|
||||
onClick={clearOverrides}
|
||||
>
|
||||
Clear All Overrides ({overriddenFieldLabels.length})
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Divider size="sm" orientation="vertical" />
|
||||
|
||||
<Stack gap="5" style={{ flex: 1 }} justify="flex-start">
|
||||
<NumberInput
|
||||
id="channel_number"
|
||||
name="channel_number"
|
||||
label="Channel # (blank to auto-assign)"
|
||||
description={
|
||||
<ProviderHintRow
|
||||
channel={channel}
|
||||
field="channel_number"
|
||||
formValue={watch('channel_number')}
|
||||
hintText={getProviderHint(channel, 'channel_number')}
|
||||
onReset={() =>
|
||||
setValue(
|
||||
'channel_number',
|
||||
getProviderFormValue(channel, 'channel_number'),
|
||||
{ shouldDirty: true }
|
||||
)
|
||||
}
|
||||
/>
|
||||
}
|
||||
value={watch('channel_number')}
|
||||
onChange={(value) => setValue('channel_number', value)}
|
||||
error={errors.channel_number?.message}
|
||||
size="xs"
|
||||
step={0.1} // Add step prop to allow decimal inputs
|
||||
precision={1} // Specify decimal precision
|
||||
/>
|
||||
|
||||
{/* Col 2: Guide Data - TVG-ID, Gracenote StationId, EPG, Program Preview */}
|
||||
<Stack
|
||||
gap="5"
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
justify="flex-start"
|
||||
>
|
||||
<TextInput
|
||||
id="tvg_id"
|
||||
name="tvg_id"
|
||||
|
|
@ -1227,7 +1128,7 @@ const ChannelForm = ({ channel: channelProp = null, isOpen, onClose }) => {
|
|||
</Popover>
|
||||
|
||||
{(isLoadingProgram || hasFetchedProgram || currentProgram) && (
|
||||
<Box mt="xs" p="xs" style={{ backgroundColor: '#1a1a1c', borderRadius: '4px' }}>
|
||||
<Box mt="xs" p="xs">
|
||||
<ProgramPreview
|
||||
program={currentProgram}
|
||||
loading={isLoadingProgram}
|
||||
|
|
@ -1237,6 +1138,113 @@ const ChannelForm = ({ channel: channelProp = null, isOpen, onClose }) => {
|
|||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Divider size="sm" orientation="vertical" />
|
||||
|
||||
{/* Col 3: Behavior/Access - Stream Profile, User Level, Mature Content, Hidden */}
|
||||
<Stack justify="flex-start" style={{ flex: 1, minWidth: 0 }}>
|
||||
<Select
|
||||
id="stream_profile_id"
|
||||
label="Stream Profile"
|
||||
name="stream_profile_id"
|
||||
description={
|
||||
<ProviderHintRow
|
||||
channel={channel}
|
||||
field="stream_profile_id"
|
||||
formValue={watch('stream_profile_id')}
|
||||
hintText={getFkProviderHint(
|
||||
channel,
|
||||
'stream_profile_id',
|
||||
streamProfiles.reduce((acc, p) => {
|
||||
acc[p.id] = p;
|
||||
return acc;
|
||||
}, {})
|
||||
)}
|
||||
onReset={() =>
|
||||
setValue(
|
||||
'stream_profile_id',
|
||||
getProviderFormValue(channel, 'stream_profile_id')
|
||||
)
|
||||
}
|
||||
/>
|
||||
}
|
||||
value={watch('stream_profile_id')}
|
||||
onChange={(value) => {
|
||||
setValue('stream_profile_id', value);
|
||||
}}
|
||||
error={errors.stream_profile_id?.message}
|
||||
data={[{ value: '0', label: '(use default)' }].concat(
|
||||
streamProfiles.map((option) => ({
|
||||
value: `${option.id}`,
|
||||
label: option.name,
|
||||
}))
|
||||
)}
|
||||
size="xs"
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="User Level Access"
|
||||
data={Object.entries(USER_LEVELS).map(([, value]) => {
|
||||
return {
|
||||
label: USER_LEVEL_LABELS[value],
|
||||
value: `${value}`,
|
||||
};
|
||||
})}
|
||||
value={watch('user_level')}
|
||||
onChange={(value) => {
|
||||
setValue('user_level', value);
|
||||
}}
|
||||
error={errors.user_level?.message}
|
||||
/>
|
||||
|
||||
<Tooltip label="Mark as mature/adult content (18+)" withArrow>
|
||||
<Box>
|
||||
<Switch
|
||||
label="Mature Content"
|
||||
checked={watch('is_adult')}
|
||||
onChange={(event) =>
|
||||
setValue('is_adult', event.currentTarget.checked)
|
||||
}
|
||||
size="md"
|
||||
/>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
label="Hides this channel from HDHR, M3U, EPG, and XC client output and preserves it from auto-cleanup. To hide channels per-user, use channel profiles instead."
|
||||
withArrow
|
||||
multiline
|
||||
w={320}
|
||||
>
|
||||
<Box>
|
||||
<Switch
|
||||
label="Hidden"
|
||||
checked={watch('hidden_from_output')}
|
||||
onChange={(event) =>
|
||||
setValue(
|
||||
'hidden_from_output',
|
||||
event.currentTarget.checked
|
||||
)
|
||||
}
|
||||
size="md"
|
||||
/>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
{channel?.auto_created && hasAnyOverride && (
|
||||
<Tooltip
|
||||
label={`Currently overriding: ${overriddenFieldLabels.join(', ')}. Clear all overrides to follow the provider values again on the next refresh.`}
|
||||
withArrow
|
||||
>
|
||||
<Button
|
||||
variant="light"
|
||||
color="orange"
|
||||
size="xs"
|
||||
onClick={clearOverrides}
|
||||
>
|
||||
Clear All Overrides ({overriddenFieldLabels.length})
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue