Files
jellybloom/src/components/layout/AppHeader.tsx
T

472 lines
19 KiB
TypeScript

import { useEffect, useRef, useState } from 'react'
import { NavLink, useLocation, useNavigate } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion'
import {
Search,
ArrowLeft,
Pin,
PinFilled,
Minus,
Square,
RestoreDown,
X,
Bell,
Film,
Tv,
} from '../../lib/icons'
import { isTauri } from '../../lib/tauri'
import { jellyfinClient, getItemsApi, getImageUrl, getStoredServerUrl } 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[]>([])
const [lastOpenedAt, setLastOpenedAt] = useState<string | null>(() => {
try { return localStorage.getItem('jf_bell_opened') } catch { return null }
})
const notifsRef = useRef<HTMLDivElement>(null)
// Poll recently-added episodes + movies for the notification bell.
// Episodes are deduplicated by series so a full-season drop doesn't
// flood the list -- only the newest episode per series is shown.
useEffect(() => {
const api = jellyfinClient.getApi()
if (!api) return
async function poll() {
try {
const auth = jellyfinClient.getAuthState()
if (!auth?.userId) return
const [movieRes, episodeRes] = await Promise.all([
getItemsApi(api!).getItems({
userId: auth.userId,
sortBy: ['DateCreated'],
sortOrder: ['Descending'],
limit: 10,
recursive: true,
includeItemTypes: ['Movie'],
fields: ['DateCreated', 'PrimaryImageAspectRatio', 'ImageTags'] as any[],
}),
getItemsApi(api!).getItems({
userId: auth.userId,
sortBy: ['DateCreated'],
sortOrder: ['Descending'],
limit: 50,
recursive: true,
includeItemTypes: ['Episode'],
fields: ['DateCreated', 'PrimaryImageAspectRatio', 'ImageTags', 'SeriesName', 'SeriesId', 'SeriesPrimaryImageTag', 'ParentIndexNumber', 'IndexNumber'] as any[],
}),
])
const movies = movieRes.data.Items || []
const episodes = episodeRes.data.Items || []
// Deduplicate episodes by series -- keep only the newest per show
const seenSeries = new Set<string>()
const dedupedEpisodes: any[] = []
for (const ep of episodes) {
const sid = ep.SeriesId || ep.SeriesName
if (!sid || seenSeries.has(sid)) continue
seenSeries.add(sid)
dedupedEpisodes.push(ep)
}
// Interleave: one movie, one episode, one movie... capped at 15
const interleaved: any[] = []
let m = 0, e = 0
while (interleaved.length < 15 && (m < movies.length || e < dedupedEpisodes.length)) {
if (m < movies.length) interleaved.push(movies[m++])
if (interleaved.length >= 15) break
if (e < dedupedEpisodes.length) interleaved.push(dedupedEpisodes[e++])
}
setNotifs(interleaved)
} catch { /* ignore */ }
}
poll()
const id = setInterval(poll, 60_000)
return () => clearInterval(id)
}, [])
// Click outside to close
useEffect(() => {
if (!notifsOpen) return
function onDocClick(e: MouseEvent) {
if (!notifsRef.current?.contains(e.target as Node)) {
setNotifsOpen(false)
}
}
document.addEventListener('mousedown', onDocClick)
return () => document.removeEventListener('mousedown', onDocClick)
}, [notifsOpen])
const hasUnread = notifs.some(n => {
const created = n.DateCreated
if (!created) return false
if (!lastOpenedAt) return true
return created > lastOpenedAt
})
// 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
import('@tauri-apps/api/window').then(({ getCurrentWindow }) => {
if (cancelled) return
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
import('@tauri-apps/api/window').then(({ getCurrentWindow }) => {
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 relative 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">
JELLYBLOOM
</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 — new releases only */}
<div ref={notifsRef} className="relative self-center mr-2">
<button
onClick={() => {
setNotifsOpen(o => {
const next = !o
if (next) {
const now = new Date().toISOString()
setLastOpenedAt(now)
try { localStorage.setItem('jf_bell_opened', now) } catch { /* noop */ }
}
return next
})
}}
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="New releases"
>
<Bell size={13} stroke={2} />
{hasUnread && (
<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-80 rounded-xl bg-glass-strong backdrop-blur-2xl border border-white/10 shadow-[0_20px_60px_-15px_rgba(0,0,0,0.85)] z-[9999] overflow-hidden"
>
<div className="px-3 py-2.5 border-b border-white/8 flex items-center justify-between">
<p className="text-[11px] font-semibold text-text-1 tracking-tight">New releases</p>
{notifs.length > 0 && (
<span className="text-[10px] text-text-4 tabular-nums">{notifs.length} new</span>
)}
</div>
<div className="max-h-72 overflow-y-auto content-scroll">
{notifs.length === 0 ? (
<p className="px-3 py-4 text-[11.5px] text-text-3 text-center">Nothing new yet</p>
) : (
notifs.map((entry: any) => {
const isUnread = !lastOpenedAt || (entry.DateCreated && entry.DateCreated > lastOpenedAt)
const isEpisode = entry.Type === 'Episode'
const seriesName = entry.SeriesName
const season = entry.ParentIndexNumber
const epNum = entry.IndexNumber
const epLabel = isEpisode && (season != null || epNum != null)
? `S${season ?? '?'}E${epNum ?? '?'}${entry.Name ? ` · ${entry.Name}` : ''}`
: null
// Poster thumbnail: series poster for episodes, movie poster for movies
const serverUrl = getStoredServerUrl()
const posterUrl = (() => {
if (!serverUrl) return null
if (isEpisode && entry.SeriesId && entry.SeriesPrimaryImageTag) {
return getImageUrl(serverUrl, entry.SeriesId, 'Primary', 160, entry.SeriesPrimaryImageTag)
}
if (entry.ImageTags?.Primary) {
return getImageUrl(serverUrl, entry.Id, 'Primary', 160, entry.ImageTags.Primary)
}
return null
})()
return (
<button
key={entry.Id}
onClick={() => {
if (entry.Id) {
navigate(`/item/${entry.Id}`)
setNotifsOpen(false)
}
}}
className={`w-full text-left px-3 py-2.5 text-[11.5px] border-b border-white/5 last:border-0 hover:bg-white/4 transition-colors flex items-start gap-3 ${isUnread ? 'bg-white/[0.03]' : ''}`}
>
{posterUrl ? (
<img
src={posterUrl}
alt=""
className="w-8 aspect-[2/3] rounded object-cover shrink-0 bg-void"
loading="lazy"
/>
) : isEpisode ? (
<Tv size={13} className="text-accent shrink-0 mt-0.5" />
) : (
<Film size={13} className="text-accent shrink-0 mt-0.5" />
)}
<div className="min-w-0 flex-1">
{isEpisode && seriesName ? (
<>
<p className="text-text-2 truncate font-medium">{seriesName}</p>
{epLabel && (
<p className="text-text-3 truncate">{epLabel}</p>
)}
</>
) : (
<p className="text-text-2 truncate font-medium">{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' }) : ''}
{entry.ProductionYear && !isEpisode ? ` · ${entry.ProductionYear}` : ''}
</p>
</div>
{isUnread && (
<span className="w-1.5 h-1.5 rounded-full bg-accent shrink-0 mt-1.5" />
)}
</button>
)
})
)}
</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>
)
}