feat: smart note history with manual/auto modes, delete entries, i18n fixes
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m16s

- Add noteHistoryMode setting (manual default / auto) with DB migration
- Manual mode: commit button in editor toolbar creates snapshots on demand
- Auto mode: smart snapshots with 20-char diff threshold + 5min cooldown,
  structural changes (color, pin, archive, labels) bypass cooldown
- Add delete individual history entries from history modal
- Fix sidebar: Notes nav no longer active on notebook pages
- Fix sidebar icon: replace filled Lightbulb with outlined FileText
- Fix title suggestions: change from amber to sky blue color scheme
- Fix hydration mismatch: add suppressHydrationWarning on locale dates
- Complete i18n: add history, sort, and AI chat translations for all 16 languages
- Translate French AI assistant section (40+ keys) from English to French
- Update README with new features and stack info

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-28 21:05:55 +02:00
parent ed807d3b2a
commit 69ea064ca8
40 changed files with 2110 additions and 250 deletions

View File

@@ -124,7 +124,7 @@ export function AgentRunLog({ agentId, agentName, onClose }: AgentRunLogProps) {
<span className="text-sm font-medium text-foreground">
{t(statusKeys[action.status] || action.status)}
</span>
<span className="text-xs text-muted-foreground">
<span className="text-xs text-muted-foreground" suppressHydrationWarning>
{formatDistanceToNow(new Date(action.createdAt), { addSuffix: true, locale: dateLocale })}
</span>
</div>

View File

@@ -24,6 +24,8 @@ interface AISettingsPanelProps {
demoMode: boolean
languageDetection: boolean
autoLabeling: boolean
noteHistory: boolean
noteHistoryMode: 'manual' | 'auto'
}
}
@@ -179,6 +181,53 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
onChange={(checked) => handleToggle('autoLabeling', checked)}
/>
<FeatureToggle
name="Historique des notes"
description="Active les snapshots de versions et la restauration depuis History"
checked={settings.noteHistory ?? false}
onChange={(checked) => handleToggle('noteHistory', checked)}
/>
{settings.noteHistory && (
<div className="space-y-2 rounded-lg border border-border/50 bg-muted/30 p-3">
<p className="text-sm font-medium">{t('notes.historyMode') || 'Mode d\'historique'}</p>
<RadioGroup
value={settings.noteHistoryMode ?? 'manual'}
onValueChange={(value) => {
const mode = value as 'manual' | 'auto'
setSettings((s) => ({ ...s, noteHistoryMode: mode }))
updateAISettings({ noteHistoryMode: mode }).then(() => {
toast.success(t('settings.settingsSaved') || 'Saved')
})
}}
className="space-y-2"
>
<div className="flex items-start gap-2">
<RadioGroupItem value="manual" id="history-manual" />
<div className="grid gap-0.5 leading-none">
<Label htmlFor="history-manual" className="text-sm font-medium">
{t('notes.historyModeManual') || 'Manuel'}
</Label>
<p className="text-xs text-muted-foreground">
{t('notes.historyModeManualDesc') || 'Créer des snapshots avec le bouton commit'}
</p>
</div>
</div>
<div className="flex items-start gap-2">
<RadioGroupItem value="auto" id="history-auto" />
<div className="grid gap-0.5 leading-none">
<Label htmlFor="history-auto" className="text-sm font-medium">
{t('notes.historyModeAuto') || 'Automatique'}
</Label>
<p className="text-xs text-muted-foreground">
{t('notes.historyModeAutoDesc') || 'Snapshots automatiques avec détection intelligente'}
</p>
</div>
</div>
</RadioGroup>
</div>
)}
{/* Demo Mode Toggle */}
<DemoModeToggle
demoMode={settings.demoMode}

View File

@@ -82,7 +82,7 @@ export function ChatSidebar({
{chat.title || t('chat.untitled')}
</span>
</div>
<span className="text-[10px] opacity-60 ml-6">
<span className="text-[10px] opacity-60 ml-6" suppressHydrationWarning>
{formatDistanceToNow(new Date(chat.updatedAt), { addSuffix: true, locale: dateLocale })}
</span>
</div>

View File

@@ -4,7 +4,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'
import { useSearchParams, useRouter } from 'next/navigation'
import dynamic from 'next/dynamic'
import { Note } from '@/lib/types'
import { getAISettings } from '@/app/actions/ai-settings'
import { updateAISettings } from '@/app/actions/ai-settings'
import { getAllNotes, searchNotes } from '@/app/actions/notes'
import { NoteInput } from '@/components/note-input'
import { NotesMainSection, type NotesViewMode } from '@/components/notes-main-section'
@@ -24,6 +24,7 @@ import { cn } from '@/lib/utils'
import { LabelFilter } from '@/components/label-filter'
import { useLanguage } from '@/lib/i18n'
import { useHomeView } from '@/context/home-view-context'
import { NoteHistoryModal } from '@/components/note-history-modal'
// Lazy-load heavy dialogs — uniquement chargés à la demande
const NoteEditor = dynamic(
@@ -42,6 +43,8 @@ const AutoLabelSuggestionDialog = dynamic(
type InitialSettings = {
showRecentNotes: boolean
notesViewMode: 'masonry' | 'tabs'
noteHistory: boolean
noteHistoryMode: 'manual' | 'auto'
}
interface HomeClientProps {
@@ -59,10 +62,14 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
initialNotes.filter(n => n.isPinned)
)
const [notesViewMode, setNotesViewMode] = useState<NotesViewMode>(initialSettings.notesViewMode)
const [noteHistoryEnabled, setNoteHistoryEnabled] = useState(initialSettings.noteHistory)
const [noteHistoryMode] = useState<'manual' | 'auto'>(initialSettings.noteHistoryMode)
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null)
const [isLoading, setIsLoading] = useState(false) // false by default — data is pre-loaded
const [notebookSuggestion, setNotebookSuggestion] = useState<{ noteId: string; content: string } | null>(null)
const [batchOrganizationOpen, setBatchOrganizationOpen] = useState(false)
const [historyOpen, setHistoryOpen] = useState(false)
const [historyNote, setHistoryNote] = useState<Note | null>(null)
const { refreshKey, triggerRefresh } = useNoteRefresh()
const { labels } = useLabels()
const { setControls } = useHomeView()
@@ -133,6 +140,22 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
if (note) setEditingNote({ note, readOnly: false })
}
const handleOpenHistory = useCallback((note: Note) => {
setHistoryNote(note)
setHistoryOpen(true)
}, [])
const handleEnableHistory = useCallback(async () => {
await updateAISettings({ noteHistory: true })
setNoteHistoryEnabled(true)
}, [])
const handleHistoryRestored = useCallback((restored: Note) => {
setNotes((prev) => prev.map((n) => (n.id === restored.id ? { ...n, ...restored } : n)))
setPinnedNotes((prev) => prev.map((n) => (n.id === restored.id ? { ...n, ...restored } : n)))
setEditingNote((prev) => (prev?.note.id === restored.id ? { ...prev, note: restored } : prev))
}, [])
const handleSizeChange = useCallback((noteId: string, size: 'small' | 'medium' | 'large') => {
setNotes(prev => prev.map(n => n.id === noteId ? { ...n, size } : n))
setPinnedNotes(prev => prev.map(n => n.id === noteId ? { ...n, size } : n))
@@ -388,6 +411,10 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
onSizeChange={handleSizeChange}
currentNotebookId={searchParams.get('notebook')}
noteHistoryEnabled={noteHistoryEnabled}
noteHistoryMode={noteHistoryMode}
onOpenHistory={handleOpenHistory}
onEnableHistory={handleEnableHistory}
/>
</div>
)}
@@ -437,6 +464,15 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
onClose={() => setEditingNote(null)}
/>
)}
<NoteHistoryModal
open={historyOpen}
onOpenChange={setHistoryOpen}
note={historyNote}
enabled={noteHistoryEnabled}
onEnableHistory={handleEnableHistory}
onRestored={handleHistoryRestored}
/>
</div>
)
}

