diff --git a/web/src/media/addTo/SaveAsSmartCollectionDialog.test.tsx b/web/src/media/addTo/SaveAsSmartCollectionDialog.test.tsx new file mode 100644 index 000000000..c8a312ea2 --- /dev/null +++ b/web/src/media/addTo/SaveAsSmartCollectionDialog.test.tsx @@ -0,0 +1,100 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { SaveAsSmartCollectionDialog } from './SaveAsSmartCollectionDialog'; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status }); +} + +const smartCollections = [ + { id: 5, name: 'Recent Sci-Fi', query: 'genre:"Science Fiction"' }, + { id: 6, name: 'Kids', query: 'tag:kids' } +]; + +interface Recorded { + url: string; + method: string; + body: unknown; +} + +function mockApi() { + const calls: Recorded[] = []; + 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; + calls.push({ body, method, url }); + + if (url === '/api/smart-collections' && method === 'GET') { + return Promise.resolve(jsonResponse(smartCollections)); + } + + if (url === '/api/smart-collections' && method === 'POST') { + const name = (body as { name: string }).name; + return Promise.resolve(jsonResponse({ id: 42, name, query: (body as { query: string }).query }, 201)); + } + + return Promise.resolve(new Response(null, { status: 204 })); + }); + + return { calls }; +} + +describe('SaveAsSmartCollectionDialog', () => { + beforeEach(() => { + window.localStorage.clear(); + vi.restoreAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it('renders nothing when closed', () => { + mockApi(); + const { container } = render( + {}} open={false} query="star trek" /> + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('loads existing smart collections plus the new option', async () => { + mockApi(); + render( {}} open query="star trek" />); + + await waitFor(() => expect(screen.getByRole('option', { name: 'Recent Sci-Fi' })).toBeTruthy()); + expect(screen.getByRole('option', { name: 'Kids' })).toBeTruthy(); + expect(screen.getByRole('option', { name: '(New smart collection)' })).toBeTruthy(); + }); + + it('creates a new smart collection with the current query', async () => { + const onSaved = vi.fn(); + const onClose = vi.fn(); + const { calls } = mockApi(); + render(); + + await waitFor(() => expect(screen.getByRole('option', { name: '(New smart collection)' })).toBeTruthy()); + fireEvent.change(screen.getByPlaceholderText('Smart collection name'), { target: { value: 'Trek' } }); + fireEvent.click(screen.getByRole('button', { name: /Save as smart collection/ })); + + await waitFor(() => expect(onSaved).toHaveBeenCalledWith('Trek')); + const createCall = calls.find((call) => call.url === '/api/smart-collections' && call.method === 'POST'); + expect(createCall?.body).toEqual({ name: 'Trek', query: 'star trek' }); + expect(onClose).toHaveBeenCalled(); + }); + + it('updates an existing smart collection query when one is chosen', async () => { + const onSaved = vi.fn(); + const { calls } = mockApi(); + render( {}} onSaved={onSaved} open query="star trek" />); + + await waitFor(() => expect(screen.getByRole('option', { name: 'Kids' })).toBeTruthy()); + fireEvent.change(screen.getByRole('combobox'), { target: { value: '6' } }); + fireEvent.click(screen.getByRole('button', { name: /Save as smart collection/ })); + + await waitFor(() => expect(onSaved).toHaveBeenCalledWith('Kids')); + const updateCall = calls.find((call) => call.url === '/api/smart-collections/6'); + expect(updateCall?.method).toBe('PUT'); + expect(updateCall?.body).toEqual({ name: 'Kids', query: 'star trek' }); + }); +}); diff --git a/web/src/media/addTo/SaveAsSmartCollectionDialog.tsx b/web/src/media/addTo/SaveAsSmartCollectionDialog.tsx new file mode 100644 index 000000000..f6e1c2126 --- /dev/null +++ b/web/src/media/addTo/SaveAsSmartCollectionDialog.tsx @@ -0,0 +1,179 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Save } from 'lucide-react'; +import { Button, Dialog, Input, Select, Spinner } from '../../components'; +import { + createSmartCollection, + getSmartCollections, + messageFromCollectionError, + updateSmartCollection, + type SmartCollection +} from '../../api'; + +export interface SaveAsSmartCollectionDialogProps { + open: boolean; + onClose: () => void; + // The search query the saved smart collection will store. + query: string; + onSaved?: (collectionName: string) => void; +} + +const NEW_COLLECTION = '__new__'; + +export function SaveAsSmartCollectionDialog(props: SaveAsSmartCollectionDialogProps) { + if (!props.open) { + return null; + } + + return ; +} + +function SaveAsSmartCollectionDialogBody({ onClose, query, onSaved }: SaveAsSmartCollectionDialogProps) { + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const [collections, setCollections] = useState([]); + const [selected, setSelected] = useState(NEW_COLLECTION); + const [newName, setNewName] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + const activeRef = useRef(true); + + useEffect(() => { + activeRef.current = true; + + return () => { + activeRef.current = false; + }; + }, []); + + const load = useCallback(() => { + getSmartCollections() + .then((result) => { + if (!activeRef.current) { + return; + } + + setCollections(result); + setSelected(NEW_COLLECTION); + setLoadError(null); + setLoading(false); + }) + .catch((error: unknown) => { + if (!activeRef.current) { + return; + } + + setLoadError(messageFromCollectionError(error, 'Unable to load smart collections')); + setLoading(false); + }); + }, []); + + useEffect(() => { + load(); + }, [load]); + + const isNew = selected === NEW_COLLECTION; + const canSubmit = !submitting && (!isNew || newName.trim().length > 0); + + const submit = () => { + if (!canSubmit) { + return; + } + + setSubmitting(true); + setSubmitError(null); + + // New: create with the query baked in. Existing: overwrite its query (keeping its name). + const save: Promise = isNew + ? createSmartCollection({ name: newName.trim(), query }).then( + (collection) => collection.name ?? newName.trim() + ) + : (() => { + const existing = collections.find((collection) => String(collection.id) === selected); + const name = existing?.name ?? 'smart collection'; + return updateSmartCollection(Number(selected), { name, query }).then(() => name); + })(); + + save + .then((name) => { + if (!activeRef.current) { + return; + } + + onSaved?.(name); + onClose(); + }) + .catch((error: unknown) => { + if (!activeRef.current) { + return; + } + + setSubmitError(messageFromCollectionError(error, 'Unable to save smart collection')); + setSubmitting(false); + }); + }; + + const options = [ + { value: NEW_COLLECTION, label: '(New smart collection)' }, + ...collections.map((collection) => ({ + value: String(collection.id), + label: collection.name ?? `Smart collection ${collection.id}` + })) + ]; + + return ( + + + + + } + onClose={onClose} + open + title="Save as smart collection" + width={460} + > + {loading ? ( +
+ +
+ ) : loadError ? ( + + {loadError} + + ) : ( +
+ setNewName(event.target.value)} + placeholder="Smart collection name" + value={newName} + /> + )} + {submitError && ( + + {submitError} + + )} +
+ )} +
+ ); +} diff --git a/web/src/media/addTo/index.ts b/web/src/media/addTo/index.ts index 4b2f5d96b..93d69aef5 100644 --- a/web/src/media/addTo/index.ts +++ b/web/src/media/addTo/index.ts @@ -2,4 +2,5 @@ export { AddToCollectionDialog, type AddToCollectionDialogProps } from './AddToC export { AddToPlaylistDialog, type AddToPlaylistDialogProps } from './AddToPlaylistDialog'; export { AddToScheduleDialog, type AddToScheduleDialogProps, type AddToScheduleItem } from './AddToScheduleDialog'; export { AddToMenu, type AddToMenuProps, type AddToTarget } from './AddToMenu'; +export { SaveAsSmartCollectionDialog, type SaveAsSmartCollectionDialogProps } from './SaveAsSmartCollectionDialog'; export { collectionTypeForMediaType, scheduleItemRequestForMediaItem, type AddToItems } from './scheduleItem'; diff --git a/web/src/screens/SearchScreen.test.tsx b/web/src/screens/SearchScreen.test.tsx new file mode 100644 index 000000000..dc69ace88 --- /dev/null +++ b/web/src/screens/SearchScreen.test.tsx @@ -0,0 +1,183 @@ +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { SearchScreen } from './SearchScreen'; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status }); +} + +const movieItem = { artwork: '', duration: null, id: 12, mediaType: 'Movie', subtitle: null, title: 'Blade Runner' }; + +function emptyGroup() { + return { totalCount: 0, items: [] }; +} + +function searchResults() { + return { + movies: { totalCount: 1, items: [movieItem] }, + shows: emptyGroup(), + seasons: emptyGroup(), + artists: emptyGroup(), + episodes: emptyGroup(), + musicVideos: emptyGroup(), + songs: emptyGroup(), + otherVideos: emptyGroup(), + images: emptyGroup(), + remoteStreams: emptyGroup() + }; +} + +const allItems = { + movieIds: [7, 8], + showIds: null, + seasonIds: null, + episodeIds: null, + artistIds: null, + musicVideoIds: null, + otherVideoIds: null, + songIds: null, + imageIds: null, + remoteStreamIds: null +}; + +const emptyBuckets = { + artistIds: [], + episodeIds: [], + imageIds: [], + movieIds: [], + musicVideoIds: [], + otherVideoIds: [], + remoteStreamIds: [], + seasonIds: [], + showIds: [], + songIds: [] +}; + +interface Recorded { + url: string; + method: string; + body: unknown; +} + +function mockApi() { + const calls: Recorded[] = []; + 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; + calls.push({ body, method, url }); + + if (url.startsWith('/api/search/all-items')) { + return Promise.resolve(jsonResponse(allItems)); + } + if (url.startsWith('/api/search')) { + return Promise.resolve(jsonResponse(searchResults())); + } + if (url === '/api/collections' && method === 'GET') { + return Promise.resolve(jsonResponse([{ collectionType: 'Collection', id: 1, name: 'Favorites', useCustomPlaybackOrder: false }])); + } + if (url === '/api/smart-collections' && method === 'GET') { + return Promise.resolve(jsonResponse([])); + } + if (url === '/api/smart-collections' && method === 'POST') { + const name = (body as { name: string }).name; + return Promise.resolve(jsonResponse({ id: 42, name, query: (body as { query: string }).query }, 201)); + } + if (url === '/api/playlists/groups') { + return Promise.resolve(jsonResponse([])); + } + return Promise.resolve(new Response(null, { status: 204 })); + }); + + return { calls }; +} + +function renderWithQuery() { + window.history.replaceState(null, '', '/app/search?query=trek'); + return render(); +} + +describe('SearchScreen', () => { + beforeEach(() => { + window.localStorage.clear(); + vi.restoreAllMocks(); + }); + + afterEach(() => { + cleanup(); + window.history.replaceState(null, '', '/'); + }); + + it('drills into a result card via its detail path', async () => { + mockApi(); + renderWithQuery(); + + await waitFor(() => expect(screen.getByText('Blade Runner')).toBeTruthy()); + const pushState = vi.spyOn(window.history, 'pushState'); + fireEvent.click(screen.getByText('Blade Runner')); + + expect(pushState).toHaveBeenCalledWith(null, '', '/app/media/movies/12'); + }); + + it('selects cards and adds the selection to a collection', async () => { + const { calls } = mockApi(); + renderWithQuery(); + + await waitFor(() => expect(screen.getByText('Blade Runner')).toBeTruthy()); + + // Enter select mode, then click the card to toggle it selected. + fireEvent.click(screen.getByRole('button', { name: /Select/ })); + fireEvent.click(screen.getByText('Blade Runner')); + + await waitFor(() => expect(screen.getByText('1 selected')).toBeTruthy()); + + fireEvent.click(screen.getByRole('button', { name: 'Add to collection' })); + + const dialog = await screen.findByRole('dialog'); + await waitFor(() => expect(within(dialog).getByRole('option', { name: 'Favorites' })).toBeTruthy()); + fireEvent.click(within(dialog).getByRole('button', { name: /Add to collection/ })); + + await waitFor(() => expect(calls.some((call) => call.url === '/api/collections/1/items')).toBe(true)); + const addCall = calls.find((call) => call.url === '/api/collections/1/items'); + expect(addCall?.method).toBe('POST'); + expect(addCall?.body).toEqual({ ...emptyBuckets, movieIds: [12] }); + }); + + it('adds all query results (not just the loaded page) to a collection', async () => { + const { calls } = mockApi(); + renderWithQuery(); + + await waitFor(() => expect(screen.getByText('Blade Runner')).toBeTruthy()); + + fireEvent.click(screen.getByRole('button', { name: /Add all to collection/ })); + + // The whole-query id set is resolved via /api/search/all-items before the dialog opens. + await waitFor(() => expect(calls.some((call) => call.url.startsWith('/api/search/all-items'))).toBe(true)); + + const dialog = await screen.findByRole('dialog'); + await waitFor(() => expect(within(dialog).getByRole('option', { name: 'Favorites' })).toBeTruthy()); + fireEvent.click(within(dialog).getByRole('button', { name: /Add to collection/ })); + + await waitFor(() => expect(calls.some((call) => call.url === '/api/collections/1/items')).toBe(true)); + const addCall = calls.find((call) => call.url === '/api/collections/1/items'); + expect(addCall?.body).toEqual({ ...emptyBuckets, movieIds: [7, 8] }); + }); + + it('saves the current query as a new smart collection', async () => { + const { calls } = mockApi(); + renderWithQuery(); + + await waitFor(() => expect(screen.getByText('Blade Runner')).toBeTruthy()); + + fireEvent.click(screen.getByRole('button', { name: /Save as smart collection/ })); + + const dialog = await screen.findByRole('dialog'); + await waitFor(() => expect(within(dialog).getByRole('option', { name: '(New smart collection)' })).toBeTruthy()); + fireEvent.change(within(dialog).getByPlaceholderText('Smart collection name'), { target: { value: 'Trek Stuff' } }); + fireEvent.click(within(dialog).getByRole('button', { name: /Save as smart collection/ })); + + await waitFor(() => expect(calls.some((call) => call.url === '/api/smart-collections' && call.method === 'POST')).toBe(true)); + const createCall = calls.find((call) => call.url === '/api/smart-collections' && call.method === 'POST'); + expect(createCall?.body).toEqual({ name: 'Trek Stuff', query: 'trek' }); + }); +}); diff --git a/web/src/screens/SearchScreen.tsx b/web/src/screens/SearchScreen.tsx index cd1acf4fb..547d9d8b3 100644 --- a/web/src/screens/SearchScreen.tsx +++ b/web/src/screens/SearchScreen.tsx @@ -1,9 +1,24 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { Search, TriangleAlert } from 'lucide-react'; -import { Button, Card, Input, Spinner } from '../components'; -import { getSearchResults, messageFromSearchError, type SearchResults } from '../api'; +import { CheckSquare, FolderPlus, ListVideo, Save, Search, TriangleAlert, X } from 'lucide-react'; +import { Button, Card, Input, Spinner, Toast } from '../components'; +import { + getSearchAllItems, + getSearchResults, + messageFromSearchError, + toAddItemsRequestFromSearch, + type LibraryBrowseItem, + type SearchResults +} from '../api'; import { navigateToPath } from '../routing'; import { MediaPosterCard } from '../media/MediaPosterCard'; +import { mediaDetailPath } from '../media/mediaKinds'; +import { + AddToCollectionDialog, + AddToMenu, + AddToPlaylistDialog, + SaveAsSmartCollectionDialog, + type AddToItems +} from '../media/addTo'; const PAGE_SIZE = 50; @@ -33,11 +48,29 @@ type SearchState = | { results: null; error: string; status: 'error' } | { results: null; error: null; status: 'loading' }; +// Which whole-query / selection dialog is open. Per-card adds are owned by each card's AddToMenu. +type ActiveDialog = + | { kind: 'collection'; items: AddToItems } + | { kind: 'playlist'; items: AddToItems } + | { kind: 'save-smart' } + | null; + +type Notice = { tone: 'ok' | 'error'; message: string }; + +function itemKey(item: LibraryBrowseItem): string { + return `${item.mediaType}-${item.id}`; +} + export function SearchScreen() { const initialQuery = new URLSearchParams(window.location.search).get('query') ?? ''; const [queryInput, setQueryInput] = useState(initialQuery); const [query, setQuery] = useState(initialQuery); const [state, setState] = useState({ results: null, error: null, status: 'loading' }); + const [selectMode, setSelectMode] = useState(false); + const [selected, setSelected] = useState>(new Map()); + const [dialog, setDialog] = useState(null); + const [pendingAll, setPendingAll] = useState<'collection' | 'playlist' | null>(null); + const [notice, setNotice] = useState(null); const activeRef = useRef(true); const seqRef = useRef(0); const hasQuery = query.trim().length > 0; @@ -92,11 +125,74 @@ export function SearchScreen() { navigateToPath(`/app/media?kind=${slug}&q=${encodeURIComponent(query.trim())}`); }; + const clearSelection = () => setSelected(new Map()); + + // Screen-level Select toggle (deliberate UX deviation from Blazor's always-on corner-select): + // OFF => cards drill in on click; ON => cards toggle selection. Turning it off clears the set. + const toggleSelectMode = () => { + if (selectMode) { + clearSelection(); + } + setSelectMode((current) => !current); + }; + + const toggleSelect = (item: LibraryBrowseItem) => { + const key = itemKey(item); + setSelected((current) => { + const next = new Map(current); + if (next.has(key)) { + next.delete(key); + } else { + next.set(key, item); + } + return next; + }); + }; + + // Add the whole query's results (not just the loaded page): resolve every matching id, then open + // the target dialog with a pre-built request override. Blazor ref: Search.razor AddAllTo*. + const addAll = (kind: 'collection' | 'playlist') => { + const trimmed = query.trim(); + if (!trimmed) { + return; + } + + setPendingAll(kind); + getSearchAllItems(trimmed) + .then((result) => { + if (!activeRef.current) { + return; + } + setPendingAll(null); + setDialog({ kind, items: { requestOverride: toAddItemsRequestFromSearch(result) } }); + }) + .catch((error: unknown) => { + if (!activeRef.current) { + return; + } + setPendingAll(null); + setNotice({ tone: 'error', message: messageFromSearchError(error, 'Unable to load all results') }); + }); + }; + + const addSelection = (kind: 'collection' | 'playlist') => { + setDialog({ kind, items: [...selected.values()] }); + }; + + const onAddedToSelectionTarget = (label: string) => (name: string) => { + setDialog(null); + clearSelection(); + setNotice({ tone: 'ok', message: `Added to ${label} “${name}”` }); + }; + const totalMatches = state.status === 'success' && state.results ? GROUPS.reduce((sum, group) => sum + (state.results?.[group.key].totalCount ?? 0), 0) : 0; + const hasResults = hasQuery && state.status === 'success' && totalMatches > 0; + const selectionCount = selected.size; + return (
@@ -106,8 +202,84 @@ export function SearchScreen() { placeholder="Search movies, shows, episodes, music…" value={queryInput} /> + + {hasResults && ( + <> + + + + + + )}
+ {notice && ( + setNotice(null)} tone={notice.tone} /> + )} + + {selectMode && selectionCount > 0 && ( +
+ {selectionCount} selected + + + + +
+ )} + {!hasQuery && (
Type a query to search across every media kind.
@@ -164,13 +336,51 @@ export function SearchScreen() { )}
- {data.items.map((item) => ( - - ))} + {data.items.map((item) => { + const key = itemKey(item); + const detailPath = mediaDetailPath(item); + return ( + setNotice({ tone: 'ok', message })} /> + ) + } + item={item} + key={key} + onOpen={!selectMode && detailPath ? () => navigateToPath(detailPath) : undefined} + onToggleSelect={selectMode ? toggleSelect : undefined} + selected={selectMode ? selected.has(key) : undefined} + /> + ); + })}
); })} + + setDialog(null)} + open={dialog?.kind === 'collection'} + /> + setDialog(null)} + open={dialog?.kind === 'playlist'} + /> + setDialog(null)} + onSaved={(name) => { + setDialog(null); + clearSelection(); + setNotice({ tone: 'ok', message: `Saved smart collection “${name}”` }); + }} + open={dialog?.kind === 'save-smart'} + query={query.trim()} + /> ); } diff --git a/web/src/shell.css b/web/src/shell.css index 03ce2b911..4cbf386a1 100644 --- a/web/src/shell.css +++ b/web/src/shell.css @@ -4148,3 +4148,19 @@ border-color: var(--ctv-accent); color: var(--ctv-text); } + +/* Search multi-select action bar (#208): appears below the actionbar while items are selected. */ +.ctv-search-selectbar { + display: flex; + align-items: center; + gap: var(--space-5, 10px); + border: 1px solid var(--ctv-accent); + border-radius: var(--radius-md, 7px); + background: var(--ctv-accent-soft); + padding: var(--space-4, 8px) var(--space-7, 16px); +} + +.ctv-search-selectbar-count { + font-weight: 600; + color: var(--ctv-text); +}