- general.continue/send - structuredViews.tagApplied/filterDone/filterTodo/propertyStatus - wizard.taskA/taskB - richTextEditor.preview*Tip (7 clés SlashPreview) - wizard.* au niveau racine (48 clés FR + 48 EN) - Total: 0 clé manquante pour FR et EN - 0 erreur TypeScript
180 lines
6.3 KiB
TypeScript
180 lines
6.3 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useState } from 'react'
|
|
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
|
|
import { ExternalLink, Link2, Columns2, GitMerge, Loader2, X } from 'lucide-react'
|
|
import { useLanguage } from '@/lib/i18n/LanguageProvider'
|
|
import { cn } from '@/lib/utils'
|
|
import { sanitizeRichHtml } from '@/lib/sanitize-content'
|
|
|
|
interface LinkedNotePreviewDialogProps {
|
|
isOpen: boolean
|
|
onClose: () => void
|
|
noteId: string
|
|
initialTitle?: string | null
|
|
initialExcerpt?: string
|
|
onInsertCitation?: () => void
|
|
onCompare?: () => void
|
|
onMerge?: () => void
|
|
citationLoading?: boolean
|
|
}
|
|
|
|
function stripHtml(html: string): string {
|
|
return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
|
|
}
|
|
|
|
function openNoteInNewTab(noteId: string) {
|
|
window.open(`/home?openNote=${encodeURIComponent(noteId)}`, '_blank', 'noopener,noreferrer')
|
|
}
|
|
|
|
const linkActionClass =
|
|
'inline-flex items-center gap-1 text-[11px] font-semibold text-muted-foreground hover:text-foreground transition-colors hover:underline'
|
|
|
|
export function LinkedNotePreviewDialog({
|
|
isOpen,
|
|
onClose,
|
|
noteId,
|
|
initialTitle,
|
|
initialExcerpt,
|
|
onInsertCitation,
|
|
onCompare,
|
|
onMerge,
|
|
citationLoading = false,
|
|
}: LinkedNotePreviewDialogProps) {
|
|
const { t } = useLanguage()
|
|
const [title, setTitle] = useState(initialTitle ?? '')
|
|
const [content, setContent] = useState('')
|
|
const [isLoading, setIsLoading] = useState(false)
|
|
const [loadError, setLoadError] = useState(false)
|
|
|
|
useEffect(() => {
|
|
if (!isOpen || !noteId) return
|
|
let cancelled = false
|
|
setIsLoading(true)
|
|
setLoadError(false)
|
|
setTitle(initialTitle ?? '')
|
|
setContent('')
|
|
|
|
void (async () => {
|
|
try {
|
|
const res = await fetch(`/api/notes/${noteId}`)
|
|
if (!res.ok) throw new Error('fetch failed')
|
|
const data = await res.json()
|
|
if (cancelled) return
|
|
if (data.success && data.data) {
|
|
setTitle(data.data.title || t('memoryEcho.comparison.untitled'))
|
|
setContent(data.data.content || '')
|
|
} else {
|
|
setLoadError(true)
|
|
}
|
|
} catch {
|
|
if (!cancelled) setLoadError(true)
|
|
} finally {
|
|
if (!cancelled) setIsLoading(false)
|
|
}
|
|
})()
|
|
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [isOpen, noteId, initialTitle, t])
|
|
|
|
const displayTitle = title || initialTitle || t('memoryEcho.comparison.untitled')
|
|
const plainBody = content ? stripHtml(content) : (initialExcerpt ?? '')
|
|
const isHtml = content.includes('<')
|
|
|
|
return (
|
|
<Dialog open={isOpen} onOpenChange={open => { if (!open) onClose() }}>
|
|
<DialogContent showCloseButton={false} className="max-w-lg max-h-[72vh] overflow-hidden flex flex-col p-0 gap-0">
|
|
<div className="flex items-center justify-between gap-2 px-4 py-3 border-b border-border/50">
|
|
<DialogTitle className="text-sm font-semibold truncate pr-2 leading-snug">
|
|
{displayTitle}
|
|
</DialogTitle>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="p-0.5 rounded text-muted-foreground hover:text-foreground shrink-0"
|
|
aria-label={t('memoryEcho.editorSection.close')}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto px-4 py-3 min-h-0">
|
|
{isLoading && !plainBody && (
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
{t('memoryEcho.editorSection.loading')}
|
|
</div>
|
|
)}
|
|
{loadError && !plainBody && (
|
|
<p className="text-xs text-muted-foreground">{t('memoryEcho.preview.loadError')}</p>
|
|
)}
|
|
{content && isHtml && (
|
|
<div
|
|
className={cn(
|
|
'prose prose-sm dark:prose-invert max-w-none',
|
|
'prose-p:text-sm prose-p:leading-relaxed prose-p:my-2',
|
|
'prose-headings:text-sm prose-headings:font-semibold prose-headings:mt-3 prose-headings:mb-1',
|
|
'prose-li:text-sm prose-table:text-xs'
|
|
)}
|
|
dangerouslySetInnerHTML={{ __html: sanitizeRichHtml(content) }}
|
|
/>
|
|
)}
|
|
{!isHtml && plainBody && (
|
|
<div className="text-sm leading-relaxed text-foreground/90 whitespace-pre-wrap font-serif">
|
|
{plainBody}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="px-4 py-2.5 border-t border-border/50 flex items-center justify-between gap-2 flex-wrap">
|
|
<button
|
|
type="button"
|
|
onClick={() => openNoteInNewTab(noteId)}
|
|
className={linkActionClass}
|
|
>
|
|
<ExternalLink className="h-3 w-3" />
|
|
{t('memoryEcho.editorSection.openInEditor')}
|
|
</button>
|
|
<div className="flex items-center gap-3">
|
|
{onCompare && (
|
|
<button
|
|
type="button"
|
|
className={linkActionClass}
|
|
onClick={() => { onCompare(); onClose() }}
|
|
>
|
|
<Columns2 className="h-3 w-3" />
|
|
{t('memoryEcho.editorSection.compare')}
|
|
</button>
|
|
)}
|
|
{onInsertCitation && (
|
|
<button
|
|
type="button"
|
|
disabled={citationLoading}
|
|
className="inline-flex items-center gap-1 text-[11px] font-bold text-indigo-600 dark:text-indigo-400 hover:underline disabled:opacity-50"
|
|
onClick={() => { onInsertCitation(); onClose() }}
|
|
>
|
|
<Link2 className="h-3 w-3" />
|
|
{citationLoading
|
|
? t('memoryEcho.editorSection.embedding')
|
|
: t('memoryEcho.editorSection.embedPassage')}
|
|
</button>
|
|
)}
|
|
{onMerge && (
|
|
<button
|
|
type="button"
|
|
className="inline-flex items-center gap-1 text-[11px] font-semibold text-purple-600 dark:text-purple-400 hover:underline"
|
|
onClick={() => { onMerge(); onClose() }}
|
|
>
|
|
<GitMerge className="h-3 w-3" />
|
|
{t('memoryEcho.editorSection.merge')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|