mirror of
https://github.com/reconurge/flowsint.git
synced 2026-03-12 01:44:42 -05:00
feat: vehicles
This commit is contained in:
@@ -14,7 +14,7 @@ export async function GET(_: Request, { params }: { params: Promise<{ investigat
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
} const { data: individuals, error: indError } = await supabase
|
||||
.from("individuals")
|
||||
.select("*, ip_addresses(*), phone_numbers(*), social_accounts(*), emails(*), physical_addresses(*), group_id")
|
||||
.select("*, ip_addresses(*), phone_numbers(*), social_accounts(*), emails(*), physical_addresses(*), vehicles(*), group_id")
|
||||
.eq("investigation_id", investigation_id)
|
||||
const { data: groups, error: groupsError } = await supabase
|
||||
.from("groups")
|
||||
@@ -127,6 +127,22 @@ export async function GET(_: Request, { params }: { params: Promise<{ investigat
|
||||
label: "IP",
|
||||
})
|
||||
})
|
||||
// Ajouter les adresses IP
|
||||
ind.vehicles?.forEach((vehicle: any) => {
|
||||
nodes.push({
|
||||
id: vehicle.id.toString(),
|
||||
type: "vehicle",
|
||||
data: { label: `${vehicle.plate}-${vehicle.model}` },
|
||||
position: { x: -100, y: -100 },
|
||||
})
|
||||
edges.push({
|
||||
source: individualId,
|
||||
target: vehicle.id.toString(),
|
||||
type: "custom",
|
||||
id: `${individualId}-${vehicle.id}`.toString(),
|
||||
label: vehicle.type,
|
||||
})
|
||||
})
|
||||
// Ajouter les adresses physiques
|
||||
ind.physical_addresses?.forEach((address: any) => {
|
||||
nodes.push({
|
||||
|
||||
@@ -5,8 +5,7 @@ import { useParams } from "next/navigation"
|
||||
import { supabase } from "@/lib/supabase/client"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { AlertCircle, AtSign, Camera, Facebook, Github, GithubIcon, Instagram, Locate, MapPin, MessageCircleDashed, Phone, PlusIcon, Send, User } from "lucide-react"
|
||||
import type React from "react" // Added import for React
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -16,12 +15,14 @@ import {
|
||||
DropdownMenuSub,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuSubContent
|
||||
DropdownMenuSubContent,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { nodesTypes } from "@/lib/utils"
|
||||
import { Alert, AlertTitle, AlertDescription } from "../ui/alert"
|
||||
import { Badge } from "../ui/badge"
|
||||
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogTitle } from "../ui/dialog"
|
||||
import { actionItems, type ActionItem } from "@/lib/action-items"
|
||||
import { PlusIcon } from "lucide-react"
|
||||
|
||||
export default function NewActions({ addNodes }: { addNodes: any }) {
|
||||
const { investigation_id } = useParams()
|
||||
@@ -30,13 +31,13 @@ export default function NewActions({ addNodes }: { addNodes: any }) {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleOpenAddNodeModal = (e: { stopPropagation: () => void }, tableName: string, individualId?: string) => {
|
||||
const handleOpenAddNodeModal = (e: { stopPropagation: () => void }, key: string) => {
|
||||
e.stopPropagation()
|
||||
if (!nodesTypes[tableName as keyof typeof nodesTypes]) {
|
||||
if (!nodesTypes[key as keyof typeof nodesTypes]) {
|
||||
toast.error("Invalid node type.")
|
||||
return
|
||||
}
|
||||
setCurrentNodeType(nodesTypes[tableName as keyof typeof nodesTypes])
|
||||
setCurrentNodeType(nodesTypes[key as keyof typeof nodesTypes])
|
||||
setError(null)
|
||||
setOpenNodeModal(true)
|
||||
}
|
||||
@@ -49,6 +50,7 @@ export default function NewActions({ addNodes }: { addNodes: any }) {
|
||||
const data = Object.fromEntries(new FormData(e.currentTarget))
|
||||
await handleAddNode(data)
|
||||
}
|
||||
|
||||
const handleAddNode = async (data: any) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
@@ -58,8 +60,9 @@ export default function NewActions({ addNodes }: { addNodes: any }) {
|
||||
.insert(dataToInsert)
|
||||
.select("*")
|
||||
.single()
|
||||
console.log(insertError)
|
||||
if (insertError) {
|
||||
toast.error(insertError.details)
|
||||
toast.error("Failed to create node.")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
@@ -79,77 +82,63 @@ export default function NewActions({ addNodes }: { addNodes: any }) {
|
||||
setError(null)
|
||||
} catch (error) {
|
||||
toast.error("An unexpected error occurred")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Render a dropdown menu item
|
||||
const renderMenuItem = (item: ActionItem) => {
|
||||
const Icon = item.icon
|
||||
|
||||
if (item.children) {
|
||||
return (
|
||||
<DropdownMenuSub key={item.id}>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Icon className="mr-3 h-4 w-4 opacity-50" />
|
||||
{item.label}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent>
|
||||
{item.children.map((childItem) => renderMenuItem(childItem))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
disabled={item.disabled}
|
||||
onClick={(e) => handleOpenAddNodeModal(e, item.key)}
|
||||
>
|
||||
<Icon className="mr-2 h-4 w-4 opacity-70" />
|
||||
{item.label}
|
||||
{item.comingSoon && (
|
||||
<Badge variant="outline" className="ml-2">
|
||||
soon
|
||||
</Badge>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild><Button size={"icon"}><PlusIcon /></Button></DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem onClick={(e) => handleOpenAddNodeModal(e, "individuals")}>
|
||||
<User className="mr-2 h-4 w-4 opacity-70" /> New relation
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={(e) => handleOpenAddNodeModal(e, "phone_numbers")}>
|
||||
<Phone className="mr-2 h-4 w-4 opacity-70" />
|
||||
Phone number
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={(e) => handleOpenAddNodeModal(e, "physical_addresses")}>
|
||||
<MapPin className="mr-2 h-4 w-4 opacity-70" />
|
||||
Physical address
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={(e) => handleOpenAddNodeModal(e, "emails")}>
|
||||
<AtSign className="mr-2 h-4 w-4 opacity-70" />
|
||||
Email address
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={(e) => handleOpenAddNodeModal(e, "ip_addresses")}>
|
||||
<Locate className="mr-2 h-4 w-4 opacity-70" />
|
||||
IP address
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>Social account</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem onClick={(e) => handleOpenAddNodeModal(e, "social_accounts_facebook")}>
|
||||
<Facebook className="mr-2 h-4 w-4 opacity-70" />
|
||||
Facebook
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={(e) => handleOpenAddNodeModal(e, "social_accounts_instagram")}>
|
||||
<Instagram className="mr-2 h-4 w-4 opacity-70" />
|
||||
Instagram
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={(e) => handleOpenAddNodeModal(e, "social_accounts_telegram")}>
|
||||
<Send className="mr-2 h-4 w-4 opacity-70" />
|
||||
Telegram
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={(e) => handleOpenAddNodeModal(e, "social_accounts_signal")}>
|
||||
<MessageCircleDashed className="mr-2 h-4 w-4 opacity-70" />
|
||||
Signal
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={(e) => handleOpenAddNodeModal(e, "social_accounts_snapchat")}>
|
||||
<Camera className="mr-2 h-4 w-4 opacity-70" />
|
||||
Snapchat
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={(e) => handleOpenAddNodeModal(e, "social_accounts_github")}>
|
||||
<Github className="mr-2 h-4 w-4 opacity-70" />
|
||||
Github
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled onClick={(e) => handleOpenAddNodeModal(e, "social_accounts_coco")}>
|
||||
Coco{" "}
|
||||
<Badge variant="outline" className="ml-2">
|
||||
soon
|
||||
</Badge>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu >
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size={"icon"}>
|
||||
<PlusIcon />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-48" align="start">{actionItems.map((item) => renderMenuItem(item))}</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Dialog open={openAddNodeModal && currentNodeType} onOpenChange={setOpenNodeModal}>
|
||||
<DialogContent>
|
||||
<DialogTitle>New {currentNodeType?.type}</DialogTitle>
|
||||
<DialogDescription>Add a new related {currentNodeType?.type}.</DialogDescription>
|
||||
<form onSubmit={onSubmitNewNodeModal}>
|
||||
<div className="flex flex-col ga-3">
|
||||
<div className="flex flex-col gap-3">
|
||||
{currentNodeType?.fields.map((field: any, i: number) => {
|
||||
const [key, value] = field.split(":")
|
||||
return (
|
||||
@@ -182,6 +171,5 @@ export default function NewActions({ addNodes }: { addNodes: any }) {
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -47,11 +47,8 @@ import AddNodeModal from "../add-node-modal"
|
||||
import { Dialog, DialogTrigger } from "../../ui/dialog"
|
||||
import { memo } from "react"
|
||||
import { shallow } from "zustand/shallow"
|
||||
import FloatingEdge from "./simple-floating-edge"
|
||||
import { TooltipProvider } from "@/components/ui/tooltip"
|
||||
import NodeContextMenu from "./nodes/node-context-menu"
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import ProfilePanel from "./profile-panel"
|
||||
import GroupNode from "./nodes/group"
|
||||
import CustomEdge from "./nodes/custom-edge"
|
||||
const edgeTypes = {
|
||||
|
||||
@@ -72,13 +72,13 @@ const EditableEdge = memo((props: EdgeProps) => {
|
||||
value={editValue}
|
||||
onChange={handleInputChange}
|
||||
onBlur={handleInputBlur}
|
||||
className="h-7 w-24"
|
||||
className="h-5 text-xs rounded-sm"
|
||||
/>
|
||||
<Button
|
||||
ref={submitButtonRef}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
className="h-5 w-5"
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
>
|
||||
|
||||
@@ -4,26 +4,12 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
AtSign,
|
||||
Camera,
|
||||
Facebook,
|
||||
GithubIcon,
|
||||
Instagram,
|
||||
Locate,
|
||||
MapPin,
|
||||
MessageCircleDashed,
|
||||
Phone,
|
||||
Send,
|
||||
SquarePenIcon,
|
||||
User,
|
||||
} from "lucide-react"
|
||||
import { SquarePenIcon } from "lucide-react"
|
||||
import { useQueryState } from "nuqs"
|
||||
import { memo, useCallback, useState } from "react"
|
||||
import { useParams } from "next/navigation"
|
||||
@@ -40,7 +26,7 @@ import { AlertCircle } from "lucide-react"
|
||||
import { NodeNotesEditor } from "./node-notes-editor"
|
||||
import { checkEmail } from "@/lib/actions/search"
|
||||
import { nodesTypes } from "@/lib/utils"
|
||||
// Node types definition
|
||||
import { actionItems, type ActionItem } from "@/lib/action-items"
|
||||
|
||||
// Node Context Menu component
|
||||
interface NodeContextMenuProps {
|
||||
@@ -49,293 +35,285 @@ interface NodeContextMenuProps {
|
||||
onClose: () => void | null | undefined
|
||||
}
|
||||
|
||||
const NodeContextMenu = memo(
|
||||
({ x, y, onClose }: NodeContextMenuProps) => {
|
||||
const { currentNode, setCurrentNode } = useFlowStore()
|
||||
const { addNodes, addEdges, setNodes, setEdges } = useReactFlow()
|
||||
const { investigation_id } = useParams()
|
||||
const [openAddNodeModal, setOpenNodeModal] = useState(false)
|
||||
const [error, setError] = useState<null | string>(null)
|
||||
const [openNote, setOpenNote] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [currentNodeType, setCurrentNodeType] = useState<any | null>(null)
|
||||
const { confirm } = useConfirm()
|
||||
const [_, setIndividualId] = useQueryState("individual_id")
|
||||
const NodeContextMenu = memo(({ x, y, onClose }: NodeContextMenuProps) => {
|
||||
const { currentNode, setCurrentNode } = useFlowStore()
|
||||
const { addNodes, addEdges, setNodes, setEdges } = useReactFlow()
|
||||
const { investigation_id } = useParams()
|
||||
const [openAddNodeModal, setOpenNodeModal] = useState(false)
|
||||
const [error, setError] = useState<null | string>(null)
|
||||
const [openNote, setOpenNote] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [currentNodeType, setCurrentNodeType] = useState<any | null>(null)
|
||||
const { confirm } = useConfirm()
|
||||
const [_, setIndividualId] = useQueryState("individual_id")
|
||||
|
||||
const handleCheckEmail = useCallback(() => {
|
||||
// @ts-ignore
|
||||
if (!currentNode && currentNode?.data && !currentNode?.data?.email) return
|
||||
// @ts-ignore
|
||||
toast.promise(checkEmail(currentNode?.data?.email, investigation_id), {
|
||||
loading: "Loading...",
|
||||
success: () => {
|
||||
return `Scan on ${currentNode?.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>
|
||||
)
|
||||
},
|
||||
})
|
||||
}, [currentNode, investigation_id])
|
||||
const handleDuplicateNode = async () => {
|
||||
if (!currentNode) return
|
||||
await supabase
|
||||
.from("individuals")
|
||||
.select("*")
|
||||
.eq("id", currentNode.id)
|
||||
.single()
|
||||
.then(async ({ data, error }) => {
|
||||
if (error) throw error
|
||||
const { data: node, error: insertError } = await supabase
|
||||
.from("individuals")
|
||||
.insert({ full_name: data.full_name })
|
||||
.select("*")
|
||||
.single()
|
||||
if (insertError) toast.error(insertError.details)
|
||||
addNodes({
|
||||
id: node.id,
|
||||
type: "individual",
|
||||
data: node,
|
||||
position: { x: 0, y: -100 },
|
||||
})
|
||||
})
|
||||
}
|
||||
const _handleDeleteNode = async (type: string) => {
|
||||
if (!currentNode) return
|
||||
if (await confirm({ title: "Node deletion", message: "Are you sure you want to delete this node?" })) {
|
||||
await supabase
|
||||
const handleCheckEmail = useCallback(() => {
|
||||
// @ts-ignore
|
||||
if (!currentNode && currentNode?.data && !currentNode?.data?.email) return
|
||||
// @ts-ignore
|
||||
toast.promise(checkEmail(currentNode?.data?.email, investigation_id), {
|
||||
loading: "Loading...",
|
||||
success: () => {
|
||||
return `Scan on ${currentNode?.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>
|
||||
)
|
||||
},
|
||||
})
|
||||
}, [currentNode, investigation_id])
|
||||
|
||||
const handleDuplicateNode = async () => {
|
||||
if (!currentNode) return
|
||||
await supabase
|
||||
.from("individuals")
|
||||
.select("*")
|
||||
.eq("id", currentNode.id)
|
||||
.single()
|
||||
.then(async ({ data, error }) => {
|
||||
if (error) throw error
|
||||
const { data: node, error: insertError } = await supabase
|
||||
.from("individuals")
|
||||
.delete()
|
||||
.eq("id", currentNode.id)
|
||||
.then(({ error }) => {
|
||||
if (error) toast.error(error.details)
|
||||
})
|
||||
setNodes((nodes: any[]) => nodes.filter((node: { id: any }) => node.id !== currentNode?.id?.toString()))
|
||||
setEdges((edges: any[]) => edges.filter((edge: { source: any }) => edge.source !== currentNode?.id?.toString()))
|
||||
onClose()
|
||||
toast.success("Node deleted.")
|
||||
}
|
||||
}
|
||||
const handleDeleteNode = useCallback(_handleDeleteNode, [currentNode?.id, setNodes, setEdges, confirm, onClose])
|
||||
const setOpenAddNodeModal = (e: { stopPropagation: () => void }, tableName: string, individualId?: string) => {
|
||||
e.stopPropagation()
|
||||
if (!currentNode) return
|
||||
if (!nodesTypes[tableName as keyof typeof nodesTypes]) {
|
||||
toast.error("Invalid node type.")
|
||||
return
|
||||
}
|
||||
setCurrentNodeType(nodesTypes[tableName as keyof typeof nodesTypes])
|
||||
setError(null)
|
||||
setOpenNodeModal(true)
|
||||
}
|
||||
const onSubmitNewNodeModal = async (e: {
|
||||
preventDefault: () => void
|
||||
currentTarget: HTMLFormElement | undefined
|
||||
}) => {
|
||||
e.preventDefault()
|
||||
const data = Object.fromEntries(new FormData(e.currentTarget))
|
||||
await handleAddNode(data)
|
||||
}
|
||||
const handleAddNode = async (data: any) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
if (!currentNode) {
|
||||
toast.error("No node detected.")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
const dataToInsert = { ...data, investigation_id }
|
||||
if (currentNodeType.table !== "individuals") {
|
||||
dataToInsert["individual_id"] = currentNode.id
|
||||
}
|
||||
const { data: nodeData, error: insertError } = await supabase
|
||||
.from(currentNodeType.table)
|
||||
.insert(dataToInsert)
|
||||
.insert({ full_name: data.full_name })
|
||||
.select("*")
|
||||
.single()
|
||||
if (insertError) {
|
||||
toast.error(insertError.details)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
if (!nodeData) {
|
||||
toast.error("Failed to create node.")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
if (currentNodeType.table === "individuals") {
|
||||
const { error: relationshipError } = await supabase.from("relationships").upsert({
|
||||
individual_a: currentNode.id,
|
||||
individual_b: nodeData.id,
|
||||
relation_type: "relation",
|
||||
})
|
||||
if (relationshipError) {
|
||||
toast.error("Error creating new relation: " + relationshipError.message)
|
||||
}
|
||||
}
|
||||
const newNode = {
|
||||
id: nodeData.id,
|
||||
type: currentNodeType.type,
|
||||
data: { ...nodeData, label: data[currentNodeType.fields[0]] },
|
||||
position: { x: 0, y: 0 },
|
||||
}
|
||||
addNodes(newNode)
|
||||
if (currentNode.id) {
|
||||
const newEdge = {
|
||||
source: currentNode.id as string,
|
||||
target: nodeData.id,
|
||||
type: "custom",
|
||||
id: `${currentNode.id}-${nodeData.id}`.toString(),
|
||||
label: currentNodeType.type === "individual" ? "relation" : currentNodeType.type,
|
||||
}
|
||||
addEdges(newEdge)
|
||||
}
|
||||
setOpenNodeModal(false)
|
||||
setError(null)
|
||||
setTimeout(() => {
|
||||
setCurrentNode(nodeData)
|
||||
setLoading(false)
|
||||
}, 0)
|
||||
} catch (error) {
|
||||
toast.error("An unexpected error occurred")
|
||||
setLoading(false)
|
||||
}
|
||||
if (insertError) toast.error(insertError.details)
|
||||
addNodes({
|
||||
id: node.id,
|
||||
type: "individual",
|
||||
data: node,
|
||||
position: { x: 0, y: -100 },
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const _handleDeleteNode = async (type: string) => {
|
||||
if (!currentNode) return
|
||||
if (await confirm({ title: "Node deletion", message: "Are you sure you want to delete this node?" })) {
|
||||
await supabase
|
||||
.from("individuals")
|
||||
.delete()
|
||||
.eq("id", currentNode.id)
|
||||
.then(({ error }) => {
|
||||
if (error) toast.error(error.details)
|
||||
})
|
||||
setNodes((nodes: any[]) => nodes.filter((node: { id: any }) => node.id !== currentNode?.id?.toString()))
|
||||
setEdges((edges: any[]) => edges.filter((edge: { source: any }) => edge.source !== currentNode?.id?.toString()))
|
||||
onClose()
|
||||
toast.success("Node deleted.")
|
||||
}
|
||||
const handleEditClick = useCallback(() => setIndividualId(currentNode?.id as string), [currentNode?.id, setIndividualId])
|
||||
const handleDuplicateClick = useCallback(() => handleDuplicateNode(), [])
|
||||
const handleDeleteClick = useCallback(() => handleDeleteNode(currentNode?.type as string), [currentNode?.type, handleDeleteNode])
|
||||
const handleNoteClick = useCallback(() => setOpenNote(true), [setOpenNote])
|
||||
if (!currentNode) return null
|
||||
}
|
||||
|
||||
const handleDeleteNode = useCallback(_handleDeleteNode, [currentNode?.id, setNodes, setEdges, confirm, onClose])
|
||||
|
||||
const handleOpenAddNodeModal = (e: { stopPropagation: () => void }, key: string) => {
|
||||
e.stopPropagation()
|
||||
if (!currentNode) return
|
||||
if (!nodesTypes[key as keyof typeof nodesTypes]) {
|
||||
toast.error("Invalid node type.")
|
||||
return
|
||||
}
|
||||
setCurrentNodeType(nodesTypes[key as keyof typeof nodesTypes])
|
||||
setError(null)
|
||||
setOpenNodeModal(true)
|
||||
}
|
||||
|
||||
const onSubmitNewNodeModal = async (e: {
|
||||
preventDefault: () => void
|
||||
currentTarget: HTMLFormElement | undefined
|
||||
}) => {
|
||||
e.preventDefault()
|
||||
const data = Object.fromEntries(new FormData(e.currentTarget))
|
||||
await handleAddNode(data)
|
||||
}
|
||||
|
||||
const handleAddNode = async (data: any) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
if (!currentNode) {
|
||||
toast.error("No node detected.")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
const dataToInsert = { ...data, investigation_id }
|
||||
if (currentNodeType.table !== "individuals") {
|
||||
dataToInsert["individual_id"] = currentNode.id
|
||||
}
|
||||
const { data: nodeData, error: insertError } = await supabase
|
||||
.from(currentNodeType.table)
|
||||
.insert(dataToInsert)
|
||||
.select("*")
|
||||
.single()
|
||||
if (insertError) {
|
||||
toast.error("An error occured during the creation.")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
if (!nodeData) {
|
||||
toast.error("An error occured during the creation.")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
if (currentNodeType.table === "individuals") {
|
||||
const { error: relationshipError } = await supabase.from("relationships").upsert({
|
||||
individual_a: currentNode.id,
|
||||
individual_b: nodeData.id,
|
||||
relation_type: "relation",
|
||||
})
|
||||
if (relationshipError) {
|
||||
toast.error("Error creating new relation.")
|
||||
}
|
||||
}
|
||||
const newNode = {
|
||||
id: nodeData.id,
|
||||
type: currentNodeType.type,
|
||||
data: { ...nodeData, label: data[currentNodeType.fields[0]] },
|
||||
position: { x: 0, y: 0 },
|
||||
}
|
||||
addNodes(newNode)
|
||||
if (currentNode.id) {
|
||||
const newEdge = {
|
||||
source: currentNode.id as string,
|
||||
target: nodeData.id,
|
||||
type: "custom",
|
||||
id: `${currentNode.id}-${nodeData.id}`.toString(),
|
||||
label: currentNodeType.type === "individual" ? "relation" : currentNodeType.type,
|
||||
}
|
||||
addEdges(newEdge)
|
||||
}
|
||||
setOpenNodeModal(false)
|
||||
setError(null)
|
||||
setTimeout(() => {
|
||||
setCurrentNode(nodeData)
|
||||
setLoading(false)
|
||||
}, 0)
|
||||
} catch (error) {
|
||||
toast.error("An unexpected error occurred.")
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditClick = useCallback(
|
||||
() => setIndividualId(currentNode?.id as string),
|
||||
[currentNode?.id, setIndividualId],
|
||||
)
|
||||
const handleDuplicateClick = useCallback(() => handleDuplicateNode(), [])
|
||||
const handleDeleteClick = useCallback(
|
||||
() => handleDeleteNode(currentNode?.type as string),
|
||||
[currentNode?.type, handleDeleteNode],
|
||||
)
|
||||
const handleNoteClick = useCallback(() => setOpenNote(true), [setOpenNote])
|
||||
|
||||
// Render a dropdown menu item with the current node's ID
|
||||
const renderMenuItem = (item: ActionItem) => {
|
||||
const Icon = item.icon
|
||||
|
||||
if (item.children) {
|
||||
return (
|
||||
<DropdownMenuSub key={item.id}>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Icon className="mr-3 h-4 w-4 opacity-50" />
|
||||
{item.label}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>{item.children.map((childItem) => renderMenuItem(childItem))}</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu open={Boolean(currentNode) && Boolean(x) && Boolean(y)} onOpenChange={onClose}>
|
||||
<DropdownMenuContent
|
||||
className="absolute z-50 min-w-40 max-w-48 bg-popover text-popover-foreground rounded-md border shadow-md py-1 overflow-hidden"
|
||||
style={{ top: y, left: x }}
|
||||
>
|
||||
{Boolean(currentNode?.data?.email) &&
|
||||
(<DropdownMenuItem onClick={handleCheckEmail}>
|
||||
Search {currentNodeType}
|
||||
</DropdownMenuItem>)}
|
||||
<DropdownMenuItem onClick={handleNoteClick}>
|
||||
New note
|
||||
<SquarePenIcon className="!h-4 !w-4" />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>New</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem onClick={(e) => setOpenAddNodeModal(e, "individuals")}>
|
||||
<User className="mr-2 h-4 w-4 opacity-70" /> New relation
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={(e) => setOpenAddNodeModal(e, "phone_numbers", currentNode.id)}>
|
||||
<Phone className="mr-2 h-4 w-4 opacity-70" />
|
||||
Phone number
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={(e) => setOpenAddNodeModal(e, "physical_addresses", currentNode.id)}>
|
||||
<MapPin className="mr-2 h-4 w-4 opacity-70" />
|
||||
Physical address
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={(e) => setOpenAddNodeModal(e, "emails", currentNode.id)}>
|
||||
<AtSign className="mr-2 h-4 w-4 opacity-70" />
|
||||
Email address
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={(e) => setOpenAddNodeModal(e, "ip_addresses", currentNode.id)}>
|
||||
<Locate className="mr-2 h-4 w-4 opacity-70" />
|
||||
IP address
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>Social account</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem onClick={(e) => setOpenAddNodeModal(e, "social_accounts_facebook", currentNode.id)}>
|
||||
<Facebook className="mr-2 h-4 w-4 opacity-70" />
|
||||
Facebook
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={(e) => setOpenAddNodeModal(e, "social_accounts_instagram", currentNode.id)}>
|
||||
<Instagram className="mr-2 h-4 w-4 opacity-70" />
|
||||
Instagram
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={(e) => setOpenAddNodeModal(e, "social_accounts_telegram", currentNode.id)}>
|
||||
<Send className="mr-2 h-4 w-4 opacity-70" />
|
||||
Telegram
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={(e) => setOpenAddNodeModal(e, "social_accounts_signal", currentNode.id)}>
|
||||
<MessageCircleDashed className="mr-2 h-4 w-4 opacity-70" />
|
||||
Signal
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={(e) => setOpenAddNodeModal(e, "social_accounts_snapchat", currentNode.id)}>
|
||||
<Camera className="mr-2 h-4 w-4 opacity-70" />
|
||||
Snapchat
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={(e) => setOpenAddNodeModal(e, "social_accounts_github", currentNode.id)}>
|
||||
<GithubIcon className="mr-2 h-4 w-4 opacity-70" />
|
||||
Github
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled onClick={(e) => setOpenAddNodeModal(e, "social_accounts_coco", currentNode.id)}>
|
||||
Coco{" "}
|
||||
<Badge variant="outline" className="ml-2">
|
||||
soon
|
||||
</Badge>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuItem onClick={handleEditClick}>View and edit</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleDuplicateClick}>Duplicate</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleDeleteClick} className="text-red-600">
|
||||
Delete
|
||||
<span className="ml-auto text-xs text-muted-foreground">⌘ ⌫</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Dialog open={openAddNodeModal && currentNodeType} onOpenChange={setOpenNodeModal}>
|
||||
<DialogContent>
|
||||
<DialogTitle>New {currentNodeType?.type}</DialogTitle>
|
||||
<DialogDescription>Add a new related {currentNodeType?.type}.</DialogDescription>
|
||||
<form onSubmit={onSubmitNewNodeModal}>
|
||||
<div className="flex flex-col ga-3">
|
||||
{currentNodeType?.fields.map((field: any, i: number) => {
|
||||
const [key, value] = field.split(":")
|
||||
return (
|
||||
<label key={i}>
|
||||
<p className="my-2">{key}</p>
|
||||
<Input defaultValue={value || ""} name={key} placeholder={`Your value here (${key})`} />
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="flex items-center gap-2 justify-end mt-4">
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button disabled={loading} type="submit">
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{currentNode.id && <NodeNotesEditor openNote={openNote} setOpenNote={setOpenNote} individualId={currentNode.id} />}
|
||||
</>
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
disabled={item.disabled}
|
||||
onClick={(e) => handleOpenAddNodeModal(e, item.key)}
|
||||
>
|
||||
<Icon className="mr-3 h-4 w-4 opacity-70" />
|
||||
{item.label}
|
||||
{item.comingSoon && (
|
||||
<Badge variant="outline" className="ml-2">
|
||||
soon
|
||||
</Badge>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (!currentNode) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu open={Boolean(currentNode) && Boolean(x) && Boolean(y)} onOpenChange={onClose}>
|
||||
<DropdownMenuContent
|
||||
className="absolute z-50 min-w-40 max-w-48 bg-popover text-popover-foreground rounded-md border shadow-md py-1 overflow-hidden"
|
||||
style={{ top: y, left: x }}
|
||||
>
|
||||
{Boolean(currentNode?.data?.email) && (
|
||||
<DropdownMenuItem onClick={handleCheckEmail}>Search {currentNodeType}</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={handleNoteClick}>
|
||||
New note
|
||||
<SquarePenIcon className="ml-2 h-4 w-4" />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>New</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-48">{actionItems.map((item) => renderMenuItem(item))}</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuItem onClick={handleEditClick}>View and edit</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleDuplicateClick}>Duplicate</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleDeleteClick} className="text-red-600">
|
||||
Delete
|
||||
<span className="ml-auto text-xs text-muted-foreground">⌘ ⌫</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Dialog open={openAddNodeModal && currentNodeType} onOpenChange={setOpenNodeModal}>
|
||||
<DialogContent>
|
||||
<DialogTitle>New {currentNodeType?.type}</DialogTitle>
|
||||
<DialogDescription>Add a new related {currentNodeType?.type}.</DialogDescription>
|
||||
<form onSubmit={onSubmitNewNodeModal}>
|
||||
<div className="flex flex-col gap-3">
|
||||
{currentNodeType?.fields.map((field: any, i: number) => {
|
||||
const [key, value] = field.split(":")
|
||||
return (
|
||||
<label key={i}>
|
||||
<p className="my-2">{key}</p>
|
||||
<Input defaultValue={value || ""} name={key} placeholder={`Your value here (${key})`} />
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="flex items-center gap-2 justify-end mt-4">
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button disabled={loading} type="submit">
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{currentNode.id && (
|
||||
<NodeNotesEditor openNote={openNote} setOpenNote={setOpenNote} individualId={currentNode.id} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
export default NodeContextMenu
|
||||
|
||||
|
||||
@@ -176,7 +176,7 @@ const createStore = (initialNodes: AppNode[] = [], initialEdges: Edge[] = []) =>
|
||||
animated,
|
||||
style: {
|
||||
...edge.style,
|
||||
stroke: animated ? "var(--primary)" : "#b1b1b750",
|
||||
// stroke: animated ? "var(--primary)" : "#b1b1b750",
|
||||
opacity: animated ? 1 : 0.25,
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user