detail page components

This commit is contained in:
2026-03-27 23:06:44 +02:00
parent 02f0f58ec9
commit a039249ede
41 changed files with 6470 additions and 0 deletions
+128
View File
@@ -0,0 +1,128 @@
import { useMemo, useState } from 'react'
import { motion } from 'framer-motion'
import { Play } from '../../lib/icons'
import { useYoutubeViewer } from '../../stores/youtube-viewer-store'
import type { TmdbVideo } from '../../api/tmdb'
interface Props {
videos: TmdbVideo[] | null | undefined
}
const TAB_ORDER: Array<{ key: string; label: string }> = [
{ key: 'Trailer', label: 'Trailers' },
{ key: 'Teaser', label: 'Teasers' },
{ key: 'Featurette', label: 'Featurettes' },
{ key: 'Behind the Scenes', label: 'Behind the scenes' },
{ key: 'Clip', label: 'Clips' },
{ key: 'Bloopers', label: 'Bloopers' },
]
/**
* Tabbed video gallery sourced from TMDB videos.results. We filter to
* YouTube-hosted videos since that's what TMDB overwhelmingly returns
* and what we can thumbnail without an extra API. Tabs only appear for
* categories that actually have videos.
*/
export default function VideosSection({ videos }: Props) {
const buckets = useMemo(() => {
const m = new Map<string, TmdbVideo[]>()
for (const v of videos || []) {
if (v.site !== 'YouTube' || !v.key) continue
const list = m.get(v.type) || []
list.push(v)
m.set(v.type, list)
}
// Stable sort within each bucket: official first, then most recent.
for (const [, list] of m) {
list.sort((a, b) => {
if (a.official !== b.official) return a.official ? -1 : 1
return (b.published_at || '').localeCompare(a.published_at || '')
})
}
return m
}, [videos])
const tabs = TAB_ORDER.filter(t => (buckets.get(t.key)?.length ?? 0) > 0)
const [activeKey, setActiveKey] = useState(() => tabs[0]?.key || 'Trailer')
if (tabs.length === 0) return null
const active = buckets.get(activeKey) || []
return (
<div>
<div className="flex gap-1 mb-4 overflow-x-auto hide-scrollbar -mx-1 px-1">
{tabs.map(t => {
const count = buckets.get(t.key)?.length ?? 0
const on = activeKey === t.key
return (
<button
key={t.key}
onClick={() => setActiveKey(t.key)}
className={`relative h-8 px-3 rounded-md text-[12px] font-medium tracking-tight transition whitespace-nowrap focus-ring ${
on ? 'text-accent' : 'text-text-3 hover:text-text-1'
}`}
>
{on && (
<motion.span
layoutId="videos-active"
className="absolute inset-0 bg-accent/15 border border-accent/25 rounded-md"
transition={{ type: 'spring', stiffness: 380, damping: 30 }}
/>
)}
<span className="relative">
{t.label}
<span className={`ml-1.5 tabular-nums ${on ? 'text-accent/70' : 'text-text-4'}`}>{count}</span>
</span>
</button>
)
})}
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-3">
{active.map(v => (
<VideoCard key={v.id} video={v} />
))}
</div>
</div>
)
}
function VideoCard({ video }: { video: TmdbVideo }) {
const show = useYoutubeViewer(s => s.show)
const thumb = `https://img.youtube.com/vi/${video.key}/hqdefault.jpg`
return (
<button
type="button"
onClick={() =>
show({
videoKey: video.key,
title: video.name,
subtitle: `${video.type}${video.official ? ' · Official' : ''}`,
})
}
className="group block w-full text-left rounded-xl overflow-hidden bg-elevated ring-1 ring-border hover:ring-border-strong transition focus-ring"
>
<div className="relative aspect-video bg-black overflow-hidden">
<img
src={thumb}
alt={video.name}
loading="lazy"
className="w-full h-full object-cover group-hover:scale-[1.04] transition-transform duration-300"
/>
<div className="absolute inset-0 grid place-items-center bg-black/35 opacity-90 group-hover:bg-black/55 transition">
<span className="w-11 h-11 rounded-full bg-white/95 grid place-items-center text-void shadow-md">
<Play size={16} fill="currentColor" className="translate-x-px" />
</span>
</div>
</div>
<div className="p-3">
<p className="text-[12.5px] font-medium text-text-1 leading-tight tracking-tight line-clamp-2 mb-1">
{video.name}
</p>
<p className="text-[10.5px] text-text-4 uppercase tracking-[0.12em]">
{video.type}{video.official ? ' · Official' : ''}
</p>
</div>
</button>
)
}