feat(web): Channel Builder (#89) #136
+728
-1
@@ -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
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,3 +4,4 @@ export * from './dataDisplay';
|
||||
export * from './feedback';
|
||||
export * from './forms';
|
||||
export * from './navigation';
|
||||
export * from './overlay';
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user