store/resources/js/components/NotificationBell.vue

292 lines
10 KiB
Vue

<script setup lang="ts">
import { router } from '@inertiajs/vue3';
import { Bell, CheckCheck, Eye, Trash2, X } from '@lucide/vue';
import { onMounted, onUnmounted, ref } from 'vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
interface NotificationItem {
id: number;
title: string;
body: string | null;
url: string | null;
is_read: boolean;
created_at: string;
}
const notifications = ref<NotificationItem[]>([]);
const unreadCount = ref(0);
const isOpen = ref(false);
let interval: ReturnType<typeof setInterval> | null = null;
function csrfToken(): string {
return (
document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement
)?.content ?? '';
}
async function fetchNotifications(): Promise<void> {
try {
const response = await fetch('/notifications', {
headers: { 'X-Requested-With': 'XMLHttpRequest' },
});
if (response.ok) {
const data = (await response.json()) as {
notifications: NotificationItem[];
unread_count: number;
};
notifications.value = data.notifications;
unreadCount.value = data.unread_count;
}
} catch {
// silent
}
}
async function markAsRead(notification: NotificationItem): Promise<void> {
if (notification.is_read) {
return;
}
try {
await fetch(`/notifications/${notification.id}/read`, {
method: 'PATCH',
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
},
});
notification.is_read = true;
unreadCount.value = Math.max(0, unreadCount.value - 1);
} catch {
// silent
}
}
function openNotification(notification: NotificationItem): void {
if (notification.url) {
isOpen.value = false;
router.visit(notification.url);
}
}
async function deleteNotification(id: number): Promise<void> {
try {
await fetch(`/notifications/${id}`, {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
},
});
const idx = notifications.value.findIndex((n) => n.id === id);
if (idx !== -1) {
if (!notifications.value[idx].is_read) {
unreadCount.value = Math.max(0, unreadCount.value - 1);
}
notifications.value.splice(idx, 1);
}
} catch {
// silent
}
}
async function markAllAsRead(): Promise<void> {
try {
await fetch('/notifications/read-all', {
method: 'PATCH',
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
},
});
notifications.value.forEach((n) => {
n.is_read = true;
});
unreadCount.value = 0;
} catch {
// silent
}
}
async function deleteAll(): Promise<void> {
try {
await fetch('/notifications', {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
},
});
notifications.value = [];
unreadCount.value = 0;
} catch {
// silent
}
}
function timeAgo(dateStr: string): string {
const now = Date.now();
const then = new Date(dateStr).getTime();
const seconds = Math.floor((now - then) / 1000);
if (seconds < 60) {
return 'Baru saja';
}
if (seconds < 3600) {
return `${Math.floor(seconds / 60)}m lalu`;
}
if (seconds < 86400) {
return `${Math.floor(seconds / 3600)}j lalu`;
}
return `${Math.floor(seconds / 86400)}h lalu`;
}
onMounted(() => {
void fetchNotifications();
interval = setInterval(() => void fetchNotifications(), 30000);
});
onUnmounted(() => {
if (interval) {
clearInterval(interval);
}
});
</script>
<template>
<TooltipProvider>
<Popover v-model:open="isOpen">
<PopoverTrigger as-child>
<Button variant="ghost" size="icon" class="relative size-8">
<Bell class="size-4" />
<Badge
v-if="unreadCount > 0"
variant="destructive"
class="absolute -top-1 -right-1 flex size-4 items-center justify-center rounded-full p-0 text-[10px]"
>
{{ unreadCount > 99 ? '99+' : unreadCount }}
</Badge>
</Button>
</PopoverTrigger>
<PopoverContent align="end" :side-offset="8" class="w-80 p-0">
<!-- Header -->
<div class="flex items-center justify-between border-b px-4 py-3">
<h4 class="text-sm font-semibold">Notifikasi</h4>
<div class="flex items-center gap-1">
<Tooltip v-if="unreadCount > 0">
<TooltipTrigger as-child>
<button
class="flex items-center rounded p-1 text-muted-foreground hover:text-foreground"
@click="markAllAsRead"
>
<CheckCheck class="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Tandai semua dibaca</TooltipContent>
</Tooltip>
<Tooltip v-if="notifications.length > 0">
<TooltipTrigger as-child>
<button
class="flex items-center rounded p-1 text-muted-foreground hover:text-destructive"
@click="deleteAll"
>
<Trash2 class="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Hapus semua</TooltipContent>
</Tooltip>
</div>
</div>
<!-- List -->
<div class="max-h-80 overflow-y-auto">
<div
v-if="notifications.length === 0"
class="px-4 py-8 text-center text-sm text-muted-foreground"
>
Tidak ada notifikasi
</div>
<div
v-for="notification in notifications"
:key="notification.id"
class="group flex items-start gap-3 border-b px-4 py-3 transition-colors hover:bg-accent"
:class="{ 'bg-primary/5': !notification.is_read }"
>
<!-- Unread dot -->
<div
class="mt-1.5 size-2 shrink-0 rounded-full"
:class="notification.is_read ? 'bg-transparent' : 'bg-primary'"
/>
<!-- Content -->
<div
class="min-w-0 flex-1 cursor-pointer"
@click="openNotification(notification)"
>
<p class="text-sm font-medium leading-tight">
{{ notification.title }}
</p>
<p
v-if="notification.body"
class="mt-0.5 text-xs leading-snug text-muted-foreground line-clamp-2"
>
{{ notification.body }}
</p>
<p class="mt-1 text-[10px] text-muted-foreground">
{{ timeAgo(notification.created_at) }}
</p>
</div>
<!-- Actions -->
<div
class="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100"
>
<Tooltip v-if="!notification.is_read">
<TooltipTrigger as-child>
<button
class="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
@click.stop="markAsRead(notification)"
>
<Eye class="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Tandai dibaca</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button
class="rounded p-1 text-muted-foreground hover:bg-muted hover:text-destructive"
@click.stop="deleteNotification(notification.id)"
>
<X class="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Hapus</TooltipContent>
</Tooltip>
</div>
</div>
</div>
</PopoverContent>
</Popover>
</TooltipProvider>
</template>