Ajoute la base organisable par carnet (schéma, champs partagés, valeurs par note) avec activation guidée, tableau éditable, kanban et suppression de colonnes. Corrige le multiselect en vue tableau et enrichit sidebar, grille et i18n FR/EN. Inclut aussi les améliorations flashcards SM-2, l'audit consentement IA et la robustesse du serveur MCP (config, validation, rate-limit, métriques). Co-authored-by: Cursor <cursoragent@cursor.com>
437 lines
18 KiB
TypeScript
437 lines
18 KiB
TypeScript
'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'
|
|
import { GhostTags } from '@/components/ghost-tags'
|
|
import { EditorImages } from '@/components/editor-images'
|
|
import { TitleSuggestions } from '@/components/title-suggestions'
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu'
|
|
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, PanelRight, Check, Loader2, Save, MoreHorizontal,
|
|
Trash2, LogOut, Wand2, Share2, Wind, Paperclip, GraduationCap
|
|
} from 'lucide-react'
|
|
import { FlashcardGenerateDialog } from '@/components/flashcards/flashcard-generate-dialog'
|
|
import { NoteShareDialog } from './note-share-dialog'
|
|
import { deleteNote, leaveSharedNote } from '@/app/actions/notes'
|
|
import { emitNoteChange } from '@/lib/note-change-sync'
|
|
import { useLanguage } from '@/lib/i18n'
|
|
import { NOTE_COLORS, NoteColor, Note } from '@/lib/types'
|
|
import { cn } from '@/lib/utils'
|
|
import { toast } from 'sonner'
|
|
import { format } from 'date-fns'
|
|
|
|
interface NoteEditorToolbarProps {
|
|
mode: 'fullPage' | 'dialog'
|
|
onClose: () => void
|
|
onToggleAttachments?: () => void
|
|
attachmentsCount?: number
|
|
}
|
|
|
|
export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachmentsCount }: NoteEditorToolbarProps) {
|
|
const { state, actions, note, readOnly, fullPage, notebooks, fileInputRef } = useNoteEditorContext()
|
|
const { t } = useLanguage()
|
|
const [isConverting, setIsConverting] = useState(false)
|
|
const [shareOpen, setShareOpen] = useState(false)
|
|
const [flashcardsOpen, setFlashcardsOpen] = useState(false)
|
|
|
|
const notebookName = notebooks.find(nb => nb.id === note.notebookId)?.name || null
|
|
|
|
const undoSnapshotRef = useRef<{ content: string; isMarkdown: boolean } | null>(null)
|
|
|
|
const handleConvertToRichtext = async () => {
|
|
if (isConverting || !state.content.trim()) return
|
|
setIsConverting(true)
|
|
|
|
const snapshot = { content: state.content, isMarkdown: state.isMarkdown }
|
|
undoSnapshotRef.current = snapshot
|
|
|
|
try {
|
|
let html: string
|
|
if (state.isMarkdown) {
|
|
const { marked } = await import('marked')
|
|
html = await marked(state.content, { async: false }) as string
|
|
} else {
|
|
html = state.content
|
|
.split(/\n{2,}/)
|
|
.map(para => `<p>${para.trim().replace(/\n/g, '<br />')}</p>`)
|
|
.join('')
|
|
}
|
|
actions.setContent(html)
|
|
actions.setIsMarkdown(false)
|
|
|
|
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)
|
|
if (snap.isMarkdown) actions.setIsMarkdown(true)
|
|
undoSnapshotRef.current = null
|
|
toast.info(t('ai.undoApplied') || 'Conversion undone')
|
|
},
|
|
},
|
|
})
|
|
} catch {
|
|
toast.error(t('notes.transformFailed') || 'Conversion failed')
|
|
} finally {
|
|
setIsConverting(false)
|
|
}
|
|
}
|
|
|
|
if (mode === 'fullPage') {
|
|
const handleCloseWithSave = async () => {
|
|
if (state.isDirty && !state.isSaving) {
|
|
await actions.handleSaveInPlace()
|
|
}
|
|
onClose()
|
|
}
|
|
|
|
return (
|
|
<div className="px-4 sm:px-8 md:px-12 py-4 sm:py-6 md:py-8 flex items-center justify-between sticky top-0 bg-white/95 dark:bg-background/95 backdrop-blur-sm z-40 border-b border-border dark:border-white/10">
|
|
<button
|
|
onClick={handleCloseWithSave}
|
|
className="flex items-center gap-2 text-foreground hover:opacity-60 transition-opacity"
|
|
>
|
|
<ArrowLeft size={18} />
|
|
<span className="text-sm font-medium">{t('notes.backToCollection')}</span>
|
|
</button>
|
|
|
|
<div className="flex items-center gap-4">
|
|
<span className="hidden sm:flex items-center gap-1.5 text-[11px] text-foreground/40 select-none">
|
|
{state.isSaving
|
|
? <><Loader2 className="h-3 w-3 animate-spin" /><span>{t('notes.saving')}</span></>
|
|
: state.isDirty
|
|
? <><span className="h-1.5 w-1.5 rounded-full bg-amber-400 inline-block" /><span>{t('notes.dirtyStatus')}</span></>
|
|
: <><Check className="h-3 w-3 text-emerald-500" /><span>{t('notes.savedStatus')}</span></>}
|
|
</span>
|
|
|
|
{state.isMarkdown && !readOnly && (
|
|
<button
|
|
title={state.showMarkdownPreview ? t('notes.markdownEditingTitle') : t('notes.markdownPreviewTitle')}
|
|
aria-label={state.showMarkdownPreview ? t('notes.markdownEditingTitle') : t('notes.markdownPreviewTitle')}
|
|
onClick={() => actions.setShowMarkdownPreview(!state.showMarkdownPreview)}
|
|
className={cn(
|
|
'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} />
|
|
</button>
|
|
)}
|
|
|
|
{state.isMarkdown && !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>
|
|
)}
|
|
|
|
<button
|
|
title={t('ai.openAssistant')}
|
|
aria-label={t('ai.openAssistant')}
|
|
onClick={() => { actions.setAiOpen(!state.aiOpen); actions.setInfoOpen(false) }}
|
|
className={cn(
|
|
'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} />
|
|
</button>
|
|
|
|
<button
|
|
title={t('notes.brainstormThisIdea')}
|
|
aria-label={t('notes.brainstormThisIdeaAria')}
|
|
onClick={() => {
|
|
const title = note.title || ''
|
|
const summary = state.content?.replace(/<[^>]*>/g, '').slice(0, 200) || ''
|
|
const seed = title ? `${title}. ${summary}` : summary
|
|
if (!seed.trim()) return
|
|
window.open(`/brainstorm?seed=${encodeURIComponent(seed.slice(0, 300))}&sourceNoteId=${note.id}`, '_self')
|
|
}}
|
|
className="p-1.5 rounded-full border border-brand-accent/30 dark:border-brand-accent/50 text-brand-accent hover:bg-brand-accent/10 dark:hover:bg-brand-accent/20 transition-all"
|
|
>
|
|
<Wind size={16} />
|
|
</button>
|
|
|
|
{!readOnly && (
|
|
<button
|
|
title={t('flashcards.toolbarGenerate')}
|
|
aria-label={t('flashcards.toolbarGenerate')}
|
|
onClick={() => setFlashcardsOpen(true)}
|
|
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"
|
|
>
|
|
<GraduationCap size={16} />
|
|
</button>
|
|
)}
|
|
|
|
{!readOnly && onToggleAttachments && (
|
|
<button
|
|
title={t('notes.attachments') || 'Attachments'}
|
|
aria-label={t('notes.attachments') || 'Attachments'}
|
|
onClick={onToggleAttachments}
|
|
className="relative 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"
|
|
>
|
|
<Paperclip size={16} />
|
|
{(attachmentsCount ?? 0) > 0 && (
|
|
<span className="absolute -top-1 -right-1 w-3.5 h-3.5 bg-primary text-primary-foreground text-[8px] font-bold rounded-full flex items-center justify-center">
|
|
{attachmentsCount}
|
|
</span>
|
|
)}
|
|
</button>
|
|
)}
|
|
|
|
{!readOnly && (
|
|
<button
|
|
title={state.isDirty ? t('notes.saveNow') : t('notes.noModification')}
|
|
aria-label={state.isDirty ? t('notes.saveNoteAria') : t('notes.noChangesToSaveAria')}
|
|
onClick={actions.handleSaveInPlace}
|
|
disabled={state.isSaving || !state.isDirty}
|
|
className={cn(
|
|
'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} />}
|
|
</button>
|
|
)}
|
|
|
|
{!readOnly && (
|
|
<button
|
|
title={t('notes.shareNoteTitle')}
|
|
aria-label={t('notes.shareNoteAria')}
|
|
onClick={() => setShareOpen(true)}
|
|
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"
|
|
>
|
|
<Share2 size={16} />
|
|
</button>
|
|
)}
|
|
|
|
{!readOnly && (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button aria-label={t('notes.optionsMenuAria')} 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, { skipRevalidation: true })
|
|
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
|
|
toast.success(t('notes.noteDeletedToast'))
|
|
onClose()
|
|
} catch { toast.error(t('notes.deleteNoteFailedToast')) }
|
|
}}
|
|
className="text-red-600 dark:text-red-400 focus:text-red-600"
|
|
>
|
|
<Trash2 className="h-4 w-4 me-2" />
|
|
{t('notes.deleteNoteConfirmItem')}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
|
|
{shareOpen && (
|
|
<NoteShareDialog
|
|
noteId={note.id}
|
|
noteTitle={state.title}
|
|
onClose={() => setShareOpen(false)}
|
|
/>
|
|
)}
|
|
|
|
<FlashcardGenerateDialog
|
|
open={flashcardsOpen}
|
|
onClose={() => setFlashcardsOpen(false)}
|
|
noteId={note.id}
|
|
noteTitle={state.title || note.title || 'Untitled'}
|
|
onSaved={(deckId) => {
|
|
toast.success(t('flashcards.savedCount', { count: '' }).replace('{count}', ''), {
|
|
description: t('flashcards.reviewNow') || 'Review now',
|
|
action: {
|
|
label: t('flashcards.reviewNow') || 'Review now →',
|
|
onClick: () => {
|
|
window.open(`/revision?deckId=${encodeURIComponent(deckId)}`, '_self')
|
|
},
|
|
},
|
|
duration: 8000,
|
|
})
|
|
}}
|
|
/>
|
|
|
|
<button
|
|
aria-label={t('notes.documentInfoAria')}
|
|
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>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="flex items-center justify-between pt-3 border-t border-border/30">
|
|
<div className="flex items-center gap-0.5">
|
|
{!readOnly && (
|
|
<>
|
|
<Button variant="ghost" size="icon" className={cn('h-8 w-8 rounded-md', state.currentReminder && 'text-primary')}
|
|
onClick={() => actions.setShowReminderDialog(true)} title={t('notes.setReminder')}>
|
|
<Bell className="h-4 w-4" />
|
|
</Button>
|
|
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md"
|
|
onClick={() => fileInputRef.current?.click()} title={t('notes.addImage')}>
|
|
<ImageIcon className="h-4 w-4" />
|
|
</Button>
|
|
|
|
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md"
|
|
onClick={() => actions.setShowLinkDialog(true)} title={t('notes.addLink')}>
|
|
<LinkIcon className="h-4 w-4" />
|
|
</Button>
|
|
|
|
{state.isMarkdown && (
|
|
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md"
|
|
onClick={() => actions.setShowMarkdownPreview(!state.showMarkdownPreview)}
|
|
title={state.showMarkdownPreview ? t('general.edit') : t('notes.preview')}>
|
|
<Eye className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className={cn('h-8 gap-1.5 px-2 text-xs font-medium transition-all duration-200 rounded-md', state.aiOpen && 'bg-primary/10 text-primary')}
|
|
onClick={() => actions.setAiOpen(!state.aiOpen)}
|
|
title={t('ai.aiNoteTitle')}
|
|
>
|
|
<Sparkles className="h-3.5 w-3.5" />
|
|
<span className="hidden sm:inline">{t('ai.aiNoteTitle')}</span>
|
|
</Button>
|
|
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md" title={t('notes.changeSize')}>
|
|
<Maximize2 className="h-4 w-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent>
|
|
<div className="flex flex-col gap-1 p-1">
|
|
{(['small', 'medium', 'large'] as const).map((s) => (
|
|
<Button key={s} variant="ghost" size="sm"
|
|
onClick={() => actions.setSize(s)}
|
|
className={cn('justify-start capitalize', state.size === s && 'bg-accent')}>
|
|
{s}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md" title={t('notes.changeColor')}>
|
|
<Palette className="h-4 w-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent>
|
|
<div className="grid grid-cols-5 gap-2 p-2">
|
|
{Object.entries(NOTE_COLORS).map(([colorName, classes]) => (
|
|
<button key={colorName}
|
|
className={cn('h-7 w-7 rounded-full border-2 transition-transform hover:scale-110', classes.bg,
|
|
state.color === colorName ? 'border-gray-900 dark:border-gray-100' : 'border-gray-300 dark:border-gray-700')}
|
|
onClick={() => actions.setColor(colorName as NoteColor)} title={colorName} />
|
|
))}
|
|
</div>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
<LabelManager existingLabels={state.labels} notebookId={note.notebookId} onUpdate={actions.setLabels} />
|
|
</>
|
|
)}
|
|
{readOnly && (
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<span className="text-xs">{t('notes.sharedReadOnly')}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
{readOnly ? (
|
|
<>
|
|
<Button
|
|
variant="default"
|
|
onClick={actions.handleMakeCopy}
|
|
className="flex items-center gap-2"
|
|
>
|
|
<Copy className="h-4 w-4" />
|
|
{t('notes.makeCopy')}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
className="flex items-center gap-2 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/30"
|
|
onClick={async () => {
|
|
try {
|
|
await leaveSharedNote(note.id, { skipRevalidation: true })
|
|
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
|
|
toast.success(t('notes.leftShare'))
|
|
onClose()
|
|
} catch {
|
|
toast.error(t('general.error'))
|
|
}
|
|
}}
|
|
>
|
|
<LogOut className="h-4 w-4" />
|
|
{t('notes.leaveShare')}
|
|
</Button>
|
|
<Button variant="ghost" onClick={onClose}>
|
|
{t('general.close')}
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Button variant="ghost" onClick={onClose}>
|
|
{t('general.cancel')}
|
|
</Button>
|
|
<Button onClick={actions.handleSave} disabled={state.isSaving}>
|
|
{state.isSaving ? t('notes.saving') : t('general.save')}
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|