diff --git a/README.md b/README.md index e215bc4..28367a2 100644 --- a/README.md +++ b/README.md @@ -34,3 +34,33 @@ You can check out [the Next.js GitHub repository](https://github.com/vercel/next The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. + +## Development with Docker (recommended for full features) + +The project uses Postgres for caching generated movie insight cards. For the best experience (including persistent cache and automatic migrations): + +```bash +# Start everything (app + database) +docker compose -f docker-compose.dev.yml up --build +``` + +- Open http://localhost:3000 +- The first time it will run DB migrations automatically. +- Source code is mounted for live reload (`npm run dev` inside the container). +- Stop with `docker compose -f docker-compose.dev.yml down` + +Environment variables (TMDB_API_KEY, LLM_API_KEY, etc.) are read from your `.env` file. + +If you prefer running without Docker: + +```bash +npm run dev +``` + +> Note: Without the database the insight cards will still generate (they just won't be cached), thanks to graceful cache handling. + +## Production + +```bash +docker compose -f docker-compose.yml up --build +``` diff --git a/app/api/movie/[id]/route.ts b/app/api/movie/[id]/route.ts new file mode 100644 index 0000000..a94afce --- /dev/null +++ b/app/api/movie/[id]/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getMovieDetails } from "@/lib/tmdb"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + const movieId = Number(id); + + if (!Number.isFinite(movieId) || movieId <= 0) { + return NextResponse.json({ error: "Invalid movie ID" }, { status: 400 }); + } + + try { + const movie = await getMovieDetails(movieId); + return NextResponse.json(movie); + } catch (error) { + console.error("Movie details fetch failed:", error); + return NextResponse.json( + { error: "Failed to load movie details" }, + { status: 500 }, + ); + } +} diff --git a/app/page.tsx b/app/page.tsx index 4f93bed..11c811c 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,29 +1,133 @@ +"use client"; + +import { useState } from "react"; import { SearchBar } from "@/components/SearchBar"; +import { MovieHeader } from "@/components/MovieHeader"; +import { MovieInsightPanel } from "@/components/MovieInsightPanel"; +import type { TmdbMovieDetails, TmdbSearchResult } from "@/lib/tmdb"; export default function HomePage() { + const [selectedMovie, setSelectedMovie] = useState( + null, + ); + const [movieDetails, setMovieDetails] = useState( + null, + ); + const [isLoadingMovie, setIsLoadingMovie] = useState(false); + const [movieError, setMovieError] = useState(null); + + const handleSelect = async (movie: TmdbSearchResult) => { + setSelectedMovie(movie); + setMovieDetails(null); + setMovieError(null); + setIsLoadingMovie(true); + + try { + const res = await fetch(`/api/movie/${movie.id}`); + if (!res.ok) { + throw new Error("Failed to load movie details"); + } + const details: TmdbMovieDetails = await res.json(); + setMovieDetails(details); + } catch (err) { + setMovieError( + err instanceof Error ? err.message : "Failed to load movie details", + ); + } finally { + setIsLoadingMovie(false); + } + }; + + const clearSelection = () => { + setSelectedMovie(null); + setMovieDetails(null); + setIsLoadingMovie(false); + setMovieError(null); + }; + + const hasFullMovie = movieDetails != null; + return ( -
-
-
-

- Film Intel -

-

- Movie insight you won't find in one click -

-

- Ratings explained, skip guides, sensitivity warnings, and deep - production context for any film. -

+ <> + {/* Compact navbar: title + slogan on left, search on right */} + - -
+
+ {!selectedMovie && ( +
+

+ Search for any film using the bar above. We'll generate + ratings explained, skip guides, sensitivity warnings, production + facts, and more. +

+
+ )} -
+ {selectedMovie && ( +
+ {hasFullMovie ? ( + + ) : ( +
+
+

+ {selectedMovie.title} + {selectedMovie.release_date ? ( + + ({selectedMovie.release_date.slice(0, 4)}) + + ) : null} +

+ {selectedMovie.overview && ( +

+ {selectedMovie.overview} +

+ )} + {isLoadingMovie && ( +

+ Loading extended details... +

+ )} + {movieError && ( +

+ {movieError}. Card content will still load. +

+ )} +
+
+ )} + + g.name) ?? []} + /> +
+ )} +
+ +
AI-generated content for informational purposes. Verify timestamps on your copy.
-
+ ); } \ No newline at end of file diff --git a/components/MovieHeader.tsx b/components/MovieHeader.tsx index 9cce01d..dc6e7cc 100644 --- a/components/MovieHeader.tsx +++ b/components/MovieHeader.tsx @@ -3,20 +3,37 @@ import Link from "next/link"; import { ArrowLeft } from "lucide-react"; import { getPosterUrl, getUsCertification, type TmdbMovieDetails } from "@/lib/tmdb"; -export function MovieHeader({ movie }: { movie: TmdbMovieDetails }) { +export function MovieHeader({ + movie, + onClear, +}: { + movie: TmdbMovieDetails; + onClear?: () => void; +}) { const poster = getPosterUrl(movie.poster_path, "w500"); const year = movie.release_date?.slice(0, 4); const certification = getUsCertification(movie); return (
- - - Back to search - + {onClear ? ( + + ) : ( + + + Back to search + + )}
diff --git a/components/SearchBar.tsx b/components/SearchBar.tsx index d0c474c..e6bd5bb 100644 --- a/components/SearchBar.tsx +++ b/components/SearchBar.tsx @@ -3,21 +3,21 @@ import { Search } from "lucide-react"; import Link from "next/link"; import { useEffect, useRef, useState } from "react"; -import { getPosterUrl } from "@/lib/tmdb"; +import { getPosterUrl, type TmdbSearchResult } from "@/lib/tmdb"; import { cn } from "@/lib/utils"; -interface SearchResult { - id: number; - title: string; - release_date: string; - poster_path: string | null; - overview: string; -} - -export function SearchBar({ autoFocus = false }: { autoFocus?: boolean }) { +export function SearchBar({ + autoFocus = false, + onSelect, + compact = false, +}: { + autoFocus?: boolean; + onSelect?: (movie: TmdbSearchResult) => void; + compact?: boolean; +}) { const containerRef = useRef(null); const [query, setQuery] = useState(""); - const [results, setResults] = useState([]); + const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); const [open, setOpen] = useState(false); @@ -34,7 +34,7 @@ export function SearchBar({ autoFocus = false }: { autoFocus?: boolean }) { `/api/search?q=${encodeURIComponent(query)}`, { signal: controller.signal }, ); - const data = (await response.json()) as { results: SearchResult[] }; + const data = (await response.json()) as { results: TmdbSearchResult[] }; setResults(data.results ?? []); setOpen(true); } catch (error) { @@ -69,10 +69,15 @@ export function SearchBar({ autoFocus = false }: { autoFocus?: boolean }) { return (
- + query.trim() && setOpen(true)} placeholder="Search for a movie..." - className="w-full rounded-2xl border border-white/10 bg-white/5 py-4 pl-12 pr-4 text-base text-white outline-none transition focus:border-amber-400/40 focus:bg-white/[0.07] focus:ring-2 focus:ring-amber-400/20" + className={cn( + "w-full border border-white/10 bg-white/5 text-white outline-none transition focus:border-amber-400/40 focus:bg-white/[0.07] focus:ring-2 focus:ring-amber-400/20", + compact + ? "rounded-xl py-2 pl-9 pr-3 text-sm" + : "rounded-2xl py-4 pl-12 pr-4 text-base", + )} />
@@ -116,41 +126,61 @@ export function SearchBar({ autoFocus = false }: { autoFocus?: boolean }) { const poster = getPosterUrl(result.poster_path, "w185"); const year = result.release_date?.slice(0, 4); + const inner = ( + <> +
+ {poster ? ( + + ) : ( +
+ N/A +
+ )} +
+
+

+ {result.title} + {year ? ( + + ({year}) + + ) : null} +

+

+ {result.overview || "No overview available"} +

+
+ + ); + return (
  • - setOpen(false)} - className="flex items-center gap-3 border-b border-white/5 px-4 py-3 transition hover:bg-white/5 last:border-b-0" - > -
    - {poster ? ( - - ) : ( -
    - N/A -
    - )} -
    -
    -

    - {result.title} - {year ? ( - - ({year}) - - ) : null} -

    -

    - {result.overview || "No overview available"} -

    -
    - + {onSelect ? ( + + ) : ( + setOpen(false)} + className="flex items-center gap-3 border-b border-white/5 px-4 py-3 transition hover:bg-white/5 last:border-b-0" + > + {inner} + + )}
  • ); })} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 05f6f6a..f7f4c82 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,9 +1,20 @@ +# Development Docker Compose (self-contained). +# This file defines both the app (with live reload) and the db. +# +# Usage (from project root): +# docker compose -f docker-compose.dev.yml up --build +# +# After first start the database migrations run automatically before the dev server. +# Stop with: docker compose -f docker-compose.dev.yml down +# +# For production-like: docker compose -f docker-compose.yml up + services: app: build: context: . target: deps - command: npm run dev + command: sh -c "npm run db:migrate && npm run dev" working_dir: /app volumes: - .:/app @@ -21,4 +32,22 @@ services: WATCHPACK_POLLING: "true" depends_on: db: - condition: service_healthy \ No newline at end of file + condition: service_healthy + + db: + image: postgres:16-alpine + environment: + POSTGRES_USER: film + POSTGRES_PASSWORD: film + POSTGRES_DB: film + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U film -d film"] + interval: 5s + timeout: 5s + retries: 5 + restart: unless-stopped + +volumes: + pgdata: \ No newline at end of file diff --git a/lib/cache.ts b/lib/cache.ts index c6451ea..b50cbdc 100644 --- a/lib/cache.ts +++ b/lib/cache.ts @@ -7,20 +7,27 @@ export async function getCachedCard( movieId: number, cardType: CardType, ): Promise { - const db = getDb(); - const rows = await db - .select() - .from(cardCache) - .where( - and(eq(cardCache.movieId, movieId), eq(cardCache.cardType, cardType)), - ) - .limit(1); + try { + const db = getDb(); + const rows = await db + .select() + .from(cardCache) + .where( + and(eq(cardCache.movieId, movieId), eq(cardCache.cardType, cardType)), + ) + .limit(1); - if (rows.length === 0) { + if (rows.length === 0) { + return null; + } + + return rows[0].payload as T; + } catch (err) { + // DB may be unavailable in local dev (e.g. DATABASE_URL points to Docker 'db' host). + // Treat as cache miss so card generation can still proceed via LLM. + console.warn(`[cache] getCachedCard(${movieId}, ${cardType}) skipped:`, err); return null; } - - return rows[0].payload as T; } export async function setCachedCard( @@ -28,20 +35,24 @@ export async function setCachedCard( cardType: CardType, payload: T, ): Promise { - const db = getDb(); + try { + const db = getDb(); - await db - .insert(cardCache) - .values({ - movieId, - cardType, - payload, - }) - .onConflictDoUpdate({ - target: [cardCache.movieId, cardCache.cardType], - set: { + await db + .insert(cardCache) + .values({ + movieId, + cardType, payload, - createdAt: new Date(), - }, - }); + }) + .onConflictDoUpdate({ + target: [cardCache.movieId, cardCache.cardType], + set: { + payload, + createdAt: new Date(), + }, + }); + } catch (err) { + console.warn(`[cache] setCachedCard(${movieId}, ${cardType}) skipped:`, err); + } } \ No newline at end of file