update
This commit is contained in:
163
resources/js/components/social/NotificationDropdown.jsx
Normal file
163
resources/js/components/social/NotificationDropdown.jsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
|
||||
export default function NotificationDropdown({ initialUnreadCount = 0, notificationsUrl = '/api/notifications' }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [items, setItems] = useState([])
|
||||
const [unreadCount, setUnreadCount] = useState(Number(initialUnreadCount || 0))
|
||||
const rootRef = useRef(null)
|
||||
const csrfToken = typeof document !== 'undefined'
|
||||
? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
|
||||
: null
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined
|
||||
|
||||
const onDocumentClick = (event) => {
|
||||
if (rootRef.current && !rootRef.current.contains(event.target)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', onDocumentClick)
|
||||
return () => document.removeEventListener('mousedown', onDocumentClick)
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || items.length > 0) return
|
||||
|
||||
setLoading(true)
|
||||
fetch(notificationsUrl, {
|
||||
headers: { Accept: 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!response.ok) throw new Error('Failed to load notifications')
|
||||
return response.json()
|
||||
})
|
||||
.then((payload) => {
|
||||
setItems(Array.isArray(payload?.data) ? payload.data : [])
|
||||
setUnreadCount(Number(payload?.unread_count || 0))
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [items.length, notificationsUrl, open])
|
||||
|
||||
const markAllRead = async () => {
|
||||
try {
|
||||
await fetch('/api/notifications/read-all', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken || '',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
setItems((current) => current.map((item) => ({ ...item, read: true })))
|
||||
setUnreadCount(0)
|
||||
} catch {
|
||||
// Keep current state on failure.
|
||||
}
|
||||
}
|
||||
|
||||
const markSingleRead = async (id) => {
|
||||
await fetch(`/api/notifications/${id}/read`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken || '',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
|
||||
setItems((current) => current.map((item) => item.id === id ? { ...item, read: true } : item))
|
||||
setUnreadCount((current) => Math.max(0, current - 1))
|
||||
}
|
||||
|
||||
const handleNotificationClick = async (event, item) => {
|
||||
if (!item?.id || item.read) return
|
||||
|
||||
const href = event.currentTarget.getAttribute('href')
|
||||
if (!href) return
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
try {
|
||||
await markSingleRead(item.id)
|
||||
} catch {
|
||||
// Continue to the destination even if marking as read fails.
|
||||
}
|
||||
|
||||
window.location.assign(href)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className="relative inline-flex h-10 w-10 items-center justify-center rounded-lg text-white/75 transition hover:bg-white/5 hover:text-white"
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M18 8a6 6 0 10-12 0c0 7-3 7-3 7h18s-3 0-3-7" />
|
||||
<path d="M13.7 21a2 2 0 01-3.4 0" />
|
||||
</svg>
|
||||
{unreadCount > 0 ? (
|
||||
<span className="absolute -bottom-1 right-0 rounded bg-red-700/80 px-1.5 py-0.5 text-[11px] font-semibold text-white border border-sb-line">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<div className="absolute right-0 mt-2 w-[22rem] overflow-hidden rounded-2xl border border-white/[0.08] bg-panel shadow-2xl shadow-black/35">
|
||||
<div className="flex items-center justify-between border-b border-white/[0.06] px-4 py-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white">Notifications</h3>
|
||||
<p className="text-xs text-white/35">Recent follows, likes, comments, and unlocks.</p>
|
||||
</div>
|
||||
{unreadCount > 0 ? (
|
||||
<button type="button" onClick={markAllRead} className="text-xs font-medium text-sky-300 transition hover:text-sky-200">
|
||||
Mark all read
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="max-h-[28rem] overflow-y-auto">
|
||||
{loading ? <div className="px-4 py-6 text-sm text-white/45">Loading notifications…</div> : null}
|
||||
{!loading && items.length === 0 ? <div className="px-4 py-6 text-sm text-white/45">No notifications yet.</div> : null}
|
||||
{!loading && items.map((item) => (
|
||||
<a
|
||||
key={item.id}
|
||||
href={item.url || '/dashboard/comments/received'}
|
||||
onClick={(event) => handleNotificationClick(event, item)}
|
||||
className={[
|
||||
'flex gap-3 border-b border-white/[0.05] px-4 py-3 transition hover:bg-white/[0.04]',
|
||||
item.read ? 'bg-transparent' : 'bg-sky-500/[0.06]',
|
||||
].join(' ')}
|
||||
>
|
||||
<img
|
||||
src={item.actor?.avatar_url || 'https://files.skinbase.org/default/avatar_default.webp'}
|
||||
alt={item.actor?.name || 'Notification'}
|
||||
className="h-10 w-10 rounded-full object-cover ring-1 ring-white/10"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm text-white/85">{item.message}</p>
|
||||
<p className="mt-1 text-xs uppercase tracking-[0.16em] text-white/30">{item.time_ago || ''}</p>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-white/[0.06] bg-white/[0.02] px-4 py-3">
|
||||
<a href="/dashboard/notifications" className="inline-flex items-center gap-2 text-sm font-medium text-sky-300 transition hover:text-sky-200">
|
||||
<i className="fa-solid fa-arrow-right" aria-hidden="true" />
|
||||
View all notifications
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user