detail page components
This commit is contained in:
@@ -0,0 +1,95 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import type { WikidataAward } from '../../api/wikidata'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
awards: WikidataAward[] | null | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRIORITY_KEYWORDS = [
|
||||||
|
'Academy Award',
|
||||||
|
'Oscar',
|
||||||
|
'Golden Globe',
|
||||||
|
'BAFTA',
|
||||||
|
'Emmy',
|
||||||
|
'Cannes',
|
||||||
|
'Palme',
|
||||||
|
'Berlin',
|
||||||
|
'Sundance',
|
||||||
|
'Venice',
|
||||||
|
'Critics',
|
||||||
|
'SAG',
|
||||||
|
'Hugo',
|
||||||
|
'Nebula',
|
||||||
|
'Saturn',
|
||||||
|
'MTV',
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact award grid pulled from Wikidata P166. We surface the most
|
||||||
|
* recognised prizes first (Oscars, Globes, BAFTAs, Emmys, etc.), then
|
||||||
|
* dedupe and cap at a sensible count. A "Show all" toggle reveals the
|
||||||
|
* remainder when there are many.
|
||||||
|
*/
|
||||||
|
export default function AwardsBlock({ awards }: Props) {
|
||||||
|
const [expanded, setExpanded] = useState(false)
|
||||||
|
|
||||||
|
const ordered = useMemo(() => {
|
||||||
|
if (!awards) return []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const unique: WikidataAward[] = []
|
||||||
|
for (const a of awards) {
|
||||||
|
const key = a.label
|
||||||
|
if (seen.has(key)) continue
|
||||||
|
seen.add(key)
|
||||||
|
unique.push(a)
|
||||||
|
}
|
||||||
|
return unique.sort((a, b) => priority(a) - priority(b))
|
||||||
|
}, [awards])
|
||||||
|
|
||||||
|
if (ordered.length === 0) return null
|
||||||
|
const visible = expanded ? ordered : ordered.slice(0, 12)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{visible.map((a, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={a.id || a.label}
|
||||||
|
initial={{ opacity: 0, y: 4 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.25, delay: Math.min(i * 0.02, 0.3) }}
|
||||||
|
title={[a.ceremony, a.point_in_time?.slice(0, 4), a.for_work].filter(Boolean).join(' · ')}
|
||||||
|
className="inline-flex items-center gap-1.5 h-7 px-3 bg-elevated/60 ring-1 ring-border hover:ring-border-strong rounded-full text-[11.5px] text-text-2 tracking-tight"
|
||||||
|
>
|
||||||
|
<svg className="w-3 h-3 text-amber-400" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path d="M10 2a1 1 0 011 1v.5a4.5 4.5 0 014.5 4.5v.5h.5a1.5 1.5 0 010 3H15v.5a5 5 0 01-4 4.9V18h2a1 1 0 110 2H7a1 1 0 110-2h2v-1.1A5 5 0 015 12v-.5h-.5a1.5 1.5 0 010-3H5V8a4.5 4.5 0 014.5-4.5V3a1 1 0 011-1z" />
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">{a.label}</span>
|
||||||
|
{a.point_in_time && (
|
||||||
|
<span className="text-text-4 tabular-nums">
|
||||||
|
{a.point_in_time.slice(0, 4)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{ordered.length > 12 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded(v => !v)}
|
||||||
|
className="mt-3 text-[11.5px] text-text-3 hover:text-accent transition tracking-tight focus-ring rounded"
|
||||||
|
>
|
||||||
|
{expanded ? 'Show fewer' : `Show all ${ordered.length}`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function priority(a: WikidataAward): number {
|
||||||
|
const label = a.label || ''
|
||||||
|
for (let i = 0; i < PRIORITY_KEYWORDS.length; i++) {
|
||||||
|
if (label.includes(PRIORITY_KEYWORDS[i])) return i
|
||||||
|
}
|
||||||
|
return PRIORITY_KEYWORDS.length
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import HorizontalScroller from '../ui/HorizontalScroller'
|
||||||
|
import { getTmdbImageUrl } from '../../api/tmdb'
|
||||||
|
|
||||||
|
interface FallbackPerson {
|
||||||
|
Id?: string | null
|
||||||
|
Name?: string | null
|
||||||
|
Role?: string | null
|
||||||
|
PrimaryImageTag?: string | null
|
||||||
|
Type?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
cast: any[]
|
||||||
|
fallbackPeople?: FallbackPerson[] | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CastList({ cast, fallbackPeople }: Props) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const list = cast.length ? cast : (fallbackPeople || []).filter((p: any) => p.Type === 'Actor')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="-mx-7">
|
||||||
|
<HorizontalScroller gap="gap-2" arrowsBottomInset="bottom-8">
|
||||||
|
{list.map((c: any, i: number) => (
|
||||||
|
<motion.button
|
||||||
|
key={`${c.id ?? c.Id ?? c.name ?? c.Name ?? i}`}
|
||||||
|
onClick={() => c.id && navigate(`/person/${c.id}`)}
|
||||||
|
initial={{ opacity: 0, y: 6 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: Math.min(i * 0.025, 0.4) }}
|
||||||
|
whileHover={{ y: -2 }}
|
||||||
|
className="flex flex-col items-center text-center shrink-0 w-[88px] group focus-ring rounded-lg p-1"
|
||||||
|
>
|
||||||
|
<div className="w-16 h-16 rounded-full overflow-hidden bg-elevated mb-2 ring-1 ring-border group-hover:ring-accent/30 transition-all duration-200">
|
||||||
|
{c.profile_path ? (
|
||||||
|
<img
|
||||||
|
src={getTmdbImageUrl(c.profile_path, 'w185')}
|
||||||
|
alt={c.name}
|
||||||
|
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full grid place-items-center text-text-3 text-[15px] font-display font-semibold bg-gradient-to-br from-elevated to-higher">
|
||||||
|
{(c.name || c.Name || '?')[0]}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] text-text-1 truncate w-full leading-tight font-medium">
|
||||||
|
{c.name || c.Name}
|
||||||
|
</span>
|
||||||
|
{(c.character || c.Role) && (
|
||||||
|
<span className="text-[10px] text-text-4 truncate w-full leading-tight mt-0.5">
|
||||||
|
{c.character || c.Role}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</motion.button>
|
||||||
|
))}
|
||||||
|
</HorizontalScroller>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { MonitorPlay, Tv, Loader2 } from '../../lib/icons'
|
||||||
|
import { listCastTargets, sendToSession, type CastTarget } from '../../lib/cast'
|
||||||
|
import { toast } from '../../stores/toast-store'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
itemId: string
|
||||||
|
mediaType?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Play on..." picker. Pops a small list of other Jellyfin sessions that
|
||||||
|
* advertise SupportsRemoteControl, filtered by what they can play. Click
|
||||||
|
* a target to fire `Sessions/{id}/Playing` and hand the item off.
|
||||||
|
*/
|
||||||
|
export default function CastMenu({ itemId, mediaType }: Props) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [targets, setTargets] = useState<CastTarget[]>([])
|
||||||
|
const [sendingTo, setSendingTo] = useState<string | null>(null)
|
||||||
|
const wrapRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
function onDocClick(e: MouseEvent) {
|
||||||
|
if (!wrapRef.current) return
|
||||||
|
if (e.target instanceof Node && !wrapRef.current.contains(e.target)) {
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onEsc(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') setOpen(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', onDocClick)
|
||||||
|
document.addEventListener('keydown', onEsc)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', onDocClick)
|
||||||
|
document.removeEventListener('keydown', onEsc)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
let cancelled = false
|
||||||
|
setLoading(true)
|
||||||
|
listCastTargets(mediaType)
|
||||||
|
.then(t => {
|
||||||
|
if (!cancelled) setTargets(t)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setTargets([])
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [open, mediaType])
|
||||||
|
|
||||||
|
async function castTo(target: CastTarget) {
|
||||||
|
setSendingTo(target.sessionId)
|
||||||
|
try {
|
||||||
|
await sendToSession(target.sessionId, itemId)
|
||||||
|
toast(`Sent to ${target.deviceName}`, 'success')
|
||||||
|
setOpen(false)
|
||||||
|
} catch {
|
||||||
|
toast('Could not send to that device', 'error')
|
||||||
|
} finally {
|
||||||
|
setSendingTo(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={wrapRef} className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(o => !o)}
|
||||||
|
aria-label="Play on another device"
|
||||||
|
title="Play on another device"
|
||||||
|
aria-expanded={open}
|
||||||
|
className={`w-11 h-11 rounded-lg backdrop-blur grid place-items-center transition-all duration-200 hover:scale-105 active:scale-95 focus-ring ${
|
||||||
|
open
|
||||||
|
? 'bg-accent/15 border border-accent/30'
|
||||||
|
: 'bg-white/10 border border-white/15 hover:bg-white/15'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<MonitorPlay size={16} className="text-white/80" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 6, scale: 0.97 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: 6, scale: 0.97 }}
|
||||||
|
transition={{ duration: 0.18, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
className="absolute bottom-full mb-2 right-0 w-[280px] z-30 bg-[#0c0a08]/96 backdrop-blur-xl border border-white/14 rounded-xl shadow-[0_20px_50px_-15px_rgba(0,0,0,0.7)] overflow-hidden"
|
||||||
|
role="menu"
|
||||||
|
>
|
||||||
|
<div className="px-3.5 pt-3 pb-2 border-b border-white/8">
|
||||||
|
<p className="text-[10px] uppercase tracking-[0.16em] font-semibold text-white/55">
|
||||||
|
Play on
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[280px] overflow-y-auto content-scroll py-1.5">
|
||||||
|
{loading && (
|
||||||
|
<div className="px-3.5 py-4 flex items-center gap-2 text-[12px] text-white/60">
|
||||||
|
<Loader2 size={13} className="animate-spin" />
|
||||||
|
Looking for devices...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loading && targets.length === 0 && (
|
||||||
|
<p className="px-3.5 py-4 text-[12px] text-white/55 leading-snug">
|
||||||
|
No other devices are signed in to this server right now.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!loading &&
|
||||||
|
targets.map(t => {
|
||||||
|
const isSending = sendingTo === t.sessionId
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={t.sessionId}
|
||||||
|
onClick={() => castTo(t)}
|
||||||
|
disabled={!!sendingTo}
|
||||||
|
className="w-full flex items-center gap-3 px-3.5 py-2.5 text-left hover:bg-white/6 transition-colors disabled:opacity-50 focus-ring"
|
||||||
|
>
|
||||||
|
<span className="w-8 h-8 rounded-md bg-white/8 grid place-items-center shrink-0">
|
||||||
|
{isSending ? (
|
||||||
|
<Loader2 size={13} className="text-white/70 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Tv size={14} className="text-white/70" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 min-w-0">
|
||||||
|
<span className="block text-[12.5px] font-medium text-white tracking-tight truncate">
|
||||||
|
{t.deviceName}
|
||||||
|
</span>
|
||||||
|
<span className="block text-[10.5px] text-white/50 truncate">
|
||||||
|
{[t.client, t.userName].filter(Boolean).join(' - ')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { Boxes, ChevronRight, Check } from '../../lib/icons'
|
||||||
|
import { getTmdbImageUrl, type TmdbCollectionRef, type TmdbCollection } from '../../api/tmdb'
|
||||||
|
import { useLibraryByTmdbId } from '../../hooks/use-jellyfin'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
collectionRef?: TmdbCollectionRef | null
|
||||||
|
collection?: TmdbCollection | null
|
||||||
|
currentMovieId?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CollectionStrip({ collectionRef, collection, currentMovieId }: Props) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { data: libraryMap } = useLibraryByTmdbId()
|
||||||
|
const ref = collection || collectionRef
|
||||||
|
if (!ref) return null
|
||||||
|
|
||||||
|
// Sort by release date ascending. Items with no release date (or an
|
||||||
|
// unparseable one) sort to the END so unannounced sequels don't hijack
|
||||||
|
// the #1 slot just because their date field is empty.
|
||||||
|
const parts = (collection?.parts || []).slice().sort((a, b) => {
|
||||||
|
const ta = a.release_date ? Date.parse(a.release_date) : NaN
|
||||||
|
const tb = b.release_date ? Date.parse(b.release_date) : NaN
|
||||||
|
const da = Number.isFinite(ta) ? ta : Number.POSITIVE_INFINITY
|
||||||
|
const db = Number.isFinite(tb) ? tb : Number.POSITIVE_INFINITY
|
||||||
|
return da - db
|
||||||
|
})
|
||||||
|
const inLibraryCount = libraryMap
|
||||||
|
? parts.reduce((acc, p) => acc + (libraryMap.has(String(p.id)) ? 1 : 0), 0)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative overflow-hidden rounded-xl border border-border">
|
||||||
|
{/* Backdrop */}
|
||||||
|
{ref.backdrop_path && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-cover bg-center"
|
||||||
|
style={{ backgroundImage: `url(${getTmdbImageUrl(ref.backdrop_path, 'w1280')})` }}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-void/95 via-void/85 to-void/55" />
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-void/95 to-transparent" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!ref.backdrop_path && <div className="absolute inset-0 bg-gradient-to-br from-elevated to-void" />}
|
||||||
|
|
||||||
|
<div className="relative p-5">
|
||||||
|
<div className="flex items-center justify-between mb-3 gap-3 flex-wrap">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/collection/${ref.id}`)}
|
||||||
|
className="group flex items-center gap-2 text-left focus-ring rounded-md"
|
||||||
|
>
|
||||||
|
<Boxes size={14} className="text-accent" />
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-[0.14em] text-text-3">Part of</span>
|
||||||
|
<span className="text-[15px] font-semibold tracking-tight text-white">{ref.name}</span>
|
||||||
|
<ChevronRight size={14} className="text-text-3 transition-transform duration-200 group-hover:translate-x-0.5" />
|
||||||
|
</button>
|
||||||
|
{parts.length > 0 && libraryMap && (
|
||||||
|
<span className="text-[11px] text-white/60 tracking-tight">
|
||||||
|
<span className="tabular-nums text-white font-semibold">{inLibraryCount}</span>
|
||||||
|
<span className="text-white/45"> of </span>
|
||||||
|
<span className="tabular-nums text-white">{parts.length}</span>
|
||||||
|
<span className="text-white/45"> in your library · watch in release order</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{parts.length > 0 ? (
|
||||||
|
<div className="flex gap-3 overflow-x-auto hide-scrollbar -mx-1 px-1 py-1">
|
||||||
|
{parts.map((m, i) => {
|
||||||
|
const isCurrent = currentMovieId != null && m.id === currentMovieId
|
||||||
|
const inLibrary = libraryMap?.has(String(m.id)) ?? false
|
||||||
|
return (
|
||||||
|
<motion.button
|
||||||
|
key={m.id}
|
||||||
|
onClick={() => !isCurrent && navigate(`/item/tmdb-${m.id}`)}
|
||||||
|
initial={{ opacity: 0, y: 6 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: Math.min(i * 0.04, 0.4) }}
|
||||||
|
whileHover={{ y: -3 }}
|
||||||
|
className={`shrink-0 w-[110px] focus-ring rounded-md text-left ${isCurrent ? 'cursor-default' : ''}`}
|
||||||
|
>
|
||||||
|
<div className={`relative aspect-[2/3] rounded-md overflow-hidden bg-elevated ring-1 ${
|
||||||
|
isCurrent ? 'ring-accent' : 'ring-border'
|
||||||
|
}`}>
|
||||||
|
{m.poster_path ? (
|
||||||
|
<img
|
||||||
|
src={getTmdbImageUrl(m.poster_path, 'w300')}
|
||||||
|
alt={m.title}
|
||||||
|
className={`w-full h-full object-cover transition ${
|
||||||
|
inLibrary || isCurrent ? '' : 'opacity-60 saturate-50'
|
||||||
|
}`}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full grid place-items-center text-text-3 text-2xl font-display">
|
||||||
|
{m.title?.[0]}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={`absolute top-1.5 left-1.5 inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full text-[10px] font-bold tabular-nums tracking-tight ${
|
||||||
|
isCurrent
|
||||||
|
? 'bg-accent text-void'
|
||||||
|
: inLibrary
|
||||||
|
? 'bg-white/85 text-void'
|
||||||
|
: 'bg-black/60 text-white/80 ring-1 ring-white/15'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
{inLibrary && !isCurrent && (
|
||||||
|
<span className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-accent/90 text-void grid place-items-center" title="In your library">
|
||||||
|
<Check size={11} strokeWidth={3} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!inLibrary && !isCurrent && libraryMap && (
|
||||||
|
<span className="absolute bottom-1.5 left-1.5 right-1.5 text-center text-[9px] uppercase tracking-[0.12em] font-semibold text-white/85 bg-black/55 backdrop-blur-sm rounded px-1 py-0.5">
|
||||||
|
Missing
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isCurrent && (
|
||||||
|
<div className="absolute inset-0 bg-accent/15 grid place-items-end justify-center pb-2">
|
||||||
|
<span className="px-2 h-5 inline-flex items-center bg-accent text-void rounded-full text-[9px] font-bold uppercase tracking-wider">
|
||||||
|
Current
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-[11.5px] text-white/90 truncate mt-1.5 font-medium">{m.title}</p>
|
||||||
|
{m.release_date && (
|
||||||
|
<p className="text-[10px] text-white/55 truncate tabular-nums">{m.release_date.slice(0, 4)}</p>
|
||||||
|
)}
|
||||||
|
</motion.button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-[12px] text-text-3 italic">Loading collection...</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import type { TmdbCastMember } from '../../api/tmdb'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
crew: TmdbCastMember[] | null | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMPOSER_JOBS = new Set([
|
||||||
|
'Original Music Composer',
|
||||||
|
'Music',
|
||||||
|
'Composer',
|
||||||
|
'Theme Song Performance',
|
||||||
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Theme by ..." line surfaced from TMDB credits.crew. Surfaces the
|
||||||
|
* composer(s) without burying them in the dense crew strip. Renders
|
||||||
|
* nothing when there's no composer credit.
|
||||||
|
*/
|
||||||
|
export default function ComposerBlock({ crew }: Props) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
if (!crew || crew.length === 0) return null
|
||||||
|
|
||||||
|
const seen = new Set<number>()
|
||||||
|
const composers: TmdbCastMember[] = []
|
||||||
|
for (const c of crew) {
|
||||||
|
if (!c.job || !COMPOSER_JOBS.has(c.job)) continue
|
||||||
|
if (seen.has(c.id)) continue
|
||||||
|
seen.add(c.id)
|
||||||
|
composers.push(c)
|
||||||
|
if (composers.length >= 3) break
|
||||||
|
}
|
||||||
|
if (composers.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p className="text-[12.5px] text-text-3 leading-relaxed">
|
||||||
|
<span className="text-text-4 mr-1.5">Theme by</span>
|
||||||
|
{composers.map((c, i) => (
|
||||||
|
<span key={c.id}>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/person/${c.id}`)}
|
||||||
|
className="text-text-1 font-medium hover:text-accent transition focus-ring rounded"
|
||||||
|
>
|
||||||
|
{c.name}
|
||||||
|
</button>
|
||||||
|
{i < composers.length - 1 && <span className="text-text-5 mx-1.5">·</span>}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { getTmdbImageUrl, type TmdbCastMember } from '../../api/tmdb'
|
||||||
|
import HorizontalScroller from '../ui/HorizontalScroller'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
crew: TmdbCastMember[]
|
||||||
|
limit?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEPT_ORDER = ['Directing', 'Writing', 'Production', 'Camera', 'Editing', 'Sound', 'Visual Effects', 'Art']
|
||||||
|
|
||||||
|
export default function CrewGrid({ crew, limit = 14 }: Props) {
|
||||||
|
if (!crew?.length) return null
|
||||||
|
|
||||||
|
// De-duplicate by person id+job and prioritise high-impact departments
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const top: TmdbCastMember[] = []
|
||||||
|
for (const dept of DEPT_ORDER) {
|
||||||
|
for (const c of crew) {
|
||||||
|
if (c.department !== dept) continue
|
||||||
|
const key = `${c.id}-${c.job}`
|
||||||
|
if (seen.has(key)) continue
|
||||||
|
seen.add(key)
|
||||||
|
top.push(c)
|
||||||
|
if (top.length >= limit) break
|
||||||
|
}
|
||||||
|
if (top.length >= limit) break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!top.length) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="-mx-7">
|
||||||
|
<HorizontalScroller gap="gap-2" arrowsBottomInset="bottom-8">
|
||||||
|
{top.map((c, i) => (
|
||||||
|
<CrewMember key={`${c.id}-${c.credit_id ?? i}`} member={c} index={i} />
|
||||||
|
))}
|
||||||
|
</HorizontalScroller>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CrewMember({ member, index }: { member: TmdbCastMember; index: number }) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
return (
|
||||||
|
<motion.button
|
||||||
|
initial={{ opacity: 0, y: 6 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: Math.min(index * 0.025, 0.4) }}
|
||||||
|
whileHover={{ y: -2 }}
|
||||||
|
onClick={() => member.id && navigate(`/person/${member.id}`)}
|
||||||
|
className="flex flex-col items-center text-center shrink-0 w-[88px] group focus-ring rounded-lg p-1"
|
||||||
|
>
|
||||||
|
<div className="w-16 h-16 rounded-full overflow-hidden bg-elevated mb-2 ring-1 ring-border group-hover:ring-accent/30 transition-all duration-200">
|
||||||
|
{member.profile_path ? (
|
||||||
|
<img
|
||||||
|
src={getTmdbImageUrl(member.profile_path, 'w185')}
|
||||||
|
alt={member.name}
|
||||||
|
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full grid place-items-center text-text-3 text-[15px] font-display font-semibold bg-gradient-to-br from-elevated to-higher">
|
||||||
|
{member.name[0]}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] text-text-1 truncate w-full leading-tight font-medium">{member.name}</span>
|
||||||
|
{member.job && (
|
||||||
|
<span className="text-[10px] text-text-4 truncate w-full leading-tight mt-0.5">{member.job}</span>
|
||||||
|
)}
|
||||||
|
</motion.button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,465 @@
|
|||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { useNavigate, type NavigateFunction } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
Play,
|
||||||
|
Heart,
|
||||||
|
Clock,
|
||||||
|
Star,
|
||||||
|
Plus,
|
||||||
|
Share2,
|
||||||
|
ChevronRight,
|
||||||
|
RefreshCw,
|
||||||
|
Tv2,
|
||||||
|
Film as FilmIcon,
|
||||||
|
Disc3,
|
||||||
|
} from '../../lib/icons'
|
||||||
|
import type { BaseItemDto } from '../../api/types'
|
||||||
|
import type { TmdbMovie, TmdbTvShow } from '../../api/tmdb'
|
||||||
|
import type { CinemetaMeta } from '../../api/cinemeta'
|
||||||
|
import MetaBadges from '../ui/MetaBadges'
|
||||||
|
import WatchlistButton from '../ui/WatchlistButton'
|
||||||
|
import RequestButton from '../request/RequestButton'
|
||||||
|
import ExternalLinks from './ExternalLinks'
|
||||||
|
import HeroTechCard from './HeroTechCard'
|
||||||
|
import CastMenu from './CastMenu'
|
||||||
|
import { useYoutubeViewer } from '../../stores/youtube-viewer-store'
|
||||||
|
import { usePreferencesStore } from '../../stores/preferences-store'
|
||||||
|
import type { useRefreshItem, useSampleEpisode } from '../../hooks/use-jellyfin'
|
||||||
|
import { buildShareUrl, copyToClipboard } from '../../lib/share'
|
||||||
|
import { toast } from '../../stores/toast-store'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: BaseItemDto
|
||||||
|
itemType: string
|
||||||
|
tmdbMovieData: TmdbMovie | null | undefined
|
||||||
|
tmdbTvData: TmdbTvShow | null | undefined
|
||||||
|
tmdbId: string | null | undefined
|
||||||
|
posterUrl: string
|
||||||
|
logoUrl: string | null
|
||||||
|
backdropUrl: string | null
|
||||||
|
title: string
|
||||||
|
year?: number | null
|
||||||
|
rating: string | null | undefined
|
||||||
|
runtime: string | null
|
||||||
|
taglineText: string | null | undefined
|
||||||
|
genres: string[]
|
||||||
|
resumeTime: string | null
|
||||||
|
progress: number | null | undefined
|
||||||
|
isFavorite: boolean | null | undefined
|
||||||
|
playUrl: string
|
||||||
|
resumeUrl: string
|
||||||
|
trailer: { key: string; official?: boolean } | null | undefined
|
||||||
|
cinemetaData: CinemetaMeta | null | undefined
|
||||||
|
showTmdbRatings: boolean
|
||||||
|
sampleEpisode: ReturnType<typeof useSampleEpisode>
|
||||||
|
refresh: ReturnType<typeof useRefreshItem>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DetailHero({
|
||||||
|
item,
|
||||||
|
itemType,
|
||||||
|
tmdbMovieData,
|
||||||
|
tmdbTvData,
|
||||||
|
tmdbId,
|
||||||
|
posterUrl,
|
||||||
|
logoUrl,
|
||||||
|
backdropUrl,
|
||||||
|
title,
|
||||||
|
year,
|
||||||
|
rating,
|
||||||
|
runtime,
|
||||||
|
taglineText,
|
||||||
|
genres,
|
||||||
|
resumeTime,
|
||||||
|
progress,
|
||||||
|
isFavorite,
|
||||||
|
playUrl,
|
||||||
|
resumeUrl,
|
||||||
|
trailer,
|
||||||
|
cinemetaData,
|
||||||
|
showTmdbRatings,
|
||||||
|
sampleEpisode,
|
||||||
|
refresh,
|
||||||
|
}: Props) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const tmdbData = itemType === 'Movie' ? tmdbMovieData : tmdbTvData
|
||||||
|
const preRollTrailers = usePreferencesStore(s => s.preRollTrailers)
|
||||||
|
const youtube = useYoutubeViewer()
|
||||||
|
|
||||||
|
function handlePlay() {
|
||||||
|
if (preRollTrailers && trailer && itemType === 'Movie') {
|
||||||
|
youtube.show({
|
||||||
|
videoKey: trailer.key,
|
||||||
|
title: `${title} - Trailer`,
|
||||||
|
subtitle: trailer.official ? 'Official trailer' : 'Trailer',
|
||||||
|
onClose: () => navigate(playUrl),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
navigate(playUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative h-[68vh] min-h-[520px] -mt-14 overflow-hidden">
|
||||||
|
{backdropUrl && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 1.06, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
transition={{ duration: 1.4, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
className="absolute inset-0 bg-cover bg-top"
|
||||||
|
style={{ backgroundImage: `url(${backdropUrl})` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-void via-void/60 to-void/10" />
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-void via-void/30 to-transparent" />
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_75%_25%,transparent_0%,rgba(0,0,0,0.5)_85%)]" />
|
||||||
|
|
||||||
|
<div className="relative h-full flex items-end pb-12 px-7 gap-7">
|
||||||
|
{/* Left column: type pill + poster */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1], delay: 0.1 }}
|
||||||
|
className="hidden md:flex flex-col gap-3 shrink-0"
|
||||||
|
>
|
||||||
|
{itemType && <TypePill itemType={itemType} />}
|
||||||
|
{posterUrl && (
|
||||||
|
<motion.img
|
||||||
|
src={posterUrl}
|
||||||
|
alt={title}
|
||||||
|
initial={{ opacity: 0, scale: 0.96 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ duration: 0.7, ease: [0.16, 1, 0.3, 1], delay: 0.15 }}
|
||||||
|
onError={e => { (e.target as HTMLImageElement).style.display = 'none' }}
|
||||||
|
className="w-[180px] aspect-[2/3] object-cover rounded-xl ring-1 ring-white/10 shadow-[0_24px_60px_-12px_rgba(0,0,0,0.7)]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1], delay: 0.2 }}
|
||||||
|
className="flex-1 min-w-0 max-w-3xl"
|
||||||
|
>
|
||||||
|
{/* Type pill (small screens only - shown above poster on md+) */}
|
||||||
|
<div className="flex items-center gap-2 mb-4 flex-wrap md:hidden">
|
||||||
|
{itemType && <TypePill itemType={itemType} />}
|
||||||
|
<MetaBadges item={item} variant="hero" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{itemType === 'Episode' && item.SeriesId && (
|
||||||
|
<EpisodeBreadcrumb item={item} navigate={navigate} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{logoUrl ? (
|
||||||
|
<div className="mb-4 flex justify-start">
|
||||||
|
<img
|
||||||
|
src={logoUrl}
|
||||||
|
alt={title}
|
||||||
|
className="block h-auto max-h-40 md:max-h-48 max-w-[520px] w-auto drop-shadow-[0_4px_24px_rgba(0,0,0,0.7)]"
|
||||||
|
onError={e => { (e.target as HTMLImageElement).style.display = 'none' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<h1 className="font-display text-5xl md:text-6xl font-bold text-white leading-[0.95] tracking-tight mb-4 text-left drop-shadow-[0_4px_16px_rgba(0,0,0,0.55)]">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{taglineText && (
|
||||||
|
<p className="text-white/65 text-[15px] italic mb-3 max-w-xl font-display">
|
||||||
|
"{taglineText}"
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2.5 text-[12px] text-white/70 font-medium mb-3 flex-wrap">
|
||||||
|
{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="flex items-center gap-1 tabular-nums">
|
||||||
|
<Clock size={11} /> {runtime}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* Ratings shown on the right-side card (and here only on small screens for accessibility) */}
|
||||||
|
<span className="lg:hidden flex items-center gap-2.5">
|
||||||
|
{showTmdbRatings && tmdbData?.vote_average != null && tmdbData.vote_average > 0 && (
|
||||||
|
<>
|
||||||
|
<Dot />
|
||||||
|
<span className="flex items-center gap-1" title="TMDB community score">
|
||||||
|
<Star size={11} className="text-accent fill-accent" />
|
||||||
|
<span className="tabular-nums">{tmdbData.vote_average.toFixed(1)}</span>
|
||||||
|
<span className="text-white/45 text-[10px] uppercase tracking-wide">TMDB</span>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{item.CommunityRating != null && item.CommunityRating > 0 && (
|
||||||
|
<>
|
||||||
|
<Dot />
|
||||||
|
<span className="flex items-center gap-1 text-cool" title="Jellyfin community average">
|
||||||
|
<Star size={11} className="fill-current" />
|
||||||
|
<span className="tabular-nums">{item.CommunityRating.toFixed(1)}</span>
|
||||||
|
<span className="text-white/45 text-[10px] uppercase tracking-wide">Jellyfin</span>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{cinemetaData?.imdbRating && Number(cinemetaData.imdbRating) > 0 && (
|
||||||
|
<>
|
||||||
|
<Dot />
|
||||||
|
<span className="flex items-center gap-1" title="IMDB rating">
|
||||||
|
<Star size={11} className="text-yellow-400 fill-yellow-400" />
|
||||||
|
<span className="tabular-nums">{Number(cinemetaData.imdbRating).toFixed(1)}</span>
|
||||||
|
<span className="text-white/45 text-[10px] uppercase tracking-wide">IMDB</span>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{genres.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 mb-4">
|
||||||
|
{genres.slice(0, 5).map(g => (
|
||||||
|
<span
|
||||||
|
key={g}
|
||||||
|
className="px-2.5 h-6 inline-flex items-center bg-white/8 hover:bg-white/12 backdrop-blur text-white/80 text-[11px] rounded-full border border-white/8 transition-colors cursor-default"
|
||||||
|
>
|
||||||
|
{g}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 flex-wrap mb-5">
|
||||||
|
<button
|
||||||
|
onClick={handlePlay}
|
||||||
|
className="group 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/30 focus-ring"
|
||||||
|
>
|
||||||
|
<Play size={16} strokeWidth={0} fill="currentColor" />
|
||||||
|
Play
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{resumeTime && (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(resumeUrl)}
|
||||||
|
className="h-11 px-4 bg-accent/15 text-accent border border-accent/30 hover:bg-accent/20 rounded-lg flex items-center gap-2 text-[13px] font-medium transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] focus-ring"
|
||||||
|
>
|
||||||
|
<Play size={14} strokeWidth={0} fill="currentColor" />
|
||||||
|
Resume from <span className="tabular-nums">{resumeTime}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{trailer && (
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
useYoutubeViewer.getState().show({
|
||||||
|
videoKey: trailer.key,
|
||||||
|
title: `${title} - Trailer`,
|
||||||
|
subtitle: trailer.official ? 'Official trailer' : 'Trailer',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="h-11 px-4 bg-white/10 hover:bg-white/15 text-white border border-white/15 backdrop-blur rounded-lg flex items-center gap-2 text-[13px] font-medium transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] focus-ring"
|
||||||
|
>
|
||||||
|
Trailer
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{item.Id && (item.Type === 'Movie' || item.Type === 'Series') && (
|
||||||
|
<WatchlistButton itemId={item.Id} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tmdbId && (item.Type === 'Movie' || item.Type === 'Series') && (
|
||||||
|
<RequestButton
|
||||||
|
tmdbId={Number(tmdbId)}
|
||||||
|
kind={item.Type === 'Movie' ? 'movie' : 'tv'}
|
||||||
|
tmdbData={tmdbData as any}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<IconButton aria-label={isFavorite ? 'Remove favorite' : 'Add favorite'} active={!!isFavorite}>
|
||||||
|
<Heart size={16} className={isFavorite ? 'text-accent fill-accent' : 'text-white/80'} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton aria-label="Add to playlist">
|
||||||
|
<Plus size={16} className="text-white/80" />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
aria-label="Copy share link"
|
||||||
|
title="Copy share link"
|
||||||
|
onClick={async () => {
|
||||||
|
const url = buildShareUrl({
|
||||||
|
tmdbId,
|
||||||
|
imdbId: item.ProviderIds?.Imdb,
|
||||||
|
kind: itemType === 'Series' || itemType === 'Episode' ? 'tv' : 'movie',
|
||||||
|
})
|
||||||
|
if (!url) {
|
||||||
|
toast('No shareable link for this item', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const ok = await copyToClipboard(url)
|
||||||
|
toast(ok ? 'Link copied to clipboard' : 'Could not copy link', ok ? 'success' : 'error')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Share2 size={16} className="text-white/80" />
|
||||||
|
</IconButton>
|
||||||
|
{item.Id && <CastMenu itemId={item.Id} mediaType={item.MediaType ?? undefined} />}
|
||||||
|
<IconButton
|
||||||
|
aria-label="Refresh metadata"
|
||||||
|
onClick={() => {
|
||||||
|
if (!item.Id) return
|
||||||
|
refresh.mutate({
|
||||||
|
itemId: item.Id,
|
||||||
|
replaceAllMetadata: false,
|
||||||
|
replaceAllImages: false,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
disabled={refresh.isPending}
|
||||||
|
title={
|
||||||
|
refresh.isPending
|
||||||
|
? 'Refreshing...'
|
||||||
|
: refresh.isSuccess
|
||||||
|
? 'Refresh queued'
|
||||||
|
: 'Re-scrape metadata + images from TMDB / TVDB'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
size={16}
|
||||||
|
className={`text-white/80 ${refresh.isPending ? 'animate-[spin-soft_0.8s_linear_infinite]' : ''}`}
|
||||||
|
/>
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ExternalLinks
|
||||||
|
tmdbId={tmdbId}
|
||||||
|
type={itemType === 'Series' ? 'tv' : 'movie'}
|
||||||
|
ids={tmdbData?.external_ids}
|
||||||
|
jellyfinExternalUrls={item.ExternalUrls as any}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{progress != null && progress > 0 && (
|
||||||
|
<div className="mt-5 max-w-md">
|
||||||
|
<div className="flex items-center justify-between text-[10px] text-white/60 mb-1.5 font-medium uppercase tracking-wider">
|
||||||
|
<span>Watch progress</span>
|
||||||
|
<span className="tabular-nums">{Math.round(progress)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1 bg-white/10 rounded-full overflow-hidden">
|
||||||
|
<motion.div
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={{ width: `${progress}%` }}
|
||||||
|
transition={{ duration: 1, ease: [0.16, 1, 0.3, 1], delay: 0.4 }}
|
||||||
|
className="h-full bg-accent shadow-[0_0_8px_rgba(245,182,66,0.6)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 14 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1], delay: 0.35 }}
|
||||||
|
className="self-end"
|
||||||
|
>
|
||||||
|
<HeroTechCard
|
||||||
|
techItem={itemType === 'Series' ? sampleEpisode.data : item}
|
||||||
|
tmdbVote={tmdbData?.vote_average}
|
||||||
|
tmdbVoteCount={tmdbData?.vote_count}
|
||||||
|
jellyfinCommunity={item.CommunityRating}
|
||||||
|
jellyfinCritic={item.CriticRating}
|
||||||
|
imdbRating={
|
||||||
|
cinemetaData?.imdbRating
|
||||||
|
? Number(cinemetaData.imdbRating)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
showTmdbRatings={showTmdbRatings}
|
||||||
|
seasonsCount={
|
||||||
|
itemType === 'Series'
|
||||||
|
? tmdbTvData?.number_of_seasons ?? null
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
episodesCount={
|
||||||
|
itemType === 'Series'
|
||||||
|
? tmdbTvData?.number_of_episodes ?? null
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
seriesStatus={itemType === 'Series' ? tmdbTvData?.status ?? null : null}
|
||||||
|
onRescan={
|
||||||
|
item.Id
|
||||||
|
? () =>
|
||||||
|
refresh.mutate({
|
||||||
|
itemId: item.Id!,
|
||||||
|
replaceAllMetadata: false,
|
||||||
|
replaceAllImages: false,
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
isRescanning={refresh.isPending}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 h-32 bg-gradient-to-b from-transparent to-void pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TypePill({ itemType }: { itemType: string }) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5 h-6 px-2.5 self-start rounded-full bg-white/8 border border-white/12 backdrop-blur text-[10px] font-semibold uppercase tracking-[0.12em] text-white/80">
|
||||||
|
{itemType === 'Series' || itemType === 'Episode' ? (
|
||||||
|
<Tv2 size={10} className="text-accent" />
|
||||||
|
) : itemType === 'MusicAlbum' || itemType === 'MusicArtist' ? (
|
||||||
|
<Disc3 size={10} className="text-accent" />
|
||||||
|
) : (
|
||||||
|
<FilmIcon size={10} className="text-accent" />
|
||||||
|
)}
|
||||||
|
{itemType === 'Series' ? 'Series' : itemType === 'Movie' ? 'Movie' : itemType}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EpisodeBreadcrumb({ item, navigate }: { item: BaseItemDto; navigate: NavigateFunction }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/item/${item.SeriesId}`)}
|
||||||
|
className="inline-flex items-center gap-2 mb-3 group focus-ring rounded"
|
||||||
|
>
|
||||||
|
<span className="text-[11px] font-semibold text-white/70 uppercase tracking-[0.18em] group-hover:text-white transition">
|
||||||
|
{item.SeriesName}
|
||||||
|
</span>
|
||||||
|
{item.ParentIndexNumber != null && item.IndexNumber != null && (
|
||||||
|
<span className="text-[11px] text-white/50 font-medium tabular-nums">
|
||||||
|
S{item.ParentIndexNumber} · E{item.IndexNumber}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<ChevronRight size={12} className="text-white/40 group-hover:text-white/80 group-hover:translate-x-0.5 transition" />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Dot() {
|
||||||
|
return <span className="text-white/30">·</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconButton({
|
||||||
|
children,
|
||||||
|
active,
|
||||||
|
...props
|
||||||
|
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { active?: boolean }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
{...props}
|
||||||
|
className={`w-11 h-11 rounded-lg backdrop-blur grid place-items-center transition-all duration-200 hover:scale-105 active:scale-95 focus-ring disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 ${
|
||||||
|
active
|
||||||
|
? 'bg-accent/15 border border-accent/30'
|
||||||
|
: 'bg-white/10 border border-white/15 hover:bg-white/15'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,458 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Tag } from '../../lib/icons'
|
||||||
|
import { Section } from '../ui/SectionLabel'
|
||||||
|
import type { BaseItemDto } from '../../api/types'
|
||||||
|
import type {
|
||||||
|
TmdbMovie,
|
||||||
|
TmdbTvShow,
|
||||||
|
TmdbCollection,
|
||||||
|
TmdbWatchProviders,
|
||||||
|
TmdbKeyword,
|
||||||
|
TmdbReview,
|
||||||
|
TmdbVideo,
|
||||||
|
} from '../../api/tmdb'
|
||||||
|
import type { CinemetaMeta } from '../../api/cinemeta'
|
||||||
|
import type { TvmazeShow } from '../../api/tvmaze'
|
||||||
|
import type { WikidataAward, WikidataLocation } from '../../api/wikidata'
|
||||||
|
import type { RTRating } from '../../api/rotten-tomatoes'
|
||||||
|
|
||||||
|
type WikiSection = { extract: string; title: string }
|
||||||
|
import { usePreferencesStore } from '../../stores/preferences-store'
|
||||||
|
import { useDiary } from '../../stores/diary-store'
|
||||||
|
import { parseImdbRating } from '../../lib/episode-meta'
|
||||||
|
import { hasTechSpecs } from './TechSpecs'
|
||||||
|
import VersionsSelector from './VersionsSelector'
|
||||||
|
import ReadingMode from './ReadingMode'
|
||||||
|
import SeriesSection from './SeriesSection'
|
||||||
|
import EpisodeExtras from './EpisodeExtras'
|
||||||
|
import CastList from './CastList'
|
||||||
|
import CrewGrid from './CrewGrid'
|
||||||
|
import ComposerBlock from './ComposerBlock'
|
||||||
|
import FactsPanel from './FactsPanel'
|
||||||
|
import WhereToWatch from './WhereToWatch'
|
||||||
|
import SeriesStatusBlock from './SeriesStatusBlock'
|
||||||
|
import VideosSection from './VideosSection'
|
||||||
|
import ProductionTrivia from './ProductionTrivia'
|
||||||
|
import ReceptionPanel from './ReceptionPanel'
|
||||||
|
import WatchTimeline from './WatchTimeline'
|
||||||
|
import FilmingLocationsMap from './FilmingLocationsMap'
|
||||||
|
import AwardsBlock from './AwardsBlock'
|
||||||
|
import TechSpecs from './TechSpecs'
|
||||||
|
import PersonalSection from './PersonalSection'
|
||||||
|
import DiarySection from './DiarySection'
|
||||||
|
import CollectionStrip from './CollectionStrip'
|
||||||
|
import ReviewsSection from './ReviewsSection'
|
||||||
|
import DiscoveryRows from './DiscoveryRows'
|
||||||
|
import FromSameRow from './FromSameRow'
|
||||||
|
import { getSeriesAirInfo } from '../../lib/item-types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: BaseItemDto
|
||||||
|
itemId: string | undefined
|
||||||
|
itemType: string
|
||||||
|
imdbId: string | null
|
||||||
|
tmdbId: string | null | undefined
|
||||||
|
tmdbMovieData: TmdbMovie | null | undefined
|
||||||
|
tmdbTvData: TmdbTvShow | null | undefined
|
||||||
|
tmdbCollectionData: TmdbCollection | null | undefined
|
||||||
|
cinemetaData: CinemetaMeta | null | undefined
|
||||||
|
tvmazeData: TvmazeShow | null | undefined
|
||||||
|
wikiProduction: WikiSection | null | undefined
|
||||||
|
awardsData: WikidataAward[] | null | undefined
|
||||||
|
locationsData: WikidataLocation[] | null | undefined
|
||||||
|
rtData: RTRating | null | undefined
|
||||||
|
/** Wikipedia article title for the item - the ReceptionPanel uses
|
||||||
|
* this to pull the Critical-response section. */
|
||||||
|
wikiTitle: string | null
|
||||||
|
region: string
|
||||||
|
watchProviders: TmdbWatchProviders | null | undefined
|
||||||
|
cast: unknown[]
|
||||||
|
crew: unknown[]
|
||||||
|
keywords: TmdbKeyword[]
|
||||||
|
reviews: TmdbReview[]
|
||||||
|
videos: TmdbVideo[] | undefined
|
||||||
|
recommendations: { id: number; adult?: boolean }[]
|
||||||
|
similar: { id: number; adult?: boolean }[]
|
||||||
|
libraryMap: Map<string, { id: string; name: string; type: string }> | undefined
|
||||||
|
overview: string
|
||||||
|
overviewSource: string | null
|
||||||
|
sources: { Id?: string }[]
|
||||||
|
activeSourceId: string | null
|
||||||
|
onSourceChange: (id: string | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DetailMainSections({
|
||||||
|
item,
|
||||||
|
itemId,
|
||||||
|
itemType,
|
||||||
|
imdbId,
|
||||||
|
tmdbId,
|
||||||
|
tmdbMovieData,
|
||||||
|
tmdbTvData,
|
||||||
|
tmdbCollectionData,
|
||||||
|
cinemetaData,
|
||||||
|
tvmazeData,
|
||||||
|
wikiProduction,
|
||||||
|
awardsData,
|
||||||
|
locationsData,
|
||||||
|
rtData,
|
||||||
|
wikiTitle,
|
||||||
|
region,
|
||||||
|
watchProviders,
|
||||||
|
cast,
|
||||||
|
crew,
|
||||||
|
keywords,
|
||||||
|
reviews,
|
||||||
|
videos,
|
||||||
|
recommendations,
|
||||||
|
similar,
|
||||||
|
libraryMap,
|
||||||
|
overview,
|
||||||
|
overviewSource,
|
||||||
|
sources,
|
||||||
|
activeSourceId,
|
||||||
|
onSourceChange,
|
||||||
|
}: Props) {
|
||||||
|
const prefs = usePreferencesStore()
|
||||||
|
const hasDiaryEntries = useDiary(s => itemId ? s.entries.some(e => e.itemId === itemId) : false)
|
||||||
|
const [readingModeOpen, setReadingModeOpen] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="relative px-7 pt-2 space-y-10">
|
||||||
|
{/* Versions selector when there's more than one source */}
|
||||||
|
{sources.length > 1 && (
|
||||||
|
<Section label="Versions">
|
||||||
|
<VersionsSelector
|
||||||
|
item={item}
|
||||||
|
selectedSourceId={activeSourceId}
|
||||||
|
onChange={onSourceChange}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Synopsis */}
|
||||||
|
{overview && (
|
||||||
|
<Section label="Overview" id="detail-overview">
|
||||||
|
<p className="text-[15px] text-text-2 leading-[1.7] max-w-[68ch] tracking-[-0.005em] [text-wrap:pretty]">
|
||||||
|
{overview}
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex items-center gap-3 flex-wrap">
|
||||||
|
{overviewSource && (
|
||||||
|
<p className="text-[10.5px] text-text-4">Source: {overviewSource}</p>
|
||||||
|
)}
|
||||||
|
{overview.length > 600 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setReadingModeOpen(true)}
|
||||||
|
className="text-[11.5px] text-accent hover:text-accent-hover transition tracking-tight focus-ring rounded"
|
||||||
|
>
|
||||||
|
Open in reader
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
<ReadingMode
|
||||||
|
open={readingModeOpen}
|
||||||
|
onClose={() => setReadingModeOpen(false)}
|
||||||
|
title={item.Name || ''}
|
||||||
|
body={overview}
|
||||||
|
source={overviewSource}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Watch timeline: the user's personal history with this title.
|
||||||
|
Letterboxd-flavoured chronology of diary entries. Self-hides
|
||||||
|
when there's no diary history. */}
|
||||||
|
{hasDiaryEntries && itemId && (
|
||||||
|
<Section label="Your watches">
|
||||||
|
<WatchTimeline itemId={itemId} />
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reception: aggregate of every available rating + critical
|
||||||
|
response prose. Replaces the ratings strip that used to live
|
||||||
|
inside the About / FactsPanel section. */}
|
||||||
|
{(itemType === 'Movie' || itemType === 'Series') && (
|
||||||
|
<Section label="Reception" id="detail-reception">
|
||||||
|
<ReceptionPanel
|
||||||
|
itemId={itemId}
|
||||||
|
rt={rtData}
|
||||||
|
imdbRating={parseImdbRating(cinemetaData?.imdbRating)}
|
||||||
|
tmdbScore={(itemType === 'Movie' ? tmdbMovieData?.vote_average : tmdbTvData?.vote_average) ?? null}
|
||||||
|
tmdbVotes={(itemType === 'Movie' ? tmdbMovieData?.vote_count : tmdbTvData?.vote_count) ?? null}
|
||||||
|
jellyfinCommunityRating={item.CommunityRating ?? null}
|
||||||
|
wikiTitle={wikiTitle}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Series episodes - lead with what the user came for on a series
|
||||||
|
page. Full-width because the table scrolls and packs many rows. */}
|
||||||
|
{itemType === 'Series' && itemId && (
|
||||||
|
<div id="detail-episodes" className="scroll-mt-4">
|
||||||
|
<SeriesSection seriesId={itemId} imdbId={imdbId} tmdbId={tmdbId ? Number(tmdbId) : null} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Episode-specific extras (only on Episode pages) */}
|
||||||
|
{itemType === 'Episode' && prefs.detail.show.episodeExtras && (
|
||||||
|
<Section label="">
|
||||||
|
<EpisodeExtras item={item} />
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* People row: Cast + Crew side-by-side */}
|
||||||
|
{(cast.length > 0 || crew.length > 0) && (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-x-8 gap-y-10">
|
||||||
|
{cast.length > 0 && (
|
||||||
|
<Section label="Cast" id="detail-cast">
|
||||||
|
<CastList cast={cast as any[]} fallbackPeople={item.People} />
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
{crew.length > 0 && (
|
||||||
|
<Section label="Crew">
|
||||||
|
<CrewGrid crew={crew as any[]} />
|
||||||
|
<div className="mt-3">
|
||||||
|
<ComposerBlock crew={crew as any[]} />
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* "What is this" cluster: About + Where to Watch + Series Status
|
||||||
|
flow into a CSS multi-column layout. Items are packed by content
|
||||||
|
height (newspaper-style) so a tall About doesn't leave a 600px
|
||||||
|
void next to a short Where to Watch row. */}
|
||||||
|
{((itemType === 'Movie' || itemType === 'Series') || watchProviders) && (
|
||||||
|
<div className="columns-1 lg:columns-2 gap-8 [column-fill:balance]">
|
||||||
|
{(itemType === 'Movie' || itemType === 'Series') && (
|
||||||
|
<div className="break-inside-avoid mb-8">
|
||||||
|
<Section label="About">
|
||||||
|
<FactsPanel
|
||||||
|
itemType={itemType}
|
||||||
|
movie={tmdbMovieData || null}
|
||||||
|
tv={tmdbTvData || null}
|
||||||
|
region={region}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{watchProviders && (
|
||||||
|
<div className="break-inside-avoid mb-8">
|
||||||
|
<WhereToWatch providers={watchProviders} defaultRegion={region} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{itemType === 'Series' && (
|
||||||
|
<div className="break-inside-avoid mb-8">
|
||||||
|
<Section label="Series status">
|
||||||
|
<SeriesStatusBlock
|
||||||
|
show={tmdbTvData ?? undefined}
|
||||||
|
tvmazeShow={tvmazeData || null}
|
||||||
|
jellyfinAirDays={getSeriesAirInfo(item).airDays}
|
||||||
|
jellyfinAirTime={getSeriesAirInfo(item).airTime}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Videos */}
|
||||||
|
{prefs.detail.show.videos && (videos?.length ?? 0) > 0 && (
|
||||||
|
<Section label="Videos">
|
||||||
|
<VideosSection videos={videos} />
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Trivia */}
|
||||||
|
{prefs.detail.show.trivia && (wikiProduction?.extract || (keywords.length > 0 && wikiProduction)) && (
|
||||||
|
<Section label="Trivia" id="detail-trivia">
|
||||||
|
<ProductionTrivia
|
||||||
|
keywords={keywords}
|
||||||
|
wikiProduction={wikiProduction}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filming locations */}
|
||||||
|
{prefs.detail.show.filmingLocations && (locationsData?.length ?? 0) > 0 && (
|
||||||
|
<Section label="Filming locations">
|
||||||
|
<FilmingLocationsMap locations={locationsData} />
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Activity cluster: Awards + Tech specs + Personal + Diary in a
|
||||||
|
multi-column flow so wildly different content heights pack
|
||||||
|
efficiently. */}
|
||||||
|
{((prefs.detail.show.awards && (awardsData?.length ?? 0) > 0)
|
||||||
|
|| hasTechSpecs(item)
|
||||||
|
|| keywords.length > 0
|
||||||
|
|| (item.Id && !String(item.Id).startsWith('tmdb-') && (item.Type === 'Movie' || item.Type === 'Series'))) && (
|
||||||
|
<div className="columns-1 lg:columns-2 gap-8 [column-fill:balance]">
|
||||||
|
{prefs.detail.show.awards && (awardsData?.length ?? 0) > 0 && (
|
||||||
|
<div className="break-inside-avoid mb-8">
|
||||||
|
<Section label="Awards">
|
||||||
|
<AwardsBlock awards={awardsData} />
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasTechSpecs(item) && (
|
||||||
|
<div className="break-inside-avoid mb-8">
|
||||||
|
<Section label="Tech specs">
|
||||||
|
<TechSpecs item={item} />
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.Id && !String(item.Id).startsWith('tmdb-') && (item.Type === 'Movie' || item.Type === 'Series') && prefs.detail.show.personal && (
|
||||||
|
<div className="break-inside-avoid mb-8">
|
||||||
|
<Section label="Personal">
|
||||||
|
<PersonalSection itemId={item.Id} showRewatchToggle />
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.Id && !String(item.Id).startsWith('tmdb-') && (item.Type === 'Movie' || item.Type === 'Series') && prefs.detail.show.diary && (
|
||||||
|
<div className="break-inside-avoid mb-8">
|
||||||
|
<Section label="Diary">
|
||||||
|
<DiarySection itemId={item.Id} itemName={item.Name || 'Unknown'} />
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{keywords.length > 0 && (
|
||||||
|
<div className="break-inside-avoid mb-8">
|
||||||
|
<Section label="Keywords">
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{keywords.slice(0, 24).map(k => (
|
||||||
|
<span
|
||||||
|
key={k.id}
|
||||||
|
className="inline-flex items-center gap-1 h-6 px-2.5 bg-elevated/50 hover:bg-elevated border border-border hover:border-border-hover rounded-full text-[11px] text-text-2 hover:text-text-1 transition-colors cursor-default"
|
||||||
|
>
|
||||||
|
<Tag size={9} className="text-text-4" />
|
||||||
|
{k.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Collection - the franchise / series strip */}
|
||||||
|
{tmdbMovieData?.belongs_to_collection && (
|
||||||
|
<Section label="Collection">
|
||||||
|
<CollectionStrip
|
||||||
|
collectionRef={tmdbMovieData.belongs_to_collection}
|
||||||
|
collection={tmdbCollectionData ?? undefined}
|
||||||
|
currentMovieId={tmdbMovieData.id}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reviews */}
|
||||||
|
{reviews.length > 0 && (
|
||||||
|
<Section label="Reviews" id="detail-reviews">
|
||||||
|
<ReviewsSection reviews={reviews} />
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section id="detail-more" className="scroll-mt-4 mt-14 pt-8 border-t border-border/60">
|
||||||
|
<div className="px-7 mb-6">
|
||||||
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
|
<span className="w-1 h-3.5 rounded-full bg-accent" />
|
||||||
|
<span className="text-[11px] font-semibold text-text-2 uppercase tracking-[0.14em]">
|
||||||
|
More like this
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[12.5px] text-text-3 max-w-xl">
|
||||||
|
Titles by the same people on this project plus algorithmic picks
|
||||||
|
you might want to queue up next.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* "From the same..." rows: highlight other work by the headline
|
||||||
|
creator + a top-billed actor before the generic Discovery rows.
|
||||||
|
Each row self-hides when the person has fewer than 3 other
|
||||||
|
credits in the same media kind, so this stays scoped. */}
|
||||||
|
{(itemType === 'Movie' || itemType === 'Series') && (
|
||||||
|
<FromSameRowsCluster
|
||||||
|
itemType={itemType}
|
||||||
|
tmdbId={tmdbId}
|
||||||
|
cast={cast}
|
||||||
|
crew={crew}
|
||||||
|
libraryMap={libraryMap}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Discovery rows - library matches first, then full lists */}
|
||||||
|
<DiscoveryRows
|
||||||
|
recommendations={recommendations}
|
||||||
|
similar={similar}
|
||||||
|
libraryMap={libraryMap}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks the headline director (or series creator) + the top-billed
|
||||||
|
* actor from the credits and renders a FromSameRow for each. Skips
|
||||||
|
* either row when there's nothing useful to render.
|
||||||
|
*/
|
||||||
|
function FromSameRowsCluster({
|
||||||
|
itemType,
|
||||||
|
tmdbId,
|
||||||
|
cast,
|
||||||
|
crew,
|
||||||
|
libraryMap,
|
||||||
|
}: {
|
||||||
|
itemType: string
|
||||||
|
tmdbId: string | null | undefined
|
||||||
|
cast: unknown[]
|
||||||
|
crew: unknown[]
|
||||||
|
libraryMap: Map<string, { id: string; name: string; type: string }> | undefined
|
||||||
|
}) {
|
||||||
|
const kind: 'movie' | 'tv' = itemType === 'Series' ? 'tv' : 'movie'
|
||||||
|
const crewList = crew as Array<{ id: number; name: string; job?: string; department?: string }>
|
||||||
|
const castList = cast as Array<{ id: number; name: string; order?: number }>
|
||||||
|
|
||||||
|
const director = crewList.find(c => c.job === 'Director')
|
||||||
|
// For series, "Director" is rarely meaningful (each episode has its
|
||||||
|
// own); use the executive producer / creator chain instead.
|
||||||
|
const creator = kind === 'tv'
|
||||||
|
? crewList.find(c => c.job === 'Executive Producer' || c.job === 'Creator')
|
||||||
|
: null
|
||||||
|
const headlinePerson = director || creator
|
||||||
|
const topActor = [...castList].sort((a, b) => (a.order ?? 999) - (b.order ?? 999))[0]
|
||||||
|
|
||||||
|
const excludeId = tmdbId ? Number(tmdbId) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{headlinePerson && (
|
||||||
|
<FromSameRow
|
||||||
|
personId={headlinePerson.id}
|
||||||
|
personName={headlinePerson.name}
|
||||||
|
role={director ? 'director' : 'writer'}
|
||||||
|
excludeTmdbId={excludeId}
|
||||||
|
kind={kind}
|
||||||
|
libraryMap={libraryMap}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{topActor && (
|
||||||
|
<FromSameRow
|
||||||
|
personId={topActor.id}
|
||||||
|
personName={topActor.name}
|
||||||
|
role="actor"
|
||||||
|
excludeTmdbId={excludeId}
|
||||||
|
kind={kind}
|
||||||
|
libraryMap={libraryMap}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
|
||||||
|
export interface SectionTab {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Element ids the user can jump to, in display order. The component
|
||||||
|
* filters out ones that don't actually exist in the DOM at render
|
||||||
|
* time so the tab row mirrors what's on the page. */
|
||||||
|
tabs: SectionTab[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact table-of-contents strip rendered just below the hero. Smooth-
|
||||||
|
* scrolls to the targeted section on click and keeps the active tab
|
||||||
|
* synced with the viewport via IntersectionObserver - useful on long
|
||||||
|
* series pages where the user might want to jump from Overview straight
|
||||||
|
* to Cast or More-like-this without scrolling.
|
||||||
|
*
|
||||||
|
* The tabs are NOT sticky - that role is owned by `DetailStickyBar` so
|
||||||
|
* the page doesn't end up with two competing top-anchored chromes.
|
||||||
|
*/
|
||||||
|
export default function DetailSectionTabs({ tabs }: Props) {
|
||||||
|
const [active, setActive] = useState<string | null>(null)
|
||||||
|
const [available, setAvailable] = useState<SectionTab[]>([])
|
||||||
|
|
||||||
|
// Filter to tabs whose target element actually exists in the DOM.
|
||||||
|
// Sections are conditionally rendered, so listing them all from the
|
||||||
|
// parent would surface dead links for sections that hid themselves.
|
||||||
|
useEffect(() => {
|
||||||
|
const present = tabs.filter(t => document.getElementById(t.id))
|
||||||
|
setAvailable(present)
|
||||||
|
}, [tabs])
|
||||||
|
|
||||||
|
// Spy on each section so the active tab matches what's mostly in view.
|
||||||
|
useEffect(() => {
|
||||||
|
if (available.length === 0) return
|
||||||
|
const root = document.querySelector('main.content-scroll') as HTMLElement | null
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
entries => {
|
||||||
|
// Pick the highest entry that's in view - the one closest to the
|
||||||
|
// top of the viewport from the user's perspective.
|
||||||
|
const inView = entries
|
||||||
|
.filter(e => e.isIntersecting)
|
||||||
|
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)
|
||||||
|
if (inView.length > 0) {
|
||||||
|
setActive(inView[0].target.id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
root,
|
||||||
|
// Activate when a section's top is near the top of the viewport,
|
||||||
|
// not when it just barely enters from below. The negative bottom
|
||||||
|
// margin makes the active zone the upper third of the viewport.
|
||||||
|
rootMargin: '-12% 0px -65% 0px',
|
||||||
|
threshold: 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
for (const t of available) {
|
||||||
|
const el = document.getElementById(t.id)
|
||||||
|
if (el) observer.observe(el)
|
||||||
|
}
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [available])
|
||||||
|
|
||||||
|
function scrollTo(id: string) {
|
||||||
|
const el = document.getElementById(id)
|
||||||
|
if (!el) return
|
||||||
|
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
|
setActive(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (available.length < 2) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
aria-label="Sections"
|
||||||
|
className="relative -mx-7 px-7 overflow-x-auto hide-scrollbar"
|
||||||
|
>
|
||||||
|
<ul className="flex items-center gap-1 min-w-max">
|
||||||
|
{available.map(t => {
|
||||||
|
const isActive = active === t.id
|
||||||
|
return (
|
||||||
|
<li key={t.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => scrollTo(t.id)}
|
||||||
|
aria-current={isActive ? 'true' : undefined}
|
||||||
|
className={`relative h-10 px-3.5 inline-flex items-center text-[12.5px] font-medium tracking-tight transition-colors focus-ring rounded-md ${
|
||||||
|
isActive ? 'text-text-1' : 'text-text-3 hover:text-text-1'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
{isActive && (
|
||||||
|
<motion.span
|
||||||
|
layoutId="detail-tab-underline"
|
||||||
|
className="absolute -bottom-px left-2 right-2 h-[2px] bg-accent rounded-t"
|
||||||
|
transition={{ type: 'spring', stiffness: 380, damping: 32 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
export default function DetailSkeleton() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="relative h-[68vh] min-h-[520px] -mt-14 overflow-hidden">
|
||||||
|
<div className="absolute inset-0 skeleton" />
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-void via-void/60 to-void/10" />
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-void via-void/30 to-transparent" />
|
||||||
|
<div className="relative h-full flex items-end pb-12 px-7 gap-8">
|
||||||
|
<div className="hidden md:block w-[200px] aspect-[2/3] skeleton rounded-xl shrink-0" />
|
||||||
|
<div className="flex-1 space-y-4">
|
||||||
|
<div className="skeleton h-5 w-24 rounded" />
|
||||||
|
<div className="skeleton h-12 w-2/3 rounded" />
|
||||||
|
<div className="skeleton h-4 w-1/3 rounded" />
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<div className="skeleton h-11 w-28 rounded-lg" />
|
||||||
|
<div className="skeleton h-11 w-32 rounded-lg" />
|
||||||
|
<div className="skeleton h-11 w-11 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-7 py-6 space-y-6">
|
||||||
|
<div className="skeleton h-3 w-20 rounded" />
|
||||||
|
<div className="skeleton h-3 w-full rounded" />
|
||||||
|
<div className="skeleton h-3 w-full rounded" />
|
||||||
|
<div className="skeleton h-3 w-3/4 rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { Play, Heart, Share2 } from '../../lib/icons'
|
||||||
|
import { buildShareUrl, copyToClipboard } from '../../lib/share'
|
||||||
|
import { toast } from '../../stores/toast-store'
|
||||||
|
import DetailSectionTabs, { type SectionTab } from './DetailSectionTabs'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Controlled visibility - parent decides whether the chrome is shown.
|
||||||
|
* Pass `usePastSentinel(sentinelRef)` for the standard behaviour. */
|
||||||
|
visible: boolean
|
||||||
|
title: string
|
||||||
|
posterUrl?: string | null
|
||||||
|
logoUrl?: string | null
|
||||||
|
progress?: number | null
|
||||||
|
isFavorite?: boolean | null
|
||||||
|
playUrl: string
|
||||||
|
resumeUrl: string
|
||||||
|
tmdbId?: string | number | null
|
||||||
|
imdbId?: string | null
|
||||||
|
itemType?: string
|
||||||
|
onFavoriteToggle?: () => void
|
||||||
|
/** Section tabs to render below the action row. Pass an empty array
|
||||||
|
* to hide the tab strip; pass a list to enable section nav. */
|
||||||
|
tabs?: SectionTab[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sticky top chrome for the detail page. Owns the slim action row
|
||||||
|
* (logo / title + Share / Favorite / Play) AND the section tabs
|
||||||
|
* underneath. Both appear together once the user has scrolled past
|
||||||
|
* the hero, animated in as a unit so the chrome doesn't look like two
|
||||||
|
* separate things flickering on screen.
|
||||||
|
*
|
||||||
|
* Visibility is fully controlled - the parent owns the sentinel + the
|
||||||
|
* `usePastSentinel` hook. This component just renders.
|
||||||
|
*/
|
||||||
|
export default function DetailStickyBar({
|
||||||
|
visible,
|
||||||
|
title,
|
||||||
|
posterUrl,
|
||||||
|
logoUrl,
|
||||||
|
progress,
|
||||||
|
isFavorite,
|
||||||
|
playUrl,
|
||||||
|
resumeUrl,
|
||||||
|
tmdbId,
|
||||||
|
imdbId,
|
||||||
|
itemType,
|
||||||
|
onFavoriteToggle,
|
||||||
|
tabs,
|
||||||
|
}: Props) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const hasProgress = typeof progress === 'number' && progress > 0
|
||||||
|
const hasTabs = !!tabs && tabs.length > 1
|
||||||
|
|
||||||
|
async function onShare() {
|
||||||
|
const url = buildShareUrl({
|
||||||
|
tmdbId,
|
||||||
|
imdbId,
|
||||||
|
kind: itemType === 'Series' || itemType === 'Episode' ? 'tv' : 'movie',
|
||||||
|
})
|
||||||
|
if (!url) {
|
||||||
|
toast('No shareable link for this item', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const ok = await copyToClipboard(url)
|
||||||
|
toast(ok ? 'Link copied to clipboard' : 'Could not copy link', ok ? 'success' : 'error')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sticky top-0 z-30 h-0">
|
||||||
|
<AnimatePresence>
|
||||||
|
{visible && (
|
||||||
|
<motion.div
|
||||||
|
key="sticky-chrome"
|
||||||
|
initial={{ y: -120, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
exit={{ y: -120, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.26, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
className="absolute top-0 left-0 right-0 bg-void/92 backdrop-blur-xl border-b border-border/80 shadow-[0_8px_20px_-12px_rgba(0,0,0,0.6)]"
|
||||||
|
>
|
||||||
|
<div className="h-14 flex items-center gap-3 px-7">
|
||||||
|
{logoUrl ? (
|
||||||
|
<img
|
||||||
|
src={logoUrl}
|
||||||
|
alt=""
|
||||||
|
className="h-7 max-w-[120px] object-contain"
|
||||||
|
onError={e => { (e.target as HTMLImageElement).style.display = 'none' }}
|
||||||
|
/>
|
||||||
|
) : posterUrl ? (
|
||||||
|
<img
|
||||||
|
src={posterUrl}
|
||||||
|
alt=""
|
||||||
|
className="h-8 aspect-[2/3] rounded object-cover ring-1 ring-border"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<span className="flex-1 min-w-0 text-[13px] font-semibold text-text-1 tracking-tight truncate">
|
||||||
|
{logoUrl ? '' : title}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="shrink-0 flex items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
onClick={onShare}
|
||||||
|
aria-label="Copy share link"
|
||||||
|
title="Copy share link"
|
||||||
|
className="w-9 h-9 grid place-items-center rounded-full text-text-2 hover:text-text-1 hover:bg-white/8 transition-colors focus-ring"
|
||||||
|
>
|
||||||
|
<Share2 size={15} />
|
||||||
|
</button>
|
||||||
|
{onFavoriteToggle && (
|
||||||
|
<button
|
||||||
|
onClick={onFavoriteToggle}
|
||||||
|
aria-label={isFavorite ? 'Remove favorite' : 'Add favorite'}
|
||||||
|
className="w-9 h-9 grid place-items-center rounded-full text-text-2 hover:text-text-1 hover:bg-white/8 transition-colors focus-ring"
|
||||||
|
>
|
||||||
|
<Heart
|
||||||
|
size={15}
|
||||||
|
className={isFavorite ? 'text-accent fill-accent' : ''}
|
||||||
|
fill={isFavorite ? 'currentColor' : 'none'}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{playUrl && (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(hasProgress ? resumeUrl : playUrl)}
|
||||||
|
className="inline-flex items-center gap-1.5 h-9 px-3.5 rounded-full bg-accent hover:bg-accent-hover text-void text-[12.5px] font-semibold tracking-tight transition-all duration-150 hover:scale-[1.03] active:scale-[0.97] focus-ring"
|
||||||
|
>
|
||||||
|
<Play size={12} fill="currentColor" stroke={0} />
|
||||||
|
{hasProgress ? 'Resume' : 'Play'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasTabs && (
|
||||||
|
<div className="px-7">
|
||||||
|
<DetailSectionTabs tabs={tabs!} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { Plus, Star, Trash2, X } from '../../lib/icons'
|
||||||
|
import { useDiary, type DiaryEntry } from '../../stores/diary-store'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
itemId: string
|
||||||
|
itemName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUICK_EMOJI = ['🎬', '🍿', '😢', '😱', '🤯', '😴', '❤️', '🤩', '🤔', '😂', '👀', '🔥']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-item diary log. Shows chronological entries for this item with
|
||||||
|
* optional rating + emoji + note, plus a "Log a watch" button that
|
||||||
|
* opens an inline editor for a new entry.
|
||||||
|
*
|
||||||
|
* Editing works in-place with a small drawer below each entry.
|
||||||
|
*/
|
||||||
|
export default function DiarySection({ itemId, itemName }: Props) {
|
||||||
|
// Select the whole array; filter + sort in render via useMemo.
|
||||||
|
// A selector that returns .filter()/.sort() output creates a new
|
||||||
|
// reference each call and trips useSyncExternalStore's loop guard.
|
||||||
|
const allEntries = useDiary(s => s.entries)
|
||||||
|
const add = useDiary(s => s.add)
|
||||||
|
const remove = useDiary(s => s.remove)
|
||||||
|
const [composing, setComposing] = useState(false)
|
||||||
|
const sorted = useMemo(
|
||||||
|
() =>
|
||||||
|
allEntries
|
||||||
|
.filter(e => e.itemId === itemId)
|
||||||
|
.sort((a, b) => b.watchedAt.localeCompare(a.watchedAt)),
|
||||||
|
[allEntries, itemId],
|
||||||
|
)
|
||||||
|
|
||||||
|
function startCompose() {
|
||||||
|
setComposing(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitNew(payload: { rating?: number; note?: string; emoji?: string; rewatch?: boolean }) {
|
||||||
|
add({ itemId, itemName, ...payload })
|
||||||
|
setComposing(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p className="text-[12.5px] text-text-3 leading-relaxed">
|
||||||
|
{sorted.length === 0
|
||||||
|
? "You haven't logged a watch yet for this item."
|
||||||
|
: `${sorted.length} ${sorted.length === 1 ? 'entry' : 'entries'}`}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={startCompose}
|
||||||
|
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-full bg-accent text-void text-[12px] font-semibold tracking-tight hover:bg-accent-hover transition focus-ring"
|
||||||
|
>
|
||||||
|
<Plus size={12} stroke={2.5} />
|
||||||
|
Log a watch
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{composing && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -4 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -4 }}
|
||||||
|
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
>
|
||||||
|
<Composer onCancel={() => setComposing(false)} onSave={commitNew} />
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{sorted.length > 0 && (
|
||||||
|
<ol className="space-y-2 mt-1">
|
||||||
|
{sorted.map(entry => (
|
||||||
|
<DiaryRow key={entry.id} entry={entry} onRemove={() => remove(entry.id)} />
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Composer({
|
||||||
|
onCancel,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
onCancel: () => void
|
||||||
|
onSave: (payload: { rating?: number; note?: string; emoji?: string; rewatch?: boolean }) => void
|
||||||
|
}) {
|
||||||
|
const [rating, setRating] = useState(0)
|
||||||
|
const [note, setNote] = useState('')
|
||||||
|
const [emoji, setEmoji] = useState<string | undefined>(undefined)
|
||||||
|
const [rewatch, setRewatch] = useState(false)
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl bg-elevated/40 ring-1 ring-border p-4 space-y-3">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{QUICK_EMOJI.map(g => (
|
||||||
|
<button
|
||||||
|
key={g}
|
||||||
|
onClick={() => setEmoji(emoji === g ? undefined : g)}
|
||||||
|
className={`w-8 h-8 grid place-items-center rounded-md text-[16px] transition ${
|
||||||
|
emoji === g ? 'bg-accent/15 ring-1 ring-accent/30' : 'hover:bg-elevated/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{g}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-[10.5px] uppercase tracking-[0.16em] font-semibold text-text-3 mr-2">
|
||||||
|
Rating
|
||||||
|
</span>
|
||||||
|
{Array.from({ length: 10 }).map((_, i) => {
|
||||||
|
const n = i + 1
|
||||||
|
const on = n <= rating
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
onClick={() => setRating(rating === n ? 0 : n)}
|
||||||
|
aria-label={`Rate ${n} out of 10`}
|
||||||
|
className={`w-6 h-6 grid place-items-center rounded transition ${
|
||||||
|
on ? 'text-accent' : 'text-text-4 hover:text-text-2'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Star size={12} fill={on ? 'currentColor' : 'none'} stroke={on ? 0 : 1.5} />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{rating > 0 && (
|
||||||
|
<span className="text-[11px] text-text-3 tabular-nums ml-1">{rating}/10</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
value={note}
|
||||||
|
onChange={e => setNote(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
placeholder="A thought, a quote, or just nothing"
|
||||||
|
className="w-full px-3 py-2 rounded-md bg-void/40 ring-1 ring-border focus:ring-accent/50 outline-none text-[12.5px] tracking-tight resize-y leading-relaxed text-text-1 placeholder:text-text-4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||||
|
<label className="inline-flex items-center gap-1.5 text-[11.5px] text-text-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={rewatch}
|
||||||
|
onChange={e => setRewatch(e.target.checked)}
|
||||||
|
className="accent-amber-500"
|
||||||
|
/>
|
||||||
|
Logged as a rewatch
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="h-8 px-3 rounded-full text-[11.5px] text-text-2 hover:text-text-1 hover:bg-elevated transition focus-ring"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onSave({ rating: rating || undefined, note: note.trim() || undefined, emoji, rewatch })}
|
||||||
|
className="h-8 px-4 rounded-full bg-accent text-void text-[11.5px] font-semibold tracking-tight hover:bg-accent-hover transition focus-ring"
|
||||||
|
>
|
||||||
|
Log
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiaryRow({ entry, onRemove }: { entry: DiaryEntry; onRemove: () => void }) {
|
||||||
|
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
||||||
|
const date = new Date(entry.watchedAt)
|
||||||
|
const dateLabel = date.toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric', month: 'short', day: 'numeric',
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<li className="rounded-lg bg-elevated/40 ring-1 ring-border p-3 group">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{entry.emoji && (
|
||||||
|
<span className="text-[20px] leading-none mt-0.5 select-none">{entry.emoji}</span>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap text-[11.5px] text-text-3 mb-1 tracking-tight">
|
||||||
|
<span className="text-text-2 font-medium tabular-nums">{dateLabel}</span>
|
||||||
|
{entry.rating != null && entry.rating > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="text-text-5">·</span>
|
||||||
|
<span className="inline-flex items-center gap-1 text-accent tabular-nums">
|
||||||
|
<Star size={10} fill="currentColor" stroke={0} />
|
||||||
|
{entry.rating}/10
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{entry.rewatch && (
|
||||||
|
<>
|
||||||
|
<span className="text-text-5">·</span>
|
||||||
|
<span className="text-text-4">rewatch</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{entry.note && (
|
||||||
|
<p className="text-[12.5px] text-text-1 leading-relaxed whitespace-pre-wrap">
|
||||||
|
{entry.note}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (confirmingDelete) onRemove()
|
||||||
|
else setConfirmingDelete(true)
|
||||||
|
}}
|
||||||
|
onBlur={() => setConfirmingDelete(false)}
|
||||||
|
aria-label={confirmingDelete ? 'Confirm delete' : 'Delete entry'}
|
||||||
|
className={`w-7 h-7 grid place-items-center rounded transition focus-ring ${
|
||||||
|
confirmingDelete
|
||||||
|
? 'text-red-300 bg-red-500/15'
|
||||||
|
: 'text-text-4 hover:text-red-300 opacity-0 group-hover:opacity-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{confirmingDelete ? <X size={12} stroke={2.5} /> : <Trash2 size={12} stroke={2} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import ContentRow from '../ui/ContentRow'
|
||||||
|
import { mapTmdbToJf } from '../../lib/tmdb-mapping'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
recommendations: any[]
|
||||||
|
similar: any[]
|
||||||
|
libraryMap?: Map<string, { id: string; name: string; type: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DiscoveryRows({ recommendations, similar, libraryMap }: Props) {
|
||||||
|
// Combined pool for "in library" - dedup by tmdb id
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const pool = [...recommendations, ...similar].filter(m => {
|
||||||
|
if (seen.has(String(m.id))) return false
|
||||||
|
seen.add(String(m.id))
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
const inLibrary = libraryMap
|
||||||
|
? pool.filter(m => libraryMap.has(String(m.id)))
|
||||||
|
: []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{inLibrary.length > 0 && (
|
||||||
|
<div className="mt-8">
|
||||||
|
<ContentRow
|
||||||
|
title="In your library"
|
||||||
|
subtitle="Related titles you already own"
|
||||||
|
items={mapTmdbToJf(inLibrary, libraryMap)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{recommendations.length > 0 && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<ContentRow
|
||||||
|
title="You might like"
|
||||||
|
subtitle="Recommendations from TMDB"
|
||||||
|
items={mapTmdbToJf(recommendations, libraryMap)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{similar.length > 0 && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<ContentRow
|
||||||
|
title="More like this"
|
||||||
|
subtitle="Algorithmically similar"
|
||||||
|
items={mapTmdbToJf(similar, libraryMap)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { useTmdbEpisode, useTmdbTvShow } from '../../hooks/use-tmdb'
|
||||||
|
import { useItemDetails } from '../../hooks/use-jellyfin'
|
||||||
|
import { getTmdbImageUrl } from '../../api/tmdb'
|
||||||
|
import type { BaseItemDto } from '../../api/types'
|
||||||
|
import HorizontalScroller from '../ui/HorizontalScroller'
|
||||||
|
import ReviewsSection from './ReviewsSection'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: BaseItemDto
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extra sections rendered on episode-type detail pages:
|
||||||
|
*
|
||||||
|
* 1. **Featured in this episode** - per-episode TMDB cast filtered to the
|
||||||
|
* guest stars (people not in the main show ensemble). Falls back to
|
||||||
|
* nothing if TMDB doesn't have credits for the episode.
|
||||||
|
* 2. **Directed / Written by** - inline line below the cast row.
|
||||||
|
* 3. **Series reviews** - the show's TMDB reviews with a "from the
|
||||||
|
* series" caption, since TMDB doesn't expose per-episode reviews.
|
||||||
|
*
|
||||||
|
* Renders nothing if the parent series has no TMDB id, so non-TMDB
|
||||||
|
* libraries are unaffected.
|
||||||
|
*/
|
||||||
|
export default function EpisodeExtras({ item }: Props) {
|
||||||
|
const seriesJfId = item.SeriesId ?? undefined
|
||||||
|
const { data: seriesItem } = useItemDetails(seriesJfId)
|
||||||
|
const seriesTmdbId = seriesItem?.ProviderIds?.Tmdb
|
||||||
|
const seriesTmdbNum = seriesTmdbId ? Number(seriesTmdbId) : null
|
||||||
|
const seasonNum = item.ParentIndexNumber ?? undefined
|
||||||
|
const episodeNum = item.IndexNumber ?? undefined
|
||||||
|
|
||||||
|
const tvShow = useTmdbTvShow(seriesTmdbNum)
|
||||||
|
const episode = useTmdbEpisode(seriesTmdbNum, seasonNum, episodeNum)
|
||||||
|
|
||||||
|
if (!seriesTmdbNum) return null
|
||||||
|
|
||||||
|
const credits = episode.data?.credits
|
||||||
|
const mainCastIds = new Set((tvShow.data?.credits?.cast || []).map(c => c.id))
|
||||||
|
// Surface guest stars first; pad with episode-specific cast that aren't
|
||||||
|
// in the main ensemble. Cap at 8.
|
||||||
|
const guests = (credits?.cast || []).filter(c => !mainCastIds.has(c.id)).slice(0, 8)
|
||||||
|
const crew = credits?.crew || []
|
||||||
|
const director = crew.find(c => c.job === 'Director')
|
||||||
|
const writer = crew.find(
|
||||||
|
c => c.job === 'Writer' || c.job === 'Screenplay' || c.job === 'Story',
|
||||||
|
)
|
||||||
|
|
||||||
|
const reviews = tvShow.data?.reviews?.results || []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{(guests.length > 0 || director || writer) && (
|
||||||
|
<motion.section
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.32, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
>
|
||||||
|
<p className="text-[10px] uppercase tracking-[0.18em] font-semibold text-text-3 mb-3 leading-none">
|
||||||
|
Featured in this episode
|
||||||
|
</p>
|
||||||
|
{guests.length > 0 && (
|
||||||
|
<div className="-mx-7 mb-2">
|
||||||
|
<HorizontalScroller gap="gap-3" arrowsBottomInset="bottom-12">
|
||||||
|
{guests.map(c => (
|
||||||
|
<div key={c.id} className="shrink-0 w-[112px]">
|
||||||
|
<div className="aspect-[2/3] rounded-lg overflow-hidden bg-elevated ring-1 ring-border mb-1.5">
|
||||||
|
{c.profile_path ? (
|
||||||
|
<img
|
||||||
|
src={getTmdbImageUrl(c.profile_path, 'w185')}
|
||||||
|
alt={c.name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full grid place-items-center text-text-4 text-2xl font-display font-semibold opacity-40">
|
||||||
|
{c.name?.[0]?.toUpperCase() || '?'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-[12px] text-text-1 font-medium leading-tight tracking-tight truncate">
|
||||||
|
{c.name}
|
||||||
|
</p>
|
||||||
|
{c.character && (
|
||||||
|
<p className="text-[10.5px] text-text-3 leading-tight truncate">
|
||||||
|
{c.character}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</HorizontalScroller>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(director || writer) && (
|
||||||
|
<p className="text-[12px] text-text-3 leading-relaxed">
|
||||||
|
{director && (
|
||||||
|
<>
|
||||||
|
Directed by <span className="text-text-1 font-medium">{director.name}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{director && writer && <span className="text-text-5 mx-2">·</span>}
|
||||||
|
{writer && (
|
||||||
|
<>
|
||||||
|
Written by <span className="text-text-1 font-medium">{writer.name}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</motion.section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{reviews.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<p className="text-[10px] uppercase tracking-[0.18em] font-semibold text-text-3 mb-1 leading-none">
|
||||||
|
Reviews
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-text-4 mb-4">From the series</p>
|
||||||
|
<ReviewsSection reviews={reviews} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { EPISODE_GROUP_TYPES, type OrderingOption } from '../../lib/episode-order'
|
||||||
|
import type { TmdbEpisodeGroup } from '../../api/tmdb'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
groups: TmdbEpisodeGroup[]
|
||||||
|
selectedId: string
|
||||||
|
onChange: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact dropdown that lets users switch between aired order and any
|
||||||
|
* alternative TMDB episode groups. Filters out tiny groups (<5
|
||||||
|
* episodes) since those are usually fragments, not real orderings.
|
||||||
|
*/
|
||||||
|
export default function EpisodeOrderToggle({ groups, selectedId, onChange }: Props) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const options: OrderingOption[] = [
|
||||||
|
{ id: 'aired', label: 'Aired order' },
|
||||||
|
...groups
|
||||||
|
.filter(g => g.episode_count >= 5 && g.type !== 1)
|
||||||
|
.map(g => ({
|
||||||
|
id: g.id,
|
||||||
|
label: g.name || EPISODE_GROUP_TYPES[g.type] || 'Alternate',
|
||||||
|
description: EPISODE_GROUP_TYPES[g.type],
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||||
|
}
|
||||||
|
window.addEventListener('mousedown', handler)
|
||||||
|
return () => window.removeEventListener('mousedown', handler)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const current = options.find(o => o.id === selectedId) || options[0]
|
||||||
|
|
||||||
|
if (options.length <= 1) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(v => !v)}
|
||||||
|
className="inline-flex items-center gap-1.5 h-7 px-2.5 rounded-lg bg-elevated/60 ring-1 ring-border hover:ring-border-strong text-[11.5px] text-text-2 hover:text-text-1 transition tracking-tight"
|
||||||
|
>
|
||||||
|
<svg className="w-3 h-3 opacity-70" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 5a1 1 0 011-1h8a1 1 0 110 2H4a1 1 0 01-1-1zm0 5a1 1 0 011-1h4a1 1 0 110 2H4a1 1 0 01-1-1z" />
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium">{current.label}</span>
|
||||||
|
<svg className={`w-3 h-3 opacity-60 transition ${open ? 'rotate-180' : ''}`} viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path d="M5.5 7.5L10 12l4.5-4.5" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -4 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -4 }}
|
||||||
|
transition={{ duration: 0.18, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
className="absolute right-0 top-full mt-1.5 z-30 min-w-[180px] rounded-xl bg-surface/95 backdrop-blur-md ring-1 ring-border-strong shadow-xl overflow-hidden"
|
||||||
|
>
|
||||||
|
{options.map(opt => (
|
||||||
|
<button
|
||||||
|
key={opt.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onChange(opt.id)
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
className={`w-full text-left px-3 py-2 text-[12px] hover:bg-elevated/80 transition ${
|
||||||
|
opt.id === selectedId ? 'text-accent' : 'text-text-2'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="font-medium leading-tight">{opt.label}</div>
|
||||||
|
{opt.description && opt.description !== opt.label && (
|
||||||
|
<div className="text-[10px] text-text-4 mt-0.5">{opt.description}</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Star } from '../../lib/icons'
|
||||||
|
import { useCinemetaEpisode } from '../../lib/episode-meta-context'
|
||||||
|
import { parseImdbRating } from '../../lib/episode-meta'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
season: number | null | undefined
|
||||||
|
episode: number | null | undefined
|
||||||
|
/** Tuned for inline placement next to small meta text. */
|
||||||
|
size?: 'sm' | 'md'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Yellow IMDB pill rendered next to episode meta when Cinemeta has a
|
||||||
|
* rating for this S/E. Silently renders nothing when the data is missing,
|
||||||
|
* so it adds zero clutter to series Cinemeta hasn't indexed.
|
||||||
|
*/
|
||||||
|
export default function EpisodeRatingChip({ season, episode, size = 'sm' }: Props) {
|
||||||
|
const cinemeta = useCinemetaEpisode(season, episode)
|
||||||
|
const rating = parseImdbRating(cinemeta?.imdbRating)
|
||||||
|
if (rating == null) return null
|
||||||
|
const cls =
|
||||||
|
size === 'sm'
|
||||||
|
? 'inline-flex items-center gap-1 h-4 px-1.5 rounded text-[10px] font-mono font-semibold text-yellow-300 bg-yellow-500/10 ring-1 ring-yellow-500/20 tabular-nums'
|
||||||
|
: 'inline-flex items-center gap-1 h-5 px-1.5 rounded text-[11px] font-mono font-semibold text-yellow-300 bg-yellow-500/10 ring-1 ring-yellow-500/20 tabular-nums'
|
||||||
|
return (
|
||||||
|
<span className={cls} title="IMDB rating (via Cinemeta)" aria-label={`IMDB rating ${rating.toFixed(1)}`}>
|
||||||
|
<Star size={size === 'sm' ? 9 : 10} className="text-yellow-400 fill-yellow-400" />
|
||||||
|
{rating.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { Play, Check, ChevronRight, CloudOff } from '../../lib/icons'
|
||||||
|
import { useBulkMarkPlayed } from '../../hooks/use-jellyfin'
|
||||||
|
import { usePreferencesStore } from '../../stores/preferences-store'
|
||||||
|
import { formatRuntime } from '../../lib/format'
|
||||||
|
import { getBestImage, getStoredServerUrl } from '../../api/jellyfin'
|
||||||
|
import type { BaseItemDto } from '../../api/types'
|
||||||
|
import EpisodeRatingChip from './EpisodeRatingChip'
|
||||||
|
import FillerChip from './FillerChip'
|
||||||
|
import MetaBadges from '../ui/MetaBadges'
|
||||||
|
import SwipeReveal from '../ui/SwipeReveal'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
episode: BaseItemDto
|
||||||
|
index: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EpisodeRow({ episode, index }: Props) {
|
||||||
|
const serverUrl = getStoredServerUrl()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [thumbErrored, setThumbErrored] = useState(false)
|
||||||
|
const spoilerBlur = usePreferencesStore(s => s.episode.show.spoilerBlur)
|
||||||
|
const showFillerChips = usePreferencesStore(s => s.episode.show.fillerChips)
|
||||||
|
const showRatingChips = usePreferencesStore(s => s.episode.show.ratingChips)
|
||||||
|
const swipeActionsEnabled = usePreferencesStore(s => s.episode.behavior.swipeActions)
|
||||||
|
const markPlayed = useBulkMarkPlayed()
|
||||||
|
|
||||||
|
const thumbUrl = getBestImage(serverUrl, episode, 'thumb', 360)
|
||||||
|
const showThumb = thumbUrl && !thumbErrored
|
||||||
|
|
||||||
|
const isWatched = episode.UserData?.Played
|
||||||
|
const progress = episode.UserData?.PlayedPercentage
|
||||||
|
const runtime = episode.RunTimeTicks ? formatRuntime(episode.RunTimeTicks) : null
|
||||||
|
|
||||||
|
// Missing = file not on disk. Jellyfin's canonical signal is LocationType:
|
||||||
|
// "Virtual" means aired but not acquired, "FileSystem" means we have the
|
||||||
|
// file. Trusting LocationType matches what Jellyfin's own UI does.
|
||||||
|
const isMissing = (episode as { LocationType?: string }).LocationType === 'Virtual'
|
||||||
|
|
||||||
|
function goToDetail() {
|
||||||
|
if (!episode.Id) return
|
||||||
|
navigate(`/item/${episode.Id}`)
|
||||||
|
}
|
||||||
|
function goToPlay(e: React.MouseEvent) {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (isMissing || !episode.Id) return
|
||||||
|
navigate(`/play/${episode.Id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SwipeReveal
|
||||||
|
enabled={swipeActionsEnabled}
|
||||||
|
actions={episode.Id && !isMissing ? [
|
||||||
|
{
|
||||||
|
label: isWatched ? 'Unwatched' : 'Watched',
|
||||||
|
tone: isWatched ? 'neutral' : 'accent',
|
||||||
|
onPress: () => {
|
||||||
|
if (!episode.Id) return
|
||||||
|
markPlayed.mutate({ itemIds: [episode.Id], played: !isWatched })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] : []}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
onClick={goToDetail}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
goToDetail()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={`View ${episode.Name || 'episode'} details`}
|
||||||
|
initial={{ opacity: 0, y: 6 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: Math.min(index * 0.025, 0.3), ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
className={`w-full group flex gap-4 p-3 rounded-lg transition-all duration-200 text-left focus-ring cursor-pointer ${
|
||||||
|
isMissing ? 'opacity-55' : 'hover:bg-elevated/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={goToPlay}
|
||||||
|
disabled={isMissing}
|
||||||
|
aria-label={isMissing ? 'Episode missing' : `Play ${episode.Name || 'episode'}`}
|
||||||
|
className={`w-[180px] aspect-video rounded-md overflow-hidden bg-elevated shrink-0 relative focus-ring ${
|
||||||
|
isMissing ? 'ring-1 ring-error/25 cursor-not-allowed' : 'ring-1 ring-border cursor-pointer'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{showThumb ? (
|
||||||
|
<img
|
||||||
|
src={thumbUrl}
|
||||||
|
alt=""
|
||||||
|
onError={() => setThumbErrored(true)}
|
||||||
|
className={`w-full h-full object-cover transition-[transform,filter] duration-500 group-hover:scale-105 ${
|
||||||
|
spoilerBlur && !isWatched ? 'blur-md saturate-50 group-hover:blur-0 group-hover:saturate-100' : ''
|
||||||
|
}`}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full grid place-items-center bg-gradient-to-br from-elevated to-surface">
|
||||||
|
<span className="text-text-4 text-2xl font-display font-semibold opacity-50 select-none">
|
||||||
|
{episode.IndexNumber != null ? `E${episode.IndexNumber}` : (episode.Name || '?')[0]?.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/40 to-transparent" />
|
||||||
|
{!isMissing && (
|
||||||
|
<div className="absolute inset-0 grid place-items-center opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||||
|
<div className="w-11 h-11 rounded-full bg-accent grid place-items-center shadow-lg shadow-black/40">
|
||||||
|
<Play size={16} className="text-void translate-x-0.5" fill="currentColor" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isMissing && (
|
||||||
|
<div className="absolute inset-0 grid place-items-center bg-black/40">
|
||||||
|
<div className="flex flex-col items-center gap-1 text-error">
|
||||||
|
<CloudOff size={20} />
|
||||||
|
<span className="text-[9px] font-bold uppercase tracking-[0.12em]">Missing</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{progress != null && progress > 0 && (
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 h-[2px] bg-black/30">
|
||||||
|
<div
|
||||||
|
className="h-full bg-accent shadow-[0_0_6px_rgba(245,182,66,0.5)]"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="absolute top-1.5 right-1.5">
|
||||||
|
<MetaBadges item={episode} />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0 py-0.5">
|
||||||
|
<div className="flex items-baseline gap-2.5 mb-1.5">
|
||||||
|
{episode.IndexNumber != null && (
|
||||||
|
<span className="text-accent/80 text-[11px] tabular-nums font-mono font-semibold tracking-wider shrink-0">
|
||||||
|
{String(episode.IndexNumber).padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-[14.5px] text-text-1 font-semibold tracking-tight leading-tight truncate">
|
||||||
|
{episode.Name}
|
||||||
|
</span>
|
||||||
|
{isWatched && (
|
||||||
|
<span className="shrink-0 w-4 h-4 rounded-full bg-accent grid place-items-center self-center">
|
||||||
|
<Check size={9} className="text-void" strokeWidth={3} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isMissing && (
|
||||||
|
<span className="shrink-0 inline-flex items-center gap-1 h-4 px-1.5 rounded bg-error/15 text-error text-[9px] font-bold uppercase tracking-[0.06em] self-center">
|
||||||
|
<CloudOff size={8} />
|
||||||
|
Missing
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{showFillerChips && (
|
||||||
|
<span className="shrink-0 self-center">
|
||||||
|
<FillerChip
|
||||||
|
season={episode.ParentIndexNumber ?? null}
|
||||||
|
episode={episode.IndexNumber ?? null}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="ml-auto flex items-center gap-2 shrink-0 text-[11px] text-text-4 tabular-nums">
|
||||||
|
{showRatingChips && (
|
||||||
|
<EpisodeRatingChip
|
||||||
|
season={episode.ParentIndexNumber ?? null}
|
||||||
|
episode={episode.IndexNumber ?? null}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{episode.PremiereDate && (
|
||||||
|
<span>
|
||||||
|
{new Date(episode.PremiereDate).toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{episode.PremiereDate && runtime && <span className="text-text-5">·</span>}
|
||||||
|
{runtime && <span>{runtime}</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{episode.Overview && (
|
||||||
|
<p
|
||||||
|
className={`text-[12.5px] text-text-3 leading-[1.65] line-clamp-2 max-w-[78ch] tracking-[-0.005em] [text-wrap:pretty] transition-[filter,color] duration-200 ${
|
||||||
|
spoilerBlur && !isWatched
|
||||||
|
? 'blur-[3px] text-text-4 group-hover:blur-0 group-hover:text-text-3'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{episode.Overview}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ChevronRight
|
||||||
|
size={18}
|
||||||
|
className="text-text-4 self-center opacity-0 group-hover:opacity-100 transition-all duration-200 -translate-x-1 group-hover:translate-x-0"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</SwipeReveal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { ExternalLink } from '../../lib/icons'
|
||||||
|
import type { TmdbExternalIds } from '../../api/tmdb'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
tmdbId?: string | number | null
|
||||||
|
type?: 'movie' | 'tv'
|
||||||
|
ids?: TmdbExternalIds | null
|
||||||
|
jellyfinExternalUrls?: { Name?: string | null; Url?: string | null }[] | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ExternalLinks({ tmdbId, type, ids, jellyfinExternalUrls }: Props) {
|
||||||
|
const links: { label: string; href: string; primary?: boolean }[] = []
|
||||||
|
|
||||||
|
if (ids?.imdb_id) {
|
||||||
|
links.push({ label: 'IMDb', href: `https://www.imdb.com/title/${ids.imdb_id}/`, primary: true })
|
||||||
|
}
|
||||||
|
if (tmdbId) {
|
||||||
|
links.push({
|
||||||
|
label: 'TMDB',
|
||||||
|
href: `https://www.themoviedb.org/${type === 'tv' ? 'tv' : 'movie'}/${tmdbId}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (ids?.wikidata_id) {
|
||||||
|
links.push({ label: 'Wikidata', href: `https://www.wikidata.org/wiki/${ids.wikidata_id}` })
|
||||||
|
}
|
||||||
|
if (ids?.tvdb_id) {
|
||||||
|
links.push({ label: 'TVDB', href: `https://thetvdb.com/?tab=series&id=${ids.tvdb_id}` })
|
||||||
|
}
|
||||||
|
for (const u of jellyfinExternalUrls || []) {
|
||||||
|
if (u.Url && u.Name && !links.some(l => l.label === u.Name)) {
|
||||||
|
links.push({ label: u.Name, href: u.Url })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!links.length) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
{links.map(l => (
|
||||||
|
<a
|
||||||
|
key={l.label}
|
||||||
|
href={l.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="group inline-flex items-center gap-1.5 h-8 px-2.5 bg-white/8 hover:bg-white/15 backdrop-blur border border-white/10 hover:border-white/20 rounded-md text-[11.5px] text-white/80 hover:text-white font-medium transition-all duration-150 focus-ring"
|
||||||
|
>
|
||||||
|
{l.label}
|
||||||
|
<ExternalLink size={11} className="text-white/45 group-hover:text-white/75 transition-colors" />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { Calendar, Cloud, Disc3, Star, Building2, Globe, Languages } from '../../lib/icons'
|
||||||
|
import {
|
||||||
|
countryLabel,
|
||||||
|
flagEmoji,
|
||||||
|
formatLongDate,
|
||||||
|
formatUsd,
|
||||||
|
languageLabel,
|
||||||
|
} from '../../lib/format'
|
||||||
|
import { getTmdbImageUrl, type TmdbMovie, type TmdbTvShow } from '../../api/tmdb'
|
||||||
|
import BrandLogo from '../ui/BrandLogo'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
itemType: 'Movie' | 'Series' | 'Episode' | string | undefined
|
||||||
|
movie?: TmdbMovie | null
|
||||||
|
tv?: TmdbTvShow | null
|
||||||
|
region: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReleaseSplit {
|
||||||
|
theatrical: string | null
|
||||||
|
digital: string | null
|
||||||
|
physical: string | null
|
||||||
|
premiere: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickReleases(movie: TmdbMovie, region: string): ReleaseSplit {
|
||||||
|
const empty: ReleaseSplit = {
|
||||||
|
theatrical: null,
|
||||||
|
digital: null,
|
||||||
|
physical: null,
|
||||||
|
premiere: null,
|
||||||
|
}
|
||||||
|
const all = movie.release_dates?.results || []
|
||||||
|
const regional = all.find(r => r.iso_3166_1 === region)
|
||||||
|
const us = all.find(r => r.iso_3166_1 === 'US')
|
||||||
|
const list = regional?.release_dates || us?.release_dates || []
|
||||||
|
const out = { ...empty }
|
||||||
|
for (const d of list) {
|
||||||
|
// type 1 Premiere, 2 Theatrical limited, 3 Theatrical, 4 Digital,
|
||||||
|
// 5 Physical, 6 TV
|
||||||
|
if (d.type === 3 && !out.theatrical) out.theatrical = d.release_date
|
||||||
|
if (d.type === 2 && !out.theatrical) out.theatrical = d.release_date
|
||||||
|
if (d.type === 4 && !out.digital) out.digital = d.release_date
|
||||||
|
if (d.type === 5 && !out.physical) out.physical = d.release_date
|
||||||
|
if (d.type === 1 && !out.premiere) out.premiere = d.release_date
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FactsPanel({ itemType, movie, tv, region }: Props) {
|
||||||
|
const releases = useMemo(
|
||||||
|
() => (movie ? pickReleases(movie, region) : null),
|
||||||
|
[movie, region],
|
||||||
|
)
|
||||||
|
|
||||||
|
const isMovie = itemType === 'Movie' && !!movie
|
||||||
|
const isSeries = itemType === 'Series' && !!tv
|
||||||
|
|
||||||
|
if (!isMovie && !isSeries) return null
|
||||||
|
|
||||||
|
const status = isMovie ? movie?.status : tv?.status
|
||||||
|
const originalLanguage = isMovie ? movie?.original_language : tv?.original_language
|
||||||
|
const productionCountries = (isMovie
|
||||||
|
? movie?.production_countries
|
||||||
|
: tv?.production_countries) || []
|
||||||
|
const productionCompanies = (isMovie
|
||||||
|
? movie?.production_companies
|
||||||
|
: tv?.production_companies) || []
|
||||||
|
const networks = isSeries ? tv?.networks || [] : []
|
||||||
|
const tagline = isMovie ? movie?.tagline : tv?.tagline
|
||||||
|
const homepage = isMovie ? movie?.homepage : tv?.homepage
|
||||||
|
const originalTitle = isMovie
|
||||||
|
? movie?.original_title && movie.original_title !== movie.title
|
||||||
|
? movie.original_title
|
||||||
|
: null
|
||||||
|
: tv?.original_name && tv?.original_name !== tv?.name
|
||||||
|
? tv.original_name
|
||||||
|
: null
|
||||||
|
const seriesType = isSeries ? tv?.type : null
|
||||||
|
const inProduction = isSeries ? tv?.in_production : null
|
||||||
|
const numberOfSeasons = isSeries ? tv?.number_of_seasons : null
|
||||||
|
const numberOfEpisodes = isSeries ? tv?.number_of_episodes : null
|
||||||
|
const firstAir = isSeries ? tv?.first_air_date : null
|
||||||
|
const lastAir = isSeries ? tv?.last_air_date : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl bg-elevated/40 border border-border overflow-hidden">
|
||||||
|
{/* Tagline header (if any) */}
|
||||||
|
{tagline && (
|
||||||
|
<div className="px-5 pt-5 pb-4 border-b border-border/60">
|
||||||
|
<p className="font-display text-[15px] italic text-text-2 leading-snug tracking-[-0.005em]">
|
||||||
|
"{tagline}"
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Ratings now live in the dedicated Reception section near the
|
||||||
|
top of the page. About panel sticks to facts: dates, budgets,
|
||||||
|
studios, languages, etc. */}
|
||||||
|
|
||||||
|
{/* Facts list */}
|
||||||
|
<dl className="divide-y divide-border/60">
|
||||||
|
{originalTitle && (
|
||||||
|
<Row label="Original title">
|
||||||
|
<span className="text-text-1">{originalTitle}</span>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status && (
|
||||||
|
<Row label="Status">
|
||||||
|
<span className={`inline-flex items-center gap-1.5 ${
|
||||||
|
status === 'Released' || status === 'Ended'
|
||||||
|
? 'text-text-1'
|
||||||
|
: 'text-accent'
|
||||||
|
}`}>
|
||||||
|
{(isSeries && inProduction) ||
|
||||||
|
status === 'In Production' ||
|
||||||
|
status === 'Returning Series' ||
|
||||||
|
status === 'Planned' ? (
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-pulse" />
|
||||||
|
) : null}
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{seriesType && (
|
||||||
|
<Row label="Type">
|
||||||
|
<span className="text-text-1">{seriesType}</span>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Movie release dates */}
|
||||||
|
{isMovie && releases && (releases.theatrical || releases.digital || releases.physical || releases.premiere) && (
|
||||||
|
<Row label="Release dates" alignTop>
|
||||||
|
<div className="flex flex-col gap-1.5 text-text-1">
|
||||||
|
{releases.theatrical && (
|
||||||
|
<DateLine icon={<Calendar size={12} />} label="Theatrical" date={releases.theatrical} />
|
||||||
|
)}
|
||||||
|
{releases.digital && (
|
||||||
|
<DateLine icon={<Cloud size={12} />} label="Digital" date={releases.digital} />
|
||||||
|
)}
|
||||||
|
{releases.physical && (
|
||||||
|
<DateLine icon={<Disc3 size={12} />} label="Physical" date={releases.physical} />
|
||||||
|
)}
|
||||||
|
{releases.premiere && !releases.theatrical && (
|
||||||
|
<DateLine icon={<Star size={12} />} label="Premiere" date={releases.premiere} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Series air dates */}
|
||||||
|
{isSeries && firstAir && (
|
||||||
|
<Row label="First air date">
|
||||||
|
<span className="text-text-1">{formatLongDate(firstAir)}</span>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
{isSeries && lastAir && lastAir !== firstAir && (
|
||||||
|
<Row label="Last air date">
|
||||||
|
<span className="text-text-1">{formatLongDate(lastAir)}</span>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
{isSeries && (numberOfSeasons || numberOfEpisodes) && (
|
||||||
|
<Row label="Run length">
|
||||||
|
<span className="text-text-1 tabular-nums">
|
||||||
|
{numberOfSeasons ? `${numberOfSeasons} season${numberOfSeasons === 1 ? '' : 's'}` : ''}
|
||||||
|
{numberOfSeasons && numberOfEpisodes ? ' · ' : ''}
|
||||||
|
{numberOfEpisodes ? `${numberOfEpisodes} episode${numberOfEpisodes === 1 ? '' : 's'}` : ''}
|
||||||
|
</span>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Money */}
|
||||||
|
{isMovie && movie?.revenue ? (
|
||||||
|
<Row label="Revenue">
|
||||||
|
<span className="text-text-1 tabular-nums">{formatUsd(movie.revenue)}</span>
|
||||||
|
</Row>
|
||||||
|
) : null}
|
||||||
|
{isMovie && movie?.budget ? (
|
||||||
|
<Row label="Budget">
|
||||||
|
<span className="text-text-1 tabular-nums">{formatUsd(movie.budget)}</span>
|
||||||
|
</Row>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Language + country */}
|
||||||
|
{originalLanguage && (
|
||||||
|
<Row label="Original language">
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-text-1">
|
||||||
|
<Languages size={12} className="text-text-3" />
|
||||||
|
{languageLabel(originalLanguage)}
|
||||||
|
</span>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
{productionCountries.length > 0 && (
|
||||||
|
<Row label={productionCountries.length > 1 ? 'Production countries' : 'Production country'} alignTop>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
{productionCountries.slice(0, 4).map(c => (
|
||||||
|
<span key={c.iso_3166_1} className="inline-flex items-center gap-1.5 text-text-1">
|
||||||
|
<span aria-hidden className="text-[14px] leading-none">
|
||||||
|
{flagEmoji(c.iso_3166_1) || <Globe size={12} className="text-text-3" />}
|
||||||
|
</span>
|
||||||
|
{countryLabel(c.iso_3166_1) || c.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Studios + networks */}
|
||||||
|
{productionCompanies.length > 0 && (
|
||||||
|
<Row label={productionCompanies.length > 1 ? 'Studios' : 'Studio'} alignTop>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{productionCompanies.slice(0, 4).map(c => (
|
||||||
|
<span key={c.id} className="inline-flex items-center gap-2 text-text-1">
|
||||||
|
{c.logo_path ? (
|
||||||
|
<BrandLogo
|
||||||
|
src={getTmdbImageUrl(c.logo_path, 'w92')}
|
||||||
|
alt={c.name}
|
||||||
|
height={14}
|
||||||
|
maxWidth={80}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Building2 size={12} className="text-text-3" />
|
||||||
|
)}
|
||||||
|
<span className="truncate">{c.name}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
{networks.length > 0 && (
|
||||||
|
<Row label={networks.length > 1 ? 'Networks' : 'Network'} alignTop>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{networks.slice(0, 4).map(n => (
|
||||||
|
<span key={n.id} className="inline-flex items-center gap-2 text-text-1">
|
||||||
|
{n.logo_path ? (
|
||||||
|
<BrandLogo
|
||||||
|
src={getTmdbImageUrl(n.logo_path, 'w92')}
|
||||||
|
alt={n.name}
|
||||||
|
height={14}
|
||||||
|
maxWidth={80}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Building2 size={12} className="text-text-3" />
|
||||||
|
)}
|
||||||
|
<span className="truncate">{n.name}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{homepage && (
|
||||||
|
<Row label="Homepage">
|
||||||
|
<a
|
||||||
|
href={homepage}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-accent hover:text-accent-hover transition truncate inline-block max-w-full"
|
||||||
|
>
|
||||||
|
{homepage.replace(/^https?:\/\//, '').replace(/\/$/, '')}
|
||||||
|
</a>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
alignTop = false,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
children: React.ReactNode
|
||||||
|
alignTop?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={`flex gap-4 px-5 py-3 ${alignTop ? 'items-start' : 'items-center'}`}>
|
||||||
|
<dt className="w-32 shrink-0 text-[11.5px] font-semibold text-text-3 uppercase tracking-[0.12em]">
|
||||||
|
{label}
|
||||||
|
</dt>
|
||||||
|
<dd className="flex-1 min-w-0 text-[13.5px] tracking-tight">{children}</dd>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DateLine({ icon, label, date }: { icon: React.ReactNode; label: string; date: string }) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-2 tabular-nums">
|
||||||
|
<span className="w-4 h-4 grid place-items-center text-text-3">{icon}</span>
|
||||||
|
<span className="text-[10.5px] text-text-3 uppercase tracking-[0.14em] w-[78px]">{label}</span>
|
||||||
|
<span>{formatLongDate(date)}</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { useFillerFlag } from '../../lib/episode-meta-context'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
season: number | null | undefined
|
||||||
|
episode: number | null | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filler/canon chip for anime episodes. Renders nothing when the show
|
||||||
|
* isn't in our bundled filler list, so non-anime users never see it.
|
||||||
|
*
|
||||||
|
* - 'filler': bright orange chip
|
||||||
|
* - 'mostly-filler': dimmer amber chip
|
||||||
|
* - 'canon': no chip (canon is the assumed default)
|
||||||
|
*/
|
||||||
|
export default function FillerChip({ season, episode }: Props) {
|
||||||
|
const flag = useFillerFlag(season, episode)
|
||||||
|
if (!flag || flag === 'canon') return null
|
||||||
|
if (flag === 'filler') {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center h-4 px-1.5 rounded text-[9.5px] font-bold uppercase tracking-[0.06em] text-orange-300 bg-orange-500/15 ring-1 ring-orange-500/25"
|
||||||
|
title="Filler episode (community classification)"
|
||||||
|
>
|
||||||
|
Filler
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center h-4 px-1.5 rounded text-[9.5px] font-bold uppercase tracking-[0.06em] text-amber-300 bg-amber-500/15 ring-1 ring-amber-500/25"
|
||||||
|
title="Mostly filler episode (community classification)"
|
||||||
|
>
|
||||||
|
Mostly filler
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet'
|
||||||
|
import L from 'leaflet'
|
||||||
|
import { parseCoords, type WikidataLocation } from '../../api/wikidata'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
locations: WikidataLocation[] | null | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inline SVG icon - default Leaflet markers reference image files we
|
||||||
|
// don't ship; building our own keeps the bundle clean and matches the
|
||||||
|
// app's accent color.
|
||||||
|
const markerIcon = L.divIcon({
|
||||||
|
className: 'jf-filming-marker',
|
||||||
|
html: `<svg width="22" height="30" viewBox="0 0 22 30" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M11 0C4.92 0 0 4.7 0 10.5 0 18 11 30 11 30s11-12 11-19.5C22 4.7 17.08 0 11 0z" fill="#F5B642" stroke="#1d1208" stroke-width="1.4"/>
|
||||||
|
<circle cx="11" cy="10.5" r="3.5" fill="#1d1208"/>
|
||||||
|
</svg>`,
|
||||||
|
iconSize: [22, 30],
|
||||||
|
iconAnchor: [11, 30],
|
||||||
|
popupAnchor: [0, -28],
|
||||||
|
})
|
||||||
|
|
||||||
|
interface Pin {
|
||||||
|
loc: WikidataLocation
|
||||||
|
lat: number
|
||||||
|
lon: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function FitToPins({ pins }: { pins: Pin[] }) {
|
||||||
|
const map = useMap()
|
||||||
|
useEffect(() => {
|
||||||
|
if (pins.length === 0) return
|
||||||
|
if (pins.length === 1) {
|
||||||
|
map.setView([pins[0].lat, pins[0].lon], 6)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const bounds = L.latLngBounds(pins.map(p => [p.lat, p.lon] as [number, number]))
|
||||||
|
map.fitBounds(bounds, { padding: [40, 40], maxZoom: 8 })
|
||||||
|
}, [pins, map])
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leaflet map plotting filming locations from Wikidata. Shows a small
|
||||||
|
* legend listing locations missing coordinates so users can still see
|
||||||
|
* them. Renders nothing when no locations are returned.
|
||||||
|
*
|
||||||
|
* The map uses CARTO's dark basemap to match the app's overall
|
||||||
|
* aesthetic. No API key required.
|
||||||
|
*/
|
||||||
|
export default function FilmingLocationsMap({ locations }: Props) {
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Defer mount until the user scrolls the map into view. Leaflet creates
|
||||||
|
// a lot of DOM and tiles on construction; lazy-mount keeps cold detail
|
||||||
|
// page navigation snappy.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current) return
|
||||||
|
const el = containerRef.current
|
||||||
|
const obs = new IntersectionObserver(entries => {
|
||||||
|
for (const e of entries) {
|
||||||
|
if (e.isIntersecting) {
|
||||||
|
setMounted(true)
|
||||||
|
obs.disconnect()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, { rootMargin: '120px' })
|
||||||
|
obs.observe(el)
|
||||||
|
return () => obs.disconnect()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const { pins, missing } = useMemo(() => {
|
||||||
|
const pinned: Pin[] = []
|
||||||
|
const without: WikidataLocation[] = []
|
||||||
|
for (const loc of locations || []) {
|
||||||
|
const c = parseCoords(loc.coords)
|
||||||
|
if (c) pinned.push({ loc, lat: c[0], lon: c[1] })
|
||||||
|
else without.push(loc)
|
||||||
|
}
|
||||||
|
return { pins: pinned, missing: without }
|
||||||
|
}, [locations])
|
||||||
|
|
||||||
|
if (!locations || locations.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="space-y-3">
|
||||||
|
<div className="relative aspect-[16/9] rounded-xl overflow-hidden ring-1 ring-border bg-elevated">
|
||||||
|
{mounted && pins.length > 0 ? (
|
||||||
|
<MapContainer
|
||||||
|
center={[pins[0].lat, pins[0].lon]}
|
||||||
|
zoom={4}
|
||||||
|
scrollWheelZoom={false}
|
||||||
|
className="w-full h-full"
|
||||||
|
attributionControl={false}
|
||||||
|
>
|
||||||
|
<TileLayer
|
||||||
|
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
||||||
|
attribution='© OpenStreetMap, © CARTO'
|
||||||
|
/>
|
||||||
|
{pins.map(p => (
|
||||||
|
<Marker key={p.loc.id} position={[p.lat, p.lon]} icon={markerIcon}>
|
||||||
|
<Popup>
|
||||||
|
<div className="text-[12px]">
|
||||||
|
<p className="font-semibold">{p.loc.label}</p>
|
||||||
|
{p.loc.countryLabel && (
|
||||||
|
<p className="text-[11px] opacity-70">{p.loc.countryLabel}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
</Marker>
|
||||||
|
))}
|
||||||
|
<FitToPins pins={pins} />
|
||||||
|
</MapContainer>
|
||||||
|
) : (
|
||||||
|
<div className="absolute inset-0 grid place-items-center text-text-4 text-[12px]">
|
||||||
|
{pins.length === 0 ? 'No mappable coordinates' : 'Loading map...'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{locations.map(loc => (
|
||||||
|
<span
|
||||||
|
key={loc.id}
|
||||||
|
className="inline-flex items-center gap-1.5 h-6 px-2.5 rounded-full bg-elevated/60 ring-1 ring-border text-[11px] text-text-2 tracking-tight"
|
||||||
|
title={loc.countryLabel || ''}
|
||||||
|
>
|
||||||
|
<svg className="w-2.5 h-2.5 text-accent" viewBox="0 0 12 12" fill="currentColor">
|
||||||
|
<path d="M6 0a4 4 0 00-4 4c0 3 4 8 4 8s4-5 4-8a4 4 0 00-4-4zm0 5.5a1.5 1.5 0 110-3 1.5 1.5 0 010 3z" />
|
||||||
|
</svg>
|
||||||
|
{loc.label}
|
||||||
|
{loc.countryLabel && (
|
||||||
|
<span className="text-text-4">· {loc.countryLabel}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{missing.length > 0 && pins.length > 0 && (
|
||||||
|
<p className="text-[10.5px] text-text-5 tracking-tight">
|
||||||
|
{missing.length} {missing.length === 1 ? 'location has' : 'locations have'} no mapped coordinates yet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { useLibraryItems } from '../../hooks/use-jellyfin'
|
||||||
|
import { usePosterGridClasses } from '../../lib/density'
|
||||||
|
import PosterCard from '../ui/PosterCard'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
itemId: string
|
||||||
|
title: string
|
||||||
|
collectionType?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FolderView({ itemId, title, collectionType }: Props) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
let includeItemTypes: string[] | undefined
|
||||||
|
if (collectionType === 'tvshows') includeItemTypes = ['Series']
|
||||||
|
else if (collectionType === 'movies') includeItemTypes = ['Movie']
|
||||||
|
else if (collectionType === 'music') includeItemTypes = ['MusicAlbum', 'MusicArtist']
|
||||||
|
|
||||||
|
const { data, isLoading } = useLibraryItems(itemId, {
|
||||||
|
sortBy: ['SortName'],
|
||||||
|
sortOrder: ['Ascending'],
|
||||||
|
includeItemTypes,
|
||||||
|
limit: 200,
|
||||||
|
})
|
||||||
|
|
||||||
|
const items = data?.Items || []
|
||||||
|
const gridCls = usePosterGridClasses()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-8 px-7">
|
||||||
|
<h1 className="text-2xl font-semibold text-text-1 mb-6 tracking-tight font-display">{title}</h1>
|
||||||
|
{isLoading ? (
|
||||||
|
<PosterGridSkeleton />
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<p className="text-[13px] text-text-4">No items found.</p>
|
||||||
|
) : (
|
||||||
|
<div className={gridCls}>
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={item.Id}
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: Math.min(i * 0.015, 0.3) }}
|
||||||
|
>
|
||||||
|
<PosterCard item={item} priority={i < 12} onClick={() => item.Id && navigate(`/item/${item.Id}`)} />
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PosterGridSkeleton() {
|
||||||
|
const gridCls = usePosterGridClasses()
|
||||||
|
return (
|
||||||
|
<div className={gridCls}>
|
||||||
|
{Array.from({ length: 18 }).map((_, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<div className="skeleton aspect-[2/3] rounded-lg" />
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
<div className="skeleton h-3 w-3/4 rounded" />
|
||||||
|
<div className="skeleton h-2.5 w-1/2 rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useTmdbPerson } from '../../hooks/use-tmdb'
|
||||||
|
import { mapTmdbToJf } from '../../lib/tmdb-mapping'
|
||||||
|
import ContentRow from '../ui/ContentRow'
|
||||||
|
import type { TmdbCombinedCreditCrew, TmdbCombinedCreditCast } from '../../api/tmdb'
|
||||||
|
|
||||||
|
type Role = 'director' | 'writer' | 'composer' | 'actor'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
personId: number
|
||||||
|
personName: string
|
||||||
|
role: Role
|
||||||
|
/** TMDB id of the current item so it gets filtered out of the row. */
|
||||||
|
excludeTmdbId?: number | string | null
|
||||||
|
/** Filter the credit list to the same media kind as the current page. */
|
||||||
|
kind: 'movie' | 'tv'
|
||||||
|
libraryMap?: Map<string, { id: string; name: string; type: string }> | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROLE_LABELS: Record<Role, string> = {
|
||||||
|
director: 'Director',
|
||||||
|
writer: 'Writer',
|
||||||
|
composer: 'Composer',
|
||||||
|
actor: 'Top-billed cast',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Row of other titles by a named person (director, writer, composer, or
|
||||||
|
* actor). Hides itself when there's too little material to be useful -
|
||||||
|
* one or two credits feels like a leak, four-plus reads as intentional.
|
||||||
|
*
|
||||||
|
* Filters to the same media kind (movie or tv) as the current detail
|
||||||
|
* page so a series page doesn't get drowned in director's movie work.
|
||||||
|
*/
|
||||||
|
export default function FromSameRow({
|
||||||
|
personId,
|
||||||
|
personName,
|
||||||
|
role,
|
||||||
|
excludeTmdbId,
|
||||||
|
kind,
|
||||||
|
libraryMap,
|
||||||
|
}: Props) {
|
||||||
|
const personQuery = useTmdbPerson(personId)
|
||||||
|
const items = useMemo(() => {
|
||||||
|
const credits = personQuery.data?.combined_credits
|
||||||
|
if (!credits) return []
|
||||||
|
|
||||||
|
const excludeStr = excludeTmdbId != null ? String(excludeTmdbId) : null
|
||||||
|
const source = role === 'actor' ? credits.cast : credits.crew
|
||||||
|
|
||||||
|
const matched = (source as Array<TmdbCombinedCreditCast | TmdbCombinedCreditCrew>).filter(c => {
|
||||||
|
if (c.media_type !== kind) return false
|
||||||
|
if (excludeStr && String(c.id) === excludeStr) return false
|
||||||
|
if (!c.poster_path) return false
|
||||||
|
if (role === 'actor') return true
|
||||||
|
const crew = c as TmdbCombinedCreditCrew
|
||||||
|
if (role === 'director') return crew.job === 'Director'
|
||||||
|
if (role === 'writer') return crew.department === 'Writing'
|
||||||
|
if (role === 'composer') {
|
||||||
|
return (
|
||||||
|
crew.job === 'Original Music Composer' ||
|
||||||
|
crew.job === 'Music' ||
|
||||||
|
crew.job === 'Composer'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
// De-dup by tmdb id (writers especially get credited multiple times
|
||||||
|
// on the same title under different jobs).
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const deduped = matched.filter(c => {
|
||||||
|
const k = String(c.id)
|
||||||
|
if (seen.has(k)) return false
|
||||||
|
seen.add(k)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
return deduped
|
||||||
|
.sort((a, b) => (b.vote_average || 0) * (b.popularity || 0) - (a.vote_average || 0) * (a.popularity || 0))
|
||||||
|
.slice(0, 20)
|
||||||
|
}, [personQuery.data, role, excludeTmdbId, kind])
|
||||||
|
|
||||||
|
// Skip rows that would look anemic - fewer than 3 entries doesn't
|
||||||
|
// earn its keep on the page.
|
||||||
|
if (items.length < 3) return null
|
||||||
|
|
||||||
|
const mapped = mapTmdbToJf(
|
||||||
|
items.map(it => ({
|
||||||
|
...it,
|
||||||
|
adult: false,
|
||||||
|
// mapTmdbToJf reads media_type to pick kind, which is already set.
|
||||||
|
})),
|
||||||
|
libraryMap,
|
||||||
|
)
|
||||||
|
|
||||||
|
const title = role === 'actor' ? `More with ${personName}` : `From ${personName}`
|
||||||
|
const subtitle = `${ROLE_LABELS[role]} on this title - other ${kind === 'movie' ? 'films' : 'shows'} they've worked on`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContentRow
|
||||||
|
title={title}
|
||||||
|
subtitle={subtitle}
|
||||||
|
items={mapped}
|
||||||
|
layoutKey={`from_same_${role}_${personId}`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
import type { BaseItemDto } from '../../api/types'
|
||||||
|
import MediaTechIcons from '../ui/MediaTechIcons'
|
||||||
|
import { RefreshCw, Loader2 } from '../../lib/icons'
|
||||||
|
import {
|
||||||
|
pickPrimarySource,
|
||||||
|
getAudioStreams,
|
||||||
|
resolutionLabel,
|
||||||
|
videoRangeLabel,
|
||||||
|
} from '../../lib/jellyfin-meta'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
techItem?: BaseItemDto | null
|
||||||
|
tmdbVote?: number | null
|
||||||
|
tmdbVoteCount?: number | null
|
||||||
|
jellyfinCommunity?: number | null
|
||||||
|
jellyfinCritic?: number | null
|
||||||
|
/** IMDB rating from the Cinemeta keyless feed (string in the source). */
|
||||||
|
imdbRating?: number | null
|
||||||
|
showTmdbRatings?: boolean
|
||||||
|
/** TV-show specific extras */
|
||||||
|
seasonsCount?: number | null
|
||||||
|
episodesCount?: number | null
|
||||||
|
seriesStatus?: string | null
|
||||||
|
/** Click handler for the "Rescan files" button shown when the
|
||||||
|
* techItem has a file but no probed streams. Omit to hide. */
|
||||||
|
onRescan?: () => void
|
||||||
|
isRescanning?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when the techItem has at least one renderable media
|
||||||
|
* detail. Mirrors the inner gate of MediaTechIcons so the parent can
|
||||||
|
* decide whether to show the "Media" label at all rather than render
|
||||||
|
* an empty section.
|
||||||
|
*/
|
||||||
|
function hasMediaSignal(item: BaseItemDto | null | undefined): boolean {
|
||||||
|
if (!item) return false
|
||||||
|
const source = pickPrimarySource(item)
|
||||||
|
const audio = getAudioStreams(item)[0]
|
||||||
|
const res = resolutionLabel(item)
|
||||||
|
const range = videoRangeLabel(item)
|
||||||
|
return !!(res || range || audio?.Codec || source?.Container)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Right-side rail of the detail-page hero. Minimal, frameless. Tech icons
|
||||||
|
* sit on top, then ratings, then at-a-glance stats - separated by hairline
|
||||||
|
* dividers rather than card chrome.
|
||||||
|
*/
|
||||||
|
export default function HeroTechCard({
|
||||||
|
techItem,
|
||||||
|
tmdbVote,
|
||||||
|
tmdbVoteCount,
|
||||||
|
jellyfinCommunity,
|
||||||
|
jellyfinCritic,
|
||||||
|
imdbRating,
|
||||||
|
showTmdbRatings = true,
|
||||||
|
seasonsCount,
|
||||||
|
episodesCount,
|
||||||
|
seriesStatus,
|
||||||
|
onRescan,
|
||||||
|
isRescanning,
|
||||||
|
}: Props) {
|
||||||
|
const showIcons = hasMediaSignal(techItem)
|
||||||
|
// Rescan is a passive offer whenever a refresh handler is available -
|
||||||
|
// not only when probe data is missing. Users want a way to re-probe
|
||||||
|
// even when SOMETHING is shown, because the sample might be from one
|
||||||
|
// good episode while others in the series are still missing data.
|
||||||
|
const showRescan = !!onRescan
|
||||||
|
const isProbeMissing = !showIcons
|
||||||
|
const showMediaSection = showIcons || showRescan
|
||||||
|
const showTmdb = showTmdbRatings && tmdbVote != null && tmdbVote > 0
|
||||||
|
const showJfCommunity = jellyfinCommunity != null && jellyfinCommunity > 0
|
||||||
|
const showCritic = jellyfinCritic != null && jellyfinCritic > 0
|
||||||
|
const showImdb = imdbRating != null && imdbRating > 0
|
||||||
|
const showRatings = showTmdb || showJfCommunity || showCritic || showImdb
|
||||||
|
const showStats = seasonsCount != null || episodesCount != null || seriesStatus
|
||||||
|
|
||||||
|
if (!showMediaSection && !showRatings && !showStats) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="hidden lg:flex flex-col gap-5 w-[260px] shrink-0 self-end pb-1">
|
||||||
|
{showMediaSection && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between gap-2 mb-2.5">
|
||||||
|
<Label>Media</Label>
|
||||||
|
{showRescan && (
|
||||||
|
<button
|
||||||
|
onClick={onRescan}
|
||||||
|
disabled={isRescanning}
|
||||||
|
title={isProbeMissing
|
||||||
|
? 'Stream info missing - click to ask Jellyfin to re-probe the file'
|
||||||
|
: 'Ask Jellyfin to re-probe the file (refreshes stream info)'}
|
||||||
|
aria-label="Rescan media files"
|
||||||
|
className="inline-flex items-center justify-center w-6 h-6 rounded text-text-3 hover:text-accent hover:bg-elevated/60 transition-colors duration-150 focus-ring disabled:opacity-60 disabled:cursor-progress"
|
||||||
|
>
|
||||||
|
{isRescanning ? (
|
||||||
|
<Loader2 size={12} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw size={11} stroke={2} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{showIcons ? (
|
||||||
|
<MediaTechIcons item={techItem} className="!gap-2" />
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={onRescan}
|
||||||
|
disabled={isRescanning}
|
||||||
|
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-md text-[11.5px] font-medium bg-elevated/60 text-text-2 border border-border hover:border-border-hover hover:text-text-1 transition-colors duration-150 focus-ring disabled:opacity-60 disabled:cursor-progress"
|
||||||
|
>
|
||||||
|
{isRescanning ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={12} className="animate-spin" />
|
||||||
|
Rescanning...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RefreshCw size={12} stroke={2} />
|
||||||
|
Rescan files
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!showIcons && (
|
||||||
|
<p className="text-[10.5px] text-text-4 mt-2 leading-snug">
|
||||||
|
Stream info missing. Click to ask Jellyfin to re-probe the file.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showMediaSection && (showRatings || showStats) && <Divider />}
|
||||||
|
|
||||||
|
{showRatings && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Ratings</Label>
|
||||||
|
{showTmdb && (
|
||||||
|
<RatingRow
|
||||||
|
source="TMDB"
|
||||||
|
tone="amber"
|
||||||
|
value={tmdbVote!.toFixed(1)}
|
||||||
|
meta={tmdbVoteCount ? `${formatVotes(tmdbVoteCount)} votes` : undefined}
|
||||||
|
tooltip="The Movie Database community score"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showImdb && (
|
||||||
|
<RatingRow
|
||||||
|
source="IMDB"
|
||||||
|
tone="yellow"
|
||||||
|
value={imdbRating!.toFixed(1)}
|
||||||
|
tooltip="IMDB rating (via Cinemeta)"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showJfCommunity && (
|
||||||
|
<RatingRow
|
||||||
|
source="Jellyfin"
|
||||||
|
tone="cool"
|
||||||
|
value={jellyfinCommunity!.toFixed(1)}
|
||||||
|
tooltip="Jellyfin community average"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showCritic && (
|
||||||
|
<RatingRow
|
||||||
|
source="Critics"
|
||||||
|
tone="emerald"
|
||||||
|
value={`${Math.round(jellyfinCritic!)}%`}
|
||||||
|
tooltip="Aggregate critic score"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showRatings && showStats && <Divider />}
|
||||||
|
|
||||||
|
{showStats && (
|
||||||
|
<div>
|
||||||
|
<Label>At a glance</Label>
|
||||||
|
<dl className="grid grid-cols-3 gap-x-4 gap-y-3 mt-2">
|
||||||
|
{seasonsCount != null && <Stat label="Seasons" value={seasonsCount} />}
|
||||||
|
{episodesCount != null && <Stat label="Episodes" value={episodesCount} />}
|
||||||
|
{seriesStatus && (
|
||||||
|
<Stat
|
||||||
|
label="Status"
|
||||||
|
value={seriesStatus === 'Returning Series' ? 'Airing' : seriesStatus}
|
||||||
|
tone={
|
||||||
|
seriesStatus === 'Returning Series' ? 'success'
|
||||||
|
: seriesStatus === 'Canceled' ? 'error'
|
||||||
|
: 'default'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Label({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<p className="text-[10px] uppercase tracking-[0.18em] font-semibold text-text-3 mb-2.5 leading-none">
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Divider() {
|
||||||
|
return <span className="h-px bg-border" />
|
||||||
|
}
|
||||||
|
|
||||||
|
function RatingRow({
|
||||||
|
source,
|
||||||
|
tone,
|
||||||
|
value,
|
||||||
|
meta,
|
||||||
|
tooltip,
|
||||||
|
}: {
|
||||||
|
source: string
|
||||||
|
tone: 'amber' | 'cool' | 'emerald' | 'yellow'
|
||||||
|
value: string
|
||||||
|
meta?: string
|
||||||
|
tooltip: string
|
||||||
|
}) {
|
||||||
|
const dotCls =
|
||||||
|
tone === 'amber' ? 'bg-accent'
|
||||||
|
: tone === 'cool' ? 'bg-cool'
|
||||||
|
: tone === 'yellow' ? 'bg-yellow-400'
|
||||||
|
: 'bg-emerald-400'
|
||||||
|
const valCls =
|
||||||
|
tone === 'amber' ? 'text-accent'
|
||||||
|
: tone === 'cool' ? 'text-cool'
|
||||||
|
: tone === 'yellow' ? 'text-yellow-300'
|
||||||
|
: 'text-emerald-300'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-baseline gap-2.5" title={tooltip}>
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full self-center shrink-0 ${dotCls}`} />
|
||||||
|
<span className="text-[12.5px] text-text-2 font-medium">{source}</span>
|
||||||
|
{meta && <span className="text-[10.5px] text-text-4 truncate">{meta}</span>}
|
||||||
|
<span className={`ml-auto text-[18px] font-bold tabular-nums tracking-tight font-display ${valCls}`}>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
tone = 'default',
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: string | number
|
||||||
|
tone?: 'default' | 'success' | 'error'
|
||||||
|
}) {
|
||||||
|
const valCls =
|
||||||
|
tone === 'success' ? 'text-success'
|
||||||
|
: tone === 'error' ? 'text-error'
|
||||||
|
: 'text-text-1'
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-[9.5px] uppercase tracking-[0.14em] font-semibold text-text-3 mb-0.5 leading-none truncate">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
<p className={`text-[18px] font-bold tabular-nums tracking-tight font-display truncate ${valCls}`}>
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatVotes(n: number): string {
|
||||||
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
||||||
|
if (n >= 1_000) return `${(n / 1_000).toFixed(n >= 10_000 ? 0 : 1)}k`
|
||||||
|
return n.toString()
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { Star, Check } from '../../lib/icons'
|
||||||
|
import { usePersonalData } from '../../stores/personal-data-store'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
itemId: string
|
||||||
|
/** When true, surfaces a "rewatch?" toggle next to the rewatch counter
|
||||||
|
* so the user can manually flip the most-recent watch flag without
|
||||||
|
* needing to re-play. Useful when they marked it watched outside the
|
||||||
|
* player. */
|
||||||
|
showRewatchToggle?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Personal" section on item detail pages: a 1-10 rating, freeform note,
|
||||||
|
* and the rewatch tally. All state lives in `usePersonalData` (local).
|
||||||
|
*
|
||||||
|
* The note input is debounced via local React state so typing doesn't
|
||||||
|
* trigger a store write per keystroke.
|
||||||
|
*/
|
||||||
|
export default function PersonalSection({ itemId, showRewatchToggle }: Props) {
|
||||||
|
const entry = usePersonalData(s => s.entries[itemId])
|
||||||
|
const setRating = usePersonalData(s => s.setRating)
|
||||||
|
const setNote = usePersonalData(s => s.setNote)
|
||||||
|
const recordWatch = usePersonalData(s => s.recordWatch)
|
||||||
|
|
||||||
|
// Local mirror of the note so typing isn't laggy.
|
||||||
|
const [localNote, setLocalNote] = useState(entry?.note || '')
|
||||||
|
|
||||||
|
// Sync from store on item change (so navigating to another item swaps
|
||||||
|
// notes correctly without leaking state).
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalNote(entry?.note || '')
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [itemId])
|
||||||
|
|
||||||
|
// Debounce writes by 400ms.
|
||||||
|
useEffect(() => {
|
||||||
|
if (localNote === (entry?.note || '')) return
|
||||||
|
const id = setTimeout(() => setNote(itemId, localNote), 400)
|
||||||
|
return () => clearTimeout(id)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [localNote])
|
||||||
|
|
||||||
|
const rating = entry?.rating ?? 0
|
||||||
|
const rewatchCount = entry?.rewatchCount ?? 0
|
||||||
|
const lastWasRewatch = entry?.lastWasRewatch ?? false
|
||||||
|
const [hoverRating, setHoverRating] = useState(0)
|
||||||
|
// Effective fill is the hover preview when hovering, else the saved rating.
|
||||||
|
const previewRating = hoverRating || rating
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] uppercase tracking-[0.18em] font-semibold text-text-3 mb-2">
|
||||||
|
Your rating
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1.5 flex-wrap"
|
||||||
|
onMouseLeave={() => setHoverRating(0)}
|
||||||
|
>
|
||||||
|
{Array.from({ length: 10 }).map((_, i) => {
|
||||||
|
const n = i + 1
|
||||||
|
const on = n <= previewRating
|
||||||
|
const isHover = hoverRating > 0
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
onClick={() => setRating(itemId, rating === n ? 0 : n)}
|
||||||
|
onMouseEnter={() => setHoverRating(n)}
|
||||||
|
aria-label={`Rate ${n} out of 10`}
|
||||||
|
className={`w-8 h-8 grid place-items-center rounded-md transition-all duration-150 focus-ring ${
|
||||||
|
on
|
||||||
|
? isHover
|
||||||
|
? 'bg-accent/25 text-accent shadow-[0_0_12px_-4px_rgba(245,182,66,0.6)]'
|
||||||
|
: 'bg-accent/15 text-accent'
|
||||||
|
: 'bg-elevated/40 text-text-4 hover:text-text-2 hover:bg-elevated/70'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Star size={14} fill={on ? 'currentColor' : 'none'} stroke={on ? 0 : 1.5} />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<span className="ml-2 text-[12.5px] text-text-3 tabular-nums">
|
||||||
|
{previewRating > 0 ? `${previewRating} / 10` : 'Not rated'}
|
||||||
|
</span>
|
||||||
|
{rating > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setRating(itemId, 0)}
|
||||||
|
className="ml-1 text-[11px] text-text-4 hover:text-text-2 transition focus-ring rounded"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] uppercase tracking-[0.18em] font-semibold text-text-3 mb-2">
|
||||||
|
Note
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
value={localNote}
|
||||||
|
onChange={e => setLocalNote(e.target.value)}
|
||||||
|
placeholder='Sticky note for this item - "Lent to Alex Mar 2026", "Watched at the cinema", etc.'
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2.5 rounded-md bg-elevated/40 ring-1 ring-border focus:ring-accent/50 outline-none text-[13px] tracking-tight resize-y leading-relaxed text-text-1 placeholder:text-text-4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] uppercase tracking-[0.18em] font-semibold text-text-3 mb-2">
|
||||||
|
Rewatches
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<motion.span
|
||||||
|
key={rewatchCount}
|
||||||
|
initial={{ scale: 0.8 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-full bg-accent/8 ring-1 ring-accent/20 text-[12.5px] text-accent font-medium tracking-tight"
|
||||||
|
>
|
||||||
|
<span className="tabular-nums font-semibold">{rewatchCount}</span>
|
||||||
|
{rewatchCount === 1 ? 'rewatch' : 'rewatches'}
|
||||||
|
</motion.span>
|
||||||
|
{showRewatchToggle && (
|
||||||
|
<button
|
||||||
|
onClick={() => recordWatch(itemId, !lastWasRewatch)}
|
||||||
|
className={`inline-flex items-center gap-1.5 h-8 px-3 rounded-full text-[11.5px] tracking-tight transition focus-ring ${
|
||||||
|
lastWasRewatch
|
||||||
|
? 'bg-accent text-void'
|
||||||
|
: 'bg-elevated/40 ring-1 ring-border text-text-2 hover:text-text-1'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{lastWasRewatch ? <Check size={11} stroke={2.5} /> : null}
|
||||||
|
{lastWasRewatch ? 'Last watch was a rewatch' : 'Mark last watch as rewatch'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { Eye, EyeOff, Heart, X, Disc3 } from '../../lib/icons'
|
||||||
|
import { getBestImage } from '../../api/jellyfin'
|
||||||
|
import type { BaseItemDto } from '../../api/types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Floating ghost that follows the pointer while a drag is in progress.
|
||||||
|
* Shows the moving item count and a label so multi-row drags read clearly.
|
||||||
|
*/
|
||||||
|
export function DragGhost({ count, pointerY, label }: { count: number; pointerY: number; label: string }) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95 }}
|
||||||
|
transition={{ duration: 0.12 }}
|
||||||
|
style={{ position: 'fixed', left: 16, top: pointerY - 18, pointerEvents: 'none', zIndex: 80 }}
|
||||||
|
className="bg-glass-strong backdrop-blur-xl border border-border-hover rounded-md px-3 py-1.5 shadow-2xl"
|
||||||
|
>
|
||||||
|
<p className="text-[12px] text-text-1 font-semibold tracking-tight">
|
||||||
|
{count > 1 ? `Moving ${count} items` : label || 'Moving item'}
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectionToolbarProps {
|
||||||
|
count: number
|
||||||
|
onClear: () => void
|
||||||
|
onRemove: () => void
|
||||||
|
onMarkPlayed: () => void
|
||||||
|
onMarkUnplayed: () => void
|
||||||
|
onToggleFavorite: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bottom-floating toolbar that appears whenever the user has rows
|
||||||
|
* selected. Bundles the bulk actions (mark watched / unwatched, favorite,
|
||||||
|
* remove) plus a Done button to clear the selection.
|
||||||
|
*/
|
||||||
|
export function SelectionToolbar({
|
||||||
|
count,
|
||||||
|
onClear,
|
||||||
|
onRemove,
|
||||||
|
onMarkPlayed,
|
||||||
|
onMarkUnplayed,
|
||||||
|
onToggleFavorite,
|
||||||
|
}: SelectionToolbarProps) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ y: 80, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
exit={{ y: 80, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.32, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
className="fixed left-1/2 -translate-x-1/2 bottom-6 z-toast inline-flex items-center gap-2 h-12 pl-4 pr-2 rounded-full bg-black/85 backdrop-blur-xl border border-white/12 shadow-2xl"
|
||||||
|
role="toolbar"
|
||||||
|
aria-label="Playlist selection"
|
||||||
|
>
|
||||||
|
<span className="text-[12.5px] font-semibold text-white tabular-nums tracking-tight">
|
||||||
|
{count} selected
|
||||||
|
</span>
|
||||||
|
<span className="w-px h-6 bg-white/10 mx-1" />
|
||||||
|
<ToolbarButton onClick={onMarkPlayed} icon={<Eye size={13} />} label="Mark watched" />
|
||||||
|
<ToolbarButton onClick={onMarkUnplayed} icon={<EyeOff size={13} />} label="Mark unwatched" />
|
||||||
|
<ToolbarButton onClick={onToggleFavorite} icon={<Heart size={13} />} label="Favorite" />
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={onRemove}
|
||||||
|
icon={<X size={13} />}
|
||||||
|
label="Remove"
|
||||||
|
tone="danger"
|
||||||
|
/>
|
||||||
|
<span className="w-px h-6 bg-white/10 mx-1" />
|
||||||
|
<button
|
||||||
|
onClick={onClear}
|
||||||
|
className="h-8 px-3 rounded-full text-[11.5px] text-white/70 hover:text-white hover:bg-white/10 transition-colors focus-ring"
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolbarButton({
|
||||||
|
onClick,
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
tone,
|
||||||
|
}: {
|
||||||
|
onClick: () => void
|
||||||
|
icon: React.ReactNode
|
||||||
|
label: string
|
||||||
|
tone?: 'danger'
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={`inline-flex items-center gap-1.5 h-8 px-2.5 rounded-full text-[11.5px] font-medium transition-colors focus-ring ${
|
||||||
|
tone === 'danger'
|
||||||
|
? 'text-error hover:bg-error/15'
|
||||||
|
: 'text-white/85 hover:text-white hover:bg-white/10'
|
||||||
|
}`}
|
||||||
|
title={label}
|
||||||
|
aria-label={label}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
<span className="hidden lg:inline">{label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 2x2 poster mosaic fallback for playlists that don't have a primary
|
||||||
|
* image. Shows the first 4 item posters in a grid - if fewer than 4 are
|
||||||
|
* available, blanks fill the gaps. Empty state shows a music disc icon.
|
||||||
|
*/
|
||||||
|
export function PosterMosaic({ items, serverUrl }: { items: BaseItemDto[]; serverUrl: string }) {
|
||||||
|
const tiles = items
|
||||||
|
.slice(0, 4)
|
||||||
|
.map(it => getBestImage(serverUrl, it, 'primary', 300))
|
||||||
|
.filter(Boolean) as string[]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-[200px] aspect-square rounded-xl overflow-hidden ring-1 ring-white/10 shadow-[0_24px_60px_-12px_rgba(0,0,0,0.7)] grid grid-cols-2 grid-rows-2 gap-px bg-elevated">
|
||||||
|
{tiles.length > 0 ? (
|
||||||
|
Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<div key={i} className="bg-elevated relative overflow-hidden">
|
||||||
|
{tiles[i] ? (
|
||||||
|
<img src={tiles[i]} alt="" className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full bg-gradient-to-br from-elevated to-surface" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="col-span-2 row-span-2 grid place-items-center">
|
||||||
|
<Disc3 size={48} className="text-text-4 opacity-50" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { Play, Check, Tv2, Film, Music } from '../../lib/icons'
|
||||||
|
import { getBestImage } from '../../api/jellyfin'
|
||||||
|
import { formatRuntime } from '../../lib/format'
|
||||||
|
import type { BaseItemDto } from '../../api/types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: BaseItemDto
|
||||||
|
index: number
|
||||||
|
serverUrl: string
|
||||||
|
selected: boolean
|
||||||
|
dragging: boolean
|
||||||
|
showDropIndicatorBefore: boolean
|
||||||
|
onPointerDownBody: (e: React.PointerEvent) => void
|
||||||
|
refSetter: (el: HTMLLIElement | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PlaylistRow({
|
||||||
|
item,
|
||||||
|
index,
|
||||||
|
serverUrl,
|
||||||
|
selected,
|
||||||
|
dragging,
|
||||||
|
showDropIndicatorBefore,
|
||||||
|
onPointerDownBody,
|
||||||
|
refSetter,
|
||||||
|
}: Props) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [thumbErrored, setThumbErrored] = useState(false)
|
||||||
|
const thumb = getBestImage(serverUrl, item, 'thumb', 320)
|
||||||
|
const showThumb = thumb && !thumbErrored
|
||||||
|
|
||||||
|
const isWatched = item.UserData?.Played
|
||||||
|
const progress = item.UserData?.PlayedPercentage
|
||||||
|
const runtime = item.RunTimeTicks ? formatRuntime(item.RunTimeTicks) : null
|
||||||
|
const year = item.ProductionYear || (item.PremiereDate ? new Date(item.PremiereDate).getFullYear() : null)
|
||||||
|
const augmented = item as BaseItemDto & {
|
||||||
|
LocationType?: string
|
||||||
|
MediaSources?: unknown[]
|
||||||
|
Path?: string
|
||||||
|
AlbumArtist?: string
|
||||||
|
Album?: string
|
||||||
|
}
|
||||||
|
const isMissing =
|
||||||
|
augmented.LocationType === 'Virtual' ||
|
||||||
|
(!(augmented.MediaSources?.length) && !augmented.Path)
|
||||||
|
|
||||||
|
const subtitle =
|
||||||
|
item.Type === 'Episode'
|
||||||
|
? `${item.SeriesName || ''}${
|
||||||
|
item.ParentIndexNumber != null && item.IndexNumber != null
|
||||||
|
? ` · S${item.ParentIndexNumber} · E${item.IndexNumber}`
|
||||||
|
: ''
|
||||||
|
}`
|
||||||
|
: item.Type === 'Movie'
|
||||||
|
? 'Movie'
|
||||||
|
: item.Type === 'Audio'
|
||||||
|
? augmented.AlbumArtist || augmented.Album || 'Audio'
|
||||||
|
: item.Type || ''
|
||||||
|
|
||||||
|
const TypeIcon =
|
||||||
|
item.Type === 'Episode' || item.Type === 'Series'
|
||||||
|
? Tv2
|
||||||
|
: item.Type === 'Audio' || item.Type === 'MusicAlbum'
|
||||||
|
? Music
|
||||||
|
: Film
|
||||||
|
|
||||||
|
function goPlay(e: React.MouseEvent) {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (isMissing || !item.Id) return
|
||||||
|
navigate(`/play/${item.Id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
ref={refSetter}
|
||||||
|
className={`relative ${dragging ? 'opacity-30' : ''}`}
|
||||||
|
>
|
||||||
|
{showDropIndicatorBefore && (
|
||||||
|
<span className="absolute -top-px inset-x-2 h-px bg-accent z-10 shadow-[0_0_8px_rgba(245,182,66,0.6)]" />
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
onPointerDown={onPointerDownBody}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={`Playlist item ${index + 1}`}
|
||||||
|
aria-selected={selected}
|
||||||
|
className={`group flex items-center gap-4 px-3 py-2.5 rounded-lg transition-colors duration-150 cursor-pointer focus-ring touch-none ${
|
||||||
|
isMissing ? 'opacity-60' : ''
|
||||||
|
} ${
|
||||||
|
selected
|
||||||
|
? 'bg-accent/12 ring-1 ring-accent/40 hover:bg-accent/15'
|
||||||
|
: 'hover:bg-elevated/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="w-8 shrink-0 text-[12px] tabular-nums font-mono text-text-4 group-hover:text-text-3 transition-colors text-center">
|
||||||
|
{String(index + 1).padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={goPlay}
|
||||||
|
onPointerDown={e => e.stopPropagation()}
|
||||||
|
disabled={isMissing}
|
||||||
|
aria-label={isMissing ? 'Item missing' : `Play ${item.Name}`}
|
||||||
|
className={`relative shrink-0 w-[120px] aspect-video rounded-md overflow-hidden bg-elevated ring-1 ring-border focus-ring ${
|
||||||
|
isMissing ? 'cursor-not-allowed' : 'cursor-pointer'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{showThumb ? (
|
||||||
|
<img
|
||||||
|
src={thumb}
|
||||||
|
alt=""
|
||||||
|
onError={() => setThumbErrored(true)}
|
||||||
|
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full grid place-items-center bg-gradient-to-br from-elevated to-surface">
|
||||||
|
<TypeIcon size={20} className="text-text-4 opacity-60" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/40 to-transparent" />
|
||||||
|
{!isMissing && (
|
||||||
|
<div className="absolute inset-0 grid place-items-center opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||||
|
<div className="w-9 h-9 rounded-full bg-accent grid place-items-center shadow-lg shadow-black/40">
|
||||||
|
<Play size={13} className="text-void translate-x-0.5" fill="currentColor" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{progress != null && progress > 0 && progress < 95 && (
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 h-[2px] bg-black/30">
|
||||||
|
<div className="h-full bg-accent" style={{ width: `${progress}%` }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0 flex flex-col gap-0.5">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<p className="text-[13.5px] text-text-1 font-semibold tracking-tight truncate">
|
||||||
|
{item.Name || 'Untitled'}
|
||||||
|
</p>
|
||||||
|
{isWatched && (
|
||||||
|
<span
|
||||||
|
className="shrink-0 w-4 h-4 rounded-full bg-accent grid place-items-center self-center"
|
||||||
|
title="Watched"
|
||||||
|
>
|
||||||
|
<Check size={9} className="text-void" strokeWidth={3} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{subtitle && (
|
||||||
|
<p className="text-[11.5px] text-text-3 truncate flex items-center gap-1.5">
|
||||||
|
<TypeIcon size={10} className="text-text-4 shrink-0" />
|
||||||
|
{subtitle}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hidden md:flex items-center gap-3 text-[11.5px] text-text-4 tabular-nums shrink-0">
|
||||||
|
{year && <span>{year}</span>}
|
||||||
|
{year && runtime && <span className="text-text-5">·</span>}
|
||||||
|
{runtime && <span>{runtime}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,555 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import {
|
||||||
|
Play,
|
||||||
|
Shuffle,
|
||||||
|
Heart,
|
||||||
|
Disc3,
|
||||||
|
} from '../../lib/icons'
|
||||||
|
import {
|
||||||
|
usePlaylistItems,
|
||||||
|
usePlaylistMove,
|
||||||
|
usePlaylistRemove,
|
||||||
|
useBulkMarkPlayed,
|
||||||
|
useBulkToggleFavorite,
|
||||||
|
} from '../../hooks/use-jellyfin'
|
||||||
|
import { getBestImage } from '../../api/jellyfin'
|
||||||
|
import { formatRuntime } from '../../lib/format'
|
||||||
|
import type { BaseItemDto } from '../../api/types'
|
||||||
|
import { useQueueStore } from '../../stores/queue-store'
|
||||||
|
import PlaylistRow from './PlaylistRow'
|
||||||
|
import { DragGhost, SelectionToolbar, PosterMosaic } from './PlaylistChrome'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PlaylistItemId is the per-row identifier the Jellyfin Playlists endpoint
|
||||||
|
* returns so the server knows *which* instance of a duplicated track to
|
||||||
|
* move or remove. The SDK's BaseItemDto doesn't include this field, but
|
||||||
|
* the playlist-items response always has it. Extracted here so the cast
|
||||||
|
* lives in one place.
|
||||||
|
*/
|
||||||
|
function playlistItemId(item: BaseItemDto | undefined): string | undefined {
|
||||||
|
return (item as (BaseItemDto & { PlaylistItemId?: string }) | undefined)?.PlaylistItemId
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Editable playlist view. The list supports:
|
||||||
|
* - Plain click on row body -> /item/{id} (detail)
|
||||||
|
* - Click on thumbnail -> /play/{id}
|
||||||
|
* - Ctrl/Cmd-click -> toggle selection (no nav)
|
||||||
|
* - Shift-click -> range-select from the last anchor
|
||||||
|
* - Drag a row -> reorder
|
||||||
|
* - Drag a row in selection -> reorder the whole selection as a unit
|
||||||
|
* - Selection toolbar -> remove, mark played/unplayed, favorite
|
||||||
|
*
|
||||||
|
* Reorders are optimistic: the local copy reflows instantly and Jellyfin's
|
||||||
|
* moveItem call is fired in the background. On settle, the cached query
|
||||||
|
* invalidates so the next read confirms server order.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PRIMARY_BTN =
|
||||||
|
'inline-flex items-center gap-2 h-11 px-6 rounded-full text-[13px] font-semibold tracking-tight transition-[transform,background] duration-200 ease-out focus-ring active:scale-[0.97]'
|
||||||
|
const SECONDARY_BTN =
|
||||||
|
'inline-flex items-center gap-2 h-11 px-5 rounded-full bg-white/8 hover:bg-white/14 backdrop-blur text-white text-[13px] font-medium tracking-tight border border-white/14 hover:border-white/25 transition-all duration-200 ease-out focus-ring active:scale-[0.97]'
|
||||||
|
const ICON_BTN =
|
||||||
|
'w-11 h-11 rounded-full bg-white/8 hover:bg-white/14 backdrop-blur border border-white/14 hover:border-white/25 transition-all duration-200 ease-out focus-ring active:scale-[0.97] grid place-items-center'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: BaseItemDto
|
||||||
|
serverUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PlaylistView({ item, serverUrl }: Props) {
|
||||||
|
const playlistId = item.Id || ''
|
||||||
|
const { data: rawItems = [], isLoading } = usePlaylistItems(playlistId || undefined)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const moveMut = usePlaylistMove(playlistId || undefined)
|
||||||
|
const removeMut = usePlaylistRemove(playlistId || undefined)
|
||||||
|
const markMut = useBulkMarkPlayed()
|
||||||
|
const favMut = useBulkToggleFavorite()
|
||||||
|
|
||||||
|
// Local optimistic copy. Stays in sync with the server until the user
|
||||||
|
// does something edit-y, then the local copy leads.
|
||||||
|
const [localItems, setLocalItems] = useState<BaseItemDto[]>([])
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalItems(rawItems as BaseItemDto[])
|
||||||
|
}, [rawItems])
|
||||||
|
const items = localItems
|
||||||
|
|
||||||
|
/* ── Selection ──────────────────────────────────────────── */
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||||
|
const [anchorIndex, setAnchorIndex] = useState<number | null>(null)
|
||||||
|
|
||||||
|
// Map of PlaylistItemId -> index for quick lookups
|
||||||
|
const indexByEntry = useMemo(() => {
|
||||||
|
const m = new Map<string, number>()
|
||||||
|
items.forEach((it, i) => {
|
||||||
|
const eid = playlistItemId(it)
|
||||||
|
if (eid) m.set(eid, i)
|
||||||
|
})
|
||||||
|
return m
|
||||||
|
}, [items])
|
||||||
|
|
||||||
|
// Drop selection if the underlying list lost some entries (e.g. delete)
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedIds(prev => {
|
||||||
|
const next = new Set<string>()
|
||||||
|
for (const eid of prev) if (indexByEntry.has(eid)) next.add(eid)
|
||||||
|
return next.size === prev.size ? prev : next
|
||||||
|
})
|
||||||
|
}, [indexByEntry])
|
||||||
|
|
||||||
|
// Esc clears selection
|
||||||
|
useEffect(() => {
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape' && selectedIds.size > 0) {
|
||||||
|
e.stopPropagation()
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
|
}, [selectedIds.size])
|
||||||
|
|
||||||
|
function toggleOne(entryId: string, index: number) {
|
||||||
|
setSelectedIds(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(entryId)) next.delete(entryId)
|
||||||
|
else next.add(entryId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
setAnchorIndex(index)
|
||||||
|
}
|
||||||
|
function selectRange(toIndex: number) {
|
||||||
|
if (anchorIndex == null) {
|
||||||
|
const eid = playlistItemId(items[toIndex])
|
||||||
|
if (eid) {
|
||||||
|
setSelectedIds(new Set([eid]))
|
||||||
|
setAnchorIndex(toIndex)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const lo = Math.min(anchorIndex, toIndex)
|
||||||
|
const hi = Math.max(anchorIndex, toIndex)
|
||||||
|
const next = new Set<string>()
|
||||||
|
for (let i = lo; i <= hi; i++) {
|
||||||
|
const eid = playlistItemId(items[i])
|
||||||
|
if (eid) next.add(eid)
|
||||||
|
}
|
||||||
|
setSelectedIds(next)
|
||||||
|
}
|
||||||
|
function clearSelection() {
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
setAnchorIndex(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Drag-and-drop ──────────────────────────────────────── */
|
||||||
|
type DragState = {
|
||||||
|
pointerId: number
|
||||||
|
indices: number[] // indices being moved (1 or many for multi-drag)
|
||||||
|
startY: number
|
||||||
|
currentY: number
|
||||||
|
dropIndex: number // 0..items.length, where the drop will land
|
||||||
|
}
|
||||||
|
const [drag, setDrag] = useState<DragState | null>(null)
|
||||||
|
const listRef = useRef<HTMLOListElement | null>(null)
|
||||||
|
const rowRefs = useRef<Map<number, HTMLLIElement>>(new Map())
|
||||||
|
// Tracks small movements before commit so a click doesn't accidentally start a drag
|
||||||
|
const pendingDragRef = useRef<{
|
||||||
|
pointerId: number
|
||||||
|
rowIndex: number
|
||||||
|
startX: number
|
||||||
|
startY: number
|
||||||
|
startedDrag: boolean
|
||||||
|
} | null>(null)
|
||||||
|
const DRAG_THRESHOLD = 5
|
||||||
|
|
||||||
|
function rowMidY(idx: number): number {
|
||||||
|
const el = rowRefs.current.get(idx)
|
||||||
|
if (!el) return 0
|
||||||
|
const r = el.getBoundingClientRect()
|
||||||
|
return r.top + r.height / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeDropIndex(pointerY: number, draggingIndices: number[]): number {
|
||||||
|
// Skip the dragged rows themselves when computing drop position so the
|
||||||
|
// pointer doesn't snap onto a row that's about to vanish.
|
||||||
|
const draggingSet = new Set(draggingIndices)
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
if (draggingSet.has(i)) continue
|
||||||
|
if (pointerY < rowMidY(i)) return i
|
||||||
|
}
|
||||||
|
return items.length
|
||||||
|
}
|
||||||
|
|
||||||
|
function startMaybeDrag(rowIndex: number, e: React.PointerEvent) {
|
||||||
|
// Only left-button mouse / single-touch, and not on inner buttons
|
||||||
|
if (e.button !== 0) return
|
||||||
|
pendingDragRef.current = {
|
||||||
|
pointerId: e.pointerId,
|
||||||
|
rowIndex,
|
||||||
|
startX: e.clientX,
|
||||||
|
startY: e.clientY,
|
||||||
|
startedDrag: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerMove(e: React.PointerEvent) {
|
||||||
|
const pending = pendingDragRef.current
|
||||||
|
if (!pending || pending.pointerId !== e.pointerId) return
|
||||||
|
if (!pending.startedDrag) {
|
||||||
|
const dx = e.clientX - pending.startX
|
||||||
|
const dy = e.clientY - pending.startY
|
||||||
|
if (Math.abs(dx) < DRAG_THRESHOLD && Math.abs(dy) < DRAG_THRESHOLD) return
|
||||||
|
pending.startedDrag = true
|
||||||
|
// Decide what's being dragged: the row, or the whole selection
|
||||||
|
const rowEntry = playlistItemId(items[pending.rowIndex])
|
||||||
|
const draggingSet = new Set<number>()
|
||||||
|
if (rowEntry && selectedIds.has(rowEntry)) {
|
||||||
|
for (const eid of selectedIds) {
|
||||||
|
const idx = indexByEntry.get(eid)
|
||||||
|
if (idx != null) draggingSet.add(idx)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
draggingSet.add(pending.rowIndex)
|
||||||
|
}
|
||||||
|
const indices = Array.from(draggingSet).sort((a, b) => a - b)
|
||||||
|
;(e.target as Element).setPointerCapture?.(e.pointerId)
|
||||||
|
setDrag({
|
||||||
|
pointerId: e.pointerId,
|
||||||
|
indices,
|
||||||
|
startY: e.clientY,
|
||||||
|
currentY: e.clientY,
|
||||||
|
dropIndex: computeDropIndex(e.clientY, indices),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setDrag(prev => {
|
||||||
|
if (!prev || prev.pointerId !== e.pointerId) return prev
|
||||||
|
const dropIndex = computeDropIndex(e.clientY, prev.indices)
|
||||||
|
return { ...prev, currentY: e.clientY, dropIndex }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerUp(e: React.PointerEvent) {
|
||||||
|
const pending = pendingDragRef.current
|
||||||
|
pendingDragRef.current = null
|
||||||
|
if (drag && drag.pointerId === e.pointerId) {
|
||||||
|
commitDrop(drag)
|
||||||
|
setDrag(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// No drag started -> treat as click
|
||||||
|
if (pending && pending.pointerId === e.pointerId && !pending.startedDrag) {
|
||||||
|
handleRowClick(pending.rowIndex, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitDrop(d: DragState) {
|
||||||
|
const moved = d.indices
|
||||||
|
if (moved.length === 0) return
|
||||||
|
const moving = moved.map(i => items[i]).filter(Boolean) as BaseItemDto[]
|
||||||
|
const remaining = items.filter((_, i) => !moved.includes(i))
|
||||||
|
// Translate the drop index from "original space" to "remaining-array space"
|
||||||
|
// by subtracting the count of moved indices that come before the drop
|
||||||
|
const beforeDrop = moved.filter(i => i < d.dropIndex).length
|
||||||
|
const insertAt = Math.max(0, Math.min(remaining.length, d.dropIndex - beforeDrop))
|
||||||
|
const next = [
|
||||||
|
...remaining.slice(0, insertAt),
|
||||||
|
...moving,
|
||||||
|
...remaining.slice(insertAt),
|
||||||
|
]
|
||||||
|
setLocalItems(next)
|
||||||
|
// Fire the API call sequentially so each move sees the server state from
|
||||||
|
// the previous one. Final indices come from the new array.
|
||||||
|
;(async () => {
|
||||||
|
for (let k = 0; k < moving.length; k++) {
|
||||||
|
const targetIndex = insertAt + k
|
||||||
|
const movingPlaylistItemId = playlistItemId(moving[k])
|
||||||
|
if (!movingPlaylistItemId) continue
|
||||||
|
try {
|
||||||
|
await moveMut.mutateAsync({ playlistItemId: movingPlaylistItemId, newIndex: targetIndex })
|
||||||
|
} catch {
|
||||||
|
/* invalidate-on-settle will reconcile */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRowClick(rowIndex: number, e: React.PointerEvent) {
|
||||||
|
const it = items[rowIndex]
|
||||||
|
if (!it) return
|
||||||
|
const eid = playlistItemId(it)
|
||||||
|
if (e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
selectRange(rowIndex)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.ctrlKey || e.metaKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (eid) toggleOne(eid, rowIndex)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Plain click - clear selection and navigate
|
||||||
|
clearSelection()
|
||||||
|
if (it.Id) navigate(`/item/${it.Id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Bulk actions ───────────────────────────────────────── */
|
||||||
|
function selectedItemIds(): string[] {
|
||||||
|
return Array.from(selectedIds)
|
||||||
|
.map(eid => items[indexByEntry.get(eid) ?? -1]?.Id)
|
||||||
|
.filter(Boolean) as string[]
|
||||||
|
}
|
||||||
|
function bulkRemove() {
|
||||||
|
const ids = Array.from(selectedIds)
|
||||||
|
if (ids.length === 0) return
|
||||||
|
setLocalItems(prev => prev.filter(it => !selectedIds.has(playlistItemId(it) ?? '')))
|
||||||
|
clearSelection()
|
||||||
|
removeMut.mutate(ids)
|
||||||
|
}
|
||||||
|
function bulkMark(played: boolean) {
|
||||||
|
const ids = selectedItemIds()
|
||||||
|
if (ids.length === 0) return
|
||||||
|
markMut.mutate({ itemIds: ids, played })
|
||||||
|
}
|
||||||
|
function bulkFavorite() {
|
||||||
|
const ids = selectedItemIds()
|
||||||
|
if (ids.length === 0) return
|
||||||
|
// If any in the selection isn't favorited, favorite them all.
|
||||||
|
// Otherwise unfavorite them all.
|
||||||
|
const items_ = ids
|
||||||
|
.map(id => items.find(it => it.Id === id))
|
||||||
|
.filter(Boolean) as BaseItemDto[]
|
||||||
|
const anyMissing = items_.some(it => !it.UserData?.IsFavorite)
|
||||||
|
favMut.mutate({ itemIds: ids, favorite: anyMissing })
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Hero data ──────────────────────────────────────────── */
|
||||||
|
const totalRuntimeTicks = useMemo(
|
||||||
|
() => items.reduce((sum, it) => sum + Number(it.RunTimeTicks ?? 0), 0),
|
||||||
|
[items],
|
||||||
|
)
|
||||||
|
const totalRuntime = totalRuntimeTicks > 0 ? formatRuntime(totalRuntimeTicks) : null
|
||||||
|
const watchedCount = useMemo(
|
||||||
|
() => items.filter(it => it.UserData?.Played).length,
|
||||||
|
[items],
|
||||||
|
)
|
||||||
|
const playlistPoster = getBestImage(serverUrl, item, 'primary', 600)
|
||||||
|
const playlistBackdrop =
|
||||||
|
getBestImage(serverUrl, item, 'backdrop', 1920) ||
|
||||||
|
(items[0] ? getBestImage(serverUrl, items[0], 'backdrop', 1920) : '') ||
|
||||||
|
playlistPoster
|
||||||
|
|
||||||
|
function playFirst() {
|
||||||
|
const playable = items.filter(it => !!it.Id) as BaseItemDto[]
|
||||||
|
if (playable.length === 0) return
|
||||||
|
useQueueStore.getState().setQueue({
|
||||||
|
items: playable,
|
||||||
|
originalOrder: playable,
|
||||||
|
index: 0,
|
||||||
|
source: playlistId ? { type: 'playlist', id: playlistId } : null,
|
||||||
|
shuffled: false,
|
||||||
|
})
|
||||||
|
if (playable[0].Id) navigate(`/play/${playable[0].Id}`)
|
||||||
|
}
|
||||||
|
function shufflePlay() {
|
||||||
|
const playable = items.filter(it => !!it.Id) as BaseItemDto[]
|
||||||
|
if (playable.length === 0) return
|
||||||
|
// Fisher-Yates - the `.sort(() => Math.random() - 0.5)` trick produces
|
||||||
|
// biased orderings because TimSort calls the comparator inconsistently.
|
||||||
|
const shuffled = [...playable]
|
||||||
|
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1))
|
||||||
|
;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
|
||||||
|
}
|
||||||
|
useQueueStore.getState().setQueue({
|
||||||
|
items: shuffled,
|
||||||
|
originalOrder: playable,
|
||||||
|
index: 0,
|
||||||
|
source: playlistId ? { type: 'playlist', id: playlistId } : null,
|
||||||
|
shuffled: true,
|
||||||
|
})
|
||||||
|
if (shuffled[0].Id) navigate(`/play/${shuffled[0].Id}`)
|
||||||
|
}
|
||||||
|
function togglePlaylistFavorite() {
|
||||||
|
if (!item.Id) return
|
||||||
|
favMut.mutate({ itemIds: [item.Id], favorite: !item.UserData?.IsFavorite })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} onPointerCancel={() => setDrag(null)}>
|
||||||
|
{/* ── Hero ─────────────────────────────────────────────── */}
|
||||||
|
<div className="relative isolate overflow-hidden">
|
||||||
|
{playlistBackdrop && (
|
||||||
|
<div className="absolute inset-0 -z-10">
|
||||||
|
<img
|
||||||
|
src={playlistBackdrop}
|
||||||
|
alt=""
|
||||||
|
className="w-full h-full object-cover scale-110 blur-[6px] opacity-55"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-b from-void/30 via-void/70 to-void" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="px-7 pt-12 pb-10 flex flex-col md:flex-row gap-7 items-end">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.96, y: 12 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.55, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
{playlistPoster ? (
|
||||||
|
<img
|
||||||
|
src={playlistPoster}
|
||||||
|
alt=""
|
||||||
|
className="w-[200px] aspect-square object-cover rounded-xl ring-1 ring-white/10 shadow-[0_24px_60px_-12px_rgba(0,0,0,0.7)]"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<PosterMosaic items={items} serverUrl={serverUrl} />
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 18 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.55, ease: [0.16, 1, 0.3, 1], delay: 0.1 }}
|
||||||
|
className="flex-1 min-w-0"
|
||||||
|
>
|
||||||
|
<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.16em] text-white/80 mb-3">
|
||||||
|
<Disc3 size={10} className="text-accent" />
|
||||||
|
Playlist
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<h1 className="font-display text-4xl md:text-5xl font-bold text-white leading-[1] tracking-tight mb-3 drop-shadow-[0_4px_16px_rgba(0,0,0,0.55)]">
|
||||||
|
{item.Name || 'Untitled'}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-[12.5px] text-white/65 font-medium tabular-nums mb-5">
|
||||||
|
{items.length} {items.length === 1 ? 'item' : 'items'}
|
||||||
|
{totalRuntime && <> · {totalRuntime}</>}
|
||||||
|
{watchedCount > 0 && <> · {watchedCount} watched</>}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2.5 flex-wrap mb-4">
|
||||||
|
<button
|
||||||
|
onClick={playFirst}
|
||||||
|
disabled={items.length === 0}
|
||||||
|
className={`${PRIMARY_BTN} bg-accent hover:bg-accent-hover text-void shadow-[0_8px_24px_-8px_rgba(245,182,66,0.5)] hover:scale-[1.03] disabled:opacity-50 disabled:hover:scale-100`}
|
||||||
|
>
|
||||||
|
<Play size={14} className="-ml-0.5" fill="currentColor" />
|
||||||
|
Play
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={shufflePlay}
|
||||||
|
disabled={items.length === 0}
|
||||||
|
className={`${SECONDARY_BTN} disabled:opacity-50`}
|
||||||
|
>
|
||||||
|
<Shuffle size={14} stroke={2} />
|
||||||
|
Shuffle
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={togglePlaylistFavorite}
|
||||||
|
aria-label="Like playlist"
|
||||||
|
aria-pressed={!!item.UserData?.IsFavorite}
|
||||||
|
className={`${ICON_BTN} ${item.UserData?.IsFavorite ? 'text-accent' : 'text-white'}`}
|
||||||
|
>
|
||||||
|
<Heart
|
||||||
|
size={16}
|
||||||
|
stroke={item.UserData?.IsFavorite ? 0 : 2}
|
||||||
|
fill={item.UserData?.IsFavorite ? 'currentColor' : 'none'}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{item.Overview && (
|
||||||
|
<p className="text-[13px] text-white/75 leading-[1.7] max-w-[68ch] tracking-[-0.005em]">
|
||||||
|
{item.Overview}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Item list ────────────────────────────────────────── */}
|
||||||
|
<div className="px-7 pt-2 pb-10">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex flex-col gap-2 mt-4">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-[68px] rounded-lg skeleton" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<p className="text-[14px] text-text-2 font-medium mb-1">This playlist is empty</p>
|
||||||
|
<p className="text-[12px] text-text-4">Add items to it from any movie or show on your server.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="relative">
|
||||||
|
{/* Tip strip - shown only when nothing is selected */}
|
||||||
|
{selectedIds.size === 0 && (
|
||||||
|
<p className="text-[10.5px] text-text-4 mb-2 mt-1 px-1">
|
||||||
|
Drag rows to reorder. Ctrl-click to multi-select, Shift-click for a range.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<ol ref={listRef} className="flex flex-col gap-1 mt-2 select-none">
|
||||||
|
{items.map((it, i) => {
|
||||||
|
const eid = playlistItemId(it)
|
||||||
|
const isSelected = !!eid && selectedIds.has(eid)
|
||||||
|
const isDragging = drag?.indices.includes(i) || false
|
||||||
|
return (
|
||||||
|
<PlaylistRow
|
||||||
|
key={`${eid || it.Id || i}-${i}`}
|
||||||
|
item={it}
|
||||||
|
index={i}
|
||||||
|
serverUrl={serverUrl}
|
||||||
|
selected={isSelected}
|
||||||
|
dragging={isDragging}
|
||||||
|
showDropIndicatorBefore={drag?.dropIndex === i}
|
||||||
|
onPointerDownBody={(ev) => startMaybeDrag(i, ev)}
|
||||||
|
refSetter={(el) => {
|
||||||
|
if (el) rowRefs.current.set(i, el)
|
||||||
|
else rowRefs.current.delete(i)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{/* Drop indicator at the very end of the list */}
|
||||||
|
{drag && drag.dropIndex === items.length && (
|
||||||
|
<li className="relative h-1 -my-px">
|
||||||
|
<span className="absolute inset-x-2 top-1/2 -translate-y-1/2 h-px bg-accent shadow-[0_0_8px_rgba(245,182,66,0.6)]" />
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
{/* Floating drag preview */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{drag && (
|
||||||
|
<DragGhost
|
||||||
|
count={drag.indices.length}
|
||||||
|
pointerY={drag.currentY}
|
||||||
|
label={items[drag.indices[0]]?.Name || ''}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Selection toolbar ────────────────────────────────── */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{selectedIds.size > 0 && (
|
||||||
|
<SelectionToolbar
|
||||||
|
count={selectedIds.size}
|
||||||
|
onClear={clearSelection}
|
||||||
|
onRemove={bulkRemove}
|
||||||
|
onMarkPlayed={() => bulkMark(true)}
|
||||||
|
onMarkUnplayed={() => bulkMark(false)}
|
||||||
|
onToggleFavorite={bulkFavorite}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { ChevronRight } from '../../lib/icons'
|
||||||
|
|
||||||
|
interface Keyword {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
keywords: Keyword[] | null | undefined
|
||||||
|
/** Plain-text "Production" section pulled from Wikipedia. Optional. */
|
||||||
|
wikiProduction?: { extract: string; title: string } | null | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trivia block combining TMDB keywords (always present, lots of them)
|
||||||
|
* with the Wikipedia "Production" section (richer, sometimes long).
|
||||||
|
*
|
||||||
|
* The wiki extract collapses to the first 3 paragraphs by default to keep
|
||||||
|
* the page calm; a "Read more" toggle reveals the rest.
|
||||||
|
*/
|
||||||
|
export default function ProductionTrivia({ keywords, wikiProduction }: Props) {
|
||||||
|
const [expanded, setExpanded] = useState(false)
|
||||||
|
const hasKeywords = (keywords?.length ?? 0) > 0
|
||||||
|
const hasWiki = !!wikiProduction?.extract
|
||||||
|
|
||||||
|
if (!hasKeywords && !hasWiki) return null
|
||||||
|
|
||||||
|
const fullParagraphs = (wikiProduction?.extract || '')
|
||||||
|
.split(/\n{2,}/)
|
||||||
|
.map(p => p.trim())
|
||||||
|
.filter(p => p.length > 30)
|
||||||
|
const previewParagraphs = fullParagraphs.slice(0, 3)
|
||||||
|
const hasMore = fullParagraphs.length > previewParagraphs.length
|
||||||
|
const shown = expanded ? fullParagraphs : previewParagraphs
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{hasWiki && shown.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] uppercase tracking-[0.18em] font-semibold text-text-3 mb-2.5">
|
||||||
|
Production
|
||||||
|
</p>
|
||||||
|
<div className="space-y-3 text-[13.5px] text-text-2 leading-relaxed">
|
||||||
|
{shown.map((p, i) => (
|
||||||
|
<motion.p
|
||||||
|
key={i}
|
||||||
|
initial={{ opacity: 0, y: 4 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: Math.min(i * 0.05, 0.3) }}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</motion.p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{hasMore && (
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded(v => !v)}
|
||||||
|
className="mt-3 inline-flex items-center gap-1 text-[11.5px] text-accent hover:text-accent-hover tracking-tight focus-ring rounded"
|
||||||
|
>
|
||||||
|
{expanded ? 'Show less' : 'Read more'}
|
||||||
|
<motion.span animate={{ rotate: expanded ? 90 : 0 }} transition={{ duration: 0.2 }}>
|
||||||
|
<ChevronRight size={11} />
|
||||||
|
</motion.span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<AnimatePresence>
|
||||||
|
{wikiProduction?.title && (
|
||||||
|
<motion.p
|
||||||
|
key="src"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className="mt-3 text-[10.5px] text-text-5 tracking-tight"
|
||||||
|
>
|
||||||
|
Source: Wikipedia, "{wikiProduction.title}"
|
||||||
|
</motion.p>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { X } from '../../lib/icons'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
title: string
|
||||||
|
body: string
|
||||||
|
source?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const FONT_SIZES: Array<{ label: string; class: string }> = [
|
||||||
|
{ label: 'Aa', class: 'text-[15px] leading-[1.7]' },
|
||||||
|
{ label: 'Aa', class: 'text-[17px] leading-[1.7]' },
|
||||||
|
{ label: 'Aa', class: 'text-[19px] leading-[1.75]' },
|
||||||
|
{ label: 'Aa', class: 'text-[22px] leading-[1.8]' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typography-tuned reader overlay for long Wikipedia / Cinemeta
|
||||||
|
* synopses. Keyboard: Esc to close, +/- to bump font size.
|
||||||
|
*
|
||||||
|
* The overlay sits above everything else and traps focus implicitly by
|
||||||
|
* being modal; we don't render an explicit focus trap because the
|
||||||
|
* user can dismiss with Esc or click outside.
|
||||||
|
*/
|
||||||
|
export default function ReadingMode({ open, onClose, title, body, source }: Props) {
|
||||||
|
const [fontIdx, setFontIdx] = useState(1)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
else if (e.key === '+' || e.key === '=') setFontIdx(i => Math.min(FONT_SIZES.length - 1, i + 1))
|
||||||
|
else if (e.key === '-' || e.key === '_') setFontIdx(i => Math.max(0, i - 1))
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
|
}, [open, onClose])
|
||||||
|
|
||||||
|
// Split on blank lines into paragraphs so Wikipedia-style copy reads
|
||||||
|
// properly, and on single newlines as soft breaks within a paragraph.
|
||||||
|
const paragraphs = body
|
||||||
|
.split(/\n{2,}/)
|
||||||
|
.map(p => p.trim())
|
||||||
|
.filter(p => p.length > 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="fixed inset-0 z-[80] bg-void/95 backdrop-blur-sm overflow-y-auto"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<motion.article
|
||||||
|
initial={{ y: 24, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
exit={{ y: 24, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.32, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
className="relative max-w-[68ch] mx-auto py-12 px-6 md:px-8"
|
||||||
|
>
|
||||||
|
<header className="sticky top-4 z-10 flex items-center justify-end gap-2 mb-4 -mr-2">
|
||||||
|
<div className="inline-flex items-center bg-elevated/70 ring-1 ring-border rounded-md p-0.5 backdrop-blur">
|
||||||
|
{FONT_SIZES.map((f, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => setFontIdx(i)}
|
||||||
|
aria-label={`Font size ${i + 1}`}
|
||||||
|
className={`h-7 px-2 rounded text-text-3 hover:text-text-1 transition ${
|
||||||
|
fontIdx === i ? 'bg-accent/15 text-accent' : ''
|
||||||
|
}`}
|
||||||
|
style={{ fontSize: 10 + i * 2 }}
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close reader"
|
||||||
|
className="w-8 h-8 grid place-items-center rounded-full bg-elevated/70 ring-1 ring-border text-text-3 hover:text-text-1 backdrop-blur transition focus-ring"
|
||||||
|
>
|
||||||
|
<X size={14} stroke={2} />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<h1 className="font-display text-3xl md:text-4xl font-bold text-text-1 tracking-tight mb-6 leading-tight [text-wrap:balance]">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`space-y-5 text-text-1 ${FONT_SIZES[fontIdx].class} font-light tracking-[-0.005em] [text-wrap:pretty]`}
|
||||||
|
style={{ fontFamily: 'Geist, system-ui, sans-serif' }}
|
||||||
|
>
|
||||||
|
{paragraphs.map((p, i) => (
|
||||||
|
<p key={i}>{p}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{source && (
|
||||||
|
<p className="mt-8 text-[11.5px] text-text-4 tracking-tight">
|
||||||
|
Source: {source}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</motion.article>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { Star } from '../../lib/icons'
|
||||||
|
import type { RTRating } from '../../api/rotten-tomatoes'
|
||||||
|
import { getPersonalEntry } from '../../stores/personal-data-store'
|
||||||
|
import { usePersonalData } from '../../stores/personal-data-store'
|
||||||
|
import { useWikiSection } from '../../hooks/use-external'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Jellyfin item id - drives the personal rating lookup. Pass null for
|
||||||
|
* TMDB-only contexts where personal data doesn't apply. */
|
||||||
|
itemId?: string | null
|
||||||
|
rt?: RTRating | null
|
||||||
|
imdbRating?: number | null
|
||||||
|
/** TMDB community score on a 0-10 scale. */
|
||||||
|
tmdbScore?: number | null
|
||||||
|
tmdbVotes?: number | null
|
||||||
|
/** Jellyfin's own averaged community rating (item.CommunityRating). */
|
||||||
|
jellyfinCommunityRating?: number | null
|
||||||
|
/** Wikipedia article title to fetch the Critical-response / Reception
|
||||||
|
* section from. If null, the prose summary block is hidden. */
|
||||||
|
wikiTitle?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Rating {
|
||||||
|
source: string
|
||||||
|
/** 0-100 normalised score for the aggregate calc. */
|
||||||
|
norm: number
|
||||||
|
/** Display value as the user expects it. */
|
||||||
|
display: string
|
||||||
|
badge: React.ReactNode
|
||||||
|
href?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single home for everything users mean when they ask "is it any good?"
|
||||||
|
* Aggregates RT critics, RT audience, IMDb, TMDB, Jellyfin community,
|
||||||
|
* and the user's own rating into one panel, computes a meta-average,
|
||||||
|
* and pairs it with the Wikipedia critical-response paragraph when
|
||||||
|
* available so the page actually says *why* the score is what it is.
|
||||||
|
*/
|
||||||
|
export default function ReceptionPanel({
|
||||||
|
itemId,
|
||||||
|
rt,
|
||||||
|
imdbRating,
|
||||||
|
tmdbScore,
|
||||||
|
tmdbVotes,
|
||||||
|
jellyfinCommunityRating,
|
||||||
|
wikiTitle,
|
||||||
|
}: Props) {
|
||||||
|
// Subscribe so personal rating changes re-render the aggregate live.
|
||||||
|
usePersonalData(s => s.entries)
|
||||||
|
const personal = itemId ? getPersonalEntry(itemId) : null
|
||||||
|
const personalRating = personal && personal.rating > 0 ? personal.rating : null
|
||||||
|
|
||||||
|
// Wikipedia "Critical response" section (with "Reception" fallback
|
||||||
|
// tried inside the same hook - many articles use either heading).
|
||||||
|
const wikiCritical = useWikiSection(wikiTitle ?? null, 'Critical response')
|
||||||
|
const wikiReception = useWikiSection(
|
||||||
|
// Avoid the second fetch when the first hit returns content.
|
||||||
|
wikiTitle && !wikiCritical.data ? wikiTitle : null,
|
||||||
|
'Reception',
|
||||||
|
)
|
||||||
|
const wikiText = wikiCritical.data?.extract || wikiReception.data?.extract || null
|
||||||
|
|
||||||
|
const ratings = useMemo<Rating[]>(() => {
|
||||||
|
const out: Rating[] = []
|
||||||
|
if (rt?.criticsScore != null) {
|
||||||
|
out.push({
|
||||||
|
source: 'Tomatometer',
|
||||||
|
norm: rt.criticsScore,
|
||||||
|
display: `${Math.round(rt.criticsScore)}%`,
|
||||||
|
href: rt.url,
|
||||||
|
badge: (
|
||||||
|
<span className="text-[14px] leading-none">
|
||||||
|
{rt.criticsRating === 'Rotten' ? '🤢' : '🍅'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (rt?.audienceScore != null) {
|
||||||
|
out.push({
|
||||||
|
source: 'Audience',
|
||||||
|
norm: rt.audienceScore,
|
||||||
|
display: `${Math.round(rt.audienceScore)}%`,
|
||||||
|
href: rt.url,
|
||||||
|
badge: <span className="text-[14px] leading-none">🍿</span>,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (typeof imdbRating === 'number' && imdbRating > 0) {
|
||||||
|
out.push({
|
||||||
|
source: 'IMDb',
|
||||||
|
norm: imdbRating * 10,
|
||||||
|
display: imdbRating.toFixed(1),
|
||||||
|
badge: (
|
||||||
|
<span className="inline-flex items-center justify-center px-1 h-3.5 rounded-[2px] bg-[#f5c518] text-[8.5px] font-bold text-black tracking-tight">
|
||||||
|
IMDb
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (typeof tmdbScore === 'number' && tmdbScore > 0) {
|
||||||
|
out.push({
|
||||||
|
source: tmdbVotes ? `TMDB - ${tmdbVotes.toLocaleString()} votes` : 'TMDB',
|
||||||
|
norm: tmdbScore * 10,
|
||||||
|
display: `${Math.round(tmdbScore * 10)}%`,
|
||||||
|
badge: <Star size={13} className="text-accent" fill="currentColor" stroke={0} />,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (typeof jellyfinCommunityRating === 'number' && jellyfinCommunityRating > 0) {
|
||||||
|
out.push({
|
||||||
|
source: 'Jellyfin community',
|
||||||
|
norm: jellyfinCommunityRating * 10,
|
||||||
|
display: jellyfinCommunityRating.toFixed(1),
|
||||||
|
badge: <Star size={13} className="text-cool" fill="currentColor" stroke={0} />,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (personalRating != null) {
|
||||||
|
out.push({
|
||||||
|
source: 'Your rating',
|
||||||
|
norm: personalRating * 10,
|
||||||
|
display: `${personalRating}/10`,
|
||||||
|
badge: <Star size={13} className="text-accent" fill="currentColor" stroke={0} />,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}, [rt, imdbRating, tmdbScore, tmdbVotes, jellyfinCommunityRating, personalRating])
|
||||||
|
|
||||||
|
const aggregate = useMemo(() => {
|
||||||
|
if (ratings.length === 0) return null
|
||||||
|
const sum = ratings.reduce((acc, r) => acc + r.norm, 0)
|
||||||
|
return Math.round(sum / ratings.length)
|
||||||
|
}, [ratings])
|
||||||
|
|
||||||
|
if (ratings.length === 0 && !wikiText) return null
|
||||||
|
|
||||||
|
// Trim the wiki prose to the first 2 paragraphs so the panel stays calm.
|
||||||
|
const wikiParagraphs = wikiText
|
||||||
|
? wikiText.split(/\n{2,}/).map(s => s.trim()).filter(p => p.length > 40).slice(0, 2)
|
||||||
|
: []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl bg-elevated/30 ring-1 ring-border overflow-hidden">
|
||||||
|
{aggregate != null && (
|
||||||
|
<div className="px-5 py-4 flex items-center gap-5 border-b border-border/60 bg-void/30">
|
||||||
|
<div className="shrink-0 relative">
|
||||||
|
<AggregateGauge value={aggregate} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-[10.5px] uppercase tracking-[0.18em] font-semibold text-text-3 mb-1">
|
||||||
|
Aggregate
|
||||||
|
</p>
|
||||||
|
<p className="text-[12.5px] text-text-2 leading-snug">
|
||||||
|
Average across {ratings.length} {ratings.length === 1 ? 'source' : 'sources'}.
|
||||||
|
{ratings.length > 1 && (
|
||||||
|
<span className="text-text-4"> Equally weighted - the simplest honest math.</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ratings.length > 0 && (
|
||||||
|
<div className="px-5 py-4 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-3 gap-3">
|
||||||
|
{ratings.map(r => (
|
||||||
|
<RatingCell key={r.source} rating={r} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{wikiParagraphs.length > 0 && (
|
||||||
|
<div className="border-t border-border/60 px-5 py-4 bg-void/15">
|
||||||
|
<p className="text-[10.5px] uppercase tracking-[0.18em] font-semibold text-text-3 mb-3">
|
||||||
|
Critical response
|
||||||
|
</p>
|
||||||
|
<div className="space-y-3 text-[13px] text-text-2 leading-relaxed">
|
||||||
|
{wikiParagraphs.map((p, i) => (
|
||||||
|
<p key={i}>{p}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-[10.5px] text-text-5 tracking-tight">
|
||||||
|
Source: Wikipedia, "{(wikiCritical.data || wikiReception.data)?.title}"
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RatingCell({ rating }: { rating: Rating }) {
|
||||||
|
const content = (
|
||||||
|
<div className="flex items-center gap-2.5 p-2.5 rounded-lg bg-void/30 ring-1 ring-border/70 hover:ring-border-strong transition-colors h-full">
|
||||||
|
<span className="shrink-0 w-7 h-7 grid place-items-center">{rating.badge}</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-[10.5px] uppercase tracking-[0.14em] font-semibold text-text-3 truncate">
|
||||||
|
{rating.source}
|
||||||
|
</p>
|
||||||
|
<p className="text-[15px] font-display font-bold text-text-1 tabular-nums leading-none mt-0.5">
|
||||||
|
{rating.display}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
if (rating.href) {
|
||||||
|
return (
|
||||||
|
<a href={rating.href} target="_blank" rel="noopener noreferrer" className="block focus-ring rounded-lg">
|
||||||
|
{content}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Donut-style score gauge. Sweeps a ring proportional to the score and
|
||||||
|
* tints the stroke amber→cool depending on how high it is. Pure SVG, no
|
||||||
|
* external dependency.
|
||||||
|
*/
|
||||||
|
function AggregateGauge({ value }: { value: number }) {
|
||||||
|
const clamped = Math.max(0, Math.min(100, value))
|
||||||
|
const radius = 28
|
||||||
|
const circumference = 2 * Math.PI * radius
|
||||||
|
const dash = (clamped / 100) * circumference
|
||||||
|
const tone =
|
||||||
|
clamped >= 80 ? 'var(--color-accent)'
|
||||||
|
: clamped >= 60 ? 'var(--color-accent-deep)'
|
||||||
|
: clamped >= 40 ? 'var(--color-warning)'
|
||||||
|
: 'var(--color-error)'
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 72 72" width={64} height={64} className="-rotate-90">
|
||||||
|
<circle cx={36} cy={36} r={radius} fill="none" stroke="var(--color-border)" strokeWidth={6} />
|
||||||
|
<circle
|
||||||
|
cx={36}
|
||||||
|
cy={36}
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
stroke={tone}
|
||||||
|
strokeWidth={6}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={`${dash} ${circumference}`}
|
||||||
|
style={{ transition: 'stroke-dasharray 0.6s cubic-bezier(0.16, 1, 0.3, 1)' }}
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x="36"
|
||||||
|
y="40"
|
||||||
|
textAnchor="middle"
|
||||||
|
className="font-display font-bold fill-text-1"
|
||||||
|
style={{ fontSize: 18, transform: 'rotate(90deg)', transformOrigin: '36px 36px' }}
|
||||||
|
>
|
||||||
|
{clamped}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { Star, ExternalLink } from '../../lib/icons'
|
||||||
|
import { getTmdbImageUrl, type TmdbReview } from '../../api/tmdb'
|
||||||
|
import { formatDate } from '../../lib/format'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
reviews?: TmdbReview[] | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReviewsSection({ reviews }: Props) {
|
||||||
|
const [expanded, setExpanded] = useState<Record<string, boolean>>({})
|
||||||
|
const list = (reviews || []).slice(0, 4)
|
||||||
|
if (!list.length) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{list.map((r, i) => {
|
||||||
|
const isOpen = expanded[r.id]
|
||||||
|
const score = r.author_details?.rating
|
||||||
|
const avatar = r.author_details?.avatar_path
|
||||||
|
const isShortContent = (r.content?.length || 0) < 320
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.article
|
||||||
|
key={r.id}
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: Math.min(i * 0.05, 0.25) }}
|
||||||
|
className="rounded-xl bg-elevated/40 border border-border p-4 hover:border-border-hover transition-colors"
|
||||||
|
>
|
||||||
|
<header className="flex items-center gap-3 mb-2">
|
||||||
|
<div className="w-9 h-9 rounded-full overflow-hidden bg-higher ring-1 ring-border">
|
||||||
|
{avatar ? (
|
||||||
|
<img
|
||||||
|
src={getTmdbImageUrl(normalizeAvatar(avatar), 'w92')}
|
||||||
|
alt={r.author}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full grid place-items-center text-text-3 font-display font-semibold text-[13px]">
|
||||||
|
{(r.author || '?')[0].toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-[12.5px] text-text-1 font-medium truncate">{r.author}</p>
|
||||||
|
<p className="text-[10.5px] text-text-4">{formatDate(r.created_at)}</p>
|
||||||
|
</div>
|
||||||
|
{score != null && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 h-7 rounded-md bg-accent/15 text-accent text-[12px] font-semibold tabular-nums border border-accent/25">
|
||||||
|
<Star size={10} fill="currentColor" />
|
||||||
|
{score.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p
|
||||||
|
className={`text-[12.5px] text-text-2 leading-relaxed whitespace-pre-line ${
|
||||||
|
!isOpen && !isShortContent ? 'line-clamp-4' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{r.content}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mt-2.5">
|
||||||
|
{!isShortContent && (
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded(s => ({ ...s, [r.id]: !s[r.id] }))}
|
||||||
|
className="text-[11px] text-accent hover:text-accent-hover transition-colors font-medium focus-ring rounded px-1 -mx-1"
|
||||||
|
>
|
||||||
|
{isOpen ? 'Show less' : 'Read full review'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{r.url && (
|
||||||
|
<a
|
||||||
|
href={r.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="ml-auto text-[11px] text-text-4 hover:text-text-2 transition-colors flex items-center gap-1"
|
||||||
|
>
|
||||||
|
Read on TMDB <ExternalLink size={10} />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.article>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAvatar(path: string): string {
|
||||||
|
// TMDB sometimes returns gravatar-prefixed paths like '/https://...'
|
||||||
|
if (path.startsWith('/https')) return path.slice(1)
|
||||||
|
return path
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import type { BaseItemDto } from '../../api/types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
episodes: BaseItemDto[] | null | undefined
|
||||||
|
/** Hide the sparkline if no episodes have any progress at all (avoids
|
||||||
|
* visual noise on freshly-added seasons). */
|
||||||
|
hideWhenEmpty?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One thin bar per episode. Bar height encodes watched percentage:
|
||||||
|
* - 0% - tiny stub at the baseline (so the column is countable)
|
||||||
|
* - <100 - partial accent fill from the bottom
|
||||||
|
* - 100 - full accent
|
||||||
|
*
|
||||||
|
* Renders inline next to the season tab. Cheap (pure DOM, no SVG) and
|
||||||
|
* tooltips report a "X of N watched - P%" summary.
|
||||||
|
*/
|
||||||
|
export default function SeasonProgressSparkline({ episodes, hideWhenEmpty = true }: Props) {
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
if (!episodes?.length) return null
|
||||||
|
let watched = 0
|
||||||
|
let totalPct = 0
|
||||||
|
const bars: number[] = []
|
||||||
|
for (const ep of episodes) {
|
||||||
|
const ud = ep.UserData
|
||||||
|
const pct = ud?.PlayedPercentage ?? (ud?.Played ? 100 : 0)
|
||||||
|
bars.push(pct)
|
||||||
|
if (ud?.Played) watched++
|
||||||
|
totalPct += pct
|
||||||
|
}
|
||||||
|
const avg = totalPct / episodes.length
|
||||||
|
return { bars, watched, total: episodes.length, avg }
|
||||||
|
}, [episodes])
|
||||||
|
|
||||||
|
if (!stats) return null
|
||||||
|
if (hideWhenEmpty && stats.bars.every(b => b === 0)) return null
|
||||||
|
|
||||||
|
const tooltip = `${stats.watched} of ${stats.total} watched - ${Math.round(stats.avg)}%`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-end gap-[1px] h-3.5 ml-1.5 align-middle"
|
||||||
|
title={tooltip}
|
||||||
|
aria-label={tooltip}
|
||||||
|
>
|
||||||
|
{stats.bars.map((pct, i) => {
|
||||||
|
const filled = pct >= 99
|
||||||
|
const partial = pct > 0 && pct < 99
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="w-[2px] rounded-[1px] transition-colors"
|
||||||
|
style={{
|
||||||
|
height: pct === 0 ? '2px' : `${Math.max(3, (pct / 100) * 14)}px`,
|
||||||
|
backgroundColor: filled
|
||||||
|
? 'var(--accent, #F5B642)'
|
||||||
|
: partial
|
||||||
|
? 'rgba(245, 182, 66, 0.5)'
|
||||||
|
: 'var(--border, rgba(255,255,255,0.12))',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import {
|
||||||
|
useSeasons,
|
||||||
|
useSeasonsEpisodes,
|
||||||
|
useEpisodes,
|
||||||
|
} from '../../hooks/use-jellyfin'
|
||||||
|
import { useTmdbTvShow, useTmdbEpisodeGroup } from '../../hooks/use-tmdb'
|
||||||
|
import { useCinemeta } from '../../hooks/use-external'
|
||||||
|
import { buildCinemetaEpisodeMap } from '../../api/cinemeta'
|
||||||
|
import { EpisodeMetaProvider } from '../../lib/episode-meta-context'
|
||||||
|
import { parseImdbRating } from '../../lib/episode-meta'
|
||||||
|
import { useAnimeFiller, classifyEpisode } from '../../lib/anime-filler'
|
||||||
|
import type { FillerFlag } from '../../lib/anime-filler'
|
||||||
|
import { applyEpisodeGroupOrder } from '../../lib/episode-order'
|
||||||
|
import { usePreferencesStore } from '../../stores/preferences-store'
|
||||||
|
import { formatRuntime } from '../../lib/format'
|
||||||
|
import { getImageUrl, getStoredServerUrl } from '../../api/jellyfin'
|
||||||
|
import type { BaseItemDto } from '../../api/types'
|
||||||
|
import SeasonProgressSparkline from './SeasonProgressSparkline'
|
||||||
|
import EpisodeOrderToggle from './EpisodeOrderToggle'
|
||||||
|
import TimeSavedBadge from './TimeSavedBadge'
|
||||||
|
import EpisodeRow from './EpisodeRow'
|
||||||
|
import { Section } from '../ui/SectionLabel'
|
||||||
|
|
||||||
|
type FillerOf = (
|
||||||
|
season: number | null | undefined,
|
||||||
|
episode: number | null | undefined,
|
||||||
|
) => FillerFlag
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
seriesId: string
|
||||||
|
imdbId: string | null
|
||||||
|
tmdbId: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SeriesSection({ seriesId, imdbId, tmdbId }: Props) {
|
||||||
|
const [selectedSeason, setSelectedSeason] = useState<string | null>(null)
|
||||||
|
const { data: seasonsRaw } = useSeasons(seriesId)
|
||||||
|
const showSparklines = usePreferencesStore(s => s.episode.show.sparklines)
|
||||||
|
// Episode-group ordering. The selected id is either 'aired' (Jellyfin
|
||||||
|
// sequence, season-scoped) or a TMDB episode-group id (cross-season,
|
||||||
|
// reordered). Persisted per-series in localStorage so users keep the
|
||||||
|
// ordering they prefer for each show without polluting global prefs.
|
||||||
|
const orderStorageKey = `episodeOrder:${seriesId}`
|
||||||
|
const [orderId, setOrderId] = useState<string>(() => {
|
||||||
|
if (typeof window === 'undefined') return 'aired'
|
||||||
|
return localStorage.getItem(orderStorageKey) || 'aired'
|
||||||
|
})
|
||||||
|
const handleOrderChange = (id: string) => {
|
||||||
|
setOrderId(id)
|
||||||
|
try { localStorage.setItem(orderStorageKey, id) } catch { /* noop */ }
|
||||||
|
}
|
||||||
|
const tmdbTv = useTmdbTvShow(tmdbId)
|
||||||
|
const episodeGroups = tmdbTv.data?.episode_groups?.results || []
|
||||||
|
const groupQuery = useTmdbEpisodeGroup(orderId !== 'aired' ? orderId : null)
|
||||||
|
// Fetch Cinemeta for the show so we can pull per-episode IMDB ratings.
|
||||||
|
// Same call DetailPage already makes - React Query dedups it.
|
||||||
|
const { data: cinemetaSeriesData } = useCinemeta(imdbId, 'series')
|
||||||
|
const cinemetaMap = useMemo(
|
||||||
|
() => buildCinemetaEpisodeMap(cinemetaSeriesData),
|
||||||
|
[cinemetaSeriesData],
|
||||||
|
)
|
||||||
|
// Anime filler classification (only matches when the show is in the
|
||||||
|
// bundled list, otherwise always returns null).
|
||||||
|
const fillerData = useAnimeFiller(tmdbId)
|
||||||
|
|
||||||
|
// Specials (IndexNumber 0) get pushed to the end so the tabs open on Season 1.
|
||||||
|
const seasons = useMemo(() => {
|
||||||
|
if (!seasonsRaw) return seasonsRaw
|
||||||
|
return [...seasonsRaw].sort((a, b) => {
|
||||||
|
const ai = a.IndexNumber ?? 0
|
||||||
|
const bi = b.IndexNumber ?? 0
|
||||||
|
const aSpecial = ai === 0 ? 1 : 0
|
||||||
|
const bSpecial = bi === 0 ? 1 : 0
|
||||||
|
if (aSpecial !== bSpecial) return aSpecial - bSpecial
|
||||||
|
return ai - bi
|
||||||
|
})
|
||||||
|
}, [seasonsRaw])
|
||||||
|
|
||||||
|
const firstSeason = seasons?.[0]?.Id
|
||||||
|
const activeSeason = selectedSeason || firstSeason || null
|
||||||
|
const serverUrl = getStoredServerUrl()
|
||||||
|
|
||||||
|
// Per-season episode fetches feed the progress sparkline on each tab.
|
||||||
|
// Skipped on huge shows (>20 seasons) where the request burst isn't worth it.
|
||||||
|
const seasonIds = useMemo(
|
||||||
|
() => (seasons && seasons.length <= 20 ? seasons.map(s => s.Id!).filter(Boolean) : []),
|
||||||
|
[seasons],
|
||||||
|
)
|
||||||
|
const seasonEpisodeQueries = useSeasonsEpisodes(seriesId, seasonIds)
|
||||||
|
const episodesBySeason = useMemo(() => {
|
||||||
|
const m = new Map<string, BaseItemDto[]>()
|
||||||
|
seasonIds.forEach((id, i) => {
|
||||||
|
const data = seasonEpisodeQueries[i]?.data
|
||||||
|
if (data) m.set(id, data)
|
||||||
|
})
|
||||||
|
return m
|
||||||
|
}, [seasonIds, seasonEpisodeQueries])
|
||||||
|
|
||||||
|
// When a non-aired group is selected, fetch all episodes across seasons
|
||||||
|
// so the group can reorder freely. Otherwise stay scoped to the active
|
||||||
|
// season for performance.
|
||||||
|
const episodesScope = orderId === 'aired' ? activeSeason || undefined : undefined
|
||||||
|
const { data: episodes } = useEpisodes(seriesId, episodesScope)
|
||||||
|
|
||||||
|
const sortMode = usePreferencesStore(s => s.episode.behavior.sortMode)
|
||||||
|
const setEpisodeBehavior = usePreferencesStore(s => s.setEpisodeBehavior)
|
||||||
|
|
||||||
|
// Map a (season, episode) pair to its absolute episode number, summing
|
||||||
|
// ChildCount across earlier seasons. Filler lists count this way.
|
||||||
|
const seasonStarts = useMemo(() => {
|
||||||
|
const m = new Map<number, number>()
|
||||||
|
if (!seasons) return m
|
||||||
|
let running = 0
|
||||||
|
// Specials (season 0) don't add to the canon count
|
||||||
|
const ordered = seasons.filter(s => (s.IndexNumber ?? 0) > 0)
|
||||||
|
for (const s of ordered) {
|
||||||
|
const idx = s.IndexNumber ?? 0
|
||||||
|
m.set(idx, running)
|
||||||
|
running += s.ChildCount ?? 0
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}, [seasons])
|
||||||
|
|
||||||
|
const fillerOf = useMemo<FillerOf>(() => {
|
||||||
|
return (season, episode) => {
|
||||||
|
if (!fillerData || season == null || episode == null) return null
|
||||||
|
const start = seasonStarts.get(season)
|
||||||
|
if (start == null) return null // probably specials
|
||||||
|
return classifyEpisode(start + episode, fillerData)
|
||||||
|
}
|
||||||
|
}, [fillerData, seasonStarts])
|
||||||
|
|
||||||
|
// Apply group reorder first (if a non-aired group is active), then any
|
||||||
|
// user sort. When sorting by rating we look up each episode's IMDB
|
||||||
|
// score in the cinemeta map; episodes without a rating sort to the end
|
||||||
|
// regardless of direction so unrated entries don't pollute the top.
|
||||||
|
const orderedEpisodes = useMemo(() => {
|
||||||
|
if (!episodes) return episodes
|
||||||
|
const reordered = orderId !== 'aired' && groupQuery.data
|
||||||
|
? applyEpisodeGroupOrder(episodes, groupQuery.data)
|
||||||
|
: episodes
|
||||||
|
if (sortMode === 'order') return reordered
|
||||||
|
const direction = sortMode === 'rating-desc' ? -1 : 1
|
||||||
|
return [...reordered].sort((a, b) => {
|
||||||
|
const ar = parseImdbRating(
|
||||||
|
cinemetaMap.get(`${a.ParentIndexNumber ?? -1}:${a.IndexNumber ?? -1}`)?.imdbRating,
|
||||||
|
)
|
||||||
|
const br = parseImdbRating(
|
||||||
|
cinemetaMap.get(`${b.ParentIndexNumber ?? -1}:${b.IndexNumber ?? -1}`)?.imdbRating,
|
||||||
|
)
|
||||||
|
if (ar == null && br == null) return (a.IndexNumber ?? 0) - (b.IndexNumber ?? 0)
|
||||||
|
if (ar == null) return 1
|
||||||
|
if (br == null) return -1
|
||||||
|
return (ar - br) * direction
|
||||||
|
})
|
||||||
|
}, [episodes, sortMode, cinemetaMap, orderId, groupQuery.data])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EpisodeMetaProvider cinemetaMap={cinemetaMap} fillerOf={fillerOf}>
|
||||||
|
<Section label="Episodes">
|
||||||
|
<div className="flex items-center gap-3 mb-4 -mx-1 px-1">
|
||||||
|
{orderId === 'aired' && seasons && seasons.length > 1 ? (
|
||||||
|
<div className="flex-1 flex gap-1.5 overflow-x-auto hide-scrollbar">
|
||||||
|
{seasons.map(s => {
|
||||||
|
const isActive = activeSeason === s.Id
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={s.Id}
|
||||||
|
onClick={() => setSelectedSeason(s.Id!)}
|
||||||
|
className={`relative flex items-center gap-2 pr-3.5 h-9 text-[12px] font-medium rounded-md transition-all duration-200 whitespace-nowrap focus-ring ${
|
||||||
|
isActive ? 'text-accent' : 'text-text-3 hover:text-text-1'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isActive && (
|
||||||
|
<motion.span
|
||||||
|
layoutId="season-active"
|
||||||
|
className="absolute inset-0 bg-accent/15 border border-accent/25 rounded-md"
|
||||||
|
transition={{ type: 'spring', stiffness: 380, damping: 30 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{s.ImageTags?.Primary && s.Id && (
|
||||||
|
<img
|
||||||
|
src={getImageUrl(serverUrl, s.Id, 'Primary', 64, s.ImageTags.Primary)}
|
||||||
|
alt=""
|
||||||
|
className="relative z-10 w-7 h-[42px] rounded-l-md object-cover shrink-0"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="relative inline-flex items-center">
|
||||||
|
{s.Name}
|
||||||
|
{showSparklines && (
|
||||||
|
<SeasonProgressSparkline episodes={episodesBySeason.get(s.Id!)} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1" />
|
||||||
|
)}
|
||||||
|
{episodeGroups.length > 0 && (
|
||||||
|
<EpisodeOrderToggle
|
||||||
|
groups={episodeGroups}
|
||||||
|
selectedId={orderId}
|
||||||
|
onChange={handleOrderChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{cinemetaMap.size > 0 && (
|
||||||
|
<div className="flex p-0.5 bg-void/60 rounded-md border border-border shrink-0">
|
||||||
|
{([
|
||||||
|
{ value: 'order', label: 'Order' },
|
||||||
|
{ value: 'rating-desc', label: 'Best' },
|
||||||
|
{ value: 'rating-asc', label: 'Worst' },
|
||||||
|
] as const).map(opt => {
|
||||||
|
const active = sortMode === opt.value
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => setEpisodeBehavior('sortMode', opt.value)}
|
||||||
|
className={`h-7 px-2.5 text-[11px] font-medium tracking-tight rounded transition-colors duration-150 focus-ring ${
|
||||||
|
active ? 'bg-accent text-void' : 'text-text-3 hover:text-text-1'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Season totals + time-saved badge */}
|
||||||
|
<div className="flex items-center gap-3 mb-3 -mx-1 px-1 text-[11.5px] text-text-3">
|
||||||
|
{(() => {
|
||||||
|
const list = orderId === 'aired' && activeSeason
|
||||||
|
? episodesBySeason.get(activeSeason) || episodes || []
|
||||||
|
: episodes || []
|
||||||
|
const total = list.reduce((acc, ep) => acc + Number(ep.RunTimeTicks ?? 0), 0)
|
||||||
|
const count = list.length
|
||||||
|
if (count === 0) return null
|
||||||
|
const runtimeLabel = total > 0 ? formatRuntime(total) : null
|
||||||
|
return (
|
||||||
|
<span className="tracking-tight">
|
||||||
|
<span className="tabular-nums text-text-2 font-medium">{count}</span>
|
||||||
|
<span className="text-text-4"> {count === 1 ? 'episode' : 'episodes'}</span>
|
||||||
|
{runtimeLabel && (
|
||||||
|
<>
|
||||||
|
<span className="text-text-5 mx-1.5">·</span>
|
||||||
|
<span className="tabular-nums text-text-2 font-medium">{runtimeLabel}</span>
|
||||||
|
<span className="text-text-4"> total</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
<div className="flex-1" />
|
||||||
|
<TimeSavedBadge seriesId={seriesId} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.div
|
||||||
|
key={activeSeason || 'no-season'}
|
||||||
|
initial={{ opacity: 0, y: 4 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -4 }}
|
||||||
|
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
className="space-y-1"
|
||||||
|
>
|
||||||
|
{orderedEpisodes?.map((ep, i) => (
|
||||||
|
<EpisodeRow key={ep.Id} episode={ep} index={i} />
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</Section>
|
||||||
|
</EpisodeMetaProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { Calendar, Play, Radio, User, ExternalLink } from '../../lib/icons'
|
||||||
|
import type { TmdbTvShow, TmdbEpisode } from '../../api/tmdb'
|
||||||
|
import { getTmdbImageUrl } from '../../api/tmdb'
|
||||||
|
import type { TvmazeShow } from '../../api/tvmaze'
|
||||||
|
import { formatDate, formatRelative } from '../../lib/format'
|
||||||
|
import BrandLogo from '../ui/BrandLogo'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
show?: TmdbTvShow | null
|
||||||
|
tvmazeShow?: TvmazeShow | null
|
||||||
|
jellyfinAirDays?: string[] | null
|
||||||
|
jellyfinAirTime?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SeriesStatusBlock({
|
||||||
|
show,
|
||||||
|
tvmazeShow,
|
||||||
|
jellyfinAirDays,
|
||||||
|
jellyfinAirTime,
|
||||||
|
}: Props) {
|
||||||
|
if (!show) return null
|
||||||
|
|
||||||
|
const status = show.status
|
||||||
|
const networks = show.networks || []
|
||||||
|
const creators = show.created_by || []
|
||||||
|
const last = show.last_episode_to_air
|
||||||
|
const next = show.next_episode_to_air
|
||||||
|
|
||||||
|
// Schedule fallback chain: Jellyfin's own air metadata > TVmaze schedule.
|
||||||
|
// TVmaze provides days as e.g. ['Tuesday'] and time as 'HH:mm'.
|
||||||
|
const tvmazeDays = tvmazeShow?.schedule?.days || []
|
||||||
|
const tvmazeTime = tvmazeShow?.schedule?.time || ''
|
||||||
|
const airDays = jellyfinAirDays && jellyfinAirDays.length > 0 ? jellyfinAirDays : tvmazeDays
|
||||||
|
const airTime = jellyfinAirTime || tvmazeTime
|
||||||
|
|
||||||
|
const officialSite = tvmazeShow?.officialSite || null
|
||||||
|
const tvmazeRuntime = tvmazeShow?.averageRuntime || tvmazeShow?.runtime || null
|
||||||
|
|
||||||
|
const hasAny =
|
||||||
|
!!status ||
|
||||||
|
networks.length > 0 ||
|
||||||
|
creators.length > 0 ||
|
||||||
|
last ||
|
||||||
|
next ||
|
||||||
|
(airDays && airDays.length > 0) ||
|
||||||
|
!!officialSite
|
||||||
|
|
||||||
|
if (!hasAny) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* ── Frameless stats strip ─────────────────────────────── */}
|
||||||
|
<div className="flex flex-wrap gap-x-10 gap-y-5 pb-5 mb-5 border-b border-border">
|
||||||
|
{status && (
|
||||||
|
<Stat
|
||||||
|
icon={<Radio size={11} className="text-text-3" strokeWidth={2} />}
|
||||||
|
label="Status"
|
||||||
|
>
|
||||||
|
<StatusValue status={status} />
|
||||||
|
</Stat>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{networks.length > 0 && (
|
||||||
|
<Stat
|
||||||
|
icon={<NetworkGlyph />}
|
||||||
|
label={networks.length > 1 ? 'Networks' : 'Network'}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{networks.slice(0, 4).map(n => (
|
||||||
|
n.logo_path ? (
|
||||||
|
<BrandLogo
|
||||||
|
key={n.id}
|
||||||
|
src={getTmdbImageUrl(n.logo_path, 'w92')}
|
||||||
|
alt={n.name}
|
||||||
|
height={14}
|
||||||
|
maxWidth={64}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
key={n.id}
|
||||||
|
className="text-[11.5px] text-text-1 font-semibold tracking-tight"
|
||||||
|
title={n.name}
|
||||||
|
>
|
||||||
|
{n.name}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{(airDays?.length || airTime) && (
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-[11px] text-text-3 -mt-0.5">
|
||||||
|
<Calendar size={10} className="text-text-4" />
|
||||||
|
{airDays?.join(', ')}
|
||||||
|
{airTime ? ` · ${airTime}` : ''}
|
||||||
|
{tvmazeRuntime ? ` · ${tvmazeRuntime}m` : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{officialSite && (
|
||||||
|
<a
|
||||||
|
href={officialSite}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-[11px] text-accent hover:text-accent-hover transition-colors -mt-0.5 self-start focus-ring rounded"
|
||||||
|
>
|
||||||
|
Official site
|
||||||
|
<ExternalLink size={9} />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Stat>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{creators.length > 0 && (
|
||||||
|
<Stat
|
||||||
|
icon={<User size={11} className="text-text-3" strokeWidth={2} />}
|
||||||
|
label={creators.length > 1 ? 'Created by' : 'Creator'}
|
||||||
|
>
|
||||||
|
<span className="text-[13px] text-text-1 font-semibold tracking-tight">
|
||||||
|
{creators.map(c => c.name).join(', ')}
|
||||||
|
</span>
|
||||||
|
</Stat>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Episode features ─────────────────────────────────── */}
|
||||||
|
{(last || next) && (
|
||||||
|
<div className={`grid gap-4 ${last && next ? 'md:grid-cols-2' : ''}`}>
|
||||||
|
{last && <EpisodeFeature ep={last} label="Last episode" tone="muted" />}
|
||||||
|
{next && <EpisodeFeature ep={next} label="Next episode" tone="accent" />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode
|
||||||
|
label: string
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5 mb-1.5 leading-none">
|
||||||
|
{icon}
|
||||||
|
<p className="text-[10px] uppercase tracking-[0.16em] font-semibold text-text-3">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusValue({ status }: { status: string }) {
|
||||||
|
const tone =
|
||||||
|
status === 'Returning Series' ? { dot: 'bg-success', text: 'text-success', label: 'Airing' }
|
||||||
|
: status === 'In Production' ? { dot: 'bg-cool', text: 'text-cool', label: status }
|
||||||
|
: status === 'Ended' ? { dot: 'bg-text-3', text: 'text-text-1', label: status }
|
||||||
|
: status === 'Canceled' ? { dot: 'bg-error', text: 'text-error', label: status }
|
||||||
|
: { dot: 'bg-text-3', text: 'text-text-1', label: status }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<span className={`relative flex h-2 w-2`}>
|
||||||
|
<span className={`absolute inline-flex h-full w-full rounded-full ${tone.dot}`} />
|
||||||
|
{tone.label === 'Airing' && (
|
||||||
|
<span className={`absolute inline-flex h-full w-full rounded-full ${tone.dot} animate-ping opacity-75`} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className={`text-[13px] font-semibold tracking-tight ${tone.text}`}>{tone.label}</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NetworkGlyph() {
|
||||||
|
// Simple broadcast/wave glyph - matches Tabler icon stroke style
|
||||||
|
return (
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-text-3" aria-hidden>
|
||||||
|
<path d="M5 16a8 8 0 0 1 14 0" />
|
||||||
|
<path d="M8 16a5 5 0 0 1 8 0" />
|
||||||
|
<circle cx="12" cy="16" r="1" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EpisodeFeature({
|
||||||
|
ep,
|
||||||
|
label,
|
||||||
|
tone,
|
||||||
|
}: {
|
||||||
|
ep: TmdbEpisode
|
||||||
|
label: string
|
||||||
|
tone: 'muted' | 'accent'
|
||||||
|
}) {
|
||||||
|
const still = ep.still_path ? getTmdbImageUrl(ep.still_path, 'w500') : ''
|
||||||
|
const labelCls = tone === 'accent' ? 'text-accent' : 'text-text-3'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {/* could navigate to episode if we had a Jellyfin id */}}
|
||||||
|
initial={{ opacity: 0, y: 6 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
className="group flex gap-4 p-2.5 rounded-lg hover:bg-glass-light transition-colors text-left focus-ring"
|
||||||
|
>
|
||||||
|
<div className="relative w-[180px] aspect-video rounded-md overflow-hidden bg-elevated shrink-0 ring-1 ring-border">
|
||||||
|
{still ? (
|
||||||
|
<img
|
||||||
|
src={still}
|
||||||
|
alt={ep.name}
|
||||||
|
loading="lazy"
|
||||||
|
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-[1.04]"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full grid place-items-center text-text-4">
|
||||||
|
<span className="font-display text-2xl font-bold opacity-50">
|
||||||
|
E{ep.episode_number}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/55 via-black/0 to-transparent" />
|
||||||
|
{tone === 'accent' && (
|
||||||
|
<div className="absolute inset-0 ring-1 ring-accent/40 rounded-md" />
|
||||||
|
)}
|
||||||
|
<div className="absolute bottom-1.5 left-2 text-[10px] font-mono font-bold text-white/95 tabular-nums tracking-wider">
|
||||||
|
S{ep.season_number} · E{ep.episode_number}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0 py-0.5">
|
||||||
|
<p className={`text-[10px] uppercase tracking-[0.16em] font-semibold ${labelCls} mb-1.5 leading-none`}>
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
<p className="text-[14.5px] text-text-1 font-semibold tracking-tight leading-tight line-clamp-2">
|
||||||
|
{ep.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11.5px] text-text-3 mt-2 flex items-center gap-1.5 tabular-nums">
|
||||||
|
<Calendar size={10} className="text-text-4" />
|
||||||
|
{ep.air_date ? formatDate(ep.air_date) : 'TBA'}
|
||||||
|
{ep.air_date && (
|
||||||
|
<>
|
||||||
|
<span className="text-text-5">·</span>
|
||||||
|
<span className={tone === 'accent' ? 'text-accent' : ''}>
|
||||||
|
{formatRelative(ep.air_date)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{ep.runtime && (
|
||||||
|
<p className="text-[11px] text-text-4 mt-1 tabular-nums">{ep.runtime}m</p>
|
||||||
|
)}
|
||||||
|
{tone === 'accent' && (
|
||||||
|
<p className="text-[11px] text-accent font-medium mt-2 inline-flex items-center gap-1">
|
||||||
|
<Play size={10} fill="currentColor" />
|
||||||
|
Premieres soon
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
Film,
|
||||||
|
Activity,
|
||||||
|
Bolt,
|
||||||
|
HardDrive,
|
||||||
|
Boxes,
|
||||||
|
Volume2,
|
||||||
|
Subtitles,
|
||||||
|
CalendarPlus,
|
||||||
|
} from '../../lib/icons'
|
||||||
|
import type { BaseItemDto } from '../../api/types'
|
||||||
|
import {
|
||||||
|
pickPrimarySource,
|
||||||
|
getVideoStream,
|
||||||
|
getAudioStreams,
|
||||||
|
getSubtitleStreams,
|
||||||
|
resolutionLabel,
|
||||||
|
videoCodecLabel,
|
||||||
|
framerateLabel,
|
||||||
|
videoRangeLabel,
|
||||||
|
audioFormatLabel,
|
||||||
|
subtitleLabel,
|
||||||
|
} from '../../lib/jellyfin-meta'
|
||||||
|
import { formatBitrate, formatBytes, formatDate, languageLabel } from '../../lib/format'
|
||||||
|
import StatTile, { type StatTileData } from '../ui/StatTile'
|
||||||
|
import MediaTechIcons from '../ui/MediaTechIcons'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: BaseItemDto
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasTechSpecs(item: BaseItemDto): boolean {
|
||||||
|
const source = pickPrimarySource(item)
|
||||||
|
const video = getVideoStream(item)
|
||||||
|
return !!(source || video)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TechSpecs({ item }: Props) {
|
||||||
|
const source = pickPrimarySource(item)
|
||||||
|
const video = getVideoStream(item)
|
||||||
|
const audio = getAudioStreams(item)
|
||||||
|
const subs = getSubtitleStreams(item)
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
if (!source && !video) return null
|
||||||
|
|
||||||
|
const tiles: StatTileData[] = []
|
||||||
|
const res = resolutionLabel(item)
|
||||||
|
const range = videoRangeLabel(item)
|
||||||
|
if (res) {
|
||||||
|
tiles.push({
|
||||||
|
icon: Film,
|
||||||
|
label: 'Resolution',
|
||||||
|
value: range ? `${res} · ${range}` : res,
|
||||||
|
tone: range ? 'amber' : 'default',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (video) {
|
||||||
|
const codec = videoCodecLabel(video)
|
||||||
|
if (codec) tiles.push({ icon: Film, label: 'Codec', value: codec })
|
||||||
|
const fps = framerateLabel(video)
|
||||||
|
if (fps) tiles.push({ icon: Activity, label: 'Frame rate', value: fps })
|
||||||
|
if (video.BitRate) {
|
||||||
|
tiles.push({
|
||||||
|
icon: Bolt,
|
||||||
|
label: 'Video bitrate',
|
||||||
|
value: formatBitrate(video.BitRate),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (audio[0]) {
|
||||||
|
const a = audioFormatLabel(audio[0])
|
||||||
|
if (a) tiles.push({ icon: Volume2, label: 'Audio', value: a })
|
||||||
|
}
|
||||||
|
if (subs.length) {
|
||||||
|
tiles.push({
|
||||||
|
icon: Subtitles,
|
||||||
|
label: 'Subtitles',
|
||||||
|
value: `${subs.length} track${subs.length === 1 ? '' : 's'}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (source?.Container) {
|
||||||
|
tiles.push({ icon: Boxes, label: 'Container', value: source.Container.toUpperCase() })
|
||||||
|
}
|
||||||
|
if (source?.Bitrate) {
|
||||||
|
tiles.push({ icon: Bolt, label: 'Total bitrate', value: formatBitrate(source.Bitrate) })
|
||||||
|
}
|
||||||
|
if (source?.Size) {
|
||||||
|
tiles.push({ icon: HardDrive, label: 'File size', value: formatBytes(source.Size) })
|
||||||
|
}
|
||||||
|
if (item.DateCreated) {
|
||||||
|
tiles.push({
|
||||||
|
icon: CalendarPlus,
|
||||||
|
label: 'Added',
|
||||||
|
value: formatDate(item.DateCreated),
|
||||||
|
tone: 'amber',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Top: tech badge strip */}
|
||||||
|
<MediaTechIcons item={item} />
|
||||||
|
|
||||||
|
{/* Tile grid */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
|
||||||
|
{tiles.map(t => (
|
||||||
|
<StatTile key={t.label} tile={t} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Per-stream details */}
|
||||||
|
<div className="rounded-lg border border-border overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(o => !o)}
|
||||||
|
className="w-full flex items-center justify-between px-4 py-2.5 text-[11px] font-semibold uppercase tracking-[0.14em] text-text-3 hover:text-text-1 hover:bg-glass-light transition-colors focus-ring"
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="w-1 h-3.5 rounded-full bg-accent/60" />
|
||||||
|
Per-stream details
|
||||||
|
<span className="text-text-4 lowercase tracking-normal font-normal text-[11px]">
|
||||||
|
({1 + audio.length + subs.length} stream{1 + audio.length + subs.length === 1 ? '' : 's'})
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
size={13}
|
||||||
|
className={`transition-transform duration-200 ${open ? 'rotate-180' : ''}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{open && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.28, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
className="overflow-hidden border-t border-border"
|
||||||
|
>
|
||||||
|
<div className="p-3 space-y-2">
|
||||||
|
{video && (
|
||||||
|
<StreamRow
|
||||||
|
icon={Film}
|
||||||
|
type="Video"
|
||||||
|
primary={[
|
||||||
|
videoCodecLabel(video),
|
||||||
|
video.Width && video.Height ? `${video.Width}×${video.Height}` : '',
|
||||||
|
framerateLabel(video),
|
||||||
|
].filter(Boolean).join(' · ')}
|
||||||
|
secondary={[
|
||||||
|
video.BitRate ? formatBitrate(video.BitRate) : '',
|
||||||
|
video.BitDepth ? `${video.BitDepth}-bit` : '',
|
||||||
|
video.PixelFormat,
|
||||||
|
video.ColorSpace?.toUpperCase(),
|
||||||
|
].filter(Boolean).join(' · ')}
|
||||||
|
flags={[
|
||||||
|
range && { label: range, tone: 'amber' as const },
|
||||||
|
].filter((x): x is { label: string; tone: 'amber' } => !!x)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{audio.map((a, i) => (
|
||||||
|
<StreamRow
|
||||||
|
key={`a-${i}`}
|
||||||
|
icon={Volume2}
|
||||||
|
type={`Audio ${audio.length > 1 ? i + 1 : ''}`.trim()}
|
||||||
|
primary={audioFormatLabel(a) || 'Unknown'}
|
||||||
|
secondary={[
|
||||||
|
languageLabel(a.Language),
|
||||||
|
a.BitRate ? formatBitrate(a.BitRate) : '',
|
||||||
|
a.SampleRate ? `${(a.SampleRate / 1000).toFixed(1)} kHz` : '',
|
||||||
|
].filter(Boolean).join(' · ')}
|
||||||
|
flags={[
|
||||||
|
a.IsDefault && { label: 'Default', tone: 'accent' as const },
|
||||||
|
].filter((x): x is { label: string; tone: 'accent' } => !!x)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{subs.map((s, i) => {
|
||||||
|
const flags: { label: string; tone: 'cool' | 'accent' | 'success' | 'muted' }[] = []
|
||||||
|
if (s.IsDefault) flags.push({ label: 'Default', tone: 'accent' })
|
||||||
|
if (s.IsForced) flags.push({ label: 'Forced', tone: 'cool' })
|
||||||
|
if (s.IsHearingImpaired) flags.push({ label: 'SDH', tone: 'success' })
|
||||||
|
if (s.IsExternal) flags.push({ label: 'External', tone: 'muted' })
|
||||||
|
return (
|
||||||
|
<StreamRow
|
||||||
|
key={`s-${i}`}
|
||||||
|
icon={Subtitles}
|
||||||
|
type={`Subtitle ${subs.length > 1 ? i + 1 : ''}`.trim()}
|
||||||
|
primary={subtitleLabel(s).split(' (')[0] || 'Unknown'}
|
||||||
|
secondary={[
|
||||||
|
s.Codec?.toUpperCase(),
|
||||||
|
s.Title && s.Title !== s.DisplayTitle ? s.Title : '',
|
||||||
|
].filter(Boolean).join(' · ')}
|
||||||
|
flags={flags}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StreamRow({
|
||||||
|
icon: Icon,
|
||||||
|
type,
|
||||||
|
primary,
|
||||||
|
secondary,
|
||||||
|
flags,
|
||||||
|
}: {
|
||||||
|
icon: typeof Film
|
||||||
|
type: string
|
||||||
|
primary: string
|
||||||
|
secondary?: string
|
||||||
|
flags?: { label: string; tone: 'amber' | 'cool' | 'accent' | 'success' | 'muted' }[]
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-3 p-3 rounded-md bg-elevated/30 border border-border/60">
|
||||||
|
<span className="shrink-0 w-8 h-8 rounded-md grid place-items-center bg-glass-light text-text-2 ring-1 ring-border">
|
||||||
|
<Icon size={14} stroke={1.75} />
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap mb-1">
|
||||||
|
<span className="text-[10px] uppercase tracking-[0.14em] font-semibold text-text-3 leading-none">
|
||||||
|
{type}
|
||||||
|
</span>
|
||||||
|
{flags?.map(f => (
|
||||||
|
<Flag key={f.label} label={f.label} tone={f.tone} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-[13px] text-text-1 font-semibold tracking-tight truncate">
|
||||||
|
{primary}
|
||||||
|
</p>
|
||||||
|
{secondary && (
|
||||||
|
<p className="text-[11.5px] text-text-3 mt-0.5 tabular-nums truncate font-mono">
|
||||||
|
{secondary}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Flag({
|
||||||
|
label,
|
||||||
|
tone,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
tone: 'amber' | 'cool' | 'accent' | 'success' | 'muted'
|
||||||
|
}) {
|
||||||
|
const cls =
|
||||||
|
tone === 'amber' ? 'bg-amber-500/15 text-amber-200 ring-amber-500/25'
|
||||||
|
: tone === 'cool' ? 'bg-cool/15 text-cool ring-cool/25'
|
||||||
|
: tone === 'accent' ? 'bg-accent/15 text-accent ring-accent/25'
|
||||||
|
: tone === 'success' ? 'bg-success/15 text-success ring-success/25'
|
||||||
|
: 'bg-glass-light text-text-2 ring-border'
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center h-4 px-1.5 rounded text-[9px] font-bold uppercase tracking-wider ring-1 leading-none ${cls}`}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { readTimeSaved, formatTimeSaved } from '../../lib/time-saved'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
seriesId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Small chip surfacing the time auto-skip has saved on this series.
|
||||||
|
* Renders nothing when the total is below 60 seconds so we don't badge
|
||||||
|
* shows where the user has only seen one episode.
|
||||||
|
*/
|
||||||
|
export default function TimeSavedBadge({ seriesId }: Props) {
|
||||||
|
const [entry, setEntry] = useState(() => readTimeSaved(seriesId))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEntry(readTimeSaved(seriesId))
|
||||||
|
// Re-check when the user comes back to the tab; auto-skip happens in
|
||||||
|
// a different page, so we want the badge to update on focus.
|
||||||
|
const onFocus = () => setEntry(readTimeSaved(seriesId))
|
||||||
|
window.addEventListener('focus', onFocus)
|
||||||
|
return () => window.removeEventListener('focus', onFocus)
|
||||||
|
}, [seriesId])
|
||||||
|
|
||||||
|
const total = entry.intro + entry.credits
|
||||||
|
if (total < 60) return null
|
||||||
|
|
||||||
|
const introLabel = entry.intro > 0 ? formatTimeSaved(entry.intro) : null
|
||||||
|
const creditsLabel = entry.credits > 0 ? formatTimeSaved(entry.credits) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1.5 h-7 px-2.5 rounded-md bg-accent/8 ring-1 ring-accent/20 text-[11.5px] text-accent font-medium tracking-tight"
|
||||||
|
title={[
|
||||||
|
introLabel ? `Intros: ${introLabel}` : null,
|
||||||
|
creditsLabel ? `Credits: ${creditsLabel}` : null,
|
||||||
|
].filter(Boolean).join(' · ')}
|
||||||
|
>
|
||||||
|
<svg className="w-3 h-3" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path d="M10 2a8 8 0 100 16 8 8 0 000-16zm.75 4.25a.75.75 0 00-1.5 0v4c0 .2.08.39.22.53l2.5 2.5a.75.75 0 101.06-1.06l-2.28-2.28V6.25z" />
|
||||||
|
</svg>
|
||||||
|
Auto-skip saved {formatTimeSaved(total)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { HardDrive } from '../../lib/icons'
|
||||||
|
import type { BaseItemDto } from '../../api/types'
|
||||||
|
import { summarizeSource } from '../../lib/jellyfin-meta'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: BaseItemDto
|
||||||
|
selectedSourceId: string | null
|
||||||
|
onChange: (sourceId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VersionsSelector({ item, selectedSourceId, onChange }: Props) {
|
||||||
|
const sources = (item.MediaSources || []) as any[]
|
||||||
|
if (sources.length <= 1) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-[10px] font-semibold text-white/55 uppercase tracking-[0.14em] flex items-center gap-1.5">
|
||||||
|
<HardDrive size={10} />
|
||||||
|
Available versions
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{sources.map(src => {
|
||||||
|
const summary = summarizeSource(src)
|
||||||
|
const id = src.Id || src.id || ''
|
||||||
|
const active = id === selectedSourceId
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
onClick={() => onChange(id)}
|
||||||
|
className={`relative h-auto min-h-9 px-3 py-1.5 text-left rounded-md border transition-all duration-150 focus-ring ${
|
||||||
|
active
|
||||||
|
? 'bg-accent/15 border-accent/40 text-accent'
|
||||||
|
: 'bg-white/5 border-white/10 text-white/80 hover:bg-white/10 hover:border-white/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{active && (
|
||||||
|
<motion.span
|
||||||
|
layoutId="version-active"
|
||||||
|
className="absolute inset-0 rounded-md ring-1 ring-accent/50"
|
||||||
|
transition={{ type: 'spring', stiffness: 380, damping: 30 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="relative block text-[12px] font-semibold tracking-tight">
|
||||||
|
{summary.label || src.Name || 'Version'}
|
||||||
|
</span>
|
||||||
|
{summary.detail && (
|
||||||
|
<span className="relative block text-[10px] text-white/55 mt-0.5">{summary.detail}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { Star, RefreshCw, CalendarStar } from '../../lib/icons'
|
||||||
|
import { useDiary } from '../../stores/diary-store'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
itemId?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Letterboxd-style watch timeline. Reads every diary entry for the
|
||||||
|
* current item from the diary store, sorts oldest-first so the timeline
|
||||||
|
* reads chronologically left-to-right, and renders each as a card with
|
||||||
|
* date / rating / note / rewatch indicator.
|
||||||
|
*
|
||||||
|
* Hides itself when there's no diary history - cold-start users
|
||||||
|
* shouldn't see an empty section staring back at them.
|
||||||
|
*/
|
||||||
|
export default function WatchTimeline({ itemId }: Props) {
|
||||||
|
const entries = useDiary(s => s.entries)
|
||||||
|
const ordered = useMemo(() => {
|
||||||
|
if (!itemId) return []
|
||||||
|
return entries
|
||||||
|
.filter(e => e.itemId === itemId)
|
||||||
|
.sort((a, b) => a.watchedAt.localeCompare(b.watchedAt))
|
||||||
|
}, [entries, itemId])
|
||||||
|
|
||||||
|
if (ordered.length === 0) return null
|
||||||
|
|
||||||
|
const rewatches = ordered.filter(e => e.rewatch).length
|
||||||
|
const firstDate = ordered[0].watchedAt
|
||||||
|
const lastDate = ordered[ordered.length - 1].watchedAt
|
||||||
|
const spanLabel = ordered.length === 1
|
||||||
|
? `Watched once - ${formatLong(firstDate)}`
|
||||||
|
: rewatches > 0
|
||||||
|
? `${ordered.length} watches over ${spanText(firstDate, lastDate)} - ${rewatches} ${rewatches === 1 ? 'rewatch' : 'rewatches'}`
|
||||||
|
: `${ordered.length} watches over ${spanText(firstDate, lastDate)}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<span className="w-7 h-7 grid place-items-center rounded-full bg-accent/15 ring-1 ring-accent/30">
|
||||||
|
<CalendarStar size={13} className="text-accent" />
|
||||||
|
</span>
|
||||||
|
<p className="text-[12.5px] text-text-2 tracking-tight">{spanLabel}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative -mx-1 overflow-x-auto hide-scrollbar">
|
||||||
|
<ol className="flex items-stretch gap-2 px-1 min-w-max">
|
||||||
|
{ordered.map((entry, i) => (
|
||||||
|
<motion.li
|
||||||
|
key={entry.id}
|
||||||
|
initial={{ opacity: 0, y: 6 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: Math.min(i * 0.04, 0.4) }}
|
||||||
|
className="relative w-[200px] shrink-0 rounded-xl bg-elevated/30 ring-1 ring-border hover:ring-border-strong transition-colors p-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
|
<span className="text-[10.5px] uppercase tracking-[0.14em] font-semibold text-text-3 tabular-nums">
|
||||||
|
{formatShort(entry.watchedAt)}
|
||||||
|
</span>
|
||||||
|
{entry.rewatch && (
|
||||||
|
<span
|
||||||
|
title="Rewatch"
|
||||||
|
className="inline-flex items-center gap-0.5 h-4 px-1 rounded-full bg-cool/15 text-cool text-[9.5px] font-semibold uppercase tracking-[0.1em]"
|
||||||
|
>
|
||||||
|
<RefreshCw size={9} stroke={2.4} />
|
||||||
|
Rewatch
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 mb-1.5">
|
||||||
|
{entry.emoji && <span className="text-[18px] leading-none">{entry.emoji}</span>}
|
||||||
|
{typeof entry.rating === 'number' && entry.rating > 0 && (
|
||||||
|
<span className="inline-flex items-center gap-0.5 text-accent tabular-nums text-[13px] font-display font-bold">
|
||||||
|
<Star size={12} fill="currentColor" stroke={0} />
|
||||||
|
{entry.rating}
|
||||||
|
<span className="text-text-4 text-[10.5px] font-normal font-sans ml-0.5">/10</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{entry.note ? (
|
||||||
|
<p className="text-[11.5px] text-text-2 leading-snug line-clamp-3">
|
||||||
|
{entry.note}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-[11px] text-text-5 italic">No note</p>
|
||||||
|
)}
|
||||||
|
{i < ordered.length - 1 && (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="absolute top-1/2 -right-2 w-2 h-px bg-border"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</motion.li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatShort(iso: string): string {
|
||||||
|
const d = new Date(iso)
|
||||||
|
if (Number.isNaN(d.getTime())) return ''
|
||||||
|
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLong(iso: string): string {
|
||||||
|
const d = new Date(iso)
|
||||||
|
if (Number.isNaN(d.getTime())) return ''
|
||||||
|
return d.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function spanText(firstIso: string, lastIso: string): string {
|
||||||
|
const a = new Date(firstIso).getTime()
|
||||||
|
const b = new Date(lastIso).getTime()
|
||||||
|
const days = Math.max(0, Math.round((b - a) / 86_400_000))
|
||||||
|
if (days === 0) return 'a single day'
|
||||||
|
if (days < 7) return `${days} ${days === 1 ? 'day' : 'days'}`
|
||||||
|
if (days < 60) {
|
||||||
|
const weeks = Math.round(days / 7)
|
||||||
|
return `${weeks} ${weeks === 1 ? 'week' : 'weeks'}`
|
||||||
|
}
|
||||||
|
if (days < 365) {
|
||||||
|
const months = Math.round(days / 30)
|
||||||
|
return `${months} ${months === 1 ? 'month' : 'months'}`
|
||||||
|
}
|
||||||
|
const years = (days / 365).toFixed(1).replace(/\.0$/, '')
|
||||||
|
return `${years} ${years === '1' ? 'year' : 'years'}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { ExternalLink, Globe } from '../../lib/icons'
|
||||||
|
import Select, { type SelectOption } from '../ui/Select'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import type { TmdbWatchProviders } from '../../api/tmdb'
|
||||||
|
import { getTmdbImageUrl } from '../../api/tmdb'
|
||||||
|
import { countryLabel } from '../../lib/format'
|
||||||
|
import { SectionLabel } from '../ui/SectionLabel'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
providers?: TmdbWatchProviders | null
|
||||||
|
defaultRegion: string
|
||||||
|
onRegionChange?: (region: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMMON_REGIONS = ['US', 'GB', 'CA', 'AU', 'DE', 'FR', 'ES', 'IT', 'NL', 'JP', 'BR', 'IN', 'MX']
|
||||||
|
|
||||||
|
export default function WhereToWatch({ providers, defaultRegion, onRegionChange }: Props) {
|
||||||
|
const [region, setRegion] = useState(defaultRegion)
|
||||||
|
|
||||||
|
const all = providers?.results || {}
|
||||||
|
const regionData = all[region]
|
||||||
|
|
||||||
|
const availableRegions = Object.keys(all).sort()
|
||||||
|
|
||||||
|
if (!regionData && !availableRegions.length) return null
|
||||||
|
|
||||||
|
function changeRegion(next: string) {
|
||||||
|
setRegion(next)
|
||||||
|
onRegionChange?.(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section>
|
||||||
|
<div className="flex items-center justify-between mb-3 gap-3 flex-wrap">
|
||||||
|
<SectionLabel>Where to watch</SectionLabel>
|
||||||
|
<Select
|
||||||
|
size="sm"
|
||||||
|
ariaLabel="Region"
|
||||||
|
triggerIcon={<Globe size={11} stroke={2} />}
|
||||||
|
value={region}
|
||||||
|
onChange={changeRegion}
|
||||||
|
width="min-w-[160px]"
|
||||||
|
options={
|
||||||
|
[...new Set([region, ...COMMON_REGIONS, ...availableRegions])].map<SelectOption<string>>(r => ({
|
||||||
|
value: r,
|
||||||
|
label: countryLabel(r) || r,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!regionData ? (
|
||||||
|
<p className="text-[12px] text-text-4">
|
||||||
|
Not available for streaming in {countryLabel(region) || region}.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{regionData.flatrate && (
|
||||||
|
<ProviderRow label="Stream" link={regionData.link} providers={regionData.flatrate} />
|
||||||
|
)}
|
||||||
|
{regionData.free && (
|
||||||
|
<ProviderRow label="Free" link={regionData.link} providers={regionData.free} />
|
||||||
|
)}
|
||||||
|
{regionData.ads && (
|
||||||
|
<ProviderRow label="With ads" link={regionData.link} providers={regionData.ads} />
|
||||||
|
)}
|
||||||
|
{regionData.rent && (
|
||||||
|
<ProviderRow label="Rent" link={regionData.link} providers={regionData.rent} />
|
||||||
|
)}
|
||||||
|
{regionData.buy && (
|
||||||
|
<ProviderRow label="Buy" link={regionData.link} providers={regionData.buy} />
|
||||||
|
)}
|
||||||
|
{regionData.link && (
|
||||||
|
<a
|
||||||
|
href={regionData.link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-[11px] text-text-4 hover:text-accent transition-colors"
|
||||||
|
>
|
||||||
|
View all options on TMDB <ExternalLink size={10} />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProviderRow({
|
||||||
|
label,
|
||||||
|
link,
|
||||||
|
providers,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
link: string
|
||||||
|
providers: { provider_id: number; provider_name: string; logo_path: string | null }[]
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-[0.12em] text-text-3 w-16 shrink-0">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{providers.map((p, i) => (
|
||||||
|
<motion.a
|
||||||
|
key={p.provider_id}
|
||||||
|
href={link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
initial={{ opacity: 0, y: 4 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: i * 0.04 }}
|
||||||
|
whileHover={{ y: -2 }}
|
||||||
|
className="group relative shrink-0"
|
||||||
|
title={p.provider_name}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 rounded-lg bg-accent/0 group-hover:bg-accent/15 blur-md transition-all duration-200" />
|
||||||
|
<div className="relative w-10 h-10 rounded-lg overflow-hidden ring-1 ring-border group-hover:ring-accent/40 transition-all duration-150">
|
||||||
|
{p.logo_path ? (
|
||||||
|
<img
|
||||||
|
src={getTmdbImageUrl(p.logo_path, 'w92')}
|
||||||
|
alt={p.provider_name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full grid place-items-center bg-elevated text-text-3 text-[10px]">
|
||||||
|
{p.provider_name[0]}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ children }: { children: React.ReactNode }) {
|
||||||
|
return <section className="mb-9">{children}</section>
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user