Merge pull request 'feat(web): collections management screen — manual + smart collections (#140)' (#156) from feat/140-collections-screen into main
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled

This commit was merged in pull request #156.
This commit is contained in:
2026-07-07 10:54:19 +00:00
8 changed files with 1734 additions and 10 deletions
+5
View File
@@ -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} />;
}
+223
View File
@@ -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([]);
});
});
+145
View File
@@ -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;
}
+1
View File
@@ -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 -10
View File
@@ -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,
+268
View File
@@ -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"');
});
});
+926
View File
@@ -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>
);
}
+165
View File
@@ -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);
}