+
+
+
+ {state.status === 'error' && (
+
+
+ {state.error}
+
+
+
+ )}
+
+ {state.status === 'loading' ? (
+
+
+ Loading {kind.label.toLowerCase()}…
+
+ ) : state.items.length === 0 ? (
+
+
+ {query ? `No ${kind.label.toLowerCase()} match this search.` : `No ${kind.label.toLowerCase()} found.`}
+
+
+ ) : (
+ <>
+
+ {state.items.map((item) => (
+
+ ))}
+
+
+
+ {state.totalCount} {kind.label.toLowerCase()}
+
+
+ setPageNum((current) => Math.max(0, current - 1))}
+ size="sm"
+ title="Previous page"
+ >
+
+
+
+ Page {pageNum + 1} of {totalPages}
+
+ = totalPages}
+ onClick={() => setPageNum((current) => current + 1)}
+ size="sm"
+ title="Next page"
+ >
+
+
+
+ >
+ )}
+
+
+
+
+ Browsing indexed library items. Use the search box for a quick per-kind filter, or the global search for
+ results across every media kind.
+
+
+
+ );
+}
diff --git a/web/src/screens/SearchScreen.tsx b/web/src/screens/SearchScreen.tsx
new file mode 100644
index 000000000..cd1acf4fb
--- /dev/null
+++ b/web/src/screens/SearchScreen.tsx
@@ -0,0 +1,176 @@
+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 { navigateToPath } from '../routing';
+import { MediaPosterCard } from '../media/MediaPosterCard';
+
+const PAGE_SIZE = 50;
+
+interface GroupDef {
+ key: keyof SearchResults;
+ label: string;
+ // MediaBrowse slug for "See all"; null for kinds without a top-level browse page (seasons).
+ slug: string | null;
+}
+
+// Order + labels mirror the legacy Blazor search page.
+const GROUPS: GroupDef[] = [
+ { key: 'movies', label: 'Movies', slug: 'movies' },
+ { key: 'shows', label: 'TV Shows', slug: 'shows' },
+ { key: 'seasons', label: 'Seasons', slug: null },
+ { key: 'episodes', label: 'Episodes', slug: 'episodes' },
+ { key: 'artists', label: 'Artists', slug: 'artists' },
+ { key: 'musicVideos', label: 'Music Videos', slug: 'music-videos' },
+ { key: 'songs', label: 'Songs', slug: 'songs' },
+ { key: 'otherVideos', label: 'Other Videos', slug: 'other-videos' },
+ { key: 'images', label: 'Images', slug: 'images' },
+ { key: 'remoteStreams', label: 'Remote Streams', slug: 'remote-streams' }
+];
+
+type SearchState =
+ | { results: SearchResults; error: null; status: 'success' }
+ | { results: null; error: string; status: 'error' }
+ | { results: null; error: null; status: 'loading' };
+
+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