feat(web): Channel Builder (#89) #136

Merged
timothy merged 4 commits from feat/89-channel-builder into main 2026-07-06 23:40:44 +02:00
17 changed files with 4272 additions and 22 deletions
+728 -1
View File
@@ -1993,6 +1993,584 @@ describe('ChicoryTV SPA scaffold', () => {
expect(container.querySelector('.lucide-triangle-alert')).toBeInTheDocument();
expect(container.querySelector('.lucide-circle-x')).toBeInTheDocument();
});
// ---- Channel Builder (#89) -----------------------------------------------
const renderBuilder = async () => {
window.history.replaceState(null, '', '/app/new-channel');
const utils = render(<App />);
await screen.findByPlaceholderText('Search shows & movies…');
return utils;
};
// The shell's nav link and top-bar primary action also read "New Channel" /
// "Create Channel"; scope these to the builder toolbar.
const createBtn = () =>
within(document.querySelector('.ctv-builder-toolbar') as HTMLElement).getByRole('button', {
name: 'Create Channel'
});
const builderTitle = () => screen.getByText('New Channel', { selector: '.ctv-builder-title' });
const numberField = () => screen.getByLabelText('Number', { exact: false }) as HTMLInputElement;
const builderDefaults = () => ({
channels: [channelSummary()],
channelTemplates: [channelTemplate()],
defaultChannelTemplate: channelTemplate(),
ffmpegProfiles: [ffmpegProfile()]
});
it('renders the builder three-column layout with library items and the default template', async () => {
mockDashboardApi({
...builderDefaults(),
browseItems: [browseItem(), browseItem({ id: 2, mediaItemId: 2, title: 'Tom & Jerry' })]
});
const { container } = await renderBuilder();
expect(builderTitle()).toBeInTheDocument();
expect(screen.getByText('Lineup')).toBeInTheDocument();
expect(screen.getByText('Channel Template')).toBeInTheDocument();
expect(screen.getByText('Standard')).toBeInTheDocument();
expect(await screen.findByText('Looney Tunes')).toBeInTheDocument();
expect(screen.getByText('Tom & Jerry')).toBeInTheDocument();
expect(container.querySelectorAll('.ctv-builder-col')).toHaveLength(3);
expect(screen.getByText('This channel has no content yet. Double-click or drag titles from the library to build the lineup.')).toBeInTheDocument();
});
it('computes the AUTO channel number from the max integer part of existing channels', async () => {
mockDashboardApi({
...builderDefaults(),
channels: [channelSummary({ id: 1, number: '5' }), channelSummary({ id: 2, number: '13.1' })],
browseItems: [browseItem()]
});
await renderBuilder();
const numberInput = numberField();
expect(numberInput.value).toBe('14');
expect(screen.getByText('AUTO')).toBeInTheDocument();
});
it('adds a library item to the lineup on double-click and dedupes repeats', async () => {
mockDashboardApi({ ...builderDefaults(), browseItems: [browseItem()] });
const { container } = await renderBuilder();
fireEvent.doubleClick(await screen.findByText('Looney Tunes'));
expect(container.querySelector('.ctv-builder-summary')?.textContent).toContain('1 in lineup');
expect(container.querySelectorAll('.ctv-builder-lineup-row')).toHaveLength(1);
fireEvent.doubleClick(screen.getAllByText('Looney Tunes')[0]);
expect(container.querySelectorAll('.ctv-builder-lineup-row')).toHaveLength(1);
});
it('removes a lineup item and clears the lineup through the confirm dialog', async () => {
mockDashboardApi({
...builderDefaults(),
browseItems: [browseItem(), browseItem({ id: 2, mediaItemId: 2, title: 'Tom & Jerry' })]
});
const { container } = await renderBuilder();
fireEvent.doubleClick(await screen.findByText('Looney Tunes'));
fireEvent.doubleClick(screen.getByText('Tom & Jerry'));
expect(container.querySelectorAll('.ctv-builder-lineup-row')).toHaveLength(2);
fireEvent.click(within(container.querySelectorAll('.ctv-builder-lineup-row')[0] as HTMLElement).getByRole('button', { name: 'Remove' }));
expect(container.querySelectorAll('.ctv-builder-lineup-row')).toHaveLength(1);
fireEvent.click(screen.getByRole('button', { name: 'Clear' }));
fireEvent.click(await screen.findByRole('button', { name: 'Clear lineup' }));
await waitFor(() => expect(container.querySelectorAll('.ctv-builder-lineup-row')).toHaveLength(0));
});
it('filters library results through the debounced search query', async () => {
mockDashboardApi({
...builderDefaults(),
browseHandler: (search) => {
const query = (search.get('query') ?? '').toLowerCase();
const all = [
browseItem(),
browseItem({ id: 2, mediaItemId: 2, mediaType: 'Movie', collectionType: 'Movie', title: 'Blade Runner', libraryName: 'Movies' })
];
const page = query ? all.filter((item) => String(item.title).toLowerCase().includes(query)) : all;
return { page, totalCount: page.length };
}
});
await renderBuilder();
expect(await screen.findByText('Blade Runner')).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText('Search shows & movies…'), { target: { value: 'blade' } });
await waitFor(() => expect(screen.queryByText('Looney Tunes')).not.toBeInTheDocument());
expect(screen.getByText('Blade Runner')).toBeInTheDocument();
});
it('reorders lineup items with native drag and drop', async () => {
mockDashboardApi({
...builderDefaults(),
browseItems: [browseItem(), browseItem({ id: 2, mediaItemId: 2, title: 'Tom & Jerry' })]
});
const { container } = await renderBuilder();
fireEvent.doubleClick(await screen.findByText('Looney Tunes'));
fireEvent.doubleClick(screen.getByText('Tom & Jerry'));
let rows = container.querySelectorAll('.ctv-builder-lineup-row');
expect(rows[0].querySelector('.ctv-builder-row-title')?.textContent).toBe('Looney Tunes');
fireEvent.dragStart(rows[0]);
fireEvent.dragOver(rows[1]);
fireEvent.drop(rows[1]);
rows = container.querySelectorAll('.ctv-builder-lineup-row');
expect(rows[0].querySelector('.ctv-builder-row-title')?.textContent).toBe('Tom & Jerry');
expect(rows[1].querySelector('.ctv-builder-row-title')?.textContent).toBe('Looney Tunes');
});
it('validates the channel number for format and uniqueness', async () => {
mockDashboardApi({
...builderDefaults(),
channels: [channelSummary({ number: '5' })],
browseItems: [browseItem()]
});
await renderBuilder();
const numberInput = numberField();
fireEvent.change(numberInput, { target: { value: 'abc' } });
expect(await screen.findByText(/Use digits/)).toBeInTheDocument();
fireEvent.change(numberInput, { target: { value: '5' } });
expect(await screen.findByText(/already in use/)).toBeInTheDocument();
});
it('keeps Create disabled until a name and at least one lineup item exist', async () => {
mockDashboardApi({ ...builderDefaults(), browseItems: [browseItem()] });
await renderBuilder();
const createButton = createBtn();
expect(createButton).toBeDisabled();
fireEvent.doubleClick(await screen.findByText('Looney Tunes'));
fireEvent.change(screen.getByLabelText('Channel name'), { target: { value: 'Retro Cartoons' } });
expect(createButton).toBeEnabled();
});
it('preselects the default template and flags an override when the Shuffle toggle diverges', async () => {
mockDashboardApi({ ...builderDefaults(), browseItems: [browseItem()] });
const { container } = await renderBuilder();
// default template has shuffleScheduleItems=false -> no override tag yet
expect(screen.queryByText('overrides template')).not.toBeInTheDocument();
const shuffleRow = container.querySelector('.ctv-builder-toggle-row:not(.ctv-builder-toggle-live)') as HTMLElement;
fireEvent.click(shuffleRow);
expect(await screen.findByText('overrides template')).toBeInTheDocument();
});
it('applies a selected template shuffle default and clears the override', async () => {
mockDashboardApi({
...builderDefaults(),
channelTemplates: [
channelTemplate(),
channelTemplate({ id: 11, isDefault: false, name: 'Music videos', shuffleScheduleItems: true })
],
browseItems: [browseItem()]
});
const { container } = await renderBuilder();
const shuffleRow = container.querySelector('.ctv-builder-toggle-row:not(.ctv-builder-toggle-live)') as HTMLElement;
fireEvent.click(shuffleRow);
expect(await screen.findByText('overrides template')).toBeInTheDocument();
// open the template picker and select the shuffle-by-default template
fireEvent.click(screen.getByText('Standard'));
fireEvent.click(await screen.findByText('Music videos'));
await waitFor(() => expect(screen.queryByText('overrides template')).not.toBeInTheDocument());
});
it('marks a rerun-collection row invalid in a multi-item lineup and blocks Create', async () => {
mockDashboardApi({
...builderDefaults(),
browseItems: [
browseItem(),
browseItem({ id: 9, title: 'Saturday Block', mediaType: 'RerunCollection', collectionType: 'RerunFirstRun', collectionKind: 'Rerun', rerunCollectionId: 9, mediaItemId: undefined })
]
});
await renderBuilder();
fireEvent.doubleClick(await screen.findByText('Looney Tunes'));
fireEvent.doubleClick(screen.getByText('Saturday Block'));
fireEvent.change(screen.getByLabelText('Channel name'), { target: { value: 'Mixed' } });
expect(await screen.findByText('Only valid alone')).toBeInTheDocument();
expect(createBtn()).toBeDisabled();
});
it('clears a MultiCollection shuffle requirement when the Shuffle toggle is turned on', async () => {
mockDashboardApi({
...builderDefaults(),
browseItems: [
browseItem({ id: 7, title: 'Prime Time', mediaType: 'MultiCollection', collectionType: 'MultiCollection', multiCollectionId: 7, mediaItemId: undefined })
]
});
const { container } = await renderBuilder();
fireEvent.doubleClick(await screen.findByText('Prime Time'));
fireEvent.change(screen.getByLabelText('Channel name'), { target: { value: 'Multi' } });
expect(await screen.findByText('Requires Shuffle')).toBeInTheDocument();
expect(createBtn()).toBeDisabled();
const shuffleRow = container.querySelector('.ctv-builder-toggle-row:not(.ctv-builder-toggle-live)') as HTMLElement;
fireEvent.click(shuffleRow);
await waitFor(() => expect(screen.queryByText('Requires Shuffle')).not.toBeInTheDocument());
expect(createBtn()).toBeEnabled();
});
it('posts a well-formed create request and navigates to channels on success', async () => {
mockDashboardApi({
...builderDefaults(),
channels: [channelSummary({ number: '5' })],
browseItems: [browseItem(), browseItem({ id: 2, mediaItemId: 2, title: 'Tom & Jerry' })]
});
await renderBuilder();
fireEvent.doubleClick(await screen.findByText('Looney Tunes'));
fireEvent.doubleClick(screen.getByText('Tom & Jerry'));
fireEvent.change(screen.getByLabelText('Channel name'), { target: { value: ' Retro Cartoons ' } });
fireEvent.click(createBtn());
await waitFor(() => expect(window.location.pathname).toBe('/app/channels'));
const body = requestBodyFor('/api/channels/from-lineup');
expect(body.name).toBe('Retro Cartoons');
expect(body.number).toBe('6');
expect(body.group).toBe('ErsatzTV');
expect(body.templateId).toBe(10);
expect(body.logo).toEqual({ contentType: '', path: '' });
expect((body.advanced as Record<string, unknown>).playbackOrder).toBe('Chronological');
expect((body.advanced as Record<string, unknown>).playoutMode).toBe('Continuous');
const lineup = body.lineup as Array<Record<string, unknown>>;
expect(lineup).toHaveLength(2);
expect(lineup[0].mediaItemId).toBe(1);
expect(lineup[1].mediaItemId).toBe(2);
expect(lineup[0].mediaType).toBe('TelevisionShow');
});
it('highlights the offending lineup row and shows the detail on a 422 ProblemDetails', async () => {
mockDashboardApi({
...builderDefaults(),
browseItems: [browseItem(), browseItem({ id: 2, mediaItemId: 2, title: 'Tom & Jerry' })],
// Verbatim wire shape: ApiResults hard-codes the 422 title "Validation failed",
// and the handler's not-found detail is "lineup[i] <Label> <id> does not exist."
fromLineupFailure: { detail: 'lineup[1] TelevisionShow 999 does not exist.', status: 422, title: 'Validation failed' }
});
const { container } = await renderBuilder();
fireEvent.doubleClick(await screen.findByText('Looney Tunes'));
fireEvent.doubleClick(screen.getByText('Tom & Jerry'));
fireEvent.change(screen.getByLabelText('Channel name'), { target: { value: 'Retro' } });
fireEvent.click(createBtn());
expect(await screen.findByText('lineup[1] TelevisionShow 999 does not exist.')).toBeInTheDocument();
await waitFor(() => {
const rows = container.querySelectorAll('.ctv-builder-lineup-row');
expect(rows[1].classList.contains('ctv-builder-lineup-row-error')).toBe(true);
});
expect(window.location.pathname).toBe('/app/new-channel');
});
it('uploads the channel image then references the returned path in the create body', async () => {
mockDashboardApi({ ...builderDefaults(), browseItems: [browseItem()] });
const { container } = await renderBuilder();
fireEvent.doubleClick(await screen.findByText('Looney Tunes'));
fireEvent.change(screen.getByLabelText('Channel name'), { target: { value: 'Retro' } });
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File(['logo-bytes'], 'logo.png', { type: 'image/png' });
fireEvent.change(fileInput, { target: { files: [file] } });
fireEvent.click(createBtn());
await waitFor(() => expect(window.location.pathname).toBe('/app/channels'));
const uploadCall = vi
.mocked(window.fetch)
.mock.calls.find(([input]) => input.toString() === '/api/artwork/uploads');
expect(uploadCall).toBeDefined();
const formData = uploadCall?.[1]?.body as FormData;
expect(formData).toBeInstanceOf(FormData);
expect(formData.get('target')).toBe('logo');
expect((formData.get('file') as File).name).toBe('logo.png');
const body = requestBodyFor('/api/channels/from-lineup');
expect(body.logo).toEqual({ contentType: 'image/png', path: '/artwork/logo/uploaded.png' });
});
it('fans out 5 typed browse requests in Collections mode and renders the merged, title-sorted items', async () => {
const byType: Record<string, Record<string, unknown>> = {
Collection: browseItem({
id: 2,
title: 'Saturday Cartoons',
mediaType: 'Collection',
collectionType: 'Collection',
collectionKind: 'Manual',
collectionId: 2,
mediaItemId: undefined
}),
SmartCollection: browseItem({
id: 3,
title: 'Action Movies',
mediaType: 'SmartCollection',
collectionType: 'SmartCollection',
collectionKind: 'Smart',
smartCollectionId: 3,
mediaItemId: undefined
}),
MultiCollection: browseItem({
id: 7,
title: 'Prime Time',
mediaType: 'MultiCollection',
collectionType: 'MultiCollection',
collectionKind: 'Multi',
multiCollectionId: 7,
mediaItemId: undefined
}),
RerunCollection: browseItem({
id: 9,
title: 'Saturday Block',
mediaType: 'RerunCollection',
collectionType: 'RerunFirstRun',
collectionKind: 'Rerun',
rerunCollectionId: 9,
mediaItemId: undefined
}),
Playlist: browseItem({
id: 12,
title: 'Zzz Late Night',
mediaType: 'Playlist',
collectionType: 'Playlist',
playlistId: 12,
mediaItemId: undefined
})
};
mockDashboardApi({
...builderDefaults(),
browseItems: [browseItem()],
browseHandler: (search) => {
const mediaType = search.get('mediaType');
const item = mediaType ? byType[mediaType] : undefined;
return item ? { page: [item], totalCount: 1 } : { page: [], totalCount: 0 };
}
});
const { container } = await renderBuilder();
fireEvent.click(screen.getByRole('button', { name: 'Collections' }));
expect(await screen.findByText('Action Movies')).toBeInTheDocument();
const browseTypes = vi
.mocked(window.fetch)
.mock.calls.map(([input]) => input.toString())
.filter((url) => url.startsWith('/api/library/browse'))
.map((url) => new URL(url, window.location.origin).searchParams.get('mediaType'))
.filter((mediaType): mediaType is string => mediaType != null);
expect(browseTypes).toEqual(['Collection', 'SmartCollection', 'MultiCollection', 'RerunCollection', 'Playlist']);
const titles = Array.from(container.querySelectorAll('.ctv-builder-poster-title')).map((el) => el.textContent);
expect(titles).toEqual(['Action Movies', 'Prime Time', 'Saturday Block', 'Saturday Cartoons', 'Zzz Late Night']);
});
it('loads a second page of library results and preserves the first page', async () => {
const pages = [
[browseItem({ id: 1, mediaItemId: 1, title: 'Item A' }), browseItem({ id: 2, mediaItemId: 2, title: 'Item B' })],
[browseItem({ id: 3, mediaItemId: 3, title: 'Item C' })]
];
mockDashboardApi({
...builderDefaults(),
browseHandler: (search) => {
const pageNum = Number(search.get('pageNum') ?? '0');
return { page: pages[pageNum] ?? [], totalCount: 3 };
}
});
await renderBuilder();
expect(await screen.findByText('Item A')).toBeInTheDocument();
expect(screen.getByText('Item B')).toBeInTheDocument();
expect(screen.queryByText('Item C')).not.toBeInTheDocument();
const loadMoreButton = screen.getByRole('button', { name: 'Load more' });
fireEvent.click(loadMoreButton);
expect(await screen.findByText('Item C')).toBeInTheDocument();
expect(screen.getByText('Item A')).toBeInTheDocument();
expect(screen.getByText('Item B')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Load more' })).not.toBeInTheDocument();
});
it('saves the current settings as a new template and selects it', async () => {
const created = channelTemplate({
id: 55,
name: 'Saturday Special',
description: 'Custom mix.',
isDefault: false,
isSystem: false,
shuffleScheduleItems: true
});
mockDashboardApi({
...builderDefaults(),
browseItems: [browseItem()],
createTemplateResponse: created
});
const { container } = await renderBuilder();
const shuffleRow = container.querySelector('.ctv-builder-toggle-row:not(.ctv-builder-toggle-live)') as HTMLElement;
fireEvent.click(shuffleRow);
fireEvent.click(screen.getByText('Standard'));
fireEvent.click(await screen.findByText('Save current settings as template…'));
expect(await screen.findByText('Save as channel template')).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('Template name'), { target: { value: 'Saturday Special' } });
fireEvent.change(screen.getByLabelText('Description'), { target: { value: 'Custom mix.' } });
fireEvent.click(screen.getByRole('button', { name: 'Save template' }));
await waitFor(() => expect(screen.queryByText('Save as channel template')).not.toBeInTheDocument());
const body = requestBodyFor('/api/channel-templates');
expect(body.name).toBe('Saturday Special');
expect(body.description).toBe('Custom mix.');
expect(body.shuffleScheduleItems).toBe(true);
expect(body.playoutMode).toBe('Continuous');
expect(screen.getByText('Saturday Special')).toBeInTheDocument();
});
it('shows a friendly error and skips channel creation when the artwork upload returns a bare 413', async () => {
mockDashboardApi({
...builderDefaults(),
browseItems: [browseItem()],
artworkUploadFailure: { status: 413 }
});
const { container } = await renderBuilder();
fireEvent.doubleClick(await screen.findByText('Looney Tunes'));
fireEvent.change(screen.getByLabelText('Channel name'), { target: { value: 'Retro' } });
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File(['logo-bytes'], 'logo.png', { type: 'image/png' });
fireEvent.change(fileInput, { target: { files: [file] } });
fireEvent.click(createBtn());
expect(await screen.findByText('Image is too large (max 30 MB).')).toBeInTheDocument();
expect(window.location.pathname).toBe('/app/new-channel');
expect(
vi.mocked(window.fetch).mock.calls.some(([input]) => input.toString() === '/api/channels/from-lineup')
).toBe(false);
expect(createBtn()).toBeEnabled();
});
it('renders the empty-templates state and keeps Create disabled with no templates', async () => {
mockDashboardApi({
...builderDefaults(),
channelTemplates: [],
defaultChannelTemplate: null,
browseItems: [browseItem()]
});
await renderBuilder();
expect(
screen.getByText(/No channel templates exist yet\. Create one in Settings before building a channel/)
).toBeInTheDocument();
fireEvent.doubleClick(await screen.findByText('Looney Tunes'));
fireEvent.change(screen.getByLabelText('Channel name'), { target: { value: 'Retro Cartoons' } });
expect(createBtn()).toBeDisabled();
});
it('sends a Collection lineup item with only its typed id populated', async () => {
mockDashboardApi({
...builderDefaults(),
browseItems: [
browseItem({
id: 2,
title: 'Saturday Cartoons',
mediaType: 'Collection',
collectionType: 'Collection',
collectionKind: 'Manual',
collectionId: 2,
mediaItemId: undefined
})
]
});
await renderBuilder();
fireEvent.doubleClick(await screen.findByText('Saturday Cartoons'));
fireEvent.change(screen.getByLabelText('Channel name'), { target: { value: 'Marathon' } });
fireEvent.click(createBtn());
await waitFor(() => expect(window.location.pathname).toBe('/app/channels'));
const body = requestBodyFor('/api/channels/from-lineup');
const lineup = body.lineup as Array<Record<string, unknown>>;
expect(lineup).toHaveLength(1);
expect(lineup[0].mediaType).toBe('Collection');
expect(lineup[0].collectionType).toBe('Collection');
expect(lineup[0].collectionId).toBe(2);
expect(lineup[0].mediaItemId).toBeUndefined();
expect(lineup[0].multiCollectionId).toBeUndefined();
expect(lineup[0].smartCollectionId).toBeUndefined();
expect(lineup[0].rerunCollectionId).toBeUndefined();
expect(lineup[0].playlistId).toBeUndefined();
});
it('renders the builder under the cool and dual design themes', async () => {
mockDashboardApi({ ...builderDefaults(), browseItems: [browseItem()] });
await renderBuilder();
applyDesignSystemTheme('cool');
expect(document.documentElement).toHaveAttribute('data-theme', 'cool');
expect(builderTitle()).toBeInTheDocument();
applyDesignSystemTheme('dual');
expect(document.documentElement).toHaveAttribute('data-theme', 'dual');
expect(screen.getByPlaceholderText('Search shows & movies…')).toBeInTheDocument();
});
});
function jsonResponse(body: unknown, status = 200): Response {
@@ -2047,6 +2625,92 @@ function mediaSource(overrides: Record<string, unknown> = {}): Record<string, un
};
}
// ---- Channel Builder (#89) fixtures ----------------------------------------
// The API serializes with Newtonsoft NullValueHandling.Ignore: null-valued
// members are omitted from the wire entirely. Fixtures mirror that by
// stripping null/undefined keys after overrides are applied.
function omitNullKeys(fixture: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(Object.entries(fixture).filter(([, value]) => value != null));
}
// ChannelResponseModel
function channelSummary(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
categories: '',
fFmpegProfile: '1080p H.264',
group: 'ErsatzTV',
id: 1,
isEnabled: true,
language: 'English',
name: 'Movies',
number: '5',
showInEpg: true,
sortNumber: 5,
streamingMode: 'HttpLiveStreamingSegmenter',
...overrides
};
}
// ChannelTemplateResponseModel — nullable members (filler/watermark ids,
// preferred languages, streamSelector, musicVideoCreditsTemplate) are null on
// the Standard template and therefore absent from the serialized payload.
function channelTemplate(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return omitNullKeys({
description: 'General-purpose 1080p H.264.',
fFmpegProfileId: 100,
fixedStartTimeBehavior: 'Strict',
id: 10,
idleBehavior: 'StopOnDisconnect',
isDefault: true,
isSystem: true,
musicVideoCreditsMode: 'None',
name: 'Standard',
playoutMode: 'Continuous',
playoutSource: 'Generated',
randomStartPoint: false,
shuffleScheduleItems: false,
songVideoMode: 'Default',
streamSelectorMode: 'Default',
streamingMode: 'HttpLiveStreamingSegmenter',
subtitleMode: 'None',
transcodeMode: 'OnDemand',
...overrides
});
}
// LibraryBrowseItemResponseModel - Newtonsoft omits null fields, so exactly one
// typed id is populated and nullable metadata (duration/itemCount) is absent
// unless the caller supplies it.
function browseItem(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return omitNullKeys({
artwork: '',
collectionType: 'TelevisionShow',
id: 1,
libraryId: 31,
libraryName: 'Cartoons',
mediaItemId: 1,
mediaType: 'TelevisionShow',
title: 'Looney Tunes',
...overrides
});
}
// FFmpegFullProfileResponseModel (only id/name are read by the builder)
function ffmpegProfile(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return { id: 100, name: '1080p H.264', ...overrides };
}
// Read the JSON body captured for a mutating request to `path`.
function requestBodyFor(path: string): Record<string, unknown> {
const call = vi
.mocked(window.fetch)
.mock.calls.find(([input, init]) => input.toString() === path && init?.body != null);
if (!call) {
throw new Error(`no request captured for ${path}`);
}
return JSON.parse(call[1]?.body as string) as Record<string, unknown>;
}
// PlayoutListItemResponseModel: rail rows have no detail-only fields (playoutMode/scheduleFile).
function listPlayout(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
@@ -2190,8 +2854,18 @@ function scheduleItemRequest(item: Record<string, unknown>): Record<string, unkn
function mockDashboardApi({
addScheduleItemResponse = null,
artworkUploadFailure = null,
browseHandler = null,
browseItems = [],
channels = [],
channelStates = [],
channelTemplates = [],
createTemplateResponse = null,
defaultChannelTemplate = null,
ffmpegProfiles = [],
fromLineupFailure = null,
fromLineupResponse = { channelId: 1, playlistId: null, playoutId: 3, programScheduleId: 2 },
watermarks = [],
collections = [],
confirm = false,
fillerPresets = [],
@@ -2227,8 +2901,18 @@ function mockDashboardApi({
version = { apiVersion: 3, appVersion: '26.4.0' }
}: {
addScheduleItemResponse?: unknown;
artworkUploadFailure?: { status: number } | null;
browseHandler?: ((search: URLSearchParams) => { page: unknown[]; totalCount: number }) | null;
browseItems?: unknown[];
channels?: unknown[];
channelStates?: unknown[];
channelTemplates?: unknown[];
createTemplateResponse?: unknown;
defaultChannelTemplate?: unknown;
ffmpegProfiles?: unknown[];
fromLineupFailure?: { detail?: string; status?: number; title?: string } | null;
fromLineupResponse?: unknown;
watermarks?: unknown[];
collections?: unknown[];
confirm?: boolean;
fillerPresets?: unknown[];
@@ -2266,6 +2950,7 @@ function mockDashboardApi({
vi.spyOn(window, 'confirm').mockReturnValue(confirm);
vi.spyOn(window, 'prompt').mockReturnValue(prompt);
let currentLibraryScanStatuses = libraryScanStatuses;
let currentChannelTemplates = channelTemplates;
let currentScheduleItems = scheduleItems;
let remainingMediaSourcesFailures = mediaSourcesFailuresBeforeSuccess;
let remainingGuideFailures = guideFailuresBeforeSuccess;
@@ -2285,6 +2970,48 @@ function mockDashboardApi({
return Promise.resolve(jsonResponse(channelStates));
}
// ---- Channel Builder (#89) endpoints (before the generic /api/channels/) ----
if (path === '/api/channels/from-lineup') {
if (fromLineupFailure) {
return Promise.resolve(jsonResponse(fromLineupFailure, fromLineupFailure.status ?? 422));
}
return Promise.resolve(jsonResponse(fromLineupResponse, 201));
}
if (path.startsWith('/api/library/browse')) {
const search = new URL(path, window.location.origin).searchParams;
const result = browseHandler
? browseHandler(search)
: { page: browseItems, totalCount: browseItems.length };
return Promise.resolve(jsonResponse(result));
}
if (path === '/api/channel-templates') {
if ((init?.method ?? 'GET') === 'POST') {
const created = createTemplateResponse ?? channelTemplate();
currentChannelTemplates = [...currentChannelTemplates, created];
return Promise.resolve(jsonResponse(created, 201));
}
return Promise.resolve(jsonResponse(currentChannelTemplates));
}
if (path === '/api/channel-templates/default') {
return Promise.resolve(
defaultChannelTemplate ? jsonResponse(defaultChannelTemplate) : jsonResponse(null, 404)
);
}
if (path === '/api/ffmpeg/profiles') {
return Promise.resolve(jsonResponse(ffmpegProfiles));
}
if (path === '/api/artwork/uploads') {
if (artworkUploadFailure) {
return Promise.resolve(new Response(null, { status: artworkUploadFailure.status }));
}
return Promise.resolve(jsonResponse({ contentType: 'image/png', path: '/artwork/logo/uploaded.png' }, 201));
}
if (path.startsWith('/api/guide?')) {
if (remainingGuideFailures > 0) {
remainingGuideFailures -= 1;
@@ -2352,7 +3079,7 @@ function mockDashboardApi({
}
if (path === '/api/watermarks') {
return Promise.resolve(jsonResponse([]));
return Promise.resolve(jsonResponse(watermarks));
}
if (path === '/api/media-sources') {
+6 -4
View File
@@ -53,6 +53,8 @@ import {
Tv
} from 'lucide-react';
import chicoryIconUrl from '../../design-system/assets/chicorytv-icon.svg';
import { ChannelBuilderScreen } from './builder/ChannelBuilder';
import { navigateToPath } from './routing';
import {
Badge,
Button,
@@ -271,10 +273,6 @@ function routeHref(route: ScreenRoute): string {
return route.id === 'dashboard' ? '/app' : route.path;
}
function navigateToPath(path: string) {
window.history.pushState(null, '', path);
window.dispatchEvent(new PopStateEvent('popstate'));
}
function SidebarNavGroup({
activeRoute,
@@ -2822,6 +2820,10 @@ function ScreenContent({
return <ChannelsScreen />;
}
if (route.id === 'builder') {
return <ChannelBuilderScreen />;
}
if (route.id === 'guide') {
return <GuideScreen />;
}
+28
View File
@@ -0,0 +1,28 @@
import { ApiError, request } from './client';
import type { components } from './generated/v1';
export type ArtworkUpload = components['schemas']['ArtworkUploadResponseModel'];
export type ArtworkUploadTarget = 'logo' | 'watermark';
export function uploadArtwork(file: File, target: ArtworkUploadTarget): Promise<ArtworkUpload> {
const body = new FormData();
body.append('file', file);
body.append('target', target);
return request<ArtworkUpload>('/api/artwork/uploads', {
body,
method: 'POST'
});
}
export function messageFromArtworkError(error: unknown, fallback = 'Unable to upload artwork'): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}
+43
View File
@@ -0,0 +1,43 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { getDefaultChannelTemplate } from './channelTemplates';
describe('getDefaultChannelTemplate', () => {
beforeEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
it('returns null when no default template is set (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(getDefaultChannelTemplate()).resolves.toBeNull();
});
it('rethrows non-404 errors', async () => {
vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ status: 500, title: 'Server error' }), {
headers: { 'Content-Type': 'application/json' },
status: 500
})
);
await expect(getDefaultChannelTemplate()).rejects.toMatchObject({ status: 500 });
});
it('returns the default template when present', async () => {
const template = { id: 1, name: 'Default', isDefault: true };
vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify(template), {
headers: { 'Content-Type': 'application/json' },
status: 200
})
);
await expect(getDefaultChannelTemplate()).resolves.toMatchObject(template);
});
});
+40
View File
@@ -0,0 +1,40 @@
import { ApiError, request } from './client';
import type { components } from './generated/v1';
export type ChannelTemplate = components['schemas']['ChannelTemplateResponseModel'];
export type CreateChannelTemplateRequest = components['schemas']['CreateChannelTemplateRequest'];
export function getChannelTemplates(): Promise<ChannelTemplate[]> {
return request<ChannelTemplate[]>('/api/channel-templates');
}
export async function getDefaultChannelTemplate(): Promise<ChannelTemplate | null> {
try {
return await request<ChannelTemplate>('/api/channel-templates/default');
} catch (error) {
if (error instanceof ApiError && error.status === 404) {
return null;
}
throw error;
}
}
export function createChannelTemplate(body: CreateChannelTemplateRequest): Promise<ChannelTemplate> {
return request<ChannelTemplate>('/api/channel-templates', {
body,
method: 'POST'
});
}
export function messageFromChannelTemplateError(error: unknown, fallback = 'Unable to load channel templates'): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}
+11
View File
@@ -7,6 +7,8 @@ export type ChannelState = components['schemas']['ChannelStateResponseModel'];
export type BulkRenumberChannelsRequest = components['schemas']['BulkRenumberChannelsRequest'];
export type BulkMoveChannelsToGroupRequest = components['schemas']['BulkMoveChannelsToGroupRequest'];
export type BulkDeleteChannelsRequest = components['schemas']['BulkDeleteChannelsRequest'];
export type CreateChannelFromLineupRequest = components['schemas']['CreateChannelFromLineupRequest'];
export type CreateChannelFromLineupResponseModel = components['schemas']['CreateChannelFromLineupResponseModel'];
export interface ChannelsScreenData {
channels: ChannelSummary[];
@@ -67,6 +69,15 @@ export function deleteChannel(channelId: number): Promise<void> {
});
}
export function createChannelFromLineup(
body: CreateChannelFromLineupRequest
): Promise<CreateChannelFromLineupResponseModel> {
return request<CreateChannelFromLineupResponseModel>('/api/channels/from-lineup', {
body,
method: 'POST'
});
}
export function useChannelsScreenQuery(pollMs = 30000): ChannelsScreenQueryState {
const [state, setState] = useState<ChannelsScreenState>({
data: null,
+4
View File
@@ -1,9 +1,13 @@
export * from './artwork';
export * from './auth';
export * from './channels';
export * from './channelTemplates';
export * from './client';
export * from './dashboard';
export * from './guide';
export * from './libraries';
export * from './libraryBrowse';
export * from './pickers';
export * from './playouts';
export * from './schedules';
export * from './useChannelsQuery';
+54
View File
@@ -0,0 +1,54 @@
import { ApiError, request } from './client';
import type { components } from './generated/v1';
export type LibraryBrowseItem = components['schemas']['LibraryBrowseItemResponseModel'];
export type LibraryBrowseMediaType = components['schemas']['LibraryBrowseMediaType'];
export type PagedLibraryBrowseItems = components['schemas']['PagedLibraryBrowseItemsResponseModel'];
export interface GetLibraryBrowseItemsParams {
libraryId?: number;
mediaType?: LibraryBrowseMediaType;
pageNum?: number;
pageSize?: number;
query?: string;
}
export function getLibraryBrowseItems(params: GetLibraryBrowseItemsParams = {}): Promise<PagedLibraryBrowseItems> {
const searchParams = new URLSearchParams();
if (params.query) {
searchParams.set('query', params.query);
}
if (params.libraryId != null) {
searchParams.set('libraryId', String(params.libraryId));
}
if (params.mediaType) {
searchParams.set('mediaType', params.mediaType);
}
if (params.pageNum != null) {
searchParams.set('pageNum', String(params.pageNum));
}
if (params.pageSize != null) {
searchParams.set('pageSize', String(params.pageSize));
}
const queryString = searchParams.toString();
return request<PagedLibraryBrowseItems>(`/api/library/browse${queryString ? `?${queryString}` : ''}`);
}
export function messageFromLibraryBrowseError(error: unknown, fallback = 'Unable to load library items'): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}
+49
View File
@@ -0,0 +1,49 @@
import { ApiError, request } from './client';
import type { components } from './generated/v1';
export type FillerPreset = components['schemas']['FillerPresetResponseModel'];
export type Watermark = components['schemas']['WatermarkResponseModel'];
export type GraphicsElement = components['schemas']['GraphicsElementResponseModel'];
export type FFmpegProfile = components['schemas']['FFmpegFullProfileResponseModel'];
export function getFillerPresets(): Promise<FillerPreset[]> {
return request<FillerPreset[]>('/api/filler-presets').then(sortByName);
}
export function getWatermarks(): Promise<Watermark[]> {
return request<Watermark[]>('/api/watermarks').then(sortByName);
}
export function getGraphicsElements(): Promise<GraphicsElement[]> {
return request<GraphicsElement[]>('/api/graphics-elements').then(sortByName);
}
export function getFFmpegProfiles(): Promise<FFmpegProfile[]> {
return request<FFmpegProfile[]>('/api/ffmpeg/profiles').then(sortByName);
}
function sortByName<T extends { name: null | string }>(items: T[]): T[] {
return [...items].sort((left, right) => {
if (left.name == null) {
return right.name == null ? 0 : 1;
}
if (right.name == null) {
return -1;
}
return left.name.localeCompare(right.name, undefined, { sensitivity: 'base' });
});
}
export function messageFromPickersError(error: unknown, fallback = 'Unable to load picker data'): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}
+5 -16
View File
@@ -1,6 +1,8 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ApiError, request } from './client';
import type { components } from './generated/v1';
import { getFillerPresets, getWatermarks } from './pickers';
import type { FillerPreset, Watermark } from './pickers';
export type ProgramSchedule = components['schemas']['ProgramScheduleViewModel'];
// These fields are real on the wire (the backend serializes ProgramScheduleItemViewModel
@@ -21,8 +23,7 @@ export type ScheduleItemRequest = components['schemas']['ScheduleItemRequest'];
export type ReplaceScheduleItemsRequest = components['schemas']['ReplaceScheduleItemsRequest'];
export type MediaCollection = components['schemas']['MediaCollectionViewModel'];
export type SmartCollection = components['schemas']['SmartCollectionViewModel'];
export type FillerPreset = components['schemas']['FillerPresetResponseModel'];
export type Watermark = components['schemas']['WatermarkResponseModel'];
export type { FillerPreset, Watermark };
export interface SchedulePickerData {
collections: MediaCollection[];
@@ -73,14 +74,6 @@ export function getSmartCollections(): Promise<SmartCollection[]> {
return request<SmartCollection[]>('/api/smart-collections');
}
export function getFillerPresets(): Promise<FillerPreset[]> {
return request<FillerPreset[]>('/api/filler-presets');
}
export function getWatermarks(): Promise<Watermark[]> {
return request<Watermark[]>('/api/watermarks');
}
export function addScheduleItem(scheduleId: number, body: ScheduleItemRequest): Promise<ProgramScheduleItem> {
return request<ProgramScheduleItem>(`/api/schedules/${scheduleId}/items`, {
body,
@@ -272,9 +265,9 @@ async function getSchedulePickerData(): Promise<SchedulePickerData> {
return {
collections,
fillerPresets: sortByName(fillerPresets),
fillerPresets,
smartCollections,
watermarks: sortByName(watermarks)
watermarks
};
}
@@ -287,10 +280,6 @@ function emptyPickerData(): SchedulePickerData {
};
}
function sortByName<T extends { name: null | string }>(items: T[]): T[] {
return [...items].sort((left, right) => (left.name ?? '').localeCompare(right.name ?? ''));
}
function messageFromScheduleError(error: unknown, fallback = 'Unable to load schedules'): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
File diff suppressed because it is too large Load Diff
+75 -1
View File
@@ -920,6 +920,79 @@
background: var(--action-primary);
}
.ctv-dialog-overlay {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(0, 0, 0, 0.55);
}
.ctv-dialog {
display: flex;
flex-direction: column;
width: 480px;
max-width: 100%;
max-height: calc(100vh - 48px);
border: 1px solid var(--border-hairline);
border-radius: var(--radius-lg);
background: var(--surface-card);
box-shadow: var(--shadow-lg);
outline: 0;
animation: ctv-dialog-in var(--dur-slow) var(--ease-out);
}
.ctv-dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 16px 12px;
border-bottom: 1px solid var(--border-hairline);
}
.ctv-dialog-title {
color: var(--text-primary);
font-size: var(--text-md);
font-weight: var(--weight-semibold);
}
.ctv-dialog-body {
overflow-y: auto;
padding: 16px;
color: var(--text-secondary);
font-size: var(--text-sm);
line-height: 1.5;
}
.ctv-dialog-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding: 12px 16px 16px;
border-top: 1px solid var(--border-hairline);
}
.ctv-confirm-message {
color: var(--text-secondary);
}
@keyframes ctv-dialog-in {
from {
opacity: 0;
transform: scale(0.97);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes ctv-spin {
to {
transform: rotate(360deg);
@@ -952,7 +1025,8 @@
@media (prefers-reduced-motion: reduce) {
.ctv-spinner svg,
.ctv-status-dot-ping,
.ctv-progress-fill-indeterminate {
.ctv-progress-fill-indeterminate,
.ctv-dialog {
animation: none;
}
}
+1
View File
@@ -4,3 +4,4 @@ export * from './dataDisplay';
export * from './feedback';
export * from './forms';
export * from './navigation';
export * from './overlay';
+122
View File
@@ -0,0 +1,122 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ConfirmDialog, Dialog } from './overlay';
describe('Dialog', () => {
afterEach(() => {
cleanup();
document.body.style.overflow = '';
});
it('renders nothing when closed', () => {
render(
<Dialog open={false} onClose={vi.fn()} title="Hidden">
Body content
</Dialog>
);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('renders the title and children when open', () => {
render(
<Dialog open onClose={vi.fn()} title="Save as template">
Body content
</Dialog>
);
expect(screen.getByRole('dialog', { name: 'Save as template' })).toBeInTheDocument();
expect(screen.getByText('Body content')).toBeInTheDocument();
});
it('calls onClose when the backdrop is clicked but not when the panel is clicked', () => {
const onClose = vi.fn();
render(
<Dialog open onClose={onClose} title="Save as template">
Body content
</Dialog>
);
fireEvent.click(screen.getByText('Body content'));
expect(onClose).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('dialog').parentElement as HTMLElement);
expect(onClose).toHaveBeenCalledTimes(1);
});
it('calls onClose on Escape', () => {
const onClose = vi.fn();
render(
<Dialog open onClose={onClose} title="Save as template">
Body content
</Dialog>
);
fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose).toHaveBeenCalledTimes(1);
});
it('locks and restores body scroll, honoring the previous inline value', () => {
document.body.style.overflow = 'scroll';
const { rerender } = render(
<Dialog open onClose={vi.fn()} title="Save as template">
Body content
</Dialog>
);
expect(document.body.style.overflow).toBe('hidden');
rerender(
<Dialog open={false} onClose={vi.fn()} title="Save as template">
Body content
</Dialog>
);
expect(document.body.style.overflow).toBe('scroll');
});
});
describe('ConfirmDialog', () => {
afterEach(() => {
cleanup();
document.body.style.overflow = '';
});
it('fires onConfirm and onCancel', () => {
const onConfirm = vi.fn();
const onCancel = vi.fn();
render(
<ConfirmDialog
open
title="Delete channel"
message="This cannot be undone."
onConfirm={onConfirm}
onCancel={onCancel}
/>
);
fireEvent.click(screen.getByRole('button', { name: 'Confirm' }));
expect(onConfirm).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(onCancel).toHaveBeenCalledTimes(1);
});
it('disables the confirm button while busy', () => {
render(
<ConfirmDialog
open
busy
title="Delete channel"
message="This cannot be undone."
onConfirm={vi.fn()}
onCancel={vi.fn()}
/>
);
expect(screen.getByRole('button', { name: 'Confirm' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled();
});
});
+148
View File
@@ -0,0 +1,148 @@
import { useEffect, useId, useRef, type CSSProperties, type MouseEvent, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
import { Button, IconButton } from './forms';
export interface DialogProps {
open: boolean;
onClose: () => void;
title: ReactNode;
children?: ReactNode;
footer?: ReactNode;
width?: number;
style?: CSSProperties;
}
export function Dialog({ open, onClose, title, children, footer, width, style }: DialogProps) {
const titleId = useId();
const panelRef = useRef<HTMLDivElement>(null);
// Latest-ref for onClose so the Escape listener never has to re-subscribe when
// the parent re-renders with a fresh onClose identity.
const onCloseRef = useRef(onClose);
useEffect(() => {
onCloseRef.current = onClose;
});
// Focus the panel + lock body scroll only on the closed->open transition.
// (Keyed on `open` alone — depending on onClose here would steal focus from
// inputs on every keystroke, since parents hand us a new onClose each render.)
useEffect(() => {
if (!open) {
return;
}
panelRef.current?.focus();
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previousOverflow;
};
}, [open]);
// Escape-to-close listener; reads the latest onClose via ref so it too only
// depends on `open`.
useEffect(() => {
if (!open) {
return;
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onCloseRef.current();
}
};
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('keydown', onKeyDown);
};
}, [open]);
if (!open) {
return null;
}
const onOverlayClick = (event: MouseEvent<HTMLDivElement>) => {
if (event.target === event.currentTarget) {
onClose();
}
};
return createPortal(
<div className="ctv-dialog-overlay" onClick={onOverlayClick}>
<div
className="ctv-dialog"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
tabIndex={-1}
ref={panelRef}
style={{ ...(width ? { width } : null), ...style }}
>
<div className="ctv-dialog-header">
<span id={titleId} className="ctv-dialog-title">
{title}
</span>
<IconButton title="Close" onClick={onClose}>
<X aria-hidden="true" size={15} />
</IconButton>
</div>
<div className="ctv-dialog-body">{children}</div>
{footer && <div className="ctv-dialog-footer">{footer}</div>}
</div>
</div>,
document.body
);
}
export interface ConfirmDialogProps {
open: boolean;
title: ReactNode;
message: ReactNode;
confirmLabel?: string;
cancelLabel?: string;
tone?: 'danger' | 'accent';
busy?: boolean;
onConfirm: () => void;
onCancel: () => void;
}
export function ConfirmDialog({
open,
title,
message,
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
tone = 'accent',
busy = false,
onConfirm,
onCancel
}: ConfirmDialogProps) {
return (
<Dialog
open={open}
onClose={onCancel}
title={title}
footer={
<>
<Button variant="secondary" onClick={onCancel} disabled={busy}>
{cancelLabel}
</Button>
<Button
variant={tone === 'danger' ? 'danger' : 'primary'}
onClick={onConfirm}
disabled={busy}
loading={busy}
>
{confirmLabel}
</Button>
</>
}
>
<p className="ctv-confirm-message">{message}</p>
</Dialog>
);
}
+7
View File
@@ -0,0 +1,7 @@
// Client-side navigation shared by the shell (App.tsx) and screens that live in
// their own modules (e.g. the Channel Builder). Kept in its own file so screens
// don't import App.tsx (which would create an import cycle).
export function navigateToPath(path: string) {
window.history.pushState(null, '', path);
window.dispatchEvent(new PopStateEvent('popstate'));
}
+863
View File
@@ -2131,6 +2131,849 @@
color: var(--status-live);
}
/* ---- Channel Builder (#89) --------------------------------------------- */
/* The builder is a full-height flagship screen. `.ctv-main` is a bounded,
scrolling grid track (100vh - topbar); the ThemeSwitcher + main padding sit
above us, so we bound our own height off the viewport to get three
independently scrolling columns rather than one long page scroll. */
.ctv-builder {
display: flex;
flex-direction: column;
height: calc(100vh - 132px);
min-height: 460px;
margin: calc(var(--space-7, 16px) * -1) calc(var(--pad-page, 32px) * -1) calc(var(--pad-page, 32px) * -1);
border-top: 1px solid var(--border-hairline);
}
.ctv-hide-sb {
scrollbar-width: thin;
scrollbar-color: var(--border-control) transparent;
}
.ctv-hide-sb::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.ctv-hide-sb::-webkit-scrollbar-thumb {
background: var(--border-control);
border-radius: 8px;
}
.ctv-hide-sb::-webkit-scrollbar-track {
background: transparent;
}
.ctv-builder-toolbar {
display: flex;
align-items: center;
gap: var(--space-6, 12px);
padding: var(--space-6, 12px) 20px;
border-bottom: 1px solid var(--border-hairline);
flex: 0 0 auto;
}
.ctv-builder-toolbar-tile {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: var(--radius-sm);
background: var(--ctv-accent-soft);
color: var(--ctv-accent);
flex: 0 0 auto;
}
.ctv-builder-toolbar-titles {
flex: 1;
min-width: 0;
}
.ctv-builder-title {
font: var(--weight-semibold) var(--text-md) / 1 var(--font-sans);
color: var(--text-primary);
}
.ctv-builder-summary {
margin-top: 3px;
font: var(--text-xs) / 1 var(--font-sans);
color: var(--text-secondary);
}
.ctv-mono {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
.ctv-builder-error {
display: flex;
align-items: flex-start;
gap: var(--space-4, 8px);
padding: var(--space-4, 8px) 20px;
border-bottom: 1px solid var(--border-hairline);
background: var(--ctv-error-soft);
color: var(--status-error);
font: var(--text-xs) / 1.4 var(--font-sans);
flex: 0 0 auto;
}
.ctv-builder-body {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr) 304px;
}
.ctv-builder-col {
display: flex;
flex-direction: column;
min-height: 0;
min-width: 0;
overflow: hidden;
border-right: 1px solid var(--border-hairline);
}
.ctv-builder-col-rail {
border-right: none;
background: var(--surface-card);
}
.ctv-builder-col-head {
padding: 14px 16px 10px;
display: flex;
flex-direction: column;
gap: var(--space-5, 10px);
flex: 0 0 auto;
}
.ctv-builder-col-head-row {
flex-direction: row;
align-items: center;
}
.ctv-builder-scroll {
flex: 1;
min-height: 0;
min-width: 0;
overflow-y: auto;
overflow-x: hidden;
}
.ctv-builder-scroll-body {
padding: 4px 16px 16px;
}
.ctv-builder-rail-scroll {
padding: 14px 16px 24px;
display: flex;
flex-direction: column;
gap: var(--space-7, 16px);
}
.ctv-builder-headrow {
display: flex;
align-items: center;
gap: var(--space-5, 10px);
flex-wrap: wrap;
row-gap: var(--space-4, 8px);
}
.ctv-builder-eyebrow {
font: var(--weight-semibold) var(--text-2xs) / 1 var(--font-sans);
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-disabled);
}
.ctv-builder-chiprow {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.ctv-builder-chip {
padding: 4px 10px;
border-radius: var(--radius-pill);
cursor: pointer;
font: var(--weight-medium) var(--text-2xs) / 1.4 var(--font-sans);
background: var(--ctv-surface-2);
color: var(--text-secondary);
border: 1px solid var(--border-hairline);
}
.ctv-builder-chip-on {
background: var(--ctv-accent-soft);
color: var(--ctv-accent);
border-color: var(--ctv-accent-ring, rgba(224, 138, 60, 0.35));
}
.ctv-builder-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(104px, 1fr));
gap: var(--space-5, 10px);
}
.ctv-builder-compact-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.ctv-builder-empty {
padding: 40px 0;
text-align: center;
font: var(--text-xs) / 1.5 var(--font-sans);
color: var(--text-disabled);
}
.ctv-builder-loadmore {
margin-top: var(--space-6, 12px);
display: flex;
justify-content: center;
}
/* poster placeholder */
.ctv-builder-poster {
position: relative;
flex: 0 0 auto;
overflow: hidden;
border-radius: var(--radius-sm);
border: 1px solid var(--border-hairline);
}
.ctv-builder-poster img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.ctv-builder-poster-icon {
position: absolute;
display: inline-flex;
z-index: 1;
}
.ctv-builder-poster-scrim {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 18px 8px 8px;
background: linear-gradient(to top, rgba(11, 13, 17, 0.92), transparent);
z-index: 1;
}
.ctv-builder-poster-title {
font: var(--weight-semibold) var(--text-xs) / 1.15 var(--font-sans);
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ctv-builder-poster-meta {
margin-top: 3px;
font: var(--text-2xs) / 1 var(--font-mono);
color: var(--text-secondary);
}
/* hover lift comes from the shared .ctv-lift utility */
.ctv-builder-libcard {
position: relative;
cursor: grab;
}
.ctv-builder-libcard-poster {
position: relative;
border-radius: var(--radius-sm);
}
.ctv-builder-libcard-compact {
display: flex;
align-items: center;
gap: var(--space-5, 10px);
padding: 6px 8px;
border-radius: var(--radius-sm);
cursor: grab;
background: var(--surface-card);
border: 1px solid var(--border-hairline);
}
.ctv-builder-compact-title {
font: var(--weight-medium) var(--text-xs) / 1.15 var(--font-sans);
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ctv-builder-compact-meta {
margin-top: 2px;
font: var(--text-2xs) / 1 var(--font-sans);
color: var(--text-secondary);
}
.ctv-builder-added-check {
position: absolute;
top: 6px;
right: 6px;
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--action-primary);
color: var(--text-on-accent);
display: flex;
align-items: center;
justify-content: center;
box-shadow: var(--shadow-sm);
animation: ctv-pop 260ms cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes ctv-pop {
from {
transform: scale(0.2);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
/* lineup */
.ctv-builder-lineup-list {
display: flex;
flex-direction: column;
gap: 7px;
}
.ctv-builder-lineup-row {
display: flex;
align-items: center;
gap: 11px;
padding: 9px 10px;
border-radius: var(--radius-md);
background: var(--surface-card);
border: 1px solid var(--border-hairline);
transition: opacity var(--dur-fast);
}
.ctv-builder-lineup-row-error {
border-color: var(--status-error);
}
.ctv-builder-grip {
display: inline-flex;
cursor: grab;
color: var(--ctv-text-faint);
flex: 0 0 auto;
}
.ctv-builder-order {
width: 22px;
flex: 0 0 auto;
text-align: center;
font: var(--weight-medium) var(--text-xs) / 1 var(--font-mono);
color: var(--text-secondary);
}
.ctv-builder-row-main {
flex: 1;
min-width: 0;
}
.ctv-builder-row-title {
font: var(--weight-semibold) var(--text-sm) / 1.2 var(--font-sans);
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ctv-builder-row-sub {
margin-top: 2px;
font: var(--text-2xs) / 1 var(--font-sans);
color: var(--text-secondary);
}
.ctv-builder-row-dur {
font: var(--text-2xs) / 1 var(--font-mono);
color: var(--text-disabled);
flex: 0 0 auto;
}
.ctv-builder-lineup-empty {
min-height: 220px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-6, 12px);
text-align: center;
border: 1px dashed var(--border-control);
border-radius: var(--radius-md);
color: var(--text-disabled);
padding: 24px;
}
.ctv-builder-lineup-empty-over {
border-color: var(--ctv-accent);
background: var(--ctv-accent-soft);
}
.ctv-builder-lineup-hint {
margin-top: 4px;
padding: 10px 12px;
border-radius: var(--radius-md);
border: 1px dashed var(--border-hairline);
display: flex;
align-items: center;
gap: var(--space-4, 8px);
color: var(--text-disabled);
font: var(--text-2xs) / 1 var(--font-sans);
}
/* rail */
.ctv-builder-rail-block {
display: flex;
flex-direction: column;
gap: var(--space-5, 10px);
}
.ctv-builder-auto-chip {
font: 9px / 1 var(--font-mono);
padding: 2px 5px;
border-radius: var(--radius-xs);
background: var(--ctv-accent-soft);
color: var(--ctv-accent);
}
.ctv-builder-dropzone {
display: flex;
gap: var(--space-6, 12px);
padding: var(--space-6, 12px);
border-radius: var(--radius-sm);
cursor: pointer;
background: var(--ctv-bg-sunken);
border: 1px dashed var(--border-control);
}
.ctv-builder-dropzone-over {
border-color: var(--ctv-accent);
}
.ctv-builder-preview-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-5, 10px);
}
.ctv-builder-preview-tile {
padding: 10px;
border-radius: var(--radius-sm);
background: var(--surface-card);
border: 1px solid var(--border-hairline);
}
.ctv-builder-toggle-row {
display: flex;
align-items: flex-start;
gap: 11px;
padding: 10px 0;
cursor: pointer;
}
.ctv-builder-toggle-live {
gap: var(--space-5, 10px);
padding: 10px 11px;
border-radius: var(--radius-sm);
border: 1px solid transparent;
transition: background 240ms cubic-bezier(0.16, 1, 0.3, 1), border-color 240ms cubic-bezier(0.16, 1, 0.3, 1);
}
.ctv-builder-toggle-live-on {
background: var(--ctv-live-soft);
border-color: var(--ctv-live);
}
.ctv-builder-toggle-label {
font: var(--weight-medium) var(--text-sm) / 1.2 var(--font-sans);
color: var(--text-primary);
}
.ctv-builder-toggle-help {
margin-top: 2px;
font: var(--text-2xs) / 1.35 var(--font-sans);
color: var(--text-secondary);
}
.ctv-builder-overrides-tag {
font: var(--text-2xs) / 1 var(--font-sans);
color: var(--text-disabled);
padding: 2px 6px;
border-radius: var(--radius-xs);
background: var(--ctv-surface-2);
border: 1px solid var(--border-hairline);
}
.ctv-builder-live-pill {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 7px;
border-radius: var(--radius-pill);
background: var(--ctv-live-soft);
color: var(--ctv-live);
font: var(--weight-semibold) 9px / 1.4 var(--font-sans);
letter-spacing: 0.06em;
}
.ctv-builder-hairline {
height: 1px;
background: var(--border-hairline);
}
.ctv-builder-tpl-button {
width: 100%;
display: flex;
align-items: center;
gap: var(--space-5, 10px);
padding: 10px 11px;
border-radius: var(--radius-sm);
cursor: pointer;
text-align: left;
background: var(--ctv-bg-sunken);
border: 1px solid var(--border-control);
}
.ctv-builder-tpl-tile {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: var(--radius-xs);
background: var(--ctv-surface-3);
color: var(--ctv-accent);
flex: 0 0 auto;
}
.ctv-builder-tpl-name {
font: var(--weight-medium) var(--text-sm) / 1 var(--font-sans);
color: var(--text-primary);
}
.ctv-builder-tpl-desc {
margin-top: 3px;
font: var(--text-2xs) / 1.3 var(--font-sans);
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ctv-builder-tpl-list {
margin-top: 6px;
display: flex;
flex-direction: column;
gap: 4px;
padding: 4px;
border-radius: var(--radius-sm);
background: var(--ctv-bg-sunken);
border: 1px solid var(--border-hairline);
}
.ctv-builder-tpl-option {
display: flex;
align-items: center;
gap: 9px;
padding: 8px 9px;
border-radius: var(--radius-xs);
cursor: pointer;
text-align: left;
background: transparent;
border: none;
}
.ctv-builder-tpl-option-on {
background: color-mix(in srgb, var(--action-primary) 14%, transparent);
}
.ctv-builder-tpl-option-name {
font: var(--weight-medium) var(--text-xs) / 1 var(--font-sans);
color: var(--text-primary);
}
.ctv-builder-tpl-save {
display: flex;
align-items: center;
gap: 7px;
padding: 8px 9px;
border-radius: var(--radius-xs);
cursor: pointer;
background: none;
border: none;
color: var(--text-secondary);
font: var(--weight-medium) var(--text-xs) / 1 var(--font-sans);
}
.ctv-builder-tpl-empty {
padding: 10px 11px;
border-radius: var(--radius-sm);
background: var(--ctv-bg-sunken);
border: 1px solid var(--border-hairline);
font: var(--text-2xs) / 1.4 var(--font-sans);
color: var(--text-secondary);
}
.ctv-builder-config-chips {
margin-top: var(--space-4, 8px);
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.ctv-builder-config-chip {
padding: 3px 8px;
border-radius: var(--radius-xs);
background: var(--ctv-surface-2);
border: 1px solid var(--border-hairline);
font: var(--text-2xs) / 1.4 var(--font-sans);
color: var(--text-secondary);
}
.ctv-builder-adv {
border-top: 1px solid var(--border-hairline);
padding-top: 14px;
}
.ctv-builder-adv-head {
display: flex;
align-items: center;
gap: 9px;
margin-bottom: var(--space-5, 10px);
}
.ctv-builder-adv-head-title {
font: var(--weight-medium) var(--text-sm) / 1 var(--font-sans);
color: var(--text-primary);
}
.ctv-builder-adv-count {
font: var(--text-2xs) / 1 var(--font-mono);
color: var(--text-disabled);
}
.ctv-builder-adv-tabs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-4, 8px);
}
.ctv-builder-adv-tab {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 10px;
border-radius: var(--radius-sm);
cursor: pointer;
font: var(--weight-medium) var(--text-xs) / 1 var(--font-sans);
background: var(--ctv-surface-2);
color: var(--text-secondary);
border: 1px solid var(--border-hairline);
transition: background 200ms cubic-bezier(0.16, 1, 0.3, 1), color 200ms, border-color 200ms;
}
.ctv-builder-adv-tab-on {
background: color-mix(in srgb, var(--action-primary) 14%, transparent);
color: var(--action-primary);
border-color: color-mix(in srgb, var(--action-primary) 38%, transparent);
}
.ctv-builder-adv-panel {
overflow: hidden;
transition: max-height 340ms cubic-bezier(0.16, 1, 0.3, 1);
}
.ctv-builder-adv-fields {
margin-top: var(--space-6, 12px);
display: flex;
flex-direction: column;
gap: 14px;
}
.ctv-builder-adv-info {
display: flex;
align-items: center;
gap: var(--space-4, 8px);
font: var(--text-2xs) / 1.35 var(--font-sans);
color: var(--text-secondary);
}
.ctv-builder-adv-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-4, 8px);
}
.ctv-builder-adv-readonly {
padding: 7px 9px;
border-radius: var(--radius-xs);
background: var(--ctv-bg-sunken);
border: 1px solid var(--border-hairline);
opacity: 0.72;
}
.ctv-builder-adv-readonly-key {
font: var(--text-2xs) / 1 var(--font-sans);
color: var(--text-disabled);
}
.ctv-builder-adv-readonly-value {
margin-top: 3px;
font: var(--weight-medium) var(--text-xs) / 1 var(--font-sans);
color: var(--text-primary);
}
.ctv-builder-adv-switchrow {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4, 8px);
padding: 7px 9px;
border-radius: var(--radius-xs);
background: var(--ctv-bg-sunken);
border: 1px solid var(--border-hairline);
font: var(--text-xs) / 1.2 var(--font-sans);
color: var(--text-primary);
}
/* segmented control (builder-local) */
.ctv-builder-seg {
position: relative;
display: grid;
grid-auto-flow: column;
grid-auto-columns: 1fr;
padding: 2px;
border-radius: var(--radius-sm);
background: var(--ctv-bg-sunken);
border: 1px solid var(--border-hairline);
}
.ctv-builder-seg-thumb {
position: absolute;
top: 2px;
bottom: 2px;
left: 2px;
background: var(--ctv-surface-3);
border-radius: var(--radius-xs);
box-shadow: var(--shadow-sm);
transition: transform 300ms cubic-bezier(0.16, 1, 0.3, 1);
pointer-events: none;
}
.ctv-builder-seg-btn {
position: relative;
z-index: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 5px 10px;
border: none;
background: transparent;
cursor: pointer;
color: var(--text-secondary);
transition: color 220ms cubic-bezier(0.16, 1, 0.3, 1);
font: var(--weight-medium) var(--text-2xs) / 1 var(--font-sans);
}
.ctv-builder-seg-btn-on {
color: var(--text-primary);
}
.ctv-builder-linkbtn {
background: none;
border: none;
cursor: pointer;
font: var(--text-2xs) / 1 var(--font-sans);
color: var(--text-secondary);
padding: 0;
}
.ctv-builder-disclosure {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 5px;
background: none;
border: none;
cursor: pointer;
padding: 2px 0;
font: var(--text-2xs) / 1 var(--font-sans);
color: var(--text-secondary);
}
.ctv-builder-sep-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-5, 10px);
padding: 10px;
border-radius: var(--radius-sm);
background: var(--ctv-bg-sunken);
border: 1px solid var(--border-hairline);
}
/* Shared motion utilities — ported from the prototype (chicorytv-admin/app.html).
Subtle, iOS/macOS-style: quick, ease-out, small. */
.ctv-lift {
transition:
transform 240ms cubic-bezier(0.16, 1, 0.3, 1),
box-shadow 240ms cubic-bezier(0.16, 1, 0.3, 1);
}
.ctv-lift:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.ctv-press {
transition:
transform 150ms cubic-bezier(0.16, 1, 0.3, 1),
background 220ms cubic-bezier(0.16, 1, 0.3, 1),
color 220ms cubic-bezier(0.16, 1, 0.3, 1),
border-color 220ms cubic-bezier(0.16, 1, 0.3, 1);
}
.ctv-press:active {
transform: scale(0.96);
}
@media (prefers-reduced-motion: reduce) {
.ctv-lift,
.ctv-press,
.ctv-builder-seg-thumb,
.ctv-builder-seg-btn,
.ctv-builder-adv-panel,
.ctv-builder-adv-tab,
.ctv-builder-lineup-row,
.ctv-builder-libcard,
.ctv-builder-toggle-live,
.ctv-builder-added-check {
transition: none;
animation: none;
}
.ctv-lift:hover,
.ctv-press:active {
transform: none;
}
}
@media (max-width: 980px) {
.ctv-app-shell {
grid-template-columns: 1fr;
@@ -2245,4 +3088,24 @@
grid-column: 2 / -1;
justify-self: start;
}
.ctv-builder {
height: auto;
margin: calc(var(--space-6, 12px) * -1);
}
.ctv-builder-body {
grid-template-columns: 1fr;
}
.ctv-builder-col,
.ctv-builder-col-rail {
overflow: visible;
border-right: none;
border-bottom: 1px solid var(--border-hairline);
}
.ctv-builder-scroll {
overflow: visible;
}
}