MovieCard.tsx 3.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116
  1. import { useState } from 'react'
  2. import type { MovieCard as MovieCardType, MovieDetail } from '../types'
  3. import { getMovieDetail } from '../api/movies'
  4. import { DetailFacts } from './DetailFacts'
  5. import { formatRating, formatRuntime } from '../lib/format'
  6. interface MovieCardProps {
  7. movie: MovieCardType
  8. index?: number
  9. }
  10. export function MovieCardView({ movie, index = 0 }: MovieCardProps) {
  11. const [runtime, setRuntime] = useState(movie.runtime)
  12. const [overview, setOverview] = useState(movie.overview_safe)
  13. const [detail, setDetail] = useState<MovieDetail | null>(null)
  14. const [expanded, setExpanded] = useState(false)
  15. const [loadingDetail, setLoadingDetail] = useState(false)
  16. const [detailError, setDetailError] = useState<string | null>(null)
  17. async function handleToggle() {
  18. const next = !expanded
  19. setExpanded(next)
  20. if (!next || detail) return
  21. setLoadingDetail(true)
  22. setDetailError(null)
  23. try {
  24. const res = await getMovieDetail(movie.id)
  25. if (res.data) {
  26. setDetail(res.data)
  27. if (res.data.runtime != null) setRuntime(res.data.runtime)
  28. if (res.data.overview) setOverview(res.data.overview)
  29. }
  30. } catch (err) {
  31. setDetailError(err instanceof Error ? err.message : '详情加载失败')
  32. } finally {
  33. setLoadingDetail(false)
  34. }
  35. }
  36. const rating = formatRating(detail?.rating ?? movie.rating)
  37. const runtimeLabel = formatRuntime(runtime)
  38. return (
  39. <article
  40. className="movie-card"
  41. style={{ animationDelay: `${Math.min(index, 8) * 60}ms` }}
  42. >
  43. <button
  44. type="button"
  45. className="movie-card__hit"
  46. onClick={() => void handleToggle()}
  47. aria-expanded={expanded}
  48. >
  49. <div className="movie-card__poster">
  50. {movie.poster_url ? (
  51. <img src={movie.poster_url} alt="" loading="lazy" />
  52. ) : (
  53. <div className="movie-card__poster-fallback">{movie.title.slice(0, 1)}</div>
  54. )}
  55. </div>
  56. <div className="movie-card__body">
  57. <header className="movie-card__header">
  58. <h3>{movie.title}</h3>
  59. <div className="movie-card__meta">
  60. {movie.year != null && <span>{movie.year}</span>}
  61. {rating && <span>评分 {rating}</span>}
  62. {runtimeLabel && <span>{runtimeLabel}</span>}
  63. {movie.genres.slice(0, 3).map((g) => (
  64. <span key={g}>{g}</span>
  65. ))}
  66. </div>
  67. </header>
  68. {movie.why && <p className="movie-card__why">{movie.why}</p>}
  69. {movie.vibe_tags.length > 0 && (
  70. <ul className="movie-card__tags">
  71. {movie.vibe_tags.map((tag) => (
  72. <li key={tag}>{tag}</li>
  73. ))}
  74. </ul>
  75. )}
  76. {movie.caution && (
  77. <p className="movie-card__caution">适看提示:{movie.caution}</p>
  78. )}
  79. <p className="movie-card__more muted">
  80. {expanded ? '收起详情' : '展开详情'}
  81. </p>
  82. </div>
  83. </button>
  84. {expanded && (
  85. <div className="movie-card__detail">
  86. {loadingDetail && <p className="muted">正在加载详情…</p>}
  87. {detailError && <p className="error-text">{detailError}</p>}
  88. {!loadingDetail && detail?.tagline && (
  89. <p className="detail-tagline">{detail.tagline}</p>
  90. )}
  91. {!loadingDetail && overview && (
  92. <p className="detail-overview">{overview}</p>
  93. )}
  94. {!loadingDetail && detail && <DetailFacts detail={detail} />}
  95. {!loadingDetail && !overview && !detailError && (
  96. <p className="muted">暂无简介</p>
  97. )}
  98. {!loadingDetail && detail?.tmdb_url && (
  99. <p className="detail-link">
  100. <a href={detail.tmdb_url} target="_blank" rel="noreferrer">
  101. 在 TMDB 查看
  102. </a>
  103. </p>
  104. )}
  105. </div>
  106. )}
  107. </article>
  108. )
  109. }