merge main into feat/145-block-history (post-#181); regenerate OpenAPI artifacts
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getLibraryBrowseItems } from './libraryBrowse';
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status
|
||||
});
|
||||
}
|
||||
|
||||
function browseUrl(fetchMock: ReturnType<typeof vi.spyOn>): URL {
|
||||
return new URL(String(fetchMock.mock.calls[0][0]), 'http://localhost');
|
||||
}
|
||||
|
||||
describe('getLibraryBrowseItems', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('maps the paging/library/mediaType params into the query string', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ page: [], totalCount: 0 }));
|
||||
|
||||
await getLibraryBrowseItems({
|
||||
query: 'star',
|
||||
libraryId: 5,
|
||||
mediaType: 'TelevisionShow',
|
||||
pageNum: 2,
|
||||
pageSize: 25
|
||||
});
|
||||
|
||||
const url = browseUrl(fetchMock);
|
||||
expect(url.pathname).toBe('/api/library/browse');
|
||||
expect(url.searchParams.get('query')).toBe('star');
|
||||
expect(url.searchParams.get('libraryId')).toBe('5');
|
||||
expect(url.searchParams.get('mediaType')).toBe('TelevisionShow');
|
||||
expect(url.searchParams.get('pageNum')).toBe('2');
|
||||
expect(url.searchParams.get('pageSize')).toBe('25');
|
||||
expect(url.searchParams.has('parentId')).toBe(false);
|
||||
});
|
||||
|
||||
it('sends parentId to scope seasons to a specific show', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ page: [], totalCount: 0 }));
|
||||
|
||||
await getLibraryBrowseItems({ mediaType: 'TelevisionSeason', parentId: 42, pageSize: 100 });
|
||||
|
||||
const url = browseUrl(fetchMock);
|
||||
expect(url.searchParams.get('mediaType')).toBe('TelevisionSeason');
|
||||
expect(url.searchParams.get('parentId')).toBe('42');
|
||||
});
|
||||
|
||||
it('omits parentId when it is not provided', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ page: [], totalCount: 0 }));
|
||||
|
||||
await getLibraryBrowseItems({ mediaType: 'Movie' });
|
||||
|
||||
expect(browseUrl(fetchMock).searchParams.has('parentId')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,8 @@ export interface GetLibraryBrowseItemsParams {
|
||||
mediaType?: LibraryBrowseMediaType;
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
// Only meaningful with mediaType 'TelevisionSeason': filters seasons to the given show id.
|
||||
parentId?: number;
|
||||
query?: string;
|
||||
}
|
||||
|
||||
@@ -36,6 +38,10 @@ export function getLibraryBrowseItems(params: GetLibraryBrowseItemsParams = {}):
|
||||
searchParams.set('pageSize', String(params.pageSize));
|
||||
}
|
||||
|
||||
if (params.parentId != null) {
|
||||
searchParams.set('parentId', String(params.parentId));
|
||||
}
|
||||
|
||||
const queryString = searchParams.toString();
|
||||
|
||||
return request<PagedLibraryBrowseItems>(`/api/library/browse${queryString ? `?${queryString}` : ''}`);
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { cleanup, render, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ChannelBuilderScreen } from './ChannelBuilder';
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status
|
||||
});
|
||||
}
|
||||
|
||||
// Mocks every endpoint the builder loads on mount so it can render its library
|
||||
// browser, plus /api/library/browse which the browse hook fans out across.
|
||||
function mockBuilderApi() {
|
||||
return vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => {
|
||||
const url = input.toString();
|
||||
|
||||
if (url.startsWith('/api/library/browse')) {
|
||||
return Promise.resolve(jsonResponse({ page: [], totalCount: 0 }));
|
||||
}
|
||||
|
||||
if (url === '/api/channel-templates/default') {
|
||||
// Builder treats a 404 here as "no default template".
|
||||
return Promise.resolve(jsonResponse({ status: 404, title: 'Not Found' }, 404));
|
||||
}
|
||||
|
||||
// channels, channel-templates, ffmpeg/profiles, filler-presets, watermarks, media-sources
|
||||
return Promise.resolve(jsonResponse([]));
|
||||
});
|
||||
}
|
||||
|
||||
function browseMediaTypes(fetchMock: ReturnType<typeof mockBuilderApi>): string[] {
|
||||
return fetchMock.mock.calls
|
||||
.map(([u]) => u.toString())
|
||||
.filter((u) => u.startsWith('/api/library/browse'))
|
||||
.map((u) => new URL(u, 'http://localhost').searchParams.get('mediaType'))
|
||||
.filter((mediaType): mediaType is string => mediaType != null && mediaType !== '');
|
||||
}
|
||||
|
||||
describe('ChannelBuilder library browse', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it('fans out over movies/shows/artists and never requests TelevisionSeason', async () => {
|
||||
const fetchMock = mockBuilderApi();
|
||||
|
||||
render(<ChannelBuilderScreen />);
|
||||
|
||||
// Wait until the initial library browse fan-out has fired.
|
||||
await waitFor(() => {
|
||||
expect(browseMediaTypes(fetchMock).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const types = new Set(browseMediaTypes(fetchMock));
|
||||
expect(types).toEqual(new Set(['Movie', 'TelevisionShow', 'Artist']));
|
||||
expect(types.has('TelevisionSeason')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -140,14 +140,15 @@ const COLLECTION_MEDIA_TYPES: LibraryBrowseMediaType[] = [
|
||||
'Playlist'
|
||||
];
|
||||
|
||||
// The pre-#168 API default: browsing a library shows the "pickable" top-level
|
||||
// kinds only (shows/seasons/movies/artists), not every episode/song/etc.
|
||||
// nested underneath them. Kept explicit here since `GET /api/library/browse`
|
||||
// now spans all 10 media kinds when `mediaType` is omitted.
|
||||
// Browsing a library shows the "pickable" top-level kinds only, not every
|
||||
// episode/song/etc. nested underneath them. Kept explicit here since
|
||||
// `GET /api/library/browse` now spans all 10 media kinds when `mediaType` is
|
||||
// omitted. TelevisionSeason is intentionally excluded so a multi-season show
|
||||
// renders as a single tile instead of flooding the grid with per-season tiles
|
||||
// (issue #180); seasons are reachable via the show tile's Seasons drill-in.
|
||||
const LIBRARY_MEDIA_TYPES: LibraryBrowseMediaType[] = [
|
||||
'Movie',
|
||||
'TelevisionShow',
|
||||
'TelevisionSeason',
|
||||
'Artist'
|
||||
];
|
||||
|
||||
@@ -326,6 +327,7 @@ function LibCard({
|
||||
added,
|
||||
compact,
|
||||
onAdd,
|
||||
onSeasons,
|
||||
onDragStart,
|
||||
onDragEnd
|
||||
}: {
|
||||
@@ -333,6 +335,7 @@ function LibCard({
|
||||
added: boolean;
|
||||
compact: boolean;
|
||||
onAdd: () => void;
|
||||
onSeasons?: () => void;
|
||||
onDragStart: () => void;
|
||||
onDragEnd: () => void;
|
||||
}) {
|
||||
@@ -369,6 +372,18 @@ function LibCard({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{onSeasons && (
|
||||
<IconButton
|
||||
size="sm"
|
||||
title="Browse seasons"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onSeasons();
|
||||
}}
|
||||
>
|
||||
<FolderTree size={15} aria-hidden="true" />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton size="sm" title={added ? 'Added' : 'Add to lineup'} onClick={onAdd}>
|
||||
{added ? (
|
||||
<Check size={15} color="var(--action-primary)" aria-hidden="true" />
|
||||
@@ -399,11 +414,111 @@ function LibCard({
|
||||
<Check size={14} aria-hidden="true" />
|
||||
</div>
|
||||
)}
|
||||
{onSeasons && (
|
||||
<button
|
||||
type="button"
|
||||
className="ctv-builder-seasons-btn ctv-press"
|
||||
title="Browse seasons"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onSeasons();
|
||||
}}
|
||||
>
|
||||
<FolderTree size={13} aria-hidden="true" />
|
||||
Seasons
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Seasons drill-in ------------------------------------------------------
|
||||
// Expands a TelevisionShow tile into its seasons so a specific season can be
|
||||
// added to the lineup without flooding the main grid with per-season tiles
|
||||
// (issue #180). Mounted fresh per show (keyed on show id) so initial state is
|
||||
// 'loading' and the fetch effect only ever calls setState in its callbacks.
|
||||
function SeasonsDialog({
|
||||
show,
|
||||
addedKeys,
|
||||
onAdd,
|
||||
onClose
|
||||
}: {
|
||||
show: LibraryBrowseItem;
|
||||
addedKeys: Set<string>;
|
||||
onAdd: (item: LibraryBrowseItem) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [seasons, setSeasons] = useState<LibraryBrowseItem[]>([]);
|
||||
const [status, setStatus] = useState<'loading' | 'error' | 'success'>('loading');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
getLibraryBrowseItems({ mediaType: 'TelevisionSeason', parentId: show.id, pageSize: 100 })
|
||||
.then((result) => {
|
||||
if (active) {
|
||||
setSeasons(result.page ?? []);
|
||||
setStatus('success');
|
||||
}
|
||||
})
|
||||
.catch((loadError: unknown) => {
|
||||
if (active) {
|
||||
setError(messageFromError(loadError));
|
||||
setStatus('error');
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [show.id]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onClose={onClose}
|
||||
title={`Seasons — ${show.title}`}
|
||||
width={520}
|
||||
footer={
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Done
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{status === 'loading' ? (
|
||||
<div className="ctv-builder-empty">
|
||||
<Spinner size={18} tone="accent" />
|
||||
</div>
|
||||
) : status === 'error' ? (
|
||||
<div className="ctv-builder-empty" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
) : seasons.length === 0 ? (
|
||||
<div className="ctv-builder-empty">This show has no seasons.</div>
|
||||
) : (
|
||||
<div className="ctv-builder-seasons-list">
|
||||
{seasons.map((season) => {
|
||||
const added = addedKeys.has(lineupKey(season));
|
||||
return (
|
||||
<div key={lineupKey(season)} className="ctv-builder-seasons-row">
|
||||
<Poster item={season} width={40} height={54} mini />
|
||||
<div className="ctv-builder-seasons-row-title">{season.title}</div>
|
||||
<IconButton size="sm" title={added ? 'Added' : 'Add to lineup'} onClick={() => onAdd(season)}>
|
||||
{added ? (
|
||||
<Check size={15} color="var(--action-primary)" aria-hidden="true" />
|
||||
) : (
|
||||
<Plus size={15} aria-hidden="true" />
|
||||
)}
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Library browse data hook ---------------------------------------------
|
||||
interface BrowseState {
|
||||
status: 'loading' | 'error' | 'success';
|
||||
@@ -422,7 +537,7 @@ async function loadCollections(query: string): Promise<{ page: LibraryBrowseItem
|
||||
return { page: merged, totalCount: merged.length };
|
||||
}
|
||||
|
||||
// Fan out across the four pickable library kinds (movies/shows/seasons/artists)
|
||||
// Fan out across the pickable top-level library kinds (movies/shows/artists)
|
||||
// instead of the unscoped browse, which now also returns episodes/songs/etc.
|
||||
// Mirrors loadCollections's per-kind Promise.all pattern, but preserves real
|
||||
// paging (each kind keeps its own pageNum/totalCount, summed across kinds) so
|
||||
@@ -674,6 +789,9 @@ function ChannelBuilder({
|
||||
const [compact, setCompact] = useState(false);
|
||||
const [libraryId, setLibraryId] = useState<number | null>(null);
|
||||
|
||||
// Show whose seasons are being browsed in the drill-in dialog (issue #180).
|
||||
const [seasonsShow, setSeasonsShow] = useState<LibraryBrowseItem | null>(null);
|
||||
|
||||
// debounce search input -> query
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => setQuery(searchInput.trim()), 280);
|
||||
@@ -1144,6 +1262,9 @@ function ChannelBuilder({
|
||||
compact={compact}
|
||||
added={addedKeys.has(lineupKey(item))}
|
||||
onAdd={() => addItem(item)}
|
||||
onSeasons={
|
||||
item.mediaType === 'TelevisionShow' ? () => setSeasonsShow(item) : undefined
|
||||
}
|
||||
onDragStart={() => setDragLib(lineupKey(item))}
|
||||
onDragEnd={() => {
|
||||
if (dragLib != null) {
|
||||
@@ -1571,6 +1692,16 @@ function ChannelBuilder({
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{seasonsShow && (
|
||||
<SeasonsDialog
|
||||
key={seasonsShow.id}
|
||||
show={seasonsShow}
|
||||
addedKeys={addedKeys}
|
||||
onAdd={addItem}
|
||||
onClose={() => setSeasonsShow(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmClear}
|
||||
title="Clear the lineup?"
|
||||
|
||||
@@ -266,15 +266,15 @@ describe('CollectionsScreen', () => {
|
||||
expect(new URL(String(browseCall?.[0]), 'http://localhost').searchParams.get('query')).toBe('collection:"Favorites"');
|
||||
});
|
||||
|
||||
it('fans the add-items search out over the 4 addable kinds and merges the results', async () => {
|
||||
const byType: Record<string, { id: number; mediaType: string; title: string }> = {
|
||||
Movie: { id: 1, mediaType: 'Movie', title: 'Zathura' },
|
||||
TelevisionShow: { id: 2, mediaType: 'TelevisionShow', title: 'Adventure Time' },
|
||||
TelevisionSeason: { id: 3, mediaType: 'TelevisionSeason', title: 'Melon Season 1' },
|
||||
Artist: { id: 4, mediaType: 'Artist', title: 'Between Movie and Show' }
|
||||
};
|
||||
const addItemsByType: Record<string, { id: number; mediaType: string; title: string }> = {
|
||||
Movie: { id: 1, mediaType: 'Movie', title: 'Zathura' },
|
||||
TelevisionShow: { id: 2, mediaType: 'TelevisionShow', title: 'Adventure Time' },
|
||||
TelevisionSeason: { id: 3, mediaType: 'TelevisionSeason', title: 'Melon Season 1' },
|
||||
Artist: { id: 4, mediaType: 'Artist', title: 'Between Movie and Show' }
|
||||
};
|
||||
|
||||
const fetchMock = mockApi({
|
||||
function mockAddItemsApi() {
|
||||
return mockApi({
|
||||
onRequest: (url) => {
|
||||
if (url.startsWith('/api/library/browse')) {
|
||||
const params = new URL(url, 'http://localhost').searchParams;
|
||||
@@ -287,14 +287,16 @@ describe('CollectionsScreen', () => {
|
||||
}
|
||||
|
||||
const mediaType = params.get('mediaType');
|
||||
const item = mediaType ? byType[mediaType] : undefined;
|
||||
const item = mediaType ? addItemsByType[mediaType] : undefined;
|
||||
return jsonResponse(item ? { page: [item], totalCount: 1 } : { page: [], totalCount: 0 });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function openAddItemsDialog() {
|
||||
render(<CollectionsScreen />);
|
||||
await screen.findByText('Favorites');
|
||||
|
||||
@@ -302,8 +304,21 @@ describe('CollectionsScreen', () => {
|
||||
await screen.findByText(/best-effort search preview/);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add items' }));
|
||||
return screen.getByRole('dialog');
|
||||
}
|
||||
|
||||
const dialog = screen.getByRole('dialog');
|
||||
function addItemsBrowseTypes(fetchMock: ReturnType<typeof mockApi>): string[] {
|
||||
return fetchMock.mock.calls
|
||||
.map(([u]) => u.toString())
|
||||
.filter((u) => u.startsWith('/api/library/browse'))
|
||||
.map((u) => new URL(u, 'http://localhost').searchParams.get('mediaType'))
|
||||
.filter((mediaType): mediaType is string => mediaType != null && mediaType !== '');
|
||||
}
|
||||
|
||||
it('default add-items search excludes seasons and fans out over movies/shows/artists', async () => {
|
||||
const fetchMock = mockAddItemsApi();
|
||||
|
||||
const dialog = await openAddItemsDialog();
|
||||
fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), {
|
||||
target: { value: 'a' }
|
||||
});
|
||||
@@ -311,15 +326,30 @@ describe('CollectionsScreen', () => {
|
||||
|
||||
expect(await within(dialog).findByText('Adventure Time')).toBeInTheDocument();
|
||||
expect(within(dialog).getByText('Zathura')).toBeInTheDocument();
|
||||
expect(within(dialog).getByText('Melon Season 1')).toBeInTheDocument();
|
||||
expect(within(dialog).getByText('Between Movie and Show')).toBeInTheDocument();
|
||||
// Seasons are excluded from the default fan-out (issue #180).
|
||||
expect(within(dialog).queryByText('Melon Season 1')).not.toBeInTheDocument();
|
||||
|
||||
const browseTypes = fetchMock.mock.calls
|
||||
expect(new Set(addItemsBrowseTypes(fetchMock))).toEqual(new Set(['Movie', 'TelevisionShow', 'Artist']));
|
||||
});
|
||||
|
||||
it('selecting the Seasons filter searches TelevisionSeason and surfaces seasons', async () => {
|
||||
const fetchMock = mockAddItemsApi();
|
||||
|
||||
const dialog = await openAddItemsDialog();
|
||||
fireEvent.change(within(dialog).getByPlaceholderText('Search movies, shows, seasons, artists…'), {
|
||||
target: { value: 'a' }
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Seasons' }));
|
||||
|
||||
expect(await within(dialog).findByText('Melon Season 1')).toBeInTheDocument();
|
||||
// The explicit filter narrows the fan-out to seasons only.
|
||||
expect(within(dialog).queryByText('Zathura')).not.toBeInTheDocument();
|
||||
|
||||
const seasonCall = fetchMock.mock.calls
|
||||
.map(([u]) => u.toString())
|
||||
.filter((u) => u.startsWith('/api/library/browse'))
|
||||
.map((u) => new URL(u, 'http://localhost').searchParams.get('mediaType'))
|
||||
.filter((mediaType): mediaType is string => mediaType != null);
|
||||
|
||||
expect(new Set(browseTypes)).toEqual(new Set(['Movie', 'TelevisionShow', 'TelevisionSeason', 'Artist']));
|
||||
.some((u) => new URL(u, 'http://localhost').searchParams.get('mediaType') === 'TelevisionSeason');
|
||||
expect(seasonCall).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,9 +46,25 @@ import { TYPE_LABEL } from '../media/mediaKinds';
|
||||
|
||||
type Tab = 'manual' | 'smart';
|
||||
|
||||
// Every kind that can be added to a manual collection from the picker.
|
||||
const ADDABLE_TYPE_LIST: LibraryBrowseItem['mediaType'][] = ['Movie', 'TelevisionShow', 'TelevisionSeason', 'Artist'];
|
||||
const ADDABLE_TYPES = new Set<LibraryBrowseItem['mediaType']>(ADDABLE_TYPE_LIST);
|
||||
|
||||
// The default fan-out excludes seasons so a multi-season show doesn't flood the
|
||||
// results with per-season rows (issue #180); seasons stay reachable via the
|
||||
// explicit media-kind filter below.
|
||||
const DEFAULT_SEARCH_KINDS: LibraryBrowseItem['mediaType'][] = ['Movie', 'TelevisionShow', 'Artist'];
|
||||
|
||||
type MediaKindFilter = 'all' | LibraryBrowseItem['mediaType'];
|
||||
|
||||
const MEDIA_KIND_FILTERS: { label: string; value: MediaKindFilter }[] = [
|
||||
{ label: 'All', value: 'all' },
|
||||
{ label: 'Movies', value: 'Movie' },
|
||||
{ label: 'Shows', value: 'TelevisionShow' },
|
||||
{ label: 'Seasons', value: 'TelevisionSeason' },
|
||||
{ label: 'Artists', value: 'Artist' }
|
||||
];
|
||||
|
||||
function sortByName<T extends { name?: null | string }>(items: T[]): T[] {
|
||||
return [...items].sort((left, right) => (left.name ?? '').localeCompare(right.name ?? ''));
|
||||
}
|
||||
@@ -301,15 +317,17 @@ function AddItemsDialog({
|
||||
const [selected, setSelected] = useState<Map<string, LibraryBrowseItem>>(() => new Map());
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [kindFilter, setKindFilter] = useState<MediaKindFilter>('all');
|
||||
|
||||
const runSearch = async () => {
|
||||
const runSearch = async (filter: MediaKindFilter = kindFilter) => {
|
||||
setSearching(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const trimmed = query.trim();
|
||||
const kinds = filter === 'all' ? DEFAULT_SEARCH_KINDS : [filter];
|
||||
const perKind = await Promise.all(
|
||||
ADDABLE_TYPE_LIST.map((mediaType) => getLibraryBrowseItems({ pageSize: 50, query: trimmed, mediaType }))
|
||||
kinds.map((mediaType) => getLibraryBrowseItems({ pageSize: 50, query: trimmed, mediaType }))
|
||||
);
|
||||
const merged = perKind
|
||||
.flatMap((result) => result.page ?? [])
|
||||
@@ -323,6 +341,11 @@ function AddItemsDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const selectKindFilter = (filter: MediaKindFilter) => {
|
||||
setKindFilter(filter);
|
||||
void runSearch(filter);
|
||||
};
|
||||
|
||||
const toggle = (item: LibraryBrowseItem) => {
|
||||
const key = `${item.mediaType}:${item.id}`;
|
||||
setSelected((current) => {
|
||||
@@ -397,9 +420,23 @@ function AddItemsDialog({
|
||||
Search
|
||||
</Button>
|
||||
</form>
|
||||
<div className="ctv-collections-picker-filters" role="group" aria-label="Filter by media kind">
|
||||
{MEDIA_KIND_FILTERS.map((filter) => (
|
||||
<button
|
||||
aria-pressed={kindFilter === filter.value}
|
||||
className={`ctv-collections-picker-filter ctv-press${kindFilter === filter.value ? ' ctv-collections-picker-filter-active' : ''}`}
|
||||
key={filter.value}
|
||||
onClick={() => selectKindFilter(filter.value)}
|
||||
type="button"
|
||||
>
|
||||
{filter.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<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.
|
||||
Add movies, shows, seasons and artists. “All” searches movies, shows and artists; pick
|
||||
“Seasons” to find a specific season. Episodes, music, images and other item kinds can’t be
|
||||
added from here yet.
|
||||
</p>
|
||||
{error && (
|
||||
<span className="ctv-field-error" role="alert">
|
||||
|
||||
@@ -2432,6 +2432,57 @@
|
||||
animation: ctv-pop 260ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
/* Seasons drill-in affordance on show tiles (issue #180) */
|
||||
.ctv-builder-seasons-btn {
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
bottom: 6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-hairline);
|
||||
background: color-mix(in srgb, var(--surface-card) 82%, transparent);
|
||||
color: var(--text-primary);
|
||||
font: var(--weight-medium) var(--text-2xs) / 1 var(--font-sans);
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.ctv-builder-seasons-btn:hover {
|
||||
border-color: color-mix(in srgb, var(--action-primary) 45%, transparent);
|
||||
color: var(--action-primary);
|
||||
}
|
||||
|
||||
.ctv-builder-seasons-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 420px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ctv-builder-seasons-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-5, 10px);
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-hairline);
|
||||
}
|
||||
|
||||
.ctv-builder-seasons-row-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font: var(--weight-medium) var(--text-xs) / 1.15 var(--font-sans);
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@keyframes ctv-pop {
|
||||
from {
|
||||
transform: scale(0.2);
|
||||
@@ -3703,6 +3754,35 @@
|
||||
color: var(--text-disabled);
|
||||
}
|
||||
|
||||
/* Media-kind filter chips in the add-items dialog (issue #180) */
|
||||
.ctv-collections-picker-filters {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ctv-collections-picker-filter {
|
||||
padding: 4px 12px;
|
||||
border-radius: var(--radius-pill, 999px);
|
||||
border: 1px solid var(--border-hairline);
|
||||
background: var(--surface-card);
|
||||
color: var(--text-secondary);
|
||||
font: var(--weight-medium) var(--text-2xs) / 1 var(--font-sans);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ctv-collections-picker-filter:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: color-mix(in srgb, var(--action-primary) 35%, transparent);
|
||||
}
|
||||
|
||||
.ctv-collections-picker-filter-active {
|
||||
background: color-mix(in srgb, var(--action-primary) 14%, transparent);
|
||||
border-color: color-mix(in srgb, var(--action-primary) 45%, transparent);
|
||||
color: var(--action-primary);
|
||||
}
|
||||
|
||||
.ctv-collections-picker-results {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user