366 lines
14 KiB
TypeScript
366 lines
14 KiB
TypeScript
'use client'
|
|
|
|
import { useNoteEditorContext } from './note-editor-context'
|
|
import { NoteTitleBlock } from './note-title-block'
|
|
import { NoteContentArea } from './note-content-area'
|
|
import { NoteMetadataSection } from './note-metadata-section'
|
|
import { InlinePaywall } from '@/components/settings/inline-paywall'
|
|
import { EditorImages } from '@/components/editor-images'
|
|
import { ComparisonModal } from '@/components/comparison-modal'
|
|
import { FusionModal } from '@/components/fusion-modal'
|
|
import { ReminderDialog } from '@/components/reminder-dialog'
|
|
import { ContextualAIChat } from '@/components/contextual-ai-chat'
|
|
import { MemoryEchoSection } from '@/components/memory-echo-section'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogFooter,
|
|
} from '@/components/ui/dialog'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { X } from 'lucide-react'
|
|
import { useLanguage } from '@/lib/i18n'
|
|
import { cn } from '@/lib/utils'
|
|
import { toast } from 'sonner'
|
|
import { Note } from '@/lib/types'
|
|
import { useState } from 'react'
|
|
|
|
interface NoteEditorDialogProps {
|
|
onClose: () => void
|
|
}
|
|
|
|
export function NoteEditorDialog({ onClose }: NoteEditorDialogProps) {
|
|
const { state, actions, note, readOnly, notebooks, fileInputRef } = useNoteEditorContext()
|
|
const { t } = useLanguage()
|
|
const [comparisonSimilarity, setComparisonSimilarity] = useState<number | undefined>()
|
|
|
|
const handleSaveAndClose = async () => {
|
|
await actions.handleSave()
|
|
onClose()
|
|
}
|
|
|
|
return (
|
|
<Dialog open={true} onOpenChange={onClose}>
|
|
<DialogContent
|
|
className={cn(
|
|
'!max-w-[min(95vw,1600px)] h-[90vh] overflow-hidden p-0 flex flex-row items-stretch rounded-lg',
|
|
state.colorClasses
|
|
)}
|
|
>
|
|
<div className="flex-1 min-w-0 flex flex-col overflow-y-auto space-y-4 px-6 py-6">
|
|
<DialogHeader>
|
|
<DialogTitle className="sr-only">{t('notes.edit')}</DialogTitle>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<h2 className="text-lg font-semibold">{readOnly ? t('notes.view') : t('notes.edit')}</h2>
|
|
</div>
|
|
{readOnly && (
|
|
<Badge variant="secondary" className="bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-foreground">
|
|
{t('notes.readOnly')}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4">
|
|
{/* Title */}
|
|
<NoteTitleBlock />
|
|
|
|
{/* Title Suggestions */}
|
|
{!readOnly && state.titleSuggestions.length > 0 && (
|
|
<div>
|
|
{/* TitleSuggestions component */}
|
|
</div>
|
|
)}
|
|
|
|
{/* Images */}
|
|
<EditorImages images={state.images} onRemove={actions.handleRemoveImage} />
|
|
|
|
{/* Link Previews */}
|
|
{state.links.length > 0 && (
|
|
<div className="flex flex-col gap-2">
|
|
{state.links.map((link, idx) => (
|
|
<div key={idx} className="relative group border rounded-lg overflow-hidden bg-white/50 dark:bg-black/20 flex">
|
|
{link.imageUrl && (
|
|
<div className="w-24 h-24 flex-shrink-0 bg-cover bg-center" style={{ backgroundImage: `url(${link.imageUrl})` }} />
|
|
)}
|
|
<div className="p-2 flex-1 min-w-0 flex flex-col justify-center">
|
|
<h4 className="font-medium text-sm truncate">{link.title || link.url}</h4>
|
|
{link.description && <p className="text-xs text-gray-500 truncate">{link.description}</p>}
|
|
<a href={link.url} target="_blank" rel="noopener noreferrer" className="text-xs text-primary truncate hover:underline block mt-1">
|
|
{new URL(link.url).hostname}
|
|
</a>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="absolute top-1 right-1 h-6 w-6 p-0 bg-white/50 hover:bg-white opacity-0 group-hover:opacity-100 transition-opacity rounded-full"
|
|
onClick={() => actions.handleRemoveLink(idx)}
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Content Area */}
|
|
{state.quotaExceededFeature === 'reformulate' && (
|
|
<InlinePaywall
|
|
feature="reformulate"
|
|
onDismiss={() => actions.setQuotaExceededFeature(null)}
|
|
/>
|
|
)}
|
|
<NoteContentArea />
|
|
|
|
{/* Metadata Section */}
|
|
<NoteMetadataSection />
|
|
|
|
{/* Memory Echo Connections Section */}
|
|
{!readOnly && (
|
|
<MemoryEchoSection
|
|
noteId={note.id}
|
|
onCompareNotes={(noteIds: string[], meta?: { similarity?: number }) => {
|
|
setComparisonSimilarity(meta?.similarity)
|
|
Promise.all(noteIds.map(async (id: string) => {
|
|
try {
|
|
const res = await fetch(`/api/notes/${id}`)
|
|
if (!res.ok) {
|
|
console.error(`Failed to fetch note ${id}`)
|
|
return null
|
|
}
|
|
const data = await res.json()
|
|
if (data.success && data.data) {
|
|
return data.data
|
|
}
|
|
return null
|
|
} catch (error) {
|
|
console.error(`Error fetching note ${id}:`, error)
|
|
return null
|
|
}
|
|
}))
|
|
.then(notes => notes.filter((n: any) => n !== null) as Array<Partial<Note>>)
|
|
.then(fetchedNotes => {
|
|
actions.setComparisonNotes(fetchedNotes)
|
|
})
|
|
}}
|
|
onMergeNotes={async (noteIds: string[]) => {
|
|
const fetchedNotes = await Promise.all(noteIds.map(async (id: string) => {
|
|
try {
|
|
const res = await fetch(`/api/notes/${id}`)
|
|
if (!res.ok) {
|
|
console.error(`Failed to fetch note ${id}`)
|
|
return null
|
|
}
|
|
const data = await res.json()
|
|
if (data.success && data.data) {
|
|
return data.data
|
|
}
|
|
return null
|
|
} catch (error) {
|
|
console.error(`Error fetching note ${id}:`, error)
|
|
return null
|
|
}
|
|
}))
|
|
actions.setFusionNotes(fetchedNotes.filter((n: any) => n !== null) as Array<Partial<Note>>)
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Dialog Toolbar - inline for now */}
|
|
<div className="flex items-center justify-between pt-3 border-t border-border/30">
|
|
<div className="flex gap-2">
|
|
<Button variant="ghost" onClick={onClose}>
|
|
{t('general.cancel')}
|
|
</Button>
|
|
<Button onClick={handleSaveAndClose} disabled={state.isSaving}>
|
|
{state.isSaving ? t('notes.saving') : t('general.save')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
multiple
|
|
className="hidden"
|
|
onChange={actions.handleImageUpload}
|
|
/>
|
|
</div>
|
|
|
|
{/* ── AI Copilot Side Panel ── */}
|
|
{state.aiOpen && (
|
|
<ContextualAIChat
|
|
onClose={() => actions.setAiOpen(false)}
|
|
noteTitle={state.title}
|
|
noteContent={state.content}
|
|
noteImages={state.allImages}
|
|
noteId={note.id}
|
|
onApplyToNote={(newContent: string) => {
|
|
actions.setPreviousContentForCopilot(state.content)
|
|
actions.setContent(newContent)
|
|
}}
|
|
onUndoLastAction={state.previousContentForCopilot !== null ? () => {
|
|
if (state.previousContentForCopilot !== null) {
|
|
actions.setContent(state.previousContentForCopilot)
|
|
}
|
|
actions.setPreviousContentForCopilot(null)
|
|
} : undefined}
|
|
lastActionApplied={state.previousContentForCopilot !== null}
|
|
notebooks={notebooks}
|
|
notebookId={note.notebookId ?? undefined}
|
|
notebookName={notebooks.find(nb => nb.id === note.notebookId)?.name ?? undefined}
|
|
/>
|
|
)}
|
|
</DialogContent>
|
|
|
|
{/* Reminder Dialog */}
|
|
<ReminderDialog
|
|
open={state.showReminderDialog}
|
|
onOpenChange={actions.setShowReminderDialog}
|
|
currentReminder={state.currentReminder}
|
|
onSave={actions.handleReminderSave}
|
|
onRemove={actions.handleRemoveReminder}
|
|
/>
|
|
|
|
{/* Link Dialog */}
|
|
<Dialog open={state.showLinkDialog} onOpenChange={actions.setShowLinkDialog}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>{t('notes.addLink')}</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4 py-4">
|
|
<Input
|
|
placeholder="https://example.com"
|
|
value={state.linkUrl}
|
|
onChange={(e) => actions.setLinkUrl(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault()
|
|
actions.handleAddLink()
|
|
}
|
|
}}
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="ghost" onClick={() => actions.setShowLinkDialog(false)}>
|
|
{t('general.cancel')}
|
|
</Button>
|
|
<Button onClick={actions.handleAddLink}>
|
|
{t('general.add')}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Reformulation Modal */}
|
|
{state.reformulationModal && (
|
|
<Dialog open={!!state.reformulationModal} onOpenChange={() => actions.setReformulationModal(null)}>
|
|
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>{t('ai.reformulationComparison')}</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="grid grid-cols-2 gap-4 py-4">
|
|
<div>
|
|
<h3 className="font-semibold mb-2 text-sm text-gray-600 dark:text-gray-400">{t('ai.original')}</h3>
|
|
<div className="p-4 bg-gray-50 dark:bg-gray-900 rounded-lg text-sm">
|
|
{state.reformulationModal.originalText}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<h3 className="font-semibold mb-2 text-sm text-purple-600 dark:text-purple-400">
|
|
{t('ai.reformulated')} ({state.reformulationModal.option})
|
|
</h3>
|
|
<div className="p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg text-sm">
|
|
{state.reformulationModal.reformulatedText}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="ghost" onClick={() => actions.setReformulationModal(null)}>
|
|
{t('general.cancel')}
|
|
</Button>
|
|
<Button onClick={actions.handleApplyRefactor}>
|
|
{t('general.apply')}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)}
|
|
|
|
{/* Comparison Modal */}
|
|
{state.comparisonNotes && state.comparisonNotes.length > 0 && (
|
|
<ComparisonModal
|
|
isOpen={!!state.comparisonNotes}
|
|
onClose={() => {
|
|
setComparisonSimilarity(undefined)
|
|
actions.setComparisonNotes([])
|
|
}}
|
|
notes={state.comparisonNotes}
|
|
similarity={comparisonSimilarity}
|
|
onMergeNotes={async (noteIds: string[]) => {
|
|
const fetchedNotes = await Promise.all(noteIds.map(async (id: string) => {
|
|
try {
|
|
const res = await fetch(`/api/notes/${id}`)
|
|
if (!res.ok) return null
|
|
const data = await res.json()
|
|
return data.success && data.data ? data.data : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}))
|
|
actions.setFusionNotes(fetchedNotes.filter((n): n is Partial<Note> => n !== null))
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Fusion Modal */}
|
|
{state.fusionNotes && state.fusionNotes.length > 0 && (
|
|
<FusionModal
|
|
isOpen={!!state.fusionNotes}
|
|
onClose={() => actions.setFusionNotes([])}
|
|
notes={state.fusionNotes}
|
|
onConfirmFusion={async ({ title, content }: { title: string; content: string }, options: { keepAllTags: boolean; archiveOriginals: boolean }) => {
|
|
// Save current first
|
|
await actions.handleSave()
|
|
|
|
// Use createNote directly since handleMakeCopy doesn't handle fusion
|
|
const { createNote } = await import('@/app/actions/notes')
|
|
await createNote({
|
|
title,
|
|
content,
|
|
labels: options.keepAllTags
|
|
? [...new Set(state.fusionNotes.flatMap(n => n.labels || []))]
|
|
: state.fusionNotes[0].labels || [],
|
|
color: state.fusionNotes[0].color,
|
|
type: 'text',
|
|
isMarkdown: true,
|
|
autoGenerated: true,
|
|
aiProvider: 'fusion',
|
|
notebookId: state.fusionNotes[0].notebookId ?? undefined
|
|
})
|
|
|
|
// Archive original notes if option is selected
|
|
if (options.archiveOriginals) {
|
|
const { updateNote } = await import('@/app/actions/notes')
|
|
for (const fusionNote of state.fusionNotes) {
|
|
if (fusionNote.id) {
|
|
await updateNote(fusionNote.id, { isArchived: true })
|
|
}
|
|
}
|
|
}
|
|
|
|
toast.success(t('toast.notesFusionSuccess'))
|
|
onClose()
|
|
}}
|
|
/>
|
|
)}
|
|
</Dialog>
|
|
)
|
|
} |