refactor: split NoteEditor into focused components + consolidate contexts
Phase 1: NoteEditor Split (64KB → 9 focused components) - components/note-editor/: types.ts, context, toolbar, title-block, content-area, metadata-section, full-page, dialog compositions - Maintains backwards compatibility via re-export from note-editor.tsx Phase 2: Context Consolidation (5 → 3 contexts) - NotebooksContext absorbs LabelContext (labels CRUD) - EditorUIContext merges HomeViewContext + NotebookDragContext - Removed: LabelContext, home-view-context, notebook-drag-context Phase 3: React Query Infrastructure - Added QueryProvider with @tanstack/react-query - lib/query-keys.ts: centralized query key definitions - lib/query-hooks.ts: useNotes, useNotebooksQuery, useLabelsQuery - lib/use-refresh.ts: hybrid invalidateQueries + triggerRefresh helper - NotebooksContext: invalidateQueries on mutations (with triggerRefresh fallback) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
307
memento-note/components/note-editor/note-editor-toolbar.tsx
Normal file
307
memento-note/components/note-editor/note-editor-toolbar.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
'use client'
|
||||
|
||||
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 { NoteTypeSelector } from '@/components/note-type-selector'
|
||||
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, Info, Check, Loader2, Save, MoreHorizontal,
|
||||
Trash2, LogOut
|
||||
} from 'lucide-react'
|
||||
import { deleteNote, leaveSharedNote } from '@/app/actions/notes'
|
||||
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
||||
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
|
||||
}
|
||||
|
||||
export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
|
||||
const { state, actions, note, readOnly, fullPage, notebooks, fileInputRef } = useNoteEditorContext()
|
||||
const { t } = useLanguage()
|
||||
const { triggerRefresh } = useNoteRefresh()
|
||||
|
||||
const notebookName = notebooks.find(nb => nb.id === note.notebookId)?.name || null
|
||||
|
||||
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">
|
||||
{/* Left: back */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex items-center gap-2 text-foreground hover:opacity-60 transition-opacity"
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
<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">
|
||||
{state.isSaving
|
||||
? <><Loader2 className="h-3 w-3 animate-spin" /><span>Saving…</span></>
|
||||
: state.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={state.noteType}
|
||||
onChange={(newType) => { actions.setNoteType(newType); actions.setIsDirty(true) }}
|
||||
compact
|
||||
/>
|
||||
|
||||
{/* Preview toggle — only for text/markdown, in toolbar where it's visible */}
|
||||
{(state.noteType === 'text' || state.noteType === 'markdown') && !readOnly && (
|
||||
<button
|
||||
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',
|
||||
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 */}
|
||||
<button
|
||||
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',
|
||||
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 */}
|
||||
{!readOnly && (
|
||||
<button
|
||||
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',
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* Three-dot options menu */}
|
||||
{!readOnly && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button aria-label="Menu des options" 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>
|
||||
)
|
||||
}
|
||||
|
||||
// Dialog toolbar
|
||||
return (
|
||||
<div className="flex items-center justify-between pt-3 border-t border-border/30">
|
||||
<div className="flex items-center gap-0.5">
|
||||
{!readOnly && (
|
||||
<>
|
||||
{/* Reminder */}
|
||||
<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>
|
||||
{/* Add Image */}
|
||||
<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>
|
||||
|
||||
{/* Add Link */}
|
||||
<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>
|
||||
|
||||
<NoteTypeSelector value={state.noteType} onChange={(newType) => { actions.setNoteType(newType); if (newType !== 'markdown') actions.setShowMarkdownPreview(false) }} />
|
||||
|
||||
{state.noteType === 'markdown' && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* AI Copilot */}
|
||||
{state.noteType !== 'checklist' && (
|
||||
<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="IA Note">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">IA Note</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Size Selector */}
|
||||
<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>
|
||||
|
||||
{/* Color Picker */}
|
||||
<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>
|
||||
|
||||
{/* Label Manager */}
|
||||
<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)
|
||||
toast.success(t('notes.leftShare') || 'Share removed')
|
||||
triggerRefresh()
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user