home-roll-teaser-card.tsx 3.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112
  1. "use client";
  2. import Link from "next/link";
  3. import type { Movie } from "@/types/movie";
  4. import { TMDB_GENRE_MAP, getTMDBImageUrl } from "@/types/tmdb";
  5. /**
  6. * <HomeRollTeaserCard /> — in-place result card for the home-page cross-list
  7. * roll. Renders IN PLACE per PROJECT_SCOPE.md:222-223; the only navigation
  8. * is the user-initiated "Open list" link.
  9. *
  10. * Uses the DB `Movie` row shape (not the TMDB landing teaser shape). Titles,
  11. * genres, and group names are rendered as React text children only — no
  12. * `dangerouslySetInnerHTML`, no unescaped `title=` attributes.
  13. */
  14. interface HomeRollTeaserCardProps {
  15. movie: Movie;
  16. groupId: string;
  17. groupName: string | null;
  18. onReroll: () => void;
  19. }
  20. function genreLabelsFromIds(ids: string[]): string[] {
  21. const labels: string[] = [];
  22. for (const raw of ids) {
  23. const id = Number.parseInt(raw, 10);
  24. if (Number.isNaN(id)) continue;
  25. const name = TMDB_GENRE_MAP[id];
  26. if (name) labels.push(name);
  27. }
  28. return labels;
  29. }
  30. export function HomeRollTeaserCard({
  31. movie,
  32. groupId,
  33. groupName,
  34. onReroll,
  35. }: HomeRollTeaserCardProps) {
  36. const posterUrl = getTMDBImageUrl(movie.poster_path, "panel");
  37. const genreLabels = genreLabelsFromIds(movie.genres ?? []);
  38. return (
  39. <div
  40. className="mt-4 rounded-xl border border-foreground/10 bg-foreground/5 p-4 sm:p-6"
  41. data-testid="home-roll-teaser-card"
  42. >
  43. <div className="flex flex-col gap-4 sm:flex-row">
  44. {posterUrl ? (
  45. // eslint-disable-next-line @next/next/no-img-element
  46. <img
  47. src={posterUrl}
  48. alt={movie.title}
  49. loading="lazy"
  50. className="w-32 h-48 rounded-lg object-cover self-center sm:self-start"
  51. />
  52. ) : (
  53. <div className="w-32 h-48 rounded-lg bg-foreground/10 flex items-center justify-center text-xs text-foreground/50 self-center sm:self-start">
  54. No poster
  55. </div>
  56. )}
  57. <div className="flex-1 min-w-0">
  58. <h2 className="text-lg sm:text-xl font-semibold text-foreground break-words">
  59. {movie.title}
  60. </h2>
  61. <p className="mt-1 text-sm text-foreground/60">{movie.year}</p>
  62. {genreLabels.length > 0 && (
  63. <div className="mt-2 flex flex-wrap gap-1.5">
  64. {genreLabels.map((label) => (
  65. <span
  66. key={label}
  67. className="rounded-full bg-foreground/10 px-2 py-0.5 text-xs text-foreground/70"
  68. >
  69. {label}
  70. </span>
  71. ))}
  72. </div>
  73. )}
  74. <p
  75. className="mt-3 text-xs text-foreground/60 truncate"
  76. style={{ maxWidth: "22rem" }}
  77. >
  78. from {groupName ?? "a list"}
  79. </p>
  80. <div className="mt-4 flex flex-wrap gap-2">
  81. <button
  82. type="button"
  83. onClick={onReroll}
  84. aria-label="Re-roll the dice"
  85. className="rounded-lg bg-foreground text-background px-4 py-2 text-sm font-medium hover:opacity-90 transition-opacity"
  86. style={{ minHeight: 44 }}
  87. >
  88. 🎲 Re-roll
  89. </button>
  90. <Link
  91. href={`/list/${groupId}`}
  92. className="rounded-lg border border-foreground/20 px-4 py-2 text-sm font-medium text-foreground hover:bg-foreground/5 transition-colors inline-flex items-center"
  93. style={{ minHeight: 44 }}
  94. >
  95. Open list
  96. </Link>
  97. </div>
  98. </div>
  99. </div>
  100. </div>
  101. );
  102. }