| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179 |
- "use client";
- import { useEffect, useRef, useState } from "react";
- import { createPortal } from "react-dom";
- import Link from "next/link";
- import type { TMDBMovie } from "@/types/tmdb";
- import { TMDB_GENRE_MAP, getTMDBImageUrl } from "@/types/tmdb";
- interface MoreInfoModalProps {
- movie: TMDBMovie;
- onClose: () => void;
- }
- export function MoreInfoModal({ movie, onClose }: MoreInfoModalProps) {
- const [mounted, setMounted] = useState(false);
- const [trailerUrl, setTrailerUrl] = useState<string | null>(null);
- const [trailerStatus, setTrailerStatus] = useState<"loading" | "ready" | "none" | "error">(
- "loading",
- );
- const dialogRef = useRef<HTMLDivElement>(null);
- const closeBtnRef = useRef<HTMLButtonElement>(null);
- useEffect(() => {
- // Standard SSR-safe portal mount flag — intentional setState-in-effect.
- // eslint-disable-next-line react-hooks/set-state-in-effect
- setMounted(true);
- }, []);
- const year = movie.release_date ? movie.release_date.slice(0, 4) : "";
- const genres = movie.genre_ids
- .map((id) => TMDB_GENRE_MAP[id])
- .filter(Boolean)
- .slice(0, 4);
- const posterUrl = getTMDBImageUrl(movie.poster_path, "panel");
- useEffect(() => {
- let cancelled = false;
- fetch(`/api/tmdb/movie/${movie.id}/videos`)
- .then((res) => res.json())
- .then((data: { trailerUrl: string | null }) => {
- if (cancelled) return;
- if (data.trailerUrl) {
- setTrailerUrl(data.trailerUrl);
- setTrailerStatus("ready");
- } else {
- setTrailerStatus("none");
- }
- })
- .catch(() => {
- if (!cancelled) setTrailerStatus("error");
- });
- return () => {
- cancelled = true;
- };
- }, [movie.id]);
- useEffect(() => {
- const previouslyFocused = document.activeElement as HTMLElement | null;
- closeBtnRef.current?.focus();
- function handleKey(e: KeyboardEvent) {
- if (e.key === "Escape") {
- e.stopPropagation();
- onClose();
- }
- if (e.key === "Tab" && dialogRef.current) {
- const focusables = Array.from(
- dialogRef.current.querySelectorAll<HTMLElement>(
- 'button, a[href], [tabindex]:not([tabindex="-1"])',
- ),
- ).filter((el) => !el.hasAttribute("disabled"));
- if (focusables.length === 0) return;
- const first = focusables[0];
- const last = focusables[focusables.length - 1];
- if (e.shiftKey && document.activeElement === first) {
- e.preventDefault();
- last.focus();
- } else if (!e.shiftKey && document.activeElement === last) {
- e.preventDefault();
- first.focus();
- }
- }
- }
- document.addEventListener("keydown", handleKey);
- return () => {
- document.removeEventListener("keydown", handleKey);
- previouslyFocused?.focus?.();
- };
- }, [onClose]);
- if (!mounted) return null;
- return createPortal(
- <div
- className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4"
- onClick={onClose}
- >
- <div
- ref={dialogRef}
- role="dialog"
- aria-modal="true"
- aria-labelledby="more-info-title"
- className="relative flex max-h-[92vh] w-full max-w-5xl flex-col gap-6 overflow-y-auto rounded-2xl bg-background p-6 shadow-2xl sm:flex-row sm:gap-8 sm:p-8 lg:max-w-6xl"
- onClick={(e) => e.stopPropagation()}
- >
- <button
- ref={closeBtnRef}
- onClick={onClose}
- aria-label="Close"
- className="absolute right-3 top-3 flex h-9 w-9 items-center justify-center rounded-full bg-foreground/5 text-xl text-foreground/70 transition-colors hover:bg-foreground/15 hover:text-foreground"
- >
- ×
- </button>
- {posterUrl && (
- /* eslint-disable-next-line @next/next/no-img-element */
- <img
- src={posterUrl}
- alt={`Poster for ${movie.title}`}
- loading="lazy"
- className="mx-auto h-auto w-48 flex-shrink-0 rounded-xl shadow-lg sm:w-64"
- />
- )}
- <div className="flex min-w-0 flex-1 flex-col">
- <h2 id="more-info-title" className="pr-12 text-3xl font-semibold leading-tight">
- {movie.title}
- {year && <span className="ml-2 text-xl font-normal text-foreground/60">({year})</span>}
- </h2>
- {genres.length > 0 && (
- <div className="mt-3 flex flex-wrap gap-2">
- {genres.map((genre) => (
- <span
- key={genre}
- className="rounded-full bg-foreground/10 px-3 py-1 text-sm text-foreground/70"
- >
- {genre}
- </span>
- ))}
- </div>
- )}
- {movie.overview && (
- <p className="mt-5 text-base leading-relaxed text-foreground/85">{movie.overview}</p>
- )}
- <div className="mt-auto flex flex-col gap-3 pt-6 sm:flex-row">
- <Link
- href="/login"
- className="flex-1 rounded-lg bg-foreground py-3 text-center text-base font-semibold text-background transition-opacity hover:opacity-90"
- >
- Add to list
- </Link>
- {trailerStatus === "ready" && trailerUrl ? (
- <a
- href={trailerUrl}
- target="_blank"
- rel="noopener noreferrer"
- className="flex-1 rounded-lg border border-foreground/30 py-3 text-center text-base font-semibold transition-colors hover:bg-foreground/5"
- >
- Watch Trailer
- </a>
- ) : (
- <button
- disabled
- className="flex-1 rounded-lg border border-foreground/20 py-3 text-base font-semibold text-foreground/40"
- >
- {trailerStatus === "loading" ? "Loading…" : "No trailer"}
- </button>
- )}
- </div>
- </div>
- </div>
- </div>,
- document.body,
- );
- }
|