shared ui: poster cards, content rows, scrollers, lazy mount

This commit is contained in:
2026-03-27 02:58:46 +02:00
parent b0c2e48224
commit 02f0f58ec9
31 changed files with 4434 additions and 0 deletions
+329
View File
@@ -0,0 +1,329 @@
import { useEffect, useState } from 'react'
import { NavLink, useLocation, useNavigate } from 'react-router-dom'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { motion, AnimatePresence } from 'framer-motion'
import {
Search,
ArrowLeft,
Pin,
PinFilled,
Minus,
Square,
RestoreDown,
X,
Bell,
} from '../../lib/icons'
import { isTauri } from '../../lib/tauri'
import { jellyfinClient, getActivityLogApi } from '../../api/jellyfin'
/**
* The single chrome bar at the top of the in-app shell. Replaces the
* previous trio of (1) Tauri titlebar with brand + window controls, (2)
* sidebar brand row, and (3) TopBar with back button + page title +
* search. One bar covers all of them.
*
* The whole thing is a Tauri drag region, so the user can grab any empty
* stretch (logo, title, padding) to move the window. Buttons opt out
* automatically since they sit on top.
*/
const TOP_LEVEL_PATHS = new Set([
'/',
'/movies',
'/shows',
'/playlists',
'/music',
'/search',
'/discover',
'/settings',
])
function pageTitleFor(pathname: string): string {
if (pathname === '/') return 'Home'
if (pathname === '/movies') return 'Movies'
if (pathname === '/shows') return 'TV Shows'
if (pathname === '/playlists') return 'Playlists'
if (pathname === '/music') return 'Music'
if (pathname === '/search') return 'Search'
if (pathname === '/discover') return 'Discover'
if (pathname === '/settings') return 'Settings'
return ''
}
interface Props {
pinned: boolean
onTogglePin: () => void
}
export default function AppHeader({ pinned, onTogglePin }: Props) {
const navigate = useNavigate()
const location = useLocation()
const [scrolled, setScrolled] = useState(false)
const [maximized, setMaximized] = useState(false)
const [notifsOpen, setNotifsOpen] = useState(false)
const [notifs, setNotifs] = useState<any[]>([])
// Poll activity log for the notification bell
useEffect(() => {
const api = jellyfinClient.getApi()
if (!api) return
async function poll() {
try {
const res = await getActivityLogApi(api!).getLogEntries({ limit: 8 })
setNotifs(res.data.Items || [])
} catch { /* ignore */ }
}
poll()
const id = setInterval(poll, 60_000)
return () => clearInterval(id)
}, [])
// Subtle background fade in when the main pane is scrolled - same UX
// affordance the old TopBar had, just on the unified header.
useEffect(() => {
const main = document.querySelector('main.content-scroll') as HTMLElement | null
if (!main) {
setScrolled(false)
return
}
const onScroll = () => setScrolled(main.scrollTop > 4)
main.addEventListener('scroll', onScroll, { passive: true })
onScroll()
return () => main.removeEventListener('scroll', onScroll)
}, [location.pathname])
// Ctrl+K opens search
useEffect(() => {
function onKey(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault()
navigate('/search')
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [navigate])
// Track maximize state so the maximize icon flips to a restore icon when
// the window is full-screen. Only meaningful in Tauri.
useEffect(() => {
if (!isTauri) return
let cancelled = false
let unlisten: (() => void) | undefined
const win = getCurrentWindow()
win.isMaximized().then(v => {
if (!cancelled) setMaximized(v)
}).catch(() => {})
win.onResized(async () => {
const v = await win.isMaximized()
if (!cancelled) setMaximized(v)
}).then(u => {
if (cancelled) u()
else unlisten = u
}).catch(() => {})
return () => {
cancelled = true
unlisten?.()
}
}, [])
function callWin(action: 'minimize' | 'toggleMaximize' | 'close') {
if (!isTauri) return
const win = getCurrentWindow()
if (action === 'minimize') win.minimize()
else if (action === 'toggleMaximize') win.toggleMaximize()
else win.close()
}
const showBack = !TOP_LEVEL_PATHS.has(location.pathname)
const title = pageTitleFor(location.pathname)
return (
<header
data-tauri-drag-region
className={`app-header shrink-0 h-11 flex items-stretch z-sticky border-b transition-colors duration-300 ease-out ${
scrolled
? 'bg-glass-strong backdrop-blur-xl border-border'
: 'bg-void/95 border-border/60'
}`}
>
{/* ── Left: brand + pin ──────────────────────────────────── */}
<div data-tauri-drag-region className="flex items-center gap-2 pl-3 pr-2">
<NavLink
to="/"
aria-label="Home"
className="group flex items-center gap-2.5 focus-ring rounded-md px-1 py-1 -mx-1 hover:bg-glass-light transition-colors"
>
<div className="relative w-6 h-6 shrink-0">
<div className="absolute inset-0 rounded-md bg-gradient-to-br from-accent to-accent-press" />
<div className="absolute inset-0 rounded-md bg-accent-glow blur-[6px] opacity-50" />
<div className="relative w-full h-full rounded-md grid place-items-center overflow-hidden">
<span
aria-hidden
className="block w-[64%] h-[64%] bg-void"
style={{
WebkitMaskImage: 'url(/icon.svg)',
maskImage: 'url(/icon.svg)',
WebkitMaskRepeat: 'no-repeat',
maskRepeat: 'no-repeat',
WebkitMaskPosition: 'center',
maskPosition: 'center',
WebkitMaskSize: 'contain',
maskSize: 'contain',
}}
/>
</div>
</div>
<span className="hidden sm:flex flex-col leading-none">
<span className="text-[11.5px] font-bold tracking-[0.18em] text-text-1 font-display">
JELLYFIN
</span>
<span className="text-[8.5px] uppercase tracking-[0.2em] font-semibold text-text-4 mt-0.5">
Media client
</span>
</span>
</NavLink>
<button
type="button"
onClick={onTogglePin}
aria-label={pinned ? 'Unpin sidebar' : 'Pin sidebar open'}
aria-pressed={pinned}
title={pinned ? 'Unpin sidebar' : 'Pin sidebar open'}
className={`w-7 h-7 grid place-items-center rounded-md transition-colors duration-150 focus-ring ${
pinned
? 'text-accent bg-accent-dim hover:bg-accent/20'
: 'text-text-3 hover:text-text-1 hover:bg-glass-light'
}`}
>
{pinned ? <PinFilled size={13} /> : <Pin size={13} stroke={2} />}
</button>
</div>
{/* ── Middle: back button + page title (drag region) ─────── */}
<div data-tauri-drag-region className="flex-1 min-w-0 flex items-center gap-2 px-3">
{showBack && (
<button
onClick={() => navigate(-1)}
className="w-7 h-7 grid place-items-center rounded-md text-text-3 hover:text-text-1 hover:bg-glass-light transition-colors duration-150 focus-ring shrink-0"
aria-label="Back"
>
<ArrowLeft size={15} stroke={2} />
</button>
)}
<AnimatePresence mode="wait">
{title && (
<motion.h1
data-tauri-drag-region
key={title}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.22, ease: [0.16, 1, 0.3, 1] }}
className="text-[13px] font-semibold text-text-1 tracking-tight truncate"
>
{title}
</motion.h1>
)}
</AnimatePresence>
</div>
{/* ── Right: search + window controls ─────────────────────── */}
<div className="flex items-stretch">
<button
onClick={() => navigate('/search')}
className="group self-center mr-2 flex items-center gap-2 h-7 pl-2 pr-1.5 rounded-md bg-elevated/50 hover:bg-elevated border border-border hover:border-border-hover transition-all duration-200 ease-out text-text-3 hover:text-text-2 focus-ring"
aria-label="Search"
>
<Search size={12} strokeWidth={2} className="text-text-3 group-hover:text-accent transition-colors duration-200" />
<span className="text-[11px] hidden md:inline">Search anything</span>
<kbd className="hidden md:inline-flex items-center gap-0.5 ml-1 px-1.5 h-4 rounded text-[9.5px] font-mono text-text-4 bg-void/60 border border-border">
<span className="text-[8px]">^</span>K
</kbd>
</button>
{/* Notification bell */}
<div className="relative self-center mr-2">
<button
onClick={() => setNotifsOpen(o => !o)}
className="relative w-7 h-7 grid place-items-center rounded-md text-text-3 hover:text-text-1 hover:bg-elevated transition-colors focus-ring"
aria-label="Notifications"
>
<Bell size={13} stroke={2} />
{notifs.length > 0 && (
<span className="absolute top-0.5 right-0.5 w-1.5 h-1.5 rounded-full bg-accent" />
)}
</button>
<AnimatePresence>
{notifsOpen && (
<motion.div
initial={{ opacity: 0, y: 6, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 6, scale: 0.98 }}
transition={{ duration: 0.18 }}
className="absolute right-0 top-9 w-72 rounded-xl bg-glass-strong backdrop-blur-2xl border border-white/10 shadow-[0_20px_60px_-15px_rgba(0,0,0,0.85)] z-modal overflow-hidden"
>
<div className="px-3 py-2.5 border-b border-white/8">
<p className="text-[11px] font-semibold text-text-1 tracking-tight">Recent activity</p>
</div>
<div className="max-h-64 overflow-y-auto content-scroll">
{notifs.length === 0 ? (
<p className="px-3 py-4 text-[11.5px] text-text-3 text-center">No recent activity</p>
) : (
notifs.map((entry: any, i: number) => (
<div
key={entry.Id || i}
className="px-3 py-2 text-[11.5px] border-b border-white/5 last:border-0 hover:bg-white/4 transition-colors"
>
<p className="text-text-2 truncate">{entry.Name}</p>
<p className="text-text-4 tabular-nums mt-0.5">
{entry.DateCreated ? new Date(entry.DateCreated).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : ''}
</p>
</div>
))
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
{isTauri && (
<>
<WinButton onClick={() => callWin('minimize')} aria-label="Minimize">
<Minus size={14} stroke={2} />
</WinButton>
<WinButton
onClick={() => callWin('toggleMaximize')}
aria-label={maximized ? 'Restore' : 'Maximize'}
>
{maximized ? <RestoreDown size={12} stroke={2} /> : <Square size={12} stroke={2} />}
</WinButton>
<WinButton onClick={() => callWin('close')} aria-label="Close" variant="close">
<X size={14} stroke={2.25} />
</WinButton>
</>
)}
</div>
</header>
)
}
function WinButton({
children,
variant,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: 'close' }) {
return (
<button
{...props}
className={`w-11 h-full grid place-items-center text-text-3 transition-colors duration-100 focus-ring ${
variant === 'close'
? 'hover:bg-red-500 hover:text-white'
: 'hover:bg-elevated hover:text-text-1'
}`}
>
{children}
</button>
)
}
+419
View File
@@ -0,0 +1,419 @@
import { Suspense, useState, type ComponentType } from 'react'
import { NavLink, Outlet } from 'react-router-dom'
import { motion, LayoutGroup, AnimatePresence } from 'framer-motion'
import * as Tooltip from '@radix-ui/react-tooltip'
import {
Home,
Film,
Tv,
Search,
Compass,
Settings,
LogOut,
Playlists,
User,
Database,
Activity,
Radio,
Download,
} from '../../lib/icons'
import type { AuthState } from '../../api/types'
import AppHeader from './AppHeader'
import MiniPlayer from '../player/MiniPlayer'
import NowPlaying from '../player/NowPlaying'
import OfflineBanner from '../ui/OfflineBanner'
import QuickLookModal from '../ui/QuickLookModal'
import YoutubeViewerModal from '../ui/YoutubeViewerModal'
import RequestModal from '../request/RequestModal'
import ToastHost from '../ui/ToastHost'
import { useRequestModal } from '../../stores/request-modal-store'
import {
useSidebarStore,
SIDEBAR_COLLAPSED_W,
SIDEBAR_DEFAULT_W,
SIDEBAR_MIN_W,
SIDEBAR_MAX_W,
} from '../../stores/sidebar-store'
interface NavItem {
to: string
icon: ComponentType<{ size?: number; stroke?: number; className?: string }>
label: string
kbd?: string
}
interface NavSection {
label: string
items: NavItem[]
}
const NAV_SECTIONS: NavSection[] = [
{
label: 'Library',
items: [
{ to: '/', icon: Home, label: 'Home' },
{ to: '/movies', icon: Film, label: 'Movies' },
{ to: '/shows', icon: Tv, label: 'Shows' },
{ to: '/playlists', icon: Playlists, label: 'Playlists' },
{ to: '/live', icon: Radio, label: 'Live TV' },
{ to: '/requests', icon: Database, label: 'Requests' },
{ to: '/downloads', icon: Download, label: 'Downloads' },
],
},
{
label: 'Discover',
items: [
{ to: '/discover', icon: Compass, label: 'Discover' },
{ to: '/search', icon: Search, label: 'Search', kbd: 'Ctrl K' },
],
},
{
label: 'Account',
items: [
{ to: '/profile', icon: User, label: 'Profile' },
{ to: '/stats', icon: Activity, label: 'Stats' },
{ to: '/settings', icon: Settings, label: 'Settings' },
],
},
]
interface Props {
auth: AuthState
onLogout: () => void
}
export default function AppShell({ auth, onLogout }: Props) {
const pinned = useSidebarStore(s => s.pinned)
const togglePinned = useSidebarStore(s => s.togglePinned)
const pinnedWidth = useSidebarStore(s => s.pinnedWidth)
const setPinnedWidth = useSidebarStore(s => s.setPinnedWidth)
const resetPinnedWidth = useSidebarStore(s => s.resetPinnedWidth)
const [hovered, setHovered] = useState(false)
const [resizing, setResizing] = useState(false)
const [nowPlayingOpen, setNowPlayingOpen] = useState(false)
const expanded = pinned || hovered
// When pinned the user controls the width; hover-expanded uses the default
const targetWidth = !expanded
? SIDEBAR_COLLAPSED_W
: pinned
? pinnedWidth
: SIDEBAR_DEFAULT_W
function startResize(e: React.PointerEvent<HTMLDivElement>) {
if (!pinned) return
e.preventDefault()
setResizing(true)
const startX = e.clientX
const startW = pinnedWidth
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
function onMove(ev: PointerEvent) {
const next = Math.max(SIDEBAR_MIN_W, Math.min(SIDEBAR_MAX_W, startW + (ev.clientX - startX)))
setPinnedWidth(next)
}
function onUp() {
setResizing(false)
document.body.style.cursor = ''
document.body.style.userSelect = ''
document.removeEventListener('pointermove', onMove)
document.removeEventListener('pointerup', onUp)
}
document.addEventListener('pointermove', onMove)
document.addEventListener('pointerup', onUp)
}
function resetWidth() {
if (!pinned) return
resetPinnedWidth()
}
return (
<Tooltip.Provider delayDuration={400} skipDelayDuration={150}>
<div className="h-full w-full flex flex-col bg-void text-text-1">
{/* Single chrome bar at the top: brand + pin + back/title + search +
window controls. Replaces the old sidebar brand row + TopBar +
Tauri-only Titlebar. */}
<AppHeader pinned={pinned} onTogglePin={togglePinned} />
<div className="flex-1 flex overflow-hidden min-h-0">
<motion.aside
className="app-sidebar h-full glass border-r border-border flex flex-col shrink-0 relative z-sidebar"
initial={false}
animate={{ width: targetWidth }}
transition={resizing ? { duration: 0 } : { duration: 0.32, ease: [0.16, 1, 0.3, 1] }}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<LayoutGroup id="sidebar-nav">
<nav className="flex-1 py-3 flex flex-col gap-3 overflow-y-auto hide-scrollbar">
{NAV_SECTIONS.map(section => (
<Section key={section.label} section={section} expanded={expanded} />
))}
<div className="mt-auto px-3">
<SidebarItem
expanded={expanded}
item={{ to: '#logout', icon: LogOut, label: 'Sign out' }}
asButton
onClick={onLogout}
/>
</div>
</nav>
</LayoutGroup>
<UserCard auth={auth} expanded={expanded} />
{pinned && (
<div
role="separator"
aria-orientation="vertical"
aria-label="Resize sidebar"
onPointerDown={startResize}
onDoubleClick={resetWidth}
className={`group/resize absolute top-0 right-0 h-full w-2 -mr-1 cursor-col-resize z-10 ${
resizing ? 'pointer-events-auto' : ''
}`}
title="Drag to resize · Double-click to reset"
>
<span
className={`absolute right-1 top-0 bottom-0 w-px transition-all duration-150 ${
resizing
? 'bg-accent shadow-[0_0_8px_rgba(245,182,66,0.6)]'
: 'bg-transparent group-hover/resize:bg-accent/60'
}`}
/>
</div>
)}
</motion.aside>
{/* Main pane */}
<div className="flex-1 flex flex-col min-w-0 relative">
<OfflineBanner />
<main className="flex-1 overflow-y-auto content-scroll relative">
<Suspense fallback={<RouteFallback />}>
<Outlet />
</Suspense>
</main>
<MiniPlayer onExpand={() => setNowPlayingOpen(true)} />
</div>
</div>
<NowPlaying isOpen={nowPlayingOpen} onClose={() => setNowPlayingOpen(false)} />
<QuickLookModal />
<YoutubeViewerModal />
<RequestModalMount />
<ToastHost />
</div>
</Tooltip.Provider>
)
}
function RequestModalMount() {
const open = useRequestModal(s => s.open)
const tmdbId = useRequestModal(s => s.tmdbId)
const kind = useRequestModal(s => s.kind)
const tmdbData = useRequestModal(s => s.tmdbData)
const close = useRequestModal(s => s.close)
if (!tmdbId) return null
return (
<RequestModal
open={open}
onClose={close}
tmdbId={tmdbId}
kind={kind}
tmdbData={tmdbData}
/>
)
}
function RouteFallback() {
return (
<div className="h-full grid place-items-center">
<div className="flex items-center gap-2 text-text-3 text-[12.5px] font-medium">
<span className="w-1 h-1 rounded-full bg-accent animate-pulse" />
Loading
</div>
</div>
)
}
/* ──────────────────────────────────────────────────────────── */
/* Section + Item */
/* ──────────────────────────────────────────────────────────── */
function Section({ section, expanded }: { section: NavSection; expanded: boolean }) {
return (
<div className="px-3">
<AnimatePresence>
{expanded && (
<motion.p
initial={{ opacity: 0, y: -2 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -2 }}
transition={{ duration: 0.18 }}
className="text-[10px] uppercase tracking-[0.18em] font-semibold text-text-4 px-2.5 mb-1.5 leading-none"
>
{section.label}
</motion.p>
)}
</AnimatePresence>
<div className="flex flex-col gap-0.5">
{section.items.map(item => (
<SidebarItem key={item.to} item={item} expanded={expanded} />
))}
</div>
</div>
)
}
interface SidebarItemProps {
item: NavItem
expanded: boolean
asButton?: boolean
onClick?: () => void
}
function SidebarItem({ item, expanded, asButton = false, onClick }: SidebarItemProps) {
const inner = (isActive: boolean) => (
<>
{/* Left active rail */}
{isActive && (
<motion.span
layoutId="sidebar-active-rail"
className="absolute left-0 top-1.5 bottom-1.5 w-[3px] rounded-r-full bg-accent shadow-[0_0_8px_rgba(245,182,66,0.6)]"
transition={{ type: 'spring', stiffness: 380, damping: 32 }}
/>
)}
<span
className={`relative w-9 h-10 grid place-items-center shrink-0 transition-colors duration-150 ${
isActive ? 'text-accent' : 'text-text-3 group-hover:text-text-1'
}`}
>
<item.icon size={17} stroke={isActive ? 2.2 : 1.75} />
</span>
<AnimatePresence>
{expanded && (
<motion.span
initial={{ opacity: 0, x: -4 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -4 }}
transition={{ duration: 0.18 }}
className={`relative flex-1 text-[13px] font-medium tracking-tight whitespace-nowrap ${
isActive ? 'text-accent' : 'text-text-2 group-hover:text-text-1'
}`}
>
{item.label}
</motion.span>
)}
</AnimatePresence>
<AnimatePresence>
{expanded && item.kbd && (
<motion.kbd
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
className="relative shrink-0 mr-2.5 px-1.5 h-5 inline-flex items-center rounded text-[10px] font-mono text-text-4 bg-void/60 border border-border"
>
{item.kbd}
</motion.kbd>
)}
</AnimatePresence>
</>
)
const baseCls =
'group relative flex items-center h-10 rounded-lg w-full focus-ring overflow-hidden'
const node = asButton ? (
<button
type="button"
onClick={onClick}
className={`${baseCls} text-text-3 hover:text-text-1`}
>
{inner(false)}
</button>
) : (
<NavLink
to={item.to}
end={item.to === '/'}
className={({ isActive }) =>
`${baseCls} ${isActive ? 'bg-glass-light' : 'hover:bg-glass-light'}`
}
>
{({ isActive }) => inner(isActive)}
</NavLink>
)
// Wrap in tooltip - only renders when collapsed
if (expanded) return node
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>{node}</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
side="right"
sideOffset={10}
className="z-toast inline-flex items-center gap-2 px-2.5 h-7 rounded-md bg-glass-strong backdrop-blur-xl border border-border-hover text-[12px] text-text-1 font-medium shadow-lg select-none"
>
{item.label}
{item.kbd && (
<kbd className="inline-flex items-center px-1.5 h-4 rounded text-[10px] font-mono text-text-4 bg-void/60 border border-border">
{item.kbd}
</kbd>
)}
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
)
}
/* ──────────────────────────────────────────────────────────── */
/* User card */
/* ──────────────────────────────────────────────────────────── */
function UserCard({ auth, expanded }: { auth: AuthState; expanded: boolean }) {
const initial = (auth.userName || 'U')[0].toUpperCase()
const serverHost = (() => {
try {
return auth.serverUrl ? new URL(auth.serverUrl).host : ''
} catch { return '' }
})()
return (
<div className="px-3 py-3 border-t border-border shrink-0">
<div className="group flex items-center h-11 rounded-lg px-1.5 hover:bg-glass-light transition-colors cursor-default min-w-0">
<div className="relative shrink-0">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-accent/30 via-higher to-elevated grid place-items-center text-text-1 text-[12px] font-bold ring-1 ring-border font-display">
{initial}
</div>
<span className="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full bg-success ring-2 ring-surface" aria-label="Online" />
</div>
<AnimatePresence>
{expanded && (
<motion.div
initial={{ opacity: 0, x: -4 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -4 }}
transition={{ duration: 0.18 }}
className="ml-2.5 min-w-0 flex-1 leading-tight"
>
<p className="text-[12.5px] text-text-1 font-semibold truncate tracking-tight">
{auth.userName || 'User'}
</p>
{serverHost && (
<p className="text-[10px] text-text-4 truncate tabular-nums">{serverHost}</p>
)}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
)
}
+88
View File
@@ -0,0 +1,88 @@
import { useEffect, useState } from 'react'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { Minus, Square, RestoreDown, X } from '../../lib/icons'
/**
* Custom titlebar for the Tauri desktop shell. With `decorations: false` on
* the window, the OS chrome is gone - this bar provides the dragging region
* (`data-tauri-drag-region`) and the min / max / close affordances.
*
* Themed to match the rest of the app: 36px tall, void-tone background with
* a subtle bottom border, hairline accent on hover for the buttons, classic
* red on the close hover. Title shown center-left.
*/
export default function Titlebar() {
const [maximized, setMaximized] = useState(false)
const win = getCurrentWindow()
useEffect(() => {
let cancelled = false
win.isMaximized().then(v => {
if (!cancelled) setMaximized(v)
})
const unlistenP = win.onResized(async () => {
const v = await win.isMaximized()
if (!cancelled) setMaximized(v)
})
return () => {
cancelled = true
unlistenP.then(u => u())
}
}, [win])
return (
<div
data-tauri-drag-region
className="app-titlebar shrink-0 h-9 flex items-center justify-between bg-void/95 border-b border-border select-none"
style={{ ['WebkitUserSelect' as any]: 'none' }}
>
<div data-tauri-drag-region className="flex items-center gap-2 pl-3">
<span
data-tauri-drag-region
className="w-4 h-4 rounded-md bg-gradient-to-br from-accent to-accent-press grid place-items-center text-void font-bold text-[9px]"
>
j
</span>
<span
data-tauri-drag-region
className="text-[11.5px] font-medium tracking-tight text-text-2"
>
Jellyfin
</span>
</div>
<div className="flex items-stretch h-full">
<TitlebarButton onClick={() => win.minimize()} aria-label="Minimize">
<Minus size={14} stroke={2} />
</TitlebarButton>
<TitlebarButton onClick={() => win.toggleMaximize()} aria-label={maximized ? 'Restore' : 'Maximize'}>
{maximized ? <RestoreDown size={12} stroke={2} /> : <Square size={12} stroke={2} />}
</TitlebarButton>
<TitlebarButton onClick={() => win.close()} aria-label="Close" variant="close">
<X size={14} stroke={2.25} />
</TitlebarButton>
</div>
</div>
)
}
function TitlebarButton({
children,
onClick,
variant,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: 'close' }) {
return (
<button
{...props}
onClick={onClick}
className={`w-11 h-full grid place-items-center text-text-3 transition-colors duration-100 focus-ring ${
variant === 'close'
? 'hover:bg-red-500 hover:text-white'
: 'hover:bg-elevated hover:text-text-1'
}`}
>
{children}
</button>
)
}
+56
View File
@@ -0,0 +1,56 @@
import { Check, Clock, RefreshCw, AlertCircle } from '../../lib/icons'
import { useItemAvailability } from '../../hooks/use-availability'
interface Props {
tmdbId: string | number | null | undefined
/** Compact icon-only mode for poster overlays. */
variant?: 'pill' | 'icon'
}
const TONE: Record<string, { label: string; color: string }> = {
available: { label: 'Available', color: 'bg-success/20 text-success ring-success/30' },
partial: { label: 'Partial', color: 'bg-amber-500/20 text-amber-200 ring-amber-400/30' },
processing: { label: 'Downloading', color: 'bg-blue-500/20 text-blue-200 ring-blue-400/30' },
pending: { label: 'Pending', color: 'bg-purple-500/20 text-purple-200 ring-purple-400/30' },
requested: { label: 'Requested', color: 'bg-elevated/80 text-text-2 ring-border' },
}
/**
* Renders a small status chip ("Available", "Downloading", "Pending"
* etc.) sourced from `useItemAvailability`. Returns null when the item
* isn't tracked anywhere - rendering "missing" as a chip would just be
* noise on every card.
*/
export default function AvailabilityChip({ tmdbId, variant = 'pill' }: Props) {
const record = useItemAvailability(tmdbId)
if (!record || record.status === 'missing') return null
const tone = TONE[record.status]
if (!tone) return null
if (variant === 'icon') {
const Icon =
record.status === 'available' ? Check
: record.status === 'processing' ? RefreshCw
: record.status === 'pending' ? Clock
: AlertCircle
return (
<span
className={`inline-flex items-center justify-center w-5 h-5 rounded-full ring-1 ${tone.color}`}
title={tone.label}
>
<Icon size={10} stroke={2.25} />
</span>
)
}
return (
<span
className={`inline-flex items-center gap-1 h-[18px] px-1.5 rounded text-[9.5px] font-bold uppercase tracking-[0.06em] ring-1 backdrop-blur shadow-sm ${tone.color} ${
record.status === 'processing' ? 'animate-pulse' : ''
}`}
title={tone.label}
>
{tone.label}
</span>
)
}
+119
View File
@@ -0,0 +1,119 @@
import { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { X, Check, Heart } from '../../lib/icons'
import { useLibrarySelection } from '../../stores/library-selection-store'
import { useBulkMarkPlayed, useBulkToggleFavorite, useRefreshItem } from '../../hooks/use-jellyfin'
import { useWatchlist } from '../../hooks/use-watchlist'
/**
* Floating toolbar shown over the bottom of the page when at least one
* library item is multi-selected. Surfaces bulk actions (mark watched,
* unwatched, favorite, add to watchlist) with optimistic UI - mutations
* fire all at once and the React Query invalidation refreshes the
* underlying query.
*/
export default function BatchActionsBar() {
const selected = useLibrarySelection(s => s.selected)
const clear = useLibrarySelection(s => s.clear)
const markPlayed = useBulkMarkPlayed()
const toggleFav = useBulkToggleFavorite()
const refreshItem = useRefreshItem()
const watchlist = useWatchlist()
const [busy, setBusy] = useState(false)
const ids = Array.from(selected)
const count = ids.length
if (count === 0) return null
async function run(fn: () => Promise<unknown>) {
if (busy) return
setBusy(true)
try {
await fn()
clear()
} finally {
setBusy(false)
}
}
return (
<AnimatePresence>
{count > 0 && (
<motion.div
initial={{ y: 60, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 60, 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-[70] flex items-center gap-1 px-3 py-2 rounded-full bg-surface/95 backdrop-blur-xl ring-1 ring-border-strong shadow-[0_20px_60px_-15px_rgba(0,0,0,0.85)]"
>
<span className="text-[12px] text-text-2 font-medium tabular-nums px-2 mr-1">
{count} selected
</span>
<Action
label="Mark watched"
disabled={busy}
icon={<Check size={13} stroke={2.5} />}
onClick={() => run(() => markPlayed.mutateAsync({ itemIds: ids, played: true }))}
/>
<Action
label="Mark unwatched"
disabled={busy}
onClick={() => run(() => markPlayed.mutateAsync({ itemIds: ids, played: false }))}
/>
<Action
label="Favorite"
disabled={busy}
icon={<Heart size={13} stroke={2} />}
onClick={() => run(() => toggleFav.mutateAsync({ itemIds: ids, favorite: true }))}
/>
<Action
label="Add to watchlist"
disabled={busy || !watchlist.playlistId}
onClick={() => run(async () => {
for (const id of ids) await watchlist.addToWatchlist(id)
})}
/>
<Action
label="Refresh metadata"
disabled={busy}
onClick={() => run(async () => {
for (const id of ids) {
await refreshItem.mutateAsync({ itemId: id })
}
})}
/>
<button
onClick={clear}
aria-label="Clear selection"
className="ml-1 w-8 h-8 grid place-items-center rounded-full text-text-3 hover:text-text-1 hover:bg-elevated transition focus-ring"
>
<X size={13} stroke={2} />
</button>
</motion.div>
)}
</AnimatePresence>
)
}
function Action({
label,
icon,
disabled,
onClick,
}: {
label: string
icon?: React.ReactNode
disabled?: boolean
onClick: () => void
}) {
return (
<button
onClick={onClick}
disabled={disabled}
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-full text-[12px] font-medium tracking-tight text-text-2 hover:text-text-1 hover:bg-elevated transition disabled:opacity-40 focus-ring"
>
{icon}
{label}
</button>
)
}
+52
View File
@@ -0,0 +1,52 @@
import { useLogoTone } from '../../lib/logo-tone'
interface Props {
src: string
alt: string
/** Inner image height in px - the pad sizes around it */
height?: number
/** Max image width in px to prevent absurdly wide logos from dominating */
maxWidth?: number
className?: string
}
/**
* Renders a remote logo (TMDB network / production company) on a small pad
* whose color is chosen by sampling the logo's luminance:
* - dark-content logos sit on a near-white pad
* - light-content logos sit on a near-black pad
* - unknown / unloaded uses the dark-content default
*/
export default function BrandLogo({
src,
alt,
height = 14,
maxWidth = 80,
className = '',
}: Props) {
const tone = useLogoTone(src)
const padCls =
tone === 'light'
? 'bg-zinc-950/95 ring-white/15 shadow-md'
: 'bg-white/95 ring-black/10 shadow-sm'
// Vertical padding scales subtly with image height
const vPad = Math.max(4, Math.round(height * 0.4))
const hPad = Math.max(8, Math.round(height * 0.8))
return (
<span
className={`inline-flex items-center justify-center rounded-md ring-1 ${padCls} ${className}`}
style={{ paddingTop: vPad, paddingBottom: vPad, paddingLeft: hPad, paddingRight: hPad }}
title={alt}
>
<img
src={src}
alt={alt}
loading="lazy"
className="object-contain block"
style={{ height, maxWidth }}
/>
</span>
)
}
+45
View File
@@ -0,0 +1,45 @@
import { useMemo } from 'react'
import { useTmdbDiscoverMovies, useTmdbDiscoverTv } from '../../hooks/use-tmdb'
import { useLibraryByTmdbId } from '../../hooks/use-jellyfin'
import { mapTmdbToJf } from '../../lib/tmdb-mapping'
import ContentRow from './ContentRow'
interface Props {
/** TMDB company id (for movie studios) or network id (for TV networks). */
brandId: number
label: string
subtitle?: string
/** Decides which discover endpoint to hit. */
kind: 'movie' | 'tv'
}
/**
* One row sourced from TMDB discover with a single `with_companies` /
* `with_networks` filter. Sorted by popularity descending so the row
* leads with the brand's most recognisable titles.
*/
export default function BrandRow({ brandId, label, subtitle, kind }: Props) {
const movieParams = kind === 'movie' ? { with_companies: String(brandId), sort_by: 'popularity.desc' } : ({} as Record<string, string>)
const tvParams = kind === 'tv' ? { with_networks: String(brandId), sort_by: 'popularity.desc' } : ({} as Record<string, string>)
const movieDiscover = useTmdbDiscoverMovies(movieParams)
const tvDiscover = useTmdbDiscoverTv(tvParams)
const libraryByTmdbId = useLibraryByTmdbId()
const items = useMemo(() => {
const raw = kind === 'movie'
? (movieDiscover.data?.results || []).map(m => ({ ...m, media_type: 'movie' }))
: (tvDiscover.data?.results || []).map(m => ({ ...m, media_type: 'tv' }))
return mapTmdbToJf(raw.slice(0, 18), libraryByTmdbId.data)
}, [kind, movieDiscover.data, tvDiscover.data, libraryByTmdbId.data])
if (items.length === 0) return null
return (
<ContentRow
title={label}
subtitle={subtitle}
items={items}
layoutKey={`brand_${kind}_${brandId}`}
/>
)
}
+92
View File
@@ -0,0 +1,92 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useCanonListResolved } from '../../hooks/use-canon-list'
import { useLibraryByTmdbId } from '../../hooks/use-jellyfin'
import { mapTmdbToJf } from '../../lib/tmdb-mapping'
import type { CanonList } from '../../lib/canon-lists'
import ContentRow from './ContentRow'
interface Props {
list: CanonList
}
/**
* Renders one bundled canon list as a row. Lazy-mounts so we don't fire
* 20+ TMDB requests on home page load - each row only resolves once
* scrolled near the viewport. The user's library is cross-referenced
* so in-library entries get the accent ring naturally and missing
* entries are visible as the row's main story.
*/
export default function CanonListRow({ list }: Props) {
const containerRef = useRef<HTMLDivElement>(null)
const [near, setNear] = useState(false)
useEffect(() => {
if (!containerRef.current) return
const el = containerRef.current
const obs = new IntersectionObserver(
entries => {
for (const e of entries) {
if (e.isIntersecting) {
setNear(true)
obs.disconnect()
return
}
}
},
{ rootMargin: '200px' },
)
obs.observe(el)
return () => obs.disconnect()
}, [])
return (
<div ref={containerRef}>
{near ? <CanonListRowMounted list={list} /> : <RowPlaceholder list={list} />}
</div>
)
}
// Cap the resolved list at 12 entries so each canon row triggers at
// most ~12 TMDB lookups on first scroll-into-view. Anyone curious about
// the rest can follow the source link in the row subtitle.
const MAX_CANON_RESOLVES = 12
function CanonListRowMounted({ list }: Props) {
const ids = useMemo(
() => list.tmdbMovieIds.slice(0, MAX_CANON_RESOLVES),
[list.tmdbMovieIds],
)
const { items: tmdbItems } = useCanonListResolved(ids)
const libraryByTmdbId = useLibraryByTmdbId()
const items = useMemo(
() => mapTmdbToJf(tmdbItems, libraryByTmdbId.data),
[tmdbItems, libraryByTmdbId.data],
)
if (items.length === 0) return <RowPlaceholder list={list} />
return (
<ContentRow
title={list.title}
subtitle={list.subtitle}
items={items}
layoutKey={`canon_${list.id}`}
/>
)
}
function RowPlaceholder({ list }: Props) {
return (
<section className="mb-10">
<div className="px-7 mb-3.5">
<h2 className="text-[18px] font-semibold text-text-1 tracking-tight">{list.title}</h2>
<p className="text-[12px] text-text-3 mt-0.5">{list.subtitle}</p>
</div>
<div className="px-7 flex gap-3">
{Array.from({ length: 8 }).map((_, j) => (
<div key={j} className="shrink-0 w-[160px]">
<div className="skeleton aspect-[2/3] rounded-lg" />
</div>
))}
</div>
</section>
)
}
+79
View File
@@ -0,0 +1,79 @@
import { forwardRef, type ButtonHTMLAttributes, type HTMLAttributes, type ReactNode } from 'react'
type ChipTone = 'neutral' | 'accent' | 'cool' | 'success' | 'warning' | 'error' | 'glow' | 'outline'
type ChipSize = 'xs' | 'sm' | 'md'
const toneClass: Record<ChipTone, string> = {
neutral: 'bg-white/8 text-text-1 border-white/8 hover:bg-white/12',
accent: 'bg-accent/15 text-accent border-accent/25 hover:bg-accent/20',
cool: 'bg-cool/12 text-cool border-cool/25',
success: 'bg-success/12 text-success border-success/25',
warning: 'bg-warning/12 text-warning border-warning/25',
error: 'bg-error/12 text-error border-error/25',
glow:
'bg-gradient-to-r from-accent/20 to-cool/15 text-text-1 border-accent/30 shadow-[0_0_12px_rgba(245,182,66,0.25)]',
outline: 'bg-transparent text-text-2 border-border hover:border-border-hover hover:text-text-1',
}
const sizeClass: Record<ChipSize, string> = {
xs: 'h-5 px-1.5 text-[10px] gap-1 rounded',
sm: 'h-6 px-2 text-[11px] gap-1.5 rounded-md',
md: 'h-7 px-2.5 text-[12px] gap-1.5 rounded-md',
}
interface ChipBase {
tone?: ChipTone
size?: ChipSize
icon?: ReactNode
trailing?: ReactNode
active?: boolean
}
export type ChipProps = ChipBase & HTMLAttributes<HTMLSpanElement> & { as?: 'span' }
export type ChipButtonProps = ChipBase & ButtonHTMLAttributes<HTMLButtonElement> & { as: 'button' }
export const Chip = forwardRef<HTMLSpanElement | HTMLButtonElement, ChipProps | ChipButtonProps>(
function Chip(props, ref) {
const {
tone = 'neutral',
size = 'sm',
icon,
trailing,
active,
className = '',
children,
as = 'span',
...rest
} = props as ChipBase & { as?: 'span' | 'button' } & Record<string, any>
const baseCls =
`inline-flex items-center font-medium tracking-tight border whitespace-nowrap select-none transition-colors duration-150 ${
sizeClass[size]
} ${toneClass[active ? 'accent' : tone]} ${className}`
if (as === 'button') {
return (
<button
{...(rest as ButtonHTMLAttributes<HTMLButtonElement>)}
ref={ref as React.Ref<HTMLButtonElement>}
className={`${baseCls} cursor-pointer focus-ring`}
>
{icon}
{children}
{trailing}
</button>
)
}
return (
<span
{...(rest as HTMLAttributes<HTMLSpanElement>)}
ref={ref as React.Ref<HTMLSpanElement>}
className={baseCls}
>
{icon}
{children}
{trailing}
</span>
)
},
)
+308
View File
@@ -0,0 +1,308 @@
import { useState, useEffect } from 'react'
import useEmblaCarousel from 'embla-carousel-react'
import { ChevronLeft, ChevronRight, ArrowRight, Library, List, ListDetails } from '../../lib/icons'
import { motion, AnimatePresence } from 'framer-motion'
import type { BaseItemDto } from '../../api/types'
import PosterCard from './PosterCard'
import { useNavigate } from 'react-router-dom'
import { getBestImage, getStoredServerUrl } from '../../api/jellyfin'
import { useQuickLookStore } from '../../stores/quick-look-store'
import { formatRuntime } from '../../lib/format'
type Layout = 'poster' | 'backdrop' | 'list'
interface Props {
title: string
subtitle?: string
items: BaseItemDto[]
aspect?: 'poster' | 'landscape' | 'square'
loading?: boolean
seeAllHref?: string
/** Persist a non-default layout under this key. Defaults to title. */
layoutKey?: string
}
function readSavedLayout(key: string, fallback: Layout): Layout {
if (typeof window === 'undefined') return fallback
const v = localStorage.getItem(`row_layout:${key}`)
if (v === 'poster' || v === 'backdrop' || v === 'list') return v
return fallback
}
function SkeletonCards({
count = 8,
aspect = 'poster',
width,
}: {
count?: number
aspect?: 'poster' | 'landscape' | 'square'
width: string
}) {
const aspectClass = aspect === 'poster' ? 'aspect-[2/3]' : aspect === 'square' ? 'aspect-square' : 'aspect-video'
return (
<>
{Array.from({ length: count }).map((_, i) => (
<div key={i} className={`shrink-0 ${width}`}>
<div className={`skeleton ${aspectClass} rounded-lg`} />
<div className="mt-2 space-y-1.5">
<div className="skeleton h-3 w-3/4 rounded" />
<div className="skeleton h-2.5 w-1/2 rounded" />
</div>
</div>
))}
</>
)
}
export default function ContentRow({ title, subtitle, items, aspect = 'poster', loading, seeAllHref, layoutKey }: Props) {
// Map the legacy `aspect` prop onto the new `layout` model. Landscape
// rows default to 'backdrop' so existing call sites keep their look.
const defaultLayout: Layout = aspect === 'landscape' ? 'backdrop' : 'poster'
const persistKey = layoutKey || title
const [layout, setLayoutState] = useState<Layout>(() => readSavedLayout(persistKey, defaultLayout))
const setLayout = (next: Layout) => {
setLayoutState(next)
try { localStorage.setItem(`row_layout:${persistKey}`, next) } catch { /* noop */ }
}
const effectiveAspect: 'poster' | 'landscape' | 'square' =
layout === 'backdrop' ? 'landscape' : layout === 'poster' ? (aspect === 'square' ? 'square' : 'poster') : 'poster'
const [ref, embla] = useEmblaCarousel({
align: 'start',
containScroll: 'trimSnaps',
dragFree: true,
})
const [canScrollPrev, setCanScrollPrev] = useState(false)
const [canScrollNext, setCanScrollNext] = useState(false)
const navigate = useNavigate()
useEffect(() => {
if (!embla) return
const update = () => {
setCanScrollPrev(embla.canScrollPrev())
setCanScrollNext(embla.canScrollNext())
}
embla.on('select', update)
embla.on('reInit', update)
embla.on('scroll', update)
update()
return () => {
embla.off('select', update)
embla.off('reInit', update)
embla.off('scroll', update)
}
}, [embla])
const widthClass =
effectiveAspect === 'poster' ? 'w-[160px]'
: effectiveAspect === 'square' ? 'w-[180px]'
: 'w-[280px]'
const showHeader = !!title || !!subtitle || !!seeAllHref
return (
<section className="mb-10">
{/* Row header */}
{showHeader && (
<div className="flex items-end justify-between mb-3.5 px-7 gap-3">
<div className="min-w-0">
{title && <h2 className="text-[18px] font-semibold text-text-1 tracking-tight">{title}</h2>}
{subtitle && (
<p className="text-[12px] text-text-3 mt-0.5">{subtitle}</p>
)}
</div>
<div className="flex items-center gap-3 shrink-0">
<LayoutSwitcher value={layout} onChange={setLayout} />
{seeAllHref && (
<button
onClick={() => navigate(seeAllHref)}
className="group flex items-center gap-1 text-[12px] text-text-3 hover:text-accent transition-colors duration-200 px-2 -mr-2 py-1 focus-ring rounded"
>
See all
<ArrowRight
size={12}
className="transition-transform duration-200 group-hover:translate-x-0.5"
/>
</button>
)}
</div>
</div>
)}
{/* List layout - vertical stack, no carousel */}
{layout === 'list' && !loading && (
<div className="px-7 space-y-2">
{items.slice(0, 12).map((item, i) => (
<ListRowCard key={item.Id} item={item} index={i} />
))}
{items.length > 12 && (
<button
onClick={() => seeAllHref && navigate(seeAllHref)}
disabled={!seeAllHref}
className="w-full text-[12px] text-text-3 hover:text-accent py-2 transition disabled:opacity-50"
>
{seeAllHref ? `Show all ${items.length}` : `+${items.length - 12} more`}
</button>
)}
</div>
)}
{layout !== 'list' && (
<>
{/* Carousel with edge fades + nav arrows */}
<div className="relative group/row">
{/* Edge fade left */}
<div
className={`absolute left-0 top-0 bottom-0 w-12 z-10 pointer-events-none bg-gradient-to-r from-void via-void/60 to-transparent transition-opacity duration-300 ${
canScrollPrev ? 'opacity-100' : 'opacity-0'
}`}
/>
{/* Edge fade right */}
<div
className={`absolute right-0 top-0 bottom-0 w-12 z-10 pointer-events-none bg-gradient-to-l from-void via-void/60 to-transparent transition-opacity duration-300 ${
canScrollNext ? 'opacity-100' : 'opacity-0'
}`}
/>
{/* Prev */}
<AnimatePresence>
{canScrollPrev && (
<motion.button
initial={{ opacity: 0, x: 4 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 4 }}
transition={{ duration: 0.18 }}
onClick={() => embla?.scrollPrev()}
className="absolute left-2 top-0 bottom-12 w-12 z-20 flex items-center justify-center opacity-0 group-hover/row:opacity-100 transition-opacity duration-200 focus:opacity-100"
aria-label="Previous"
>
<div className="w-9 h-9 rounded-full glass-strong border border-border-hover grid place-items-center text-text-2 hover:text-accent shadow-md hover:scale-105 active:scale-95 transition-all duration-200">
<ChevronLeft size={16} strokeWidth={2.25} />
</div>
</motion.button>
)}
</AnimatePresence>
{/* Next */}
<AnimatePresence>
{canScrollNext && (
<motion.button
initial={{ opacity: 0, x: -4 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -4 }}
transition={{ duration: 0.18 }}
onClick={() => embla?.scrollNext()}
className="absolute right-2 top-0 bottom-12 w-12 z-20 flex items-center justify-center opacity-0 group-hover/row:opacity-100 transition-opacity duration-200 focus:opacity-100"
aria-label="Next"
>
<div className="w-9 h-9 rounded-full glass-strong border border-border-hover grid place-items-center text-text-2 hover:text-accent shadow-md hover:scale-105 active:scale-95 transition-all duration-200">
<ChevronRight size={16} strokeWidth={2.25} />
</div>
</motion.button>
)}
</AnimatePresence>
{/* Track */}
<div ref={ref} className="overflow-hidden">
<div className="flex gap-3 px-7 py-1">
{loading ? (
<SkeletonCards aspect={aspect} width={widthClass} />
) : (
items.map((item, i) => (
<motion.div
key={item.Id}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.4,
delay: Math.min(i * 0.025, 0.4),
ease: [0.16, 1, 0.3, 1],
}}
className={`shrink-0 ${widthClass}`}
>
<PosterCard
item={item}
aspect={effectiveAspect}
priority={i < 6}
onClick={() => item.Id && navigate(`/item/${item.Id}`)}
/>
</motion.div>
))
)}
</div>
</div>
</div>
</>
)}
</section>
)
}
function LayoutSwitcher({ value, onChange }: { value: Layout; onChange: (l: Layout) => void }) {
const opts: Array<{ k: Layout; label: string; Icon: typeof List }> = [
{ k: 'poster', label: 'Posters', Icon: ListDetails },
{ k: 'backdrop', label: 'Backdrops', Icon: Library },
{ k: 'list', label: 'List', Icon: List },
]
return (
<div className="hidden md:flex items-center gap-0.5 bg-elevated/40 ring-1 ring-border rounded-md p-0.5">
{opts.map(o => {
const on = value === o.k
return (
<button
key={o.k}
onClick={() => onChange(o.k)}
aria-label={o.label}
title={o.label}
className={`w-7 h-7 grid place-items-center rounded transition focus-ring ${
on ? 'bg-accent/15 text-accent ring-1 ring-accent/30' : 'text-text-3 hover:text-text-1'
}`}
>
<o.Icon size={13} stroke={2} />
</button>
)
})}
</div>
)
}
function ListRowCard({ item, index }: { item: BaseItemDto; index: number }) {
const navigate = useNavigate()
const openQuickLook = useQuickLookStore(s => s.open)
const serverUrl = getStoredServerUrl()
const thumb =
getBestImage(serverUrl, item, 'thumb', 240) ||
getBestImage(serverUrl, item, 'primary', 200) ||
(item as any)._tmdbPoster ||
null
const subtitle = [
item.ProductionYear,
item.RunTimeTicks ? formatRuntime(item.RunTimeTicks) : '',
(item.Genres || []).slice(0, 2).join(', '),
].filter(Boolean).join(' · ')
return (
<motion.button
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: Math.min(index * 0.02, 0.25) }}
onClick={() => item.Id && navigate(`/item/${item.Id}`)}
onContextMenu={e => { e.preventDefault(); openQuickLook(item) }}
className="w-full flex items-center gap-3 p-2 rounded-lg bg-elevated/40 ring-1 ring-border hover:ring-border-strong hover:bg-elevated/70 transition text-left focus-ring"
>
<div className="shrink-0 w-[88px] aspect-video rounded bg-black overflow-hidden ring-1 ring-border">
{thumb && <img src={thumb} alt="" className="w-full h-full object-cover" loading="lazy" />}
</div>
<div className="min-w-0 flex-1">
<p className="text-[13px] font-medium text-text-1 truncate tracking-tight">
{item.Name || 'Untitled'}
</p>
{subtitle && (
<p className="text-[11px] text-text-3 truncate mt-0.5">{subtitle}</p>
)}
{item.Overview && (
<p className="text-[11px] text-text-4 truncate mt-0.5 hidden sm:block">{item.Overview}</p>
)}
</div>
</motion.button>
)
}
+53
View File
@@ -0,0 +1,53 @@
import { Component, type ReactNode } from 'react'
import { AlertCircle, RotateCw } from '../../lib/icons'
interface Props {
children: ReactNode
fallback?: ReactNode
}
interface State {
hasError: boolean
error: Error | null
}
export default class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false, error: null }
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
render() {
if (this.state.hasError) {
if (this.props.fallback) return this.props.fallback
return (
<div className="flex flex-col items-center justify-center h-full min-h-[400px] p-10 text-center">
<div className="relative w-14 h-14 mb-5">
<div className="absolute inset-0 rounded-full bg-error/10 blur-xl" />
<div className="relative w-full h-full rounded-full bg-error/10 grid place-items-center ring-1 ring-error/20">
<AlertCircle size={22} className="text-error" />
</div>
</div>
<h2 className="text-text-1 text-[17px] font-semibold mb-1.5 tracking-tight">Something went wrong</h2>
<p className="text-text-3 text-[13px] mb-5 max-w-md leading-relaxed">
{this.state.error?.message || 'An unexpected error occurred while rendering this view.'}
</p>
<button
onClick={() => this.setState({ hasError: false, error: null })}
className="h-10 px-5 bg-elevated hover:bg-higher border border-border hover:border-border-hover text-text-1 rounded-lg text-[13px] font-medium transition-all duration-200 ease-out flex items-center gap-2 focus-ring"
>
<RotateCw size={14} />
Try again
</button>
</div>
)
}
return this.props.children
}
}
+122
View File
@@ -0,0 +1,122 @@
import { useEffect, useState, type ReactNode } from 'react'
import useEmblaCarousel from 'embla-carousel-react'
import { motion, AnimatePresence } from 'framer-motion'
import { ChevronLeft, ChevronRight } from '../../lib/icons'
interface Props {
children: ReactNode
/** Padding-x applied to the inner track. Matches whatever sits above
* it in the page (typically `px-7`). */
trackPadding?: string
/** Gap between children. Cast strips use 12px; recommendation rows
* use 12-16px. */
gap?: string
/** Vertical offset for the nav arrows so they sit centered on the
* poster/avatar, not on the label below. The default `bottom-12`
* matches ContentRow which has poster + 2 lines of text. Use
* `bottom-14` for cast (avatar + 2 lines) or `bottom-0` to center
* on the full row. */
arrowsBottomInset?: string
/** Optional className overrides on the outer wrapper. */
className?: string
}
/**
* Carousel chrome reused across content rows, cast strips, crew
* grids, and TMDB recommendation strips. Wraps its children in an
* Embla viewport with smooth horizontal scroll, hover-revealed nav
* arrows on either side, and edge fades that hide when the user has
* already scrolled to that end.
*
* Children should be a flex row of equal-height items (the parent
* supplies the layout; HorizontalScroller just provides the chrome).
*/
export default function HorizontalScroller({
children,
trackPadding = 'px-7',
gap = 'gap-3',
arrowsBottomInset = 'bottom-12',
className,
}: Props) {
const [ref, embla] = useEmblaCarousel({
align: 'start',
containScroll: 'trimSnaps',
dragFree: true,
})
const [canPrev, setCanPrev] = useState(false)
const [canNext, setCanNext] = useState(false)
useEffect(() => {
if (!embla) return
const update = () => {
setCanPrev(embla.canScrollPrev())
setCanNext(embla.canScrollNext())
}
embla.on('select', update)
embla.on('reInit', update)
embla.on('scroll', update)
update()
return () => {
embla.off('select', update)
embla.off('reInit', update)
embla.off('scroll', update)
}
}, [embla])
return (
<div className={`relative group/scroller ${className || ''}`}>
{/* Edge fade left */}
<div
className={`absolute left-0 top-0 bottom-0 w-12 z-10 pointer-events-none bg-gradient-to-r from-void via-void/60 to-transparent transition-opacity duration-300 ${
canPrev ? 'opacity-100' : 'opacity-0'
}`}
/>
{/* Edge fade right */}
<div
className={`absolute right-0 top-0 bottom-0 w-12 z-10 pointer-events-none bg-gradient-to-l from-void via-void/60 to-transparent transition-opacity duration-300 ${
canNext ? 'opacity-100' : 'opacity-0'
}`}
/>
<AnimatePresence>
{canPrev && (
<motion.button
initial={{ opacity: 0, x: 4 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 4 }}
transition={{ duration: 0.18 }}
onClick={() => embla?.scrollPrev()}
className={`absolute left-2 top-0 ${arrowsBottomInset} w-12 z-20 flex items-center justify-center opacity-0 group-hover/scroller:opacity-100 transition-opacity duration-200 focus:opacity-100`}
aria-label="Previous"
>
<div className="w-9 h-9 rounded-full glass-strong border border-border-hover grid place-items-center text-text-2 hover:text-accent shadow-md hover:scale-105 active:scale-95 transition-all duration-200">
<ChevronLeft size={16} strokeWidth={2.25} />
</div>
</motion.button>
)}
</AnimatePresence>
<AnimatePresence>
{canNext && (
<motion.button
initial={{ opacity: 0, x: -4 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -4 }}
transition={{ duration: 0.18 }}
onClick={() => embla?.scrollNext()}
className={`absolute right-2 top-0 ${arrowsBottomInset} w-12 z-20 flex items-center justify-center opacity-0 group-hover/scroller:opacity-100 transition-opacity duration-200 focus:opacity-100`}
aria-label="Next"
>
<div className="w-9 h-9 rounded-full glass-strong border border-border-hover grid place-items-center text-text-2 hover:text-accent shadow-md hover:scale-105 active:scale-95 transition-all duration-200">
<ChevronRight size={16} strokeWidth={2.25} />
</div>
</motion.button>
)}
</AnimatePresence>
<div ref={ref} className="overflow-hidden">
<div className={`flex ${gap} ${trackPadding} py-1`}>{children}</div>
</div>
</div>
)
}
+146
View File
@@ -0,0 +1,146 @@
import { useEffect, useRef, useState, Component, type ReactNode } from 'react'
interface Props {
/** Children to mount when scrolled near, unmount when scrolled far.
* React Query's cache keeps data warm between mounts, so re-entering
* the area doesn't re-fetch (within the query's staleTime). */
children: ReactNode
/** Optional placeholder rendered while children are unmounted.
* Default: a skeleton row sized to match a typical ContentRow so
* the page's scroll geometry stays stable when content swaps in
* and out. */
placeholder?: ReactNode
/** Mount when the wrapper enters this margin around the viewport.
* Should be tighter than `unmountMargin` so the user doesn't see
* empty content as they scroll towards the row. */
mountMargin?: string
/** Unmount when the wrapper leaves this (larger) margin. The gap
* between mount + unmount margins gives hysteresis - the row stays
* mounted while the user is still scrolling around the visible
* area, and only tears down once they've moved well past it. */
unmountMargin?: string
/** Optional override on the wrapper className. */
className?: string
}
/**
* Windowed mount/unmount for expensive children (data fetches, large
* image grids, motion trees). Used by HomePage to keep memory bounded
* during a long scroll - rows mount when you approach them, stay
* mounted while you're nearby, and unmount once you've scrolled well
* past them.
*
* Two IntersectionObservers with different root margins provide the
* hysteresis: a tight mount margin (~600px) so content's ready by the
* time it scrolls into view, and a wider unmount margin (~1500px) so
* the row doesn't thrash when the user scrolls back and forth across
* the boundary.
*
* Directional unmount: only rows BELOW the viewport are allowed to
* unmount. Anything above the viewport stays mounted so the layout
* above the user's scroll position can't shrink (which would jump the
* scroll). In practice this means scrolling down keeps everything
* you've passed alive, and scrolling back up unmounts the rows you've
* left behind underneath.
*/
export default function LazyMount({
children,
placeholder,
mountMargin = '600px',
unmountMargin = '1500px',
className,
}: Props) {
const ref = useRef<HTMLDivElement>(null)
const [mounted, setMounted] = useState(false)
useEffect(() => {
if (typeof IntersectionObserver === 'undefined') {
// SSR / very old browsers - render immediately so the page
// doesn't ship blank skeletons forever.
setMounted(true)
return
}
const el = ref.current
if (!el) return
// Mount observer: when the wrapper is within mountMargin of the
// viewport, become mounted. Stays attached so we re-mount if the
// user scrolls back into range.
const mountObs = new IntersectionObserver(
entries => {
for (const e of entries) {
if (e.isIntersecting) setMounted(true)
}
},
{ rootMargin: mountMargin },
)
// Unmount observer: only fires when the row is far OUTSIDE the
// wider unmountMargin AND the row is currently below the viewport
// (boundingClientRect.top > 0 means its top edge is below the
// viewport's top). Rows above the viewport never unmount, because
// shrinking layout above the user's scroll position would yank the
// scroll position upward.
const unmountObs = new IntersectionObserver(
entries => {
for (const e of entries) {
if (!e.isIntersecting && e.boundingClientRect.top > 0) {
setMounted(false)
}
}
},
{ rootMargin: unmountMargin },
)
mountObs.observe(el)
unmountObs.observe(el)
return () => {
mountObs.disconnect()
unmountObs.disconnect()
}
}, [mountMargin, unmountMargin])
return (
<div ref={ref} className={className}>
{mounted ? <RowBoundary>{children}</RowBoundary> : (placeholder ?? <DefaultRowSkeleton />)}
</div>
)
}
/**
* Per-row error boundary that isolates a single home-page row from the rest
* of the page. If a row crashes (bad data, a missing field, a thrown render),
* only that row goes blank - sibling rows keep rendering. Failures are sent
* to the console for diagnosis but never surfaced as a user-visible error
* panel inside the row, since a silent gap is far less disruptive than a
* red banner in the middle of the home feed.
*/
export class RowBoundary extends Component<{ children: ReactNode }, { failed: boolean }> {
state = { failed: false }
static getDerivedStateFromError() {
return { failed: true }
}
componentDidCatch(error: unknown) {
console.warn('[home row] render failed', error)
}
render() {
if (this.state.failed) return null
return this.props.children
}
}
function DefaultRowSkeleton() {
return (
<section className="mb-10">
<div className="px-7 mb-3.5">
<div className="skeleton h-5 w-48 rounded mb-1.5" />
<div className="skeleton h-3 w-64 rounded" />
</div>
<div className="px-7 flex gap-3 overflow-hidden">
{Array.from({ length: 6 }).map((_, j) => (
<div key={j} className="shrink-0 w-[160px]">
<div className="skeleton aspect-[2/3] rounded-lg" />
</div>
))}
</div>
</section>
)
}
+97
View File
@@ -0,0 +1,97 @@
import { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { X } from '../../lib/icons'
import { normaliseLetterboxdListUrl } from '../../api/letterboxd'
import { useLetterboxdLists } from '../../stores/letterboxd-lists-store'
interface Props {
open: boolean
onClose: () => void
}
export default function LetterboxdAddModal({ open, onClose }: Props) {
const add = useLetterboxdLists(s => s.add)
const [url, setUrl] = useState('')
const [error, setError] = useState<string | null>(null)
function save() {
const norm = normaliseLetterboxdListUrl(url)
if (!norm) {
setError('That doesn\'t look like a Letterboxd list URL.')
return
}
add(norm)
setUrl('')
setError(null)
onClose()
}
return (
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
onClick={onClose}
className="fixed inset-0 z-[80] grid place-items-center bg-black/65 backdrop-blur-sm p-6"
>
<motion.div
initial={{ y: 20, scale: 0.96 }}
animate={{ y: 0, scale: 1 }}
exit={{ y: 20, scale: 0.96 }}
transition={{ duration: 0.32, ease: [0.16, 1, 0.3, 1] }}
onClick={e => e.stopPropagation()}
className="relative w-full max-w-md rounded-2xl bg-surface ring-1 ring-border-strong shadow-[0_30px_80px_-20px_rgba(0,0,0,0.85)] p-5"
>
<button
onClick={onClose}
aria-label="Close"
className="absolute top-3 right-3 w-8 h-8 grid place-items-center rounded-full text-text-3 hover:text-text-1 hover:bg-elevated transition focus-ring"
>
<X size={14} stroke={2} />
</button>
<h2 className="text-[16px] font-semibold tracking-tight text-text-1 mb-1">
Add a Letterboxd list
</h2>
<p className="text-[12px] text-text-3 mb-4 leading-relaxed">
Paste the URL of any public Letterboxd list. We'll cross-check it against
your library so missing entries are obvious.
</p>
<input
type="url"
value={url}
onChange={e => {
setUrl(e.target.value)
if (error) setError(null)
}}
onKeyDown={e => { if (e.key === 'Enter') save() }}
placeholder="https://letterboxd.com/USER/list/SLUG/"
autoFocus
className="w-full h-10 px-3 rounded-md bg-elevated/60 ring-1 ring-border focus:ring-accent/50 outline-none text-[13px] tracking-tight"
/>
{error && (
<p className="text-[11.5px] text-red-300 mt-2">{error}</p>
)}
<div className="flex items-center justify-end gap-2 mt-5">
<button
onClick={onClose}
className="h-10 px-4 rounded-full text-[12.5px] text-text-2 hover:text-text-1 hover:bg-elevated transition focus-ring"
>
Cancel
</button>
<button
onClick={save}
disabled={!url.trim()}
className="h-10 px-5 rounded-full bg-accent text-void text-[12.5px] font-semibold tracking-tight transition disabled:opacity-40 disabled:cursor-not-allowed hover:bg-accent-hover focus-ring"
>
Add list
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}
+158
View File
@@ -0,0 +1,158 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useQueries } from '@tanstack/react-query'
import { Trash2 } from '../../lib/icons'
import { fetchLetterboxdList, type LetterboxdListItem } from '../../api/letterboxd'
import { getMovieFull } from '../../api/tmdb'
import { useLibraryByTmdbId } from '../../hooks/use-jellyfin'
import { mapTmdbToJf } from '../../lib/tmdb-mapping'
import { useLetterboxdLists, type SavedLetterboxdList } from '../../stores/letterboxd-lists-store'
import ContentRow from './ContentRow'
interface Props {
saved: SavedLetterboxdList
}
const RSS_STALE = 6 * 60 * 60 * 1000
/**
* One imported Letterboxd list rendered as a content row. Lazy-mounts
* once scrolled near the viewport so importing 5 lists doesn't burn
* 100+ TMDB requests on home page load.
*/
export default function LetterboxdListRow({ saved }: Props) {
const containerRef = useRef<HTMLDivElement>(null)
const [near, setNear] = useState(false)
useEffect(() => {
if (!containerRef.current) return
const el = containerRef.current
const obs = new IntersectionObserver(
entries => {
for (const e of entries) {
if (e.isIntersecting) {
setNear(true)
obs.disconnect()
return
}
}
},
{ rootMargin: '200px' },
)
obs.observe(el)
return () => obs.disconnect()
}, [])
return (
<div ref={containerRef}>
{near ? <Mounted saved={saved} /> : <Placeholder saved={saved} />}
</div>
)
}
function Mounted({ saved }: Props) {
const remove = useLetterboxdLists(s => s.remove)
const rss = useQuery({
queryKey: ['letterboxd', 'list', saved.url],
queryFn: () => fetchLetterboxdList(saved.url),
staleTime: RSS_STALE,
})
const data = rss.data
// Cap at 30 entries to keep the request burst reasonable; serious
// lists with 100 films would slam TMDB on first mount otherwise.
const entries: LetterboxdListItem[] = useMemo(
() => (data?.items || []).slice(0, 30),
[data?.items],
)
const ids = useMemo(
() => entries.map(e => e.tmdbId).filter((x): x is string => !!x).map(Number),
[entries],
)
const tmdbQueries = useQueries({
queries: ids.map(id => ({
queryKey: ['tmdb', 'movie-full', id],
queryFn: () => getMovieFull(id),
staleTime: 24 * 60 * 60 * 1000,
})),
})
const libraryByTmdbId = useLibraryByTmdbId()
const items = useMemo(() => {
const tmdbMap = new Map<number, any>()
ids.forEach((id, i) => {
const d = tmdbQueries[i]?.data
if (d) tmdbMap.set(id, { ...d, media_type: 'movie' })
})
// Preserve list order; drop items the proxy returned without a TMDB id.
const ordered: any[] = []
for (const e of entries) {
if (!e.tmdbId) continue
const d = tmdbMap.get(Number(e.tmdbId))
if (d) ordered.push(d)
}
return mapTmdbToJf(ordered, libraryByTmdbId.data)
}, [entries, ids, tmdbQueries, libraryByTmdbId.data])
if (rss.isLoading) return <Placeholder saved={saved} />
if (!data) {
return (
<section className="mb-10 px-7">
<h2 className="text-[18px] font-semibold text-text-1 tracking-tight mb-1">
{saved.customTitle || 'Letterboxd list'}
</h2>
<p className="text-[12px] text-text-3 mb-3">
Couldn't load this list. The proxy may be down, or the URL is private.
</p>
<button
onClick={() => remove(saved.url)}
className="text-[11px] text-text-4 hover:text-red-300 transition tracking-tight focus-ring rounded"
>
Remove
</button>
</section>
)
}
if (items.length === 0) return null
const matched = items.filter(i => (i as any)._inLibrary).length
const subtitle = `${matched} of ${items.length} in your library · from Letterboxd`
return (
<div className="relative group/llb">
<ContentRow
title={saved.customTitle || data.title}
subtitle={subtitle}
items={items}
layoutKey={`letterboxd_${saved.url}`}
/>
<button
onClick={() => {
if (confirm('Remove this Letterboxd list?')) remove(saved.url)
}}
title="Remove this list"
className="absolute top-3 right-7 w-8 h-8 grid place-items-center rounded-full text-text-3 hover:text-red-300 hover:bg-elevated/80 opacity-0 group-hover/llb:opacity-100 transition focus-ring"
>
<Trash2 size={13} stroke={2} />
</button>
</div>
)
}
function Placeholder({ saved }: Props) {
return (
<section className="mb-10">
<div className="px-7 mb-3.5">
<h2 className="text-[18px] font-semibold text-text-1 tracking-tight">
{saved.customTitle || 'Letterboxd list'}
</h2>
<p className="text-[12px] text-text-3 mt-0.5 truncate max-w-md">{saved.url}</p>
</div>
<div className="px-7 flex gap-3">
{Array.from({ length: 6 }).map((_, j) => (
<div key={j} className="shrink-0 w-[160px]">
<div className="skeleton aspect-[2/3] rounded-lg" />
</div>
))}
</div>
</section>
)
}
+416
View File
@@ -0,0 +1,416 @@
import { useEffect, useState } from 'react'
import type { BaseItemDto } from '../../api/types'
import {
pickPrimarySource,
getAudioStreams,
getSubtitleStreams,
resolutionLabel,
videoRangeLabel,
} from '../../lib/jellyfin-meta'
/**
* Compact horizontal strip of "official-style" media tech badges.
* Each badge tries to render an SVG from /icons/media-tech/<name>.svg
* (drop your own per public/icons/media-tech/README.md) and falls back to
* a clean typographic badge when the SVG is missing.
*/
interface Props {
item: BaseItemDto | null | undefined
size?: 'sm' | 'md'
className?: string
}
const ICON_BASE = '/icons/media-tech'
export default function MediaTechIcons({ item, size = 'md', className = '' }: Props) {
if (!item) return null
const source = pickPrimarySource(item)
const audio = getAudioStreams(item)[0]
const subs = getSubtitleStreams(item)
const res = resolutionLabel(item)
const range = videoRangeLabel(item)
const audioCodec = (audio?.Codec || '').toLowerCase()
const audioProfile = (audio?.Profile || '').toLowerCase()
const audioSpatial = (audio?.AudioSpatialFormat || '').toLowerCase()
const channels = audio?.Channels || 0
const channelLayout = (audio?.ChannelLayout || '').toLowerCase()
const audio_ = pickAudioFormat(audioCodec, audioProfile, audioSpatial)
const channel_ = pickChannelLabel(channels, channelLayout)
const container = source?.Container?.toLowerCase()
const anything = res || range || audio_ || channel_ || container || subs.length > 0
if (!anything) return null
return (
<div className={`flex items-center gap-1.5 flex-wrap ${className}`}>
{res && <ResolutionBadge res={res} size={size} />}
{range && <RangeBadge range={range} size={size} />}
{audio_ && <AudioBadge format={audio_} size={size} />}
{channel_ && <ChannelBadge label={channel_} size={size} />}
{container && <ContainerBadge label={container} size={size} />}
{subs.length > 0 && <SubBadge count={subs.length} size={size} />}
</div>
)
}
/* ──────────────────────────────────────────────────────────── */
/* Smart icon-or-fallback */
/* ──────────────────────────────────────────────────────────── */
/**
* Reads a local SVG once, parses its viewBox / dimensions to derive aspect
* ratio, and caches the result. We then render the icon via CSS mask-image -
* which paints the element's `background-color` clipped by the SVG silhouette,
* giving a uniform white render regardless of the source SVG's fill colors.
*/
const svgInfoCache = new Map<string, { aspect: number } | 'missing'>()
function useSvgInfo(url: string): { aspect: number | null; missing: boolean } {
const initial = svgInfoCache.get(url)
const [aspect, setAspect] = useState<number | null>(
initial && initial !== 'missing' ? initial.aspect : null,
)
const [missing, setMissing] = useState<boolean>(initial === 'missing')
useEffect(() => {
if (svgInfoCache.has(url)) return
let cancelled = false
fetch(url)
.then(res => {
if (!res.ok) throw new Error(`${res.status}`)
const ct = res.headers.get('content-type') || ''
if (!ct.includes('svg') && !ct.includes('xml')) throw new Error('not svg')
return res.text()
})
.then(raw => {
const a = parseAspect(raw)
if (a == null) throw new Error('no dimensions')
svgInfoCache.set(url, { aspect: a })
if (!cancelled) setAspect(a)
})
.catch(() => {
svgInfoCache.set(url, 'missing')
if (!cancelled) setMissing(true)
})
return () => { cancelled = true }
}, [url])
return { aspect, missing }
}
function parseAspect(svg: string): number | null {
const vb = svg.match(/<svg[^>]*\sviewBox\s*=\s*"\s*([-\d.eE]+)\s+([-\d.eE]+)\s+([-\d.eE]+)\s+([-\d.eE]+)\s*"/i)
if (vb) {
const w = parseFloat(vb[3])
const h = parseFloat(vb[4])
if (w > 0 && h > 0) return w / h
}
const wAttr = svg.match(/<svg[^>]*\swidth\s*=\s*"([\d.]+)/i)
const hAttr = svg.match(/<svg[^>]*\sheight\s*=\s*"([\d.]+)/i)
if (wAttr && hAttr) {
const w = parseFloat(wAttr[1])
const h = parseFloat(hAttr[1])
if (w > 0 && h > 0) return w / h
}
return null
}
function IconOrFallback({
iconFile,
fallback,
size,
alt,
}: {
iconFile: string
fallback: React.ReactNode
size: 'sm' | 'md'
alt: string
}) {
const url = `${ICON_BASE}/${iconFile}`
const { aspect, missing } = useSvgInfo(url)
if (missing) return <>{fallback}</>
const h = size === 'sm' ? 18 : 22
const w = aspect ? Math.round(h * aspect) : h * 3
// Cap absurdly wide logos so a single badge can't dominate the row
const cappedWidth = Math.min(w, h * 6)
return (
<span
role="img"
aria-label={alt}
title={alt}
className="inline-block select-none align-middle"
style={{
height: h,
width: cappedWidth,
backgroundColor: 'white',
WebkitMaskImage: `url(${url})`,
maskImage: `url(${url})`,
WebkitMaskRepeat: 'no-repeat',
maskRepeat: 'no-repeat',
WebkitMaskPosition: 'center',
maskPosition: 'center',
WebkitMaskSize: 'contain',
maskSize: 'contain',
}}
/>
)
}
/* ──────────────────────────────────────────────────────────── */
/* Resolution */
/* ──────────────────────────────────────────────────────────── */
function ResolutionBadge({ res, size }: { res: string; size: 'sm' | 'md' }) {
const file =
res === '4K' ? '4k-uhd.svg'
: res === '1080p' ? 'hd-1080p.svg'
: res === '720p' ? 'hd-720p.svg'
: null
if (file) {
return <IconOrFallback iconFile={file} alt={res} size={size} fallback={<ResolutionTextBadge res={res} size={size} />} />
}
return <ResolutionTextBadge res={res} size={size} />
}
function ResolutionTextBadge({ res, size }: { res: string; size: 'sm' | 'md' }) {
const h = size === 'sm' ? 'h-5' : 'h-6'
const fs = size === 'sm' ? 'text-[9px]' : 'text-[10px]'
if (res === '4K') {
return (
<span className={`${h} ${fs} inline-flex items-stretch overflow-hidden rounded-md ring-1 ring-white/10 shadow-sm font-bold uppercase tracking-[0.04em] select-none`}>
<span className="px-1.5 grid place-items-center bg-white text-black">4K</span>
<span className="px-1.5 grid place-items-center bg-black text-white tracking-[0.18em]">UHD</span>
</span>
)
}
if (res === '1080p') {
return (
<span className={`${h} ${fs} inline-flex items-stretch overflow-hidden rounded-md ring-1 ring-white/15 shadow-sm font-bold uppercase tracking-[0.04em] select-none`}>
<span className="px-1.5 grid place-items-center bg-white text-black">HD</span>
<span className="px-1.5 grid place-items-center bg-black text-white tracking-[0.18em]">1080P</span>
</span>
)
}
return (
<span className={`${h} ${fs} inline-flex items-center px-2 rounded-md bg-black text-white/90 ring-1 ring-white/10 font-bold uppercase tracking-[0.12em] select-none`}>
{res}
</span>
)
}
/* ──────────────────────────────────────────────────────────── */
/* HDR / Dolby Vision */
/* ──────────────────────────────────────────────────────────── */
function RangeBadge({ range, size }: { range: string; size: 'sm' | 'md' }) {
const file =
range === 'Dolby Vision' ? 'dolby-vision.svg'
: range === 'HDR10+' ? 'hdr10-plus.svg'
: range === 'HDR10' || range === 'HDR' ? 'hdr10.svg'
: null
if (file) {
return <IconOrFallback iconFile={file} alt={range} size={size} fallback={<RangeTextBadge range={range} size={size} />} />
}
return <RangeTextBadge range={range} size={size} />
}
function RangeTextBadge({ range, size }: { range: string; size: 'sm' | 'md' }) {
const h = size === 'sm' ? 'h-5' : 'h-6'
const fs = size === 'sm' ? 'text-[9px]' : 'text-[10px]'
if (range === 'Dolby Vision') {
return (
<span className={`${h} ${fs} inline-flex items-center px-2 rounded-md bg-black text-white ring-1 ring-white/15 shadow-sm font-bold uppercase tracking-[0.12em] select-none`}>
Dolby Vision
</span>
)
}
if (range === 'HDR10+') {
return (
<span className={`${h} ${fs} inline-flex items-center px-2 rounded-md ring-1 ring-amber-300/30 shadow-sm font-bold uppercase tracking-[0.06em] select-none bg-gradient-to-b from-amber-400 to-amber-600 text-black`}>
HDR10<sup className="ml-px text-[7px]">+</sup>
</span>
)
}
return (
<span className={`${h} ${fs} inline-flex items-center px-2 rounded-md ring-1 ring-amber-300/30 shadow-sm font-bold uppercase tracking-[0.08em] select-none bg-gradient-to-b from-amber-400 to-amber-600 text-black`}>
{range}
</span>
)
}
/* ──────────────────────────────────────────────────────────── */
/* Audio */
/* ──────────────────────────────────────────────────────────── */
interface AudioFormat {
iconFile: string | null
label: string
alt: string
/** When set, the typographic fallback gets a tone treatment. */
fallbackTone?: 'dolby' | 'dts' | 'lossless' | 'lossy'
}
function AudioBadge({ format, size }: { format: AudioFormat; size: 'sm' | 'md' }) {
if (format.iconFile) {
return (
<IconOrFallback
iconFile={format.iconFile}
alt={format.alt}
size={size}
fallback={<AudioTextBadge format={format} size={size} />}
/>
)
}
return <AudioTextBadge format={format} size={size} />
}
function AudioTextBadge({ format, size }: { format: AudioFormat; size: 'sm' | 'md' }) {
const h = size === 'sm' ? 'h-5' : 'h-6'
const fs = size === 'sm' ? 'text-[9px]' : 'text-[10px]'
const toneCls =
format.fallbackTone === 'dolby'
? 'bg-black text-white ring-white/15'
: format.fallbackTone === 'dts'
? 'bg-gradient-to-b from-zinc-700 to-black text-white ring-white/15'
: format.fallbackTone === 'lossless'
? 'bg-emerald-500/15 text-emerald-300 ring-emerald-500/30'
: 'bg-white/8 text-white/85 ring-white/12'
return (
<span className={`${h} ${fs} ${toneCls} inline-flex items-center px-2 rounded-md ring-1 font-bold uppercase tracking-[0.06em] select-none`}>
{format.label}
</span>
)
}
/* ──────────────────────────────────────────────────────────── */
/* Channel / Container / Subs */
/* ──────────────────────────────────────────────────────────── */
function ChannelBadge({ label, size }: { label: string; size: 'sm' | 'md' }) {
const file =
label === '5.1' ? '5-1.svg'
: label === '7.1' ? '7-1.svg'
: label === 'Stereo' ? 'stereo.svg'
: label === 'Mono' ? 'mono.svg'
: null
if (file) {
return <IconOrFallback iconFile={file} alt={label} size={size} fallback={<ChannelTextBadge label={label} size={size} />} />
}
return <ChannelTextBadge label={label} size={size} />
}
function ChannelTextBadge({ label, size }: { label: string; size: 'sm' | 'md' }) {
const h = size === 'sm' ? 'h-5' : 'h-6'
const fs = size === 'sm' ? 'text-[9px]' : 'text-[10px]'
return (
<span className={`${h} ${fs} inline-flex items-center px-2 rounded-md bg-white/8 text-white/90 ring-1 ring-white/12 font-bold uppercase tracking-[0.06em] tabular-nums select-none`}>
{label}
</span>
)
}
function ContainerBadge({ label, size }: { label: string; size: 'sm' | 'md' }) {
// Allow per-container icon files: mkv.svg, mp4.svg, webm.svg, etc.
const file = `${label}.svg`
const upper = label.toUpperCase()
return (
<IconOrFallback
iconFile={file}
alt={upper}
size={size}
fallback={<ContainerTextBadge label={upper} size={size} />}
/>
)
}
function ContainerTextBadge({ label, size }: { label: string; size: 'sm' | 'md' }) {
const h = size === 'sm' ? 'h-5' : 'h-6'
const fs = size === 'sm' ? 'text-[9px]' : 'text-[10px]'
return (
<span className={`${h} ${fs} inline-flex items-center px-2 rounded-md bg-void text-text-2 ring-1 ring-border font-bold uppercase tracking-[0.08em] select-none`}>
{label}
</span>
)
}
function SubBadge({ count, size }: { count: number; size: 'sm' | 'md' }) {
return (
<IconOrFallback
iconFile="closed-captions.svg"
alt={`Closed captions (${count} tracks)`}
size={size}
fallback={<SubTextBadge count={count} size={size} />}
/>
)
}
function SubTextBadge({ count, size }: { count: number; size: 'sm' | 'md' }) {
const h = size === 'sm' ? 'h-5' : 'h-6'
const fs = size === 'sm' ? 'text-[9px]' : 'text-[10px]'
return (
<span
className={`${h} ${fs} inline-flex items-center gap-1 px-2 rounded-md bg-white/5 text-white/75 ring-1 ring-white/10 font-bold uppercase tracking-[0.08em] tabular-nums select-none`}
title={`${count} subtitle track${count === 1 ? '' : 's'}`}
>
CC
<span className="text-white/55">×{count}</span>
</span>
)
}
/* ──────────────────────────────────────────────────────────── */
/* Codec → format mapping */
/* ──────────────────────────────────────────────────────────── */
function pickAudioFormat(codec: string, profile: string, spatial: string): AudioFormat | null {
if (spatial.includes('dolbyatmos') || profile.includes('atmos')) {
return { iconFile: 'dolby-atmos.svg', label: 'Atmos', alt: 'Dolby Atmos', fallbackTone: 'dolby' }
}
if (spatial.includes('dtsx') || profile.includes(':x')) {
return { iconFile: 'dts-x.svg', label: 'DTS:X', alt: 'DTS:X', fallbackTone: 'dts' }
}
if (codec === 'eac3') return { iconFile: 'dolby-digital-plus.svg', label: 'Dolby Digital+', alt: 'Dolby Digital Plus', fallbackTone: 'dolby' }
if (codec === 'truehd') return { iconFile: 'dolby-truehd.svg', label: 'TrueHD', alt: 'Dolby TrueHD', fallbackTone: 'dolby' }
if (codec === 'ac3') return { iconFile: 'dolby-digital.svg', label: 'Dolby Digital', alt: 'Dolby Digital', fallbackTone: 'dolby' }
if (codec === 'dts') {
if (profile.includes('hd ma') || profile.includes('hd-ma')) {
return { iconFile: 'dts-hd-ma.svg', label: 'DTS-HD MA', alt: 'DTS-HD Master Audio', fallbackTone: 'dts' }
}
if (profile.includes('hd hra') || profile.includes('hd-hra')) {
return { iconFile: 'dts-hd-hra.svg', label: 'DTS-HD HRA', alt: 'DTS-HD HRA', fallbackTone: 'dts' }
}
return { iconFile: 'dts.svg', label: 'DTS', alt: 'DTS', fallbackTone: 'dts' }
}
if (codec === 'flac') return { iconFile: null, label: 'FLAC', alt: 'FLAC', fallbackTone: 'lossless' }
if (codec === 'pcm' || codec.startsWith('pcm_')) return { iconFile: null, label: 'PCM', alt: 'PCM', fallbackTone: 'lossless' }
if (codec === 'alac') return { iconFile: null, label: 'ALAC', alt: 'ALAC', fallbackTone: 'lossless' }
if (codec === 'opus') return { iconFile: null, label: 'Opus', alt: 'Opus', fallbackTone: 'lossy' }
if (codec === 'aac') return { iconFile: null, label: 'AAC', alt: 'AAC', fallbackTone: 'lossy' }
if (codec === 'mp3') return { iconFile: null, label: 'MP3', alt: 'MP3', fallbackTone: 'lossy' }
if (codec === 'vorbis') return { iconFile: null, label: 'Vorbis', alt: 'Vorbis', fallbackTone: 'lossy' }
return null
}
function pickChannelLabel(channels: number, layout: string): string | null {
if (layout.includes('7.1') || channels >= 8) return '7.1'
if (layout.includes('5.1') || channels === 6) return '5.1'
if (layout.includes('quad') || channels === 4) return '4.0'
if (channels === 2) return 'Stereo'
if (channels === 1) return 'Mono'
return null
}
+84
View File
@@ -0,0 +1,84 @@
import type { BaseItemDto } from '../../api/types'
import { getTopBadges, getAllBadges } from '../../lib/jellyfin-meta'
interface Props {
item: Pick<BaseItemDto, 'MediaSources' | 'Width' | 'Height'>
variant?: 'card' | 'hero'
className?: string
}
/** Tiny corner pills for poster cards (4K / HDR / DV / Atmos). Hidden when nothing notable. */
export default function MetaBadges({ item, variant = 'card', className = '' }: Props) {
const badges = variant === 'card' ? getTopBadges(item) : getAllBadges(item)
if (badges.length === 0) return null
if (variant === 'card') {
return (
<div className={`flex flex-col items-end gap-1 ${className}`}>
{badges.map(b => (
<CardBadge key={b} label={b} />
))}
</div>
)
}
return (
<div className={`flex items-center gap-1.5 flex-wrap ${className}`}>
{badges.map(b => (
<HeroBadge key={b} label={b} />
))}
</div>
)
}
function CardBadge({ label }: { label: string }) {
const tone = badgeTone(label)
return (
<span
className={`inline-flex items-center h-[18px] px-1.5 rounded text-[9px] font-bold tracking-[0.04em] uppercase backdrop-blur-md border ${tone}`}
title={label}
>
{label}
</span>
)
}
function HeroBadge({ label }: { label: string }) {
const tone = badgeTone(label, true)
return (
<span
className={`inline-flex items-center h-6 px-2 rounded-md text-[10px] font-bold tracking-[0.06em] uppercase backdrop-blur border ${tone}`}
>
{label}
</span>
)
}
function badgeTone(label: string, hero = false): string {
// Subtle gradient accents to reinforce visual meaning without screaming
switch (label) {
case '4K':
return hero
? 'bg-cool/20 text-cool border-cool/35'
: 'bg-cool/30 text-cool border-cool/40'
case 'HDR10':
case 'HDR10+':
case 'HDR':
return hero
? 'bg-gradient-to-r from-amber-500/30 to-rose-500/25 text-amber-200 border-amber-400/35'
: 'bg-gradient-to-r from-amber-500/40 to-rose-500/35 text-amber-100 border-amber-400/45'
case 'Dolby Vision':
return hero
? 'bg-gradient-to-r from-indigo-500/30 to-blue-500/25 text-indigo-100 border-indigo-400/40'
: 'bg-gradient-to-r from-indigo-500/40 to-blue-500/35 text-indigo-100 border-indigo-400/50'
case 'Atmos':
case 'DTS:X':
return hero
? 'bg-emerald-500/20 text-emerald-200 border-emerald-500/35'
: 'bg-emerald-500/30 text-emerald-100 border-emerald-500/40'
default:
return hero
? 'bg-white/10 text-white/85 border-white/15'
: 'bg-white/15 text-white/95 border-white/20'
}
}
+51
View File
@@ -0,0 +1,51 @@
import { useState, useEffect } from 'react'
import { WifiOff, RefreshCw } from '../../lib/icons'
import { motion, AnimatePresence } from 'framer-motion'
export default function OfflineBanner() {
const [offline, setOffline] = useState(!navigator.onLine)
useEffect(() => {
const goOffline = () => setOffline(true)
const goOnline = () => setOffline(false)
window.addEventListener('offline', goOffline)
window.addEventListener('online', goOnline)
return () => {
window.removeEventListener('offline', goOffline)
window.removeEventListener('online', goOnline)
}
}, [])
return (
<AnimatePresence>
{offline && (
<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-b border-error/15 bg-error/5"
>
<div className="px-7 py-2.5 flex items-center justify-between">
<div className="flex items-center gap-2.5">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-error/60" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-error" />
</span>
<WifiOff size={14} className="text-error/90" />
<span className="text-[13px] text-error/95 font-medium">Cannot reach server</span>
<span className="text-[12px] text-error/60">- check your connection</span>
</div>
<button
onClick={() => window.location.reload()}
className="group flex items-center gap-1.5 text-[12px] text-error/80 hover:text-error transition-colors px-2 py-1 rounded-md hover:bg-error/10"
>
<RefreshCw size={12} className="transition-transform duration-500 group-hover:rotate-180" />
Retry
</button>
</div>
</motion.div>
)}
</AnimatePresence>
)
}
+129
View File
@@ -0,0 +1,129 @@
import { useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { motion } from 'framer-motion'
import { useTmdbPerson } from '../../hooks/use-tmdb'
import { useLibraryByTmdbId } from '../../hooks/use-jellyfin'
import { mapTmdbToJf } from '../../lib/tmdb-mapping'
import { getTmdbImageUrl } from '../../api/tmdb'
import ContentRow from './ContentRow'
interface Props {
/** TMDB person id. */
personId: number
/** Display name; pre-known from the spotlight aggregation. */
name: string
/** "Director" or "Actor" - drives the credit filter. */
role: 'director' | 'actor'
profilePath?: string | null
/** How many of this person's titles the user has already watched. */
watchedCount: number
}
const COMMERCIAL_DEPARTMENTS = ['Acting', 'Directing']
/**
* Renders a personal-pick row sourced from a TMDB person's filmography.
*
* Strategy:
* - Director: pull from `combined_credits.crew` filtered to job="Director",
* sorted by release date descending.
* - Actor: pull from `combined_credits.cast`, dropping low-billed
* appearances (one-line cameos), sorted by popularity then date.
*
* In-library matches show the accent ring naturally via PosterCard's
* `_inLibrary` honoring; missing items render in the row beside them.
*/
export default function PersonSpotlightRow({ personId, name, role, profilePath, watchedCount }: Props) {
const person = useTmdbPerson(personId)
const libraryByTmdbId = useLibraryByTmdbId()
const items = useMemo(() => {
const credits = person.data?.combined_credits
if (!credits) return []
const list =
role === 'director'
? (credits.crew || []).filter(c => c.job === 'Director')
: (credits.cast || [])
// Drop trivia / archival appearances and ultra-deep cuts.
.filter(c => COMMERCIAL_DEPARTMENTS.includes((c as any).department || 'Acting'))
// De-dupe by id (a director may have multiple crew credits on one film
// when they're also writer; a cast member may appear twice for episodic
// tv guest spots).
const seen = new Set<number>()
const unique = list.filter(c => {
if (seen.has(c.id)) return false
seen.add(c.id)
return true
})
// Sort: newest first, but with low-popularity entries pushed to the end.
const sorted = [...unique].sort((a, b) => {
const ap = a.popularity ?? 0
const bp = b.popularity ?? 0
// Bucket popularity so date dominates within "famous-enough" tiers.
const ab = ap >= 5 ? 0 : 1
const bb = bp >= 5 ? 0 : 1
if (ab !== bb) return ab - bb
const ad = (a.release_date || a.first_air_date || '').slice(0, 10)
const bd = (b.release_date || b.first_air_date || '').slice(0, 10)
return bd.localeCompare(ad)
})
return mapTmdbToJf(sorted.slice(0, 24), libraryByTmdbId.data)
}, [person.data, libraryByTmdbId.data, role])
if (items.length === 0) return null
const totalCount = items.length
const remaining = Math.max(0, totalCount - watchedCount)
return (
<section className="mb-10">
<div className="px-7 mb-3.5 flex items-end justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
{profilePath && (
<motion.img
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3 }}
src={getTmdbImageUrl(profilePath, 'w185')}
alt=""
className="w-12 h-12 rounded-full object-cover ring-1 ring-border shrink-0"
/>
)}
<div className="min-w-0">
<p className="text-[10px] uppercase tracking-[0.18em] font-semibold text-text-3 mb-0.5">
{role === 'director' ? 'Director spotlight' : 'Following the actor'}
</p>
<h2 className="text-[18px] font-semibold text-text-1 tracking-tight leading-tight">
<PersonName personId={personId} name={name} />
</h2>
<p className="text-[12px] text-text-3 mt-0.5">
<span className="tabular-nums text-text-2 font-medium">{watchedCount}</span> watched
{' · '}
<span className="tabular-nums text-text-2 font-medium">{remaining}</span> remaining
</p>
</div>
</div>
</div>
<ContentRow
title=""
items={items}
layoutKey={`spotlight_${role}_${personId}`}
/>
</section>
)
}
function PersonName({ personId, name }: { personId: number; name: string }) {
const navigate = useNavigate()
return (
<button
onClick={() => navigate(`/person/${personId}`)}
className="hover:text-accent transition focus-ring rounded"
>
{name}
</button>
)
}
+445
View File
@@ -0,0 +1,445 @@
import { useEffect, useRef, useState } from 'react'
import { Play, Heart, Check, Library } from '../../lib/icons'
import { motion, useMotionValue, useSpring, useTransform } from 'framer-motion'
import type { BaseItemDto } from '../../api/types'
import { getBestImage, getStoredServerUrl } from '../../api/jellyfin'
import MetaBadges from './MetaBadges'
import { usePreferencesStore } from '../../stores/preferences-store'
import { useHoverTrailer } from '../../hooks/use-hover-trailer'
import { useCollectionMeter } from '../../hooks/use-collection-meter'
import { usePrebuffer } from '../../hooks/use-prebuffer'
import { useQuickLookStore } from '../../stores/quick-look-store'
import AvailabilityChip from './AvailabilityChip'
import { tmdbDecoration, isInLibrary, getTmdbId } from '../../lib/item-types'
interface Props {
item: BaseItemDto
aspect?: 'poster' | 'landscape' | 'square'
onClick?: () => void
priority?: boolean
/** Mark this card as belonging to the user's local library (TMDB id matched). */
inLibrary?: boolean
/** Reduce visual weight for items NOT in library (in mixed grids). */
dim?: boolean
/** Multi-select state from the library selection store. When `selected` is
* true the card renders a check overlay; the parent owns the toggle. */
selected?: boolean
/** Called on Ctrl/Cmd-click when selection is enabled. Returns true if
* the click should be considered a selection toggle (suppressing
* navigation). */
onToggleSelect?: () => void
/** Library context: rendered as part of the user's own library page.
* Suppresses badges that are redundant in that context (no point
* flagging "Available" or "In library" on items the user obviously
* already owns). */
libraryContext?: boolean
/** Tighter corner radius for dense layouts (Library "map" view) where
* rounded-lg crops too much of the artwork at small card sizes. */
compact?: boolean
}
export default function PosterCard({
item,
aspect = 'poster',
onClick,
priority = false,
inLibrary = false,
dim = false,
selected = false,
onToggleSelect,
libraryContext = false,
compact = false,
}: Props) {
const cardRef = useRef<HTMLButtonElement>(null)
const showTechBadges = usePreferencesStore(s => s.showTechBadges)
const reduceMotion = usePreferencesStore(s => s.reduceMotion)
const hoverTrailersPref = usePreferencesStore(s => s.hoverTrailers)
const quickLookPref = usePreferencesStore(s => s.quickLookEnabled)
const openQuickLook = useQuickLookStore(s => s.open)
const [hovered, setHovered] = useState(false)
const [imgLoaded, setImgLoaded] = useState(false)
const [imgError, setImgError] = useState(false)
const [trailerStarted, setTrailerStarted] = useState(false)
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const trailerUnmountTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const trailer = useHoverTrailer(item, hovered, hoverTrailersPref && !reduceMotion)
// Same hover-arm gate as the trailer: meter only fetches once the user
// has paused on the card. Returns null when the movie isn't part of a
// TMDB collection.
const collectionMeterPref = usePreferencesStore(s => s.detail.show.collectionMeter)
const collectionMeter = useCollectionMeter(item, hovered && collectionMeterPref)
// Pre-fire PlaybackInfo + first-kilobyte fetch so the player click→play
// path feels instant. Hover-armed for the same reason as the trailer:
// we don't want to slam the server when sweeping the cursor across a
// grid.
const prebufferPref = usePreferencesStore(s => s.prebufferOnHover)
usePrebuffer(item, hovered && prebufferPref)
// Mount the trailer iframe when we have a video AND we're hovered.
// Unmount is delayed by 1.5s after leaving so a brief mouse-out
// (moving to read the title, sliding past, etc.) doesn't tear down
// the YouTube load and re-trigger autoplay on the next hover.
useEffect(() => {
if (hovered && trailer.videoKey) {
if (trailerUnmountTimer.current) {
clearTimeout(trailerUnmountTimer.current)
trailerUnmountTimer.current = null
}
if (!trailerStarted) setTrailerStarted(true)
return
}
if (!hovered && trailerStarted) {
if (trailerUnmountTimer.current) clearTimeout(trailerUnmountTimer.current)
trailerUnmountTimer.current = setTimeout(() => setTrailerStarted(false), 1500)
return () => {
if (trailerUnmountTimer.current) {
clearTimeout(trailerUnmountTimer.current)
trailerUnmountTimer.current = null
}
}
}
return undefined
}, [hovered, trailer.videoKey, trailerStarted])
// Magnetic tilt - flat rotation when reduce-motion is enabled
const mx = useMotionValue(0)
const my = useMotionValue(0)
const rotateX = useSpring(useTransform(my, [-0.5, 0.5], reduceMotion ? [0, 0] : [3.5, -3.5]), { stiffness: 220, damping: 22 })
const rotateY = useSpring(useTransform(mx, [-0.5, 0.5], reduceMotion ? [0, 0] : [-3.5, 3.5]), { stiffness: 220, damping: 22 })
const glareX = useTransform(mx, [-0.5, 0.5], ['25%', '75%'])
const glareY = useTransform(my, [-0.5, 0.5], ['25%', '75%'])
function handleMove(e: React.MouseEvent<HTMLButtonElement>) {
if (reduceMotion || !cardRef.current) return
const rect = cardRef.current.getBoundingClientRect()
mx.set((e.clientX - rect.left) / rect.width - 0.5)
my.set((e.clientY - rect.top) / rect.height - 0.5)
}
function handleLeave() {
setHovered(false)
// trailerStarted is reset by the delayed-unmount effect above so a
// brief cursor-out keeps the iframe alive.
if (longPressTimer.current) {
clearTimeout(longPressTimer.current)
longPressTimer.current = null
}
mx.set(0)
my.set(0)
}
function handleContextMenu(e: React.MouseEvent) {
if (!quickLookPref) return
e.preventDefault()
openQuickLook(item)
}
function handleTouchStart() {
if (!quickLookPref) return
if (longPressTimer.current) clearTimeout(longPressTimer.current)
longPressTimer.current = setTimeout(() => openQuickLook(item), 550)
}
function handleTouchEnd() {
if (longPressTimer.current) {
clearTimeout(longPressTimer.current)
longPressTimer.current = null
}
}
const serverUrl = getStoredServerUrl()
const tmdbPosterUrl = tmdbDecoration(item)._tmdbPoster
const imageUrl =
getBestImage(
serverUrl,
item,
aspect === 'landscape' ? 'thumb' : 'primary',
aspect === 'poster' ? 320 : 480,
) ||
tmdbPosterUrl ||
''
// Items can carry `_inLibrary` from a TMDB-mapped synthetic; respect it as default.
const effectiveInLibrary = inLibrary || isInLibrary(item)
const title = item.Name || item.SeriesName || 'Untitled'
const subtitle = [
item.ProductionYear,
item.RunTimeTicks ? `${Math.round(Number(item.RunTimeTicks) / 600000000)} min` : '',
item.Type === 'Episode' && item.ParentIndexNumber != null && item.IndexNumber != null
? `S${item.ParentIndexNumber} - E${item.IndexNumber}`
: '',
].filter(Boolean).join(' · ')
const progress = item.UserData?.PlayedPercentage
const isWatched = item.UserData?.Played
const isFavorite = item.UserData?.IsFavorite
const aspectClass =
aspect === 'poster' ? 'aspect-[2/3]'
: aspect === 'square' ? 'aspect-square'
: 'aspect-video'
// Iframes don't honor `object-fit: cover`. We compute explicit
// cover dimensions from the card aspect AND oversize the iframe by
// ~30% in each axis so the edge chrome (top title bar, bottom
// control bar) falls outside the visible window. The video stays
// centered via translate(-50%, -50%) and the parent's overflow:hidden
// crops the bleed.
//
// YouTube's newer player paints a big center play/prev/next overlay
// we can't crop around anyway, so there's no point oversizing
// further at the cost of seeing less of the actual trailer. 30%
// bleed catches the edge chrome and lets the trailer composition
// stay mostly intact.
//
// height = 130% of card height (15% bleed above + below)
// width = height × 16/9 × (card_height / card_width)
// poster (h/w 1.5): width ≈ 346.67%, height 130%
// square (h/w 1): width ≈ 231.11%, height 130%
// landscape (h/w 0.5625): width = 130%, height 130%
const trailerCoverStyle: React.CSSProperties =
aspect === 'poster'
? { width: '346.67%', height: '130%' }
: aspect === 'square'
? { width: '231.11%', height: '130%' }
: { width: '130%', height: '130%' }
return (
<motion.button
ref={cardRef}
onClick={(e) => {
// Ctrl/Cmd-click is a selection toggle, not a navigation. The
// parent decides whether to honor it via onToggleSelect.
if (onToggleSelect && (e.ctrlKey || e.metaKey || e.shiftKey)) {
e.preventDefault()
e.stopPropagation()
onToggleSelect()
return
}
// When something is already selected, plain clicks toggle as well
// so the user can fluidly grow / shrink the selection.
if (onToggleSelect && selected) {
e.preventDefault()
e.stopPropagation()
onToggleSelect()
return
}
onClick?.()
}}
onMouseMove={handleMove}
onMouseEnter={() => setHovered(true)}
onMouseLeave={handleLeave}
onContextMenu={handleContextMenu}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
onTouchMove={handleTouchEnd}
style={{ rotateX, rotateY, transformStyle: 'preserve-3d', transformPerspective: 1000 }}
whileTap={{ scale: 0.97 }}
transition={{ duration: 0.18, ease: [0.16, 1, 0.3, 1] }}
className={`group text-left w-full focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-void ${compact ? 'rounded-[3px]' : 'rounded-lg'}`}
>
<div
className={`relative overflow-hidden ${compact ? 'rounded-[3px]' : 'rounded-lg'} bg-elevated/60 transition-all duration-200 ${aspectClass} ${
selected
? 'ring-2 ring-accent shadow-[0_0_24px_-2px_rgba(245,182,66,0.7)]'
: effectiveInLibrary
? 'ring-2 ring-accent/60 shadow-[0_0_24px_-6px_rgba(245,182,66,0.45)]'
: 'ring-1 ring-border'
} ${dim && !effectiveInLibrary ? 'opacity-65 grayscale-[20%] hover:opacity-100 hover:grayscale-0' : ''}`}
style={{ transform: 'translateZ(0)' }}
>
{/* Skeleton */}
{!imgLoaded && !imgError && <div className="absolute inset-0 skeleton" />}
{/* Image */}
{imageUrl && !imgError && (
<motion.img
src={imageUrl}
alt={title}
loading={priority ? 'eager' : 'lazy'}
decoding="async"
onLoad={() => setImgLoaded(true)}
onError={() => setImgError(true)}
initial={{ opacity: 0, scale: 1.02 }}
animate={{
opacity: imgLoaded ? 1 : 0,
scale: hovered ? 1.08 : 1,
}}
transition={{
opacity: { duration: 0.45, ease: [0.16, 1, 0.3, 1] },
scale: { duration: 0.7, ease: [0.16, 1, 0.3, 1] },
}}
className="absolute inset-0 w-full h-full object-cover"
/>
)}
{/* Image fallback (no image) */}
{(!imageUrl || imgError) && (
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-elevated to-surface">
<span className="text-text-4 text-[36px] font-display font-semibold opacity-50 select-none">
{title[0]?.toUpperCase()}
</span>
</div>
)}
{/* Hover trailer - YouTube embed with autoplay + mute. Sized to
COVER the card (not contain): the iframe is wider than the
parent for poster/square aspects so the 16:9 video fully
fills the visible frame and the sides crop. All YouTube
chrome is masked via player params + opaque overlays so the
user sees motion art, not a player. */}
{trailerStarted && trailer.videoKey && (
<div className="absolute inset-0 overflow-hidden pointer-events-none bg-black transition-opacity duration-300">
<iframe
src={`https://www.youtube-nocookie.com/embed/${trailer.videoKey}?autoplay=1&mute=1&controls=0&modestbranding=1&playsinline=1&rel=0&loop=1&playlist=${trailer.videoKey}&iv_load_policy=3&disablekb=1&fs=0&showinfo=0&cc_load_policy=0&color=white`}
title=""
allow="autoplay; encrypted-media; picture-in-picture"
referrerPolicy="strict-origin-when-cross-origin"
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 border-0 pointer-events-none"
style={trailerCoverStyle}
/>
</div>
)}
{/* Bottom gradient (always there for legibility, intensifies on hover) */}
<div className="absolute inset-x-0 bottom-0 h-2/3 bg-gradient-to-t from-black/85 via-black/30 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
{/* Tinted vignette on hover */}
<div
className="absolute inset-0 transition-opacity duration-300"
style={{ opacity: hovered ? 1 : 0, boxShadow: 'inset 0 0 60px rgba(0,0,0,0.5)' }}
/>
{/* Diagonal glare following cursor */}
<motion.div
className="absolute inset-0 pointer-events-none mix-blend-overlay"
style={{
opacity: hovered ? 0.5 : 0,
background: `radial-gradient(circle at ${glareX.get()}% ${glareY.get()}%, rgba(255,255,255,0.35), transparent 45%)`,
transition: 'opacity 200ms ease-out',
}}
/>
{/* Play affordance */}
<motion.div
className="absolute inset-0 grid place-items-center"
initial={false}
animate={{
opacity: hovered ? 1 : 0,
scale: hovered ? 1 : 0.7,
}}
transition={{ type: 'spring', stiffness: 400, damping: 24 }}
>
<div className="relative">
<div className="absolute inset-0 rounded-full bg-accent blur-xl opacity-50" />
<div className="relative w-12 h-12 rounded-full bg-accent grid place-items-center shadow-lg shadow-black/50 ring-1 ring-accent-hover">
<Play size={20} className="text-void translate-x-0.5" fill="currentColor" />
</div>
</div>
</motion.div>
{/* Top-left status row */}
<div className="absolute top-2 left-2 flex items-center gap-1">
{selected && (
<div className="w-6 h-6 rounded-full bg-accent text-void grid place-items-center shadow-md">
<Check size={13} strokeWidth={3} />
</div>
)}
{!selected && !libraryContext && effectiveInLibrary && (
<div className="inline-flex items-center gap-1 h-[18px] px-1.5 rounded bg-accent/95 text-void text-[9px] font-bold uppercase tracking-[0.04em] backdrop-blur shadow-md">
<Library size={9} strokeWidth={2.5} />
In library
</div>
)}
{!selected && !libraryContext && !effectiveInLibrary && (
<AvailabilityChip
tmdbId={getTmdbId(item)}
/>
)}
{isFavorite && (
<div className="w-5 h-5 rounded-full bg-black/50 backdrop-blur grid place-items-center">
<Heart size={10} className="text-accent" fill="currentColor" />
</div>
)}
</div>
{/* Top-right status row + tech badges */}
<div className="absolute top-2 right-2 flex flex-col items-end gap-1.5">
{isWatched && (
<div className="w-5 h-5 rounded-full bg-accent grid place-items-center">
<Check size={10} className="text-void" strokeWidth={3} />
</div>
)}
{aspect !== 'square' && showTechBadges && <MetaBadges item={item} />}
</div>
{/* Hover meta strip - title floats up */}
<motion.div
initial={false}
animate={{ opacity: hovered ? 1 : 0, y: hovered ? 0 : 8 }}
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
className="absolute inset-x-0 bottom-0 px-3 pb-2.5 pt-8"
>
<p className="text-[12px] text-white font-semibold drop-shadow line-clamp-2 leading-tight">
{title}
</p>
{subtitle && (
<p className="text-[10px] text-white/70 drop-shadow mt-0.5 truncate">
{subtitle}
</p>
)}
</motion.div>
{/* Collection completion meter - shown on hover when this movie
is part of a TMDB collection. Sits above the play-progress
bar so they don't fight for the same pixels. */}
{hovered && collectionMeter && (
<motion.div
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.22, ease: [0.16, 1, 0.3, 1] }}
className="absolute left-2 right-2 bottom-2 px-2 py-1.5 rounded-md bg-black/65 backdrop-blur-sm ring-1 ring-white/15"
>
<div className="flex items-center justify-between text-[9.5px] uppercase tracking-[0.12em] font-semibold text-white/85 mb-1">
<span className="truncate">{collectionMeter.collectionName}</span>
<span className="tabular-nums shrink-0 ml-1.5">
{collectionMeter.watched}/{collectionMeter.total}
</span>
</div>
<div className="h-1 rounded-full bg-white/15 overflow-hidden">
<div
className="h-full bg-accent transition-[width] duration-300"
style={{ width: `${(collectionMeter.watched / collectionMeter.total) * 100}%` }}
/>
</div>
</motion.div>
)}
{/* Progress bar - flush bottom, accent fill */}
{progress != null && progress > 0 && !isWatched && (
<div className="absolute bottom-0 left-0 right-0 h-[3px] bg-black/40">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${progress}%` }}
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
className="h-full bg-accent shadow-[0_0_8px_rgba(245,182,66,0.6)]"
/>
</div>
)}
</div>
{/* Below text */}
<div className="mt-2.5 px-px">
<p className={`text-[13.5px] truncate leading-snug tracking-[-0.01em] ${
effectiveInLibrary ? 'text-text-1 font-semibold' : 'text-text-1 font-medium'
}`}>
{title}
</p>
{subtitle && (
<p className="text-[11.5px] text-text-3 truncate mt-1 leading-tight tabular-nums">
{subtitle}
</p>
)}
</div>
</motion.button>
)
}
+201
View File
@@ -0,0 +1,201 @@
import { useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion'
import { Play, Info, Star, Clock, X } from '../../lib/icons'
import { useQuickLookStore } from '../../stores/quick-look-store'
import { useItemDetails } from '../../hooks/use-jellyfin'
import { useTmdbMovie, useTmdbTvShow } from '../../hooks/use-tmdb'
import { getBestImage, getStoredServerUrl } from '../../api/jellyfin'
import { getTmdbImageUrl } from '../../api/tmdb'
import { formatRuntime } from '../../lib/format'
import WatchlistButton from './WatchlistButton'
import RequestButton from '../request/RequestButton'
import { getTmdbId, asTmdbDetail } from '../../lib/item-types'
/**
* Right-click / long-press peek for any item card. Renders a centered
* modal with the backdrop, title, summary, top cast, and shortcut
* actions (Play, More info). Doesn't navigate so the user keeps their
* place in the browse view.
*/
export default function QuickLookModal() {
const navigate = useNavigate()
const target = useQuickLookStore(s => s.item)
const close = useQuickLookStore(s => s.close)
const open = !!target
const { data: full } = useItemDetails(open && target?.Id && !String(target.Id).startsWith('tmdb-') ? target.Id : undefined)
const item = full || target
const tmdbId = getTmdbId(item)
const numericTmdb = tmdbId ? Number(tmdbId) : null
const isSeries = item?.Type === 'Series'
const tmdbMovie = useTmdbMovie(!isSeries ? numericTmdb : null)
const tmdbTv = useTmdbTvShow(isSeries ? numericTmdb : null)
const tmdbData = asTmdbDetail(isSeries ? tmdbTv.data : tmdbMovie.data)
useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') close()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [open, close])
if (!open || !item) return null
const serverUrl = getStoredServerUrl()
const backdrop = serverUrl ? getBestImage(serverUrl, item, 'backdrop', 1280) : null
const fallbackBackdrop = tmdbData?.backdrop_path ? getTmdbImageUrl(tmdbData.backdrop_path, 'w1280') : null
const heroImg = backdrop || fallbackBackdrop
const overview = item.Overview || tmdbData?.overview || ''
const year = item.ProductionYear || (tmdbData?.release_date || tmdbData?.first_air_date || '').slice(0, 4)
const runtime = item.RunTimeTicks ? formatRuntime(item.RunTimeTicks) : null
const community = item.CommunityRating ?? tmdbData?.vote_average
const cast = (tmdbData?.credits?.cast || []).slice(0, 6)
const genres = item.Genres || []
const isLocal = !!item.Id && !String(item.Id).startsWith('tmdb-')
return (
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
onClick={close}
className="fixed inset-0 z-[80] grid place-items-center bg-black/65 backdrop-blur-sm p-6"
role="dialog"
aria-label={`Quick look at ${item.Name}`}
>
<motion.div
initial={{ y: 20, scale: 0.97 }}
animate={{ y: 0, scale: 1 }}
exit={{ y: 20, scale: 0.97 }}
transition={{ duration: 0.32, ease: [0.16, 1, 0.3, 1] }}
onClick={e => e.stopPropagation()}
className="relative w-full max-w-2xl rounded-2xl overflow-hidden bg-surface ring-1 ring-border-strong shadow-[0_30px_80px_-20px_rgba(0,0,0,0.85)]"
>
<button
onClick={close}
aria-label="Close"
className="absolute top-3 right-3 z-10 w-8 h-8 grid place-items-center rounded-full bg-black/55 backdrop-blur text-white/85 hover:bg-black/75 transition focus-ring"
>
<X size={14} stroke={2} />
</button>
{heroImg && (
<div className="relative aspect-[16/8] overflow-hidden bg-elevated">
<img src={heroImg} alt={item.Name || ''} className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-gradient-to-t from-surface via-surface/40 to-transparent" />
</div>
)}
<div className="p-6 -mt-8 relative">
<h2 className="text-[22px] font-display font-bold tracking-tight text-text-1 mb-2 leading-tight">
{item.Name}
</h2>
<div className="flex items-center gap-2 text-[11.5px] text-text-3 mb-4 flex-wrap font-medium">
{year && <span className="tabular-nums">{year}</span>}
{year && (runtime || community) && <Dot />}
{runtime && (
<span className="inline-flex items-center gap-1 tabular-nums">
<Clock size={11} stroke={2} />
{runtime}
</span>
)}
{community && (
<>
{runtime && <Dot />}
<span className="inline-flex items-center gap-1">
<Star size={11} className="text-accent" fill="currentColor" stroke={0} />
<span className="tabular-nums">{Number(community).toFixed(1)}</span>
</span>
</>
)}
</div>
{genres.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-4">
{genres.slice(0, 4).map(g => (
<span
key={g}
className="inline-flex items-center h-6 px-2.5 bg-elevated/70 text-text-2 text-[10.5px] rounded-full ring-1 ring-border tracking-tight"
>
{g}
</span>
))}
</div>
)}
{overview && (
<p className="text-[13.5px] text-text-2 leading-relaxed line-clamp-4 mb-5">
{overview}
</p>
)}
{cast.length > 0 && (
<div className="mb-5">
<p className="text-[10px] uppercase tracking-[0.16em] font-semibold text-text-4 mb-2">
Cast
</p>
<p className="text-[12.5px] text-text-2 leading-snug">
{cast.map((c, i) => (
<span key={c.id}>
<span className="font-medium text-text-1">{c.name}</span>
{c.character && <span className="text-text-4"> as {c.character}</span>}
{i < cast.length - 1 && <span className="text-text-5 mx-1.5">·</span>}
</span>
))}
</p>
</div>
)}
<div className="flex items-center gap-2.5 flex-wrap">
{isLocal && (
<button
onClick={() => {
close()
navigate(`/play/${item.Id}`)
}}
className="inline-flex items-center gap-2 h-11 px-5 rounded-full bg-accent hover:bg-accent-hover text-void text-[13px] font-semibold tracking-tight transition-[transform,background] duration-200 ease-out hover:scale-[1.03] active:scale-[0.97] focus-ring shadow-[0_8px_24px_-8px_rgba(245,182,66,0.5)]"
>
<Play size={14} fill="currentColor" />
Play
</button>
)}
<button
onClick={() => {
close()
if (item.Id) navigate(`/item/${item.Id}`)
}}
className="inline-flex items-center gap-2 h-11 px-5 rounded-full bg-white/8 hover:bg-white/14 text-text-1 text-[13px] font-medium tracking-tight border border-border hover:border-border-strong transition focus-ring"
>
<Info size={14} stroke={2} />
More info
</button>
{isLocal && item.Id && (item.Type === 'Movie' || item.Type === 'Series') && (
<WatchlistButton itemId={item.Id} />
)}
{numericTmdb && (item.Type === 'Movie' || item.Type === 'Series') && (
<RequestButton
tmdbId={numericTmdb}
kind={item.Type === 'Movie' ? 'movie' : 'tv'}
tmdbData={tmdbData as any}
/>
)}
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}
function Dot() {
return <span className="text-text-5">·</span>
}
+40
View File
@@ -0,0 +1,40 @@
import type { ReactNode } from 'react'
/**
* The accent-bar + uppercase label used throughout the detail surfaces.
* Use `SectionLabel` standalone when the caller already wraps content in
* its own `<section>` or div; use `Section` for the common case of "label
* above a block".
*/
export function SectionLabel({ children }: { children: ReactNode }) {
return (
<div className="flex items-center gap-2">
<span className="w-1 h-3.5 rounded-full bg-accent" />
<h3 className="text-[11px] font-semibold text-text-2 uppercase tracking-[0.14em]">{children}</h3>
</div>
)
}
export function Section({
label,
children,
id,
}: {
label: string
children: ReactNode
/** Optional dom id so the section-tabs strip can target it for
* smooth-scroll. Omit when the section isn't part of the tabbed
* table of contents. */
id?: string
}) {
return (
<section id={id} className="scroll-mt-4">
{label && (
<div className="mb-3">
<SectionLabel>{label}</SectionLabel>
</div>
)}
{children}
</section>
)
}
+137
View File
@@ -0,0 +1,137 @@
import { type ComponentType, type ReactNode } from 'react'
import * as RxSelect from '@radix-ui/react-select'
import { motion, AnimatePresence } from 'framer-motion'
import { Check, ChevronDown } from '../../lib/icons'
export interface SelectOption<V extends string = string> {
value: V
label: ReactNode
/** Optional left icon component (Tabler / lucide-shaped: { size, stroke }) */
icon?: ComponentType<{ size?: number; stroke?: number; className?: string }>
/** Subtle dim of the row, e.g. for non-content items like "All genres" */
muted?: boolean
}
interface Props<V extends string = string> {
value: V
onChange: (v: V) => void
options: SelectOption<V>[]
placeholder?: string
size?: 'sm' | 'md'
/** Optional leading icon shown in the trigger */
triggerIcon?: ReactNode
/** Tighten the trigger to fit narrow columns */
width?: string
/** Aria label for the trigger */
ariaLabel?: string
/** Render the dropdown portal inside this container instead of body */
portalContainer?: HTMLElement | null
disabled?: boolean
}
export default function Select<V extends string = string>({
value,
onChange,
options,
placeholder,
size = 'md',
triggerIcon,
width,
ariaLabel,
portalContainer,
disabled,
}: Props<V>) {
const trigSize =
size === 'sm'
? 'h-7 px-2.5 text-[11.5px] gap-1.5'
: 'h-9 px-3 text-[12.5px] gap-2'
const chevSize = size === 'sm' ? 12 : 14
const selected = options.find(o => o.value === value)
const SelectedIcon = selected?.icon
return (
<RxSelect.Root value={value} onValueChange={(v) => onChange(v as V)} disabled={disabled}>
<RxSelect.Trigger
aria-label={ariaLabel}
className={`group inline-flex items-center justify-between rounded-md bg-elevated/60 hover:bg-elevated border border-border hover:border-border-hover text-text-1 transition-all duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:border-accent/50 disabled:opacity-50 disabled:cursor-not-allowed ${trigSize} ${width ?? 'min-w-[8rem]'}`}
>
<span className="inline-flex items-center min-w-0 gap-1.5">
{triggerIcon && (
<span className="text-text-3 group-hover:text-text-1 transition-colors shrink-0">
{triggerIcon}
</span>
)}
{SelectedIcon && (
<SelectedIcon size={size === 'sm' ? 12 : 14} stroke={1.75} className="text-text-3 shrink-0" />
)}
<RxSelect.Value placeholder={<span className="text-text-3">{placeholder}</span>}>
<span className={`truncate font-medium ${selected?.muted ? 'text-text-3' : ''}`}>
{selected?.label ?? placeholder}
</span>
</RxSelect.Value>
</span>
<RxSelect.Icon className="text-text-3 group-hover:text-text-1 ml-2 shrink-0 transition-colors data-[state=open]:rotate-180">
<ChevronDown size={chevSize} stroke={2} />
</RxSelect.Icon>
</RxSelect.Trigger>
<RxSelect.Portal container={portalContainer ?? undefined}>
<RxSelect.Content
position="popper"
sideOffset={6}
align="start"
className="z-dropdown overflow-hidden rounded-lg border border-border-hover bg-glass-strong backdrop-blur-xl shadow-[0_24px_60px_-12px_rgba(0,0,0,0.7)] focus:outline-none origin-[var(--radix-select-content-transform-origin)]"
>
<AnimatePresence>
<motion.div
initial={{ opacity: 0, y: -4, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.16, ease: [0.16, 1, 0.3, 1] }}
>
<RxSelect.Viewport className="p-1.5 max-h-[min(420px,var(--radix-select-content-available-height))] min-w-[var(--radix-select-trigger-width)]">
{options.map(opt => (
<Item key={opt.value} option={opt} size={size} />
))}
</RxSelect.Viewport>
</motion.div>
</AnimatePresence>
</RxSelect.Content>
</RxSelect.Portal>
</RxSelect.Root>
)
}
function Item<V extends string = string>({
option,
size,
}: {
option: SelectOption<V>
size: 'sm' | 'md'
}) {
const { value, label, icon: Icon, muted } = option
const sizeCls =
size === 'sm'
? 'h-8 px-2 text-[12px] gap-2'
: 'h-9 px-2.5 text-[13px] gap-2.5'
const iconSize = size === 'sm' ? 14 : 15
return (
<RxSelect.Item
value={value}
className={`group relative flex items-center rounded-md cursor-pointer outline-none select-none transition-colors duration-100 data-[highlighted]:bg-glass-light data-[state=checked]:text-accent ${sizeCls} ${muted ? 'text-text-3' : 'text-text-1'}`}
>
<RxSelect.ItemIndicator className="absolute right-2 text-accent">
<Check size={iconSize - 2} stroke={2.5} />
</RxSelect.ItemIndicator>
{Icon && (
<span className="text-text-3 group-data-[highlighted]:text-text-1 group-data-[state=checked]:text-accent transition-colors shrink-0">
<Icon size={iconSize} stroke={1.75} />
</span>
)}
<RxSelect.ItemText asChild>
<span className="flex-1 min-w-0 truncate font-medium tracking-tight pr-6">{label}</span>
</RxSelect.ItemText>
</RxSelect.Item>
)
}
+95
View File
@@ -0,0 +1,95 @@
import { useMemo } from 'react'
import { useLibraryItems } from '../../hooks/use-jellyfin'
import ContentRow from './ContentRow'
import { Trash2 } from '../../lib/icons'
import { useSmartShelves, type SmartShelfRule } from '../../stores/smart-shelves-store'
interface Props {
rule: SmartShelfRule
}
/**
* Renders a single rule-based shelf on the home page. Translates the
* rule into Jellyfin /Items query params and pipes the result into
* ContentRow. The header carries a small delete button so the user can
* dismiss a shelf they no longer want.
*/
export default function SmartShelfRow({ rule }: Props) {
const remove = useSmartShelves(s => s.remove)
const years = useMemo(() => {
if (rule.yearMin == null && rule.yearMax == null) return undefined
const lo = rule.yearMin ?? 1900
const hi = rule.yearMax ?? new Date().getFullYear()
if (hi < lo) return [lo]
const out: number[] = []
for (let y = lo; y <= hi; y++) out.push(y)
return out
}, [rule.yearMin, rule.yearMax])
const sortBy =
rule.sortBy === 'random' ? ['Random']
: rule.sortBy === 'recent' ? ['DateCreated']
: rule.sortBy === 'rating' ? ['CommunityRating']
: ['SortName']
const sortOrder = rule.sortBy === 'name' ? ['Ascending'] : ['Descending']
const filters: string[] | undefined =
rule.watched === 'played' ? ['IsPlayed']
: rule.watched === 'unplayed' ? ['IsUnplayed']
: undefined
const includeItemTypes =
rule.type === 'movie' ? ['Movie']
: rule.type === 'series' ? ['Series']
: ['Movie', 'Series']
const { data } = useLibraryItems(undefined, {
includeItemTypes,
genres: rule.genres.length > 0 ? rule.genres : undefined,
years,
minCommunityRating: rule.minRating,
filters,
sortBy,
sortOrder,
limit: rule.limit,
})
const items = data?.Items || []
if (items.length === 0) return null
return (
<div className="relative group/shelf">
<ContentRow
title={rule.name}
subtitle={describeRule(rule)}
items={items}
layoutKey={`smart_${rule.id}`}
/>
<button
onClick={() => {
if (confirm(`Remove the "${rule.name}" smart shelf?`)) remove(rule.id)
}}
title="Remove this shelf"
className="absolute top-3 right-7 w-8 h-8 grid place-items-center rounded-full text-text-3 hover:text-red-300 hover:bg-elevated/80 opacity-0 group-hover/shelf:opacity-100 transition focus-ring"
>
<Trash2 size={13} stroke={2} />
</button>
</div>
)
}
function describeRule(rule: SmartShelfRule): string {
const parts: string[] = []
if (rule.type === 'movie') parts.push('Movies')
else if (rule.type === 'series') parts.push('Series')
if (rule.genres.length > 0) parts.push(rule.genres.slice(0, 3).join(', '))
if (rule.yearMin != null || rule.yearMax != null) {
parts.push(`${rule.yearMin ?? '...'}-${rule.yearMax ?? '...'}`)
}
if (rule.watched === 'unplayed') parts.push('unwatched')
else if (rule.watched === 'played') parts.push('watched')
if (rule.minRating) parts.push(`rating ≥ ${rule.minRating}`)
return parts.join(' · ')
}
+268
View File
@@ -0,0 +1,268 @@
import { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { X, Filter } from '../../lib/icons'
import { useSmartShelves, type SmartShelfRule } from '../../stores/smart-shelves-store'
interface Props {
open: boolean
onClose: () => void
}
const GENRE_CHOICES = [
'Action', 'Adventure', 'Animation', 'Comedy', 'Crime', 'Documentary', 'Drama',
'Family', 'Fantasy', 'History', 'Horror', 'Music', 'Mystery', 'Romance',
'Science Fiction', 'Thriller', 'War', 'Western',
]
/**
* Lightweight wizard for building a SmartShelfRule. Persists into the
* shelves store; the home page subscribes and renders one row per shelf.
*
* Inputs only what's actionable on the Jellyfin /Items endpoint to avoid
* client-side filtering surprises - mostly genres, year range, type,
* watched status, minimum rating, sort.
*/
export default function SmartShelfWizard({ open, onClose }: Props) {
const add = useSmartShelves(s => s.add)
const [draft, setDraft] = useState<Omit<SmartShelfRule, 'id' | 'createdAt'>>(emptyDraft())
function reset() {
setDraft(emptyDraft())
}
function save() {
if (!draft.name.trim()) return
add({ ...draft, name: draft.name.trim() })
reset()
onClose()
}
function toggleGenre(g: string) {
setDraft(d => ({
...d,
genres: d.genres.includes(g) ? d.genres.filter(x => x !== g) : [...d.genres, g],
}))
}
return (
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
onClick={onClose}
className="fixed inset-0 z-[80] grid place-items-center bg-black/65 backdrop-blur-sm p-6"
role="dialog"
>
<motion.div
initial={{ y: 24, scale: 0.96 }}
animate={{ y: 0, scale: 1 }}
exit={{ y: 24, scale: 0.96 }}
transition={{ duration: 0.32, ease: [0.16, 1, 0.3, 1] }}
onClick={e => e.stopPropagation()}
className="relative w-full max-w-xl rounded-2xl bg-surface ring-1 ring-border-strong shadow-[0_30px_80px_-20px_rgba(0,0,0,0.85)] max-h-[88vh] overflow-y-auto"
>
<header className="sticky top-0 z-10 flex items-center justify-between p-5 pb-3 bg-surface/95 backdrop-blur border-b border-border">
<div className="flex items-center gap-2">
<Filter size={16} className="text-accent" />
<h2 className="text-[16px] font-semibold tracking-tight text-text-1">
New smart shelf
</h2>
</div>
<button
onClick={onClose}
aria-label="Close"
className="w-8 h-8 grid place-items-center rounded-full text-text-3 hover:text-text-1 hover:bg-elevated transition focus-ring"
>
<X size={14} stroke={2} />
</button>
</header>
<div className="p-5 pt-4 space-y-5 text-[13px]">
<Field label="Name">
<input
type="text"
value={draft.name}
onChange={e => setDraft(d => ({ ...d, name: e.target.value }))}
placeholder='e.g. "Unwatched 4K HDR" or "1980s noir"'
className="w-full h-10 px-3 rounded-md bg-elevated/60 ring-1 ring-border focus:ring-accent/50 outline-none text-[13px] tracking-tight"
/>
</Field>
<Field label="Type">
<Segmented
value={draft.type}
onChange={v => setDraft(d => ({ ...d, type: v as any }))}
options={[
{ value: 'any', label: 'Any' },
{ value: 'movie', label: 'Movies' },
{ value: 'series', label: 'Series' },
]}
/>
</Field>
<Field label="Genres" hint="Pick any number; leave empty for any genre">
<div className="flex flex-wrap gap-1.5">
{GENRE_CHOICES.map(g => {
const on = draft.genres.includes(g)
return (
<button
key={g}
onClick={() => toggleGenre(g)}
className={`h-7 px-2.5 rounded-full text-[11.5px] tracking-tight transition border ${
on
? 'bg-accent/15 text-accent border-accent/40'
: 'bg-elevated/40 text-text-2 border-border hover:border-border-strong'
}`}
>
{g}
</button>
)
})}
</div>
</Field>
<Field label="Year range" hint="Inclusive on both ends">
<div className="flex items-center gap-2 max-w-xs">
<input
type="number"
value={draft.yearMin ?? ''}
onChange={e =>
setDraft(d => ({
...d,
yearMin: e.target.value ? Number(e.target.value) : undefined,
}))
}
placeholder="From"
className="flex-1 h-10 px-3 rounded-md bg-elevated/60 ring-1 ring-border focus:ring-accent/50 outline-none tabular-nums"
/>
<span className="text-text-4"></span>
<input
type="number"
value={draft.yearMax ?? ''}
onChange={e =>
setDraft(d => ({
...d,
yearMax: e.target.value ? Number(e.target.value) : undefined,
}))
}
placeholder="To"
className="flex-1 h-10 px-3 rounded-md bg-elevated/60 ring-1 ring-border focus:ring-accent/50 outline-none tabular-nums"
/>
</div>
</Field>
<Field label="Watched">
<Segmented
value={draft.watched}
onChange={v => setDraft(d => ({ ...d, watched: v as any }))}
options={[
{ value: 'any', label: 'Any' },
{ value: 'unplayed', label: 'Unwatched' },
{ value: 'played', label: 'Watched' },
]}
/>
</Field>
<Field label="Minimum rating" hint="Community rating; leave blank for any">
<input
type="number"
step="0.1"
min="0"
max="10"
value={draft.minRating ?? ''}
onChange={e =>
setDraft(d => ({
...d,
minRating: e.target.value ? Number(e.target.value) : undefined,
}))
}
placeholder="e.g. 7.5"
className="w-32 h-10 px-3 rounded-md bg-elevated/60 ring-1 ring-border focus:ring-accent/50 outline-none tabular-nums"
/>
</Field>
<Field label="Sort">
<Segmented
value={draft.sortBy}
onChange={v => setDraft(d => ({ ...d, sortBy: v as any }))}
options={[
{ value: 'random', label: 'Random' },
{ value: 'recent', label: 'Recent' },
{ value: 'rating', label: 'Rating' },
{ value: 'name', label: 'Name' },
]}
/>
</Field>
</div>
<footer className="sticky bottom-0 z-10 flex items-center justify-end gap-2 p-5 pt-3 bg-surface/95 backdrop-blur border-t border-border">
<button
onClick={onClose}
className="h-10 px-4 rounded-full text-[12.5px] text-text-2 hover:text-text-1 hover:bg-elevated transition focus-ring"
>
Cancel
</button>
<button
onClick={save}
disabled={!draft.name.trim()}
className="h-10 px-5 rounded-full bg-accent text-void text-[12.5px] font-semibold tracking-tight transition disabled:opacity-40 disabled:cursor-not-allowed hover:bg-accent-hover focus-ring"
>
Save shelf
</button>
</footer>
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}
function emptyDraft(): Omit<SmartShelfRule, 'id' | 'createdAt'> {
return {
name: '',
type: 'any',
genres: [],
watched: 'any',
sortBy: 'random',
limit: 18,
}
}
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
<div>
<label className="block text-[10.5px] uppercase tracking-[0.16em] font-semibold text-text-3 mb-1.5">
{label}
</label>
{children}
{hint && <p className="text-[11px] text-text-4 mt-1.5 leading-relaxed">{hint}</p>}
</div>
)
}
function Segmented<T extends string>({
value,
onChange,
options,
}: {
value: T
onChange: (v: T) => void
options: { value: T; label: string }[]
}) {
return (
<div className="inline-flex bg-elevated/40 ring-1 ring-border rounded-md p-0.5">
{options.map(o => (
<button
key={o.value}
onClick={() => onChange(o.value)}
className={`h-8 px-3 rounded text-[11.5px] font-medium tracking-tight transition focus-ring ${
value === o.value ? 'bg-accent text-void' : 'text-text-3 hover:text-text-1'
}`}
>
{o.label}
</button>
))}
</div>
)
}
+45
View File
@@ -0,0 +1,45 @@
import type { ComponentType } from 'react'
type IconType = ComponentType<{ size?: number; stroke?: number; className?: string }>
export type StatTileTone = 'default' | 'success' | 'cool' | 'amber' | 'error'
export interface StatTileData {
icon: IconType
label: string
value: string
tone?: StatTileTone
}
interface Props {
tile: StatTileData
}
/**
* A small "icon + label + value" card used in stat grids (Series Stats,
* Tech Specs, anywhere else summary numeric data needs visual interest).
*/
export default function StatTile({ tile }: Props) {
const Icon = tile.icon
const iconCls =
tile.tone === 'success' ? 'bg-success/10 text-success ring-success/20'
: tile.tone === 'cool' ? 'bg-cool/10 text-cool ring-cool/25'
: tile.tone === 'amber' ? 'bg-accent/12 text-accent ring-accent/25'
: tile.tone === 'error' ? 'bg-error/10 text-error ring-error/25'
: 'bg-glass-light text-text-2 ring-border'
return (
<div className="group relative flex items-center gap-3 rounded-lg p-3 bg-elevated/30 border border-border hover:border-border-hover hover:bg-elevated/50 transition-colors min-w-0">
<span className={`shrink-0 w-9 h-9 rounded-md grid place-items-center ring-1 ${iconCls}`}>
<Icon size={16} stroke={1.75} />
</span>
<div className="min-w-0">
<p className="text-[10px] uppercase tracking-[0.14em] font-semibold text-text-3 mb-0.5 leading-none truncate">
{tile.label}
</p>
<p className="text-[15px] text-text-1 font-bold tracking-tight tabular-nums font-display truncate leading-tight">
{tile.value}
</p>
</div>
</div>
)
}
+125
View File
@@ -0,0 +1,125 @@
import { useRef, useState, type ReactNode } from 'react'
import { motion, useMotionValue, useTransform, type PanInfo } from 'framer-motion'
interface Action {
label: string
icon?: ReactNode
/** Tailwind class set for the action button background. */
tone: 'accent' | 'success' | 'danger' | 'neutral'
onPress: () => void
}
interface Props {
actions: Action[]
children: ReactNode
/** Pixels each action consumes; total reveal = actions.length * width. */
actionWidth?: number
/** Optional: skip rendering the reveal entirely (e.g. on desktop where
* swipe gestures are foreign). */
enabled?: boolean
}
/**
* Wraps a row in a horizontally-draggable surface that slides left to
* reveal one or more action buttons in the right gutter. The actions
* snap into place when the user drags past 50% of the reveal width;
* otherwise the row springs back closed.
*
* Designed for touch + trackpad horizontal pan (framer-motion's drag
* gesture handles both). Pointer-down is suppressed when revealed so a
* tap on an action doesn't bubble up to the row's primary onClick.
*/
export default function SwipeReveal({
actions,
children,
actionWidth = 84,
enabled = true,
}: Props) {
const containerRef = useRef<HTMLDivElement>(null)
const x = useMotionValue(0)
const [open, setOpen] = useState(false)
const totalReveal = actions.length * actionWidth
function onDragEnd(_: any, info: PanInfo) {
const offset = info.offset.x + info.velocity.x * 0.05
if (offset < -totalReveal / 2) {
x.set(-totalReveal)
setOpen(true)
} else {
x.set(0)
setOpen(false)
}
}
const overlayOpacity = useTransform(x, [-totalReveal, 0], [1, 0])
if (!enabled || actions.length === 0) {
return <>{children}</>
}
return (
<div ref={containerRef} className="relative overflow-hidden">
{/* Action buttons in the right gutter - revealed by sliding the row left. */}
<div
className="absolute inset-y-0 right-0 flex"
style={{ width: totalReveal }}
aria-hidden={!open}
>
{actions.map((a, i) => (
<button
key={i}
type="button"
onClick={(e) => {
e.stopPropagation()
a.onPress()
x.set(0)
setOpen(false)
}}
className={`flex flex-col items-center justify-center gap-1 text-[10.5px] font-semibold uppercase tracking-[0.1em] text-white px-1 ${actionToneClass(a.tone)}`}
style={{ width: actionWidth }}
>
{a.icon}
{a.label}
</button>
))}
</div>
<motion.div
drag="x"
dragDirectionLock
dragConstraints={{ left: -totalReveal, right: 0 }}
dragElastic={0.1}
dragMomentum={false}
onDragEnd={onDragEnd}
// touch-action: pan-y tells the browser that vertical scroll
// should win over horizontal pan, so the user can scroll the
// episode list naturally without the swipe gesture stealing
// touches.
style={{ x, touchAction: 'pan-y' }}
animate={{ x: open ? -totalReveal : 0 }}
transition={{ type: 'spring', stiffness: 380, damping: 32 }}
className="relative bg-void"
>
{/* Subtle shadow + dim while peeking, fully solid when closed. */}
<motion.div
aria-hidden
className="absolute inset-0 pointer-events-none bg-black/15"
style={{ opacity: overlayOpacity }}
/>
{children}
</motion.div>
</div>
)
}
function actionToneClass(tone: Action['tone']): string {
switch (tone) {
case 'accent':
return 'bg-accent text-void'
case 'success':
return 'bg-emerald-600 hover:bg-emerald-500'
case 'danger':
return 'bg-red-600 hover:bg-red-500'
default:
return 'bg-elevated/80 hover:bg-elevated text-text-1'
}
}
+39
View File
@@ -0,0 +1,39 @@
import { motion, AnimatePresence } from 'framer-motion'
import { Check, AlertCircle, Info } from '../../lib/icons'
import { useToastStore } from '../../stores/toast-store'
/**
* Global toast outlet. Renders short-lived status messages at the bottom
* of the viewport. Mount once near the app root; everywhere else calls
* `toast(message, tone)` from `stores/toast-store`.
*/
export default function ToastHost() {
const toasts = useToastStore(s => s.toasts)
return (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-toast flex flex-col gap-2 items-center pointer-events-none">
<AnimatePresence>
{toasts.map(t => {
const Icon = t.tone === 'success' ? Check : t.tone === 'error' ? AlertCircle : Info
const tint =
t.tone === 'success' ? 'text-success' :
t.tone === 'error' ? 'text-error' :
'text-accent'
return (
<motion.div
key={t.id}
initial={{ opacity: 0, y: 12, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 12, scale: 0.97 }}
transition={{ duration: 0.22, ease: [0.16, 1, 0.3, 1] }}
className="inline-flex items-center gap-2 h-10 px-4 rounded-full bg-black/90 backdrop-blur-xl border border-white/12 shadow-2xl text-[12.5px] font-medium tracking-tight text-white pointer-events-auto"
role="status"
>
<Icon size={13} className={tint} />
<span>{t.message}</span>
</motion.div>
)
})}
</AnimatePresence>
</div>
)
}
+66
View File
@@ -0,0 +1,66 @@
import { useState } from 'react'
import { Bookmark, Check } from '../../lib/icons'
import { useWatchlist } from '../../hooks/use-watchlist'
interface Props {
itemId: string | null | undefined
/** Visual style. 'pill' for hero rows, 'icon' for compact corners. */
variant?: 'pill' | 'icon'
/** Disable when the item is a TMDB-only result (synthetic id). */
disabled?: boolean
}
/**
* Toggles an item in the user's "Watchlist" playlist. Optimistic so the
* UI flips on click without waiting for the server round-trip.
*/
export default function WatchlistButton({ itemId, variant = 'pill', disabled }: Props) {
const { isInWatchlist, toggle, isLoading } = useWatchlist()
const [busy, setBusy] = useState(false)
const inList = isInWatchlist(itemId)
async function onClick(e: React.MouseEvent) {
e.preventDefault()
e.stopPropagation()
if (!itemId || busy || disabled) return
setBusy(true)
try {
await toggle(itemId)
} finally {
setBusy(false)
}
}
if (variant === 'icon') {
return (
<button
onClick={onClick}
disabled={disabled || isLoading || !itemId || busy}
title={inList ? 'Remove from watchlist' : 'Add to watchlist'}
aria-label={inList ? 'Remove from watchlist' : 'Add to watchlist'}
className={`w-9 h-9 grid place-items-center rounded-full transition focus-ring ${
inList
? 'bg-accent text-void hover:bg-accent-hover'
: 'bg-white/10 text-white hover:bg-white/15 ring-1 ring-white/15'
} ${busy ? 'opacity-60' : ''}`}
>
{inList ? <Check size={14} stroke={2.5} /> : <Bookmark size={14} stroke={2} />}
</button>
)
}
return (
<button
onClick={onClick}
disabled={disabled || isLoading || !itemId || busy}
className={`inline-flex items-center gap-2 h-11 px-5 rounded-lg text-[13px] font-medium tracking-tight transition-all duration-200 focus-ring ${
inList
? 'bg-accent text-void hover:bg-accent-hover'
: 'bg-white/10 text-white border border-white/20 backdrop-blur hover:bg-white/15 hover:border-white/30 hover:scale-[1.02] active:scale-[0.98]'
} ${busy ? 'opacity-60' : ''}`}
>
{inList ? <Check size={14} stroke={2.5} /> : <Bookmark size={14} stroke={2} />}
{inList ? 'In watchlist' : 'Watchlist'}
</button>
)
}
+102
View File
@@ -0,0 +1,102 @@
import { useEffect } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { ArrowLeft, ExternalLink } from '../../lib/icons'
import { useYoutubeViewer } from '../../stores/youtube-viewer-store'
/**
* Full-window in-app YouTube player. Replaces the per-row
* `<a target="_blank">` opens for trailers / featurettes / behind-the-
* scenes clips so users don't get yanked into a browser. Uses the
* youtube-nocookie embed with full controls + sound enabled, sized to
* the viewport with a black backdrop and a Back button in the top-left.
*
* Esc closes. Clicking the dimmed area outside the player closes too.
*/
export default function YoutubeViewerModal() {
const open = useYoutubeViewer(s => s.open)
const videoKey = useYoutubeViewer(s => s.videoKey)
const title = useYoutubeViewer(s => s.title)
const subtitle = useYoutubeViewer(s => s.subtitle)
const close = useYoutubeViewer(s => s.close)
useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') close()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [open, close])
return (
<AnimatePresence>
{open && videoKey && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.22 }}
onClick={close}
className="fixed inset-0 z-[90] bg-black/95 backdrop-blur-md flex flex-col"
>
{/* Top bar: back + title + open-on-youtube link */}
<div
onClick={e => e.stopPropagation()}
className="shrink-0 flex items-center gap-3 px-5 py-3 border-b border-white/8"
>
<button
onClick={close}
aria-label="Back"
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-full bg-white/8 hover:bg-white/12 ring-1 ring-white/10 hover:ring-white/20 text-[12.5px] text-white font-medium tracking-tight transition focus-ring"
>
<ArrowLeft size={13} stroke={2} />
Back
</button>
<div className="min-w-0 flex-1">
<p className="text-[13.5px] text-white font-semibold tracking-tight truncate">{title || 'Video'}</p>
{subtitle && (
<p className="text-[11px] text-white/55 truncate uppercase tracking-[0.12em] font-medium">
{subtitle}
</p>
)}
</div>
<a
href={`https://www.youtube.com/watch?v=${videoKey}`}
target="_blank"
rel="noopener noreferrer"
onClick={e => e.stopPropagation()}
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-full text-[11.5px] text-white/65 hover:text-white hover:bg-white/8 transition focus-ring"
title="Open on YouTube"
>
YouTube
<ExternalLink size={11} stroke={2} />
</a>
</div>
{/* Player area - black to mask any letterbox the video itself
has. The iframe is centered and capped at 16:9 aspect with
the viewport as bound. */}
<motion.div
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.98 }}
transition={{ duration: 0.32, ease: [0.16, 1, 0.3, 1] }}
onClick={e => e.stopPropagation()}
className="flex-1 min-h-0 grid place-items-center p-4"
>
<div className="w-full h-full max-w-[min(100vw-32px,calc((100vh-160px)*16/9))] aspect-video rounded-xl overflow-hidden bg-black ring-1 ring-white/8 shadow-[0_30px_80px_-20px_rgba(0,0,0,0.85)]">
<iframe
key={videoKey}
src={`https://www.youtube-nocookie.com/embed/${videoKey}?autoplay=1&rel=0&modestbranding=1&iv_load_policy=3&playsinline=1`}
title={title}
allow="autoplay; encrypted-media; picture-in-picture; fullscreen"
allowFullScreen
className="w-full h-full border-0"
/>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}
+28
View File
@@ -0,0 +1,28 @@
/**
* Brand mark SVGs sourced from `simple-icons` (CC0 1.0 / public domain).
* The marks themselves are trademarks of their respective owners and are
* displayed here under nominative fair use to identify the underlying media
* format - the same way Plex, Apple TV, Kodi and Infuse do.
*
* Source: https://simpleicons.org (license: CC0 1.0 Universal)
*/
import type { SVGProps } from 'react'
export function DolbyMark(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="currentColor" aria-hidden {...props}>
<title>Dolby</title>
<path d="M0 3.564v16.872h2.488c4.648 0 8.438-3.788 8.438-8.436s-3.79-8.436-8.438-8.436H0zm21.512 0c-4.648 0-8.438 3.788-8.438 8.436s3.79 8.436 8.438 8.436H24V3.564h-2.488z" />
</svg>
)
}
export function DtsMark(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="currentColor" aria-hidden {...props}>
<title>DTS</title>
<path d="m23.556 14.346-1.194-1.173a.841.841 0 0 1 .604-1.445h.59a.346.346 0 0 0 .349-.343v-.636H18.97a1.492 1.492 0 0 0-1.507 1.477v.003c0 .396.16.775.444 1.05l1.201 1.18a.841.841 0 0 1-.604 1.446h-1.849a1.306 1.306 0 0 1-1.317-1.294v-2.876h1.135a.346.346 0 0 0 .35-.343v-.636h-1.485V7.587l-3.866 1.66v1.494h-1.87V7.123h-2.87a.986.986 0 0 0-.997.98v2.638H3.67C1.514 10.741 0 11.893 0 13.81c0 1.71 1.776 3.068 3.676 3.068h4.615a1.306 1.306 0 0 0 1.318-1.294v-3.855h1.863v2.503c0 1.423.874 2.646 2.65 2.646h8.371A1.492 1.492 0 0 0 24 15.4v-.003a1.444 1.444 0 0 0-.444-1.051zM5.729 15.683a.217.217 0 0 1-.219.214h-.13c-1.34 0-1.835-.908-1.85-2.088.015-1.216.525-2.088 1.85-2.088h.349v3.962z" />
</svg>
)
}