- Add slides.tool.ts with support for title, bullets, chart, stats, table, cards, timeline, quote, comparison, equation, image, summary slide types - Chart types: bar, horizontal-bar, line, donut, radar - Integrate with agent executor and canvas system - Add multilingual support (en/fr) - Various UI improvements and bug fixes Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
399 lines
16 KiB
TypeScript
399 lines
16 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
|
|
} from 'lucide-react'
|
|
import { NoteShareDialog } from './note-share-dialog'
|
|
import { deleteNote, leaveSharedNote } from '@/app/actions/notes'
|
|
import { useRefresh } from '@/lib/use-refresh'
|
|
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 { refreshNotes } = useRefresh()
|
|
const [isConverting, setIsConverting] = useState(false)
|
|
const [shareOpen, setShareOpen] = 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') {
|
|
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={onClose}
|
|
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 && 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)
|
|
refreshNotes(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)}
|
|
/>
|
|
)}
|
|
|
|
<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)
|
|
toast.success(t('notes.leftShare'))
|
|
refreshNotes(note.notebookId)
|
|
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>
|
|
)
|
|
}
|