notification bell shows new releases only, with unread tracking and click-outside close
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { NavLink, useLocation, useNavigate } from 'react-router-dom'
|
import { NavLink, useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
@@ -12,9 +12,11 @@ import {
|
|||||||
RestoreDown,
|
RestoreDown,
|
||||||
X,
|
X,
|
||||||
Bell,
|
Bell,
|
||||||
|
Film,
|
||||||
|
Tv,
|
||||||
} from '../../lib/icons'
|
} from '../../lib/icons'
|
||||||
import { isTauri } from '../../lib/tauri'
|
import { isTauri } from '../../lib/tauri'
|
||||||
import { jellyfinClient, getActivityLogApi } from '../../api/jellyfin'
|
import { jellyfinClient, getItemsApi } from '../../api/jellyfin'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The single chrome bar at the top of the in-app shell. Replaces the
|
* The single chrome bar at the top of the in-app shell. Replaces the
|
||||||
@@ -62,14 +64,28 @@ export default function AppHeader({ pinned, onTogglePin }: Props) {
|
|||||||
const [maximized, setMaximized] = useState(false)
|
const [maximized, setMaximized] = useState(false)
|
||||||
const [notifsOpen, setNotifsOpen] = useState(false)
|
const [notifsOpen, setNotifsOpen] = useState(false)
|
||||||
const [notifs, setNotifs] = useState<any[]>([])
|
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 activity log for the notification bell
|
// Poll recently-added items for the notification bell
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const api = jellyfinClient.getApi()
|
const api = jellyfinClient.getApi()
|
||||||
if (!api) return
|
if (!api) return
|
||||||
async function poll() {
|
async function poll() {
|
||||||
try {
|
try {
|
||||||
const res = await getActivityLogApi(api!).getLogEntries({ limit: 8 })
|
const auth = jellyfinClient.getAuthState()
|
||||||
|
if (!auth?.userId) return
|
||||||
|
const res = await getItemsApi(api!).getItems({
|
||||||
|
userId: auth.userId,
|
||||||
|
sortBy: ['DateCreated'],
|
||||||
|
sortOrder: ['Descending'],
|
||||||
|
limit: 15,
|
||||||
|
recursive: true,
|
||||||
|
includeItemTypes: ['Movie', 'Series', 'Episode'],
|
||||||
|
fields: ['DateCreated', 'PrimaryImageAspectRatio'],
|
||||||
|
} as any)
|
||||||
setNotifs(res.data.Items || [])
|
setNotifs(res.data.Items || [])
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
@@ -78,6 +94,25 @@ export default function AppHeader({ pinned, onTogglePin }: Props) {
|
|||||||
return () => clearInterval(id)
|
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
|
// Subtle background fade in when the main pane is scrolled - same UX
|
||||||
// affordance the old TopBar had, just on the unified header.
|
// affordance the old TopBar had, just on the unified header.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -247,15 +282,25 @@ export default function AppHeader({ pinned, onTogglePin }: Props) {
|
|||||||
</kbd>
|
</kbd>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Notification bell */}
|
{/* Notification bell — new releases only */}
|
||||||
<div className="relative self-center mr-2">
|
<div ref={notifsRef} className="relative self-center mr-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setNotifsOpen(o => !o)}
|
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"
|
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"
|
aria-label="New releases"
|
||||||
>
|
>
|
||||||
<Bell size={13} stroke={2} />
|
<Bell size={13} stroke={2} />
|
||||||
{notifs.length > 0 && (
|
{hasUnread && (
|
||||||
<span className="absolute top-0.5 right-0.5 w-1.5 h-1.5 rounded-full bg-accent" />
|
<span className="absolute top-0.5 right-0.5 w-1.5 h-1.5 rounded-full bg-accent" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@@ -266,26 +311,49 @@ export default function AppHeader({ pinned, onTogglePin }: Props) {
|
|||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
exit={{ opacity: 0, y: 6, scale: 0.98 }}
|
exit={{ opacity: 0, y: 6, scale: 0.98 }}
|
||||||
transition={{ duration: 0.18 }}
|
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"
|
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-modal overflow-hidden"
|
||||||
>
|
>
|
||||||
<div className="px-3 py-2.5 border-b border-white/8">
|
<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">Recent activity</p>
|
<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>
|
||||||
<div className="max-h-64 overflow-y-auto content-scroll">
|
<div className="max-h-72 overflow-y-auto content-scroll">
|
||||||
{notifs.length === 0 ? (
|
{notifs.length === 0 ? (
|
||||||
<p className="px-3 py-4 text-[11.5px] text-text-3 text-center">No recent activity</p>
|
<p className="px-3 py-4 text-[11.5px] text-text-3 text-center">Nothing new yet</p>
|
||||||
) : (
|
) : (
|
||||||
notifs.map((entry: any, i: number) => (
|
notifs.map((entry: any) => {
|
||||||
<div
|
const isUnread = !lastOpenedAt || (entry.DateCreated && entry.DateCreated > lastOpenedAt)
|
||||||
key={entry.Id || i}
|
return (
|
||||||
className="px-3 py-2 text-[11.5px] border-b border-white/5 last:border-0 hover:bg-white/4 transition-colors"
|
<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-center gap-2.5 ${isUnread ? 'bg-white/[0.03]' : ''}`}
|
||||||
>
|
>
|
||||||
<p className="text-text-2 truncate">{entry.Name}</p>
|
{entry.Type === 'Series' || entry.Type === 'Episode' ? (
|
||||||
|
<Tv size={13} className="text-accent shrink-0" />
|
||||||
|
) : (
|
||||||
|
<Film size={13} className="text-accent shrink-0" />
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-text-2 truncate font-medium">{entry.Name}</p>
|
||||||
<p className="text-text-4 tabular-nums mt-0.5">
|
<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.DateCreated ? new Date(entry.DateCreated).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : ''}
|
||||||
|
{entry.ProductionYear ? ` · ${entry.ProductionYear}` : ''}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))
|
{isUnread && (
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-accent shrink-0" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
Reference in New Issue
Block a user