feat(notes): liens internes, onglet Réseau, living blocks et consentement IA
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>
This commit is contained in:
194
architectural-grid/src/components/LivingBlock.tsx
Normal file
194
architectural-grid/src/components/LivingBlock.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { Zap, HelpCircle, ArrowRight, RefreshCw, Unlink, AlertCircle } from 'lucide-react';
|
||||
import { Note } from '../types';
|
||||
|
||||
interface LivingBlockProps {
|
||||
sourceNoteId: string;
|
||||
blockIndex: number;
|
||||
allNotes: Note[];
|
||||
hostNote: Note;
|
||||
onUpdateNote: (updatedNote: Note) => void;
|
||||
onOpenNote: (noteId: string) => void;
|
||||
wsConnected: boolean;
|
||||
broadcastLivingBlockUpdate?: (sourceNoteId: string, blockIndex: number, newText: string) => void;
|
||||
}
|
||||
|
||||
export const LivingBlock: React.FC<LivingBlockProps> = ({
|
||||
sourceNoteId,
|
||||
blockIndex,
|
||||
allNotes,
|
||||
hostNote,
|
||||
onUpdateNote,
|
||||
onOpenNote,
|
||||
wsConnected,
|
||||
broadcastLivingBlockUpdate
|
||||
}) => {
|
||||
const [pulse, setPulse] = useState(false);
|
||||
const pulseRef = useRef<any>(null);
|
||||
|
||||
// Locate source note and actual paragraph text
|
||||
const sourceNote = allNotes.find(n => n.id === sourceNoteId);
|
||||
const paragraphs = sourceNote?.content.split('\n') || [];
|
||||
const rawText = paragraphs[blockIndex];
|
||||
|
||||
// Store a local cache in standard state to support the "Source Deleted Snapshot" or local typing lag minimization
|
||||
const [localText, setLocalText] = useState(rawText || "Contenu de l'extrait sémantique.");
|
||||
const [isDeleted, setIsDeleted] = useState(!sourceNote || rawText === undefined);
|
||||
|
||||
// Sync state if source note or text updates from outside
|
||||
useEffect(() => {
|
||||
const isSourceMissing = !sourceNote || rawText === undefined;
|
||||
setIsDeleted(isSourceMissing);
|
||||
|
||||
if (!isSourceMissing && rawText !== localText) {
|
||||
setLocalText(rawText);
|
||||
}
|
||||
}, [rawText, sourceNote]);
|
||||
|
||||
// Handle pulse notification when custom update event is received from socket
|
||||
useEffect(() => {
|
||||
const handlePulseEvent = (e: any) => {
|
||||
if (e.detail && e.detail.sourceNoteId === sourceNoteId && e.detail.blockIndex === blockIndex) {
|
||||
setPulse(true);
|
||||
if (pulseRef.current) clearTimeout(pulseRef.current);
|
||||
pulseRef.current = setTimeout(() => {
|
||||
setPulse(false);
|
||||
}, 1000);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('living-block-pulse', handlePulseEvent);
|
||||
return () => {
|
||||
window.removeEventListener('living-block-pulse', handlePulseEvent);
|
||||
if (pulseRef.current) clearTimeout(pulseRef.current);
|
||||
};
|
||||
}, [sourceNoteId, blockIndex]);
|
||||
|
||||
// Edit body text and stream to central note and websockets
|
||||
const handleBodyTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newText = e.target.value;
|
||||
setLocalText(newText);
|
||||
|
||||
if (sourceNote && !isDeleted) {
|
||||
const updatedParagraphs = [...paragraphs];
|
||||
updatedParagraphs[blockIndex] = newText;
|
||||
const updatedSourceNote = {
|
||||
...sourceNote,
|
||||
content: updatedParagraphs.join('\n')
|
||||
};
|
||||
// 1. Update state
|
||||
onUpdateNote(updatedSourceNote);
|
||||
|
||||
// 2. Broadcast via WS connection to other terminals
|
||||
if (broadcastLivingBlockUpdate) {
|
||||
broadcastLivingBlockUpdate(sourceNoteId, blockIndex, newText);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Convert Living Block to normal local text paragraph
|
||||
const handleConvertLocalText = () => {
|
||||
const hostParagraphs = hostNote.content.split('\n');
|
||||
// Find matching shortcode index
|
||||
const codeToSearch = `[[living-block:${sourceNoteId}:${blockIndex}]]`;
|
||||
const targetIdx = hostParagraphs.findIndex(line => line.trim() === codeToSearch);
|
||||
|
||||
if (targetIdx !== -1) {
|
||||
hostParagraphs[targetIdx] = localText; // Replace code with snapped plain text
|
||||
const updatedHostNote = {
|
||||
...hostNote,
|
||||
content: hostParagraphs.join('\n')
|
||||
};
|
||||
onUpdateNote(updatedHostNote);
|
||||
}
|
||||
};
|
||||
|
||||
// Styling helpers
|
||||
const borderStyle = isDeleted
|
||||
? 'border-rose-500/60 dark:border-red-900/60 bg-rose-50/20 dark:bg-rose-950/5'
|
||||
: !wsConnected
|
||||
? 'border-amber-500 dark:border-amber-700 bg-amber-50/10 dark:bg-amber-950/5'
|
||||
: pulse
|
||||
? 'border-blue-500 shadow-md shadow-blue-500/15 bg-blue-50/20 dark:bg-blue-950/10'
|
||||
: 'border-blue-500/80 bg-blue-50/5 dark:bg-blue-950/5';
|
||||
|
||||
return (
|
||||
<div className="group/block relative my-6">
|
||||
<div
|
||||
className={`w-full rounded-xl border-l-3 border-y border-r border-[#E8E6E3] dark:border-zinc-800 transition-all duration-300 overflow-hidden ${borderStyle}`}
|
||||
>
|
||||
{/* Header (20px) */}
|
||||
<div className="px-4.5 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" />
|
||||
) : (
|
||||
<Zap size={10} className={wsConnected ? 'text-blue-500 fill-blue-500/20' : 'text-amber-500'} />
|
||||
)}
|
||||
<span className="text-[10px] font-sans font-medium text-concrete hover:text-ink transition-colors cursor-default max-w-[200px] truncate">
|
||||
{isDeleted ? "Source déconnectée" : sourceNote?.title || "Note connectée"}
|
||||
</span>
|
||||
|
||||
{/* Live syncing status badge */}
|
||||
{isDeleted ? (
|
||||
<span className="bg-rose-500/10 text-rose-600 dark:text-rose-400 font-bold px-1.5 py-0.2 rounded text-[8px] uppercase tracking-wider font-sans">
|
||||
DÉCONNECTÉ
|
||||
</span>
|
||||
) : wsConnected ? (
|
||||
<span className="bg-blue-500/10 text-blue-600 dark:text-blue-400 font-bold px-1.5 py-0.2 rounded text-[8px] uppercase tracking-wider font-sans animate-pulse">
|
||||
LIVE
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
title="Synchronisation suspendue"
|
||||
className="bg-amber-500/10 text-amber-600 dark:text-amber-400 font-bold px-1.5 py-0.2 rounded text-[8px] uppercase tracking-wider font-sans cursor-help"
|
||||
>
|
||||
HORS-LIGNE
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{isDeleted ? (
|
||||
<button
|
||||
onClick={handleConvertLocalText}
|
||||
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"
|
||||
title="Détacher le bloc et le transformer en texte normal dans cette note"
|
||||
>
|
||||
<Unlink size={10} />
|
||||
Décharger le lien
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
{!wsConnected && (
|
||||
<span className="text-[9px] text-amber-600 dark:text-amber-400 font-medium italic cursor-default">
|
||||
Synchro suspendue
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onOpenNote(sourceNoteId)}
|
||||
className="opacity-0 group-hover/block:opacity-100 flex items-center gap-1 text-[9.5px] font-extrabold text-blue-600 dark:text-blue-400 hover:underline transition-all"
|
||||
>
|
||||
Ouvrir <ArrowRight size={10} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body content editable block */}
|
||||
<div className="p-4 bg-blue-500/[0.015] dark:bg-blue-500/[0.005]">
|
||||
<textarea
|
||||
value={localText}
|
||||
onChange={handleBodyTextChange}
|
||||
disabled={isDeleted}
|
||||
rows={Math.max(2, Math.ceil(localText.length / 75))}
|
||||
className={`w-full bg-transparent border-none outline-none focus:ring-0 resize-none p-0 text-sm sm:text-base leading-relaxed text-ink/80 dark:text-dark-ink font-sans placeholder:text-concrete/20 ${isDeleted ? 'cursor-not-allowed opacity-80 select-all' : ''}`}
|
||||
placeholder="Écrivez le contenu du bloc dynamique ici..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user