home page rows
This commit is contained in:
@@ -0,0 +1,340 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Play, Info, Flame, ChevronLeft, ChevronRight, Star, Clock, Tv2, Film } from '../../lib/icons'
|
||||
import type { useNavigate } from 'react-router-dom'
|
||||
import { formatRuntime } from '../../lib/format'
|
||||
import { getBestImage, getImageUrl, getStoredServerUrl } from '../../api/jellyfin'
|
||||
import { usePreferencesStore } from '../../stores/preferences-store'
|
||||
import type { BaseItemDto } from '../../api/types'
|
||||
import { Dot } from './home-utils'
|
||||
|
||||
/* ───────────────────────────────────────────────────────────────── */
|
||||
/* Featured Carousel - Ken Burns rotating hero */
|
||||
/* ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
export function FeaturedCarousel({
|
||||
items,
|
||||
navigate,
|
||||
}: {
|
||||
items: BaseItemDto[]
|
||||
navigate: ReturnType<typeof useNavigate>
|
||||
}) {
|
||||
const [index, setIndex] = useState(0)
|
||||
const [paused, setPaused] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
// Track whether the user navigated forward (next) or backward (prev) so
|
||||
// the slide cross-fade has a slight directional drift.
|
||||
const directionRef = useRef<1 | -1>(1)
|
||||
|
||||
const heroAutoAdvance = usePreferencesStore(s => s.heroAutoAdvance)
|
||||
const heroAutoAdvanceMs = usePreferencesStore(s => s.heroAutoAdvanceMs)
|
||||
const reduceMotion = usePreferencesStore(s => s.reduceMotion)
|
||||
const advanceDisabled = !heroAutoAdvance || reduceMotion
|
||||
|
||||
const total = items.length
|
||||
const item = items[index]
|
||||
|
||||
/* Auto-advance + smooth progress */
|
||||
useEffect(() => {
|
||||
if (paused || advanceDisabled || total <= 1) {
|
||||
setProgress(0)
|
||||
return
|
||||
}
|
||||
setProgress(0)
|
||||
const start = Date.now()
|
||||
const id = setInterval(() => {
|
||||
const elapsed = Date.now() - start
|
||||
const p = Math.min(1, elapsed / heroAutoAdvanceMs)
|
||||
setProgress(p)
|
||||
if (p >= 1) {
|
||||
directionRef.current = 1
|
||||
setIndex(i => (i + 1) % total)
|
||||
}
|
||||
}, 50)
|
||||
return () => clearInterval(id)
|
||||
}, [index, paused, total, advanceDisabled, heroAutoAdvanceMs])
|
||||
|
||||
/* Keyboard nav */
|
||||
useEffect(() => {
|
||||
if (total <= 1) return
|
||||
function handle(e: KeyboardEvent) {
|
||||
const tag = (document.activeElement?.tagName || '').toLowerCase()
|
||||
if (tag === 'input' || tag === 'textarea' || tag === 'select') return
|
||||
if (e.key === 'ArrowLeft') {
|
||||
directionRef.current = -1
|
||||
setIndex(i => (i - 1 + total) % total)
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
directionRef.current = 1
|
||||
setIndex(i => (i + 1) % total)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handle)
|
||||
return () => window.removeEventListener('keydown', handle)
|
||||
}, [total])
|
||||
|
||||
function goPrev() {
|
||||
directionRef.current = -1
|
||||
setIndex(i => (i - 1 + total) % total)
|
||||
}
|
||||
function goNext() {
|
||||
directionRef.current = 1
|
||||
setIndex(i => (i + 1) % total)
|
||||
}
|
||||
function goTo(i: number) {
|
||||
directionRef.current = i > index ? 1 : -1
|
||||
setIndex(i)
|
||||
}
|
||||
|
||||
if (!item) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative h-[62vh] min-h-[460px] max-h-[680px] -mt-14 mb-2 overflow-hidden group/hero"
|
||||
onMouseEnter={() => setPaused(true)}
|
||||
onMouseLeave={() => setPaused(false)}
|
||||
role="region"
|
||||
aria-label="Featured titles"
|
||||
aria-roledescription="carousel"
|
||||
>
|
||||
<AnimatePresence mode="sync" initial={false}>
|
||||
<FeaturedSlide
|
||||
key={item.Id}
|
||||
item={item}
|
||||
navigate={navigate}
|
||||
direction={directionRef.current}
|
||||
/>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Edge gradients to keep arrows + title legible regardless of backdrop */}
|
||||
<div className="absolute inset-y-0 left-0 w-32 bg-gradient-to-r from-void/40 to-transparent pointer-events-none opacity-0 group-hover/hero:opacity-100 transition-opacity duration-300" />
|
||||
<div className="absolute inset-y-0 right-0 w-32 bg-gradient-to-l from-void/40 to-transparent pointer-events-none opacity-0 group-hover/hero:opacity-100 transition-opacity duration-300" />
|
||||
|
||||
{/* Bottom gradient blend into rows */}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-32 bg-gradient-to-b from-transparent to-void pointer-events-none" />
|
||||
|
||||
{/* Manual nav arrows - only when more than 1 slide */}
|
||||
{total > 1 && (
|
||||
<>
|
||||
<CarouselArrow side="left" onClick={goPrev} />
|
||||
<CarouselArrow side="right" onClick={goNext} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Indicators - Netflix-style filling progress bars */}
|
||||
{total > 1 && (
|
||||
<div className="absolute bottom-6 left-7 right-7 z-10 flex items-center gap-3 pointer-events-none">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{items.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => goTo(i)}
|
||||
aria-label={`Go to slide ${i + 1}`}
|
||||
className="group/dot relative h-1 w-9 rounded-full bg-white/15 overflow-hidden pointer-events-auto cursor-pointer hover:h-1.5 transition-all duration-150"
|
||||
>
|
||||
<span
|
||||
className="absolute inset-y-0 left-0 bg-accent rounded-full"
|
||||
style={{
|
||||
width: i < index ? '100%' : i === index ? `${progress * 100}%` : '0%',
|
||||
transition: i === index ? 'none' : 'width 200ms ease-out',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="ml-auto text-[10px] uppercase tracking-[0.18em] font-semibold text-white/45 tabular-nums">
|
||||
{String(index + 1).padStart(2, '0')} / {String(total).padStart(2, '0')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselArrow({ side, onClick }: { side: 'left' | 'right'; onClick: () => void }) {
|
||||
const Icon = side === 'left' ? ChevronLeft : ChevronRight
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
aria-label={side === 'left' ? 'Previous slide' : 'Next slide'}
|
||||
className={`absolute top-1/2 -translate-y-1/2 z-10 w-12 h-12 grid place-items-center rounded-full glass-strong border border-white/15 text-white/85 hover:text-white hover:scale-105 active:scale-95 transition-all duration-200 shadow-lg shadow-black/40 opacity-0 group-hover/hero:opacity-100 focus-ring ${
|
||||
side === 'left' ? 'left-5' : 'right-5'
|
||||
}`}
|
||||
>
|
||||
<Icon size={18} stroke={2.25} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function FeaturedSlide({
|
||||
item,
|
||||
navigate,
|
||||
direction,
|
||||
}: {
|
||||
item: BaseItemDto
|
||||
navigate: ReturnType<typeof useNavigate>
|
||||
direction: 1 | -1
|
||||
}) {
|
||||
const serverUrl = getStoredServerUrl()
|
||||
|
||||
const reduceMotion = usePreferencesStore(s => s.reduceMotion)
|
||||
|
||||
const backdrop = getBestImage(serverUrl, item, 'backdrop', 1920)
|
||||
const logo = item.Id && item.ImageTags?.Logo
|
||||
? getImageUrl(serverUrl, item.Id, 'Logo', 600, item.ImageTags.Logo)
|
||||
: null
|
||||
|
||||
const title = item.Name || ''
|
||||
const overview = item.Overview || ''
|
||||
const year = item.ProductionYear
|
||||
const rating = item.OfficialRating
|
||||
const runtime = item.RunTimeTicks ? formatRuntime(item.RunTimeTicks) : null
|
||||
const genres = (item.Genres || []).slice(0, 3)
|
||||
const community = item.CommunityRating
|
||||
const progress = item.UserData?.PlayedPercentage
|
||||
const hasProgress = typeof progress === 'number' && progress > 0
|
||||
const isSeries = item.Type === 'Series'
|
||||
const TypeIcon = isSeries ? Tv2 : Film
|
||||
const tagline = (item as { Taglines?: string[] }).Taglines?.[0]
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={item.Id}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.7, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="absolute inset-0"
|
||||
>
|
||||
{/* Ken Burns backdrop - slow scale + horizontal pan, disabled when reduce-motion is on */}
|
||||
{backdrop && (
|
||||
<motion.div
|
||||
initial={reduceMotion ? { scale: 1, x: 0 } : { scale: 1.04, x: direction === 1 ? -12 : 12 }}
|
||||
animate={reduceMotion ? { scale: 1, x: 0 } : { scale: 1.14, x: direction === 1 ? 12 : -12 }}
|
||||
transition={reduceMotion ? { duration: 0 } : { duration: 14, ease: 'linear' }}
|
||||
className="absolute inset-0 bg-cover bg-top will-change-transform"
|
||||
style={{ backgroundImage: `url(${backdrop})` }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Multi-direction gradient masks for legibility */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-void via-void/55 to-transparent" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-void via-void/40 to-transparent" />
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_75%_30%,rgba(0,0,0,0)_0%,rgba(0,0,0,0.45)_75%)]" />
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative h-full flex items-end pb-20 px-7">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1], delay: 0.18 }}
|
||||
className="max-w-2xl"
|
||||
>
|
||||
{/* Eyebrow */}
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="inline-flex items-center gap-1.5 h-6 px-2.5 rounded-full bg-accent/12 border border-accent/30 text-accent text-[10px] font-semibold uppercase tracking-[0.14em]">
|
||||
<Flame size={10} strokeWidth={2.5} fill="currentColor" />
|
||||
Featured
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5 h-6 px-2.5 rounded-full bg-white/8 border border-white/12 backdrop-blur text-[10px] font-semibold uppercase tracking-[0.14em] text-white/85">
|
||||
<TypeIcon size={10} className="text-accent" stroke={2} />
|
||||
{isSeries ? 'Series' : 'Movie'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Title - logo if available, else display text */}
|
||||
{logo ? (
|
||||
<img
|
||||
src={logo}
|
||||
alt={title}
|
||||
className="block h-auto max-h-32 md:max-h-36 max-w-[560px] w-auto mb-4 drop-shadow-[0_4px_24px_rgba(0,0,0,0.7)]"
|
||||
onError={e => { (e.target as HTMLImageElement).style.display = 'none' }}
|
||||
/>
|
||||
) : (
|
||||
<h1 className="font-display text-5xl md:text-6xl font-bold text-white leading-[0.95] tracking-tight mb-4 drop-shadow-[0_4px_16px_rgba(0,0,0,0.6)]">
|
||||
{title}
|
||||
</h1>
|
||||
)}
|
||||
|
||||
{tagline && (
|
||||
<p className="text-white/70 text-[15px] italic mb-3 max-w-xl font-display drop-shadow-sm">
|
||||
"{tagline}"
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Meta line */}
|
||||
<div className="flex items-center gap-2.5 text-[12px] text-white/75 mb-3 flex-wrap font-medium">
|
||||
{year && <span className="tabular-nums">{year}</span>}
|
||||
{year && (rating || runtime) && <Dot />}
|
||||
{rating && (
|
||||
<span className="px-1.5 py-0.5 border border-white/25 rounded text-[10px] font-semibold tracking-wide">
|
||||
{rating}
|
||||
</span>
|
||||
)}
|
||||
{rating && runtime && <Dot />}
|
||||
{runtime && (
|
||||
<span className="inline-flex items-center gap-1 tabular-nums">
|
||||
<Clock size={11} stroke={2} />
|
||||
{runtime}
|
||||
</span>
|
||||
)}
|
||||
{community != null && community > 0 && (
|
||||
<>
|
||||
<Dot />
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Star size={11} className="text-accent" fill="currentColor" stroke={0} />
|
||||
<span className="tabular-nums">{community.toFixed(1)}</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Genres */}
|
||||
{genres.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-5">
|
||||
{genres.map(g => (
|
||||
<span
|
||||
key={g}
|
||||
className="inline-flex items-center h-6 px-2.5 bg-white/8 backdrop-blur text-white/85 text-[11px] rounded-full border border-white/8"
|
||||
>
|
||||
{g}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overview */}
|
||||
{overview && (
|
||||
<p className="text-[14px] text-white/85 leading-[1.65] mb-6 line-clamp-3 max-w-xl drop-shadow-sm">
|
||||
{overview}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2.5 flex-wrap">
|
||||
<button
|
||||
onClick={() => item.Id && navigate(`/play/${item.Id}${hasProgress ? '?resume=true' : ''}`)}
|
||||
className="group relative h-11 px-6 bg-white text-void rounded-lg flex items-center gap-2 text-[14px] font-semibold transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] shadow-lg shadow-black/40 focus-ring"
|
||||
>
|
||||
<Play size={15} stroke={0} fill="currentColor" />
|
||||
{hasProgress ? 'Resume' : 'Play'}
|
||||
{hasProgress && (
|
||||
<span className="absolute -bottom-1 left-2 right-2 h-[2px] bg-void/15 rounded-full overflow-hidden">
|
||||
<span className="block h-full bg-accent" style={{ width: `${progress}%` }} />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => item.Id && navigate(`/item/${item.Id}`)}
|
||||
className="h-11 px-5 bg-white/10 text-white border border-white/20 backdrop-blur rounded-lg flex items-center gap-2 text-[14px] font-medium transition-all duration-200 hover:bg-white/15 hover:border-white/30 hover:scale-[1.02] active:scale-[0.98] focus-ring"
|
||||
>
|
||||
<Info size={15} stroke={2} />
|
||||
More info
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user