fix: remove duplicate useMemo inside handleGenerateTitles (hooks violation = broken save+title), add Save btn + three-dot delete + Ctrl+S to fullPage toolbar
This commit is contained in:
@@ -25,8 +25,8 @@ import {
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { NoteTypeSelector } from '@/components/note-type-selector'
|
||||
import { RichTextEditor } from '@/components/rich-text-editor'
|
||||
import { X, Plus, Palette, Image as ImageIcon, Bell, Eye, Link as LinkIcon, Sparkles, Maximize2, Copy, Wand2, LogOut, ArrowLeft, ChevronRight, Info, Check, Loader2 } from 'lucide-react'
|
||||
import { updateNote, createNote, cleanupOrphanedImages, leaveSharedNote } from '@/app/actions/notes'
|
||||
import { X, Plus, Palette, Image as ImageIcon, Bell, Eye, Link as LinkIcon, Sparkles, Maximize2, Copy, Wand2, LogOut, ArrowLeft, ChevronRight, Info, Check, Loader2, Save, MoreHorizontal, Trash2 } from 'lucide-react'
|
||||
import { updateNote, createNote, cleanupOrphanedImages, leaveSharedNote, deleteNote } from '@/app/actions/notes'
|
||||
import { format } from 'date-fns'
|
||||
import { useTitleSuggestions } from '@/hooks/use-title-suggestions'
|
||||
import { MarkdownSlashCommands } from './markdown-slash-commands'
|
||||
@@ -329,12 +329,6 @@ export function NoteEditor({ note, readOnly = false, onClose, fullPage = false }
|
||||
.join(' ')
|
||||
.trim()
|
||||
|
||||
const allImages = useMemo(() => {
|
||||
const extracted = noteType === 'richtext' ? extractImagesFromHTML(content) : [];
|
||||
return Array.from(new Set([...images, ...extracted]));
|
||||
}, [images, content, noteType]);
|
||||
|
||||
|
||||
|
||||
const wordCount = fullContentForAI.split(/\s+/).filter(word => word.length > 0).length
|
||||
|
||||
@@ -680,7 +674,54 @@ export function NoteEditor({ note, readOnly = false, onClose, fullPage = false }
|
||||
}
|
||||
}
|
||||
|
||||
// ── fullPage mode: editorial layout (fidèle au prototype) ──
|
||||
// Save in place (fullPage) — without closing
|
||||
const handleSaveInPlace = async () => {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
await updateNote(note.id, {
|
||||
title: title.trim() || null,
|
||||
content: noteType !== 'checklist' ? content : '',
|
||||
checkItems: noteType === 'checklist' ? checkItems : null,
|
||||
labels,
|
||||
images,
|
||||
links,
|
||||
color,
|
||||
reminder: currentReminder,
|
||||
isMarkdown: noteType === 'markdown',
|
||||
type: noteType,
|
||||
size,
|
||||
})
|
||||
if (removedImageUrls.length > 0) {
|
||||
cleanupOrphanedImages(removedImageUrls, note.id).catch(() => {})
|
||||
}
|
||||
await refreshLabels()
|
||||
triggerRefresh()
|
||||
setIsDirty(false)
|
||||
toast.success('Note sauvegardée !')
|
||||
} catch (error) {
|
||||
console.error('Failed to save note:', error)
|
||||
toast.error('Erreur lors de la sauvegarde.')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl+S / Cmd+S shortcut — save in place in fullPage mode
|
||||
// Note: this useEffect must be outside the if(fullPage) branch (no conditional hooks)
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
useEffect(() => {
|
||||
if (!fullPage) return
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault()
|
||||
handleSaveInPlace()
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handler)
|
||||
return () => document.removeEventListener('keydown', handler)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fullPage, isSaving])
|
||||
|
||||
if (fullPage) {
|
||||
const notebookName = notebooks.find(nb => nb.id === note.notebookId)?.name || null
|
||||
|
||||
@@ -704,69 +745,113 @@ export function NoteEditor({ note, readOnly = false, onClose, fullPage = false }
|
||||
<span className="text-sm font-medium">Back to collection</span>
|
||||
</button>
|
||||
|
||||
{/* Right: status + type + AI + Info */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Save status */}
|
||||
<span className="hidden sm:flex items-center gap-1.5 text-[11px] text-foreground/40 select-none">
|
||||
{isSaving
|
||||
? <><Loader2 className="h-3 w-3 animate-spin" /><span>Saving…</span></>
|
||||
: isDirty
|
||||
? <><span className="h-1.5 w-1.5 rounded-full bg-amber-400 inline-block" /><span>Modified</span></>
|
||||
: <><Check className="h-3 w-3 text-emerald-500" /><span>Saved</span></>}
|
||||
</span>
|
||||
{/* Right: status + type + AI + Info */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Save status */}
|
||||
<span className="hidden sm:flex items-center gap-1.5 text-[11px] text-foreground/40 select-none">
|
||||
{isSaving
|
||||
? <><Loader2 className="h-3 w-3 animate-spin" /><span>Saving…</span></>
|
||||
: isDirty
|
||||
? <><span className="h-1.5 w-1.5 rounded-full bg-amber-400 inline-block" /><span>Modified</span></>
|
||||
: <><Check className="h-3 w-3 text-emerald-500" /><span>Saved</span></>}
|
||||
</span>
|
||||
|
||||
{/* Note type */}
|
||||
<NoteTypeSelector
|
||||
value={noteType}
|
||||
onChange={(newType) => { setNoteType(newType); setShowMarkdownPreview(newType === 'markdown'); setIsDirty(true) }}
|
||||
compact
|
||||
/>
|
||||
{/* Note type */}
|
||||
<NoteTypeSelector
|
||||
value={noteType}
|
||||
onChange={(newType) => { setNoteType(newType); setShowMarkdownPreview(newType === 'markdown'); setIsDirty(true) }}
|
||||
compact
|
||||
/>
|
||||
|
||||
{/* Preview toggle — only for text/markdown, in toolbar where it's visible */}
|
||||
{(noteType === 'text' || noteType === 'markdown') && !readOnly && (
|
||||
{/* Preview toggle — only for text/markdown, in toolbar where it's visible */}
|
||||
{(noteType === 'text' || noteType === 'markdown') && !readOnly && (
|
||||
<button
|
||||
onClick={() => setShowMarkdownPreview(v => !v)}
|
||||
title={showMarkdownPreview ? 'Revenir à l\'édition' : 'Prévisualiser le rendu'}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-1.5 rounded-full border transition-all duration-300 text-xs font-medium',
|
||||
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>{showMarkdownPreview ? 'Éditer' : 'Aperçu'}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* AI — rounded-full, exact prototype style */}
|
||||
<button
|
||||
onClick={() => setShowMarkdownPreview(v => !v)}
|
||||
title={showMarkdownPreview ? 'Revenir à l\'édition' : 'Prévisualiser le rendu'}
|
||||
onClick={() => { setAiOpen(v => !v); 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',
|
||||
showMarkdownPreview
|
||||
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'
|
||||
)}
|
||||
>
|
||||
<Eye size={16} />
|
||||
<span>{showMarkdownPreview ? 'Éditer' : 'Aperçu'}</span>
|
||||
<Sparkles size={16} />
|
||||
<span>AI Assistant</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* AI — rounded-full, exact prototype style */}
|
||||
<button
|
||||
onClick={() => { setAiOpen(v => !v); 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',
|
||||
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
|
||||
onClick={() => { setInfoOpen(v => !v); 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',
|
||||
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>
|
||||
|
||||
{/* Info — rounded-full */}
|
||||
<button
|
||||
onClick={() => { setInfoOpen(v => !v); 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',
|
||||
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'
|
||||
{/* Save button */}
|
||||
{!readOnly && (
|
||||
<button
|
||||
onClick={handleSaveInPlace}
|
||||
disabled={isSaving || !isDirty}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-1.5 rounded-full border transition-all duration-300 text-xs font-medium',
|
||||
isDirty
|
||||
? 'bg-foreground text-background border-foreground hover:opacity-80'
|
||||
: 'border-black/20 dark:border-white/20 text-foreground/40 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{isSaving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||
<span>{isSaving ? 'Saving…' : 'Save'}</span>
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<Info size={16} />
|
||||
<span>Document Info</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Three-dot options menu */}
|
||||
{!readOnly && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="p-1.5 rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all">
|
||||
<MoreHorizontal size={16} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
try {
|
||||
await deleteNote(note.id)
|
||||
triggerRefresh()
|
||||
toast.success('Note supprimée.')
|
||||
onClose()
|
||||
} catch { toast.error('Impossible de supprimer.') }
|
||||
}}
|
||||
className="text-red-600 dark:text-red-400 focus:text-red-600"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Supprimer la note
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* BODY — max-w-4xl, px-12, py-16 */}
|
||||
|
||||
Reference in New Issue
Block a user