feat(web): Dialog primitive + Channel Builder API modules (refs #89)

- components/overlay.tsx: first Dialog/ConfirmDialog (portal, esc/backdrop
  close, scroll lock, aria wiring, reduced-motion aware) + 7 tests
- api: libraryBrowse, channelTemplates (default 404->null), artwork upload
  (multipart /api/artwork/uploads), pickers (client-sorted lists incl.
  /api/ffmpeg/profiles), createChannelFromLineup in channels.ts
- schedules.ts now sources filler/watermark fetchers from pickers.ts
  (avoids duplicate barrel exports)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 22:07:36 +02:00
co-authored by Claude Fable 5
parent 2596a433a8
commit 8e11362360
12 changed files with 559 additions and 17 deletions
+28
View File
@@ -0,0 +1,28 @@
import { ApiError, request } from './client';
import type { components } from './generated/v1';
export type ArtworkUpload = components['schemas']['ArtworkUploadResponseModel'];
export type ArtworkUploadTarget = 'logo' | 'watermark';
export function uploadArtwork(file: File, target: ArtworkUploadTarget): Promise<ArtworkUpload> {
const body = new FormData();
body.append('file', file);
body.append('target', target);
return request<ArtworkUpload>('/api/artwork/uploads', {
body,
method: 'POST'
});
}
export function messageFromArtworkError(error: unknown, fallback = 'Unable to upload artwork'): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}
+43
View File
@@ -0,0 +1,43 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { getDefaultChannelTemplate } from './channelTemplates';
describe('getDefaultChannelTemplate', () => {
beforeEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
it('returns null when no default template is set (404)', async () => {
vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ status: 404, title: 'Not Found' }), {
headers: { 'Content-Type': 'application/json' },
status: 404
})
);
await expect(getDefaultChannelTemplate()).resolves.toBeNull();
});
it('rethrows non-404 errors', async () => {
vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ status: 500, title: 'Server error' }), {
headers: { 'Content-Type': 'application/json' },
status: 500
})
);
await expect(getDefaultChannelTemplate()).rejects.toMatchObject({ status: 500 });
});
it('returns the default template when present', async () => {
const template = { id: 1, name: 'Default', isDefault: true };
vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify(template), {
headers: { 'Content-Type': 'application/json' },
status: 200
})
);
await expect(getDefaultChannelTemplate()).resolves.toMatchObject(template);
});
});
+40
View File
@@ -0,0 +1,40 @@
import { ApiError, request } from './client';
import type { components } from './generated/v1';
export type ChannelTemplate = components['schemas']['ChannelTemplateResponseModel'];
export type CreateChannelTemplateRequest = components['schemas']['CreateChannelTemplateRequest'];
export function getChannelTemplates(): Promise<ChannelTemplate[]> {
return request<ChannelTemplate[]>('/api/channel-templates');
}
export async function getDefaultChannelTemplate(): Promise<ChannelTemplate | null> {
try {
return await request<ChannelTemplate>('/api/channel-templates/default');
} catch (error) {
if (error instanceof ApiError && error.status === 404) {
return null;
}
throw error;
}
}
export function createChannelTemplate(body: CreateChannelTemplateRequest): Promise<ChannelTemplate> {
return request<ChannelTemplate>('/api/channel-templates', {
body,
method: 'POST'
});
}
export function messageFromChannelTemplateError(error: unknown, fallback = 'Unable to load channel templates'): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}
+11
View File
@@ -7,6 +7,8 @@ export type ChannelState = components['schemas']['ChannelStateResponseModel'];
export type BulkRenumberChannelsRequest = components['schemas']['BulkRenumberChannelsRequest'];
export type BulkMoveChannelsToGroupRequest = components['schemas']['BulkMoveChannelsToGroupRequest'];
export type BulkDeleteChannelsRequest = components['schemas']['BulkDeleteChannelsRequest'];
export type CreateChannelFromLineupRequest = components['schemas']['CreateChannelFromLineupRequest'];
export type CreateChannelFromLineupResponseModel = components['schemas']['CreateChannelFromLineupResponseModel'];
export interface ChannelsScreenData {
channels: ChannelSummary[];
@@ -67,6 +69,15 @@ export function deleteChannel(channelId: number): Promise<void> {
});
}
export function createChannelFromLineup(
body: CreateChannelFromLineupRequest
): Promise<CreateChannelFromLineupResponseModel> {
return request<CreateChannelFromLineupResponseModel>('/api/channels/from-lineup', {
body,
method: 'POST'
});
}
export function useChannelsScreenQuery(pollMs = 30000): ChannelsScreenQueryState {
const [state, setState] = useState<ChannelsScreenState>({
data: null,
+4
View File
@@ -1,9 +1,13 @@
export * from './artwork';
export * from './auth';
export * from './channels';
export * from './channelTemplates';
export * from './client';
export * from './dashboard';
export * from './guide';
export * from './libraries';
export * from './libraryBrowse';
export * from './pickers';
export * from './playouts';
export * from './schedules';
export * from './useChannelsQuery';
+54
View File
@@ -0,0 +1,54 @@
import { ApiError, request } from './client';
import type { components } from './generated/v1';
export type LibraryBrowseItem = components['schemas']['LibraryBrowseItemResponseModel'];
export type LibraryBrowseMediaType = components['schemas']['LibraryBrowseMediaType'];
export type PagedLibraryBrowseItems = components['schemas']['PagedLibraryBrowseItemsResponseModel'];
export interface GetLibraryBrowseItemsParams {
libraryId?: number;
mediaType?: LibraryBrowseMediaType;
pageNum?: number;
pageSize?: number;
query?: string;
}
export function getLibraryBrowseItems(params: GetLibraryBrowseItemsParams = {}): Promise<PagedLibraryBrowseItems> {
const searchParams = new URLSearchParams();
if (params.query) {
searchParams.set('query', params.query);
}
if (params.libraryId != null) {
searchParams.set('libraryId', String(params.libraryId));
}
if (params.mediaType) {
searchParams.set('mediaType', params.mediaType);
}
if (params.pageNum != null) {
searchParams.set('pageNum', String(params.pageNum));
}
if (params.pageSize != null) {
searchParams.set('pageSize', String(params.pageSize));
}
const queryString = searchParams.toString();
return request<PagedLibraryBrowseItems>(`/api/library/browse${queryString ? `?${queryString}` : ''}`);
}
export function messageFromLibraryBrowseError(error: unknown, fallback = 'Unable to load library items'): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}
+49
View File
@@ -0,0 +1,49 @@
import { ApiError, request } from './client';
import type { components } from './generated/v1';
export type FillerPreset = components['schemas']['FillerPresetResponseModel'];
export type Watermark = components['schemas']['WatermarkResponseModel'];
export type GraphicsElement = components['schemas']['GraphicsElementResponseModel'];
export type FFmpegProfile = components['schemas']['FFmpegFullProfileResponseModel'];
export function getFillerPresets(): Promise<FillerPreset[]> {
return request<FillerPreset[]>('/api/filler-presets').then(sortByName);
}
export function getWatermarks(): Promise<Watermark[]> {
return request<Watermark[]>('/api/watermarks').then(sortByName);
}
export function getGraphicsElements(): Promise<GraphicsElement[]> {
return request<GraphicsElement[]>('/api/graphics-elements').then(sortByName);
}
export function getFFmpegProfiles(): Promise<FFmpegProfile[]> {
return request<FFmpegProfile[]>('/api/ffmpeg/profiles').then(sortByName);
}
function sortByName<T extends { name: null | string }>(items: T[]): T[] {
return [...items].sort((left, right) => {
if (left.name == null) {
return right.name == null ? 0 : 1;
}
if (right.name == null) {
return -1;
}
return left.name.localeCompare(right.name, undefined, { sensitivity: 'base' });
});
}
export function messageFromPickersError(error: unknown, fallback = 'Unable to load picker data'): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}
+5 -16
View File
@@ -1,6 +1,8 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ApiError, request } from './client';
import type { components } from './generated/v1';
import { getFillerPresets, getWatermarks } from './pickers';
import type { FillerPreset, Watermark } from './pickers';
export type ProgramSchedule = components['schemas']['ProgramScheduleViewModel'];
// These fields are real on the wire (the backend serializes ProgramScheduleItemViewModel
@@ -21,8 +23,7 @@ export type ScheduleItemRequest = components['schemas']['ScheduleItemRequest'];
export type ReplaceScheduleItemsRequest = components['schemas']['ReplaceScheduleItemsRequest'];
export type MediaCollection = components['schemas']['MediaCollectionViewModel'];
export type SmartCollection = components['schemas']['SmartCollectionViewModel'];
export type FillerPreset = components['schemas']['FillerPresetResponseModel'];
export type Watermark = components['schemas']['WatermarkResponseModel'];
export type { FillerPreset, Watermark };
export interface SchedulePickerData {
collections: MediaCollection[];
@@ -73,14 +74,6 @@ export function getSmartCollections(): Promise<SmartCollection[]> {
return request<SmartCollection[]>('/api/smart-collections');
}
export function getFillerPresets(): Promise<FillerPreset[]> {
return request<FillerPreset[]>('/api/filler-presets');
}
export function getWatermarks(): Promise<Watermark[]> {
return request<Watermark[]>('/api/watermarks');
}
export function addScheduleItem(scheduleId: number, body: ScheduleItemRequest): Promise<ProgramScheduleItem> {
return request<ProgramScheduleItem>(`/api/schedules/${scheduleId}/items`, {
body,
@@ -272,9 +265,9 @@ async function getSchedulePickerData(): Promise<SchedulePickerData> {
return {
collections,
fillerPresets: sortByName(fillerPresets),
fillerPresets,
smartCollections,
watermarks: sortByName(watermarks)
watermarks
};
}
@@ -287,10 +280,6 @@ function emptyPickerData(): SchedulePickerData {
};
}
function sortByName<T extends { name: null | string }>(items: T[]): T[] {
return [...items].sort((left, right) => (left.name ?? '').localeCompare(right.name ?? ''));
}
function messageFromScheduleError(error: unknown, fallback = 'Unable to load schedules'): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
+75 -1
View File
@@ -920,6 +920,79 @@
background: var(--action-primary);
}
.ctv-dialog-overlay {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(0, 0, 0, 0.55);
}
.ctv-dialog {
display: flex;
flex-direction: column;
width: 480px;
max-width: 100%;
max-height: calc(100vh - 48px);
border: 1px solid var(--border-hairline);
border-radius: var(--radius-lg);
background: var(--surface-card);
box-shadow: var(--shadow-lg);
outline: 0;
animation: ctv-dialog-in var(--dur-slow) var(--ease-out);
}
.ctv-dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 16px 12px;
border-bottom: 1px solid var(--border-hairline);
}
.ctv-dialog-title {
color: var(--text-primary);
font-size: var(--text-md);
font-weight: var(--weight-semibold);
}
.ctv-dialog-body {
overflow-y: auto;
padding: 16px;
color: var(--text-secondary);
font-size: var(--text-sm);
line-height: 1.5;
}
.ctv-dialog-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding: 12px 16px 16px;
border-top: 1px solid var(--border-hairline);
}
.ctv-confirm-message {
color: var(--text-secondary);
}
@keyframes ctv-dialog-in {
from {
opacity: 0;
transform: scale(0.97);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes ctv-spin {
to {
transform: rotate(360deg);
@@ -952,7 +1025,8 @@
@media (prefers-reduced-motion: reduce) {
.ctv-spinner svg,
.ctv-status-dot-ping,
.ctv-progress-fill-indeterminate {
.ctv-progress-fill-indeterminate,
.ctv-dialog {
animation: none;
}
}
+1
View File
@@ -4,3 +4,4 @@ export * from './dataDisplay';
export * from './feedback';
export * from './forms';
export * from './navigation';
export * from './overlay';
+122
View File
@@ -0,0 +1,122 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ConfirmDialog, Dialog } from './overlay';
describe('Dialog', () => {
afterEach(() => {
cleanup();
document.body.style.overflow = '';
});
it('renders nothing when closed', () => {
render(
<Dialog open={false} onClose={vi.fn()} title="Hidden">
Body content
</Dialog>
);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('renders the title and children when open', () => {
render(
<Dialog open onClose={vi.fn()} title="Save as template">
Body content
</Dialog>
);
expect(screen.getByRole('dialog', { name: 'Save as template' })).toBeInTheDocument();
expect(screen.getByText('Body content')).toBeInTheDocument();
});
it('calls onClose when the backdrop is clicked but not when the panel is clicked', () => {
const onClose = vi.fn();
render(
<Dialog open onClose={onClose} title="Save as template">
Body content
</Dialog>
);
fireEvent.click(screen.getByText('Body content'));
expect(onClose).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('dialog').parentElement as HTMLElement);
expect(onClose).toHaveBeenCalledTimes(1);
});
it('calls onClose on Escape', () => {
const onClose = vi.fn();
render(
<Dialog open onClose={onClose} title="Save as template">
Body content
</Dialog>
);
fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose).toHaveBeenCalledTimes(1);
});
it('locks and restores body scroll, honoring the previous inline value', () => {
document.body.style.overflow = 'scroll';
const { rerender } = render(
<Dialog open onClose={vi.fn()} title="Save as template">
Body content
</Dialog>
);
expect(document.body.style.overflow).toBe('hidden');
rerender(
<Dialog open={false} onClose={vi.fn()} title="Save as template">
Body content
</Dialog>
);
expect(document.body.style.overflow).toBe('scroll');
});
});
describe('ConfirmDialog', () => {
afterEach(() => {
cleanup();
document.body.style.overflow = '';
});
it('fires onConfirm and onCancel', () => {
const onConfirm = vi.fn();
const onCancel = vi.fn();
render(
<ConfirmDialog
open
title="Delete channel"
message="This cannot be undone."
onConfirm={onConfirm}
onCancel={onCancel}
/>
);
fireEvent.click(screen.getByRole('button', { name: 'Confirm' }));
expect(onConfirm).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(onCancel).toHaveBeenCalledTimes(1);
});
it('disables the confirm button while busy', () => {
render(
<ConfirmDialog
open
busy
title="Delete channel"
message="This cannot be undone."
onConfirm={vi.fn()}
onCancel={vi.fn()}
/>
);
expect(screen.getByRole('button', { name: 'Confirm' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled();
});
});
+127
View File
@@ -0,0 +1,127 @@
import { useEffect, useId, useRef, type CSSProperties, type MouseEvent, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
import { Button, IconButton } from './forms';
export interface DialogProps {
open: boolean;
onClose: () => void;
title: ReactNode;
children?: ReactNode;
footer?: ReactNode;
width?: number;
style?: CSSProperties;
}
export function Dialog({ open, onClose, title, children, footer, width, style }: DialogProps) {
const titleId = useId();
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) {
return;
}
panelRef.current?.focus();
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
};
document.addEventListener('keydown', onKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
document.removeEventListener('keydown', onKeyDown);
};
}, [open, onClose]);
if (!open) {
return null;
}
const onOverlayClick = (event: MouseEvent<HTMLDivElement>) => {
if (event.target === event.currentTarget) {
onClose();
}
};
return createPortal(
<div className="ctv-dialog-overlay" onClick={onOverlayClick}>
<div
className="ctv-dialog"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
tabIndex={-1}
ref={panelRef}
style={{ ...(width ? { width } : null), ...style }}
>
<div className="ctv-dialog-header">
<span id={titleId} className="ctv-dialog-title">
{title}
</span>
<IconButton title="Close" onClick={onClose}>
<X aria-hidden="true" size={15} />
</IconButton>
</div>
<div className="ctv-dialog-body">{children}</div>
{footer && <div className="ctv-dialog-footer">{footer}</div>}
</div>
</div>,
document.body
);
}
export interface ConfirmDialogProps {
open: boolean;
title: ReactNode;
message: ReactNode;
confirmLabel?: string;
cancelLabel?: string;
tone?: 'danger' | 'accent';
busy?: boolean;
onConfirm: () => void;
onCancel: () => void;
}
export function ConfirmDialog({
open,
title,
message,
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
tone = 'accent',
busy = false,
onConfirm,
onCancel
}: ConfirmDialogProps) {
return (
<Dialog
open={open}
onClose={onCancel}
title={title}
footer={
<>
<Button variant="secondary" onClick={onCancel} disabled={busy}>
{cancelLabel}
</Button>
<Button
variant={tone === 'danger' ? 'danger' : 'primary'}
onClick={onConfirm}
disabled={busy}
loading={busy}
>
{confirmLabel}
</Button>
</>
}
>
<p className="ctv-confirm-message">{message}</p>
</Dialog>
);
}