([]);
+ 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]);
+
+ const handleImport = useCallback(
+ async (tmdbId: number, type: "movie" | "tv") => {
+ const res = await fetch("/api/titles/import", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ tmdbId, type }),
+ });
+ const data = await res.json();
+ if (data.id) {
+ router.push(`/titles/${data.id}`);
+ }
+ },
+ [router],
+ );
+
+ 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) => (
+
+ handleImport(r.tmdbId, r.type)}
+ />
+
+ ))}
+
+ )}
+
+ {!loading && selectedGenre !== null && results.length === 0 && (
+
+ No titles found for this genre.
+
+ )}
+
+ );
+}
diff --git a/app/(pages)/explore/hero-banner.tsx b/app/(pages)/explore/hero-banner.tsx
new file mode 100644
index 0000000..01f72be
--- /dev/null
+++ b/app/(pages)/explore/hero-banner.tsx
@@ -0,0 +1,126 @@
+"use client";
+
+import { IconLoader2, IconPlus, IconStar } from "@tabler/icons-react";
+import { motion } from "motion/react";
+import Image from "next/image";
+import { useRouter } from "next/navigation";
+import { useState } from "react";
+import { tmdbImageUrl } from "@/lib/tmdb/image";
+
+interface HeroBannerProps {
+ tmdbId: number;
+ type: "movie" | "tv";
+ title: string;
+ overview: string;
+ backdropPath: string | null;
+ voteAverage: number;
+}
+
+export function HeroBanner({
+ tmdbId,
+ type,
+ title,
+ overview,
+ backdropPath,
+ voteAverage,
+}: HeroBannerProps) {
+ const router = useRouter();
+ const [importing, setImporting] = useState(false);
+ const backdropUrl = tmdbImageUrl(backdropPath, "w1280");
+
+ async function handleImport() {
+ setImporting(true);
+ try {
+ const res = await fetch("/api/titles/import", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ tmdbId, type }),
+ });
+ const data = await res.json();
+ if (data.id) {
+ router.push(`/titles/${data.id}`);
+ }
+ } finally {
+ setImporting(false);
+ }
+ }
+
+ return (
+
+
+ {backdropUrl ? (
+
+ ) : (
+
+ )}
+
+ {/* Gradient overlays */}
+
+
+
+ {/* Content */}
+
+
+
+
+
+
+ {type}
+
+ {voteAverage > 0 && (
+
+
+ {voteAverage.toFixed(1)}
+
+ )}
+
+ Trending today
+
+
+
+ {title}
+
+
+ {overview}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/(pages)/explore/loading.tsx b/app/(pages)/explore/loading.tsx
new file mode 100644
index 0000000..afd37ad
--- /dev/null
+++ b/app/(pages)/explore/loading.tsx
@@ -0,0 +1,45 @@
+import { TitleCardSkeleton } from "@/components/skeletons";
+import { Skeleton } from "@/components/ui/skeleton";
+
+export default function ExploreLoading() {
+ return (
+
+ {/* Hero skeleton */}
+
+
+
+
+ {/* 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
new file mode 100644
index 0000000..4d16f96
--- /dev/null
+++ b/app/(pages)/explore/page.tsx
@@ -0,0 +1,90 @@
+import { IconDeviceTv, IconFlame, IconMovie } from "@tabler/icons-react";
+import { getGenres, getPopular, getTrending } from "@/lib/tmdb/client";
+import { GenreBrowser } from "./genre-browser";
+import { HeroBanner } from "./hero-banner";
+import { TitleRow } from "./title-row";
+
+function mapResults(
+ results: {
+ id: number;
+ media_type?: string;
+ title?: string;
+ name?: string;
+ poster_path: string | null;
+ release_date?: string;
+ first_air_date?: string;
+ vote_average: number;
+ }[],
+ fallbackType: "movie" | "tv",
+) {
+ return results
+ .filter((r) => r.poster_path)
+ .map((r) => ({
+ tmdbId: r.id,
+ type: (r.media_type === "movie" || r.media_type === "tv"
+ ? r.media_type
+ : fallbackType) as "movie" | "tv",
+ title: r.title ?? r.name ?? "",
+ posterPath: r.poster_path,
+ releaseDate: r.release_date ?? r.first_air_date ?? null,
+ voteAverage: r.vote_average,
+ }));
+}
+
+export default async function ExplorePage() {
+ const [trending, popularMovies, popularTv, movieGenres, tvGenres] =
+ await Promise.all([
+ getTrending("all", "day"),
+ getPopular("movie"),
+ getPopular("tv"),
+ getGenres("movie"),
+ getGenres("tv"),
+ ]);
+
+ const trendingItems = mapResults(trending.results, "movie");
+ const popularMovieItems = mapResults(popularMovies.results, "movie");
+ const popularTvItems = mapResults(popularTv.results, "tv");
+
+ const heroTitle = trending.results.find(
+ (r) =>
+ r.backdrop_path && (r.media_type === "movie" || r.media_type === "tv"),
+ );
+
+ return (
+
+ {heroTitle && (
+
+ )}
+
+ }
+ items={trendingItems.slice(0, 20)}
+ />
+
+ }
+ items={popularMovieItems.slice(0, 20)}
+ />
+
+ }
+ items={popularTvItems.slice(0, 20)}
+ />
+
+
+
+ );
+}
diff --git a/app/(pages)/explore/title-row.tsx b/app/(pages)/explore/title-row.tsx
new file mode 100644
index 0000000..f693f0d
--- /dev/null
+++ b/app/(pages)/explore/title-row.tsx
@@ -0,0 +1,90 @@
+"use client";
+
+import { motion } from "motion/react";
+import { useRouter } from "next/navigation";
+import { useCallback } from "react";
+import { TitleCard } from "@/components/title-card";
+
+interface TitleRowItem {
+ tmdbId: number;
+ type: "movie" | "tv";
+ title: string;
+ posterPath: string | null;
+ releaseDate: string | null;
+ voteAverage: number;
+}
+
+interface TitleRowProps {
+ heading: string;
+ icon: React.ReactNode;
+ items: TitleRowItem[];
+}
+
+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 TitleRow({ heading, icon, items }: TitleRowProps) {
+ const router = useRouter();
+
+ const handleImport = useCallback(
+ async (tmdbId: number, type: "movie" | "tv") => {
+ const res = await fetch("/api/titles/import", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ tmdbId, type }),
+ });
+ const data = await res.json();
+ if (data.id) {
+ router.push(`/titles/${data.id}`);
+ }
+ },
+ [router],
+ );
+
+ if (items.length === 0) return null;
+
+ return (
+
+
+ {icon}
+
{heading}
+
+
+ {items.map((item) => (
+
+ handleImport(item.tmdbId, item.type)}
+ />
+
+ ))}
+
+
+ );
+}
diff --git a/app/(pages)/search/page.tsx b/app/(pages)/search/page.tsx
deleted file mode 100644
index c0c9579..0000000
--- a/app/(pages)/search/page.tsx
+++ /dev/null
@@ -1,107 +0,0 @@
-"use client";
-
-import { IconDeviceTv, IconMovie } from "@tabler/icons-react";
-import { useRouter } from "next/navigation";
-import { useCallback, useState } from "react";
-import { toast } from "sonner";
-import { SearchAutocomplete } from "@/components/search-autocomplete";
-import { TitleCard } from "@/components/title-card";
-
-interface SearchResult {
- tmdbId: number;
- type: "movie" | "tv";
- title: string;
- overview: string;
- releaseDate: string | null;
- posterPath: string | null;
- popularity: number;
- voteAverage: number;
-}
-
-export default function SearchPage() {
- const router = useRouter();
- const [results, setResults] = useState([]);
- const [loading, setLoading] = useState(false);
- const [importing, setImporting] = useState(null);
- const [searched, setSearched] = useState(false);
-
- const handleResults = useCallback((res: SearchResult[]) => {
- setResults(res);
- if (res.length > 0) setSearched(true);
- }, []);
-
- const handleLoading = useCallback((l: boolean) => {
- setLoading(l);
- if (l) setSearched(true);
- }, []);
-
- async function handleOpen(tmdbId: number, type: "movie" | "tv") {
- setImporting(tmdbId);
- try {
- const res = await fetch("/api/titles/import", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ tmdbId, type }),
- });
- const data = await res.json();
- if (data.id) {
- router.push(`/titles/${data.id}`);
- }
- } catch {
- toast.error("Failed to load title");
- } finally {
- setImporting(null);
- }
- }
-
- return (
-
-
-
Search
-
- Find movies and TV shows to track
-
-
-
-
-
- {!loading && searched && results.length === 0 && (
-
-
-
-
-
-
No results found
-
- )}
-
- {!loading && results.length > 0 && (
-
- {results.map((r) => (
-
-
handleOpen(r.tmdbId, r.type)}
- />
- {importing === r.tmdbId && (
-
- )}
-
- ))}
-
- )}
-
- );
-}
diff --git a/app/(pages)/setup/page.tsx b/app/(pages)/setup/page.tsx
index 84175b3..c7dee70 100644
--- a/app/(pages)/setup/page.tsx
+++ b/app/(pages)/setup/page.tsx
@@ -57,7 +57,8 @@ const steps = [
{
number: "3",
title: "Add it to your environment",
- description: "Set the TMDB_API_READ_ACCESS_TOKEN environment variable and restart Sofa.",
+ description:
+ "Set the TMDB_API_READ_ACCESS_TOKEN environment variable and restart Sofa.",
},
];
diff --git a/app/api/explore/discover/route.ts b/app/api/explore/discover/route.ts
new file mode 100644
index 0000000..dc98b93
--- /dev/null
+++ b/app/api/explore/discover/route.ts
@@ -0,0 +1,48 @@
+import { type NextRequest, NextResponse } from "next/server";
+import { isTmdbConfigured } from "@/lib/config";
+import { discover } from "@/lib/tmdb/client";
+
+export async function GET(req: NextRequest) {
+ if (!isTmdbConfigured()) {
+ return NextResponse.json(
+ {
+ error: "TMDB API key is not configured. Visit /setup for instructions.",
+ code: "TMDB_NOT_CONFIGURED",
+ },
+ { status: 503 },
+ );
+ }
+
+ const { searchParams } = req.nextUrl;
+ const type = searchParams.get("type") === "tv" ? "tv" : "movie";
+ const genre = searchParams.get("genre");
+ const sortBy = searchParams.get("sort_by") || "popularity.desc";
+ const page = searchParams.get("page") || "1";
+
+ const params: Record = {
+ sort_by: sortBy,
+ "vote_count.gte": "50",
+ };
+ if (genre) {
+ params.with_genres = genre;
+ }
+
+ const results = await discover(type, params, Number(page));
+
+ const filtered = results.results.filter((r) => r.poster_path);
+
+ return NextResponse.json({
+ results: filtered.map((r) => ({
+ tmdbId: r.id,
+ type,
+ title: r.title ?? r.name,
+ overview: r.overview,
+ releaseDate: r.release_date ?? r.first_air_date,
+ posterPath: r.poster_path,
+ popularity: r.popularity,
+ voteAverage: r.vote_average,
+ })),
+ page: results.page,
+ totalPages: results.total_pages,
+ });
+}
diff --git a/components/command-palette.tsx b/components/command-palette.tsx
index f9e4b54..c2c737f 100644
--- a/components/command-palette.tsx
+++ b/components/command-palette.tsx
@@ -94,10 +94,10 @@ export function CommandPalette() {
action: () => router.push("/dashboard"),
scope: "Navigation",
});
- registerShortcut("nav-search", {
- keys: ["g", "s"],
- description: "Go to search",
- action: () => router.push("/search"),
+ registerShortcut("nav-explore", {
+ keys: ["g", "e"],
+ description: "Go to explore",
+ action: () => router.push("/explore"),
scope: "Navigation",
});
}, [registerShortcut, setCommandPaletteOpen, setHelpOpen, router]);
@@ -279,12 +279,12 @@ export function CommandPalette() {
{
setCommandPaletteOpen(false);
- router.push("/search");
+ router.push("/explore");
}}
>
- Go to Search
- G S
+ Go to Explore
+ G E
{
diff --git a/components/mobile-tab-bar.tsx b/components/mobile-tab-bar.tsx
index 10231ac..3b669ce 100644
--- a/components/mobile-tab-bar.tsx
+++ b/components/mobile-tab-bar.tsx
@@ -1,6 +1,6 @@
"use client";
-import { IconHome, IconSearch, IconSettings } from "@tabler/icons-react";
+import { IconCompass, IconHome, IconSettings } from "@tabler/icons-react";
import { motion } from "motion/react";
import Link from "next/link";
import { usePathname } from "next/navigation";
@@ -8,7 +8,7 @@ import { useSession } from "@/lib/auth/client";
const tabs = [
{ href: "/dashboard", label: "Home", icon: IconHome },
- { href: "/search", label: "Search", icon: IconSearch },
+ { href: "/explore", label: "Explore", icon: IconCompass },
{ href: "/settings", label: "Settings", icon: IconSettings },
] as const;
diff --git a/components/nav-bar.tsx b/components/nav-bar.tsx
index 4685578..abf515d 100644
--- a/components/nav-bar.tsx
+++ b/components/nav-bar.tsx
@@ -10,7 +10,7 @@ import { signOut, useSession } from "@/lib/auth/client";
const navLinks = [
{ href: "/dashboard", label: "Home" },
- { href: "/search", label: "Search" },
+ { href: "/explore", label: "Explore" },
] as const;
export function NavBar() {
diff --git a/lib/tmdb/client.ts b/lib/tmdb/client.ts
index c00081d..bf65c12 100644
--- a/lib/tmdb/client.ts
+++ b/lib/tmdb/client.ts
@@ -1,4 +1,5 @@
import type {
+ TmdbGenreListResponse,
TmdbMovieDetails,
TmdbRecommendationResponse,
TmdbSearchResponse,
@@ -18,6 +19,7 @@ function getApiKey() {
async function tmdbFetch(
path: string,
params?: Record,
+ fetchOptions?: RequestInit,
): Promise {
const url = new URL(`${BASE_URL}${path}`);
if (params) {
@@ -27,9 +29,11 @@ async function tmdbFetch(
}
const res = await fetch(url.toString(), {
+ ...fetchOptions,
headers: {
Authorization: `Bearer ${getApiKey()}`,
Accept: "application/json",
+ ...fetchOptions?.headers,
},
});
@@ -90,4 +94,41 @@ export async function getSimilar(tmdbId: number, type: "movie" | "tv") {
return tmdbFetch(`/${type}/${tmdbId}/similar`);
}
+export async function getTrending(
+ mediaType: "all" | "movie" | "tv",
+ timeWindow: "day" | "week" = "day",
+) {
+ return tmdbFetch(
+ `/trending/${mediaType}/${timeWindow}`,
+ undefined,
+ { next: { revalidate: 3600 } },
+ );
+}
+
+export async function getPopular(type: "movie" | "tv", page = 1) {
+ return tmdbFetch(
+ `/${type}/popular`,
+ { page: String(page) },
+ { next: { revalidate: 3600 } },
+ );
+}
+
+export async function getGenres(type: "movie" | "tv") {
+ return tmdbFetch(`/genre/${type}/list`, undefined, {
+ next: { revalidate: 86400 },
+ });
+}
+
+export async function discover(
+ type: "movie" | "tv",
+ params: Record,
+ page = 1,
+) {
+ return tmdbFetch(
+ `/discover/${type}`,
+ { ...params, page: String(page) },
+ { next: { revalidate: 3600 } },
+ );
+}
+
export { tmdbImageUrl } from "./image";
diff --git a/lib/tmdb/types.ts b/lib/tmdb/types.ts
index 6726fdc..e24a4b4 100644
--- a/lib/tmdb/types.ts
+++ b/lib/tmdb/types.ts
@@ -110,3 +110,12 @@ export interface TmdbRecommendationResponse {
total_pages: number;
total_results: number;
}
+
+export interface TmdbGenre {
+ id: number;
+ name: string;
+}
+
+export interface TmdbGenreListResponse {
+ genres: TmdbGenre[];
+}