147 lines
5.0 KiB
Vue
147 lines
5.0 KiB
Vue
|
|
<script setup lang="ts">
|
||
|
|
import { computed } from 'vue';
|
||
|
|
import { router, usePage } from '@inertiajs/vue3';
|
||
|
|
import { Bell } from 'lucide-vue-next';
|
||
|
|
import { Button } from '@/components/ui/button';
|
||
|
|
import {
|
||
|
|
DropdownMenu,
|
||
|
|
DropdownMenuContent,
|
||
|
|
DropdownMenuItem,
|
||
|
|
DropdownMenuLabel,
|
||
|
|
DropdownMenuSeparator,
|
||
|
|
DropdownMenuTrigger,
|
||
|
|
} from '@/components/ui/dropdown-menu';
|
||
|
|
|
||
|
|
type NotificationItem = {
|
||
|
|
id: string;
|
||
|
|
type: string;
|
||
|
|
data: {
|
||
|
|
folder_id?: number;
|
||
|
|
folder_title?: string;
|
||
|
|
mentioned_by_name?: string;
|
||
|
|
message?: string;
|
||
|
|
url?: string;
|
||
|
|
};
|
||
|
|
read_at: string | null;
|
||
|
|
created_at: string;
|
||
|
|
};
|
||
|
|
|
||
|
|
type UserNotifications = {
|
||
|
|
unread_count: number;
|
||
|
|
readUrl: string | null;
|
||
|
|
readAllUrl: string | null;
|
||
|
|
items: NotificationItem[] | undefined;
|
||
|
|
};
|
||
|
|
|
||
|
|
const page = usePage();
|
||
|
|
|
||
|
|
const userNotifications = computed<UserNotifications>(() => {
|
||
|
|
return (page.props as Record<string, unknown>).userNotifications as UserNotifications;
|
||
|
|
});
|
||
|
|
|
||
|
|
const unreadCount = computed(() => userNotifications.value?.unread_count ?? 0);
|
||
|
|
const items = computed(() => userNotifications.value?.items ?? []);
|
||
|
|
const isLoading = computed(() => userNotifications.value?.items === undefined);
|
||
|
|
|
||
|
|
function markAsRead(notification: NotificationItem) {
|
||
|
|
const readUrl = userNotifications.value?.readUrl;
|
||
|
|
if (!readUrl) return;
|
||
|
|
|
||
|
|
// Replace __ID__ placeholder with actual notification ID
|
||
|
|
// This is a convention: the server provides a URL template with __ID__ as placeholder
|
||
|
|
const url = readUrl.replace('__ID__', notification.id);
|
||
|
|
router.post(url, {}, { preserveScroll: true });
|
||
|
|
}
|
||
|
|
|
||
|
|
function navigateToNotification(notification: NotificationItem) {
|
||
|
|
const targetUrl = notification.data?.url;
|
||
|
|
if (!targetUrl) return;
|
||
|
|
|
||
|
|
if (!notification.read_at) {
|
||
|
|
markAsRead(notification);
|
||
|
|
}
|
||
|
|
|
||
|
|
router.visit(targetUrl, {
|
||
|
|
onError: () => {
|
||
|
|
// Folder may have been deleted — mark as read anyway
|
||
|
|
if (!notification.read_at) {
|
||
|
|
markAsRead(notification);
|
||
|
|
}
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function markAllAsRead() {
|
||
|
|
const readAllUrl = userNotifications.value?.readAllUrl;
|
||
|
|
if (!readAllUrl) return;
|
||
|
|
|
||
|
|
router.post(readAllUrl, {}, { preserveScroll: true });
|
||
|
|
}
|
||
|
|
</script>
|
||
|
|
|
||
|
|
<template>
|
||
|
|
<DropdownMenu>
|
||
|
|
<DropdownMenuTrigger as-child>
|
||
|
|
<Button variant="ghost" size="icon" class="relative">
|
||
|
|
<Bell class="size-4" />
|
||
|
|
<span
|
||
|
|
v-if="unreadCount > 0"
|
||
|
|
class="absolute -right-0.5 -top-0.5 flex size-4 items-center justify-center rounded-full bg-destructive text-[10px] font-bold text-destructive-foreground"
|
||
|
|
>
|
||
|
|
{{ unreadCount > 9 ? '9+' : unreadCount }}
|
||
|
|
</span>
|
||
|
|
</Button>
|
||
|
|
</DropdownMenuTrigger>
|
||
|
|
<DropdownMenuContent align="end" class="w-80">
|
||
|
|
<DropdownMenuLabel>Notifications</DropdownMenuLabel>
|
||
|
|
<DropdownMenuSeparator />
|
||
|
|
|
||
|
|
<div v-if="isLoading" class="px-2 py-4 text-center text-sm text-muted-foreground">
|
||
|
|
Chargement...
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div v-else-if="!items.length" class="px-2 py-4 text-center text-sm text-muted-foreground">
|
||
|
|
Aucune notification.
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<template v-else>
|
||
|
|
<DropdownMenuItem
|
||
|
|
v-for="notification in items"
|
||
|
|
:key="notification.id"
|
||
|
|
class="flex cursor-pointer flex-col items-start gap-1 p-3"
|
||
|
|
:class="{ 'opacity-50': notification.read_at }"
|
||
|
|
@click="navigateToNotification(notification)"
|
||
|
|
>
|
||
|
|
<div class="flex w-full items-center justify-between gap-2">
|
||
|
|
<span class="text-xs font-medium">
|
||
|
|
{{ notification.data?.mentioned_by_name ?? 'Système' }}
|
||
|
|
</span>
|
||
|
|
<span class="text-xs text-muted-foreground">
|
||
|
|
{{ notification.created_at }}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
<p class="text-xs text-muted-foreground">
|
||
|
|
<span v-if="notification.data?.folder_title" class="font-medium text-foreground">
|
||
|
|
{{ notification.data.folder_title }}
|
||
|
|
</span>
|
||
|
|
{{ notification.data?.message ? ` — ${notification.data.message}` : '' }}
|
||
|
|
</p>
|
||
|
|
<span
|
||
|
|
v-if="!notification.read_at"
|
||
|
|
class="size-1.5 rounded-full bg-primary"
|
||
|
|
/>
|
||
|
|
</DropdownMenuItem>
|
||
|
|
</template>
|
||
|
|
|
||
|
|
<DropdownMenuSeparator v-if="items.length > 0" />
|
||
|
|
<DropdownMenuItem
|
||
|
|
v-if="unreadCount > 0"
|
||
|
|
class="justify-center text-xs text-muted-foreground"
|
||
|
|
@click="markAllAsRead"
|
||
|
|
>
|
||
|
|
Marquer tout comme lu
|
||
|
|
</DropdownMenuItem>
|
||
|
|
</DropdownMenuContent>
|
||
|
|
</DropdownMenu>
|
||
|
|
</template>
|