feat(web): dedicated channel edit screen (#146) #154
+22
-1
@@ -54,6 +54,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import chicoryMarkUrl from '../../design-system/assets/chicory-mark.svg';
|
||||
import { ChannelBuilderScreen } from './builder/ChannelBuilder';
|
||||
import { ChannelEditScreen } from './screens/ChannelEditScreen';
|
||||
import { SettingsScreen } from './screens/SettingsScreen';
|
||||
import { navigateToPath } from './routing';
|
||||
import {
|
||||
@@ -124,6 +125,7 @@ type ScreenId =
|
||||
| 'dashboard'
|
||||
| 'channels'
|
||||
| 'builder'
|
||||
| 'editChannel'
|
||||
| 'guide'
|
||||
| 'schedules'
|
||||
| 'playouts'
|
||||
@@ -181,6 +183,21 @@ const routes: ScreenRoute[] = [
|
||||
primaryAction: 'Create Channel',
|
||||
placeholder: 'Channel builder workspace'
|
||||
},
|
||||
{
|
||||
// Not in the sidebar nav; reached via the edit pencil on the channel table. The
|
||||
// screen owns parsing the {id} suffix (see ChannelEditScreen), so it opts into
|
||||
// sub-paths like /app/edit-channel/5.
|
||||
id: 'editChannel',
|
||||
path: '/app/edit-channel',
|
||||
label: 'Edit Channel',
|
||||
title: 'Edit Channel',
|
||||
kicker: 'Channel',
|
||||
description: 'Full channel editor: identity, playout, streaming, selection and branding.',
|
||||
icon: <Tv aria-hidden="true" size={16} />,
|
||||
primaryAction: 'Save Changes',
|
||||
placeholder: 'Channel editor workspace',
|
||||
allowSubPaths: true
|
||||
},
|
||||
{
|
||||
id: 'guide',
|
||||
path: '/app/guide',
|
||||
@@ -1213,7 +1230,7 @@ function ChannelTableRow({
|
||||
<IconButton disabled size="sm" title={`Preview unavailable for ${channel.name}`}>
|
||||
<Play aria-hidden="true" size={15} />
|
||||
</IconButton>
|
||||
<IconButton onClick={() => navigateToPath(`/app/new-channel?edit=${channel.id}`)} size="sm" title={`Edit ${channel.name}`}>
|
||||
<IconButton onClick={() => navigateToPath(`/app/edit-channel/${channel.id}`)} size="sm" title={`Edit ${channel.name}`}>
|
||||
<Pencil aria-hidden="true" size={15} />
|
||||
</IconButton>
|
||||
<IconButton disabled size="sm" title={`Troubleshoot ${channel.name}`}>
|
||||
@@ -2841,6 +2858,10 @@ function ScreenContent({
|
||||
return <ChannelBuilderScreen />;
|
||||
}
|
||||
|
||||
if (route.id === 'editChannel') {
|
||||
return <ChannelEditScreen key={window.location.pathname} />;
|
||||
}
|
||||
|
||||
if (route.id === 'guide') {
|
||||
return <GuideScreen />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getChannelById, updateChannel, type UpdateChannelRequest } from './channels';
|
||||
|
||||
const sampleChannel = {
|
||||
id: 5,
|
||||
number: '5',
|
||||
name: 'Cartoons',
|
||||
group: 'ChicoryTV',
|
||||
categories: '',
|
||||
fFmpegProfileId: 1,
|
||||
slugSeconds: null,
|
||||
logo: { path: '', contentType: '' },
|
||||
streamSelectorMode: 'Default',
|
||||
streamSelector: null,
|
||||
preferredAudioLanguageCode: null,
|
||||
preferredAudioTitle: null,
|
||||
playoutSource: 'Generated',
|
||||
playoutMode: 'Continuous',
|
||||
mirrorSourceChannelId: null,
|
||||
playoutOffset: null,
|
||||
streamingMode: 'TransportStreamHybrid',
|
||||
watermarkId: null,
|
||||
fallbackFillerId: null,
|
||||
playoutCount: 0,
|
||||
preferredSubtitleLanguageCode: null,
|
||||
subtitleMode: 'None',
|
||||
musicVideoCreditsMode: 'None',
|
||||
musicVideoCreditsTemplate: null,
|
||||
songVideoMode: 'Default',
|
||||
transcodeMode: 'OnDemand',
|
||||
idleBehavior: 'StopOnDisconnect',
|
||||
isEnabled: true,
|
||||
showInEpg: true
|
||||
};
|
||||
|
||||
describe('getChannelById', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('GETs the channel by id and returns the view model', async () => {
|
||||
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify(sampleChannel), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status: 200
|
||||
})
|
||||
);
|
||||
|
||||
await expect(getChannelById(5)).resolves.toMatchObject({ id: 5, name: 'Cartoons' });
|
||||
|
||||
const [url, init] = fetchSpy.mock.calls[0];
|
||||
expect(url).toBe('/api/channels/5');
|
||||
expect((init?.method ?? 'GET').toUpperCase()).toBe('GET');
|
||||
});
|
||||
|
||||
it('rejects with the ApiError status on 404', async () => {
|
||||
vi.spyOn(window, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify({ status: 404, title: 'Not Found' }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status: 404
|
||||
})
|
||||
);
|
||||
|
||||
await expect(getChannelById(999)).rejects.toMatchObject({ status: 404 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateChannel', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('PUTs the request body to the channel id and returns the updated view model', async () => {
|
||||
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify({ ...sampleChannel, name: 'Renamed' }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status: 200
|
||||
})
|
||||
);
|
||||
|
||||
const body = { ...sampleChannel, name: 'Renamed' } as unknown as UpdateChannelRequest;
|
||||
|
||||
await expect(updateChannel(5, body)).resolves.toMatchObject({ name: 'Renamed' });
|
||||
|
||||
const [url, init] = fetchSpy.mock.calls[0];
|
||||
expect(url).toBe('/api/channels/5');
|
||||
expect((init?.method ?? '').toUpperCase()).toBe('PUT');
|
||||
expect(JSON.parse(init?.body as string)).toMatchObject({ name: 'Renamed', number: '5' });
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,8 @@ import type { components } from './generated/v1';
|
||||
|
||||
export type ChannelSummary = components['schemas']['ChannelResponseModel'];
|
||||
export type ChannelState = components['schemas']['ChannelStateResponseModel'];
|
||||
export type Channel = components['schemas']['ChannelViewModel'];
|
||||
export type UpdateChannelRequest = components['schemas']['UpdateChannelRequest'];
|
||||
export type BulkRenumberChannelsRequest = components['schemas']['BulkRenumberChannelsRequest'];
|
||||
export type BulkMoveChannelsToGroupRequest = components['schemas']['BulkMoveChannelsToGroupRequest'];
|
||||
export type BulkDeleteChannelsRequest = components['schemas']['BulkDeleteChannelsRequest'];
|
||||
@@ -33,6 +35,17 @@ export function getChannelStates(): Promise<ChannelState[]> {
|
||||
return request<ChannelState[]>('/api/channels/state');
|
||||
}
|
||||
|
||||
export function getChannelById(channelId: number): Promise<Channel> {
|
||||
return request<Channel>(`/api/channels/${channelId}`);
|
||||
}
|
||||
|
||||
export function updateChannel(channelId: number, body: UpdateChannelRequest): Promise<Channel> {
|
||||
return request<Channel>(`/api/channels/${channelId}`, {
|
||||
body,
|
||||
method: 'PUT'
|
||||
});
|
||||
}
|
||||
|
||||
export async function getChannelsScreenData(): Promise<ChannelsScreenData> {
|
||||
const [channels, channelStates] = await Promise.all([
|
||||
getChannels(),
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ChannelEditScreen } from './ChannelEditScreen';
|
||||
|
||||
const channel = {
|
||||
id: 5,
|
||||
number: '5',
|
||||
name: 'Cartoons',
|
||||
group: 'ChicoryTV',
|
||||
categories: 'Kids',
|
||||
fFmpegProfileId: 1,
|
||||
slugSeconds: null,
|
||||
logo: { path: '', contentType: '' },
|
||||
streamSelectorMode: 'Default',
|
||||
streamSelector: null,
|
||||
preferredAudioLanguageCode: null,
|
||||
preferredAudioTitle: null,
|
||||
playoutSource: 'Generated',
|
||||
playoutMode: 'Continuous',
|
||||
mirrorSourceChannelId: null,
|
||||
playoutOffset: null,
|
||||
streamingMode: 'TransportStreamHybrid',
|
||||
watermarkId: null,
|
||||
fallbackFillerId: null,
|
||||
playoutCount: 0,
|
||||
preferredSubtitleLanguageCode: null,
|
||||
subtitleMode: 'None',
|
||||
musicVideoCreditsMode: 'None',
|
||||
musicVideoCreditsTemplate: null,
|
||||
songVideoMode: 'Default',
|
||||
transcodeMode: 'OnDemand',
|
||||
idleBehavior: 'StopOnDisconnect',
|
||||
isEnabled: true,
|
||||
showInEpg: true
|
||||
};
|
||||
|
||||
// The PUT body is the GET response minus the read-only id / playoutCount fields.
|
||||
function expectedPutBody(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
const { id, playoutCount, ...rest } = channel;
|
||||
void id;
|
||||
void playoutCount;
|
||||
return { ...rest, ...overrides };
|
||||
}
|
||||
|
||||
function json(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status
|
||||
});
|
||||
}
|
||||
|
||||
interface FetchOptions {
|
||||
channelStatus?: number;
|
||||
onPut?: (body: unknown) => void;
|
||||
}
|
||||
|
||||
function mockApi({ channelStatus = 200, onPut }: FetchOptions = {}) {
|
||||
return vi.spyOn(window, 'fetch').mockImplementation((input, init) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : (input as Request).url;
|
||||
const method = (init?.method ?? 'GET').toUpperCase();
|
||||
|
||||
if (url === '/api/channels/5' && method === 'PUT') {
|
||||
onPut?.(JSON.parse(init?.body as string));
|
||||
return Promise.resolve(json({ ...channel, name: 'Renamed' }));
|
||||
}
|
||||
|
||||
if (url === '/api/channels/5') {
|
||||
return channelStatus === 200
|
||||
? Promise.resolve(json(channel))
|
||||
: Promise.resolve(json({ status: channelStatus, title: 'Not Found' }, channelStatus));
|
||||
}
|
||||
|
||||
if (url === '/api/ffmpeg/profiles') {
|
||||
return Promise.resolve(json([{ id: 1, name: 'Default profile' }]));
|
||||
}
|
||||
|
||||
if (url === '/api/watermarks') {
|
||||
return Promise.resolve(json([{ id: 2, name: 'Corner bug' }]));
|
||||
}
|
||||
|
||||
if (url === '/api/filler-presets') {
|
||||
return Promise.resolve(json([{ id: 3, name: 'Bumpers' }]));
|
||||
}
|
||||
|
||||
if (url === '/api/channels') {
|
||||
return Promise.resolve(json([{ id: 5, number: '5', name: 'Cartoons', group: 'ChicoryTV' }]));
|
||||
}
|
||||
|
||||
return Promise.resolve(json({ status: 404, title: 'Not Found' }, 404));
|
||||
});
|
||||
}
|
||||
|
||||
describe('ChannelEditScreen', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
window.history.replaceState(null, '', '/app/edit-channel/5');
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
window.history.replaceState(null, '', '/');
|
||||
});
|
||||
|
||||
it('hydrates the form from the loaded channel', async () => {
|
||||
mockApi();
|
||||
render(<ChannelEditScreen />);
|
||||
|
||||
expect(await screen.findByDisplayValue('Cartoons')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('5')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('Kids')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('saves an edited field with a PUT carrying the updated body', async () => {
|
||||
const puts: unknown[] = [];
|
||||
mockApi({ onPut: (body) => puts.push(body) });
|
||||
render(<ChannelEditScreen />);
|
||||
|
||||
const nameInput = await screen.findByDisplayValue('Cartoons');
|
||||
fireEvent.change(nameInput, { target: { value: 'Renamed' } });
|
||||
|
||||
const saveButton = await screen.findByRole('button', { name: 'Save changes' });
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => expect(puts).toHaveLength(1));
|
||||
|
||||
expect(puts[0]).toEqual(expectedPutBody({ name: 'Renamed' }));
|
||||
|
||||
expect(await screen.findByText('Channel saved')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('forces showInEpg off in the PUT body when Enabled is turned off', async () => {
|
||||
const puts: unknown[] = [];
|
||||
mockApi({ onPut: (body) => puts.push(body) });
|
||||
render(<ChannelEditScreen />);
|
||||
|
||||
await screen.findByDisplayValue('Cartoons');
|
||||
|
||||
// The General pane renders exactly two switches, in document order: Enabled, then Show in EPG.
|
||||
const [enabledSwitch, showInEpgSwitch] = screen.getAllByRole('switch');
|
||||
fireEvent.click(enabledSwitch);
|
||||
|
||||
expect(showInEpgSwitch).toHaveAttribute('aria-checked', 'false');
|
||||
expect(showInEpgSwitch).toHaveAttribute('aria-disabled', 'true');
|
||||
|
||||
const saveButton = await screen.findByRole('button', { name: 'Save changes' });
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => expect(puts).toHaveLength(1));
|
||||
|
||||
expect(puts[0]).toEqual(expectedPutBody({ isEnabled: false, showInEpg: false }));
|
||||
});
|
||||
|
||||
it('shows an error state when the channel is not found', async () => {
|
||||
mockApi({ channelStatus: 404 });
|
||||
render(<ChannelEditScreen />);
|
||||
|
||||
expect(await screen.findByText('Channel not found.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Back to channels' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,903 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import {
|
||||
AudioLines,
|
||||
Check,
|
||||
CircleCheck,
|
||||
Image as ImageIcon,
|
||||
ListVideo,
|
||||
Music,
|
||||
SlidersHorizontal,
|
||||
Tv,
|
||||
TriangleAlert,
|
||||
Upload
|
||||
} from 'lucide-react';
|
||||
import { navigateToPath } from '../routing';
|
||||
import { Badge, Button, Card, ChannelLogo, Input, Select, Switch } from '../components';
|
||||
import {
|
||||
ApiError,
|
||||
getChannelById,
|
||||
getChannels,
|
||||
getFFmpegProfiles,
|
||||
getFillerPresets,
|
||||
getWatermarks,
|
||||
messageFromError,
|
||||
updateChannel,
|
||||
uploadArtwork,
|
||||
type Channel,
|
||||
type ChannelSummary,
|
||||
type FFmpegProfile,
|
||||
type FillerPreset,
|
||||
type UpdateChannelRequest,
|
||||
type Watermark
|
||||
} from '../api';
|
||||
|
||||
const EDIT_BASE_PATH = '/app/edit-channel';
|
||||
|
||||
type SectionId = 'general' | 'playout' | 'streaming' | 'selection' | 'music' | 'branding';
|
||||
|
||||
interface SectionDef {
|
||||
hint: string;
|
||||
icon: ReactNode;
|
||||
id: SectionId;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const SECTIONS: SectionDef[] = [
|
||||
{ hint: 'Number, name, group', icon: <Tv aria-hidden="true" size={16} />, id: 'general', label: 'General' },
|
||||
{ hint: 'Source & scheduling', icon: <ListVideo aria-hidden="true" size={16} />, id: 'playout', label: 'Playout' },
|
||||
{ hint: 'FFmpeg & output', icon: <SlidersHorizontal aria-hidden="true" size={16} />, id: 'streaming', label: 'Streaming' },
|
||||
{ hint: 'Audio & subtitles', icon: <AudioLines aria-hidden="true" size={16} />, id: 'selection', label: 'Stream selection' },
|
||||
{ hint: 'Credits & songs', icon: <Music aria-hidden="true" size={16} />, id: 'music', label: 'Music video' },
|
||||
{ hint: 'Logo & watermark', icon: <ImageIcon aria-hidden="true" size={16} />, id: 'branding', label: 'Branding' }
|
||||
];
|
||||
|
||||
const SLUG_SECONDS_OPTIONS = [
|
||||
{ label: '(none)', value: '' },
|
||||
{ label: '0.5 seconds', value: '0.5' },
|
||||
{ label: '1 second', value: '1' },
|
||||
{ label: '2 seconds', value: '2' },
|
||||
{ label: '3 seconds', value: '3' },
|
||||
{ label: '5 seconds', value: '5' }
|
||||
];
|
||||
|
||||
// Server serializes TimeSpan with the constant ("c") format: [-][d.]hh:mm:ss. The Blazor
|
||||
// editor exposes the offset as whole hours, so we translate to/from that here. Hours >= 24
|
||||
// must use the days component, or the value fails to round-trip on the wire.
|
||||
function timeSpanToHours(value: null | string | undefined): number {
|
||||
if (!value) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const negative = value.startsWith('-');
|
||||
const body = negative ? value.slice(1) : value;
|
||||
const dotIndex = body.indexOf('.');
|
||||
const colonIndex = body.indexOf(':');
|
||||
let days = 0;
|
||||
let rest = body;
|
||||
|
||||
if (dotIndex !== -1 && (colonIndex === -1 || dotIndex < colonIndex)) {
|
||||
days = Number(body.slice(0, dotIndex));
|
||||
rest = body.slice(dotIndex + 1);
|
||||
}
|
||||
|
||||
const hours = Number(rest.split(':')[0] ?? '0');
|
||||
const total = days * 24 + hours;
|
||||
return negative ? -total : total;
|
||||
}
|
||||
|
||||
function hoursToTimeSpan(hours: number): null | string {
|
||||
if (!Number.isFinite(hours) || hours === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const negative = hours < 0;
|
||||
const abs = Math.abs(Math.trunc(hours));
|
||||
const days = Math.floor(abs / 24);
|
||||
const rem = abs % 24;
|
||||
const core = days > 0
|
||||
? `${days}.${String(rem).padStart(2, '0')}:00:00`
|
||||
: `${rem}:00:00`;
|
||||
return `${negative ? '-' : ''}${core}`;
|
||||
}
|
||||
|
||||
function channelIdFromPathname(pathname: string): number | null {
|
||||
const normalized = pathname.replace(/\/+$/, '');
|
||||
|
||||
if (!normalized.startsWith(`${EDIT_BASE_PATH}/`)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = Number(normalized.slice(EDIT_BASE_PATH.length + 1).split('/')[0]);
|
||||
return Number.isInteger(id) && id > 0 ? id : null;
|
||||
}
|
||||
|
||||
// The PUT body is the GET response minus the read-only id / playoutCount / webEncodedName
|
||||
// fields, so hydration is a straight field copy.
|
||||
function draftFromChannel(channel: Channel): UpdateChannelRequest {
|
||||
return {
|
||||
name: channel.name,
|
||||
number: channel.number,
|
||||
group: channel.group,
|
||||
categories: channel.categories,
|
||||
fFmpegProfileId: channel.fFmpegProfileId,
|
||||
slugSeconds: channel.slugSeconds,
|
||||
logo: channel.logo,
|
||||
streamSelectorMode: channel.streamSelectorMode,
|
||||
streamSelector: channel.streamSelector,
|
||||
preferredAudioLanguageCode: channel.preferredAudioLanguageCode,
|
||||
preferredAudioTitle: channel.preferredAudioTitle,
|
||||
playoutSource: channel.playoutSource,
|
||||
playoutMode: channel.playoutMode,
|
||||
mirrorSourceChannelId: channel.mirrorSourceChannelId,
|
||||
playoutOffset: channel.playoutOffset,
|
||||
streamingMode: channel.streamingMode,
|
||||
watermarkId: channel.watermarkId,
|
||||
fallbackFillerId: channel.fallbackFillerId,
|
||||
preferredSubtitleLanguageCode: channel.preferredSubtitleLanguageCode,
|
||||
subtitleMode: channel.subtitleMode,
|
||||
musicVideoCreditsMode: channel.musicVideoCreditsMode,
|
||||
musicVideoCreditsTemplate: channel.musicVideoCreditsTemplate,
|
||||
songVideoMode: channel.songVideoMode,
|
||||
transcodeMode: channel.transcodeMode,
|
||||
idleBehavior: channel.idleBehavior,
|
||||
isEnabled: channel.isEnabled,
|
||||
showInEpg: channel.showInEpg
|
||||
};
|
||||
}
|
||||
|
||||
function numberOrEmpty(value: null | number | undefined): string {
|
||||
return value == null ? '' : String(value);
|
||||
}
|
||||
|
||||
function sortByChannelNumber(channels: ChannelSummary[]): ChannelSummary[] {
|
||||
return [...channels].sort((left, right) => {
|
||||
const leftNumber = Number.parseFloat(left.number);
|
||||
const rightNumber = Number.parseFloat(right.number);
|
||||
|
||||
if (Number.isNaN(leftNumber) || Number.isNaN(rightNumber)) {
|
||||
return left.number.localeCompare(right.number, undefined, { numeric: true });
|
||||
}
|
||||
|
||||
return leftNumber - rightNumber;
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- shared row primitives (mirror SettingsScreen) ---------- */
|
||||
|
||||
function Row({
|
||||
children,
|
||||
control = 300,
|
||||
first = false,
|
||||
help,
|
||||
label
|
||||
}: {
|
||||
children: ReactNode;
|
||||
control?: number;
|
||||
first?: boolean;
|
||||
help?: string;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="ctv-settings-row" style={first ? { borderTop: 'none' } : undefined}>
|
||||
<div className="ctv-settings-row-main">
|
||||
<div className="ctv-settings-row-label">{label}</div>
|
||||
{help && <div className="ctv-settings-row-help">{help}</div>}
|
||||
</div>
|
||||
<div className="ctv-settings-row-control" style={{ flex: `0 0 ${control}px` }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Pane({ children, subtitle, title }: { children: ReactNode; subtitle: string; title: string }) {
|
||||
return (
|
||||
<div className="ctv-settings-pane">
|
||||
<div className="ctv-settings-pane-header">
|
||||
<div className="ctv-settings-pane-title">{title}</div>
|
||||
<div className="ctv-settings-pane-subtitle">{subtitle}</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ReferenceData {
|
||||
channels: ChannelSummary[];
|
||||
fillerPresets: FillerPreset[];
|
||||
watermarks: Watermark[];
|
||||
ffmpegProfiles: FFmpegProfile[];
|
||||
}
|
||||
|
||||
/* ---------- panes ---------- */
|
||||
|
||||
function GeneralPane({
|
||||
draft,
|
||||
set
|
||||
}: {
|
||||
draft: UpdateChannelRequest;
|
||||
set: (patch: Partial<UpdateChannelRequest>) => void;
|
||||
}) {
|
||||
return (
|
||||
<Pane subtitle="Identity shown in the lineup, guide and IPTV playlist." title="General">
|
||||
<Card padded={false}>
|
||||
<Row control={200} first help="Displayed channel number, e.g. 5 or 5.1." label="Number">
|
||||
<Input
|
||||
error={draft.number?.trim() ? null : 'Number is required'}
|
||||
fullWidth
|
||||
onChange={(event) => set({ number: event.target.value })}
|
||||
size="sm"
|
||||
value={draft.number ?? ''}
|
||||
/>
|
||||
</Row>
|
||||
<Row help="Channel display name." label="Name">
|
||||
<Input
|
||||
error={draft.name?.trim() ? null : 'Name is required'}
|
||||
fullWidth
|
||||
onChange={(event) => set({ name: event.target.value })}
|
||||
size="sm"
|
||||
value={draft.name ?? ''}
|
||||
/>
|
||||
</Row>
|
||||
<Row help="Groups channels in clients that support it." label="Group">
|
||||
<Input fullWidth onChange={(event) => set({ group: event.target.value })} size="sm" value={draft.group ?? ''} />
|
||||
</Row>
|
||||
<Row help="Comma-separated list of categories." label="Categories">
|
||||
<Input
|
||||
fullWidth
|
||||
onChange={(event) => set({ categories: event.target.value })}
|
||||
placeholder="News, Sports"
|
||||
size="sm"
|
||||
value={draft.categories ?? ''}
|
||||
/>
|
||||
</Row>
|
||||
<Row help="Whether this channel is served to clients." label="Enabled">
|
||||
<Switch
|
||||
checked={draft.isEnabled}
|
||||
onChange={(next) => set(next ? { isEnabled: next } : { isEnabled: next, showInEpg: false })}
|
||||
size="sm"
|
||||
/>
|
||||
</Row>
|
||||
<Row help="Include this channel in the XMLTV guide." label="Show in EPG">
|
||||
<Switch
|
||||
checked={draft.showInEpg}
|
||||
disabled={!draft.isEnabled}
|
||||
onChange={(next) => set({ showInEpg: next })}
|
||||
size="sm"
|
||||
/>
|
||||
</Row>
|
||||
</Card>
|
||||
</Pane>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayoutPane({
|
||||
channelId,
|
||||
data,
|
||||
draft,
|
||||
playoutLocked,
|
||||
set
|
||||
}: {
|
||||
channelId: number;
|
||||
data: ReferenceData;
|
||||
draft: UpdateChannelRequest;
|
||||
playoutLocked: boolean;
|
||||
set: (patch: Partial<UpdateChannelRequest>) => void;
|
||||
}) {
|
||||
const mirrorOptions = [
|
||||
{ label: '(none)', value: '' },
|
||||
...sortByChannelNumber(data.channels.filter((channel) => channel.id !== channelId)).map((channel) => ({
|
||||
label: `(${channel.number}) - ${channel.name}`,
|
||||
value: String(channel.id)
|
||||
}))
|
||||
];
|
||||
|
||||
return (
|
||||
<Pane subtitle="Where the channel's content comes from and how it progresses." title="Playout">
|
||||
<Card padded={false}>
|
||||
<Row
|
||||
first
|
||||
help={
|
||||
playoutLocked
|
||||
? 'Cannot be changed once a generated channel has a playout.'
|
||||
: 'Generated builds its own schedule; Mirror follows another channel.'
|
||||
}
|
||||
label="Playout source"
|
||||
>
|
||||
<Select
|
||||
disabled={playoutLocked}
|
||||
fullWidth
|
||||
onChange={(event) => set({ playoutSource: event.target.value as UpdateChannelRequest['playoutSource'] })}
|
||||
options={[
|
||||
{ label: 'Generated', value: 'Generated' },
|
||||
{ label: 'Mirror', value: 'Mirror' }
|
||||
]}
|
||||
size="sm"
|
||||
value={draft.playoutSource}
|
||||
/>
|
||||
</Row>
|
||||
{draft.playoutSource === 'Mirror' ? (
|
||||
<>
|
||||
<Row help="The generated channel this channel mirrors." label="Mirror source channel">
|
||||
<Select
|
||||
fullWidth
|
||||
onChange={(event) =>
|
||||
set({ mirrorSourceChannelId: event.target.value === '' ? null : Number(event.target.value) })
|
||||
}
|
||||
options={mirrorOptions}
|
||||
size="sm"
|
||||
value={draft.mirrorSourceChannelId == null ? '' : String(draft.mirrorSourceChannelId)}
|
||||
/>
|
||||
</Row>
|
||||
<Row control={160} help="Shift the mirrored playout by this many hours." label="Playout offset">
|
||||
<Input
|
||||
fullWidth
|
||||
onChange={(event) => set({ playoutOffset: hoursToTimeSpan(Number(event.target.value)) })}
|
||||
size="sm"
|
||||
style={{ fontFamily: 'var(--font-mono)' }}
|
||||
trailing={<span className="ctv-settings-unit">hours</span>}
|
||||
type="number"
|
||||
value={String(timeSpanToHours(draft.playoutOffset))}
|
||||
/>
|
||||
</Row>
|
||||
</>
|
||||
) : (
|
||||
<Row help="Controls how the generated playout progresses." label="Playout mode">
|
||||
<Select
|
||||
fullWidth
|
||||
onChange={(event) => set({ playoutMode: event.target.value as UpdateChannelRequest['playoutMode'] })}
|
||||
options={[
|
||||
{ label: 'Continuous', value: 'Continuous' },
|
||||
{ label: 'On Demand', value: 'OnDemand' }
|
||||
]}
|
||||
size="sm"
|
||||
value={draft.playoutMode}
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
<Row help="How the transcoder behaves once all clients disconnect." label="Idle behavior">
|
||||
<Select
|
||||
fullWidth
|
||||
onChange={(event) => set({ idleBehavior: event.target.value as UpdateChannelRequest['idleBehavior'] })}
|
||||
options={[
|
||||
{ label: 'Stop On Disconnect', value: 'StopOnDisconnect' },
|
||||
{ label: 'Keep Running', value: 'KeepRunning' }
|
||||
]}
|
||||
size="sm"
|
||||
value={draft.idleBehavior}
|
||||
/>
|
||||
</Row>
|
||||
<Row help="When the transcoding process is active. Only On Demand is supported." label="Transcode mode">
|
||||
<Select disabled fullWidth options={[{ label: 'On Demand', value: 'OnDemand' }]} size="sm" value={draft.transcodeMode} />
|
||||
</Row>
|
||||
</Card>
|
||||
</Pane>
|
||||
);
|
||||
}
|
||||
|
||||
function StreamingPane({
|
||||
data,
|
||||
draft,
|
||||
set
|
||||
}: {
|
||||
data: ReferenceData;
|
||||
draft: UpdateChannelRequest;
|
||||
set: (patch: Partial<UpdateChannelRequest>) => void;
|
||||
}) {
|
||||
const hlsDirect = draft.streamingMode === 'HttpLiveStreamingDirect';
|
||||
|
||||
return (
|
||||
<Pane subtitle="Output container and the transcoding profile applied to this channel." title="Streaming">
|
||||
<Card padded={false}>
|
||||
<Row first help="Delivery container / protocol served to clients." label="Streaming mode">
|
||||
<Select
|
||||
fullWidth
|
||||
onChange={(event) => set({ streamingMode: event.target.value as UpdateChannelRequest['streamingMode'] })}
|
||||
options={[
|
||||
{ label: 'MPEG-TS', value: 'TransportStreamHybrid' },
|
||||
{ label: 'MPEG-TS (Legacy)', value: 'TransportStream' },
|
||||
{ label: 'HLS Direct', value: 'HttpLiveStreamingDirect' },
|
||||
{ label: 'HLS Segmenter', value: 'HttpLiveStreamingSegmenter' }
|
||||
]}
|
||||
size="sm"
|
||||
value={draft.streamingMode}
|
||||
/>
|
||||
</Row>
|
||||
<Row help={hlsDirect ? 'Not used in HLS Direct mode.' : 'Transcoding preset for this channel.'} label="FFmpeg profile">
|
||||
<Select
|
||||
disabled={hlsDirect}
|
||||
fullWidth
|
||||
onChange={(event) => set({ fFmpegProfileId: Number(event.target.value) })}
|
||||
options={data.ffmpegProfiles.map((profile) => ({
|
||||
label: profile.name ?? `Profile ${profile.id}`,
|
||||
value: String(profile.id)
|
||||
}))}
|
||||
size="sm"
|
||||
value={String(draft.fFmpegProfileId)}
|
||||
/>
|
||||
</Row>
|
||||
<Row help="Black video / silent audio inserted between every playout item." label="Slug seconds">
|
||||
<Select
|
||||
fullWidth
|
||||
onChange={(event) => set({ slugSeconds: event.target.value === '' ? null : Number(event.target.value) })}
|
||||
options={SLUG_SECONDS_OPTIONS}
|
||||
size="sm"
|
||||
value={numberOrEmpty(draft.slugSeconds)}
|
||||
/>
|
||||
</Row>
|
||||
</Card>
|
||||
</Pane>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectionPane({
|
||||
draft,
|
||||
set
|
||||
}: {
|
||||
draft: UpdateChannelRequest;
|
||||
set: (patch: Partial<UpdateChannelRequest>) => void;
|
||||
}) {
|
||||
const isDefault = draft.streamSelectorMode === 'Default';
|
||||
|
||||
return (
|
||||
<Pane subtitle="How audio and subtitle tracks are chosen for playback." title="Stream selection">
|
||||
<Card padded={false}>
|
||||
<Row first help="Default picks tracks by preference; Custom uses a selector script." label="Stream selector mode">
|
||||
<Select
|
||||
fullWidth
|
||||
onChange={(event) =>
|
||||
set({ streamSelectorMode: event.target.value as UpdateChannelRequest['streamSelectorMode'] })
|
||||
}
|
||||
options={[
|
||||
{ label: 'Default', value: 'Default' },
|
||||
{ label: 'Custom', value: 'Custom' }
|
||||
]}
|
||||
size="sm"
|
||||
value={draft.streamSelectorMode}
|
||||
/>
|
||||
</Row>
|
||||
{isDefault ? (
|
||||
<>
|
||||
<Row help="ISO 639-2 code, e.g. eng. Blank keeps the file order." label="Preferred audio language">
|
||||
<Input
|
||||
fullWidth
|
||||
onChange={(event) => set({ preferredAudioLanguageCode: event.target.value || null })}
|
||||
placeholder="eng"
|
||||
size="sm"
|
||||
style={{ fontFamily: 'var(--font-mono)' }}
|
||||
value={draft.preferredAudioLanguageCode ?? ''}
|
||||
/>
|
||||
</Row>
|
||||
<Row help="Prefer an audio track whose title contains this text." label="Preferred audio title">
|
||||
<Input
|
||||
fullWidth
|
||||
onChange={(event) => set({ preferredAudioTitle: event.target.value || null })}
|
||||
size="sm"
|
||||
value={draft.preferredAudioTitle ?? ''}
|
||||
/>
|
||||
</Row>
|
||||
<Row help="ISO 639-2 code, e.g. eng. Blank disables preference." label="Preferred subtitle language">
|
||||
<Input
|
||||
fullWidth
|
||||
onChange={(event) => set({ preferredSubtitleLanguageCode: event.target.value || null })}
|
||||
placeholder="eng"
|
||||
size="sm"
|
||||
style={{ fontFamily: 'var(--font-mono)' }}
|
||||
value={draft.preferredSubtitleLanguageCode ?? ''}
|
||||
/>
|
||||
</Row>
|
||||
<Row help="When subtitle tracks are burned in." label="Subtitle mode">
|
||||
<Select
|
||||
fullWidth
|
||||
onChange={(event) => set({ subtitleMode: event.target.value as UpdateChannelRequest['subtitleMode'] })}
|
||||
options={[
|
||||
{ label: 'None', value: 'None' },
|
||||
{ label: 'Forced', value: 'Forced' },
|
||||
{ label: 'Default', value: 'Default' },
|
||||
{ label: 'Any', value: 'Any' }
|
||||
]}
|
||||
size="sm"
|
||||
value={draft.subtitleMode}
|
||||
/>
|
||||
</Row>
|
||||
</>
|
||||
) : (
|
||||
<Row help="Name of a custom stream-selector script in the config folder." label="Stream selector">
|
||||
<Input
|
||||
fullWidth
|
||||
onChange={(event) => set({ streamSelector: event.target.value || null })}
|
||||
size="sm"
|
||||
style={{ fontFamily: 'var(--font-mono)' }}
|
||||
value={draft.streamSelector ?? ''}
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
</Card>
|
||||
</Pane>
|
||||
);
|
||||
}
|
||||
|
||||
function MusicPane({
|
||||
draft,
|
||||
set
|
||||
}: {
|
||||
draft: UpdateChannelRequest;
|
||||
set: (patch: Partial<UpdateChannelRequest>) => void;
|
||||
}) {
|
||||
const creditsOn = draft.musicVideoCreditsMode === 'GenerateSubtitles';
|
||||
|
||||
return (
|
||||
<Pane subtitle="Overlays and progress behavior for music video and song content." title="Music video">
|
||||
<Card padded={false}>
|
||||
<Row first help="Generate on-screen credits for music videos." label="Credits mode">
|
||||
<Select
|
||||
fullWidth
|
||||
onChange={(event) =>
|
||||
set({ musicVideoCreditsMode: event.target.value as UpdateChannelRequest['musicVideoCreditsMode'] })
|
||||
}
|
||||
options={[
|
||||
{ label: 'None', value: 'None' },
|
||||
{ label: 'Generate Subtitles', value: 'GenerateSubtitles' }
|
||||
]}
|
||||
size="sm"
|
||||
value={draft.musicVideoCreditsMode}
|
||||
/>
|
||||
</Row>
|
||||
<Row help="Name of the credits template. Only used when credits are generated." label="Credits template">
|
||||
<Input
|
||||
disabled={!creditsOn}
|
||||
fullWidth
|
||||
onChange={(event) => set({ musicVideoCreditsTemplate: event.target.value || null })}
|
||||
size="sm"
|
||||
style={{ fontFamily: 'var(--font-mono)' }}
|
||||
value={draft.musicVideoCreditsTemplate ?? ''}
|
||||
/>
|
||||
</Row>
|
||||
<Row help="Optional progress bar overlay for song content." label="Song video mode">
|
||||
<Select
|
||||
fullWidth
|
||||
onChange={(event) => set({ songVideoMode: event.target.value as UpdateChannelRequest['songVideoMode'] })}
|
||||
options={[
|
||||
{ label: 'Default', value: 'Default' },
|
||||
{ label: 'With Progress', value: 'WithProgress' }
|
||||
]}
|
||||
size="sm"
|
||||
value={draft.songVideoMode}
|
||||
/>
|
||||
</Row>
|
||||
</Card>
|
||||
</Pane>
|
||||
);
|
||||
}
|
||||
|
||||
function BrandingPane({
|
||||
data,
|
||||
draft,
|
||||
logoPreview,
|
||||
onUploadLogo,
|
||||
set,
|
||||
uploadError,
|
||||
uploading
|
||||
}: {
|
||||
data: ReferenceData;
|
||||
draft: UpdateChannelRequest;
|
||||
logoPreview: string | null;
|
||||
onUploadLogo: (file: File) => void;
|
||||
set: (patch: Partial<UpdateChannelRequest>) => void;
|
||||
uploadError: string | null;
|
||||
uploading: boolean;
|
||||
}) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const hlsDirect = draft.streamingMode === 'HttpLiveStreamingDirect';
|
||||
const existingLogo = draft.logo?.urlWithContentType ?? draft.logo?.path ?? null;
|
||||
const previewSrc = logoPreview ?? existingLogo;
|
||||
|
||||
return (
|
||||
<Pane subtitle="Channel logo and the overlays applied while streaming." title="Branding">
|
||||
<Card padded={false}>
|
||||
<Row control={340} first help="Shown in the guide and as the on-screen bug." label="Logo">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<ChannelLogo name={draft.name ?? ''} size={48} src={previewSrc} />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<Button
|
||||
loading={uploading}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
size="sm"
|
||||
startIcon={<Upload aria-hidden="true" size={14} />}
|
||||
variant="secondary"
|
||||
>
|
||||
Upload logo
|
||||
</Button>
|
||||
{uploadError && (
|
||||
<span className="ctv-field-error" role="alert">
|
||||
{uploadError}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
accept="image/*"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files && event.target.files[0];
|
||||
if (file) {
|
||||
onUploadLogo(file);
|
||||
}
|
||||
event.target.value = '';
|
||||
}}
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
type="file"
|
||||
/>
|
||||
</div>
|
||||
</Row>
|
||||
<Row help={hlsDirect ? 'Not used in HLS Direct mode.' : 'Overlay applied to the channel.'} label="Watermark">
|
||||
<Select
|
||||
disabled={hlsDirect}
|
||||
fullWidth
|
||||
onChange={(event) => set({ watermarkId: event.target.value === '' ? null : Number(event.target.value) })}
|
||||
options={[
|
||||
{ label: '(none)', value: '' },
|
||||
...data.watermarks.map((watermark) => ({
|
||||
label: watermark.name ?? `Watermark ${watermark.id}`,
|
||||
value: String(watermark.id)
|
||||
}))
|
||||
]}
|
||||
size="sm"
|
||||
value={draft.watermarkId == null ? '' : String(draft.watermarkId)}
|
||||
/>
|
||||
</Row>
|
||||
<Row help="Plays when the playout has nothing scheduled." label="Fallback filler">
|
||||
<Select
|
||||
fullWidth
|
||||
onChange={(event) => set({ fallbackFillerId: event.target.value === '' ? null : Number(event.target.value) })}
|
||||
options={[
|
||||
{ label: '(none)', value: '' },
|
||||
...data.fillerPresets.map((preset) => ({
|
||||
label: preset.name ?? `Filler ${preset.id}`,
|
||||
value: String(preset.id)
|
||||
}))
|
||||
]}
|
||||
size="sm"
|
||||
value={draft.fallbackFillerId == null ? '' : String(draft.fallbackFillerId)}
|
||||
/>
|
||||
</Row>
|
||||
</Card>
|
||||
</Pane>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- screen ---------- */
|
||||
|
||||
interface LoadedState {
|
||||
channelId: number;
|
||||
data: ReferenceData;
|
||||
playoutLocked: boolean;
|
||||
}
|
||||
|
||||
export function ChannelEditScreen() {
|
||||
const [channelId] = useState<number | null>(() => channelIdFromPathname(window.location.pathname));
|
||||
const [loaded, setLoaded] = useState<LoadedState | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState<UpdateChannelRequest | null>(null);
|
||||
const [saved, setSaved] = useState<UpdateChannelRequest | null>(null);
|
||||
const [section, setSection] = useState<SectionId>('general');
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [justSaved, setJustSaved] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [logoPreview, setLogoPreview] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (channelId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
|
||||
Promise.all([getChannelById(channelId), getFFmpegProfiles(), getWatermarks(), getFillerPresets(), getChannels()])
|
||||
.then(([channel, ffmpegProfiles, watermarks, fillerPresets, channels]) => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
const initial = draftFromChannel(channel);
|
||||
setDraft(initial);
|
||||
setSaved(initial);
|
||||
setLoaded({
|
||||
channelId,
|
||||
data: { channels, ffmpegProfiles, fillerPresets, watermarks },
|
||||
playoutLocked: channel.playoutSource === 'Generated' && channel.playoutCount > 0
|
||||
});
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof ApiError && error.status === 404) {
|
||||
setLoadError('Channel not found.');
|
||||
} else {
|
||||
setLoadError(messageFromError(error));
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [channelId]);
|
||||
|
||||
const effectiveError = loadError ?? (channelId === null ? 'Invalid channel.' : null);
|
||||
|
||||
if (effectiveError) {
|
||||
return (
|
||||
<div className="ctv-settings-error" role="alert">
|
||||
<TriangleAlert aria-hidden="true" size={16} />
|
||||
<span>{effectiveError}</span>
|
||||
<Button onClick={() => navigateToPath('/app/channels')} size="sm" variant="secondary">
|
||||
Back to channels
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loaded === null || draft === null || saved === null) {
|
||||
return (
|
||||
<div className="ctv-settings-loading">
|
||||
<span>Loading channel…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const set = (patch: Partial<UpdateChannelRequest>) => {
|
||||
setJustSaved(false);
|
||||
setDraft((current) => (current ? { ...current, ...patch } : current));
|
||||
};
|
||||
|
||||
const uploadLogo = async (file: File) => {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
setUploadError('Choose an image file.');
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setUploadError(null);
|
||||
|
||||
try {
|
||||
const uploaded = await uploadArtwork(file, 'logo');
|
||||
setJustSaved(false);
|
||||
setDraft((current) =>
|
||||
current ? { ...current, logo: { path: uploaded.path, contentType: uploaded.contentType } } : current
|
||||
);
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => setLogoPreview(typeof reader.result === 'string' ? reader.result : null);
|
||||
reader.readAsDataURL(file);
|
||||
} catch (error) {
|
||||
setUploadError(messageFromError(error));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const nameValid = Boolean(draft.name?.trim());
|
||||
const numberValid = Boolean(draft.number?.trim());
|
||||
const valid = nameValid && numberValid;
|
||||
const dirty = JSON.stringify(draft) !== JSON.stringify(saved);
|
||||
|
||||
const discard = () => {
|
||||
setDraft(saved);
|
||||
setLogoPreview(null);
|
||||
setSaveError(null);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!valid || !dirty || saving) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
|
||||
try {
|
||||
const updated = await updateChannel(loaded.channelId, draft);
|
||||
const next = draftFromChannel(updated);
|
||||
setSaved(next);
|
||||
setDraft(next);
|
||||
setLogoPreview(null);
|
||||
setJustSaved(true);
|
||||
window.setTimeout(() => setJustSaved(false), 1800);
|
||||
} catch (error) {
|
||||
setSaveError(messageFromError(error));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ctv-settings">
|
||||
<nav aria-label="Channel sections" className="ctv-settings-rail">
|
||||
{SECTIONS.map((entry) => {
|
||||
const active = entry.id === section;
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-current={active ? 'true' : undefined}
|
||||
className={`ctv-settings-rail-item ctv-press${active ? ' ctv-settings-rail-item-active' : ''}`}
|
||||
key={entry.id}
|
||||
onClick={() => setSection(entry.id)}
|
||||
type="button"
|
||||
>
|
||||
<span className="ctv-settings-rail-icon" style={{ color: active ? 'var(--ctv-accent)' : 'var(--text-secondary)' }}>
|
||||
{entry.icon}
|
||||
</span>
|
||||
<span className="ctv-settings-rail-text">
|
||||
<span className="ctv-settings-rail-label" style={{ color: active ? 'var(--text-primary)' : 'var(--text-secondary)' }}>
|
||||
{entry.label}
|
||||
</span>
|
||||
<span className="ctv-settings-rail-hint">{entry.hint}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="ctv-settings-body">
|
||||
<div className="ctv-settings-scroll">
|
||||
{section === 'general' && <GeneralPane draft={draft} set={set} />}
|
||||
{section === 'playout' && (
|
||||
<PlayoutPane channelId={loaded.channelId} data={loaded.data} draft={draft} playoutLocked={loaded.playoutLocked} set={set} />
|
||||
)}
|
||||
{section === 'streaming' && <StreamingPane data={loaded.data} draft={draft} set={set} />}
|
||||
{section === 'selection' && <SelectionPane draft={draft} set={set} />}
|
||||
{section === 'music' && <MusicPane draft={draft} set={set} />}
|
||||
{section === 'branding' && (
|
||||
<BrandingPane
|
||||
data={loaded.data}
|
||||
draft={draft}
|
||||
logoPreview={logoPreview}
|
||||
onUploadLogo={(file) => void uploadLogo(file)}
|
||||
set={set}
|
||||
uploadError={uploadError}
|
||||
uploading={uploading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(dirty || justSaved) && (
|
||||
<div className="ctv-settings-savebar-wrap">
|
||||
<div className="ctv-settings-savebar ctv-lift">
|
||||
{justSaved ? (
|
||||
<>
|
||||
<CircleCheck aria-hidden="true" color="var(--status-ok)" size={15} />
|
||||
<span className="ctv-settings-savebar-text">Channel saved</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="ctv-settings-savebar-text">Unsaved changes</span>
|
||||
{!valid && <Badge tone="neutral">Name and number are required</Badge>}
|
||||
{saveError && <span className="ctv-settings-savebar-error">{saveError}</span>}
|
||||
<span className="ctv-settings-savebar-divider" />
|
||||
<Button onClick={discard} size="sm" variant="ghost">
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!valid || saving}
|
||||
loading={saving}
|
||||
onClick={() => void save()}
|
||||
size="sm"
|
||||
startIcon={<Check aria-hidden="true" size={14} />}
|
||||
variant="primary"
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user