All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 43s
- Add reminder action to NoteActions (masonry view) and NoteMetaSidebar (tabs view) - NotificationPanel now fetches and displays upcoming/overdue reminders alongside share requests - Badge count includes overdue reminders; overdue items show mark-as-done toggle - Sidebar trash count refreshes on NoteRefreshContext trigger (no more manual refresh) - Add "Clear completed" button on reminders page with clearCompletedReminders action - Add i18n keys for new features (en/fr) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
294 lines
11 KiB
TypeScript
294 lines
11 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useCallback } from 'react'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Bell, Check, X, Clock, AlertCircle, CheckCircle2, Circle, Share2 } from 'lucide-react'
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from '@/components/ui/popover'
|
|
import { getPendingShareRequests, respondToShareRequest, getNotesWithReminders, toggleReminderDone } from '@/app/actions/notes'
|
|
import { toast } from 'sonner'
|
|
import { useNoteRefreshOptional } from '@/context/NoteRefreshContext'
|
|
import { cn } from '@/lib/utils'
|
|
import { useLanguage } from '@/lib/i18n'
|
|
import { formatDistanceToNow } from 'date-fns'
|
|
|
|
interface ShareRequest {
|
|
id: string
|
|
status: string
|
|
permission: string
|
|
createdAt: Date
|
|
note: {
|
|
id: string
|
|
title: string | null
|
|
content: string
|
|
color: string
|
|
createdAt: Date
|
|
}
|
|
sharer: {
|
|
id: string
|
|
name: string | null
|
|
email: string
|
|
image: string | null
|
|
}
|
|
}
|
|
|
|
interface ReminderNote {
|
|
id: string
|
|
title: string | null
|
|
content: string
|
|
reminder: Date | string | null
|
|
isReminderDone: boolean
|
|
}
|
|
|
|
export function NotificationPanel() {
|
|
const { triggerRefresh } = useNoteRefreshOptional()
|
|
const { t } = useLanguage()
|
|
const [requests, setRequests] = useState<ShareRequest[]>([])
|
|
const [reminders, setReminders] = useState<ReminderNote[]>([])
|
|
const [isLoading, setIsLoading] = useState(false)
|
|
const [open, setOpen] = useState(false)
|
|
|
|
const loadData = useCallback(async () => {
|
|
try {
|
|
const [shareData, reminderData] = await Promise.all([
|
|
getPendingShareRequests(),
|
|
getNotesWithReminders(),
|
|
])
|
|
setRequests(shareData as any)
|
|
setReminders((reminderData as any) || [])
|
|
} catch (error: any) {
|
|
console.error('Failed to load notifications:', error)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
loadData()
|
|
const interval = setInterval(loadData, 30000)
|
|
const onFocus = () => loadData()
|
|
window.addEventListener('focus', onFocus)
|
|
return () => {
|
|
clearInterval(interval)
|
|
window.removeEventListener('focus', onFocus)
|
|
}
|
|
}, [loadData])
|
|
|
|
const now = new Date()
|
|
const activeReminders = reminders.filter(r => !r.isReminderDone && r.reminder)
|
|
const overdueReminders = activeReminders.filter(r => new Date(r.reminder!) < now)
|
|
const upcomingReminders = activeReminders.filter(r => new Date(r.reminder!) >= now)
|
|
|
|
const pendingCount = requests.length + overdueReminders.length
|
|
|
|
const handleAccept = async (shareId: string) => {
|
|
try {
|
|
await respondToShareRequest(shareId, 'accept')
|
|
setRequests(prev => prev.filter(r => r.id !== shareId))
|
|
toast.success(t('notification.accepted'), {
|
|
description: t('collaboration.nowHasAccess', { name: 'Note' }),
|
|
duration: 3000,
|
|
})
|
|
triggerRefresh()
|
|
setOpen(false)
|
|
} catch (error: any) {
|
|
console.error('[NOTIFICATION] Error:', error)
|
|
toast.error(error.message || t('general.error'))
|
|
}
|
|
}
|
|
|
|
const handleDecline = async (shareId: string) => {
|
|
try {
|
|
await respondToShareRequest(shareId, 'decline')
|
|
setRequests(prev => prev.filter(r => r.id !== shareId))
|
|
toast.info(t('notification.declined'))
|
|
if (requests.length <= 1) setOpen(false)
|
|
} catch (error: any) {
|
|
console.error('[NOTIFICATION] Error:', error)
|
|
toast.error(error.message || t('general.error'))
|
|
}
|
|
}
|
|
|
|
const handleToggleReminder = async (noteId: string, done: boolean) => {
|
|
try {
|
|
await toggleReminderDone(noteId, done)
|
|
setReminders(prev => prev.map(r => r.id === noteId ? { ...r, isReminderDone: done } : r))
|
|
triggerRefresh()
|
|
} catch {
|
|
toast.error(t('general.error'))
|
|
}
|
|
}
|
|
|
|
const hasContent = requests.length > 0 || activeReminders.length > 0
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="relative h-9 w-9 p-0 hover:bg-accent/50 transition-all duration-200"
|
|
>
|
|
<Bell className="h-4 w-4 transition-transform duration-200 hover:scale-110" />
|
|
{pendingCount > 0 && (
|
|
<Badge
|
|
variant="destructive"
|
|
className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center p-0 text-xs animate-pulse shadow-lg"
|
|
>
|
|
{pendingCount > 9 ? '9+' : pendingCount}
|
|
</Badge>
|
|
)}
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent align="end" className="w-80 p-0">
|
|
<div className="px-4 py-3 border-b bg-gradient-to-r from-primary/5 to-primary/10 dark:from-primary/10 dark:to-primary/15">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Bell className="h-4 w-4 text-primary dark:text-primary-foreground" />
|
|
<span className="font-semibold text-sm">{t('notification.notifications')}</span>
|
|
</div>
|
|
{pendingCount > 0 && (
|
|
<Badge className="bg-primary hover:bg-primary/90 text-primary-foreground shadow-md">
|
|
{pendingCount}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{isLoading ? (
|
|
<div className="p-6 text-center text-sm text-muted-foreground">
|
|
<div className="animate-spin h-6 w-6 border-2 border-primary border-t-transparent rounded-full mx-auto mb-2" />
|
|
</div>
|
|
) : !hasContent ? (
|
|
<div className="p-6 text-center text-sm text-muted-foreground">
|
|
<Bell className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
|
<p className="font-medium">{t('notification.noNotifications') || 'No new notifications'}</p>
|
|
</div>
|
|
) : (
|
|
<div className="max-h-96 overflow-y-auto">
|
|
{/* Overdue reminders */}
|
|
{overdueReminders.map((note) => (
|
|
<div
|
|
key={note.id}
|
|
className="p-3 border-b last:border-0 hover:bg-accent/50 transition-colors duration-150"
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
<button
|
|
onClick={() => handleToggleReminder(note.id, true)}
|
|
className="mt-0.5 flex-none text-amber-500 hover:text-green-500 transition-colors"
|
|
title={t('reminders.markDone')}
|
|
>
|
|
<Circle className="w-4 h-4" />
|
|
</button>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-1.5 mb-0.5">
|
|
<AlertCircle className="w-3 h-3 text-amber-500" />
|
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-amber-600 dark:text-amber-400">
|
|
{t('reminders.overdue')}
|
|
</span>
|
|
</div>
|
|
<p className="text-sm font-medium truncate">{note.title || t('notification.untitled')}</p>
|
|
<div className="flex items-center gap-1 mt-1 text-xs text-muted-foreground">
|
|
<Clock className="w-3 h-3" />
|
|
{note.reminder && formatDistanceToNow(new Date(note.reminder), { addSuffix: true })}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{/* Upcoming reminders */}
|
|
{upcomingReminders.slice(0, 5).map((note) => (
|
|
<div
|
|
key={note.id}
|
|
className="p-3 border-b last:border-0 hover:bg-accent/50 transition-colors duration-150"
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
<Clock className="w-4 h-4 mt-0.5 flex-none text-primary" />
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium truncate">{note.title || t('notification.untitled')}</p>
|
|
<div className="text-xs text-muted-foreground mt-0.5">
|
|
{note.reminder && new Date(note.reminder).toLocaleDateString(undefined, {
|
|
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{/* Share requests */}
|
|
{requests.map((request) => (
|
|
<div
|
|
key={request.id}
|
|
className="p-3 border-b last:border-0 hover:bg-accent/50 transition-colors duration-150"
|
|
>
|
|
<div className="flex items-start gap-3 mb-2">
|
|
<div className="h-7 w-7 rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center text-white font-semibold text-[10px] shadow-md shrink-0">
|
|
{(request.sharer.name || request.sharer.email)[0].toUpperCase()}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-semibold truncate">
|
|
{request.sharer.name || request.sharer.email}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground truncate mt-0.5">
|
|
{t('notification.shared', { title: request.note.title || t('notification.untitled') })}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-2 mt-2">
|
|
<button
|
|
onClick={() => handleDecline(request.id)}
|
|
className={cn(
|
|
"flex-1 h-7 px-3 text-[11px] font-semibold rounded-md",
|
|
"border border-border bg-background",
|
|
"text-muted-foreground",
|
|
"hover:bg-muted hover:text-foreground",
|
|
"transition-all duration-200",
|
|
"flex items-center justify-center gap-1",
|
|
"active:scale-95"
|
|
)}
|
|
>
|
|
<X className="h-3 w-3" />
|
|
{t('notification.decline') || t('general.cancel')}
|
|
</button>
|
|
<button
|
|
onClick={() => handleAccept(request.id)}
|
|
className={cn(
|
|
"flex-1 h-7 px-3 text-[11px] font-semibold rounded-md",
|
|
"bg-primary text-primary-foreground",
|
|
"hover:bg-primary/90",
|
|
"shadow-sm",
|
|
"transition-all duration-200",
|
|
"flex items-center justify-center gap-1",
|
|
"active:scale-95"
|
|
)}
|
|
>
|
|
<Check className="h-3 w-3" />
|
|
{t('notification.accept') || t('general.confirm')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Footer link to reminders page */}
|
|
{activeReminders.length > 0 && (
|
|
<div className="px-4 py-2 border-t bg-muted/30">
|
|
<a
|
|
href="/reminders"
|
|
className="text-[11px] font-medium text-primary hover:underline"
|
|
>
|
|
{t('reminders.viewAll') || t('reminders.title') || 'Voir tous les rappels'}
|
|
</a>
|
|
</div>
|
|
)}
|
|
</PopoverContent>
|
|
</Popover>
|
|
)
|
|
}
|