275 lines
9.6 KiB
Vue
275 lines
9.6 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.is_read) {
|
|
void markAsRead(notification);
|
|
}
|
|
|
|
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 class="z-[300]">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 class="z-[300]">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">
|
|
{{ 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 class="z-[300]">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 class="z-[300]">Hapus</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</TooltipProvider>
|
|
</template>
|