fix(ui): sélection groupée dans la boîte de réception
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m17s
CI / Deploy production (on server) (push) Successful in 24s

Permet de cocher plusieurs notes, les déplacer vers un carnet ou les envoyer à la corbeille, et rend la carte mentale plus lisible.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Antigravity
2026-09-01 19:40:10 +00:00
parent 4fc5e3ce04
commit a7b16870a4
15 changed files with 509 additions and 37 deletions

File diff suppressed because one or more lines are too long

View File

@@ -64,6 +64,57 @@ export async function restoreNote(id: string) {
} }
} }
const MAX_BULK_NOTES = 200
function uniqueNoteIds(ids: string[]) {
return [...new Set(ids.filter((id) => typeof id === 'string' && id.length > 0))].slice(0, MAX_BULK_NOTES)
}
export async function bulkTrashNotes(ids: string[], options?: { skipRevalidation?: boolean }) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const unique = uniqueNoteIds(ids)
if (unique.length === 0) return { success: true, count: 0 }
try {
const result = await prisma.note.updateMany({
where: { id: { in: unique }, userId: session.user.id, trashedAt: null },
data: { trashedAt: new Date() },
})
if (!options?.skipRevalidation) {
revalidatePath('/home')
}
return { success: true, count: result.count }
} catch (error) {
console.error('Error bulk-trashing notes:', error)
throw new Error('Failed to trash notes')
}
}
export async function restoreNotes(ids: string[], options?: { skipRevalidation?: boolean }) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const unique = uniqueNoteIds(ids)
if (unique.length === 0) return { success: true, count: 0 }
try {
const result = await prisma.note.updateMany({
where: { id: { in: unique }, userId: session.user.id, trashedAt: { not: null } },
data: { trashedAt: null },
})
if (!options?.skipRevalidation) {
revalidatePath('/home')
revalidatePath('/trash')
}
return { success: true, count: result.count }
} catch (error) {
console.error('Error restoring notes:', error)
throw new Error('Failed to restore notes')
}
}
export async function getTrashedNotes() { export async function getTrashedNotes() {
const session = await auth() const session = await auth()
if (!session?.user?.id) return [] if (!session?.user?.id) return []

View File

@@ -721,6 +721,41 @@ export async function updateNote(id: string, data: {
} }
} }
const MAX_BULK_MOVE = 200
export async function bulkMoveNotes(
ids: string[],
notebookId: string,
options?: { skipRevalidation?: boolean },
) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const unique = [...new Set(ids.filter((id) => typeof id === 'string' && id.length > 0))].slice(0, MAX_BULK_MOVE)
if (unique.length === 0) return { success: true, count: 0 }
const notebook = await prisma.notebook.findFirst({
where: { id: notebookId, userId: session.user.id, trashedAt: null },
select: { id: true },
})
if (!notebook) throw new Error('Notebook not found')
try {
const result = await prisma.note.updateMany({
where: { id: { in: unique }, userId: session.user.id, trashedAt: null },
data: { notebookId },
})
if (!options?.skipRevalidation) {
revalidatePath('/home')
revalidatePath(`/notebook/${notebookId}`)
}
return { success: true, count: result.count }
} catch (error) {
console.error('Error bulk-moving notes:', error)
throw new Error('Failed to move notes')
}
}
// Toggle functions // Toggle functions
export async function togglePin( export async function togglePin(
id: string, id: string,
@@ -1169,6 +1204,8 @@ import {
deleteNote as _deleteNote, deleteNote as _deleteNote,
trashNote as _trashNote, trashNote as _trashNote,
restoreNote as _restoreNote, restoreNote as _restoreNote,
bulkTrashNotes as _bulkTrashNotes,
restoreNotes as _restoreNotes,
getTrashedNotes as _getTrashedNotes, getTrashedNotes as _getTrashedNotes,
permanentDeleteNote as _permanentDeleteNote, permanentDeleteNote as _permanentDeleteNote,
emptyTrash as _emptyTrash, emptyTrash as _emptyTrash,
@@ -1202,6 +1239,8 @@ export async function enableNoteHistory(...args: Parameters<typeof _enableNoteHi
export async function deleteNote(...args: Parameters<typeof _deleteNote>) { return _deleteNote(...args) } export async function deleteNote(...args: Parameters<typeof _deleteNote>) { return _deleteNote(...args) }
export async function trashNote(...args: Parameters<typeof _trashNote>) { return _trashNote(...args) } export async function trashNote(...args: Parameters<typeof _trashNote>) { return _trashNote(...args) }
export async function restoreNote(...args: Parameters<typeof _restoreNote>) { return _restoreNote(...args) } export async function restoreNote(...args: Parameters<typeof _restoreNote>) { return _restoreNote(...args) }
export async function bulkTrashNotes(...args: Parameters<typeof _bulkTrashNotes>) { return _bulkTrashNotes(...args) }
export async function restoreNotes(...args: Parameters<typeof _restoreNotes>) { return _restoreNotes(...args) }
export async function getTrashedNotes(...args: Parameters<typeof _getTrashedNotes>) { return _getTrashedNotes(...args) } export async function getTrashedNotes(...args: Parameters<typeof _getTrashedNotes>) { return _getTrashedNotes(...args) }
export async function permanentDeleteNote(...args: Parameters<typeof _permanentDeleteNote>) { return _permanentDeleteNote(...args) } export async function permanentDeleteNote(...args: Parameters<typeof _permanentDeleteNote>) { return _permanentDeleteNote(...args) }
export async function emptyTrash(...args: Parameters<typeof _emptyTrash>) { return _emptyTrash(...args) } export async function emptyTrash(...args: Parameters<typeof _emptyTrash>) { return _emptyTrash(...args) }

View File

@@ -16,10 +16,14 @@ export function ConfirmDeleteNoteDialog({
open, open,
onOpenChange, onOpenChange,
onConfirm, onConfirm,
title,
description,
}: { }: {
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
onConfirm: () => void | Promise<void> onConfirm: () => void | Promise<void>
title?: string
description?: string
}) { }) {
const { t } = useLanguage() const { t } = useLanguage()
@@ -27,8 +31,8 @@ export function ConfirmDeleteNoteDialog({
<AlertDialog open={open} onOpenChange={onOpenChange}> <AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>{t('notes.confirmDeleteTitle')}</AlertDialogTitle> <AlertDialogTitle>{title ?? t('notes.confirmDeleteTitle')}</AlertDialogTitle>
<AlertDialogDescription>{t('notes.confirmDelete')}</AlertDialogDescription> <AlertDialogDescription>{description ?? t('notes.confirmDelete')}</AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel> <AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>

View File

@@ -18,9 +18,18 @@ interface BridgeNote {
note?: { id: string; title: string | null } note?: { id: string; title: string | null }
} }
const CLUSTER_COLORS = ['#F87171', '#60A5FA', '#34D399', '#FBBF24', '#A78BFA', '#F472B6', '#2DD4BF'] const CLUSTER_COLORS = ['#B91C1C', '#1D4ED8', '#047857', '#B45309', '#6D28D9', '#BE185D', '#0F766E']
const EASE = [0.16, 1, 0.3, 1] as const const EASE = [0.16, 1, 0.3, 1] as const
function clusterInk(hex: string): string {
const n = hex.replace('#', '')
const r = parseInt(n.slice(0, 2), 16)
const g = parseInt(n.slice(2, 4), 16)
const b = parseInt(n.slice(4, 6), 16)
const y = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255
return y > 0.45 ? '#1C1917' : '#FAFAF9'
}
function clusterPoint(index: number, count: number): { x: number; y: number } { function clusterPoint(index: number, count: number): { x: number; y: number } {
if (count === 1) return { x: 50, y: 42 } if (count === 1) return { x: 50, y: 42 }
const angle = -Math.PI / 2 + (index * 2 * Math.PI) / count const angle = -Math.PI / 2 + (index * 2 * Math.PI) / count
@@ -69,8 +78,8 @@ export function DashboardMindOrbit({
className="w-full h-[180px] rounded-2xl border border-dashed border-border/35 bg-white/50 dark:bg-zinc-900/50 flex flex-col items-center justify-center gap-2 p-6 text-center hover:border-brand-accent/30 transition-all" className="w-full h-[180px] rounded-2xl border border-dashed border-border/35 bg-white/50 dark:bg-zinc-900/50 flex flex-col items-center justify-center gap-2 p-6 text-center hover:border-brand-accent/30 transition-all"
> >
<Layers size={22} className="text-concrete/35" /> <Layers size={22} className="text-concrete/35" />
<p className="text-xs text-concrete italic max-w-[220px]">{t('homeDashboard.mindMapEmpty')}</p> <p className="text-[13px] text-ink/80 dark:text-dark-ink/80 italic max-w-[220px]">{t('homeDashboard.mindMapEmpty')}</p>
<span className="text-[9px] font-mono uppercase font-bold text-brand-accent">{t('homeDashboard.mindMapOpen')}</span> <span className="text-[13px] font-semibold uppercase tracking-wider text-brand-accent">{t('homeDashboard.mindMapOpen')}</span>
</button> </button>
) )
} }
@@ -85,7 +94,7 @@ export function DashboardMindOrbit({
<button <button
type="button" type="button"
onClick={onOpenInsights} onClick={onOpenInsights}
className="inline-flex items-center gap-0.5 text-[8px] font-mono uppercase font-bold text-brand-accent hover:underline" className="inline-flex items-center gap-0.5 text-[13px] font-semibold uppercase tracking-wider text-brand-accent hover:underline"
> >
{t('homeDashboard.fullMap')} {t('homeDashboard.fullMap')}
<ArrowUpRight size={10} /> <ArrowUpRight size={10} />
@@ -108,11 +117,11 @@ export function DashboardMindOrbit({
d={`M 50 44 L ${point.x} ${point.y}`} d={`M 50 44 L ${point.x} ${point.y}`}
fill="none" fill="none"
stroke={color} stroke={color}
strokeWidth={0.7} strokeWidth={1.2}
strokeLinecap="round" strokeLinecap="round"
vectorEffect="non-scaling-stroke" vectorEffect="non-scaling-stroke"
initial={reduced ? false : { pathLength: 0, opacity: 0 }} initial={reduced ? false : { pathLength: 0, opacity: 0 }}
animate={{ pathLength: 1, opacity: 0.4 }} animate={{ pathLength: 1, opacity: 0.85 }}
transition={{ duration: reduced ? 0 : 0.45, delay: reduced ? 0 : 0.12 + idx * 0.05, ease: EASE }} transition={{ duration: reduced ? 0 : 0.45, delay: reduced ? 0 : 0.12 + idx * 0.05, ease: EASE }}
/> />
) )
@@ -149,21 +158,22 @@ export function DashboardMindOrbit({
transition={{ duration: reduced ? 0 : 0.38, delay: reduced ? 0 : 0.18 + idx * 0.05, ease: EASE }} transition={{ duration: reduced ? 0 : 0.38, delay: reduced ? 0 : 0.18 + idx * 0.05, ease: EASE }}
> >
<div <div
className="rounded-full border-2 flex items-center justify-center font-mono font-bold text-white shadow-sm group-hover:shadow-md transition-shadow" className="rounded-full border-2 flex items-center justify-center font-semibold shadow-sm group-hover:shadow-md transition-shadow"
style={{ style={{
width: size, width: size,
height: size, height: size,
backgroundColor: `${color}cc`, backgroundColor: color,
borderColor: `${color}40`, borderColor: color,
fontSize: Math.max(9, size * 0.22), color: clusterInk(color),
fontSize: Math.max(13, size * 0.28),
}} }}
> >
{cluster.noteIds.length} {cluster.noteIds.length}
</div> </div>
<span className="text-[9px] font-medium text-ink/80 dark:text-dark-ink/80 text-center line-clamp-2 leading-snug max-w-[128px] group-hover:text-brand-accent transition-colors"> <span className="text-[13px] font-medium text-ink dark:text-dark-ink text-center line-clamp-2 leading-snug max-w-[128px] group-hover:text-brand-accent transition-colors">
{label} {label}
</span> </span>
<span className="pointer-events-none absolute top-full mt-1 max-w-[200px] px-2 py-1 rounded-md bg-ink text-white text-[10px] leading-snug text-center opacity-0 group-hover:opacity-100 transition-opacity shadow-lg z-20"> <span className="pointer-events-none absolute top-full mt-1 max-w-[200px] px-2 py-1 rounded-md bg-ink text-white text-[13px] leading-snug text-center opacity-0 group-hover:opacity-100 transition-opacity shadow-lg z-20">
{label} {label}
</span> </span>
</motion.button> </motion.button>
@@ -182,12 +192,12 @@ export function DashboardMindOrbit({
> >
<Zap size={11} className="text-brand-accent shrink-0" /> <Zap size={11} className="text-brand-accent shrink-0" />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-[10px] font-semibold text-ink dark:text-dark-ink truncate group-hover:text-brand-accent transition-colors"> <p className="text-[13px] font-semibold text-ink dark:text-dark-ink truncate group-hover:text-brand-accent transition-colors">
{topBridge.note.title || t('homeDashboard.untitled')} {topBridge.note.title || t('homeDashboard.untitled')}
</p> </p>
<p className="text-[8px] font-mono uppercase text-concrete">{t('homeDashboard.bridgeNote')}</p> <p className="text-[12px] text-ink/70 dark:text-dark-ink/70">{t('homeDashboard.bridgeNote')}</p>
</div> </div>
<span className="text-[8px] font-mono font-bold text-brand-accent bg-brand-accent/10 px-1.5 py-0.5 rounded-full shrink-0"> <span className="text-[12px] font-semibold text-brand-accent bg-brand-accent/10 px-1.5 py-0.5 rounded-full shrink-0">
{Math.round(topBridge.bridgeScore * 100)}% {Math.round(topBridge.bridgeScore * 100)}%
</span> </span>
</motion.button> </motion.button>

View File

@@ -4,7 +4,7 @@ import React, { useState, useEffect, useCallback, useRef, useTransition, useMemo
import { useSearchParams, useRouter } from 'next/navigation' import { useSearchParams, useRouter } from 'next/navigation'
import dynamic from 'next/dynamic' import dynamic from 'next/dynamic'
import { Note } from '@/lib/types' import { Note } from '@/lib/types'
import { getAllNotes, searchNotes, enableNoteHistory, getNoteById, createNote, deleteNote, togglePin, toggleArchive, updateNote, updateFullOrderWithoutRevalidation } from '@/app/actions/notes' import { getAllNotes, searchNotes, enableNoteHistory, getNoteById, createNote, deleteNote, togglePin, toggleArchive, updateNote, updateFullOrderWithoutRevalidation, bulkTrashNotes, bulkMoveNotes } from '@/app/actions/notes'
import { NotesListViews, type NotesLayoutMode, type NotesClassicLayoutMode, isClassicLayoutMode } from '@/components/notes-list-views' import { NotesListViews, type NotesLayoutMode, type NotesClassicLayoutMode, isClassicLayoutMode } from '@/components/notes-list-views'
import { import {
NOTES_LAYOUT_STORAGE_KEY, NOTES_LAYOUT_STORAGE_KEY,
@@ -36,7 +36,8 @@ import { toast } from 'sonner'
import { AnimatePresence, motion } from 'motion/react' import { AnimatePresence, motion } from 'motion/react'
import { isDashboardHomeRoute } from '@/lib/dashboard/home-route' import { isDashboardHomeRoute } from '@/lib/dashboard/home-route'
import { ConfirmDeleteNoteDialog } from '@/components/confirm-delete-note-dialog' import { ConfirmDeleteNoteDialog } from '@/components/confirm-delete-note-dialog'
import { showNoteTrashedToast } from '@/lib/notes/trash-toast' import { showNoteTrashedToast, showNotesTrashedToast } from '@/lib/notes/trash-toast'
import { InboxBulkBar } from '@/components/inbox-bulk-bar'
type SortOrder = 'newest' | 'oldest' | 'alpha' | 'manual' type SortOrder = 'newest' | 'oldest' | 'alpha' | 'manual'
@@ -163,6 +164,9 @@ export function HomeClient({
const [showStudyPlanner, setShowStudyPlanner] = useState(false) const [showStudyPlanner, setShowStudyPlanner] = useState(false)
const [showOrganizer, setShowOrganizer] = useState(false) const [showOrganizer, setShowOrganizer] = useState(false)
const [notePendingDelete, setNotePendingDelete] = useState<Note | null>(null) const [notePendingDelete, setNotePendingDelete] = useState<Note | null>(null)
const [selectedInboxIds, setSelectedInboxIds] = useState<Set<string>>(() => new Set())
const [bulkBusy, setBulkBusy] = useState(false)
const [bulkTrashOpen, setBulkTrashOpen] = useState(false)
const handleExportCSV = useCallback(() => { const handleExportCSV = useCallback(() => {
if (!searchParams.get('notebook')) return if (!searchParams.get('notebook')) return
@@ -194,6 +198,10 @@ export function HomeClient({
}, [searchParams]) }, [searchParams])
const notebookFilter = searchParams.get('notebook') const notebookFilter = searchParams.get('notebook')
const isInboxView =
!notebookFilter &&
searchParams.get('shared') !== '1' &&
searchParams.get('reminders') !== '1'
const schemaHook = useNotebookSchema(notebookFilter) const schemaHook = useNotebookSchema(notebookFilter)
const structuredModeActive = Boolean(notebookFilter && schemaHook.schema) const structuredModeActive = Boolean(notebookFilter && schemaHook.schema)
const wantsStructuredView = Boolean( const wantsStructuredView = Boolean(
@@ -602,6 +610,68 @@ export function HomeClient({
[noteVisibleInCurrentView, patchNoteInList, removeNoteFromList, t] [noteVisibleInCurrentView, patchNoteInList, removeNoteFromList, t]
) )
const handleToggleInboxSelect = useCallback((id: string) => {
setSelectedInboxIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}, [])
const handleBulkMoveInbox = useCallback(
async (notebookId: string) => {
const moved = notes.filter((n) => selectedInboxIds.has(n.id))
const ids = moved.map((n) => n.id)
if (ids.length === 0) return
setBulkBusy(true)
for (const note of moved) {
removeNoteFromList(note.id)
emitNoteChange({ type: 'updated', note: { ...note, notebookId } })
}
setSelectedInboxIds(new Set())
try {
await bulkMoveNotes(ids, notebookId, { skipRevalidation: true })
toast.success(t('notes.bulkMovedToast', { count: ids.length }))
refreshNotebooks()
} catch {
setNotes((prev) => [...moved, ...prev])
toast.error(t('general.error'))
} finally {
setBulkBusy(false)
}
},
[notes, selectedInboxIds, removeNoteFromList, refreshNotebooks, t],
)
const confirmBulkTrashInbox = useCallback(async () => {
const moved = notes.filter((n) => selectedInboxIds.has(n.id))
const ids = moved.map((n) => n.id)
setBulkTrashOpen(false)
if (ids.length === 0) return
setBulkBusy(true)
for (const note of moved) {
removeNoteFromList(note.id)
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
}
setSelectedInboxIds(new Set())
try {
await bulkTrashNotes(ids, { skipRevalidation: true })
showNotesTrashedToast(moved, t, () => {
setNotes((prev) => [...moved, ...prev])
})
refreshNotebooks()
} catch {
setNotes((prev) => [...moved, ...prev])
for (const note of moved) {
emitNoteChange({ type: 'created', note })
}
toast.error(t('general.error'))
} finally {
setBulkBusy(false)
}
}, [notes, selectedInboxIds, removeNoteFromList, refreshNotebooks, t])
const handleNoteContentPatch = useCallback((noteId: string, patch: Partial<Note>) => { const handleNoteContentPatch = useCallback((noteId: string, patch: Partial<Note>) => {
setNotes((prev) => { setNotes((prev) => {
const next = prev.map((n) => (n.id === noteId ? { ...n, ...patch } : n)) const next = prev.map((n) => (n.id === noteId ? { ...n, ...patch } : n))
@@ -827,6 +897,27 @@ export function HomeClient({
return sortedNotes.filter(n => n.isPinned) return sortedNotes.filter(n => n.isPinned)
}, [sortedNotes]) }, [sortedNotes])
const handleSelectAllInbox = useCallback(() => {
setSelectedInboxIds(new Set(sortedNotes.map((n) => n.id)))
}, [sortedNotes])
const handleDeselectAllInbox = useCallback(() => {
setSelectedInboxIds(new Set())
}, [])
useEffect(() => {
if (!isInboxView) {
setSelectedInboxIds(new Set())
return
}
setSelectedInboxIds((prev) => {
if (prev.size === 0) return prev
const valid = new Set(sortedNotes.map((n) => n.id))
const next = new Set([...prev].filter((id) => valid.has(id)))
return next.size === prev.size ? prev : next
})
}, [isInboxView, sortedNotes])
const sortLabels: Record<SortOrder, string> = { const sortLabels: Record<SortOrder, string> = {
newest: t('sidebar.sortNewest'), newest: t('sidebar.sortNewest'),
oldest: t('sidebar.sortOldest'), oldest: t('sidebar.sortOldest'),
@@ -928,6 +1019,8 @@ export function HomeClient({
? t('sidebar.sharedWithMe') ? t('sidebar.sharedWithMe')
: searchParams.get('reminders') === '1' : searchParams.get('reminders') === '1'
? t('sidebar.reminders') ? t('sidebar.reminders')
: isInboxView
? t('sidebar.inbox')
: t('notes.title')} : t('notes.title')}
</h1> </h1>
</div> </div>
@@ -1224,6 +1317,18 @@ export function HomeClient({
</div> </div>
</div> </div>
{isInboxView && notes.length > 0 && (
<InboxBulkBar
totalCount={sortedNotes.length}
selectedCount={selectedInboxIds.size}
busy={bulkBusy}
onSelectAll={handleSelectAllInbox}
onDeselectAll={handleDeselectAllInbox}
onMove={(notebookId) => { void handleBulkMoveInbox(notebookId) }}
onTrash={() => setBulkTrashOpen(true)}
/>
)}
{availableTags.length > 0 && ( {availableTags.length > 0 && (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -1381,6 +1486,14 @@ export function HomeClient({
onNoteIllustrationGenerated={handleNoteIllustrationGenerated} onNoteIllustrationGenerated={handleNoteIllustrationGenerated}
onNoteIllustrationDeleted={handleNoteIllustrationDeleted} onNoteIllustrationDeleted={handleNoteIllustrationDeleted}
onGridReorder={handleGridReorder} onGridReorder={handleGridReorder}
selection={isInboxView ? {
selectedIds: selectedInboxIds,
onToggle: handleToggleInboxSelect,
onToggleAll: selectedInboxIds.size === sortedNotes.length
? handleDeselectAllInbox
: handleSelectAllInbox,
allSelected: sortedNotes.length > 0 && selectedInboxIds.size === sortedNotes.length,
} : undefined}
/> />
)} )}
</div> </div>
@@ -1499,11 +1612,16 @@ export function HomeClient({
)} )}
<ConfirmDeleteNoteDialog <ConfirmDeleteNoteDialog
open={notePendingDelete != null} open={notePendingDelete != null || bulkTrashOpen}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) setNotePendingDelete(null) if (!open) {
setNotePendingDelete(null)
setBulkTrashOpen(false)
}
}} }}
onConfirm={confirmDeleteNoteFromList} title={bulkTrashOpen ? t('notes.confirmBulkDeleteTitle') : undefined}
description={bulkTrashOpen ? t('notes.confirmBulkDelete') : undefined}
onConfirm={bulkTrashOpen ? confirmBulkTrashInbox : confirmDeleteNoteFromList}
/> />
{showNotebookSlides && currentNotebook && ( {showNotebookSlides && currentNotebook && (

View File

@@ -0,0 +1,92 @@
'use client'
import { FolderOpen, Trash2 } from 'lucide-react'
import { MoveToNotebookPicker } from '@/components/move-to-notebook-picker'
import { useLanguage } from '@/lib/i18n'
import { useNotebooks } from '@/context/notebooks-context'
import { cn } from '@/lib/utils'
export function InboxBulkBar({
totalCount,
selectedCount,
busy,
onSelectAll,
onDeselectAll,
onMove,
onTrash,
}: {
totalCount: number
selectedCount: number
busy?: boolean
onSelectAll: () => void
onDeselectAll: () => void
onMove: (notebookId: string) => void
onTrash: () => void
}) {
const { t } = useLanguage()
const { notebooks } = useNotebooks()
const allSelected = totalCount > 0 && selectedCount === totalCount
const hasSelection = selectedCount > 0
return (
<div className="flex flex-wrap items-center gap-3 pt-1">
<button
type="button"
onClick={allSelected ? onDeselectAll : onSelectAll}
disabled={totalCount === 0 || busy}
className="text-[13px] font-medium text-foreground hover:opacity-70 transition-opacity disabled:opacity-40"
>
{allSelected ? t('notes.deselectAll') : t('notes.selectAll')}
</button>
<span className="text-[12px] text-muted-foreground">
{selectedCount === 1
? t('notes.selectedCountOne')
: t('notes.selectedCount', { count: selectedCount })}
</span>
<div className="flex items-center gap-2 ms-auto">
{hasSelection ? (
<MoveToNotebookPicker
notebooks={notebooks}
currentNotebookId={null}
showGeneralNotes={false}
onSelect={(notebookId) => {
if (notebookId) onMove(notebookId)
}}
>
<button
type="button"
disabled={busy}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[12px] font-medium border border-foreground/15 text-foreground hover:bg-foreground/5 transition-colors"
>
<FolderOpen size={14} />
{t('notes.bulkMove')}
</button>
</MoveToNotebookPicker>
) : (
<button
type="button"
disabled
className="flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[12px] font-medium border border-transparent text-muted-foreground/50 cursor-not-allowed"
>
<FolderOpen size={14} />
{t('notes.bulkMove')}
</button>
)}
<button
type="button"
disabled={!hasSelection || busy}
onClick={onTrash}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[12px] font-medium border transition-colors',
hasSelection
? 'border-rose-500/30 text-rose-600 dark:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-500/10'
: 'border-transparent text-muted-foreground/50 cursor-not-allowed',
)}
>
<Trash2 size={14} />
{t('notes.bulkTrash')}
</button>
</div>
</div>
)
}

View File

@@ -17,6 +17,7 @@ type MoveToNotebookPickerProps = {
children: React.ReactElement children: React.ReactElement
align?: 'start' | 'end' align?: 'start' | 'end'
preferDropUp?: boolean preferDropUp?: boolean
showGeneralNotes?: boolean
} }
export function MoveToNotebookPicker({ export function MoveToNotebookPicker({
@@ -26,6 +27,7 @@ export function MoveToNotebookPicker({
children, children,
align = 'end', align = 'end',
preferDropUp = false, preferDropUp = false,
showGeneralNotes = true,
}: MoveToNotebookPickerProps) { }: MoveToNotebookPickerProps) {
const { t } = useLanguage() const { t } = useLanguage()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
@@ -91,7 +93,7 @@ export function MoveToNotebookPicker({
selectedId={currentNotebookId} selectedId={currentNotebookId}
onSelect={(id) => handleSelect(id)} onSelect={(id) => handleSelect(id)}
onClose={close} onClose={close}
showGeneralNotes showGeneralNotes={showGeneralNotes}
generalNotesLabel={t('notebookSuggestion.generalNotes') || 'Notes générales'} generalNotesLabel={t('notebookSuggestion.generalNotes') || 'Notes générales'}
onSelectGeneralNotes={() => handleSelect(null)} onSelectGeneralNotes={() => handleSelect(null)}
searchPlaceholder={t('notebookSuggestion.filterNotebooks') || 'Filtrer les carnets…'} searchPlaceholder={t('notebookSuggestion.filterNotebooks') || 'Filtrer les carnets…'}

View File

@@ -0,0 +1,39 @@
'use client'
import { Check } from 'lucide-react'
import { cn } from '@/lib/utils'
export function NoteSelectCheckbox({
checked,
onToggle,
label,
className,
}: {
checked: boolean
onToggle: () => void
label: string
className?: string
}) {
return (
<button
type="button"
role="checkbox"
aria-checked={checked}
aria-label={label}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation()
onToggle()
}}
className={cn(
'shrink-0 w-5 h-5 rounded border flex items-center justify-center transition-colors',
checked
? 'bg-brand-accent border-brand-accent text-white'
: 'border-foreground/25 bg-background/90 hover:border-brand-accent/60',
className,
)}
>
{checked ? <Check size={12} strokeWidth={3} /> : null}
</button>
)
}

View File

@@ -25,6 +25,7 @@ import { useNotebooks } from '@/context/notebooks-context'
import { toast } from 'sonner' import { toast } from 'sonner'
import { ConfirmDeleteNoteDialog } from '@/components/confirm-delete-note-dialog' import { ConfirmDeleteNoteDialog } from '@/components/confirm-delete-note-dialog'
import { showNoteTrashedToast } from '@/lib/notes/trash-toast' import { showNoteTrashedToast } from '@/lib/notes/trash-toast'
import { NoteSelectCheckbox } from '@/components/note-select-checkbox'
import { fr } from 'date-fns/locale/fr' import { fr } from 'date-fns/locale/fr'
import { enUS } from 'date-fns/locale/en-US' import { enUS } from 'date-fns/locale/en-US'
import { formatAbsoluteDateLocalized } from '@/lib/utils/format-localized-date' import { formatAbsoluteDateLocalized } from '@/lib/utils/format-localized-date'
@@ -37,6 +38,10 @@ type NotesEditorialViewProps = {
onOpen: (note: Note, readOnly?: boolean) => void onOpen: (note: Note, readOnly?: boolean) => void
notebookName?: string notebookName?: string
onOpenHistory?: (note: Note) => void onOpenHistory?: (note: Note) => void
selection?: {
selectedIds: ReadonlySet<string>
onToggle: (id: string) => void
}
} & NoteCollectionActions } & NoteCollectionActions
function formatNoteDate(date: Date | string, language: string): string { function formatNoteDate(date: Date | string, language: string): string {
@@ -376,6 +381,7 @@ export function NotesEditorialView({
onMoveToNotebook, onMoveToNotebook,
onNotePatch, onNotePatch,
onNoteIllustrationGenerated, onNoteIllustrationGenerated,
selection,
}: NotesEditorialViewProps) { }: NotesEditorialViewProps) {
const { t, language } = useLanguage() const { t, language } = useLanguage()
const { data: session } = useSession() const { data: session } = useSession()
@@ -454,7 +460,14 @@ export function NotesEditorialView({
</div> </div>
<h2 className="font-memento-serif text-2xl font-medium text-foreground flex items-center gap-2 justify-between"> <h2 className="font-memento-serif text-2xl font-medium text-foreground flex items-center gap-2 justify-between">
<span className="inline-flex items-center gap-2 truncate"> <span className="inline-flex items-center gap-2 truncate min-w-0">
{selection && (
<NoteSelectCheckbox
checked={selection.selectedIds.has(note.id)}
onToggle={() => selection.onToggle(note.id)}
label={t('notes.selectNote')}
/>
)}
{note.isPinned && <Pin size={14} className="text-amber-500 fill-amber-500 shrink-0" />} {note.isPinned && <Pin size={14} className="text-amber-500 fill-amber-500 shrink-0" />}
{note.historyEnabled && <History size={14} className="text-emerald-500 shrink-0" />} {note.historyEnabled && <History size={14} className="text-emerald-500 shrink-0" />}
{title} {title}

View File

@@ -53,6 +53,7 @@ import { useHydrated } from '@/lib/use-hydrated'
import { formatDistanceToNow } from 'date-fns' import { formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale/fr' import { fr } from 'date-fns/locale/fr'
import { enUS } from 'date-fns/locale/en-US' import { enUS } from 'date-fns/locale/en-US'
import { NoteSelectCheckbox } from '@/components/note-select-checkbox'
export type NotesLayoutMode = 'grid' | 'list' | 'table' | 'kanban' | 'gallery' export type NotesLayoutMode = 'grid' | 'list' | 'table' | 'kanban' | 'gallery'
export type NotesClassicLayoutMode = 'grid' | 'list' | 'table' export type NotesClassicLayoutMode = 'grid' | 'list' | 'table'
@@ -253,6 +254,12 @@ type NotesListViewsProps = {
onOpenHistory?: (note: Note) => void onOpenHistory?: (note: Note) => void
notebookName?: string notebookName?: string
onGridReorder?: (orderedIds: string[]) => void | Promise<void> onGridReorder?: (orderedIds: string[]) => void | Promise<void>
selection?: {
selectedIds: ReadonlySet<string>
onToggle: (id: string) => void
onToggleAll?: () => void
allSelected?: boolean
}
} & Partial<NoteCollectionActions> } & Partial<NoteCollectionActions>
export function NotesListViews({ export function NotesListViews({
@@ -269,6 +276,7 @@ export function NotesListViews({
onNotePatch, onNotePatch,
onNoteIllustrationGenerated, onNoteIllustrationGenerated,
onGridReorder, onGridReorder,
selection,
}: NotesListViewsProps) { }: NotesListViewsProps) {
const { t, language } = useLanguage() const { t, language } = useLanguage()
const { data: session } = useSession() const { data: session } = useSession()
@@ -352,6 +360,7 @@ export function NotesListViews({
onOpenHistory={onOpenHistory} onOpenHistory={onOpenHistory}
onGridReorder={onGridReorder} onGridReorder={onGridReorder}
pinnedLabel={t('notes.pinned')} pinnedLabel={t('notes.pinned')}
selection={selection}
/> />
) )
} }
@@ -363,6 +372,15 @@ export function NotesListViews({
<table className="w-full text-left border-collapse min-w-[720px]"> <table className="w-full text-left border-collapse min-w-[720px]">
<thead> <thead>
<tr className="border-b border-border/30"> <tr className="border-b border-border/30">
{selection && (
<th className="w-10 px-3 py-3">
<NoteSelectCheckbox
checked={!!selection.allSelected}
onToggle={() => selection.onToggleAll?.()}
label={selection.allSelected ? t('notes.deselectAll') : t('notes.selectAll')}
/>
</th>
)}
<th <th
className="w-[32%] px-4 py-3 text-[10px] uppercase tracking-widest font-black text-muted-foreground cursor-pointer hover:text-foreground" className="w-[32%] px-4 py-3 text-[10px] uppercase tracking-widest font-black text-muted-foreground cursor-pointer hover:text-foreground"
onClick={() => handleSort('title')} onClick={() => handleSort('title')}
@@ -407,6 +425,15 @@ export function NotesListViews({
role="button" role="button"
className="h-11 hover:bg-foreground/[0.02] cursor-pointer transition-colors group focus-visible:bg-foreground/[0.04]" className="h-11 hover:bg-foreground/[0.02] cursor-pointer transition-colors group focus-visible:bg-foreground/[0.04]"
> >
{selection && (
<td className="px-3 py-2 w-10">
<NoteSelectCheckbox
checked={selection.selectedIds.has(note.id)}
onToggle={() => selection.onToggle(note.id)}
label={t('notes.selectNote')}
/>
</td>
)}
<td className="px-4 py-2 font-memento-serif text-[13px] font-medium truncate max-w-[280px]"> <td className="px-4 py-2 font-memento-serif text-[13px] font-medium truncate max-w-[280px]">
<span className="inline-flex items-center gap-2 truncate group-hover:text-brand-accent transition-colors"> <span className="inline-flex items-center gap-2 truncate group-hover:text-brand-accent transition-colors">
{note.isPinned && <Pin size={11} className="text-amber-500 fill-amber-500 shrink-0" />} {note.isPinned && <Pin size={11} className="text-amber-500 fill-amber-500 shrink-0" />}
@@ -464,6 +491,7 @@ export function NotesListViews({
onMoveToNotebook={onMoveToNotebook} onMoveToNotebook={onMoveToNotebook}
onNotePatch={onNotePatch} onNotePatch={onNotePatch}
onNoteIllustrationGenerated={onNoteIllustrationGenerated} onNoteIllustrationGenerated={onNoteIllustrationGenerated}
selection={selection}
/> />
</div> </div>
)} )}
@@ -479,6 +507,7 @@ export function NotesListViews({
onMoveToNotebook={onMoveToNotebook} onMoveToNotebook={onMoveToNotebook}
onNotePatch={onNotePatch} onNotePatch={onNotePatch}
onNoteIllustrationGenerated={onNoteIllustrationGenerated} onNoteIllustrationGenerated={onNoteIllustrationGenerated}
selection={selection}
/> />
)} )}
</div> </div>
@@ -497,6 +526,13 @@ function formatGridCardDate(date: Date | string, language: string): string {
return `${month.toUpperCase()} ${day}, ${year}` return `${month.toUpperCase()} ${day}, ${year}`
} }
type NotesSelection = {
selectedIds: ReadonlySet<string>
onToggle: (id: string) => void
onToggleAll?: () => void
allSelected?: boolean
}
type GridCardSharedProps = { type GridCardSharedProps = {
note: Note note: Note
index: number index: number
@@ -512,6 +548,7 @@ type GridCardSharedProps = {
onNoteIllustrationDeleted?: (noteId: string) => void | Promise<void> onNoteIllustrationDeleted?: (noteId: string) => void | Promise<void>
onOpenHistory?: (note: Note) => void onOpenHistory?: (note: Note) => void
isOverlay?: boolean isOverlay?: boolean
selection?: NotesSelection
} }
function NotesMasonryGrid({ function NotesMasonryGrid({
@@ -624,6 +661,7 @@ function NotesGridSection({
onDeleteNote, onDeleteNote,
onMoveToNotebook, onMoveToNotebook,
onNoteIllustrationGenerated, onNoteIllustrationGenerated,
selection,
className, className,
}: Omit<GridCardSharedProps, 'note' | 'index' | 'isOverlay'> & { }: Omit<GridCardSharedProps, 'note' | 'index' | 'isOverlay'> & {
notes: Note[] notes: Note[]
@@ -650,6 +688,7 @@ function NotesGridSection({
onDeleteNote={onDeleteNote} onDeleteNote={onDeleteNote}
onMoveToNotebook={onMoveToNotebook} onMoveToNotebook={onMoveToNotebook}
onNoteIllustrationGenerated={onNoteIllustrationGenerated} onNoteIllustrationGenerated={onNoteIllustrationGenerated}
selection={selection}
/> />
) : ( ) : (
<GridCard <GridCard
@@ -665,6 +704,7 @@ function NotesGridSection({
onDeleteNote={onDeleteNote} onDeleteNote={onDeleteNote}
onMoveToNotebook={onMoveToNotebook} onMoveToNotebook={onMoveToNotebook}
onNoteIllustrationGenerated={onNoteIllustrationGenerated} onNoteIllustrationGenerated={onNoteIllustrationGenerated}
selection={selection}
/> />
), ),
)} )}
@@ -721,6 +761,7 @@ const GridCard = memo(function GridCard({
onNoteIllustrationDeleted, onNoteIllustrationDeleted,
onOpenHistory, onOpenHistory,
isOverlay = false, isOverlay = false,
selection,
}: GridCardSharedProps) { }: GridCardSharedProps) {
const router = useRouter() const router = useRouter()
const { t, language } = useLanguage() const { t, language } = useLanguage()
@@ -760,8 +801,21 @@ const GridCard = memo(function GridCard({
onNoteIllustrationGenerated={onNoteIllustrationGenerated} onNoteIllustrationGenerated={onNoteIllustrationGenerated}
onNoteIllustrationDeleted={onNoteIllustrationDeleted} onNoteIllustrationDeleted={onNoteIllustrationDeleted}
/> />
{selection && !isOverlay && (
<div className="absolute top-2 start-2 z-20">
<NoteSelectCheckbox
checked={selection.selectedIds.has(note.id)}
onToggle={() => selection.onToggle(note.id)}
label={t('notes.selectNote')}
className="shadow-sm"
/>
</div>
)}
{note.isPinned && ( {note.isPinned && (
<div className="absolute top-3 start-3 bg-background/90 backdrop-blur-sm p-1.5 rounded-full shadow-sm border border-border/40 text-amber-500"> <div className={cn(
'absolute top-3 bg-background/90 backdrop-blur-sm p-1.5 rounded-full shadow-sm border border-border/40 text-amber-500',
selection ? 'start-10' : 'start-3',
)}>
<Pin size={11} className="fill-amber-500" /> <Pin size={11} className="fill-amber-500" />
</div> </div>
)} )}

View File

@@ -229,15 +229,15 @@ export function PublicSiteChrome({
</div> </div>
<span className="font-serif text-lg">Memento</span> <span className="font-serif text-lg">Memento</span>
</div> </div>
<p className="text-sm text-white/60">{t('landing.footer.desc')}</p> <p className="text-sm text-white/80">{t('landing.footer.desc')}</p>
</div> </div>
<div className="grid grid-cols-3 gap-10 text-sm"> <div className="grid grid-cols-3 gap-10 text-sm">
{(['product', 'community', 'legal'] as const).map((section) => ( {(['product', 'community', 'legal'] as const).map((section) => (
<div key={section}> <div key={section}>
<p className="text-[13px] font-medium tracking-wide text-white/70 mb-3"> <p className="text-[13px] font-medium tracking-wide text-white/80 mb-3">
{t(`landing.footer.${section}.title`)} {t(`landing.footer.${section}.title`)}
</p> </p>
<ul className="space-y-2 text-white/70"> <ul className="space-y-2 text-white/80">
{[0, 1, 2].map((j) => { {[0, 1, 2].map((j) => {
const label = t(`landing.footer.${section}.link${j}`) const label = t(`landing.footer.${section}.link${j}`)
const href = t(`landing.footer.${section}.link${j}Href`) const href = t(`landing.footer.${section}.link${j}Href`)
@@ -258,7 +258,7 @@ export function PublicSiteChrome({
))} ))}
</div> </div>
</div> </div>
<p className="max-w-6xl mx-auto mt-12 pt-8 border-t border-white/[0.06] text-[13px] text-white/70 tracking-wide"> <p className="max-w-6xl mx-auto mt-12 pt-8 border-t border-white/[0.06] text-[13px] text-white/80 tracking-wide">
© 2026 Memento. {t('landing.footer.rights')} © 2026 Memento. {t('landing.footer.rights')}
</p> </p>
</footer> </footer>

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import { toast } from 'sonner' import { toast } from 'sonner'
import { restoreNote } from '@/app/actions/notes' import { restoreNote, restoreNotes } from '@/app/actions/notes'
import { emitNoteChange } from '@/lib/note-change-sync' import { emitNoteChange } from '@/lib/note-change-sync'
import type { Note } from '@/lib/types' import type { Note } from '@/lib/types'
@@ -27,3 +27,31 @@ export function showNoteTrashedToast(
}, },
}) })
} }
export function showNotesTrashedToast(
notes: Note[],
t: (key: string, params?: Record<string, string | number>) => string,
onRestored?: () => void,
) {
if (notes.length === 1) {
showNoteTrashedToast(notes[0], t, onRestored)
return
}
toast.success(t('notes.bulkTrashedToast', { count: notes.length }), {
action: {
label: t('notes.undoDelete'),
onClick: async () => {
try {
await restoreNotes(notes.map((note) => note.id), { skipRevalidation: true })
for (const note of notes) {
emitNoteChange({ type: 'created', note: { ...note, trashedAt: null } })
}
onRestored?.()
toast.success(t('trash.noteRestored'))
} catch {
toast.error(t('general.error'))
}
},
},
})
}

View File

@@ -114,6 +114,17 @@
"title": "Notes", "title": "Notes",
"newNote": "New note", "newNote": "New note",
"reorganize": "Reorganize notes", "reorganize": "Reorganize notes",
"selectAll": "Select all",
"deselectAll": "Deselect all",
"selectedCount": "{count} notes selected",
"selectedCountOne": "1 note selected",
"bulkMove": "Move",
"bulkTrash": "Trash",
"selectNote": "Select this note",
"confirmBulkDeleteTitle": "Send to trash",
"confirmBulkDelete": "These notes will go to the trash. You can restore them later.",
"bulkTrashedToast": "{count} notes sent to trash.",
"bulkMovedToast": "{count} notes moved.",
"untitled": "Untitled", "untitled": "Untitled",
"placeholder": "Take a note...", "placeholder": "Take a note...",
"markdownPlaceholder": "Take a note... (Markdown supported)", "markdownPlaceholder": "Take a note... (Markdown supported)",

View File

@@ -114,6 +114,17 @@
"title": "Notes", "title": "Notes",
"newNote": "Nouvelle note", "newNote": "Nouvelle note",
"reorganize": "Réorganiser les notes", "reorganize": "Réorganiser les notes",
"selectAll": "Tout sélectionner",
"deselectAll": "Tout désélectionner",
"selectedCount": "{count} notes sélectionnées",
"selectedCountOne": "1 note sélectionnée",
"bulkMove": "Déplacer",
"bulkTrash": "Corbeille",
"selectNote": "Sélectionner cette note",
"confirmBulkDeleteTitle": "Envoyer à la corbeille",
"confirmBulkDelete": "Ces notes iront dans la corbeille. Vous pourrez les récupérer ensuite.",
"bulkTrashedToast": "{count} notes envoyées à la corbeille.",
"bulkMovedToast": "{count} notes déplacées.",
"untitled": "Sans titre", "untitled": "Sans titre",
"placeholder": "Prenez une note...", "placeholder": "Prenez une note...",
"markdownPlaceholder": "Prenez une note... (Markdown supporté)", "markdownPlaceholder": "Prenez une note... (Markdown supporté)",