Merge pull request 'feat(web): collections management screen — manual + smart collections (#140)' (#156) from feat/140-collections-screen into main
This commit was merged in pull request #156.
This commit is contained in:
@@ -55,6 +55,7 @@ import {
|
||||
import chicoryMarkUrl from '../../design-system/assets/chicory-mark.svg';
|
||||
import { ChannelBuilderScreen } from './builder/ChannelBuilder';
|
||||
import { ChannelEditScreen } from './screens/ChannelEditScreen';
|
||||
import { CollectionsScreen } from './screens/CollectionsScreen';
|
||||
import { SettingsScreen } from './screens/SettingsScreen';
|
||||
import { navigateToPath } from './routing';
|
||||
import {
|
||||
@@ -2882,6 +2883,10 @@ function ScreenContent({
|
||||
return <SettingsScreen />;
|
||||
}
|
||||
|
||||
if (route.id === 'collections') {
|
||||
return <CollectionsScreen />;
|
||||
}
|
||||
|
||||
return <PlaceholderScreen route={route} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
addItemsToCollection,
|
||||
createCollection,
|
||||
createSmartCollection,
|
||||
deleteCollection,
|
||||
deleteSmartCollection,
|
||||
emptyAddItemsRequest,
|
||||
getCollectionItemsPreview,
|
||||
getCollections,
|
||||
getSmartCollections,
|
||||
removeItemFromCollection,
|
||||
toAddItemsRequest,
|
||||
updateCollection,
|
||||
updateSmartCollection
|
||||
} from './collections';
|
||||
import type { LibraryBrowseItem } from './libraryBrowse';
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status
|
||||
});
|
||||
}
|
||||
|
||||
function noContent(): Response {
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
function browseItem(id: number, mediaType: LibraryBrowseItem['mediaType']): LibraryBrowseItem {
|
||||
return {
|
||||
artwork: '',
|
||||
collectionId: null,
|
||||
collectionKind: null,
|
||||
collectionType: 'Collection',
|
||||
duration: null,
|
||||
id,
|
||||
itemCount: null,
|
||||
libraryId: null,
|
||||
libraryName: null,
|
||||
mediaItemId: id,
|
||||
mediaType,
|
||||
multiCollectionId: null,
|
||||
playlistId: null,
|
||||
rerunCollectionId: null,
|
||||
smartCollectionId: null,
|
||||
title: 'Item'
|
||||
};
|
||||
}
|
||||
|
||||
describe('collections api client', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('getCollections fetches the manual collections list', async () => {
|
||||
const fetchMock = vi
|
||||
.spyOn(window, 'fetch')
|
||||
.mockResolvedValue(jsonResponse([{ id: 1, name: 'Movies', useCustomPlaybackOrder: false }]));
|
||||
|
||||
await expect(getCollections()).resolves.toHaveLength(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/collections', expect.objectContaining({ method: 'GET' }));
|
||||
});
|
||||
|
||||
it('createCollection POSTs the name and returns the created collection', async () => {
|
||||
const fetchMock = vi
|
||||
.spyOn(window, 'fetch')
|
||||
.mockResolvedValue(jsonResponse({ id: 7, name: 'New', useCustomPlaybackOrder: false }, 201));
|
||||
|
||||
await expect(createCollection({ name: 'New' })).resolves.toMatchObject({ id: 7 });
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('/api/collections');
|
||||
expect(init).toMatchObject({ method: 'POST' });
|
||||
expect(JSON.parse(String(init?.body))).toEqual({ name: 'New' });
|
||||
});
|
||||
|
||||
it('updateCollection PUTs name and useCustomPlaybackOrder', async () => {
|
||||
const fetchMock = vi
|
||||
.spyOn(window, 'fetch')
|
||||
.mockResolvedValue(jsonResponse({ id: 3, name: 'Renamed', useCustomPlaybackOrder: true }));
|
||||
|
||||
await updateCollection(3, { name: 'Renamed', useCustomPlaybackOrder: true });
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('/api/collections/3');
|
||||
expect(init).toMatchObject({ method: 'PUT' });
|
||||
expect(JSON.parse(String(init?.body))).toEqual({ name: 'Renamed', useCustomPlaybackOrder: true });
|
||||
});
|
||||
|
||||
it('deleteCollection issues a DELETE and resolves on 204', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
|
||||
|
||||
await expect(deleteCollection(9)).resolves.toBeUndefined();
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/collections/9', expect.objectContaining({ method: 'DELETE' }));
|
||||
});
|
||||
|
||||
it('addItemsToCollection POSTs the bucketed request', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
|
||||
const body = { ...emptyAddItemsRequest(), movieIds: [11, 12] };
|
||||
|
||||
await addItemsToCollection(5, body);
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('/api/collections/5/items');
|
||||
expect(init).toMatchObject({ method: 'POST' });
|
||||
expect(JSON.parse(String(init?.body)).movieIds).toEqual([11, 12]);
|
||||
});
|
||||
|
||||
it('removeItemFromCollection DELETEs the item by media-item id', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
|
||||
|
||||
await removeItemFromCollection(5, 42);
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/collections/5/items/42', expect.objectContaining({ method: 'DELETE' }));
|
||||
});
|
||||
|
||||
it('rethrows API errors (e.g. 422 on delete)', async () => {
|
||||
vi.spyOn(window, 'fetch').mockResolvedValue(
|
||||
jsonResponse({ status: 422, title: 'In use' }, 422)
|
||||
);
|
||||
|
||||
await expect(deleteCollection(1)).rejects.toMatchObject({ status: 422 });
|
||||
});
|
||||
|
||||
it('getSmartCollections / create / update / delete hit the smart-collection routes', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = input.toString();
|
||||
const method = (init?.method ?? 'GET').toUpperCase();
|
||||
|
||||
if (url === '/api/smart-collections' && method === 'GET') {
|
||||
return Promise.resolve(jsonResponse([{ id: 1, name: 'Action', query: 'genre:action' }]));
|
||||
}
|
||||
|
||||
if (url === '/api/smart-collections' && method === 'POST') {
|
||||
return Promise.resolve(jsonResponse({ id: 2, name: 'Sci-Fi', query: 'genre:scifi' }, 201));
|
||||
}
|
||||
|
||||
if (url === '/api/smart-collections/2' && method === 'PUT') {
|
||||
return Promise.resolve(jsonResponse({ id: 2, name: 'Sci-Fi', query: 'genre:"science fiction"' }));
|
||||
}
|
||||
|
||||
if (url === '/api/smart-collections/2' && method === 'DELETE') {
|
||||
return Promise.resolve(noContent());
|
||||
}
|
||||
|
||||
throw new Error(`unexpected ${method} ${url}`);
|
||||
});
|
||||
|
||||
await expect(getSmartCollections()).resolves.toHaveLength(1);
|
||||
await expect(createSmartCollection({ name: 'Sci-Fi', query: 'genre:scifi' })).resolves.toMatchObject({ id: 2 });
|
||||
await expect(
|
||||
updateSmartCollection(2, { name: 'Sci-Fi', query: 'genre:"science fiction"' })
|
||||
).resolves.toMatchObject({ query: 'genre:"science fiction"' });
|
||||
await expect(deleteSmartCollection(2)).resolves.toBeUndefined();
|
||||
|
||||
const createCall = fetchMock.mock.calls.find(([, init]) => (init?.method ?? '').toUpperCase() === 'POST');
|
||||
expect(JSON.parse(String(createCall?.[1]?.body))).toEqual({ name: 'Sci-Fi', query: 'genre:scifi' });
|
||||
});
|
||||
|
||||
it('getCollectionItemsPreview issues a quoted collection: Lucene query', async () => {
|
||||
const fetchMock = vi
|
||||
.spyOn(window, 'fetch')
|
||||
.mockResolvedValue(jsonResponse({ totalCount: 0, page: [] }));
|
||||
|
||||
await getCollectionItemsPreview('The Office');
|
||||
|
||||
const url = new URL(String(fetchMock.mock.calls[0][0]), 'http://localhost');
|
||||
expect(url.pathname).toBe('/api/library/browse');
|
||||
expect(url.searchParams.get('query')).toBe('collection:"The Office"');
|
||||
});
|
||||
|
||||
it('getCollectionItemsPreview returns [] for a blank name without calling the API', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ totalCount: 0, page: [] }));
|
||||
|
||||
await expect(getCollectionItemsPreview(' ')).resolves.toEqual([]);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('toAddItemsRequest bucket mapping', () => {
|
||||
it('routes each browse media type into its own bucket using item.id', () => {
|
||||
const result = toAddItemsRequest([
|
||||
browseItem(10, 'Movie'),
|
||||
browseItem(11, 'Movie'),
|
||||
browseItem(20, 'TelevisionShow'),
|
||||
browseItem(30, 'TelevisionSeason'),
|
||||
browseItem(40, 'Artist')
|
||||
]);
|
||||
|
||||
expect(result.movieIds).toEqual([10, 11]);
|
||||
expect(result.showIds).toEqual([20]);
|
||||
expect(result.seasonIds).toEqual([30]);
|
||||
expect(result.artistIds).toEqual([40]);
|
||||
});
|
||||
|
||||
it('skips kinds that are not addable media items (collections, playlists, etc.)', () => {
|
||||
const result = toAddItemsRequest([
|
||||
browseItem(1, 'Collection'),
|
||||
browseItem(2, 'SmartCollection'),
|
||||
browseItem(3, 'MultiCollection'),
|
||||
browseItem(4, 'RerunCollection'),
|
||||
browseItem(5, 'Playlist'),
|
||||
browseItem(6, 'Movie')
|
||||
]);
|
||||
|
||||
expect(result.movieIds).toEqual([6]);
|
||||
expect(result.showIds).toEqual([]);
|
||||
expect(result.seasonIds).toEqual([]);
|
||||
expect(result.artistIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('emptyAddItemsRequest leaves the picker-unreachable buckets empty', () => {
|
||||
const empty = emptyAddItemsRequest();
|
||||
|
||||
expect(empty.episodeIds).toEqual([]);
|
||||
expect(empty.musicVideoIds).toEqual([]);
|
||||
expect(empty.otherVideoIds).toEqual([]);
|
||||
expect(empty.songIds).toEqual([]);
|
||||
expect(empty.imageIds).toEqual([]);
|
||||
expect(empty.remoteStreamIds).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import { ApiError, request } from './client';
|
||||
import type { components } from './generated/v1';
|
||||
import { getLibraryBrowseItems, type LibraryBrowseItem } from './libraryBrowse';
|
||||
|
||||
export type MediaCollection = components['schemas']['MediaCollectionViewModel'];
|
||||
export type SmartCollection = components['schemas']['SmartCollectionViewModel'];
|
||||
export type CreateCollectionRequest = components['schemas']['CreateCollectionRequest'];
|
||||
export type UpdateCollectionRequest = components['schemas']['UpdateCollectionRequest'];
|
||||
export type AddItemsToCollectionRequest = components['schemas']['AddItemsToCollectionRequest'];
|
||||
export type CreateSmartCollectionRequest = components['schemas']['CreateSmartCollectionRequest'];
|
||||
export type UpdateSmartCollectionRequest = components['schemas']['UpdateSmartCollectionRequest'];
|
||||
|
||||
/* ---------- manual collections ---------- */
|
||||
|
||||
export function getCollections(): Promise<MediaCollection[]> {
|
||||
return request<MediaCollection[]>('/api/collections');
|
||||
}
|
||||
|
||||
export function getCollection(id: number): Promise<MediaCollection> {
|
||||
return request<MediaCollection>(`/api/collections/${id}`);
|
||||
}
|
||||
|
||||
export function createCollection(body: CreateCollectionRequest): Promise<MediaCollection> {
|
||||
return request<MediaCollection>('/api/collections', { body, method: 'POST' });
|
||||
}
|
||||
|
||||
export function updateCollection(id: number, body: UpdateCollectionRequest): Promise<MediaCollection> {
|
||||
return request<MediaCollection>(`/api/collections/${id}`, { body, method: 'PUT' });
|
||||
}
|
||||
|
||||
export function deleteCollection(id: number): Promise<void> {
|
||||
return request<void>(`/api/collections/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export function addItemsToCollection(id: number, body: AddItemsToCollectionRequest): Promise<void> {
|
||||
return request<void>(`/api/collections/${id}/items`, { body, method: 'POST' });
|
||||
}
|
||||
|
||||
export function removeItemFromCollection(id: number, mediaItemId: number): Promise<void> {
|
||||
return request<void>(`/api/collections/${id}/items/${mediaItemId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
/* ---------- smart collections ---------- */
|
||||
|
||||
export function getSmartCollections(): Promise<SmartCollection[]> {
|
||||
return request<SmartCollection[]>('/api/smart-collections');
|
||||
}
|
||||
|
||||
export function getSmartCollection(id: number): Promise<SmartCollection> {
|
||||
return request<SmartCollection>(`/api/smart-collections/${id}`);
|
||||
}
|
||||
|
||||
export function createSmartCollection(body: CreateSmartCollectionRequest): Promise<SmartCollection> {
|
||||
return request<SmartCollection>('/api/smart-collections', { body, method: 'POST' });
|
||||
}
|
||||
|
||||
export function updateSmartCollection(id: number, body: UpdateSmartCollectionRequest): Promise<SmartCollection> {
|
||||
return request<SmartCollection>(`/api/smart-collections/${id}`, { body, method: 'PUT' });
|
||||
}
|
||||
|
||||
export function deleteSmartCollection(id: number): Promise<void> {
|
||||
return request<void>(`/api/smart-collections/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
/* ---------- add-items bucket mapping ---------- */
|
||||
|
||||
// The add-items request buckets media-item ids by kind. The library-browse search only
|
||||
// surfaces four kinds (Movie / TelevisionShow / TelevisionSeason / Artist), so those are
|
||||
// the only buckets reachable from the picker. The remaining buckets (episodes, music
|
||||
// videos, songs, images, other videos, remote streams) can't be produced by browse and
|
||||
// are left empty here. See CollectionsScreen for the honest note about this limit.
|
||||
export function emptyAddItemsRequest(): AddItemsToCollectionRequest {
|
||||
return {
|
||||
artistIds: [],
|
||||
episodeIds: [],
|
||||
imageIds: [],
|
||||
movieIds: [],
|
||||
musicVideoIds: [],
|
||||
otherVideoIds: [],
|
||||
remoteStreamIds: [],
|
||||
seasonIds: [],
|
||||
showIds: [],
|
||||
songIds: []
|
||||
};
|
||||
}
|
||||
|
||||
// Buckets a set of browse results into an AddItemsToCollectionRequest. Movie / Show /
|
||||
// Season / Artist are the only kinds library-browse can return as concrete media; each
|
||||
// carries its media-item id in `id` (which equals `mediaItemId` for these types, since
|
||||
// they are all MediaItem subclasses). Ids must go into their type-specific bucket, since
|
||||
// the server validates each bucket against that entity type (a Show id in movieIds fails
|
||||
// validation). Any other kind (collections, smart/multi/rerun collections, playlists) is
|
||||
// not an addable media item and is skipped.
|
||||
export function toAddItemsRequest(items: LibraryBrowseItem[]): AddItemsToCollectionRequest {
|
||||
const requestBody = emptyAddItemsRequest();
|
||||
|
||||
for (const item of items) {
|
||||
switch (item.mediaType) {
|
||||
case 'Movie':
|
||||
requestBody.movieIds?.push(item.id);
|
||||
break;
|
||||
case 'TelevisionShow':
|
||||
requestBody.showIds?.push(item.id);
|
||||
break;
|
||||
case 'TelevisionSeason':
|
||||
requestBody.seasonIds?.push(item.id);
|
||||
break;
|
||||
case 'Artist':
|
||||
requestBody.artistIds?.push(item.id);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return requestBody;
|
||||
}
|
||||
|
||||
// Best-effort partial listing of a manual collection's members. No API returns a manual
|
||||
// collection's items by id; the only path is the Lucene `collection:"name"` search field
|
||||
// via library-browse, which covers Movie / Show / Season / Artist only. Callers must treat
|
||||
// this as an incomplete preview, never as the authoritative contents.
|
||||
export async function getCollectionItemsPreview(name: string): Promise<LibraryBrowseItem[]> {
|
||||
const trimmed = name.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const escaped = trimmed.replace(/"/g, '\\"');
|
||||
const result = await getLibraryBrowseItems({ pageSize: 100, query: `collection:"${escaped}"` });
|
||||
return result.page ?? [];
|
||||
}
|
||||
|
||||
export function messageFromCollectionError(error: unknown, fallback = 'Unable to load collections'): string {
|
||||
if (error instanceof ApiError) {
|
||||
return error.detail ?? error.message;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ export * from './auth';
|
||||
export * from './channels';
|
||||
export * from './channelTemplates';
|
||||
export * from './client';
|
||||
export * from './collections';
|
||||
export * from './dashboard';
|
||||
export * from './guide';
|
||||
export * from './libraries';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ApiError, request } from './client';
|
||||
import type { components } from './generated/v1';
|
||||
import { getCollections, getSmartCollections, type MediaCollection, type SmartCollection } from './collections';
|
||||
import { getFillerPresets, getWatermarks } from './pickers';
|
||||
import type { FillerPreset, Watermark } from './pickers';
|
||||
|
||||
@@ -21,8 +22,6 @@ export type ProgramScheduleItem = components['schemas']['ProgramScheduleItemView
|
||||
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, Watermark };
|
||||
|
||||
export interface SchedulePickerData {
|
||||
@@ -66,14 +65,6 @@ export function getScheduleItems(scheduleId: number): Promise<ProgramScheduleIte
|
||||
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 addScheduleItem(scheduleId: number, body: ScheduleItemRequest): Promise<ProgramScheduleItem> {
|
||||
return request<ProgramScheduleItem>(`/api/schedules/${scheduleId}/items`, {
|
||||
body,
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { CollectionsScreen } from './CollectionsScreen';
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status
|
||||
});
|
||||
}
|
||||
|
||||
const manualCollections = [
|
||||
{ collectionType: 'Collection', id: 1, name: 'Favorites', state: 'Normal', useCustomPlaybackOrder: false },
|
||||
{ collectionType: 'Collection', id: 2, name: 'Kids', state: 'Normal', useCustomPlaybackOrder: true }
|
||||
];
|
||||
|
||||
const smartCollections = [{ id: 10, name: 'Action', query: 'genre:action' }];
|
||||
|
||||
interface MockOptions {
|
||||
manual?: unknown[];
|
||||
smart?: unknown[];
|
||||
onRequest?: (url: string, method: string, body: unknown) => Response | null;
|
||||
}
|
||||
|
||||
function mockApi(options: MockOptions = {}) {
|
||||
const manual = options.manual ?? manualCollections;
|
||||
const smart = options.smart ?? smartCollections;
|
||||
|
||||
return vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = input.toString();
|
||||
const method = (init?.method ?? 'GET').toUpperCase();
|
||||
const body = init?.body ? JSON.parse(String(init.body)) : undefined;
|
||||
|
||||
if (options.onRequest) {
|
||||
const override = options.onRequest(url, method, body);
|
||||
|
||||
if (override) {
|
||||
return Promise.resolve(override);
|
||||
}
|
||||
}
|
||||
|
||||
if (url === '/api/collections' && method === 'GET') {
|
||||
return Promise.resolve(jsonResponse(manual));
|
||||
}
|
||||
|
||||
if (url === '/api/smart-collections' && method === 'GET') {
|
||||
return Promise.resolve(jsonResponse(smart));
|
||||
}
|
||||
|
||||
if (url.startsWith('/api/library/browse')) {
|
||||
return Promise.resolve(jsonResponse({ page: [], totalCount: 0 }));
|
||||
}
|
||||
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
});
|
||||
}
|
||||
|
||||
describe('CollectionsScreen', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it('renders both manual and smart collections', async () => {
|
||||
mockApi();
|
||||
render(<CollectionsScreen />);
|
||||
|
||||
expect(await screen.findByText('Favorites')).toBeInTheDocument();
|
||||
expect(screen.getByText('Kids')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Smart/ }));
|
||||
|
||||
expect(await screen.findByText('Action')).toBeInTheDocument();
|
||||
expect(screen.getByText('genre:action')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an error state when loading fails', async () => {
|
||||
mockApi({
|
||||
onRequest: (url) => (url === '/api/collections' ? jsonResponse({ status: 500, title: 'Boom' }, 500) : null)
|
||||
});
|
||||
|
||||
render(<CollectionsScreen />);
|
||||
|
||||
expect(await screen.findByText('Boom')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('creates a manual collection via POST', async () => {
|
||||
const fetchMock = mockApi({
|
||||
onRequest: (url, method) =>
|
||||
url === '/api/collections' && method === 'POST'
|
||||
? jsonResponse({ collectionType: 'Collection', id: 3, name: 'New One', state: 'Normal', useCustomPlaybackOrder: false }, 201)
|
||||
: null
|
||||
});
|
||||
|
||||
render(<CollectionsScreen />);
|
||||
await screen.findByText('Favorites');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New collection' }));
|
||||
|
||||
const dialog = screen.getByRole('dialog');
|
||||
fireEvent.change(within(dialog).getByPlaceholderText('Collection name'), { target: { value: 'New One' } });
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Create' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
fetchMock.mock.calls.some(
|
||||
([u, init]) => u === '/api/collections' && (init?.method ?? '').toUpperCase() === 'POST'
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
const postCall = fetchMock.mock.calls.find(
|
||||
([u, init]) => u === '/api/collections' && (init?.method ?? '').toUpperCase() === 'POST'
|
||||
);
|
||||
expect(JSON.parse(String(postCall?.[1]?.body))).toEqual({ name: 'New One' });
|
||||
});
|
||||
|
||||
it('renames a manual collection via PUT', async () => {
|
||||
const fetchMock = mockApi({
|
||||
onRequest: (url, method) =>
|
||||
url === '/api/collections/1' && method === 'PUT'
|
||||
? jsonResponse({ collectionType: 'Collection', id: 1, name: 'Renamed', state: 'Normal', useCustomPlaybackOrder: false })
|
||||
: null
|
||||
});
|
||||
|
||||
render(<CollectionsScreen />);
|
||||
await screen.findByText('Favorites');
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Rename' })[0]);
|
||||
|
||||
const dialog = screen.getByRole('dialog');
|
||||
const input = within(dialog).getByPlaceholderText('Collection name') as HTMLInputElement;
|
||||
expect(input.value).toBe('Favorites');
|
||||
fireEvent.change(input, { target: { value: 'Renamed' } });
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => {
|
||||
const putCall = fetchMock.mock.calls.find(
|
||||
([u, init]) => u === '/api/collections/1' && (init?.method ?? '').toUpperCase() === 'PUT'
|
||||
);
|
||||
expect(putCall).toBeDefined();
|
||||
expect(JSON.parse(String(putCall?.[1]?.body))).toMatchObject({ name: 'Renamed' });
|
||||
});
|
||||
});
|
||||
|
||||
it('toggles custom playback order via PUT', async () => {
|
||||
const fetchMock = mockApi({
|
||||
onRequest: (url, method) =>
|
||||
url === '/api/collections/1' && method === 'PUT'
|
||||
? jsonResponse({ collectionType: 'Collection', id: 1, name: 'Favorites', state: 'Normal', useCustomPlaybackOrder: true })
|
||||
: null
|
||||
});
|
||||
|
||||
render(<CollectionsScreen />);
|
||||
await screen.findByText('Favorites');
|
||||
|
||||
// First switch in the list belongs to "Favorites".
|
||||
const switches = screen.getAllByRole('switch');
|
||||
fireEvent.click(switches[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
const putCall = fetchMock.mock.calls.find(
|
||||
([u, init]) => u === '/api/collections/1' && (init?.method ?? '').toUpperCase() === 'PUT'
|
||||
);
|
||||
expect(putCall).toBeDefined();
|
||||
expect(JSON.parse(String(putCall?.[1]?.body))).toMatchObject({ useCustomPlaybackOrder: true });
|
||||
});
|
||||
});
|
||||
|
||||
it('confirms and DELETEs a manual collection', async () => {
|
||||
const fetchMock = mockApi();
|
||||
|
||||
render(<CollectionsScreen />);
|
||||
await screen.findByText('Favorites');
|
||||
|
||||
const rows = screen.getAllByRole('button', { name: 'Delete' });
|
||||
fireEvent.click(rows[0]);
|
||||
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
expect(within(dialog).getByText(/Delete "Favorites"/)).toBeInTheDocument();
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Delete' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
fetchMock.mock.calls.some(
|
||||
([u, init]) => u === '/api/collections/1' && (init?.method ?? '').toUpperCase() === 'DELETE'
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a smart collection and previews the query against browse', async () => {
|
||||
const fetchMock = mockApi({
|
||||
onRequest: (url, method) => {
|
||||
if (url.startsWith('/api/library/browse')) {
|
||||
return jsonResponse({ page: [{ id: 1, mediaType: 'Movie', title: 'The Matrix' }], totalCount: 1 });
|
||||
}
|
||||
|
||||
if (url === '/api/smart-collections' && method === 'POST') {
|
||||
return jsonResponse({ id: 11, name: 'Sci-Fi', query: 'genre:scifi' }, 201);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
render(<CollectionsScreen />);
|
||||
await screen.findByText('Favorites');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Smart/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New smart collection' }));
|
||||
|
||||
const dialog = screen.getByRole('dialog');
|
||||
fireEvent.change(within(dialog).getByPlaceholderText('Smart collection name'), { target: { value: 'Sci-Fi' } });
|
||||
fireEvent.change(within(dialog).getByPlaceholderText(/genre/), { target: { value: 'genre:scifi' } });
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Preview results' }));
|
||||
|
||||
expect(await within(dialog).findByText('The Matrix')).toBeInTheDocument();
|
||||
expect(within(dialog).getByText('1 matches')).toBeInTheDocument();
|
||||
|
||||
const browseCall = fetchMock.mock.calls.find(([u]) => u.toString().startsWith('/api/library/browse'));
|
||||
expect(new URL(String(browseCall?.[0]), 'http://localhost').searchParams.get('query')).toBe('genre:scifi');
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Create' }));
|
||||
|
||||
await waitFor(() => {
|
||||
const postCall = fetchMock.mock.calls.find(
|
||||
([u, init]) => u === '/api/smart-collections' && (init?.method ?? '').toUpperCase() === 'POST'
|
||||
);
|
||||
expect(postCall).toBeDefined();
|
||||
expect(JSON.parse(String(postCall?.[1]?.body))).toEqual({ name: 'Sci-Fi', query: 'genre:scifi' });
|
||||
});
|
||||
});
|
||||
|
||||
it('opens a manual collection and previews its items via a collection: query', async () => {
|
||||
const fetchMock = mockApi({
|
||||
onRequest: (url) => {
|
||||
if (url.startsWith('/api/library/browse')) {
|
||||
const q = new URL(url, 'http://localhost').searchParams.get('query') ?? '';
|
||||
|
||||
if (q.startsWith('collection:')) {
|
||||
return jsonResponse({ page: [{ id: 5, mediaItemId: 5, mediaType: 'Movie', title: 'Inception' }], totalCount: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
render(<CollectionsScreen />);
|
||||
await screen.findByText('Favorites');
|
||||
|
||||
fireEvent.click(screen.getByText('Favorites'));
|
||||
|
||||
expect(await screen.findByText('Inception')).toBeInTheDocument();
|
||||
expect(screen.getByText(/best-effort search preview/)).toBeInTheDocument();
|
||||
|
||||
const browseCall = fetchMock.mock.calls.find(([u]) =>
|
||||
u.toString().includes('collection%3A')
|
||||
);
|
||||
expect(new URL(String(browseCall?.[0]), 'http://localhost').searchParams.get('query')).toBe('collection:"Favorites"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,926 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Check,
|
||||
FolderTree,
|
||||
Info,
|
||||
ListVideo,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
TriangleAlert
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
ConfirmDialog,
|
||||
Dialog,
|
||||
IconButton,
|
||||
Input,
|
||||
Spinner,
|
||||
Switch
|
||||
} from '../components';
|
||||
import {
|
||||
addItemsToCollection,
|
||||
createCollection,
|
||||
createSmartCollection,
|
||||
deleteCollection,
|
||||
deleteSmartCollection,
|
||||
getCollectionItemsPreview,
|
||||
getCollections,
|
||||
getLibraryBrowseItems,
|
||||
getSmartCollections,
|
||||
messageFromCollectionError,
|
||||
removeItemFromCollection,
|
||||
toAddItemsRequest,
|
||||
updateCollection,
|
||||
updateSmartCollection,
|
||||
type LibraryBrowseItem,
|
||||
type MediaCollection,
|
||||
type SmartCollection
|
||||
} from '../api';
|
||||
|
||||
type Tab = 'manual' | 'smart';
|
||||
|
||||
const ADDABLE_TYPES = new Set<LibraryBrowseItem['mediaType']>([
|
||||
'Movie',
|
||||
'TelevisionShow',
|
||||
'TelevisionSeason',
|
||||
'Artist'
|
||||
]);
|
||||
|
||||
const TYPE_LABEL: Record<LibraryBrowseItem['mediaType'], string> = {
|
||||
Artist: 'Artist',
|
||||
Collection: 'Collection',
|
||||
Movie: 'Movie',
|
||||
MultiCollection: 'Multi',
|
||||
Playlist: 'Playlist',
|
||||
RerunCollection: 'Rerun',
|
||||
SmartCollection: 'Smart',
|
||||
TelevisionSeason: 'Season',
|
||||
TelevisionShow: 'Show'
|
||||
};
|
||||
|
||||
function sortByName<T extends { name?: null | string }>(items: T[]): T[] {
|
||||
return [...items].sort((left, right) => (left.name ?? '').localeCompare(right.name ?? ''));
|
||||
}
|
||||
|
||||
/* ---------- data hook ---------- */
|
||||
|
||||
interface CollectionsData {
|
||||
manual: MediaCollection[];
|
||||
smart: SmartCollection[];
|
||||
}
|
||||
|
||||
type CollectionsState =
|
||||
| { data: CollectionsData; error: null; status: 'success' }
|
||||
| { data: null; error: string; status: 'error' }
|
||||
| { data: null; error: null; status: 'loading' };
|
||||
|
||||
function useCollectionsData() {
|
||||
const [state, setState] = useState<CollectionsState>({ data: null, error: null, status: 'loading' });
|
||||
const activeRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current = true;
|
||||
return () => {
|
||||
activeRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Fetch only; state updates happen in the async callbacks (never synchronously in the
|
||||
// effect body) so the initial 'loading' default stands until data resolves.
|
||||
const load = useCallback(() => {
|
||||
Promise.all([getCollections(), getSmartCollections()])
|
||||
.then(([manual, smart]) => {
|
||||
if (activeRef.current) {
|
||||
setState({ data: { manual, smart }, error: null, status: 'success' });
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (activeRef.current) {
|
||||
setState({ data: null, error: messageFromCollectionError(error), status: 'error' });
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const refresh = useCallback(
|
||||
(quiet = false) => {
|
||||
if (!quiet) {
|
||||
setState({ data: null, error: null, status: 'loading' });
|
||||
}
|
||||
|
||||
load();
|
||||
},
|
||||
[load]
|
||||
);
|
||||
|
||||
return { refresh, state };
|
||||
}
|
||||
|
||||
/* ---------- create / rename dialogs ---------- */
|
||||
|
||||
function NameDialog({
|
||||
busy,
|
||||
confirmLabel,
|
||||
error,
|
||||
initialName = '',
|
||||
onCancel,
|
||||
onSubmit,
|
||||
open,
|
||||
title
|
||||
}: {
|
||||
busy: boolean;
|
||||
confirmLabel: string;
|
||||
error: string | null;
|
||||
initialName?: string;
|
||||
onCancel: () => void;
|
||||
onSubmit: (name: string) => void;
|
||||
open: boolean;
|
||||
title: string;
|
||||
}) {
|
||||
// Parent remounts this via `key` on each open, so `initialName` seeds fresh state
|
||||
// without a reset effect.
|
||||
const [name, setName] = useState(initialName);
|
||||
const trimmed = name.trim();
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
footer={
|
||||
<>
|
||||
<Button disabled={busy} onClick={onCancel} variant="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={busy || trimmed.length === 0} loading={busy} onClick={() => onSubmit(trimmed)} variant="primary">
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
onClose={onCancel}
|
||||
open={open}
|
||||
title={title}
|
||||
width={440}
|
||||
>
|
||||
<Input label="Name" onChange={(event) => setName(event.target.value)} placeholder="Collection name" value={name} />
|
||||
{error && (
|
||||
<span className="ctv-field-error" role="alert">
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function SmartDialog({
|
||||
busy,
|
||||
error,
|
||||
initial,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
open
|
||||
}: {
|
||||
busy: boolean;
|
||||
error: string | null;
|
||||
initial: { name: string; query: string } | null;
|
||||
onCancel: () => void;
|
||||
onSubmit: (values: { name: string; query: string }) => void;
|
||||
open: boolean;
|
||||
}) {
|
||||
// Parent remounts this via `key` on each open, so `initial` seeds fresh state.
|
||||
const [name, setName] = useState(initial?.name ?? '');
|
||||
const [query, setQuery] = useState(initial?.query ?? '');
|
||||
const [preview, setPreview] = useState<{ count: number; sample: LibraryBrowseItem[] } | null>(null);
|
||||
const [previewing, setPreviewing] = useState(false);
|
||||
const [previewError, setPreviewError] = useState<string | null>(null);
|
||||
|
||||
const runPreview = async () => {
|
||||
const trimmed = query.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPreviewing(true);
|
||||
setPreviewError(null);
|
||||
|
||||
try {
|
||||
const result = await getLibraryBrowseItems({ pageSize: 24, query: trimmed });
|
||||
setPreview({ count: result.totalCount ?? result.page?.length ?? 0, sample: result.page ?? [] });
|
||||
} catch (error) {
|
||||
setPreviewError(messageFromCollectionError(error, 'Unable to preview query'));
|
||||
} finally {
|
||||
setPreviewing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const trimmedName = name.trim();
|
||||
const trimmedQuery = query.trim();
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
footer={
|
||||
<>
|
||||
<Button disabled={busy} onClick={onCancel} variant="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={busy || trimmedName.length === 0 || trimmedQuery.length === 0}
|
||||
loading={busy}
|
||||
onClick={() => onSubmit({ name: trimmedName, query: trimmedQuery })}
|
||||
variant="primary"
|
||||
>
|
||||
{initial ? 'Save' : 'Create'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
onClose={onCancel}
|
||||
open={open}
|
||||
title={initial ? 'Edit smart collection' : 'New smart collection'}
|
||||
width={560}
|
||||
>
|
||||
<Input label="Name" onChange={(event) => setName(event.target.value)} placeholder="Smart collection name" value={name} />
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Input
|
||||
label="Search query"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder='e.g. genre:"action" AND released:2000-2010'
|
||||
style={{ fontFamily: 'var(--font-mono)' }}
|
||||
value={query}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
||||
<Button
|
||||
disabled={previewing || trimmedQuery.length === 0}
|
||||
loading={previewing}
|
||||
onClick={() => void runPreview()}
|
||||
size="sm"
|
||||
startIcon={<Search aria-hidden="true" size={14} />}
|
||||
variant="secondary"
|
||||
>
|
||||
Preview results
|
||||
</Button>
|
||||
</div>
|
||||
{previewError && (
|
||||
<span className="ctv-field-error" role="alert">
|
||||
{previewError}
|
||||
</span>
|
||||
)}
|
||||
{preview && (
|
||||
<div className="ctv-collections-preview">
|
||||
<div className="ctv-collections-preview-head">
|
||||
<Badge tone="accent">{preview.count} matches</Badge>
|
||||
</div>
|
||||
<ul className="ctv-collections-preview-list">
|
||||
{preview.sample.slice(0, 12).map((item) => (
|
||||
<li key={`${item.mediaType}:${item.id}`}>
|
||||
<span className="ctv-collections-preview-title">{item.title}</span>
|
||||
<span className="ctv-collections-preview-type">{TYPE_LABEL[item.mediaType]}</span>
|
||||
</li>
|
||||
))}
|
||||
{preview.sample.length === 0 && <li className="ctv-collections-preview-empty">No matches</li>}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<span className="ctv-field-error" role="alert">
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- add-items picker ---------- */
|
||||
|
||||
function AddItemsDialog({
|
||||
collection,
|
||||
onAdded,
|
||||
onClose,
|
||||
open
|
||||
}: {
|
||||
collection: MediaCollection;
|
||||
onAdded: () => void;
|
||||
onClose: () => void;
|
||||
open: boolean;
|
||||
}) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<LibraryBrowseItem[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [selected, setSelected] = useState<Map<string, LibraryBrowseItem>>(() => new Map());
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
const runSearch = async () => {
|
||||
setSearching(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await getLibraryBrowseItems({ pageSize: 50, query: query.trim() });
|
||||
setResults((result.page ?? []).filter((item) => ADDABLE_TYPES.has(item.mediaType)));
|
||||
} catch (searchError) {
|
||||
setError(messageFromCollectionError(searchError, 'Unable to search library'));
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggle = (item: LibraryBrowseItem) => {
|
||||
const key = `${item.mediaType}:${item.id}`;
|
||||
setSelected((current) => {
|
||||
const next = new Map(current);
|
||||
|
||||
if (next.has(key)) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.set(key, item);
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (selected.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAdding(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await addItemsToCollection(collection.id, toAddItemsRequest([...selected.values()]));
|
||||
onAdded();
|
||||
onClose();
|
||||
} catch (addError) {
|
||||
setError(messageFromCollectionError(addError, 'Unable to add items'));
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
footer={
|
||||
<>
|
||||
<Button disabled={adding} onClick={onClose} variant="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={adding || selected.size === 0}
|
||||
loading={adding}
|
||||
onClick={() => void submit()}
|
||||
startIcon={<Plus aria-hidden="true" size={14} />}
|
||||
variant="primary"
|
||||
>
|
||||
Add {selected.size > 0 ? selected.size : ''} item{selected.size === 1 ? '' : 's'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
onClose={onClose}
|
||||
open={open}
|
||||
title={`Add items to ${collection.name ?? 'collection'}`}
|
||||
width={620}
|
||||
>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void runSearch();
|
||||
}}
|
||||
style={{ display: 'flex', gap: 8 }}
|
||||
>
|
||||
<Input
|
||||
leadingIcon={<Search aria-hidden="true" size={14} />}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search movies, shows, seasons, artists…"
|
||||
value={query}
|
||||
/>
|
||||
<Button loading={searching} size="sm" type="submit" variant="secondary">
|
||||
Search
|
||||
</Button>
|
||||
</form>
|
||||
<p className="ctv-collections-picker-note">
|
||||
The library search returns movies, shows, seasons and artists. Episodes, music, images and other item kinds
|
||||
can't be added from here yet.
|
||||
</p>
|
||||
{error && (
|
||||
<span className="ctv-field-error" role="alert">
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
<div className="ctv-collections-picker-results">
|
||||
{results.length === 0 && !searching ? (
|
||||
<div className="ctv-collections-picker-empty">No results — try a search above.</div>
|
||||
) : (
|
||||
results.map((item) => {
|
||||
const key = `${item.mediaType}:${item.id}`;
|
||||
const isSelected = selected.has(key);
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-pressed={isSelected}
|
||||
className={`ctv-collections-picker-row ctv-press${isSelected ? ' ctv-collections-picker-row-active' : ''}`}
|
||||
key={key}
|
||||
onClick={() => toggle(item)}
|
||||
type="button"
|
||||
>
|
||||
<span className="ctv-collections-picker-check">
|
||||
{isSelected ? <Check aria-hidden="true" size={14} /> : null}
|
||||
</span>
|
||||
<span className="ctv-collections-picker-row-title">{item.title}</span>
|
||||
<Badge tone="neutral">{TYPE_LABEL[item.mediaType]}</Badge>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- manual collection items view ---------- */
|
||||
|
||||
function ManualItemsView({
|
||||
collection,
|
||||
onBack
|
||||
}: {
|
||||
collection: MediaCollection;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const [items, setItems] = useState<LibraryBrowseItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [removing, setRemoving] = useState<number | null>(null);
|
||||
const activeRef = useRef(true);
|
||||
|
||||
// No synchronous setState here: `loading` starts true and flips false in `finally`, so
|
||||
// this is safe to call from an effect. Reloads (after add/remove) keep the list visible.
|
||||
const load = useCallback(() => {
|
||||
getCollectionItemsPreview(collection.name ?? '')
|
||||
.then((preview) => {
|
||||
if (activeRef.current) {
|
||||
setItems(preview);
|
||||
setError(null);
|
||||
}
|
||||
})
|
||||
.catch((loadError: unknown) => {
|
||||
if (activeRef.current) {
|
||||
setError(messageFromCollectionError(loadError, 'Unable to load collection items'));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (activeRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
}, [collection.name]);
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current = true;
|
||||
load();
|
||||
|
||||
return () => {
|
||||
activeRef.current = false;
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
const remove = async (item: LibraryBrowseItem) => {
|
||||
const mediaItemId = item.mediaItemId ?? item.id;
|
||||
setRemoving(mediaItemId);
|
||||
|
||||
try {
|
||||
await removeItemFromCollection(collection.id, mediaItemId);
|
||||
load();
|
||||
} catch (removeError) {
|
||||
setError(messageFromCollectionError(removeError, 'Unable to remove item'));
|
||||
} finally {
|
||||
setRemoving(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ctv-collections">
|
||||
<div className="ctv-channels-actionbar">
|
||||
<Button onClick={onBack} size="sm" startIcon={<ArrowLeft aria-hidden="true" size={14} />} variant="ghost">
|
||||
All collections
|
||||
</Button>
|
||||
<span className="ctv-collections-detail-title">{collection.name}</span>
|
||||
<span className="ctv-channels-spacer" />
|
||||
<Button onClick={() => setPickerOpen(true)} size="sm" startIcon={<Plus aria-hidden="true" size={14} />}>
|
||||
Add items
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="ctv-settings-warn-callout" role="note">
|
||||
<Info aria-hidden="true" color="var(--status-warn)" size={14} />
|
||||
<span>
|
||||
The API has no endpoint to list a manual collection's items. This is a best-effort search preview covering
|
||||
movies, shows, seasons and artists only — other kinds in this collection won't appear. Adding items works for
|
||||
all shown kinds.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="ctv-channels-error" role="alert">
|
||||
<TriangleAlert aria-hidden="true" size={15} />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card padded={false}>
|
||||
{loading ? (
|
||||
<div className="ctv-collections-loading">
|
||||
<Spinner size={18} />
|
||||
<span>Loading items…</span>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="ctv-collections-empty">No previewable items in this collection.</div>
|
||||
) : (
|
||||
items.map((item, index) => (
|
||||
<div
|
||||
className="ctv-settings-flush-row"
|
||||
key={`${item.mediaType}:${item.id}`}
|
||||
style={index === 0 ? { borderTop: 'none' } : undefined}
|
||||
>
|
||||
<ListVideo aria-hidden="true" color="var(--text-disabled)" size={15} />
|
||||
<span className="ctv-settings-flush-row-main ctv-settings-flush-row-title">{item.title}</span>
|
||||
<Badge tone="neutral">{TYPE_LABEL[item.mediaType]}</Badge>
|
||||
<IconButton
|
||||
onClick={() => void remove(item)}
|
||||
size="sm"
|
||||
title={`Remove ${item.title}`}
|
||||
variant="ghost"
|
||||
>
|
||||
{removing === (item.mediaItemId ?? item.id) ? (
|
||||
<Spinner size={14} />
|
||||
) : (
|
||||
<Trash2 aria-hidden="true" size={14} />
|
||||
)}
|
||||
</IconButton>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<AddItemsDialog
|
||||
collection={collection}
|
||||
key={`add-${pickerOpen}`}
|
||||
onAdded={load}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
open={pickerOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- screen ---------- */
|
||||
|
||||
export function CollectionsScreen() {
|
||||
const { refresh, state } = useCollectionsData();
|
||||
const [tab, setTab] = useState<Tab>('manual');
|
||||
const [selected, setSelected] = useState<MediaCollection | null>(null);
|
||||
|
||||
// dialogs
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [renameTarget, setRenameTarget] = useState<MediaCollection | null>(null);
|
||||
const [smartTarget, setSmartTarget] = useState<{ collection: SmartCollection | null; open: boolean }>({
|
||||
collection: null,
|
||||
open: false
|
||||
});
|
||||
const [deleteTarget, setDeleteTarget] = useState<
|
||||
{ kind: 'manual'; item: MediaCollection } | { kind: 'smart'; item: SmartCollection } | null
|
||||
>(null);
|
||||
|
||||
const [dialogBusy, setDialogBusy] = useState(false);
|
||||
const [dialogError, setDialogError] = useState<string | null>(null);
|
||||
const [rowError, setRowError] = useState<string | null>(null);
|
||||
const [togglingId, setTogglingId] = useState<number | null>(null);
|
||||
|
||||
if (state.status === 'loading') {
|
||||
return (
|
||||
<div className="ctv-collections-loading" role="status">
|
||||
<Spinner size={18} />
|
||||
<span>Loading collections…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.status === 'error') {
|
||||
return (
|
||||
<div className="ctv-collections">
|
||||
<div className="ctv-channels-error" role="alert">
|
||||
<TriangleAlert aria-hidden="true" size={15} />
|
||||
<span>{state.error}</span>
|
||||
<span className="ctv-channels-spacer" />
|
||||
<Button onClick={() => refresh()} size="sm" variant="secondary">
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (selected) {
|
||||
// Keep the selected reference fresh across refreshes (e.g. after a rename elsewhere).
|
||||
const current = state.data.manual.find((collection) => collection.id === selected.id) ?? selected;
|
||||
return <ManualItemsView collection={current} onBack={() => setSelected(null)} />;
|
||||
}
|
||||
|
||||
const manual = sortByName(state.data.manual);
|
||||
const smart = sortByName(state.data.smart);
|
||||
|
||||
const runDialog = async (operation: () => Promise<void>, onSuccess: () => void) => {
|
||||
setDialogBusy(true);
|
||||
setDialogError(null);
|
||||
|
||||
try {
|
||||
await operation();
|
||||
onSuccess();
|
||||
refresh(true);
|
||||
} catch (error) {
|
||||
setDialogError(messageFromCollectionError(error, 'Operation failed'));
|
||||
} finally {
|
||||
setDialogBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleCustomOrder = async (collection: MediaCollection, next: boolean) => {
|
||||
setRowError(null);
|
||||
setTogglingId(collection.id);
|
||||
|
||||
try {
|
||||
await updateCollection(collection.id, { name: collection.name, useCustomPlaybackOrder: next });
|
||||
refresh(true);
|
||||
} catch (error) {
|
||||
setRowError(messageFromCollectionError(error, 'Unable to update collection'));
|
||||
} finally {
|
||||
setTogglingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (!deleteTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = deleteTarget;
|
||||
void runDialog(
|
||||
() =>
|
||||
target.kind === 'manual'
|
||||
? deleteCollection(target.item.id)
|
||||
: deleteSmartCollection(target.item.id),
|
||||
() => setDeleteTarget(null)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ctv-collections">
|
||||
<div className="ctv-channels-actionbar">
|
||||
<div aria-label="Collection type" className="ctv-segmented" role="group">
|
||||
<button aria-pressed={tab === 'manual'} onClick={() => setTab('manual')} type="button">
|
||||
Manual <code>{manual.length}</code>
|
||||
</button>
|
||||
<button aria-pressed={tab === 'smart'} onClick={() => setTab('smart')} type="button">
|
||||
Smart <code>{smart.length}</code>
|
||||
</button>
|
||||
</div>
|
||||
<span className="ctv-channels-spacer" />
|
||||
{tab === 'manual' ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setDialogError(null);
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
size="sm"
|
||||
startIcon={<Plus aria-hidden="true" size={14} />}
|
||||
>
|
||||
New collection
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setDialogError(null);
|
||||
setSmartTarget({ collection: null, open: true });
|
||||
}}
|
||||
size="sm"
|
||||
startIcon={<Plus aria-hidden="true" size={14} />}
|
||||
>
|
||||
New smart collection
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{rowError && (
|
||||
<div className="ctv-channels-error" role="alert">
|
||||
<TriangleAlert aria-hidden="true" size={15} />
|
||||
<span>{rowError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'manual' && (
|
||||
<Card padded={false}>
|
||||
{manual.length === 0 ? (
|
||||
<div className="ctv-collections-empty">No manual collections yet.</div>
|
||||
) : (
|
||||
manual.map((collection, index) => (
|
||||
<div
|
||||
className="ctv-settings-flush-row"
|
||||
key={collection.id}
|
||||
style={index === 0 ? { borderTop: 'none' } : undefined}
|
||||
>
|
||||
<FolderTree aria-hidden="true" color="var(--ctv-accent)" size={15} />
|
||||
<button
|
||||
className="ctv-settings-flush-row-main ctv-settings-flush-row-title ctv-collections-linkbtn"
|
||||
onClick={() => setSelected(collection)}
|
||||
type="button"
|
||||
>
|
||||
{collection.name}
|
||||
</button>
|
||||
<label className="ctv-collections-order-toggle" title="Use custom playback order">
|
||||
<Switch
|
||||
checked={collection.useCustomPlaybackOrder}
|
||||
disabled={togglingId === collection.id}
|
||||
onChange={(next) => void toggleCustomOrder(collection, next)}
|
||||
size="sm"
|
||||
/>
|
||||
<span>Custom order</span>
|
||||
</label>
|
||||
<IconButton onClick={() => setSelected(collection)} size="sm" title="Manage items" variant="ghost">
|
||||
<ListVideo aria-hidden="true" size={14} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
setDialogError(null);
|
||||
setRenameTarget(collection);
|
||||
}}
|
||||
size="sm"
|
||||
title="Rename"
|
||||
variant="ghost"
|
||||
>
|
||||
<Pencil aria-hidden="true" size={14} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={() => setDeleteTarget({ item: collection, kind: 'manual' })}
|
||||
size="sm"
|
||||
title="Delete"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 aria-hidden="true" size={14} />
|
||||
</IconButton>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{tab === 'smart' && (
|
||||
<Card padded={false}>
|
||||
{smart.length === 0 ? (
|
||||
<div className="ctv-collections-empty">No smart collections yet.</div>
|
||||
) : (
|
||||
smart.map((collection, index) => (
|
||||
<div
|
||||
className="ctv-settings-flush-row"
|
||||
key={collection.id}
|
||||
style={index === 0 ? { borderTop: 'none' } : undefined}
|
||||
>
|
||||
<Sparkles aria-hidden="true" color="var(--ctv-accent)" size={15} />
|
||||
<div className="ctv-settings-flush-row-main">
|
||||
<div className="ctv-settings-flush-row-title">{collection.name}</div>
|
||||
<div className="ctv-collections-query">{collection.query}</div>
|
||||
</div>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
setDialogError(null);
|
||||
setSmartTarget({ collection, open: true });
|
||||
}}
|
||||
size="sm"
|
||||
title="Edit"
|
||||
variant="ghost"
|
||||
>
|
||||
<Pencil aria-hidden="true" size={14} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={() => setDeleteTarget({ item: collection, kind: 'smart' })}
|
||||
size="sm"
|
||||
title="Delete"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 aria-hidden="true" size={14} />
|
||||
</IconButton>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<ClassicNote />
|
||||
|
||||
{/* create manual */}
|
||||
<NameDialog
|
||||
busy={dialogBusy}
|
||||
confirmLabel="Create"
|
||||
error={createOpen ? dialogError : null}
|
||||
key={`create-${createOpen}`}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onSubmit={(name) => void runDialog(() => createCollection({ name }).then(() => undefined), () => setCreateOpen(false))}
|
||||
open={createOpen}
|
||||
title="New collection"
|
||||
/>
|
||||
|
||||
{/* rename manual */}
|
||||
<NameDialog
|
||||
busy={dialogBusy}
|
||||
confirmLabel="Save"
|
||||
error={renameTarget ? dialogError : null}
|
||||
initialName={renameTarget?.name ?? ''}
|
||||
key={`rename-${renameTarget?.id ?? 'none'}`}
|
||||
onCancel={() => setRenameTarget(null)}
|
||||
onSubmit={(name) =>
|
||||
renameTarget &&
|
||||
void runDialog(
|
||||
() =>
|
||||
updateCollection(renameTarget.id, {
|
||||
name,
|
||||
useCustomPlaybackOrder: renameTarget.useCustomPlaybackOrder
|
||||
}).then(() => undefined),
|
||||
() => setRenameTarget(null)
|
||||
)
|
||||
}
|
||||
open={renameTarget !== null}
|
||||
title="Rename collection"
|
||||
/>
|
||||
|
||||
{/* create / edit smart */}
|
||||
<SmartDialog
|
||||
busy={dialogBusy}
|
||||
error={dialogError}
|
||||
key={`smart-${smartTarget.open}-${smartTarget.collection?.id ?? 'new'}`}
|
||||
initial={
|
||||
smartTarget.collection
|
||||
? { name: smartTarget.collection.name ?? '', query: smartTarget.collection.query ?? '' }
|
||||
: null
|
||||
}
|
||||
onCancel={() => setSmartTarget({ collection: null, open: false })}
|
||||
onSubmit={(values) => {
|
||||
const editing = smartTarget.collection;
|
||||
void runDialog(
|
||||
() =>
|
||||
(editing
|
||||
? updateSmartCollection(editing.id, values)
|
||||
: createSmartCollection(values)
|
||||
).then(() => undefined),
|
||||
() => setSmartTarget({ collection: null, open: false })
|
||||
);
|
||||
}}
|
||||
open={smartTarget.open}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
busy={dialogBusy}
|
||||
confirmLabel="Delete"
|
||||
message={
|
||||
deleteTarget ? (
|
||||
<>
|
||||
<span>{`Delete "${deleteTarget.item.name ?? 'this collection'}"? This cannot be undone.`}</span>
|
||||
{dialogError && (
|
||||
<span className="ctv-field-error" role="alert">
|
||||
{dialogError}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
''
|
||||
)
|
||||
}
|
||||
onCancel={() => {
|
||||
setDeleteTarget(null);
|
||||
setDialogError(null);
|
||||
}}
|
||||
onConfirm={confirmDelete}
|
||||
open={deleteTarget !== null}
|
||||
title={deleteTarget?.kind === 'smart' ? 'Delete smart collection' : 'Delete collection'}
|
||||
tone="danger"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClassicNote() {
|
||||
return (
|
||||
<div className="ctv-settings-callout">
|
||||
<Info aria-hidden="true" size={14} />
|
||||
<span>
|
||||
Multi-collections, rerun collections and playlists aren't available here yet — manage them in the Classic UI
|
||||
for now.
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3428,3 +3428,168 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* ---------- collections screen (#140) ---------- */
|
||||
.ctv-collections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-5, 10px);
|
||||
}
|
||||
|
||||
.ctv-collections-loading,
|
||||
.ctv-collections-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-4, 8px);
|
||||
padding: var(--space-8, 20px);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm, 13px);
|
||||
}
|
||||
|
||||
.ctv-collections-detail-title {
|
||||
font-size: var(--text-sm, 13px);
|
||||
font-weight: var(--weight-medium, 500);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ctv-collections-linkbtn {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
font-weight: var(--weight-medium, 500);
|
||||
}
|
||||
|
||||
.ctv-collections-linkbtn:hover {
|
||||
color: var(--ctv-accent);
|
||||
}
|
||||
|
||||
.ctv-collections-order-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3, 6px);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs, 12px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ctv-collections-query {
|
||||
margin-top: 3px;
|
||||
font: var(--text-2xs) / 1.3 var(--font-mono);
|
||||
color: var(--text-disabled);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
/* smart-collection preview + picker */
|
||||
.ctv-collections-preview {
|
||||
margin-top: 12px;
|
||||
border: 1px solid var(--border-hairline);
|
||||
border-radius: var(--radius-sm, 5px);
|
||||
background: var(--ctv-bg-sunken);
|
||||
padding: var(--space-5, 10px);
|
||||
}
|
||||
|
||||
.ctv-collections-preview-head {
|
||||
margin-bottom: var(--space-4, 8px);
|
||||
}
|
||||
|
||||
.ctv-collections-preview-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ctv-collections-preview-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4, 8px);
|
||||
font-size: var(--text-xs, 12px);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.ctv-collections-preview-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ctv-collections-preview-type,
|
||||
.ctv-collections-preview-empty {
|
||||
font: var(--text-2xs) / 1 var(--font-mono);
|
||||
color: var(--text-disabled);
|
||||
}
|
||||
|
||||
.ctv-collections-picker-note {
|
||||
margin: 10px 0 0;
|
||||
font: var(--text-2xs) / 1.4 var(--font-sans);
|
||||
color: var(--text-disabled);
|
||||
}
|
||||
|
||||
.ctv-collections-picker-results {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ctv-collections-picker-empty {
|
||||
padding: var(--space-7, 16px);
|
||||
text-align: center;
|
||||
color: var(--text-disabled);
|
||||
font-size: var(--text-xs, 12px);
|
||||
}
|
||||
|
||||
.ctv-collections-picker-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4, 8px);
|
||||
width: 100%;
|
||||
border: 1px solid var(--border-hairline);
|
||||
border-radius: var(--radius-sm, 5px);
|
||||
background: var(--surface-card);
|
||||
padding: var(--space-4, 8px) var(--space-5, 10px);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: var(--text-primary);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.ctv-collections-picker-row-active {
|
||||
background: color-mix(in srgb, var(--action-primary) 12%, transparent);
|
||||
border-color: color-mix(in srgb, var(--action-primary) 35%, transparent);
|
||||
}
|
||||
|
||||
.ctv-collections-picker-row-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--text-sm, 13px);
|
||||
}
|
||||
|
||||
.ctv-collections-picker-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--action-primary);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user