Files
Momento/memento-note/components/notification-panel.tsx
Sepehr Ramezani dbd49d6fcb
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m25s
feat: 8 AI providers, rich text editor, agent notifications, UI contrast & font settings
- Add DeepSeek, OpenRouter, Mistral, Z.AI, LM Studio as AI providers
  with editable model names via Combobox in admin settings
- Fix OpenRouter broken by normalizeProvider bug in config.ts
- Convert agent-created notes from Markdown to HTML (TipTap rich text)
- Add Notification model + in-app notifications for agent results
- Agent notification click opens the created note directly
- Add note count display on notebook and inbox headers
- Fix checklist toggle in card view (persist state via localCheckItems)
- Add checklist creation option in tabs/list view (dropdown on + button)
- Fix image description ENOENT error with HTTP fallback
- Improve UI contrast across all themes (input, border, checkbox visibility)
- Add font family setting (Inter vs System Default) in Appearance settings
- Fix CSS font-sans variable conflict (removed dead Geist references)
- Update README with new features and 8 providers

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-01 16:14:07 +02:00

380 lines
16 KiB
TypeScript

'use client'
import { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Bell, Check, X, Clock, AlertCircle, CheckCircle2, Circle, Share2, Bot, Trash2 } from 'lucide-react'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { getPendingShareRequests, respondToShareRequest, getNotesWithReminders, toggleReminderDone } from '@/app/actions/notes'
import { getUnreadNotifications, markNotificationRead, markAllNotificationsRead, type AppNotification } from '@/app/actions/notifications'
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 router = useRouter()
const [requests, setRequests] = useState<ShareRequest[]>([])
const [reminders, setReminders] = useState<ReminderNote[]>([])
const [appNotifications, setAppNotifications] = useState<AppNotification[]>([])
const [isLoading, setIsLoading] = useState(false)
const [open, setOpen] = useState(false)
const loadData = useCallback(async () => {
try {
const [shareData, reminderData, notifData] = await Promise.all([
getPendingShareRequests(),
getNotesWithReminders(),
getUnreadNotifications(),
])
setRequests(shareData as any)
setReminders((reminderData as any) || [])
setAppNotifications(notifData || [])
} 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 + appNotifications.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 handleMarkNotifRead = async (notifId: string) => {
await markNotificationRead(notifId)
setAppNotifications(prev => prev.filter(n => n.id !== notifId))
}
const handleMarkAllRead = async () => {
await markAllNotificationsRead()
setAppNotifications([])
}
const hasContent = requests.length > 0 || activeReminders.length > 0 || appNotifications.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>
<div className="flex items-center gap-2">
{appNotifications.length > 0 && (
<button
onClick={handleMarkAllRead}
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
title={t('notification.markAllRead') || 'Mark all read'}
>
<Check className="h-3.5 w-3.5" />
</button>
)}
{pendingCount > 0 && (
<Badge className="bg-primary hover:bg-primary/90 text-primary-foreground shadow-md">
{pendingCount}
</Badge>
)}
</div>
</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">
{/* App notifications (agents, system) */}
{appNotifications.map((notif) => (
<div
key={notif.id}
className="p-3 border-b last:border-0 hover:bg-accent/50 transition-colors duration-150 cursor-pointer"
onClick={() => {
if (notif.actionUrl) {
handleMarkNotifRead(notif.id)
setOpen(false)
router.push(notif.actionUrl)
}
}}
>
<div className="flex items-start gap-3">
<div className={cn(
"mt-0.5 flex-none rounded-full p-1",
notif.type === 'agent_success' && 'bg-green-100 dark:bg-green-900/30 text-green-600',
notif.type === 'agent_failure' && 'bg-red-100 dark:bg-red-900/30 text-red-600',
notif.type === 'system' && 'bg-blue-100 dark:bg-blue-900/30 text-blue-600',
)}>
{notif.type.startsWith('agent') ? (
<Bot className="w-3.5 h-3.5" />
) : (
<AlertCircle className="w-3.5 h-3.5" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 mb-0.5">
<span className={cn(
"text-[10px] font-semibold uppercase tracking-wider",
notif.type === 'agent_success' && 'text-green-600 dark:text-green-400',
notif.type === 'agent_failure' && 'text-red-600 dark:text-red-400',
notif.type === 'system' && 'text-blue-600 dark:text-blue-400',
)}>
{notif.type === 'agent_success' && (t('notification.agentSuccess') || 'Agent completed')}
{notif.type === 'agent_failure' && (t('notification.agentFailed') || 'Agent failed')}
{notif.type === 'system' && 'System'}
</span>
</div>
<p className="text-sm font-medium truncate">{notif.title}</p>
{notif.message && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{notif.message}</p>
)}
<div className="flex items-center gap-1 mt-1 text-xs text-muted-foreground">
<Clock className="w-3 h-3" />
{formatDistanceToNow(new Date(notif.createdAt), { addSuffix: true })}
</div>
</div>
<button
onClick={(e) => { e.stopPropagation(); handleMarkNotifRead(notif.id) }}
className="mt-0.5 text-muted-foreground/40 hover:text-foreground transition-colors"
title={t('notification.dismiss') || 'Dismiss'}
>
<X className="w-3.5 h-3.5" />
</button>
</div>
</div>
))}
{/* 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>
)
}