feat(web): media browse, global search & trash screens (#141)
- MediaBrowseScreen (/app/media): one parameterized screen for all 9 top-level kinds with an in-screen kind switcher, per-kind search box, 100/page paging - SearchScreen (/app/search): grouped per-kind results with counts + 'See all'; TopBar search input now navigates here - TrashScreen (/app/trash): state:FileNotFound results, multi-select + Empty Trash via DELETE /api/media-items and /api/maintenance/empty_trash (confirm dialogs) - Shared media/mediaKinds (icon/label maps, hueOf, duration helpers) + MediaPosterCard; ChannelBuilder and CollectionsScreen now reuse the single-source maps (their exhaustive Record<LibraryBrowseMediaType> had to cover the 6 new kinds anyway) - api: search.ts, mediaItems.ts, maintenance.ts (+ URL-assert tests) - One consolidated 'Browse' nav entry with an in-screen kind switcher instead of 9 per-kind nav rows (deviation from Blazor's per-kind Media links) - Nav: Media group gains Browse/Search/Trash; Settings Classic-UI help trimmed Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,9 +11,12 @@ export * from './guide';
|
||||
export * from './libraries';
|
||||
export * from './libraryBrowse';
|
||||
export * from './logs';
|
||||
export * from './maintenance';
|
||||
export * from './mediaItems';
|
||||
export * from './pickers';
|
||||
export * from './playouts';
|
||||
export * from './schedules';
|
||||
export * from './search';
|
||||
export * from './settings';
|
||||
export * from './troubleshoot';
|
||||
export * from './useChannelsQuery';
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { emptyTrash } from './maintenance';
|
||||
|
||||
describe('emptyTrash', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('POSTs /api/maintenance/empty_trash', async () => {
|
||||
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(new Response(null, { status: 200 }));
|
||||
|
||||
await emptyTrash();
|
||||
|
||||
const [url, init] = fetchSpy.mock.calls[0];
|
||||
expect(url).toBe('/api/maintenance/empty_trash');
|
||||
expect((init?.method ?? 'GET').toUpperCase()).toBe('POST');
|
||||
});
|
||||
|
||||
it('rejects with the ApiError status on failure', async () => {
|
||||
vi.spyOn(window, 'fetch').mockResolvedValue(new Response(null, { status: 500 }));
|
||||
|
||||
await expect(emptyTrash()).rejects.toMatchObject({ status: 500 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ApiError, request } from './client';
|
||||
|
||||
export function emptyTrash(): Promise<void> {
|
||||
return request<void>('/api/maintenance/empty_trash', { method: 'POST' });
|
||||
}
|
||||
|
||||
export function messageFromMaintenanceError(error: unknown, fallback = 'Maintenance action failed'): string {
|
||||
if (error instanceof ApiError) {
|
||||
return error.detail ?? error.message;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { deleteMediaItems } from './mediaItems';
|
||||
|
||||
describe('deleteMediaItems', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('DELETEs /api/media-items with the ids in the body', async () => {
|
||||
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(new Response(null, { status: 204 }));
|
||||
|
||||
await deleteMediaItems([1, 2, 3]);
|
||||
|
||||
const [url, init] = fetchSpy.mock.calls[0];
|
||||
expect(url).toBe('/api/media-items');
|
||||
expect((init?.method ?? 'GET').toUpperCase()).toBe('DELETE');
|
||||
expect(JSON.parse(init?.body as string)).toEqual({ ids: [1, 2, 3] });
|
||||
});
|
||||
|
||||
it('rejects with the ApiError status on failure', async () => {
|
||||
vi.spyOn(window, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify({ status: 422, title: 'Validation failed' }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status: 422
|
||||
})
|
||||
);
|
||||
|
||||
await expect(deleteMediaItems([])).rejects.toMatchObject({ status: 422 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ApiError, request } from './client';
|
||||
|
||||
export function deleteMediaItems(ids: number[]): Promise<void> {
|
||||
return request<void>('/api/media-items', { body: { ids }, method: 'DELETE' });
|
||||
}
|
||||
|
||||
export function messageFromMediaItemsError(error: unknown, fallback = 'Unable to delete media items'): string {
|
||||
if (error instanceof ApiError) {
|
||||
return error.detail ?? error.message;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getSearchResults } from './search';
|
||||
|
||||
const emptyGroup = { totalCount: 0, items: [] };
|
||||
const sampleResults = {
|
||||
movies: emptyGroup,
|
||||
shows: emptyGroup,
|
||||
seasons: emptyGroup,
|
||||
artists: emptyGroup,
|
||||
episodes: emptyGroup,
|
||||
musicVideos: emptyGroup,
|
||||
songs: emptyGroup,
|
||||
otherVideos: emptyGroup,
|
||||
images: emptyGroup,
|
||||
remoteStreams: emptyGroup
|
||||
};
|
||||
|
||||
describe('getSearchResults', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('GETs /api/search with the query', async () => {
|
||||
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify(sampleResults), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status: 200
|
||||
})
|
||||
);
|
||||
|
||||
await getSearchResults({ query: 'star' });
|
||||
|
||||
const [url, init] = fetchSpy.mock.calls[0];
|
||||
expect(url).toBe('/api/search?query=star');
|
||||
expect((init?.method ?? 'GET').toUpperCase()).toBe('GET');
|
||||
});
|
||||
|
||||
it('includes pageSize when provided', async () => {
|
||||
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify(sampleResults), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status: 200
|
||||
})
|
||||
);
|
||||
|
||||
await getSearchResults({ query: 'star wars', pageSize: 50 });
|
||||
|
||||
const [url] = fetchSpy.mock.calls[0];
|
||||
expect(url).toBe('/api/search?query=star+wars&pageSize=50');
|
||||
});
|
||||
|
||||
it('rejects with the ApiError status on failure', async () => {
|
||||
vi.spyOn(window, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify({ status: 422, title: 'Validation failed' }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status: 422
|
||||
})
|
||||
);
|
||||
|
||||
await expect(getSearchResults({ query: '' })).rejects.toMatchObject({ status: 422 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ApiError, request } from './client';
|
||||
import type { components } from './generated/v1';
|
||||
|
||||
export type SearchResults = components['schemas']['SearchResultsResponseModel'];
|
||||
export type SearchResultGroup = components['schemas']['SearchResultGroupResponseModel'];
|
||||
|
||||
export interface GetSearchResultsParams {
|
||||
query: string;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export function getSearchResults(params: GetSearchResultsParams): Promise<SearchResults> {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set('query', params.query);
|
||||
|
||||
if (params.pageSize != null) {
|
||||
searchParams.set('pageSize', String(params.pageSize));
|
||||
}
|
||||
|
||||
return request<SearchResults>(`/api/search?${searchParams.toString()}`);
|
||||
}
|
||||
|
||||
export function messageFromSearchError(error: unknown, fallback = 'Unable to search library'): string {
|
||||
if (error instanceof ApiError) {
|
||||
return error.detail ?? error.message;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
Reference in New Issue
Block a user