more-info-modal.tsx 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  1. "use client";
  2. import { useEffect, useRef, useState } from "react";
  3. import { createPortal } from "react-dom";
  4. import Link from "next/link";
  5. import type { TMDBMovie } from "@/types/tmdb";
  6. import { TMDB_GENRE_MAP, getTMDBImageUrl } from "@/types/tmdb";
  7. interface MoreInfoModalProps {
  8. movie: TMDBMovie;
  9. onClose: () => void;
  10. }
  11. export function MoreInfoModal({ movie, onClose }: MoreInfoModalProps) {
  12. const [mounted, setMounted] = useState(false);
  13. const [trailerUrl, setTrailerUrl] = useState<string | null>(null);
  14. const [trailerStatus, setTrailerStatus] = useState<"loading" | "ready" | "none" | "error">(
  15. "loading",
  16. );
  17. const dialogRef = useRef<HTMLDivElement>(null);
  18. const closeBtnRef = useRef<HTMLButtonElement>(null);
  19. useEffect(() => {
  20. // Standard SSR-safe portal mount flag — intentional setState-in-effect.
  21. // eslint-disable-next-line react-hooks/set-state-in-effect
  22. setMounted(true);
  23. }, []);
  24. const year = movie.release_date ? movie.release_date.slice(0, 4) : "";
  25. const genres = movie.genre_ids
  26. .map((id) => TMDB_GENRE_MAP[id])
  27. .filter(Boolean)
  28. .slice(0, 4);
  29. const posterUrl = getTMDBImageUrl(movie.poster_path, "panel");
  30. useEffect(() => {
  31. let cancelled = false;
  32. fetch(`/api/tmdb/movie/${movie.id}/videos`)
  33. .then((res) => res.json())
  34. .then((data: { trailerUrl: string | null }) => {
  35. if (cancelled) return;
  36. if (data.trailerUrl) {
  37. setTrailerUrl(data.trailerUrl);
  38. setTrailerStatus("ready");
  39. } else {
  40. setTrailerStatus("none");
  41. }
  42. })
  43. .catch(() => {
  44. if (!cancelled) setTrailerStatus("error");
  45. });
  46. return () => {
  47. cancelled = true;
  48. };
  49. }, [movie.id]);
  50. useEffect(() => {
  51. const previouslyFocused = document.activeElement as HTMLElement | null;
  52. closeBtnRef.current?.focus();
  53. function handleKey(e: KeyboardEvent) {
  54. if (e.key === "Escape") {
  55. e.stopPropagation();
  56. onClose();
  57. }
  58. if (e.key === "Tab" && dialogRef.current) {
  59. const focusables = Array.from(
  60. dialogRef.current.querySelectorAll<HTMLElement>(
  61. 'button, a[href], [tabindex]:not([tabindex="-1"])',
  62. ),
  63. ).filter((el) => !el.hasAttribute("disabled"));
  64. if (focusables.length === 0) return;
  65. const first = focusables[0];
  66. const last = focusables[focusables.length - 1];
  67. if (e.shiftKey && document.activeElement === first) {
  68. e.preventDefault();
  69. last.focus();
  70. } else if (!e.shiftKey && document.activeElement === last) {
  71. e.preventDefault();
  72. first.focus();
  73. }
  74. }
  75. }
  76. document.addEventListener("keydown", handleKey);
  77. return () => {
  78. document.removeEventListener("keydown", handleKey);
  79. previouslyFocused?.focus?.();
  80. };
  81. }, [onClose]);
  82. if (!mounted) return null;
  83. return createPortal(
  84. <div
  85. className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4"
  86. onClick={onClose}
  87. >
  88. <div
  89. ref={dialogRef}
  90. role="dialog"
  91. aria-modal="true"
  92. aria-labelledby="more-info-title"
  93. 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"
  94. onClick={(e) => e.stopPropagation()}
  95. >
  96. <button
  97. ref={closeBtnRef}
  98. onClick={onClose}
  99. aria-label="Close"
  100. 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"
  101. >
  102. ×
  103. </button>
  104. {posterUrl && (
  105. /* eslint-disable-next-line @next/next/no-img-element */
  106. <img
  107. src={posterUrl}
  108. alt={`Poster for ${movie.title}`}
  109. loading="lazy"
  110. className="mx-auto h-auto w-48 flex-shrink-0 rounded-xl shadow-lg sm:w-64"
  111. />
  112. )}
  113. <div className="flex min-w-0 flex-1 flex-col">
  114. <h2 id="more-info-title" className="pr-12 text-3xl font-semibold leading-tight">
  115. {movie.title}
  116. {year && <span className="ml-2 text-xl font-normal text-foreground/60">({year})</span>}
  117. </h2>
  118. {genres.length > 0 && (
  119. <div className="mt-3 flex flex-wrap gap-2">
  120. {genres.map((genre) => (
  121. <span
  122. key={genre}
  123. className="rounded-full bg-foreground/10 px-3 py-1 text-sm text-foreground/70"
  124. >
  125. {genre}
  126. </span>
  127. ))}
  128. </div>
  129. )}
  130. {movie.overview && (
  131. <p className="mt-5 text-base leading-relaxed text-foreground/85">{movie.overview}</p>
  132. )}
  133. <div className="mt-auto flex flex-col gap-3 pt-6 sm:flex-row">
  134. <Link
  135. href="/login"
  136. className="flex-1 rounded-lg bg-foreground py-3 text-center text-base font-semibold text-background transition-opacity hover:opacity-90"
  137. >
  138. Add to list
  139. </Link>
  140. {trailerStatus === "ready" && trailerUrl ? (
  141. <a
  142. href={trailerUrl}
  143. target="_blank"
  144. rel="noopener noreferrer"
  145. className="flex-1 rounded-lg border border-foreground/30 py-3 text-center text-base font-semibold transition-colors hover:bg-foreground/5"
  146. >
  147. Watch Trailer
  148. </a>
  149. ) : (
  150. <button
  151. disabled
  152. className="flex-1 rounded-lg border border-foreground/20 py-3 text-base font-semibold text-foreground/40"
  153. >
  154. {trailerStatus === "loading" ? "Loading…" : "No trailer"}
  155. </button>
  156. )}
  157. </div>
  158. </div>
  159. </div>
  160. </div>,
  161. document.body,
  162. );
  163. }