feat: icon-only toolbar, versioning fixes, history modal, PanelRight repositioning

- Toolbar: remove text labels from all icon buttons (AI, Save, Preview, Convert)
  all buttons now icon-only with title tooltip for accessibility
- Toolbar: reposition PanelRight (info panel toggle) to far right after three-dot menu
- Versioning: decouple getNoteHistory/restoreNoteVersion from global userAISettings.noteHistory
  now checks note.historyEnabled directly — unblocks manual per-note history
- Versioning: add 'Sauvegarder cette version' button in Versions tab of info panel
  calls commitNoteHistory with visual feedback (spinner → success state)
- note-document-info-panel: import commitNoteHistory, add isSavingVersion state
- notes.ts: fix double guard that silently blocked all history operations
This commit is contained in:
Antigravity
2026-05-09 07:28:03 +00:00
parent 574c8b3166
commit 97b08e5d0b
65 changed files with 2991 additions and 2296 deletions

View File

@@ -10,11 +10,12 @@ interface NoteEditorProps {
readOnly?: boolean
onClose: () => void
fullPage?: boolean
onNoteSaved?: (savedNote: Note) => void
}
export function NoteEditor({ note, readOnly, onClose, fullPage = false }: NoteEditorProps) {
export function NoteEditor({ note, readOnly, onClose, fullPage = false, onNoteSaved }: NoteEditorProps) {
return (
<NoteEditorProvider note={note} readOnly={readOnly} fullPage={fullPage}>
<NoteEditorProvider note={note} readOnly={readOnly} fullPage={fullPage} onNoteSaved={onNoteSaved}>
{fullPage ? (
<NoteEditorFullPage onClose={onClose} />
) : (

View File

@@ -25,10 +25,11 @@ interface NoteEditorProviderProps {
note: Note
readOnly?: boolean
fullPage?: boolean
onNoteSaved?: (savedNote: Note) => void
children: ReactNode
}
export function NoteEditorProvider({ note, readOnly = false, fullPage = false, children }: NoteEditorProviderProps) {
export function NoteEditorProvider({ note, readOnly = false, fullPage = false, onNoteSaved, children }: NoteEditorProviderProps) {
const { data: session } = useSession()
const { t } = useLanguage()
const queryClient = useQueryClient()
@@ -133,6 +134,13 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, c
enabled: fullPage && !title && !dismissedTitleSuggestions,
})
// Wire autoTitleSuggestions into state so NoteTitleBlock can display them
useEffect(() => {
if (autoTitleSuggestions.length > 0) {
setTitleSuggestions(autoTitleSuggestions)
}
}, [autoTitleSuggestions])
// Track previous content for copilot action undo
const [previousContentForCopilot, setPreviousContentForCopilot] = useState<string | null>(null)
@@ -169,7 +177,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, c
for (const file of Array.from(files)) {
try {
const url = await uploadImageFile(file)
setImages(prev => [...prev, url])
setImages(prev => prev.includes(url) ? prev : [...prev, url])
} catch (error) {
console.error('Upload error:', error)
toast.error(t('notes.uploadFailed', { filename: file.name }))
@@ -190,7 +198,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, c
if (!file) continue
try {
const url = await uploadImageFile(file)
setImages(prev => [...prev, url])
setImages(prev => prev.includes(url) ? prev : [...prev, url])
} catch {
toast.error(t('notes.uploadFailed', { filename: 'pasted image' }))
}
@@ -293,7 +301,14 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, c
const data = await response.json()
setTitleSuggestions(data.suggestions || [])
toast.success(t('ai.titlesGenerated', { count: data.suggestions.length }))
// Auto-apply first title for dialog mode (fullPage shows suggestions UI instead)
if (!fullPage && data.suggestions?.[0]?.title) {
setTitle(data.suggestions[0].title)
setDismissedTitleSuggestions(true)
toast.success(t('ai.titlesGenerated', { count: data.suggestions.length }))
} else if (data.suggestions?.length) {
toast.success(t('ai.titlesGenerated', { count: data.suggestions.length }))
}
} catch (error: any) {
console.error('Error generating titles:', error)
toast.error(error.message || t('ai.titleGenerationFailed'))
@@ -521,9 +536,11 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, c
}
const handleSave = async () => {
console.log('[SAVE] handleSave called, note.id:', note.id)
setIsSaving(true)
try {
await updateNote(note.id, {
console.log('[SAVE] Calling updateNote...')
const result = await updateNote(note.id, {
title: title.trim() || null,
content: noteType !== 'checklist' ? content : '',
checkItems: noteType === 'checklist' ? checkItems : null,
@@ -536,20 +553,25 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, c
type: noteType,
size,
})
console.log('[SAVE] updateNote succeeded, result title:', result?.title, 'result content len:', result?.content?.length)
console.log('[SAVE] prevNoteRef BEFORE sync:', JSON.stringify(prevNoteRef.current.content)?.substring(0, 80))
// Keep local note ref in sync with saved data so useEffect detects changes correctly
prevNoteRef.current = { ...prevNoteRef.current, ...result }
console.log('[SAVE] prevNoteRef AFTER sync:', JSON.stringify(prevNoteRef.current.content)?.substring(0, 80))
if (removedImageUrls.length > 0) {
cleanupOrphanedImages(removedImageUrls, note.id).catch(() => {})
}
await refreshLabels()
// Notify parent with the freshly-saved note so it can update its local state immediately
onNoteSaved?.(result)
// Invalidate note and notes list cache
queryClient.invalidateQueries({ queryKey: queryKeys.note(note.id) })
queryClient.invalidateQueries({ queryKey: queryKeys.notes(note.notebookId) })
triggerRefresh()
// Note: onClose is handled by the composition component
toast.success(t('notes.saved') || 'Note sauvegardée !')
} catch (error) {
console.error('Failed to save note:', error)
console.error('[SAVE] updateNote failed:', error)
toast.error(t('notes.saveFailed') || 'Erreur lors de la sauvegarde.')
} finally {
setIsSaving(false)
}
@@ -633,9 +655,11 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, c
// Save in place (fullPage) — without closing
const handleSaveInPlace = async () => {
console.log('[SAVE] handleSaveInPlace called, note.id:', note.id, 'content length:', content.length, 'title:', title.substring(0, 50))
setIsSaving(true)
try {
await updateNote(note.id, {
console.log('[SAVE] Calling updateNote with note.id:', note.id, '| content len:', content.length, '| title:', title.substring(0, 30))
const updatePayload = {
title: title.trim() || null,
content: noteType !== 'checklist' ? content : '',
checkItems: noteType === 'checklist' ? checkItems : null,
@@ -647,11 +671,20 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, c
isMarkdown: noteType === 'markdown',
type: noteType,
size,
})
}
console.log('[SAVE] payload.content:', JSON.stringify(updatePayload.content)?.substring(0, 100))
const result = await updateNote(note.id, updatePayload)
console.log('[SAVE] updateNote succeeded, result.id:', result?.id, '| result.content len:', result?.content?.length, '| result.title:', result?.title)
console.log('[SAVE] prevNoteRef BEFORE sync:', JSON.stringify(prevNoteRef.current.content)?.substring(0, 50))
// Sync local note reference with saved data so prop/state stay aligned after save
prevNoteRef.current = { ...prevNoteRef.current, ...result }
console.log('[SAVE] prevNoteRef AFTER sync:', JSON.stringify(prevNoteRef.current.content)?.substring(0, 50))
if (removedImageUrls.length > 0) {
cleanupOrphanedImages(removedImageUrls, note.id).catch(() => {})
}
await refreshLabels()
// Notify parent with the freshly-saved note so it can update its local state immediately
onNoteSaved?.(result)
// Invalidate note and notes list cache
queryClient.invalidateQueries({ queryKey: queryKeys.note(note.id) })
queryClient.invalidateQueries({ queryKey: queryKeys.notes(note.notebookId) })
@@ -659,7 +692,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, c
setIsDirty(false)
toast.success('Note sauvegardée !')
} catch (error) {
console.error('Failed to save note:', error)
console.error('[SAVE] updateNote failed:', error)
toast.error('Erreur lors de la sauvegarde.')
} finally {
setIsSaving(false)
@@ -725,8 +758,11 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, c
isAnalyzingSuggestions, isMarkdown, allImages, colorClasses
])
// Build actions object
const actions: NoteEditorActions = useMemo(() => ({
// Build actions object — NOT memoized to avoid stale closures.
// handleSave / handleSaveInPlace close over content, title, labels, etc.
// which change on every keystroke. Memoizing with [] would freeze those
// values at the first render, causing the wrong content to be saved.
const actions: NoteEditorActions = {
setTitle: (t) => { setTitle(t); setIsDirty(true); setDismissedTitleSuggestions(true) },
setDismissedTitleSuggestions,
setContent: (c) => { setContent(c); setIsDirty(true) },
@@ -775,9 +811,10 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, c
setInfoOpen,
setIsProcessingAI,
setIsGeneratingTitles,
setIsAnalyzingSuggestions: (a) => { /* handled by useAutoTagging */ },
setIsAnalyzingSuggestions: (_a) => { /* handled by useAutoTagging */ },
setPreviousContentForCopilot,
}), [])
}
const value: NoteEditorContextValue = useMemo(() => ({
note,

View File

@@ -1,5 +1,6 @@
'use client'
import { useState, useRef } from 'react'
import { useNoteEditorContext } from './note-editor-context'
import { LabelManager } from '@/components/label-manager'
import { LabelBadge } from '@/components/label-badge'
@@ -17,11 +18,11 @@ import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
X, Plus, Palette, Image as ImageIcon, Bell, Eye, Link as LinkIcon, Sparkles,
Maximize2, Copy, ArrowLeft, ChevronRight, Info, Check, Loader2, Save, MoreHorizontal,
Trash2, LogOut
Maximize2, Copy, ArrowLeft, ChevronRight, PanelRight, Check, Loader2, Save, MoreHorizontal,
Trash2, LogOut, Wand2
} from 'lucide-react'
import { deleteNote, leaveSharedNote } from '@/app/actions/notes'
import { useNoteRefresh } from '@/context/NoteRefreshContext'
import { useRefresh } from '@/lib/use-refresh'
import { useLanguage } from '@/lib/i18n'
import { NOTE_COLORS, NoteColor, Note } from '@/lib/types'
import { cn } from '@/lib/utils'
@@ -36,10 +37,59 @@ interface NoteEditorToolbarProps {
export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
const { state, actions, note, readOnly, fullPage, notebooks, fileInputRef } = useNoteEditorContext()
const { t } = useLanguage()
const { triggerRefresh } = useNoteRefresh()
const { refreshNotes } = useRefresh()
const [isConverting, setIsConverting] = useState(false)
const notebookName = notebooks.find(nb => nb.id === note.notebookId)?.name || null
// Snapshot for undo — stored in a ref so the toast callback isn't a stale closure
const undoSnapshotRef = useRef<{ content: string; noteType: string } | null>(null)
const handleConvertToRichtext = async () => {
if (isConverting || !state.content.trim()) return
setIsConverting(true)
// Capture snapshot BEFORE converting
const snapshot = { content: state.content, noteType: state.noteType }
undoSnapshotRef.current = snapshot
try {
let html: string
if (state.noteType === 'markdown') {
// Proper markdown → HTML via marked (no AI needed)
const { marked } = await import('marked')
html = await marked(state.content, { async: false }) as string
} else {
// Plain text → wrap paragraphs in <p> tags
html = state.content
.split(/\n{2,}/)
.map(para => `<p>${para.trim().replace(/\n/g, '<br />')}</p>`)
.join('')
}
actions.setContent(html)
actions.setNoteType('richtext')
toast.success(t('notes.convertedToRichText') || 'Converted to rich text', {
duration: 8000,
action: {
label: t('notes.undo') || '↩ Undo',
onClick: () => {
const snap = undoSnapshotRef.current
if (!snap) return
actions.setContent(snap.content)
actions.setNoteType(snap.noteType as any)
undoSnapshotRef.current = null
toast.info(t('ai.undoApplied') || 'Conversion undone')
},
},
})
} catch {
toast.error(t('notes.transformFailed') || 'Conversion failed')
} finally {
setIsConverting(false)
}
}
if (mode === 'fullPage') {
return (
<div className="px-12 py-8 flex items-center justify-between sticky top-0 bg-white/95 dark:bg-zinc-950/95 backdrop-blur-sm z-40 border-b border-border dark:border-white/10">
@@ -70,68 +120,70 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
compact
/>
{/* Preview toggle — only for text/markdown, in toolbar where it's visible */}
{/* Preview toggle — icon only */}
{(state.noteType === 'text' || state.noteType === 'markdown') && !readOnly && (
<button
title={state.showMarkdownPreview ? 'Revenir à l\'édition' : 'Aperçu'}
aria-label={state.showMarkdownPreview ? 'Revenir à l\'édition' : 'Prévisualiser le rendu'}
onClick={() => actions.setShowMarkdownPreview(!state.showMarkdownPreview)}
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-full border transition-all duration-300 text-xs font-medium',
'p-1.5 rounded-full border transition-all duration-300',
state.showMarkdownPreview
? 'bg-foreground text-background border-foreground'
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5'
)}
>
<Eye size={16} />
<span>{state.showMarkdownPreview ? 'Éditer' : 'Aperçu'}</span>
</button>
)}
{/* AI — rounded-full, exact prototype style */}
{/* Convert to Rich Text — icon only */}
{(state.noteType === 'text' || state.noteType === 'markdown') && !readOnly && (
<button
title={t('ai.convertToRichtext') || 'Convert to Rich Text'}
aria-label={t('ai.convertToRichtext') || 'Convert to Rich Text'}
onClick={handleConvertToRichtext}
disabled={isConverting}
className={cn(
'p-1.5 rounded-full border transition-all duration-300',
'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5',
isConverting && 'opacity-50 cursor-not-allowed'
)}
>
{isConverting ? <Loader2 size={14} className="animate-spin" /> : <Wand2 size={14} />}
</button>
)}
{/* AI — icon only */}
<button
title="AI Assistant"
aria-label="Ouvrir l'assistant IA"
onClick={() => { actions.setAiOpen(!state.aiOpen); actions.setInfoOpen(false) }}
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-full border transition-all duration-300 text-xs font-medium',
'p-1.5 rounded-full border transition-all duration-300',
state.aiOpen
? 'bg-foreground text-background border-foreground'
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5'
)}
>
<Sparkles size={16} />
<span>AI Assistant</span>
</button>
{/* Info — rounded-full */}
<button
aria-label="Informations du document"
onClick={() => { actions.setInfoOpen(!state.infoOpen); actions.setAiOpen(false) }}
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-full border transition-all duration-300 text-xs font-medium',
state.infoOpen
? 'bg-foreground text-background border-foreground'
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5'
)}
>
<Info size={16} />
<span>Document Info</span>
</button>
{/* Save button */}
{/* Save — icon only */}
{!readOnly && (
<button
title={state.isDirty ? 'Enregistrer' : 'Aucune modification'}
aria-label={state.isDirty ? 'Enregistrer la note' : 'Aucune modification à enregistrer'}
onClick={actions.handleSaveInPlace}
disabled={state.isSaving || !state.isDirty}
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-full border transition-all duration-300 text-xs font-medium',
'p-1.5 rounded-full border transition-all duration-300',
state.isDirty
? 'bg-foreground text-background border-foreground hover:opacity-80'
: 'border-black/20 dark:border-white/20 text-foreground/40 cursor-not-allowed'
)}
>
{state.isSaving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
<span>{state.isSaving ? 'Saving…' : 'Save'}</span>
</button>
)}
@@ -148,7 +200,7 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
onClick={async () => {
try {
await deleteNote(note.id)
triggerRefresh()
refreshNotes(note.notebookId)
toast.success('Note supprimée.')
onClose()
} catch { toast.error('Impossible de supprimer.') }
@@ -161,6 +213,20 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Info panel toggle — rightmost, icon only */}
<button
aria-label="Informations du document"
onClick={() => { actions.setInfoOpen(!state.infoOpen); actions.setAiOpen(false) }}
className={cn(
'p-1.5 rounded-full border transition-all duration-300',
state.infoOpen
? 'bg-foreground text-background border-foreground'
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5'
)}
>
<PanelRight size={16} />
</button>
</div>
</div>
)
@@ -277,7 +343,7 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
try {
await leaveSharedNote(note.id)
toast.success(t('notes.leftShare') || 'Share removed')
triggerRefresh()
refreshNotes(note.notebookId)
onClose()
} catch {
toast.error(t('general.error'))

View File

@@ -29,8 +29,8 @@ export function NoteTitleBlock() {
}}
disabled={readOnly}
className={cn(
'w-full text-4xl md:text-5xl font-memento-serif font-bold border-0 outline-none px-0 bg-transparent text-foreground leading-tight',
'placeholder:text-foreground/20 resize-none overflow-hidden',
'w-full text-4xl md:text-5xl font-memento-serif font-bold border-0 outline-none px-0 bg-transparent text-foreground leading-tight break-words',
'placeholder:text-foreground/20 resize-none overflow-hidden break-words',
!readOnly && 'pr-12'
)}
/>
@@ -39,21 +39,26 @@ export function NoteTitleBlock() {
<button
type="button"
onClick={async () => {
console.log('[TITLE] Sparkles button clicked')
const plain = state.content.replace(/<[^>]+>/g, ' ').trim()
const wordCount = plain.split(/\s+/).filter(Boolean).length
console.log('[TITLE] Content length:', plain.length, 'Word count:', wordCount)
if (wordCount < 10) {
toast.error('Ajoutez au moins 10 mots avant de générer un titre.')
return
}
actions.setIsProcessingAI(true)
try {
console.log('[TITLE] Calling /api/ai/title-suggestions...')
const res = await fetch('/api/ai/title-suggestions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: plain }),
})
console.log('[TITLE] API response:', res.status)
if (res.ok) {
const data = await res.json()
console.log('[TITLE] Suggestions:', data.suggestions)
const s = data.suggestions?.[0]?.title ?? ''
if (s) {
actions.setTitle(s)
@@ -62,9 +67,14 @@ export function NoteTitleBlock() {
toast.error('Impossible de générer un titre.')
}
} else {
const err = await res.text()
console.error('[TITLE] API error:', err)
toast.error('Erreur lors de la génération du titre.')
}
} catch { toast.error('Erreur réseau.') } finally { actions.setIsProcessingAI(false) }
} catch (e) {
console.error('[TITLE] Fetch failed:', e)
toast.error('Erreur réseau.')
} finally { actions.setIsProcessingAI(false) }
}}
disabled={state.isProcessingAI}
className="absolute right-0 top-2 opacity-0 group-hover:opacity-60 hover:!opacity-100 transition-opacity rounded-lg p-2 text-foreground/50 hover:bg-black/5"