Overview
Search components provide powerful filtering and discovery features for movies and TV shows. The system includes a smart autocomplete search bar, advanced filters for genres, cast, ratings, and more.
SearchBar
Intelligent search component with autocomplete, keyboard navigation, and debounced API calls.
Props
placeholder
string
default: "Type / to search"
Placeholder text shown in the search input
Key Features
Autocomplete Real-time suggestions as you type with keyboard navigation
Keyboard Shortcuts Press / to focus, Esc to blur, arrow keys to navigate
Debounced Search 300ms debounce to reduce API calls
Typewriter Effect Animated placeholder text rotation
Usage Example
import { SearchBar } from "@/components/Layout/SearchBar" ;
export default function Navbar () {
return (
< nav >
< SearchBar placeholder = "Type / to search" />
</ nav >
);
}
State Management
The SearchBar uses nuqs for URL state management:
const [ query , setQuery ] = useQueryState ( "query" , parseAsString );
const [ searchInput , setSearchInput ] = useState ( "" );
const [ debouncedQuery , setDebouncedQuery ] = useState ( "" );
Autocomplete Implementation
const debouncedSearch = useCallback (
debounce (( value ) => {
const trimmedValue = value . trim ();
if ( trimmedValue ) {
setDebouncedQuery ( trimmedValue . replace ( / \s + / g , "+" ));
} else {
setDebouncedQuery ( "" );
}
}, 300 ),
[]
);
const { data : autocompleteResults , isLoading } = useSWR (
debouncedQuery ? `/api/search/query?query= ${ debouncedQuery } ` : null ,
( endpoint ) => axios . get ( endpoint ). then (({ data }) => data )
);
const autocompleteData = autocompleteResults ?. results
?. slice ( 0 , 10 )
. filter (( item , index , self ) => {
const title = ( item . title ?? item . name ). toLowerCase ();
return index === self . findIndex (
( i ) => ( i . title ?? i . name ). toLowerCase () === title
);
}) || [];
useEffect (() => {
const handleKeyDown = ( e ) => {
if ( e . key === "/" ) {
e . preventDefault ();
searchRef . current . focus ();
}
if ( e . key === "Escape" ) {
searchRef . current . blur ();
}
if ( e . key === "ArrowDown" ) {
e . preventDefault ();
setHighlightedIndex (( prev ) =>
prev === totalItems - 1 ? 0 : prev + 1
);
}
if ( e . key === "ArrowUp" ) {
e . preventDefault ();
setHighlightedIndex (( prev ) =>
prev <= 0 ? totalItems - 1 : prev - 1
);
}
};
document . addEventListener ( "keydown" , handleKeyDown );
return () => document . removeEventListener ( "keydown" , handleKeyDown );
}, [ autocompleteData , highlightedIndex ]);
Filter Components
Advanced filtering system for discovering films by multiple criteria.
Genre Filter
Multi-select genre filter with AND/OR logic.
Props
Array of genre objects with id and name properties
Features
import { useQueryState , parseAsString } from "nuqs" ;
import Select from "react-select" ;
export default function Genre ({ genresData }) {
const [ withGenres , setWithGenres ] = useQueryState ( "with_genres" , parseAsString );
const [ toggleSeparation , setToggleSeparation ] = useState ( AND_SEPARATION );
const separation = toggleSeparation === AND_SEPARATION ? "," : "|" ;
const handleGenreChange = ( selectedOption ) => {
const value = selectedOption . map (( option ) => option . value );
if ( value . length === 0 ) {
setWithGenres ( null );
} else {
setWithGenres ( value . join ( separation ));
}
};
return (
< section >
{ /* AND/OR Toggle */ }
< div className = "flex justify-between" >
< span > Genre </ span >
< div >
< button onClick = { () => handleSeparator ( AND_SEPARATION ) } > AND </ button >
< button onClick = { () => handleSeparator ( OR_SEPARATION ) } > OR </ button >
</ div >
</ div >
{ /* Multi-select */ }
< Select
options = { genresOptions }
onChange = { handleGenreChange }
value = { genre }
isMulti
/>
</ section >
);
}
AND vs OR Logic
AND (,): Films must have ALL selected genres
OR (|): Films can have ANY selected genre
Example: with_genres=28,12 (Action AND Adventure) vs with_genres=28|12 (Action OR Adventure)
Cast Filter
Asynchronous actor search with multi-select.
Implementation
import AsyncSelect from "react-select/async" ;
import { debounce } from "@mui/material" ;
export default function Cast () {
const [ withCast , setWithCast ] = useQueryState ( "with_cast" , parseAsString );
const [ cast , setCast ] = useState ([]);
const castsLoadOptions = debounce ( async ( inputValue , callback ) => {
const { data } = await axios . get ( `/api/search/person` , {
params: { query: inputValue },
});
const options = data . results . map (( person ) => ({
value: person . id ,
label: person . name ,
}));
callback ( options );
}, 1000 );
const handleCastChange = ( selectedOption ) => {
const value = selectedOption . map (( option ) => option . value );
setWithCast ( value . length === 0 ? null : value . join ( separation ));
};
return (
< AsyncSelect
noOptionsMessage = { () => "Type to search" }
loadingMessage = { () => "Searching..." }
loadOptions = { castsLoadOptions }
onChange = { handleCastChange }
value = { cast }
placeholder = "Search actor..."
isMulti
/>
);
}
Cast Hydration
Debouncing
When loading from URL parameters, the component fetches full actor details: useEffect (() => {
if ( withCast ) {
const splitted = withCast . split ( separation );
Promise . all (
splitted . map (( castId ) =>
axios . get ( `/api/person/ ${ castId } ` ). then (({ data }) => data )
)
). then (( responses ) => {
const searchCast = responses . map (( cast ) => ({
value: cast . id ,
label: cast . name ,
}));
setCast ( searchCast );
});
}
}, [ withCast , separation ]);
Search requests are debounced by 1000ms to prevent excessive API calls: const castsLoadOptions = debounce ( async ( inputValue , callback ) => {
// ... fetch logic
}, 1000 );
Rating Filter
Slider-based rating range filter (0-10 scale).
Props
Implementation
import { Slider } from "@mui/material" ;
import { useQueryState , parseAsString } from "nuqs" ;
export default function Rating ({ sliderStyles }) {
const [ ratingParam , setRatingParam ] = useQueryState ( "rating" , parseAsString );
const [ ratingSlider , setRatingSlider ] = useState ([ 0 , 10 ]);
const handleRatingChange = ( event , newValue ) => {
if ( ! newValue ) {
setRatingParam ( null );
} else {
setRatingParam ( ` ${ newValue [ 0 ] } .. ${ newValue [ 1 ] } ` );
}
};
const ratingMarks = [
{ value: 0 , label: ratingSlider [ 0 ] },
{ value: 10 , label: ratingSlider [ 1 ] },
];
return (
< section >
< span > Rating </ span >
< Slider
value = { ratingSlider }
onChange = { ( e , newValue ) => setRatingSlider ( newValue ) }
onChangeCommitted = { handleRatingChange }
step = { 0.1 }
min = { 0 }
max = { 10 }
marks = { ratingMarks }
sx = { sliderStyles }
/>
</ section >
);
}
The slider uses onChange for immediate visual feedback and onChangeCommitted for updating the URL, reducing unnecessary re-renders during dragging.
Available Filters
Popcorn Vision includes these filter components:
Genre Multi-select with AND/OR logic
Cast Async search for actors
Crew Search directors, writers, etc.
Company Filter by production company
Network TV network filter (TV shows only)
Keyword Filter by TMDB keywords
Language Filter by original language
Rating Range slider (0-10)
Rating Count Minimum number of votes
Release Date Date range picker
URL State Management
All filters use nuqs for managing URL query parameters:
import { useQueryState , parseAsString } from "nuqs" ;
const [ withGenres , setWithGenres ] = useQueryState ( "with_genres" , parseAsString );
const [ rating , setRating ] = useQueryState ( "rating" , parseAsString );
const [ query , setQuery ] = useQueryState ( "query" , parseAsString );
Benefits
Shareable URLs
Users can bookmark or share URLs with filters applied
Browser Navigation
Back/forward buttons work correctly with filter changes
SSR Compatible
Filters can be read on the server for initial page load
Type Safety
parseAsString, parseAsInteger, etc. provide type safety
API Integration
Search and filter components work with these API routes:
Autocomplete Search
Person Search
Filter Search
GET / api / search / query ? query = inception
// Returns: { results: [...films] }
Styling
Filters use custom styled react-select components:
export const inputStyles = {
control : ( styles ) => ({
... styles ,
backgroundColor: "#1a1f2e" ,
borderColor: "#2a3142" ,
"&:hover" : {
borderColor: "#3a4152" ,
},
}),
menu : ( styles ) => ({
... styles ,
backgroundColor: "#1a1f2e" ,
}),
option : ( styles , { isFocused , isSelected }) => ({
... styles ,
backgroundColor: isSelected
? "#3b82f6"
: isFocused
? "#2a3142"
: "transparent" ,
}),
};
Key Files
Search Components
Constants
src/components/
├── Layout/
│ └── SearchBar.jsx # Main search component
└── Search/
└── Filter/
├── Cast.jsx # Actor filter
├── Crew.jsx # Director/writer filter
├── Genre.jsx # Genre multi-select
├── Rating.jsx # Rating slider
├── RatingCount.jsx # Vote count filter
├── ReleaseDate.jsx # Date range picker
├── Company.jsx # Production company
├── Network.jsx # TV network
├── Keyword.jsx # Keyword search
└── Language.jsx # Language filter