From 15c6cdd0868265ccbe6aa46afce698863e95494f Mon Sep 17 00:00:00 2001 From: Timothy Date: Sun, 5 Jul 2026 10:41:29 +0200 Subject: [PATCH 1/2] feat(web): add Channels screen refs #84 --- web/src/App.test.tsx | 294 ++++++++++++++++++++++++++- web/src/App.tsx | 425 ++++++++++++++++++++++++++++++++++++++++ web/src/api/channels.ts | 170 +++++++++++++++- web/src/shell.css | 299 ++++++++++++++++++++++++++++ 4 files changed, 1184 insertions(+), 4 deletions(-) diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index d0b4a55ef..e0381e0d3 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { cleanup, fireEvent, render, screen, waitFor } 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'; @@ -11,6 +11,7 @@ import { describe('ChicoryTV SPA scaffold', () => { afterEach(() => { cleanup(); + vi.useRealTimers(); window.history.replaceState(null, '', '/'); }); @@ -35,13 +36,13 @@ describe('ChicoryTV SPA scaffold', () => { expect(designSystemStylesheet).toBe('../../design-system/styles.css'); }); - it('routes between shell screen slots without a page reload', () => { + it('routes between shell screen slots without a page reload', async () => { render(); fireEvent.click(screen.getByRole('link', { name: 'Channels' })); expect(screen.getByRole('heading', { name: 'Channels' })).toBeInTheDocument(); - expect(screen.getByText('Channel list workspace')).toBeInTheDocument(); + expect(await screen.findByText('No channels returned')).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'Channels' })).toHaveAttribute( 'aria-current', 'page' @@ -271,6 +272,271 @@ describe('ChicoryTV SPA scaffold', () => { expect(window.fetch).not.toHaveBeenCalledWith('/api/schedules', expect.any(Object)); }); + it('renders the Channels screen from live channel and state APIs', async () => { + mockDashboardApi({ + channels: [ + { + fFmpegProfile: 'HLS Direct', + group: 'Kids', + id: 1, + isEnabled: true, + language: 'en', + name: 'Retro Cartoons', + number: '5.1', + showInEpg: true, + sortNumber: 5.1, + streamingMode: 'HLS Direct' + }, + { + fFmpegProfile: 'MPEG-TS', + group: 'News', + id: 2, + isEnabled: false, + language: 'fr', + name: 'News 24', + number: '24', + showInEpg: false, + sortNumber: 24, + streamingMode: 'MPEG-TS' + } + ], + channelStates: [ + { + channelId: 1, + channelNumber: '5.1', + onAir: true, + nowPlaying: { + finishUtc: '2026-07-04T21:30:00Z', + startUtc: '2026-07-04T21:00:00Z', + title: 'Saturday Morning Cartoons' + } + }, + { + channelId: 2, + channelNumber: '24', + onAir: false, + nowPlaying: null + } + ] + }); + + render(); + + fireEvent.click(screen.getByRole('link', { name: 'Channels' })); + + expect(await screen.findByRole('heading', { name: 'Channels' })).toBeInTheDocument(); + expect(await screen.findByRole('table', { name: 'Channels lineup' })).toBeInTheDocument(); + expect(screen.getByText('Retro Cartoons')).toBeInTheDocument(); + expect(screen.getByText('Saturday Morning Cartoons')).toBeInTheDocument(); + expect(screen.getByText('News 24')).toBeInTheDocument(); + expect(screen.getByText('Off air')).toBeInTheDocument(); + expect(screen.getByTitle('Disabled')).toHaveTextContent('D'); + expect(screen.getByTitle('Hidden from EPG')).toHaveTextContent('H'); + expect(screen.getByRole('button', { name: 'On air 1' })).toBeInTheDocument(); + expect(screen.getByText('2 of 2 channels')).toBeInTheDocument(); + expect(window.fetch).toHaveBeenCalledWith('/api/channels/state', expect.any(Object)); + }); + + it('polls only channel state after the Channels screen initial load', async () => { + const intervalHandlers: Array<() => void> = []; + vi.spyOn(window, 'setInterval').mockImplementation((handler) => { + if (typeof handler === 'function') { + intervalHandlers.push(handler as () => void); + } + + return 1; + }); + vi.spyOn(window, 'clearInterval').mockImplementation(() => undefined); + mockDashboardApi({ + channels: [ + { + fFmpegProfile: 'HLS Direct', + group: 'Kids', + id: 1, + isEnabled: true, + language: 'en', + name: 'Retro Cartoons', + number: '5.1', + showInEpg: true, + sortNumber: 5.1, + streamingMode: 'HLS Segmenter' + } + ], + channelStates: [{ channelId: 1, channelNumber: '5.1', onAir: true, nowPlaying: null }] + }); + + render(); + + fireEvent.click(screen.getByRole('link', { name: 'Channels' })); + expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument(); + + const channelFetchesBeforePoll = fetchCount('/api/channels'); + const stateFetchesBeforePoll = fetchCount('/api/channels/state'); + + intervalHandlers.forEach((handler) => handler()); + + await waitFor(() => { + expect(fetchCount('/api/channels/state')).toBeGreaterThan(stateFetchesBeforePoll); + }); + expect(fetchCount('/api/channels')).toBe(channelFetchesBeforePoll); + }); + + it('keeps preview disabled because the list DTO lacks legacy preview support data', async () => { + mockDashboardApi({ + channels: [ + { + fFmpegProfile: 'HLS Direct', + group: 'Kids', + id: 1, + isEnabled: true, + language: 'en', + name: 'Retro Cartoons', + number: '5.1', + showInEpg: true, + sortNumber: 5.1, + streamingMode: 'HLS Segmenter' + } + ], + channelStates: [{ channelId: 1, channelNumber: '5.1', onAir: true, nowPlaying: null }] + }); + + render(); + + fireEvent.click(screen.getByRole('link', { name: 'Channels' })); + expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument(); + + expect(screen.getByRole('button', { name: 'Preview unavailable for Retro Cartoons' })).toBeDisabled(); + }); + + it('filters the Channels screen by on-air and disabled rows', async () => { + mockDashboardApi({ + channels: [ + { + fFmpegProfile: 'HLS Direct', + group: 'Kids', + id: 1, + isEnabled: true, + language: 'en', + name: 'Retro Cartoons', + number: '5.1', + showInEpg: true, + sortNumber: 5.1, + streamingMode: 'HLS Direct' + }, + { + fFmpegProfile: 'MPEG-TS', + group: 'News', + id: 2, + isEnabled: false, + language: 'fr', + name: 'News 24', + number: '24', + showInEpg: false, + sortNumber: 24, + streamingMode: 'MPEG-TS' + } + ], + channelStates: [ + { channelId: 1, channelNumber: '5.1', onAir: true, nowPlaying: null }, + { channelId: 2, channelNumber: '24', onAir: false, nowPlaying: null } + ] + }); + + render(); + + fireEvent.click(screen.getByRole('link', { name: 'Channels' })); + expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'On air 1' })); + expect(screen.getByText('Retro Cartoons')).toBeInTheDocument(); + expect(screen.queryByText('News 24')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Disabled 1' })); + expect(screen.queryByText('Retro Cartoons')).not.toBeInTheDocument(); + expect(screen.getByText('News 24')).toBeInTheDocument(); + }); + + it('runs channel row delete and refetches the Channels screen', async () => { + mockDashboardApi({ + channels: [ + { + fFmpegProfile: 'HLS Direct', + group: 'Kids', + id: 1, + isEnabled: true, + language: 'en', + name: 'Retro Cartoons', + number: '5.1', + showInEpg: true, + sortNumber: 5.1, + streamingMode: 'HLS Direct' + } + ], + confirm: true + }); + + render(); + + fireEvent.click(screen.getByRole('link', { name: 'Channels' })); + expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Delete Retro Cartoons' })); + + await screen.findByText('Retro Cartoons'); + expect(window.fetch).toHaveBeenCalledWith('/api/channels/1', expect.objectContaining({ method: 'DELETE' })); + expect(fetchCount('/api/channels')).toBeGreaterThan(2); + }); + + it('runs bulk move and delete actions with ProblemDetails error display', async () => { + mockDashboardApi({ + channels: [ + { + fFmpegProfile: 'HLS Direct', + group: 'Kids', + id: 1, + isEnabled: true, + language: 'en', + name: 'Retro Cartoons', + number: '5.1', + showInEpg: true, + sortNumber: 5.1, + streamingMode: 'HLS Direct' + } + ], + confirm: true, + mutationFailures: { + '/api/channels/bulk/delete': { + detail: 'Channel 1 cannot be deleted while active', + status: 422, + title: 'Validation failed' + } + }, + prompt: 'Movies' + }); + + render(); + + fireEvent.click(screen.getByRole('link', { name: 'Channels' })); + expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('checkbox', { name: 'Select Retro Cartoons' })); + fireEvent.click(screen.getByRole('button', { name: 'Move to group' })); + + expect(window.fetch).toHaveBeenCalledWith('/api/channels/bulk/group', expect.objectContaining({ + body: JSON.stringify({ channelIds: [1], group: 'Movies' }), + method: 'POST' + })); + + expect(await screen.findByRole('checkbox', { name: 'Select Retro Cartoons' })).toHaveAttribute( + 'aria-checked', + 'false' + ); + fireEvent.click(screen.getByRole('checkbox', { name: 'Select Retro Cartoons' })); + fireEvent.click(screen.getByRole('button', { name: 'Delete selected' })); + + expect(await screen.findByText('Channel 1 cannot be deleted while active')).toBeInTheDocument(); + }); + it('shows the dashboard loading state while requests are pending', async () => { vi.spyOn(window, 'fetch').mockImplementation(() => new Promise(() => {})); @@ -428,18 +694,27 @@ function jsonResponse(body: unknown, status = 200): Response { function mockDashboardApi({ channels = [], channelStates = [], + confirm = false, health = [], mediaSources = [], + mutationFailures = {}, playouts = { page: [], totalCount: 0 }, + prompt = null, version = { apiVersion: 3, appVersion: '26.4.0' } }: { channels?: unknown[]; channelStates?: unknown[]; + confirm?: boolean; health?: unknown[]; mediaSources?: unknown[]; + mutationFailures?: Record; playouts?: unknown; + prompt?: string | null; version?: unknown; } = {}) { + vi.spyOn(window, 'confirm').mockReturnValue(confirm); + vi.spyOn(window, 'prompt').mockReturnValue(prompt); + vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => { const path = input.toString(); @@ -467,6 +742,19 @@ function mockDashboardApi({ return Promise.resolve(jsonResponse(version)); } + if (path in mutationFailures) { + return Promise.resolve(jsonResponse(mutationFailures[path], 422)); + } + + if ( + path === '/api/channels/bulk/renumber' || + path === '/api/channels/bulk/group' || + path === '/api/channels/bulk/delete' || + path.startsWith('/api/channels/') + ) { + return Promise.resolve(new Response(null, { status: 204 })); + } + return Promise.resolve(jsonResponse(null, 404)); }); } diff --git a/web/src/App.tsx b/web/src/App.tsx index 83c503882..8d6ced2be 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -15,17 +15,24 @@ import { ChevronDown, CircleHelp, ClipboardCopy, + Folder, + FolderInput, FolderTree, + Hash, Info, LayoutDashboard, LayoutGrid, Library, ListVideo, Plus, + Pencil, + Play, Radio, RefreshCw, Search, Settings, + Stethoscope, + Trash2, TriangleAlert, Tv, } from 'lucide-react'; @@ -34,6 +41,7 @@ import { Badge, Button, Card, + Checkbox, ChannelLogo, IconButton, NavItem, @@ -45,9 +53,17 @@ import { } from './components'; import { useChannelsQuery, + useChannelsScreenQuery, + bulkDeleteChannels, + bulkMoveChannelsToGroup, + bulkRenumberChannels, + deleteChannel, + messageFromError, useDashboardHealthQuery, useDashboardQuery, useDashboardVersionQuery, + type ChannelState, + type ChannelSummary, type DashboardChannel, type DashboardChannelState, type DashboardHealthQueryState @@ -213,6 +229,11 @@ function routeHref(route: ScreenRoute): string { return route.id === 'dashboard' ? '/app' : route.path; } +function navigateToPath(path: string) { + window.history.pushState(null, '', path); + window.dispatchEvent(new PopStateEvent('popstate')); +} + function SidebarNavGroup({ activeRoute, ids, @@ -739,6 +760,406 @@ function DashboardScreen({ ); } +type ChannelViewFilter = 'all' | 'onair' | 'disabled'; + +const streamingModeBadgeTones: Record = { + 'HLS Direct': 'neutral', + 'HLS Segmenter': 'accent', + 'MPEG-TS': 'accent', + 'MPEG-TS (Legacy)': 'neutral' +}; + +function stateByChannelId(states: ChannelState[]): Map { + return new Map(states.map((state) => [state.channelId, state])); +} + +function sortedChannels(channels: ChannelSummary[]): ChannelSummary[] { + return [...channels].sort((left, right) => left.sortNumber - right.sortNumber || left.number.localeCompare(right.number)); +} + +function groupedChannels(channels: ChannelSummary[]): Array<{ group: string; rows: ChannelSummary[] }> { + const groups: Array<{ group: string; rows: ChannelSummary[] }> = []; + const indexes = new Map(); + + for (const channel of channels) { + const group = channel.group || 'Ungrouped'; + const index = indexes.get(group); + + if (index == null) { + indexes.set(group, groups.length); + groups.push({ group, rows: [channel] }); + } else { + groups[index].rows.push(channel); + } + } + + return groups; +} + +function progressFromChannelState(state: ChannelState): number | null { + return state.nowPlaying ? progressFromNowPlaying(state.nowPlaying) : null; +} + +function Marker({ children, title }: { children: ReactNode; title: string }) { + return ( + + {children} + + ); +} + +function ChannelsLoadingState() { + return ( + +
+ + Loading channels +
+
+ ); +} + +function ChannelsErrorState({ error, refresh }: { error: string; refresh: () => void }) { + return ( + Channels unavailable} + subtitle="Live API request failed" + actions={} + > +
+ API request failed + {error} +
+
+ ); +} + +function ChannelsEmptyState() { + return ( + No channels} subtitle="The API returned an empty channel lineup."> +
No channels returned
+
+ ); +} + +function ChannelsScreen() { + const query = useChannelsScreenQuery(); + const [filter, setFilter] = useState('all'); + const [selectedIds, setSelectedIds] = useState>(() => new Set()); + const [mutationError, setMutationError] = useState(null); + const [mutating, setMutating] = useState(false); + + if (query.status === 'loading') { + return ; + } + + if (query.status === 'error') { + return ; + } + + const channels = sortedChannels(query.data.channels); + const statesById = stateByChannelId(query.data.channelStates); + const onAirCount = query.data.channelStates.filter((state) => state.onAir).length; + const disabledCount = channels.filter((channel) => !channel.isEnabled).length; + const visibleChannels = channels.filter((channel) => { + if (filter === 'onair') { + return statesById.get(channel.id)?.onAir === true; + } + + if (filter === 'disabled') { + return !channel.isEnabled; + } + + return true; + }); + const groups = groupedChannels(visibleChannels); + const visibleIds = visibleChannels.map((channel) => channel.id); + const selectedVisibleCount = visibleIds.filter((id) => selectedIds.has(id)).length; + const allVisibleSelected = visibleIds.length > 0 && selectedVisibleCount === visibleIds.length; + const someVisibleSelected = selectedVisibleCount > 0 && !allVisibleSelected; + const selectedChannels = channels.filter((channel) => selectedIds.has(channel.id)); + + const refreshAfterMutation = async (operation: () => Promise) => { + setMutationError(null); + setMutating(true); + + try { + await operation(); + setSelectedIds(new Set()); + query.refresh(); + } catch (error: unknown) { + setMutationError(messageFromError(error)); + } finally { + setMutating(false); + } + }; + + const toggleAll = () => { + setSelectedIds((current) => { + const next = new Set(current); + + if (allVisibleSelected) { + visibleIds.forEach((id) => next.delete(id)); + } else { + visibleIds.forEach((id) => next.add(id)); + } + + return next; + }); + }; + + const toggleOne = (channelId: number) => { + setSelectedIds((current) => { + const next = new Set(current); + + if (next.has(channelId)) { + next.delete(channelId); + } else { + next.add(channelId); + } + + return next; + }); + }; + + const moveSelectedToGroup = () => { + const group = window.prompt('Move selected channels to group', selectedChannels[0]?.group ?? ''); + + if (group == null) { + return; + } + + void refreshAfterMutation(() => bulkMoveChannelsToGroup({ + channelIds: selectedChannels.map((channel) => channel.id), + group + })); + }; + + const renumberSelected = () => { + const firstNumber = window.prompt('First channel number', selectedChannels[0]?.number ?? '1'); + + if (firstNumber == null) { + return; + } + + const start = Number.parseFloat(firstNumber); + + if (!Number.isFinite(start)) { + setMutationError('First channel number must be numeric'); + return; + } + + void refreshAfterMutation(() => bulkRenumberChannels({ + channels: selectedChannels.map((channel, index) => ({ + id: channel.id, + number: formatChannelNumber(start + index) + })) + })); + }; + + const deleteSelected = () => { + if (!window.confirm(`Delete ${selectedChannels.length} selected channel${selectedChannels.length === 1 ? '' : 's'}?`)) { + return; + } + + void refreshAfterMutation(() => bulkDeleteChannels({ channelIds: selectedChannels.map((channel) => channel.id) })); + }; + + const deleteOne = (channel: ChannelSummary) => { + if (!window.confirm(`Delete ${channel.name}?`)) { + return; + } + + void refreshAfterMutation(() => deleteChannel(channel.id)); + }; + + if (channels.length === 0) { + return ; + } + + return ( +
+
+ {selectedChannels.length > 0 ? ( + <> + {selectedChannels.length} selected + + + + + + + + ) : ( + <> +
+ + + +
+ + {onAirCount} on air + + + )} +
+ + {mutationError && ( +
+
+ )} + +
+
+ + + + + + + + + + + + + {groups.map(({ group, rows }) => ( + + ))} + +
No.ChannelNow playingStreamingFFmpeg +
+
+
+ {visibleChannels.length} of {channels.length} channels + {groups.length} groups +
+
+
+ ); +} + +function ChannelGroupRows({ + channels, + group, + onDelete, + onToggle, + selectedIds, + statesById +}: { + channels: ChannelSummary[]; + group: string; + onDelete: (channel: ChannelSummary) => void; + onToggle: (channelId: number) => void; + selectedIds: Set; + statesById: Map; +}) { + return ( + <> + + + + + + {channels.map((channel) => ( + + ))} + + ); +} + +function formatChannelNumber(value: number): string { + return Number.isInteger(value) ? `${value}` : value.toFixed(2).replace(/0+$/, '').replace(/\.$/, ''); +} + +function ChannelTableRow({ + channel, + onDelete, + onToggle, + selected, + state +}: { + channel: ChannelSummary; + onDelete: (channel: ChannelSummary) => void; + onToggle: (channelId: number) => void; + selected: boolean; + state: ChannelState | null; +}) { + const live = state?.onAir === true; + const dim = !channel.isEnabled; + const progress = state ? progressFromChannelState(state) : null; + const streamingTone = streamingModeBadgeTones[channel.streamingMode] ?? 'neutral'; + + return ( + + + onToggle(channel.id)} /> + + + {live && }{channel.number} + + +
+ +
+ {channel.name} + + {!channel.isEnabled && D} + {!channel.showInEpg && H} + + {channel.language || 'Language unset'} +
+
+ + + {state?.nowPlaying ? ( +
+ {state.nowPlaying.title} + +
+ ) : ( + {channel.isEnabled ? 'Idle' : 'Off air'} + )} + + {channel.streamingMode} + {channel.fFmpegProfile || 'Unassigned'} + +
+ + + navigateToPath(`/app/new-channel?edit=${channel.id}`)} size="sm" title={`Edit ${channel.name}`}> + + + + onDelete(channel)} size="sm" title={`Delete ${channel.name}`}> + +
+ + + ); +} + function PlaceholderScreen({ route }: { route: ScreenRoute }) { return (
@@ -799,6 +1220,10 @@ function ScreenContent({ return ; } + if (route.id === 'channels') { + return ; + } + return ; } diff --git a/web/src/api/channels.ts b/web/src/api/channels.ts index 379703918..feec08fe2 100644 --- a/web/src/api/channels.ts +++ b/web/src/api/channels.ts @@ -1,8 +1,176 @@ -import { request } from './client'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { ApiError, request } from './client'; import type { components } from './generated/v1'; export type ChannelSummary = components['schemas']['ChannelResponseModel']; +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 interface ChannelsScreenData { + channels: ChannelSummary[]; + channelStates: ChannelState[]; +} + +export type ChannelsScreenQueryState = + | { data: ChannelsScreenData; error: null; refresh: () => void; status: 'success' } + | { data: null; error: string; refresh: () => void; status: 'error' } + | { data: null; error: null; refresh: () => void; status: 'loading' }; + +type ChannelsScreenState = + | { data: ChannelsScreenData; error: null; status: 'success' } + | { data: null; error: string; status: 'error' } + | { data: null; error: null; status: 'loading' }; export function getChannels(): Promise { return request('/api/channels'); } + +export function getChannelStates(): Promise { + return request('/api/channels/state'); +} + +export async function getChannelsScreenData(): Promise { + const [channels, channelStates] = await Promise.all([ + getChannels(), + getChannelStates() + ]); + + return { channels, channelStates }; +} + +export function bulkRenumberChannels(body: BulkRenumberChannelsRequest): Promise { + return request('/api/channels/bulk/renumber', { + body, + method: 'POST' + }); +} + +export function bulkMoveChannelsToGroup(body: BulkMoveChannelsToGroupRequest): Promise { + return request('/api/channels/bulk/group', { + body, + method: 'POST' + }); +} + +export function bulkDeleteChannels(body: BulkDeleteChannelsRequest): Promise { + return request('/api/channels/bulk/delete', { + body, + method: 'POST' + }); +} + +export function deleteChannel(channelId: number): Promise { + return request(`/api/channels/${channelId}`, { + method: 'DELETE' + }); +} + +export function useChannelsScreenQuery(pollMs = 30000): ChannelsScreenQueryState { + const [state, setState] = useState({ + data: null, + error: null, + status: 'loading' + }); + const activeRef = useRef(true); + + useEffect(() => { + activeRef.current = true; + + return () => { + activeRef.current = false; + }; + }, []); + + const load = useCallback((showLoading = true) => { + if (showLoading) { + setState({ data: null, error: null, status: 'loading' }); + } + + getChannelsScreenData() + .then((data) => { + if (activeRef.current) { + setState({ data, error: null, status: 'success' }); + } + }) + .catch((error: unknown) => { + if (activeRef.current) { + setState({ data: null, error: messageFromError(error), status: 'error' }); + } + }); + }, []); + + const loadChannelStates = useCallback(() => { + getChannelStates() + .then((channelStates) => { + if (activeRef.current) { + setState((current) => { + if (current.status !== 'success') { + return current; + } + + return { + data: { ...current.data, channelStates }, + error: null, + status: 'success' + }; + }); + } + }) + .catch(() => { + // Keep the current table visible on background polling failures. + }); + }, []); + + useEffect(() => { + const loadForEffect = () => { + getChannelsScreenData() + .then((data) => { + if (activeRef.current) { + setState({ data, error: null, status: 'success' }); + } + }) + .catch((error: unknown) => { + if (activeRef.current) { + setState({ data: null, error: messageFromError(error), status: 'error' }); + } + }); + }; + + loadForEffect(); + const intervalId = window.setInterval(() => { + loadChannelStates(); + }, pollMs); + + return () => { + window.clearInterval(intervalId); + }; + }, [loadChannelStates, pollMs]); + + const refresh = useCallback(() => { + load(); + }, [load]); + + if (state.status === 'success') { + return { data: state.data, error: null, refresh, status: 'success' }; + } + + if (state.status === 'error') { + return { data: null, error: state.error, refresh, status: 'error' }; + } + + return { data: null, error: null, refresh, status: 'loading' }; +} + +export function messageFromError(error: unknown): string { + if (error instanceof ApiError) { + return error.detail ?? error.message; + } + + if (error instanceof Error) { + return error.message; + } + + return 'Unable to load channels'; +} diff --git a/web/src/shell.css b/web/src/shell.css index fcedceadd..c74e99f3b 100644 --- a/web/src/shell.css +++ b/web/src/shell.css @@ -703,6 +703,287 @@ text-transform: uppercase; } +.ctv-channels-screen { + display: grid; + gap: var(--space-5, 10px); +} + +.ctv-channels-actionbar { + min-height: 58px; + display: flex; + align-items: center; + gap: var(--space-5, 10px); + border: 1px solid var(--border-hairline); + border-radius: var(--radius-md, 7px); + background: var(--surface-card); + padding: var(--space-5, 10px) var(--space-7, 16px); +} + +.ctv-segmented { + display: inline-flex; + gap: 2px; + border: 1px solid var(--border-hairline); + border-radius: var(--radius-sm, 5px); + background: var(--ctv-bg-sunken); + padding: 2px; +} + +.ctv-segmented button { + height: 26px; + display: inline-flex; + align-items: center; + gap: var(--space-3, 6px); + border: 0; + border-radius: var(--radius-xs, 3px); + background: transparent; + color: var(--text-secondary); + cursor: pointer; + padding: 0 11px; + font: inherit; + font-size: var(--text-xs, 12px); +} + +.ctv-segmented button[aria-pressed="true"] { + background: var(--ctv-surface-3); + color: var(--text-primary); + font-weight: var(--weight-medium, 500); +} + +.ctv-segmented code, +.ctv-channels-selected code, +.ctv-channels-live code, +.ctv-channels-footer code, +.ctv-channel-group-row code, +.ctv-channel-number code { + font-family: var(--font-mono, ui-monospace, monospace); + font-variant-numeric: tabular-nums; +} + +.ctv-channels-selected, +.ctv-channels-live { + display: inline-flex; + align-items: center; + gap: var(--space-3, 6px); + color: var(--text-secondary); + font-size: var(--text-xs, 12px); +} + +.ctv-channels-selected { + color: var(--text-primary); + font-size: var(--text-sm, 13px); + font-weight: var(--weight-medium, 500); +} + +.ctv-channels-spacer { + flex: 1; +} + +.ctv-channels-error { + display: flex; + align-items: center; + gap: var(--space-4, 8px); + border: 1px solid color-mix(in srgb, var(--status-error) 32%, transparent); + border-radius: var(--radius-md, 7px); + background: var(--ctv-error-soft); + color: var(--status-error); + padding: var(--space-5, 10px) var(--space-6, 12px); + font-size: var(--text-sm, 13px); + font-weight: var(--weight-medium, 500); +} + +.ctv-channels-table-frame { + overflow: hidden; + border: 1px solid var(--border-hairline); + border-radius: var(--radius-md, 7px); + background: var(--surface-card); +} + +.ctv-channels-table-scroll { + overflow: auto; +} + +.ctv-channels-table { + width: 100%; + min-width: 920px; + border-collapse: collapse; +} + +.ctv-channels-table th { + position: sticky; + top: 0; + z-index: 2; + height: 34px; + border-bottom: 1px solid var(--border-hairline); + background: var(--surface-card); + color: var(--text-disabled); + padding: 0 var(--pad-cell-x, 12px); + text-align: left; + white-space: nowrap; + font-size: var(--text-2xs, 11px); + font-weight: var(--weight-medium, 500); + letter-spacing: var(--tracking-caps, 0.06em); + text-transform: uppercase; +} + +.ctv-channels-table td { + height: 56px; + border-top: 1px solid var(--border-hairline); + color: var(--text-primary); + padding: 0 var(--pad-cell-x, 12px); + vertical-align: middle; +} + +.ctv-channel-check { + width: 38px; + padding-left: 14px !important; +} + +.ctv-channel-group-row td { + height: 32px; + border-top: 1px solid var(--border-hairline); + border-bottom: 1px solid var(--border-hairline); + background: var(--ctv-bg-sunken); +} + +.ctv-channel-group-row span { + display: inline-flex; + align-items: center; + gap: var(--space-4, 8px); + color: var(--text-secondary); + font-size: var(--text-2xs, 11px); + font-weight: var(--weight-semibold, 600); + letter-spacing: var(--tracking-caps, 0.06em); + text-transform: uppercase; +} + +.ctv-channel-group-row svg { + color: var(--text-disabled); +} + +.ctv-channel-row-live { + background: var(--ctv-live-soft); + box-shadow: inset 2px 0 0 var(--status-live); +} + +.ctv-channel-row-selected { + background: var(--ctv-accent-soft); +} + +.ctv-channel-row-dim td:not(:last-child) { + opacity: 0.58; +} + +.ctv-channel-number { + width: 74px; +} + +.ctv-channel-number span { + display: inline-flex; + align-items: center; + gap: var(--space-3, 6px); + color: var(--status-live); +} + +.ctv-channel-identity { + min-width: 220px; + display: flex; + align-items: center; + gap: 11px; +} + +.ctv-channel-identity > div { + min-width: 0; + display: grid; + grid-template-columns: auto auto; + align-items: center; + gap: 3px var(--space-4, 8px); +} + +.ctv-channel-identity strong { + overflow: hidden; + color: var(--text-primary); + font-weight: var(--weight-medium, 500); + line-height: 1.2; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ctv-channel-identity small { + grid-column: 1 / -1; + color: var(--text-disabled); + font-size: var(--text-2xs, 11px); + line-height: 1; +} + +.ctv-channel-markers { + display: inline-flex; + gap: var(--space-2, 4px); +} + +.ctv-channel-marker { + min-width: 15px; + height: 15px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--border-control); + border-radius: var(--radius-xs, 3px); + background: var(--ctv-surface-3); + color: var(--text-disabled); + padding: 0 3px; + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 9px; + font-weight: var(--weight-semibold, 600); + line-height: 1; +} + +.ctv-channel-now { + width: 280px; +} + +.ctv-channel-now > div { + max-width: 250px; + display: grid; + gap: var(--space-3, 6px); +} + +.ctv-channel-now span { + overflow: hidden; + color: var(--text-secondary); + font-size: var(--text-xs, 12px); + line-height: 1.2; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ctv-channel-now > span { + color: var(--text-faint); +} + +.ctv-channel-ffmpeg { + width: 140px; + color: var(--text-secondary) !important; + font-family: var(--font-mono, ui-monospace, monospace); + font-size: var(--text-xs, 12px); +} + +.ctv-channel-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 2px; +} + +.ctv-channels-footer { + display: flex; + align-items: center; + justify-content: space-between; + border-top: 1px solid var(--border-hairline); + color: var(--text-secondary); + padding: var(--space-5, 10px) var(--space-7, 16px); + font-size: var(--text-xs, 12px); +} + @media (max-width: 980px) { .ctv-app-shell { grid-template-columns: 1fr; @@ -746,4 +1027,22 @@ .ctv-slot-grid { grid-template-columns: 1fr; } + + .ctv-channels-actionbar { + align-items: flex-start; + flex-wrap: wrap; + } + + .ctv-channels-spacer { + display: none; + } + + .ctv-segmented { + width: 100%; + } + + .ctv-segmented button { + flex: 1; + justify-content: center; + } } -- 2.47.3 From ecf6cf75132460e24c1da579f1fe2682769e71d3 Mon Sep 17 00:00:00 2001 From: Timothy Date: Sun, 5 Jul 2026 11:20:32 +0200 Subject: [PATCH 2/2] fix(web): scope channel bulk ops to visible rows, review fixes for #84 - Derive selectedChannels from visibleChannels and clear selection on filter change so bulk operations can never act on rows hidden by the active filter (reachable via the 30s state poll under the on-air filter) - Disable the per-row delete button while a mutation is in flight - Correct the --pad-cell-x fallback (token resolves to 16px) - Add tests: bulk renumber request body + selection clearing, and selection reset across filter changes Review: PR #124 findings 1, 2, 4, 8 Co-Authored-By: Claude Fable 5 --- web/src/App.test.tsx | 111 +++++++++++++++++++++++++++++++++++++++++++ web/src/App.tsx | 21 ++++++-- web/src/shell.css | 4 +- 3 files changed, 129 insertions(+), 7 deletions(-) diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index e0381e0d3..d7bdd191b 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -537,6 +537,117 @@ describe('ChicoryTV SPA scaffold', () => { expect(await screen.findByText('Channel 1 cannot be deleted while active')).toBeInTheDocument(); }); + it('runs bulk renumber and clears the selection afterward', async () => { + mockDashboardApi({ + channels: [ + { + fFmpegProfile: 'HLS Direct', + group: 'Kids', + id: 1, + isEnabled: true, + language: 'en', + name: 'Retro Cartoons', + number: '5.1', + showInEpg: true, + sortNumber: 5.1, + streamingMode: 'HLS Direct' + }, + { + fFmpegProfile: 'MPEG-TS', + group: 'News', + id: 2, + isEnabled: false, + language: 'fr', + name: 'News 24', + number: '24', + showInEpg: false, + sortNumber: 24, + streamingMode: 'MPEG-TS' + } + ], + prompt: '10' + }); + + render(); + + fireEvent.click(screen.getByRole('link', { name: 'Channels' })); + expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('checkbox', { name: 'Select Retro Cartoons' })); + fireEvent.click(screen.getByRole('checkbox', { name: 'Select News 24' })); + fireEvent.click(screen.getByRole('button', { name: 'Renumber' })); + + expect(window.fetch).toHaveBeenCalledWith('/api/channels/bulk/renumber', expect.objectContaining({ + body: JSON.stringify({ channels: [{ id: 1, number: '10' }, { id: 2, number: '11' }] }), + method: 'POST' + })); + + expect(await screen.findByRole('checkbox', { name: 'Select Retro Cartoons' })).toHaveAttribute( + 'aria-checked', + 'false' + ); + expect(screen.getByRole('checkbox', { name: 'Select News 24' })).toHaveAttribute( + 'aria-checked', + 'false' + ); + }); + + it('clears the selection when the channel view filter changes', async () => { + mockDashboardApi({ + channels: [ + { + fFmpegProfile: 'HLS Direct', + group: 'Kids', + id: 1, + isEnabled: true, + language: 'en', + name: 'Retro Cartoons', + number: '5.1', + showInEpg: true, + sortNumber: 5.1, + streamingMode: 'HLS Direct' + }, + { + fFmpegProfile: 'MPEG-TS', + group: 'News', + id: 2, + isEnabled: true, + language: 'fr', + name: 'News 24', + number: '24', + showInEpg: true, + sortNumber: 24, + streamingMode: 'MPEG-TS' + } + ], + channelStates: [ + { channelId: 1, channelNumber: '5.1', onAir: true, nowPlaying: null }, + { channelId: 2, channelNumber: '24', onAir: false, nowPlaying: null } + ] + }); + + render(); + + fireEvent.click(screen.getByRole('link', { name: 'Channels' })); + expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('checkbox', { name: 'Select Retro Cartoons' })); + fireEvent.click(screen.getByRole('checkbox', { name: 'Select News 24' })); + expect(screen.getByRole('button', { name: 'Delete selected' })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Clear' })); + expect(screen.getByRole('button', { name: 'On air 1' })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'On air 1' })); + expect(screen.queryByText('News 24')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('checkbox', { name: 'Select Retro Cartoons' })); + fireEvent.click(screen.getByRole('button', { name: 'Clear' })); + + expect(screen.queryByRole('button', { name: 'Delete selected' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'All 2' })).toBeInTheDocument(); + }); + it('shows the dashboard loading state while requests are pending', async () => { vi.spyOn(window, 'fetch').mockImplementation(() => new Promise(() => {})); diff --git a/web/src/App.tsx b/web/src/App.tsx index 8d6ced2be..fd832dd0d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -877,7 +877,7 @@ function ChannelsScreen() { const selectedVisibleCount = visibleIds.filter((id) => selectedIds.has(id)).length; const allVisibleSelected = visibleIds.length > 0 && selectedVisibleCount === visibleIds.length; const someVisibleSelected = selectedVisibleCount > 0 && !allVisibleSelected; - const selectedChannels = channels.filter((channel) => selectedIds.has(channel.id)); + const selectedChannels = visibleChannels.filter((channel) => selectedIds.has(channel.id)); const refreshAfterMutation = async (operation: () => Promise) => { setMutationError(null); @@ -965,6 +965,11 @@ function ChannelsScreen() { void refreshAfterMutation(() => bulkDeleteChannels({ channelIds: selectedChannels.map((channel) => channel.id) })); }; + const changeFilter = (nextFilter: ChannelViewFilter) => { + setFilter(nextFilter); + setSelectedIds(new Set()); + }; + const deleteOne = (channel: ChannelSummary) => { if (!window.confirm(`Delete ${channel.name}?`)) { return; @@ -993,9 +998,9 @@ function ChannelsScreen() { ) : ( <>
- - - + + +
{onAirCount} on air @@ -1031,6 +1036,7 @@ function ChannelsScreen() { channels={rows} group={group} key={group} + mutating={mutating} onDelete={deleteOne} onToggle={toggleOne} selectedIds={selectedIds} @@ -1052,6 +1058,7 @@ function ChannelsScreen() { function ChannelGroupRows({ channels, group, + mutating, onDelete, onToggle, selectedIds, @@ -1059,6 +1066,7 @@ function ChannelGroupRows({ }: { channels: ChannelSummary[]; group: string; + mutating: boolean; onDelete: (channel: ChannelSummary) => void; onToggle: (channelId: number) => void; selectedIds: Set; @@ -1075,6 +1083,7 @@ function ChannelGroupRows({ void; onToggle: (channelId: number) => void; selected: boolean; @@ -1151,7 +1162,7 @@ function ChannelTableRow({ - onDelete(channel)} size="sm" title={`Delete ${channel.name}`}> + onDelete(channel)} size="sm" title={`Delete ${channel.name}`}>
diff --git a/web/src/shell.css b/web/src/shell.css index c74e99f3b..e1df48878 100644 --- a/web/src/shell.css +++ b/web/src/shell.css @@ -816,7 +816,7 @@ border-bottom: 1px solid var(--border-hairline); background: var(--surface-card); color: var(--text-disabled); - padding: 0 var(--pad-cell-x, 12px); + padding: 0 var(--pad-cell-x, 16px); text-align: left; white-space: nowrap; font-size: var(--text-2xs, 11px); @@ -829,7 +829,7 @@ height: 56px; border-top: 1px solid var(--border-hairline); color: var(--text-primary); - padding: 0 var(--pad-cell-x, 12px); + padding: 0 var(--pad-cell-x, 16px); vertical-align: middle; } -- 2.47.3