feat(notes): liens internes, onglet Réseau, living blocks et consentement IA
Some checks failed
CI / Lint, Test & Build (push) Failing after 1m19s
CI / Deploy production (on server) (push) Has been skipped

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:
Antigravity
2026-05-24 14:27:29 +00:00
parent 077e665dfc
commit e2672cd2c2
323 changed files with 20670 additions and 42431 deletions

View 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>
);
};