Ver Fonte

[Phase 5] Landing TeaserCard: 1.3x size, info button + MoreInfoModal

- Reduced TeaserCard size (poster w-48 -> w-36 ~ 1.3x reel poster)
  so it doesn't crowd the buttons below the carousel
- New "i" info button between title and genres opens MoreInfoModal
- MoreInfoModal: title + year, genres, plot, Add to list (-> /login),
  Watch Trailer (lazy fetch /api/tmdb/movie/[id]/videos)
- Modal layout: poster left, content right on >= sm; stacks on mobile;
  max-w-5xl / lg:max-w-6xl for readable line lengths
- Modal uses createPortal to document.body so it escapes the
  animate-emerge wrapper's lingering transform: scale(1) (which would
  otherwise establish a containing block and clamp fixed inset-0 to
  the TeaserCard's tiny box)
- ESC closes, click-outside closes, focus trap, focus restoration
  (mirrors GenreRollModal a11y pattern)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User há 2 meses atrás
pai
commit
0061375c2e

+ 176 - 0
src/components/landing/more-info-modal.tsx

@@ -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,
+  );
+}

+ 46 - 31
src/components/landing/teaser-card.tsx

@@ -1,11 +1,16 @@
+"use client";
+
+import { useState } from "react";
 import { getTMDBImageUrl, TMDB_GENRE_MAP } from "@/types/tmdb";
 import type { TMDBMovie } from "@/types/tmdb";
+import { MoreInfoModal } from "./more-info-modal";
 
 interface TeaserCardProps {
   movie: TMDBMovie;
 }
 
 export function TeaserCard({ movie }: TeaserCardProps) {
+  const [modalOpen, setModalOpen] = useState(false);
   const posterUrl = getTMDBImageUrl(movie.poster_path, "grid");
   const genres = movie.genre_ids
     .map((id) => TMDB_GENRE_MAP[id])
@@ -14,36 +19,46 @@ export function TeaserCard({ movie }: TeaserCardProps) {
   const year = movie.release_date ? movie.release_date.slice(0, 4) : "";
 
   return (
-    <div className="flex w-full max-w-xs flex-col items-center rounded-xl bg-foreground/5 p-4">
-      {posterUrl ? (
-        /* eslint-disable-next-line @next/next/no-img-element */
-        <img
-          src={posterUrl}
-          alt={`Movie poster for ${movie.title}`}
-          loading="lazy"
-          className="h-auto w-48 rounded-lg"
-        />
-      ) : (
-        <div className="flex h-72 w-48 items-center justify-center rounded-lg bg-foreground/10 text-foreground/40">
-          No poster
-        </div>
-      )}
-      <h3 className="mt-3 text-center text-lg font-semibold">
-        {movie.title}
-        {year && <span className="ml-1 text-sm font-normal text-foreground/50">({year})</span>}
-      </h3>
-      {genres.length > 0 && (
-        <div className="mt-2 flex flex-wrap justify-center gap-1.5">
-          {genres.map((genre) => (
-            <span
-              key={genre}
-              className="rounded-full bg-foreground/10 px-2.5 py-0.5 text-xs text-foreground/70"
-            >
-              {genre}
-            </span>
-          ))}
-        </div>
-      )}
-    </div>
+    <>
+      <div className="flex w-40 flex-col items-center rounded-xl bg-background/95 p-3 shadow-2xl ring-1 ring-foreground/10 backdrop-blur">
+        {posterUrl ? (
+          /* eslint-disable-next-line @next/next/no-img-element */
+          <img
+            src={posterUrl}
+            alt={`Movie poster for ${movie.title}`}
+            loading="lazy"
+            className="h-auto w-36 rounded-lg"
+          />
+        ) : (
+          <div className="flex h-52 w-36 items-center justify-center rounded-lg bg-foreground/10 text-xs text-foreground/40">
+            No poster
+          </div>
+        )}
+        <h3 className="mt-2 text-center text-sm font-semibold leading-tight line-clamp-2">
+          {movie.title}
+          {year && <span className="ml-1 text-xs font-normal text-foreground/50">({year})</span>}
+        </h3>
+        <button
+          onClick={() => setModalOpen(true)}
+          aria-label={`More info about ${movie.title}`}
+          className="mt-1.5 flex h-6 w-6 items-center justify-center rounded-full border border-foreground/30 text-xs font-serif italic text-foreground/70 transition-colors hover:bg-foreground/5 hover:text-foreground"
+        >
+          i
+        </button>
+        {genres.length > 0 && (
+          <div className="mt-1.5 flex flex-wrap justify-center gap-1">
+            {genres.slice(0, 2).map((genre) => (
+              <span
+                key={genre}
+                className="rounded-full bg-foreground/10 px-2 py-0.5 text-[10px] text-foreground/70"
+              >
+                {genre}
+              </span>
+            ))}
+          </div>
+        )}
+      </div>
+      {modalOpen && <MoreInfoModal movie={movie} onClose={() => setModalOpen(false)} />}
+    </>
   );
 }