mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-16 04:44:23 +08:00
201 lines
9.5 KiB
TypeScript
201 lines
9.5 KiB
TypeScript
import { memo, useEffect, useRef, useState, type ReactNode } from 'react'
|
||
import { Link } from 'react-router-dom'
|
||
import { Film, Play, Layers, Star } from 'lucide-react'
|
||
import { ARTWORK, imageURL } from '../api/client'
|
||
import type { Media } from '../types'
|
||
|
||
const ACTION_OVERLAY_CLASS =
|
||
'absolute right-2 top-2 z-20 flex flex-wrap justify-end gap-1 opacity-100 transition-opacity sm:pointer-events-none sm:opacity-0 sm:group-hover:pointer-events-auto sm:group-hover:opacity-100 sm:focus-within:pointer-events-auto sm:focus-within:opacity-100'
|
||
|
||
// memo:父级状态变化(如轮播切图、其它卡片操作)不再级联重渲染所有卡片。
|
||
// 注意 actions/renderActions 必须引用稳定(用 renderActions 传函数)memo 才生效。
|
||
export const MediaCard = memo(function MediaCard({
|
||
media, progress, count, rating, linkTo, linkState, onClick, actions, renderActions, compact, showLibrary,
|
||
}: {
|
||
media: Media
|
||
progress?: number
|
||
count?: number
|
||
rating?: number
|
||
linkTo?: string
|
||
linkState?: { from?: string }
|
||
onClick?: () => void
|
||
actions?: ReactNode
|
||
renderActions?: (media: Media) => ReactNode
|
||
compact?: boolean
|
||
showLibrary?: boolean
|
||
}) {
|
||
const ref = useRef<HTMLDivElement>(null)
|
||
const href = linkTo ?? `/media/${media.id}`
|
||
const [posterFit, setPosterFit] = useState<'cover' | 'contain'>('cover')
|
||
// 固定档位而不是在调用点拼尺寸:紧凑布局与普通卡片共用同一份缩略图缓存。
|
||
const posterSrc = imageURL(media.poster_url, media.updated_at, ARTWORK.posterCard)
|
||
const blurredPosterSrc = imageURL(media.poster_url, media.updated_at, ARTWORK.posterTiny)
|
||
const displayRating = rating ?? media.rating
|
||
const versionCount = media.versions?.length ?? 0
|
||
const libraryName = media.display_library_name || media.library_name
|
||
// renderActions 延迟到卡片自身渲染时才调用,保证 memo 生效
|
||
const actionContent = actions ?? renderActions?.(media)
|
||
|
||
useEffect(() => {
|
||
setPosterFit('cover')
|
||
}, [media.poster_url, media.updated_at])
|
||
|
||
const card = (
|
||
<div
|
||
ref={ref}
|
||
className="relative flex h-full flex-col overflow-hidden rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] shadow-[0_1px_3px_rgba(0,0,0,0.01),0_1px_2px_rgba(0,0,0,0.015)] transition-all duration-300 hover:scale-[1.04] hover:-translate-y-1.5 hover:border-brand-500/40 hover:shadow-[0_12px_32px_var(--app-shadow)]"
|
||
>
|
||
{/* Poster Wrapper */}
|
||
<div className="relative aspect-[2/3] w-full shrink-0 overflow-hidden bg-[var(--app-panel-soft)]">
|
||
{media.poster_url ? (
|
||
<>
|
||
{posterFit === 'contain' && (
|
||
<img
|
||
src={blurredPosterSrc}
|
||
alt=""
|
||
aria-hidden="true"
|
||
loading="lazy"
|
||
className="absolute inset-0 h-full w-full scale-110 object-cover object-center opacity-25 blur-xl"
|
||
referrerPolicy="no-referrer"
|
||
/>
|
||
)}
|
||
<img
|
||
src={posterSrc}
|
||
alt={media.title}
|
||
loading="lazy"
|
||
decoding="async"
|
||
onLoad={(event) => {
|
||
const img = event.currentTarget
|
||
setPosterFit(img.naturalWidth > img.naturalHeight ? 'contain' : 'cover')
|
||
}}
|
||
className={
|
||
'relative block h-full w-full object-center transition-transform duration-700 ease-out group-hover:scale-105 ' +
|
||
(posterFit === 'contain' ? 'object-contain p-1.5' : 'object-cover')
|
||
}
|
||
referrerPolicy="no-referrer"
|
||
/>
|
||
</>
|
||
) : (
|
||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-[var(--app-panel-soft)] text-[var(--app-muted)]">
|
||
<Film size={28} className="stroke-[1.5]" />
|
||
<span className="text-[10px] uppercase tracking-wider font-bold">No Poster</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Rating Badge */}
|
||
{displayRating > 0 && (
|
||
<span className="absolute left-2 top-2 inline-flex items-center gap-0.5 rounded-lg border border-white/15 bg-[#111827]/90 px-1.5 py-0.5 text-[10px] font-bold text-[#c9954a] shadow-sm">
|
||
<Star size={10} fill="currentColor" className="shrink-0" />
|
||
<span>{displayRating.toFixed(1)}</span>
|
||
</span>
|
||
)}
|
||
|
||
{/* Premium Hover Overlay(CSS 过渡替代 framer-motion 逐卡动画实例) */}
|
||
<div className={`absolute inset-0 bg-gradient-to-t from-[#111827]/90 via-[#111827]/30 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex flex-col justify-end ${
|
||
compact ? 'p-3' : 'p-4'
|
||
}`}>
|
||
<div className="space-y-2 translate-y-4 transition-transform duration-200 group-hover:translate-y-0">
|
||
<span className={`inline-flex items-center gap-1.5 rounded-xl bg-brand-500 px-4 py-2 font-bold text-white shadow-md shadow-brand-500/20 ${
|
||
compact ? 'py-1.5 text-[11px]' : 'text-xs py-2'
|
||
}`}>
|
||
<Play size={compact ? 8 : 10} fill="currentColor" className="text-white" />
|
||
<span>立即观影</span>
|
||
</span>
|
||
{!compact && (
|
||
<p className="text-[10px] text-gray-200 font-semibold line-clamp-2 leading-relaxed">
|
||
{media.overview || "暂无简介内容"}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Progress Bar overlay */}
|
||
{progress !== undefined && progress > 0 && progress < 1 && (
|
||
<div className="absolute inset-x-0 bottom-0 h-1.5 bg-[var(--app-hover)]">
|
||
<div
|
||
className="h-full bg-gradient-to-r from-brand-400 to-brand-500 rounded-r-full transition-all duration-300"
|
||
style={{ width: `${Math.round(progress * 100)}%` }}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Media Metadata Info */}
|
||
<div className={`space-y-1 border-t border-[var(--app-border)] bg-[var(--app-panel)] ${
|
||
compact ? 'p-2' : 'p-2.5 sm:p-4'
|
||
}`}>
|
||
{/* 手机端一张卡只有 ~100px 宽:标题放开到两行,并预留两行高度,
|
||
这样同一行的卡片不会因为标题行数不同而参差不齐。 */}
|
||
<p className={`font-bold text-[var(--app-text)] transition-colors duration-200 group-hover:text-brand-500 ${
|
||
compact
|
||
? 'truncate text-xs'
|
||
: 'line-clamp-2 min-h-[2.75em] text-[13px] leading-snug sm:line-clamp-none sm:min-h-0 sm:truncate sm:text-sm sm:leading-normal'
|
||
}`}>
|
||
{media.title}
|
||
</p>
|
||
{showLibrary && libraryName && (
|
||
<p title={libraryName} className="truncate text-[10px] text-[var(--app-muted)] sm:text-xs">
|
||
{libraryName}
|
||
</p>
|
||
)}
|
||
{/* 单行、不折行:年份可截断,徽标永不压缩。折行会让同一行里各卡片的
|
||
徽标落位高低不一,看起来就是「错位」,所以宁可极窄屏上截断年份。
|
||
min-h 统一行高,避免有/无徽标的卡片年份基线差几像素。 */}
|
||
<div className="flex min-h-[21px] items-center text-[10px] font-bold uppercase text-[var(--app-muted)] sm:min-h-0 sm:gap-x-2 sm:text-[11px] sm:tracking-wider">
|
||
<span className="truncate">{media.year > 0 ? media.year : "未知年份"}</span>
|
||
<span className="ml-auto flex shrink-0 items-center gap-0.5 sm:gap-1.5">
|
||
{/* 集数/版本数徽标:放在海报下方的信息行而不是压在画面上。海报底部通常
|
||
自带片名,角标会盖住它(Game of Thrones / Breaking Bad 都很明显)。 */}
|
||
{(count !== undefined && count > 1) || (count === undefined && versionCount > 1) ? (
|
||
<span className="inline-flex items-center gap-0.5 whitespace-nowrap rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-1 py-0.5 text-[var(--app-subtle)] sm:gap-1 sm:px-1.5">
|
||
<Layers size={10} className="shrink-0 text-[#c9954a]" />
|
||
<span>{count !== undefined && count > 1 ? `${count} 集` : `${versionCount} 版本`}</span>
|
||
</span>
|
||
) : null}
|
||
{/* 编码格式在小卡片上没有信息价值,手机上直接隐藏,避免挤掉集数徽标。 */}
|
||
{media.video_codec && (
|
||
<span className="hidden whitespace-nowrap rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-1.5 py-0.5 text-[var(--app-subtle)] sm:inline-block">
|
||
{media.video_codec}
|
||
</span>
|
||
)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
|
||
if (onClick) {
|
||
return (
|
||
<div className="group relative block h-full w-full">
|
||
<button type="button" onClick={onClick} className="block h-full w-full text-left">
|
||
{card}
|
||
</button>
|
||
{actionContent && (
|
||
<div className={ACTION_OVERLAY_CLASS}>
|
||
{actionContent}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (actionContent) {
|
||
return (
|
||
<div className="group relative block h-full">
|
||
<Link to={href} state={linkState} className="block h-full">
|
||
{card}
|
||
</Link>
|
||
<div className={ACTION_OVERLAY_CLASS}>
|
||
{actionContent}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<Link to={href} state={linkState} className="group block h-full">
|
||
{card}
|
||
</Link>
|
||
)
|
||
})
|