Skip to main content

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. 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

Layout/Navbar.jsx
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 { 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

genresData
array
required
Array of genre objects with id and name properties

Features

Filter/Genre.jsx
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

Filter/Cast.jsx
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
    />
  );
}
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]);

Rating Filter

Slider-based rating range filter (0-10 scale).

Props

sliderStyles
object
required
MUI Slider style object

Implementation

Filter/Rating.jsx
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

1

Shareable URLs

Users can bookmark or share URLs with filters applied
2

Browser Navigation

Back/forward buttons work correctly with filter changes
3

SSR Compatible

Filters can be read on the server for initial page load
4

Type Safety

parseAsString, parseAsInteger, etc. provide type safety

API Integration

Search and filter components work with these API routes:
GET /api/search/query?query=inception
// Returns: { results: [...films] }

Styling

Filters use custom styled react-select components:
utils/inputStyles.js
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

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

Build docs developers (and LLMs) love