This commit is contained in:
135
frontend/src/components/NotificationBell.jsx
Normal file
135
frontend/src/components/NotificationBell.jsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||
import {
|
||||
$isRealtimeConnected,
|
||||
$notifications,
|
||||
$unreadNotifications,
|
||||
connectNotifications,
|
||||
fetchNotifications,
|
||||
markAllNotificationsAsRead,
|
||||
markNotificationAsRead,
|
||||
} from '../stores/notificationStore';
|
||||
import { $accessToken, $user } from '../stores/authStore';
|
||||
import NotificationDetailModal from './notifications/NotificationDetailModal';
|
||||
|
||||
function formatTimestamp(value) {
|
||||
if (!value) return '';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
return new Intl.DateTimeFormat('hr-HR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export default function NotificationBell() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedNotification, setSelectedNotification] = useState(null);
|
||||
const notifications = useStore($notifications);
|
||||
const unread = useStore($unreadNotifications);
|
||||
const isConnected = useStore($isRealtimeConnected);
|
||||
const token = useStore($accessToken);
|
||||
const user = useStore($user);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const latestNotifications = useMemo(
|
||||
() => notifications.slice(0, 6),
|
||||
[notifications]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !user?.id) return;
|
||||
fetchNotifications();
|
||||
connectNotifications();
|
||||
}, [token, user?.id]);
|
||||
|
||||
// Stabilan placeholder za SSR + prvi client render.
|
||||
if (!mounted || !token) {
|
||||
return <div className="h-10 w-10" aria-hidden="true" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
className="relative inline-flex h-10 w-10 items-center justify-center rounded-lg border border-border-hairline bg-canvas-base text-text-muted hover:bg-canvas-deep"
|
||||
aria-label="Notifikacije"
|
||||
>
|
||||
<span className="text-lg">🔔</span>
|
||||
{unread > 0 && (
|
||||
<span className="absolute -right-1 -top-1 inline-flex min-h-5 min-w-5 items-center justify-center rounded-full bg-red-600 px-1 text-xs font-bold text-white">
|
||||
{unread > 99 ? '99+' : unread}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 z-30 mt-2 w-96 overflow-hidden rounded-xl border border-border-hairline bg-canvas-elevated shadow-lg">
|
||||
<div className="flex items-center justify-between border-b border-border-hairline px-4 py-3">
|
||||
<div>
|
||||
<p className="font-semibold text-text-main">Notifikacije</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
Realtime: {isConnected ? 'spojen' : 'nije spojen'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => markAllNotificationsAsRead()}
|
||||
className="text-xs font-medium text-indigo-600 hover:text-indigo-800"
|
||||
>
|
||||
Označi sve
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul className="max-h-96 overflow-y-auto">
|
||||
{latestNotifications.length === 0 && (
|
||||
<li className="px-4 py-6 text-sm text-text-muted">Nema notifikacija.</li>
|
||||
)}
|
||||
{latestNotifications.map((notification) => (
|
||||
<li
|
||||
key={notification.id}
|
||||
className={`cursor-pointer border-b border-border-hairline px-4 py-3 last:border-b-0 ${
|
||||
notification.is_read ? 'bg-canvas-elevated' : 'bg-indigo-50/30'
|
||||
}`}
|
||||
onClick={async () => {
|
||||
if (!notification.is_read) {
|
||||
try {
|
||||
await markNotificationAsRead(notification.id);
|
||||
} catch {
|
||||
// Error handling already done in store toast.
|
||||
}
|
||||
}
|
||||
setSelectedNotification(notification);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-sm font-semibold text-text-main">
|
||||
{notification.title}
|
||||
</p>
|
||||
<span className="shrink-0 text-[11px] text-text-muted">
|
||||
{formatTimestamp(notification.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-text-muted">{notification.message}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<NotificationDetailModal
|
||||
open={!!selectedNotification}
|
||||
notification={selectedNotification}
|
||||
onClose={() => setSelectedNotification(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user