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:
@@ -16,10 +16,14 @@ export function ConfirmDeleteNoteDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onConfirm: () => void | Promise<void>
|
||||
title?: string
|
||||
description?: string
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
|
||||
@@ -27,8 +31,8 @@ export function ConfirmDeleteNoteDialog({
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('notes.confirmDeleteTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{t('notes.confirmDelete')}</AlertDialogDescription>
|
||||
<AlertDialogTitle>{title ?? t('notes.confirmDeleteTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{description ?? t('notes.confirmDelete')}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
|
||||
|
||||
@@ -18,9 +18,18 @@ interface BridgeNote {
|
||||
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
|
||||
|
||||
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 } {
|
||||
if (count === 1) return { x: 50, y: 42 }
|
||||
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"
|
||||
>
|
||||
<Layers size={22} className="text-concrete/35" />
|
||||
<p className="text-xs text-concrete italic max-w-[220px]">{t('homeDashboard.mindMapEmpty')}</p>
|
||||
<span className="text-[9px] font-mono uppercase font-bold text-brand-accent">{t('homeDashboard.mindMapOpen')}</span>
|
||||
<p className="text-[13px] text-ink/80 dark:text-dark-ink/80 italic max-w-[220px]">{t('homeDashboard.mindMapEmpty')}</p>
|
||||
<span className="text-[13px] font-semibold uppercase tracking-wider text-brand-accent">{t('homeDashboard.mindMapOpen')}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -85,7 +94,7 @@ export function DashboardMindOrbit({
|
||||
<button
|
||||
type="button"
|
||||
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')}
|
||||
<ArrowUpRight size={10} />
|
||||
@@ -108,11 +117,11 @@ export function DashboardMindOrbit({
|
||||
d={`M 50 44 L ${point.x} ${point.y}`}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={0.7}
|
||||
strokeWidth={1.2}
|
||||
strokeLinecap="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
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 }}
|
||||
/>
|
||||
)
|
||||
@@ -149,21 +158,22 @@ export function DashboardMindOrbit({
|
||||
transition={{ duration: reduced ? 0 : 0.38, delay: reduced ? 0 : 0.18 + idx * 0.05, ease: EASE }}
|
||||
>
|
||||
<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={{
|
||||
width: size,
|
||||
height: size,
|
||||
backgroundColor: `${color}cc`,
|
||||
borderColor: `${color}40`,
|
||||
fontSize: Math.max(9, size * 0.22),
|
||||
backgroundColor: color,
|
||||
borderColor: color,
|
||||
color: clusterInk(color),
|
||||
fontSize: Math.max(13, size * 0.28),
|
||||
}}
|
||||
>
|
||||
{cluster.noteIds.length}
|
||||
</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}
|
||||
</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}
|
||||
</span>
|
||||
</motion.button>
|
||||
@@ -182,12 +192,12 @@ export function DashboardMindOrbit({
|
||||
>
|
||||
<Zap size={11} className="text-brand-accent shrink-0" />
|
||||
<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')}
|
||||
</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>
|
||||
<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)}%
|
||||
</span>
|
||||
</motion.button>
|
||||
|
||||
@@ -4,7 +4,7 @@ import React, { useState, useEffect, useCallback, useRef, useTransition, useMemo
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import dynamic from 'next/dynamic'
|
||||
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 {
|
||||
NOTES_LAYOUT_STORAGE_KEY,
|
||||
@@ -36,7 +36,8 @@ import { toast } from 'sonner'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import { isDashboardHomeRoute } from '@/lib/dashboard/home-route'
|
||||
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'
|
||||
@@ -163,6 +164,9 @@ export function HomeClient({
|
||||
const [showStudyPlanner, setShowStudyPlanner] = useState(false)
|
||||
const [showOrganizer, setShowOrganizer] = useState(false)
|
||||
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(() => {
|
||||
if (!searchParams.get('notebook')) return
|
||||
@@ -194,6 +198,10 @@ export function HomeClient({
|
||||
}, [searchParams])
|
||||
|
||||
const notebookFilter = searchParams.get('notebook')
|
||||
const isInboxView =
|
||||
!notebookFilter &&
|
||||
searchParams.get('shared') !== '1' &&
|
||||
searchParams.get('reminders') !== '1'
|
||||
const schemaHook = useNotebookSchema(notebookFilter)
|
||||
const structuredModeActive = Boolean(notebookFilter && schemaHook.schema)
|
||||
const wantsStructuredView = Boolean(
|
||||
@@ -602,6 +610,68 @@ export function HomeClient({
|
||||
[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>) => {
|
||||
setNotes((prev) => {
|
||||
const next = prev.map((n) => (n.id === noteId ? { ...n, ...patch } : n))
|
||||
@@ -827,6 +897,27 @@ export function HomeClient({
|
||||
return sortedNotes.filter(n => n.isPinned)
|
||||
}, [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> = {
|
||||
newest: t('sidebar.sortNewest'),
|
||||
oldest: t('sidebar.sortOldest'),
|
||||
@@ -928,7 +1019,9 @@ export function HomeClient({
|
||||
? t('sidebar.sharedWithMe')
|
||||
: searchParams.get('reminders') === '1'
|
||||
? t('sidebar.reminders')
|
||||
: t('notes.title')}
|
||||
: isInboxView
|
||||
? t('sidebar.inbox')
|
||||
: t('notes.title')}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1224,6 +1317,18 @@ export function HomeClient({
|
||||
</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 && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -1381,6 +1486,14 @@ export function HomeClient({
|
||||
onNoteIllustrationGenerated={handleNoteIllustrationGenerated}
|
||||
onNoteIllustrationDeleted={handleNoteIllustrationDeleted}
|
||||
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>
|
||||
@@ -1499,11 +1612,16 @@ export function HomeClient({
|
||||
)}
|
||||
|
||||
<ConfirmDeleteNoteDialog
|
||||
open={notePendingDelete != null}
|
||||
open={notePendingDelete != null || bulkTrashOpen}
|
||||
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 && (
|
||||
|
||||
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
|
||||
align?: 'start' | 'end'
|
||||
preferDropUp?: boolean
|
||||
showGeneralNotes?: boolean
|
||||
}
|
||||
|
||||
export function MoveToNotebookPicker({
|
||||
@@ -26,6 +27,7 @@ export function MoveToNotebookPicker({
|
||||
children,
|
||||
align = 'end',
|
||||
preferDropUp = false,
|
||||
showGeneralNotes = true,
|
||||
}: MoveToNotebookPickerProps) {
|
||||
const { t } = useLanguage()
|
||||
const [open, setOpen] = useState(false)
|
||||
@@ -91,7 +93,7 @@ export function MoveToNotebookPicker({
|
||||
selectedId={currentNotebookId}
|
||||
onSelect={(id) => handleSelect(id)}
|
||||
onClose={close}
|
||||
showGeneralNotes
|
||||
showGeneralNotes={showGeneralNotes}
|
||||
generalNotesLabel={t('notebookSuggestion.generalNotes') || 'Notes générales'}
|
||||
onSelectGeneralNotes={() => handleSelect(null)}
|
||||
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 { ConfirmDeleteNoteDialog } from '@/components/confirm-delete-note-dialog'
|
||||
import { showNoteTrashedToast } from '@/lib/notes/trash-toast'
|
||||
import { NoteSelectCheckbox } from '@/components/note-select-checkbox'
|
||||
import { fr } from 'date-fns/locale/fr'
|
||||
import { enUS } from 'date-fns/locale/en-US'
|
||||
import { formatAbsoluteDateLocalized } from '@/lib/utils/format-localized-date'
|
||||
@@ -37,6 +38,10 @@ type NotesEditorialViewProps = {
|
||||
onOpen: (note: Note, readOnly?: boolean) => void
|
||||
notebookName?: string
|
||||
onOpenHistory?: (note: Note) => void
|
||||
selection?: {
|
||||
selectedIds: ReadonlySet<string>
|
||||
onToggle: (id: string) => void
|
||||
}
|
||||
} & NoteCollectionActions
|
||||
|
||||
function formatNoteDate(date: Date | string, language: string): string {
|
||||
@@ -376,6 +381,7 @@ export function NotesEditorialView({
|
||||
onMoveToNotebook,
|
||||
onNotePatch,
|
||||
onNoteIllustrationGenerated,
|
||||
selection,
|
||||
}: NotesEditorialViewProps) {
|
||||
const { t, language } = useLanguage()
|
||||
const { data: session } = useSession()
|
||||
@@ -454,7 +460,14 @@ export function NotesEditorialView({
|
||||
</div>
|
||||
|
||||
<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.historyEnabled && <History size={14} className="text-emerald-500 shrink-0" />}
|
||||
{title}
|
||||
|
||||
@@ -53,6 +53,7 @@ import { useHydrated } from '@/lib/use-hydrated'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale/fr'
|
||||
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 NotesClassicLayoutMode = 'grid' | 'list' | 'table'
|
||||
@@ -253,6 +254,12 @@ type NotesListViewsProps = {
|
||||
onOpenHistory?: (note: Note) => void
|
||||
notebookName?: string
|
||||
onGridReorder?: (orderedIds: string[]) => void | Promise<void>
|
||||
selection?: {
|
||||
selectedIds: ReadonlySet<string>
|
||||
onToggle: (id: string) => void
|
||||
onToggleAll?: () => void
|
||||
allSelected?: boolean
|
||||
}
|
||||
} & Partial<NoteCollectionActions>
|
||||
|
||||
export function NotesListViews({
|
||||
@@ -269,6 +276,7 @@ export function NotesListViews({
|
||||
onNotePatch,
|
||||
onNoteIllustrationGenerated,
|
||||
onGridReorder,
|
||||
selection,
|
||||
}: NotesListViewsProps) {
|
||||
const { t, language } = useLanguage()
|
||||
const { data: session } = useSession()
|
||||
@@ -352,6 +360,7 @@ export function NotesListViews({
|
||||
onOpenHistory={onOpenHistory}
|
||||
onGridReorder={onGridReorder}
|
||||
pinnedLabel={t('notes.pinned')}
|
||||
selection={selection}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -363,6 +372,15 @@ export function NotesListViews({
|
||||
<table className="w-full text-left border-collapse min-w-[720px]">
|
||||
<thead>
|
||||
<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
|
||||
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')}
|
||||
@@ -407,6 +425,15 @@ export function NotesListViews({
|
||||
role="button"
|
||||
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]">
|
||||
<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" />}
|
||||
@@ -464,6 +491,7 @@ export function NotesListViews({
|
||||
onMoveToNotebook={onMoveToNotebook}
|
||||
onNotePatch={onNotePatch}
|
||||
onNoteIllustrationGenerated={onNoteIllustrationGenerated}
|
||||
selection={selection}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -479,6 +507,7 @@ export function NotesListViews({
|
||||
onMoveToNotebook={onMoveToNotebook}
|
||||
onNotePatch={onNotePatch}
|
||||
onNoteIllustrationGenerated={onNoteIllustrationGenerated}
|
||||
selection={selection}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -497,6 +526,13 @@ function formatGridCardDate(date: Date | string, language: string): string {
|
||||
return `${month.toUpperCase()} ${day}, ${year}`
|
||||
}
|
||||
|
||||
type NotesSelection = {
|
||||
selectedIds: ReadonlySet<string>
|
||||
onToggle: (id: string) => void
|
||||
onToggleAll?: () => void
|
||||
allSelected?: boolean
|
||||
}
|
||||
|
||||
type GridCardSharedProps = {
|
||||
note: Note
|
||||
index: number
|
||||
@@ -512,6 +548,7 @@ type GridCardSharedProps = {
|
||||
onNoteIllustrationDeleted?: (noteId: string) => void | Promise<void>
|
||||
onOpenHistory?: (note: Note) => void
|
||||
isOverlay?: boolean
|
||||
selection?: NotesSelection
|
||||
}
|
||||
|
||||
function NotesMasonryGrid({
|
||||
@@ -624,6 +661,7 @@ function NotesGridSection({
|
||||
onDeleteNote,
|
||||
onMoveToNotebook,
|
||||
onNoteIllustrationGenerated,
|
||||
selection,
|
||||
className,
|
||||
}: Omit<GridCardSharedProps, 'note' | 'index' | 'isOverlay'> & {
|
||||
notes: Note[]
|
||||
@@ -650,6 +688,7 @@ function NotesGridSection({
|
||||
onDeleteNote={onDeleteNote}
|
||||
onMoveToNotebook={onMoveToNotebook}
|
||||
onNoteIllustrationGenerated={onNoteIllustrationGenerated}
|
||||
selection={selection}
|
||||
/>
|
||||
) : (
|
||||
<GridCard
|
||||
@@ -665,6 +704,7 @@ function NotesGridSection({
|
||||
onDeleteNote={onDeleteNote}
|
||||
onMoveToNotebook={onMoveToNotebook}
|
||||
onNoteIllustrationGenerated={onNoteIllustrationGenerated}
|
||||
selection={selection}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
@@ -721,6 +761,7 @@ const GridCard = memo(function GridCard({
|
||||
onNoteIllustrationDeleted,
|
||||
onOpenHistory,
|
||||
isOverlay = false,
|
||||
selection,
|
||||
}: GridCardSharedProps) {
|
||||
const router = useRouter()
|
||||
const { t, language } = useLanguage()
|
||||
@@ -760,8 +801,21 @@ const GridCard = memo(function GridCard({
|
||||
onNoteIllustrationGenerated={onNoteIllustrationGenerated}
|
||||
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 && (
|
||||
<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" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -229,15 +229,15 @@ export function PublicSiteChrome({
|
||||
</div>
|
||||
<span className="font-serif text-lg">Memento</span>
|
||||
</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 className="grid grid-cols-3 gap-10 text-sm">
|
||||
{(['product', 'community', 'legal'] as const).map((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`)}
|
||||
</p>
|
||||
<ul className="space-y-2 text-white/70">
|
||||
<ul className="space-y-2 text-white/80">
|
||||
{[0, 1, 2].map((j) => {
|
||||
const label = t(`landing.footer.${section}.link${j}`)
|
||||
const href = t(`landing.footer.${section}.link${j}Href`)
|
||||
@@ -258,7 +258,7 @@ export function PublicSiteChrome({
|
||||
))}
|
||||
</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')}
|
||||
</p>
|
||||
</footer>
|
||||
|
||||
Reference in New Issue
Block a user