|
@@ -0,0 +1,176 @@
|
|
|
|
|
+"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(() => {
|
|
|
|
|
+ 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,
|
|
|
|
|
+ );
|
|
|
|
|
+}
|