From 5995707c84654513f126dbeda69379f118ffb0f0 Mon Sep 17 00:00:00 2001 From: Timothy Date: Tue, 7 Jul 2026 12:43:03 +0200 Subject: [PATCH] feat(web): collections management screen for manual + smart collections (#140) Replaces the PlaceholderScreen at /app/collections with a real screen that covers manual and smart collections, matching the SPA's extracted-screen pattern (screens/CollectionsScreen.tsx; App.tsx gets only the route branch + import). Manual collections: list, create, rename, delete, and a per-row toggle for UseCustomPlaybackOrder (PUT). Item management: an add-items picker that searches the library (library/browse) and buckets results into the typed AddItemsToCollectionRequest, plus per-item remove. Smart collections: list, create, edit (name + query), delete, with a live result preview that runs the query through library/browse. API layer: new api/collections.ts owns the typed CRUD (getCollections/ getSmartCollections moved here from schedules.ts; schedules imports them). Unit tests cover the client, the add-items bucket mapping, and the screen. Honest gap: no API endpoint lists a manual collection's items by id. The items view uses a best-effort collection:"name" Lucene search (movies/shows/seasons/ artists only) behind a prominent note. Multi/rerun/playlist collections are out of scope (no API) and pointed to the Classic UI. Follow-ups #151/#152/#153. Co-Authored-By: Claude Fable 5 --- web/src/App.tsx | 5 + web/src/api/collections.test.ts | 223 +++++ web/src/api/collections.ts | 145 ++++ web/src/api/index.ts | 1 + web/src/api/schedules.ts | 11 +- web/src/screens/CollectionsScreen.test.tsx | 268 ++++++ web/src/screens/CollectionsScreen.tsx | 926 +++++++++++++++++++++ web/src/shell.css | 165 ++++ 8 files changed, 1734 insertions(+), 10 deletions(-) create mode 100644 web/src/api/collections.test.ts create mode 100644 web/src/api/collections.ts create mode 100644 web/src/screens/CollectionsScreen.test.tsx create mode 100644 web/src/screens/CollectionsScreen.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index 634f0d5da..29f9a517c 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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 ; } + if (route.id === 'collections') { + return ; + } + return ; } diff --git a/web/src/api/collections.test.ts b/web/src/api/collections.test.ts new file mode 100644 index 000000000..2fc4e7f78 --- /dev/null +++ b/web/src/api/collections.test.ts @@ -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([]); + }); +}); diff --git a/web/src/api/collections.ts b/web/src/api/collections.ts new file mode 100644 index 000000000..1db3815f6 --- /dev/null +++ b/web/src/api/collections.ts @@ -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 { + return request('/api/collections'); +} + +export function getCollection(id: number): Promise { + return request(`/api/collections/${id}`); +} + +export function createCollection(body: CreateCollectionRequest): Promise { + return request('/api/collections', { body, method: 'POST' }); +} + +export function updateCollection(id: number, body: UpdateCollectionRequest): Promise { + return request(`/api/collections/${id}`, { body, method: 'PUT' }); +} + +export function deleteCollection(id: number): Promise { + return request(`/api/collections/${id}`, { method: 'DELETE' }); +} + +export function addItemsToCollection(id: number, body: AddItemsToCollectionRequest): Promise { + return request(`/api/collections/${id}/items`, { body, method: 'POST' }); +} + +export function removeItemFromCollection(id: number, mediaItemId: number): Promise { + return request(`/api/collections/${id}/items/${mediaItemId}`, { method: 'DELETE' }); +} + +/* ---------- smart collections ---------- */ + +export function getSmartCollections(): Promise { + return request('/api/smart-collections'); +} + +export function getSmartCollection(id: number): Promise { + return request(`/api/smart-collections/${id}`); +} + +export function createSmartCollection(body: CreateSmartCollectionRequest): Promise { + return request('/api/smart-collections', { body, method: 'POST' }); +} + +export function updateSmartCollection(id: number, body: UpdateSmartCollectionRequest): Promise { + return request(`/api/smart-collections/${id}`, { body, method: 'PUT' }); +} + +export function deleteSmartCollection(id: number): Promise { + return request(`/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 { + 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; +} diff --git a/web/src/api/index.ts b/web/src/api/index.ts index ae1bb5eb1..48f9a1267 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -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'; diff --git a/web/src/api/schedules.ts b/web/src/api/schedules.ts index 37ec44770..8729585f1 100644 --- a/web/src/api/schedules.ts +++ b/web/src/api/schedules.ts @@ -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(`/api/schedules/${scheduleId}/items`); } -export function getCollections(): Promise { - return request('/api/collections'); -} - -export function getSmartCollections(): Promise { - return request('/api/smart-collections'); -} - export function addScheduleItem(scheduleId: number, body: ScheduleItemRequest): Promise { return request(`/api/schedules/${scheduleId}/items`, { body, diff --git a/web/src/screens/CollectionsScreen.test.tsx b/web/src/screens/CollectionsScreen.test.tsx new file mode 100644 index 000000000..cc966e299 --- /dev/null +++ b/web/src/screens/CollectionsScreen.test.tsx @@ -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(); + + 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(); + + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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"'); + }); +}); diff --git a/web/src/screens/CollectionsScreen.tsx b/web/src/screens/CollectionsScreen.tsx new file mode 100644 index 000000000..d0fc87b40 --- /dev/null +++ b/web/src/screens/CollectionsScreen.tsx @@ -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([ + 'Movie', + 'TelevisionShow', + 'TelevisionSeason', + 'Artist' +]); + +const TYPE_LABEL: Record = { + Artist: 'Artist', + Collection: 'Collection', + Movie: 'Movie', + MultiCollection: 'Multi', + Playlist: 'Playlist', + RerunCollection: 'Rerun', + SmartCollection: 'Smart', + TelevisionSeason: 'Season', + TelevisionShow: 'Show' +}; + +function sortByName(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({ 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 ( + + + + + } + onClose={onCancel} + open={open} + title={title} + width={440} + > + setName(event.target.value)} placeholder="Collection name" value={name} /> + {error && ( + + {error} + + )} + + ); +} + +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(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 ( + + + + + } + onClose={onCancel} + open={open} + title={initial ? 'Edit smart collection' : 'New smart collection'} + width={560} + > + setName(event.target.value)} placeholder="Smart collection name" value={name} /> +
+ setQuery(event.target.value)} + placeholder='e.g. genre:"action" AND released:2000-2010' + style={{ fontFamily: 'var(--font-mono)' }} + value={query} + /> +
+
+ +
+ {previewError && ( + + {previewError} + + )} + {preview && ( +
+
+ {preview.count} matches +
+
    + {preview.sample.slice(0, 12).map((item) => ( +
  • + {item.title} + {TYPE_LABEL[item.mediaType]} +
  • + ))} + {preview.sample.length === 0 &&
  • No matches
  • } +
+
+ )} + {error && ( + + {error} + + )} +
+ ); +} + +/* ---------- 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([]); + const [searching, setSearching] = useState(false); + const [selected, setSelected] = useState>(() => new Map()); + const [error, setError] = useState(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 ( + + + + + } + onClose={onClose} + open={open} + title={`Add items to ${collection.name ?? 'collection'}`} + width={620} + > +
{ + event.preventDefault(); + void runSearch(); + }} + style={{ display: 'flex', gap: 8 }} + > + } + onChange={(event) => setQuery(event.target.value)} + placeholder="Search movies, shows, seasons, artists…" + value={query} + /> + +
+

+ The library search returns movies, shows, seasons and artists. Episodes, music, images and other item kinds + can't be added from here yet. +

+ {error && ( + + {error} + + )} +
+ {results.length === 0 && !searching ? ( +
No results — try a search above.
+ ) : ( + results.map((item) => { + const key = `${item.mediaType}:${item.id}`; + const isSelected = selected.has(key); + + return ( + + ); + }) + )} +
+
+ ); +} + +/* ---------- manual collection items view ---------- */ + +function ManualItemsView({ + collection, + onBack +}: { + collection: MediaCollection; + onBack: () => void; +}) { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [pickerOpen, setPickerOpen] = useState(false); + const [removing, setRemoving] = useState(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 ( +
+
+ + {collection.name} + + +
+ +
+
+ + {error && ( +
+
+ )} + + + {loading ? ( +
+ + Loading items… +
+ ) : items.length === 0 ? ( +
No previewable items in this collection.
+ ) : ( + items.map((item, index) => ( +
+
+ )) + )} +
+ + setPickerOpen(false)} + open={pickerOpen} + /> +
+ ); +} + +/* ---------- screen ---------- */ + +export function CollectionsScreen() { + const { refresh, state } = useCollectionsData(); + const [tab, setTab] = useState('manual'); + const [selected, setSelected] = useState(null); + + // dialogs + const [createOpen, setCreateOpen] = useState(false); + const [renameTarget, setRenameTarget] = useState(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(null); + const [rowError, setRowError] = useState(null); + const [togglingId, setTogglingId] = useState(null); + + if (state.status === 'loading') { + return ( +
+ + Loading collections… +
+ ); + } + + if (state.status === 'error') { + return ( +
+
+
+
+ ); + } + + 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 setSelected(null)} />; + } + + const manual = sortByName(state.data.manual); + const smart = sortByName(state.data.smart); + + const runDialog = async (operation: () => Promise, 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 ( +
+
+
+ + +
+ + {tab === 'manual' ? ( + + ) : ( + + )} +
+ + {rowError && ( +
+
+ )} + + {tab === 'manual' && ( + + {manual.length === 0 ? ( +
No manual collections yet.
+ ) : ( + manual.map((collection, index) => ( +
+
+ )) + )} +
+ )} + + {tab === 'smart' && ( + + {smart.length === 0 ? ( +
No smart collections yet.
+ ) : ( + smart.map((collection, index) => ( +
+
+ )) + )} +
+ )} + + + + {/* create manual */} + setCreateOpen(false)} + onSubmit={(name) => void runDialog(() => createCollection({ name }).then(() => undefined), () => setCreateOpen(false))} + open={createOpen} + title="New collection" + /> + + {/* rename manual */} + 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 */} + 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} + /> + + + {`Delete "${deleteTarget.item.name ?? 'this collection'}"? This cannot be undone.`} + {dialogError && ( + + {dialogError} + + )} + + ) : ( + '' + ) + } + onCancel={() => { + setDeleteTarget(null); + setDialogError(null); + }} + onConfirm={confirmDelete} + open={deleteTarget !== null} + title={deleteTarget?.kind === 'smart' ? 'Delete smart collection' : 'Delete collection'} + tone="danger" + /> +
+ ); +} + +function ClassicNote() { + return ( +
+
+ ); +} diff --git a/web/src/shell.css b/web/src/shell.css index a2d83467d..2fdf82614 100644 --- a/web/src/shell.css +++ b/web/src/shell.css @@ -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); +}