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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user