mirror of
https://github.com/reconurge/flowsint.git
synced 2026-03-12 01:44:42 -05:00
feat: node performance enhancement
This commit is contained in:
@@ -16,6 +16,8 @@ import { useConfirm } from "@/components/use-confirm-dialog";
|
||||
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { NodeNotesEditor } from "../node-notes-editor";
|
||||
import { useFlowStore } from "@/store/flow-store";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface NodeContextType {
|
||||
setOpenAddNodeModal: any,
|
||||
@@ -55,6 +57,7 @@ export const NodeProvider: React.FC<NodeProviderProps> = (props: any) => {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [nodeType, setnodeType] = useState<any | null>(null)
|
||||
const nodeId = useNodeId();
|
||||
const { setCurrentNode } = useFlowStore()
|
||||
const { confirm } = useConfirm();
|
||||
|
||||
const returnError = (message: string) => {
|
||||
@@ -79,53 +82,113 @@ export const NodeProvider: React.FC<NodeProviderProps> = (props: any) => {
|
||||
await handleAddNode(data);
|
||||
};
|
||||
|
||||
const handleAddNode = async (data: any) => {
|
||||
setLoading(true)
|
||||
if (!nodeId) return returnError("No node detected.")
|
||||
const dataToInsert = { ...data, investigation_id }
|
||||
if (nodeType.table !== "individuals")
|
||||
dataToInsert["individual_id"] = nodeId
|
||||
const node = await supabase.from(nodeType.table).insert(dataToInsert).select("*")
|
||||
.single()
|
||||
.then(({ data, error }) => {
|
||||
if (error)
|
||||
returnError(error.details)
|
||||
return data
|
||||
})
|
||||
if (!node) return
|
||||
if (nodeType.table === "individuals") {
|
||||
// create relation to investigation
|
||||
await supabase.from("investigation_individuals").insert({
|
||||
individual_id: node.id,
|
||||
investigation_id: investigation_id
|
||||
}).then(({ error }) => console.log(error))
|
||||
const handleAddNode = async (
|
||||
data: any,
|
||||
) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
|
||||
await supabase.from("relationships").upsert({
|
||||
individual_a: nodeId,
|
||||
individual_b: node.id,
|
||||
relation_type: "relation"
|
||||
}).then(({ error }) => { if (error) returnError(error.details) }
|
||||
)
|
||||
// Validate required data
|
||||
if (!nodeId) {
|
||||
toast.error("No node detected.")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Prepare data for insertion
|
||||
const dataToInsert = { ...data, investigation_id }
|
||||
if (nodeType.table !== "individuals") {
|
||||
dataToInsert["individual_id"] = nodeId
|
||||
}
|
||||
|
||||
// Insert the node
|
||||
const { data: nodeData, error: insertError } = await supabase
|
||||
.from(nodeType.table)
|
||||
.insert(dataToInsert)
|
||||
.select("*")
|
||||
.single()
|
||||
|
||||
if (insertError) {
|
||||
toast.error(insertError.details)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (!nodeData) {
|
||||
toast.error("Failed to create node.")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle individuals table specific logic
|
||||
if (nodeType.table === "individuals") {
|
||||
// Create relation to investigation
|
||||
const { error: relationError } = await supabase.from("investigation_individuals").insert({
|
||||
individual_id: nodeData.id,
|
||||
investigation_id: investigation_id,
|
||||
})
|
||||
|
||||
if (relationError) {
|
||||
toast.error("Error creating investigation relation:" + JSON.stringify(relationError))
|
||||
}
|
||||
|
||||
// Create relationship between individuals
|
||||
const { error: relationshipError } = await supabase.from("relationships").upsert({
|
||||
individual_a: nodeId,
|
||||
individual_b: nodeData.id,
|
||||
relation_type: "relation",
|
||||
})
|
||||
|
||||
if (relationshipError) {
|
||||
toast.error(relationshipError.details)
|
||||
}
|
||||
}
|
||||
|
||||
// Add node to graph
|
||||
const newNode = {
|
||||
id: nodeData.id,
|
||||
type: nodeType.type,
|
||||
data: { ...nodeData, label: data[nodeType.fields[0]] },
|
||||
position: { x: 0, y: 0 },
|
||||
}
|
||||
|
||||
addNodes(newNode)
|
||||
|
||||
// Add edge if needed
|
||||
if (nodeId) {
|
||||
const newEdge = {
|
||||
source: nodeId,
|
||||
target: nodeData.id,
|
||||
type: "custom",
|
||||
id: `${nodeId}-${nodeData.id}`.toString(),
|
||||
label: nodeType.type === "individual" ? "relation" : nodeType.type,
|
||||
}
|
||||
|
||||
addEdges(newEdge)
|
||||
}
|
||||
|
||||
// Use a callback function with setState to ensure we're working with the latest state
|
||||
// This is the fix for the setCurrentNode issue
|
||||
const newNodeId = nodeData.id
|
||||
|
||||
// Close modal and update state in a specific order
|
||||
setOpenNodeModal(false)
|
||||
setError(null)
|
||||
|
||||
// Use setTimeout to ensure this happens after the current execution context
|
||||
// This helps with React's batching of state updates
|
||||
setTimeout(() => {
|
||||
setCurrentNode(newNodeId)
|
||||
setLoading(false)
|
||||
}, 0)
|
||||
} catch (error) {
|
||||
toast.error("An unexpected error occurred")
|
||||
setLoading(false)
|
||||
}
|
||||
addNodes({
|
||||
id: node.id,
|
||||
type: nodeType.type,
|
||||
data: { ...node, label: data[nodeType.fields[0]] },
|
||||
position: { x: 0, y: 0 }
|
||||
});
|
||||
if (nodeId)
|
||||
addEdges({
|
||||
source: nodeId,
|
||||
target: node.id,
|
||||
type: 'custom',
|
||||
id: `${nodeId}-${node.id}`.toString(),
|
||||
label: nodeType.type === "individual" ? "relation" : nodeType.type,
|
||||
});
|
||||
setLoading(false)
|
||||
setError(null)
|
||||
setOpenNodeModal(false)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const handleDuplicateNode = async () => {
|
||||
await supabase.from("individuals")
|
||||
.select("*")
|
||||
@@ -137,7 +200,7 @@ export const NodeProvider: React.FC<NodeProviderProps> = (props: any) => {
|
||||
.insert({ full_name: data.full_name })
|
||||
.select("*")
|
||||
.single()
|
||||
if (insertError) returnError(insertError.details)
|
||||
if (insertError) toast.error(insertError.details)
|
||||
addNodes({
|
||||
id: node.id,
|
||||
type: "individual",
|
||||
@@ -154,7 +217,7 @@ export const NodeProvider: React.FC<NodeProviderProps> = (props: any) => {
|
||||
.delete()
|
||||
.eq("id", nodeId)
|
||||
.then(({ error }) => {
|
||||
if (error) returnError(error.details)
|
||||
if (error) toast.error(error.details)
|
||||
})
|
||||
setNodes((nodes: any[]) => nodes.filter((node: { id: any; }) => node.id !== nodeId.toString()));
|
||||
setEdges((edges: any[]) => edges.filter((edge: { source: any; }) => edge.source !== nodeId.toString()));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import type React from "react"
|
||||
import {
|
||||
ReactFlow,
|
||||
ReactFlowProvider,
|
||||
@@ -10,9 +11,10 @@ import {
|
||||
type ColorMode,
|
||||
// @ts-ignore
|
||||
MiniMap,
|
||||
type NodeMouseHandler,
|
||||
} from "@xyflow/react"
|
||||
import "@xyflow/react/dist/style.css"
|
||||
import IndividualNode from "./nodes/individual"
|
||||
import IndividualNode from "./nodes/person"
|
||||
import PhoneNode from "./nodes/phone"
|
||||
import IpNode from "./nodes/ip_address"
|
||||
import EmailNode from "./nodes/email"
|
||||
@@ -27,10 +29,13 @@ import {
|
||||
PlusIcon,
|
||||
GroupIcon,
|
||||
WorkflowIcon,
|
||||
Trash2Icon,
|
||||
CopyIcon,
|
||||
EditIcon,
|
||||
LinkIcon,
|
||||
} from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
import NewActions from "./new-actions"
|
||||
import FloatingEdge from "./floating-edge"
|
||||
import FloatingConnectionLine from "./floating-connection"
|
||||
import { useParams } from "next/navigation"
|
||||
import { Tooltip, TooltipContent } from "@/components/ui/tooltip"
|
||||
@@ -46,12 +51,12 @@ import AddNodeModal from "./add-node-modal"
|
||||
import { Dialog, DialogTrigger } from "../ui/dialog"
|
||||
import { memo } from "react"
|
||||
import { shallow } from "zustand/shallow"
|
||||
import CustomEdge from "./custom-edge"
|
||||
import floatingEdge from "./floating-edge"
|
||||
import FloatingEdge from "./simple-floating-edge"
|
||||
import { TooltipProvider } from "@/components/ui/tooltip"
|
||||
|
||||
// Define constants outside component to prevent recreation
|
||||
const edgeTypes = {
|
||||
custom: floatingEdge
|
||||
custom: FloatingEdge,
|
||||
}
|
||||
|
||||
const nodeTypes = {
|
||||
@@ -69,7 +74,14 @@ const nodeEdgeSelector = (store: { nodes: any; edges: any }) => ({
|
||||
edges: store.edges,
|
||||
})
|
||||
|
||||
const actionsSelector = (store: { onNodesChange: any; onEdgesChange: any; onConnect: any; onNodeClick: any; onPaneClick: any; onLayout: any }) => ({
|
||||
const actionsSelector = (store: {
|
||||
onNodesChange: any
|
||||
onEdgesChange: any
|
||||
onConnect: any
|
||||
onNodeClick: any
|
||||
onPaneClick: any
|
||||
onLayout: any
|
||||
}) => ({
|
||||
onNodesChange: store.onNodesChange,
|
||||
onEdgesChange: store.onEdgesChange,
|
||||
onConnect: store.onConnect,
|
||||
@@ -84,103 +96,188 @@ const stateSelector = (store: { currentNode: any; resetNodeStyles: any; reloadin
|
||||
reloading: store.reloading,
|
||||
})
|
||||
|
||||
// FlowControls component to reduce LayoutFlow complexity
|
||||
interface FlowControlsProps {
|
||||
onLayout: (direction: string, fitView: () => void) => void;
|
||||
fitView: () => void;
|
||||
handleRefetch: () => void;
|
||||
reloading: boolean;
|
||||
setView: (view: string) => void;
|
||||
zoomIn: () => void;
|
||||
zoomOut: () => void;
|
||||
addNodes: (payload: Node | Node[]) => void;
|
||||
// Node Context Menu component
|
||||
interface NodeContextMenuProps {
|
||||
x: number
|
||||
y: number
|
||||
nodeId: string | null
|
||||
nodeType: string | null
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const FlowControls = memo(({ onLayout, fitView, handleRefetch, reloading, setView, zoomIn, zoomOut, addNodes }: FlowControlsProps) => {
|
||||
const NodeContextMenu = memo(({ x, y, nodeId, nodeType, onClose }: NodeContextMenuProps) => {
|
||||
if (!nodeId) return null
|
||||
|
||||
const handleEditNode = () => {
|
||||
console.log(`Edit node ${nodeId} of type ${nodeType}`)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleDeleteNode = () => {
|
||||
console.log(`Delete node ${nodeId}`)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleDuplicateNode = () => {
|
||||
console.log(`Duplicate node ${nodeId}`)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleCreateConnection = () => {
|
||||
console.log(`Create connection from node ${nodeId}`)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Panel position="top-left" className="flex items-center gap-1 w-53">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
onLayout("TB", fitView)
|
||||
setTimeout(() => {
|
||||
fitView()
|
||||
}, 100)
|
||||
}}
|
||||
>
|
||||
<AlignCenterVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Auto layout</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button size="icon" variant="outline" onClick={() => setView("large-graph")}>
|
||||
<WorkflowIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>View 2D Graph</TooltipContent>
|
||||
</Tooltip>
|
||||
</Panel>
|
||||
<Panel position="top-right" className="flex items-center gap-1">
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<div className="flex gap-1 items-center">
|
||||
<Button size="icon" disabled={reloading} variant="outline" onClick={handleRefetch}>
|
||||
<RotateCwIcon className={cn("h-4 w-4", reloading && "animate-spin")} />
|
||||
</Button>
|
||||
<NewActions addNodes={addNodes} />
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
<Panel position="bottom-left" className="flex flex-col items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button size="icon" variant="outline" onClick={() => fitView()}>
|
||||
<MaximizeIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Center view</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button size="icon" variant="outline" onClick={() => zoomIn()}>
|
||||
<ZoomInIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Zoom in</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button size="icon" variant="outline" onClick={() => zoomOut()}>
|
||||
<ZoomOutIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Zoom out</TooltipContent>
|
||||
</Tooltip>
|
||||
</Panel>
|
||||
</>
|
||||
<div
|
||||
className="absolute z-50 min-w-[80px] bg-popover text-popover-foreground rounded-md border shadow-md py-1 overflow-hidden"
|
||||
style={{ top: y, left: x }}
|
||||
>
|
||||
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground border-b">
|
||||
{nodeType?.toUpperCase()} NODE: {nodeId.slice(0, 8)}
|
||||
</div>
|
||||
<div className="py-1">
|
||||
<button
|
||||
className="w-full flex items-center gap-2 text-left px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||
onClick={handleEditNode}
|
||||
>
|
||||
<EditIcon className="h-4 w-4" />
|
||||
Edit Node
|
||||
</button>
|
||||
<button
|
||||
className="w-full flex items-center gap-2 text-left px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||
onClick={handleDeleteNode}
|
||||
>
|
||||
<Trash2Icon className="h-4 w-4" />
|
||||
Delete Node
|
||||
</button>
|
||||
<button
|
||||
className="w-full flex items-center gap-2 text-left px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||
onClick={handleDuplicateNode}
|
||||
>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
Duplicate Node
|
||||
</button>
|
||||
<button
|
||||
className="w-full flex items-center gap-2 text-left px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||
onClick={handleCreateConnection}
|
||||
>
|
||||
<LinkIcon className="h-4 w-4" />
|
||||
Create Connection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
});
|
||||
})
|
||||
|
||||
interface FlowControlsProps {
|
||||
onLayout: (direction: string, fitView: () => void) => void
|
||||
fitView: () => void
|
||||
handleRefetch: () => void
|
||||
reloading: boolean
|
||||
setView: (view: string) => void
|
||||
zoomIn: () => void
|
||||
zoomOut: () => void
|
||||
addNodes: (payload: Node | Node[]) => void
|
||||
}
|
||||
|
||||
const FlowControls = memo(
|
||||
({ onLayout, fitView, handleRefetch, reloading, setView, zoomIn, zoomOut, addNodes }: FlowControlsProps) => {
|
||||
return (
|
||||
<>
|
||||
<Panel position="top-left" className="flex items-center gap-1 w-53">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
onLayout("TB", fitView)
|
||||
setTimeout(() => {
|
||||
fitView()
|
||||
}, 100)
|
||||
}}
|
||||
>
|
||||
<AlignCenterVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Auto layout</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button size="icon" variant="outline" onClick={() => setView("large-graph")}>
|
||||
<WorkflowIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>View 2D Graph</TooltipContent>
|
||||
</Tooltip>
|
||||
</Panel>
|
||||
<Panel position="top-right" className="flex items-center gap-1">
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<div className="flex gap-1 items-center">
|
||||
<Button size="icon" disabled={reloading} variant="outline" onClick={handleRefetch}>
|
||||
<RotateCwIcon className={cn("h-4 w-4", reloading && "animate-spin")} />
|
||||
</Button>
|
||||
<NewActions addNodes={addNodes} />
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
<Panel position="bottom-left" className="flex flex-col items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button size="icon" variant="outline" onClick={() => fitView()}>
|
||||
<MaximizeIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Center view</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button size="icon" variant="outline" onClick={() => zoomIn()}>
|
||||
<ZoomInIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Zoom in</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button size="icon" variant="outline" onClick={() => zoomOut()}>
|
||||
<ZoomOutIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Zoom out</TooltipContent>
|
||||
</Tooltip>
|
||||
</Panel>
|
||||
</>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
interface LayoutFlowProps {
|
||||
refetch: () => void;
|
||||
theme: string;
|
||||
refetch: () => void
|
||||
theme: string
|
||||
}
|
||||
|
||||
const LayoutFlow = ({ refetch, theme }: LayoutFlowProps) => {
|
||||
const { fitView, zoomIn, zoomOut, addNodes, getNode, setCenter } = useReactFlow()
|
||||
const { fitView, zoomIn, zoomOut, addNodes, getNode, setCenter, getNodes } = useReactFlow()
|
||||
const { investigation_id } = useParams()
|
||||
const { settings } = useInvestigationStore()
|
||||
const [_, setView] = useQueryState("view", { defaultValue: "flow-graph" })
|
||||
|
||||
// Node context menu state
|
||||
const [nodeContextMenu, setNodeContextMenu] = useState<{
|
||||
x: number
|
||||
y: number
|
||||
nodeId: string | null
|
||||
nodeType: string | null
|
||||
} | null>(null)
|
||||
|
||||
// Split store access to minimize re-renders
|
||||
const { nodes, edges } = useFlowStore(nodeEdgeSelector, shallow)
|
||||
const { onNodesChange, onEdgesChange, onConnect, onNodeClick, onPaneClick, onLayout } =
|
||||
useFlowStore(actionsSelector, shallow)
|
||||
const { onNodesChange, onEdgesChange, onConnect, onNodeClick, onPaneClick, onLayout } = useFlowStore(
|
||||
actionsSelector,
|
||||
shallow,
|
||||
)
|
||||
const { currentNode, resetNodeStyles, reloading } = useFlowStore(stateSelector, shallow)
|
||||
|
||||
// Initial layout
|
||||
@@ -204,7 +301,7 @@ const LayoutFlow = ({ refetch, theme }: LayoutFlowProps) => {
|
||||
// Node highlighting effect
|
||||
useEffect(() => {
|
||||
resetNodeStyles()
|
||||
if (!currentNode) return;
|
||||
if (!currentNode) return
|
||||
|
||||
const internalNode = getNode(currentNode)
|
||||
if (!internalNode) return
|
||||
@@ -228,71 +325,108 @@ const LayoutFlow = ({ refetch, theme }: LayoutFlowProps) => {
|
||||
}, [currentNode, getNode, setCenter, resetNodeStyles])
|
||||
|
||||
// Memoize connection handler to prevent recreation
|
||||
const handleConnect = useCallback(
|
||||
(params: any) => onConnect(params, investigation_id),
|
||||
[onConnect, investigation_id]
|
||||
const handleConnect = useCallback((params: any) => onConnect(params, investigation_id), [onConnect, investigation_id])
|
||||
|
||||
// Handle node context menu
|
||||
const handleNodeContextMenu: NodeMouseHandler = useCallback((event, node) => {
|
||||
// Prevent default context menu
|
||||
event.preventDefault()
|
||||
setNodeContextMenu({
|
||||
x: event.clientX - 300,
|
||||
y: event.clientY - 40,
|
||||
nodeId: node.id,
|
||||
nodeType: node.type || "default",
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Close node context menu
|
||||
const closeNodeContextMenu = useCallback(() => {
|
||||
setNodeContextMenu(null)
|
||||
}, [])
|
||||
|
||||
// Handle pane click to close context menu
|
||||
const handlePaneClick = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
closeNodeContextMenu()
|
||||
onPaneClick(event)
|
||||
},
|
||||
[onPaneClick, closeNodeContextMenu],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh_-_48px)] relative">
|
||||
<Dialog>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger className="h-full w-full">
|
||||
<ReactFlow
|
||||
colorMode={theme as ColorMode}
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={handleConnect}
|
||||
onNodeClick={onNodeClick}
|
||||
onPaneClick={onPaneClick}
|
||||
minZoom={0.1}
|
||||
// @ts-ignore
|
||||
connectionLineComponent={FloatingConnectionLine}
|
||||
fitView
|
||||
proOptions={{ hideAttribution: true }}
|
||||
nodeTypes={nodeTypes}
|
||||
// @ts-ignore
|
||||
// edgeTypes={edgeTypes}
|
||||
className="!bg-background"
|
||||
>
|
||||
<FlowControls
|
||||
onLayout={onLayout}
|
||||
fitView={fitView}
|
||||
handleRefetch={handleRefetch}
|
||||
reloading={reloading}
|
||||
setView={setView}
|
||||
zoomIn={zoomIn}
|
||||
zoomOut={zoomOut}
|
||||
<TooltipProvider>
|
||||
<Dialog>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger className="h-full w-full">
|
||||
<ReactFlow
|
||||
colorMode={theme as ColorMode}
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={handleConnect}
|
||||
onNodeClick={onNodeClick}
|
||||
onPaneClick={handlePaneClick}
|
||||
onNodeContextMenu={handleNodeContextMenu}
|
||||
minZoom={0.1}
|
||||
// @ts-ignore
|
||||
addNodes={addNodes}
|
||||
/>
|
||||
<Background />
|
||||
{settings.showMiniMap && <MiniMap pannable />}
|
||||
</ReactFlow>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-32">
|
||||
<DialogTrigger asChild>
|
||||
<ContextMenuItem>
|
||||
<PlusIcon className="h-4 w-4 opacity-60" />
|
||||
<span>New node</span>
|
||||
connectionLineComponent={FloatingConnectionLine}
|
||||
fitView
|
||||
proOptions={{ hideAttribution: true }}
|
||||
nodeTypes={nodeTypes}
|
||||
// @ts-ignore
|
||||
edgeTypes={edgeTypes}
|
||||
className="!bg-background"
|
||||
>
|
||||
<FlowControls
|
||||
onLayout={onLayout}
|
||||
fitView={fitView}
|
||||
handleRefetch={handleRefetch}
|
||||
reloading={reloading}
|
||||
setView={setView}
|
||||
zoomIn={zoomIn}
|
||||
zoomOut={zoomOut}
|
||||
// @ts-ignore
|
||||
addNodes={addNodes}
|
||||
/>
|
||||
<Background />
|
||||
{settings.showMiniMap && <MiniMap pannable />}
|
||||
</ReactFlow>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-32">
|
||||
<DialogTrigger asChild>
|
||||
<ContextMenuItem>
|
||||
<PlusIcon className="h-4 w-4 opacity-60" />
|
||||
<span>New node</span>
|
||||
</ContextMenuItem>
|
||||
</DialogTrigger>
|
||||
<ContextMenuItem disabled>
|
||||
<GroupIcon className="h-4 w-4 opacity-60" />
|
||||
<span>New group</span>
|
||||
</ContextMenuItem>
|
||||
</DialogTrigger>
|
||||
<ContextMenuItem disabled>
|
||||
<GroupIcon className="h-4 w-4 opacity-60" />
|
||||
<span>New group</span>
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
<AddNodeModal addNodes={addNodes} />
|
||||
</Dialog>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
<AddNodeModal addNodes={addNodes} />
|
||||
</Dialog>
|
||||
|
||||
{/* Node-specific context menu */}
|
||||
{nodeContextMenu && (
|
||||
<NodeContextMenu
|
||||
x={nodeContextMenu.x}
|
||||
y={nodeContextMenu.y}
|
||||
nodeId={nodeContextMenu.nodeId}
|
||||
nodeType={nodeContextMenu.nodeType}
|
||||
onClose={closeNodeContextMenu}
|
||||
/>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
// Memoize LayoutFlow to prevent unnecessary re-renders
|
||||
const MemoizedLayoutFlow = memo(LayoutFlow);
|
||||
const MemoizedLayoutFlow = memo(LayoutFlow)
|
||||
|
||||
function Graph({ graphQuery }: { graphQuery: any }) {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
@@ -327,4 +461,5 @@ function Graph({ graphQuery }: { graphQuery: any }) {
|
||||
}
|
||||
|
||||
// Export the memoized Graph component
|
||||
export default memo(Graph)
|
||||
export default memo(Graph)
|
||||
|
||||
|
||||
@@ -1,169 +1,41 @@
|
||||
"use client"
|
||||
|
||||
import { memo, useCallback, useMemo } from "react"
|
||||
import { Handle, Position, useStore } from "@xyflow/react"
|
||||
import { NodeProvider, useNodeContext } from "@/components/contexts/node-context"
|
||||
import { AtSignIcon } from "lucide-react"
|
||||
import { cn, zoomSelector } from "@/lib/utils"
|
||||
import { useInvestigationStore } from "@/store/investigation-store"
|
||||
import { CopyButton } from "@/components/copy"
|
||||
import { useSearchContext } from "@/components/contexts/search-context"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu"
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { useFlowStore } from "@/store/flow-store"
|
||||
import { toast } from "sonner"
|
||||
import { checkEmail } from "@/lib/actions/search"
|
||||
|
||||
function EmailNode({ data }: any) {
|
||||
const { handleDeleteNode, loading } = useNodeContext()
|
||||
const { settings } = useInvestigationStore()
|
||||
const { currentNode } = useFlowStore()
|
||||
const { handleOpenSearchModal } = useSearchContext()
|
||||
// Optimisation avec fonction d'égalité pour éviter les re-rendus inutiles
|
||||
const showContent = useStore(zoomSelector, (a, b) => a === b)
|
||||
|
||||
// Callbacks mémorisés
|
||||
const handleDelete = useCallback(() => handleDeleteNode(data.type), [data.type, handleDeleteNode])
|
||||
const handleSearch = useCallback(() => handleOpenSearchModal(data.email), [data.email, handleOpenSearchModal])
|
||||
|
||||
const handleCheckEmail = useCallback(() => {
|
||||
toast.promise(checkEmail(data.email, data.investigation_id), {
|
||||
loading: "Loading...",
|
||||
success: () => {
|
||||
return `Scan on ${data.email} has been launched.`
|
||||
},
|
||||
error: (error: any) => {
|
||||
return (
|
||||
<div className="overflow-hidden">
|
||||
<p className="font-bold">An error occured.</p>
|
||||
<pre className="overflow-auto">
|
||||
<code>{JSON.stringify(error, null, 2)}</code>
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
})
|
||||
}, [data.email, data.investigation_id])
|
||||
|
||||
// Mémorisation du contenu du nœud
|
||||
const nodeContent = useMemo(() => {
|
||||
if (settings.showNodeLabel && showContent) {
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"border hover:border-primary rounded-full p-0 shadow-none backdrop-blur bg-background/40",
|
||||
currentNode === data.id && "border-primary",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 p-1">
|
||||
<Badge variant="secondary" className="h-6 w-6 p-0 rounded-full">
|
||||
<AtSignIcon className="h-4 w-4" />
|
||||
</Badge>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm">{data.label}</span>
|
||||
{settings.showCopyIcon && <CopyButton className="rounded-full h-7 w-7 text-xs" content={data.label} />}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" className="rounded-full p-0">
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarFallback>
|
||||
<AtSignIcon className="h-3 w-3" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{data.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}, [settings.showNodeLabel, showContent, currentNode, data.id, data.label, settings.showCopyIcon])
|
||||
|
||||
// Mémorisation de la poignée
|
||||
const handle = useMemo(
|
||||
() => <Handle type="target" position={Position.Top} className={cn("w-16 bg-teal-500 opacity-0")} />,
|
||||
[],
|
||||
)
|
||||
|
||||
// Mémorisation du menu contextuel
|
||||
const contextMenu = useMemo(
|
||||
() => (
|
||||
<ContextMenuContent>
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>Search</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent>
|
||||
<ContextMenuItem onClick={handleCheckEmail}>Associated socials</ContextMenuItem>
|
||||
<ContextMenuItem onClick={handleSearch}>HIBPwd</ContextMenuItem>
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
<ContextMenuItem>
|
||||
Copy content
|
||||
<span className="ml-auto text-xs text-muted-foreground">⌘ C</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem>
|
||||
Duplicate
|
||||
<span className="ml-auto text-xs text-muted-foreground">⌘ D</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={handleDelete} className="text-red-600">
|
||||
Delete
|
||||
<span className="ml-auto text-xs text-muted-foreground">⌘ ⌫</span>
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
),
|
||||
[handleCheckEmail, handleSearch, handleDelete],
|
||||
)
|
||||
import React, { memo } from 'react';
|
||||
import { Handle, Position } from '@xyflow/react';
|
||||
import { CopyButton } from '@/components/copy';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { AtSignIcon } from 'lucide-react';
|
||||
|
||||
export default memo(({ data }: any) => {
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>
|
||||
<div className={cn(loading ? "opacity-40" : "opacity-100")}>
|
||||
{nodeContent}
|
||||
{handle}
|
||||
<>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
className={cn("w-16 bg-teal-500 opacity-0")}
|
||||
/>
|
||||
|
||||
<Card
|
||||
className={cn(
|
||||
"border hover:border-primary rounded-full p-0 shadow-none backdrop-blur bg-background/40",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 p-1">
|
||||
<Badge variant="secondary" className="h-6 w-6 p-0 rounded-full">
|
||||
<AtSignIcon className="h-4 w-4" />
|
||||
</Badge>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm">{data.label}</span>
|
||||
<CopyButton className="rounded-full h-7 w-7 text-xs" content={data.label} />
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
{contextMenu}
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
// Composant enveloppé avec une fonction de comparaison personnalisée
|
||||
const MemoizedNode = memo(
|
||||
(props: any) => (
|
||||
<NodeProvider>
|
||||
<EmailNode {...props} />
|
||||
</NodeProvider>
|
||||
),
|
||||
(prevProps, nextProps) => {
|
||||
// Ne re-rendre que si les données importantes ont changé
|
||||
return (
|
||||
prevProps.id === nextProps.id &&
|
||||
prevProps.data.label === nextProps.data.label &&
|
||||
prevProps.data.email === nextProps.data.email &&
|
||||
prevProps.selected === nextProps.selected
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
export default MemoizedNode
|
||||
|
||||
</Card>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
className={cn("w-16 bg-teal-500 opacity-0")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -1,141 +1,41 @@
|
||||
"use client"
|
||||
|
||||
import { memo, useCallback, useMemo } from "react"
|
||||
import { Handle, Position, useStore } from "@xyflow/react"
|
||||
import { NodeProvider, useNodeContext } from "@/components/contexts/node-context"
|
||||
import { LocateIcon, Zap } from "lucide-react"
|
||||
import { cn, zoomSelector } from "@/lib/utils"
|
||||
import { useInvestigationStore } from "@/store/investigation-store"
|
||||
import { CopyButton } from "@/components/copy"
|
||||
import { useSearchContext } from "@/components/contexts/search-context"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu"
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { useFlowStore } from "@/store/flow-store"
|
||||
|
||||
function IpNode({ data }: any) {
|
||||
const { handleDeleteNode, loading } = useNodeContext()
|
||||
const { settings } = useInvestigationStore()
|
||||
const { currentNode } = useFlowStore()
|
||||
const { handleOpenSearchModal } = useSearchContext()
|
||||
// Optimisation avec fonction d'égalité pour éviter les re-rendus inutiles
|
||||
const showContent = useStore(zoomSelector, (a, b) => a === b)
|
||||
|
||||
// Callbacks mémorisés
|
||||
const handleDelete = useCallback(() => handleDeleteNode(data.type), [data.type, handleDeleteNode])
|
||||
const handleSearch = useCallback(() => handleOpenSearchModal(data.label), [data.label, handleOpenSearchModal])
|
||||
|
||||
// Mémorisation du contenu du nœud
|
||||
const nodeContent = useMemo(() => {
|
||||
if (settings.showNodeLabel && showContent) {
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"border hover:border-primary rounded-full p-0 shadow-none backdrop-blur bg-background/40",
|
||||
currentNode === data.id && "border-primary",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 p-1">
|
||||
<Badge variant="secondary" className="h-6 w-6 p-0 rounded-full">
|
||||
<LocateIcon className="h-4 w-4" />
|
||||
</Badge>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm">{data.label}</span>
|
||||
{settings.showCopyIcon && <CopyButton className="rounded-full h-7 w-7 text-xs" content={data.label} />}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" className="rounded-full p-0">
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarFallback>
|
||||
<LocateIcon className="h-3 w-3" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{data.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}, [settings.showNodeLabel, showContent, currentNode, data.id, data.label, settings.showCopyIcon])
|
||||
|
||||
// Mémorisation de la poignée
|
||||
const handle = useMemo(
|
||||
() => <Handle type="target" position={Position.Top} className={cn("w-16 bg-teal-500 opacity-0")} />,
|
||||
[],
|
||||
)
|
||||
|
||||
// Mémorisation du menu contextuel
|
||||
const contextMenu = useMemo(
|
||||
() => (
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={handleSearch}>
|
||||
Launch search
|
||||
<Zap className="ml-2 h-4 w-4 text-orange-500" />
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem>
|
||||
Copy content
|
||||
<span className="ml-auto text-xs text-muted-foreground">⌘ C</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem>
|
||||
Duplicate
|
||||
<span className="ml-auto text-xs text-muted-foreground">⌘ D</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={handleDelete} className="text-red-600">
|
||||
Delete
|
||||
<span className="ml-auto text-xs text-muted-foreground">⌘ ⌫</span>
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
),
|
||||
[handleSearch, handleDelete],
|
||||
)
|
||||
import React, { memo } from 'react';
|
||||
import { Handle, Position } from '@xyflow/react';
|
||||
import { CopyButton } from '@/components/copy';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { AtSignIcon, LocateIcon } from 'lucide-react';
|
||||
|
||||
export default memo(({ data }: any) => {
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>
|
||||
<div className={cn(loading ? "opacity-40" : "opacity-100")}>
|
||||
{nodeContent}
|
||||
{handle}
|
||||
<>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
className={cn("w-16 bg-teal-500 opacity-0")}
|
||||
/>
|
||||
|
||||
<Card
|
||||
className={cn(
|
||||
"border hover:border-primary rounded-full p-0 shadow-none backdrop-blur bg-background/40",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 p-1">
|
||||
<Badge variant="secondary" className="h-6 w-6 p-0 rounded-full">
|
||||
<LocateIcon className="h-4 w-4" />
|
||||
</Badge>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm">{data.label}</span>
|
||||
<CopyButton className="rounded-full h-7 w-7 text-xs" content={data.label} />
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
{contextMenu}
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
// Composant enveloppé avec une fonction de comparaison personnalisée
|
||||
const MemoizedNode = memo(
|
||||
(props: any) => (
|
||||
<NodeProvider>
|
||||
<IpNode {...props} />
|
||||
</NodeProvider>
|
||||
),
|
||||
(prevProps, nextProps) => {
|
||||
// Ne re-rendre que si les données importantes ont changé
|
||||
return (
|
||||
prevProps.id === nextProps.id &&
|
||||
prevProps.data.label === nextProps.data.label &&
|
||||
prevProps.selected === nextProps.selected
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
export default MemoizedNode
|
||||
|
||||
</Card>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
className={cn("w-16 bg-teal-500 opacity-0")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -1,156 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { memo, useMemo, useCallback } from "react"
|
||||
// @ts-ignore
|
||||
import { Handle, NodeToolbar, Position, useStore } from "@xyflow/react"
|
||||
import { useInvestigationStore } from "@/store/investigation-store"
|
||||
import { NodeProvider, useNodeContext } from "@/components/contexts/node-context"
|
||||
import { useSearchContext } from "@/components/contexts/search-context"
|
||||
import { cn, zoomSelector } from "@/lib/utils"
|
||||
import { CopyButton } from "@/components/copy"
|
||||
import { useChatContext } from "@/components/contexts/chatbot-context"
|
||||
import { AtSign, Camera, Facebook, GithubIcon, Instagram, Locate, MapPin, MessageCircleDashed, Send, SquarePenIcon } from "lucide-react"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu"
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { Edit, Phone, User, Zap } from "lucide-react"
|
||||
import { useFlowStore } from "@/store/flow-store"
|
||||
import { useQueryState } from "nuqs"
|
||||
|
||||
// Composant optimisé pour les performances
|
||||
function Custom(props: any) {
|
||||
const { settings } = useInvestigationStore()
|
||||
const { currentNode } = useFlowStore()
|
||||
const { setOpenAddNodeModal, handleDuplicateNode, setOpenNote, handleDeleteNode, loading } = useNodeContext()
|
||||
const { handleOpenSearchModal } = useSearchContext()
|
||||
const { handleOpenChat } = useChatContext()
|
||||
// Utilisation d'un sélecteur d'égalité pour éviter les re-rendus inutiles
|
||||
const showContent = useStore(zoomSelector, (a, b) => a === b)
|
||||
const [_, setIndividualId] = useQueryState("individual_id")
|
||||
const { data } = props
|
||||
|
||||
// Callbacks mémorisés pour éviter les recréations de fonctions
|
||||
const handleEditClick = useCallback(() => setIndividualId(data.id), [data.id, setIndividualId])
|
||||
const handleChatClick = useCallback(() => handleOpenChat(data), [data, handleOpenChat])
|
||||
const handleSearchClick = useCallback(
|
||||
() => handleOpenSearchModal(data.full_name),
|
||||
[data.full_name, handleOpenSearchModal],
|
||||
)
|
||||
const handleDuplicateClick = useCallback(() => handleDuplicateNode(), [handleDuplicateNode])
|
||||
const handleDeleteClick = useCallback(() => handleDeleteNode(data.type), [data.type, handleDeleteNode])
|
||||
const handleNoteClick = useCallback(() => setOpenNote(true), [setOpenNote])
|
||||
const handleDoubleClick = useCallback(() => setIndividualId(data.id), [data.id, setIndividualId])
|
||||
|
||||
// Mémorisation du contenu du nœud pour éviter les recalculs
|
||||
const nodeContent = useMemo(() => {
|
||||
if (settings.showNodeLabel && showContent) {
|
||||
return (
|
||||
<Card
|
||||
onDoubleClick={handleDoubleClick}
|
||||
className={cn(
|
||||
"p-1 border border-border hover:border-primary duration-100 rounded-lg shadow-none",
|
||||
currentNode === data.id && "border-primary",
|
||||
)}
|
||||
>
|
||||
<div className="flex gap-2 items-center rounded-full">
|
||||
<Avatar className="h-9 w-9">
|
||||
<AvatarImage src={data?.image_url} alt={data.full_name} />
|
||||
<AvatarFallback>{loading ? "..." : data.full_name[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
{settings.showNodeLabel && showContent && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm">{data.full_name}</span>
|
||||
{settings.showCopyIcon && <CopyButton className="rounded-full" content={data.full_name} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onDoubleClick={handleDoubleClick}
|
||||
className={cn(
|
||||
"rounded-full border border-transparent hover:border-primary",
|
||||
currentNode === data.id && "border-primary",
|
||||
)}
|
||||
>
|
||||
<Avatar className="h-12 w-12">
|
||||
<AvatarImage src={data?.image_url} alt={data.full_name} />
|
||||
<AvatarFallback>
|
||||
<User className="h-4 w-4" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{data.full_name}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}, [settings.showNodeLabel, showContent, data, loading, currentNode, handleDoubleClick, settings.showCopyIcon])
|
||||
|
||||
// Mémorisation des handles pour éviter les recalculs
|
||||
const handles = useMemo(
|
||||
() => (
|
||||
<>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
// className={cn("w-16 bg-teal-500 opacity-0", showContent ? "group-hover:opacity-100 opacity-0" : "opacity-0")}
|
||||
/>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
// className={cn("w-16 bg-teal-500 opacity-0", showContent ? "group-hover:opacity-100 opacity-0" : "opacity-0")}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
[showContent],
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ ...props.style, borderRadius: "100000px!important" }}>
|
||||
<div className={cn(loading ? "opacity-40" : "opacity-100", "overflow-hidden group")}>
|
||||
{nodeContent}
|
||||
{handles}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const IndividualNode = memo(
|
||||
(props: any) => (
|
||||
<NodeProvider>
|
||||
<Custom {...props} />
|
||||
</NodeProvider>
|
||||
),
|
||||
(prevProps, nextProps) => {
|
||||
// Comparaison personnalisée pour éviter les re-rendus inutiles
|
||||
// Ne re-rendre que si les données importantes ont changé
|
||||
return (
|
||||
prevProps.id === nextProps.id &&
|
||||
prevProps.data.full_name === nextProps.data.full_name &&
|
||||
prevProps.data.image_url === nextProps.data.image_url &&
|
||||
prevProps.selected === nextProps.selected
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
export default IndividualNode
|
||||
|
||||
40
flowsint-web/src/components/investigations/nodes/person.tsx
Normal file
40
flowsint-web/src/components/investigations/nodes/person.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React, { memo } from 'react';
|
||||
import { Handle, Position } from '@xyflow/react';
|
||||
import { CopyButton } from '@/components/copy';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
|
||||
export default memo(({ data }: any) => {
|
||||
return (
|
||||
<>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
className={cn("w-16 bg-teal-500 opacity-0")}
|
||||
/>
|
||||
|
||||
<Card
|
||||
className={cn(
|
||||
"p-1 border border-border hover:border-primary duration-100 rounded-lg shadow-none backdrop-blur bg-background/40",
|
||||
)}
|
||||
>
|
||||
<div className="flex gap-2 items-center rounded-full">
|
||||
<Avatar className="h-9 w-9">
|
||||
<AvatarImage src={data?.image_url} alt={data.full_name} />
|
||||
<AvatarFallback>{data.full_name[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm">{data.full_name}</span>
|
||||
<CopyButton className="rounded-full" content={data.full_name} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
className={cn("w-16 bg-teal-500 opacity-0")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -1,130 +1,41 @@
|
||||
"use client"
|
||||
|
||||
import { memo, useCallback, useMemo } from "react"
|
||||
import { Handle, Position, useStore } from "@xyflow/react"
|
||||
import { NodeProvider, useNodeContext } from "@/components/contexts/node-context"
|
||||
import { LocateIcon, Zap } from "lucide-react"
|
||||
import { cn, zoomSelector } from "@/lib/utils"
|
||||
import { useInvestigationStore } from "@/store/investigation-store"
|
||||
import { CopyButton } from "@/components/copy"
|
||||
import { useSearchContext } from "@/components/contexts/search-context"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu"
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { useFlowStore } from "@/store/flow-store"
|
||||
|
||||
function PhoneNode({ data }: any) {
|
||||
const { handleDeleteNode, loading } = useNodeContext()
|
||||
const { settings } = useInvestigationStore()
|
||||
const { currentNode } = useFlowStore()
|
||||
const { handleOpenSearchModal } = useSearchContext()
|
||||
const showContent = useStore(zoomSelector, (a, b) => a === b)
|
||||
const handleDelete = useCallback(() => handleDeleteNode(data.type), [data.type, handleDeleteNode])
|
||||
const handleSearch = useCallback(() => handleOpenSearchModal(data.label), [data.label, handleOpenSearchModal])
|
||||
|
||||
const nodeContent = useMemo(() => {
|
||||
if (settings.showNodeLabel && showContent) {
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"border hover:border-primary rounded-full p-0 shadow-none backdrop-blur bg-background/40",
|
||||
currentNode === data.id && "border-primary",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 p-1">
|
||||
<Badge variant="secondary" className="h-6 w-6 p-0 rounded-full">
|
||||
<LocateIcon className="h-4 w-4" />
|
||||
</Badge>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm">{data.label}</span>
|
||||
{settings.showCopyIcon && <CopyButton className="rounded-full h-7 w-7 text-xs" content={data.label} />}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" className="rounded-full p-0">
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarFallback>
|
||||
<LocateIcon className="h-3 w-3" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{data.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}, [settings.showNodeLabel, showContent, currentNode, data.id, data.label, settings.showCopyIcon])
|
||||
|
||||
const handle = useMemo(
|
||||
() => <Handle type="target" position={Position.Top} className={cn("w-16 bg-teal-500 opacity-0")} />,
|
||||
[],
|
||||
)
|
||||
const contextMenu = useMemo(
|
||||
() => (
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={handleSearch}>
|
||||
Launch search
|
||||
<Zap className="ml-2 h-4 w-4 text-orange-500" />
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem>
|
||||
Copy content
|
||||
<span className="ml-auto text-xs text-muted-foreground">⌘ C</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem>
|
||||
Duplicate
|
||||
<span className="ml-auto text-xs text-muted-foreground">⌘ D</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={handleDelete} className="text-red-600">
|
||||
Delete
|
||||
<span className="ml-auto text-xs text-muted-foreground">⌘ ⌫</span>
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
),
|
||||
[handleSearch, handleDelete],
|
||||
)
|
||||
import React, { memo } from 'react';
|
||||
import { Handle, Position } from '@xyflow/react';
|
||||
import { CopyButton } from '@/components/copy';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { AtSignIcon, PhoneIcon } from 'lucide-react';
|
||||
|
||||
export default memo(({ data }: any) => {
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>
|
||||
<div className={cn(loading ? "opacity-40" : "opacity-100")}>
|
||||
{nodeContent}
|
||||
{handle}
|
||||
<>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
className={cn("w-16 bg-teal-500 opacity-0")}
|
||||
/>
|
||||
|
||||
<Card
|
||||
className={cn(
|
||||
"border hover:border-primary rounded-full p-0 shadow-none backdrop-blur bg-background/40",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 p-1">
|
||||
<Badge variant="secondary" className="h-6 w-6 p-0 rounded-full">
|
||||
<PhoneIcon className="h-4 w-4" />
|
||||
</Badge>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm">{data.label}</span>
|
||||
<CopyButton className="rounded-full h-7 w-7 text-xs" content={data.label} />
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
{contextMenu}
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
const MemoizedNode = memo(
|
||||
(props: any) => (
|
||||
<NodeProvider>
|
||||
<PhoneNode {...props} />
|
||||
</NodeProvider>
|
||||
),
|
||||
(prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.id === nextProps.id &&
|
||||
prevProps.data.label === nextProps.data.label &&
|
||||
prevProps.selected === nextProps.selected
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
export default MemoizedNode
|
||||
|
||||
</Card>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
className={cn("w-16 bg-teal-500 opacity-0")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -1,42 +1,24 @@
|
||||
"use client"
|
||||
import React, { memo } from 'react';
|
||||
import { Handle, Position } from '@xyflow/react';
|
||||
import { CopyButton } from '@/components/copy';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { AtSignIcon, LocateIcon } from 'lucide-react';
|
||||
|
||||
import { memo, useCallback, useMemo } from "react"
|
||||
import { Handle, Position, useStore } from "@xyflow/react"
|
||||
import { NodeProvider, useNodeContext } from "@/components/contexts/node-context"
|
||||
import { LocateIcon, Zap } from "lucide-react"
|
||||
import { cn, zoomSelector } from "@/lib/utils"
|
||||
import { useInvestigationStore } from "@/store/investigation-store"
|
||||
import { CopyButton } from "@/components/copy"
|
||||
import { useSearchContext } from "@/components/contexts/search-context"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu"
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { useFlowStore } from "@/store/flow-store"
|
||||
export default memo(({ data }: any) => {
|
||||
return (
|
||||
<>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
className={cn("w-16 bg-teal-500 opacity-0")}
|
||||
/>
|
||||
|
||||
function AddressNode({ data }: any) {
|
||||
const { handleDeleteNode, loading } = useNodeContext()
|
||||
const { settings } = useInvestigationStore()
|
||||
const { currentNode } = useFlowStore()
|
||||
const { handleOpenSearchModal } = useSearchContext()
|
||||
const showContent = useStore(zoomSelector, (a, b) => a === b)
|
||||
const handleDelete = useCallback(() => handleDeleteNode(data.type), [data.type, handleDeleteNode])
|
||||
const handleSearch = useCallback(() => handleOpenSearchModal(data.label), [data.label, handleOpenSearchModal])
|
||||
|
||||
const nodeContent = useMemo(() => {
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"border hover:border-primary rounded-full p-0 shadow-none backdrop-blur bg-background/40",
|
||||
currentNode === data.id && "border-primary",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 p-1">
|
||||
@@ -45,68 +27,15 @@ function AddressNode({ data }: any) {
|
||||
</Badge>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm">{data.label}</span>
|
||||
{settings.showCopyIcon && <CopyButton className="rounded-full h-7 w-7 text-xs" content={data.label} />}
|
||||
<CopyButton className="rounded-full h-7 w-7 text-xs" content={data.label} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}, [currentNode, data.id, data.label, settings.showCopyIcon])
|
||||
|
||||
const handle = useMemo(
|
||||
() => <Handle type="target" position={Position.Top} className={cn("w-16 bg-teal-500 opacity-0")} />,
|
||||
[],
|
||||
)
|
||||
const contextMenu = useMemo(
|
||||
() => (
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={handleSearch}>
|
||||
Launch search
|
||||
<Zap className="ml-2 h-4 w-4 text-orange-500" />
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem>
|
||||
Copy content
|
||||
<span className="ml-auto text-xs text-muted-foreground">⌘ C</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem>
|
||||
Duplicate
|
||||
<span className="ml-auto text-xs text-muted-foreground">⌘ D</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={handleDelete} className="text-red-600">
|
||||
Delete
|
||||
<span className="ml-auto text-xs text-muted-foreground">⌘ ⌫</span>
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
),
|
||||
[handleSearch, handleDelete],
|
||||
)
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>
|
||||
<div className={cn(loading ? "opacity-40" : "opacity-100")}>
|
||||
{nodeContent}
|
||||
{handle}
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
{contextMenu}
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
const MemoizedNode = memo(
|
||||
(props: any) => (
|
||||
<NodeProvider>
|
||||
<AddressNode {...props} />
|
||||
</NodeProvider>
|
||||
),
|
||||
(prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.id === nextProps.id &&
|
||||
prevProps.data.label === nextProps.data.label &&
|
||||
prevProps.selected === nextProps.selected
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
export default MemoizedNode
|
||||
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
className={cn("w-16 bg-teal-500 opacity-0")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -1,163 +1,45 @@
|
||||
"use client"
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import { Handle, Position } from '@xyflow/react';
|
||||
import { CopyButton } from '@/components/copy';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { AtSignIcon } from 'lucide-react';
|
||||
import { usePlatformIcons } from '@/lib/hooks/use-platform-icons';
|
||||
|
||||
import { memo, useCallback, useMemo } from "react"
|
||||
import { Handle, Position, useStore } from "@xyflow/react"
|
||||
import { NodeProvider, useNodeContext } from "@/components/contexts/node-context"
|
||||
import { MapPinIcon, Zap } from "lucide-react"
|
||||
import { cn, zoomSelector } from "@/lib/utils"
|
||||
import { useInvestigationStore } from "@/store/investigation-store"
|
||||
import { CopyButton } from "@/components/copy"
|
||||
import { useSearchContext } from "@/components/contexts/search-context"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu"
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { usePlatformIcons } from "@/lib/hooks/use-platform-icons"
|
||||
import { useFlowStore } from "@/store/flow-store"
|
||||
|
||||
function SocialNode({ data }: any) {
|
||||
const { handleDeleteNode, loading } = useNodeContext()
|
||||
const { settings } = useInvestigationStore()
|
||||
const { currentNode } = useFlowStore()
|
||||
export default memo(({ data }: any) => {
|
||||
const platformsIcons = usePlatformIcons()
|
||||
const { handleOpenSearchModal } = useSearchContext()
|
||||
// Optimisation avec fonction d'égalité pour éviter les re-rendus inutiles
|
||||
const showContent = useStore(zoomSelector, (a, b) => a === b)
|
||||
|
||||
// Callbacks mémorisés
|
||||
const handleDelete = useCallback(() => handleDeleteNode(data.type), [data.type, handleDeleteNode])
|
||||
const handleSearch = useCallback(() => handleOpenSearchModal(data.label), [data.label, handleOpenSearchModal])
|
||||
|
||||
// Mémorisation de l'icône de la plateforme
|
||||
const platformIcon = useMemo(() => {
|
||||
// @ts-ignore
|
||||
return platformsIcons?.[data?.platform]?.icon
|
||||
}, [platformsIcons, data?.platform])
|
||||
|
||||
// Mémorisation du contenu du nœud
|
||||
const nodeContent = useMemo(() => {
|
||||
if (settings.showNodeLabel && showContent) {
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"border hover:border-primary rounded-full p-0 shadow-none backdrop-blur bg-background/40",
|
||||
currentNode === data.id && "border-primary",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 p-1">
|
||||
<Badge variant="secondary" className="h-6 w-6 p-0 rounded-full">
|
||||
{platformIcon}
|
||||
</Badge>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm">{data.username || data.profile_url}</span>
|
||||
{settings.showCopyIcon && (
|
||||
<CopyButton className="rounded-full h-7 w-7 text-xs" content={data.username || data.profile_url} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" className="rounded-full p-0">
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarFallback>
|
||||
<MapPinIcon className="h-3 w-3" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{data.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}, [
|
||||
settings.showNodeLabel,
|
||||
showContent,
|
||||
currentNode,
|
||||
data.id,
|
||||
data.username,
|
||||
data.profile_url,
|
||||
data.label,
|
||||
settings.showCopyIcon,
|
||||
platformIcon,
|
||||
])
|
||||
|
||||
// Mémorisation de la poignée
|
||||
const handle = useMemo(
|
||||
() => <Handle type="target" position={Position.Top} className={cn("w-16 bg-teal-500 opacity-0")} />,
|
||||
[],
|
||||
)
|
||||
|
||||
// Mémorisation du menu contextuel
|
||||
const contextMenu = useMemo(
|
||||
() => (
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={handleSearch}>
|
||||
Launch search
|
||||
<Zap className="ml-2 h-4 w-4 text-orange-500" />
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem>
|
||||
Copy content
|
||||
<span className="ml-auto text-xs text-muted-foreground">⌘ C</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem>
|
||||
Duplicate
|
||||
<span className="ml-auto text-xs text-muted-foreground">⌘ D</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={handleDelete} className="text-red-600">
|
||||
Delete
|
||||
<span className="ml-auto text-xs text-muted-foreground">⌘ ⌫</span>
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
),
|
||||
[handleSearch, handleDelete],
|
||||
)
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>
|
||||
<div className={cn(loading ? "opacity-40" : "opacity-100")}>
|
||||
{nodeContent}
|
||||
{handle}
|
||||
<>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
className={cn("w-16 bg-teal-500 opacity-0")}
|
||||
/>
|
||||
<Card
|
||||
className={cn(
|
||||
"border hover:border-primary rounded-full p-0 shadow-none backdrop-blur bg-background/40",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 p-1">
|
||||
<Badge variant="secondary" className="h-6 w-6 p-0 rounded-full">
|
||||
{platformIcon}
|
||||
</Badge>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm">{data.username || data.profile_url}</span>
|
||||
<CopyButton className="rounded-full h-7 w-7 text-xs" content={data.username || data.profile_url} />
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
{contextMenu}
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
// Composant enveloppé avec une fonction de comparaison personnalisée
|
||||
const MemoizedNode = memo(
|
||||
(props: any) => (
|
||||
<NodeProvider>
|
||||
<SocialNode {...props} />
|
||||
</NodeProvider>
|
||||
),
|
||||
(prevProps, nextProps) => {
|
||||
// Ne re-rendre que si les données importantes ont changé
|
||||
return (
|
||||
prevProps.id === nextProps.id &&
|
||||
prevProps.data.username === nextProps.data.username &&
|
||||
prevProps.data.profile_url === nextProps.data.profile_url &&
|
||||
prevProps.data.platform === nextProps.data.platform &&
|
||||
prevProps.selected === nextProps.selected
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
export default MemoizedNode
|
||||
|
||||
</Card>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
className={cn("w-16 bg-teal-500 opacity-0")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { getBezierPath, getStraightPath, useInternalNode } from '@xyflow/react';
|
||||
|
||||
import { getEdgeParams } from '@/lib/utils';
|
||||
|
||||
interface FloatingEdgeProps {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
markerEnd?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
function FloatingEdge({ id, source, target, markerEnd, style }: FloatingEdgeProps) {
|
||||
const sourceNode = useInternalNode(source);
|
||||
const targetNode = useInternalNode(target);
|
||||
|
||||
if (!sourceNode || !targetNode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { sx, sy, tx, ty } = getEdgeParams(
|
||||
sourceNode,
|
||||
targetNode,
|
||||
);
|
||||
|
||||
const [edgePath] = getStraightPath({
|
||||
sourceX: sx,
|
||||
sourceY: sy,
|
||||
targetX: tx,
|
||||
targetY: ty,
|
||||
});
|
||||
|
||||
return (
|
||||
<path
|
||||
id={id}
|
||||
className="react-flow__edge-path"
|
||||
d={edgePath}
|
||||
markerEnd={markerEnd}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default FloatingEdge;
|
||||
@@ -1,5 +1,7 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
//@ts-ignore
|
||||
import * as d3 from "d3-force"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
@@ -8,6 +10,7 @@ export function cn(...inputs: ClassValue[]) {
|
||||
export const zoomSelector = (s: { transform: number[]; }) => s.transform[2] >= 0.6;
|
||||
|
||||
import { Position, MarkerType } from '@xyflow/react';
|
||||
import { AppNode } from "@/store/flow-store";
|
||||
|
||||
// this helper function returns the intersection point
|
||||
// of the line between the center of the intersectionNode and the target node
|
||||
@@ -106,4 +109,100 @@ export function initialElements() {
|
||||
}
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
}
|
||||
|
||||
interface Node {
|
||||
id: string
|
||||
position?: { x: number; y: number }
|
||||
measured?: { width: number; height: number }
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
interface Edge {
|
||||
source: string
|
||||
target: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
interface LayoutOptions {
|
||||
direction?: string
|
||||
strength?: number
|
||||
distance?: number
|
||||
iterations?: number
|
||||
}
|
||||
|
||||
export const getLayoutedElements = (
|
||||
nodes: AppNode[],
|
||||
edges: Edge[],
|
||||
options: LayoutOptions = {
|
||||
direction: "LR",
|
||||
strength: -300,
|
||||
distance: 100,
|
||||
iterations: 300,
|
||||
},
|
||||
) => {
|
||||
// Create a map of node IDs to indices for the simulation
|
||||
const nodeMap = new Map(nodes.map((node, i) => [node.id, i]))
|
||||
|
||||
// Create a copy of nodes with positions for the simulation
|
||||
const nodesCopy = nodes.map((node) => ({
|
||||
...node,
|
||||
x: node.position?.x || Math.random() * 500,
|
||||
y: node.position?.y || Math.random() * 500,
|
||||
width: node.measured?.width || 0,
|
||||
height: node.measured?.height || 0,
|
||||
}))
|
||||
|
||||
// Create links for the simulation using indices
|
||||
const links = edges.map((edge) => ({
|
||||
source: nodeMap.get(edge.source),
|
||||
target: nodeMap.get(edge.target),
|
||||
original: edge,
|
||||
}))
|
||||
|
||||
// Create the simulation
|
||||
const simulation = d3
|
||||
.forceSimulation(nodesCopy)
|
||||
.force(
|
||||
"link",
|
||||
d3.forceLink(links).id((d: any) => nodeMap.get(d.id)),
|
||||
)
|
||||
.force("charge", d3.forceManyBody().strength(options.strength || -300))
|
||||
.force("center", d3.forceCenter(250, 250))
|
||||
.force(
|
||||
"collision",
|
||||
d3.forceCollide().radius((d: any) => Math.max(d.width, d.height) / 2 + 10),
|
||||
)
|
||||
|
||||
// If direction is horizontal, adjust forces
|
||||
if (options.direction === "LR") {
|
||||
simulation.force("x", d3.forceX(250).strength(0.1))
|
||||
simulation.force("y", d3.forceY(250).strength(0.05))
|
||||
} else {
|
||||
simulation.force("x", d3.forceX(250).strength(0.05))
|
||||
simulation.force("y", d3.forceY(250).strength(0.1))
|
||||
}
|
||||
|
||||
// Run the simulation synchronously
|
||||
simulation.stop()
|
||||
for (let i = 0; i < (options.iterations || 300); i++) {
|
||||
simulation.tick()
|
||||
}
|
||||
|
||||
// Update node positions based on simulation results
|
||||
const updatedNodes = nodesCopy.map((node) => ({
|
||||
...node,
|
||||
position: {
|
||||
x: node.x - node.width / 2,
|
||||
y: node.y - node.height / 2,
|
||||
},
|
||||
}))
|
||||
|
||||
return {
|
||||
nodes: updatedNodes,
|
||||
edges,
|
||||
}
|
||||
}
|
||||
|
||||
export default getLayoutedElements
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type OnNodesChange,
|
||||
type OnEdgesChange,
|
||||
} from '@xyflow/react';
|
||||
import getLayoutedElements from '@/lib/utils';
|
||||
|
||||
export type AppNode = Node;
|
||||
|
||||
@@ -29,29 +30,6 @@ export type AppState = {
|
||||
updateNode: (nodeId: string, nodeData: Partial<Node>) => void;
|
||||
resetNodeStyles: () => void;
|
||||
};
|
||||
const getLayoutedElements = (nodes: any[], edges: any[], options: { direction: any; }) => {
|
||||
const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
|
||||
g.setGraph({ rankdir: options.direction });
|
||||
|
||||
edges.forEach((edge) => g.setEdge(edge.source, edge.target));
|
||||
nodes.forEach((node) =>
|
||||
g.setNode(node.id, {
|
||||
...node,
|
||||
width: node.measured?.width ?? 0,
|
||||
height: node.measured?.height ?? 0,
|
||||
}),
|
||||
);
|
||||
Dagre.layout(g);
|
||||
return {
|
||||
nodes: nodes.map((node) => {
|
||||
const position = g.node(node.id);
|
||||
const x = position.x - (node.measured?.width ?? 0) / 2;
|
||||
const y = position.y - (node.measured?.height ?? 0) / 2;
|
||||
return { ...node, position: { x, y } };
|
||||
}),
|
||||
edges,
|
||||
};
|
||||
};
|
||||
const createStore = (initialNodes: AppNode[] = [], initialEdges: Edge[] = []) => {
|
||||
return create<AppState>((set, get) => ({
|
||||
nodes: initialNodes,
|
||||
@@ -149,6 +127,7 @@ const createStore = (initialNodes: AppNode[] = [], initialEdges: Edge[] = []) =>
|
||||
onLayout: (direction = 'TB', fitView: () => void) => {
|
||||
const { nodes, edges } = getLayoutedElements(get().nodes, get().edges, { direction });
|
||||
set({ nodes });
|
||||
//@ts-ignore
|
||||
set({ edges });
|
||||
fitView();
|
||||
},
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
--card-foreground: hsl(0 0% 3.9%);
|
||||
--popover: hsl(0 0% 100%);
|
||||
--popover-foreground: hsl(0 0% 3.9%);
|
||||
--primary: hsl(24.6 95% 53.1%);
|
||||
--primary: hsl(221.2 83.2% 53.3%);
|
||||
--primary-foreground: hsl(0 0% 98%);
|
||||
--secondary: hsl(0 0% 96.1%);
|
||||
--secondary-foreground: hsl(0 0% 9%);
|
||||
@@ -28,7 +28,7 @@
|
||||
--border: hsla(0, 0%, 90%, 0.807);
|
||||
--input: hsl(0 0% 89.8%);
|
||||
--ring: hsl(0 0% 3.9%);
|
||||
--chart-1: hsl(24.6 95% 53.1%);
|
||||
--chart-1: hsl(221.2 83.2% 53.3%);
|
||||
--chart-2: hsl(173 58% 39%);
|
||||
--chart-3: hsl(197 37% 24%);
|
||||
--chart-4: hsl(43 74% 66%);
|
||||
@@ -43,7 +43,7 @@
|
||||
--sidebar-accent: hsl(240 4.8% 95.9%);
|
||||
--sidebar-accent-foreground: hsl(240 5.9% 10%);
|
||||
--sidebar-border: hsl(220 13% 91%);
|
||||
--sidebar-ring: hsl(24.6 95% 53.1%);
|
||||
--sidebar-ring: hsl(221.2 83.2% 53.3%);
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -53,7 +53,7 @@
|
||||
--card-foreground: hsl(0 0% 98%);
|
||||
--popover: hsl(240 0% 9.9%);
|
||||
--popover-foreground: hsl(0 0% 98%);
|
||||
--primary: hsl(24.6 95% 53.1%);
|
||||
--primary: hsl(221.2 83.2% 53.3%);
|
||||
--primary-foreground: hsl(0, 0%, 100%);
|
||||
--secondary: hsl(0 0% 14.9%);
|
||||
--secondary-foreground: hsl(0 0% 98%);
|
||||
@@ -66,14 +66,14 @@
|
||||
--border: hsla(0, 0%, 23%, 0.67);
|
||||
--input: hsl(0 0% 14.9%);
|
||||
--ring: hsl(0 0% 83.1%);
|
||||
--chart-1: hsl(24.6 95% 53.1%);
|
||||
--chart-1: hsl(221.2 83.2% 53.3%);
|
||||
--chart-2: hsl(160 60% 45%);
|
||||
--chart-3: hsl(30 80% 55%);
|
||||
--chart-4: hsl(24.6 95% 53.1%);
|
||||
--chart-4: hsl(221.2 83.2% 53.3%);
|
||||
--chart-5: hsl(340 75% 55%);
|
||||
--sidebar: hsl(240 0% 9.9%);
|
||||
--sidebar-foreground: hsl(240 4.8% 95.9%);
|
||||
--sidebar-primary: hsl(24.6 95% 53.1%);
|
||||
--sidebar-primary: hsl(221.2 83.2% 53.3%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 100%);
|
||||
--sidebar-accent: hsl(240 3.7% 15.9%);
|
||||
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
|
||||
|
||||
Reference in New Issue
Block a user