diff --git a/app/(pages)/explore/filterable-title-row.tsx b/app/(pages)/explore/filterable-title-row.tsx new file mode 100644 index 0000000..012fafc --- /dev/null +++ b/app/(pages)/explore/filterable-title-row.tsx @@ -0,0 +1,185 @@ +"use client"; + +import { WheelGesturesPlugin } from "embla-carousel-wheel-gestures"; +import { motion } from "motion/react"; +import { useEffect, useState } from "react"; +import { TitleCardSkeleton } from "@/components/skeletons"; +import { TitleCard } from "@/components/title-card"; +import { + Carousel, + CarouselContent, + CarouselItem, +} from "@/components/ui/carousel"; + +interface Genre { + id: number; + name: string; +} + +interface TitleRowItem { + tmdbId: number; + type: "movie" | "tv"; + title: string; + posterPath: string | null; + releaseDate: string | null; + voteAverage: number; +} + +interface FilterableTitleRowProps { + heading: string; + icon: React.ReactNode; + mediaType: "movie" | "tv"; + defaultItems: TitleRowItem[]; + genres: Genre[]; +} + +const staggerContainer = { + hidden: {}, + visible: { transition: { staggerChildren: 0.05 } }, +}; + +const staggerItem = { + hidden: { opacity: 0, y: 12, scale: 0.98 }, + visible: { + opacity: 1, + y: 0, + scale: 1, + transition: { type: "spring" as const, stiffness: 300, damping: 24 }, + }, +}; + +export function FilterableTitleRow({ + heading, + icon, + mediaType, + defaultItems, + genres, +}: FilterableTitleRowProps) { + const [selectedGenre, setSelectedGenre] = useState(null); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + + const items = selectedGenre === null ? defaultItems : results; + + useEffect(() => { + if (selectedGenre === null) { + setResults([]); + return; + } + + let cancelled = false; + setLoading(true); + + fetch( + `/api/explore/discover?type=${mediaType}&genre=${selectedGenre}&sort_by=popularity.desc`, + ) + .then((r) => r.json()) + .then((data) => { + if (!cancelled) { + setResults(data.results ?? []); + } + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [selectedGenre, mediaType]); + + function toggleGenre(genreId: number) { + setSelectedGenre(selectedGenre === genreId ? null : genreId); + } + + return ( +
+
+ {icon} +

{heading}

+
+ + {/* Genre chips */} +
+ {genres.map((genre) => ( + + ))} +
+ + {/* Loading skeleton */} + {loading && ( +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ +
+ ))} +
+ )} + + {/* Empty state */} + {!loading && selectedGenre !== null && items.length === 0 && ( +

+ No titles found for this genre. +

+ )} + + {/* Carousel */} + {!loading && items.length > 0 && ( + + + + {items.slice(0, 20).map((item) => ( + + + + + + ))} + + + + )} +
+ ); +} diff --git a/app/(pages)/explore/genre-browser.tsx b/app/(pages)/explore/genre-browser.tsx deleted file mode 100644 index b8da92c..0000000 --- a/app/(pages)/explore/genre-browser.tsx +++ /dev/null @@ -1,174 +0,0 @@ -"use client"; - -import { motion } from "motion/react"; -import { useEffect, useState } from "react"; -import { TitleCardSkeleton } from "@/components/skeletons"; -import { TitleCard } from "@/components/title-card"; - -interface Genre { - id: number; - name: string; -} - -interface DiscoverResult { - tmdbId: number; - type: "movie" | "tv"; - title: string; - posterPath: string | null; - releaseDate: string | null; - voteAverage: number; -} - -interface GenreBrowserProps { - movieGenres: Genre[]; - tvGenres: Genre[]; -} - -const staggerContainer = { - hidden: {}, - visible: { transition: { staggerChildren: 0.04 } }, -}; - -const staggerItem = { - hidden: { opacity: 0, y: 12, scale: 0.98 }, - visible: { - opacity: 1, - y: 0, - scale: 1, - transition: { type: "spring" as const, stiffness: 300, damping: 24 }, - }, -}; - -export function GenreBrowser({ movieGenres, tvGenres }: GenreBrowserProps) { - const [mediaType, setMediaType] = useState<"movie" | "tv">("movie"); - const [selectedGenre, setSelectedGenre] = useState(null); - const [results, setResults] = useState([]); - const [loading, setLoading] = useState(false); - - const genres = mediaType === "movie" ? movieGenres : tvGenres; - - function switchMediaType(type: "movie" | "tv") { - setMediaType(type); - setSelectedGenre(null); - setResults([]); - } - - useEffect(() => { - if (selectedGenre === null) { - setResults([]); - return; - } - - let cancelled = false; - setLoading(true); - - fetch( - `/api/explore/discover?type=${mediaType}&genre=${selectedGenre}&sort_by=popularity.desc`, - ) - .then((r) => r.json()) - .then((data) => { - if (!cancelled) { - setResults(data.results ?? []); - } - }) - .finally(() => { - if (!cancelled) setLoading(false); - }); - - return () => { - cancelled = true; - }; - }, [selectedGenre, mediaType]); - - return ( -
-
-

Browse by Genre

-
- - -
-
- - {/* Genre chips */} -
- {genres.map((genre) => ( - - ))} -
- - {/* Results */} - {loading && ( -
- {Array.from({ length: 10 }).map((_, i) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: static skeleton placeholders - - ))} -
- )} - - {!loading && results.length > 0 && ( - - {results.slice(0, 20).map((r) => ( - - - - ))} - - )} - - {!loading && selectedGenre !== null && results.length === 0 && ( -

- No titles found for this genre. -

- )} -
- ); -} diff --git a/app/(pages)/explore/loading.tsx b/app/(pages)/explore/loading.tsx index afd37ad..72039bc 100644 --- a/app/(pages)/explore/loading.tsx +++ b/app/(pages)/explore/loading.tsx @@ -1,6 +1,33 @@ import { TitleCardSkeleton } from "@/components/skeletons"; import { Skeleton } from "@/components/ui/skeleton"; +function TitleRowSkeleton({ withGenreChips }: { withGenreChips?: boolean }) { + return ( +
+ + {withGenreChips && ( +
+ {Array.from({ length: 7 }).map((_, i) => ( + + ))} +
+ )} +
+ {Array.from({ length: 6 }).map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: static skeleton placeholders +
+ +
+ ))} +
+
+ ); +} + export default function ExploreLoading() { return (
@@ -9,37 +36,9 @@ export default function ExploreLoading() {
- {/* Title row skeletons */} - {[1, 2, 3].map((section) => ( -
- -
- {Array.from({ length: 6 }).map((_, i) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: static skeleton placeholders -
- -
- ))} -
-
- ))} - - {/* Genre browser skeleton */} -
-
- - -
-
- {Array.from({ length: 8 }).map((_, i) => ( - - ))} -
-
+ + + ); } diff --git a/app/(pages)/explore/page.tsx b/app/(pages)/explore/page.tsx index 7e5b341..d389710 100644 --- a/app/(pages)/explore/page.tsx +++ b/app/(pages)/explore/page.tsx @@ -1,7 +1,7 @@ import { IconDeviceTv, IconFlame, IconMovie } from "@tabler/icons-react"; import { getGenres, getPopular, getTrending } from "@/lib/tmdb/client"; import { tmdbImageUrl } from "@/lib/tmdb/image"; -import { GenreBrowser } from "./genre-browser"; +import { FilterableTitleRow } from "./filterable-title-row"; import { HeroBanner } from "./hero-banner"; import { TitleRow } from "./title-row"; @@ -70,21 +70,20 @@ export default async function ExplorePage() { items={trendingItems.slice(0, 20)} /> - } - items={popularMovieItems.slice(0, 20)} + mediaType="movie" + defaultItems={popularMovieItems.slice(0, 20)} + genres={movieGenres.genres} /> - } - items={popularTvItems.slice(0, 20)} - /> - - ); diff --git a/components/skeletons.tsx b/components/skeletons.tsx index ff358b2..68dd86c 100644 --- a/components/skeletons.tsx +++ b/components/skeletons.tsx @@ -4,9 +4,9 @@ export function TitleCardSkeleton() { return (
-
+
- +
);