feat: architectural grid editor fullPage + slash commands + doc info panel + AI title
This commit is contained in:
@@ -25,8 +25,12 @@ 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 } from 'lucide-react'
|
||||
import { X, Plus, Palette, Image as ImageIcon, Bell, Eye, Link as LinkIcon, Sparkles, Maximize2, Copy, Wand2, LogOut, ArrowLeft, Info, Check, Loader2 } from 'lucide-react'
|
||||
import { updateNote, createNote, cleanupOrphanedImages, leaveSharedNote } from '@/app/actions/notes'
|
||||
import { format } from 'date-fns'
|
||||
import { useTitleSuggestions } from '@/hooks/use-title-suggestions'
|
||||
import { MarkdownSlashCommands } from './markdown-slash-commands'
|
||||
import { NoteDocumentInfoPanel } from './note-document-info-panel'
|
||||
import { fetchLinkMetadata } from '@/app/actions/scrape'
|
||||
import { cn, extractImagesFromHTML } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
@@ -57,9 +61,10 @@ interface NoteEditorProps {
|
||||
note: Note
|
||||
readOnly?: boolean
|
||||
onClose: () => void
|
||||
fullPage?: boolean
|
||||
}
|
||||
|
||||
export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps) {
|
||||
export function NoteEditor({ note, readOnly = false, onClose, fullPage = false }: NoteEditorProps) {
|
||||
const { data: session } = useSession()
|
||||
const [aiAssistantEnabled, setAiAssistantEnabled] = useState(true)
|
||||
const [autoLabelingEnabled, setAutoLabelingEnabled] = useState(true)
|
||||
@@ -147,6 +152,17 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
||||
// AI processing state for ActionBar
|
||||
const [isProcessingAI, setIsProcessingAI] = useState(false)
|
||||
const [aiOpen, setAiOpen] = useState(false)
|
||||
const [infoOpen, setInfoOpen] = useState(false)
|
||||
const [isDirty, setIsDirty] = useState(false)
|
||||
|
||||
// fullPage — auto title suggestions
|
||||
const [dismissedTitleSuggestions, setDismissedTitleSuggestions] = useState(false)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const { suggestions: autoTitleSuggestions } = useTitleSuggestions({
|
||||
content,
|
||||
enabled: fullPage && !title && !dismissedTitleSuggestions,
|
||||
})
|
||||
|
||||
// Track previous content for copilot action undo
|
||||
const [previousContentForCopilot, setPreviousContentForCopilot] = useState<string | null>(null)
|
||||
|
||||
@@ -642,6 +658,179 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
||||
}
|
||||
}
|
||||
|
||||
// ── fullPage mode: early return with editorial layout ──
|
||||
if (fullPage) {
|
||||
return (
|
||||
<>
|
||||
<div className="h-full flex flex-col overflow-hidden bg-background">
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* main scrollable column */}
|
||||
<div className="flex-1 flex flex-col overflow-y-auto">
|
||||
|
||||
{/* sticky toolbar */}
|
||||
<div className="px-6 py-3 flex items-center justify-between sticky top-0 bg-background/90 backdrop-blur-sm z-40 border-b border-border gap-4">
|
||||
<button onClick={onClose} className="flex items-center gap-2 text-foreground hover:opacity-60 transition-opacity shrink-0">
|
||||
<ArrowLeft size={16} />
|
||||
<span className="text-sm font-medium hidden sm:inline">{t('notes.backToCollection') || 'Retour'}</span>
|
||||
</button>
|
||||
<div className="flex-1 flex justify-center">
|
||||
<NoteTypeSelector
|
||||
value={noteType}
|
||||
onChange={(newType) => { setNoteType(newType); setShowMarkdownPreview(newType === 'markdown'); setIsDirty(true) }}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<span className="hidden sm:flex items-center gap-1 text-[11px] text-muted-foreground/50 mr-2 select-none">
|
||||
{isSaving
|
||||
? <><Loader2 className="h-3 w-3 animate-spin" /><span>{t('notes.saving') || 'Saving...'}</span></>
|
||||
: isDirty
|
||||
? <><span className="h-1.5 w-1.5 rounded-full bg-amber-400 inline-block" /><span>{t('notes.dirtyStatus') || 'Modifié'}</span></>
|
||||
: <><Check className="h-3 w-3 text-emerald-500" /><span>{t('notes.savedStatus') || 'Enregistré'}</span></>}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => { setAiOpen(v => !v); setInfoOpen(false) }}
|
||||
className={cn('flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-xs font-medium transition-all',
|
||||
aiOpen ? 'bg-foreground text-background border-foreground' : 'border-border text-muted-foreground hover:text-foreground hover:bg-muted')}
|
||||
>
|
||||
<Sparkles size={13} /><span className="hidden sm:inline">IA</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setInfoOpen(v => !v); setAiOpen(false) }}
|
||||
className={cn('flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-xs font-medium transition-all',
|
||||
infoOpen ? 'bg-foreground text-background border-foreground' : 'border-border text-muted-foreground hover:text-foreground hover:bg-muted')}
|
||||
>
|
||||
<Info size={13} /><span className="hidden sm:inline">Info</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* body */}
|
||||
<div className="max-w-4xl mx-auto w-full px-12 py-16 space-y-12">
|
||||
{/* meta */}
|
||||
<div className="text-[12px] text-muted-foreground uppercase tracking-[.25em] font-bold" suppressHydrationWarning>
|
||||
{format(new Date(note.contentUpdatedAt), 'MMM d, yyyy')}
|
||||
</div>
|
||||
|
||||
{/* title + AI */}
|
||||
<div className="space-y-4">
|
||||
<div className="group relative">
|
||||
<input
|
||||
dir="auto" type="text"
|
||||
placeholder={t('notes.titlePlaceholder')}
|
||||
value={title}
|
||||
onChange={(e) => { setTitle(e.target.value); setIsDirty(true); setDismissedTitleSuggestions(true) }}
|
||||
disabled={readOnly}
|
||||
className="w-full text-5xl md:text-6xl font-memento-serif font-bold border-0 outline-none px-0 bg-transparent text-foreground leading-tight placeholder:text-muted-foreground/30 pr-14"
|
||||
/>
|
||||
{!title && !readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
const plain = content.replace(/<[^>]+>/g, ' ').trim()
|
||||
if (plain.split(/\s+/).filter(Boolean).length < 3) return
|
||||
setIsProcessingAI(true)
|
||||
try {
|
||||
const res = await fetch('/api/ai/title-suggestions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: plain }) })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const s = data.title || data.suggestedTitle || (data.suggestions?.[0]?.title ?? '')
|
||||
if (s) { setTitle(s); setIsDirty(true) }
|
||||
}
|
||||
} catch {} finally { setIsProcessingAI(false) }
|
||||
}}
|
||||
disabled={isProcessingAI}
|
||||
className="absolute right-0 top-1/2 -translate-y-1/2 opacity-30 hover:opacity-100 transition-opacity rounded-lg p-2 text-muted-foreground hover:bg-muted hover:text-primary"
|
||||
title={t('ai.suggestTitle') || 'Générer un titre IA'}
|
||||
>
|
||||
{isProcessingAI ? <Loader2 className="h-5 w-5 animate-spin" /> : <Sparkles className="h-5 w-5" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!title && !dismissedTitleSuggestions && autoTitleSuggestions.length > 0 && (
|
||||
<TitleSuggestions
|
||||
suggestions={autoTitleSuggestions}
|
||||
onSelect={(s) => { setTitle(s); setDismissedTitleSuggestions(true); setIsDirty(true) }}
|
||||
onDismiss={() => setDismissedTitleSuggestions(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* editor */}
|
||||
<div className="max-w-2xl mx-auto pb-32">
|
||||
{noteType === 'richtext' ? (
|
||||
<RichTextEditor
|
||||
content={content}
|
||||
onChange={(v) => { setContent(v); setIsDirty(true) }}
|
||||
className="min-h-[200px] text-lg font-light leading-relaxed"
|
||||
onImageUpload={uploadImageFile}
|
||||
/>
|
||||
) : noteType === 'markdown' && showMarkdownPreview ? (
|
||||
<div
|
||||
className="min-h-[200px] cursor-text prose prose-sm dark:prose-invert max-w-none text-base leading-relaxed"
|
||||
onClick={() => setShowMarkdownPreview(false)}
|
||||
>
|
||||
<MarkdownContent content={content} />
|
||||
<p className="text-[11px] text-muted-foreground/40 mt-4 select-none">Cliquez pour éditer</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
dir="auto"
|
||||
placeholder={t('notes.takeNote') || "Tapez '/' pour les commandes..."}
|
||||
value={content}
|
||||
onFocus={() => setShowMarkdownPreview(false)}
|
||||
onChange={(e) => { setContent(e.target.value); setIsDirty(true) }}
|
||||
disabled={readOnly}
|
||||
className="w-full min-h-[400px] border-0 outline-none px-0 bg-transparent text-lg leading-relaxed font-light resize-none placeholder:text-muted-foreground/30"
|
||||
/>
|
||||
{noteType === 'markdown' && content && !readOnly && (
|
||||
<button type="button" onClick={() => setShowMarkdownPreview(true)}
|
||||
className="mt-2 text-[11px] text-muted-foreground/50 hover:text-foreground flex items-center gap-1 transition-colors">
|
||||
<Eye className="h-3 w-3" /> Prévisualiser le Markdown
|
||||
</button>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<MarkdownSlashCommands
|
||||
textareaRef={textareaRef as React.RefObject<HTMLTextAreaElement>}
|
||||
value={content}
|
||||
onChange={(v) => { setContent(v); setIsDirty(true) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* side panels */}
|
||||
{aiOpen && (
|
||||
<ContextualAIChat
|
||||
onClose={() => setAiOpen(false)}
|
||||
noteTitle={title} noteContent={content} noteImages={allImages} noteId={note.id}
|
||||
onApplyToNote={(nc) => { setPreviousContentForCopilot(content); setContent(nc); setIsDirty(true); if (noteType === 'markdown') setShowMarkdownPreview(true) }}
|
||||
onUndoLastAction={previousContentForCopilot !== null ? () => { setContent(previousContentForCopilot!); setPreviousContentForCopilot(null) } : undefined}
|
||||
lastActionApplied={previousContentForCopilot !== null}
|
||||
notebooks={notebooks.map(nb => ({ id: nb.id, name: nb.name }))}
|
||||
diagramInsertFormat={noteType === 'richtext' ? 'html' : 'markdown'}
|
||||
/>
|
||||
)}
|
||||
{infoOpen && (
|
||||
<NoteDocumentInfoPanel
|
||||
note={note} content={content}
|
||||
onClose={() => setInfoOpen(false)}
|
||||
onNoteRestored={(r) => { setContent(r.content || ''); setTitle(r.title || ''); setIsDirty(false) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<input ref={fileInputRef} type="file" accept="image/*" multiple className="hidden" onChange={handleImageUpload} />
|
||||
</div>
|
||||
<ReminderDialog open={showReminderDialog} onOpenChange={setShowReminderDialog} currentReminder={currentReminder} onSave={handleReminderSave} onRemove={handleRemoveReminder} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={onClose}>
|
||||
<DialogContent
|
||||
|
||||
Reference in New Issue
Block a user