fix(ui): sélection groupée dans la boîte de réception
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:
@@ -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 []
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
92
memento-note/components/inbox-bulk-bar.tsx
Normal file
92
memento-note/components/inbox-bulk-bar.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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…'}
|
||||||
|
|||||||
39
memento-note/components/note-select-checkbox.tsx
Normal file
39
memento-note/components/note-select-checkbox.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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'))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)",
|
||||||
|
|||||||
@@ -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é)",
|
||||||
|
|||||||
Reference in New Issue
Block a user