diff --git a/app/(pages)/dashboard/page.tsx b/app/(pages)/dashboard/page.tsx index f9ce3fa..7211b26 100644 --- a/app/(pages)/dashboard/page.tsx +++ b/app/(pages)/dashboard/page.tsx @@ -154,10 +154,10 @@ export default function DashboardPage() {

- Start searching + Start exploring )} diff --git a/app/(pages)/explore/genre-browser.tsx b/app/(pages)/explore/genre-browser.tsx new file mode 100644 index 0000000..db6d180 --- /dev/null +++ b/app/(pages)/explore/genre-browser.tsx @@ -0,0 +1,190 @@ +"use client"; + +import { motion } from "motion/react"; +import { useRouter } from "next/navigation"; +import { useCallback, 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 router = useRouter(); + 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]); + + 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 ? ( + {title} + ) : ( +
+ )} + + {/* 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 && ( -
-
-
- - Loading - -
-
- )} -
- ))} -
- )} -
- ); -} 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[]; +}