shared ui: poster cards, content rows, scrollers, lazy mount
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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(' · ')
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user