Rend les liens entre notes visibles et persistants (sync NoteLink au save, auto-save, graphe réseau rafraîchi), ajoute living blocks, Memory Echo, recherche globale, consentement IA explicite et consolide les prototypes design en architectural-grid. Co-authored-by: Cursor <cursoragent@cursor.com>
187 lines
7.6 KiB
TypeScript
187 lines
7.6 KiB
TypeScript
'use client'
|
|
|
|
import { Node, mergeAttributes } from '@tiptap/core'
|
|
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
|
import { useEffect, useRef, useState, useCallback } from 'react'
|
|
import { Zap, AlertCircle, Unlink, ArrowRight } from 'lucide-react'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// LiveBlock Node View
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface LiveBlockViewProps {
|
|
node: {
|
|
attrs: {
|
|
sourceNoteId: string
|
|
blockId: string
|
|
snapshotContent: string
|
|
sourceNoteTitle: string
|
|
}
|
|
}
|
|
updateAttributes: (attrs: Record<string, string>) => void
|
|
deleteNode: () => void
|
|
}
|
|
|
|
function LiveBlockView({ node, updateAttributes, deleteNode }: LiveBlockViewProps) {
|
|
const { sourceNoteId, blockId, snapshotContent, sourceNoteTitle } = node.attrs
|
|
const [localContent, setLocalContent] = useState(snapshotContent || '')
|
|
const [isDeleted, setIsDeleted] = useState(false)
|
|
const [isOffline, setIsOffline] = useState(false)
|
|
const [pulse, setPulse] = useState(false)
|
|
const pulseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
|
|
// Fetch current block status on mount
|
|
useEffect(() => {
|
|
if (!sourceNoteId || !blockId) return
|
|
fetch(`/api/blocks/${encodeURIComponent(blockId)}/status?sourceNoteId=${sourceNoteId}`)
|
|
.then(r => r.json())
|
|
.then((data: { exists: boolean; content: string; sourceNoteTitle: string }) => {
|
|
if (!data.exists) {
|
|
setIsDeleted(true)
|
|
} else {
|
|
setLocalContent(data.content)
|
|
updateAttributes({ snapshotContent: data.content, sourceNoteTitle: data.sourceNoteTitle })
|
|
}
|
|
})
|
|
.catch(() => setIsOffline(true))
|
|
}, [sourceNoteId, blockId]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// Listen for real-time block update events
|
|
useEffect(() => {
|
|
const handleBlockUpdate = (e: CustomEvent) => {
|
|
if (e.detail?.blockId !== blockId) return
|
|
setLocalContent(e.detail.content)
|
|
updateAttributes({ snapshotContent: e.detail.content })
|
|
setPulse(true)
|
|
if (pulseTimerRef.current) clearTimeout(pulseTimerRef.current)
|
|
pulseTimerRef.current = setTimeout(() => setPulse(false), 1200)
|
|
}
|
|
const handleBlockDeleted = (e: CustomEvent) => {
|
|
if (e.detail?.blockId !== blockId) return
|
|
setIsDeleted(true)
|
|
}
|
|
|
|
window.addEventListener('live-block:update', handleBlockUpdate as EventListener)
|
|
window.addEventListener('live-block:deleted', handleBlockDeleted as EventListener)
|
|
return () => {
|
|
window.removeEventListener('live-block:update', handleBlockUpdate as EventListener)
|
|
window.removeEventListener('live-block:deleted', handleBlockDeleted as EventListener)
|
|
if (pulseTimerRef.current) clearTimeout(pulseTimerRef.current)
|
|
}
|
|
}, [blockId, updateAttributes])
|
|
|
|
const handleDetach = useCallback(async () => {
|
|
// Convert this node to a plain paragraph with snapshot text
|
|
deleteNode()
|
|
}, [deleteNode])
|
|
|
|
const handleOpenSource = useCallback(() => {
|
|
window.open(`/home?openNote=${encodeURIComponent(sourceNoteId)}`, '_blank', 'noopener,noreferrer')
|
|
}, [sourceNoteId])
|
|
|
|
const borderClass = isDeleted
|
|
? 'border-l-rose-500 border-y-rose-200 border-r-rose-200 bg-rose-50/20 dark:border-l-red-700 dark:border-y-red-900/40 dark:border-r-red-900/40 dark:bg-red-950/5'
|
|
: isOffline
|
|
? 'border-l-amber-500 border-y-amber-200 border-r-amber-200 bg-amber-50/10 dark:border-l-amber-600 dark:border-y-amber-800/40 dark:border-r-amber-800/40'
|
|
: pulse
|
|
? 'border-l-blue-500 border-y-blue-300 border-r-blue-300 bg-blue-50/20 shadow-md shadow-blue-500/15 dark:bg-blue-950/10'
|
|
: 'border-l-blue-500 border-y-[#E8E6E3] border-r-[#E8E6E3] bg-blue-50/5 dark:border-y-zinc-800 dark:border-r-zinc-800 dark:bg-blue-950/5'
|
|
|
|
return (
|
|
<NodeViewWrapper>
|
|
<div className="group/liveblock my-4 not-prose">
|
|
<div className={`w-full rounded-xl border-l-[3px] border-y border-r transition-all duration-300 overflow-hidden ${borderClass}`}>
|
|
{/* Header */}
|
|
<div className="px-4 py-1.5 flex items-center justify-between bg-black/[0.015] dark:bg-white/[0.01] border-b border-black/[0.03] dark:border-white/[0.02]">
|
|
<div className="flex items-center gap-2">
|
|
{isDeleted ? (
|
|
<AlertCircle size={10} className="text-rose-500 shrink-0" />
|
|
) : (
|
|
<Zap size={10} className={`shrink-0 ${isOffline ? 'text-amber-500' : 'text-blue-500 fill-blue-500/20'}`} />
|
|
)}
|
|
<span className="text-[10px] font-sans font-medium text-[var(--color-concrete)] hover:text-[var(--color-ink)] transition-colors cursor-default max-w-[200px] truncate">
|
|
{isDeleted ? 'Source déconnectée' : (sourceNoteTitle || 'Note connectée')}
|
|
</span>
|
|
{isDeleted ? (
|
|
<span className="bg-rose-500/10 text-rose-600 dark:text-rose-400 font-bold px-1.5 rounded text-[8px] uppercase tracking-wider">
|
|
DÉCONNECTÉ
|
|
</span>
|
|
) : isOffline ? (
|
|
<span className="bg-amber-500/10 text-amber-600 dark:text-amber-400 font-bold px-1.5 rounded text-[8px] uppercase tracking-wider">
|
|
HORS-LIGNE
|
|
</span>
|
|
) : (
|
|
<span className="bg-blue-500/10 text-blue-600 dark:text-blue-400 font-bold px-1.5 rounded text-[8px] uppercase tracking-wider animate-pulse">
|
|
LIVE
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{isDeleted ? (
|
|
<button
|
|
onClick={handleDetach}
|
|
className="text-[9.5px] font-bold text-rose-600 hover:text-rose-500 dark:text-rose-400 flex items-center gap-1 hover:underline transition-all"
|
|
contentEditable={false}
|
|
>
|
|
<Unlink size={10} />
|
|
Décharger le lien
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={handleOpenSource}
|
|
className="opacity-0 group-hover/liveblock:opacity-100 flex items-center gap-1 text-[9.5px] font-extrabold text-blue-600 dark:text-blue-400 hover:underline transition-all"
|
|
contentEditable={false}
|
|
>
|
|
Ouvrir <ArrowRight size={10} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{/* Content */}
|
|
<div className="px-4 py-3 bg-blue-500/[0.015] dark:bg-blue-500/[0.005]">
|
|
<p
|
|
className="text-sm leading-relaxed text-[var(--color-ink)] opacity-80 dark:text-[var(--color-dark-ink)] font-sans whitespace-pre-wrap"
|
|
contentEditable={false}
|
|
>
|
|
{localContent || '(bloc vide)'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</NodeViewWrapper>
|
|
)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// TipTap Node Definition
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const LiveBlockExtension = Node.create({
|
|
name: 'liveBlock',
|
|
group: 'block',
|
|
atom: true,
|
|
draggable: true,
|
|
selectable: true,
|
|
|
|
addAttributes() {
|
|
return {
|
|
sourceNoteId: { default: '' },
|
|
blockId: { default: '' },
|
|
snapshotContent: { default: '' },
|
|
sourceNoteTitle: { default: '' },
|
|
}
|
|
},
|
|
|
|
parseHTML() {
|
|
return [{ tag: 'div[data-live-block]' }]
|
|
},
|
|
|
|
renderHTML({ HTMLAttributes }) {
|
|
return ['div', mergeAttributes(HTMLAttributes, { 'data-live-block': 'true' })]
|
|
},
|
|
|
|
addNodeView() {
|
|
return ReactNodeViewRenderer(LiveBlockView)
|
|
},
|
|
})
|