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