feat(channel-form): reorganize channel edit form into three semantic columns for improved layout and usability

This commit is contained in:
SergeantPanda 2026-06-01 19:34:39 -05:00
parent f2a4c1f665
commit 1fd37b9a51
2 changed files with 144 additions and 135 deletions

View file

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

View file

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