View File

@@ -39,6 +39,9 @@ interface MasonryGridProps {
onEdit?: (note: Note, readOnly?: boolean) => void;
onSizeChange?: (noteId: string, size: 'small' | 'medium' | 'large') => void;
isTrashView?: boolean;
noteHistoryEnabled?: boolean;
noteHistoryMode?: 'manual' | 'auto';
onOpenHistory?: (note: Note) => void;
}
// ─────────────────────────────────────────────
@@ -53,6 +56,8 @@ interface SortableNoteProps {
isDragging?: boolean;
isOverlay?: boolean;
isTrashView?: boolean;
noteHistoryEnabled?: boolean;
onOpenHistory?: (note: Note) => void;
}
const SortableNoteItem = memo(function SortableNoteItem({
@@ -64,6 +69,8 @@ const SortableNoteItem = memo(function SortableNoteItem({
isDragging,
isOverlay,
isTrashView,
noteHistoryEnabled,
onOpenHistory,
}: SortableNoteProps) {
const {
attributes,
@@ -98,6 +105,8 @@ const SortableNoteItem = memo(function SortableNoteItem({
isDragging={isDragging}
isTrashView={isTrashView}
onSizeChange={(newSize) => onSizeChange(note.id, newSize)}
noteHistoryEnabled={noteHistoryEnabled}
onOpenHistory={onOpenHistory}
/>
</div>
);
@@ -114,6 +123,8 @@ interface SortableGridSectionProps {
onDragStartNote: (noteId: string) => void;
onDragEndNote: () => void;
isTrashView?: boolean;
noteHistoryEnabled?: boolean;
onOpenHistory?: (note: Note) => void;
}
const SortableGridSection = memo(function SortableGridSection({
@@ -124,6 +135,8 @@ const SortableGridSection = memo(function SortableGridSection({
onDragStartNote,
onDragEndNote,
isTrashView,
noteHistoryEnabled,
onOpenHistory,
}: SortableGridSectionProps) {
const ids = useMemo(() => notes.map(n => n.id), [notes]);
@@ -140,6 +153,8 @@ const SortableGridSection = memo(function SortableGridSection({
onDragEndNote={onDragEndNote}
isDragging={draggedNoteId === note.id}
isTrashView={isTrashView}
noteHistoryEnabled={noteHistoryEnabled}
onOpenHistory={onOpenHistory}
/>
))}
</div>
@@ -150,7 +165,14 @@ const SortableGridSection = memo(function SortableGridSection({
// ─────────────────────────────────────────────
// Main MasonryGrid component
// ─────────────────────────────────────────────
export function MasonryGrid({ notes, onEdit, onSizeChange, isTrashView }: MasonryGridProps) {
export function MasonryGrid({
notes,
onEdit,
onSizeChange,
isTrashView,
noteHistoryEnabled = false,
onOpenHistory,
}: MasonryGridProps) {
const { t } = useLanguage();
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null);
const { startDrag, endDrag, draggedNoteId } = useNotebookDrag();
@@ -261,6 +283,8 @@ export function MasonryGrid({ notes, onEdit, onSizeChange, isTrashView }: Masonr
onDragStartNote={startDrag}
onDragEndNote={endDrag}
isTrashView={isTrashView}
noteHistoryEnabled={noteHistoryEnabled}
onOpenHistory={onOpenHistory}
/>
</div>
)}
@@ -280,6 +304,8 @@ export function MasonryGrid({ notes, onEdit, onSizeChange, isTrashView }: Masonr
onDragStartNote={startDrag}
onDragEndNote={endDrag}
isTrashView={isTrashView}
noteHistoryEnabled={noteHistoryEnabled}
onOpenHistory={onOpenHistory}
/>
</div>
)}
@@ -294,6 +320,8 @@ export function MasonryGrid({ notes, onEdit, onSizeChange, isTrashView }: Masonr
onEdit={handleEdit}
isDragging={true}
onSizeChange={(newSize) => handleSizeChange(activeNote.id, newSize)}
noteHistoryEnabled={noteHistoryEnabled}
onOpenHistory={onOpenHistory}
/>
</div>
) : null}

View File

@@ -17,6 +17,7 @@ import {
FileText,
Trash2,
RotateCcw,
History,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { NOTE_COLORS } from "@/lib/types"
@@ -38,6 +39,8 @@ interface NoteActionsProps {
isTrashView?: boolean
onRestore?: () => void
onPermanentDelete?: () => void
onOpenHistory?: () => void
historyEnabled?: boolean
className?: string
}
@@ -57,6 +60,8 @@ export function NoteActions({
isTrashView,
onRestore,
onPermanentDelete,
onOpenHistory,
historyEnabled = false,
className
}: NoteActionsProps) {
const { t } = useLanguage()
@@ -176,6 +181,15 @@ export function NoteActions({
)}
</DropdownMenuItem>
{onOpenHistory && (
<DropdownMenuItem onClick={onOpenHistory}>
<History className="h-4 w-4 mr-2" />
{historyEnabled
? (t('notes.history') || 'Historique')
: (t('notes.enableHistory') || "Activer l'historique")}
</DropdownMenuItem>
)}
{/* Size Selector */}
{onSizeChange && (
<>

View File

@@ -115,6 +115,8 @@ interface NoteCardProps {
onResize?: () => void
onSizeChange?: (newSize: 'small' | 'medium' | 'large') => void
isTrashView?: boolean
noteHistoryEnabled?: boolean
onOpenHistory?: (note: Note) => void
}
// Helper function to get initials from name
@@ -153,7 +155,9 @@ export const NoteCard = memo(function NoteCard({
isDragging,
onResize,
onSizeChange,
isTrashView
isTrashView,
noteHistoryEnabled = false,
onOpenHistory,
}: NoteCardProps) {
const router = useRouter()
const searchParams = useSearchParams()
@@ -610,7 +614,7 @@ export const NoteCard = memo(function NoteCard({
{/* Footer with Date only */}
<div className="mt-3 flex items-center justify-end">
{/* Creation Date */}
<div className="text-xs text-muted-foreground">
<div className="text-xs text-muted-foreground" suppressHydrationWarning>
{formatDistanceToNow(new Date(note.createdAt), { addSuffix: true, locale: getDateLocale(language) })}
</div>
</div>
@@ -645,6 +649,8 @@ export const NoteCard = memo(function NoteCard({
isTrashView={isTrashView}
onRestore={handleRestore}
onPermanentDelete={handlePermanentDelete}
onOpenHistory={() => onOpenHistory?.(note)}
historyEnabled={noteHistoryEnabled}
className="absolute bottom-0 left-0 right-0 p-2 opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity"
/>
)}

View File

@@ -0,0 +1,249 @@
'use client'
import { useEffect, useMemo, useState, useTransition } from 'react'
import { formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale/fr'
import { enUS } from 'date-fns/locale/en-US'
import { History, Loader2, RotateCcw, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import { getNoteHistory, restoreNoteVersion, deleteNoteHistoryEntry } from '@/app/actions/notes'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { useLanguage } from '@/lib/i18n'
import type { Note, NoteHistoryEntry } from '@/lib/types'
import { cn } from '@/lib/utils'
interface NoteHistoryModalProps {
open: boolean
onOpenChange: (open: boolean) => void
note: Note | null
enabled: boolean
onEnableHistory: () => Promise<void>
onRestored: (note: Note) => void
}
function getDateLocale(language: string) {
if (language === 'fr') return fr
return enUS
}
export function NoteHistoryModal({
open,
onOpenChange,
note,
enabled,
onEnableHistory,
onRestored,
}: NoteHistoryModalProps) {
const { t, language } = useLanguage()
const [entries, setEntries] = useState<NoteHistoryEntry[]>([])
const [selectedId, setSelectedId] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [isRestoring, startRestoring] = useTransition()
const [isEnabling, startEnabling] = useTransition()
useEffect(() => {
if (!open || !note || !enabled) return
let cancelled = false
setIsLoading(true)
getNoteHistory(note.id, 50)
.then((result) => {
if (cancelled) return
setEntries(result)
setSelectedId(result[0]?.id ?? null)
})
.catch((error) => {
console.error('Failed to load note history:', error)
toast.error(t('general.error'))
})
.finally(() => {
if (!cancelled) setIsLoading(false)
})
return () => {
cancelled = true
}
}, [open, note, enabled, t])
const selectedEntry = useMemo(
() => entries.find((entry) => entry.id === selectedId) ?? null,
[entries, selectedId]
)
const handleRestore = () => {
if (!note || !selectedEntry) return
startRestoring(async () => {
try {
const restored = await restoreNoteVersion(note.id, selectedEntry.id)
onRestored(restored)
toast.success(t('notes.historyRestored') || 'Version restaurée')
} catch (error) {
console.error('Failed to restore history entry:', error)
toast.error(t('general.error'))
}
})
}
const handleEnable = () => {
startEnabling(async () => {
try {
await onEnableHistory()
toast.success(t('notes.historyEnabled') || 'History activé')
} catch (error) {
console.error('Failed to enable history:', error)
toast.error(t('general.error'))
}
})
}
const handleDeleteEntry = (entryId: string) => {
if (!note) return
if (!confirm(t('notes.deleteVersionConfirm') || 'Supprimer cette version définitivement ?')) return
startRestoring(async () => {
try {
await deleteNoteHistoryEntry(note.id, entryId)
setEntries((prev) => prev.filter((e) => e.id !== entryId))
if (selectedId === entryId) {
setSelectedId(null)
}
toast.success(t('notes.versionDeleted') || 'Version supprimée')
} catch (error) {
console.error('Failed to delete history entry:', error)
toast.error(t('general.error'))
}
})
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-5xl p-0">
<DialogHeader className="border-b border-border/60 px-6 py-4">
<DialogTitle className="flex items-center gap-2">
<History className="h-4 w-4 text-primary" />
{t('notes.history') || 'Historique'}
</DialogTitle>
<DialogDescription>
{note?.title || t('notes.untitled') || 'Sans titre'}
</DialogDescription>
</DialogHeader>
{!enabled ? (
<div className="space-y-3 px-6 py-8">
<p className="text-sm text-muted-foreground">
{t('notes.historyDisabledDesc') || "L'historique est désactivé pour votre compte."}
</p>
<Button onClick={handleEnable} disabled={isEnabling}>
{isEnabling && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('notes.enableHistory') || "Activer l'historique"}
</Button>
</div>
) : (
<div className="grid grid-cols-[260px_1fr] gap-0">
<div className="max-h-[60vh] overflow-y-auto border-r border-border/60 p-3">
{isLoading ? (
<div className="flex items-center gap-2 px-2 py-3 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t('general.loading')}
</div>
) : entries.length === 0 ? (
<p className="px-2 py-3 text-sm text-muted-foreground">
{t('notes.historyEmpty') || 'Aucune version disponible'}
</p>
) : (
<div className="space-y-1">
{entries.map((entry) => (
<div
key={entry.id}
className={cn(
'group/entry relative w-full rounded-md border px-2.5 py-2 text-left transition-colors',
selectedId === entry.id
? 'border-primary/40 bg-primary/8'
: 'border-border/70 hover:bg-muted/60'
)}
>
<button
type="button"
onClick={() => setSelectedId(entry.id)}
className="w-full text-left"
>
<p className="text-xs font-semibold text-foreground">
v{entry.version}
</p>
<p className="text-[11px] text-muted-foreground" suppressHydrationWarning>
{formatDistanceToNow(new Date(entry.createdAt), {
addSuffix: true,
locale: getDateLocale(language),
})}
</p>
{entry.reason && (
<p className="mt-1 line-clamp-1 text-[11px] text-muted-foreground">
{entry.reason}
</p>
)}
</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); handleDeleteEntry(entry.id) }}
className="absolute right-1.5 top-1.5 rounded p-0.5 text-muted-foreground/40 opacity-0 transition-opacity hover:text-red-500 group-hover/entry:opacity-100"
title={t('notes.deleteVersion') || 'Supprimer'}
>
<Trash2 className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
</div>
<div className="max-h-[60vh] overflow-y-auto px-6 py-4">
{selectedEntry ? (
<div className="space-y-4">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t('notes.title') || 'Titre'}
</p>
<p className="mt-1 text-sm text-foreground">
{selectedEntry.title || t('notes.untitled') || 'Sans titre'}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t('notes.content') || 'Contenu'}
</p>
<pre className="mt-1 whitespace-pre-wrap rounded-md border border-border/70 bg-muted/30 p-3 text-sm text-foreground">
{selectedEntry.content || ''}
</pre>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">
{t('notes.historySelectVersion') || 'Sélectionnez une version pour prévisualiser son contenu'}
</p>
)}
</div>
</div>
)}
<DialogFooter className="border-t border-border/60 px-6 py-3">
{enabled && selectedEntry && (
<Button onClick={handleRestore} disabled={isRestoring}>
{isRestoring
? <Loader2 className="mr-2 h-4 w-4 animate-spin" />
: <RotateCcw className="mr-2 h-4 w-4" />}
{t('notes.restore') || 'Restaurer'}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -18,8 +18,10 @@ import { useLanguage } from '@/lib/i18n'
import { cn } from '@/lib/utils'
import {
updateNote,
toggleArchive,
deleteNote,
createNote,
commitNoteHistory,
} from '@/app/actions/notes'
import { fetchLinkMetadata } from '@/app/actions/scrape'
import {
@@ -39,6 +41,8 @@ import {
Loader2,
Check,
RotateCcw,
History,
GitCommitHorizontal,
} from 'lucide-react'
import { toast } from 'sonner'
import { MarkdownContent } from '@/components/markdown-content'
@@ -62,6 +66,9 @@ interface NoteInlineEditorProps {
onDelete?: (noteId: string) => void
onArchive?: (noteId: string) => void
onChange?: (noteId: string, fields: Partial<Note>) => void
onOpenHistory?: (note: Note) => void
noteHistoryEnabled?: boolean
noteHistoryMode?: 'manual' | 'auto'
colorKey: NoteColor
/** If true and the note is a Markdown note, open directly in preview mode */
defaultPreviewMode?: boolean
@@ -90,6 +97,9 @@ export function NoteInlineEditor({
onDelete,
onArchive,
onChange,
onOpenHistory,
noteHistoryEnabled = false,
noteHistoryMode = 'manual',
colorKey,
defaultPreviewMode = false,
}: NoteInlineEditorProps) {
@@ -313,7 +323,8 @@ export function NoteInlineEditor({
startTransition(async () => {
onArchive?.(note.id)
try {
await updateNote(note.id, { isArchived: !note.isArchived }, { skipRevalidation: true })
await toggleArchive(note.id, !note.isArchived)
triggerRefresh()
} catch {
// Cannot easily revert since onArchive removes from list
toast.error(t('general.error'))
@@ -479,7 +490,13 @@ export function NoteInlineEditor({
<Button variant="ghost" size="icon"
className={cn('h-8 w-8', isMarkdown && 'text-primary bg-primary/10')}
onClick={() => { setIsMarkdown(!isMarkdown); if (isMarkdown) setShowMarkdownPreview(false); scheduleSave() }}
onClick={() => {
const nextIsMarkdown = !isMarkdown
setIsMarkdown(nextIsMarkdown)
onChange?.(note.id, { isMarkdown: nextIsMarkdown })
if (!nextIsMarkdown) setShowMarkdownPreview(false)
scheduleSave()
}}
title="Markdown">
<FileText className="h-4 w-4" />
</Button>
@@ -515,6 +532,26 @@ export function NoteInlineEditor({
{/* Right group: meta actions + save indicator */}
<div className="flex items-center gap-1">
{noteHistoryEnabled && noteHistoryMode === 'manual' && (
<Button
variant="ghost"
size="sm"
className="h-7 gap-1.5 text-xs text-primary/70 hover:text-primary"
title={t('notes.commitVersion')}
onClick={() => {
startTransition(async () => {
try {
await commitNoteHistory(note.id)
toast.success(t('notes.versionSaved'))
} catch {
toast.error(t('general.error'))
}
})
}}
>
<GitCommitHorizontal className="h-3.5 w-3.5" />
</Button>
)}
<span className="mr-1 flex items-center gap-1 text-[11px] text-muted-foreground/50 select-none">
{isSaving ? (
<><Loader2 className="h-3 w-3 animate-spin" /> {t('notes.saving')}</>
@@ -557,6 +594,16 @@ export function NoteInlineEditor({
? <><ArchiveRestore className="h-4 w-4 mr-2" />{t('notes.unarchive')}</>
: <><Archive className="h-4 w-4 mr-2" />{t('notes.archive')}</>}
</DropdownMenuItem>
{onOpenHistory && (
<DropdownMenuItem
onClick={() => onOpenHistory(note)}
>
<History className="h-4 w-4 mr-2" />
{noteHistoryEnabled
? (t('notes.history') || 'Historique')
: (t('notes.enableHistory') || "Activer l'historique")}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem className="text-red-600 dark:text-red-400" onClick={handleDelete}>
<Trash2 className="h-4 w-4 mr-2" />{t('notes.delete')}
@@ -781,9 +828,9 @@ export function NoteInlineEditor({
{/* ── Footer ───────────────────────────────────────────────────────────── */}
<div className="shrink-0 border-t border-border/20 px-8 py-2">
<div className="flex items-center gap-3 text-[11px] text-muted-foreground/40">
<span>{t('notes.modified') } {formatDistanceToNow(new Date(note.updatedAt), { addSuffix: true, locale: dateLocale })}</span>
<span suppressHydrationWarning>{t('notes.modified') } {formatDistanceToNow(new Date(note.updatedAt), { addSuffix: true, locale: dateLocale })}</span>
<span>·</span>
<span>{t('notes.created') || 'Créée'} {formatDistanceToNow(new Date(note.createdAt), { addSuffix: true, locale: dateLocale })}</span>
<span suppressHydrationWarning>{t('notes.created') || 'Créée'} {formatDistanceToNow(new Date(note.createdAt), { addSuffix: true, locale: dateLocale })}</span>
</div>
</div>

View File

@@ -25,20 +25,49 @@ interface NotesMainSectionProps {
onEdit?: (note: Note, readOnly?: boolean) => void
onSizeChange?: (noteId: string, size: 'small' | 'medium' | 'large') => void
currentNotebookId?: string | null
noteHistoryEnabled?: boolean
noteHistoryMode?: 'manual' | 'auto'
onOpenHistory?: (note: Note) => void
onEnableHistory?: () => Promise<void>
}
export function NotesMainSection({ notes, viewMode, onEdit, onSizeChange, currentNotebookId }: NotesMainSectionProps) {
export function NotesMainSection({
notes,
viewMode,
onEdit,
onSizeChange,
currentNotebookId,
noteHistoryEnabled = false,
noteHistoryMode = 'manual',
onOpenHistory,
onEnableHistory,
}: NotesMainSectionProps) {
if (viewMode === 'tabs') {
return (
<div className="flex min-h-0 flex-1 flex-col" data-testid="notes-grid-tabs-wrap">
<NotesTabsView notes={notes} onEdit={onEdit} currentNotebookId={currentNotebookId} />
<NotesTabsView
notes={notes}
onEdit={onEdit}
currentNotebookId={currentNotebookId}
noteHistoryEnabled={noteHistoryEnabled}
noteHistoryMode={noteHistoryMode}
onOpenHistory={onOpenHistory}
onEnableHistory={onEnableHistory}
/>
</div>
)
}
return (
<div data-testid="notes-grid">
<MasonryGridLazy notes={notes} onEdit={onEdit} onSizeChange={onSizeChange} />
<MasonryGridLazy
notes={notes}
onEdit={onEdit}
onSizeChange={onSizeChange}
noteHistoryEnabled={noteHistoryEnabled}
noteHistoryMode={noteHistoryMode}
onOpenHistory={onOpenHistory}
/>
</div>
)
}

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useState, useTransition } from 'react'
import { useCallback, useEffect, useMemo, useState, useTransition } from 'react'
import { useNoteRefreshOptional } from '@/context/NoteRefreshContext'
import {
DndContext,
@@ -24,17 +24,32 @@ import { cn } from '@/lib/utils'
import { NoteInlineEditor } from '@/components/note-inline-editor'
import { useLanguage } from '@/lib/i18n'
import { getNoteDisplayTitle } from '@/lib/note-preview'
import { updateFullOrderWithoutRevalidation, createNote, deleteNote } from '@/app/actions/notes'
import {
updateFullOrderWithoutRevalidation,
createNote,
deleteNote,
updateNote,
toggleArchive,
} from '@/app/actions/notes'
import { useNotebooks } from '@/context/notebooks-context'
import {
GripVertical,
Hash,
ListChecks,
Pin,
PinOff,
FileText,
Clock,
Plus,
Loader2,
Trash2,
ListFilter,
FolderInput,
Archive,
Share2,
Check,
Hash,
History,
PanelRightClose,
PanelRightOpen,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
@@ -45,8 +60,22 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { toast } from 'sonner'
import { formatDistanceToNow } from 'date-fns'
import { format, type Locale } from 'date-fns'
import { fr } from 'date-fns/locale/fr'
import { enUS } from 'date-fns/locale/en-US'
@@ -54,8 +83,14 @@ interface NotesTabsViewProps {
notes: Note[]
onEdit?: (note: Note, readOnly?: boolean) => void
currentNotebookId?: string | null
noteHistoryEnabled?: boolean
noteHistoryMode?: 'manual' | 'auto'
onOpenHistory?: (note: Note) => void
onEnableHistory?: () => Promise<void>
}
type SortOrder = 'date-desc' | 'date-asc' | 'title-asc' | 'title-desc'
// Color accent strip for each note
const COLOR_ACCENT: Record<NoteColor, string> = {
default: 'bg-primary',
@@ -104,9 +139,19 @@ function getColorKey(note: Note): NoteColor {
}
function getDateLocale(language: string) {
if (language === 'fr') return fr;
if (language === 'fa') return require('date-fns/locale').faIR;
return enUS;
if (language === 'fr') return fr
if (language === 'fa') return require('date-fns/locale').faIR
return enUS
}
function formatNoteDate(date: Date | string, locale: Locale): string {
const d = typeof date === 'string' ? new Date(date) : date
return format(d, 'd MMM yyyy', { locale })
}
function countWords(content: string | null | undefined): number {
if (!content) return 0
return content.trim().split(/\s+/).filter(Boolean).length
}
// ─── Sortable List Item ───────────────────────────────────────────────────────
@@ -144,161 +189,445 @@ function SortableNoteListItem({
const title = getNoteDisplayTitle(note, untitledLabel)
const snippet =
note.type === 'checklist'
? (note.checkItems?.map((i) => i.text).join(' · ') || '').substring(0, 150)
: (note.content || '').substring(0, 150)
? (note.checkItems?.map((i) => i.text).join(' · ') || '').substring(0, 200)
: (note.content || '').substring(0, 200)
const dateLocale = getDateLocale(language)
const timeAgo = formatDistanceToNow(new Date(note.updatedAt), {
addSuffix: true,
locale: dateLocale,
})
const dateStr = formatNoteDate(note.updatedAt, dateLocale)
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'group relative flex cursor-pointer select-none items-stretch gap-0 transition-all duration-150',
'border-b border-border/40 last:border-b-0',
'group relative flex cursor-pointer select-none items-stretch transition-all duration-150',
'border-b border-border/50 last:border-b-0',
selected
? 'bg-primary/5 dark:bg-primary/10 shadow-sm'
: 'hover:bg-muted/50',
isDragging && 'opacity-80 shadow-xl ring-2 ring-primary/30 rounded-lg'
? 'bg-primary/[0.06] dark:bg-primary/10'
: 'bg-background hover:bg-muted/40 dark:hover:bg-muted/20',
isDragging && 'opacity-75 shadow-lg ring-1 ring-primary/20'
)}
onClick={onSelect}
role="option"
aria-selected={selected}
>
{/* Color accent bar */}
{/* Left accent bar — solid when selected, transparent otherwise */}
<div
className={cn(
'w-1 shrink-0 transition-all duration-200',
selected ? COLOR_ACCENT[ck] : 'bg-transparent group-hover:bg-border/40'
'w-[3px] shrink-0 rounded-r-full transition-all duration-200',
selected ? COLOR_ACCENT[ck] : 'bg-transparent'
)}
/>
{/* Drag handle */}
<button
type="button"
className="flex cursor-grab items-center px-1.5 text-muted-foreground/30 opacity-0 transition-opacity group-hover:opacity-100 active:cursor-grabbing"
aria-label={reorderLabel}
{...attributes}
{...listeners}
onClick={(e) => e.stopPropagation()}
>
<GripVertical className="h-3.5 w-3.5" />
</button>
{/* Main card content */}
<div className="min-w-0 flex-1 px-4 py-4">
{/* Note type icon */}
<div className="flex items-center py-4 pe-1">
{note.type === 'checklist' ? (
<ListChecks
className={cn(
'h-4 w-4 shrink-0 transition-colors',
selected ? COLOR_ICON[ck] : 'text-muted-foreground/50 group-hover:text-muted-foreground'
{/* Row 1: type icon + date */}
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5">
{note.type === 'checklist' ? (
<ListChecks
className={cn(
'h-3.5 w-3.5 shrink-0',
selected ? COLOR_ICON[ck] : 'text-muted-foreground/40'
)}
/>
) : (
<FileText
className={cn(
'h-3.5 w-3.5 shrink-0',
selected ? COLOR_ICON[ck] : 'text-muted-foreground/40'
)}
/>
)}
/>
) : (
<FileText
className={cn(
'h-4 w-4 shrink-0 transition-colors',
selected ? COLOR_ICON[ck] : 'text-muted-foreground/50 group-hover:text-muted-foreground'
{note.isPinned && (
<Pin className="h-3 w-3 shrink-0 fill-current text-primary/70" />
)}
/>
)}
</div>
{/* Text content */}
<div className="min-w-0 flex-1 py-3.5 pe-3">
<div className="flex items-center gap-2">
<p
</div>
<span
suppressHydrationWarning
className={cn(
'truncate text-base font-heading font-medium transition-colors',
selected ? 'text-foreground' : 'text-foreground/80 group-hover:text-foreground'
'shrink-0 text-[11px] tabular-nums',
selected ? 'text-muted-foreground' : 'text-muted-foreground/60'
)}
>
{title}
</p>
{note.isPinned && (
<Pin className="h-3 w-3 shrink-0 fill-current text-primary" aria-label="Épinglée" />
)}
</div>
{snippet && (
<p className="mt-0.5 truncate text-xs text-muted-foreground/70">{snippet}</p>
)}
<div className="mt-1.5 flex items-center gap-2">
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/50">
<Clock className="h-2.5 w-2.5" />
{timeAgo}
{dateStr}
</span>
</div>
{/* Row 2: title */}
<p
className={cn(
'mb-1.5 text-[13.5px] leading-snug transition-colors',
selected
? 'font-semibold text-foreground'
: 'font-medium text-foreground/85 group-hover:text-foreground'
)}
>
{title}
</p>
{/* Row 3: snippet */}
{snippet && (
<p className="line-clamp-2 text-[12px] leading-relaxed text-muted-foreground/60">
{snippet}
</p>
)}
{/* Row 4: label chips */}
{Array.isArray(note.labels) && note.labels.length > 0 && (
<div className="mt-2.5 flex flex-wrap gap-1.5">
{note.labels.slice(0, 3).map((label) => (
<span
key={label}
className={cn(
'inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-[10px] font-medium leading-none transition-colors',
selected
? 'border-primary/25 text-primary/70'
: 'border-border text-muted-foreground/65 group-hover:border-border/80'
)}
>
<span className="h-1 w-1 rounded-full bg-current opacity-60" />
{label}
</span>
))}
{note.labels.length > 3 && (
<span className="inline-flex items-center rounded-full border border-border/60 px-2 py-0.5 text-[10px] text-muted-foreground/50">
+{note.labels.length - 3}
</span>
)}
</div>
)}
</div>
{/* Actions column: drag + delete on hover */}
<div className="flex flex-col items-center justify-between py-3 pe-2 opacity-0 transition-opacity group-hover:opacity-100">
<button
type="button"
className="cursor-grab p-1 text-muted-foreground/30 active:cursor-grabbing"
aria-label={reorderLabel}
{...attributes}
{...listeners}
onClick={(e) => e.stopPropagation()}
>
<GripVertical className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
className="p-1 text-muted-foreground/40 hover:text-destructive"
aria-label={deleteLabel}
title={deleteLabel}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
)
}
// ─── Note Meta Sidebar ────────────────────────────────────────────────────────
function SidebarSection({ title }: { title: string }) {
return (
<div className="mb-3 flex items-center gap-2">
<p className="text-[10px] font-black uppercase tracking-[0.12em] text-muted-foreground whitespace-nowrap">
{title}
</p>
<div className="flex-1 h-px bg-border/60" />
</div>
)
}
function SidebarActionBtn({
icon,
label,
onClick,
disabled = false,
}: {
icon: React.ReactNode
label: string
onClick: () => void
disabled?: boolean
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={cn(
"group flex w-full items-center gap-3 rounded-md px-2.5 py-2 text-[13px] font-medium transition-all duration-150",
disabled
? "cursor-not-allowed text-muted-foreground/60 opacity-70"
: "text-foreground/70 hover:bg-sky-50 hover:text-sky-700"
)}
>
<span
className={cn(
"transition-colors",
disabled ? "text-muted-foreground/60" : "text-muted-foreground group-hover:text-sky-600"
)}
>
{icon}
</span>
{label}
</button>
)
}
function NoteMetaSidebar({
note,
onPinToggle,
onArchive,
noteHistoryEnabled = false,
onOpenHistory,
onEnableHistory,
}: {
note: Note
onPinToggle: (note: Note) => void
onArchive: (note: Note) => void
noteHistoryEnabled?: boolean
onOpenHistory?: (note: Note) => void
onEnableHistory?: () => Promise<void>
}) {
const { t } = useLanguage()
const { notebooks, moveNoteToNotebookOptimistic } = useNotebooks()
const [moveOpen, setMoveOpen] = useState(false)
const [isMoving, setIsMoving] = useState(false)
// t() returns the key itself when not found — use this wrapper for safe fallbacks
const ts = (key: string, fallback: string) => {
const v = t(key as Parameters<typeof t>[0])
return v === key ? fallback : v
}
const wordCount = countWords(note.content)
const noteTypeLabel =
note.type === 'checklist'
? ts('notes.typeChecklist', 'Checklist')
: note.isMarkdown
? ts('notes.typeMarkdown', 'Markdown')
: ts('notes.typeText', 'Text')
const handleMoveToNotebook = async (notebookId: string | null) => {
setIsMoving(true)
try {
await moveNoteToNotebookOptimistic(note.id, notebookId)
setMoveOpen(false)
toast.success(ts('notebookSuggestion.movedToNotebook', 'Note déplacée'))
} catch {
toast.error(ts('notes.moveFailed', 'Déplacement échoué'))
} finally {
setIsMoving(false)
}
}
const handleHistory = async () => {
if (!noteHistoryEnabled) {
if (!onEnableHistory) {
toast.info(ts('notes.historyDisabledDesc', "L'historique est désactivé pour votre compte."))
return
}
try {
await onEnableHistory()
toast.success(ts('notes.historyEnabled', 'Historique activé'))
onOpenHistory?.(note)
} catch {
toast.error(ts('general.error', 'Erreur'))
}
return
}
onOpenHistory?.(note)
}
return (
<aside className="flex w-56 shrink-0 flex-col bg-muted border-l border-slate-300 border-t-2 border-t-primary/40 overflow-y-auto shadow-[-6px_0_16px_-4px_rgba(0,0,0,0.08)]">
{/* ── DOCUMENT INFO ── */}
<div className="px-4 pt-5 pb-4 border-b border-border">
<SidebarSection title={ts('notes.documentInfo', 'Document Info')} />
<div className="space-y-3">
{/* Type */}
<div className="flex items-center justify-between">
<p className="text-[11px] font-semibold text-muted-foreground">{ts('notes.type', 'Type')}</p>
<span
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium",
note.type === 'checklist'
? "bg-emerald-50 border-emerald-200 text-emerald-700"
: note.isMarkdown
? "bg-violet-50 border-violet-200 text-violet-700"
: "bg-card border-slate-200 text-slate-600"
)}
>
{note.type === 'checklist'
? <ListChecks className="h-3 w-3" />
: <FileText className="h-3 w-3" />}
{noteTypeLabel}
</span>
</div>
{/* Word count — discreet inline row */}
<div className="flex items-center justify-between">
<p className="text-[11px] font-semibold text-muted-foreground">{ts('notes.wordCount', 'Mots')}</p>
<p className="text-[13px] font-semibold text-foreground/70 tabular-nums">
{wordCount.toLocaleString()} <span className="text-[10px] font-normal text-muted-foreground">{ts('notes.words', 'mots')}</span>
</p>
</div>
{/* Labels */}
{Array.isArray(note.labels) && note.labels.length > 0 && (
<>
<span className="text-muted-foreground/30">·</span>
<div className="flex items-center gap-1">
<Hash className="h-2.5 w-2.5 text-muted-foreground/40" />
<span className="truncate text-[11px] text-muted-foreground/50">
{note.labels.slice(0, 2).join(', ')}
{note.labels.length > 2 && ` +${note.labels.length - 2}`}
</span>
<div>
<p className="mb-1.5 text-[11px] font-semibold text-muted-foreground">{ts('notes.labels', 'Labels')}</p>
<div className="flex flex-wrap gap-1">
{note.labels.map((label) => (
<span
key={label}
className="inline-flex items-center gap-1 rounded-full border border-slate-200 bg-card px-2 py-0.5 text-[11px] font-medium text-foreground/70"
>
<Hash className="h-2.5 w-2.5" />
{label}
</span>
))}
</div>
</>
</div>
)}
</div>
</div>
{/* Delete button - visible on hover */}
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
className="flex items-center px-2 text-red-500/60 opacity-0 transition-opacity group-hover:opacity-100 hover:text-red-600"
aria-label={deleteLabel}
title={deleteLabel}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
{/* ── ACTIONS ── */}
<div className="px-4 pt-4 pb-5 flex-1">
<SidebarSection title={ts('notes.actions', 'Actions')} />
<div className="space-y-0.5">
{/* Move to notebook */}
<Popover open={moveOpen} onOpenChange={setMoveOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="group flex w-full items-center gap-3 rounded-md px-2.5 py-2 text-[13px] font-medium text-foreground/70 hover:bg-sky-50 hover:text-sky-700 transition-all duration-150"
>
<span className="text-muted-foreground group-hover:text-sky-600 transition-colors">
{isMoving
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <FolderInput className="h-3.5 w-3.5" />}
</span>
{t('notebookSuggestion.moveToNotebook')}
</button>
</PopoverTrigger>
<PopoverContent side="left" align="start" className="w-52 p-1.5">
<div className="mb-1 px-2 py-1 text-[10px] font-bold uppercase tracking-wider text-muted-foreground/60">
{t('notebookSuggestion.moveToNotebook')}
</div>
<button
type="button"
onClick={() => handleMoveToNotebook(null)}
className={cn(
'flex w-full items-center gap-2 rounded px-2 py-1.5 text-[12px] font-medium hover:bg-muted transition-colors',
!note.notebookId ? 'text-primary' : 'text-foreground/70'
)}
>
{!note.notebookId
? <Check className="h-3 w-3 shrink-0" />
: <span className="h-3 w-3 shrink-0" />}
{t('notes.generalNotes')}
</button>
{notebooks.map((nb) => (
<button
key={nb.id}
type="button"
onClick={() => handleMoveToNotebook(nb.id)}
className={cn(
'flex w-full items-center gap-2 rounded px-2 py-1.5 text-[12px] font-medium hover:bg-muted transition-colors',
note.notebookId === nb.id ? 'text-primary' : 'text-foreground/70'
)}
>
{note.notebookId === nb.id
? <Check className="h-3 w-3 shrink-0" />
: <span className="h-3 w-3 shrink-0" />}
{nb.name}
</button>
))}
</PopoverContent>
</Popover>
{/* Pin / Unpin */}
<SidebarActionBtn
icon={note.isPinned ? <PinOff className="h-3.5 w-3.5" /> : <Pin className="h-3.5 w-3.5" />}
label={note.isPinned ? t('notes.unpin') : t('notes.pin')}
onClick={() => onPinToggle(note)}
disabled
/>
{/* Archive */}
<SidebarActionBtn
icon={<Archive className="h-3.5 w-3.5" />}
label={t('notes.archive')}
onClick={() => onArchive(note)}
/>
{/* History */}
<SidebarActionBtn
icon={<History className="h-3.5 w-3.5" />}
label={
noteHistoryEnabled
? ts('notes.history', 'Historique')
: ts('notes.enableHistory', "Activer l'historique")
}
onClick={() => void handleHistory()}
/>
</div>
</div>
</aside>
)
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsViewProps) {
export function NotesTabsView({
notes,
onEdit,
currentNotebookId,
noteHistoryEnabled = false,
noteHistoryMode = 'manual',
onOpenHistory,
onEnableHistory,
}: NotesTabsViewProps) {
const { t, language } = useLanguage()
const { triggerRefresh } = useNoteRefreshOptional()
const [items, setItems] = useState<Note[]>(notes)
const [selectedId, setSelectedId] = useState<string | null>(null)
const [isCreating, startCreating] = useTransition()
const [noteToDelete, setNoteToDelete] = useState<Note | null>(null)
const [sortOrder, setSortOrder] = useState<SortOrder>('date-desc')
const [sidebarOpen, setSidebarOpen] = useState(true)
useEffect(() => {
// Only reset when notes are added or removed, NOT on content/field changes
// Field changes arrive through onChange -> setItems already
setItems((prev) => {
const prevIds = prev.map((n) => n.id).join(',')
const incomingIds = notes.map((n) => n.id).join(',')
if (prevIds === incomingIds) {
// Same set of notes: merge only structural fields (pin, color, archive)
return prev.map((p) => {
const fresh = notes.find((n) => n.id === p.id)
if (!fresh) return p
// Use fresh labels from server if they've changed (e.g., global label deletion)
const labelsChanged = JSON.stringify(fresh.labels?.sort()) !== JSON.stringify(p.labels?.sort())
return {
...fresh,
title: p.title,
content: p.content,
checkItems: p.checkItems,
isMarkdown: p.isMarkdown,
// Always use server labels if different (for global label changes)
labels: labelsChanged ? fresh.labels : p.labels
}
})
}
// Different set (add/remove) or reordered from server: full sync
// CRITICAL: We MUST preserve local text edits so inline editor state isn't lost
return notes.map((fresh) => {
const local = prev.find((p) => p.id === fresh.id)
if (!local) return fresh
@@ -308,7 +637,6 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie
title: local.title,
content: local.content,
checkItems: local.checkItems,
isMarkdown: local.isMarkdown,
labels: labelsChanged ? fresh.labels : local.labels
}
})
@@ -325,7 +653,6 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie
)
}, [items])
// Listen for global label deletion and immediately update local state
useEffect(() => {
const handler = (e: Event) => {
const { name } = (e as CustomEvent).detail
@@ -343,7 +670,22 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie
return () => window.removeEventListener('label-deleted', handler)
}, [])
// Scroll to top of sidebar on note change handled by NoteInlineEditor internally
// Sorted display items (does NOT affect persisted order)
const sortedItems = useMemo(() => {
if (sortOrder === 'date-desc') return [...items]
return [...items].sort((a, b) => {
if (sortOrder === 'date-asc') {
return new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
}
if (sortOrder === 'title-asc') {
return (a.title || '').localeCompare(b.title || '')
}
if (sortOrder === 'title-desc') {
return (b.title || '').localeCompare(a.title || '')
}
return 0
})
}, [items, sortOrder])
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
@@ -372,15 +714,14 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie
const selected = items.find((n) => n.id === selectedId) ?? null
const colorKey = selected ? getColorKey(selected) : 'default'
/** Create a new blank note, add it to the sidebar and select it immediately */
const handleCreateNote = () => {
startCreating(async () => {
try {
const newNote = await createNote({
content: '',
const newNote = await createNote({
content: '',
title: undefined,
notebookId: currentNotebookId || undefined,
skipRevalidation: true
skipRevalidation: true
})
if (!newNote) return
setItems((prev) => {
@@ -396,52 +737,116 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie
})
}
const handlePinToggle = async (note: Note) => {
const next = !note.isPinned
setItems((prev) => prev.map((n) => n.id === note.id ? { ...n, isPinned: next } : n))
try {
await updateNote(note.id, { isPinned: next }, { skipRevalidation: true })
toast.success(next ? (t('notes.pinned') || 'Épinglée') : (t('notes.unpinned') || 'Désépinglée'))
} catch {
setItems((prev) => prev.map((n) => n.id === note.id ? { ...n, isPinned: note.isPinned } : n))
toast.error(t('notes.updateFailed') || 'Mise à jour échouée')
}
}
const handleArchive = async (note: Note) => {
try {
await toggleArchive(note.id, true)
setItems((prev) => prev.filter((n) => n.id !== note.id))
setSelectedId((prev) => (prev === note.id ? null : prev))
triggerRefresh()
toast.success(t('notes.archived') || 'Note archivée')
} catch {
toast.error(t('notes.archiveFailed') || 'Archivage échoué')
}
}
return (
<div
className="flex min-h-0 flex-1 gap-0 overflow-hidden rounded-2xl border border-border/60 shadow-sm"
className="flex min-h-0 flex-1 gap-0 overflow-hidden rounded-xl border border-border/70 shadow-sm"
style={{ height: 'max(360px, min(85vh, calc(100vh - 9rem)))' }}
data-testid="notes-grid-tabs"
>
{/* ── Left sidebar: note list ── */}
<div className="flex w-72 shrink-0 flex-col border-r border-border/60 bg-muted/20">
{/* Sidebar header with note count + new note button */}
<div className="border-b border-border/40 px-3 py-2.5">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground/60">
{/* ── Left panel: note list ── */}
<div className="flex w-80 shrink-0 flex-col border-r border-border/60 bg-background">
{/* Header */}
<div className="flex items-center justify-between border-b border-border/60 bg-background/95 px-4 py-3.5">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold tracking-tight text-foreground">
{t('notes.title')}
<span className="ms-2 rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
{items.length}
</span>
</span>
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-semibold text-primary">
{items.length}
</span>
</div>
<div className="flex items-center gap-1">
{/* Sort / filter button */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
'h-7 w-7 p-0 text-muted-foreground/70 hover:bg-primary/8 hover:text-primary',
sortOrder !== 'date-desc' && 'text-primary bg-primary/8'
)}
title={t('notes.sort') || 'Trier'}
>
<ListFilter className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuLabel className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60 py-1">
{t('notes.sortBy') || 'Trier par'}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup value={sortOrder} onValueChange={(v) => setSortOrder(v as SortOrder)}>
<DropdownMenuRadioItem value="date-desc" className="text-[13px]">
{t('notes.sortDateDesc') || 'Date (récent)'}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="date-asc" className="text-[13px]">
{t('notes.sortDateAsc') || 'Date (ancien)'}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="title-asc" className="text-[13px]">
{t('notes.sortTitleAsc') || 'Titre A → Z'}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="title-desc" className="text-[13px]">
{t('notes.sortTitleDesc') || 'Titre Z → A'}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
{/* New note button */}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
className="h-7 w-7 p-0 text-muted-foreground/70 hover:bg-primary/8 hover:text-primary"
onClick={handleCreateNote}
disabled={isCreating}
title={t('notes.newNote') }
title={t('notes.newNote')}
>
{isCreating
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <Plus className="h-3.5 w-3.5" />}
: <Plus className="h-4 w-4" />}
</Button>
</div>
</div>
{/* Scrollable note list */}
<div
className="flex-1 overflow-y-auto overscroll-contain p-2"
className="flex-1 overflow-y-auto overscroll-contain bg-background"
role="listbox"
aria-label={t('notes.viewTabs')}
>
{items.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 px-4 text-center">
<div className="mb-3 rounded-full bg-background p-3 shadow-sm border border-border/50">
<FileText className="h-5 w-5 text-muted-foreground/40" />
<div className="flex flex-col items-center justify-center px-6 py-16 text-center">
<div className="mb-4 rounded-2xl border border-border/60 bg-muted/30 p-4">
<FileText className="h-6 w-6 text-muted-foreground/40" />
</div>
<p className="text-sm font-medium text-muted-foreground">{t('notes.emptyStateTabs') || 'Aucune note'}</p>
<p className="mt-1 text-xs text-muted-foreground/60">{t('notes.createFirst') || 'Créez votre première note'}</p>
</div>
) : (
<DndContext
@@ -454,8 +859,8 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie
items={items.map((n) => n.id)}
strategy={verticalListSortingStrategy}
>
<div className="flex flex-col gap-0.5">
{items.map((note) => (
<div className="flex flex-col">
{sortedItems.map((note) => (
<SortableNoteListItem
key={note.id}
note={note}
@@ -475,42 +880,74 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie
</div>
</div>
{/* ── Right content panel — always in edit mode ── */}
{/* ── Right content panel ── */}
{selected ? (
<div
className={cn(
'flex min-w-0 flex-1 flex-col overflow-hidden bg-gradient-to-br',
COLOR_PANEL_BG[colorKey]
<div className="flex min-w-0 flex-1 overflow-hidden">
{/* Editor */}
<div
className={cn(
'relative flex min-w-0 flex-1 flex-col overflow-hidden bg-gradient-to-b',
COLOR_PANEL_BG[colorKey]
)}
>
<NoteInlineEditor
key={selected.id}
note={selected}
noteHistoryEnabled={noteHistoryEnabled}
noteHistoryMode={noteHistoryMode}
onOpenHistory={onOpenHistory}
colorKey={colorKey}
defaultPreviewMode={true}
onChange={(noteId, fields) => {
setItems((prev) =>
prev.map((n) => (n.id === noteId ? { ...n, ...fields } : n))
)
}}
onDelete={(noteId) => {
setItems((prev) => prev.filter((n) => n.id !== noteId))
setSelectedId((prev) => (prev === noteId ? null : prev))
}}
onArchive={(noteId) => {
setItems((prev) => prev.filter((n) => n.id !== noteId))
setSelectedId((prev) => (prev === noteId ? null : prev))
triggerRefresh()
}}
/>
{/* Toggle sidebar button — top-right of editor, always visible */}
<button
type="button"
onClick={() => setSidebarOpen((v) => !v)}
title={sidebarOpen ? 'Masquer le panneau' : 'Afficher le panneau'}
className="absolute top-3 right-3 z-20 flex h-7 w-7 items-center justify-center rounded-md border border-border/70 bg-background/90 backdrop-blur-sm shadow-sm text-muted-foreground hover:text-primary hover:border-primary/40 hover:bg-primary/5 transition-colors"
>
{sidebarOpen
? <PanelRightClose className="h-3.5 w-3.5" />
: <PanelRightOpen className="h-3.5 w-3.5" />}
</button>
</div>
{/* Meta sidebar — collapsible */}
{sidebarOpen && (
<NoteMetaSidebar
note={selected}
onPinToggle={handlePinToggle}
onArchive={handleArchive}
noteHistoryEnabled={noteHistoryEnabled}
onOpenHistory={onOpenHistory}
onEnableHistory={onEnableHistory}
/>
)}
>
<NoteInlineEditor
key={selected.id}
note={selected}
colorKey={colorKey}
defaultPreviewMode={true}
onChange={(noteId, fields) => {
setItems((prev) =>
prev.map((n) => (n.id === noteId ? { ...n, ...fields } : n))
)
}}
onDelete={(noteId) => {
setItems((prev) => prev.filter((n) => n.id !== noteId))
setSelectedId((prev) => (prev === noteId ? null : prev))
}}
onArchive={(noteId) => {
setItems((prev) => prev.filter((n) => n.id !== noteId))
setSelectedId((prev) => (prev === noteId ? null : prev))
}}
/>
</div>
) : (
<div className="flex min-w-0 flex-1 items-center justify-center bg-muted/10 border-l border-border/40">
<div className="text-center px-6">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-background shadow-sm border border-border/50">
<FileText className="h-8 w-8 text-muted-foreground/30" />
<div className="flex min-w-0 flex-1 items-center justify-center bg-muted/10">
<div className="px-10 text-center">
<div className="mx-auto mb-5 flex h-16 w-16 items-center justify-center rounded-2xl border border-border/60 bg-background shadow-sm">
<FileText className="h-7 w-7 text-muted-foreground/30" />
</div>
<h3 className="text-lg font-heading font-medium text-foreground">{items.length === 0 ? t('notes.emptyNotebook') : t('notes.noNoteSelected')}</h3>
<p className="mt-2 text-sm text-muted-foreground max-w-sm mx-auto">
<p className="text-sm font-medium text-foreground/60">
{items.length === 0 ? t('notes.emptyNotebook') : t('notes.noNoteSelected')}
</p>
<p className="mt-1.5 text-xs text-muted-foreground/50 max-w-[200px] mx-auto leading-relaxed">
{items.length === 0
? t('notes.emptyNotebookDesc')
: t('notes.selectOrCreateNote')}
@@ -528,7 +965,7 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie
{t('notes.confirmDelete') || 'Are you sure you want to delete this note?'}
{noteToDelete && (
<span className="mt-2 block font-medium text-foreground">
"{getNoteDisplayTitle(noteToDelete, t('notes.untitled'))}"
&quot;{getNoteDisplayTitle(noteToDelete, t('notes.untitled'))}&quot;
</span>
)}
</DialogDescription>
@@ -561,4 +998,3 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie
</div>
)
}

View File

@@ -4,7 +4,7 @@ import Link from 'next/link'
import { usePathname, useSearchParams, useRouter } from 'next/navigation'
import { cn } from '@/lib/utils'
import {
Lightbulb,
FileText,
Bell,
Archive,
Trash2,
@@ -81,7 +81,8 @@ export function Sidebar({ className, user }: { className?: string, user?: any })
return pathname === '/' &&
!searchParams.get('label') &&
!searchParams.get('archived') &&
!searchParams.get('trashed')
!searchParams.get('trashed') &&
!searchParams.get('notebook')
}
// For labels
@@ -131,7 +132,7 @@ export function Sidebar({ className, user }: { className?: string, user?: any })
<div className="flex flex-col gap-1 px-3">
<NavItem
href="/"
icon={Lightbulb}
icon={FileText}
label={t('sidebar.notes') || 'Notes'}
active={isActive('/')}
/>

View File

@@ -15,16 +15,16 @@ export function TitleSuggestions({ suggestions, onSelect, onDismiss }: TitleSugg
if (suggestions.length === 0) return null
return (
<div className="mt-2 p-3 bg-amber-50 dark:bg-amber-950 border border-amber-200 dark:border-amber-800 rounded-lg animate-in fade-in slide-in-from-top-2 duration-300">
<div className="mt-2 p-3 bg-sky-50 dark:bg-sky-950/50 border border-sky-200 dark:border-sky-800 rounded-lg animate-in fade-in slide-in-from-top-2 duration-300">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 text-sm font-medium text-amber-900 dark:text-amber-100">
<div className="flex items-center gap-2 text-sm font-medium text-sky-900 dark:text-sky-100">
<Sparkles className="w-4 h-4" />
<span>{t('titleSuggestions.title')}</span>
</div>
<button
type="button"
onClick={onDismiss}
className="text-amber-600 hover:text-amber-900 dark:text-amber-400 dark:hover:text-amber-200 transition-colors"
className="text-sky-500 hover:text-sky-900 dark:text-sky-400 dark:hover:text-sky-200 transition-colors"
>
<X className="w-4 h-4" />
</button>
@@ -38,19 +38,19 @@ export function TitleSuggestions({ suggestions, onSelect, onDismiss }: TitleSugg
onClick={() => onSelect(suggestion.title)}
className={cn(
"w-full text-left px-3 py-2 rounded-md transition-all",
"hover:bg-amber-100 dark:hover:bg-amber-900",
"text-sm text-amber-900 dark:text-amber-100",
"border border-transparent hover:border-amber-300 dark:hover:border-amber-700"
"hover:bg-sky-100 dark:hover:bg-sky-900/50",
"text-sm text-sky-900 dark:text-sky-100",
"border border-transparent hover:border-sky-300 dark:hover:border-sky-700"
)}
>
<div className="flex items-start justify-between gap-2">
<span className="font-medium">{suggestion.title}</span>
<span className="text-xs text-amber-600 dark:text-amber-400 whitespace-nowrap">
<span className="text-xs text-sky-500 dark:text-sky-400 whitespace-nowrap">
{suggestion.confidence}%
</span>
</div>
{suggestion.reasoning && (
<p className="text-xs text-amber-700 dark:text-amber-300 mt-1">
<p className="text-xs text-sky-600 dark:text-sky-300 mt-1">
{suggestion.reasoning}
</p>
)}