authentification nocodebackend ok

This commit is contained in:
2026-02-08 16:12:25 +01:00
commit be5bd2b2bf
37 changed files with 9585 additions and 0 deletions

View File

@@ -0,0 +1,680 @@
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { WorkflowData, PlotNode, PlotConnection, PlotNodeType, Entity, EntityType } from '../types';
import { Plus, Trash2, ArrowRight, BookOpen, MessageCircle, Zap, Palette, Save, Link2 } from 'lucide-react';
interface StoryWorkflowProps {
data: WorkflowData;
onUpdate: (data: WorkflowData) => void;
entities: Entity[];
onNavigateToEntity: (entityId: string) => void;
}
const CARD_WIDTH = 260;
const CARD_HEIGHT = 220;
const INITIAL_COLORS = [
'#ffffff', // White
'#dbeafe', // Blue
'#dcfce7', // Green
'#fef9c3', // Yellow
'#fee2e2', // Red
'#f3e8ff', // Purple
];
const renderTextWithLinks = (text: string, entities: Entity[], onNavigate: (id: string) => void) => {
if (!text) return <span className="text-slate-400 italic">Description...</span>;
const parts: (string | React.ReactNode)[] = [text];
entities.forEach(entity => {
if (!entity.name) return;
const regex = new RegExp(`(${entity.name})`, 'gi');
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (typeof part === 'string') {
const split = part.split(regex);
if (split.length > 1) {
const newParts = split.map((s, idx) => {
if (s.toLowerCase() === entity.name.toLowerCase()) {
return (
<span
key={`${entity.id}-${idx}`}
onClick={(e) => { e.stopPropagation(); onNavigate(entity.id); }}
className="text-indigo-600 hover:text-indigo-800 underline decoration-indigo-300 hover:decoration-indigo-600 cursor-pointer font-medium bg-indigo-50 px-0.5 rounded transition-all"
title={`Voir la fiche de ${entity.name}`}
>
{s}
</span>
);
}
return s;
});
parts.splice(i, 1, ...newParts);
i += newParts.length - 1;
}
}
}
});
return <>{parts}</>;
};
interface StoryNodeProps {
node: PlotNode;
isSelected: boolean;
isEditing: boolean;
activeColorPickerId: string | null;
entities: Entity[];
savedColors: string[];
onMouseDown: (e: React.MouseEvent, id: string) => void;
onMouseUp: (e: React.MouseEvent, id: string) => void;
onStartConnection: (e: React.MouseEvent, id: string) => void;
onUpdate: (id: string, updates: Partial<PlotNode>) => void;
onSetEditing: (id: string | null) => void;
onToggleColorPicker: (id: string) => void;
onSaveColor: (color: string) => void;
onNavigateToEntity: (id: string) => void;
onInputFocus: (e: React.FocusEvent) => void;
onInputCheckAutocomplete: (e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>, id: string, field: 'title'|'description') => void;
onKeyDownInInput: (e: React.KeyboardEvent, id: string) => void;
}
const StoryNode = React.memo(({
node, isSelected, isEditing, activeColorPickerId, entities, savedColors,
onMouseDown, onMouseUp, onStartConnection, onUpdate, onSetEditing,
onToggleColorPicker, onSaveColor, onNavigateToEntity,
onInputFocus, onInputCheckAutocomplete, onKeyDownInInput
}: StoryNodeProps) => {
const [showTypePicker, setShowTypePicker] = useState(false);
const richDescription = useMemo(() => {
return renderTextWithLinks(node.description, entities, onNavigateToEntity);
}, [node.description, entities, onNavigateToEntity]);
return (
<div
className={`absolute flex flex-col rounded-xl shadow-sm border transition-all z-10 group
${isSelected ? 'ring-2 ring-indigo-500 shadow-lg scale-[1.01]' : 'border-slate-200 hover:shadow-md'}
`}
style={{
transform: `translate3d(${node.x}px, ${node.y}px, 0)`,
width: CARD_WIDTH,
height: CARD_HEIGHT,
backgroundColor: node.color || '#ffffff',
willChange: 'transform'
}}
onMouseDown={(e) => onMouseDown(e, node.id)}
onMouseUp={(e) => onMouseUp(e, node.id)}
onMouseLeave={() => setShowTypePicker(false)}
>
<div className="h-1.5 rounded-t-xl bg-black/5 w-full cursor-grab active:cursor-grabbing" />
<div className="flex-1 px-4 pb-4 pt-2 flex flex-col overflow-hidden relative">
<div className="flex justify-between items-start mb-2 relative">
{isEditing ? (
<input
className="font-bold text-slate-800 bg-white/50 border-b border-indigo-400 outline-none w-full mr-6 text-sm p-1 rounded"
value={node.title}
onChange={(e) => onUpdate(node.id, { title: e.target.value })}
onFocus={onInputFocus}
autoFocus
/>
) : (
<div
className="font-bold text-slate-800 cursor-text truncate mr-6 text-sm"
onDoubleClick={() => onSetEditing(node.id)}
>
{node.title}
</div>
)}
<button
onClick={(e) => { e.stopPropagation(); onToggleColorPicker(node.id); }}
className="p-1 rounded-full hover:bg-black/10 text-slate-400 hover:text-indigo-600 transition-colors absolute right-0 top-0"
>
<Palette size={14} />
</button>
{activeColorPickerId === node.id && (
<div className="absolute right-[-10px] top-8 bg-white rounded-lg shadow-xl border border-slate-200 p-3 z-50 w-48 animate-in fade-in zoom-in-95 duration-100 cursor-default" onMouseDown={(e) => e.stopPropagation()}>
<div className="grid grid-cols-4 gap-2 mb-3">
{savedColors.map(color => (
<button
key={color}
onClick={() => onUpdate(node.id, { color })}
className={`w-8 h-8 rounded-full border border-slate-200 shadow-sm transition-transform hover:scale-110 ${node.color === color ? 'ring-2 ring-offset-1 ring-indigo-400' : ''}`}
style={{ backgroundColor: color }}
/>
))}
</div>
<div className="flex items-center gap-2 pt-2 border-t border-slate-100">
<div className="relative w-8 h-8 rounded-full overflow-hidden border border-slate-300 shadow-inner">
<input
type="color"
className="absolute -top-2 -left-2 w-16 h-16 cursor-pointer"
value={node.color || '#ffffff'}
onChange={(e) => onUpdate(node.id, { color: e.target.value })}
/>
</div>
<button
onClick={() => onSaveColor(node.color || '#ffffff')}
className="text-[10px] font-bold text-indigo-600 hover:text-indigo-800 hover:underline flex-1 text-right"
>
+ SAUVER
</button>
</div>
</div>
)}
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar relative mb-4">
{isEditing ? (
<textarea
className={`w-full h-full bg-white/70 resize-none outline-none text-xs leading-relaxed p-2 rounded border border-indigo-100 shadow-inner ${node.type === 'dialogue' ? 'font-mono text-slate-700' : 'text-slate-600'}`}
placeholder={node.type === 'dialogue' ? "Héros: Salut !\nGuide: ..." : "Résumé de l'intrigue..."}
value={node.description}
onChange={(e) => onInputCheckAutocomplete(e, node.id, 'description')}
onKeyDown={(e) => onKeyDownInInput(e, node.id)}
onFocus={onInputFocus}
onBlur={() => onSetEditing(null)}
/>
) : (
<div
className={`w-full h-full text-xs text-slate-600 leading-relaxed p-1 cursor-text whitespace-pre-wrap ${node.type === 'dialogue' ? 'font-mono bg-indigo-50/30 rounded pl-2 border-l-2 border-indigo-200' : ''}`}
onClick={() => onSetEditing(node.id)}
>
{richDescription}
</div>
)}
</div>
<div className="absolute bottom-2 right-2 z-20">
{showTypePicker && (
<div className="absolute bottom-full mb-2 right-0 bg-white shadow-xl border border-slate-200 rounded-lg p-1 flex gap-1 animate-in zoom-in-95 duration-100 w-max" onMouseDown={(e) => e.stopPropagation()}>
<button
onClick={(e) => { e.stopPropagation(); onUpdate(node.id, { type: 'story' }); setShowTypePicker(false); }}
className={`p-1.5 rounded hover:bg-slate-100 ${node.type === 'story' ? 'bg-indigo-50 ring-1 ring-indigo-200' : ''}`}
title="Narration"
>
<BookOpen size={14} className="text-slate-500" />
</button>
<button
onClick={(e) => { e.stopPropagation(); onUpdate(node.id, { type: 'action' }); setShowTypePicker(false); }}
className={`p-1.5 rounded hover:bg-amber-50 ${node.type === 'action' ? 'bg-amber-50 ring-1 ring-amber-200' : ''}`}
title="Action"
>
<Zap size={14} className="text-amber-500" />
</button>
<button
onClick={(e) => { e.stopPropagation(); onUpdate(node.id, { type: 'dialogue' }); setShowTypePicker(false); }}
className={`p-1.5 rounded hover:bg-blue-50 ${node.type === 'dialogue' ? 'bg-blue-50 ring-1 ring-blue-200' : ''}`}
title="Dialogue"
>
<MessageCircle size={14} className="text-blue-500" />
</button>
</div>
)}
<button
className="p-1.5 rounded-full bg-white/70 hover:bg-white shadow-sm border border-slate-100 hover:border-indigo-200 transition-all opacity-80 group-hover:opacity-100"
onClick={(e) => { e.stopPropagation(); setShowTypePicker(!showTypePicker); }}
>
{node.type === 'story' && <BookOpen size={14} className="text-slate-500" />}
{node.type === 'action' && <Zap size={14} className="text-amber-500" />}
{node.type === 'dialogue' && <MessageCircle size={14} className="text-blue-500" />}
</button>
</div>
</div>
<button
className="absolute -right-3 top-1/2 -translate-y-1/2 w-6 h-6 bg-white border border-slate-300 rounded-full flex items-center justify-center text-slate-400 hover:text-indigo-600 hover:border-indigo-500 shadow-sm opacity-0 group-hover:opacity-100 transition-all z-20"
onMouseDown={(e) => onStartConnection(e, node.id)}
>
<ArrowRight size={12} />
</button>
</div>
);
}, (prev, next) => {
return (
prev.node === next.node &&
prev.isSelected === next.isSelected &&
prev.isEditing === next.isEditing &&
prev.activeColorPickerId === next.activeColorPickerId &&
prev.entities === next.entities
);
});
interface SuggestionState {
active: boolean;
trigger: string;
query: string;
nodeId: string;
field: 'title' | 'description';
cursorIndex: number;
selectedIndex: number;
filteredEntities: Entity[];
}
const StoryWorkflow: React.FC<StoryWorkflowProps> = ({ data, onUpdate, entities, onNavigateToEntity }) => {
const containerRef = useRef<HTMLDivElement>(null);
const rafRef = useRef<number | null>(null);
const [internalNodes, setInternalNodes] = useState<PlotNode[]>(data.nodes);
const internalNodesRef = useRef(internalNodes);
useEffect(() => { internalNodesRef.current = internalNodes; }, [internalNodes]);
useEffect(() => {
setInternalNodes(data.nodes);
}, [data.nodes]);
useEffect(() => {
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, []);
const [activeSuggestion, setActiveSuggestion] = useState<SuggestionState | null>(null);
const [selectedNodeIds, setSelectedNodeIds] = useState<Set<string>>(new Set());
const [savedColors, setSavedColors] = useState<string[]>(INITIAL_COLORS);
const [activeColorPickerId, setActiveColorPickerId] = useState<string | null>(null);
const [editingNodeId, setEditingNodeId] = useState<string | null>(null);
const [history, setHistory] = useState<WorkflowData[]>([]);
const [isDragging, setIsDragging] = useState(false);
const [dragStartPositions, setDragStartPositions] = useState<Map<string, {x: number, y: number}>>(new Map());
const [dragStartMouse, setDragStartMouse] = useState({ x: 0, y: 0 });
const [connectingNodeId, setConnectingNodeId] = useState<string | null>(null);
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
const [isPanning, setIsPanning] = useState(false);
const [panStart, setPanStart] = useState({ x: 0, y: 0 });
const [scrollStart, setScrollStart] = useState({ x: 0, y: 0 });
const pushHistory = useCallback(() => {
setHistory(prev => {
const newHistory = [...prev, data];
if (newHistory.length > 20) return newHistory.slice(newHistory.length - 20);
return newHistory;
});
}, [data]);
const updateNode = useCallback((id: string, updates: Partial<PlotNode>) => {
const currentNodes = internalNodesRef.current;
onUpdate({
...data,
nodes: currentNodes.map(n => n.id === id ? { ...n, ...updates } : n)
});
}, [data, onUpdate]);
const handleInputFocus = useCallback((e: React.FocusEvent) => {
e.stopPropagation();
}, []);
const handleInputWithAutocomplete = useCallback((
e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>,
nodeId: string,
field: 'title' | 'description'
) => {
const val = e.target.value;
updateNode(nodeId, { [field]: val });
const cursor = e.target.selectionStart || 0;
const textBeforeCursor = val.slice(0, cursor);
const match = textBeforeCursor.match(/([@#^])([^@#^\s]*)$/);
if (match) {
const trigger = match[1];
const query = match[2].toLowerCase();
const targetType = trigger === '@' ? EntityType.CHARACTER : trigger === '#' ? EntityType.LOCATION : EntityType.OBJECT;
const filtered = entities.filter(ent =>
ent.type === targetType &&
ent.name.toLowerCase().includes(query)
);
setActiveSuggestion({
active: true,
trigger,
query,
nodeId,
field,
cursorIndex: cursor,
selectedIndex: 0,
filteredEntities: filtered
});
} else {
setActiveSuggestion(null);
}
}, [updateNode, entities]);
const insertEntity = (entity: Entity) => {
if (!activeSuggestion) return;
const { nodeId, field, trigger, query } = activeSuggestion;
const node = internalNodesRef.current.find(n => n.id === nodeId);
if (!node) return;
const currentText = node[field] as string;
const cursor = activeSuggestion.cursorIndex;
const insertionLength = trigger.length + query.length;
const startIdx = cursor - insertionLength;
if (startIdx < 0) return;
const before = currentText.slice(0, startIdx);
const after = currentText.slice(cursor);
const isDialogue = node.type === 'dialogue' && activeSuggestion.trigger === '@';
const suffix = isDialogue ? ": " : " ";
updateNode(nodeId, { [field]: before + entity.name + suffix + after });
setActiveSuggestion(null);
};
const handleKeyDownInInput = useCallback((e: React.KeyboardEvent, nodeId: string) => {
if (activeSuggestion && activeSuggestion.nodeId === nodeId) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveSuggestion(prev => prev ? { ...prev, selectedIndex: (prev.selectedIndex + 1) % prev.filteredEntities.length } : null);
return;
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveSuggestion(prev => prev ? { ...prev, selectedIndex: (prev.selectedIndex - 1 + prev.filteredEntities.length) % prev.filteredEntities.length } : null);
return;
} else if (e.key === 'Tab' || e.key === 'Enter') {
e.preventDefault();
if (activeSuggestion.filteredEntities.length > 0) {
insertEntity(activeSuggestion.filteredEntities[activeSuggestion.selectedIndex]);
} else {
setActiveSuggestion(null);
}
return;
} else if (e.key === 'Escape') {
setActiveSuggestion(null);
return;
}
}
}, [activeSuggestion, entities, updateNode]);
const handleNodeMouseDown = useCallback((e: React.MouseEvent, nodeId: string) => {
e.stopPropagation();
setActiveColorPickerId(null);
setSelectedNodeIds(prevSelected => {
const newSelection = new Set(prevSelected);
if (e.ctrlKey) {
if (newSelection.has(nodeId)) newSelection.delete(nodeId);
else newSelection.add(nodeId);
} else {
if (!newSelection.has(nodeId)) {
newSelection.clear();
newSelection.add(nodeId);
}
}
const finalDragIds = e.ctrlKey ? newSelection : (newSelection.has(nodeId) ? newSelection : new Set([nodeId]));
const startPositions = new Map<string, {x: number, y: number}>();
internalNodesRef.current.forEach(n => {
if (finalDragIds.has(n.id)) {
startPositions.set(n.id, { x: n.x, y: n.y });
}
});
setDragStartPositions(startPositions);
return newSelection;
});
setIsDragging(true);
setDragStartMouse({ x: e.clientX, y: e.clientY });
pushHistory();
}, [pushHistory]);
const startConnection = useCallback((e: React.MouseEvent, nodeId: string) => {
e.stopPropagation();
pushHistory();
setConnectingNodeId(nodeId);
}, [pushHistory]);
const finishConnection = useCallback((e: React.MouseEvent, targetId: string) => {
if (connectingNodeId && connectingNodeId !== targetId) {
const exists = data.connections.some(c => c.source === connectingNodeId && c.target === targetId);
if (!exists) {
const newConn: PlotConnection = {
id: `conn-${Date.now()}`,
source: connectingNodeId,
target: targetId
};
onUpdate({
...data,
nodes: internalNodesRef.current,
connections: [...data.connections, newConn]
});
}
}
setConnectingNodeId(null);
}, [data, onUpdate, connectingNodeId]);
const handleToggleColorPicker = useCallback((id: string) => {
setActiveColorPickerId(prev => prev === id ? null : id);
}, []);
const handleSaveColor = useCallback((color: string) => {
setSavedColors(prev => !prev.includes(color) ? [...prev, color] : prev);
}, []);
const handleMouseMove = (e: React.MouseEvent) => {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
const clientX = e.clientX;
const clientY = e.clientY;
if (isPanning && containerRef.current) {
const dx = clientX - panStart.x;
const dy = clientY - panStart.y;
containerRef.current.scrollLeft = scrollStart.x - dx;
containerRef.current.scrollTop = scrollStart.y - dy;
return;
}
const scrollLeft = containerRef.current?.scrollLeft || 0;
const scrollTop = containerRef.current?.scrollTop || 0;
setMousePos({ x: clientX - rect.left + scrollLeft, y: clientY - rect.top + scrollTop });
if (isDragging) {
if (rafRef.current) return;
rafRef.current = requestAnimationFrame(() => {
const dx = clientX - dragStartMouse.x;
const dy = clientY - dragStartMouse.y;
setInternalNodes(prevNodes => prevNodes.map(node => {
const startPos = dragStartPositions.get(node.id);
if (startPos) return { ...node, x: startPos.x + dx, y: startPos.y + dy };
return node;
}));
rafRef.current = null;
});
}
};
const handleMouseUp = () => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
if (isDragging) onUpdate({ ...data, nodes: internalNodesRef.current });
setIsDragging(false);
setIsPanning(false);
setConnectingNodeId(null);
};
const handleCanvasMouseDown = (e: React.MouseEvent) => {
if (!e.ctrlKey) setSelectedNodeIds(new Set());
setActiveSuggestion(null);
setActiveColorPickerId(null);
setEditingNodeId(null);
setIsPanning(true);
setPanStart({ x: e.clientX, y: e.clientY });
if (containerRef.current) {
setScrollStart({ x: containerRef.current.scrollLeft, y: containerRef.current.scrollTop });
}
};
const handleCanvasDoubleClick = (e: React.MouseEvent) => {
e.preventDefault();
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
const x = e.clientX - rect.left + (containerRef.current?.scrollLeft || 0) - CARD_WIDTH / 2;
const y = e.clientY - rect.top + (containerRef.current?.scrollTop || 0) - CARD_HEIGHT / 2;
pushHistory();
const newNode: PlotNode = {
id: `node-${Date.now()}`,
x,
y,
title: 'Nouvel événement',
description: '',
color: INITIAL_COLORS[0],
type: 'story'
};
onUpdate({ ...data, nodes: [...internalNodesRef.current, newNode] });
setSelectedNodeIds(new Set([newNode.id]));
setEditingNodeId(newNode.id);
};
const handleDeleteSelected = () => {
if (selectedNodeIds.size === 0) return;
pushHistory();
const newNodes = internalNodes.filter(n => !selectedNodeIds.has(n.id));
const newConnections = data.connections.filter(c => !selectedNodeIds.has(c.source) && !selectedNodeIds.has(c.target));
onUpdate({ nodes: newNodes, connections: newConnections });
setSelectedNodeIds(new Set());
};
const handleAddNodeCenter = () => {
pushHistory();
const scrollLeft = containerRef.current?.scrollLeft || 0;
const scrollTop = containerRef.current?.scrollTop || 0;
const clientWidth = containerRef.current?.clientWidth || 800;
const clientHeight = containerRef.current?.clientHeight || 600;
const newNode: PlotNode = {
id: `node-${Date.now()}`,
x: scrollLeft + clientWidth / 2 - CARD_WIDTH / 2,
y: scrollTop + clientHeight / 2 - CARD_HEIGHT / 2,
title: 'Nouveau point d\'intrigue',
description: '',
color: INITIAL_COLORS[0],
type: 'story'
};
onUpdate({ ...data, nodes: [...internalNodesRef.current, newNode] });
setSelectedNodeIds(new Set([newNode.id]));
setEditingNodeId(newNode.id);
};
return (
<div className="h-full flex flex-col overflow-hidden bg-[#eef2ff] relative">
<div className="h-12 bg-white border-b border-indigo-100 flex items-center justify-between px-4 z-10 shadow-sm shrink-0">
<div className="flex items-center gap-2">
<button onClick={handleAddNodeCenter} className="flex items-center gap-1.5 px-3 py-1.5 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 text-xs font-bold transition-all shadow-md shadow-indigo-100">
<Plus size={14} /> AJOUTER NŒUD
</button>
<div className="w-px h-6 bg-slate-100 mx-2" />
<div className="text-[10px] uppercase font-bold text-slate-400 tracking-wider">
{selectedNodeIds.size > 0 ? `${selectedNodeIds.size} SÉLECTIONNÉ(S)` : 'Double-cliquez sur le canvas pour créer'}
</div>
</div>
<div className="flex items-center gap-2">
<button onClick={handleDeleteSelected} disabled={selectedNodeIds.size === 0} className="p-2 text-red-500 hover:bg-red-50 rounded-lg disabled:opacity-30 transition-colors" title="Supprimer">
<Trash2 size={16} />
</button>
</div>
</div>
<div
ref={containerRef}
className="flex-1 overflow-auto relative cursor-grab active:cursor-grabbing bg-[#eef2ff]"
onMouseDown={handleCanvasMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onDoubleClick={handleCanvasDoubleClick}
style={{
backgroundImage: 'radial-gradient(#d1d5db 1px, transparent 1px)',
backgroundSize: '24px 24px'
}}
>
<svg className="absolute top-0 left-0 w-[4000px] h-[4000px] pointer-events-none z-0">
{data.connections.map(conn => {
const source = internalNodes.find(n => n.id === conn.source);
const target = internalNodes.find(n => n.id === conn.target);
if (!source || !target) return null;
const startX = source.x + CARD_WIDTH / 2;
const startY = source.y + CARD_HEIGHT / 2;
const endX = target.x + CARD_WIDTH / 2;
const endY = target.y + CARD_HEIGHT / 2;
return (
<line key={conn.id} x1={startX} y1={startY} x2={endX} y2={endY} stroke="#cbd5e1" strokeWidth="2" markerEnd="url(#arrowhead)" />
);
})}
{connectingNodeId && (
<line
x1={(internalNodes.find(n => n.id === connectingNodeId)?.x || 0) + CARD_WIDTH/2}
y1={(internalNodes.find(n => n.id === connectingNodeId)?.y || 0) + CARD_HEIGHT/2}
x2={mousePos.x} y2={mousePos.y}
stroke="#6366f1" strokeWidth="2" strokeDasharray="5,5" markerEnd="url(#arrowhead-blue)"
/>
)}
<defs>
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="28" refY="3.5" orient="auto">
<path d="M0,0 L0,7 L10,3.5 Z" fill="#cbd5e1" />
</marker>
<marker id="arrowhead-blue" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto">
<path d="M0,0 L0,7 L10,3.5 Z" fill="#6366f1" />
</marker>
</defs>
</svg>
{internalNodes.map(node => (
<StoryNode
key={node.id}
node={node}
isSelected={selectedNodeIds.has(node.id)}
isEditing={editingNodeId === node.id}
activeColorPickerId={activeColorPickerId}
entities={entities}
savedColors={savedColors}
onMouseDown={handleNodeMouseDown}
onMouseUp={finishConnection}
onStartConnection={startConnection}
onUpdate={updateNode}
onSetEditing={setEditingNodeId}
onToggleColorPicker={handleToggleColorPicker}
onSaveColor={handleSaveColor}
onNavigateToEntity={onNavigateToEntity}
onInputFocus={handleInputFocus}
onInputCheckAutocomplete={handleInputWithAutocomplete}
onKeyDownInInput={handleKeyDownInInput}
/>
))}
</div>
{activeSuggestion && (
<div className="fixed z-50 bg-white rounded-xl shadow-2xl border border-indigo-100 w-64 max-h-48 overflow-y-auto" style={{ left: '50%', top: '50%', transform: 'translate(-50%, -50%)' }}>
<div className="px-3 py-2 bg-indigo-600 text-white text-[10px] font-black uppercase tracking-widest">
Insérer {activeSuggestion.trigger === '@' ? 'Personnage' : activeSuggestion.trigger === '#' ? 'Lieu' : 'Objet'}
</div>
<div className="divide-y divide-slate-50">
{activeSuggestion.filteredEntities.length > 0 ? (
activeSuggestion.filteredEntities.map((ent, idx) => (
<button
key={ent.id}
className={`w-full text-left px-4 py-3 text-xs flex items-center gap-3 hover:bg-indigo-50 transition-colors ${idx === activeSuggestion.selectedIndex ? 'bg-indigo-50 text-indigo-700 font-bold' : 'text-slate-700'}`}
onClick={() => insertEntity(ent)}
>
{ent.name}
</button>
))
) : (
<div className="p-4 text-xs text-slate-400 italic text-center">Aucun résultat</div>
)}
</div>
</div>
)}
</div>
);
};
export default StoryWorkflow;