Merge pull request 'feat(web): Schedule editor screen (#86)' (#125) from feat/86-schedule-editor into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m18s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled

This commit was merged in pull request #125.
This commit is contained in:
2026-07-05 10:22:16 +00:00
5 changed files with 1838 additions and 6 deletions
+529 -3
View File
@@ -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
View File
@@ -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} />;
}
+1
View File
@@ -2,4 +2,5 @@ export * from './auth';
export * from './channels';
export * from './client';
export * from './dashboard';
export * from './schedules';
export * from './useChannelsQuery';
+304
View File
@@ -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;
}
+343
View File
@@ -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;
}
}