Merge pull request 'feat(web): Schedule editor screen (#86)' (#125) from feat/86-schedule-editor into main
This commit was merged in pull request #125.
This commit is contained in:
+529
-3
@@ -1,4 +1,4 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { App } from './App';
|
||||
import { Button, Checkbox, Input, ProgressBar, Switch, Tabs, Toast, Tooltip } from './components';
|
||||
@@ -52,7 +52,7 @@ describe('ChicoryTV SPA scaffold', () => {
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Schedules' }));
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Schedules' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Schedule editor workspace')).toBeInTheDocument();
|
||||
expect(await screen.findByText('No schedules returned')).toBeInTheDocument();
|
||||
expect(window.location.pathname).toBe('/app/schedules');
|
||||
});
|
||||
|
||||
@@ -648,6 +648,318 @@ describe('ChicoryTV SPA scaffold', () => {
|
||||
expect(screen.getByRole('button', { name: 'All 2' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Schedule editor from live schedule APIs', async () => {
|
||||
mockDashboardApi({
|
||||
scheduleItems: [
|
||||
scheduleItem({ durationEstimate: '01:30:00', id: 11, name: 'Saturday Cartoons' }),
|
||||
scheduleItem({
|
||||
collection: { id: 6, name: 'Station IDs' },
|
||||
collectionType: 'Collection',
|
||||
durationEstimate: null,
|
||||
guideMode: 'Filler',
|
||||
id: 12,
|
||||
name: 'Station IDs',
|
||||
playoutMode: 'Flood'
|
||||
})
|
||||
],
|
||||
schedules: [schedule({ id: 5, name: 'Prime Time Cartoons' })],
|
||||
scheduleItemsTotalDuration: '01:30:00'
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Schedules' }));
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Prime Time Cartoons' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('list', { name: 'Schedule lineup' })).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Saturday Cartoons').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('01:30:00').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('Station IDs').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('unknown')).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: 'Content' })).toHaveAttribute('aria-selected', 'true');
|
||||
expect(screen.getAllByDisplayValue('Prime Time Cartoons').length).toBeGreaterThan(0);
|
||||
expect(window.fetch).toHaveBeenCalledWith('/api/schedules', expect.any(Object));
|
||||
expect(window.fetch).toHaveBeenCalledWith('/api/schedules/5/items', expect.any(Object));
|
||||
expect(window.fetch).toHaveBeenCalledWith('/api/collections', expect.any(Object));
|
||||
expect(window.fetch).not.toHaveBeenCalledWith('/api/languages', expect.any(Object));
|
||||
});
|
||||
|
||||
it('fetches the schedules screen data exactly once on mount', async () => {
|
||||
mockDashboardApi({ schedules: [schedule({ id: 5, name: 'Prime Time Cartoons' })] });
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Schedules' }));
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Prime Time Cartoons' })).toBeInTheDocument();
|
||||
expect(fetchCount('/api/schedules')).toBe(1);
|
||||
});
|
||||
|
||||
it('shows the Schedule editor error state when a picker endpoint fails', async () => {
|
||||
vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => {
|
||||
const path = input.toString();
|
||||
|
||||
if (path === '/api/schedules') {
|
||||
return Promise.resolve(jsonResponse([schedule({ id: 5, name: 'Prime Time Cartoons' })]));
|
||||
}
|
||||
|
||||
if (path === '/api/schedules/5/items') {
|
||||
return Promise.resolve(jsonResponse({ items: [], totalDurationEstimate: null }));
|
||||
}
|
||||
|
||||
if (path === '/api/collections') {
|
||||
return Promise.resolve(jsonResponse({
|
||||
detail: 'Collections service is unavailable',
|
||||
status: 500,
|
||||
title: 'Internal error'
|
||||
}, 500));
|
||||
}
|
||||
|
||||
return Promise.resolve(jsonResponse([]));
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Schedules' }));
|
||||
|
||||
expect(await screen.findByText('Collections service is unavailable')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the Schedule editor loading state', async () => {
|
||||
vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => {
|
||||
const path = input.toString();
|
||||
|
||||
if (path === '/api/schedules') {
|
||||
return new Promise<Response>(() => {});
|
||||
}
|
||||
|
||||
return Promise.resolve(jsonResponse([]));
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Schedules' }));
|
||||
|
||||
expect(await screen.findByText('Loading schedules')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Schedule editor API errors and retries 404 parent handling', async () => {
|
||||
mockDashboardApi({
|
||||
scheduleItemFailuresBeforeSuccess: 1,
|
||||
scheduleItemFailure: {
|
||||
detail: 'Schedule 5 was not found',
|
||||
status: 404,
|
||||
title: 'Not found'
|
||||
},
|
||||
schedules: [schedule({ id: 5, name: 'Missing Schedule' })]
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Schedules' }));
|
||||
|
||||
expect(await screen.findByText('Schedule 5 was not found')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
|
||||
|
||||
expect(await screen.findByText('No schedule items')).toBeInTheDocument();
|
||||
expect(fetchCount('/api/schedules/5/items')).toBe(2);
|
||||
});
|
||||
|
||||
it('shows an empty Schedule editor state when no schedule exists', async () => {
|
||||
mockDashboardApi({ schedules: [] });
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Schedules' }));
|
||||
|
||||
expect(await screen.findByText('No schedules returned')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an empty Schedule editor lineup for a schedule with no items', async () => {
|
||||
mockDashboardApi({ schedules: [schedule({ id: 5, name: 'Empty Schedule' })] });
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Schedules' }));
|
||||
|
||||
expect(await screen.findByText('No schedule items')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Add item' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reorders schedule items with keyboard controls and sends the exact replace-all body', async () => {
|
||||
const first = scheduleItem({ id: 11, name: 'Saturday Cartoons' });
|
||||
const second = scheduleItem({
|
||||
collection: { id: 6, name: 'Station IDs' },
|
||||
durationEstimate: null,
|
||||
guideMode: 'Filler',
|
||||
id: 12,
|
||||
name: 'Station IDs',
|
||||
playoutMode: 'Flood'
|
||||
});
|
||||
|
||||
mockDashboardApi({
|
||||
replaceScheduleItemsResponse: [second, first],
|
||||
scheduleItems: [first, second],
|
||||
schedules: [schedule({ id: 5, name: 'Prime Time Cartoons' })]
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Schedules' }));
|
||||
expect(await screen.findAllByText('Saturday Cartoons')).not.toHaveLength(0);
|
||||
|
||||
const collectionsFetchesBeforeReorder = fetchCount('/api/collections');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Move Station IDs up' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.fetch).toHaveBeenCalledWith('/api/schedules/5/items', expect.objectContaining({
|
||||
body: JSON.stringify({ items: [scheduleItemRequest(second), scheduleItemRequest(first)] }),
|
||||
method: 'PUT'
|
||||
}));
|
||||
});
|
||||
const lineup = screen.getByRole('list', { name: 'Schedule lineup' });
|
||||
const rows = within(lineup).getAllByRole('listitem');
|
||||
expect(rows[0]).toHaveTextContent('Station IDs');
|
||||
expect(rows[1]).toHaveTextContent('Saturday Cartoons');
|
||||
expect(fetchCount('/api/collections')).toBe(collectionsFetchesBeforeReorder);
|
||||
});
|
||||
|
||||
it('rolls back schedule reorder and shows ProblemDetails on replace failure', async () => {
|
||||
mockDashboardApi({
|
||||
mutationFailures: {
|
||||
'/api/schedules/5/items': {
|
||||
detail: 'Schedule items are locked',
|
||||
status: 422,
|
||||
title: 'Validation failed'
|
||||
}
|
||||
},
|
||||
scheduleItems: [
|
||||
scheduleItem({ id: 11, name: 'Saturday Cartoons' }),
|
||||
scheduleItem({ collection: { id: 6, name: 'Station IDs' }, id: 12, name: 'Station IDs' })
|
||||
],
|
||||
schedules: [schedule({ id: 5, name: 'Prime Time Cartoons' })]
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Schedules' }));
|
||||
expect(await screen.findAllByText('Saturday Cartoons')).not.toHaveLength(0);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Move Station IDs up' }));
|
||||
|
||||
expect(await screen.findByText('Schedule items are locked')).toBeInTheDocument();
|
||||
const lineup = screen.getByRole('list', { name: 'Schedule lineup' });
|
||||
expect(within(lineup).getAllByRole('listitem')[0]).toHaveTextContent('Saturday Cartoons');
|
||||
});
|
||||
|
||||
it('adds schedule items using the selected (non-first) collection', async () => {
|
||||
const added = scheduleItem({ collection: { id: 4, name: 'Nature Docs' }, id: 20, name: 'Nature Docs' });
|
||||
mockDashboardApi({
|
||||
addScheduleItemResponse: added,
|
||||
collections: [
|
||||
collection({ id: 3, name: 'Movie Mix' }),
|
||||
collection({ id: 4, name: 'Nature Docs' })
|
||||
],
|
||||
scheduleItems: [],
|
||||
scheduleItemsAfterAdd: [added],
|
||||
schedules: [schedule({ id: 5, name: 'Prime Time Cartoons' })]
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Schedules' }));
|
||||
expect(await screen.findByText('No schedule items')).toBeInTheDocument();
|
||||
|
||||
const collectionsFetchesBeforeAdd = fetchCount('/api/collections');
|
||||
|
||||
fireEvent.change(screen.getByRole('combobox', { name: 'Collection for new item' }), {
|
||||
target: { value: '4' }
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add item' }));
|
||||
|
||||
expect(await screen.findAllByText('Nature Docs')).not.toHaveLength(0);
|
||||
const expectedBody = scheduleItemRequest(added);
|
||||
expect(expectedBody.collectionId).toBe(4);
|
||||
expect(expectedBody.collectionType).toBe('Collection');
|
||||
expect(window.fetch).toHaveBeenCalledWith('/api/schedules/5/items', expect.objectContaining({
|
||||
body: JSON.stringify(expectedBody),
|
||||
method: 'POST'
|
||||
}));
|
||||
expect(fetchCount('/api/schedules/5/items')).toBeGreaterThan(2);
|
||||
expect(fetchCount('/api/collections')).toBe(collectionsFetchesBeforeAdd);
|
||||
});
|
||||
|
||||
it('shows ProblemDetails when adding a schedule item fails', async () => {
|
||||
mockDashboardApi({
|
||||
collections: [collection({ id: 3, name: 'Movie Mix' })],
|
||||
mutationFailures: {
|
||||
'/api/schedules/5/items': {
|
||||
detail: 'Collection is required',
|
||||
status: 422,
|
||||
title: 'Validation failed'
|
||||
}
|
||||
},
|
||||
scheduleItems: [],
|
||||
schedules: [schedule({ id: 5, name: 'Prime Time Cartoons' })]
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Schedules' }));
|
||||
expect(await screen.findByText('No schedule items')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add item' }));
|
||||
|
||||
expect(await screen.findByText('Collection is required')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('deletes schedule items and refetches after success', async () => {
|
||||
mockDashboardApi({
|
||||
confirm: true,
|
||||
scheduleItems: [scheduleItem({ id: 11, name: 'Saturday Cartoons' })],
|
||||
scheduleItemsAfterDelete: [],
|
||||
schedules: [schedule({ id: 5, name: 'Prime Time Cartoons' })]
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Schedules' }));
|
||||
expect(await screen.findAllByText('Saturday Cartoons')).not.toHaveLength(0);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove Saturday Cartoons' }));
|
||||
|
||||
expect(await screen.findByText('No schedule items')).toBeInTheDocument();
|
||||
expect(window.fetch).toHaveBeenCalledWith('/api/schedules/5/items/11', expect.objectContaining({
|
||||
method: 'DELETE'
|
||||
}));
|
||||
});
|
||||
|
||||
it('shows ProblemDetails when deleting a schedule item fails', async () => {
|
||||
mockDashboardApi({
|
||||
confirm: true,
|
||||
mutationFailures: {
|
||||
'/api/schedules/5/items/11': {
|
||||
detail: 'Item is in use by an active playout',
|
||||
status: 422,
|
||||
title: 'Validation failed'
|
||||
}
|
||||
},
|
||||
scheduleItems: [scheduleItem({ id: 11, name: 'Saturday Cartoons' })],
|
||||
schedules: [schedule({ id: 5, name: 'Prime Time Cartoons' })]
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Schedules' }));
|
||||
expect(await screen.findAllByText('Saturday Cartoons')).not.toHaveLength(0);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove Saturday Cartoons' }));
|
||||
|
||||
expect(await screen.findByText('Item is in use by an active playout')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the dashboard loading state while requests are pending', async () => {
|
||||
vi.spyOn(window, 'fetch').mockImplementation(() => new Promise<Response>(() => {}));
|
||||
|
||||
@@ -802,31 +1114,184 @@ function jsonResponse(body: unknown, status = 200): Response {
|
||||
});
|
||||
}
|
||||
|
||||
function schedule(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
fixedStartTimeBehavior: 'Flexible',
|
||||
id: 1,
|
||||
keepMultiPartEpisodesTogether: true,
|
||||
name: 'Default Schedule',
|
||||
randomStartPoint: false,
|
||||
shuffleScheduleItems: false,
|
||||
treatCollectionsAsShows: false,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function collection(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
collectionType: 'Collection',
|
||||
id: 2,
|
||||
name: 'Saturday Cartoons',
|
||||
useCustomPlaybackOrder: false,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleItem(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
const collection = { id: 2, name: 'Saturday Cartoons', ...(overrides.collection as object | undefined) };
|
||||
|
||||
return {
|
||||
collection,
|
||||
collectionType: 'Collection',
|
||||
customTitle: null,
|
||||
discardToFillAttempts: null,
|
||||
durationEstimate: '00:25:00',
|
||||
fallbackFiller: null,
|
||||
fillWithGroupMode: 'None',
|
||||
fixedStartTimeBehavior: null,
|
||||
graphicsElements: [],
|
||||
guideMode: 'Normal',
|
||||
id: 11,
|
||||
index: 0,
|
||||
marathonBatchSize: null,
|
||||
marathonGroupBy: 'None',
|
||||
marathonShuffleGroups: false,
|
||||
marathonShuffleItems: false,
|
||||
mediaItem: null,
|
||||
midRollFiller: null,
|
||||
multipleCount: null,
|
||||
multipleMode: 'Count',
|
||||
multiCollection: null,
|
||||
name: collection.name,
|
||||
playbackOrder: 'Shuffle',
|
||||
playlist: null,
|
||||
playoutDuration: null,
|
||||
playoutMode: 'One',
|
||||
postRollFiller: null,
|
||||
preferredAudioLanguageCode: null,
|
||||
preferredAudioTitle: null,
|
||||
preferredSubtitleLanguageCode: null,
|
||||
preRollFiller: null,
|
||||
rerunCollection: null,
|
||||
searchQuery: null,
|
||||
searchTitle: null,
|
||||
smartCollection: null,
|
||||
startTime: null,
|
||||
startType: 'Dynamic',
|
||||
subtitleMode: null,
|
||||
tailFiller: null,
|
||||
tailMode: 'None',
|
||||
watermarkIds: [],
|
||||
watermarks: [],
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleItemRequest(item: Record<string, unknown>): Record<string, unknown> {
|
||||
const collection = item.collection as { id?: number } | null | undefined;
|
||||
const multiCollection = item.multiCollection as { id?: number } | null | undefined;
|
||||
const smartCollection = item.smartCollection as { id?: number } | null | undefined;
|
||||
const rerunCollection = item.rerunCollection as { id?: number } | null | undefined;
|
||||
const mediaItem = item.mediaItem as { id?: number } | null | undefined;
|
||||
const playlist = item.playlist as { id?: number } | null | undefined;
|
||||
const watermarks = item.watermarks as Array<{ id: number }> | null | undefined;
|
||||
const graphicsElements = item.graphicsElements as Array<{ id: number }> | null | undefined;
|
||||
const fillerId = (value: unknown) => (value as { id?: number } | null | undefined)?.id ?? null;
|
||||
|
||||
return {
|
||||
collectionId: collection?.id ?? null,
|
||||
collectionType: item.collectionType ?? 'Collection',
|
||||
customTitle: item.customTitle ?? null,
|
||||
discardToFillAttempts: item.discardToFillAttempts ?? null,
|
||||
fallbackFillerId: fillerId(item.fallbackFiller),
|
||||
fillWithGroupMode: item.fillWithGroupMode ?? 'None',
|
||||
fixedStartTimeBehavior: item.fixedStartTimeBehavior ?? null,
|
||||
graphicsElementIds: graphicsElements?.map((element) => element.id) ?? [],
|
||||
guideMode: item.guideMode ?? 'Normal',
|
||||
marathonBatchSize: item.marathonBatchSize ?? null,
|
||||
marathonGroupBy: item.marathonGroupBy ?? 'None',
|
||||
marathonShuffleGroups: item.marathonShuffleGroups ?? false,
|
||||
marathonShuffleItems: item.marathonShuffleItems ?? false,
|
||||
mediaItemId: mediaItem?.id ?? null,
|
||||
midRollFillerId: fillerId(item.midRollFiller),
|
||||
multipleCount: item.multipleCount ?? null,
|
||||
multipleMode: item.multipleMode ?? 'Count',
|
||||
multiCollectionId: multiCollection?.id ?? null,
|
||||
playbackOrder: item.playbackOrder ?? 'Shuffle',
|
||||
playlistId: playlist?.id ?? null,
|
||||
playoutDuration: item.playoutDuration ?? null,
|
||||
playoutMode: item.playoutMode ?? 'One',
|
||||
postRollFillerId: fillerId(item.postRollFiller),
|
||||
preferredAudioLanguageCode: item.preferredAudioLanguageCode ?? null,
|
||||
preferredAudioTitle: item.preferredAudioTitle ?? null,
|
||||
preferredSubtitleLanguageCode: item.preferredSubtitleLanguageCode ?? null,
|
||||
preRollFillerId: fillerId(item.preRollFiller),
|
||||
rerunCollectionId: rerunCollection?.id ?? null,
|
||||
searchQuery: item.searchQuery ?? null,
|
||||
searchTitle: item.searchTitle ?? null,
|
||||
smartCollectionId: smartCollection?.id ?? null,
|
||||
startTime: item.startTime ?? null,
|
||||
startType: item.startType ?? 'Dynamic',
|
||||
subtitleMode: item.subtitleMode ?? null,
|
||||
tailFillerId: fillerId(item.tailFiller),
|
||||
tailMode: item.tailMode ?? 'None',
|
||||
watermarkIds: watermarks?.map((watermark) => watermark.id) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function mockDashboardApi({
|
||||
addScheduleItemResponse = null,
|
||||
channels = [],
|
||||
channelStates = [],
|
||||
collections = [],
|
||||
confirm = false,
|
||||
fillerPresets = [],
|
||||
health = [],
|
||||
mediaSources = [],
|
||||
multiCollections = [],
|
||||
mutationFailures = {},
|
||||
playouts = { page: [], totalCount: 0 },
|
||||
prompt = null,
|
||||
replaceScheduleItemsResponse = null,
|
||||
scheduleItemFailure = null,
|
||||
scheduleItemFailuresBeforeSuccess = 0,
|
||||
scheduleItems = [],
|
||||
scheduleItemsAfterAdd = null,
|
||||
scheduleItemsAfterDelete = null,
|
||||
scheduleItemsTotalDuration = null,
|
||||
schedules = [],
|
||||
smartCollections = [],
|
||||
version = { apiVersion: 3, appVersion: '26.4.0' }
|
||||
}: {
|
||||
addScheduleItemResponse?: unknown;
|
||||
channels?: unknown[];
|
||||
channelStates?: unknown[];
|
||||
collections?: unknown[];
|
||||
confirm?: boolean;
|
||||
fillerPresets?: unknown[];
|
||||
health?: unknown[];
|
||||
mediaSources?: unknown[];
|
||||
multiCollections?: unknown[];
|
||||
mutationFailures?: Record<string, unknown>;
|
||||
playouts?: unknown;
|
||||
prompt?: string | null;
|
||||
replaceScheduleItemsResponse?: unknown;
|
||||
scheduleItemFailure?: unknown;
|
||||
scheduleItemFailuresBeforeSuccess?: number;
|
||||
scheduleItems?: unknown[];
|
||||
scheduleItemsAfterAdd?: unknown[] | null;
|
||||
scheduleItemsAfterDelete?: unknown[] | null;
|
||||
scheduleItemsTotalDuration?: string | null;
|
||||
schedules?: unknown[];
|
||||
smartCollections?: unknown[];
|
||||
version?: unknown;
|
||||
} = {}) {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(confirm);
|
||||
vi.spyOn(window, 'prompt').mockReturnValue(prompt);
|
||||
let currentScheduleItems = scheduleItems;
|
||||
let remainingScheduleItemFailures = scheduleItemFailuresBeforeSuccess;
|
||||
|
||||
vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => {
|
||||
vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const path = input.toString();
|
||||
|
||||
if (path === '/api/channels') {
|
||||
@@ -837,6 +1302,67 @@ function mockDashboardApi({
|
||||
return Promise.resolve(jsonResponse(channelStates));
|
||||
}
|
||||
|
||||
if (path === '/api/schedules') {
|
||||
return Promise.resolve(jsonResponse(schedules));
|
||||
}
|
||||
|
||||
if (path.match(/^\/api\/schedules\/\d+\/items$/)) {
|
||||
const method = init?.method ?? 'GET';
|
||||
|
||||
if (method !== 'GET' && path in mutationFailures) {
|
||||
return Promise.resolve(jsonResponse(mutationFailures[path], 422));
|
||||
}
|
||||
|
||||
if (method === 'POST') {
|
||||
currentScheduleItems = scheduleItemsAfterAdd ?? (addScheduleItemResponse ? [...currentScheduleItems, addScheduleItemResponse] : currentScheduleItems);
|
||||
return Promise.resolve(jsonResponse(addScheduleItemResponse ?? null, 201));
|
||||
}
|
||||
|
||||
if (method === 'PUT') {
|
||||
currentScheduleItems = (replaceScheduleItemsResponse as unknown[] | null) ?? currentScheduleItems;
|
||||
return Promise.resolve(jsonResponse(currentScheduleItems));
|
||||
}
|
||||
|
||||
if (remainingScheduleItemFailures > 0) {
|
||||
remainingScheduleItemFailures -= 1;
|
||||
return Promise.resolve(jsonResponse(scheduleItemFailure, 404));
|
||||
}
|
||||
|
||||
return Promise.resolve(jsonResponse({
|
||||
items: currentScheduleItems,
|
||||
totalDurationEstimate: scheduleItemsTotalDuration
|
||||
}));
|
||||
}
|
||||
|
||||
if (path.match(/^\/api\/schedules\/\d+\/items\/\d+$/)) {
|
||||
if (path in mutationFailures) {
|
||||
return Promise.resolve(jsonResponse(mutationFailures[path], 422));
|
||||
}
|
||||
|
||||
currentScheduleItems = scheduleItemsAfterDelete ?? currentScheduleItems;
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
}
|
||||
|
||||
if (path === '/api/collections') {
|
||||
return Promise.resolve(jsonResponse(collections));
|
||||
}
|
||||
|
||||
if (path === '/api/smart-collections') {
|
||||
return Promise.resolve(jsonResponse(smartCollections));
|
||||
}
|
||||
|
||||
if (path === '/api/multi-collections') {
|
||||
return Promise.resolve(jsonResponse(multiCollections));
|
||||
}
|
||||
|
||||
if (path === '/api/filler-presets') {
|
||||
return Promise.resolve(jsonResponse(fillerPresets));
|
||||
}
|
||||
|
||||
if (path === '/api/watermarks') {
|
||||
return Promise.resolve(jsonResponse([]));
|
||||
}
|
||||
|
||||
if (path === '/api/media-sources') {
|
||||
return Promise.resolve(jsonResponse(mediaSources));
|
||||
}
|
||||
|
||||
+661
-3
@@ -3,11 +3,13 @@ import {
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type DragEvent,
|
||||
type KeyboardEvent,
|
||||
type MouseEvent,
|
||||
type ReactNode
|
||||
} from 'react';
|
||||
import {
|
||||
ArrowDownWideNarrow,
|
||||
Bell,
|
||||
CalendarClock,
|
||||
Cast,
|
||||
@@ -15,9 +17,11 @@ import {
|
||||
ChevronDown,
|
||||
CircleHelp,
|
||||
ClipboardCopy,
|
||||
Copy,
|
||||
Folder,
|
||||
FolderInput,
|
||||
FolderTree,
|
||||
GripVertical,
|
||||
Hash,
|
||||
Info,
|
||||
LayoutDashboard,
|
||||
@@ -31,10 +35,12 @@ import {
|
||||
RefreshCw,
|
||||
Search,
|
||||
Settings,
|
||||
Shuffle,
|
||||
Stethoscope,
|
||||
Timer,
|
||||
Trash2,
|
||||
TriangleAlert,
|
||||
Tv,
|
||||
Tv
|
||||
} from 'lucide-react';
|
||||
import chicoryIconUrl from '../../design-system/assets/chicorytv-icon.svg';
|
||||
import {
|
||||
@@ -44,12 +50,15 @@ import {
|
||||
Checkbox,
|
||||
ChannelLogo,
|
||||
IconButton,
|
||||
Input,
|
||||
NavItem,
|
||||
NavSection,
|
||||
ProgressBar,
|
||||
Select,
|
||||
Spinner,
|
||||
Stat,
|
||||
StatusDot
|
||||
StatusDot,
|
||||
Tabs
|
||||
} from './components';
|
||||
import {
|
||||
useChannelsQuery,
|
||||
@@ -59,14 +68,23 @@ import {
|
||||
bulkRenumberChannels,
|
||||
deleteChannel,
|
||||
messageFromError,
|
||||
addScheduleItem,
|
||||
deleteScheduleItem,
|
||||
replaceScheduleItems,
|
||||
useScheduleScreenQuery,
|
||||
useDashboardHealthQuery,
|
||||
useDashboardQuery,
|
||||
useDashboardVersionQuery,
|
||||
type ChannelState,
|
||||
type ChannelSummary,
|
||||
type DashboardChannel,
|
||||
type SchedulePickerData,
|
||||
type DashboardChannelState,
|
||||
type DashboardHealthQueryState
|
||||
type DashboardHealthQueryState,
|
||||
type MediaCollection,
|
||||
type ProgramSchedule,
|
||||
type ProgramScheduleItem,
|
||||
type ScheduleItemRequest
|
||||
} from './api';
|
||||
import {
|
||||
applyDesignSystemTheme,
|
||||
@@ -1171,6 +1189,642 @@ function ChannelTableRow({
|
||||
);
|
||||
}
|
||||
|
||||
type ScheduleInspectorTab = 'content' | 'playback' | 'filler' | 'overrides';
|
||||
|
||||
function SchedulesLoadingState() {
|
||||
return (
|
||||
<Card>
|
||||
<div className="ctv-dashboard-state">
|
||||
<Spinner size={20} tone="accent" />
|
||||
<span>Loading schedules</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function SchedulesErrorState({ error, refresh }: { error: string; refresh: () => void }) {
|
||||
return (
|
||||
<Card
|
||||
title={<h2>Schedules unavailable</h2>}
|
||||
subtitle="Live API request failed"
|
||||
actions={<Button onClick={refresh} startIcon={<RefreshCw aria-hidden="true" size={14} />} variant="secondary">Retry</Button>}
|
||||
>
|
||||
<div className="ctv-dashboard-error">
|
||||
<span>API request failed</span>
|
||||
<strong>{error}</strong>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function SchedulesEmptyState() {
|
||||
return (
|
||||
<Card title={<h2>No schedules</h2>} subtitle="The API returned an empty schedule list.">
|
||||
<div className="ctv-dashboard-empty">No schedules returned</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleScreen() {
|
||||
const query = useScheduleScreenQuery();
|
||||
const [selectedItemId, setSelectedItemId] = useState<number | null>(null);
|
||||
const [selectedCollectionId, setSelectedCollectionId] = useState<number | null>(null);
|
||||
const [dragItemId, setDragItemId] = useState<number | null>(null);
|
||||
const [overItemId, setOverItemId] = useState<number | null>(null);
|
||||
const [inspectorTab, setInspectorTab] = useState<ScheduleInspectorTab>('content');
|
||||
const [mutationError, setMutationError] = useState<string | null>(null);
|
||||
const [mutating, setMutating] = useState(false);
|
||||
const mutatingRef = useRef(false);
|
||||
|
||||
if (query.status === 'loading') {
|
||||
return <SchedulesLoadingState />;
|
||||
}
|
||||
|
||||
if (query.status === 'error') {
|
||||
return <SchedulesErrorState error={query.error} refresh={query.refresh} />;
|
||||
}
|
||||
|
||||
const { activeSchedule, items, pickers, schedules, totalDurationEstimate } = query.data;
|
||||
const { itemsLoading, setActiveSchedule, setItems } = query;
|
||||
|
||||
if (!activeSchedule) {
|
||||
return <SchedulesEmptyState />;
|
||||
}
|
||||
|
||||
const orderedItems = sortedScheduleItems(items);
|
||||
const selectedItem = orderedItems.find((item) => item.id === selectedItemId) ?? orderedItems[0] ?? null;
|
||||
const effectiveSelectedItemId = selectedItem?.id ?? null;
|
||||
const selectedIndex = selectedItem ? orderedItems.findIndex((item) => item.id === selectedItem.id) : -1;
|
||||
const collectionOptions = sortedCollections(pickers.collections);
|
||||
const effectiveCollectionId = collectionOptions.some((collection) => collection.id === selectedCollectionId)
|
||||
? selectedCollectionId
|
||||
: collectionOptions[0]?.id ?? null;
|
||||
|
||||
const setMutatingState = (value: boolean) => {
|
||||
mutatingRef.current = value;
|
||||
setMutating(value);
|
||||
};
|
||||
|
||||
const refetchItemsAfterMutation = async (operation: () => Promise<void>) => {
|
||||
setMutationError(null);
|
||||
setMutatingState(true);
|
||||
|
||||
try {
|
||||
await operation();
|
||||
setActiveSchedule(activeSchedule.id);
|
||||
} catch (error: unknown) {
|
||||
setMutationError(messageFromError(error));
|
||||
} finally {
|
||||
setMutatingState(false);
|
||||
}
|
||||
};
|
||||
|
||||
const persistOrder = async (nextItems: ProgramScheduleItem[]) => {
|
||||
const previousSelectedId = effectiveSelectedItemId;
|
||||
setSelectedItemId((current) => current ?? previousSelectedId ?? nextItems[0]?.id ?? null);
|
||||
|
||||
try {
|
||||
setMutationError(null);
|
||||
setMutatingState(true);
|
||||
const result = await replaceScheduleItems(activeSchedule.id, {
|
||||
items: nextItems.map(scheduleItemToRequest)
|
||||
});
|
||||
setSelectedItemId(previousSelectedId ?? result[0]?.id ?? null);
|
||||
setItems(result);
|
||||
} catch (error: unknown) {
|
||||
setSelectedItemId(previousSelectedId);
|
||||
setMutationError(messageFromError(error));
|
||||
} finally {
|
||||
setMutatingState(false);
|
||||
}
|
||||
};
|
||||
|
||||
const moveItem = (itemId: number, direction: -1 | 1) => {
|
||||
if (mutatingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const index = orderedItems.findIndex((item) => item.id === itemId);
|
||||
const nextIndex = index + direction;
|
||||
|
||||
if (index < 0 || nextIndex < 0 || nextIndex >= orderedItems.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextItems = [...orderedItems];
|
||||
const [moved] = nextItems.splice(index, 1);
|
||||
nextItems.splice(nextIndex, 0, moved);
|
||||
void persistOrder(nextItems);
|
||||
};
|
||||
|
||||
const dropItem = (targetId: number) => {
|
||||
if (dragItemId == null || dragItemId === targetId || mutatingRef.current) {
|
||||
setDragItemId(null);
|
||||
setOverItemId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const from = orderedItems.findIndex((item) => item.id === dragItemId);
|
||||
const to = orderedItems.findIndex((item) => item.id === targetId);
|
||||
|
||||
if (from >= 0 && to >= 0) {
|
||||
const nextItems = [...orderedItems];
|
||||
const [moved] = nextItems.splice(from, 1);
|
||||
nextItems.splice(to, 0, moved);
|
||||
void persistOrder(nextItems);
|
||||
}
|
||||
|
||||
setDragItemId(null);
|
||||
setOverItemId(null);
|
||||
};
|
||||
|
||||
const addItem = () => {
|
||||
const collection = collectionOptions.find((candidate) => candidate.id === effectiveCollectionId);
|
||||
|
||||
if (!collection) {
|
||||
setMutationError('A collection is required before adding a schedule item');
|
||||
return;
|
||||
}
|
||||
|
||||
void refetchItemsAfterMutation(async () => {
|
||||
const added = await addScheduleItem(activeSchedule.id, newScheduleItemRequest(collection));
|
||||
setSelectedItemId(added.id ?? null);
|
||||
});
|
||||
};
|
||||
|
||||
const deleteItem = (item: ProgramScheduleItem) => {
|
||||
const itemId = item.id;
|
||||
|
||||
if (itemId == null || !window.confirm(`Remove ${scheduleItemName(item)}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
void refetchItemsAfterMutation(async () => {
|
||||
await deleteScheduleItem(activeSchedule.id, itemId);
|
||||
setSelectedItemId(null);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ctv-schedule-screen">
|
||||
<section className="ctv-schedule-header">
|
||||
<span className="ctv-schedule-header-icon"><CalendarClock aria-hidden="true" size={18} /></span>
|
||||
<div>
|
||||
<h2>{activeSchedule.name ?? 'Unnamed schedule'}</h2>
|
||||
<p>
|
||||
{schedules.length} schedule{schedules.length === 1 ? '' : 's'} · {orderedItems.length} item{orderedItems.length === 1 ? '' : 's'} · programs <code>{totalDurationEstimate ?? 'unknown'}</code>
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
disabled={mutating}
|
||||
label="Active schedule"
|
||||
onChange={(event) => setActiveSchedule(Number(event.target.value))}
|
||||
options={scheduleOptions(schedules)}
|
||||
size="sm"
|
||||
value={`${activeSchedule.id}`}
|
||||
/>
|
||||
<Button disabled startIcon={<Play aria-hidden="true" size={15} />} variant="secondary">Preview playout</Button>
|
||||
</section>
|
||||
|
||||
{mutationError && (
|
||||
<div className="ctv-channels-error" role="alert">
|
||||
<TriangleAlert aria-hidden="true" size={15} />
|
||||
<span>{mutationError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ctv-schedule-grid">
|
||||
<section className="ctv-schedule-lineup-panel" aria-label="Lineup">
|
||||
<div className="ctv-schedule-panel-head">
|
||||
<span>Lineup · drag to reorder</span>
|
||||
{itemsLoading && <Spinner size={13} tone="muted" />}
|
||||
<Select
|
||||
disabled={mutating || collectionOptions.length === 0}
|
||||
label="Collection for new item"
|
||||
onChange={(event) => setSelectedCollectionId(Number(event.target.value))}
|
||||
options={pickerOptions(collectionOptions)}
|
||||
size="sm"
|
||||
value={`${effectiveCollectionId ?? ''}`}
|
||||
/>
|
||||
<Button disabled={mutating} onClick={addItem} size="sm" startIcon={<Plus aria-hidden="true" size={14} />} variant="ghost">Add item</Button>
|
||||
</div>
|
||||
|
||||
{orderedItems.length === 0 ? (
|
||||
<div className="ctv-schedule-empty">No schedule items</div>
|
||||
) : (
|
||||
<div className="ctv-schedule-lineup" role="list" aria-label="Schedule lineup">
|
||||
{orderedItems.map((item, index) => (
|
||||
<ScheduleItemBlock
|
||||
active={item.id === selectedItem?.id}
|
||||
dragging={item.id === dragItemId}
|
||||
isFirst={index === 0}
|
||||
isLast={index === orderedItems.length - 1}
|
||||
isOver={item.id === overItemId && dragItemId != null && dragItemId !== item.id}
|
||||
item={item}
|
||||
key={item.id ?? index}
|
||||
mutating={mutating}
|
||||
onDelete={deleteItem}
|
||||
onDrop={dropItem}
|
||||
onMove={moveItem}
|
||||
onOver={setOverItemId}
|
||||
onSelect={setSelectedItemId}
|
||||
onStartDrag={setDragItemId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<ScheduleInspector
|
||||
item={selectedItem}
|
||||
itemIndex={selectedIndex}
|
||||
mutating={mutating}
|
||||
onDelete={deleteItem}
|
||||
pickers={pickers}
|
||||
schedule={activeSchedule}
|
||||
tab={inspectorTab}
|
||||
onTabChange={(value) => setInspectorTab(value as ScheduleInspectorTab)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleItemBlock({
|
||||
active,
|
||||
dragging,
|
||||
isFirst,
|
||||
isLast,
|
||||
isOver,
|
||||
item,
|
||||
mutating,
|
||||
onDelete,
|
||||
onDrop,
|
||||
onMove,
|
||||
onOver,
|
||||
onSelect,
|
||||
onStartDrag
|
||||
}: {
|
||||
active: boolean;
|
||||
dragging: boolean;
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
isOver: boolean;
|
||||
item: ProgramScheduleItem;
|
||||
mutating: boolean;
|
||||
onDelete: (item: ProgramScheduleItem) => void;
|
||||
onDrop: (targetId: number) => void;
|
||||
onMove: (itemId: number, direction: -1 | 1) => void;
|
||||
onOver: (itemId: number | null) => void;
|
||||
onSelect: (itemId: number | null) => void;
|
||||
onStartDrag: (itemId: number | null) => void;
|
||||
}) {
|
||||
const itemId = item.id ?? null;
|
||||
const name = scheduleItemName(item);
|
||||
const fill = scheduleFillDescriptor(item);
|
||||
const fixed = item.startType === 'Fixed';
|
||||
const filler = item.guideMode === 'Filler';
|
||||
|
||||
const onDragOver = (event: DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
onOver(itemId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ctv-schedule-lineup-row" role="listitem">
|
||||
<div className="ctv-schedule-rail" aria-hidden="true">
|
||||
<span className={fixed ? 'ctv-schedule-rail-dot-fixed' : 'ctv-schedule-rail-dot'} />
|
||||
<code>{fixed ? formatScheduleTime(item.startTime) : 'flows'}</code>
|
||||
</div>
|
||||
<div
|
||||
aria-roledescription="draggable schedule item"
|
||||
className={`ctv-schedule-block${active ? ' ctv-schedule-block-active' : ''}${dragging ? ' ctv-schedule-block-dragging' : ''}${isOver ? ' ctv-schedule-block-over' : ''}`}
|
||||
draggable={!mutating}
|
||||
onDragEnd={() => {
|
||||
onStartDrag(null);
|
||||
onOver(null);
|
||||
}}
|
||||
onDragOver={onDragOver}
|
||||
onDragStart={() => onStartDrag(itemId)}
|
||||
onDrop={() => {
|
||||
if (itemId != null) {
|
||||
onDrop(itemId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="ctv-schedule-block-main"
|
||||
aria-current={active ? 'true' : undefined}
|
||||
onClick={() => onSelect(itemId)}
|
||||
>
|
||||
<GripVertical aria-hidden="true" size={16} />
|
||||
<ChannelLogo name={name} size={38} />
|
||||
<span className="ctv-schedule-block-title">
|
||||
<strong>{name}</strong>
|
||||
<small>{formatScheduleCollectionType(item.collectionType)}</small>
|
||||
</span>
|
||||
<span className="ctv-schedule-fill-chip">
|
||||
{fill.icon}
|
||||
<code>{fill.label}</code>
|
||||
</span>
|
||||
</button>
|
||||
<div className="ctv-schedule-block-meta">
|
||||
<span><ArrowDownWideNarrow aria-hidden="true" size={13} />{formatScheduleEnum(item.playbackOrder ?? 'Shuffle')}</span>
|
||||
{filler && <Badge tone="neutral">Hidden from guide</Badge>}
|
||||
<span className="ctv-schedule-block-duration">{item.durationEstimate ?? 'unknown'}</span>
|
||||
</div>
|
||||
<div className="ctv-schedule-block-actions">
|
||||
<IconButton disabled={mutating || isFirst || itemId == null} onClick={() => itemId != null && onMove(itemId, -1)} size="sm" title={`Move ${name} up`}>
|
||||
<ChevronDown className="ctv-schedule-up-icon" aria-hidden="true" size={15} />
|
||||
</IconButton>
|
||||
<IconButton disabled={mutating || isLast || itemId == null} onClick={() => itemId != null && onMove(itemId, 1)} size="sm" title={`Move ${name} down`}>
|
||||
<ChevronDown aria-hidden="true" size={15} />
|
||||
</IconButton>
|
||||
<IconButton disabled={mutating || itemId == null} onClick={() => onDelete(item)} size="sm" title={`Remove ${name}`}>
|
||||
<Trash2 aria-hidden="true" size={15} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleInspector({
|
||||
item,
|
||||
itemIndex,
|
||||
mutating,
|
||||
onDelete,
|
||||
onTabChange,
|
||||
pickers,
|
||||
schedule,
|
||||
tab
|
||||
}: {
|
||||
item: ProgramScheduleItem | null;
|
||||
itemIndex: number;
|
||||
mutating: boolean;
|
||||
onDelete: (item: ProgramScheduleItem) => void;
|
||||
onTabChange: (value: string) => void;
|
||||
pickers: SchedulePickerData;
|
||||
schedule: ProgramSchedule;
|
||||
tab: ScheduleInspectorTab;
|
||||
}) {
|
||||
if (!item) {
|
||||
return (
|
||||
<section className="ctv-schedule-inspector">
|
||||
<div className="ctv-schedule-empty">Select or add a schedule item</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const name = scheduleItemName(item);
|
||||
const fill = scheduleFillDescriptor(item);
|
||||
|
||||
return (
|
||||
<section className="ctv-schedule-inspector" aria-label="Schedule item inspector">
|
||||
<div className="ctv-schedule-inspector-head">
|
||||
<ChannelLogo name={name} size={34} />
|
||||
<div>
|
||||
<strong>{name}</strong>
|
||||
<span>Item {itemIndex + 1} · {formatScheduleCollectionType(item.collectionType)}</span>
|
||||
</div>
|
||||
<IconButton disabled={mutating || item.id == null} onClick={() => onDelete(item)} title="Remove selected item">
|
||||
<Trash2 aria-hidden="true" size={15} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="ctv-schedule-inspector-badges">
|
||||
<Badge tone={item.startType === 'Fixed' ? 'accent' : 'neutral'} dot>{item.startType === 'Fixed' ? formatScheduleTime(item.startTime) : 'Dynamic'}</Badge>
|
||||
<Badge tone="neutral">{formatScheduleEnum(item.playbackOrder ?? 'Shuffle')}</Badge>
|
||||
<Badge tone="neutral">{fill.label}</Badge>
|
||||
</div>
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={onTabChange}
|
||||
tabs={[
|
||||
{ value: 'content', label: 'Content' },
|
||||
{ value: 'playback', label: 'Playback' },
|
||||
{ value: 'filler', label: 'Filler' },
|
||||
{ value: 'overrides', label: 'Overrides' }
|
||||
]}
|
||||
/>
|
||||
<div className="ctv-schedule-inspector-body">
|
||||
{tab === 'content' && (
|
||||
<>
|
||||
<div className="ctv-schedule-form-grid">
|
||||
<Select disabled label="Start type" value={item.startType ?? 'Dynamic'} options={['Dynamic', 'Fixed']} />
|
||||
<Input disabled label="Start time" value={item.startType === 'Fixed' ? formatScheduleTime(item.startTime) : ''} placeholder="dynamic" />
|
||||
</div>
|
||||
<Select disabled label="Collection type" value={item.collectionType ?? 'Collection'} options={[item.collectionType ?? 'Collection']} />
|
||||
<Select
|
||||
disabled
|
||||
label="Collection"
|
||||
value={`${collectionIdForItem(item) ?? ''}`}
|
||||
options={pickerOptions(pickers.collections)}
|
||||
/>
|
||||
<Input disabled label="Custom title" value={item.customTitle ?? ''} placeholder="Optional guide title" />
|
||||
<div className="ctv-schedule-form-grid">
|
||||
<Select disabled label="Guide mode" value={item.guideMode ?? 'Normal'} options={['Normal', 'Filler']} />
|
||||
<Input disabled label="Schedule" value={schedule.name ?? 'Unnamed schedule'} />
|
||||
</div>
|
||||
<Checkbox disabled checked={schedule.keepMultiPartEpisodesTogether} label="Keep multi-part together" />
|
||||
</>
|
||||
)}
|
||||
{tab === 'playback' && (
|
||||
<>
|
||||
<Select disabled label="Playback order" value={item.playbackOrder ?? 'Shuffle'} options={[item.playbackOrder ?? 'Shuffle']} />
|
||||
<Select disabled label="Playout mode" value={item.playoutMode ?? 'One'} options={['Flood', 'One', 'Multiple', 'Duration']} />
|
||||
<div className="ctv-schedule-form-grid">
|
||||
<Input disabled label="Multiple count" value={item.multipleCount ?? item.count ?? ''} placeholder="n/a" />
|
||||
<Input disabled label="Duration estimate" value={item.durationEstimate ?? 'unknown'} />
|
||||
</div>
|
||||
<div className="ctv-schedule-note">
|
||||
{fill.icon}
|
||||
<span>{playbackExplanation(item)}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{tab === 'filler' && (
|
||||
<div className="ctv-schedule-form-grid">
|
||||
<Select disabled label="Pre-roll" value={item.preRollFiller?.name ?? 'None'} options={fillerOptions(pickers.fillerPresets)} />
|
||||
<Select disabled label="Mid-roll" value={item.midRollFiller?.name ?? 'None'} options={fillerOptions(pickers.fillerPresets)} />
|
||||
<Select disabled label="Post-roll" value={item.postRollFiller?.name ?? 'None'} options={fillerOptions(pickers.fillerPresets)} />
|
||||
<Select disabled label="Fallback" value={item.fallbackFiller?.name ?? 'None'} options={fillerOptions(pickers.fillerPresets)} />
|
||||
<Select disabled label="Tail" value={item.tailFiller?.name ?? 'None'} options={fillerOptions(pickers.fillerPresets)} />
|
||||
</div>
|
||||
)}
|
||||
{tab === 'overrides' && (
|
||||
<div className="ctv-schedule-form-grid">
|
||||
<Select disabled label="Watermarks" value={item.watermarks?.[0]?.name ?? 'Inherit'} options={['Inherit', ...namedPickerLabels(pickers.watermarks)]} />
|
||||
<Select disabled label="Subtitle mode" value={item.subtitleMode ?? 'Inherit'} options={['Inherit', 'None', 'Any', 'Forced', 'Default']} />
|
||||
<Input disabled label="Preferred audio" value={item.preferredAudioLanguageCode ?? ''} placeholder="Deferred: no languages endpoint" />
|
||||
<Input disabled label="Preferred subtitle" value={item.preferredSubtitleLanguageCode ?? ''} placeholder="Deferred: no languages endpoint" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function sortedScheduleItems(items: ProgramScheduleItem[]): ProgramScheduleItem[] {
|
||||
return [...items].sort((left, right) => (left.index ?? 0) - (right.index ?? 0));
|
||||
}
|
||||
|
||||
function scheduleItemToRequest(item: ProgramScheduleItem): ScheduleItemRequest {
|
||||
return {
|
||||
collectionId: item.collection?.id ?? null,
|
||||
collectionType: item.collectionType ?? 'Collection',
|
||||
customTitle: item.customTitle ?? null,
|
||||
discardToFillAttempts: item.discardToFillAttempts ?? null,
|
||||
fallbackFillerId: item.fallbackFiller?.id ?? null,
|
||||
fillWithGroupMode: item.fillWithGroupMode ?? 'None',
|
||||
fixedStartTimeBehavior: item.fixedStartTimeBehavior ?? null,
|
||||
graphicsElementIds: item.graphicsElements?.map((element) => element.id) ?? [],
|
||||
guideMode: item.guideMode ?? 'Normal',
|
||||
marathonBatchSize: item.marathonBatchSize ?? null,
|
||||
marathonGroupBy: item.marathonGroupBy ?? 'None',
|
||||
marathonShuffleGroups: item.marathonShuffleGroups ?? false,
|
||||
marathonShuffleItems: item.marathonShuffleItems ?? false,
|
||||
mediaItemId: item.mediaItem?.mediaItemId ?? null,
|
||||
midRollFillerId: item.midRollFiller?.id ?? null,
|
||||
multipleCount: item.multipleCount ?? item.count ?? null,
|
||||
multipleMode: item.multipleMode ?? 'Count',
|
||||
multiCollectionId: item.multiCollection?.id ?? null,
|
||||
playbackOrder: item.playbackOrder ?? 'Shuffle',
|
||||
playlistId: item.playlist?.id ?? null,
|
||||
playoutDuration: item.playoutDuration ?? null,
|
||||
playoutMode: item.playoutMode ?? 'One',
|
||||
postRollFillerId: item.postRollFiller?.id ?? null,
|
||||
preferredAudioLanguageCode: item.preferredAudioLanguageCode ?? null,
|
||||
preferredAudioTitle: item.preferredAudioTitle ?? null,
|
||||
preferredSubtitleLanguageCode: item.preferredSubtitleLanguageCode ?? null,
|
||||
preRollFillerId: item.preRollFiller?.id ?? null,
|
||||
rerunCollectionId: item.rerunCollection?.id ?? null,
|
||||
searchQuery: item.searchQuery ?? null,
|
||||
searchTitle: item.searchTitle ?? null,
|
||||
smartCollectionId: item.smartCollection?.id ?? null,
|
||||
startTime: item.startTime ?? null,
|
||||
startType: item.startType ?? 'Dynamic',
|
||||
subtitleMode: item.subtitleMode ?? null,
|
||||
tailFillerId: item.tailFiller?.id ?? null,
|
||||
tailMode: item.tailMode ?? 'None',
|
||||
watermarkIds: item.watermarks?.map((watermark) => watermark.id) ?? []
|
||||
};
|
||||
}
|
||||
|
||||
function newScheduleItemRequest(collection: MediaCollection): ScheduleItemRequest {
|
||||
return {
|
||||
collectionId: collection.id,
|
||||
collectionType: collection.collectionType ?? 'Collection',
|
||||
customTitle: null,
|
||||
discardToFillAttempts: null,
|
||||
fallbackFillerId: null,
|
||||
fillWithGroupMode: 'None',
|
||||
fixedStartTimeBehavior: null,
|
||||
graphicsElementIds: [],
|
||||
guideMode: 'Normal',
|
||||
marathonBatchSize: null,
|
||||
marathonGroupBy: 'None',
|
||||
marathonShuffleGroups: false,
|
||||
marathonShuffleItems: false,
|
||||
mediaItemId: null,
|
||||
midRollFillerId: null,
|
||||
multipleCount: null,
|
||||
multipleMode: 'Count',
|
||||
multiCollectionId: null,
|
||||
playbackOrder: 'Shuffle',
|
||||
playlistId: null,
|
||||
playoutDuration: null,
|
||||
playoutMode: 'One',
|
||||
postRollFillerId: null,
|
||||
preferredAudioLanguageCode: null,
|
||||
preferredAudioTitle: null,
|
||||
preferredSubtitleLanguageCode: null,
|
||||
preRollFillerId: null,
|
||||
rerunCollectionId: null,
|
||||
searchQuery: null,
|
||||
searchTitle: null,
|
||||
smartCollectionId: null,
|
||||
startTime: null,
|
||||
startType: 'Dynamic',
|
||||
subtitleMode: null,
|
||||
tailFillerId: null,
|
||||
tailMode: 'None',
|
||||
watermarkIds: []
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleItemName(item: ProgramScheduleItem): string {
|
||||
return item.name ?? item.collection?.name ?? item.multiCollection?.name ?? item.smartCollection?.name ?? item.searchTitle ?? item.searchQuery ?? 'Unnamed item';
|
||||
}
|
||||
|
||||
function collectionIdForItem(item: ProgramScheduleItem): number | null {
|
||||
return item.collection?.id ?? item.multiCollection?.id ?? item.smartCollection?.id ?? null;
|
||||
}
|
||||
|
||||
function sortedCollections(collections: MediaCollection[]): MediaCollection[] {
|
||||
return [...collections].sort((left, right) => (left.name ?? '').localeCompare(right.name ?? ''));
|
||||
}
|
||||
|
||||
function scheduleOptions(schedules: ProgramSchedule[]): Array<{ label: string; value: string }> {
|
||||
return schedules.map((schedule) => ({ label: schedule.name ?? `Schedule ${schedule.id}`, value: `${schedule.id}` }));
|
||||
}
|
||||
|
||||
function scheduleFillDescriptor(item: ProgramScheduleItem): { icon: ReactNode; label: string } {
|
||||
if (item.playoutMode === 'Flood') {
|
||||
return { icon: <GripVertical aria-hidden="true" size={13} />, label: 'Fills to next' };
|
||||
}
|
||||
|
||||
if (item.playoutMode === 'Multiple') {
|
||||
return { icon: <Copy aria-hidden="true" size={13} />, label: `x${item.multipleCount ?? item.count ?? '?'}` };
|
||||
}
|
||||
|
||||
if (item.playoutMode === 'Duration') {
|
||||
return { icon: <Timer aria-hidden="true" size={13} />, label: item.durationEstimate ?? 'Duration' };
|
||||
}
|
||||
|
||||
return { icon: <Shuffle aria-hidden="true" size={13} />, label: '1 item' };
|
||||
}
|
||||
|
||||
function playbackExplanation(item: ProgramScheduleItem): string {
|
||||
if (item.playoutMode === 'Flood') {
|
||||
return 'Plays until the next fixed-start item.';
|
||||
}
|
||||
|
||||
if (item.playoutMode === 'Multiple') {
|
||||
return `Plays ${item.multipleCount ?? item.count ?? 'multiple'} items, then advances.`;
|
||||
}
|
||||
|
||||
if (item.playoutMode === 'Duration') {
|
||||
return 'Plays for a fixed duration.';
|
||||
}
|
||||
|
||||
return 'Plays exactly one item, then advances.';
|
||||
}
|
||||
|
||||
function formatScheduleTime(value: string | null | undefined): string {
|
||||
return value?.slice(0, 5) ?? 'dynamic';
|
||||
}
|
||||
|
||||
function formatScheduleEnum(value: string): string {
|
||||
return value.replace(/([a-z])([A-Z])/g, '$1 $2');
|
||||
}
|
||||
|
||||
function formatScheduleCollectionType(value: string | undefined): string {
|
||||
return formatScheduleEnum(value ?? 'Collection');
|
||||
}
|
||||
|
||||
function pickerOptions(items: MediaCollection[]): Array<{ label: string; value: string }> {
|
||||
return items.map((item) => ({ label: item.name ?? `Collection ${item.id}`, value: `${item.id}` }));
|
||||
}
|
||||
|
||||
function namedPickerLabels(items: unknown[]): string[] {
|
||||
return items.map((item) => (item as { name?: null | string }).name ?? 'Unnamed');
|
||||
}
|
||||
|
||||
function fillerOptions(items: unknown[]): string[] {
|
||||
return ['None', ...namedPickerLabels(items)];
|
||||
}
|
||||
|
||||
function PlaceholderScreen({ route }: { route: ScreenRoute }) {
|
||||
return (
|
||||
<div className="ctv-screen-stack">
|
||||
@@ -1235,6 +1889,10 @@ function ScreenContent({
|
||||
return <ChannelsScreen />;
|
||||
}
|
||||
|
||||
if (route.id === 'schedules') {
|
||||
return <ScheduleScreen />;
|
||||
}
|
||||
|
||||
return <PlaceholderScreen route={route} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,4 +2,5 @@ export * from './auth';
|
||||
export * from './channels';
|
||||
export * from './client';
|
||||
export * from './dashboard';
|
||||
export * from './schedules';
|
||||
export * from './useChannelsQuery';
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ApiError, request } from './client';
|
||||
import type { components } from './generated/v1';
|
||||
|
||||
export type ProgramSchedule = components['schemas']['ProgramScheduleViewModel'];
|
||||
// These fields are real on the wire (the backend serializes ProgramScheduleItemViewModel
|
||||
// subtypes with Newtonsoft using the runtime type), but the OpenAPI schema doesn't declare
|
||||
// them: ProgramScheduleItemViewModel is an abstract base with no polymorphism annotation, so
|
||||
// the generator only sees the base shape. Tracked in Gitea issue #126 — remove this widening
|
||||
// once the schema is fixed to describe the concrete subtypes.
|
||||
export type ProgramScheduleItem = components['schemas']['ProgramScheduleItemViewModel'] & {
|
||||
count?: null | string;
|
||||
discardToFillAttempts?: null | number;
|
||||
multipleCount?: null | string;
|
||||
multipleMode?: components['schemas']['MultipleMode'];
|
||||
playoutDuration?: null | string;
|
||||
tailMode?: components['schemas']['TailMode'];
|
||||
};
|
||||
export type ProgramScheduleItemsWithDuration = components['schemas']['ProgramScheduleItemsWithDurationViewModel'];
|
||||
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 interface SchedulePickerData {
|
||||
collections: MediaCollection[];
|
||||
fillerPresets: FillerPreset[];
|
||||
smartCollections: SmartCollection[];
|
||||
watermarks: Watermark[];
|
||||
}
|
||||
|
||||
export interface ScheduleScreenData {
|
||||
activeSchedule: ProgramSchedule | null;
|
||||
items: ProgramScheduleItem[];
|
||||
pickers: SchedulePickerData;
|
||||
schedules: ProgramSchedule[];
|
||||
totalDurationEstimate: string | null;
|
||||
}
|
||||
|
||||
export type ScheduleScreenQueryState =
|
||||
| {
|
||||
data: ScheduleScreenData;
|
||||
error: null;
|
||||
itemsLoading: boolean;
|
||||
refresh: () => void;
|
||||
setActiveSchedule: (scheduleId: number) => void;
|
||||
setItems: (items: ProgramScheduleItem[]) => void;
|
||||
status: 'success';
|
||||
}
|
||||
| { data: null; error: string; refresh: () => void; status: 'error' }
|
||||
| { data: null; error: null; refresh: () => void; status: 'loading' };
|
||||
|
||||
type ScheduleScreenState =
|
||||
| { data: ScheduleScreenData; error: null; itemsLoading: boolean; status: 'success' }
|
||||
| { data: null; error: string; status: 'error' }
|
||||
| { data: null; error: null; status: 'loading' };
|
||||
|
||||
export function getSchedules(): Promise<ProgramSchedule[]> {
|
||||
return request<ProgramSchedule[]>('/api/schedules');
|
||||
}
|
||||
|
||||
export function getScheduleItems(scheduleId: number): Promise<ProgramScheduleItemsWithDuration> {
|
||||
return request<ProgramScheduleItemsWithDuration>(`/api/schedules/${scheduleId}/items`);
|
||||
}
|
||||
|
||||
export function getCollections(): Promise<MediaCollection[]> {
|
||||
return request<MediaCollection[]>('/api/collections');
|
||||
}
|
||||
|
||||
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,
|
||||
method: 'POST'
|
||||
});
|
||||
}
|
||||
|
||||
export function replaceScheduleItems(
|
||||
scheduleId: number,
|
||||
body: ReplaceScheduleItemsRequest
|
||||
): Promise<ProgramScheduleItem[]> {
|
||||
return request<ProgramScheduleItem[]>(`/api/schedules/${scheduleId}/items`, {
|
||||
body,
|
||||
method: 'PUT'
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteScheduleItem(scheduleId: number, itemId: number): Promise<void> {
|
||||
return request<void>(`/api/schedules/${scheduleId}/items/${itemId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
export async function getScheduleScreenData(): Promise<ScheduleScreenData> {
|
||||
const schedules = await getSchedules();
|
||||
const activeSchedule = schedules[0] ?? null;
|
||||
|
||||
if (!activeSchedule) {
|
||||
return {
|
||||
activeSchedule: null,
|
||||
items: [],
|
||||
pickers: emptyPickerData(),
|
||||
schedules,
|
||||
totalDurationEstimate: null
|
||||
};
|
||||
}
|
||||
|
||||
const [itemsEnvelope, pickers] = await Promise.all([
|
||||
getScheduleItems(activeSchedule.id),
|
||||
getSchedulePickerData()
|
||||
]);
|
||||
|
||||
return {
|
||||
activeSchedule,
|
||||
items: (itemsEnvelope.items ?? []) as ProgramScheduleItem[],
|
||||
pickers,
|
||||
schedules,
|
||||
totalDurationEstimate: itemsEnvelope.totalDurationEstimate
|
||||
};
|
||||
}
|
||||
|
||||
export function useScheduleScreenQuery(): ScheduleScreenQueryState {
|
||||
const [state, setState] = useState<ScheduleScreenState>({
|
||||
data: null,
|
||||
error: null,
|
||||
status: 'loading'
|
||||
});
|
||||
const activeRef = useRef(true);
|
||||
const activeScheduleIdRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current = true;
|
||||
|
||||
return () => {
|
||||
activeRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const load = useCallback(() => {
|
||||
getScheduleScreenData()
|
||||
.then((data) => {
|
||||
if (activeRef.current) {
|
||||
activeScheduleIdRef.current = data.activeSchedule?.id ?? null;
|
||||
setState({ data, error: null, itemsLoading: false, status: 'success' });
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (activeRef.current) {
|
||||
setState({ data: null, error: messageFromScheduleError(error, 'Unable to load schedules'), status: 'error' });
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setState({ data: null, error: null, status: 'loading' });
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const setActiveSchedule = useCallback((scheduleId: number) => {
|
||||
activeScheduleIdRef.current = scheduleId;
|
||||
|
||||
setState((current) => {
|
||||
if (current.status !== 'success') {
|
||||
return current;
|
||||
}
|
||||
|
||||
const nextActiveSchedule = current.data.schedules.find((schedule) => schedule.id === scheduleId)
|
||||
?? current.data.activeSchedule;
|
||||
|
||||
return {
|
||||
data: { ...current.data, activeSchedule: nextActiveSchedule },
|
||||
error: null,
|
||||
itemsLoading: true,
|
||||
status: 'success'
|
||||
};
|
||||
});
|
||||
|
||||
getScheduleItems(scheduleId)
|
||||
.then((itemsEnvelope) => {
|
||||
if (!activeRef.current || activeScheduleIdRef.current !== scheduleId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState((current) => {
|
||||
if (current.status !== 'success') {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
...current.data,
|
||||
items: (itemsEnvelope.items ?? []) as ProgramScheduleItem[],
|
||||
totalDurationEstimate: itemsEnvelope.totalDurationEstimate
|
||||
},
|
||||
error: null,
|
||||
itemsLoading: false,
|
||||
status: 'success'
|
||||
};
|
||||
});
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!activeRef.current || activeScheduleIdRef.current !== scheduleId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState({
|
||||
data: null,
|
||||
error: messageFromScheduleError(error, 'Unable to load schedule items'),
|
||||
status: 'error'
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setItems = useCallback((items: ProgramScheduleItem[]) => {
|
||||
setState((current) => {
|
||||
if (current.status !== 'success') {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
data: { ...current.data, items },
|
||||
error: null,
|
||||
itemsLoading: false,
|
||||
status: 'success'
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (state.status === 'success') {
|
||||
return {
|
||||
data: state.data,
|
||||
error: null,
|
||||
itemsLoading: state.itemsLoading,
|
||||
refresh,
|
||||
setActiveSchedule,
|
||||
setItems,
|
||||
status: 'success'
|
||||
};
|
||||
}
|
||||
|
||||
if (state.status === 'error') {
|
||||
return { data: null, error: state.error, refresh, status: 'error' };
|
||||
}
|
||||
|
||||
return { data: null, error: null, refresh, status: 'loading' };
|
||||
}
|
||||
|
||||
async function getSchedulePickerData(): Promise<SchedulePickerData> {
|
||||
const [collections, smartCollections, fillerPresets, watermarks] = await Promise.all([
|
||||
getCollections(),
|
||||
getSmartCollections(),
|
||||
getFillerPresets(),
|
||||
getWatermarks()
|
||||
]);
|
||||
|
||||
return {
|
||||
collections,
|
||||
fillerPresets: sortByName(fillerPresets),
|
||||
smartCollections,
|
||||
watermarks: sortByName(watermarks)
|
||||
};
|
||||
}
|
||||
|
||||
function emptyPickerData(): SchedulePickerData {
|
||||
return {
|
||||
collections: [],
|
||||
fillerPresets: [],
|
||||
smartCollections: [],
|
||||
watermarks: []
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
@@ -984,6 +984,330 @@
|
||||
font-size: var(--text-xs, 12px);
|
||||
}
|
||||
|
||||
.ctv-schedule-screen {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
gap: var(--space-6, 12px);
|
||||
}
|
||||
|
||||
.ctv-schedule-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-6, 12px);
|
||||
border: 1px solid var(--border-hairline);
|
||||
border-radius: var(--radius-md, 7px);
|
||||
background: var(--surface-card);
|
||||
padding: var(--space-6, 12px) var(--space-8, 20px);
|
||||
}
|
||||
|
||||
.ctv-schedule-header-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: var(--radius-sm, 5px);
|
||||
background: var(--ctv-accent-soft);
|
||||
color: var(--ctv-accent);
|
||||
}
|
||||
|
||||
.ctv-schedule-header > div {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ctv-schedule-header h2,
|
||||
.ctv-schedule-inspector-head strong {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-md, 14px);
|
||||
font-weight: var(--weight-semibold, 600);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.ctv-schedule-header p,
|
||||
.ctv-schedule-inspector-head span {
|
||||
margin: var(--space-2, 4px) 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs, 12px);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.ctv-schedule-header code,
|
||||
.ctv-schedule-block-duration,
|
||||
.ctv-schedule-rail code,
|
||||
.ctv-schedule-fill-chip code {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.ctv-schedule-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(420px, 1fr) minmax(360px, 460px);
|
||||
gap: var(--space-8, 20px);
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ctv-schedule-lineup-panel,
|
||||
.ctv-schedule-inspector {
|
||||
min-height: 0;
|
||||
border: 1px solid var(--border-hairline);
|
||||
border-radius: var(--radius-md, 7px);
|
||||
background: var(--surface-card);
|
||||
}
|
||||
|
||||
.ctv-schedule-lineup-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ctv-schedule-panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-5, 10px);
|
||||
border-bottom: 1px solid var(--border-hairline);
|
||||
padding: var(--space-5, 10px) var(--space-7, 16px);
|
||||
}
|
||||
|
||||
.ctv-schedule-panel-head > span {
|
||||
color: var(--text-disabled);
|
||||
font-size: var(--text-2xs, 11px);
|
||||
font-weight: var(--weight-semibold, 600);
|
||||
line-height: 1;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ctv-schedule-lineup {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: var(--space-6, 12px);
|
||||
}
|
||||
|
||||
.ctv-schedule-lineup-row {
|
||||
display: grid;
|
||||
grid-template-columns: 58px minmax(0, 1fr);
|
||||
gap: var(--space-5, 10px);
|
||||
}
|
||||
|
||||
.ctv-schedule-rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-2, 4px);
|
||||
padding-top: var(--space-7, 16px);
|
||||
color: var(--text-disabled);
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.ctv-schedule-rail-dot,
|
||||
.ctv-schedule-rail-dot-fixed {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border: 2px solid var(--border-control);
|
||||
border-radius: var(--radius-pill, 999px);
|
||||
}
|
||||
|
||||
.ctv-schedule-rail-dot-fixed {
|
||||
border-color: var(--ctv-accent);
|
||||
background: var(--ctv-accent);
|
||||
}
|
||||
|
||||
.ctv-schedule-block {
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: var(--space-4, 8px);
|
||||
margin-bottom: var(--space-6, 12px);
|
||||
border: 1px solid var(--border-hairline);
|
||||
border-radius: var(--radius-md, 7px);
|
||||
background: var(--ctv-surface-1);
|
||||
padding: var(--space-5, 10px);
|
||||
transition:
|
||||
background var(--dur-fast) var(--ease-standard),
|
||||
border-color var(--dur-fast) var(--ease-standard),
|
||||
opacity var(--dur-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.ctv-schedule-block-active {
|
||||
border-color: var(--action-primary);
|
||||
background: var(--ctv-accent-soft);
|
||||
}
|
||||
|
||||
.ctv-schedule-block-dragging {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.ctv-schedule-block-over {
|
||||
box-shadow: inset 0 2px 0 var(--action-primary);
|
||||
}
|
||||
|
||||
.ctv-schedule-block-main {
|
||||
display: grid;
|
||||
grid-template-columns: 16px 38px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--space-5, 10px);
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.ctv-schedule-block-main > svg {
|
||||
color: var(--text-disabled);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.ctv-schedule-block-title {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: var(--space-2, 4px);
|
||||
}
|
||||
|
||||
.ctv-schedule-block-title strong {
|
||||
overflow: hidden;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-md, 14px);
|
||||
font-weight: var(--weight-semibold, 600);
|
||||
line-height: 1.2;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ctv-schedule-block-title small,
|
||||
.ctv-schedule-block-meta {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs, 12px);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.ctv-schedule-fill-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3, 6px);
|
||||
border: 1px solid var(--border-hairline);
|
||||
border-radius: var(--radius-sm, 5px);
|
||||
background: var(--ctv-bg-sunken);
|
||||
color: var(--text-primary);
|
||||
padding: var(--space-3, 6px) var(--space-4, 8px);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ctv-schedule-fill-chip svg,
|
||||
.ctv-schedule-block-meta svg,
|
||||
.ctv-schedule-note svg {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.ctv-schedule-block-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4, 8px);
|
||||
flex-wrap: wrap;
|
||||
padding-left: 64px;
|
||||
}
|
||||
|
||||
.ctv-schedule-block-meta > span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2, 4px);
|
||||
}
|
||||
|
||||
.ctv-schedule-block-duration {
|
||||
margin-left: auto;
|
||||
color: var(--text-disabled);
|
||||
font-size: var(--text-2xs, 11px);
|
||||
}
|
||||
|
||||
.ctv-schedule-block-actions {
|
||||
position: absolute;
|
||||
right: var(--space-3, 6px);
|
||||
bottom: var(--space-3, 6px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.ctv-schedule-up-icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.ctv-schedule-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 180px;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm, 13px);
|
||||
}
|
||||
|
||||
.ctv-schedule-inspector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ctv-schedule-inspector-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-5, 10px);
|
||||
border-bottom: 1px solid var(--border-hairline);
|
||||
padding: var(--space-7, 16px);
|
||||
}
|
||||
|
||||
.ctv-schedule-inspector-head > div {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
gap: var(--space-2, 4px);
|
||||
}
|
||||
|
||||
.ctv-schedule-inspector-badges {
|
||||
display: flex;
|
||||
gap: var(--space-3, 6px);
|
||||
flex-wrap: wrap;
|
||||
padding: var(--space-5, 10px) var(--space-7, 16px);
|
||||
}
|
||||
|
||||
.ctv-schedule-inspector .ctv-tabs {
|
||||
padding: 0 var(--space-7, 16px);
|
||||
}
|
||||
|
||||
.ctv-schedule-inspector-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6, 12px);
|
||||
overflow: auto;
|
||||
padding: var(--space-7, 16px);
|
||||
}
|
||||
|
||||
.ctv-schedule-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-6, 12px);
|
||||
}
|
||||
|
||||
.ctv-schedule-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-5, 10px);
|
||||
border: 1px solid var(--border-hairline);
|
||||
border-radius: var(--radius-sm, 5px);
|
||||
background: var(--ctv-bg-sunken);
|
||||
color: var(--text-secondary);
|
||||
padding: var(--space-6, 12px);
|
||||
font-size: var(--text-xs, 12px);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.ctv-app-shell {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -1045,4 +1369,23 @@
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ctv-schedule-header {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ctv-schedule-grid,
|
||||
.ctv-schedule-form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.ctv-schedule-block-main {
|
||||
grid-template-columns: 16px 38px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.ctv-schedule-fill-chip {
|
||||
grid-column: 2 / -1;
|
||||
justify-self: start;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user