feat: reminder button in list/tabs view, notifications show reminders, trash count live update
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>
This commit is contained in:
2026-04-30 20:43:02 +02:00
parent 07f8a60b69
commit 0a900b3582
9 changed files with 283 additions and 39 deletions

View File

@@ -319,6 +319,31 @@ export async function toggleReminderDone(noteId: string, done: boolean) {
}
}
// Clear completed reminders (set reminder to null for done reminders)
export async function clearCompletedReminders() {
const session = await auth();
if (!session?.user?.id) return { error: 'Unauthorized' }
try {
await prisma.note.updateMany({
where: {
userId: session.user.id,
isReminderDone: true,
reminder: { not: null },
},
data: {
reminder: null,
isReminderDone: false,
},
})
revalidatePath('/reminders')
return { success: true }
} catch (error) {
console.error('Error clearing completed reminders:', error)
return { error: 'Failed to clear reminders' }
}
}
// Get archived notes only
export async function getArchivedNotes() {
const session = await auth();

View File

@@ -9,6 +9,7 @@ import {
import {
Archive,
ArchiveRestore,
Bell,
MoreVertical,
Palette,
Pin,
@@ -22,6 +23,8 @@ import {
import { cn } from "@/lib/utils"
import { NOTE_COLORS } from "@/lib/types"
import { useLanguage } from "@/lib/i18n"
import { ReminderDialog } from "@/components/reminder-dialog"
import { useState } from "react"
interface NoteActionsProps {
isPinned: boolean
@@ -41,6 +44,9 @@ interface NoteActionsProps {
onPermanentDelete?: () => void
onOpenHistory?: () => void
historyEnabled?: boolean
noteId?: string
currentReminder?: Date | null
onUpdateReminder?: (noteId: string, reminder: Date | null) => void
className?: string
}
@@ -62,9 +68,13 @@ export function NoteActions({
onPermanentDelete,
onOpenHistory,
historyEnabled = false,
noteId,
currentReminder,
onUpdateReminder,
className
}: NoteActionsProps) {
const { t } = useLanguage()
const [showReminder, setShowReminder] = useState(false)
// Trash view: show only Restore and Permanent Delete
if (isTrashView) {
@@ -105,6 +115,36 @@ export function NoteActions({
className={cn("flex items-center justify-end gap-1", className)}
onClick={(e) => e.stopPropagation()}
>
{/* Reminder */}
{noteId && onUpdateReminder && (
<>
<Button
variant="ghost"
size="sm"
className={cn("h-8 w-8 p-0", currentReminder && "text-primary")}
title={t('reminder.setReminder')}
onClick={() => setShowReminder(true)}
>
<Bell className="h-4 w-4" />
</Button>
<div onClick={(e) => e.stopPropagation()}>
<ReminderDialog
open={showReminder}
onOpenChange={setShowReminder}
currentReminder={currentReminder || null}
onSave={(date) => {
onUpdateReminder(noteId, date)
setShowReminder(false)
}}
onRemove={() => {
onUpdateReminder(noteId, null)
setShowReminder(false)
}}
/>
</div>
</>
)}
{/* Color Palette */}
<DropdownMenu>
<DropdownMenuTrigger asChild>

View File

@@ -177,6 +177,24 @@ export const NoteCard = memo(function NoteCard({
const [comparisonNotes, setComparisonNotes] = useState<string[] | null>(null)
const [fusionNotes, setFusionNotes] = useState<Array<Partial<Note>>>([])
const [showNotebookMenu, setShowNotebookMenu] = useState(false)
const [reminderDate, setReminderDate] = useState<Date | null>(note.reminder ? new Date(note.reminder) : null)
const handleUpdateReminder = async (noteId: string, reminder: Date | null) => {
startTransition(async () => {
try {
await updateNote(noteId, { reminder })
setReminderDate(reminder)
triggerRefresh()
if (reminder) {
toast.success(t('notes.reminderSet', { datetime: reminder.toLocaleString() }))
} else {
toast.info(t('reminder.removeReminder'))
}
} catch {
toast.error(t('general.error'))
}
})
}
// Move note to a notebook
const handleMoveToNotebook = async (notebookId: string | null) => {
@@ -653,6 +671,9 @@ export const NoteCard = memo(function NoteCard({
onPermanentDelete={handlePermanentDelete}
onOpenHistory={() => onOpenHistory?.(note)}
historyEnabled={noteHistoryEnabled}
noteId={note.id}
currentReminder={reminderDate}
onUpdateReminder={handleUpdateReminder}
className="absolute bottom-0 left-0 right-0 p-2 opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity"
/>
)}

View File

@@ -50,8 +50,10 @@ import {
History,
PanelRightClose,
PanelRightOpen,
Bell,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { ReminderDialog } from '@/components/reminder-dialog'
import {
Dialog,
DialogContent,
@@ -385,17 +387,20 @@ function NoteMetaSidebar({
onArchive,
onOpenHistory,
onEnableHistory,
onUpdateReminder,
}: {
note: Note
onPinToggle: (note: Note) => void
onArchive: (note: Note) => void
onOpenHistory?: (note: Note) => void
onEnableHistory?: (noteId: string) => Promise<void>
onUpdateReminder?: (noteId: string, reminder: Date | null) => void
}) {
const { t } = useLanguage()
const { notebooks, moveNoteToNotebookOptimistic } = useNotebooks()
const [moveOpen, setMoveOpen] = useState(false)
const [isMoving, setIsMoving] = useState(false)
const [showReminder, setShowReminder] = useState(false)
// t() returns the key itself when not found — use this wrapper for safe fallbacks
const ts = (key: string, fallback: string) => {
@@ -571,6 +576,32 @@ function NoteMetaSidebar({
onClick={() => onArchive(note)}
/>
{/* Reminder */}
{onUpdateReminder && (
<>
<SidebarActionBtn
icon={<Bell className={cn("h-3.5 w-3.5", note.reminder && "text-primary")} />}
label={t('reminder.setReminder')}
onClick={() => setShowReminder(true)}
/>
<div onClick={(e) => e.stopPropagation()}>
<ReminderDialog
open={showReminder}
onOpenChange={setShowReminder}
currentReminder={note.reminder ? new Date(note.reminder) : null}
onSave={(date) => {
onUpdateReminder(note.id, date)
setShowReminder(false)
}}
onRemove={() => {
onUpdateReminder(note.id, null)
setShowReminder(false)
}}
/>
</div>
</>
)}
{/* History */}
<SidebarActionBtn
icon={<History className="h-3.5 w-3.5" />}
@@ -928,6 +959,22 @@ export function NotesTabsView({
onArchive={handleArchive}
onOpenHistory={onOpenHistory}
onEnableHistory={onEnableHistory}
onUpdateReminder={async (noteId, reminder) => {
try {
await updateNote(noteId, { reminder })
setItems((prev) =>
prev.map((n) => (n.id === noteId ? { ...n, reminder } : n))
)
if (reminder) {
toast.success(t('notes.reminderSet', { datetime: reminder.toLocaleString() }))
} else {
toast.info(t('reminder.removeReminder'))
}
triggerRefresh()
} catch {
toast.error(t('general.error'))
}
}}
/>
)}
</div>

View File

@@ -3,17 +3,18 @@
import { useState, useEffect, useCallback } from 'react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Bell, Check, X, Clock } from 'lucide-react'
import { Bell, Check, X, Clock, AlertCircle, CheckCircle2, Circle, Share2 } from 'lucide-react'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { getPendingShareRequests, respondToShareRequest } from '@/app/actions/notes'
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
@@ -35,34 +36,52 @@ interface ShareRequest {
}
}
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 loadRequests = useCallback(async () => {
const loadData = useCallback(async () => {
try {
const data = await getPendingShareRequests()
setRequests(data as any)
const [shareData, reminderData] = await Promise.all([
getPendingShareRequests(),
getNotesWithReminders(),
])
setRequests(shareData as any)
setReminders((reminderData as any) || [])
} catch (error: any) {
console.error('Failed to load share requests:', error)
console.error('Failed to load notifications:', error)
}
}, [])
useEffect(() => {
loadRequests()
const interval = setInterval(loadRequests, 10000)
const onFocus = () => loadRequests()
loadData()
const interval = setInterval(loadData, 30000)
const onFocus = () => loadData()
window.addEventListener('focus', onFocus)
return () => {
clearInterval(interval)
window.removeEventListener('focus', onFocus)
}
}, [loadRequests])
}, [loadData])
const pendingCount = requests.length
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 {
@@ -92,6 +111,18 @@ export function NotificationPanel() {
}
}
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>
@@ -130,20 +161,72 @@ export function NotificationPanel() {
<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>
) : requests.length === 0 ? (
) : !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-4 border-b last:border-0 hover:bg-accent/50 transition-colors duration-150"
className="p-3 border-b last:border-0 hover:bg-accent/50 transition-colors duration-150"
>
<div className="flex items-start gap-3 mb-3">
<div className="h-8 w-8 rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center text-white font-semibold text-xs shadow-md shrink-0">
<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">
@@ -156,47 +239,54 @@ export function NotificationPanel() {
</div>
</div>
<div className="flex gap-2 mt-3">
<div className="flex gap-2 mt-2">
<button
onClick={() => handleDecline(request.id)}
className={cn(
"flex-1 h-9 px-4 text-xs font-semibold rounded-lg",
"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.5",
"flex items-center justify-center gap-1",
"active:scale-95"
)}
>
<X className="h-3.5 w-3.5" />
<X className="h-3 w-3" />
{t('notification.decline') || t('general.cancel')}
</button>
<button
onClick={() => handleAccept(request.id)}
className={cn(
"flex-1 h-9 px-4 text-xs font-semibold rounded-lg",
"flex-1 h-7 px-3 text-[11px] font-semibold rounded-md",
"bg-primary text-primary-foreground",
"hover:bg-primary/90",
"shadow-sm hover:shadow",
"shadow-sm",
"transition-all duration-200",
"flex items-center justify-center gap-1.5",
"flex items-center justify-center gap-1",
"active:scale-95"
)}
>
<Check className="h-3.5 w-3.5" />
<Check className="h-3 w-3" />
{t('notification.accept') || t('general.confirm')}
</button>
</div>
<div className="flex items-center gap-1.5 mt-3 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
<span>{new Date(request.createdAt).toLocaleDateString()}</span>
</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>
)

View File

@@ -1,9 +1,9 @@
'use client'
import { useState, useTransition } from 'react'
import { Bell, BellOff, CheckCircle2, Circle, Clock, AlertCircle, RefreshCw } from 'lucide-react'
import { Bell, BellOff, CheckCircle2, Circle, Clock, AlertCircle, RefreshCw, Trash2 } from 'lucide-react'
import { Note } from '@/lib/types'
import { toggleReminderDone } from '@/app/actions/notes'
import { toggleReminderDone, clearCompletedReminders } from '@/app/actions/notes'
import { useLanguage } from '@/lib/i18n'
import { cn } from '@/lib/utils'
import { useRouter } from 'next/navigation'
@@ -213,12 +213,27 @@ export function RemindersPage({ notes: initialNotes }: RemindersPageProps) {
{/* Terminés */}
{done.length > 0 && (
<section>
<SectionTitle
icon={CheckCircle2}
label={t('reminders.done') || 'Terminés'}
count={done.length}
color="text-green-600 dark:text-green-400"
/>
<div className="flex items-center justify-between mb-3">
<SectionTitle
icon={CheckCircle2}
label={t('reminders.done') || 'Terminés'}
count={done.length}
color="text-green-600 dark:text-green-400"
/>
<button
onClick={() => {
startTransition(async () => {
await clearCompletedReminders()
setNotes(prev => prev.filter(n => !n.isReminderDone))
router.refresh()
})
}}
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-red-500 transition-colors"
>
<Trash2 className="w-3 h-3" />
{t('reminders.clearCompleted') || 'Effacer'}
</button>
</div>
<div className="space-y-3">
{done.map(note => (
<ReminderCard key={note.id} note={note} onToggleDone={handleToggleDone} t={t} />

View File

@@ -23,6 +23,7 @@ import {
import { useLanguage } from '@/lib/i18n'
import { NotebooksList } from './notebooks-list'
import { useHomeViewOptional } from '@/context/home-view-context'
import { useNoteRefreshOptional } from '@/context/NoteRefreshContext'
import { useEffect, useState } from 'react'
import { getTrashCount } from '@/app/actions/notes'
@@ -34,6 +35,7 @@ export function Sidebar({ className, user }: { className?: string, user?: any })
const router = useRouter()
const { t } = useLanguage()
const homeBridge = useHomeViewOptional()
const { refreshKey } = useNoteRefreshOptional()
const [trashCount, setTrashCount] = useState(0)
const searchKey = searchParams.toString()
@@ -44,7 +46,7 @@ export function Sidebar({ className, user }: { className?: string, user?: any })
useEffect(() => {
if (HIDDEN_ROUTES.some(r => pathname.startsWith(r))) return
getTrashCount().then(setTrashCount)
}, [pathname, searchKey])
}, [pathname, searchKey, refreshKey])
// Hide sidebar on Agents, Chat IA and Lab routes
if (HIDDEN_ROUTES.some(r => pathname.startsWith(r))) return null

View File

@@ -740,7 +740,9 @@
"markDone": "Mark as done",
"markUndone": "Mark as undone",
"todayAt": "Today at {time}",
"tomorrowAt": "Tomorrow at {time}"
"tomorrowAt": "Tomorrow at {time}",
"clearCompleted": "Clear completed",
"viewAll": "View all reminders"
},
"notebook": {
"create": "Create Notebook",

View File

@@ -740,7 +740,9 @@
"markDone": "Marquer comme terminé",
"markUndone": "Marquer comme non terminé",
"todayAt": "Aujourd'hui à {time}",
"tomorrowAt": "Demain à {time}"
"tomorrowAt": "Demain à {time}",
"clearCompleted": "Effacer les terminés",
"viewAll": "Voir tous les rappels"
},
"notebook": {
"create": "Créer un carnet",